用NumPy手工打造 Wide & Deep

浏览: 1868

作者:  石塔西

爱好机器学习算法,以及军事和历史

知乎ID:https://www.zhihu.com/people/si-ta-xi

自从我的《看Google如何实现Wide & Deep模型》发表之后,很多同学私信我,询问我的Wide & Deep实现的源代码。其实我的实现在扩展性、易用性上肯定不能和TensorFlow自带的实现相比,但是又充斥着一些业务细节。这些业务细节,技术本身没有什么难度,但又很敏感,清理起来很麻烦,所以我的那个TensorFlow实现暂时没有开源的计划了。

近年来,深度学习在推荐领域的应用得到了越来越多的关注,一系列新的算法,各种NN,各种FM,纷至而来,让人目不暇接,眼花缭乱。但是,在推荐领域经历了几年的摸爬滚打之后,我却开始了“返璞归真”:

一来,各种NN与FM,看似繁杂。实际上,只要把握住它们的发展脉络,即“如何兼顾记忆与扩展”、“如何处理高维、稀疏的类别特征”、“如何实现特征交叉”(见《看Google如何实现Wide & Deep模型》),你就会发现各种高大上的新算法不过是沿着这条脉络,在某个枝叉上的修补。这样一来,各种NN与FM,在你脑中,就不再是一个个独立的缩写,而能够编织成网,融会贯通。

二来,与其追读每篇新论文,调用作者提供的开源实现,每个模型都“走马观花”。不如找一个经典模型,自己从头到尾实现一遍,才理解得更加通透。

在我看来,已经不算新的Wide & Deep(WDL)就是这样一个经典模型,在“如何兼顾记忆与扩展”、“如何处理高维、稀疏的类别特征”、“如何实现特征交叉”三个方面,表现得很充分。为此,在上周,我花了一个星期的业余时间,用NumPy将Wide & Deep从头到尾实现了一遍,重温了算法的各种技术细节,受益匪浅。

https://github.com/stasi009/NumpyWDL

尽管在细节上还有待完善,我将它开源出来,希望和感兴趣的同学共同探讨。因为没有文档,在这里小撰一文,希望帮感兴趣的同学,理解我的代码。

我的实现基本模仿了tf.estimator.DNNLinearCombinedClassifier的结构。在手工实现Wide & Deep的过程中,我主要考虑如下三个技术关键点:

  • 模块化设计

  • Embedding的稀疏实现

  • Embedding的权重共享

模块化设计

首先区分几个概念。比如,我们从“活跃App”+“新安装App”+“卸载App”三个方面来描述一个用户的手机使用习惯。而每个方面可以用“微信:0.9,微博:0.5,淘宝:0.3,……”这样的ke-value-pair来表示

  • “活跃App”, “新安装App”, “卸载App”被称为三个Field

  • “活跃 微信","安装 微博","卸载 淘宝”被称为Feature,分别隶属于某个Field。在经过字典映射后,每个Feature都有自己的feature id(整数)和feature value(浮点数)

  • “微信","微博","淘宝”都来自一个叫“App”的Vocabulary。在以上例子中,App Vocabulary为“活跃App”, “新安装App”, “卸载App”三个Field所共享

WDL在最上层其实就是一个LR模型,probability=sigmoid(logit),而

WDL的最上层

为此,总体上我的WDL由如下几个部分实现

  • DNN部分由dnn.py中的DeepNetwork实现

  • Wide部分由wide_layer.py中的WideLayer实现

  • WideLayer为每个field生成FtrlEstimator实例,负责用FTRL算法优化这个field下feature的权重

因为Wide主要功能是“记忆”,所以常接入一些ID类的特征,非常稀疏,所以需要使用FTRL算法来优化,以充分利用数据的稀疏性,并使得到的权重尽可能稀疏。FTRL的实现就是按照经典论文《Ad Click Prediction: a View from the Trenches》中Algorithm 1实现的。值得一提的就是,与我之前见过的一些实现不同,我的FTRL实现没有将所有特征放置在同一个特征空间中并统一编号,而是按照Field划分特征空间,每个Field单独存储、优化权重。这样做有三个好处:

  • 代码清晰、易读

  • 方便扩展。比如某个Field下新增/删除了一个Feature,只有这个Field下的Feature需要重新编号,其他Field不受影响。

  • 各个Field之间可以并行计算

