☰
PyTorch CNN关系抽取:位置向量与多核卷积基线解析
2026/11/5 2:09:16 网站建设 项目流程

简介:面向自然语言处理初学者与关系抽取实践者的 PyTorch 实战包,围绕 SemEval 数据集实现基于卷积神经网络的关系分类流程,可用于课程实验、模型复现与文本分类入门。压缩包共 43 个文件,约 36.95MB,以 7 个 Python 脚本承担数据构建、词表生成、网络定义、训练与评估,10 个文本文件及若干 JSON、XML 记录数据与配置,另有日志、示意图和压缩数据等辅助内容;目录按数据、词向量、模型与工具模块划分,便于按步骤阅读和修改。已有 745 人学习下载。项目把嵌入层、多核卷积、最大池化、全连接分类和交叉熵训练串联起来,并附模型结构示意图,读者可据此理解局部特征提取、预训练词向量加载与指标评估的完整链路,也能借鉴数据预处理和实验记录方式,快速搭建自己的关系抽取基线。

1. 从一句「苹果收购了这家初创公司」说起:CNN 关系抽取到底在判什么

给你一句话和句子里两个标好的实体,让你判断它们之间是什么关系——这是信息抽取里最容易被低估的一环。业务同事丢过来几千条新闻,问「哪些公司在收购哪些公司」,你总不能人工标。这份CNN关系抽取模型.zip就是干这个的:一份 PyTorch 实现的卷积神经网络关系抽取工程,训练和评测跑的是SemEval2010_task8 数据集,9 种关系类型加上正反方向、再带上 Other,一共 19 类标签。它不适合拿去直接上生产,但特别适合两类人:一类是想搞明白「关系抽取模型的输入到底长什么样」的入门者,另一类是需要一个 30 分钟能跑完、能当对照组的 baseline 的熟手。词向量加位置向量喂进多核卷积,结构干净得能一晚上读完。

2. 项目定位与技术选型:为什么这套 CNN 架构今天还值得拆

2.1 任务定义与 19 个标签是怎么来的

先把任务说清楚,不然后面看代码全是抽象的。关系抽取的输入是一个三元组:一个句子、句子里两个实体的位置、以及一个待预测的关系标签。输出是 19 个类别里的一个。SemEval-2010 Task 8 这个数据集里,标注者给出的关系有 9 种语义类型,每种类型都区分方向,所以展开成 18 个类别,再加上一个兜底的 Other。

关系类型e1→e2 含义e2→e1 含义
Cause-Effecte1 导致 e2e2 导致 e1
Instrument-Agencye1 是 e2 使用的工具反过来
Product-Producere1 是 e2 生产的产品反过来
Content-Containere1 在 e2 容器里反过来
Entity-Destinatione1 移动到 e2反过来
Entity-Origine1 来自 e2反过来
Member-Collectione1 是 e2 的成员反过来
Message-Topice1 是关于 e2 的消息反过来
Component-Wholee1 是 e2 的部件反过来
Other以上都不是—

方向这件事看着像细节,实际是新手第一个翻车点。Product-Producer(e1,e2)和Product-Producer(e2,e1)在语义上是完全相反的两句话,但很多实现为了省事直接把方向抹掉,只做 10 分类,结果 F1 虚高好几个点,换成真实业务数据立刻崩。

数据集规模上,训练集约 8000 条,测试集 2717 条,句长平均不到 20 个 token。这个量级很关键——它意味着参数量上百万的模型会直接过拟合,也意味着你可以在笔记本上把整套流程跑完,不用等集群。

2.2 三种编码器的取舍:为什么这里选 CNN

关系抽取的判别信号有个特点:极度局部。判断「某公司收购了某公司」,起作用的往往就是实体中间那个动词加介词,acquired、took over、is a subsidiary of。这类 n-gram 级特征正是卷积核最擅长的东西。

CNN 方案的核心思路是:用多种宽度的卷积核(比如 3、4、5)在词序列上滑动,每种宽度负责捕获不同长度的短语模式,然后对每个卷积核的输出在时间维度上做 max pooling,只保留最强的那一个响应。这样一句 20 个词的句子最后被压成一个固定长度的向量,和句长无关,接个全连接就能分类。

