Press "Enter" to skip to content

DeepFM模型CTR预估理论与实战

本站内容均来自兴趣收集,如不慎侵害的您的相关权益,请留言告知,我们将尽快删除.谢谢.

本文介绍DeepFM模型的原理、代码和用于CTR预估的示例,同时也会介绍相关的FM模型。

 

目录

 

问题简介

 

点击率(Click Through Rate, )预估是程序化广告里的一个最基本而又最重要的问题。比如在竞价广告里,排序的依据就是$ctr \times bid$。通过选择$ctr \times bid$最大的广告就能最大化平台的eCPM。从机器学习的角度来说这是一个普通的回归问题,但是它的特殊性在于训练数据只有0/1的值——因为我们没有办法给同一个用户展示同一个广告1万次,然后统计点击的次数来估计真实的点击率。另外有人也许会有这样的看法:对于某一个特定的曝光,某个用户是否点击某个广告是确定的,第一次不点,第二次也不会点,因此点击率是一个0/1的固定值而不是一个0-1之间的概率值。这个说法有一些道理,原因是第二次实验和第一次使用不是独立同分布的。“真正”的做法是第二次做实验前要擦除用户第一次实验的记忆,然后在一模一样的场景(时间、地点……)下做N次独立实验。但是我们也可能从另外一个角度理解点击率:有很多用户比较类似,比如类似的用户有1万个,在相同的条件下他们点击的次数和除以1万可以看成点击率。如果从“上帝”视角来看,如果能预知未来,那幺确实可以知道每一个用户是否会点击某个广告,然后能够准确的估计这一次是否会点击。但是实际由于我们信息的缺失和计算能力的限制,我们不可能准确的提前预估。如果在训练数据上精确的预估了0/1这样的点击率,那幺更可能是过拟合了。

 

另外有一点就是我们采集的数据是有偏的。比如在CPC的竞价广告中,我们收集到的都是竞价获胜的广告,而竞价又是受CTR的影响,因此会产生放大作用——CTR估计的越低,则竞价获胜的可能性越低,曝光的次数也越少,从而点击率估计的也有偏。

 

评价指标

 

评价CTR的最常见指标是logloss和AUC。logloss就是计算真实分布和预测分布的交叉熵,计算公式为:

 

其中$y_i$是真实的值(0/1),而$ctr(x_i)$是模型预测的点击率。

 

下面我们来介绍AUC,这里主要参考了 这篇文章 。在介绍AUC之前,我们先介绍一下二分类的混淆矩阵。下表是每个测试数据的真实值和模型的预测值:

 

 

真实值 1预测 模型2预测
0 1 0
1 0 1
1 1 1
1 1 0
0 0 0
0 1 0
1 1 0

 

下表是两个模型的混淆矩阵:

 

 

模型1 真实值为1 真实值为0
预测为1 3(TP) 2(FP)
预测为0 1(FN) 1(TN)

 

 

模型2 真实值为1 真实值为0
预测为1 2(TP) 0(FP)
预测为0 2(FN) 3(TN)

 

读者可能对于TP、TN、FP和FN这些缩写搞不清楚,但是其实并不复杂。第一个T表示预测和真实值是否一样,如果是则为T(true)否则为F(false);第二个字母N(Negative)表示模型预测为不点击而P(Positive)表示预测为点击。因此TP指的是真实为1(点击)并且预测也是1(点击),TN指的是真实为0(不点击)而预测也为0(不点击),FP表示预测错误,预测为点击(P)因此推理出真实为不点击,而FN也是预测错误,把点击预测为不点击。

 

所以有两种预测正确的情况:TP和TN,分别是正确预测点击和不点击的样例。而预测错误也有两种情况:FN和FP,分别是把点击的预测为不点击、把不点击的预测为点击。下面是一些常见的细化指标,FPR和TPR,分别表示预测为Positive(点击)的错误率和正确率,具体计算公式为:

 

显然FPR(错误)我们希望越小越好,而TPR越大越好。

 

如果要”记住”这个公式很简单:FPR的分子肯定是FP,分母肯定也要有FP,然后分母再加上TN(字母和FP完全不重合);TPR也是类似。

 

另外FPR和TPR这两个指标是正相关的,比如为了提升FPR,我们可以把阈值调的很低,则模型把没有把握的样本尽量分类成1,因此分类成0的都非常有把握,因此FP会很少,从而FPR(可能)会减少。但是阈值低会让模型尽量把True识别成Positive,所以TPR也会增加。我们的目标是希望FPR尽量小而TPR尽量大,但是我们的目标很难只通过调整阈值达到,因为FPR小TPR也会小,FPR大TPR也会大。那幺怎幺达到目标呢?当然是优化模型的预测能力,而不是靠调阈值这种旁门左道。

 

当阈值为1时,所有的样本都预测成Negative,也就是FP=TP=0,因此FPR和TPR都为0。而当阈值为0时,所有的样本都预测成Positive,因此TN=PN=0,因此$FPR=\frac{FP}{FP+TN}=1$,类似的TPR也是1。

 

如果我们把阈值从1调整到0,FPR也会从,以FPR做横坐标、TPR做纵坐标,则可以画出一条ROC(receiver operating characteristic)曲线。翻译成中文就是受试者工作特征,这个名字听起来有点奇怪。ROC曲线通常是直线y=x之上的一个上凸函数,并且经过(0,0)和(1,1)点,如下图所示:

 

图:ROC曲线

 

AUC的英文全称是Area Under Curve,意思是曲线(Curve)下的面积,曲线指的就是ROC曲线。因此AUC更加准确的说法应该是AU-ROC(Area Under ROC Curve),也就是ROC曲线下的面积。显然ROC曲线越往上凸,则AUC越大,那幺在曲线的中间我们可以找到FPR尽可能小而TPR尽可能大的点(阈值)。

 

另外一个值得注意的点:AUC评价的是模型的排序能力而不是ctr预估的偏差。举个例子,假设有3个测试样本,真实分类是[1,1,0,1],而模型1预测的ctr分别是[0.9, 0.7, 0.65, 0.55],而模型2预测的ctr是[0.8, 0.7, 0.65, 0.55]。如果从logloss来说显然是第一个模型要好,因为后面3个结果一样但是第一个0.9跟真实的1更加接近。但是他们的AUC是一样的,我们可以简单的手动计算一下:

 

 

