最近把一个经典实验重新做了一遍用 Python 的 scikit-learn 模块拿决策树算法对德国信贷数据做分类。很多人一听到“德国信贷数据”觉得陌生其实就是一份公开的信贷风控数据集里面统计了 1000 条用户贷款记录每条包含 20 个特征和 1 个风险标签标签要么是“信用良好”要么是“信用不良”。说白了这是一个标准的二分类问题给出一位用户的年龄、收入、存款、贷款金额等信息让模型判断这个人能不能按时还钱。这个实验我前前后后跑了好几轮。第一版直接用默认参数建树效果惨不忍睹准确率看起来还行但风险用户几乎全被漏掉后来把数据编码、样本划分、树参数一点点理清楚之后结果才算能看。这篇文章把整个过程中踩过的坑、验证过的逻辑、调过的参数都整理出来给正准备做类似分类实验的读者一份可以照抄的实操手册。无论你是刚学机器学习的学生还是想用 Python 快速跑通一个风控小项目这套流程都能直接复用。1. 项目概述与决策树方案选型1.1 德国信贷数据集到底长什么样德国信贷数据集是一个很典型的小型风控数据集样本量不大1000 条记录正负样本不是对半分的信用良好用户 700 条信用不良用户 300 条。这个比例其实非常接近真实信贷业务中的坏账率分布所以做实验的时候不能只盯着准确率这个坑后面会详细说。数据集里每条样本包含 20 个特征大类可以分成两类数值型特征贷款时长、贷款金额、分期付款比率、居住年限、年龄、现有信用卡数量、可依赖人数一共 7 个。类别型特征支票账户状态、信用历史、贷款用途、储蓄账户、就业情况、个人状态、其他债务人、房产、其他分期计划、住房类型、工作性质、是否有电话、是否外籍工人一共 13 个。这些特征基本覆盖了银行审批一笔贷款时会看的几个维度客户的资金状况账户余额、储蓄、还款能力收入、工作年限、贷款用途与金额、个人稳定性年龄、住房、居住年限。特征数量不多不少20 个正好适合做决策树实验——既不会因为维度太高导致树很难可视化也不会因为特征太少显得太玩具。标签部分要特别注意文件里目标列用 1 和 2 表示1 是信用良好2 是信用不良。加载进 DataFrame 之后最好先处理成 0 和 1否则后面算 ROC_AUC 和混淆矩阵的时候正负类语义很容易搞反。1.2 为什么这个实验选决策树而不是别的模型决策树能成为这个实验的主角不是随便挑的有几个非常实际的原因第一可解释性。信贷审批是一个强监管场景风控模型给出“拒绝”或“通过”的决定之后业务方往往需要向客户说明理由。决策树天然输出一堆“如果……那么……”的规则比如“如果支票账户余额小于 0 且贷款金额超过 5000就判定为高风险”这种规则可以直接翻译成自然语言这是逻辑回归和神经网络都做不到的。第二对数据分布不敏感。决策树是分段函数不需要对特征做归一化和标准化不同量纲的特征混在一起也能直接跑。实验里贷款金额这种动辄几千上万的变量和年龄这种几十的变量放进决策树里完全没压力。这对新手来说非常友好省掉了一大堆预处理工作。第三作为基线模型很稳。真正的风控系统里决策树很少单独上阵更多是作为随机森林、梯度提升等集成模型的基学习器。但做实验的时候先用一棵单树跑通全流程搞清楚特征和标签的关系再去升级模型逻辑上最顺。而且决策树训练速度快1000 条样本瞬间出结果调参迭代的成本极低。当然决策树也有明显短板主要是单棵树容易过拟合、对噪声敏感所以后面的章节会专门讲剪枝。用一棵树做实验其实重点就在于把“剪枝”这件事琢磨明白后面用集成模型时才不会踩坑。1.3 实验流程先画个路线图这个项目我拆成了六个阶段每个阶段都有明确的交付物加载数据并核对字段确认没有读错列、漏掉行检查类型。清洗与编码把类别型文本特征转成数值处理缺失值。划分训练集和测试集按类别比例分层抽样固定随机种子。训练基准模型用默认参数建第一棵树拿到一个“底线成绩”。调参与剪枝用网格搜索找相对合理的参数组合控制过拟合。评估与解释看混淆矩阵、ROC_AUC、特征重要性把模型结论讲清楚。第六步经常被初学者忽略但其实这是整个实验里最有价值的部分。数据实验不只是把准确率刷高而是要知道模型凭什么做出判断以及误判会给业务带来什么后果。2. 数据加载、清洗与特征编码2.1 手工核对一遍字段结构拿到数据文件之后我强烈建议先打开原始文件扫一眼而不是直接写加载代码。这个数据集没有表头特征之间用空格分隔第一眼看去是一串字符串和数字混排的矩阵。为了后续处理方便我给每一列都取了名字。这里把关键字段整理成了一张表方便对照特征名含义类型典型取值checking_status支票账户状态类别A11余额小于0、A12余额适中、A14无账户duration贷款时长月数值6~72credit_history信用历史类别A30~A34从无信用到已结清purpose贷款用途类别A40~A46新车、二手车、教育等credit_amount贷款金额数值250~18424savings储蓄账户余额类别A61~A65employment就业年限类别A71~A75installment_rate分期付款占收入比数值1~4personal_status个人状态与性别类别A91~A94age年龄数值19~75housing住房类型类别A151自有、A152租用等foreign_worker是否外籍工人类别A201是、A202否target信用标签标签1良好、2不良这里每个类别型变量都用 A 开头的代码表示比如 A11、A12这些代码本身是有业务含义的尤其是 checking_status 里“A14 表示无账户”这条信息特别关键。在德国征信体系里没有支票账户往往是风险信号后面特征重要性分析里这个变量常年排第一。2.2 数据加载与缺失值检查加载代码我习惯用 pandas 的 read_csv加上自定义列名一次性到位import pandas as pd import numpy as np col_names [ checking_status, duration, credit_history, purpose, credit_amount, savings, employment, installment_rate, personal_status, other_debtors, residence, property, age, other_installment, housing, num_credits, job, num_liable, telephone, foreign_worker, target ] df pd.read_csv(german.data, sep\s, headerNone, namescol_names) print(df.shape) # 期望输出 (1000, 21) print(df.head())这里我用的是sep\s因为原始文件有时候是一个空格有时候是多个空格用正则匹配多个空白字符更保险。读进来之后先检查缺失值print(df.isnull().sum().sum()) # 这个数据集本身是干净的输出0虽然这份数据集没有缺失值但处理流程上检查这一步不能省。如果换成真实业务数据缺失值处理是躲不开的一环常见做法包括删除缺失率过高的特征、用众数或中位数填充、用模型预测填充。顺带提醒一句填充缺失值之前一定要分清是“随机缺失”还是“结构性缺失”比如“没有支票账户”和“支票账户信息缺失”在其他数据集里是两回事填错语义会引入偏差。2.3 类别特征编码的两种姿势决策树算法本身不直接吃字符串必须把文本特征转成数值。这里有两种做法我用两种都试过效果差异值得说一下。第一种是 LabelEncoder把所有 object 类型的列统一编码成 0、1、2……这种方案的优点是快一次循环搞定而且决策树跑起来完全没问题from sklearn.preprocessing import LabelEncoder for col in df.select_dtypes(include[object]).columns: df[col] LabelEncoder().fit_transform(df[col])第二种是 OneHotEncoder 或 pandas 的 get_dummies把每个类别值单独拆成一列比如 checking_status 有 4 个取值就拆成 4 列df pd.get_dummies(df, drop_firstFalse)从理论严谨性上讲像 checking_status 这种名义变量nominalA11、A12、A14 之间并没有大小顺序LabelEncoder 会强加一个 0、1、2 的顺序关系虽然决策树通过多次分裂也能近似处理这种问题但单棵树能力有限编码顺序确实可能影响分裂路径。OneHot 没有这个隐患。但从这个实验的实际结果看两种编码方式跑出来的准确率和 ROC_AUC 差别不算大都在 1 到 2 个百分点以内。原因在于决策树分裂时选择的是“哪个取值分开”而 LabelEncoder 的顺序影响主要在数值化后分裂阈值的选取方式上样本量小的时候会被噪声盖过去。我的建议是如果后续要对比逻辑回归或神经网络统一用 OneHot 编码如果只跑决策树实验LabelEncoder 省事够用。不过一旦决定用一种编码训练和预测阶段必须保持一致否则模型会直接报特征数量不匹配的错误。3. 决策树模型构建与剪枝调参3.1 sklearn 决策树核心参数逐个拆解sklearn 的 DecisionTreeClassifier 参数不少真正会影响这个项目结果的也就六七个我把它们分成三组来看第一组是树的结构控制参数直接决定树的复杂度和过拟合程度max_depth限制树的最大深度。深度越深模型越能记住训练集的细节但也越容易把噪声学进去。德国信贷数据特征不多深度 3 到 6 是比较合理的搜索范围。min_samples_split内部节点继续分裂所需的最小样本数。设置成 10 意味着如果一个节点里样本少于 10 个就不允许再分这是一个非常有效的剪枝手段。min_samples_leaf叶子节点最少样本数。这个参数比 min_samples_split 更细粒度它约束每个叶子至少要有多少样本对平滑预测效果明显。信贷数据正负样本不平衡叶子节点样本太少会导致预测结果抖动剧烈。max_features每次分裂时最多考虑多少个特征。默认是全部特征单棵树上一般不需要设但可以用来增加树的随机性对随机森林有意义。第二组是纯度度量参数决定每个节点用什么标准选择最优分裂特征criterion可选 gini 或 entropy。这个在后面单独拆开讲。第三组是业务相关的参数class_weight正负样本的权重。因为这个数据集是 700 好 300 坏我可以让模型给“坏客户”更高的权重让它更重视少数类。random_state随机种子。决策树在特征值相同的情况下有时候会遇到随机平分选择固定种子的作用不是影响模型能力而是保证每次跑出来的结果完全一致方便复现实验。3.2 基尼系数和信息增益到底怎么选节点分裂的核心逻辑就是找到一个特征和阈值让分裂后的两个子节点“更纯”。sklearn 提供了两种纯度的度量方式。基尼系数的计算公式是Gini 1 - Σ(p_i²)其中 p_i 是节点中第 i 个类别的样本占比。如果节点里全是同一类Gini 是 0如果两类各占一半Gini 是 0.5。分裂时会遍历所有特征和取值选择让子节点基尼系数加权和最小的那个切分点。信息增益用到的是熵Entropy -Σ(p_i · log₂(p_i))然后信息增益 父节点熵 - 子节点加权熵。熵的本质是对“不确定性”的度量信息增益越大说明这个特征把不确定性消除得越多。我从多个随机种子下做了对比试验两种准则在这个数据集上的准确率差异基本在 0.5 个百分点以内属于噪声范围。工程上的共识是 gini 计算速度快一点因为不涉及对数运算而且 sklearn 的默认值就是 gini。我的建议很直接别纠结默认 gini 先跑跑通了再换成 entropy 对比一次就行。如果你看到某篇文章说熵一定比基尼好那大概率是拿特定数据集跑出来的偶然结论。3.3 用 GridSearchCV 做一组真实调参网格搜索是调参最笨也最稳的方法把所有候选参数组合排列组合交叉验证挑出平均分最高的组合。直接上代码from sklearn.model_selection import train_test_split, GridSearchCV from sklearn.tree import DecisionTreeClassifier from sklearn.metrics import classification_report, confusion_matrix, roc_auc_score # 目标值从1/2转换为0/1 df[target] df[target] - 1 X df.drop(target, axis1) y df[target] X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.3, random_state42, stratifyy ) # 基准模型默认参数 baseline DecisionTreeClassifier(random_state42) baseline.fit(X_train, y_train) print(默认参数准确率:, (baseline.predict(X_test) y_test).mean())基准模型跑出来训练集上的准确率接近 1.0测试集上却只有 0.7 左右。训练和测试差这么远就是典型的过拟合信号——树把训练样本的细节背下来了遇到新样本就露馅。接下来用网格搜索找相对合理的参数param_grid { max_depth: [3, 4, 5, 6, 8], min_samples_split: [5, 10, 15, 20], min_samples_leaf: [2, 5, 10], criterion: [gini, entropy] } clf DecisionTreeClassifier(random_state42) grid GridSearchCV( clf, param_grid, cv5, scoringroc_auc, n_jobs-1, verbose1 ) grid.fit(X_train, y_train) print(最佳参数:, grid.best_params_)这里有个细节scoring 我用的是 roc_auc而不是 accuracy。原因很简单这个数据集 7 成是好客户模型只要全预测“好”准确率就有 70%但这样的模型在风控场景里毫无价值。ROC_AUC 对类别不平衡更稳健能同时看模型排序好坏客户的能力更适合这种场景。我跑出来的最佳组合大致是 max_depth5、min_samples_split10、min_samples_leaf5、criterionentropy 这一类的组合。不同的随机种子可能会有微调但方向是一致的树的深度不要超过 6叶子节点最少要有 5 个样本兜底。这个规律放到其他中小规模数据集上也基本成立。4. 评估指标、混淆矩阵与特征重要性4.1 准确率不是唯一标准模型训练完之后最忌只看一个准确率。拿我调完参的模型举例测试集上准确率大概是 0.75看起来不高但放在信贷场景里这个模型的 ROC_AUC 约 0.79已经有一定的排序能力了。我建议至少打印四样东西准确率、分类报告精确率、召回率、F1、混淆矩阵、ROC_AUC。代码直接抄best grid.best_estimator_ y_pred best.predict(X_test) y_prob best.predict_proba(X_test)[:, 1] print(classification_report(y_test, y_pred, target_names[good, bad])) print(混淆矩阵:\n, confusion_matrix(y_test, y_test, labels[0, 1])) print(ROC_AUC:, roc_auc_score(y_test, y_prob))分类报告里重点看“坏客户”那一行的召回率。默认不设 class_weight 的时候模型倾向于把样本预测成多数类坏客户的召回率往往只有 0.3 左右这意味着实际 100 个违约客户模型只抓住了 30 个剩下的全被漏了。后面我会专门讲怎么处理这个问题。4.2 混淆矩阵背后是真实的钱混淆矩阵把预测结果分成四类真正例、假正例、真负例、假负例。在信贷语境下假负例实际违约模型却判定为良好放款之后收不回来。亏的是贷款本金。假正例实际守约模型却判定为不良拒绝放款。损失的是这笔贷款的利息收益。两者都不是一个好结果但成本完全不对等。假设一笔贷款本金 5000利率 10%放给坏客户的损失是本息 5500而误杀一个好客户只是少赚 500。所以很多银行宁可牺牲一定的准确率也要提高坏客户的召回率。sklearn 里最简单的处理办法是给坏客户加重权重clf_weighted DecisionTreeClassifier( max_depth5, min_samples_leaf5, min_samples_split10, class_weight{0: 1, 1: 3}, random_state42 ) clf_weighted.fit(X_train, y_train)把坏客户的权重设为 3 之后模型会更积极地识别风险用户坏客户召回率能明显提升但代价是误杀一些好客户。具体权重设多少要看业务上“漏放一单”和“误杀一单”的成本比值来决定这就是风控里常说的“代价敏感学习”。4.3 特征重要性排名告诉你模型在“看什么”sklearn 的决策树训练完成后feature_importances_ 属性直接给出每个特征对树分裂贡献的归一化得分全部特征加起来等于 1。跑完这个实验我记录到的特征重要性排名非常稳定支票账户状态checking_status排第一重要性占比大概 0.2 左右。贷款金额credit_amount、贷款时长duration和储蓄账户savings紧随其后。年龄age的排名在中游。电话号码telephone、外籍工人foreign_worker这些特征重要性接近 0。这个排名跟业务直觉对得上一个连支票账户都没有的人还款能力确实值得怀疑贷款金额越大、期限越长风险自然越高。所以我一直觉得特征重要性分析是决策树实验里最“出活”的一步它能帮你验证数据里有没有业务逻辑也能帮你发现哪个特征录错了数据。至于可视化可以用 sklearn 的 plot_tree 把整棵树画出来import matplotlib.pyplot as plt from sklearn.tree import plot_tree plt.figure(figsize(20, 10)) plot_tree(best, filledTrue, feature_namesX.columns, class_names[good, bad]) plt.show()深度 5 的树画出来大概有几十个节点直接看会有点晕但挑出从根节点往下两层的几条主路径看看就足够理解模型的决策规则了。5. 踩坑记录与排查技巧速查表5.1 类别不平衡到底要不要专门处理700 好 300 坏从比例上看不算极端不平衡很多教程会告诉你“不用处理”。我的实际操作体会是不处理也能跑通但模型的业务价值会打折。要分清楚两个目标。如果只关心准确率不管有多少坏客户被漏掉那确实不用处理。如果你希望模型在“抓到坏人”这件事上有可用性就必须处理。处理手段除了前面说的 class_weight还可以用 SMOTE 这类过采样方法或者简单地对少数类做重采样。这个数据集上我试过把坏客户权重调到 2 到 4 之间效果比过采样更直接因为决策树的训练不受采样比例影响权重法成本最低。5.2 随机种子决定你能不能复现结果决策树训练过程中存在随机性特征值完全相同的节点不会凭空出现但当存在并列最优分裂点时sklearn 会随机选择一个。这导致同一个参数、同一份数据两次运行可能得到两棵不同的树。我一开始没固定 random_state结果调参时发现同样的参数组合每次跑出来的 ROC_AUC 差 2 到 3 个百分点根本没法判断参数改动是有效果还是偶然。后来把所有模型和数据集划分都加上 random_state42整个实验才变得可控。另外所有代码的随机种子要保持一致train_test_split 里的 random_state 和 DecisionTreeClassifier 里的 random_state 是两回事一个管样本划分一个管树生长都要固定。5.3 高频报错与排查速查表报错现象原因解决方案ValueError: could not convert string to float特征列还有字符串漏做了 LabelEncoder 或 one-hot预测时特征数量不匹配编码方式不一致训练和预测用同一套编码逻辑最好用 Pipeline准确率极高但 ROC_AUC 极低正负类标反了检查 target 列 1/2 是否已转成 0/1训练集分数 1.0测试集 0.7过拟合减少 max_depth增大 min_samples_leafGridSearchCV 跑得很慢参数组合太多缩小候选范围先粗调后细调最后特别提醒一个隐蔽问题LabelEncoder 如果直接 fit 在全量数据上会导致“数据泄漏”。虽然 sklearn 不报错但你实际上是在用测试集的信息来编码训练数据交叉验证的分数会被低估。正确做法是先划分训练测试再对训练集 fit然后 transform 测试集。在这个实验里因为类别取值在两个集合里基本都存在影响不大但养成这个习惯能省很多麻烦。5.4 往下的路还能怎么走单棵决策树只是一个起点。如果手痒想继续拓展我建议按三条线走第一条线是集成学习。用同样参数调好的决策树作为基学习器换成随机森林或梯度提升训练集和测试集差距会明显缩小ROC_AUC 大概率能从 0.79 涨到 0.82 以上。你会发现单树的过拟合问题在集成模型里被天然平滑掉了。第二条线是特征工程。试试把年龄离散成年龄段、把贷款金额取对数、把“检查账户状态储蓄状态”组合成一个复合特征看看特征重要性排名会不会发生变化。这比盲目调参更有意思。第三条线是模型解释。用树的可视化去对照真实业务规则比如看模型是否把“无支票账户”和“长期失业”视为高风险信号。如果树的规律跟业务经验相反不需要急着改模型先回头查数据是不是有问题。我在实际做这个实验的过程中最有价值的收获其实不是把准确率从 0.7 调到 0.75而是理解了一件事模型调优的本质不是参数搜索而是对业务成本的建模。每组参数的背后都对应着不同的误判代价调参的过程其实就是和业务目标反复对齐的过程。最后再分享一个小技巧做 GridSearchCV 的时候把 roc_auc 换成 recall 再看一遍结果你会看到参数组合明显更激进地偏向抓坏客户选哪份结果取决于你手里那笔钱的成本结构。