~不要~重复自己

'不要重复自己'

🤗 Transformers设计哲学

“不要重复自己”或者DRY,是软件开发中众所周知的原则。该原则起源于《实用程序员》(The pragmatic programmer),这本关于代码设计的畅销书之一。这个原则的简单信息显而易见:不要重写已经存在于其他地方的逻辑。这确保代码保持同步,使其更容易维护和更健壮。对这个逻辑模式的任何更改将统一影响其所有依赖项。

乍一看,Hugging Face的Transformers库的设计几乎与DRY原则背道而驰。注意机制的代码多少会复制到不同的模型文件中50多次。有时整个BERT模型的代码会复制到其他模型文件中。我们经常强制要求新的模型贡献与现有模型完全相同 – 除了一个小的逻辑调整 – 以复制所有现有代码。为什么我们这样做?我们只是懒惰或者不知所措,无法将所有逻辑部分集中到一个地方吗?

不,我们并不懒惰 – 这是一个非常有意识的决定,不将DRY设计原则应用于Transformers库。相反,我们决定采用一个不同的设计原则,我们称之为单一模型文件策略。单一模型文件策略规定,模型的前向传递所需的所有代码都在一个且仅有一个文件中 – 称为模型文件。如果读者想要理解BERT在推理中的工作原理,她只需要查看BERT的modeling_bert.py文件。我们通常拒绝将不同模型的相同子组件抽象到一个新的集中位置。我们不想有一个包含所有可能注意机制的attention_layer.py。再次,为什么我们这样做?

简而言之,原因如下:

  • 1. Transformers是由开源社区构建的。
  • 2. 我们的产品是模型,我们的客户是阅读或调整模型代码的用户。
  • 3. 机器学习领域发展极快。
  • 4. 机器学习模型是静态的。

1. 由开源社区构建

Transformers是为了积极激励外部贡献而构建的。一个贡献通常是修复错误或者新增模型。如果在一个模型文件中发现了一个错误,我们希望尽可能地让发现者轻松修复。没有什么比修复错误后发现它导致其他100个模型失败更令人泄气的了。

由于模型代码独立于所有其他模型,对于只理解自己正在处理的模型的人来说,修复它是相当容易的。同样地,如果只添加一个新的模型文件,添加新建模代码并审查相应的PR也更容易。贡献者不必弄清楚如何将新功能添加到一个集中的注意力机制中,而不会破坏现有模型。审阅者可以轻松验证是否破坏了任何现有模型。

2. 建模代码是我们的产品

我们假设Transformers库的用户不仅阅读文档,而且还查看实际的建模代码,并可能对其进行修改。这个假设得到了支持,因为Transformers库被fork了10,000多次,Transformers论文被引用了一千多次。因此,对于第一次阅读Transformers建模代码的人来说,很容易理解并可能进行适应,这非常重要。在一个单一的建模文件中提供所有必要的逻辑组件,有助于提高可读性和可适应性。此外,我们非常关心合理的变量/方法命名,并且更喜欢表达性强、可读性强的代码而不是字符紧凑的代码。

3. 机器学习发展速度极快

机器学习领域,特别是神经网络领域的研究发展非常快。一年前是最先进的模型可能在今天已经过时了。我们不知道哪种注意机制、位置嵌入或者架构在一年后将是最好的。因此,我们无法定义适用于所有模型的标准逻辑模式。

举个例子,两年前,可能已经将BERT的自注意力层定义为所有Transformers模型使用的标准注意力层。从逻辑上讲,一个“标准”注意函数可以被移动到一个集中的attention.py文件中。但是随后出现了在每个注意力层中添加相对位置嵌入的注意力层(T5),多种不同形式的分块注意力(Reformer、Longformer、BigBird)以及位置和词嵌入的单独注意机制(DeBERTa)等等…每次我们都会不得不问自己,“标准”注意函数应该适应还是最好将一个新的注意函数添加到attention.py中。但是我们该如何命名它呢?attention_with_positional_embdreformer_attentiondeberta_attention

给机器学习模型的逻辑组件起泛化的名称是危险的,因为对于这个组件代表的理解可能会很快改变或过时。例如,chunked attention 是指 GPTNeo、Reformer 还是 BigBird 的 chunked attention?注意力层是自注意力层、交叉注意力层,还是两者都包含?然而,如果我们按照模型的名称给注意力层命名,我们应该直接将注意力函数放在相应的建模文件中。

