作者:Pratik Bhavsar

编译:ronghuaiyang

导读: 模型训练只是产品化中的很小的一个环节。

问题描述 💰

最近,我一直在巩固在不同 ML 项目中工作的经验。我将从我最近的 NLP 项目的角度来讲述这个故事,对短语进行分类 — 一个多类单标签问题。

NLP 的 Central embedder 结构


团队结构 👪

搭建人工智能团队是相当棘手的。如果公司内部人员不具备这些技能,你就必须招聘。由于每个项目都有一个开始和结束时间,很难从头开始让整个团队都参与进来。幸运的是,项目需要的大多数的人我们都有,我们的小团队包括以下成员。

  • 产品负责人(1) — 制定项目需求
  • 项目经理(1) — 负责项目计划和技术问题
  • 敏捷主管(1) — 确保敏捷执行并解决障碍
  • 数据分析师(2) — 对领域知识进行迁移,并协助从各种数据存储收集数据
  • 数据科学家(2) — 制定数据 pipelines,ML POC,软件工程和部署计划。设计和构建部署 pipelines、Python 软件工程、服务器规模、无服务器 pipelines 和再训练模型

数据 📊这是一个 NLP 项目,数据在 RDBMS 数据库中。坦率地说,我们很幸运,不需要做太多就能得到训练数据。查询所有权属于与我们一起工作的数据团队,而数据 pipeline 是由数据科学家创建的。

如果你没有训练数据,你可能必须遵循以下路线之一。

创建训练数据需要时间,最好有一个标注工具。如果你没有,你可以使用 Spacy team 的 prodi.gy 获取文本数据。

还有另一个开源工具 Doccano。

历史优化

当我们训练模型时,我们意识到我们可能不需要所有的数据,在我们的案例中,这些数据已经有 5 年了。我们尝试用不同数量的历史数据建模,发现 3 年足够了。

在不牺牲度量标准的前提下,尽可能少地使用历史可以让我们更快地训练模型,更好地学习最近的模式。


POC 👻

经过对经典和深度学习的多次迭代,我们决定采用(feature extractor + head)基于单词嵌入的方法来完成分类任务。

我们还必须处理数据中的不平衡,并尝试了许多技术。

  • 偏采样和降采样
  • 用 scikit-imblearn 进行过采样
  • 用 scikit-imblearn 进行降采样
  • model.fit 中的类别权重

度量 🙈

由于我们处理的是一个多类(~190)不平衡数据集,我们选择加权 f1 作为度量,因为它对少数类健壮且容易理解。


软件工程 👀

工程结构


text_classifier

│

├───notebooks

│   ├───classifier-a

│   ├───classifier-b

│   └───classifier-c

│       ├───data(common sample training data)

│       ├───preparation

│       ├───modelling

│       ├───evaluation

│       └───final

│

├───tc(acronym of text_classifier. contains core modules)

│   │   queries.py

│   │   base.py

│   │   preparation.py

│   │   models.py

│   │   train.py

│   │   postprocessing.py

│   │   predict.py

│   │   document_processing.py

│   │

│   ├───config

│   │       input_data_config.py

│   │       splunklog_config.py

│   │       training_config.py

│   │

│   ├───nlp

│   │       embeddings.py

│   │       preprocessors.py

│   │

│   └───utilities

│           helpers.py

│           loggers.py

│           metrics.py

│           plotting.py

│           preprocessors.py

│           aws.py

│           db_connectors.py

│           html.py

│

├───data

│       ├───classifier-a

│       ├───classifier-b

│       └───classifier-c

│

├───API

│       router.py

│       flask_app.py

│       request_handlers.py

│       inference.py

│

├───env

│       base.yaml

│       cpu.yaml

│       gpu.yaml

│       build.yaml

│

│

├───deployment

│       └───terraform

│

├───persistence

│

├───scripts

│

└───tests

为了让代码以结构化的方式发展,在一开始就设置好项目结构是非常重要的。我们花了很多时间,讨论了很多次才达成一致。看看这个,从基本的框架开始。

这就是我们训练 AWS EC2/local 模型和 AWS s3 上的备份代码、数据、模型和报告的方式。目录结构是由 prepare 和 train 类自动创建的。


data

└───region(we have models trained on many regions)

    ├───model-a(model for predicting a)

    ├───model-b(model for predicting b)

    └───model-c(model for predicting c)

        └───2019-08-01(model version as per date)

            ├───code.zip(codebase backup)

            ├───raw(fetched-data)

            ├───processed(training-data)

            └───models

                └───1(NN-architecture-type)

                    ├───model.h5

                    ├───encoders.pkl

                    └───reports

                        ├───train_report.csv

                        ├───test_report.csv

                        ├───keras_train_history.csv

                        └───keras_test_history.csv

