習(xí)第一課:用線性模型理解神經(jīng)網(wǎng)絡(luò)核心原理)
1. 為什么線性模型值得作為深度學(xué)習(xí)的第一課1.1 它到底在解決什么問題作為帶過不少人入門的老手我被問過很多次“深度學(xué)習(xí)的第一個模型該學(xué)什么”。我的回答從來沒變過寫一個最簡單的線性模型。這個答案看起來太樸素甚至有點(diǎn)對不起“深度學(xué)習(xí)”四個字但真正做完之后你會發(fā)現(xiàn)它能幫你把神經(jīng)網(wǎng)絡(luò)的骨架一次性搭清楚。題目標(biāo)題里寫的liner我默認(rèn)是linear的筆誤我們聊的就是基于linear線性模型的生成也就是讓模型從數(shù)據(jù)中“生成”出一組能擬合規(guī)律的參數(shù)。線性模型解決的是這樣一類問題給定一系列輸入和輸出我們希望找到一個從輸入到輸出的線性映射關(guān)系。舉個例子房價和面積之間如果近似成正比那么面積每增加一平米房價增加多少就是一個線性關(guān)系。我們不需要一開始就上卷積神經(jīng)網(wǎng)絡(luò)只要用一條直線y kx b去擬合數(shù)據(jù)點(diǎn)模型自動把k和b學(xué)出來這就是線性模型最原始的場景。放在深度學(xué)習(xí)語境下線性模型不只是“直線擬合”這么簡單。它是神經(jīng)網(wǎng)絡(luò)的最小單元。我們常聽到的全連接層、稠密層、MLP里的Linear層本質(zhì)上都是線性變換。你在Transformer里看到的Q、K、V矩陣第一步也是線性映射。所以如果能把線性模型的原理吃透后面看任何模型源碼都不會心虛。1.2 深度學(xué)習(xí)框架里的線性層長什么樣在PyTorch中torch.nn.Linear(in_features, out_features)就是深度學(xué)習(xí)中最常見的線性層。它內(nèi)部維護(hù)兩個參數(shù)權(quán)重矩陣W和偏置向量b。前向傳播的計算公式是y x W.T b。這個公式和中學(xué)生學(xué)的一次函數(shù)ykxb幾乎沒有區(qū)別只是從標(biāo)量擴(kuò)展到了向量和矩陣。當(dāng)輸入是一個批量的樣本時假設(shè)形狀是[batch_size, in_features]Linear層會把它映射成[batch_size, out_features]的輸出。每個輸出神經(jīng)元做的事情就是把輸入向量的每個維度分別乘以一個權(quán)重再求和最后加上偏置。整個過程中沒有任何非線性變換這也是它被稱為線性的原因。理解了Linear層你就理解了深度學(xué)習(xí)里大約六成的基礎(chǔ)代碼。因?yàn)闊o論你怎么堆網(wǎng)絡(luò)最底層的計算單元依然是線性變換加激活函數(shù)。激活函數(shù)負(fù)責(zé)引入非線性線性變換負(fù)責(zé)特征組合。線性層沒有激活函數(shù)也能存在但它單獨(dú)使用只能表達(dá)線性關(guān)系。這就是本文要做的核心實(shí)驗(yàn)只用一個線性層讓模型從數(shù)據(jù)中學(xué)會生成正確的參數(shù)。1.3 一個“簡單”的線性模型也沒那么簡單很多人覺得線性模型太基礎(chǔ)不需要專門學(xué)。但“會調(diào)用API”和“理解原理”是兩碼事。我見過不少同學(xué)能跑通CNN卻說不清為什么學(xué)習(xí)率過大會導(dǎo)致loss變成NaN也不知道為什么模型參數(shù)不更新時先查一下梯度是否為0。本質(zhì)上就是對梯度更新這條鏈路缺乏肌肉記憶。線性模型正好把所有核心概念壓縮到最有辨識度的小場景里前向傳播、損失函數(shù)、梯度計算、參數(shù)更新、收斂判斷。這些概念在復(fù)雜模型里被大量代碼和網(wǎng)絡(luò)結(jié)構(gòu)掩蓋但在線性模型里每一個都可以直接用數(shù)學(xué)公式寫出來。你把這一套流程跑通遇到的坑踩一遍以后再切換到神經(jīng)網(wǎng)絡(luò)遇到的問題基本是同一批只是規(guī)模變大而已。用真實(shí)項(xiàng)目心態(tài)來對待這個“玩具模型”很重要。線性模型在生產(chǎn)里依然被廣泛使用比如銷售預(yù)測、溫度預(yù)測、廣告點(diǎn)擊率預(yù)估的baseline。它更是你生成式AI實(shí)驗(yàn)的起點(diǎn)。今天這篇文章我們就從數(shù)據(jù)生成開始手寫一個線性模型再用PyTorch復(fù)現(xiàn)一遍最后聊聊那些訓(xùn)練中必然會踩的坑。2. 先把數(shù)學(xué)寫明白前向傳播、損失和梯度2.1 前向傳播的矩陣表示要用代碼實(shí)現(xiàn)線性模型第一步是把數(shù)學(xué)公式寫清楚。假設(shè)我們有m個樣本每個樣本只有一個特征x那么前向傳播就是y_pred x * w b在批量訓(xùn)練時可以把所有樣本堆疊成一個列向量X形狀是[m, 1]。權(quán)重w是標(biāo)量b是標(biāo)量那么y_pred X * w b如果特征不止一個那么X變成[m, n]w變成[n, 1]輸出仍然是[m, 1]。用矩陣乘法寫就是y_pred X w b。這里的 在NumPy和PyTorch中都表示矩陣乘法。為什么需要矩陣形式因?yàn)樯疃葘W(xué)習(xí)訓(xùn)練時永遠(yuǎn)是一次處理一批數(shù)據(jù)。如果不用矩陣就得寫for循環(huán)逐個樣本計算效率極低。更重要的是矩陣形式下梯度公式非常優(yōu)雅可以利用線性代數(shù)的性質(zhì)直接推導(dǎo)。你只要記住輸入的形狀是[batch, features]權(quán)重形狀是[features, output_dim]輸出就是[batch, output_dim]。這個形狀匹配規(guī)則在以后搭建任何線性層時都不會變。2.2 損失函數(shù)為什么要用MSE模型輸出的預(yù)測值和真實(shí)值之間一定有誤差。我們需要一個數(shù)值指標(biāo)來衡量誤差有多大這個指標(biāo)就是損失函數(shù)。線性模型最常用的損失函數(shù)是均方誤差MSE公式為L (1/m) * Σ (y_pred - y_true)^2為什么用平方而不是絕對值原因有三個。第一平方函數(shù)處處可導(dǎo)尤其在誤差接近0的時候?qū)?shù)也平滑趨近0這非常有利于梯度下降絕對值函數(shù)在0點(diǎn)不可導(dǎo)雖然也有專門處理辦法但對入門來說沒必要增加難度。第二平方放大了大誤差的懲罰模型會更努力去修正那些偏離得離譜的預(yù)測。第三MSE對應(yīng)高斯噪聲下的最大似然估計統(tǒng)計意義很清晰。當(dāng)然MSE也有缺點(diǎn)它會對離群點(diǎn)過度敏感。一個極端異常值會讓模型拼命往那個點(diǎn)靠攏。但在我們這一篇的線性模型場景中數(shù)據(jù)是自己生成的服從正態(tài)分布噪聲MSE就是最自然的選擇。理解MSE后面再看交叉熵、KL散度都會有一個對照基準(zhǔn)。2.3 梯度的直觀理解有了損失函數(shù)我們要找到一組w和b讓L盡可能小。怎么找梯度下降。梯度是一個向量它指向損失函數(shù)在當(dāng)前參數(shù)處上升最快的方向。想讓損失下降就要沿著負(fù)梯度方向更新參數(shù)。對線性模型梯度可以手推出來。先求出預(yù)測誤差error y_pred - y_true那么dL/dw (2/m) * X^T errordL/db (2/m) * Σ error這里的關(guān)鍵是誤差error是驅(qū)動一切更新的源頭。預(yù)測偏高了參數(shù)就往下調(diào)預(yù)測偏低了參數(shù)就往上調(diào)。整個過程和“一個人看自己偏離目標(biāo)多遠(yuǎn)然后往回走”是一樣的。理解這一點(diǎn)后再去看PyTorch里的loss.backward()你就知道它其實(shí)就是在自動計算這一堆導(dǎo)數(shù)沒有任何玄學(xué)。3. 用NumPy手寫一個可訓(xùn)練的線性模型3.1 準(zhǔn)備數(shù)據(jù)用帶噪聲的線性函數(shù)生成為了驗(yàn)證模型能不能學(xué)會參數(shù)我們得先造一批“標(biāo)準(zhǔn)答案”。真實(shí)規(guī)律設(shè)定為y 3.2x 0.8然后疊加一些正態(tài)分布噪聲。這樣模型能學(xué)到的參數(shù)不會和真實(shí)參數(shù)完全一樣但會非常接近。噪聲的存在讓任務(wù)更真實(shí)也讓我們可以觀察損失最終降到什么程度。數(shù)據(jù)生成代碼如下import numpy as np np.random.seed(42) X np.random.rand(200, 1) * 5 # 生成200個[0,5]區(qū)間內(nèi)的x true_w 3.2 true_b 0.8 y true_w * X true_b np.random.randn(200, 1) * 0.5這里X的形狀是[200, 1]y的形狀也是[200, 1]。噪聲標(biāo)準(zhǔn)差設(shè)置為0.5所以最終MSE的理論下限大約是0.25也就是噪聲方差0.5的平方。如果你發(fā)現(xiàn)訓(xùn)練后loss低于0.25反而說明模型把噪聲也學(xué)進(jìn)去了屬于過擬合信號。這一步很多人會忽略但它其實(shí)是判斷模型是否正常收斂的標(biāo)尺。3.2 前向傳播與損失計算接下來定義前向傳播和損失函數(shù)。代碼非常簡潔def forward(X, w, b): return X w b def mse_loss(y_pred, y_true): return np.mean((y_pred - y_true) ** 2)注意np.mean會把所有樣本誤差的平方取平均這樣損失就是標(biāo)量。為什么不直接用np.sum因?yàn)槿绻麡颖緮?shù)變成1000損失值會變大不同batch之間就沒法比較。除以樣本數(shù)之后損失表示“平均每個樣本錯多少”語義更清晰。初始化參數(shù)時w一般用隨機(jī)數(shù)b可以初始化為0。這里有個小技巧w的初始值不要太大。如果初始w是10模型一開始預(yù)測會非常大誤差和梯度都很大可能直接導(dǎo)致參數(shù)更新震蕩。常見的做法是讓w初始值在0附近比如np.random.randn(1, 1) * 0.1。3.3 梯度下降更新代碼有了前向傳播和損失就可以寫訓(xùn)練循環(huán)。這里我選擇手動計算梯度因?yàn)檫@是理解整個深度學(xué)習(xí)最關(guān)鍵的節(jié)點(diǎn)。w np.random.randn(1, 1) * 0.1 b np.zeros((1, 1)) lr 0.01 epochs 500 for epoch in range(epochs): y_pred forward(X, w, b) loss mse_loss(y_pred, y) error y_pred - y grad_w (2.0 / len(X)) * X.T error grad_b (2.0 / len(X)) * np.sum(error) w w - lr * grad_w b b - lr * grad_b if epoch % 50 0: print(fepoch {epoch}, loss {loss:.4f}, w {w.item():.4f}, b {b.item():.4f})訓(xùn)練過程中每隔50個epoch打印一次。你會發(fā)現(xiàn)loss從某個初始值逐漸下降w慢慢接近3.2b接近0.8。如果lr設(shè)置合適500輪之后就能收斂到差不多位置。這里的(2.0 / len(X))正是MSE對w求導(dǎo)的結(jié)果系數(shù)2不能漏否則梯度方向是對的但每一步的步長會錯一半導(dǎo)致收斂變慢。3.4 跑起來看結(jié)果訓(xùn)練完成后把學(xué)到的參數(shù)和真實(shí)參數(shù)對比你會看到類似這樣的輸出epoch 450, loss 0.2381, w 3.1894, b 0.8236w和b沒有完全等于3.2和0.8因?yàn)閿?shù)據(jù)里有噪聲模型只能無限接近真實(shí)的線性規(guī)律。如果把原始數(shù)據(jù)散點(diǎn)圖和擬合直線畫在同一張圖里可以看到直線穿過了點(diǎn)云的中心。這說明模型已經(jīng)成功“生成”了一個線性映射。這一步的意義在于我們用最基礎(chǔ)的工具復(fù)現(xiàn)了深度學(xué)習(xí)的最小閉環(huán)前向傳播計算預(yù)測值損失函數(shù)量化誤差反向傳播這里手動推導(dǎo)計算梯度然后用梯度更新參數(shù)。之后所有深度學(xué)習(xí)框架做的都是同一件事。把這個閉環(huán)刻進(jìn)腦子里你就算真正入門了。4. 用PyTorch實(shí)現(xiàn)同樣的模型并驗(yàn)證效果4.1 為什么換成PyTorchNumPy版本能讓我們看清每一步的數(shù)學(xué)原理但真實(shí)做深度學(xué)習(xí)時不會手動算梯度。PyTorch的autograd機(jī)制會自動構(gòu)建計算圖并計算梯度我們只需要定義網(wǎng)絡(luò)結(jié)構(gòu)和損失函數(shù)。更重要的是PyTorch的模塊化設(shè)計讓我們可以從線性模型平滑過渡到多層神經(jīng)網(wǎng)絡(luò)。很多人一開始直接學(xué)PyTorch會陷入“為什么需要.backward()”的困惑。但在手寫了NumPy版本之后你看到loss.backward()就知道它等同于我們手動算的grad_w和grad_b看到optimizer.step()就知道它等同于w w - lr * grad_w。有了基礎(chǔ)框架在你眼里就不是魔法而是工具。4.2 搭建模型與訓(xùn)練循環(huán)用PyTorch實(shí)現(xiàn)同樣的線性模型代碼反而更短。但有幾個細(xì)節(jié)必須注意否則會踩坑。import torch import torch.nn as nn # 數(shù)據(jù)轉(zhuǎn)換 X_t torch.tensor(X, dtypetorch.float32) y_t torch.tensor(y, dtypetorch.float32) # 定義模型一個輸入維度為1輸出維度為1的線性層 model nn.Linear(1, 1) # 定義優(yōu)化器和損失函數(shù) optimizer torch.optim.SGD(model.parameters(), lr0.01) loss_fn nn.MSELoss() for epoch in range(500): y_pred model(X_t) loss loss_fn(y_pred, y_t) optimizer.zero_grad() loss.backward() optimizer.step() if epoch % 50 0: print(fepoch {epoch}, loss {loss.item():.4f})這里的model(X_t)會調(diào)用Linear層的forward自動完成x W.T b。訓(xùn)練循環(huán)里有一行很不起眼但極其重要的代碼optimizer.zero_grad()。PyTorch的梯度是累積的如果不清零上一輪的梯度會加到這一輪上導(dǎo)致參數(shù)更新方向出現(xiàn)偏差訓(xùn)練結(jié)果一團(tuán)糟。新手最容易漏掉的就是這一行。4.3 完整代碼與結(jié)果訓(xùn)練結(jié)束后通過以下方式取出參數(shù)print(model.weight.item(), model.bias.item())正常情況下輸出會在3.2和0.8附近。這里有一個值得注意的現(xiàn)象每次運(yùn)行結(jié)果會有細(xì)微差別因?yàn)槟P蛥?shù)初始化是隨機(jī)的。但最終都會收斂到同一片區(qū)域。PyTorch的nn.Linear默認(rèn)會做合理的參數(shù)初始化所以我們的初始w已經(jīng)接近0不需要額外處理。如果你把優(yōu)化器換成Adam比如torch.optim.Adam(model.parameters(), lr0.05)收斂速度會明顯加快。這可能讓你產(chǎn)生“Adam比SGD更好”的錯覺。其實(shí)在簡單凸問題上兩者都能收斂只是路徑不同。線性模型最適合先看SGD的表現(xiàn)因?yàn)樗庇^、更容易配合你手動計算的梯度做驗(yàn)證。4.4 從線性模型推廣到多層感知機(jī)學(xué)會了線性層下一步稍微擴(kuò)展一下就能變成一個兩層的神經(jīng)網(wǎng)絡(luò)。把模型改成model nn.Sequential( nn.Linear(1, 16), nn.ReLU(), nn.Linear(16, 1) )訓(xùn)練代碼幾乎不用改。這時候模型不再是直線了而是一條可以彎曲的曲線。因?yàn)橹虚g加了一層ReLU激活函數(shù)給模型注入了非線性表達(dá)能力。你會發(fā)現(xiàn)對于同樣的線性數(shù)據(jù)這個兩層網(wǎng)絡(luò)也能學(xué)會而且可能比單層線性模型學(xué)得更復(fù)雜。但如果數(shù)據(jù)真的是線性關(guān)系兩層網(wǎng)絡(luò)反而可能過擬合。這個實(shí)驗(yàn)的意義在于讓你看清深度學(xué)習(xí)和線性模型不是兩套完全割裂的東西。多層的神經(jīng)網(wǎng)絡(luò)只是在基礎(chǔ)線性變換之間插入了激活函數(shù)整體訓(xùn)練邏輯依然是前向傳播、求loss、反向傳播、更新參數(shù)。你掌握了線性模型就等于把神經(jīng)網(wǎng)絡(luò)中最核心的骨架握在了手里。5. 訓(xùn)練過程中最常見的坑和調(diào)參建議5.1 學(xué)習(xí)率太大loss爆炸我見過非常多初學(xué)者在第一個訓(xùn)練循環(huán)里就把lr設(shè)成1然后眼睜睜看著loss變成NaN。為什么會這樣回到參數(shù)更新公式w w - lr * grad_w。如果lr太大每次w移動的距離超過真實(shí)需要的范圍就可能越過最優(yōu)區(qū)域來到誤差更大的地方。下一輪的梯度也因此更大參數(shù)進(jìn)一步飛出去最終數(shù)值溢出。你可以用相同的數(shù)據(jù)試試lr0.5或lr1。大概幾十輪之后loss就會變成inf或者nan。這絕不是代碼寫錯了而是優(yōu)化過程不穩(wěn)定。處理方式很簡單把學(xué)習(xí)率調(diào)小比如0.001通常能穩(wěn)定下來。學(xué)習(xí)率跟模型結(jié)構(gòu)、數(shù)據(jù)尺度都有關(guān)系沒有通用最優(yōu)值但從小學(xué)習(xí)率開始總是對的。5.2 特征未歸一化梯度震蕩線性模型對輸入特征的尺度很敏感。如果x的范圍是0到5SGD收斂很快。但如果x的范圍是0到50000損失函數(shù)曲面就會變成一個細(xì)長的碗梯度方向不指向碗底導(dǎo)致收斂極其緩慢甚至來回震蕩。解決辦法是對特征做標(biāo)準(zhǔn)化讓每個維度的均值接近0標(biāo)準(zhǔn)差接近1。標(biāo)準(zhǔn)化代碼很簡單X_mean X_t.mean() X_std X_t.std() X_norm (X_t - X_mean) / X_std這樣處理后損失函數(shù)的等高線更接近圓形梯度下降路徑會平緩很多。這在實(shí)際項(xiàng)目中會比在本文實(shí)驗(yàn)中更重要因?yàn)檎鎸?shí)數(shù)據(jù)的特征尺度往往相差懸殊。記住一條原則深度學(xué)習(xí)模型里輸入的數(shù)值規(guī)模不宜過大盡量保持在0附近。5.3 怎么判斷模型收斂了很多人只看loss數(shù)值變得很小就認(rèn)為訓(xùn)練結(jié)束。但更可靠的判斷是看參數(shù)是否穩(wěn)定。在你手動打印的日志中如果最后幾百個epoch里w和b幾乎不再變化說明已經(jīng)收斂。如果loss還在緩慢下降而參數(shù)已經(jīng)不動可能梯度已經(jīng)太小需要加大學(xué)習(xí)率或改用Adam。還有一個實(shí)用方法畫出訓(xùn)練過程中l(wèi)oss曲線。理想曲線是平滑下降最后趨平。如果loss先降后升說明學(xué)習(xí)率可能偏大開始震蕩了。如果loss全程基本不動可能是初始化或?qū)W習(xí)率的問題也可能是梯度為零。對線性模型來說梯度幾乎不會為零所以更可能是學(xué)習(xí)率太小。5.4 用真實(shí)項(xiàng)目心態(tài)看待“玩具模型”有同學(xué)會問這篇文章里的模型連“深度學(xué)習(xí)”都算不上值得認(rèn)真學(xué)嗎值得。因?yàn)楹芏鄰?fù)雜模型最容易出的問題——loss不降、梯度異常、參數(shù)不更新——在線性模型里就能復(fù)現(xiàn)和排查。用最小場景學(xué)會這些套路比直接抱著一堆預(yù)訓(xùn)練模型瞎調(diào)參有效得多。在生產(chǎn)中線性回歸也是很多業(yè)務(wù)的強(qiáng)基線。你給客戶做的第一個預(yù)測系統(tǒng)往往是從線性模型開始的。它訓(xùn)練快、解釋性強(qiáng)、不容易過擬合。先把線性模型做到極致再考慮加網(wǎng)絡(luò)層數(shù)這是一種非常務(wù)實(shí)的路徑。深度學(xué)習(xí)從來不是“模型越復(fù)雜越好”而是在保證效果的前提下選擇最合適的解決方案。從數(shù)據(jù)生成到手動梯度再到PyTorch自動求導(dǎo)最后到調(diào)參避坑我們把一個簡單的線性模型從里到外過了一遍。如果你能跟著代碼敲一遍再把學(xué)習(xí)率、初始化值改一改觀察loss和參數(shù)的變化比看十篇理論文章都有用。我個人帶人的習(xí)慣是只允許學(xué)員在完成手寫線性回歸之后再去碰CNN和Transformer。這個順序幫很多人避開了“一看就會、一跑就廢”的窘境。