
1. NLP任务版图从输入输出形态倒推分类逻辑很多人刚接触自然语言处理时容易被一堆缩写搞晕分词、词性标注、命名实体识别、句法分析、语义角色标注、文本分类、情感分析、机器翻译、问答系统……看起来每个都是独立任务学起来像在背单词。但我在实际带项目和做技术选型时发现这些任务并不是散落的珠子它们背后有一条非常清晰的分类主线——输入和输出的形态决定了任务类型。抓住这条线你就能把整个NLP任务体系串起来而不是死记硬背。1.1 按输入输出粒度划分的四大任务族我习惯把NLP任务按“输入是什么、输出是什么”分成四大族。这个分法不是学术界的标准分类但它在工程落地时特别好用因为你拿到一个业务需求第一件事就是判断它属于哪一族然后才能决定用什么模型架构、标注数据怎么设计、评估指标怎么选。第一族序列标注类任务。输入是一个词序列输出是跟输入等长的标签序列。典型代表是分词、词性标注、命名实体识别。比如命名实体识别输入“某公司发布了新款处理器”输出就是“某公司/ORG 发布/O 了/O 新款/O 处理器/PRODUCT”。每个词或者每个字都要打一个标签输入输出长度一致。这类任务的核心是逐位置的分类决策但位置之间不是独立的前后标签有强约束关系所以模型必须建模上下文依赖。第二族句子级分类任务。输入是一个句子或一段文本输出是一个类别标签。文本分类、情感分析、意图识别都属于这一类。比如情感分析输入“这个功能用起来很顺手”输出“正面”。跟序列标注不同这里不需要对每个词做决策而是把整个句子压缩成一个语义表示再映射到类别空间。工程上这类任务的数据标注成本相对低因为一句话一个标签标注速度快但难点在于如何让模型抓住决定类别的关键片段而不是被无关词干扰。第三族序列到序列任务。输入一个序列输出另一个序列两者长度可以不同。机器翻译、文本摘要、对话生成、问答生成都是这一类。这是NLP里最复杂也最有意思的一族因为输出空间是开放的理论上可以是任意长度的任意文本。这类任务对模型的要求最高需要同时具备理解输入和生成输出的能力。早期用统计方法做后来神经网络兴起后逐渐被编码器-解码器架构统治。第四族匹配与排序任务。输入是两段文本输出是它们的匹配程度或排序分数。文本蕴含、语义相似度计算、检索排序都属于这一类。这类任务在搜索、推荐、问答系统里非常常见。它的特点是需要同时编码两段文本并建模它们之间的交互关系而不是单独编码每一段。工程上这类任务对负样本构造非常敏感负样本选得好不好直接决定模型能不能学到真正的语义匹配能力。把这四族任务搞清楚之后你再看到一个新任务先问自己输入是什么粒度输出是什么形态属于哪一族这样就能快速定位技术方案的大方向。1.2 为什么任务分类对工程落地如此关键有人可能会问分类归分类跟实际做项目有什么关系关系太大了。我踩过的一个坑就是早期做一个客服工单自动分类的需求我一开始把它当成序列标注来做给每个词打标签结果标注成本极高而且模型效果不稳定。后来重新梳理发现它本质上是句子级分类任务一句话对应一个工单类别换成文本分类的方案后标注效率提升了三倍模型准确率也上去了。任务分类决定了三件事标注方案怎么设计、模型架构怎么选、评估指标怎么定。序列标注任务必须用逐位置的评估指标比如实体级别的精确率和召回率句子级分类用准确率、F1值就够了序列到序列任务要用BLEU、ROUGE这类生成质量指标匹配排序任务则要看MRR、NDCG这些排序指标。如果你把任务类型搞错了后面所有环节都会跟着错。还有一个容易被忽视的点不同任务族对数据量的需求差异很大。句子级分类通常几千条标注数据就能出一个可用的基线模型序列标注因为要逐词标注同样数量的句子产生的监督信号多得多但标注成本也高序列到序列任务对数据量的要求最高因为输出空间大模型需要大量样本才能学到合理的生成分布。匹配排序任务则特别依赖负样本的质量和数量正样本往往不难获取难的是构造有区分度的负样本。所以我在做任何NLP项目之前一定会先花时间把任务分类搞清楚跟业务方对齐输入输出形态然后再动手。这一步看起来慢实际上是最省时间的做法。1.3 从任务分类到语言模型为什么需要NNLM把任务分类理清楚之后一个自然的问题就出现了不管哪一类任务模型都需要把文本转换成数值表示。怎么表示词怎么表示句子这就是语言模型要解决的问题。最早的做法是one-hot编码每个词用一个很长的向量表示向量里只有一个位置是1其余全是0。这个方法简单但有两个致命问题一是维度灾难词表有多大向量就有多长二是词与词之间没有语义关联“处理器”和“芯片”的one-hot向量正交模型完全看不出它们有关系。后来出现了分布式表示的思想用一个低维稠密向量来表示词语义相近的词在向量空间里距离也相近。这个思想最早可以追溯到信息检索领域的潜在语义分析但真正把它跟神经网络结合起来、并用于语言建模的就是NNLMNeural Network Language Model神经网络语言模型。NNLM的核心贡献在于它不再把词当作离散符号而是学习每个词的连续向量表示同时用这些向量来预测下一个词。这个思路直接启发了后来的Word2Vec、GloVe乃至整个预训练语言模型时代。所以你看任务分类和NNLM并不是两个孤立的话题。任务分类告诉你“要解决什么问题”NNLM告诉你“用什么方式把文本表示成模型能处理的形式”。两者合在一起才构成NLP从问题定义到技术方案的完整链条。2. NNLM核心机制拆解从one-hot到稠密向量NNLM看起来是一个“预测下一个词”的模型但它真正的价值在于顺带学出了词的稠密向量表示。这个“顺带”非常关键——它意味着我们不需要人工标注语义相似度数据只需要大量无标注文本就能让模型自己学会词与词之间的语义关系。这一节我把NNLM的架构拆开讲清楚每个部件为什么这么设计以及在实际实现时需要注意什么。2.1 输入层为什么用拼接而不是求和NNLM的输入是前n-1个词的one-hot向量。假设词表大小是V每个词用V维one-hot表示那么输入就是n-1个V维向量。这些向量经过一个共享的嵌入矩阵C维度是V×dd是词向量维度映射后变成n-1个d维稠密向量。这里有一个设计选择把n-1个词的嵌入向量拼接起来形成一个(n-1)×d维的长向量而不是求和或平均。为什么因为求和或平均会丢失词序信息。“张三打李四”和“李四打张三”如果只做词向量平均表示完全一样但语义截然不同。拼接保留了每个位置的信息后面的全连接层可以学到位置相关的模式。但拼接也有代价输入维度变成(n-1)×d如果n5、d100输入就是400维。全连接层的参数量会随n线性增长。所以NNLM的上下文窗口n通常不会设得太大一般取3到5。这也是后来RNN、Transformer要解决的核心问题之一——如何在不显著增加参数量的情况下建模更长的上下文。在实际实现时嵌入矩阵C的初始化很关键。我一般用均匀分布做小随机初始化范围控制在[-0.1, 0.1]左右。初始化太大容易导致训练初期梯度爆炸太小则学不到有效表示。这个经验值不是绝对的但在我做过的多个文本分类和语言模型任务里这个范围比较稳。2.2 隐藏层与输出层非线性从哪来拼接后的向量经过一个全连接层通常带tanh激活函数得到一个隐藏表示。这一层的作用是引入非线性让模型能学到词与词之间的复杂组合关系。如果没有非线性整个模型就退化成对输入嵌入的线性变换表达能力会大打折扣。隐藏层之后是输出层维度是V对应词表里每个词作为下一个词的概率。输出层用softmax归一化得到概率分布。训练目标是最小化预测分布和真实下一个词之间的交叉熵。这里有一个工程上非常现实的问题V通常很大softmax的计算量是O(V)。如果词表有十万个词每次前向传播都要算十万维的softmax训练会非常慢。实际中常用的优化手段有几种一是用层次softmax把词表组织成二叉树计算量降到O(log V)二是用负采样把多分类问题转化成二分类问题每次只更新正样本和少量负样本三是限制词表大小把低频词统一映射成特殊标记。这些技巧在Word2Vec里被系统化地使用但思想源头可以追溯到NNLM。我在实际项目里如果词表超过五万基本都会考虑负采样或者层次softmax。具体选哪个要看任务特点如果词频分布比较均匀层次softmax效果更稳如果词频长尾严重负采样配合适当的负样本分布效果更好。2.3 训练目标与词向量的“副产品”效应NNLM的训练目标很直接给定前n-1个词最大化下一个词的条件概率。但训练完成后我们真正想要的往往不是这个预测能力而是嵌入矩阵C——它包含了每个词的稠密向量表示。这个“副产品”之所以有价值是因为它捕捉了词的分布式语义。语义相近的词在预测下一个词时扮演的角色也相似所以它们的嵌入向量会被训练得比较接近。比如“处理器”和“芯片”在很多上下文里可以互换模型在训练过程中会逐渐把它们的向量拉近。但NNLM学到的词向量有一个局限它只利用了单向的上下文。也就是说预测下一个词时只看前面的词不看后面的词。这导致词向量对右侧上下文的信息利用不足。后来的Word2Vec通过CBOW和Skip-gram两种结构分别从不同方向利用上下文部分缓解了这个问题。再后来的ELMo、BERT则用双向结构彻底解决了这个问题。但NNLM作为开山之作它的核心思想——用神经网络学词向量——一直延续至今。我在实际使用NNLM学到的词向量时有一个经验窗口大小n对词向量质量影响很大。n太小学到的词向量偏向句法信息语义相似度捕捉不够n太大训练变慢而且远距离词的噪声会干扰。一般n取3到5比较平衡。如果任务特别依赖语义相似度可以适当增大n但要注意计算成本。3. 从零实现NNLM完整流程与关键参数这一节我带你走一遍NNLM的完整实现流程。我会用PyTorch来写因为它的动态图机制对调试这种结构不复杂的模型特别友好。整个流程包括数据准备、模型定义、训练循环、词向量提取四个环节。每个环节我都会说明关键参数的选择依据和实操中的注意事项。3.1 数据准备构建词表与训练样本第一步是构建词表。我通常会把所有文本读进来做简单的分词英文按空格中文按字或按词统计词频然后保留频率最高的前N个词其余映射成unk。N的取值要看数据规模一般五万到十万之间。太小会导致太多词变成unk太大则softmax计算量爆炸。from collections import Counter def build_vocab(corpus, max_size50000): counter Counter() for sentence in corpus: counter.update(sentence) vocab [pad, unk] [w for w, _ in counter.most_common(max_size-2)] word2idx {w: i for i, w in enumerate(vocab)} return vocab, word2idx构建完词表后要把每句话转换成索引序列然后滑动窗口生成训练样本。假设窗口大小n3那么对于序列[w1, w2, w3, w4, w5]样本就是([w1,w2], w3)、([w2,w3], w4)、([w3,w4], w5)。这里要注意窗口大小n是上下文词的数量不包括预测目标词。所以实际输入是n个词预测第n1个词。def make_samples(ids, n3): samples [] for i in range(len(ids) - n): context ids[i:in] target ids[in] samples.append((context, target)) return samples实操中有一个细节容易被忽略句首和句尾的处理。如果句子长度小于n要么跳过要么用pad补齐。我一般选择跳过因为短句本身信息量有限强行补齐反而引入噪声。另外如果不同句子之间没有分隔窗口会跨句滑动导致上下文不连贯。所以我会在每句话末尾加一个eos标记窗口滑动到eos时就停止下一句话重新开始。3.2 模型定义嵌入层、隐藏层、输出层的参数选择模型定义部分我用PyTorch的nn.Embedding来实现嵌入矩阵C用nn.Linear实现隐藏层和输出层。关键参数有三个词向量维度d、隐藏层维度h、窗口大小n。import torch import torch.nn as nn import torch.nn.functional as F class NNLM(nn.Module): def __init__(self, vocab_size, embed_dim100, hidden_dim200, context_size3): super().__init__() self.embed nn.Embedding(vocab_size, embed_dim) self.fc1 nn.Linear(context_size * embed_dim, hidden_dim) self.fc2 nn.Linear(hidden_dim, vocab_size) self.context_size context_size def forward(self, x): emb self.embed(x) # (batch, context_size, embed_dim) emb emb.view(-1, self.context_size * self.embed_dim) hidden torch.tanh(self.fc1(emb)) logits self.fc2(hidden) return logits词向量维度d一般取50到300。d太小表示能力不足词向量区分度低d太大参数量增加训练容易过拟合。我一般从100开始试如果数据量大且任务复杂可以加到200或300。隐藏层维度h通常取d的1到2倍我一般设200。h太大训练慢h太小模型容量不够。窗口大小n前面说过3到5比较平衡。还有一个细节nn.Embedding默认会初始化成标准正态分布。我一般会手动改成均匀分布范围[-0.1, 0.1]这样训练初期更稳定。self.embed.weight.data.uniform_(-0.1, 0.1)3.3 训练循环学习率、批次大小与早停策略训练循环用标准的交叉熵损失和随机梯度下降。优化器我一般选Adam学习率设1e-3。批次大小取64或128太小梯度噪声大太大内存吃紧且泛化可能变差。model NNLM(vocab_sizelen(vocab)) optimizer torch.optim.Adam(model.parameters(), lr1e-3) criterion nn.CrossEntropyLoss() for epoch in range(20): model.train() total_loss 0 for context, target in train_loader: context context.to(device) target target.to(device) optimizer.zero_grad() logits model(context) loss criterion(logits, target) loss.backward() optimizer.step() total_loss loss.item() print(fEpoch {epoch}, Loss: {total_loss / len(train_loader)})训练过程中要监控验证集上的损失。如果验证损失连续几个epoch不下降就触发早停。我一般设patience3也就是连续3个epoch验证损失没改善就停。这样可以避免过拟合也节省训练时间。还有一个实操技巧梯度裁剪。NNLM虽然不深但嵌入层的梯度有时候会比较大尤其是训练初期。我一般会加torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5)防止梯度爆炸导致训练不稳定。3.4 词向量提取与相似度验证训练完成后嵌入矩阵model.embed.weight就是我们要的词向量。每一行对应词表里一个词的向量。提取出来后可以用余弦相似度找近义词验证词向量质量。def most_similar(word, word2idx, embeddings, topk5): idx word2idx[word] vec embeddings[idx] sims F.cosine_similarity(embeddings, vec.unsqueeze(0), dim1) top torch.topk(sims, topk1) return [(vocab[i], sims[i].item()) for i in top.indices if i ! idx][:topk]验证的时候我会挑几组语义相近的词比如“处理器”和“芯片”、“训练”和“学习”看它们的余弦相似度是不是明显高于随机词对。如果相似度都在0.1以下说明词向量没学好需要检查数据量、窗口大小、训练轮数这些参数。我踩过的一个坑是数据量太少时词向量质量很差。NNLM虽然比传统方法数据效率高但仍然需要一定规模的语料。如果语料只有几万词学出来的词向量基本不可用。这种情况下要么增加数据要么用预训练词向量做初始化要么直接上预训练语言模型。4. 常见问题与排查技巧实录做NNLM实现和调参的过程中我遇到过不少问题。有些是代码层面的有些是参数选择层面的还有些是数据层面的。这一节我把典型问题和排查思路整理出来方便你遇到类似情况时快速定位。4.1 训练损失不下降的四种可能原因训练损失不下降是最常见的问题。我一般按以下顺序排查第一检查数据管道。最常见的原因是输入和标签错位了。比如窗口滑动时把目标词也放进了上下文或者标签索引偏移了一位。这种错误不会报错但模型学不到任何东西。排查方法很简单手动打印几条样本看看上下文和标签是不是符合预期。第二检查学习率。学习率太大损失会震荡甚至发散学习率太小损失下降极慢。我一般先用1e-3试如果损失震荡就降到1e-4如果下降太慢就升到3e-3。Adam对学习率相对鲁棒但也不是万能的。第三检查词表大小和unk比例。如果unk占比太高模型大部分时候在预测unk损失自然降不下去。这时候要么增大词表要么检查分词是否合理。第四检查模型容量。如果嵌入维度和隐藏层维度太小模型表达能力不足损失会卡在一个较高的水平。这时候适当增大d和h通常能看到改善。4.2 词向量相似度异常的排查思路词向量相似度异常通常表现为语义相近的词相似度不高或者所有词的相似度都差不多。前者说明模型没学到语义关系后者说明词向量坍缩了所有词映射到了空间里同一个区域。排查思路如下现象可能原因排查方法解决方向相近词相似度低窗口太小语义信息不足增大n到5或7重新训练相近词相似度低训练轮数不够看训练损失是否还在下降增加epoch所有词相似度接近学习率太大嵌入坍缩检查嵌入向量范数降低学习率所有词相似度接近数据量太少统计语料总词数增加数据或换预训练相似度整体偏低嵌入维度太小检查d值增大d到200或300我遇到过一次所有词相似度都在0.9以上的情况排查后发现是学习率设成了0.1太大导致嵌入向量全部坍缩到同一个方向。降到1e-3后重新训练相似度分布就正常了。4.3 训练效率优化从小时级到分钟级NNLM的训练效率主要受三个因素影响词表大小、批次大小、是否用GPU。如果词表有十万词softmax是主要瓶颈。我一般用负采样来加速把多分类变成二分类每次只更新正样本和5到10个负样本。这样每个batch的计算量从O(V)降到O(负样本数)训练速度能提升一个数量级。另一个优化点是数据加载。如果训练样本很多用PyTorch的DataLoader配合多进程加载可以避免GPU等数据。我一般设num_workers4pin_memoryTrue这样数据从CPU到GPU的传输会快很多。还有一个容易被忽视的点批次大小和词表大小的匹配。如果词表很大而批次很小每个batch的梯度噪声会很大训练不稳定。我一般保证批次大小至少是词表大小的千分之一。比如词表十万批次至少设100。如果显存不够就用梯度累积攒几个batch再更新一次。4.4 从NNLM到现代语言模型的演进线索NNLM虽然简单但它确立的几个核心思想一直延续到今天用稠密向量表示词、用神经网络建模上下文、用预测下一个词作为训练目标。后来的Word2Vec简化了NNLM的结构去掉了隐藏层直接用嵌入层到输出层的映射训练效率大幅提升。ELMo引入了双向LSTM让词向量能根据上下文动态变化。BERT用Transformer编码器和掩码语言模型彻底抛弃了单向限制成为预训练语言模型的标杆。理解NNLM的价值不在于用它本身而在于理解这些演进线索背后的逻辑。为什么Word2Vec要去掉隐藏层因为隐藏层虽然增加了非线性但也增加了计算量而词向量质量对隐藏层的依赖并不强。为什么ELMo要双向因为单向上下文丢失了右侧信息对语义理解不利。为什么BERT要用掩码因为双向模型如果直接预测下一个词会看到答案必须把部分词遮住才能形成有效的训练目标。这些问题的答案都能从NNLM的设计选择里找到影子。所以我在带新人时一定会让他们先手写一遍NNLM不是为了用它而是为了理解语言模型的基本原理和设计权衡。把这个基础打牢后面看Word2Vec、ELMo、BERT的论文时就能看出每个改进解决了什么问题而不是囫囵吞枣地记结论。我个人在实际操作中的体会是NNLM的代码量不大但涉及的知识点很密集词表构建、嵌入层、窗口采样、softmax、交叉熵、梯度下降、词向量评估。把这套流程完整走一遍对NLP工程能力的提升是实打实的。后面再学更复杂的模型时你会发现很多概念都是相通的只是换了个形式出现。