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