
如果你一听到语言模型就想到那些动辄几百亿参数的大模型我劝你先停一下。神经网络语言模型NNLM这个名字看起来像老古董实际上它才是撬动整个自然语言处理变革的那根杠杆。我最早接触它时也是一头雾水觉得教材里的公式又冷又硬后来自己在小数据集上从零实现了一遍才真正体会到什么叫原来这里是这么回事。这篇文章会从语言模型到底在计算什么讲起一直讲到NNLM的结构拆解、训练细节、我在实操中踩过的坑以及它和后来那些词向量、预训练模型之间的血缘关系。不管你是刚入门深度学习的学生还是想复习基础的从业者把这一套吃透之后再去看Transformer、看GPT你会发现很多东西都是老熟人换了新衣服。1. 语言模型到底在算什么一个概率问题而已很多人第一次接触语言模型这四个字脑子里浮现的都是写诗、写代码、聊天其实那只是语言模型的应用外壳。语言模型内部做的事情抽象到极致就一句话计算一段文本出现的概率。1.1 从链式法则说起假如我们有一个句子我今天想喝咖啡语言模型要算的就是这个句子作为一个整体出现的概率 P(我, 今天, 想, 喝, 咖啡)。直接算一整句话的概率是不可能的句子组合太多了但我们可以用链式法则把它拆开P(我, 今天, 想, 喝, 咖啡) P(我) × P(今天|我) × P(想|我,今天) × P(喝|我,今天,想) × P(咖啡|我,今天,想,喝)翻译成人话就是我们要一个个词地预测给定前面已经出现的词下一个词最可能是什么。所以语言模型的核心变成了一个条件概率问题给定历史上下文 w₁, w₂, ..., w₍ₜ₋₁₎计算下一个词 wₜ 出现的概率 P(wₜ | w₁, w₂, ..., w₍ₜ₋₁₎)。这个逻辑其实特别像我们人类的阅读习惯。你读到我今天想喝____大脑会自动开始猜测后面是什么。咖啡、茶、水、牛奶都可以但想喝桌子就不对劲。语言模型就是把这个猜测过程变成数学上的概率分布。1.2 困惑度模型的自检尺子模型做得好不好不能靠感觉。语言模型领域有一个专用的评价指标叫困惑度Perplexity简称PPL。它和交叉熵直接相关公式是PPL exp(平均负对数似然)我不打算在这里堆公式。你只要知道一个直观含义就行困惑度近似等于模型在每一步预测下一个词时平均面临的候选词数量。如果困惑度是50就意味着模型预测下一个词时相当于在50个候选词里做选择。困惑度越低说明模型对文本的预测越自信、越准确。我在训练模型时经常盯着训练集和验证集的困惑度曲线看。训练集困惑度一直降但验证集降不动这就说明模型开始死记硬背训练数据了得停手或者调正则化。这个指标一直到今天的预训练模型时代仍然是各家公司衡量语言模型经典能力的重要参考。2. N-gram的痛统计派撞上了数据稀疏的墙在神经网络语言模型出现之前统治语言模型领域的是N-gram方法。理解N-gram的局限才能真正理解NNLM的价值在哪里。不然你只是学会了一个模型结构不知道眼前这堵墙长什么样。2.1 N-gram的统计本质N-gram的做法非常朴素假设第t个词只依赖于前面n-1个词而不是整段历史。比如Bi-gram二元模型就是看前一个词Tri-gram三元模型就是看前两个词。然后在大规模语料里数次数P(wₜ | wₜ₋₂, wₜ₋₁) Count(wₜ₋₂, wₜ₋₁, wₜ) / Count(wₜ₋₂, wₜ₋₁)翻译过来就是在一堆文本里数我今天想出现过多少次再数我今天想喝出现过多少次两者一除就算出下一个词是喝的概率。这个方法听起来很合理但实际操作起来问题很大。最核心的就是数据稀疏N越大需要统计的词组合数就爆炸式增长。假设词表大小是VN-gram需要存储V的N次方量级的组合计数。甚至不需要多V是5万的话Tri-gram的组合数量理论上有1250亿种可能绝大多数组合在语料里出现的次数是0。我当时在一份几百万句的中文语料上统计Tri-gram跑出来的结果很尴尬大概有百分之六七十的三元组只出现过一次还有大量三元组从来没出现过但模型照样要给它一个概率否则整句话的概率就变成0了。2.2 平滑只是创可贴不是解药统计派为了解决零概率问题发明了一系列平滑方法比如加1平滑、Katz回退、Kneser-Ney平滑。思路说白了就是把高频组合的概率匀出一部分分给那些没见过的组合让模型不至于一票否决。平滑确实管用但本质上是治标不治本。它只能把概率质量重新分配不能让模型真正理解词和词之间的相似性。比如语料里出现过我今天想喝茶没出现过我今天想喝咖啡N-gram模型对咖啡的概率估计完全不受茶的影响因为两个词串在它眼里是没有任何关联的不同符号。这个问题说白了就是符号的离散性问题。每个词是一个独立的IDID和ID之间没有距离、没有相似度猫和狗的关系与猫和卡车的关系在数学上完全一样都是两个毫不相干的符号。语言里最宝贵的东西——词语之间的语义相似性、语法位置相似性——在N-gram里被彻底丢弃了。3. NNLM的破局思路让词语学会用向量说话神经网络语言模型的革命性突破不在于它用了神经网络用神经网络拟合N-gram也没意义而在于它提出了一种全新的词表示方式分布式表示Distributed Representation。这个思路直接改变了NLP的底层逻辑。3.1 核心洞察为什么词要变成向量N-gram把每个词当作独立的原子符号而NNLM的想法则截然不同先把每个词映射成一个低维稠密向量比如128维让向量里的每一个维度都可以参与表达这个词的某个特征。也就是说猫和狗的向量在很多维度上会非常接近而猫和卡车的向量距离就会很远。这个想法的来源其实很朴素读完一个句子我们判断我今天想喝____后面是什么词时靠的是一种类比联想。看到茶我们联想咖啡、牛奶、果汁因为它们在语义上是一类。如果模型能把茶的表示平滑地泛化到咖啡的表示上那即使语料里没见过喝咖啡模型也能给出一个合理的概率。这就是神经网络语言模型泛化能力的根源。当时提出这一思路的研究者把话挑明过只要词表里每个词对应一个连续向量语言模型就能在训练中自动把相似语境的词映射到相邻的向量空间。这个结论后来被反复验证也成了Word2Vec一类词向量方法的思想源头。3.2 结构总览从one-hot到softmax的四步走NNLM的完整前向传播流程可以拆成四个清晰的阶段查表得到词向量输入是一串词的one-hot编码哪个词是1其余全是0通过一个Embedding矩阵查出每个词对应的稠密向量。这一步本质上是查表不是计算。拼接上下文向量把前n-1个词对应的向量首尾拼接变成一个更长的向量作为整个上下文的一个完整表示。非线性变换拼接向量经过一个全连接层激活函数用tanh把信息压到隐藏层。这里的关键词是非线性。如果没有这一步多层线性变换等价于一层模型根本学不到复杂规律。softmax打分隐藏层再经过一个输出层得到词表大小比如5万维的分数向量用softmax归一化输出每个词作为下一个词的概率分布。我之前画了无数遍这个结构图才真正记住。Embedding矩阵的形状是词表大小×向量维度输出层的权重形状是词表大小×隐藏层维度。这也就意味着模型的绝大多数参数都集中在这两个大矩阵上。3.3 Embedding背后的直觉Embedding这一层最值得细细品。它看起来只是一个矩阵乘法其实干了两件事。第一件事是降维。one-hot向量是5万维的其中只有一个是1其余全是0信息密度极低。Embedding矩阵把5万维的稀疏向量投影到128维的稠密空间信息被压缩了数百倍但保留了足够的表达空间。第二件事是语义编码。Embedding矩阵的行向量就是每个词的可学习表示训练过程中梯度回传会不断调整这些向量让出现在相似上下文里的词在向量空间中慢慢靠近。训练结束后你可以拿这些向量做词语相似度计算会发现自己训练的模型也有国王 - 男人 女人 ≈ 女王这种规律虽然它训练目标只是预测下一个词从来没被告诉过任何语义知识。这种从预测任务中自发涌现语义的特性是整个分布式表示思想最有魅力的一面。4. 训练一个真正的NNLM损失函数、梯度回传与代码实现讲完结构该说怎么训练了。说实话结构看得再明白不亲自动手写一遍训练流程你永远不知道梯度回传经过softmax、Embedding时是什么感觉。4.1 损失函数和标准答案比差距NNLM的训练本质上是一个多分类问题。给定前n-1个词模型输出一个词表大小的概率分布而训练样本给出的标准答案是某个确定的下一个词。我们用的损失函数是交叉熵损失。交叉熵的含义可以这样理解模型的预测分布与真实分布标准答案那个词的one-hot分布之间到底差了多少。模型给正确答案打的概率越高损失就越小把高概率打给了错误词损失就大。举个例子语料里有我今天想喝咖啡模型输入我今天想喝标准答案是咖啡。如果模型输出咖啡的概率是0.7损失是 -ln(0.7) ≈ 0.357如果模型输出咖啡的概率只有0.0001损失就是 -ln(0.0001) ≈ 9.21。这个巨大的反差就是梯度信号迫使模型调整参数把尽可能多的概率质量分配给正确词。4.2 训练循环里藏着魔鬼在真正跑训练之前有几个细节是教科书不写但实际特别重要的窗口大小n的选择很微妙。n太小上下文信息不够模型学得像傻子n太大训练样本变少输入维度变高计算成本直线上升。我当时在中文数据上用窗口大小5左右做了平衡。学习率不能一条路走到黑。我习惯用初始学习率0.1到0.3用SGD或者0.001到0.003用Adam然后在训练过程中按epoch衰减。NNLM不算是特别深的网络但softmax层大梯度更新不够稳学习率稍微大一点就出现损失震荡。参数初始化非常影响收敛速度。Embedding层我当时用均值为0、标准差0.1的正态分布初始化隐藏层权重用小一点的随机初始化避免tanh饱和区导致梯度消失。4.3 可直接复现的PyTorch代码骨架下面这个代码是我自己的精简版可以直接复现一个最小可用的NNLM。数据集很朴素就是一组小句子适合跑通整个流程。import torch import torch.nn as nn import torch.optim as optim import math torch.manual_seed(42) # 1. 准备一个微型语料 corpus [ 我 今天 想 喝 咖啡, 我 今天 想 喝 茶, 我 昨天 想 喝 牛奶, 他 今天 想 吃 苹果, 她 明天 想 吃 蛋糕, ] # 2. 构建词表 word2idx {} for line in corpus: tokens line.split() for w in tokens: if w not in word2idx: word2idx[w] len(word2idx) idx2word {i: w for w, i in word2idx.items()} V len(word2idx) # 词表大小 n 3 # 用前 n-12 个词预测下一个词 embed_size 32 # 词向量维度 hidden_size 64 # 隐藏层维度 lr 0.1 epochs 100 # 3. 构造训练样本滑动窗口 samples [] for line in corpus: tokens line.split() ids [word2idx[w] for w in tokens] for t in range(n, len(ids)): context ids[t-n:t] target ids[t] samples.append((context, target)) # 4. 定义NNLM模型 class NNLM(nn.Module): def __init__(self, vocab_size, embed_size, hidden_size, n): super().__init__() self.embed nn.Embedding(vocab_size, embed_size) self.hidden nn.Linear((n-1) * embed_size, hidden_size) self.tanh nn.Tanh() self.output nn.Linear(hidden_size, vocab_size) def forward(self, context): # context: [batch, n-1] 的词索引 emb self.embed(context) # [batch, n-1, embed_size] emb emb.view(emb.size(0), -1) # [batch, (n-1)*embed_size] h self.tanh(self.hidden(emb)) # [batch, hidden_size] logits self.output(h) # [batch, vocab_size] return logits model NNLM(V, embed_size, hidden_size, n) criterion nn.CrossEntropyLoss() optimizer optim.SGD(model.parameters(), lrlr) # 5. 训练 for epoch in range(epochs): total_loss 0.0 for ctx, tgt in samples: ctx torch.tensor([ctx]) tgt torch.tensor([tgt]) optimizer.zero_grad() logits model(ctx) loss criterion(logits, tgt) loss.backward() optimizer.step() total_loss loss.item() if (epoch 1) % 20 0: ppl math.exp(total_loss / len(samples)) print(fepoch {epoch1}, loss{total_loss/len(samples):.4f}, ppl{ppl:.2f}) # 6. 推理测试 def predict_next(prefix): ids [word2idx[w] for w in prefix.split()] ctx torch.tensor([ids]) logits model(ctx) probs torch.softmax(logits, dim-1)[0] top_idx torch.argsort(probs, descendingTrue)[:3] return [(idx2word[i.item()], probs[i].item()) for i in top_idx] print(predict_next(我 今天 想))这个小代码跑下来你会看到困惑度从几十降到个位数模型最后能分清喝后面更可能是饮料类名词吃后面更可能是食物类名词。这个经验很重要因为它证明了更泛化的结论神经网络语言模型不需要显式规则就能从数据里学出语义类别。5. 实操复盘训练NNLM时我踩过的那些坑训练一个简单NNLM看起来容易但实际运行起来各种问题该来的都会来。这里把我踩过的坑按踩中的时间顺序整理一份供你复现时对照排查。5.1 第一个坑词向量维度拍脑袋选了300我一开始听到Embedding维度一般用几百维就直接上了300。结果在小语料上训练损失下降慢得像乌龟爬而且验证集困惑度比用64维还高。原因很简单词表总共才几十个词用300维向量去表示它们参数比训练样本还多模型根本泛化不了。后来我总结了一个经验维度选择和词表大小、语料规模要匹配。小语料用32到64维中等规模语料用100到200维真正大语料才需要300维以上大表示空间。这个规律在后续做词向量任务时也同样适用。5.2 第二个坑没做低频词截断模型被生僻词带偏当我换到一份稍微大一点的语料时词表膨胀到几十万大量词只出现一两次。NNLM在更新这些低频词的Embedding时梯度信号非常稀薄训练过程被这些僵尸词拖得又慢又抖。解决方式是做一个最小频次截断出现次数小于阈值的词统一映射成一个特殊的unk符号表示未知词。我当时的经验是在几百MB的中文语料上把频次阈值定在3到5左右既能覆盖绝大多数常用词又不会让词表臃肿到难以处理。这个预处理后来成了我做所有NLP任务的默认第一步。5.3 第三个坑softmax全部按batch并行显存直接爆连续处理小句子没有感觉一旦把语料放大每个训练批次里每个样本的softmax输出层分量都包含整个词表这个大矩阵在batch一大时显存占用非常恐怖。我的做法是控制batch size同时在处理超大词表时改用负采样或层次softmax的思路。可以简化理解与其每次计算五万个词的归一化不如只拿一个正样本和几十个随机负样本算损失大幅降低计算量。虽然严格来说这变成了近似训练但在工程上往往值得。5.4 第四个坑损失死活不降最后发现是数据问题最让人崩溃的不是模型难调而是损失函数完全不往下走。我排查了好几天最后发现是语料里有一批空行和繁简混杂的文本预处理阶段没清洗干净导致大量无意义样本在干扰训练。从那以后我很重视一条工序数据清洗先于参数调优。在做任何语言模型实验前检查数据的分词一致性、编码格式、特殊符号残留这些细节看起来事小但影响巨大。很多新手遇到模型不收敛的问题八成不是网络结构的问题而是没过数据清洗这一关。6. 从NNLM到今天的预训练时代它到底留下了什么最后想聊一个宏观问题NNLM这个2003年前后提出的模型结构放到今天已经被Transformer远远甩开但为什么我们还是应该认真地把它学透因为它留下的不是结构而是一套至今未变的思想范式。6.1 三个至今沿用的核心遗产第一是词嵌入Embedding的范式。这是NNLM最重要的贡献它把NLP从符号时代推进到了向量时代。你今天看到的所有预训练模型无论多宏大第一步都还是把文本转成向量表示原始设计思路从NNLM一脉相承。第二是语言模型的任务形式。预测下一个词这个目标非常朴素但恰恰因为朴素才能从无限规模的文本中自动获取监督信号。后来的大模型训练依然大量使用这种自监督思路就是看它能不能从预测下一个词里学到知识。这个想法简单力量却大得惊人。第三是用模型在同一任务中同时完成表征学习和任务学习。Embedding在学习过程中不断被优化同时模型的预测能力也在提升两者不是分开两步走而是端到端一起完成的。这种端到端的思想后来被发扬光大成了深度学习的主流做法。6.2 NNLM的短板如何被一步一步补上NNLM本身有两个明显的软肋一是窗口长度有限上下文永远只看前面几个词二是softmax在大词表上计算开销太大。后来者的改进路线几乎是照着这两条走的。先把固定窗口换成循环结构让历史信息可以在隐层中传递接着发现Embedding本身有独立价值于是单独训练词向量并放出开源工具让整个领域人人可用再后来靠大规模预训练加微调方式把语言模型变成了解决各种任务的通用底座再到今天用自注意力机制直接让任意两个词之间都能建立远距离依赖彻底摆脱了窗口限制。每一步的底层逻辑都离不开把词变成向量从预测任务中学习规律这个最初的思想基石。所以如果你想理解现在这些大型语言模型的本质花一个下午把NNLM从零实现一遍可能是性价比极高的一条路径。我自己在这条路上走的体会是学模型结构不只是为了写代码而是为了建立一种看到任何网络结构能自己拆解输入、参数、梯度、瓶颈在哪的本事。NNLM虽然简单但五脏俱全是一个非常好的训练场。当你亲手完成一次从语料预处理到损失下降再到生成合理预测的完整流程那种原来AI是这样理解语言的体验是看任何教程都替代不了的。