使用Pytorch逐步搭建一个图像分类Project



  • 使用Pytorch逐步搭建一个图像分类Project

    [TOC]

    第一步:准备数据

    图像分类,首先需要准备好图像,在一个Project里面(本文路径都是相对于Project文件夹的相对路径),通常将数据存放在data/文件夹下,由于包含训练集,验证集和测试集,所以在data/文件夹下还包含三个子文件夹,分别为data/train_datadata/eval_datadata/test_data,因此,data/文件夹的布局如下。

    |--data
    |   |--train_data/
    |   |--eval_data/
    |   |--test_data/
    

    在训练数据,验证数据或测试数据文件夹下,都包含有许多子文件夹,每一个子文件夹都是一类图片,子文件夹名就是图片的标签,当然,有可能子文件夹名含有一些特定的前缀、后缀等等,这些都可以通过字符串操作去除,以下展示训练集的文件夹格式:

    |--data
    |   |--train_data
    |          |--n02085620-Chihuahua/
    |          |--n02085782-Japanese_spaniel/
    |          |--n02085936-Maltese_dog/
    |          |......
    

    其中,每一个子文件夹都包含一类图片,上述图片数据集基于Stanford Dogs数据集

    第二步:source文件夹

    source文件夹的目录树为

    |--source
    |    |--models/
    |    |--summary/
    |    |--dict/
    
    • models子文件夹用于保存训练过程中的临时模型或者验证集准确率最高的模型。
    • summary子文件夹用于保存tensorboardXtensorboard保存的summary文件。
    • dict子文件夹用于保存字典。

    具体使用将会在后面介绍。

    第三步:标签到类别的映射字典

    下一步就是得到标签到类别以及类别到标签的字典,这里使用Pythonpickle库。

    • 类别到标签的字典用于在训练时,根据图片类别(class)转化成相应的索引(index),从而生成one-hot向量(此乃后话)。
    • 标签到类别的字典用于在预测时,根据预测的最大值的索引(index)转化成相应的图片类别(class)。

    索引到类别(index_to_label)字典的格式生成如下

    {0: 'Chihuahua',
     1: 'Japanese_spaniel',
     2: 'Maltese_dog',
     3: 'Pekinese',
     4: 'Shih',
     ...
     118: 'dhole',
     119: 'African_hunting_dog'}
    

    类别到索引(name_to_index)字典格式生成如下

    {'Afghan_hound': 9,
     'African_hunting_dog': 119,
     'Airedale': 40,
     'American_Staffordshire_terrier': 29,
     'Appenzeller': 89,
     ...
     'toy_terrier': 7,
     'vizsla': 60,
     'whippet': 21,
     'wire': 37}
    

    第四步:预处理

    上述几个步骤已经将数据准备的很充分了,但是对于图片而言,往往准备好原始数据还是操之过急,在正式开始训练前,还需要进行数据预处理,大致有如下几点。

    • 灰度化:如果是将图片输入神经网络,此法一般不会使用,因为颜色通道往往也会包含对象的信息,灰度化会导致信息丢失。
    • 几何变换:平移,旋转,缩放,镜像等等。
    • (其他骚操作)

    如果数据集比较小,通过预处理可以扩充数据集,同时对于网络学习到更加泛化的特征也很有用。

    数据预处理的库可以使用PILskimage,功能都比较好。

    另外,预处理过后的图片可以直接放在原图片所在的文件夹,通过添加后缀名标识处理方法,如下

    0_1545398676339_n02085620_949.jpg
    0_1545398746351_n02085620_949_flip.jpg
    0_1545398770928_n02085620_949_rotate.jpg

    第五步:dataloader类

    到现在为止,数据的准备工作告一段落,开始进行数据读取函数的编写,首先说明一下这里数据读取的接口,对于Pytorch来说,数据读取非常的方便,可以直接使用torchvision.transforms.ImageFolder类进行读取(注意要确保数据存放格式正确,详情)

    在这里,我们使用另外一种方式,使用自定义torch.utils.data.Dataset类来进行数据读取、索引,使用torch.utils.DataLoader类来对上述Dataset的数据进行”包装“,用于之后输入到模型训练和预测。Pytorch文档

    Step1:生成图片路径对应图片标签的TXT文件

    代码文件为preprocess.py**

    这里说明一下为什么要这么做,这里生成的TXT文件内容如下

    ./data/train_data/n02085620-Chihuahua\n02085620_10621.jpg 0
    ./data/train_data/n02085620-Chihuahua\n02085620_1073.jpg 0
    ./data/train_data/n02085620-Chihuahua\n02085620_10976.jpg 0
    ...
    

    每行表示一个样本,每个样本由路径和标签组成,两者通过空格隔开,这样做的目的在于,我们在创建Dataset类的时候,可以直接保存图片路径,而不用读取全部的图片保存在内存中,在输出的时候,Dataset类再调用*PIL.Image.read()*读取图片返回,尽管这样可能速度比较慢,但是经过我的测试,情况还可以。

    # preprocess.py
    # -*- coding: utf-8 -*-
    
    import os
    from tqdm import tqdm
    import pickle
    
    def gen_image_path_file(path, label_dict=None):
        '''
        This function is used to generate image path file.
        It is a txt file, format:
            [image_path] [name]
            (the name of the picture is the class name)
        The path is the root path of the data, the txt file
        will be saved in root path.If label_dict is given,
        the format will be:
            [image_path] [label]
        :param path: the image path
        :return: None
        '''
        if not os.path.exists(path):
            raise Exception("==> The path does not exists : %s" % path)
    
        names = []  # store the class name of the image
        im_paths = []  # store the im_path of the image
        print("==> Begin to generate names and paths")
        for dirname in tqdm(os.listdir(path)):
            dirpath = os.path.join(path, dirname)
            if os.path.isfile(dirpath):
                continue
            name = dirname.split("-")[1]
            for im_name in os.listdir(dirpath):
                im_path = os.path.join(dirpath, im_name)
                names.append(name)
                im_paths.append(im_path)
    
        print("==> Generate image path txt file")
        # Gen txt file
        if not label_dict:
            lines = ["%s %s" % (ph, nm) for ph, nm in zip(im_paths, names)]  # gen "[path] [name]" line
            print("==> Generate image_name file")
            txt_path = os.path.join(path, "image_name.txt")
            with open(txt_path, "w+") as fp:
                fp.write('\n'.join(lines))
        else:
            # transform the image name to label
            labels = list(map(lambda x: str(label_dict[x]), names)) # transform from image name to label
            lines = ["%s %s" % (ph, lb) for ph, lb in zip(im_paths, labels)]
            txt_path = os.path.join(path, "image_label.txt")
            with open(txt_path, "w+") as fp:
                fp.write("\n".join(lines))
    
    if __name__ == "__main__":
        label_dict = pickle.load(open("./source/dict/name_to_label.pkl", "rb"))
        gen_image_path_file("./data/train_data/", label_dict)
    

    这样,可以在对应的data/train_data/下生成一个TXT文件,同理,可以在验证集和测试集下生成这样的TXT文件。

    Step2:构建Dataset类

    构建Dataset类比较简单,大致步骤如下

    • 创建类,继承torch.utils.data.Dataset类,这里,我们创建一个名叫dataset的类。

      class dataset(Dataset):
          def __init__(self, path, data="train"):
          ......
      
    • 重载Dataset类的*_len__getitem_内置函数,其中_len_用于输出数据的个数,_getitem_*用于通过索引值输出对应索引位置的数据。最终代码如下:

      # dataloader.py
      # -*- coding: utf-8 -*-
      
      from torch.utils.data import *
      import torch
      from torchvision import transforms
      from PIL import Image
      import os
      import numpy as np
      
      
      class dataset(Dataset):
          def __init__(self, path, data="train"):
              '''
              :param path: The data path
              :param data: The type of the data, "train" and "test" only
              '''
              super(dataset, self).__init__()
      
              # Check train data txt file
              if not os.path.exists(os.path.join(path, "image_label.txt")):
                  print("==> The image_label.txt does not exist.")
                  exit(0)
      
              # import all training images
              # transfrom the image
              if data == "train":
                  self.transform = transforms.Compose([
                                   transforms.Resize((224, 224), Image.BILINEAR),
                                   transforms.RandomRotation(degrees=30,
                                            resample=False,
                                            expand=False),
                                   transforms.ToTensor(),
                                   transforms.Normalize([0.476, 0.451, 0.390], [0.070, 0.067, 0.070])
              ])
              elif data == "test":
                  self.transform = transforms.Compose([
                                   transforms.Resize((224, 224), Image.BILINEAR),
                                   transforms.ToTensor(),
                                   transforms.Normalize([0.476, 0.451, 0.390], [0.070, 0.067, 0.070])
              ])
              else:
                  raise Exception("Error ==> There is no %s dataset type." % data)
              # get all images paths and labels
              with open(os.path.join(path, "image_label.txt"), "r") as fp:
                  self.lines = fp.read().split("\n")
      
          def __len__(self):
              return len(self.lines)
      
          def __getitem__(self, item):
              fn, lb = self.lines[item].split()
              img = Image.open(fn)
              if hasattr(self, "transform") and self.transform is not None:
                  img = self.transform(img)
              return img, torch.from_numpy(np.array(lb, dtype=np.int64))
      

    说明

    代码中的几点说明如下

    • Pytorchtorchivision.transforms提供了非常好的图片转换API,它对图片的操作主要是使用PIL库,默认读取的方式为PIL.Image.open(),并且在读取之后可以通过该模块封装的其它方法对图片进行操作变换,比如旋转,裁剪等等,另外特别好的是能够通过torchision.transforms.ToTensor直接转化为torch.Tensor,真可谓是巧夺天工,而且还可以通过自定义lambda函数对图片进行变换,可谓良心。
    • 在这里值得注意的是我们读取和保存的是TXT文件,也就是我们并不会直接读取图片,而是将图片路径和图片标签读取出来,然后当需要返回某个图片的时候再使用PIL读取返回。
    • 另外,这里要注意,我们返回的是两个值,一个是img,另外一个是label,两个变量都需要是torch.Tensor类型的。

    Step3:构建DataLoader类

    上述过程将Dataset构建之后,现在就可以用DataLoader进行“包装”了,为什么说叫包装?因为dataset基本上已经完成了从读取到返回数据的目的了,但是,对于我们训练而言,这些还远远不够,比如我们还需要实现以下需求:

    • 输入batch_size数量的数据,形状为*(batch_size,channel,width,height),但是仅仅使用上述的dataset*每次只能返回一个数据。
    • shuffle操作,对于训练集,一般情况下我们需要对数据进行打乱,但使用dataset无法shuffle。
    • 多线程读取数据。

    因此,我们通过DataLoader的包装可以轻而易举地实现这样的需求,代码如下:

    # 这段代码是从train.py提取的一段
    print("==> Load data")
        imgLoader = DataLoader(dataset(path=conf.RAW_TRAIN_DATA, "train"),
                               batch_size=conf.BATCH_SIZE,
                               shuffle=True,
                               num_workers=1)
        evalLoader = DataLoader(dataset(path=conf.RAW_TEST_DATA, "test"),
                                batch_size=100,
                                shuffle=False,
                                drop_last=False)
    

    然后通过for循环,就可以直接输出一个batch的数据了,美哉美哉。

    for batch, (X, Y) in enumerate(imgLoader):
        ...
    

    第六步:构建网络

    讲道理,这一步内容很多,所以我也就不讲了,这一步其实可以单独拿出作为一个学习模块,但是我们注重于怎样构建一个Project,所以,这一步我主要讲一下它的构成和接口。

    在开始前,我们在主目录建立一个文件夹,名为models/,用于保存模型文件(和前面的不同,这里是*.py*文件,就是实例化一个model的代码文件)

    目录树如下

    |--models
    |    |--__init__.py
    |    |--PeleeNet.py
    |    |--......
    

    注意:这里要有一个*_init_.py*文件,表示这个文件夹为一个模块。

    构建一个模型类主要有以下几个步骤:

    • 构建类名,继承torch.nn.Module

      class PeleeNet(nn.Module):
          def __init__(self, config):
      
    • 初始化各网络层,这里记住一点,<u>不要使用Python的数组,字典等来保存几个初始化网络层</u>,比如:

      self.stem = [ConvLayer(32, 16, 1, 1, 0), ConvLayer(16, 32, 3, 2, 1), ...]
      

      这样做在之后如果要移到GPU上跑时,这些网络层无法被移到GPU里面去,导致类型错误,因此,可以使用torch.nn.Sequential或者torch.nn.ModuleList,这两个是有差别的哦。

    • 定义前向传播通路,也就是*forward()*函数

    最后说一下这里默认网络的输入shape为*(batch_size, channel, width, height),网络输出shape(batch_size, num_class)*,只要这样规定好,这里的网络可以替换为其它图像分类网络,前提是保证API对应。

    代码如下

    # models/PeleeNet.py
    # -*- coding: utf-8 -*-
    
    import torch.nn as nn
    import torch
    
    
    def ConvLayer(in_channel, out_channel, kernel_size, stride, padding):
        '''
        This function is used to apply a combination of conv layer,
        batch_normal layer and Relu layer
        '''
        return nn.Sequential(
            nn.Conv2d(in_channels=in_channel,
                      out_channels=out_channel,
                      kernel_size=kernel_size,
                      stride=stride,
                      padding=padding),
            nn.BatchNorm2d(out_channel),
            nn.ReLU()
        )
    
    
    class PeleeNet(nn.Module):
        def __init__(self, config):
            super(PeleeNet, self).__init__()
            self.MODEL_NAME = "PeleeNet"
            self.config = config
    
            # input shape(N, C_in, H, W)
            # stage0 : apply stem block
            self.stem_block_conv0 = ConvLayer(in_channel=3,
                                              out_channel=32,
                                              kernel_size=3,
                                              stride=2,
                                              padding=1)
            self.stem_block_convl_1 = ConvLayer(32, 16, 1, 1, 0)
            self.stem_block_convl_2 = ConvLayer(16, 32, 3, 2, 1)
            self.stem_block_convr = nn.MaxPool2d(kernel_size=2,
                                                 stride=2,
                                                 padding=0)
            self.stem_block_convf = ConvLayer(64, 32, 1, 1, 0)
    
            # stage1 : apply dense block and transition block
            self.dense_block_1 = self.dense_block(in_channel=32,
                                                  block_num=3,
                                                  k=32)
            self.trasition_layer_1 = self.transition_layer(in_channel=128,
                                                           out_channel=128,
                                                           is_avgpooling=True)
    
            # stage2 : apply dense block and transition block
            self.dense_block_2 = self.dense_block(in_channel=128,
                                                  block_num=4,
                                                  k=32)
            self.trasition_layer_2 = self.transition_layer(in_channel=256,
                                                           out_channel=256,
                                                           is_avgpooling=True)
    
            # stage3 : apply dense block and transition block
            self.dense_block_3 = self.dense_block(in_channel=256,
                                                  block_num=8,
                                                  k=32)
            self.trasition_layer_3 = self.transition_layer(in_channel=512,
                                                           out_channel=512,
                                                           is_avgpooling=True)
    
            # stage4 : apply dense block and transition block without avg
            self.dense_block_4 = self.dense_block(in_channel=512,
                                                  block_num=6,
                                                  k=32)
            self.trasition_layer_4 = self.transition_layer(in_channel=704,
                                                           out_channel=704,
                                                           is_avgpooling=False)
    
            # stage5 : classification
            self.globel_avg = nn.AvgPool2d(7)
            self.fc = nn.Linear(704,self.config.NUM_CLASS)
    
        def forward(self, input):
            # The input shape (N, C_in, H, W), (batch_size, 3, 224, 224)
            # First get through stem block
            # shape (batch_size, 3, 224, 224)
            output = self.stem_block_conv0(input)
            # shape (batch_size, 32, 112, 112)
            output_l = self.stem_block_convl_1(output)
            output_l = self.stem_block_convl_2(output_l)
            # shape (batch_size, 32, 56, 56)
            output_r = self.stem_block_convr(output)
            # shape (batch_size, 32, 56, 56)
            output = torch.cat([output_l, output_r], 1)
            # shape (batch_size, 64, 56, 56)
            output = self.stem_block_convf(output)
            # shape (batch_size, 32, 56, 56)
    
            # Apply 4 stages with dense block and transition layer
            for stage, dense_block, transition_block in zip([1,2,3,4],
                [self.dense_block_1, self.dense_block_2, self.dense_block_3, self.dense_block_4],
                [self.trasition_layer_1, self.trasition_layer_2, self.trasition_layer_3, self.trasition_layer_4]):
                # Apply dense block
                for i in range(int(len(dense_block)/2)):
                    convl = dense_block[2*i]
                    convr = dense_block[2*i+1]
                    output_l = convl(output)
                    output_r = convr(output)
                    output = torch.cat([output, output_l, output_r], 1)
                # Apply transition block
                if stage == 4:
                    output = transition_block(output)
                else:
                    conv, avg = transition_block
                    output = conv(output)
                    output = avg(output)
    
            # Apply classification
            # shape (batch_size, 704, 7, 7)
            output = self.globel_avg(output)
            output = self.fc(output.view((-1, 704)))
            return output
    
        def dense_block(self,in_channel, block_num, k=32):
            '''
            This part is used to apply dense block
            :param block_num: the num of dense layers
            :param k: the num of feature maps
            :return: a list contains layers
            '''
            blocks = nn.ModuleList([]) # used to store dense layers
            for i in range(block_num):
                # left channel, this won't change the size of image
                # and output channel is k/2
                convl = nn.Sequential(
                    ConvLayer(k*i+in_channel, 2*k, 1, 1, 0),
                    ConvLayer(2*k, int(k/2), 3, 1, 1)
                )
                # right channel, this won't change the size of image
                # and output channel is k/2
                convr = nn.Sequential(
                    ConvLayer(k*i+in_channel, 2*k, 1, 1, 0),
                    ConvLayer(2*k, int(k/2), 3, 1, 1),
                    ConvLayer(int(k/2), int(k/2), 3, 1, 1)
                )
                # add to blocks
                blocks.extend([convl, convr])
            return blocks
    
        def transition_layer(self, in_channel, out_channel, is_avgpooling=True):
            conv0 = ConvLayer(in_channel, out_channel, 1, 1, 0)
            if is_avgpooling:
                avg = nn.AvgPool2d(2, 2, 0)
                return nn.ModuleList([conv0, avg])
            else:
                return conv0
    

    第七步:构建训练函数

    这一波也是稍微有点复杂,主要是细节要注意,我们从粗到细,逐步完善。

    • Step1:初始化神经网络

      # Initialize the PeleeNet
      net = PeleeNet.PeleeNet(conf)
      net.apply(weight_init)
      
    • Step2:初始化lossacc

      # Set best loss and best acc
      best_loss = float('inf')
      best_acc = 0.0
      
    • Step3:使用GPU

      # Use GPU
      if conf.USE_CUDA:
          print("==> Using CUDA to train")
          net.cuda()
          # https://www.pytorchtutorial.com/when-should-we-set-cudnn-benchmark-to-true/
          torch.backends.cudnn.benchmark = True
      
    • Step4:设置optimizercriterion(就是优化器和loss函数)

      # Set learning rate and set hyper-parameter
      optimizer = optim.Adam(net.parameters(), lr=conf.LEARNING_RATE, weight_decay=1e-4)
      # Set loss
      criterion = nn.CrossEntropyLoss()
      
    • Step5:进入epoch循环和batch循环

      for epoch in range(conf.NUM_EPOCHS):
          print("############## Training epoch {}#############".format(epoch))
          # Adjust the learning rate according to epoches
          adjust_learning_rate(optimizer, epoch, conf.LEARNING_RATE)
          for batch, (X, Y) in enumerate(imgLoader):
              ......
      
      • 如果为训练模式,设置训练模式

        # Set net as training mode
        net.train()
        
      • 如果为测试模式,设置验证模式

        net.eval()
        
    • Step6:开启训练之旅

      print("==> Load data")
      imgLoader = DataLoader(dataset(path=conf.RAW_TRAIN_DATA),
                                 batch_size=conf.BATCH_SIZE,
                                 shuffle=True,
                                 num_workers=1)
      evalLoader = DataLoader(dataset(path=conf.RAW_TEST_DATA),
                                  batch_size=100,
                                  shuffle=False,
                                  drop_last=False)
      # Initialize SummaryWriter
      writer = SummaryWriter(log_dir=conf.SOURCE_DIR_PATH["SUMMARY_DIR"])
      
          # Begin to train
          print("==> Begin to train")
          train()
      
      


  • 上述已经将大致的框架搭建了,搭建后的文件目录树如下:

    |--data/
    |   |--train_data/
    |   |--test_data/
    |   |--eval_data/
    |--models/
    |   |--__init__.py
    |   |--PeleeNet.py
    |--source/
    |   |--dict/
    |        |--label_to_name.pkl
    |        |--name_to_label.pkl
    |   |--models/
    |   |--summary/
    |--preprocess.py
    |--dataloader.py
    |--train.py
    

    现在就是开始往精细的方面进行加工了

    映射字典的生成

    映射字典,在大部分情况下是在数据准备好之后就要生成,这样方便统一进行管理,也保证了后面的标签和索引的一致性。

    生成映射字典的代码在preprocess.py里面,大致的思路是首先生成一个空的字典,然后遍历整个数据集(或训练集),在遍历过程中索引从0逐渐加1,最后将字典导出为*.pkl文件或者.json文件,我使用的是.pkl*文件。

    pickle

    使用pickle库,可以方便地将python的字典保存为*.pkl二进制文件,需要注意的是python2的二进制文件和python3的二进制文件处理的方式不同,因此,注意使用pickle*时,在save和load时,注意python版本的一致。

    python通过pickle.dump()保存到文件,通过pickle.load()从文件载入,代码如下:

    def gen_image_path_file(path, label_dict=None):
        '''
        This function is used to generate image path file.
        It is a txt file, format:
            [image_path] [name]
            (the name of the picture is the class name)
        The path is the root path of the data, the txt file
        will be saved in root path.If label_dict is given,
        the format will be:
            [image_path] [label]
        :param path: the image path
        :return: None
        '''
        if not os.path.exists(path):
            raise Exception("==> The path does not exists : %s" % path)
    
        names = []  # store the class name of the image
        im_paths = []  # store the im_path of the image
        print("==> Begin to generate names and paths")
        for dirname in tqdm(os.listdir(path)):
            dirpath = os.path.join(path, dirname)
            if os.path.isfile(dirpath):
                continue
            name = dirname.split("-")[1]
            for im_name in os.listdir(dirpath):
                im_path = os.path.join(dirpath, im_name)
                names.append(name)
                im_paths.append(im_path)
    
        print("==> Generate image path txt file")
        # Gen txt file
        if not label_dict:
            lines = ["%s %s" % (ph, nm) for ph, nm in zip(im_paths, names)]  # gen "[path] [name]" line
            print("==> Generate image_name file")
            txt_path = os.path.join(path, "image_name.txt")
            with open(txt_path, "w+") as fp:
                fp.write('\n'.join(lines))
        else:
            # transform the image name to label
            labels = list(map(lambda x: str(label_dict[x]), names)) # transform from image name to label
            lines = ["%s %s" % (ph, lb) for ph, lb in zip(im_paths, labels)]
            txt_path = os.path.join(path, "image_label.txt")
            with open(txt_path, "w+") as fp:
                fp.write("\n".join(lines))
    

    json

    使用Python的Json保存也很方便,Json是一种轻量级的数据交换格式,通过json.dump()对数据进行编码写入文件,通过json.load()从文件载入数据并解码。

    Json也提供*json.dumps()*用于编码生成字符串以及json.loads()用于从字符串解码。

    str()方法

    一个简单粗暴的方法是直接通过python的str()方法,直接将python字典转化成字符串,使用eval方法将字符串解码。

    我强烈不推荐该方法,因为该方法有一些弊端:

    • 字典的value如果不属于python内置的类型,那么在代码中需要import该类型,比如如果字典的value为numpy.array()类型,那么在代码中就需要from numpy import array,使得在eval解析时能够正确解析出来。

    • 字典的value如果是一个自定义函数,比如lambdafilter等等,返回的是一个function对象,在调用*str()*方法时,解析出来的是函数保存的内存地址,比如

      d = {"a" : lambda x: x+1}
      

      使用*srt()*之后,得到的结果是

      "{'a': <function <lambda> at 0x0000021082847B70>}"
      

      显然,在下次解析的时候会出现问题,因为内存的内容发生了改变。



  • 图像预处理

    在传统的数字图像处理中,大多数使用的是灰度图像特征,RGB特征使用较少(当然,颜色直方图特征使用了颜色特征),因此,在这种情况下,通常需要将彩色图像转化成灰度图像,然后使用各种特征算子提取图像特征,这里我们使用的是神经网络,输入的图像本身的通道数就是3,所以不用将图像转化成灰度图。

    这里主要说一下图像增强的方式,图像增强可以扩充数据集,而且扩充的数据能够使得神经网络学习到鲁棒性更加好的特征。

    PIL进行数据增强

    使用Pillow(PIL)库可以很轻松地对图像进行处理,PIL库中用于图像增强的函数主要在PIL.ImageEnhance模块中,可以对图像做以下处理:

    • 亮度增强或减弱

      ImageEnhance.Brightnesss()
      
    • 颜色增强或减弱

      ImageEnhance.Color()
      
    • 对比度增强或减弱

      ImageEnhance.Contrast()
      
    • 锐度增强或减弱

      ImageEnhance.Sharpness()
      

    使用方法为先实例化一个增强类,传入Image类图片,然后调用enhance()方法对图像增强或减弱。比如:

    from PIL import ImageEnhance
    from PIL import Image
    
    im = Image.open("filename.jpg")
    im_enh = ImageEnhance.Color(im)
    im_enh.enhance(1.3)  # 输入值大于1,表示增强
    im_enh.enhance(0.8)  # 输入值小于1,表示减弱
    

    另外,PIL库也可以用于对图片进行resizerotate



  • TensorboardX的使用

    在训练时,我们希望实时监测训练的过程,比如训练每个batch的loss,以及每隔一段时间对训练出来的模型使用验证集预测一遍得到的acc,虽然说可以通过自己生成这些数据之后再通过matplotlib画图得到曲线,但是为了追求更加精简的风格,我们使用TensorboardX库,这个库可以直接使用pip安装,另外,安装该库前,需要先安装tensorflowtensorboard

    使用步骤如下:

    • 在一切开始训练前,使用如下代码初始化一个SummaryWriter

      from tensorboardX import SummaryWriter
      
      writer = SummaryWriter(log_dir="log_dir_path")
      

      其中,log_dir_path就是用于保存summary文件的路径。

    • 在训练过程中,如果我要每个batch记录一次训练的loss,那么大致结构如下:

      for epoch in range(NUM_EPOCH):
          for batch in range(NUM_BATCHES):
              ...(training process and get loss)
              writer.add_scalar('train/loss', loss, epoch*NUM_EPOCH+batch)
              ...
      
    • 在训练过程中,如果每隔VALIDPEREPOCH使用模型预测一次,那么大致结构如下:

      for epoch in range(NUM_EPOCH):
          for batch in range(NUM_BATCHES):
              ...(training process and get loss)
              if (epoch + 1) % VALIDPEREPOCH:
                  writer.add_scalar('eval/acc', acc, epoch*NUM_EPOCH+batch)
              ...
      
    • 在训练结束后,使用writer.close()关闭



  • 数据归一化

    不知你发现没有,之前在写dataloader时,我们在写transformer的时候,该部分的代码如下:

    self.transform = transforms.Compose([
                                 transforms.Resize((224, 224), Image.BILINEAR),
                                 transforms.RandomRotation(degrees=30,
                                          resample=False,
                                          expand=False),
                                 transforms.ToTensor(),
                                 transforms.Normalize([0.476, 0.451, 0.390], [0.070, 0.067, 0.070])
            ])
    

    注意里面有一个

    transforms.Normalize([0.476, 0.451, 0.390], [0.070, 0.067, 0.070])
    

    这个是数据归一化代码,也就是说,我们在输入图像数据的时候,会将图像特征归一化到均值为0,方差为1的数据分布上,在这里,我们传入两个一维数组,第一个一维数组表示分别在RGB上的均值,第二个一维数组表示在RGB上的方差。

    这两个数据是在所有训练集中得到的,在预测时,也需要使用同样的均值和方差进行归一化,然后输入网络进行预测。

    这里说一下为什么需要使用归一化,在CS231n的课程上说过,如果数据的分布离原点非常远,那么分类器对于权重的扰动会变得非常的敏感,这样,在训练的时候会非常困难。

    这里,我们举一个简单的例子,比如对于二维的情况,使用二分类,特征点分布在离原点很远很远处,那么,试着在二维图上画一条过原点的直线(这里不考虑偏置b),使得两个类别分别在直线的两边,可以想象,这条直线的斜率必须要很好的设置,否则,如果直线斜率稍微偏一点点,那么很容易分错,想象一下在训练的过程中,就是在调节斜率,那么稍微调节一下,就很容易分错,导致很难稳定地学习到分类器。

    但是如果把所有的数据移动到原点,如果移动到原点后,正样本数据分布在第一象限,负样本分布在第三象限,那么我这个时候斜率只要是负值,就符合要求,显然,这个时候的分类器很容易训练。

    生成均值和方差的代码在preprocess.py中,代码如下:

    def get_image_mean(path):
        # Get all the image and compute mean
        r_sum = 0.0
        g_sum = 0.0
        b_sum = 0.0
    
        filenum = 0
        print("==> Computing means......")
        for root, dirs, files in tqdm(os.walk(path)):
            for file in files:
                if os.path.splitext(file)[-1] == ".jpg":
                    im = Image.open(os.path.join(root, file))
                    im = im.resize((224,224))  # Resize the image
                    r, g, b = im.split()  # divide it into R, G, B channel
                    r_sum += np.sum(np.array(r) / 255) / (224 * 224)
                    g_sum += np.sum(np.array(g) / 255) / (224 * 224)
                    b_sum += np.sum(np.array(b) / 255) / (224 * 224)
                    filenum += 1
    
        print("==> The mean of R channel : {:.4f}".format(r_sum/filenum))
        print("==> The mean of G channel : {:.4f}".format(g_sum/filenum))
        print("==> The mean of B channel : {:.4f}".format(b_sum/filenum))
    
        # Calculate std of R, G, B channel
        r_std = 0.0
        g_std = 0.0
        b_std = 0.0
        print("==> Computing std......")
        for root, dirs, files in tqdm(os.walk(path)):
            for file in files:
                if os.path.splitext(file)[-1] == ".jpg":
                    im = Image.open(os.path.join(root, file))
                    im = im.resize((224, 224))
                    r, g, b = im.split()
                    r_std += np.sum(np.square(np.array(r) / 255 - r_sum / filenum)) / (224 * 224)
                    g_std += np.sum(np.square(np.array(g) / 255 - g_sum / filenum)) / (224 * 224)
                    b_std += np.sum(np.square(np.array(b) / 255 - b_sum / filenum)) / (224 * 224)
    
        print("==> The std of R channel : {:.4f}".format(r_std/filenum))
        print("==> The std of G channel : {:.4f}".format(g_std/filenum))
        print("==> The std of B channel : {:.4f}".format(b_std/filenum))
    


  • 网络初始化

    CS231n上也有提到,在训练卷积网络时,如果初始化的权值的分布为均值为0,方差为1的正态分布,那么随着网络的逐渐加深,输出的分布会逐渐集中于0,或者发散于无穷,这样会导致训练很深的网络变得异常的困难,对于模型的收敛速度和模型质量有很大的影响。这里推荐使用Xavier Initialization以及其变种进行初始化,在pytorch中,我们可以使用torch.nn.init.xavier_normal_()进行权重初始化,也可以是使用自定义的函数进行初始化,大致的思路就是,在训练时,实例化网络之后,改变其中权值的初始值,代码如下:

    def weight_init(m):
        '''
        This function is used to initialize the model weight,
        :param m: The input module
        :return: None
        '''
        if isinstance(m, nn.Conv2d):  # Init the nn.Conv2d weight
            nn.init.xavier_normal_(m.weight.data,
                                   nn.init.calculate_gain('relu'))
            if m.bias is not None:  # Init the bias
                nn.init.constant_(m.bias.data, 0.0)
        elif isinstance(m, nn.BatchNorm2d):
            nn.init.constant_(m.weight.data, 1.0)
            nn.init.constant_(m.bias.data, 0.0)
        elif isinstance(m, nn.Linear):
            nn.init.normal_(m.weight.data,
                            mean=0,
                            std=0.01)
    

    在实例化网络之后,使用net.apply()对实例化后的网络进行初始化,代码如下:

    # Initialize the PeleeNet
    net = PeleeNet.PeleeNet(conf)
    # Initialize the weight of the layers
    net.apply(weight_init)
    

    也可以自定义一个初始化函数,替换nn.init部分,给layer.weight.data赋值,即可对网络层进行初始化。



  • 调整学习率

    学习率在网络训练过程中是一个很重要的参数,如果学习率过高,虽然训练速度很快,但是当快达到模型训练的最优点附近时,由于学习率很高,模型训练时很容易跨过最优点而不断地“错过”,如果学习率过低,那么从模型初始化到模型最优点需要经过很多次地调整,训练时间非常长,因此,我们可以考虑先使用比较高的学习率训练,比如0.010.001,然后根据tensorboard得到的曲线分析训练多少个epoch的时候loss会达到饱和,这个时候,可以通过设置随epoch衰减的学习率,刚开始学习率比较高,网络能够很快地训练,在网络训练快达到饱和时,降低学习率,使得网络能够比较稳定地优化到最优点。

    pytorch的optimizer通过param_groups来管理参数信息。

    调整学习率代码如下:

    def adjust_learning_rate(optimizer, epoch, init_lr):
        '''
        This is used to adjust learning rate during training
        :param optimizer: Net optimizer
        :param epoch: The num of epoches trained
        :param init_lr: The initial learning rate
        :return: None
        '''
        lr = init_lr * (0.1 ** (epoch // 50))
        for param_group in optimizer.param_groups:
            param_group['lr'] = lr
    

    其中50表示每50个epoch学习率降低0.1倍,init_lr表示初始化的学习率,在之后循环训练每个epoch时,对optimizer进行调整,大致代码如下:

    for epoch in range(conf.NUM_EPOCHS):
        print("############## Training epoch {}#############".format(epoch))
        # Adjust the learning rate according to epoches
        adjust_learning_rate(optimizer, epoch, conf.LEARNING_RATE)
        for batch, (X, Y) in enumerate(imgLoader):
            ......
    

    这样,每当训练50个epoch,便可以降低学习率。



  • 参数模块化

    为了对整个Project的参数进行统一的管理,我们通常会使用一个特定的文件比如config.py文件,将整个工程的参数(包括模型的参数)都保存在该文件里面,然后在其它文件里面通过导入该文件即可使用特定的参数,这样方便实现参数的模块化。

    config.py文件内,我们通过定义一个Config类来保存参数,主要参数包含以下种:

    • 路径(包括训练集、测试集路径,模型保存路径,source路径等等)
    • 数据参数(包括输入图片的大小,通道,类别数等等)
    • 训练通用参数(包括使用GPU,使用已训练模型,学习率,batch_size等等)
    • 模型特定参数

    Config类的方法主要有:

    • 初始化路径文件夹

    大致的config.py文件内容如下:

    # -*- coding: utf-8 -*-
    
    import os
    import torch
    
    class Config():
        def __init__(self):
            # general param
            self.RETRAIN = True
            self.USE_CUDA = torch.cuda.is_available()
    
            # define the data paths
            self.RAW_TRAIN_DATA = "./data/train_data/"
            self.RAW_TEST_DATA = "./data/eval_data/"
            # define the source path
            self.SOURCE_DIR_PATH = {
                "MODEL_DIR" : "./source/models/",
                "SUMMARY_DIR" : "./source/summary/"
            }
            # define the file path
            self.LABEL_TO_NAME_PATH = "./source/dict/label_to_name_dict.pkl"
            self.NAME_TO_LABEL_PATH = "./source/dict/name_to_label_dict.pkl"
    
            # check the path
            self.check_dir()
    
            # define the param of the training
            self.WIDTH = 488
            self.HEIGHT = 488
            self.CHANNEL = 3
            self.NUM_CLASS = 250
            self.BATCH_SIZE = 30
            self.NUM_EPOCHS = 500
            self.LEARNING_RATE = 0.001
            self.VALPERBATCH = 2
    
        def check_dir(self):
            '''
            This function is used to check the dirs.if data path
            does not exists, raise error.if source path does not
            exits, make new dirs.
            :return: None
            '''
            # check the data path
            if not os.path.exists(self.RAW_TEST_DATA):
                raise Exception("==> Error: Data path %s does not exist." % self.RAW_TEST_DATA)
            if not os.path.exists(self.RAW_TRAIN_DATA):
                raise Exception("==> Error: Data path %s does not exist." % self.RAW_TRAIN_DATA)
    
            # check the source path
            for name, path in self.SOURCE_DIR_PATH.items():
                if not os.path.exists(path):
                    print("==> Creating %s : %s" % (name, path))
                    os.makedirs(path)
    


  • 此回复已被删除!

 

Copyright © 2018 bbs.dian.org.cn All rights reserved.

与 Dian 的连接断开,我们正在尝试重连,请耐心等待