而这种"将每个Field单独划分成一个模块"的做法,也是TensorFlow实现Wide侧的手法。(见《看Google如何实现Wide & Deep模型(3)》)https://zhuanlan.zhihu.com/p/48251812

讲完了Wide侧各Field的模块化实现,还要考虑Deep侧与Wide侧两个模块是如何设计的。设计主要考虑的是代码复用,同样的Deep侧与Wide侧代码,既能合起来实现Wide & Deep,也能够单独使用来实现DNN与LR。

但是,有一个问题是,DNN是基于Mini-Batch优化的,而Wide侧使用的FTRL是一个Online Learning算法。Wide侧得到某个样本的Wide_Logit之后,需要与Deep侧得到的Deep_Logit相加,得到总的Logit之后,才能计算梯度,才能更新权重。

我的方法是让外界传入一个proba_fn函数来根据logit计算概率。视Wide单独使用还是与Deep联合使用,proba_fn实现如下两种逻辑

  • 当Wide侧单独使用来实现LR时,probability=sigmoid(logit)

  • 在Wide & Deep中,

    • Deep侧先完成前代,得到这个batch下所有样本的Deep_Logits。

    • Wide侧在逐一学习每个样本时,先得到这条样本的Wide_Logit,再去已经计算好的Deep_Logits中找到这条样本的Deep_Logit,probability=sigmoid(wide_logit+deep_logit)

    • 再计算梯度,开始回代。

这部分逻辑见WideDeepEstimator中的_predict_proba与train_batch两个函数。

class WideDeepEstimator(BaseEstimator):
   def __init__(self, wide_hparams, deep_hparams, data_source):
       self._current_deep_logits = None

       self._wide_layer = WideLayer(......,
                                    proba_fn=self._predict_proba)

       self._dnn = DeepNetwork(......)

       super().__init__(data_source)

   def _predict_proba(self, example_idx, wide_logit):
       deep_logit = self._current_deep_logits[example_idx]
       logit = deep_logit + wide_logit
       return 1 / (1 + np.exp(-logit))

   def train_batch(self, features, labels):
       self._current_deep_logits = self._dnn.forward(features)

       pred_probas = self._wide_layer.train(features, labels)

       self._dnn.backward(grads2logits=pred_probas - labels)

       return pred_probas

Embedding的稀疏实现

正如我之前所论述的,深度学习在推荐、搜索领域的运用,是围绕着稀疏的ID类特征所展开的,其主要方法就是Embedding,变ID类特征的“精确匹配”为“模糊查找”,以增强扩展。而在实现Embedding时,需要注意两点

  • 与传统MLP接收稠密输入不同,Embedding的输入高维且稀疏,One/Multi-Hot-Encoding之后进行矩阵运算代价太大,所以需要实现稀疏的前代与回代。

  • 推荐系统中的Embedding与NLP中的Embedding也有不同。

    • NLP中,一句话的一个位置上只有一个词,所以Embedding往往变成了,从Embedding矩阵抽取与词对应的行上的行向量

    • 推荐系统中,一个Field下往往有多个Feature,Embedding是将多个Feature Embedding合并成一个向量,即所谓的Pooling。比如某个App Field下的Feature有"微信:0.9,微博:0.5,淘宝:0.3",Embedding=0.9*微信向量+0.5*微博向量+0.3*淘宝向量

如何表示稀疏输入,很费了一番思考。

  • 一开始想模仿TensorFlow,用sp_ids, sp_weights两上SparseTensor来表示,但是这两个SparseTensor中的indices, dense_shape必须完全相同,是重复的。既浪费空间,而且重复的东西就会带来“不一致”的隐患。

  • 后来考虑使用KVPair = namedtuple('KVPair', ['example_index', 'feature_id', 'feature_value'])表示一个非零特征。整个稀疏输入就是list of KVPair,程序处理上是方便了很多,但是每个KVPair都是一个namedtuple,生成了大多的small object,会给GC造成压力。

  • 目前决定采用3个list的方式来表示稀疏输入

    • example_indices: 是[n_non_zeros]的整数数组,表示样本在batch中的序号。而且要求其中的数值是从小到大排好序的

    • feature_ids: 是[n_non_zeros]的整数数组,表示非零特征的序号,可以重复

    • feature_values: 是[n_non_zeros]的浮点数组,表示非零特征的数值

基于以上稀疏输入的表示,Embedding的实现,见embedding_layer.EmbeddingLayer这个类。可见无论前代与回代,只有原始输入中的非零特征参与计算。

