Press "Enter" to skip to content

使用Pants组织ML代码库

您是否曾经在项目之间复制粘贴过大量的实用代码块,导致同一份代码存在于不同的代码库中?或者,也许您不得不在您存储数据的GCP存储桶的名称更新后,向数十个项目提交拉取请求?

上述情况在机器学习团队中经常发生,其后果从单个开发人员的烦恼到团队无法按需交付代码的能力不等。幸运的是,这里有一个解决办法。

让我们深入了解monorepo的世界,这是一种在Google等主要技术公司广泛采用的架构,以及它如何增强您的机器学习工作流程。尽管存在一些缺点,但monorepo提供了众多优势,使其成为管理复杂机器学习生态系统的令人信服的选择。

我们将简要讨论monorepo的优点和缺点,探讨为什么它是机器学习团队的一个优秀架构选择,并了解BigTech如何使用它。最后,我们将看到如何利用Pants构建系统的力量,将您的机器学习monorepo组织成一个强大的CI/CD构建系统。

让我们开始这个旅程,简化您的机器学习项目管理。

什么是monorepo?

使用Pants组织ML代码库 四海 第1张
机器学习monorepo | 来源:作者

monorepo(简称为monolithic repository)是一种软件开发策略,其中多个项目的代码存储在同一个代码库中。这个概念可以是广泛的,包括所有公司使用各种编程语言编写的代码存储在一起(有人提到了Google吗?),也可以是由小团队开发的几个Python项目存放在一个代码库中。

在本博客文章中,我们关注存储机器学习代码的代码库。

Monorepo与polyrepo

Monorepo与polyrepo方法形成鲜明对比,polyrepo方法中,每个项目或组件都有自己单独的代码库。关于这两种方法的优势和劣势已经有很多讨论,我们不会深入探讨这个问题。我们只是简单介绍一下基本情况。

monorepo架构提供了以下优势:

使用Pants组织ML代码库 四海 第2张
Monorepo架构 | 来源:作者
  • 单一的CI/CD流水线,意味着没有隐藏的部署知识散布在不同代码库的不同贡献者之间;
  • 原子提交,由于所有项目都驻留在同一个代码库中,开发人员可以进行跨项目的更改,这些更改会跨越多个项目合并为一个提交;
  • 轻松共享各种实用工具和模板;
  • 易于统一编码标准和方法;
  • 更好的代码可发现性

当然,没有免费的午餐。我们需要为上述好处付出代价,这个代价以以下形式出现:

  • 可扩展性挑战:随着代码库的增长,管理monorepo可能变得越来越困难。在规模非常大的情况下,您需要强大的工具和服务器来处理克隆、拉取和推送更改等操作,这可能需要大量时间和资源。
  • 复杂性:monorepo在管理上可能更加复杂,特别是在依赖关系和版本控制方面。对于共享组件的更改可能会影响到许多项目,因此需要额外的谨慎,以避免破坏性更改。
  • 可见性和访问控制:由于每个人都在同一个代码库中工作,很难控制谁可以访问什么。虽然这不是一个真正的缺点,但在代码受到非常严格的保密协议约束的情况下,可能会带来法律问题。

决定monorepo提供的优势是否值得付出代价,应由每个组织或团队自行决定。然而,除非您在一个规模非常大或者处理非常机密的任务,否则我认为至少在我所熟悉的领域——机器学习项目方面,monorepo在大多数情况下都是一个很好的架构选择。

让我们谈谈为什么这样做。

使用单一仓库进行机器学习

单一仓库对于机器学习项目特别适用的原因至少有六个。

  • 1
    数据管道集成
  • 2
    实验一致性
  • 3
    简化模型版本控制
  • 4
    跨职能协作
  • 5
    原子化变更
  • 6
    统一编码规范

数据管道集成

机器学习项目通常涉及对数据进行预处理、转换和提供给模型的数据管道。这些数据管道可能与机器学习代码紧密集成。将数据管道和机器学习代码放在同一个代码库中有助于维护紧密的集成并简化工作流程。

实验一致性

机器学习开发涉及大量的实验。将所有实验放在一个单一的代码库中可以确保环境设置的一致性,并减少由于代码或数据版本的差异导致的不一致的风险。

简化模型版本控制

