Skip to main content

AlchemyCat alpha version.

Project description

Alchemy Cat

PyPI version

banner

AlchemyCat 为深度学习提供了一套先进的配置系统。
语法简单优雅,支持继承、组合、依赖以最小化配置冗余,并支持自动调参

下表对比了 AlchemyCat 和其他配置系统(😡不支持,🤔有限支持,🥳支持):

功能 argparse yaml YACS mmcv AlchemyCat
可复现 😡 🥳 🥳 🥳 🥳
IDE跳转 😡 😡 🥳 🥳 🥳
继承 😡 😡 🤔 🤔 🥳
组合 😡 😡 🤔 🤔 🥳
依赖 😡 😡 😡 😡 🥳
自动调参 😡 😡 😡 😡 🥳

AlchemyCat 囊括了此前 "SOTA" 配置系统提供的所有功能,且充分考虑了各种特殊情况,稳定性有保障。

AlchemyCat 的独到之处在于:

  • 支持继承、组合来复用已有配置,最小化配置冗余。
  • 支持配置项间相互依赖,一处修改,处处生效,大大降低修改配置时的心智负担。
  • 提供一台自动调参机,只需对配置文件做一点点修改,即可实现自动调参并总结。
  • 且采用了更加简单优雅、pythonic 的语法,附带大量开箱即用的实用方法、属性。

如果您已经在使用上表中某个配置系统,迁移到 AlchemyCat 几乎是零成本的。花15分钟阅读下面的文档,并将 AlchemyCat 运用到项目中,从此你的GPU将永无空闲!

安装

pip install alchemy-cat

简单使用

AlchemyCat 确保一份配置对应唯一一个实验记录,二者间的双射关系保证了实验的可复现性。

config C + algorithm code A ——> reproducible experiment E(C, A)

实验目录是自动创建的,且与配置文件有相同的相对路径。路径可以是多级目录,路径中可以有空格、逗号、等号等。这便于分门别类地管理实验。譬如:

.
├── configs
│   ├── MNIST
│   │   ├── resnet18,wd=1e-5@run2
│   │   │   └── cfg.py
│   │   └── vgg,lr=1e-2
│   │       └── cfg.py
│   └── VOC2012
│       └── swin-T,γ=10
│           └── 10 epoch
│               └── cfg.py
└── experiment
    ├── MNIST
    │   ├── resnet18,wd=1e-5@run2
    │   │   └── xxx.log
    │   └── vgg,lr=1e-2
    │       └── xxx.log
    └── VOC2012
        └── swin-T,γ=10
            └── 10 epoch
                └── xxx.log

最佳实践:在cfg.py旁边创建一个__init__.py(一般IDE会自动创建),并避免路径中含有'.'。遵守该最佳实践有助于 IDE 调试,且能够在cfg.py中使用相对导入。

让我们从一个不完整的例子开始,了解如何书写配置文件并在代码中加载它。我们首先书写配置文件:

# -- [INCOMPLETE] configs/mnist/plain_usage/cfg.py --

from torchvision.datasets import MNIST
from alchemy_cat.dl_config import Config

cfg = Config()

cfg.rand_seed = 0

cfg.dt.cls = MNIST
cfg.dt.ini.root = '/tmp/data'
cfg.dt.ini.train = True

# ... Code Omitted.

这里我们首先实例化一个配置类别对象cfg,随后通过属性操作.来添加配置项。配置项可以是任意 python 对象,包括函数、方法、类。

最佳实践:我们推荐直接在配置项中指定函数或类,而不是通过字符串或信号量来控制程序的行为。前者在 IDE 中可以直接跳转,阅读和调试都更加方便。

Config是python dict类别的子类,上面代码定义了一个树结构的嵌套字典:

>>> print(cfg.to_dict())
{'rand_seed': 0,
 'dt': {'cls': <class 'torchvision.datasets.mnist.MNIST'>,
        'ini': {'root': '/tmp/data', 'train': True}}}

cfg 支持所有字典用法:

>>> cfg.keys()
dict_keys(['rand_seed', 'dt'])

>>> cfg['dt']['ini']['root']
'/tmp/data'

>>> {**cfg['dt']['ini'], 'download': True}
{'root': '/tmp/data', 'train': True, 'download': True}

可以用字典(yaml、json)或字典的子类(YACS、mmcv.Config)来初始化Config对象:

>>> Config({'rand_seed': 0, 'dt': {'cls': MNIST, 'ini': {'root': '/tmp/data', 'train': True}}})
{'rand_seed': 0, 'dt': {'cls': <class 'torchvision.datasets.mnist.MNIST'>, 'ini': {'root': '/tmp/data', 'train': True}}}

用属性操作.来读写cfg会更加清晰,譬如下面代码,根据配置文件创建并初始化了MNIST数据集:

>>> dataset = cfg.dt.cls(**cfg.dt.ini)
>>> dataset
Dataset MNIST
    Number of datapoints: 60000
    Root location: /tmp/data
    Split: Train

若访问不存在的键,会返回一个一次性空字典,在主代码可将其视作False

>>> cfg.not_exit
{}

主代码中,使用下面代码加载配置:

# # [INCOMPLETE] -- train.py --

from alchemy_cat.dl_config import load_config
cfg = load_config('configs/mnist/base/cfg.py', experiments_root='/tmp/experiment', config_root='configs')
# ... Code Omitted.
torch.save(model.state_dict(), f"{cfg.rslt_dir}/model_{epoch}.pth")  # Save all experiment results to cfg.rslt_dir.