和另外两条路线比:

  • BiLSTM:串行计算,长依赖建模更强,但在 SemEval 这种短句数据集上优势不明显,训练慢 3 到 5 倍。如果你的数据是长文档、跨句子关系,那 LSTM 或 Transformer 更合适。
  • BERT 类预训练模型:这个数据集上 F1 能到 0.89 左右,比 CNN 高 10 个点以上。代价是显存、推理延迟和标注成本,而且在小样本业务场景里,预训练模型微调不好照样不如 CNN。
  • CNN:单卡几十分钟跑完一轮完整实验,改一行代码就能换特征,作为对照组和教学载体几乎没有替代品。

所以这个项目的定位很明确:它不是 SOTA 复现,它是一个结构透明、可以逐层拆开看的 baseline。你要做的第一件事不是调它,是理解它,然后用它当你自己方案的对照组。

2.3 目录结构与依赖环境

一个规规矩矩的 PyTorch 项目应该长什么样,这个包基本给了一个正例:

cnn-relation-extraction/ ├── config.py # 超参、路径、标签映射集中管理 ├── dataset.py # 原始标注解析 + Dataset + collate_fn ├── model.py # 三路 Embedding + 多核卷积 + 分类头 ├── train.py # 训练循环、早停、保存 best checkpoint ├── evaluate.py # 单独加载 ckpt 评测,输出分类报告 ├── utils.py # 位置向量计算、词表构建、seed 固定 ├── data/ │ ├── SemEval2010_task8_all_data/ │ │ ├── SemEval2010_task8_training/TRAIN_FILE.TXT │ │ └── SemEval2010_task8_testing_keys/TEST_FILE_FULL.TXT │ └── embeddings/ # 预训练词向量放这里 └── runs/ # checkpoint 与日志输出

把超参、路径、标签映射全塞进config.py是我强烈建议的习惯。关系抽取实验你至少要改五六个超参,散落在各个文件里,两天后自己都记不清哪个结果对应哪组配置。

环境上没什么玄学,Python 3.8 以上、PyTorch 2.x 都行:

conda create -n re-cnn python=3.9 -y conda activate re-cnn pip install torch numpy tqdm scikit-learn

scikit-learn只用来算classification_report和混淆矩阵,你要自己写 F1 计算也能省掉。词向量是外部依赖,项目本身不带,常见做法是下 100 维的 word2vec 或 GloVe 文本格式放进去——注意是文本格式(每行词 空格 向量),二进制.bin得先转。

代码里读取配置我一般这么写,路径全部做成相对于项目根目录,换机器不用改:

# config.py import os ROOT = os.path.dirname(os.path.abspath(__file__)) class Config: train_file = os.path.join(ROOT, "data/SemEval2010_task8_all_data/" "SemEval2010_task8_training/TRAIN_FILE.TXT") test_file = os.path.join(ROOT, "data/SemEval2010_task8_all_data/" "SemEval2010_task8_testing_keys/TEST_FILE_FULL.TXT") emb_file = os.path.join(ROOT, "data/embeddings/word2vec.txt") emb_dim = 100 # 词向量维度,必须和 emb_file 对齐 pos_dim = 5 # 位置向量维度,随机初始化 max_pos = 30 # 相对距离截断阈值,超过 ±30 一律算 30 max_len = 100 # 句子最大长度,SemEval 平均 19,100 足够 num_filters = 230 # 每种卷积核的输出通道数 kernel_sizes = (3, 4, 5) dropout = 0.5 num_classes = 19 batch_size = 50 lr = 1e-3 epochs = 50 patience = 5 # 验证集 F1 连续 5 轮不涨就停

这里每个参数都有它的来处。max_pos=30是原论文的设定,因为绝大多数实体到其他词的距离都在 30 以内,截断能避免位置嵌入表过大。num_filters=230对应 100 维词向量加两路 5 维位置向量拼出来的 110 维,卷积核宽度取 3、4、5 覆盖三元组到五元组短语。batch_size=50偏小是因为训练集只有 8000 条,batch 开太大会导致一轮只有 160 步,梯度更新次数不够。

3. 数据管线拆解:从 SemEval 原始标注到带位置向量的 batch

3.1 原始标注格式长什么样

拿到数据集第一件事是打开文件看一眼,别急着写 Dataset。SemEval 的TRAIN_FILE.TXT是这么组织的,一条样本占四行,样本之间空一行:

