teach.pascalyim.com
目录

DL · 章节 1

深度学习 1 —— 线性神经元

在 Kaggle 上打开

课程章节 · 中央理工里尔学院 · Pascal Yim · 2026 年

经过机器学习板块对回归、分类、聚类与降维的系统训练,我们终于来到深度学习的门槛。深度网络由层层堆叠的"神经元"组成,而最简单的神经元——线性神经元 (linear neuron)——其实就是我们已经熟悉的线性回归。本章我们不会一上来就调用 nn.Linear,而是退回到最朴素的形式:从一根标量权重 aa 和一根偏置 bb 开始,亲手把梯度下降的循环写一遍,体会"训练一个神经元"到底意味着什么。打通这一关之后,从单输入到多输入、从批量到随机、从手写代码到 PyTorch,都只是同一个原理的自然推广。

1. 单输入线性神经元

1.1 模型的最小形式

一个单输入线性神经元接收一个标量 xx,经过一次仿射变换输出预测值

u=ax+b.u = a\,x + b.

这里:

  • xRx \in \mathbb{R} 是输入特征,
  • aRa \in \mathbb{R}权重 (weight),
  • bRb \in \mathbb{R}偏置 (bias),
  • uRu \in \mathbb{R} 是预测输出。

形式上,这与前面的简单线性回归毫无区别。区别在于学习方法:线性回归有解析解,而深度学习中的神经元几乎从不依赖解析解,而是用梯度下降逐步逼近最优参数。这种思路一旦掌握,就能无障碍地迁移到层数任意、激活任意、规模任意的神经网络上。

1.2 代价函数:均方误差

给定 nn 个训练点 (xi,yi)(x_i, y_i),我们沿用最常见的 均方误差 (Mean Squared Error, MSE) 作为代价函数:

E(a,b)  =  1ni=1n(yi(axi+b))2.E(a, b) \;=\; \frac{1}{n}\sum_{i=1}^{n} \bigl(y_i - (a x_i + b)\bigr)^2.

ui=axi+bu_i = a x_i + b 为第 ii 个样本的预测,则可写成

E(a,b)=1ni=1n(yiui)2.E(a, b) = \frac{1}{n}\sum_{i=1}^{n} (y_i - u_i)^2.

关键直觉:MSE 是一个关于参数 (a,b)(a, b)碗状二次曲面。曲面的最低点对应最优参数。我们的任务是从某个随机初始点出发,顺着曲面向下"滑"到谷底。

2. 梯度下降

2.1 原理

代价函数 E(a,b)E(a, b) 在参数空间中定义了一张曲面。它的梯度

E=(Ea,  Eb)\nabla E = \left(\tfrac{\partial E}{\partial a}, \;\tfrac{\partial E}{\partial b}\right)

指向曲面在当前点上升最快的方向。如果我们沿着梯度的反方向前进一小步,EE 一定会下降——只要步长足够小。这就是梯度下降的全部哲学:

aaηEa,bbηEb,\begin{aligned} a &\leftarrow a - \eta\,\frac{\partial E}{\partial a}, \\ b &\leftarrow b - \eta\,\frac{\partial E}{\partial b}, \end{aligned}

其中 η>0\eta > 0 称为学习率 (learning rate)。它控制每一步迈得多大。学习率太小,收敛缓慢;学习率太大,可能在谷底两侧来回震荡,甚至发散。我们将在本章末专门讨论这个问题。

2.2 偏导数的链式推导

为了实现梯度下降,我们必须显式地写出两个偏导数。先看单个样本的代价

Ei=12(yiui)2,ui=axi+b.E_i = \frac{1}{2}(y_i - u_i)^2, \qquad u_i = a x_i + b.

(这里加了一个 12\tfrac{1}{2} 因子,是为了让求导后系数变得简洁;它不会影响最优解的位置。) 由链式法则:

Eia=Eiuiuia=(yiui)xi=(uiyi)xi,\frac{\partial E_i}{\partial a} = \frac{\partial E_i}{\partial u_i} \cdot \frac{\partial u_i}{\partial a} = -(y_i - u_i)\cdot x_i = (u_i - y_i)\,x_i, Eib=Eiuiuib=(yiui)1=(uiyi).\frac{\partial E_i}{\partial b} = \frac{\partial E_i}{\partial u_i} \cdot \frac{\partial u_i}{\partial b} = -(y_i - u_i)\cdot 1 = (u_i - y_i).

把所有样本的贡献求平均,就得到批量梯度:

Ea=1ni=1n(uiyi)xi,Eb=1ni=1n(uiyi).\frac{\partial E}{\partial a} = \frac{1}{n}\sum_{i=1}^{n}(u_i - y_i)\,x_i, \qquad \frac{\partial E}{\partial b} = \frac{1}{n}\sum_{i=1}^{n}(u_i - y_i).

