盒子
盒子

一篇论文解读

那就告一段落吧。

在平衡内心与周遭的过程中,缝缝补补自己眼中千疮百孔的世界……

很多事都不一样了,在表示同意赞赏nb还行的同时,其实内心也在保持着一些最后的倔强甚至不屑的态度。向往诗和远方的同时,也在吐槽当下糟糕的境况。这是暂时的妥协,而不是最后的结果。

可以预见的是,有一天,我也会被一块大饼圈住,为别人给的蛋糕沾沾自喜,因为天上掉的馅饼开始信奉神明,在推杯换盏中周旋,吃饱了面包然后驻足休息,养老等死,我的墓志铭大概就是我的第一个”hello world”代码。这是我最后的倔强,而不是暂时的妥协。

技术无罪,资本作祟的时代,人人都好像鬼怪,争夺面包,吸食人xie,手捧圣经,说着抱歉,最后还不忘总结,口感似乎差了点……

二十一岁我还在对自己说:管他三七二十一,先做自己想做的事,说自己想说的话,走自己想走的路……

时过境迁,我才意识到,这是原来是叫愤青啊。在深感无力的同时,我也只能长叹一口气(很长很长,用英文就是long long long…)。我还以为我在做自己认为对的事情,我在做自己能做到的事情。

每一次成长,都是和自己谈判的过程,而每次妥协都是在塑造新的自己。over!!!


真的不是在吐槽,有认认真真研读!!!

有关:Integrating Linguistic Knowledge to Sentence Paraphrase Generation 论文解读。

模型框架

典型Transformer-based 结构,编码器和解码器都是多个Multi-head Attention 组成。如图:

image-20211006132242262

大概分为三个部分:Sentence EncoderParaphrase DecoderSynonym Labeling

按照论文里的思路,模型训练包含了一个辅助任务即:Synonym Labeling 。先用encoder部分做辅助任务训练模型,然后整体训练做生成任务。但其实也是可以一起训练的。

下面结合作者开源的代码进行一些分析。

数据处理

  1. 第一步

执行 data_processing.py 脚本,生成一个字典文件 vocab 文件。

1
2
3
4
5
6
7
8
9
10
<pad>
<unk>
<s>
</s>



···
<eos>
<sos>

神奇的是首行的<pad> 并不是脚本添加的,需要自己手动添加,这是运行后面程序发现的。而且多出的<eos>,<sos> 也并没有用到。

  1. 第二步

执行prepro_dict.py 脚本,生成数据集对应的同义词对文件:train_paraphrased_pair.txtdev_paraphrased_pair.txttest_paraphrased_pair.txt

train_paraphrased_pair.txt 为例:

1
2
年景->1 或者->2 年景->4 或->2 抑或->2 要么->2 要->2 抑->2
······

对应train数据集中的第一行是:

1
1929 年 还是 1989 年 ?

具体对应论文中Synonym Pairs Representation 部分:同义词位置对Synonym-position paris

词->pos :其中pos表示sentence 中的位置, 是指同义词,即句子中pos位置上词对应的同义词。年景->1 中位置1处的词为年景 即是的同义词。

  1. 总结

数据处理这一步两个脚本,生成需要的数据文件有:一个词表文件.vocab ,三个同义词位置对文件_paraphrased_pair.txt

整个实验还需要五个数据集文件:train{.src,.tgt},dev{.src,.tgt},test{.src}

最后还想提一下:

tcnp.train.src 第268178行竟然是空行!!!对应同义词对为:<unk>-><unk> !!!

1
2
3
4
5
6
7
8
# tcnp.train.src
268179 阿富汗 肯定 存在 错误 。
268180 在 阿富汗 肯定 有 错误 。
268181 事实上 , 我们 确实 在 阿富汗 犯 了 许多 错误 。
# tcnp.train.tgt
268179 事实上 , 我们 确实 在 阿富汗 犯 了 许多 错误 。
268180 阿富汗 肯定 存在 错误 。
268181 在 阿富汗 肯定 有 错误 。

