语言模型介绍

建模

马尔科夫假设

unigram-bigram-trigram 因为人只能根据已发生的事去推测未来,用频率去代替概率
一元语法即朴素贝叶斯假设,这里马尔科夫假设(参考前面文章)就是假设现在x受到前面几个影响,三元语法就是受到前面两个
一元就是只依赖自己,二元语法就是依赖前面一个词,三元语法就是依赖前面两个词
实现就是可以存下所有比如二元语法的两个词在一起的频率,但空间复杂度太高了
总结:

一元语法代码:
很多类方法都是上一节内容
python
tokens = d2l.tokenize(read_time_machine())
# 因为每个文本行不一定是一个句子或一个段落,因此我们把所有文本行拼接到一起
corpus = [token for line in tokens for token in line]
vocab = d2l.Vocab(corpus)
vocab.token_freqs[:10]
tokens是 d2l.tokenize(read_time_machine())的结果,
默认按单词切分,所以它是一个二维列表:
外层:每一行文本
内层:该行拆分出的单词列表
tokens = [
['the', 'time', 'machine'],
['by', 'h', 'g', 'wells'],
...
]
双重循环等价于
corpus = []
for line in tokens: # 外层:遍历每一行
for token in line: # 内层:遍历该行的每个单词
corpus.append(token)
结果 corpus变成一维列表,包含所有单词(按原文顺序):
['the', 'time', 'machine', 'by', 'h', 'g', 'wells', ...]
第一行:把二维的单词列表拍扁成一维,方便统计和索引。
第二行:根据一维单词列表构建词表,让每个单词拥有一个唯一的数字 ID,为后续神经网络输入做准备。
vocab = d2l.Vocab(corpus)
作用:基于所有单词构建一个词表对象 vocab。
Vocab类做了什么?
统计词频:遍历 corpus,用 collections.Counter统计每个单词出现的次数。
排序:按频率从高到低排序。
分配索引:
索引0固定为 <unk>(未知词元)
然后依次分配索引给所有出现过的单词(频率 ≥ min_freq,默认 min_freq=0,即所有单词都保留)
建立双向映射:
vocab.token_to_idx:单词 → 索引(字典)
vocab.idx_to_token:索引 → 单词(列表)
之后你能做什么?
vocab['the']→ 返回 'the'的索引(例如 1)
vocab.to_tokens([1, 2, 3])→ 返回索引对应的单词列表
vocab.token_freqs[:10]→ 查看频率最高的10个单词及其频率
[('the', 2261),
('i', 1267),
('and', 1245),
('of', 1155),
('a', 816),
('to', 695),
('was', 552),
('in', 541),
('that', 443),
('my', 440)]
vocab.token_freqs是一个列表,元素为 (token, freq)元组,按频率从高到低排序。切片 :10取出前10个
为什么是这些单词?
-
《时间机器》是英文小说,最常见的英语停用词(stop words)如
the、and、of、a等自然占据前列。 -
i和my的出现频率较高,因为小说是第一人称叙述。 -
这些高频词构成了语言的基础骨架,在后续的文本建模中常被保留或作为特征。
去查看2元 3元语法时候规律
python
# -------------------- 二元词组(Bigram)--------------------
# 使用 zip 将 corpus 错位配对,生成相邻单词的二元组列表
# corpus[:-1] : 从第一个到倒数第二个单词
# corpus[1:] : 从第二个到最后一个单词
# zip(...) 将两个列表对应位置元素打包成 (word_i, word_{i+1}) 的元组
bigram_tokens = [pair for pair in zip(corpus[:-1], corpus[1:])]
# 基于二元组列表构建词表
# Vocab 会自动统计每个二元组的出现频率,并分配索引
bigram_vocab = Vocab(bigram_tokens)
# 输出出现频率最高的前10个二元组及其频次
# 例如:[('of the', 309), ('in the', 169), ('the time', 145), ...]
bigram_vocab.token_freqs[:10]
[(('of', 'the'), 309),
(('in', 'the'), 169),
(('i', 'had'), 130),
(('i', 'was'), 112),
(('and', 'the'), 109),
(('the', 'time'), 102),
(('it', 'was'), 99),
(('to', 'the'), 85),
(('as', 'i'), 78),
(('of', 'a'), 73)]
# -------------------- 三元词组(Trigram)--------------------
# 使用 zip 将 corpus 错位三重配对,生成相邻三个单词的三元组列表
# corpus[:-2] : 从第一个到倒数第三个单词
# corpus[1:-1] : 从第二个到倒数第二个单词
# corpus[2:] : 从第三个到最后一个单词
# zip(...) 将三个列表对应位置元素打包成 (word_i, word_{i+1}, word_{i+2}) 的元组
trigram_tokens = [triple for triple in zip(
corpus[:-2], corpus[1:-1], corpus[2:]
)]
# 基于三元组列表构建词表
trigram_vocab = Vocab(trigram_tokens)
# 输出出现频率最高的前10个三元组及其频次
# 例如:[('the time traveller', 59), ('the time machine', 30), ...]
trigram_vocab.token_freqs[:10]
[(('the', 'time', 'traveller'), 59),
(('the', 'time', 'machine'), 30),
(('the', 'medical', 'man'), 24),
(('it', 'seemed', 'to'), 16),
(('it', 'was', 'a'), 15),
(('here', 'and', 'there'), 15),
(('seemed', 'to', 'me'), 14),
(('i', 'did', 'not'), 14),
(('i', 'saw', 'the'), 13),
(('i', 'began', 'to'), 13)]
关键点解释
-
**
zip(corpus[:-1], corpus[1:])**-
corpus是一维单词列表,例如['the', 'time', 'machine', 'by', ...] -
corpus[:-1]去掉最后一个单词:['the', 'time', 'machine', 'by'] -
corpus[1:]去掉第一个单词:['time', 'machine', 'by', ...] -
zip将两者对应位置组合成('the','time'),('time','machine'),('machine','by'), ... -
结果
bigram_tokens是二元组列表,长度为len(corpus)-1。
-
-
**
zip(corpus[:-2], corpus[1:-1], corpus[2:])**-
类似地,错位两个位置,生成
('the','time','machine'),('time','machine','by'), ... -
结果
trigram_tokens是三元组列表,长度为**len(corpus)-2。**
-
-
**
Vocab(bigram_tokens)**-
将每个二元组(如
('the','time'))视为一个"词元",统计其出现频率并分配索引。 -
注意:这里二元组是以元组形式存储的,
Vocab会将其当作整体处理。 -
最终
bigram_vocab.token_freqs返回按频率降序排列的((word1, word2), freq)列表。
-
-
**为什么这样做?**
-
通过查看高频二元组/三元组,可以了解文本中常见的短语搭配,这对于理解语言模型中的 n-gram 统计特性很有帮助。
-
在后续的文本生成或语言模型训练中,n-gram 是一种基础的统计方法。
-
可视化结果对比:
python
# 从 bigram_vocab.token_freqs 中提取所有二元组的频率值
# token_freqs 是一个列表,元素为 ((word1, word2), freq) 或 (token, freq) 的元组
# 这里用列表推导式只取 freq 部分,得到纯频率列表
bigram_freqs = [freq for token, freq in bigram_vocab.token_freqs]
# 从 trigram_vocab.token_freqs 中提取所有三元组的频率值
trigram_freqs = [freq for token, freq in trigram_vocab.token_freqs]
# 使用 d2l.plot 绘制三条曲线:
# 1. freqs(一元词频,假设之前已定义,如 unigram 的频率列表)
# 2. bigram_freqs(二元词频)
# 3. trigram_freqs(三元词频)
# 横轴:词元排名(按频率从高到低排序后的序号),纵轴:频率
# 双对数坐标(xscale='log', yscale='log')用于观察是否符合齐普夫定律
# legend 分别标注三条曲线的名称
d2l.plot([freqs, bigram_freqs, trigram_freqs],
xlabel='token: x', # 横轴标签(x 表示排名)
ylabel='frequency: n(x)', # 纵轴标签(n(x) 表示频率)
xscale='log', # 横轴使用对数刻度
yscale='log', # 纵轴使用对数刻度
legend=['unigram', 'bigram', 'trigram']) # 图例

-
在双对数坐标系下,如果曲线近似直线,则验证了 齐普夫定律:词频与其排名成反比(即少数高频词出现极多,大量低频词出现极少)。
-
通常,高阶 n-gram(如 trigram)的频率下降更快,曲线更陡峭。
X轴的意思是下标,也就是从0开始。因为uni,big,trig都排序了,所以0对应出现的词频是最高的。
这表明大部分多元语法词(连词)都是低频 ,所以之前推测n元语法需要存的词需要很多是错的,这为我们可以实现存储提供可能 如下图,三元比一二元都要少 做一次低频词过滤后是可能存下来的n元词法

划分序列(代码难)
随机采样

为了不重复然后尽量啥数据都有机会(随机性),比如划分好每段数据,然后0,T在这段数据中选取k作为开始
python
import random
import torch
def seq_data_iter_random(corpus, batch_size, num_steps): #@save
"""使用随机抽样生成一个小批量子序列"""
# 1. 从随机偏移量开始裁剪 corpus,增加样本多样性
# random.randint(0, num_steps - 1) 生成 0 ~ num_steps-1 的随机整数
# 这样每个 epoch 的起始位置不同,相当于数据增强
corpus = corpus[random.randint(0, num_steps - 1):]
# 2. 计算可以划分出多少个完整的子序列(每个子序列长度为 num_steps)
# 减1是因为我们需要用当前词元预测下一个词元(标签),所以最后一个词元不能作为起始点
# 例如 corpus 长度为 100,num_steps=5,则最多有 (100-1)//5 = 19 个完整子序列
num_subseqs = (len(corpus) - 1) // num_steps
# 3. 生成所有子序列的起始索引(从0开始,步长为 num_steps)
# 例如 num_subseqs=19,则 initial_indices = [0, 5, 10, ..., 90]
initial_indices = list(range(0, num_subseqs * num_steps, num_steps))
# 4. 打乱起始索引顺序,使得每个 batch 中的子序列在原始序列上不相邻
# 这就是"随机抽样"的关键:打破时序依赖性,提高训练稳定性
random.shuffle(initial_indices)
# 5. 定义一个内部函数,用于从 corpus 中截取从 pos 开始的 num_steps 个词元
def data(pos):
# 返回从 pos 到 pos+num_steps 的切片(左闭右开)
return corpus[pos: pos + num_steps]
# 6. 计算可以生成多少个完整的 mini-batch
num_batches = num_subseqs // batch_size
# 7. 遍历每个 mini-batch
for i in range(0, batch_size * num_batches, batch_size):
# 取出当前 batch 对应的起始索引(已经打乱过)
initial_indices_per_batch = initial_indices[i: i + batch_size]
# 特征 X:每个样本是从起始索引 j 开始的 num_steps 个词元
X = [data(j) for j in initial_indices_per_batch]
# 标签 Y:每个样本是从 j+1 开始的 num_steps 个词元(即下一个词元序列)
# 注意:X 和 Y 的长度相同,Y 是 X 向右平移一位的结果
Y = [data(j + 1) for j in initial_indices_per_batch]
# 将列表转换为张量并返回(batch_size × num_steps)
yield torch.tensor(X), torch.tensor(Y)
函数整体思路
-
随机偏移:从随机位置开始截取 corpus,使每个 epoch 的数据起点不同。
-
划分不重叠的子序列 :将剩余 corpus 划分为若干个长度为
num_steps的不重叠片段(忽略最后一个不完整的片段)。 -
随机打乱:打乱这些片段的起始索引,使得每个 batch 中的样本在原始序列中不相邻,从而打破时序相关性(类似随机采样)。
-
生成 batch :每次取
batch_size个起始索引,构造特征X(当前词元序列)和标签Y(下一个词元序列),并用yield返回一个 batch 的张量对。
关键设计
-
为什么用
yield:生成器方式,节省内存,按需生成 batch。 -
为什么
X和Y长度相同 :因为语言模型的任务是给定前num_steps个词元预测下一个词元,所以标签也是num_steps个词元(对应每个位置的下一个词元)。 -
随机采样 vs 顺序采样 :本函数是随机采样,适合训练时增加随机性 ;另一种常用方式是顺序采样(
seq_data_iter_sequential),保持时序顺序,适合评估或特定模型。
计算 minibatch 的数量是为了确保每个 batch 都是完整的,避免生成不完整的最后一个 batch。
为什么需要计算 num_batches?
在 seq_data_iter_random函数中,我们先把所有可能的子序列(长度为 num_steps)的起始索引打乱了,然后想按 batch_size一组一组地取出,生成一个个 mini-batch。
如果不计算 num_batches,直接遍历所有起始索引,可能会导致最后一个 batch 的样本数不足 batch_size(例如剩下3个样本,但 batch_size=5)。在很多深度学习训练中,我们希望每个 batch 大小一致(尤其是使用 GPU 时,固定大小的张量更高效),所以通常会丢弃最后一个不完整的 batch。
num_batches = num_subseqs // batch_size计算出最多可以组成多少个完整的 batch (向下取整)。然后循环 for i in range(0, batch_size * num_batches, batch_size)只取前 num_batches * batch_size个起始索引,确保每个 batch 恰好有 batch_size个样本。

