Hugging Face 生态入门

从零到前沿 ML 自学课程 · 阶段0:数学与工具基础 · 能力点:Hugging Face——先会手写循环再用 Trainer,通往阶段5 的工具桥梁

前面九课,你已经能从零写出 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} 时,内部严格走了三段:

HF 推理三段流水线 文本经 tokenizer 编码成 id,model 前向出 logits,后处理 softmax/argmax 得标签 ① 编码(tokenize) ② 前向(model) ③ 后处理 输入文本 "这电影真好看" tokenizer 编码 [CLS] 这 电影 真 好看 → [101,2,5,4,3] 形状 (N, L) model 前向 model 嵌入→若干层→打分 输出 logits logits [-1.2, 2.8] 形状 (N, C) softmax /argmax 正面 (0.98)
HF 推理三段流水线:文本经 tokenizer 编码成 id,model 前向出 logits,后处理 softmax/argmax 得标签。

"魔法感"全部来自这三段被封进了一个 __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.train() 展开就是第6课手写循环的五步 上半部分是 Trainer 内部的五步骨架,下半部分是每个 TrainingArguments 配置项控制哪一步。 Trainer.train() 展开就是第 6 课手写循环的五步 上:Trainer 内部就是这五步骨架 · 下:每个 TrainingArguments 配置项控制哪一步 Trainer.train() 内部(双重 for 循环:epochs × batches) ① zero_grad 清空梯度 ② forward 算预测 logits ③ loss 算损失 ④ backward 填每个 .grad ⑤ step 更新参数 每个 TrainingArguments 配置项 → 控制循环的哪一步 learning_rate ⑤ step():更新步幅 = optimizer 里的 lr num_train_epochs 外层 for:整个训练集过几遍 per_device_train_batch_size 内层 for:每个 batch 喂多少样本(DataLoader 切) lr_scheduler_type ⑤ step() 之后 scheduler.step() 调整 lr gradient_accumulation_steps 攒 N 个 batch 的 ④ backward 梯度,再 ⑤ step 一次 封装没有魔法:会写第 6 课的五步循环,就看得懂 Trainer 在做什么。
Trainer.train() 展开就是第 6 课手写循环的五步:zero_grad/forward/loss/backward/step,箭头指明配置项对应到哪一步。

下面这张对照表把它们一一钉死。左边是 Trainer 的配置/调用,右边是你第 6 课手写循环里的对应代码或步骤:

Trainer / TrainingArguments第 6 课手写循环里的对应
learning_rate=5e-5你创建 optimizer = AdamW(params, lr=5e-5) 时的 lr(Trainer 默认就用 AdamW)
per_device_train_batch_size=16DataLoader 每次取出的 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"。

动手练习

  1. 给玩具 tokenizer 加 <sep> 和 attention_mask。 在第一段代码基础上:encode 句对时在两句间插 <sep>;pad_batch 同时返回一个同形状的 mask 矩阵(真实 token=1,pad=0)。打印验证 mask 与 padded 形状一致。
  2. 把玩具 model 改成"三分类"。 在第二段代码里把 C 改成 3(负面/中性/正面),调整 W 的形状为 (D, 3),手设权重让"慢"这类词落到"中性"。验证 argmax 能输出三类。
  3. 对照表反向填空。 不看上文,给定手写循环代码 opt.zero_grad(); loss=crit(model(x),y); loss.backward(); opt.step(),写出每一行分别对应 Trainer 的哪个配置项或内部步骤。

掌握自检