决策树收入预测实战:基于UCI Adult数据集的分类建模与调参全流程
做了一个UCI的Adult收入预测项目用的是决策树。这个项目非常适合用来理解分类模型的完整流程而且数据集干净、特征丰富不需要自己去爬数据、做复杂清洗能让你把精力集中在建模本身。文章会从数据探索、特征处理、决策树原理、调参技巧到结果解读把整个实操过程完整复现一遍顺便把踩过的坑和建议一起放出来给正在练手的朋友做个参考。1. 项目整体设计与任务拆解1.1 为什么选Adult数据集和决策树的组合Adult数据集也叫“人口收入普查数据集”是UCI机器学习库里的经典二分类任务。它包含约三万两千条样本、十四条属性包括年龄、工作类型、教育程度、婚姻状况、职业、种族、性别、每周工作时长等目标是用这些信息预测一个人的年收入是否超过五万美元。我在拿到这个项目后第一个考虑的问题是选一个什么模型来打底。做收入预测这种表格型数据可选方案非常多逻辑回归、KNN、朴素贝叶斯、支持向量机都能做。但我最后还是先把决策树作为第一版模型理由很直接决策树的可解释性是所有这类模型里最好的而且它对特征量纲不敏感不需要做标准化处理这对一个包含大量类别特征的数据集来说能省掉很多预处理步骤。另一个原因是Adult数据集中特征的类型非常丰富有连续值、有类别值、有顺序值还有缺失值。决策树天然支持混合型特征的处理方式。虽然scikit-learn要求输入为数值但经过简单编码后树模型对这种混合特征的适应力远好于距离类模型。对初学阶段来讲用决策树做第一版baseline能最快打通从数据到模型再到评估的完整流程。这个项目适合谁如果你已经能熟练读取CSV文件了解pandas的基本操作但对分类模型的全流程还没有完整概念又不想花太多时间在数据工程细节上那用这个数据集做决策树项目是一个很好的切入点。它不需要GPU不需要深度学习框架一台普通笔记本电脑就能轻松跑完。1.2 核心任务与业务目标收入预测本质上是一个二分类任务标签就是收入是否大于50K。这里有个细节值得注意原始数据在数据处理上并不均衡年收入低于50K的样本占了大多数只有不到百分之二十五的人收入超过五万。如果我们只看准确率哪怕模型“无脑”预测所有人收入都低于50K也能拿到大概百分之七十五的准确率。这个数字看起来很高但完全没有实际价值。所以在项目一开始我就在心里定了一条基准线准确率必须和基线对比着看同时把精确率、召回率、F1值放在一起评估才能反映模型真实水平。从业务角度来理解这个任务的感受有点类似“银行贷款审批”或“用户分层”的场景。比如在营销场景里你更关心能不能把高收入用户全找出来这时候召回率更关键在风控场景里分错一个高收入用户会造成损失那精确率就很重要。Adult数据的应用虽然是教学性质但你从一开始就要学会带着业务视角去选择评估指标而不是默认选择accuracy。1.3 技术选型为什么用scikit-learn这个项目我用的是Python环境下的scikit-learn版本是1.x系列配合pandas做数据处理matplotlib和graphviz做可视化。没有额外引入XGBoost或LightGBM这类竞赛级工具原因很简单项目目标是理解决策树算法本身的机制而scikit-learn的DecisionTreeClassifier是教学最友好的实现参数清晰、文档完善、布线和绘图工具齐全。用sklearn还有一个好处它的API设计统一同一个fit/predict接口后面换随机森林、梯度提升树都一样方便后续扩展。如果你后续想对比其他模型不需要全部重写代码只要改一个类名就能跑出其他模型的结果。2. 收入预测的数据预处理全流程2.1 第一个拦路虎缺失值处理Adult数据集的原始文件是CSV格式但它有一个非常典型的特点缺失值并不是用空白或NaN表示而是用问号“?”填充的。如果不加处理pandas会把这一列读取成object类型后续建模直接被报错卡住。这里多说一句很多初学者在Kaggle上找现成的干净CSV练手没有碰过这种“脏数据”场景等到自己处理含问号的数据时第一反应往往是用fillna直接填一个默认值。这其实需要看情况。对于workclass、occupation、native-country这类类别变量我采用的是“单独分成一类”的策略而不是简单填充到众数。因为缺失本身可能也有业务含义比如“无工作经历”的人没有occupation把它单独标成一类反而让模型有机会学到这层含义。处理这个问题的代码很简单import pandas as pd df pd.read_csv(adult.data, headerNone) df.replace(?, pd.NA, inplaceTrue) df.isna().sum()缺失值比例如果很低比如低于百分之一直接删除也行。但Adult数据集的occupation字段缺失比例不高所以我选择了保留并标记为“Unknown”类。避免在项目初期就把一部分样本丢掉毕竟样本量本来就不算大。2.2 类别特征编码LabelEncoder还是OneHotEncoderAdult数据集中有多个类别型特征例如workclass、education、marital-status、occupation、relationship、race、sex、native-country。决策树模型本身可以处理名义变量和有序变量的混合但在sklearn实现中所有输入必须是数值型所以编码不可避免。这里有个非常重要的选择点。对于树模型直接在类别特征上做LabelEncoder其实是可以用但不严谨因为LabelEncoder会给类别加上人为的顺序感比如“High School”编码为1“Bachelors”编码为2这树模型可能误认为两者存在数值意义上的大小关系。好在决策树是基于阈值切分的它对这种顺序感的利用方式与线性模型不同影响相对小但依然不够严谨。我最终的方案是对无序类别特征用OneHotEncoder对教育程度这种本身有顺序含义的特征保留字符串然后在特征工程阶段手动映射为有意义的整数例如让“Preschool”对应0“Bachelors”对应10“Doctorate”对应14。顺便提一句原始数据集里已经有education-num字段它就是education的数值版本可以直接用无需额外操作。OneHot编码在sklearn中的写法是这样注意需要设置handle_unknown参数。from sklearn.preprocessing import OneHotEncoder encoder OneHotEncoder(handle_unknownignore, sparse_outputFalse) encoded encoder.fit_transform(df[[workclass, occupation]])这里还有一个易踩的坑fit_transform只应该在交叉验证的训练集上调用而不是在全量数据上调用否则验证集的信息会被泄漏进预处理器。后文在交叉验证的部分会再强调这一点。2.3 数值特征处理与切分数据集连续型特征age、fnlwgt、education-num、capital-gain、capital-loss、hours-per-week在决策树中不需要标准化这是树模型的好处之一。但我仍然建议先看看它们的分布尤其是capital-gain和capital-loss这两个特征有大量样本值为0分布严重偏斜。在树模型里这没问题切分点时依然能找到有效阈值但在后续换线性模型时偏斜分布会带来不小的麻烦所以提前掌握特征的分布形态是建模的好习惯。数据处理完成后需要将特征矩阵X和标签y切分为训练集和测试集。我的做法是保持原始顺序用train_test_split函数切分并设置random_state固定随机种子方便实验结果的可复现对冲或对照。from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy )特别说一下stratify这个参数。考虑到标签分布不平衡我使用了分层抽样让训练集和测试集中高收入样本的占比与原始数据集保持一致。这样做的好处是后续计算出的准确率、召回率和线上表现之间的差距更小不会因为某一次切分不均衡导致评估失真。3. 决策树建模与调参核心环节3.1 决策树到底在学什么先花点时间把决策树的核心机制讲透因为后续所有的调参行为都要围绕这个机制来理解。决策树做分类就是把特征空间逐步切分成若干矩形区域。每一步切分时算法都在问同一个问题“在当前这个区域里用哪个特征、以什么阈值划分能最大程度让左子节点和右子节点中的类别纯度比父节点更高。”这里面的“纯度”通常用基尼系数Gini或信息熵来衡量。决策树的生长过程非常符合人的直觉——先根据“是否为丈夫”这样的关系特征把人群一分为二再在“一周工作时长是否超过40小时”这个条件上进行细分。这种层层提问最后生成一棵树的过程就是决策树分类最直观的原理解释。基尼系数的计算公式是[ Gini 1 - \sum_{i1}^{C} p_i^2 ]其中C是类别数p_i是第i类样本在当前节点中的占比。对二分类问题基尼系数在两类样本各占一半时达到最大值0.5在节点完全纯净时取0。每次节点分裂时算法都会计算不同切分的加权基尼系数下降值选“下降最多”的那个切分作为最优分裂。这也是决策树和神经网络之间一个明显的差异点树的每一次判断都可以被追踪、被解释而神经网络更像黑盒。在收入预测这个场景中如果我们需要解释模型为什么把一个人划分到“低收入”类别决策树可以直接给出决策路径比如“年龄小于31婚姻状态为未婚教育年限小于12 → 收入≤50K”这种可视化的边缘路径在实际业务落地时非常有价值。3.2 默认参数先跑一版结果其实不差第一步建模我建议不要做任何调参直接用sklearn的默认参数跑一遍看看效果也体会一下“默认值”到底处于什么水平。from sklearn.tree import DecisionTreeClassifier clf DecisionTreeClassifier(random_state42) clf.fit(X_train, y_train) train_acc clf.score(X_train, y_train) test_acc clf.score(X_test, y_test)我跑出来的结果是训练集准确率接近百分之九十八测试集准确率约为百分之八十二。这个差距非常明显典型的过拟合症状。默认情况下决策树会一直生长直到所有叶子节点都纯为止导致模型把训练集里的噪声都记住了遇到新数据时泛化能力自然下降。但请注意哪怕过拟合测试集82%的准确率也已经明显超过了上文说的75%基线。这说明决策树在Adult数据集上确实能学到有价值的规律。接下来调参的核心目的就是把这个准确率往上再推同时缩小训练集和测试集准确率之间的差距。3.3 剪枝参数的几个关键旋钮决策树过拟合的根源是树太深、节点太多。在sklearn的DecisionTreeClassifier中有几个参数直接控制树的复杂度max_depth限制树的最大深度。Depth太大容易过拟合太小则容易欠拟合。min_samples_split一个节点至少需要多少个样本才允许继续分裂。min_samples_leaf叶子节点至少包含多少个样本。max_features每次分裂时最多考虑多少个特征。这几个参数的优先级不太一样。我个人的使用习惯是先把max_depth粗暴地限制一下比如设置5到8会发现结果立刻发生变化。然后从min_samples_leaf入手做微调因为它对控制叶子节点方差非常有效。min_samples_leaf设置得越大模型越保守叶子节点包含的样本数越多拟合出来的概率也更稳定。在Adult数据集上我发现把max_depth设到6左右、min_samples_leaf设为5时测试集准确率能稳定在84%到85%之间而训练集准确率降到了87%左右。这个差距明显健康多了说明模型不再是死记硬背而是开始总结规律。3.4 网格搜索与随机搜索查找最优参数手动调参虽然直观但效率较低也容易遗漏参数之间的组合效应。为了更系统地搜索我用了GridSearchCV。from sklearn.model_selection import GridSearchCV param_grid { max_depth: [4, 6, 8, 10, 12], min_samples_split: [2, 5, 10], min_samples_leaf: [1, 2, 5] } grid GridSearchCV( DecisionTreeClassifier(random_state42), param_grid, cv5, scoringroc_auc, n_jobs-1, verbose1 ) grid.fit(X_train, y_train) print(grid.best_params_) print(grid.best_score_)这里选择f1或roc_auc作为评分指标而不是直接用accuracy。原因刚才说过类别不平衡精度和auc会欺骗你。准确率只说明表面对错但roc_auc能反映模型分辨正负样本的能力。网格搜索的运行时间会很可观三次方级别的参数组合要跑很久。但决策树比较轻量在Adult这样三万样本的规模下完整跑一轮其实只要几十秒这在其他模型上不可想象这也是树模型适合做baseline调参的理由之一。两层嵌套的GridSearchCV会默认使用分层K折交叉验证最终得出的最佳参数组合可信度更高。如果不设置scoring参数sklearn默认用准确率这种不平衡场景下容易选出一个“什么都分不开”但准确率好看的模型。3.5 用学习曲线判断过拟合还是欠拟合想要深入理解模型处于什么状态学习曲线是比准确率更直观的工具。画出不同训练集占比下模型在训练集和测试集上的表现能帮你判断模型的表现瓶颈到底来自数据还是来自模型复杂度。import numpy as np from sklearn.model_selection import learning_curve train_sizes, train_scores, test_scores learning_curve( DecisionTreeClassifier(random_state42), X_train, y_train, cv5, train_sizesnp.linspace(0.1, 1.0, 10), scoringf1 )画出来的学习曲线中如果随着训练数据增加训练集曲线和交叉验证曲线在最后收敛位于同一个位置说明模型已经达到非饱和状态如果两者之间的差距始终很大就说明模型还在过拟合。曲线间隙小且两条曲线的横向长度基本持平就说明当前数据量可能不够需要找更多数据而不是单纯调参。在Adult数据集上如果不对深度做限制两条曲线的差距在数据量较小时很大数据量增加后训练分数和验证分数的差距会缩小但依然存在所以过拟合问题靠增加数据可以部分缓解但最终还是要配合剪枝才能真正压制。4. 结果解读与常见问题排查实录4.1 从训练好的树里读出决策路径模型训练好之后单独打印准确率远远不够。决策树项目有天然的优势可以把整棵树画出来用肉眼看它到底学了什么规则。最简单的画法是用matplotlib配合sklearn的plot_tree方法。但需要注意如果树的深度设置得过大、叶子节点过多画出来的图会大得难以阅读字全部挤在一起。我建议在展示最终结果前先单独训练一棵max_depth4的小树用于可视化这样图的尺寸可控规则清晰可读。import matplotlib.pyplot as plt from sklearn.tree import plot_tree plt.figure(figsize(20, 10)) plot_tree( clf, max_depth4, feature_namesfeature_names, class_names[50K, 50K], filledTrue, roundedTrue, fontsize10 ) plt.show()我实际画图后根节点第一个分裂特征选中的是marital-status第二层分裂特征主要结合education-num和capital-gain。这符合业务直觉婚姻状况影响家庭整体收入结构教育水平与收入高度相关而资本利得有部分人异常高直接把高收入群体区分出来了。如果想输出成图文件可以使用graphvizdot -Tpng tree.dot -o tree.png如果直接使用graphviz注意确保本机安装了graphviz的二进制程序只装了python的graphviz库是不够的。4.2 特征重要性哪些变量在驱动预测决策树还提供一个很实用的属性feature_importances_它表示每个特征在所有节点分裂中的总贡献度。这个值是根据每个特征在减少不纯度方面的贡献加权累加得到的。可以用一行代码快速得到最重要的特征。importance pd.Series(clf.feature_importances_, indexfeature_names).sort_values(ascendingFalse) print(importance.head(10))在我的结果里重要性排名靠前的特征包括education-num、marital-status映射后的对应编码、capital-gain、age、hours-per-week。这非常合理。education-num是连续数值树可以灵活地在不同等级间切分充分利用所有样本。婚姻状况直接将样本划成两大块也大大降低了整体不纯度。值得注意的是由于one-hot编码的关系单个类别特征的重要性会被拆分到多个列上这使得它们看起来没有单个连续特征重要。如果只看feature_importances_就判断“职业不重要”可能被误导合理的做法是把属于原始特征的多个one-hot列的重要性相加再排名。4.3 决策边界可视化与分类报告画出完整决策边界比较适合二维特征而Adult数据集特征维度高无法直接画二维图。但可以做降维或挑两个重要特征作二维可视化尝试主要目的是感受树模型的“阶梯型”切分边界。更实用的做法是输出详细的分类报告和混淆矩阵from sklearn.metrics import classification_report, confusion_matrix y_pred grid.best_estimator_.predict(X_test) print(classification_report(y_test, y_pred)) print(confusion_matrix(y_test, y_pred))分类报告里你可以看到哪个类别的精确率、召回率偏低。在我的运行结果中正类收入大于50K的召回率往往偏低也就是说不少真实高收入人群被判断成了低收入。这正是类别不平衡问题的典型表现如果你的应用场景更看重高收入人群的召回率就需要考虑class_weight参数或者使用阈值调整、采样等方式加以处理。4.4 不平衡数据的处理class_weight与阈值调整Adult数据集不平衡度虽然不是特别极端但也是影响分类效果的主要因素。我在实验时把class_weight设置为balanced发现正类的召回率明显提升但精确率有所下降。这里需要做一个trade-off选择没有一个组合是绝对正确的要看业务目标。针对收入预测这种场景试想一下如果你正在做的是“高收入用户筛选”错漏一个高收入用户反而损失机会如果你做的是“信贷审批”把低收入用户误判成高收入可能会带来坏账风险。为了在项目报告中体现思考深度我建议做一次阈值的滑动分析调高或调低判断正类的概率阈值观察精确率和召回率的变化关系并画出PR曲线确定最合适的概率阈值。4.5 常见问题速查表实操过程中容易出问题的点集中在这几个地方我整理成一张表方便排查。问题现象造成原因解决办法训练集准确率99%测试集只有80%决策树深度过深严重过拟合限制max_depth增大min_samples_leaf代码报错could not convert string to float类别特征没有进行编码检查是否完成LabelEncoder或OneHotEncoderOneHot编码后特征数量爆炸类别基数高比如native-country有42个值低频类别合并或改用有序编码或直接用树模型自带处理GridSearchCV跑起来特别慢参数组合过多或未加n_jobs-1先粗搜再细搜开启多核并行降低交叉验证折数画出来的决策树图片字太小完全看不清树的深度太深或图尺寸过小用max_depth4的小树演示规则设置figsize(20,10)训练集和测试集分布不一致测试集精度波动大没有用stratify做分层抽样train_test_split时设置stratifyy4.6 交叉验证与数据泄露防护还有一个最常见的坑在GridSearchCV里直接对全量数据做编码处理。正确步骤是先把数据切分成训练集和测试集然后在训练集上fit编码器或预处理器用同一个编码器transform测试集。如果对全量数据做fit_transform测试集的信息就会被“泄露”最终准确率会虚高换成线上真实数据后效果会有明显缩水。同理如果要使用特征选择或PCA之类的降维操作也必须放在交叉验证的fold内部进行。sklearn提供了Pipeline机制可以把预处理和模型打包确保每一步都在正确的数据子集上执行。这个习惯越早养成后面做复杂项目的风险越小。在Adult项目中我实际使用的最简Pipeline是这样from sklearn.pipeline import Pipeline from sklearn.compose import ColumnTransformer preprocessor ColumnTransformer( transformers[ (num, passthrough, numeric_features), (cat, OneHotEncoder(handle_unknownignore), categorical_features) ] ) pipeline Pipeline([ (prep, preprocessor), (clf, DecisionTreeClassifier(random_state42)) ]) # 配合GridSearchCV搜索clf__max_depth等参数参数名前缀clf__必须在GridSearchCV的param_grid中一致否则会报参数错误。5. 扩展方向与个人实操体会5.1 如何与随机森林、梯度提升树对比决策树做完之后下一步的扩展方向非常清晰就是集成学习。随机森林、梯度提升树本质上都是多棵决策树的组合在保持树的可解释性语言体系的同时能大幅提升泛化能力。在Adult数据集上我简单跑了一遍随机森林在默认参数下测试集准确率就能达到约85%比单棵调参后的决策树还要高一个点。梯度提升树如GradientBoostingClassifier能达到约87%。这个对比实验强烈建议做因为能直观体会到“集成”的威力。对业务来说决策树负责解释和基线随机森林负责更优的精度两者互补使用是比较理想的落地搭配。5.2 尝试在别的数据集上迁移Adult数据集的表格式结构和业务含义都是比较通用的“表格型预测”模板。如果想迁移到其他场景比如电商用户复购预测、银行客户流失预警、员工离职风险评估都可以沿用这样一个流程清洗缺失值编码类别特征、分层切分、训练baseline决策树、调参对比集成模型、解读特征重要性输出关键业务结论。基本上只要把读入的CSV文件换成对应的数据集改动的地方很少。我在这个基础上还试过给特征加入交互项比如“教育年限与工作时间的乘积”以及“年龄分桶”之类的操作。决策树本身能自动寻找特征的组合和切分但对这种强交互信息的探索手动做特征工程仍然能带来一点收益。这也是表格类项目中数据科学家的价值所在特征理解比盲目调参更能提升模型上限。5.3 最终的个人实操体会我在做完整个收入预测项目后一个比较深的感受是模型调参其实并没有想象中的那么玄乎关键是理解数据长什么样、模型偏好什么样、业务到底要什么。如果用一句话总结就是决策树项目的常规操作路径从数据到调参再到结果评估比很多人想象中更有章法。做一个决策树项目核心收获不是“我把准确率从82%调到了85%”而是“我知道了为什么这个特征能拿来做根节点”、“为什么max_depth设大了会过拟合”、“为什么只看准确率会被骗”。这些认知会迁移到后续任何一个模型项目中。最后建议你把每个关键步骤的中间结果都保留下来例如默认参数的训练集和测试集分数、调参后的分数、不同剪枝参数对应树的深度和叶子数。这些数据点会在你做项目总结或给他人分享时成为最有说服力的证据链。如果你手头正在练习决策树项目建议先别急着上随机森林和XGBoost把单棵树从入门到调参这一步打磨扎实。最后再分享一个小技巧用sklearn自带的export_text输出一棵树的文本规则虽然丑但调试时比画图更直接能清晰看到每一步分裂的特征和阈值。这个技巧在我排查过拟合问题时帮了我不少忙。