1. 项目概述:从“语言概率计算器”说起
你有没有试过在手机输入法里打“今天天气”,刚敲完“今”,键盘就自动跳出“今天天气怎么样”?或者在写邮件时,输入“请查收附件”,后半句“已附在下方”几乎不假思索就弹了出来?这些看似魔法的功能,背后站着的不是玄学,而是一群被训练得极其“懂人话”的数学模型——统计语言模型(Statistical Language Model, SLM)。它本质上就是一个 大规模、高精度的语言概率计算器 :给定前面几个词,它能快速算出接下来最可能出现的词是什么,以及这个可能性有多大。这和我们人类说话时的直觉高度吻合:说“我爱吃……”,后面接“苹果”“火锅”“巧克力”的概率,远高于接“混凝土”或“显微镜”。这种对语言内在规律的量化建模,正是SLM的核心价值。
本文聚焦于SLM中最经典、最易理解、也最具教学价值的入门范式——
Bigram Model(二元语法模型)
。它不依赖GPU集群,不调用百亿参数大模型API,仅用几十行代码、一个包含三句话的极小语料库,就能让你亲手搭建、训练、并亲眼看到一个“会预测下一个词”的模型是如何从零诞生的。这不是一个抽象的理论推导,而是一次可触摸、可调试、可复现的实操旅程。无论你是刚接触NLP的编程新手,还是想夯实基础的算法工程师,又或是对“AI怎么听懂人话”充满好奇的产品经理,这篇内容都为你准备了完整的、去平台化的、一步一坑的实践指南。它不讲空泛的“信息论起源”,也不堆砌“Transformer架构演进史”,而是把镜头拉近到代码行间,告诉你
P("cat" | "the")
这个概率值究竟是怎么被算出来的,为什么
torch.nn.Embedding
层在这里不可或缺,以及当你发现模型把“句子”预测成“句号”时,问题到底出在分词逻辑还是索引映射上。真正的理解,永远始于亲手让第一行代码跑通,并看到那个属于你自己的、微小却真实的预测结果。
2. 核心设计思路与方案选型解析
2.1 为什么从Bigram开始?而非Trigram或Neural LM?
选择Bigram作为SLM的起点,绝非随意为之,而是基于一套清晰、务实、且经得起时间检验的工程权衡。我们可以把它想象成学习骑自行车:你不会一上来就挑战山地越野车,而是先从一辆结构简单、重心稳定、故障率低的儿童平衡车开始。Bigram就是NLP世界的那辆“平衡车”。
首先,
复杂度与可解释性的黄金分割点
。一个Trigram模型需要计算
P(w_n | w_{n-2}, w_{n-1})
,即每个词的概率依赖于前两个词。这在数学上意味着状态空间呈指数级膨胀。对于一个包含V个词的词汇表,Bigram的参数量是V²,而Trigram则是V³。当V=10,000时,Bigram需要1亿个参数,Trigram则飙升至1万亿——这已经超出了单机内存的承载能力,更别提训练了。而Bigram的1亿参数,用现代CPU或一块入门级GPU,几分钟就能完成训练。更重要的是,Bigram的每一个概率值
P("is" | "this")
都可以直接在原始语料中数出来:只要统计“this is”这个组合出现了多少次,再除以“this”开头的总次数。这个过程完全透明、可审计、可验证,没有任何黑箱。你可以打开文本编辑器,手动数一遍,然后和代码输出的结果做比对,误差为零。这种“所见即所得”的确定性,是任何深度学习模型都无法提供的宝贵学习体验。
其次,
它是所有高级模型的“原子基石”
。今天的GPT、Claude等大模型,其底层的自回归生成机制,依然是在不断预测
P(w_n | w_1, ..., w_{n-1})
。它们的强大,源于用神经网络这个“超级函数逼近器”,将这个无限长的条件概率,巧妙地压缩、泛化、并学习到了海量的隐含模式。但这个目标函数本身,从未改变。Bigram模型,就是这个目标函数最朴素、最赤裸的表达形式。当你亲手实现一个Bigram,你就亲手定义了
P(w_n | context)
这个核心契约。后续学习LSTM或Transformer时,你不会再困惑于“为什么损失函数要用CrossEntropyLoss”,因为你早已在Bigram里用它来最小化
log P(predicted_word | true_context)
。这种底层逻辑的贯通,是跳过基础、直奔大模型所无法获得的。
最后, 它完美规避了“数据稀疏性”这个NLP头号杀手的初级形态 。在真实世界中,绝大多数词组组合在语料中只出现一次,甚至零次。这就是所谓的“长尾分布”。一个5-gram模型,面对一个从未见过的五词序列,几乎束手无策。而Bigram的“短视”反而成了优势:它只看前一个词,因此即使整个句子是全新的,只要其中的每一对相邻词(如“the cat”, “cat sat”, “sat on”)在训练集中出现过,它就能给出一个合理的预测。这就像一个经验丰富的老编辑,他可能没读过你写的这篇稿子,但他对“的”后面大概率跟名词、“很”后面大概率跟形容词的直觉,足以让他帮你润色。Bigram,就是这种直觉的数学化身。
2.2 为何选择PyTorch而非纯NumPy或Scikit-learn?
原始代码使用了PyTorch,这是一个深思熟虑的技术选型,而非随波逐流。我们可以从三个层面来剖析其必要性。
第一层是
教学目的的精准匹配
。本项目的终极目标,不是为了在Kaggle上拿一个高分,而是为了让你理解“模型如何学习”。PyTorch的
nn.Module
提供了完美的面向对象封装:
self.embedding
、
self.linear1
这些属性,清晰地映射着模型的物理结构——一个查表层,一个全连接层。它的
forward()
方法,就是你大脑中“数据流经模型”的直观图景。相比之下,如果用纯NumPy手写矩阵乘法和梯度更新,代码会迅速淹没在
np.dot()
、
np.sum()
和
np.transpose()
的海洋里,你很难一眼看出哪个变量代表词向量,哪个代表隐藏层激活值。而Scikit-learn的
Pipeline
虽然简洁,但它是一个高度封装的黑盒,你无法窥探
fit()
内部究竟如何计算
P("is" | "this")
的梯度。PyTorch在“可控性”和“可读性”之间,找到了最佳平衡点。
第二层是
未来路径的无缝衔接
。今天你用PyTorch写一个Bigram,明天你就可以把
BigramLanguageModel
类里的
linear1
和
linear2
替换成
nn.LSTMCell
,或者把整个
forward
逻辑重构成一个
nn.TransformerEncoderLayer
。所有的API、数据加载方式(
DataLoader
)、优化器(
optim.Adam
)都保持一致。你积累的不是某个特定模型的“一次性脚本”,而是一套可迁移的、工业级的深度学习开发范式。这就像学开车,你不会先去学开拖拉机,再学开卡车,最后再学开轿车;你直接学开一辆标准的、带自动挡的家用车,因为它的油门、刹车、方向盘逻辑,是所有车辆的通用语言。PyTorch,就是NLP领域的那辆“标准家用车”。
第三层是
工程实践的现实考量
。原始代码中,
corpus = ['this is a sentence', ...]
是一个极小的玩具数据集。但在真实项目中,你的语料可能是GB级别的维基百科快照。PyTorch的
Dataset
和
DataLoader
可以轻松处理这种规模的数据流,支持多进程预加载、动态批处理、内存映射等高级特性。而NumPy在处理GB级数组时,会直接触发内存溢出(OOM),让你的笔记本电脑瞬间卡死。这种从“玩具”到“生产”的平滑过渡能力,是PyTorch赋予你的隐形资产。
2.3 为何采用“词嵌入+全连接”架构,而非直接查表计数?
这是本项目最精妙、也最容易被初学者误解的设计。原始描述中提到“n-gram模型用最大似然估计”,这通常意味着一个简单的计数公式:
P(w_n | w_{n-1}) = count(w_{n-1}, w_n) / count(w_{n-1})
。那么,为什么代码里要大费周章地引入
nn.Embedding
和
nn.Linear
,搞一个看起来更复杂的神经网络呢?
答案在于:
我们正在构建的,不是一个“静态查表器”,而是一个“可学习的、泛化的概率计算器”
。让我们用一个生活化的例子来说明。假设你是一位新来的餐厅服务员,第一天上班,老板给你一本《点菜频率手册》,上面写着:“‘可乐’在‘汉堡’之后被点的概率是85%”。这很好,但第二天,一位顾客点了“牛肉卷”,你立刻懵了——手册里没有“牛肉卷”这一项!你无法泛化。而一个“可学习”的模型,会把“汉堡”和“牛肉卷”都映射到一个连续的向量空间里。在这个空间中,“汉堡”的向量和“牛肉卷”的向量距离很近,因为它们都是“主食”。所以,模型学到的不是孤立的
P("可乐"|"汉堡")=0.85
,而是
P("可乐"|x)
这个函数,其中
x
是“主食”这个概念的向量表示。当遇到新的“牛肉卷”时,它能利用这个函数,给出一个合理的、泛化的预测。
nn.Embedding
层,就是这个向量空间的创建者。它把每一个离散的单词(如"this", "is"),映射成一个稠密的、实数的向量(例如[0.23, -1.45, 0.89, ...])。这个向量不再只是一个ID,而是蕴含了这个词的语义信息。
nn.Linear
层,则是这个泛化函数的执行者。它接收“前一个词”的向量,通过线性变换(加权求和),输出一个“对所有可能的下一个词”的打分(logits)。最后,
CrossEntropyLoss
会强制模型,让“正确答案”(如"is")的打分,远高于其他所有错误答案(如"a", "sentence")的打分。这个过程,就是在学习
P(w_n | w_{n-1})
的分布。
所以,这个看似“过度设计”的神经网络,其本质是在用一种更强大、更灵活、更具扩展性的方式,来实现和超越传统n-gram的统计目标。它不是抛弃了统计,而是用神经网络这个工具,把统计学习提升到了一个全新的维度。
3. 核心细节解析与实操要点
3.1 词汇表构建:从字符串到数字ID的精确映射
词汇表(Vocabulary)是整个语言模型的“字典”,它的构建质量,直接决定了后续所有计算的根基是否牢固。原始代码中的
vocab = set()
看似简单,但其中暗藏玄机,稍有不慎,就会导致模型训练失败或预测失真。
首先,
分词(Tokenization)是第一步,也是最关键的一步
。代码中
sentence.split()
使用空格作为分隔符,这在英文中是可行的,但必须意识到其局限性。它会将标点符号视为单词的一部分。例如,句子
"Hello, world!"
会被切分为
["Hello,", "world!"]
,而不是
["Hello", ",", "world", "!"]
。这意味着
"Hello,"
和
"Hello"
会被当作两个完全不同的词,模型永远无法学会“逗号通常跟在名词后”这一通用规则。在真实项目中,你需要引入专业的分词器,如
nltk.word_tokenize
或
spaCy
。但对于我们的教学目标,
split()
足够揭示核心原理。关键在于,你要清楚地知道,你当前的“词”(token)的定义是什么。
其次, 特殊标记(Special Tokens)的预留是专业性的体现 。一个健壮的词汇表,绝不能只有语料中出现的词。它必须预留几个“元字符”:
-
<PAD>:用于填充(Padding)不同长度的序列,使它们能组成统一的batch。 -
<UNK>:代表“未知词”(Unknown Word)。当模型遇到训练时从未见过的词(Out-of-Vocabulary, OOV),就用<UNK>来代替,而不是崩溃。 -
<BOS>/<EOS>:分别代表“句子开始”(Beginning of Sentence)和“句子结束”(End of Sentence),用于明确序列的边界。
原始代码没有这些,是因为它处理的是一个超小、超干净的玩具数据集,不存在OOV问题。但当你把代码迁移到真实数据时,忘记添加
<UNK>
,会导致
KeyError: 'new_word'
,程序直接报错。因此,在构建
word_to_ix
字典时,一个更专业的写法是:
# 初始化词汇表,加入特殊标记
vocab = {'<PAD>': 0, '<UNK>': 1, '<BOS>': 2, '<EOS>': 3}
idx = 4 # 下一个可用索引
for sentence in corpus:
for word in sentence.split():
if word not in vocab: # 避免重复添加
vocab[word] = idx
idx += 1
这样,
word_to_ix
就变成了一个功能完备的字典,为未来的扩展铺平了道路。
最后,
大小写与标准化处理是数据清洗的必修课
。
"The"
和
"the"
在ASCII码中是两个不同的字符串,
split()
会把它们当作两个词。这会人为地将词汇表扩大一倍,并稀释统计信号。在真实项目中,你几乎总是要先执行
word.lower()
。同样,去除多余的空格、处理制表符
\t
、换行符
\n
,都是必不可少的预处理步骤。这些细节,往往决定了一个模型是“能跑通”,还是“能上线”。
3.2 模型架构详解:Embedding层的物理意义与Linear层的数学本质
BigramLanguageModel
类的结构,是理解现代NLP的微观入口。我们逐层拆解,剥开它的数学外衣,看到其物理内核。
self.embedding = nn.Embedding(vocab_size, embedding_dim)
。
nn.Embedding
乍看是一个“查表”操作,但它的本质是一个
可学习的、维度为
vocab_size x embedding_dim
的权重矩阵
。想象一下,你有一本巨大的词典,每一页是一个词,而每一页上不是文字解释,而是一串
embedding_dim
个数字组成的坐标(比如,
"king"
的坐标是[0.8, -0.2, 1.5, ...])。当你调用
self.embedding(input)
时,
input
是一个整数(如
word_to_ix["the"] = 5
),模型做的就是:翻开词典的第5页,把那串坐标抄下来,作为
"the"
的向量表示。这个过程,在数学上就是一次
矩阵索引(Matrix Indexing)
。而这个矩阵本身,就是模型要学习的第一个核心参数。训练开始时,这些坐标是随机初始化的;训练结束时,它们被调整为能最好地服务于下游任务(预测下一个词)的最优表示。这就是词向量(Word Embedding)的全部秘密:它不是一个预设的、固定的规则,而是一个通过数据反向学习出来的、最能捕捉词语间关系的“语义坐标系”。
self.linear1 = nn.Linear(embedding_dim, hidden_dim)
和
self.linear2 = nn.Linear(hidden_dim, vocab_size)
。这两层
nn.Linear
,是经典的全连接层,其数学本质是
仿射变换(Affine Transformation)
:
output = input @ weight.T + bias
。
linear1
接收
embedding_dim
维的词向量,将其投影到一个
hidden_dim
维的“隐藏空间”。这个空间可以被理解为一个“中间语义场”,它比原始的词向量更抽象、更浓缩。
linear2
则将这个隐藏表示,再次投影回
vocab_size
维的“输出空间”,这个空间的每一维,对应着词汇表中一个词的“未归一化得分”(logit)。最终,
nn.CrossEntropyLoss
会将这些logits通过Softmax转换为概率,并计算与真实标签的交叉熵。这里的关键洞察是:
linear2
的权重矩阵,其形状是
vocab_size x hidden_dim
。这意味着,矩阵的每一行,实际上就是词汇表中对应词的“输出向量”。这与
embedding
层的权重矩阵(
vocab_size x embedding_dim
)形成了完美的对称。在很多先进的模型(如Word2Vec的Skip-Gram)中,这两个矩阵甚至会被共享(tied weights),因为它们本质上都在学习同一个东西:词的语义表示。
3.3 训练循环的魔鬼细节:从Bigram提取到梯度更新的完整链路
训练循环是模型“活”起来的心脏。原始代码中的几行
for
循环,包含了从数据到知识的全部转化过程。我们将其拆解为四个不可分割的环节,任何一个环节出错,都会导致模型“学不会”。
环节一:Bigram的精确提取
。
bigrams = [(sentence.split()[i], sentence.split()[i+1]) for i in range(len(sentence.split())-1)]
。这行代码的意图是好的,但它存在一个严重的性能和可维护性缺陷:它对同一个
sentence.split()
调用了两次。在Python中,每次调用
split()
都会重新遍历字符串、分配内存、创建新列表。对于一个长句子,这完全是不必要的开销。更优雅、更安全的写法是:
words = sentence.split() # 只分割一次
bigrams = [(words[i], words[i+1]) for i in range(len(words)-1)]
此外,这个提取逻辑默认了句子至少有两个词。如果语料中混入了单字词或空行,
range(len(words)-1)
会产生
range(0)
,导致
bigrams
为空列表,进而使该句子在本轮训练中被完全忽略。一个鲁棒的版本应该加上长度检查:
if len(words) < 2:
continue # 跳过太短的句子
环节二:张量的正确构造与设备管理
。
input = torch.tensor([word_to_ix[bigram[0]]], dtype=torch.long)
。这里有两个关键点。第一,
[word_to_ix[...]]
外面的方括号,是为了将一个标量(scalar)构造成一个一维张量(1D tensor),其形状为
(1,)
。这是因为
nn.Embedding
层期望的输入是一个
LongTensor
,其每个元素都是一个词的索引。第二,
dtype=torch.long
是强制要求。如果你不小心写成
torch.float
,PyTorch会抛出
RuntimeError: Expected tensor for argument #1 'indices' to have scalar type Long
。这是一个非常典型的、初学者必踩的坑。在真实项目中,你还必须考虑设备(CPU/GPU)。如果模型在GPU上,而
input
张量还在CPU上,
model(input)
会报错。因此,更完善的写法是:
input = torch.tensor([word_to_ix.get(bigram[0], 1)], dtype=torch.long).to(device) # 使用<UNK>
target = torch.tensor([word_to_ix.get(bigram[1], 1)], dtype=torch.long).to(device)
环节三:梯度清零与反向传播的因果律
。
optimizer.zero_grad()
和
loss.backward()
是深度学习的“呼吸”与“心跳”。
zero_grad()
的作用,是将模型所有参数的梯度缓存(
.grad
属性)清零。如果不做这一步,每次
backward()
计算出的梯度,都会累加(accumulate)到上一次的梯度上,导致参数更新方向完全错误。你可以把它想象成一个记账员,每次记账前,必须先把上一笔的账目擦掉,否则数字会越滚越大。
loss.backward()
则是启动“链式法则”的开关,它会从损失值
loss
开始,沿着计算图(Computation Graph)反向追溯,自动计算出模型中每一个可学习参数(
weight
,
bias
)对这个损失的偏导数(即梯度)。这个过程是PyTorch自动完成的,你无需手动求导,这是框架带来的巨大生产力解放。
环节四:参数更新与学习率的哲学
。
optimizer.step()
是最终的“落笔”动作。它根据
optimizer
中存储的优化算法(这里是SGD),使用刚刚计算出的梯度,去更新模型的参数。公式很简单:
parameter = parameter - learning_rate * gradient
。这里的
LEARNING_RATE = 0.1
是一个经验值。它像一个“步长控制器”:太大,模型会在最优解附近疯狂震荡,永远无法收敛;太小,模型会像蜗牛一样爬行,训练时间长得令人绝望。在真实项目中,学习率往往需要配合学习率调度器(Learning Rate Scheduler)动态调整,例如开始时用较大的学习率快速下降,后期用小学习率精细微调。但对于我们的Bigram玩具模型,0.1是一个能让它在100个epoch内稳定收敛的“黄金值”。
4. 实操过程与核心环节实现
4.1 完整可运行代码:从零开始的端到端复现
下面是一份经过全面加固、注释详尽、可直接复制粘贴运行的完整代码。它修复了原始代码的所有潜在缺陷,并增加了关键的诊断和可视化功能,确保你能清晰地看到模型“学会”了什么。
import torch
import torch.nn as nn
import torch.optim as optim
import matplotlib.pyplot as plt
# ------------------- 1. 数据准备与预处理 -------------------
# 原始语料,我们稍作扩充,增加一点多样性
corpus = [
"this is a sentence",
"another sentence here",
"and yet another sentence",
"the cat sat on the mat",
"a dog ran across the yard"
]
# 构建词汇表:包含特殊标记
vocab = {'<PAD>': 0, '<UNK>': 1, '<BOS>': 2, '<EOS>': 3}
idx = 4
for sentence in corpus:
# 简单的预处理:转小写,按空格分割
words = sentence.lower().split()
for word in words:
if word not in vocab:
vocab[word] = idx
idx += 1
# 创建反向映射,方便后续查看
ix_to_word = {idx: word for word, idx in vocab.items()}
print(f"词汇表大小: {len(vocab)}")
print(f"词汇表内容: {vocab}")
# ------------------- 2. 模型定义 -------------------
class BigramLanguageModel(nn.Module):
def __init__(self, vocab_size, embedding_dim, hidden_dim):
super(BigramLanguageModel, self).__init__()
self.embedding = nn.Embedding(vocab_size, embedding_dim)
self.linear1 = nn.Linear(embedding_dim, hidden_dim)
self.linear2 = nn.Linear(hidden_dim, vocab_size)
self.relu = nn.ReLU() # 添加非线性激活,提升表达能力
def forward(self, inputs):
# inputs shape: (batch_size=1,)
embeds = self.embedding(inputs) # shape: (1, embedding_dim)
hidden = self.relu(self.linear1(embeds)) # shape: (1, hidden_dim)
output = self.linear2(hidden) # shape: (1, vocab_size)
return output
# ------------------- 3. 超参数与初始化 -------------------
EMBEDDING_DIM = 16 # 增加维度,提升模型容量
HIDDEN_DIM = 32
LEARNING_RATE = 0.05 # 微调学习率
NUM_EPOCHS = 200
device = torch.device('cpu') # 默认使用CPU,如需GPU,改为 'cuda'
# 实例化模型
model = BigramLanguageModel(len(vocab), EMBEDDING_DIM, HIDDEN_DIM).to(device)
# 定义损失函数和优化器
loss_function = nn.CrossEntropyLoss()
optimizer = optim.SGD(model.parameters(), lr=LEARNING_RATE)
# ------------------- 4. 训练循环与监控 -------------------
train_losses = []
for epoch in range(NUM_EPOCHS):
total_loss = 0.0
# 遍历每一条语料
for sentence in corpus:
words = sentence.lower().split()
if len(words) < 2:
continue
# 提取所有Bigram
bigrams = [(words[i], words[i+1]) for i in range(len(words)-1)]
# 遍历每一个Bigram进行训练
for bigram in bigrams:
# 获取索引,使用get()方法处理OOV,返回<UNK>的索引1
input_idx = vocab.get(bigram[0], 1)
target_idx = vocab.get(bigram[1], 1)
# 构造张量并移动到设备
input_tensor = torch.tensor([input_idx], dtype=torch.long).to(device)
target_tensor = torch.tensor([target_idx], dtype=torch.long).to(device)
# 清零梯度
optimizer.zero_grad()
# 前向传播
output = model(input_tensor) # shape: (1, vocab_size)
# 计算损失
loss = loss_function(output, target_tensor)
# 反向传播
loss.backward()
# 参数更新
optimizer.step()
total_loss += loss.item()
# 记录每个epoch的平均损失
avg_loss = total_loss / (len(corpus) * 10) # 估算总bigram数
train_losses.append(avg_loss)
# 每20个epoch打印一次,避免刷屏
if epoch % 20 == 0:
print(f'Epoch {epoch:3d} | Average Loss: {avg_loss:.4f}')
# ------------------- 5. 训练结果可视化 -------------------
plt.figure(figsize=(10, 5))
plt.plot(train_losses, label='Training Loss')
plt.xlabel('Epoch')
plt.ylabel('Loss')
plt.title('Bigram Model Training Curve')
plt.legend()
plt.grid(True)
plt.show()
# ------------------- 6. 模型推理与结果分析 -------------------
print("\n=== 模型推理测试 ===")
# 测试几个常见的前缀
test_prefixes = ["this", "the", "a", "and"]
for prefix in test_prefixes:
# 获取输入词的索引
input_idx = vocab.get(prefix, 1)
input_tensor = torch.tensor([input_idx], dtype=torch.long).to(device)
# 模型预测
with torch.no_grad(): # 关闭梯度计算,节省内存
logits = model(input_tensor) # shape: (1, vocab_size)
probs = torch.softmax(logits, dim=1) # 转换为概率
# 获取概率最高的前3个词
topk_probs, topk_indices = torch.topk(probs, k=3, dim=1)
print(f"\n给定前缀 '{prefix}':")
for i in range(3):
word_idx = topk_indices[0][i].item()
prob = topk_probs[0][i].item()
word = ix_to_word.get(word_idx, "<UNK>")
print(f" {i+1}. '{word}' (概率: {prob:.3f})")
# ------------------- 7. 手动验证:与最大似然估计对比 -------------------
print("\n=== 手动验证:与ML估计对比 ===")
# 我们手动统计语料中 "the" 后面出现各词的频次
the_counts = {}
total_the = 0
for sentence in corpus:
words = sentence.lower().split()
for i in range(len(words)-1):
if words[i] == "the":
next_word = words[i+1]
the_counts[next_word] = the_counts.get(next_word, 0) + 1
total_the += 1
print(f"'the' 出现总次数: {total_the}")
print("'the' 后面的词及其频次:")
for word, count in sorted(the_counts.items(), key=lambda x: x[1], reverse=True):
print(f" '{word}': {count} 次 ({count/total_the:.3f})")
# 获取模型对 "the" 的预测
input_idx = vocab.get("the", 1)
input_tensor = torch.tensor([input_idx], dtype=torch.long).to(device)
with torch.no_grad():
logits = model(input_tensor)
probs = torch.softmax(logits, dim=1)[0]
# 找出模型认为概率最高的3个词
topk_probs, topk_indices = torch.topk(probs, k=3)
print(f"\n模型对 'the' 的预测 (Top-3):")
for i in range(3):
word_idx = topk_indices[i].item()
word = ix_to_word.get(word_idx, "<UNK>")
prob = topk_probs[i].item()
print(f" '{word}': {prob:.3f}")
这段代码的亮点在于其 可验证性 。它不仅训练模型,还专门开辟了一个“手动验证”模块,将模型的预测结果,与你在语料中亲手数出来的最大似然(ML)估计结果进行并排对比。你会发现,在训练初期,两者可能相差甚远;但随着epoch增加,模型的预测概率会越来越接近你手动统计的频次比例。这种“眼见为实”的对比,是建立你对模型信任感的最坚实基础。
4.2 关键参数选择的计算与权衡
在代码中,我们设置了
EMBEDDING_DIM = 16
和
HIDDEN_DIM = 32
。这些数字并非拍脑袋决定,而是基于对模型容量与数据规模的审慎评估。
首先,
词嵌入维度(Embedding Dim)的选择
。一个经验法则是:
embedding_dim ≈ sqrt(vocab_size)
。我们的词汇表大小约为15(你可以运行代码查看
print(len(vocab))
),
sqrt(15) ≈ 3.87
。但我们选择了16,原因有二:第一,3或4维的向量空间过于狭小,无法有效区分“cat”和“dog”这样语义相近但不同的词;第二,硬件友好性。现代GPU的内存访问是按“块”(cache line)进行的,维度为16、32、64等2的幂次,能获得最佳的内存带宽利用率。16是一个在表达力和效率之间取得良好平衡的“甜蜜点”。
其次,
隐藏层维度(Hidden Dim)的选择
。它通常设置为嵌入维度的2倍左右。这是因为
linear1
层的任务,是将一个相对“稀疏”的词向量(16维),映射到一个更“稠密”、更“抽象”的中间表示(32维)。这个中间表示需要有足够的自由度,去捕捉词与词之间更复杂的交互模式。如果
hidden_dim
太小(比如等于
embedding_dim
),
linear1
层就退化为一个简单的线性变换,失去了引入非线性(
ReLU
)的意义;如果太大(比如128),模型会变得臃肿,容易在小数据集上过拟合,即把训练数据中的噪声也当成了规律来学习。
最后,
学习率(Learning Rate)的微调
。原始代码的
0.1
对于我们的扩充语料来说略显激进。我们将其降为
0.05
,并通过观察训练曲线来验证。一个健康的训练曲线,应该呈现出平滑、单调的下降趋势。如果曲线剧烈抖动(loss忽高忽低),说明学习率太大;如果曲线几乎水平不动,说明学习率太小。我们通过
plt.plot(train_losses)
将这个过程可视化,让抽象的“学习”变得具体可感。
4.3 模型推理与结果解读:读懂模型的“语言直觉”
训练完成后,最激动人心的时刻就是进行推理(Inference)。代码末尾的
test_prefixes
部分,展示了模型对几个常见前缀的预测。让我们深入解读一下输出结果。
假设你看到这样的输出:
给定前缀 'the':
1. 'cat' (概率: 0.421)
2. 'mat' (概率: 0.285)
3. 'yard' (概率: 0.153)
这不仅仅是一个冰冷的概率列表,它背后讲述了一个关于“语言模式”的故事。模型之所以认为
'cat'
最有可能跟在
'the'
后面,是因为在训练语料中,
"the cat"
这个组合出现了(例如在
"the cat sat on the mat"
中)。同理,
'mat'
紧随
'cat'
之后,所以
"the cat sat on the mat"
这条长链,通过
"the cat"
和
"cat sat"
这两个Bigram,共同强化了
'the' -> 'cat'
和
'cat' -> 'sat'
的关联。模型并没有“记住”整条句子,而是通过学习海量的局部片段(Bigram),拼凑出了对全局结构的理解。
提示:注意观察
'a'的预测。你可能会看到'sentence'、'dog'、'yard'等词。这反映了'a'作为一个不定冠词,其后接名词的泛化能力。模型已经从"a sentence"和"a dog"中,抽象出了'a'倾向于修饰可数名词的规律。这种从具体实例中提炼出一般性规则的能力,正是机器学习的精髓所在。
这种解读方式,将模型从一个“黑箱预测器”,转变为你理解语言统计规律的“对话伙伴”。每一次推理,都是一次与模型的问答,它用概率数字回答你:“在你给我的这个世界里,接下来最可能发生什么?”
5. 常见问题与排查技巧实录
5.1 典型问题速查表
| 问题现象 | 可能原因 | 排查与解决技巧 |
|---|---|---|
KeyError: 'xxx'
| 词汇表构建时遗漏了某个词,或推理时输入了训练语料中未出现的词。 |
立即检查
:在构建
vocab
后,打印
print(corpus)
和
print(vocab.keys())
,确认所有词都在字典里。
永久修复
:在
vocab.get(word, 1)
中始终使用
<UNK>
索引(1),并在
ix_to_word
中定义好
1: '<UNK>'
。
|
| **`RuntimeError: Expected |

223

被折叠的 条评论
为什么被折叠?



