基于PaddlePaddle的點擊率的深度學習方法嘗試
前言
前面在團隊內部分享點擊率相關的一些文章時,輸出了一篇常見計算廣告點擊率預估算法總結,看了一些廣告點擊率的文章,從最經典的Logistic Regression到Factorization Machined,FFM,FNN,PNN到今年的DeepFM,還有文章里面沒有講的gbdt+lr這類,一直想找時間實踐下,正好這次在學習paddle的時候在它的models目錄下看到了DeepFM的實現,因為之前對DeepFM有過比較詳細的描述,這里稍微復習一下:
DeepFM更有意思的地方是WDL和FM結合了,其實就是把PNN和WDL結合了,PNN即將FM用神經網絡的方式構造了一遍,作為wide的補充,原始的Wide and Deep,Wide的部分只是LR,構造線性關系,Deep部分建模更高階的關系,所以在Wide and Deep中還需要做一些特征的東西,如Cross Column的工作,而我們知道FM是可以建模二階關系達到Cross column的效果,DeepFM就是把FM和NN結合,無需再對特征做諸如Cross Column的工作了,這個是我感覺最吸引人的地方,其實FM的部分感覺就是PNN的一次描述,這里只描述下結構圖,PNN的部分前面都描述, FM部分:

Deep部分:

DeepFM相對于FNN、PNN,能夠利用其Deep部分建模更高階信息(二階以上),而相對于Wide and Deep能夠減少特征工程的部分工作,wide部分類似FM建模一、二階特征間關系, 算是NN和FM的一個更***的結合方向,另外不同的是如下圖,DeepFM的wide和deep部分共享embedding向量空間,wide和deep均可以更新embedding部分,雖說wide部分純是PNN的工作,但感覺還是蠻有意思的。

本文相關代碼部分都是來自于paddlepaddle/model, 我這里走一遍流程,學習下,另外想要了解算法原理的可以仔細再看看上面的文章,今天我們來paddlepaddle上做下實驗,來從代碼程度學習下DeepFM怎么實現的:
數據集說明
criteo Display Advertising Challenge,數據主要來criteolab一周的業務數據,用來預測用戶在訪問頁面時,是否會點擊某廣告。
wget --no-check-certificate https://s3-eu-west-1.amazonaws.com/criteo-labs/dac.tar.gz tar zxf dac.tar.gz rm -f dac.tar.gz mkdir raw mv ./*.txt raw/
數據有點大, 大概4.26G,慢慢等吧,數據下載完成之后,解壓出train.csv,test.csv,其中訓練集45840617條樣本數,測試集45840617條樣本,數據量還是蠻大的。 數據主要有三部分組成:
- label: 廣告是否被點擊;
- 連續性特征: 1-13,為各維度下的統計信息,連續性特征;
- 離散型特征:一些被脫敏處理的類目特征
Overview
整個項目主要由幾個部分組成:

數據處理
這里數據處理主要包括兩個部分:
- 連續值特征值處理:
- 濾除統計次數95%以上的數據,這樣可以濾除大部分異值數據,這里的處理方式和以前我在1號店做相關工作時一致,代碼里面已經做了這部分工作,直接給出了這部分的特征閾值;
- 歸一化處理,這里andnew ng的課程有張圖很明顯,表明不同的特征的值域范圍,會使得模型尋優走『之』字形,這樣會增加收斂的計算和時間;
- 離散特征值處理:
- one-hot: 對應特征值映射到指定維度的只有一個值為1的稀疏變量;
- embedding: 對應特征值映射到指定的特征維度上;
具體我們來研究下代碼:
class ContinuousFeatureGenerator:
"""
Normalize the integer features to [0, 1] by min-max normalization
"""
def __init__(self, num_feature):
self.num_feature = num_feature
self.min = [sys.maxint] * num_feature
self.max = [-sys.maxint] * num_feature
def build(self, datafile, continous_features):
with open(datafile, 'r') as f:
for line in f:
features = line.rstrip('\n').split('\t')
for i in range(0, self.num_feature):
val = features[continous_features[i]]
if val != '':
val = int(val)
if val > continous_clip[i]:
val = continous_clip[i]
self.min[i] = min(self.min[i], val)
self.max[i] = max(self.max[i], val)
def gen(self, idx, val):
if val == '':
return 0.0
val = float(val)
return (val - self.min[idx]) / (self.max[idx] - self.min[idx])
連續特征是在1-13的位置,讀取文件,如果值大于對應維度的特征值的95%閾值,則該特征值置為該閾值,并計算特征維度的***、最小值,在gen時歸一化處理。
class CategoryDictGenerator:
"""
Generate dictionary for each of the categorical features
"""
def __init__(self, num_feature):
self.dicts = []
self.num_feature = num_feature
for i in range(0, num_feature):
self.dicts.append(collections.defaultdict(int))
def build(self, datafile, categorial_features, cutoff=0):
with open(datafile, 'r') as f:
for line in f:
features = line.rstrip('\n').split('\t')
for i in range(0, self.num_feature):
if features[categorial_features[i]] != '':
self.dicts[i][features[categorial_features[i]]] += 1
for i in range(0, self.num_feature):
self.dicts[i] = filter(lambda x: x[1] >= cutoff,
self.dicts[i].items())
self.dicts[i] = sorted(self.dicts[i], key=lambda x: (-x[1], x[0]))
vocabs, _ = list(zip(*self.dicts[i]))
self.dicts[i] = dict(zip(vocabs, range(1, len(vocabs) + 1)))
self.dicts[i]['<unk>'] = 0
def gen(self, idx, key):
if key not in self.dicts[idx]:
res = self.dicts[idx]['<unk>']
else:
res = self.dicts[idx][key]
return res
def dicts_sizes(self):
return map(len, self.dicts)
類目特征的處理相對比較麻煩,需要遍歷,然后得到對應維度上所有出現值的所有情況,對打上對應id,為后續類目特征賦予id。這部分耗時好大,慢慢等吧,另外強烈希望paddlepaddle的小伙伴能在輸出處理期間打印下提示信息,算了,我之后有時間看看能不能提提pr。
經過上面的特征處理之后,訓練集的值變為:

reader
paddle里面reader的文件,自由度很高,自己可以寫生成器,然后使用batch的api,完成向網絡傳入batchsize大小的數據:
class Dataset:
def _reader_creator(self, path, is_infer):
def reader():
with open(path, 'r') as f:
for line in f:
features = line.rstrip('\n').split('\t')
dense_feature = map(float, features[0].split(','))
sparse_feature = map(int, features[1].split(','))
if not is_infer:
label = [float(features[2])]
yield [dense_feature, sparse_feature
] + sparse_feature + [label]
else:
yield [dense_feature, sparse_feature] + sparse_feature
return reader
def train(self, path):
return self._reader_creator(path, False)
def test(self, path):
return self._reader_creator(path, False)
def infer(self, path):
return self._reader_creator(path, True)
主要邏輯在兌入文件,然后yield對應的網絡數據的輸入格式
模型構造
模型構造,DeepFM在paddlepaddle里面比較簡單,因為有專門的fm層,這個據我所知在TensorFlow或MXNet里面沒有專門的fm層,但是值得注意的是,在paddlepaddle里面的fm層,只建模二階關系,需要再加入fc才是完整的fm,實現代碼如下:
def fm_layer(input, factor_size, fm_param_attr):
first_order = paddle.layer.fc(
input=input, size=1, act=paddle.activation.Linear())
second_order = paddle.layer.factorization_machine(
input=input,
factor_size=factor_size,
act=paddle.activation.Linear(),
param_attr=fm_param_attr)
out = paddle.layer.addto(
input=[first_order, second_order],
act=paddle.activation.Linear(),
bias_attr=False)
return out
然后就是構造DeepFM,這里根據下面的代碼畫出前面的圖,除去數據處理的部分,就是DeepFM的網絡結構:
def DeepFM(factor_size, infer=False):
dense_input = paddle.layer.data(
name="dense_input",
type=paddle.data_type.dense_vector(dense_feature_dim))
sparse_input = paddle.layer.data(
name="sparse_input",
type=paddle.data_type.sparse_binary_vector(sparse_feature_dim))
sparse_input_ids = [
paddle.layer.data(
name="C" + str(i),
type=s(sparse_feature_dim))
for i in range(1, 27)
]
dense_fm = fm_layer(
dense_input,
factor_size,
fm_param_attr=paddle.attr.Param(name="DenseFeatFactors"))
sparse_fm = fm_layer(
sparse_input,
factor_size,
fm_param_attr=paddle.attr.Param(name="SparseFeatFactors"))
def embedding_layer(input):
return paddle.layer.embedding(
input=input,
size=factor_size,
param_attr=paddle.attr.Param(name="SparseFeatFactors"))
sparse_embed_seq = map(embedding_layer, sparse_input_ids)
sparse_embed = paddle.layer.concat(sparse_embed_seq)
fc1 = paddle.layer.fc(
input=[sparse_embed, dense_input],
size=400,
act=paddle.activation.Relu())
fc2 = paddle.layer.fc(input=fc1, size=400, act=paddle.activation.Relu())
fc3 = paddle.layer.fc(input=fc2, size=400, act=paddle.activation.Relu())
predict = paddle.layer.fc(
input=[dense_fm, sparse_fm, fc3],
size=1,
act=paddle.activation.Sigmoid())
if not infer:
label = paddle.layer.data(
name="label", type=paddle.data_type.dense_vector(1))
cost = paddle.layer.multi_binary_label_cross_entropy_cost(
input=predict, label=label)
paddle.evaluator.classification_error(
name="classification_error", input=predict, label=label)
paddle.evaluator.auc(name="auc", input=predict, label=label)
return cost
else:
return predict
其中,主要包括三個部分,一個是多個fc組成的deep部分,第二個是sparse fm部分,然后是dense fm部分,如圖:

這里蠻簡單的,具體的api去查下文檔就可以了,這里稍微說明一下的是,sparse feature這塊有兩部分一塊是embedding的處理,這里是先生成對應的id,然后用id來做embedding,用作后面fc的輸出,然后sparse_input是onehot表示用來作為fm的輸出,fm來計算一階和二階隱變量關系。
模型訓練
數據量太大,單機上跑是沒有問題,可以正常運行成功,在我內部機器上,可以運行成功,但是有兩個問題:
- fm由于處理的特征為稀疏表示,而paddlepaddle在這塊的FM層的支持只有在cpu上,速度很慢,分析原因其實不是fm的速度的問題,因為deepfm有設計多個fc,應該是這里的速度影響, 在paddlepaddle github上有提一個issue,得知暫時paddlepaddle不能把部分放到gpu上面跑,給了一個解決方案把所有的sparse改成dense,發現在這里gpu顯存hold不住;
- 我的機器太渣,因為有開發任務不能長期占用;
所以綜上,我打算研究下在百度云上怎么通過k8s來布置paddlepaddle的分布式集群。
文檔https://cloud.baidu.com/doc/CCE/GettingStarted.html#.E9.85.8D.E7.BD.AEpaddlecloud
研究來研究去,***步加卡主了,不知道怎么回事,那個頁面就是出不來...出師未捷身先死,提了個issue: https://github.com/PaddlePaddle/cloud/issues/542,等后面解決了再來更新分布式訓練的部分。
單機的訓練沒有什么大的問題,由上面所說,因為fm的sparse不支持gpu,所以很慢,拉的百度云上16核的機器,大概36s/100 batch,總共樣本4000多w,一個epoch預計4個小時,MMP,等吧,分布式的必要性就在這里。
另外有在paddlepaddle里面提一個issue:
https://github.com/PaddlePaddle/Paddle/issues/7010,說把sparse轉成dense的話可以直接在gpu上跑起來,這個看起來不值得去嘗試,sparse整個維度還是挺高的,期待對sparse op 有更好的解決方案,更期待在能夠把單層單層的放在gpu,多設備一起跑,這方面,TensorFlow和MXNet要好太多。
這里我遇到一個問題,我使用paddle的docker鏡像的時候,可以很穩定的占用16個cpu的大部分計算力,但是我在云主機上自己裝的時候,cpu占用率很低,可能是和我環境配置有點問題,這個問題不大,之后為了不污染環境主要用docker來做相關的開發工作,所以這里問題不大。
cpu占有率有比較明顯的跳動,這里從主觀上比TensorFlow穩定性要差一些,不排除是sparse op的影響,印象中,TensorFlow cpu的占用率很穩定。


