☰
scale_pos_weight与StratifiedKFold整合:交叉验证下的样本不平衡处理方案
2026/10/12 6:36:55 网站建设 项目流程

做这行久了你会发现,真正让人头秃的不是特征工程的复杂度,而是样本不平衡这事到底该在哪个环节处理。上个月我在一个用户流失预警项目里,正样本只占全量的4%左右,模型用 XGBoost 训练,交叉验证用 StratifiedKFold,调参时顺手把 scale_pos_weight 设成了全局正负比,结果离线评估和线上表现差了一大截。后来一步步排查才发现,问题出在 scale_pos_weight 和 StratifiedKFold 的配合方式上。今天这篇【小白笔记】就把我踩过的坑和最终的整合方案完整写出来,标题里提到的 scale_pos_weight 和 StratifiedKFold 两个词,拆开看都不复杂,但合在一起用,里面藏着不少细节。这篇内容适合刚接触不平衡分类、正在用交叉验证调参的开发者,看完可以直接套用到自己的二分类项目里。

1. 先把概念掰开:scale_pos_weight 和 StratifiedKFold 各自解决什么问题

1.1 scale_pos_weight:不是简单“调大正类权重”,而是改变优化目标

scale_pos_weight 是 XGBoost、LightGBM 等梯度提升树模型里用于平衡正负样本权重的参数。以 XGBoost 为例,它的默认值是 1,意思是模型把训练集里的正类和负类放在同等重要的位置。当样本严重不平衡时,比如正样本只占 1%,模型在每一轮迭代里看到的绝大多数梯度都来自负类,学到的决策边界会严重偏向把样本预测成负类——因为这样整体损失最小。

scale_pos_weight 的作用就是给正类样本的损失乘上一个放大系数,通常在二分类里取负类数量除以正类数量。比如训练集有 1000 个负类、40 个正类,那么 scale_pos_weight 可以设为 25。这个系数会同时在样本权重、梯度计算和剪枝判断里生效,本质上改变了模型优化的目标函数:让模型把“漏掉正类”和“误判负类”的代价变得可比,而不是单纯追求整体准确率。

这里有个很多人容易误解的点:scale_pos_weight 不是把样本复制成 25 份,也不是简单的过采样,而是在计算损失时给正类样本的梯度加权。比如在 logistic loss 里,每个正样本的损失项前面乘上这个权重,模型后续分裂时会更倾向于“把正样本分对”。

我在早期调参时习惯直接设一个固定的 scale_pos_weight,比如按整体训练集的负正比来设,省事,但后面你会发现,交叉验证时这样做往往会把模型“跑偏”。至于为什么,下文细说。

1.2 StratifiedKFold:让每一折都复刻整体分布

StratifiedKFold 是 scikit-learn 提供的一种交叉验证切分策略。普通的 KFold 会把数据等分成 K 份,每份都是随机抽取,但这种随机抽取在类别不平衡时容易出事——可能出现某一折里正样本极少甚至为零,另一折正样本偏多,导致每折训练出来的模型差异巨大,评估结果方差飙升。

StratifiedKFold 做的事情很简单:在切分时保证每一折里正负样本的比例和整个数据集的整体比例尽可能一致。比如数据集中负类占 90%、正类占 10%,那么 5 折交叉验证中每一折都会尽量维持 90% 负类 + 10% 正类。

这种“分层”思想在分类问题里几乎是标配。它让评估更稳定,因为每一折的验证集分布都代表了真实的业务场景。如果我们做的是一个全量数据里正样本只有 5% 的分类任务,那么验证集里也应该维持 5% 的正样本,这样评估出的 AUC、F1 才有参考价值。

但注意,StratifiedKFold 只保证整体比例近似,并不保证每一折里正样本的具体数量完全相同,尤其是当数据集较小、正样本个数不能被 K 整除时,各折的正样本数会有 1 到 2 个的差异。这个差异看似微小,但如果正样本总量只有 30 个,那么每一折可能只有 6 个正样本,任何细微波动都会被模型放大。

1.3 为什么说它们是一对“黄金搭档”

先说一个真实场景:某风控项目,负类(正常用户)有 5 万,正类(风险用户)只有 1800,比例大概是 27:1。我一开始的做法是:

  • 用 StratifiedKFold 完成 5 折切分;
  • 用整个训练集的负正比 27 作为 scale_pos_weight,固定不变;
  • 在每一折里用同一个权重训练模型。

