跳至内容

PGN 原理与实现

指针生成网络的词表生成、原文复制与重复抑制。

  • 文本摘要
  • Seq2Seq
  • PGN
  • Attention
  • Coverage
  • Beam Search

指针生成网络(Pointer-Generator Network,PGN)合并词表生成与原文复制的概率,让摘要能够输出原文中的词表外词。 Coverage 记录历史注意力,约束反复关注相同位置的行为,缓解重复生成。

See 等人在 2017 年的论文中提出的 PGN 摘要模型由双向 LSTM 编码器、单向 LSTM 解码器和注意力机制组成,属于早期的序列生成模型。

文本摘要有两种常见方式:

  • 抽取式摘要:选择原文中的句子或片段,TextRank 属于这一类。
  • 生成式摘要:逐步输出词,能够重新组织表达。

PageRank 根据网页之间的链接关系计算重要性。网页的得分不仅取决于有多少页面指向它,还取决于这些页面的得分;各页面的分数通过迭代更新。

TextRank 把类似的图排序方法用于文本。做抽取式摘要时,以句子为节点,按内容相似度建立带权边,通过迭代评分选择重要句子。

它直接选取原句,不需要训练逐词生成的解码器。句子排序本身不负责改写或消除句间重复。

1. Seq2Seq 与注意力

Seq2Seq 将输入序列映射为输出序列。摘要任务的输入是原文,输出是较短的摘要;两者长度不必相同。

假设分词后的原文是 x1,…,xNx_1,\ldots,x_N:

  • 编码器:双向 LSTM 为每个原文位置产生隐状态 hih_i,保留该位置的表示。
  • 解码器:接收上一个词的向量表示,递推更新状态,每个时间步预测一个词。第 tt 步的状态记为 sts_t,包含当前输出前缀的信息。

注意力机制根据 sts_t 和每个 hih_i 计算分数,经 softmax 得到原文各位置的权重:

eit=v⊤tanh⁡(Whhi+Wsst+battn),at=softmax⁡(et)e_i^t=v^\top\tanh(W_hh_i+W_ss_t+b_{\mathrm{attn}}),\qquad a^t=\operatorname{softmax}(e^t)

WhW_h、WsW_s、vv 和 battnb_{\mathrm{attn}} 是可学习参数。aita_i^t 表示当前步分配给第 ii 个原文位置的注意力,有效位置的权重之和为 11。

PAD 是补齐序列长度的标识,对应位置不参与注意力分配。

按注意力权重汇总编码器状态,就得到当前步的上下文向量:

ht∗=∑i=1Naithih_t^*=\sum_{i=1}^{N}a_i^t h_i

上下文向量随解码步骤变化,使不同的输出词能够依赖不同的原文内容。解码器状态 sts_t 与 ht∗h_t^* 拼接,经过线性层和 softmax,得到固定词表上的概率分布 PvocabtP_{\mathrm{vocab}}^t。

普通的固定词表输出层只能为词表内的词分配概率。 某个人名若不在词表里,即使注意力指向它,输出层也没有对应的类别。

词表外词称为 OOV(Out-of-Vocabulary),输入时通常映射为统一的 UNK 标识。

这里的“词”指分词后的 token。按词分词、按字分词和子词分词会改变 OOV 的范围,不能脱离分词方式讨论 OOV 比例。

2. PGN 的复制机制

2.1 词表分布与位置分布

PGN 保留词表生成分支,同时把注意力分布作为复制分支的依据。两条分支描述的对象不同:

分布每个元素对应什么能提供哪些输出
词表分布 PvocabtP_{\mathrm{vocab}}^t固定词表中的一个词词表内的词,包括原文未出现的词
注意力分布 ata^t原文中的一个位置该位置上的词,包括原文中的 OOV

同一个词可能出现在原文的多个位置。复制这个词的概率,要把所有对应位置的注意力加起来。 因而复制分布为:

Pcopyt(w)=∑i:xi=waitP_{\mathrm{copy}}^t(w)=\sum_{i:x_i=w}a_i^t

求和条件 xi=wx_i=w 表示只收集内容为 ww 的原文位置。经过这一步,复制分支的概率才从“位置”对应到“词”。

2.2 生成权重与最终分布

生成权重 pgentp_{\mathrm{gen}}^t 由当前上下文向量、解码器状态和当前解码输入的词向量 ztz_t 共同计算:

