简介:这份资源是面向计算机相关专业学生与深度学习学习者的实体关系联合抽取项目源码,适用于课程设计、期末大作业及项目实战练习。项目以BiLSTM-LSTM-Softmax为核心架构,通过双向长短期记忆网络捕获上下文语义,结合LSTM的门控机制建模长距离依赖,再由Softmax完成关系标签的概率分类,可应用于问答系统、信息检索等文本分析场景。压缩包共46个文件,约10.34MB,以18个Python源码文件为主体,辅以9个JSON配置、4份Markdown说明文档及若干备份与数据文件,涵盖数据预处理、模型构建、训练与评估等模块,目录按算法示例、演示、自测练习分层组织,便于按需查阅。目前已有55人学习下载。读者可借助完整源码理解联合抽取的建模流程,掌握BiLSTM与LSTM的衔接方式、Softmax输出处理及训练评估指标分析,并在此基础上完成二次开发与实验复现。
1. BiLSTM-LSTM-Softmax 做实体关系联合抽取:为什么这套组合至今仍是很多项目的起点
如果你手头有一批中文或英文的短文本,需要同时抽出「实体」和「实体之间的关系」,比如从一条新闻里识别出「张三」任职于「某公司」,或者从一句商品评论里抽出「屏幕」属于「手机」的某个属性,那么 BiLSTM-LSTM-Softmax 这套结构大概率是你绕不开的第一个能跑通的方案。它的核心思路并不复杂:用 BiLSTM 把每个字的上下文信息压进向量里,再用 LSTM 做序列级的标签解码,最后用 Softmax 在每个时间步上输出标签概率。相比现在动辄上预训练模型的方案,它的参数量小、训练快、对标注数据量的要求也低,特别适合标注样本只有几千条、又需要快速验证联合抽取可行性的场景。很多做 lstm 神经网络入门的人,第一个能跑出实体关系联合抽取结果的模型,就是这套结构。它不完美,但足够让你把「实体识别」和「关系分类」这两件事塞进一个模型里,而不是拆成两个独立管道。
2. 联合抽取到底在抽什么:从标注方案到模型输出
2.1 联合抽取和管道式抽取的本质区别
管道式抽取的做法是先用一个模型做命名实体识别,把实体边界标出来,再用另一个模型对实体对做关系分类。这种做法的好处是每一步都可以单独调优,但问题也很明显:前一步的错误会直接传给后一步,而且关系分类时只能看到实体对,看不到实体所在的完整上下文。联合抽取则是在一个模型里同时完成两件事,通常用序列标注的方式,把关系信息编码进标签里。比如用「B-ORG」「I-ORG」表示实体边界,用「B-ORG-任职于」表示这个实体参与了「任职于」这个关系。这样模型在解码时,既能看到实体边界,也能看到关系类型,上下文信息不会被切断。
BiLSTM-LSTM-Softmax 这套结构在联合抽取里的角色分工很明确。BiLSTM 层负责把输入序列的每个位置编码成一个包含前后文信息的向量,LSTM 层负责在这个向量序列上做标签解码,Softmax 层负责在每个时间步输出所有标签的概率分布。训练时用交叉熵损失,推理时用维特比解码或者直接取 argmax。这套流程在 PyTorch 或 TensorFlow 里都能用几十行代码搭出来,但真正影响效果的是标注方案的设计和标签体系的对齐。
2.2 标注方案怎么选:BIO、BIOES 还是关系标签嵌入
最常见的标注方案是 BIO,也就是 Begin、Inside、Outside 三件套。对于实体关系联合抽取,通常会在 BIO 的基础上加上关系类型,形成类似「B-实体类型-关系类型」的标签。比如「B-PER-任职于」表示这个 token 是一个人物的开始,并且这个人物参与了「任职于」关系。这种方案的好处是标签数量可控,坏处是当实体类型和关系类型都很多时,标签空间会膨胀得很快。另一种方案是 BIOES,多了 End 和 Single 两个标签,边界信息更明确,但标签数量也会增加。实际项目中,如果实体类型不超过 10 种、关系类型不超过 20 种,BIO 加关系后缀的方案通常就够用了。
还有一种做法是把关系标签单独拿出来,不嵌入到实体标签里,而是让模型在输出实体边界的同时,额外输出一个关系分类向量。这种做法在 BiLSTM-LSTM-Softmax 结构里不太常见,因为 LSTM 的输出是序列化的,每个时间步只能输出一个标签分布。如果要做关系分类,通常需要在 LSTM 后面再接一个池化层或者注意力层,把实体对的表示拼起来再分类。但这样一来,模型就不再是纯粹的序列标注结构了,训练和推理的复杂度都会上升。对于刚起步的项目,我一般建议先用 BIO 加关系后缀的方案,把流程跑通,再考虑要不要换更复杂的结构。
2.3 数据格式和标签对齐的实操细节
假设你有一批标注数据,每条样本是一个句子和一组三元组,格式是「句子\t实体1\t关系\t实体2」。你需要把它转成序列标注格式,也就是每个 token 对应一个标签。这个过程叫标签对齐,是联合抽取里最容易翻车的地方。常见的坑是实体边界和分词边界不一致,比如「北京市」被分成「北京」和「市」,但标注里「北京市」是一个整体。这时候要么调整分词器,要么在标签对齐时做特殊处理。我一般会先用分词器把句子切成 token,然后按字符级别对齐标签,因为中文实体关系抽取里字符级标注比词级标注更稳。
下面是一个标签对齐的 Python 示例,假设输入是字符列表和三元组列表,输出是每个字符的标签:
def align_labels(chars, triples, entity_types, relation_types): """ chars: 字符列表,比如 ['张', '三', '就', '职', '于', '某', '公', '司'] triples: 三元组列表,比如 [('张三', '任职于', '某公司')] entity_types: 实体类型列表,比如 ['PER', 'ORG'] relation_types: 关系类型列表,比如 ['任职于'] 返回: 每个字符对应的标签列表 """ # 初始化所有字符为 O labels = ['O'] * len(chars) # 构建字符到索引的映射 char_to_idx = {} for i, ch in enumerate(chars): if ch not in char_to_idx: char_to_idx[ch] = [] char_to_idx[ch].append(i) for subj, rel, obj in triples: # 对齐主体 subj_chars = list(subj) obj_chars = list(obj) # 这里简化处理,假设实体在句子中连续出现 # 实际项目中需要用字符串匹配找到起始位置 subj_start = find_sublist(chars, subj_chars) obj_start = find_sublist(chars, obj_chars) if subj_start is not None: # 主体第一个字符标 B,后续标 I labels[subj_start] = f'B-{entity_types[0]}-{rel}' for i in range(1, len(subj_chars)): labels[subj_start + i] = f'I-{entity_types[0]}-{rel}' if obj_start is not None: labels[obj_start] = f'B-{entity_types[1]}-{rel}' for i in range(1, len(obj_chars)): labels[obj_start + i] = f'I-{entity_types[1]}-{rel}' return labels def find_sublist(full, sub): """在 full 列表中找到 sub 列表的起始索引""" n, m = len(full), len(sub) for i in range(n - m + 1): if full[i:i+m] == sub: return i return None这段代码的逻辑很直接:先把所有字符标成 O,然后遍历每个三元组,找到主体和客体在字符序列里的起始位置,把对应位置的标签改成 B 或 I 加实体类型和关系类型。参数说明:chars是字符级输入,triples是三元组列表,entity_types和relation_types是预定义的标签集合。实际使用时,find_sublist需要处理重复字符和嵌套实体的情况,这里为了简洁做了简化。标签对齐做完后,你还需要把标签转成索引,构建标签到 id 的映射,才能喂给模型。
3. 用 PyTorch 搭一个能跑的 BiLSTM-LSTM-Softmax 模型
3.1 模型结构拆解:嵌入层、BiLSTM 层、LSTM 层、Softmax 层
整个模型可以拆成四块。第一块是嵌入层,把字符 id 映射成稠密向量。第二块是 BiLSTM 层,输入是嵌入向量序列,输出是每个时间步的前向和后向隐藏状态拼接。第三块是 LSTM 层,输入是 BiLSTM 的输出序列,输出是每个时间步的隐藏状态。第四块是 Softmax 层,把 LSTM 的输出映射到标签空间,得到每个时间步的标签概率分布。训练时用交叉熵损失,推理时取每个时间步概率最大的标签。
这里有一个容易混淆的点:BiLSTM 和 LSTM 的层数可以调整,但通常 BiLSTM 用一层就够了,LSTM 也用一层。如果数据量很大,可以加到两层,但要注意过拟合。嵌入维度一般设 128 或 256,隐藏维度一般设 256 或 512。这些参数没有绝对的最优值,需要根据你的数据规模和标签数量来调。我一般会先用 128 嵌入、256 隐藏跑一遍,看验证集上的 F1 再决定要不要加大。
3.2 完整模型代码和关键参数说明
下面是一个可以直接运行的 PyTorch 模型定义,包含嵌入层、BiLSTM、LSTM 和输出层:
import torch import torch.nn as nn class BiLSTM_LSTM_Softmax(nn.Module): def __init__(self, vocab_size, embed_dim, hidden_dim, num_tags, dropout=0.3): """ vocab_size: 字符表大小 embed_dim: 嵌入维度,常用 128 或 256 hidden_dim: LSTM 隐藏维度,常用 256 或 512 num_tags: 标签数量,等于实体类型数乘以关系类型数加 O dropout: dropout 比例,常用 0.3 到 0.5 """ super().__init__() # 嵌入层,padding_idx 设为 0,避免 padding 参与梯度更新 self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) # BiLSTM 层,batch_first=True 表示输入格式为 (batch, seq_len, embed_dim) self.bilstm = nn.LSTM(embed_dim, hidden_dim, batch_first=True, bidirectional=True) # LSTM 层,输入维度是 BiLSTM 输出的两倍隐藏维度 self.lstm = nn.LSTM(hidden_dim * 2, hidden_dim, batch_first=True) # 输出层,把隐藏状态映射到标签空间 self.fc = nn.Linear(hidden_dim, num_tags) # dropout 层,防止过拟合 self.dropout = nn.Dropout(dropout) def forward(self, x, mask=None): """ x: 输入字符 id 序列,形状 (batch, seq_len) mask: 可选的 padding mask,形状 (batch, seq_len) 返回: 每个时间步的标签概率分布,形状 (batch, seq_len, num_tags) """ # 嵌入层 embed = self.embedding(x) # (batch, seq_len, embed_dim) embed = self.dropout(embed) # BiLSTM 层 bilstm_out, _ = self.bilstm(embed) # (batch, seq_len, hidden_dim * 2) bilstm_out = self.dropout(bilstm_out) # LSTM 层 lstm_out, _ = self.lstm(bilstm_out) # (batch, seq_len, hidden_dim) lstm_out = self.dropout(lstm_out) # 输出层 logits = self.fc(lstm_out) # (batch, seq_len, num_tags) return logits这段代码的关键参数有三个:embed_dim、hidden_dim和dropout。embed_dim控制字符向量的表达能力,太小会导致欠拟合,太大会增加参数量和过拟合风险。hidden_dim控制 LSTM 的记忆容量,一般设成embed_dim的两倍左右。dropout在嵌入层、BiLSTM 输出和 LSTM 输出后各加了一次,比例从 0.3 到 0.5 都可以试。注意padding_idx=0这个设置,它让 padding 位置的嵌入向量不参与梯度更新,避免 padding 影响模型学习。
3.3 训练循环和损失函数的选择
训练时用交叉熵损失,但要注意忽略 padding 位置的损失。PyTorch 的CrossEntropyLoss有一个ignore_index参数,可以把 padding 对应的标签设成 -100,然后在计算损失时忽略掉。优化器一般用 Adam,学习率设 1e-3 到 1e-4,配合学习率衰减。batch size 根据显存大小设,一般 32 或 64。训练轮数看验证集 F1 什么时候不再上升,通常 20 到 50 轮就够了。
下面是一个简化的训练循环:
def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss = 0 for batch in dataloader: # 假设 batch 包含 input_ids, labels, mask input_ids = batch['input_ids'].to(device) labels = batch['labels'].to(device) mask = batch['mask'].to(device) optimizer.zero_grad() logits = model(input_ids, mask) # (batch, seq_len, num_tags) # 把 logits 和 labels 展平,忽略 padding 位置 logits_flat = logits.view(-1, logits.size(-1)) labels_flat = labels.view(-1) loss = criterion(logits_flat, labels_flat) loss.backward() # 梯度裁剪,防止梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() total_loss += loss.item() return total_loss / len(dataloader)这段代码里有两个关键点:一是labels里 padding 位置要设成 -100,这样CrossEntropyLoss会自动忽略;二是梯度裁剪,LSTM 类模型在长序列上容易梯度爆炸,max_norm=5.0是一个常用的经验值。学习率我一般先用 1e-3,如果损失震荡就降到 1e-4。batch size 如果显存不够,可以降到 16 或 8,但太小会导致训练不稳定。
4. 避坑与排查:联合抽取里最容易翻车的五个地方
4.1 标签对齐错位导致模型学不到东西
现象:训练损失一直不下降,验证集 F1 在 0.1 左右徘徊。原因:标签对齐时实体边界和分词边界不一致,或者三元组里的实体在句子中找不到对应位置,导致大量标签被错误地标成 O。解决:先用脚本检查每条样本的标签对齐结果,把找不到实体的样本单独拿出来看。如果是分词问题,换成字符级标注;如果是实体嵌套,考虑用 BIOES 或者多层标签。
4.2 实体类型和关系类型组合爆炸
现象:标签数量超过 200,模型参数量暴涨,训练极慢。原因:实体类型和关系类型做笛卡尔积,比如 10 种实体类型和 20 种关系类型组合出 200 个标签。解决:先做关系类型剪枝,只保留高频关系;或者把关系分类从序列标注里拆出来,用实体边界加关系向量的方式做。如果一定要用联合标注,可以考虑分层 Softmax 或者把关系类型嵌入到标签向量里,减少输出层参数量。
4.3 BiLSTM 和 LSTM 的维度不匹配
现象:模型定义时报维度错误,或者前向传播时张量形状对不上。原因:BiLSTM 的输出维度是hidden_dim * 2,而 LSTM 的输入维度需要和它一致。如果 BiLSTM 的hidden_dim设成 256,LSTM 的输入维度就必须是 512。解决:在定义 LSTM 时显式指定input_size=hidden_dim * 2,或者在 BiLSTM 后面加一个线性层把维度降下来。我一般直接在 LSTM 的input_size里写清楚,避免混淆。
4.4 学习率过大导致损失震荡
现象:训练损失忽高忽低,验证集 F1 波动很大。原因:LSTM 类模型对学习率比较敏感,尤其是 BiLSTM 加 LSTM 的堆叠结构,梯度容易爆炸。解决:把学习率从 1e-3 降到 1e-4,加梯度裁剪,或者用学习率预热。如果还是震荡,检查一下输入序列长度是不是太长,超过 200 的序列可以考虑截断或者分段。
4.5 推理时忘记加 mask 导致 padding 位置输出乱码
现象:推理结果里出现大量无意义的标签,或者实体边界错乱。原因:推理时没有把 padding 位置的输出屏蔽掉,Softmax 在 padding 位置也输出了概率。解决:推理时用 mask 把 padding 位置的 logits 设成负无穷,再取 argmax。或者在解码时只取有效长度内的标签,忽略后面的输出。
5. 进阶技巧:用 CRF 替代 Softmax 和用预训练嵌入提升 F1
5.1 为什么 Softmax 在序列标注里不够用
Softmax 在每个时间步独立输出标签概率,不考虑标签之间的转移约束。比如 BIO 标注里,I-ORG 不能出现在 B-PER 后面,但 Softmax 不知道这个约束,可能会输出非法标签序列。CRF 层则显式建模标签转移概率,在解码时用维特比算法找全局最优路径。在 BiLSTM-LSTM-Softmax 结构里,把 Softmax 换成 CRF 通常能提升 2 到 5 个点的 F1,尤其是当标签数量多、标签之间约束强的时候。
下面是一个用torchcrf替换 Softmax 的示例:
from torchcrf import CRF class BiLSTM_LSTM_CRF(nn.Module): def __init__(self, vocab_size, embed_dim, hidden_dim, num_tags, dropout=0.3): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.bilstm = nn.LSTM(embed_dim, hidden_dim, batch_first=True, bidirectional=True) self.lstm = nn.LSTM(hidden_dim * 2, hidden_dim, batch_first=True) self.fc = nn.Linear(hidden_dim, num_tags) self.crf = CRF(num_tags, batch_first=True) self.dropout = nn.Dropout(dropout) def forward(self, x, mask=None): embed = self.dropout(self.embedding(x)) bilstm_out, _ = self.bilstm(embed) bilstm_out = self.dropout(bilstm_out) lstm_out, _ = self.lstm(bilstm_out) lstm_out = self.dropout(lstm_out) logits = self.fc(lstm_out) return logits def loss(self, x, tags, mask): logits = self.forward(x, mask) # CRF 的损失函数是负对数似然 return -self.crf(logits, tags, mask=mask, reduction='mean') def decode(self, x, mask): logits = self.forward(x, mask) return self.crf.decode(logits, mask=mask)这段代码把 Softmax 换成了 CRF,损失函数和推理方式都变了。CRF的batch_first=True要和 LSTM 保持一致。loss方法返回的是负对数似然,decode方法返回的是最优标签序列。注意 CRF 的训练速度比 Softmax 慢,因为要计算配分函数,但推理时用维特比解码,速度还可以接受。
5.2 用预训练字符嵌入替代随机初始化
随机初始化的嵌入在标注数据少的时候容易过拟合。一个实用的技巧是用预训练的字符嵌入,比如在大量无标注文本上跑 Word2Vec 或者用 BERT 的字符级输出。如果不想引入 BERT 的复杂度,可以用gensim训练一个字符级 Word2Vec,然后把嵌入矩阵加载到nn.Embedding里。具体做法是:先用gensim训练 Word2Vec,得到每个字符的向量,然后构建一个嵌入矩阵,行数等于字符表大小,列数等于嵌入维度。加载时用self.embedding.weight.data.copy_(pretrained_embed),并且可以选择冻结嵌入层或者微调。
5.3 验证方法:用实体级别 F1 而不是标签级别准确率
标签级别的准确率在类别不平衡时会虚高,因为 O 标签占大多数。真正能反映联合抽取效果的是实体级别 F1,也就是把预测的实体边界和关系类型跟标注做精确匹配。计算方式是:先解码出标签序列,再还原成三元组,然后跟标注三元组做比较。精确率是预测正确的三元组数除以预测三元组总数,召回率是预测正确的三元组数除以标注三元组总数,F1 是两者的调和平均。我一般会在验证集上每轮算一次实体级别 F1,取最高的模型保存。
5.4 一个我踩过的坑:序列长度不一致导致的 batch 内 padding 问题
早期做这个模型时,我直接把不同长度的序列拼成一个 batch,没有做 padding 对齐,结果模型报错。后来加了 padding,但忘了在损失函数里忽略 padding 位置,导致模型在 padding 上浪费了大量容量。再后来加了 mask,但推理时又忘了用 mask,导致输出里混入了 padding 位置的标签。这个坑我踩了三次才彻底解决。现在的习惯是:数据预处理时统一做 padding 和 mask,训练和推理时都显式传入 mask,损失函数里用ignore_index=-100忽略 padding。这套流程虽然繁琐,但能避免大部分低级错误。希望帮到你。
本文还有配套的精品资源,点击获取