第八课.简单的图像分类(二)

常见的卷积神经网络架构

20世纪60年代初,David Hubel,Torsten Wiesel和Steven Kuffler在哈佛医学院建立了神经生物学系。他们在论文《Receptive fields, binocular interaction and functional architecture in the cat’s visual cortex》中提出了Receptive fields的概念;1980年,日本科学家福岛邦彦在论文《Neocognitron: A self-organizing neural network model for a mechanism of pattern recognition unaffected by shift in position》提出了一个包含卷积层、池化层的神经网络结构。但是计算量巨大,且未能找到一个好的参数更新方法;
直到1998年,在这个基础上,Yann Lecun在论文《Gradient-Based Learning Applied to Document Recognition》中提出了LeNet-5,将BackPropogation应用到这个神经网络结构的训练上,就形成了当代卷积神经网络的雏形。
fig1
LeNet虽然也在阅读支票、识别手写数字体一类的任务上很有效果,但在一般的实际任务中表现不如SVM、Boosting等算法,所以一直处于学术界边缘的地位;
直到2012年的Imagenet图像识别竞赛中,Hinton组的论文《ImageNet Classification with Deep Convolutional Neural Networks》中提到的Alexnet引入了全新的深层结构和dropout方法,颠覆了图像识别领域,使深度学习受到广泛关注:
fig2
后面就来到了CNN蓬勃发展的年代,出现了VGG Net(Very Deep Convolutional Network for large-scale Image Recognition),模型逐渐变深:
fig3
但面临着一个问题,模型过深不仅难以训练,还必须要巨大体量的数据集,在2015年,何凯明老师提出了至今都很出名的ResNet(Deep Residual Learning for Image Recognition):
fig4
最初,MSRA的任少卿、何凯明、孙剑老师,尝试把identity加入到神经网络中,但最简单的identity却出人意料的有效,直接使CNN能够深化到152层、1202层:
fig5
这样的设计可以让学习过程中的feature转变为特征的残差,而不是直接变换特征,某种程度上可以降低学习的难度,所以网络也可以变得很深;
紧接着,就从ResNet演变出DenseNet,将残差的优势进一步发挥:
fig6
使用DenseNet块再组合得到自定义网络:
fig7

卷积网络的平移不变性

卷积网络计算时,filter在特征上滑动,在第七课中提到,卷积层相当于filter个数的全连接网络组合,每个全连接网络最后一层只有一个神经元;
张量输入全连接网络相当于向量之间的点积(Product),而点积是衡量相似程度的一种方式,其物理意义是越相似的向量,点积结果越大;回到卷积网络,一组filter相当于一组局部物体的模板,当filter滑动到对应局部物体上时,在输出特征的某一层上,该区域会得到一个较大的值;另外也体现了:不论局部物体在图像中的哪个位置,CNN都能检测到;
但是,CNN不能解决旋转问题,比如一个局部物体旋转后,CNN就不能检测到,因为filter只认识没有旋转过的局部物体,所以一种解决办法是扩充数据集,对图像进行旋转,强迫CNN学习到局部物体旋转后的检测能力;

卷积网络的识别原理简述

其实通过上面的描述,容易想象到CNN的识别物体的原理:
filter只是探测局部物体,输入张量通过一层CNN后得到输出张量,假设输出张量为 ( N , c , h , w ) (N,c,h,w) (N,c,h,w),从batch中取出一组feature ( c , h , w ) (c,h,w) (c,h,w) c c c不仅是通道数也是卷积层的filter数量,一个filter代表着一个局部物体模板,即 c c c个通道就分别代表 c c c个局部物体,而想判断局部物体 i i i是否存在(是否被CNN检测到),就看通道 i i i对应的张量 ( h , w ) (h,w) (h,w)中有没有哪个区域的值很大;
当进行全局的MaxPooling后,相当于取出每个通道的最大局物体与模板的相似度,得到 ( N , c ) (N,c) (N,c)的张量,同样,从batch中取出一组feature ( c ) (c) (c),它是局部物体与模板相似度组合而成的序列,这个序列反应了它可能含有哪些局部物体;另外,不同类别的物体由不同的局部物体组成,所以,这组特征可以输入全连接网络进行分类,从而得到物体类别(原理也是点积);

卷积神经网络的缺陷


通过以上描述,会发现CNN存在一个缺陷:
对于下图:
fig8
CNN将会检测到人脸上的局部物体"鼻子,眼睛,嘴巴",经过全连接网络后显然会分类为人脸:
fig9
但这在现实中,很难让人说:“这是人脸”


CNN的迁移学习

迁移学习简介

