01_tokenizer_sentiment_analysis

3214 字
16 分钟
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*TokenizerAutoModel* 被称为 Generic Type,意思是「根据你给的模型名称,自动猜出该用哪个具体类」。

from transformers import AutoTokenizer, AutoModelForSequenceClassification

AutoTokenizer 本身是一个”自动调度器”,你不需要手动指定:

# ❌ 如果不通用,你需要自己判断:
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) # 自动选 DistilBertTokenizer
model = AutoModelForSequenceClassification.from_pretrained(model_name) # 自动选 DistilBertForSequenceClassification

Auto* 系列就是 Hugging Face 提供的一个”万能接口”——你只需要告诉它模型名,它就自动匹配正确的 tokenizer 类和模型类,对初学者非常友好,不需要记每种模型的具体类名。

1.2 Tokenizer 要与 Model 相匹配是什么意思?#

核心意思:Tokenizer 和 Model 必须来自同一个「预训练 checkpoint」,保证它们的”词汇表”一致。

什么是 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)

这里 tokenizermodel 用的是同一个 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_mask1=真实词, 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=512padding=True 时,max_length 的设置实质上没有意义,因为 padding 会自动遍历所有句子,找到最长的长度,然后补 0 到那个长度。
  • padding='max_length' 时,会补 0 直到 512 位(max_length 的值)。

2. 调用模型#

import torch
import torch.nn.functional as F

torch.nn.functional(通常简写为 F)是 PyTorch 中包含各种神经网络函数的子模块。它提供了无状态的函数式 API,比如:

函数作用
F.softmax()把分数转成概率
F.relu()激活函数
F.sigmoid()激活函数,输出 0~1
F.cross_entropy()计算分类任务的损失
F.dropout()随机丢弃神经元,防止过拟合
F.conv2d()二维卷积操作
……几十种常用函数

torch.nn 的区别:

torch.nntorch.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_layers6Transformer 层数(BERT base 是 12 层,DistilBERT 精简了一半)
n_heads12每层的注意力头数
dim768隐藏层的维度(每个 token 被编码成 768 维的向量)
hidden_dim3072前馈网络的中间层维度
vocab_size30522词表大小——和 tokenizer 的 vocab_size 一致
max_position_embeddings512模型能处理的最大 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) 中的 ** 会把字典展开,等价于:
# 不要执行此 cell
model(
input_ids=tensor([[...], [...]]),
token_type_ids=tensor([[...], [...]]),
attention_mask=tensor([[...], [...]])
)

所以,model(**batch_input) 就是把 tokenizer 准备好的 3 个张量,作为关键字参数一次性传给模型,让模型进行前向计算。

文章分享

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

01_tokenizer_sentiment_analysis
https://github.com/chunhuizhang/bilibili_vlogs/blob/master/fine_tune/bert/tutorials/06_attn_01.ipynb
作者
HAC
发布于
2026-06-06
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
HAC
观之非易,行且克难
Greetings
欢迎来到我的博客!这里主要分享我的学习笔记与兴趣爱好。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
32
分类
5
标签
13
总字数
79,889
运行时长
0
最后活动
0 天前

文章目录