前面九课,你已经能从零写出 micrograd、迷你 nn.Module,并在第 6 课亲手敲出完整训练循环的五个步骤:zero_grad → forward → loss → backward → step。这一课是通往阶段 5(真正的大模型)的工具桥梁。我们要做的不是"换一套魔法",而是把你已经理解的零件,对应到 Hugging Face(简称 HF)生态的封装上:pipeline 是"tokenizer + model + 后处理"的三段流水线,Trainer 就是你第 6 课那个循环穿上了西装。本课的纪律是:先手写跑通机制,再认领真实 API——这样你用 Trainer 时,它对你就不是黑箱。
读完这一课,你将能够
- 用一句话拆解
pipeline的三段结构(编码 → 前向 → 后处理),说出每段进出的数据形状。 - 手写一个最小 tokenizer:建词表、
encode/decode、把一个 batch 的变长句子 pad 到同长。 - 用纯 NumPy 串起"嵌入 → 池化 → 线性打分 → softmax → argmax",复现
pipeline内部到底在算什么。 - 把
TrainingArguments的每个超参(lr/batch/epochs/scheduler/梯度累积)对应回第 6 课手写循环的具体部件。 - 写出
transformers.pipeline、AutoTokenizer/AutoModel.from_pretrained、datasets.load_dataset().map()、Trainer的最小正确骨架。
一行推理的背后:pipeline 的三段流水线
HF 最出名的入口是 pipeline——一行代码跑通一个任务。但"一行"只是 API 的薄,不是机制的薄。当你写 clf("这电影真好看") 得到 {'label': 'POSITIVE', 'score': 0.98} 时,内部严格走了三段:
- ① tokenizer 编码:把字符串切成 token,再查表换成整数 id,补上特殊 token(如
[CLS]/[SEP]),输出形状 (N, L) 的 id 矩阵(N=句子数,L=对齐后的长度)。 - ② model 前向:id 进入模型,吐出 logits(未过 softmax 的原始分数),分类任务形状 (N, C)(C=类别数),或 (N, L, H) 的隐藏状态(H=隐藏维)。这一步本质就是你前几课的矩阵乘 + 非线性。
- ③ 后处理:对 logits 做
softmax转概率、argmax取类别,再把类别 id 映射回人类标签字符串。
"魔法感"全部来自这三段被封进了一个 __call__。下面我们把每一段都亲手写出来,你会发现没有一处是你没学过的。
第一段·手写最小 tokenizer
机制直觉:tokenizer 干两件事——把文本切成单元、把单元查表换成 id。真实的子词分词(BPE/WordPiece)只是"怎么切"更聪明,但"查表→id→加特殊 token→pad 对齐"这套骨架是不变的。我们先用最朴素的"按空格切词"实现,把骨架吃透。这里的变长 padding 正是第 7 课 DataLoader 里 collate 要解决的同一个问题。
最小可跑:下面这段纯 Python(无第三方库)建词表、实现 encode/decode,并把一个 batch pad 到同长。
# 1) 建词表:特殊 token 占据小 id,便于辨认
specials = ["<pad>", "<unk>", "<cls>"]
corpus = "这 电影 真 好看 但 节奏 有点 慢".split()
vocab = {tok: i for i, tok in enumerate(specials)}
for w in corpus:
if w not in vocab:
vocab[w] = len(vocab)
inv_vocab = {i: w for w, i in vocab.items()}
PAD, UNK, CLS = vocab["<pad>"], vocab["<unk>"], vocab["<cls>"]
def encode(sentence):
ids = [CLS] # 句首加 <cls>
for w in sentence.split():
ids.append(vocab.get(w, UNK)) # 未登录词 → <unk>
return ids
def decode(ids):
# 注意:这里只剔除 pad,<cls> 仍保留,所以 decode 出来会带句首的 <cls>
return " ".join(inv_vocab[i] for i in ids if i != PAD)
def pad_batch(list_of_ids):
L = max(len(x) for x in list_of_ids) # 对齐到最长
return [x + [PAD] * (L - len(x)) for x in list_of_ids]
batch = ["这 电影 真 好看", "节奏 有点 慢", "这 电影 太 难看"]
ids = [encode(s) for s in batch]
padded = pad_batch(ids)
print("词表:", vocab)
print("encode:", ids) # 注意第3句"太""难看"未登录 → <unk>(=1)
print("decode 第0句:", decode(padded[0])) # 仅剔除 pad,仍带句首 <cls>
for row in padded:
print(row) # 三行等长 → 可堆成 (N, L) 矩阵
运行后你会看到:第三句里"太""难看"不在词表,被映射成 <unk>(id=1);三句长度不一,pad 后变成等长的三行,正好能堆成 (3, L) 的整数矩阵喂给模型。decode 这里只剔除了 pad,所以第 0 句还原出来会带着句首的 <cls>(若想还原成纯人类句子,再把特殊 token 一并过滤即可)。这就是 tokenizer 的全部本质,真实版只是切词更精细、词表更大。
真实 API 当指针:HF 用 AutoTokenizer 按 Hub 上的模型名加载已训练好的词表与切词规则。
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("bert-base-chinese")
out = tok(["这电影真好看", "节奏有点慢"],
padding=True, # 对应我们的 pad_batch
truncation=True, # 超长截断
return_tensors="pt") # 直接返回 PyTorch 张量
print(out["input_ids"].shape) # (2, L),含 [CLS]/[SEP]/[PAD]
print(out["attention_mask"]) # 1=真实 token, 0=pad,告诉模型忽略 pad
注意 attention_mask:它标记哪些位置是 pad,让模型在算注意力时跳过填充位——这是我们玩具版省略、但真实模型必备的一环。
第二段+第三段·手写最小 pipeline(纯 NumPy)
机制直觉:拿到 id 矩阵后,model 要把"整数 id"变成"类别分数"。最简结构是:嵌入(embedding,每个 id 查一个向量)→ 池化(把一句话的若干向量聚合成一个句向量)→ 线性打分(句向量乘权重得到每类的 logit)→ softmax/argmax。真实 Transformer 在"嵌入"和"打分"之间塞了几十层注意力,但首尾两端就是这个样子。下面我们用手设权重(不需要真训练)把三段串起来,重点是结构而非精度。
import numpy as np
# 本段自带一个独立小词表,id 与第一段无关,只用来演示结构。
vocab = {"<pad>":0, "<unk>":1, "<cls>":2,
"好看":3, "真":4, "电影":5, "难看":6, "慢":7, "节奏":8}
V, D, C = len(vocab), 4, 2 # 词表大小, 嵌入维, 类别数(0=负面,1=正面)
rng = np.random.default_rng(0)
E = rng.normal(0, 1, size=(V, D)) # 嵌入表 (V, D)
# 手设倾向:让"好看"偏正面,"难看/慢"偏负面(直接写进嵌入第0维)
E[vocab["好看"]] = [ 2.0, 0, 0, 0]
E[vocab["难看"]] = [-2.0, 0, 0, 0]
E[vocab["慢"]] = [-1.0, 0, 0, 0]
W = np.array([[-1.0, 1.0],[0,0],[0,0],[0,0]]) # 线性层 (D, C): 第0维大→正面
def softmax(z):
z = z - z.max(-1, keepdims=True)
e = np.exp(z); return e / e.sum(-1, keepdims=True)
def toy_model(id_matrix):
vec = E[id_matrix] # (N, L, D) 查嵌入
mask = (id_matrix != vocab["<pad>"])[..., None] # (N, L, 1) 屏蔽 pad
pooled = (vec * mask).sum(1) / mask.sum(1) # (N, D) 平均池化
logits = pooled @ W # (N, C) 线性打分
return logits
# 两句:一正一负,已 pad 对齐
batch_ids = np.array([[2,5,4,3], # <cls> 电影 真 好看
[2,8,7,0]]) # <cls> 节奏 慢 <pad>
logits = toy_model(batch_ids)
probs = softmax(logits) # 后处理①
pred = probs.argmax(-1) # 后处理②
labels = {0:"负面", 1:"正面"}
print("logits:\n", np.round(logits,2))
print("probs:\n", np.round(probs,2))
for i, p in enumerate(pred):
print(f"句{i} → {labels[p]} (置信 {probs[i,p]:.2f})")
跑出来第 0 句判"正面"、第 1 句判"负面"。注意 mask 那行:pad 位被排除在平均池化外,呼应上面 attention_mask 的作用。你也会发现第 0 句的置信只有 0.58 左右——这是因为"电影""真"用的是随机嵌入,把"好看"的正信号稀释了;结论方向对,但置信不高。真实模型靠训练让每个词的向量各就各位,信号才会干脆。这就是 pipeline 内部的骨架——id → 嵌入 → 聚合 → 打分 → softmax → argmax → 标签。真实模型只是中间的"聚合"换成了几十层 Transformer,权重是训出来的而非手设的。
真实 API 当指针:
from transformers import pipeline
clf = pipeline("sentiment-analysis") # 一行:内部三段自动串好
print(clf("这电影真好看"))
# [{'label': 'POSITIVE', 'score': 0.98}]
gen = pipeline("text-generation", model="gpt2")
print(gen("Once upon a time", max_new_tokens=20)[0]["generated_text"])
# 想看清三段,可不用 pipeline,手动拆开:
import torch
from transformers import AutoTokenizer, AutoModelForSequenceClassification
name = "distilbert-base-uncased-finetuned-sst-2-english"
tok = AutoTokenizer.from_pretrained(name)
model = AutoModelForSequenceClassification.from_pretrained(name)
inputs = tok("I love this movie", return_tensors="pt") # 段①编码
with torch.no_grad():
logits = model(**inputs).logits # 段②前向 (1, 2)
probs = logits.softmax(-1) # 段③后处理
print(model.config.id2label[probs.argmax(-1).item()]) # → 'POSITIVE'
datasets:load_dataset + .map() 批量预处理
机制直觉:训练前要把"一堆文本"统一 tokenize 成 id。datasets 库提供 load_dataset 拉取数据集,再用 .map() 把一个函数(通常就是 tokenizer)批量作用到每条样本上——概念上等价于第 7 课你给 Dataset 写的预处理,只是它带缓存、支持多进程、内存映射,能处理超大语料。
from datasets import load_dataset
from transformers import AutoTokenizer
ds = load_dataset("imdb") # {'train':..., 'test':...}
tok = AutoTokenizer.from_pretrained("distilbert-base-uncased")
def preprocess(batch): # 对一批样本 tokenize
return tok(batch["text"], truncation=True)
ds = ds.map(preprocess, batched=True) # 批量、可缓存、可多进程
print(ds["train"][0].keys()) # dict_keys(['text','label','input_ids','attention_mask'])
【重点】Trainer 就是你第 6 课的训练循环
很多人初学时直接 Trainer().train(),看到 loss 下降就完事,却不知道里面在干嘛。但你不一样——你第 6 课已经亲手写过那个循环。Trainer 不是新东西,它是你那个循环的封装:TrainingArguments 里的每个超参,都对应循环里某个你写过的部件。
下面这张对照表把它们一一钉死。左边是 Trainer 的配置/调用,右边是你第 6 课手写循环里的对应代码或步骤:
| Trainer / TrainingArguments | 第 6 课手写循环里的对应 |
|---|---|
learning_rate=5e-5 | 你创建 optimizer = AdamW(params, lr=5e-5) 时的 lr(Trainer 默认就用 AdamW) |
per_device_train_batch_size=16 | DataLoader 每次取出的 batch 大小(一次 forward 喂多少样本) |
num_train_epochs=3 | 外层 for epoch in range(3) 的循环次数 |
lr_scheduler_type="linear" | 每个 step 后 scheduler.step() 调整学习率(第 6 课提到的 warmup/衰减) |
gradient_accumulation_steps=4 | 累积 4 个 batch 的梯度再 step()+zero_grad(),等效更大 batch |
weight_decay=0.01 | 权重衰减项(AdamW 用的是解耦式衰减,近似 L2 正则;仅朴素 SGD 上二者严格等价) |
Trainer 内部 loss = model(**batch).loss | 你的 logits = model(x); loss = criterion(logits, y) |
Trainer 内部 loss.backward() | 一模一样的 loss.backward()(autograd 反传) |
Trainer 内部 optimizer.step() | 一模一样的 optimizer.step() |
Trainer 内部 optimizer.zero_grad() | 一模一样的 optimizer.zero_grad() |
eval_strategy="epoch" + compute_metrics | 你的 model.eval() + with torch.no_grad() 验证段 |
换句话说,Trainer.train() 展开后,就是你第 6 课写的那五步在一个双重 for 循环里转。它额外帮你做的是:日志、checkpoint、多卡分发、混合精度(这些是第 8、9 课的内容,Trainer 把它们也包了)。下面是最小微调骨架:
from transformers import (AutoModelForSequenceClassification,
TrainingArguments, Trainer)
model = AutoModelForSequenceClassification.from_pretrained(
"distilbert-base-uncased", num_labels=2)
args = TrainingArguments(
output_dir="out",
learning_rate=5e-5, # ↔ optimizer 的 lr(默认 AdamW)
per_device_train_batch_size=16, # ↔ batch 大小
num_train_epochs=3, # ↔ 外层 epoch 循环
lr_scheduler_type="linear", # ↔ scheduler.step()
gradient_accumulation_steps=1, # ↔ 梯度累积
eval_strategy="epoch", # ↔ 每 epoch 跑验证(旧版叫 evaluation_strategy,4.46+ 重命名为 eval_strategy)
)
trainer = Trainer(
model=model, args=args,
train_dataset=ds["train"], eval_dataset=ds["test"],
processing_class=tok, # 新版用 processing_class;旧版用 tokenizer=tok
)
trainer.train() # ← 内部就是 L6 的 zero_grad/forward/loss/backward/step
哲学:别因为 Trainer 好用就跳过手写循环。理解了第 6 课,Trainer 对你才是"已知零件的组装",调参时你知道每个旋钮拧的是哪根筋;否则它永远是黑箱,loss 不降你也无从下手。至于 Hub(模型/数据集托管,from_pretrained 按名字下载)和 accelerate(一套代码自动适配单卡/多卡/混合精度),这一课只需知道有它们存在,遇到时再深入。
调一调,观察现象
① 改 pad 长度,看 pad 是否被正确忽略。
import numpy as np
vocab = {"<pad>":0, "好看":3}
E = np.zeros((9,4)); E[3] = [2.0,0,0,0] # 只有"好看"有正面信号
def pooled(ids):
vec = E[np.array(ids)]
mask = (np.array(ids) != 0)[:,None]
return (vec*mask).sum(0)/mask.sum(0)
print("不加pad :", pooled([3])) # [2,0,0,0]
print("加3个pad:", pooled([3,0,0,0])) # 仍是 [2,0,0,0]
预期现象:两次池化结果完全相同。为什么:mask 把 pad 位的贡献剔除、分母也只数真实 token,所以 pad 多少都不影响句向量——这正是 attention_mask 存在的意义。若你删掉 mask,pad 会把句向量稀释,预测随长度漂移。
② 改 softmax 输入的尺度(温度),看置信度。
import numpy as np
def softmax(z):
z=z-z.max(); e=np.exp(z); return e/e.sum()
logit = np.array([1.0, 2.0])
for T in [0.5, 1.0, 4.0]:
print(f"T={T}: {np.round(softmax(logit/T),3)}")
预期现象:T 越小,概率越极端(接近 0/1);T 越大,越平(接近 0.5/0.5)。为什么:除以温度 T 等价于缩放 logits 间距,argmax 选出的类别不变,但"置信度"被拉伸或压平——文本生成里的 temperature 参数就是这个旋钮。
③ 改梯度累积步数,看等效 batch。(概念演示,纯计数)
accum_steps = 4 # 改成 1 / 4 / 8 看 step 次数
micro_batch = 8
n_micro = 32 # 一共喂 32 个 micro-batch
updates = n_micro // accum_steps
print(f"每次实际 forward 样本数: {micro_batch}")
print(f"等效 batch 大小: {micro_batch*accum_steps}")
print(f"参数更新(step)次数: {updates}")
预期现象:accum_steps 翻倍,等效 batch 翻倍、step 次数减半。为什么:梯度累积把多个小 batch 的梯度加起来再更新一次,用小显存模拟大 batch——对应表里的 gradient_accumulation_steps,机制就是"多攒几次 backward 再 step+zero_grad"。
动手练习
- 给玩具 tokenizer 加
<sep>和attention_mask。 在第一段代码基础上:encode 句对时在两句间插<sep>;pad_batch 同时返回一个同形状的 mask 矩阵(真实 token=1,pad=0)。打印验证 mask 与 padded 形状一致。 - 把玩具 model 改成"三分类"。 在第二段代码里把
C改成 3(负面/中性/正面),调整W的形状为 (D, 3),手设权重让"慢"这类词落到"中性"。验证 argmax 能输出三类。 - 对照表反向填空。 不看上文,给定手写循环代码
opt.zero_grad(); loss=crit(model(x),y); loss.backward(); opt.step(),写出每一行分别对应Trainer的哪个配置项或内部步骤。
掌握自检
- 我能不看笔记说出 pipeline 三段的名字,以及每段进/出的张量形状((N,L) → (N,C) → 标签)。
- 我能解释 tokenizer 里
<pad>/<unk>/<cls>各自的用途,以及为什么需要把 batch pad 到同长。 - 我能说出
attention_mask解决什么问题,并在玩具池化里指出哪行代码实现了它。 - 我能把
learning_rate/batch_size/epochs/scheduler/gradient_accumulation_steps一一对应回第 6 课循环的具体部件。 - 我能写出
AutoTokenizer.from_pretrained+AutoModelForSequenceClassification.from_pretrained+ 手动 softmax 的三段推理骨架,API 不出错。 - 我能解释"为什么不该跳过手写循环直接用 Trainer"。
可以先放过的点
- BPE/WordPiece 子词分词的具体算法:现在按空格切词理解骨架就够了。等你进入阶段 5 真正读 Transformer 论文时再回来看"为什么要子词"。
- accelerate / 多卡分布式 / DeepSpeed:这是规模化的工程,单卡跑通后、确实遇到显存或速度瓶颈时再学(也呼应第 8 课)。
- Trainer 的高级回调、自定义
compute_metrics、混合精度细节:先用默认配置把一次微调跑通,看到 loss 下降、验证指标上升,再去拆这些旋钮。 - Hub 上传/版本管理/模型卡:等你真有一个想分享的模型时再学,不影响本课的"理解封装"目标。