wide and deep 模型的核心思想是结合线性模型的记忆能力(memorization)和 DNN 模型的泛化能力(generalization),在训练过程中同时优化 2 个模型的参数,从而达到整体模型的预测能力最优。
wide部分,也就是广义线性模型,广泛用于大规模的回归和分类问题。一般:线性模型 + 特征交叉
deep部分,也就是深度神经网络模型。
记忆能力Memorization趋向于更加保守,推荐用户之前有过行为的items。相比之下,泛化能力generalization更加趋向于提高推荐系统的多样性(diversity)。
wide&deep模型则是将上述两个模型的优点结合起来,兼具记忆能力和泛化能力。
其模型图如下:
Wide部分如上图是 y = w T x + b y=w^{T} x+b y=wTx+b的广义线性模型。输入特征可以是连续特征,也可以是稀疏的离散特征,离散特征之间进行交叉后可以构成更高维的离散特征。线性模型训练中通过 L1 正则化,能够很快收敛到有效的特征组合中。最重要的转换之一是交叉乘积转换,定义为
$ \Large \phi_{k}(\mathbf{x})=\prod_{i=1}^{d} x_{i}^{c_{k i}} \quad c_{k i} \in{0,1} $
C k i C_{ki} Cki是一个布尔变量,其取值为:如果第i个特征是第k个变换 ϕ k \phi_{k} ϕk的一部分则为1,其它为0。对于二值特征,一个组合特征当原特征都为1的时候才会1(例如“性别=女”且“语言=英语”时,AND(性别=女,语言=英语)=1,其他情况均为0),这捕获了二元特征之间的相互作用,并为广义线性模型增加了非线性。
Wide部分的作用是让模型具有较强的“记忆能力”。“记忆能力”可以被理解为模型直接学习并利用历史数据中物品或者特征的“共现频率”的能力。一般来说,协同过滤、逻辑回归等简单模型有较强的“记忆能力”。由于这类模型的结构简单,原始数据往往可以直接影响推荐结果,产生类似于“如果点击过A,就推荐B”这类规则式的推荐,这就相当于模型直接记住了历史数据的分布特点,并利用这些记忆进行推荐。
举例说明:
假设在推荐模型的训练过程中,设置如下组合特征:{user_installed_app=netflix,impression_app=pandora}
(简称netflix&pandora),它代表用户已经安装了netflix这款应用,而且曾应用商店曾经将pandora应用推送给用户看过。如果以“最终是否安装pandora”为数据标签(label),则可以轻易地统计出netflix&pandora
这个特征和安装pandora
这个标签之间的共现频率。假设二者的共现频率高达10%(全局的平均应用安装率为1%),这个特征如此之强,以至于在设计模型时,希望模型一发现有这个特征,就推荐pandora这款应用(像一个深刻的记忆点一样印在脑海中),这就是所谓的**模型的“记忆能力”。**像逻辑回归这类简单模型,如果发现这样的“强特征”,则其相应的权重就会在模型训练过程中被调整得非常大,这样就实现了对这个特征的直接记忆。
deep 端对应的是 DNN 模型,每个特征对应一个低维的实数向量,称之为特征的 embedding。DNN 模型通过反向传播调整隐藏层的权重,并且更新特征的 embedding。
首先对于类别特征,比如对于类别型特征,首先需要将这些高维稀疏特征 转换成低维稠密的embedding向量。随机初始化embedding向量,然后在模型训练中最小化最终损失函数。这些低维稠密向量馈送到前向传递中的神经网络的隐藏层中。 具体来说,每个隐藏层执行以下计算:
a ( l + 1 ) = f ( W ( l ) a ( l ) + b ( l ) ) \large a^{(l+1)}=f\left(W^{(l)} a^{(l)}+b^{(l)}\right) a(l+1)=f(W(l)a(l)+b(l))
l
是层数,f
是激活函数,通常使用RELU函数, a l , b l , W l a^l,b^l,W^l al,bl,Wl是是第l
层的激活、偏置和模型权重。
Deep部分的主要作用是让模型具有“泛化能力”。“泛化能力”可以被理解为模型传递特征的相关性,以及发掘稀疏甚至从未出现过的稀有特征与最终标签相关性的能力。深度神经网络通过特征的多次自动组合,可以深度发掘数据中潜在的模式,即使是非常稀疏的特征向量输入,也能得到较稳定平滑的推荐概率,这就是简单模型所缺乏的“泛化能力”。
整个模型的输出是线性模型输出与DNN模型输出的叠加,模型训练采用的是联合训练(joint training),训练误差会同时反馈到线性模型和 DNN 模型中进行参数更新。
joint training 中模型的融合是在训练阶段进行的,单个模型的权重更新会受到 wide 端和 deep 端对模型训练误差的共同影响。因此在模型的特征设计阶段,wide 端模型和 deep 端模型只需要分别专注于擅长的方面,wide 端模型通过离散特征的交叉组合进行 memorization,deep 端模型通过特征的 embedding 进行 generalization,这样单个模型的大小和复杂度也能得到控制,而整体模型的性能仍能得到提高。
wide端采用FTRL和L1正则化来优化,deep端采用AdaGrad算法来优化,wide & deep Model的后向传播采用mini-batch stochastic optimization。
这个联合模型如图1(中)所示。对于逻辑回归问题,模型的预测是:
P ( Y = 1 ∣ x ) = σ ( w w i d e T [ x , ϕ ( x ) ] + w d e e p T a ( l f ) + b ) \large P(Y=1 \mid \mathbf{x})=\sigma\left(\mathbf{w}_{w i d e}^{T}[\mathbf{x}, \phi(\mathbf{x})]+\mathbf{w}_{d e e p}^{T} a^{\left(l_{f}\right)}+b\right) P(Y=1∣x)=σ(wwideT[x,ϕ(x)]+wdeepTa(lf)+b)
其中,where Y Y Y is the binary class label, σ ( ⋅ ) \sigma(\cdot) σ(⋅) is the sigmoid function, ϕ ( x ) \phi(\mathbf{x}) ϕ(x) are the cross product transformations of the original features x \mathbf{x} x, and b b b is the bias term. w wide \mathbf{w}_{\text {wide }} wwide is the vector of all wide model weights, and w deep \mathbf{w}_{\text {deep }} wdeep are the weights applied on the final activations a ( l f ) a^{\left(l_{f}\right)} a(lf).
Wide&Deep模型把单输入层的Wide部分与由Embedding层和多隐层组成的Deep部分连接起来,一起输入最终的输出层。单层的Wide部分善于处理大量稀疏的id类特征;Deep部分利用神经网络表达能力强的特点,进行深层的特征交叉,挖掘藏在特征背后的数据模式。最终,利用逻辑回归模型,输出层将Wide部分和Deep部分组合起来,形成统一的模型。
下图展现了Google Play的推荐团队对业务场景的深刻理解。下图中可以详细地了解到Wide&Deep模型到底将哪些特征作为Deep部分的输入,将哪些特征作为Wide部分的输入。
Deep部分的输入是全量的特征向量,包括用户年龄(Age)、已安装应用数量(#App Installs)、设备类型(Device Class)、已安装应用(User Installed App)、曝光应用(Impression App)等特征。已安装应用、曝光应用等类别特征,需要经过Embedding层输入连接层,拼接成1200维的Embedding向量,再经过3层ReLU全连接层,最终输入LogLoss输出层。
数据集:https://archive.ics.uci.edu/ml/datasets/adult
对数据的处理包括:
针对Wide Model的输入:
tf.feature_column.categorical_column_with_vocabulary_list
tf.feature_column.bucketized_column
hash_bucket
操作,即指定类别数量,而后进行one-hot编码crossed_column
针对Deep Model的输入:
tf.feature_column.indicator_column、tf.feature_column.embedding_column
此外,读取数据时,对数据进行了缺失值缺省填充。
参考:https://github.com/ShaoQiBNU/wide_and_deep
import tensorflow as tf
# 1. 最基本的特征:
# Continuous columns. Wide和Deep组件都会用到。
age = tf.feature_column.numeric_column('age')
education_num = tf.feature_column.numeric_column('education_num')
capital_gain = tf.feature_column.numeric_column('capital_gain')
capital_loss = tf.feature_column.numeric_column('capital_loss')
hours_per_week = tf.feature_column.numeric_column('hours_per_week')
# 离散特征
education = tf.feature_column.categorical_column_with_vocabulary_list(
'education', ['Bachelors', 'HS-grad', '11th', 'Masters', '9th', 'Some-college',
'Assoc-acdm', 'Assoc-voc', '7th-8th', 'Doctorate', 'Prof-school',
'5th-6th', '10th', '1st-4th', 'Preschool', '12th'])
marital_status = tf.feature_column.categorical_column_with_vocabulary_list(
'marital_status', ['Married-civ-spouse', 'Divorced', 'Married-spouse-absent',
'Never-married', 'Separated', 'Married-AF-spouse', 'Widowed'])
relationship = tf.feature_column.categorical_column_with_vocabulary_list(
'relationship', ['Husband', 'Not-in-family', 'Wife', 'Own-child', 'Unmarried',
'Other-relative'])
workclass = tf.feature_column.categorical_column_with_vocabulary_list(
'workclass', ['Self-emp-not-inc', 'Private', 'State-gov', 'Federal-gov',
'Local-gov', '?', 'Self-emp-inc', 'Without-pay', 'Never-worked'])
# hash buckets
occupation = tf.feature_column.categorical_column_with_hash_bucket(
'occupation', hash_bucket_size=1000
)
# Transformations
age_buckets = tf.feature_column.bucketized_column(
age, boundaries=[18, 25, 30, 35, 40, 45, 50, 55, 60, 65]
)
# 2. The Wide Model: Linear Model with CrossedFeatureColumns
"""
The wide model is a linear model with a wide set of *sparse and crossed feature* columns
Wide部分用了一个规范化后的连续特征age_buckets,其他的连续特征没有使用
"""
base_columns = [
# 全是离散特征
education, marital_status, relationship, workclass, occupation, age_buckets,
]
crossed_columns = [
tf.feature_column.crossed_column(
['education', 'occupation'], hash_bucket_size=1000),
tf.feature_column.crossed_column(
[age_buckets, 'education', 'occupation'], hash_bucket_size=1000
)]
# 3. The Deep Model: Neural Network with Embeddings
"""
1. Sparse Features -> Embedding vector -> 串联(Embedding vector, 连续特征) -> 输入到Hidden Layer
2. Embedding Values随机初始化
3. 另外一种处理离散特征的方法是:one-hot or multi-hot representation. 但是仅仅适用于维度较低的,embedding是更加通用的做法
4. embedding_column(embedding);indicator_column(multi-hot);
"""
deep_columns = [
# 连续特征
age,
education_num,
capital_gain,
capital_loss,
hours_per_week,
# categorical_column表示成 multi-hot形式的 dense tensor
tf.feature_column.indicator_column(workclass),
tf.feature_column.indicator_column(education),
tf.feature_column.indicator_column(marital_status),
tf.feature_column.indicator_column(relationship),
# To show an example of embedding
tf.feature_column.embedding_column(occupation, dimension=8)
]
model_dir = './model/wide_deep'
# 4. Combine Wide & Deep
model = tf.estimator.DNNLinearCombinedClassifier(
model_dir=model_dir,
linear_feature_columns=base_columns + crossed_columns,
dnn_feature_columns=deep_columns,
dnn_hidden_units=[100, 50]
)
# 5. Train & Evaluate
_CSV_COLUMNS = [
'age', 'workclass', 'fnlwgt', 'education', 'education_num',
'marital_status', 'occupation', 'relationship', 'race', 'gender',
'capital_gain', 'capital_loss', 'hours_per_week', 'native_country',
'income_bracket'
]
#设置默认值
_CSV_COLUMN_DEFAULTS = [[0], [''], [0], [''], [0], [''], [''], [''], [''], [''],
[0], [0], [0], [''], ['']]
_NUM_EXAMPLES = {
'train': 32561,
'validation': 16281,
}
def input_fn(data_file, num_epochs, shuffle, batch_size):
"""为Estimator创建一个input function"""
assert tf.gfile.Exists(data_file), "{0} not found.".format(data_file)
def parse_csv(line):
print("Parsing", data_file)
# tf.decode_csv会把csv文件转换成 a list of Tensor,一列一个
# record_defaults用于指明每一列的缺失值用什么填充
columns = tf.decode_csv(line, record_defaults=_CSV_COLUMN_DEFAULTS)
features = dict(zip(_CSV_COLUMNS, columns))
labels = features.pop('income_bracket')
# tf.equal(x, y) 返回一个bool类型Tensor, 表示x == y, element-wise
return features, tf.equal(labels, '>50K')
dataset = tf.data.TextLineDataset(data_file).map(parse_csv, num_parallel_calls=5)
if shuffle:
dataset = dataset.shuffle(buffer_size=_NUM_EXAMPLES['train'] + _NUM_EXAMPLES['validation'])
dataset = dataset.repeat(num_epochs)
dataset = dataset.batch(batch_size)
iterator = dataset.make_one_shot_iterator()
batch_features, batch_labels = iterator.get_next()
return batch_features, batch_labels
# Train + Eval
train_epochs = 30
epochs_per_eval = 2
batch_size = 40
train_file = 'adult.data'
test_file = 'adult.test'
for n in range(train_epochs // epochs_per_eval):
model.train(input_fn=lambda: input_fn(train_file, epochs_per_eval, True, batch_size))
results = model.evaluate(input_fn=lambda: input_fn(test_file, 1, False, batch_size))
# Display Eval results
print("Results at epoch {0}".format((n+1) * epochs_per_eval))
print('-'*30)
for key in sorted(results):
print("{0:20}: {1:.4f}".format(key, results[key]))