從零開始編寫一個(gè)寵物識(shí)別系統(tǒng)(爬蟲、模型訓(xùn)練和調(diào)優(yōu)、模型部署、Web服務(wù))
點(diǎn)擊下方卡片,關(guān)注“新機(jī)器視覺”公眾號(hào)
重磅干貨,第一時(shí)間送達(dá)
心血來潮,想從零開始編寫一個(gè)相對(duì)完整的深度學(xué)習(xí)小項(xiàng)目。想到就做,那么首先要考慮的問題是,寫什么?
思量再三,我決定寫一個(gè)寵物識(shí)別系統(tǒng),即給定一張圖片,判斷圖片上的寵物是什么。寵物種類暫定為四類——貓、狗、鼠、兔。之所以想到做這個(gè),是因?yàn)樵诓皇褂霉_數(shù)據(jù)集的情況下,寵物圖片數(shù)據(jù)集獲取的難度相對(duì)低一些。
小項(xiàng)目分為如下幾個(gè)部分:
爬蟲。從網(wǎng)絡(luò)上下載寵物圖片,構(gòu)建訓(xùn)練用的數(shù)據(jù)集。
模型構(gòu)建、訓(xùn)練和調(diào)優(yōu)。鑒于我們的數(shù)據(jù)比較少,這部分需要做遷移學(xué)習(xí)。
模型部署和 Web 服務(wù)。將訓(xùn)練好的模型部署成 Web 接口,并使用 Vue.js + Element UI 編寫測(cè)試頁面。
好嘞,開搞吧!
本文涉及到的所有代碼,均已上傳到 GitHub:
pets_classifer?(https://github.com/AaronJny/pets_classifer)
一、爬蟲
訓(xùn)練模型肯定是需要數(shù)據(jù)集的,那么數(shù)據(jù)集從哪來?因?yàn)槭菑牧汩_始嘛,假設(shè)我們做的這個(gè)問題,業(yè)內(nèi)沒有公開的數(shù)據(jù)集,我們需要自己制作數(shù)據(jù)集。
一個(gè)很簡(jiǎn)單的想法是,利用搜索引擎搜索相關(guān)圖片,使用爬蟲批量下載,然后人工去除不正確的圖片。舉個(gè)例子,我們先處理貓的圖片,步驟如下:
1.使用搜索引擎搜索貓的圖片。
2.使用爬蟲將搜索出的貓的圖片批量下載到本地,放到一個(gè)名為
cats的文件夾里面。3.人工瀏覽一遍圖片,將“不包含貓”的圖片和“除貓外還包含其他寵物(狗、鼠、兔)”的圖片從文件夾中刪除。
這樣,貓的圖片我們就搜集完成了,其他幾個(gè)類別的圖片也是類似的操作。不用擔(dān)心人工過濾圖片花費(fèi)的時(shí)間較長,全部過一遍也就二十多分鐘吧。
然后是搜索引擎的選擇。搜索引擎用的比較多的無非兩種——Google 和百度。我分別使用 Google 和百度進(jìn)行了圖片搜索,發(fā)現(xiàn)百度的搜索結(jié)果遠(yuǎn)不如 Google 準(zhǔn)確,于是就選擇了 Google,所以我的爬蟲代碼是基于 Google 編寫的,運(yùn)行我的爬蟲代碼需要你的網(wǎng)絡(luò)能夠訪問 Google。
如果你的網(wǎng)絡(luò)不能訪問 Google,可以考慮自行實(shí)現(xiàn)基于百度的爬蟲程序,邏輯都是相通的。
因?yàn)橄胱岉?xiàng)目輕量級(jí)一些,故沒有使用 scrapy 框架。爬蟲使用 requests+beautifulsoup4 實(shí)現(xiàn),并發(fā)使用 gevent 實(shí)現(xiàn)。
# -*- coding: utf-8 -*-
# @File : spider.py
# @Author : AaronJny
# @Time : 2019/12/16
# @Desc : 從谷歌下載指定圖片
from gevent import monkey
monkey.patch_all()
import functools
import logging
import os
from bs4 import BeautifulSoup
from gevent.pool import Pool
import requests
import settings
# 設(shè)置日志輸出格式
logging.basicConfig(format='%(asctime)s - %(pathname)s[line:%(lineno)d] - %(levelname)s: %(message)s',
level=logging.INFO)
# 搜索關(guān)鍵詞字典
keywords_map = settings.IMAGE_CLASS_KEYWORD_MAP
# 圖片保存根目錄
images_root = settings.IMAGES_ROOT
# 每個(gè)類別下載多少頁圖片
download_pages = settings.SPIDER_DOWNLOAD_PAGES
# 圖片編號(hào)字典,每種圖片都從0開始編號(hào),然后遞增
images_index_map = dict(zip(keywords_map.keys(), [0 for _ in keywords_map]))
# 圖片去重器
duplication_filter = set()
# 請(qǐng)求頭
headers = {
'accept-encoding': 'gzip, deflate, br',
'accept-language': 'zh-CN,zh;q=0.9',
'user-agent': 'Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2490.76 Mobile Safari/537.36',
'accept': '*/*',
'referer': 'https://www.google.com/',
'authority': 'www.google.com',
}
# 重試裝飾器
def try_again_while_except(max_times=3):
"""
當(dāng)出現(xiàn)異常時(shí),自動(dòng)重試。
連續(xù)失敗max_times次后放棄。
"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
error_cnt = 0
error_msg = ''
while error_cnt < max_times:
try:
return func(*args, **kwargs)
except Exception as e:
error_msg = str(e)
error_cnt += 1
if error_msg:
logging.error(error_msg)
return wrapper
return decorator
@try_again_while_except()
def download_image(session, image_url, image_class):
"""
從給定的url中下載圖片,并保存到指定路徑
"""
# 下載圖片
resp = session.get(image_url, timeout=20)
# 檢查圖片是否下載成功
if resp.status_code != 200:
raise Exception('Response Status Code {}!'.format(resp.status_code))
# 分配一個(gè)圖片編號(hào)
image_index = images_index_map.get(image_class, 0)
# 更新待分配編號(hào)
images_index_map[image_class] = image_index + 1
# 拼接圖片路徑
image_path = os.path.join(images_root, image_class, '{}.jpg'.format(image_index))
# 保存圖片
with open(image_path, 'wb') as f:
f.write(resp.content)
# 成功寫入了一張圖片
return True
@try_again_while_except()
def get_and_analysis_google_search_page(session, page, image_class, keyword):
"""
使用google進(jìn)行搜索,下載搜索結(jié)果頁面,解析其中的圖片地址,并對(duì)有效圖片進(jìn)一步發(fā)起請(qǐng)求
"""
logging.info('Class:{} Page:{} Processing...'.format(image_class, page + 1))
# 記錄從本頁成功下載的圖片數(shù)量
downloaded_cnt = 0
# 構(gòu)建請(qǐng)求參數(shù)
params = (
('q', keyword),
('tbm', 'isch'),
('async', '_id:islrg_c,_fmt:html'),
('asearch', 'ichunklite'),
('start', str(page * 100)),
('ijn', str(page)),
)
# 進(jìn)行搜索
resp = requests.get('https://www.google.com/search', params=params, timeout=20)
# 解析搜索結(jié)果
bsobj = BeautifulSoup(resp.content, 'lxml')
divs = bsobj.find_all('div', {'class': 'islrtb isv-r'})
for div in divs:
image_url = div.get('data-ou')
# 只有當(dāng)圖片以'.jpg','.jpeg','.png'結(jié)尾時(shí)才下載圖片
if image_url.endswith('.jpg') or image_url.endswith('.jpeg') or image_url.endswith('.png'):
# 過濾掉相同圖片
if image_url not in duplication_filter:
# 使用去重器記錄
duplication_filter.add(image_url)
# 下載圖片
flag = download_image(session, image_url, image_class)
if flag:
downloaded_cnt += 1
logging.info('Class:{} Page:{} Done. {} images downloaded.'.format(image_class, page + 1, downloaded_cnt))
def search_with_google(image_class, keyword):
"""
通過google下載數(shù)據(jù)集
"""
# 創(chuàng)建session對(duì)象
session = requests.session()
session.headers.update(headers)
# 每個(gè)類別下載10頁數(shù)據(jù)
for page in range(download_pages):
get_and_analysis_google_search_page(session, page, image_class, keyword)
def run():
# 首先,創(chuàng)建數(shù)據(jù)文件夾
if not os.path.exists(images_root):
os.mkdir(images_root)
for sub_images_dir in keywords_map.keys():
# 對(duì)于每個(gè)圖片類別都創(chuàng)建一個(gè)單獨(dú)的文件夾保存
sub_path = os.path.join(images_root, sub_images_dir)
if not os.path.exists(sub_path):
os.mkdir(sub_path)
# 開始下載,這里使用gevent的協(xié)程池進(jìn)行并發(fā)
pool = Pool(len(keywords_map))
for image_class, keyword in keywords_map.items():
pool.spawn(search_with_google, image_class, keyword)
pool.join()
if __name__ == '__main__':
run()
項(xiàng)目中涉及到的所有配置參數(shù),都提取到了settings.py中,內(nèi)容如下,以供查閱:
# -*- coding: utf-8 -*-
# @File : settings.py
# @Author : AaronJny
# @Time : 2019/12/16
# @Desc :
# ##########爬蟲############
# 圖片類別和搜索關(guān)鍵詞的映射關(guān)系
IMAGE_CLASS_KEYWORD_MAP = {
'cats': '寵物貓',
'dogs': '寵物狗',
'mouses': '寵物鼠',
'rabbits': '寵物兔'
}
# 圖片保存根目錄
IMAGES_ROOT = './images'
# 爬蟲每個(gè)類別下載多少頁圖片
SPIDER_DOWNLOAD_PAGES = 20
# #########數(shù)據(jù)###########
# 每個(gè)類別選取的圖片數(shù)量
SAMPLES_PER_CLASS = 345
# 參與訓(xùn)練的類別
CLASSES = ['cats', 'dogs', 'mouses', 'rabbits']
# 參與訓(xùn)練的類別數(shù)量
CLASS_NUM = len(CLASSES)
# 類別->編號(hào)的映射
CLASS_CODE_MAP = {
'cats': 0,
'dogs': 1,
'mouses': 2,
'rabbits': 3
}
# 編號(hào)->類別的映射
CODE_CLASS_MAP = {
0: '貓',
1: '狗',
2: '鼠',
3: '兔'
}
# 隨機(jī)數(shù)種子
RANDOM_SEED = 13 # 四個(gè)類別時(shí)樣本較為均衡的隨機(jī)數(shù)種子
# RANDOM_SEED = 19 # 三個(gè)類別時(shí)樣本較為均衡的隨機(jī)數(shù)種子
# 訓(xùn)練集比例
TRAIN_DATASET = 0.6
# 開發(fā)集比例
DEV_DATASET = 0.2
# 測(cè)試集比例
TEST_DATASET = 0.2
# mini_batch大小
BATCH_SIZE = 16
# imagenet數(shù)據(jù)集均值
IMAGE_MEAN = [0.485, 0.456, 0.406]
# imagenet數(shù)據(jù)集標(biāo)準(zhǔn)差
IMAGE_STD = [0.299, 0.224, 0.225]
# #########訓(xùn)練#########
# 學(xué)習(xí)率
LEARNING_RATE = 0.001
# 訓(xùn)練epoch數(shù)
TRAIN_EPOCHS = 30
# 保存訓(xùn)練模型的路徑
MODEL_PATH = './model.h5'
# ########Web#########
# Web服務(wù)端口
WEB_PORT = 5000
爬蟲使用 Google 進(jìn)行圖片搜索,每個(gè)寵物搜索 10 頁,下載其中的所有圖片。當(dāng)爬蟲運(yùn)行完成后,項(xiàng)目下會(huì)多出一個(gè)images文件夾,點(diǎn)進(jìn)去有四個(gè)子文件夾,分別為cats、dogs、mouses、rabbits。每一個(gè)子文件夾里面是對(duì)應(yīng)類別的寵物圖片。

其中貓圖片 600+ 張,狗圖片 600+ 張,鼠圖片 400+ 張,兔圖片 500+ 張?;ǘ喾昼姇r(shí)間,過一遍全部圖片,剔除其中不符合要求的圖片。注意,這一步是必做的,而且要認(rèn)真對(duì)待,我吃了虧的 = =
進(jìn)行一輪篩選后,剩下圖片張數(shù):
| 寵物 | 圖片數(shù)量 |
|---|---|
| 貓 | 521 |
| 狗 | 526 |
| 鼠 | 346 |
| 兔 | 345 |
考慮各類別樣本均衡的問題,無非是過采樣和欠采樣。因?yàn)槭菆D片數(shù)據(jù),也可以使用數(shù)據(jù)增強(qiáng)的手段,為圖片數(shù)量較少的類別生成一些圖片,使樣本數(shù)量均衡。但出于如下原因考慮,我直接做了欠采樣,即每個(gè)類別只選取了 345 張樣本:
使用數(shù)據(jù)增強(qiáng)的話,需要在原圖片的基礎(chǔ)上,重新生成一份數(shù)據(jù)集,嫌麻煩……
使用數(shù)據(jù)增強(qiáng)后,樣本數(shù)量比較多,無法同時(shí)讀取到內(nèi)存里面,只能寫個(gè)生成器,處理哪一部分的時(shí)候,實(shí)時(shí)從硬盤讀取。弊端有倆:① 頻繁讀取硬盤,肯定比不上所有數(shù)據(jù)都放在內(nèi)存里面,會(huì)拖慢訓(xùn)練速度;② 還是嫌麻煩……
說到底就是自己太懶了……當(dāng)然,可想而知,使用數(shù)據(jù)增強(qiáng)(在這里,數(shù)據(jù)增強(qiáng)可以作為一種過采樣的方式)使數(shù)據(jù)樣本都達(dá)到 526,訓(xùn)練的效果肯定會(huì)更好,能好多少就不知道了,有興趣的可以自行實(shí)現(xiàn),沒啥難點(diǎn),就是麻煩點(diǎn)。
下面該對(duì)數(shù)據(jù)做預(yù)處理了。很多經(jīng)典的模型接收的輸入格式都為(None,224,224,3),由于我們的樣本較少,不可避免地需要用到遷移學(xué)習(xí),所以我們的數(shù)據(jù)格式與經(jīng)典模型保持一致,也使用(None,224,224,3),下面是預(yù)處理過程:
# -*- coding: utf-8 -*-
# @File : data.py
# @Author : AaronJny
# @Time : 2019/12/16
# @Desc :
import os
import random
import tensorflow as tf
import settings
# 每個(gè)類別選取的圖片數(shù)量
samples_per_class = settings.SAMPLES_PER_CLASS
# 圖片根目錄
images_root = settings.IMAGES_ROOT
# 類別->編碼的映射
class_code_map = settings.CLASS_CODE_MAP
# 我們準(zhǔn)備使用經(jīng)典網(wǎng)絡(luò)在imagenet數(shù)據(jù)集上的與訓(xùn)練權(quán)重,所以歸一化時(shí)也要使用imagenet的平均值和標(biāo)準(zhǔn)差
image_mean = tf.constant(settings.IMAGE_MEAN)
image_std = tf.constant(settings.IMAGE_STD)
def normalization(x):
"""
對(duì)輸入圖片x進(jìn)行歸一化,返回歸一化的值
"""
return (x - image_mean) / image_std
def train_preprocess(x, y):
"""
對(duì)訓(xùn)練數(shù)據(jù)進(jìn)行預(yù)處理。
注意,這里的參數(shù)x是圖片的路徑,不是圖片本身;y是圖片的標(biāo)簽值
"""
# 讀取圖片
x = tf.io.read_file(x)
# 解碼成張量
x = tf.image.decode_jpeg(x, channels=3)
# 將圖片縮放到[244,244],比輸入[224,224]稍大一些,方便后面數(shù)據(jù)增強(qiáng)
x = tf.image.resize(x, [244, 244])
# 隨機(jī)決定是否左右鏡像
if random.choice([0, 1]):
x = tf.image.random_flip_left_right(x)
# 隨機(jī)從x中剪裁出(224,224,3)大小的圖片
x = tf.image.random_crop(x, [224, 224, 3])
# 讀完上面的代碼可以發(fā)現(xiàn),這里的數(shù)據(jù)增強(qiáng)并不增加圖片數(shù)量,一張圖片經(jīng)過變換后,
# 仍然只是一張圖片,跟我們前面說的增加圖片數(shù)量的邏輯不太一樣。
# 這么做主要是應(yīng)對(duì)我們的數(shù)據(jù)集里可能會(huì)存在相同圖片的情況。
# 將圖片的像素值縮放到[0,1]之間
x = tf.cast(x, dtype=tf.float32) / 255.
# 歸一化
x = normalization(x)
# 將標(biāo)簽轉(zhuǎn)成one-hot形式
y = tf.cast(y, dtype=tf.int32)
y = tf.one_hot(y, settings.CLASS_NUM)
return x, y
def dev_preprocess(x, y):
"""
對(duì)驗(yàn)證集和測(cè)試集進(jìn)行數(shù)據(jù)預(yù)處理的方法。
和train_preprocess的主要區(qū)別在于,不進(jìn)行數(shù)據(jù)增強(qiáng),以保證驗(yàn)證結(jié)果的穩(wěn)定性。
"""
# 讀取并縮放圖片
x = tf.io.read_file(x)
x = tf.image.decode_jpeg(x, channels=3)
x = tf.image.resize(x, [224, 224])
# 歸一化
x = tf.cast(x, dtype=tf.float32) / 255.
x = normalization(x)
# 將標(biāo)簽轉(zhuǎn)成one-hot形式
y = tf.cast(y, dtype=tf.int32)
y = tf.one_hot(y, settings.CLASS_NUM)
return x, y
# (圖片路徑,標(biāo)簽)的列表
image_path_and_labels = []
# 排序,保證每次拿到的順序都一樣
sub_images_dir_list = sorted(list(os.listdir(images_root)))
# 遍歷每一個(gè)子目錄
for sub_images_dir in sub_images_dir_list:
sub_path = os.path.join(images_root, sub_images_dir)
# 如果給定路徑是文件夾,并且這個(gè)類別參與訓(xùn)練
if os.path.isdir(sub_path) and sub_images_dir in settings.CLASSES:
# 獲取當(dāng)前類別的編碼
current_label = class_code_map.get(sub_images_dir)
# 獲取子目錄下的全部圖片名稱
images = sorted(list(os.listdir(sub_path)))
# 隨機(jī)打亂(排序和置隨機(jī)數(shù)種子都是為了保證每次的結(jié)果都一樣)
random.seed(settings.RANDOM_SEED)
random.shuffle(images)
# 保留前settings.SAMPLES_PER_CLASS個(gè)
images = images[:samples_per_class]
# 構(gòu)建(x,y)對(duì)
for image_name in images:
abs_image_path = os.path.join(sub_path, image_name)
image_path_and_labels.append((abs_image_path, current_label))
# 計(jì)算各數(shù)據(jù)集樣例數(shù)
total_samples = len(image_path_and_labels) # 總樣例數(shù)
train_samples = int(total_samples * settings.TRAIN_DATASET) # 訓(xùn)練集樣例數(shù)
dev_samples = int(total_samples * settings.DEV_DATASET) # 開發(fā)集樣例數(shù)
test_samples = total_samples - train_samples - dev_samples # 測(cè)試集樣例數(shù)
# 打亂數(shù)據(jù)集
random.seed(settings.RANDOM_SEED)
random.shuffle(image_path_and_labels)
# 將圖片數(shù)據(jù)和標(biāo)簽數(shù)據(jù)分開,此時(shí)它們?nèi)允且灰粚?duì)應(yīng)的
x_data = tf.constant([img for img, label in image_path_and_labels])
y_data = tf.constant([label for img, label in image_path_and_labels])
# 開始劃分?jǐn)?shù)據(jù)集
# 訓(xùn)練集
train_db = tf.data.Dataset.from_tensor_slices((x_data[:train_samples], y_data[:train_samples]))
# 打亂順序,數(shù)據(jù)預(yù)處理,設(shè)置批大小
train_db = train_db.shuffle(10000).map(train_preprocess).batch(settings.BATCH_SIZE)
# 開發(fā)集(驗(yàn)證集)
dev_db = tf.data.Dataset.from_tensor_slices(
(x_data[train_samples:train_samples + dev_samples], y_data[train_samples:train_samples + dev_samples]))
# 數(shù)據(jù)預(yù)處理,設(shè)置批大小
dev_db = dev_db.map(dev_preprocess).batch(settings.BATCH_SIZE)
# 測(cè)試集
test_db = tf.data.Dataset.from_tensor_slices(
(x_data[train_samples + dev_samples:], y_data[train_samples + dev_samples:]))
# 數(shù)據(jù)預(yù)處理,設(shè)置批大小
test_db = test_db.map(dev_preprocess).batch(settings.BATCH_SIZE)
二、模型構(gòu)建、訓(xùn)練和調(diào)優(yōu)
數(shù)據(jù)已經(jīng)全部處理完畢,該考慮模型了。首先,我們數(shù)據(jù)集太小了,直接構(gòu)建自己的網(wǎng)絡(luò)并訓(xùn)練,并不是一個(gè)好方案。因?yàn)檫@幾種寵物其實(shí)挺難區(qū)分的,所以模型需要有一定復(fù)雜度,才能很好擬合這些數(shù)據(jù),但我們的數(shù)據(jù)又太少了,最后的結(jié)果一定是過擬合,而且還是救不回來的那種 = = 所以我們考慮從遷移學(xué)習(xí)入手。
什么是遷移學(xué)習(xí)?懶得重新組織語言的我,默默地從之前寫的博文里面摘了一段:
一般認(rèn)為,深度卷積神經(jīng)網(wǎng)絡(luò)的訓(xùn)練是對(duì)數(shù)據(jù)集特征的一步步抽取的過程,從簡(jiǎn)單的特征,到復(fù)雜的特征。
訓(xùn)練好的模型學(xué)習(xí)到的是對(duì)圖像特征的抽取方法,所以在 imagenet 數(shù)據(jù)集上訓(xùn)練好的模型理論上來說,也可以直接用于抽取其他圖像的特征,這也是遷移學(xué)習(xí)的基礎(chǔ)。自然,這樣的效果往往沒有在新數(shù)據(jù)上重新訓(xùn)練的效果好,但能夠節(jié)省大量的訓(xùn)練時(shí)間,在特定情況下非常有用。
上面說的特定情況也包括我們面臨的這一種——用于實(shí)際問題的數(shù)據(jù)集過小。
說到遷移學(xué)習(xí),我最先想到的是 VGG16,就先用 VGG16 搞了一波。使用在 imagenet 數(shù)據(jù)集上預(yù)訓(xùn)練的 VGG16 網(wǎng)絡(luò),去除頂部的全連接層,凍結(jié)全部參數(shù),使它們?cè)诮酉聛淼挠?xùn)練中不會(huì)改變。然后加上自己的全連接層,最后的輸出層節(jié)點(diǎn)為 4,對(duì)應(yīng)于我們的四分類問題。開始訓(xùn)練。
模型在訓(xùn)練集上的誤差很快降到 5% 以下,但是在驗(yàn)證集上的準(zhǔn)確率基本在 70+%,很明顯,過擬合了。好嘛,盤它!主要使用如下方法嘗試解決過擬合問題:
調(diào)節(jié)全連接層的層數(shù)和每層的節(jié)點(diǎn)數(shù)
添加 BN 層(雖說不是為了解決過擬合問題誕生的,但一定程度上是有效果的)
添加 Dropout 層
調(diào)節(jié) Dropout Rate
添加 l2 正則
一頓操作猛如虎,回頭一看 0-5。這些方法確實(shí)對(duì)過擬合有所緩解,驗(yàn)證集上的準(zhǔn)確率也確實(shí)有所提升,但只能達(dá)到 81% 左右。
然后我嘗試了 Resnet50,當(dāng)然也過擬合了,盤它!最后驗(yàn)證集 accuracy 能達(dá)到 83% 左右。
很明顯了,在全連接層的調(diào)整意義不大,究其根本,在于 VGG16 和 ResNet50 去除了全連接層之后,參數(shù)的數(shù)量也達(dá)到了 20M+。兩千萬的參數(shù)使得模型嚴(yán)重過擬合,所以我們需要換一個(gè)參數(shù)少一點(diǎn)的模型。
于是,我盯上了 DenseNet121,它的參數(shù)數(shù)量只有 7M。繼續(xù)盤它!果然,在一段時(shí)間的調(diào)優(yōu)后,模型的性能有了明顯的提升,驗(yàn)證集上的 accuracy 達(dá)到了 87% 左右。雖然和 ResNet 相比,準(zhǔn)確率只高了 4%,但相比于 ResNet50 96% 的訓(xùn)練 accuracy 而言,DenseNet121 的訓(xùn)練 accuracy 只有 90% 左右。也就是說,對(duì)于 DenseNet121 而言,這個(gè)問題已經(jīng)不再是過擬合問題了(相差 3% 我是可以接受的),而是欠擬合了。
然而淡騰的是,再怎么調(diào)參,模型都很難繼續(xù)擬合了,調(diào)小學(xué)習(xí)率也不行。模型本身沒啥問題的話,我開始懷疑數(shù)據(jù)集有沒有問題,畢竟這種無法擬合的問題有很大概率是數(shù)據(jù)導(dǎo)致的。于是我就去檢查了一下數(shù)據(jù)集……
這就是我前面強(qiáng)調(diào)認(rèn)真過一遍數(shù)據(jù)集的原因了,我當(dāng)時(shí)只是花個(gè)幾分鐘粗略地過了一下,刪除掉一些明顯不對(duì)的圖片。我第二次認(rèn)真過數(shù)據(jù)集的時(shí)候才發(fā)現(xiàn),有很多異常圖片沒有過濾掉,比如貓的目錄下有狗的圖片,狗的目錄下有貓的圖片,還有一些不同動(dòng)物同框的圖片,以及我自己都認(rèn)不出來的圖片……

文章第一部分中各類圖片數(shù)量的表格,其實(shí)就是我第二遍過濾后的結(jié)果統(tǒng)計(jì)。
過濾完成后,模型的性能有了明顯的提升,訓(xùn)練 accuracy 約為 93%-94%,驗(yàn)證 accuracy 為 94%,測(cè)試 accuracy 為 92%.我們先來看一下代碼,后面會(huì)對(duì)這個(gè)結(jié)果再進(jìn)行分析。
首先,是模型的構(gòu)建:
# -*- coding: utf-8 -*-
# @File : models.py
# @Author : AaronJny
# @Time : 2019/12/16
# @Desc :
import tensorflow as tf
import settings
def my_densenet():
"""
創(chuàng)建并返回一個(gè)基于densenet的Model對(duì)象
"""
# 獲取densenet網(wǎng)絡(luò),使用在imagenet上訓(xùn)練的參數(shù)值,移除頭部的全連接網(wǎng)絡(luò),池化層使用max_pooling
densenet = tf.keras.applications.DenseNet121(include_top=False, weights='imagenet', pooling='max')
# 凍結(jié)預(yù)訓(xùn)練的參數(shù),在之后的模型訓(xùn)練中不會(huì)改變它們
densenet.trainable = False
# 構(gòu)建模型
model = tf.keras.Sequential([
# 輸入層,shape為(None,224,224,3)
tf.keras.layers.Input((224, 224, 3)),
# 輸入到DenseNet121中
densenet,
# 將DenseNet121的輸出展平,以作為全連接層的輸入
tf.keras.layers.Flatten(),
# 添加BN層
tf.keras.layers.BatchNormalization(),
# 隨機(jī)失活
tf.keras.layers.Dropout(0.5),
# 第一個(gè)全連接層,激活函數(shù)relu
tf.keras.layers.Dense(512, activation=tf.nn.relu),
# BN層
tf.keras.layers.BatchNormalization(),
# 隨機(jī)失活
tf.keras.layers.Dropout(0.5),
# 第二個(gè)全連接層,激活函數(shù)relu
tf.keras.layers.Dense(64, activation=tf.nn.relu),
# BN層
tf.keras.layers.BatchNormalization(),
# 輸出層,為了保證輸出結(jié)果的穩(wěn)定,這里就不添加Dropout層了
tf.keras.layers.Dense(settings.CLASS_NUM, activation=tf.nn.softmax)
])
return model
if __name__ == '__main__':
model = my_densenet()
model.summary()
網(wǎng)絡(luò)的 summary:
Model: "sequential"
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
densenet121 (Model) (None, 1024) 7037504
_________________________________________________________________
flatten (Flatten) (None, 1024) 0
_________________________________________________________________
batch_normalization (BatchNo (None, 1024) 4096
_________________________________________________________________
dropout (Dropout) (None, 1024) 0
_________________________________________________________________
dense (Dense) (None, 512) 524800
_________________________________________________________________
batch_normalization_1 (Batch (None, 512) 2048
_________________________________________________________________
dropout_1 (Dropout) (None, 512) 0
_________________________________________________________________
dense_1 (Dense) (None, 64) 32832
_________________________________________________________________
batch_normalization_2 (Batch (None, 64) 256
_________________________________________________________________
dense_2 (Dense) (None, 4) 260
=================================================================
Total params: 7,601,796
Trainable params: 561,092
Non-trainable params: 7,040,704
_________________________________________________________________
參數(shù)總量 7601796 個(gè),其中可訓(xùn)練參數(shù) 561092 個(gè) 。
模型和數(shù)據(jù)都已準(zhǔn)備完畢,可以開始訓(xùn)練了。讓我們編寫一個(gè)訓(xùn)練用的腳本:
# -*- coding: utf-8 -*-
# @File : train.py
# @Author : AaronJny
# @Time : 2019/12/17
# @Desc :
import tensorflow as tf
from data import train_db, dev_db
import models
import settings
# 從models文件中導(dǎo)入模型
model = models.my_densenet()
model.summary()
# 配置優(yōu)化器、損失函數(shù)、以及監(jiān)控指標(biāo)
model.compile(tf.keras.optimizers.Adam(settings.LEARNING_RATE), loss=tf.keras.losses.categorical_crossentropy,
metrics=['accuracy'])
# 在每個(gè)epoch結(jié)束后嘗試保存模型參數(shù),只有當(dāng)前參數(shù)的val_accuracy比之前保存的更優(yōu)時(shí),才會(huì)覆蓋掉之前保存的參數(shù)
model_check_point = tf.keras.callbacks.ModelCheckpoint(filepath=settings.MODEL_PATH, monitor='val_accuracy',
save_best_only=True)
# 使用tf.keras的高級(jí)接口進(jìn)行訓(xùn)練
model.fit_generator(train_db, epochs=settings.TRAIN_EPOCHS, validation_data=dev_db, callbacks=[model_check_point])
現(xiàn)在,我們可以運(yùn)行腳本進(jìn)行訓(xùn)練了,最優(yōu)的參數(shù)將被保存在settings.MODEL_PATH。訓(xùn)練完成后,我們需要調(diào)用驗(yàn)證腳本,驗(yàn)證下模型在驗(yàn)證集和測(cè)試集上的表現(xiàn):
# -*- coding: utf-8 -*-
# @File : eval.py
# @Author : AaronJny
# @Time : 2019/12/17
# @Desc :
import tensorflow as tf
from data import dev_db, test_db
from models import my_densenet
import settings
# 創(chuàng)建模型
model = my_densenet()
# 加載參數(shù)
model.load_weights(settings.MODEL_PATH)
# 因?yàn)橄胗胻f.keras的高級(jí)接口做驗(yàn)證,所以還是需要編譯模型
model.compile(tf.keras.optimizers.Adam(settings.LEARNING_RATE), loss=tf.keras.losses.categorical_crossentropy,
metrics=['accuracy'])
# 驗(yàn)證集accuracy
print('dev', model.evaluate(dev_db))
# 測(cè)試集accuracy
print('test', model.evaluate(test_db))
輸出如下:
18/18 [==============================] - 5s 304ms/step - loss: 0.1936 - accuracy: 0.9457
dev [0.19364455559601387, 0.9456522]
18/18 [==============================] - 1s 64ms/step - loss: 0.2666 - accuracy: 0.9203
test [0.26657224384446937, 0.9202899]
能夠看到,模型在驗(yàn)證集上的準(zhǔn)確率為 94.57%,在測(cè)試集上的準(zhǔn)確率為 92.03%,已經(jīng)達(dá)到我的心里預(yù)期了,畢竟這么少的數(shù)據(jù),還要啥自行車?
隨著訓(xùn)練 epoch 的增多,模型的訓(xùn)練 accuracy 始終在[0.92,0.95]左右徘徊不定,沒法繼續(xù)擬合。究其原因,應(yīng)該還是數(shù)據(jù)的鍋。我們看一下識(shí)別錯(cuò)的樣本,在 eval.py 腳本中,增加下面這一段程序:
# 查看識(shí)別錯(cuò)誤的數(shù)據(jù)
for x, y in test_db:
y_pred = model(x)
y_pred = tf.argmax(y_pred, axis=1).numpy()
y_true = tf.argmax(y, axis=1).numpy()
batch_size = y_pred.shape[0]
for i in range(batch_size):
if y_pred[i] != y_true[i]:
print('{} 被錯(cuò)誤識(shí)別成 {}!'.format(settings.CODE_CLASS_MAP[y_true[i]], settings.CODE_CLASS_MAP[y_pred[i]]))
重新跑一下 eval.py 腳本,輸出如下:
18/18 [==============================] - 5s 291ms/step - loss: 0.1936 - accuracy: 0.9457
dev [0.19364455559601387, 0.9456522]
18/18 [==============================] - 1s 64ms/step - loss: 0.2666 - accuracy: 0.9203
test [0.26657224384446937, 0.9202899]
狗 被錯(cuò)誤識(shí)別成 兔!
狗 被錯(cuò)誤識(shí)別成 兔!
狗 被錯(cuò)誤識(shí)別成 兔!
鼠 被錯(cuò)誤識(shí)別成 兔!
狗 被錯(cuò)誤識(shí)別成 貓!
鼠 被錯(cuò)誤識(shí)別成 貓!
狗 被錯(cuò)誤識(shí)別成 兔!
狗 被錯(cuò)誤識(shí)別成 鼠!
鼠 被錯(cuò)誤識(shí)別成 兔!
狗 被錯(cuò)誤識(shí)別成 兔!
貓 被錯(cuò)誤識(shí)別成 兔!
貓 被錯(cuò)誤識(shí)別成 鼠!
貓 被錯(cuò)誤識(shí)別成 兔!
鼠 被錯(cuò)誤識(shí)別成 兔!
狗 被錯(cuò)誤識(shí)別成 兔!
狗 被錯(cuò)誤識(shí)別成 貓!
鼠 被錯(cuò)誤識(shí)別成 兔!
狗 被錯(cuò)誤識(shí)別成 兔!
鼠 被錯(cuò)誤識(shí)別成 兔!
狗 被錯(cuò)誤識(shí)別成 貓!
鼠 被錯(cuò)誤識(shí)別成 兔!
狗 被錯(cuò)誤識(shí)別成 兔!
來,跟我一起唱——都是兔子惹的禍 ~
能夠看到,出錯(cuò)的大部分都是被誤識(shí)別成兔子了。對(duì)應(yīng)到數(shù)據(jù)集上,雖然已經(jīng)刪掉了部分問題比較大的圖片,但兔子的圖片確實(shí)不好認(rèn)。有很多兔子圖片我人工分辨都認(rèn)不出是兔子(捂臉.jpg)。然后,有些兔子圖片看起來很像貓,有些看起來很像狗,有些看起來很像鼠……
如果我們把兔子圖片去掉,將系統(tǒng)改為三分類問題,準(zhǔn)確度將大幅度提高。當(dāng)然了,按理說識(shí)別的類別數(shù)量變了,除了調(diào)整輸出層的節(jié)點(diǎn)數(shù)量外,要想取得最佳效果,模型的其他參數(shù)也需要做相應(yīng)調(diào)整的。我自己已經(jīng)實(shí)測(cè)了,但限于篇幅,就不演示了,如果有興趣的話,可以直接在settings.py里進(jìn)行調(diào)整,將它變?yōu)槿诸悊栴}。改這兩個(gè)地方:
# 參與訓(xùn)練的類別
CLASSES = ['cats', 'dogs', 'mouses', 'rabbits']
# 隨機(jī)數(shù)種子
RANDOM_SEED = 13 # 四個(gè)類別時(shí)樣本較為均衡的隨機(jī)數(shù)種子
改成:
# 參與訓(xùn)練的類別
CLASSES = ['cats', 'dogs', 'mouses']
# 隨機(jī)數(shù)種子
RANDOM_SEED = 19 # 三個(gè)類別時(shí)樣本較為均衡的隨機(jī)數(shù)種子
然后重新訓(xùn)練和驗(yàn)證即可。這只是一個(gè)插曲,本文仍然以四分類問題繼續(xù)說明后續(xù)內(nèi)容。
三、Web 接口編寫
模型訓(xùn)練好了,我們要把它應(yīng)用起來。我準(zhǔn)備編寫一個(gè) Web 服務(wù),用戶可以通過瀏覽器上傳一張圖片,服務(wù)器判斷此圖片的類別后,返回相關(guān)數(shù)據(jù)給用戶。Web 后端使用 Flask,小而輕,前端則選用 Vue.js + Element-UI 實(shí)現(xiàn)。
先寫后端:
# -*- coding: utf-8 -*-
# @File : app.py
# @Author : AaronJny
# @Time : 2019/12/18
# @Desc :
from flask import Flask
from flask import jsonify
from flask import request, render_template
import tensorflow as tf
from models import my_densenet
import settings
app = Flask(__name__)
# 導(dǎo)入模型
model = my_densenet()
# 加載訓(xùn)練好的參數(shù)
model.load_weights(settings.MODEL_PATH)
@app.route('/', methods=['GET'])
def index():
"""
首頁,vue入口
"""
return render_template('index.html')
@app.route('/api/v1/pets_classify/', methods=['POST'])
def pets_classify():
"""
寵物圖片分類接口,上傳一張圖片,返回此圖片上的寵物是那種類別,概率多少
"""
# 獲取用戶上傳的圖片
img_str = request.files.get('file').read()
# 進(jìn)行數(shù)據(jù)預(yù)處理
x = tf.image.decode_image(img_str, channels=3)
x = tf.image.resize(x, (224, 224))
x = x / 255.
x = (x - tf.constant(settings.IMAGE_MEAN)) / tf.constant(settings.IMAGE_STD)
x = tf.reshape(x, (1, 224, 224, 3))
# 預(yù)測(cè)
y_pred = model(x)
pet_cls_code = tf.argmax(y_pred, axis=1).numpy()[0]
pet_cls_prob = float(y_pred.numpy()[0][pet_cls_code])
pet_cls_prob = '{}%'.format(int(pet_cls_prob * 100))
pet_class = settings.CODE_CLASS_MAP.get(pet_cls_code)
# 將預(yù)測(cè)結(jié)果組織成json
res = {
'code': 0,
'data': {
'pet_cls': pet_class,
'probability': pet_cls_prob,
'msg': '
{} '>概率{}'.format(pet_class, pet_cls_prob),
}
}
# 返回json數(shù)據(jù)
return jsonify(res)
if __name__ == '__main__':
app.run(port=settings.WEB_PORT)
后端腳本app.py很簡(jiǎn)單,主要就兩個(gè)方法。其中index方法會(huì)返回首頁的 HTML 源碼,是用戶在瀏覽器端的訪問入口;另一個(gè)方法pets_classify則提供了計(jì)算給定圖片類別的功能。
前端文件index.html主要是提供了一個(gè)照片墻,用戶上傳圖片到照片墻,服務(wù)器就會(huì)計(jì)算圖片類別并返回相關(guān)數(shù)據(jù)。代碼如下:
寵物識(shí)別Demo
action="http://localhost:5000/api/v1/pets_classify/"
list-type="picture-card"
:on-preview="handlePictureCardPreview"
:on-success="handleUploadSuccess"
:on-remove="handleRemove">
讓我們?cè)囋囆ЧJ紫?,運(yùn)行app.py腳本,啟動(dòng) Web 服務(wù),當(dāng)你看到如下輸出時(shí),說明服務(wù)啟動(dòng)成功了:
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
* Serving Flask app "app" (lazy loading)
* Environment: production
WARNING: This is a development server. Do not use it in a production deployment.
Use a production WSGI server instead.
* Debug mode: off
因?yàn)橹皇情_發(fā)環(huán)境,這么啟動(dòng)就可以了。如果是生產(chǎn)環(huán)境,請(qǐng)不要這么做,可以選擇使用 nginx + gunicorn +uWSGI + gevent 進(jìn)行部署。
四、測(cè)試
打開瀏覽器,輸入 http://localhost:5000?進(jìn)入 index 頁面。頁面長這個(gè)樣子:

點(diǎn)擊網(wǎng)頁中的上傳框,我們可以選擇圖片上傳并識(shí)別:


當(dāng)然了,這里不選擇我們數(shù)據(jù)集里的圖片更好,哪怕是測(cè)試集里的。你可以去網(wǎng)上下載、或者通過其他渠道獲取這四種動(dòng)物的圖片來測(cè)試,這里我只做演示,就不搞那么麻煩了,直接從數(shù)據(jù)集里隨便選幾張照片。我們可以繼續(xù)上傳圖片給服務(wù)器識(shí)別:

OK,演示到此為止,如果有興趣的話可以自行測(cè)試。
結(jié)語
文章到此結(jié)束,如果您喜歡的話,給我點(diǎn)個(gè)贊唄 ~
菜鳥一只,歡迎大佬們拍磚 ~
作者:AaronJny
地址:https://www.aaronjny.com/articles/2019/12/17/1576592367309.html源碼:https://github.com/AaronJny/pets_classifer
本文僅做學(xué)術(shù)分享,如有侵權(quán),請(qǐng)聯(lián)系刪文。
