简介:这份资源面向自然语言处理初学者与知识图谱方向的学习者,聚焦实体关系抽取这一核心任务,提供基于注意力机制与双向LSTM结合的AttBiLSTM模型实现方案,可用于从文本中识别实体并判定其语义关系,为知识图谱构建提供原料。压缩包共5个文件,全部为Python源码,整体约6KB,涵盖模型架构定义、数据加载与中文处理工具、训练器与配置模块,结构紧凑便于逐模块研读。已有255人学习下载,说明该方案在入门实践中具有一定参考价值。读者可借此理解BiLSTM如何双向捕获上下文、注意力机制如何聚焦关系相关词,并掌握NER与关系抽取的衔接流程、损失函数与优化器选择、精确率召回率F1评估等关键环节,进而将模型迁移至搜索引擎、问答系统等实际场景。
1. 从一份压缩包说起:AttBiLSTM 做实体关系抽取到底难在哪
你拿到一个名为「利用AttBiLSTM实现实体关系抽取.zip」的压缩包,第一反应大概率是:BiLSTM 我熟,注意力机制我也懂,拼起来不就是个分类模型吗?但真正跑过关系抽取任务的人都知道,坑不在模型结构本身,而在数据标注格式、实体对边界、关系类别不均衡这三件事上。AttBiLSTM 的核心思路是:用双向 LSTM 分别编码实体所在句子的上下文,再通过注意力层自动学习哪些词对判断两个实体的关系最重要,最后接 softmax 做关系分类。它解决的是「一句话里两个实体之间到底是什么关系」这个问题,适合做知识图谱构建、信息抽取、问答系统底层支撑的从业者。本文不假设你手里有源码,而是从零把这条链路讲清楚:数据怎么准备、模型怎么搭、参数怎么调、坑在哪。
2. 数据准备与标注格式:关系抽取的第一道门槛
2.1 关系抽取的数据长什么样
实体关系抽取的输入是一条句子加上两个实体在句中的位置,输出是预定义的关系类别。常见的数据格式有两种:一种是 JSON 行格式,每行一条样本,包含text、head、tail、relation四个字段;另一种是 CoNLL 风格的列格式,每行一个 token,用列标记实体边界和关系标签。我一般推荐 JSON 行格式,因为可读性好、解析方便、跟后续的 PyTorch Dataset 对接自然。
一个典型的样本长这样:
{ "text": "某公司的创始人在某高校完成了博士学业", "head": {"name": "某公司", "pos": [0, 3]}, "tail": {"name": "某高校", "pos": [8, 11]}, "relation": "创始人毕业于" }这里pos是实体在 token 序列中的起止索引,左闭右开。注意:如果你的文本是中文,分词方式直接决定了pos的含义。按字切分和按词切分,索引完全不一样。我踩过的坑是:预处理时按字切,训练时按词切,结果实体位置全错位,模型学了个寂寞。
2.2 用 Python 构建 Dataset 和 DataLoader
下面是一个可直接复用的 Dataset 实现,假设你已经把数据整理成了 JSON 行格式,并且用字级别切分:
import json import torch from torch.utils.data import Dataset, DataLoader from transformers import BertTokenizer class RelationDataset(Dataset): def __init__(self, file_path, tokenizer, max_len=128, relation2id=None): self.samples = [] self.tokenizer = tokenizer self.max_len = max_len # relation2id 可以从训练集统计生成,保证类别映射一致 self.relation2id = relation2id or {} with open(file_path, 'r', encoding='utf-8') as f: for line in f: item = json.loads(line.strip()) self.samples.append(item) # 如果没有传入映射,自动构建 if not self.relation2id: relations = sorted(set(s['relation'] for s in self.samples)) self.relation2id = {r: i for i, r in enumerate(relations)} def __len__(self): return len(self.samples) def __getitem__(self, idx): item = self.samples[idx] text = item['text'] head_pos = item['head']['pos'] tail_pos = item['tail']['pos'] relation = item['relation'] # 按字切分,构建 token 序列 tokens = list(text) # 用 BERT tokenizer 做子词切分,同时保留字符到子词的映射 encoding = self.tokenizer( tokens, is_split_into_words=True, max_length=self.max_len, padding='max_length', truncation=True, return_tensors='pt' ) # 获取每个字符对应的子词索引 word_ids = encoding.word_ids(batch_index=0) # 定位 head 和 tail 在子词序列中的位置 head_start = None head_end = None tail_start = None tail_end = None for subword_idx, word_idx in enumerate(word_ids): if word_idx is None: continue if word_idx == head_pos[0] and head_start is None: head_start = subword_idx if word_idx == head_pos[1] - 1: head_end = subword_idx if word_idx == tail_pos[0] and tail_start is None: tail_start = subword_idx if word_idx == tail_pos[1] - 1: tail_end = subword_idx # 如果实体被截断,用 CLS 位置兜底,实际项目中应过滤这类样本 head_start = head_start if head_start is not None else 0 head_end = head_end if head_end is not None else 0 tail_start = tail_start if tail_start is not None else 0 tail_end = tail_end if tail_end is not None else 0 return { 'input_ids': encoding['input_ids'].squeeze(0), 'attention_mask': encoding['attention_mask'].squeeze(0), 'head_start': torch.tensor(head_start, dtype=torch.long), 'head_end': torch.tensor(head_end, dtype=torch.long), 'tail_start': torch.tensor(tail_start, dtype=torch.long), 'tail_end': torch.tensor(tail_end, dtype=torch.long), 'label': torch.tensor(self.relation2id[relation], dtype=torch.long) }这段代码的关键点有三个。第一,is_split_into_words=True告诉 tokenizer 输入已经是切好的 token 列表,它会自动处理子词拼接。第二,word_ids()返回每个子词对应的原始词索引,这是定位实体边界的核心。第三,实体被截断时我用 0 兜底,但实际训练中应该直接过滤掉这类样本,否则模型会学到错误的实体位置。
参数说明:max_len=128对大多数中文句子够用,如果你的文本偏长可以调到 256,但注意显存占用会平方级增长。relation2id必须从训练集生成后固定下来,验证集和测试集直接用这个映射,不能各自重建。
2.3 关系类别不均衡的处理策略
关系抽取数据集里,不同关系类别的样本数往往差几十倍。比如「创始人毕业于」可能有几千条,「竞争对手是」可能只有几十条。直接训练的话,模型会倾向于预测多数类。常见做法有三种:一是在 loss 里加类别权重,用torch.nn.CrossEntropyLoss(weight=weights);二是对少数类做过采样;三是用 focal loss 替代交叉熵。我一般先用类别权重,简单有效,权重设为总样本数 / (类别数 * 该类样本数)。
3. AttBiLSTM 模型搭建:从嵌入层到注意力输出
3.1 为什么是 BiLSTM + 注意力,而不是纯 BERT
你可能会问:既然用了 BERT tokenizer,为什么不直接上 BERT 做分类?答案是:AttBiLSTM 在中小规模数据上往往比直接微调 BERT 更稳。原因在于 BiLSTM 对序列顺序的建模更显式,注意力层能聚焦到实体周围的上下文词,而 BERT 的 [CLS] 向量在数据量不足时容易过拟合。另外,AttBiLSTM 的参数量远小于 BERT,训练速度快,适合快速迭代和验证想法。当然,如果你的数据量超过十万条,直接微调 BERT 或 RoBERTa 效果会更好。
3.2 模型代码实现与维度说明
import torch import torch.nn as nn import torch.nn.functional as F class AttBiLSTM(nn.Module): def __init__(self, vocab_size, embed_dim=128, hidden_dim=256, num_relations=10, dropout=0.3, num_layers=1): super(AttBiLSTM, self).__init__() # 嵌入层:vocab_size 是词表大小,embed_dim 是词向量维度 self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) # 双向 LSTM:输入维度 embed_dim,隐藏维度 hidden_dim # 双向输出维度是 hidden_dim * 2 self.bilstm = nn.LSTM( input_size=embed_dim, hidden_size=hidden_dim, num_layers=num_layers, batch_first=True, bidirectional=True, dropout=dropout if num_layers > 1 else 0 ) # 注意力层:对 BiLSTM 输出做加权求和 # 注意力打分用一个线性层,输入维度 hidden_dim * 2,输出维度 1 self.attention = nn.Linear(hidden_dim * 2, 1) # 分类层:输入是注意力加权后的向量拼接实体位置向量 # 这里用 head 和 tail 的 BiLSTM 输出拼接,维度 hidden_dim * 4 self.classifier = nn.Sequential( nn.Linear(hidden_dim * 4, hidden_dim * 2), nn.ReLU(), nn.Dropout(dropout), nn.Linear(hidden_dim * 2, num_relations) ) self.dropout = nn.Dropout(dropout) def forward(self, input_ids, attention_mask, head_start, head_end, tail_start, tail_end): # input_ids: (batch, seq_len) # attention_mask: (batch, seq_len) # head_start/head_end/tail_start/tail_end: (batch,) # 嵌入层输出: (batch, seq_len, embed_dim) embeds = self.embedding(input_ids) embeds = self.dropout(embeds) # BiLSTM 输出: (batch, seq_len, hidden_dim * 2) lstm_out, _ = self.bilstm(embeds) # 注意力机制:计算每个位置的权重 # attn_scores: (batch, seq_len, 1) attn_scores = self.attention(lstm_out) # 用 attention_mask 屏蔽 padding 位置 attn_scores = attn_scores.squeeze(-1) # (batch, seq_len) attn_scores = attn_scores.masked_fill(attention_mask == 0, -1e9) attn_weights = F.softmax(attn_scores, dim=-1) # (batch, seq_len) # 加权求和得到句子表示: (batch, hidden_dim * 2) sentence_repr = torch.bmm(attn_weights.unsqueeze(1), lstm_out).squeeze(1) # 提取 head 和 tail 位置的 BiLSTM 输出 # 用 gather 按位置索引取向量 batch_size = input_ids.size(0) head_start_vec = lstm_out[torch.arange(batch_size), head_start] # (batch, hidden_dim*2) head_end_vec = lstm_out[torch.arange(batch_size), head_end] tail_start_vec = lstm_out[torch.arange(batch_size), tail_start] tail_end_vec = lstm_out[torch.arange(batch_size), tail_end] # 拼接:句子表示 + head 起止 + tail 起止 # 实际中也可以只用 head_start 和 tail_start,或取平均 head_vec = (head_start_vec + head_end_vec) / 2 tail_vec = (tail_start_vec + tail_end_vec) / 2 # 最终特征: (batch, hidden_dim * 4) final_repr = torch.cat([sentence_repr, head_vec, tail_vec], dim=-1) # 分类 logits = self.classifier(final_repr) return logits逐段说明:嵌入层用padding_idx=0让 padding 位置的向量不参与梯度更新。BiLSTM 的batch_first=True让输入维度是(batch, seq_len, embed_dim),符合直觉。注意力层用一个线性层打分,再用 mask 把 padding 位置的分数压到负无穷,softmax 后 padding 权重为 0。实体位置向量用gather按索引取,这里取了起止位置的平均,你也可以只取起始位置或做最大池化。分类层输入维度是hidden_dim * 4,因为句子表示是hidden_dim * 2,head 和 tail 各hidden_dim * 2,但 head 和 tail 我做了平均后各剩hidden_dim * 2,所以拼接后是hidden_dim * 2 + hidden_dim * 2 = hidden_dim * 4。
参数建议:embed_dim=128对中文够用,hidden_dim=256是常见起点,dropout=0.3在中小数据集上比较稳。如果过拟合严重,先把 dropout 调到 0.5,再考虑减小 hidden_dim。
3.3 训练循环与关键超参数
def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss = 0 for batch in dataloader: input_ids = batch['input_ids'].to(device) attention_mask = batch['attention_mask'].to(device) head_start = batch['head_start'].to(device) head_end = batch['head_end'].to(device) tail_start = batch['tail_start'].to(device) tail_end = batch['tail_end'].to(device) labels = batch['label'].to(device) optimizer.zero_grad() logits = model(input_ids, attention_mask, head_start, head_end, tail_start, tail_end) loss = criterion(logits, labels) loss.backward() # 梯度裁剪,防止 LSTM 梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() total_loss += loss.item() return total_loss / len(dataloader)关键参数:学习率用1e-3配 Adam,如果 loss 震荡明显降到5e-4。梯度裁剪max_norm=5.0是 LSTM 类模型的标配,不裁剪的话 loss 很容易变成 nan。batch size 从 32 或 64 起步,显存不够就降到 16。
4. 避坑与排查:那些让模型不收敛的细节
4.1 实体位置索引错位
现象:训练 loss 正常下降,但验证集 F1 始终在随机水平附近。原因:实体pos是按词切分标注的,但模型输入是按字切分的,索引对不上。解决:统一切分粒度,要么全按字,要么全按词。如果必须用 BERT tokenizer,用word_ids()做映射,并且过滤掉实体被截断的样本。
4.2 注意力权重全为零
现象:打印注意力权重发现所有位置都是 0 或均匀分布。原因:attention_mask的维度不对,或者 mask 的值不是 0/1 而是 True/False。解决:检查attention_mask的形状是否为(batch, seq_len),值是否为 0 和 1。另外,如果 padding 太多,有效 token 太少,注意力也会退化,建议统计一下句子长度分布,把max_len设成覆盖 95% 样本的长度。
4.3 关系类别映射不一致
现象:训练时准确率很高,推理时预测结果全是乱码类别。原因:训练集和验证集各自构建了relation2id,映射顺序不同。解决:从训练集生成relation2id后保存成 JSON 文件,验证、测试、推理全部加载同一个映射。
4.4 梯度爆炸导致 loss 变 nan
现象:训练几个 batch 后 loss 突然变成 nan。原因:LSTM 在长序列上容易梯度爆炸,尤其是学习率偏大时。解决:加梯度裁剪,降低学习率,或者把 LSTM 层数设为 1。如果还不行,检查输入里有没有异常长的序列,做截断。
4.5 过拟合:训练集 F1 95%,验证集 F1 60%
现象:训练集表现很好,验证集差很多。原因:数据量太少、模型参数太多、dropout 不够。解决:增大 dropout 到 0.5,减小 hidden_dim 到 128,加 L2 正则化,或者用早停策略。如果数据量确实少,考虑用预训练词向量初始化嵌入层,或者直接上 BERT 微调。
5. 进阶技巧:用对抗训练和实体标记提升 AttBiLSTM 的鲁棒性
5.1 对抗训练在嵌入层加扰动
对抗训练(FGM)的思路是在嵌入层加一个小的扰动,让模型对输入变化更鲁棒。实现很简单,在每次loss.backward()之前,对嵌入层的梯度做一次扰动:
class FGM: def __init__(self, model, epsilon=1.0): self.model = model self.epsilon = epsilon self.backup = {} def attack(self): for name, param in self.model.named_parameters(): if 'embedding' in name and param.requires_grad: self.backup[name] = param.data.clone() norm = torch.norm(param.grad) if norm != 0: r_at = self.epsilon * param.grad / norm param.data.add_(r_at) def restore(self): for name, param in self.model.named_parameters(): if name in self.backup: param.data = self.backup[name] self.backup = {}用法:在loss.backward()后调用fgm.attack(),然后再次loss.backward(),最后fgm.restore()再optimizer.step()。注意第二次 backward 前要optimizer.zero_grad()只清一次,不要清两次。epsilon=1.0是常见起点,太大反而会破坏训练。
5.2 实体标记特征:把实体类型也告诉模型
AttBiLSTM 默认只用了实体位置,没有用实体类型。如果数据里标注了实体类型(如「人物」「机构」「地点」),可以把类型嵌入拼到实体向量上。做法是:为每个实体类型分配一个可学习的嵌入向量,维度设为 32 或 64,然后跟实体位置的 BiLSTM 输出拼接。这样模型能学到「人物-机构」和「机构-地点」之间的关系差异。
5.3 用交叉验证选超参数
关系抽取数据量通常不大,单次划分验证集波动很大。我一般用 5 折交叉验证,每折训练一个模型,取平均 F1 作为超参数选择依据。重点调三个参数:hidden_dim(128/256/512)、dropout(0.2/0.3/0.5)、lr(1e-3/5e-4/2e-4)。用网格搜索,不要用随机搜索,因为参数空间不大,网格更可控。
5.4 推理阶段的批处理与后处理
推理时不要一条一条跑,攒成 batch 效率高很多。后处理阶段,如果同一个实体对有多条句子,可以把所有句子的预测概率平均,取最大概率的关系作为最终结果。另外,如果关系类别有方向性(如「A 是 B 的创始人」和「B 是 A 的创始人」不同),注意 head 和 tail 的顺序不能搞反。
我自己的习惯是:每次跑完实验,先把relation2id、max_len、hidden_dim、dropout、lr这五个参数记到实验日志里,再记验证集 F1 和训练集 F1。如果训练集 F1 比验证集高超过 20 个点,直接判定过拟合,不再看其他指标。这个习惯帮我省了很多后悔药。希望帮到你。
本文还有配套的精品资源,点击获取