簡介
本文要介紹的是由哈爾濱工業(yè)大學(xué)聯(lián)合華為發(fā)表論文《DeepFM: A Factorization-Machine based Neural Network for CTR Prediction》中提出的DeepFM模型。其實(shí)根據(jù)名字可以看出來,此模型包含Deep和FM兩個(gè)部分。其中Deep部分就是普通的深度神經(jīng)網(wǎng)絡(luò)塘安,F(xiàn)M是因子分解機(jī)(Factorization Machine)挥等,用于來做特征交叉削咆。DeepFM實(shí)際上是將FM模型與Wide&Deep模型進(jìn)行了整合。DeepFM對(duì)Wide&Deep模型的改進(jìn)之處在于迅矛,它使用了FM來替換了原來的Wide部分棒坏,加強(qiáng)了淺層網(wǎng)絡(luò)部分特征組合的能力燕差。為了更好地理解DeepFM模型遭笋,讀者最好先了解一下FM模型和Wide&Deep模型坝冕。可以參考這兩篇博客:
介紹
在推薦系統(tǒng)中瓦呼,學(xué)習(xí)隱藏在用戶行為數(shù)據(jù)背后的復(fù)雜特征交互對(duì)最大化CTR任務(wù)起著重要的作用喂窟,而提高CTR的預(yù)估準(zhǔn)確率能夠直接為企業(yè)帶來豐厚的利益。很多在線廣告系統(tǒng)是根據(jù)來對(duì)候選廣告進(jìn)行排名的央串,其中
代表用戶每次點(diǎn)擊廣告時(shí)系統(tǒng)能獲得的收益磨澡。
學(xué)習(xí)用戶點(diǎn)擊行為背后的隱藏特征交互對(duì)CTR預(yù)估任務(wù)來說十分重要。作者通過對(duì)主流的app應(yīng)用市場調(diào)查研究發(fā)現(xiàn)质和,用戶常常在用餐時(shí)間下載外賣類app稳摄,這就是一種對(duì)”app類別“和“時(shí)間”這兩種特征的二階交互信息,這類的二階交互信息可以用于CTR預(yù)估任務(wù)饲宿。再比如厦酬,男性青少年偏愛射擊類和角色扮演游戲,這些信息是3階的瘫想。即包含了”性別“仗阅,”年齡“和”app類別“,這也有助于CTR預(yù)估任務(wù)国夜。這些用戶行為背后的特征交互是非常復(fù)雜的减噪,這些特征包括了低階和高階的交互信息。2016年谷歌提出的Wide & Deep模型對(duì)特征進(jìn)行了低階和高階交互车吹,整體上提高了模型性能筹裕。然而,Wide&Deep模型中的”Wide“部分仍然需要依賴手工特征提取窄驹。
DeepFM模型
為了能夠同時(shí)進(jìn)行低階和高階的特征融合朝卒,并且以端到端的方式訓(xùn)練,論文作者提出了DeepFM模型馒吴,模型結(jié)構(gòu)圖如下:DeepFM模型實(shí)際上延續(xù)了Wide&Deep雙模型組合的結(jié)構(gòu)扎运。它使用FM去替換了Wide&Deep中的Wide部分瑟曲,加強(qiáng)了淺層網(wǎng)絡(luò)部分特征組合的能力。上圖中豪治,左側(cè)的FM部分與右側(cè)的Deep神經(jīng)網(wǎng)絡(luò)部分共享同樣的Embedding層洞拨。左側(cè)的FM部分對(duì)不同的特征域的Embedding進(jìn)行了兩兩交叉,也就是將Embedding向量當(dāng)做原FM中的特征隱向量负拟。最后將FM的輸出與Deep部分的輸出一同輸入到最后的輸出層烦衣,參與最后的目標(biāo)擬合。
與Wide&Deep模型相比掩浙,DeepFM模型的改進(jìn)主要是針對(duì)Wide&Deep模型中Wide部分不具備特征自動(dòng)組合能力的缺陷而進(jìn)行的花吟。這里的改動(dòng)動(dòng)機(jī)與Deep&Cross模型完全一致,唯一的不同在于Deep&Cross模型利用多層Cross網(wǎng)絡(luò)進(jìn)行特征組合厨姚,而DeepFM使用了FM進(jìn)行特征組合衅澈。關(guān)于Deep&Cross模型,可以參考推薦系統(tǒng)之Deep&Cross模型原理以及代碼實(shí)踐谬墙。
假設(shè)訓(xùn)練數(shù)據(jù)集中包含個(gè)實(shí)例
今布,其中
是一個(gè)包含
個(gè)特征域的數(shù)據(jù)記錄,通常包含用戶和物品數(shù)據(jù)拭抬。
是對(duì)應(yīng)的標(biāo)簽數(shù)據(jù)部默,代表用戶的點(diǎn)擊行為(1代表用戶點(diǎn)擊,0代表沒點(diǎn)擊)造虎。
中包含了許多類別域傅蹂,比如性別,地點(diǎn)以及連續(xù)變量算凿,比如年齡等份蝴。每一個(gè)類別特征域都通過one-hot進(jìn)行編碼,每個(gè)連續(xù)特征域就代表它本身澎媒,或者可以通過離散化之后通過one-hot進(jìn)行表征搞乏。那么, 每個(gè)實(shí)例可以被轉(zhuǎn)換成
戒努,其中
是一個(gè)
維向量请敦,其中
是
中第
個(gè)特征域的向量化表示。一般來說储玫,
是一個(gè)高維的及其稀疏的向量侍筛。
DeepFM中的FM部分可以學(xué)習(xí)一階和二階特征交互。對(duì)于特征撒穷,標(biāo)量
可以用來衡量它的一階重要程度匣椰,隱向量
可以用來衡量它與其它特征之間的交互的影響。
被傳入FM模型中去捕捉2階特征交互端礼,同時(shí)被傳入到Deep部分中去捕獲更高階的特征交互禽笑。所有的參數(shù)包括
入录,
都是網(wǎng)絡(luò)的參數(shù),都通過以下聯(lián)合預(yù)測模型來共同訓(xùn)練:
下面依次從FM和Deep兩個(gè)部分來詳細(xì)描述DeepFM模型佳镜。
FM部分
FM部分的結(jié)構(gòu)圖如下:
FM模型主要的作用是能夠在數(shù)據(jù)極其稀疏的情況下高效地學(xué)習(xí)到組合特征僚稿,包括2階以及更高階數(shù)。而在以往的方式中蟀伸,特征
Deep部分
Deep部分就更簡單了,就是一個(gè)前向深度網(wǎng)絡(luò)迟蜜,它用來學(xué)習(xí)更高階的特征交互刹孔。Deep部分如下: 這里的DNN與圖像或者音頻領(lǐng)域使用的DNN不同,這些領(lǐng)域的輸出數(shù)據(jù)通常都是連續(xù)且稠密的小泉。而CTR預(yù)估任務(wù)中的數(shù)據(jù)通常是極度稀疏芦疏,超高維的冕杠,并且是類別數(shù)據(jù)和連續(xù)數(shù)據(jù)混合的微姊,因此需要從網(wǎng)絡(luò)結(jié)構(gòu)上進(jìn)行改變。因此在輸入數(shù)據(jù)和Hidden Layer中間有一個(gè)Dense EMbeddings層分预,它的主要作用就是將稀疏高維的數(shù)據(jù)壓縮成稠密的實(shí)數(shù)向量兢交。
- 盡管輸入向量長度可能不同,但是他們的Embedding大小都是相同的(比如為
)笼痹。
- FM中的隱向量
現(xiàn)在直接變成了DeepFM模型中的網(wǎng)絡(luò)權(quán)重參數(shù)配喳,它們被用來將輸入數(shù)據(jù)壓縮成embedding向量。
FFN模型使用的是一個(gè)預(yù)訓(xùn)練好的FM用來做網(wǎng)絡(luò)參數(shù)初始化凳干,而DeepFM是相當(dāng)于直接把FM集成到了模型內(nèi)部晴裹,跟著模型一起訓(xùn)練優(yōu)化。這樣就消除了FM的預(yù)訓(xùn)練過程救赐,整個(gè)網(wǎng)絡(luò)就可以使用端到端的方式來進(jìn)行訓(xùn)練了涧团。我們可以將Embedding層的輸出表示為:
特別值得注意的是苗沧,F(xiàn)M部分和Deep部分是共享同樣的特征embedding表示的刊棕,這樣做帶來了兩個(gè)好處:
- 它可以同時(shí)從原始特征中學(xué)習(xí)低階和高階的特征組合
- 無需對(duì)輸入進(jìn)行額外的特征工程,而在Wide&Deep中是需要的
DeepFM與其他神經(jīng)網(wǎng)絡(luò)對(duì)比
作者將DeepFM與FNN待逞、PNN鞠绰、Wide&Deep等進(jìn)行了對(duì)比,分別分析了各自的優(yōu)缺點(diǎn)飒焦,總結(jié)出了下表:可以看到DeepFM模型是唯一一個(gè)不需要預(yù)訓(xùn)練和特征工程蜈膨,且可以同時(shí)捕獲低階和高階特征交互的模型。
代碼實(shí)踐
模型部分:
import torch
import numpy as np
import torch.nn as nn
import torch.utils.data as Data
from torch.utils.data import DataLoader
import torch.optim as optim
import torch.nn.functional as F
from sklearn.metrics import log_loss, roc_auc_score
from collections import OrderedDict, namedtuple, defaultdict
from BaseModel.basemodel import BaseModel
class DeepFM(BaseModel):
def __init__(self, config, feat_sizes, sparse_feature_columns, dense_feature_columns):
super(DeepFM, self).__init__(config)
self.feat_sizes = feat_sizes
self.device = config['device']
self.sparse_feature_columns = sparse_feature_columns
self.dense_feature_columns = dense_feature_columns
self.embedding_size = config['ebedding_size']
self.l2_reg_linear = config['l2_reg_linear']
# self.feature_index 建立feature到列名到輸入數(shù)據(jù)X的相對(duì)位置的映射
self.feature_index = self.build_input_features(self.feat_sizes)
self.bias = nn.Parameter(torch.zeros((1,)))
# self.weight
self.weight = nn.Parameter(torch.Tensor(len(self.dense_feature_columns), 1)).to(self.device)
torch.nn.init.normal_(self.weight, mean=0, std=0.0001)
# FM的Linear部分牺荠,即對(duì)每一個(gè)特征乘上一個(gè)權(quán)重系數(shù)翁巍,由于有稠密和稀疏特征,因此這里分為了兩部分休雌。
self.embedding_dict1 = self.create_embedding_matrix(self.sparse_feature_columns, feat_sizes, 1, device=self.device)
self.embedding_dict2 = self.create_embedding_matrix(self.sparse_feature_columns, feat_sizes, self.embedding_size, device=self.device)
# Deep
self.dropout = nn.Dropout(self._config['dnn_dropout'])
self.dnn_input_size = self.embedding_size * len(self.sparse_feature_columns) + len(self.dense_feature_columns)
hidden_units = [self.dnn_input_size] + self._config['dnn_hidden_units']
self.linears = nn.ModuleList(
[nn.Linear(hidden_units[i], hidden_units[i + 1]) for i in range(len(hidden_units) - 1)])
self.relus = nn.ModuleList(
[nn.ReLU() for i in range(len(hidden_units) - 1)])
for name, tensor in self.linears.named_parameters():
if 'weight' in name:
nn.init.normal_(tensor, mean=0, std=config['init_std'])
self.dnn_linear = nn.Linear(
self._config['dnn_hidden_units'][-1], 1, bias=False).to(self.device)
self.to(self.device)
def forward(self, X):
'''
FM liner
'''
sparse_embedding_list1 = [self.embedding_dict1[feat](
X[:, self.feature_index[feat][0]:self.feature_index[feat][1]].long())
for feat in self.sparse_feature_columns]
dense_value_list2 = [X[:, self.feature_index[feat][0]:self.feature_index[feat][1]]
for feat in self.dense_feature_columns]
linear_sparse_logit = torch.sum(torch.cat(sparse_embedding_list1, dim=-1), dim=-1, keepdim=False)
linear_dense_logit = torch.cat(dense_value_list2, dim=-1).matmul(self.weight)
logit = linear_sparse_logit + linear_dense_logit
sparse_embedding_list = [self.embedding_dict2[feat](
X[:, self.feature_index[feat][0]:self.feature_index[feat][1]].long())
for feat in self.sparse_feature_columns]
'''
FM second
'''
fm_input = torch.cat(sparse_embedding_list, dim=1) # shape: (batch_size,field_size,embedding_size)
square_of_sum = torch.pow(torch.sum(fm_input, dim=1, keepdim=True), 2) # shape: (batch_size,1,embedding_size)
sum_of_square = torch.sum(torch.pow(fm_input, 2), dim=1, keepdim=True) # shape: (batch_size,1,embedding_size)
cross_term = square_of_sum - sum_of_square
cross_term = 0.5 * torch.sum(cross_term, dim=2, keepdim=False) # shape: (batch_size,1)
logit += cross_term
'''
DNN
'''
# sparse_embedding_list灶壶、 dense_value_list2
dnn_sparse_input = torch.cat(sparse_embedding_list, dim=1)
batch_size = dnn_sparse_input.shape[0]
# print(dnn_sparse_input.shape)
dnn_sparse_input=dnn_sparse_input.reshape(batch_size,-1)
# dnn_sparse_input shape: [ batch_size, len(sparse_feat)*embedding_size ]
dnn_dense_input = torch.cat(dense_value_list2, dim=-1)
# print(dnn_sparse_input.shape)
# dnn_dense_input shape: [ batch_size, len(dense_feat) ]
dnn_total_input = torch.cat([dnn_sparse_input, dnn_dense_input], dim=-1)
deep_input = dnn_total_input
for i in range(len(self.linears)):
fc = self.linears[i](deep_input)
fc = self.relus[i](fc)
fc = self.dropout(fc)
deep_input = fc
dnn_output = self.dnn_linear(deep_input)
logit += dnn_output
'''
output: sigmoid(DNN + FM)
'''
y_pred = torch.sigmoid(logit+self.bias)
return y_pred
def fit(self, train_input, y_label, val_input, y_val, batch_size=5000, epochs=15, verbose=5):
x = [train_input[feature] for feature in self.feature_index]
for i in range(len(x)):
if len(x[i].shape) == 1:
x[i] = np.expand_dims(x[i], axis=1) # 擴(kuò)展成2維,以便后續(xù)cat
# 構(gòu)造torch 數(shù)據(jù)加載器
train_tensor_data = Data.TensorDataset(torch.from_numpy(np.concatenate(x, axis=-1)), torch.from_numpy(y_label))
train_loader = DataLoader(dataset=train_tensor_data,shuffle=True ,batch_size=batch_size)
print(self.device, end="\n")
model = self.train()
loss_func = F.binary_cross_entropy
# loss_func = F.binary_cross_entropy_with_logits
optimizer = optim.Adam(model.parameters(), lr=0.001, weight_decay = 0.0)
# optimizer = optim.Adagrad(model.parameters(),lr=0.01)
# 顯示 一次epoch需要幾個(gè)step
sample_num = len(train_tensor_data)
steps_per_epoch = (sample_num - 1) // batch_size + 1
print("Train on {0} samples, {1} steps per epoch".format(
len(train_tensor_data), steps_per_epoch))
for epoch in range(epochs):
loss_epoch = 0
total_loss_epoch = 0.0
train_result = {}
pred_ans = []
true_ans = []
with torch.autograd.set_detect_anomaly(True):
for index, (x_train, y_train) in enumerate(train_loader):
x = x_train.to(self.device).float()
y = y_train.to(self.device).float()
y_pred = model(x).squeeze()
optimizer.zero_grad()
loss = loss_func(y_pred, y.squeeze(),reduction='mean')
#L2 norm
loss = loss + self.l2_reg_linear * self.get_L2_Norm()
loss.backward(retain_graph=True)
optimizer.step()
total_loss_epoch = total_loss_epoch + loss.item()
y_pred = y_pred.cpu().data.numpy() # .squeeze()
pred_ans.append(y_pred)
true_ans.append(y.squeeze().cpu().data.numpy())
if (epoch % verbose == 0):
print('epoch %d train loss is %.4f train AUC is %.4f' %
(epoch,total_loss_epoch / steps_per_epoch,roc_auc_score(np.concatenate(true_ans), np.concatenate(pred_ans))))
self.val_auc_logloss(val_input, y_val, batch_size=50000)
print(" ")
def predict(self, test_input, batch_size=256, use_double=False):
"""
:param x: The input data, as a Numpy array (or list of Numpy arrays if the model has multiple inputs).
:param batch_size: Integer. If unspecified, it will default to 256.
:return: Numpy array(s) of predictions.
"""
model = self.eval()
x = [test_input[feature] for feature in self.feature_index]
for i in range(len(x)):
if len(x[i].shape) == 1:
x[i] = np.expand_dims(x[i], axis=1) # 擴(kuò)展成2維杈曲,以便后續(xù)cat
tensor_data = Data.TensorDataset(torch.from_numpy(np.concatenate(x, axis=-1)))
test_loader = DataLoader(dataset=tensor_data, shuffle=False, batch_size=batch_size)
pred_ans = []
with torch.no_grad():
for index, x_test in enumerate(test_loader):
x = x_test[0].to(self.device).float()
# y = y_test.to(self.device).float()
y_pred = model(x).cpu().data.numpy() # .squeeze()
pred_ans.append(y_pred)
if use_double:
return np.concatenate(pred_ans).astype("float64")
else:
return np.concatenate(pred_ans)
def val_auc_logloss(self, val_input, y_val, batch_size=256, use_double=False):
pred_ans = self.predict(val_input, batch_size)
print("test LogLoss is %.4f test AUC is %.4f"%(log_loss(y_val, pred_ans), roc_auc_score(y_val, pred_ans)) )
def get_L2_Norm(self ):
loss = torch.zeros((1,), device=self.device)
loss = loss + torch.norm(self.weight)
for t in self.embedding_dict1.parameters():
loss = loss+ torch.norm(t)
for t in self.embedding_dict2.parameters():
loss = loss+ torch.norm(t)
return loss
def build_input_features(self, feat_sizes):
# Return OrderedDict: {feature_name:(start, start+dimension)}
features = OrderedDict()
start = 0
for feat in feat_sizes:
feat_name = feat
if feat_name in features:
continue
features[feat_name] = (start, start + 1)
start += 1
return features
def create_embedding_matrix(self ,sparse_feature_columns, feat_sizes, embedding_size,init_std=0.0001, device='cpu'):
embedding_dict = nn.ModuleDict(
{feat: nn.Embedding(feat_sizes[feat], embedding_size, sparse=False)
for feat in sparse_feature_columns}
)
for tensor in embedding_dict.values():
nn.init.normal_(tensor.weight, mean=0, std=init_std)
return embedding_dict.to(device)
使用了一個(gè)很小的criteo數(shù)據(jù)集進(jìn)行訓(xùn)練和測試驰凛,模型很快過擬合了,不過因?yàn)橹皇莇emo担扑,也沒有繼續(xù)調(diào)整恰响。
訓(xùn)練和測試結(jié)果如下:
完整代碼見:https://github.com/HeartbreakSurvivor/RsAlgorithms/tree/main/DeepFM。
參考
- 《深度學(xué)習(xí)推薦系統(tǒng)》-- 王喆
- https://www.ijcai.org/Proceedings/2017/0239.pdf
- https://www.pythonheidong.com/blog/article/496185/ca6722040a4cc47134a5/