load_config函数会导入configs/mnist/base/cfg.py中的cfg,处理继承、依赖。指定实验和配置的根目录experiments_rootconfig_root后,load_config会自动创建实验目录experiment/mnist/base/cfg.py并赋值给cfg.rslt_dir,一切实验结果都应当保存到cfg.rslt_dir中。

加载得到的cfg默认是冻结的,即cfg.is_frozen == True,此时不允许增删改配置。若要修改配置,可以通过cfg.unfreeze()解冻。

本章小结

  • 配置文件提供一个Config对象cfg,其本质是一个树结构的嵌套字典,支持.操作读写。
  • cfg访问不存在的键时,返回一个一次性空字典,可将其视作False
  • 使用load_config函数加载配置文件,实验目录会自动创建,其路径会赋值给cfg.rslt_dir

继承

新配置可以继承已有的基配置,写作cfg = Config(caps='base_cfg.py')。如此可以复用基配置,新配置只需覆写要修改的项目,或新增一些项目。如对基配置

# -- [INCOMPLETE] configs/mnist/plain_usage/cfg.py --

# ... Code Omitted.

cfg.loader.ini.batch_size = 128
cfg.loader.ini.num_workers = 2

cfg.opt.cls = optim.AdamW
cfg.opt.ini.lr = 0.01

# ... Code Omitted.

如果新的实验想翻倍批次大小,新配置可写作:

# -- configs/mnist/plain_usage,2xbs/cfg.py --

from alchemy_cat.dl_config import Config

cfg = Config(caps='configs/mnist/plain_usage/cfg.py')  # Inherit from base config.

cfg.loader.ini.batch_size = 128 * 2  # Double batch size.

cfg.opt.ini.lr = 0.01 * 2  # Linear scaling rule, see https://arxiv.org/abs/1706.02677

继承的行为和字典的update类似。核心区别在于,当新配置和基配置有同名键,且值也是一棵配置树时(称作“子配置树”),我们会递归进入子配置树执行update操作。因此,新配置的cfg.loader.ini.num_workers并未丢失,而是依旧保持基配置的值。

>>> base_cfg = load_config('configs/mnist/plain_usage/cfg.py', create_rslt_dir=False)
>>> new_cfg = load_config('configs/mnist/plain_usage,2xbs/cfg.py', create_rslt_dir=False)
>>> base_cfg.loader.ini
{'batch_size': 128, 'num_workers': 2}
>>> new_cfg.loader.ini
{'batch_size': 256, 'num_workers': 2}

若想在新配置中重写整棵子配置树,可将其设子树置为 "whole",例如

# -- configs/mnist/plain_usage,override_loader/cfg.py --

from alchemy_cat.dl_config import Config

cfg = Config(caps='configs/mnist/plain_usage/cfg.py')  # Inherit from base config.

cfg.loader.ini.set_whole()  # Set subtree as whole.
cfg.loader.ini.shuffle = False
cfg.loader.ini.drop_last = False

此时,cfg.loader.ini将完全由新配置定义:

>>> base_cfg = load_config('configs/mnist/plain_usage/cfg.py', create_rslt_dir=False)
>>> new_cfg = load_config('configs/mnist/plain_usage,2xbs/cfg.py', create_rslt_dir=False)
>>> base_cfg.loader.ini
{'batch_size': 128, 'num_workers': 2}
>>> new_cfg.loader.ini
{'shuffle': False, 'drop_last': False}

自然而然地,让基配置可以可以继承自另一个基配置,可以实现链式继承。

最后,配置也支持多继承,写作cfg = Config(caps=('base.py', 'patch1.py', 'patch2.py', ...)),其建立一条base -> patch1 -> patch2 -> current cfg的继承链。这种写法中,靠右的基配置常作为补丁项,用于添加一套经常共现的配置项。例如下面的patch:

# -- configs/patches/cifar10.py --

import torchvision.transforms as T
from torchvision.datasets import CIFAR10

from alchemy_cat.dl_config import Config

cfg = Config()

cfg.dt.set_whole()
cfg.dt.cls = CIFAR10
cfg.dt.ini.root = '/tmp/data'
cfg.dt.ini.transform = T.Compose([T.ToTensor(), T.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))])

新配置需要改用CIFAR10数据集时,只需要继承该补丁即可,无需再写CIFAR10有关的配置:

# -- configs/mnist/plain_usage,cifar10/cfg.py --

from alchemy_cat.dl_config import Config

cfg = Config(caps=('configs/mnist/plain_usage/cfg.py', 'alchemy_cat/dl_config/examples/configs/patches/cifar10.py'))
>>> cfg = load_config('configs/mnist/plain_usage,cifar10/cfg.py', create_rslt_dir=False)
>>> cfg.dt
{'cls': torchvision.datasets.cifar.CIFAR10,
 'ini': {'root': '/tmp/data',
  'transform': Compose(
      ToTensor()
      Normalize(mean=(0.5, 0.5, 0.5), std=(0.5, 0.5, 0.5))
  )}}

继承的实现细节

继承时,我们先拷贝整棵基配置树,再以新配置更新之,确保新配置和基配置的树结构相互隔离——即修改新配置的结构不会影响基配置。故而更复杂继承关系,如菱形继承也是支持的,只是不太可读,不推荐使用。
同时注意,叶结点的值是引用传递,原地修改将在影响整条继承链。

本章小结

  • 新配置可以继承已有的基配置,复用基配置的项目,并覆写、新增一些配置项。
  • 新配置对基配置的更新是递归进行的,使用Config.set_whole可以退回到dict默认的更新方式。
  • Config支持链式继承和多继承,多继承可实现更加细粒度的复用。

依赖