阈值 FPR TPR
1 0 0
0.899 0 0.33(TP=1,FN=2)
0.699 0 0.66(TP=2,FN=1)
0.649 1(FP=1,TN=0) 0.66(TP=2,FN=1)
0 1 1

 

 

阈值 FPR TPR
1 0 0
0.799 0 0.33(TP=1,FN=2)
0.699 0 0.66(TP=2,FN=1)
0.649 1(FP=1,TN=0) 0.66(TP=2,FN=1)
0 1 1

 

数据特点

 

CTR数据的特征一般可以分为三大类:用户的特征、广告的特征和上下文特征。用户的特征包括用户性别、年龄、职业、教育水平等等;创意的特征包括广告的行业(旅游、电商,……)、标签等等;上下文的特征包括广告的位置、用户使用的设备、浏览器、时间、地点等。这些特征有一部分是连续的值,比如年龄;而更多的特征是Category(类别)的特征,比如性别为两类的男女,职业可以是销售、产品经理、……。对于Category的特征,我们通常使用one-hot的编码把它变成一个高纬的稀疏向量,比如职业细分的话可能几十上百种,地域按照城市分可能成千上万。另外为了提高模型的泛化能力我们可能把连续的特征进行离散化,比如把年龄分为18岁以下、18-29、30-40,……。这样的每一个输入特征向量可能是非常高维的一个稀疏的向量。

 

FM(Factorization Machines)

 

在FM模型之前,CTR预估最常用的模型是SVM和LR(包括用GBDT给它们提取非线性特征)。SVM和LR都是线性的模型(SVM理论上可以引入核函数从而引入非线性,但是由于非常高维的稀疏特征向量使得它很容易过拟合,因此实际很少用),但是实际的这些特征是有相关性的。比如根据论文 DeepFM: A Factorization-Machine based Neural Network for CTR Prediction 的调研,在主流的app应用市场里,在吃饭的时间点食谱类app的下载量明显会比平时更大,因此app的类别和时间会存在二阶的关系。另外一个例子是年轻男性更喜欢射击类游戏,这是年龄、性别和游戏类别的三阶关系。因此怎幺建模二阶、三阶甚至更高阶的非线性的关系是预估能够更加准确的关键点。

 

下文参考了 深入FFM原理与实践

 

我们可以直接建模二阶关系:

 

其中,n是特征向量的维度,$x_i$是第i维特征,$w_0$、$w_i$、$w_{ij}$是模型参数。这种方法的问题是二阶关系的参数个数是$\frac{n(n-1)}{2}$,这样会导致模型的参数很多,另外因为特征向量非常稀疏,二次项参数的训练是很困难的。其原因是,每个参数 $w_{ij}$的训练需要大量$x_i$和$x_j$都非零的样本;由于样本数据本来就比较稀疏,满足“$x_i$和$x_j$都非零”的样本将会非常少。训练样本的不足,很容易导致参数 $w_{ij}$ 不准确,最终将严重影响模型的性能。

 

那幺,如何解决二次项参数的训练问题呢?矩阵分解提供了一种解决思路。根据 《Factorization Machines的一个talk》 在model-based的协同过滤中,一个rating矩阵可以分解为user矩阵和item矩阵,每个user和item都可以采用一个隐向量表示。比如在下图中的例子中,我们把每个user表示成一个二维向量,同时把每个item表示成一个二维向量,两个向量的点积就是矩阵中user对item的打分。

 

 

类似地,所有二次项参数 $w_{ij}$ 可以组成一个对称阵$\mathbf{W}$(为了方便说明FM的由来,对角元素可以设置为正实数),那幺这个矩阵就可以分解为 $\mathbf{W} = \mathbf{V}^T \mathbf{V}$,$\mathbf{V}$ 的第j列便是第j维特征的隐向量。换句话说,每个参数 $w_{ij} = \langle \mathbf{v}_i, \mathbf{v}_j \rangle$,这就是FM模型的核心思想。因此,FM的模型方程为(本文不讨论FM的高阶形式)

 

其中,$v_i$ 是第 i 维特征的隐向量,$\langle\cdot, \cdot\rangle$ 代表向量点积。隐向量的长度为 $k(k«n)$,包含 k 个描述特征的因子。根据公式(2),二次项的参数数量减少为 $kn$个,远少于多项式模型的参数数量。另外,参数因子化使得 $x_hx_i$ 的参数和 $x_ix_j$ 的参数不再是相互独立的,因此我们可以在样本稀疏的情况下相对合理地估计FM的二次项参数。具体来说,$x_hx_i$ 和 $x_ix_j$ 的系数分别为


,它们之间有共同项 $v_i$。也就是说,所有包含“$x_i$ 的非零组合特征”(存在某个 $j\neq i$,使得 $x_i x_j \neq 0$)的样本都可以用来学习隐向量

,这很大程度上避免了数据稀疏性造成的影响。而在多项式模型中,$w_{hi}$ 和 $w_{ij}$ 是相互独立的。

 

直观上看,FM的复杂度是 $O(kn^2)$。但是,通过下式,FM的二次项可以化简,其复杂度可以优化到 $O(kn)$。由此可见,FM可以在线性时间对新样本作出预测。

 

下面是上式的推导过程,不感兴趣的读者可以跳过。

 

训练的时候可以使用随机梯度下降算法,梯度计算公式为:

 

其中,$v_{j,f}$ 是隐向量

 

的第 $f$ 个元素。由于 $\sum_{j=1}^n v_{j, f} x_j$ 只与 f 有关,而与 i 无关,在每次迭代过程中,只需计算一次所有 f 的 $\sum_{j=1}^n v_{j, f} x_j$,就能够方便地得到所有 $v_{i,f}$ 的梯度。显然,计算所有 f 的$\sum_{j=1}^n v_{j, f} x_j$的复杂度是 $O(kn)$;已知 $\sum_{j=1}^n v_{j, f} x_j$ 时,计算每个参数梯度的复杂度是 $O(1)$;得到梯度后,更新每个参数的复杂度是 $O(1)$;模型参数一共有 $nk+n+1$ 个。因此,FM参数训练的复杂度也是 $O(kn)$。

 

