NLP经典代码复盘-NNLM-介绍+逐行代码解析
Part1-相关介绍:
神经网络语言模型(NNLM)是一种人工智能模型,用于学习预测词序列中下一个词的概率分布。它是自然语言处理(NLP)中的一个强大工具,在机器翻译、语音识别和文本生成等领域都有广泛的应用。Paper - A Neural Probabilistic Language Model(2003)[1]
原理NNLM 首先学习词的分布式表示,也称为词嵌入,它捕捉了词之间的语义相似性。然后将这些嵌入输入到神经网络模型中,通常是一个前馈神经网络或循环神经网络(RNN),该模型根据前面的词提供的上下文来学习预测序列中的下一个词。

例如,给定句子“猫在坐在”,NNLM 可能会高概率地预测下一个词为“地板”,因为这是给定上下文的常见补充。

从底部往上看:
- 最底层(输入层):
- w₀, w₁, ..., wₜ 是输入的词序列
- C(w) 是词嵌入层,将输入词转换为密集向量表示
- x层:
- 每个时间步骤会考虑n个词的上下文
- 比如 x₀ 包含了 w₀ 的多个副本的词嵌入
- U 是一个权重矩阵,用于变换词嵌入
- s层:
- s₀, s₁, ..., sₜ 是隐藏状态
- 通过 U 矩阵将 x 层的信息映射到隐藏层
- 输出层:
- V 是另一个权重矩阵
- softmax 用于将隐藏状态转换为概率分布
- P(vᵢ|w) 表示给定上下文词w时,预测下一个词vᵢ的概率
主要特点:
- 使用了固定大小的上下文窗口(n个词)
- 每个时间步都会产生一个概率分布
- 模型可以预测序列中任何位置的下一个词
这种架构常用于语言建模任务,特别是在需要考虑固定大小上下文的情况下。它是早期神经网络语言模型的典型代表,为后来的更复杂模型(如RNN、Transformer等)奠定了基础。
主要优势是可以:
- 捕捉词序信息
- 学习词之间的语义关系
- 处理变长序列
- 产生连续的概率分布
这个模型的关键特征是:
- 前馈神经网络(Feed-forward Neural Network)结构
- 不像RNN那样有循环连接
- 使用固定窗口大小的上下文 (n-gram)
- 每次预测只考虑前n个词
- 词嵌入创新
- 首次引入了可学习的词嵌入层 (C(w))
- 这个想法后来影响了 Word2Vec 等模型
- 三层结构
- 输入层:词嵌入层
- 隐藏层:非线性变换层
- 输出层:softmax层
这个模型虽然现在看来比较简单,但它开创性地:
- 解决了传统n-gram模型的稀疏性问题
- 引入了词的分布式表示
- 为后来的深度学习语言模型铺平了道路
它的局限性(也是为什么后来被RNN、Transformer取代):
- 固定的上下文窗口大小限制了长距离依赖的建模
- 参数量随词表大小增长
- 计算效率相对较低
第一个局限性:固定的上下文窗口大小限制了长距离依赖的建模 这个模型使用固定大小的窗口(比如前后5个词)来预测中心词。这意味着超出这个窗口范围的词之间的关系就无法建模。 举个例子:"我今天早上吃了一个苹果,它很甜。"在这句话中,如果窗口大小设为3,那么模型就无法学习到"苹果"和"甜"之间的关系,因为它们之间的距离太远了。 第二个局限性:参数量随词表大小增长 对于词表大小为V的情况,模型需要为每个词学习一个向量表示,参数量是O(V)级别的 当处理大规模语料时,词表可能会非常大(比如几十万个词),这样参数量就会变得很庞大 大量参数不仅增加了存储开销,还容易导致过拟合问题 第三个局限性:计算效率相对较低 模型在训练时需要计算每个词和词表中所有其他词的相似度,这是一个O(V^2)的计算量 对于大词表来说,这种计算开销是很大的 虽然有一些优化方法(如负采样),但相比后来的模型架构仍然不够高效
这就是为什么后来的RNN和Transformer模型能够取代它的原因: RNN可以处理任意长度的序列,克服了固定窗口的限制 Transformer的注意力机制可以直接建模任意位置词之间的关系 它们都有更高效的参数共享机制,不会随词表大小线性增长
示例
假设我们有一个大型的文本语料库,比如一系列新闻文章。我们可以对这些数据进行 NNLM 训练,以学习单词和它们上下文之间的关系。训练完成后,模型可以生成连贯和与上下文相关的句子。
例如,如果我们提供初始短语“人工智能是”,NNLM 可能生成以下完成句子:“人工智能正在改变行业,重塑未来的工作。”
应用
1、机器翻译: NNLM 在机器翻译系统中发挥作用,通过预测源语言上下文的下一个词来生成流畅且准确的翻译。 2、语音识别: NNLM 在语音识别系统中起着至关重要的作用,通过从口语表达中预测最可能的词序列。 3、文本生成: NNLM 在各种文本生成任务中使用,包括对话生成、故事生成和内容摘要,在这些任务中,它们基于给定的输入生成连贯且与上下文相关的文本。 4、语言建模: NNLM 作为语言建模任务的基础,用于估计在给定上下文中序列单词发生的概率。这在拼写检查、自动完成和语法错误检测等任务中特别有用。
Part2-代码逐行解释:
NNLM实现的代码如下:
import torch # 导入PyTorch库,用于构建和训练神经网络 import torch.nn as nn # 导入神经网络模块 import torch.optim as optim # 导入优化器模块 def make_batch(): input_batch = [] # 初始化输入批次列表 target_batch = [] # 初始化目标批次列表 for sen in sentences: word = sen.split() # 使用空格分割句子,得到单词列表 input = [word_dict[n] for n in word[:-1]] # 将句子中除最后一个单词外的所有单词转换为对应的索引,作为输入 target = word_dict[word[-1]] # 将句子的最后一个单词转换为对应的索引,作为目标 # 我们通常称这种模型为“因果语言模型”(Casual Language Model),因为它预测下一个单词 input_batch.append(input) # 将输入添加到输入批次中 target_batch.append(target) # 将目标添加到目标批次中 return input_batch, target_batch # 返回输入批次和目标批次 # 模型定义 class NNLM(nn.Module): def __init__(self): super(NNLM, self).__init__() # 初始化父类 self.C = nn.Embedding(n_class, m) # 定义嵌入层,将词汇索引转换为向量表示,n_class是词汇表大小,m是嵌入维度 self.H = nn.Linear(n_step * m, n_hidden, bias=False) # 定义线性层,将输入向量转换为隐藏层表示,n_step是上下文窗口大小,n_hidden是隐藏层大小 self.d = nn.Parameter(torch.ones(n_hidden)) # 定义偏置向量d,形状为[n_hidden] self.U = nn.Linear(n_hidden, n_class, bias=False) # 定义线性层,将隐藏层表示转换为输出,n_class是词汇表大小 self.W = nn.Linear(n_step * m, n_class, bias=False) # 定义线性层,将输入向量直接转换为输出,类似于残差连接 self.b = nn.Parameter(torch.ones(n_class)) # 定义偏置向量b,形状为[n_class] def forward(self, X): X = self.C(X) # 将输入X通过嵌入层,得到嵌入向量,形状为[batch_size, n_step, m] X = X.view(-1, n_step * m) # 将嵌入向量展平为二维张量,形状为[batch_size, n_step * m] tanh = torch.tanh(self.d + self.H(X)) # 将展平后的输入通过线性层H,加上偏置d后经过tanh激活,形状为[batch_size, n_hidden] output = self.b + self.W(X) + self.U(tanh) # 将输入X通过线性层W,加上隐藏层输出U(tanh)和偏置b,得到最终输出,形状为[batch_size, n_class] return output # 返回输出 if __name__ == '__main__': n_step = 2 # 定义上下文窗口大小,即n-1,在论文中为n-1 n_hidden = 2 # 定义隐藏层大小,即h,在论文中为h m = 2 # 定义嵌入维度,即m,在论文中为m sentences = ["i like dog", "i love coffee", "i hate milk"] # 定义训练句子 word_list = " ".join(sentences).split() # 将所有句子连接成一个字符串并分割成单词列表 word_list = list(set(word_list)) # 去除重复单词,得到词汇表 word_dict = {w: i for i, w in enumerate(word_list)} # 创建单词到索引的字典 number_dict = {i: w for i, w in enumerate(word_list)} # 创建索引到单词的字典 n_class = len(word_dict) # 词汇表大小,即词汇表中的单词数量 model = NNLM() # 实例化模型 criterion = nn.CrossEntropyLoss() # 定义损失函数为交叉熵损失 optimizer = optim.Adam(model.parameters(), lr=0.001) # 定义优化器为Adam,学习率为0.001 input_batch, target_batch = make_batch() # 生成输入批次和目标批次 input_batch = torch.LongTensor(input_batch) # 将输入批次转换为PyTorch的长整型张量 target_batch = torch.LongTensor(target_batch) # 将目标批次转换为PyTorch的长整型张量 # 训练过程 for epoch in range(5000): optimizer.zero_grad() # 清零梯度 output = model(input_batch) # 前向传播,得到输出 # 输出形状为[batch_size, n_class],目标批次形状为[batch_size] loss = criterion(output, target_batch) # 计算损失 if (epoch + 1) % 1000 == 0: print('Epoch:', '%04d' % (epoch + 1), 'cost =', '{:.6f}'.format(loss)) # 每1000个epoch打印一次损失 loss.backward() # 反向传播,计算梯度 optimizer.step() # 更新模型参数 # 预测 predict = model(input_batch).data.max(1, keepdim=True)[1] # 对输入批次进行预测,选择概率最高的类别 # 测试 print([sen.split()[:2] for sen in sentences], '->', [number_dict[n.item()] for n in predict.squeeze()]) # 打印预测结果
Part3-一些关键代码的原理分析
nn.Embedding的工作原理如下:
self.C = nn.Embedding(n_class, m)
这里:
- n_class: 词表大小(vocabulary size),比如5000个词
- m: 词嵌入维度,比如每个词用300维向量表示
输入X的要求:
- X应该是一个整数张量
- 这些整数是词表中词的索引(index)
- 取值范围必须在[0, n_class-1]之间
举个例子:
# 词表大小为5000,嵌入维度为300 embedding = nn.Embedding(5000, 300) # 输入可以是: x = torch.tensor([1, 4, 2]) # batch_size=3 # 或者 x = torch.tensor([[1,4,2], [5,6,7]]) # batch_size=2,每个样本长度为3
工作过程:
X = self.C(X)
- embedding层内部维护了一个形状为(n_class, m)的参数矩阵
- 当输入整数i时,就会返回这个矩阵的第i行
- 本质上是一个查表(lookup)操作
具体转换过程:
# 假设输入 X = torch.tensor([1, 4, 2]) # embedding矩阵形状 (5000, 300) # 输出 X 的形状会变成 (3, 300) # 每一行是对应输入索引的300维词向量
"输入X"之后:
- Embedding层已经封装了查表的逻辑
- 只要给出词的索引,它就能自动返回对应的词向量
- 这些词向量是模型训练过程中学习得到的
维度变化:
- 如果输入X形状是(N,),输出形状是(N, m)
- 如果输入X形状是(N, L),输出形状是(N, L, m)
在不同维度下词嵌入(embedding)的查找过程:
对于一维输入向量的情况:
X = torch.tensor([1, 4, 2]) # 输入形状: (3,) embedding = torch.randn(5000, 300) # embedding矩阵: (5000, 300) output = embedding[X] # 输出形状: (3, 300)
对于二维输入张量的情况:
# 假设输入是一个batch的句子,每个句子有4个词 X = torch.tensor([ [1, 4, 2, 0], # 第1个句子 [3, 2, 1, 4] # 第2个句子 ]) # 输入形状: (2, 4) embedding = torch.randn(5000, 300) # embedding矩阵: (5000, 300) output = embedding[X] # 输出形状: (2, 4, 300)
在二维输入的情况下:
- 输出的第一个维度(2)表示batch size
- 第二个维度(4)表示序列长度
- 第三个维度(300)是词向量维度
所以对于形状为(batch_size, seq_len)的输入,经过embedding层后输出形状会变为(batch_size, seq_len, embedding_dim)。每个位置都会被替换为对应词的词向量。
embedding中词表大小(vocabulary size)的作用:
embedding矩阵的形状是(5000, 300),这里:
- 5000 是词表大小(vocabulary size),表示我们可以表示的不同词的数量
- 300 是每个词的词向量维度
工作原理:
# 假设我们的词表是这样的映射: # "hello" -> 1 # "world" -> 4 # "good" -> 2 # ... (其他词) # 总共5000个不同的词,每个词都有一个0-4999的唯一索引 X = torch.tensor([1, 4, 2]) # 输入的是词的索引 embedding = torch.randn(5000, 300) # 5000个词,每个词用300维向量表示 # embedding[1] 获取第1行,也就是"hello"的词向量 # embedding[4] 获取第4行,也就是"world"的词向量 # embedding[2] 获取第2行,也就是"good"的词向量
关键点:
- 输入张量X中的每个数字必须在0到4999之间,因为这是在5000行的embedding矩阵中查找对应行
- 如果输入一个>=5000的索引,会导致索引越界错误
- 通常会保留索引0用于特殊的PAD token(用于将不同长度的序列补齐到相同长度)
实际应用中:
# 假设我们有一个句子: "hello world" # 首先需要通过词表映射转换成索引 text_indices = torch.tensor([1, 4]) # "hello"->1, "world"->4
# 然后通过embedding层获取词向量 word_vectors = embedding[text_indices] # 输出形状(2, 300)
这就是为什么在处理文本数据时,我们需要:
- 首先建立词表(vocabulary),为每个词分配唯一的索引
- 将文本转换为索引序列
- 使用这些索引从embedding矩阵中查找对应的词向量
词表大小(5000)决定了模型能够处理的不同词的数量上限。如果实际应用中有更多不同的词,就需要相应增加词表大小。
首先,创建词表和映射:
sentences = ["i like dog", "i love coffee", "i hate milk"] word_list = " ".join(sentences).split() # ['i', 'like', 'dog', 'i', 'love', 'coffee', 'i', 'hate', 'milk'] word_list = list(set(word_list)) # 去重: ['i', 'like', 'dog', 'love', 'coffee', 'hate', 'milk'] word_dict = {w: i for i, w in enumerate(word_list)}
此时的word_dict可能长这样:
word_dict = { 'i': 0, 'like': 1, 'dog': 2, 'love': 3, 'coffee': 4, 'hate': 5, 'milk': 6 }
接下来看make_batch()函数是如何使用这个词典的:
def make_batch(): input_batch = [] target_batch = [] for sen in sentences: word = sen.split() input = [word_dict[n] for n in word[:-1]] # 转换除最后一个词外的所有词为索引 target = word_dict[word[-1]] # 最后一个词的索引作为目标 # 补齐到n_step长度 while len(input) < n_step: input.append(0) # 用0做padding input_batch.append(input) target_batch.append(target) return input_batch, target_batch
具体例子: 对于句子 "i like dog":
- 输入序列会是:[word_dict['i'], word_dict['like']] = [0, 1]
- 目标是:word_dict['dog'] = 2
所以实际的batch可能是:
input_batch = [ [0, 1], # 来自 "i like dog" [0, 3], # 来自 "i love coffee" [0, 5] # 来自 "i hate milk" ] target_batch = [ 2, # "dog"的索引 4, # "coffee"的索引 6 # "milk"的索引 ]
最后这些索引被转换为张量:
input_batch = torch.LongTensor(input_batch) # 形状: (3, 2) target_batch = torch.LongTensor(target_batch) # 形状: (3)
原始的文本句子就被转换成了模型可以处理的数字索引序列。这些索引会被传入模型的embedding层,将每个词映射到对应的词向量空间中。每个索引实际上就是embedding矩阵中的行索引。
核心公式解读:

用一个具体的例子来解释这两个公式的计算过程:
sentences = ["i like dog"] n_step = 2 # 上下文窗口大小 # 1. 首先计算概率 P(wt|wt-1,...wt-n+1) # 对于 "i like dog" 这个句子: # 我们要预测 "dog" 基于前面的 "i like" # 模型前向传播过程: def forward(self, input): # input形状: (batch_size, n_step) # 例如:[[0, 1]] # 0和1是"i"和"like"的索引 # 1. 通过embedding层获取词向量 embedded = self.embed(input) # 形状: (batch_size, n_step, m) # 2. 将词向量展平并通过隐藏层 h = torch.tanh(self.h(embedded.view(-1, self.n_step * self.m))) # 3. 通过输出层得到每个词的分数 output = self.out(h) # 形状: (batch_size, n_class) # 4. 通过softmax得到概率分布 return F.log_softmax(output, dim=1) # 这就是log P(wt|wt-1,...wt-n+1) # 计算损失函数 L def compute_loss(output, target): # output是模型输出的log概率 # target是实际的下一个词的索引(在我们的例子中是"dog"的索引) # CrossEntropyLoss已经包含了log_softmax loss = criterion(output, target) # 加上正则化项 R(θ) l2_reg = 0 for param in model.parameters(): l2_reg += torch.norm(param) loss += 0.001 * l2_reg # R(θ)项,0.001是正则化系数 return loss # 参数更新 def update_params(): # 1. 计算梯度 loss.backward() # 2. 使用优化器更新参数 # θ ← θ + ε * (∂log P/∂θ) optimizer.step() # Adam优化器会自动完成参数更新 optimizer.zero_grad() # 清空梯度 # 完整的训练步骤 input_batch = torch.LongTensor([[0, 1]]) # "i like" target_batch = torch.LongTensor([2]) # "dog" for epoch in range(100): # 前向传播 output = model(input_batch) # 计算损失 loss = compute_loss(output, target_batch) # 更新参数 update_params()
具体计算流程:
- 对于句子"i like dog":
- 输入是"i like"的索引序列[0, 1]
- 目标是预测"dog"的索引
# 第一个公式(L)的计算:
# L = (1/T) * Σ log P(wt|context) + R(θ) # 其中T是训练样本数量 L = (1/1) * log P("dog"|"i like") + λ||θ||²
# 第二个公式(参数更新)的计算:
# θ ← θ + ε * (∂log P/∂θ)
# 这由优化器自动完成:
optimizer.step()
这个过程会不断重复,直到模型收敛,即损失函数L达到最小值。每次迭代都会:
- 计算当前参数下的条件概率
- 计算损失函数
- 计算梯度
- 更新参数
第一个公式(目标函数):
L = (1/T)∑log f(wt, wt-1, ..., wt-n+1; θ) + R(θ)
这是一个对数似然函数加正则化项:
- T 是训练样本的总数
- ∑ 表示对所有训练样本求和
- log f(...) 是对每个词在其上下文下出现的概率取对数
- wt 是当前要预测的词
- wt-1, ..., wt-n+1 是上下文窗口中的前面n-1个词
- θ 是模型参数
- R(θ) 是正则化项,用来防止过拟合
第二个公式(参数更新):
θ ← θ + ε(∂log P(wt|wt-1, ...wt-n+1)/∂θ)
这是梯度上升更新公式:
- θ 是模型参数
- ε 是学习率
- ∂log P/∂θ 是对数概率关于参数的偏导数(梯度)
- ← 表示参数更新
举个具体例子:
# 假设我们有句子 "i like dog" # n_step = 2 (上下文窗口大小) # 1. 计算概率 input_context = "i like" # 上下文 target_word = "dog" # 目标词 # 计算 log P("dog"|"i like") log_prob = model(input_context) # 模型输出的是log概率 # 2. 计算损失(第一个公式) loss = -log_prob + lambda * ||parameters||² # 加入L2正则化 # 3. 参数更新(第二个公式) # 在PyTorch中自动完成: loss.backward() # 计算梯度 optimizer.step() # 更新参数:θ_new = θ_old + ε * gradient
简单来说:
- 第一个公式告诉我们要最大化什么:最大化每个词在其上下文下出现的概率
- 第二个公式告诉我们如何达到这个目标:通过梯度上升来更新参数
这就是语言模型试图学习的东西:给定一个上下文序列,预测下一个最可能出现的词。
为什么log里要加入模型参数θ?
log f(wt, wt-1, ..., wt-n+1; θ)
- f是一个由模型参数θ定义的函数,它输出预测下一个词的概率
- θ包含了模型的所有参数,比如:
- embedding层的权重
- 隐藏层的权重和偏置
- 输出层的权重和偏置
举个例子:
class NNLM(nn.Module): def __init__(self): super(NNLM, self).__init__() self.embed = nn.Embedding(n_class, m) # θ1: embedding矩阵 self.h = nn.Linear(n_step * m, n_hidden) # θ2: 隐藏层权重 self.out = nn.Linear(n_hidden, n_class) # θ3: 输出层权重 def forward(self, X): # X是输入的词索引序列 embedded = self.embed(X) # 使用θ1 h = torch.tanh(self.h(embedded.view(-1, n_step * m))) # 使用θ2 output = self.out(h) # 使用θ3 return F.log_softmax(output, dim=1) # 计算log概率
为什么能通过梯度上升来更新参数?
- 关键在于概率f是参数θ的可导函数
- 通过链式法则,我们可以计算损失函数L对每个参数的偏导数
# 假设我们要预测"dog"基于上下文"i like" # 1. 前向传播 embedded = embed_layer("i like") # 使用embedding参数 hidden = hidden_layer(embedded) # 使用隐藏层参数 logits = output_layer(hidden) # 使用输出层参数 probs = softmax(logits) # 得到概率分布 log_prob_dog = log(probs["dog"]) # 取"dog"的对数概率 # 2. 反向传播 # ∂log_prob_dog/∂θ可以通过链式法则计算: # ∂log_prob_dog/∂θ = # ∂log_prob_dog/∂probs * # ∂probs/∂logits * # ∂logits/∂hidden * # ∂hidden/∂embedded * # ∂embedded/∂θ # 3. 参数更新 θ_new = θ_old + ε * (∂log_prob_dog/∂θ)
这样工作是因为:
- 所有运算(embedding查找、矩阵乘法、激活函数等)都是可导的
- 参数影响着最终的概率输出
- 我们可以计算参数的微小变化如何影响最终概率
- 沿着梯度方向更新参数会增加目标词的预测概率
所以,整个过程是:
- 使用当前参数计算预测概率
- 计算这个预测与真实目标的差距(损失)
- 计算损失对参数的梯度
- 使用梯度更新参数,使得下次预测更准确


浙公网安备 33010602011771号