
開門見山問一句SGD 的“隨機”到底隨機在哪很多人的第一反應是“隨機抽樣本”再追問“那為什么隨機抽樣本反而比用全部樣本效果好”答上來的人就少了一大半。我這些年面試算法崗簡歷上十個有九個寫“熟悉 SGD”但能把隨機梯度下降里這個“隨機”拆開講明白的確實不多。這篇文章不打算停留在概念層面我會直接手寫代碼把全批量梯度下降、隨機梯度下降、小批量隨機梯度下降放在一起做可視化對比再補一個非凸函數(shù)上的實驗讓你從梯度估計、收斂路徑、loss 震蕩這幾個維度真正理解“隨機”二字的含義。內容不挑基礎只要你寫過一點 Python、知道導數(shù)怎么算就能跟下來??赐曛笠院罂?PyTorch 里的torch.optim.SGD你會有一種“原來如此”的感覺。1. 先搞清楚一件事梯度下降到底在干什么1.1 損失函數(shù)與梯度下山理論要理解 SGD就得先理解梯度下降本身。我習慣用一個比喻你半夜摸黑下山看不見整個山體的全貌只能靠腳下感受坡度坡度最陡的方向就是下降最快的方向你就朝那個方向邁一步然后重新感受坡度繼續(xù)走。這里的“山”就是損失函數(shù)L(w)腳下的“坡度”就是梯度?L(w)邁的步子大小就是學習率η。參數(shù)更新公式就一行w w - η * ?L(w)但這里藏著一個容易被忽略的問題?L(w)是個什么梯度是全部樣本的梯度還是一個樣本的梯度還是一批樣本的梯度教科書上默認講的是“全批量梯度下降”也就是用全部訓練樣本算出一個總損失再對這個總損失求梯度。這在數(shù)學上干干凈凈但放到現(xiàn)實中很奢侈。舉個例子假設訓練集有 100 萬張圖片每迭代一次就要把 100 萬個樣本全部過一遍才算出一個梯度。一次兩次還好迭代幾百個 epoch 就是幾億次前向計算。更麻煩的是很多模型的目標函數(shù)是非凸的全批量梯度下降往往只會穩(wěn)定地滾進離初始點最近的局部極小值后面再也出不來。所以我一直覺得把 SGD 理解成“為了速度犧牲精度”是不準確的。它確實更快但它的優(yōu)點遠不止“快”這么簡單。1.2 全量梯度 vs 單樣本梯度SGD 名字的由來1951 年Kiefer 和 Wolfowitz 提出隨機逼近方法后來 Robbins 和 Monro 在 1951 年前后奠定了隨機優(yōu)化理論再到 1986 年 Rumelhart、Hinton 等人在反向傳播論文里把 SGD 用于神經(jīng)網(wǎng)絡訓練“隨機梯度下降”這個詞就從學術界走進了工程界。那名字里的“隨機”是怎么來的因為每一步迭代它不拿全部樣本算梯度而是從訓練集里隨機抽出一個樣本用這個樣本的梯度當作全量梯度的近似然后更新參數(shù)。w w - η * ?L_i(w) # 其中 i 是隨機抽取的樣本下標注意看這里的梯度帶了下標i意思是“第 i 個樣本產(chǎn)生的梯度”。這個梯度跟全量梯度一比一般是不相等的它是一個有噪聲的估計量。所以 SGD 每一步走得歪歪扭扭不成直線但無數(shù)個歪歪扭扭的步子疊起來反而能走到一個不錯的位置。你們看 PyTorch 里的torch.optim.SGD默認參數(shù)里有個batch_size嗎沒有。因為優(yōu)化器不負責切 batch它只負責更新參數(shù)。數(shù)據(jù)怎么切成一個個 batch是DataLoader的事。這個設計經(jīng)常被新手忽略但恰恰說明一件重要的事所謂“隨機梯度下降”在實際工程里通常指的就是“小批量隨機梯度下降”每次隨機抽一批樣本比如 32 個、64 個而不是嚴格意義上的“每次只抽一個樣本”。2. “隨機”的三層含義數(shù)據(jù)隨機、梯度隨機、路徑隨機2.1 每次迭代從訓練集中隨機抽樣本“隨機”的第一個層面體現(xiàn)在“抽樣本”這個動作上。全批量梯度下降每次用的是同一個數(shù)據(jù)集計算出來的梯度方向是確定的SGD 每次先打亂數(shù)據(jù)再依次或者隨機抽出一個或一批樣本從數(shù)學上講每個樣本被抽中的概率都相等所以叫“隨機”。這一步帶來的直接好處是計算量大幅下降。一個 epoch 里全批量梯度下降只更新 1 次參數(shù)而 SGD 可以更新 N 次N 等于樣本數(shù)或者 N/batch_size 次。參數(shù)更新頻率上去了模型收斂自然就快。這不是什么玄學就是字面上的“更新次數(shù)變多”。不過要注意工程實現(xiàn)里更常用的做法是每個 epoch 先shuffle數(shù)據(jù)然后順序切 batch。這樣能保證在一個 epoch 內每個樣本恰好吃到一次同時 batch 之間的順序又具備隨機性。這是一種“有放回”與“無放回”的折中。嚴格數(shù)學意義的 SGD 是有放回抽樣但實踐的 mini-batch 通常是無放回抽樣加洗牌效果一樣好還更穩(wěn)定。2.2 隨機梯度是真實梯度的無偏估計第二個層面是“梯度隨機”。你隨機抽一個樣本 i 算出來的梯度跟全量梯度之間有偏差但期望上它們相等E[?L_i(w)] ?L(w)也就是說如果反復抽很多次把每個樣本梯度求平均最終會逼近真實梯度。這叫做“無偏估計”。為什么這個性質重要因為它保證了 SGD 在理論上最終能收斂到和批量梯度下降相似的解附近只是路徑更曲折。統(tǒng)計學里有個樸素直覺每個隨機樣本梯度都有噪聲但噪聲平均下來會被抵消。你扔硬幣單次結果是隨機的扔一萬次正反面比例就會接近 1:1。SGD 靠的就是這種“大量噪聲加期望不變”的機制。但要注意無偏并不等于高效。隨機梯度的方差很大尤其當數(shù)據(jù)里存在大量離群點時個別樣本的梯度會非常大導致參數(shù)更新步子邁得特別夸張。這也是為什么后來出現(xiàn) Momentum、RMSProp、Adam 的原因——它們本質上都是在想辦法抑制隨機梯度帶來的大方差。2.3 噪聲是壞事嗎跳出局部極小與正則化效應第三個層面也是最容易誤解的一點隨機梯度的“噪聲”不是缺陷反而是一種優(yōu)勢。全批量梯度下降特別“一根筋”遇到局部極小值梯度為零就停住不動了。但 SGD 因為每一步的梯度是帶噪聲的即使當前到了局部極小點下一步隨機抽到的樣本產(chǎn)生的梯度也大概率不為零于是它就被“踢”出去繼續(xù)探索。你可以把 SGD 想象成一位喝了點小酒的旅人下山的路徑雖然歪歪扭扭但遇到一個小坑時踉蹌一下就邁過去了而全批量梯度下降像個過分謹慎的人走到哪個坑就穩(wěn)穩(wěn)地蹲在哪個坑里。這引出了一個很著名的實驗結果在深度神經(jīng)網(wǎng)絡訓練中SGD 的解往往比全批量梯度下降泛化得更好。2017 年 Keskar 等人的論文對此做了詳細討論后來很多研究跟進雖然機制尚無定論但一個主流解釋是小批量梯度的噪聲對優(yōu)化軌跡施加了一種隱式的正則化效果幫助模型避開那些尖銳的極小值而趨向更平坦的極值區(qū)域。平坦區(qū)域的解對測試數(shù)據(jù)的擾動更不敏感所以泛化性能更好。我們不需要背結論只需要記住一個判斷方法如果你的模型用torch.optim.SGD訓練時 loss 曲線是一條幾乎沒有抖動的光滑曲線你要警惕——這可能說明你用的數(shù)據(jù) loader 順序固定、學習率太低、或者 batch 太大隨機性沒有真正發(fā)揮出來。反而那些看起來“毛毛躁躁”的 loss 曲線往往才是訓練健康的標志。3. 代碼實戰(zhàn)BGD、SGD、Mini-batch 手寫對比3.1 實驗環(huán)境與數(shù)據(jù)準備紙上談兵夠多了下面用代碼把三種優(yōu)化器跑一遍。我用的是最普通的線性回歸任務損失函數(shù)是 MSE梯度可以精確算出來方便對比。環(huán)境只需要numpy和matplotlib不需要深度學習框架這樣能看清楚優(yōu)化器內部的每一步。import numpy as np import matplotlib.pyplot as plt np.random.seed(42) # 生成 200 個樣本真實關系是 y 2x 1加一點高斯噪聲 X np.random.uniform(-2, 2, (200, 1)) y_true 2 * X 1 y y_true np.random.randn(200, 1) * 0.3 plt.scatter(X, y, s8, alpha0.6) plt.xlabel(x) plt.ylabel(y) plt.title(Simulated dataset) plt.show()這組數(shù)據(jù)跟真實場景里的訓練集很像樣本少、有噪聲、線性關系隱藏在噪聲背后。如果優(yōu)化器夠好它應該收斂到接近w2, b1的參數(shù)。我額外說明一下參數(shù)初始化。這里統(tǒng)一用一個固定的隨機種子初始化w0.0, b0.0保證三個實驗的起點完全一致對比才公平。3.2 三個優(yōu)化器完整實現(xiàn)全批量梯度下降BGDdef bgd(X, y, lr0.1, epochs150): m len(y) w np.zeros((1, 1)) b 0.0 history [] for epoch in range(epochs): y_pred X w b grad_w (2 / m) * X.T (y_pred - y) grad_b (2 / m) * np.sum(y_pred - y) w w - lr * grad_w b b - lr * grad_b loss np.mean((X w b - y) ** 2) history.append(loss) return w, b, history注意 BGD 的梯度是全量計算的(2 / m) * X.T (y_pred - y)括號里是每個樣本的預測誤差乘上 X 再除 m得到的就是整個數(shù)據(jù)集的梯度方向。這里沒有隨機因素同一個初始點、同一個數(shù)據(jù)每次跑出來的軌跡完全一樣。隨機梯度下降SGD嚴格版def sgd(X, y, lr0.01, epochs150): m len(y) w np.zeros((1, 1)) b 0.0 history [] for epoch in range(epochs): # 嚴格意義的SGD每次迭代隨機抽一個樣本 idx np.random.choice(m) xi X[idx:idx1] yi y[idx:idx1] y_pred xi w b grad_w 2 * xi.T (y_pred - yi) grad_b 2 * np.sum(y_pred - yi) w w - lr * grad_w b b - lr * grad_b # 每個epoch結束后記錄一次全量loss loss np.mean((X w b - y) ** 2) history.append(loss) return w, b, history嚴格版的 SGD 在每個 epoch 里只更新一次參數(shù)且只用 1 個樣本估計梯度。你會發(fā)現(xiàn)它的 loss 曲線非常顛簸這是正常的。我故意讓它的 lr 比 BGD 低一個數(shù)量級因為單樣本梯度的方差太大學習率稍微調高就容易直接發(fā)散。小批量隨機梯度下降Mini-batch SGDdef mini_batch_sgd(X, y, batch_size16, lr0.05, epochs150): m len(y) w np.zeros((1, 1)) b 0.0 history [] for epoch in range(epochs): # 每個epoch先洗牌再切成小批量 perm np.random.permutation(m) for j in range(0, m, batch_size): idx perm[j:jbatch_size] xi X[idx] yi y[idx] y_pred xi w b n len(idx) grad_w (2 / n) * xi.T (y_pred - yi) grad_b (2 / n) * np.sum(y_pred - yi) w w - lr * grad_w b b - lr * grad_b loss np.mean((X w b - y) ** 2) history.append(loss) return w, b, historyMini-batch 的邏輯是每個 epoch 先把數(shù)據(jù)隨機打亂然后按batch_size16切成 13 個 batch每個 batch 都更新一次參數(shù)。所以一個 epoch 內參數(shù)更新 13 次lOSS 會來回波動但整體趨勢向下。3.3 可視化loss 曲線與收斂路徑對比寫一個調用函數(shù)把三條 loss 曲線畫在同一張圖里w_bgd, b_bgd, hist_bgd bgd(X, y, lr0.1, epochs150) w_sgd, b_sgd, hist_sgd sgd(X, y, lr0.01, epochs150) w_mini, b_mini, hist_mini mini_batch_sgd(X, y, batch_size16, lr0.05, epochs150) plt.figure(figsize(10, 5)) plt.plot(hist_bgd, labelBGD (full batch), linewidth2) plt.plot(hist_sgd, labelSGD (single sample), alpha0.7) plt.plot(hist_mini, labelMini-batch SGD (batch16), alpha0.7) plt.yscale(log) plt.xlabel(epoch) plt.ylabel(MSE loss (log scale)) plt.title(Loss curves: BGD vs SGD vs Mini-batch SGD) plt.legend() plt.grid(True, alpha0.3) plt.show() print(fBGD: w{w_bgd[0][0]:.4f}, b{b_bgd[0]:.4f}) print(fSGD: w{w_sgd[0][0]:.4f}, b{b_sgd[0]:.4f}) print(fMini-batch: w{w_mini[0][0]:.4f}, b{b_mini[0]:.4f})正常跑出來的結果應該是這樣方法w 估計值b 估計值150 epoch 后 lossBGD~1.99~0.98約 0.09SGD嚴格在 1.8~2.1 之間波動在 0.8~1.2 之間波動約 0.10Mini-batch~2.00~0.99約 0.09幾個點值得展開第一BGD 的 loss 曲線最平滑一路穩(wěn)步下降沒有任何抖動。嚴格 SGD 的 loss 曲線有的 epoch 在下降有的在反彈像一個醉漢在下樓。Mini-batch 介于二者之間雖然有震蕩但波動幅度比嚴格 SGD 小得多。第二嚴格 SGD 的最終參數(shù)估計明顯更“不穩(wěn)”單獨跑一次運氣好收斂到接近最優(yōu)運氣差甚至會偏不少。這恰好說明隨機梯度的方差很大。實踐里如果真用嚴格 SGD 訓練模型一般需要配合學習率衰減否則后期參數(shù)會在最優(yōu)值附近來回震蕩無法精確定位。第三Mini-batch 在綜合表現(xiàn)上完勝。它的收斂速度接近 SGD參數(shù)精度接近 BGD訓練時間也短。這就是為什么深度學習框架里的SGD優(yōu)化器實戰(zhàn)中幾乎總是搭配一個大于 1 的batch_size來使用。3.4 非凸函數(shù)上的“隨機”優(yōu)勢實驗線性回歸太“溫柔”了損失函數(shù)的等高線是碗狀的只有一個全局最小點體現(xiàn)不出 SGD 跳出局部極小的本事。我再加一個實驗在一維非凸函數(shù)上做梯度下降直觀看看隨機噪聲如何救命。# 定義目標函數(shù) f(x) 0.2*x^4 x^3 - 2*x^2 - x # 導數(shù): df(x) 0.8*x^3 3*x^2 - 4*x - 1 def f(x): return 0.2 * x**4 x**3 - 2 * x**2 - x def df(x): return 0.8 * x**3 3 * x**2 - 4 * x - 1 xs np.linspace(-5, 4, 500) plt.plot(xs, f(xs), labelf(x), linewidth2) plt.axhline(0, colorblack, linewidth0.5) plt.axvline(0, colorblack, linewidth0.5) plt.xlabel(x) plt.ylabel(f(x)) plt.title(Non-convex function) plt.grid(True, alpha0.3) plt.legend() plt.show()這個函數(shù)有兩個局部極小值一個在x ≈ -4.8附近另一個在x ≈ 1.2附近哪個局部極小值更低可以肉眼對比。我用x0.5作為初始點分別跑全量梯度下降和帶隨機噪聲的梯度下降。def bgd_nonconvex(x0, lr0.01, steps200): x x0 path [x] for _ in range(steps): x x - lr * df(x) path.append(x) return np.array(path) def sgd_noise_nonconvex(x0, lr0.01, steps200, noise_std0.3): x x0 path [x] for _ in range(steps): grad df(x) np.random.randn() * noise_std x x - lr * grad path.append(x) return np.array(path)這里給梯度人為加一個高斯噪聲模擬 SGD 隨機采樣帶來的梯度擾動。跑出來的路徑會展示出兩種完全不同的結局BGD 從 0.5 出發(fā)大概率穩(wěn)定滑入離它更近的那個局部極小點。加了噪聲的 SGD 在前期會大幅擺動有可能越過中間的高坡直接滾進更低的那個極小點。這就是隨機噪聲賦予的“探索能力”。雖然模擬用的不是真數(shù)據(jù)集但機制跟真實訓練完全一致。深度學習的損失函數(shù)動輒上百萬維布滿局部極小和鞍點如果不用帶隨機性的優(yōu)化算法模型基本會被困在最初的坑里出不來。所以 SGD 的“隨機”不是工程妥協(xié)而是數(shù)學上賦予模型逃離局部極小的核心機制。4. 參數(shù)調優(yōu)與經(jīng)驗總結4.1 學習率和 batch_size 怎么配參數(shù)調優(yōu)里學習率和 batch_size 是最關鍵的兩個旋鈕。根據(jù)我自己的經(jīng)驗給幾個實用參考學習率過大SGD 的 loss 曲線會發(fā)散訓練直接崩掉。此時你會看到 loss 變成nan或者猛增幾個數(shù)量級。解決辦法不是反復換學習率而是先調低一個數(shù)量級試試。學習率過小模型收斂極慢鑒定方法是畫 loss 曲線如果 50 個 epoch 后下降幅度太少說明學習率偏低。batch_size 越大梯度估計越接近全量梯度訓練越穩(wěn)定但泛化性可能變差batch_size 越小梯度噪聲越大越容易跳出局部極小但收斂不穩(wěn)。我常用的初始配置是batch_size 取 32 或 64學習率先按 1e-3 起步Adam 場景或 1e-2 起步SGDMomentum 場景跑幾十個 epoch 看曲線趨勢再調整。深度學習項目里很少上來就微調學習率都是先讓模型“跑起來”再根據(jù)曲線形態(tài)反向調節(jié)。4.2 訓練策略shuffle、epoch 與學習率衰減實踐中還有幾個比調參更重要的細節(jié)。首先是數(shù)據(jù)打亂。DataLoader里的shuffleTrue不是可有可無的選項它直接決定每個 epoch 內的 batch 構成是否隨機進而決定隨機梯度下降是否名副其實。如果數(shù)據(jù)原始順序里存在類別聚集比如前 5000 個樣本全是貓、后 5000 個全是狗不打亂就直接切 batch某些 batch 的梯度會嚴重偏斜。其次是學習率衰減。SGD 后期收斂不精確很大程度上是因為學習率恒定。常見的衰減策略有 step decay、指數(shù)衰減、cosine annealing還有 PyTorch 支持的torch.optim.lr_scheduler.ReduceLROnPlateau——它會監(jiān)控 loss如果連續(xù)幾個 epoch 沒下降就自動降學習率。這個調度器我建議大家在 CV / NLP 之外的普通任務里當作首選省心且效果穩(wěn)定。最后是 epoch 和 total steps 的關系。很多人混淆這兩個概念。epoch 是整個訓練集過一遍的次數(shù)total steps 是參數(shù)更新的總次數(shù)。mini-batch 里total steps epoch × (樣本數(shù) / batch_size)??磳W習率調度時有的衰減策略是按 epoch 衰減有的是按 step 衰減搞清楚單位才不會在代碼里踩坑。4.3 常見誤區(qū)SGD 不等于隨機初始化也不等于每次抽一個樣本說幾個我在代碼 review 里經(jīng)常遇見的誤區(qū)。誤區(qū)一覺得“SGD 優(yōu)化器必須搭配單樣本訓練”。實際上 PyTorch 的SGD是支持任意 batch_size 的你把DataLoader的 batch_size 設為 64配合SGD跑的就是 mini-batch 隨機梯度下降。學術論文里寫 “SGD” 時九成指代的也是 mini-batch 版本。誤區(qū)二把隨機初始化權重當成“隨機梯度下降”里的“隨機”。隨機初始化解決的是參數(shù)對稱性問題SGD 里的“隨機”指的是每次梯度估計使用的數(shù)據(jù)子集隨機兩者是不同維度的概念。誤區(qū)三看見 loss 曲線抖動就以為出 bug 了。對 SGD / mini-batch 訓練來說loss 波動是正常的。真正的異常是長期不下降、數(shù)值溢出、或者同一份代碼設置相同的隨機種子卻得到不同結果——后者通常是全局狀態(tài)沒控制好。5. 常見問題與排查技巧實錄5.1 loss 震蕩劇烈但不下降這是最常被問到的現(xiàn)象。loss 曲線像心電圖一樣高頻抖動但整體沒有下降趨勢。我一般按下面的順序排查把學習率調低一個數(shù)量級看是否好轉。如果學習率太高梯度會來回越過最優(yōu)值loss 居高不下。檢查數(shù)據(jù)是否做了歸一化。特征尺度差太大時梯度方向會被大尺度特征主導小批量樣本抽出不同的特征組合導致梯度方向混亂。查看是否存在離群點。個別極端樣本產(chǎn)生的梯度可能遠超普通樣本讓每次更新都像被拽了一下??梢越y(tǒng)計一下梯度范數(shù)的分布如果最大值跟中位數(shù)差幾個數(shù)量級就要考慮裁剪梯度gradient clipping。真實項目中最常見的原因就是學習率偏大加數(shù)據(jù)未歸一化。把StandardScaler接在數(shù)據(jù)前面很多 loss 震蕩問題能夠直接消失。5.2 用了 SGD 還是逃不出局部極小有些朋友跟我說SGD 不是能跳出局部極小嗎怎么我的模型還是收斂得很差這里要潑盆冷水跳出局部極小的能力與噪聲強度成正比而噪聲強度與 batch_size 成反比。如果你把 batch_size 設成 512、1024梯度噪聲被大量樣本平均掉了跟全批量梯度下降幾乎沒有區(qū)別隨機帶來的探索能力也蕩然無存。另外學習率太低也會削弱探索能力。隨機梯度的絕對值太小噪聲更新壓不過勢壘模型照樣困在局部極小里。建議把學習率調高到能明顯看到 loss 波動的水平前幾個 epoch 允許它大幅震蕩然后再做衰減這是一種簡單有效的“預熱 退火”策略。5.3 隨機數(shù)種子到底該不該固定必須固定尤其是你想復現(xiàn)實驗結果的時候。Python 里的隨機數(shù)涉及多個源頭random、numpy.random、PyTorch 的torch.manual_seed、CUDA 的torch.cuda.manual_seed_all。光固定其中一個另幾個還是隨機的。固定種子的意義不只是復現(xiàn)實驗更重要的是讓你能穩(wěn)定地對比不同超參數(shù)。如果每次訓練的數(shù)據(jù)順序都在變你很難分清 loss 的改善是來自學習率調整還是純粹撞運氣。我通常會在腳本開頭統(tǒng)一調用def set_seed(seed42): import random random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)但只說固定種子也不全面。真要追求“完全可復現(xiàn)”還要注意 GPU 算子的非確定性、數(shù)據(jù)加載多線程的隨機性、以及 cuDNN 的 auto-tune。這些東西在生產(chǎn)環(huán)境里很難 100% 鎖死所以我一般把復現(xiàn)目標設定在“同一臺機器、同一套環(huán)境、同一種子”這個級別追求絕對復現(xiàn)意義不大。我個人調試優(yōu)化器時還有個習慣建一個臨時腳本把模型和數(shù)據(jù)縮到一個小規(guī)模版本比如 2000 個樣本、單層網(wǎng)絡然后用不同的優(yōu)化器分別跑 50 個 epoch畫 loss 曲線對比。這種小實驗 5 分鐘就能出結果比在大模型上盲調高效得多。SGD 這類優(yōu)化器的行為在小規(guī)模場景下和大規(guī)模場景下規(guī)律是一致的一旦你在小實驗里確認了“這個 batch_size 下曲線太震蕩”“這個學習率下收斂太慢”放大到真實數(shù)據(jù)時只需要按比例調整學習率方向不會變。最后再分享一個和本文主題直接相關的感悟優(yōu)化的“隨機性”真的是個寶貝。很多人追求讓 loss 曲線平平穩(wěn)穩(wěn)地下滑甚至覺得抖動是訓練不健康的信號。但看完這篇文章你應該明白了跳動意味著探索探索意味著模型有機會發(fā)現(xiàn)更優(yōu)的解。下次訓練時再看到那條毛毛躁躁的下降曲線不用慌這恰好說明你的優(yōu)化器正在用“隨機”的力量幫你把模型推向一個更遠的地方。