train 训练

训练部分代码:

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
def train(self, xs, ys, x_paraphrased_dict, synonym_label=None):
# forward
memory, sents1 = self.encode(xs)
_, _, synonym_label_loss = self.labeling(synonym_label, memory)
logits, preds, y, sents2 = self.decode(ys, x_paraphrased_dict, memory)

# train scheme
# generation loss
y_ = label_smoothing(tf.one_hot(y, depth=self.hp.vocab_size))
ce = tf.nn.softmax_cross_entropy_with_logits_v2(logits=logits, labels=y_)
nonpadding = tf.to_float(tf.not_equal(y, self.token2idx["<pad>"])) # 0: <pad>
loss = tf.reduce_sum(ce * nonpadding) / (tf.reduce_sum(nonpadding) + 1e-7)
# multi task loss
tloss = self.hp.l_alpha * loss + (1.0-self.hp.l_alpha) * synonym_label_loss

global_step = tf.train.get_or_create_global_step()
lr = noam_scheme(self.hp.lr, global_step, self.hp.warmup_steps)
optimizer = tf.train.AdamOptimizer(lr)
train_op = optimizer.minimize(tloss, global_step=global_step)

tf.summary.scalar('lr', lr)
tf.summary.scalar("loss", loss)
tf.summary.scalar("tloss", tloss)
tf.summary.scalar("global_step", global_step)

summaries = tf.summary.merge_all()

return loss, train_op, global_step, summaries

大概流程就是: encoder -> labeing and decoder

Sentence Encoder

输入:

  • sentence x token [x1,x2,x3,…,xn]

输出:

  • memory: 经过多个Multi-head Attention后的输出

和常规的transformer encoder一样。先是对句子token向量进行embedding,然后添加位置编码positional_encoding

对应代码在:model.py

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
def encode(self, xs, training=True):
with tf.variable_scope("encoder", reuse=tf.AUTO_REUSE):
x, seqlens, sents1 = xs

# embedding
enc = tf.nn.embedding_lookup(self.embeddings, x) # (N, T1, d_model)
enc *= self.hp.d_model**0.5 # scale

enc += positional_encoding(enc, self.hp.maxlen1)
enc = tf.layers.dropout(enc, self.hp.dropout_rate, training=training)

## Blocks
for i in range(self.hp.num_blocks):
with tf.variable_scope("num_blocks_{}".format(i), reuse=tf.AUTO_REUSE):
# self-attention
enc = multihead_attention(queries=enc,
keys=enc,
values=enc,
num_heads=self.hp.num_heads,
dropout_rate=self.hp.dropout_rate,
training=training,
causality=False)
# feed forward
enc = ff(enc, num_units=[self.hp.d_ff, self.hp.d_model])
memory = enc
return memory, sents1

不过有趣的是论文中关于Encoder的部分貌似有些问题:

image-20211006182655905

这是论文中的式子。在transformer中是这样的:Block(Q,K,V) = LNorm(FFN(m)+m)、m=LNorm(MultiAttn(Q,K,V)+Q) 。先add再norm啊!!!

image-20211006183417409

不知道是不是排版错误的原因。主要作者开源的代码里multihead_attention部分还有ffn 部分是先add再norm的。

image-20211006184128806

image-20211006184216337

属实给整蒙了。

Paraphrase Decoder

输入:

  • sentence y token [y1,y2,y3,…,ym] 对应tgt中的句子的token
  • x_paraphrased_dict 引入的外部知识,也就是同义词位置对:synonyms- position pairs
  • memory: 编码器的输出

输出:

  • logits, y_hat: logits是最后一层的输出,y_hat是预测值

这部分有self-attention 、vanilla attention、paraphrased dictionary attention。

self-attention部分和原本的transformer中一样,key=value=query。vanilla attention其实就是key与value相同,query不一样。paraphrased dictionary attention就是引入外部同义词字典知识的部分。这部分主要是计算论文中ct ,按论文中公式来即可。

