1. 关系抽取任务与PCNN出场的背景
关系抽取一直是信息抽取里比较“硬核”的活儿。简单说,就是给定一段文本,让模型判断其中两个实体之间到底是什么关系,比如“某公司收购了某团队”这句里,“收购”就是一个关系。更形式化一点,关系抽取输出的通常是三元组(头实体,关系,尾实体),这是知识图谱构建、问答系统、事件抽取甚至舆情分析里最底层的基础能力。
我第一次接触这个任务时,最大的痛点不是“看不懂模型”,而是“拿不到干净数据”。人工标注的关系语料太贵了,成本高、规模小,很难支撑深度学习模型的训练。于是出现了远程监督这种思路:利用现成的知识库,把知识库中的三元组与文本自动对齐,自动生成训练样本。
但远程监督有个著名的问题:标签噪声。一个三元组如果出现过,那么凡是包含这两个实体的句子,都会被直接当成该关系的正例,哪怕句子表达的是完全不同的意思。举个例子,某知识库里有一条“A公司收购B团队”,远程监督会把“A公司在B团队的办公室开了一场发布会”这种句子也标注成收购关系,这明显是错的。模型如果直接吃这种数据,很容易学到一堆虚假关联。
当时主流的解决路线有两种:一类是设计复杂的特征模板,靠专家经验做特征工程;另一类是用核函数做结构化匹配。这两条路线都太依赖NLP预处理工具,词性、依存句法一步错,后面全错。而且特征模板在不同领域间的迁移能力很差,换个领域就得重新设计。
PCNN(分段卷积神经网络,Piecewise Convolutional Neural Network)就是在这样的背景下出现的。它的思路很直接:仍然用卷积神经网络自动学习文本表示,但在最大池化环节加了一个“分段”操作,让卷积结果按照头实体之前、头尾实体之间、尾实体之后三个区间分别做池化,把实体位置信息叠加进特征提取过程。这样一来,模型既不像传统特征模板那样依赖句法工具,又比普通CNN更能捕捉“实体周围不同段落”对关系判断的贡献。
这篇文章适合正在做关系抽取、知识图谱构建,或者想搞懂CNN在NLP里怎么落地的人。我会从任务背景、结构设计、数据预处理、训练细节到PyTorch实现,把PCNN的前因后果讲清楚,也会把我在实际使用中踩过的坑一并交代。
2. PCNN整体设计思路拆解
2.1 普通CNN处理句子分类的不足
在PCNN之前,大家用CNN做文本分类已经有比较成熟的方案。思路一般是这样:把句子里的每个词映射成词向量,拼成一个矩阵,然后用若干个卷积核在句子窗口上滑,得到一组特征图,最后做一次全局最大池化,接一个softmax分类。
这套流程在情感分类、主题分类这类任务上效果不错,因为“全局最大池化”相当于把整句话最显著的n-gram特征抓出来。但关系抽取不一样,它极度依赖实体位置信息。比如“张三的妻子是李四”和“李四的妻子是张三”,两个句子如果只换实体顺序,用全局最大池化去编码,特征基本一样,但关系指向完全不同。
还有更微妙的场景。关系往往体现在“实体周围搭配合适的触发词”上。比如“A公司的创始人王五”里,跟关系最相关的是“创始人”这个词,它位于实体B的前面;而在“赵六加入了A公司”里,触发词“加入”位于实体A和实体B之间。这三段位置的语义贡献不一样,全局最大池化等于把它们全混在一起,丢失了位置信息。
PCNN就是要解决这个问题。它不加复杂的注意力、不改词向量预训练方式,只把池化这一步从“整个句子一个最大值”改成“按实体位置切成三段,每段分别取最大值”。这三段局部特征再拼起来,作为整个句子的最终表示。
2.2 分段最大池化的构思
分段最大池化的前提是:句子中知道头实体和尾实体的位置。我们把句子按头实体和尾实体出现的位置,划分为三个区间:
- 第一个区间:头实体之前的文本;
- 第二个区间:头实体和尾实体之间的文本;
- 第三个区间:尾实体之后的文本。
每个卷积核在整句话上滑动后,会生成一个特征序列。普通最大池化是取这个序列的全局最大值,分段最大池化则分别从三个区间里取各自的最大值。三个最大值拼成一个三元组,这个三元组就是该卷积核在“实体前、实体间、实体后”三个语义段上的综合响应。
为什么要这样设计?因为关系判断本质上是一个“局部证据”问题。头实体前面出现“创始人”是证据,头尾实体之间出现“收购”“加入”“就职于”是证据,尾实体后面出现表身份的补充说明也可能是证据。分段操作让模型能够分别编码三类证据,而不是让它们互相竞争。
我曾经用一个直观的类比给新人解释:普通最大池化像全班只选一个最高分,这个最高分可能来自第一道题;分段最大池化像按三个科目分别选最高分,再把三个最高分组成一份“偏科但更有信息量”的成绩单。对于关系抽取而言,那份“分科成绩单”显然更有用。
2.3 PCNN整体架构俯瞰
PCNN的完整流程,用一句话概括就是:词向量加位置向量作为输入,经过卷积核滑动提取局部特征,分段最大池化压实特征,再经过全连接层输出每个关系的得分,训练时还可以叠加多实例学习的约束。
整个架构分成四块:
- 输入表示层:每个词使用预训练词向量,并与当前候选实体对的位置向量拼接;
- 卷积层:一组不同宽度的卷积核在输入矩阵上做窗口卷积,得到多张特征图;
- 分段最大池化层:每个特征图按实体边界分成三段,各自做最大池化,拼接成一个固定维度的向量;
- 输出层:将池化向量送入全连接层,接softmax得到关系类别概率。
这里有一个细节很多人容易忽略:位置向量的引入是PCNN能“感知实体位置”的关键,而分段池化是“利用”实体位置的关键。两层配合,模型才真正把“实体边界”变成特征的一部分。
3. 数据表示与预处理:PCNN的输入到底长什么样
3.1 词向量与位置向量
PCNN的每个词最终表示由两部分拼接而成。第一部分是常规的词向量,一般用word2vec、GloVe或者Bert之前的静态词向量初始化,维度我常用300维。第二部分是位置向量,核心思路是:对于当前候选关系对(头实体、尾实体),计算当前词到头实体和尾实体的相对距离。
相对距离的计算方法很简单。把句子里的每个词按序号排好,头实体某个词的位置是pos1,当前词的位置是pos_k,那么当前词到头实体的相对距离就是pos_k - pos1。同理可以计算到尾实体的相对距离。这两个距离值各自映射到一个可学习的向量,维度通常取5到10之间,也就是每个距离维度产生一个固定长度的向量。
举个例子。句子“某公司在上海成立”,假设头实体是“某公司”,尾实体是“上海”。词“在”的位置如果与“某公司”相差1,与“上海”相差1,那它的位置向量就是距离1对应的向量和距离-1对应的向量。位置向量不需要手工设计规则,全部靠训练学出来,这比传统特征模板里手工定义相对距离特征要灵活得多。
位置向量的维度选择需要一点经验。太小了比如2维,模型很难区分不同距离;太大了比如50维,参数膨胀,在远程监督这种噪声大的数据上反而容易过拟合。我常用5维,距离范围限制在[-30, 30]内,超出范围统一映射到边界值,实测既能保持位置敏感度,又不至于把序列拉得太长。
3.2 候选关系样本的构造方式
模型训练时,每个样本包含三部分:一个句子、一个候选实体对、一个关系标签。远程监督造样本的基本流程是这样的:
首先,从知识库里取一个三元组,比如(A公司,收购,B团队)。然后去文本库里搜索所有同时包含A公司和B团队这两个实体名的句子。搜到的每个句子都构成一条候选样本,关系标签直接定为“收购”。
这样做的好处是几乎不需要人工标注,坏处就是前面提到的标签噪声。一个句子可能只是“A公司CEO会见了B团队”,但也会被打上“收购”标签。为了缓解这个问题,PCNN在原论文里采用了一种训练策略:把包含同一个实体对的所有句子打包成一个“袋子”,模型不再保证每个句子都对应标签,而是保证“袋子中至少有一个句子能推出该关系”。这就是多实例学习的思路,后面我会专门展开。
数据预处理上,还需要把所有句子截断到固定长度。我一般设置最大长度为120个词,超过截断,不足则用零向量补齐。实体词本身不替换成特殊符号,而是保留原词,但位置向量已经标明了实体边界,模型可以自己学习“这个词是实体”这个概念。
3.3 实体重叠与句子划分的细节
有一个容易踩坑的地方:一句话里可能有多对实体,或者两个实体在句中出现多次。远程监督造数据时,如果不加约束,同一个句子会被反复构造多次,只因为候选实体对不同。
我在处理时通常遵循一个原则:同一时刻只处理一个候选实体对,并且只使用该候选实体对在句子中第一次出现的实体位置。如果句子中同时有多个相同实体名,位置向量会以第一次出现为准。这样做不算完美,但能避免位置向量在多个可能位置之间摇摆导致的训练不稳定。
还有一种情况是实体跨句子出现,比如头实体在某一句,尾实体在下一句。PCNN处理的是单句子分类,这种情况原则上不处理。我的经验是,先做句子切分,凡是头尾实体不在同一句的,直接丢弃或尝试用指代消解工具做合并。合并后如果仍然跨句,就放弃这条样本,不要硬凑。
4. PCNN核心层逐一拆解与计算细节
4.1 卷积层到底在算什么
卷积层在PCNN里并不是用来识别图像的,而是在文本上做“滑动窗口匹配”。假设输入矩阵的宽度是词向量加位置向量的维度,高度是句子长度,卷积核本质上是一个小型矩阵,宽度跟输入宽度一致,高度是一个窗口大小,比如3、5、7个词。
卷积核从句子第一个词开始,每次滑动一个词,对当前窗口内所有词向量做加权求和加偏置,得到一个标量。窗口划过整个句子,就得到一串标量,也就是一张特征图。这里可以用一个生活化类比理解:每一组卷积核相当于一个“触发词检测器”,比如某个卷积核可能学到了“收购”“并购”“入股”这类词的共性,另一个卷积核可能学到了“创始人”“担任”“任职”这类词的共性。
PCNN通常使用多个不同尺寸的卷积核,比如同时使用窗口大小为3、5、7的卷积核各若干。这样做的好处是能够捕捉不同长度的局部上下文。窗口3更关注词级别的搭配,窗口7则能覆盖“某某在北京创立了某公司”这样较长的表达。
有一个细节值得注意:卷积核的宽度必须和输入向量的维度严格一致,这样窗口滑动时才能一次覆盖整个词向量。如果词向量是300维,位置向量是10维(两个5维拼接),那么输入宽度就是310,所有卷积核的宽度也必须设计成310。
4.2 分段最大池化的“分段”到底怎么分
这是PCNN区别普通CNN的关键。特征图生成后,我们要根据头实体位置和尾实体位置,把特征图划分成三段。
首先需要确定一个坐标体系。假设句子长度为n,卷积核经过滑动后,特征图的长度大约也是n。我们记录头实体在句子中的起始位置为start1、结束位置为end1,尾实体的起始位置为start2、结束位置为end2。在做分段时,比较稳妥的做法是:第一段取start1之前的区域,第二段取end1到start2之间的区域,第三段取end2之后的区域。
不过这里有个细节需要注意:实体内部的区域到底算哪一段?在PCNN的标准做法里,头实体和尾实体本身的内容并不会单独成段,而是分别属于相邻段。实际操作中,我倾向于把第一段定义为“位置小于等于end1”的区域,第二段定义为“大于end1且小于start2”的区域,第三段定义为“大于等于end2”的区域。这样实体词本身会与其前文或者后文共同参与池化。
分段之后,每个区间分别取最大值。因为实体位置的不同,三个区间的长度可能不一样,但取最大值之后输出维度与区间长度无关,永远是一个标量。所以不管句子多长,每个卷积核最终输出3个值,把多个卷积核的结果拼接起来,就得到了一个固定长度的向量,可以直接接全连接层。
为什么要分三段而不是两段?三段对应的是“实体前—实体间—实体后”三个语义区域,这也是关系证据最常出现的三个位置。头实体之前的形容词修饰、头尾实体之间的触发词、尾实体之后的补语说明,各有各的作用。分成三段,其实就是让模型强制按这三个区域去各找各的证据。
4.3 输出层与损失函数
池化得到的向量会送入一个全连接层,把维度映射到关系类别总数。比如关系类别有10种,包括“无关”,那么全连接层输出10个实数。经过softmax归一化后,每个关系类别得到一个概率。
损失函数通常使用交叉熵。对于一个句子样本,如果它的标签是“收购”,那么模型要让“收购”对应的概率尽量接近1,其他类别尽量接近0。由于PCNN后面一般还要叠加多实例学习,实际损失不一定是单句级别的交叉熵,而是“袋子”级别的。这一点我放到下一节讲。
关于“无关”类别要专门提一句。远程监督产生的大量句子,并不一定真的表达对应关系,所以我们不能只训练正例,必须构造负例。最简单的做法是把没有出现在任何知识库三元组中的实体对样本标为“无关”。负例的构造比例一般要控制好,我通常让正负例比例保持在1比1到1比3之间。负例太少,模型会把几乎所有句子都预测成某种关系;负例太多,模型又容易学成“永远输出无关”,丢失关系的区分能力。
5. 多实例学习:PCNN训练工程的灵魂
5.1 为什么不能把每个句子当成独立样本
远程监督标签噪声的问题,前面已经反复提过。如果训练时把每个句子当成独立样本,就会发生一件很麻烦的事:大量句子被标上了错误的“强关系标签”,模型为了拟合这些错误标签,会逐渐记住“只要出现这两个实体就输出某个关系”这种偷懒的模式。
这种模式的危害是,模型在预测时不是在理解句子,而是在核对实体名。只要实体名配对出现过,不论句子上下文说什么,都会给出对应的关系。我在一次实验里见过一个典型失败案例:模型对一个“A公司起诉B团队”的句子输出了“收购”,原因是训练数据里有大量“A公司与B团队”的句子被标成收购关系,模型完全被实体共现带偏了。
多实例学习就是为了打破这种实体共现的强相关性。它的核心思想是:把包含同一个实体对的所有句子放到一个袋子里,训练时只要求袋子的整体预测与标签一致,而不要求袋子里的每一条句子都一致。
5.2 袋子的构造与选择函数
具体训练时,怎么由“袋子”得到整体预测?常见的方式有三种:取平均、取最大、按注意力加权。
PCNN最早采用的是“取最大”策略,也叫select-one或max策略。前向传播时,袋子里每个句子都会得到一个关于各种关系的概率向量,我们只选择概率最大(或得分最高)的那个句子作为袋子的代表,然后用这个代表句子的输出计算损失。反向传播时,只有被选中的句子会更新梯度,其他句子在这一轮不贡献梯度。
为什么取最大有效?因为远程监督袋子里的错误句子虽然多,但正确句子在模型逐步收敛后,大概率会获得更高的关系得分。取最大相当于让模型每轮自动选择一个最有可能是真证据的句子来学习,从而避免错误句子主导训练。
后来很多人把取最大改成注意力加权,也就是所有句子按置信度加权平均。这种方法更平滑,但计算量更大。PCNN原始版本用的就是取最大,效果已经能明显超过普通的单句CNN,所以先理解取最大,再考虑升级注意力。
5.3 训练超参与收敛经验
多实例学习的训练比普通分类模型更容易出现两类现象:一类是收敛慢,因为每轮只更新一个句子的梯度;另一类是过拟合,因为袋子的数量远小于句子数量,模型实际上是在更小的样本集上训练。
我常用的配置是这样的:批次大小按“袋子”来设置,每个批次取若干个袋子,每个袋子内部包含最多50个句子。如果袋子里的句子超过50个,随机抽50个;如果不足50个,就全部保留。这样既能控制内存,又能保证多数情况下能看到足够多的候选句子。
学习率方面,我用Adam优化器时通常初始化为1e-3,用SGD时通常是0.1配合动量0.9。如果你发现训练震荡明显,可以尝试把学习率调到原来的五分之一,并且在epoch中途做一次线性衰减,比如训练到一半时学习率减半,效果往往会更稳定。
还有一点是我个人非常重要的体会:远程监督数据里“无关”类别占比一定要控制。如果无关负例过多,袋子里经常全是负例,多实例学习很难选出正常的正例句子,梯度信号会被稀释。我在预处理阶段会按实体对去重,尽量保证每个袋子都有一定比例的正例句子。
6. 基于PyTorch的PCNN最小实现骨架
6.1 模型类的基本定义
用PyTorch实现PCNN,核心就是把前面讲的输入表示、卷积、分段池化、输出四层搭起来。下面这段代码不是完整可运行仓库,但给出了最核心的模型结构与前向流程,可以直接嵌到自己的训练脚本里。
import torch import torch.nn as nn import torch.nn.functional as F class PCNN(nn.Module): def __init__(self, word_dim, pos_dim, filters_num, filters_width, num_classes, max_len): super().__init__() self.word_dim = word_dim self.pos_dim = pos_dim self.filters_num = filters_num self.filters_width = filters_width self.max_len = max_len # 词向量和位置向量,位置向量分别对应头实体距离和尾实体距离 self.word_embed = nn.Embedding(vocab_size, word_dim, padding_idx=0) self.pos1_embed = nn.Embedding(2 * max_len + 3, pos_dim, padding_idx=0) self.pos2_embed = nn.Embedding(2 * max_len + 3, pos_dim, padding_idx=0) input_width = word_dim + 2 * pos_dim self.convs = nn.ModuleList() for width in filters_width: self.convs.append( nn.Conv1d(input_width, filters_num, kernel_size=width, padding=width // 2) ) self.fc = nn.Linear(filters_num * len(filters_width) * 3, num_classes) self.dropout = nn.Dropout(0.5) def forward(self, words, pos1, pos2, entity_pos): # entity_pos: (batch, 4) 分别是头实体起、头实体止、尾实体起、尾实体止 word_emb = self.word_embed(words) # (b, n, word_dim) pos1_emb = self.pos1_embed(pos1) # (b, n, pos_dim) pos2_emb = self.pos2_embed(pos2) # (b, n, pos_dim) x = torch.cat([word_emb, pos1_emb, pos2_emb], dim=-1) # (b, n, input_width) x = x.transpose(1, 2) # Conv1d 期望 (b, c, n) pool_results = [] for conv in self.convs: feature_map = torch.relu(conv(x)) # (b, filters_num, n) p1, p2, p3 = self.piecewise_pool(feature_map, entity_pos) pool_results.append(torch.cat([p1, p2, p3], dim=1)) pooled = torch.cat(pool_results, dim=1) pooled = self.dropout(pooled) logits = self.fc(pooled) return logits6.2 分段池化的实现细节
分段池化是PCNN最不好写对的地方。关键点在于:卷积输出特征图的长度,与原始句子长度可能不完全一致,尤其当卷积没有padding时,长度会缩短。我在代码里给卷积加了padding,让特征图长度尽量与句子长度保持一致,这样实体位置可以直接映射到特征图坐标。
实现时可以按下面逻辑操作:把特征图尺寸记为(batch, filters_num, seq_len),实体位置信息是每个样本的头实体起始、头实体结束、尾实体起始、尾实体结束。注意这里坐标是词级别的,卷积padding之后正好对应特征图的位置。
每个池化区间对应一组索引,对落在区间内的特征值求最大值。如果区间为空,也就是实体相邻或者实体前没有词,我会返回一个很小的负值,比如-1e6,这样既不会影响梯度,也不会被当成有效特征。
def piecewise_pool(self, feature_map, entity_pos): # feature_map: (batch, filters_num, seq_len) batch_size, filters_num, seq_len = feature_map.size() p1_list, p2_list, p3_list = [], [], [] for i in range(batch_size): # 坐标来自词级别,默认与特征图长度对齐 head_start, head_end, tail_start, tail_end = entity_pos[i] seg1 = feature_map[i, :, :head_end + 1] seg2 = feature_map[i, :, head_end + 1:tail_start + 1] seg3 = feature_map[i, :, tail_start + 1:] max1 = seg1.max(dim=1).values if seg1.size(1) > 0 else torch.full((filters_num,), -1e6, device=feature_map.device) max2 = seg2.max(dim=1).values if seg2.size(1) > 0 else torch.full((filters_num,), -1e6, device=feature_map.device) max3 = seg3.max(dim=1).values if seg3.size(1) > 0 else torch.full((filters_num,), -1e6, device=feature_map.device) p1_list.append(max1) p2_list.append(max2) p3_list.append(max3) p1 = torch.stack(p1_list, dim=0) p2 = torch.stack(p2_list, dim=0) p3 = torch.stack(p3_list, dim=0) return p1, p2, p36.3 多实例袋子的前向与损失写法
多实例训练的前向过程和普通前向有一个区别:普通模型一次输入一个句子,多实例模型一次输入一个袋子。PyTorch里可以通过维度扩展简单地实现:先把袋子内的所有句子都喂给PCNN,得到每个句子的得分,再按袋子维度取最大。
一种常见做法是,模型forward仍然接收每个句子,只不过把同一个袋子的所有句子打包成一个batch。假设一个袋子有m个句子,关系类别有c个,模型输出形状是(m, c)。用max操作取袋子的最大得分:
bag_logits, _ = torch.max(sentence_logits, dim=0, keepdim=True) loss = criterion(bag_logits, bag_label)注意,这里取最大是在关系维度上吗?不是,是在句子维度上取最大。也就是说,对每个关系类别,我们都从m个句子里找出该类别得分最高的那个句子,用这个最高分作为袋子在这个类别上的得分。这比只取一个句子的完整得分更能抵抗单句噪声。
训练时要特别留意梯度不回传的问题。PyTorch的max操作是支持反向传播的,梯度会只流向被选中的句子,其他句子自然得到零梯度,这正是多实例学习想要的效果。
7. 实际训练与复现时的常见问题
7.1 训练不收敛,先查数据顺序
很多人在PCNN上复现效果不理想,第一反应是改模型结构,我建议先检查数据的batch组成方式。多实例训练时,如果每个batch里一个袋子只出现一次,每轮梯度更新关于袋子的随机性很大,训练曲线会像心电图一样剧烈抖动。
我的做法是:在每个batch里放进多个不同的实体对袋子,也就是batch维度等于“实体对个数”,每个实体对内部的句子并行计算。最好在shuffle时保证同一个实体对的句子始终被放进同一个袋子,否则会破坏多实例的边界条件。
此外,不要忘记对同一个实体对的不同句子做乱序。袋子里句子的顺序每次训练都打乱一下,能让“取最大”这个操作变得更随机,间接起到类似正则化的作用。
7.2 位置向量维度与距离范围的影响
位置向量的维度别看是个小超参,影响挺大。我一开始用20维的位置向量,模型参数量暴增,训练集效果很好,验证集却一路走低,明显过拟合。后来逐渐缩减到5维,效果反而稳定了。
距离范围的截断也很关键。句子最长120词,理论距离范围在[-119, 119]之间,但绝大多数有效关系证据都集中在头尾实体附近30个词的范围内。距离太远的位置向量几乎没有意义。把超出[-30, 30]的距离统一映射到边界值,不仅能减少embedding表规模,还能让模型把注意力集中在实体附近的语义区域。
7.3 远程监督脏标签怎么进一步缓解
PCNN对脏标签已经有一定耐受性,但如果你想更稳,可以再加一个简单的置信度过滤:训练好几轮后,把每个袋子中每次都获得最高分的句子拿出来,重新看一下它们的预测概率。如果某个袋子的最高概率始终很低,说明这个袋子很可能整体都是噪声样本,可以把它当作负例加入“无关”类别。
还有一种成本更低的做法:在预处理阶段,用实体对在文本中的共现频率做过滤。如果两个实体在文本库中频繁共现,但知识库里并没有它们的关系三元组,这类样本大概率是无关负例,可以直接保留为负例。如果两个实体共现频率极低,却在知识库里有关系,可能是抽取样本太少,需要适当增加采样。
8. PCNN之后的演进路线与我的一点体会
8.1 从PCNN到PCNN+ATT
PCNN问世之后,一个很自然的方向就是给多实例学习换更聪明的选择函数。原始版本用max,后面出现了注意力机制版,本质上是把所有句子的隐层表示做加权求和,权重取决于句子与当前关系标签的匹配程度。这个改动在公开数据集上通常能比原始PCNN提升一到两个百分点。
如果你已经理解了PCNN的分段池化,升级到PCNN+ATT非常容易。只需要把第6节的max替换成attention模块:先对每个句子打分,再对句子向量加权平均。需要提醒的是,注意力版本训练时对初始化更敏感,最好先跑通max版本,再切换注意力版本做微调。
8.2 预训练语言模型时代还需要PCNN吗
经常有人问我:现在BERT、大模型这么强,PCNN还有必要学吗?我的回答是:有必要,但要分清场景。
预训练模型在少量干净数据的关系抽取上确实表现更好,但它的计算成本高,远程监督大数据量下训练成本更是翻倍。PCNN胜在结构简单、训练快、可解释性强,尤其适合作为基线模型,或者用于需要大规模远程监督预标注的场景。实际上不少工业级知识图谱系统里,PCNN仍然被当成第一阶段的候选生成器,用来快速筛选出高质量关系候选,后续再用更重的模型精排。
另一个原因是,PCNN对位置信息的建模思路被很多后来者继承。分段池化、实体感知的区间划分、位置向量拼接,这些设计在现在很多关系抽取模型里依然看得到影子。理解了PCNN,再看后面各种花式实体感知模型,会轻松很多。
8.3 我的整体使用总结
从我个人的实际经验看,PCNN是一个“投入产出比”非常高的模型。数据面做好远程监督样本构造,代码面做好分段池化,训练面用好多实例学习,基本上就能得到一个比传统特征方法稳定得多的关系抽取基线。
如果你从头实现,我建议按这个顺序排查:先确认位置向量有没有拼对,再确认分段区间是否覆盖了实体前后的完整区域,最后再确认袋子的batch方式是否一致。这三个地方只要有一个错,模型效果都会明显退化,而且很难通过调学习率救回来。
最后分享一个小技巧:PCNN对词向量的质量比较敏感。如果用通用中文词向量效果不佳,可以先在领域语料上增量训练一轮词向量,多数情况下关系抽取的F1值会有肉眼可见的提升。领域词向量对齐了领域表达之后,卷积核学到的触发词会更准确,分段池化能提取到的证据也更靠谱。