基於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.gztar zxf dac.tar.gzrm -f dac.tar.gzmkdir rawmv ./*.txt raw/

數據有點大, 大概4.26G,慢慢等吧,數據下載完成之後,解壓出train.csv,test.csv,其中訓練集45840617條樣本數,測試集45840617條樣本,數據量還是蠻大的。 數據主要有三部分組成:

  • label: 廣告是否被點擊;
  • 連續性特徵: 1-13,為各維度下的統計信息,連續性特徵;
  • 離散型特徵:一些被脫敏處理的類目特徵

Overview

整個項目主要由幾個部分組成:

數據處理

這裡數據處理主要包括兩個部分:

  1. 連續值特徵值處理:
  • 濾除統計次數95%以上的數據,這樣可以濾除大部分異值數據,這裡的處理方式和以前我在1號店做相關工作時一致,代碼裡面已經做了這部分工作,直接給出了這部分的特徵閾值;
  • 歸一化處理,這裡andnew ng的課程有張圖很明顯,表明不同的特徵的值域範圍,會使得模型尋優走『之』字形,這樣會增加收斂的計算和時間;
  1. 離散特徵值處理:
  • 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("
").split(" ") 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("
").split(" ") 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("
").split(" ") 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來計算一階和二階隱變數關係。

模型訓練

數據量太大,單機上跑是沒有問題,可以正常運行成功,在我內部機器上,可以運行成功,但是有兩個問題:

  1. fm由於處理的特徵為稀疏表示,而paddlepaddle在這塊的FM層的支持只有在cpu上,速度很慢,分析原因其實不是fm的速度的問題,因為deepfm有設計多個fc,應該是這裡的速度影響, 在paddlepaddle github上有提一個issue,得知暫時paddlepaddle不能把部分放到gpu上面跑,給了一個解決方案把所有的sparse改成dense,發現在這裡gpu顯存hold不住;
  2. 我的機器太渣,因為有開發任務不能長期佔用;

所以綜上,我打算研究下在百度雲上怎麼通過k8s來布置paddlepaddle的分散式集群。

文檔cloud.baidu.com/doc/CCE

研究來研究去,第一步加卡主了,不知道怎麼回事,那個頁面就是出不來...出師未捷身先死,提了個issue: github.com/PaddlePaddle,等後面解決了再來更新分散式訓練的部分。

單機的訓練沒有什麼大的問題,由上面所說,因為fm的sparse不支持gpu,所以很慢,拉的百度雲上16核的機器,大概36s/100 batch,總共樣本4000多w,一個epoch預計4個小時,MMP,等吧,分散式的必要性就在這裡。

另外有在paddlepaddle裡面提一個issue:

github.com/PaddlePaddle,說把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("
".join(map(str, predictions)) + "
")

總結

照例總結一下,DeemFM是17年深度學習在點擊率預估、推薦這塊的新的方法,有點類似於deep and wide的思想,將傳統的fm來nn化,利用神經網路強大的建模能力來挖掘數據中的有效信息,paddlepaddle在這塊有現成的deepfm模型,單機部署起來比較容易,分散式,這裡我按照百度雲上的教程還未成功,後續會持續關注。另外,因為最近在做大規模機器學習框架相關的工作,越發覺得別說成熟的,僅僅能夠work的框架就很不錯了,而比較好用的如現在的TensorFlowMXNet,開發起來真的難上加難,以前光是做調包俠時沒有體驗,現在深入到這塊的工作時,才知道其中的難度,也從另一個角度開始審視現在的各種大規模機器學習框架,比如TensorFlow、MXNet,在深度學習的支持上,確實很棒,但是也有瓶頸,對於大規模海量的feature,尤其是sparse op的支持上,至少現在還未看到特別好的支持,就比如這裡的FM,可能大家都會吐槽為啥這麼慢,沒做框架之前,我也會吐槽,但是開始接觸了一些的時候,才知道FM,主要focus在sparse相關的數據對象,而這部分數據很難在gpu上完成比較高性能的計算,所以前面經過paddle的開發者解釋sparse相關的計算不支持gpu的時候,才感同身受,一個好的大規模機器學習框架必須要從不同目標來評價,如果需求是大規律數據,那穩定性、可擴展性是重點,如果是更多演算法、模型的支持,可能現在的TensorFlow、MXNet才是標杆,多麼希望現在大規模機器學習框架能夠多元化的發展,有深度學習支持力度大的,也有傳統演算法上,把數據量、訓練規模、並行化加速並做到極致的,這樣的發展才或許稱得上百花齊放,其實我們不需要太多不同長相的TensorFlow、MXNet鎚子,有時候我們就需要把鐮刀而已,希望大規模機器學習框架的發展,不應該僅僅像TensorFlow、MXNet一樣,希望有一個專註把做大規模、大數據量、極致並行化加速作為roadmap的新標杆,加油。


推薦閱讀:

寫給大家看的機器學習書(第四篇)—— 機器學習為什麼是可行的(上)
AI、神經網路、機器學習、深度學習和大數據的核心知識備忘錄分享
分散式機器學習里的 數據並行 和 模型並行 各是什麼意思?
有沒有國內寫的比較好的、深入淺出的、並且貼近實戰的機器學習與深度學習書籍或博客?
有基於spark的parameter server嗎?

TAG:TensorFlow | 深度学习DeepLearning | 大规模机器学习 |