关键公式:训练神经元时,每一步都要算出"误差 uiyiu_i - y_i"这个量,再分别用它去乘 xix_i (得到对 aa 的梯度) 和 1 (得到对 bb 的梯度)。整个深度学习的反向传播本质上都是这种"误差 ×\times 输入"的模式。

2.3 三种下降策略:Batch、SGD、Mini-batch

到此为止,我们计算梯度时使用了全部 nn 个训练样本,这叫批量梯度下降 (batch gradient descent)。它确定性强、方向准,但当数据集庞大时,每走一步都要扫遍整张数据集,代价巨大。

实践中常见三种策略:

  • Batch (全批量): 每次更新使用全部 nn 个样本。梯度精确,路径平滑,但慢。
  • SGD (随机梯度下降): 每次更新只随机抽一个样本 (xi,yi)(x_i, y_i) 计算梯度。更新极快、内存极省,但梯度是噪声估计,损失曲线会剧烈抖动。
  • Mini-batch (小批量): 每次更新随机抽取一个大小为 BB 的子集 (典型 B=16,32,64B = 16, 32, 64),用其平均梯度更新参数。

Batch vs SGD 的核心区别: 批量是"少而精"的更新,随机是"多而糙"的更新。Mini-batch 取折中——既保留了向量化计算的硬件友好性,又通过噪声帮助模型逃离糟糕的局部最优。深度学习库默认就是 Mini-batch,因为 GPU 喜欢矩阵而非标量,而小批量正好把数据组织成矩阵。

3. 从零实现 LinearNeuron1D

3.1 类的骨架

把上面的公式翻译成 Python,我们得到一个最简单的可训练神经元类。它包含三个方法:

  • __init__ 随机初始化参数 a,ba, b;
  • forward(x) 计算预测 u=ax+bu = a x + b (整个向量一次算出);
  • fit(x, y, lr, epochs) 通过梯度下降迭代更新 a,ba, b
class LinearNeuron1D: def __init__(self): self.a = np.random.uniform() self.b = np.random.uniform() self.history = [] def forward(self, x): return self.a * x + self.b def fit(self, x, y, lr=0.1, epochs=10): for _ in range(epochs): u = self.forward(x) grad_a = ((u - y) * x).mean() grad_b = (u - y).mean() self.a -= lr * grad_a self.b -= lr * grad_b loss = ((y - u) ** 2).mean() self.history.append(loss)

关键直觉: (u - y) * x(u - y) 这两个向量化表达式,正是我们前面推导的偏导数——只不过把求和写成了 NumPy 的 .mean()。一行公式,一行代码,几乎是字面对应。

3.2 在 abalone_mini 上测试

我们用 abalone (鲍鱼) 数据集预测年轮数 Rings,先只用一个特征 Length:

df = pd.read_csv('abalone_mini.csv') x = df['Length'].to_numpy() y = df['Rings'].to_numpy() model = LinearNeuron1D() model.fit(x, y, lr=0.1, epochs=500) y_hat = model.forward(x) print(f"MAE : {mean_absolute_error(y, y_hat):.2f}") print(f"RMSE : {np.sqrt(mean_squared_error(y, y_hat)):.2f}")

model.history 画出来,我们就能看到一条经典的"L 形"损失曲线:开始几十个 epoch 损失快速下降,随后变缓直至几乎水平。这正是梯度下降逼近谷底的视觉证据。

3.3 加上 SGD 与 Mini-batch

fit 扩展成能在三种模式间切换非常自然——只需在每个 epoch 内决定使用哪些样本计算梯度即可:

def fit(self, x, y, lr=0.1, epochs=10, mode="batch", batch_size=32): n = len(x) for _ in range(epochs): if mode == "batch": idx = np.arange(n) elif mode == "sgd": idx = np.random.randint(0, n, size=1) elif mode == "minibatch": idx = np.random.choice(n, size=batch_size, replace=False) u = self.forward(x[idx]) grad_a = ((u - y[idx]) * x[idx]).mean() grad_b = (u - y[idx]).mean() self.a -= lr * grad_a self.b -= lr * grad_b

np.random.choice(n, size=B, replace=False){0,,n1}\{0, \dots, n-1\} 中无放回抽取 BB 个索引——这是 mini-batch 的标准实现。运行时,你会发现 SGD 模式下 history 曲线锯齿状跳动,而 mini-batch 介于两者之间——这正是噪声水平差异的可视化。

4. 多输入线性神经元

4.1 从标量到矩阵

现实问题几乎从不只有一个特征。鲍鱼有长度、直径、高度、各类重量;房屋有面积、房龄、卧室数。设有 nn 个观测、每个观测 mm 个特征,数据组织成矩阵

XRn×m.X \in \mathbb{R}^{n \times m}.