在进行 POCs 时,我们不知道哪些模块将成为最终解决方案的一部分,因此不太重视模块化或可重用性。但是一旦我们完成了 POC,我们应该将最终的代码合并到 notebooks 中,并将它们保存在**/notebook /final**中。我们有一个 notebook 用于数据准备步骤,另一个用于建模。


notebooks

    └───classifier-a

        ├───data

        ├───preparation

        ├───modelling

        ├───evaluation

        └───final

            ├───preparation.ipynb

            └───modelling.ipynb

这些 notebooks 也成为了我们的展示材料。

继承/引用 ⏬

我们在写训练类的时候,考虑到了预测类会重用训练类。所以每次我们在预处理或编码步骤上做任何改变时,我们只需要在训练类上做。

推理类

我们的推理模块使用 predict 类,并对数据进行某些检查,以防止出现故障,比如空字符串。我们还将结果保存到一个中央 PostgreSQL 推理数据库中。

我们的路由器是一个简单的 flask 路由器对不同的模型有不同的方法。所有重要的异常都被捕获并与适当的消息一起返回。

推理数据库

我们保存所有的推理结果,以分析生产中的模型,如输入值,预测值,模型版本,模型类型,概率等。

我们的下一步是创建用于创建 ML 性能报告的 API。

设计模式 🐗

Singleton 模式 来初始化嵌入,并为不同的模型使用相同的对象。这节省了 ec2 的内存使用。

工厂模式 用不同的配置初始化模型训练类。

装饰模式

  1. 时间函数的装饰器,以了解哪些函数花费更多的时间。
  2. 一个装饰器,用于在 DB 查询失败时重试。这确保了数据的获取,并且不会使训练 pipeline 失败。
  3. 用于函数执行开始和结束的 Splunk 日志记录的装饰器。我们在 Splunk 和 AWS Cloudwatch 上保存日志。

扩展性 🌀

从一开始,我们就想开发代码库来使用它处理不同的数据。所以我们通过 configs 来参数化输入数据和模型超参数。


重构 🐵

在我们完成了这个项目之后,我们有了许多可以用于任何项目的公用工具。

内部开源

占项目总时间的百分比数字。这对于不同项目来说是不同的。

内部开源允许贡献者组成一个生态系统,为每个人开发和使用可重用组件。我们发现好的软件工程要比 POC 花费更多的时间。通过创建库,开发人员和数据科学家现在可以集中精力更快地开发和部署模型。

去掉 common utilities 还使得项目代码库更轻,更容易理解。


部署 🐙

AWS 基础设施

  • S3
  • EC2
  • ECR
  • ECS
  • Cloudwatch

我们使用 Conda, Docker, Terraform, Jenkins, ECR, ALB 和 ECS 作为我们的部署 pipeline。

![](Productionizing NLP Models.assets/1_F1TxMnnwoZACG5uPXNngWw.png)

环境 🛠

经过大量的实验和讨论,我们选择通过 4 个 yml 配置来处理 windows/linux 上的所有 pip/conda 对 cpu/gpu 的 python 依赖。

  • base.yml → 所有非深度学习包通过 pip 和 conda 安装
  • cpu.yml → Tensorflow CPU 通过 pip 安装 (pip 不会安装 cuda toolkit 和 cuDNN,这样让我们的环境更轻)
  • gpu.yml → Tensorflow gpu 通过 conda 安装 (conda 可以搞定 cuda toolkit 和 cudnn)
  • build.yml → 其他服务需要的包通过 pip 和 conda 安装。我们使用 gunicorn 进行模型服务。gunicorn 在 windows 中不可用,我们将它安装在 Linux docker 环境中用于生产。

本地测试的环境


conda env create -f env/base.ymlconda env update -f env/cpu.yml

训练模型的 Docker/EC2 环境


conda env create -f env/base.ymlconda env update -f env/gpu.yml

模型服务的 Docker/EC2 环境 在 CPU 实例上 (通过 Docker 容器)


conda env create -f env/base.ymlconda env update -f env/cpu.ymlconda env update -f env/build.yml

每次我们开始使用一个新包时,我们都会手动将其添加到 yml 中。我们尝试了 pipreqsconda export — no-builds 来自动导出包,但是发现很多依赖项和 package-build-info 也被导出,使得我们的环境看起来很脏。通过手动添加包,我们可以确定包的使用情况,也可以在 POC 之后删除一些未使用的包。

最初,我们使用 AllenNLP 来生成嵌入,并在我们的环境中添加了许多包。由于我们使用 Keras 进行建模,我们决定完全切换 tensorflow 生态系统,从 tensorflow-hub 获取模型。

负载测试 💥

最初,负载测试非常简单。通过使用 JMeter 测试负载情况,我们优化了这些参数的服务。

  • ECS 中的任务的数量
  • 任务中 gunicorn workers 的数量
  • 每个 worker 的线程数

