从N-gram到Transformer:语言模型原理与工程实现全解析 想弄明白NLP里那些花哨的模型最扎实的第一步还是老老实实回到“语言模型”这个地基。第4课放在这个位置很合理前面讲了分词、词向量这些原料后面又要讲机器翻译、文本分类、生成式对话语言模型刚好是承接原料和任务的枢纽。我这些年接触过的NLP项目不管最后用的是多复杂的框架解释清楚问题时绕来绕去最后总要回到“模型到底怎么评估下一词的概率”这件事上。所以这篇文章我把自己从统计n-gram到RNN、再到简单Transformer的完整学习路线整理出来讲清楚语言模型在解决什么问题、怎么评估、怎么动手实现一个能跑的版本以及那些教科书里不太会写的坑。如果你正在做文本生成、机器翻译、语音识别或者搜索联想看完应该能直接照搬我的实现套路。即使你只是对NLP感兴趣还没写过一行代码语言模型这个视角也会让你之后看任何模型时都轻松很多。1. 语言模型到底解决什么问题1.1 一个最朴素的定义猜下一个词和算句子概率语言模型本质上是一个概率系统它给任意一串词分配一个“像不像话”的概率。比如 P(我 爱 吃 苹果) 会比 P(苹果 吃 我 爱) 高得多。有了这个打分能力它就能做很多事。最直观的应用是手机输入法。你在聊天框里敲“明天我们一起去”输入法会给出“吃饭”“上课”“跑步”这些候选词其实它们就是语言模型根据前面六个字对整个词表做了一次概率排序。类似地搜索引擎的搜索提示、语音识别软件把声音转成文字背后都是同一套逻辑在给定上下文的情况下预测下一个词是谁或者给候选句子打分。为什么这个看似简单的“猜下一个词”能撑起整个NLP领域因为几乎所有生成任务都可以被拆成一步步地预测一个词。写摘要是一个词一个词地往外蹦做机器翻译是看到完整源语言句子后再一个词一个词地生成目标语言聊天机器人也是类似的流程。判别任务比如情感分析、文本分类虽然不需要生成内容但很多模型也先用“预测下一个词”的方式在大规模语料上做预训练然后再在分类任务上精调。语言模型对自己能力的定义很形式化给定一个词序列 w1, w2, …, wT我们希望估计整个序列的联合概率 P(w1, w2, ..., wT)。这个概率可以展开成条件概率的乘积也就是所谓的链式法则P(w1, w2, …, wT) P(w1) × P(w2 | w1) × P(w3 | w1, w2) × … × P(wT | w1, …, wT-1)这条公式看起来只是数学恒等式但它是所有语言模型工作的核心框架。模型只需要学会一件事在给定前文的情况下下一个词的概率分布长什么样。整个句子的概率就是这些条件概率相乘的结果。1.2 无孔不入的存在它在NLP四大类任务中的影子有人会问文本分类也要用语言模型吗直接拿词向量加一个分类器不就行了确实简单分类任务可以不用显式的语言模型但顶级的预训练模型本质上都带语言模型的目标。比如BERT不是典型的自回归语言模型它用的是掩码语言模型盖住句子中间一个词让模型用左右两侧的词把它猜出来。这个思路和“预测下一个词”同宗同源。模型在那一步学会的是词语之间的统计关系而不是单纯记下词向量。可以说NLP里几乎没有一个角落能完全绕开语言模型的统计约束。语音识别里声学模型把音素变成候选汉字候选往往有好几个最终选哪个由语言模型决定它负责说出“我们一起吃饭”比“我们一起吃板”更符合常理。机器翻译里的beam search每个候选句子的得分也不是只看翻译模型语言模型还要评估它作为一段目标语言文本是否通顺。搜索引擎里给长Query打分、在错别字纠错时给出更可能的替代词都在用语言模型做排序。理解这一点再看后面的各种模型就会清晰很多。真正落地一个NLP系统通常结合了两种能力一种是理解输入的内容另一种是判断输出到底像不像人话。后一种能力基本就是语言模型的专场。1.3 技术路线演进从统计计数到大规模预训练不去回顾历史很难理解为什么今天大家开口闭口都是“大模型”。语言模型的演进路线本质上是沿着两条线索走的表达能力越来越强计算和存储代价越来越可控。最早的n-gram模型是数数的时代。它统计“在‘我’出现后‘爱’出现的次数除以‘我’出现的次数”来估计条件概率。做法朴素效果也朴素遇到没见过的组合就束手无策需要各种平滑技巧来兜底。上世纪九十年代的语音识别系统里普遍用的是三元模型已经算得上工程能力很强了。1990年代后期神经网络语言模型被提出来不再统计原始计数而是把词映射到低维向量用神经网络拟合 P(wt | wt-1, ..., wt-k)。这个思路从根本上缓解了数据稀疏问题因为相似的词在向量空间里挨得近模型可以泛化到没见过的组合。2017年Transformer出现后注意力机制取代了循环结构模型能够直接建模很长的上下文而且可以并行训练。从GPT到BERT再到后来的规模化预训练语言模型从“某个任务里的一个模块”变成了“几乎所有NLP任务的基础底座”。体现在工程上就是你不必再为一个任务单独训练语言模型拿预训练好的模型去改一改就够了。但不管技术怎么变核心的定义没变给一串词打分预测下一个词。理解这条主线后面学什么模型都只是换一种方式去拟合这个目标而已。2. 数学工具和调试指标学语言模型前必须先搞懂这些2.1 链式法则、条件概率和独立假设的演化直接按照链式法则去估计完整条件概率不现实因为上下文无限长数据永远不够。所以早期模型会做一个近似假设当前词只依赖前面固定数量的 n-1 个词。这就是n-gram模型。二元模型bigram假设当前词只依赖上一个词P(wt | w1, ..., wt-1) ≈ P(wt | wt-1)三元模型trigram依赖前两个词。这个假设很粗暴但工程上有效。它把原本指数级的参数空间压到 O(V^n)V是词表大小n是阶数。词表一万个词时二元模型要存储的参数大约是一亿个计数已经需要很多内存了。所以早期系统常用三元再用一些技巧减少存储。我再给你一个可操作的例子。假设语料中有这三句话我 爱 吃 苹果我 爱 看 电影你 爱 吃 香蕉要估计 P(吃 | 我 爱)我们数一下“我 爱”后面分别跟了什么。前两个句子都是“我 爱”开头后面一个跟“吃”一个跟“看”所以 P(吃 | 我爱)1/2P(看 | 我爱)1/2。如果语料里出现“我爱跑步”概率就会被摊到三份上。这就是最原始的统计语言模型。写代码时通常会给句子加上句首BOS和句尾EOS标记。这样语言模型能知道“句子什么时候结束”也能处理句首没有上下文的情况。处理之后BOS 我 爱 吃 苹果 EOS于是产生了特殊条件概率 P(我 | BOS)P(EOS | 我 爱 吃 苹果)。这个细节看起来不起眼实际影响很大没有EOS模型永远不会给句子做“结束”的决策。2.2 困惑度Perplexity到底在说什么模型训练完了怎么评价好坏语言模型最常用的评估指标是困惑度PerplexityPPL。公式很简洁PPL exp( -(1/N) · Σ log P(wt | context) )N是测试集里的词数量。从直觉上理解PPL是在问模型每次预测下一个词时平均有几个候选词让它拿不准。如果PPL1说明模型对下一词已经确定非常自信PPL100相当于模型在每个位置都面对一百个候选词基本靠猜。举个小例子。假设测试集只有一句话“我爱吃苹果”路径概率是 0.2×0.5×0.40.04N3。计算平均对数似然(log0.2 log0.5 log0.4)/3 (-1.609 -0.693 -0.916)/3 ≈ -1.073。取指数后 PPL≈e^1.073≈2.92。这个2.92可以解读为模型平均在面对约3个候选词时做选择对一句话来说已经是相当确定的了。我在实际项目中不会只盯PPL一个数。PPL低说明模型在统计层面拟合得好但不保证生成效果一定惊艳。而且PPL是相对指标不同词表、不同分词方法算出来的数没有绝对的可比性。同一个语料你用字级别分词和词级别分词PPL数值会差很多。所以比较模型时要保证测试集和分词方式完全一致PPL才有意义。2.3 数据稀疏的经典应对平滑与回退n-gram模型最大的敌人是未出现的组合。语料里没有“苹果 飞”这个二元组那么 P(飞 | 苹果)0整个句子概率直接变0log0就没法算。早期研究者想了一堆平滑方案。最简单的加1平滑给所有计数都加1包括没出现过的。公式P(wt | wt-1) (count(wt-1, wt) 1) / (count(wt-1) V)V是词表大小。这样做避免了零概率但也给所有没见过的组合分配了相同的微小概率质量不高。后来的Kneser-Ney平滑表现好得多它利用“一个词作为新搭配接续者”的统计信息做折扣。工程上如果你用现成库比如语言建模工具包kenlm直接调Kneser-Ney就行。平滑问题在神经网络模型里不明显因为词向量天然有泛化能力。但哪怕我用RNN或Transformer数据稀疏依然藏在未知词里。现代方案是用子词分词比如BPE把罕见词拆成多个子词片段。比如“unhappiness”拆成“un”“happiness”或“un”“happi”“ness”这样即使没见过的整词也可能由见过的片段组合出来模型能力一下子就变强了。好消息是求解语言模型你不需要特别手动做平滑。坏消息是你需要选择合适的子词分词器并且自己设计词表大小。词表太大训练参数太多词表太小句子被切得太碎语义信息损失。这属于工程调参的范畴没有绝对标准要在具体任务里试。3. 实操复盘从N-Gram到神经网络的完整实现3.1 语料整理与分词喂给模型的第一口饭无论用什么模型语料处理都是决定成败的第一环。我在做小规模实验时常犯的一个错误是拿了原始文本直接按空格切分结果满屏标点和大小写词汇表膨胀到几十万训练又慢又差。正确的流程是统一小写去除低质量噪声。比如把网页标签、超链接、无意义的表情符号清掉。这里注意不要过度清理有些项目里的专业术语本身长得像乱码清掉了反而损失信息。选择分词粒度。中文可以按字切分也可以先分词再按词切分英文通常直接按空格再进行后续子词处理。中文分词推荐用现成库但词表太大会导致模型参数爆炸所以我经常用字级别或BPE子词级别省心还抗稀疏。构建词表和索引。给每个token分配一个整数ID同时保留未登录词标记一般用[UNK]表示。给句子添加句首句尾标记[BOS]和[EOS]。做完这些后我还是习惯看一眼统计数据语料总token数、句子数、词表大小、平均句长。这些数字决定后续网络规模怎么定平均句长过短就说明语料被切太多了。3.2 N-Gram基线一个统计模型如何在CPU上实现预测你不需要一上来就跑神经网络先做一个n-gram模型当基线能让你快速了解任务难易程度也是之后判断神经网络到底提升了多少的锚点。核心代码逻辑很简单。我给出一个二元模型的极简版本from collections import defaultdict, Counter # 输入已经用整数ID表示的句子列表每条是list[int] def train_bigram(corpus, vocab_size): # 计数P(w_t | w_{t-1})以及一元的边际计数 bigram_count defaultdict(Counter) unigram_count Counter() for sentence in corpus: # 已经包含 [BOS] 0 和 [EOS] 1 for i in range(1, len(sentence)): prev, cur sentence[i-1], sentence[i] bigram_count[prev][cur] 1 unigram_count[prev] 1 return bigram_count, unigram_count def bigram_probability(prev, cur, bigram_count, unigram_count, vocab_size, alpha1.0): # 加一平滑 numerator bigram_count[prev][cur] alpha denominator unigram_count[prev] alpha * vocab_size return numerator / denominator def sentence_score(sentence, bigram_count, unigram_count, vocab_size): log_prob 0.0 for i in range(1, len(sentence)): p bigram_probability(sentence[i-1], sentence[i], bigram_count, unigram_count, vocab_size) log_prob math.log(p) return log_prob训练完这个基线你在验证集上算一下PPL。如果句子只有几个词PPL可能很低但这不代表模型好因为句子越短越容易猜。我在真实语料上跑过一个一百万句话的小语料二元模型的PPL通常在500到1500之间。这个数值会让你知道直接从零预测自然语言的下一个词有多难。n-gram可以快速上线但它完全不理解语义只看局部共现。遇到“我在北京上学已经读了三年书——北京”这种需要长距离照应的句子n-gram基本无能为力因为上下文窗口只有一两个词。3.3 神经网络语言模型从RNN到Transformer的关键思路神经网络语言模型解决了n-gram的两个核心问题长距离依赖和参数泛化。最早的神经网络语言模型把前n个词的词向量拼起来过一层全连接网络再用softmax生成词表上的概率分布。后来RNN出现模型不再只固定看前n个词而是通过隐藏状态把整个前文的信息都带进来。我在学习RNN语言模型时发现PyTorch实现的核心部分比想象中简洁。下面是一个简化版本展示建模思路真正跑起来还需要补上数据分批和掩码逻辑。import torch import torch.nn as nn class RNNLanguageModel(nn.Module): def __init__(self, vocab_size, embedding_dim128, hidden_dim256): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim) self.rnn nn.LSTM(embedding_dim, hidden_dim, batch_firstTrue) self.output nn.Linear(hidden_dim, vocab_size) def forward(self, input_ids): # input_ids: [batch, seq_len] emb self.embedding(input_ids) # [batch, seq_len, emb] output, _ self.rnn(emb) # [batch, seq_len, hidden] logits self.output(output) # [batch, seq_len, vocab] return logits训练时输入是“我 爱 吃”三个词目标是从第二个位置开始对应的“爱 吃 苹果”让模型预测每个位置下一个词的概率然后计算交叉熵损失。这里一个关键技巧是要用mask把padding位置对损失的贡献抹掉否则那些凑数用的[PAD]标记也会被当成学习目标模型会产生偏移。Transformer和RNN相比最大变化是注意力机制直接建模序列中任意两个位置的依赖。你一眼望去“我昨天在公园里遇见了老同学他……”里“他”指代谁Transformer能在第一层就提取到“老同学”。RNN则要靠隐藏状态一步步传递距离远信息衰减不可避免。关于训练Transformer还是RNN在小数据量实验里RNN或者LSTM已经足够而且更容易稳定收敛。真要去处理大规模生产任务再上预训练Transformer模型也来得及没必要从零训练一个巨型Transformer。3.4 评估与生成训练后必须做的两件事训练语言模型后马上测试两件事计算PPL和生成句子。PPL是客观指标生成则暴露模型的一切缺陷。PPL计算代码并不复杂。把测试集按同样的方式处理成ID序列跑一遍模型拿到每个位置的log概率排除padding和[EOS]部分后求平均最后取负对数平均的指数。关键是要保证训练集和测试集的分词方式完全一致不然PPL不可比。抽一个具体结果做参照。我训练一个简单LSTM语言模型语料是一千万条短新闻词表三万训练了两个epoch。训练集PPL能从1500降到320左右验证集PPL大概在380到450之间。这是合理范围进一步压PPL需要更大的模型、更长的训练或者更多数据。生成句子时最简单的策略是贪心每次选概率最大的词接在后面一直拼到[EOS]。但贪心容易生成重复且无聊的内容。常用技巧是温度采样def sample_from_logits(logits, temperature0.8): # 温度越低越接近贪心温度越高越随机 probs torch.softmax(logits / temperature, dim-1) return torch.multinomial(probs, num_samples1).item()温度参数很有意思。温度太低句子安全但模板化温度太高句子语法都会崩。我一般在0.7到1.0之间调试拿几十条生成结果人工看一遍比只看PPL靠谱得多。4. 经验之谈那些教科书不讲的排障记录4.1 训练不稳定损失波动大、梯度消失的排障手记RNN语言模型训练中最常见的问题是梯度消失和损失发散。症状表现有几种训练几轮后损失不降、降着降着突然出现NaN、或者验证集PPL在某个值附近剧烈震荡。我整理了一个速查表方便你按图索骥症状可能原因调整手段损失一直不降学习率太大损失在最优点附近振荡降低学习率或者加学习率warmup损失变成NaN梯度爆炸logits过大导致softmax溢出梯度裁剪nn.utils.clip_grad_norm_ 设 1.0 左右验证集PPL先降后升过拟合模型记住了训练集减小模型维度、加dropout、早停生成内容大量重复模型容量不足或温度设置过低增大hidden尺寸提高采样温度加重复惩罚训练很慢不收敛初始化不好深层网络信号衰减使用较小初始化范围检查数据是否归一化梯度裁剪是我用得最频繁的技能。训练初期我总喜欢把学习率调大希望快速收敛结果经常一到某个batchloss直接变NaN。原因很简单RNN的梯度计算图较长梯度大小按指数规律累积不加裁剪一次异常batch就把参数推出有效区域。在训练循环里加一行torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0)通常就能把发散的倾向压住。这个动作对LSTM和Transformer都适用我已经把它写进自己的默认训练模板里不解释。4.2 评估指标失灵PPL降了但生成效果不对这类情况我遇到过太多次。训练第5个epoch时验证集PPL还在降看起来一切完美。可把模型拉出来生成句子时发现它只会循环输出“今天天气不错今天天气不错”这种内容或者生成到一半突然接一个莫名其妙的[UNK]。问题出在哪PPL是平均指标它反映的是模型在每个位置的平均选择能力但生成是一个自回归过程每一步都在接上一步的输出。训练时用真实词作为输入测试时却用模型自己生成的词作为下一步输入误差就会随着生成过程累积。这被称为曝光偏差。实操上我会做两三件事第一训练过程中定期把当前模型拉出来生成几条句子让主观质量参与判断不要只盯PPL。第二如果模型总是重复提高采样温度选择top-k采样只从概率最高的前k个词里抽。k取40左右比较常见。第三在语料里故意加入多轮对话文本模型才有足够的数据学会什么话该停、什么词能换。有一次我训练客服对话模型PPL降到250左右看起来够用了但生成的回答里频繁出现“我们是一家人”这句因为训练语料里公司口号出现频率太高。这个现象说明模型学到了统计优势却没学到语境切换。后来我用对数频率过滤数据把过于高频的模板类句子降权生成质量立竿见影。4.3 领域漂移与过拟合模型在测试集上会骗你很多人训练时只准备一份训练集和一份验证集跑完就觉得自己完成了。实际落地的时候新来的线上数据往往和训练数据分布完全不一样。这就是领域漂移。比如你拿新闻语料训练语言模型来写电商文案PPL在新闻测试集上可能只有300换到电商评论上轻松飙到800。如果直接部署生成出来的句子带着浓厚的新闻腔毫无电商感觉。解决思路没有捷径就是收集目标领域的语料继续预训练。我这边有一个临时方案适合快速验证把训练集中一小部分真实线上日志混进去比例在10%到30%之间重新微调。混入线上数据后PPL虽然会略涨一点但生成结果反而更像实际业务要的文本。因为线上数据里包含用户真实输入习惯、产品专属说法这是多少条通用语料都替代不了的。过拟合也常在语言模型里玩花样。大型模型训练久了的典型表现是训练PPL很低验证PPL很高。这时候不要盲目加大数据先看模型参数量和语料量级的匹配度。小语料就该用小模型。我曾经拿300万句话去训练一个过千万参数的LSTM不仅慢还严重过拟合。换成一半尺寸的模型加上dropout之后验证PPL反而更优。这再一次验证了那个朴素道理模型容量要跟数据量匹配。写到最后我想说语言模型确实是个越学越深的领域。前几课你还在和分词、词向量打交道到这里开始接触序列概率建模手头能做的事情一下子就多了。我记得自己第一次把二元n-gram跑通时只觉得代码很无聊直到看见它能“写出”一段四不像的句子才意识到这个统计框架的威力。后来一步步换成RNN、Transformer理解通透了就发现所有生成系统的内核无非是“把概率建模做好”这一件事。如果你也是刚开始学建议按这个顺序走先把n-gram用极简语料跑通亲手算一遍PPL再跳到RNN最后再碰Transformer。不要一开始就把目标定为“复现一个大模型”那样你会迷失在海量的工程细节里。语言模型的美妙之处在于对概率的直觉一旦打通后面不管是做生成、做翻译、做对话都会多一种“这个东西到底像不像人话”的判断力。这种判断力才是NLP从业者最值钱的经验。