当前位置:首页>排行榜>微调评测:别只盯着 loss

微调评测:别只盯着 loss

  • 更新时间 2026-09-26 06:42:08
微调评测:别只盯着 loss

标题:微调评测:别只盯着 loss

digest:loss 不等于能用,三层指标+过拟合+遗忘诊断


帮一个团队 review 微调结果,训练 loss 从 2.1 一路降到 0.28,eval loss 也稳定在 0.35,团队信心满满准备上线。我让他们把测试集里 200 条真实客服 query 跑一遍,格式合格率 61%,拒识率(该拒的没拒)18%,其中有一条把"我要退货"回复成了"感谢您的信任,我们会尽快为您办理开户手续"。

loss 0.28,业务指标不及格。

微调工程里一个反复被踩的坑:把 loss 当作评测终点。loss 只回答了一个问题——"模型在训练分布上,对下一个 token 的预测有多准"。它不回答"模型能不能完成任务",不回答"模型有没有把原来的能力弄丢",更不回答"模型能不能部署"。

这篇聊清楚三件事:怎么搭一套三层评测、怎么发现过拟合和灾难性遗忘、怎么在工程上防住它们。文末给一段可以直接在 CPU 上跑通的 pipeline。

一、微调评测的三个层次

评测不是一个指标,是一套分层的信号系统。

第一层:loss 指标层

train loss、eval loss、perplexity。这层的作用只有一个——告诉你训练过程本身有没有崩。loss 不下降说明学习率、数据格式、mask 策略出了问题;loss 下降太快通常意味着 label 泄漏或者数据太简单。

perplexity 稍微强一点,能反映模型对某个 domain 文本的"熟悉程度",但仍然是 token 级别的概率信号。一个能把训练集背下来的模型 perplexity 会非常低,但业务上可能完全不能用。

这层的定位:训练监控,不是评测。

第二层:自动指标层

ROUGE、BLEU、BERTScore、classification accuracy、F1。这些指标和任务挂钩了,但和人类判断之间还是有 gap。

ROUGE 衡量的是 n-gram overlap,一段答案表达同样意思但用词不同,ROUGE 就会低。BLEU 在翻译任务里尚可,在开放生成任务里几乎没用。分类和抽取任务里 accuracy 和 F1 相对可靠,但要小心类别不平衡——90% 都是"正常"样本的场景下,全预测正常也能拿 90% 准确率。

这层的定位:批量、自动、可回归,用来在 CI 里卡门槛。

第三层:任务指标层

你真正关心的业务数字。客服场景里是格式合格率、拒识准确率、意图分类正确率;代码生成场景里是编译通过率、单测通过率;SQL 生成场景里是执行结果一致率。

这一层往往要写 domain-specific 的评测代码,不能靠 open-source metrics 一把梭。写起来烦,但这是决定"能不能上线"的唯一信号。

三层的权重:任务指标 > 自动指标 > loss。但只看任务指标也不行——任务指标出问题时,你要靠 loss 和自动指标定位到底是训练崩了、还是数据问题、还是评测集问题。

二、过拟合的诊断

过拟合在 LLM 微调里的表现,和传统 ML 有几个不一样的地方。

最早的信号:train loss 和 eval loss 分叉

train loss 持续下降,eval loss 开始抬头,这是教科书上的过拟合。SFT 里通常发生在第 2-3 个 epoch,尤其是数据量小于 10k 的时候。检测方法很直接:每 N 步同时评估 train 和 eval loss,画一条对比曲线。

更隐蔽的信号:eval loss 还在降,但任务指标变差

模型可能学会了训练集的表面 pattern——比如输出总是以"好的,"开头,或者总是用某种句式——eval loss 因为 pattern 匹配而下降,但泛化能力其实在退化。

唯一的检测手段是:在 held-out set 上定期跑任务指标,不只是 loss。频率上,每个 epoch 至少一次,如果训练集小可以每几百步跑一次。

极端表现:边界外输入 hallucinate 加剧

