01_tokenizer_sentiment_analysis
0. 写在前面
BERT(Bidirectional Encoder Representations from Transformers)是 Google 在 2018 年提出的预训练语言模型。它的核心思想可以概括为:
先在海量文本上”自学”语言规律(预训练),再针对具体任务”微调”(Fine-tuning)。
打个比方:BERT 就像一个已经读过整个维基百科和大量书籍的”语文大师”——它已经掌握了语法、词汇、上下文关系。你只需要在它上面加一层简单的分类器,就能让它做情感分析、问答、文本分类等任务,而不需要从零训练一个大模型。
它可以做:
| 应用场景 | 例子 |
|---|---|
| 情感分析 | 判断评论是正面还是负面 |
| 文本分类 | 新闻分类、垃圾邮件过滤 |
| 问答系统 | 给定文章,回答问题 |
| 命名实体识别 | 提取人名、地名、组织名 |
| 语义搜索 | 理解搜索意图而非关键词匹配 |
BERT 是 NLP 领域的奠基性工作,后来的 GPT、T5、LLaMA 等模型都在此基础上发展而来。可以说它是进入现代 NLP 和 LLM 世界的”必修课”。
这是 BERT 系列的第一节,用 DistilBERT(一个轻量版 BERT)做情感分析,手把手带你跑通整个推理流程。
整篇笔记分为两大块:我们首先看看 Tokenizer 是如何构造输入的,接着调用模型完成一次实际的推理过程。
1. Tokenizer 构造输入
- tokenizer, model :相匹配,tokenizer outputs -> model intput
- Auto*Tokenizer, AutoModel* :Generic type
test_sentences = ['today is not that bad', 'today is so bad']model_name = 'distilbert-base-uncased-finetuned-sst-2-english'1.1 Generic Type(通用类型)是什么意思?
这里的 Auto*Tokenizer、AutoModel* 被称为 Generic Type,意思是「根据你给的模型名称,自动猜出该用哪个具体类」。
from transformers import AutoTokenizer, AutoModelForSequenceClassificationAutoTokenizer 本身是一个”自动调度器”,你不需要手动指定:
# ❌ 如果不通用,你需要自己判断:from transformers import DistilBertTokenizer, DistilBertForSequenceClassification
tokenizer = DistilBertTokenizer.from_pretrained(model_name)model = DistilBertForSequenceClassification.from_pretrained(model_name)
# ✅ 用 Auto 系列,框架自动帮你选:from transformers import AutoTokenizer, AutoModelForSequenceClassification
tokenizer = AutoTokenizer.from_pretrained(model_name) # 自动选 DistilBertTokenizermodel = AutoModelForSequenceClassification.from_pretrained(model_name) # 自动选 DistilBertForSequenceClassificationAuto* 系列就是 Hugging Face 提供的一个”万能接口”——你只需要告诉它模型名,它就自动匹配正确的 tokenizer 类和模型类,对初学者非常友好,不需要记每种模型的具体类名。
1.2 Tokenizer 要与 Model 相匹配是什么意思?
核心意思:Tokenizer 和 Model 必须来自同一个「预训练 checkpoint」,保证它们的”词汇表”一致。
Checkpoint 本质上就是「训练过程中某一个时刻保存下来的完整模型快照」,包含模型权重、优化器状态、配置等。
你可以把它理解为一个游戏存档,模型训练到某个时刻保存下来,下次可以直接从这个状态继续训练或推理。
在下面的代码中,这个 model_name 就是 Hugging Face 模型中心(Hub)上的一个 checkpoint 的名称。它对应的是 Hugging Face 团队(或社区)在训练 DistilBERT 模型到最佳状态时保存下来的完整快照。
一个典型的 checkpoint 包含:
distilbert-base-uncased-finetuned-sst-2-english/├── config.json ← 模型架构配置(几层transformer、隐藏层大小等)├── model.safetensors ← 模型权重参数(就是那个 104 个权重文件)├── tokenizer_config.json ← tokenizer 配置├── vocab.txt ← 词表(每个词对应哪个数字ID)└── ... ← 其他辅助文件用代码来说明:
tokenizer = AutoTokenizer.from_pretrained(model_name)model = AutoModelForSequenceClassification.from_pretrained(model_name)这里 tokenizer 和 model 用的是同一个 model_name(同一个 checkpoint),这就是”相匹配”。
为什么需要匹配?
- Tokenizer 的工作是把文本变成数字 ID:
"today is not that bad"→[101, 2651, 2003, 2025, 2008, 2919, 102] - 模型内部有一个 Embedding 层,它有一个固定的词表,比如第 2651 号位置存着
"today"的向量表示。 - 如果 tokenizer 和 model 不匹配,比如用 BERT 的 tokenizer 给 DistilBERT 模型用——tokenizer 把
"today"编成了编号 1234,但 DistilBERT 的第 1234 号位置存的是另一个词(比如"planet"),那模型就会理解错,输出毫无意义的结果。
所以 tokenizer outputs → model input 这条线意味着:tokenizer 产出的数字 ID 必须和 model 内部的词表一一对应,才能正确输入模型。
# 演示子词切分:遇到生词怎么办?unknown_words = ["today is not that bad", "today is so bad", "today is unbelievably bad", "antidisestablishment"]for w in unknown_words: tokens = tokenizer.tokenize(w) ids = tokenizer.convert_tokens_to_ids(tokens) print(f"原句: {w}") print(f" 子词: {tokens}") print(f" IDs : {ids}\n")
# 看看词表大小print(f"词表大小: {tokenizer.vocab_size}")print(f"unk_token: {tokenizer.unk_token} (ID={tokenizer.unk_token_id})")输出为:
原句: today is not that bad 子词: ['today', 'is', 'not', 'that', 'bad'] # .tokenize() 的输出 IDs : [2651, 2003, 2025, 2008, 2919] # .convert_tokens_to_ids() 的输出
原句: today is so bad 子词: ['today', 'is', 'so', 'bad'] IDs : [2651, 2003, 2061, 2919]
原句: today is unbelievably bad 子词: ['today', 'is', 'un', '##bel', '##ie', '##va', '##bly', 'bad'] IDs : [2651, 2003, 4895, 8671, 2666, 3567, 6321, 2919]
原句: antidisestablishment 子词: ['anti', '##dis', '##est', '##ab', '##lish', '##ment'] IDs : [3424, 10521, 4355, 7875, 13602, 3672]
词表大小: 30522 # .vocab_size 的输出unk_token: [UNK] (ID=100)batch_input = tokenizer(test_sentences, padding=True, truncation=True, return_tensors='pt')print(batch_input)输出为:
{'input_ids': tensor([[ 101, 2651, 2003, 2025, 2008, 2919, 102], [ 101, 2651, 2003, 2061, 2919, 102, 0]]), 'token_type_ids': tensor([[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0]]), 'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 0]])}参数解析:
padding=True:补齐到相同长度。因为test_sentences是两个长度不同的句子,所以padding=True的作用是把短的句子用 0 补齐,让同一个 batch 里的所有句子长度相同,才能拼成一个矩阵输入模型。- 从上面的输出中可以很清楚地看到,第一句:
[101, 2651, 2003, 2025, 2008, 2919, 102];第二句:[ 101, 2651, 2003, 2061, 2919, 102, 0].
- 从上面的输出中可以很清楚地看到,第一句:
truncation=True:超长截断。如果某个句子超过了模型能接受的最大长度(DistilBERT 是 512 个 token),就截掉超出的部分。对于你的短句不影响,但如果你处理一篇长文章时会非常重要。return_tensors='pt':告诉 tokenizer 返回的数据格式:'pt'→ PyTorch 张量(我们用的框架)'tf'→ TensorFlow 张量'np'→ NumPy 数组- 不指定 → Python 列表
输出解析:
input_ids:每个词在词表中的编号。例如 101=CLS, 2651=today, 102=SEP, 0=PAD.101=CLS:BERT 模型人为加入的特殊标记(Special Tokens),放在每个输入句子的最开头,它的最终隐藏状态(最后一层 Transformer 输出的向量)被设计用来代表整个句子的语义,用于分类任务。102=SEP:放在每个句子的末尾,或者用于分隔两个句子。
token_type_ids:区分不同句子的标记。全 0 说明只有一个句子(非句子对任务)。attention_mask:1=真实词, 0=填充的无效位。第二行最后一个 0 表示那个位置是补的,模型应该忽略它。attention_mask特别重要:它告诉模型”别管那些补的 0,它们不是真正的词”,这样模型计算注意力时就不会被无意义的 padding 干扰。
其他常见的特殊标记:
[CLS]=101:分类标记,在句首[SEP]=102:分隔/结束标记[PAD]=0:填充标记,用于补齐长度[UNK]=100:未知词标记,遇到不认识的字[MASK]=103:掩码标记,BERT 预训练时遮住词用
一些其他的调用:
tokenizer(test_sentences[0])
tokenizer.encode(test_sentences[0])
tokenizer.tokenize(test_sentences[0])输出分别为:
{'input_ids': [101, 2651, 2003, 2025, 2008, 2919, 102], 'token_type_ids': [0, 0, 0, 0, 0, 0, 0], 'attention_mask': [1, 1, 1, 1, 1, 1, 1]}
[101, 2651, 2003, 2025, 2008, 2919, 102]
['today', 'is', 'not', 'that', 'bad']实际上我们可以看出 tokenizer.encode() = tokenizer.tokenize() + tokenizer.convert_tokens_to_ids(). 其中:
tokenizer.encode()将单词编码为数字,且有特殊标记;tokenizer.tokenize()是分词器,将句子拆成词;tokenizer.convert_tokens_to_ids()将单词编码为数字,但不加特殊标记。
接着我们来看解码的过程:
tokenizer.decode([101, 2651, 2003, 2025, 2008, 2919, 102])
tokenizer.vocab输出为:
'[CLS] today is not that bad [SEP]'
{'constitutional': 6543, 'forearms': 27323, '##ythe': 26688, '##ל': 29799, '[unused89]': 90,... 'brothel': 27308, 'elsewhere': 6974, 'rey': 12569, '##º': 29662, ...}可以看出,tokenizer 的原理就是 tokenizer.vocab 字典存储了 token -> id 的映射关系,包括某些特殊的 token.
tokenizer 的参数如下:
tokenizer(test_sentences, max_length=512, padding=True, truncation=True, return_tensors='pt')
tokenizer(test_sentences, max_length=512, padding='max_length', truncation=True, return_tensors='pt')输入如下:
{'input_ids': tensor([[ 101, 2651, 2003, 2025, 2008, 2919, 102], [ 101, 2651, 2003, 2061, 2919, 102, 0]]), 'token_type_ids': tensor([[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0]]), 'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 0]])}
{'input_ids': tensor([[ 101, 2651, 2003, ..., 0, 0, 0], [ 101, 2651, 2003, ..., 0, 0, 0]]), 'token_type_ids': tensor([[0, 0, 0, ..., 0, 0, 0], [0, 0, 0, ..., 0, 0, 0]]), 'attention_mask': tensor([[1, 1, 1, ..., 0, 0, 0], [1, 1, 1, ..., 0, 0, 0]])}可见:
- 当同时设置
max_length=512和padding=True时,max_length的设置实质上没有意义,因为 padding 会自动遍历所有句子,找到最长的长度,然后补 0 到那个长度。 - 当
padding='max_length'时,会补 0 直到 512 位(max_length的值)。
2. 调用模型
import torchimport torch.nn.functional as Ftorch.nn.functional(通常简写为 F)是 PyTorch 中包含各种神经网络函数的子模块。它提供了无状态的函数式 API,比如:
| 函数 | 作用 |
|---|---|
F.softmax() | 把分数转成概率 |
F.relu() | 激活函数 |
F.sigmoid() | 激活函数,输出 0~1 |
F.cross_entropy() | 计算分类任务的损失 |
F.dropout() | 随机丢弃神经元,防止过拟合 |
F.conv2d() | 二维卷积操作 |
| …… | 几十种常用函数 |
和 torch.nn 的区别:
torch.nn | torch.nn.functional | |
|---|---|---|
| 使用方式 | nn.Linear(in, out) 创建对象 | F.linear(x, w, b) 直接调用函数 |
| 是否有参数 | ✅ 带可学习参数(权重、偏置) | ❌ 无参数,参数需自己传入 |
| 例子 | layer = nn.Linear(10, 5) → layer(x) | F.linear(x, weight, bias) |
类比:
nn.Linear像一个带水箱的电热水壶——插上电就能用,它自己管理水(参数)F.linear像一个燃气灶上的水壶——你得自己倒水进去(手动传参数)
F.softmax() 是无参数的函数——它只是做个数学变换,不需要学习任何东西,所以这里用 functional 版本非常合适。如果是需要训练的层(如线性层、卷积层),则用 nn.Module 版本。
model.config输出为:
DistilBertConfig { "activation": "gelu", "architectures": [ "DistilBertForSequenceClassification" ], "attention_dropout": 0.1, "bos_token_id": null, "dim": 768, "dropout": 0.1, "dtype": "float32", "eos_token_id": null, "finetuning_task": "sst-2", "hidden_dim": 3072, "id2label": { "0": "NEGATIVE", "1": "POSITIVE" }, "initializer_range": 0.02, "label2id": { "NEGATIVE": 0, "POSITIVE": 1 }, "max_position_embeddings": 512, "model_type": "distilbert", "n_heads": 12,... "tie_weights_": true, "tie_word_embeddings": true, "transformers_version": "5.9.0", "vocab_size": 30522}简单来说,model.config 会打印出模型的完整配置信息——它对应的是 checkpoint 中的 config.json 文件,描述了模型的架构设计和任务相关信息。
挑几个关键字段解释:
| 字段 | 值 | 含义 |
|---|---|---|
model_type | "distilbert" | 模型架构类型 |
n_layers | 6 | Transformer 层数(BERT base 是 12 层,DistilBERT 精简了一半) |
n_heads | 12 | 每层的注意力头数 |
dim | 768 | 隐藏层的维度(每个 token 被编码成 768 维的向量) |
hidden_dim | 3072 | 前馈网络的中间层维度 |
vocab_size | 30522 | 词表大小——和 tokenizer 的 vocab_size 一致 |
max_position_embeddings | 512 | 模型能处理的最大 token 长度 |
activation | "gelu" | 激活函数类型 |
finetuning_task | "sst-2" | 在 SST-2(斯坦福情感分析数据集)上微调过 |
id2label | {"0": "NEGATIVE", "1": "POSITIVE"} | 数字 ID 到标签名的映射 |
label2id | {"NEGATIVE": 0, "POSITIVE": 1} | 标签名到数字 ID 的反向映射 |
with torch.no_grad(): outputs = model(**batch_input) print(outputs) scores = F.softmax(outputs.logits, dim=1) # dim=1 对每行 softmax print(scores) labels = torch.argmax(scores, dim=1) # dim=1 对每行取最大值的索引 print(labels) labels = [model.config.id2label[id] for id in labels.tolist()] # 把数字标签翻译成人类可读的文本标签 print(labels)输出:
SequenceClassifierOutput(loss=None, logits=tensor([[-3.4620, 3.6118], [ 4.7508, -3.7899]]), hidden_states=None, attentions=None)
tensor([[8.4632e-04, 9.9915e-01], [9.9980e-01, 1.9531e-04]])
tensor([1, 0])
['POSITIVE', 'NEGATIVE']输出解析:
loss=None:损失值。- 训练时:这里会是一个数字,表示预测和真实标签之间的差距,用来反向传播更新参数
- 推理时:因为没有真实标签,所以 loss=None
logits:模型的原始预测分数(最关键的部分)。这是模型最终的输出,形状是(batch_size=2, num_labels=2):
句子1 句子2 ┌──────────────┐ ┌──────────────┐ │ 负面 正面 │ │ 负面 正面 │logits = [[-3.4620, 3.6118], [4.7508, -3.7899]]hidden_states=None:每一层的隐藏状态。- 如果你在调用模型时传
output_hidden_states=True,这里会返回每一层 Transformer 的输出向量 - 默认为 None,不返回(节省内存)
- 如果你在调用模型时传
attentions=None:注意力权重。- 如果你传
output_attentions=True,这里会返回每一层的注意力分数,可以可视化看模型关注了哪些词 - 默认为 None,不返回
- 如果你传
代码解析:
with:进入 with 块时开启某种模式,退出时自动恢复原状,省去手动开关的麻烦。在这里,我们进入with块时开启“不记录梯度”的模式,退出with后,恢复正常的梯度记录模式。torch.no_grad():这是 PyTorch 中的推理模式,关闭梯度计算,有两个好处:- 节省内存(最主要)—— 不记录计算图,显存占用大幅降低,对长文本或大 batch 尤其明显;
- 加速计算 —— 省去了构建计算图和跟踪梯度的开销。
**batch_input:**是字典解包(Dictionary Unpacking)运算符。我们已经知道batch_input是一个字典,model(**batch_input)中的**会把字典展开,等价于:
# 不要执行此 cellmodel( input_ids=tensor([[...], [...]]), token_type_ids=tensor([[...], [...]]), attention_mask=tensor([[...], [...]]))所以,model(**batch_input) 就是把 tokenizer 准备好的 3 个张量,作为关键字参数一次性传给模型,让模型进行前向计算。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!