detectron2中使用configurable函数对其他函数进行装饰,作用为将cfg参数拆包。
注:全文中的cfg字眼,均表示一个名称为"cfg"的形参,其类型为fvcore.common.config.CfgNode及其子类或omegaconf.DictConfig及其子类
一、签名
在detectron2/config/config.py中定义了configurable函数,完整签名为:
configurable(init_func=None, *, from_config=None)
python中,函数参数列表中独立的"*" 后面的参数,必须用关键字传参。(python3.8之后还支持列表中独立的"/",其之前的参数不可用关键字传参)
configurable(arg1, from_config=arg2)
(√)
configurable(arg1, arg2)
(×, TypeError: configurable() takes 1 positional argument but 2 were given)
二、作用
detectron2.config.configurable
(init_func=None, *, from_config=None)装饰一个函数或一个类的__init__方法,以便能通过调用一个能将CfgNode转换为若干参数的from_config()函数,来允许主调方以CfgNode对象为参调用这个函数或类__init__方法。
该函数作用为:装饰某个函数,使得主调方在调该函数时,会先调用指定的拆解函数将传进来的cfg参数拆解成参数字典,再以字典调用被装饰函数。
detectron2使用CfgNode类(位于detecront2.config,继承自fvcore.common.config.CfgNode)来保存网络结构、训练阶段、推理阶段、数据集等等领域的参数,展开的话可能有上百个参数。对于detectron2中的某个函数而言,可能用到CfgNode中某些环节的若干个参数,但按照编程理念来讲,不可能每个函数都要求输入一整个CfgNode实例,这不符合最小接口隔离原则,于是每个函数都应该将自己的需要将用到的环节参数完整地列到参数列表中,如def func(weights, num_classes, trainset)
。但是如此一来,对于主调方而言,就得从CfgNode中一个一个地提取参数,传入被调函数,调用函数可能复杂形如:
func(cfg.MODEL.WEIGHTS, cfg.MODEL.ROI_HEADS.NUMCLASSES, cfg.DATASETS.TRAIN)
这对于使用者而言,无疑是一种灾难。
而configurable函数的存在解决了这一矛盾。通过使用configurable装饰需要很多参数的函数,可以委托拆包函数有选择地从cfg中提取需要的参数,传入被装饰函数中,使得调用形式化简为:
func(cfg)
并且由于configurable会识别入参是否含有cfg,因此上述两种对func的调用方式均为合法。此外configurable内部实现的参数判断远不仅对cfg生效,甚至以下的调用方式也是合法的:
func(cfg, num_classes=80)
三、用法
该函数有两种用法,装饰普通函数和装饰类初始化器。
-
装饰类初始器
class A: @configurable def __init__(self, a, b=2, c=3): pass @classmethod def from_config(cls, cfg): # 'cfg' must be the first argument # Returns kwargs to be passed to __init__ return {"a": cfg.A, "b": cfg.B} a1 = A(a=1, b=2) # regular construction a2 = A(cfg) # construct with a cfg a3 = A(cfg, b=3, c=4) # construct with extra overwrite
python的装饰器
这种用法基本属于装饰器的标准写法——在函数func签名上方写“@decorator”(decorator代指装饰器名称)。
解释器在调用该func函数时,等同于执行:
func = decorator(func) # decorator生成一个将func函数包装后的函数并返回,并同名覆盖func func() # 调用上方decorator生成的函数`
“等同于”的说法可能不够准确,因为第一句
func = decorator(func)
是在函数声明处执行的,且只执行一次。
同样,上面configurable的例子,也是在configurable函数中,将传进来的__init__函数包装,此后调用__init__函数等同于调用configurable包装的函数。那么包装后的函数长什么样子呢,具体行为是:
- 检查入参是否含有cfg
- 如含有cfg,调用该类的from_config类方法(classmethod),将cfg转换成参数字典,调用__init__。
- 如无cfg,直接按原参数列表调用__init__。
-
装饰普通函数
@configurable(from_config=lambda cfg: {"a": cfg.A, "b": cfg.B}) def a_func(a, b=2, c=3): pass a1 = a_func(a=1, b=2) # regular call a2 = a_func(cfg) # call with a cfg a3 = a_func(cfg, b=3, c=4) # call with extra overwrite
这种写法实际上颇具迷惑性,不过迷惑性只在你想从运行时了解configurable实现逻辑时出现,先将迷惑性跳过。此处configurable的功能还是很明朗的:指定from_config方法,包装某函数。
包装后的函数具体行为仍然是:
-
检查入参是否含有cfg
-
如含有cfg,调用from_config方法,将cfg转换成参数字典,调用a_func。
-
如无cfg,直接按原参数列表调用a_func。
实际上此处的configurable已经不是一个装饰器了,它仅仅是一个返回装饰器的函数,在理解configurable实现逻辑时,此处容易产生迷惑。
-
四、实现逻辑
configurable如何判断被装饰函数是类初始化器还是普通函数呢?
非常简单,configurable第一个参数init_func不为空,则被装饰的是类初始化器,为空则被装饰的是普通函数。
def configurable(init_func=None, *, from_config=None):
if init_func is not None:
# 被装饰的是类初始化器
...
else:
# 被装饰的是普通函数
...
这表明,configurable内部是不负责区分两种功能的核心操作的,怎么区分被装饰函数,取决于如何调用configurable。
在第一种用法中,"@configurable"将configurable当作装饰器,此时被装饰的函数__init__作为参数传入configurable,自然参数init_func不为空,走第一个分支。
在第二种用法中,"@configurable(from_config=lambda cfg: {“a”: cfg.A, “b”: cfg.B})直接指定参数from_config调用一次configurable函数,函数返回另一个装饰器,此时发挥装饰器效果,由该装饰器去包装被装饰函数a_func。
为了更好地理解上面两句话,我们约定几个名词:
def my_decorator(func):
def wrapped(*args, **kwargs):
# do something before
func(*args, **kwargs)
# do something after
return wrapped
@my_decorator
def my_func(arg1, arg2, arg2):
# do something
pass
myfunc(1, 2, 3) # 等同于my_decorator(my_func)(1, 2, 3),等同于wrapped(1, 2, 3),但语法上这个wrapped是无法取到实例的。
上面列举了一个标准的装饰器装饰函数的写法,我们称my_decorator为一个装饰器,称wrapped为一个包裹函数,称my_func为被my_decorator装饰的被装饰函数。(声明:包裹函数与被装饰函数两个名词是笔者添加的,仅作区分目的,并非专业名词)
于是,调用被装饰函数,相当于调用了装饰器对被装饰函数进行包装后的包裹函数。
-
明显,第一种用法,就属于上述写法,调用__init__,相当于调用了configurable对__init__的包裹函数。不过由于__init__是类成员方法,在参数上需要做小小的改变:
def my_decorator(init_func): def wrapped(self, *args, **kwargs): type(self).f() init_func(self, *args, **kwargs) # do something after return wrapped class MyClass: @my_decorator def __init__(self, arg1, arg2, arg3): # do something pass @classmethod def f(): pass a = MyClass(1, 2, 3) # 等同于 # MyClass.f() # a <- __init__(1, 2, 3) # do something after
-
那么,第二种用法,属于以下的写法:
def get_my_decorator(somthing_before): def my_decorator(func): def wrapped(*args, **kwargs): something_before() func(*args, **kwargs) # do something after return wrapped return my_decorator def f(): pass @get_my_decorator(f) # 不考虑语法、将something_before硬编码为f的话,等同于@my_decorator def my_func(arg1, arg2, arg2): # do something pass my_func(1, 2, 3) # 等同于 # f() # my_func(1, 2, 3) # do something after
调用my_func,等于调用了由get_my_decorator生成的装饰器对my_func进行包装后的包裹函数,其中get_my_decorator生成的装饰器负责将f函数包装到my_func前面执行。
综上所述,configurable的实现逻辑为:
def configurable(init_func=None, *, from_config=None):
if init_func is not None:
# 被装饰的是类初始化器
def wrapped(self, *args, **kwargs):
...
if 参数包含cfg:
args_dict = type(self).from_config(cfg) # cfg是从arg或kwargs中提取的
init_func(self, **args_dict)
else:
init_func(self, *arg, **kwargs)
return wrapped
else:
if from_config is None:
return configurable
# 被装饰的是普通函数
def decorator(func):
def wrapped(*args, **kwargs):
...
if 参数包含cfg:
args_dict = from_config(cfg) # cfg是从arg或kwargs中提取的
func(**args_dict)
else:
func(*args, kwargs)
return wrapped
return decorator
注意到当init_func和from_config均为空时,直接将configurable自身返回了,这样会使得"@configurable"和"@configurable()"两种写法效果相等。此外源码中还使用@functool.wraps来保持原函数的函数名称、注释文档、参数列表等等,此处没有写出来。
五、参数判断
在上文的实现逻辑中,关于configurable内部的参数判断仅用中文伪码“参数包含cfg”及"args_dict"写出,而实际上源码实现了许多鲁棒性操作:
- 当入参不包含"cfg"时,直接调用被装饰函数,而不进行拆包
- 当拆包函数第一个参数不接收"cfg"时,抛出TypeError异常
- 第2点满足时,当拆包函数支持变参时,将入参全部传入拆包函数
- 第2点满足时,当拆包函数不支持变参时,将入参列表中拆包函数所支持的参数提取出来,传入拆包函数,并将剩余的参数与拆包函数返回值合并,传入被装饰函数。
由于第4点的存在,被configurable装饰的函数不仅支持传"cfg"调用、传具体参数调用,还支持两者混合调用,三种调用方式例子在二、作用中已列出。
六、写在最后
这篇文章简单地总结了一下detectron2中的configurable思想、用法和实现逻辑。文中实现逻辑依据于detectron2 v0.4源码及detectron2官方文档,除此之外没有参考任何关于detectron2的文章或书籍,具有一定程度的主观性及缺乏实验性,如有错误和理解不到之处,欢迎指出探讨。