在单一代码库中,代码和模型版本是同步的,因为它们都被提交到同一个代码库中。这使得更容易管理和追踪模型版本,在强调机器学习可复现性的项目中尤为重要。

只需获取任意一点的提交 SHA,即可获得所有模型和服务状态的信息。

跨职能协作

机器学习项目通常涉及数据科学家、机器学习工程师和软件工程师之间的合作。单一仓库通过为所有项目相关的代码和资源提供单一真相,促进了跨职能的协作。

原子化变更

在机器学习的上下文中,模型的性能可能依赖于各种相互关联的因素,如数据预处理、特征提取、模型架构和后处理。单一仓库允许进行原子化变更,即对这些组件的多个变更可以作为一个提交,确保相互依赖始终保持同步。

统一编码规范

最后,机器学习团队通常包括没有软件工程背景的成员。这些数学家、统计学家和计量经济学家是聪明的人,他们有很多出色的想法和训练解决业务问题的模型的能力。然而,编写干净、易读和易于维护的代码可能不是他们的强项。

单一仓库通过自动检查和强制执行所有项目的编码规范来帮助,这不仅确保了高代码质量,还帮助不太擅长工程的团队成员学习和成长。

行业中的实践:著名的单一仓库

在软件开发领域,世界上一些最大和最成功的公司使用单一仓库。以下是一些值得注意的例子。

  • 谷歌:谷歌一直坚决支持单一仓库方法。他们整个代码库估计包含20亿行代码,都存储在一个巨大的代码库中。他们甚至发表了一篇论文介绍这个方法。
  • Meta:Meta也使用单一仓库来管理他们庞大的代码库。他们创建了一个名为“Mercurial”的版本控制系统来处理单一仓库的大小和复杂性。
  • Twitter:Twitter长期以来一直使用Pants来管理他们的单一仓库。

许多其他公司如微软、优步、Airbnb和Stripe也在其代码库的某些部分使用单一仓库方法。

理论讲够了!现在让我们看看如何实际构建一个机器学习单一仓库。因为仅仅将原本分开的代码库放在一个文件夹中是不够的。

如何使用Python设置机器学习单一仓库?

在本节中,我们将以我为本文创建的一个示例机器学习代码库为基础进行讨论。这是一个简单的单一仓库,只包含一个项目或模块:一个名为mnist的手写数字分类器,以其使用的著名数据集命名。

您现在只需要知道,在单一仓库的根目录下有一个名为mnist的目录,在其中有一些用于训练模型的Python代码、相应的单元测试以及用于在容器中运行训练的Dockerfile。

使用Pants组织ML代码库 四海 第3张

我们将使用这个简单的示例来保持简单,但在一个更大的monorepo中,mnist只是存储库根目录中的许多项目文件夹之一,每个项目文件夹都包含源代码、测试、dockerfiles和至少要求文件。

构建系统:为什么需要它以及如何选择?

为什么需要?

想想一下,在monorepo中开发不同项目的不同团队除了编写代码之外的所有操作,作为他们开发工作流程的一部分。他们会对他们的代码运行代码检查工具以确保符合样式标准,运行单元测试,构建docker容器和Python wheels等构件,将它们推送到外部构件仓库,并将它们部署到生产环境。

以测试为例。

你对你维护的实用函数进行了更改,运行了测试,一切都是绿色的。但是你如何确保你的更改不会破坏其他团队可能正在导入你的实用程序的代码?当然,你也应该运行他们的测试套件。

但是要做到这一点,你需要确切地知道你更改的代码在哪里被使用。随着代码库的增长,手动查找这一点并不可扩展。当然,作为替代方案,你总是可以执行所有的测试,但是同样:这种方法并不可扩展。

使用Pants组织ML代码库 四海 第4张
为什么需要一个系统:测试 | 来源:作者

另一个例子生产部署

无论你是每周、每天还是持续部署,当时机到来时,你将构建monorepo中的所有服务并将它们推送到生产环境。但是,嘿,你需要在每个场合都构建所有服务吗?这可能在规模上耗时且昂贵。

有些项目可能已经几周没有更新了。另一方面,它们使用的共享实用程序代码可能已经更新了。我们如何决定要构建什么?再说一遍,这一切都与依赖关系有关。理想情况下,我们只构建受最近更改影响的服务。

