libai 配置系统设计文档
配置系统主要是为模型结构的定义和训练超参提供配置参数,好的配置系统可以让模型的定义以及训练流程更清晰,也可以让用户一眼能够了解到不同模型配置差异以及训练差异在哪里,而且还可以让模型的复现变得更加容易。
一个好的配置系统我认为有下面4个特点:
- 生成的配置文件可以序列化和反序列化,这样的好处有两个:1. 用户可以通过 diff config 轻松获得两次训练的配置差异;2. 直接 load config 便能复现别人的结果,能够节省大量沟通的时间成本;
- 配置系统层次清晰,比如模型结构相关的参数、网络训练的超参以及数据读取的参数能够清晰的分开;
- 参数在配置之后是可修改的,因为有的时候需要根据数据集的 size 再动态设置训练的 iters 等,这就需要用户定义好配置之后,参数可以再次被修改,但是需要有一定的保护机制;
- 用户可以灵活增加配置参数而不需要侵入式修改 libai 的内部代码,希望用户可以将 libai 作为一个 lib 进行使用,而不是 clone 之后在上面修改;
调研了市面上比较常见的配置系统,下面是一个总结
megatron
使用 python argparser 进行定义
def parse_args(extra_args_provider=None, defaults={},
ignore_unknown_args=False):
"""Parse all arguments."""
parser = argparse.ArgumentParser(description='Megatron-LM Arguments',
allow_abbrev=False)
# Standard arguments.
parser = _add_network_size_args(parser)
parser = _add_regularization_args(parser)
parser = _add_training_args(parser)
parser = _add_initialization_args(parser)
...
问题是定义的参数无法序列化,不好对比两次训练的超参差别,在代码中通过 get_args() 直接穿透到内层获得参数,同时参数的修改没有保护机制,非常容易误修改参数;
huggingface
使用了 python class 进行定义
@dataclass
class TrainingArguments:
"""
TrainingArguments is the subset of the arguments we use in our example scripts **which relate to the training loop
itself**.
Using :class:`~transformers.HfArgumentParser` we can turn this class into `argparse
<https://docs.python.org/3/library/argparse.html#module-argparse>`__ arguments that can be specified on the command
line.
Parameters:
output_dir (:obj:`str`):
The output directory where the model predictions and checkpoints will be written.
overwrite_output_dir (:obj:`bool`, `optional`, defaults to :obj:`False`):
If :obj:`True`, overwrite the content of the output directory. Use this to continue training if
:obj:`output_dir` points to a checkpoint directory.
...
"""
output_dir: str = field(
metadata={"help": "The output directory where the model predictions and checkpoints will be written."},
)
overwrite_output_dir: bool = field(
default=False,
metadata={
"help": (
"Overwrite the content of the output directory. "
"Use this to continue training if output_dir points to a checkpoint directory."
)
},
)
...
huggingface 的配置系统非常繁琐,阅读代码的时候很不方便,整个配置被分散在了不同的 python 文件中,貌似可以保存配置,但是不确定能不能读取配置进行再次训练,也不了解新增配置参数是否方便;
detectron2 & ColossalAI & mmdet
他们的配置系统类似于传统的 yacs-based 配置系统,不过都是利用 dict 的方式进行定义
# d2
model = L(GeneralizedRCNN)(
backbone=L(FPN)(
bottom_up=L(ResNet)(
stem=L(BasicStem)(in_channels=3, out_channels=64, norm="FrozenBN"),
stages=L(ResNet.make_default_stages)(
depth=50,
stride_in_1x1=True,
norm="FrozenBN",
),
out_features=["res2", "res3", "res4", "res5"],
),
in_features="${.bottom_up.out_features}",
out_channels=256,
top_block=L(LastLevelMaxPool)(),
),
proposal_generator=L(RPN)(
in_features=["p2", "p3", "p4", "p5", "p6"],
...
# colossal
model = dict(
type='VisionTransformerFromConfig',
tensor_splitting_cfg=dict(
type='ViTInputSplitter2D',
),
embedding_cfg=dict(
type='ViTPatchEmbedding2D',
img_size=IMG_SIZE,
patch_size=PATCH_SIZE,
embed_dim=DIM,
),
...
# mmdet
norm_cfg = dict(type='BN', requires_grad=False)
model = dict(
type='FasterRCNN',
backbone=dict(
type='ResNet',
depth=50,
num_stages=3,
strides=(1, 2, 2),
...
这一类配置系统和传统的 yaml 类型的配置系统相比,优势就是非常灵活,利用了 python 语法可以方便的增加和修改字典,也可以在配置系统中写一些简单的算术或者是简单函数,同时也可以非侵入式增加配置参数。 整体配置的定义非常清晰,网络定义,dataloader 以及 训练流程可以完全由配置系统构成,也支持序列化和反序列化,应该能够支持用户任意组合内置模块进行训练而不需要额外写代码,只需要完成配置即可。

补充fairseq的配置方法
- fairseq采用dataclass和parser结合的方法,dataclass负责通用的参数,如训练、测试时相关的配置;parser负责专用的参数,如模型中参数。
- 对于model、task、criterion这三种类,都包含着add_args这样一个静态函数,负责添加专用参数。
- 上述这三个类采用注册机制,以model类为例,解析parser的时候分两步,第一步解析通用参数,得到model类型,根据model类型再解析model中的专有参数。
- 优点:专有参数相互隔离,不会丢失参数。
- 缺点:这种方法将参数输出,不支持序列化和反序列化。
Configuration System 方案讨论
配置系统的确定需要同时考虑模型构建,数据读取以及训练流程等构建,下面是一些配置系统的使用方式以及讨论。
基本的配置方式
当前决定采用 yacs-based 的配置系统 + 命令行参数共同构建,其中使用 python dict 来替代 yaml 有以下几点考虑:
- yaml 并不够灵活,python dict 可以利用 python 语法轻易完成增删改查的功能;
- python dict 内部可以写一些简单的运算,比如要算一下
32*100*1024,可以不用人工算好; - 可以使用更复杂的嵌套和数据类型;
- import 以及 compose 其他的 config 文件,可以利用熟悉的 python 语法;
整体的配置有下面三个好处:
- 配置文件可以序列化和反序列化,用户可以读取任意序列化后的配置文件进行结果复现,可以同时接受 python/yaml 格式的文件;
- 不同模块的配置文件可以分成不同的 python 文件进行放置,结构更清晰,查找更方便;
- 加入命令行参数可以让方便用户进行简单的参数修改;
一个简单的例子
一个简单的配置文件如下
# examples_cfg.py
from ._base_.models.bert import model
from ._base_.default_train import train
# User can customize these arguments in model
model.embedding.num_embeddings = 12345
model.blocks.hidden_size = 321
model.blocks.num_layers = 3
# User can customize these arguments in train
train.eval_period = 2000
用户可以通过
python3 train_net.py --config-file configs/examples_cfg.py train.eval_period=1000
读取这个配置文件,同时在命令行对 train.eval_period 的值进行简单的修改。
配置系统构建完之后,会保存为如下 config.yaml 文件
model:
_target_: libai.models.Bert
blocks: {_target_: libai.models.gpt.GPT.make_default_blocks, hidden_size: 321, num_layers: 3}
embedding: {_target_: libai.layers.VocabEmbedding, embedding_dim: 756, num_embeddings: 12345}
train:
amp: {enabled: false}
checkpointer: {max_to_keep: 100, period: 5000}
device: cuda
eval_period: 1000
init_checkpoint: ''
log_period: 20
max_iter: 5
output_dir: ./output
如果用户希望复现结果,可以使用下面的命令
python3 train_net.py --config-file output/config.yaml
LayCall 的优势
上面是一个基本的 config 使用,除此之外,detectron2 还新增了一个 LazyCall 的方案,其可以进行递归实例化,主要的模式是使用字典来描述 class/function 的调用关系,字典中包含的 _target_ 即为要调用的路径,比如 modules.submodule.class_name,其他的 keys 表示调用的传参,同时这些参数本身也可以通过递归调用进行实例化。
基于 LazyCall 的机制,一个非常的复杂的 Mask R-CNN 可以通过下面的配置文件进行构建
model = L(GeneralizedRCNN)(
backbone=L(FPN)(
bottom_up=L(ResNet)(
stem=L(BasicStem)(in_channels=3, out_channels=64, norm="FrozenBN"),
stages=L(ResNet.make_default_stages)(
depth=50,
stride_in_1x1=True,
norm="FrozenBN",
),
out_features=["res2", "res3", "res4", "res5"],
),
in_features="${.bottom_up.out_features}",
out_channels=256,
top_block=L(LastLevelMaxPool)(),
),
proposal_generator=L(RPN)(
in_features=["p2", "p3", "p4", "p5", "p6"],
head=L(StandardRPNHead)(in_channels=256, num_anchors=3),
anchor_generator=L(DefaultAnchorGenerator)(
sizes=[[32], [64], [128], [256], [512]],
aspect_ratios=[0.5, 1.0, 2.0],
strides=[4, 8, 16, 32, 64],
offset=0.0,
),
anchor_matcher=L(Matcher)(
thresholds=[0.3, 0.7], labels=[0, -1, 1], allow_low_quality_matches=True
),
box2box_transform=L(Box2BoxTransform)(weights=[1.0, 1.0, 1.0, 1.0]),
batch_size_per_image=256,
positive_fraction=0.5,
pre_nms_topk=(2000, 1000),
post_nms_topk=(1000, 1000),
nms_thresh=0.7,
),
roi_heads=L(StandardROIHeads)(
num_classes=80,
batch_size_per_image=512,
positive_fraction=0.25,
proposal_matcher=L(Matcher)(
thresholds=[0.5], labels=[0, 1], allow_low_quality_matches=False
),
box_in_features=["p2", "p3", "p4", "p5"],
box_pooler=L(ROIPooler)(
output_size=7,
scales=(1.0 / 4, 1.0 / 8, 1.0 / 16, 1.0 / 32),
sampling_ratio=0,
pooler_type="ROIAlignV2",
),
box_head=L(FastRCNNConvFCHead)(
input_shape=ShapeSpec(channels=256, height=7, width=7),
conv_dims=[],
fc_dims=[1024, 1024],
),
box_predictor=L(FastRCNNOutputLayers)(
input_shape=ShapeSpec(channels=1024),
test_score_thresh=0.05,
box2box_transform=L(Box2BoxTransform)(weights=(10, 10, 5, 5)),
num_classes="${..num_classes}",
),
mask_in_features=["p2", "p3", "p4", "p5"],
mask_pooler=L(ROIPooler)(
output_size=14,
scales=(1.0 / 4, 1.0 / 8, 1.0 / 16, 1.0 / 32),
sampling_ratio=0,
pooler_type="ROIAlignV2",
),
mask_head=L(MaskRCNNConvUpsampleHead)(
input_shape=ShapeSpec(channels=256, width=14, height=14),
num_classes="${..num_classes}",
conv_dims=[256, 256, 256, 256, 256],
),
),
pixel_mean=[103.530, 116.280, 123.675],
pixel_std=[1.0, 1.0, 1.0],
input_format="BGR",
)
如此复杂的模型都可以利用 LazyCall 来构建,利用他来构建 Bert 当然也很容易
from libai.config import LazyCall as L
from libai.models import Bert
from libai.layers import VocabEmbedding, TransformerLayer
model = L(Bert)(
embedding=L(VocabEmbedding)(num_embeddings=500, embedding_dim=756),
blocks=L(Bert.make_default_blocks)(
num_layers=12, layer_class=TransformerLayer, hidden_size=123
),
add_pooler=False,
)
不过这样的构建方式依赖于模型的整体代码,所以下面讨论一下模型构建的一些问题。
模型构建
对于一些比较小的 submodule,因为其本身就是其他更大组件的 components,比如 Linear,Layernorm 等等,所以这种小 module 直接定义参数就好,可以和配置系统解耦,因为其本身的参数也比较少,另外也方便别人来借鉴我们的 submodule,增加我们的 credits。
一些比较大的 module 或者是 models 的构建一般有下面三种方式
- 第一种方式是通过将构成这个 models 的 submodule 作为参数传进来;
class Bert1(nn.Module):
def __init__(self, embedding, blocks, add_pooler=True,) -> None:
super().__init__()
self.embedding = embedding
self.add_pooler = add_pooler
for i, block in enumerate(blocks):
name = f"layer_{i}"
self.add_module(name, block)
@staticmethod
def make_default_blocks(num_layers, layer_class=None, **kwargs):
if layer_class is None:
layer_class = TransformerLayer
layers = []
for i in range(num_layers):
kwargs["layer_idx"] = i
layers.append(layer_class(**kwargs))
return layers
- 第二种方式是将参数传进来,在内部进行 submodule 的实例化
class Bert2(nn.Module):
def __init__(self, num_vocab, hidden_size, blocks, add_pooler=True,) -> None:
super().__init__()
self.embedding = VocabEmbedding(num_vocab, hidden_size)
self.add_pooler = add_pooler
for i, block in enumerate(blocks):
name = f"layer_{i}"
self.add_module(name, block)
- 第三种方式是将 cfg 传进来,在内部获取参数,然后再进行实例化
class Bert3(nn.Module):
def __init__(self, cfg, blocks) -> None:
super().__init__()
self.num_vocab = cfg.num_vocab
self.hidden_size = cfg.hidden_size
self.add_pooler = cfg.add_pooler
self.embedding = VocabEmbedding(self.num_vocab, self.hidden_size)
for i, block in enumerate(blocks):
name = f"layer_{i}"
self.add_module(name, block)
至于其他的变种,比如通过一个类方法对参数进行解析等等,都是类似的,如果大家还有其他的参考也可以在下面补充。
下面是三种方法的一个比较:
-
第二种方式和第三种方式类似,只是传参是 cfg 还是具体的超参数,直接传参有可能会让参数变得非常多,比如20个以后的超参,而 cfg 的问题是用户无法一目了然地查看这个模型到底用了哪些参数;
-
第一种方式和其他方式对比来看,第一种方式是将 submodule 在外面定义之后再作为参数传进来,这样传参比较清晰,同时参数个数也比较少,如果要替换也很方便,不需要改代码;
-
2、3是将 submodule 的实例化过程定义在了 model 内部,如果用户要替换其中一个 submodule,比如用户想用自己定义的 embedding layer,需要继承这个 model class,然后重写一遍 init;
下面列一下不同的 model 传参方式的配置系统
-
通过 submodule 进行传参;
model = L(Bert)( embedding=L(VocabEmbedding)(num_embeddings=500, embedding_dim=756), blocks=L(Bert.make_default_blocks)( num_layers=12, layer_class=TransformerLayer, hidden_size=123 ), add_pooler=False, ) -
通过实际的参数进行传参;
model = L(Bert)( num_vocab=500, hidden_size=756, blocks=L(Bert.make_default_blocks)( num_layers=12, layer_class=TransformerLayer, hidden_size=123 ), add_pooler=False, ) -
通过 cfg 进行传参;
bert_cfg = dict(num_vocab=500, hidden_size=756, add_pooler=False) model = L(Bert)( bert_cfg, blocks=L(Bert.make_default_blocks)( num_layers=12, layer_class=TransformerLayer, hidden_size=123 ) )这里大家可以讨论以及发表自己的意见。
其他部分
除了模型的定义之后,其他部分都是类似的,也更简单,比如数据集的构建,可以通过下面的方式
train_loader = L(build_train_loader)(
dataset=L(get_dataset_name)(names="bert_chinese")
)
Q&A 和讨论
Q: 这里是使用instantiate函数将所有东西实例化吗,能不能让cfg包含所有参数,然后创建模型或dataloader的时候通过cfg.xxx来访问那个参数?
A: instantiate 只会实例化 _target_ 对象,cfg 包含所有的参数是可以的,也可以通过 cfg.xxx 来访问具体的配置。
Q: dict下的配置和parser下的配置是什么关系? A: 从 dict 下读取完整配置,parser 下的配置是修改 dict 中的内容。
Q: 怎么添加默认参数?bert模型可能有base、large这样的区分,配置默认参数,让用户可以使用“bert-base”这样的方式获取它的全部配置,无须改动其他参数。 A: 可以读取不同的 py 文件进行配置的迁移,下面是一个例子
# bert_base.py
from libai.config import LazyCall as L
from libai.models import Bert
from libai.layers import VocabEmbedding, TransformerLayer
model = L(Bert)(
embedding=L(VocabEmbedding)(num_embeddings=500, embedding_dim=756),
blocks=L(Bert.make_default_blocks)(
num_layers=12, layer_class=TransformerLayer, hidden_size=123
),
add_pooler=False,
arg_1=1,
arg_2=2,
arg_3=3,
arg_4=4,
)
# bert_large.py
from .bert_base import model
model.embedding.embedding_dim = 678
model.arg_1 = 1000
# bert_small.py
from .bert_base import model
model.arg_4 = 10
Q: 在上一个问题的情况下,如何支持参数重写,例如在微调阶段,可能会使用不同的dropout,怎么适应这种需求? A: 如果 dropout 作为一个模型的超参,那么可以用上面类似的方式
# pretrain_bert.py
model = L(Bert)(
embedding=L(VocabEmbedding)(num_embeddings=500, embedding_dim=756),
blocks=L(Bert.make_default_blocks)(
num_layers=12, layer_class=TransformerLayer, hidden_size=123
),
add_pooler=False,
arg_1=1,
arg_2=2,
arg_3=3,
arg_4=4,
dropout_prob=0.1,
)
# finetune_bert.py
from .pretrain_bert import model
model.dropout_prob = 0.5
模型构建部分,我不太赞成第一种,首先bert、gpt这样的模型,它的backstone很固定,都是transformer layer,可能attention等其他细节会发生变化,但可以用户重新搭建一个模型,然后换另一个名字。方法2、3有各自的优缺点,可以结合一下,既可以方便创建类,也可以清楚模型所涉及的参数:
class Bert(nn.Module):
def __init__(self, num_vocab, hidden_size, blocks, add_pooler=True,) -> None:
super().__init__()
self.embedding = VocabEmbedding(num_vocab, hidden_size)
self.add_pooler = add_pooler
for i, block in enumerate(blocks):
name = f"layer_{i}"
self.add_module(name, block)
@classmethod
def build_model(cls, cfg):
return Bert(cfg.num_vocab, cgf.hidden_size)
另外,我们需要以LazyCall来实例化模型吗,我感觉这个需求也没有。我认为的配置文件需求如下:
- 参数分为通用参数和专有参数。通用参数为训练、推理等常用的配置,与模型、任务无关。专有参数为模型、任务的相关参数,例如,模型的hidden_size,dropout,num_heads,数据集里的mlm_prob。程序先解析通用参数,再根据通用参数中的模型类型、任务类型,解析出专有参数。
- 配置文件可序列化、反序列化,易保存。
- 使用cfg.xxx访问参数,不用它来实例化模型或其他组件,具体如何使用参数,由用户或本框架决定。
- 参数可以分组,方便管理与查找,也可以避免冗余。
- 有些参数应当设置默认值,这也可以减少配置文件中参数的数量。
- 配置文件易修改,用户可以轻松修改或重写配置文件。
你们看看这个需求有什么疑问,先把需求定下来,再看解决方案。
需求一开始就写好了,你可以滑上去看,目前的解决方案也可以解决这些需求
我觉得讨论模型的实例化方式就可以了
那就先讨论两个问题吧,1.怎么实例化模型,2.怎么给模型添加参数。这两个问题彼此关联,所以放在一起讨论。
如果采用方法一实例化模型,模型的参数也在写配置文件时确定下来,由用户按照规定的格式搭建模型并指定参数。这种方法在nlp领域没有得到推广,nlp中都是解析参数后根据参数实例化模型,所以我在上面提了另一种方式实例化。这种方式有个缺点,就是不同模型的参数不同,如何做到模型参数隔离,即使用哪个模型,cfg里只包含哪个模型的参数,避免参数冗余和缺失。参照其他的库,在模型里添加add_args参数,这里添加模型相关的参数。然后分两步把参数解析出来。
@Ldpe2G @CPFLAME 你们怎么看?
我觉得不用因为 nlp 里面不这样做就去否定这个做法,这个库本身的定位也不是只做 nlp 的内容,我们应该去讨论哪种方式更好,不用受限在 nlp 的领域
模型的参数不需要通过 add_args 进行添加,因为现在的配置系统本身已经没有 python argparser,只需要在 init 里面定义好参数即可,最终的参数隔离是通过不同的模型的配置文件进行隔离的,比如
# bert_base.py
bert_cfg = dict(num_vocab=500, hidden_size=756, add_pooler=False, num_layers=100) # 这里的值可以理解为给定的默认值
# gpt_base.py
gpt_cfg = dict(num_vocab=1000, hidden_size=1000)
最终 dataloader 的配置,distributed 配置,model 的配置,train 的配置都在不同的配置文件中进行隔离,使用的时候通过 import 这些文件,然后可以任意修改,在训练开始前进行序列化
个人感觉还是不合适,nlp的backstone少,不会出现cv里面框架和backstone排列组合的情形。这个框架最开始的用户还是做nlp预训练的,得迎合他们的习惯。通过LazyCall方法生成模型,会增加学习框架的难度。采用传参加初始化,或build_model这样的方法,比较简单,用户可以轻松上手。
另外,采用dict的方式,和dataclass有什么区别?我看到了好几个库都使用dataclass,这是python的新特性,类似于命名元组,可以设置类型,可以添加默认值,可以有help说明。但目前不清楚dict和dataclass的优劣,可能新更新的库都用dataclass?可以考虑采用这种方法。
现在卡在这里不太好,建议先确定一种简单的,易于实现的,让项目推动下去。即使是args,也可以继续做。之后在性能调试,或者使用时发现缺点,到时候改也行。最好今天确定下来,然后按照一个方案做下去。让框架早日处于可调试,可使用状态。
- 我并没有和你 argue 用什么方式初始化模型,我只是列举了三种方式,然后并没有主张说一定要用什么方式;
- 我觉得使用 dict 已经足够,如果不能说出 dataclass 比 dict 在这里有什么显著优势,我不倾向引入新特性,用户并不熟悉;
- 我也不觉得现在会卡在这里,可以继续去完善 module,写模型,搞 dataloader,搞 trainer,配置系统同步推进,这些事情并不依赖配置系统;
- lazycall 的作用是为了替换整个框架的 register 机制,这个我们可以后面再讨论,而且 lazycall 本身是配置系统的一个功能;
目前看来第一种方法是最灵活的,
但是感觉有一个问题就是, 用户没办法第一眼看到模型结构, 得配合config中submodule的传参, 看看传进来的子函数是什么, 然后配合model.py, 才能确定整体的网络结构.
在阅读代码的时候, 对于用户来说这个还是有点麻烦的, 这个问题有没有办法避免?
我们今天把模型定义的方式讨论好就可以推进后续的工作,后续并不依赖整个配置系统确定才能写代码,所以大家可以都发表一下各自的意见,目前应该就是我列的这三种方式,如果有同学知道其他的方式还可以补充 @CPFLAME @dangkai4u @rentainhe @Ldpe2G
我有个糟糕的预感, 可能第一种写法, 在阅读代码的时候会最后变成 层层嵌套的那种方式. 要看一个东西可能得跳好几个地方.
class Bert(nn.Module):
def __init__(self, num_vocab, hidden_size, blocks, add_pooler=True)
super().__init__()
self.embedding = VocabEmbedding(num_vocab, hidden_size)
self.add_pooler = add_pooler
for i, block in enumerate(blocks):
name = f"layer_{i}"
self.add_module(name, block)
@classmethod
def build_model(cls, cfg):
return Bert(cfg.num_vocab, cgf.hidden_size, cfg.num_layers)
model = Bert.build_model(cfg)
这种方式怎么样,把方法2和3结合起来了。
这个我记得星宇在code review里面提过
这样确实会简洁一点
如果其他人没有意见,那就采用这种方式吧
配置系统
目前的配置系统基于下面4个特点进行构建:
- 生成的配置文件可以序列化和反序列化,这样的好处有两个:1. 用户可以通过 diff config 轻松获得两次训练的配置差异;2. 直接 load config 便能复现别人的结果,能够节省大量沟通的时间成本;
- 配置系统层次清晰,比如模型结构相关的参数、网络训练的超参以及数据读取的参数能够清晰的分开;
- 参数在配置之后是可修改的,因为有的时候需要根据数据集的 size 再动态设置训练的 iters 等,这就需要用户定义好配置之后,参数可以再次被修改;
- 用户可以灵活增加配置参数而不需要侵入式修改 libai 的内部代码,希望用户可以将 libai 作为一个 lib 进行使用,而不是 clone 之后在上面修改;
两种配置方式
最终选择基于 LazyConfig 来构建 libai 项目,提供两种风格的配置文件,一种是注册+解析的方式,一种是 LazyCall 的方式。注册+解析是为了兼容之前一些项目的构建习惯,LazyCall 则是另外一种全新的配置系统思路,比注册+解析更灵活。
注册+解析是之前比较常用的配置方式,通过装饰器将可能会用到的对象注册好,随后便可以通过名字去获取对应的对象,在实例化阶段将参数传入。这种方式的一个问题在于额外引入的注册机制并不够方便,只有注册过的对象才能获取,而我们显然不能对所有代码中的对象都进行注册,一些其他库的对象也无法注册;另外一个问题在于不同用户需要使用对方注册的内容时也不够方便。
LazyCall 是另外一种类型的配置方式,不需要利用注册系统,利用 python 配置文件的特性将需要的模块直接 import,利用延后初始化的特性直接传参,在实例化之前可以进行任意参数的修改,最终在需要的时候进行实例化。
配置文件语法
上述两种方式都是采用了 python 文件来构建,因为序列化和反序列化的问题,没有采用命令行传参。另外考虑到 yaml 文件不够灵活,而 python 不仅语法更熟悉,而且可以内置一些计算,更复杂的嵌套和数据类型。
最终生成的配置文件会保存成一个 yaml 文件,如果要复现实验结果,只需要将 config.yaml 作为配置文件传到系统中。
不同类型的参数可以放在不同的地方实现参数隔离,比如
# data.py
data = dict(
# Pad the vocab size to be divisible by this value
# This is added for computational efficiency reasons.
make_vocab_size_divisible_by=128,
data_path=[
"/workspace/idea_model/idea_bert/output_data/loss_compara_content_sentence"
],
split="949,50,1",
vocab_file="/workspace/idea_model/idea_bert/bert-base-chinese-vocab.txt",
...
)
# bert.py
model = dict(
model_name="BertModel",
model_cfg=dict(
vocab_size=30522,
hidden_size=768,
hidden_layers=24,
num_attention_heads=12,
...
)
)
# gpt.py
model = dict(
model_name="GPT_2",
model_cfg=dict(
vocab_size=30522,
hidden_size=768,
...
)
)
# bert_large.py
from bert.py import model
model.model_cfg.hidden_size = 1537
通过这种方式,需要使用哪个模型作为模板可以直接 import 对应的模型配置文件,然后再做对应的修改,如果是一个全新的模型,也可以根据模型本身的参数完全重写一套,保证配置文件中的参数名字和模型定义的参数名字一致就可以了。
要查看内置模型需要的参数也可以直接访问对应模型的配置文件,不需要到模型定义的代码处进行查看。最后序列化会只保存使用到的配置文件参数。
模型的配置文件举例
下面分别用上述提到的两种配置方式对 bert 模型构建进行举例说明。
采用注册+解析的方式
# configs/model/bert.py
# registry + arguments parse
model = dict(
model_name="BertModel",
model_cfg=dict(
vocab_size=30522,
hidden_size=768,
hidden_layers=24,
num_attention_heads=12,
intermediate_size=4096,
hidden_dropout_prob=0.1,
attention_probs_dropout_prob=0.1,
max_position_embeddings=512,
num_tokentypes=2,
add_pooling_layer=True,
initializer_range=0.02,
layernorm_eps=1e-5,
bias_gelu_fusion=True,
bias_dropout_fusion=True,
scale_mask_softmax_fusion=False,
apply_query_key_layer_scaling=True,
),
)
# modify default arguments
model.model_cfg.vocab_size = 3000
序列化后的 config.yaml 文件,支持反序列化之后进行构建。
# config.yaml
model:
model_name: BertModel
model_cfg: {add_pooling_layer: true, apply_query_key_layer_scaling: true, attention_probs_dropout_prob: 0.1, bias_dropout_fusion: true, bias_gelu_fusion: true, hidden_dropout_prob: 0.1, hidden_layers: 24, hidden_size: 768, initializer_range: 0.02, intermediate_size: 4096, layernorm_eps: 1.0e-05, max_position_embeddings: 512, num_attention_heads: 12, num_tokentypes: 2, scale_mask_softmax_fusion: false, vocab_size: 30522}
LazyCall
# configs/model/bert.py
# LazyCall
from libai.config import LazyCall
from libai.models import BertModel
cfg = dict(
vocab_size=30522,
hidden_size=768,
hidden_layers=24,
num_attention_heads=12,
intermediate_size=4096,
hidden_dropout_prob=0.1,
attention_probs_dropout_prob=0.1,
max_position_embeddings=512,
num_tokentypes=2,
add_pooling_layer=True,
initializer_range=0.02,
layernorm_eps=1e-5,
bias_gelu_fusion=True,
bias_dropout_fusion=True,
scale_mask_softmax_fusion=False,
apply_query_key_layer_scaling=True,
)
model = LazyCall(BertModel)(cfg=cfg)
# modify default arguments
model.cfg.vocab_size = 3000
序列化之后的配置文件。
model:
_target_: libai.models.BertModel
cfg: {add_pooling_layer: true, apply_query_key_layer_scaling: true, attention_probs_dropout_prob: 0.1, bias_dropout_fusion: true, bias_gelu_fusion: true, hidden_dropout_prob: 0.1, hidden_layers: 24, hidden_size: 768, initializer_range: 0.02, intermediate_size: 4096, layernorm_eps: 1.0e-05, max_position_embeddings: 512, num_attention_heads: 12, num_tokentypes: 2, scale_mask_softmax_fusion: false, vocab_size: 30522}
上面两种方法最终获得的保存结果是类似的,唯一的区别是 LazyCall 可以更好的定位到使用的模型是 libai.models.BertModel 而注册+解析的方式只能获得一个模型名称。
总结
有了标准的注册+解析的配置文件机制,最后再讲一下为什么我们还想额外引入 LazyCall 的机制:
-
使用 LazyCall 的对象构建是与配置系统无关的,其只是常见的 python 函数和类,这意味着我们的配置文件甚至可以被其他的库引入,比如
linear1d = {"_target_": "libai.layers.Linear", "in_features": 100, "out_features": 200, "parallel": "col"} linear = {"_target_": "flow.nn.Linear", "in_features": 100, "out_features": 200}这个配置文件可以直接在其他的库进行实例化而不依赖于使用 cfg 文件。
-
通过阅读配置结果,可以更清晰地看到调用了哪个类,以及哪个函数,他们分别使用的参数是什么。
-
cfg中的 key 并不需要提前被定义,使用 LazyCall 给了我们机会在对象和函数被实例化之前有机会能修改他的值,这给了我们更多的灵活性。 -
LazyCall 和注册+解析是兼容的,同时给了我们更多的可能性去探索可编程式的配置文件。
整体调用流程介绍
-
首先通过一个完整的
config.py构建配置参数,这个文件可以通过 import 其他已有的文件来减少重复参数的写入,比如# bert_large_pretrain.py from .common.train import train # 训练相关的超参,比如 batch_size, train_iter 等参数 from .common.optim import optim, scheduler # optimizer 和 lr scheduler 构建的超参数 from .common.data.nlp_data import data # 和数据有关的超参数 from .common.models.bert_base import model # 和模型相关的超参数 # 针对 bert_large 从 bert_base 修改的超参 model.cfg.hidden_size = 1536 model.cfg.hidden_layers = 16 # 修改一些训练超参 train.micro_batch_size = 32 train.output_dir = "bert_large_ckpt" -
接着通过命令行传参将该配置文件传入,也可以在命令行中修改配置文件中的参数项
python3 -m oneflow.distributed.launch \ --nproc_per_node 4 --nnodes 1 --node_rank 0 --master_addr 127.0.0.1 \ tools/pretrain_bert.py \ --config-file configs/bert_large_pretrain.py optim.lr=0.01 train.log_period=1 -
在主体代码中,会通过
cfg = LazyConfig.load(args.config_file)获得配置文件中的参数,然后通过LazyConfig.apply_overrides(cfg, args.opts)重载命令行的传参。 -
在训练之前进行一些基本的设置,主要是下面6个步骤:
- 设置 LiBai logger;
- 记录一些基本信息,比如配置文件名称,命令行参数,环境变量等等;
- 设置分布式环境
这里会通过
flow.env.get_node_size(), flow.env.get_world_size()来获取实际的 gpu 数量和节点数量,然后和配置文件中对应的参数进行比较- 如果是载入自己写的配置文件,默认配置文件这没有这些参数的 key,会被直接增加到配置文件中;
- 如果是载入别人的配置文件进行训练,这些保存的配置文件会存在这些分布式信息,这是会和这次训练设置的分布式信息进行比较,如果不匹配会给出 warning,提示这种配置和之前的模型训练不一致,可能无法完全复现结果,最后仍然按本次配置的 gpu 和节点进行训练;
- 设置 tokenizer 以改变 vocab_size 的大小;
- 检查 batch size,设置 gradient accumulation 以及 global batch size 等信息;
- 序列化 config 文件,以便复现结果;
数据集相关配置文件
要求:比较灵活地支持多数据集训练和多数据集测试
我觉得 data 的配置文件不应该每一种 dataset 一个,每一种 dataset 之间的区别并不大
dataloader 相关的配置不应该放到 data 的配置文件里面,因为所有的 dataset 共享一个 dataloader, data 的配置应该和数据集本身的处理相关
data 配置里面应该包含的内容:
-
数据集的名称,路径等
data = dict( train_sets = [ dict(dataset1, cfg), dict(dataset2, cfg), dict(dataset3, cfg) ], test_sets = [ dict(dataset1, cfg), dict(dataset2, cfg) ], )需要一个类似 text dataset 里面的 blendableDataset,将两个 dataset 进行类别合并
-
数据增强方法的配置,比如 mmdet 中类似于使用了一个 pipeline 对数据进行处理
train_pipeline = [ dict(type='LoadImageFromFile', to_float32=True), dict(type='LoadAnnotations', with_bbox=True), dict( type='PhotoMetricDistortion', brightness_delta=32, contrast_range=(0.5, 1.5), saturation_range=(0.5, 1.5), hue_delta=18), dict( type='RandomCenterCropPad', crop_size=(511, 511), ratios=(0.6, 0.7, 0.8, 0.9, 1.0, 1.1, 1.2, 1.3), test_mode=False, test_pad_mode=None, **img_norm_cfg), dict(type='Resize', img_scale=(511, 511), keep_ratio=False), dict(type='RandomFlip', flip_ratio=0.5), dict(type='Normalize', **img_norm_cfg), dict(type='DefaultFormatBundle'), dict(type='Collect', keys=['img', 'gt_bboxes', 'gt_labels']), ] test_pipeline = [ dict(type='LoadImageFromFile', to_float32=True), dict( type='MultiScaleFlipAug', scale_factor=1.0, flip=True, transforms=[ dict(type='Resize'), dict( type='RandomCenterCropPad', crop_size=None, ratios=None, border=None, test_mode=True, test_pad_mode=['logical_or', 127], **img_norm_cfg), dict(type='RandomFlip'), dict(type='Normalize', **img_norm_cfg), dict(type='ImageToTensor', keys=['img']), dict( type='Collect', keys=['img'], meta_keys=('filename', 'ori_shape', 'img_shape', 'pad_shape', 'scale_factor', 'flip', 'img_norm_cfg', 'border')), ]) ]因为我们有 lazycall 的存在,所以这里会更加简单
BlendableDataset没有区分文本和图像,是通用的
主要的问题是在 cv 里面,还是如果两个 dataset 的 classes 不一样,则需要 reindex 类别和下标,所以现在我们决定先只支持类别一致的 dataset 进行融合