mmClassification学习笔记

文章目录

  • 前言
  • mmcv安装
  • mmcv库文件夹架构
  • mmcv概要
  • mmcvclassification文件夹
  • 官方给的demo[只是一个模型推理]
  • 根据官方教程进行学习
    • 1 如何编写配置文件
      • 配置文件以及权重命名规则
        • 配置文件命名规则
        • 权值文件命名规则
      • 我们自己的配置文件的结构
        • 模型
        • 数据
        • 训练策略
        • 运行设置(注意这个“运行”不是runner)
      • 继承并修改配置文件
        • 使用配置文件里的中间变量
        • 忽略基础配置文件中的部分内容
        • 引用基础配置文件里的变量
      • 通过命令行参数修改配置信息
      • 导入用户自定义模块【这部分最后看】
    • 2 如何添加新数据集
      • (1)通过重新组织数据来自定义数据集
        • 将数据集重新组织为已有格式
        • 自定义数据集的示例
      • (2)通过混合数据集来自定义数据集【这部分我还未涉及到】
        • 重复数据集
        • 类别平衡数据集
    • 3 如何设计数据处理流程(data pipeline)
      • (1)设计数据流水线(data pipeline)
        • 数据加载
        • 预处理
        • 格式化
        • 实际代码中如何使用pipeline
      • (2)扩展及使用自定义流水线
      • (3)流水线可视化
    • 4 如何增加新模块(new module)
      • 开发新组件
      • 添加新的主干网络
      • 添加新的颈部组件(neck)
      • 添加新的头部组件head
      • 添加新的损失函数
      • 总结
    • 5 如何自定义优化策略
      • 使用Pytorch内置优化器
      • 定制学习率调整策略
        • (1)定制学习率衰减策略
        • (2)定制学习率预热策略(warmup)
      • 定制动量调整策略
      • 参数化精细配置
      • 梯度裁剪与梯度累计
        • 梯度裁剪
        • 梯度累计
      • 用户自定义优化方法【该部分先不掌握】
        • 自定义优化器
          • 1. 定义一个新的优化器
          • 2. 注册优化器
          • 3. 在配置文件中指定优化器
        • 自定义优化器构造器
    • 6 如何自定义模型运行参数
      • 定制工作流workflow
      • 钩子Hook
        • 默认训练钩子
          • (1)权重文件钩子(CheckpointHook)
          • (2)日志钩子(LoggerHooks)
          • (3)验证钩子(EvalHook)
        • 使用内置钩子
      • 自定义钩子(了解钩子的生成和使用)
        • 1.创建一个新钩子
        • 2.注册新钩子
        • 3.修改配置
      • 常见问题
        • config中的resume_from, load_from,init_cfg.Pretrained 区别
    • 7 如何微调模型
      • (1)继承基础配置
      • (2)修改模型
      • (3)修改数据集
      • (4)修改训练策略设置
      • (5)开始训练
    • 8 模型推理和微调的Shell运行指令
    • 9 mmcls的一些apis使用
      • 模型推理
      • 模型微调
    • 8 模型推理和微调的Shell运行指令
    • 9 mmcls的一些apis使用
      • 模型推理
      • 模型微调

前言

在学习mmcv之前,我需要去知道这玩意儿到底是个啥。mmcv是用于cv研究的基础Python,支持MMLAB的许多研究框架,例如MMDetection、MMSegmentation之类的。所以要接触这些具体框架,来深入理解mmcv库,以此达到能够运用它的地步。

根据该库主要分为两个部分,一部分是与深度学习框架无关的工具函数,比如IO/Image/Video相关的一些操作,另一部分是为Pytorch写的一套训练工具,可以大大减少用户需要写的代码量,同时让整个流程的定制变得容易,这一部分是我比较感兴趣的。

通过查阅,mmcv一共提供如下功能:

  • 通用IO API
  • 图像处理
  • 视频处理
  • 图像和注释可视化
  • 实用程序(progress bar, timer)
  • 带有hook机制的Pytorch运行器
  • 各种CNN架构

现在看着这些东西,还是很陌生的,不过慢慢学习下去,像鑫哥说的那样,肯定会有不少的收获。

由于mmcv库内容很多,所以我需要通过一个具体的任务来学习这个库。从最简单的开始吧,看mmcvclassification。mmcv的代码在服务器上跑,在本机上看。

另外,b站上有OpenMMLab开源工具的使用教学,我先看教学视频,然后再看源码,这样会吸收得更快。网课笔记见IPAD,本文笔记记录我阅读源码、网课笔记以及官方教程所得

学习目标:学习了这个之后,能把它用在我的实际项目中。

mmcv安装

根据官方的解释,mmcv分mmcv-fullmmcv两种版本,差别仅在于full版本提供CUDA选项。由于我的实验均在服务器上进行,因此在我的本机上安装mmcv普通版本即可。

pip install mmcv

mmcv库文件夹架构

【主要关注mmcv文件夹】

├─.github
│  ├─ISSUE_TEMPLATE
│  └─workflows
├─docs
│  ├─community
│  ├─deployment
│  ├─get_started
│  ├─understand_mmcv
│  └─_static
│      ├─community
│      ├─css
│      └─image
├─docs_zh_CN
│  ├─community
│  ├─deployment
│  ├─get_started
│  ├─understand_mmcv
│  └─_static
│      ├─css
│      └─image
├─examples
├─mmcv
│  ├─arraymisc
│  ├─cnn
│  │  ├─bricks
│  │  └─utils
│  ├─engine
│  ├─fileio
│  │  └─handlers
│  ├─image
│  ├─model_zoo
│  ├─onnx
│  │  └─onnx_utils
│  ├─ops
│  │  └─csrc
│  │      ├─common
│  │      │  └─cuda
│  │      ├─onnxruntime
│  │      │  └─cpu
│  │      ├─parrots
│  │      ├─pytorch
│  │      │  └─cuda
│  │      └─tensorrt
│  │          └─plugins
│  ├─parallel
│  ├─runner
│  │  ├─hooks
│  │  │  └─logger
│  │  └─optimizer
│  ├─tensorrt
│  ├─utils
│  ├─video
│  └─visualization
├─requirements
└─tests
    ├─data
    │  ├─config
    │  ├─demo.lmdb
    │  ├─for_3d_ops
    │  ├─for_ccattention
    │  ├─for_psa_mask
    │  ├─for_scan
    │  │  └─sub
    │  ├─model_zoo
    │  │  └─mmcv_home
    │  ├─patches
    │  └─scripts
    ├─test_cnn
    ├─test_image
    ├─test_ops
    ├─test_runner
    ├─test_utils
    └─test_video

mmcv概要

mmcv分为公用底层模块和抽象训练接口,共用底层模块如下,其中算子部分是最重要的:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4wCeCOK5-1648435522728)(C:\Users\PrinceAlLHH\AppData\Roaming\Typora\typora-user-images\image-20211130165113926.png)]

这里重点介绍一下mmcv的抽象训练接口:

  • 执行器Runner类

    负责实现完整的训练流程,执行训练流程的抽象逻辑,被用于各算法库中,目前实现了IterBasedRunner和EpochBasedRunner,用户可以自定义Runner来定义更灵活的训练流程。

    通过注册hook在预定义的位点执行自定义函数,实现自定义训练流程:

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tRDbCsua-1648435522730)(file:///F:\Users\PrinceAlLHH\Documents\Tencent Files\378072862\Image\C2C\584F66480B1C55E4CCF26ED306D005CC.png)]

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uhQJ4XHP-1648435522731)(C:\Users\PrinceAlLHH\AppData\Roaming\Typora\typora-user-images\image-20211130164739056.png)]

  • 钩子Hook类

    在Runner运行过程中被触发而执行的函数,Runner通过Hooks来完成训练流程中的各种具体行为,如打印log,存储模型等。

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uZ6Hj2NY-1648435522732)(C:\Users\PrinceAlLHH\AppData\Roaming\Typora\typora-user-images\image-20211130164816558.png)]

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BdeKBDUm-1648435522732)(C:\Users\PrinceAlLHH\AppData\Roaming\Typora\typora-user-images\image-20211130164846027.png)]

  • 配置文件Config类

    将YAML、JSON、Python定义的配置转成统一的dict。一般通过Registry来实现配置文件中字段到模块类的映射和实例化

  • 注册器Registry类

    管理从字符串到模块类的映射,一般通过相应的build函数将配置字段映射到对应的模块类,并将该类实例化。

    使用步骤:
    (1)新建一个专门掌管该模块的Registry,如DATASETS,MODEL、PIPELINES等,如:

    DATASETS = Registry('dataset')
    PIPELINES = Registry('pipeline')
    

    (2)实现我们需要的模块的build函数,如:

    def build_dataset(cfg, default_args=None):
        from .dataset_wrappers import (ConcatDataset, RepeatDataset,
                                       ClassBalancedDataset)
        if isinstance(cfg, (list, tuple)):
            dataset = ConcatDataset([build_dataset(c, default_args) for c in cfg])
        elif cfg['type'] == 'RepeatDataset':
            dataset = RepeatDataset(
                build_dataset(cfg['dataset'], default_args), cfg['times'])
        elif cfg['type'] == 'ClassBalancedDataset':
            dataset = ClassBalancedDataset(
                build_dataset(cfg['dataset'], default_args), cfg['oversample_thr'])
        else:
            dataset = build_from_cfg(cfg, DATASETS, default_args)
    
        return dataset
    

    (3)在该模块的Registry中注册该模块(注册步骤分两步:装饰器+配置文件import);

    (4)构建并使用该模块。

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CiLuVmgD-1648435522733)(C:\Users\PrinceAlLHH\AppData\Roaming\Typora\typora-user-images\image-20211130164923188.png)]

OK,大致介绍完之后,咱们就去实际代码中,看一看是如何运用这些训练接口的吧

mmcvclassification文件夹

├─.dev_scripts
│  └─benchmark_regression
├─.github
│  ├─ISSUE_TEMPLATE
│  └─workflows
├─.idea
│  └─inspectionProfiles
├─configs
│  ├─fp16
│  ├─lenet
│  ├─mlp_mixer
│  ├─mobilenet_v2
│  ├─mobilenet_v3
│  ├─regnet
│  ├─repvgg
│  │  └─deploy
│  ├─res2net
│  ├─resnest
│  ├─resnet
│  ├─resnext
│  ├─seresnet
│  ├─shufflenet_v1
│  ├─shufflenet_v2
│  ├─swin_transformer
│  ├─t2t_vit
│  ├─tnt
│  ├─vgg
│  ├─vision_transformer
│  └─_base_
│      ├─datasets
│      │  └─pipelines
│      ├─models
│      │  ├─regnet
│      │  └─swin_transformer
│      └─schedules
├─demo
├─docker
│  └─serve
├─docs
│  ├─community
│  ├─imgs
│  ├─tools
│  ├─tutorials
│  └─_static
│      ├─css
│      ├─image
│      │  └─tools
│      │      └─visualization
│      └─js
├─docs_zh-CN
│  ├─community
│  ├─tools
│  ├─tutorials
│  └─_static
│      ├─css
│      └─image
│          └─tools
│              └─visualization
├─mmcls
│  ├─apis
│  ├─core
│  │  ├─evaluation
│  │  ├─export
│  │  ├─fp16
│  │  ├─utils
│  │  └─visualization
│  ├─datasets
│  │  ├─pipelines
│  │  └─samplers
│  ├─models
│  │  ├─backbones
│  │  ├─classifiers
│  │  ├─heads
│  │  ├─losses
│  │  ├─necks
│  │  └─utils
│  │      └─augment
│  └─utils
├─requirements
├─resources
├─tests
│  ├─data
│  │  └─dataset
│  │      ├─a
│  │      └─b
│  │          └─subb
│  ├─test_data
│  │  ├─test_datasets
│  │  └─test_pipelines
│  ├─test_downstream
│  ├─test_metrics
│  ├─test_models
│  │  ├─test_backbones
│  │  └─test_utils
│  ├─test_runtime
│  └─test_utils
└─tools
    ├─analysis_tools
    ├─convert_models
    ├─deployment
    ├─misc
    └─visualizations

官方给的demo[只是一个模型推理]

先看一下该项目提供的一个demo:./demo/image_demo.py。这个demo内容很简单,所以全po上来,做一下注释,然后分析一下:

# ./demo/image_demo.py

# Copyright (c) OpenMMLab. All rights reserved.
from argparse import ArgumentParser

from mmcls.apis import inference_model, init_model, show_result_pyplot


def main():
    parser = ArgumentParser()   # 实例化一个ArgumentParser对象,用来装填main函数输入
    # 添加argument,其实有点像c++的main的char *arg
    parser.add_argument('img', help='Image file')
    parser.add_argument('config', help='Config file')
    parser.add_argument('checkpoint', help='Checkpoint file')
    parser.add_argument(
        '--device', default='cuda:0', help='Device used for inference')
    args = parser.parse_args()  # 返回对象args,这个对象包含如上4个参数的成员属性

    # build the model from a config file and a checkpoint file
    model = init_model(args.config, args.checkpoint, device=args.device)    # 初始化模型
    # test a single image
    result = inference_model(model, args.img)   # 获得推理输出
    # show the results
    show_result_pyplot(model, args.img, result) # 展示结果


if __name__ == '__main__':
    main()

# ./mmcls/apis/inference.py includes function init_model, inference_model, show_result_pyplot

# Copyright (c) OpenMMLab. All rights reserved.
import warnings

import mmcv
import numpy as np
import torch
from mmcv.parallel import collate, scatter
from mmcv.runner import load_checkpoint

from mmcls.datasets.pipelines import Compose
from mmcls.models import build_classifier


def init_model(config, checkpoint=None, device='cuda:0', options=None):
    """Initialize a classifier from config file.

    Args:
        config (str or :obj:`mmcv.Config`): Config file path or the config
            object.
        checkpoint (str, optional): Checkpoint path. If left as None, the model
            will not load any weights.
        options (dict): Options to override some settings in the used config.

    Returns:
        nn.Module: The constructed classifier.
    """
    if isinstance(config, str):	# 当传入的config是个str对象时
        config = mmcv.Config.fromfile(config) # 获得Config对象
    elif not isinstance(config, mmcv.Config): # 当传入的config既不是str或Config对象
        raise TypeError('config must be a filename or Config object, '
                        f'but got {type(config)}')
    if options is not None:
        config.merge_from_dict(options)	
    config.model.pretrained = None
    model = build_classifier(config.model)	# 使用注册好的分类器模块
    if checkpoint is not None:
        # Mapping the weights to GPU may cause unexpected video memory leak
        # which refers to https://github.com/open-mmlab/mmdetection/pull/6405
        checkpoint = load_checkpoint(model, checkpoint, map_location='cpu')
        if 'CLASSES' in checkpoint.get('meta', {}):
            model.CLASSES = checkpoint['meta']['CLASSES']
        else:
            from mmcls.datasets import ImageNet
            warnings.simplefilter('once')
            warnings.warn('Class names are not saved in the checkpoint\'s '
                          'meta data, use imagenet by default.')
            model.CLASSES = ImageNet.CLASSES
    model.cfg = config  # save the config in the model for convenience
    model.to(device)
    model.eval()
    return model

# 推理模型
def inference_model(model, img):
    """Inference image(s) with the classifier.

    Args:
        model (nn.Module): The loaded classifier.
        img (str/ndarray): The image filename or loaded image.

    Returns:
        result (dict): The classification results that contains
            `class_name`, `pred_label` and `pred_score`.
    """
    cfg = model.cfg
    # 这一句代码可以输出当前model挂载在gpu还是cpu上
    device = next(model.parameters()).device  # model device
    # build the data pipeline
    if isinstance(img, str):
        # 下列pipeline是个包含字典项的list,使用pipeline[0]可以索引在第一个位置的位置的字典项,然后使用pipeline[0]['type']来访问该字典的type的这个ket-value对 
        # list可以使用insert和pop来插入或移除掉位于某一索引的项
        if cfg.data.test.pipeline[0]['type'] != 'LoadImageFromFile':
            cfg.data.test.pipeline.insert(0, dict(type='LoadImageFromFile'))
        data = dict(img_info=dict(filename=img), img_prefix=None)
    else:
        if cfg.data.test.pipeline[0]['type'] == 'LoadImageFromFile':
            cfg.data.test.pipeline.pop(0)
        data = dict(img=img)
    test_pipeline = Compose(cfg.data.test.pipeline) # Compose a data pipeline with a sequence of transforms.
    data = test_pipeline(data)	# test_pipeline里有个__call__方法,用来对data作transforms
    # 将每个数据字段放入具有outer dimension batch size的Tensor/DataContainer中。
    # collect函数作用是,仅保留模型的forward方法所需的项
    data = collate([data], samples_per_gpu=1)
    if next(model.parameters()).is_cuda:
        # scatter to specified GPU
        data = scatter(data, [device])[0]

    # forward the model 前向计算
    with torch.no_grad():
        scores = model(return_loss=False, **data)	# *表示元组解引用,**表示字典解引用
        pred_score = np.max(scores, axis=1)[0]
        pred_label = np.argmax(scores, axis=1)[0]
        result = {'pred_label': pred_label, 'pred_score': float(pred_score)}
    result['pred_class'] = model.CLASSES[result['pred_label']]
    return result


def show_result_pyplot(model,
                       img,
                       result,
                       fig_size=(15, 10),
                       title='result',
                       wait_time=0):
    """Visualize the classification results on the image.

    Args:
        model (nn.Module): The loaded classifier.
        img (str or np.ndarray): Image filename or loaded image.
        result (list): The classification result.
        fig_size (tuple): Figure size of the pyplot figure.
            Defaults to (15, 10).
        title (str): Title of the pyplot figure.
            Defaults to 'result'.
        wait_time (int): How many seconds to display the image.
            Defaults to 0.
    """
    if hasattr(model, 'module'):
        model = model.module
    model.show_result(
        img,
        result,
        show=True,
        fig_size=fig_size,
        win_name=title,
        wait_time=wait_time)

首先,看到调用mmcv.Config.fromfile这里,我ctl B一下,看到了@staticmethod和@properly的用法,例如:

@staticmethod
    def _validate_py_syntax(filename):
        with open(filename, 'r', encoding='utf-8') as f:
            # Setting encoding explicitly to resolve coding issue on windows
            content = f.read()
        try:
            ast.parse(content)
        except SyntaxError as e:
            raise SyntaxError('There are syntax errors in config '
                              f'file {filename}: {e}')
@property
def text(self):
    return self._text

这里的这些方法是Config类下的,@staticmethod修饰的静态方法可以直接通过类来调用,就如mmcv.Config.fromfile;而@properly修饰的方法,可以让该方法作为属性被调用,用来返回该类下的属性,也就是提供了一个只读接口。【见我做的装饰器笔记】

OK,言归正传,通过上面这个demo,有几点看到的新鲜的东西:

  • inference_model中建立data pipeline,进行输入数据的处理

    关注以下这三行代码:

    test_pipeline = Compose(cfg.data.test.pipeline)
    data = test_pipeline(data)
    data = collate([data], samples_per_gpu=1)
    

    第一行,根据配置文件,定义出pipeline;

    第二行,根据pipeline进行数据预处理

    第三行,collate函数即删除,除了参数 keys (这里是data)指定以外的所有键值对。

  • 使用Registry来获得分类器模型:在def init_model函数中,构建分类器模型的时候,即build_classifier(config.model)是使用已经注册好的分类器,ctrl Bmodel = build_classifier(config.model)时,得到如下代码:

    # Copyright (c) OpenMMLab. All rights reserved.
    from mmcv.cnn import MODELS as MMCV_MODELS
    from mmcv.cnn.bricks.registry import ATTENTION as MMCV_ATTENTION
    from mmcv.utils import Registry
    
    MODELS = Registry('models', parent=MMCV_MODELS)
    
    BACKBONES = MODELS
    NECKS = MODELS
    HEADS = MODELS
    LOSSES = MODELS
    CLASSIFIERS = MODELS
    
    ATTENTION = Registry('attention', parent=MMCV_ATTENTION)
    
    
    def build_backbone(cfg):
        """Build backbone."""
        return BACKBONES.build(cfg)
    
    
    def build_neck(cfg):
        """Build neck."""
        return NECKS.build(cfg)
    
    
    def build_head(cfg):
        """Build head."""
        return HEADS.build(cfg)
    
    
    def build_loss(cfg):
        """Build loss."""
        return LOSSES.build(cfg)
    
    
    def build_classifier(cfg):
        return CLASSIFIERS.build(cfg)
    
    

    MODELS = Registry(‘models’, parent=MMCV_MODELS)是新建了一个叫做MODELS的注册器Registry,即我们自己构建的model模块都要往这个注册器中注册,后面的build_classifier(cfg)函数就是实现构建我们的分类器model对应的build函数。

    利用Registry获得分类器模型的过程:demo中的build函数的参数是config实例,也就是我们ctrl b的代码model = build_classifier(config.model)中的参数config.model。一般来说,build函数的作用就是,在注册之后,获得config中的type字段,例如converter_type = cfg.pop(‘type’),在Registry里去索引我们想要使用的那个类,并获得它,再把arg和config传进去进行实例化得到分类器模型。

    这里就要说一下,config实例是如何生成的了,见如下代码:

    if isinstance(config, str):
        config = mmcv.Config.fromfile(config)
    elif not isinstance(config, mmcv.Config):
        raise TypeError('config must be a filename or Config object, '
                        f'but got {type(config)}')
    if options is not None:
        config.merge_from_dict(options)
    

    最初,config是我们传入程序的一个config文件,然后这个文件通过Config类中的fromfile方法,给转换成一个Config类的实例,然后将这个实例通过它自身的merge_from_dict方法,将其转化为一个字典类型的数据结构,这个字典里,就包括各种字段,包含上面所说的type字段。

    所以再重复一句,一般来说,build函数的参数是config实例中的type字段,也就是说,我们所得到的模型,是根据我们提供的type,在Registry中进行对应选择的。但在这个例子中,传入的是config.model,但是我追踪不到这个model的定义,所以现在是个未知数,所以我也想看看,注册进Registry的模型是在哪里定义的?看来应该是从配置文件中来的,然后转化成Config类来调用。

    这样看来,我有非常多的问题需要等待解决,所以之后,根据官方的基础教程,来一步步学习如何编写这些文件和模块。

根据官方教程进行学习

一共有如下几条需要学习的:

  • 如何编写配置文件
  • 如何增加新数据集
  • 如何设计数据处理流程(data pipeline)
  • 如何增加新模块
  • 如何自定义优化策略
  • 如何自定义运行参数
  • 如何微调模型

ok,我觉得根据学习教程,自己写写code,肯定coding能力能上升的。把这些基础的都学习了,然后自己写一个demo出来。

1 如何编写配置文件

mmcv主要是使用python文件来作为配置文件【不像一般的YAML文件等】。首先,所有配置文件放置在configs文件夹下【基于mmclassification目录】。主要包含 _base_ 原始配置文件夹 以及 resnet, swin_transformervision_transformer 等诸多算法文件夹。_base_文件夹下提供的是可用来继承的基本配置文件。

配置文件的流程:

  • 在configs文件夹下,创建自己的配置文件夹(命名规则见后),然后在这个文件夹下,创建我们自己的python配置文件;

  • 进入到python配置文件,为了更轻松地构建我们自己的训练配置文件【配置文件中包括模型、数据、训练策略、默认运行设置等信息】,需要继承已有的基本配置文件,可以一次性继承多个,用列表封装起来,那么如何继承的呢?如【这下面只是范例,具体继承哪个文件,根据需求来】:

    _base_ = [
        '../_base_/models/resnet50.py',           # 模型
        '../_base_/datasets/imagenet_bs32.py',    # 数据
        '../_base_/schedules/imagenet_bs256.py',  # 训练策略
        '../_base_/default_runtime.py'            # 默认运行设置
    ]
    

配置文件以及权重命名规则

配置文件命名规则

MMClassification 按照以下风格进行配置文件命名,代码库的贡献者需要遵循相同的命名规则。文件名总体分为四部分:算法信息,模块信息,训练信息和数据信息。逻辑上属于不同部分的单词之间用下划线 '_' 连接,同一部分有多个单词用短横线 '-' 连接。

{algorithm info}_{module info}_{training info}_{data info}.py
  • algorithm info:算法信息,算法名称或者网络架构,如 resnet 等;
  • module info: 模块信息,因任务而异,用以表示一些特殊的 neck、head 和 pretrain 信息;
  • training info:一些训练信息,训练策略设置,包括 batch size,schedule 数据增强等;
  • data info:数据信息,数据集名称、模态、输入尺寸等,如 imagenet, cifar 等;

通过上述描述,我也可以知道这些文件名具体指代的意义是什么了。

例:这里提一个需要记忆的配置文件名的案例:

repvgg-D2se_deploy_4xb64-autoaug-lbs-mixup-coslr-200e_in1k.py
  • repvgg-D2se: 算法信息
    • repvgg: 主要算法名称。
    • D2se: 模型的结构。
  • deploy:模块信息,该模型为推理状态。
  • 4xb64-autoaug-lbs-mixup-coslr-200e: 训练信息
    • 4xb64: 使用4块 GPU 并且 每块 GPU 的批大小为64。
    • autoaug: 使用 AutoAugment 数据增强方法。
    • lbs: 使用 label smoothing 损失函数。
    • mixup: 使用 mixup 训练增强方法。
    • coslr: 使用 cosine scheduler 优化策略。
    • 200e: 训练 200 轮次。
  • in1k: 数据信息。 配置文件用于 ImageNet1k 数据集上使用 224x224 大小图片训练。

权值文件命名规则

权重的命名主要包括配置文件名,日期和哈希值。

{config_name}_{date}-{hash}.pth

我们自己的配置文件的结构

configs/_base_ 文件夹下有 4 个基本组件类型,分别是:

  • 模型(model)
  • 数据(data)
  • 训练策略(schedule)
  • 运行设置(runtime)

因此,我可以通过继承一些_base_ 提供的基本配置文件轻松构建自己的训练配置文件。由来自_base_ 的组件组成的配置称为 primitive。那这个继承是如何继承呢?在网课笔记中,是这样提到的:在自己的配置文件中,加入下面这行代码,可继承对应的模型信息:

_base_ = '../faster_rcnn/faster_rcnn_r50_fpn_1x_coco.py'

由上面这行代码我们可以得知,如果我们要创建自己的配置文件,首先要在configs文件夹下创建我们自己的配置文件夹,然后在这个文件夹中编写我们的python配置文件,然后在这个python文件中输入上述这行代码,继承基本配置文件。

完成基本的继承之后,接下来,就是来构建配置文件的大部分:模型、数据、训练策略、默认运行设置

模型

模型信息model,在我们的python配置文件中是一个python字典dict,主要包括网络结构、损失函数等信息:

  • type : 分类器名称,也就是我们需要从Registry中选择模型的“依据”, 目前 MMClassification 只支持 ImageClassifier, 参考 API 文档。
  • backbone : 主干网类型,可用选项参考 API 文档。
  • neck : 颈网络类型,目前 MMClassification 只支持 GlobalAveragePooling, 参考 API 文档。
  • head : 头网络类型, 包括单标签分类与多标签分类头网络,可用选项参考 API 文档。
    • loss : 损失函数类型, 支持 CrossEntropyLoss, LabelSmoothLoss 等,可用选项参考 API 文档。
  • train_cfg :训练配置, 支持 mixup, cutmix 等训练增强。

注意!配置文件中的’type’不是构造时的参数,而是类名

举一个model的例子:

model = dict(					# 创建一个dict来存放model的信息
    type='ImageClassifier',     # 分类器类型
    backbone=dict(				
        type='ResNet',          # 主干网络类型
        depth=50,               # 主干网网络深度, ResNet 一般有18, 34, 50, 101, 152 可以选择
        num_stages=4,           # 主干网络状态(stages)的数目,这些状态产生的特征图作为后续的 head 的输入。
        out_indices=(3, ),      # 输出的特征图输出索引。越远离输入图像,索引越大
        frozen_stages=-1,       # 网络微调时,冻结网络的stage(训练时不执行反相传播算法),若num_stages=4,backbone包含stem 与 4 个 stages。frozen_stages为-1时,不冻结网络; 为0时,冻结 stem; 为1时,冻结 stem 和 stage1; 为4时,冻结整个backbone
        style='pytorch'),       # 主干网络的风格,'pytorch' 意思是步长为2的层为 3x3 卷积, 'caffe' 意思是步长为2的层为 1x1 卷积。
    neck=dict(type='GlobalAveragePooling'),    # 颈网络类型
    head=dict(
        type='LinearClsHead',     # 线性分类头,
        num_classes=1000,         # 输出类别数,这与数据集的类别数一致
        in_channels=2048,         # 输入通道数,这与 neck 的输出通道一致
        loss=dict(type='CrossEntropyLoss', loss_weight=1.0), # 损失函数配置信息
        topk=(1, 5),              # 评估指标,Top-k 准确率, 这里为 top1 与 top5 准确率
    ))

