这一期综合示例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_unknown="ignore", sparse_output=False, drop="first" )handle_unknown="ignore":训练时没见过的类别,转换时不会报错,而是生成全 0 向量。这在线上推理时非常关键。比如训练数据的时间段里没有出现某个新渠道,线上突然来了,模型不能直接崩溃。sparse_output=False:输出稠密数组,方便直接转 DataFrame 排查数据。特征量不大时用这个更顺手,特别适合教学和调试;如果特征量很大,再改回稀疏矩阵也来得及。drop="first":每个离散特征会删掉第一个类别对应的那一列。对线性模型来说,这能避免完全多重共线性,也能降低特征维度。
如果你用的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_unknown="ignore"), ["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,忽略drop="first"的语义。当你设置drop="first"时,未知类别在转换后会变成全 0,这一行本质上被归类到你删除的那一个“参照类别”上。这个行为在生产环境里是合理的,但你要知道它发生了,否则排查特征时会觉得“为什么这个新渠道的向量全是 0”。
另外一点建议:如果在调试时想把编码后的结果打出来看,可以用prep.set_output(transform="pandas"),让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, cv=5, method="predict_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", axis=1) y = df["target"] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y )stratify=y保证训练集和测试集里的正类比例接近,避免因为随机切分导致某一侧比例异常。
4.2 训练管线
构建 Pipeline 时,我把数值特征和离散特征分开处理。数值列用StandardScaler,防止金额这种大数值量级把系数的梯度带偏;离散列用 one-hot,并且设置handle_unknown="ignore"。
cat_cols = ["region", "channel"] num_cols = ["amount", "days"] prep = ColumnTransformer([ ("num", StandardScaler(), num_cols), ("cat", OneHotEncoder(handle_unknown="ignore", drop="first"), cat_cols) ]) pipe = Pipeline([ ("prep", prep), ("clf", LogisticRegression(max_iter=1000, class_weight="balanced")) ]) pipe.fit(X_train, y_train)class_weight="balanced"是另一个经常和阈值调整一起出现的设置。它相当于在训练时提高少数类的权重,间接改变模型的输出概率,让模型更“愿意”预测正类。设置它之后,模型输出的概率已经根据样本权重做了调整,所以后续阈值更应该重新搜索,而不是继续用 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, cv=5, method="predict_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_unknown="ignore" |
| 测试集特征列数和训练集对不上 | 用了pd.get_dummies且没有对齐类别 | 把 one-hot 放进ColumnTransformer,或手动reindex对齐列 |
| 标准化后离散特征也变成了标准化值 | 数值列和离散列混在一个 Transformer 里处理 | 分开放入ColumnTransformer |
cross_val_predict报错,模型没有predict_proba | 分类器不支持概率输出 | 换method="decision_function",或使用支持概率输出的模型 |
| 阈值无论如何调,F1 都上不去 | 模型本身欠拟合,或者特征没处理干净 | 先看交叉验证分数,再考虑加特征或换模型 |
| one-hot 后特征矩阵太大 | 类别基数太高 | 用min_frequency合并低频类别,或用 target encoding |
5.2 我踩过的三个坑
第一个坑是在划分数据集之前就用get_dummies。当时图省事,把全量数据一次性编码,然后再切训练集测试集。结果测试集里全是“已经见过”的类别,上线后新客户一进来,特征生成逻辑完全对不上,模型直接报错。后来改成 Pipeline + ColumnTransformer 之后,这个坑才彻底填掉。
第二个坑是拿测试集调阈值。我最早理解阈值调整时,为了省事,直接在测试集上试 0.3 和 0.4,试出好看的数字就在报告里写上。后来用一份新测试数据重新评估,效果掉得厉害,才意识到这相当于用测试集信息做了超参数搜索,严重过拟合到旧的测试样本上。
第三个坑是只看准确率。响应数据里负类占多数时,只要把所有样本判成负类,准确率都可能超过 80%,这让模型看起来还行,实际上一个正类都没捞到。所以做分类评估时,一定要同时看混淆矩阵、精确率、召回率和 F1,尤其是正类比例不平衡的时候,准确率是最容易骗人的指标。
最后再分享一个小经验:阈值调整不是模型训练完成后的“补丁”,而是和特征工程、模型选择并列的一个决策环节。建议每次迭代模型时,把“最优阈值”也写进模型卡片里,连同交叉验证时的分数一起记录。否则过两个月再跑同一个项目,很可能想不起来当前阈值为 0.35 到底是怎么选出来的。