Skip to content

第 8 章 微调文本分类实战 ​

学习目标 ​

  • 独立完成一次完整的文本分类微调:数据 → 训练 → 评估 → 保存 → 预测
  • 理解数据量、学习率、轮数对结果的影响
  • 会用 id2label 让推理输出可读
  • 能识别「小样本实验」与「真实效果」的区别

8.1 任务设定 ​

目标:SST-2 影评情感二分类(negative / positive)。模型:prajjwal1/bert-tiny(438 万参数,几分钟内跑完);数据:训练 2000 条、验证 400 条。

完整流程五步:

text
加载数据集 → 分词预处理 → 构造模型(带标签映射) → Trainer 训练评估 → 保存并预测

8.2 数据与预处理 ​

python
from datasets import load_dataset
from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("google-bert/bert-base-uncased")

def tokenize(examples):
    return tokenizer(examples["sentence"], truncation=True, max_length=64)

def prepare(split):
    ds = load_dataset("nyu-mll/glue", "sst2", split=split)
    return (ds.map(tokenize, batched=True, remove_columns=["sentence", "idx"])
              .rename_column("label", "labels"))

train_set = prepare("train[:2000]")
eval_set = prepare("validation[:400]")
print("train:", len(train_set), "| eval:", len(eval_set))

输出:

train: 2000 | eval: 400

这一步完全复用第 6 章的写法:分词、删列、标签列改名。

8.3 构造模型与标签映射 ​

python
from transformers import BertConfig, BertForSequenceClassification

config = BertConfig.from_pretrained(
    "prajjwal1/bert-tiny",
    num_labels=2,
    id2label={0: "negative", 1: "positive"},
    label2id={"negative": 0, "positive": 1},
)
model = BertForSequenceClassification.from_pretrained(
    "prajjwal1/bert-tiny", config=config)
print("参数总数:", sum(p.numel() for p in model.parameters()))

输出(LOAD REPORT 省略):

参数总数: 4386178

加载时 LOAD REPORT 会显示 classifier.weight / classifier.bias 为 MISSING——分类头是新初始化的,这正是要训练的部分(第 3 章)。

8.4 训练 ​

python
import numpy as np
from transformers import (TrainingArguments, Trainer,
                          DataCollatorWithPadding, set_seed)
from evaluate import load as load_metric

set_seed(42)
accuracy = load_metric("accuracy")

def compute_metrics(eval_pred):
    logits, labels = eval_pred
    preds = np.argmax(logits, axis=-1)
    return accuracy.compute(predictions=preds, references=labels)

args = TrainingArguments(
    output_dir="./results-sst2",
    learning_rate=1e-4,
    num_train_epochs=2,
    per_device_train_batch_size=16,
    per_device_eval_batch_size=32,
    eval_strategy="epoch",
    logging_strategy="steps",
    logging_steps=20,
    save_strategy="no",
    report_to=[],
    disable_tqdm=True,
    fp16=True,
)

collator = DataCollatorWithPadding(tokenizer=tokenizer, return_tensors="pt")
trainer = Trainer(model=model, args=args,
                  train_dataset=train_set, eval_dataset=eval_set,
                  data_collator=collator, compute_metrics=compute_metrics)
trainer.train()

训练日志(真实运行):

{'loss': '0.6905', 'grad_norm': '2.49', 'learning_rate': '9.24e-05', 'epoch': '0.16'}
{'loss': '0.6776', 'grad_norm': '2.503', 'learning_rate': '8.44e-05', 'epoch': '0.32'}
{'loss': '0.6679', 'grad_norm': '2.791', 'learning_rate': '7.64e-05', 'epoch': '0.48'}
{'loss': '0.6517', 'grad_norm': '1.732', 'learning_rate': '6.84e-05', 'epoch': '0.64'}
{'loss': '0.6578', 'grad_norm': '3.509', 'learning_rate': '6.04e-05', 'epoch': '0.8'}
{'loss': '0.6545', 'grad_norm': '5.742', 'learning_rate': '5.24e-05', 'epoch': '0.96'}
{'eval_loss': '0.6339', 'eval_accuracy': '0.655', 'epoch': '1'}
{'loss': '0.6305', 'grad_norm': '1.565', 'learning_rate': '4.44e-05', 'epoch': '1.12'}
{'loss': '0.6209', 'grad_norm': '2.268', 'learning_rate': '3.64e-05', 'epoch': '1.28'}
{'loss': '0.5851', 'grad_norm': '2.825', 'learning_rate': '2.84e-05', 'epoch': '1.44'}
{'loss': '0.5755', 'grad_norm': '3.991', 'learning_rate': '2.04e-05', 'epoch': '1.6'}
{'loss': '0.5679', 'grad_norm': '3.593', 'learning_rate': '1.24e-05', 'epoch': '1.76'}
{'loss': '0.5947', 'grad_norm': '2.468', 'learning_rate': '4.4e-06', 'epoch': '1.92'}
{'eval_loss': '0.5957', 'eval_accuracy': '0.695', 'epoch': '2'}

