05_BERT+embedding_source_code
本节主要关注 BERT 模型中 Embedding 部分的细节与源码。

1. 总览
- BERT imput embedding: 一种查表操作(lookup table)
- 查表
- token embeddings:
- segment embeddings:
- position embeddings:
- 后处理
- layer norm
- dropout
- 查表
1.1 详解
“查表” 这个词很形象:Embedding 本质上就是一张表(矩阵),每一行代表一个”东西”的向量表示。输入一个编号,就去表里找出对应的那一行向量。
BERT 的输入 Embedding 由 三张表(三个子嵌入)相加而成:
1.1.1 Token Embeddings — 30522 × 768
- 30522:BERT 的词表大小,也就是 BERT 认识的”单词/子词”总数。
- 768:每个 token 被映射成一个 768 维的向量。
- 含义:这本质上是一个大矩阵,有 30522 行,每行是一个 768 维的向量。输入一个 token(比如 “猫”),就在这张表里找到对应的那一行,取出一个 768 维的向量。
- 直觉理解:把每个单词”翻译”成计算机能理解的一串数字(向量)。
1.1.2 Segment Embeddings — 0/1 × 8 = 768
- BERT 在处理句子对任务(如问答、推理)时,需要区分两个句子。
- 0/1:第一个句子标记为 0,第二个句子标记为 1。
- 8 是隐藏维度(实际上就是 768),写法
0/1 × 8 = 768意思是:- 句子 A 的 segment embedding = 一个 768 维的全 0 向量
- 句子 B 的 segment embedding = 一个 768 维的全 1 向量
- 直觉理解:告诉 BERT “这个词属于第一句还是第二句”。
1.1.3 Position Embeddings — 512 × 768
- 512:BERT 最大能处理的序列长度(512 个 token)。
- 768:每个位置也是一个 768 维向量。
- 位置 0 有一个向量,位置 1 有另一个向量……位置 511 也有一个向量。
- 直觉理解:因为 Transformer 没有”顺序感”,位置嵌入负责告诉模型”这个词在句子的第几个位置”。
1.1.4 核心公式
三个 768 维向量逐元素相加,结果还是一个 768 维向量,作为 BERT 第一层的输入。
1.1.5 后处理(Post-processing)
相加之后,还要经过两步:
- LayerNorm(层归一化) — 把数据拉到一个稳定的范围,加速训练、防止梯度爆炸/消失。
- Dropout — 训练时随机”丢弃”一部分神经元(设为 0),防止过拟合。
1.2 一张图总结
| 嵌入类型 | 矩阵维度 | 作用 |
|---|---|---|
| Token Embedding | 把词变成向量 | |
| Segment Embedding | 区分句子 A/B | |
| Position Embedding | 告诉位置信息 |
最后三个向量相加 → LayerNorm → Dropout,就得到了 BERT 的最终输入。
from transformers import BertTokenizer, BertModelimport torchfrom torch import nnmodel_name = 'bert-base-uncased'tokenizer = BertTokenizer.from_pretrained(model_name)model = BertModel.from_pretrained(model_name)test_sentence = "This is a test sentence"inputs = tokenizer(test_sentence, return_tensors='pt')inputs输出为:
{'input_ids': tensor([[ 101, 2023, 2003, 1037, 3231, 6251, 102]]), 'token_type_ids': tensor([[0, 0, 0, 0, 0, 0, 0]]), 'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1]])}上述代码把句子 “This is a test sentence” 分词成了 7 个 token:
| token ID | 含义 |
|---|---|
| 101 | [CLS] — 句子开头标记 |
| 2023 | ”this” |
| 2003 | ”is” |
| 1037 | ”a” |
| 3231 | ”test” |
| 6251 | ”sentence” |
| 102 | [SEP] — 句子结束/分隔标记 |
input_ids = inputs['input_ids']token_type_ids = inputs['token_type_ids']model.embeddings(input_ids=input_ids, token_type_ids=token_type_ids)输出为:
tensor([[[ 0.1686, -0.2858, -0.3261, ..., -0.0276, 0.0383, 0.1640], [-0.6485, 0.6739, -0.0932, ..., 0.4475, 0.6696, 0.1820], [-0.6270, -0.0633, -0.3143, ..., 0.3427, 0.4636, 0.4594], ..., [ 0.6010, -0.6970, -0.2001, ..., 0.2960, 0.2060, -1.7181], [ 0.8323, 0.2878, 0.0021, ..., 0.2628, -1.1310, -1.2708], [-0.1481, -0.2948, -0.1690, ..., -0.5009, 0.2544, -0.0700]]], grad_fn=<NativeLayerNormBackward0>)这调用了 BERT 的 Embedding 层(我们上一轮刚讲过的那三层:Token + Segment + Position 嵌入相加 → LayerNorm → Dropout),返回的就是这 7 个 token 的最终嵌入向量。
这个张量的形状是 [1, 7, 768]:
| 维度 | 大小 | 含义 |
|---|---|---|
| dim 0 | 1 | batch size(一次只输入了一个句子) |
| dim 1 | 7 | 序列长度(7 个 token) |
| dim 2 | 768 | 隐藏维度(每个 token 的嵌入向量维度) |
1.3.1 每一行代表什么
输出的 7 行,每一行对应一个 token 的 768 维嵌入向量:
| 行 | 对应 token | 768 维向量(部分显示) |
|---|---|---|
| 第 1 行 | [CLS] | [ 0.1686, -0.2858, -0.3261, ..., -0.0276, 0.0383, 0.1640] |
| 第 2 行 | ”this” | [-0.6485, 0.6739, -0.0932, ..., 0.4475, 0.6696, 0.1820] |
| 第 3 行 | ”is” | [-0.6270, -0.0633, -0.3143, ..., 0.3427, 0.4636, 0.4594] |
| 第 4-5 行 | ”a”、“test” | …(中间省略) |
| 第 6 行 | ”sentence” | [ 0.8323, 0.2878, 0.0021, ..., 0.2628, -1.1310, -1.2708] |
| 第 7 行 | [SEP] | [-0.1481, -0.2948, -0.1690, ..., -0.5009, 0.2544, -0.0700] |
1.3.2 这些数字到底是什么?
每个 token 的 768 个数字共同构成了它的 语义向量(embedding vector):
- 每个数字本身没有直观含义,我们不能说”0.1686 代表什么”。
- 但所有这些数字的组合(模式、方向、距离),就编码了这个 token 的语义信息。
- 比如:语义相近的词,它们的 768 维向量在高维空间中的距离更近(余弦相似度更高)。
1.3.3 grad_fn=<NativeLayerNormBackward0> 是什么意思?
这表示这个张量是计算图的一部分,可以自动求梯度(requires_grad=True)。NativeLayerNormBackward0 告诉我们这个张量是由 LayerNorm 操作产生的,所以它的反向传播函数(用于计算梯度)来自 LayerNorm。简单说就是:这个张量可以参与梯度下降训练。
1.3.4 用一张图总结
句子: "This is a test sentence" │ 分词 ▼tokens: [CLS] this is a test sentence [SEP] │ │ │ │ │ │ │ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ┌──────────────────────────────────────────────┐ │ BERT Embedding Layer │ │ (Token Emb + Segment Emb + Position Emb │ │ → LayerNorm → Dropout) │ └──────────────────────────────────────────────┘ │ │ │ │ │ │ │ ▼ ▼ ▼ ▼ ▼ ▼ ▼ 768维 768维 768维 768维 768维 768维 768维 ← 这就是我们看到的输出 向量 向量 向量 向量 向量 向量 向量所以,我们看到的这个 [1, 7, 768] 的张量,本质上就是 7 个 token 各自的高维语义表示,后续会送入 BERT 的 Transformer 层进一步处理。
我们也可以看到位置编码:
pos_ids = torch.arange(input_ids.shape[1])pos_ids输出为:
tensor([0, 1, 2, 3, 4, 5, 6])代码解析:
input_ids.shape— 前面我们知道input_ids的形状是[1, 7](1 个 batch,7 个 token)input_ids.shape[1]— 取第 1 个维度(索引从 0 开始),值为 7(序列长度)torch.arange(7)— PyTorch 的arange= “array range”,生成从 0 开始到 6 的整数序列
所以输出就是:
tensor([0, 1, 2, 3, 4, 5, 6])这 7 个数字就是每个 token 在句子中的位置索引(Position IDs)。
| 位置 ID | 对应 token | 位置含义 |
|---|---|---|
| 0 | [CLS] | 句子的第 1 个位置 |
| 1 | ”this” | 第 2 个位置 |
| 2 | ”is” | 第 3 个位置 |
| 3 | ”a” | 第 4 个位置 |
| 4 | ”test” | 第 5 个位置 |
| 5 | ”sentence” | 第 6 个位置 |
| 6 | [SEP] | 第 7 个位置 |
回顾我们第一轮讲的内容:BERT 的 Embedding 由 三部分相加 组成:
而 Position Embedding 就是通过这个 pos_ids 去查另一张表得到的:
| pos_id | 查表得到 |
|---|---|
| 0 | Position Embedding 表的第 0 行(768 维向量) |
| 1 | 第 1 行 |
| 2 | 第 2 行 |
| … | … |
| 6 | 第 6 行 |
这 7 个位置向量分别加到对应 token 的 Token Embedding 上,模型就知道每个词在句子中的先后顺序了。
总之,pos_ids 就是 BERT 用来查 Position Embedding 表的”索引”,0 表示第一个 token,1 表示第二个,依此类推。因为 Transformer 本身没有顺序感,所以需要用位置编号来告诉模型词的排列顺序。
2. Embedding 中各个 embeddings
2.1 token embedding
token_embeddings = model.embeddings.word_embeddings(input_ids)token_embeddings输出为:
tensor([[[ 1.3630e-02, -2.6490e-02, -2.3503e-02, ..., 8.6805e-03, 7.1340e-03, 1.5147e-02], [-5.7095e-02, 1.5283e-02, -4.6868e-03, ..., -3.2484e-03, 9.7317e-05, 9.4175e-03], [-3.6044e-02, -2.4606e-02, -2.5735e-02, ..., 3.3691e-03, -1.8300e-03, 2.6855e-02], ..., [ 2.3670e-02, -6.1351e-02, -2.9575e-02, ..., -1.0239e-02, -7.2316e-03, -1.1745e-01], [ 3.2079e-02, 6.3135e-03, -6.4352e-03, ..., -1.1689e-03, -1.0810e-01, -8.9524e-02], [-1.4521e-02, -9.9615e-03, 6.0263e-03, ..., -2.5035e-02, 4.6379e-03, -1.5378e-03]]], grad_fn=<EmbeddingBackward0>)2.1.1 两段输出对比
我们来对比一下 model.embeddings(...) 的输出和上面这段 model.embeddings.word_embeddings(...) 的输出。
1. 代码上的区别
| 输出 1(之前) | 输出 2(现在) | |
|---|---|---|
| 调用的代码 | model.embeddings(...) | model.embeddings.word_embeddings(...) |
| 含义 | 完整 Embedding 层 | 仅 Token Embedding 查表 |
2. 输出上的区别
| 对比项 | ① 完整 Embedding | ② 仅 Token Embedding |
|---|---|---|
| 数值格式 | 标准小数 0.1686 | 科学记数法 1.3630e-02 |
| 数值大小 | 约 -1.7 ~ 0.8(较大) | 约 -0.12 ~ 0.03(很小) |
grad_fn | <NativeLayerNormBackward0> | <EmbeddingBackward0> |
3. 为什么会这样?
model.embeddings(...) ┌──────────────────────────┐input_ids ──────► Token Embedding ──┐ │ (word_embeddings) │ │ 👆 这是我们现在的输出 │ 相加│ ├──► │token_type_ids ──► Segment Embedding ──┘ │ │pos_ids ─────────► Position Embedding ──┐ │ ↑ 前面我们算的 pos_ids │ 相加 │ ├──► │ │ │ LayerNorm ◄── 让数值范围变大到 -2 ~ 2 │ Dropout │ ▼ 👆 这是之前的输出 └──────────────────────────┘2.1.2 关键点
① Token Embedding 的输出(我们现在看到的):
- 只是简单的查表,从
[30522 × 768]的矩阵中取出对应行 - 这些初始嵌入的数值范围很小(均值 0 附近),所以显示为科学记数法
1.36e-02 = 0.0136 grad_fn=<EmbeddingBackward0>— 表示这个张量来自查表操作
② 完整 Embedding 的输出(之前看到的):
- = Token Embedding + Segment Embedding + Position Embedding,然后通过 LayerNorm
- LayerNorm 会对数据做标准化:减去均值、除以标准差,再乘以缩放参数
- 标准化后数值范围被拉大到 -2 ~ 2 左右,所以显示为普通小数
grad_fn=<NativeLayerNormBackward0>— 表示这个张量来自LayerNorm 操作
2.1.3 一句话总结
我们现在看到的(②)是 “原材料”——每个 token 从词表中查出来的原始向量,数值小且分散;之前看到的(①)是 “加工后” 的成品——三个嵌入相加后再经过 LayerNorm 归一化,数值范围更大更规整,这才是真正喂给 BERT Transformer 层的输入。
2.2 segment type embedding
segment_embeddings = model.embeddings.token_type_embeddings(token_type_ids)segment_embeddings输出为:
tensor([[[ 0.0004, 0.0110, 0.0037, ..., -0.0066, -0.0034, -0.0086], [ 0.0004, 0.0110, 0.0037, ..., -0.0066, -0.0034, -0.0086], [ 0.0004, 0.0110, 0.0037, ..., -0.0066, -0.0034, -0.0086], ..., [ 0.0004, 0.0110, 0.0037, ..., -0.0066, -0.0034, -0.0086], [ 0.0004, 0.0110, 0.0037, ..., -0.0066, -0.0034, -0.0086], [ 0.0004, 0.0110, 0.0037, ..., -0.0066, -0.0034, -0.0086]]], grad_fn=<EmbeddingBackward0>)关于上面的输出,我们可以观察到,全部 7 行都一模一样。
这是因为在之前 tokenizer 的输出中:
'token_type_ids': tensor([[0, 0, 0, 0, 0, 0, 0]])所有 token 的 segment 标记都是 0,因为我们的句子 “This is a test sentence” 只有一个句子(没有句子 B).
2.2.1 查表过程
Segment Embedding 表的大小是 2 × 768(只有两行):
| segment 编号 | 含义 | 嵌入向量 |
|---|---|---|
| 0 | 句子 A(第一个句子) | [0.0004, 0.0110, 0.0037, ..., -0.0066, -0.0034, -0.0086] |
| 1 | 句子 B(第二个句子) | 另一个不同的 768 维向量 |
因为所有 token_type_ids 都是 0,所以每个 token 查到的都是第 0 行,7 个结果自然完全一样。
2.2.2 示意图
token_type_ids: [0, 0, 0, 0, 0, 0, 0] │ │ │ │ │ │ │ ┌───────────┘ │ │ │ │ │ └───────────┐ │ ┌─────────┘ │ │ │ └─────────┐ │ │ │ ┌───────┘ │ └───────┐ │ │ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ┌────────────────────────────────────────────┐ │ Segment Embedding Table (2 × 768) │ │ ┌─────────────────────────────┐ │ │ │ 行 0: 句子A的向量 ← 全部查到这一行 │ │ │ 行 1: 句子B的向量 │ │ └─────────────────────────────┘ │ └────────────────────────────────────────────┘ │ │ │ │ │ │ │ ▼ ▼ ▼ ▼ ▼ ▼ ▼ 全部是相同的 768 维向量2.2.3 那什么时候会用上 segment 1?
当处理句子对任务时,比如 QA(问答):
tokenizer("What is your name?", "My name is BERT.")这时 token_type_ids 就会是:
[0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1] ↑ 第一句 ↑ 第二句前 7 个 token(第一句)查到行 0,后 5 个 token(第二句)查到行 1,两句的 segment embedding 就会不同,BERT 就能区分”哪个词属于哪个句子”了。
2.3 position embedding
pos_embeddings = model.embeddings.position_embeddings(pos_ids)pos_embeddings输出为:
tensor([[ 1.7505e-02, -2.5631e-02, -3.6642e-02, ..., 3.3437e-05, 6.8312e-04, 1.5441e-02], [ 7.7580e-03, 2.2613e-03, -1.9444e-02, ..., 2.8910e-02, 2.9753e-02, -5.3247e-03], [-1.1287e-02, -1.9644e-03, -1.1573e-02, ..., 1.4908e-02, 1.8741e-02, -7.3140e-03], ..., [-5.6087e-03, -1.0445e-02, -7.2288e-03, ..., 2.0837e-02, 3.5402e-03, 4.7708e-03], [-3.0871e-03, -1.8956e-02, -1.8930e-02, ..., 7.4045e-03, 2.0183e-02, 3.4077e-03], [ 6.4257e-03, -1.7664e-02, -2.2067e-02, ..., 6.7531e-04, 1.1108e-02, 3.7521e-03]], grad_fn=<EmbeddingBackward0>)2.3.1 七行各不相同
| 位置 ID | 768 维向量(部分) | 是否与其它行相同? |
|---|---|---|
| 0 | [ 1.7505e-02, -2.5631e-02, -3.6642e-02, ...] | ❌ 唯一 |
| 1 | [ 7.7580e-03, 2.2613e-03, -1.9444e-02, ...] | ❌ 唯一 |
| 2 | [-1.1287e-02, -1.9644e-03, -1.1573e-02, ...] | ❌ 唯一 |
| 3 | … | ❌ 唯一 |
| 4 | … | ❌ 唯一 |
| 5 | [-3.0871e-03, -1.8956e-02, -1.8930e-02, ...] | ❌ 唯一 |
| 6 | [ 6.4257e-03, -1.7664e-02, -2.2067e-02, ...] | ❌ 唯一 |
每个位置的向量都是独一无二的,这样才能让模型区分”第 0 个位置”和”第 1 个位置”。
2.3.2 查表过程
Position Embedding 表的大小是 512 × 768:
pos_ids: [0, 1, 2, 3, 4, 5, 6] │ │ │ │ │ │ │ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ┌────────────────────────────────┐ │ Position Embedding Table │ │ (512 × 768) │ │ │ │ 行 0: ──── 查 → 第1行输出 │ │ 行 1: ──── 查 → 第2行输出 │ │ 行 2: ──── 查 → 第3行输出 │ │ ... │ │ 行 6: ──── 查 → 第7行输出 │ │ ... │ │ 行 511: │ └────────────────────────────────┘2.3.3 与前面几个输出的对比总结
现在我们已经看完了 BERT Embedding 的三张表:
| 嵌入类型 | 代码 | 输出特点 |
|---|---|---|
| Token Embedding | model.embeddings.word_embeddings(input_ids) | 7 行各不相同(不同词 → 不同向量) |
| Segment Embedding | model.embeddings.token_type_embeddings(token_type_ids) | 7 行全部相同(都是句子 A) |
| Position Embedding | model.embeddings.position_embeddings(pos_ids) | 7 行各不相同(不同位置 → 不同向量) |
最终,这三者逐元素相加,再经过 LayerNorm 和 Dropout,就得到了我们第一次看到的完整 Embedding 输出 🎯
最终输出 = Token向量 + Segment向量 + Position向量 → LayerNorm → Dropout2.4 input embedding
print(token_embeddings.shape)print(segment_embeddings.shape)print(pos_embeddings.shape)input_embeddings = token_embeddings + segment_embeddings + pos_embeddingsinput_embeddings输出为:
torch.Size([1, 7, 768])torch.Size([1, 7, 768])torch.Size([7, 768])
tensor([[[ 0.0316, -0.0411, -0.0564, ..., 0.0021, 0.0044, 0.0219], [-0.0489, 0.0285, -0.0204, ..., 0.0190, 0.0265, -0.0045], [-0.0469, -0.0156, -0.0336, ..., 0.0117, 0.0135, 0.0109], ..., [ 0.0185, -0.0608, -0.0331, ..., 0.0040, -0.0071, -0.1213], [ 0.0294, -0.0017, -0.0217, ..., -0.0004, -0.0913, -0.0948], [-0.0077, -0.0166, -0.0123, ..., -0.0310, 0.0124, -0.0064]]], grad_fn=<AddBackward0>)我们可以对比一下之前的 model.embedding() ,来看一下两者在流程中的位置:
token_embeddings segment_embeddings pos_embeddings │ │ │ └───────┬───────────┴───────┬───────────┘ │ │ ▼ ▼ input_embeddings = 三者相加 ← 我们现在看到的 (grad_fn=<AddBackward0>) │ ▼ LayerNorm ◄── 这一步会把数值分布重新调整! │ Dropout │ ▼ model.embeddings(...) 的返回值 ← 之前看到的 (grad_fn=<NativeLayerNormBackward0>)2.4.1 数值上的直接对比
以第 1 个 token([CLS])的前几个维度为例:
| 维度 | 三者相加(input_embeddings) | model.embeddings(…) |
|---|---|---|
| dim 0 | 0.0316 | 0.1686 |
| dim 1 | -0.0411 | -0.2858 |
| dim 2 | -0.0564 | -0.3261 |
数值完全不同,就是因为 LayerNorm 在中间起了作用:
它会:
- 减去均值 — 对每个 token 的 768 个维度求均值然后减去
- 除以标准差 — 把数值缩放到单位方差附近
- 乘以可学习参数 再加 — 缩放和平移
所以即使输入是 0.0316,经过 LayerNorm 后可能会变成 0.1686(数值被拉大了)。
2.4.2 另外还有一个细节
注意打印出来的形状:
torch.Size([1, 7, 768]) ← token_embeddingstorch.Size([1, 7, 768]) ← segment_embeddingstorch.Size([7, 768]) ← pos_embeddings(少了一个维度!)pos_embeddings 的形状是 [7, 768] 而不是 [1, 7, 768],相加时 PyTorch 会自动广播(broadcast),给它补上一个 batch 维度,这也是通过代码手动相加与 model.embeddings() 内部处理的一个小差异。
2.4.3 总结
input_embeddings(手动相加) | model.embeddings(...) | |
|---|---|---|
| 做了哪些操作 | 仅相加 ✅ | 相加 ✅ → LayerNorm ✅ → Dropout ✅ |
grad_fn | <AddBackward0> | <NativeLayerNormBackward0> |
| 数值特点 | 原始值较小、未归一化 | 经过标准化,范围更大 |
所以,我们手动算的 input_embeddings 只是 半成品,model.embeddings() 返回的才是经过 LayerNorm 和 Dropout 后的 成品 🎯
3. 后处理
embeddings = model.embeddings.LayerNorm(input_embeddings)embeddings = model.embeddings.dropout(embeddings)embeddings输出为:
tensor([[[ 0.1686, -0.2858, -0.3261, ..., -0.0276, 0.0383, 0.1640], [-0.6485, 0.6739, -0.0932, ..., 0.4475, 0.6696, 0.1820], [-0.6270, -0.0633, -0.3143, ..., 0.3427, 0.4636, 0.4594], ..., [ 0.6010, -0.6970, -0.2001, ..., 0.2960, 0.2060, -1.7181], [ 0.8323, 0.2878, 0.0021, ..., 0.2628, -1.1310, -1.2708], [-0.1481, -0.2948, -0.1690, ..., -0.5009, 0.2544, -0.0700]]], grad_fn=<NativeLayerNormBackward0>)可见,与一开始 model.embeddings() 的输出是一致的。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!