我们对自动扩容给出了一个很好的想法,它可以通过平均/最大 RAM 使用量、平均/最大 CPU 使用量和 API 调用的数量来触发。任何方法对我们都不起作用,因为我们不想浪费 EC2 的资源来为自动扩容保留空间。没有保留空间导致了需要创建 EC2,这需要时间。因为知道创建实例需要 1-5 分钟,所以所有请求都将转到现有的服务,而不会向部署在新 EC2 上的新任务发送任何内容。

我们也考虑过 AWS Fargate,但它比 EC2 贵两倍。

最后唯一有意义的是分配全部 CPU 和一半 RAM 给任务。自动扩容需要 RAM,所以我们保留空间用于部署更多的任务,但确保不会浪费 CPU,因为它是瓶颈。

我们选择 AWS t3 实例而不是 t2 作为其默认的 burstable 行为,这有助于我们使用累积的积分。


成本优化 🔥

缓存

您可能知道,与 word2vec 和 glove 这些固定的非上下文嵌入的词汇不同,像 ELMo 和 BERT 这样的语言模型是有上下文的,没有任何固定的词汇表。这样做的缺点是每次都需要通过模型计算这个词的嵌入。这给我们带来了相当大的麻烦,因为我们看到了模型处理导致的大量 CPU 峰值。

由于我们的文本短语的平均长度为 5,并且是有很多重复的,所以我们缓存了短语的嵌入以避免重新计算。我们的代码可以通过添加这个小方法我们得到了一个 20 x 加速 🏄。


#Earlier

language_model.get_sentence_embedding(sentence)

#Later

from cachetools import LRUCache, cached

@cached(cache=LRUCache(maxsize=10000))

def get_sentence_embedding(sentence):

    return language_model.get_sentence_embedding(sentence)

缓存大小优化

因为 LRU(最近最少使用)缓存的时间复杂度是 O(log(n)),缓存越小越好。但我们也知道,我们想要尽可能多地缓存。所以越大越好。这意味着我们必须根据经验优化缓存的最大大小。我们发现 50000 是我们的甜蜜点。

修正负载测试方法

通过使用缓存,我们不能只使用几个测试样本,因为缓存会使它们不需要计算。因此,我们必须定义可变的测试用例,以模拟真实的文本样本。我们使用 python 脚本创建请求示例,并使用 JMeter 进行测试。


Central embedder 结构 💢

最后,当我们从 3 个模型扩展到 21 个模型时,我们必须考虑如何使其健壮且经济。语言模型是一个沉重的组成部分,而文本清洗和前馈头模型的计算比较轻。

由于语言模型对所有模型都是通用的,所以我们决定创建一个单独的服务供所有模型使用。这让我们减少了一个沉重的负担。

Central embedder 结构目前,我们也在考虑使用 AWS lambda 为模型提供服务,摆脱基础设施。


学习 😅

项目完成后,你真的希望有些事情做得更好,而有些事情根本就没做。我脑海中浮现出的几个建议是:

  • Apache Airflow + Sagemaker — 对于完成的 scheduled pipelines Airflow 非常有用,Sagemaker 对于超参数调试有一个非常好用的层。

![](Productionizing NLP Models.assets/0_o6PvGxgnuOMH7ARk.gif)

  • 模型重训练 — 避免为项目生成标记数据,否则每次再训练模型时都必须创建它。你可以在工作流程中为训练模型创建标记数据。你可以了解如何保存原始/收集的数据,并编写脚本来创建训练数据,以便使用新数据对模型进行再训练。如果没有上述选项,你还可以利用半监督学习。
  • 模型压缩 — 如果你的神经网络模型的延迟超过你的需求,你可以使用剪枝和量化使它们更快。
  • 偏差检查 使用 MIT 的这框架

历史偏差 — 因为数据分布会随着时间而改变

表示偏差 — 当数据的某些部分没有得到充分表达时

度量偏差 — 当标签被用作真实标签的代理时

聚合偏差 — 当同一个模型用于不同的数据集时

评估偏差 — 当测试数据与真实世界的数据不匹配时

  • 可解释性 — 使用像[eli5]这样的库来理解模型预测和偏差

eli5 如何解释 scikit pipeline 的预测

奖励 🍹

现在,我创建了一个清单,让自己保持在正确的轨道上。有时候在拥挤的人群中很容易迷失方向。

建模清单 📘

  1. 我们的模型度量和业务度量是什么?它们是相同的还是不同的?
  2. 更多的数据会改善度量标准吗?我们能得到更多的数据吗?
  3. 我们是否使用了 fp16 和多 gpu 来减少训练时间?我们是否优化了批大小并尝试了 one_cycle_fit 来减少训练时间?我们是用 Adam,Radam,ranger 还是新