微调数据都是"标准问题-标准回答",模型学到"看到问题就要回答"。真实场景里用户会问奇奇怪怪的东西,一个健康的微调模型应该在无法处理时拒识或转人工,过拟合的模型会硬编一个格式对但内容错的答案出来。

评测集里必须包含 out-of-distribution 样本,专门测拒识率。

三、防过拟合的工程手段

数据侧

验证集比例通常 5%-10%,绝对数量至少 500 条,太少了指标抖动大到没法用。验证集要和训练集独立采样,不能是训练集里随机抽的——如果数据本身有重复或近似样本,随机抽的验证集会严重高估性能。

数据多样性比数据量更重要。10k 条覆盖 50 种问法的数据,比 100k 条只有 5 种问法的数据要好。可以用 embedding 聚类看一下数据分布,聚类数太少就补数据。

训练侧

Early stopping 是最有效的手段,配合"多 checkpoint 选最优"用。具体做法是每个 epoch 存一个 checkpoint,训练结束后在评测集上跑所有 checkpoint,选任务指标最高的那一个——而不是用最后一个 epoch 的模型。

学习率上,warmup + cosine decay 是稳妥选择。SFT 场景常用 2e-5 到 5e-5,warmup ratio 3%-5%,超过这个范围要么学不进要么容易过拟合。gradient clipping 设 1.0 就行,主要是防训练中期出现的 loss spike。

评估侧

评测集要 freeze 下来,版本化管理。每次跑评测用同一份集合、同一份评测脚本,不然指标不可比。评测集要定期扩充,把线上遇到的 badcase 加进去,但不要把这些 badcase 同时加到训练集里——那就成了作弊。

四、灾难性遗忘:比过拟合更隐蔽

过拟合至少有 eval loss 在报警,灾难性遗忘完全无声无息。

具体表现

用 5000 条客服对话 SFT 了一个 7B 模型,客服任务上准确率 92%。上线之后有用户随口问"帮我写个 Python 排序",模型输出的是一段完全跑不通的伪代码——base model 本来能写对的。

原因:SFT 用的是全参微调,训练目标只有客服对话,其他能力对应的参数被"覆盖"了。数据分布越窄、训练步数越长、学习率越大,遗忘越严重。

检测方法

准备一份"通用能力评测集",覆盖代码、数学、常识、多轮对话几个维度,每个维度 50-100 条。这份集合不参与训练,用来对比 base model 和 fine-tuned model 的表现。

关键是"每次微调都跑,形成回归 baseline"。如果微调后通用能力下降超过 5-10%,要重新评估微调方案。

缓解手段

LoRA/QLoRA 本身就是防遗忘的——base weights 冻结不动,只训 adapter,通用能力天然保留。这也是为什么生产环境里 LoRA 用得比全参多。

如果一定要全参 SFT,加 replay 数据:训练集里混入 5%-20% 的通用数据(比如 alpaca、sharegpt 采样),让模型在学新任务的同时"复习"旧能力。replay 数据比例太低没用,太高又稀释了任务信号,具体值要调。

五、完整可运行示例

一段可以在 CPU 上跑通的评测 pipeline,包含 ROUGE-L 计算、任务指标计算、过拟合曲线分析、EarlyStopping 实现。用 mock 数据,不依赖任何外部库。

