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向量正交,模型完全看不出它们有关系。
后来出现了分布式表示的思想:用一个低维稠密向量来表示词,语义相近的词在向量空间里距离也相近。这个思想最早可以追溯到信息检索领域的潜在语义分析,但真正把它跟神经网络结合起来、并用于语言建模的,就是NNLM(Neural 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×d,d是词向量维度)映射后,变成n-1个d维稠密向量。
这里有一个设计选择:把n-1个词的嵌入向量拼接起来,形成一个(n-1)×d维的长向量,而不是求和或平均。为什么?因为求和或平均会丢失词序信息。“张三打李四”和“李四打张三”如果只做词向量平均,表示完全一样,但语义截然不同。拼接保留了每个位置的信息,后面的全连接层可以学到位置相关的模式。
但拼接也有代价:输入维度变成(n-1)×d,如果n=5、d=100,输入就是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_size=50000): 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构建完词表后,要把每句话转换成索引序列,然后滑动窗口生成训练样本。假设窗口大小n=3,那么对于序列[w1, w2, w3, w4, w5],样本就是([w1,w2], w3)、([w2,w3], w4)、([w3,w4], w5)。这里要注意:窗口大小n是上下文词的数量,不包括预测目标词。所以实际输入是n个词,预测第n+1个词。
def make_samples(ids, n=3): samples = [] for i in range(len(ids) - n): context = ids[i:i+n] target = ids[i+n] 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_dim=100, hidden_dim=200, context_size=3): 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_size=len(vocab)) optimizer = torch.optim.Adam(model.parameters(), lr=1e-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(f"Epoch {epoch}, Loss: {total_loss / len(train_loader)}")训练过程中要监控验证集上的损失。如果验证损失连续几个epoch不下降,就触发早停。我一般设patience=3,也就是连续3个epoch验证损失没改善就停。这样可以避免过拟合,也节省训练时间。
还有一个实操技巧:梯度裁剪。NNLM虽然不深,但嵌入层的梯度有时候会比较大,尤其是训练初期。我一般会加torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5),防止梯度爆炸导致训练不稳定。
3.4 词向量提取与相似度验证
训练完成后,嵌入矩阵model.embed.weight就是我们要的词向量。每一行对应词表里一个词的向量。提取出来后,可以用余弦相似度找近义词,验证词向量质量。
def most_similar(word, word2idx, embeddings, topk=5): idx = word2idx[word] vec = embeddings[idx] sims = F.cosine_similarity(embeddings, vec.unsqueeze(0), dim=1) top = torch.topk(sims, topk+1) 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_workers=4,pin_memory=True,这样数据从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工程能力的提升是实打实的。后面再学更复杂的模型时,你会发现很多概念都是相通的,只是换了个形式出现。