简介这是一份面向自然语言处理学习者的关系抽取实战项目包围绕 PyTorch 与卷积神经网络实现针对 SemEval2010_task8 数据集完成实体关系分类适合希望从数据预处理、模型搭建到训练评估完整走通流程的开发者。压缩包共 43 个文件约 36.95MB包含 7 份 Python 源码、10 份文本说明或配置、8 份缓存、6 份工程配置以及结构示意图、运行日志、数据与嵌入压缩包等目录中可见数据构建、词表生成、数据加载、网络定义、训练与评估等模块。已有 744 人学习说明其作为课程设计或入门复现案例具有一定参考价值。读者可据此理解嵌入层、卷积层、池化层与全连接分类器的组织方式参考实验配置、运行日志和评估脚本排查训练问题并对照结构示意图梳理从语料构建到关系预测的完整实现链路。1. 关系抽取从一份压缩包落地CNN 模型能解决什么问题拿到一个只写着「CNN关系抽取模型.zip」的压缩包很多人第一反应是找 README找不到就开始猜目录。这个项目不是大模型微调而是一条很经典的 PyTorch 小模型流水线输入一句已经标好两个实体的文本输出这两个实体之间的关系类别SemEval2010_task 风格的数据正好可以直接喂进去。它适合想先跑通关系抽取 baseline 的开发者也适合拿 CNN 结构做对照实验的 A 同学如果你要的是开箱即用的工业级抽取平台它不合适但作为可改、可读、能复现的实验骨架价值在源码本身。2. 目录与运行链路从解压到跑出第一组 P/R/F1压缩包解压后最怕两件事入口脚本找不到数据路径全是作者机器上的绝对路径。这个 CNN 关系抽取项目的运行链路其实很短读标注文件生成词 id 和两组位置 idDataset 组 batchTextCNN 前向CrossEntropyLoss 反传最后在测试集上算 P/R/F1。先把这条链路跑通再谈改模型否则后面每改一处都像在黑匣子里调参。2.1 目录结构与数据格式先确认入口再谈模型不同压缩包可能把src拍平到根目录也可能多一层项目名。不要急着改代码先找train.py、evaluate.py和数据目录。常见结构大致如下cnn-relation-extraction/ ├── data/ │ ├── train.txt │ ├── test.txt │ └── vocab.txt ├── configs/ │ └── cnn_base.json ├── src/ │ ├── data.py │ ├── model.py │ ├── train.py │ ├── evaluate.py │ └── utils.py ├── runs/ └── requirements.txtdata/train.txt和data/test.txt一般是一行一个样本句子里的两个实体用e1、e2标出来行尾是关系标签。vocab.txt是词表configs/cnn_base.json保存最大长度、词向量维度、卷积核尺寸、位置特征截断范围这些参数。runs/是训练输出目录常见会生成best.pt、last.pt、label2id.json和日志。先把这些文件和脚本对应上再确认train.py里的默认路径是不是相对路径如果是作者本机的绝对路径改成data/train.txt这种相对路径别硬编码到自己的盘符。数据行常见长这样1 The e1cat/e1 sat on the e2mat/e2 . Cause-Effect(e1,e2)这里的Cause-Effect(e1,e2)不只是关系名还带方向。解析时只取括号前的Cause-Effect作为类别方向信息是否保留要看项目设计。如果训练集和测试集的标签顺序不一致后面 F1 会直接掉到怀疑人生所以标签映射一定要单独存一份不要每次靠sorted(set(labels))临时生成。2.2 环境准备与启动命令把第一次训练压到最小变量先建虚拟环境装最小依赖。这个项目不需要一上来装一堆实验框架torch、numpy、tqdm、scikit-learn足够跑通。GPU 环境按本机 CUDA 版本装对应的 torch不要照抄别人的 wheel。python -m venv .venv source .venv/bin/activate pip install --upgrade pip pip install torch numpy tqdm scikit-learn python train.py \ --train_file data/train.txt \ --test_file data/test.txt \ --max_len 100 \ --batch_size 64 \ --epochs 30 \ --lr 1e-3 \ --out_dir runs/cnn_baselineWindows 下激活命令换成.venv\Scripts\activate其他参数不变。--max_len 100是句子截断长度关系抽取样本通常不会特别长先设 100 比一上来设 256 更稳--batch_size 64是显存和梯度的折中显存不够就降到 32--epochs 30配合 early stopping 使用不要盲跑 100 轮--lr 1e-3是 Adam 的常见起点loss 抖动明显再降到5e-4--out_dir决定 checkpoint 和日志落在哪建议每次实验换目录别覆盖上一次结果。关键参数可以先按这张表核对参数作用建议起点max_len句子截断长度100batch_size批大小32 或 64emb_dim词向量维度200pos_dim位置向量维度5 到 10num_filters每种卷积核输出通道数100kernel_sizes卷积窗口3、4、5dropout分类前丢弃率0.5lr学习率1e-3注意如果入口文件叫main.py把命令里的train.py换掉即可不要为了跑通去改源码里的默认绝对路径。2.3 跑通判断看日志、看 checkpoint、看评估报告训练启动后先看三样东西loss 是否在动、验证集 macro-F1 是否在涨、runs/cnn_baseline/下有没有best.pt。如果 loss 从第一轮开始就是log(num_classes)附近不动大概率是标签映射或数据解析有问题不是 CNN 结构不行。正常情况下一两个 epoch 内 loss 会明显下降验证集 F1 可能起步很低但不应该一直是 0。训练结束后单独跑评估确认 checkpoint 能加载python evaluate.py \ --checkpoint runs/cnn_baseline/best.pt \ --test_file data/test.txt评估脚本一般会输出 accuracy、macro-F1、各类别 P/R/F1。关系抽取不要只看 accuracy类别不均衡时全预测Other也能有不错的 accuracy但 macro-F1 会非常难看。best.pt里如果只存了state_dict没有存label2id和模型参数加载时很容易维度不匹配这是后面二次开发最常见的坑之一。3. 数据处理拆解SemEval2010_task 样本到 CNN 张量的完整链路关系抽取的瓶颈通常在数据不在 CNN。SemEval2010_task 这类数据一行一个句子实体用e1、e2标出关系标签在行尾。模型看不到原始字符串只能看到 token id、实体位置距离和标签 id所以解析层要把这三样对齐。解析错一个实体边界后面位置特征全歪模型学到的就是噪声。3.1 标注文件解析实体 span 比实体字符串更重要先写解析函数把句子、实体 token 起止位置和关系标签拆出来。下面这段代码兼容制表符分隔和空格分隔两种常见写法实体用字符 span 定位再映射到 token spanimport re from dataclasses import dataclass from typing import Optional TAG_RE re.compile(re([12])(.*?)/e\1) dataclass class Sample: tokens: list e1_start: int e1_end: int e2_start: int e2_end: int label: str def parse_line(line: str) - Optional[Sample]: line line.strip() if not line or line.startswith(#): return None # 常见格式句子TABRelation(e1,e2) if \t in line: sent, label_part line.split(\t, 1) else: sent, label_part line.rsplit( , 1) sent sent.strip().strip() label label_part.split(()[0].strip() spans {} for m in TAG_RE.finditer(sent): spans[m.group(1)] (m.start(1), m.end(1)) # 去掉实体标记保留纯文本 clean TAG_RE.sub(lambda m: m.group(2), sent) # 这里用空格分词中文或标点密集语料换成自己的 tokenizer tokens clean.split() token_spans, cursor [], 0 for tok in tokens: s clean.find(tok, cursor) e s len(tok) token_spans.append((s, e)) cursor e def to_token_span(char_span): cs, ce char_span idxs [i for i, (ts, te) in enumerate(token_spans) if te cs and ts ce] if not idxs: raise ValueError(f实体未对齐到 token: {char_span}) return min(idxs), max(idxs) e1_start, e1_end to_token_span(spans[1]) e2_start, e2_end to_token_span(spans[2]) return Sample(tokens, e1_start, e1_end, e2_start, e2_end, label)逻辑上TAG_RE只负责抓实体标签clean把标签去掉得到可分词文本token_spans记录每个 token 在纯文本中的字符区间最后把实体字符区间映射成 token 下标。参数上e1_start和e1_end都是闭区间后面算位置距离时直接按这两个下标做减法。真实数据里实体可能跨多个 token所以不要只存实体字符串字符串一拼错位置特征就全错。3.2 位置特征把实体边界变成两个整数序列CNN 本身不知道哪个 token 是实体位置特征就是给每个 token 算它到两个实体的距离。实体内部距离为 0实体左边为负右边为正再截断到固定范围并整体平移到非负区间。这样nn.Embedding才能查表。def position_ids(length, start, end, max_pos60): ids [] for i in range(length): if i start: d i - start elif i end: d i - end else: d 0 d max(-max_pos, min(max_pos, d)) ids.append(d max_pos 1) # 0 留给 padding return ids这段函数的输出是长度为length的整数列表。max_pos60时距离范围被压到[-60, 60]加max_pos 1后真实位置 id 落在[1, 121]0 专门留给 padding所以位置词表大小是2 * max_pos 2 122。如果句子很长超出 60 的距离会被截断这是拿少量长距离信息换稳定性的常见做法。位置 id 的 padding 必须单独留 0不要和真实位置 0 混用不然后面 embedding 会把 padding 当成实体附近的位置。3.3 Dataset 与 collate_fn动态 padding 和词表要一起保存Dataset 负责把Sample转成张量collate_fn负责在一个 batch 内补齐到当前最大长度。动态 padding 比全量补到 100 更省显存尤其是短句多的数据集。import torch from torch.utils.data import Dataset class RelationDataset(Dataset): def __init__(self, samples, word2id, label2id, max_len100, max_pos60): self.samples samples self.word2id word2id self.label2id label2id self.max_len max_len self.max_pos max_pos def __len__(self): return len(self.samples) def __getitem__(self, idx): s self.samples[idx] tokens s.tokens[:self.max_len] length len(tokens) ids [self.word2id.get(t, self.word2id[unk]) for t in tokens] pos1 position_ids(length, s.e1_start, min(s.e1_end, length - 1), self.max_pos) pos2 position_ids(length, s.e2_start, min(s.e2_end, length - 1), self.max_pos) return { input_ids: torch.tensor(ids, dtypetorch.long), pos1: torch.tensor(pos1, dtypetorch.long), pos2: torch.tensor(pos2, dtypetorch.long), label: torch.tensor(self.label2id[s.label], dtypetorch.long), } def collate_fn(batch): max_len max(len(x[input_ids]) for x in batch) batch_size len(batch) input_ids torch.zeros(batch_size, max_len, dtypetorch.long) pos1 torch.zeros(batch_size, max_len, dtypetorch.long) pos2 torch.zeros(batch_size, max_len, dtypetorch.long) for i, x in enumerate(batch): length len(x[input_ids]) input_ids[i, :length] x[input_ids] pos1[i, :length] x[pos1] pos2[i, :length] x[pos2] labels torch.tensor([x[label] for x in batch], dtypetorch.long) return {input_ids: input_ids, pos1: pos1, pos2: pos2, label: labels}word2id里必须有pad和unk且pad的 id 要固定为 0和模型里的padding_idx0对上。label2id要和 checkpoint 一起保存训练和评估必须用同一份映射。截断句子时实体可能被截掉min(s.e1_end, length - 1)只是兜底真正跑之前最好统计一下截断样本比例如果比例很高应该先改max_len或做句子切分而不是硬训练。4. 模型核心拆解TextCNN 关系分类器的前向链路这个模型没有 Transformer 那么多层核心就是三个动作词向量和位置向量拼接多尺寸卷积核提取局部片段max-pool 后接分类头。结构简单但每个张量的维度都要对齐否则报错信息会让人来回翻代码。4.1 词向量与位置向量拼接让 CNN 知道实体在哪下面是一版可直接用的 TextCNN 关系分类器词向量和两组位置向量在最后一维拼接再转成Conv1d需要的[batch, channel, length]import torch import torch.nn as nn class CnnRelationModel(nn.Module): def __init__( self, vocab_size, num_labels, word_dim200, pos_dim10, pos_vocab122, num_filters100, kernel_sizes(3, 4, 5), dropout0.5, ): super().__init__() self.word_emb nn.Embedding(vocab_size, word_dim, padding_idx0) self.pos_emb nn.Embedding(pos_vocab, pos_dim, padding_idx0) emb_dim word_dim pos_dim * 2 self.convs nn.ModuleList([ nn.Conv1d(emb_dim, num_filters, kernel_sizek, paddingk // 2) for k in kernel_sizes ]) self.dropout nn.Dropout(dropout) self.fc nn.Linear(num_filters * len(kernel_sizes), num_labels) def forward(self, input_ids, pos1, pos2): w self.word_emb(input_ids) # [B, L, word_dim] p1 self.pos_emb(pos1) # [B, L, pos_dim] p2 self.pos_emb(pos2) # [B, L, pos_dim] x torch.cat([w, p1, p2], dim-1) # [B, L, emb_dim] x x.transpose(1, 2) # [B, emb_dim, L] pooled [] for conv in self.convs: h torch.relu(conv(x)) h torch.max(h, dim2).values # 每个卷积核取最强响应 pooled.append(h) x torch.cat(pooled, dim-1) x self.dropout(x) return self.fc(x)word_dim200控制词向量容量pos_dim10是位置向量维度两个位置向量一共占 20 维所以emb_dim220。num_filters100表示每种卷积核输出 100 个通道三个尺寸拼起来就是 300 维最后接num_labels类。transpose(1, 2)不能省Conv1d要的是通道在前。padding_idx0让 padding 的词向量和位置向量不参与梯度更新这一点在微调预训练词向量时尤其要保留。4.2 卷积核与池化的取舍3/4/5 不是拍脑袋关系抽取里真正有用的往往是很短的触发片段比如“出生于”“位于”“导致”“Part of”。3、4、5 三种卷积窗口分别覆盖三元组、四元组和五元组片段比只用一个窗口更稳。窗口再大参数量上去了短句上的收益也不明显。num_filters从 100 起步够用数据量小的时候调到 64 也能跑别一上来 256 堆满显存。max-pool 的作用是每个卷积核只保留最强响应让模型对实体位置变化没那么敏感。代价是长距离依赖弱两个实体隔得很远时CNN 可能抓不到关系线索。这个项目本身就是 CNN baseline不要指望它解决所有长文本关系如果数据里长句多可以把 max-pool 换成 attention pooling或者在上层加一层 BiLSTM但那是二次开发的事先把原始结构跑出可信数字。dropout0.5放在分类头前面是防止小数据集过拟合的常见手段。训练集 F1 很高、测试集 F1 很低时先看 dropout 和 early stopping不要急着加层。max_len100也要和位置截断max_pos60一起看句子被截到 100 个 token实体距离超过 60 的部分会被压平长距离样本多的时候要单独统计。4.3 训练与评估CrossEntropyLoss 之外的三个细节训练循环里除了前向、反传、step本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?