作者:杨辉之,新浪微博推荐广告算法工程师,个人知乎专栏(推荐机器学习模型&架构随笔)欢迎交流讨论
为了提高模型的时效性、样本容量和节省集群资源,本人去年主导并完成了基于tensorflow的增量学习框架,目前组内基本所有产品线的ctr和cvr模型都从batch训练方式切换为增量训练方式,线下和线上都取得了显著的收益。现将中间踩过的坑和增量学习应该需要考虑的关键点做个简单的总结,当然还有很多不完善的地方,欢迎一起拍砖讨论。
增量模型在每次训练前都需要对上一次的模型做restore操作,包括所有的模型参数和相应优化器的一二阶梯度信息。这里有一个注意点就是一定要对优化器的一二阶梯度信息做restore,不然增量模型在流量低峰和流量高峰后几个小时会有低估或高估较多的现象出现,当然也跟实际业务也存在一定的因果关系,具体什么原因欢迎大家一起讨论。单纯对整个模型做restore的代码特别简单。
load_vars = [var for var in tf.get_collection(tf.GraphKeys.GLOBAL_VARIABLES) if var.op.name.find("EpochWait") == -1]
self.saver = tf.train.Saver(var_list=load_vars)
self.saver.restore(session, tf.train.latest_checkpoint(self._restore_model_dir))
但上述方式需要预设一个很大的Embedding矩阵,在特征数快超出该门槛值的时候,需要重新进行增量会带来很大的不便,一个是门槛值不好预设,另一个是模型的特征规模受限。所以Embedding矩阵的特征规模动态增长是一个需要解决的方案。这里最自然能想到的方法就是将上一次增量的Embedding矩阵通过emb_var.eval(session)保存成ndarray格式的变量oldvar,在此基础通过np.concatenate方法动态增长为新的ndarray格式的变量new_var,在tf的计算图内新增一个emb_var.assign(new_var)的op,再通过session.run(op)即可完成Embedding矩阵的动态增长。
最开始按这个方法实现后整个流程都没有问题,但是增量一段时间后,发现这个流程报了“Cannot serialize protocol buffer of type tensorflow.GraphDef as the serialized size (2152158729 bytes) would be larger than the limit (2147483647 bytes)”的error,一看模型的"graph.pbtxt"文件超出了2G,"graph.pbtxt"文件存的是tf的计算图相关信息,理论大小不应该超出2G。通过查看tf的官方文档和增量流程中"graph.pbtxt"文件变化过程,发现emb_var.assign(new_var)的op操作会将new_var存到计算图中,造成了"graph.pbtxt"文件不断在增长直到超出2G的限制。找到原因就好解决了,将new_var由tf.placeholder进行替换,并在session.run(op)中通过feed_dict将new_var传入,完美解决了计算图过大问题以及实现了Embedding矩阵的动态增长。
增量训练过程中会出现很低频的特征进入模型训练,对模型的训练结果是不置信的,泛化性较差。这里我们实现了两种机制来限定新增特征准入:
设置特征准入“门槛”,历史累计pv低于“门槛”值的特征不进模型训练
利用动态L1正则技术,低频的特征施加比较大的正则项
第1种方式是一个硬规则,极端情况下会出现增量训练前该feaid出现了“门槛”值-1次,本次增量训练刚好满足“门槛”值,则实际参与训练的样本只有一条,仍然存在泛化性差的feaid出现,但实现简单。
第2种方式则相对平滑很多,具体L1正则系数的公式可如下所示:
上式中 为不满足pv“门槛”值的惩罚系数, 为pv“门槛”值, 为该 的历史累计pv,0.0001为满足门槛值 的L1正则系数,不满足门槛值 的L1正则系数则在此基础上乘以一定的倍数,以让模型对该 的学习更倾向于先验L1分布,提升泛化性,减少ctr模型预估误差。
随着增量模型一直往前滚,特征规模也一直在增长,从几千万增长到几亿,会受制于性能和单机内存的压力,故需要对增量模型的特征规模进行压缩,也即特征淘汰。这里我们实现了三种方式来做特征淘汰:
淘汰模型中未成使用过slot下面的所有feaid
淘汰长久未更新的feaid
淘汰L1正则项很小的feaid
第一种方式主要是针对在生成feature_mapping时引入了模型训练时并未配置的slot,造成了特征空间浪费,只需要对Embedding矩阵做个简单的重编码映射即可,实现相对简单。
第二种方式主要是针对长久没有在样本中出现的feaid,比如广告adid已经不投放了,与其相关的单特征和交叉特征都可以进行淘汰操作,具体实现可以对feature_mapping做定时天级的checkpoint的保存,在此基础统计n天都未有累计pv变化的feaid进行淘汰即可。
第三种方式主要是针对Embedding权重很小feaid进行淘汰,这也是常规的模型剪枝操作,简单有效。
面对几十亿、上百亿的训练样本,实际业务中一般采用大规模分布式训练,为了提升训练效率一般采用每个worker对ps端的参数进行异步更新,这会带来一个问题就是:有些worker性能好,训练了多个epoch,而性能差的worker只训练了一个epoch,造成训练样本倾斜。所以添加epoch wait还是很有必要的。这里给出基于tensorflow实现的epoch wait机制。
# 计算图内变量定义
with tf.variable_scope("EpochWait"):
worker_cur_epoch_nums = [tf.get_variable(name="woker_{}_epoch_num".format(i), shape=[],
initializer=tf.constant_initializer(0), dtype=tf.int32, trainable=False)
for i in range(worker_num)]
# epoch wait变量获取 & 该worker epoch个数自增op创建
for var in tf.get_collection(tf.GraphKeys.GLOBAL_VARIABLES):
if var.op.name.find("EpochWait") != -1 and var.op.name.find("epoch_num") != -1:
self._worker_cur_epoch_nums.append(var)
if var.op.name.find("woker_{}_epoch_num".format(self._task_index)) != -1:
self._cur_epoch_add_op = tf.assign_add(var, 1)
# epoch wait 同步
session = run_context.session
self._have_run_steps += 1
self._have_run_all_steps += 1
global_step = session.run(tf.get_collection(tf.GraphKeys.GLOBAL_STEP))
if self._have_run_all_steps % 1000 == 0:
print("{0}-{1} 当前local step: {2}".format(self._job_name, self._task_index, self._have_run_all_steps))
if self._have_run_steps == self._worker_steps_per_epoch:
self._have_run_steps = 0
session.run(self._cur_epoch_add_op)
cur_train_epoch = session.run(self._worker_cur_epoch_nums[self._task_index])
while True:
no_finish_num = len([tmp_epoch for tmp_epoch in session.run(self._worker_cur_epoch_nums) if tmp_epoch != cur_train_epoch])
if no_finish_num > 0 and self._wait_num < 120:
print("当前epoch: {0}, 未完成该epoch的worker个数: {1}, global step: {2}, 等待30秒".format(cur_train_epoch, no_finish_num, global_step))
time.sleep(30)
self._wait_num += 1
else:
if cur_train_epoch == self._num_epochs:
if self._job_name == "chief":
print("{0}-{1} worker完成{2}epoch训练,进行模型保存".format(self._job_name, self._task_index, self._num_epochs))
time.sleep(20)
elif self._job_name == "worker":
print("{0}-{1} worker完成{2}个epoch训练".format(self._job_name, self._task_index, self._num_epochs))
time.sleep(1000)
self._wait_num = 0
break
if self._job_name == "chief" and self._have_run_all_steps == self._need_run_steps:
print("chief finish {0} steps, will finish model train".format(self._have_run_all_steps))
添加epoch wait机制后,实际业务中的ctr模型相对不添加版本离线auc有千一二的提升,效果还是很明显的。
在大规模分布式训练实践中,有两个可以显著提升训练效率的关键点,一个是在input_fn中利用tf.data.TextLineDataset或tf.data.TFRecordDataset搭建并行的datapipeline,能提高数据处理和训练的并行度;一个是对tf.nn.embedding_lookup中ids参数先做tf.unique操作,再通过tf.gather函数还原,能极大减小worker与ps之间的通信压力,提高ps的cpu的使用率,当然也跟实际特征的稀疏度有关,我们业务中实测能降低60%的通信压力,效率提升十分显著。至于op操作的优化可以通过tf.train.ProfilerHook打印每个环节的耗时,进行有针对性的op优化。