构建 LightGBM 二分类模型,数据存在类别不平衡(正样本率 < 30%),模型排序能力正常(AUC 合理),但预测概率系统性偏高——预测均值远大于实际正样本率,整体偏差显著。

诊断方法:分箱校准统计

按固定步长(如 0.05)对预测概率分箱,统计每箱实际正样本率,观察偏差形态:

偏差 = 实际正样本率 – 预测均值

正值 → 模型低估

负值 → 模型高估(本案例的表现)

偏差特征:驼峰形分布

  • 两端偏差小:低概率区和高概率区偏差较小
  • 中间偏差大:65~0.70 区间偏差最大
  • 整体偏高:所有分箱的预测均值都大于实际正样本率

根因分析

定位过程

  • 第一步:排除数据泄露。检查 train/val/test 划分——使用分层抽样,各集正样本率一致,排除数据分布不一致。
  • 第二步:排除特征问题检查特征工程——无目标编码泄露,无未来信息。
  • 第三步:锁定超参数。查看 LightGBM 配置,发现关键参数:
lgb.LGBMClassifier(
    objective="binary",
    is_unbalance=True,    # ← 问题根源
    ...
)

is_unbalance 的数学原理

is_unbalance=True 让 LightGBM 自动将正样本权重设为 负样本数 / 正样本数:

假设正样本率 = 14% → 负正比 = 86/14 ≈ 6.2 → 每个正样本的权重 = 6.2

等价于在 logit 空间整体偏移:

原始 logit:    logit(p) = log(p / (1-p))
             对 p=0.14: logit(0.14) = log(0.14/0.86) ≈ -1.80

加权后 logit:  logit(p) + log(scale_pos_weight)
             = -1.80 + log(6.2) = -1.80 + 1.82 = +0.02

反向变换:     sigmoid(0.02) ≈ 0.505

左:Sigmoid 曲线偏移(真实概率14%被预测为~50%) 右:导数曲线(解释驼峰形)

真实概率 14% 的样本被预测为 ~50%,与观察到的预测均值偏高完全吻合。

为什么偏差呈”驼峰形”

logit 偏移是加性偏移(在 logit 空间加一个常数),但 sigmoid 变换是非线性的:

sigmoid 的导数 = p(1-p)

- p 接近 0 或 1 时,导数→0,logit 偏移对概率影响小 → 两端偏差小
- p 接近 0.5 时,导数最大=0.25,logit 偏移对概率影响最大 → 中间偏差大

这完美解释了”驼峰形”偏差——中间区域 sigmoid 最敏感,偏移被放大;两端 sigmoid 饱和,偏移被压缩。

is_unbalance vs scale_pos_weight

参数 行为 对概率校准的影响
is_unbalance=True 自动设权重 = 负/正比 严重偏移 概率不可信
scale_pos_weight=负/正比 手动设相同值 完全相同的偏移
scale_pos_weight=1.0 不加权 自然校准(但可能欠采样正样本)
scale_pos_weight=1~5(Optuna调参) 在排序和校准间找平衡 适度偏移 可被后校准修正

关键认知:is_unbalance 和 scale_pos_weight 本质相同,都是改变正样本权重。区别在于 is_unbalance 自动设为负/正比(极端值),而 scale_pos_weight 可以手动控制。

三层修复方案

修复层1:根因修复——移除 is_unbalance

# 修复前
lgb.LGBMClassifier(
    objective="binary",
    is_unbalance=True,     # ← 移除
)

# 修复后
lgb.LGBMClassifier(
    objective="binary",
    # is_unbalance 已移除,scale_pos_weight 交给 Optuna 调参
)

同时在 Optuna 搜索空间新增 scale_pos_weight:

"scale_pos_weight": trial.suggest_float("scale_pos_weight", 1.0, 5.0),

设计意图:让 Optuna 在 AUC(排序能力)和概率校准之间自动找平衡。scale_pos_weight=1.0 最自然校准但可能排序略差,5.0 接近原 is_unbalance 效果但排序更好。

修复层2:后校准层——Isotonic Regression

from sklearn.isotonic import IsotonicRegression

# 在 val 集上拟合校准器
calibrator = IsotonicRegression(
    out_of_bounds="clip",  # 新数据超出范围时截断到[0,1]
    y_min=0, y_max=1,
    increasing=True,        # 保证单调递增(保序)
)
calibrator.fit(y_val_proba, y_val)  # val集原始概率 → 真实标签

