Numpy手写优化器:从SGD到Adam的实现与调参
1. 为什么优化器值得单独封装1.1 优化器在训练流程里到底干了什么很多人学神经网络实现的时候会把大量精力放在前向传播和反向传播上这两块确实是最容易让人劝退的部分。但等到你真正用Numpy把全连接层、卷积层、激活函数都写了一遍、跑通了一次反向传播之后你会发现一个问题梯度算出来了参数怎么更新这一步看起来就是param - lr * grad简单得让人怀疑是不是漏了什么。实际上这个简单的算式只是优化器家族里的一个成员而且是最朴素的那个。从整个训练闭环来看一个典型的Numpy神经网络训练循环是这样的前向计算得到预测值用损失函数算出误差反向传播得到每个参数的梯度最后用优化器把梯度和当前参数结合起来产生一组新的参数。前两步决定了模型看得准不准反向传播决定了该往哪个方向走而优化器决定了每一步走多远、怎么走、走的时候要不要参考以前走过的路。前三步是数学推导最后一步其实是工程和经验的混合体。所以我一直觉得优化器是整个训练流程里被低估的环节。同样的网络结构、同样的初始化、同样的数据换一个优化器收敛速度和最终精度可以差出好几个量级。这一点在Numpy手写实现的场景里体现得尤其明显因为你没有框架帮你做那些隐式的梯度缩放、动量缓冲、学习率调度所有策略都得自己一行一行写出来。1.2 为什么要在Numpy里折腾优化器说实话现在用PyTorch或者TensorFlow一行optimizer torch.optim.Adam(model.parameters(), lr1e-3)就完事了完全没必要自己用Numpy实现。但如果你是在学习阶段或者需要在纯Numpy环境下搭建一个小型网络做验证手写优化器有几个实实在在的价值。第一个价值是理解原理。你去看Adam的论文或者公式心里想的可能是哦就是维护一阶矩和二阶矩然后做偏差校正。但只有当你亲手用Numpy把这段逻辑写出来并且跑在真实数据上观察它的收敛行为你才会真正理解为什么二阶矩能起到自适应学习率的作用为什么偏差校正在前几步那么关键。这些理解是直接调框架API给不了你的。第二个价值是调试和教学。在很多教学场景里你需要的是一个没有隐藏逻辑的最小实现。框架的优化器封装了几十层抽象出了问题不好定位自己写的Numpy版本就那么几十行每一行在做什么一目了然。第三个价值是定制灵活。做研究或者特殊场景的时候你可能会遇到标准的Adam不收敛或者需要在更新时对梯度做特殊处理这类需求。这时候你手里有一个Numpy版本的优化器基类改起来非常方便因为它就是纯粹的数值计算没有框架绑定。1.3 封装优化器时的接口设计思路既然要封装就要先想清楚接口怎么设计。我的建议是优化器需要和网络层解耦。网络层负责保存参数、计算梯度优化器只负责拿到参数和梯度之后更新参数。二者之间的通信方式很简单优化器遍历网络层暴露出来的参数字典拿到每个参数的梯度和当前值完成更新。接口可以设计得很薄。核心就两个方法一个是初始化内部状态动量缓冲、二阶矩估计这些另一个是根据当前参数和梯度做一步更新。这样不同优化器的区别就收敛到了内部状态怎么初始化和更新公式长什么样两个问题上整个架构非常干净。class Optimizer: def __init__(self, lr0.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成为深度学习默认选择的核心原因它对学习率不那么敏感默认的beta10.9、beta20.999、eps1e-8在绝大多数任务上都能直接工作。各个优化器的关键特征对比优化器核心思想需要维护的状态适用场景SGD沿梯度反方向更新无训练后期fine-tuning泛化性好Momentum累积历史更新方向速度变量解决震荡加速穿越平缓区RMSProp梯度平方指数加权缩放学习率二阶矩估计非平稳目标RNN训练常客Adam一阶矩二阶矩偏差校正两个矩估计默认选择绝大多任务可用3. Numpy实现四种优化器的完整封装3.1 优化器基类与状态管理有了前面的接口设计剩下的事情就是往里面填代码。这里我把step方法再细化一点让它可以处理某些层没有梯度的情况避免因为某些参数没有被反向传播到而崩溃。import numpy as np class Optimizer: def __init__(self, lr0.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, lr0.01, momentum0.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, lr0.01, momentum0.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, lr0.001, decay_rate0.9, eps1e-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, lr0.001, beta10.9, beta20.999, eps1e-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, axis0) 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_size64): 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.bdef 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(lr1e-3) model MLP(hidden_size64) for epoch in range(50): for x_batch, y_batch in make_batches(X_train, y_train, batch_size64): # 前向 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_decay0.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(fepoch {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在lr0.01下收敛最慢50个epoch后损失还在缓慢下降准确率大约86%Momentum明显快一些大概86%到89%之间浮动RMSProp从第一个epoch开始损失下降就比较快最后能到90%附近Adam前几个epoch的下降速度尤其夸张损失几乎是指数级往下掉最终准确率最高接近92%。这个结果不意外因为数据规模小、网络简单各家优化器都能work只是收敛速度差异大。如果你把学习率调大比如lr0.1SGD大概率直接发散而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项目里复用每次要加一个新优化器基本十分钟就能写完跑通。