使用Pants组织ML代码库 四海 第5张
为什么需要一个系统:部署 | 来源:作者

所有这些都可以用一个简单的shell脚本和一个小的代码库来处理,但随着规模的扩大和项目开始共享代码,会出现一些挑战,其中许多围绕着依赖管理。

选择正确的系统

如果你投资一个合适的构建系统,上述所有问题都不再是问题。构建系统的主要任务是构建代码。它应该以巧妙的方式来做到这一点:开发人员只需要告诉它要构建什么(“构建受我的最新提交影响的docker镜像”,或者“只运行覆盖我更新的方法的代码的测试”),但具体的构建方式应由系统来确定。

有几个很棒的开源构建系统可供选择。由于大部分机器学习是用Python完成的,让我们专注于最好支持Python的系统。在这方面,两个最受欢迎的选择是Bazel和Pants。

Bazel是Google内部构建系统Blaze的开源版本。Pants也受到Blaze的很大启发,它的技术设计目标与Bazel类似。有兴趣的读者可以在这篇博文中找到Pants与Bazel的很好比较(但请记住,它来自Pants的开发人员)。monorepo.tools的底部还提供了另一个比较。

两个系统都很出色,我在这里没有意图宣称哪个更好。话虽如此,Pants通常被描述为易于设置、易于接近和针对Python进行了优化,这使其非常适合机器学习monorepo。

根据我个人的经验,决定我选择Pants的决定性因素是其积极而乐于助人的社区。无论你有什么问题或疑虑,只需在社区Slack频道上发布,一群支持性的人们很快就会帮助你解决。

介绍 Pants

好了,现在是时候进入正题了!我们将一步一步地介绍不同的 Pants 功能以及如何实现它们。你可以在这里查看相关的样例代码库。

设置

Pants 可以通过 pip 进行安装。在本教程中,我们将使用最新稳定版本 2.15.1。

pip install pantsbuild.pants==2.15.1

Pants 可以通过一个名为 pants.toml 的全局主配置文件进行配置。在其中,我们可以配置 Pants 自身的行为以及其依赖的下游工具(如 pytest 或 mypy)的设置。

让我们从一个最简单的 pants.toml 开始:

[GLOBAL]

pants_version = "2.15.1"

backend_packages = [

    "pants.backend.python",

]

[source]

root_patterns = ["/"]

[python]

interpreter_constraints = ["==3.9.*"]

在全局部分,我们定义了 Pants 的版本和我们需要的后端包。这些包是支持不同功能的 Pants 引擎。对于初学者来说,我们只包括 Python 后端。

在源代码部分,我们将源代码设置为仓库的根目录。自从版本 2.15 开始,为了确保这一点被识别,我们还需要在仓库的根目录下添加一个空的 BUILD_ROOT 文件。

最后,在 Python 部分,我们选择要使用的 Python 版本。Pants 将在系统中搜索与此处指定条件匹配的版本,所以请确保您已安装此版本。

这就是一个开始!接下来,让我们来看看任何构建系统的核心:BUILD 文件。

BUILD 文件

BUILD 文件是用于以声明方式定义目标(要构建的内容)和它们的依赖关系(它们所需的内容)的配置文件。

您可以在不同层级的目录树中拥有多个 BUILD 文件。文件越多,对依赖关系管理的控制就越细粒度。实际上,谷歌的代码库几乎每个目录都有一个 BUILD 文件。

在我们的示例中,我们将使用三个 BUILD 文件:

  • mnist/BUILD – 在项目目录中,此构建文件将定义项目的 Python 依赖和构建 Docker 容器;
  • mnist/src/BUILD – 在源代码目录中,此构建文件将定义 Python 源代码,即需要进行 Python 特定检查的文件;
  • mnist/tests/BUILD – 在测试目录中,此构建文件将定义要使用 Pytest 运行的文件以及这些测试运行所需的依赖项。

让我们来看一下 mnist/src/BUILD 文件:

python_sources(

    name="python",

    resolve="mnist",

    sources=["**/*.py"],

)

与此同时,mnist/BUILD 文件如下:

python_requirements(

    name="reqs",

    source="requirements.txt",

    resolve="mnist",

)