8001 "The <e1>company</e1> fabricates <e2>products</e2>." Product-Producer(e2,e1) Comment: 8002 "The <e1>man</e1> was born in <e2>Berlin</e2>." Other Comment:

第一行是编号加带实体标记的原句,<e1>...</e1>标出第一个实体;第二行是关系标签,注意这里的写法Product-Producer(e2,e1)带方向;后两行是注释,直接丢。

解析要做的三件事:抽出带标记的句子、记下两个实体在去掉标记后的 token 序列里的起止下标、把(e2,e1)这种方向信息翻译成 18 类里唯一的一个整数。

# dataset.py import re RELATIONS = [ "Cause-Effect", "Instrument-Agency", "Product-Producer", "Content-Container", "Entity-Destination", "Entity-Origin", "Member-Collection", "Message-Topic", "Component-Whole", ] # 展开成 18 个带方向标签 + Other = 19 LABELS = [f"{r}(e1,e2)" for r in RELATIONS] + \ [f"{r}(e2,e1)" for r in RELATIONS] + ["Other"] LABEL2ID = {lab: i for i, lab in enumerate(LABELS)} def parse_file(path): samples = [] with open(path, encoding="utf-8") as f: lines = [ln.rstrip("\n") for ln in f if ln.strip()] # 每 4 行一个样本:id行、句子行、标签行、注释行 for i in range(0, len(lines), 4): sent_line = lines[i + 1].strip().strip('"') label_line = lines[i + 2].strip() tokens, e1_span, e2_span = tokenize_with_spans(sent_line) samples.append({ "tokens": tokens, "e1": e1_span, # (start, end) 闭区间,去掉标记后的下标 "e2": e2_span, "label": LABEL2ID[label_line.replace(" ", "")], }) return samples def tokenize_with_spans(sent): """把 <e1>xxx</e1> 拆掉,同时记录实体 token 的起止下标""" tokens, e1, e2 = [], None, None for tok in sent.split(): if tok.startswith("<e1>"): e1 = (len(tokens), None) if tok.startswith("<e2>"): e2 = (len(tokens), None) clean = re.sub(r"</?e[12]>", "", tok) if clean: # 标记独占一个 token 时不产生空词 tokens.append(clean) if tok.endswith("</e1>"): e1 = (e1[0], len(tokens) - 1) if tok.endswith("</e2>"): e2 = (e2[0], len(tokens) - 1) return tokens, e1, e2

tokenize_with_spans这段是关键。实体边界必须落在去标记之后的下标体系上,因为后面算位置向量用的是这个下标。如果你先算位置再删标记,所有位置会整体偏移一到两个 token,模型学到的是错的相对距离——这个错误不会报错,只会让你 F1 莫名其妙低 10 个点。

另一个细节是实体本身怎么喂。两种常见做法:一是保留原词,让词向量去表达;二是把实体整体替换成特殊符号<e1>、<e2>,让模型只关注「这里有个实体」而不关注它是什么。后者在实体词在训练集里没出现过时更稳。这个项目走的是第一种,简单,但你二次开发时可以两种都试,很多时候第二种能涨一个点。

3.2 位置向量:把「实体在哪」变成可学习特征

这是 CNN 关系抽取里最值得讲的一块,也是它区别于普通文本分类的地方。

光有词向量,模型看到的是the company fabricates products,它不知道 company 是 e1、products 是 e2。而「谁是谁」直接决定关系方向。位置向量(Position Embedding)解决的就是这件事:对句子里的每个 token,计算它到 e1 的距离和到 e2 的距离,截断到[-max_pos, max_pos],各自查一张随机初始化的嵌入表,得到两个pos_dim维向量。

举个例子,max_pos=30,某个 token 在 e1 后面第 4 个位置,那它到 e1 的距离是 +4,索引是4 + 30 = 34;在 e1 前面第 7 个位置,距离是 -7,索引是 23。加上偏移是为了消灭负索引——PyTorch 的nn.Embedding遇到负索引会当成倒数第几个取,不报错,结果全错。