上面这个例子中,有需要注意的地方是:

  • model里面有一个三个“子model”:backbone、neck以及head,它们同样也由dict构建,也是个“model”,也都有type字段。

  • 在backbone中,提到了stem、stages的概念,我之前没太关注过这两个概念,就在这里做一下笔记,以初始设计空间AnyNet为例:

    AnyNet包括三部分:简单的stem,进行大量计算的body和最后用于预测分类的head。由于网络的精度和速度主要依赖于body部分,因此将stem和head固定下来,更多的去讨论body的情况。初始的AnyNet的body部分包括4个stage,每个stage由若干block组成,而输入分辨率、block的数量、block的宽度(通道数)等则为该设计空间的变量参数。

    image-20211202102003558

    如在resnet中,block都采用的是残差瓶颈block,如下图所示,使用这样block的AnyNet设计空间叫AnyNetX。

    image-20211202102047022

    因此AnyNetX设计空间共计包括16个自由度:4个stages,每个stage包括4个参数(block的数量 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fuhCJonH-1648435522735)(https://www.zhihu.com/equation?tex=d_i)] 、block的宽度 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IFKNhKaV-1648435522736)(https://www.zhihu.com/equation?tex=w_i)] 、bottleneck的比率 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1F8zwBDL-1648435522737)(https://www.zhihu.com/equation?tex=b_i)] 、组宽度 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sYZr3iSx-1648435522737)(https://www.zhihu.com/equation?tex=g_i)] )

  • 通过这种编写model配置的方式看来,感觉很简洁,并且可读性也很高。

  • 另外,我之前在看mmclassification的demo那里的时候,传入build函数的参数是config.model,当时我不知道这个“.model”是啥,通过这个例子我就明白了,这个model原来是个dict,其包含了字段type

数据

数据参数data在配置文件中,同样为一个python字典dict,主要包含构造DataLoader(数据集加载器)的配置信息。

  • samples_per_gpu:构建dataloader时,每个GPU的Batch Size;

  • workers_per_gpu:构建dataloader时,每个GPU的线程数;

    【这里补充一个小知识点:GPU的多线程机制

    GPU的执行速度很快,但是当运行从内存中获取纹理数据这样的指令时(即访问内存,由于内存访问是瓶颈,此操作比较缓慢),整个流水线便出现长时间停顿。在CPU内部,使用多级Cache来提高访问内存的速度。GPU中也使用Cache,不过Cache命中率不高,只用Cache解决不了这个问题。所以,为了保持流水线保持忙碌,GPU的设计者使用了多线程机制(multi-threading)。当像素着色器针对某个像素的线程A遇到存取纹理的指令时,GPU会马上切换到另外一个线程B,对另一个像素进行处理。等到纹理从内存中取回时,可再切换到线程A。

  • train | val | test:构造数据集

    • type:数据集类型,MMClassification支持ImageNetCifar等数据集,参考API 文档;
    • data_prefix:数据集根目录;
    • pipeline:数据处理流水线【该部分的设计教程见后面】

评估参数evaluation也是一个字典,为evaluation hook的配置信息【hook钩子函数】,主要包括评估间隔、评估指标等。

具体配置见下面这个例子【以后自己配置的时候,变量命名也按照这个例子当中的来】:

# dataset settings
dataset_type = 'ImageNet'	# 数据集名称
# 这里学到了一个写dict的一个代码规范:key-value中的那个等号两边不要空格
img_norm_cfg = dict(		# 图像的归一化配置,用来归一化输入的图像
    mean=[123.675, 116.28, 103.53],	# 预训练里用来预训练backbone模型的平均值
    std=[58.395, 57.12, 57.375],		# 预训练里用来预训练backbone模型的标准差
    to_rgb=True;						# 是否反转通道,因为使用cv2,mmcv读取图片默认为BGR通道顺序,这里进行Normalize均值方差数组的数值是以RGB的通道顺序来进行的,因此需要反转通道顺序。
)

# 训练数据的处理流水线【是一个包含dicts的list】
train_pipeline = [
    # 注意,这里的type也和model构造里的type一样,
    # type都表示的是数据处理的某些具体操作
    dict(type='LoadImageFromFile'),					# 读取图片
    dict(type='RandomResizedCrop',size=224),		# 随机缩放抠图
    dict(type='RandomFlip', flip_prob=0.5, direction='horizontal'),	# 以概率为0.5随机水平反转图片
    # *对元组进行解引用,**是对字典进行解引用
    dict(type='Normalize', **img_norm_cfg),			# 归一化
    dict(type='ImageToTensor', keys=['img'])		# 将image转化为torch.Tensor
    dict(type='ToTensor', keys=['gt_label'])		# 将gt_label转化为torch.Tensor
    dict(type='Colletct', keys=['img', 'gt_label'])	# 决定数据中哪些key应该传递给分类器的流程,这里表示应该传递key为'img'和'gt_label'的数据
]

# 测试数据流水线
test_pipeline = [
    dict(type='LoadImageFromFile'),
    dict(type='Resize', size=(256, -1)),
    dict(type='CenterCrop', crop_size=224),
    dict(type='Normalize', **img_norm_cfg),
    dict(type='ImageToTensor', keys=['img']),
    dict(type='Collect', keys=['img'])             # test 时不传递 gt_label
]

# 构建data
data = dict(
	samples_per_gpu = 32, # 单个GPU的Batch Size
    workers_per_gpu = 2,  # 单个GPU的线程数
    train = dict(
    	type=dataset_type,			# 数据集名称
        data_prefix='data/imagenet/train'	# 这是个相对路径,相对于项目根目录,当不存在ann_file时,类别信息从文件夹自动获取[设置项目根目录可以用sys库来解决]
        pipeline=train_pipeline
    ),
    val = dict(
    	type=dataset_type,
        data_prefix='data/imagenet/val',
        ann_file='data/imagenet/meta/val.txt',   # 标注文件路径,存在 ann_file 时,不通过文件夹自动获取类别信息
        pipeline=test_pipeline
    ),
    test=dict(             # 测试数据集信息
        type=dataset_type,
        data_prefix='data/imagenet/val',
        ann_file='data/imagenet/meta/val.txt',
        pipeline=test_pipeline
    )
)

# 构建evaluation,即evaluation hook的配置
evaluation = dict(	
	interval=1,				# 验证期间的间隔,单位为epoch或者iter,取决于runner类型
    metric='accuracy'		# 验证期间使用的指标。
)
  • 通过这个例子,可以清楚地知道,pipeline的构建是一个包含dict的list,其包含的内容也都大致清楚了,所以也可以消除我当时看demo的时候的疑惑了。
  • 另外,如果我们想要调用训练流水线,就这么调用:data.train.pipeline,就是说,可以这么调用字典的key

训练策略

该部分配置主要含有:优化器设置、optimizer hook设置、学习率策略和runner设置,下面来具体说明:

  • optimizer:优化器设置信息,支持Pytorch所有的优化器,参参考相关 mmcv 文档
  • optimizer_configoptimizer hook的配置文件,如设置梯度限制等,参考相关mmcv 代码
  • lr_config:学习率策略,支持 “CosineAnnealing”、 “Step”、 “Cyclic” 等等,参考相关 mmcv 文档
  • runner : 有关 runner 可以参考 mmcv 对于 runner 介绍文档

具体配置见下面这个例子:

# 用于构建优化器的配置文件。支持Pytorch中的所有优化器,同时它们的参数于Pytorch里的优化器参数一致
optimizer = dict(
	type='SGD',		# 优化器类型
    lr=0.1,			# 优化器的学习率,参数的使用细节请参照对应的Pytorch文档
    momentum=0.9,	# 动量
    weight_decay=0.0001		# 权重衰减系数
)

# optimizer hook的配置文件
optimizer_config = dict(grad_clip=None)	# 大多数方法不使用梯度限制grad_clip。

# 学习率调整配置,用于注册LrUpdater hook
lr_config = dict(
    policy='step',			# 调度流程(scheduler)的策略,也支持CosineAnnealing,Cyclic等策略
    step=[30, 60, 90]		# 在epoch为30,60,90的时候,lr进行衰减
)

# runner设置
runner = dict(
	type='EpochBasedRunner',	# 将要使用的runner的类别,如IterBasedRunner 或 EpochBasedRunner。
    max_epochs=100				# runner总回合数, 对于IterBasedRunner使用 `max_iters`
)

运行设置(注意这个“运行”不是runner)

本部分设置主要包括权重策略、日志配置、分布式训练参数、断点权重路径和工作目录等等。具体配置见如下这个例子:

# Checkpoint hook 的配置文件,Checkpoint即保存模型
checkpoint_config = dict(interval=1)	# 保存的间隔为1,单位会根据runner的设置而改变,取值为epoch或iter,在上面训练策略设置中,我们设置了runner为EpochBasedRunner,所以此时单位为epoch

# 日志配置信息
log_config = dict(
	interval = 100				# 打印日志的间隔,同样单位由runner设置决定
    hooks=[
        dict(type='TextLoggerHook'),          # 用于记录训练过程的文本记录器(logger)。
        # dict(type='TensorboardLoggerHook')  # 同样支持 Tensorboard 日志
    ]
)

# 分布式训练参数的设置,端口同样可被设置
dist_params = dict(backend='nccl')	# nccl means "Nvidia Collective multi-GPU Communication Library",它是一个实现多GPU的collective communication通信(all-gather, reduce, broadcast)库

log_level = 'INFO'			# 日志的输出级别
resume_from = None			# 从给定路径恢复检查点(Checkpoints),训练模式将从检查点保存的轮次开始恢复训练
workflow = [('train', 1)]	# runner的工作流程,[('train', 1)]表示只有一个工作流,且工作流只进行一次
work_dir = 'work_dir'		# 用于保存当前实验的模型检查点和日志的目录文件路径

继承并修改配置文件

  • 如该part最开始所说,为了精简代码、更快地修改配置文件,以及便于理解,我们最好继承现有方法

  • 对于在同一算法文件夹下的所有配置文件,MMClassification推荐只存在一个对应的原始配置文件。

  • 所有其他的配置文件都应该继承原始配置文件,这样就能保证配置文件的最大继承深度为3

例如,如果我们在ResNet的基础上做了一些修改,我们可以首先通过指定_base_ = ‘./resnet50_8xb32_in1k.py’(这个路径是相对于我的配置文件的路径,所以也可以看出,我们自己的配置文件,也最好和原始配置文件放在同一目录下),来继承基础的ResNet结构、数据集以及其他训练配置信息,然后修改配置文件中的必要参数以完成继承。如想在基础resnet50的基础上将训练轮数由100改为300,和修改学习率衰减轮数,同时修改数据集路径,我们可以建立新的配置文件configs/resnet/resnet50_8xb32-300e_in1k.py(从这里也可以看出,我们创建的新的配置文件,要和原始配置文件在同一目录路径下),文件中写入以下内容:

_base_ = './resnet50_8xb32_in1k.py'

runner = dict(max_epochs=300)

lr_config = dict(step=[150, 200, 250])

data = dict(
	train=dict(data_prefix='mydata/imagenet/train')
	val=dict(data_prefix='mydata/imagenet/train',),
    test=dict(data_prefix='mydata/imagenet/train')
)

从这个例子,我得出了几点总结:

  • 首先,这种**”继承方式“, 在继承之后,然后直接改写**对应的我们想改写的配置,例如改写runner时,我们只需要改写对应的max_epochs,而不用再加上type那个字段;同样对于lr_config,我们这里只改写了step。其他没有被改写的地方,和所继承的基础配置文件保持一致。这种继承方式,需要记住【这个需要去源码看一看,核实一下】。

  • 另外对于目录路径问题,例如修改data配置的时候,我们设置data_prefix’mydata/imagenet/train’,所以可知这个根目录应该是工程project所在路径,因此我们在编写文件的时候,我们需要先设置project中文件索引的根目录为project本身的路径,如果通过Pycharm运行的话,可以直接设置工程路径为根目录路径,如果直接用python的话,需要用sys库来添加根目录,如:

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wYCBoE5G-1648435522738)(C:\Users\PrinceAlLHH\AppData\Roaming\Typora\typora-user-images\image-20211129201546066.png)]

    图中os.path.join(os.getcwd(), “…/…/…/”)相当于构建路径./…/…/…/

​ 我感觉还有个做法,就是如果了解整个目录结构的话,可以使用…来返回上级目录,这样索引到对应 文件夹,比如:data_prefix=’./…/…/mydata/imagenet/train’

使用配置文件里的中间变量

用一些中间变量让配置文件更加清晰,也更容易修改

以数据集配置文件为例,其中的train_pipeline / test_pipeline就是我们所说的中间变量,这个中间变量会传入data dict中。如果我们想要修改一些例如训练或测试时输入图片的大小,就只需要修改train_pipeline / test_pipeline,就不用在data dict中修改了。这样的话,提高了代码的独立性,不会让每次修改都修改整体的代码【比如将一个过程给封装成函数,然后需要改的话,直接改函数内容,调用部分仍保持不变,跟咱们这个中间变量是一个道理】。

# dataset settings
dataset_type = 'ImageNet'	# 数据集名称
# 这里学到了一个写dict的一个代码规范:key-value中的那个等号两边不要空格
img_norm_cfg = dict(		# 图像的归一化配置,用来归一化输入的图像
    mean=[123.675, 116.28, 103.53],	# 预训练里用来预训练backbone模型的平均值
    std=[58.395, 57.12, 57.375],		# 预训练里用来预训练backbone模型的标准差
    to_rgb=True;						# 是否反转通道,因为使用cv2,mmcv读取图片默认为BGR通道顺序,这里进行Normalize均值方差数组的数值是以RGB的通道顺序来进行的,因此需要反转通道顺序。
)

# 训练数据的处理流水线【是一个包含dicts的list】
train_pipeline = [
    # 注意,这里的type也和model构造里的type一样,
    # type都表示的是数据处理的某些具体操作
    dict(type='LoadImageFromFile'),					# 读取图片
    dict(type='RandomResizedCrop',size=224),		# 随机缩放抠图
    dict(type='RandomFlip', flip_prob=0.5, direction='horizontal'),	# 以概率为0.5随机水平反转图片
    # *对元组进行解引用,**是对字典进行解引用
    dict(type='Normalize', **img_norm_cfg),			# 归一化
    dict(type='ImageToTensor', keys=['img'])		# 将image转化为torch.Tensor
    dict(type='ToTensor', keys=['gt_label'])		# 将gt_label转化为torch.Tensor
    dict(type='Colletct', keys=['img', 'gt_label'])	# 决定数据中哪些key应该传递给分类器的流程,这里表示应该传递key为'img'和'gt_label'的数据
]

# 测试数据流水线
test_pipeline = [
    dict(type='LoadImageFromFile'),
    dict(type='Resize', size=(256, -1)),
    dict(type='CenterCrop', crop_size=224),
    dict(type='Normalize', **img_norm_cfg),
    dict(type='ImageToTensor', keys=['img']),
    dict(type='Collect', keys=['img'])             # test 时不传递 gt_label
]

# 构建data
data = dict(
	samples_per_gpu = 32, # 单个GPU的Batch Size
    workers_per_gpu = 2,  # 单个GPU的线程数
    train = dict(
    	type=dataset_type,			# 数据集名称
        data_prefix='data/imagenet/train'	# 这是个相对路径,相对于项目根目录,当不存在ann_file时,类别信息从文件夹自动获取[设置项目根目录可以用sys库来解决]
        pipeline=train_pipeline
    ),
    val = dict(
    	type=dataset_type,
        data_prefix='data/imagenet/val',
        ann_file='data/imagenet/meta/val.txt',   # 标注文件路径,存在 ann_file 时,不通过文件夹自动获取类别信息
        pipeline=test_pipeline
    ),
    test=dict(             # 测试数据集信息
        type=dataset_type,
        data_prefix='data/imagenet/val',
        ann_file='data/imagenet/meta/val.txt',
        pipeline=test_pipeline
    )
)

# 构建evaluation,即evaluation hook的配置
evaluation = dict(	
	interval=1,				# 验证期间的间隔,单位为epoch或者iter,取决于runner类型
    metric='accuracy'		# 验证期间使用的指标。
)

忽略基础配置文件中的部分内容