对应代码在:model.py

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
def decode(self, ys, x_paraphrased_dict, memory, training=True):
with tf.variable_scope("decoder", reuse=tf.AUTO_REUSE):
decoder_inputs, y, seqlens, sents2 = ys
x_paraphrased_dict, paraphrased_lens, paraphrased_sents = x_paraphrased_dict
# embedding
dec = tf.nn.embedding_lookup(self.embeddings, decoder_inputs) # (N, T2, d_model)
dec *= self.hp.d_model ** 0.5 # scale

dec += positional_encoding(dec, self.hp.maxlen2)
dec = tf.layers.dropout(dec, self.hp.dropout_rate, training=training)

batch_size = tf.shape(decoder_inputs)[0] # (N, T2, 2)
seqlens = tf.shape(decoder_inputs)[1] # (N, T2, 2)
paraphrased_lens = tf.shape(x_paraphrased_dict)[1] # (N, T2, 2)

x_paraphrased_o, x_paraphrased_p = x_paraphrased_dict[:,:,0], x_paraphrased_dict[:,:,1]

x_paraphrased_o_embedding = tf.nn.embedding_lookup(self.embeddings, x_paraphrased_o) # N, W2, d_model
if self.hp.paraphrase_type == 0:
x_paraphrased_p_embedding = tf.nn.embedding_lookup(self.embeddings, x_paraphrased_p)
else:
x_paraphrased_p_embedding = paraphrased_positional_encoding(x_paraphrased_p, self.hp.maxlen2, self.hp.d_model)

# Blocks
for i in range(self.hp.num_blocks):
with tf.variable_scope("num_blocks_{}".format(i), reuse=tf.AUTO_REUSE):
# Masked self-attention (Note that causality is True at this time)
dec = multihead_attention(queries=dec,
keys=dec,
values=dec,
num_heads=self.hp.num_heads,
dropout_rate=self.hp.dropout_rate,
training=training,
causality=True,
scope="self_attention")

# Vanilla attention
dec = multihead_attention(queries=dec,
keys=memory,
values=memory,
num_heads=self.hp.num_heads,
dropout_rate=self.hp.dropout_rate,
training=training,
causality=False,
scope="vanilla_attention")
### Feed Forward
dec = ff(dec, num_units=[self.hp.d_ff, self.hp.d_model])

# add paraphrased dictionary attention
h = tf.fill([batch_size, seqlens, paraphrased_lens, self.hp.d_model], 1.0) * tf.expand_dims(dec, axis=2)

o_embeding = tf.fill([batch_size, seqlens, paraphrased_lens, self.hp.d_model], 1.0) * tf.expand_dims(x_paraphrased_o_embedding, axis=1)
W_a_o = tf.get_variable("original_word_parameter_w", [2*self.hp.d_model], initializer=tf.initializers.random_normal(
stddev=0.01, seed=None))
V_a_o = tf.get_variable("original_word_parameter_v", [2*self.hp.d_model], initializer=tf.initializers.random_normal(
stddev=0.01, seed=None))
h_o_concat = tf.concat([h, o_embeding], -1) # N, T2, W2, 2*d_model
score_tem_o = tf.tanh(W_a_o * h_o_concat) # N, T2, W2, 2*d_model
score_o = tf.reduce_sum(V_a_o * score_tem_o, axis=-1) # N, T2, W2
a = tf.nn.softmax(score_o) # N, T2, W2
c_o = tf.matmul(a, x_paraphrased_o_embedding) # (N, T2, W2) * (N, W2, d_model) --> N, T2, d_model

