深度学习推荐系统(八)AFM模型及其在Criteo数据集上的应用
1 AFM模型原理及其实现
-
沿着特征工程自动化的思路,深度学习模型从 PNN ⼀路⾛来,经过了Wide&Deep、Deep&Cross、FNN、DeepFM、NFM等模型,进⾏了大量的、基于不同特征互操作思路的尝试。
-
但特征工程的思路走到这里几乎已经穷尽了可能的尝试,模型进⼀步提升的空间非常小,这也是这类模型的局限性所在。
-
从这之后,越来越多的深度学习推荐模型开始探索更多“结构”上的尝试,诸如注意力机制、序列模型、强化学习等在其他领域大放异彩的模型结构也逐渐进⼊推荐系统领域,并且在推荐模型的效果提升上成果显著。
-
从 2017年开始,推荐领域也开始尝试将注意力机制引入模型之中,由浙江大学提出的AFM和由阿里巴巴提出的DIN是典型模型。
1.1 AFM模型原理
-
AFM模型和NFM模型结构上非常相似, 算是NFM模型的一个延伸。在NFM中, 不同特征域的特征embedding向量经过特征交叉池化层的交叉,将各个交叉特征向量进行
加和
, 然后后面跟了一个DNN网络。 加和池化,它相当于一视同仁
地对待所有交叉特征, 没有考虑不同特征对结果的影响程度。 -
这可能会影响最后的预测效果, 因为不是所有的交互特征都能够对最后的预测起作用。 没有用的交互特征可能会产生噪声。例如:如果应用场景是预测一位男性用户是否购买一款键盘的可能性, 那么“性别=男且购买历史包含鼠标”这个交叉特征, 很可能比“性别=男且用户年龄=30”这一个交叉特征重要。
-
作者在提出NFM之后,把注意力机制引入到了里面去, 来学习不同交叉特征对于结果的不同影响程度。
-
AFM 是从改进模型结构的⾓度出发进行的⼀次有益尝试。它与具体的应用场景无关。
1.1.1 AFM模型结构
-
AFM模型是通过在
特征交叉层和最终的输出层之间加入注意力网络来引入了注意力机制
-
注意力网络的作用是为每⼀个交叉特征提供权重,也就是注意力得分
该模型的网络架构如下:
1.1.2 基于注意力机制的池化层
其中要学习的模型参数就是特征交叉层到注意力网络全连接层的权重矩阵W,偏置向量b,以及全连接层到softmax输出层的权重向量h。
注意力网络将与整个模型⼀起参与梯度反向传播的学习过程,得到最终的权重参数。
1.2 AFM模型代码复现
import torch.nn as nn
import torch.nn.functional as F
import torch
import itertools
class Dnn(nn.Module):
"""
Dnn part
"""
def __init__(self, hidden_units, dropout=0.):
"""
hidden_units: 列表, 每个元素表示每一层的神经单元个数, 比如[256, 128, 64], 两层网络, 第一层神经单元128, 第二层64, 第一个维度是输入维度
dropout: 失活率
"""
super(Dnn, self).__init__()
self.dnn_network = nn.ModuleList(
[nn.Linear(layer[0], layer[1]) for layer in list(zip(hidden_units[:-1], hidden_units[1:]))])
self.dropout = nn.Dropout(p=dropout)
def forward(self, x):
for linear in self.dnn_network:
x = linear(x)
x = F.relu(x)
x = self.dropout(x)
return x
class Attention_layer(nn.Module):
def __init__(self, att_units):
"""
:param att_units: [embed_dim, att_vector]
"""
super(Attention_layer, self).__init__()
self.att_w = nn.Linear(att_units[0], att_units[1])
self.att_dense = nn.Linear(att_units[1], 1)
# bi_interaction (batch_size, (field_num*(field_num-1))/2, embed_dim)
def forward(self, bi_interaction):
# a的shape为(batch_size, (field_num*(field_num-1))/2, att_vector)
a = self.att_w(bi_interaction)
a = F.relu(a)
# 得到注意力分数,shape为(batch_size, (field_num*(field_num-1))/2, 1)
att_scores = self.att_dense(a)
# 为dim=1进行softmax转换 shape为(batch_size, (field_num*(field_num-1))/2, 1)
att_weight = F.softmax(att_scores, dim=1)
# 得到最终权重(广播机制) * bi_interaction,然后把dim=1压缩(行的相同位置相加、去掉dim=1)
# 即(field_num*(field_num-1))/2,embed_dim)变为embed_dim
# att_out的shape为(batch_size, embed_dim)
att_out = torch.sum(att_weight * bi_interaction, dim=1)
return att_out
class AFM(nn.Module):
def __init__(self, feature_info, mode, hidden_units, embed_dim=8, att_vector=8, dropout=0.5, useDNN=False):
"""
AFM:
:param feature_info: 特征信息(数值特征, 类别特征, 类别特征embedding映射)
:param mode: A string, 三种模式, 'max': max pooling, 'avg': average pooling 'att', Attention
:param att_vector: 注意力网络的隐藏层单元个数
:param hidden_units: DNN网络的隐藏单元个数, 一个列表的形式, 列表的长度代表层数, 每个元素代表每一层神经元个数
:param dropout: Dropout比率
:param useDNN: 默认不使用DNN网络
"""
super(AFM, self).__init__()
self.dense_features, self.sparse_features, self.sparse_features_map = feature_info
self.mode = mode
self.useDNN = useDNN
# embedding层, 这里需要一个列表的形式, 因为每个类别特征都需要embedding
self.embed_layers = nn.ModuleDict(
{
'embed_' + str(key): nn.Embedding(num_embeddings=val, embedding_dim=embed_dim)
for key, val in self.sparse_features_map.items()
}
)
# 如果是注意机制的话,这里需要加一个注意力网络
if self.mode == 'att':
self.attention = Attention_layer([embed_dim, att_vector])
# 如果使用DNN的话, 这里需要初始化DNN网络
if self.useDNN:
# 注意 这里的总维度 = 数值型特征的维度 + embedding的维度
self.fea_num = len(self.dense_features) + embed_dim
hidden_units.insert(0, self.fea_num)
self.bn = nn.BatchNorm1d(self.fea_num)
self.dnn_network = Dnn(hidden_units, dropout)
self.nn_final_linear = nn.Linear(hidden_units[-1], 1)
else:
# 注意 这里的总维度 = 数值型特征的维度 + embedding的维度
self.fea_num = len(self.dense_features) + embed_dim
self.nn_final_linear = nn.Linear(self.fea_num, 1)
def forward(self, x):
# 1、先把输入向量x分成两部分处理、因为数值型和类别型的处理方式不一样
dense_inputs, sparse_inputs = x[:, :len(self.dense_features)], x[:, len(self.dense_features):]
# 转换为long形
sparse_inputs = sparse_inputs.long()
# 2、不同的类别特征分别embedding
sparse_embeds = [
self.embed_layers['embed_' + key](sparse_inputs[:, i]) for key, i in
zip(self.sparse_features_map.keys(), range(sparse_inputs.shape[1]))
]
# 3、embedding进行堆叠 fild_num即为离散特征数
sparse_embeds = torch.stack(sparse_embeds) # (离散特征数, batch_size, embed_dim)
sparse_embeds = sparse_embeds.permute((1, 0, 2)) # (batch_size, 离散特征数, embed_dim)
# 这里得到embedding向量之后 sparse_embeds(batch_size, 离散特征数, embed_dim)
# 下面进行两两交叉, 注意这时候不能加和了,也就是NFM的那个计算公式不能用, 这里两两交叉的结果要进入Attention
# 两两交叉embedding之后的结果是一个(batch_size, (field_num*field_num-1)/2, embed_dim)
# 这里实现的时候采用一个技巧就是组合
# 比如fild_num有3个的话,那么组合embedding就是[0,1] [0,2],[1,2]位置的embedding乘积操作
first = []
second = []
for f, s in itertools.combinations(range(sparse_embeds.shape[1]), 2):
first.append(f)
second.append(s)
# 取出first位置的embedding 假设field是3的话,就是[0, 0, 1]位置的embedding
# p的shape为(batch_size, (field_num*(field_num-1))/2, embed_dim)
p = sparse_embeds[:, first, :]
# 取出second位置的embedding 假设field是3的话,就是[1, 2, 2]位置的embedding
# q的shape为(batch_size, (field_num*(field_num-1))/2, embed_dim)
q = sparse_embeds[:, second, :]
# 最终得到bi_interaction的shape为(batch_size, (field_num*(field_num-1))/2, embed_dim)
# p * q ,对应位置相乘
# 假设field_num为3,即为[batch_size,(0,0,1),embed_dim] 与 [batch_size,(1,2,2),embed_dim] 对应元素相乘
# 即[0,1] [0,2],[1,2]位置的embedding乘积操作
bi_interaction = p * q
if self.mode == 'max':
att_out = torch.sum(bi_interaction, dim=1) # (batch_size, embed_dim)
elif self.mode == 'avg':
att_out = torch.mean(bi_interaction, dim=1) # (batch_size, embed_dim)
else:
# 注意力网络
att_out = self.attention(bi_interaction) # (batch_size, embed_dim)
# 把离散特征和连续特征进行拼接
x = torch.cat([att_out, dense_inputs], dim=-1)
if not self.useDNN:
outputs = torch.sigmoid(self.nn_final_linear(x))
else:
# BatchNormalization
x = self.bn(x)
# deep
dnn_outputs = self.nn_final_linear(self.dnn_network(x))
outputs = torch.sigmoid(dnn_outputs)
return outputs
if __name__ == '__main__':
x = torch.rand(size=(2, 5), dtype=torch.float32)
feature_info = [
['I1', 'I2'], # 连续性特征
['C1', 'C2', 'C3'], # 离散型特征,即field_num=3
{
'C1': 20,
'C2': 20,
'C3': 20
}
]
# 建立模型
hidden_units = [128, 64, 32]
mode = "att"
net = AFM(feature_info, mode, hidden_units)
print(net)
print(net(x))
AFM(
(embed_layers): ModuleDict(
(embed_C1): Embedding(20, 8)
(embed_C2): Embedding(20, 8)
(embed_C3): Embedding(20, 8)
)
(attention): Attention_layer(
(att_w): Linear(in_features=8, out_features=8, bias=True)
(att_dense): Linear(in_features=8, out_features=1, bias=True)
)
(nn_final_linear): Linear(in_features=10, out_features=1, bias=True)
)
tensor([[0.5879],
[0.6086]], grad_fn=<SigmoidBackward0>)
2 AFM模型在Criteo数据集上的应用
数据的预处理可以参考
深度学习推荐系统(二)Deep Crossing及其在Criteo数据集上的应用
2.1 准备训练数据
import pandas as pd
import torch
from torch.utils.data import TensorDataset, Dataset, DataLoader
import torch.nn as nn
from sklearn.metrics import auc, roc_auc_score, roc_curve
import warnings
warnings.filterwarnings('ignore')
# 封装为函数
def prepared_data(file_path):
# 读入训练集,验证集和测试集
train_set = pd.read_csv(file_path + 'train_set.csv')
val_set = pd.read_csv(file_path + 'val_set.csv')
test_set = pd.read_csv(file_path + 'test.csv')
# 这里需要把特征分成数值型和离散型
# 因为后面的模型里面离散型的特征需要embedding, 而数值型的特征直接进入了stacking层, 处理方式会不一样
data_df = pd.concat((train_set, val_set, test_set))
# 数值型特征直接放入stacking层
dense_features = ['I' + str(i) for i in range(1, 14)]
# 离散型特征需要需要进行embedding处理
sparse_features = ['C' + str(i) for i in range(1, 27)]
# 定义一个稀疏特征的embedding映射, 字典{key: value},
# key表示每个稀疏特征, value表示数据集data_df对应列的不同取值个数, 作为embedding输入维度
sparse_feas_map = {}
for key in sparse_features:
sparse_feas_map[key] = data_df[key].nunique()
feature_info = [dense_features, sparse_features, sparse_feas_map] # 这里把特征信息进行封装, 建立模型的时候作为参数传入
# 把数据构建成数据管道
dl_train_dataset = TensorDataset(
# 特征信息
torch.tensor(train_set.drop(columns='Label').values).float(),
# 标签信息
torch.tensor(train_set['Label'].values).float()
)
dl_val_dataset = TensorDataset(
# 特征信息
torch.tensor(val_set.drop(columns='Label').values).float(),
# 标签信息
torch.tensor(val_set['Label'].values).float()
)
dl_train = DataLoader(dl_train_dataset, shuffle=True, batch_size=16)
dl_vaild = DataLoader(dl_val_dataset, shuffle=True, batch_size=16)
return feature_info,dl_train,dl_vaild,test_set
file_path = './preprocessed_data/'
feature_info,dl_train,dl_vaild,test_set = prepared_data(file_path)
2.2 建立AFM模型
from _01_afm import AFM
hidden_units = [128, 64, 32]
mode = "att"
dnn_dropout = 0.0
net = AFM(feature_info, mode, hidden_units, dropout=dnn_dropout, useDNN=True)
# 测试一下模型
for feature, label in iter(dl_train):
out = net(feature)
print(feature.shape)
print(out.shape)
print(out)
break
3.3 模型的训练
from AnimatorClass import Animator
from TimerClass import Timer
# 模型的相关设置
def metric_func(y_pred, y_true):
pred = y_pred.data
y = y_true.data
return roc_auc_score(y, pred)
def try_gpu(i=0):
if torch.cuda.device_count() >= i + 1:
return torch.device(f'cuda:{i}')
return torch.device('cpu')
def train_ch(net, dl_train, dl_vaild, num_epochs, lr, device):
"""⽤GPU训练模型"""
print('training on', device)
net.to(device)
# 二值交叉熵损失
loss_func = nn.BCELoss()
optimizer = torch.optim.Adam(params=net.parameters(), lr=lr)
animator = Animator(xlabel='epoch', xlim=[1, num_epochs],legend=['train loss', 'train auc', 'val loss', 'val auc']
,figsize=(8.0, 6.0))
timer, num_batches = Timer(), len(dl_train)
log_step_freq = 10
for epoch in range(1, num_epochs + 1):
# 训练阶段
net.train()
loss_sum = 0.0
metric_sum = 0.0
for step, (features, labels) in enumerate(dl_train, 1):
timer.start()
# 梯度清零
optimizer.zero_grad()
# 正向传播
predictions = net(features)
loss = loss_func(predictions, labels.unsqueeze(1) )
try: # 这里就是如果当前批次里面的y只有一个类别, 跳过去
metric = metric_func(predictions, labels)
except ValueError:
pass
# 反向传播求梯度
loss.backward()
optimizer.step()
timer.stop()
# 打印batch级别日志
loss_sum += loss.item()
metric_sum += metric.item()
if step % log_step_freq == 0:
animator.add(epoch + step / num_batches,(loss_sum/step, metric_sum/step, None, None))
# 验证阶段
net.eval()
val_loss_sum = 0.0
val_metric_sum = 0.0
for val_step, (features, labels) in enumerate(dl_vaild, 1):
with torch.no_grad():
predictions = net(features)
val_loss = loss_func(predictions, labels.unsqueeze(1))
try:
val_metric = metric_func(predictions, labels)
except ValueError:
pass
val_loss_sum += val_loss.item()
val_metric_sum += val_metric.item()
if val_step % log_step_freq == 0:
animator.add(epoch + val_step / num_batches, (None,None,val_loss_sum / val_step , val_metric_sum / val_step))
print(f'final: loss {loss_sum/len(dl_train):.3f}, auc {metric_sum/len(dl_train):.3f},'
f' val loss {val_loss_sum/len(dl_vaild):.3f}, val auc {val_metric_sum/len(dl_vaild):.3f}')
print(f'{num_batches * num_epochs / timer.sum():.1f} examples/sec on {str(device)}')
lr, num_epochs = 0.001, 5
train_ch(net, dl_train, dl_vaild, num_epochs, lr, try_gpu())