# 预测时应用
y_proba_calibrated = calibrator.predict(y_proba_raw)
校准方法 原理 参数数 适用场景 选择理由
Isotonic Regression 非参数化保序回归 数据驱动 大数据量(>10万),任意单调形变 能修正驼峰形
Platt Scaling Logistic Regression 2个(a,b) S型偏移 无法修驼峰

关键约束:校准器必须在 val 集上拟合,不能在 test 集上拟合(否则数据泄露)。

修复层3:验证对比——校准前后完整评估

校准前后 Reliability Diagram 对比——校准后近乎完美贴合对角线

验证逻辑:

  • AUC/KS 校准前后应基本不变(保序回归不改变排序)
  • Brier/LogLoss/ECE 应显著下降(校准改善概率精度)
  • 预测均值应收敛到实际正样本率

完整代码实现

分箱校准统计函数

import numpy as np
import pandas as pd
from sklearn.metrics import roc_auc_score, log_loss, brier_score_loss


def calibration_bin_statistics(y_true, y_proba, bin_width=0.05):
    """
    按固定步长对预测概率分箱,统计每箱实际正样本率。

    Returns
    -------
    pd.DataFrame, 各分箱统计表 + ECE + 最大偏差箱信息
    """
    y_true = np.asarray(y_true).astype(int)
    y_proba = np.asarray(y_proba).astype(float)

    bin_edges = np.arange(0, 1 + bin_width, bin_width)
    bin_indices = np.digitize(y_proba, bin_edges, right=False) - 1
    bin_indices = np.clip(bin_indices, 0, len(bin_edges) - 2)

    records = []
    for i in range(len(bin_edges) - 1):
        lower, upper = bin_edges[i], bin_edges[i + 1]
        mask = bin_indices == i
        count = mask.sum()

        if count > 0:
            pred_mean = y_proba[mask].mean()
            actual_rate = y_true[mask].mean()
            pos_count = int(y_true[mask].sum())
            gap = actual_rate - pred_mean
        else:
            pred_mean = actual_rate = gap = np.nan
            pos_count = 0

        records.append({
            "bin_label": f"{lower:.2f}~{upper:.2f}",
            "sample_count": count,
            "sample_pct": count / len(y_true),
            "pred_mean": pred_mean,
            "actual_rate": actual_rate,
            "gap": gap,
            "abs_gap": abs(gap) if not np.isnan(gap) else np.nan,
            "positive_count": pos_count,
        })

    df = pd.DataFrame(records)

    valid = df.dropna(subset=["abs_gap"])
    ece = (valid["abs_gap"] * valid["sample_pct"]).sum() if len(valid) > 0 else np.nan

    if len(valid) > 0:
        max_gap_row = valid.loc[valid["abs_gap"].idxmax()]
        max_gap_info = (max_gap_row["bin_label"], max_gap_row["pred_mean"],
                        max_gap_row["actual_rate"], max_gap_row["gap"])
    else:
        max_gap_info = None

    return df, ece, max_gap_info

概率校准器实现

from sklearn.isotonic import IsotonicRegression
from sklearn.linear_model import LogisticRegression


def fit_calibrator(y_val_proba, y_val, method="isotonic"):
    """在 val 集上拟合概率校准器。"""
    if method == "none":
        return None

    if method == "isotonic":
        calibrator = IsotonicRegression(
            out_of_bounds="clip", y_min=0, y_max=1, increasing=True,
        )
        calibrator.fit(y_val_proba, y_val)

    elif method == "platt":
        calibrator = LogisticRegression(C=1e10, solver="lbfgs")
        calibrator.fit(y_val_proba.reshape(-1, 1), y_val)

    else:
        raise ValueError(f"未知校准方法: {method}")

    return calibrator


def calibrate_proba(y_proba_raw, calibrator):
    """应用校准器到模型预测概率。"""
    if calibrator is None:
        return y_proba_raw
    if isinstance(calibrator, IsotonicRegression):
        return calibrator.predict(y_proba_raw)
    elif isinstance(calibrator, LogisticRegression):
        return calibrator.predict_proba(y_proba_raw.reshape(-1, 1))[:, 1]
    return y_proba_raw

