分类微调:换头


分类微调:换头

本节摘要:预训练语言模型是自注意力块的堆叠,末端接一个 token 预测头。当你想做垃圾短信识别时,头错了,但主体大致没错。本节拆掉 LM 头,在池化表示上粘一个两类线性层,用两种方式训练分类器:只调最后一层(冻体)、全参数微调,共用一个训练循环。评估用留出集上的精确率(precision)、召回率(recall)、F1 加混淆矩阵。你学到每种策略换得什么、付出什么——800 条 SMS 上,只调头训练快、几乎不过拟合;全调慢、易在小数据上过拟合,但当下游领域偏离预训练语料时精度更高。

对应原课程:Phase 19 · Lesson 38 · classifier-finetuning(原英文 phases/19-capstone-projects/38-classifier-finetuning/docs/en.md)。本节属「从零构建 GPT」赛道第九节。

学习目标

阅读完本节,你应当能够:

  1. 在不重置主体的前提下,把语言模型头换成分类头。
  2. 实现两种训练机制:冻体(只调头)与全微调,共用一个训练循环。
  3. 构建感知分词器的数据管线:填充、掩码填充位、按注意力掩码池化输出。
  4. 从原始 logits 算出精确率、召回率、F1 与混淆矩阵。
  5. 权衡参数量、训练时间、与精度余量之间的关系。

一、问题与直觉

你预训练了一个小 transformer,输出头把最后隐状态投到 1000 token 的词表。你现在有 800 条标注为垃圾/正常的 SMS,要一个二分类器。三个选项里有一个错的、两个对的。错的是从零训一个新分类器——预训练主体已编码有用结构(词身份、位置、简单共现),扔掉它浪费了构建它的算力。两个对的是换头冻体、换头全调。

模型是 f_theta(tokens) -> hidden_states,头是 g_phi(hidden) -> logits。换头意味着留 theta、换 phi。主体参数贵,头只是一层线性。冻体训练时,你对 phi 算梯度、对 theta 置零——PyTorch 里设 body 参数 requires_grad=False,优化器只见头。全微调让梯度回流整栈,主体权重漂向分类目标,风险是小数据上的灾难性遗忘(主体的预训练被过拟合噪声冲掉)。

池化问题

分类器要每序列一个向量,而非每 token 一个。三种常见选择:均值池化(按注意力掩码加权平均隐状态)、CLS 池化(加特殊 token、只用它的输出,BERT 做法)、末 token 池化(用最后非填充 token,GPT 式分类器做法)。本节用带显式注意力掩码加权的均值池化——最简、跨序列长度信号稳、无需预训练 CLS token。

数据与指标

800 条 SMS,400 垃圾 400 正常,在 code/main.py 里确定性生成(固定种子、模板、槽填充、5~25 token 长)。80/20 分层划分(640 训练/160 测试),使测试集保 50/50 平衡,精确率/召回率能读成诚实数字。

二分类、类 1 为正(垃圾)。TP 预测垃圾且是垃圾,FP 预测垃圾但非垃圾,FN 预测非垃圾但垃圾,TN 预测非垃圾且非垃圾。头条三指标:precision = TP/(TP+FP)(被标垃圾的里真垃圾的比例)、recall = TP/(TP+FN)(真垃圾里被标的比例)、F1 = 2PR/(P+R)(两者调和均值)。混淆矩阵把四个计数打成 2x2 格。

二、从零实现

主体是刻意微型的 transformer:词表 260、隐 64、4 头、2 块、最大序列 32,小到能在 CPU 上 90 秒内把两种机制都训到收敛。本节不预训练它,而是用 pretrain_quick 在同一夹具文本上跑 5 epoch LM 训练,给主体一个非平凡的起点,保持自包含。

