
简介面向数据挖掘课程实践与毕业设计的完整项目资源包以Python实现航空公司客户流失分析覆盖数据清洗、特征工程、可视化、决策树分类预测及模型调优全流程适合大三学生及初级数据分析人员模仿练习。压缩包共15个文件包含7个csv数据表原始数据及清洗、标准化后的多版本数据、4个py格式源码脚本、docx项目报告、pdf决策树可视化图及辅助文件整体约80.73MB目录划分清晰便于定位预处理、建模、画图等环节代码。目前已有777人学习下载属于经典客户流失预测场景。通过该资源可掌握pandas数据预处理、matplotlib/seaborn可视化、scikit-learn决策树建模、网格搜索调参及常用评估指标准确率、召回率、AUC等的完整实操方法同时参考报告撰写思路从数据洞察中提炼客户挽留建议将模型预测转化为业务行动全面提升解决真实问题的数据挖掘能力。1. 航空公司客户流失分析为什么首选决策树分类预测航空公司市场部最头疼的问题不是旺季没票卖而是会员悄悄流失连续几个月不乘机等再想挽回时他已经变成竞品的高频用户。数据挖掘里解决这类问题最常用的手段就是用 Python 对会员行为数据做分类预测而决策树是其中解释成本最低、最容易被业务接受的一种。决策树的准确率未必能超过 XGBoost但它能把“谁快流失了”变成一条条看得懂的业务规则入会时间短、近期无乘机记录、累计里程低。营销部门可以直接拿规则去圈人而不是面对一个黑盒打分。这套流程适合两类人一是刚接触数据挖掘、想把决策树从原理落到代码的工程师二是已经用 SPSS Modeler 这类可视化工具做过流失模型、想迁移到 Python 的技术人员。下面按一个完整数据挖掘项目的推进顺序把从数据表到分类预测报告的链路走一遍。核心就三件事把标签定准、把特征造好、把树剪到能交代。2. 先吃透数据字典航空客户的字段口径与流失标签定义2.1 流失标签不能简单拍成 1 和 0业务口径决定模型上限航空公司给的数据表通常是两张一张会员信息表一张乘机记录表。会员表里常见字段有会员卡号、入会日期、性别、年龄、会员卡级别、常驻地乘机记录表里一般有航班日期、航线、舱位等级、票价、累计里程。建模前要把两张表按会员 ID 汇总成“每个会员一行”的宽表后续决策树、交叉验证、报告展示都基于这张宽表。最常见做法是为每个会员生成这样一组字段最近一次乘机距统计日的天数Recency、过去一年乘机次数Frequency、累计里程、在册天数Tenure、平均乘机间隔。这些字段不是拍脑袋定的而是在回答“他还来不来、来得多不多、花得多不多”。表 2-1 是常用口径可直接对照手上的数据调整。特征名计算口径业务含义recency_days统计日减去最近乘机日多久没飞越大越危险frequency_last_12m统计日前 12 个月乘机次数活跃度total_mileage累计里程或累计积分消费能力tenure_days统计日减去入会日在册时长avg_interval_daystenure_days 除以乘机次数出行规律性流失标签怎么定绝大多数民航案例把“统计日截止前连续 N 个月没有乘机记录”视为流失N 取 3、6、12 的都有。要注意标签窗口和特征窗口必须错开特征只用观察期数据标签在表现期看。如果数据表本身就是某个时间点的快照也要先确认统计日是哪一天否则 Recency 会整体算错一个月。提示不要一上来就做模型。先把字段口径列清楚和业务方确认“流失”的定义再谈准确率。标签口径错了后面所有评估都是自欺欺人。2.2 用 pandas 给数据表做体检缺失、分布和取值范围拿到 CSV 或 Excel 导出的宽表第一件事不是建模而是看数据行不行。下面的脚本可以快速完成初步体检import pandas as pd df pd.read_csv(airline_customer.csv, encodinggbk) print(df.shape) print(df.columns.tolist()) print(df.isnull().mean().sort_values(ascendingFalse).head(10)) print(df.describe(includeall).T) print(df.groupby(churn_label).size())这段代码里read_csv的encoding参数要注意很多航司导出的表是 GBK 编码字段里带中文时间戳时UTF-8 直接读出乱码是常见问题。isnull().mean()按列计算缺失率并降序排列能一眼看出年龄、常驻地哪些字段缺失严重。describe(includeall)会同时输出数值字段的均值、最值和类别字段的取值种类用来排查 0 值、负值混入时间字段的情况。最后用groupby(churn_label).size()看正负样本比例如果流失样本只占 5%后面建模必须关注类别不平衡不能只看准确率。这个阶段最容易踩的坑是把 0 当月缺省值。比如有会员活跃天数填 0真实含义是“不足一天”而不是“没有活跃”两回事。先用value_counts()看这些字段的分布再决定怎么填。2.3 把日期字段变成天数特征树才有东西可切决策树做分裂的依据是“特征值小于某个阈值”所以日期不能直接丢给模型必须转换成数值型天数。常见的做法是定一个统计日用它减去各种日期字段import pandas as pd df[stat_date] pd.to_datetime(2019-12-31) df[last_flight_date] pd.to_datetime(df[last_flight_date], errorscoerce) df[recency_days] (df[stat_date] - df[last_flight_date]).dt.days df[join_date] pd.to_datetime(df[join_date], errorscoerce) df[tenure_days] (df[stat_date] - df[join_date]).dt.days df[avg_interval_days] df[tenure_days] / (df[frequency_last_12m] 1)pd.to_datetime后面的errorscoerce很关键遇到解析不了的日期会变成NaN而不是让整个脚本抛异常挂掉这在真实脏数据里几乎必踩。recency_days越大说明用户越久没飞是最直接的流失信号avg_interval_days是平均多少天飞一次用来刻画“规律型出差用户”和“一年飞一次的用户”之间的区别分母加 1 是防除零。日期类特征造完以后原始日期字段就可以从特征列表里删掉了留着反而会让树产生“某年某月之前”这种无法外推的切分。3. 数据预处理和特征构造决策树也要处理缺失值3.1 缺失值先填上sklearn 的决策树吃不了 NaN很多教程默认数据是干净的但真实会员表里年龄、常驻地、会员级别都可能为空。sklearn 的DecisionTreeClassifier在fit之前不接受NaN这一点和 XGBoost / LightGBM 不同那两家原生态支持缺失值分裂。所以预处理阶段必须把缺失值处理掉常见做法是数值字段用中位数填充类别字段用众数填充业务上有明确含义的比如常驻地为空直接归为“未知”类别。选中位数而不是均值是因为累计里程这类字段右偏严重少数常旅客会把均值拉得很高填进去反而扭曲分布。代码如下from sklearn.impute import SimpleImputer num_cols [age, total_mileage, recency_days, tenure_days] cat_cols [gender, member_level, region] num_imputer SimpleImputer(strategymedian) df[num_cols] num_imputer.fit_transform(df[num_cols]) cat_imputer SimpleImputer(strategymost_frequent) df[cat_cols] cat_imputer.fit_transform(df[cat_cols])这里最容易犯的错误是“测试集再单独 fit 一次”。fit_transform是先在训练集上学到中位数和众数再转换训练集后面处理测试集时只能用同一个 imputer 做transform不能再fit否则测试集信息就泄露到了训练过程里评估结果会虚高。3.2 类别特征编码One-Hot 还是 Ordinal 要看有无顺序决策树分裂时比较的是“小于某个阈值”性别、常驻地这类字符串没有大小关系不能直接参与比较。如果硬把它们编码成 0、1、2树会默认 2 大于 1切出来全是编号的边缘情况业务上也讲不通。所以原则是有序类别用OrdinalEncoder无序类别用OneHotEncoder。会员级别和舱位等级是有序的普通卡、银卡、金卡、白金卡存在明确等级递进关系性别和常驻地是无序的只能拆成多个 0/1 列from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder level_order [[普卡, 银卡, 金卡, 白金卡]] oe OrdinalEncoder(categorieslevel_order) df[member_level_ord] oe.fit_transform(df[[member_level]]) ohe OneHotEncoder(sparse_outputFalse, handle_unknownignore) region_encoded ohe.fit_transform(df[[region]]) region_df pd.DataFrame(region_encoded, columnsohe.get_feature_names_out()) df pd.concat([df, region_df], axis1)sparse_outputFalse让 One-Hot 结果返回常规数组而不是稀疏矩阵这个参数在 sklearn 1.2 之前叫sparse升级版本后运行旧代码会报参数名错误。handle_unknownignore是防止测试集里出现训练集没见过的城市名直接报错它会把这个取值全部编码为全 0 向量。One-Hot 的代价是列数变多决策树本身不受特征数量影响但会拖慢网格搜索所以常驻地区如果取值超过 20 个可以先按大区归并再编码。3.3 训练集与测试集切分先按时间再谈随机客户流失预测本质上是“用过去预测未来”。如果直接train_test_split随机切分模型会从测试集里偷看到未来数据上线后效果往往比验证时差一截。更稳妥的做法是把数据按统计日排序用前一段时间的样本训练用后一段时间的样本验证。但很多数据表本身就是单时间点快照所有会员的统计日是同一天没有时间维度的先后关系。这时只能退而求其次用随机切分但一定要做分层抽样from sklearn.model_selection import train_test_split feature_cols [ c for c in df.columns if c not in [churn_label, member_id, stat_date, last_flight_date, join_date, region, member_level] ] X df[feature_cols] y df[churn_label] X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.3, stratifyy, random_state42 )stratifyy会保证训练集和测试集里流失用户的比例一致否则流失样本本来就少随机切分后测试集里可能只有几个流失用户召回率算出来一点意义都没有。random_state42固定随机种子让源码里的结果可以按原样复现这也是写报告时能交代数据来源的前提。特征列表里手动排除掉member_id这类高基数 ID 字段和原始日期字段它们要么是唯一值要么会和构造出的天数特征严重重合。4. 决策树建模与调参从一棵默认树到能交代的模型4.1 先跑一棵默认决策树看看过拟合长什么样调参之前先跑一棵完全不设限的决策树目的不是用它做预测而是直观感受过拟合from sklearn.tree import DecisionTreeClassifier from sklearn.metrics import roc_auc_score dt DecisionTreeClassifier(random_state42) dt.fit(X_train, y_train) print(train acc:, dt.score(X_train, y_train)) print(test acc:, dt.score(X_test, y_test)) print(depth:, dt.get_depth(), leaves:, dt.get_n_leaves()) y_pred_prob dt.predict_proba(X_test)[:, 1] print(auc:, roc_auc_score(y_test, y_pred_prob))默认参数下max_depthNone树会一直生长到每个叶子都纯净训练集准确率经常接近 100%但测试集表现为 AUC 卡在 0.6 上下。get_depth()和get_n_leaves()会把树有多复杂直接打印出来深度超过 20、叶子数超过 500 都算默认树的正常表现。在客户流失这类信噪比不高的数据里默认树基本不可用必须从三个方向限制生长深度、内部节点最少样本数、叶子最少样本数。4.2 用 GridSearchCV 调 max_depth 与 min_samples_leafmin_samples_leaf通常是抑制过拟合最有效的参数它约束每个叶子至少覆盖 20 或 50 个样本避免树为了一个极端客群单独分出一个分支。max_depth限制树的层数上限min_samples_split控制内部节点继续分裂的门槛。三者一起搜比单独调一个更稳from sklearn.model_selection import GridSearchCV param_grid { max_depth: [4, 6, 8, 10], min_samples_leaf: [20, 50, 100], min_samples_split: [50, 100, 200], } dt DecisionTreeClassifier(random_state42) grid GridSearchCV( dt, param_grid, scoringroc_auc, cv5, n_jobs-1, ) grid.fit(X_train, y_train) print(grid.best_params_) print(grid.best_score_)参数作用常见取值说明max_depth限制树最大深度4~10每深一层叶子数翻倍越深越容易过拟合min_samples_leaf叶子节点最少样本数20~200最有效的过拟合抑制参数min_samples_split内部节点最少样本数50~200样本量小时不用单独调scoring用roc_auc而不是accuracy是因为流失样本占比通常低准确率会被多数类带偏。cv5在训练集内部再分 5 份轮换验证n_jobs-1用满 CPU 核心并行计算。搜索结束后不要只看best_score_要拿着best_params_在测试集上重新评估测试 AUC 明显低于交叉验证分数说明调参过拟合了验证流程本身。4.3 后剪枝用 ccp_alpha 找复杂度拐点网格搜索限制的是“生长上限”后剪枝则是先让树生长完整再按成本复杂度剪掉冗余分支。sklearn 里对应ccp_alpha它会在叶子纯度和树复杂度之间取平衡alpha 越大剪枝越狠from sklearn.tree import DecisionTreeClassifier from sklearn.metrics import roc_auc_score dt DecisionTreeClassifier(random_state42, ccp_alpha0.0) path dt.cost_complexity_pruning_path(X_train, y_train) alphas path.ccp_alphas results [] for alpha in alphas[::10]: d DecisionTreeClassifier(random_state42, ccp_alphaalpha) d.fit(X_train, y_train) train_auc roc_auc_score(y_train, d.predict_proba(X_train)[:, 1]) test_auc roc_auc_score(y_test, d.predict_proba(X_test)[:, 1]) results.append((alpha, train_auc, test_auc, d.get_depth())) for row in results[:5]: print(falpha{row[0]:.5f} train_auc{row[1]:.3f} test_auc{row[2]:.3f} depth{row[3]})cost_complexity_pruning_path返回一组候选ccp_alpha值按alphas[::10]间隔采样是避免几百个 alpha 全跑一遍。每个 alpha 都重新训练一棵树并在训练和测试上分别算 AUC训练 AUC 下降不多、测试 AUC 稳定甚至上升的阶段就是复杂度拐点。折线图可以清楚地看到测试 AUC 先升后降拐点附近的 alpha 就是最后要用的值。比起拍脑袋定max_depth这套流程在报告里解释起来更硬不是试出来的是从复杂度路径上找到的。5. 评估结果并把它翻译回业务动作5.1 流失场景先盯召回率再盯 AUC流失预测的错分成本不对等把正常用户误判为流失最多消耗一通回访电话把真正要流失的用户漏掉等于丢掉未来若干次飞行收入。所以评估要同时看召回率和 AUC而不是只看准确率from sklearn.metrics import classification_report, confusion_matrix y_pred grid.best_estimator_.predict(X_test) print(confusion_matrix(y_test, y_pred)) print(classification_report(y_test, y_pred, target_names[未流失, 流失]))confusion_matrix返回[[TN, FP], [FN, TP]]看第二列的召回率真实流失的人里抓到了多少。分类报告里流失类的 recall 是业务最关注的值precision 反而可以适当让步。5.2 用 export_text 把决策树结构翻译成业务规则决策树模型的可解释性最终要落到一条条规则上。export_text能把训练好的树输出成缩进格式的 if-else 结构直接变成业务方看得懂的话from sklearn.tree import export_text rules export_text( grid.best_estimator_, feature_namesfeature_cols, max_depth5, decimals2, ) print(rules)输出格式类似“如果 recency_days 大于 180 且 total_mileage 小于等于某个里程门槛则流失概率为 0.73”。这种规则可以直接抄进营销平台的圈人条件。配合feature_importances_按维度排序还能看出模型最依赖的信号是什么用来验证字段口径有没有造错。5.3 用 predict_proba 和成本口径算出挽留阈值决策树分类器不仅能 predict还能输出每个叶子内流失样本占比。把这个概率和业务成本结合起来才能回答“到底该给谁打电话”import numpy as np proba grid.best_estimator_.predict_proba(X_test)[:, 1] cost_per_call 5 profit_per_saved 800 thresholds np.arange(0.3, 0.8, 0.05) for t in thresholds: to_call proba t saved (to_call (y_test 1)).sum() cost to_call.sum() * cost_per_call benefit saved * profit_per_saved * 0.2 print(ft{t:.2f} calls{to_call.sum():4d} saved{saved:3d} net{benefit - cost:8.0f})上面假设一通电话成本 5 元、挽回一个客户价值 800 元、挽回成功率 20%只是演示口径。把这三个数字换成财务给的预算和目标群体规模净收益最高的阈值就是运营要执行的分数线高于这个分数进入挽留名单低于它暂时不打扰。最终报告里附上树的可视化图和这份阈值表数据挖掘项目才算闭环到业务动作上。本文还有配套的精品资源点击获取