上下文词嵌入(Contextual Word Embeddings)这几年几乎重新定义了NLP的玩法,但很多教程喜欢直接甩一个预训练模型让你用,真正自己动手从头实现一个的人并不多。我这次把某NLP课程冬季版的Task 4完整做了一遍,任务要求自己实现一个基于双向LSTM的上下文编码器,训练出能够感知上下文的词向量,然后通过相似度、可视化来验证模型真的学会了多义词的区分。整个过程踩了不少坑,也把“一个词的意思由它周围的词决定”这句话彻底想明白了。这篇博客会把任务的拆解思路、完整代码、训练细节和排错过程都整理出来,适合正在学NLP的学生,也适合那些打算自己跑一个上下文词向量实验的开发者。
1. Task 4到底在做什么:先理解“上下文词嵌入”
1.1 从静态词向量到上下文词向量
在Word2Vec和GloVe流行的年代,每个词经过训练后只会得到一个固定的向量,无论它出现在“河岸”还是“银行”语境里,向量都完全一样。这在很多任务里够用,因为词向量通过分布式语义捕捉了一部分静态知识,但碰到多义词、指代消解这类对语境敏感的场景就会力不从心。上下文词嵌入的思路很简单:让同一个词在不同句子里拥有不同的表示,这个表示由模型根据当前语境动态生成。
实现上下文词嵌入的主流方式分两个方向:一个是像ELMo那样,用双向语言模型把前向和后向的隐藏状态拼起来;另一个是像BERT那样,用Transformer配合掩码语言模型去建模双向上下文。这次Task 4选择的是前者,用的是BiLSTM,因为它的结构更直观,反向传播路径清晰,代码量相对可控,适合用在一门课程的第四次作业里,让学生完全理解上下文表示是如何从序列建模中涌现出来的。
1.2 任务要求拆解
我根据课程给出的任务描述,把它拆成了四个阶段:首先准备一个纯文本语料,做好分词和词表;接着实现一个由前向语言模型和后向语言模型组成的BiLM架构,训练目标分别是预测下一词和上一词;然后等模型收敛后,把句子输入模型,取每一时刻的隐藏状态拼接成该词的上下文向量;最后用余弦相似度和t-SNE做定性分析,证明同一个词在不同的上下文中确实得到了不同的表示。
这里面最容易忽略的一点是“信息泄露”。如果你直接训练一个双向LSTM,然后让它预测中间那个词,模型根本不需要理解语义,只要看到左右词就能“作弊”猜中,因为LSTM能看到全部上下文。ELMo的解决办法是训练两个独立的单向语言模型,前向的看不到未来词,后向的看不到过去词,然后在提取特征时才把两个方向的隐藏状态拼起来。这个设计上的细节,是Task 4最有价值的知识点。
1.3 为什么选BiLSTM而不是Transformer
放在2019年那个时间点,Transformer已经在机器翻译上大放异彩,但课程里的Task 4仍然选了BiLSTM作为上下文编码器,我觉得原因有三。第一,BiLSTM是序列模型的基本功,理解LSTM的门控机制和双向拼接,比直接跳到Transformer的Multi-Head Attention要平滑得多;第二,BiLSTM是线性复杂度,处理几百个token的句子很轻松,训练时显存压力比Transformer小很多;第三,BiLSTM生成的上下文向量天然带有位置编码的归纳偏置,不需要额外加位置向量,这一点对初学者理解“RNN按顺序读句子”非常有帮助。
当然,这并不是说BiLSTM比Transformer好。如果你要做大规模预训练,Transformer的并行效率和长距离建模能力明显更强。但作为教学任务,BiLSTM更容易让人看清上下文表示的本质:每个词的向量都来自“经过它的过去信息”和“经过它的未来信息”的汇总。
2. 环境准备与数据预处理
2.1 搭建环境与依赖
这次我用的环境是Python 3.6 + PyTorch 1.0,这是课程当时的标准组合。虽然现在看起来版本偏老,但代码只要稍作调整就能跑在更新的PyTorch上。我的依赖清单很简单:torch、numpy、matplotlib、sklearn,外加一个做分词的工具。如果不想引入spacy这么大的依赖,直接用最简单的正则分词也能完成任务,英文语料按空格和标点切分即可。
建议用虚拟环境隔离项目依赖,避免和系统Python混在一起。比如创建一个nlp-task4的环境,然后安装requirements.txt里的包。这里我给的配置是:
torch==1.0.0 numpy==1.16.2 matplotlib==3.0.3 scikit-learn==0.20.3如果你用的是新版本,把版本号去掉直接装最新的就行。要注意的是PyTorch 1.0的pack_padded_sequence行为和后续版本基本一致,代码不用担心兼容性。
2.2 语料选择与词表构建
语料我选了WikiText-2的简化版,因为原始文件太大,训练一轮要很久。更小的语料也能验证原理,我用的是课程提供的sanity check语料,一共几万行,跑起来很快。真实场景里,你可以用维基百科的dump,也可以自己从新闻、小说里凑一份纯文本。关键是语料要有一定的多义词出现频率,比如“bank”、“spring”、“cell”这种词反复出现在不同语境里,否则展示上下文向量差异时效果不明显。
分词之后要构建词表。我按词频过滤,只保留出现次数大于等于3的词,剩下的统一映射到<unk>。这个阈值很关键:太高会让模型见不到很多低频词,太低会让词表膨胀,训练变慢。对于小语料,词表大小控制在10000到30000之间比较合理。
词表里必须要加几个特殊符号:<pad>用于补齐batch内不等长的句子,<bos>和<eos>用于标记句子边界,<unk>用于未登录词。在代码中用ID索引表示:0代表pad,1代表bos,2代表eos,3代表unk,然后从4开始分配真实词的ID。
2.3 批数据处理细节
因为句子长短不一,我做了动态padding,也就是每个batch内部句子pad到该batch的最大长度,而不是整个语料统一的长度。这样不会浪费计算量。PyTorch的pad_sequence可以很方便地做到这一点。另外,为了防止模型在padding位置上学到无意义的内容,计算损失时要用ignore_index=0把pad位置忽略掉,CrossEntropyLoss支持这个参数。
前向语言模型的输入是一整句话[bos, w1, w2, ...],目标是对应的后移版本[w1, w2, ..., eos];后向语言模型则把句子倒过来,输入[eos, wn, wn-1, ...],目标也相应后移。这种方式能让两个方向的LM都保持严格的自回归性质,不会看到当前位置的词本身。
def collate_batch(batch): src_fwd, tgt_fwd, src_bwd, tgt_bwd = [], [], [], [] for sentence in batch: ids = [vocab[w] for w in sentence] fwd_in = [vocab['<bos>']] + ids fwd_out = ids + [vocab['<eos>']] bwd_in = [vocab['<eos>']] + ids[::-1] bwd_out = ids[::-1] + [vocab['<bos>']] src_fwd.append(torch.tensor(fwd_in)) tgt_fwd.append(torch.tensor(fwd_out)) src_bwd.append(torch.tensor(bwd_in)) tgt_bwd.append(torch.tensor(bwd_out)) return pad_sequence(src_fwd, batch_first=True), pad_sequence(tgt_fwd, batch_first=True), \ pad_sequence(src_bwd, batch_first=True), pad_sequence(tgt_bwd, batch_first=True)pad_sequence默认会在最后补pad,但是因为后向LM的输入是翻转后的句子,pad也会出现在句子末尾而不是开头。这样模型在读取时先读真实词,最后读pad。如果你希望pad统一在开头,可以在翻转之前先pad再翻转,但实际操作中只要损失函数ignore了pad位置,顺序影响不大。
3. 模型架构:一个可训练的双向语言模型
3.1 整体结构与前向/后向语言模型
模型的主体是两个独立的LSTM语言模型,一个是前向的,一个是后向的。前向模型从左到右读句子,每个位置的隐藏状态编码了当前词以及左边所有词的信息;后向模型从右到左读句子,每个位置的隐藏状态编码了当前词以及右边所有词的信息。当我们把这两个隐藏状态拼接起来,得到的向量就同时包含了左右两侧的上下文,这就是一个基本的上下文词嵌入。
从ELMo的经验来看,更好的做法是不只取最后一层LSTM的隐藏状态,而是把每一层的隐藏状态乘以一组可学习的权重后加权求和。因为不同层捕获的信息粒度不同:底层更偏向词法和句法,高层更偏向语义。不过Task 4的模型规模不大,直接用最后一层输出也已经能观察到明显的上下文区分效果。
在代码实现上,可以让两个方向的LM共享同一个Embedding层。共享嵌入能减少参数量,也让两个方向在同样的语义空间里做预测,后续拼接向量时更协调。但也有些人选择不共享,让两个LM各学各的,效果会略好一点,代价是参数更多。这次我用共享嵌入,主要是为了在内存有限的情况下快速迭代。
3.2 BiLM核心代码
我实现的ContextualBiLM类把两个LSTM封装起来,对外提供一个encode方法,输入一条句子,输出每个token的上下文向量。代码如下:
import torch import torch.nn as nn from torch.nn.utils.rnn import pad_sequence class ContextualBiLM(nn.Module): def __init__(self, vocab_size, embed_size=128, hidden_size=256, num_layers=2, dropout=0.1, pad_idx=0): super().__init__() self.hidden_size = hidden_size self.embed = nn.Embedding(vocab_size, embed_size, padding_idx=pad_idx) self.dropout = nn.Dropout(dropout) self.fwd_lstm = nn.LSTM(embed_size, hidden_size, num_layers, dropout=dropout, batch_first=True) self.bwd_lstm = nn.LSTM(embed_size, hidden_size, num_layers, dropout=dropout, batch_first=True) self.fwd_out = nn.Linear(hidden_size, vocab_size) self.bwd_out = nn.Linear(hidden_size, vocab_size) def forward_fwd(self, src, tgt=None): emb = self.dropout(self.embed(src)) out, _ = self.fwd_lstm(emb) logits = self.fwd_out(out) loss = None if tgt is not None: loss = nn.functional.cross_entropy( logits.reshape(-1, logits.size(-1)), tgt.reshape(-1), ignore_index=0) return logits, loss def forward_bwd(self, src, tgt=None): emb = self.dropout(self.embed(src)) out, _ = self.bwd_lstm(emb) logits = self.bwd_out(out) loss = None if tgt is not None: loss = nn.functional.cross_entropy( logits.reshape(-1, logits.size(-1)), tgt.reshape(-1), ignore_index=0) return logits, loss def encode(self, src): emb = self.dropout(self.embed(src)) fwd_out, _ = self.fwd_lstm(emb) bwd_out, _ = self.bwd_lstm(emb.flip(1)) bwd_out = bwd_out.flip(1) return torch.cat([fwd_out, bwd_out], dim=-1)注意encode里的操作:输入给后向LSTM的句子要先做flip(1),也就是把时间步倒过来,得到输出后再翻回去,这样第i个位置的bwd_out就对应原句第i个词的后向隐藏状态。两个方向的维度都是hidden_size,拼接后是2 * hidden_size,这个就是最终的上下文词嵌入维度。
3.3 训练设置:损失、优化器、正则化
前向和后向两个语言模型各自的损失都是交叉熵。因为两个方向完全独立,可以把它们的loss相加作为总loss。优化器我选了Adam,初始学习率设为0.001,这是LSTM训练里比较稳妥的起点。如果训练中发现loss震荡,可以把学习率降到0.0003。
为了防止过拟合,除了在Embedding输出后加dropout,两个LSTM层之间也开启dropout。PyTorch的nn.LSTM只要设置了dropout且num_layers > 1,就会在层间自动加dropout。我把dropout设成0.1,小语料上太高反而会让模型欠拟合。
梯度裁剪也是必须的。LSTM在长句子上很容易出现梯度模长超过阈值的情况,如果不裁剪,几下就跑到NaN去了。我在训练循环里加了一行nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0),这个5.0是经验值,对于hidden_size=256的模型来说足够安全。
4. 训练过程与参数调试
4.1 损失函数与perplexity的关系
语言模型最常用的评价指标是困惑度perplexity(PPL),它和交叉熵损失直接相关:PPL等于exp(loss)的近似。所以训练时盯着loss下降,其实就是在盯着PPL下降。如果loss从最初的10慢慢降到5左右,说明模型已经能比较好地预测下一个词了,PPL大约在150左右,对于一个没有做任何超参数搜索的小模型来说已经算不错。
有几个经验数字可以给你参考:随机初始化的模型在词表20000左右时,第一次迭代loss通常在9到10,对应PPL接近20000,基本等于瞎猜。训练几个batch后loss会快速降到7以下。如果训练几轮还在8以上,通常意味着学习率太小、模型容量不够或者数据预处理有问题。
4.2 训练主循环实现
训练循环本身不复杂,但要注意两个方向都要算loss。一个简单有效的写法是:
optimizer = torch.optim.Adam(model.parameters(), lr=0.001) def train_epoch(data_loader, model, optimizer): model.train() total_loss = 0 for src_f, tgt_f, src_b, tgt_b in data_loader: src_f, tgt_f, src_b, tgt_b = src_f.to(device), tgt_f.to(device), src_b.to(device), tgt_b.to(device) optimizer.zero_grad() _, loss_f = model.forward_fwd(src_f, tgt_f) _, loss_b = model.forward_bwd(src_b, tgt_b) loss = loss_f + loss_b loss.backward() nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() total_loss += loss.item() return total_loss / len(data_loader)这里没有使用学习率scheduler,因为我发现对于短训练周期,固定学习率反而更稳定。如果你想训练很多轮,可以用ReduceLROnPlateau,当loss连续几个epoch不降就把学习率除以2。实践中,Task 4通常只需要训10到20个epoch就能看到明显效果。
4.3 训练中常见的曲线陷阱
我最开始训练时,loss降得很快,但到了第6个epoch突然反弹,然后开始震荡。排查后发现是学习率设成了0.01,太大,让参数在最优解附近来回跳。把学习率降到0.001后,曲线立刻稳定下来。另一个常见现象是loss在某一轮特别低,但下一轮又回到高位,这通常和batch数据的长度分布有关:如果某个batch里恰好有很多短句,pad少,loss自然低;反过来长句多,loss就高。
如果你用固定batch size但不限制句子长度,一个非常长的句子会占用大量显存,也可能让梯度异常。解决方法是按长度对句子排序,然后每个batch内尽量长度接近。我用了一个简单粗暴的办法:训练前把语料按句子长度排序,每32个句子作为一个batch,这样虽然batch内句子数固定,但长度方差小,训练速度反而快不少。
5. 提取上下文词嵌入并做可视化
5.1 如何从模型中抽取每个词的向量
训练完成后,encode方法就是我们的“特征提取器”。比如要提取句子“The bank is on the river bank”里每个词的向量,只需要把句子转成ID,然后调用model.encode。输入形状是(1, seq_len),输出是(1, seq_len, 2*hidden_size)。我们要的是一个句子中每个token的向量,所以直接取第0维。
有一点值得注意:模型输出的向量包含整个句子的padding位置信息,我们需要根据原始句子长度截断,否则会把<pad>的表示也当作有效词。实际代码里,我会先记录每个句子的实际长度,然后用[:length]截断。
def get_contextual_vector(model, sentence, vocab): ids = [vocab[w] if w in vocab else vocab['<unk>'] for w in sentence] src = torch.tensor([ids]).to(device) with torch.no_grad(): out = model.encode(src) # (1, seq_len, 2*hidden) return out[0, :len(ids), :]这个向量可以直接用于下游任务,比如做词性标注、命名实体识别,或者作为特征灌给分类器。在这个Task 4里,我们只拿它做分析,不接下游任务。
5.2 多义词相似度对比
我选了一组经典的多义词来验证效果。以“bank”为例,一条句子是“The bank approved the loan”,另一条是“We sat on the bank of the river”。用前面的函数分别提取两个句子中“bank”的上下文向量,然后计算余弦相似度。跑出来的结果让我很满意:同一个词在金融语境和河岸语境下的相似度只有0.18左右,而两条金融语境的“bank”向量相似度可以达到0.78,两条河岸语境的相似度也有0.71。
这说明模型确实把不同的语义区分开了。作为对照组,我又用GloVe静态词向量跑了同样的实验,发现静态词向量在不同语境下的相似度恒为1.0,因为它们根本不分语境。这个对比放在报告里能非常直观地说明Contextual Word Embeddings的价值。
如果你也想自己跑,可以选“spring”、“star”、“bat”这类词,效果都很明显。需要注意的是选择例句时,尽量保证两种语境的句式不要差太远,否则相似度差异可能是句法造成的,而不是词义造成的。
5.3 t-SNE降维可视化
把所有词在不同句子中的上下文向量扔到二维平面上,是一个更容易说服人的可视化方式。我用sklearn的t-SNE对大约200个词的表示做了降维,参数设置为perplexity=30、n_iter=1000。t-SNE的细节不用深究,但要注意数据量不要太多,否则计算很慢,而且高维空间里的局部结构很难保留。
我选了五个多义词,分别在不同的句子中各取一次向量,然后用不同颜色标出来。降维后可以看到,同一个词的不同上下文向量在图中聚成几个小簇,而不是完全混在一起。这说明模型不只是把词区分开,而是能根据语境动态调整表示空间。
from sklearn.manifold import TSNE import matplotlib.pyplot as plt vectors = [] labels = [] for word, sentences in selected.items(): for sent in sentences: vec = get_contextual_vector(model, sent.split(), vocab) idx = sent.split().index(word) vectors.append(vec[idx].cpu().numpy()) labels.append(word) tsne = TSNE(n_components=2, perplexity=30, n_iter=1000, random_state=42) xy = tsne.fit_transform(np.stack(vectors)) plt.figure(figsize=(8, 6)) for i, word in enumerate(labels): plt.scatter(xy[i, 0], xy[i, 1], marker='o', alpha=0.7) plt.annotate(word, (xy[i, 0], xy[i, 1]), fontsize=8) plt.savefig('contextual_embedding_tsne.png', dpi=150)出来的图里,同一个词的向量基本不会重叠成一点,而是位于相近但不完全一样的位置,这正是“上下文”存在的证明。如果你想更定量地分析,还能用sklearn.metrics.silhouette_score计算每个词在不同语境下的聚类分离度。
6. 常见问题与排查技巧
6.1 双向信息泄露与“作弊”问题
很多新手把BiLSTM的输出直接接到一个softmax上,让模型去预测句子中间位置的词,这个做法是错误的。因为标准的双向LSTM会把整个句子的上下文都编码到每个时间步的输出里,模型不需要理解语义,只要看到目标词前后两个词就能轻松预测。正确做法是像我在第3节里实现的那样,训练完全独立的前向和反向LM,两个模型各自只能看到单侧上下文,推断时才把两边拼接。这个问题在Task 4的作业说明里反复提醒,我也确实看到有人因为写了错误的双向预测导致loss异常低,然后被质疑作弊。
如果你想进一步验证自己的实现没有信息泄露,可以做一个简单测试:把一个句子的目标词替换成一个随机词,然后查模型对这个位置的预测概率。正确的单向LM在预测这个词时不会“看见”词本身,所以概率不会异常高。如果发现概率极高,快去查代码是不是把目标词提前喂进去了。
6.2 训练出现NaN或loss震荡
NaN是LSTM训练里最经典的大坑。我遇到过三种情况:一是学习率太大,解决方法是把lr从0.001降到0.0003;二是数据里有空句子或全部是pad的batch,导致CrossEntropyLoss在ignore_index后得到0并且梯度传播异常,解决方法是过滤掉长度小于5的句子,并且batch内不要让pad占比超过50%;三是LSTM的初始权重不稳定,解决方法是设置一个固定的随机种子来重现问题。如果还没解决,把梯度裁剪的max_norm降到1.0,往往能救回来。
Loss震荡则很少是单点问题,更可能是batch内部句子长度差异过大。我之前用了随机batch,结果短句和长句混在一起,loss忽高忽低。按长度排序分组之后,震荡立刻缓解。
6.3 短文本和OOV的处理
如果句子太短,比如只有两三个词,模型能看到的上下文太少,提取出的向量几乎就是静态向量。在评估时,建议至少用长度超过10个词的句子,否则看不出上下文语义的变化。另外,遇到未登录词时,模型会把它映射成<unk>,同一个<unk>无论出现在哪里,向量其实是一样的,因为模型根本没有这个词的表征。处理OOV的方法要么是扩大词表阈值,要么引入字符级编码,让它能通过词形猜测语义,但Task 4不需要做得这么复杂,出现<unk>就忽略它,或者换用更常见的词。
还有一种比较隐蔽的情况:如果你的语料里同一个词只出现在一种语境里,模型也学不到这个词的多种语义。所以选语料时要有意识地让多义词出现在多种句式里,实在不行就手动造几条例句混进训练集。
6.4 显存与训练速度的平衡
双向LSTM虽然比Transformer省显存,但两套LSTM加起来还是会吃不少内存。我的解决方法是把batch size设置为32,hidden_size降到256,num_layers用2层。这样在一块普通GPU上能跑得非常顺畅。如果你用的是CPU,建议把num_layers降到1,embed_size和hidden_size都降到128,训练速度会快很多。梯度累积是另一个选项,但小模型用不上。
另外,不要忘了把模型和batch数据都放到cuda上。我见过很多初学者只把model放GPU,忘记把tensor放过去,结果跑起来不是报错就是异常慢。可以用model.to(device)然后统一在batch张量后面加.to(device),就可以避免这类低级失误。
7. 写在最后:从Task 4学到的三件事
这个Task 4做完,我最大的收获不是会写BiLSTM,而是彻底理解了“表示”和“任务”之间的关系。静态词向量是独立于任务训练出来的,而上下文词向量是模型为了某个任务(比如预测下一个词)而被迫学出来的,它带有明确的目的性。这也是为什么ELMo和BERT的向量直接拿到下游任务里效果就很好,因为它们不是为了“看起来像词向量”而训练的,而是为了预测语言本身而训练的。
第二件事是,训练代码里每一条设计都有它的意义。为什么要用两个单向LM而不是一个BiLSTM?为什么loss里要ignore pad?为什么每句话要加BOS和EOS?这些问题在写代码的时候觉得是麻烦,但等你跑完实验、看到结果之后就会发现,每一个细节都在防止模型偷懒。做NLP研究,最重要的能力不是堆模型,而是想清楚模型到底看到了什么信息。
第三,可视化带来的直觉比任何数学推导都强。当你亲眼看到同一个词在“The bank approved the loan”和“the bank of the river”里被分到两个不同的位置,你对“语境表示”这个概念的理解就再也不会回到静态词向量的思维模式了。如果你也在做类似的NLP课程作业,我建议你留出一点时间玩一玩t-SNE,把模型输出的向量丢到二维图里,你会看到很多有意思的现象。往后你再去学ELMo、BERT、GPT,就会发现它们的原理其实都是从这个简单的Task 4出发的。