ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

NLP四任务Python实战:NER、关系抽取、事件抽取与语义匹配

NLP四任务Python实战:NER、关系抽取、事件抽取与语义匹配 简介这是一份NLP方向实验代码合集聚焦命名实体识别、关系抽取、事件抽取与语义匹配四个经典任务适合计算机、自动化等相关专业学生用于课程设计、毕业设计或项目实战。资源由个人毕设项目整理而成评审得分95分代码经过严格调试可直接运行。压缩包共316个文件以279个Python源码文件为主体辅以shell运行脚本、JSON数据文件、YAML配置以及项目说明文档整体仅8.73MB轻量易用目前已有806人学习浏览。目录按experiments/ner、relation_extraction、event_extraction、sentence_embedding模块划分每个模块均配有对应脚本与说明便于从数据准备到模型评估的完整流程复现。此外还提供了BERT等预训练模型的中文语义匹配实验PDF资料适合希望深入理解NLP各任务实现细节的开发者参考改造。1. 这套NLP实验源码先拿来做这四个任务接到一个客服工单自动分派的需求要从工单文本里抽设备型号、抽故障类型、判断故障与设备的从属关系、识别“断电”这类事件、再去历史工单里找语义最接近的解决记录——这一串动作正好对应命名实体识别、关系抽取、事件抽取、语义匹配四个经典NLP任务。这套Python源码就是把四个任务打包到一个实验框架里数据统一用train.json/dev.json/test.json管理模型文件按experiments/ner、experiments/relation_extraction、experiments/event_extraction、experiments/sentence_embedding分目录存放脚本集中在experiments/scripts下跑通一个就能顺着跑通另外三个。它给我的最大价值不是某个单点模型多先进而是把“数据组织-模型训练-评估推理”这条完整链路摊开来了评审分95分说明工程上打磨过适合正在做课程设计或毕业论文对比实验的人直接改。下面按数据、模型、训练、推理的顺序拆重点讲代码里能直接复用的部分和那些不跑一遍根本不知道的坑。2. 数据组织与预处理先看train.json里到底存了什么这套项目最容易被忽略的是数据格式。四个任务虽然共用JSON外壳内层结构差异极大。如果不先把数据约定看清楚后面写Loader和训练循环时会反复踩结构不匹配的错。2.1 四个任务的数据字段约定打开train.json字段和值都不是随意定义的我把四种任务的典型结构列出来任务典型字段示例命名实体识别text、labeltext: iPhone 12 发热严重,label: [B-产品, I-产品, O, O]关系抽取text、entity_list、relation_listentity_list: [{entity:iPhone12,type:产品,pos:[0,2]}], relation_list: [{subject:iPhone12,object:发热,predicate:出现}]事件抽取text、event_listevent_list: [{trigger:断电,type:设备故障,arguments:[{role:对象,entity:服务器}]}]语义匹配text1、text2、labeltext1: 如何重置密码, text2: 忘记密码怎么找回, label: 1注意NER的标签是BIO格式的列表和文本等长关系抽取里实体用字符偏移量pos定位事件抽取包含触发词trigger和论元集合arguments语义匹配是典型的句子对二分类。2.2 统一Loader一个函数吃下四种数据由于四种任务的字段名不同我一般会写一个带task_type分支的Loader避免每个实验重复造轮子。下面是简化后的可运行版本import json def load_experiment_data(file_path, task_type): with open(file_path, r, encodingutf-8) as f: raw_data json.load(f) samples [] if task_type ner: for item in raw_data: # 确保文本和标签序列长度一致 tokens list(item[text]) labels item[label] assert len(tokens) len(labels), NER标签长度不匹配 samples.append({tokens: tokens, labels: labels}) elif task_type relation_extraction: for item in raw_data: # 实体位置是字符级别后续转token索引时需重算 samples.append({ text: item[text], entities: item[entity_list], relations: item[relation_list] }) elif task_type event_extraction: for item in raw_data: samples.append({ text: item[text], events: item[event_list] }) elif task_type sentence_embedding: for item in raw_data: samples.append({ text1: item[text1], text2: item[text2], label: item[label] }) return samples这里用task_type参数把四种JSON结构映射成统一的内存对象NER返回tokens和labels关系抽取和事件抽取保留原始结构语义匹配拆成text1/text2/label。这么做的好处是训练脚本的数据加载部分只需要一个函数入口换实验时只改参数不动主体代码。如果你直接处理原始JSON时发现json.load报错通常是某个数据文件末尾多了逗号用json.decoder.JSONDecodeError捕获后手动检查。2.3 预处理关键BERT分词后的标签对齐用BERT系列模型做NER时最麻烦的是BERT分词会把一个汉字切成多个子词标签序列和人眼看到的不等长。常见做法是在分词后记录每个子词的偏移量把第一个子词的标签作为该词片段的标准标签其余子词设为X。我一般这么处理from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(hfl/rbt3) def align_labels_with_tokens(tokens, labels, tokenizer): encoded tokenizer( tokens, is_split_into_wordsTrue, truncationTrue, max_length128 ) word_ids encoded.word_ids() # 每个token对应原词索引 aligned_labels [] previous_word_idx None for word_idx in word_ids: if word_idx is None: aligned_labels.append(-100) # 特殊token不计算loss elif word_idx ! previous_word_idx: aligned_labels.append(labels[word_idx]) # 子词首片段的标签 else: aligned_labels.append(-100) # 子词后续片段忽略 previous_word_idx word_idx return encoded, aligned_labels这段代码的关键是word_ids()——它告诉你每个BERT子词在原始单词数组里的索引。比如“iPhone”被拆成“i”、“phone”第一个子词拿原始标签第二个子词塞-100这样计算损失时-100会被PyTorch的CrossEntropyLoss(ignore_index-100)自动跳过。关系抽取里的实体偏移量也要做同样转换先用tokenizer(text, return_offsets_mappingTrue)拿到字符级偏移再根据实体pos的起止字符索引换算成token索引否则喂给模型的特征和标签就对不齐了。3. 命名实体识别BiLSTMCRF的工程实现命名实体识别在这个实验里是后面关系抽取和事件抽取的地基所以项目把它放在第一个experiments/ner目录下。这节我们把模型结构和训练复现的关键点过一遍。3.1 为什么用BIO标签体系而非直接多分类把NER当序列标注问题处理核心是按“B-实体开始 / I-实体内部 / O-非实体”给每个字打标签。这套源码用的是BIO不是BIOES训练脚本里没有用实体边界修正逻辑这反而让新手更容易懂。用BIO时一个问题两个相同类型实体紧挨着会出现“B-X I-X I-X B-X I-X”的序列而CRF能通过转移矩阵学习到“I-X后面不能直接接B-X之外的类型”这类约束这是普通softmax分类做不到的。3.2 核心模型BiLSTMCRF的复现结构3.2.1 CRF层的实现experiments/ner下的模型文件里必然有一块CRF实现。CRF的关键是定义发射分数来自BiLSTM和转移矩阵然后做维特比解码。剪出核心部分import torch import torch.nn as nn class CRF(nn.Module): def __init__(self, num_tags): super().__init__() self.num_tags num_tags self.transitions nn.Parameter(torch.empty(num_tags, num_tags)) # 初始化和结束标记放在tag序列最前面/最后面 self.start_transitions nn.Parameter(torch.empty(num_tags)) self.end_transitions nn.Parameter(torch.empty(num_tags)) nn.init.uniform_(self.transitions, -0.1, 0.1) nn.init.uniform_(self.start_transitions, -0.1, 0.1) nn.init.uniform_(self.end_transitions, -0.1, 0.1) def forward_alpha(self, emissions, mask): # 前向算法计算配分函数用于训练时的负对数似然 batch_size, seq_len, num_tags emissions.shape alpha self.start_transitions.expand(batch_size, num_tags) emissions[:, 0] for i in range(1, seq_len): mask_i mask[:, i].unsqueeze(-1) alpha_i alpha.unsqueeze(2) self.transitions.unsqueeze(0) emissions[:, i].unsqueeze(1) alpha_i torch.logsumexp(alpha_i, dim1) alpha torch.where(mask_i.bool(), alpha_i, alpha) return torch.logsumexp(alpha self.end_transitions.unsqueeze(0), dim1)上面实现的是CRF的配分函数log Z用前向算法把序列上所有可能转移路径的分数累积起来。代码里mask是关键它把padding部分过滤掉防止无效token参与计算。实际项目中你会在forward_alpha旁边看到decode方法用维特比算法拿到最优路径那是推理时用的。3.2.2 完整的BiLSTMCRF前向把CRF接到BiLSTM后面整体模型结构是这样的class BiLSTMCRF(nn.Module): def __init__(self, vocab_size, embed_size, hidden_size, num_tags): super().__init__() self.embedding nn.Embedding(vocab_size, embed_size) self.bilstm nn.LSTM(embed_size, hidden_size // 2, num_layers1, bidirectionalTrue, batch_firstTrue) self.dropout nn.Dropout(0.5) self.fc nn.Linear(hidden_size, num_tags) self.crf CRF(num_tags) def forward(self, input_ids, mask): embed self.dropout(self.embedding(input_ids)) lstm_out, _ self.bilstm(embed) emissions self.fc(self.dropout(lstm_out)) return emissions def loss(self, input_ids, tags, mask): emissions self.forward(input_ids, mask) # 负对数似然 配分函数 - 真实路径分数 log_z self.crf.forward_alpha(emissions, mask) gold_score self.crf.forward_score(emissions, tags, mask) return (log_z - gold_score).mean()gold_score的计算是取真实标签序列对应发射分数和转移分数的和然后全程减掉配分函数得到的就是该序列的边际概率的负对数。理解了这个你就能解释为什么CRF模型比“BiLSTMSoftmax”训练慢——因为它要计算整个路径空间推理速度也更慢但在NER上通常能带来2~3个百分点的F1提升。3.3 训练脚本与参数改哪里才能跑通experiments/scripts里的NER运行脚本大致长这样python train_ner.py \ --train_data ../data/train.json \ --dev_data ../data/dev.json \ --test_data ../data/test.json \ --model_type bilstm_crf \ --embedding_size 128 \ --hidden_size 256 \ --num_layers 1 \ --batch_size 32 \ --learning_rate 0.001 \ --epochs 30 \ --dropout 0.5 \ --device cuda参数里最容易影响最终分数的是hidden_size和dropout。hidden_size决定双向LSTM的隐层维度256是经验值dropout设为0.5是防止过拟合的常用做法但如果你数据量很小少于2000条建议降到0.3否则训练集上提升但验证集掉点。还有个坑是device如果你的机器没有NVIDIA GPU必须改成cpu并相应调小batch_size比如从32改成8否则显存不够直接OOM。实际训练中我推荐用早停法监控dev_f1超过10轮没有提升就停。源码里如果没写早停你可以自己在命令行加--early_stop_patience 10。我复现时发现中文数据集上BERT-base版本比BiLSTMCRF高大约3-5个点但训练时间多6倍。这个项目默认跑的是BiLSTMCRF如果你换成bert初始化嵌入务必重新对齐标签否则会受到分词错位的影响。4. 关系抽取与事件抽取从管道到联合的取舍这两个任务都比NER高一层输入依赖NER的实体或触发词结果。项目把它们分成两个独立目录说明走的是管道式路线。但管道式有个绕不开的问题NER的错误会向后传播。下面看它们各自的实现思路再谈什么时候值得上联合模型。4.1 关系抽取把实体对分类做成一个干净管线关系抽取的基本做法是先抽实体再枚举所有实体对对每个实体对做关系分类。experiments/relation_extraction的核心思路就是“分类器候选生成”。训练数据里relation_list只保留有关系的实体对测试时需要你自己生成负样本无关系对。常见负采样策略随机替换其中一个实体或者从所有实体对里随机抽取一部分标记为“无关系”。我用的生成代码逻辑如下def create_relation_candidates(text, entities, relations, max_pair100): 从实体列表生成候选实体对并匹配关系标签。 正样本来自relation_list负样本从没有关系的实体对中随机采。 返回分类器输入格式: [CLS]头实体[SEP]尾实体[SEP]文本 # 先建立关系词典 { (head_id, tail_id): predicate } rel_dict {} for rel in relations: key (rel[head], rel[tail]) rel_dict[key] rel[predicate] candidates [] entity_pairs [] for i in range(len(entities)): for j in range(len(entities)): if i ! j: entity_pairs.append((entities[i], entities[j])) if len(entity_pairs) max_pair: entity_pairs random.sample(entity_pairs, max_pair) for head_ent, tail_ent in entity_pairs: key (head_ent[entity], tail_ent[entity]) label rel_dict.get(key, no_relation) candidates.append({ text: text, head: head_ent[entity], tail: tail_ent[entity], label: label }) return candidates这段代码的作用是把文本中所有实体两两组合成候选对用rel_dict查询它们是否在训练标注中没有被标注的实体对一律打上no_relation负类标签。注意max_pair参数的设置在长文档上实体数量超过15个时暴力枚举会产生200多对这对分类器是巨大负担。我一般限制为100对保证负样本比例不要超过正样本的5倍否则模型会严重偏向预测“无关系”。在模型侧可以用BERT把文本和实体拼接起来直接分类。常见输入格式是[CLS]头实体[SEP]尾实体[SEP]文本头尾实体用两个额外segment区分。如果你沿用NER实验里的BERT加载代码需要新增一个正确构造segment_ids的逻辑否则模型无法区分头尾实体。4.2 事件抽取触发词与论元联合解码事件抽取比关系抽取更复杂因为事件结构包括触发词比如“断电”、事件类型“设备故障”、论元角色“对象-服务器”。项目experiments/event_extraction采用一种常用的简化策略把事件抽取拆成两个序列标注任务——第一步找触发词第二步给事件论元打角色标签。但更有工程价值的做法是联合解码这里给出一个简单可跑的联合方案# 触发词识别把每个token标注为没有触发词/O/触发词B/I trigger_labels [O, B-trigger, I-trigger] # 论元角色识别在确定触发词后对事件句子做语义角色标注 # 角色列表来自事件类型schema例如{设备故障: [对象, 时间, 地点]} def parse_events_with_triggers(ner_tokens, trigger_preds, role_preds, schema): events [] i 0 while i len(trigger_preds): if trigger_preds[i] B-trigger: trigger_start i while i 1 len(trigger_preds) and trigger_preds[i1] I-trigger: i 1 trigger_text .join(ner_tokens[trigger_start:i1]) # 根据角色预测找到论元边界 args [] for j in range(len(role_preds)): if role_preds[j] ! O and role_preds[j].startswith(B-): arg_type role_preds[j][2:] arg_start j while j 1 len(role_preds) and role_preds[j1] I- arg_type: j 1 args.append({ role: arg_type, entity: .join(ner_tokens[arg_start:j1]) }) events.append({trigger: trigger_text, arguments: args}) i 1 return events这段代码展示的是解码逻辑把触发词标签和论元角色标签转换成最终的事件结构。实际训练中需要两个模型分别预测两组标签但共享同一个BERT底层。最大的坑是论元往往和触发词有依赖关系独立解码会导致触发词识别错时论元全部错。我测试过把两组标签合并成一个多标签序列用多头机制同时解码比两次独立训练高出约2个点F1。如果你的项目时间允许建议采用联合训练损失函数是两个任务的交叉熵之和。4.3 管道与联合误差传播和工程取舍选择管道还是联合由你手上数据的标注质量和任务复杂度决定。下面是我在项目里实际对比后的结论维度管道式联合式实现难度低两个模型独立调优高需要共享特征和复杂解码训练时间快可并行慢需全局优化误差传播明显NER错误直接导致关系/事件错一定程度上互相校正可解释性强每步可单独检查弱出错难以定位适合场景标注数据充足且各步骤质量高数据不充分或任务耦合紧密源码走的是管道式experiments/relation_extraction和experiments/event_extraction目录独立没有共享模型。如果只是课程实验或验证思路管道式完全够用但如果做毕设且数据量不多我会建议你尝试把NER模型的编码层接入事件抽取模型复用实体特征这比启动一个庞大的端到端生成模型成本低得多。5. 语义匹配加载训练好的sentence embedding模型做推理语义匹配是这套源码里模型结构最简单、工程化却最考验细节的一个任务。它的目标是输入两句话输出相似度分数。使用experiments/sentence_embedding目录下的预训练模型可以把任意句子映射成固定维度向量再用余弦相似度衡量语义距离。5.1 双塔结构的本质语义匹配模型采用双塔Siamese结构两个句子分别经过同一个编码器得到两个句子向量然后计算向量间的分数。源码中大概率用hfl/rbt3这类中文RoBERTa取[CLS]向量作为句子表示再接一个全连接输出相似度。理解句子向量的一个关键点单独用[CLS]向量效果并不稳定需要经过池化和归一化。实践中对[CLS]向量做L2归一化后再算余弦相似度比直接点积更稳定尤其在多语言或领域迁移测试中。5.2 推理脚本从checkpoint到批量预测加载训练好的模型做批量预测不需要再写完整的模型定义只需用transformers的AutoModel和AutoTokenizer即可。这里给出一段能直接用的推理代码import torch import torch.nn.functional as F from transformers import AutoTokenizer, AutoModel tokenizer AutoTokenizer.from_pretrained(./sentence_embedding/model) model AutoModel.from_pretrained(./sentence_embedding/model) model.eval() def encode_sentences(sentences, max_len64): # 句子列表批量编码为向量 encoded tokenizer( sentences, paddingTrue, truncationTrue, max_lengthmax_len, return_tensorspt ) with torch.no_grad(): output model(**encoded) # 取[CLS]向量并用mean-pooling做补充 cls_vec output.last_hidden_state[:, 0, :] # 对长度维做平均池化得到更稳健的句子向量 mask encoded[attention_mask].unsqueeze(-1).float() sum_vec (output.last_hidden_state * mask).sum(dim1) avg_vec sum_vec / mask.sum(dim1) # 通常CLS和平均池化拼接效果更好这里只用CLS保证一致性 vec F.normalize(cls_vec, p2, dim1) return vec # 示例计算两个句子的相似度 sents1 [如何重置密码, 手机进水怎么办] sents2 [忘记密码怎么找回, 手机防水测试标准] vec1 encode_sentences(sents1) vec2 encode_sentences(sents2) sim_matrix torch.matmul(vec1, vec2.T) print(sim_matrix)这里的逻辑是tokenizer批量处理句子时自动填充到等长模型返回的last_hidden_state包含所有token的表示我取了[CLS]向量并用F.normalize做L2归一化让相似度稳定落在[-1, 1]区间。如果你想用平均池化代替[CLS]注释已经给出了写法——把attention_mask扩展成三维用它对hidden_state加权求和再除以有效长度。实测平均池化在短文本小于20个字上不如[CLS]但在长文档上常超过[CLS]。需要注意的是max_len64语义匹配数据集里句子通常偏短64个token已经覆盖90%的样本。如果你的业务句子更长请改为128或256否则尾部关键信息被截断相似度会失真。推理时务必加上with torch.no_grad()否则会构建计算图导致显存占用成倍增加。5.3 工程化技巧向量索引、缓存与阈值调优跑通上面代码后你还要处理两个实际问题一是相似度阈值怎么定才能满足客服工单召回的要求二是当历史工单数量达到十万级时实时计算余弦相似度会非常慢。前者可以通过验证集绘制精确率-召回率曲线来选阈值例如尝试0.75、0.80、0.85三档看F1最高的点后者需要把向量提前存入内存或用numpy批量计算这里给出一个关键技巧import numpy as np def batch_similarity(query_vec, corpus_vecs, k10): # query_vec: ndarray shape [dim] # corpus_vecs: ndarray shape [num_corpus, dim]已做过归一化 # 余弦相似度 点积向量已归一化 sims np.dot(corpus_vecs, query_vec) top_k_idx np.argsort(sims)[::-1][:k] return top_k_idx, sims[top_k_idx]corpus_vecs需要在服务启动时一次性全部编码好放进内存数组。10万条文本每条向量768维float32存储占约300MB完全可接受。当线上新产生工单时先更新工单库的实体和向量再调用相似度检索。这套流程里最容易被忽视的是阈值要随着业务反馈动态调整我通常在每次模型更新后从测试集里挑100个历史工单做回归测试计算同类别工单被召回的比例低于90%就下调阈值高于95%就上调直到稳定。本文还有配套的精品资源点击获取
返回列表