# 前言
在许多工程项目中,经常需要配置一些选项供用户或者员工自己使用。以人工智能领域为例, Paddlepaddle
、 mmdetection
等深度学习框架都需要根据需求在配置文件中配置数据、网络等相关参数。 Paddlepaddle
使用 YAML
格式存储配置信息, mmdetection
则直接使用 Python
文件来设定配置。此外, JSON
格式也是常用的数据存储格式。无论何种格式,当配置信息不断增加膨胀之后,如何组织配置的结构就成为了一件迫切需要考虑的事情。
这三种配置格式孰优孰劣,我无法评价。由于我目前接手的工作中使用的是 YAML
格式的配置文件,因此本文主要介绍最近一段时间经过调研搜索后,我决定采用的一种配置文件组织方案。
在实际阐述方案之前,有必要阐述一下 YAML
和 dataclass
这两个概念。如果读者对这二者比较熟悉,可以直接跳过相关部分。
# YAML
中的 tag
YAML
诞生于 2004 年,根据官网说法[1],其有 7 条设计目标,其中,易读、不同编程语言间良好的兼容性、可扩展等特性使得其被广泛用于存储格式化的信息。
关于其具体的语法,可以参考官方网站或者其他教程,这里不做过多介绍,本文主要介绍其 tag
标签功能。
tag
可以标注 YAML
中数据的类型或者其归属的对象类别,其可以是 str
、 int
、 float
等基本类型,也可以是用户自己定义的任何有意义的类别名称。这些标签可以帮助我们在阅读或者解析的时候,更方便地判断数据的归属。
我们不妨来看一个例子:
name: 'linn' | |
age: 18 | |
gender: 'male' |
在上述 YAML
文件中,我们定义了三个属性,可以很清晰地看到,这三个属性描述的都是个人信息,它们在许多场景下通常会结伴出现。对于这种数据,有一个专门的称呼叫做数据泥团(Data Clumps)[2]。有时候,我们会发现某些函数包含大量的参数,而这些参数往往一起出现,很显然,在通常的编程语言中,用结构体或者类来组织这种数据,比直接使用原生类型是更好的选择。而在 YAML
中,我们也可以用 tag
来表征这一特点:
!Person | |
name: 'linn' | |
age: 18 | |
gender: 'male' |
在 YAML
中,使用 !
来表示这一标识是一个 tag
的名称。相比于仅仅列出属性,增加一个 tag
可以使得数据的语义更加明晰。当我们的配置文件中的数据项膨胀到数十个的时候,增加一个 tag
能够帮助你更好的理解配置选项的意义。
# 解析 YAML
的 tag
当然,光在 YAML
中定义一个 tag
,并没有全部发挥出 tag
的作用。 tag
黑能配合编程语言,帮助解析 YAML
文件中的内容。
想象一下,当没有 tag
的时候,我们通常是如何解析 YAML
的?一般情况的代码大致如下(为了方便,直接在文件中用字符串表示 YAML
内容):
import yaml | |
class Person: | |
def __init__(self, name, age, gender): | |
self.name = name | |
self.age = age | |
self.gender = gender | |
yaml_str = """ | |
name: 'linn' | |
age: 18 | |
gender: 'male' | |
""" | |
content = yaml.load(yaml_str, Loader=yaml.SafeLoader) | |
""" | |
content | |
{'name': 'linn', 'age': 18, 'gender': 'male'} | |
""" | |
person = Person(**content) |
以上代码可以分为两个部分:
- 读取
YAML
文件的内容,赋值给某个对象。一般而言,读入的内容会存储在字典或者列表中; - 创建某个类的对象,利用读取的
YAML
内容为其初始化。
对于结构简单的YAML
文件,上述方法还可接受,但是如果YAML
文件变得复杂,那么按照上述方法来解析的代码便会变得繁琐。
我们可以看下面这个例子[3]:
name: MyBusiness | |
locations: | |
- "Hawaii" | |
- "India" | |
- "Japan" | |
employees: | |
- !Employee | |
name: Matthew Burruss | |
id: 1 | |
- !Employee | |
name: John Doe | |
id: 2 |
显然,如果 employees
中的内容较多,我们甚至还需要写一个循环来初始化这些对象。而通过结合 YAML
的 tag
,我们可以自定义 constructor
来解析其内容:
import yaml | |
class Employee: | |
"""Employee class.""" | |
def __init__(self, name, id): | |
self._name, self._id = name, id | |
def employee_constructor(loader: yaml.SafeLoader, node: yaml.nodes.MappingNode) -> Employee: | |
"""Construct an employee.""" | |
return Employee(**loader.construct_mapping(node)) | |
def get_loader(): | |
"""Add constructors to PyYAML loader.""" | |
loader = yaml.SafeLoader | |
loader.add_constructor("!Employee", employee_constructor) | |
return loader | |
yaml.load(open("config.yml", "rb"), Loader=get_loader()) | |
""" | |
{ | |
'name': 'MyBusiness', | |
'locations': ['Hawaii', 'India', 'Japan'], | |
'employees': [ | |
<__main__.Employee object at 0x7f0ea2694d10>, | |
<__main__.Employee object at 0x7f0ea2694d90> | |
] | |
} | |
""" |
这样一来,我们可以直接生成对应的类的对象,省去了自己创建的过程。
# dataclass
你是否经常面临下面的情形:
class Person: | |
def __init__(self, name, age, gender, ...): | |
self.name = name | |
self.age = age | |
self.gender = gender | |
... |
当我们在 __init__
函数的参数列表中敲击了一系列参数之后,又需要在 __init__
函数体中,将它们一一赋值给类成员变量。一旦这些变量数量增多,这项工作就成了一项非常繁琐无趣的事情。
很多时候,我们定义类只是想将一些相关的数据组织起来,但却不得不动手编写许多重复的代码。于是乎,这里就轮到 dataclass
出场了。类比而言, dataclass
可以看成是 C/C++
中的结构体,能够方便我们将一系列的数据组合在一起,同时给每个成员指定默认值,不用再手动为成员变量赋值。
可以看下面这个例子[4]:
from dataclasses import dataclass | |
@dataclass | |
class Lang: | |
"""a dataclass that describes a programming language""" | |
name: str = 'python' | |
strong_type: bool = True | |
static_type: bool = False | |
age: int = 28 |
这里定义了一个编程语言的类 Lang
,我们从 dataclasses
模块中引入了装饰器 dataclass
。之后,我们在类中定义了一系列成员变量。
使用上述方式定义之后,该类会自动生成一个初始化构造函数:
def __init__(self, name: str='python', | |
strong_type: bool=True, | |
static_type: bool=False, | |
age: int=28): | |
self.name = name | |
self.strong_type = strong_type | |
self.static_type = static_type | |
self.age = age |
除此之外, __repr__
和 __eq__
函数也会自动生成,便于打印和比较。通过以上方式,再也不用每次都自己定义一个构造函数,并且为每个变量都赋值一遍,省却了繁琐的操作。
执行效果如下:
>>> Lang() | |
Lang(name='python', strong_type=True, static_type=False, age=28) | |
>>> Lang('js', False, False, 23) | |
Lang(name='js', strong_type=False, static_type=False, age=23) | |
>>> Lang('js', False, False, 23) == Lang() | |
False | |
>>> Lang('python', True, False, 28) == Lang() | |
True |
除此之外, dataclass
可以通过 asdict
或者 astuple
生成类成员的字典或者元组。
需要注意的是,对于 mutable
类型的变量,如 list
、 map
等,推荐使用 field
来初始化:
from dataclasses import dataclass, field | |
@dataclass | |
class C: | |
mylist: List[int] = field(default_factory=list) |
# 结合 dataclass
和 YAML
在介绍以上两部分内容之后,我们可以结合 dataclass
和 YAML
来方便地构建配置类,并从 YAML
文件导入,或者导出到 YAML
文件。
参考了相关资料后[3:1][5],我编写了下面的参数类模板。
import sys | |
import yaml | |
from yaml import SafeLoader, SafeDumper | |
from yaml.nodes import MappingNode | |
from dataclasses import dataclass, asdict, field, is_dataclass | |
from typing import Type | |
from typing_extensions import Self | |
@dataclass | |
class BaseConfig(object): | |
@classmethod | |
def constructor(cls, loader: SafeLoader, node: MappingNode) -> Self: | |
"""Construct an instance.""" | |
return cls(**loader.construct_mapping(node)) | |
@classmethod | |
def loader(cls, safe_loader: SafeLoader) -> Type[SafeLoader]: | |
"""Add constructors to PyYAML loader.""" | |
safe_loader = yaml.SafeLoader | |
safe_loader.add_constructor(f"!{cls.__name__}", cls.constructor) | |
for (name, data_fields) in cls.__dataclass_fields__.items(): | |
cls_type = data_fields.type | |
if is_dataclass(cls_type): | |
safe_loader.add_constructor(f"!{cls_type.__name__}", cls_type.constructor) | |
safe_loader = cls_type.loader(SafeLoader) | |
return safe_loader | |
@classmethod | |
def representer(cls, dumper: SafeDumper, config) -> MappingNode: | |
"""Represent an instance as a YAML mapping node.""" | |
return dumper.represent_mapping(f"!{cls.__name__}", config.__dict__) | |
@classmethod | |
def dumper(cls, safe_dumper: SafeDumper) -> Type[SafeDumper]: | |
"""Add representers to a YAML seriailizer.""" | |
# safe_dumper = yaml.SafeDumper | |
safe_dumper.add_representer(cls, cls.representer) | |
for (name, data_fields) in cls.__dataclass_fields__.items(): | |
cls_type = data_fields.type | |
if is_dataclass(cls_type): | |
safe_dumper.add_representer(cls_type, cls_type.representer) | |
safe_dumper = cls_type.dumper(safe_dumper) | |
return safe_dumper | |
@dataclass | |
class DatasetConfig(BaseConfig): | |
data_root_path: str = "./data/coco" | |
train_path: str = "./data/coco/train" | |
train_ann_path: str = "./data/coco/annotations/instances_train2017.json" | |
@dataclass | |
class COCODatasetConfig(BaseConfig): | |
name: str = "COCO" | |
num_classes: int = 80 | |
dataset: DatasetConfig = field(default_factory=DatasetConfig) | |
@dataclass | |
class COCODataConfig(BaseConfig): | |
train_data: COCODatasetConfig = field(default_factory=COCODatasetConfig) | |
val_data: COCODatasetConfig = field(default_factory=COCODatasetConfig) | |
# If you want dump without tag, change the tag name of the class | |
# to 'tag:yaml.org,2002:map', this is the default map type of YAML | |
# @classmethod | |
# def representer(cls, dumper: SafeDumper, config) -> MappingNode: | |
# """Represent an instance as a YAML mapping node.""" | |
# return dumper.represent_mapping("tag:yaml.org,2002:map", config.__dict__) | |
if __name__ == "__main__": | |
# config = COCODatasetConfig() | |
# config = yaml.load(open("output.yaml", "rb"), Loader=COCODataConfig.loader(SafeLoader)) | |
config = COCODataConfig() | |
print(config) | |
print(asdict(config)) | |
# config = DatasetConfig() | |
# with open("output.yaml", "w") as stream: | |
# stream.write(yaml.dump(config, Dumper=COCODatasetConfig.dumper())) | |
# stream.write(yaml.dump(config, Dumper=COCODataConfig.dumper(SafeDumper))) | |
# yaml.dump(config, sys.stdout, Dumper=COCODatasetConfig.dumper(), sort_keys=False) | |
yaml.dump(config, sys.stdout, Dumper=COCODataConfig.dumper(SafeDumper), sort_keys=False) |
上述模板可以支持多个 dataclass
类别的嵌套,并且会保留每个类别的 tag
标签,便于将来再读取配置的内容。
如果不想输出某个 dataclass
类的 tag
,则重载其 representer
和 loader
函数,修改其中设定的 tag
名称为 tag:yaml.org,2002:map
[6]。其他类型可以参看官方文档[7]。
如果某些变量依赖于其他变量的赋值,可以使用 __post_init__
方法:
@dataclass | |
class C: | |
a: int | |
b: int | |
c: int = field(init=False) | |
def __post_init__(self): | |
self.c = self.a + self.b |
此外,如果需要额外的参数用于初始化,但是之后的程序中不需要用到它的话,可以指定一个 field
的类型注解为 dataclasses.InitVar
,那么这个 field
将只能在初始化过程中( __init__
和 __post_init__
)使用,当初始化完成后访问该 field
会返回一个 dataclasses.Field
对象而不是 field
原本的值,也就是该 field
不再是一个可访问的数据对象。比如一个由数据库对象,它只需要在初始化的过程中被访问:
@dataclass | |
class C: | |
i: int | |
j: int = None | |
database: InitVar[DatabaseType] = None | |
def __post_init__(self, database): | |
if self.j is None and database is not None: | |
self.j = database.lookup('j') | |
c = C(10, database=my_database) |
database
只在初始化过程中用于初始化 i, j
,后续无法再访问,可以认为是传递了一个额外的参数用于初始化操作。
# 总结
通过以上内容,我们得到了一个用于 YAML
格式的通用模板类,用于导入或者导出相关的配置内容,帮助我们更好地管理配置文件中参数的结构,提高工作效率。减少加班