计算机视觉:基于眼疾分类数据集iChallenge-PM图像分类经典模型剖析(LeNet,AlexNet,VGG,GoogLeNet,ResNet)
計算機視覺:圖像分類經典模型
- LeNet
- AlexNet
- VGG
- GoogLeNet
- ResNet
圖像分類是根據圖像的語義信息對不同類別圖像進行區分,是計算機視覺的核心,是物體檢測、圖像分割、物體跟蹤、行為分析、人臉識別等其他高層次視覺任務的基礎。圖像分類在許多領域都有著廣泛的應用,如:安防領域的人臉識別和智能視頻分析等,交通領域的交通場景識別,互聯網領域基于內容的圖像檢索和相冊自動歸類,醫學領域的圖像識別等。
本節將基于眼疾分類數據集iChallenge-PM,對圖像分類領域的經典卷積神經網絡進行剖析,介紹如何應用這些基礎模塊構建卷積神經網絡,解決圖像分類問題。涵蓋如下卷積神經網絡:
-
LeNet:Yan LeCun等人于1998年第一次將卷積神經網絡應用到圖像分類任務上,在手寫數字識別任務上取得了巨大成功。
-
AlexNet:Alex Krizhevsky等人在2012年提出了AlexNet, 并應用在大尺寸圖片數據集ImageNet上,獲得了2012年ImageNet比賽冠軍(ImageNet Large Scale Visual Recognition Challenge,ILSVRC)。
-
VGG:Simonyan和Zisserman于2014年提出了VGG網絡結構,是當前最流行的卷積神經網絡之一,由于其結構簡單、應用性極強而深受廣大研究者歡迎。
-
GoogLeNet:Christian Szegedy等人在2014提出了GoogLeNet,并取得了2014年ImageNet比賽冠軍。
-
ResNet:Kaiming He等人在2015年提出了ResNet,通過引入殘差模塊加深網絡層數,在ImagNet數據集上的錯誤率降低到3.6%,超越了人眼識別水平。ResNet的設計思想深刻地影響了后來的深度神經網絡的設計。
LeNet
LeNet是最早的卷積神經網絡之一。1998年,Yan LeCun第一次將LeNet卷積神經網絡應用到圖像分類上,在手寫數字識別任務中取得了巨大成功。LeNet通過連續使用卷積和池化層的組合提取圖像特征,其架構如 圖1 所示,這里展示的是作者論文中的LeNet-5模型:
圖1:LeNet模型網絡結構示意圖
-
第一模塊:包含5×5的6通道卷積和2×2的池化。卷積提取圖像中包含的特征模式(激活函數使用sigmoid),圖像尺寸從32減小到28。經過池化層可以降低輸出特征圖對空間位置的敏感性,圖像尺寸減到14。
-
第二模塊:和第一模塊尺寸相同,通道數由6增加為16。卷積操作使圖像尺寸減小到10,經過池化后變成5。
-
第三模塊:包含5×5的120通道卷積。卷積之后的圖像尺寸減小到1,但是通道數增加為120。將經過第3次卷積提取到的特征圖輸入到全連接層。第一個全連接層的輸出神經元的個數是64,第二個全連接層的輸出神經元個數是分類標簽的類別數,對于手寫數字識別其大小是10。然后使用Softmax激活函數即可計算出每個類別的預測概率。
【提示】:
卷積層的輸出特征圖如何當作全連接層的輸入使用呢?
卷積層的輸出數據格式是[N,C,H,W][N, C, H, W][N,C,H,W],在輸入全連接層的時候,會自動將數據拉平,
也就是對每個樣本,自動將其轉化為長度為KKK的向量,
其中K=C×H×WK = C \times H \times WK=C×H×W,一個mini-batch的數據維度變成了N×KN\times KN×K的二維向量。
LeNet在手寫數字識別上的應用
LeNet網絡的實現代碼如下:
# 導入需要的包 import paddle import paddle.fluid as fluid import numpy as np from paddle.fluid.dygraph.nn import Conv2D, Pool2D, Linear# 定義 LeNet 網絡結構 class LeNet(fluid.dygraph.Layer):def __init__(self, num_classes=1):super(LeNet, self).__init__()# 創建卷積和池化層塊,每個卷積層使用Sigmoid激活函數,后面跟著一個2x2的池化self.conv1 = Conv2D(num_channels=1, num_filters=6, filter_size=5, act='sigmoid')self.pool1 = Pool2D(pool_size=2, pool_stride=2, pool_type='max')self.conv2 = Conv2D(num_channels=6, num_filters=16, filter_size=5, act='sigmoid')self.pool2 = Pool2D(pool_size=2, pool_stride=2, pool_type='max')# 創建第3個卷積層self.conv3 = Conv2D(num_channels=16, num_filters=120, filter_size=4, act='sigmoid')# 創建全連接層,第一個全連接層的輸出神經元個數為64, 第二個全連接層輸出神經元個數為分類標簽的類別數self.fc1 = Linear(input_dim=120, output_dim=64, act='sigmoid')self.fc2 = Linear(input_dim=64, output_dim=num_classes)# 網絡的前向計算過程def forward(self, x):x = self.conv1(x)x = self.pool1(x)x = self.conv2(x)x = self.pool2(x)x = self.conv3(x)x = fluid.layers.reshape(x, [x.shape[0], -1])x = self.fc1(x)x = self.fc2(x)return x下面的程序使用隨機數作為輸入,查看經過LeNet-5的每一層作用之后,輸出數據的形狀
# 輸入數據形狀是 [N, 1, H, W] # 這里用np.random創建一個隨機數組作為輸入數據 x = np.random.randn(*[3,1,28,28]) x = x.astype('float32') with fluid.dygraph.guard():# 創建LeNet類的實例,指定模型名稱和分類的類別數目m = LeNet(num_classes=10)# 通過調用LeNet從基類繼承的sublayers()函數,# 查看LeNet中所包含的子層print(m.sublayers())x = fluid.dygraph.to_variable(x)for item in m.sublayers():# item是LeNet類中的一個子層# 查看經過子層之后的輸出數據形狀try:x = item(x)except:x = fluid.layers.reshape(x, [x.shape[0], -1])x = item(x)if len(item.parameters())==2:# 查看卷積和全連接層的數據和參數的形狀,# 其中item.parameters()[0]是權重參數w,item.parameters()[1]是偏置參數bprint(item.full_name(), x.shape, item.parameters()[0].shape, item.parameters()[1].shape)else:# 池化層沒有參數print(item.full_name(), x.shape) # -*- coding: utf-8 -*-# LeNet 識別手寫數字import os import random import paddle import paddle.fluid as fluid import numpy as np# 定義訓練過程 def train(model):print('start training ... ')model.train()epoch_num = 5opt = fluid.optimizer.Momentum(learning_rate=0.001, momentum=0.9, parameter_list=model.parameters())# 使用Paddle自帶的數據讀取器train_loader = paddle.batch(paddle.dataset.mnist.train(), batch_size=10)valid_loader = paddle.batch(paddle.dataset.mnist.test(), batch_size=10)for epoch in range(epoch_num):for batch_id, data in enumerate(train_loader()):# 調整輸入數據形狀和類型x_data = np.array([item[0] for item in data], dtype='float32').reshape(-1, 1, 28, 28)y_data = np.array([item[1] for item in data], dtype='int64').reshape(-1, 1)# 將numpy.ndarray轉化成Tensorimg = fluid.dygraph.to_variable(x_data)label = fluid.dygraph.to_variable(y_data)# 計算模型輸出logits = model(img)# 計算損失函數loss = fluid.layers.softmax_with_cross_entropy(logits, label)avg_loss = fluid.layers.mean(loss)if batch_id % 1000 == 0:print("epoch: {}, batch_id: {}, loss is: {}".format(epoch, batch_id, avg_loss.numpy()))avg_loss.backward()opt.minimize(avg_loss)model.clear_gradients()model.eval()accuracies = []losses = []for batch_id, data in enumerate(valid_loader()):# 調整輸入數據形狀和類型x_data = np.array([item[0] for item in data], dtype='float32').reshape(-1, 1, 28, 28)y_data = np.array([item[1] for item in data], dtype='int64').reshape(-1, 1)# 將numpy.ndarray轉化成Tensorimg = fluid.dygraph.to_variable(x_data)label = fluid.dygraph.to_variable(y_data)# 計算模型輸出logits = model(img)pred = fluid.layers.softmax(logits)# 計算損失函數loss = fluid.layers.softmax_with_cross_entropy(logits, label)acc = fluid.layers.accuracy(pred, label)accuracies.append(acc.numpy())losses.append(loss.numpy())print("[validation] accuracy/loss: {}/{}".format(np.mean(accuracies), np.mean(losses)))model.train()# 保存模型參數fluid.save_dygraph(model.state_dict(), 'mnist')if __name__ == '__main__':# 創建模型with fluid.dygraph.guard():model = LeNet(num_classes=10)#啟動訓練過程train(model)LeNet在眼疾識別數據集iChallenge-PM上的應用
iChallenge-PM是百度大腦和中山大學中山眼科中心聯合舉辦的iChallenge比賽中,提供的關于病理性近視(Pathologic Myopia,PM)的醫療類數據集,包含1200個受試者的眼底視網膜圖片,訓練、驗證和測試數據集各400張。下面我們詳細介紹LeNet在iChallenge-PM上的訓練過程。
說明:
如今近視已經成為困擾人們健康的一項全球性負擔,在近視人群中,有超過35%的人患有重度近視。近視會拉長眼睛的光軸,也可能引起視網膜或者絡網膜的病變。隨著近視度數的不斷加深,高度近視有可能引發病理性病變,這將會導致以下幾種癥狀:視網膜或者絡網膜發生退化、視盤區域萎縮、漆裂樣紋損害、Fuchs斑等。因此,及早發現近視患者眼睛的病變并采取治療,顯得非常重要。
數據可以從AIStudio下載
查看數據集圖片
iChallenge-PM中既有病理性近視患者的眼底圖片,也有非病理性近視患者的圖片,命名規則如下:
-
病理性近視(PM):文件名以P開頭
-
非病理性近視(non-PM):
-
高度近視(high myopia):文件名以H開頭
-
正常眼睛(normal):文件名以N開頭
-
我們將病理性患者的圖片作為正樣本,標簽為1; 非病理性患者的圖片作為負樣本,標簽為0。從數據集中選取兩張圖片,通過LeNet提取特征,構建分類器,對正負樣本進行分類,并將圖片顯示出來。代碼如下所示:
import os import numpy as np import matplotlib.pyplot as plt %matplotlib inline from PIL import ImageDATADIR = '/home/aistudio/work/palm/PALM-Training400/PALM-Training400' # 文件名以N開頭的是正常眼底圖片,以P開頭的是病變眼底圖片 file1 = 'N0012.jpg' file2 = 'P0095.jpg'# 讀取圖片 img1 = Image.open(os.path.join(DATADIR, file1)) img1 = np.array(img1) img2 = Image.open(os.path.join(DATADIR, file2)) img2 = np.array(img2)# 畫出讀取的圖片 plt.figure(figsize=(16, 8)) f = plt.subplot(121) f.set_title('Normal', fontsize=20) plt.imshow(img1) f = plt.subplot(122) f.set_title('PM', fontsize=20) plt.imshow(img2) plt.show()
定義數據讀取器
使用OpenCV從磁盤讀入圖片,將每張圖縮放到224×224224\times224224×224大小,并且將像素值調整到[?1,1][-1, 1][?1,1]之間,代碼如下所示:
import cv2 import random import numpy as np# 對讀入的圖像數據進行預處理 def transform_img(img):# 將圖片尺寸縮放道 224x224img = cv2.resize(img, (224, 224))# 讀入的圖像數據格式是[H, W, C]# 使用轉置操作將其變成[C, H, W]img = np.transpose(img, (2,0,1))img = img.astype('float32')# 將數據范圍調整到[-1.0, 1.0]之間img = img / 255.img = img * 2.0 - 1.0return img# 定義訓練集數據讀取器 def data_loader(datadir, batch_size=10, mode = 'train'):# 將datadir目錄下的文件列出來,每條文件都要讀入filenames = os.listdir(datadir)def reader():if mode == 'train':# 訓練時隨機打亂數據順序random.shuffle(filenames)batch_imgs = []batch_labels = []for name in filenames:filepath = os.path.join(datadir, name)img = cv2.imread(filepath)img = transform_img(img)if name[0] == 'H' or name[0] == 'N':# H開頭的文件名表示高度近似,N開頭的文件名表示正常視力# 高度近視和正常視力的樣本,都不是病理性的,屬于負樣本,標簽為0label = 0elif name[0] == 'P':# P開頭的是病理性近視,屬于正樣本,標簽為1label = 1else:raise('Not excepted file name')# 每讀取一個樣本的數據,就將其放入數據列表中batch_imgs.append(img)batch_labels.append(label)if len(batch_imgs) == batch_size:# 當數據列表的長度等于batch_size的時候,# 把這些數據當作一個mini-batch,并作為數據生成器的一個輸出imgs_array = np.array(batch_imgs).astype('float32')labels_array = np.array(batch_labels).astype('float32').reshape(-1, 1)yield imgs_array, labels_arraybatch_imgs = []batch_labels = []if len(batch_imgs) > 0:# 剩余樣本數目不足一個batch_size的數據,一起打包成一個mini-batchimgs_array = np.array(batch_imgs).astype('float32')labels_array = np.array(batch_labels).astype('float32').reshape(-1, 1)yield imgs_array, labels_arrayreturn reader# 定義驗證集數據讀取器 def valid_data_loader(datadir, csvfile, batch_size=10, mode='valid'):# 訓練集讀取時通過文件名來確定樣本標簽,驗證集則通過csvfile來讀取每個圖片對應的標簽# 請查看解壓后的驗證集標簽數據,觀察csvfile文件里面所包含的內容# csvfile文件所包含的內容格式如下,每一行代表一個樣本,# 其中第一列是圖片id,第二列是文件名,第三列是圖片標簽,# 第四列和第五列是Fovea的坐標,與分類任務無關# ID,imgName,Label,Fovea_X,Fovea_Y# 1,V0001.jpg,0,1157.74,1019.87# 2,V0002.jpg,1,1285.82,1080.47# 打開包含驗證集標簽的csvfile,并讀入其中的內容filelists = open(csvfile).readlines()def reader():batch_imgs = []batch_labels = []for line in filelists[1:]:line = line.strip().split(',')name = line[1]label = int(line[2])# 根據圖片文件名加載圖片,并對圖像數據作預處理filepath = os.path.join(datadir, name)img = cv2.imread(filepath)img = transform_img(img)# 每讀取一個樣本的數據,就將其放入數據列表中batch_imgs.append(img)batch_labels.append(label)if len(batch_imgs) == batch_size:# 當數據列表的長度等于batch_size的時候,# 把這些數據當作一個mini-batch,并作為數據生成器的一個輸出imgs_array = np.array(batch_imgs).astype('float32')labels_array = np.array(batch_labels).astype('float32').reshape(-1, 1)yield imgs_array, labels_arraybatch_imgs = []batch_labels = []if len(batch_imgs) > 0:# 剩余樣本數目不足一個batch_size的數據,一起打包成一個mini-batchimgs_array = np.array(batch_imgs).astype('float32')labels_array = np.array(batch_labels).astype('float32').reshape(-1, 1)yield imgs_array, labels_arrayreturn reader # 查看數據形狀 DATADIR = '/home/aistudio/work/palm/PALM-Training400/PALM-Training400' train_loader = data_loader(DATADIR, batch_size=10, mode='train') data_reader = train_loader() data = next(data_reader) data[0].shape, data[1].shapeeval_loader = data_loader(DATADIR, batch_size=10, mode='eval') data_reader = eval_loader() data = next(data_reader) data[0].shape, data[1].shapeLeNet 識別眼疾圖片
# -*- coding: utf-8 -*-# LeNet 識別眼疾圖片import os import random import paddle import paddle.fluid as fluid import numpy as npDATADIR = '/home/aistudio/work/palm/PALM-Training400/PALM-Training400' DATADIR2 = '/home/aistudio/work/palm/PALM-Validation400' CSVFILE = '/home/aistudio/labels.csv'# 定義訓練過程 def train(model):with fluid.dygraph.guard():print('start training ... ')model.train()epoch_num = 5# 定義優化器opt = fluid.optimizer.Momentum(learning_rate=0.001, momentum=0.9, parameter_list=model.parameters())# 定義數據讀取器,訓練數據讀取器和驗證數據讀取器train_loader = data_loader(DATADIR, batch_size=10, mode='train')valid_loader = valid_data_loader(DATADIR2, CSVFILE)for epoch in range(epoch_num):for batch_id, data in enumerate(train_loader()):x_data, y_data = dataimg = fluid.dygraph.to_variable(x_data)label = fluid.dygraph.to_variable(y_data)# 運行模型前向計算,得到預測值logits = model(img)# 進行loss計算loss = fluid.layers.sigmoid_cross_entropy_with_logits(logits, label)avg_loss = fluid.layers.mean(loss)if batch_id % 10 == 0:print("epoch: {}, batch_id: {}, loss is: {}".format(epoch, batch_id, avg_loss.numpy()))# 反向傳播,更新權重,清除梯度avg_loss.backward()opt.minimize(avg_loss)model.clear_gradients()model.eval()accuracies = []losses = []for batch_id, data in enumerate(valid_loader()):x_data, y_data = dataimg = fluid.dygraph.to_variable(x_data)label = fluid.dygraph.to_variable(y_data)# 運行模型前向計算,得到預測值logits = model(img)# 二分類,sigmoid計算后的結果以0.5為閾值分兩個類別# 計算sigmoid后的預測概率,進行loss計算pred = fluid.layers.sigmoid(logits)loss = fluid.layers.sigmoid_cross_entropy_with_logits(logits, label)# 計算預測概率小于0.5的類別pred2 = pred * (-1.0) + 1.0# 得到兩個類別的預測概率,并沿第一個維度級聯pred = fluid.layers.concat([pred2, pred], axis=1)acc = fluid.layers.accuracy(pred, fluid.layers.cast(label, dtype='int64'))accuracies.append(acc.numpy())losses.append(loss.numpy())print("[validation] accuracy/loss: {}/{}".format(np.mean(accuracies), np.mean(losses)))model.train()# save params of modelfluid.save_dygraph(model.state_dict(), 'palm')# save optimizer statefluid.save_dygraph(opt.state_dict(), 'palm')# 定義評估過程 def evaluation(model, params_file_path):with fluid.dygraph.guard():print('start evaluation .......')#加載模型參數model_state_dict, _ = fluid.load_dygraph(params_file_path)model.load_dict(model_state_dict)model.eval()eval_loader = data_loader(DATADIR, batch_size=10, mode='eval')acc_set = []avg_loss_set = []for batch_id, data in enumerate(eval_loader()):x_data, y_data = dataimg = fluid.dygraph.to_variable(x_data)label = fluid.dygraph.to_variable(y_data)y_data = y_data.astype(np.int64)label_64 = fluid.dygraph.to_variable(y_data)# 計算預測和精度prediction, acc = model(img, label_64)# 計算損失函數值loss = fluid.layers.sigmoid_cross_entropy_with_logits(prediction, label)avg_loss = fluid.layers.mean(loss)acc_set.append(float(acc.numpy()))avg_loss_set.append(float(avg_loss.numpy()))# 求平均精度acc_val_mean = np.array(acc_set).mean()avg_loss_val_mean = np.array(avg_loss_set).mean()print('loss={}, acc={}'.format(avg_loss_val_mean, acc_val_mean))# 導入需要的包 import paddle import paddle.fluid as fluid import numpy as np from paddle.fluid.dygraph.nn import Conv2D, Pool2D, Linear# 定義 LeNet 網絡結構 class LeNet(fluid.dygraph.Layer):def __init__(self, num_classes=1):super(LeNet, self).__init__()# 創建卷積和池化層塊,每個卷積層使用Sigmoid激活函數,后面跟著一個2x2的池化self.conv1 = Conv2D(num_channels=3, num_filters=6, filter_size=5, act='sigmoid')self.pool1 = Pool2D(pool_size=2, pool_stride=2, pool_type='max')self.conv2 = Conv2D(num_channels=6, num_filters=16, filter_size=5, act='sigmoid')self.pool2 = Pool2D(pool_size=2, pool_stride=2, pool_type='max')# 創建第3個卷積層self.conv3 = Conv2D(num_channels=16, num_filters=120, filter_size=4, act='sigmoid')# 創建全連接層,第一個全連接層的輸出神經元個數為64, 第二個全連接層輸出神經元個數為分類標簽的類別數self.fc1 = Linear(input_dim=300000, output_dim=64, act='sigmoid')self.fc2 = Linear(input_dim=64, output_dim=num_classes)# 網絡的前向計算過程def forward(self, x, label=None):x = self.conv1(x)x = self.pool1(x)x = self.conv2(x)x = self.pool2(x)x = self.conv3(x)x = fluid.layers.reshape(x, [x.shape[0], -1])x = self.fc1(x)x = self.fc2(x)if label is not None:acc = fluid.layers.accuracy(input=x, label=label)return x, accelse:return xif __name__ == '__main__':# 創建模型with fluid.dygraph.guard():model = LeNet(num_classes=1)train(model)# evaluation(model, params_file_path="palm")通過運行結果可以看出,在眼疾篩查數據集iChallenge-PM上,LeNet的loss很難下降,模型沒有收斂。這是因為MNIST數據集的圖片尺寸比較小(28×2828\times2828×28),但是眼疾篩查數據集圖片尺寸比較大(原始圖片尺寸約為2000×20002000 \times 20002000×2000,經過縮放之后變成224×224224 \times 224224×224),LeNet模型很難進行有效分類。這說明在圖片尺寸比較大時,LeNet在圖像分類任務上存在局限性。
AlexNet
通過上面的實際訓練可以看到,雖然LeNet在手寫數字識別數據集上取得了很好的結果,但在更大的數據集上表現卻并不好。自從1998年LeNet問世以來,接下來十幾年的時間里,神經網絡并沒有在計算機視覺領域取得很好的結果,反而一度被其它算法所超越,原因主要有兩方面,一是神經網絡的計算比較復雜,對當時計算機的算力來說,訓練神經網絡是件非常耗時的事情;另一方面,當時還沒有專門針對神經網絡做算法和訓練技巧的優化,神經網絡的收斂是件非常困難的事情。
隨著技術的進步和發展,計算機的算力越來越強大,尤其是在GPU并行計算能力的推動下,復雜神經網絡的計算也變得更加容易實施。另一方面,互聯網上涌現出越來越多的數據,極大的豐富了數據庫。同時也有越來越多的研究人員開始專門針對神經網絡做算法和模型的優化,Alex Krizhevsky等人提出的AlexNet以很大優勢獲得了2012年ImageNet比賽的冠軍。這一成果極大的激發了產業界對神經網絡的興趣,開創了使用深度神經網絡解決圖像問題的途徑,隨后也在這一領域涌現出越來越多的優秀成果。
AlexNet與LeNet相比,具有更深的網絡結構,包含5層卷積和3層全連接,同時使用了如下三種方法改進模型的訓練過程:
-
數據增廣:深度學習中常用的一種處理方式,通過對訓練隨機加一些變化,比如平移、縮放、裁剪、旋轉、翻轉或者增減亮度等,產生一系列跟原始圖片相似但又不完全相同的樣本,從而擴大訓練數據集。通過這種方式,可以隨機改變訓練樣本,避免模型過度依賴于某些屬性,能從一定程度上抑制過擬合。
-
使用Dropout抑制過擬合
-
使用ReLU激活函數減少梯度消失現象
AlexNet的具體結構如 圖2 所示:
圖2:AlexNet模型網絡結構示意圖
AlexNet在眼疾篩查數據集iChallenge-PM上具體實現的代碼如下所示:
# -*- coding:utf-8 -*-# 導入需要的包 import paddle import paddle.fluid as fluid import numpy as np from paddle.fluid.dygraph.nn import Conv2D, Pool2D, Linear# 定義 AlexNet 網絡結構 class AlexNet(fluid.dygraph.Layer):def __init__(self, num_classes=1):super(AlexNet, self).__init__()# AlexNet與LeNet一樣也會同時使用卷積和池化層提取圖像特征# 與LeNet不同的是激活函數換成了‘relu’self.conv1 = Conv2D(num_channels=3, num_filters=96, filter_size=11, stride=4, padding=5, act='relu')self.pool1 = Pool2D(pool_size=2, pool_stride=2, pool_type='max')self.conv2 = Conv2D(num_channels=96, num_filters=256, filter_size=5, stride=1, padding=2, act='relu')self.pool2 = Pool2D(pool_size=2, pool_stride=2, pool_type='max')self.conv3 = Conv2D(num_channels=256, num_filters=384, filter_size=3, stride=1, padding=1, act='relu')self.conv4 = Conv2D(num_channels=384, num_filters=384, filter_size=3, stride=1, padding=1, act='relu')self.conv5 = Conv2D(num_channels=384, num_filters=256, filter_size=3, stride=1, padding=1, act='relu')self.pool5 = Pool2D(pool_size=2, pool_stride=2, pool_type='max')self.fc1 = Linear(input_dim=12544, output_dim=4096, act='relu')self.drop_ratio1 = 0.5self.fc2 = Linear(input_dim=4096, output_dim=4096, act='relu')self.drop_ratio2 = 0.5self.fc3 = Linear(input_dim=4096, output_dim=num_classes)def forward(self, x):x = self.conv1(x)x = self.pool1(x)x = self.conv2(x)x = self.pool2(x)x = self.conv3(x)x = self.conv4(x)x = self.conv5(x)x = self.pool5(x)x = fluid.layers.reshape(x, [x.shape[0], -1])x = self.fc1(x)# 在全連接之后使用dropout抑制過擬合x= fluid.layers.dropout(x, self.drop_ratio1)x = self.fc2(x)# 在全連接之后使用dropout抑制過擬合x = fluid.layers.dropout(x, self.drop_ratio2)x = self.fc3(x)return x通過運行結果可以發現,在眼疾篩查數據集iChallenge-PM上使用AlexNet,loss能有效下降,經過5個epoch的訓練,在驗證集上的準確率可以達到94%左右。
VGG
VGG是當前最流行的CNN模型之一,2014年由Simonyan和Zisserman提出,其命名來源于論文作者所在的實驗室Visual Geometry Group。AlexNet模型通過構造多層網絡,取得了較好的效果,但是并沒有給出深度神經網絡設計的方向。VGG通過使用一系列大小為3x3的小尺寸卷積核和pooling層構造深度卷積神經網絡,并取得了較好的效果。VGG模型因為結構簡單、應用性極強而廣受研究者歡迎,尤其是它的網絡結構設計方法,為構建深度神經網絡提供了方向。
圖3 是VGG-16的網絡結構示意圖,有13層卷積和3層全連接層。VGG網絡的設計嚴格使用3×33\times 33×3的卷積層和池化層來提取特征,并在網絡的最后面使用三層全連接層,將最后一層全連接層的輸出作為分類的預測。
在VGG中每層卷積將使用ReLU作為激活函數,在全連接層之后添加dropout來抑制過擬合。使用小的卷積核能夠有效地減少參數的個數,使得訓練和測試變得更加有效。比如使用兩層3×33\times 33×3卷積層,可以得到感受野為5的特征圖,而比使用5×55 \times 55×5的卷積層需要更少的參數。由于卷積核比較小,可以堆疊更多的卷積層,加深網絡的深度,這對于圖像分類任務來說是有利的。VGG模型的成功證明了增加網絡的深度,可以更好的學習圖像中的特征模式。
圖3:VGG模型網絡結構示意圖
VGG在眼疾識別數據集iChallenge-PM上的具體實現如下代碼所示:
# -*- coding:utf-8 -*-# VGG模型代碼 import numpy as np import paddle import paddle.fluid as fluid from paddle.fluid.layer_helper import LayerHelper from paddle.fluid.dygraph.nn import Conv2D, Pool2D, BatchNorm, Linear from paddle.fluid.dygraph.base import to_variable# 定義vgg塊,包含多層卷積和1層2x2的最大池化層 class vgg_block(fluid.dygraph.Layer):def __init__(self, num_convs, in_channels, out_channels):"""num_convs, 卷積層的數目num_channels, 卷積層的輸出通道數,在同一個Incepition塊內,卷積層輸出通道數是一樣的"""super(vgg_block, self).__init__()self.conv_list = []for i in range(num_convs):conv_layer = self.add_sublayer('conv_' + str(i), Conv2D(num_channels=in_channels, num_filters=out_channels, filter_size=3, padding=1, act='relu'))self.conv_list.append(conv_layer)in_channels = out_channelsself.pool = Pool2D(pool_stride=2, pool_size = 2, pool_type='max')def forward(self, x):for item in self.conv_list:x = item(x)return self.pool(x)class VGG(fluid.dygraph.Layer):def __init__(self, conv_arch=((2, 64), (2, 128), (3, 256), (3, 512), (3, 512))):super(VGG, self).__init__()self.vgg_blocks=[]iter_id = 0# 添加vgg_block# 這里一共5個vgg_block,每個block里面的卷積層數目和輸出通道數由conv_arch指定in_channels = [3, 64, 128, 256, 512, 512]for (num_convs, num_channels) in conv_arch:block = self.add_sublayer('block_' + str(iter_id), vgg_block(num_convs, in_channels=in_channels[iter_id], out_channels=num_channels))self.vgg_blocks.append(block)iter_id += 1self.fc1 = Linear(input_dim=512*7*7, output_dim=4096,act='relu')self.drop1_ratio = 0.5self.fc2= Linear(input_dim=4096, output_dim=4096,act='relu')self.drop2_ratio = 0.5self.fc3 = Linear(input_dim=4096, output_dim=1)def forward(self, x):for item in self.vgg_blocks:x = item(x)x = fluid.layers.reshape(x, [x.shape[0], -1])x = fluid.layers.dropout(self.fc1(x), self.drop1_ratio)x = fluid.layers.dropout(self.fc2(x), self.drop2_ratio)x = self.fc3(x)return x with fluid.dygraph.guard():model = VGG()train(model)通過運行結果可以發現,在眼疾篩查數據集iChallenge-PM上使用VGG,loss能有效的下降,經過5個epoch的訓練,在驗證集上的準確率可以達到94%左右。
GoogLeNet
GoogLeNet是2014年ImageNet比賽的冠軍,它的主要特點是網絡不僅有深度,還在橫向上具有“寬度”。由于圖像信息在空間尺寸上的巨大差異,如何選擇合適的卷積核大小來提取特征就顯得比較困難了。空間分布范圍更廣的圖像信息適合用較大的卷積核來提取其特征,而空間分布范圍較小的圖像信息則適合用較小的卷積核來提取其特征。為了解決這個問題,GoogLeNet提出了一種被稱為Inception模塊的方案。如 圖4 所示:
說明:
- Google的研究人員為了向LeNet致敬,特地將模型命名為GoogLeNet
- Inception一詞來源于電影《盜夢空間》(Inception)
圖4:Inception模塊結構示意圖
圖4(a)是Inception模塊的設計思想,使用3個不同大小的卷積核對輸入圖片進行卷積操作,并附加最大池化,將這4個操作的輸出沿著通道這一維度進行拼接,構成的輸出特征圖將會包含經過不同大小的卷積核提取出來的特征。Inception模塊采用多通路(multi-path)的設計形式,每個支路使用不同大小的卷積核,最終輸出特征圖的通道數是每個支路輸出通道數的總和,這將會導致輸出通道數變得很大,尤其是使用多個Inception模塊串聯操作的時候,模型參數量會變得非常大。為了減小參數量,Inception模塊使用了圖(b)中的設計方式,在每個3x3和5x5的卷積層之前,增加1x1的卷積層來控制輸出通道數;在最大池化層后面增加1x1卷積層減小輸出通道數。基于這一設計思想,形成了上圖(b)中所示的結構。下面這段程序是Inception塊的具體實現方式,可以對照圖(b)和代碼一起閱讀。
提示:
可能有讀者會問,經過3x3的最大池化之后圖像尺寸不會減小嗎,為什么還能跟另外3個卷積輸出的特征圖進行拼接?這是因為池化操作可以指定窗口大小kh=kw=3k_h = k_w = 3kh?=kw?=3,pool_stride=1和pool_padding=1,輸出特征圖尺寸可以保持不變。
Inception模塊的具體實現如下代碼所示:
class Inception(fluid.dygraph.Layer):def __init__(self, c1, c2, c3, c4, **kwargs):'''Inception模塊的實現代碼,c1, 圖(b)中第一條支路1x1卷積的輸出通道數,數據類型是整數c2,圖(b)中第二條支路卷積的輸出通道數,數據類型是tuple或list, 其中c2[0]是1x1卷積的輸出通道數,c2[1]是3x3c3,圖(b)中第三條支路卷積的輸出通道數,數據類型是tuple或list, 其中c3[0]是1x1卷積的輸出通道數,c3[1]是3x3c4, 圖(b)中第一條支路1x1卷積的輸出通道數,數據類型是整數'''super(Inception, self).__init__()# 依次創建Inception塊每條支路上使用到的操作self.p1_1 = Conv2D(num_filters=c1, filter_size=1, act='relu')self.p2_1 = Conv2D(num_filters=c2[0], filter_size=1, act='relu')self.p2_2 = Conv2D(num_filters=c2[1], filter_size=3, padding=1, act='relu')self.p3_1 = Conv2D(num_filters=c3[0], filter_size=1, act='relu')self.p3_2 = Conv2D(num_filters=c3[1], filter_size=5, padding=2, act='relu')self.p4_1 = Pool2D(pool_size=3, pool_stride=1, pool_padding=1, pool_type='max')self.p4_2 = Conv2D(num_filters=c4, filter_size=1, act='relu')def forward(self, x):# 支路1只包含一個1x1卷積p1 = self.p1_1(x)# 支路2包含 1x1卷積 + 3x3卷積p2 = self.p2_2(self.p2_1(x))# 支路3包含 1x1卷積 + 5x5卷積p3 = self.p3_2(self.p3_1(x))# 支路4包含 最大池化和1x1卷積p4 = self.p4_2(self.p4_1(x))# 將每個支路的輸出特征圖拼接在一起作為最終的輸出結果return fluid.layers.concat([p1, p2, p3, p4], axis=1)GoogLeNet的架構如 圖5 所示,在主體卷積部分中使用5個模塊(block),每個模塊之間使用步幅為2的3 ×3最大池化層來減小輸出高寬。
- 第一模塊使用一個64通道的7 × 7卷積層。
- 第二模塊使用2個卷積層:首先是64通道的1 × 1卷積層,然后是將通道增大3倍的3 × 3卷積層。
- 第三模塊串聯2個完整的Inception塊。
- 第四模塊串聯了5個Inception塊。
- 第五模塊串聯了2 個Inception塊。
- 第五模塊的后面緊跟輸出層,使用全局平均池化層來將每個通道的高和寬變成1,最后接上一個輸出個數為標簽類別數的全連接層。
說明:
在原作者的論文中添加了圖中所示的softmax1和softmax2兩個輔助分類器,如下圖所示,訓練時將三個分類器的損失函數進行加權求和,以緩解梯度消失現象。這里的程序作了簡化,沒有加入輔助分類器。
圖5:GoogLeNet模型網絡結構示意圖
GoogLeNet的具體實現如下代碼所示:
# -*- coding:utf-8 -*-# GoogLeNet模型代碼 import numpy as np import paddle import paddle.fluid as fluid from paddle.fluid.layer_helper import LayerHelper from paddle.fluid.dygraph.nn import Conv2D, Pool2D, BatchNorm, Linear from paddle.fluid.dygraph.base import to_variable# 定義Inception塊 class Inception(fluid.dygraph.Layer):def __init__(self, c0,c1, c2, c3, c4, **kwargs):'''Inception模塊的實現代碼,c1, 圖(b)中第一條支路1x1卷積的輸出通道數,數據類型是整數c2,圖(b)中第二條支路卷積的輸出通道數,數據類型是tuple或list, 其中c2[0]是1x1卷積的輸出通道數,c2[1]是3x3c3,圖(b)中第三條支路卷積的輸出通道數,數據類型是tuple或list, 其中c3[0]是1x1卷積的輸出通道數,c3[1]是3x3c4, 圖(b)中第一條支路1x1卷積的輸出通道數,數據類型是整數'''super(Inception, self).__init__()# 依次創建Inception塊每條支路上使用到的操作self.p1_1 = Conv2D(num_channels=c0, num_filters=c1, filter_size=1, act='relu')self.p2_1 = Conv2D(num_channels=c0, num_filters=c2[0], filter_size=1, act='relu')self.p2_2 = Conv2D(num_channels=c2[0], num_filters=c2[1], filter_size=3, padding=1, act='relu')self.p3_1 = Conv2D(num_channels=c0, num_filters=c3[0], filter_size=1, act='relu')self.p3_2 = Conv2D(num_channels=c3[0], num_filters=c3[1], filter_size=5, padding=2, act='relu')self.p4_1 = Pool2D(pool_size=3, pool_stride=1, pool_padding=1, pool_type='max')self.p4_2 = Conv2D(num_channels=c0, num_filters=c4, filter_size=1, act='relu')def forward(self, x):# 支路1只包含一個1x1卷積p1 = self.p1_1(x)# 支路2包含 1x1卷積 + 3x3卷積p2 = self.p2_2(self.p2_1(x))# 支路3包含 1x1卷積 + 5x5卷積p3 = self.p3_2(self.p3_1(x))# 支路4包含 最大池化和1x1卷積p4 = self.p4_2(self.p4_1(x))# 將每個支路的輸出特征圖拼接在一起作為最終的輸出結果return fluid.layers.concat([p1, p2, p3, p4], axis=1) class GoogLeNet(fluid.dygraph.Layer):def __init__(self):super(GoogLeNet, self).__init__()# GoogLeNet包含五個模塊,每個模塊后面緊跟一個池化層# 第一個模塊包含1個卷積層self.conv1 = Conv2D(num_channels=3, num_filters=64, filter_size=7, padding=3, act='relu')# 3x3最大池化self.pool1 = Pool2D(pool_size=3, pool_stride=2, pool_padding=1, pool_type='max')# 第二個模塊包含2個卷積層self.conv2_1 = Conv2D(num_channels=64, num_filters=64, filter_size=1, act='relu')self.conv2_2 = Conv2D(num_channels=64, num_filters=192, filter_size=3, padding=1, act='relu')# 3x3最大池化self.pool2 = Pool2D(pool_size=3, pool_stride=2, pool_padding=1, pool_type='max')# 第三個模塊包含2個Inception塊self.block3_1 = Inception(192, 64, (96, 128), (16, 32), 32)self.block3_2 = Inception(256, 128, (128, 192), (32, 96), 64)# 3x3最大池化self.pool3 = Pool2D(pool_size=3, pool_stride=2, pool_padding=1, pool_type='max')# 第四個模塊包含5個Inception塊self.block4_1 = Inception(480, 192, (96, 208), (16, 48), 64)self.block4_2 = Inception(512, 160, (112, 224), (24, 64), 64)self.block4_3 = Inception(512, 128, (128, 256), (24, 64), 64)self.block4_4 = Inception(512, 112, (144, 288), (32, 64), 64)self.block4_5 = Inception(528, 256, (160, 320), (32, 128), 128)# 3x3最大池化self.pool4 = Pool2D(pool_size=3, pool_stride=2, pool_padding=1, pool_type='max')# 第五個模塊包含2個Inception塊self.block5_1 = Inception(832, 256, (160, 320), (32, 128), 128)self.block5_2 = Inception(832, 384, (192, 384), (48, 128), 128)# 全局池化,尺寸用的是global_pooling,pool_stride不起作用self.pool5 = Pool2D(pool_stride=1, global_pooling=True, pool_type='avg')self.fc = Linear(input_dim=1024, output_dim=1, act=None)def forward(self, x):x = self.pool1(self.conv1(x))x = self.pool2(self.conv2_2(self.conv2_1(x)))x = self.pool3(self.block3_2(self.block3_1(x)))x = self.block4_3(self.block4_2(self.block4_1(x)))x = self.pool4(self.block4_5(self.block4_4(x)))x = self.pool5(self.block5_2(self.block5_1(x)))x = fluid.layers.reshape(x, [x.shape[0], -1])x = self.fc(x)return x通過運行結果可以發現,使用GoogLeNet在眼疾篩查數據集iChallenge-PM上,loss能有效的下降,經過5個epoch的訓練,在驗證集上的準確率可以達到95%左右。
ResNet
ResNet是2015年ImageNet比賽的冠軍,將識別錯誤率降低到了3.6%,這個結果甚至超出了正常人眼識別的精度。
通過前面幾個經典模型學習,我們可以發現隨著深度學習的不斷發展,模型的層數越來越多,網絡結構也越來越復雜。那么是否加深網絡結構,就一定會得到更好的效果呢?從理論上來說,假設新增加的層都是恒等映射,只要原有的層學出跟原模型一樣的參數,那么深模型結構就能達到原模型結構的效果。換句話說,原模型的解只是新模型的解的子空間,在新模型解的空間里應該能找到比原模型解對應的子空間更好的結果。但是實踐表明,增加網絡的層數之后,訓練誤差往往不降反升。
Kaiming He等人提出了殘差網絡ResNet來解決上述問題,其基本思想如 圖6所示。
- 圖6(a):表示增加網絡的時候,將xxx映射成y=F(x)y=F(x)y=F(x)輸出。
- 圖6(b):對圖6(a)作了改進,輸出y=F(x)+xy=F(x) + xy=F(x)+x。這時不是直接學習輸出特征yyy的表示,而是學習y?xy-xy?x。
- 如果想學習出原模型的表示,只需將F(x)F(x)F(x)的參數全部設置為0,則y=xy=xy=x是恒等映射。
- F(x)=y?xF(x) = y - xF(x)=y?x也叫做殘差項,如果x→yx\rightarrow yx→y的映射接近恒等映射,圖6(b)中通過學習殘差項也比圖6(a)學習完整映射形式更加容易。
圖6:殘差塊設計思想
圖6(b)的結構是殘差網絡的基礎,這種結構也叫做殘差塊(Residual block)。輸入$x$通過跨層連接,能更快的向前傳播數據,或者向后傳播梯度。殘差塊的具體設計方案如 圖7 所示,這種設計方案也成稱作瓶頸結構(BottleNeck)。