到發這篇文章位置,跑到17300個batch,基本能達到auc為0.8左右,loss為0.208左右。
預測
預測代碼和前一篇將paddle里面的demo一樣,只需要,重新定義一下網絡,然后綁定好模型訓練得到的參數,然后傳入數據即可完成inference,paddle,有專門的Inference接口,只要傳入output_layer,和訓練學習到的parameters,就可以很容易的新建一個模型的前向inference網絡。
def infer():
args = parse_args()
paddle.init(use_gpu=False, trainer_count=1)
model = DeepFM(args.factor_size, infer=True)
parameters = paddle.parameters.Parameters.from_tar(
gzip.open(args.model_gz_path, 'r'))
inferer = paddle.inference.Inference(
output_layer=model, parameters=parameters)
dataset = reader.Dataset()
infer_reader = paddle.batch(dataset.infer(args.data_path), batch_size=1000)
with open(args.prediction_output_path, 'w') as out:
for id, batch in enumerate(infer_reader()):
res = inferer.infer(input=batch)
predictions = [x for x in itertools.chain.from_iterable(res)]
out.write('\n'.join(map(str, predictions)) + '\n')
總結
照例總結一下,DeemFM是17年深度學習在點擊率預估、推薦這塊的新的方法,有點類似于deep and wide的思想,將傳統的fm來nn化,利用神經網絡強大的建模能力來挖掘數據中的有效信息,paddlepaddle在這塊有現成的deepfm模型,單機部署起來比較容易,分布式,這里我按照百度云上的教程還未成功,后續會持續關注。另外,因為最近在做大規模機器學習框架相關的工作,越發覺得別說成熟的,僅僅能夠work的框架就很不錯了,而比較好用的如現在的TensorFlow\MXNet,開發起來真的難上加難,以前光是做調包俠時沒有體驗,現在深入到這塊的工作時,才知道其中的難度,也從另一個角度開始審視現在的各種大規模機器學習框架,比如TensorFlow、MXNet,在深度學習的支持上,確實很棒,但是也有瓶頸,對于大規模海量的feature,尤其是sparse op的支持上,至少現在還未看到特別好的支持,就比如這里的FM,可能大家都會吐槽為啥這么慢,沒做框架之前,我也會吐槽,但是開始接觸了一些的時候,才知道FM,主要focus在sparse相關的數據對象,而這部分數據很難在gpu上完成比較高性能的計算,所以前面經過paddle的開發者解釋sparse相關的計算不支持gpu的時候,才感同身受,一個好的大規模機器學習框架必須要從不同目標來評價,如果需求是大規律數據,那穩定性、可擴展性是重點,如果是更多算法、模型的支持,可能現在的TensorFlow、MXNet才是標桿,多么希望現在大規模機器學習框架能夠多元化的發展,有深度學習支持力度大的,也有傳統算法上,把數據量、訓練規模、并行化加速并做到***的,這樣的發展才或許稱得上百花齊放,其實我們不需要太多不同長相的TensorFlow、MXNet錘子,有時候我們就需要把鐮刀而已,希望大規模機器學習框架的發展,不應該僅僅像TensorFlow、MXNet一樣,希望有一個專注把做大規模、大數據量、***并行化加速作為roadmap的新標桿,加油。

