每一行是一个样本,每一列是一个特征。多输入线性神经元的预测写成

u=Xw+b,u = X w + b,

其中:

  • wRmw \in \mathbb{R}^{m} 是权重向量 (每个特征一个权重),
  • bRb \in \mathbb{R} 仍是标量偏置 (NumPy 的广播会自动把它加到每一行),
  • uRnu \in \mathbb{R}^{n} 是预测向量。

关键公式: 标量 aa 升级为向量 ww,乘法 axa x 升级为矩阵-向量乘法 XwX w,代价函数写法不变。从一维到多维,本质就是这一句话。

4.2 多输入下的梯度

对应的代价函数仍是批量 MSE:

E(w,b)=1ni=1n(yi(xiw+b))2,E(w, b) = \frac{1}{n}\sum_{i=1}^{n}\bigl(y_i - (x_i^\top w + b)\bigr)^2,

梯度推导沿用链式法则,结果同样美观:

wE=1nX(uy),Eb=1ni=1n(uiyi).\nabla_w E = \frac{1}{n}\,X^\top (u - y), \qquad \frac{\partial E}{\partial b} = \frac{1}{n}\sum_{i=1}^{n}(u_i - y_i).

注意 X(uy)X^\top (u - y) 这一项的形状——(m,n)×(n,)=(m,)(m, n) \times (n,) = (m,)——正好与 ww 同形,可以直接做参数更新。

4.3 类 LinearNeuron

仿照一维版本写出多维版本,只有两处差别:权重 wwfit 第一次调用时按 XX 的列数初始化;计算梯度时把标量乘法替换为 X.T @ (u - y):

class LinearNeuron: def __init__(self): self.w = None self.b = np.random.uniform() self.history = [] def forward(self, X): return X @ self.w + self.b def fit(self, X, y, lr=0.1, epochs=10, mode="batch", batch_size=32): n, m = X.shape if self.w is None: self.w = np.random.uniform(size=m) for _ in range(epochs): if mode == "batch": idx = np.arange(n) elif mode == "sgd": idx = np.random.randint(0, n, size=1) else: idx = np.random.choice(n, size=batch_size, replace=False) Xb, yb = X[idx], y[idx] u = self.forward(Xb) grad_w = Xb.T @ (u - yb) / len(idx) grad_b = (u - yb).mean() self.w -= lr * grad_w self.b -= lr * grad_b self.history.append(((y - self.forward(X)) ** 2).mean())

abalone_mini 上,我们丢掉 Ringsyy,其余列当 XX:模型大约几百轮就能稳定到 MAE \approx 1.6 左右——比单输入版本明显改善。

5. 输入归一化:为什么必不可少

5.1 一个失败的例子

把同一个 LinearNeuron 直接套到 house_mini 数据集 (预测房价),你会发现一件令人崩溃的事:不管 epoch 多长,损失要么爆炸到 NaN,要么寸步难行。原因在于房屋特征量级悬殊——面积是几千平方英尺,房龄是几十年,卧室数是个位数。同一个学习率 η\eta 在不同的特征方向上效果完全不同。

5.2 一维理论:曲率与学习率

为看清问题本质,先回到一维情形。考虑二次代价

E(a)=12k(aa)2,k>0,E(a) = \frac{1}{2}\,k\,(a - a^*)^2, \qquad k > 0,

其中 kk 衡量曲率 (海森矩阵就是 kk 自身)。梯度下降递推为

at+1=atηk(ata).a_{t+1} = a_t - \eta\,k\,(a_t - a^*).

设误差 et=atae_t = a_t - a^*,则

et+1=(1ηk)et.e_{t+1} = (1 - \eta k)\,e_t.

为使迭代收敛,需要 1ηk<1|1 - \eta k| < 1,即

0<η<2k.0 < \eta < \frac{2}{k}.

关键直觉: 学习率允许的上限正比于 1/k1/k。曲率 kk 越大 (代价函数越"陡峭"),学习率必须越小;反之,在曲率小的方向上又希望 η\eta 大些以加快收敛。当不同特征对应不同曲率时,找不到一个对所有方向都合适的 η\eta——这就是不归一化时优化失败的根本原因。

5.3 多维情形:让所有方向曲率接近

把所有输入特征缩放到相近量级,代价曲面就从狭长的"香蕉形山谷"变成接近圆形的碗,梯度下降可以选用一个较大的学习率而不发散。常用方法 (来自 sklearn.preprocessing):

  • StandardScaler:零均值、单位方差,xscaled=(xμ)/σx_{\text{scaled}} = (x - \mu)/\sigma。深度学习里最常用。
  • MinMaxScaler:线性映射到 [0,1][0, 1],xscaled=(xxmin)/(xmaxxmin)x_{\text{scaled}} = (x - x_{\min})/(x_{\max} - x_{\min})。保留分布形状,对异常值敏感。
  • RobustScaler:用中位数和四分位距,稳健抗异常值。