听起来挺合理,但实测下来每一折的验证表现忽高忽低。后来我把每一折训练集里的正负比打印出来才发现,5 折分别算出来的负正比是 25.3、26.1、28.2、27.5、27.9,虽然和全局 27 接近,但不完全一致。更麻烦的是,我用的数据集不是一次性收集的,前期和后期采集的正样本比例有明显差异,导致 StratifiedKFold 按全局分层切分后,每一折训练集的分布各有侧重。

scale_pos_weight 是模型训练时使用的参数,它应该反映“当前这一折训练集”的正负分布,而不是整个数据集的分布。如果固定使用一个全局权重,那相当于给每一折模型都套了一件不合身的衣服:某一折训练集正样本偏多时,权重会稍大,模型会过度关注正样本;某一折训练集正样本偏少时,权重又显得不够,模型还是会向负类倾斜。

把 scale_pos_weight 整合进 StratifiedKFold 的本质,就是在交叉验证循环内部,对每一折单独计算 scale_pos_weight,并把这个动态权重传给模型。这样既保证了验证集的分层分布,又让每个模型都基于自己实际拿到的训练数据进行权重平衡。两个工具一配合,才算真正闭环。

2. 整合的核心思路:动态计算每一折的 scale_pos_weight,而不是用全局固定值

2.1 全局固定权重的问题到底出在哪

很多人觉得,既然 StratifiedKFold 保证了每一折比例接近整体,那直接用全局权重就够了,干嘛要动态算?理论上看,当数据进行完全随机打乱且样本量足够大时,每一折的比例确实会趋近全局比例。但实际项目中,样本往往存在时间序列效应、采集批次差异,甚至重复样本,StratifiedKFold 只能保证“按全局比例分层”,不能保证“每一折内的影响因子和全局一致”。

举一个极端例子:数据集中有 1000 个样本,其中 50 个正样本,正样本里又有 10 个来自某个特定时间段,特征模式明显不同于其他正样本。当 StratifiedKFold 把这 10 个特殊正样本的一部分分到某一折的训练集时,该折训练集的正样本特征分布就偏移了。此时如果仍用全局 19:1 的权重,那这个模型就严重低估了当前训练集里正样本的复杂性,最终在验证集上表现波动。

动态原则是:scale_pos_weight 永远是当前训练集的正负分布函数,而不是一个从外部手工指定后一成不变的常量。这样做的好处有两个:一是模型永远以“当前折看到的数据”为基础做平衡,贴合实际;二是让交叉验证的每一折都能独立反映真实业务场景中分布漂移带来的影响。

2.2 正确的姿势:在循环内部计算权重,并绑定到当前模型

具体操作上,不是简单地把 scale_pos_weight 放进参数字典然后用 cross_val_score 一跑了之。因为 cross_val_score 这类封装好的工具没办法让你在每一折动态计算并修改模型参数。你需要手动实现交叉验证循环。

先给出核心代码骨架,后面章节会放出完整可运行版本:

skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) for fold, (train_idx, val_idx) in enumerate(skf.split(X, y)): X_train_fold, X_val_fold = X[train_idx], X[val_idx] y_train_fold, y_val_fold = y[train_idx], y[val_idx] # 关键:根据当前训练折计算 scale_pos_weight neg_count = (y_train_fold == 0).sum() pos_count = (y_train_fold == 1).sum() scale_pos_weight = neg_count / pos_count # 把这个动态权重传给模型 model = XGBClassifier(scale_pos_weight=scale_pos_weight, ...) model.fit(X_train_fold, y_train_fold) # 在未加权的验证集上评估 preds = model.predict_proba(X_val_fold)[:, 1] auc = roc_auc_score(y_val_fold, preds)

为什么强调在循环内部计算?因为 XGBoost 在 fit 时读取 scale_pos_weight 参数来构建损失函数,如果我们在循环外部设置全局值,那 fit 阶段用的就是全局值,模型并不知道当前折的真实分布。手动循环的好处是每个模型都是“新鲜”的,权重完全由当前训练折决定。

有些开发者可能会想:不如把 scale_pos_weight 作为一个超参数,用网格搜索统一寻找?但网格搜索找的是“平均最优权重”,并不代表每一折都最优。动态权重的作用是让每一折都使用符合该折分布的权重,这比一个全局值更稳定。

2.3 验证集要不要也加权?这里必须想清楚

我在刚接触这个方案时,心里一直嘀咕:训练集里使用了 scale_pos_weight,那验证集预测时要不要也传入权重?答案是:不需要,也不应该。

