亚马逊AWS官方博客

深度解析 Amazon Retail System 用户倾向预测模型以及使用 DJL 在 Apache Spark 进行深度学习推理任务

前言

如今,越来越多的公司正在为用户量身定制内容并产生个性化推荐。例如商家个性化产品推荐以及促销活动。为了产生最好的产品内容,我们首先需要推测用户的下一步动作。比如,用户会通过浏览一个商品并将其添加进购物车。如果我们在此时此刻推送此类商品的促销信息,那么用户会更有更大概率去购买商品。通过对于用户过去的行为以及喜好,我们可以推断出用户在未来潜在的行为倾向从而产生更好的个性化内容( 例如:邮件推送,广告)。

在亚马逊,我们使用Apache MXNet构造了一个多标签分类模型用于在数千类别里预测用户倾向。通过预测的结果,我们可以创造一种个性化的内容,帮助用户去选择最好的商品。这个文章将通过准备数据,模型构造和模型部署三个步骤来介绍在构造模型中我们遇到的各种挑战以及使用Deep Java Library (DJL)Apache Spark上进行大规模的深度学习推理任务。因为使用的工具完全开源,你也可以尝试去构建类似的应用。

准备数据

我们需要准备两组数据用于训练,分别是输入数据和标记数据。

输入数据

无论构建什么样的机器学习模型,其中一个很重要的部分就是输入数据。我们在这个模型中选择了多标记分类模型,这样做的好处是我们无需构建多个Pipeline(管道),只需要一个就可以进行推理任务。这个数据管道采集了来自于多个分类中用户的表现,然后通过这些信息,我们可以推断用户下一步在这些分类中的行为倾向。相比于构造大量的binary分类器,这种单模型多标记的设计减少了维护成本。

然后我们开始准备输入数据。我们为几亿亚马逊用户创建了几十万维度的特征向量,因为数据相对稀疏,我们使用了稀疏矩阵的形式来构造我们的输入数据:

上图展示了一个稀疏矩阵的表达类型。通过给出数据大小,数据内容和对应的坐标,我们可以构建出一个稀疏矩阵。相比于密集矩阵,稀疏矩阵可以帮助节约内存以及加快推理的速度。

标记数据

这个倾向模型将会判断用户对于一个类别的选择,在不同的地区这些类别的内容也不尽相同。对于每一个地区而言,我们都有几千种类别。每一个类别都使用简单的0或者1表达。1表示用户进行了这个类别的行为,0则反之。这些过去行为的类别,将会被用来评估用户在未来是否会进行同一种选择。下面就是一组以one-hot编码呈现的类别:

在这个案例中,用户A进行了类别1和类别3的动作,然后用户B之做了类别2的行为。

构造模型

模型架构

这个倾向模型是通过使用MXNet Python API构造的。总体来说,它是一个包含了稀疏输入层,隐藏层,和上千输出层的前馈神经网络。虽然输出结果可以很容易的用逻辑回归的方式表达,但是我们还是选择了使用softmax处理输出以达到多种类别判断结果的平衡。这样不仅可以帮助我们判断用户是否有可能会选择这个类别,也会给出相较于其他类别的相对可能性。下图就是一个这个网络结构的样例,一个简单的输入和4个类别输出:

下面是一段伪代码展示这个网络结构:

data <- 输入变量,类型 'csr' 矩阵
weight <- 变量,类型 'row_sparse' 矩阵
bias <- 变量,长度等于第一个隐藏层的大小
first_hidden_layer <- 第一个隐藏层,在 sparse.dot(data,weight) 和 bias 之间用了broadcast.add
hidden_layers <- 后续的隐藏层,activation 和 dropout 交替使用
classification_layer <- 分类层,N个 FullyConnected 层
output_layer <- SoftmaxOutput 输出层

模型训练

为了更好的训练模型,我们写了一个自定义的迭代器来处理稀疏输入然后将它转成MXNet的NDArray。在每一次迭代过程中,我们批量读入包含有用户ID,标记,以及稀疏特征的输入。然后我们使用这些信息构造MXNet CSR 矩阵来编码非0值以及它们对应的位置信息。下面是一个稀疏矩阵设定的案例,其中批大小为3,特征大小为5:

标记数据输入到MXNet Module中作为标准的MXNet NDArray。每一个在表里的数据点都代表了一个类别。然后我们进行了进一步转化,把0和1的值表达成了一个二维数组,以[是,不是]的形式来表达。比如如果数据中类别1的值为1,那么二维数组的表达就是 [1, 0]。最后我们将结果构建成一个二维矩阵,其中第一个维度是类别维度,第二个维度是这个类别的完成度。下面展示了一个批大小为3,类别为4的例子:

然后我们把特征和标记包装成MXNet Databatch用来训练。训练的损失函数为交叉熵损失。随着Apache MXNet技术的演进,我们也在逐步利用基于MXNet Gluon的Block架构来改进模型构造以及训练过程。

模型部署

部署中的挑战

因为数据量大,使用率高,在生产环境中,我们需要构建一个可以容易扩展且容易去维护的架构。在试过一些工具之后,我们认为Apache Spark可以帮助我们在需求的处理时间内扩展完成任务。通过一段时间的深度学习框架比较,我们最终选择了性能和精度最优的Apache MXNet作为我们深度学习的平台。

