SeqGAN 是第一篇用 GAN 在 NLP 上做出一些成果的 model,Lantao Yu 的 CV 看的我也是羡慕不已,想着同龄人已经有如此出色的成绩,不由得感觉自己还是太菜了。
GAN for NLP
先前写过一篇文章,关于 Adversarial Training 在 NLP 的一些思考,Adversarial Training 算是 GAN 的源头,而 GAN 算是其在生成器领域的一大成就。但这个 GAN 在文本领域一直不怎么 work,先前我也做过用 GAN 中 Discriminator 作为 Classifier,希望对抗训练能够提升其分类准确度,然而并不成功。原因当时也总结了一下,记录在这里,一句话概括就是 Generator 太垃圾根本不能忽悠 Discriminator,反而 Discriminator 因为训练轮数少了(和 Generator 交替训练)被拖了后腿。那么问题又回到了 GAN 来生成文本的问题上了,图像和文本的核心区别在于图像的 Pixel 表示是连续的,而文本是由离散的 token 组成。因而,Goodfellow 也在 reddit 上说:
You can make slight changes to the synthetic data only if it is based on continuous numbers. If it is based on discrete numbers, there is no way to make a slight change.
If you output the word “penguin”, you can’t change that to “penguin + .001” on the next step, because there is no such word as “penguin + .001”. You have to go all the way from “penguin” to “ostrich”.
参数的微小改变不能对结果产生影响,或者说影响的方向也不对,这就导致 Discriminator 的梯度回传变得没有意义。再进一步的关于 GAN for NLP 的讨论,建议可以阅读胡杨的文章。前面说的这些都是为了凸显 SeqGAN 这一工作的重要性,毕竟,GAN 在 NLP 上这么难搞,还能搞出来,肯定了不得。确实,SeqGAN 是了 RL + GAN 用于文本生成的一大创举,接下来,一睹风采。
Seq GAN
Seq GAN 的模型很简洁:
沿用 GAN 的架构, Generator 来生成文本和 Discriminator 来判别文本是真实的还是生成的,那么是怎么解决更新 Generator 参数的这个问题的呢?用 Policy Gradient!如果我们把 Generator 看成是一个 Agent,他在每一 time step 上生成的 word token 作为 action,此前生成的所有 tokens 作为 state,我们就可以设计一个 reward function 来指导 Generator 生成更真实的句子。因为 Discriminator 输出是真实句子的概率 0-1,直接拿来作为 reward,于是就有了下面的式子:
$$ Q_{D_\phi}^{G_\theta}=(a = y_{T}, s= Y_{1:T-1} = D_{\phi}(Y_{1:T}))$$
但是对于生成了一半的句子怎么评估其真实性呢?用蒙特卡洛(蒙特卡洛就是随机采样)搜索在一个 Generator 的拷贝上(为了避免搜索过程引入对梯度的影响)补全句子,然后再交给 Discriminator 评估。于是,完成的 Q function 如下:
讲到这里,整个模型的架构已经讲完了,是不是很简单粗暴明了。接下来实作上还有一些小 tricks,文章的算法摘录如下:
Tricks
有些经验性的东西能 work 但是说不出个由头,也就成了所谓的玄学,文章用到的一些 tricks 如下:
CNN 做 Discriminator,LSTM 做 Generator:LSTM 做 Generator 没什么话说,Seq2Seq 里面也差不多是这么做的,为什么用 CNN 做 Discriminator 而不是 RNN,我觉得一个很重要的原因是 CNN 比 RNN,其他的就不清楚了。
还有就是 Reddit 上一个用户的 trick,也在 SeqGAN 中出现了:
a). Train a generator (G0) as normal using max-likelihood.
b). Train your discriminator to discriminate between inputs of this generator (G0) and real data.
c). Start with a fresh generator (G1) and use the GAN architecture to train it using the same discriminator.
先用 MLE 来训练 G,再用 G 生成的文本和真实文本预训练一下 D,再用全新的 G1 开始 Adversarial Training,可以理解为以后的拳击运动做热身运动吧。不过文章没有用一个全新的 G1,而是在 G 的基础上继续 G vs D 的过程。
$G_\beta$ 的参数并不是完全和 $G_\theta$ 保持完全一致,而是稍稍有些滞后更新,这是一个 weight decay 的操作,目的是用于 regularization,后面会详细的说。
Code
SeqGAN 的代码已经开源,但网上很少有结合代码对照论文讲解的。我向来是奉行 talk is cheap, show me the code
这一朴素的原则,因此,我来尝试着对论文的核心代码做一些小小的解释。
Generator
前面说了,Generator 是用一个 LSTM 做的,TensorFlow 本身有着 LSTM cell 的封装,但是不好用,为什么呢?因为 token 是要一步一步生成的,而 LSTM cell 是无法参与 iteration 这一过程的,因此需要手搓一个 LSTM cell 和循环生成 token 的代码:
1 | # TensorArray for storing results |
LSTM 的代码就不放了。这里用 TensorArray 来做结果的保存让我学到了,以前我都一直不知道用什么 TensorFlow 类型能不断的往里面写和读(Variable、Constant不能写,placeholder 要你传进去)。
至于预训练阶段,我们的生成是要基于真实的 token 的,所以代码有所区别:
1 | # supervised loss |
手动 optimize varibales 虽然我也不是第一次见到了,一般我都很懒的 tf.train.AdamOptimizer().minimize(loss)
,但我查了一下,说是梯度裁剪gradient clip
确实很多情况下比不裁剪要好,以后也就尽量这么干吧。
最后来看一眼 Generator 的 loss :
1 | # generator loss |
就是我们上篇 blog 讲的,在 MLE loss 基础上,通过 reward 加权求和得到。
Discriminator
文章用 CNN 做 Discriminator,实际上就是一个分类器,输出 [0, 1] 则为真实文本,[1, 0] 即为生成本文。CNN 做 Classifier 在图像领域我是见的很多,做文本听说过但还真是第一次见:
1 | # Create a convolution + maxpool layer for each filter size |
把 word embedding 之后的 [batch_size, seq_len, embedding_size]
的向量扩充一个维度(对应 RGB channel)变成[batch_size, seq_len, embedding_size, 1]
我们就可以把文本看成一张图片了,然后使用不同 size 的 kernel,主要是 [1, filter_size, embedding_size, 1],因为第一维是 batch_size 一般不做卷积操作,第二维是长度,我们会用不同长度的 filter_size 来 capture 不同长度下的特征,然后最后池化到 1x1,再把不同 kernel 卷积的结果拼起来,最后来一层全连接层输出到 num_class 搞定。
同样这里使用了手动 optimize:
1 | self.params = [param for param in tf.trainable_variables() if 'discriminator' in param.name] |
G_beta
$G_\beta$ 值得好好谈一谈,首先是要实现的功能:MC 产生完整的句子以及给出 reward。
先来看 MC 产生完整句子:
1 | # Unstack the values of a `Tensor` to the TensorArray |
这里有两个循环,是针对两种情况,一个是已经生成的部分,直接用已经给出的 tokens 作为输入,但是考虑到后面继续生成未完成的部分我们还需要最后一个给定 tokens 所产生的 hidden_state,所以我们依旧进行 time step 的循环;到了没有 tokens 的时候,根据最后一个 hidden_state 继续生成完整的句子,就和 Generator 的代码类似了。
给出 reward,就是基于已经补充完整的句子,通过 Discriminator 计算他的真实性,用真实性概率作为 reward:
1 | def get_reward(self, sess, input_x, rollout_num, discriminator): |
因为我们的 Discriminator 的输出是类似 [0.1, 0.9]
这样的概率,所以句子为真实文本的概率即为 item[1]
的值,我们采样 rollout_num
次,对最后的 reward 取平均得到最终的 reward,这也就是前面 Q function 的实际实现。PS:原版的实现这里直接把 self.sequence_length 代成了 20,为此我还提了一个 PR,不知道 LantaoYu 会不会 merge, 2333.
然后是其参数更新的手段,由于篇幅,只展示最后输出单元的代码:
1 | def update_output_unit(self): |
文章里说 $G_\beta$ 是和 $G_\theta$ 完全相同的,但这里参数的更新并不是直接拷贝,而是根据 update rate 来计算需要更新的部分,也可以理解,因为 SGD 训练的更新必然是 variance 的,而限制每次更新的幅度能够起到 regularizaiton 的作用。
Training Process
训练的过程就按照算法描述的部分,这边就摘录一些核心的片段:
1 | # avoid occupy all the memory of the GPU |
这是我以前一直不知道的用法,因为我没有 GPU(贫穷限制了我的想象力),因为 TensorFlow 默认会占完所有显存,所以如果和别人共用机子,写上这样的配置可以按需分配显存。
1 | for total_batch in range(TOTAL_BATCH): |
核心的 Adversarial Training 的代码就在上面了,在一次迭代中生成一些 fake data,根据 reward 来更新 G 的参数,同时滞后更新 $G_\beta$ 的参数,然后再训练 Discriminator。这里是 Discriminator 训练的次数多于 Generator,这也是 GAN 里面常用的手段了。
Summary
SeqGAN 的代码是用的合成数据做实验,输出只有 loss,无法直观的感受其生产的结果,但实际真实的文本生成还没有对应的开源代码。论文有提到说拿来测试过古诗生成,所以接下来我会尝试用 SeqGAN 做一下古诗生成的任务,看看效果到底怎么样。
这个代码看了我三天,收获到了很多原本不知道的 TensorFlow 的用法,所以说,代码还是多看多写。