校准前后完整评估

from sklearn.metrics import roc_auc_score, log_loss, brier_score_loss, roc_curve


def compute_ks(y_true, y_proba):
    """计算 KS 统计量。"""
    fpr, tpr, _ = roc_curve(y_true, y_proba)
    return max(tpr - fpr)


def evaluate_calibration(model, calibrator, X_test, y_test):
    """在测试集上输出校准前/后完整对比评估。"""
    y_proba_raw = model.predict_proba(X_test)[:, 1]
    y_proba_cal = calibrate_proba(y_proba_raw, calibrator)

    auc_raw = roc_auc_score(y_test, y_proba_raw)
    auc_cal = roc_auc_score(y_test, y_proba_cal)
    ks_raw = compute_ks(y_test, y_proba_raw)
    ks_cal = compute_ks(y_test, y_proba_cal)
    brier_raw = brier_score_loss(y_test, y_proba_raw)
    brier_cal = brier_score_loss(y_test, y_proba_cal)
    logloss_raw = log_loss(y_test, y_proba_raw)
    logloss_cal = log_loss(y_test, y_proba_cal)

    print(f"  {'指标':<12} {'校准前':>12} {'校准后':>12} {'变化':>10}")
    print(f"  AUC:     {auc_raw:.6f} → {auc_cal:.6f}  (排序能力)")
    print(f"  Brier:   {brier_raw:.6f} → {brier_cal:.6f}  (↓=改善)")
    print(f"  LogLoss: {logloss_raw:.6f} → {logloss_cal:.6f}  (↓=改善)")

    # 分箱校准统计
    df_raw, ece_raw, _ = calibration_bin_statistics(y_test, y_proba_raw)
    df_cal, ece_cal, _ = calibration_bin_statistics(y_test, y_proba_cal)

    print(f"  ECE:     {ece_raw:.4f} → {ece_cal:.4f}  (改善 {(ece_raw-ece_cal)/ece_raw*100:.1f}%)")

    return {
        "auc_raw": auc_raw, "auc_cal": auc_cal,
        "ece_raw": ece_raw, "ece_cal": ece_cal,
    }

端到端完整示例

"""
端到端示例:LightGBM 二分类 + 概率校准
流程:生成模拟数据 → 三方划分 → 训练 → 校准 → 评估
"""
import numpy as np
import lightgbm as lgb
from sklearn.model_selection import train_test_split
from sklearn.isotonic import IsotonicRegression
from sklearn.metrics import roc_auc_score, brier_score_loss

# 1. 生成模拟数据(类别不平衡,正样本率约15%)
np.random.seed(42)
n = 100_000
X = np.random.randn(n, 10)
logit = (X[:, 0] * 1.5 + X[:, 1] * 1.0 + X[:, 2] * 0.5 +
         np.sin(X[:, 3]) * 0.8 + np.random.randn(n) * 0.3)
y = (1 / (1 + np.exp(-logit)) > 0.85).astype(int)

# 2. 三方划分 train(64%)/val(16%)/test(20%)
X_temp, X_test, y_temp, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y)
X_train, X_val, y_train, y_val = train_test_split(
    X_temp, y_temp, test_size=0.2, random_state=42, stratify=y_temp)

# 3. 场景1: is_unbalance=True(有问题)
model_bad = lgb.LGBMClassifier(
    objective="binary", is_unbalance=True,  # ← 问题根源
    n_estimators=200, learning_rate=0.05, num_leaves=31,
    random_state=42, verbosity=-1)
model_bad.fit(X_train, y_train, eval_set=[(X_val, y_val)],
              callbacks=[lgb.log_evaluation(0)])

# 4. 场景2: 修复方案(移除is_unbalance + Isotonic校准)
model_fixed = lgb.LGBMClassifier(
    objective="binary", scale_pos_weight=1.0,  # 不加权
    n_estimators=200, learning_rate=0.05, num_leaves=31,
    random_state=42, verbosity=-1)
model_fixed.fit(X_train, y_train, eval_set=[(X_val, y_val)],
                callbacks=[lgb.log_evaluation(0)])

# 在 val 集上拟合校准器
y_val_proba = model_fixed.predict_proba(X_val)[:, 1]
calibrator = IsotonicRegression(
    out_of_bounds="clip", y_min=0, y_max=1, increasing=True)