有时,我们需要设置 _delete_=True 去忽略基础配置文件里的一些域内容。 可以参照 mmcv 来获得一些简单的指导。

下面是个例子。如果想在上述Resnet50案例中使用cosine schedule,那么我们继承对应的基础配置文件,并直接修改【将policy字段改成’CosineAnnealing’】的话,会报 get unexcepected keyword 'step' 的error。这是因为,在基础配置文件中,lr_config字典域中,仍然保存着step的字段,而这个字段是和step schedule相配的字段,所以我们直接修改policy的话,就会导致不匹配,所以我们需要使用_delete_=True去忽略所继承的基础配置文件里的lr_config字典域的相关内容,那我们应该这样继承和修改:

_base_ = '../../configs/resnet/resnet50_8xb32_in1k.py'

lr_config = dict(
	_delete=True,	# 删除基础配置文件中,关于lr_config的相关内容
    policy='CosineAnnealing',
    min_lr=0,
    warmup='linear',
    by_epoch=True,
    warmup_iters=5,
    warmup_ratio=0.1
)

引用基础配置文件里的变量

刚刚我们继承了基础配置文件,但有时可能我们在此基础上改写的时候,会出现重复定义的情况,例如该域内已经有了某些内容,我们在继承之后,又定义了此内容,就导致重复了。所以我们可以引用_base_配置信息中的一些域内容,来避免重复定义。可以参照 mmcv 来获得一些简单的指导。

现在来看一个例子:当我们需要在训练数据预处理pipeline中使用auto augment数据增强,我们可以在定义train_pipeline之前,先在我们的_base_中,直接添加进一个auto augument数据增强的配置文件,这里添加的是 [configs/_base_/datasets/imagenet_bs64_autoaug.py],然后定义train_pipeline的时候,直接通过代码{{_base_.auto_increasing_policies}}来引用变量,具体过程如下:

_base_ = ['./pipelines/auto_aug.py']

# dataset settings
dataset_type = 'ImageNet'
img_norm_cfg = dict(
    mean=[123.675, 116.28, 103.53], std=[58.395, 57.12, 57.375], to_rgb=True)
train_pipeline = [
    dict(type='LoadImageFromFile'),
    dict(type='RandomResizedCrop', size=224),
    dict(type='RandomFlip', flip_prob=0.5, direction='horizontal'),
    dict(type='AutoAugment', policies={{_base_.auto_increasing_policies}}),
    dict(type='Normalize', **img_norm_cfg),
    dict(type='ImageToTensor', keys=['img']),
    dict(type='ToTensor', keys=['gt_label']),
    dict(type='Collect', keys=['img', 'gt_label'])
]
test_pipeline = [...]
data = dict(
    samples_per_gpu=64,
    workers_per_gpu=2,
    train=dict(..., pipeline=train_pipeline),
    val=dict(..., pipeline=test_pipeline))
evaluation = dict(interval=1, metric='accuracy')

以上代码要注意:

  • 这里要注意以下,_base_可以继承多个基础配置文件,然后用一个列表来封装它们。所以由这个我们可以知道,如果以后我们想在自己的配置文件中实现多个功能,并且这些功能都有对应的基础配置文件来实现了,我们就可以继承这多个基础配置文件,然后用列表封装一下,最后引用这些配置文件中对应的变量即可。

通过命令行参数修改配置信息

当用户使用脚本 “tools/train.py” 或者 “tools/test.py” 提交任务,以及使用一些工具脚本时,可以通过指定 --cfg-options 参数来直接修改所使用的配置文件内容。

  • 更新配置文件内的字典

    可以按照原始配置文件中字典的键的顺序指定配置选项。
    例如,--cfg-options model.backbone.norm_eval=False 将主干网络中的所有 BN 模块更改为 train 模式。

  • 更新配置文件内列表的键

    一些配置字典在配置文件中会形成一个列表。例如,训练流水线 data.train.pipeline 通常是一个列表。
    例如,[dict(type='LoadImageFromFile'), dict(type='TopDownRandomFlip', flip_prob=0.5), ...] 。如果要将流水线中的 'flip_prob=0.5' 更改为 'flip_prob=0.0',您可以这样指定 --cfg-options data.train.pipeline.1.flip_prob=0.0

  • 更新列表/元组的值。

    当配置文件中需要更新的是一个列表或者元组,例如,配置文件通常会设置 workflow=[('train', 1)],用户如果想更改,
    需要指定 --cfg-options workflow="[(train,1),(val,1)]"注意这里的引号 " 对于列表以及元组数据类型的修改是必要的,并且 不允许 引号内所指定的值的书写存在空格。

导入用户自定义模块【这部分最后看】

本部分仅在当将 MMClassification 当作库构建自己项目时可能用到,初学者可跳过。

在学习完后续教程 如何添加新数据集、如何设计数据处理流程 、如何增加新模块 后,您可能使用 MMClassification 完成自己的项目并在项目中自定义了数据集、模型、数据增强等。为了精简代码,可以将 MMClassification 作为一个第三方库,只需要保留自己的额外的代码,并在配置文件中导入自定义的模块。案例可以参考 OpenMMLab 算法大赛项目。

只需要在你的配置文件中添加以下代码:

custom_imports = dict(
    imports=['your_dataset_class',
             'your_transforme_class',
             'your_model_class',
             'your_module_class'],
    allow_failed_imports=False)

所以下一步就是学习,如何添加新数据集

2 如何添加新数据集

(1)通过重新组织数据来自定义数据集

将数据集重新组织为已有格式

最简单的方法,就是将数据集转换为现有的数据集格式(ImageNet格式)

Imagenet格式如下,为了训练,根据图片的类别,存放至不同子目录下。训练数据文件夹结构如下所示:

imagenet
├── ...
├── train
│   ├── n01440764
│   │   ├── n01440764_10026.JPEG
│   │   ├── n01440764_10027.JPEG
│   │   ├── ...
│   ├── ...
│   ├── n15075141
│   │   ├── n15075141_999.JPEG
│   │   ├── n15075141_9993.JPEG
│   │   ├── ...

为了验证,我们还要提供一个annotation列表。列表的每一行都包含一个文件名及其相应的真实标签。格式如下:

ILSVRC2012_val_00000001.JPEG 65
ILSVRC2012_val_00000002.JPEG 970
ILSVRC2012_val_00000003.JPEG 230
ILSVRC2012_val_00000004.JPEG 809
ILSVRC2012_val_00000005.JPEG 516

注:真实标签的值应该位于 [0, 类别数目 - 1] 之间

具体的文件夹结构,可以去看Imagenet目录,然后我们自己的按照他这个格式即可。

自定义数据集的示例

我们可以编写一个继承自BasesDataset的新数据集类(看下面的示例代码)【注意,我们在编写自己类之后,一定要记得将其注册进对应的Registry中,两步法】,并重载load_annotations(self)方法,类似 CIFAR10 和 ImageNet。【这两个是mmclassification提供的两个数据集类,可以去看看这两个py文件是怎么编写的】

通常,此方法返回一个包含所有样本的列表,其中的每个样本都是一个字典。字典中包含了必要的数据信息,比如imggt_label,见后面的示例代码。

假如我们要实现一个Filelist数据,annotation列表文件image_list.txt内容的格式如下:

000001.jpg 0
000002.jpg 1

于是根据所提供的annotation列表文件,我们就可以建立起Filelist类,并使用load_annotation方法,提取出annotation列表的内容,变成一个样本列表,然后返回。具体看下面的代码。我们可以在mmcls/datasets/filelists.py中创建一个新的数据集类以加载数据。【我们写的类就放在这个datasets文件夹中】:

import mmcv
import numpy as np

from .builder import DATASETS	# DATASETS是掌管dataset模块的Registry,我们自己写的数据集类,要注册进这个Registry中
from .base_dataset import BaseDataset	# 我们编写的新数据集类要继承这个Base数据集类

@DATASETS.registry_module()	# Registry注册第一步
class Filelist(BaseDataset):	# 继承BaseDataset类
    # 只需要改写load_annotation方法
    def load_annotation(self):
        assert isinstance(self.ann_file, str) 	# 确保实例中的ann_file属性是个str
        
        data_infos = []							# 建立样本列表
        with open(self.ann_file) as f:			# 打开ann_file,并进行操作
            # strip() 方法用于移除字符串头尾指定的字符(默认为空格或换行符)或字符序列。
            # split() 通过指定分隔符对字符串进行切片,并返回分割后的字符串列表
            # samples:[[filename_1, gt_label_1, [filename_2, gt_label_2, ...]]]
            samples = [x.strip().split(' ') for x in f.readline()]
            for filename, gt_lable in samples:
                # 为每个文件创建info字典
                info = {'image_prefix': self.data_prefix}	# 添加文件路径
                info['img_info'] = {'filename': filename}	# 添加文件名
                info['gt_label'] = np.array(gt_label, dtype=np.int64)
                data_infos.append(info)
            return data_infos

将新的数据集类加入到 mmcls/datasets/__init__.py 中【注册Registry第二步,在对应模块注册器目录下的__init__文件中添加,这里的模块注册器是datasets】:

from .base_dataset import BaseDataset
...
from .filelist import Filelist

__all__ = ['BaseDataset', ... , 'Filelist']

然后在配置文件中,为了能使用Filelist,我们可以按以下方式修改配置

train = dict(
	type='Filelist',
    ann_file='image_list.txt',	# 是我们之前对应的annotation列表文件
    pipeline=train_pipeline
)

从上面这个例子中,有几点总结:

  • builder。我们可以看到,在写我们自己的数据集类的时候,需要先将这个新的数据集类注册进dataset对应的Registry中,而这个Registry和对应的build函数,就存放在builder.py文件中,上面的DATASET就是dataset对应的Registry。

  • Regisrty。从上面这个例子,我们就第一次经历了如何进行注册。注册分为两步,第一步,在定义我们的新的数据集类的时候,加上装饰器,如@DATASET.registry.module();第二步,在对应模块(这里是dataset模块)的__init__.py文件中,添加相应的注册信息(我们的新数据集类以及所继承的数据集基类)。

  • __init__.py。我们可以发现,我们注册一个小模块的第二步,就是将小模块名给放到对应的大模块目录下的__init__.py文件中,这是为啥呢?首先,该文件标识其所在目录为一个模块包(module package);并且,当一个目录包含了该文件时,当我们用import来导入该目录(模块包)时,会执行__init__.py文件中的代码,在注册所用的这些__init_.py文件中,代码范例如下:

    # mmcv/blob/master/mmcv/runner/__init__.py
    from .base_module import BaseModule, ModuleDict, ModuleList, Sequential
    from .base_runner import BaseRunner
    from .builder import RUNNERS, build_runner
    from .checkpoint import (CheckpointLoader, _load_checkpoint,
                             _load_checkpoint_with_prefix, load_checkpoint,
                             load_state_dict, save_checkpoint, weights_to_cpu)
    from .default_constructor import DefaultRunnerConstructor
    from .dist_utils import (allreduce_grads, allreduce_params, get_dist_info,
                             init_dist, master_only)
    from .epoch_based_runner import EpochBasedRunner, Runner
    from .fp16_utils import LossScaler, auto_fp16, force_fp32, wrap_fp16_model
    from .hooks import (HOOKS, CheckpointHook, ClosureHook, DistEvalHook,
                        DistSamplerSeedHook, DvcliveLoggerHook, EMAHook, EvalHook,
                        Fp16OptimizerHook, GradientCumulativeFp16OptimizerHook,
                        GradientCumulativeOptimizerHook, Hook, IterTimerHook,
                        LoggerHook, LrUpdaterHook, MlflowLoggerHook,
                        NeptuneLoggerHook, OptimizerHook, PaviLoggerHook,
                        SyncBuffersHook, TensorboardLoggerHook, TextLoggerHook,
                        WandbLoggerHook)
    from .iter_based_runner import IterBasedRunner, IterLoader
    from .log_buffer import LogBuffer
    from .optimizer import (OPTIMIZER_BUILDERS, OPTIMIZERS,
                            DefaultOptimizerConstructor, build_optimizer,
                            build_optimizer_constructor)
    from .priority import Priority, get_priority
    from .utils import get_host_info, get_time_str, obj_from_dict, set_random_seed
    
    __all__ = [
        'BaseRunner', 'Runner', 'EpochBasedRunner', 'IterBasedRunner', 'LogBuffer',
        'HOOKS', 'Hook', 'CheckpointHook', 'ClosureHook', 'LrUpdaterHook',
        'OptimizerHook', 'IterTimerHook', 'DistSamplerSeedHook', 'LoggerHook',
        'PaviLoggerHook', 'TextLoggerHook', 'TensorboardLoggerHook',
        'NeptuneLoggerHook', 'WandbLoggerHook', 'MlflowLoggerHook',
        'DvcliveLoggerHook', '_load_checkpoint', 'load_state_dict',
        'load_checkpoint', 'weights_to_cpu', 'save_checkpoint', 'Priority',
        'get_priority', 'get_host_info', 'get_time_str', 'obj_from_dict',
        'init_dist', 'get_dist_info', 'master_only', 'OPTIMIZER_BUILDERS',
        'OPTIMIZERS', 'DefaultOptimizerConstructor', 'build_optimizer',
        'build_optimizer_constructor', 'IterLoader', 'set_random_seed',
        'auto_fp16', 'force_fp32', 'wrap_fp16_model', 'Fp16OptimizerHook',
        'SyncBuffersHook', 'EMAHook', 'build_runner', 'RUNNERS', 'allreduce_grads',
        'allreduce_params', 'LossScaler', 'CheckpointLoader', 'BaseModule',
        '_load_checkpoint_with_prefix', 'EvalHook', 'DistEvalHook', 'Sequential',
        'ModuleDict', 'ModuleList', 'GradientCumulativeOptimizerHook',
        'GradientCumulativeFp16OptimizerHook', 'DefaultRunnerConstructor'
    ]
    

    因此,通过注册了模块到这个文件当中【在__all__列表中添加类名】,我们便可以这样调用我们的小模块了,假如我们注册了CheckpointHook类到该模块包runner中,我们就这样调用:

    mmcv.runner.CheckpointHook
    

(2)通过混合数据集来自定义数据集【这部分我还未涉及到】

MMClassification 还支持混合数据集以进行训练。目前支持合并和重复数据集。

重复数据集

我们使用 RepeatDataset 作为一个重复数据集的封装。举个例子,假设原始数据集是 Dataset_A,为了重复它,我们需要如下的配置文件:

dataset_A_train = dict(
        type='RepeatDataset',
        times=N,
        dataset=dict(  # 这里是 Dataset_A 的原始配置
            type='Dataset_A',
            ...
            pipeline=train_pipeline
        )
    )

类别平衡数据集

我们使用 ClassBalancedDataset 作为根据类别频率对数据集进行重复采样的封装类。进行重复采样的数据集需要实现函数 self.get_cat_ids(idx) 以支持 ClassBalancedDataset

举个例子,按照 oversample_thr=1e-3Dataset_A 进行重复采样,需要如下的配置文件:

dataset_A_train = dict(
        type='ClassBalancedDataset',
        oversample_thr=1e-3,
        dataset=dict(  # 这里是 Dataset_A 的原始配置
            type='Dataset_A',
            ...
            pipeline=train_pipeline
        )
    )

更加具体的细节,请参考 源代码。

下一步就是学习如何设计数据处理流程

3 如何设计数据处理流程(data pipeline)

(1)设计数据流水线(data pipeline)

按照Pytorch典型的用法,我们要通过DatasetDataLoader两个模块来使用多个worker【这个worker就是之前提到GPU时说的,所使用的用来加载数据(batch)GPU线程数】进行数据加载。对Dataset的索引操作将返回一个与模型的forward方法的参数对应的字典【就是网络输入参数x】。

在mmcv中,数据流水线和数据集是解耦的【即数据处理和数据集的制作是分开处理的问题】。通常,数据集定义如何处理标注文件,而数据流水线定义所有的准备数据字典这个过程的步骤。流水线由一系列操作组成,每个操作都将一个字典作为输入,并输出一个字典。这些操作分为数据加载,预处理和格式化。【所以data pipeline不仅仅是对data进行一个transforms,还要实现数据加载和格式化功能】。

具体来看一个例子,以下为Resnet50在ImagNet数据集上的data pipeline:

img_norm_cfg = dict(
    mean=[123.675, 116.28, 103.53], std=[58.395, 57.12, 57.375], to_rgb=True)
train_pipeline = [
    dict(type='LoadImageFromFile'),
    dict(type='RandomResizedCrop', size=224),
    dict(type='RandomFlip', flip_prob=0.5, direction='horizontal'),
    dict(type='Normalize', **img_norm_cfg),
    dict(type='ImageToTensor', keys=['img']),
    dict(type='ToTensor', keys=['gt_label']),
    dict(type='Collect', keys=['img', 'gt_label'])
]
test_pipeline = [
    dict(type='LoadImageFromFile'),
    dict(type='Resize', size=256),
    dict(type='CenterCrop', crop_size=224),
    dict(type='Normalize', **img_norm_cfg),
    dict(type='ImageToTensor', keys=['img']),
    dict(type='Collect', keys=['img'])
]
  • 从上面这段代码可以看出,流水线的每个操作都用字典来表示;对于每个操作,都列出了所添加、更新、删除的相关字典字段。在流水线的最后,使用Collect仅保留进行模型的forward方法所需要的项train部分需要imggt_label,而test部分仅需要img

  • 同样可能还有一个疑问,上述的pipeline的字典项,仅仅提供的是type字段,也就是一个字符串,那么程序是怎么识别这些字符串,来调用相应的操作呢?通过阅读源码可以发现,是通过Registry注册器实现的。回想以下注册器Registry的功能:管理从字符串到模块类的映射。一般通过相应的build函数将配置字段映射到对应的模块类,并将该类实例化。所以这些用字符串表示的操作,可映射为对应的模块类,并一般由build函数对该类进行一个实例化。代码中是这样体现的:

"""
registry.py
"""
obj_cls = registry.get(obj_type)	# obj_type是transform对应的type字段
									# obj_cls是返回的obj_type对应的,在Registry中已注册的对应的模块类。
...
return obj_cls(**args)	# args是transform,也就是pipeline list中的字典项,**为字典的解引用
# 返回字典项transform对应的模块的实例

上段的代码,在之前配置文件教程中已经接触到了,我比较关心的是数据是如何加载的。

数据加载

LoadImageFromFile - 从文件中加载图像

  • 添加:img,img_shape,ori_shape

默认情况下,LoadImageFromFile 将会直接从硬盘加载图像,但对于一些效率较高、规
模较小的模型,这可能会导致 IO 瓶颈。MMCV 支持多种数据加载后端来加速这一过程。例
如,如果训练设备上配置了 memcached,那么我们按照如下
方式修改配置文件。

memcached_root = '/mnt/xxx/memcached_client/'
train_pipeline = [
    dict(
        type='LoadImageFromFile',
        file_client_args=dict(
            backend='memcached',
            server_list_cfg=osp.join(memcached_root, 'server_list.conf'),
            client_cfg=osp.join(memcached_root, 'client.conf'))),
]

更多支持的数据加载后端,可以参见 mmcv.fileio.FileClient。

预处理

Resize - 缩放图像尺寸

  • 添加:scale, scale_idx, pad_shape, scale_factor, keep_ratio
  • 更新:img, img_shape

RandomFlip - 随机翻转图像

  • 添加:flip, flip_direction
  • 更新:img

RandomCrop - 随机裁剪图像

  • 更新:img, pad_shape

Normalize - 图像数据归一化

  • 添加:img_norm_cfg
  • 更新:img

格式化

ToTensor - 转换(标签)数据至 torch.Tensor

  • 更新:根据参数 keys 指定

ImageToTensor - 转换图像数据至 torch.Tensor

  • 更新:根据参数 keys 指定

Collect - 保留指定键值

  • 删除:除了参数 keys 指定以外的所有键值对

实际代码中如何使用pipeline

在官方提供的demo中,是这么使用pipeline的:

# build the data pipeline
if isinstance(img, str):
    if cfg.data.test.pipeline[0]['type'] != 'LoadImageFromFile':
        cfg.data.test.pipeline.insert(0, dict(type='LoadImageFromFile'))
    data = dict(img_info=dict(filename=img), img_prefix=None)
else:
    if cfg.data.test.pipeline[0]['type'] == 'LoadImageFromFile':
        cfg.data.test.pipeline.pop(0)
    data = dict(img=img)
test_pipeline = Compose(cfg.data.test.pipeline)
data = test_pipeline(data)
data = collate([data], samples_per_gpu=1)

以上代码有如下注意的点

  • data被初始化成了一个字典data = dict(img=img)
  • 不像Pytorch原本的Dataset和DataLoader操作,比这些典型操作更为简洁。
  • Compose类的作用,就是将pipeline这个list中的字典项 ,根据它们的type值,从registry中获得对应的操作模块的实例,test_pipelineCompose实例,其包含了如上所说的操作模块的实例属性,通过data = test_pipeline(data)调用该实例的__call__函数,返回处理后的data
  • 这一过程的代码非常简洁,仅用data = test_pipeline(data)就完成了数据处理部分,而像ResizeRandomFlip等操作所需的参数,都直接在我们的config文件中配置好了,所以在代码上就不用体现了,这里也可以看出,使用mmcv这套流程,能让我们的代码变得很简洁。

(2)扩展及使用自定义流水线

如何使用我们的自定义流水线呢?步骤如下:

  • 编写一个新的数据处理操作【是个类】,并放置在mmcls/datasets/pipelines/目录下的任何一个文件中,例如my_pipeline.py。这个类需要重载__call__方法【让类可以像函数一样调用】,该方法接受一个字典作为输入,并返回一个字典。

    # my_pipeline.py 
    from mmcls.datasets import PIPELINES	# PIPELINES是pipeline对应的registry
    
    @PIPELINES.registry_module()	# 注册步骤一
    class MyTransform(object):
        def __call__(self, results):
            # 对 results['img'] 进行变换操作
            ...	# 这一部分操作就类似于上一小节中,所讲的实际代码中如何使用pipeline
            
            return results
    
  • Registry注册第二步,在mmcls/datasets/pipelines/__init__.py中导入这个新的类:

    ...
    from .my_pipeline import MyTransform
    
    __all__ = [
        ..., 'Mytransform'		# 导入该类,即完成注册
    ]
    
  • 在数据流水线的配置文件中,添加具体的数据处理操作细节:

    img_norm_cfg = dict(
        mean=[123.675, 116.28, 103.53], std=[58.395, 57.12, 57.375], to_rgb=True)
    train_pipeline = [
        dict(type='LoadImageFromFile'),
        dict(type='RandomResizedCrop', size=224),
        dict(type='RandomFlip', flip_prob=0.5, direction='horizontal'),
        dict(type='MyTransform'),	# 这一行对应我们自己编写的数据处理类
        dict(type='Normalize', **img_norm_cfg),
        dict(type='ImageToTensor', keys=['img']),	# 将keys对应于img,即对img作ImageToTensor
        dict(type='ToTensor', keys=['gt_label']),
        dict(type='Collect', keys=['img', 'gt_label'])
    ]
    

以上使用步骤中,需要注意的点:

  • 在使用的时候,同样要把我们自定义的pipeline给注册到对应的registy中

  • 在配置文件中,这一行代码有什么用呢?dict(type=‘MyTransform’)

  • Mytransform中的__call__方法中,具体是怎么进行变换操作的?在demo中,是通过对应的build函数,根据我自己写的config函数,来创建出pipeline实例,然后通过这个实例对数据进行一个transforms,如下:

    # 具体进行数据操作的py文件
    test_pipeline = Compose(cfg.data.test.pipeline)
    data = test_pipeline(data)
    data = collate([data], samples_per_gpu=1)
    
    # compose.py
    from collections.abc import Sequence
    
    from mmcv.utils import build_from_cfg
    
    from ..builder import PIPELINES
    
    @PIPELINES.register_module()
    class Compose(object):
        """Compose a data pipeline with a sequence of transforms.
    
        Args:
            transforms (list[dict | callable]):
                Either config dicts of transforms or transform objects.
        """
    
        def __init__(self, transforms):
            assert isinstance(transforms, Sequence)
            self.transforms = []
            for transform in transforms:
                if isinstance(transform, dict):
                    transform = build_from_cfg(transform, PIPELINES)
                    self.transforms.append(transform)
                elif callable(transform):
                    self.transforms.append(transform)
                else:
                    raise TypeError('transform must be callable or a dict, but got'
                                    f' {type(transform)}')
    
        def __call__(self, data):
            for t in self.transforms:
                data = t(data)
                if data is None:
                    return None
            return data
    
        def __repr__(self):
            format_string = self.__class__.__name__ + '('
            for t in self.transforms:
                format_string += f'\n    {t}'
            format_string += '\n)'
            return format_string
    transform = build_from_cfg(transform, PIPELINES)
    

(3)流水线可视化

设计好数据流水线后,可以使用可视化工具查看具体的效果。

4 如何增加新模块(new module)

开发新组件

我们基本上将模型组件分为三个部分:

  • 主干网络backbone:通常是一个特征提取网络,例如ResNet、MobileNet
  • 颈部neck:用于连接主干网络和头部的组件,例如GlobalAveragePooling
  • 头部head:用于执行特定任务的组件,例如分类和回归

添加新的主干网络

在这里,以新建Resnet_CIFAR为例,展示了如何开发一个新的主干网络组件。

Resnet_CIFAR针对于CIFAR32x32的图像输入,将Resnet中kernel_size=7,stride=2的设置替换为kernel_size=3,stride=1,并移除了stem层,及之后的MaxPooling,以避免传递过小的特征图到残差块中。【因为CIFAR的输入图像尺度本来就只有32x32,所以要改小kervel_size和stride,以及去掉MaxPooling,防止通过卷积后的特征图太小了。同样因为输入尺寸太小,所以取消掉resnet中的stem层,因为stem层的主要作用就是降采样,所以这里没必要降采样降得那么厉害,这里补充一个知识点:一个7x7卷积核的感受野=三个3x3卷积核的感受野】

