在“设计杂谈”系列,主要会讲述设计相关的问题与思考。本文将主要针对配置的起源与实际面对的问题进行必要的讲述,以求为读者建立一个初步的概念。
话不多说,开始。
由静态到动态
首先,我们来看这样一个需求:
很简单对吧,那不妨先脑洞一波,来看看一般的做法会是怎样的。
写脚本时的硬编码
首先,我们来看看一种最简单的情况。比如,现在需要把一个或者一批图片,按照既定的操作来进行处理。你打算写一个脚本来完成这项工作,使用 PIL
来进行处理,大概会是这样的一个画风
from PIL import Image
image = Image.open('test_pic.png')
new_image: Image.Image = image
# flip operation, left_right or top_bottom
new_image = new_image.transpose(Image.FLIP_TOP_BOTTOM)
# rotate, 0-360, from x-axis to y-axis
new_image = new_image.rotate(45)
# crop, x1, y1, x2, y2
width, height = new_image.size
new_image = new_image.crop((width / 4, height / 4, width * 3 / 4, height * 3 / 4))
而运行结果则是这样的(此处使用matplotlib展示图片,下文同;左图为原图,右图为转换后的图)
既定的效果实现了,效果十分令人满意,一切都没啥问题。
写轮子时的函数化
而接下来,由于这个脚本在越来越多的地方被用到,所以需要将其进行一个封装,使之不再依赖于特定的图片,也不再依赖于固定的流程,可以被任意调用以实现图片的处理。
一番思索后,你进行了封装,于是画风变成了这个样子
from PIL import Image
def processes(image: Image.Image, ops) -> Image.Image:
cur_image = image
for op in ops:
op_type, *op_vals = op
if op_type == 'flip':
cur_image = cur_image.transpose(op_vals[0])
elif op_type == 'rotate':
cur_image = cur_image.rotate(op_vals[0])
elif op_type == 'crop':
cur_image = cur_image.crop(op_vals)
else:
raise ValueError(f'Unknown operation - {repr(op_type)}.')
return cur_image
if __name__ == '__main__':
image_ = Image.open('test_pic.png')
width, height = image_.size
new_image = processes(
image_,
[
('flip', Image.FLIP_TOP_BOTTOM),
('rotate', 45),
('crop', width / 4, height / 4, width * 3 / 4, height * 3 / 4),
]
)
直接运行时,可以获得和之前程序等价的效果。此后,对图片的三种操作正式被封装为了轮子,并在项目中被广泛使用。
写工具时的配置化
然而没过多久,随着处理过程的越发复杂,又开始有了新的需求。具体来说,你希望可以以一种更加黑盒的形态来对图片进行处理,并且考虑到很多时候处理的流程都是相对固定的,因此如果对图片进行处理时可以不必编写Python源代码那才是最好的。
你思来想去,决定使用yaml这样的“配置文件”,来表达图片处理的流程逻辑。结合之前的函数化轮子,再一次封装后,画风又变了。首先是配置文件 config.yaml
processes:
- type: flip
direction: top_bottom
- type: rotate
angle: 45
- type: crop
x1: 100
y1: 100
x2: 500
y2: 500
即呈现这样的结构
而后运行命令(该脚本已经经过重构,此处由于并非问题重点,因而不作详细展示)
python process.py config.yaml test_pic.png
也同样得到了上述的结果,并且这一过程中对于使用者而言,并无需关注 process.py
的具体内容。
配置文件
基本特性
诸如上面的“故事”,相比各位都非常熟悉,常做开发的人应该都会日常遇到。当一个业务逻辑需要往通用化方向发展时,就会产生“轮子”这一形态;而当“轮子”需要被进一步黑盒化、工具化时,则需要引入“配置”这一概念,也就是上面所使用的 config.yaml
。
对于“配置文件”,稍作观察不难发现其特性:
- 多为树状结构(JSON、YAML、conf等均是如此)
- 表达唯一且明确的语义(即配置本身不该含有歧义)
- 可移植,不依赖具体的语言或环境(并无绝对界限,能满足实际需要即可)
现实存在的问题
针对上述的第1和第2点,我们可以推测出以下的事实:
- 意味着其经常需要处理多层次的配置数据,表达嵌套的语义。
- 意味着配置需要能精确表达其语义,展示其所有信息以确保对配置解析器而言能解读出唯一含义。
这样一来,在配置的设计上就会存在一个不可避免的问题——绝对完备且嵌套的语义,必然导致配置文件可能变得极为冗长。例如上述的图片处理,如果处理的阶段再多上一些,尤其是剪裁(crop)操作要是较多的话,则可以形成一个极长无比的配置文件。
也许你可能还是对配置的复杂性没有概念,那么容我们再看一个更复杂些的例子
更复杂的例子
其实说不复杂也不复杂,就是上面图片处理的plus版。更具体来说,就是对一系列的图片,进行带有一定随机性的处理。这个操作常应用于深度学习领域,被称为数据增强(Data Augmentation),定义如下:
举个例子,你需要训练一个用于识别“手掌”图片的模型,然而你收集到的数据大都是左手,就像这样的
训练过模型的都知道,如果直接将这样的数据作为训练集,则很可能会出现针对“左手”的过拟合情况,通俗来说,就是会让模型只认识左手,而不认识右手。
而为了解决这一问题,我们需要对原本的图片数据进行“增强”,也就是随机转换生成一些新的数据,其中比较常见的四种转换分别为(均基于 torchvision
):
- 一定概率进行水平翻转(RandomHorizontalFlip)
- 一定概率进行竖直翻转(RandomVerticalFlip)
- 随机旋转一定角度(RandomRotation)
- 随机裁剪一定尺寸(RandomCrop)
- 随机改变一定色调(ColorJitter)
例如,对于上面那只左手,可以经过一系列随机转换后,形成如下的效果(左上角为原图,其余八张图为转换后的图)
有了这些数据,则可以很大程度上解决掉“无法识别右手”的情况,也大概不怎么会再因为光线之类的问题而导致识别出错了。
交代了完了这个问题,如果我们也需要将“数据增强”这一过程,封装为与上文类似的配置形态,则会变得极为复杂且混乱。比如,我们来随便挑一个配置看看,下面这个是RandomCrop
的输入数据格式(完整版在此)
一共五个参数,其中有三个是带有结构化数据的。而与之类似的还有RandomRotation
,相比之下RandomVerticalFlip
和RandomHorizontalFlip
要简单不少。
我们可以尝试用YAML格式写一下由上述四类增强转换组合形成的流水线,画风则很可能是这样的
processes:
- type: random_vertial_flip
p: 0.5
- type: random_horizontal_flip
p: 0.5
- type: random_rotation
degrees:
- -180
- 180
interpolation: nearest
expand: true
center:
- 100
- 100
fill: 0
- type: random_crop
size:
- 300
- 300
padding:
- 3
- 5
pad_if_needed: true
fill:
- 255
- 128
- 0
padding_mode: constant
即呈现这样的
这还仅仅只是每种类型仅添加了一次,如果次数更多的话,结构还会更加复杂。不仅如此,序列形式的结构化数据在树状结构的配置文件中的表意也并不足够清晰。
设计上的矛盾点
以上的问题,在配置文件的实际使用中是广泛存在的,而且由于实际应用中还会涉及到多模块,以及多种不同的结构化数据格式,因此还可能存在更加冗长的内容,以及层数更深的嵌套。
而这个问题实际上根源在于配置灵活性和易用性的矛盾。例如,在上述数据增强的例子中,RandomCrop和RandomRotation均有较多的参数,但是实际应用中会被使用的只是一少部分。而若要覆盖此类操作的全部潜在功能,则又必须设立如此复杂的各类参数。于是矛盾就是这样形成的。这也正是为什么常见的配置文件往往都比较冗长的原因。
鉴于这样的情况,为了更好地设计配置文件格式,使之便于编写和查阅,则是一个值得讨论的问题。实际上,在设计领域已经有比较成功的实践,且适合于配置文件的设计,这便是——“约定大于配置” 原则。具体内容,将在下一篇中作详细讨论,敬请期待。