import dnnlpy.nn as dnn
import dnnlpy.nn.functional as dF
import dnnlpy.models.gpt as gpt
import torch
import torch.nn as nn
from torch import Tensor
print('PyTorch version:', torch.__version__)PyTorch version: 2.12.1+cpu
jshn9515
2026-06-18
2026-06-18
前面几节里,我们已经完成了三件事:
vocab_size 从哪里来。现在回头看 MiniGPT 的结构,中间其实有一个很重要的对称关系:
输入和输出都和同一个词表有关。
输入时,模型需要把 token id 转成向量;输出时,模型又要把 hidden state 映射回词表大小的 logits。也就是说,语言模型在两端都需要和 vocabulary 打交道。
这一节就专门放大看这两端:
PyTorch version: 2.12.1+cpu
假设 tokenizer 的词表大小是 \(V\),模型的 hidden size 是 \(D\)。
输入端的 token embedding 是一个查表矩阵:
\[ E \in \mathbb{R}^{V \times D} \]
它的作用是:
\[ \text{token id} \rightarrow \text{token vector} \]
输出端的 LM head 是一个线性层:
\[ W_{\text{out}} \in \mathbb{R}^{V \times D} \]
它的作用是:
\[ \text{hidden state} \rightarrow \text{logits over vocabulary} \]
所以两端的形状非常相似:
token embedding weight: (V, D)
lm head weight: (V, D)
区别在于使用方向不同。
输入时,我们用 token id 去取 E 的某一行:
\[ x_t = E[\text{token\_id}_t] \]
输出时,我们把 hidden state 和 W_out 的每一行做内积,得到每个 token 的 logit:
\[ \text{logits}_t = h_t W_{\text{out}}^\top \]
也就是说,输入端是从 id 到向量,输出端是从向量到 id 的打分。
先用一个小例子看形状。
vocab_size = 10
embed_dim = 4
batch_size = 2
block_size = 5
input_ids = torch.tensor(
[
[1, 3, 5, 7, 9],
[2, 4, 6, 8, 0],
]
)
embedding = nn.Embedding(vocab_size, embed_dim)
lm_head = dnn.Linear(embed_dim, vocab_size, bias=False)
x_emb = embedding(input_ids)
logits = lm_head(x_emb)
print('Embedding weight shape:', embedding.weight.shape)
print('LM head weight shape:', lm_head.weight.shape)
print('input_ids.shape:', input_ids.shape)
print('x_emb.shape:', x_emb.shape)
print('logits.shape:', logits.shape)Embedding weight shape: torch.Size([10, 4])
LM head weight shape: torch.Size([10, 4])
input_ids.shape: torch.Size([2, 5])
x_emb.shape: torch.Size([2, 5, 4])
logits.shape: torch.Size([2, 5, 10])
这里发生的形状变化是:
\[ (B, T) \rightarrow (B, T, D) \rightarrow (B, T, V) \]
这正是语言模型从 token ids 到词表预测的基本路径。
一个很容易误解的地方是:token id 虽然是整数,但它本身没有数值含义。
比如 tokenizer 里可能有:
"deep" -> 12
"learning" -> 503
"GPT" -> 87
这并不表示 learning 比 deep 大,也不表示 token 503 和 token 12 的距离有什么语义意义。所以模型不会直接把这些整数当成连续数值输入。
nn.Embedding 做的事情更像是查表:
token id 12 -> embedding.weight[12]
token id 503 -> embedding.weight[503]
token id 87 -> embedding.weight[87]
也可以把 token id 想成 one-hot 向量。假设词表大小是 \(V\),第 \(i\) 个 token 的 one-hot 表示是 \(e_i\),那么 embedding 可以写成:
\[ x_i = e_i^\top E \]
但是实际实现时不会真的构造 one-hot,因为那太浪费。PyTorch 直接根据 id 取对应的行。
vocab_size = 5
embed_dim = 3
embedding = nn.Embedding(vocab_size, embed_dim)
token_id = torch.tensor([2])
lookup_result = embedding(token_id)
manual_result = embedding.weight[2]
print('Embedding table:', embedding.weight, sep='\n')
print('Lookup result:', lookup_result)
print('Manual result:', manual_result)
flag = torch.allclose(lookup_result.squeeze(0), manual_result)
print('Is lookup result equal to manual result?', flag)Embedding table:
Parameter containing:
tensor([[-0.4144, -2.3037, -0.5111],
[-0.0472, 0.8101, 1.1786],
[ 0.3040, 0.6548, -0.6065],
[-0.5411, 1.2844, 0.1289],
[ 0.1311, -0.1328, 0.2571]], requires_grad=True)
Lookup result: tensor([[ 0.3040, 0.6548, -0.6065]], grad_fn=<EmbeddingBackward0>)
Manual result: tensor([ 0.3040, 0.6548, -0.6065], grad_fn=<SelectBackward0>)
Is lookup result equal to manual result? True
所以 embedding 的本质是:
给词表里的每个 token 学一个向量表示。
训练开始时,这些向量通常是随机初始化的。训练过程中,反向传播会不断调整这些向量,让它们适合 next-token prediction。
在 18.2 里,我们把 token embedding 和 positional embedding 加在一起:
\[ X = E_{\text{token}}[\text{input\_ids}] + E_{\text{pos}}[\text{positions}] \]
这两个 embedding 很像,都是查表,但含义不同。
Token embedding 的表大小由词表决定:
\[ E_{\text{token}} \in \mathbb{R}^{V \times D} \]
Positional embedding 的表大小由最大上下文长度决定:
\[ E_{\text{pos}} \in \mathbb{R}^{T_{\max} \times D} \]
它们最后相加,是因为模型需要同时知道两个信息:
vocab_size = 100
block_size = 8
embed_dim = 16
input_ids = torch.tensor([[10, 25, 31, 7]])
B, T = input_ids.size()
tok_embed = nn.Embedding(vocab_size, embed_dim) # word/token embedding
pos_embed = nn.Embedding(block_size, embed_dim) # positional embedding
positions = torch.arange(T)
x_tok = tok_embed(input_ids)
x_pos = pos_embed(positions)
x = x_tok + x_pos
print('input_ids.shape:', input_ids.shape)
print('positions.shape:', positions.shape)
print('x_tok.shape:', x_tok.shape)
print('x_pos.shape:', x_pos.shape)
print('x.shape:', x.shape)input_ids.shape: torch.Size([1, 4])
positions.shape: torch.Size([4])
x_tok.shape: torch.Size([1, 4, 16])
x_pos.shape: torch.Size([4, 16])
x.shape: torch.Size([1, 4, 16])
注意,positional embedding 不参与 LM head 的输出词表预测。LM head 只关心下一个 token 是词表里的哪一个,而不是下一个位置是多少。因此,weight tying 通常只发生在 token embedding 和 LM head 之间,而不是 positional embedding 和 LM head 之间。
经过 GPT blocks 之后,每个位置都有一个 hidden state:
\[ h_t \in \mathbb{R}^{D} \]
LM head 要做的是给词表里每个 token 一个分数:
\[ \text{logits}_t \in \mathbb{R}^{V} \]
如果 LM head 的权重是:
\[ W_{\text{out}} \in \mathbb{R}^{V \times D} \]
那么第 \(j\) 个 token 的 logit 可以写成:
\[ \text{logit}_{t,j} = h_t \cdot W_{\text{out},j} \]
这里 \(W_{\text{out},j}\) 是 LM head 权重矩阵的第 \(j\) 行,也可以理解成“输出端给第 \(j\) 个 token 学到的向量”。
所以 LM head 可以被理解成:
拿当前位置的 hidden state,去和词表中每个 token 的输出向量做相似度打分。
当然,严格来说这只是线性层的内积打分,不一定是归一化后的相似度。Softmax 之后才会变成概率分布:
\[ p(x_{t+1}=j \mid x_{\le t}) = \frac{\exp(\text{logit}_{t,j})} {\sum_{k=1}^{V} \exp(\text{logit}_{t,k})} \]
训练时,我们通常直接把 logits 送进 cross entropy。
B, T, D = 2, 4, 16
V = 100
h = torch.randn(B, T, D)
lm_head = dnn.Linear(D, V, bias=False)
logits = lm_head(h)
labels = torch.randint(0, V, (B, T))
loss = dF.cross_entropy_loss(
logits.reshape(B * T, V),
labels.reshape(B * T),
)
print('h.shape:', h.shape)
print('logits.shape:', logits.shape)
print('labels.shape:', labels.shape)
print('Loss:', loss.item())h.shape: torch.Size([2, 4, 16])
logits.shape: torch.Size([2, 4, 100])
labels.shape: torch.Size([2, 4])
Loss: 4.626043319702148
这里 labels[b, t] 是第 b 个样本、第 t 个位置真正应该预测的下一个 token id。
现在来看一个关键问题。
输入端 token embedding 有一张表:
\[ E \in \mathbb{R}^{V \times D} \]
输出端 LM head 也有一张形状相同的权重:
\[ W_{\text{out}} \in \mathbb{R}^{V \times D} \]
既然它们都在为同一个词表里的 token 学向量,能不能直接共享同一组参数?这就是 weight tying。
最简单的写法是:
这样 tok_embed.weight 和 lm_head.weight 不再是两份独立参数,而是同一个参数对象。
直觉上,weight tying 相当于让模型满足:
一个 token 作为输入时的表示,和它作为输出候选 token 时的表示,使用同一套向量空间。
于是输出 logit 可以写成:
\[ \text{logit}_{t,j} = h_t \cdot E_j \]
其中 \(E_j\) 既是第 \(j\) 个 token 的输入 embedding,也是输出端用来打分的 token 向量。
下面写一个最小例子。
class TiedEmbeddingLM(nn.Module):
"""A tiny model that only demonstrates embedding, LM head, and weight tying."""
def __init__(self, vocab_size: int, embed_dim: int = 128):
super().__init__()
self.token_embed = nn.Embedding(vocab_size, embed_dim)
self.lm_head = dnn.Linear(embed_dim, vocab_size, bias=False)
# Weight tying: both modules share the same Parameter object.
self.lm_head.weight = self.token_embed.weight
assert self.lm_head.weight is self.token_embed.weight
def forward(self, input_ids: Tensor) -> Tensor:
x = self.token_embed(input_ids)
logits = self.lm_head(x)
return logits检查一下它们是不是同一个参数对象:
model = TiedEmbeddingLM(vocab_size=20, embed_dim=8)
print('Embedding weight shape:', model.token_embed.weight.shape)
print('LM head weight shape: ', model.lm_head.weight.shape)
flag1 = model.token_embed.weight is model.lm_head.weight
flag2 = model.token_embed.weight.data_ptr() == model.lm_head.weight.data_ptr()
print('Is the same object?', flag1)
print('Do they share the same data pointer?', flag2)Embedding weight shape: torch.Size([20, 8])
LM head weight shape: torch.Size([20, 8])
Is the same object? True
Do they share the same data pointer? True
可以看到,两个模块的 weight 指向同一块参数。
如果不做 weight tying,embedding 和 LM head 各自都有一份 \(V \times D\) 的参数:
\[ \text{params}_{\text{untied}} = VD + VD = 2VD \]
如果做 weight tying,这两部分共享同一份参数:
\[ \text{params}_{\text{tied}} = VD \]
也就是说,光这一项就能省掉 \(VD\) 个参数。
当词表很大时,这个节省并不小。比如:
Untied params: 76,800,000
Tied params: 38,400,000
Saved params: 146.4844 MB
这里还只是 embedding 和 LM head 两端的参数。如果模型更大,词表更大,节省会更明显。
不过 weight tying 不只是为了省参数。更重要的是,它给输入 token 表示和输出 token 表示加了一个约束:它们必须共享同一个向量空间。这通常是合理的。因为语言模型输入端和输出端面对的是同一批 token。
做 weight tying 之后,同一个参数会在 forward 里被用两次:
因此反向传播时,它也会收到两部分梯度贡献。我们可以用一个小例子看一下。
vocab_size = 10
model = TiedEmbeddingLM(vocab_size=vocab_size, embed_dim=4)
input_ids = torch.tensor([[1, 2, 3]])
labels = torch.tensor([[2, 3, 4]])
logits = model(input_ids)
loss = dF.cross_entropy_loss(
logits.reshape(-1, vocab_size),
labels.reshape(-1),
)
loss.backward()
print('Loss:', loss.item())
print('Embedding grad shape:', model.token_embed.weight.grad.shape)
flag = model.lm_head.weight.grad is model.token_embed.weight.grad
print('Is the gradient the same object?', flag)Loss: 3.625714063644409
Embedding grad shape: torch.Size([10, 4])
Is the gradient the same object? True
因为二者是同一个参数,所以梯度也会累积到同一个 .grad 上。
具体来说,共享权重在计算图中有两条使用路径:一条来自输入端的 embedding,另一条来自输出端的 LM head。反向传播时,两条路径会分别产生梯度贡献,然后自动相加到同一个 .grad 中。优化器最后使用这个总梯度,对共享参数只更新一次:
\[ \left. \frac{\partial L}{\partial W} \right|{\text{Embedding}} + \left. \frac{\partial L}{\partial W} \right|{\text{LM Head}} \]
因此,这不是两个参数分别更新,也不是简单地把梯度乘 2,而是两条路径共同决定同一个参数的更新。Weight tying 不是复制一份权重过去,而是让两个地方真的共享同一个可学习参数。
回到 MiniGPT,weight tying 通常只需要改 LM head 的定义和初始化。
在不共享权重时,我们可能会写:
如果要共享权重,常见写法是:
下面写一个 weight tying 版本的 MiniGPT。
class MiniGPTWithWeightTying(nn.Module):
def __init__(
self,
vocab_size: int,
block_size: int, # or context window
embed_dim: int = 128,
num_layers: int = 4,
num_heads: int = 4,
hidden_dim: int = 512,
dropout: float = 0.0,
):
super().__init__()
self.minigpt = gpt.MiniGPT(
vocab_size,
block_size,
embed_dim=embed_dim,
num_layers=num_layers,
num_heads=num_heads,
hidden_dim=hidden_dim,
dropout=dropout,
weight_tying=True, # Enable weight tying
)
def forward(self, input_ids: Tensor) -> Tensor:
return self.minigpt(input_ids)
def loss(self, input_ids: Tensor, targets: Tensor | None = None) -> Tensor:
return self.minigpt.loss(input_ids, targets)测试一下:
model = MiniGPTWithWeightTying(
vocab_size=100,
block_size=8,
embed_dim=32,
)
token_ids = torch.tensor([[10, 25, 31, 7, 42]])
input_ids = token_ids[:, :-1]
labels = token_ids[:, 1:]
logits = model(input_ids)
loss = model.loss(token_ids, labels)
print('input_ids.shape:', input_ids.shape)
print('labels.shape:', labels.shape)
print('logits.shape:', logits.shape)
print('Loss:', loss.item())
flag = model.minigpt.token_embed.weight is model.minigpt.lm_head.weight
print('Is embedding weight the same object as LM head weight?', flag)input_ids.shape: torch.Size([1, 4])
labels.shape: torch.Size([1, 4])
logits.shape: torch.Size([1, 4, 100])
Loss: 4.6374053955078125
Is embedding weight the same object as LM head weight? True
可以看到,输入和输出端确实共享了同一份权重。
首先,vocab_size 会同时影响 embedding 和 LM head。
假设 tokenizer 的词表大小是 \(V\),那么:
token_embed: (V, D)
lm_head: (D -> V)
logits: (B, T, V)
所以,如果换 tokenizer,词表大小变了,模型的 token embedding 和 LM head 都要跟着变。这也是为什么不能随便拿一个 tokenizer 去配另一个不匹配的模型。Token id 的含义和模型参数是一一对应的。
其次,embed_dim 必须和 hidden size 对齐。Token embedding 的维度必须等于 LM head 输入的 hidden size。
也就是说:
token_embed.weight: (V, D)
lm_head.weight: (V, D)
如果 GPT blocks 输出的是 (B, T, D),LM head 才能直接使用同一个 (V, D) 权重做输出投影。
最后,LM head 输出的是 logits,不是 token id。
LM head 的输出是:
\[ \text{logits} \in \mathbb{R}^{B \times T \times V} \]
它还不是最终 token。
训练时,logits 会进入 cross entropy。生成时,才会根据最后一个位置的 logits 做 argmax 或采样。
Prefix: tensor([[10, 25, 31]])
Last_logits shape: torch.Size([1, 100])
Next token: tensor([37])
还有一个点要额外注意:weight tying 只发生在 token embedding 和 LM head 之间,不包括 positional embedding。因为 positional embedding 是给位置编号学向量,它的大小是 (block_size, D);而 LM head 输出的是词表 logits,大小是 (B, T, V)。所以 positional embedding 和 LM head 没有共享权重的意义。
这一节我们放大看了 MiniGPT 两端最容易被忽略的部分:embedding 和 LM head。
输入端:
\[ \text{input\_ids} \in \mathbb{N}^{B \times T} \rightarrow X \in \mathbb{R}^{B \times T \times D} \]
输出端:
\[ H \in \mathbb{R}^{B \times T \times D} \rightarrow \text{logits} \in \mathbb{R}^{B \times T \times V} \]
其中:
到这里,MiniGPT 的结构部分已经基本清楚了。
接下来就该进入训练问题:给定一长串 token,怎么切 batch?怎么设 context length?怎么计算 loss?以及一次训练循环到底更新了哪些参数。