NLP经典代码复盘-NNLM-介绍+逐行代码解析

Part1-相关介绍:

神经网络语言模型(NNLM)是一种人工智能模型,用于学习预测词序列中下一个词的概率分布。它是自然语言处理(NLP)中的一个强大工具,在机器翻译、语音识别和文本生成等领域都有广泛的应用。Paper - A Neural Probabilistic Language Model(2003)[1]

原理

NNLM 首先学习词的分布式表示,也称为词嵌入,它捕捉了词之间的语义相似性。然后将这些嵌入输入到神经网络模型中,通常是一个前馈神经网络或循环神经网络(RNN),该模型根据前面的词提供的上下文来学习预测序列中的下一个词。

 

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

从底部往上看:

  1. 最底层(输入层):
  • w₀, w₁, ..., wₜ 是输入的词序列
  • C(w) 是词嵌入层,将输入词转换为密集向量表示
  1. x层:
  • 每个时间步骤会考虑n个词的上下文
  • 比如 x₀ 包含了 w₀ 的多个副本的词嵌入
  • U 是一个权重矩阵,用于变换词嵌入
  1. s层:
  • s₀, s₁, ..., sₜ 是隐藏状态
  • 通过 U 矩阵将 x 层的信息映射到隐藏层
  1. 输出层:
  • V 是另一个权重矩阵
  • softmax 用于将隐藏状态转换为概率分布
  • P(vᵢ|w) 表示给定上下文词w时,预测下一个词vᵢ的概率

主要特点:

  1. 使用了固定大小的上下文窗口(n个词)
  2. 每个时间步都会产生一个概率分布
  3. 模型可以预测序列中任何位置的下一个词

这种架构常用于语言建模任务,特别是在需要考虑固定大小上下文的情况下。它是早期神经网络语言模型的典型代表,为后来的更复杂模型(如RNN、Transformer等)奠定了基础。

主要优势是可以:

  • 捕捉词序信息
  • 学习词之间的语义关系
  • 处理变长序列
  • 产生连续的概率分布

这个模型的关键特征是:

  1. 前馈神经网络(Feed-forward Neural Network)结构
  • 不像RNN那样有循环连接
  • 使用固定窗口大小的上下文 (n-gram)
  • 每次预测只考虑前n个词
  1. 词嵌入创新
  • 首次引入了可学习的词嵌入层 (C(w))
  • 这个想法后来影响了 Word2Vec 等模型
  1. 三层结构
  • 输入层:词嵌入层
  • 隐藏层:非线性变换层
  • 输出层: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)

在二维输入的情况下:

  1. 输出的第一个维度(2)表示batch size
  2. 第二个维度(4)表示序列长度
  3. 第三个维度(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"的词向量

关键点:

  1. 输入张量X中的每个数字必须在0到4999之间,因为这是在5000行的embedding矩阵中查找对应行
  2. 如果输入一个>=5000的索引,会导致索引越界错误
  3. 通常会保留索引0用于特殊的PAD token(用于将不同长度的序列补齐到相同长度)

实际应用中:

# 假设我们有一个句子: "hello world"
# 首先需要通过词表映射转换成索引
text_indices = torch.tensor([1, 4])  # "hello"->1, "world"->4
# 然后通过embedding层获取词向量 word_vectors = embedding[text_indices] # 输出形状(2, 300)

这就是为什么在处理文本数据时,我们需要:

  1. 首先建立词表(vocabulary),为每个词分配唯一的索引
  2. 将文本转换为索引序列
  3. 使用这些索引从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()

具体计算流程:

  1. 对于句子"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达到最小值。每次迭代都会:

  1. 计算当前参数下的条件概率
  2. 计算损失函数
  3. 计算梯度
  4. 更新参数

第一个公式(目标函数):

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

简单来说:

  1. 第一个公式告诉我们要最大化什么:最大化每个词在其上下文下出现的概率
  2. 第二个公式告诉我们如何达到这个目标:通过梯度上升来更新参数

这就是语言模型试图学习的东西:给定一个上下文序列,预测下一个最可能出现的词。

为什么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/∂θ)

这样工作是因为:

  1. 所有运算(embedding查找、矩阵乘法、激活函数等)都是可导的
  2. 参数影响着最终的概率输出
  3. 我们可以计算参数的微小变化如何影响最终概率
  4. 沿着梯度方向更新参数会增加目标词的预测概率

所以,整个过程是:

  1. 使用当前参数计算预测概率
  2. 计算这个预测与真实目标的差距(损失)
  3. 计算损失对参数的梯度
  4. 使用梯度更新参数,使得下次预测更准确

Part4-相关学习链接

🔗学习链接1

🔗学习链接2

posted @ 2025-01-17 00:21  谁的青春不迷糊  阅读(273)  评论(0)    收藏  举报