目录
Transformers 设计理念
如何为现代机器学习设计开源库
“不要重复自己 (Don’t Repeat Yourself)” ,或 DRY,是广为人知的软件开发原则。该原则出自《程序员修炼之道: 从小工到专家》 (英文名为 The pragmatic programmer),这是代码设计领域迄今为止阅读量最大的一本书。该原则言简意赅,即: 重用而不要重写其他地方已有的逻辑。这可以确保代码保持同步,使其更易于维护且更健壮。该做法使得对公共代码逻辑的任何更改都会统一地影响所有依赖该公共代码逻辑的代码。
乍一看,Hugging Face transformers 库的设计与 DRY 原则背道而驰。注意力机制的代码被复制到不同的模型文件里不下 50 次。有时整个 BERT 模型的代码都会被复制到其他模型文件中。贡献者在添加新模型时,如果新模型用到了现有的某个模型,我们经常强制要求他们把该现有模型的所有代码复制到新模型代码中,连一个小小的逻辑调整也不例外。我们为什么要这么做?是因为我们太懒抑或是因为我们无力承担将所有公共逻辑集中到一个地方所带来的工作量?
不,我们并不懒 —— 不在 transformers 库中使用 DRY 原则是有意之举。我们决定采用一种与 DRY 不同的设计原则,我们称之为 单模型文件 策略 (single model file policy)。 单一模型文件 策略要求,任何模型的所有代码都只应该放在一个文件中,这个文件就是该模型自己的模型文件。如果读者想了解 BERT 如何是进行推理的,他/她只需要阅读 BERT 的 modeling_bert.py
文件即可。通常情况下,我们拒绝任何将不同模型的相同子模块抽象并集中到一个新文件中的尝试。我们不想要一个包含所有可能的注意力机制的 attention_layer.py
。
我们为何作出这样的设计呢?我们将原因概括如下:
- 1. Transformers 生于开源,服务开源
- 2. 我们的产品是模型,我们的客户是那些阅读或修改模型代码的用户。
- 3. 机器学习领域发展极其迅速。
- 4. 机器学习模型是静态的。
1. 生于开源,服务开源
Transformers 积极鼓励来自外部的贡献。贡献一般有错误修复和新模型添加两类。如果有人发现了某个模型文件中的错误,我们希望他/她很容易就能修复它。没有什么比修复了一个 bug 却发现它导致了其他模型上的 100 个 bug 更令人沮丧的了。
因为每个模型代码相互独立,所以对于只了解他/她正在用的那个模型的人来说,修复它会轻松很多。同样,如果只添加一个新的模型文件,添加新的模型代码以及 review 相应的 PR 会更容易。贡献者不必弄清楚如何在不破坏现有模型的情况下向公共的注意力机制代码添加新功能,代码评审者也缺省地知道这个 PR 不会破坏任何一个现有模型。
2. 模型代码即产品
我们假设 transformers 库的很多用户不仅会阅读文档,而且会查看实际模型代码并有可能对其进行修改。鉴于 transformers 库被 fork 了 1 万多次,我们的 transformers 论文被引用了 1 千多次,这个假设应该是站得住脚的。
因此,最重要的是让第一次阅读 transformers 模型代码的人能够轻松理解并修改它。在单个模型文件中囊括该模型的所有必要逻辑组件有助于提高可读性和可修改性。处于同样的目的,我们也非常关注变量及方法命名的合理性,我们更喜欢表达力强/可读性强的代码,而不盲目追求短代码。
3. 机器学习正以惊人的速度发展
机器学习领域,尤其是神经网络领域的研究发展非常迅速。一年前最先进的模型今天可能已经过时了。我们甚至不知道明年会流行哪一种注意力机制、位置嵌入或架构。因此,我们无法定义适用于所有模型的标准模板。
例如,两年前,人们可能将 BERT 的自注意力层定义为所有 transformer 模型的标准注意力层。从逻辑上讲,“标准”注意力函数可以移到一个集中性的 attention.py
文件中。但是随后出现了在每层中添加相对位置嵌入的注意力层 (如 T5),多种不同形式的分块注意力层 (Reformer,Longformer,BigBird),以及将位置嵌入和词嵌入分离的注意力机制 (DeBERTa) …… 每当发生这类事情时,我们都不得不问自己是否应该调整“标准”注意力函数,还是说向 attention.py
添加一个新的注意力函数更好。但如果要添加新的注意力函数,我们该如何命名呢? attention_with_positional_embd
, reformer_attention
还有 deberta_attention
?
给机器学习模型的组件起通用的名字是危险的,因为关于名字意义的解释可能会很快改变或过时。例如,分块注意力指的是 GPTNeo 的分块注意力,还是 Reformer 的分块注意力,抑或是 BigBird 的分块注意力?注意层是自注意层、交叉注意层,还是两者都包含?如果我们最终决定用模型名称来命名注意力层,我们何不直接把这个注意力函数放在相应的模型文件中?
4. 机器学习模型是静态的
Transformers 库是不同研究团队创建的统一且完善的机器学习模型的集合。每个机器学习模型通常都对应一篇论文及其官方 GitHub 存储库。机器学习模型一旦发布,后面就很少会对其进行调整或更改。
相反,研究团队倾向于发布基于之前模型构建的新模型,而很少对已发布的代码进行重大更改。在决定 transformers 库的设计原则时,这是一个重要的认知。这意味着一旦将模型架构添加到 transformers 中,模型的基本组件就不会再改变。有可能会发现并修复一些错误,有可能会重命名方法或变量,也有可能对模型的输出或输入格式进行微调,但一般不会改动模型的核心组件。因此,对 transformers 中的所有模型进行大的全局性改动的需求大大减少,这使得每个逻辑模块只存在一次这件事情变得不那么重要,因为我们很少改动它。
第二个认知是模型之间 不 存在双向依赖。新发布的模型可能依赖于现存模型,但很明显,现存模型在逻辑上并不依赖于其前面的模型。例如,T5 部分建立在 BERT 之上,因此 T5 的模型代码在逻辑上可能依赖于 BERT 的模型代码,但 BERT 在逻辑上绝不可能依赖于 T5。因此,重构 BERT 的注意力功能以使其满足 T5 的要求这件事在逻辑上不合理 —— 阅读 BERT 的注意力层代码的人不需要对 T5 有任何了解。同样,这也促使我们不要将注意力层等组件集中到所有模型都可以访问的公共模块中。
另一方面,新模型的代码在逻辑上可能对其前面的模型有一定的依赖性。例如,DeBERTa-v2 的代码确实在某种程度上依赖于 DeBERTa 的代码。通过确保 DeBERTa-v2 的模型代码与 DeBERTa 的保持同步,可以显著提高可维护性。理论上来讲,修复 DeBERTa 中的 bug 的同时也应该修复 DeBERTa-v2 中的相同 bug。我们如何在确保新模型与其依赖的模型保持同步的同时维持 单模型文件 策略?
现在,我们解释一下为什么我们在 “重复自己” 之后加上星号$ {}^{\textbf{*}} $。我们不会无脑复制粘贴现有模型的相应代码,即使看上去我们好像就是这么做的。 Transformers 的核心维护者之一 Sylvain Gugger 发现了一种既尊重 单文件策略 又将可维护性成本控制在一定范围内的好机制。该机制,我们暂且称其为 “复制机制” ,允许我们使用 #Copied from <predecessor_model>.<function>
语句标记某些逻辑组件 (如注意力层函数),从而强制被标记的当前代码与 <predecessor_model>
的 <function>
相同。例如,DeBERTa-v2 类 里的这行代码强制整个 DebertaV2Layer
类除了类名前缀 DeBERTav2
之外须与 DebertaLayer 类 相同。如此可以看到,复制机制使模型代码非常容易理解,同时又显著减少了维护成本。如果有人改动了某个模型的某个函数,则我们可以使用一个自动化工具来更正依赖于这个模型的这个函数的所有其他模型的相应代码。
缺点
显然,单文件策略也有缺点,我们在这里简单提两个。
Transformers 的一个主要目标是为所有模型的推理和训练提供统一的 API,以便用户可以在不同模型之间快速切换。但是,如果不允许模型文件使用抽象这一设计模式,则确保跨模型的统一 API 会困难得多。我们通过运行 大量 测试 (截至本文撰写时,每天需要运行大约 2 万次测试) 来解决这个问题,以确保模型遵循一致的 API。在这种情况下,单文件策略要求我们在评审新模型和新测例时非常严格。
其次,有很多研究仅针对机器学习模型的单个组件。 例如 ,有研究团队会致力于研究一种适用于所有现有预训练模型的注意力机制的新形式,如 Rethinking Attention with Performers 一文所做的。我们应该如何将此类研究纳入 transformers 库?确实不好弄。我们应该改变所有现有模型吗?这将违背上文中的第 3 点和第 4 点。还是我们应该添加 100 多个新的模型文件,每个文件都以 Performer...
为前缀?这也很荒谬。遗憾的是,对此类情况我们还没有好的解决方案,我们只能选择不将该论文的成果集成到 transformers 中。等这篇论文获得更多关注并有了性能强大的预训练 checkpoint,我们可能会为其中最重要的模型添加一个新的模型文件,例如目前我们已有 modeling_performer_bert.py
。
以上内容来源于huggingface。写的很棒对于理解transformers库有很大帮助,所以完全复制到此处。
编码风格
Transformers是一个有自己的编码风格库。
- 模型的forward传递完全在建模文件中编写,同时完全独立于库中的其他模型。如果重用另一个模型的一个块,会复制代码并粘贴它,并在顶部添加一个# Copied from注释(参见 1、 2)。
- 代码采用描述性变量名并避免缩写。例如,优先使用activation而不是act。强烈不鼓励使用单字母变量名,除非它是for循环中的索引。
- 一般来说,transformers采用更长的显式代码,而不是简短的魔法代码。
- 避免在PyTorch中子类化nn.Sequential,而是子类化nn.Module并编写forward传递,这样任何使用代码的人都可以通过添加打印语句或断点来快速调试它。
- 函数签名应该进行类型注释。对于其余部分,好的变量名比类型注解更可读和可理解。
三大基类
Transformers库主要围绕每个模型的三个基类构建:
Model classes
模型类可以是PyTorch模型(torch.nn.Module)、Keras模型(tf.keras.Model)或JAX/Flax模型(flax.linen.Module),这些模型可以使用库中提供的预训练权重。PreTrainedModel
Configuration classes
配置类存储构建模型所需的超参数(如层数和隐藏大小)。你不总是需要自己实例化这些。特别是,如果你使用的是未经修改的预训练模型,创建模型时会自动实例化配置(这是模型的一部分)。PretrainedConfig
Preprocessing classes
预处理类将原始数据转换为模型接受的格式。tokenizer
存储每个模型的词汇表,并提供对喂给模型的一系列token embedding索引字符串编码和解码方法。Image processors
预处理视觉输入,feature extractors
预处理音频输入,processor
处理多模态输入。
# llava 文件结构,多模态模型
2023/12/20 16:24 5,490 configuration_llava.py
2024/02/27 12:13 5,420 convert_llava_weights_to_hf.py
2024/02/27 12:13 29,239 modeling_llava.py
2023/12/20 16:24 7,282 processing_llava.py
2023/12/20 16:24 1,814 __init__.py
所有这些类都可以从预训练实例实例化,本地保存,并通过三种方法在Hub上共享:
from_pretrained()
让你从库本身提供的预训练版本(支持的模型可以在模型Hub上找到)或用户本地(或服务器上)存储的版本中实例化模型、配置和预处理类。save_pretrained()
让你本地保存模型、配置和预处理类,以便使用from_pretrained()重新加载。push_to_hub()
让你将模型、配置和预处理类分享到Hub,因此它们可以很容易地被每个人访问。
要成功地理解一个模型的运作机理,首先要理解模型与其配置PreTrainedModel
和PretrainedConfig
之间的交互。为了示例目的,将模型称为BrandNewBert。
如你所见,在Transformers中使用了继承,但其将抽象级别保持在绝对最低。对于库中的任何模型,抽象层次从不超过两级。
每个模型都需要一个配置类,对于BrandNewBert,就是BrandNewBertConfig
。这个配置类继承自PretrainedConfig
,它包含了初始化模型所需的所有参数,如模型大小、隐藏层的数量、词汇表大小等。这些参数不仅有助于模型的实例化,还确保了模型能够以标准化的方式被序列化和反序列化。
BrandNewBertModel
继承自BrandNewBertPreTrainedModel
,后者又继承自PreTrainedModel
,就这样。作为一般规则,我们希望确保新模型只依赖于PreTrainedModel
。这是所有Transformers模型的基类。通过这种继承,BrandNewBert获得了一些重要的方法,比如[~PreTrainedModel.from_pretrained
] 和[~PreTrainedModel.save_pretrained
],这些方法处理模型的加载和保存,确保模型权重可以轻松地在不同环境之间共享和迁移。PreTrainedModel
也提供了钩子和方法来支持模型的自定义和扩展,同时确保与Transformers生态系统的兼容性。
BrandNewBert的实现应该专注于特定的模型架构和行为,特别是它的forward
方法。这是模型接收输入并产生输出的地方,根据预训练的配置进行操作。BrandNewBert可以使用BrandNewBertConfig
中定义的配置信息来构建其特定的神经网络架构。BrandNewBertModel.forward
应该在新的modeling_brand_new_bert.py
脚本中完全定义。
如果你希望添加的模型支持特定的任务,如文本分类或序列标记,你可以创建专用的头部类(例如BrandNewBertForMaskedLM
),这些类使用BrandNewBert作为基础模型,而不是直接从它继承。如BrandNewBertForMaskedLM不是继承自BrandNewBertModel,而是将BrandNewBertModel作为一个类变量,在其forward传递中调用,以保持低层次的抽象。这样做是为了保持模型架构的模块化和灵活性,允许相同的基础模型被重用于不同的任务,而无需为每个任务创建完全独立的模型版本。
llama模型文件继承关系,LlamaForSequenceClassification
、LlamaForCausalLM
几个类的区别主要在于forward
函数的差别。
class LlamaModel(LlamaPreTrainedModel):
def __init__(self, config: LlamaConfig): # 继承
super().__init__(config)
self.padding_idx = config.pad_token_id
self.vocab_size = config.vocab_size
...
class LlamaForCausalLM(LlamaPreTrainedModel):
_tied_weights_keys = ["lm_head.weight"]
def __init__(self, config):
super().__init__(config)
self.model = LlamaModel(config) # 继承
self.vocab_size = config.vocab_size
self.lm_head = nn.Linear(config.hidden_size, config.vocab_size, bias=False)
self.post_init()
transformers模型处理流程
一个transformers模型的添加步骤,在后续的描述中进行了一些调整。可以据此更好的理解该库的运行原理,轻松应对所有模型。此步骤在进行其他非transformers仓库的复现过程中也有很好的参考意义。
- 了解模型的理论知识
- 准备开发环境
- 设置原始存储库的调试环境
- 使用原始存储库和checkpoint成功运行' forward() '的脚本
- 成功添加模型骨架到Transformers
- 成功将original checkpoint 转换为Transformers checkpoint
- 在成功运行“forward()”,并给出与original checkpoint 相同的输出
- 完成模型测试
- 成功添加tokenizer
- 运行端到端集成测试
- 完成文档
- 添加demo notebook
- 上传模型权重到Hub
- 提交拉取请求
1. (可选)BrandNewBert 模型的理论知识
花一些时间阅读 BrandNewBert 的论文。论文中可能有一些难以理解的大段内容。如果是这种情况,没关系 - 别担心!目标不是深入理解论文,而是提取实施该模型所需的必要信息。但不必花太多时间在理论方面,而是专注于实践方面,即:
- brand_new_bert 是什么类型的模型?类似 BERT 的仅编码器模型?类似 GPT2 的仅解码器模型?类似 BART 的编码器-解码器模型?如果不熟悉这些之间的区别,请查看 model_summary。
- brand_new_bert 的应用有哪些?文本分类?文本生成?Seq2Seq 任务,如摘要生成?
- 模型的新特性是什么,使其与 BERT/GPT-2/BART 不同?
- 哪个已有的 Transformers 模型 最类似于 brand_new_bert?
- 使用的是什么类型的分词器?SentencePiece 分词器?WordPiece 分词器?与 BERT 或 BART 使用的分词器相同吗?
2. 接下来准备你的环境
通过单击存储库页面上的 “Fork” 仓库。将您的 transformers 分支克隆到本地磁盘,并添加基本存储库作为远程。
参见Transformers之环境安装。
- (https://github.com/huggingface/transformers)。这将在您的GitHub用户帐户下创建代码副本。
- 将您的transformer分支克隆到本地磁盘,并将基本存储库添加为远程存储库:
git clone https://github.com/[your Github handle]/transformers.git cd transformers git remote add upstream https://github.com/huggingface/transformers.git
Cookiecutter Templates
使用’ cookiecutter ‘工具需要安装所有的’ dev '依赖项。
git clone https://github.com/huggingface/transformers
cd transformers
pip install -e ".[dev]"
取决于您的操作系统,并且由于transformer的可选依赖项的数量正在增长,您可能会得到一个命令执行失败。如果是这种情况,请确保安装你正在使用的深度学习框架(PyTorch, TensorFlow和/或flex)然后做:
pip install -e ".[quality]"
安装完成后,你可以使用CLI命令add-new-model
来生成你的模型:
transformers-cli add-new-model-like
What is the model you would like to duplicate? Please provide the lowercase `model_type` (e.g. roberta): clip
What is the name (with no special casing) for your new model in the paper (e.g. RoBERTa)? clipcp
What identifier would you like to use for the `model_type` of this model? [clipcp]
What lowercase name would you like to use for the module (folder) of this model? [clipcp]
What prefix (camel-cased) would you like to use for the model classes of this model (e.g. Roberta)? [Clipcp]
What prefix (upper-cased) would you like to use for the constants relative to this model? [CLIPCP]
What will be the name of the config class for this model? [ClipcpConfig]
Please give a checkpoint identifier (on the model Hub) for this new model (e.g. facebook/FacebookAI/roberta-base):
Will your new model use the same processing class as clip (CLIPImageProcessor, CLIPFeatureExtractor, CLIPTokenizer, CLIPProcessor) (yes/no)? no
What will be the name of the tokenizer class for this model? [ClipcpTokenizer]
What will be the name of the image processor class for this model? [ClipcpImageProcessor]
What will be the name of the feature extractor class for this model? [ClipcpFeatureExtractor]
What will be the name of the processor class for this model? [ClipcpProcessor]
Should we add # Copied from statements when creating the new modeling file (yes/no)? [yes]
Should we add a version of your new model in all the frameworks implemented by clip (['pt']) (yes/no)? [yes]
一旦命令完成,应该有多个新文件自动生成:
3. 在原始存储库中运行预训练checkpoint
首先,您将使用原始的 brand_new_bert 存储库。通常,原始实现很“研究性”。这意味着文档可能缺失,代码可能难以理解。但这正是重新实现 brand_new_bert 的动机所在。
首先深入研究原始存储库。成功地在原始存储库中运行官方预训练模型通常是最困难的一步。根据我们的经验,花费一些时间熟悉原始代码库非常重要。您需要弄清以下内容:
- 在哪里找到预训练权重?
- 如何将预训练权重加载到相应的模型中?
- 如何独立于模型运行分词器?
- 跟踪一次前向传递,以便了解哪些类和函数需要进行简单的前向传递。通常情况下,您只需重新实现这些函数。
- 能够定位模型的重要组件:模型的类在哪里?是否有模型子类,例如 EncoderModel、DecoderModel?自注意力层在哪里?是否有多个不同的注意力层,例如自注意力、交叉注意力等?
- 您如何在原始环境中调试模型?您是否需要添加打印语句,是否可以使用像 ipdb 这样的交互式调试器,还是应该使用像 PyCharm 这样高效的 IDE 来调试模型?
- 在开始移植过程之前,您能够高效地调试原始存储库中的代码非常重要!
建议使用以下两种调试环境之一:
- Jupyter notebooks / google colab
- 本地 Python 脚本。
Jupyter 笔记本的优点是它们允许逐个单元格地执行,这有助于更好地将逻辑组件相互分开,并且具有更快的调试周期,因为中间结果可以被存储。此外,笔记本通常更容易与其他贡献者共享。
Jupyter 笔记本的明显缺点是,如果您不习惯使用它们,您将不得不花一些时间适应新的编程环境,而且您可能无法再使用您已知的调试工具,如 ipdb
。
对于每个代码库,一个很好的第一步总是加载一个小的pretrained checkpoint,并能够使用一个虚拟的整数向量作为输入 ID 来重现单个前向传递。这样的脚本可能如下所示(伪代码):
model = BrandNewBertModel.load_pretrained_checkpoint("/path/to/checkpoint/")
input_ids = [0, 4, 5, 2, 3, 7, 9] # 输入 id 的向量
original_output = model.predict(input_ids)
接下来,关于调试策略,通常有几种选择:
- 将原始模型分解为许多小的可测试组件,并对每个组件进行
forward
以进行验证。 - 将原始模型仅分解为原始的 tokenizer 和原始的 model,对这些进行
forward
,并使用中间的打印语句或断点进行验证。
选择哪种策略取决于原始代码库的不同。如果原始代码库允许您将模型分解为更小的子组件,例如,如果原始代码库可以轻松地在即时模式下运行,通常值得花费精力这样做。这样做有一些重要的优势:
- 在稍后比较原始模型和 Transformers 实现时,您可以自动验证每个组件是否与 Transformers 实现的相应组件匹配,而不是依赖于通过打印语句进行视觉比较。
- 它可以让您将将一个模型移植的大问题分解为只是移植单个组件的较小问题,从而更好地组织工作。
- 将模型分解为逻辑有意义的组件将有助于您更好地了解模型的设计,从而更好地理解模型。
- 在稍后的阶段,逐个组件测试可以帮助您确保在继续更改代码时不会发生回归。
然而,如果原始代码库非常复杂或仅允许将中间组件编译为已编译模式,则将模型分解为更小的可测试子组件可能耗费太多时间,甚至是不可能的。一个很好的例子是T5’s MeshTensorFlow库,它非常复杂,不提供将模型分解为其子组件的简单方法。对于这样的库,通常依赖于验证打印语句。
无论选择哪种策略,应该从首先调试起始层开始,最后调试结束层。
建议您按照以下顺序检查输出,每一层都应以以下顺序检查:
- 检查传递给模型的输入 ID
- 检查word embeddings
- 检查第一个 Transformer 层的输入
- 检查第一个 Transformer 层的输出
- 检查后续 n - 1 个 Transformer 层的输出
- 检查整个 BrandNewBert 模型的输出
输入 ID 应该是一个整数数组,例如 input_ids = [0, 4, 4, 3, 2, 4, 1, 7, 19]以下层的输出通常由多维浮点数组组成,可能如下所示:
[[ [-0.1465, -0.6501, 0.1993, ..., 0.1451, 0.3430, 0.6024],
[-0.4417, -0.5920, 0.3450, ..., -0.3062, 0.6182, 0.7132],
[-0.5009, -0.7122, 0.4548, ..., -0.3662, 0.6091, 0.7648],
...,
[-0.5613, -0.6332, 0.4324, ..., -0.3792, 0.7372, 0.9288],
[-0.5416, -0.6345, 0.4180, ..., -0.3564, 0.6992, 0.9191],
[-0.5334, -0.6403, 0.4271, ..., -0.3339, 0.6533, 0.8694]]],
添加到 Transformers 的每个模型都需要通过了一些集成测试,这意味着原始模型和Transformers 版本的重新实现必须在精度上完全相同的情况下给出相同的输出,误差容差为 0.001!由于同一模型在不同库框架中编写可能会产生略有不同的输出,这是正常的,误差容差为 1e-3(0.001)。因此,将多次将Transformers 版本的中间输出与 brand_new_bert 的原始实现进行比较,这种高效的调试环境绝对重要。以下是使调试环境尽可能高效的一些建议。
- 找到调试中间结果的最佳方法。原始存储库是使用 PyTorch 编写的吗?那么可能需要花时间编写一个较长的脚本,将原始模型分解为较小的子组件以检索中间值。
- 使用能找到的最小的pretrained checkpoint。checkpoint越小,调试循环就越快。如果预训练模型太大,以至于前向传递需要超过 10 秒,那么这就不是有效的。如果只有非常大的checkpoint可用,则创建一个在新环境中具有随机初始化权重的虚拟模型,并保存这些权重以与模型的Transformers 版本进行比较,可能会更有意义。
- 确保正在使用最简单的方式调用原始存储库中的前向传递。理想情况下,您希望找到原始存储库中只调用一次前向传递的函数,即通常称为 predict、evaluate、forward 或 call 的函数。不希望调试多次调用 forward 的函数,例如生成文本的函数,如 autoregressive_sample、generate。
- 尝试将tokenization与模型的前向传递分开。如果原始存储库显示了必须输入字符串的示例,那么尝试找出在前向调用中字符串输入何时更改为输入 ID,并从此点开始。这可能意味着可能需要编写自己的小型脚本或更改原始代码,以便可以直接输入 ID 而不是输入字符串。
- 确保调试设置中的模型not in training mode,这通常会导致模型因模型中存在多个 dropout 层而产生随机输出。确保调试环境中的前向传递是确定性的,以便不使用 dropout 层。或者,如果旧实现和新实现在同一框架中,则使用 transformers.utils.set_seed 来确保模型是确定性的。
4. 将生成的模型代码调整为 brand_new_bert
首先,我们将只关注模型本身,不关心分词器。所有相关的代码应该可以在生成的文件src/transformers/models/brand_new_bert/modeling_brand_new_bert.py 和 src/transformers/models/brand_new_bert/configuration_brand_new_bert.py 中找到。
现在,可以开始编码了. 在 src/transformers/models/brand_new_bert/modeling_brand_new_bert.py 中生成的代码将具有与 BERT 相同的架构,如果是仅编码器模型,或与 BART 相同的架构,如果是编码器-解码器模型。在这一点上,回想关于模型的理论知识:模型与 BERT 或 BART 有何不同? 实现这些变化通常意味着改变 self-attention 层、规范化层的顺序等。再次强调,查看 Transformers 中已存在模型的类似架构通常是有用的,以更好地了解应该如何实现模型。
注意,在这一点上,您不必非常确定您的代码是否完全正确或干净。相反,建议您先将原始代码的第一个不完善、复制粘贴版本添加到 src/transformers/models/brand_new_bert/modeling_brand_new_bert.py 中。根据经验,通过快速添加所需代码的第一个版本,并使用下一节中描述的转换脚本迭代地改进/纠正代码,效率要高得多。在这一点上,唯一需要工作的是实例化brand_new_bert
的 Transformers 实现,即以下命令应该可以工作:
from transformers import BrandNewBertModel, BrandNewBertConfig
model = BrandNewBertModel(BrandNewBertConfig())
上面的命令将根据 BrandNewBertConfig() 中定义的默认参数创建一个模型,其权重为随机权重,从而确保所有组件的init()
方法可用。
请注意,所有自定义初始化应该在 BrandnewBertPreTrainedModel 类的 _init_weights
方法中实现。它应该根据配置中的变量初始化所有子模块, _init_weights
会在被在模型的 __init__()
方法中的self.post_init()
调用。此外还会被from_pretrained()
方法调用。以下是具有BERT_init_weights
方法的示例:
def _init_weights(self, module):
"""Initialize the weights"""
if isinstance(module, nn.Linear):
module.weight.data.normal_(mean=0.0, std=self.config.initializer_range)
if module.bias is not None:
module.bias.data.zero_()
elif isinstance(module, nn.Embedding):
module.weight.data.normal_(mean=0.0, std=self.config.initializer_range)
if module.padding_idx is not None:
module.weight.data[module.padding_idx].zero_()
elif isinstance(module, nn.LayerNorm):
module.bias.data.zero_()
module.weight.data.fill_(1.0)
如果需要某些模块的特殊初始化,您可以使用一些更自定义的方案。例如,在 Wav2Vec2ForPreTraining 中,最后两个线性层需要具有常规 PyTorch nn.Linear
的初始化,但所有其他层应使用如上所示的初始化。
def _init_weights(self, module):
"""Initialize the weights"""
if isinstance(module, Wav2Vec2ForPreTraining):
module.project_hid.reset_parameters()
module.project_q.reset_parameters()
module.project_hid._is_hf_initialized = True
module.project_q._is_hf_initialized = True
elif isinstance(module, nn.Linear):
module.weight.data.normal_(mean=0.0, std=self.config.initializer_range)
if module.bias is not None:
module.bias.data.zero_()
_is_hf_initialized
标志在内部用于确保我们只初始化子模块一次。通过将其设置为 True,我们确保了对 module.project_q 和 module.project_hid 执行的自定义初始化不会被后来的操作覆盖,因此不会将 _init_weights
函数应用于它们。
5. 编写转换脚本
接下来,编写一个转换脚本,将原始存储库中用于调试 brand_new_bert 的checkpoint转换为与您刚刚创建的 Transformers 实现的 brand_new_bert 兼容的checkpoint。建议不要从头开始编写转换脚本,而是查看 Transformers 中已经存在的用于转换类似模型的转换脚本。通常,复制一个已经存在的转换脚本并对其进行微调以适应自己用例就足够了。
接下来,快速解释一下 PyTorch 模型如何存储层权重并定义层名称。在 PyTorch 中,层的名称根据给出的层的类属性的名称定义。让我们来定义一个名为 SimpleModel
的 PyTorch 虚拟模型,如下所示:
from torch import nn
class SimpleModel(nn.Module):
def __init__(self):
super().__init__()
self.dense = nn.Linear(10, 10)
self.intermediate = nn.Linear(10, 10)
self.layer_norm = nn.LayerNorm(10)
现在,我们可以创建这个模型定义的实例,它将填充所有权重:dense
、intermediate
、layer_norm
,都是随机权重。我们可以打印模型来查看其架构:
model = SimpleModel()
print(model)
这将打印如下:
SimpleModel(
(dense): Linear(in_features=10, out_features=10, bias=True)
(intermediate): Linear(in_features=10, out_features=10, bias=True)
(layer_norm): LayerNorm((10,), eps=1e-05, elementwise_affine=True)
)
可以看到层的名称是由 PyTorch 中的类属性的名称定义的。您可以打印特定层的权重值:
print(model.dense.weight.data)
以查看权重是否被随机初始化
tensor([[-0.0818, 0.2207, -0.0749, -0.0030, 0.0045, -0.1569, -0.1598, 0.0212,
-0.2077, 0.2157],
[ 0.1044, 0.0201, 0.0990, 0.2482, 0.3116, 0.2509, 0.2866, -0.2190,
0.2166, -0.0212],
[-0.2000, 0.1107, -0.1999, -0.3119, 0.1559, 0.0993, 0.1776, -0.1950,
-0.1023, -0.0447],
[-0.0888, -0.1092, 0.2281, 0.0336, 0.1817, -0.0115, 0.2096, 0.1415,
-0.1876, -0.2467],
[ 0.2208, -0.2352, -0.1426, -0.2636, -0.2889, -0.2061, -0.2849, -0.0465,
0.2577, 0.0402],
[ 0.1502, 0.2465, 0.2566, 0.0693, 0.2352, -0.0530, 0.1859, -0.0604,
0.2132, 0.1680],
[ 0.1733, -0.2407, -0.1721, 0.1484, 0.0358, -0.0633, -0.0721, -0.0090,
0.2707, -0.2509],
[-0.1173, 0.1561, 0.2945, 0.0595, -0.1996, 0.2988, -0.0802, 0.0407,
0.1829, -0.1568],
[-0.1164, -0.2228, -0.0403, 0.0428, 0.1339, 0.0047, 0.1967, 0.2923,
0.0333, -0.0536],
[-0.1492, -0.1616, 0.1057, 0.1950, -0.2807, -0.2710, -0.1586, 0.0739,
0.2220, 0.2358]]).
在转换脚本中,应该用与checkpoint中对应层的精确权重填充这些随机初始化的权重。例如:
# 获取匹配的层权重,例如
# 递归算法
layer_name = "dense"
pretrained_weight = array_of_dense_layer
model_pointer = getattr(model, "dense")
model_pointer.weight.data = torch.from_numpy(pretrained_weight)
在执行此操作时,必须验证PyTorch 模型中的每个随机初始化的权重与其对应的预训练checkpoint权重在形状和名称上完全匹配。为此,需要为形状添加断言语句,并打印出检查点权重的名称。例如,添加类似以下的语句:
assert (
model_pointer.weight.shape == pretrained_weight.shape
), f"Pointer shape of random weight {model_pointer.shape} and array shape of checkpoint weight {pretrained_weight.shape} mismatched"
此外,还应该打印出两种权重的名称,以确保它们匹配,例如:
logger.info(f"Initialize PyTorch weight {layer_name} from {pretrained_weight.name}")
如果形状或名称不匹配,您可能错误地将checkpoint权重分配给了Transformers 实现的随机初始化层。
形状不正确可能是因为在 BrandNewBertConfig()
中设置的参数与您要转换的检查点使用的参数不完全匹配。但也可能是因为 PyTorch 中的某个层的实现需要在进行转换之前对权重进行转置。
最后,还应该检查所有必需的权重是否已初始化,并打印出所有未用于初始化的检查点权重,以确保模型已正确转换。对于此步骤而言,将检查点权重正确加载到 Transformers 实现中,直到所有权重都正确加载到 Transformers 模型中为止。加载checkpoint到 Transformers 实现的正确性后,可以将模型保存在您选择的文件夹中 /path/to/converted/checkpoint/folder
,该文件夹应包含一个 pytorch_model.bin
文件和一个 config.json
文件:
model.save_pretrained("/path/to/converted/checkpoint/folder")
6. 实现前向传递
成功将预训练权重加载到 Transformers 实现后,现在应确保前向传递被正确实现。它应如下所示:
model = BrandNewBertModel.from_pretrained("/path/to/converted/checkpoint/folder")
input_ids = [0, 4, 4, 3, 2, 4, 1, 7, 19]
output = model(input_ids).last_hidden_states
很可能Transformers 实现和原始模型实现第一次不会给出完全相同的输出,或者前向传递会出错。不要失望 - 这是预期的!首先,确保前向传递不会引发任何错误。通常会发生错误的原因是使用了错误的维度,导致维度不匹配错误,或者使用了错误的数据类型对象,例如 torch.long
而不是 torch.float32
。
确保了前向传递不会引发任何错误后,现在应确保输出精度为 1e-3
。首先,应确保输出形状是相同的,即对于 Transformers 实现脚本和原始实现,outputs.shape
应产生相同的值。接下来,应确保输出值也相同。这是添加新模型中最困难的部分之一。 Transformers 实现和原始模型实现输出不相同时常见的错误包括:
- 有些层未添加,例如未添加激活层,或者遗漏了残差连接
- 词嵌入矩阵未进行关联
- 使用了错误的位置嵌入,因为原始实现使用了偏移量
- 前向传递过程中应用了 dropout。要解决此问题,请确保 model.training 为 False,并且在前向传递过程中不会激活任何 dropout 层,例如将 self.training 传递给 PyTorch 的 functional dropout
修复问题的最佳方法通常是将原始实现和Transformers 实现的前向传递放在一起进行对比,并检查是否存在任何差异。理想情况下,您应该在两个实现的前向传递中添加许多打印语句,以及在网络的相同位置分别打印出中间输出,并逐步删除显示相同值的打印语句。
当您确信两个实现产生相同的输出时,请使用 torch.allclose(original_output, output, atol=1e-3)
来验证输出是否等价。这样,最困难的部分就完成了!
7. 添加所有必要的模型测试用例
此时,已成功添加了新模型。但很可能该模型尚未完全符合所需的设计。为了确保实现完全兼容 Transformers,应通过所有常见的测试。Cookiecutter 应该自动为模型添加了一个测试文件,可能位于相同的位置 tests/models/brand_new_bert/test_modeling_brand_new_bert.py
。运行此测试文件以验证所有常见测试是否通过:
pytest tests/models/brand_new_bert/test_modeling_brand_new_bert.py
完成所有常见测试后,现在至关重要的是确保通过添加的所有特殊功能进行了测试,以便在 BrandNewBertModelTester
/BrandNewBertModelTest
下添加一个单独的测试。这部分经常被忽视。
首先,应添加集成测试。这些集成测试基本上与早期用于实现模型到 Transformers 的调试脚本执行相同的操作。Cookiecutter 已经为您添加了这些模型测试的模板,称为 BrandNewBertModelIntegrationTests
,您只需要填写它们即可。要确保这些测试通过,请运行:
RUN_SLOW=1 pytest -sv tests/models/brand_new_bert/test_modeling_brand_new_bert.py::BrandNewBertModelIntegrationTests
如果您使用的是 Windows,您应该将 `RUN_SLOW=1` 替换为 `SET RUN_SLOW=1`
其次,应该在单独的测试文件中添加所有特定于 brand_new_bert 的功能。这部分经常被忽视,但在两方面非常有用:
- 它通过展示 brand_new_bert 的特殊功能应如何工作,帮助将您在添加模型过程中获得的知识传递给社区。
- 未来的贡献者可以通过运行这些特殊测试快速测试对模型的更改。
8. 实现tokenizer
接下来,我们应该添加 brand_new_bert 的tokenizer。通常,分词器与 Transformers 中的现有tokenizer等效或非常相似。
找到/提取原始tokenizer文件并将其加载到 Transformers 的tokenizer实现中非常重要。
为了确保tokenizer正常工作,建议首先在原始存储库中创建一个脚本,该脚本输入一个字符串并返回 input_ids
。它可能类似于这个(伪代码):
input_str = "This is a long example input string containing special characters .$?-, numbers 2872 234 12 and words."
model = BrandNewBertModel.load_pretrained_checkpoint("/path/to/checkpoint/")
input_ids = model.tokenize(input_str)
可能需要再次深入原始存储库,以找到正确的tokenizer函数,或者甚至需要更改原始存储库,以仅输出 input_ids
。编写一个使用原始存储库的功能性标记脚本后,应创建一个Transformers 的类似脚本。它应该类似于这样:
from transformers import BrandNewBertTokenizer
input_str = "This is a long example input string containing special characters .$?-, numbers 2872 234 12 and words."
tokenizer = BrandNewBertTokenizer.from_pretrained("/path/to/tokenizer/folder/")
input_ids = tokenizer(input_str).input_ids
当两者的 input_ids
产生相同的值时,作为最后一步,还应添加一个tokenizer测试文件。
类似于 brand_new_bert 的模型测试文件,brand_new_bert 的tokenizer测试文件应包含几个硬编码的集成测试。
9. 运行端到端集成测试
添加了tokenizer后,您还应该使用模型和tokenizer添加一些端到端集成测试到tests/models/brand_new_bert/test_modeling_brand_new_bert.py
中。这样的测试应该展示一个有意义的文本对样本,说明 Transformers 的实现是否如预期那样工作。一个有意义的文本对样本可以包括源到目标翻译对、文章到摘要对、问题到答案对等。如果没有一个迁移的checkpoint已经在一个下游任务上进行了微调,只需依赖模型测试即可。最后,为了确保模型完全正常,建议您也在 GPU 上运行所有测试。如果忘记了向模型的内部张量添加一些 .to(self.device)
语句,会导致错误。
10. 添加Docstring
现在,所有 brand_new_bert 的必要功能都已添加 ,唯一剩下的是添加一个良好的Docstring和一个文档页面。Cookiecutter 应该已经添加了一个名为 docs/source/model_doc/brand_new_bert.md
的模板文件。用户通常会在使用模型之前首先查看此页面。因此,文档必须清晰易懂。
接下来,请确保添加到 src/transformers/models/brand_new_bert/modeling_brand_new_bert.py
的Docstring是正确的,并包含所有必要的输入和输出。我们有一份关于编写文档和文档字符串格式的详细指南。要谨慎对待文档,因为文档通常是社区与模型的第一个接触点,应该至少和代码一样小心对待。
代码重构
现在已经添加了 brand_new_bert 的所有必要代码。此时,运行以下命令,以纠正一些潜在的错误代码风格:
make style
并验证代码风格是否通过质量检查:
make quality
在 Transformers 中可能仍然会有一些其他非常严格的设计测试失败,这会显示在您的拉取请求的测试中。这往往是因为文档字符串中缺少一些信息或者某些名称不正确。
最后,现在是时候在确保代码正确运行后重构代码了。所有测试通过后,现在是时候再次审视已添加的代码并进行一些重构了。
11. 添加notebook
添加一个笔记本非常有帮助,其中应该详细展示了如何使用 brand_new_bert 进行推断和/或在下游任务上进行微调。