综上,我们新建的这个Resnet_CIFAR模型,需要继承自Resnet,并且修改stem层。具体步骤如下:

  • 创建一个新文件mmcls/models/backbones/resnet_cifar.py【以下代码空缺的,就去看基类源码】

    import torch.nn as nn
    from ..builder import BACKBONES	# backbone对应的registry
    from .resnet import ResNet
    
    @BACKBONES.register_module()	# 注册进主干网络对应的registry第一步
    class ResNet_CIFAR(ResNet):
        """ResNet backbone for CIFAR.
    
        (对这个主干网络的简短描述)
    
        Args:
            depth(int): Network depth, from {18, 34, 50, 101, 152}.
            ...
            (参数文档)
        """
        
        def __init__(self, depth, deep_stem=False, **kwargs):
            # 调用基类ResNet的初始化函数
            # super() 函数是用于调用父类(超类)的一个方法。
            # Python3 可以使用直接使用 super().xxx 代替 super(Class, self).xxx,xxx为父类方法
            super(ResNet_CIFAR, self).__init__(depth, deep_stem=deep_stem, **kwargs)
            # 其他的特殊的初始化流程
            # 我的这个网络不支持deep_stem,因为不需要降采样,所以要确保self.deep_stem应该要为false
            assert not self.deep_stem, 'ResNet_CIFAR do not support deep_stem'
    	
        def _make_stem_layer(self, in_channels, base_channels):
            # 重载基类的方法,以实现对网络结构的修改
            # build_conv_layer、build_norm_layer等函数的定义见基类代码
            self.conv1 = build_conv_layer(
            	self.conv_cfg,
            	in_channels,
                base_channels,
                kernel_size=3,
                stride=1,
                padding=1,
                bias=False
            )
            # norm1是@property修饰的类内方法,用来读取norm1_name的属性值
            self.norm1_name, norm1 = build_norm_layer(
            	self.norm_cfg, base_channels, postfix=1
            )
            self.add_module(self.norm1_name, norm1)
            self.relu = nn.ReLu(inplace=True)
            
        def forward(self, x):  # 需要返回一个元组
            pass  # 此处省略了网络的前向实现
    
        def init_weights(self, pretrained=None):
            pass  # 如果有必要的话,重载基类 ResNet 的参数初始化函数
    
        def train(self, mode=True):
            pass  # 如果有必要的话,重载基类 ResNet 的训练状态函数
    

    注意,forward函数的返回值是个Tuple

  • mmcls/models/backbones/__init__.py中导入新模块【注册第二步】

    ...
    from .resnet_cifar import ResNet_CIFAR
    
    __all__ = [
        ..., 'ResNet_CIFAR'
    ]
    
  • 在配置文件中,使用新的主干网络

  • model = dict(
        ...
        backbone=dict(
            type='ResNet_CIFAR',
            depth=18,
            other_arg=xxx),
        ...
    

添加新的颈部组件(neck)

neck的核心作用大致有两个:

  • neck进行特征融合,将backbone输出的不同尺度的特征图进行融合,即叠加了不同尺寸感受野的信息,可以耦合更丰富的特征;
  • neck决定了head的数量,因为neck提供了多尺度学习,不同尺度的目标被分配到不同的head进行学习。

这里我们以 GlobalAveragePooling 为例。这是一个非常简单的颈部组件,没有任何参数。

要添加新的neck组件,我们主要需要实现forward函数,该函数对backbone的输出进行一些操作,并将结果送到头部。具体步骤如下:

  • 创建一个新文件mmcls/models/necks/gap.py

    import torch.nn as nn
    
    from ..builder import NECKS
    
    @NECKS.registry_module()	# 注册第一步
    class GlobalAveragePooling(nn.Module):
        def __init__(self):
            self.gap = nn.AdaptiveAvgPool2d((1, 1))
        
        def forward(self, inputs):
            # 简单起见,我们默认输入是一个张量【经过pipeline后,已经Tensor化了吧
            outs = self.gap(inputs)
            # Tensor的view()方法的作用相当于numpy中的reshape,重新定义Tensor的形状。
            outs = outs.view(inputs.size(0), -1)
            return outs
    
  • mmcls/models/necks/__init__.py中导入新模块【注册第二步】

    ...
    from .gap import GlobalAveragePooling
    
    __all__ = [
    	..., 'GlobalAveragePooling'
    ]
    
  • 修改配置文件,以使用新的neck

    model = dict(
        ...
    	neck=dict(type='GlobalAveragePooling')
    	...
    )
    

该例只是为了展示过程,具体的neck细节根据实际应用来确定

添加新的头部组件head

在这里,我们以LinearClsHead为例,说明如何开发新的head组件。

要添加一个新的head,基本上我们需要实现(重写?)forward_train函数,它接受来自neck或backbone的特征图作为输入,并基于真实标签计算。具体步骤如下:

  • 创建一个文件mmcls/models/heads/linear_head.py,我们实现的head组件可以继承Head基类,这样可以减少我们的代码量,更为简洁

    from ..builder import HEADS
    from .cls_head import ClsHead
    
    @HEADS.registry_module()	# 注册第一步
    class LinearClsHead(ClsHead):
        def __init__(self,
                    num_classes,
                    in_channels,
                    loss=dict(type='CrossEntropyLoss', loss_weight=1.0),
                    topk=(1, )):
            super(LinearClsHead, self).__init__(loss=loss, topk=topk)
            self.in_channels = in_channels
            self.num_classes = num_classes
            
            if self.num_classes <= 0:
                raise ValueError(
                    f'num_classes={num_classes} must be a positive integer')
            
            self._init_layers()	
            
        def _init_layers(self):
            self.fc = nn.Linear(self.in_channels, self.num_classes)
            
        def init_weights(self):
            normal_init(self.fc, mean=0, std=0.01, bias=0)	# 该函数看源码
            
        def forward_train(self, x, gt_label):
            cls_score = self.fc(x)	# where is 权值初始化?
            losses = self.loss(cls_score, gt_label)
            return losses
    
  • mmcls/models/heads/__init__.py中导入这个模块【注册第二步】

    ...
    from .linear_head import LinearClsHead
    
    __all__ = [
    	..., 'LinearClsHead'
    ]
    
  • 修改配置文件,以使用新的head

    连同之前写的backboneneck完整的模型配置如下:

    model = dict(
    	type='ImageClassifier',	# type字段就是为了表明这个model具体是个什么,然后registry也根据这个type来找到对应的类进行实例化
        backbone=dict(
        	type='ResNet',
            depth=50,
            num_stages=4,
            out_indices=(3, ),
            style='Pytorch'
        ),
        neck=dict(type='GlobalAveragePooling'),
        head=dict(
        	type='LinearClsHead',
            num_classes=1000,
            in_channels=2048,
            loss=dict(type='CrossEntropyLoss', loss_weight=1.0)
            topk=(1, 5)
        )
    )
    

添加新的损失函数

要添加新的损失函数,我们主要需要在损失函数模块中实现forward函数。另外,利用装饰器weighted_loss可以方便地对每个元素的损失进行加权平均。

假设我们要模拟从另一个分类模型生成的概率分布,需要添加L1loss来实现该目的。具体步骤如下:

  • 创建一个新文件mmcls/models/losses/l1_loss.py

    import torch
    import torch.nn as nn
    
    from ..builder import LOSSES
    from .utils import weighted_loss
    
    @weighted_loss
    def l1_loss(pred, target):
        assert pred.size() == target.size() and target.numel() > 0
        loss = torch.abs(pred - target)
        return loss
    
    @LOSSES.registry_module()
    class L1Loss(nn.Module):
        def __init__(self, reduction='mean', loss_weight=1.0):
            super(L1Loss, self).__init__()
            self.reduction = reduction
            self.loss_weight = loss_weight
            
        def forward(self,
                   	pred,
                   	target,
                   	weight=None,
                   	avg_factor=None,
                   	reduction_override=None):
            assert reduction_override in (None, 'none', 'mean', 'sum')
            reduction = (
            	reduction_override if reduction_override else self.reduction	
            )
            loss = self.loss_weight * l1_loss(pred, target, weight, 							reduction=reduction, avg_factor=avg_factor)	# 注意l1_loss是被装饰过的
            return loss
    

    注意一下foward中调用l1_loss是,看似多传入了weight、reduction、avg_factor三个参数,但实质上这个装饰器的作用,是装饰器weighted_loss中的inner函数需要这些参数,所以可以传入,该inner函数如下:

    @functools.wraps(loss_func)
    def wrapper(pred,
                target,
                weight=None,
                reduction='mean',
                avg_factor=None,
                **kwargs):
        # get element-wise loss
        loss = loss_func(pred, target, **kwargs)
        loss = weight_reduce_loss(loss, weight, reduction, avg_factor)
        return loss
    
  • 在文件 mmcls/models/losses/__init__.py 中导入这个模块

    ...
    from .l1_loss import L1Loss, l1_loss
    
    __all__ = [
        ..., 'L1Loss', 'l1_loss'	# 注意这里也要把定义的l1_loss函数给导入进去,不然程序不知道
    ]
    

    注意一下:l1_loss也是我们自己定义的函数,所以为了让程序知道,我们也需要把它导入到对应的损失模块中,不然程序不认识该函数。

  • 修改配置文件中的 loss 字段以使用新的损失函数

    loss=dict(type='L1Loss', loss_weight=1.0)
    

总结

以上学习到了4个模块的创建,但其上的创建细节很简单,只是为了展示流程而这样写的代码。所以我在自己写demo的时候,流程参考这里,但细节部分还是需要去参考源码,了解作者是怎么写的。

5 如何自定义优化策略

该部分将介绍,如何在运行自定义模型时,来进行构造优化器、定制学习率及动量调整策略、梯度裁剪、梯度累计,以及用户自定义优化方法等。

使用Pytorch内置优化器

MMClassification支持Pytorch实现的所有优化器,仅需在配置文件中,指定“optimizer”字段

例如,如果要使用“SGD”,则修改如下:

optimizer = dict(type='SGD', lr=0.001, weight_decay=0.001)	# weight_decay为L2正则化中的权重衰减系数

要修改模型的学习率,只需要在优化器的配置中修改lr即可,就上列代码中的lr。要配置其他参数,可直接根据 PyTorch API 文档 进行。

【注意:type不是optimizer构造时的参数,而是Pytorch内置优化器的类名】

例如,如果想使用Adam,并设置参数为torch.optim.Adam(params, lr=0.001, betas=(0.9, 0.999), eps=1e-8, weight_decay=0, amsgrad=False),则需要进行如下更改:

optimizer = dict(type='SGD', lr=0.001, betas=(0.9, 0.999), eps=1e-8, weight_decay=0, amsgrad=False)

定制学习率调整策略

(1)定制学习率衰减策略

在深度学习研究中,广泛应用学习率衰减来提高网络的性能。要使用学习率衰减,可以在配置中设置lr_config字段。

比如在默认的ResNet网络训练中,我们使用*阶梯式**的学习率衰减策略,配置文件中为:

lr_config = dict(policy='step', step=[100, 150])

这样配置后,在训练过程中,程序会周期性地调用 MMCV 中的 StepLRHook 来进行学习率更新。

此外,MMClassification还支持其他学习率调整方法,如 CosineAnnealingPoly 等。详情可见 这里

  • ConsineAnnealing:

    lr_config = dict(policy='CosineAnnealing', min_lr_ratio=1e-5)
    
  • Poly:

    lr_config = dict(policy='poly', power=0.9, min_lr=1e-4, by_epoch=False)
    

注意:step、ConsineAnnealing、Poly等调整方法需要的参数见 这里这个py文件中。

(2)定制学习率预热策略(warmup)

为什么要进行warmup(预热,热身)呢?因为在训练的早期阶段,网络容易不稳定,而学习率的预热就是为了减少这种不稳定性【热身就是开始时不要那么激烈的运动,循序渐进】。通过预热,学习率将从一个很小的值逐步提高到预定值。

在MMClassification中,我们同样使用lr_config字段来配置学习率预热策略,主要的参数有如下几个:

  • warmup:学习率预热策略曲线类别,必须为’constant’‘linear’'exp’或者None其一,如果为None,则不使用学习率预热策略。
  • warmup_by_epoch:是否以epoch为单位进行预热
  • warmup_iters:预热的次数,当warmup_by_epoch=True时,单位为epoch【指的是预热的次数是以epoch为单位,就算该字段叫做warmup_iters】;当warmup_by_epoch=False时,单位为迭代次数iter。
  • warmup_ratio:预热的初始学习率lr = lr * warmup_ratio

举两个具体的例子:

  • 迭代次数线性预热:

    lr_config = dict(
    	policy='CosineAnnealing',
        by_epoch=False,
        min_lr_ratio=1e-2,
        warmup='linear',
        warmup_by_epoch=False,
        warmup_ratio=1e-3,
        warmup_iters=20 * 1252
    )
    
  • epoch指数预热:

    lr_config = dict(
        policy='CosineAnnealing',
        min_lr_ratio=1e-2,
        warmup='exp',
        warmup_iters=5,
        warmup_ratio=0.1,
        warmup_by_epoch=True)
    
配置完成后,可以使用 MMClassification 提供的 [学习率可视化工具](https://mmclassification.readthedocs.io/zh_CN/latest/tools/visualization.html#id3) 画出对应学习率调整曲线。

定制动量调整策略

MMClassification 支持动量调整器根据学习率修改模型的动量,从而使模型收敛更快。【啥是动量调整器,就是用来调整动量的吗?动量的意义:通过引入动量就可以加速我们的学习过程,可以在鞍点处继续前行,也可以逃离一些较小的局部最优区域,具体动量的数学含义自行上网搜索】

动量调整器通常与学习率调整器一起使用,例如,以下配置用于加速收敛
更多细节可参考 CyclicLrUpdater 和 CyclicMomentumUpdater。

lr_config = dict(
    policy='cyclic',
    target_ratio=(10, 1e-4),
    cyclic_times=1,
    step_ratio_up=0.4,
)
momentum_config = dict(
    policy='cyclic',
    target_ratio=(0.85 / 0.95, 1),
    cyclic_times=1,
    step_ratio_up=0.4,
)

参数化精细配置

一些模型可能具有一些特定于参数的设置,以进行优化,例如BathNorm层不添加权重衰减,或者对不同的网络层使用不同的学习率等等。【BatchNorm层的原理见[这里](BatchNorm的原理及代码实现 - 知乎 (zhihu.com))】

在MMClassification中,我们通过optimizerparamwise_cfg参数进行配置,可以参考MMCV。

  • 使用指定参数

    MMClassification提供了包括lr_multdecay_multbias_lr_multbias_decay_multnorm_decay_multdwconv_decay_multdcn_offset_lr_multbypass_duplicate 参数【这些参数的含义见[源码](mmcv/default_constructor.py at master · open-mmlab/mmcv · GitHub)】,指定相关所有的 baisnormdwconvdcnbypass 参数。例如令模型中所有的 BN 层不进行权重衰减:

    optimizer = dict(
    	type='SGD',
        lr=0.8,
        weight_decay=1e-4,
        paramwise_cfg=dict(norm_decay_mult=0.)
    )
    
  • 使用custom_keys对指定参数进行设置【custom意味“自定义”“】

    MMClassification可通过custom_keys指定不同的参数,使用不同的学习率或者权重衰减,例如对特定的权重参数不使用权重衰减:

    paramwise_cfg = dict(
    	custom_keys={
    		'backbone.cls_token': dict(decay_mult=0.),	# backbone.cls_token具体是啥,还得以后用到的时候,看看源码才知道,backbone应该是主干网络的实例,然后cls_token是该实例中的一个属性,表示cls_token这一层网络
            'backbone.pos_embed': dict(decay_mult=0.)
    	}
    )
    
    optimizer = dict(
    	type='SGD',
        lr=0.8,
        weight_decay=1e-4,
        paramwise_cfg=paramwise_cfg
    )
    

    再例如对backbone使用更小的学习率和衰减参数

    paramwise_cfg = dict(
    	custom_keys={
            'backbone': dict(lr_mult=0.1, decay_mult=0.9)
        }
    )
    
    optimizer = dict(
    	type='SGD',
        lr=0.8,
        weight_decay=1e-4,
        # backbone的'lr'和'weight_decay'分别为0.1*lr和0.9*weight_decay
        paramwise_cfg = paramwise_cfg
    )
    

梯度裁剪与梯度累计

除了 PyTorch 优化器的基本功能,MMCV还提供了一些对优化器的增强功能,例如梯度裁剪、梯度累计等,参考 MMCV。

梯度裁剪

**梯度裁剪有什么作用呢?**在训练过程中,损失函数可能接近于一些异常陡峭的区域,从而导致[梯度爆炸]((26条消息) 梯度消失和梯度爆炸原因及其解决方案_小T是我-CSDN博客_梯度爆炸)。而梯度裁剪可以帮助稳定训练过程,更多介绍见该页面。

目前支持在 optimizer_config 字段中添加 grad_clip 参数来进行梯度裁剪,更详细的参数可参考 PyTorch 文档。用例如下:

# norm_type: 使用的范数类型,此处使用范数2。
optimizer_config = dict(grad_clip=dict(max_norm=35, norm_type=2))

当使用继承,并修改基础配置方式时,如果基础配置中grad_clip=None,需要添加_delete_=True,即忽略掉optimizer_config域中的内容,用例如下:

_base_ = [./_base_/schedules/imagenet_bs256_coslr.py]

optimizer_config = dict(
	grad_clip=dict(max_norm=35, norm_type=2),
    _delete_=True,
    type="OptimizerHook"	# 当 type 为 'OptimizerHook',可以省略 type;其他情况下,此处必须指明 type='xxxOptimizerHook'。
)

梯度累计

**梯度累计有啥用?**计算资源缺乏时,每次训练批次的大小batch_size只能设置为较小的值,这可能会影响模型的性能。因此,可以用梯度累计来规避这个问题。用例如下:

data = dict(samples_per_gpu=64)	# batch_size=64
optimizer_config = dict(
    type="GradientCumulativeOptimizerHook",	# 这是一个hook
	cumulative_iters=4
)

该例表示训练时,每4个iter执行一次反向传播。由于此时单张GPU上的batch_size为64,使用了该梯度累计后,也就等价于单张GPU上一次迭代的batch_size为256,上述代码和下列代码等价:

data = dict(samples_per_gpu=256)
optimizer_config = dict(
	type="OptimizerHook"
)

【注意:当在 optimizer_config 中不指定优化器钩子类型(type)时,默认使用 OptimizerHook。】

用户自定义优化方法【该部分先不掌握】

在学术研究和工业实践中,可能需要使用 MMClassification 未实现的优化方法,可以通过以下方法添加。

本部分将修改 MMClassification 源码或者向 MMClassification 框架添加代码,初学者可跳过。

自定义优化器

1. 定义一个新的优化器

一个自定义的优化器可根据如下规则进行定制

假设我们想添加一个名为 MyOptimzer 的优化器,其拥有参数 a, bc
可以创建一个名为 mmcls/core/optimizer 的文件夹,并在目录下的一个文件,如 mmcls/core/optimizer/my_optimizer.py 中实现该自定义优化器:

from mmcv.runner import OPTIMIZERS
from torch.optim import Optimizer


@OPTIMIZERS.register_module()
class MyOptimizer(Optimizer):

    def __init__(self, a, b, c):

2. 注册优化器

要注册上面定义的上述模块,首先需要将此模块导入到主命名空间中。有两种方法可以实现它。

  • 修改 mmcls/core/optimizer/__init__.py,将其导入至 optimizer 包;再修改 mmcls/core/__init__.py 以导入 optimizer

    创建 mmcls/core/optimizer/__init__.py 文件。
    新定义的模块应导入到 mmcls/core/optimizer/__init__.py 中,以便注册器能找到新模块并将其添加:

# 在 mmcls/core/optimizer/__init__.py 中
from .my_optimizer import MyOptimizer # MyOptimizer 是我们自定义的优化器的名字

__all__ = ['MyOptimizer']
# 在 mmcls/core/__init__.py 中
...
from .optimizer import *  # noqa: F401, F403
  • 在配置中使用 custom_imports 手动导入
custom_imports = dict(imports=['mmcls.core.optimizer.my_optimizer'], allow_failed_imports=False)

mmcls.core.optimizer.my_optimizer 模块将会在程序开始阶段被导入,MyOptimizer 类会随之自动被注册。
注意,只有包含 MyOptmizer 类的包会被导入。mmcls.core.optimizer.my_optimizer.MyOptimizer 不会 被直接导入。

3. 在配置文件中指定优化器

之后,用户便可在配置文件的 optimizer 域中使用 MyOptimizer
在配置中,优化器由 “optimizer” 字段定义,如下所示:

optimizer = dict(type='SGD', lr=0.02, momentum=0.9, weight_decay=0.0001)

要使用自定义的优化器,可以将该字段更改为

optimizer = dict(type='MyOptimizer', a=a_value, b=b_value, c=c_value)

自定义优化器构造器

某些模型可能具有一些特定于参数的设置以进行优化,例如 BatchNorm 层的权重衰减。

虽然我们的 DefaultOptimizerConstructor 已经提供了这些强大的功能,但可能仍然无法覆盖需求。
此时我们可以通过自定义优化器构造函数来进行其他细粒度的参数调整。

from mmcv.runner.optimizer import OPTIMIZER_BUILDERS


@OPTIMIZER_BUILDERS.register_module()
class MyOptimizerConstructor:

    def __init__(self, optimizer_cfg, paramwise_cfg=None):
        pass

    def __call__(self, model):
        ...    # 在这里实现自己的优化器构造器。
        return my_optimizer

这里是我们默认的优化器构造器的实现,可以作为新优化器构造器实现的模板。

6 如何自定义模型运行参数

在本部分中,将包含如何在运行自定义模型时,进行自定义工作流钩子的方法。

定制工作流workflow

啥是工作流呀?工作流是一个形如(任务名,周期数)的列表【下面这个代码就是工作流的范例】,用于指定运行顺序周期。这里“周期数”的单位由执行器(runner)的类型来决定。

比如在MMClassification中,我们默认使用基于Epoch的执行器EpochBasedRunner,那么**“周期数”指的就是对应的任务要执行多少个周期,每个周期为n个epochs,这个n在我们配置runner的时候给出**。通常,我们只希望执行训练任务,那么只需要使用以下设置:

workflow = [('train', 1)]	# [(任务名1, 周期数1), (任务名2, 周期数2), ...]

有时,我们可能希望在训练过程中穿插 检查模型在验证集上的一些指标(例如损失、准确度等),那么在这种情况下,可以将工作流设置为:

workflow = [('train', 1), ('val', 1)]

这样设置的话,程序会一轮训练,一轮验证地反复执行。

需要注意地是,默认情况下,并不推荐使用这种方式来验证模型,而是推荐在训练中使用EvalHook这个钩子来进行模型验证,以下有几点要注意

  • 在验证周期时不会更新模型参数。
  • 配置文件内的关键词 max_epochs 控制训练时期数,并且不会影响验证工作流程。
  • 工作流 [('train', 1), ('val', 1)][('train', 1)] 不会改变 EvalHook 的行为。因为 EvalHookafter_train_epoch 调用,而验证工作流只会影响 after_val_epoch 调用的钩子。因此,[('train', 1), ('val', 1)][('train', 1)] 的区别在于,runner 在完成每一轮训练后,会计算验证集上的损失。

以上,例如 after_train_epoch这些是啥我还不太明白,不过接下来要学习钩子,相信学完之后就明白了。

钩子Hook

钩子机制在OpenMMlab开源算法库中的应用非常广泛,结合执行器runner可以对训练过程的整个生命周期进行管理,也就是说,Hook是要注册进runner来完成相应的任务,可以通过相关文章进一步理解钩子。【这个文章很好,能很好地理解钩子,下面的笔记有些就参考了该文章。】

钩子只有在被注册了才起作用,目前钩子主要分为两类:

  • 默认训练钩子

    默认训练钩子由runner默认注册,一般为一些基础性功能的钩子,并且已有确定的优先级,一般不需要修改优先级【优先级is what?】。

  • 自定义钩子

    自定义钩子通过custom_hooks注册,一般为一些增强型功能的钩子,需要在配置文件中指定优先级,不指定该钩子的优先级,将默认被设定为’NORMAL‘。

优先级列表

Level Value
HIGHEST 0
VERY_HIGH 10
HIGH 30
ABOVE_NORMAL 40
NORMAL(default) 50
BELOW_NORMAL 60
LOW 70
VERY_LOW 90
LOWEST 100

优先级确定钩子的执行顺序,每次训练前,日志会打印出各个阶段钩子的执行顺序,方便调试。

【这里解释以下什么是Hook的优先级:hook是分优先级插入到hooks的list中,因为Hook调用的时候,是遍历这个list,按顺序调用其中的hook,优先级越高的放在list的前面,就会被更快地调用】

默认训练钩子

有一些常见的钩子未通过custom_hooks注册,但会在Runner中默认注册,即我们不用注册它们即可使用,它们是:

Hooks Priority
LrUpdaterHook VERY_HIGH (10)
MomentumUpdaterHook HIGH (30)
OptimizerHook ABOVE_NORMAL (40)
CheckpointHook NORMAL (50)
IterTimerHook LOW (70)
EvalHook LOW (70)
LoggerHook(s) VERY_LOW (90)

OptimizerHookMomentumUpdaterHookLrUpdaterHook 在 优化策略 部分进行了介绍,

IterTimerHook 用于记录所用时间,目前不支持修改;

下面将学习如何去使用CheckpointHookLoggerHooksEvalHook

(1)权重文件钩子(CheckpointHook)

MMCV的runner在配置文件中使用checkpoint_config来初始化CheckpointHook【这是其的源代码】这个Hook。下面这个例子只是一个简单的示例:

checkpoint_config = dict(interval=1)

用户可以设置max_keep_ckpts来仅保存少量模型权重文件,或者通过save_optimizer决定是否存储优化器的状态字典,可以看一下CheckpointHook这个类初始化时,可添加的参数,这些都可用于CheckpointHook的初始化:

@HOOKS.registry_module()
class CheckpointHook(Hook):		# 继承hook基类
 def __init__(self,
                 interval=-1,
                 by_epoch=True,
                 save_optimizer=True,
                 out_dir=None,
                 max_keep_ckpts=-1,
                 save_last=True,
                 sync_buffer=False,
                 file_client_args=None,
                 **kwargs):
        self.interval = interval
        self.by_epoch = by_epoch
        self.save_optimizer = save_optimizer
        self.out_dir = out_dir
        self.max_keep_ckpts = max_keep_ckpts
        self.save_last = save_last
        self.args = kwargs
        self.sync_buffer = sync_buffer
        self.file_client_args = file_client_args
 pass

更多细节可参考 这里以及源代码。

(2)日志钩子(LoggerHooks)

可以看到,LoggerHooks后面接了个’s’,这是因为其可包含多个记录器钩子。配置文件中使用log_config来进行日志记录器的配置,其可包装多个记录器钩子,并可以设置间隔。

目前,MMCV支持TextLoggerHookWandbLoggerHookMlflowLoggerHookTensorboardLoggerHook,更多细节,[请看LoggerHooks源码](mmcv/mmcv/runner/hooks/logger at master · open-mmlab/mmcv · GitHub)以及细节介绍。下面是一个配置范例:

log_config = dict(
	interval=50,	# 该interval的单位根据runner的设置而定
    hooks=[			# 这里进行日志记录,要调用的hook有两个,按顺序调用
        dict(type='TextLoggerHook'),
        dict(type='TensorboardLoggerHook')
    ]
)
(3)验证钩子(EvalHook)

配置文件中的evaluation字段用于初始化 EvalHook【注意这里的字段没有后缀_config】【看一下EvalHook的初始化函数,来了解各参数的含义】

EvalHook有一些保留参数【应该是初始化所需的参数,非**kwargs参数】,如intervalsave_beststart等。其他的参数【应该是**kwargs参数】,如metrics将被传递给dataset.evaluate()。下面是一个简单的配置例子:

evaluation = dict(interval=1, metric='accuracy', metric_options={'topk': (1, )})

我们可以通过参数save_best保存取得最好验证结果时的模型权重,配置如下:

# "auto"表示自动选择指标来进行模型的比较,也可以指定一个特定的key,比如"accuracy_top-1"
evaluation = dict(interval=1, save_best=True, metric='accuracy', metric_options={'topk': (1, )})	# metric和metric_options在这里用来指定用于模型比较的指标

在跑一些大型实验时,可以通过修改参数start,来跳过训练靠前的epoch的验证步骤,以节约时间,以下这个例子表示,在第200轮之前,只执行训练流程,不执行验证;从第200轮开始,在每一轮训练之后都要进行验证,配置如下:

evaluation = dict(interval=1, start=200, metric='accuracy', metric_options={'topk': (1, )})

注意

  • 在 MMClassification 的默认配置文件中,evaluation 字段一般被放在 datasets 基础配置文件中,除了基础配置文件以外,我们自定义的配置文件中,evaluation字段也和dataset配置部分在一起,具体可见该学习笔记第一章配置文件中的数据部分以及[源码](D:\研究生科研\MMlab\mmclassification\configs)。

使用内置钩子

一些钩子已在MMCV和MMClassification中实现【已经注册好了】,具体的作用见源码,mmcv/runner/hooks/mmcls/core/utils/下保存了mmcv实现的钩子,在对应目录下的__init__.py中可以看到可以直接使用的Hook:

  • EMAHook
  • SyncBuffersHook
  • EmptyCacheHook
  • ProfilerHook

我们可以直接修改配置文件,来使用这些钩子,例如如下:

custom_hooks = [
	dict(type='MMCVHook', a=a_value, b=b_value, priority='NORMAL')
]

例如使用 EMAHook,进行一次 EMA 的间隔是100个迭代:

custom_hooks = [
    dict(type='EMAHook', interval=100, priority='HIGH')
]

注意

这些内置钩子,实质上也是custom的,所以要添加在custom_hooks中,通过custom_hooks来注册。

自定义钩子(了解钩子的生成和使用)

1.创建一个新钩子

在 MMClassification 中创建一个新钩子,并在训练中使用它的示例:

  • 实现新钩子,并完成注册第一步:

    # 在mmcv/runner/__init__.py中查看mmcv.runner模块中可导入的内容有哪些
    from mmcv.runner import HOOKS, Hook	# HOOKS是钩子对应的Registry,Hook为钩子的基类,用于继承
    
    @HOOK.register_module()	# 注册第一步
    class MyHook(Hook):
        def __init__(self, a, b):
            pass
        
        def before_run(self, runner):	# 输入是runner执行器
            pass
        
        def after_run(self, runner):
            pass
        
        def before_epoch(self, runner):
            pass
        
        def after_epoch(self, runner):
            pass
        
        def before_iter(self, runner):
            pass
        
        def after_iter(self, runner):
            pass
    

    根据钩子的功能,用户需要指定钩子在训练的每个阶段将要执行的操作,比如 before_runafter_runbefore_epochafter_epochbefore_iterafter_iter。注意,各阶段操作的输入是runner执行器。【比如该hook只在before_run中运行,那么只用定义before_run这个方法】

2.注册新钩子

之后,需要导入MyHook,假设该文件在 mmcls/core/utils/my_hook.py,有两种办法导入它:

  • 【常用】修改mmcls/core/utils/__init__.py进行导入,以便注册器能够找到并添加新模块,使得我们可以用mmcls.core.utils.MyHook来调用我们自己的Hook:

    from .my_hook import MyHook
    
    __all__ = ['MyHook']
    
  • 【教程推荐】使用配置文件中的custom_imports字段【该字段可以导入新构建的模块,无论模型、Hook等都可以】手动导入:

    custom_imports = dict(imports=['mmcls.core.utils.my_hook'],
                         allow_failed_imports=False)
    

3.修改配置

custom_hooks = [
    dict(type='MyHook', a=a_value, b=b_value)
]

还可通过 priority 参数设置钩子优先级,如下所示:

custom_hooks = [
    dict(type='MyHook', a=a_value, b=b_value, priority='NORMAL')
]

默认情况下,在注册过程中,钩子的优先级设置为“NORMAL”。

常见问题

config中的resume_from, load_from,init_cfg.Pretrained 区别

  • load_from :仅仅加载模型权重,但周期数从 0 开始计数,常被用于微调模型,主要用于加载预训练或者训练好的模型;
  • resume_from :加载模型参数和优化器状态,并且保留检查点所在的周期数,常被用于恢复意外被中断的训练。不仅导入模型权重,还会导入优化器信息,当前轮次(epoch)信息,主要用于从断点继续训练。
  • init_cfg.Pretrained :在权重初始化期间加载权重,您可以指定要加载的模块。 这通常在微调模型时使用,请参阅教程 2:如何微调模型

以上这些函数在apis/train.py中出现

7 如何微调模型

该教程提供了如何将 Model Zoo 中提供的预训练模型用于其他数据集,已获得更好的效果。

在新数据集上微调模型分为两步:

  • 按照之前的第二章内容,添加对新数据集的支持
  • 按照本教程的内容修改配置文件

假设我们现在有一个在ImageNet-2021数据集上训练好的ResNet-50模型,并且希望在CIFAR-10数据集上进行模型微调【因为模型在另一个数据集上已经训练好了,所以我们把这个模型在新数据集上的训练叫做微调】,我们需要修改配置文件中的五个部分。

(1)继承基础配置

首先,创建一个新的配置文件configs/tutorial/resnet50_finetune_cifar.py来保存我们的配置,当然,文件名可以自己自由设定【/tutorial目录即教程目录,我们实际使用的时候,单独在/configs下创建出一个配置文件目录即可】

为了重用不同配置之间的通用部分,我们支持从多个现有配置中继承配置。

  • 要微调【finetune】ResNet-50模型,新的配置文件需要继承/configs/_base_/models/resnet50.py文件,来搭建我们自己模型的基本架构。为了使用CIFAR10数据集,
  • 新的配置文件可以直接继承/configs/_base_/datasets/cifar10.py,这样我们就可以不用再配置数据部分了。
  • 而为了保留运行相关设置,比如训练调整期,新的配置文件需要继承/configs/_base_/default_runtime.py

要继承以上三个文件,只需要在我们的配置文件的开头这样写:

_base_ = [
    '../_base_/models/resnet50.py',
    '../_base_/datasets/cifar10.py',
    '../_base_/default_runtime.py'
]

除此之外,你也可以不使用继承,直接编写完整的配置文件,例如
configs/lenet/lenet5_mnist.py

(2)修改模型

在进行模型微调时,通常希望在backbone部分加载预训练模型,再用我们的数据集训练一个新的分类头head,当然,backbone部分也会根据新数据集得到训练。

为了在backbone部分加载预训练模型,我们需要修改backbone的初始化设置,使用Pretrained类型的初始化函数。另外,在初始化设置中,我们使用prefix='backbone'来告诉初始化函数要移除权值文件中键值名称的前缀,比如把backbone.conv1变成conv1。为了方便起见,这里使用一个在线的权重文件李建安,它会在训练前自动下载对应的文件,当然也可以提前下载这个模型文件,然后在这里使用本地路径即可。

接下来,新的配置文件需要按照新数据集的类别数目来修改head的配置。在这里只需要修改num_classes即可。新的配置文件如下:

model = dict(
	backbone=dict(
		init_cfg=dict(
			type='Pretrained',          checkpoint='https://download.openmmlab.com/mmclassification/v0/resnet/resnet50_8xb32_in1k_20210831-ea4938fc.pth',
            prefix='backbone'
		)
	),
    head=dict(num_classes=10)	
)

注意:这里我们只需要设定我们想要修改的部分设置,其他的配置将会自动从我们继承的父配置文件中获取,就像之前说的那样。

【head中还有关于loss的,里面的num_classes是否也要改呢?因为原配置文件中,该参数设置的是1000,那么对于cifar10来说,应该是要改的】

另外,有时我们在微调时会希望冻结backbone前面几层的参数,这么做有助于在后续训练中,保持网络从预训练权重中获得的提取低阶特征的能力。在MMClassification中,这一功能可以通过简单的一个frozen_stages参数来实现,比如我们需要冻结前两层网络的参数,只需要在上面的配置中添加一行代码:

model = dict(
	backbone=dict(
		frozen_stages=2,	# 冻结前两层网络参数
        init_cfg=dict(
			type='Pretrained',          checkpoint='https://download.openmmlab.com/mmclassification/v0/resnet/resnet50_8xb32_in1k_20210831-ea4938fc.pth',
            prefix='backbone'
		)
	),
    head=dict(num_classes=10)
)

注意:目前不是所有的网络都支持frozen_stages参数,在使用之前,先检查一下文档,来查看我所要使用的backbone是否支持。

(3)修改数据集

当针对一个新的数据集进行微调时,通常都需要修改一些数据集相关的配置。比如这里,我们就需要把CIFAR-10数据集中的图像大小从32缩放到224来配合ImageNet上预训练模型的输入。这一需要可以通过修改数据集的预处理pipeline来实现。下面做个示例:

img_norm_cfg = dict(
    mean=[125.307, 122.961, 113.8575],
    std=[51.5865, 50.847, 51.255],
    to_rgb=False
)

train_pipeline = [
    dict(type='RandomCrop', size=32, padding=4),
    dict(type='RandomFlip', flip_prob=0.5, direction='horizontal'),
    dict(type='Resize', size=224),	# 在这里进行缩放
    dict(type='Normalize', **img_norm_cfg),
    dict(type='ImageToTensor', keys=['img']),
    dict(type='ToTensor', keys=['gt_label']),
    dict(type='Collect', keys=['img', 'gt_label'])
]

test_pipeline = [	# 测试部分,就不需要裁剪和反转了,直接缩放
    dict(type='Resize', size=224),
    dict(type='Normalize', **img_norm_cfg),
    dict(type='ImageToTensor', keys=['img']),
    dict(type='Collect', keys=['img']),
]

data = dict(	# 这里为了做范例,只进行了pipeline的配置
	train=dict(pipeline=train_pipeline),
    val=dict(pipeline=test_pipeline),
    test=dict(pipeline=test_pipeline)
)

(4)修改训练策略设置

用于微调任务的超参数,与默认配置不同,通常只需要较小的学习率和较少的训练时间。

# 用于batch_size=128的优化器学习率
optimizer = dict(type='SGD', lr=0.01, momentum=0.9, weight_decay=0.0001)
optimizer_config = dict(grad_clip=None)
# 学习率衰减策略
lr_config = dict(policy='step', step=[15])
runner = dict(type='EpochBasedRunner', max_epochs=200)
log_config = dict(interval=100)

注意:我们现在可使用的runner只有两个:EpochBasedRunnerIterBasedRunner

(5)开始训练

现在,我们完成了用于微调的配置文件,完整的内容如下:

_base_ = [
    '../_base_/models/resnet50.py',
    '../_base_/datasets/cifar10_bs16.py',
    '../_base_/default_runtime.py'
]

# 模型设置
model = dict(
	backbone=dict(
    	frozen_stages=2,
        init_cfg=dict(
        	type='Pretrained',
    checkpoint='https://download.openmmlab.com/mmclassification/v0/resnet/resnet50_8xb32_in1k_20210831-ea4938fc.pth',
            prefix='backbone'
        )
    ),
    head=dict(num_classes=10)
)

# 数据集设置
img_norm_cfg = dict(
    mean=[125.307, 122.961, 113.8575],
    std=[51.5865, 50.847, 51.255],
    to_rgb=False,
)
train_pipeline = [
    dict(type='RandomCrop', size=32, padding=4),
    dict(type='RandomFlip', flip_prob=0.5, direction='horizontal'),
    dict(type='Resize', size=224),
    dict(type='Normalize', **img_norm_cfg),
    dict(type='ImageToTensor', keys=['img']),
    dict(type='ToTensor', keys=['gt_label']),
    dict(type='Collect', keys=['img', 'gt_label']),
]
test_pipeline = [
    dict(type='Resize', size=224),
    dict(type='Normalize', **img_norm_cfg),
    dict(type='ImageToTensor', keys=['img']),
    dict(type='Collect', keys=['img']),
]
data = dict(
    train=dict(pipeline=train_pipeline),
    val=dict(pipeline=test_pipeline),
    test=dict(pipeline=test_pipeline),
)

# 训练策略设置
# 用于批大小为 128 的优化器学习率
optimizer = dict(type='SGD', lr=0.01, momentum=0.9, weight_decay=0.0001)
optimizer_config = dict(grad_clip=None)
# 学习率衰减策略
lr_config = dict(policy='step', step=[15])
runner = dict(type='EpochBasedRunner', max_epochs=200)
log_config = dict(interval=100)

接下来,我们使用一台 8 张 GPU 的电脑来训练我们的模型,指令如下:

bash tools/dist_train.sh configs/tutorial/resnet50_finetune_cifar.py 8

当然,我们也可以使用单张 GPU 来进行训练,使用如下命令:

python tools/train.py configs/tutorial/resnet50_finetune_cifar.py

但是如果我们使用单张 GPU 进行训练的话,需要在数据集设置部分作如下修改:

data = dict(
    samples_per_gpu=128,
    train=dict(pipeline=train_pipeline),
    val=dict(pipeline=test_pipeline),
    test=dict(pipeline=test_pipeline),
)

这是因为我们的训练策略是针对批次大小(batch size)为 128 设置的。在父配置文件中,
设置了 samples_per_gpu=16,如果使用 8 张 GPU,总的批次大小就是 128。而如果使
用单张 GPU,就必须手动修改 samples_per_gpu=128 来匹配训练策略。

OK,现在MMClassification的教程都学好了,开始重新看那个demo,然后自己写一个!

注意:MMClassification的api和tool使用见mmclassification\docs_zh-CN\tutorials\MMClassification_python_cn.ipynbmmclassification\docs_zh-CN\tutorials\MMClassification_tools_cn.ipynb

8 模型推理和微调的Shell运行指令

当我们配置好后,就可以开始运行代码,进行训练和测试,具体的shell命令见该文档

综合来说,我们如果要进行模型推理和微调,就是用./tools/test.py./tools/train.py,这两个文件也是调用了第九章中的一些api进行实现。

9 mmcls的一些apis使用

该部分主要介绍两点:

  • 进行模型推理
  • 进行模型微调

模型推理

首先我们要构建模型,这时候,要导入mmcls.apis模块包中的三个api:

import mmcv
from mmcls.apis import inference_model, init_model, show_result_pyplot

# 指明设备
device = 'cuda:0' # 使用第一张gpu卡
# device = 'cpu'

# 通过配置文件和权重文件来初始化模型
model = init_model(config_file, checkpoint_file, device=device)

接着,使用初始化好的模型进行推理:

result = inference_model(model, img)	# img为图像文件

最后,展示单张图片的推理结果:

show_result_pyplot(model, img, result)

以上就是这三个api的简单使用,具体见api的源码。

模型微调

模型微调的基本步骤如下:

  • 准备新数据集,并满足MMClassification要求,具体见本笔记的第二章
  • 根据新的数据集来修改训练配置。具体见本笔记的第七章
  • 进行训练和验证。

前两点我们已经在前面学过了,这里主要讲一下第三点。

模型微调也就是训练,因此基于我们修改的配置文件,开始对我们的数据集进行模型微调,具体代码如下【下面的代码也是简洁化了的,比如没给出cfg的定义之类的,下面只是一个代码框架而已】:

import time
import mmcv
import os.path as osp

from mmcls.apis import train_model
from mmcls.datasets import build_dataset
from mmcls.models import build_classifier

# 创建工作目录
mmcv.mkdir_or_exist(osp.abspath(cfg.work_dir))	# 根据配置文件中指定的工作目录路径(得到其绝对路径),创建出该工作目录

# 创建分类器
model = build_classifier(cfg.model)
model.init_weights()

# 创建数据集
datasets = [build_dataset(cfg.data.train)]	

# 添加类别属性以方便可视化
model.CLASSES = datasets[0].CLASSES

# 开始微调
train_model(
	model,
    datasets,
    cfg,
    distributed=False,
    validate=True,
    timestamp=time.strftime('%Y%m%d_%H%M%S', time.localtime()),
    meta=dict()
)

注意

  • 注意,代码中的cfg已经是我们的配置文件类的实例,也就是将我们写的配置文件给实例化成了python代码,像modeldata等字段已经成了config的成员属性。

  • 创建分类器build_classifier的流程是:先实例化出CLASSIFIERS这个Registry,因为该Registry包含了字符串到模块类的映射,即直接用我们的配置文件,通过Registry实例化出我们的分类器模型,如下:

    CLASSIFIERS.build(cfg)
    
  • cfg.data.train中的data.train,是访问data字典中的train这个key,这是可以像模块一样访问的

  • meta参数记录一些重要的信息,例如environment info以及seed等等

结束训练后,就可以将训练好的模型,进行推理,得到推理结果

img = mmcv.imread('data/cats_dogs_dataset/training_set/training_set/cats/cat.1.jpg')

result = inference_model(model, img)

show_result_pyplot(model, img, result)

注意

  • 教程中说,训练结束后,所有的输出(日志文件和模型权重文件),会被保存到工作目录work_dir中,但是我还没在源码中看到这个,可以去看看

u=16,如果使用 8 张 GPU,总的批次大小就是 128。而如果使 用单张 GPU,就必须手动修改samples_per_gpu=128` 来匹配训练策略。

OK,现在MMClassification的教程都学好了,开始重新看那个demo,然后自己写一个!

注意:MMClassification的api和tool使用见mmclassification\docs_zh-CN\tutorials\MMClassification_python_cn.ipynbmmclassification\docs_zh-CN\tutorials\MMClassification_tools_cn.ipynb

8 模型推理和微调的Shell运行指令

当我们配置好后,就可以开始运行代码,进行训练和测试,具体的shell命令见该文档

综合来说,我们如果要进行模型推理和微调,就是用./tools/test.py./tools/train.py,这两个文件也是调用了第九章中的一些api进行实现。

9 mmcls的一些apis使用

该部分主要介绍两点:

  • 进行模型推理
  • 进行模型微调

模型推理

首先我们要构建模型,这时候,要导入mmcls.apis模块包中的三个api:

import mmcv
from mmcls.apis import inference_model, init_model, show_result_pyplot

# 指明设备
device = 'cuda:0' # 使用第一张gpu卡
# device = 'cpu'

# 通过配置文件和权重文件来初始化模型
model = init_model(config_file, checkpoint_file, device=device)

接着,使用初始化好的模型进行推理:

result = inference_model(model, img)	# img为图像文件

最后,展示单张图片的推理结果:

show_result_pyplot(model, img, result)

以上就是这三个api的简单使用,具体见api的源码。

模型微调

模型微调的基本步骤如下:

  • 准备新数据集,并满足MMClassification要求,具体见本笔记的第二章
  • 根据新的数据集来修改训练配置。具体见本笔记的第七章
  • 进行训练和验证。

前两点我们已经在前面学过了,这里主要讲一下第三点。

模型微调也就是训练,因此基于我们修改的配置文件,开始对我们的数据集进行模型微调,具体代码如下【下面的代码也是简洁化了的,比如没给出cfg的定义之类的,下面只是一个代码框架而已】:

import time
import mmcv
import os.path as osp

from mmcls.apis import train_model
from mmcls.datasets import build_dataset
from mmcls.models import build_classifier

# 创建工作目录
mmcv.mkdir_or_exist(osp.abspath(cfg.work_dir))	# 根据配置文件中指定的工作目录路径(得到其绝对路径),创建出该工作目录

# 创建分类器
model = build_classifier(cfg.model)
model.init_weights()

# 创建数据集
datasets = [build_dataset(cfg.data.train)]	

# 添加类别属性以方便可视化
model.CLASSES = datasets[0].CLASSES

# 开始微调
train_model(
	model,
    datasets,
    cfg,
    distributed=False,
    validate=True,
    timestamp=time.strftime('%Y%m%d_%H%M%S', time.localtime()),
    meta=dict()
)

注意

  • 注意,代码中的cfg已经是我们的配置文件类的实例,也就是将我们写的配置文件给实例化成了python代码,像modeldata等字段已经成了config的成员属性。

  • 创建分类器build_classifier的流程是:先实例化出CLASSIFIERS这个Registry,因为该Registry包含了字符串到模块类的映射,即直接用我们的配置文件,通过Registry实例化出我们的分类器模型,如下:

    CLASSIFIERS.build(cfg)
    
  • cfg.data.train中的data.train,是访问data字典中的train这个key,这是可以像模块一样访问的

  • meta参数记录一些重要的信息,例如environment info以及seed等等

结束训练后,就可以将训练好的模型,进行推理,得到推理结果

img = mmcv.imread('data/cats_dogs_dataset/training_set/training_set/cats/cat.1.jpg')

result = inference_model(model, img)

show_result_pyplot(model, img, result)

注意

  • 教程中说,训练结束后,所有的输出(日志文件和模型权重文件),会被保存到工作目录work_dir中,但是我还没在源码中看到这个,可以去看看

你可能感兴趣的:(python,计算机视觉,分类)