main.py 实现:ByteTokenizer(字节到 id、保留 pad id)、LMBody(嵌入 + 块栈,返隐状态)、MeanPool(掩码加权序列轴平均)、Classifier(主体+池+线性头,主体跨机制是同一实例)、freeze_body/unfreeze_body(切 body 参数的 requires_grad)、train_classifier(一个共享循环,按可训参数组配优化器)、evaluate(跑测试集返 Metrics)、run_demo(快速预训主体,再训评估只调头,再训评估全调)。

切换两种机制只在一行:

def train_classifier(model, loader, epochs, mode="head_only"): if mode == "head_only": freeze_body(model) # body.requires_grad = False params = [p for p in model.parameters() if p.requires_grad] else: unfreeze_body(model) params = model.parameters() opt = torch.optim.Adam(params, lr=3e-4) for ep in range(epochs): for x, mask, y in loader: logits = model(x, mask) # (B, 2) loss = F.cross_entropy(logits, y) loss.backward(); opt.step(); opt.zero_grad() return evaluate(model, test_loader) ​

设计要点:同一 train_classifier 函数处理两种机制,切换只是 requires_grad 与优化器参数组——这是本节的 API 契约。在你自家模型上,只要模型有「主体+头」结构,这个函数原样复用。

三、框架对比

HuggingFace transformers 的 AutoModelForSequenceClassification 把这一切打包:加分类头、按 requires_grad 控冻体、内置 Trainer 跑训练评估。本节手写让你看清均值池化的掩码加权怎么算、冻结怎么落地、混淆矩阵怎么数。PEFT 库(LoRA、Prompt Tuning)是冻体的进阶版——不冻全 body 而是冻全后插低秩适配器,在大模型上以 <1% 参数达到接近全调的效果。本节的冻体/全调是理解 LoRA 为何有效的起点。

四、可复用产物

main.py + code/tests/test_main.py。demo 在 CPU 上约 90 秒跑完:快速预训主体、训只调头、训全调、打印两份报告(精度/召回/F1/混淆矩阵)。典型结果:只调头 20 epoch 后精确率约 0.9、召回约 0.85;全调慢约 3 倍,落在两点以内(随种子)。本节不选赢家,教你读数字与成本:800 例 + 微型主体,只调头是对的选择;8 万例 + 大主体,全调开始回报。

五、练习

  1. CLS 池化:把均值池化换成 CLS 池化(加可学习 CLS token、只用它的输出),重训只调头,对比 F1。
  2. 类权重:给垃圾类加更高类权重(pos_weight),重训,观察召回提升、精确率下降。
  3. 冻结层数:冻结 body 的第一块、只调第二块 + 头,对比只调头与全调。
  4. 阈值扫描:评估时把决策阈值从 0.1 扫到 0.9,绘精确率-召回率曲线。
  5. LoRA 雏形:在 body 每层注入 A @ B(低秩)残差,冻主体只调 A/B,观察参数量与 F1 的权衡。

本节要点回顾

  1. 换头不重置主体:预训练主体已编码结构,扔掉浪费算力,留 theta 换 phi。
  2. 两种机制共一循环:冻体(requires_grad=False)与全调只差优化器参数组。
  3. 池化得序列向量:均值/CLS/末 token 三选,本节用掩码加权均值池化。
  4. 三指标:精确率(标垃圾里真垃圾)、召回(真垃圾里被标)、F1(调和均值),加混淆矩阵。
  5. 权衡:小数据只调头快且不过拟合,大数据/领域漂移时全调精度更高。
  6. API 契约:train_classifier 同函数处理两种机制,切换一行——这是可复用的关键。

下一节,我们做「指令微调 SFT」——用边界 token 与 -100 掩码,只在回答 token 上算损失,把基础模型变成能听指令的模型。


作者与出处
原作者: Rohit Gupta
来源:rohitg00
许可证:MIT
整理: 灏天文库整理
由灏天文库结构化整理,提供目录导航、全文检索与在线阅读,便于系统化学习
发布者: 作者: Rohit Gupta 转发
评论区 (0)
U