線性回歸:手寫訓練閉環(huán)的關鍵細節(jié)與踩坑指南)
1. 為什么線性回歸值得徒手寫一遍而不是直接調(diào)包很多初學者看到“線性回歸從零開始實現(xiàn)”這個標題會想PyTorch里nn.Linear一行就搞定了手動實現(xiàn)有什么意義我最初也有這個想法畢竟李沐那本《動手學深度學習》前面幾章看起來挺簡單線性回歸無非就是w乘x加b再算個損失。直到我真的跟著第三章把代碼敲完才發(fā)現(xiàn)這個“簡單”的章節(jié)里藏著整個深度學習訓練流程的全部骨架數(shù)據(jù)集的構(gòu)造、模型的定義、損失函數(shù)的選擇、梯度的手動推導、參數(shù)的迭代更新——這五個環(huán)節(jié)在后續(xù)所有復雜的卷積網(wǎng)絡、Transformer、擴散模型里一個都少不了。換句話說線性回歸就是深度學習的最小可運行系統(tǒng)你在這一章里建立的“訓練閉環(huán)”心智模型會在之后每讀一個模型時反復被調(diào)用。李沐把這一節(jié)定位成“從零開始”意思是不借助任何深度學習框架的自動求導和封裝層只用torch.Tensor和純數(shù)學運算把整個訓練過程寫出來。官方教程里配套的代碼很簡單但正因為簡單很多細節(jié)容易被一眼帶過比如梯度為什么是x * (y_pred - y)比如為什么要b.grad.zero_()比如學習率0.03這個數(shù)字是怎么拍出來的。這些細節(jié)恰恰是手寫代碼時最容易卡殼的地方。如果你正準備啃《動手學深度學習》或者已經(jīng)看完了課程視頻但覺得“看懂了卻寫不出來”這篇文章就是為你準備的。我會把從零開始的完整實現(xiàn)拆開揉碎講清楚每一步在干什么、為什么這么干、以及我實際跑代碼時踩過的坑。提示閱讀本文前至少要知道Pytorch Tensor的基本操作不用會自動求導因為這一節(jié)的核心恰恰是“不用自動求導”。2. 從造數(shù)據(jù)到梯度推導五個環(huán)節(jié)缺一不可2.1 造一份“看起來真實”的數(shù)據(jù)線性回歸從零實現(xiàn)的第一步不是寫模型而是先造數(shù)據(jù)。李沐的代碼里用了標準的隨機線性模型import torch def synthetic_data(w_true, b_true, num_examples): 生成 y Xw b 噪聲 的模擬數(shù)據(jù) X torch.normal(0, 1, (num_examples, len(w_true))) y torch.matmul(X, w_true) b_true y torch.normal(0, 0.01, y.shape) # 加入噪聲 return X, y.reshape((-1, 1))這里有個值得琢磨的點為什么特征X要采樣自標準正態(tài)分布而不是均勻分布原因有兩個。第一標準正態(tài)分布在數(shù)學上方便推導后續(xù)如果要做更復雜的驗證均值和方差都是已知的第二真實場景中許多特征本身就近似服從正態(tài)分布比如身高、考試成績用正態(tài)分布造數(shù)據(jù)更貼近實際。噪聲的方差0.01是人為設定的它決定了任務難度。噪聲太大模型的擬合能力會被噪聲淹沒損失很難降下去噪聲太小又顯得太“假”體現(xiàn)不出泛化的意義。0.01這個值在視覺上會讓結(jié)果看著幾乎是一條干凈的直線但又能明顯感受到參數(shù)估計不是精確命中而是有一定波動非常適合展示訓練收斂的過程。2.2 模型、損失和梯度先動手推導再寫代碼線性回歸的模型長這樣y_hat X w b損失函數(shù)用均方誤差loss (1 / n) * sum((y_hat - y)^2)這些公式本身不復雜但真正考驗人的是梯度的推導。手寫訓練閉環(huán)不允許調(diào)用loss.backward()你得先寫出每個參數(shù)的偏導數(shù)再把它翻譯成代碼。對均方誤差求偏導后梯度是這樣梯度_w (1 / n) * X^T (y_hat - y) 梯度_b (1 / n) * sum(y_hat - y)我最初犯過一個經(jīng)典錯誤想當然地以為梯度是X^T (y - y_hat)結(jié)果符號反了參數(shù)越更新越離譜。后來我總結(jié)了一個口訣算梯度的時候看的是損失對參數(shù)的敏感度不是參數(shù)對損失的敏感度方向反了就成了“梯度上升”每一步都在往損失增大的方向走。理論上梯度可以用數(shù)值法驗證給w加一個小擾動看損失變化量除以擾動值近似等于解析梯度。我在復現(xiàn)時用過這個辦法確實能快速定位行列是否對齊、符號是否寫反。2.3 有了梯度怎么更新參數(shù)得到梯度之后更新規(guī)則是標準的隨機梯度下降w - lr * grad_w b - lr * grad_b這里的核心超參數(shù)是學習率lr。李沐給的示例是lr0.03但如果你換一批數(shù)據(jù)或者改變特征的取值范圍這個值可能需要重新調(diào)。學習率太小時收斂極慢可能幾百輪都看不到明顯變化學習率過大則會出現(xiàn)損失震蕩甚至爆掉。我后來跑的時候把學習率調(diào)到0.1試過參數(shù)學得飛快但后期會在真實值附近來回震蕩細看損失曲線像鋸齒一樣這時就需要配合衰減策略才能穩(wěn)定。3. 手寫訓練閉環(huán)的關鍵代碼與執(zhí)行細節(jié)3.1 全套代碼長什么樣先把完整代碼貼出來再逐段解釋。注意這段代碼的目的不是展示PyTorch的用法而是讓你能看到“訓練”的本質(zhì)import torch # 1. 生成數(shù)據(jù) true_w torch.tensor([4.0, -3.0]) true_b torch.tensor([2.0]) X, y synthetic_data(true_w, true_b, 1000) # 2. 初始化模型參數(shù) w torch.normal(0, 0.01, size(2, 1), requires_gradTrue) b torch.zeros(1, requires_gradTrue) # 3. 定義模型和損失 def linreg(X, w, b): return torch.matmul(X, w) b def squared_loss(y_hat, y): return (y_hat - y.reshape(y_hat.shape)) ** 2 / 2 # 4. 定義優(yōu)化算法 def sgd(params, lr, batch_size): with torch.no_grad(): for param in params: param - lr * param.grad / batch_size param.grad.zero_() # 5. 訓練 lr 0.03 num_epochs 5 batch_size 32 net linreg loss squared_loss for epoch in range(num_epochs): for i in range(0, len(X), batch_size): batch_X X[i:ibatch_size] batch_y y[i:ibatch_size] l loss(net(batch_X, w, b), batch_y) l.sum().backward() sgd([w, b], lr, batch_size) with torch.no_grad(): train_l loss(net(X, w, b), y) print(fepoch {epoch 1}, loss {float(train_l.mean()):f})跑完5個epoch輸出類似這樣epoch 1, loss 2.145678 epoch 2, loss 0.283912 epoch 3, loss 0.044512 epoch 4, loss 0.008317 epoch 5, loss 0.002345最終打印學到的參數(shù)會很接近[4.0, -3.0]和2.0但不會完全相等。這個“不完全相等”不是缺陷而是隨機梯度下降和噪聲共同作用下的正常結(jié)果——理解這件事是從“照著代碼敲”走向“真正理解訓練”的關鍵一步。3.2 為什么w用正態(tài)初始化b用零初始化我見過不少人在這里糾結(jié)為什么w要torch.normal(0, 0.01)初始化而b直接設成0背后的邏輯說穿了很簡單如果所有w都初始化為0那么在一個全連接層里每個神經(jīng)元拿到的梯度在首輪是完全相同的因為輸入特征和輸出誤差都一樣所有參數(shù)會同方向更新相當于每層只有一個有效參數(shù)在學習這就是所謂的“對稱性問題”。用隨機小值初始化是為了打破對稱性讓每個參數(shù)走上不同的更新路徑。b設成零則沒有這個顧慮。偏置項不參與特征和參數(shù)之間的乘法交互哪怕初始為0第一輪梯度就能把它拉起來不會出現(xiàn)“所有偏置一樣”的困擾。這也是PyTorch里nn.Linear默認偏置初始化的底層邏輯。如果你以后自己設計網(wǎng)絡層初始化的套路基本就兩條小隨機數(shù)打破對稱偏置盡量從0或極小值開始。3.3 損失的sum()和mean()之爭以及除的那個batch_size在訓練循環(huán)里我給每個batch計算損失后調(diào)用的是l.sum().backward()而不是l.mean().backward()。這有什么實質(zhì)區(qū)別區(qū)別在于梯度的大小。如果一種實現(xiàn)用mean()那么梯度是每個樣本梯度的平均值此時學習率的量級和batch大小無關如果用sum()梯度是每個樣本梯度的總和batch越大梯度越猛學習率必須相應縮小。李沐代碼里在sgd函數(shù)中手動除以batch_size本質(zhì)上就是既用了sum()求梯度又在更新時做了平均的補償。很多初學者會問“那這兩個選哪個好”我的建議是鎖定其中一種并且在調(diào)參時時刻記得自己選的是哪種。否則你會發(fā)現(xiàn)換了一個batch_size最優(yōu)學習率突然就不起作用了——這很可能不是模型問題而是損失聚合方式變了梯度量級跟著變了。3.4 手動清零grad的必要性在sgd函數(shù)中有一行param.grad.zero_()這一行很容易被忽略但它的作用極其關鍵。PyTorch在backward()時是“累加”梯度而不是覆蓋梯度。如果不手動清零每輪迭代后梯度就會疊加到之前的值上參數(shù)更新方向會被歷史梯度污染收斂過程變得非常怪異——你以為模型在正常學習實際上它每次都在用“所有歷史梯度的總和”更新自己。手動清零這個操作就是確?!斑@一輪的梯度只屬于這一輪”。這個設計和我的一個舊習慣沖突過以前用純NumPy實現(xiàn)時每次手動計算完梯度梯度變量就丟掉了不存在累加問題。換到PyTorch后自動求導的存在讓梯度變成了“帶記憶”的狀態(tài)這恰恰是框架與純手寫最大的心智差異。你在讀李沐的代碼時看到每個batch的backward()之前或之后都有zero_要形成條件反射以后自己寫訓練循環(huán)才不會踩坑。4. 收斂過程可視化loss曲線、參數(shù)逼近與學習率觀察4.1 畫出誤差曲線訓練才真正“看得見”我在第一次手寫實現(xiàn)時只盯著loss數(shù)值看總覺得少點什么。后來把過程中的loss記錄到列表里繪制出來才真正感受到梯度下降的節(jié)奏。loss_history [] for epoch in range(num_epochs): for i in range(0, len(X), batch_size): batch_X X[i:ibatch_size] batch_y y[i:ibatch_size] l loss(net(batch_X, w, b), batch_y) l.sum().backward() sgd([w, b], lr, batch_size) with torch.no_grad(): train_l loss(net(X, w, b), y) loss_history.append(train_l.mean().item())把loss_history用matplotlib畫出來會得到一條陡峭下降后趨于平緩的曲線。第一輪epoch結(jié)束loss可能還在2.0以上第二輪就到了0.28第三輪0.04之后就貼著噪聲水平緩慢下降了。這個形態(tài)是所有訓練過程的共同模板前期是快速下降的“學習期”后期是緩慢逼近的“微調(diào)期”。4.2 把參數(shù)逼近過程也畫出來能治好你對“訓練到底在干嘛”的困惑只畫loss還不夠。真正的頓悟來自于同時追蹤w和b在每一輪后的取值。我在代碼里加了一行記錄param_history.append((w.detach().clone().numpy(), b.detach().clone().numpy()))跑完后把每一輪學到的w[0]和w[1]畫成折線你會看到它們從最初接近0的隨機起點一步步逼近true_w[4.0, -3.0]而且在逼近目標后還會有小幅抖動。這個抖動的大小和噪聲方差、學習率有關并不需要太擔心。這種可視化方法有一個非常實用的價值當你的模型學歪了curve可視化能立刻告訴你是整體方向不對還是在某個維度上震蕩。比如我調(diào)試時發(fā)現(xiàn)w[1]在-2.8到-3.2之間反復橫跳但w[0]已經(jīng)收斂到3.99附近這往往是某個特征的方差太大導致的需要做特征標準化。數(shù)據(jù)標準化這個坑書里提了一句但沒展開實際中卻非常重要。如果你造的數(shù)據(jù)里x1范圍是0.01~0.02而x2范圍是100~200那么兩個參數(shù)的收斂速度會有天壤之別梯度下降會變得難以捉摸。4.3 學習率從“跑不動”到“飛出去”邊界在哪里學習率是手寫訓練閉環(huán)里最敏感的一個旋鈕。我在復現(xiàn)時試過三組值結(jié)果極具參考性學習率現(xiàn)象原因0.0035個epoch后loss才1.5左右參數(shù)遠未收斂步長太小需要更多輪數(shù)才能到達目標區(qū)域0.035個epoch后loss降到0.002附近參數(shù)基本收斂書上的默認值正好在“穩(wěn)而快”的區(qū)間1.0loss瞬間變成NaN或巨大數(shù)值參數(shù)直接飛了步長太大每次更新跨過了目標區(qū)域梯度在震蕩中不斷放大實際操作中如果遇到NaN第一反應不是檢查數(shù)據(jù)有沒有臟值而是先檢查學習率。線性回歸這種凸函數(shù)都被學習率干翻了更復雜的非凸問題更敏感。手寫代碼最大的好處就是你能看到參數(shù)的每一步變化稍微加幾行打印就能定位為“學習率”還是“梯度錯誤”造成的發(fā)散。提示判斷學習率是否過大有一個快速方法——打印訓練初期一輪內(nèi)的loss變化如果loss在第一輪內(nèi)不降反升或者劇烈震蕩大概率是學習率偏大建議把學習率除以10再看。4.4 batch_size的影響一次看多少本書再總結(jié)規(guī)律隨機梯度下降里的batch_size是另一個影響迭代節(jié)奏的參數(shù)。李沐示例里用了32我嘗試過1、16、64三檔體會如下batch_size1每個樣本都更新一次參數(shù)梯度噪聲極大收斂不光滑但乍一看loss降得很快因為每一輪步數(shù)多。batch_size16折中方案訓練曲線噪聲可控收斂速度也比較快。batch_size64每個epoch的參數(shù)更新次數(shù)少了但梯度方向更穩(wěn)定后期loss曲線平滑前期收斂速度稍慢一些。為什么會這樣因為batch_size越大梯度是對更多樣本的“平均意見”方差更小方向更接近全局梯度但權(quán)重更新次數(shù)也少了整體收斂步數(shù)變少。你可以把它想象成調(diào)查民意問1個人得出的方向很隨機問64個人得出的方向很靠譜但你問64個人需要花更多時間只能少問幾輪。5. 我在復現(xiàn)時踩過的坑和調(diào)試思路5.1 坑一廣播機制把loss的形狀悄悄變了這個坑出現(xiàn)在計算損失的那一行。我的原始代碼長這樣def squared_loss(y_hat, y): return (y_hat - y) ** 2 / 2看起來挺對但在訓練循環(huán)里batch_y是從y切片來的形狀是(32, 1)而y_hat也是(32, 1)兩者相減沒問題??梢坏┠硞€batch恰好只有一條數(shù)據(jù)y_hat變成(1,)而y還是(1, 1)廣播機制會悄悄把形狀變成(1, 1)代碼不報錯但語義已經(jīng)變了。這種“靜默廣播”問題極難定位因為程序跑得很順暢loss數(shù)值也正常但結(jié)果就是不收斂。后來我學乖了在損失函數(shù)里強制reshapey y.reshape(y_hat.shape)這樣能保證兩個張量的形狀永遠一致避免廣播歧義。這種問題在純手寫代碼里很常見因為你不依賴高層API幫你管好形狀每一處都得自己負責。排查時最簡單的方法是加斷言assert y_hat.shape y.shape報錯即暴露。5.2 坑二梯度下降每一步都用no_grad還是偶爾忘記了在sgd函數(shù)里我們手動修改param的數(shù)值用的是param - lr * param.grad / batch_size。如果不在torch.no_grad()上下文里執(zhí)行這個操作PyTorch會把這個“參數(shù)更新”也記錄到計算圖里產(chǎn)生新的梯度路徑導致后續(xù)backward時計算圖越滾越大訓練速度越來越慢甚至顯存暴漲。我一開始偷懶沒加no_grad跑了幾個epoch后感覺代碼越來越慢忍不住打了一堆print排查最后才想到是計算圖在累積。這個問題的隱蔽性在于前幾個epoch非??鞄缀醪煊X不到異常但累積到一定量級后計算圖和內(nèi)存占用會像滾雪球一樣膨脹。寫手寫訓練閉環(huán)時請養(yǎng)成一個習慣凡是手動修改參數(shù)的操作都包在with torch.no_grad():里。我曾經(jīng)見過有同學在參數(shù)更新后又調(diào)用了一次損失計算導致參數(shù)更新也被納入了計算圖整個調(diào)試過程非常崩潰。5.3 坑三梯度為0參數(shù)紋絲不動到底哪里錯了另一次卡了我很久的問題是打印梯度時發(fā)現(xiàn)param.grad竟然全是0參數(shù)根本不動。檢查代碼模型、損失、初始化都看不出問題。最后發(fā)現(xiàn)我在構(gòu)造w的時候用了.detach().clone()再賦值導致后面的requires_grad標志沒有傳播過去。還有些同學會在中途對w做原地操作時不小心讓requires_grad消失。排查思路其實很直接在第一個batch前手動打印w.grad看看有沒有值沒有值再檢查requires_grad是否為True一層層往上倒追。很多看起來神秘的問題最后都落在這類“看似無關緊要的Tensor狀態(tài)”上。手寫代碼的優(yōu)勢就在于每一條計算鏈路都是自己搭的只要耐心打點逐段排查很容易找到斷點。5.4 坑四特征排列順序?qū)κ諗克俣鹊挠绊戇€有一個容易被忽略的細節(jié)特征的量綱差異。李沐書里代碼默認X ~ N(0, 1)所以不需要標準化。但如果你照著實現(xiàn)卻把真實數(shù)據(jù)換成房價預測之類的場景——面積可能是幾十到幾百平米房齡是1到50年房間數(shù)是1到10——三個特征的方差差距就很大。梯度下降在量綱差異大的特征上會表現(xiàn)得很奇怪梯度更新的主要方向被數(shù)值大的特征主導數(shù)值小的特征幾乎學不動。這不是線性回歸的缺陷而是樸素梯度下降的固有弱點。碰到這種數(shù)據(jù)建議先把每個特征減去均值、除以標準差再做訓練。我把這個測試做過一個有趣的對照同一份數(shù)據(jù)標準化前w1和w2的收斂速度差了3倍以上標準化后幾乎同步收斂最終精度也更好?,F(xiàn)在再做線性回歸的從零實現(xiàn)我會直接默認數(shù)據(jù)標準化即便當前數(shù)據(jù)本來就不需要也能避免很多隱含問題。6. 手寫實現(xiàn)與PyTorch高層API的銜接6.1 從手寫代碼到nn.Linear只是封裝不是魔法李沐在后面的章節(jié)里會切換到nn.Linear、nn.MSELoss、optim.SGD這些高層API這會讓代碼大幅精簡。有人擔心前面花這么大力氣手寫會不會白費完全不會。nn.Linear的底層邏輯和我們的手寫實現(xiàn)幾乎一模一樣初始化一個權(quán)重矩陣和一個偏置向量前向就是x weight.T bias。nn.MSELoss等價于我們定義的squared_loss只是額外做了mean歸一化。optim.SGD則對應我們的sgd函數(shù)只是自動處理了梯度的清零、更新和參數(shù)狀態(tài)管理。有一次我用nn.Linear替換了手寫模型后梯度曲線的走向和手寫時完全一致唯一的差異是nn.MSELoss默認除以樣本數(shù)導致loss數(shù)值比手寫里sum()后除以batch_size略有不同。這就驗證了一件事框架做得再高級底層沒有魔法只是把我們從零實現(xiàn)時的數(shù)學和步驟打包了。6.2 什么情況下還值得繼續(xù)手寫在快速迭代項目里我不會傻傻地手寫每一個模型。但下面這三種情況我一定會回到手寫方式調(diào)試復雜模型時。當Transformer的訓練loss詭異暴漲框架自動求導又看不出問題所在時我會把某個最小子模塊比如單個注意力頭的梯度用手寫方式復算一遍對比兩邊梯度是否一致。這種方法幫我找出過兩個非常隱蔽的bug。研究新優(yōu)化器時。想試一個新優(yōu)化器、新?lián)p失函數(shù)手寫梯度是最快驗證想法的方式。直接改幾行數(shù)學代碼比翻閱框架文檔找有沒有內(nèi)置實現(xiàn)更快。教學和講解時。給別人講清楚“訓練到底是什么”手寫一個回歸版本往往比對著框架文檔說一百句都有效。6.3 手寫過程中的代碼組織心得最后順手分享一個我后期總結(jié)的代碼組織習慣。不要把所有代碼塞進一個單元格或一個腳本里而是按模塊拆開哪怕只是一個幾十行的demodata.py # 數(shù)據(jù)生成 model.py # 模型定義 loss.py # 損失函數(shù) sgd.py # 優(yōu)化器 train.py # 訓練循環(huán)一開始我覺得這種拆法小題大做但當我需要同時調(diào)試幾個不同版本的學習率、初始化方案時立刻感受到了好處每改一個環(huán)節(jié)只需要動一個文件也不會因為修改了數(shù)據(jù)生成代碼而誤傷訓練循環(huán)。等后續(xù)學CNN、RNN時這個習慣會讓你在“手寫閉環(huán)”的基礎上更好地理解更復雜的框架流程。7. 從這個最小閉環(huán)延伸下一步還能驗證什么把線性回歸從零實現(xiàn)跑通之后你可以在這個極簡框架上做幾個小實驗每一項都花不了多少時間但對理解深度學習有實實在在的加成把學習率改成0.1用一個很小的數(shù)據(jù)集觀察參數(shù)是不是在真值附近來回震蕩理解“不收斂”和“震蕩”的邊界。把噪聲方差從0.01改成0.5看看loss降到多少以后就不再下降了——這是“模型容量和噪聲底限”的最直觀感受。把batch_size改成1跑夠幾十個epoch體會隨機梯度下降與全量梯度下降的差異。把“線性”模型改成帶ReLU的兩層網(wǎng)絡你會發(fā)現(xiàn)同樣的訓練閉環(huán)代碼幾乎不用大改這恰恰說明了線性回歸這一章的普適性。我在這里折騰了兩天最大的收獲不是學會了怎么用PyTorch而是理解了一條最根本的原則訓練一個模型本質(zhì)上就是反復重復“前向計算、求出誤差、根據(jù)誤差調(diào)整參數(shù)”這三件事。后面所有看似高深的架構(gòu)無論是CNN里的卷積核、Transformer里的注意力矩陣還是GAN里的對抗博弈核心都沒有逃開這個循環(huán)。先把最小閉環(huán)吃透再去看復雜的模型你會發(fā)現(xiàn)自己看得懂的不只是代碼而是代碼背后那一整套“為什么這樣設計”的邏輯。如果你正在逐行啃李沐的書我特別建議你試著不看答案把這一節(jié)完整重寫一遍再對照書上代碼找出差異。你會發(fā)現(xiàn)自己寫出來的代碼和書上的代碼可能風格迥異但訓練效果殊途同歸。那一刻你才算是真正把這個最小閉環(huán)消化成了自己的東西。