Skip to content

从零搭一套模型评估

本页速览 模型评估不是算几个指标,而是一套围绕业务目标设计的系统方法。本文从评估体系设计出发,给出分类、回归、交叉验证、错误分析、类别不平衡与 LLM 评估的完整代码与工程化实践。

从零搭一套模型评估 ​

先说一句可能让你不舒服的话:模型的离线指标(准确率、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. 与业务目标单调相关吗?——准确率上升 1% 意味着利润上升吗?很多时候并不。
  2. 对数据分布变化敏感吗?——在不平衡数据上 accuracy 会被多数类淹没(详见第六节)。
  3. 方差大吗?——在小测试集上,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)全部预测对的比例类别均衡时
精确率 PrecisionTP/(TP+FP)预测为正的里有多少是对的误报代价高
召回率 RecallTP/(TP+FN)实际为正的里召回多少漏报代价高
F12·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-AUCAP(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 遍。诚实的评估体系会预留一份从未碰过的最终测试集,并在验证集指标饱和后、做一次性的最终确认。详见评估的常见陷阱。

最后是一条总纲:评估体系的价值不取决于指标多高级,而取决于它能否稳定地回答"我的模型上线后,业务会变好吗"。指标是代理,业务才是真实;评估的意义,是让两者之间的缝隙尽可能小、且已知。

十、延伸阅读 ​

参考资料 ​