p_embeding = tf.fill([batch_size, seqlens, paraphrased_lens, self.hp.d_model], 1.0) * tf.expand_dims(x_paraphrased_p_embedding, axis=1)
W_a_p = tf.get_variable("paraphrased_word_parameter_w", [2*self.hp.d_model], initializer=tf.initializers.random_normal(
stddev=0.01, seed=None))
V_a_p = tf.get_variable("paraphrased_word_parameter_v", [2*self.hp.d_model], initializer=tf.initializers.random_normal(
stddev=0.01, seed=None))
h_p_concat = tf.concat([h, p_embeding], -1) # N, T2, W2, 2*d_model
score_tem_p = tf.tanh(W_a_p * h_p_concat) # N, T2, W2, 2*d_model
score_p = tf.reduce_sum(V_a_p * score_tem_p, axis=-1) # N, T2, W2
a = tf.nn.softmax(score_p) # N, T2, W2
c_p = tf.matmul(a, x_paraphrased_p_embedding) # (N, T2, W2) * (N, W2, d_model) --> N, T2, d_model

c_t = tf.concat([c_o, c_p], axis=-1) # N, T2, d_model --> N, T2, 2*d_model
out_dec = tf.layers.dense(tf.concat([dec, c_t], axis=-1), self.hp.d_model, activation=tf.tanh, use_bias=False, kernel_initializer=tf.initializers.random_normal(
stddev=0.01, seed=None))

# Final linear projection (embedding weights are shared)
weights = tf.transpose(self.embeddings) # (d_model, vocab_size)
logits = tf.einsum('ntd,dk->ntk', out_dec, weights) # (N, T2, vocab_size)
y_hat = tf.to_int32(tf.argmax(logits, axis=-1))

return logits, y_hat, y, sents2

在最后的输出层部分:论文中的 softmax layer:

代码中很巧妙的使用 tf.transpose(self.embeddings) 来表示Wy 从而将输出映射到vocab输出。

这里需要注意的是x_paraphrased_dict的表示。论文中叫做synonyms- position pairs,使用P 表示。

si 表示同义词,pi 表示同义词对应sentence中的位置。训练时,会将si 进行embeddingpi 进行位置编码positional_encoding 。这里的embeddingpositional_encodingencoder部分共享。

Synonym Labeling

输入:

  • synonym_label:同义词标签
  • memory: encoder的输出

输出:

  • logits, y_hat, loss: 一个全连接层的输出、一个预测值、一个损失

synonym_label:[True,False,…],对于给定的一个句子,如果句中词对应位置有同义词这对应label为True,否者为False。这一部分的loss对应论文中的loss2。

这是一个辅助任务,目的是确定给定句子中每个词是否有对应的同义词。有助于更好地定位同义词的位置,结合短语和同义词在原句中的语言关系。可以肯定的是,这是一个二分类任务,并且两个任务共用一个encoder。描述如下:

image-20211006192852063

对应代码:

1
2
3
4
5
6
7
8
9
10
11
12
def labeling(self, x, menmory):
synonym_label, seqlens, sents1 = x
logits = tf.layers.dense(menmory, 2, activation=tf.tanh, use_bias=False,
kernel_initializer=tf.initializers.random_normal(stddev=0.01, seed=None))
y_hat = tf.to_int32(tf.argmax(logits, axis=-1))

# Synonym Labeling loss
y = tf.one_hot(synonym_label, depth=2)
ce = tf.nn.softmax_cross_entropy_with_logits_v2(logits=logits, labels=y)
nonpadding = tf.to_float(tf.not_equal(sents1, self.token2idx["<pad>"])) # 0: <pad>
loss = tf.reduce_sum(ce * nonpadding) / (tf.reduce_sum(nonpadding) + 1e-7)
return logits, y_hat, loss

需要注意的是代码中还有def train_labeling(self, xs, synonym_label=None): 的地方。按照论文中的描述:

image-20211006194032511

这个函数是用来单独做Synonym Labeling 任务的。按论文中的原意,应该是先model.train_labeling(xs, synonym_label) 然后 在进行model.train(xs, ys, x_paraphrased_dict, synonym_label)

但是train.py 代码中并没有这样做。而是直接进行train

有关细节问题

  1. vocab

前面提到过,生成的词表中多出<sos>,<eos> 两个没有用到的词,少了用于填充的<pad>,并且需要自己在首行手动插入<pad> 。猜测可能是课题组的祖传代码。