pgent=σ ⁣(wh⊤ht∗+ws⊤st+wx⊤zt+bptr)p_{\mathrm{gen}}^t=\sigma\!\left(w_h^\top h_t^*+w_s^\top s_t+w_x^\top z_t+b_{\mathrm{ptr}}\right)

σ\sigma 是 sigmoid 函数,将结果映射到 00 与 11 之间。式中的权重和偏置通过训练学习,每步的输入和状态变化会影响 pgentp_{\mathrm{gen}}^t。

ztz_t 是上一个输出词的输入表示,首步使用开始标识。

pgentp_{\mathrm{gen}}^t 控制词表分支的权重,1−pgent1-p_{\mathrm{gen}}^t 控制复制分支的权重。 两条分支共同形成最终分布:

Pt(w)=pgentPvocabt(w)+(1−pgent)∑i:xi=waitP^t(w)=p_{\mathrm{gen}}^tP_{\mathrm{vocab}}^t(w) +(1-p_{\mathrm{gen}}^t)\sum_{i:x_i=w}a_i^t

这是软混合:计算时保留两部分贡献,不需要按某个阈值把其中一条分支关掉。同一个词既在固定词表内、又出现在原文中时,两部分概率会加到一起。

PGN 的两条分支:词表分布与按词聚合的复制分布分别加权,按词相加形成最终分布。

示例

原文 token 为 [岑川, 到访, 青岚, ,, 岑川, 发言],对应注意力为 [0.30, 0.05, 0.15, 0.05, 0.35, 0.10],总和为 11。

固定词表不含“岑川”,但含“发言”。设 pgent=0.4p_{\mathrm{gen}}^t=0.4,Pvocabt(发言)=0.1P_{\mathrm{vocab}}^t(\text{发言})=0.1。

  • “岑川”出现两次,复制概率为 0.30+0.35=0.650.30+0.35=0.65,最终概率为 0.4×0+0.6×0.65=0.390.4\times0+0.6\times0.65=0.39。
  • “发言”的词表概率和复制概率均为 0.10.1,最终概率为 0.4×0.1+0.6×0.1=0.100.4\times0.1+0.6\times0.1=0.10。

“岑川”虽然不在固定词表内,仍获得了非零输出概率。这里计算的是当前步的概率,不代表模型已经选中该词,也不代表完整摘要的质量。

2.3 扩展词表与 OOV

最终分布定义在固定词表与当前原文词集合的并集上,这个并集称为扩展词表。原文中的每个不同 OOV 获得一个临时编号;同一个 OOV 重复出现时,共用该编号。

实现概率合并时,词表分布在新增位置补零,复制分支按照原文的扩展词表编号累加概率。scatter_add 这类按索引累加的操作,就能把同词位置的贡献加到一起。

同一个源内 OOV,作为输入和预测目标时使用的编号不同:

  • Embedding 输入使用固定词表中的 UNK 编号,因为 Embedding 表没有临时扩展编号对应的向量。
  • 复制分支保留能够还原原词的扩展编号。训练时以这个编号作为目标,从最终分布中取对应概率。

原作者的解码实现将复制出的 OOV 映射为 UNK,作为下一步输入;目标编号处理则将参考摘要中既不在固定词表、也不在原文里的词映射为 UNK。

不在固定词表内、也没出现在原文里的词,两条分支都无法提供。

即使复制出的名字正确,模型仍可能把它与错误的动作、时间或数字组合起来。复制机制不能单独保证整句符合原文事实。

3. Coverage 的重复抑制

PGN 能复制原文,但仍可能在多个解码步骤反复关注相同位置,产生重复短语。Coverage 为注意力机制加入历史信息。

3.1 累积历史注意力

第 tt 步开始时,coverage 向量记录之前所有步骤的注意力之和:

ct=∑τ=1t−1aτ,c1=0c^t=\sum_{\tau=1}^{t-1}a^\tau,\qquad c^1=\mathbf{0}

citc_i^t 较大,表示原文第 ii 个位置此前获得过较多注意力。它是累计量,不是归一化概率,单个位置的值能够超过 11。

Coverage 作为额外输入参与当前注意力打分:

eit=v⊤tanh⁡(Whhi+Wsst+wccit+battn)e_i^t=v^\top\tanh(W_hh_i+W_ss_t+w_c c_i^t+b_{\mathrm{attn}})

wcw_c 是可学习向量。历史累计量因此能够影响当前注意力,但“累计量大”并不直接等于“当前权重一定小”:最终分数仍由输入特征和学习到的参数共同决定。

3.2 覆盖损失

覆盖损失衡量当前注意力与历史累计注意力的重叠:

ℓcovt=∑i=1Nmin⁡(ait,cit)\ell_{\mathrm{cov}}^t=\sum_{i=1}^{N}\min(a_i^t,c_i^t)

某个位置此前几乎没被关注时,citc_i^t 很小,对该位置分配当前注意力产生的重叠也小;若此前已经获得较多注意力,当前继续分配的部分就更可能被计入损失。

示例

假设原文只有三个有效位置,第一步注意力为 a1=(0.6,0.3,0.1)a^1=(0.6,0.3,0.1)。第二步开始时,累计量为 c2=(0.6,0.3,0.1)c^2=(0.6,0.3,0.1)。

比较两个假设的当前分布:

  • a2=(0.5,0.4,0.1)a^2=(0.5,0.4,0.1):覆盖损失为 0.5+0.3+0.1=0.90.5+0.3+0.1=0.9。
  • a~2=(0.1,0.2,0.7)\tilde a^2=(0.1,0.2,0.7):覆盖损失为 0.1+0.2+0.1=0.40.1+0.2+0.1=0.4。

第二个分布把更多注意力分给此前关注较少的第三个位置,因此重叠损失更低。这个比较只说明覆盖损失的作用;哪个分布更有利于生成正确摘要,还取决于目标词的预测损失。

PGN 使用最终词分布计算目标词的负对数似然,加上覆盖损失,形成每一步的训练目标:

ℓt=−log⁡Pt(yt∗)+λℓcovt\ell_t=-\log P^t(y_t^*)+\lambda\ell_{\mathrm{cov}}^t

yt∗y_t^* 是参考摘要在当前步的词,λ\lambda 控制重复关注受到多大的惩罚。

  • 目标词的概率越高,第一项损失越低。
  • 当前注意力与历史重叠越多,第二项损失越大。

训练对摘要有效位置的损失取平均,PAD 不计入。

计算当前覆盖损失时必须使用更新前的 ctc^t。 当前步完成后,历史量更新为 ct+1=ct+atc^{t+1}=c^t+a^t。

若误把更新后的累计量代入,因为 cit+1≥aitc_i^{t+1}\ge a_i^t,每一项都会变为 aita_i^t。损失总和恒为 11,就无法区分重复程度。

Coverage 惩罚的是原文位置上的注意力重叠,不要求摘要提及每个原文位置,也没有直接识别语义重复。多个位置表达同一件事时,模型仍可能换一个位置重复相同内容。

4. 训练输入与权重共享

4.1 数据与特殊标识

训练数据是成对的原文和参考摘要。固定词表由训练集建立;原文和摘要使用相同的分词、编号规则。

  • 长度统计:观察原文、摘要的长度分布,确定截断长度。原文被截去的部分无法参与复制,OOV 扩展编号应按截断后的内容建立。
  • 特殊标识:BOS 表示开始,EOS 表示结束,PAD 补齐 batch 内的长度,UNK 接收固定词表外的输入。
  • 清洗规则:删除网页标签、损坏字符等噪声,同时保留正文中的人名、数字、否定词和句子结构。生成式摘要不应直接套用抽取关键词时的停用词过滤。

4.2 训练时的输入来源

Teacher Forcing 在训练时把参考摘要的上一个词作为当前输入。推理时没有参考答案,模型只能使用自己已经输出的词。

假设“记者”和“到访”都在固定词表内,参考摘要以“记者/到访”开头。预测第二个词时:

  • 训练:Teacher Forcing 输入参考词“记者”,即使模型第一步预测错了。
  • 推理:输入模型实际输出的词,后续预测可能受到错误前缀影响。

这种训练与推理时历史输入的差异称为 Exposure Bias(曝光偏差)。模型训练时主要接触正确前缀,推理时却需要处理自身错误累积产生的前缀。

Scheduled Sampling 在训练中按一定概率选择真实前词或模型预测词,并随训练进展调整比例,让模型接触自身预测形成的输入。