# utils.py import torch def position_features(length, e1_span, e2_span, max_pos=30): """返回两条位置索引序列,取值 [0, 2*max_pos]""" e1_start, e1_end = e1_span e2_start, e2_end = e2_span # 实体用中心位置代表,多 token 实体也不会失真 e1_center = (e1_start + e1_end) // 2 e2_center = (e2_start + e2_end) // 2 p1 = torch.zeros(length, dtype=torch.long) p2 = torch.zeros(length, dtype=torch.long) for i in range(length): d1 = max(-max_pos, min(max_pos, i - e1_center)) d2 = max(-max_pos, min(max_pos, i - e2_center)) p1[i] = d1 + max_pos p2[i] = d2 + max_pos return p1, p2

三个参数说明一下。max_pos决定嵌入表大小,表长是2*max_pos+1,取 30 就是 61 行,非常小,不会过拟合。用实体中心而不是起点,是为了多词实体(比如New York City)时距离分布更对称。截断用min/max双向夹,别只夹上界,负方向超了同样会越界。

有人会问:为什么不直接把「到实体的距离」当数值特征拼进去?因为距离和关系类型的关系不是线性的——距离 3 和距离 4 的语义差异,可能比距离 3 和距离 20 的差异还大。做成可学习的嵌入,模型自己决定怎么用这些距离桶。

3.3 Dataset 与 batch 组装

到这一步,一条样本已经被拆成四样东西:token id 序列、两条位置索引序列、标签。Dataset 负责把它们变成 tensor,collate_fn负责把变长句子 pad 成同一个长度。

# dataset.py(接上文) import torch from torch.utils.data import Dataset from utils import position_features class REDataset(Dataset): def __init__(self, samples, vocab, max_len=100, max_pos=30): self.samples = samples self.vocab = vocab self.max_len = max_len self.max_pos = max_pos self.pad_id = vocab["<pad>"] self.unk_id = vocab["<unk>"] def __len__(self): return len(self.samples) def __getitem__(self, idx): s = self.samples[idx] ids = [self.vocab.get(t.lower(), self.unk_id) for t in s["tokens"]] # 超长就截断,但要保证两个实体都还在窗口内 if len(ids) > self.max_len: keep_start = max(0, min(s["e1"][0], s["e2"][0]) - self.max_len // 2) ids = ids[keep_start: keep_start + self.max_len] e1 = (s["e1"][0] - keep_start, s["e1"][1] - keep_start) e2 = (s["e2"][0] - keep_start, s["e2"][1] - keep_start) else: e1, e2 = s["e1"], s["e2"] p1, p2 = position_features(len(ids), e1, e2, self.max_pos) return { "input_ids": torch.tensor(ids, dtype=torch.long), "pos1": p1, "pos2": p2, "label": torch.tensor(s["label"], dtype=torch.long), } def collate_fn(batch, pad_id=0): max_len = max(x["input_ids"].size(0) for x in batch) out = {"input_ids": [], "pos1": [], "pos2": [], "label": []} for x in batch: gap = max_len - x["input_ids"].size(0) out["input_ids"].append(torch.cat([x["input_ids"], torch.full((gap,), pad_id, dtype=torch.long)])) # 位置向量 pad 位补 max_pos,等价于「距离 0」,不影响池化 out["pos1"].append(torch.cat([x["pos1"], torch.full((gap,), 30, dtype=torch.long)])) out["pos2"].append(torch.cat([x["pos2"], torch.full((gap,), 30, dtype=torch.long)])) out["label"].append(x["label"]) return {k: torch.stack(v) for k, v in out.items()}

__getitem__里的截断逻辑值得多说一句。直接ids[:max_len]是最省事的写法,但如果实体在句子后半段,一截就把实体截没了,位置向量算出来全是边界值,这条样本等于噪声。用实体位置做中心去截,能保证窗口一定覆盖两个实体。SemEval 句长短,这个分支基本走不到,但你把模型挪到新闻正文、专利摘要上,它就是救命的那行。

collate_fn里 pad 位的位置索引补max_pos(即距离 0),是因为卷积会扫过 pad 区域,如果补 0(距离 -30),模型会以为句子末尾一堆词都离实体极远,反而引入假信号。这是个很细的设定,属于我踩过坑之后固定下来的写法。

4. 模型与训练逐段讲:Embedding 拼接、多核卷积与 micro-F1 评估

4.1 输入层:三路 Embedding 拼成一条 110 维的序列