calibrator.fit(y_val_proba, y_val)

# 校准后预测
y_proba_raw = model_fixed.predict_proba(X_test)[:, 1]
y_proba_cal = calibrator.predict(y_proba_raw)

# 5. 指标对比
print(f"正样本率:      {y_test.mean():.4f}")
print(f"is_unbalance   预测均值={y_proba_bad.mean():.4f}  偏差={y_test.mean()-y_proba_bad.mean():+.4f}")
print(f"修复-校准前     预测均值={y_proba_raw.mean():.4f}  偏差={y_test.mean()-y_proba_raw.mean():+.4f}")
print(f"修复-校准后     预测均值={y_proba_cal.mean():.4f}  偏差={y_test.mean()-y_proba_cal.mean():+.4f}")
print(f"AUC:  is_unbalance={roc_auc_score(y_test, y_proba_bad):.6f}  校准后={roc_auc_score(y_test, y_proba_cal):.6f}")
print(f"Brier: is_unbalance={brier_score_loss(y_test, y_proba_bad):.6f}  校准后={brier_score_loss(y_test, y_proba_cal):.6f}")

模型保存与加载(含校准器)

import pickle


def save_model_with_calibrator(model, calibrator, filepath, meta=None):
    """序列化模型 + 校准器 + 元信息。"""
    artifact = {"model": model, "calibrator": calibrator, "meta": meta or {}}
    with open(filepath, "wb") as f:
        pickle.dump(artifact, f)


def load_model_with_calibrator(filepath):
    """加载模型 + 校准器。"""
    with open(filepath, "rb") as f:
        artifact = pickle.load(f)
    return artifact["model"], artifact["calibrator"], artifact["meta"]

# 使用:
# save_model_with_calibrator(model, calibrator, "model.pkl",
#                            meta={"features": feature_cols})
# model, calibrator, meta = load_model_with_calibrator("model.pkl")
# y_proba = calibrate_proba(model.predict_proba(X)[:, 1], calibrator)

经验总结

什么时候需要概率校准

使用场景 是否需要校准 原因
仅用排序(如 Top-K 筛选) 不需要 排序不变,概率绝对值不影响排序结果
概率阈值决策(如 P>0.5 判正) 需要 阈值依赖概率绝对值,偏移导致误判
概率作为下游模型输入 需要 下游模型依赖准确概率,偏移会传播
概率用于业务决策(如金额=概率×收益) 需要 直接影响业务金额计算

诊断流程

常见误区

误区 事实
“AUC 高就行,概率准不准无所谓” 如果概率用于阈值决策或下游计算,AUC 高不等于概率可用
“is_unbalance 和 scale_pos_weight 效果不同” 本质相同,都是改正样本权重,只是取值不同
“用了 class_weight=’balanced’ 就不用校准了” class_weight 也会导致同样的 logit 偏移
“校准会降低模型性能” 保序回归不改变排序,AUC/KS 不变,只改善概率精度
“Platt Scaling 和 Isotonic 效果一样” Platt 仅2参数,只能修S型偏移;Isotonic 非参数,能修任意单调形变

适用范围与限制

适用

  • LightGBM / XGBoost / CatBoost 二分类
  • 正样本率 < 30% 的类别不平衡场景
  • 使用了is_unbalance / class_weight=’balanced’ / 高 scale_pos_weight 的模型
  • 预测概率用于阈值决策或下游金额计算

不适用

  • 多分类问题(需用 Temperature Scaling)
  • 正样本率接近 50% 的平衡数据(无需校准)
  • 仅用于排序、不关心概率绝对值的场景
  • 深度学习模型(需用 Platt Scaling / Temperature Scaling,Isotonic 容易过拟合)

注意事项

  • 校准器必须在 val 集上拟合,不能在 test 集上拟合(数据泄露)
  • 校准器需要定期更新——数据分布漂移后,校准映射关系会失效
  • 高概率区间样本量少时偏差波动大——属正常统计噪声,不影响主区间结论
  • Isotonic Regression 对小数据集容易过拟合——建议 val 集 > 10 万样本
  • scale_pos_weight 和 is_unbalance 不能同时使用——LightGBM 会报错或行为未定义
0