在训练一个新的图像分类任务时,往往不会从完全随机初始化的模型开始,通常会利用在ImageNet上预训练的模型加速训练,可以认为预训练模型已经具有提取Local feature(边,角等局部信息)的能力,而恰好这种local feature也存在于别的任务中,所以可以将模型迁移到其他任务,继续训练;这是一种transfer learning的方法;
迁移学习通常有以下两种方式:

  • fine tuning:从预训练模型开始,改变一些模型的架构,继续训练整个模型的参数;
  • feature extraction:不改变预训练模型的参数,只更新自己后增添的模型参数,形象理解为将预训练模型当做特征提取的工具;

之所以在ImageNet上预训练,是因为ImageNet是一个种类丰富的数据集,这可以使预训练模型具备提取大部分事物特征的能力,从而利于迁移学习能在各种任务上展开;

迁移学习的一般步骤:

  • 1.初始化预训练模型;
  • 2.更改最后一层输出层;
  • 3.可以重新定义一个optimizer更新参数,主要是选择更新哪些参数以及学习率的调整;
  • 4.模型训练;

数据集

使用hymenoptera_data数据集,这个数据集包括两类图片, beesants, 这些数据都被处理成了可以使用torchvision.datasets.ImageFolder来读取的格式。

需要的参数有:

  • num_classes表示数据集分类的类别数;
  • batch_size mini-batch的大小;
  • num_epochs 数据集遍历次数;
  • feature_extract 表示训练的时候使用fine tuning方式还是feature extraction方式;

使用dataloader生成batch

使用dataloader生成batch:

from torchvision import datasets,transforms
import torch.utils.data as tud
import os

DATA_DIR = "./hymenoptera_data"

# Batch size for training (change depending on how much memory you have)
BATCH_SIZE = 32
# Number of epochs to train for
NUM_EPOCHS = 15

INPUT_SIZE = 224

# 对数据预处理,并使用dataloader
# ImageFolder以目录名作为类别
train_images=datasets.ImageFolder(os.path.join(DATA_DIR,"train"),
                               transforms.Compose([
                                   #随便从图片中截取input_size*input_size的图片
                                   transforms.RandomResizedCrop(INPUT_SIZE),
                                   #以概率水平翻转PIL图像或张量,shape应该为[...,H,W],默认概率为0.5
                                   transforms.RandomHorizontalFlip(),
                                   transforms.ToTensor(),
                               ]))

trainloader=tud.DataLoader(train_images,
                          batch_size=BATCH_SIZE,
                          shuffle=True, # 每个epoch打乱一次
                          num_workers=0)

img=next(iter(trainloader))
print(img[0].size()) # [32,3,224,224]
print(img[1].size()) # [32]

通过transforms.ToPILImage()将张量转为PIL image,实现可视化:

def imageshow(tensor, title=None):
    import matplotlib.pyplot as plt
    from torchvision import transforms
    uncode = transforms.ToPILImage()

    plt.figure()
    # clone,梯度会流向原tensor
    # 注意区别clone和detach:回顾pytorch记事本
    image = tensor.cpu().clone()
    image = image.squeeze(0)  # 去除batch_size维度
    image = uncode(image)

    plt.imshow(image)
    if title is not None:
        plt.title(title)
    plt.show()

从batch中选一个image的张量进行可视化:

imageshow(img[0][31], title='image')

fig10
改进写法,重新定义dataloader,并对输入图像的张量进行标准化,已知三个通道的均值和标准差分别为:

mean:[0.485, 0.456, 0.406],
std: [0.229, 0.224, 0.225]

把训练集和验证集的dataloader保存到字典里:

# 改进写法,重新定义dataloader
data_transforms = {
    "train": transforms.Compose([  # 随便从图片中截取input_size*input_size的图片
        transforms.RandomResizedCrop(INPUT_SIZE),
        # 以概率水平翻转PIL图像或张量,shape应该为[...,H,W],默认概率为0.5
        transforms.RandomHorizontalFlip(),
        transforms.ToTensor(),
        # 已知该数据集归一化后的mean和std
        transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])]),

    # 验证和训练时对数据的操作是不同的
    "val": transforms.Compose([  # 从中心裁剪input_size*input_size的图片
        transforms.CenterCrop(INPUT_SIZE),
        transforms.ToTensor(),
        # 已知该数据集归一化后的mean和std
        transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])])
}

image_datasets = {x: datasets.ImageFolder(os.path.join(DATA_DIR, x), data_transforms[x]) for x in ["train", "val"]}

dataloaders_dict = {x:tud.DataLoader(image_datasets[x],
                                      batch_size=BATCH_SIZE,
                                      shuffle=True,  # 每个epoch打乱一次
                                      num_workers=0) for x in ["train", "val"]}

同样地,可以利用之前定义的函数imageshow(tensor, title=None)对张量可视化:

# 获取一个样本图片的张量
img=next(iter(dataloaders_dict["val"]))