模型的第一层做两件事:查词向量、查位置向量,然后把三者在最后一维上 concat。

# model.py import torch import torch.nn as nn class CNNRelationExtractor(nn.Module): def __init__(self, vocab_size, num_classes=19, emb_dim=100, pos_dim=5, max_pos=30, num_filters=230, kernel_sizes=(3, 4, 5), dropout=0.5, pretrained_emb=None): super().__init__() self.word_emb = nn.Embedding(vocab_size, emb_dim, padding_idx=0) if pretrained_emb is not None: # 拷贝预训练权重,OOV 与 pad 保持随机/零 self.word_emb.weight.data.copy_(pretrained_emb) pos_table_size = 2 * max_pos + 1 self.pos_emb1 = nn.Embedding(pos_table_size, pos_dim) self.pos_emb2 = nn.Embedding(pos_table_size, pos_dim) in_channel = emb_dim + 2 * pos_dim # 100 + 5 + 5 = 110 self.convs = nn.ModuleList([ nn.Conv2d(1, num_filters, (k, in_channel)) for k in kernel_sizes ]) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(num_filters * len(kernel_sizes), num_classes)

padding_idx=0让 pad 位置的词向量在反向传播时梯度恒为零,不会被训练污染。预训练词向量拷进去之后要不要 freeze,是个需要实验的决定:数据量 8000 条的情况下,我一般先 freeze 跑一轮看 baseline,再解冻微调看涨不涨,涨了就解冻,掉了就锁死。两种都合理,但必须两个都试过再下结论。

Conv2d(1, num_filters, (k, in_channel))这个写法来自 Kim CNN 那一脉。为什么用二维卷积而不是一维?因为核的高度是k(窗口内几个词),宽度直接等于整个特征维度in_channel,等价于在词序列上滑一个k元窗口,输出是(batch, num_filters, L-k+1, 1)。用Conv1d也能做,但二维写法在后续接 pool 时形状更直观。

4.2 前向传播:卷积、时间维 max pooling、拼接分类

def forward(self, input_ids, pos1, pos2): w = self.word_emb(input_ids) # (B, L, 100) p1 = self.pos_emb1(pos1) # (B, L, 5) p2 = self.pos_emb2(pos2) # (B, L, 5) x = torch.cat([w, p1, p2], dim=-1) # (B, L, 110) x = x.unsqueeze(1) # (B, 1, L, 110) pooled = [] for conv in self.convs: c = torch.relu(conv(x)) # (B, F, L-k+1, 1) c = c.squeeze(-1) # (B, F, L-k+1) c = torch.max(c, dim=2)[0] # (B, F) 时间维取最大 pooled.append(c) feat = torch.cat(pooled, dim=1) # (B, F * 3) feat = self.dropout(feat) return self.fc(feat) # (B, 19) logits

squeeze(-1)那一步不能省。卷积输出是四维,如果在时间维取 max 之前忘了压掉最后一维,torch.max(c, dim=2)会得到一个元组,后续torch.cat直接报维度错——这是这份代码里最容易被改坏的地方。

max pooling 的作用是选出每个卷积核在整个句子上最强的响应。好处是输出长度与句长无关,坏处是丢掉了位置信息,一句话里出现两次相同模式,模型只知道「至少有一次」。对关系抽取这种单实体对任务基本够用,但如果你的任务是判断「同一对实体在文中有几种关系」,就得换 attention pooling。

dropout=0.5放在池化之后、全连接之前,这是标准位置。8000 条数据配 690 维特征,过拟合来得很快,dropout 调到 0.3 以下基本就是自找麻烦。

4.3 训练循环与早停

训练脚本没有花活,核心是固定 seed、按验证集 F1 早停、只保存最优 checkpoint。

# train.py(核心片段) import random, numpy as np, torch from torch.utils.data import DataLoader def set_seed(seed=42): random.seed(seed); np.random.seed(seed) torch.manual_seed(seed); torch.cuda.manual_seed_all(seed) def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss = 0.0 for batch in loader: input_ids = batch["input_ids"].to(device) pos1, pos2 = batch["pos1"].to(device), batch["pos2"].to(device) labels = batch["label"].to(device) optimizer.zero_grad() logits = model(input_ids, pos1, pos2) loss = criterion(logits, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() total_loss += loss.item() * labels.size(0) return total_loss / len(loader.dataset)

