1. 从零手写损失函数、优化器与正则项:为什么我不建议你直接调库
刚入门深度学习那会儿,我也和大多数人一样,model.compile(loss='mse', optimizer='adam')一行代码搞定,训练跑起来就完事。直到有一次模型在验证集上死活不收敛,训练损失降得挺漂亮,验证损失却像坐过山车,我才意识到——如果连损失函数内部在算什么、优化器每一步怎么更新参数、正则项到底加在哪里都不清楚,调参就纯粹是碰运气。
这篇文章要聊的,就是用 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, training=True) 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(optimizer='adam', loss=mse_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 SGD:
class MomentumSGD: def __init__(self, learning_rate=0.01, momentum=0.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), trainable=False ) 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创建并设置trainable=False,避免它被误加入可训练参数列表。第三,assign_sub是原地操作,比var = var - lr * v更高效,也不会打断计算图。
使用方式和 Keras 优化器一致:
optimizer = MomentumSGD(learning_rate=0.01, momentum=0.9) for x_batch, y_batch in dataset: with tf.GradientTape() as tape: y_pred = model(x_batch, training=True) 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_lambda=1e-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_l2=1e-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_rate=0.001, weight_decay=1e-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), trainable=False) self.v[name] = tf.Variable(tf.zeros_like(var), trainable=False) 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,如果还不够再加 L2,lambda从 1e-5 开始试。
提示:BatchNorm 层本身有一定的正则效果,因为它在每个 batch 上计算均值和方差,引入了噪声。如果模型里 BatchNorm 很多,L2 的
lambda可以适当调小。
5. 完整训练流程与常见问题排查
5.1 把损失、优化器、正则串起来
下面是一个完整的训练循环,把前面所有模块整合在一起:
import tensorflow as tf # 1. 定义模型 model = tf.keras.Sequential([ tf.keras.layers.Dense(128, activation='relu', input_shape=(10,)), tf.keras.layers.Dense(64, activation='relu'), 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_rate=0.01, momentum=0.9) # 4. 正则项函数 def l2_reg(model, lam=1e-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, training=True) 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, training=False) val_loss += mse_loss(y_val, y_pred).numpy() print(f"Epoch {epoch}: train_loss={epoch_loss/len(train_dataset):.4f}, " f"val_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 组不同配置,记录每组的最佳验证损失,最后选最好的那组。这个过程虽然费时间,但比拍脑袋调参靠谱得多。