验证集的作用是模拟模型在真实世界中的表现。真实世界里你不会知道某个样本是正类还是负类,也不会给模型一个“权重提示”,模型就是靠学到的决策函数去预测。如果验证集在评估时也加权,你评估的就是一个“被权重修饰过的表现”,而不是真实泛化能力。

举个例子:验证集里 100 个负类、10 个正类,如果评估时给正类预测错误加上高权重,那 F1 可能会因为模型对正类的高敏感而显得不错,但这个模型推上线后,面对完全没有权重的现实数据,效果可能完全两样。所以验证集指标必须基于原始标签计算,比如 AUC、PR-AUC、F1、Recall、Precision,这些都天然地把正负类区别对待,不需要额外加权。

训练集加权,验证集不加权,这是实践中最重要的边界。这个边界的把握,直接决定了你的交叉验证结论可不可信。

3. 实操记录:搭配 XGBoost 的完整 Python 实现

3.1 准备数据与评估指标

为了演示整个流程,我构造了一份模拟二分类数据:2000 个样本,10 个特征,正样本占比 10%。实际项目中你可以换成自己的业务数据,但流程完全一致。

评估指标方面,我不会只看准确率,因为正负比 9:1 时,全部预测为负类也有 90% 的准确率,没有参考价值。我会重点关注:

  • AUC:衡量模型区分正负类的能力,对类别比例不敏感。
  • PR-AUC:在正样本极少时比 ROC-AUC 更敏感,更适合不平衡问题。
  • F1-score:精确率和召回率的调和平均,适合业务上需要同时关注误报和漏报的场合。
  • Recall@某个 Precision 阈值:如果业务上更在意“找出尽可能多的正样本”,可以看召回率。

交叉验证结束后,我会计算每一折指标的平均值和标准差,方差越小说明方案越稳定。

3.2 五折交叉验证的完整循环代码

下面这个代码是我在项目里使用的完整版本,注释里写清楚了每个关键动作的目的。你可以直接复制后,把 X、y 换成自己的数据跑一遍。

import numpy as np import pandas as pd from sklearn.model_selection import StratifiedKFold from sklearn.metrics import roc_auc_score, average_precision_score, f1_score, recall_score, precision_score from xgboost import XGBClassifier # 构造模拟数据:2000 个样本,10 个特征,10% 正类 rng = np.random.default_rng(42) n_samples = 2000 n_features = 10 X = rng.normal(size=(n_samples, n_features)) y = np.where(rng.random(n_samples) < 0.10, 1, 0) # 基础参数:除了 scale_pos_weight 动态设置,其余用相对稳定的默认值或调优值 base_params = { 'learning_rate': 0.05, 'max_depth': 4, 'n_estimators': 200, 'subsample': 0.8, 'colsample_bytree': 0.8, 'eval_metric': 'auc', 'early_stopping_rounds': 20, 'random_state': 42, } skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) # 记录每一折的指标 fold_metrics = { 'auc': [], 'pr_auc': [], 'f1': [], 'recall': [], 'precision': [], } # 额外记录每一折计算的权重,便于后续观察 fold_weights = [] for fold, (train_idx, val_idx) in enumerate(skf.split(X, y), 1): X_train, X_val = X[train_idx], X[val_idx] y_train, y_val = y[train_idx], y[val_idx] # 计算当前训练集的正负数量 pos_count = int(np.sum(y_train == 1)) neg_count = int(np.sum(y_train == 0)) scale_pos_weight = neg_count / pos_count fold_weights.append(scale_pos_weight) # 动态传入 scale_pos_weight params = base_params.copy() params['scale_pos_weight'] = scale_pos_weight model = XGBClassifier(**params) model.fit( X_train, y_train, eval_set=[(X_val, y_val)], verbose=False ) # 在未加权的验证集上预测概率 pred_proba = model.predict_proba(X_val)[:, 1] # 计算指标(默认阈值 0.5,你可以根据业务需求调整) y_pred = (pred_proba >= 0.5).astype(int) fold_metrics['auc'].append(roc_auc_score(y_val, pred_proba)) fold_metrics['pr_auc'].append(average_precision_score(y_val, pred_proba)) fold_metrics['f1'].append(f1_score(y_val, y_pred)) fold_metrics['recall'].append(recall_score(y_val, y_pred)) fold_metrics['precision'].append(precision_score(y_val, y_pred)) print(f'Fold {fold} | scale_pos_weight={scale_pos_weight:.2f} ' f'| AUC={fold_metrics["auc"][-1]:.4f} ' f'| PR-AUC={fold_metrics["pr_auc"][-1]:.4f} ' f'| F1={fold_metrics["f1"][-1]:.4f}') # 展示每折权重及指标汇总 print('\n各折权重:', [round(w, 2) for w in fold_weights]) for metric in fold_metrics: values = fold_metrics[metric] print(f'{metric}: 均值={np.mean(values):.4f} 标准差={np.std(values):.4f}')

