特征

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:

1
2
3
用户细粒度Id: UserId
用户画像:Age,Gender,收入,职业
用户行为:近期浏览的ItemIds

模型

每类特征设置不同Embedding Size

特征包含的信息越丰富,越需要更大的Embedding Size来描述,特征包含信息的丰富程度可以通过特征的粒度和数量表现出来,具体的一个划分方式可以看下图。
特征粒度方面,单特征相对算粗粒度,组合特征相对较细,组合的层次越深,粒度越细。比如UserId#ItemId的组合特征刻画了用户对商品的倾向,UserId#ItemId#Hour刻画了用户在某个时间对某个商品的倾向【比如外卖,举个例子,实际一般不会这么组合】。特征刻画的粒度越细,说明指代的越具体,包含的信息非常明确却单一,这类特征一般不需要再和其他特征进行组合,所以Embedding Size会更小。
特征数量方面,一般数量越大,包含的信息越多,比如UserIdItemId可以达到上亿,一个UserId可以描述这个用户的很多信息,像AgeGender这类特征规模很小的特征所包含的信息相对有限。

模型结构

Wide&Deep最靠谱,DeepFM、DCN之类的效果都不好。在Wide&Deep模型的基础上,没有将Wide部分单独拿出来,而是和Deep在一起,通过特征划分Group后一起Embedding,效果可以超过Wide&Deep,并且右边添加了一个基于离散特征统计的历史CTR的网络,可以降低模型的Variance,效果提升非常明显。总之,不要过分迷信一些论文。

减少多余训练参数

二分类模型下,最后一层全连接的代码经常是下面这样:

1
2
3
4
layer = ... # 倒数第二层
weight = tf.get_variable('weight', [dim, 2], initializer=...)
bias = tf.get_variable('bias', [2], initializer=...)
logits = tf.matmul(layer, weight) + bias

因为是二分类,连接label=0的所有边其实不需要学习,0就是最优参数,如果加入学习的话,实际上多了一些冗余参数,而且梯度下降必定无法保障它们收敛到这个最优解。改成下面这样,auc可以较大提升。

1
2
3
4
5
6
7
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。

1
2
3
4
5
6
7
8
9
10
11
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性能

使用

1
2
3
4
5
6
7
def embedding_lookup_sparse_with_distributed_aggregation(params,
sp_ids,
sp_weights,
partition_strategy="mod",
name=None,
combiner=None,
max_norm=None)

代替

1
2
3
4
5
6
7
def embedding_lookup_sparse(params,
sp_ids,
sp_weights,
partition_strategy="mod",
name=None,
combiner=None,
max_norm=None)

。后者在ps端lookup出许多embedding后传给worker,在worker端做聚合,前者在ps端做多个embedding的聚合后传给worker,通信量会小很多。

不要使用TensorFlow Feature Columns

TensorFlow Feature Columns的性能很差,建议把特征相关的所有工作,包括离散化、组合等操作都放在单独的特征抽取工具里面,TensorFlow只包含模型部分代码。

QueueRunner批量读数据

使用read_up_to接口批量读数据,性能提升非常大。

1
2
3
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差不多,主要是几个接口的使用顺序要注意。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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

CTR模型训练场景下,主要耗时操作是Embedding Lookup,不适合GPU,全连接层又很小,CPU足够应付。整体来看,P40Intel® Xeon® Processor E5-2650 v4 (30M Cache, 2.20 GHz)快5%左右,但价格贵很多,性价比来看,推荐主频更快的CPU。

其他

后续再单独讲

怎样训练千亿特征TGB级别参数的超大模型

怎样将单机无法加载的超大模型做线上预测服务

秒级在线深度学习架构

参考