05_BERT+embedding_source_code

3655 字
18 分钟
05_BERT+embedding_source_code

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

1. 总览#

  • BERT imput embedding: 一种查表操作(lookup table)
    • 查表
      • token embeddings: 30522×76830522\times 768
      • segment embeddings: 0/1×8=7680/1 \times 8=768
      • position embeddings: 512×768512\times 768
    • 后处理
      • 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 核心公式#

最终输入=Token Embedding+Segment Embedding+Position Embedding\text{最终输入} = \text{Token Embedding} + \text{Segment Embedding} + \text{Position Embedding}

三个 768 维向量逐元素相加,结果还是一个 768 维向量,作为 BERT 第一层的输入。


1.1.5 后处理(Post-processing)#

相加之后,还要经过两步:

  1. LayerNorm(层归一化) — 把数据拉到一个稳定的范围,加速训练、防止梯度爆炸/消失。
  2. Dropout — 训练时随机”丢弃”一部分神经元(设为 0),防止过拟合。

1.2 一张图总结#

嵌入类型矩阵维度作用
Token Embedding30522×76830522 \times 768把词变成向量
Segment Embedding2×7682 \times 768区分句子 A/B
Position Embedding512×768512 \times 768告诉位置信息

最后三个向量相加 → LayerNorm → Dropout,就得到了 BERT 的最终输入。

from transformers import BertTokenizer, BertModel
import torch
from torch import nn
model_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 01batch size(一次只输入了一个句子)
dim 17序列长度(7 个 token)
dim 2768隐藏维度(每个 token 的嵌入向量维度)

1.3.1 每一行代表什么#

输出的 7 行,每一行对应一个 token 的 768 维嵌入向量

对应 token768 维向量(部分显示)
第 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])

代码解析:

  1. input_ids.shape — 前面我们知道 input_ids 的形状是 [1, 7](1 个 batch,7 个 token)
  2. input_ids.shape[1] — 取第 1 个维度(索引从 0 开始),值为 7(序列长度)
  3. 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 由 三部分相加 组成:

最终嵌入=Token Embedding+Segment Embedding+Position Embedding\text{最终嵌入} = \text{Token Embedding} + \text{Segment Embedding} + \text{Position Embedding}

Position Embedding 就是通过这个 pos_ids 去查另一张表得到的:

Position Embedding 表=512最大长度×768向量维度\text{Position Embedding 表} = \underbrace{512}_{\text{最大长度}} \times \underbrace{768}_{\text{向量维度}}
pos_id查表得到
0Position 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 七行各不相同#

位置 ID768 维向量(部分)是否与其它行相同?
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 Embeddingmodel.embeddings.word_embeddings(input_ids)7 行各不相同(不同词 → 不同向量)
Segment Embeddingmodel.embeddings.token_type_embeddings(token_type_ids)7 行全部相同(都是句子 A)
Position Embeddingmodel.embeddings.position_embeddings(pos_ids)7 行各不相同(不同位置 → 不同向量)

最终,这三者逐元素相加,再经过 LayerNorm 和 Dropout,就得到了我们第一次看到的完整 Embedding 输出 🎯

最终输出 = Token向量 + Segment向量 + Position向量 → LayerNorm → Dropout

2.4 input embedding#

print(token_embeddings.shape)
print(segment_embeddings.shape)
print(pos_embeddings.shape)
input_embeddings = token_embeddings + segment_embeddings + pos_embeddings
input_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 00.03160.1686
dim 1-0.0411-0.2858
dim 2-0.0564-0.3261

数值完全不同,就是因为 LayerNorm 在中间起了作用:

LayerNorm(x)=γxμσ+β\text{LayerNorm}(x) = \gamma \cdot \frac{x - \mu}{\sigma} + \beta

它会:

  1. 减去均值 μ\mu — 对每个 token 的 768 个维度求均值然后减去
  2. 除以标准差 σ\sigma — 把数值缩放到单位方差附近
  3. 乘以可学习参数 γ\gamma 再加 β\beta — 缩放和平移

所以即使输入是 0.0316,经过 LayerNorm 后可能会变成 0.1686(数值被拉大了)。

2.4.2 另外还有一个细节#

注意打印出来的形状:

torch.Size([1, 7, 768]) ← token_embeddings
torch.Size([1, 7, 768]) ← segment_embeddings
torch.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() 的输出是一致的。

文章分享

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

05_BERT+embedding_source_code
https://github.com/chunhuizhang/bilibili_vlogs/
作者
HAC
发布于
2026-06-10
许可协议
CC BY-NC-SA 4.0

评论区

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

音乐

暂未播放

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

文章目录