上一节的例子中,当新配置修改基配置的批次大小时,学习率也随之变化。这种一个配置项随着另一个配置项的变化情况,称作“依赖”。

在修改某个配置项的时候,忘记修改它的依赖项,是非常常见的 bug。 好在 AlchemyCat 可以定义依赖项,如此,每次只需要修改依赖的源头,所有依赖项都会自动更新。例如

# -- [INCOMPLETE] configs/mnist/base/cfg.py --

from alchemy_cat.dl_config import Config, DEP
# ... Code Omitted.

cfg.loader.ini.batch_size = 128
# ... Code Omitted.
cfg.opt.ini.lr = DEP(lambda c: c.loader.ini.batch_size // 128 * 0.01)  # Linear scaling rule.

# ... Code Omitted.

其中,学习率cfg.opt.ini.lr作为依赖项DEP,借助批次大小cfg.loader.ini.batch_size算出。DEP接受一个函数,函数的实参将会是cfg,并返回依赖项的值。

新配置中,我们只需修改批次大小,学习率会自动更新:

# -- configs/mnist/base,2xbs/cfg.py --

from alchemy_cat.dl_config import Config

cfg = Config(caps='configs/mnist/base/cfg.py')

cfg.loader.ini.batch_size = 128 * 2  # Double batch size, learning rate will be doubled automatically.
>>> cfg = load_config('configs/mnist/base,2xbs/cfg.py', create_rslt_dir=False)
>>> cfg.loader.ini.batch_size
256
>>> cfg.opt.ini.lr
0.02

下面展示一个更复杂的例子

# -- configs/mnist/base/cfg.py --

# ... Code Omitted.

cfg.sched.epochs = 30
@cfg.sched.set_DEP(name='warm_epochs', priority=0)  # kwarg `name` is not necessary
def warm_epochs(c: Config) -> int:  # warm_epochs = 10% of total epochs
    return round(0.1 * c.sched.epochs)

cfg.sched.warm.cls = sched.LinearLR
cfg.sched.warm.ini.total_iters = DEP(lambda c: c.sched.warm_epochs, priority=1)
cfg.sched.warm.ini.start_factor = 1e-5
cfg.sched.warm.ini.end_factor = 1.

cfg.sched.main.cls = sched.CosineAnnealingLR
cfg.sched.main.ini.T_max = DEP(lambda c: c.sched.epochs - c.sched.warm.ini.total_iters,
                               priority=2)  # main_epochs = total_epochs - warm_epochs

# ... Code Omitted.
>>> print(cfg.sched.to_txt(prefix='cfg.sched.'))  # A pretty print of the config tree.
cfg.sched = Config()
# ------- ↓ LEAVES ↓ ------- #
cfg.sched.epochs = 30
cfg.sched.warm_epochs = 3
cfg.sched.warm.cls = <class 'torch.optim.lr_scheduler.LinearLR'>
cfg.sched.warm.ini.total_iters = 3
cfg.sched.warm.ini.start_factor = 1e-05
cfg.sched.warm.ini.end_factor = 1.0
cfg.sched.main.cls = <class 'torch.optim.lr_scheduler.CosineAnnealingLR'>
cfg.sched.main.ini.T_max = 27

上面代码中,总的训练轮次cfg.sched.epochs是依赖源头,预热轮次 cfg.sched.warm_epochs是总训练轮次的 10%,主轮次cfg.sched.main.ini.T_max是总训练轮次减去预热轮次。只需修改总训练轮次,依赖项预热轮次和主轮次都会自动更新。

依赖项cfg.sched.warm_epochs使用了Config.set_DEP装饰器来定义,所饰函数即DEP的首个参数,定义依赖项的计算方式。装饰器可通过关键字参数name指定依赖项的键名,若缺省则使用被饰函数之名。当依赖项的计算函数较为复杂时,推荐使用装饰器来定义。

当依赖项依赖另一个依赖项时,需要保证依赖性按正确顺序解算。默认的解算顺序是定义顺序。也可以通过priority参数来指定解算顺序,数值越小,解算越早。譬如上面cfg.sched.warm_epochscfg.sched.warm.ini.total_iters,后者又被cfg.sched.main.ini.T_max依赖,故他们的priority依次增加。

本章小结

  • 当一个配置项依赖于另一个配置项时,可将其定义依赖项。改变依赖源头时,依赖项会根据计算函数自动解算,而无需手动修改。
  • 依赖项有两种定义方式:直接赋值为DEP(...),或使用Config.set_DEP装饰器。
  • 依赖项间相互依赖时,可通过priority参数来指定解算顺序,否则按照定义顺序解算。

组合

组合是另一种复用配置的方式。预定义好的子配置树,可以像积木一样,组合出完整的配置。譬如,下面配置定义了一套学习率策略:

# -- configs/addons/linear_warm_cos_sched.py --
import torch.optim.lr_scheduler as sched

from alchemy_cat.dl_config import Config, DEP

cfg = Config()

cfg.epochs = 30

@cfg.set_DEP(priority=0)  # warm_epochs = 10% of total epochs
def warm_epochs(c: Config) -> int:
    return round(0.1 * c.epochs)

cfg.warm.cls = sched.LinearLR
cfg.warm.ini.total_iters = DEP(lambda c: c.warm_epochs, priority=1)
cfg.warm.ini.start_factor = 1e-5
cfg.warm.ini.end_factor = 1.

cfg.main.cls = sched.CosineAnnealingLR
cfg.main.ini.T_max = DEP(lambda c: c.epochs - c.warm.ini.total_iters,
                         priority=2)  # main_epochs = total_epochs - warm_epochs

最终配置中,我们直接组合这套学习率策略:

# -- configs/mnist/base,sched_from_addon/cfg.py --
# ... Code Omitted.

cfg.sched = Config('configs/addons/linear_warm_cos_sched.py')

# ... Code Omitted.
>>> print(cfg.sched.to_txt(prefix='cfg.sched.'))  # A pretty print of the config tree.
cfg.sched = Config()
# ------- ↓ LEAVES ↓ ------- #
cfg.sched.epochs = 30
cfg.sched.warm_epochs = 3
cfg.sched.warm.cls = <class 'torch.optim.lr_scheduler.LinearLR'>
cfg.sched.warm.ini.total_iters = 3
cfg.sched.warm.ini.start_factor = 1e-05
cfg.sched.warm.ini.end_factor = 1.0
cfg.sched.main.cls = <class 'torch.optim.lr_scheduler.CosineAnnealingLR'>
cfg.sched.main.ini.T_max = 27

看起来非常简单!就是将预定义的子配置树,赋值/挂载到最终配置。Config('path/to/cfg.py')返回配置文件中,cfg对象的拷贝(与继承中一样,拷贝树结构以保证拷贝前后对配置的修改相互隔离)。

组合和依赖的实现细节

细心的读者可能会疑惑,DEP是如何决定依赖项计算函数的参数c,解算时具体传入哪一个Config对象?在本章的例子中,c的实参是学习率子配置,因此cfg.warm.ini.total_iters的计算函数为lambda c: c.warm_epochs。然而,在上一章的例子中,c的实参是最终配置,因此cfg.sched.warm.ini.total_iters的计算函数为lambda c: c.sched.warm_epochs

其实,c的实参,是DEP第一次被挂载到配置树时,被挂载的那颗配置树之根节点。Config在数据结构上是一棵双向树,DEP第一次被挂载时,会上溯到根节点,记录DEP到根的相对距离。解算时,上溯相同距离,找到对应的配置树,并传入计算函数。

要阻止该默认行为,可以设置DEP(lambda c: ..., rel=False),此时c的实参总是为最终配置。

最佳实践:与面向对象的组合和继承类似,配置的组合和继承,初衷都是为了复用配置代码。其中,组合更加灵活、低耦合。因此,应当优先使用组合,尽量减少继承层次。

本章小结

  • 可以先定义几组子配置,再将他们赋值为最终配置的键值对,以此组合出完整的配置。

完整样例

展开完整样例

学习率相关的子配置树

# -- configs/addons/linear_warm_cos_sched.py --

import torch.optim.lr_scheduler as sched

from alchemy_cat.dl_config import Config, DEP

cfg = Config()

cfg.epochs = 30

@cfg.set_DEP(priority=0)  # warm_epochs = 10% of total epochs
def warm_epochs(c: Config) -> int:
    return round(0.1 * c.epochs)

cfg.warm.cls = sched.LinearLR
cfg.warm.ini.total_iters = DEP(lambda c: c.warm_epochs, priority=1)
cfg.warm.ini.start_factor = 1e-5
cfg.warm.ini.end_factor = 1.

cfg.main.cls = sched.CosineAnnealingLR
cfg.main.ini.T_max = DEP(lambda c: c.epochs - c.warm.ini.total_iters,
                         priority=2)  # main_epochs = total_epochs - warm_epochs

组合得到的基配置

# -- configs/mnist/base/cfg.py --

import torchvision.models as model
import torchvision.transforms as T
from torch import optim
from torchvision.datasets import MNIST

from alchemy_cat.dl_config import Config, DEP

cfg = Config()

cfg.rand_seed = 0

# -* Set datasets.
cfg.dt.cls = MNIST
cfg.dt.ini.root = '/tmp/data'
cfg.dt.ini.transform = T.Compose([T.Grayscale(3), T.ToTensor(), T.Normalize((0.1307,), (0.3081,)),])

# -* Set data loader.
cfg.loader.ini.batch_size = 128
cfg.loader.ini.num_workers = 2

# -* Set model.
cfg.model.cls = model.resnet18
cfg.model.ini.num_classes = DEP(lambda c: len(c.dt.cls.classes))

# -* Set optimizer.
cfg.opt.cls = optim.AdamW
cfg.opt.ini.lr = DEP(lambda c: c.loader.ini.batch_size // 128 * 0.01)  # Linear scaling rule.

# -* Set scheduler.
cfg.sched = Config('configs/addons/linear_warm_cos_sched.py')

# -* Set logger.
cfg.log.save_interval = DEP(lambda c: c.sched.epochs // 5, priority=1)  # Save model at every 20% of total epochs.

继承自基配置,批次大小翻倍,轮次数目减半的新配置

# -- configs/mnist/base,sched_from_addon,2xbs,2÷epo/cfg.py --

from alchemy_cat.dl_config import Config

cfg = Config(caps='configs/mnist/base,sched_from_addon/cfg.py')

cfg.loader.ini.batch_size = 256

cfg.sched.epochs = 15

注意,依赖项如学习率、预热轮次、主轮次都会自动更新:

>>> cfg = load_config('configs/mnist/base,sched_from_addon,2xbs,2÷epo/cfg.py', create_rslt_dir=False)
>>> print(cfg)
cfg = Config()
cfg.set_whole(False).set_attribute('_cfgs_update_at_parser', ('configs/mnist/base,sched_from_addon/cfg.py',))
# ------- ↓ LEAVES ↓ ------- #
cfg.rand_seed = 0
cfg.dt.cls = <class 'torchvision.datasets.mnist.MNIST'>
cfg.dt.ini.root = '/tmp/data'
cfg.dt.ini.transform = Compose(
    Grayscale(num_output_channels=3)
    ToTensor()
    Normalize(mean=(0.1307,), std=(0.3081,))
)
cfg.loader.ini.batch_size = 256
cfg.loader.ini.num_workers = 2
cfg.model.cls = <function resnet18 at 0x7f5bcda68a40>
cfg.model.ini.num_classes = 10
cfg.opt.cls = <class 'torch.optim.adamw.AdamW'>
cfg.opt.ini.lr = 0.02
cfg.sched.epochs = 15
cfg.sched.warm_epochs = 2
cfg.sched.warm.cls = <class 'torch.optim.lr_scheduler.LinearLR'>
cfg.sched.warm.ini.total_iters = 2
cfg.sched.warm.ini.start_factor = 1e-05
cfg.sched.warm.ini.end_factor = 1.0
cfg.sched.main.cls = <class 'torch.optim.lr_scheduler.CosineAnnealingLR'>
cfg.sched.main.ini.T_max = 13
cfg.log.save_interval = 3
cfg.rslt_dir = 'mnist/base,sched_from_addon,2xbs,2÷epo'

训练代码

# -- train.py --
import argparse
import json

import torch
import torch.nn.functional as F
from rich.progress import track
from torch.optim.lr_scheduler import SequentialLR

from alchemy_cat.dl_config import load_config
from utils import eval_model

parser = argparse.ArgumentParser(description='AlchemyCat MNIST Example')
parser.add_argument('-c', '--config', type=str, default='configs/mnist/base,sched_from_addon,2xbs,2÷epo/cfg.py')
args = parser.parse_args()

# Folder 'experiment/mnist/base' will be auto created by `load` and assigned to `cfg.rslt_dir`
cfg = load_config(args.config, experiments_root='/tmp/experiment', config_root='configs')
print(cfg)

torch.manual_seed(cfg.rand_seed)  # Use `cfg` to set random seed

dataset = cfg.dt.cls(**cfg.dt.ini)  # Use `cfg` to set dataset type and its initial parameters

# Use `cfg` to set changeable parameters of loader,
# other fixed parameter like `shuffle` is set in main code
loader = torch.utils.data.DataLoader(dataset, shuffle=True, **cfg.loader.ini)

model = cfg.model.cls(**cfg.model.ini).train().to('cuda')  # Use `cfg` to set model

# Use `cfg` to set optimizer, and get `model.parameters()` in run time
opt = cfg.opt.cls(model.parameters(), **cfg.opt.ini, weight_decay=0.)

# Use `cfg` to set warm and main scheduler, and `SequentialLR` to combine them
warm_sched = cfg.sched.warm.cls(opt, **cfg.sched.warm.ini)
main_sched = cfg.sched.main.cls(opt, **cfg.sched.main.ini)
sched = SequentialLR(opt, [warm_sched, main_sched], [cfg.sched.warm_epochs])

for epoch in range(1, cfg.sched.epochs + 1):  # train `cfg.sched.epochs` epochs
    for data, target in track(loader, description=f"Epoch {epoch}/{cfg.sched.epochs}"):
        F.cross_entropy(model(data.to('cuda')), target.to('cuda')).backward()
        opt.step()
        opt.zero_grad()

    sched.step()

    # If cfg.log is defined, save model to `cfg.rslt_dir` at every `cfg.log.save_interval`
    if cfg.log and epoch % cfg.log.save_interval == 0:
        torch.save(model.state_dict(), f"{cfg.rslt_dir}/model_{epoch}.pth")

    eval_model(model)

if cfg.log:
    eval_ret = eval_model(model)
    with open(f"{cfg.rslt_dir}/eval.json", 'w') as json_f:
        json.dump(eval_ret, json_f)

运行python train.py --config 'configs/mnist/base,sched_from_addon,2xbs,2÷epo/cfg.py',将会按照配置文件中设置,使用train.py训练,并将结果保存到/tmp/experiment/mnist/base,sched_from_addon,2xbs,2÷epo目录中。

自动调参

上面的例子中,我们每运行python train.py --config path/to/cfg.py,就针对一组参数,得到一份对应的实验结果。

然而,很多时候,我们需要网格搜索参数空间,寻找最优的参数组合。若为每一组参数组合都写一个配置,既辛苦也容易出错。那能不能在一个『可调配置』中,定义整个参数空间。随后让程序自动地遍历所有参数组合,对每组参数生成一个配置并运行。进一步的,程序还应该能够自动汇总每组实验结果,以便比较。

自动调参机遍历可调配置的参数组合,生成N个子配置,运行得到N个实验记录,并将所有实验结果总结到 excel 表格中:

config to be tuned T ───> config C1 + algorithm code A ───> reproducible experiment E1(C1, A) ───> summary table S(T,A)
                     │                                                                          │  
                     ├──> config C2 + algorithm code A ───> reproducible experiment E1(C2, A) ──│ 
                    ...                                                                         ...

可调配置

要使用自动调参机,首先需要写一个可调配置:

# -- configs/tune/tune_bs_epoch/cfg.py --

from alchemy_cat.dl_config import Cfg2Tune, Param2Tune

cfg = Cfg2Tune(caps='configs/mnist/base,sched_from_addon/cfg.py')

cfg.loader.ini.batch_size = Param2Tune([128, 256, 512])

cfg.sched.epochs = Param2Tune([5, 15])

其写法与上一章中的普通配置非常类似,同样支持属性读写、继承、依赖、组合等特性。区别在于:

  • 可调配置的的数据类型是Config的子类Cfg2Tune
  • 对需要网格搜索的参数,定义为Param2Tune([v1, v2, ...]),其中v1, v2, ...是参数的可选值。

譬如上面的可调配置,会搜索一个 3×2=6 大小的参数空间,并生成如下6个子配置:

batch_size  epochs  child_configs            
128         5       configs/tune/tune_bs_epoch/batch_size=128,epochs=5/cfg.pkl
            15      configs/tune/tune_bs_epoch/batch_size=128,epochs=15/cfg.pkl
256         5       configs/tune/tune_bs_epoch/batch_size=256,epochs=5/cfg.pkl
            15      configs/tune/tune_bs_epoch/batch_size=256,epochs=15/cfg.pkl
512         5       configs/tune/tune_bs_epoch/batch_size=512,epochs=5/cfg.pkl
            15      configs/tune/tune_bs_epoch/batch_size=512,epochs=15/cfg.pkl

设置Param2Tunepriority参数,可以指定参数的搜索顺序。默认搜索顺序是定义顺序。设置 optional_value_names参数,可以为参数值指定可读的名字。例如

# -- configs/tune/tune_bs_epoch,pri,name/cfg.py --

from alchemy_cat.dl_config import Cfg2Tune, Param2Tune

cfg = Cfg2Tune(caps='configs/mnist/base,sched_from_addon/cfg.py')

cfg.loader.ini.batch_size = Param2Tune([128, 256, 512], optional_value_names=['1xbs', '2xbs', '4xbs'], priority=1)

cfg.sched.epochs = Param2Tune([5, 15], priority=0)

其搜索空间为:

epochs batch_size  child_configs                    
5      1xbs        configs/tune/tune_bs_epoch,pri,name/epochs=5,batch_size=1xbs/cfg.pkl
       2xbs        configs/tune/tune_bs_epoch,pri,name/epochs=5,batch_size=2xbs/cfg.pkl
       4xbs        configs/tune/tune_bs_epoch,pri,name/epochs=5,batch_size=4xbs/cfg.pkl
15     1xbs        configs/tune/tune_bs_epoch,pri,name/epochs=15,batch_size=1xbs/cfg.pkl
       2xbs        configs/tune/tune_bs_epoch,pri,name/epochs=15,batch_size=2xbs/cfg.pkl
       4xbs        configs/tune/tune_bs_epoch,pri,name/epochs=15,batch_size=4xbs/cfg.pk

我们还可以在参数间设置约束条件,裁剪掉不需要的参数组合,如下面例子约束总迭代数不超过 15×128:

# -- configs/tune/tune_bs_epoch,subject_to/cfg.py --

from alchemy_cat.dl_config import Cfg2Tune, Param2Tune

cfg = Cfg2Tune(caps='configs/mnist/base,sched_from_addon/cfg.py')

cfg.loader.ini.batch_size = Param2Tune([128, 256, 512])

cfg.sched.epochs = Param2Tune([5, 15],
                              subject_to=lambda cur_val: cur_val * cfg.loader.ini.batch_size.cur_val <= 15 * 128)

其搜索空间为:

batch_size epochs  child_configs                 
128        5       configs/tune/tune_bs_epoch,subject_to/batch_size=128,epochs=5/cfg.pkl  
           15      configs/tune/tune_bs_epoch,subject_to/batch_size=128,epochs=15/cfg.pkl
256        5       configs/tune/tune_bs_epoch,subject_to/batch_size=256,epochs=5/cfg.pkl

运行自动调参机

我们还需要写一小段脚本来运行自动调参机:

# -- tune_train.py --
import argparse, json, os, subprocess, torch, sys
from alchemy_cat.dl_config import Config, Cfg2TuneRunner

parser = argparse.ArgumentParser(description='Tuning AlchemyCat MNIST Example')
parser.add_argument('-c', '--cfg2tune', type=str)
args = parser.parse_args()

# Set `pool_size` to GPU num, will run `pool_size` of configs in parallel
runner = Cfg2TuneRunner(args.cfg2tune, experiment_root='/tmp/experiment', pool_size=torch.cuda.device_count())

@runner.register_work_fn  # How to run config
def work(pkl_idx: int, cfg: Config, cfg_pkl: str, cfg_rslt_dir: str) -> ...:
    subprocess.run([sys.executable, 'train.py', '-c', cfg_pkl],
                   env=os.environ | {'CUDA_VISIBLE_DEVICE': f'pkl_idx % torch.cuda.device_count()'})

@runner.register_gather_metric_fn  # How to gather metric for summary
def gather_metric(cfg: Config, cfg_rslt_dir: str, run_rslt: ..., param_comb: dict[str, tuple[..., str]]) -> dict[str, ...]:
    return json.load(open(os.path.join(cfg_rslt_dir, 'eval.json')))

runner.tuning()

上面的脚本执行了如下操作:

  • 传入可调配置的路径,实例化调参机runner = Cfg2TuneRunner(...)
    自动调参机默认逐个运行子配置。设置参数pool_size > 0,可以并行运行pool_size个子配置。对深度学习任务,pool_size一般为GPU数量 // 每个任务所占GPU数量
  • 注册工作函数。子配置将被调参机逐个传入工作函数并运行之。
    工作函数接受如下参数:pkl_idx是子配置的序号;cfg是子配置;cfg_pkl是子配置的 pickle 保存路径;cfg_rslt_dir是子配置的实验目录。一般而言,我们只需要将cfg_pkl作为配置文件(load_cfg支持读取 pickle 保存的配置)传入训练脚本即可。对深度学习任务,如上例所示,还需要为每个任务设置不同的CUDA_VISIBLE_DEVICE
  • 注册汇总函数。汇总函数对每个实验结果,返回一个字典,格式为{metric_name: metric_value}。调参机会自动遍历所有实验结果,汇总到一个表格中。
    汇总函数接受如下参数:cfg是子配置;cfg_rslt_dir是子配置的实验目录;run_rslt是工作函数的返回值;param_comb是子配置的参数组合。一般我们只需要到cfg_rslt_dir中读取实验结果并返回即可。
  • 调用runner.tuning(),开始自动调参。

调参结束后,将打印调参结果:

Metric Frame: 
                  test_loss    acc
batch_size epochs                 
128        5       1.993285  32.63
           15      0.016772  99.48
256        5       1.889874  37.11
           15      0.020811  99.49
512        5       1.790593  41.74
           15      0.024695  99.33

Saving Metric Frame at /tmp/experiment/tune/tune_bs_epoch/metric_frame.xlsx

正如提示信息所言,调参结果还会被保存到 /tmp/experiment/tune/tune_bs_epoch/metric_frame.xlsx 表格中: metric_frame

最佳实践:自动调参机与标准的工作流是正交的。因此,在写配置和代码时,先不要考虑自动调参机。需要调参时,再写一点点额外的代码,定义参数空间,指定算法的调用方式和结果的获取方式。调参完毕后,可以剥离调参机,只发布最优结果的配置和算法。

本章小结

  • 我们可以在可调配置Cfg2Tune下,使用Param2Tune定义参数空间。
  • 自动调参机Cfg2TuneRunner会遍历参数空间,生成子配置,运行子配置,并汇总实验结果。

进阶

展开进阶

美化打印

Config__str__方法被重载,以.分隔的键名,美观地打印树结构:

>>> cfg = Config()
>>> cfg.foo.bar.a = 1
>>> cfg.bar.foo.b = ['str1', 'str2']
>>> cfg.whole.set_whole()
>>> print(cfg)
cfg = Config()
cfg.whole.set_whole(True)
# ------- ↓ LEAVES ↓ ------- #
cfg.foo.bar.a = 1
cfg.bar.foo.b = ['str1', 'str2']

如果所有叶节点都是内置类型,Config的美观打印输出可直接作为 python 代码执行,并得到相同的配置:

>>> exec(cfg.to_txt(prefix='new_cfg.'), globals(), (l_dict := {}))
>>> l_dict['new_cfg'] == cfg
True

自动捕获实验日志

对深度学习任务,我们建议用init_env代替load_config,在加载配置之余,init_env还可以初始化深度学习环境,譬如设置 torch 设备、梯度、随机种子、分布式训练:

from alchemy_cat.torch_tools import init_env

if __name__ == '__main__':
    import argparse

    parser = argparse.ArgumentParser()
    parser.add_argument('-c', '--config', type=str)
    parser.add_argument('--local_rank', type=int, default=-1)
    args = parser.parse_args()
    
    device, cfg = init_env(config_path=args.config,             # config file path,read to `cfg`
                           is_cuda=True,                        # if True,`device` is cuda,else cpu
                           is_benchmark=bool(args.benchmark),   # torch.backends.cudnn.benchmark = is_benchmark
                           is_train=True,                       # torch.set_grad_enabled(is_train)
                           experiments_root="experiment",       # root of experiment dir
                           rand_seed=True,                      # set python, numpy, torch rand seed. If True, read cfg.rand_seed as seed, else use actual parameter as rand seed. 
                           cv2_num_threads=0,                   # set cv2 num threads
                           verbosity=True,                      # print more env init info
                           log_stdout=True,                     # where fork stdout to log file
                           loguru_ini=True,                     # config a pretty loguru format
                           reproducibility=False,               # set pytorch to reproducible mode
                           local_rank=...,                      # dist.init_process_group(..., local_rank=local_rank)
                           silence_non_master_rank=True,        # if True, non-master rank will not print to stdout, but only log to file
                           is_debug=bool(args.is_debug))        # is debug mode

如果设置log_stdout=Trueinit_env还会将sys.stdoutsys.stderr fork 一份到日志文件cfg.rslt_dir/{local-time}.log中。这不会干扰正常的print,但所有屏幕输出都会同时被记录到日志。因此,不再需要手动写入日志,屏幕所见即日志所得。

更详细用法可参见init_env的 docstring。

属性字典

如果您是 addict 的用户,我们的ADict可以作为addict.Dict的 drop-in replacement:from alchemy_cat.dl_config import ADict as Dict

ADict 实现了 addict.Dict 的所有接口,但重新实现了所有方法,优化了执行效率,覆盖了更多 corner case(如循环引用)。Config其实就是ADict的子类。

如果您没有使用过addict,可以考虑阅读这份文档。研究型代码常常会传递复杂的字典结构,addict.DictADict支持属性读写字典,非常适合处理嵌套字典。

循环引用

ADictConfig的初始化、继承、组合需要用到一种名为branch_copy的操作,其介于浅拷贝和深拷贝之间,即拷贝树结构,但不拷贝叶节点。ADict.copyConfig.copycopy.copy(cfg)均会调用branch_copy,而非dictcopy方法。

理论上ADict.branch_copy能够处理循环引用情况,譬如:

>>> dic = {'num': 0,
           'lst': [1, 'str'],
           'sub_dic': {'sub_num': 3}}
>>> dic['lst'].append(dic['sub_dic'])
>>> dic['sub_dic']['parent'] = dic
>>> dic
{'num': 0,
 'lst': [1, 'str', {'sub_num': 3, 'parent': {...}}],
 'sub_dic': {'sub_num': 3, 'parent': {...}}}

>>> adic = ADict(dic)
>>> adic.sub_dic.parent is adic is not dic
True
>>> adic.lst[-1] is adic.sub_dic is not dic['sub_dic']
True

ADict不同,Config的数据结构是双向树,而循环引用将成环。为避免成环,若子配置树被多次挂载到不同父配置,子配置树会先拷贝得到一棵独立的配置树,再进行挂载。正常使用下,配置树中不会出现循环引用。

总而言之,尽管循环引用是被支持的,不过即没有必要,也不推荐使用。

遍历配置树

Config.named_branchesConfig.named_ckl分别遍历配置树的所有分支和叶节点(所在的分支、键名和值):

>>> list(cfg.named_branches) 
[('', {'foo': {'bar': {'a': 1}},  
       'bar': {'foo': {'b': ['str1', 'str2']}},  
       'whole': {}}),
 ('foo', {'bar': {'a': 1}}),
 ('foo.bar', {'a': 1}),
 ('bar', {'foo': {'b': ['str1', 'str2']}}),
 ('bar.foo', {'b': ['str1', 'str2']}),
 ('whole', {})]
 
>>> list(cfg.ckl)
[({'a': 1}, 'a', 1), ({'b': ['str1', 'str2']}, 'b', ['str1', 'str2'])]

惰性继承

>>> from alchemy_cat.dl_config import Config
>>> cfg = Config(caps='configs/mnist/base,sched_from_addon/cfg.py')
>>> cfg.loader.ini.batch_size = 256
>>> cfg.sched.epochs = 15
>>> print(cfg)

cfg = Config()
cfg.set_whole(False).set_attribute('_cfgs_update_at_parser', ('configs/mnist/base,sched_from_addon/cfg.py',))
# ------- ↓ LEAVES ↓ ------- #
cfg.loader.ini.batch_size = 256
cfg.sched.epochs = 15

继承时,父配置caps不会被立即更新过来,而是等到load_config时才会被加载。惰性继承使得配置系统可以鸟瞰整条继承链,少数功能有赖于此。

协同Git

由于config C + algorithm code A ——> reproducible experiment E(C, A),意味着当配置C和算法代码A确定时,总是能复现实验E。因此,建议将配置文件和算法代码一同提交到 Git 仓库中,以便日后复现实验。

我们还提供了一个脚本,运行pyhon -m alchemy_cat.torch_tools.scripts.tag_exps -s commit_ID -a commit_ID,将交互式地列出该 commit 新增的配置,并按照配置路径给 commit 打上标签。这有助于快速回溯历史上某个实验的配置和算法。

为子任务分配显卡

Cfg2TuneRunnerwork函数有时需要给子进程分配显卡。allocate_cuda_by_group_rank可按照pkl_idx,分配空闲的显卡:

from alchemy_cat.torch_tools import allocate_cuda_by_group_rank

# ... Code before

@runner.register_work_fn  # How to run config
def work(pkl_idx: int, cfg: Config, cfg_pkl: str, cfg_rslt_dir: str) -> ...:
    current_cudas, env_with_current_cuda = allocate_cuda_by_group_rank(group_rank=pkl_idx, group_cuda_num=2, block=True, verbosity=True)
    subprocess.run([sys.executable, 'train.py', '-c', cfg_pkl], env=env_with_current_cuda)

# ... Code after

group_rank一般为pkl_idxgroup_cuda_num为子任务所需显卡数量。blockTrue时,若分配的显卡被占用,会阻塞直到有空闲。verbosityTrue时,会打印阻塞情况。

返回值current_cudas是一个列表,包含了分配的显卡号。env_with_current_cuda是设置了CUDA_VISIBLE_DEVICES的环境变量字典,可直接传入subprocess.runenv参数。

匿名函数无法 pickle 问题

Cfg2Tune生成的子配置会被 pickle 保存。然而,若Cfg2Tune定义了形似DEP(lambda c: ...)的依赖项,所存储的匿名函数无法被 pickle。变通方法有:

  • 配合装饰器@Config.set_DEP,将依赖项的计算函数定义为一个全局函数。
  • 将依赖项的计算函数定义在一个独立的模块中,然后再传递给DEP
  • 在父配置caps中定义依赖项。由于继承的处理是惰性的,Cfg2Tune生成的子配置暂时不包含依赖项。
  • 如果依赖源是可调参数,可使用特殊的依赖项P_DEP,它将于Cfg2Tune生成子配置后、保存为 pickle 前解算。

关于继承的更多技巧

继承时删除

Config.empty_leaf()结合了Config.clear()Config.set_whole(),可以得到一棵空且 "whole" 的子树。这常用于在继承时表示『删除』语义,即用一个空配置,覆盖掉基配置的某颗子配置树。

update方法

cfg是一个Config实例,base_cfg是一个dict实例,cfg.dict_update(base_cfg)cfg.update(base_cfg)cfg |= base_cfg的效果与让Config(base_cfg)继承cfg类似。

cfg.dict_update(base_cfg, incremental=True)则确保只做增量式更新——即只会增加cfg中不存在的键,而不会覆盖已有键。

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

alchemy_cat-0.0.4.tar.gz (245.3 kB view hashes)

Uploaded Source

Built Distribution

alchemy_cat-0.0.4-py3-none-any.whl (333.2 kB view hashes)

Uploaded Python 3

Supported by

AWS AWS Cloud computing and Security Sponsor Datadog Datadog Monitoring Fastly Fastly CDN Google Google Download Analytics Microsoft Microsoft PSF Sponsor Pingdom Pingdom Monitoring Sentry Sentry Error logging StatusPage StatusPage Status page