LightGBM 二分类概率校准问题修复实践
构建 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 会报错或行为未定义





