DeepFM: A Factorization-Machine based Neural Network for CTR Prediction簡介與程式碼實現
論文簡介
Abstract
對於一個基於CTR預估的推薦系統,最重要的是學習到使用者點選行為背後隱含的特徵組合。在不同的推薦場景中,低階組合特徵或者高階組合特徵可能都會對最終的CTR產生影響。但是現存的方法總是忽視了高階或低階組合特徵的聯絡,或者要求專門的特徵工程,因此作者建立了DeepFM模型,將FM與DNN結合起來。
Introduction
DeepFM的預測結果可以寫為:
FM部分
FM部分的詳細結構如下:
已知我們FM公式如下:
在目前很多的DNN模型中,都是藉助了FM這種形式來做的embedding,具體推導如下:參考自該部落格
藉助原文的圖,這裡k表示隱向量的維數, 表示第i個特徵embeding之後在隱向量的第j維。假設已經給出了V矩陣,
其中第5-15個特徵是同一個field經過one-hot編碼後的表示,這是隱向量按列排成矩陣,同時,它也可看作embedding層的引數矩陣,按照神經網路前向傳播的方式,embedding後的該slot下的向量值應該表示為:
可以看到這個結果就是一個5維的向量,而這個普通的神經網路傳遞時怎麼和FM聯絡到一起的,仔細觀察這個式子可以發現,由於是離散化或者one-hot之後的X,所以對於每一個field的特徵而言,截斷向量X都只有一個值為1,其他都為0。那麼假設上述slot的V中,j為7的特徵值為1,那麼矩陣相乘之後的結果為:
從結果中可以看到,實質上,每個slot在embedding後,其結果都是one-hot後有值的那一維特徵所對應的隱向量。看到這裡,在來解釋模型中FM部分是如何藉助這種方式得到的。回到模型的示意圖,可以看到在FM層,是對每兩個embedding向量做內積,那麼我們來看,假設兩個slot分別是第7和第20個特徵值為1:
是不是感覺特別熟悉,沒錯,這個乘積的結果就是FM中二階特徵組合的其中一例,而對於所有非0組合(embedding向量組合)求和之後,就是FM中所有二階特徵的部分,這就是模型中FM部分的由來。
當然,FM中的一階特徵,則直接在embedding之前對於特徵進行組合即可。
DNN部分
兩個主要的特點:
- 每個field的embedding保持相同的size
- FM中的隱向量v被用作embedding 權重壓縮資料
FM部分和深度部分分享一樣的特徵嵌入層能夠帶來兩個好處:
- 可以學到低階和高階特徵聯絡
- 不需要對輸入進行專門的特徵工程
程式碼實現
import tensorflow as tf
class Config(object):
"""
用來儲存一些配置資訊
"""
def __init__(self):
self.feature_dict = None
self.feature_size = None
self.field_size = None
self.embedding_size = 8
self.epochs = 100
self.deep_layers_activation = tf.nn.relu
self.loss = "logloss"
self.l2_reg = 0.1
self.learning_rate = 0.1
self.deep_layers=[32,32]
train_file = "./data/train.csv"
test_file = "./data/test.csv"
IGNORE_FEATURES = [
'id', 'target'
]
CATEGORITAL_FEATURES = [
'feat_cat_1', 'feat_cat_2'
]
NUMERIC_FEATURES = [
'feat_num_1', 'feat_num_2'
]
import pandas as pd
import DeepFM1.config as config
import gc
def FeatureDictionary(dfTrain=None, dfTest=None, numeric_cols=None, ignore_cols=None):
"""
目的是給每一個特徵維度都進行編號。
1. 對於離散特徵,one-hot之後每一列都是一個新的特徵維度。所以,原來的一維度對應的是很多維度,編號也是不同的。
2. 對於連續特徵,原來的一維特徵依舊是一維特徵。
返回一個feat_dict,用於根據 原特徵名稱和特徵取值 快速查詢出 對應的特徵編號。
:param dfTrain: 原始訓練集
:param dfTest: 原始測試集
:param numeric_cols: 所有數值型特徵
:param ignore_cols: 所有忽略的特徵. 除了數值型和忽略的,剩下的全部認為是離散型
:return: feat_dict, feat_size
1. feat_size: one-hot之後總的特徵維度。
2. feat_dict是一個{}, key是特徵string的col_name, value可能是編號(int),可能也是一個字典。
如果原特徵是連續特徵: value就是int,表示對應的特徵編號;
如果原特徵是離散特徵:value就是dict,裡面是根據離散特徵的 實際取值 查詢 該維度的特徵編號。 因為離散特徵one-hot之後,一個取值就是一個維度,
而一個維度就對應一個編號。
"""
assert not (dfTrain is None), "train dataset is not set"
assert not (dfTest is None), "test dataset is not set"
# 編號肯定是要train test一起編號的
df = pd.concat([dfTrain, dfTest], axis=0)
# 返回值
feat_dict = {}
# 目前為止的下一個編號
total_cnt = 0
for col in df.columns:
if col in ignore_cols: # 忽略的特徵不參與編號
continue
# 連續特徵只有一個編號
if col in numeric_cols:
feat_dict[col] = total_cnt
total_cnt += 1
else:
# 離散特徵,有多少個取值就有多少個編號
unique_vals = df[col].unique()
unique_cnt = df[col].nunique()
feat_dict[col] = dict(zip(unique_vals, range(total_cnt, total_cnt + unique_cnt)))
total_cnt += unique_cnt
feat_size = total_cnt
return feat_dict, feat_size
def parse(feat_dict=None, df=None, has_label=False):
"""
構造FeatureDict,用於後面Embedding
:param feat_dict: FeatureDictionary生成的。用於根據col和value查詢出特徵編號的字典
:param df: 資料輸入。可以是train也可以是test,不用拼接
:param has_label: 資料中是否包含label
:return: Xi, Xv, y
"""
assert not (df is None), "df is not set"
assert not (feat_dict is None), "feat_dict is not set"
dfi = df.copy()
if has_label:
y = df['target'].values.tolist()
dfi.drop(['id','target'],axis=1, inplace=True)
else:
ids = dfi['id'].values.tolist() # 預測樣本的ids
dfi.drop(['id'],axis=1, inplace=True)
# dfi是Feature index,大小和dfTrain相同,但是裡面的值都是特徵對應的編號。
# dfv是Feature value, 可以是binary(0或1), 也可以是實值float,比如3.14
dfv = dfi.copy()
for col in dfi.columns:
if col in config.IGNORE_FEATURES: # 用到的全域性變數: IGNORE_FEATURES, NUMERIC_FEATURES
dfi.drop([col], axis=1, inplace=True)
dfv.drop([col], axis=1, inplace=True)
continue
if col in config.NUMERIC_FEATURES: # 連續特徵1個維度,對應1個編號,這個編號是一個定值
dfi[col] = feat_dict[col]
else:
# 離散特徵。不同取值對應不同的特徵維度,編號也是不同的。
dfi[col] = dfi[col].map(feat_dict[col])
dfv[col] = 1.0
# 取出裡面的值
Xi = dfi.values.tolist()
Xv = dfv.values.tolist()
del dfi, dfv
gc.collect()
if has_label:
return Xi, Xv, y
else:
return Xi, Xv, ids
import numpy as np
import pandas as pd
import tensorflow as tf
from DeepFM1.DataReader import FeatureDictionary
from DeepFM1.DataReader import parse
import DeepFM1.config as con
##################################
# 1. 配置資訊
##################################
config = con.Config()
##################################
# 2. 讀取檔案
##################################
dfTrain = pd.read_csv(con.train_file)
dfTest = pd.read_csv(con.test_file)
##################################
# 3. 準備資料
##################################
# FeatureDict
config.feature_dict, config.feature_size = FeatureDictionary(dfTrain=dfTrain, dfTest=dfTest, numeric_cols=con.NUMERIC_FEATURES, ignore_cols=con.IGNORE_FEATURES)
print(config.feature_dict)
print(config.feature_size)
# Xi, Xv
Xi_train, Xv_train, y = parse(feat_dict=config.feature_dict, df=dfTrain, has_label=True)
Xi_test, Xv_test, ids = parse(feat_dict=config.feature_dict, df=dfTest, has_label=False)
config.field_size = len(Xi_train[0])
print(Xi_train)
print(Xv_train)
print(config.field_size)
##################################
# 4. 建立模型
##################################
# 模型引數
# BUILD THE WHOLE MODEL
tf.set_random_seed(2018)
# init_weight
weights = dict()
# Sparse Features 到 Dense Embedding的全連線權重。[其實是Embedding]
weights['feature_embedding'] = tf.Variable(initial_value=tf.random_normal(shape=[config.feature_size, config.embedding_size],mean=0,stddev=0.1),
name='feature_embedding',
dtype=tf.float32)
# Sparse Featues 到 FM Layer中Addition Unit的全連線。 [其實是Embedding,嵌入後維度為1]
weights['feature_bias'] = tf.Variable(initial_value=tf.random_uniform(shape=[config.feature_size, 1],minval=0.0,maxval=1.0),
name='feature_bias',
dtype=tf.float32)
# Hidden Layer
num_layer = len(config.deep_layers)
input_size = config.field_size * config.embedding_size
glorot = np.sqrt(2.0 / (input_size + config.deep_layers[0])) # glorot_normal: stddev = sqrt(2/(fan_in + fan_out))
weights['layer_0'] = tf.Variable(initial_value=tf.random_normal(shape=[input_size, config.deep_layers[0]],mean=0,stddev=glorot),
dtype=tf.float32)
weights['bias_0'] = tf.Variable(initial_value=tf.random_normal(shape=[1, config.deep_layers[0]],mean=0,stddev=glorot),
dtype=tf.float32)
for i in range(1, num_layer):
glorot = np.sqrt(2.0 / (config.deep_layers[i - 1] + config.deep_layers[i]))
# deep_layer[i-1] * deep_layer[i]
weights['layer_%d' % i] = tf.Variable(initial_value=tf.random_normal(shape=[config.deep_layers[i - 1], config.deep_layers[i]],mean=0,stddev=glorot),
dtype=tf.float32)
# 1 * deep_layer[i]
weights['bias_%d' % i] = tf.Variable(initial_value=tf.random_normal(shape=[1, config.deep_layers[i]],mean=0,stddev=glorot),
dtype=tf.float32)
# Output Layer
deep_size = config.deep_layers[-1]
fm_size = config.field_size + config.embedding_size
input_size = fm_size + deep_size
glorot = np.sqrt(2.0 / (input_size + 1))
weights['concat_projection'] = tf.Variable(initial_value=tf.random_normal(shape=[input_size,1],mean=0,stddev=glorot),
dtype=tf.float32)
weights['concat_bias'] = tf.Variable(tf.constant(value=0.01), dtype=tf.float32)
# build_network
feat_index = tf.placeholder(dtype=tf.int32, shape=[None, config.field_size], name='feat_index') # [None, field_size]
feat_value = tf.placeholder(dtype=tf.float32, shape=[None, config.field_size], name='feat_value') # [None, field_size]
label = tf.placeholder(dtype=tf.float16, shape=[None,1], name='label')
# Sparse Features -> Dense Embedding
embeddings_origin = tf.nn.embedding_lookup(weights['feature_embedding'], ids=feat_index) # [None, field_size, embedding_size]
feat_value_reshape = tf.reshape(tensor=feat_value, shape=[-1, config.field_size, 1]) # -1 * field_size * 1
# --------- 一維特徵 -----------
y_first_order = tf.nn.embedding_lookup(weights['feature_bias'], ids=feat_index) # [None, field_size, 1]
w_mul_x = tf.multiply(y_first_order, feat_value_reshape) # [None, field_size, 1] Wi * Xi
y_first_order = tf.reduce_sum(input_tensor=w_mul_x, axis=2) # [None, field_size]
# --------- 二維組合特徵 ----------
embeddings = tf.multiply(embeddings_origin, feat_value_reshape) # [None, field_size, embedding_size] multiply不是矩陣相乘,而是矩陣對應位置相乘。這裡應用了broadcast機制。
# sum_square part 先sum,再square
summed_features_emb = tf.reduce_sum(input_tensor=embeddings, axis=1) # [None, embedding_size]
summed_features_emb_square = tf.square(summed_features_emb)
# square_sum part
squared_features_emb = tf.square(embeddings)
squared_features_emb_summed = tf.reduce_sum(input_tensor=squared_features_emb, axis=1) # [None, embedding_size]
# second order
y_second_order = 0.5 * tf.subtract(summed_features_emb_square, squared_features_emb_summed)
# ----------- Deep Component ------------
y_deep = tf.reshape(embeddings, shape=[-1, config.field_size * config.embedding_size]) # [None, field_size * embedding_size]
for i in range(0, len(config.deep_layers)):
y_deep = tf.add(tf.matmul(y_deep, weights['layer_%d' % i]), weights['bias_%d' % i])
y_deep = config.deep_layers_activation(y_deep)
# ----------- output -----------
concat_input = tf.concat([y_first_order, y_second_order, y_deep], axis=1)
out = tf.add(tf.matmul(concat_input, weights['concat_projection']), weights['concat_bias'])
out = tf.nn.sigmoid(out)
config.loss = "logloss"
config.l2_reg = 0.1
config.learning_rate = 0.1
# loss
if config.loss == "logloss":
loss = tf.losses.log_loss(label, out)
elif config.loss == "mse":
loss = tf.losses.mean_squared_error(label, out)
# l2
if config.l2_reg > 0:
loss += tf.contrib.layers.l2_regularizer(config.l2_reg)(weights['concat_projection'])
for i in range(len(config.deep_layers)):
loss += tf.contrib.layers.l2_regularizer(config.l2_reg)(weights['layer_%d' % i])
# optimizer
optimizer = tf.train.AdamOptimizer(learning_rate=config.learning_rate, beta1=0.9, beta2=0.999, epsilon=1e-8).minimize(loss)
##################################
# 5. 訓練
##################################
# init session
sess = tf.Session(graph=tf.get_default_graph())
sess.run(tf.global_variables_initializer())
# train
feed_dict = {
feat_index: Xi_train,
feat_value: Xv_train,
label: np.array(y).reshape((-1,1))
}
for epoch in range(config.epochs):
train_loss,opt = sess.run((loss, optimizer), feed_dict=feed_dict)
print("epoch: {0}, train loss: {1:.6f}".format(epoch, train_loss))
##################################
# 6. 預測
##################################
dummy_y = [1] * len(Xi_test)
feed_dict_test = {
feat_index: Xi_test,
feat_value: Xv_test,
label: np.array(dummy_y).reshape((-1,1))
}
prediction = sess.run(out, feed_dict=feed_dict_test)
sub = pd.DataFrame({"id":ids, "pred":np.squeeze(prediction)})
print("prediction:")
print(sub)
資料集
train.csv
id,target,feat_cat_1,feat_cat_2,feat_num_1,feat_num_2
1,0,1,2,3.1,2.2
2,0,2,3,2.1,3.1
3,1,0,2,1.0,3.4
4,1,1,1,2.1,1.6
5,0,0,0,0.5,1.8
test.csv
id,target,feat_cat_1,feat_cat_2,feat_num_1,feat_num_2
6,0,1,2,3.1,2.2
7,0,2,3,2.1,3.1
8,1,0,2,1.0,3.4
9,1,1,1,2.1,1.6
10,0,0,0,0.5,1.8
以上資料純屬手工捏造,感興趣的朋友可以使用
kaggle上的資料集,挺多大佬的deepfm程式碼實現使用該資料集。