class EmbeddingLayer:
   def __init__(self, W, vocab_name, field_name):
       """        :param W: dense weight matrix, [vocab_size,embed_size]        :param b: bias, [embed_size]        """
       self.vocab_name = vocab_name
       self.field_name = field_name
       self._W = W
       self._last_input = None

   def forward(self, X):
       """        :param X: SparseInput        :return: [batch_size, embed_size]        """
       self._last_input = X

       # output: [batch_size, embed_size]
       output = np.zeros((X.n_total_examples, self._W.shape[1]))

       for example_idx, feat_id, feat_val in X.iterate_non_zeros():
           embedding = self._W[feat_id, :]
           output[example_idx, :] += embedding * feat_val

       return output

   def backward(self, prev_grads):
       """        :param prev_grads: [batch_size, embed_size]        :return: dw        """
       dW = {}

       for example_idx, feat_id, feat_val in self._last_input.iterate_non_zeros():
           # [1,embed_size]
           grad_from_one_example = prev_grads[example_idx, :] * feat_val

           if feat_id in dW:
               dW[feat_id] += grad_from_one_example

           else:
               dW[feat_id] = grad_from_one_example

       return dW

在利用计算好的导数对权重进行修正时,对Embedding矩阵的梯度进行特殊处理,只更新局部,见optimization.py中Adagrad.update函数。

class Adagrad:
   def __init__(self, lr):
       self._lr = lr
       # variable name => sum of gradient square (also a vector)
       self._sum_grad2 = {}

   def update(self, variables, gradients):
       for gradname, gradient in gradients.items():
           # ------ update cache
           g2 = gradient * gradient
           if gradname in self._sum_grad2:
               self._sum_grad2[gradname] += g2
           else:
               self._sum_grad2[gradname] = g2

           # ------ calculate delta
           delta = self._lr * gradient / (np.sqrt(self._sum_grad2[gradname]) + 1e-6)

           # ------ update
           if '@' in gradname:
               # 对应着稀疏输入的权重与梯度,gradients中的key遵循着'vocab_name@feat_id'的格式
               varname, row = gradname.split('@')
               row = int(row)

               variable = variables[varname]
               variable[row, :] -= delta
           else:
               variable = variables[gradname]
               variable -= delta

Embedding的权重共享

如前所述,多个Field可能共享一个Vocabulary,所以要求在实现Embedding时也必须支持这一共享机制。否则,既可能浪费内存,又可能因为各Field的稀疏性不一致而导致训练不充分。https://zhuanlan.zhihu.com/p/48057256

为此,我设计了一个EmbeddingCombineLayer类。

  • 这个类先将所有要用到的“字典”的Embedding矩阵初始化,

  • 再将每个Field与其对应的“字典”的Embedding矩阵联系起来。

  • 只需要将多个field指向同一个vocabulary name,就可以让这个vocabulary的Embedding为多个field所共享。

class EmbeddingCombineLayer:
   def __init__(self, vocab_infos):
       """        :param vocab_infos: a list of tuple, each tuple is (vocab_name, vocab_size, embed_size)        """
       self._weights = {}  # vocab_name ==> weight
       for vocab_name, vocab_size, embed_size in vocab_infos:
           stddev = 1 / np.sqrt(embed_size)
           initializer = TruncatedNormal(mean=0,stddev=stddev,lower=-2 * stddev,upper=2 * stddev)
           self._weights[vocab_name] = initializer(shape=[vocab_size, embed_size])

   def add_embedding(self, vocab_name, field_name):
       weight = self._weights[vocab_name]
       layer = EmbeddingLayer(W=weight, vocab_name=vocab_name, field_name=field_name)
       self._embed_layers.append(layer)