from dataclasses import dataclass, fieldfrom typing import Callable, Sequenceimport mathimport random@dataclassclass EvalSample:    query: str    reference: str    prediction: str    intent: str    predicted_intent: str@dataclassclass EvalReport:    rouge_l: float    perplexity_proxy: float    intent_accuracy: float    format_pass_rate: float    refusal_precision: float    n_samples: intdef _lcs_length(a: Sequence[str], b: Sequence[str]) -> int:    m, n = len(a), len(b)    dp = [[0] * (n + 1) for _ in range(m + 1)]    for i in range(1, m + 1):        for j in range(1, n + 1):            if a[i - 1] == b[j - 1]:                dp[i][j] = dp[i - 1][j - 1] + 1            else:                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])    return dp[m][n]def rouge_l(reference: str, prediction: str) -> float:    ref_tokens = reference.split()    pred_tokens = prediction.split()    if not ref_tokens or not pred_tokens:        return 0.0    lcs = _lcs_length(ref_tokens, pred_tokens)    precision = lcs / len(pred_tokens)    recall = lcs / len(ref_tokens)    if precision + recall == 0:        return 0.0    return 2 * precision * recall / (precision + recall)import reFORMAT_PATTERN = re.compile(r"^\[(意图|拒识)\].+")def format_valid(prediction: str) -> bool:    return bool(FORMAT_PATTERN.match(prediction.strip()))def evaluate(samples: list[EvalSample]) -> EvalReport:    if not samples:        raise ValueError("empty eval set")    rouge_scores = [rouge_l(s.reference, s.prediction) for s in samples]    intent_hits = [s.intent == s.predicted_intent for s in samples]    format_hits = [format_valid(s.prediction) for s in samples]    should_refuse = [s for s in samples if s.intent == "REFUSE"]    refused_correctly = [s for s in should_refuse if s.predicted_intent == "REFUSE"]    refusal_prec = (        len(refused_correctly) / len(should_refuse) if should_refuse else 1.0    )    return EvalReport(        rouge_l=sum(rouge_scores) / len(rouge_scores),        perplexity_proxy=math.exp(0.42),        intent_accuracy=sum(intent_hits) / len(intent_hits),        format_pass_rate=sum(format_hits) / len(format_hits),        refusal_precision=refusal_prec,        n_samples=len(samples),    )@dataclassclass TrainStep:    step: int    train_loss: float    eval_loss: float    task_metric: float@dataclassclass OverfitDiagnosis:    diverged_at: int | None    task_metric_peak_step: int    task_metric_peak_value: float    task_regressed: bool    message: strdef diagnose_overfit(    history: list[TrainStep],    divergence_ratio: float = 1.15,    regression_tolerance: float = 0.02,) -> OverfitDiagnosis:    diverged_at: int | None = None    for step in history:        if step.eval_loss > step.train_loss * divergence_ratio:            diverged_at = step.step            break    peak = max(history, key=lambda s: s.task_metric)    last = history[-1]    regressed = (peak.task_metric - last.task_metric) > regression_tolerance    if diverged_at is not None and regressed:        msg = "eval loss 分叉且任务指标从峰值回落,典型过拟合"    elif diverged_at is not None:        msg = "eval loss 已分叉但任务指标还没掉,继续观察下一 checkpoint"    elif regressed:        msg = "loss 曲线正常但任务指标回落,可能学到表面 pattern"    else:        msg = "训练健康,可继续"    return OverfitDiagnosis(        diverged_at=diverged_at,        task_metric_peak_step=peak.step,        task_metric_peak_value=peak.task_metric,        task_regressed=regressed,        message=msg,    )@dataclassclass EarlyStopping:    patience: int    min_delta: float = 1e-3    mode: str = "max"    best_value: float = field(default=-math.inf)    best_step: int = field(default=0)    bad_epochs: int = field(default=0)    should_stop: bool = field(default=False)    def __post_init__(self) -> None:        if self.mode == "min":            self.best_value = math.inf    def update(self, step: int, value: float) -> bool:        improved = (            value > self.best_value + self.min_delta            if self.mode == "max"            else value < self.best_value - self.min_delta        )        if improved:            self.best_value = value            self.best_step = step            self.bad_epochs = 0        else:            self.bad_epochs += 1            if self.bad_epochs >= self.patience:                self.should_stop = True        return self.should_stopdef simulate_training(n_steps: int, seed: int = 7) -> list[TrainStep]:    rng = random.Random(seed)    history: list[TrainStep] = []    for step in range(1, n_steps + 1):        train_loss = max(0.15, 2.0 * math.exp(-step / 40) + rng.uniform(-0.02, 0.02))        base_eval = 2.0 * math.exp(-step / 45)        overfit_bump = max(0.0, (step - 80) * 0.008)        eval_loss = base_eval + overfit_bump + rng.uniform(-0.02, 0.02)        peak_step = 90        task = 0.55 + 0.35 * math.exp(-((step - peak_step) ** 2) / 2500)        history.append(            TrainStep(step=step, train_loss=train_loss, eval_loss=eval_loss, task_metric=task)        )    return historydef build_mock_eval_set() -> list[EvalSample]:    return [        EvalSample(            query="我要退货",            reference="[意图] 退货申请",            prediction="[意图] 退货申请",            intent="RETURN",            predicted_intent="RETURN",        ),        EvalSample(            query="订单什么时候到",            reference="[意图] 物流查询",            prediction="[意图] 物流查询",            intent="LOGISTICS",            predicted_intent="LOGISTICS",        ),        EvalSample(            query="帮我写首诗",            reference="[拒识] 超出服务范围",            prediction="[意图] 内容生成",            intent="REFUSE",            predicted_intent="OTHER",        ),        EvalSample(            query="价格能便宜点吗",            reference="[意图] 议价咨询",            prediction="[意图] 议价咨询",            intent="PRICE",            predicted_intent="PRICE",        ),        EvalSample(            query="今天天气怎么样",            reference="[拒识] 超出服务范围",            prediction="[拒识] 超出服务范围",            intent="REFUSE",            predicted_intent="REFUSE",        ),    ]def pick_best_checkpoint(history: list[TrainStep]) -> TrainStep:    return max(history, key=lambda s: s.task_metric)if __name__ == "__main__":    report = evaluate(build_mock_eval_set())    print("=== 评测报告 ===")    print(f"样本数            : {report.n_samples}")    print(f"ROUGE-L           : {report.rouge_l:.3f}")    print(f"意图准确率        : {report.intent_accuracy:.3f}")    print(f"格式合格率        : {report.format_pass_rate:.3f}")    print(f"拒识精确率        : {report.refusal_precision:.3f}")    history = simulate_training(n_steps=160)    diag = diagnose_overfit(history)    print("\n=== 过拟合诊断 ===")    print(f"eval 分叉步数     : {diag.diverged_at}")    print(f"任务指标峰值步数  : {diag.task_metric_peak_step}")    print(f"任务指标峰值      : {diag.task_metric_peak_value:.3f}")    print(f"是否回落          : {diag.task_regressed}")    print(f"结论              : {diag.message}")    stopper = EarlyStopping(patience=15, min_delta=1e-3, mode="max")    for step in history:        if stopper.update(step.step, step.task_metric):            print(f"\nEarly stop @ step {step.step}, best={stopper.best_value:.3f} @ {stopper.best_step}")            break    best = pick_best_checkpoint(history)    print(f"\n最优 checkpoint   : step={best.step}, task_metric={best.task_metric:.3f}")

跑一遍会看到几件事:mock 评测集在拒识任务上失败("帮我写首诗"被判成 OTHER 而不是 REFUSE),拒识精确率降到 0.5;训练历史里 eval loss 在第 80 步左右开始分叉,任务指标在第 90 步达到峰值然后下滑,early stopping 在 patience 用完时触发;最优 checkpoint 不是最后一步,pick_best_checkpoint 会把它挑出来。

这套骨架换成真实模型时,evaluate 的输入换成模型生成结果,simulate_training 换成 trainer 的 log callback,其他部分不用动。


评测告诉你模型有没有"训好",但训好不等于能上线。上线要回答的问题是:这个 adapter 怎么加载?多个版本怎么灰度?推理服务怎么扛住并发?OOM 了怎么办?回滚流程是什么?

这些不是模型问题,是工程问题——下篇聊。

*《Fine-tuning 工程实战》系列每天更新一篇,下篇预告:*

*07 — 生产部署与版本管理:从 adapter 到推理服务*

#AI编程#大模型开发#微调#FineTuning#模型评测#过拟合#LLM

随机文章