构建文件中的这两个条目被称为目标。首先,我们有一个 Python 源代码目标,我们将其适当地称为“python”,尽管名称可以是任何值。我们将所有 .py 文件定义为我们的 Python 源代码。这是相对于构建文件的位置的,也就是说:即使我们在 mnist/src 目录之外有 Python 文件,这些源代码只会捕获 mnist/src 文件夹中的内容。还有一个 resolve 字段;我们将在接下来讨论它。

接下来,我们有一个 Python 依赖项目标。它告诉 Pants 在哪里找到执行我们的 Python 代码所需的依赖项(同样是相对于构建文件的位置,在这种情况下是 mnist 项目的根目录)。

这就是我们开始所需要的一切。为了确保构建文件定义正确,让我们运行:

pants tailor --check update-build-files --check ::

正如预期的那样,我们得到了输出:“未找到 BUILD 文件的必要更改。”。很好!

让我们花费更多时间来了解这个命令。简而言之,裸 pants tailor 可以自动创建构建文件。然而,它有时倾向于添加过多的构建文件,这就是为什么我倾向于手动添加它们,并在上面的命令之后检查它们的正确性。

在末尾的双分号是Pants标记,它告诉它在整个monorepo上运行命令。或者,我们可以将其替换为mnist:仅针对mnist模块运行。

依赖项和锁文件

为了进行高效的依赖管理,Pants依赖于锁文件。锁文件记录了每个项目使用的所有依赖项的特定版本和来源,包括直接和传递的依赖项。

通过捕获这些信息,锁文件确保在不同的环境和构建中一致地使用相同版本的依赖项。换句话说,它们作为依赖图的快照,确保了构建的可复制性和一致性。

为了为我们的mnist模块生成一个锁文件,我们需要在pants.toml中添加以下内容:

[python]
interpreter_constraints = ["==3.9.*"]
enable_resolves = true
default_resolve = "mnist"

[python.resolves]
mnist = "mnist/mnist.lock"

我们启用了解析(Pants术语,指锁文件的环境),并定义了一个mnist的解析,传递了一个文件路径。我们还将其选择为默认解析。这就是我们之前传递给Python源码和Python依赖项目标的解析:这是它们知道需要哪些依赖项的方式。现在我们可以运行:

pants generate-lockfiles

得到:

已完成:生成mnist的锁文件
已将锁文件`mnist`写入mnist/mnist.lock

这样就创建了一个文件在mnist/mnist.lock。如果您打算在远程CI/CD中使用Pants,应该将此文件与git一起检查。自然地,每次更新requirements.txt文件时都需要更新它。

在monorepo中有更多项目时,您可能更愿意有选择地为需要它的项目生成锁文件,例如 pants generate-lockfiles mnist: 。

设置就完成了!现在让我们使用Pants为我们做一些有用的事情。

使用Pants统一代码风格

Pants原生支持多个Python代码检查工具和代码格式化工具,例如Black、yapf、Docformatter、Autoflake、Flake8、isort、Pyupgrade或Bandit。它们都以相同的方式使用;在我们的示例中,让我们实现Black和Docformatter。

为此,我们在pants.toml中添加了适当的两个后端:

[GLOBAL]
pants_version = "2.15.1"
colors = true
backend_packages = [
    "pants.backend.python",
    "pants.backend.python.lint.docformatter",
    "pants.backend.python.lint.black",
]

如果我们想要配置这两个工具,可以在toml文件中添加以下附加部分,但现在让我们使用默认设置。

要使用这些格式化工具,我们需要执行所谓的Pants目标。在这种情况下,有两个相关的目标。

首先,lint目标将以检查模式运行这两个工具(按照后端包列表中的顺序,因此首先是Docformatter,然后是Black)。

pants lint ::

看起来我们的代码符合这两个格式化工具的标准!但是,如果不符合要求,我们可以执行fmt(缩写为“format”)目标来适应代码:

pants fmt ::

在实际应用中,您可能希望使用超过这两个格式化工具。在这种情况下,您可能需要更新每个格式化工具的配置,以确保与其他工具兼容。例如,如果您使用默认配置的Black,它将期望代码行不超过88个字符。

但是如果您想添加isort以自动对导入进行排序,它们将冲突:isort将在79个字符后截断行。要使isort与Black兼容,您需要在toml文件中包含以下部分:

[isort]
args = [
    "-l=88",
 ]

