幕后揭秘:库模式与设计概念
自spaCy诞生之初,开发者的生产效率就一直是其设计的核心考量,无论是细微决策还是一些重大的架构问题。其设计理念是拥抱机器学习的复杂性,而非通过易泄漏的抽象将其隐藏起来,同时也要保持良好的开发者体验。本文将深入探讨该库内部的一些设计模式、其实现方式,以及最重要的:为什么要这样设计。
在spaCy内部,工作重点在于如何:
- 平衡易用性与可定制性
- 帮助预防错误,并在错误发生时进行调试
- 提高代码可读性
- 为复杂且适应性强的软件项目提供工具
在这篇博客中,我们将更深入地探讨决定新版库设计的“如何”与“为何”。如果想了解“是什么”,可以查看这篇博客。本文基于spaCy v3发布时制作的一个视频,可以在此观看。
易用 vs. 可定制
早在2019年10月,作者受邀在PyCon India发表主题演讲。演讲标题为“让他们写代码”,解释了为什么优秀的开发者工具需要是可编程的,而不是试图预测用户可能想做的一切并提供容易泄漏的抽象。演讲中还展示了一些实用想法,如何在保持良好开发者体验的同时让工具可定制。这些想法许多都直接受到了当时为spaCy v3及其机器学习库Thinc所做工作的启发。
2015年spaCy首次发布时,人们做NLP的方式与今天大相径庭。这不仅仅是词嵌入、Transformer和迁移学习等技术层面的进步。如今,更多的团队至少拥有一名在机器学习方面经验丰富的成员,组织也更了解哪些类型的项目更可能获得成果。深度学习的本质也要求开发者介入不同层次的抽象:一旦深入细节,你可能想要向模型添加层或访问原始输出。这些都是spaCy希望支持的工作流程。但与此同时,也希望坚持该库的愿景:提供可以立即使用的、实用的预配置构建模块。希望保持库的易用性——但为此,需要正确的架构,不能将复杂性掩盖起来。
设计理念
机器学习是复杂的。若想提供更好的开发者体验,就需要直面这种复杂性,而不是仅用一堆抽象来掩盖和隐藏。spaCy为定制流程和神经网络模型的几乎每个部分提供了强大的开发者体验,包括插入任何框架实现的任何自定义模型的能力。同时,也希望让新手易于入门并提供合理的默认设置,使他们能够快速上手、高效工作并训练模型。
还希望确保通常只有一种方法可以完成事情。以前,在命令行上训练模型更方便但可扩展性较差,而编写自己的训练脚本更灵活但也更复杂——尤其是在需要正确处理细节和超参数时。spaCy现在专注于一种训练模型的工作流程:在命令行上使用spacy train,并通过一个单一的配置文件定义所有设置、超参数、模型实现、流程设置、组件、组件模型和初始化。
python -m spacy train config.cfg --output ./output --paths.train ./train --paths.dev ./dev自下而上的配置系统
配置文件是唯一的事实来源,它包含了所有设置并记录了所有默认值。即使使用默认配置训练且不打算定制任何内容,配置文件仍将包含所有设置。给定相同的配置,应该始终能够复现相同的结果。
配置被解析为字典,可以包含嵌套部分,使用点号表示法。例如,training.optimizer。
[training] dropout = 0.1 accumulate_gradient = 1 [training.optimizer] @optimizers = "Adam.v1" [training.optimizer.learn_rate] @schedules = "warmup_linear.v1" warmup_steps = 250 total_steps = 20000 initial_rate = ${vars.learn_rate} [vars] learn_rate = 0.001其底层是Python内置configparser的一种变体,也用于解析类似setup.cfg的文件。但对配置语法进行了进一步改进,允许在加载配置时解析任何JSON可序列化的值,以及更灵活的变量插值,允许引用嵌套和整个部分的配置值。
该配置系统的特别之处在于,它不仅支持JSON可序列化的值,还支持对用于创建对象的函数的引用——比如模型架构、优化器、语料库阅读器等。不希望陷入通过配置文件编程并由配置定义逻辑的陷阱——Python在这方面非常擅长。因此,@语法允许引用一个创建对象的函数,而不是定义实际逻辑。例如,@optimizers允许定义优化器注册表中函数的字符串名称。该块中的所有其他设置将作为参数传递给该函数。
🍬 Confection: Python最甜蜜的配置系统
最近已将这个配置系统独立发布为
confection,这是一个独立于Thinc和spaCy的轻量级包,易于集成到任何Python项目中。
当配置被解析时,会调用函数来创建对象,如优化器。配置是自下而上解析的,所以总是从最外层的叶子开始向上处理树。这意味着我们能够灵活地组合函数,并将一个函数返回的对象作为参数传递给另一个函数。以优化器和学习率为例。学习率的变化有不同的策略,这通常是需要定制的。更经典的方法是用一堆设置初始化优化器,包括如何创建学习率调度器。这可行,但很快就会遇到瓶颈:有很多参数,其中许多只在特定组合下有意义,并且很难换入一个完全自定义的策略,比如刚从论文中读到并想尝试的新方法。
更可组合的解决方案是将学习率调度器本身作为一个生成器传入,该生成器生成你所需的学习率序列。在配置层面上,这意味着优化器的learn_rate参数是一个引用函数的子部分。随着配置自下而上解析,学习率函数首先被调用,其返回值在创建优化器时传入。
这同样适用于流程组件和模型架构。以前,一个流程组件会创建其神经网络模型,该模型可以通过几个设置进行定制。我们称这种模式为“自上而下的配置”,一旦开始思考它,你可能会在许多地方看到它并注意到它带来的问题。其一是最顶层的对象需要接收设置,然后传递给它创建的其他对象和调用的函数,这些对象和函数再将设置传递给它们调用和创建的对象,以此类推。一个值可能需要在多个地方传递。一旦你忘记传递它,可能会在未察觉的情况下激活默认值。
例如,一个流程组件可能有一个设置来定义其嵌入表的宽度。然后它创建一个模型实例并向下传递宽度。模型随后使用该数字创建一个或多个层。如果你忘记将它传递给某一层,该层可能会回退使用其默认宽度,而默认宽度可能不同。使用自上而下的配置,很容易导致配置不匹配,以及非常微妙且深度嵌套的难以追踪的错误。
为避免此问题,你希望自下而上地构建对象树。不希望传入设置并让函数用它们创建对象——而是希望传入实例本身。这可以阻止配置被传递下去。从spaCy v3开始,可训练组件通常使用在配置中定义的模型实例进行初始化。模型架构也通常接受由函数创建的子层。这意味着组件不必负责传递一堆设置。它还使组件和模型架构具有模块化特性,因此,如果你想尝试不同的架构或嵌入策略,只需在配置中替换它。
现在,你可能会看着这些并问自己:我们到底为什么要做这些?我们编写函数,然后为它们分配字符串名称,以便可以在单独的文件中使用它们。为什么不直接使用函数本身呢?
全局函数注册系统
嗯,尽管流程需要可编程,它也需要可序列化。序列化是将状态(如Python对象或数据结构)转换为可以存储或传输并在以后重建的格式的过程。例如,将训练好的模型保存到磁盘上的目录,并在以后加载回来。当你重新创建对象时,你希望它完全是你保存的样子。在spaCy的上下文中,这意味着流程应使用相同的语言和分词器设置、具有相同设置和模型架构及超参数的相同组件,并能访问相同的二进制权重。因此,当你加载一个训练好的实体识别器时,spaCy将创建该组件、配置它并加载数据。你还希望将保存的内容限制在必要的范围内,并在可能的情况下使用安全的格式(如JSON),而不是仅仅pickle整个对象并让用户执行任意且可能不安全的代码。spaCy内置的流程组件实现了自己的序列化方法,负责保存和加载设置及权重。因此,给定一个目录并知道它是一个实体识别器,spaCy将能够重建该对象。
然而,当需要创建的对象由用户定义时,情况就变得棘手了。在spaCy v3中,流程和训练过程的几乎每个部分都可通过自定义函数进行配置:你可以为流程组件插入自己的模型实现,调整现有组件模型的嵌入层,使用自定义优化器或批处理大小调度器,或者替换流入训练数据的函数。
许多这些可定制部分在核心库的不同地方使用——比如用于创建自定义流程组件的函数或定义如何初始化空白流程的设置。不希望一直传递这些函数。相反,希望spaCy能够询问:“嘿,有没有名为relation_extractor的组件函数?我们有没有一个名为slanted_triangular的创建学习率调度器的函数?”
需要一个中央位置来存储和注册函数:函数注册表。函数注册表允许你将字符串名称映射到函数。就这么简单。这是一个简单的概念但非常强大:一个字符串名称唯一标识一个创建对象的函数,给定一个字符串和全局注册表,我们总是可以重新创建它。
REGISTRY={}defregister(name):defregister_function(func):REGISTRY[name]=funcreturnregister_function@register("my_function")defmy_function():...底层的实现(已开源为一个名为catalogue的轻量级迷你库)非常简单。我们维护一个全局注册表,就像映射字符串到函数的字典,并使用装饰器将装饰的函数添加到注册表中。它还支持通过Python入口点注册函数,因此第三方包可以为现有注册表公开函数,而无需用户导入该包。
现在在库内部,可以在注册表中查找任何字符串名称。要注册一个自定义函数,用户只需用注册表装饰器装饰它并分配一个名称。这允许用户轻松定制库深处或其他函数内部的行为。并且我们可以将这些信息以安全格式存储,如JSON可序列化的配置文件。如果注册的函数可用(即装饰器已运行),库将始终知道如何创建对象。
这非常方便,但它取决于一个简单的前提:我们需要知道并跟踪对象是如何或期望如何被创建的。如果我们只有一个对象,我们将无法再次创建它。顺便说一下,这也是我们最终对流程组件API做出一个重要更改并引入装饰器来注册自定义组件的原因。nlp.add_pipe现在只允许接受字符串名称,而不是组件函数本身。
更少的调试,更高的生产率
如前所述,spaCy的许多新功能都是围绕本质上复杂的任务重新思考开发者体验的结果。希望提供强大、可扩展且易于使用的工作流程——与此同时,必须接受现实,即错误和失误总会发生。没有人能写出完美的代码。有两种处理方法:一是在错误发生前捕获并完全防止它们,二是当错误发生时更轻松地捕获并帮助用户解决它们。
基于类型的数据验证
在spaCy v3中,最终放弃了Python 2,因此能够采用一些较新的Python特性,比如类型提示!类型提示允许你定义变量的预期类型。例如,向函数参数添加: int可以声明该值应为整数。像mypy这样的静态类型检查器可以分析你的代码并指出潜在的错误,现代编辑器也可以提供提示和自动完成功能。
defadd_numbers(a:int,b:int)->int:returna+b类型提示催生了一个全新的开发者工具生态系统,包括在运行时使用它们的库,例如用于验证通过应用程序的数据。其中一个库是Pydantic,它为spaCy和Thinc中的大量数据验证提供支持。实际上,它是spaCy配置系统的关键组成部分,帮助确保传入的配置设置是有效且完整的——即使是提供给自定义注册函数的设置!
最初是通过前同事Sebastián及其广泛使用Pydantic的库FastAPI了解到Pydantic的,该库使用它来定义API请求和响应的数据模型。想法很简单:将数据模型声明为Pydantic的BaseModel的子类,并为字段添加类型提示。然后可以用数据实例化该类,值将被转换为指定的类型(如果可能)。如果不能,你将看到一个验证错误,指出字段、其值以及预期类型。如果你以前使用过JSON模式,这基本上是相同的概念,只是由类型提示驱动。实际上,你也可以基于Pydantic模型导出JSON模式。
fromtypingimportOptionalfromfastapiimportFastAPIfrompydanticimportBaseModel## 定义数据模型classItem(BaseModel):name:strdescription:Optional[str]=Noneprice:floattax:Optional[float]=Noneapp=FastAPI()@app.post("/items/")asyncdefcreate_item(item:Item):returnitemPydantic允许你使用基本的标准库类型,如int或bool,但它也包括各种自定义类型来验证不同的数据类型。例如,用于文件路径、URL的类型,或严格和约束类型,如仅接受实际字符串而非任何可强制转换为字符串的类型的严格字符串strict str,或仅接受正整数的positive_int。
那么,Pydantic在配置系统中有什么用呢?当你训练模型时,spaCy将使用配置构造所有必需的对象,并使用其配置块中定义的参数调用注册的函数。由于配置也可以表达嵌套结构,一个函数的结果也可能传递给另一个函数,比如被优化器使用的学习率调度器。如果有问题,某个设置指定不正确或缺失,我们希望能够尽早退出并告诉你问题所在以便修复。这是通过根据Pydantic数据模型验证配置块来完成的。
对于块中的顶层属性,我们可以提供一个基础模式。还将其配置为明确禁止额外字段,因此如果你在名称中有拼写错误,你也会看到一个错误。这里的实现相当简单:在将配置解析为字典后,我们可以对其调用模式并处理验证错误——其他一切都由Pydantic负责。
类型提示与自动填充
除了常规设置,spaCy的配置还允许使用@语法引用注册的函数,块中的所有其他设置都作为参数传递给该函数。当然,也希望这些函数能够定义它们期望的类型,幸运的是,已经有一个内置机制:Python函数参数的类型提示!
要验证配置块并创建Pydantic模型,可以先检查函数参数及其默认值和类型提示(如果可用)。使用内置的inspect模块可以很容易做到这一点。接下来,可以使用这些信息创建一个动态的Pydantic模型。如果参数未指定默认值,我们假设它是必需的;如果没有类型提示,我们假设它是Any。然后可以在配置块提供的数据上调用Pydantic模型,并检查设置是否与函数参数兼容。
由于配置是自下而上解析的,当我们解析和验证其父块时,已经拥有了函数的返回值。例如,如果我们有一个返回列表的函数,其返回值传递给另一个期望列表的函数,我们可以验证这一点,甚至可以捕获注册函数返回意外值的问题。
我们为注册函数创建的动态Pydantic模型还允许我们提供另一个有用的功能:自动填充!如果一个函数定义了默认值,我们会知道它们,如果配置中不存在这些默认值,我们可以将它们添加回配置中。这对于保持配置的可复现性和避免隐藏的默认值很重要。你的注册函数仍然可以定义默认值——但在任何时候,你都能够自动生成一个包含将要使用的所有设置的完整配置。spaCy的init fill-config命令接收一个部分配置,并输出经过验证和自动填充的版本。它甚至可以显示一个漂亮的视觉差异,以便你查看添加或删除了哪些字段。
深入细节:提升机器学习的稳健性
机器学习的核心很大程度上涉及使用多维数组进行计算,然后让数据通过网络前向传播和反向传播。即使是一个小错误,比如输入和输出维度不匹配,也可能导致数小时甚至数天的痛苦调试。只需要一个超参数设置不正确或不一致,就可能导致模型产生混乱的结果或完全崩溃。调试神经网络可能是开发者生产力最重要的障碍之一,因此这是真正想要解决的问题。如果能预防错误,并帮助开发者调试剩余的问题,他们将能够花更多时间专注于有趣的事情:构建实际应用程序。
spaCy的机器学习库Thinc包含可以在代码中使用的自定义类型,包括最常见数组的类型,如用于二维浮点数数组的Floats2d,或用于一维整数数组的Ints1d。即使在静态分析和其他高级类型检查之前,为代码(尤其是抽象部分)添加类型提示的一大优势是提高可读性。仅仅知道什么应该输入和输出,就能让理解一段代码并与他人共享变得容易得多。
Mypy静态检查
如果你使用像Visual Studio Code这样的现代编辑器并启用Mypy静态检查,静态类型检查器会在返回的变量不符合函数预期时发出警告。Thinc通过后端实现了几种数组转换方法,作为model.ops使用。这将是NumPy或CuPy,具体取决于你是在CPU还是GPU上。如果静态类型检查器检测到维度数量不同,例如,那么它知道可能出了问题,无论是转换、预期的输入类型还是声明的预期输出类型,或者是这些的任何组合。你甚至在运行代码之前就能发现这一点。在运行时,像这样的小错误很容易让你陷入“无法广播形状”的错误深渊。
Mypy的酷之处在于,你可以为其扩展自定义插件以满足库特定的用例。对于Thinc,我们实现了一个插件,当你使用像chain这样的组合器时执行额外的检查,该组合器接收两个或多个层并将它们组合为一个前馈网络。在这个例子中,第一层输出一个Floats2d数组,但下一层期望输入类型为Ragged,这是一个不规则的数组。即使不运行代码,Mypy也能标记出这种不匹配,这很可能表明存在错误。
开发者生产力
软件项目由选择构成——这确实是任何类型设计的核心。在创建spaCy的过程中,做决策时反复出现的问题都与开发者生产力有关。这体现在许多小决策和一些大的架构问题上。非常关注细节,如命名、错误处理和文档。也仔细思考了不该做什么,特别是避免冗余的捷径和相互竞争的抽象。
特别注意避免可能导致用户回溯的API决策。希望确保你不会以一种方式开始解决问题,然后发现必须使用另一个更快或支持不同功能组合的备用API。这就是我们谈论提供“从原型到生产的平滑路径”时的部分含义。对于大多数项目,投入生产是一个持续的过程,而不是一次性事件。如果你将始终处于开发中,那么拥有“开发代码”然后在某个时刻需要推倒重写并不理想。
拥抱复杂性
或许我们决定不做的最重要的事情是隐藏机器学习的复杂性。开发者需要能够用库编程,这意味着组合各个部分来构建自己的解决方案。这就是为什么我们的座右铭之一是“让他们写代码”。另一种选择是试图让所有事情都变成一个函数调用的库。这最终会让人觉得像一个堆满单一功能小工具的厨房。你不想每天翻找装满切蛋器、木瓜切块器和比目鱼嫩肉器的抽屉。最好是拥有一套你了解和掌握的更少工具。仅仅抽象掉机器学习的复杂性并不能解决任何问题——你需要有成效地拥抱它们。
相关资源
- spaCy v3: 设计概念详解(幕后):本博客的视频版本
- 介绍spaCy v3: spaCy v3的新特性
- Confection: Python最甜蜜的配置系统
- Catalogue: 为你的库准备的超轻量级函数注册表
更多精彩内容 请关注我的个人公众号 公众号(办公AI智能小助手)或者 我的个人博客 https://blog.qife122.com/
对网络安全、黑客技术感兴趣的朋友可以关注我的安全公众号(网络安全技术点滴分享)