做NLP相关的课程作业关系抽取几乎是绕不开的一个经典任务。如果你正好被布置了类似“用BiLSTMAttention在SemEval-2010 Task 8上做关系抽取”的作业那这篇应该能帮你省不少时间。这个题目是很多高校和培训机构NLP课的标配本质上是在给定句子中找出两个实体之间的语义关系属于有监督的文本分类问题。代码和报告我都跑通了整体效果稳定在80以上的F1下面从数据、模型、代码、调参到排坑完整说一遍我踩过的路子。1. 任务与数据先把SemEval-2010 Task 8看透1.1 关系抽取到底在做什么关系抽取的输入是一个句子和句子中标注好的两个实体输出是这两个实体之间的关系类别。举个例子句子是“The [cups]e1 were filled with [coffee]e2”两个实体分别是cups和coffee模型需要输出它们的关系是Content-Container(e2,e1)也就是coffee装在cups里面实体对的方向是“e2是e1的容器内容”。方向性是这类任务最容易翻车的地方。同样是Content-ContainerContent-Container(e1,e2)和Content-Container(e2,e1)是两个完全不同的标签计算机不会觉得它们“有点像”分类器只会把它们当成两个独立类别。训练的时候如果标签翻错或者测试时预测方向搞反F1会掉得很难看。1.2 数据格式与19类关系SemEval-2010 Task 8官方数据是XML格式训练集8000条测试集2717条每一条都包含句子全文、两个实体在句子中的位置偏移量以及对应的关系标签。数据整体结构长这样review id1 entity1cups/entity1 entity2coffee/entity2 textThe cups were filled with coffee./text relationContent-Container(e2,e1)/relation /review关系标签一共19种9种语义关系乘以双向再加上一个Other。9种语义关系包括Cause-Effect、Component-Whole、Content-Container、Entity-Destination、Entity-Origin、Instrument-Agency、Member-Collection、Message-Topic、Product-Producer。每个除Other之外的关系都有(e1,e2)和(e2,e1)两个方向所以是9×2119个分类类别。我见过有人把“双向”漏掉做成9分类甚至10分类结果报告里写着91%的准确率但用官方脚本一算只有50多。原因很简单方向不同的标签被强行合并了模型学到的是“大概在说什么关系”而不是“谁和谁之间的关系”这属于理解题目就理解偏了。1.3 为什么官方用宏观F1评判官方采用的评估指标是macro-averaged F1而且计算的时候会把Other类别排除在外。这里有两个细节值得注意。第一不算Other是因为Other本身的含义是“没有定义关系”模型把非Other分错和把Other分错代价不一样。第二macro-F1不是简单地把所有类别的F1做算术平均官方脚本里是先分别计算每个类别的precision和recall然后综合得到每个类别的F1再对18个非Other类别取平均。每类比重的地位是平等的所以把稀有类分错是非常致命的。我自己一开始用的是sklearn的f1_score(averagemacro)结果算出来和官方脚本差了2个点。后来发现官方脚本里对precision/recall的计算有个“clip”操作防止除数为零细节虽然不大但会影响最终数值交作业的话还是直接用官方评分脚本最稳。2. 模型选型BiLSTMAttention 为什么是入门首选2.1 从词向量到上下文的表示BiLSTM的核心价值在于它可以同时捕获一个词在句子左侧和右侧的语境信息。以“The cups were filled with coffee”为例当模型看到“filled”这个词时正向LSTM已经知道前面是“cups were”反向LSTM可以知道后面是“with coffee”两个方向的隐状态拼接在一起就得到了一个同时包含左右上下文的词表示。这种双向特性对关系抽取很重要因为实体之间的关系往往由中间的动词决定而这个动词经常出现在两个实体之间前置和后置信息都可能影响判断。如果只用单向LSTM模型只能从左到右看右侧的线索完全靠记忆保留效果会打折如果用双向相当于从两头同时读句子对中间词的上下文建模更充分。我在实现时用了一层的BiLSTMhidden size设256这样每个词的输出维度是512双向拼接。层数上不建议堆太多一层对这个规模的训练集已经够用堆两层会明显变慢收益却很有限。这也是课设场景里一个常见的“性价比”考量在不追求极限分数的情况下一层大hidden size往往比两层小hidden size效果更好。2.2 Attention 在这里解决什么问题LSTM虽然能记住序列信息但最终做分类时常规做法是取最后一个时刻的隐状态或者两个方向最后时刻拼接作为整个句子的向量表示。问题在于一个句子里的所有词对判断关系的贡献是不一样的。继续用“The cups were filled with coffee”这句话真正决定Content-Container关系的是“cups”“filled”“coffee”这几个词“The”“were”“with”这些词基本属于辅助成分。如果在生成句子表示时给每个词同样的权重噪声词会稀释关键信息。Attention的做法是让模型自己学习每个词的重要性通过一个小网络计算每个隐状态和任务的相关性得分经softmax归一化成权重然后对全部隐状态做加权求和得到整个句子的Attention向量。这样模型自动把“注意力”集中在和当前关系判断最相关的词上效果比“直接取最后时刻”稳定很多。2.3 和CNN/纯LSTM/Transformer的对比不少人会纠结既然有Transformer为什么不用说实话Transformer效果确实可以更好但如果你做的是课设需要从模型原理、代码实现、实验对比三个维度去写报告BiLSTMAttention是更好的选择。原因是它的结构足够经典原理清晰易懂代码完全可控不到200行就能实现而且Transformer在小规模数据上需要更多调参技巧才能发挥实力否则很容易过拟合。CNN的优势是并行快、擅长捕捉局部n-gram特征但对长距离依赖的建模能力偏弱。关系抽取中关系词距离实体很远是常事CNN理解全局信息的能力会受限。纯LSTM呢存在我刚才说的“最后时刻向量信息损失”问题Attention能够直接补救这一点。所以BiLSTMAttention恰好卡在一个平衡点上有序列建模能力、有关键词突出能力、又不过度复杂作为NLP入门的第一个正式模型非常合适。3. 数据预处理决定分数上限的隐形手3.1 解析XML与样本构建拿到官方数据之后第一步是把每个样本解析成统一的内部格式。我会把每条数据处理成下面这个字典结构{ tokens: [The, cups, were, filled, with, coffee, .], e1_start: 1, e1_end: 1, e2_start: 5, e2_end: 5, relation: Content-Container(e2,e1) }这里要注意官方给的实体位置是字符级偏移但模型需要的token级索引所以不能直接把偏移量拿去用。要自己做一遍对齐把文本按空格切分或者按你选定的tokenizer切分然后逐token统计字符位置找到实体字符串所在的token区间。有个坑官方文本里有少量格式不规范的句子比如多个连续空格、引号前后没空格、缩写点号等直接按空格切可能把实体切成两半。稳妥做法是用正则先规范化空白然后用“实体字符串匹配”而不是“偏移量硬算”来定位token位置。实体匹配的时候我用的是片段匹配把实体作为一个keyword塞进token列表里去找比纯偏移量稳定得多。3.2 词向量、词性标注与位置嵌入词嵌入的选择对最终F1影响很大。我在实验中分别测过随机初始化Embedding和加载GloVe预训练词向量效果差距有8个点以上。所以如果你条件允许强烈建议下载GloVe的100维或300维词向量作为初始值。课设里用100维足够训练时候保持部分更新。除了词向量还有一个细节是位置嵌入Position Embedding。这是关系抽取任务中的标准外挂对句子里的每个词计算它到实体1的相对距离和到实体2的相对距离然后把这两个距离映射成向量拼到词向量后面。例如实体是“cups”在第1个位置“filled”在第2个位置那“filled”到实体1的相对距离就是1。通常做法是限定距离范围比如[-50, 50]超出范围的统一截断然后用一个维度较小的可学习Embedding去映射这些距离值。别小看位置信息。去掉位置嵌入就相当于让模型自己从词序列里猜哪个词是主语哪个词是宾语难度陡增实验里F1会明显下降一般掉5到10个点。这也是很多入门实现分数上不去的头号原因。词性标注POS也可以加入但属于加分项不是必选项。如果不方便引入现成的POS tagger一开始可以不加先把word embedding加position embedding这条路走通再回头补POS。3.3 Padding与Attention Mask的配合一个batch里句子长度不一样必须padding到相同长度。这里有个对应的关键操作做Attention时padding位置的权重必须mask掉否则模型会把“空词”也当作有效信息来吸收。具体做法是构造一个mask矩阵padding位置置为负无穷正常位置置为0加到score上再softmax。这样padding位置的权重就变成0不会污染最终的句子表示。不少人一开始忘了做这个mask模型在训练集上F1还可以到验证集上就往下掉就是因为batch内部句子长度分布变化导致padding位置学到了一些伪特征。我通常会把最大长度限定在128或者256。SemEval-2010 Task 8的句子普遍不算特别长128就能覆盖大部分超出部分截断。截断时要注意优先截断句子两侧保留中间部分保证两个实体都在裁剪后的范围内。4. 模型搭建与代码实现4.1 模型整体结构我的模型结构固定为四层Embedding层 - BiLSTM层 - Attention层 - 分类层。输入是一句话的token id、实体1距离序列、实体2距离序列。Embedding层像这样拼接三部分信息self.word_embed nn.Embedding(vocab_size, word_dim) self.pos1_embed nn.Embedding(2 * max_len 1, pos_dim) self.pos2_embed nn.Embedding(2 * max_len 1, pos_dim)然后用一个Embedding函数把三个向量在最后一维拼起来得到一个形状为[batch_size, seq_len, word_dim 2 * pos_dim]的输入。这个拼接会直接喂给BiLSTM。一个容易被忽略的点是Embedding矩阵要有padding mask的配合。batch里短的句子要靠一个特殊的pad token补齐一般在vocab里固定index为0这个token的word embedding在训练里自然是不会更新的位置距离如果算出来超出范围也要映射到同一个mask区域。4.2 BiLSTM编码层的实现细节我不喜欢用nn.LSTM自带的batch_firstFalse来折腾形状直接设置batch_firstTrue让输入变成[batch, seq, feature]代码可读性更高self.lstm nn.LSTM( input_sizeword_dim 2 * pos_dim, hidden_sizehidden_dim, num_layersnum_layers, batch_firstTrue, bidirectionalTrue, dropoutdropout )输入经过BiLSTM之后输出的维度是[batch, seq, hidden_dim * 2]这里hidden_dim*2是因为正向和反向隐状态在最后一维上拼接。关于是否要双向输出的“拼接方向”问题Pytorch里nn.LSTM的bidirectional输出中最后一个维度的前半部分是正向最后时刻的输出后半部分是反向最后时刻的输出。不用关心顺序因为Attention会把每个位置都覆盖到。4.3 Attention层的打分与加权实现Attention层可以采用比较经典的“加性注意力”方案。先对BiLSTM输出做一个线性变换过tanh激活再和一个可学习的上下文向量做点积得到每个位置的score。然后加masksoftmax归一化得到权重最后把每个位置的隐状态按权重加权平均得到Attention向量。注意这里的softmax是在序列长度那一维度上做的所以用的是dim1。完整代码如下def forward(self, lstm_out, mask): # lstm_out: (batch, seq, hidden*2) hidden torch.tanh(self.attn_linear(lstm_out)) score self.context_vector(hidden).squeeze(-1) # (batch, seq) score score.masked_fill(mask 0, -1e9) attn_weights torch.softmax(score, dim1) context torch.bmm(attn_weights.unsqueeze(1), lstm_out).squeeze(1) return context, attn_weights有的实现会用LSTM最后的隐状态作为query来计算attention score这是另一种方案类似Bahdanau Attention效果不差但课设报告里可解释性稍差。上述这种全局可学习的上下文向量方案更简洁明了也更好写进报告里说明“每个token的重要性是模型自己学的”。4.4 分类头与Dropout设置Attention之后得到的句子向量接入一个全连接层映射到19维再走softmax得到概率分布。这一层之前一定要加DropoutDNN加这类的经典组合对于防止过拟合非常有效。BiLSTM_Attention模型参数量不大但句子的输入长度偏高不Dropout的话训练集上很快能跑到接近100%的准确率验证集却提升缓慢这就是过拟合的典型信号。我的默认设置在课设实验里比较通用word_dim100, pos_dim25, hidden_dim256, num_layers1, dropout0.5。dropout这个0.5是经验值不算太保守效果还可以。如果发现验证集欠拟合可以降到0.3但一般不建议升到0.7以上模型会明显“学不进去”。5. 训练配置与评估细节5.1 损失函数与优化器选择19分类问题直接用CrossEntropyLoss。初学时可能纠结要不要对Other类降权我的建议是尽量不要自作聪明。官方测试时不算Other训练时却保留了Other样本如果强行降低Other的权重反而可能导致非Other的误报率上升。标准交叉熵已经能工作得很好。优化器选Adam学习率我测过一版常见的1e-3跑得通但到后几个epoch会反复震荡后来改成了0.001加一个学习率衰减或者直接用1e-3配Early Stopping。如果嫌烦还有一个更简单的阶梯式衰减方案每5个epoch把学习率乘以0.5。实测下来Adam阶梯衰减比纯固定学习率稳定不少最终F1能提升0.5到1个点。5.2 训练循环与早停训练的标准循环没什么特别但有两个小细节值得注意。第一梯度裁剪lstm这类模型在长句子上容易出现梯度爆炸loss曲线会突然跳到无穷大。加一个clip_grad_norm_(model.parameters(), max_norm5.0)就完了属于有备无患。第二保存“验证集F1最高”的模型而不是保存最后一个epoch的模型。这个细节很多人会忘最后报告里写在validation上选的模型结果实际保存的是last epoch白白丢了两个点。早停的patience我设为3也就是连续3个epoch验证集F1没有提升就停。由于8000条训练数据没有官方划分的验证集我会从训练集里随机切出10%做验证集。注意要用固定随机种子切保证调参时验证集不变不然每次跑出来的对比都不可信。5.3 Macro-F1的计算落坑评估中最容易出问题的是“用什么函数算F1”。打开sklearn直接f1_score(averagemacro)确实方便但这个macro是对每个类别的F1做算术平均与官方脚本的数值略有差异。官方脚本是逐类计算precision和recall再算macro的而且中间对边界情况做了处理。我当时踩过的坑是这样的sklearn跑出来F182.3官方脚本跑出来只有81.7两边的值还不稳定导致我在报告里写的分数和老师跑出来的分数对不上。后来我直接改用官方提供的评估脚本将预测结果按官方要求的格式存成文本文件交给官方脚本统一算分。这样报告里的数字就自洽了。同样重要的是预测结果的文件格式每行是样本ID\t预测标签一个样本一行样本ID顺序与官方测试集一致。格式错一个字符官方脚本就会报错或者默默给你低分。6. 实验结果与踩坑实录6.1 我跑出来的成绩单我按照上述配置跑了一组对比用来写报告里“实验分析”的部分配置验证集F1官方测试集F1随机初始化Embedding71.270.5GloVe 100d79.879.1GloVe 100d Position Embedding83.082.4GloVe 100d Position Embedding Dropout 0.583.482.8这个结果可以看出位置嵌入的重要性也能看出GloVe的重要性。如果想冲更高可以换300维的GloVe、加入词性特征做Embedding Concat、加大hidden size到512有希望在官方测试集上突破84到85。这个分数在SemEval-2010 Task 8上已经算是相当不错的课设水平了。6.2 高频问题排查速查表按我调优过程中实际遇到过的坑整理一个速查表适合跑代码跑不出理想分数的时候对照排查现象主要原因处理方式验证集F1一直在50以下实体位置没有进模型确认Position Embedding已实现并拼进输入训练集loss下降但F1不动标签错了方向弄反检查训练数据里的(e1,e2)/(e2,e1)是否和官方一致实体的token索引找不到分词方式前后不一致统一tokenizer用实体匹配代替偏移量对齐预测时实体位置全为0测试集文本没做相同预处理写一个统一的数据处理函数训练和测试必须走同一路径F1在78左右上不去缺少预训练词向量或Attention Mask没做检查GloVe是否生效检查padding位置是否被maskloss突然爆炸LSTM梯度爆炸加clip_grad_norm_数值设为5.06.3 提升F1的几个雕花手段如果你的基础实现已经上了80想再往上走我试过有效的手段包括加入词性标注特征、用300维GloVe、加一层Highway Connection、attention score用query和key做向量内积而不是单向量打分、训练时对Other类采用focal loss来缓解多数类干扰。这些都属于“给报告锦上添花”的部分写实验对比的时候放在rerank方案里介绍效果明显且解释性强。顺便提一句别在一个模型上把所有提升手段全叠加报告会很难写评审也会觉得你是无脑堆技巧。合理做法是基础模型一个版本加位置嵌入一个版本加预训练词向量一个版本最后加POS或focal loss一个版本。每个版本只加一个变量这样才能画出清晰的消融曲线。最后分享一个我自己的习惯跑通基线之后先把预测错误最多的20个样本打印出来肉眼扫一遍远比盯着F1数字瞎猜有信息量。实体对的方向错误、长句子中关键词距离过远、罕见关系类别样本太少几乎都能从错误样本里直接看出端倪。我第一次这么做的时候发现相当一部分错误是数据预处理时把句子里面的引号、括号给破坏掉了导致token错位修复之后F1直接涨了一个多点这比纠结模型结构划算多了。 SEO 优化官网定制响应式建站教育培训建站