而且在生成同义词位置对的文件的代码那里,将不在vocab中的同义词统统过滤掉了。

  1. Synonym Pairs Representation

image-20211006200116402

论文里提到如果对应同义词是一个短语,那么就将短语中词嵌入向量求和来表示该短语的向量表示。但是!!!有意思的是,代码中并未体现。而细挖他的数据会发现,给定的数据已是分好词的按空格分隔的。而且是中文数据。中文分好的词,如果是短语,分词后,还是表示一个词。而英文如:abandon同义词give up。give up分词后就是两个单词。也就出现上述情况。

因此,得出结论,作者开源的代码是不完整的。如果换成英文的数据,那么需要考虑的复杂一些了。当然也可以选择把短语同义词过滤掉,那么和中文上处理就是一样的了。

  1. paraphrase_type

paraphrase_type这个是代码中的一个配置参数,默认为1。

parser.add_argument('--paraphrase_type', default=1, type=int)

这是所有参数中为数不多没有help提示信息的参数。并且我相信这也是唯一一个没有help整不明白的参数。释义类型?

data_load.py 中找到了蛛丝马迹:

image-20211006202356732

而这一段代码,也属实有些魔幻。

首先parser.add_argument('--paraphrase_type', default=1, type=int) 这里使用的是1。而取值为1时,对应代码中esle:部分。不用很仔细就可以看出:0时,使用word_set,1时,使用pos_set。我们在观察后面的synonym_label 那一句:

synonym_label = [i in word_set if paraphrase_type else w in word_set for i, w in enumerate(in_words)]

码字到这里,我掐了以下人中,方才缓过神来。接着分析,为0的情况下,那么考虑w in word_set 的真值情况。而for i, w in enumerate(in_words) ,w属于in_words。word_set是什么呢?word_set.add(tem1) 哇哦,是同义词集合诶,in_words是什么?in_words=sent1.split()+['</s>'] 欸,那w难道不是don’t exit in word_set forever?

离谱的是,这里讨论的是paraphrase_type = 1的情况,也就是关word_set屁事的情况。word_set这时都是空的。所以synonym_label 难道不是always be False

Are you kidding me? %$*#@>?*&。。。。

而且x_paraphrase_dic 那里也是有问题的。

x_paraphrase_dict.append([token2idx.get(tem1, token2idx["<unk>"]), token2idx.get(tem2, token2idx["<unk>"])])

这个token2idx.get(tem2, token2idx["<unk>"]) 就很有问题,tem2表示的是pos啊,句子中词的位置,直接给我token2idx 我是无法理解的。

x_paraphrase_dict.append([token2idx.get(tem1, token2idx["<unk>"]), int(tem2 if tem2!="<unk>" else 0)])

int(tem2 if tem2!="<unk>" else 0) 也就是说tem2为<unk> 时,大概就是tem2取0的意思。而tem2出现<unk> 的情况时,tem1也是<unk> 。此时x_paraphrase_dict添加的就是[1, 0] (token2idx[““] = 1)。举个例子:

1
2
句子x: x1 x2 x3 x4
对应同义词位置对: <unk>-><unk>

这表示这个句子中没有词含有同义词。这个时候x_paraphrase_dict添加[1, 0],就相当于<unk> 为 x1的同义词。这怎么可能?It’s impossible!!! 而且就很不reasonable 。简直离谱!!!离了个大谱。

甚至这段代码的第二个for循环后面的那部分代码逻辑都是有问题的。synonym labeling 任务我认为是有问题的。而将<unk> 与位置0处单词绑定,本身就引入了一些噪声,甚至可能增加<unk> 释义的潜在可能性。至于模型能work,我想,synonym labeling本身作为辅助任务,其loss权值占比为0.1,影响应该是很小的。

这里大胆揣测以下paraphrase_type 意图:

  • paraphrase_type = 0时:x_paraphrase_dict包含句子中的词以及对应同义词,不含位置信息。
  • paraphrase_type = 1 时: x_paraphrase_dict其实包含同义词以及对应位置pos。