from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_train = scaler.fit_transform(X_train) X_test = scaler.transform(X_test)

关键纪律:只能在训练集上调用 fit。在训练/测试切分之前对全数据 fit_transform 等价于让模型偷看了测试集的统计量,这就是数据泄漏 (data leakage)。测试集对模型而言必须永远是"陌生的"。

5.4 复盘

house_mini 加上 StandardScaler 后再训练:

scaler = StandardScaler() X_train = scaler.fit_transform(X_train) X_test = scaler.transform(X_test) model = LinearNeuron() model.fit(X_train, y_train, mode="minibatch", lr=0.01, epochs=500)

lr=0.01 这种"普通"的学习率即可让损失平稳下降——和归一化前的 lr=1e-10 形成戏剧性对比。这一对实验是整章最值得反复体会的瞬间:所谓深度学习的"调参",本质上是在合适的几何尺度上做优化

6. 通往 PyTorch

我们手写的 LinearNeuron 在 PyTorch 中等价于一行:

model = nn.Linear(m, 1)

而我们手写的训练循环对应 PyTorch 的三件套——criterion = nn.MSELoss()optimizer = torch.optim.SGD(model.parameters(), lr=0.01)、以及循环里的 loss.backward() + optimizer.step()。从下一章开始,我们就站在这套抽象之上,把"神经元"叠成"网络"。但要谨记:每一层 nn.Linear 内部跑的,正是我们今天亲手写过的 u=Xw+bu = X w + bX(uy)X^\top (u - y)

练习

  1. 公式推导: 从单样本代价 Ei=12(yiui)2E_i = \tfrac{1}{2}(y_i - u_i)^2ui=axi+bu_i = a x_i + b 出发,自行重推 Ei/a\partial E_i / \partial aEi/b\partial E_i / \partial b,再写出批量版本 (对 i=1,,ni = 1, \dots, n 取平均)。
  2. LinearNeuron1D 实现: 写出包含 __init__forwardfit 三个方法的类。在 abalone_mini 数据集上,以 Length 为输入、Rings 为目标训练 500 个 epoch,打印 MAE/RMSE。
  3. 损失曲线: 给 fit 加上 history 属性,每个 epoch 末尾把当前 MSE 追加进去。训练后用 plt.plot(model.history) 绘制损失曲线,观察其形状。
  4. 三种模式: 把 fit 扩展到 mode ∈ {"batch", "sgd", "minibatch"},新增 batch_size 参数仅在 mini-batch 下生效。比较三种模式下 history 曲线的平滑程度,并解释为何 SGD 抖动最大。
  5. 多输入版本: 实现类 LinearNeuron,在 abalone_mini 上以全部数值列预测 Rings,观察 MAE 是否优于单输入版本。
  6. house_mini 实验: 在不归一化的情况下找一个不发散的学习率 (通常需要非常小,如 101010^{-10});随后引入 StandardScaler,试 lr ∈ {0.01, 0.1, 0.5, 1.0},记录每种设置下的训练曲线,并写下你的结论。
  7. 数据泄漏诊断: 写一段代码,先错误地对整个 XX 调用 scaler.fit_transform,再切分;然后用正确流程重做。比较两种情况下测试集的 MAE,讨论差异。

拓展阅读

  • Goodfellow, Bengio & Courville, Deep Learning (2016): 第 4 章讨论数值计算与优化基础,第 8 章 ("Optimization for Training Deep Models") 系统讲述 Batch/SGD/Mini-batch、学习率、海森矩阵与曲率适应。中文版由人民邮电出版社出版。
  • PyTorch 官方文档 — torch.nn.Linear: https://pytorch.org/docs/stable/generated/torch.nn.Linear.html。建议阅读源码里关于 weightbias 初始化的注释 (Kaiming uniform),与我们这里的 np.random.uniform() 比较。
  • PyTorch 官方教程 — Linear Regression with PyTorch: 演示如何用 nn.Linear + MSELoss + optim.SGD 复现本章手写代码,是从"NumPy 神经元"过渡到"PyTorch 神经元"的最佳一步。
  • scipy.optimize.minimize: 提供 BFGS、L-BFGS、Newton-CG 等更高级的优化算法,可用于在小规模问题上验证手写梯度下降的正确性,并对比收敛速度。
  • Bottou, Curtis & Nocedal (2018), Optimization Methods for Large-Scale Machine Learning, SIAM Review. 一篇被广泛引用的综述,对 SGD 与 mini-batch 的理论性质给出了深入而严谨的分析。
  • sklearn.preprocessing 用户指南: https://scikit-learn.org/stable/modules/preprocessing.html。比较 StandardScalerMinMaxScalerRobustScalerPowerTransformer 各自适用场景。