1. 为什么优化器值得单独封装
1.1 优化器在训练流程里到底干了什么
很多人学神经网络实现的时候,会把大量精力放在前向传播和反向传播上,这两块确实是最容易让人劝退的部分。但等到你真正用Numpy把全连接层、卷积层、激活函数都写了一遍、跑通了一次反向传播之后,你会发现一个问题:梯度算出来了,参数怎么更新?这一步看起来就是param -= lr * grad,简单得让人怀疑是不是漏了什么。实际上,这个"简单"的算式只是优化器家族里的一个成员,而且是最朴素的那个。
从整个训练闭环来看,一个典型的Numpy神经网络训练循环是这样的:前向计算得到预测值,用损失函数算出误差,反向传播得到每个参数的梯度,最后用优化器把梯度和当前参数结合起来,产生一组新的参数。前两步决定了模型"看得准不准",反向传播决定了"该往哪个方向走",而优化器决定了"每一步走多远、怎么走、走的时候要不要参考以前走过的路"。前三步是数学推导,最后一步其实是工程和经验的混合体。
所以我一直觉得,优化器是整个训练流程里被低估的环节。同样的网络结构、同样的初始化、同样的数据,换一个优化器,收敛速度和最终精度可以差出好几个量级。这一点在Numpy手写实现的场景里体现得尤其明显,因为你没有框架帮你做那些隐式的梯度缩放、动量缓冲、学习率调度,所有策略都得自己一行一行写出来。
1.2 为什么要在Numpy里折腾优化器
说实话,现在用PyTorch或者TensorFlow,一行optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)就完事了,完全没必要自己用Numpy实现。但如果你是在学习阶段,或者需要在纯Numpy环境下搭建一个小型网络做验证,手写优化器有几个实实在在的价值。
第一个价值是理解原理。你去看Adam的论文或者公式,心里想的可能是"哦,就是维护一阶矩和二阶矩,然后做偏差校正"。但只有当你亲手用Numpy把这段逻辑写出来,并且跑在真实数据上观察它的收敛行为,你才会真正理解为什么二阶矩能起到"自适应学习率"的作用,为什么偏差校正在前几步那么关键。这些理解是直接调框架API给不了你的。
第二个价值是调试和教学。在很多教学场景里,你需要的是一个没有隐藏逻辑的最小实现。框架的优化器封装了几十层抽象,出了问题不好定位;自己写的Numpy版本就那么几十行,每一行在做什么一目了然。
第三个价值是定制灵活。做研究或者特殊场景的时候,你可能会遇到"标准的Adam不收敛"或者"需要在更新时对梯度做特殊处理"这类需求。这时候你手里有一个Numpy版本的优化器基类,改起来非常方便,因为它就是纯粹的数值计算,没有框架绑定。
1.3 封装优化器时的接口设计思路
既然要封装,就要先想清楚接口怎么设计。我的建议是,优化器需要和网络层解耦。网络层负责保存参数、计算梯度,优化器只负责拿到参数和梯度之后更新参数。二者之间的通信方式很简单:优化器遍历网络层暴露出来的参数字典,拿到每个参数的梯度和当前值,完成更新。
接口可以设计得很薄。核心就两个方法:一个是初始化内部状态(动量缓冲、二阶矩估计这些),另一个是根据当前参数和梯度做一步更新。这样不同优化器的区别就收敛到了"内部状态怎么初始化"和"更新公式长什么样"两个问题上,整个架构非常干净。
class Optimizer: def __init__(self, lr=0.01): self.lr = lr self.t = 0 self.state = {} def step(self, params, grads): self.t += 1 for name in params: key = id(params[name]) if key not in self.state: self.state[key] = self.init_state(params[name], grads[name]) self.update(params[name], grads[name], self.state[key]) def init_state(self, param, grad): raise NotImplementedError def update(self, param, grad, state): raise NotImplementedError这个基类用id()作为参数的唯一标识,好处是不用额外引入命名系统,网络层传入的参数字典只需要保证同一个参数的key一致即可。具体的SGD、Momentum、RMSProp、Adam都是这个基类的子类,各自实现init_state和update两个方法就行。
2. 从动量和自适应学习率的演化看优化器设计逻辑
2.1 梯度更新本质上做的是一件事
神经网络优化问题的数学表达很简单:找到一组参数,让损失函数的值最小。梯度告诉你的是损失函数在当前参数点上升最快的方向,所以要做梯度下降,就是沿着梯度的反方向走一小步。这一小步的大小就是学习率。
param -= self.lr * grad这就是最朴素的SGD。它有一个前提假设:损失函数在当前点附近近似是线性的,所以沿着反方向走一小步总是能降低损失。但这个假设在真实的高维非凸损失曲面上经常不成立,于是衍生出了一系列改进。
朴素SGD的问题集中在三个地方:第一,学习率是全局统一的,但不同参数对学习率的敏感度差别很大,有些参数需要大步子快速穿越,有些参数需要小步子精细调整;第二,在狭长的山谷形状的损失曲面里,梯度方向的震荡会让SGD走得很慢,效率极低;第三,学习率设大了容易发散,设小了收敛太慢,很难找到合适的值。优化器的发展史,基本就是解决这三个问题的历史。
2.2 动量:解决震荡与平缓区域的穿越问题
动量(Momentum)的思路很简单:更新参数的时候,不只参考当前梯度,还参考之前的更新方向。这就像推一个球下山,球不仅受到当前位置的坡度影响,还带着之前的运动惯性。如果连续几步梯度方向一致,动量就会累积出一个较大的速度,加速跨越平缓区域;如果梯度方向频繁改变,动量会把各个方向的力互相抵消,让运动方向更平滑。
数学表达上,动量维护了一个速度变量v,每次更新前先累积历史梯度:
v = momentum * v + grad param -= lr * v这里的momentum一般取0.9,表示保留90%的历史速度,再叠加10%的当前梯度。这个系数越大,运动惯性越强,对高频震荡的抑制效果越明显,但方向改变的能力也越弱。
Nesterov动量是标准动量的小改进,它多了一个"提前看一步"的操作:先用当前速度算出参数逼近的位置,然后在这个逼近位置计算梯度。直觉上就是球在下坡的时候不是只看脚下,而是看前方几步的位置。数学表达稍微复杂一些,但在Numpy里实现起来也只差两行的差异。实测下来Nesterov在部分任务上比标准动量提速明显,尤其在训练后期接近收敛点时。
2.3 自适应学习率:让每个参数有自己的步长
动量的思路是"参考过去的方向",自适应学习率的思路则是"给每个参数单独调节步长"。这个思路的起点是AdaGrad:它对每个参数累积历史梯度的平方和,更新时用这个累积值的平方根来缩放学习率。
cache += grad * grad param -= lr * grad / (np.sqrt(cache) + eps)哪个参数的梯度一直很大,它的累积平方和就大,学习率就会被压小;哪个参数一直没什么梯度,学习率就保持较大。这个机制在稀疏特征场景里非常有效,比如ID类特征,只有少量样本激活的维度更新频率低,用AdaGrad可以让这些维度获得更大的更新步长。
但AdaGrad有个致命伤:因为累积的是从训练开始到现在的全部梯度平方和,这个值只会越来越大,学习率会被压得越来越小,最后直接停止更新。RMSProp的改进是把"全部历史平方和"换成"指数加权移动平均",只关注最近一段时间内的梯度大小,这样既保留了自适应缩放的能力,又不会让学习率单调递减到零。
cache = decay_rate * cache + (1 - decay_rate) * grad * grad param -= lr * grad / (np.sqrt(cache) + eps)这里的decay_rate通常取0.9或0.99,相当于权衡"多近的历史梯度影响当前步长"。
2.4 Adam:一阶矩与二阶矩的组合
Adam把动量和自适应学习率组合在一起,同时维护两个状态量:一阶矩估计m(相当于动量累积)和二阶矩估计v(相当于梯度平方的指数加权平均),然后分别做偏差校正,得到更新方向。
m = beta1 * m + (1 - beta1) * grad v = beta2 * v + (1 - beta2) * grad * grad m_hat = m / (1 - beta1 ** t) v_hat = v / (1 - beta2 ** t) param -= lr * m_hat / (np.sqrt(v_hat) + eps)偏差校正这一步是Adam的关键。因为m和v初始化都是零,在训练最开始的几步,指数加权平均的值会被严重拉向零,如果不做校正,前几步的更新会非常小,相当于白白浪费了早期阶段。除以(1 - beta^t)之后,早期估计会被放大到接近实际梯度的量级,训练才能快速起步。
从计算开销上看,Adam比SGD多了两个逐元素乘法和一个开方运算,但这个额外开销远小于它带来的调参便利性。这也是Adam成为深度学习默认选择的核心原因:它对学习率不那么敏感,默认的beta1=0.9、beta2=0.999、eps=1e-8在绝大多数任务上都能直接工作。
各个优化器的关键特征对比:
| 优化器 | 核心思想 | 需要维护的状态 | 适用场景 |
|---|---|---|---|
| SGD | 沿梯度反方向更新 | 无 | 训练后期fine-tuning,泛化性好 |
| Momentum | 累积历史更新方向 | 速度变量 | 解决震荡,加速穿越平缓区 |
| RMSProp | 梯度平方指数加权缩放学习率 | 二阶矩估计 | 非平稳目标,RNN训练常客 |
| Adam | 一阶矩+二阶矩+偏差校正 | 两个矩估计 | 默认选择,绝大多任务可用 |
3. Numpy实现:四种优化器的完整封装
3.1 优化器基类与状态管理
有了前面的接口设计,剩下的事情就是往里面填代码。这里我把step方法再细化一点,让它可以处理"某些层没有梯度"的情况,避免因为某些参数没有被反向传播到而崩溃。
import numpy as np class Optimizer: def __init__(self, lr=0.01): self.lr = lr self.t = 0 self.state = {} def step(self, params, grads): self.t += 1 for name in params: if name not in grads or grads[name] is None: continue key = id(params[name]) if key not in self.state: self.state[key] = self.init_state(params[name], grads[name]) self.update(params[name], grads[name], self.state[key]) def init_state(self, param, grad): raise NotImplementedError def update(self, param, grad, state): raise NotImplementedError一个值得注意的设计细节是,step的入参params和grads都用字典。这意味着网络层的params()方法返回的字典和grads()方法返回的字典,只要key命名一致,优化器就能正确配对。而不需要关心网络层内部是用列表还是字典保存参数,也不要求所有层有相同的参数字段。
3.2 SGD与Momentum的实现
朴素SGD的实现最直接。它没有任何内部状态,init_state直接返回空字典,update里做一次线性更新。
class SGD(Optimizer): def init_state(self, param, grad): return {} def update(self, param, grad, state): param -= self.lr * grad这里有个细节需要提醒:param -= self.lr * grad是原地操作,直接修改了传入的numpy数组。如果你的网络层里保存参数的引用和传入的param是同一个对象,这样没问题。但如果你的层实现里不小心做了参数拷贝,比如self.W = W.copy()之后又把原始W传给了优化器,那优化器更新的其实是副本,网络层里的参数不会变。所以封装层的参数管理时,要保证"层持有的参数对象"和"优化器拿到的参数对象"是同一个引用。
Momentum在SGD的基础上多了一个速度变量v。这里要注意状态变量的形状必须和参数完全一致,初始化用np.zeros_like。
class Momentum(Optimizer): def __init__(self, lr=0.01, momentum=0.9): super().__init__(lr) self.momentum = momentum def init_state(self, param, grad): return {'v': np.zeros_like(param)} def update(self, param, grad, state): state['v'] = self.momentum * state['v'] + grad param -= self.lr * state['v']state['v']的更新也需要注意原地操作和赋值的区别。这里用state['v'] = self.momentum * state['v'] + grad会创建一个新数组并替换旧引用,不会影响param本身,所以没有副作用。如果你想写得更高效,可以用state['v'] *= self.momentum; state['v'] += grad,效果一样。
Nesterov动量稍微折腾一点。标准写法有两种等价形式,我用的这个比较直观:
class Nesterov(Optimizer): def __init__(self, lr=0.01, momentum=0.9): super().__init__(lr) self.momentum = momentum def init_state(self, param, grad): return {'v': np.zeros_like(param)} def update(self, param, grad, state): prev_v = state['v'].copy() state['v'] = self.momentum * state['v'] + grad param -= self.momentum * prev_v + (1 + self.momentum) * grad这段代码看着有点绕,但本质就是在标准动量的基础上做了一步"超前校正"。
3.3 RMSProp的实现
RMSProp维护的是一个二阶矩估计cache,更新的时候用梯度的平方做指数加权移动平均,实际更新时除以sqrt(cache)来缩放梯度。
class RMSProp(Optimizer): def __init__(self, lr=0.001, decay_rate=0.9, eps=1e-8): super().__init__(lr) self.decay_rate = decay_rate self.eps = eps def init_state(self, param, grad): return {'cache': np.zeros_like(param)} def update(self, param, grad, state): cache = state['cache'] cache *= self.decay_rate cache += (1 - self.decay_rate) * grad * grad param -= self.lr * grad / (np.sqrt(cache) + self.eps)这里有个很实际的坑:如果你的梯度偶尔会非常大,cache的数值会剧烈波动。虽然eps加了平滑,但如果eps太小,更新量可能仍然不稳定。所以我会建议在grad进来之后,先做一个简单的梯度裁剪,超过阈值就缩放回阈值范围内,再喂给优化器。这个操作看似粗暴,实际对训练稳定性帮助极大。
RMSProp里学习率的默认值一般给0.001,比SGD的0.01小一个量级,因为RMSProp的更新量在数值上已经被缩小了,实际等效步长更大。
3.4 Adam的实现
Adam实现的关键点是偏差校正需要用到当前的迭代步数t。这里有个小技巧:把t放在优化器基类上,每次step调用时自增,这样Adam计算时直接读取self.t即可。
class Adam(Optimizer): def __init__(self, lr=0.001, beta1=0.9, beta2=0.999, eps=1e-8): super().__init__(lr) self.beta1 = beta1 self.beta2 = beta2 self.eps = eps def init_state(self, param, grad): return { 'm': np.zeros_like(param), 'v': np.zeros_like(param), } def update(self, param, grad, state): t = self.t m = state['m'] v = state['v'] m *= self.beta1 m += (1 - self.beta1) * grad v *= self.beta2 v += (1 - self.beta2) * grad * grad m_hat = m / (1 - self.beta1 ** t) v_hat = v / (1 - self.beta2 ** t) param -= self.lr * m_hat / (np.sqrt(v_hat) + self.eps)这里有个数值细节值得注意:当t比较小时,self.beta1 ** t这里的**运算在Python里是浮点幂运算,效率一般。但训练早期t只有几十、几百,影响微乎其微。真要优化的话,可以用乘以beta1的方式累积衰减系数,不过没必要,代码可读性更重要。
另外我在实际使用过程中发现,Adam对eps的选择比很多人想的敏感。默认的1e-8在大部分情况下没问题,但在某些任务上改到1e-7或者1e-4会带来明显不同的收敛行为。后面的调参部分我再细讲。
4. 优化器怎么接进神经网络层:一个完整的训练闭环
4.1 层封装中的参数与梯度暴露
优化器本身是独立的,但它要发挥作用,必须和网络层的参数管理默契配合。在Numpy封装神经网络层的时候,我一般会给每个层实现两个方法:params()返回该层所有可训练参数组成的字典,grads()返回对应梯度的字典。以全连接层为例:
class FullyConnected: def __init__(self, in_features, out_features): self.W = np.random.randn(in_features, out_features) * 0.01 self.b = np.zeros(out_features) self.grad_W = None self.grad_b = None def forward(self, x): self.x = x return x @ self.W + self.b def backward(self, dout): self.grad_W = self.x.T @ dout self.grad_b = np.sum(dout, axis=0) return dout @ self.W.T def params(self): return {'W': self.W, 'b': self.b} def grads(self): return {'W': self.grad_W, 'b': self.grad_b}注意这里params()和grads()返回的都是引用而不是拷贝。这样优化器里的param -= ...操作才能直接修改层的参数。这个约定虽然不是必须的,但能避免很多"参数更新了但模型没变"的诡异bug。你在自己的封装里也建议保持同样的约定:层的params()返回的必须是层内部持有的对象本身,不能是副本。
4.2 训练循环中的优化器调用时机
有了层之后,一个简单的两层MLP加训练循环可以这样组织:
class MLP: def __init__(self, hidden_size=64): self.fc1 = FullyConnected(784, hidden_size) self.fc2 = FullyConnected(hidden_size, 10) def forward(self, x): h = np.maximum(0, self.fc1.forward(x)) return self.fc2.forward(h) def backward(self, dout): dh = self.fc2.backward(dout) dh = dh * (self.fc2.x > 0) # relu backward self.fc1.backward(dh) def params(self): return {**self.fc1.params(), **self.fc2.params()} def grads(self): return {**self.fc1.grads(), **self.fc2.grads()}这个实现里有几个点值得品味:params()和grads()使用字典解包把两个全连接层的参数合并到一个字典里,key分别是W和b。如果网络有两层,那fc2的W会覆盖fc1的W。这显然不行。所以更严谨的做法是给每层加前缀,比如fc1.W、fc1.b、fc2.W、fc2.b:
def params(self): return { 'fc1.W': self.fc1.W, 'fc1.b': self.fc1.b, 'fc2.W': self.fc2.W, 'fc2.b': self.fc2.b, } def grads(self): return { 'fc1.W': self.fc1.grad_W, 'fc1.b': self.fc1.grad_b, 'fc2.W': self.fc2.grad_W, 'fc2.b': self.fc2.grad_b, }这种带前缀的命名方式在调试的时候非常友好,你能从优化器的状态字典里直接看出哪个参数对应的是哪一层。
训练循环的长相大概是这样:
optimizer = Adam(lr=1e-3) model = MLP(hidden_size=64) for epoch in range(50): for x_batch, y_batch in make_batches(X_train, y_train, batch_size=64): # 前向 scores = model.forward(x_batch) loss, dout = softmax_cross_entropy(scores, y_batch) # 反向 model.backward(dout) # 更新 optimizer.step(model.params(), model.grads())这个循环流程是固定的,核心就是:前向拿到loss,反向算出梯度,优化器用梯度更新参数。三者顺序不能乱。step调用完之后,所有层的参数已经被原地修改了,下一轮前向的model.forward自然用的是新参数。
4.3 权重衰减与学习率调度的接入
实际训练中,优化器通常还需要承担两件额外的事:权重衰减和学习率调度。权重衰减其实就是L2正则化,在更新参数前先让参数按比例缩小一点,防止过拟合。常见的实现方式是直接在梯度里加上decay * param,也可以在更新公式里拆出去单独算。
在优化器的框架里,我比较倾向在step方法中统一处理权重衰减,这样层和训练循环的代码都不用改:
def step(self, params, grads, weight_decay=0.0): self.t += 1 for name in params: if name not in grads or grads[name] is None: continue param = params[name] grad = grads[name] if weight_decay > 0: grad = grad + weight_decay * param key = id(param) if key not in self.state: self.state[key] = self.init_state(param, grad) self.update(param, grad, self.state[key])注意这里用grad = grad + weight_decay * param重新赋值,而不是grad += ...,原因是不想修改网络层的grads()返回的梯度对象。虽然这个梯度在反向传播之后一般不会再被复用,但养成不随便修改中间变量的习惯,能少踩很多奇怪的坑。
学习率调度的实现也很直接。经典的做法是把学习率从固定值改成随时间衰减的函数,比如阶梯衰减:
if epoch == 20: optimizer.lr *= 0.1 if epoch == 40: optimizer.lr *= 0.1这个方式虽然粗糙,但非常有效,因为训练后期适配器已经接近收敛点,保持大学习率会在最优值附近来回震荡,降低学习率可以让它稳定地逼近。对于Numpy实现的这个优化器,直接改optimizer.lr就行了,因为update方法里用的是self.lr,下一次step就会生效。
5. 调参实战:学习率、epsilon与数值稳定性
5.1 学习率怎么设才合理
学习率是最重要也最玄学的超参数。我给不出一个什么任务都通用的值,但可以分享一个试参思路:先跑一次梯度检查,确认梯度是对的,然后用从大到小的几个数量级去试,比如0.1、0.01、0.001、0.0001,看损失曲线在50步内的下降情况。如果损失直接飙升到NaN,说明学习率太大,降低一个数量级再试;如果损失下降得很慢,说明学习率偏小,提高一个数量级。
这个"试错法"看起来笨,但在小模型上非常高效,因为50步前向反向顶多也就几秒钟。一旦找到合适的学习率量级,你就能判断出这个模型的优化难度,然后再针对性地选择优化器。
不同优化器对学习率的敏感度差异很大。SGD对学习率最敏感,0.1能发散的参数,换Adam用0.1可能还在正常收敛。所以把优化器和学习率绑定考虑很重要。我的经验是:SGD适合0.01左右配合动量,RMSProp和Adam适合0.001左右起步。
5.2 epsilon到底影响什么
eps在优化器里主要起数值稳定性的作用,防止除以零。但它的值大小对收敛行为的影响被很多人忽略了。以Adam为例,更新公式的分母是sqrt(v_hat) + eps,当某个参数的梯度非常小,v_hat的平方根小于eps时,eps就会主导分母,把更新量压得特别小,相当于这个参数的学习率被eps接管了。
我把这个现象叫"eps接管效应"。当eps设得比较大,比如1e-4,一些梯度较小的参数会获得比理论更新更小的步长,整体上相当于隐式降低了这些参数的学习率。这在某些任务上可能是好事,因为它天然抑制了噪声梯度;但在另一些任务上可能让模型欠拟合。所以如果你的模型在Adam下收敛得太慢,可以试着把eps调小到1e-10甚至更小,看看是否有改善。
另外需要注意,eps的单位和梯度尺度有关。如果你的损失函数量级特别大,比如均方误差的数值在几百,梯度也会相应变大,那么eps取1e-8就太小了,更新时的除法可能产生很大的数值。这时候需要把eps调大一点,或者先用梯度归一化把梯度的量级拉到一个稳定范围内。
5.3 损失曲线与梯度范数两个观察窗口
调参不能靠猜,得靠观察。我最常用的两个观察窗口是损失曲线和梯度范数。损失曲线不用多说,一条在下降、没有剧烈波动的曲线就是健康训练的典型标志。但有时候损失曲线看起来平稳,模型却没有在学东西,这时候就要看梯度范数。
计算方式很简单:
total_grad_norm = 0.0 for name, grad in model.grads().items(): if grad is not None: total_grad_norm += np.sum(grad ** 2) total_grad_norm = np.sqrt(total_grad_norm) print(f"epoch {epoch}, loss {loss:.4f}, grad norm {total_grad_norm:.4f}")如果梯度范数持续变大,说明训练可能开始不稳定了,可以考虑降低学习率或者加梯度裁剪;如果梯度范数下降得过快,接近零,说明收敛可能进入了停滞,也许是梯度消失,也许是学习率过小。这两个指标一起看,能帮你把调参从"瞎试"变成"有依据的调试"。
梯度裁剪是我在Numpy实现里一定会加的东西,尤其是训练RNN或者比较深的网络时。实现起来就三行:
grad_norm = np.linalg.norm(grad) if grad_norm > max_norm: grad = grad * max_norm / grad_norm这个操作把超过阈值的梯度向量等比缩放到阈值以内,保留方向不变,只是限制长度。别的什么都不动,但能避免很多训练到一半损失跳成NaN的惨案。
6. 实测对比与踩坑记录
6.1 在合成数据集上跑一圈的真实结果
为了验证封装的效果,我在一个简单的二分类任务上做了对比实验。数据用sklearn.datasets.make_moons生成,加了一点噪声,网络结构是两层的全连接MLP,隐藏层16个神经元,ReLU激活,输出层用sigmoid加二分类交叉熵。每个优化器用同样的初始化、同样的数据、同样的batch大小=32,只改优化器和学习率。
跑完50个epoch后,损失曲线和最终准确率差别很大。SGD在lr=0.01下收敛最慢,50个epoch后损失还在缓慢下降,准确率大约86%;Momentum明显快一些,大概86%到89%之间浮动;RMSProp从第一个epoch开始损失下降就比较快,最后能到90%附近;Adam前几个epoch的下降速度尤其夸张,损失几乎是指数级往下掉,最终准确率最高,接近92%。
这个结果不意外,因为数据规模小、网络简单,各家优化器都能work,只是收敛速度差异大。如果你把学习率调大,比如lr=0.1,SGD大概率直接发散,而Adam虽然也会有波动,但通常能稳住。这说明Adam对学习率不敏感的传言是有道理的。
6.2 几个容易翻车的Numpy细节
写优化器的过程中,我踩过几个记忆深刻的坑,基本都和Numpy的引用机制有关。
第一个坑是前面提到过的参数拷贝问题。有一次我为了在日志里记录每一轮的参数快照,self.snapshots.append(model.params()),结果所有快照最终都是同一个值。原因就是model.params()返回的是引用,快照列表里存的其实都是同一组对象的引用,等参数更新完再看,快照自然全都是最后的值。要记录快照必须手动np.copy()。
第二个坑是np.zeros_like对dtype的处理。如果你的参数是用float32初始化的,梯度也是float32,那么优化器里的状态变量用np.zeros_like也会是float32。这在大部分情况下没问题,但如果你的梯度在某个地方被转成了float64,比如在交叉熵损失里用了np.mean后引发类型提升,那么m *= self.beta1; m += (1 - self.beta1) * grad这一步就会直接把m的dtype提升成float64。之后这个状态变量参与的所有计算都会变成float64,训练速度骤降,占用的内存也翻倍。更隐蔽的是,如果别的参数还是float32,更新时m_hat / np.sqrt(v_hat)算出来是float64,再用param -= ...去更新一个float32的数组,Numpy会悄悄把结果截断成float32。看起来不报错,但数值精度已经损失了一截。我的建议是在优化器初始化的时候统一做好dtype管理,最简单的方法是给基类传一个dtype参数,状态变量统一用它初始化。
第三个坑是关于np.sqrt和eps的放置位置。有些Adam实现会写成np.sqrt(v_hat + eps)而不是np.sqrt(v_hat) + eps。两者在数值上差别很小,当v_hat很小时行为不同。前者的eps实际上是加在方差估计里的,会让梯度平方估计非常小的地方的更新量趋近于零,但不会彻底归零;后者是加在开方之后的,等效于给所有更新量加了一个下界。做实现的时候这两个写法都能在网上看到,但实际效果可能差出几个百分点。我个人习惯用np.sqrt(v_hat) + eps,因为语义更清晰:开方得到的是梯度的量级,eps是防止这个量级为0时的除零问题。
第四个坑不太容易被注意到,涉及到param -= self.lr * m_hat / (np.sqrt(v_hat) + self.eps)这一步的原地操作。如果param是float32数组,而右边表达式的结果因为各种类型提升变成了float64,那么Numpy在进行-=的时候实际上会创建一个float64的临时数组,计算完之后再截断回float32写回param。这个过程会额外分配内存,如果参数量很大,训练的时候内存峰值会明显上涨。虽然不至于崩掉,但如果你在优化性能,这种行为值得留意。
6.3 一个小技巧:跟踪优化器状态来定位问题
最后分享一个我认为很实用的小技巧。当你怀疑"模型没在学"到底是反向传播的问题还是优化器的问题时,可以在训练循环里打印优化器内部状态的变化情况,不用看loss曲线,直接看状态量的大小。
比如Adam的m如果一直是零,说明梯度压根没有传到优化器里,问题出在反向传播,优化器是无辜的;如果v非常小,说明梯度太小,可能是初始化的问题或者梯度消失;如果v快速增大,说明梯度非常大,可能是数据没归一化或者学习率过大了。
这个技巧我第一次发现是因为在调试一个网络时,loss纹丝不动。我当时以为是优化器写错了,打印Momentum的v之后才发现,前向和反向都没问题,是因为卷积层里某个参数的初始化把梯度压到了几乎为零。从那以后,我的调试必经流程就是先看优化器状态量,再去看loss。
封装优化器这件事说难不难,说简单也没那么简单。难的地方在于你得把每种算法的细节都弄明白,不光是公式,还包括数值稳定性、引用关系、dtype管理这些工程细节。简单的地方在于,一旦你把基类接口定好了,后续往里加新的优化器就只是照着update公式填代码的事。这套Numpy优化器封装我后来在好几个纯Numpy项目里复用,每次要加一个新优化器,基本十分钟就能写完跑通。