clip_grad_norm_(max_norm=5.0)在纯 CNN 上不是必需品,但加上无害,后期你把编码器换成 LSTM 时它就是防止 loss 变 NaN 的那道保险。

早停的判据用验证集上的macro-F1(排除 Other),不用 loss,也不用 accuracy。原因很直接:19 类分布不均衡,Other 占了近 18%,模型全猜 Other 也能有 0.18 的准确率,但 F1 会是 0。用 accuracy 做早停判据,你会眼睁睁看着一个废模型被保存下来。

def macro_f1_excluding_other(y_true, y_pred, num_rel=18, other_id=18): """官方口径:只在前 18 类上算 macro-F1,Other 不参与""" f1s = [] for c in range(num_rel): tp = sum(1 for t, p in zip(y_true, y_pred) if t == c and p == c) fp = sum(1 for t, p in zip(y_true, y_pred) if t != c and p == c) fn = sum(1 for t, p in zip(y_true, y_pred) if t == c and p != c) if tp + fp + fn == 0: continue prec = tp / (tp + fp) if tp + fp else 0.0 rec = tp / (tp + fn) if tp + fn else 0.0 f1s.append(2 * prec * rec / (prec + rec) if prec + rec else 0.0) return sum(f1s) / len(f1s) if f1s else 0.0

4.4 评估口径:为什么你自己算的 F1 和论文对不上

这是最高频的困惑。你训练完,sklearn的f1_score(average='macro')一跑,得到 0.62,论文里写的是 0.78,差 16 个点,怀疑人生。

差别在口径。官方评测脚本的做法是:把 18 个带方向的类合并回 9 个语义类型(方向忽略),Other 单独处理,最终分数是在 9 个关系类型上的 macro-F1,且评估时 Other 类别不进入平均。你直接用 19 类做 macro 平均,Other 这个又大又难分的类会把平均值拉下来一大截。

所以评估脚本里必须做两件事:一是把预测结果和标签的方向合并,二是排除 Other 再平均。项目里evaluate.py单独拆出来的意义就在这——它只加载 checkpoint、只跑测试集、只按一种口径输出,避免训练过程中口径飘忽。

提示:调参期间用一个口径,汇报结果用另一个口径,是最容易出的事故。我习惯在evaluate.py里同时打印两套数:19 类 macro-F1 用于观察模型内部是否均衡,官方口径 F1 用于横向对比。两个数都记日志,别只记一个。

至于能跑到多少:这个架构在 SemEval-2010 Task 8 上的公开结果是 0.78 上下,你自己的实现落在 0.75 到 0.82 之间都算正常,低于 0.70 基本可以判定管线有问题,去下一章对号入座。

5. 避坑与排查:5 个让 F1 掉 10 个点以上的真实问题

5.1 现象:训练 loss 一路降到 0.1,测试 F1 只有 0.3

原因:位置索引出现负值,nn.Embedding把负数当倒数索引取,位置向量全是错的但程序不报错。

解决:在position_features里打印一次p1.min()和p1.max(),正常范围应该是[0, 2*max_pos]。如果看到负数,说明偏移量没加或者加了之后又减回去了。这个错最阴险的地方在于它不崩,只是让模型学不到方向信息,而方向占了 18 个类里的一半。

5.2 现象:验证集 F1 每轮上下跳 3 个点,不知道哪轮才是真实水平

原因:训练集只有 8000 条,batch 50 意味着一轮 160 步,梯度噪声大;加上 dropout 的随机性,单次实验的方差本来就大。

解决:三件事一起做。固定 seed(random、numpy、torch、cuda四个都要);batch 提到 64 但 lr 同步降到 8e-4;最终结果用 3 个不同 seed 各跑一次取均值和标准差。单次跑出 0.79 就宣称模型好,是很不专业的做法——你换个 seed 可能就是 0.75。

5.3 现象:加载预训练词向量后 F1 反而比随机初始化低

原因:词表没对齐。最常见的是预训练词向量区分大小写而代码里token.lower()了,或者相反;其次是word_emb.weight.data.copy_()的矩阵行顺序和vocab的 id 映射对不上,等于把词向量随机打乱了塞进去。

解决:加载完立刻算命中率并打印:

hit = sum(1 for w in vocab if w in emb_matrix) print(f"vocab size={len(vocab)}, hit={hit}, rate={hit/len(vocab):.2%}")

SemEval 上正常命中率在 85% 以上。低于 70% 说明大小写或词形处理不一致,先统一再谈效果。另外记得padding_idx=0那一行要在copy_之后确认没被覆盖成非零向量。

5.4 现象:长句子样本的预测几乎全是 Other

原因:截断把实体截出了窗口。ids[:max_len]这种写法,实体在 100 号位置之后的样本,位置向量全是边界值,模型等于看不到实体。

解决:用实体位置做中心截断(第 3.3 节的写法),并且在 Dataset 里加一句断言,检查截断后0 <= e1[0] <= e1[1] < len(ids)。断言在数据预处理阶段加,比在模型里加有用一百倍——模型层的断言只能告诉你形状不对,数据层的断言能告诉你哪条样本坏了。

5.5 现象:GPU 显存爆了,或者一个 epoch 跑了 40 分钟

原因:Conv2d的输入忘了unsqueeze(1),或者max之后忘了squeeze(-1),形状变成四维往下传,全连接层的参数量会翻几十倍。

解决:在forward里临时插一行print(x.shape),逐层确认形状:(B,L,110) → (B,1,L,110) → (B,230,L-k+1,1) → (B,230,L-k+1) → (B,230)。这条形状链背下来,以后遇到任何维度报错都能三秒定位。另外注意max_len=100配kernel_sizes=(3,4,5)时,最短的句子(可能只有 5 个 token)卷积后时间维只剩 1 到 3,不会报错但池化信息极少,可以考虑给短句做 pad 到最小长度。

6. 二次开发:换掉词向量和编码器之后,怎么确认自己没改坏

这份代码真正的价值不在它本身跑多少分,在于它提供了一个干净的接口边界:dataset.py负责「文本 → 三个 tensor」,model.py负责「三个 tensor → logits」。所有改造都在这条边界的两侧发生,互不干扰。

第一种改法,换数据集。你自己的标注只要整理成(句子, e1 起止, e2 起止, 标签)四元组,改parse_file和LABEL2ID就能跑。但有两个前提别忽略:类别数变了要同步改config.num_classes和分类头;你的标签如果天然没有方向,就把 18 类压成 9 类,别硬套。

第二种改法,换编码器。把卷积层替换成 BiLSTM 或接一个预训练模型,只要保证输出仍是(B, hidden)的张量,后面的fc一行不用动:

class BiLSTMEncoder(nn.Module): def __init__(self, in_dim=110, hidden=128, layers=1): super().__init__() self.lstm = nn.LSTM(in_dim, hidden, layers, batch_first=True, bidirectional=True) self.out_dim = hidden * 2 def forward(self, x): # x: (B, L, 110) h, _ = self.lstm(x) return torch.max(h, dim=1)[0] # (B, 256),与 CNN 版对齐

接口对齐之后,你可以拿同一份train.py跑两个编码器做对照,超参完全一致,这样比出来的差异才归因于模型结构,而不是调参运气。

第三种改法,加特征。这个架构最容易被低估的扩展点是实体标记:把实体整体替换成<e1>/<e2>特殊符号,再额外拼上实体类型的 embedding,通常在 SemEval 上能再拿一到两个点。

改造之后怎么验证没改坏,我固定走三步。第一步,拿 100 条训练样本过拟合,loss 必须能干到接近 0、训练集 F1 接近 1.0——如果做不到,说明数据管线或反向传播断了,别往下走。第二步,用原来的超参和 seed 复现一遍 baseline,分数应该和你改造前记录的数字在 ±0.5 以内,超了说明你无意中改了别的东西。第三步,跑 3 个 seed 取均值,和 baseline 的 3 seed 均值比,差值小于 1 个点就当没提升。

这套流程是我被坑出来的。早些年改完模型看到 F1 从 0.76 涨到 0.81,兴奋了半天,后来发现是那次随机 seed 抽得好,换一个 seed 就回到 0.75。从那以后我每次动模型结构,都强制先把 100 条过拟合测试和 3 seed 基线跑一遍,宁可多花二十分钟,也不想再对着一个假提升写两天结论。希望这份拆解能帮你少走一遍这个弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询