资讯详情

资讯详情

CNN关系抽取实战:SemEval2010标签方向与位置特征避坑

简介面向自然语言处理学习者与具备 PyTorch 基础的开发者这份 CNN 关系抽取工程围绕 SemEval2010_task8 数据集展开目标是判定句子中两个实体间的预定义关系或无关系适合作为文本分类与序列建模的入门实战也可用于课程设计与算法复现实验。压缩包共 43 个文件约 36.95MB以 7 个 Python 脚本、10 个文本说明与配置、8 个缓存文件、6 个工程配置文件为主另含词向量包、日志、实验记录与模型结构图目录按数据、词向量、模型、实验等模块组织便于查阅。已有 745 人学习。工程覆盖数据集构建、词表生成、加载、CNN 定义、训练与评估脚本读者可复现嵌入、卷积、池化到全连接分类的链路理解交叉熵损失与优化器更新并用精确率、召回率、F1 值检验效果。示意图和实验记录也有助于梳理调参排错思路。1. 拿到 zip 先别急着 pip install这个 CNN 关系抽取项目解决的是哪一类问题上周有人把一个 CNN关系抽取模型.zip 丢给我说解压之后python train.py报了一串错好不容易改成能跑F1 只有 0.31怀疑模型是坏的。我打开一看模型没坏坏的是标签映射——他把Cause-Effect(e1,e2)和Cause-Effect(e2,e1)当成了同一个类拍平19 类缩成 10 类方向信息全丢分数自然掉一大截。这个项目干的事情其实很明确在 SemEval2010_task8 上做关系抽取输入一句话和句中标注好的两个实体输出它们之间属于 9 种语义关系中的哪一种或者什么关系都没有Other。整条链路用 PyTorch 手写不依赖任何现成的 NLP 框架从读原始标注、构词表、算位置特征到 CNN 前向、训练、评估代码量不大但五脏俱全。适合两类人一类是想跑通一个完整 NLP 训练流程的新手另一类是把它当 baseline、准备往里塞 BERT 或 PCNN 的老手。2. 技术选型为什么这套 CNN 在关系抽取上还站得住2.1 任务拆解SemEval2010_task8 的 19 类标签从哪来先把标签这件事说透后面所有代码都围着它转。原始标注里一共 9 种语义关系Cause-Effect、Instrument-Agency、Product-Producer、Content-Container、Entity-Origin、Entity-Destination、Component-Whole、Member-Collection、Message-Topic。每一种关系都有方向性Cause-Effect(e1,e2)表示 e1 导致 e2Cause-Effect(e2,e1)反过来这两者在数据集里算两个不同的类。9 × 2 18再加一个兜底的Other正好 19 类。原始文件长这样一行句子一行标签中间夹着空行和注释1 The e1microphone/e1 converts sound into an electrical signal, which is then sent to the e2earphone/e2 where it is converted back into sound. Cause-Effect(e2,e1) 2 The scientists are using e1satellite/e1 images to map the e2forest/e2 coverage. Other这里有个容易忽略的细节句子里的e1出现在e2前面但标签写的是Cause-Effect(e2,e1)。也就是说标签括号里的顺序才是语义上的主客体顺序跟句子里谁先出现没关系。解析的时候如果不做重排模型看到的位置特征和标签就对不上等同往标签里灌噪声。我一般的做法是解析阶段就把 e1、e2 统一成“标签括号里第一个实体”和“第二个实体”位置特征也按这个顺序算这样 19 类里 18 个方向类才不会互相打架。数据规模上训练集 8000 句、测试集 2717 句句子平均长度不到 20 个 token最长也就 100 出头。这个体量决定了后面所有超参的取值——句子短max_len给 100 绰绰有余数据少模型容量一大就过拟合所以单层卷积加 dropout 反而是合适的。2.2 网络选型词向量 位置向量 单层卷积的取舍关系抽取的核心难点不在“理解整句话”而在“抓住两个实体之间的局部模式”。比如[实体A] was founded by [实体B]这种结构判定 Product-Producer 靠的就是实体和中间那几个词构成的局部 n-gram不需要把整句的句法树都建出来。CNN 天生适合干这个卷积核在词序列上滑一遍相当于在找各种长度固定的模式池化再把这些模式里最强的响应留下来。项目里走的是这个组合预训练词向量300 维 两个位置向量各 5 维 单层一维卷积窗口 3230 个 filter 最大池化 全连接分类。位置向量是这类任务的关键——对每个 token分别算它到 e1 和 e2 的距离把距离离散化后查表得到向量。距离信息让卷积核能感知“我现在离实体近不近”没有它模型只能靠词本身猜F1 通常会掉 8 到 10 个点。跟同期的其他路线比一下取舍就很清楚了方案优点代价适用场景CNN 位置向量训练快、参数少、单卡几十分钟抓不到长距离依赖句子短、样本少做 baselineBiLSTM Attention能建模长距离慢一倍小数据易过拟合句子长、有依存信息BERT 微调上限最高F1 能到 0.89显存、训练时间、标注量都翻倍有 GPU、追指标先跑通 CNN 这一版的意义在于它把“数据管线 训练循环 评估口径”这三件事全部暴露出来任何一个环节写错分数都会立刻塌定位起来比 BERT 那种黑盒直接得多。等你确认管线没问题了再换编码器改动量其实很小第 6 章会说怎么换。2.3 运行环境与目录依赖跑通前必须备齐的三样东西环境上没什么玄学torch 1.8、numpy、tqdm就够跑scikit-learn只在你想顺手打几个指标时用得上。真正需要提前准备的是三样外部资源缺一样都跑不起来第一是原始数据。SemEval2010_task8 的训练和测试文件一般是TRAIN_FILE.TXT和TEST_FILE.TXT两个要放到data/下。注意有些流传的版本已经被人改过标签里的逗号是全角的解析时会挂拿到手先head看一眼。第二是预训练词向量。项目默认读 300 维的vec.txt格式是每行词 数字 数字 ...。没有的话模型也能跑词向量随机初始化但小数据上随机初始化基本等于放弃治疗F1 会卡在 0.6 上下上不去。词表大小控制在 2 万以内就够SemEval 这个数据集词汇量本来就小。第三是官方打分脚本一个 perl 脚本。perl -v先确认环境里有解释器很多精简过的容器镜像里没有报错信息还特别隐晦。提示位置向量表的行数是2 * clip 1clip 默认取 30也就是 61 行。如果你想改 clip模型初始化和数据预处理两边必须同时改只改一边会直接 index out of range。3. 数据管线从原始句子到四张张量的四步3.1 目录骨架这份资源里每个文件该放在哪解压之后别急着改代码先把结构理顺。这个项目按职责拆得比较干净大致是这样cnn-re/ ├── data/ │ ├── TRAIN_FILE.TXT # 原始训练集带 e1/e2 标记 │ ├── TEST_FILE.TXT # 原始测试集 │ └── vec.txt # 300 维预训练词向量 ├── config.py # 超参、路径、标签映射集中在这里 ├── preprocess.py # 解析标注、建词表、算位置特征 ├── dataset.py # Dataset 与 collate_fn ├── model.py # CNNRelationExtractor ├── train.py # 训练 验证 早停 ├── evaluate.py # 生成测试集预测文件 └── utils.py # 日志、随机种子、指标计算真正需要你动的只有config.py路径、max_len、clip、batch_size、学习率全在里面。这种“配置集中、逻辑分散”的组织方式比把所有参数塞在train.py顶部要好排查得多尤其是你同时开好几组实验对比的时候。3.2 解析原始标注把e1/e2拆成实体下标原始句子里的实体是用 XML 风格的标签包起来的而且标签和单词之间没有空格e1microphone/e1这种。不能简单用字符串替换再加空格那样会把标点也粘在一起。比较稳的写法是扫一遍标签位置把标签之间的文本按空白切词import re TAG_RE re.compile(r(/?)e([12])) def parse_sentence(raw: str): 把一行原始句子拆成 token 列表 两个实体的 (起点, 终点) 下标 text raw.strip().strip() tokens, spans, cursor [], {1: [], 2: []}, 0 for m in TAG_RE.finditer(text): # 标签之前的普通文本直接切词接上去 tokens.extend(text[cursor:m.start()].split()) cursor m.end() idx int(m.group(2)) if m.group(1) : # 开标签记录实体起始 token spans[idx].append(len(tokens)) else: # 闭标签记录实体最后一个 token spans[idx].append(len(tokens) - 1) tokens.extend(text[cursor:].split()) # 多词实体比如 New York只取首尾后面算距离用的就是这两个点 return tokens, (spans[1][0], spans[1][1]), (spans[2][0], spans[2][1])这段逻辑里有两个点值得说明。第一闭标签记录的是len(tokens) - 1因为开标签之后实体词已经被切进tokens了此时len(tokens)指向的是实体的下一个位置。第二多词实体e1New York/e1会拿到一个区间而不是单个下标后面算位置特征时用区间中心比只用左边界稳——否则New York里的York距离被算成 1 而不是 0方向信息会偏。解析完标签还要做一步方向归一化根据标签字符串里的(e1,e2)还是(e2,e1)决定要不要把两个实体的 span 互换保证抛给模型的永远是“主体在前、客体在后”。这一步不做19 类里 18 个带方向的类会互相污染是新手最容易踩的坑第 5 章会细说。3.3 位置特征与词表两个最容易写错的地方位置特征的定义很朴素对句子中第 i 个 token算它到实体中心的距离截断到[-clip, clip]再平移到[0, 2*clip]当作 embedding 的下标。def position_ids(length: int, span, clip: int 30): span 是 (start, end)返回长度 length 的位置 id 列表 center (span[0] span[1]) / 2.0 ids [] for i in range(length): d int(round(i - center)) d max(-clip, min(clip, d)) # 截断超出范围的一律压到边界 ids.append(d clip) # 平移成非负下标范围 [0, 2*clip] return ids这里有两个坑必须提前说清楚。第一个是平移。距离本身可以是负数但nn.Embedding的下标不能是负数——PyTorch 对负索引不报错会静默地从表尾往前取于是-1取到了第 60 行-30取到了第 31 行模型照样训练、loss 照样降只是学到的位置语义全是错的。这种 bug 不看 loss 曲线根本发现不了属于典型的玄学故障。第二个是截断方向。先截断再平移和先平移再截断结果完全不同一定要先clip再clip。词表这边原则只有一条只用训练集建表。pad固定 id 0unk固定 id 1其余按词频降序排。测试集里出现的未登录词一律映射到 1。如果图省事拿全量数据建表本质上是把测试集的词频信息泄漏进训练dev 上的分数会好看得离谱一上真实数据就原形毕露。from collections import Counter class Vocab: def __init__(self, min_freq: int 1): # 0 号必须是 pad因为 embedding 的 padding_idx 要指向它 self.itos [pad, unk] self.stoi {pad: 0, unk: 1} self.min_freq min_freq def build(self, token_lists): counter Counter(t for toks in token_lists for t in toks) for word, freq in counter.most_common(): if freq self.min_freq: self.stoi[word] len(self.itos) self.itos.append(word) def encode(self, tokens): return [self.stoi.get(t, 1) for t in tokens]min_freq默认给 1因为这个数据集总共才 8000 句词表本来就不大如果你的数据里长尾词特别多调到 2 能省一点显存代价是 OOV 变多。3.4 Dataset 与动态 paddingbatch 内对齐的写法句子长度参差不齐必须 pad 到统一长度才能组 batch。有两种做法全局 pad 到max_len100或者每个 batch 内 pad 到 batch 最大长度。前者实现简单、显存浪费一点后者省显存但 mask 每批都不一样。项目里用的是前者因为句子本来就短100 的长度浪费不了多少。import torch from torch.utils.data import Dataset class RelationDataset(Dataset): def __init__(self, samples, vocab, max_len100, clip30): self.samples samples # [(tokens, span1, span2, label_id), ...] self.vocab vocab self.max_len max_len self.clip clip def __len__(self): return len(self.samples) def __getitem__(self, i): tokens, span1, span2, label self.samples[i] tokens tokens[:self.max_len] # 超长直接截断别用滑动窗口 ids self.vocab.encode(tokens) p1 position_ids(len(tokens), span1, self.clip) p2 position_ids(len(tokens), span2, self.clip) real_len len(ids) pad_n self.max_len - real_len # mask: 1 表示真实 token0 表示 padding池化前要用它 mask [1] * real_len [0] * pad_n ids [0] * pad_n p1 [self.clip] * pad_n # padding 位置的位置 id 用 0 距离占位 p2 [self.clip] * pad_n return { token_ids: torch.tensor(ids, dtypetorch.long), pos1: torch.tensor(p1, dtypetorch.long), pos2: torch.tensor(p2, dtypetorch.long), mask: torch.tensor(mask, dtypetorch.float), label: torch.tensor(label, dtypetorch.long), }注意 padding 位置的位置 id 用的是clip对应距离 0不是 0对应距离 -30。这看着是小细节但因为后面池化会被 mask 掉其实影响不大真正重要的是mask这个字段必须一路传到模型里少了它第 4 章的池化就会出问题。4. 模型与训练链路一次前向传播里发生了什么4.1 嵌入层word position 的拼接顺序三张 embedding 表拼起来当作卷积的输入通道。拼接顺序本身不敏感但要和卷积的in_channels对上word_dim 2 * pos_dim 300 10 310。import torch import torch.nn as nn class CNNRelationExtractor(nn.Module): def __init__(self, vocab_size, num_class, word_dim300, pos_dim5, num_filters230, kernel_size3, clip30, dropout0.5, pretrainedNone, freezeFalse): super().__init__() self.word_emb nn.Embedding(vocab_size, word_dim, padding_idx0) if pretrained is not None: # 预训练词向量必须整表拷贝尺寸不一致会直接报错这反而是好事 self.word_emb.weight.data.copy_(pretrained) if freeze: self.word_emb.weight.requires_grad False # 位置表大小 2*clip1第 3 章里平移后的下标正好落在这个区间 self.pos1_emb nn.Embedding(2 * clip 1, pos_dim) self.pos2_emb nn.Embedding(2 * clip 1, pos_dim) self.conv nn.Conv1d(word_dim 2 * pos_dim, num_filters, kernel_size, paddingkernel_size // 2) self.dropout nn.Dropout(dropout) self.fc nn.Linear(num_filters, num_class) def forward(self, token_ids, pos1, pos2, mask): w self.word_emb(token_ids) # B, L, 300 p1 self.pos1_emb(pos1) # B, L, 5 p2 self.pos2_emb(pos2) # B, L, 5 x torch.cat([w, p1, p2], dim-1) # B, L, 310 x self.dropout(x) x x.transpose(1, 2) # B, 310, LConv1d 要通道在前 return xpadding_idx0的作用是让pad对应的词向量在反向传播时梯度恒为 0不会被 padding 拖偏。如果你用了预训练词向量又不设padding_idx第 0 行会被慢慢改成一个奇怪的向量虽然不影响结果但看着别扭。4.2 卷积与 mask 池化短句不被 padding 拖垮的关键一行卷积本身没什么好说的paddingkernel_size // 2保证输出长度和输入一致。真正决定成败的是池化前那一步 maskdef forward(self, token_ids, pos1, pos2, mask): x ... # 省略同上 h torch.relu(self.conv(x)) # B, F, L # 关键padding 位置在池化前置为极小值否则 max 会被 0 附近的偏置干扰 h h.masked_fill(mask.unsqueeze(1) 0, -1e9) h torch.max(h, dim-1).values # B, F h self.dropout(h) return self.fc(h) # B, num_class为什么这行不能省因为nn.Conv1d带 bias即使输入全是 0卷积输出也是 bias 而不是 0。如果一个 batch 里最短的句子只有 8 个 token剩下 92 个位置都是 padding这些位置的卷积输出全是 bias 附近的常数。ReLU 之后如果 bias 是正的这些位置就会保持正值参与 max 池化——池化结果变成了“bias 的最大值”而不是“真实 token 的最大响应”。句子越短被污染得越严重表现出来就是短句子的预测普遍偏向某一类整体 F1 掉 5 个点左右但训练 loss 看着完全正常。注意masked_fill里必须用-1e9这种量级的负数不能用0或-1。ReLU 之后的激活值最小也是 0填-1虽然也能被 max 跳过但如果哪天你把激活换成 LeakyReLU 或者前面加了 BN-1就不保险了。4.3 损失、优化器与早停训练脚本参数逐项说明分类用交叉熵19 类不做类别加权——Other占了大概 17%其余 18 类相对均衡加权反而会让模型倾向于多预测 Other。优化器用 Adam学习率 1e-3如果你冻结了词向量可以放大到 2e-3如果不冻结建议降到 1e-4 到 5e-4避免预训练权重被冲掉。参数建议值说明max_len100数据集句子平均 19 词100 足够覆盖word_dim300必须和预训练词向量维度一致pos_dim5位置向量维度经验值给 10 提升不明显clip30距离截断位置表2*301 61行num_filters230与词向量维度接近再大就开始过拟合kernel_size3覆盖实体与关系词的三元模式dropout0.5嵌入后和池化后各一次batch_size32显存够可以到 64小 batch 噪声大反而有正则效果lr1e-3冻结词向量时可放大微调时降到 1e-4epochs30配合早停patience 设 5训练循环里加一步梯度裁剪成本几乎为零但能省掉很多“loss 突然变 nan”的排查时间def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss 0.0 for batch in loader: token_ids batch[token_ids].to(device) pos1, pos2 batch[pos1].to(device), batch[pos2].to(device) mask batch[mask].to(device) label batch[label].to(device) optimizer.zero_grad() logits model(token_ids, pos1, pos2, mask) loss criterion(logits, label) loss.backward() # 梯度裁剪卷积层 预训练词向量偶尔会炸梯度 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() total_loss loss.item() return total_loss / len(loader)早停的判据用 dev 上的 macro-F1不是 loss。关系抽取里 loss 降而 F1 降的情况非常常见因为模型开始偏向大类。4.4 评估为什么必须用官方 18 类 macro-F1这是整个项目里最容易被糊弄过去、又最影响结论的一环。官方的评估口径是只统计 18 个带方向的关系类Other完全不参与计算最后取这 18 个类 F1 的算术平均。如果你用sklearn的f1_score(averagemacro)直接在 19 类上算分数会凭空高出 8 到 12 个点——因为 Other 类样本多、又好分把平均值拉上去了。拿这个数去和论文比只会得出“我这模型比 SOTA 还强”的错觉。自己实现一份口径一致的计算不难OTHER_ID 18 # 约定 Other 排在最后一类 def macro_f1_excluding_other(y_true, y_pred, num_class19): 按官方口径跳过 Other只对 18 个方向类求 macro-F1 f1s [] for c in range(num_class): if c OTHER_ID: continue 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 0: f1s.append(0.0) # 该类完全没预测对按 0 计入平均 continue precision tp / (tp fp) recall tp / (tp fn) f1s.append(2 * precision * recall / (precision recall)) return sum(f1s) / len(f1s)还有一个协议层面的问题官方用的是在 8000 条训练集上做10 折交叉验证报告的是 10 折平均。项目里如果只切了一次 dev得到的数字和公开结果不可比——单次切分运气好能差 6 个点。想跟文献对齐就得把train.py外面再套一层 K 折每折独立建词表、独立训练、独立评估最后取均值。5. 避坑与排查五个把 F1 打到 0.3 的坑5.1 方向拍平标签统一成 19 类之后分数掉了 10 个点现象训练 loss 正常下降dev accuracy 看着有 0.75但 macro-F1 只有 0.31而且 Cause-Effect 的两个方向混淆严重。原因解析标签时按括号外的前缀字符串直接建映射Cause-Effect(e1,e2)和Cause-Effect(e2,e1)被当成两个类没问题但实体 span 没跟着标签顺序重排。模型学到的是“句子里先出现的实体是主体”而标签说的是“括号里第一个是主体”两者在近一半样本上相反。解决解析阶段就做归一化标签是(e2,e1)就把两个 span 互换保证输入模型的永远是“主体在前”。改完这一处同一个模型 F1 直接从 0.31 到 0.68。5.2 位置索引越界不报错负索引静默取错行现象什么都没报错但位置向量的 embedding 表看起来“学得很怪”可视化出来距离 0 和距离 -1 的向量几乎一样。原因position_ids里先截断没平移负距离直接喂给了nn.Embedding。PyTorch 对负索引不抛异常-1取的是表最后一行-30取的是第 31 行程序照跑不误。解决在position_ids里强制d clip并在__getitem__返回前加一句断言assert min(p1) 0 and max(p1) 2 * clip。这种断言跑一次只要几毫秒但能挡掉一整类沉默错误。5.3 验证集 F1 虚高词表和标准化都在全量数据上做的现象dev 上 F1 0.82换一批新数据测试掉到 0.6。原因Vocab.build传进去的是训练 验证 测试的全部 token未登录词比例被人为压低模型见过了本该没见过的词。解决build只喂训练集验证集和测试集一律走encode。同时检查词向量加载逻辑——vec.txt里没出现的词应该是随机初始化或者 UNK不要拿全量词表去对齐。5.4 训练不收敛预训练词向量被随机初始化覆盖现象第一个 epoch loss 就在 2.9 附近不动F1 一直是 0.05 左右。原因nn.Embedding先被随机初始化load_pretrained的时候词表顺序和vec.txt的行顺序没对上或者维度不匹配导致copy_报错被try/except吞掉了。解决加载预训练时打印hit_rate 命中数 / 词表大小正常应该在 0.85 以上。低于 0.6 就是词表或文件路径出了问题。另外别用try/except包住copy_尺寸不匹配就该让它报错。5.5 打分跑不通perl 缺失与输出格式错位现象evaluate.py跑完输出了预测文件但打分脚本要么报perl: command not found要么打出 0.00。原因前者是环境里没装解释器后者多半是输出格式不对——官方脚本要求每行序号\t标签标签必须严格写成Cause-Effect(e1,e2)这种带括号的形式用 Tab 分隔而不是空格。序号还得和测试文件的顺序一一对应中间差一行全错。解决先在evaluate.py里把预测结果按原测试文件的行号写回去格式手动拼字符串而不是靠str(list)装完 perl 先用 10 条样本手工核对一遍再看全量分数。6. 二次开发把 CNN 换成 PCNN、BERT 与对抗训练的改造路径这个项目的价值不在它现在的分数而在于它把一个完整训练链路拆得足够干净换任何一块都不用动全身。我按改动成本从低到高排了三条路。第一条加分段池化PCNN改动不到 30 行。现在的 max pooling 是把整句压成一个向量但关系抽取真正该盯的是两个实体之间的那段。分段池化把序列按 e1、e2 的位置切成三段每段各自 max 再拼接输出维度变成3 * num_filters全连接层跟着改一下输入维度就行。在小数据上这一招通常能涨 2 到 4 个点几乎不增加训练时间。def piecewise_max_pool(h, mask, span1, span2): h: B,F,L按 e1/e2 位置切三段每段各自 max B, F, L h.shape outs [] cut1 span1[:, 0] # 第一段结束位置 cut2 span2[:, 0] # 第二段结束位置 for lo, hi in [(0, cut1), (cut1, cut2), (cut2, L)]: # 用 arange 和边界比较构造分段 mask再和原 mask 求交 idx torch.arange(L, deviceh.device).unsqueeze(0) seg ((idx lo.unsqueeze(1)) (idx hi.unsqueeze(1))).float() seg seg * mask outs.append(torch.max(h.masked_fill(seg.unsqueeze(1) 0, -1e9), dim-1).values) return torch.cat(outs, dim-1) # B, 3F注意span1/span2得在 Dataset 里作为字段一起返回并且要按 batch 平移到同一个张量里某一段为空时实体在句首或句尾要退化成只有一个位置的 max别让它算出全-1e9。第二条换 BERT 编码器。只改model.py和dataset.py两处Dataset不再返回位置 id改成返回input_ids / attention_mask / token_type_ids用 tokenizer 编句子实体位置通过offset_mapping找回来model.py里把卷积换成AutoModel.from_pretrained加一个线性分类头取[CLS]或者实体起始 token 的隐状态都行。学习率按 2e-5 起batch 16两个 epoch 就够多了必过拟合。这条路的代价是显存和训练时间翻几倍收益是 F1 从 0.7x 直接跳到 0.8x 往上。第三条加对抗训练。在 embedding 层做 FGM一个 batch 里额外跑一次前向反传算扰动把扰动加到词向量上再算一次 loss两次梯度累加。代码量大概 20 行训练时间增加 50%在这类小数据任务上通常能换来 1 到 2 个点是性价比很高的一招尤其是在你已经在用 BERT、显存吃紧、不想再换模型的时候。我自己的习惯是任何一次改动之后先用固定的随机种子跑三遍看 F1 的方差有多大。这个数据集太小同一个配置换个种子差 1.5 个点是常事如果不做这步很容易把一次好运当成模型改进。从那以后我每次调完超参都强制走一遍三seed验证再决定要不要写进实验记录。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →