Wide&Deep模型

Wide & Deep是专门为推荐系统点击率预估而设计的一个种联合模型。

1. 点击率预估

点击率预估是对每次广告点击情况作出预测,可以输出点击或者不点击,也可以输出该次点击的概率,后者有时候也称为pClick。

点击率预估问题就是一个二分类的问题,在机器学习中可以使用逻辑回归作为模型的输出,其输出的就是一个概率值,我们可以将机器学习输出的这个概率值认为是某个用户点击某个广告的概率。

点击率预估与推荐算法的不同:

  • 广告点击率预估是需要得到某个用户对某个广告的点击率,然后结合广告的出价用于排序;
  • 而推荐算法很多大多数情况下只需要得到一个最优的推荐次序,即TopN推荐的问题。当然也可以利用广告的点击率来排序,作为广告的推荐。

2. Wide&Deep模型

2.1 提出Wide&Deep的原因

FM模型的缺点:当query-item矩阵是稀疏并且是high-rank的时候(比如user有特殊的爱好,或item比较小众),很难非常效率的学习出低维度的表示。这种情况下,大部分的query-item都没有什么关系。但是dense embedding会导致几乎所有的query-item预测值都是非0的,这就导致了推荐过度泛化,会推荐一些不那么相关的物品。相反,简单的linear model却可以通过cross-product transformation来记住这些exception rules

2.2 Wide&Deep模型简介

记忆与泛化

Memorization 和 Generalization是推荐系统很常见的两个概念,

其中Memorization指的是通过用户与商品的交互信息矩阵学习规则,而Generalization则是泛化规则。

FM算法就是很好的Generalization的例子,它可以根据交互信息学习到一个比较短的矩阵 V ,其中 vi 储存着每个用户特征的压缩表示(embedding),

协同过滤与SVD都是靠记住用户之前与哪些物品发生了交互从而推断出的推荐结果,这两者推荐结果当然存在一些差异,

Wide&Deep模型就能够融合这两种推荐结果做出最终的推荐,得到一个比之前的推荐结果都好的模型。

简单来说:

  • 记忆可以宽松定义为学习商品或者特征的共同出现频繁程度和利用历史数据中可用的相关性。
  • 泛化是基于相关性的传递性,探索从未出现或者极少出现过的新的特征组合。
  • 基于记忆的推荐系统通常更加直接地和与用户交互过的商品相关,推荐用户之前有过行为的items。
  • 和基于记忆的推荐系统相比,基于泛化的推荐系统倾向于提升推荐商品的多样性(diversity)。
  • Wide Linear Model用于memorization。Deep Neural Network用于generalization

Memorization只需要使用一个线性模型即可实现,而Generalization需要使用DNN实现。

广义线性模型:

  • 在稀疏特征上使用跨产品(cross-product)特征变换可以有效的实现记忆
  • 跨产品变换的一个局限性是不能产生没有在训练集中出现过的查询语句-商品特征对。

DNN模型:

  • DNN模型是基于嵌入的模型,分解机FM或者深度神经网络都是基于嵌入(embedding-based)的模型,这种模型通过学习每个query和item的低维稠密embedding向量,可以泛化从未出现过的查询语句-商品特征对,同时减少特征工程的负担。
  • 相比之下,DNN几乎不需要特征工程。通过对低纬度的dense embedding进行组合可以学习到更深层次的隐藏特征。但是,缺点是有点over-generalize(过度泛化)。推荐系统中表现为:会给用户推荐不是那么相关的物品,尤其是user-item矩阵比较稀疏并且是high-rank(高秩矩阵)

下面是wide&deep模型的结构图,由左边的wide部分(一个简单的线性模型),右边的deep部分(一个典型的DNN模型)。

wide&deep模型的结构图
  1. Wide部分有利于增强模型的“记忆能力”:

wide部分是一个广义的线性模型,输入的特征主要有两部分组成,一部分是原始的部分特征,另一部分是原始特征的交互特征(cross-product transformation),对于交互特征可以定义为:
\phi_{k}(x)=\prod_{i=1}^d x_i^{c_{ki}}, c_{ki}\in \{0,1\}
说明:两个特征都同时为1这个新的特征才能为1,否则就是0,说白了就是一个特征组合。

用原论文的例子举例:

AND(user_installed_app=QQ, impression_app=WeChat),当特征user_installed_app=QQ,和特征impression_app=WeChat取值都为1的时候,组合特征AND(user_installed_app=QQ, impression_app=WeChat)的取值才为1,否则为0。

对于wide部分训练时候使用的优化器是带 L1 正则的FTRL算法(Follow-the-regularized-leader),而L1 FTLR是非常注重模型稀疏性质的,也就是说W&D模型采用L1 FTRL是想让Wide部分变得更加的稀疏,

即Wide部分的大部分参数都为0,这就大大压缩了模型权重及特征向量的维度。

Wide部分模型训练完之后留下来的特征都是非常重要的,那么模型的“记忆能力”就可以理解为发现"直接的",“暴力的”,“显然的”关联规则的能力。

例如Google W&D期望wide部分发现这样的规则:用户安装了应用A,此时曝光应用B,用户安装应用B的概率大。

  1. Deep部分有利于增强模型的“泛化能力”

Deep部分是一个DNN模型,输入的特征主要分为两大类,一类是数值特征(可直接输入DNN),一类是类别特征(需要经过Embedding之后才能输入到DNN中),Deep部分的数学形式如下:
a^{(l+1)} = f(W^{l}a^{(l)} + b^{l})
DNN模型随着层数的增加,中间的特征就越抽象,也就提高了模型的泛化能力。 对于Deep部分的DNN模型作者使用了深度学习常用的优化器AdaGrad,这也是为了使得模型可以得到更精确的解。

  1. Wide部分与Deep部分的结合

Wide&Deep模型是将两部分输出的结果结合起来联合训练,将deep和wide部分的输出重新使用一个逻辑回归模型做最终的预测,输出概率值。联合训练的数学形式如下:
P(Y=1|x)=\delta(w_{wide}^T[x,\phi(x)] + w_{deep}^T a^{(lf)} + b)

2.3 Wide&Deep的构造步骤

  1. Retrieval :利用机器学习模型和一些人为定义的规则,来返回最匹配当前Query的一个小的items集合,这个集合就是最终的推荐列表的候选集。

  2. Ranking

  • 收集更细致的用户特征,如:

    • User features(年龄、性别、语言、民族等)
    • Contextual features(上下文特征:设备,时间等)
    • Impression features(展示特征:app age、app的历史统计信息等)
  • 将特征分别传入Wide和Deep

    一起做训练

    。在训练的时候,根据最终的loss计算出gradient,反向传播到Wide和Deep两部分中,分别训练自己的参数(wide组件只需要填补deep组件的不足就行了,所以需要比较少的cross-product feature transformations,而不是full-size wide Model)

    • 训练方法是用mini-batch stochastic optimization。
    • Wide组件是用FTRL(Follow-the-regularized-leader) + L1正则化学习。
    • Deep组件是用AdaGrad来学习。
  • 训练完之后推荐TopN

wide&deep模型尽管在模型结构上非常的简单,但是如果想要很好的使用wide&deep模型的话,还是要深入理解业务,确定wide部分使用哪部分特征,deep部分使用哪些特征,以及wide部分的交叉特征应该如何去选择

2.4 代码实现

2种实现方法:

  • 使用tensorflow中已经封装好的wide&deep模型,可以熟悉模型训练的整体结构;
  • 使用tensorflow中的keras实现wide&deep,可以看到模型内部的细节并将其实现。

2.4.1 Tensorflow内置的WideDeepModel

# TensorFlow
import tensorflow as tf
print("Tensorflow version " + tf.__version__)
Tensorflow version 2.3.1
import tensorflow as tf

linear_model = tf.keras.experimental.LinearModel()
linear_model.compile('adagrad', 'mse')
linear_model.fit(linear_inputs, y, epochs)
dnn_model = tf.keras.Sequential([tf.keras.layers.Dense(units=1)])
dnn_model.compile('rmsprop', 'mse')
dnn_model.fit(dnn_inputs, y, epochs)
combined_model = tf.keras.experimental.WideDeepModel(dnn_model, linear_model)
combined_model.compile(optimizer=['sgd', 'adam'], loss='mse', metrics=['AUC'])
combined_model.fit([linear_inputs, dnn_inputs], y, epochs)

2.4.2 Tensorflow中的keras实现Wide&Deep模型

import pandas as pd
import numpy as np
import warnings
import random, math, os
from tqdm import tqdm

from tensorflow.keras import *
from tensorflow.keras.layers import *
from tensorflow.keras.models import *
from tensorflow.keras.callbacks import *
import tensorflow.keras.backend as K
from tensorflow.keras.regularizers import l2, l1_l2

from sklearn.model_selection import train_test_split
from sklearn.preprocessing import OneHotEncoder, MinMaxScaler, StandardScaler, LabelEncoder

# 读取数据,并将标签做简单的转换
def get_data():
    COLUMNS = ["age", "workclass", "fnlwgt", "education", "education_num",
               "marital_status", "occupation", "relationship", "race", "gender",
               "capital_gain", "capital_loss", "hours_per_week", "native_country",
               "income_bracket"]

    df_train = pd.read_csv("data/adult_train.csv", names=COLUMNS)
    df_test = pd.read_csv("data/adult_test.csv", names=COLUMNS, skiprows=1)

    df_train['income_label'] = (df_train["income_bracket"].apply(lambda x: ">50K" in x)).astype(int)
    df_test['income_label'] = (df_test["income_bracket"].apply(lambda x: ">50K" in x)).astype(int)

    return df_train, df_test


# 特征处理分为wide部分的特征处理和deep部分的特征处理
def data_process(df_train, df_test):
    # 年龄特征离散化
    age_groups = [0, 25, 65, 90]
    age_labels = range(len(age_groups) - 1)
    df_train['age_group'] = pd.cut(df_train['age'], age_groups, labels=age_labels)
    df_test['age_group'] = pd.cut(df_test['age'], age_groups, labels=age_labels)

    # wide部分的原始特征及交叉特征
    wide_cols = ['workclass', 'education', 'marital_status', 'occupation', \
                 'relationship', 'race', 'gender', 'native_country', 'age_group']
    x_cols = (['education', 'occupation'], ['native_country', 'occupation'])

    # deep部分的特征分为两大类,一类是数值特征(可以直接输入到网络中进行训练),
    # 一类是类别特征(只能在embedding之后才能输入到模型中进行训练)
    embedding_cols = ['workclass', 'education', 'marital_status', 'occupation', \
                      'relationship', 'race', 'gender', 'native_country']
    cont_cols = ['age', 'capital_gain', 'capital_loss', 'hours_per_week']

    # 类别标签
    target = 'income_label'

    return df_train, df_test, wide_cols, x_cols, embedding_cols, cont_cols, target


def process_wide_feats(df_train, df_test, wide_cols, x_cols, target):
    # 合并训练和测试数据,后续一起编码
    df_train['IS_TRAIN'] = 1
    df_test['IS_TRAIN'] = 0
    df_wide = pd.concat([df_train, df_test])

    # 选出wide部分特征中的类别特征, 类别特征在DataFrame中是object类型
    categorical_columns = list(df_wide.select_dtypes(include=['object']).columns) 

    # 构造交叉特征
    crossed_columns_d = []
    for f1, f2 in x_cols:
        col_name = f1 + '_' + f2
        crossed_columns_d.append(col_name)
        df_wide[col_name] = df_wide[[f1, f2]].apply(lambda x: '-'.join(x), axis=1)

    # wide部分的所有特征
    wide_cols += crossed_columns_d
    df_wide = df_wide[wide_cols + [target] + ['IS_TRAIN']]

    # 将wide部分类别特征进行onehot编码
    dummy_cols = [c for c in wide_cols if c in categorical_columns + crossed_columns_d]
    df_wide = pd.get_dummies(df_wide, columns=[x for x in dummy_cols])

    # 将训练数据和测试数据分离
    train = df_wide[df_wide.IS_TRAIN == 1].drop('IS_TRAIN', axis=1)
    test = df_wide[df_wide.IS_TRAIN == 0].drop('IS_TRAIN', axis=1)

    cols = [c for c in train.columns if c != target]
    X_train = train[cols].values
    y_train = train[target].values.reshape(-1, 1)
    X_test = test[cols].values
    y_test = test[target].values.reshape(-1, 1)

    return X_train, y_train, X_test, y_test


def process_deep_feats(df_train, df_test, embedding_cols, cont_cols, target, emb_dim=8, emb_reg=1e-3):
    # 标记训练和测试集,方便特征处理完之后进行训练和测试集的分离
    df_train['IS_TRAIN'] = 1
    df_test['IS_TRAIN'] = 0
    df_deep = pd.concat([df_train, df_test])

    # 拼接数值特征和embedding特征
    deep_cols = embedding_cols + cont_cols
    df_deep = df_deep[deep_cols + [target,'IS_TRAIN']]

    # 数值类特征进行标准化
    scaler = StandardScaler()
    df_deep[cont_cols] = pd.DataFrame(scaler.fit_transform(df_train[cont_cols]), columns=cont_cols)

    # 类边特征编码
    unique_vals = dict()
    lbe = LabelEncoder()
    for feats in embedding_cols:
        df_deep[feats] = lbe.fit_transform(df_deep[feats])
        unique_vals[feats] = df_deep[feats].nunique()

    # 构造模型的输入层,和embedding层,虽然对于连续的特征没有embedding层,但是为了统一,将Reshape层
    # 当成是连续特征的embedding层
    inp_layer = []
    emb_layer = []
    for ec in embedding_cols:
        layer_name = ec + '_inp'
        inp = Input(shape=(1,), dtype='int64', name=layer_name)
        emb = Embedding(unique_vals[ec], emb_dim, input_length=1, embeddings_regularizer=l2(emb_reg))(inp)
        inp_layer.append(inp)
        emb_layer.append(inp)

    for cc in cont_cols:
        layer_name = cc + '_inp'
        inp = Input(shape=(1,), dtype='int64', name=layer_name)
        emb = Reshape((1, 1))(inp)
        inp_layer.append(inp)
        emb_layer.append(inp)

    # 训练和测试集分离
    train = df_deep[df_deep.IS_TRAIN == 1].drop('IS_TRAIN', axis=1)
    test = df_deep[df_deep.IS_TRAIN == 0].drop('IS_TRAIN', axis=1)

    # 提取训练和测试集中的特征
    X_train = [train[c] for c in deep_cols]
    y_train = np.array(train[target].values).reshape(-1, 1)
    X_test = [test[c] for c in deep_cols]
    y_test = np.array(test[target].values).reshape(-1, 1)

    return X_train, y_train, X_test, y_test, emb_layer, inp_layer


def wide_deep(df_train, df_test, wide_cols, x_cols, embedding_cols, cont_cols):
    # wide部分特征处理
    X_train_wide, y_train_wide, X_test_wide, y_test_wide = \
        process_wide_feats(df_train, df_test, wide_cols, x_cols, target)

    # deep部分特征处理
    X_train_deep, y_train_deep, X_test_deep, y_test_deep, deep_inp_embed, deep_inp_layer = \
        process_deep_feats(df_train, df_test, embedding_cols,cont_cols, target)

    # wide特征与deep特征拼接
    X_tr_wd = [X_train_wide] + X_train_deep
    Y_tr_wd = y_train_deep  # wide部分和deep部分的label是一样的
    X_te_wd = [X_test_wide] + X_test_deep
    Y_te_wd = y_test_deep  # wide部分和deep部分的label是一样的

    # wide部分的输入
    w = Input(shape=(X_train_wide.shape[1],), dtype='float32', name='wide')

    # deep部分的NN结构
    d = concatenate(deep_inp_embed)
    d = Flatten()(d)
    d = Dense(50, activation='relu', kernel_regularizer=l1_l2(l1=0.01, l2=0.01))(d)
    d = Dropout(0.5)(d)
    d = Dense(20, activation='relu', name='deep')(d)
    d = Dropout(0.5)(d)

    # 将wide部分与deep部分的输入进行拼接, 然后输入一个线性层
    wd_inp = concatenate([w, d])
    wd_out = Dense(Y_tr_wd.shape[1], activation='sigmoid', name='wide_deep')(wd_inp) 
    
    # 构建模型,这里需要注意模型的输入部分是由wide和deep部分组成的
    wide_deep = Model(inputs=[w] + deep_inp_layer, outputs=wd_out)
    wide_deep.compile(optimizer='Adam', loss='binary_crossentropy', metrics=['AUC'])

    # 设置模型学习率,不设置学习率keras默认的学习率是0.01
    wide_deep.optimizer.lr = 0.001

    # 模型训练
    wide_deep.fit(X_tr_wd, Y_tr_wd, epochs=5, batch_size=128)

    # 模型预测及验证
    results = wide_deep.evaluate(X_te_wd, Y_te_wd)

    print("\n", results)


if __name__ == '__main__':
    # 读取数据
    df_train, df_test = get_data()

    # 特征处理
    df_train, df_test, wide_cols, x_cols, embedding_cols, cont_cols, target = data_process(df_train, df_test)

    # 模型训练
    wide_deep(df_train, df_test, wide_cols, x_cols, embedding_cols, cont_cols)
Epoch 1/5
255/255 [==============================] - ETA: 0s - loss: 2.6076 - auc: 0.551 - ETA: 0s - loss: 1.8649 - auc: 0.510 - ETA: 0s - loss: 1.5997 - auc: 0.517 - ETA: 0s - loss: 1.4348 - auc: 0.539 - ETA: 0s - loss: 1.3145 - auc: 0.567 - ETA: 0s - loss: 1.2060 - auc: 0.599 - ETA: 0s - loss: 1.1209 - auc: 0.626 - 0s 1ms/step - loss: 1.0792 - auc: 0.6394
Epoch 2/5
255/255 [==============================] - ETA: 0s - loss: 0.6613 - auc: 0.725 - ETA: 0s - loss: 0.5636 - auc: 0.818 - ETA: 0s - loss: 0.5437 - auc: 0.818 - ETA: 0s - loss: 0.5285 - auc: 0.824 - ETA: 0s - loss: 0.5146 - auc: 0.830 - ETA: 0s - loss: 0.5018 - auc: 0.833 - ETA: 0s - loss: 0.4928 - auc: 0.837 - 0s 1ms/step - loss: 0.4887 - auc: 0.8403
Epoch 3/5
255/255 [==============================] - ETA: 0s - loss: 0.4285 - auc: 0.882 - ETA: 0s - loss: 0.4321 - auc: 0.856 - ETA: 0s - loss: 0.4260 - auc: 0.859 - ETA: 0s - loss: 0.4223 - auc: 0.861 - ETA: 0s - loss: 0.4190 - auc: 0.863 - ETA: 0s - loss: 0.4178 - auc: 0.862 - ETA: 0s - loss: 0.4146 - auc: 0.863 - 0s 1ms/step - loss: 0.4137 - auc: 0.8633
Epoch 4/5
255/255 [==============================] - ETA: 0s - loss: 0.3753 - auc: 0.875 - ETA: 0s - loss: 0.3897 - auc: 0.873 - ETA: 0s - loss: 0.3934 - auc: 0.868 - ETA: 0s - loss: 0.3899 - auc: 0.871 - ETA: 0s - loss: 0.3924 - auc: 0.869 - ETA: 0s - loss: 0.3888 - auc: 0.871 - ETA: 0s - loss: 0.3891 - auc: 0.870 - 0s 1ms/step - loss: 0.3889 - auc: 0.8699
Epoch 5/5
255/255 [==============================] - ETA: 0s - loss: 0.3624 - auc: 0.889 - ETA: 0s - loss: 0.3918 - auc: 0.861 - ETA: 0s - loss: 0.3836 - auc: 0.868 - ETA: 0s - loss: 0.3801 - auc: 0.871 - ETA: 0s - loss: 0.3765 - auc: 0.873 - ETA: 0s - loss: 0.3765 - auc: 0.873 - ETA: 0s - loss: 0.3749 - auc: 0.873 - 0s 1ms/step - loss: 0.3757 - auc: 0.8736
509/509 [==============================] - ETA: 0s - loss: 0.3755 - auc: 0.909 - ETA: 0s - loss: 0.3798 - auc: 0.867 - ETA: 0s - loss: 0.3728 - auc: 0.873 - ETA: 0s - loss: 0.3685 - auc: 0.877 - ETA: 0s - loss: 0.3698 - auc: 0.874 - ETA: 0s - loss: 0.3687 - auc: 0.876 - ETA: 0s - loss: 0.3694 - auc: 0.874 - ETA: 0s - loss: 0.3707 - auc: 0.874 - 0s 730us/step - loss: 0.3697 - auc: 0.8747

 [0.36972635984420776, 0.8746944069862366]

参考链接:

Tensorflow2.0(V)--keras建立了一个宽而深的模型,TensorFlow20,五,Keras,构建,WideDeep
https://www.kaggle.com/ryanholbrook/detecting-the-higgs-boson-with-tpus/notebook
推荐系统组队学习之Wide&Deep

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,569评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,499评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,271评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,087评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,474评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,670评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,911评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,636评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,397评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,607评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,093评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,418评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,074评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,092评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,865评论 0 196
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,726评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,627评论 2 270