# 可视化
imageshow(img[0][31], title='image')

fig11

设置超参数

先导入必要的包,比如使用torchvision的models可以选择预训练模型:

import numpy as np
import torchvision
import torch

# 使用torchvision的models选择预训练模型
from torchvision import datasets, transforms, models

import torch.utils.data as tud
import torch.nn as nn

import matplotlib.pyplot as plt
import time
import os
import copy

设置超参数:

DATA_DIR = "./hymenoptera_data"
# 可选择 [resnet, alexnet, vgg, squeezenet, densenet, inception]
MODEL_NAME = "resnet"
#参数下载地址 https://download.pytorch.org/models/resnet18-5c106cde.pth
MODEL_STATE_PATH="./resnet18-5c106cde.pth"

NUM_CLASSES = 2

BATCH_SIZE = 32

NUM_EPOCHS = 15

FEATURE_EXTRACT = True

USE_PRETRAINED=True

INPUT_SIZE = 224

USE_CUDA=torch.cuda.is_available()
DEVICE=torch.device("cuda" if USE_CUDA else "cpu")

注意resnet18的模型参数需要提前下载,下载地址:resnet18-5c106cde.pth

使用dataloader

和数据集部分的内容一样,使用dataloader生成batch,只是注意改进写法:

data_transforms = {
    "train": transforms.Compose([  # 随便从图片中截取input_size*input_size的图片
        transforms.RandomResizedCrop(INPUT_SIZE),
        # 以概率水平翻转PIL图像或张量,shape应该为[...,H,W],默认概率为0.5
        transforms.RandomHorizontalFlip(),
        transforms.ToTensor(),
        # 已知该数据集归一化后的mean和std
        transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])]),

    # 验证和训练时对数据的操作是不同的
    "val": transforms.Compose([  # 从中心裁剪input_size*input_size的图片
        transforms.CenterCrop(INPUT_SIZE),
        transforms.ToTensor(),
        # 已知该数据集归一化后的mean和std
        transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])])
}

image_datasets = {x: datasets.ImageFolder(os.path.join(DATA_DIR, x), data_transforms[x]) for x in ["train", "val"]}

dataloaders_dict = {x: tud.DataLoader(image_datasets[x],
                                      batch_size=BATCH_SIZE,
                                      shuffle=True,  # 每个epoch打乱一次
                                      num_workers=0) for x in ["train", "val"]}

加载预训练模型与模型参数

加载预训练模型只是获得模型的框架,还需要载入来自官网训练的参数 resnet18-5c106cde.pth 才是完整的预训练模型;
如果是feature extraction的训练方式,需要定义一个函数冻结模型的参数,在训练时,就不再追踪预训练模型的梯度,节省显存:

def set_parameters_requires_grad(model, feature_extract):
    if feature_extract:
        # feature extraction方式的迁移学习不需要更新预训练模型,不用计算预训练模型的梯度
        for param in model.parameters():
            param.requires_grad = False
    else:
        pass

如果默认让torchvision保存预训练参数,会不便于管理文件,所以我先加载模型,再单独加载参数,模型及对应参数文件在文档里找:
torchvision/models

# 初始化预训练模型
def initialize_model(model_name, model_state_path, feature_extract, num_classes, use_pretrained=True):
    if model_name == "resnet":
        # 如果pretrained=False,得到是一个完全随机初始化的resnet18
        model = models.resnet18(pretrained=False)

        # 如果默认让torchvision保存预训练参数,不便于管理文件,所以我先加载模型,再单独加载参数
        # 模型及对应参数文件在文档里找:
        # https://github.com/pytorch/vision/tree/master/torchvision/models
        # 加载预训练模型的参数
        if use_pretrained:
            model.load_state_dict(torch.load(model_state_path))
        # 根据是否需要fine tuning设置参数
        set_parameters_requires_grad(model, feature_extract)
        # 获取模型最后fc层的输入特征数
        num_features = model.fc.in_features
        # 虽然前面已经将parameters()的requires_grad全设为False,但现在相当于重新定义了fc
        # 凡是使用nn下的模块,该模块的待学习参数都是requires_grad=True
        # 即,模型将只更新fc层
        model.fc = nn.Linear(num_features, num_classes)
    else:
        print("model not found")
    return model

实例化这个模型:

model = initialize_model(MODEL_NAME,
                         MODEL_STATE_PATH,
                         FEATURE_EXTRACT,
                         NUM_CLASSES,
                         USE_PRETRAINED)

print(model.layer1[0].conv1.weight.requires_grad)
print(model.fc.weight.requires_grad)

打印结果:

False,True