python
my_seq = list(range(35))
for X, Y in seq_data_iter_random(my_seq, batch_size=2, num_steps=5):
print('X: ', X, '\nY:', Y)
X: tensor([[ 1, 2, 3, 4, 5],
[16, 17, 18, 19, 20]])
Y: tensor([[ 2, 3, 4, 5, 6],
[17, 18, 19, 20, 21]])
X: tensor([[21, 22, 23, 24, 25],
[26, 27, 28, 29, 30]])
Y: tensor([[22, 23, 24, 25, 26],
[27, 28, 29, 30, 31]])
X: tensor([[ 6, 7, 8, 9, 10],
[11, 12, 13, 14, 15]])
Y: tensor([[ 7, 8, 9, 10, 11],
[12, 13, 14, 15, 16]])
意思是1 对应y=2 2对应y=3(需要预测的) 比如给你 1 2 3 需要预测出4这样子 (没想到代码这样写)
每个minbacth独立的(因为随机采样),比如最后那个x 11开始 但他之前的x是26开始都有
这里x是连续的原因主要是,使用list(range(35)来模拟的corpus,所以就是连续的,实际情况这里将是每个word)
顺序分区:
python
def seq_data_iter_sequential(corpus, batch_size, num_steps):
"""使用顺序分区生成一个小批量子序列"""
# 1. 随机偏移 offset ∈ [0, num_steps],增加多样性
offset = random.randint(0, num_steps)
# 2. 计算可以使用的总词元数(必须是 batch_size 的整数倍)
# (len(corpus) - offset - 1) 是去掉偏移和最后一个标签占位的有效长度
# 除以 batch_size 后向下取整再乘以 batch_size,确保整除
num_tokens = ((len(corpus) - offset - 1) // batch_size) * batch_size
# 3. 提取特征 X 和标签 Y(X 是当前位置,Y 是下一位置)
Xs = torch.tensor(corpus[offset: offset + num_tokens])
Ys = torch.tensor(corpus[offset + 1: offset + 1 + num_tokens])
# 4. 重塑为 (batch_size, -1),即分成 batch_size 行,每行是一段连续序列
Xs, Ys = Xs.reshape(batch_size, -1), Ys.reshape(batch_size, -1)
# 5. 计算每行可以切成多少个长度为 num_steps 的小段
num_batches = Xs.shape[1] // num_steps
# 6. 按 num_steps 步长遍历,每次取所有 batch 的第 i 到 i+num_steps 列
for i in range(0, num_steps * num_batches, num_steps):
X = Xs[:, i: i + num_steps] # shape: (batch_size, num_steps)
Y = Ys[:, i: i + num_steps]
yield X, Y
python
for X, Y in seq_data_iter_sequential(my_seq, batch_size=2, num_steps=5):
print('X: ', X, '\nY:', Y)

看到是对应关系 迭代期间来自两个相邻的小批量中的子序列在原始序列中确实是相邻的。
随机采样和顺序采样小结:
随机采样:
1随机偏移
corpus = corpusrandom.randint(0, num_steps - 1):
从 [0, num_steps-1]中随机选一个偏移量,丢弃前面的部分,使每个 epoch 的起始点不同。
2计算子序列数量
num_subseqs = (len(corpus) - 1) // num_steps
剩余序列中,每 num_steps个词元构成一个子序列(不重叠),最后一个词元不能作为起始(因为需要标签),所以减1。
3生成起始索引列表
initial_indices = list(range(0, num_subseqs * num_steps, num_steps))
每个子序列的起始索引:0, num_steps, 2*num_steps, ...
4打乱起始索引
random.shuffle(initial_indices) 随机打乱,使得后续每个 batch 中的子序列在原始序列中不相邻。
5定义截取函数
def data(pos):
return corpuspos: pos + num_steps
6循环生成 batch
for i in range(0, batch_size * num_batches, batch_size):
initial_indices_per_batch = initial_indicesi: i + batch_size
X = data(j) for j in initial_indices_per_batch
Y = data(j + 1) for j in initial_indices_per_batch
yield torch.tensor(X), torch.tensor(Y)
每次取 batch_size个起始索引,构造特征 X(当前词元序列)和标签 Y(下一个词元序列),形状均为 (batch_size, num_steps)。
顺序:
1随机偏移 offset = random.randint(0, num_steps)
从 [0, num_steps]中随机选一个偏移量,同样增加多样性。
2计算可用词元数
num_tokens = ((len(corpus) - offset - 1) // batch_size) * batch_size
去掉偏移量和最后一个标签占位后,取 batch_size的整数倍长度,确保后面能均匀分成 batch_size份。
3提取特征和标签
Xs = torch.tensor(corpusoffset: offset + num_tokens)
Ys = torch.tensor(corpusoffset + 1: offset + 1 + num_tokens) Xs是当前位置的词元,Ys是下一个位置的词元(一一对应)
4重塑为二维矩阵
Xs, Ys = Xs.reshape(batch_size, -1), Ys.reshape(batch_size, -1)
将一维序列按行切分成 batch_size个等长的连续块,每块长度为 num_tokens // batch_size。
例如:batch_size=2,序列 [0,1,2,3,4,5,6,7]→ 第0行 [0,1,2,3],第1行 [4,5,6,7]。
5计算每行可切的完整段数num_batches = Xs.shape1 // num_steps
每行的长度除以 num_steps,向下取整,得到可以切出的完整段数。
6循环生成 batch
for i in range(0, num_steps * num_batches, num_steps):
X = Xs:, i: i + num_steps # 取所有行的第 i 到 i+num_steps 列
Y = Ys:, i: i + num_steps
yield X, Y
每次沿列方向移动 num_steps,取出所有行在同一列区间的子序列。
例如 batch_size=2, num_steps=2,第一轮取 X = [[0,1],[4,5]],第二轮取 [[2,3],[6,7]]。
| 方面 | 随机采样 | 顺序采样 |
|---|---|---|
| 子序列来源 | 从整个序列中不重叠 地切分,然后随机打乱顺序 | 将序列分成 batch_size 个连续块,每个块内保持原始顺序 |
| batch 内子序列的关系 | 各子序列在原始序列中可能相距很远,无时序关联 | 同一 batch 的不同行来自不同的连续区域 ,但各行之间是并行的(同一时间步的不同"线程") |
| 梯度传播 | 每个子序列独立,梯度不跨子序列传播 | 同一行内的子序列连续,梯度可沿时间步传播(但不同行之间独立) |
| 是否保留全局顺序 | ❌ 不保留,完全随机 | ✅ 保留每行内部的顺序,但行间是并行的 |
| 典型用途 | 训练时增加随机性,防止过拟合 | 更适合需要保持时序连贯性的任务(如语言模型评估、状态传递) |
| 数据利用率 | 丢弃末尾不完整的子序列(通过 num_batches控制) |
丢弃末尾不完整的列(通过 num_batches控制),但利用了所有对齐的连续块 |