它改变训练时的输入来源,不能仅凭采用了这种策略就认定摘要质量提高。

4.3 权重共享

Weight Tying(权重共享)改变的是参数关系。 两种不同的共享方式是:

  • 编码器与解码器共享输入 Embedding:需要对齐词表及编号含义。
  • 解码器的输入 Embedding 与输出投影共享权重:需要匹配矩阵维度。

共享权重能减少独立参数,但不会改变“训练输入真实前词、推理输入预测前词”的差异。两套 Embedding 不同本身也不构成曝光偏差。

5. Beam Search 的候选搜索

模型为下一步提供概率分布,解码算法据此选择输出序列。贪心解码每步只保留概率最大的词,一旦选错前缀,后续步骤无法回到被舍弃的分支。

Beam Search(束搜索) 同时保留至多 KK 条尚未完成的候选序列。每一步的搜索过程是:

  1. 扩展当前候选,得到可能的后续词。
  2. 将新词的条件概率计入整条路径的分数。
  3. 按新分数保留得分最高的 KK 条候选。

通常用累积对数概率评分:

S(y1:t)=∑τ=1tlog⁡P(yτ∣y<τ,x)S(y_{1:t})=\sum_{\tau=1}^{t}\log P(y_\tau\mid y_{<\tau},x)

这里的 xx 是完整原文,y<τy_{<\tau} 是当前候选的历史前缀。每条候选都有自己的解码器状态;带 coverage 的模型还需要分别保存候选对应的累计注意力。

下面是自拟的两步搜索例子。设第一步只有 A、B 两个候选,第二步只有 X、Y 两个候选,之后都以概率 11 输出结束符 EOS,因此第二步的路径概率就是完整序列概率。

束搜索的示例:A 的首步概率较高,但保留 B 分支才能找到路径概率更高的 BX。

  • 贪心解码在第一步选择 A,最终只能得到概率为 0.300.30 的 AX 或 AY。
  • K=2K=2 的束搜索保留 A 和 B,第二步能够发现概率为 0.360.36 的 BX。

保留多个前缀能改变搜索结果,但有限宽度的束搜索仍可能丢弃后续更好的路径,不保证全局最优。

输出 EOS 的候选进入完成集合,不继续扩展。其余候选继续搜索,并设置最大输出长度防止无限生成。

直接比较不同长度序列的概率乘积,会倾向于较短结果:每增加一个词,都会乘上不大于 11 的概率。实际评分常加入长度归一化,比较实验时需要说明规则。

Beam Search 本身没有重复惩罚。 如果模型给重复序列较高概率,搜索仍可能选中它。扩大 KK 会增加需要计算和保存的候选状态,是否值得取决于质量变化与推理成本。

6. 核心源码

下面的 PGN + Coverage 核心实现包含编码器、带 coverage 的注意力、解码器、生成与复制概率合并,以及训练损失计算。

它接收已经编号的原文与摘要,支持一次训练计算和单步解码。代码未包含数据集加载、完整训练流程或 Beam Search,也不代表已经训练好的摘要模型。

6.1 输入与调用

固定词表大小记为 VV,batch 大小为 BB,补齐后的原文、摘要长度分别为 NN、TT。四组编号都使用 torch.long,与模型放在同一设备上:

输入形状编号含义
source_ids[B,N][B,N]原文的固定词表编号,OOV 换成 UNK
source_extended_ids[B,N][B,N]相同原文的扩展编号,源内 OOV 从 VV 起编号
decoder_input_ids[B,T][B,T]BOS 加参考摘要前缀,OOV 输入使用 UNK
target_extended_ids[B,T][B,T]参考摘要加 EOS,源内 OOV 保留扩展编号

每条原文独立维护 OOV 表;同一个编号在不同样本中可能代表不同词。max_oov 是本批次中单条原文最多包含多少个不同 OOV,最终分布的最后一维为 V+max_oovV+\text{max\_oov}。

示例

固定词表含 到访,不含 岑川;设 岑川 在当前原文中的扩展编号为 VV。参考摘要为 岑川/到访 时,解码输入是 [BOS, UNK, 到访],训练目标是 [V, 到访, EOS]。

source_ids 中的“岑川”同样写成 UNK,source_extended_ids 中每次出现的“岑川”都写成 VV。

