学深度学习的人几乎都有一个共同的经历:前几课老老实实看线性回归、看感知器,然后某天突然被推到多层网络和反向传播面前,中间的激活函数和梯度下降就被压缩成"调库就行"的黑盒。我带新人的时候特别爱问一个问题——把你手上这个感知器的激活函数从 Sigmoid 换成阶跃函数,你觉得还能用梯度下降训练吗?十个人里有七八个答"能吧,不就是换个函数",然后代码一跑,loss 从头到尾纹丝不动,权重一次都没更新过。
这个问题的答案,恰好把感知器、激活函数、梯度下降这三样东西串成了一条线。感知器是结构,激活函数是那扇决定"能不能求导"的门,梯度下降是沿着门后面的坡度往下走的方法。三者缺一环,这套东西就转不起来。这篇内容我打算从几何直觉讲到代码落地,中间把阶跃、Sigmoid、ReLU 这三种最常出现在入门材料里的激活函数放进同一个感知器里对照跑一遍,再把梯度下降的更新公式一步步推出来,最后把我自己在写这类小模型时反复踩过的坑摊开讲。
不管你是在校学生刚接触这一块,还是工作几年回头补基础,只要写过几行 Python、知道矩阵乘法大概是怎么回事,下面的内容应该都能跟着走完。
1. 感知器不是"神经网络的前身",它是一条会自己转的直线
很多人对感知器的印象停留在"最简单的人工神经元",然后就跳过去了。这个理解不算错,但太薄。感知器真正值得琢磨的地方在于:它是一个用数据反过来修正自己位置的决策边界。前向算一遍得出预测,跟真实标签比一比,错了就挪一挪,挪的方向和幅度由规则决定。整套机器学习的闭环在它身上已经完整了,只是规模小到可以在草稿纸上算完。
1.1 点积的几何含义:权重向量其实是个"朝向"
先把形式写清楚。一个感知器接收输入向量 $x = (x_1, x_2, ..., x_n)$,每个维度配一个权重 $w_i$,再加一个偏置 $b$,算出中间量:
$$z = w_1x_1 + w_2x_2 + ... + w_nx_n + b = w^Tx + b$$
然后交给激活函数 $f$,得到输出 $\hat{y} = f(z)$。
这里最关键的是理解 $w^Tx$ 是什么。它不是抽象的乘法求和,而是一个投影操作:把输入点 $x$ 投影到权重向量 $w$ 所指的方向上,得到的是"这个点在 $w$ 方向上走了多远"。所以 $w$ 本质上定义了一个朝向,$b$ 则决定这条分界线离原点有多远。
二维情况下最直观。取 $w = (1, 1)$,$b = -1$,决策边界就是 $x_1 + x_2 - 1 = 0$,也就是一条斜率为 -1、过点 $(1, 0)$ 和 $(0, 1)$ 的直线。直线上方 $z > 0$,下方 $z < 0$。三维空间里它变成一个平面,再往高维走叫超平面。名字听着唬人,其实就是"把空间一刀切成两半的那玩意儿"。
理解了这一点,训练的含义也就清楚了:训练感知器,就是在找一个合适的 $w$ 和 $b$,让这条线正好卡在两堆样本中间。所谓"学习",学的是这条线的位置和倾角,不是别的。
1.2 为什么中间必须插一个激活函数
如果去掉 $f$,输出就是 $z$ 本身,一个可以取任意实数的量。这东西没法直接拿来做二分类,你总不能说"输出大于 3.7 算正类"——这个阈值从哪来?没有依据。
激活函数干的第一件事就是把无界的实数压到有限的、有语义的区间里。阶跃函数把它压成 0 和 1 两个值,Sigmoid 压成 (0, 1) 之间的概率,ReLU 则保留正值、把负值截成 0。三种做法对应三种完全不同的训练逻辑和适用场景,后面会细说。
第二件事更隐蔽,但更要命:激活函数决定了误差能不能"传回去"。梯度下降要更新权重,得知道"权重稍微变一点,误差会变多少",这个量就是导数。如果激活函数在某处不可导,或者导数恒为 0,那这条链子就断了。原始的感知器用的正是阶跃函数,它的导数在除了 0 以外的地方全是 0——这意味着梯度下降在它身上根本没法工作。
所以历史上感知器的训练压根不用梯度下降,用的是一套叫感知器学习规则的东西:
$$w \leftarrow w + \eta (y - \hat{y}) x$$
分类正确时 $y - \hat{y} = 0$,权重不动;分类错误时按输入 $x$ 的方向推一把,推的幅度由学习率 $\eta$ 控制。这条规则有个很漂亮的性质:只要数据线性可分,它一定能在有限步内收敛。但它也有硬伤——不输出概率、对噪声极其敏感、数据不可分时永远在震荡。
这里有个常见的认知错位:很多人以为"感知器是用梯度下降训练的",其实严格意义上的原始感知器不是。用梯度下降训练的那个东西,激活函数已经换成了 Sigmoid 或 ReLU。搞清楚这一点,后面看代码就不会困惑为什么有的实现里根本没有反向传播那一段。
2. 三种激活函数塞进同一个感知器:阶跃、Sigmoid、ReLU 的横向对照
光讲公式没感觉,我把三种激活函数放在完全相同的条件下跑一遍。数据是两簇二维高斯分布点,均值分别在 (2, 2) 和 (-2, -2),标准差都是 1,各 200 个样本,按 8:2 划分训练和验证。权重初始化都用同一个随机种子,学习率统一 0.1,迭代 200 轮。这样对照才有意义。
2.1 阶跃函数:最"正宗",却把梯度这条路堵死了
形式很干脆:
$$f(z) = \begin{cases} 1, & z > 0 \ 0, & z \le 0 \end{cases}$$
它的优点是输出干净、判断明确,非常适合解释"感知器就是一个开关"这件事。缺点是全方位的:
- 导数在 $z \ne 0$ 处恒为 0,在 $z = 0$ 处不存在。梯度下降拿到的梯度永远是 0,权重一步都更新不了。
- 输出没有"置信度"的概念。模型说 1 就是 1,哪怕 $z = 0.0001$,它跟 $z = 100$ 给的结果一模一样。这在后面做多分类、做概率输出时会非常难受。
- 对异常点近乎偏执。一个标签标错的样本落在对面,可能把整条边界拽偏很远。
我用阶跃函数配梯度下降跑了一次,代码没报错,训练日志里 200 轮的 loss 一直是同一个数字,准确率卡在 50% 左右,就是随机猜的水平。这不是代码写错了,是数学上就不成立。用阶跃函数必须换感知器学习规则,不能用梯度下降,这是两套体系。
顺带说一句,阶跃函数在现代深度学习里几乎绝迹了,唯一还常见的地方是某些二值化网络的推理阶段,以及作为教学工具解释"阈值"的概念。
2.2 Sigmoid:把硬开关换成软开关,顺带买来一个可用的导数
$$f(z) = \frac{1}{1 + e^{-z}}$$
它的图像是一条平滑的 S 形曲线,$z$ 很大时趋近 1,很小时趋近 0,中间过渡平滑。关键是它的导数有一个极其简洁的形式:
$$f'(z) = f(z)(1 - f(z))$$
这个性质太重要了。意味着你只要在前向传播时把 $f(z)$ 存下来,反向传播时一步乘法就能拿到导数,不需要重新算指数。这也是早期实现里 Sigmoid 特别受欢迎的实际原因之一。
用 Sigmoid 替换阶跃后,同一个感知器、同样的数据和超参,这次 loss 是真的在下降,200 轮后训练集准确率到了 96% 上下。原因很直接:导数不为零,梯度有了着力点。
但 Sigmoid 有它自己的麻烦,而且这些麻烦在层数变多之后会急剧放大:
| 问题 | 具体表现 | 根本原因 |
|---|---|---|
| 梯度饱和 | 输出接近 0 或 1 时梯度接近 0 | $f(z)(1-f(z))$ 在两端趋近 0 |
| 输出非零均值 | 下一层输入的均值总是偏正 | 值域固定在 (0,1) |
| 计算开销 | 每轮都要算指数 | $e^{-z}$ 的代价高于加减乘 |
梯度饱和是影响最大的那个。假设某个样本的 $z$ 被推到 -15,Sigmoid 输出约等于 0,导数约等于 $0 \times 1 = 0$,梯度基本消失,这个样本对权重更新的贡献几乎为零。数据没做归一化的时候这种情况太容易发生了,后面第五节会专门讲。
2.3 ReLU:一个看起来偷懒、实际极为有效的选择
$$f(z) = \max(0, z)$$
正数原样放行,负数直接归零。导数更简单:$z > 0$ 时为 1,$z < 0$ 时为 0,$z = 0$ 处不可导(实践中直接取 0 或 1,都行)。
它的优势非常实际:梯度在正半轴恒为 1,不存在饱和问题,权重更新不会因为输入太大而被吞掉。计算上只是一次比较,比算指数快得多。这也是为什么它在后来几乎成了深层网络的默认选项。
麻烦在于负半轴。一旦某个神经元的输入长期为负,梯度恒为 0,权重再也不会更新,这个神经元就"死"了,学术点说叫Dead ReLU。学习率设太大时死得特别快——一次过大的更新可能把权重推到让所有样本的 $z$ 都为负的区域,从此这个单元永久失活。
缓解办法有两个:一是把学习率调小,二是在负半轴给一个很小的斜率(比如 0.01),这就是 Leaky ReLU。我个人的经验是,小网络里用原版 ReLU 就够,真出现大面积死亡再换 Leaky 版本,不必一上来就上复杂变体。
2.4 把三组结果摆在一张表里看
同一份数据、同一个随机种子、同样的 0.1 学习率、同样 200 轮,三次跑下来的大致量级如下(数值是多次运行的中位数,具体会因为初始化有浮动):
| 激活函数 | 训练准确率 | 验证准确率 | 训练 200 轮的 loss 变化 | 是否可用梯度下降 |
|---|---|---|---|---|
| 阶跃 | 约 50% | 约 50% | 完全不变 | 否 |
| Sigmoid | 约 96% | 约 94% | 从 0.69 降到 0.12 左右 | 是 |
| ReLU | 约 97% | 约 94% | 从 0.69 降到 0.10 左右 | 是 |
注意 Sigmoid 和 ReLU 在这份数据上差距不大,因为这是个只有一层、且数据线性可分的极简任务。ReLU 的优势要到网络变深、数据变复杂之后才会明显体现——单层感知器上它是"够用",不是"碾压"。
还有一个细节值得说:ReLU 输出不是概率,它的值域是 $[0, +\infty)$。拿它做二分类时,通常还要在最后接一个 Sigmoid 或者用"输出是否大于 0"来判类。我在第一次用 ReLU 做二分类时就犯过这个错,直接把输出当成概率去算交叉熵,结果 loss 变成负数,排查了半天。
3. 梯度下降:那条更新公式到底是怎么推出来的
会用optimizer.step()不算懂梯度下降。真正要理解的是:为什么是减去梯度,为什么系数是学习率,为什么不同的损失函数配不同的激活函数会有天壤之别。这三个问题搞清楚,调参的时候就不会瞎试。
3.1 先得有个尺子:损失函数是怎么被造出来的
权重更新得有方向感,"往哪边调能让结果更好"这个问题需要一个量化的答案。这个量就是损失函数。二分类里最常见的有两种。
平方误差(MSE):
$$L = \frac{1}{2}(y - \hat{y})^2$$
前面那个 $\frac{1}{2}$ 纯粹是为了求导时跟平方出来的 2 约掉,不改变最优解的位置。
交叉熵(BCE):
$$L = -\left[y\log\hat{y} + (1-y)\log(1-\hat{y})\right]$$
两者选哪个不是随便定的。交叉熵是从"用预测分布去拟合真实分布"这个角度推出来的,它衡量的是两个伯努利分布之间的差异(准确说是 KL 散度里与参数相关的那部分)。它的一个关键性质是:当预测错得很离谱时,它的惩罚会非常大。真实标签是 1,模型预测 0.01,交叉熵给出约 4.6 的损失,而平方误差只给出约 0.49。错得越狠,拉回来的力气越大,收敛自然更快。
我的建议很明确:做分类就用交叉熵,做回归就用 MSE,不要混着来。有人觉得都是"算误差"随便选一个,结果训练速度差出好几倍。
3.2 链式法则在单个感知器上的完整展开
以 Sigmoid + 交叉熵为例,把每一步都拆开,这个过程值得亲手推一遍。
前向:
$$z = w^Tx + b, \quad \hat{y} = \sigma(z) = \frac{1}{1+e^{-z}}$$
损失:
$$L = -\left[y\log\hat{y} + (1-y)\log(1-\hat{y})\right]$$
对 $z$ 求导,先对 $\hat{y}$ 求,再乘以 $\hat{y}$ 对 $z$ 的导数:
$$\frac{\partial L}{\partial \hat{y}} = -\frac{y}{\hat{y}} + \frac{1-y}{1-\hat{y}}$$
$$\frac{\partial \hat{y}}{\partial z} = \hat{y}(1-\hat{y})$$
两者相乘:
$$\frac{\partial L}{\partial z} = \left(-\frac{y}{\hat{y}} + \frac{1-y}{1-\hat{y}}\right)\hat{y}(1-\hat{y}) = -y(1-\hat{y}) + (1-y)\hat{y} = \hat{y} - y$$
结果出奇地干净:梯度就是"预测值减真实值"。那些复杂的指数和分式全部消掉了。这就是 Sigmoid 配交叉熵被称为"黄金搭配"的原因,不是玄学,是数学上天生成对的。
再往下传一层,对权重和偏置:
$$\frac{\partial L}{\partial w} = \frac{\partial L}{\partial z} \cdot \frac{\partial z}{\partial w} = (\hat{y} - y)x$$
$$\frac{\partial L}{\partial b} = \hat{y} - y$$
于是更新规则:
$$w \leftarrow w - \eta(\hat{y} - y)x, \quad b \leftarrow b - \eta(\hat{y} - y)$$
看着是不是有点眼熟?跟前面感知器学习规则的形式几乎一样,只是把 $(y - \hat{y})$ 的符号换了个方向。这不是巧合。从梯度下降的角度看,感知器规则恰好是它在线性可分情形下的一个特例。
现在把激活函数换成 Sigmoid,损失换成 MSE,再算一遍对 $z$ 的导数:
$$\frac{\partial L}{\partial z} = ( \hat{y} - y ) \cdot \hat{y}(1 - \hat{y})$$
多出来的 $\hat{y}(1-\hat{y})$ 就是灾难的根源。当 $\hat{y}$ 接近 0 或 1 时,这一项趋近 0,哪怕预测错得非常离谱($y=1$ 而 $\hat{y}=0.01$),梯度也会被这个因子压得很小,训练慢得像蜗牛。这就是"分类问题不要用 MSE + Sigmoid"背后的真正原因,不是经验之谈,是能被算出来的。
至于 ReLU,导数形式是:
$$\frac{\partial L}{\partial z} = (\hat{y} - y) \cdot \mathbb{1}[z > 0]$$
正半轴完全放行,负半轴直接切断。这也解释了为什么会 Dead ReLU——一旦 $z < 0$,梯度恒为 0,权重永远不动。
3.3 学习率的三档实测与判断方法
学习率 $\eta$ 是这套东西里最玄的一个参数。我给同一条学习率设了三档,跑出来的现象很有代表性:
| 学习率 | 训练表现 | 典型现象 |
|---|---|---|
| 0.001 | 200 轮后 loss 还在 0.55 附近 | 降得太慢,需要加轮数或换优化器 |
| 0.1 | 约 40 轮后 loss 进入 0.1 区间 | 稳定收敛,是个好的起点 |
| 1.0 | 前几轮 loss 剧烈跳动,随后变成 nan | 步子迈太大,越过最优点直接发散 |
发散的机理不难理解。梯度下降的本质是沿着当前点的负梯度方向走一小步,前提是"这一小步内坡度变化不大"。步子迈得太大会直接跨过谷底冲到对面更高的位置,下一次梯度方向反过来,又跨回去,来回震荡,幅度越来越大,最后数值溢出。
判断学习率是否合适,看 loss 曲线的形状最快:
- 曲线平缓下降,斜率逐渐变小,说明合适。
- 曲线几乎水平,说明太小。
- 曲线锯齿状上下跳动,或者某个点之后突然飙高变成 nan,说明太大。
一个很实用的做法是从 0.1 起步,如果出现震荡就除以 10,如果太慢就乘以 3 试试。单层感知器这种简单结构,0.1 到 0.5 之间通常都能跑通。另外,数据做过归一化之后,学习率的容忍度会明显提高,这一点后面讲坑的时候还会提。
3.4 三种梯度下降变体到底差在哪
同样是"用梯度更新权重",按每次用多少样本算梯度,分成三类:
批量梯度下降(BGD):一次把全部样本都算一遍梯度再更新。方向最准,因为用的是真实梯度;但内存吃紧,样本上百万时一轮都跑不完。
随机梯度下降(SGD):每次只用一个样本算梯度。快,但方向噪声极大,loss 曲线抖得厉害。
小批量梯度下降(Mini-batch):每次用 32、64、128 个样本。在准确性和速度之间取平衡,也是现在实际工作的默认做法。
噪声不完全是坏事。SGD 那种抖动有时候能帮模型跳出比较浅的局部极小值,这也是它在深度网络里反而表现不错的原因之一。不过对单层感知器来说,这个优势体现不出来,用小批量就够。
4. 从零敲一个感知器:把激活函数和梯度下降缝在一起
理论说再多,不如把代码写出来跑一遍。下面的实现只用 numpy,不依赖任何深度学习框架,目的是让每一行的含义都看得见。整个文件大概一百行,包含了数据生成、三种激活函数、前向反向、训练循环和可视化。
4.1 数据准备与权重初始化
import numpy as np import matplotlib.pyplot as plt np.random.seed(42) def make_data(n_per_class=200): # 两簇二维高斯分布,中心分别在 (2,2) 和 (-2,-2) pos = np.random.randn(n_per_class, 2) * 1.0 + np.array([2.0, 2.0]) neg = np.random.randn(n_per_class, 2) * 1.0 + np.array([-2.0, -2.0]) X = np.vstack([pos, neg]) y = np.hstack([np.ones(n_per_class), np.zeros(n_per_class)]) # 打乱顺序,避免同类样本连续出现 idx = np.random.permutation(len(y)) return X[idx], y[idx] X, y = make_data() print(X.shape, y.shape) # (400, 2) (400,) print("均值", X.mean(axis=0), "标准差", X.std(axis=0))这里我特意打印了均值和标准差。数据缩放是训练前必做的一步,很多后面出现的诡异问题都跟没做这一步有关。上面这组数据均值约 0、标准差约 2,还算温和;如果输入维度里有一个量纲是几千、另一个是零点几,Sigmoid 会立刻饱和。
权重初始化也有讲究。全零初始化在单层感知器上其实能跑(因为损失对 $w$ 不是对称的),但在多层网络里会导致所有神经元学到同样的东西。通用做法是先生成小的高斯随机数:
def init_params(n_features): W = np.random.randn(n_features) * 0.01 b = 0.0 return W, b系数 0.01 是个经验值。太大会让初始 $z$ 就落进 Sigmoid 的饱和区,训练一开始就卡住;太小则信号传递不到后面。
4.2 前向、损失、反向、更新:四段式骨架
def step_activation(z): return (z > 0).astype(np.float64) def sigmoid(z): # 加上 clip 防止 exp 溢出 z = np.clip(z, -500, 500) return 1.0 / (1.0 + np.exp(-z)) def relu(z): return np.maximum(0.0, z) def forward(X, W, b, act): z = X @ W + b if act == "step": return step_activation(z), z elif act == "sigmoid": return sigmoid(z), z elif act == "relu": return relu(z), z raise ValueError("unknown activation") def compute_loss(y, yhat, loss_type="bce"): eps = 1e-12 if loss_type == "bce": yhat = np.clip(yhat, eps, 1 - eps) return -np.mean(y * np.log(yhat) + (1 - y) * np.log(1 - yhat)) elif loss_type == "mse": return 0.5 * np.mean((yhat - y) ** 2) def backward(X, y, yhat, z, W, act, loss_type="bce"): n = len(y) if act == "step": # 阶跃导数恒为 0,这里返回 0 只是为了代码能跑,实际上不会更新 dz = np.zeros_like(z) elif act == "sigmoid": if loss_type == "bce": dz = (yhat - y) / n # 交叉熵 + sigmoid 的干净结果 else: dz = (yhat - y) * yhat * (1 - yhat) / n elif act == "relu": dz = (yhat - y) * (z > 0) / n dW = X.T @ dz db = np.sum(dz) return dW, db这段代码里有几个点值得单独拎出来说。
np.clip(z, -500, 500)这一行是保命用的。np.exp(1000)会直接溢出成 inf,接着1/inf变成 0,再往后算 log 就是 nan。我第一次写 Sigmoid 时没加这行,学习率调大一点就满屏 nan,排查了挺久才定位到这儿。
yhat - y那个除以 $n$ 的操作是求均值,不是求和的。用求和也能训练,但梯度会随批量大小线性变化,换个 batch size 就得重调学习率,非常麻烦。统一用均值更省心。
阶跃那一段返回全 0 是故意的,为的是让后面的对照实验能跑完、能打印出"loss 不变"这个结果。生产代码里不会这么写,这里是为了演示。
4.3 训练循环与日志解读
def train(X, y, act, lr=0.1, epochs=200, loss_type="bce"): W, b = init_params(X.shape[1]) history = [] for epoch in range(epochs): yhat, z = forward(X, W, b, act) loss = compute_loss(y, yhat, loss_type) dW, db = backward(X, y, yhat, z, W, act, loss_type) W -= lr * dW b -= lr * db history.append(loss) if epoch % 50 == 0: pred = (yhat > 0.5).astype(int) acc = np.mean(pred == y) print(f"[{act}] epoch {epoch:3d} loss={loss:.4f} acc={acc:.3f}") return W, b, history results = {} for act in ["step", "sigmoid", "relu"]: W, b, hist = train(X, y, act, lr=0.1, epochs=200) results[act] = hist plt.figure(figsize=(8, 5)) for act, hist in results.items(): plt.plot(hist, label=act) plt.xlabel("epoch") plt.ylabel("loss") plt.legend() plt.title("three activations on the same perceptron") plt.show()跑完之后你会看到三条曲线:step 那条是完全水平的直线,sigmoid 和 relu 那两条是漂亮的下降曲线,前期陡、后期缓,最后都趋于平稳。这个"前期陡后期缓"的形状很有代表性——前期误差大,梯度也大,更新猛;接近最优点时误差小,梯度小,步子自动变小。这其实是梯度下降自带的一个好处,不需要额外设计退火策略。
如果曲线出现下面几种形态,通常对应不同的原因:
- 完全水平:激活函数不可导,或者学习率是 0。
- 一路下降但到某个点突然跳到 nan:学习率过大导致数值溢出。
- 锯齿状剧烈震荡:学习率过大,但还没到溢出的程度。
- 下降极慢:学习率太小,或者数据没有归一化。
读曲线这个能力比调参技巧更值钱。绝大多数训练问题,曲线上一眼就能看出来。
5. 我在写这类小模型时反复踩到的几个坑
下面这几个问题,几乎每一个我都在不同的时间点踩过,而且每次踩的时候都觉得"这不该出错"。写出来供对照。
5.1 用阶跃函数却指望梯度下降能救
这是最典型的一个。代码逻辑挑不出毛病,前向、损失、反向写了三十行,一跑发现权重根本没动。当时的第一反应是"梯度算错了",于是把所有导数公式挨个核对了一遍,推到半夜,最后才反应过来:阶跃函数在 $z \ne 0$ 处的导数是 0,梯度本来就是 0,算得再对也是 0。
判断方法:在反向传播里打印一下梯度的范数,如果每一轮都是 0,且激活函数是不可导的(阶跃、符号函数、sign),那就是这个原因,不用再往下查。
修复方案:要么换激活函数(Sigmoid 或 ReLU),要么换训练规则(感知器学习规则)。两条路选一条,不能混。
补充一点,sigmoid 也不是完全没这个问题。当输入被推到 $\pm 10$ 以外时,导数已经小到 $4.5 \times 10^{-5}$ 量级,实际效果和 0 差不多。所以"能不能梯度下降"不是一个非黑即白的问题,而是一个"梯度够不够大"的程度问题。
5.2 学习率给太大,loss 第一轮就变 nan
数据归一化没做,输入里有一维范围到几百,$z$ 一算出来就是几百上千。Sigmoid 加 clip 还能勉强扛住,不加 clip 直接 inf。inf 在交叉熵里过一遍 log,出来就是 nan,然后 nan 顺着梯度污染所有权重,后面全是 nan。
排查顺序:先看数据每一维的最小值、最大值、均值、方差,这一眼就能看出来有没有量纲失衡。然后看 $z$ 的取值范围。正常情况 $z$ 应该在 $-10$ 到 $10$ 这个量级内,如果动辄几百,那就得先处理数据,而不是调学习率。
修复方案:标准化,每维减均值除标准差。这一步做完,学习率的容忍范围会宽很多,原来只能 0.01 的现在 0.5 都能跑。
一个很容易忽略的细节:训练集和验证集必须用训练集算出来的均值和标准差去处理,不能各算各的。用验证集自己的统计量去归一化等于提前偷看了测试数据的分布,评估结果会虚高。
5.3 权重初始化太小,ReLU 一片死掉
ReLU 配 0.001 量级的初始化,初始 $z$ 全部落在负半轴附近,一圈下来所有神经元的梯度都是 0,loss 一动不动,表现跟阶跃函数一模一样。这个坑的迷惑性在于它是概率性的——换个随机种子可能就正常了。
判断方法:统计一下每一轮里 $z > 0$ 的比例。如果长期接近 0,说明神经元在集体休眠。
修复方案:初始化的尺度要跟输入维度挂钩。经验做法是用 $\sqrt{2/n_{in}}$ 作为标准差(这是 He 初始化的思路),$n_{in}$ 是输入维度。我一般会多试几个种子,看看结果稳不稳定,稳定了再往下做。
5.4 单层感知器的天花板:XOR 问题
前面用的两簇数据是线性可分的,一条直线就能分开。如果换成经典的异或分布——四个点 $(0,0)$、$(0,1)$、$(1,0)$、$(1,1)$,标签分别是 $0,1,1,0$——你就会发现无论怎么调,准确率都上不去。
这不是训练的问题,是结构的问题。单层感知器的决策边界永远是一条直线(高维下是超平面),而 XOR 的两类样本在几何上就是交叉排列的,任何一条直线都没法把它们分开。这是单层感知器的硬上限。
验证方法:跑出来的训练准确率长期在 50% 到 75% 之间浮动,loss 卡在一个正数下不去,同时换个随机种子结果会变但都上不去。这就是典型的"模型容量不足"。
突破方式:加一层。把两个感知器叠起来,中间用 ReLU 或 Sigmoid,输出层再用 Sigmoid,XOR 轻松就解了。这也是从感知器走向多层网络的必经一步——不是技术上多难,而是"必须加层才能解决"这个认知转折。
我建议每个人都亲手把 XOR 跑一次,看着 loss 怎么都降不下去,然后再加一层看它突然降下来。这个体验比看十篇讲解都深刻。
6. 几个能省时间的实操习惯
写代码之前先扫一遍数据:看看每维的均值、标准差、最小最大值,画个散点图。这一步花不了两分钟,但能挡掉后面大半的诡异问题。特别是二分类任务,散点图一画出来你就知道数据大致线性可分还是明显非线性,也就知道单层感知器够不够用。
调试训练过程,先把学习率固定成 0.1 跑 20 轮,打印每一轮的 loss 和梯度范数。梯度范数这个量很多人不看,但它信息量很大:范数为 0 说明链断了,范数巨大(比如 1e6 量级)说明要爆炸或者学习率太大,范数稳定在 1e-3 到 1e-1 之间通常是健康的。
对称性检验也值得做一次。把标签全部翻转,重新训练,如果模型的表现完全对称,说明流程没问题;如果出现明显不对称,多半是数据里混进了和标签相关的某种结构,或者数据划分时没打乱。
还有一个我自己常犯的错:把yhat拿去算准确率时用了> 0.5,但 ReLU 的输出压根不是概率,$[0, +\infty)$ 的范围里 0.5 这个阈值毫无意义。ReLU 做二分类要么后面再接一层 Sigmoid,要么直接判断 $z$ 的符号,别硬套 0.5。
这套东西本身不复杂,但每个环节的取舍都有它的道理。感知器的结构决定了它能解什么、不能解什么;激活函数决定了误差能不能传回去、传回去有多强;梯度下降决定了沿着什么方向走、一步走多远。三样东西拆开看都是小学问,合起来才是一台能转的机器。后面如果继续往下走,无论是加层变成多层网络,还是换成交叉熵之外的损失函数,这套分析思路都是通用的:先问结构能表达什么,再问误差能不能传,最后问步子迈多大合适。