对比第 7 章的 200 条实验:数据量 ×10 后,loss 从 0.69 稳步降到 0.57,eval_accuracy 从 0.48 升到 0.695。数据量对微调效果的影响比调参更直接。

8.5 评估 ​

python
print(trainer.evaluate())

输出:

{'eval_loss': 0.5957, 'eval_accuracy': 0.695, 'eval_runtime': 0.0943,
 'eval_samples_per_second': 4243.2, 'eval_steps_per_second': 137.9, 'epoch': 2}

0.695 是 400 条验证集上的准确率。这个数字对 438 万参数的玩具模型是合理的;换更大的模型(如真实 BERT-base)和数据全量,SST-2 可以达到 90%+。

8.6 保存与预测 ​

python
trainer.save_model("./sst2-bert-tiny")
tokenizer.save_pretrained("./sst2-bert-tiny")

目录内容:

sst2-bert-tiny/
├── config.json
├── model.safetensors
├── tokenizer.json
├── tokenizer_config.json
└── training_args.bin

用 pipeline 加载并预测新句子:

python
from transformers import pipeline

classifier = pipeline("text-classification",
                      model="./sst2-bert-tiny",
                      tokenizer=tokenizer)

for text in ["This movie is absolutely wonderful and touching.",
             "The plot is boring and the acting is terrible.",
             "A brilliant story with great performances.",
             "I fell asleep halfway through this film."]:
    print(text, "->", classifier(text))

输出(真实运行):

This movie is absolutely wonderful and touching. -> [{'label': 'positive', 'score': 0.7128987312316895}]
The plot is boring and the acting is terrible. -> [{'label': 'negative', 'score': 0.6286324262619019}]
A brilliant story with great performances. -> [{'label': 'positive', 'score': 0.7736154794692993}]
I fell asleep halfway through this film. -> [{'label': 'negative', 'score': 0.5772325396537781}]

四条全对,输出是 positive/negative 而不是 LABEL_0/LABEL_1——这就是 8.3 里 id2label 的功劳。

8.7 如果不写 id2label 会怎样 ​

对比实验(真实运行):同样训练,但 config 不传 id2label,保存后预测:

[{'label': 'LABEL_0', 'score': 0.6261881589889526}]

模型预测本身没变,只是类别名退化成编号。id2label 不是模型能力,而是「输出可读性」配置——但它是专业模型的基本素养。

8.8 参数怎么调:一次真实对照 ​

同一份 2000 条数据,只改超参数:

配置eval_accuracy
lr=5e-5, bs=16, 1 epoch0.535
lr=1e-4, bs=32, 2 epochs0.665
lr=1e-4, bs=16, 2 epochs0.695

结论:学习率与训练步数不足时,模型几乎没有学习(0.535 接近随机);给足训练后明显提升。调参时一次只改一个变量,并固定种子,才能归因。

动手实践 ​

  1. 把 8.4 的 learning_rate 改成 5e-5 和 2e-4,各跑一次,比较 eval_accuracy。
  2. 在 compute_metrics 里加 f1,输出两个指标。
  3. 用你找到的中文情感模型重复本章流程,输入 3 句中文影评。

常见错误 ​

错误 1:训练集与验证集泄露。

prepare("train[:2000]") 和 prepare("validation[:400]") 来自不同 split。如果都用 train[:N] 训练又评估,数字会虚高。

错误 2:小模型 + 小数据 + 少步数,然后迷信结果。

第 7 章的 0.48 和本章的 0.695 都是真实的,区别只在资源投入。报告效果时务必写清数据规模与超参数。

错误 3:保存时漏掉 tokenizer。

只 trainer.save_model() 不保存 tokenizer,pipeline 加载时会匹配失败。两个都要保存。

章末练习 ​

基础

  1. 完整复现 8.4 的训练,记录你的 eval_accuracy。
  2. 用保存的模型预测 5 条新句子,其中故意包含 1 条中性句子,观察模型如何「硬选」。

提高

  1. 在 2000 条数据上对比 num_train_epochs=1 与 3,记录 loss/eval_accuracy 变化,解释过拟合迹象。
  2. 统计验证集上预测错误的样本,各找出 3 条 false positive 与 false negative,总结模型犯错规律。

挑战

  1. 用 prajjwal1/bert-medium 替换 bert-tiny 前,先查清它的真实参数量与显存需求;跑一轮完整微调,对比 bert-tiny 的准确率与耗时,写成实验记录。

章末自测 ​

  1. 本章使用的模型与数据集分别是什么?
  2. id2label 的作用是什么?不写会怎样?
  3. 8.4 中 learning_rate=1e-4 相比默认 5e-5 的效果如何?
  4. 判断:验证集应该从训练集里切,而不是用数据集自带的 validation split。
  5. 保存模型时需要同时保存哪些文件?
  6. 小样本实验中 eval_accuracy 不可信的原因是什么?