资讯详情

从零手写损失函数、优化器与正则项:深入理解深度学习训练底层逻辑

📅 2026/10/22 5:11:40 | 华诺云谱 👁 阅读
从零手写损失函数、优化器与正则项:深入理解深度学习训练底层逻辑
1. 从零手写损失函数、优化器与正则项为什么我不建议你直接调库刚入门深度学习那会儿我也和大多数人一样model.compile(lossmse, optimizeradam)一行代码搞定训练跑起来就完事。直到有一次模型在验证集上死活不收敛训练损失降得挺漂亮验证损失却像坐过山车我才意识到——如果连损失函数内部在算什么、优化器每一步怎么更新参数、正则项到底加在哪里都不清楚调参就纯粹是碰运气。这篇文章要聊的就是用 TensorFlow 从底层实现损失函数、优化器和正则项。不是教你调 API而是把这三块拆开揉碎让你看清楚每一行代码背后的数学逻辑。适合已经跑过几个 Demo、想进一步理解训练过程的人也适合面试前想系统梳理一遍基础的人。读完之后你至少能做到三件事自己写一个自定义损失函数并接入tf.GradientTape手写一个带 Momentum 的 SGD 优化器搞清楚 L1、L2 正则项在代码层面到底加在哪个位置。我个人的习惯是任何一个新项目先用底层方式把核心逻辑跑通一遍确认梯度流、损失计算、参数更新都没问题再换成高层 API 加速开发。这个流程帮我省了太多排查时间。2. 损失函数不只是算一个数那么简单2.1 损失函数的本质与选择逻辑损失函数的核心作用是衡量模型预测值与真实值之间的差距。这个差距用一个标量表示训练的目标就是让这个标量越来越小。听起来简单但选择哪个损失函数、怎么实现直接决定了梯度往哪个方向走。以回归任务为例最常用的是均方误差MSE。它的公式是MSE (1/n) * Σ(y_pred - y_true)²为什么是平方而不是绝对值因为平方函数处处可导梯度计算方便而且对大误差的惩罚更重模型会优先修正那些偏差大的样本。但平方也带来一个问题对异常值非常敏感。一个极端离群点就能把整个损失拉高导致模型被带偏。这时候可以考虑 Huber Loss它在误差较小时用平方误差较大时用线性兼顾了平滑性和鲁棒性。分类任务则通常用交叉熵。二分类用 Binary Cross Entropy多分类用 Categorical Cross Entropy。交叉熵的物理意义是衡量两个概率分布之间的距离配合 Softmax 使用时梯度形式非常简洁预测概率减去真实标签。这也是为什么分类任务几乎默认用交叉熵而不是 MSE——MSE 配合 Sigmoid 或 Softmax 会出现梯度消失训练极慢。2.2 用 TensorFlow 手写 MSE 与交叉熵先看 MSE 的底层实现。假设我们有一批预测值和真实值都是float32类型的张量import tensorflow as tf def mse_loss(y_true, y_pred): # 确保类型一致 y_true tf.cast(y_true, tf.float32) y_pred tf.cast(y_pred, tf.float32) # 计算平方差 squared_diff tf.square(y_pred - y_true) # 对所有维度求平均 return tf.reduce_mean(squared_diff)这段代码看起来简单但有几个细节值得注意。第一类型转换不能省。实际项目中标签可能是int32或float64不统一类型会直接报错。第二reduce_mean默认对所有维度求平均如果你的张量形状是(batch_size, output_dim)它会先对所有元素求和再除以总元素数。如果你想要的是每个样本单独算损失再平均需要指定axis-1再求一次均值。再看二分类交叉熵的实现def binary_cross_entropy(y_true, y_pred): y_true tf.cast(y_true, tf.float32) y_pred tf.cast(y_pred, tf.float32) # 裁剪预测值防止 log(0) 出现 NaN epsilon 1e-7 y_pred tf.clip_by_value(y_pred, epsilon, 1.0 - epsilon) # 交叉熵公式 bce -(y_true * tf.math.log(y_pred) (1 - y_true) * tf.math.log(1 - y_pred)) return tf.reduce_mean(bce)这里最关键的一步是clip_by_value。如果不做裁剪当预测值恰好为 0 或 1 时log(0)会返回负无穷整个损失变成 NaN训练直接崩溃。我踩过这个坑当时排查了半天才发现是某批数据标签极端导致的。加上 epsilon 裁剪之后这个问题就彻底消失了。多分类交叉熵稍微复杂一点因为涉及类别维度def categorical_cross_entropy(y_true, y_pred): y_true tf.cast(y_true, tf.float32) y_pred tf.cast(y_pred, tf.float32) epsilon 1e-7 y_pred tf.clip_by_value(y_pred, epsilon, 1.0) # y_true 是 one-hot 编码逐元素相乘后求和 cce -tf.reduce_sum(y_true * tf.math.log(y_pred), axis-1) return tf.reduce_mean(cce)注意这里的axis-1它表示在类别维度上求和得到每个样本的损失然后再对所有样本求平均。如果忘了指定 axis就会把所有样本的所有类别混在一起求和结果完全错误。2.3 自定义损失函数的接入方式与注意事项写好了损失函数怎么接入训练流程在tf.GradientTape环境下直接调用即可with tf.GradientTape() as tape: y_pred model(x_batch, trainingTrue) loss mse_loss(y_batch, y_pred) grads tape.gradient(loss, model.trainable_variables) optimizer.apply_gradients(zip(grads, model.trainable_variables))如果你用的是 Keras 的model.compile也可以把自定义损失传进去model.compile(optimizeradam, lossmse_loss)但这里有个隐藏的坑Keras 在编译时会尝试对损失函数进行签名检查如果你的函数签名不是(y_true, y_pred)的顺序或者返回值形状不对会报一些很难懂的错。我的建议是自定义损失函数一律用(y_true, y_pred)的参数顺序返回值确保是标量。提示自定义损失函数中尽量避免使用 Python 原生循环全部用 TensorFlow 的张量操作。原生循环不仅慢而且在GradientTape下可能无法正确追踪梯度。3. 优化器参数更新的艺术3.1 从 SGD 到 Adam 的演进逻辑优化器做的事情说白了就是根据梯度决定参数下一步往哪走、走多远。最朴素的 SGD 就是w w - lr * grad其中lr是学习率。这个公式简单到不能再简单但实际用起来问题很多。如果损失曲面在一个方向上很陡、另一个方向上很平SGD 会在陡的方向来回震荡在平的方向前进缓慢。这就是为什么需要 Momentum、RMSProp、Adam 这些改进版本。Momentum 的思路是引入一个“速度”变量让参数更新具有惯性。想象一个球从山坡滚下来遇到小坑不会立刻停下而是凭借惯性冲过去。数学上就是v beta * v (1 - beta) * grad w w - lr * vbeta通常取 0.9表示保留 90% 的历史速度。这样在梯度方向一致的维度上速度会累积更新加快在梯度方向反复变化的维度上正负抵消震荡减小。RMSProp 则是对学习率进行自适应调整。它维护一个梯度平方的指数移动平均s beta * s (1 - beta) * grad² w w - lr * grad / (sqrt(s) epsilon)梯度大的维度s大实际学习率被压低梯度小的维度s小实际学习率相对提高。这样每个维度都能以自己的节奏更新。Adam 把 Momentum 和 RMSProp 结合了起来同时维护一阶矩速度和二阶矩梯度平方并做了偏差修正。这也是为什么 Adam 在大多数任务上开箱即用收敛快且稳定。3.2 手写一个带 Momentum 的 SGD 优化器下面用 TensorFlow 实现一个完整的 Momentum SGDclass MomentumSGD: def __init__(self, learning_rate0.01, momentum0.9): self.lr learning_rate self.momentum momentum self.velocity {} # 用变量名做 key 存储速度 def apply_gradients(self, grads_and_vars): for grad, var in grads_and_vars: if grad is None: continue var_name var.name # 初始化速度 if var_name not in self.velocity: self.velocity[var_name] tf.Variable( tf.zeros_like(var), trainableFalse ) v self.velocity[var_name] # 更新速度v momentum * v grad v.assign(self.momentum * v grad) # 更新参数w w - lr * v var.assign_sub(self.lr * v)这段代码有几个设计决策值得说明。第一用字典按变量名存储速度而不是用一个全局列表。这样做的好处是即使模型结构动态变化速度变量也能正确对应。第二速度变量用tf.Variable创建并设置trainableFalse避免它被误加入可训练参数列表。第三assign_sub是原地操作比var var - lr * v更高效也不会打断计算图。使用方式和 Keras 优化器一致optimizer MomentumSGD(learning_rate0.01, momentum0.9) for x_batch, y_batch in dataset: with tf.GradientTape() as tape: y_pred model(x_batch, trainingTrue) loss mse_loss(y_batch, y_pred) grads tape.gradient(loss, model.trainable_variables) optimizer.apply_gradients(zip(grads, model.trainable_variables))3.3 学习率的选择与调试经验学习率是优化器里最重要的超参数没有之一。太大损失震荡甚至发散太小收敛慢到怀疑人生。我的经验是先用一个中等偏大的值比如 0.01跑几百步观察损失曲线。如果损失上下跳动明显说明太大如果损失几乎不降说明太小。更系统的做法是做学习率扫描从 1e-5 到 1e-1 取若干值每个跑少量步数画出损失随学习率变化的曲线。通常会发现一个“甜点区”损失下降最快。然后在这个区间内再细化。另外学习率调度也很重要。训练初期用大学习率快速下降后期用小学习率精细调整。常见策略包括阶梯衰减、余弦退火、指数衰减等。我一般先用常数学习率跑通流程确认模型没问题后再加调度。注意手写优化器时一定要确保所有可训练变量都被更新到。我见过有人漏掉了 BatchNorm 的beta和gamma导致模型表现异常。用model.trainable_variables可以拿到全部可训练变量不要手动列举。4. 正则项给模型戴上紧箍咒4.1 L1 与 L2 正则的数学原理正则项的作用是限制模型复杂度防止过拟合。最常用的是 L1 和 L2。L2 正则也叫权重衰减在损失函数中加上权重的平方和L_total L_original lambda * Σ(w²)这样梯度中会多出一项2 * lambda * w参数更新时每次都会把权重往零的方向拉一点。效果是权重整体变小模型更平滑对输入的微小变化不那么敏感。L1 正则加的是权重的绝对值之和L_total L_original lambda * Σ|w|L1 的梯度是lambda * sign(w)与权重值无关恒定地把权重往零推。这导致部分权重会精确变成零产生稀疏解。如果你想让模型只保留最重要的特征L1 是更好的选择。lambda控制正则强度。太大模型欠拟合连训练集都学不好太小等于没加。通常从 1e-4 到 1e-2 之间尝试具体看任务和模型规模。4.2 在 TensorFlow 中手动添加正则项手动添加正则项有两种方式加在损失函数里或者加在优化器更新时。先看第一种def total_loss(model, y_true, y_pred, l2_lambda1e-4): # 原始损失 base_loss mse_loss(y_true, y_pred) # L2 正则项 l2_reg tf.add_n([ tf.reduce_sum(tf.square(w)) for w in model.trainable_variables ]) return base_loss l2_lambda * l2_regtf.add_n把一个列表里的张量全部加起来。注意这里只对权重做正则偏置项通常不正则化。因为偏置不影响模型的平滑程度正则化偏置反而可能欠拟合。所以更精细的做法是只挑出权重变量def l2_regularization(model, lambda_l21e-4): reg_loss 0.0 for var in model.trainable_variables: if kernel in var.name or weight in var.name: reg_loss tf.reduce_sum(tf.square(var)) return lambda_l2 * reg_loss变量名里包含kernel或weight的通常是权重矩阵bias则是偏置。这个命名约定在 Keras 中比较统一但如果你自己搭建层需要确保命名规范。第二种方式是在优化器更新时加正则也就是所谓的“解耦权重衰减”。AdamW 就是这种做法它把权重衰减从梯度计算中分离出来直接作用于参数更新class AdamW: def __init__(self, learning_rate0.001, weight_decay1e-4): self.lr learning_rate self.wd weight_decay self.m {} self.v {} self.t 0 def apply_gradients(self, grads_and_vars): self.t 1 for grad, var in grads_and_vars: if grad is None: continue name var.name if name not in self.m: self.m[name] tf.Variable(tf.zeros_like(var), trainableFalse) self.v[name] tf.Variable(tf.zeros_like(var), trainableFalse) m, v self.m[name], self.v[name] # 一阶矩和二阶矩 m.assign(0.9 * m 0.1 * grad) v.assign(0.999 * v 0.001 * tf.square(grad)) # 偏差修正 m_hat m / (1 - 0.9 ** self.t) v_hat v / (1 - 0.999 ** self.t) # 参数更新Adam 更新 解耦权重衰减 var.assign_sub(self.lr * m_hat / (tf.sqrt(v_hat) 1e-8)) var.assign_sub(self.lr * self.wd * var)这种解耦方式在 Transformer 类模型上效果尤其好因为传统的 L2 正则和 Adam 的自适应学习率会相互干扰导致正则效果不稳定。4.3 正则强度的调试与过拟合判断怎么判断正则强度是否合适最直接的方法是看训练损失和验证损失的差距。如果训练损失远低于验证损失说明过拟合需要加大正则。如果两者都很高且接近说明欠拟合应该减小正则或者增加模型容量。我通常的做法是先不加正则跑一遍记录训练和验证曲线。然后逐步增加lambda观察验证损失的变化。验证损失先降后升最低点对应的lambda就是比较合适的值。还有一个经验Dropout 和正则项不要同时开太猛。两者都是防过拟合的手段叠加使用容易导致模型学不动。我一般优先用 Dropout如果还不够再加 L2lambda从 1e-5 开始试。提示BatchNorm 层本身有一定的正则效果因为它在每个 batch 上计算均值和方差引入了噪声。如果模型里 BatchNorm 很多L2 的lambda可以适当调小。5. 完整训练流程与常见问题排查5.1 把损失、优化器、正则串起来下面是一个完整的训练循环把前面所有模块整合在一起import tensorflow as tf # 1. 定义模型 model tf.keras.Sequential([ tf.keras.layers.Dense(128, activationrelu, input_shape(10,)), tf.keras.layers.Dense(64, activationrelu), tf.keras.layers.Dense(1) ]) # 2. 自定义损失 def mse_loss(y_true, y_pred): y_true tf.cast(y_true, tf.float32) y_pred tf.cast(y_pred, tf.float32) return tf.reduce_mean(tf.square(y_pred - y_true)) # 3. 自定义优化器 optimizer MomentumSGD(learning_rate0.01, momentum0.9) # 4. 正则项函数 def l2_reg(model, lam1e-4): reg 0.0 for var in model.trainable_variables: if kernel in var.name: reg tf.reduce_sum(tf.square(var)) return lam * reg # 5. 训练循环 for epoch in range(num_epochs): epoch_loss 0.0 for x_batch, y_batch in train_dataset: with tf.GradientTape() as tape: y_pred model(x_batch, trainingTrue) loss mse_loss(y_batch, y_pred) l2_reg(model) grads tape.gradient(loss, model.trainable_variables) optimizer.apply_gradients(zip(grads, model.trainable_variables)) epoch_loss loss.numpy() # 验证 val_loss 0.0 for x_val, y_val in val_dataset: y_pred model(x_val, trainingFalse) val_loss mse_loss(y_val, y_pred).numpy() print(fEpoch {epoch}: train_loss{epoch_loss/len(train_dataset):.4f}, fval_loss{val_loss/len(val_dataset):.4f})这个流程虽然比model.fit麻烦但每一步都透明可控。调试的时候你可以在任意位置打印中间变量确认梯度是否正常、损失是否在降。5.2 常见问题速查表问题现象可能原因排查方法解决方案损失变成 NaN学习率过大、log(0)、除零打印每步损失检查是否有 inf降低学习率加 epsilon 裁剪损失不下降学习率过小、梯度消失、正则过强打印梯度范数调大学习率检查激活函数减小 lambda验证损失震荡过拟合、batch 太小对比训练和验证曲线加正则、加 Dropout、增大 batch梯度为 None变量不可训练、计算图断开检查 trainable_variables确保变量在 GradientTape 内创建训练后期损失反弹学习率未衰减观察损失曲线拐点加学习率调度正则项不生效lambda 太小、变量名不匹配打印正则项数值调大 lambda检查变量名过滤条件5.3 我踩过的几个坑第一个坑是忘记在GradientTape上下文内计算损失。有一次我把损失计算写在了with块外面结果tape.gradient返回全是 None。原因是GradientTape只记录它上下文内发生的操作外面的计算它看不到。第二个坑是自定义优化器时速度变量没有用tf.Variable而是用了普通张量。普通张量在assign_sub时会报错因为 TensorFlow 的张量是不可变的。必须用tf.Variable才能原地更新。第三个坑是正则项重复计算。Keras 层本身可以设置kernel_regularizer如果你在层里设了又在损失函数里手动加了一遍正则强度就翻倍了。要么用层自带的正则要么手动加不要混用。第四个坑是学习率调度和自定义优化器不兼容。Keras 的LearningRateScheduler回调只对 Keras 优化器生效手写优化器需要自己实现调度逻辑。我的做法是在优化器类里加一个set_lr方法在训练循环中根据 epoch 调整。6. 一些实战中的参数选择建议关于损失函数回归任务优先用 MSE有异常值时换 Huber分类任务一律交叉熵二分类用 Binary多分类用 Categorical。如果类别极度不平衡考虑 Focal Loss它通过调节因子降低易分类样本的权重。关于优化器小规模数据或简单模型用 SGD Momentum 就够了收敛稳定且泛化好大规模数据或复杂模型用 Adam收敛快但可能需要配合学习率衰减。AdamW 在 Transformer 类模型上是首选。关于正则L2 是默认选择lambda从 1e-4 开始试需要稀疏解时用 L1两者结合就是 Elastic Net但实际项目中用得少。Dropout 和 BatchNorm 本身有正则效果叠加时注意强度。关于学习率我个人的习惯是先用 1e-3 跑通确认模型能学之后再做一次学习率扫描找最优值。训练后期一定要加衰减否则损失会在最优点附近震荡。这些参数没有绝对的最优值不同任务、不同数据分布下表现差异很大。最好的办法是搭一个小的实验框架把关键参数做成可配置的跑几组对比实验用验证集上的表现说话。我一般会同时跑 5 到 10 组不同配置记录每组的最佳验证损失最后选最好的那组。这个过程虽然费时间但比拍脑袋调参靠谱得多。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