我们是一个由科研人员以及开发人员组成的开发团队。但是一些问题也由此产生:科学家主推Python(易用性)用于研究以及训练,而开发者则会选择采用基于Spark Java/Scala(稳定性)语言的开发平台。我们经过很长时间的讨论,无论是全部用Java进行,还是全用Python都不是合理解决方案。现在,通过使用DJL,我们可以无缝的将两者的优势结合在一起。因为其易用性以及稳定性的特色,我们基本不需要对代码作出太大改变就可以完成推理任务。然后考虑到它不受限制于深度学习引擎,使得日后我们部署基于其他平台的模型(TensorFlow, PyTorch) 也易如反掌。

推理任务部署

完成完整的推理任务十分简单,只需如下几步:

环境配置

通过添加如下依赖项,便可以完成在gradle上的设置

dependencies {
    compile group: 'ai.djl', name: 'repository', version: '0.4.1'
    compile group: 'ai.djl.mxnet', name: 'mxnet-engine', version: '0.4.1'
    runtime group: 'ai.djl.mxnet', name: 'mxnet-native-auto', version: '1.6.0'
}

推理逻辑

DJL使用NDList (List of NDArray)作为数据在推理任务中的传输格式。它提供了一个Translator接口(翻译器)作为前处理和后处理的模式:前处理将输入数据转化成NDArray添加进NDList,后处理将NDArray转化成输出数据。值得一提的是DJL支持稀疏矩阵输入也同时支持批量输入。

首先,我们从本地路径读入模型:

Path modelDir = Paths.get("/Your/Model/Directory");
String modelName = "your_model_name";
Model model = Model.newInstance();
model.load(modelDir, modelName);

我们设定了Translator来把特征向量转化为NDList然后把输出结果转成

class InputOutputTranslator 
        extends Translator[Array[SparseVector], Array[Array[Float]]] {

  override def processInput(translatorContext: TranslatorContext, 
                           input: Array[SparseVector]): NDList = {
   // 转化 Array[SparseVector] 到 CSR NDArray
  val indices: Array[Long] = ...
  val indptr: Array[Long] = ....
  val data: Buffer = ....
  val shape: Shape = ....
  val csrFeatures: NDArray = model.getNDManager.createCSR(data, indptr, indices, shape)
  new NDList(csrFeatures)
  }
 
 
 override def processOutput(translatorContext: TranslatorContext, 
                            predictionNDList: NDList): Array[Array[Float]] = {
 // 因为我们是批处理对多类别分类, 所以输出格式为:Array[Array[Float]]
 // 每组 Array[Float] 代表了每个类别的分类结果. 内部每个值代表了每个标记的预测结果 
   ....
 }

}

然后我们将Translator输入进model产生Predictor。Predictor是一个线程安全,可进行推理的结构体。

val predictor: Predictor[Array[SparseVector], Array[Array[Float]]] = 
       model.newPredictor(new InputOutputTranslator)
       
val featureVectorArray : Array[SparseVector] = ...  
val predictions: Array[Array[Float]] = predictor.predict(featureVectorArray)

最后我们将label对应的类别应用在数据上产生如下数据:

{
 [
  "customerId": 1
  "predictions": [
    "category_a": 0.813611214,
    "category_b": 0.580259696,
    "category_c": 7.5886305E-4,
    "category_d": 0.7010947181,
    ....
  ]
 ],
 [
  "customerId": 2
  "predictions": [
    "category_a": 0.0066125533,
    "category_b": 0.304356237,
    "category_c": 0.908850298,
    "category_d": 2.3412544E-6,
    ....
  ]
 ],
 ...
}

性能表现

在使用DJL之前,完成一次批量推理任务大概需要24小时并且附带着很多内存管理的问题。DJL显著缩短了推理时间,只需要几小时。曾经我们需要对每一个模型花费两周时间进行内存管理调试。使用DJL之后,我们无需在这上面花费更多的时间。上线模型的速度也从曾经的一个月,缩短到两周。

关于DJL

DJL是亚马逊云服务在2019年re:Invent大会推出的专为Java开发者量身定制的深度学习框架,现已运行在亚马逊数以百万的推理任务中。如果要总结DJL的主要特色,那么就是如下三点:

  • DJL不设限制于后端引擎:用户可以轻松的使用 MXNet, PyTorch, TensorFlow和fastText来在Java上做模型训练和推理。
  • DJL的算子设计无限趋近于numpy:它的使用体验上和numpy基本是无缝的,切换引擎也不会造成结果改变。
  • DJL优秀的内存管理以及效率机制:DJL拥有自己的资源回收机制,100个小时连续推理也不会内存溢出。

想了解更多,请参见下面几个链接:

https://djl.ai

https://github.com/awslabs/djl

也欢迎加入DJL的slack论坛

本篇作者

Vaibhav Goel

来自消费者行为分析团队的高级软件开发工程师

兰青

Qing Lan, AWS AI 软件开发工程师。DJL深度学习框架作者之一,Apache软件基金会项目管理委员会成员。