论文How Large a Vocabulary Does Text Classification Need? A Variational Approach to Vocabulary Selection的代码实现,我有一篇关于这个论文的介绍,这里介绍它的具体实现。
引入及简介
论文How Large a Vocabulary Does Text Classification Need? A Variational Approach to Vocabulary Selection的代码实现,我有一篇关于这个论文的介绍,这里介绍它的具体实现。
这项目的代码实现给我看麻了,肯定是那种大组的祖传代码,2019年的论文,用的是python2+tensorflow0.X(甚至不是1.X),一堆上古写法,看一点就得谷歌一会儿。
当然,好过那种不给代码的。由于实际上我没弄懂变分估计,我只是按照它的代码把整个计算逻辑搬过去了而已。
(本工作进行于2020年5月)
通过变分法估计词的重要性分布需要在具体的任务中进行,在不同的任务下,单词的重要性分布可能会发生变化。因此本项目算是个多任务学习模型,损失由主任务损失和变分词嵌入模型损失组成。
核心模型
这篇论文的核心是带变分估计的词嵌入,故其核心代码是项目中的VarDropoutEmbedding类,但是可能是因为祖传代码,这个项目的解耦做的很屑,除了VarDropoutEmbedding类,还有许多机制放在了主model中,甚至还有许多机制放在了训练的外围代码中。
VarDropoutEmbedding的初始化代码如下:
1 2 3 4 5 6 7 8 9 10 11
| def __init__(self, input_size, layer_size, batch_size, name="embedding"): self.name = name self.input_size = input_size self.layer_size = layer_size self.batch_size = batch_size self.logdropout_init = tf.random_uniform_initializer(0, 3) self.embedding_mean = tf.get_variable(name, [self.input_size, self.layer_size]) self.embedding_logdropout_ratio = tf.get_variable(name + "_ratio", [self.input_size, 1], initializer=self.logdropout_init) self.eps = tf.random_normal([self.batch_size, 1, self.layer_size], 0.0, 1.0)
|
由代码可知,模型为所有的词创建了词嵌入矩阵,即嵌入模型训练时用到了整个语料库的所有的词,因为你并不能预先假设哪些词没有用(可以进行一定的预先切割)。
并按词的数量创建了一个dropout向量,每个词的dropout值在0~3间随机生成
VarDropoutEmbedding的调用化代码如下:
1 2 3 4 5 6 7 8 9 10 11
| def __call__(self, input_data, sample=False, mask=None): if sample: output_mean = tf.nn.embedding_lookup(self.embedding_mean, input_data) output_logdropout = tf.nn.embedding_lookup(self.clip(self.embedding_logdropout_ratio), input_data) output_std = tf.exp(0.5 * output_logdropout) * output_mean output = output_mean + output_std * self.eps elif mask is None: output = tf.nn.embedding_lookup(self.clip(self.embedding_mean), input_data) else: output = tf.nn.embedding_lookup(mask * self.clip(self.embedding_mean), input_data) return output
|
当调用嵌入层时,如果是使用采样模式(由sample标记控制),就使用完整的词向量矩阵,查询每个词的向量和每个词的dropout值,并进行一定的转换(应该是变分的机制)。
如果不是采样模式,且没有通过外部传入mask的话,则直接查询嵌入;如果外部传入了mask,则用外部传入的mask(这条主要在验证和测试时使用)。
其中self.clip()是将dropout值限制到-10到10之间的一个内部方法。
代码纠缠处其之一
上面说到,本模型的损失由主任务模型和变分模型损失组成,变分估计是推断未知数据的概率分布,而衡量两个概率分布之间的差一半用KL散度,这个项目把KL散度的计算的一部分放在了VarDropoutEmbedding层中:
1 2 3 4 5
| def regularizer(self): k1, k2, k3 = 0.63576, 1.8732, 1.48695 log_alpha = self.clip(self.embedding_logdropout_ratio) KLD = -tf.reduce_sum(k1 * tf.sigmoid(k2 + k3 * log_alpha) - 0.5 * tf.nn.softplus(-log_alpha) - k1) return KLD
|
注意,只是一部分,因为这里算出来的这个KL散度并不能直接用,在主模型中还需要处理这个KL散度。
主模型
主模型中涉及变分嵌入模型的代码如下:
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
| ...
self.embedding = VarDropoutEmbedding(vocabulary_size, emb_size, batch_size)
if variational: self.mask = tf.cast(tf.less(self.embedding.embedding_logdropout_ratio, self.threshold), tf.float32) self.sparsity = tf.nn.zero_fraction(self.mask) ... self.weight_decay = tf.placeholder(tf.float32, shape=(), name="weight_decay")
if variational: if is_training: self.x_emb = tf.expand_dims(self.embedding(self.x, sample=True, mask=None), -1) else: self.x_emb = tf.expand_dims(self.embedding(self.x, sample=False, mask=self.mask), -1) self.embedding_matrix = self.embedding.zeroed_embedding(self.mask) ...
if variational: self.reg_loss = self.weight_decay * self.embedding.regularizer() else: self.reg_loss = tf.constant(0., dtype=tf.float32) ...
...
if is_training: with tf.name_scope("loss"): self.cross_entropy = tf.reduce_mean( tf.nn.sparse_softmax_cross_entropy_with_logits(logits=self.logits, labels=self.y)) self.loss = self.cross_entropy + self.reg_loss self.optimizer = tf.compat.v1.train.AdamOptimizer(self.learning_rate).minimize(self.loss, global_step=self.global_step, var_list=trainables)
|
主模型会将外部传入的threshold值送入到变分嵌入层,并获得mask矩阵。KL散度将会在主模型内通过weight_decay进行权重递减。获得主任务的输出后将会计算主任务的损失,并将两个损失相加。
按我的习惯,这个损失要么在变分嵌入层内完成,不要暴露到外面来,事实上这个reg_loss的计算与主模型无关。
要么就把所有损失都放在最外层的外围代码中,不要在模型内部写损失计算逻辑,不利于解耦。
这是我觉的第二个纠结的地方。
外围代码
前面的代码块里有一个weight_decay值,这个值是从外部给来的,仅用于执行对于KD散度的权重递减(本代码没有收录adam优化器)。
weight_decay通过get_decay_rate函数计算,并feed_dict传入(feed_dict是坏文明),代码如下:
1 2 3 4 5 6 7 8 9 10
| for epochs, x_batch, y_batch in train_batches: cur_decay, learning_rate = get_decay_rate(epochs) train_feed_dict = { model.x: x_batch, model.y: y_batch, model.weight_decay: cur_decay, model.learning_rate: learning_rate, model.threshold: 3.0, model.l1_threshold: 1e-4 }
|
由以上代码可知,get_decay_rate函数的入参是epochs,即训练轮次。
按照训练轮次进行权重递减是常见操作,但是它这里手撸权重递减,让我后面在keras里使用这个方法时,改得意识模糊。因为keras中epoch值仅有框架本身能掌握,没有提供向模型内传入当前训练的epoch的方法。
并且由于tensorflow1.X默认不是eager execution模式,而itag是用keras实现的,后端是1.x版本的tensorflow,直接改成eager execution有要修一堆bug,也没法自己简单的实现入参。
最后我在itag中新增了一个input,然后在data generator中生成epoch作为假数据输入实现。
以下是get_decay_rate函数
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
| def get_decay_rate(epoch): if "char" in args.model: return 0, 1e-3 else: if "rnn" in args.model: learning_rate = 5e-3 else: learning_rate = 5e-3 if args.l1: return 1e-6, learning_rate elif args.variational: if "rnn" in args.model: small_decay = 1e-5 else: small_decay = 1e-5 large_deacy = 1e-5 start_decay = 40 interval = (large_deacy - small_decay) / (NUM_EPOCHS - start_decay) if epoch < start_decay: cur_decay = small_decay else: cur_decay = interval * (epoch - small_decay) + small_decay return cur_decay, learning_rate else: return 0, learning_rate
|
根据使用的主任务模型的不同,KL散度的decay和学习率也有所不同。
总结
上古代码真难看。
feed_dict是坏文明。
KL散度实际计算逻辑分了三块真反人类。