圖7:殘差塊結構示意圖
下圖表示出了ResNet-50的結構,一共包含49層卷積和1層全連接,所以被稱為ResNet-50。
圖8:ResNet-50模型網絡結構示意圖
ResNet-50的具體實現如下代碼所示:
# -*- coding:utf-8 -*-# ResNet模型代碼 import numpy as np import paddle import paddle.fluid as fluid from paddle.fluid.layer_helper import LayerHelper from paddle.fluid.dygraph.nn import Conv2D, Pool2D, BatchNorm, Linear from paddle.fluid.dygraph.base import to_variable# ResNet中使用了BatchNorm層,在卷積層的后面加上BatchNorm以提升數值穩定性 # 定義卷積批歸一化塊 class ConvBNLayer(fluid.dygraph.Layer):def __init__(self,num_channels,num_filters,filter_size,stride=1,groups=1,act=None):"""num_channels, 卷積層的輸入通道數num_filters, 卷積層的輸出通道數stride, 卷積層的步幅groups, 分組卷積的組數,默認groups=1不使用分組卷積act, 激活函數類型,默認act=None不使用激活函數"""super(ConvBNLayer, self).__init__()# 創建卷積層self._conv = Conv2D(num_channels=num_channels,num_filters=num_filters,filter_size=filter_size,stride=stride,padding=(filter_size - 1) // 2,groups=groups,act=None,bias_attr=False)# 創建BatchNorm層self._batch_norm = BatchNorm(num_filters, act=act)def forward(self, inputs):y = self._conv(inputs)y = self._batch_norm(y)return y# 定義殘差塊 # 每個殘差塊會對輸入圖片做三次卷積,然后跟輸入圖片進行短接 # 如果殘差塊中第三次卷積輸出特征圖的形狀與輸入不一致,則對輸入圖片做1x1卷積,將其輸出形狀調整成一致 class BottleneckBlock(fluid.dygraph.Layer):def __init__(self,num_channels,num_filters,stride,shortcut=True):super(BottleneckBlock, self).__init__()# 創建第一個卷積層 1x1self.conv0 = ConvBNLayer(num_channels=num_channels,num_filters=num_filters,filter_size=1,act='relu')# 創建第二個卷積層 3x3self.conv1 = ConvBNLayer(num_channels=num_filters,num_filters=num_filters,filter_size=3,stride=stride,act='relu')# 創建第三個卷積 1x1,但輸出通道數乘以4self.conv2 = ConvBNLayer(num_channels=num_filters,num_filters=num_filters * 4,filter_size=1,act=None)# 如果conv2的輸出跟此殘差塊的輸入數據形狀一致,則shortcut=True# 否則shortcut = False,添加1個1x1的卷積作用在輸入數據上,使其形狀變成跟conv2一致if not shortcut:self.short = ConvBNLayer(num_channels=num_channels,num_filters=num_filters * 4,filter_size=1,stride=stride)self.shortcut = shortcutself._num_channels_out = num_filters * 4def forward(self, inputs):y = self.conv0(inputs)conv1 = self.conv1(y)conv2 = self.conv2(conv1)# 如果shortcut=True,直接將inputs跟conv2的輸出相加# 否則需要對inputs進行一次卷積,將形狀調整成跟conv2輸出一致if self.shortcut:short = inputselse:short = self.short(inputs)y = fluid.layers.elementwise_add(x=short, y=conv2)layer_helper = LayerHelper(self.full_name(), act='relu')return layer_helper.append_activation(y)# 定義ResNet模型 class ResNet(fluid.dygraph.Layer):def __init__(self, layers=50, class_dim=1):"""layers, 網絡層數,可以是50, 101或者152class_dim,分類標簽的類別數"""super(ResNet, self).__init__()self.layers = layerssupported_layers = [50, 101, 152]assert layers in supported_layers, \"supported layers are {} but input layer is {}".format(supported_layers, layers)if layers == 50:#ResNet50包含多個模塊,其中第2到第5個模塊分別包含3、4、6、3個殘差塊depth = [3, 4, 6, 3]elif layers == 101:#ResNet101包含多個模塊,其中第2到第5個模塊分別包含3、4、23、3個殘差塊depth = [3, 4, 23, 3]elif layers == 152:#ResNet50包含多個模塊,其中第2到第5個模塊分別包含3、8、36、3個殘差塊depth = [3, 8, 36, 3]# 殘差塊中使用到的卷積的輸出通道數num_filters = [64, 128, 256, 512]# ResNet的第一個模塊,包含1個7x7卷積,后面跟著1個最大池化層self.conv = ConvBNLayer(num_channels=3,num_filters=64,filter_size=7,stride=2,act='relu')self.pool2d_max = Pool2D(pool_size=3,pool_stride=2,pool_padding=1,pool_type='max')# ResNet的第二到第五個模塊c2、c3、c4、c5self.bottleneck_block_list = []num_channels = 64for block in range(len(depth)):shortcut = Falsefor i in range(depth[block]):bottleneck_block = self.add_sublayer('bb_%d_%d' % (block, i),BottleneckBlock(num_channels=num_channels,num_filters=num_filters[block],stride=2 if i == 0 and block != 0 else 1, # c3、c4、c5將會在第一個殘差塊使用stride=2;其余所有殘差塊stride=1shortcut=shortcut))num_channels = bottleneck_block._num_channels_outself.bottleneck_block_list.append(bottleneck_block)shortcut = True# 在c5的輸出特征圖上使用全局池化self.pool2d_avg = Pool2D(pool_size=7, pool_type='avg', global_pooling=True)# stdv用來作為全連接層隨機初始化參數的方差import mathstdv = 1.0 / math.sqrt(2048 * 1.0)# 創建全連接層,輸出大小為類別數目self.out = Linear(input_dim=2048, output_dim=class_dim,param_attr=fluid.param_attr.ParamAttr(initializer=fluid.initializer.Uniform(-stdv, stdv)))def forward(self, inputs):y = self.conv(inputs)y = self.pool2d_max(y)for bottleneck_block in self.bottleneck_block_list:y = bottleneck_block(y)y = self.pool2d_avg(y)y = fluid.layers.reshape(y, [y.shape[0], -1])y = self.out(y)return y通過運行結果可以發現,使用ResNet在眼疾篩查數據集iChallenge-PM上,loss能有效的下降,經過5個epoch的訓練,在驗證集上的準確率可以達到95%左右。
總結
以上是生活随笔為你收集整理的计算机视觉:基于眼疾分类数据集iChallenge-PM图像分类经典模型剖析(LeNet,AlexNet,VGG,GoogLeNet,ResNet)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 机器学习理论《统计学习方法》学习笔记:第
- 下一篇: 计算机视觉:目标检测的发展历程与基础概念