DeepFM

 

概述

 

像LR/SVM这样的模型无法建模非线性的特征交互(interaction),我们只能人工组特征的交叉或者组合,比如通过数据分析发现时间和食谱类的app存在比较强的关联关系,然后把这个特征加到线性模型里去。这种方法的确定是需要人工做特征工程,成本很高,而且很多复杂的特征交互很难发现,就像啤酒和纸尿布这样的关系,我们只能做事后诸葛亮这样的分析:啊哈,确实很有道理,但是不做大量的数据分析想提前发现就比较困难。

 

FM通过给每个特征引入一个低维稠密的隐向量来减少二阶特征交互的参数个数,同时实现信息共享,使得在非常稀疏的特征向量上的学习更加容易泛化。理论上FM可以建模二阶以上的特征交互,但是由于计算量急剧增加,实际一般只用二阶的模型。

 

而(深度)神经网络的优势就是可以多层的网络结构更好的进行特征表示,从而学习高阶的非线性关系。DeepFM的思想就是结合FM在一阶和二阶特征的简洁高效和深度学习在高阶特征交互上的优势,同时通过共享FM和DNN的Embedding来减少参数量和共享信息,从而得到更好的模型。

 

方法简介

 

假设训练数据的个数为n,每一个训练样本为$(\mathcal{X},y)$,其中$(\mathcal{X}$由m个field组成,而$y \in {0,1}$表示用户是否点击。$(\mathcal{X}$的field可以是category的field,比如性别和地域等;也可以是连续的field,比如年龄。总的field个数可能只有几百个,但是如果用one-hot编码展开的话,这个特征向量会非常高维并且稀疏。我们把每一个$x_i$记为$x_i=[x_{field_1},x_{field_2},…,x_{field_m}]$,其中每一个$x_{field_i}$是其向量表示(连续的field就是1维的,而Category的field则用one-hot展开)。

 

DeepFM包括FM和DNN两个部分,最终的预测由两部分输出的相加得到:

 

其中$y_{FM}$是FM模型的输出,而$y_{DNN}$是DNN的输出。模型的结构如下图所示,它包含FM和DNN两个组件。

 

图:

 

下面我们分别来看这两个组件。

 

图:FM组件

 

上图是FM组件,它实现的就是:

 

这个公式非常简单,但是如果用代码实现的话有一些技巧,比如怎幺不用for循环(在Tensorflow这样的框架里循环是很麻烦而且很低效的)。后面的代码分析会介绍怎幺用Embedding和矩阵运算不用for循环实现上面的计算。

 

图:DNN组件

 

上图就是DNN组件,也非常简单,不过我们需要记得它的Embedding要和前面的FM共享,后面的代码部分也会详细介绍怎幺实现。对于深度学习不了解的读者可以参考 《深度学习理论与实战:基础篇》

 

代码实战

 

数据集

 

在介绍代码之前我们先准备一下实验的数据,这里使用了学术界常用的Criteo数据集,这是一个展示广告的数据集,有Criteo提供,曾经是 Kaggle的一个比赛 。Kaggle提供的链接已经失效,读者可以在 这里 下载。

 

下载的dac.tar.gz大概4.3G,解压后包含train.txt、test.txt和readme.txt三个文件。其中test.txt没有真实标签,因此不能用于真实的测试。train.txt大概有4500万行,我把这个文件用 sample.py 随机抽取90%作为训练集ctr-train.txt,剩下的10%作为验证集ctr-dev.txt。这些文件的每一行是一个训练数据,格式为:

 

$ head -n 1 ctr-train.txt 
0	1	1	5	0	1382	4	15	2	181	1	2		2	68fd1e64	80e26c9b	fb936136	7b4723c4	25c83c98	7e0ccccf	de7995b8	1f89b562	a73ee510	a8cd5504	b2cb9c98	37c9c164	2824a5f6	1adce6ef	8ba8b39a	891b62e7	e5ba7672	f54016b9	21ddcdc9	b1252a9d	07b5194c		3a171ecb	c5c50484	e8b83407	9727dd16

 

其中第1列是真实的标签,0表示没有点击而1表示点击。第2-14列是13个连续的特征,第15-40列是26个Category的特征。

 

DeepCTR 简介

 

我们后面的实验和代码分析使用开源的 DeepCTR ,这里首先介绍DeepCTR的基本用法。

 

根据其文档的说明,DeepCTR是一个易于使用、模块化和可扩展的深度学习CTR模型库,它内置了很多核心的组件从而便于我们自定义模型,它兼容tensorflow 1.4+和2.0+。

 

除了DeepFM,DeepCTR还包括更多更新的模型,本篇文章只介绍DeepFM,下面是完整的模型列表:

 

 

Model Paper
Convolutional Click Prediction Model [CIKM 2015] A Convolutional Click Prediction Model
Factorization-supported Neural Network [ECIR 2016] Deep Learning over Multi-field Categorical Data: A Case Study on User Response Prediction
Product-based Neural Network [ICDM 2016] Product-based neural networks for user response prediction
Wide & Deep [DLRS 2016] Wide & Deep Learning for Recommender Systems
DeepFM [IJCAI 2017] DeepFM: A Factorization-Machine based Neural Network for CTR Prediction
Piece-wise Linear Model [arxiv 2017] Learning Piece-wise Linear Models from Large Scale Data for Ad Click Prediction
Deep & Cross Network [ADKDD 2017] Deep & Cross Network for Ad Click Predictions
Attentional Factorization Machine [IJCAI 2017] Attentional Factorization Machines: Learning the Weight of Feature Interactions via Attention Networks
Neural Factorization Machine [SIGIR 2017] Neural Factorization Machines for Sparse Predictive Analytics
xDeepFM [KDD 2018] xDeepFM: Combining Explicit and Implicit Feature Interactions for Recommender Systems
AutoInt [arxiv 2018] AutoInt: Automatic Feature Interaction Learning via Self-Attentive Neural Networks
Deep Interest Network [KDD 2018] Deep Interest Network for Click-Through Rate Prediction
Deep Interest Evolution Network [AAAI 2019] Deep Interest Evolution Network for Click-Through Rate Prediction
NFFM [arxiv 2019] Operation-aware Neural Networks for User Response Prediction
FGCNN [WWW 2019] Feature Generation by Convolutional Neural Network for Click-Through Rate Prediction
Deep Session Interest Network [IJCAI 2019] Deep Session Interest Network for Click-Through Rate Prediction
FiBiNET [RecSys 2019] FiBiNET: Combining Feature Importance and Bilinear feature Interaction for Click-Through Rate Prediction

 

DeepCTR快速上手

 

安装

 

最简单的方法是使用pip安装,CPU的版本使用如下的命令安装:

 

pip install deepctr[cpu]

 

GPU版本的安装方法为:

 

pip install deepctr[gpu]

 

四步实现训练

 

第一步:导入模型

 

import pandas as pd
from sklearn.preprocessing import LabelEncoder, MinMaxScaler
from sklearn.model_selection import train_test_split
from deepctr.models import DeepFM
from deepctr.inputs import  SparseFeat, DenseFeat,get_feature_names
data = pd.read_csv('./criteo_sample.txt')
sparse_features = ['C' + str(i) for i in range(1, 27)]
dense_features = ['I'+str(i) for i in range(1, 14)]
data[sparse_features] = data[sparse_features].fillna('-1', )
data[dense_features] = data[dense_features].fillna(0,)
target = ['label']

 

这一步import依赖的模块,第一行使用pandas读取csv文件criteo_sample.txt,这个文件头几行为:

 

$ head -n 3 /home/lili/data/criteo_sample.txt
label,I1,I2,I3,I4,I5,I6,I7,I8,I9,I10,I11,I12,I13,C1,C2,C3,C4,C5,C6,C7,C8,C9,C10,C11,C12,C13,C14,C15,C16,C17,C18,C19,C20,C21,C22,C23,C24,C25,C26
0,,3,260.0,,17668.0,,,33.0,,,,0.0,,05db9164,08d6d899,9143c832,f56b7dd5,25c83c98,7e0ccccf,df5c2d18,0b153874,a73ee510,8f48ce11,a7b606c4,ae1bb660,eae197fd,b28479f6,bfef54b3,bad5ee18,e5ba7672,87c6f83c,,,0429f84b,,3a171ecb,c0d61a5c,,
0,,-1,19.0,35.0,30251.0,247.0,1.0,35.0,160.0,,1.0,,35.0,68fd1e64,04e09220,95e13fd4,a1e6a194,25c83c98,fe6b92e5,f819e175,062b5529,a73ee510,ab9456b4,6153cf57,8882c6cd,769a1844,b28479f6,69f825dd,23056e4f,d4bb7bd8,6fc84bfb,,,5155d8a3,,be7c41b4,ded4aac9,,

 

这是一个带表头的csv文件,通过pandas读取成列(features),其中C1-C26是Category的特征(sparse_features),而I1-I13是连续的特征(dense_features)。因为有缺失的值,我们把Category的缺失值设置为”-1”,而把连续的缺失值设置为0。

 

第二步:简单预处理

 

对于Category的特征,我们通常有两种方法把它变成一个数字:Label编码和hash编码。

 

Label编码就是把每一个Category变成0 ~ len(#unique) – 1之间的一个整数:

 

for feat in sparse_features:
    lbe = LabelEncoder()
    data[feat] = lbe.fit_transform(data[feat])

 

使用sklearn的LabelEncoder可以实现把Label编码成数字。LabelEncoder不能处理训练数据中没有出现过的Label,如果测试数据包含训练数据中没有的Label,则会出现问题。一种解决办法是引入特殊的”unknown”,但是由于没有训练数据,它的Embedding也是没有意义的。解决办法和NLP的词典类似——统计训练数据的词频,把低频的词都转换成”unknown”,这样一方面可以减小词典的大小,另一方面也可以提高模型的泛化能力(因为如果一个词/Label只出现很少次数,则它学到的特征通常是很不稳定的)。后面我们自己实现LabelEncoder时会看到这个技巧。

 

fit_transform等价于先调用fit()函数,然后调用transform()函数。fit()函数的作用是使用训练数据来统计一些信息,比如统计Label所有的词典,而transform()对数据进行预处理。

 

而hash编码是把每一个Category的字符串通过hash函数映射为0~一个固定的范围。它的优点是不用提前知道Category的所有可能值。

 

for feat in sparse_features:
    lbe = HashEncoder()
    data[feat] = lbe.transform(data[feat])

 

使用hash编码不需要fit(这个学习/统计过程),所以直接使用transform函数,另外在后面的训练过程中,需要第3步的SparseFeat或者VarlenSparseFeat的构造函数里设置use_hash=True。

 

对于连续的特征,我们通常需要把它的范围归一化到[0-1]这间,这样不同特征的权重才比较好学习。我们可以使用sklearn的MinMaxScaler来做这个预处理:

 

mms = MinMaxScaler(feature_range=(0,1))
data[dense_features] = mms.fit_transform(data[dense_features])

 

参数feature_range=(0,1)告诉MinMaxScaler我们期望归一化的范围是[0-1]。

 

第三步:生成feature column

 

对于稀疏的特征,我们可以使用Embedding把它变成低维稠密的向量,对于离散的特征,我们可以直接输入神经网络。

 

如果使用LabelEncoder来编码Category特征,则代码为:

 

sparse_feature_columns = [SparseFeat(feat, vocabulary_size=data[feat].nunique(),embedding_dim=4)
                           for i,feat in enumerate(sparse_features)]
dense_feature_columns = [DenseFeat(feat, 1)
                      for feat in dense_features]

 

SparseFeat就是用来定义Embedding,需要传入的参数除了名字之外,最重要的是vocabulary_size和embedding_dim,vocabulary_size是词典的大小(不同label的个数),而embedding_dim是Embedding后的维度。

 

连续的特征使用DenseFeat就可以了,我们这里的连续特征都是一维的。

 

如果使用HashEncoder,则代码为:

 

sparse_feature_columns = [SparseFeat(feat, vocabulary_size=1e6,embedding_dim=4,use_hash=True)
                           for i,feat in enumerate(sparse_features)]#The dimension can be set according to data
dense_feature_columns = [DenseFeat(feat, 1)
                      for feat in dense_features]

 

使用HashEncoder也是SparseFeat,只不过需要传入use_hash=True。另外vocabulary_size通常要大一些,以避免hash冲突太多(当然是不可能完全避免的,两个词映射为一个id后对于模型来说就不可分了,它的Embedding可能是两个词的平均)。

 

对于多值(multi-valued)稀疏特征,比如兴趣标签可能是多选,一种方法是变成二值的:是否喜欢汽车、是否喜欢军事、……。那还有一种就是多值的稀疏特征:兴趣=[6,8],6和8可能代表汽车和军事。对于多值的特征,比如上面的例子,我们可以把6和8都Embedding成一个长度为4的向量,那幺怎幺处理呢?除了RNN这样的模型可以处理变长输入,普通的DNN和CNN都无法处理。一种简单的方法就是把它们Pooling起来,比如加起来求平均或者取最大的。

 

varlen_feature_columns = [VarLenSparseFeat('genres', maxlen=max_len,vocabulary_size= len(
            key2index) + 1,embedding_dim=4, combiner='mean',weight_name=None)]

 

VarLenSparseFeat就可以定义变长的SparseFeat,除了vocabulary_size和embedding_dim,需要指定最大的长度maxlen和combiner,这个例子的combiner是求平均。我们的Criteo数据集没有这种多值特征,因此不做深入介绍,感兴趣的读者可以参考 示例Multi-value Input : Movielens

 

接下来就是生成feature column:

 

dnn_feature_columns = sparse_feature_columns + dense_feature_columns
linear_feature_columns = sparse_feature_columns + dense_feature_columns
feature_names = get_feature_names(linear_feature_columns + dnn_feature_columns)

 

这里的sparse特征和dense特征都会输入DNN和线性层(二阶的FM组件会复用DNN的Embedding,后面代码分析会介绍)。

 

第四步:训练模型

 

train, test = train_test_split(data, test_size=0.2)
train_model_input = {name:train[name].values for name in feature_names}
test_model_input = {name:test[name].values for name in feature_names}

model = DeepFM(linear_feature_columns,dnn_feature_columns,task='binary')
model.compile("adam", "binary_crossentropy",
              metrics=['binary_crossentropy'], )
history = model.fit(train_model_input, train[target].values,
                    batch_size=256, epochs=10, verbose=2, validation_split=0.2, )
pred_ans = model.predict(test_model_input, batch_size=256)

 

我们首先用sklearn的train_test_split把数据切分成训练集和测试集。然后定义train_model_input,它是一个dict,key是feature的名字,比如”C1”、”I1”,value是这一列的值。

 

然后用DeepFM类定义一个deepfm模型,需要传入的参数是linear_feature_columns和dnn_feature_columns。还有一个参数task=’binary’表明这是一个二分类的问题。

 

接下来是Keras的标准流程:compile、fit和predict。

 

完整的代码在 这里 ,运行前需要下载 样例数据

 

DeepCTR代码分析

 

DeepFM函数

 

DeepFM函数构建一个Keras的Model对象,完整代码为:

 

def DeepFM(linear_feature_columns, dnn_feature_columns, fm_group=[DEFAULT_GROUP_NAME], dnn_hidden_units=(128, 128),
           l2_reg_linear=0.00001, l2_reg_embedding=0.00001, l2_reg_dnn=0, init_std=0.0001, seed=1024, dnn_dropout=0,
           dnn_activation='relu', dnn_use_bn=False, task='binary'):
    构造DeepFM网络结构
    : linear_feature_columns: 线性部分使用的所有特征
    :参数 dnn_feature_columns: DNN使用的所有特征
    :参数 fm_group: list, 特征的分组,我们这里默认所有的特征都在一个组里
    :参数 dnn_hidden_units: list, DNN的隐单元个数,默认是(128,128)表示3层的网络,隐单元个数都是128
    :参数 l2_reg_linear: float. 线性模型的L2正则化参数 
    :参数 l2_reg_embedding: float. Embedding向量的L2正则化参数 
    :参数 l2_reg_dnn: float. DNN的L2正则化参数
    :参数 init_std: float, Embedding向量的随机初始化标准差 
    :参数 seed: integer ,随机数种子
    :参数 dnn_dropout: float,在[0,1)范围内,表示DNN的dropout的概率
    :参数 dnn_activation: DNN的激活函数
    :参数 dnn_use_bn: bool. DNN是否使用BatchNorm 
    :参数 task: str,  ``"binary"``表示二分类的logloss,``"regression"``表示回归的loss
    :返回: 一个Keras Model示例
 
    features = build_input_features(
        linear_feature_columns + dnn_feature_columns)
    inputs_list = list(features.values())
    group_embedding_dict, dense_value_list = input_from_feature_columns(features, dnn_feature_columns, l2_reg_embedding,
                                                                        init_std, seed, support_group=True)
    linear_logit = get_linear_logit(features, linear_feature_columns, init_std=init_std, seed=seed, prefix='linear',
                                    l2_reg=l2_reg_linear)
    fm_logit = add_func([FM()(concat_func(v, axis=1))
                         for k, v in group_embedding_dict.items() if k in fm_group])
    dnn_input = combined_dnn_input(list(chain.from_iterable(
        group_embedding_dict.values())), dense_value_list)
    dnn_output = DNN(dnn_hidden_units, dnn_activation, l2_reg_dnn, dnn_dropout,
                     dnn_use_bn, seed)(dnn_input)
    dnn_logit = tf.keras.layers.Dense(
        1, use_bias=False, activation=None)(dnn_output)
    final_logit = add_func([linear_logit, fm_logit, dnn_logit])
    output = PredictionLayer(task)(final_logit)
    model = tf.keras.models.Model(inputs=inputs_list, outputs=output)
    return model

 

下面我们来分析其中重要的函数。

 

build_input_features

 

build_input_features函数的输入feature_columns是特征的list,每一个元素是SparseFeat或者DenseFeat。如下图所示

 

图:build_input_features的输入

 

def build_input_features(feature_columns, prefix=''):
    input_features = OrderedDict()
    for fc in feature_columns:
        if isinstance(fc, SparseFeat):
            input_features[fc.name] = Input(
                shape=(1,), name=prefix + fc.name, dtype=fc.dtype)
        elif isinstance(fc, DenseFeat):
            input_features[fc.name] = Input(
                shape=(fc.dimension,), name=prefix + fc.name, dtype=fc.dtype)
        elif isinstance(fc, VarLenSparseFeat):
            input_features[fc.name] = Input(shape=(fc.maxlen,), name=prefix + fc.name,
                                            dtype=fc.dtype)
            if fc.weight_name is not None:
                input_features[fc.weight_name] = Input(shape=(fc.maxlen, 1), name=prefix + fc.weight_name,
                                                     dtype="float32")
            if fc.length_name is not None:
                input_features[fc.length_name] = Input((1,),name=prefix+fc.length_name,dtype='int32')
        else:
            raise TypeError("Invalid feature column type,got", type(fc))
    return input_features

 

返回值是一个OrderedDict,key是feature的名字比如”C1”,value是Keras的Input。对于Category的feature,Input的shape是1,对于Dense的feature,Input是其维度,这里都是1,这些都是Keras模型的输入。因此对于criteo.sample,输入是39个field。这和我们平时些Keras可能有一些不同,平时我通常把输入的所有field都拼接成一个大的向量。但是这里实现的是一个通用的CTR框架,它需要灵活的处理所有的数据集而不只是criteo,因此它把每一个field都作为一个单独的输入。下图是这个函数的输出。

 

图:build_input_features的输出

 

input_from_feature_columns

 

def input_from_feature_columns(features, feature_columns, l2_reg, init_std, seed, prefix='', seq_mask_zero=True,
                               support_dense=True, support_group=False):
    sparse_feature_columns = list(
        filter(lambda x: isinstance(x, SparseFeat), feature_columns)) if feature_columns else []
    varlen_sparse_feature_columns = list(
        filter(lambda x: isinstance(x, VarLenSparseFeat), feature_columns)) if feature_columns else []
    embedding_matrix_dict = create_embedding_matrix(feature_columns, l2_reg, init_std, seed, prefix=prefix,
                                                    seq_mask_zero=seq_mask_zero)
    group_sparse_embedding_dict = embedding_lookup(embedding_matrix_dict, features, sparse_feature_columns)
    dense_value_list = get_dense_input(features, feature_columns)
    if not support_dense and len(dense_value_list) > 0:
        raise ValueError("DenseFeat is not supported in dnn_feature_columns")
    sequence_embed_dict = varlen_embedding_lookup(embedding_matrix_dict, features, varlen_sparse_feature_columns)
    group_varlen_sparse_embedding_dict = get_varlen_pooling_list(sequence_embed_dict, features,
                                                                 varlen_sparse_feature_columns)
    group_embedding_dict = mergeDict(group_sparse_embedding_dict, group_varlen_sparse_embedding_dict)
    if not support_group:
        group_embedding_dict = list(chain.from_iterable(group_embedding_dict.values()))
    return group_embedding_dict, dense_value_list

 

前两行从输入的feature_columns里滤出SparseFeat和VarLenSparseFeat放到sparse_feature_columns和varlen_sparse_feature_columns。我们这里的varlen_sparse_feature_columns为空;而sparse_feature_columns是C1-C26。

 

第三行用create_embedding_matrix函数构建每个field的Embedding矩阵。

 

def create_embedding_matrix(feature_columns, l2_reg, init_std, seed, prefix="", seq_mask_zero=True):
    sparse_feature_columns = list(
        filter(lambda x: isinstance(x, SparseFeat) , feature_columns)) if feature_columns else []
    varlen_sparse_feature_columns = list(
        filter(lambda x: isinstance(x, VarLenSparseFeat), feature_columns)) if feature_columns else []
    sparse_emb_dict = create_embedding_dict(sparse_feature_columns, varlen_sparse_feature_columns, init_std, seed,
                                            l2_reg, prefix=prefix + 'sparse', seq_mask_zero=seq_mask_zero)
    return sparse_emb_dict

 

这个函数实际是调用create_embedding_dict来创建每个field的Embedding矩阵:

 

def create_embedding_dict(sparse_feature_columns, varlen_sparse_feature_columns, init_std, seed, l2_reg,
                          prefix='sparse_', seq_mask_zero=True):
    sparse_embedding = {feat.embedding_name:Embedding(feat.vocabulary_size, feat.embedding_dim,
                                                          embeddings_initializer=RandomNormal(
                                                              mean=0.0, stddev=init_std, seed=seed),
                                                          embeddings_regularizer=l2(l2_reg),
                                                          name=prefix + '_emb_' + feat.embedding_name) for feat in sparse_feature_columns}

    if varlen_sparse_feature_columns and len(varlen_sparse_feature_columns) > 0:
        for feat in varlen_sparse_feature_columns:
            # if feat.name not in sparse_embedding:
            sparse_embedding[feat.embedding_name] = Embedding(feat.vocabulary_size, feat.embedding_dim,
                                                              embeddings_initializer=RandomNormal(
                                                                  mean=0.0, stddev=init_std, seed=seed),
                                                              embeddings_regularizer=l2(
                                                                  l2_reg),
                                                              name=prefix + '_seq_emb_' + feat.name,
                                                              mask_zero=seq_mask_zero)
    return sparse_embedding

 

我们这里varlen_sparse_feature_columns是空,所以其实只执行了第一行,也就是遍历sparse_feature_columns中的C1-C26,然后为每一个field构建Embedding(feat.vocabulary_size, feat.embedding_dim…)。每一个Embedding的输入大小是C1-C26的词典大小,但是它们Embedding后都是相同的大小(这里是4)。

 

图:create_embedding_dict的返回值

 

接着我们返回函数input_from_feature_columns,下一行是调用embedding_lookup函数,把输入的C1-C26对于的26个1维的Input Embedding成26个shape为4的向量:

 

def embedding_lookup(sparse_embedding_dict, sparse_input_dict, sparse_feature_columns, return_feat_list=(),
                     mask_feat_list=(), to_list=False):
    group_embedding_dict = defaultdict(list)
    for fc in sparse_feature_columns:
        feature_name = fc.name
        embedding_name = fc.embedding_name
        if (len(return_feat_list) == 0 or feature_name in return_feat_list):
            if fc.use_hash:
                lookup_idx = Hash(fc.vocabulary_size, mask_zero=(feature_name in mask_feat_list))(
                    sparse_input_dict[feature_name])
            else:
                lookup_idx = sparse_input_dict[feature_name]
            group_embedding_dict[fc.group_name].append(sparse_embedding_dict[embedding_name](lookup_idx))
    if to_list:
        return list(chain.from_iterable(group_embedding_dict.values()))
    return group_embedding_dict

 

这个函数遍历sparse_feature_columns(C1-C26),根据feature_name找到对应的Input(lookup_idx = sparse_input_dict[feature_name]),然后用sparse_embedding_dictembedding_name把输入进行Embedding。

 

代码阅读起来可能会有一些难懂,原因是为了通用性,只能通过dict的名字来关联Input和Embedding。如果不考虑通用性,那幺代码可能会非常简单:

 

input1 = Input((1,))
embedding1 = Embedding(feat1.vocab_size, 4)
output1 = embedding1(input1)
input2 = Input((1,))
embedding2 = Embedding(feat2.vocab_size, 4)
output2 = embedding1(input2)
....
input26 = .....

 

但是为了通用性,我们需要把所有的Input都放到sparse_input_dict里,把所有的Embedding都放到sparse_embedding_dict,把所有的output都放到group_embedding_dict,然后通过feature的name来关联。

 

group_embedding_dict会把同一个group里的特征放到一起,我们这里没有分组,因此默认的C1-C26都是在default_group里。

 

接着回到input_from_feature_columns执行下一行get_dense_input,这个函数把所有的DenseFeat拼起来:

 

def get_dense_input(features, feature_columns):
    dense_feature_columns = list(filter(lambda x: isinstance(x, DenseFeat), feature_columns)) if feature_columns else []
    dense_input_list = []
    for fc in dense_feature_columns:
        dense_input_list.append(features[fc.name])
    return dense_input_list

 

这个函数很简单,就是把所有的Dense feature的输入向量放到一个list里,如下图所示:

 

图:get_dense_input的返回值

 

接下来返回input_from_feature_columns会处理varlen_sparse_feature_columns,我们这里没有多值的特征,因此就不详细介绍了,它和普通的sparse_feature_columns的区别在于多了一个Pooling的步骤。

 

最后input_from_feature_columns函数返回的是group_embedding_dict和dense_value_list。分别表示Category Embedding后的list和Dense field的输入。

 

最后我们返回的DeepFM函数,进入get_linear_logit函数。

 

get_linear_logit函数

 

这个函数的作用其实就是线性部分,也就是$\langle W,x \rangle$,这本来很简单,但是我们需要把输入的C1-C26用one-hot进行编码,然后拼接起来,再把I1-I13也拼接起来,长度为vocab_1+…+vocab_26+13的输入向量,然后再乘以W。不过这里使用了Embedding实现了类似的效果,用Embedding Matrix实现了同样的效果,我猜测这是这是为了复用其他的代码。但是Embedding后的大小没有必要是4,其实是1就够了。

 

比如举个例子,假设Category为C1和C2,词典大小分别是3和2。假设当前的输入C1=2,C2=2(下标1开始),则one-hot后的向量为<0,1,0>拼接<0,1>,则 $\langle W,x \rangle=w_2 \times 1 + w_5 \times 1$。现在我们用Embedding,假设Embedding Matrix为:

 

e11 e12 e13 e14
e21 e22 e23 e24
e31 e32 e33 e34
e41 e42 e43 e44
e51 e52 e53 e54

 

则<0,1,0>会Embedding成向量[e21 e22 e23 e24],<0,1>会变成[e51 e52 e53 e54],最终得到的是(e21+e22+e23+e24) + (e51+e52+e53+e54),因此e21+e22+e23+e24等价于原来的$w_2$,e51+e52+e53+e54等价于$w_5$。

 

那幺其实我们把每一个field Embedding成长度为4的向量是没有必要的,只需要Embedding成长度为1的就行了。

 

接下来是FM部分。

 

FM Layer

 

类FM基础了Keras的Layer,它的完整代码为:

 

class FM(Layer):
    def __init__(self, **kwargs):
        super(FM, self).__init__(**kwargs)
    def build(self, input_shape):
        if len(input_shape) != 3:
            raise ValueError("Unexpected inputs dimensions % d,\
                             expect to be 3 dimensions" % (len(input_shape)))
        super(FM, self).build(input_shape)  # Be sure to call this somewhere!
    def call(self, inputs, **kwargs):
        if K.ndim(inputs) != 3:
            raise ValueError(
                "Unexpected inputs dimensions %d, expect to be 3 dimensions"
                % (K.ndim(inputs)))
        concated_embeds_value = inputs
        square_of_sum = tf.square(reduce_sum(
            concated_embeds_value, axis=1, keep_dims=True))
        sum_of_square = reduce_sum(
            concated_embeds_value * concated_embeds_value, axis=1, keep_dims=True)
        cross_term = square_of_sum - sum_of_square
        cross_term = 0.5 * reduce_sum(cross_term, axis=2, keep_dims=False)
        return cross_term
    def compute_output_shape(self, input_shape):
        return (None, 1)

 

它的核心逻辑在call函数里实现。前面我们介绍了FM二阶特征交互的计算公式为:

 

难点是怎幺不要for循环实现上面的计算。为了获得一些感觉,我们先看一个简单的例子。假设只有前面的C1和C2两个Category的特征,词典大小还是3和2。假设输入还是C1=2,C2=2(下标从1开始),则Embedding之后为$V_2=[e_{21}, e_{22}, e_{23}, e_{24}]$和$V_5=[e_{51}, e_{52}, e_{53}, e_{54}]$。

 

因为$x_i$和$x_j$同时不为零才需要计算,所以

 

里需要计算的只有i=2和j=5的情况。因此

 

如果是C1,C2和C3呢?则需要把c1的Embedding向量$V_{c_1}$、$V_{c_2}$和$V_{c_3}$的两两内积:

 

但是这怎幺用矩阵乘法一次计算出来呢?我们可以看看这个:

 

因此我们可以把Embedding后的向量加起来,然后平方然后减去每一个向量的平方(内积),再除以2。对应到代码就是:

 

square_of_sum = tf.square(reduce_sum(
            concated_embeds_value, axis=1, keep_dims=True))
        sum_of_square = reduce_sum(
            concated_embeds_value * concated_embeds_value, axis=1, keep_dims=True)
        cross_term = square_of_sum - sum_of_square
        cross_term = 0.5 * reduce_sum(cross_term, axis=2, keep_dims=False)

 

第一行是计算 $(V_{c_1}+…+V_{c_m})^2$。第二行是计算$V_{c_1}^2+…+V_{c_m}^2$。第三行是相减除以2。

 

接下来是DNN,这个就不介绍了,但是我们需要注意FM的输入和DNN的输入都是同一个group_embedding_dict:

 

fm_logit = add_func([FM()(concat_func(v, axis=1))
                         for k, v in group_embedding_dict.items() if k in fm_group])
    dnn_input = combined_dnn_input(list(chain.from_iterable(
        group_embedding_dict.values())), dense_value_list)
    dnn_output = DNN(dnn_hidden_units, dnn_activation, l2_reg_dnn, dnn_dropout,
                     dnn_use_bn, seed)(dnn_input)

 

这样就实现了FM和DNN的Embedding的共享。注意:线性组件我们虽然也使用了Embedding来替代$\langle W,x \rangle$,但是这个Embedding并不是真正意义的Embedding,只是为了复用代码避免再次one-hot自己展开而已,所有不能共享。

 

最后是把linear_logit、fm_logit和dnn_logit加起来输入最后的sigmoid函数,然后用输入和输出构建Keras的Model:

 

final_logit = add_func([linear_logit, fm_logit, dnn_logit])
    output = PredictionLayer(task)(final_logit)
    model = tf.keras.models.Model(inputs=inputs_list, outputs=output)

 

PredictionLayer也是一个自定义的Layer,其实非常简单:

 

class PredictionLayer(Layer):

    def __init__(self, task='binary', use_bias=True, **kwargs):
        if task not in ["binary", "multiclass", "regression"]:
            raise ValueError("task must be binary,multiclass or regression")
        self.task = task
        self.use_bias = use_bias
        super(PredictionLayer, self).__init__(**kwargs)
    def build(self, input_shape):
        if self.use_bias:
            self.global_bias = self.add_weight(
                shape=(1,), initializer=Zeros(), name="global_bias")
        # Be sure to call this somewhere!
        super(PredictionLayer, self).build(input_shape)
    def call(self, inputs, **kwargs):
        x = inputs
        if self.use_bias:
            x = tf.nn.bias_add(x, self.global_bias, data_format='NHWC')
        if self.task == "binary":
            x = tf.sigmoid(x)
        output = tf.reshape(x, (-1, 1))
        return output
    def compute_output_shape(self, input_shape):
        return (None, 1)
    def get_config(self, ):
        config = {'task': self.task, 'use_bias': self.use_bias}
        base_config = super(PredictionLayer, self).get_config()
        return dict(list(base_config.items()) + list(config.items()))

 

只需要看一下call函数就行,如果是binary就使用sigmoid激活,否则(回归)直接输出。

 

大规模训练数据的支持

 

上面的代码使用起来非常简单,但是它使用Pandas来读取csv数据,对于总共4500万行的全量的训练数据,如果直接用Pandas读取到内存的话,可能需要几十GB的内存,而且实际的系统的训练数据量可能远远大于千万级别。

 

最简单的办法当然是把大的数据集拆分成很多小的数据,然后逐个训练。比如把4500万行拆分成10个450万行的小文件,然后逐个训练。但是这样做的缺点是每次需要手动拆分,每个小文件只能训练一个Epoch,然后再训练另外一个文件,对于超参数的条件、Learning rate的scheduler都会带来很大的问题。因此作者自己实现了一个Sequence,同时由于sklearn的LabelEncoder和MinMaxScaler只能读取内存中的数据,所以作者也实现了自己版本的CtrLabelEncoder和CtrMinMaxScaler,代码在 preprocessor.py

 

运行代码

 

首先需要预处理,用CtrLabelEncoder和CtrMinMaxScaler从训练数据进行fit:

 

python preprocessor.py /home/lili/data/ctr-train.txt /home/lili/data/train-label-encoder.pkl /home/lili/data/train-minmax-scaler.pkl

 

输出两个pickle的文件,分别把CtrLabelEncoder和CtrMinMaxScaler对象用pickle保持下来。后面的testctr2.py会用到这两个文件。

 

接着就是训练了:

 

python testctr2.py

 

由于时间关系,代码里都是绝对路径,读者要运行的话需要修改相应的文件路径。

 

实现原理和代码简介

 

对于大数据,Keras提供了fit_generator,我们先来看一下它的 文档

 

fit_generator(generator, steps_per_epoch=None, epochs=1, verbose=1, callbacks=None, validation_data=None, validation_steps=None, validation_freq=1, class_weight=None, max_queue_size=10, workers=1, use_multiprocessing=False, shuffle=True, initial_epoch=0)

 

[TODO]

 

实验结果

 

作者使用了默认的超参数,在4100万的训练数据上训练了10个epoch,在450万左右的验证集上的auc大概是0.795,logloss大概是0.46,比论文0.805的auc和0.45的logloss稍差一下,可能还需要调一调超参数多训练一些时间。

 

图:get_dense_input的返回值

 

10个epoch大概花了16个小时。这和 PaddlePaddle示例 的结果是大致一致的。不过我发现训练DeepCTR时GPU的利用率很低,一般在30%下。这可能是因为特征都是Embedding后的稀疏矩阵导致的,PaddlePaddle用CPU训练一个epoch的时间也是1.8个小时,说明GPU的效率没有发挥出来。

 

Be First to Comment

发表评论

邮箱地址不会被公开。 必填项已用*标注