原文非空,摘要目标至少包含 EOS。原文和摘要均在右侧补 PAD;参考摘要中既不在词表、也不在原文的词,目标编号使用 UNK。

6.2 模型与损失

编码器与解码器共享输入 Embedding。编码器的双向末状态拼接后,通过线性层和 tanh 转为解码器初态;注意力和生成权重使用解码器的 hidden、cell 两部分状态。

  • 训练:forward() 对每条摘要的有效目标位置取平均,再对 batch 取平均,返回标量损失与各步最终分布。
  • 解码:decode_step() 返回单步分布、新状态和注意力。调用方负责选择输出词、累加 coverage,并处理 EOS 或最大长度停止条件。

将下面代码保存为 pgn_core.py,在 Python 3.12 环境中安装依赖并执行:

代码块 Shell
1
2
python -m pip install torch==2.9.0 numpy==2.3.4
python pgn_core.py
pgn_core.py Python
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
"""PGN + Coverage 核心实现,用于演示训练损失计算与单步解码,不复现论文结果。

输入是已编码的 batch,不包含分词、词表构建、数据加载或搜索解码。
B:batch 大小;N:原文长度;T:摘要长度;V:固定词表大小;E:词向量维度;H:LSTM 隐层维度。
"""

import torch
from torch import nn
from torch.nn import functional as F
from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence


class CoverageAttention(nn.Module):
    def __init__(self, hidden_dim: int):
        super().__init__()
        self.encoder_projection = nn.Linear(2 * hidden_dim, 2 * hidden_dim, bias=False)
        self.state_projection = nn.Linear(2 * hidden_dim, 2 * hidden_dim)
        self.coverage_projection = nn.Linear(1, 2 * hidden_dim, bias=False)
        self.score_projection = nn.Linear(2 * hidden_dim, 1, bias=False)

    def forward(self, encoder_output, state, source_mask, coverage):
        hidden, cell = state  # hidden、cell:[B, H]
        state_features = self.state_projection(torch.cat((hidden, cell), dim=-1))  # [B, 2H]
        features = self.encoder_projection(encoder_output) + state_features.unsqueeze(1)  # [B, N, 2H]
        features = features + self.coverage_projection(coverage.unsqueeze(-1))  # [B, N, 2H]
        scores = self.score_projection(torch.tanh(features)).squeeze(-1)  # [B, N]
        scores = scores.masked_fill(~source_mask, float("-inf"))  # PAD 在 softmax 前屏蔽
        attention = torch.softmax(scores, dim=-1)  # [B, N],有效原文位置之和为 1
        context = torch.bmm(attention.unsqueeze(1), encoder_output).squeeze(1)  # [B, 2H]
        return context, attention


