手把手实现Bigram语言模型:从概率计算到PyTorch实战

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
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值