外观
从零搭一套模型评估
先说一句可能让你不舒服的话:模型的离线指标(准确率、AUC、F1)再漂亮,也不能证明它上线后有用。离线指标是"代理",业务结果才是"真实"——两者之间隔着数据分布、交互反馈、用户行为变化整整三层不确定性。这不是否定离线评估,恰恰相反,正因为代理和真实之间有缝隙,我们才需要把评估本身当成一个工程问题来认真对待。
本文不讲"指标公式大全",而是讲一套评估体系怎么从零搭起来:从评估体系设计、分类与回归的完整代码、交叉验证的正确姿势,到错误分析、类别不平衡、LLM 应用评估和工程化落地。读完你会得到一份可以照抄进自己项目的评估清单。
一、评估体系设计:写代码之前先想清楚四件事
几乎每个失败的 ML 项目都能追溯到同一个起点:指标选错了,或者根本没选。很多人上手就是 accuracy_score,跑出来 98% 皆大欢喜——直到发现模型把 99% 的罕见病病人都漏诊了,或者把所有正常邮件都送进了垃圾箱。所以在写任何评估代码之前,先把下面四件事定下来。
1. 先定业务目标,再选指标
评估的第一原则:业务目标决定指标,指标反过来约束建模。这四层关系可以画成一张金字塔:
业务目标(可量化、可被老板和业务方理解)
▲ 映射
代理指标(离线可计算,例如 AUC、F1)
▲ 约束
评估协议(数据怎么划分、怎么重复实验)
▲ 约束
每个指标的统计性质(方差、对不平衡的敏感性)金字塔自上而下是"为什么",自下而上是"怎么保证"。同一个模型,业务目标不同,该用的指标完全不同:
| 业务场景 | 业务目标的量化形式 | 最该盯的指标 | 一句话直觉 |
|---|---|---|---|
| 垃圾邮件过滤 | 误杀一封正常邮件的代价 = 失去一个用户 | 精确率 Precision(低 FP) | 宁可漏杀,不可误杀 |
| 疾病筛查 | 漏诊一个病人的代价 = 延误治疗 | 召回率 Recall(低 FN) | 宁可多查,不可漏检 |
| 信贷风控 | 坏账率、通过率、收益率三者的平衡 | AUC / KS + 业务分层统计 | 排序能力决定放贷策略 |
| 推荐排序 | 点击率、转化率、曝光利用率 | NDCG@k、MAP | 排在前面的才重要 |
| 价格预测 | 大偏差导致亏损、小偏差无伤大雅 | MAE 或 MAPE(非 MSE) | 别让个别离群点绑架模型 |
这里最容易犯的错是把分类指标(accuracy)硬套到排序场景、把回归指标(MSE)硬套到有大额离群点的业务。选指标时问自己三个问题:
- 与业务目标单调相关吗?——准确率上升 1% 意味着利润上升吗?很多时候并不。
- 对数据分布变化敏感吗?——在不平衡数据上 accuracy 会被多数类淹没(详见第六节)。
- 方差大吗?——在小测试集上,AUC 的置信区间可能宽到让你怀疑人生。
2. 定基线:没有基线的指标没有意义
"准确率 92%"是好是坏?没有参照系就无从判断。在投入任何复杂模型之前,至少打三个基线:
| 基线类型 | 做法 | 用处 |
|---|---|---|
| 多数类/随机基线 | 全部预测多数类,或用均匀随机预测 | 数据本身的"难度地板" |
| 启发式基线 | 上一条数据、按规则猜测、用均值预测 | 判断 ML 到底带来了多少增量 |
| 轻量模型基线 | 逻辑回归、单棵决策树 | 判断复杂模型的收益是否配得上成本 |
基线不丢人,它是评估体系的度量原点。一个经验法则:如果复杂模型只比多数类基线高 2 个点,先怀疑评估流程,再怀疑模型。
3. 定对比协议:让所有实验可复现、可比对
评估不是跑一次就完事,而是一个持续多轮的流程。没有协议,第二轮实验和第一轮就无法比较。一个最小可用协议包含五条:
- 固定一份测试集,从建立第一天就锁死,任何人都不能为了"更好看"去改动它;
- 固定随机种子,模型、划分、采样全部固定,保证差异来自方法而非运气;
- 多次重复实验,报均值 ± 标准差,而不是只报单次结果;
- 记录数据版本与代码版本,这是"这次的提升到底是改数据还是改模型带来的"的唯一答案;
- 评估脚本与训练脚本同仓提交,评估代码永远不与模型分离。
测试集污染是评估第一大事故
凡是"看过测试集、调过测试集、拿测试集反推特征、用测试集早停"的行为,都是在制造一个假模型——它在纸上很强,上线即崩。专业的做法是测试集一次都不碰,只在验证集上迭代,最后用测试集做一次性的最终确认。这是模型评估与验证一节反复强调的纪律。
二、分类任务评估:从混淆矩阵到阈值选择
先造一份带标签的数据,本文所有分类代码都可以直接跑。我们用一个 9:1 不平衡的二分类数据集——不刻意做平衡,因为这正是第六节要处理的问题。
python
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import (
confusion_matrix, classification_report,
roc_curve, roc_auc_score,
precision_recall_curve, average_precision_score,
f1_score, precision_score, recall_score,
)
# 生成 5000 条样本、20 维特征,正类占比 10%
X, y = make_classification(
n_samples=5000, n_features=20, n_informative=12,
n_redundant=4, weights=[0.9, 0.1], random_state=42,
)
# 分层划分:保证训练/测试里正类比例一致
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, stratify=y, random_state=42,
)
model = RandomForestClassifier(n_estimators=200, random_state=42)
model.fit(X_train, y_train)
y_prob = model.predict_proba(X_test)[:, 1] # 正类的预测概率
y_pred = model.predict(X_test) # 默认阈值 0.5 的硬分类1. 混淆矩阵是一切分类指标的源头
四格表里藏着所有信息:
预测为正(Pred+) 预测为负(Pred-)
实际为正(Actual+) TP 真正例 FN 假负例(漏报)
实际为负(Actual-) FP 假正例(误报) TN 真负例python
cm = confusion_matrix(y_test, y_pred)
print("混淆矩阵:\n", cm)
# TN FP
# FN TP
tn, fp, fn, tp = cm.ravel()
print(f"TP={tp} FN={fn} FP={fp} TN={tn}")只看 cm 就能发现 accuracy 看不见的问题:如果正类只占 10%,全部预测为负也能拿到 90% 准确率,但 TP=0。所以第一件事永远是打印混淆矩阵,而不是只打印一个总分。
2. 六个基础指标,一个表格讲清
| 指标 | 公式 | 直觉 | 主用场景 |
|---|---|---|---|
| 准确率 Accuracy | (TP+TN)/(TP+FN+FP+TN) | 全部预测对的比例 | 类别均衡时 |
| 精确率 Precision | TP/(TP+FP) | 预测为正的里有多少是对的 | 误报代价高 |
| 召回率 Recall | TP/(TP+FN) | 实际为正的里召回多少 | 漏报代价高 |
| F1 | 2·P·R/(P+R) | P 与 R 的调和平均 | 两者都重要时 |
| Fβ | (1+β²)·P·R/(β²·P+R) | 给 R 加 β 倍权重 | 偏重某一方 |
| AUC / AP | 见下文 | 排序能力 / 正类平均精确率 | 阈值无关的总体比较 |
python
print(classification_report(y_test, y_pred))
print(f"F1 = {f1_score(y_test, y_pred):.3f}")classification_report 一次给出 P/R/F1 和各类别样本数,是日常迭代的主力工具。
3. 阈值是评估的一部分,不是模型的参数
大多数分类器输出的不是标签而是概率,predict() 只是在 0.5 处切了一刀。业务代价不对称时(比如筛查漏诊远比误报严重),0.5 往往不是最优切点。正确姿势是把阈值当超参来扫描:
python
for threshold in [0.3, 0.4, 0.5, 0.6, 0.7]:
y_t = (y_prob >= threshold).astype(int)
print(f"阈值 {threshold}: Precision={precision_score(y_test, y_t):.3f} "
f"Recall={recall_score(y_test, y_t):.3f} "
f"F1={f1_score(y_test, y_t):.3f}")选择哪个阈值,取决于业务代价表:一个漏诊=多少元,一个误报=多少元,求总代价最小的切点。这就是"把评估从模型内部挪到业务层"的关键一步——评估输出应该是一张"阈值-指标-代价"表,而不是一个标签数组。
4. PR 曲线与 ROC 曲线:画出模型的全谱表现
ROC 看所有阈值下真正率 vs 假正率的折中,PR 曲线看所有阈值下精确率 vs 召回率的折中。AUC 是曲线下面积,衡量的是"排序能力":随机取一个正样本和一个负样本,模型给正样本打更高分的概率。
python
# ROC 曲线与 AUC
fpr, tpr, roc_thresholds = roc_curve(y_test, y_prob)
roc_auc = roc_auc_score(y_test, y_prob)
# PR 曲线与 AP(Average Precision,PR 曲线下面积)
precision, recall, pr_thresholds = precision_recall_curve(y_test, y_prob)
ap = average_precision_score(y_test, y_prob)
print(f"ROC-AUC = {roc_auc:.3f} PR-AUC(AP) = {ap:.3f}")
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4))
ax1.plot(fpr, tpr, label=f"AUC={roc_auc:.3f}")
ax1.plot([0, 1], [0, 1], "k--", label="随机基线")
ax1.set(xlabel="假正率 FPR", ylabel="真正率 TPR", title="ROC 曲线")
ax1.legend()
ax2.plot(recall, precision, label=f"AP={ap:.3f}")
ax2.axhline(y_train.mean(), color="k", ls="--",
label="正类占比基线")
ax2.set(xlabel="召回率 Recall", ylabel="精确率 Precision",
title="PR 曲线")
ax2.legend()
plt.tight_layout()
plt.show()注意 ROC 的随机基线是 y=x 对角线(与正类比例无关),而 PR 曲线的基线是正类占比那条水平线。正类只占 10% 时,PR 曲线的基线是 0.1——这正是第六节"ROC 会骗人"的关键。
5. 把上面全部打包成一个函数
日常迭代需要"一条命令输出完整评估"。把散落代码收拢成可复用的评估器,是评估工程化的第一步:
python
def evaluate_binary_classifier(y_true, y_prob, name="模型"):
"""输出一个二分类器的完整离线评估报告。"""
results = {}
results["ROC-AUC"] = roc_auc_score(y_true, y_prob)
results["AP"] = average_precision_score(y_true, y_prob)
# 扫描阈值,找出最佳 F1 对应的一组 P/R
p, r, th = precision_recall_curve(y_true, y_prob)
f1s = 2 * p * r / (p + r + 1e-9)
best = np.argmax(f1s)
results["best_threshold"] = th[best]
results["best_P"] = p[best]
results["best_R"] = r[best]
results["best_F1"] = f1s[best]
results["confusion_matrix"] = confusion_matrix(
y_true, (y_prob >= results["best_threshold"]).astype(int))
print(f"===== {name} =====")
for k, v in results.items():
if k != "confusion_matrix":
print(f"{k}: {v:.3f}")
print("混淆矩阵(按最佳F1阈值):\n", results["confusion_matrix"])
return results一个清晰的评估函数应该是输出的信息量 > 输入的代码量,并且随时可以挂进回归测试。
三、回归评估:不止 MSE
回归评估表面上简单(一个误差数),但单个误差数恰恰是最大的坑——MSE 会被离群点绑架,R² 会被方差欺骗。做法是:指标打底 + 残差诊断。
1. 三个核心指标 + 一个判断题
| 指标 | 公式 | 性质 | 何时主导 |
|---|---|---|---|
| MSE | (1/n)Σ(yᵢ-ŷᵢ)² | 惩罚大误差(平方放大) | 大误差不可接受 |
| RMSE | √MSE | 与 y 同量纲,直观 | 需要与 y 直接对比 |
| MAE | (1/n)Σ|yᵢ-ŷᵢ| | 对离群点稳健 | 误差分布有厚尾 |
| R² | 1 − SS_res/SS_tot | 相对均值基线的解释度 | 模型间相对比较 |
一个关键判断题:当离群点来自"正常但极端的业务"(大额订单、极端天气)时,用 MAE;当离群点代表"系统故障/脏数据"时,先清洗而不是换指标。
python
from sklearn.datasets import make_regression
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score
X, y = make_regression(n_samples=3000, n_features=10, noise=15, random_state=42)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25, random_state=42)
reg = RandomForestRegressor(n_estimators=200, random_state=42)
reg.fit(X_tr, y_tr)
y_hat = reg.predict(X_te)
mse = mean_squared_error(y_te, y_hat)
rmse = np.sqrt(mse)
mae = mean_absolute_error(y_te, y_hat)
r2 = r2_score(y_te, y_hat)
print(f"MSE={mse:.1f} RMSE={rmse:.1f} MAE={mae:.1f} R²={r2:.3f}")如果 RMSE 明显大于 MAE(比如 2 倍以上),说明误差分布右尾很重——少数样本贡献了大部分误差,这正是下一步残差分析要挖的地方。
2. 残差分析:评估的 X 光机
残差 = 真实值 − 预测值。好的模型残差应当均值为 0、方差稳定、与特征和预测值无关。画四张图就够了:
python
residuals = y_te - y_hat
fig, axes = plt.subplots(2, 2, figsize=(11, 8))
# (1) 残差 vs 预测值:应呈无规律的带状,不应出现喇叭形/弯曲
axes[0, 0].scatter(y_hat, residuals, s=8, alpha=0.5)
axes[0, 0].axhline(0, color="r", lw=1)
axes[0, 0].set(xlabel="预测值", ylabel="残差", title="残差 vs 预测值")
# (2) 残差直方图:应近似正态,中心在 0
axes[0, 1].hist(residuals, bins=50, alpha=0.7)
axes[0, 1].axvline(0, color="r", lw=1)
axes[0, 1].set(xlabel="残差", ylabel="频数", title="残差分布")
# (3) 残差 vs 最重要的特征:检查是否遗漏非线性关系
imp = np.argsort(-reg.feature_importances_)[0]
axes[1, 0].scatter(X_te[:, imp], residuals, s=8, alpha=0.5)
axes[1, 0].axhline(0, color="r", lw=1)
axes[1, 0].set(xlabel=f"最重要特征 #{imp}", ylabel="残差",
title="残差 vs 最重要特征")
# (4) 残差累计占比:看误差集中在多大比例的样本上
axes[1, 1].plot(np.sort(np.abs(residuals))[::-1].cumsum()
/ np.abs(residuals).sum())
axes[1, 1].set(xlabel="按误差降序的样本序号", ylabel="误差累计占比",
title="误差集中度曲线")
plt.tight_layout()
plt.show()四种典型病征和对应的处方:
| 残差病征 | 含义 | 行动 |
|---|---|---|
| 喇叭形(残差随预测值增大而增大) | 异方差,可能漏了对数变换 | 对 y 取 log 再建模 |
| 弯曲/开口的曲线 | 漏了非线性项或特征交互 | 加特征交互、换树模型 |
| 残差均值明显偏离 0 | 系统性偏差(欠拟合或特征泄漏到方向性) | 检查特征,加大容量 |
| 误差集中在少量样本 | 厚尾,少数"大单"主导误差 | 评估 MAE 兜底,单独追这 5% |
R² 高但残差有系统性模式,说明模型"表面准确、内里有偏"。回归评估的精髓不是看一个分数,而是让模型和数据的错位显形。偏差-方差与正则化如何进一步影响误差,见过拟合与正则化。
四、交叉验证的正确用法
单一 train/test 划分的结果有随机性——换一次 seed,AUC 可能从 0.83 跳到 0.87。交叉验证(CV)用多次划分的均值 ± 标准差给出稳定估计,同时把每个样本都用上。
1. StratifiedKFold:分类任务的标准选择
python
from sklearn.model_selection import StratifiedKFold, cross_val_score
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(model, X, y, cv=skf, scoring="roc_auc")
print(f"5 折 ROC-AUC: {scores.mean():.3f} ± {scores.std():.3f}")两个关键点:
- shuffle=True + random_state=42:不洗牌时,如果数据按类别顺序排列,每一折的类别分布会严重失衡;
- Stratified 而不是普通 KFold:保证每折的正负类比例与全量一致,在小数据集、不平衡数据上尤为重要。
cross_val_score 是快速估算,但要拿到每折的预测概率做错误分析,请手动循环:
python
oof = np.zeros(len(X)) # out-of-fold 预测概率
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
for fold, (tr_idx, va_idx) in enumerate(skf.split(X, y)):
clf = RandomForestClassifier(n_estimators=200, random_state=42)
clf.fit(X[tr_idx], y[tr_idx])
oof[va_idx] = clf.predict_proba(X[va_idx])[:, 1]
print(f"OOF AUC = {roc_auc_score(y, oof):.3f}")"out-of-fold 预测"(OOF)是第五节错误分析的黄金数据源:每个样本的预测都来自没见过它的模型,因此可以放心地拿去做错误模式统计。
2. 泄漏(leakage):CV 最大的隐蔽杀手
CV 的统计有效性建立在一个前提上:每一折的训练数据只包含该折训练集的信息。最常见的三种泄漏:
| 泄漏来源 | 例子 | 后果 |
|---|---|---|
| 预处理放在 CV 外 | 先用全量数据做标准化/均值填补,再做 CV | 验证集信息流入训练,指标虚高 |
| 特征选择泄漏 | 在全量数据上选完特征再进 CV | 选中了"泄露未来"的特征 |
| 时间数据随机划分 | 用第 3 天数据训练、第 1 天数据验证 | 模型"看到未来",时序场景失效 |
标准解法是 Pipeline——让每个预处理步骤都在 CV 内部、逐折重新拟合:
python
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
pipe = Pipeline([
("scaler", StandardScaler()), # 每折内重新 fit
("clf", LogisticRegression(max_iter=2000)),
])
scores = cross_val_score(pipe, X, y, cv=skf, scoring="roc_auc")
print(f"Pipeline 5 折 AUC: {scores.mean():.3f} ± {scores.std():.3f}")3. 分组与时间序列:当样本不是独立的时候
如果你的数据里有分组的依赖结构(同一个用户的多条行为、同一批货物的多张订单、同一张图片的多个 patch),普通 KFold 会把同一组的数据拆到训练和验证两边——模型见过"同一个用户"了,评估自然虚高。用 GroupKFold 保证同组样本永远同折:
python
from sklearn.model_selection import GroupKFold
# group 数组:每个样本属于哪个用户/批次
group = np.arange(len(X)) // 10 # 示例:每 10 条样本来自同一用户
gkf = GroupKFold(n_splits=5)
scores = cross_val_score(model, X, y, cv=gkf, groups=group, scoring="roc_auc")
print(f"GroupKFold AUC: {scores.mean():.3f} ± {scores.std():.3f}")时间序列数据用 TimeSeriesSplit(只允许"过去训练、未来验证"),并注意两组原则:训练集永远早于验证集;验证集不得出现训练集未来的信息(例如特征里含"历史均值"时,要逐点滚动计算,不能一揽子算完)。
4. 嵌套 CV:当 CV 同时用于调参和评估
如果你既用 CV 调参(GridSearchCV),又用 CV 报最终指标,第二次的分数会被调参过程污染(验证集被反复看过多遍)。严谨的做法是嵌套 CV:外层 CV 评估泛化能力,内层 CV 负责调参。
python
from sklearn.model_selection import GridSearchCV
param_grid = {"n_estimators": [50, 200], "max_depth": [5, None]}
inner = GridSearchCV(RandomForestClassifier(random_state=42),
param_grid, cv=StratifiedKFold(3, shuffle=True, random_state=1))
outer_scores = cross_val_score(inner, X, y,
cv=StratifiedKFold(5, shuffle=True, random_state=2),
scoring="roc_auc")
print(f"嵌套 CV AUC: {outer_scores.mean():.3f} ± {outer_scores.std():.3f}")嵌套 CV 计算量成倍增加,日常迭代不必每次都用,但在"这个模型到底值多少分"的关键决策点,值得跑一次。小样本场景下 CV 与 bootstrap 的取舍,可参考 Kohavi 的经典研究(见参考资料)。
五、错误分析与迭代循环
评估不是终点,而是下一轮迭代的输入。缺了错误分析,评估就只剩"报个分";有了它,评估才变成"找到模型下一次进步在哪"。
1. 错误分析是什么
一句话:在验证集(或 OOF 预测)上,找出模型错的样本,理解它们为什么错,并据此决定下一步动作。核心工具是"把评估结果变成一个可查询的表格"。
python
# 把测试集变成可分析的 DataFrame
df_results = pd.DataFrame({
"y_true": y_test,
"y_prob": y_prob,
"y_pred": y_pred,
"is_error": y_test != y_pred,
"abs_error": np.abs(y_test - y_pred), # 回归用
"confidence": np.where(
y_prob >= 0.5, y_prob, 1 - y_prob), # 模型置信度
})2. 按错误类型分组统计
有了表格,错误分析就是一系列的 groupby。以下是一套可直接照抄的分组矩阵:
python
# ① 按业务字段分组:哪个渠道/时段/客群错误率最高?
df_results["group"] = X_test[:, 0] > 0 # 示例:按某特征切成两组
group_stats = (df_results.groupby("group")["is_error"]
.agg(错误数="sum", 样本数="count", 错误率="mean"))
print(group_stats)
# ② 按置信度分层:模型"很自信地错"的比例有多少?
df_results["conf_bin"] = pd.cut(df_results["confidence"],
bins=[0, 0.6, 0.8, 0.9, 1.0])
print(df_results.groupby("conf_bin", observed=True)["is_error"]
.agg(错误率="mean", 样本数="count"))
# ③ 错误样本的平均置信度 vs 正确样本
print(df_results.groupby("is_error")["confidence"].mean())三种典型发现和它们指向的行动:
| 分析发现 | 指向的根因 | 行动 |
|---|---|---|
| 某客群/渠道错误率远高于整体 | 该子集特征不足或样本太少 | 为该子集补特征、补数据 |
| 错误样本的置信度都很高 | 模型过度自信 / 校准差 | 用 CalibratedClassifierCV 做概率校准 |
| 错误样本恰好是"稀有子类" | 类别不平衡 / 数据稀疏 | 见第六节的重采样与权重方案 |
| 错误样本有共同的时间段 | 时间相关漂移 | 加时间特征、改用时间切分 |
错误分析的正确打开方式
别只看汇总数字,一定要亲手翻 20~30 条原始错误样本。数字告诉你"哪里错得多",样本告诉你"错得为什么离谱"——比如你以为模型学的是语义,其实它在抄训练集的重复句。这种认知差只有看原始样本才会出现。可解释性工具(SHAP、部分依赖图)能把这种"人工翻样本"变成系统化的归因,见可解释性与公平性。
3. 把分析转成行动:评估-迭代闭环
错误分析产出的是假设,不是结论。每次分析收敛出一张"发现 → 假设 → 行动"表,只做其中影响最大的一项,然后重跑评估:
评估(固定协议)→ 错误分析(分组统计 + 翻样本)
↑ ↓
重评估,对比上一轮 形成假设
↑ ↓
记录:改了数据/特征/模型/阈值 ──→ 执行一个最小改动一套实用的迭代纪律:
- 一次只改一个变量:加特征、换模型、调阈值分开做,否则提升归因不清;
- 每轮迭代都有对比记录:改动内容、验证集指标前后值、是否引入新错误;
- 改动要回到"业务目标"校准:验证集 AUC 涨了 0.01,但线上转化的代理指标没动,说明改进无效;
- 承认"评估饱和":连续两三轮没有像样提升时,停下来做一次大复盘,而不是继续微调。
这个循环的完整骨架,可以对照从零构建一个 ML 项目里的流程落地;迭代中最容易犯的错(拿验证集反复调、指标与业务脱节等),在常见陷阱与反模式里有一份现成的避坑清单。
六、类别不平衡的评估:为什么 PR-AUC 通常优于 ROC
不平衡不是"数据有毛病",而是大部分真实业务的默认状态:欺诈、患病、点击、故障,稀有事件永远占少数。在不平衡下,评估指标的选择直接决定你看到的是真话还是幻觉。
1. ROC 为什么会"骗人"
ROC 的横纵轴是 TPR 和 FPR,两者都是以各自类别为分母的比例——它对称地对待正负类,因此正类只占 1% 还是 50%,ROC 的形状变化很小。而 PR 曲线的纵轴是精确率,精确率的基线就是正类占比:正类占 1% 时,随便猜都有 0.5 的 ROC-AUC,但 PR 基线只有 0.01。
看一个极端但真实的例子(正类占 1%,测试集 10000 条):
| 情况 | ROC-AUC | AP(PR-AUC) |
|---|---|---|
| 模型把所有样本全判为负 | 0.5(等于随机) | 无法定义(没有正预测) |
| 模型抓到 60% 正类、误报 50 条 | 可以到 0.98+ | 约 0.55 |
| 模型抓到 10% 正类、误报 3 条 | 也可能到 0.95 | 约 0.25 |
同一次实验里,ROC-AUC 高得喜人,AP 却低得吓人——因为 ROC 被巨大的负类"稀释"了,PR 曲线则直接反映"你说它是正类时,有多大概率说对"。正类占比越低,ROC 与 PR 的结论越可能分歧。严谨起见建议两者都看,但在业务关心"正类找得准不准"时,以 PR-AUC/AP 为准。理论细节参见 Davis & Goadrich 与 Saito & Rehmsmeier 的论文(参考资料)。
2. 正确的不平衡评估姿势
- 报告 PR-AUC/AP,同时打印混淆矩阵绝对值(1 万条里的 TP/FN/FP 绝对值,比比例更有业务意义);
- 用 Fβ 代替 F1:漏诊代价高就调大 β;
- 按业务阈值报告 P/R 对:不追求"最佳 F1",而是按代价表选出阈值后的 P/R;
- 不要只看 accuracy——它会被 99% 的多数类支配。
3. 缓解手段,按顺序来
先改评估,再改数据,最后才动模型。顺序错了,前面的一切都会失真:
python
# ① 不改数据,先给损失函数加权(模型内部解决方案)
from sklearn.ensemble import RandomForestClassifier
weighted = RandomForestClassifier(n_estimators=200,
class_weight="balanced", random_state=42)
weighted.fit(X_train, y_train)
print("class_weight=balanced 的 AP:",
average_precision_score(y_test, weighted.predict_proba(X_test)[:, 1]))
# ② 过采样少数类(重采样方案,需在交叉验证内部进行)
from imblearn.over_sampling import SMOTE
from imblearn.pipeline import Pipeline as ImbPipeline
smote_pipe = ImbPipeline([
("smote", SMOTE(random_state=42)),
("clf", RandomForestClassifier(n_estimators=200, random_state=42)),
])
smote_scores = cross_val_score(smote_pipe, X, y, cv=skf, scoring="average_precision")
print(f"SMOTE 5 折 AP: {smote_scores.mean():.3f} ± {smote_scores.std():.3f}")重采样绝不能出现在验证流程里
SMOTE 或下采样只能在训练折内进行——如果先对全量数据 SMOTE 再划分,合成的少数类样本会同时出现在训练和验证集,验证指标被自己的"复制品"灌水。上面代码用 imblearn.pipeline.Pipeline 就是为了保证 SMOTE 在 CV 内部逐折执行。
除了重采样,还有收集更多正样本(永远最有效)、把问题改成异常检测/排序(正类少到个位数时)、以及用成本敏感学习(把误报/漏报代价直接写进目标函数)。最后记住:数据本身不平衡不是病,评估方式不对才是病——class_weight 和阈值选择往往比花哨的重采样更有效。
七、LLM 应用评估简介
LLM 应用(RAG、Agent、摘要、代码生成)的评估与经典 ML 有本质区别:输出是开放文本,正确性高度主观,错误不是"对/错"而是"哪部分不对"。但评估体系设计的四个步骤完全适用——只是实现方式不同。本节是速览,完整方案见大语言模型应用评估。
1. 评测集(eval set)的构建
LLM 评估同样需要"训练/开发/测试"的分层,但这里的划分对象不是模型权重,而是提示词和系统设计:
| 集合 | 用途 | 规模建议 |
|---|---|---|
| 开发集 | 迭代提示词、调 RAG 参数 | 50–200 条 |
| 测试集 | 最终验收,尽量贴近真实分布 | 200–1000 条 |
| 金标准集(golden set) | 自动回归,固定不变 | 100–500 条 |
采集渠道优先级:线上真实请求 > 人工构造的典型场景 > 从用户反馈中挖掘的失败样本。一个常见失误是评测集只含"顺利的问题",结果模型连"用户问了个无关问题"都处理不了——评测集必须故意掺入边缘用例(歧义、缺上下文、长尾方言、恶意输入)。
2. 人工评估:唯一可信的尺度
自动化指标至今无法替代人在"这段摘要是否准确、这段回复是否冒犯"上的判断。落地一套可复现的人工评估:
- 定义 评分卡:正确性、忠实度(是否忠于上下文)、有用性、风格/语气,每项 1–5 分并写清锚点;
- 多人打分,计算 一致性(inter-annotator agreement),不一致的样本回炉讨论规则;
- 每次改动同一批样本复评,避免"不同批次、不同标准"的漂移。
人工评估慢而贵,因此它的定位是校准自动指标的标尺,而不是日常迭代的主力。
3. 自动化指标:能省人力,但别迷信
| 类型 | 代表 | 用途 | 局限 |
|---|---|---|---|
| 文本相似度 | BLEU、ROUGE | 摘要、翻译 | 对"语义正确但用词不同"完全失效 |
| 组件级指标 | RAG 检索 Recall@k、命中率 | 拆开评估 RAG 的检索层 | 需要分层评测设计 |
| LLM-as-judge | 用强模型(如 GPT-4)给弱模型打分 | 忠实度、相关性 | 有自我偏好(self-preference)偏差 |
LLM-as-judge 的三个落地要点:给出结构化评分标准(比自由评论稳定得多);做 Judge 一致性校验(对一批样本同时用人和 Judge 打分,相关系数低于阈值就不可信);优先让它评"事实性/相关性"这种有锚点的维度,而不是"文笔好坏"。幻觉、评测偏差等更细的问题,详见大语言模型专题。
4. 分层评估:RAG 应用至少拆三层
一个 RAG 问答系统,端到端指标差时你根本不知道是检索没召回、还是模型没答对。拆开各评一层:
用户问题 ──► 检索层(Recall@k:正确文档召回了没?)
│
▼
生成层(忠实度:答案是否忠于检索到的文档?)
│
▼
交互层(有用性:答案是否解决了用户问题?)每一层用不同的指标、不同的失败模式、不同的优化动作。端到端一个分数的价值,远低于这张三层体检表。
八、评估工程化:让评估成为系统的一部分
评估做一次不难,难的是每一次改动都自动、可复现地评估一遍,并且上线后持续监控。这属于 MLOps 与生产部署的范畴,这里给三个必须做的最小动作。
1. 评估脚本入库,与模型代码同仓
评估不是 Jupyter 里的随手一笔,而是版本化的代码。推荐目录结构:
project/
├── model/ # 训练代码
├── evals/
│ ├── metrics.py # 指标与评估函数(如第二节的 evaluate_binary_classifier)
│ ├── datasets/ # 测试集、金标准集(含版本号)
│ ├── run_eval.py # 一键评估入口,输出 JSON/Markdown 报告
│ └── tests/ # 评估自身的单元测试
└── reports/ # 历次评估报告,可追溯run_eval.py 每次跑完输出一份含指标、数据版本、代码 commit、随机种子、运行时间的报告——这是"上一轮到底改了啥、涨没涨"的唯一证据链。
2. 用金标准集做回归测试
把评估从"人想起来才跑"变成"每次改动自动跑":
- 选 100–500 条金标准样本,跑完评估后断言关键指标不低于阈值(如
assert ap >= 0.40); - 接进 CI:改模型代码、改特征、改数据脚本都会触发评估,指标跌破阈值就 fail 构建;
- 把"只升不降"变成工程约定:上线前的模型必须通过所有金标准断言,且不低于当前线上模型在测试集上的指标。
python
# run_eval.py 的骨架(节选)
def check_regression(results, thresholds):
failures = []
for metric, limit in thresholds.items():
if results[metric] < limit:
failures.append(f"{metric}={results[metric]:.3f} < {limit}")
if failures:
raise RuntimeError("回归测试未通过: " + "; ".join(failures))3. 上线后监控:评估从"一次性"到"持续性"
离线评估的前提是"测试集代表未来",但上线后分布会漂移。至少部署三路监控:
| 监控项 | 手段 | 预警信号 |
|---|---|---|
| 数据漂移 | 特征分布(PSI/KL 散度)逐日对比 | PSI > 0.2 需复查 |
| 标签漂移 | 线上标签/结果分布变化 | 正类率突变 |
| 业务代理指标 | 线上转化率、成功率、用户投诉 | 指标与离线预期背离 |
漂移检测与模型再训练周期的完整做法,见 MLOps 与生产部署。一个原则贯穿始终:线上监控的指标必须与离线评估的指标同源——否则你会同时拥有两套互相矛盾的真相。
九、权衡与取舍
评估体系里没有免费的午餐,每一处严谨都有代价。这些取舍要主动做、写在文档里,而不是被动承受。
- 指标忠实度 vs 迭代速度:嵌套 CV、多次重复实验、千人评分最严谨,但每轮迭代要跑数小时甚至数天。日常迭代用轻量评估(单次 StratifiedKFold + 代理指标),关键决策点(上线、换模型架构)再用重评估。两级评估制是兼顾两者的常见解法。
- 测试集大小 vs 估计精度:测试集越大指标越稳,但能留给训练的数据越少。数据有限时,宁可训练集小一点也要保住测试集的代表性——测试集是"最终裁判",裁判不能被饿着。
- 指标精细 vs 可解释:NDCG、KS、校准误差更精准,但业务方听不懂;accuracy 人人能懂却常常误导。做法是把"业务听得懂的粗指标"和"工程师用的精指标"同时放进报告,而不是二选一。
- 自动化 vs 人工复核:自动化指标便宜但可能失真(LLM 评估尤甚),人工评估可信但昂贵。折中是自动化筛 + 人工抽检:自动化跑全量,按"预测失败/低置信/边界样本"分层抽样交给人工复核。
- 把验证集当测试集反复用的诱惑:迭代 50 轮后,"验证集"其实已经被你看熟了 50 遍。诚实的评估体系会预留一份从未碰过的最终测试集,并在验证集指标饱和后、做一次性的最终确认。详见评估的常见陷阱。
最后是一条总纲:评估体系的价值不取决于指标多高级,而取决于它能否稳定地回答"我的模型上线后,业务会变好吗"。指标是代理,业务才是真实;评估的意义,是让两者之间的缝隙尽可能小、且已知。
十、延伸阅读
- 站内继续:指标背后的理论见模型评估与验证;评估与调参的衔接见超参数调优实践;评估与生产系统的衔接见MLOps 与生产部署;完整项目流程见从零构建一个 ML 项目;易错点汇总见常见陷阱与反模式;LLM 专题见大语言模型。
- 先补基础:本文假设你已了解什么是机器学习;术语可随时查术语表;数据层面(采集、清洗、泄漏源头)见数据工程基础。
参考资料
- scikit-learn: Metrics and scoring: quantifying the quality of predictions —— 全部指标的定义、公式与调用方式,本文代码的官方依据
- scikit-learn: Cross-validation: evaluating estimator performance —— KFold / StratifiedKFold / GroupKFold / TimeSeriesSplit / 嵌套 CV 官方文档
- Hastie, Tibshirani, Friedman. The Elements of Statistical Learning —— 模型评估、偏差-方差与误差估计的权威教材(第 7 章)
- James, Witten, Hastie, Tibshirani. An Introduction to Statistical Learning (ISLR) —— 分类/回归评估与交叉验证的入门级权威教材(第 2、5 章)
- Fawcett. An Introduction to ROC Analysis (Pattern Recognition Letters, 2006) —— ROC 曲线的经典综述
- Davis & Goadrich. The Relationship Between Precision-Recall and ROC Curves (ICML 2006) —— PR 与 ROC 关系的形式化证明,不平衡评估的理论依据
- Saito & Rehmsmeier. The Precision-Recall Plot Is More Informative than the ROC Plot when Evaluating Binary Classifiers on Imbalanced Datasets (PLoS ONE, 2015) —— 不平衡场景下 PR 优于 ROC 的实证研究
- Kohavi. A Study of Cross-Validation and Bootstrap for Accuracy Estimation and Model Selection (IJCAI 1995) —— 交叉验证与 bootstrap 估计的经典对比
- imbalanced-learn 官方文档 —— SMOTE 等重采样方法与 imblearn Pipeline 的官方依据
- Zheng et al. Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena (NeurIPS 2023) —— LLM-as-judge 的偏差分析与校验方法
- Papineni, Roukos, Ward, Zhu. BLEU: a Method for Automatic Evaluation of Machine Translation (ACL 2002) —— BLEU 原始论文
- Lin. ROUGE: A Package for Automatic Evaluation of Summaries (ACL 2004) —— ROUGE 原始论文