01_fine_tune_transformers_on_classification
import torchfrom torch import nnimport transformersimport numpy as npimport pandas as pdimport matplotlib.pyplot as pltprint(torch.__version__) # 2.6.0+cu124print(transformers.__version__) # 5.9.0import matplotlib as mplmpl.rcParams["figure.dpi"] = 200 # 默认为 100,设置为 200 可以让图像更清晰1. text classification
- 也叫 sequence classification(text 就是 sequence 的一种)
- sentiment analysis
- 情感分析:就是一种文本分类
- 电商评论
- social web: weibo/twitter
- 情感分析:就是一种文本分类
1.1 emotions 数据集
from datasets import load_datasetemotions = load_dataset("dair-ai/emotion")emotions # dataset Dict输出为:
DatasetDict({ train: Dataset({ features: ['text', 'label'], num_rows: 16000 }) validation: Dataset({ features: ['text', 'label'], num_rows: 2000 }) test: Dataset({ features: ['text', 'label'], num_rows: 2000 })})可见,在默认设置下,会自动分为三块数据集:训练集、测试集和验证集,比例为 8:1:1. 此外,数据集为一个字典类型。
emotions.keys() # dict_keys(['train', 'validation', 'test'])print(emotions['train']), type(emotions['train']) # 数据集结构概览# 继续支持 keyprint(emotions['train']['text'][:5]) # 前五条文本数据print(emotions['train']['label'][:5]) # 前五条标签数据# 支持 indexprint(emotions['train'][:5]) # 前五条数据,包含文本和标签输出为:
Dataset({ features: ['text', 'label'], num_rows: 16000})
['i didnt feel humiliated', 'i can go from feeling so hopeless to so damned hopeful just from being around someone who cares and is awake', 'im grabbing a minute to post i feel greedy wrong', 'i am ever feeling nostalgic about the fireplace i will know that it is still on the property', 'i am feeling grouchy'][0, 0, 3, 2, 3]
{'text': ['i didnt feel humiliated', 'i can go from feeling so hopeless to so damned hopeful just from being around someone who cares and is awake', 'im grabbing a minute to post i feel greedy wrong', 'i am ever feeling nostalgic about the fireplace i will know that it is still on the property', 'i am feeling grouchy'], 'label': [0, 0, 3, 2, 3]}这里的 [0, 0, 3, 2, 3] 是每条文本对应的情感分类标签(数字编码)。emotions 数据集有 6 类情感:
| 标签值 | 情感 |
|---|---|
| 0 | sadness(悲伤) |
| 1 | joy(快乐) |
| 2 | love(喜爱) |
| 3 | anger(愤怒) |
| 4 | fear(恐惧) |
| 5 | surprise(惊讶) |
所以前 5 条分别是:sadness、sadness、anger、love、anger.
print(emotions['train'].features) # 查看所有特征
print(emotions['train'].features['label']) # 查看标签特征
print(emotions['train'].features['label'].int2str(2)) # 查看标签数字 2 对应的字符串标签输出为:
{'text': Value('string'), 'label': ClassLabel(names=['sadness', 'joy', 'love', 'anger', 'fear', 'surprise'])}
ClassLabel(names=['sadness', 'joy', 'love', 'anger', 'fear', 'surprise'])
love- 第一行:这里我们可以从第一行输出看到,训练集有两个特征:
| 特征 | 类型 | 说明 |
|---|---|---|
text | Value('string') | 文本内容,字符串类型 |
label | ClassLabel(names=[...]) | 分类标签,有 6 个类别名称 |
- 第二行:可以看到
label是一个ClassLabel类型,内部维护了数字到情感名称的映射:
| 数字 | 情感 |
|---|---|
| 0 | sadness(悲伤) |
| 1 | joy(快乐) |
| 2 | love(喜爱) |
| 3 | anger(愤怒) |
| 4 | fear(恐惧) |
| 5 | surprise(惊讶) |
- 第 3 行:
int2str()是ClassLabel提供的方法,功能是将数字标签转换为对应的文字。这里传入2,返回"love".
对应的还有反向方法 str2int(),例如:
emotions['train'].features['label'].str2int('love') # 返回 2labels = emotions['train'].features['label'].namesprint(labels) # 标签列表
num_classes = len(labels)print(num_classes) # 标签数量输出为:
['sadness', 'joy', 'love', 'anger', 'fear', 'surprise']
6这里我们把六类标签都提取出来,方便后面分析。
1.2 data visualization analysis
- dataset -> dataframe
- text length
- label freq
1.2.1 dataset to dataframe
emotions_df = pd.DataFrame.from_dict(emotions['train'])这里我们将 HuggingFace 中的 Dataset 转化成了 pandas 库中的 DataFrame. 因为 Hugging Face 的 Dataset 虽然功能强大,但在数据分析和可视化方面不如 pandas 方便,而 DataFrame 提供了大量便捷的方法。
print(emotions_df.shape, emotions_df.columns) # 数据集形状和列名emotions_df[:5] # 前五行数据输出为:
(16000, 2) Index(['text', 'label'], dtype='str')
text label0 i didnt feel humiliated 01 i can go from feeling so hopeless to so damned... 02 im grabbing a minute to post i feel greedy wrong 33 i am ever feeling nostalgic about the fireplac... 24 i am feeling grouchy 3emotions_df['label_name'] = emotions_df['label'].apply(lambda x: emotions['train'].features['label'].int2str(x))emotions_df[:5] # 前五行数据,包含标签名称输出为:
text label label_name0 i didnt feel humiliated 0 sadness1 i can go from feeling so hopeless to so damned... 0 sadness2 im grabbing a minute to post i feel greedy wrong 3 anger3 i am ever feeling nostalgic about the fireplac... 2 love4 i am feeling grouchy 3 anger1.2.2 label analysis
emotions_df.label.value_counts()输出为:
label1 53620 46663 21594 19372 13045 572Name: count, dtype: int64这是训练集中的标签分布,所有加起来恰好为 16000 条样本。
emotions_df.label_name.value_counts()输出为:
label_namejoy 5362sadness 4666anger 2159fear 1937love 1304surprise 572Name: count, dtype: int64plt.figure(figsize=(4,3))emotions_df.label_name.value_counts().plot(kind='bar')plt.xlabel('Label')plt.ylabel('Count')plt.title('Distribution of Labels')plt.xticks(rotation=45)plt.show()输出如下图:

1.2.3 text length analysis
plt.figure(figsize=(4,3))emotions_df['word per tweet'] = emotions_df['text'].str.split().apply(len)emotions_df.boxplot( column='word per tweet', by='label_name', grid=False, color='blue' # showfliers=False)plt.suptitle('')plt.xlabel('')输出如下图:

这里我们使用箱线图(Boxplot)进行分析,它展示了 emotion 数据集中,不同情感标签(怒、惧、喜、爱、悲、惊)的推文文本长度(单词数)的分布情况。
我们主要看以下几个关键元素:
- 横轴 (X 轴):表示不同的分类标签(如 anger, fear, joy 等)。
- 纵轴 (Y 轴):表示推文的单词数量(word per tweet)。
- 蓝色的矩形箱体 (Box):
- 中间的横线:表示中位数 (Median)。将该类别下所有推文的长度从小到大排列后,排在最中间的那个长度值。例如,所有类别推文长度的中位数都在 15-20 个单词左右。
- 箱子的顶边:表示上四分位数 (75th Percentile)。有 75% 的推文长度小于这个值。
- 箱子的底边:表示下四分位数 (25th Percentile)。有 25% 的推文长度小于这个值。
- 箱子的高度:表示四分位距 (IQR),包含了中间 50% 数据所在的范围,反映了数据的集中程度。
- 上下延伸的线段 (Whiskers, “胡须”):
- 表示数据的范围(不包含异常值)。通常延伸到 内的最极值。
- 顶端的横线是最大值(非异常值情况下的最大长度)。
- 底端的横线是最小值(非异常值情况下的最小长度)。可以看到所有类别的最小词数都在 2 个左右。
- 异常值 (Outliers):在代码中,你设置了
showfliers=False,这意味着这幅图隐藏了离群点(异常长或异常短的极端个例),从而让主体分布更加清晰。
从这幅图中可以得出的结论:
- 不同情感的推文长度分布非常相似,中位数大多在 15-20 词之间。
- 各个情感类别的绝大多数推文(约 75%)长度都在 30 个单词以内,最长的(不计极端异常值)在 45-50 个单词左右。
- 这表明各种情感表达在句子长度上并没有明显的差异偏好。这对于后续使用 Transformer 模型(比如 BERT,它有最大序列长度限制,如 512,这取决于 Token 数量而非单词,但通过单词数可以看出通常比较短,不太会被截断)进行文本分类是一个有用的参考信息。
当我们把 showfliers=False 注解掉后,就会看到有一些异常点。

回顾一下箱线图的知识:
-
正常范围(胡须的范围): 箱线图有一条规则用来界定什么是“正常”的数据范围。通常,程序会计算一个叫做 四分位距(IQR,即上四分位数减去下四分位数,也就是蓝色箱子的高度) 的值。
- 上方的胡须最高会延伸到:上四分位数 + 。
- 超过这个界限的数据点,就会被判定为异常值。
-
异常点的意义: 在这个特定的数据集中,这些小黑圈代表个别极其冗长的推文。
- 绝大部分推文长度都在 50 个单词(上方的蓝色横线)以内。
- 但有少数人在推文中写了 50~70 多个单词甚至更多(图中的黑圈最高达到了接近 70)。
- 这些非常长的句子不符合大众的发推习惯,所以被当做异常点单独画在上面。
当我们把文本输入给 BERT 模型时,BERT 有一个最大长度限制(往往设为 128、256 或 512)。看到最高的黑圈也就是 70 左右的> 单词数,我们就可以很放心地把模型最大序列长度(max_length)截断设得比较小(比如 128),因为即使是这些“异常”的超长文 > 本,也完全能被容纳,不会因为被截断而丢失关键信息。
print(emotions_df['word per tweet'].max()) # 最长文本的单词数量print(emotions_df['word per tweet'].idxmax()) # 最长文本的索引输出为:
666322可以看到,训练集中最长文本的索引就是第 6322 条数据,共有 66 个单词。
print(emotions_df.iloc[6322]) # 查看第 6322 条数据emotions_df.iloc[6322]['text'] # 查看第 6322 条数据的文本内容输出为:
text i guess which meant or so i assume no photos n...label 0label_name sadnessword per tweet 66Name: 6322, dtype: object
'i guess which meant or so i assume no photos no words or no other way to convey what it really feels unless you feels it yourself or khi bi t au th m i bi t th ng ng i b au i rephrase it to a bit more gloomy context unless you are hurt yourself you will never have sympathy for the hurt ones'这里我们使用到了 iloc 方法。iloc 是 pandas 中基于整数位置(integer-location)的索引方法,用于按行号(从 0 开始)访问 DataFrame 或 Series 中的数据。
它的核心特点如下:
| 方法 | 基于 | 示例 | 说明 |
|---|---|---|---|
iloc | 整数位置(行号) | df.iloc[0] | 第 0 行 |
loc | 标签/索引名 | df.loc[0] | 索引为 0 的行(可能不是第一行) |
[] | 列名 | df['text'] | 获取列 |
iloc vs loc 对比如下:
# iloc —— 按行号(不考虑索引值)emotions_df.iloc[0] # 无论索引是什么,取第 0 行
# loc —— 按索引标签(不考虑位置)emotions_df.loc[0] # 取索引值等于 0 的行当 DataFrame 的索引是默认的 0, 1, 2, ... 时,两者结果相同。但如果索引被重置或乱序,它们就不同了:
df = pd.DataFrame({'a': [10, 20, 30]}, index=[2, 0, 1])df.iloc[0] # 返回索引为 2 的行:a=10 (第 0 个位置)df.loc[0] # 返回索引为 0 的行:a=20 (标签为 0)在这里,因为 DataFrame 使用的是默认整数索引,所以 iloc[6322] 和 loc[6322] 结果一样,但 iloc 的语义更明确——就是按行号取数据。
print(emotions_df['word per tweet'].min()) # 最短文本的单词数量print(emotions_df['word per tweet'].idxmin()) # 最短文本的索引输出为:
24150可以看到,训练集中最短文本的索引就是第 4150 条数据,只有 2 个单词。
print(emotions_df.iloc[4150]) # 查看第 4150 条数据emotions_df.iloc[4150]['text'] # 查看第 4150 条数据的文本内容输出为:
text earth crakelabel 4label_name fearword per tweet 2Name: 4150, dtype: object
'earth crake'1.3 text -> tokens
数据集转化为模型接受的输入类型:
- Subword Tokenization
- WordPiece
- BERT and DistilBERT
- WordPiece
- HuggingFace
~/.cache/huggingface/- model config
tokenizer.model_max_lengthtokenizer.model_input_names
我们接下来要做的就是——将原始文本转换为模型能理解的输入格式。
1.3.1 tokenizer
from transformers import AutoTokenizermodel_ckpt = 'distilbert-base-uncased'tokenizer = AutoTokenizer.from_pretrained(model_ckpt)这里的模型名称也包含一些信息 distilbert-base-uncased:
base表示模型大小,有medium,large,我们这里选用的是base大小;uncased表示对大小写不敏感,或者说,就是小写的意思,表示这个模型在训练时将所有文本转换为小写。
print(tokenizer.encode('hello world')) # 编码文本为 token id 列表print(tokenizer.encode('HELLO WORLD')) # 编码文本为 token id 列表,注意大写会被转换为小写print(tokenizer.encode('Hello World'))输出为:
[101, 7592, 2088, 102][101, 7592, 2088, 102][101, 7592, 2088, 102]可以看到,转化出来的 token id 都是相同的。
tokenizer.encode(emotions_df.iloc[6322]['text']) # 编码第 6322 条数据的文本内容为 token id 列表输出为:
[101, 1045, 3984,... 2005, 1996, 3480, 3924, 102]注意这里以 [CLS] 开头,[SEP] 结束。
print(tokenizer.vocab_size) # 查看 tokenizer 的词汇表大小print(tokenizer.model_max_length) # 查看 tokenizer 的最大输入长度print(tokenizer.model_input_names) # 查看 tokenizer 的输入名称列表输出为:
30522512['input_ids', 'token_type_ids', 'attention_mask']for special_id in tokenizer.all_special_ids: print(special_id, tokenizer.decode(special_id)) # 查看特殊 token 的 id 和对应的字符串输出为:
100 [UNK]102 [SEP]0 [PAD]101 [CLS]103 [MASK]这部分在前面我们探索 BERT 模型时已经进行了详尽的介绍,这里不再赘述。
1.3.2 tokenize the whole dataset
def batch_tokenize(batch): return tokenizer(batch['text'], padding=True, truncation=True)emotions_encoded = emotions.map(batch_tokenize, batched=True, batch_size=None)emotions_encoded输出为:
DatasetDict({ train: Dataset({ features: ['text', 'label', 'input_ids', 'token_type_ids', 'attention_mask'], num_rows: 16000 }) validation: Dataset({ features: ['text', 'label', 'input_ids', 'token_type_ids', 'attention_mask'], num_rows: 2000 }) test: Dataset({ features: ['text', 'label', 'input_ids', 'token_type_ids', 'attention_mask'], num_rows: 2000 })})可以看到,经过 tokenize 的数据集除了原来的 text 和 label 之外,还多出了 input_ids, token_type_ids, attention_mask.
batch_tokenize(emotions['train'][:5]) # 批量编码前五条数据的文本内容输出为:
{'input_ids': [[101, 1045, 2134, 2102, 2514, 26608, 102, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [101, 1045, 2064, 2175, 2013, 3110, 2061, 20625, 2000, 2061, 9636, 17772, 2074, 2013, 2108, 2105, 2619, 2040, 14977, 1998, 2003, 8300, 102], [101, 10047, 9775, 1037, 3371, 2000, 2695, 1045, 2514, 20505, 3308, 102, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [101, 1045, 2572, 2412, 3110, 16839, 9080, 12863, 2055, 1996, 13788, 1045, 2097, 2113, 2008, 2009, 2003, 2145, 2006, 1996, 3200, 102, 0], [101, 1045, 2572, 3110, 24665, 7140, 11714, 102, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]], 'token_type_ids': [[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]], 'attention_mask': [[1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], [1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]]}这里就是简单看一下具体编码出来的东西,还记得我们之前在 batch_tokenize 函数中设置了 padding=True.
print(type(emotions_encoded['train']['input_ids'])) # 查看编码后的输入 id 的数据类型emotions_encoded['train']['input_ids'][:3] # 查看编码后的输入 id 列表的前 3 条数据输出为:
datasets.arrow_dataset.Column
[[101, 1045, 2134, 2102, 2514, 26608, 102, 0,... 0, 0, 0, 0, 0]]这里的 Column 是 Hugging Face dataset 库中基于 Apache Arrow 格式的一种列式数据结构,可以简单地看作是一个增强版的 list:
- 它的外部接口兼容 list,使用起来与 list 一样;
- 内部存储用 Arrow,兼顾了易用性和高性能。
emotions_encoded.set_format(type='torch', columns=['input_ids', 'attention_mask', 'label']) # 设置数据集格式为 PyTorch,指定需要返回的列print(type(emotions_encoded['train']['input_ids'])) # 查看编码后的输入 id 的数据类型print(type(emotions_encoded['train'][0]['input_ids'])) # 取单行print(type(emotions_encoded['train'][:5]['input_ids'])) # 取多行输出为:
<class 'datasets.arrow_dataset.Column'><class 'torch.Tensor'><class 'torch.Tensor'>这里需要注意的是,set_format 只在我们 取行(样本) 时生效,而不是在取整列时生效。
这是因为 set_format 主要是为了配合 PyTorch 的 DataLoader 使用——DataLoader 按行(样本)逐个取数据,而不是按列取。
2. fine-tune transformers
2.1 distilbert-base-uncased
DistilBERT 是 BERT 的精简版(蒸馏版),通过**知识蒸馏(Knowledge Distillation)**技术训练得到。
核心区别:
| 对比项 | BERT-base | DistilBERT |
|---|---|---|
| 参数量 | ~110M | ~66M(减少 40%) |
| 层数(Transformer blocks) | 12 层 | 6 层(减半) |
| 推理速度 | 基准 | 快约 60% |
| 性能(准确率) | 基准 | 保留约 97% 的性能 |
| 模型大小(磁盘) | ~440MB | ~260MB |
如何实现的?
DistilBERT 使用 知识蒸馏 三步走:
1. 训练好一个大的 BERT(Teacher 模型)2. DistilBERT(Student 模型)复制 Teacher 的中间层的一半3. Student 学习模仿 Teacher 的输出分布,而不仅仅是 ground truth 标签关键点:
- 层数减半:6 层 vs 12 层(DistilBERT 每 2 层对应 BERT 的 1 层)
- 去掉了 Token Type IDs:DistilBERT 不支持
token_type_ids(因为蒸馏时去掉了下一句预测任务) - 保留了核心能力:仍支持 Masked LM,仍可用于分类、QA、NER 等任务
一句话总结:
DistilBERT ≈ BERT 的 “轻量版”——体积小 40%、速度快 60%、性能只下降约 3%,非常适合资源受限的场景或对推理延迟有要求的应用。
from transformers import AutoModel
model_ckpt = 'distilbert-base-uncased'model = AutoModel.from_pretrained(model_ckpt)model # 模型结构概览输出为:
DistilBertModel( (embeddings): Embeddings( (word_embeddings): Embedding(30522, 768, padding_idx=0) (position_embeddings): Embedding(512, 768) (LayerNorm): LayerNorm((768,), eps=1e-12, elementwise_affine=True) (dropout): Dropout(p=0.1, inplace=False) ) (transformer): Transformer( (layer): ModuleList( (0-5): 6 x TransformerBlock( (attention): DistilBertSelfAttention( (q_lin): Linear(in_features=768, out_features=768, bias=True) (k_lin): Linear(in_features=768, out_features=768, bias=True) (v_lin): Linear(in_features=768, out_features=768, bias=True) (out_lin): Linear(in_features=768, out_features=768, bias=True) (dropout): Dropout(p=0.1, inplace=False) ) (sa_layer_norm): LayerNorm((768,), eps=1e-12, elementwise_affine=True) (ffn): FFN( (dropout): Dropout(p=0.1, inplace=False) (lin1): Linear(in_features=768, out_features=3072, bias=True) (lin2): Linear(in_features=3072, out_features=768, bias=True) (activation): GELUActivation() ) (output_layer_norm): LayerNorm((768,), eps=1e-12, elementwise_affine=True) ) ) ))这里我们可以看到 DistilBERT 模型的整体架构:
2.1.1 DistilBERT 整体架构概览
DistilBertModel├── Embeddings(嵌入层)└── Transformer(Transformer 层) └── 6 × TransformerBlock ├── Attention(多头自注意力) ├── sa_layer_norm(注意力后的 LayerNorm) └── FFN(前馈神经网络) └── output_layer_norm(FFN 后的 LayerNorm)1️⃣ Embeddings(嵌入层)
Embeddings( (word_embeddings): Embedding(30522, 768) ← 词嵌入 (position_embeddings): Embedding(512, 768) ← 位置嵌入 (LayerNorm): LayerNorm(768) ← 层归一化 (dropout): Dropout(p=0.1) ← 随机失活)| 组件 | 参数 | 说明 |
|---|---|---|
word_embeddings | 30522 × 768 | 词汇表大小 30522,每个词映射为 768 维向量 |
position_embeddings | 512 × 768 | 最大序列长度 512,每个位置一个 768 维向量 |
LayerNorm | 768 | 层归一化,稳定训练 |
Dropout | p=0.1 | 10% 的神经元随机丢弃,防止过拟合 |
注意:与 BERT 不同,DistilBERT 没有
token_type_ids(片段嵌入),因为蒸馏时去掉了下一句预测任务。
2️⃣ Transformer(6 层 TransformerBlock)
(0-5): 6 x TransformerBlockBERT-base 有 12 层,DistilBERT 精简为 6 层,每层由 Attention + FFN 组成。
Attention(多头自注意力)
DistilBertSelfAttention( (q_lin): Linear(768 → 768) ← Query 投影 (k_lin): Linear(768 → 768) ← Key 投影 (v_lin): Linear(768 → 768) ← Value 投影 (out_lin): Linear(768 → 768) ← 输出投影 (dropout): Dropout(p=0.1))- 输入维度:768(每个 token 的隐藏状态)
- 输出维度:768
- 多头注意力:q/k/v 的维度 768 会被拆分为多个注意力头(通常是 12 头,每头 64 维)
- 计算流程:
out_lin将多头结果拼接后投影回 768 维
sa_layer_norm(注意力后的层归一化)
LayerNorm(768)在残差连接之后进行归一化。这是 DistilBERT 的标准化设计——先 Attention,再加残差,最后 LayerNorm。
FFN(前馈神经网络)
FFN( (lin1): Linear(768 → 3072) ← 升维 4 倍 (activation): GELUActivation() ← 激活函数 (lin2): Linear(3072 → 768) ← 降维回 768 (dropout): Dropout(p=0.1))- 两层线性层,中间是一个扩张-收缩结构:
lin1:768 → 3072(扩大 4 倍)GELU激活函数(高斯误差线性单元,比 ReLU 更平滑)lin2:3072 → 768(压缩回原始维度)
- 这种结构让模型能在更高维空间中学习复杂的特征交互
output_layer_norm
LayerNorm(768)与 Attention 一样,在 FFN 的残差连接之后再进行一次归一化。
2.1.2 单层 TransformerBlock 的数据流
输入 (768) ↓ ┌──────────────────────┐ │ Self-Attention │ │ Q/K/V 投影 + 多头计算 │ └────────┬─────────────┘ ↓ (残差连接) + 输入 ↓ LayerNorm (sa_layer_norm) ↓ ┌──────────────────────┐ │ FFN │ │ 768 → 3072 → 768 │ └────────┬─────────────┘ ↓ (残差连接) + 输入 ↓ LayerNorm (output_layer_norm) ↓ 输出 (768) → 进入下一层2.1.3 与 BERT-base 的架构差异总结
| 组件 | BERT-base (12 层) | DistilBERT (6 层) |
|---|---|---|
| 嵌入层 | word + position + token_type | word + position (无 token_type) |
| 每层结构 | Attention → LayerNorm → FFN → LayerNorm | (残差后) LayerNorm → Attention → LayerNorm → FFN |
| 注意力头数 | 12 头 | 12 头 |
| 隐藏维度 | 768 | 768 |
| 激活函数 | GELU | GELU |
架构上的主要区别是 LayerNorm 的位置——DistilBERT 将 LayerNorm 移到了残差连接之后(Post-LN 的变体),这是一种更稳定的设计选择。
from transformer_utils import get_params# 查看模型参数总数get_params(model) # np.int64(66362880)from transformers import AutoModel
model_ckpt = 'bert-base-uncased'model = AutoModel.from_pretrained(model_ckpt)get_params(model) # np.int64(109482240)可以看到,distilbert-base-uncased 仅有 6 千万参数量;而我们之前频繁接触的 bert-base-uncased 有超过 1 亿参数量。
model输出为:
BertModel( (embeddings): BertEmbeddings( (word_embeddings): Embedding(30522, 768, padding_idx=0) (position_embeddings): Embedding(512, 768) (token_type_embeddings): Embedding(2, 768) (LayerNorm): LayerNorm((768,), eps=1e-12, elementwise_affine=True) (dropout): Dropout(p=0.1, inplace=False) ) (encoder): BertEncoder( (layer): ModuleList( (0-11): 12 x BertLayer( (attention): BertAttention( (self): BertSelfAttention( (query): Linear(in_features=768, out_features=768, bias=True) (key): Linear(in_features=768, out_features=768, bias=True) (value): Linear(in_features=768, out_features=768, bias=True) (dropout): Dropout(p=0.1, inplace=False) ) (output): BertSelfOutput( (dense): Linear(in_features=768, out_features=768, bias=True) (LayerNorm): LayerNorm((768,), eps=1e-12, elementwise_affine=True) (dropout): Dropout(p=0.1, inplace=False) ) ) (intermediate): BertIntermediate( (dense): Linear(in_features=768, out_features=3072, bias=True) (intermediate_act_fn): GELUActivation() ) (output): BertOutput( (dense): Linear(in_features=3072, out_features=768, bias=True) (LayerNorm): LayerNorm((768,), eps=1e-12, elementwise_affine=True) (dropout): Dropout(p=0.1, inplace=False) ) ) ) ) (pooler): BertPooler( (dense): Linear(in_features=768, out_features=768, bias=True) (activation): Tanh() ))BERT 模型的结构我们在之前的章节中已经介绍地非常具体了,二者模型的架构也在前面进行了详尽的对比,这里仅做回顾。
from transformers import AutoModelForSequenceClassification
model_ckpt = 'distilbert-base-uncased'device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')model = AutoModelForSequenceClassification.from_pretrained(model_ckpt, num_labels=num_classes).to(device)model输出为:
DistilBertForSequenceClassification( (distilbert): DistilBertModel( (embeddings): Embeddings( (word_embeddings): Embedding(30522, 768, padding_idx=0) (position_embeddings): Embedding(512, 768) (LayerNorm): LayerNorm((768,), eps=1e-12, elementwise_affine=True) (dropout): Dropout(p=0.1, inplace=False) ) (transformer): Transformer( (layer): ModuleList( (0-5): 6 x TransformerBlock( (attention): DistilBertSelfAttention( (q_lin): Linear(in_features=768, out_features=768, bias=True) (k_lin): Linear(in_features=768, out_features=768, bias=True) (v_lin): Linear(in_features=768, out_features=768, bias=True) (out_lin): Linear(in_features=768, out_features=768, bias=True) (dropout): Dropout(p=0.1, inplace=False) ) (sa_layer_norm): LayerNorm((768,), eps=1e-12, elementwise_affine=True) (ffn): FFN( (dropout): Dropout(p=0.1, inplace=False) (lin1): Linear(in_features=768, out_features=3072, bias=True) (lin2): Linear(in_features=3072, out_features=768, bias=True) (activation): GELUActivation() ) (output_layer_norm): LayerNorm((768,), eps=1e-12, elementwise_affine=True) ) ) ) ) (pre_classifier): Linear(in_features=768, out_features=768, bias=True) (classifier): Linear(in_features=768, out_features=6, bias=True) (dropout): Dropout(p=0.2, inplace=False))2.1.4 DistilBertModel vs DistilBertForSequenceClassification
仔细观察输出,核心区别在于 分类头(Classification Head) 的添加:
DistilBertModel DistilBertForSequenceClassification│ │├── Embeddings ├── Embeddings(完全相同)│ ├── word_embeddings (30522, 768) │ ├── word_embeddings (30522, 768)│ ├── position_embeddings (512, 768) │ ├── position_embeddings (512, 768)│ ├── LayerNorm(768) │ ├── LayerNorm(768)│ └── Dropout(p=0.1) │ └── Dropout(p=0.1)│ │├── Transformer (6层) ← 完全相同 → ├── Transformer (6层)│ ││ ⭐ 多出的部分:││ ├── pre_classifier: Linear(768→768)│ ├── dropout: Dropout(p=0.2) ← 注意:0.2│ └── classifier: Linear(768→6)新增的三个组件详解
(pre_classifier): Linear(in_features=768, out_features=768, bias=True)(dropout): Dropout(p=0.2, inplace=False)(classifier): Linear(in_features=768, out_features=6, bias=True)| 组件 | 维度变化 | 作用 |
|---|---|---|
pre_classifier | 768 → 768 | 额外的线性变换层,在分类前做一次特征映射,增加模型表达能力 |
Dropout(p=0.2) | — | 20% 随机失活,比 base 模型的 0.1 更高,因为分类头容易过拟合 |
classifier | 768 → 6 | 最终分类层,将 768 维隐层状态映射到 6 类情感得分 |
这里需要注意的是,如果我们没有传入 num_labels=num_classes 参数,那么默认设置为 2 个分类头:
(pre_classifier): Linear(in_features=768, out_features=768, bias=True) (classifier): Linear(in_features=768, out_features=2, bias=True) (dropout): Dropout(p=0.2, inplace=False)而传入 num_labels=num_classes 后,根据我们前面计算的结果,num_classes=6,所以模型就有了 6 个分类头。
数据流对比
DistilBertModel (基础模型):输入 → Embeddings → 6层 Transformer → [CLS]位置的768维向量
DistilBertForSequenceClassification (分类模型):输入 → Embeddings → 6层 Transformer → [CLS]位置的768维向量 ↓ pre_classifier (768→768) ↓ ReLU 激活 ↓ Dropout(p=0.2) ↓ classifier (768→6) ↓ 6 类情感得分 (logits)关键点:
DistilBertModel输出的是所有 token 位置的完整 768 维隐层状态([batch_size, seq_len, 768]),你需要自己取[CLS]并添加分类层DistilBertForSequenceClassification已经内置了分类头,自动取[CLS]token 的输出经过分类头,直接输出 6 类情感得分([batch_size, 6]),可以直接用CrossEntropyLoss训练
一句话总结:
DistilBertForSequenceClassification=DistilBertModel+ 分类头。base 模型只负责提取特征,ForSequenceClassification在顶部额外挂了pre_classifier → Dropout → classifier三层,让你可以直接做分类任务。
在运行完上述代码后,此时如果我们用 nvidia-smi 查看显卡状态,会发现模型已经加载到了显存中。
此外,还要注意上述代码的另一段输出:
Loading weights: 100%|██████████| 100/100 [00:00<00:00, 16317.71it/s][transformers] [1mDistilBertForSequenceClassification LOAD REPORT[0m from: distilbert-base-uncasedKey | Status |------------------------+------------+-vocab_transform.bias | UNEXPECTED |vocab_layer_norm.bias | UNEXPECTED |vocab_projector.bias | UNEXPECTED |vocab_layer_norm.weight | UNEXPECTED |vocab_transform.weight | UNEXPECTED |pre_classifier.weight | MISSING |classifier.weight | MISSING |classifier.bias | MISSING |pre_classifier.bias | MISSING |
Notes:- UNEXPECTED: can be ignored when loading from different task/architecture; not ok if you expect identical arch.- MISSING: those params were newly initialized because missing from the checkpoint. Consider training on your downstream task.这里我们会发现,pre_classifier.weight, classifier.weight, classifier.bias 和 pre_classifier.bias 参数是没有被正确初始化的。换句话说,它们目前只是一些随机值。要想用 DistilBertForSequenceClassification 这个模型去做预测,我们需要在自己的下游任务中训练这个模型,以 fine-tune 这部分数值。
2.2 trainer
from huggingface_hub import notebook_login
notebook_login()from transformers import TrainingArguments, Trainerbatch_size = 64logging_steps = len(emotions_encoded['train']) // batch_sizemodel_name = f'{model_ckpt}-finetuned-emotion-classification'training_args = TrainingArguments( output_dir=model_name, learning_rate=2e-5, num_train_epochs=4, per_device_train_batch_size=batch_size, per_device_eval_batch_size=batch_size, eval_strategy='epoch', weight_decay=0.01, disable_tqdm=False, logging_steps=logging_steps, push_to_hub=True, log_level='error')我们来简单介绍一下上面的几个参数:
eval_strategy='epoch':评估策略;
控制什么时候在验证集上评估模型。
⚠️ 注意:在 transformers 5.x 版本中,这个参数已从
evaluation_strategy重命名为eval_strategy。如果用了旧名字会报错unexpected keyword argument。
| 取值 | 含义 |
|---|---|
'no' | 训练期间不评估 |
'steps' | 每 eval_steps 步评估一次 |
'epoch' | 每个 epoch 结束后评估一次 ✅ 最常用 |
设置为 'epoch' 意味着每个 epoch 跑完后,模型会自动在验证集上计算损失和准确率,这样你就可以看到每个 epoch 的训练损失和验证损失,判断模型是否过拟合。
weight_decay=0.01:权重衰减;
权重衰减是一种 正则化技术,也叫 L2 正则化。它的核心思想是:在每次更新参数时,让权重向零方向稍微缩小一点点,防止模型过度依赖某些特征。
数学原理
标准梯度下降更新公式:
加入权重衰减后:
展开:
可以看到,权重 在更新前先被乘以一个小于 1 的系数 ,这就是”衰减”的含义。
想象你在画一条曲线来拟合一些数据点。如果没有权重衰减,你可能会为了让曲线穿过每一个点而把曲线画得弯弯曲曲、剧烈抖动(过拟合)。权重衰减就像给曲线加了一个”弹性约束”——让曲线尽量平滑、简单,不要过度扭曲。
一些直觉理解:
- 过拟合的模型往往权重值很大、很极端(对某些特征过度敏感)
- 权重衰减惩罚大权重,迫使权重保持较小的值
- 小权重意味着模型对每个特征的依赖更均衡,不会因为某个特征而做出极端判断
实际效果:
- ✅ 防止过拟合
- ✅ 提高泛化能力
- ✅ 在 NLP 任务中几乎总是有益的
- ⚠️ 值太大(如 0.1)会导致欠拟合;值太小(如 0.0001)几乎没效果
0.01是一个比较常用且合理的值
disable_tqdm=False:显示进度条;
logging_steps=logging_steps:日志输出步长;
控制每隔多少个训练步输出一次日志(损失值、学习率等)。
在我们的设置中,训练集有 16000 条数据,每批 64 条,所以每个 epoch 有 250 步。 因此 logging_steps=250 意味着每训练完一个 epoch 输出一次日志。
关于如何选择合适的值:
| 场景 | logging_steps 建议 |
|---|---|
| 想看每步的损失变化 | 较小值,如 10 或 50 |
| 只想看每个 epoch 的概况 | len(train_dataset) // batch_size(即每 epoch 一次) |
| 训练很快(几分钟) | 小一些,可以看到损失下降曲线 |
| 训练很慢(几小时) | 大一些,避免日志太多刷屏 |
log_level='error':日志级别;
控制 Hugging Face Transformers 库本身输出的日志详细程度。
日志级别金字塔:
╔═══════════════╗ ║ CRITICAL ║ ← 最严重,程序可能崩溃 ╠═══════════════╣ ║ ERROR ║ ← 发生了错误但程序还能继续 ╠═══════════════╣ ║ WARNING ║ ← ⭐ 默认级别,潜在问题提醒 ╠═══════════════╣ ║ INFO ║ ← 一般信息(模型加载、保存等) ╠═══════════════╣ ║ DEBUG ║ ← 最详细,调试用 └───────────────┘设置的含义:
log_level | 显示的信息 | 举例 |
|---|---|---|
'debug' | 全部信息 | 最详细的调试输出 |
'info' | info 及以上 | ”Loading model…” “Saving checkpoint…” |
'warning' | warning 及以上 | ”Some weights are not used” ⭐ 默认 |
'error' | 仅 error | 只有真正的错误才显示 |
'critical' | 仅 critical | 几乎什么都不显示 |
在我们的场景下,log_level='error' 说明:
- 这意味着 Transformers 库内部的所有信息、警告都被抑制了
- 只有真正的错误才会打印出来
- 这样做的好处是输出更干净,不会被一大堆 “Some weights of the model checkpoint were not used…” 之类的警告刷屏
关于其他训练设置:
- trainer 默认自动开启 torch 的多 gpu 模式;
per_device_train_batch_size设定每个 GPU 上的样本数量;- 一般而言,多 GPU 模式希望多个 GPU 的性能尽量接近,否则多 GPU 的速度将由最慢的 GPU 决定;
per_device_eval_batch_size类似;learning_rate以及weight_decay默认采用 AdamW 的优化算法。
from transformer_utils import compute_classification_metrics
trainer = Trainer( model=model, args=training_args, train_dataset=emotions_encoded['train'], eval_dataset=emotions_encoded['validation'], compute_metrics=compute_classification_metrics)trainer.train()输出为:
TrainOutput(global_step=1000, training_loss=0.31053504371643065, metrics={'train_runtime': 123.707, 'train_samples_per_second': 517.352, 'train_steps_per_second': 8.084, 'total_flos': 1440685723392000.0, 'train_loss': 0.31053504371643065, 'epoch': 4.0})上面就是 Hugging Face Trainer 训练完成后输出的训练报告,包含了三个主要部分:
TrainOutput( global_step=1000, # ① 总步数 training_loss=0.31053504371643065, # ② 训练损失 metrics={...} # ③ 详细指标字典)global_step=1000— 总训练步数
training_loss=0.3105— 平均训练损失
损失函数(Loss) 衡量的是模型预测与真实标签之间的差距。
这是交叉熵损失(Cross-Entropy Loss),多分类任务的标配:
| 损失值 | 含义 |
|---|---|
| ~2.0 以上 | 模型几乎在瞎猜(6 类随机猜 ≈ log6 ≈ 1.79) |
| ~1.0 | 模型开始学到一些规律 |
| ~0.31(我们的值) | ✅ 模型学到了很强的规律 |
| ~0.0 | 完美拟合(也要小心过拟合) |
从开始时的较高损失下降到 0.31,说明模型收敛得很好。
metrics— 详细指标
训练用时:
| 指标 | 值 | 解读 |
|---|---|---|
train_runtime | 123.7 秒(约 2 分钟) | 训练总耗时 |
train_samples_per_second | 517.35 条/秒 | 每秒处理 517 条文本 |
train_steps_per_second | 8.08 步/秒 | 每秒更新 8 次参数 |
这里使用的设备是单张 RTX4080 laptop.
总计算量:
| 指标 | 值 | 解读 |
|---|---|---|
total_flos | 1.44 × 10¹⁵ FLOPs | 浮点运算总数 |
FLOPs(Floating Point Operations):衡量计算量的单位。这个数字的意思是——整个训练过程一共执行了约 1.4 千万亿次浮点运算。
最终损失:
| 指标 | 值 |
|---|---|
train_loss | 0.3105(和上面的 training_loss 一样) |
一张图总结:
preds_output = trainer.predict(emotions_encoded['validation'])preds_output输出为:
PredictionOutput(predictions=array([[ 5.302656 , -1.1895534 , -1.4358829 , -1.5534474 , -1.7579308 , -1.9462327 ], [ 5.2939024 , -1.075267 , -2.0208688 , -1.311696 , -1.3683631 , -1.9535345 ], [-1.6112493 , 2.7801807 , 3.1485417 , -1.6580076 , -2.5439014 , -2.3933396 ], ..., [-1.6095595 , 5.4891787 , -0.56173605, -1.5967947 , -2.361022 , -1.9980701 ], [-2.050608 , 3.349502 , 3.0134823 , -1.7501646 , -2.6897838 , -2.3941755 ], [-1.664848 , 5.413096 , -0.7544189 , -2.052569 , -2.1643562 , -1.0560712 ]], shape=(2000, 6), dtype=float32), label_ids=array([0, 0, 2, ..., 1, 1, 1], shape=(2000,)), metrics={'test_loss': 0.14947177469730377, 'test_accuracy': 0.934, 'test_precision': 0.9347108725250716, 'test_recall': 0.934, 'test_f1': 0.934171624082316, 'test_runtime': 1.3177, 'test_samples_per_second': 1517.783, 'test_steps_per_second': 24.285})上面就是 trainer.predict() 在 验证集(2000 条样本)上的输出,它的结构如下:
PredictionOutput( predictions=..., # ① 模型输出的原始分数(logits) label_ids=..., # ② 真实标签 metrics=... # ③ 评估指标)predictions:模型输出的原始分数。
形状为 (2000, 6),即 2000 条样本 × 6 类情感,每个值叫做 logit(logits)——也就是 softmax 之前的原始分数。
看前几条数据:
样本1: [ 5.30, -1.19, -1.44, -1.55, -1.76, -1.95] → 类别 0 (sadness) 得分最高 ✅样本2: [ 5.29, -1.08, -2.02, -1.31, -1.37, -1.95] → 类别 0 (sadness) 得分最高 ✅样本3: [-1.61, 2.78, 3.15, -1.66, -2.54, -2.39] → 类别 2 (love) 得分最高 ✅Logits 如何变成预测结果?
# compute_classification_metrics 中正是这样做的preds = pred.predictions.argmax(-1) # 取每行最大值所在的索引 → 预测类别| 原始 logits | softmax 后 | argmax(预测类别) |
|---|---|---|
| [5.30, -1.19, -1.44, …] | [0.998, 0.0003, 0.0002, …] | 0 (sadness) ✅ |
| [-1.61, 2.78, 3.15, …] | [0.006, 0.407, 0.574, …] | 2 (love) ✅ |
label_ids:真实标签
array([0, 0, 2, ..., 1, 1, 1], shape=(2000,))对应 6 类情感:
| 数字 | 情感 |
|---|---|
| 0 | sadness |
| 1 | joy |
| 2 | love |
| 3 | anger |
| 4 | fear |
| 5 | surprise |
metrics:评估指标 + 核心结果
总体性能:
| 指标 | 值 | 含义 |
|---|---|---|
test_loss | 0.1495 | 验证集上的损失,比训练损失 0.31 还低,说明模型泛化很好 |
test_accuracy | 0.934 (93.4%) | 准确率——2000 条中正确预测了约 1868 条 |
test_precision | 0.9347 | 精确率(加权平均) |
test_recall | 0.9340 | 召回率(加权平均) |
test_f1 | 0.9342 | F1 分数(精确率和召回率的调和平均) |
四个指标都接近 93.4%,说明模型在各类情感上表现均衡,没有对某些类别特别偏袒或歧视。
推理性能:
| 指标 | 值 | 含义 |
|---|---|---|
test_runtime | 1.32 秒 | 评估 2000 条只用了 1.3 秒 |
test_samples_per_second | 1517.78 条/秒 | 每秒处理约 1500 条推文 |
test_steps_per_second | 24.29 步/秒 | 每秒执行约 24 步 |
相比训练时每秒 517 条,推理速度快了 3 倍(因为推理不需要反向传播计算梯度)。
一张图总结:
preds_output = trainer.predict(emotions_encoded['validation'])y_preds = np.argmax(preds_output.predictions, axis=-1)这里我们就是提取预测结果中的类别,其中:
trainer.predict()返回一个PredictionOutput对象,包含三个部分:
PredictionOutput( predictions=array(shape=(2000, 6)), # ← 每条样本对 6 类情感的原始得分 (logits) label_ids=array(shape=(2000,)), # ← 每条样本的真实标签 (0~5) metrics={...} # ← 评估指标)np.argmax()就是沿着最后一个维度(6 类情感)找出得分最高的索引。predictions的形状如上所示,为 (2000, 6),每一行是这条样本对 6 类情感的得分。
y_true = emotions_encoded['validation']['label']这里我们从预处理好的验证集中提取 真实标签列。
# import importlib# import transformer_utils# importlib.reload(transformer_utils)from transformer_utils import plot_confusion_matrixplot_confusion_matrix(y_true, y_preds, labels=labels)输出如下图:

从输出的混淆矩阵可以看出:
- 97% 的 sadness 被正确预测;
- 分别有 1% 的 sadness 被错误预测为了 anger 和 fear.
- 95% 的 joy 被正确预测;
- 93% 的 anger 被正确预测;
- 91% 的 fear 被正确预测;
- 88% 的 love 被正确预测;
- 81% 的 surprise 被正确预测;
下面我们来看测试集的结果。
preds_output = trainer.predict(emotions_encoded['test'])y_preds = np.argmax(preds_output.predictions, axis=-1)y_true = emotions_encoded['test']['label']plot_confusion_matrix(y_true, y_preds, labels=labels)输出结果如下图:

可以看到,在测试集上,模型在区分 surprise 和 fear 时表现较差,有 23% 的 surprise 都被错误归类为了 fear.
3. result analysis
from torch.nn.functional import cross_entropytokenizer.model_input_namesdef forward_pass_with_label(batch): # 将输入数据移动到设备上,并过滤出模型需要的输入 inputs = {k: v.to(device) for k, v in batch.items() if k in tokenizer.model_input_names} with torch.no_grad(): outputs = model(**inputs) pred_label = torch.argmax(outputs.logits, dim=-1) loss = cross_entropy(outputs.logits, batch['label'].to(device), reduction='none') # 将 loss 和 pred_label 从 GPU 移动到 CPU,并转换为 numpy 数组返回 return {'loss': loss.cpu().numpy(), 'pred_label': pred_label.cpu().numpy()}这个函数的目的是对每一条样本单独计算损失,而非对整个 batch 求平均:
inputs = {k: v.to(device) for k, v in batch.items() if k in tokenizer.model_input_names}- 遍历 batch 中的所有字段,即
['text', 'label', 'input_ids', 'token_type_ids', 'attention_mask', 'loss', 'pred_label']; - 只保留
tokenizer.model_input_names中的字段,即['input_ids', 'token_type_ids', 'attention_mask'],排除label和text等非模型输入字段; - 将所有选中的输入移到 GPU 上,即
device.
with torch.no_grad(): outputs = model(**inputs) pred_label = torch.argmax(outputs.logits, dim=-1) loss = cross_entropy(outputs.logits, batch['label'].to(device), reduction='none')这里是很经典的前向传播过程了,因此这里只重点介绍一下 cross_entropy 的参数:
outputs.logits:预测,形状为 (batch_size, 6),原始的 logitsbatch['label'].to(device):真实的标签,形状为 (batch_size,),每个值 0-5.- 要注意的是因为我们前面是在 GPU 上创建了
inputs,而此时batch还在 CPU 上,PyTorch 要求参与计算的张量必须在同一个设备上,所以我们这里要将batch['label']移动到 GPU.
- 要注意的是因为我们前面是在 GPU 上创建了
reduction='none':表示不对 batch 求 mean,而是返回每个样本独立的 loss,形状从 () 变为 (batch_size,).- 默认情形下,
reduction='mean',即把一个 batch 中所有样本的损失平均成一个数。
- 默认情形下,
return {'loss': loss.cpu().numpy(), 'pred_label': pred_label.cpu().numpy()}- 将 GPU 上的张量移回 CPU,否则 HuggingFace Dataset 无法存储;
- 转为 numpy 数组,便于后续分析和可视化;
- 返回一个字典,包含两个字段:
loss和pred_label.
emotions_encoded['validation'] = emotions_encoded['validation'].map( forward_pass_with_label, batched=True, batch_size=16)通过 map 将 forward_pass_with_label 应用到整个验证集上。这样,验证集的每条样本都多了两个新字段:
loss:该样本的损失;pred_label:模型预测的类别。
emotions_encoded['validation']输出为:
Dataset({ features: ['text', 'label', 'input_ids', 'token_type_ids', 'attention_mask', 'loss', 'pred_label'], num_rows: 2000})可以看到,现在验证集中确实多出了两个新字段。
selected_cols = ['text', 'label', 'loss', 'pred_label']emotions_encoded.reset_format()valid_df = pd.DataFrame.from_dict( { 'text': emotions_encoded['validation']['text'], 'label': emotions_encoded['validation']['label'], 'pred_label': emotions_encoded['validation']['pred_label'], 'loss': emotions_encoded['validation']['loss'] })valid_df['label'] = valid_df['label'].apply(lambda x: labels[x])valid_df['pred_label'] = valid_df['pred_label'].apply(lambda x: labels[x])valid_df输出为:
text label pred_label loss0 im feeling quite sad and sorry for myself but ... sadness sadness 0.0050361 i feel like i am still looking at a blank canv... sadness sadness 0.0053982 i feel like a faithful servant love love 0.3403813 i am just feeling cranky and blue anger anger 0.0060644 i can have for a treat or if i am feeling festive joy joy 0.004903... ... ... ... ...1995 im having ssa examination tomorrow in the morn... sadness sadness 0.0060891996 i constantly worry about their fight against n... joy joy 0.0054741997 i feel its important to share this info for th... joy joy 0.0048901998 i truly feel that if you are passionate enough... joy joy 0.5756971999 i feel like i just wanna buy any cute make up ... joy joy 0.006726
2000 rows × 4 columnsvalid_df[valid_df['label'] != valid_df['pred_label']]输出为:
text label pred_label loss17 i know what it feels like he stressed glaring ... anger sadness 2.50466027 i feel as if i am the beloved preparing hersel... joy love 1.36900635 i am feeling very blessed today that they shar... joy love 0.83490460 i miss our talks our cuddling our kissing and ... love joy 0.94695491 i feel like the people i know are really gener... joy love 0.953229... ... ... ... ...1958 i so desperately want to be able to help but i... fear sadness 0.8503771963 i called myself pro life and voted for perry w... joy sadness 5.0888761964 i feel vaguely cheated and a little amused joy anger 4.0812201981 i spent a lot of time feeling overwhelmed with... fear surprise 0.7464321990 i just feel too overwhelmed i can t see the fo... fear surprise 0.984590
130 rows × 4 columns这里输出的就是验证集上所有预测错误的样本,总共 130 条。
# most labels incorrectlyvalid_df[valid_df['label'] != valid_df['pred_label']].label.value_counts()输出为:
labeljoy 38love 22fear 20anger 18sadness 17surprise 15Name: count, dtype: int64我们可以查看分类错误的样本里的内部分布。
valid_df.sort_values(by='loss', ascending=False).head(10)输出为:
text label pred_label loss1500 i guess we would naturally feel a sense of lon... anger sadness 6.6071521950 i as representative of everything thats wrong ... surprise sadness 6.4674031111 im lazy my characters fall into categories of ... joy fear 6.4275921509 i guess this is a memoir so it feels like that... joy fear 6.364819882 i feel badly about reneging on my commitment t... love sadness 6.1087791963 i called myself pro life and voted for perry w... joy sadness 5.0888761840 id let you kill it now but as a matter of fact... joy fear 5.073016318 i felt ashamed of these feelings and was scare... fear sadness 4.791914405 i have been feeling extraordinarily indecisive... fear joy 4.6771451836 i got a very nasty electrical shock when i was... fear anger 4.654828可以打印出 loss 最高的前 10 条样本。
我们可以举其中一个例子:
valid_df.iloc[882].text输出为:
'i feel badly about reneging on my commitment to bring donuts to the faithful at holy family catholic church in columbus ohio'中文翻译过来就是:“我为自己食言,没有给俄亥俄州哥伦布市圣家天主教堂的信徒们带甜甜圈而感到愧疚。”
你发现了吗?这其实是一个标记错误的数据,数据集中给它的 label 是 love,但显然,这句话并不是 love,因此模型预测的 sadness 反而是对的。
对于 dair-ai/emotion 这个数据集而言,它的数据来自于推文,一般的标注流程是:
由于情感标注本身就具有主观性,加之推文是 脱离上下文 被标注的,所以不同的标注者很有可能做出不同的标注。实际上,在学术数据集中,“噪声标签” 是普遍存在的。
所以,我们之前看到的模型 93.4% 的准确率,有一部分”错误”其实是标注本身的歧义造成的——模型可能在某些样本上才是”正确”的。
而通过 loss 排序,我们可以快速发现这些可疑的标注。因为模型在数据中的规律(而非噪声)上收敛,当遇到一个违背规律的标注时,它的 loss 就会异常高。
# less loss -> more confidentvalid_df.sort_values('loss', ascending=True).head(10)输出为:
text label pred_label loss1310 i feel like an ungrateful asshole sadness sadness 0.004158702 i only find out that they are looking and feel... joy joy 0.00417621 i feel try to tell me im ungrateful tell me im... sadness sadness 0.004195845 i already feel very glamorous have a great day... joy joy 0.0042041601 i feel so ungrateful when thinking saying thes... sadness sadness 0.0042051502 i feel ungrateful for stupid shit like sadness sadness 0.004215212 i own the brushes are constantly used and i fe... joy joy 0.004218578 i got to christmas feeling positive about the ... joy joy 0.0042221466 i feel so ungrateful to be wishing this pregna... sadness sadness 0.004224133 i and feel quite ungrateful for it but i m loo... sadness sadness 0.004237我们再来看升序的情况。可以发现,模型对预测 joy 和 sadness 非常自信
4. to huggingface hub
# 1. 设置标签映射到模型 config 中id2label = {0: 'sadness', 1: 'joy', 2: 'love', 3: 'anger', 4: 'fear', 5: 'surprise'}label2id = {v: k for k, v in id2label.items()}model.config.id2label = id2labelmodel.config.label2id = label2id
# 2. 推送 tokenizer(包含标签映射)Hug_name = "" # 替换为你的 Hugging Face Hub 用户名tokenizer.push_to_hub(f"{Hug_name}/distilbert-base-uncased-finetuned-emotion-classification")
# 3. 强制重新推送模型权重trainer.push_to_hub(commit_message="Fix: add id2label and re-upload fine-tuned weights")这里我们就是将微调好的模型推送到 HuggingFace Hub 上:
我们上传的内容包括:
pytorch_model.bin— 模型权重(66M 参数的数值)config.json— 模型配置(6 分类、隐藏层维度等)tokenizer.json/vocab.txt— 分词器文件
上传后,任何人都可以以类似下面的方式加载我们的模型:
from transformers import pipeline
# 就像从官网加载 distilbert 一样classifier = pipeline( "text-classification", model="你的用户名/distilbert-base-uncased-finetuned-emotion-classification")
classifier("I feel so happy today!")# [{'label': 'joy', 'score': 0.99}]我们在下面给出了示例,加载你刚刚上传到 HuggingFace 的模型直接用于推理:
# hide_outputfrom transformers import pipeline
# 清除缓存后重新加载import shutilcache_dir = "C:\\Users\\HP\\.cache\\huggingface\\hub\\models--hac1224--distilbert-base-uncased-finetuned-emotion-classification"shutil.rmtree(cache_dir, ignore_errors=True)
# 创建 classifierclassifier = pipeline( "text-classification", model="hac1224/distilbert-base-uncased-finetuned-emotion-classification")pipeline 会自动帮你完成这些步骤:
- 加载模型,相当于
AutoModelForSequenceClassification.from_pretrained(...); - 加载分词器,相当于
AutoTokenizer.from_pretrained(...); - 文本编码,相当于
tokenizer(text, padding=True, truncation=True); - 模型推理,相当于
model(**inputs); - 后处理,相当于
softmax->argmax-> 映射标签名;
通过 classifier 创建一个分类器后,我们之后就可以像这样使用了:
classifier("I feel so happy today!")# 输出: [{'label': 'joy', 'score': 0.998}]它会自动:
- 从 Hub 下载模型权重和分词器
- 对输入文本进行分词
- 用模型推理得到 logits
- 通过 softmax 计算概率
- 返回 label(标签名)和 score(置信度)
所以,pipline = 模型 + 分词器 + 预处理 + 后处理的”一键打包”,我们不需要手写任何预处理或后处理代码,输入文本直接出结果。
# custom_tweet = 'I saw a movie today and it was really good.'custom_tweet = 'I saw a movie today and it suck.'preds = classifier(custom_tweet, top_k=None)preds输出为(top_k=None 返回全部 6 类的概率):
# 第一句话的输出[{'label': 'joy', 'score': 0.9890854954719543}, {'label': 'anger', 'score': 0.003720962442457676}, {'label': 'sadness', 'score': 0.002655301010236144}, {'label': 'love', 'score': 0.002034853445366025}, {'label': 'surprise', 'score': 0.0015041438164189458}, {'label': 'fear', 'score': 0.0009992503328248858}]
# 第二句话的输出[{'label': 'anger', 'score': 0.8032434582710266}, {'label': 'sadness', 'score': 0.09835370630025864}, {'label': 'fear', 'score': 0.04329225793480873}, {'label': 'joy', 'score': 0.040239084511995316}, {'label': 'love', 'score': 0.008198064751923084}, {'label': 'surprise', 'score': 0.006673524621874094}]注意:在 transformers 5.x 中,
return_all_scores=True已废弃,需用top_k=None代替。
preds_df = pd.DataFrame(preds)plt.bar(preds_df['label'], 100 * preds_df['score'], color='blue') # 这里之前是 labelsplt.title(f'"{custom_tweet}"')plt.ylabel("Class probability (%)")plt.show()输出如下图:


文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!