本节摘要:预训练语言模型是自注意力块的堆叠,末端接一个 token 预测头。当你想做垃圾短信识别时,头错了,但主体大致没错。本节拆掉 LM 头,在池化表示上粘一个两类线性层,用两种方式训练分类器:只调最后一层(冻体)、全参数微调,共用一个训练循环。评估用留出集上的精确率(precision)、召回率(recall)、F1 加混淆矩阵。你学到每种策略换得什么、付出什么——800 条 SMS 上,只调头训练快、几乎不过拟合;全调慢、易在小数据上过拟合,但当下游领域偏离预训练语料时精度更高。
对应原课程:Phase 19 · Lesson 38 ·
classifier-finetuning(原英文phases/19-capstone-projects/38-classifier-finetuning/docs/en.md)。本节属「从零构建 GPT」赛道第九节。
阅读完本节,你应当能够:
你预训练了一个小 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 万例 + 大主体,全调开始回报。
pos_weight),重训,观察召回提升、精确率下降。A @ B(低秩)残差,冻主体只调 A/B,观察参数量与 F1 的权衡。theta 换 phi。requires_grad=False)与全调只差优化器参数组。train_classifier 同函数处理两种机制,切换一行——这是可复用的关键。下一节,我们做「指令微调 SFT」——用边界 token 与
-100掩码,只在回答 token 上算损失,把基础模型变成能听指令的模型。