CV和深度學習
前幾篇博客講了一些深度學習常用結構的原理, 計算方法和實現. 大致有仿射網絡層, 卷積池化層, 時序運算RNN層, 以及BN, Dropout和激活函數, 損失函數等. 我也嘗試了用這些自己寫的結構搭建深度網絡解決一些實際問題.
線性層,激活函數,損失函數
卷積層, 池化層
BPTT
Dropout, Batch Normalization
今天, 深度學習是一個熱門的計算機科學研究方向, 而它最主要的應用場景是CV和NLP. 反觀常規的應用場景, 如推薦系統和數據分析, 反而不是很適合用DL. 最主要的原因是, 深度學習太過追求end to end, 雖然免去了人爲建模和特徵工程的步驟, 但也讓模型的可解釋性變得很差. 這樣的模型落地時, 不但可能在實際場景中遇到無法預測的重大錯誤, 還不容易寫出漂亮的PPT拿去融資(^^). 客戶需要的是清晰的高萃取度特徵(from Xgboost), 以及比決策樹更簡單易懂的模型(邏輯迴歸), 深度網絡這種不穩定的模型自然不適合落地. 但是打打kaggle還是沒問題的.
但是CV領域有些不同的地方, CV處理的數據是圖像數據, 從圖像數據中提取特徵的最有力手段就是深度卷積, 使用其他傳統方法得到的特徵其可解釋度不一定比深度學習更好, 所以深度學習能在CV領域大行其道. 到了今天, 已經是imagenet的時代, 一般的需求直接拿預訓練模型fine tune一下就能得到不錯的效果.
這裏就先簡單講一下CV中最基礎的圖像n分類問題的解法, 之前的博客我也實現過簡單的CNN, 不過要想得到更好的效果, CNN需要一些其他的設計技巧.
VGG
VGG基於Alexnet改動, 比起Alex它具有更深的深度. 它使用小的,多層的卷積核代替原本大的卷積核, 比如5x5, 7x7和11x11的卷積核都可以用多層3x3的卷積核來代替. 池化仍然是使用2x2, 2步長的max pooling. 它使用的參數並不比alex多很多, 雖然計算開銷上要更大一些(每一層都是獨立的矩陣運算), 但是效果卻也遠遠蓋過了alex, 他也證明了深度可以提升模型的性能.
另一點VGG的設計, 在於卷積特徵提取結束後的分類全連接層, 它使用size非常大的卷積核(和特徵圖大小一致)代替全連接層. 我們可以想象, 用KCxCxHxW的卷積核去卷積CxHxW的特徵圖, 會得到KC個輸出. 這樣的運算和"把特徵圖展開成1d, 再做全連接dense層"是完全相同的. 這就允許我們一定程度上用卷積層代替線性全連接層. 這樣做的好處是我們可以接收任意尺寸的圖片, 只要它不小於卷積核, 那就都可以運算. 這就解除了對圖片維度的限制.
Inception
GoogLeNet中使用了一種叫做Inception的技術, 它旨在讓通道數變得不那麼多, 從而減少運算量. 具體的做法, 是把當前M的通道的特徵圖, 它們每一個位置對應的像素值通過一個矩陣乘法進行線性變換, 變成比M小的N通道. 這個過程也就是用一個1x1的卷積核, 0填充卷積特徵圖. 卷積層的通道數N要小於M, 這就是Inception的運算.
比如,一個3x3的卷積核,如果其輸入和輸出的通道數均爲512,那麼需要的計算量爲9x512x512。在卷積操作中,輸出特徵圖上某一個位置,其是與所有的輸入特徵圖是相連的,這是一種密集連接結構。GoogLeNet基於這樣的理念:在深度網路中大部分的激活值是不必要的(爲0),或者由於相關性是冗餘。因此,最高效的深度網路架構應該是激活值之間是稀疏連接的,這意味着512個輸出特徵圖是沒有必要與所有的512輸入特徵圖相連。如果我們使用Inception, 就可以先通過很少運算量的1x512x512把特徵圖壓縮到n通道, 再用3x3的卷積核處理輸出成512通道, 即只需要1x512x512+9xnx512的計算量. 這樣實現了一種間接的剪枝. inception可以隨心所欲改變通道數, 但一般不會增加通道數, 不談數學只談直覺, 我們也能知道, 降低通道數信息量不一定縮減, 但增加通道數信息量一定不會增加.
GoogLeNet的另一個創新之處在於使用了average pooling, 它對最終輸出的特徵圖再做平均池化, 直接把HxW的特徵圖壓縮成1x1. 這樣全連接層接收的信息更少, 使用的參數更少. 這是有利於訓練和部署的, 實踐時GoogleNet的速度和準確率都超過了VGG.
爲什麼需要殘差
高效的CNN經典架構有很多, 比如上面的兩種. 但是不管是哪種技術, 在網絡比較深層時都會面臨梯度消失問題, 從而讓訓練很困難. 即使是VGG和googlenet也只是十幾層而已, 但是當我們想要幾十層的網絡, 事情就變得不簡單了. 我們之前說過的batch-norm可以一定程度上解決這個問題, 但是仍然有些治標不治本. 爲此kaiming大神的團隊在15年提出了resnet的方法, 它允許網絡的跨層連接, 從而梯度能夠更有效傳播到上層.
從前向傳播的角度來看, resnet讓映射更像恆等映射. 當理想映射極接近於恆等映射時,殘差映射易於捕捉恆等映射的細微波動。也就是, 我們認爲學習一個映射比起直接學習要更簡單, 實踐也告訴我們的確如此.
殘差模塊
我們使用在跨層時使用張量加法, 而如果跨層的兩者維度不同, 那麼這個相加是無法進行的. 因此跨層的部分應該不改變特徵圖的長寬, 通道數也應該是一樣的. 這點和VGG很像, 所以實踐時我們一般會用這兩種設計思想來設計網絡.
殘差模塊實現
import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
# 3x3 convolution
def conv3x3(in_channels, out_channels, stride=1):
return nn.Conv2d(in_channels, out_channels, kernel_size=3,
stride=stride, padding=1, bias=False)
# Residual block
class ResidualBlock(nn.Module):
def __init__(self, in_channels, out_channels, stride=1, downsample=None):
"""
Initializes internal Module state, shared by both nn.Module.
"""
super(ResidualBlock, self).__init__()
self.block = nn.Sequential(
conv3x3(in_channels, out_channels,stride = stride),
nn.BatchNorm2d(out_channels),
nn.ReLU(),
conv3x3(out_channels, out_channels),
nn.BatchNorm2d(out_channels)
)
self.downsample = downsample
def forward(self, x):
"""
Defines the computation performed at every call.
x: N * C * H * W
"""
# if the size of input x changes, using downsample to change the size of residual
residual = x
out = self.block(x)
if self.downsample is not None:
residual = self.downsample(x)
out += residual
out = F.relu(out)
return out
我們在兩次卷積中可能會使輸入的tensor的size與輸出的tensor的size不相等,爲了使它們能夠相加,所以輸出的tensor與輸入的tensor size不同時,我們使用downsample(由外部傳入)來使保持size相同
殘差網絡實現
上圖是ResNet18架構, 我們嘗試照着這種架構搭建殘差網用於cifar-10分類.
class ResNet(nn.Module):
def __init__(self, block, layers, num_classes=10):
"""
Initializes internal Module state, shared by both nn.Module and ScriptModule.
"""
super(ResNet, self).__init__()
self.in_channels = 64
#part1
self.conv_in = nn.Sequential(
conv3x3(3, self.in_channels),
nn.BatchNorm2d(self.in_channels),
nn.ReLU()
)
#part2
self.layer1 = self.make_layer(block, 64, num_blocks=layers[0])
self.layer2 = self.make_layer(block, 128, num_blocks=layers[1], stride=2)
self.layer3 = self.make_layer(block, 256, num_blocks=layers[2], stride=2)
self.layer4 = self.make_layer(block, 512, num_blocks=layers[3], stride=2)
#part3
self.dropout10 = nn.Dropout(0.1)
self.dropout50 = nn.Dropout(0.5)
self.avgpool = nn.AvgPool2d(4, 4)
self.fc = nn.Linear(512, 10)
def make_layer(self, block, out_channels, num_blocks, stride=1):
"""
make a layer with num_blocks blocks.
"""
downsample = None
if (stride != 1) or (self.in_channels != out_channels):
# use Conv2d with stride to downsample
downsample = nn.Sequential(
conv3x3(self.in_channels, out_channels, stride=stride),
nn.BatchNorm2d(out_channels))
# first block with downsample
layers = []
layers.append(block(self.in_channels, out_channels, stride, downsample))
self.in_channels = out_channels
# add num_blocks - 1 blocks
for i in range(1, num_blocks):
layers.append(block(out_channels, out_channels))
# return a layer containing layers
return nn.Sequential(*layers)
def forward(self, x):
"""
Defines the computation performed at every call.
"""
out = self.conv_in(x)
out = self.layer1(out)
out = self.dropout10(out)
out = self.layer2(out)
out = self.dropout10(out)
out = self.layer3(out)
out = self.dropout10(out)
out = self.layer4(out)
out = self.avgpool(out)
# view: here change output size from 4 dimensions to 2 dimensions
out = out.view(out.size(0), -1)
out = self.dropout50(out)
out = self.fc(out)
return out
resnet = ResNet(ResidualBlock, [2, 2, 2, 2]) #ResNet18
訓練
def train(model, train_loader, loss_func, optimizer, device):
"""
train model using loss_fn and optimizer in an epoch.
model: CNN networks
train_loader: a Dataloader object with training data
loss_func: loss function
device: train on cpu or gpu device
"""
model.train()
total_loss = 0
# train the model using minibatch
for i, (images, targets) in enumerate(train_loader):
images = images.to(device)
targets = targets.to(device)
# forward
outputs = model(images)
loss = loss_func(outputs, targets)
# backward and optimize
optimizer.zero_grad()
loss.backward()
optimizer.step()
total_loss += loss.item()
# every 100 iteration, print loss
if (i + 1) % 100 == 0:
print ("Step [{}/{}] Train Loss: {:.4f}"
.format(i+1, len(train_loader), loss.item()))
return total_loss / len(train_loader)
def evaluate(model, val_loader, device):
"""
model: CNN networks
val_loader: a Dataloader object with validation data
device: evaluate on cpu or gpu device
return classification accuracy of the model on val dataset
"""
# evaluate the model
model.eval()
# context-manager that disabled gradient computation
with torch.no_grad():
correct = 0
total = 0
for i, (images, targets) in enumerate(val_loader):
# device: cpu or gpu
images = images.to(device)
targets = targets.to(device)
outputs = model(images)
# return the maximum value of each row of the input tensor in the
# given dimension dim, the second return vale is the index location
# of each maxium value found(argmax)
_, predicted = torch.max(outputs.data, dim=1)
correct += (predicted == targets).sum().item()
total += targets.size(0)
accuracy = correct / total
print('Accuracy on Test Set: {:.4f} %'.format(100 * accuracy))
return accuracy
def save_model(model, save_path):
# save model
torch.save(model.state_dict(), save_path)
import matplotlib.pyplot as plt
def show_curve(ys, title):
"""
plot curlve for Loss and Accuacy
Args:
ys: loss or acc list
title: loss or accuracy
"""
x = np.array(range(len(ys)))
y = np.array(ys)
plt.plot(x, y, c='b')
plt.axis()
plt.title('{} curve'.format(title))
plt.xlabel('epoch')
plt.ylabel('{}'.format(title))
plt.show()
def fit(model, num_epochs, optimizer, device):
"""
train and evaluate an classifier num_epochs times.
n and evaluate an classifier num_epochs times.
We use optimizer and cross entropy loss to train the model.
Args:
model: CNN network
num_epochs: the number of training epochs
optimizer: optimize the loss function loss_func.to(device)
loss_func.to(device)
"""
# loss and optimizer
loss_func = nn.CrossEntropyLoss()
model.to(device)
loss_func.to(device)
# log train loss and test accuracy
losses = []
accs = []
for epoch in range(num_epochs):
print('Epoch {}/{}:'.format(epoch + 1, num_epochs))
# train step
loss = train(model, train_loader, loss_func, optimizer, device)
losses.append(loss)
# evaluate step
accuracy = evaluate(model, test_loader, device)
accs.append(accuracy)
# show curve
show_curve(losses, "train loss")
show_curve(accs, "test accuracy")
from torch.utils.data import Dataset
import torch.utils.data as Data
import torchvision.transforms as transforms
import torchvision
BATCH_SIZE = 100
transform = transforms.Compose(
[transforms.ToTensor(),
transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))])
train_data=torchvision.datasets.CIFAR10(
root='E:/cifar10',
train=True,
transform=transform
)
train_loader = Data.DataLoader(
dataset=train_data,
batch_size=BATCH_SIZE,
shuffle=True
)
test_data=torchvision.datasets.CIFAR10(
root='E:/cifar10',
train=False,
transform=transform
)
test_loader = torch.utils.data.DataLoader(test_data, batch_size=BATCH_SIZE,
shuffle=False)
resnet.load_state_dict(torch.load('resnet18.cpk'))
# Hyper-parameters
num_epochs = 5
lr = 0.001
# Device configuration
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# optimizer
optimizer = torch.optim.Adam(resnet.parameters(), lr=lr)
fit(resnet, num_epochs, optimizer, device)
網絡比較深層, 需要比之前更多的迭代epoch次數, 最好用GPU. 這裏跑了15個epoch, 可以觀察到準確率能穩步提升到85以上, 並且還有增長的空間.
反觀我們之前實現的4卷積層簡單網絡, 只能把正確率拉到70以上. 這也證明了深度對性能的影響.
SN殘差
Squeeze-and-Excitation的技巧是比較新的(2017), 用於提升ResNet的殘差模塊性能的一種技巧. 它通過把殘差模塊的輸出x做額外的全局池化, 再做編碼-譯碼以得到"每個通道的重要度打分". 這個重要度應該體現在輸出上, 所以我們把這個分數直接乘在輸出上.
這種思想類似我們後面RNN中常用的attention, 是一種end2end的設計思想. 雖然可能很不可思議, 但是實踐上這個方法是切實能提升模型性能的.
class SELayer(nn.Module):
def __init__(self, channel, reduction=16):
super(SELayer, self).__init__()
self.avg_pool = nn.AdaptiveAvgPool2d(1)
self.fc = nn.Sequential(
nn.Linear(channel, channel // reduction, bias=False),
nn.ReLU(inplace=True),
nn.Linear(channel // reduction, channel, bias=False),
nn.Sigmoid()
)
def forward(self, x):
b, c, _, _ = x.size()
y = self.avg_pool(x).view(b, c)
y = self.fc(y).view(b, c, 1, 1)
return x * y.expand_as(x)
class SEResidualBlock(nn.Module):
def __init__(self, in_channels, out_channels, stride=1, downsample=None, reduction=16):
super(SEResidualBlock, self).__init__()
self.res = ResidualBlock(in_channels, out_channels, stride, downsample)
self.se = SELayer(out_channels, reduction)
def forward(self, x):
residual = x
out = self.res(x)
out = self.se(out)
out += residual
return out