wide and deep 模型的核心思想是结合线性模型的记忆能力(memorization)和 DNN 模型的泛化能力(generalization),在训练过程中同时优化 2 个模型的参数,从而达到整体模型的预测能力最优。
1. Wide&Deep模型介绍
wide部分,也就是广义线性模型,广泛用于大规模的回归和分类问题。一般:线性模型 + 特征交叉
- 优点是具有较强的记忆能力,并且可解释性很强
- 缺点是泛化能力弱,需要通过大量的特征工程工作【由人来实现】来提高模型的泛化能力。
deep部分,也就是深度神经网络模型。
- 优点是只需要少量的特征工程就可以有较强的泛化能力,能够发现未知的特征组合。一般来说,会将初始的高维度的稀疏特征向量通过Embedding方法转化成低维度的稠密特征向量。
- 缺点是当用户-商品交互行为比较稀疏且排名较高的时候,通过Embedding方法转化的低维度稠密特征向量作为神经网络的输入很容易产生过拟合的情况。
记忆能力Memorization趋向于更加保守,推荐用户之前有过行为的items。相比之下,泛化能力generalization更加趋向于提高推荐系统的多样性(diversity)。
wide&deep模型则是将上述两个模型的优点结合起来,兼具记忆能力和泛化能力。
其模型图如下:
2. Wide&Deep模型框架
2.1 Wide模型——广义线性模型——记忆能力
Wide部分如上图是的广义线性模型。输入特征可以是连续特征,也可以是稀疏的离散特征,离散特征之间进行交叉后可以构成更高维的离散特征。线性模型训练中通过 L1 正则化,能够很快收敛到有效的特征组合中。最重要的转换之一是交叉乘积转换,定义为
$ \Large \phi_{k}(\mathbf{x})=\prod_{i=1}^{d} x_{i}^{c_{k i}} \quad c_{k i} \in{0,1} $
是一个布尔变量,其取值为:如果第i个特征是第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这款应用(像一个深刻的记忆点一样印在脑海中),这就是所谓的**模型的“记忆能力”。**像逻辑回归这类简单模型,如果发现这样的“强特征”,则其相应的权重就会在模型训练过程中被调整得非常大,这样就实现了对这个特征的直接记忆。
2.2 Deep模型——深度神经网络——泛化能力
deep 端对应的是 DNN 模型,每个特征对应一个低维的实数向量,称之为特征的 embedding。DNN 模型通过反向传播调整隐藏层的权重,并且更新特征的 embedding。
首先对于类别特征,比如对于类别型特征,首先需要将这些高维稀疏特征 转换成低维稠密的embedding向量。随机初始化embedding向量,然后在模型训练中最小化最终损失函数。这些低维稠密向量馈送到前向传递中的神经网络的隐藏层中。 具体来说,每个隐藏层执行以下计算:
l
是层数,f
是激活函数,通常使用RELU函数,是是第l
层的激活、偏置和模型权重。
Deep部分的主要作用是让模型具有“泛化能力”。“泛化能力”可以被理解为模型传递特征的相关性,以及发掘稀疏甚至从未出现过的稀有特征与最终标签相关性的能力。深度神经网络通过特征的多次自动组合,可以深度发掘数据中潜在的模式,即使是非常稀疏的特征向量输入,也能得到较稳定平滑的推荐概率,这就是简单模型所缺乏的“泛化能力”。
2.3 Wide&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(中)所示。对于逻辑回归问题,模型的预测是:
其中,where is the binary class label, is the sigmoid function, are the cross product transformations of the original features , and is the bias term. is the vector of all wide model weights, and are the weights applied on the final activations .
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输出层。
3. Wide&Deep实现
3.1 数据集介绍
数据集:https://archive.ics.uci.edu/ml/datasets/adult
3.2 数据处理
对数据的处理包括:
针对Wide Model的输入:
- 对离散特征进行one-hot编码,
tf.feature_column.categorical_column_with_vocabulary_list
- 对年龄进行分桶,即划分年龄段,
tf.feature_column.bucketized_column
- 对职业进行了
hash_bucket
操作,即指定类别数量,而后进行one-hot编码 - 人工特征工程,构造二阶或者三阶的特征交互
crossed_column
针对Deep Model的输入:
- 对离散特征进行multi-hot编码、embedding操作,
tf.feature_column.indicator_column、tf.feature_column.embedding_column
- 连续特征
此外,读取数据时,对数据进行了缺失值缺省填充。
3.3 模型实现
参考: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]))