这一期综合示例9我给自己攒的题是分类任务里最容易被忽略、但对结果影响极大的两个环节——离散特征怎么编码预测概率的阈值怎么定。很多人拿到sklearn跑完LogisticRegression就直接.predict()效果不好就换模型、加特征、调正则化结果问题根本不在模型本身而是一开始就没把类别特征喂对或者死守着 0.5 这条默认的预测线不放。这篇内容的主要场景是“某类营销响应模型”的模拟项目数据里有数值型特征也有地区、渠道这种没法直接计算的离散特征目标变量是不平衡的“是否响应”。我会把 one-hot 编码的细节、ColumnTransformer的用法、预测阈值的搜索方法以及真正能落地的调参流程拆开讲清楚。适合正在用sklearn做分类建模、但对特征编码和阈值调整还停留在“好像应该这么做”阶段的同学。1. 这一期的核心问题离散特征和 0.5 决策线1.1 为什么 one-hot 不是可选项很多人在处理离散特征时习惯直接用一个数字去表示比如“地区”有 A、B、C 三类就编码成 0、1、2。这种做法的默认假设是类别之间在数值上存在先后顺序且 C 的效果是 A 的 2 倍。但实际业务里地区、渠道、会员等级这些字段往往只有“是不是某类”的含义没有“谁比谁大多少”的数学关系。LogisticRegression这类线性模型本质上是在做加权求和如果直接把0,1,2放进模型相当于强迫模型学习一个线性的类别关系。结果可能是A、C 类别对 target 的影响方向一样、力度差不多但因为编码把它变成了 0 和 2模型不得不把距离拉大导致系数解释失真。one-hot 把每一个取值拆成一列独立的 0/1 特征让模型自己学习每个类别的独立贡献这才是对名义型离散特征最稳妥的输入方式。树模型虽然可以勉强吃下数字编码的类别特征因为它靠分裂而不是线性加权但遇到高基数类别时同样容易出问题。而且如果你在同一个项目里既用了树模型又用了线性模型统一用 one-hot 编码可以避免不同模型之间的特征口径不一致。1.2 为什么预测阈值可以也应该动sklearn的.predict()默认逻辑很简单二分类时如果predict_proba得到的正类概率大于等于 0.5就判为正类。这个 0.5 看起来很美但它有三个前提很少被人提类别相当均衡、假阳性和假阴性的代价相同、概率校准没有问题。营销响应这类场景通常不满足这些前提。实际响应率可能只有 15%给 10 个人打电话即使模型判断其中 3 个人的响应概率是 0.4如果按 0.5 线切这 3 个人全被舍弃。可是从业务收益看响应一个客户的收益可能远大于空打电话的成本这时候就应该把阈值往下调把更多“概率不够高但仍有希望”的客户捞回来。这一节其实是在说one-hot 解决的是“模型能不能理解特征”阈值调整解决的是“模型给出概率后你怎么用这个概率做决策”。两个问题一个在特征端一个在决策端但经常被混在一起最后变成盲目调参。2. OneHotEncoder 的正确打开方式2.1 先搞懂 OneHotEncoder 的关键参数sklearn的OneHotEncoder用起来很简单但真正决定生产环境能不能稳定运行的是那几个参数。from sklearn.preprocessing import OneHotEncoder enc OneHotEncoder( handle_unknownignore, sparse_outputFalse, dropfirst )handle_unknownignore训练时没见过的类别转换时不会报错而是生成全 0 向量。这在线上推理时非常关键。比如训练数据的时间段里没有出现某个新渠道线上突然来了模型不能直接崩溃。sparse_outputFalse输出稠密数组方便直接转 DataFrame 排查数据。特征量不大时用这个更顺手特别适合教学和调试如果特征量很大再改回稀疏矩阵也来得及。dropfirst每个离散特征会删掉第一个类别对应的那一列。对线性模型来说这能避免完全多重共线性也能降低特征维度。如果你用的sklearn版本比较新还可以配合min_frequency把出现频次太低的类别合并成“其他”避免 low frequency 类别带来的噪声。这个后面细说。2.2 直接对比pd.get_dummies 和 ColumnTransformer 的差异很多新手习惯用pd.get_dummies先把 DataFrame 转好再塞进模型训练。写笔记、画图没问题但放到一个正经的训练流程里至少会有三个隐患如果先pd.get_dummies(全部数据)再切训练集和测试集就相当于让训练过程提前看到了测试集中类别的分布属于特征泄漏。如果先切分再对训练集和测试集分别做get_dummies测试集里出现训练集没有的类别列数会对不上模型直接报错。get_dummies不会自动记忆“训练集里有哪些类别”后续给新数据做转换时还得自己维护一张类别映射表。更合适的方式是把编码器放进 Pipeline让它在每一折交叉验证里只对训练折做fit。配合ColumnTransformer数值列和离散列可以一次处理好。from sklearn.compose import ColumnTransformer from sklearn.preprocessing import StandardScaler prep ColumnTransformer([ (num, StandardScaler(), [amount, days]), (cat, OneHotEncoder(handle_unknownignore), [region, channel]) ])这样的好处是数值列做标准化时只用训练集的均值和标准差离散列做 one-hot 时只学习训练集的类别集合。后续预测新数据时同一个prep对象能被反复复用不需要手工对齐列名。2.3 类别特征处理中的三个常见坑第一个坑是顺序问题。有人先把 DataFrame 用get_dummies转好再丢进ColumnTransformer结果数值列也都变成了一堆 0/1 列等于把标准化这件事给架空了。正确做法是原始 DataFrame 保持干净在ColumnTransformer里分别指定数值列和离散列。第二个坑是类别基数太高。比如“城市”有几百个取值硬 one-hot 会直接让特征矩阵变得非常宽训练慢不说很多低频类别本身就代表不了稳定规律。这种情况要么用min_frequency合并低频类别要么考虑 target encoding、embedding 这类更紧凑的编码方式不要硬上 one-hot。第三个坑是只看handle_unknown忽略dropfirst的语义。当你设置dropfirst时未知类别在转换后会变成全 0这一行本质上被归类到你删除的那一个“参照类别”上。这个行为在生产环境里是合理的但你要知道它发生了否则排查特征时会觉得“为什么这个新渠道的向量全是 0”。另外一点建议如果在调试时想把编码后的结果打出来看可以用prep.set_output(transformpandas)让ColumnTransformer输出 DataFrame列名就是数值列名加 one-hot 后的特征名。否则你看到的是一堆稀疏矩阵容易劝退新手。3. 阈值调整从 predict_proba 到业务决策3.1 默认 0.5 是怎么来的LogisticRegression的预测流程本质上是先算出正类概率再拿概率和阈值比较。默认阈值确实是 0.5但这个 0.5 是“先验类别均衡 误判代价对称”时的最优选择。你把正类比例从 50% 降到 10%或者把“漏掉一个正类”的代价调高0.5 就不再是最优的切分线。很多线性分类器内部其实也是类似逻辑只是有的用decision_function阈值默认是 0。比如支持向量分类器不直接输出概率LinearSVC则只有决策函数这种情况下调整阈值的对象就从predict_proba换成了decision_function。核心思想不变不要盲信默认切分要根据业务目标移动这条线。我习惯用precision_recall_curve来做阈值搜索因为它能直观展示精确率和召回率之间的拉扯。尤其是正类不平衡时PR 曲线比 ROC 曲线更贴近业务感受。3.2 阈值搜索不是“拿测试集反复试”这是很多人容易踩的坑先跑测试集把阈值从 0.1 到 0.9 试一遍选一个 F1 最高的。这种方式的问题很明显——你用测试集选阈值再用同一个测试集报效果结果会乐观到失真。阈值本身也是一个超参数不能在最终评估集上做选择。正确做法是再从训练集里划一块验证集或者在训练集上做交叉验证得到“包外预测概率”。cross_val_predict很适合这个场景它把训练集切多折每折用剩下的数据训练预测当前折最终拼成一份完整的、没有严重过拟合的预测概率。再用这份概率去搜索阈值最后把选好的阈值放到测试集上做一次终评。from sklearn.model_selection import cross_val_predict cv_proba cross_val_predict( pipe, X_train, y_train, cv5, methodpredict_proba )[:, 1]这里需要注意pipe是完整 Pipeline里面包含了ColumnTransformer和分类器。cross_val_predict会在每一折内部重新执行整个流水线所以不会出现“标准化时用了全量数据”的泄漏。3.3 用业务成本函数定阈值不同业务对精确率和召回率的偏好完全不同。营销外呼这种场景漏掉一个高价值客户损失的是订单金额误打电话的成本则是人力成本。如果单次响应收益远大于单次外呼成本阈值就该降低宁可多打几个无效电话也要把潜在客户捞回来。可以给混淆矩阵的四种结果赋予业务代价然后用总代价来选阈值。简化公式如下总代价 FP数量 × 误判一个负类的代价 FN数量 × 漏掉一个正类的代价按这个公式遍历候选阈值选择总代价最小的那个。这个方法的优点是不用纠结“F1 到底多少算好”它直接把模型评估拉回到业务语言。实际项目中我建议每个阈值都打印出精确率、召回率、F1、总代价四项让业务同事也能看懂为什么选 0.35 而不是 0.5。4. 完整可运行的 sklearn 示例营销响应模型4.1 构造演示数据为了让你能直接复现我用随机数造一份模拟数据。特征里有数值型字段也有地区、渠道这类需要 one-hot 的离散字段目标变量是正类比例较低的二分类标签。import numpy as np import pandas as pd from sklearn.compose import ColumnTransformer from sklearn.pipeline import Pipeline from sklearn.preprocessing import OneHotEncoder, StandardScaler from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split, cross_val_predict from sklearn.metrics import ( precision_score, recall_score, f1_score, confusion_matrix, precision_recall_curve ) rng np.random.RandomState(20240901) n 3000 region rng.choice([A, B, C, D], n, p[0.4, 0.3, 0.2, 0.1]) channel rng.choice([APP, WEB, STORE], n, p[0.5, 0.3, 0.2]) amount rng.normal(3000, 1000, n).clip(100, 20000) days rng.randint(1, 30, n).astype(float) df pd.DataFrame({ amount: amount, days: days, region: region, channel: channel }) logit ( -2.5 (amount - 3000) / 800 days * 0.02 np.where(region A, 0.3, 0.0) np.where(region C, 0.8, 0.0) np.where(channel APP, 0.6, 0.0) rng.normal(0, 0.1, n) ) p 1 / (1 np.exp(-logit)) df[target] rng.binomial(1, p)这里的logit函数决定了 target 和特征之间的关系模拟出来的数据会有一定的非线性也包含离散特征的影响。随机种子固定后结果可复现。接下来切分数据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 )stratifyy保证训练集和测试集里的正类比例接近避免因为随机切分导致某一侧比例异常。4.2 训练管线构建 Pipeline 时我把数值特征和离散特征分开处理。数值列用StandardScaler防止金额这种大数值量级把系数的梯度带偏离散列用 one-hot并且设置handle_unknownignore。cat_cols [region, channel] num_cols [amount, days] prep ColumnTransformer([ (num, StandardScaler(), num_cols), (cat, OneHotEncoder(handle_unknownignore, dropfirst), cat_cols) ]) pipe Pipeline([ (prep, prep), (clf, LogisticRegression(max_iter1000, class_weightbalanced)) ]) pipe.fit(X_train, y_train)class_weightbalanced是另一个经常和阈值调整一起出现的设置。它相当于在训练时提高少数类的权重间接改变模型的输出概率让模型更“愿意”预测正类。设置它之后模型输出的概率已经根据样本权重做了调整所以后续阈值更应该重新搜索而不是继续用 0.5。先看一下默认 0.5 阈值下的效果prob_test pipe.predict_proba(X_test)[:, 1] pred_05 (prob_test 0.5).astype(int) print(默认阈值 0.5 的效果) print(精确率:, precision_score(y_test, pred_05)) print(召回率:, recall_score(y_test, pred_05)) print(F1:, f1_score(y_test, pred_05))这里不会给出固定的输出数字因为随机种子虽然固定但不同sklearn版本之间仍可能有一点小差异。重点是看思路如果召回率明显偏低就说明默认阈值太保守很多正类被压在了 0.5 线以下。4.3 调阈值与最终评估在训练集上用交叉验证得到包外预测概率再基于这份概率搜索最佳 F1 或最低业务代价阈值cv_proba cross_val_predict( pipe, X_train, y_train, cv5, methodpredict_proba )[:, 1] precisions, recalls, thresholds precision_recall_curve( y_train, cv_proba ) f1_scores ( 2 * precisions[:-1] * recalls[:-1] / (precisions[:-1] recalls[:-1] 1e-12) ) best_idx np.argmax(f1_scores) best_thr thresholds[best_idx] print(f训练集交叉验证选出的最佳阈值: {best_thr:.3f})选完阈值后用测试集做一次性评估pred_tuned (prob_test best_thr).astype(int) print(调阈值后的效果) print(精确率:, precision_score(y_test, pred_tuned)) print(召回率:, recall_score(y_test, pred_tuned)) print(F1:, f1_score(y_test, pred_tuned)) print(混淆矩阵:\n, confusion_matrix(y_test, pred_tuned))通常你会看到调阈值后召回率上升精确率可能下降但 F1 比 0.5 线更高。如果业务允许牺牲一点精确率换取召回率还可以在阈值搜索时改成最大化“单位成本收益”而不是 F1这个思路完全一样只是把优化目标换掉。5. 实际问题排查与实操心得5.1 常见问题速查我把实际项目里最容易遇到的问题整理成了一张表现象可能原因处理方式OneHotEncoder转换报错说遇到未知类别handle_unknown还是默认的error改成handle_unknownignore测试集特征列数和训练集对不上用了pd.get_dummies且没有对齐类别把 one-hot 放进ColumnTransformer或手动reindex对齐列标准化后离散特征也变成了标准化值数值列和离散列混在一个 Transformer 里处理分开放入ColumnTransformercross_val_predict报错模型没有predict_proba分类器不支持概率输出换methoddecision_function或使用支持概率输出的模型阈值无论如何调F1 都上不去模型本身欠拟合或者特征没处理干净先看交叉验证分数再考虑加特征或换模型one-hot 后特征矩阵太大类别基数太高用min_frequency合并低频类别或用 target encoding5.2 我踩过的三个坑第一个坑是在划分数据集之前就用get_dummies。当时图省事把全量数据一次性编码然后再切训练集测试集。结果测试集里全是“已经见过”的类别上线后新客户一进来特征生成逻辑完全对不上模型直接报错。后来改成 Pipeline ColumnTransformer 之后这个坑才彻底填掉。第二个坑是拿测试集调阈值。我最早理解阈值调整时为了省事直接在测试集上试 0.3 和 0.4试出好看的数字就在报告里写上。后来用一份新测试数据重新评估效果掉得厉害才意识到这相当于用测试集信息做了超参数搜索严重过拟合到旧的测试样本上。第三个坑是只看准确率。响应数据里负类占多数时只要把所有样本判成负类准确率都可能超过 80%这让模型看起来还行实际上一个正类都没捞到。所以做分类评估时一定要同时看混淆矩阵、精确率、召回率和 F1尤其是正类比例不平衡的时候准确率是最容易骗人的指标。最后再分享一个小经验阈值调整不是模型训练完成后的“补丁”而是和特征工程、模型选择并列的一个决策环节。建议每次迭代模型时把“最优阈值”也写进模型卡片里连同交叉验证时的分数一起记录。否则过两个月再跑同一个项目很可能想不起来当前阈值为 0.35 到底是怎么选出来的。