无论哪一种情况,其实都是在做一件事:就是将词与其对应的同义词之间进行绑定。第一种更像是比较直接的方式,第二种则略显委婉点。殊途同归!!!

不管是不是这两种情况,其实那块代码都是有问题的。

  1. 有关 paddings

data_load.py 中有段这样的代码,用来获取一个batch size 数据的dataset函数:

image-20211007104255666

其中关于paddings 处的地方自认为还是有些不妥的,对于src、tgt句子进行0填充是正常操作,但是对于x_paraphrased_dict也进行0填充是欠考虑的。

对于x_paraphrased_dict ,0填充,就会出现一部分[[0,0],[0,0],…]的情况 默认将[pad]字符与位置0的词对应了。

总结

这篇论文做的是: Sentence Paraphrase Generation

其中真正核心地方在于 Knowledge-Enhanced ,知识增强。主要就是通过引入外部信息(这里是同义词字典信息)来指导模型生成更加多样性的句子。关于知识增强的方式还是有很多的,这篇论文采用的应该是词表注意力机制,得到外部信息特征表示ct

代码开源了,貌似有没有完全开源!!!

开源的代码基于python3.6 + tensorflow-gpu==1.12。调试起来真的好麻烦。看不惯tf1.x的代码风格,然后用tf2.x复现了下。

  • 对于 paraphrase_type 的两种情况按上述理解做了调整。
  • <unk> 匹配第一个单词的情况进行纠正,将tme2 = <unk> 时(句子中没有词存在同义词的情况),用第一个词与第一个词匹配。即将x1的同义词匹配为x1,这样还是比较妥当的。
  • 过滤了空行,减少不必要的噪声。(空行对应的同义词对为 \->\
  • synonym_label中使用2进行padding。训练时,是需要对其进行padding的保证输入的数据工整的。比如句子idx会使用0进行填充直到maxlen,而idx=0对应词为 <pad> 。显然,<pad> 和其他词一样,是一个单独的类别了。所以,为了区分,synonym_label的padding_value设置为2。最后做成一个三分类任务。无伤大雅,主要是为了适配句子的填充。
  • 为了适应x_paraphrased_dict 的0填充,对输入句子src的首位置引入一个填充符<pad> 。这一点与第二点先呼应。

不知道效果如何,小作坊,资源有限,训练完要很久很久。敬佩所有敢于开源代码的科研人员,也希望所有开源代码可读性越来越好吧。也希望所有开源代码都能复现结果。至少把种子固定了吧!!!

————————————–——–———-———10月16日更——————-–—-———————————————

一点思考

兜兜转转,模型训练了好几遍,从训练指标来看,loss有下降,acc有升高,但是推理的时候,预测的起始符号\后一个词总是结束符\ 。debug无数遍,优化了一些细节上的小问题,还是出现那样的情况。最后将问题锁定在了padding_masklook_ahead_mask 上,其实最可能猜到就是look_ahead_mask 有问题。此处的mask都是0和1填充的,代码中使用了tf.keras.layers.MultiHeadAttention 接口,对于0、1矩阵的mask,在里面并没有进行mask*-1e9 的掩码操作,这也导致了训练时出现了数据穿越/泄露问题。所以在推理时,输入的起始字符进行预测时不能得到正确结果,至于为什么是结束符,可能是起止符在词表中相邻的缘故。

今天改好后,重新训练,十多个小时过去了,还没训练完(3090,24G显存)。

如今的顶会基本被财大气粗的大公司大实验室的团队承包,小作坊式实验室夹缝求生。顶会期刊也不乏滥竽充数者,实验结果复现难,开源名存实亡……等等一系列骚操作。现有大环境下,一言难尽。

————————————–——–———-———10月17日更——————-–—-———————————————

开完组会,被老师叫停,没有硬件资源,也只好先放弃,把其他事情提上日程。

image-20211006221538257

支持一下
  • 微信扫一扫
  • 支付宝扫一扫