網(wǎng)絡八股文:用Keras跑通MNIST與Fashion MNIST圖像分類)
在接觸深度學習的最初階段我最困惑的不是某個激活函數(shù)怎么推導而是打開編輯器不知道先寫哪一行。后來我把別人跑通的入門項目拆開看了一遍又一遍發(fā)現(xiàn)無論手寫數(shù)字識別還是衣服褲子分類代碼骨架都高度相似加載數(shù)據(jù)、做預處理、用Sequential搭網(wǎng)絡、compile、fit、evaluate。這個骨架固定到了什么程度固定到連每一行的先后順序都幾乎不會變。我習慣叫它“神經(jīng)網(wǎng)絡八股”。這篇筆記就圍繞MNIST手寫數(shù)字識別和Fashion MNIST衣褲分類兩個任務把這條八股流程完整走一遍。我會用TensorFlow與Keras的寫法從數(shù)據(jù)加載一直講到模型預測并把每步為什么要這么做、調(diào)整某個參數(shù)會帶來什么影響也一并說清楚。適合剛學完基本概念、想自己動手跑通第一個圖像分類項目的讀者也適合已經(jīng)跑通過、但還想把細節(jié)理解得更扎實的同學。1. 為什么神經(jīng)網(wǎng)絡代碼會長成“八股”樣1.1 “八股”在深度學習里不是貶義“八股”這個詞聽上去像是刻板、僵硬但在工程實踐中一套固定模板反而是最低成本的起點。原因很簡單圖像分類任務本質(zhì)上是同一類問題——把像素矩陣映射到類別標簽。既然問題的數(shù)學結構一致代碼結構當然也一致。我以前總想著每個項目都要“從頭開始設計”后來發(fā)現(xiàn)那是浪費時間。真正需要設計的是模型內(nèi)部的結構和參數(shù)而不是外圍那圈數(shù)據(jù)加載、訓練、評估的流程。把外圍流程固定下來相當于給自己搭好腳手架剩下的精力才可以全部放在模型本身。1.2 這段流程的順序有嚴格的依賴關系不少初學者會問為什么一定要按“數(shù)據(jù)→模型→編譯→訓練→評估”的順序寫能不能先建模型再處理數(shù)據(jù)答案是可以但邏輯上說不過去。因為模型第一層的輸入維度取決于數(shù)據(jù)的形狀輸出維度取決于標簽的類別數(shù)loss函數(shù)的選擇又取決于標簽是整數(shù)還是獨熱編碼。這意味著如果你不先看一眼數(shù)據(jù)后面的代碼就無從寫起。更深一層訓練的前提是模型已經(jīng)編譯而模型編譯的前提又是模型已經(jīng)搭好。每一步都在為下一步準備輸入。這種單向依賴關系正是八股流程如此穩(wěn)定的根本原因。1.3 整套流程先看個全景先給出一個極簡的八股模板后面的章節(jié)再逐個填空。# 1. 導入工具庫 import tensorflow as tf from tensorflow import keras # 2. 加載并預處理數(shù)據(jù) (x_train, y_train), (x_test, y_test) keras.datasets.mnist.load_data() x_train x_train.astype(float32) / 255.0 x_test x_test.astype(float32) / 255.0 # 3. 搭建模型 model keras.Sequential([ keras.layers.Input(shape(28, 28)), keras.layers.Flatten(), keras.layers.Dense(128, activationrelu), keras.layers.Dense(10, activationsoftmax) ]) # 4. 編譯模型 model.compile(optimizeradam, losssparse_categorical_crossentropy, metrics[accuracy]) # 5. 訓練模型 history model.fit(x_train, y_train, batch_size64, epochs5, validation_split0.1) # 6. 評估模型 test_loss, test_acc model.evaluate(x_test, y_test)這個模板放到Fashion MNIST上同樣成立唯一要改的是第二行加載數(shù)據(jù)那一句。理解了為什么是這六個步驟你就算掌握了一半的入門內(nèi)容。2. 先建立數(shù)據(jù)集認知MNIST與Fashion MNIST的“打開”方式2.1 兩行代碼就能拿到官方數(shù)據(jù)TensorFlow把常用數(shù)據(jù)集內(nèi)置在keras.datasets里MNIST和Fashion MNIST都屬于開箱即用的類型。加載方式非常統(tǒng)一from tensorflow import keras # MNIST手寫數(shù)字 0-9 (x_train, y_train), (x_test, y_test) keras.datasets.mnist.load_data() # Fashion MNIST十類衣褲鞋包 (x_f_train, y_f_train), (x_f_test, y_f_test) keras.datasets.fashion_mnist.load_data()第一次調(diào)用時程序會自動下載數(shù)據(jù)到本機緩存目錄之后再運行就直接從本地讀取。數(shù)據(jù)格式需要注意訓練集60000張圖片每張是28x28的灰度圖測試集10000張圖片同樣28x28標簽0到9的整數(shù)。x_train.shape輸出是(60000, 28, 28)代表60000個樣本、每個樣本28行28列像素。MNIST和Fashion MNIST在這個維度上完全一致所以代碼可以無縫切換。2.2 圖片樣本長什么樣類別分別是什么拿到數(shù)據(jù)后第一時間看一下圖片是很有必要的。很多人直接跳過這一步等到訓練完成才發(fā)現(xiàn)把“褲子”和“襯衫”搞混了回頭才補做可視化。import matplotlib.pyplot as plt plt.figure(figsize(10, 4)) for i in range(10): plt.subplot(2, 5, i 1) plt.imshow(x_train[i], cmapgray) plt.title(flabel: {y_train[i]}) plt.axis(off) plt.show()MNIST的10個類別就是數(shù)字0到9。Fashion MNIST的10個類別如下表所示標簽值含義標簽值含義0T恤/上衣5涼鞋1褲子6襯衫2套頭衫7運動鞋3連衣裙8包4外套9短靴兩個數(shù)據(jù)集都是10分類問題這也是它們適合做對照實驗的原因。一樣的模型結構、一樣的訓練流程差別只在圖像內(nèi)容本身。2.3 歸一化為什么要把像素除以255原始像素值范圍是0到255而神經(jīng)網(wǎng)絡在計算時傾向于數(shù)值較小的輸入。把數(shù)據(jù)統(tǒng)一縮放到0到1之間本質(zhì)上是降低輸入特征的量綱差異。如果不做歸一化初始梯度會被放大訓練過程會變得極不穩(wěn)定loss在頭幾個epoch可能跳動很劇烈甚至直接變成NaN。即使網(wǎng)絡勉強收斂最終準確率往往也不理想。歸一化只需要兩行代碼x_train x_train.astype(float32) / 255.0 x_test x_test.astype(float32) / 255.0要注意的是/255.0會讓整數(shù)數(shù)組自動變成浮點數(shù)。我更建議顯式先astype(float32)這樣在移動端或嵌入式環(huán)境里跑的時候可以避免一些類型隱式轉換帶來的小坑。3. 搭網(wǎng)絡Flatten和Dense是八股的核心部件3.1 Sequential模型的適用邊界Keras里最友好的模型接口是Sequential它按層一層層堆疊前一層的輸出就是后一層的輸入。對于極簡全連接網(wǎng)絡Sequential是首選。model keras.Sequential([ keras.layers.Input(shape(28, 28)), keras.layers.Flatten(), keras.layers.Dense(128, activationrelu), keras.layers.Dense(10, activationsoftmax) ])如果未來需要跳層連接、多輸入多輸出或者想自定義訓練循環(huán)再改用更靈活的ModelAPI。對這篇文章的兩個案例Sequential足夠。3.2 為什么全連接層前面要加FlattenFlatten做的事情很簡單把輸入從多維變成一維。一張28x28的圖片會被展開成784個數(shù)值的向量。有人會問28x28本身已經(jīng)是二維矩陣模型能不能直接吃全連接層Dense的輸入必須是一個特征向量。如果不加Flatten直接丟給DenseKeras會報錯或者得到不符合預期的shape。所以Flatten在這里相當于“把矩陣拍平成向量”的適配器是圖像分類進入全連接層前必須的一步。順帶提一句Input(shape(28, 28))這一層指定了輸入的形狀訓練時一次性讀取一個batch每個樣本都是28行28列。3.3 激活函數(shù)的選擇邏輯中間隱藏層用的是relu輸出層用的是softmax這個搭配幾乎是八股標配。relu計算簡單、能緩解梯度消失在隱藏層中使用非常穩(wěn)定。softmax會把輸出層的10個實數(shù)轉換成10個概率值且所有概率加起來等于1天然適合多分類任務的概率解釋。如果你把輸出層改成sigmoid每個節(jié)點雖然也能輸出0到1之間的值但并沒有歸一化不能真正當作多分類概率來用。二分類問題用sigmoid沒問題多分類問題的輸出層基本都選softmax。3.4 看一眼參數(shù)量和模型結構model.summary()可以列出每一層的輸出形狀和參數(shù)數(shù)量建議每次搭完模型都跑一下。model.summary()這個簡單模型的參數(shù)量主要來自兩處第一個Dense層輸入784個特征輸出128個神經(jīng)元參數(shù)量為784x128128100352第二個Dense層輸入128個特征輸出10個神經(jīng)元參數(shù)量為128x10101290。合計約10萬個參數(shù)。對這么小的網(wǎng)絡來說訓練起來非常快CPU也能輕松跑完。這也是入門案例選它的原因之一。4. 編譯、訓練與評估三段式操作詳解4.1 compile里三個參數(shù)分別是什么compile是訓練前的最后一道準備工序它告訴框架三件事用什么優(yōu)化器、用什么損失函數(shù)、關心什么指標。model.compile(optimizeradam, losssparse_categorical_crossentropy, metrics[accuracy])optimizeradam梯度下降的具體策略。Adam能自適應調(diào)整學習率大部分情況下比SGD收斂更快對新手更友好。losssparse_categorical_crossentropy多分類損失函數(shù)。名字里的sparse代表標簽是整數(shù)比如數(shù)字“7”直接傳7。metrics[accuracy]訓練過程中除了顯示loss之外額外顯示準確率。這里最容易踩的坑是損失函數(shù)對應關系。如果你的標簽是獨熱編碼one-hot比如數(shù)字“7”變成[0,0,0,0,0,0,0,1,0,0]就必須用categorical_crossentropy如果標簽是整數(shù)就用sparse_categorical_crossentropy。兩者用反了訓練過程不會直接報錯但準確率會一直停留在很低的位置。4.2 fit訓練過程的參數(shù)含義history model.fit(x_train, y_train, batch_size64, epochs5, validation_split0.1)batch_size64表示每64張圖片算一次梯度。batch_size過小訓練波動大過大內(nèi)存壓力大收斂也可能變慢。epochs5表示把整個訓練集過5遍。validation_split0.1表示從訓練集里拿出最后10%當作驗證集用于觀察模型在沒見過的數(shù)據(jù)上的表現(xiàn)。返回的history對象中保存了每個epoch的訓練loss、準確率和驗證集指標。很多人訓練完就扔了其實這里的信息非常寶貴print(history.history.keys()) # dict_keys([loss, accuracy, val_loss, val_accuracy])如果train_loss持續(xù)下降但val_loss開始反彈說明模型已經(jīng)過擬合如果兩個loss都下降緩慢則說明學習率可能偏低或模型容量不足。這些判斷都可以通過history完成。4.3 evaluate和predict的區(qū)別訓練完成后我們關心兩個問題模型整體表現(xiàn)如何對具體某張圖片會怎么判斷evaluate負責第一個問題在測試集上算出最終loss和accuracytest_loss, test_acc model.evaluate(x_test, y_test) print(f測試集準確率: {test_acc:.4f})predict負責第二個問題輸出每個樣本屬于各個類別的概率predictions model.predict(x_test[:10]) print(predictions[0]) # 輸出一個長度為10的向量例如 [0.01, 0.02, ..., 0.94]predict得到的是一串概率想要最終類別用argmax取最大值所在的下標import numpy as np pred_classes np.argmax(predictions, axis1)從概率分布里能讀到比準確率更豐富的信息如果最高概率和次高概率非常接近說明模型對這張圖也很“猶豫”這時要么加強訓練要么需要更多數(shù)據(jù)。4.4 更進一步混淆矩陣與錯分樣例大多數(shù)入門筆記到evaluate就結束了。如果真想判斷模型哪些類別容易混淆建議把預測結果和真實標簽做成混淆矩陣。from sklearn.metrics import confusion_matrix, classification_report pred_test np.argmax(model.predict(x_test), axis1) print(classification_report(y_test, pred_test))classification_report會把每個類別的精確率、召回率、F1值列出來。比如Fashion MNIST里“襯衫”和“T恤/上衣”經(jīng)常被模型互相認錯單看總體準確率你壓根發(fā)現(xiàn)不了只有看分類報告才會注意到問題出在哪幾類。這是八股流程之外非常值得加的一步。5. 同一套八股搬到Fashion MNIST真就改一行5.1 換數(shù)據(jù)集模型與訓練流程原封不動說“只改一行”稍微有點夸張實際上加載數(shù)據(jù)那一行換掉之后后面的歸一化、建模型、編譯、訓練全部可以復用。嚴謹一點完整的Fashion MNIST訓練代碼如下from tensorflow import keras import numpy as np # 換了一行數(shù)據(jù)集換成fashion_mnist (x_train, y_train), (x_test, y_test) keras.datasets.fashion_mnist.load_data() x_train x_train.astype(float32) / 255.0 x_test x_test.astype(float32) / 255.0 model keras.Sequential([ keras.layers.Input(shape(28, 28)), keras.layers.Flatten(), keras.layers.Dense(128, activationrelu), keras.layers.Dense(10, activationsoftmax) ]) model.compile(optimizeradam, losssparse_categorical_crossentropy, metrics[accuracy]) history model.fit(x_train, y_train, batch_size64, epochs5, validation_split0.1) test_loss, test_acc model.evaluate(x_test, y_test) print(fFashion MNIST 測試集準確率: {test_acc:.4f})模板的威力就在這里體現(xiàn)。同一個網(wǎng)絡結構數(shù)據(jù)換成衣服褲子鞋包流程一字不改系統(tǒng)就能完成一次完全不同的分類任務。5.2 兩個數(shù)據(jù)集的實驗結果差異我在本機分別跑過一次MNIST和Fashion MNIST網(wǎng)絡結構和超參數(shù)完全相同結果對比如下項目MNISTFashion MNIST訓練樣本數(shù)6000060000測試樣本數(shù)1000010000圖片尺寸28x28灰度28x28灰度類別數(shù)1010隱藏層神經(jīng)元128128epochs55測試集準確率約0.98約0.91MNIST明顯更容易達到高準確率因為手寫數(shù)字背景簡單、筆畫結構穩(wěn)定。Fashion MNIST里的衣服褲子和鞋靴形狀更復雜同類物品之間差異大不同類之間又存在相似特征比如襯衫和T恤、涼鞋和運動鞋。所以同樣的八股模板Fashion MNIST準確率低幾個百分點是完全正常的現(xiàn)象不代表代碼寫錯了。5.3 遷移到Fashion后可以做的簡單調(diào)整如果想在Fashion MNIST上繼續(xù)提升可以優(yōu)先考慮兩個方向一是增加訓練輪數(shù)。5個epoch對Fashion來說往往還沒完全收斂改到10或15個epoch后準確率通常會繼續(xù)上升。二是增加模型容量。可以把隱藏層從一層128改成兩層比如Dense(256, activationrelu)之后再接一個Dense(128, activationrelu)。參數(shù)量變大后模型表達能力增強但也要小心過擬合最好同時加入Dropout層。這里要強調(diào)一個原則不要一上來就堆參數(shù)。先用八股模板跑通基線再一個一個變量地調(diào)這樣你才能知道每個改動到底起了多大作用。6. 把八股跑順之后真正要避開的幾個坑6.1 忘記歸一化loss亂跳與NaN的元兇我在初學階段犯過的最蠢錯誤就是加載完數(shù)據(jù)直接丟進模型。表面上看代碼沒報錯但訓練時loss從一開始就很高而且降得極慢有時到第3個epoch直接變成NaN。原因很簡單輸入像素值范圍0到255經(jīng)過累加求和之后網(wǎng)絡中間層的輸出值變得很大配合交叉熵損失計算梯度可能溢出。這不是玄學而是數(shù)值計算的不穩(wěn)定性。規(guī)避方法就一句話訓練前先確認輸入數(shù)據(jù)范圍。打印x_train.min()和x_train.max()如果在0到255之間說明歸一化沒做回去補上。6.2 標簽格式與損失函數(shù)不匹配這是很多人繞過的彎路。標簽是整數(shù)如7用sparse_categorical_crossentropy標簽是獨熱編碼如[0,0,0,0,0,0,0,1,0,0]用categorical_crossentropy。如果把獨熱編碼的標簽丟給sparse_categorical_crossentropy訓練時會報錯形狀對不上反過來整數(shù)標簽丟給categorical_crossentropy有的版本會直接報錯有的版本會靜默地訓練出很差的模型。后一種情況更難受因為程序不報錯你以為是模型問題實際上是數(shù)據(jù)格式問題。我的習慣是只要標簽是從load_data()直接拿來的就用sparse_categorical_crossentropy盡量不做無謂的one-hot轉換。6.3 validation_split和shuffle的細節(jié)validation_split0.1的機制是從訓練集末尾按順序截取10%作為驗證集。如果你的原始數(shù)據(jù)有明顯順序比如前60000張里前幾萬都是同一種類別驗證集的代表性就會受到影響。正規(guī)一點的做法是提前用train_test_split手動切分或者訓練前手動調(diào)用shuffle打亂順序。fit默認會在每個epoch開始前shuffle訓練數(shù)據(jù)但驗證集不會shuffle這是設計好的。不要自己去測試集上做數(shù)據(jù)增強或shuffle否則評估結果會失真。6.4 隨機種子與可復現(xiàn)性神經(jīng)網(wǎng)絡權重初始化、數(shù)據(jù)打亂都帶有隨機性。同一段代碼跑兩次最終準確率可能差0.2到0.5個百分點。為了實驗可復現(xiàn)可以在開頭固定隨機種子。import numpy as np import tensorflow as tf np.random.seed(42) tf.random.set_seed(42)注意即使固定這些種子在GPU上訓練時某些底層運算仍然可能引入隨機性。不要指望每次完全相同而是保證“大方向穩(wěn)定”。如果你的準確率在兩次運行之間波動特別大那通常說明模型訓練不充分或學習率設置不合適而不是隨機性的鍋。6.5 把八股模板當成自己的腳手架最后說點心得。這個八股模板不是最終形態(tài)它是腳手架。熟練之后你完全可以在保留整體框架的前提下自由改造把Dense層換成Conv2D和MaxPooling2D就是CNN版本的圖像分類把Flatten換成GlobalAveragePooling2D可以減少全連接層參數(shù)量把model.fit換成自定義訓練循環(huán)就能接入更復雜的損失函數(shù)與優(yōu)化策略。但無論怎么改數(shù)據(jù)加載、歸一化、模型構建、編譯、訓練、評估這條主干不會變。這也是為什么我說先把八股背熟再談創(chuàng)新。我第一次完整跑通這套流程時激動得把輸出截圖看了好幾遍。后來這種成就感慢慢變成一種踏實我知道自己手里有一把能循環(huán)使用的鑰匙遇到新的圖片分類問題不用再從零開始糾結代碼結構而是集中精力去思考數(shù)據(jù)、模型和調(diào)參。這大概就是八股模板帶給我最大的價值。