这段代码跑下来,你会看到每一折的 scale_pos_weight 可能有细微差别。在我构造的这份数据里,5 折权重大约在 8.8 到 9.4 之间波动。如果样本量更小、正样本比例更极端,波动会更明显,动态权重的价值就更大。

3.3 参数细节与轻量级调优建议

我在实际使用中还注意了几个细节:

第一,early_stopping_rounds 必须配合 eval_set 使用。我在 eval_set 里放的是验证集,这样训练过程中会自动监控验证集 AUC,当连续 20 轮没有提升时停止训练,防止过拟合。但要注意,eval_metric 我设为 'auc',如果换成 'logloss' 也可以,不过 AUC 对不平衡更友好。

第二,scale_pos_weight 的计算不要用整数除法。Python 3 里 neg_count / pos_count 是浮点数,但如果写成 neg_count // pos_count 就会得到整数,可能直接变成 8 而不是 8.913,差别不小。另外,如果 pos_count 为 0,程序会报除以零错误。理论上 StratifiedKFold 保证每一折都有正类,但当正样本总数少于折数时,n_splits 必须小于等于最小类别样本数,否则 sklearn 会直接抛错。实际项目中,如果正样本非常少,比如少于 10 个,我会建议放弃交叉验证,改用留一验证或者直接训练后在独立测试集上评估。

第三,权重设得过高容易导致训练不稳定。有些教程会建议 scale_pos_weight 设置为负正比,但也有人建议用这个比例的开方,或者设置一个上限。我自己测试下来,如果比例超过 50,模型会开始把大量样本预测为正类,召回率飙升但精确率跌得很惨。这时候要结合业务目标来决定。比如风控场景里,你可能宁可精确率高点,减少人工审核成本;而疾病筛查场景里,你可能更看重召回率,宁愿多召回一些假阳性。

第四,不要忘了固定 random_state。StratifiedKFold 的 shuffle 参数设为 True 后,如果不固定随机种子,每次运行切分结果都不一样,动态权重也会跟着变,最终指标均值会有波动,不利于调参对比。我习惯固定 random_state=42,实验可复现。

3.4 在 LightGBM 中的迁移方法

不少项目里用的是 LightGBM,它同样支持 scale_pos_weight 参数,整合方式完全一样,只需要把模型类替换掉即可。在 LightGBM 中,scale_pos_weight 的默认值是 1.0,含义与 XGBoost 一致。另外还有一个 is_unbalance 参数,等价于把 scale_pos_weight 自动设为正负样本比,但我个人更推荐手动计算并传入 scale_pos_weight,这样能清晰地知道每一折用了多大的权重。

如果用的是原生 LightGBM 接口,也就是 lgb.train,需要在 params 里添加 scale_pos_weight 字段。代码长这样:

import lightgbm as lgb params = { 'objective': 'binary', 'metric': 'auc', 'learning_rate': 0.05, 'max_depth': 4, 'scale_pos_weight': scale_pos_weight, # 当前折计算值 } train_data = lgb.Dataset(X_train, label=y_train) val_data = lgb.Dataset(X_val, label=y_val, reference=train_data) model = lgb.train(params, train_data, num_boost_round=200, valid_sets=[val_data])

核心逻辑没有变化。还有一点,如果你用的是其他库,比如 CatBoost,它也有类似的 class_weights 参数,可以在 fit 时传入 sample_weight 或者 class_weights,思路都是同一个:动态计算当前训练集的类别权重,在手动交叉验证循环里逐折传入。

4. 常见问题与排查技巧实录

4.1 加入动态权重后效果反而变差?先检查这四件事

第一种可能:你的数据里正样本和负样本在特征空间里本来就有大量重叠,权重再大也无法区分,此时动态权重只是放大了噪声。建议先画一下特征分布,或者跑一个不加权重的基线,如果基线 AUC 本来就不到 0.6,问题大概率在特征质量,而不是权重。

第二种可能:评估指标选错了。如果业务目标是尽可能找到所有正样本,但你看的是准确率,那权重带来的“多预测为正类”反而会被视为坏结果。应该切换到召回率、F1 或 PR-AUC 上观察。

第三种可能:scale_pos_weight 设得过大。动态计算出来的比例如果超过 50,建议设一个上限,比如 30,或用对数压缩。我试过在一个极端稀疏的数据集里,当权重从 90 降到 30 后,F1 反而从 0.12 涨到了 0.31。

第四种可能:每一折的权重变化太大,导致模型差异大。如果两折之间的权重相差数倍,说明你的 StratifiedKFold 切分里,某一折恰好把少数特殊正样本全分进去了。这时要检查数据是否存在批次效应,或者考虑使用 StratifiedGroupKFold 按组分层,保证同一组的样本不会散落到两折。

4.2 训练集指标虚高,验证集指标难看,这正常吗?

正常,甚至可以说是必然。训练集里由于加了 scale_pos_weight,模型会在训练过程中更努力地拟合正样本,导致训练集上的 AUC 或 F1 虚高。验证集不加权,模型又没见过这些样本,所以指标会下降。如果下降幅度很大,不一定是整合方案有问题,先排查是否过拟合。

一个有用的技巧是观察每一折的验证集 AUC 的标准差。如果 5 折 AUC 标准差小于 0.02,说明模型稳定;如果大于 0.05,说明某几折的正样本特征分布差异太大,模型不稳定。这时候可以把每一折的正样本数量、权重打出来,排查是否存在数据切分异常。

还有一点:验证集上的预测阈值不要直接沿用默认的 0.5。因为模型在训练时被权重影响了,输出概率的分布整体向右偏移,0.5 阈值并不一定最优。建议在每一折验证集上单独寻找最佳阈值,或者使用所有验证集预测概率合并后一起找阈值。我一般用 PR 曲线的平衡点附近选择阈值。

4.3 StratifiedKFold 报错或者权重异常如何处理

最常见的是如下报错:

ValueError: The least populated class in y has only X members, which is too low. The number of folds cannot be greater than the number of members in each class.

意思是正样本数量太少,少于你设定的折数。解决办法很简单:减少 n_splits,比如从 5 改为 3;或者换用 RepeatedStratifiedKFold 做多次重复采样,但每次重复里的折数也要小于最少类别样本数。

另一个常见问题是,print 出来某一折的 scale_pos_weight 是 nan 或 inf。这通常是因为某一折的正样本数为 0。虽然 StratifiedKFold 理论上会避免这种情况,但在某些自定义分层标签或者使用多列组合分层时可能会出现。可以在计算前加一行保护:

if pos_count == 0: scale_pos_weight = 1.0 # 或者直接跳过该折

还有一个容易被忽略的细节:如果你的数据里标签不是 0/1,比如是 'yes'/'no',或者正类不是 1,那么你的计数逻辑要调整。建议统一把目标标签转为 0/1 后再计算,避免出现“正负颠倒”的情况。

4.4 多分类和不平衡回归场景怎么办

scale_pos_weight 是针对二分类设计的,但多分类里有一种类似思路:给每个类别设置不同的权重,然后传入 sample_weight,或者在 XGBoost 里使用 scale_pos_weight 对每个类别都生效但不推荐。多分类更常用的做法是在模型参数里设置 weight 列表,长度等于类别数。在交叉验证循环里,可以动态计算每个类别在当前训练集中的占比,反向得到权重列表,然后通过 sample_weight 传给 fit 方法。

至于不平衡回归问题,scale_pos_weight 就没法直接用了。此时可以通过权重化损失函数来实现,例如在自定义目标函数里对少数类样本的误差乘以更大的系数。但在实际业务中,我更推荐先把回归问题转化为分类问题,比如预测“流失概率是否高于某个阈值”,这样就能继续用这一整套交叉验证方案。

我给自己的项目做了一点扩展:把动态权重的计算封装成了一个很小的工具函数,传入训练标签数组就能返回各类别权重,再配合手动交叉验证循环使用。这样不仅 XGBoost 能用,LightGBM、CatBoost 也能复用,代码整洁很多。你也完全可以这么干。

最后再分享一个小技巧:在打印每一折的 scale_pos_weight 之后,你可以顺手把它和验证集 AUC 的对应关系画成散点图。如果发现 AUC 随着权重增大而单调上升或下降,说明你的训练集和验证集分布之间可能存在持续的偏移,这时候与其纠结权重,不如回头处理特征分布或数据时效性问题。权重只能平衡数量,不能平衡质量。这是我多次踩坑之后最深的体会。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询