关键在于回代时,上层传入的“Loss对本层输出的导数”是[batch_size,本层所有embedding size之和]。在EmbeddingCombineLayer.backward中,

  • 需要将以上梯度拆解,交给每个Field的Embedding层自己去回代。

  • 最后还要聚合梯度,比如“活跃App”中对“微信”有梯度,“新安装App”对“微信”也有梯度,最终“微信”embedding向量的梯度应该是以上二者之和。

   def backward(self, prev_grads):
       """        :param prev_grads:  [batch_size, sum of all embed-layer's embed_size]                            上一层传入的, Loss对本层输出的梯度        """
       assert prev_grads.shape[1] == self.output_dim

       # 因为output是每列输出的拼接,自然上一层输入的导数也是各层所需要导数的拼接
       # prev_grads_splits是一个数组,存储对应各层的导数
       col_sizes = [layer.output_dim for layer in self._embed_layers]
       prev_grads_splits = utils.split_column(prev_grads, col_sizes)

       self._grads_to_embed.clear()  # reset
       for layer, layer_prev_grads in zip(self._embed_layers, prev_grads_splits):
           # layer_prev_grads: 上一层传入的,Loss对某个layer的输出的梯度
           # layer_grads_to_feat_embed: dict, feat_id==>grads,
           # 由这一个layer造成对某vocab的embedding矩阵的某feat_id对应行的梯度
           layer_grads_to_embed = layer.backward(layer_prev_grads)

           for feat_id, g in layer_grads_to_embed.items():
               # 表示"对某个vocab的embedding weight中的第feat_id行的总导数"
               key = "{}@{}".format(layer.vocab_name, feat_id)

               if key in self._grads_to_embed:
                   self._grads_to_embed[key] += g
               else:
                   self._grads_to_embed[key] = g

测试效果

和TensorFlow Guide一样,在Census Income Data Set数据集上进行了测试。测试结果如下

  • 性能指标上,Wide & Deep > Deep > Wide,符合我们的预期

  • TensorFlow Guide上的基线准确率是0.83,而我的实现中每个模型都超过基线,可以从一个侧面反映我的实现的正确性。

三种算法的性能对比

Wide & Deep模型训练时AUC曲线如下所示

而且,在我的笔记本上跑我的代码,每秒能够处理10000上下的样本,说明效率上也还不错

后记

本文简单介绍了我用NumPy手工实现的Wide & Deep模型,重点介绍了如下技术关键点:

  • 如何模块化设计

  • 如何实现Embedding层稀疏地前代与回代

  • 如何实现Embedding层的权重共享

毕竟是我个人业余时间的练习作品,时间仓促,还有很多地方需要改进、完善:

  • 实现Dropout与Batch Normalization。不过,Dropout源于Computer Vision,其输入都是稠密的图像,与推荐、搜索领域稀疏的输入,有很大不同。根据Google与Airbnb的经验,Dropout应用于推荐任务,不仅不会提升,反而会恶化性能。

  • 实现更多的经典优化算法,比如Momentum, RMSprop, Adam等算法。

  • 对比TensorFlow的实现,我没有实现众多的Feature Column。其实,Feature Column对我们的重要性一点也不亚于DNNLinearCombinedClassifier。有时间,我一定补上。实现各种Feature Column在技术上没有什么难度,就是个“力气活”。

  • 如前所述,Wide & Deep本质上就是一个LR,而且Deep侧贡献的logit、各Field贡献的logit相互解耦。因此,可以考虑使它们的前代与回代并行化,实现Feature Parallelism。

  • 我的上一篇文章《走马观花Google TF-Ranking的源代码》觉得TF-Ranking不太好用。现在,既然我已经实现了Wide & Deep,稍加改动,就能够将Wide & Deep与Learning To Rank结合,实现pairwise/listwise的排序算法。

通过从头到尾实现一遍Wide & Deep,我进一步加深了对推荐系统中的深度学习算法的理解,受益匪浅。欢迎感兴趣的同学下载我的代码,欢迎同道中人共同探讨。

Python的爱好者社区历史文章大合集

Python的爱好者社区历史文章列表

福利:文末扫码关注公众号,“Python爱好者社区”,开始学习Python课程:

关注后在公众号内回复“ 课程 ”即可获取:

小编的转行入职数据科学(数据分析挖掘/机器学习方向)【最新免费】

小编的Python的入门免费视频课程

小编的Python的快速上手matplotlib可视化库!

崔老师爬虫实战案例免费学习视频。

陈老师数据分析报告扩展制作免费学习视频。

玩转大数据分析!Spark2.X + Python精华实战课程免费学习视频。

推荐 0
本文由 Python爱好者社区 创作,采用 知识共享署名-相同方式共享 3.0 中国大陆许可协议 进行许可。
转载、引用前需联系作者,并署名作者且注明文章出处。
本站文章版权归原作者及原出处所有 。内容为作者个人观点, 并不代表本站赞同其观点和对其真实性负责。本站是一个个人学习交流的平台,并不用于任何商业目的,如果有任何问题,请及时联系我们,我们将根据著作权人的要求,立即更正或者删除有关内容。本站拥有对此声明的最终解释权。

0 个评论

要回复文章请先登录注册