4. 机器学习模型是静态的

Transformer 库是一组统一且完善的机器学习模型,由不同的研究团队创建。每个机器学习模型通常都伴随着一篇论文和官方的 GitHub 仓库。一旦一个机器学习模型被发布,它很少会被适应或改变。

相反,研究团队倾向于发布一个基于先前模型构建的新模型,但很少对已发布的代码进行重大更改。这是在决定 Transformer 库的设计原则时的一个重要认识。这意味着一旦模型架构被添加到 Transformer 中,模型的基本组件就不再改变。虽然可能会发现和修复错误,方法和变量可能会重命名,模型的输出或输入格式可能会稍微改变,但模型的核心组件不再改变。因此,在 Transformer 中对所有模型应用全局更改的需求大大降低了,因此每个逻辑模式只存在一次变得不那么重要,因为它很少改变。

第二个认识是模型之间并不以双向方式相互依赖。最近发布的模型可能依赖于现有模型,但显然,现有模型不能在逻辑上依赖于其后继模型。例如,T5 在一定程度上是基于 BERT 构建的,因此 T5 的建模代码在逻辑上可能依赖于 BERT 的建模代码,但 BERT 不能在任何方面逻辑上依赖于 T5。因此,将 BERT 的注意力函数重构为与 T5 的注意力函数一起工作在逻辑上是不合理的 – 阅读 BERT 的注意力层的人不必了解任何关于 T5 的内容。同样,这反对将注意力层等组件集中到所有模型都可以访问的模块中。

另一方面,后继模型的建模代码可以在逻辑上依赖于其前驱模型。例如,DeBERTa-v2 的建模代码在某种程度上逻辑上依赖于 DeBERTa 的建模代码。通过确保 DeBERTa-v2 的建模代码与 DeBERTa 的代码保持同步,可显著提高可维护性。修复 DeBERTa 中的错误理想情况下也应修复 DeBERTa-v2 中的相同错误。在保持单一模型文件策略的同时,如何确保后继模型与其前驱模型保持同步?

现在,我们解释为什么在“重复自己”后面加上星号 * {}^{\textbf{*}} * 。即使看起来是这样,我们也不会盲目地复制所有现有的建模代码。Transformer 的核心维护者之一 Sylvain Gugger 发现了一个很好的机制,既遵守单一文件策略,又将可维护性成本限制在合理范围内。这个机制,松散地称为“复制机制”,允许我们用 # Copied from <predecessor_model>.<function> 语句标记逻辑组件,比如注意力层函数,强制标记的代码与 <predecessor_model><function> 相同。例如,DeBERTa-v2 类的这行代码强制整个类与 DeBERTa 的类相同,只是前缀 DeBERTav2 不同。这样,复制机制使建模代码非常易于理解,同时大大减少了维护工作。如果在前驱模型的函数中更改了一些代码,而后继模型的函数引用了该函数,则会自动纠正后继模型的函数。

缺点

显然,单一文件策略也有缺点,其中两个我们想在这里快速提到。

Transformer 的一个主要目标是为所有模型提供统一的推理和训练 API,以便用户可以在其设置中快速切换不同的模型。然而,如果建模文件不允许使用抽象的逻辑模式,确保模型之间的统一 API 就更加困难。我们通过运行大量测试(每天运行约 20,000 个测试)来解决这个问题,以确保模型遵循一致的 API。在这种情况下,单一文件策略要求我们在审查模型和测试添加时非常严格。

其次,对于机器学习模型的单个组件已经进行了大量研究。例如,研究团队在“重新思考Performers中的注意力”中研究了适用于所有现有预训练模型的新形式的注意力机制。我们应该如何将这样的研究融入到Transformers库中呢?这确实是一个问题。我们应该改变所有现有的模型吗?这与上面提到的第3点和第4点相矛盾。我们应该添加100多个以Performer...为前缀的新建模文件吗?这似乎是荒谬的。在这种情况下,很遗憾没有好的解决方案,我们选择不将这篇论文整合到Transformers中。如果这篇论文引起了更多的关注,并包含了强大的预训练检查点,我们可能会添加一些最重要的模型的新建模文件,例如modeling_performer_bert.py

结论

总而言之,在🤗 Hugging Face,我们坚信单一文件政策是Transformers的正确编码理念。

你有什么想法?如果你读到这里,我们非常希望听到你的意见!如果你想发表评论,请在这里访问相应的论坛帖子。