这确实和initialize_model函数内的内容吻合:
虽然前面已经将parameters()的requires_grad全设为False,但由于使用nn.Linear重新定义了fc(凡是使用nn下的模块,该模块的待学习参数都是requires_grad=True),所以模型只有最后fc层的weight和bias是会追踪梯度的

训练

基于前面的设置工作,现在定义函数用于训练:

def train_model(model, dataloaders_dict, device, loss_fn, optimizer, num_epochs=5):
    # 深拷贝:拷贝父子对象
    best_model_weights = copy.deepcopy(model.state_dict())
    best_acc = 0.
    val_acc_history = []

    for epoch in range(num_epochs):
        for mode in ["train", "val"]:
            running_loss = 0.
            running_correct = 0.

            if mode == "train":
                model.train()
            else:
                model.eval()

            for inputs, labels in dataloaders_dict[mode]:
                inputs, labels = inputs.to(device), labels.to(device)

                # 当torch.autograd.set_grad_enabled(True),会计算梯度
                # 否则相当于torch.no_grad
                with torch.autograd.set_grad_enabled(mode == "train"):
                    outputs = model.forward(inputs)  # [batch_size,2]
                    loss = loss_fn(outputs, labels)

                # 获取索引
                preds = torch.argmax(outputs, dim=1)

                # 如果mode为train,进行参数更新
                if mode == "train":
                    loss.backward()
                    optimizer.step()
                    model.zero_grad()

                running_loss += loss.item() * inputs.size(0)
                running_correct += torch.sum(preds.view(-1) == labels.view(-1)).item()

            epoch_loss = running_loss / len(dataloaders_dict[mode].dataset)
            epoch_accuracy = running_correct / len(dataloaders_dict[mode].dataset)
            print("epoch:{},mode:{},epoch_loss:{},epoch_accuracy:{}".format(epoch, mode, epoch_loss, epoch_accuracy))

            # 记录accuracy
            if mode == "val":
                val_acc_history.append(epoch_accuracy)

            # 保存模型
            if mode == "val" and epoch_accuracy > best_acc:
                best_acc = epoch_accuracy
                # 深拷贝:拷贝父子对象
                best_model_weights = copy.deepcopy(model.state_dict())
    # 加载最好的模型参数
    model.load_state_dict(best_model_weights)
    return model, val_acc_history

为了规范,重新进行一次模型实例化:

model=initialize_model(MODEL_NAME,
                       MODEL_STATE_PATH,
                       FEATURE_EXTRACT,
                       NUM_CLASSES,
                       USE_PRETRAINED)
model=model.to(DEVICE)

选择优化方法为SGD,但更新的参数只限于requires_grad=True的张量,所以可以借助filter函数进行过滤,filter参考python笔记本的函数部分

"""
filter函数是对可迭代对象进行过滤,返回一个新对象
filter(function or None,iterable)->filter object
"""
optimizer=torch.optim.SGD(
                filter(lambda param:param.requires_grad,model.parameters()),
                lr=1e-3,
                momentum=0.9)

这是分类问题,损失函数就简单使用交叉熵:

loss_fn=nn.CrossEntropyLoss(reduction="mean")

调用train_model进行训练:

model,val_acc_history=train_model(model,
                                  dataloaders_dict,
                                  DEVICE,
                                  loss_fn,
                                  optimizer,
                                  NUM_EPOCHS)

与随机初始化的模型对比训练效果

为了体现迁移学习的优势,同样加载resnet18,并改变最后的全连接fc层,唯一区别在于模型参数为随机初始化,然后进行训练:

# 选择一个没有预训练的模型作为对比
model_scratch=initialize_model(MODEL_NAME,
                       MODEL_STATE_PATH,
                       False,#FEATURE_EXTRACT,
                       NUM_CLASSES,
                       use_pretrained=False)
model_scratch=model_scratch.to(DEVICE)

model_scratch,val_acc_history_scratch=train_model(model_scratch,
                                  dataloaders_dict,
                                  DEVICE,
                                  loss_fn,
                                  optimizer,
                                  NUM_EPOCHS)

使用训练返回的验证集准确率,对比迁移学习和非迁移学习的效果:

plt.figure()
plt.title("Validation Accuracy vs. Number of Training Epochs")
plt.xlabel("Training Epochs")
plt.ylabel("Validation Accuracy")
plt.plot(range(1,NUM_EPOCHS+1),val_acc_history,label="Pretrained")
plt.plot(range(1,NUM_EPOCHS+1),val_acc_history_scratch,label="Scratch")
#设置y轴的极值
plt.ylim(0,1.0)
#设置x轴的刻度
plt.xticks(np.arange(1, NUM_EPOCHS+1, 1.0))
plt.legend()
plt.savefig("./acc_Compared")
plt.show()

fig12
明显看出,迁移学习加快了收敛,随机初始化模型的训练难度很大,效果明显不如迁移学习

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值