目录
常见的卷积神经网络架构
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应用到这个神经网络结构的训练上,就形成了当代卷积神经网络的雏形。
LeNet虽然也在阅读支票、识别手写数字体一类的任务上很有效果,但在一般的实际任务中表现不如SVM、Boosting等算法,所以一直处于学术界边缘的地位;
直到2012年的Imagenet图像识别竞赛中,Hinton组的论文《ImageNet Classification with Deep Convolutional Neural Networks》中提到的Alexnet引入了全新的深层结构和dropout方法,颠覆了图像识别领域,使深度学习受到广泛关注:
后面就来到了CNN蓬勃发展的年代,出现了VGG Net(Very Deep Convolutional Network for large-scale Image Recognition),模型逐渐变深:
但面临着一个问题,模型过深不仅难以训练,还必须要巨大体量的数据集,在2015年,何凯明老师提出了至今都很出名的ResNet(Deep Residual Learning for Image Recognition):
最初,MSRA的任少卿、何凯明、孙剑老师,尝试把identity加入到神经网络中,但最简单的identity却出人意料的有效,直接使CNN能够深化到152层、1202层:
这样的设计可以让学习过程中的feature转变为特征的残差,而不是直接变换特征,某种程度上可以降低学习的难度,所以网络也可以变得很深;
紧接着,就从ResNet演变出DenseNet,将残差的优势进一步发挥:
使用DenseNet块再组合得到自定义网络:
卷积网络的平移不变性
卷积网络计算时,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存在一个缺陷:
对于下图:
CNN将会检测到人脸上的局部物体"鼻子,眼睛,嘴巴",经过全连接网络后显然会分类为人脸:
但这在现实中,很难让人说:“这是人脸”
CNN的迁移学习
迁移学习简介
在训练一个新的图像分类任务时,往往不会从完全随机初始化的模型开始,通常会利用在ImageNet上预训练的模型加速训练,可以认为预训练模型已经具有提取Local feature(边,角等局部信息)的能力,而恰好这种local feature也存在于别的任务中,所以可以将模型迁移到其他任务,继续训练;这是一种transfer learning的方法;
迁移学习通常有以下两种方式:
- fine tuning:从预训练模型开始,改变一些模型的架构,继续训练整个模型的参数;
- feature extraction:不改变预训练模型的参数,只更新自己后增添的模型参数,形象理解为将预训练模型当做特征提取的工具;
之所以在ImageNet上预训练,是因为ImageNet是一个种类丰富的数据集,这可以使预训练模型具备提取大部分事物特征的能力,从而利于迁移学习能在各种任务上展开;
迁移学习的一般步骤:
- 1.初始化预训练模型;
- 2.更改最后一层输出层;
- 3.可以重新定义一个optimizer更新参数,主要是选择更新哪些参数以及学习率的调整;
- 4.模型训练;
数据集
使用hymenoptera_data数据集,这个数据集包括两类图片, bees 和 ants, 这些数据都被处理成了可以使用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')
改进写法,重新定义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')
设置超参数
先导入必要的包,比如使用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()
明显看出,迁移学习加快了收敛,随机初始化模型的训练难度很大,效果明显不如迁移学习