如果你翻过早期深度学习相关的经典资料,大概率会对这样一张网络结构图有印象:一个滑动窗口扫过句子里连续的前5个词,每个词先查表得到一个稠密向量,拼接成一长条,经过一层带tanh激活的全连接层,最后在一个巨大的词表上做softmax、输出每个词作为下一个词的概率。这就是神经网络语言模型(NNLM)。
这个网络放在今天看结构确实很朴素,但它几乎奠定了现代自然语言处理的底层思路:词不应该是one-hot那种“谁也不认识谁”的离散符号,而应该被训练成携带语义信息的稠密向量;语言模型也不一定非要用频次硬凑,用神经网络学出来的连续表示去估计概率,泛化反而更好。熟悉NLP发展脉络的人都知道,后续的词向量、RNN语言模型、注意力机制,乃至大规模预训练模型,追根溯源都能在NNLM这里找到影子。
这篇文章我会从“它到底在解决什么问题”出发,把NNLM的结构设计、训练细节、代码实现、工程避坑和影响脉络全部拆开讲。新入门的人可以把它当成深度学习NLP的第一块跳板,有经验的人也可以借机重新审视那些从NNLM一路继承下来的模型设计逻辑。
1. 神经网络语言模型在解决什么核心问题
1.1 语言建模的原始定义
语言模型做的事情一句话就能说清:给一段文本出现的概率打分。给定一个词序列,我们希望算出它出现的概率。根据条件概率的链式法则,一个句子的联合概率可以拆成:
P(w1, w2, ..., wT) = P(w1) × P(w2|w1) × P(w3|w1,w2) × ... × P(wT|w1...wT-1)
所以语言模型的核心就变成了:给出前面已经出现的词,预测下一个词的概率分布。这个能力几乎贯穿所有NLP任务——机器翻译要测算译文自然度,语音识别要挑更合理的一句转写,文本生成要保证生成内容连贯,搜索引擎在纠错、补全方面也都要用到它。可以说,语言模型是一个基础底座,NLP的很多问题上层都默认“我手上已经有了一个不错的LM”。
1.2 N-gram模型的问题清单
在神经网络登场之前,业界最常用的语言模型是N-gram统计模型,它做一个非常强的近似假设:当前词只依赖于前面n-1个词。然后直接从语料里统计这些固定长度词串出现的频次,再用最大似然估计计算条件概率。
N-gram用了几十年,但它有几个解决不了的老大难问题:
第一个是数据稀疏。假设词表大小是V,一个三元组(trigram)的可能组合就有V³种,语料再大也不可能把所有组合都覆盖到。没出现的n元组概率就是0,这对一个概率模型是致命的。于是后来出现了各种平滑手段:加一平滑、回退、插值,都是在给零概率打补丁,但补丁越多,模型越笨重。
第二个是维度灾难。符号化表示让每个词都独立成一个维度,V稍微做大一点(比如几万),存储和计算就开始失控。
第三个是我认为最致命的——语义鸿沟。在N-gram的世界里,“猫坐在垫子上”和“猫咪趴在毯子上”这两句话没有任何相似之处,因为它们没有一个字符重叠。模型无法把“猫”身上学到的统计知识迁移到“猫咪”上,更不可能理解“坐在”和“趴在”的行为相似性。每换一个新词,一切都得从头学起。
1.3 NNLM带来的两个关键转变
NNLM这一下的突破,恰恰是对准了上面所有的问题。
第一个转变是“分布式表示”。每个词不再是一个孤立的one-hot索引,而是映射成一个低维稠密向量。两个词是不是相关,看它们在向量空间里的距离就知道;相关的词大概率拥有相似的向量,这个状态就是词向量。词和词之间不再是“二值开关关系”,而是“可度量的相似关系”。
第二个转变是“连续空间上的泛化”。N-gram在离散的符号组合上统计,而神经网络接收的是连续的实数向量,并在这些向量上做非线性变换。哪怕某个具体的7元组从来没在训练集里出现,只要组成它的词和某个已出现的样例在向量空间里接近,网络就能给出一个还不错的概率估计。这种泛化能力,是频次统计怎么抠都抠不出来的。
所以你会发现,NNLM表面上看只是一个替换概率估计工具的尝试,但实际上它悄悄改变了NLP对“词”的底层假设。后来的Word2Vec、BERT、GPT,其实都在坚持这一条路:把语言问题变成高维向量空间里的表示学习问题。
2. 从零拆解NNLM的网络结构
2.1 输入层:从离散索引到稠密词向量
NNLM的输入不是原始文本,而是词的索引。假设词表大小是V,上下文中要利用的前n-1个词就是输入序列。每个词先根据词表查到一个唯一id,再通过一张形状为V×m的查找表(embedding矩阵)取出一行长度为m的稠密向量。
这个查表操作,本质上就是训练中的词向量。最初这张表是随机初始化的,训练会不断更新它,让每个词都在高维空间中找到自己合适的位置。
得到n-1个词各自的向量后,模型把这n-1个m维向量首尾拼接,变成一个长度(n-1)×m的长向量。这个长向量就是网络真正吃进去的特征。为什么要拼接而不是相加?因为拼接保留了每个位置上到底是哪个词的信息,顺序和位置结构不会被抹掉。这里有一个关键的工程设计:窗口n取多大。窗口太小,模型看不到足够上下文;窗口太大,输入维度和参数量线性膨胀,训练成本上升。经典论文里的实验普遍在5~10之间取,我自己的经验是取5左右做通用语言建模比较平衡。
2.2 隐藏层:把局部上下文压成一个表示
拼接好的长向量会送入一个全连接层,再过一个tanh激活函数。隐藏层的作用可以概括成“特征交互与压缩”:窗口内每个词的独立向量在这里进行非线性组合,生成一个能整体描述当前上下文状态的隐藏表示。
为什么偏偏用tanh?这不是历史偶然。tanh是零均值、幅度0到1左右饱和的平滑函数,它给神经元提供了柔和的非线性,在NNLM这种中等深度的网络里非常稳定。相比之下,ReLU在深层网络里更好用,但在这样一个浅层模型里反而容易因为线性区间太多而学不出足够复杂的特征交互。早期做NNLM复现时有人把tanh换成ReLU,效果波动不大但收敛略微变脆,我还是习惯于保留原始设计。
隐藏层的维度h也是一个关键超参数。h太小,信息压缩得太狠,模型表达能力不足;h太大,输出层的计算开销会以乘法级别攀升。对于中小词表,我一般从h=128起步,效果不够再往上加。
2.3 输出层:softmax与千千万万个候选词
隐藏层算出来的表示h_vector,要映射到词表V上得到每个词的得分。这个映射就是输出层权重矩阵U,形状V×h。每个词的得分经过softmax归一化,就得到一个合法的概率分布。训练时我们期望目标词对应的那一个维度的概率最大,本质上就是一个词表规模的分类问题。
这里有个容易被忽略的经典细节:原版NNLM在从输入拼接向量直接到输出层之间,还加了一个“直连接”矩阵W_direct,形状V×(n-1)×m。这个做法的直觉是:有些信息(比如最近几个词的字面重合)不需要经过隐藏层的非线性“翻译”,直接告诉输出层反而更清晰。直连接的存在让网络在面对低频但高度依赖局部词的场景时更可靠。代价是参数量暴涨,V×(n-1)×m这个乘法在词表大时非常夸张。所以后来很多复现版本干脆把这个结构去掉,只保留隐藏层到输出层的一路连接,效果差距在中小语料上不算大。
2.4 参数量和复杂度估算
我用一个具体例子让你直观知道NNLM的计算瓶颈在哪。假设词表V=10万,词向量维度m=50,窗口n=5,隐藏层h=100:
| 网络组件 | 参数量计算 | 参数量(约) |
|---|---|---|
| 词向量矩阵 | V × m | 500万 |
| 隐藏层 | h × ((n-1)×m) + h | 约2.5万 |
| 输出层 | V × h | 1000万 |
| 直连接(可选) | V × (n-1) × m | 2500万 |
看到没有,模型的大头全在输出层和直连接这两个和词表大小直接相关的矩阵上。这也解释了为什么后来的大规模语言模型几乎都会想尽办法绕开巨大softmax:要么用负采样丢掉大部分负例,要么用分层softmax把大softmax拆成树结构,本质都是在资源受限时避开这堵由词表高度决定的墙。
3. 训练目标、数据构造与优化细节
3.1 目标函数:对数似然到底在最大化什么
NNLM的训练目标不是“预测对错”,而是最大化训练语料的整体概率。把全部样本的联合概率取对数并平均,就得到目标函数;用梯度下降最大化它,等价于最小化交叉熵损失。
从直觉上理解:每一条样本任务都是“给你前面几个词,你预测下一个词”。如果模型给真正下一个词分配的概率高,整体似然就大,损失就低。这一个目标函数同时牵引了两件事——词向量矩阵和网络参数都得往“更有利于预测下一个词”的方向更新。这就是为什么NNLM能一举两得地学出词向量,又学出语言模型。
实际计算时,PyTorch这类框架通常会直接算交叉熵损失。logits张量形状(batch, vocab)和标签张量形状(batch)输入cross_entropy损失函数即可,内部已经把softmax和负对数似然合并了。
3.2 训练样本怎么切
训练数据的构造方法是:从语料第一个词开始,用一个长度为n的窗口滑动。窗口里前n-1个词是输入特征,第n个词是预测目标。滑完整个语料,理论上能得到的样本数约为“语料总词数−n+1”条。
两个实际经验值得单独说。一是在句子边界要处理得当。我个人习惯在每句开头加一个专属句子开始标记、结尾加句子结束标记。这样模型能从标记里学到“句子该何时开始、何时结束”的分布,生成时也方便。如果不想区分句子边界,至少要用拼接后的大文本流做切窗,否则句首词总是缺少足够前文,这些位置的预测就成了瞎猜。
二是实际训练时不会真的把每条样本单独喂进去,而是把小窗口重叠的样本拼成batch输入。比如把整个语料按固定batch大小切块,每个块内连续文本整体载入,让窗口在矩阵计算中大量重叠复用。这个设计牺牲一点严格独立性的干净度,但能换回高得多的训练吞吐,我建议在复现时采用。
3.3 初始化、学习率与正则化经验
这部分是实际训练时最容易踩坑的。
词向量初始化我建议用范围[-0.05, 0.05]的均匀分布。不要用太大范围,否则最初几轮梯度在softmax里被极端分数带偏,模型很难平滑收敛;太小又会让初始表示高度雷同,优化缓慢。隐藏层和输出层用默认的均匀初始化即可。
优化器方面,用Adam配学习率1e-3在多数词表5万以下的中小任务上都稳;词表接近几十万规模时,我通常把学习率降到3e-4~5e-4再微调。使用SGD的话,初学率可以放宽到0.05甚至0.1,但需要配合learning rate decay,收敛过程会更颠簸、更依赖手感和经验。
正则化上,embedding输出加dropout 0.2~0.3是我觉得性价比最高的防过拟合手段。隐藏层输出也可以加,但要注意与梯度裁剪配合。梯度裁剪在这种自带百万级输出层的模型里不是可选项,而是必需品。词表一大,输出层的梯度过几轮累积就容易冲到极大值,出现的典型症状就是loss突然变NaN。clip_grad_norm_设成1.0或5.0,能在大部分情况下直接救回训练过程。
4. 手把手实现一个极简NNLM
4.1 数据准备与预处理
实现最好的学习方式是完整跑通一遍。以下代码用PyTorch实现,不依赖任何大型数据集,用几行自造语料就能验证原理。
import torch import torch.nn as nn import torch.optim as optim from torch.nn.utils import clip_grad_norm_ # 一份微型语料,足够演示原理 texts = [ "the cat sat on the mat", "the dog sat on the floor", "cats like to sleep", "dogs like to run", "the cat likes fish", "the dog likes bones", "birds fly in the sky", "fish swim in the river", ] # 切词 tokens = [] for t in texts: tokens.extend(["<bos>"] + t.split() + ["<eos>"]) # 建词表 vocab = list(set(tokens)) + ["<unk>"] vocab_size = len(vocab) word2idx = {w: i for i, w in enumerate(vocab)} idx2word = {i: w for w, i in word2idx.items()} print("词表大小:", vocab_size, "词表:", vocab)这里我加入了句子边界标记,语料里所有真实词都能覆盖到对应id。等之后需要处理未知词时,直接把词典外词映射到索引0位置对应的unk标记即可。
4.2 模型定义的PyTorch实现
模型结构我用最简单的版本:embedding → 拼接 → tanh隐藏层 → 输出层。直连接可有可无,为了可读性先省略,后面可以自己加上试试,直观感受参数量和效果的变化。
class NNLM(nn.Module): def __init__(self, vocab_size, embed_dim, hidden_dim, context_len): super().__init__() self.context_len = context_len self.embed_dim = embed_dim self.embed = nn.Embedding(vocab_size, embed_dim) # 输入维度是 context_len 个向量拼接后的长度 self.hidden = nn.Linear(context_len * embed_dim, hidden_dim) self.tanh = nn.Tanh() self.out = nn.Linear(hidden_dim, vocab_size) self.init_weights() def init_weights(self): # 词向量用较小的均匀分布初始化 nn.init.uniform_(self.embed.weight, -0.05, 0.05) nn.init.xavier_uniform_(self.hidden.weight) nn.init.xavier_uniform_(self.out.weight) def forward(self, inputs): # inputs shape: (batch, context_len) emb = self.embed(inputs) # (batch, context_len, embed_dim) emb = emb.view(inputs.size(0), -1) # (batch, context_len * embed_dim) h = self.tanh(self.hidden(emb)) # (batch, hidden_dim) logits = self.out(h) # (batch, vocab_size) return logits这段代码可以和网络结构描述逐一对应:Embedding层就是那张查找表,view展开完成拼接,hidden层做非线性特征交互,out层把表示映射成词表得分。
4.3 训练循环与困惑度评估
构造训练样本时,我用一个固定长度的滑动窗口,在每个位置取连续context_len个词作为输入,序列的下一个词作为标签。简单、直接,每个合法位置都贡献一条样本。
context_len = 3 # 用前3个词预测下一个词 X, Y = [], [] for i in range(len(tokens) - context_len - 1): context = tokens[i:i + context_len] target = tokens[i + context_len] if context[0] != "<eos>": # 跳过跨越eos的窗口,保持句内时序 X.append([word2idx.get(w, 0) for w in context]) Y.append(word2idx.get(target, 0)) X = torch.tensor(X, dtype=torch.long) Y = torch.tensor(Y, dtype=torch.long) model = NNLM(vocab_size, embed_dim=16, hidden_dim=32, context_len=context_len) optimizer = optim.Adam(model.parameters(), lr=1e-3) loss_fn = nn.CrossEntropyLoss() model.train() for epoch in range(200): optimizer.zero_grad() logits = model(X) loss = loss_fn(logits, Y) loss.backward() clip_grad_norm_(model.parameters(), 1.0) optimizer.step() if (epoch + 1) % 40 == 0: perplexity = torch.exp(loss).item() print(f"epoch {epoch+1:>3d} loss={loss.item():.3f} perplexity={perplexity:.2f}")小语料因为样本量少、窗口短,几个epoch后loss就能明显下降。这里有三个点新手容易迷惑:一是CrossEntropyLoss内部已经做了softmax,所以模型输出层不要再额外套softmax;二是backward之后必须clip一遍梯度,再step更新;三是perplexity等于损失函数e的幂次,它表示模型预测下个词时的平均可选候选数,越小说明越有把握。
训练结束后,把embedding权重拿出来做一次PCA降维可视化,你会看到“cat”、“dog”这类同类词聚在一起,“run”、“sleep”这类动作词聚在另一侧,这就是分布式表示带来的语义聚类效果,非常直观。
5. 从NNLM继承与超越:影响范围
5.1 被后人放大的副产品:词向量
NNLM最初的目标是语言模型,词向量只是训练过程中顺手产生的“中间变量”。但后来很多人发现,这个“中间变量”的语义表示能力实在太惊艳了:同义词距离近、类比关系成立,比如“king − man + woman ≈ queen”这样的情况可以通过向量加减体现出来。于是后续研究者干脆把语言模型的目标抛到一边,只专心训练词向量——CBOW和Skip-gram就是在这样的思路上发展起来的。它们把NNLM中复杂的隐藏层和输出层简化掉,只保留一张embedding表和一个轻量预测目标,训练速度和可扩展性大幅提升,让词向量真正成为NLP基础设施。
5.2 从固定窗口走向循环与注意力
NNLM用了固定窗口,模型永远只看得到最近的n-1个词,远距离依赖问题无法避免。某段上文在第20个词位置提供了关键信息,窗口只有5时直接丢了。RNNLM的登场正是为了解决这个痛点:用循环结构把整个前文编码进一个隐状态,理论上可以携带无限长的历史信息。再往后的LSTM、GRU进一步解决了长程梯度消失问题。
再之后注意力机制出现,Transformer直接把“所有位置两两交互”做到位,远程依赖不再是靠记忆硬扛,而是靠显式关联。这一路演进各自选择的网络架构都不同,但所有模型都没有推翻NNLM的底层范式——把词映射成向量,在连续空间里更新表示,最后在词表上估计分布。变的是上下文承接方式和信息压缩的架构,不变的是NNLM定义的基本学习框架。
5.3 站在NNLM肩膀上的现代语言模型
我经常建议学NLP的人往回看,就是为了看清所谓大模型其实并没有跳出NNLM的范畴。双向预训练模型做的完形填空,本质上就是给定左右两边词、预测中间缺失词,训练目标和NNLM条件化概率估计异曲同工;自回归式生成模型做的是给定前面所有词、预测下一个词,这就是NNLM目标函数的直接放大版,只是换成了更深层的Transformer、加入了海量语料和超大算力。
甚至连效率技巧都有源头。现代大规模模型在输出层用的自适应softmax、词表权重共享、逐批次负采样,这些工程黑科技最早都是为了解决NNLM输出层体积过大这个瓶颈而被发明出来的。理解了NNLM,再去看大模型里五花八门的优化技巧,很多都能对号入座。
6. 常见问题与排查技巧实录
我把做NNLM训练与复现过程中最典型的问题整理成一张速查表,这些都是踩过一遍后才能闭眼说出来的经验。
| 问题现象 | 常见原因 | 排查与解决 |
|---|---|---|
| loss一直不降 | 学习率过大/过小、特征窗口太小、未归一化文本 | 先检查loss数值量级,适当降低或上调学习率;把窗口从3提高到5~8;确认文本中不存在没有分离的乱码词 |
| loss突然变成NaN | 梯度爆炸,多发生在输出层 | 训练循环里加梯度裁剪,建议clip值为1.0;同时降低学习率到1e-4观察恢复 |
| 训练集loss很低、验证集一塌糊涂 | 过拟合,语料太少或模型无用正则 | 增大dropout到0.3以上、加上早停、合理扩充语料 |
| 输出层计算慢得不可接受 | 词表V太大,每次全词表softmax成本过高 | 训练前可改用负采样或候选采样;推断时可以先取top-k再做softmax;或者直接使用分层softmax |
| 训练样本里OOV(未知词)过多 | 词表切分与预处理不一致,或语料太小 | 统一预处理管线,全部未知词映射到同一个unk标记;不要为每个乱码单独赋予id |
| 困惑度很低但下游任务反而效果一般 | 困惑度只衡量预测分布质量,与下游任务目标不完全一致 | 在真实下游任务(分类、生成)上直接验证模型能力,不要只看ppl |
这里挑一条最常见的工程经验再展开:文本预处理会极大影响最终效果,但又是最容易被忽视的部分。我在一个略大的中文语料上只做了一件事——把所有标点统一成半角、全角数字统一成阿拉伯数字或直接归一化,模型困惑度下降了一截。NNLM非常敏感于输入空间的稳定性,同一个语义如果被切成了四五种不同的词形,它就得白白浪费大量参数量去学习“这些词其实出现在相同位置”。把词表做大到几十万还不管控词形变化,通常不是聪明选择,宁可用更规范的词表加unk兜底。
关于困惑度还有一个实操判断经验:窗口越短,ppl通常越低,因为预测任务变简单了。所以跨模型对比ppl时,一定要固定窗口长度、词表和预处理方式,否则对比结果没有意义。同一套语料下,token级别的困惑度在50以下已经说明模型学得像模像样,200以上意味着基本还在瞎猜阶段。但ppl从不等于“模型好不好用”,翻译质量和生成连贯性才是最终裁判。
最后分享一个训练中的小技巧。想观察词向量是否真在学法,不用等到训练全部结束,训练到第40~60轮就取一次embedding做降维可视化。你会看到那些语义相近、共现模式相似的高频词先聚在一起,这是模型正在成功编码分布特征的信号。如果训了几十轮后可视化结果还是一团乱麻、看不出任何聚类,多半是学习率没调好或者预处理有脏数据,别让训练继续硬跑,回头检查数据管线更划算。这种“中途验尸”的习惯,我一直觉得比跑完再回头排错要省时间得多。