所有格式化工具都可以通过在pants.toml中将参数传递给其底层工具来以相同的方式进行配置。

使用Pants进行测试

让我们运行一些测试!为此,我们需要两个步骤。

首先,我们在pants.toml文件中添加适当的部分:

[test]
output = "all"
report = false
use_coverage = true

[coverage-py]
global_report = true

[pytest]
args = ["-vv", "-s", "-W ignore::DeprecationWarning", "--no-header"]

这些设置确保在运行测试时生成测试覆盖报告。我们还传递了一些自定义的pytest选项来调整其输出。

接下来,我们需要返回到mnist/tests/BUILD文件并添加一个Python测试目标:

python_tests(
    name="tests",
    resolve="mnist",
    sources=["test_*.py"],
)

我们将其命名为tests,并指定要使用的resolve(即锁定文件)。源代码是pytest将用于查找要运行的测试的位置;在这里,我们明确传递了所有以“test_”为前缀的.py文件。

现在我们可以运行:

pants test ::

获取结果:


✓ mnist/tests/test_data.py:../tests 成功完成,用时3.83秒。
✓ mnist/tests/test_model.py:../tests 成功完成,用时2.26秒。

名称                               语句   丢失  覆盖率
------------------------------------------------------
__global_coverage__/no-op-exe.py       0      0   100%
mnist/src/data.py                     14      0   100%
mnist/src/model.py                    15      0   100%
mnist/tests/test_data.py              21      1    95%
mnist/tests/test_model.py             20      1    95%
------------------------------------------------------
总计                                 70      2    97%

如你所见,运行这个测试套件大约需要三秒钟。现在,如果我们再次运行它,我们将立即得到结果:

✓ mnist/tests/test_data.py:../tests 成功完成,用时3.83秒(缓存)。
✓ mnist/tests/test_model.py:../tests 成功完成,用时2.26秒(缓存)。

请注意,Pants告诉我们这些结果是被缓存的。由于测试、被测试的代码或要求都没有发生变化,实际上没有必要重新运行测试——它们的结果保证是相同的,所以它们只是从缓存中提取出来。

使用Pants检查静态类型

我们再添加一个代码质量检查。Pants允许使用mypy来检查Python的静态类型。我们只需要在pants.toml中添加mypy后端:”pants.backend.python.typecheck.mypy”。

您可能还想配置mypy,通过添加以下配置部分使其输出更易读和更具信息性:

[mypy]
args = [
    "--ignore-missing-imports",
    "--local-partial-types",
    "--pretty",
    "--color-output",
    "--error-summary",
    "--show-error-codes",
    "--show-error-context",
]

有了这个,我们可以运行pants check :: 来获取结果:

完成:使用MyPy进行类型检查 - mypy - mypy成功。
成功:在6个源文件中没有找到问题

✓ mypy成功。

使用Pants发布ML模型

让我们来谈谈发布。大多数机器学习项目涉及一个或多个Docker容器,例如处理训练数据、训练模型或使用Flask或FastAPI通过API提供模型服务。在我们的示例项目中,我们还有一个用于模型训练的容器。

Pants支持自动构建和推送Docker镜像。让我们看看它是如何工作的。

首先,在pants.toml中添加docker后端:pants.backend.docker。我们还将配置我们的docker,传递一些环境变量和一个构建参数,这将在下面派上用场:

[docker]

build_args = ["SHORT_SHA"]

env_vars = ["DOCKER_CONFIG=%(env.HOME)s/.docker", "HOME", "USER", "PATH"]

现在,在mnist/BUILD文件中,我们将添加两个更多的目标:一个文件目标和一个docker镜像目标。

files(

    name="module_files",

    sources=["**/*"],

)

docker_image(

    name="train_mnist",

    dependencies=["mnist:module_files"],

    registries=["docker.io"],

    repository="michaloleszak/mnist",

    image_tags=["latest", "{build_args.SHORT_SHA}"],

)

我们将docker目标称为“train_mnist”。作为依赖项,我们需要将包含在容器中的文件列表传递给它。最方便的方法是将此列表定义为一个单独的文件目标。在这里,我们只需将mnist项目中的所有文件包含在名为module_files的目标中,并将其作为依赖项传递给docker镜像目标。

