广告CTR预估场景下的DNN调优实战
特征
DNN需要组合特征 LR模型的时候,我们需要构造许多组合特征,比如UserID与ItemID的组合,许多做DNN的都宣称简化了特征工程,由隐层学习特征交叉,但是隐层进行特征组合的方式并没有明确的理论解释,并且通过隐层参数学习的方式进行隐式的特征组合并不能保证收敛到最优解,通过显示的构造组合特征能给DNN提供一些先验信息,从实战来看,DNN加上显示的组合特征效果会好很多。
稀疏特征过滤 训练数据中出现频次过少的离散特征往往容易引起过拟合,需要统计频次并做过滤。效果比较好的一种方法是,比如生成
day
这一天的特征时,使用[day-delta_day_num, day-1]
之间的特征统计值来过滤day
这一天的稀疏特征,相比使用[day-delta_day_num, day]
之间的统计值,auc明显提升。delta_day_num
可以设成14天,过滤阈值20,具体数据可以根据业务场景和实验效果来定。DNN特征划分Group 和FFM类似,需要把特征划分成多个Group,每个Group里的特征做Embedding后Sum起来。划分方法,可以根据特征语义层面(用户、Item、组合)和数量、粒度进行划分。比如把用户相关特征划分成下面几个Group:
用户细粒度Id: UserId
用户画像:Age,Gender,收入,职业
用户行为:近期浏览的ItemIds
模型
每类特征设置不同
Embedding Size
特征包含的信息越丰富,越需要更大的Embedding Size
来描述,特征包含信息的丰富程度可以通过特征的粒度和数量表现出来,具体的一个划分方式可以看下图。 特征粒度方面,单特征相对算粗粒度,组合特征相对较细,组合的层次越深,粒度越细。比如UserId#ItemId
的组合特征刻画了用户对商品的倾向,UserId#ItemId#Hour
刻画了用户在某个时间对某个商品的倾向【比如外卖,举个例子,实际一般不会这么组合】。特征刻画的粒度越细,说明指代的越具体,包含的信息非常明确却单一,这类特征一般不需要再和其他特征进行组合,所以Embedding Size
会更小。 特征数量方面,一般数量越大,包含的信息越多,比如UserId
和ItemId
可以达到上亿,一个UserId
可以描述这个用户的很多信息,像Age
和Gender
这类特征规模很小的特征所包含的信息相对有限。模型结构 Wide&Deep最靠谱,DeepFM、DCN之类的效果都不大行。在Wide&Deep模型的基础上,没有将Wide部分单独拿出来,而是和Deep在一起,通过特征划分Group后一起Embedding,效果可以超过Wide&Deep,并且右边添加了一个基于离散特征统计的历史CTR的网络,可以降低模型的Variance,效果提升非常明显。总之,不要过分迷信一些灌水论文。
减少多余训练参数 二分类模型下,最后一层全连接的代码经常是下面这样:
因为是二分类,连接layer = ... # 倒数第二层
weight = tf.get_variable('weight', [dim, 2], initializer=...)
bias = tf.get_variable('bias', [2], initializer=...)
logits = tf.matmul(layer, weight) + biaslabel=0
的所有边其实不需要学习,0
就是最优参数,如果加入学习的话,实际上多了一些冗余参数,而且梯度下降必定无法保障它们收敛到这个最优解。改成下面这样,auc
可以较大提升。layer = ... # 倒数第二层
weight_positive = tf.get_variable('weight_positive', [dim, 1], initializer=...)
bias_positive = tf.get_variable('bias_positive', [1], initializer=...)
weight_neg = tf.get_variable('weight_negative', initializer=tf.constant(np.zeros((dim, 1), dtype=np.float32)), trainable=False)
logits_positive = tf.matmul(layer, weight_positive) + bias_positive
logits_negative = tf.matmul(layer, weight_negative)
logits = tf.concat([logits_negative, logits_positive], 1)选一个好的基线模型 这些年DNN火起来后,大家都往DNN方向发展,很多团队宣称切换到了DNN,宣传文章写的也不错,但是从实际来看,真正把DNN用好的团队并不多。比如从GBDT切换到DNN的一些组,其实整个特征流程还是沿用的GBDT思路,用几百维连续特征来做DNN,或者简单加几个小规模离散特征,少数技术强悍的团队其实做的是百亿千亿特征、模型规模TGB级别的超大DNN,所以同样是Wide&Deep模型,不同的规模下其实天壤之别。DNN相对于GBDT来说,是非常容易做出成果的,主要还是把GBDT作为基线模型有点太简单了,其实在搜索推荐这类个性化很强的场景下把GBDT换成百亿千亿级别特征的超大规模LR或者FFM也会获得很大提升,所以如果要做DNN的话,推荐用FFM来做基线,实战来看DNN相对FFM要做出成果并没有那么简单。
性能
大规模离散特征
Embedding
稀疏特征的id做embedding时,由于TensorFlow内部使用一个shape=[id_num, embedding_size]
的Variable做参数,需要把id映射成[0, id_num)间的一个数字。如果id量非常小的话,可以在特征提取后把id排序一遍生成从0开始的连续id值,但在工业界场景下id往往是用murmur hash
生成的uint64 id
,量级往往是百万到千亿级别,很难做排序。TensorFlow内部有一个Hash Table可以将uint64
映射成从0开始的连续id,但可能将不同的id映射到embedding_variable
的同一行,所以建议把embedding_variable
的行数和num_oov_buckets
设置的大一点,减小一点冲突。当然,最优方案应该是使用Map结构来实现Embedding Variable
,现在官方并没有人做,我已经修改TensorFlow底层代码实现了一个,支持千亿离散特征的embedding,在公司内已经应用,这一块也可以参考阿里发布的TensorFlowRS。embedding_variable = tf.get_variable('emb_var',
[2*id_num+2, embedding_size],
initializer=...)
hash_table = tf.contrib.lookup.index_table_from_tensor(mapping=tf.constant([0]),
num_oov_buckets=2*id_num,
dtype=tf.int64)
sparse_ids = hash_table.lookup(origin_sparse_ids)
embedding = tf.nn.embedding_lookup_sparse(embedding_variable,
sparse_ids,
None,
partition_strategy="mod")Sparse Embedding
性能 使用代替def embedding_lookup_sparse_with_distributed_aggregation(params,
sp_ids,
sp_weights,
partition_strategy="mod",
name=None,
combiner=None,
max_norm=None)。后者在ps端lookup出许多embedding后传给worker,在worker端做聚合,前者在ps端做多个embedding的聚合后传给worker,通信量会小很多。def embedding_lookup_sparse(params,
sp_ids,
sp_weights,
partition_strategy="mod",
name=None,
combiner=None,
max_norm=None)不要使用
TensorFlow Feature Columns
TensorFlow Feature Columns
的性能很差,建议把特征相关的所有工作,包括离散化、组合等操作都放在单独的特征抽取工具里面,TensorFlow只包含模型部分代码。QueueRunner
批量读数据 使用read_up_to
接口批量读数据,性能提升非常大。reader = tf.TFRecordReader()
_, serialized_example = reader.read(filename_queue)
_, serialized_example = reader.read_up_to(filename_queue, 1000)使用
DataSet
接口读数据 QueueRunner读数据时不能精确一轮一轮的读,很难做worker之间的barrier,TensorFlow DataSet可以实现精确读取一轮,在worker精确同步时比较有用TensorFlow实现Barrier。而且DataSet的性能和QueueRunner差不多,主要是几个接口的使用顺序要注意。def _parse_function(examples_proto):
features = {}
features['label'] = tf.FixedLenFeature([], tf.float32)
features['feature'] = ...
instance = tf.parse_example(examples_proto, features)
label = instance['label']
feature = instance['feature']
return label, feature
dataset = tf.data.TFRecordDataset(file_name_list)
dataset = dataset.prefetch(buffer_size=batch_size*100)
dataset = dataset.shuffle(buffer_size=batch_size*10)
dataset = dataset.batch(batch_size)
dataset = dataset.map(_parse_function, num_parallel_calls=4)
iterator = dataset.make_initializable_iterator()GPU vs CPU
现在很多做算法的言必GPU,其实很多场景下并不合适。CTR模型训练场景下,主要耗时操作是Embedding Lookup
,不适合GPU,全连接层又很小,CPU足够应付。整体来看,P40
比Intel® Xeon® Processor E5-2650 v4 (30M Cache, 2.20 GHz)
快5%左右,但价格贵很多。2.7GHz的CPU性能可以提升30%,所以从性价比来看,推荐主频更快的CPU。这个一定要分应用场景,在场景下去做正确的决定,而不是人云亦云。
其他
- 训练千亿特征TGB级别参数的超大模型
- 将单机无法加载的超大模型做线上预测服务
- 秒级在线深度学习架构