class PointerGenerator(nn.Module):
    def __init__(self, vocab_size: int, embedding_dim: int, hidden_dim: int, pad_id: int, unk_id: int):
        super().__init__()
        self.pad_id = pad_id
        self.unk_id = unk_id
        self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=pad_id)
        self.encoder = nn.LSTM(embedding_dim, hidden_dim, batch_first=True, bidirectional=True)
        self.reduce_hidden = nn.Linear(2 * hidden_dim, hidden_dim)
        self.reduce_cell = nn.Linear(2 * hidden_dim, hidden_dim)
        self.decoder = nn.LSTMCell(embedding_dim, hidden_dim)
        self.attention = CoverageAttention(hidden_dim)
        self.vocab_projection = nn.Sequential(nn.Linear(3 * hidden_dim, hidden_dim), nn.Linear(hidden_dim, vocab_size))
        self.generation_projection = nn.Linear(4 * hidden_dim + embedding_dim, 1)

    def encode(self, source_ids):
        source_mask = source_ids.ne(self.pad_id)  # [B, N],约定每条原文非空且只在右侧补 PAD
        lengths = source_mask.sum(dim=1).cpu()  # [B],pack 的长度张量放在 CPU
        embedded = self.embedding(source_ids)  # [B, N, E],OOV 已映射为 UNK
        packed = pack_padded_sequence(embedded, lengths, batch_first=True, enforce_sorted=False)
        packed_output, (hidden, cell) = self.encoder(packed)  # hidden、cell:[2, B, H]
        encoder_output, _ = pad_packed_sequence(packed_output, batch_first=True, total_length=source_ids.size(1))
        # pack 让反向 LSTM 从有效文本末尾开始,不把右侧 PAD 当成原文处理。
        hidden = torch.tanh(self.reduce_hidden(torch.cat((hidden[0], hidden[1]), dim=-1)))  # [B, H]
        cell = torch.tanh(self.reduce_cell(torch.cat((cell[0], cell[1]), dim=-1)))  # [B, H]
        return encoder_output, (hidden, cell), source_mask  # [B, N, 2H]、两个 [B, H]、[B, N]

    def decode_step(self, token_ids, state, encoder_output, source_mask, source_extended_ids, coverage, max_oov):
        # 训练和单步解码共用此入口;复制出的扩展编号进入 Embedding 时必须转成 UNK。
        input_ids = token_ids.masked_fill(token_ids >= self.embedding.num_embeddings, self.unk_id)  # [B]
        embedded = self.embedding(input_ids)  # [B, E],decoder 与 encoder 使用同一个 Embedding
        state = self.decoder(embedded, state)  # 单步 LSTM:hidden、cell 均为 [B, H]
        hidden, cell = state
        context, attention = self.attention(encoder_output, state, source_mask, coverage)
        vocab_logits = self.vocab_projection(torch.cat((hidden, context), dim=-1))  # [B, V]
        vocab_distribution = torch.softmax(vocab_logits, dim=-1)
        generation_input = torch.cat((context, hidden, cell, embedded), dim=-1)  # [B, 4H + E]
        p_gen = torch.sigmoid(self.generation_projection(generation_input))  # [B, 1],不人为截断生成权重
        final_distribution = F.pad(p_gen * vocab_distribution, (0, max_oov))  # [B, V + max_oov]
        copy_distribution = (1 - p_gen) * attention  # [B, N],这里仍按原文位置排列
        final_distribution = final_distribution.scatter_add(1, source_extended_ids, copy_distribution)
        # 同一词的多个原文位置写入同一编号;固定词表内的词同时接收生成与复制概率。
        return final_distribution, state, attention

    def forward(self, source_ids, source_extended_ids, decoder_input_ids, target_extended_ids, max_oov, coverage_weight=1.0):
        """返回标量 loss 与全部时间步的最终分布 [B, T, V + max_oov]。

        四个 ID 张量均为 torch.long,且与模型处于同一设备。
        source_ids / source_extended_ids:[B, N],相同原文的固定 / 扩展编号,右侧补 PAD。
        decoder_input_ids:[B, T],BOS 加参考摘要前缀;所有 OOV 输入均替换为 UNK。
        target_extended_ids:[B, T],含 EOS 的预测目标,右侧补 PAD,每条摘要至少一个有效目标。
        源内 OOV 从 V 起按每条原文独立编号;重复 OOV 共用编号,源外 OOV 目标使用 UNK。
        max_oov:本 batch 中单条原文的最大不同 OOV 数;PAD、UNK、BOS、EOS 均属于固定词表。
        """
        encoder_output, state, source_mask = self.encode(source_ids)
        coverage = encoder_output.new_zeros(source_ids.shape)  # [B, N],首步历史注意力为零
        target_mask = target_extended_ids.ne(self.pad_id)  # [B, T]
        step_losses, distributions = [], []

        for step in range(decoder_input_ids.size(1)):
            distribution, state, attention = self.decode_step(
                decoder_input_ids[:, step], state, encoder_output, source_mask, source_extended_ids, coverage, max_oov
            )
            target_probability = distribution.gather(1, target_extended_ids[:, step].unsqueeze(1)).squeeze(1)  # [B]
            nll = -target_probability.clamp_min(1e-12).log()  # 只避免数值下溢导致 log(0),不修改 p_gen
            overlap = torch.minimum(attention, coverage).sum(dim=1)  # [B],必须使用更新前的 coverage
            valid_step = target_mask[:, step]  # [B],PAD 目标不计损失,也不累加历史注意力
            step_losses.append((nll + coverage_weight * overlap) * valid_step)
            coverage = coverage + attention * valid_step.unsqueeze(1)  # 保留跨时间步的梯度
            distributions.append(distribution)

        loss_per_sample = torch.stack(step_losses, dim=1).sum(dim=1) / target_mask.sum(dim=1)  # [B]
        loss = loss_per_sample.mean()  # 每条摘要按有效长度平均,随后对 batch 平均
        return loss, torch.stack(distributions, dim=1)