当然,如果您知道容器只需要某个文件子集,最好只将它们作为依赖项传递。这是非常重要的,因为这些依赖项被Pants用于推断容器是否受到更改的影响并需要重新构建。在这里,由于module_files包含所有文件,如果mnist文件夹中的任何文件发生更改(即使是自述文件!),Pants将认为train_mnist docker镜像受到此更改的影响。

最后,我们还可以设置外部注册表和存储库,以及推送镜像时使用的标签:在这里,我将镜像推送到我的个人dockerhub存储库,始终使用两个标签:“latest”和短提交SHA,该SHA将作为构建参数传递。

有了这个,我们可以构建一个镜像。只剩最后一件事:由于Pants在其隔离环境中工作,无法从主机读取环境变量。因此,要构建或推送需要SHORT_SHA变量的镜像,我们需要将其与Pants命令一起传递。

我们可以像这样构建镜像:

SHORT_SHA=$(git rev-parse --short HEAD) pants package mnist:train_mnist 

得到:

已完成:构建docker镜像docker.io/michaloleszak/mnist:latest +1个附加标签。
已构建的docker镜像:
  * docker.io/michaloleszak/mnist:latest
  * docker.io/michaloleszak/mnist:0185754

快速检查显示镜像确实已构建:

docker images 


REPOSITORY            TAG       IMAGE ID       CREATED              SIZE
michaloleszak/mnist   0185754   d86dca9fb037   大约一分钟之前   3.71GB
michaloleszak/mnist   latest    d86dca9fb037   大约一分钟之前   3.71GB

我们还可以使用Pants一次构建和推送镜像。只需要用publish命令替换package命令。

SHORT_SHA=$(git rev-parse --short HEAD) pants publish mnist:train_mnist 

这将构建镜像并将其推送到我的dockerhub,它们确实已经到达那里。

在CI/CD中使用Pants

我们刚刚在本地手动运行的相同命令可以作为CI/CD流水线的一部分执行。您可以通过诸如GitHub Actions或Google CloudBuild等服务运行它们,例如在功能分支被允许合并到主分支之前作为PR检查运行,或者在合并后验证其是否正常并构建和推送容器。

在我们的toy repo中,我实现了一个在git push时运行Pants命令并且只有当它们全部通过时才允许通过的pre-push提交钩子。其中,我们运行以下命令:

pants tailor --check update-build-files --check ::
pants lint ::
pants --changed-since=main --changed-dependees=transitive check
pants test ::

您可以看到一些新的标志用于pants check,即使用mypy进行的类型检查。它们确保仅在与主分支相比发生更改的文件及其传递依赖项上运行检查。这是有用的,因为mypy运行起来需要一些时间。将其范围限制在实际需要的范围内可以加快该过程。

在CI/CD流水线中,docker构建和推送会是什么样子呢?大致如下:

pants --changed-since=HEAD^ --changed-dependees=transitive --filter-target-type=docker_image publish

我们像以前一样使用publish命令,但是带有三个额外的参数:

  • –changed-since=HEAD^和–changed-dependees=transitive确保仅构建与先前提交相比受到更改影响的容器;这对于合并后在主分支上执行非常有用。
  • –filter-target-type=docker_image确保Pants只构建和推送docker;这是因为pants publish命令可以引用除docker之外的目标:例如,它可以用于将helm chart发布到OCI注册表。

对于裤子包也是如此:除了构建Docker镜像外,它还可以创建Python包;因此,通过传递--filter-target-type选项是一个好的实践。

结论

对于机器学习团队来说,单一代码库往往是一个很好的架构选择。然而,要在规模上进行管理,则需要在适当的构建系统上进行投资。Pants是一个这样的系统:它易于设置和使用,并提供对机器学习团队经常使用的许多Python和Docker功能的原生支持。

此外,它是一个具有庞大和乐于助人社区的开源项目。我希望在阅读本文后,您会进一步尝试并使用它。即使您目前没有一个单一的代码库,Pants仍然可以简化和促进您日常工作的许多方面!

参考文献

  • Pants文档:https://www.pantsbuild.org/
  • Pants vs. Bazel博文:https://blog.pantsbuild.org/pants-vs-bazel/
  • monorepo.tools:https://monorepo.tools/
Leave a Reply

Your email address will not be published. Required fields are marked *