跟着LAP啥都学 跟着LAP啥都学

第 6 章 分类微调:替换输出头的任务适配

      +

      本章导读:将预训练模型适配为二分类器——替换输出头、冻结绝大部分参数, 在平衡后的 SMS Spam 数据集上完成有监督微调。模型骨架不变,任务从 「预测下一个词元」换成「预测类别标签」。

      学完本章你将能够:

      1. 区分分类微调与指令微调的适用场景;

      2. 实现数据平衡、填充/截断与分类头替换的完整流程;

      3. 说明「只取最后一个词元」的原理与冻结策略的设计逻辑;

      4. 完成微调并客观评估准确率的变化。

      产出与代码:chapters/chapter06_finetuning/(include/finetuning.h、 src/main.cpp;权重加载复用第 5 章 safetensors.h)。

      6.1 原理概述:分类微调还是指令微调

      一份 1.24 亿参数的 GPT-2 预训练权重到手,下一步是微调。路线有两条:把输出头 换成 Linear(768, 2) 做垃圾短信分类,或保留 50257 维输出头做指令跟随。 不少初学者默认后者才是「正经 LLM 该干的事」。实际上两条路线没有高下之分, 选择依据是任务形态。「预训练 + 微调」范式由 GPT-1 论文(Radford et al., 2018) 确立:先在海量无标注文本上自监督预训练,再用少量标注数据微调到具体任务。 这一范式沿用至今。同一份权重,两条微调路线:

      Diagram

      两条路线的完整对照——选哪条,凭任务形态:

      维度 指令微调(第 7 章) 分类微调(本章)

      训练数据

      带指令的多任务样本

      固定类别标签的样本

      模型能力

      理解并执行自然语言提示描述的任务

      只识别训练时见过的那几个类别

      输出形态

      自由文本(翻译、改写、问答)

      一个类别标签

      通用性 / 需求

      高,可应对多任务;数据与算力需求更大

      低,高度专业化;需求更少

      一种常见观点认为「专才模型是妥协,通用模型才是终极形态」。对固定类别的任务, 结论恰恰相反:若任务就是把输入归到预定义类别(情感分析、垃圾检测), 分类微调更合适——训练更快、部署更省、结果可直接量化。用指令微调模型做 垃圾短信分类并非不可行,但训练、部署与推理每一环都在多付成本,输出还要 再做一步判定才能当类别用。三条必须记住的判断:

      1. 经过分类微调的模型只会预测训练中遇到的类别——它不会解释、不会总结、 不会回答别的问题;输入也不需要额外指令。它只服务于这一类任务;

      2. 分类微调模型 = 高度专业化模型,而开发专业化模型比开发通用模型简单;

      3. 选型准则:要应对多任务、依赖复杂指令 → 指令微调;要精确归到预定义 类别(情感分析、垃圾检测)→ 分类微调。

      分类微调与预训练的三个本质差异:

      1. 标签是外部给的(ham/spam),不再是「输入右移一位」;

      2. 只取最后一个词元的输出——因果注意力让最后一个位置的 hidden state 覆盖整个序列,它天然充当序列的摘要(6.5 节细说);

      3. 冻结大部分参数:千余条样本要拟合 1.24 亿参数,平均下来每条样本只能 把每个参数改动零点零零几——信号太弱,全部参数一起动只会把预训练学到的 语言表征覆盖掉,剩下的新梯度又不够把这些参数重新校准到新任务上。冻结 浅层与中间层,只留最后几层可训练,让可动参数收缩到样本量能撑住的范围 (6.4 节细说)。

      6.2 数据准备:4825 条 ham 只留 747 条

      UCI 的 SMSSpamCollection 数据集共 5572 条真实短信:ham 4825 条, spam 747 条,spam 仅占 13.4%。这个比例接近真实分布,但直接拿去训练分类器会引入偏置: 模型会偏向多数类,不学任何特征、恒猜 ham,准确率也有 86% (4825/5572 ≈ 86.6%)。这样的模型抓不到一条垃圾短信,高准确率毫无意义。 准确率指标在类别不平衡时会失真,所以第一步先做平衡:

      // include/finetuning.h —— create_balanced_dataset
      std::vector<SpamItem> ham, spam;
      for (const auto& it : data) (it.label == 1 ? spam : ham).push_back(it);
      std::mt19937 rng(seed);                       // seed=123,固定随机种子
      std::shuffle(ham.begin(), ham.end(), rng);
      ham.resize(spam.size());                      // ham 下采样到 747

      平衡后 1494 条,标签数值化(ham→0, spam→1——和把文本转成词元 ID 是 一回事,只不过「词表」从 50257 缩到 2 个 ID),再按 70/10/20 随机划分 并落盘:train.csv(1045)/ validation.csv(149)/ test.csv(300)。

      三个容易忽略的细节:

      1. 这是下采样(downsampling)不是上采样:4825 条 ham 只保留 747 条, 丢掉了 4078 条。代价是数据量减少;收益同样实在:微调更快、类别先验 恰好 50/50(此时随机猜测基线正好是 50%,模型有没有学到东西一眼可辨)。 真实项目里还可考虑上采样、类别加权或 Focal Loss——本教程选下采样 是为了缩短训练时间;

      2. 划分函数只显式接收 train/val 两个比例,测试集自动拿剩余部分—— 20% 从未写成参数;

      3. int() 向下取整让比例轻微偏移:int(1494×0.7)=1045、 int(1494×0.1)=149,测试集实际占 20.1%。划分比例是「约等于」的, 取整带来的偏差属正常现象。

      6.3 SpamDataset:填充、截断与统一长度

      第 2 章用滑动窗口切出等长文本块,长度天然一致;本章面对的是长度不一 的短信。变长序列无法直接拼成张量,必须先统一长度。两条路:全部截断到 最短消息长度(省算力,但最短消息远短于平均长度时丢信息),或全部填充到 最长消息长度(保留全部内容,计算量更大)。本教程选填充, 填充词元用 <|endoftext|>(ID 50256)。填充不丢任何文本,代价是为填充 位置多付计算——这是「先跑通,再优化」的取舍。

      一个实现细节容易出错:填充不是把字符串 "<|endoftext|>" 拼到文本后面 再分词,而是先对原始文本分词,再把 ID 50256 追加到编码结果后面—— 避免填充词元影响分词器对文本本身的切分。

      每个文本 BPE 编码后填充到统一长度:

      class SpamDataset {
      public:
          SpamDataset(const std::string& csv_path, ch2::BpeTokenizer& tokenizer,
                      int64_t max_length = -1, int64_t pad_token_id = 50256) {
              for (const auto& it : data_)
                  encoded_texts_.push_back(tokenizer.encode(it.text));
              if (max_length < 0)
                  max_length_ = longest_encoded_length();       // 以训练集最长者为基准
              else {
                  max_length_ = max_length;                     // val/test 沿用训练集的长度
                  for (auto& e : encoded_texts_)                // 超长截断对齐
                      if ((int64_t)e.size() > max_length_) e.resize(max_length_);
              }
              for (auto& e : encoded_texts_)
                  e.resize(max_length_, pad_token_id_);         // 尾部填充
          }
      };

      能不能让 val/test 各用各的最长长度?不能。三个数据集必须用同一个 max_length(train 取最长样本长度 = 120,val/test 截断对齐)—— 必须用训练集的长度约束验证/测试集。同一模型在不同 max_length 下评估, 指标不可比,调参将失去依据。batch_size=8 后得到 130 train / 19 val / 38 test 个批次,每个批次是 inputs=[8,120], labels=[8]。

      drop_last 的不对称是刻意的:train 1045 条 → drop_last=true → 130 批 × 8 = 1040 条参与训练(丢弃末尾 5 条,保证每批形状严格一致); val/test drop_last=false → 一条不丢,否则会低估准确率。 同一个参数,训练和评估的取舍完全相反。

      顺带一提:labels 张量是 [8] 的类别索引(不是 one-hot [8,2]), 与第 5 章的 target(词元 ID 序列)对照——形状不同,语义都是「正确答案的索引」。

      填充发生在尾部,会不会污染分类结果?不会——因果注意力保证填充词元 不影响前面真实词元的表示;分类头读的最后一个位置虽然常是填充词元, 但它仍能看到之前的全部真实文本(6.5 节细说)。不过填充本身未加屏蔽 (本实现的 padding 位置同样参与 attention,没有 pad mask)——这是简化, 工业实现会加 padding mask。教学版本省略这层防护以保持代码可读, 实际项目应补上。

      6.4 替换输出头:仅训练 5.7% 的参数

      把预训练 GPT-2 改造成分类器,代码改动很小:复用整个 GPTModel (引用同一模块,不复制权重),换掉输出层,再精确控制哪些参数可训练:

      class GPTClassifierImpl : public torch::nn::Module {
      public:
          GPTClassifierImpl(ch4::GPTModel& base, int64_t num_classes) {
              base_ = base;                                    // 引用同一模块
              register_module("base", base_);
              out_head_ = register_module("out_head",
                  torch::nn::Linear(emb_dim, num_classes));    // 768 → 2
      
              for (auto& np : base_->named_parameters())       // 1. 全部冻结
                  np.value().set_requires_grad(false);
              auto& last = base_->trf_blocks->at<ch4::TransformerBlockImpl>(
                  base_->trf_blocks->size() - 1);
              for (auto& np : last.named_parameters())         // 2. 解冻最后一个块
                  np.value().set_requires_grad(true);
              for (auto& np : base_->final_norm->named_parameters())
                  np.value().set_requires_grad(true);          // 3. 解冻 final_norm
          }
          torch::Tensor forward(torch::Tensor x) {
              auto hidden = base_->forward_hidden(x);          // [b, n, 768](不接 out_head)
              return out_head_->forward(hidden);               // [b, n, 2]
          }
      };

      换头是操作,冻结是设计的关键。1045 条微调样本撑不起 1.24 亿参数的训练: 全部解冻时,梯度会冲掉预训练学到的语言知识,模型记住的是这 1045 条训练 样本本身,学不到可泛化的判别规则——这是典型的过拟合。冻结策略的三层逻辑:

      1. 为什么冻结:神经网络的较低层捕捉通用语言结构与语义,最后几层 更侧重细微的语言模式和任务特定特征——只微调最后几层通常就够了, 而且计算更高效;

      2. 为什么还要多解冻一块:技术上只训新加的分类头就足够,但实验发现 微调额外的层(最后一块 + final_norm)能显著提升预测性能;

      3. 冻结范围要圈准:新层的 requires_grad 默认为 true,是安全起点; 上面代码的冻结遍历特意只圈 base_ 的参数、新换的 out_head_ 不在 名单里——若把整个模型一遍冻完,新头会被一起冻住,可训练参数归零, loss 纹丝不动,训练全程作废。

      可训练参数的精确账本——此时模型总参数为 124,441,346(qkv_bias=true、输出头 已换成 2 类头,故不再是第 4 章的 163M 口径),其中 94.3% 保持冻结,仅 5.7% 参与训练:

      组件 参数量 可训练?

      tok_emb(50257×768)

      38,597,376

      ❌

      pos_emb(1024×768)+ 前 11 个块

      786,432 + 77,966,592

      ❌

      最后 1 个 Transformer 块

      7,087,872

      ✅

      final_norm

      1,536

      ✅

      新 out_head(768×2 + 2)

      1,538

      ✅

      合计可训练

      7,090,946

      占总参数 5.70%

      这是参数高效微调的雏形——第 9 章的 LoRA 会把这个比例再压低六成多 (2.10%,且精度不掉),本章算是它的思想预习。

      C++ 细节:优化器只接收 trainable_parameters() 里 requires_grad()==true 的张量。LibTorch 冻结用 np.value().set_requires_grad(false)(是 setter, 不是 Python 的 requires_grad = False 赋值)。

      6.5 只看最后一个词元:分类损失与准确率

      分类头的输入是整条短信(填充到 120 个词元),但它只读最后一个词元的 隐藏状态。初看这个设计,一个自然的问题是:前面 119 个词元的信息怎么办?

      信息没有丢。钥匙是第 3 章的因果注意力:掩码保证第 i 个词元只能看到 \(\le i\) 的位置;最后一个词元因此见过前面全部词元,它的隐藏状态 相当于整句的压缩摘要。分类头读的不是最后一个字,而是浓缩在最后一个位置的 整句话。与预训练损失相比,代码只改了一行——索引 -1 取序列最后位置:

      inline torch::Tensor calc_loss_batch(const torch::Tensor& input_batch,
                                           const torch::Tensor& target_batch,
                                           GPTClassifier& model) {
          auto logits = model->forward(input_batch).index(
              {torch::indexing::Slice(), -1, torch::indexing::Slice()});  // [b, 2]
          return torch::nn::functional::cross_entropy(logits,
                                                      target_batch.to(logits.device()));
      }

      calc_accuracy_loader 同理:argmax 后与标签比对, GPU 上比较前要 .to(torch::kCPU)。

      两条公式概括本节。分类头读最后一个词元的隐藏状态,输出两个 logit:

      \[\hat{y} = \arg\max_{c}\; \mathrm{softmax}\!\left( W_{\mathrm{cls}}\, h_{\mathrm{last}} + b\right)_{c}\]

      损失仍是交叉熵,二分类下等价于只看真实类别那一支的对数概率:

      \[\mathcal{L} = -\log p_{y} = -\log \mathrm{softmax}(z)_{y}\]

      二分类的 softmax 就是一条 sigmoid——logit 过零即决策边界:

      06 sigmoid
      Figure 1. 公式图像:p(spam) = sigmoid(z),虚线为 0.5 决策边界

      为什么用交叉熵而不是直接优化准确率?因为分类准确率不可微——它依赖 argmax,梯度处处为 0,无法反向传播。交叉熵是它的可微代理: 优化交叉熵,准确率随之提升。

      微调前的基线怎么读:各取 10 批(80 样本)的初始准确率 46.25% / 45.00% / 48.75%——全部略低于 50%(二分类随机猜测基线),说明随机初始化的分类头 还没有判别能力,属正常现象。初始损失 2.453 / 2.583 / 2.322 明显高于 ln(2) ≈ 0.693 的「随机基线」,但这不代表模型有问题:新分类头随机 初始化导致 logits 尺度很大(如 7.45),交叉熵对「自信的错误」惩罚极重。 这也解释了损失为何能从前 50 步的 2.15 降到 0.62——头几步把 logits 尺度压回正常区间,惩罚随即松动。

      为什么取最后一个词元值得再强调一次:因果注意力掩码(第 3 章)保证 第 i 个词元只能看到 \(\le i\) 的位置——最后一个词元是唯一能访问前面所有 词元的位置,所以它天然充当整句的压缩表示。

      但有一个潜在问题需要明确:所有样本都右侧填充到 120,短消息的 最后一个位置其实是填充词元而非真实文本——用填充位置做分类可靠吗? 好在填充词元通过因果注意力仍能看到之前的全部真实词元,信息没有丢失, 这种做法仍然成立。但若把 max_length 拉到 1024(练习 6.1), 最后一个位置离真实文本末尾隔着近千个填充词元,判别信号将严重稀释。 这是练习 6.1 预期「性能显著下降」的第一原因(其余两个:注意力计算量随 \(L^2\) 增长 (长度 ×8.5,计算量 ×73);分类头要从一个纯填充位置提取全部判别信息)。 「只取最后一个词元」成立的前提,恰恰是 120 这个贴近真实文本长度的 max_length——最后一个位置离真实文本足够近。

      6.6 微调训练循环:学习率下调,按样本计数

      骨架与第 5 章完全相同(zero_grad → forward → backward → step), 变化集中在超参与监控指标。训练循环与预训练版的差异可以列成一张表:

      维度 第 5 章 train_model_simple 本章 train_classifier_simple

      跟踪累计量

      tokens_seen(词元数)

      examples_seen(样本数)

      每轮末的监控

      打印生成的文本样本

      计算训练/验证准确率

      损失计算

      全部词元

      仅最后一个词元

      优化目标

      预测下一个词元

      预测类别标签

      三个超参变了:lr=5e-5(比预训练的 4e-4 低了将近一个数量级——学习率过大 会破坏预训练学到的语言表示,微调阶段必须明显调低)、eval_freq=50、 按样本数统计(examples_seen 而非 token 数——分类任务一个样本才 产出一个标签):

      torch::manual_seed(123);
      torch::optim::AdamWOptions options(5e-5);
      options.weight_decay(0.1);
      auto optimizer = torch::optim::AdamW(classifier->trainable_parameters(), options);
      ch6::train_classifier_simple(classifier, train_loader, val_loader, optimizer,
                                   /*num_epochs=*/5, /*eval_freq=*/50, /*eval_iter=*/5);

      注意配置变化:微调时 drop_rate = 0.0(关闭 dropout)、qkv_bias = true、 context_length = 1024(与官方权重一致)。

      读训练日志的两条纪律:

      1. 轮末准确率的分母只有 40(eval_iter=5 × 8)——「100.00%」只是 40/40,波动 1 个样本就是 2.5 个百分点,只能看趋势,不能当结论;

      2. 曲线并非严格单调(如第 3 轮步 350 的 loss 0.340 高于步 300 的 0.333)——小批量估计的正常抖动,不是 bug。

      轮数怎么选:没有通用解,5 轮通常是合理的起点。前几轮就过拟合 → 减轮数;验证损失仍在下降 → 加轮数。本章取 5 轮的依据: 没有早期过拟合迹象,且验证损失已接近 0。

      6.7 编译运行与预期输出:从 46% 到 97%

      ./scripts/run.sh chapter06_finetuning

      关键输出(横幅编号为程序内的演示序号,与本章小节号略有错位):

      === 6.2 准备数据集
      总样本数: 5572
      标签分布: ham = 4825, spam = 747
      平衡后: ham = 747, spam = 747(总计 1494)
      划分: train = 1045, val = 149, test = 300
      
      === 6.3 创建数据加载器
      训练集 max_length = 120
      批次数量: 130 training, 19 validation, 38 test
      
      === 6.4 初始化带有预训练权重的模型
      生成文本: Every effort moves you toward finding an ideal new way to practice
      指令输入生成(未微调,不遵循指令): ...(续写文本,不会回答 yes/no)
      
      === 6.6 计算分类损失和准确率
      Training accuracy: 46.25% | Validation accuracy: 45.00% | Test accuracy: 48.75%
      Training loss: 2.453 | Validation loss: 2.583 | Test loss: 2.322
      
      === 6.7 在有监督数据上微调模型
      Ep 1 (Step 50): Train loss ..., Val loss ...    ← 每 50 步评估一次损失
      每轮末尾另打印一行准确率(分母 40 = eval_iter 5 × batch 8,2.5% 步进,
      只能看趋势):训练准确率从 60% 附近起步,第 5 轮升至 95% 附近,
      验证集同步上行到 92% 附近
      
      === 6.8 使用大语言模型作为垃圾消息分类器
      text_1: You are a winner you have been specially selected to receive $1000 cash ...
      分类结果: spam
      text_2: Hey, just wanted to check if we're still on for dinner tonight? ...
      分类结果: not spam
      已保存分类模型到 data/review_classifier.pth

      微调结束后的全量评估(不设 eval_iter,跑完所有批次)应达到:

      数据集 准确率 正确数 / 总数

      Training

      97.21%

      1011 / 1040

      Validation

      97.32%

      145 / 149

      Test

      95.67%

      287 / 300

      从 46% 到 97%,只用了 5 轮 × 1045 条样本——预训练依赖海量无标注数据, 微调只需要少量高质量标注样本。验证集略高于测试集是常见现象——模型开发 过程中会反复调整超参以提升验证集性能,这些针对验证集的调整对测试集不完全 适用。缩小差距的手段:加大 drop_rate 或优化器的 weight_decay。

      程序输出里 6.4 一段的「指令输入生成」实验值得多看一眼:给未微调的 GPT-2 输入 'Is the following text spam? Answer yes or no',它不会回答, 而是续写一段与垃圾短信相关的文本。这直观展示了基座模型与遵循指令模型 之间的差距——第 7 章的指令微调正是要弥补它。 输出清单中 6.8 一段的实测还形成了一组对照:6.4 一段里模型无法处理的那条 「You are a winner…​」文本,微调后由 classify_review 正确判为 spam—— 同一份权重,任务从续写换成了分类。

      另一个交付侧的要点:state_dict 只存权重不存架构——加载前必须重建 结构完全一致的模型(尤其 out_head 已换成 Linear(768, 2)), 拿一个 50257 维输出头的裸 GPTModel 去 load 会形状不匹配失败。 稳妥做法是把配置和权重一起存。

      本章小结

      • 分类微调 = 替换输出头 + 冻结绝大部分参数 + 只读最后一个词元的表示;选型看任务形态:要一个标签选分类,要自由文本选指令微调。

      • 数据工程三步:下采样平衡(5572 → 1494)、标签数值化、70/10/20 划分;恒猜多数类即得 86% 准确率,说明不平衡数据下准确率会失真。三个数据集必须共用训练集的 max_length=120。

      • 可训练参数仅 7,090,946(占 5.70%);冻结范围只圈 base、新头不在名单(可安全先换头),整个模型一遍冻完漏掉新头则可训练参数归零,训练全程作废。

      • 只取最后一个词元成立的前提是因果注意力 + 填充层足够薄;交叉熵是准确率的可微代理,初始 loss 高于 ln(2) 的原因是 logits 尺度大、错罚更重,而非模型学得差。

      • 训练四要素:lr 低一个数量级(5e-5)、按样本计数、监控准确率、轮数看验证集。准确率从 46% 提升到 97%(test 95.67%),依赖预训练特征与最后一块的联合微调。

      自测清单

      能不回看就答出这些,本章就算过关:

      1. 数据三件套:5572 → 平衡 1494 → 1045/149/300,CSV 已落盘

      2. 130/19/38 个批次,max_length=120,padding 用 50256 (train drop_last 丢 5 条 → 实训 1040 条)

      3. 可训练参数 7,090,946(5.70%),并能说出冻结范围为什么只圈 base—— 整个模型一遍冻完、漏掉新头会发生什么

      4. 微调前准确率 ~46%(略低于 50% 随机基线), loss ≈ 2.4–2.6 并能解释为何高于 ln(2)

      5. 5 轮后 val accuracy ≥ 92%,全量评估 test ≈ 95.7%

      6. classify_review 正确判别 winner 短信为 spam、dinner 短信为 not spam (与 6.4 前后对照)

      7. review_classifier.pth 已保存

      习题

      1. 把 unfreeze_all() 打开(全部 1.24 亿参数可训练),对比 5 轮后的 val accuracy 与过拟合情况——冻结策略到底防住了什么?

      2. 把硬编码为 2 的 num_classes 改成其他数量(例如 3 类新闻), 重新编译并体会 Linear(768, 2) 中 2 的来源。

      3. 把 eval_iter 从 5 改成全部批次(130),观察评估精度与耗时的取舍。

      4. 对比 lr=5e-5 与 lr=5e-4:学习率放大 10 倍后微调还稳吗?

      5. 用 test_loader 完整跑一遍 calc_accuracy_loader, 把 test accuracy 精确到小数点后两位。

      6. 把三个数据集的 max_length 拉到 1024 重新训练, 验证「性能显著下降 + 训练变慢」,并说出三个原因(信号稀释、\(\mathcal{O}(L^2)\)、 分类头退化为对填充位置分类)。

      拓展阅读