if __name__ == "__main__":
    torch.manual_seed(7)
    # 固定词表:PAD=0、UNK=1、BOS=2、EOS=3,其余固定词编号为 4~7。
    # 两条原文各有一个 OOV,临时编号都为 8;它们在不同样本中不必指同一个词。
    # 第一条原文的同一 OOV 出现两次,必须共用扩展编号 8。
    source_ids = torch.tensor([[1, 4, 1, 5, 0], [6, 1, 0, 0, 0]])  # [2, 5]
    source_extended_ids = torch.tensor([[8, 4, 8, 5, 0], [6, 8, 0, 0, 0]])  # [2, 5]
    decoder_input_ids = torch.tensor([[2, 1, 5], [2, 6, 3]])  # [2, 3],目标中的 8 作为输入时改为 UNK
    target_extended_ids = torch.tensor([[8, 5, 3], [6, 3, 0]])  # [2, 3],第二条摘要末尾是 PAD
    model = PointerGenerator(vocab_size=8, embedding_dim=16, hidden_dim=32, pad_id=0, unk_id=1)
    loss, distributions = model(source_ids, source_extended_ids, decoder_input_ids, target_extended_ids, max_oov=1)
    loss.backward()
    gradients_finite = all(p.grad is not None and torch.isfinite(p.grad).all() for p in model.parameters())
    print("最终分布形状:", tuple(distributions.shape))
    print("反向传播成功且梯度有限:", gradients_finite)

    model.eval()
    with torch.no_grad():
        encoder_output, state, source_mask = model.encode(source_ids)
        coverage = encoder_output.new_zeros(source_ids.shape)
        first_distribution, state, attention = model.decode_step(
            decoder_input_ids[:, 0], state, encoder_output, source_mask, source_extended_ids, coverage, max_oov=1
        )
        # 后续解码需要保留 state,累加 coverage,并把选中的编号作为下一步 token_ids。
        coverage = coverage + attention
        print("首步源内 OOV 概率大于零:", bool((first_distribution[:, 8] > 0).all()))

示例使用随机初始化参数和两条手工构造的输入,检查分布形状、反向传播和源内 OOV 的输出概率,不用于评估摘要质量。

运行结果 Text
1
2
3
# 最终分布形状: (2, 3, 9)
# 反向传播成功且梯度有限: True
# 首步源内 OOV 概率大于零: True

此实现保留 p_gen 的 sigmoid 结果,不额外截断生成权重;只在目标词概率取对数时设置数值下限,避免计算 log(0)。

7. 效果判断与适用范围

复制、coverage 和束搜索作用在不同位置,检查效果时需要分别观察:

要判断的问题可观察的结果需要避免的误判
复制机制是否有帮助原文 OOV 的保留情况,以及人名、数字等细节是否正确复制出的词正确,不代表它与其他词组成的事实关系正确
Coverage 是否减少重复重复短语比例、重复句子,以及摘要是否遗漏重要信息单纯降低重复,不代表主要内容保留得更好
搜索设置是否合适摘要质量、输出长度、延迟与候选状态的存储开销更高的模型概率,不等于更高的人类评价

ROUGE 常用于摘要评测:

  • ROUGE-1:衡量单词的重合。
  • ROUGE-2:衡量连续词对的重合。
  • ROUGE-L:基于最长公共子序列。

这些指标反映与参考摘要的重合程度,不能单独判定事实一致性。 人名与动作的对应关系、数字归属、否定和时间关系仍需要单独核对。

模型对照应使用相同的数据划分与分词规则,并交代输入截断长度、摘要长度限制和解码评分。若一个模型使用贪心解码,另一个使用较宽的束搜索,结果差异就同时包含模型与搜索设置的影响。

在支持充分字符或字节覆盖的子词分词方案下,生僻词能够拆成多个 token,传统词级模型的 OOV 问题会有所变化。

PGN 的扩展词表机制因而不能直接当作现代大语言模型的通用必需组件。是否需要显式复制机制,要结合分词方案和任务对原文片段的保留要求判断。

评论

使用 GitHub 登录后参与讨论,评论将公开保存在 GitHub。

阅读到这里时加载评论。