先说结论:调模型调到最后还能天天盯着看的指标,其实就那么四个——epoch、batch、loss、val_loss。几乎所有人在入门深度学习的第一天就听过它们,但真正能把四者之间的关系讲清楚、并在实际训练里用对的人没那么多。我见过太多人把 epoch 直接拉到几百轮才开始调参,也见过有人 batch size 随手填个 32 就再也没动过,最后抱怨显存不够、收敛太慢、验证集一动不动。这篇文章我打算把这四个概念从定义、换算关系、参数选择逻辑,到训练曲线怎么读、出问题怎么排查,完整捋一遍,中间夹带一些我自己踩过的坑和实际项目里的经验数值,希望能让刚上手的人少走点弯路,也让已经跑过几个模型的人重新校准一下自己的直觉。
1. 先把四个概念摆到桌面上:一次训练到底在干什么
1.1 从原始数据到一次参数更新
要理解 epoch 和 batch,得先接受一个事实:神经网络没法一次性吃下你所有的数据。假设你有 10 万张图片要训练一个分类模型,如果每一轮都拿全部 10 万张算一次梯度再更新一次参数,会发生两件糟糕的事。第一,显存扛不住,10 万张图的前向激活值放在显存里,普通卡直接爆掉。第二,梯度方向会很"稳",但更新次数太少,一个 epoch 只更新一次参数,收敛速度慢到没法用。
所以从业者的做法是把数据切开,一块一块喂给模型。每次喂进去的这一小块,就叫一个batch。模型对这一个 batch 做一次前向传播算出损失(也就是loss),做一次反向传播算出梯度,然后优化器更新一次参数。这个"取一个 batch 到更新一次参数"的完整动作,叫做一个iteration,也有人叫 step。
而当全部数据都被完整地过了一遍,不管中间切了多少个 batch,这一整轮就叫做一个epoch。换句话说,epoch 是"数据过一遍"的宏观单位,iteration 是"参数更新一次"的微观单位,batch 是连接它们两个的中间量。
这里有个容易混淆的点:很多人把 batch 和 iteration 当成一回事,其实严格说 batch 是数据块的容量(名词属性更强),iteration 是动作的次数。20 万条样本、batch size 取 200,那需要 1000 个 iteration 才能完成一个 epoch。这个换算关系建议刻进脑子里,后面调参全靠它。
提示:很多人问"epoch 到底要设多少",这个问题的答案永远是"看验证集",但前提是你得先把 iteration 和 epoch 的换算搞清楚,否则你连"模型这辈子更新过多少次参数"都不知道,调参就是盲猜。
1.2 batch、iteration、epoch 三者的换算关系
把换算关系写成公式,比记文字靠谱得多:
- 每个 epoch 的 iteration 数 = ceil(样本总数 / batch_size)
- 总 iteration 数 = 每个 epoch 的 iteration 数 × epoch 数
- 有效 batch size = 单卡 batch_size × 梯度累积步数 × 数据并行卡数
拿一个真实场景举例。手头数据集 12.8 万条样本,单卡 batch size 设 64,那一个 epoch 就是 128000 / 64 = 2000 个 iteration。如果训练 30 个 epoch,总迭代次数就是 6 万次,也就意味着参数被更新了 6 万次。
| 参数 | 含义 | 典型取值范围 | 主要影响 |
|---|---|---|---|
| batch_size | 一次前向反传的样本数 | 8 ~ 512 | 显存占用、训练速度、梯度噪声 |
| iteration | 一次参数更新的动作 | 由数据集和 batch 决定 | 决定总训练步数 |
| epoch | 完整过一遍数据集 | 5 ~ 200 | 训练总时长、过拟合风险 |
| loss | 训练集上的损失值 | 越小越好,但要看趋势 | 判断模型是否在学 |
| val_loss | 验证集上的损失值 | 越小越好,是选模型依据 | 判断泛化能力 |
上表这几个数值不是随便写的,是我在图像分类和文本分类任务里反复试出来比较稳的区间。当然具体项目差异很大,检测、分割这类任务的 batch size 常常只有 2 到 8,因为单张图的激活值太大了。这个后面会展开。
2. epoch 和 batch 的选择逻辑:为什么我不建议一上来就调它们
2.1 epoch 太多会怎样:过拟合的现场
新手最常见的误区是"epoch 拉满总没错"。理论上,训练集上的 loss 会随着 epoch 增加一直往下走,看起来很美。但模型的目的是在没见过的数据上表现好,而 val_loss 往往在某个 epoch 之后开始掉头往上走。这个拐点就是过拟合开始的地方。
我印象很深的一次,做一个工业质检的二分类任务,训练集只有 4000 多张图,我图省事设了 200 个 epoch。结果前 30 个 epoch val_loss 一路降到 0.18,很漂亮;从第 40 个 epoch 开始,train loss 还在降,掉到 0.02,但 val_loss 已经悄悄爬回 0.35 了。模型把训练集里的每张图的噪声都背下来了,换到新图上直接歇菜。
正确的思路是:epoch 设一个偏大的上限(比如 100 或 200),然后靠**早停(Early Stopping)**来截断。早停的逻辑很朴素——盯着 val_loss,如果连续 N 个 epoch 都不再创新低,就停下来。这个 N 通常叫 patience,取 5 到 10 比较常见,太小容易被正常波动误伤,太大浪费时间。
注意:早停必须配合"保存最佳模型"一起用。如果你只早停不保存,最后拿到的可能是已经过拟合的那一版参数。保存逻辑一定挂在 val_loss 最低的那个 epoch 上,而不是最后一个 epoch。
还有一个细节,如果你是做微调(fine-tune)预训练模型,epoch 通常只要 3 到 10 就够了,甚至 2 到 3 个 epoch 就能出很好的效果。因为预训练权重本身已经很强,你只是把它往你的任务上掰一点,用力过猛反而会把原有的知识覆盖掉,这个现象在圈子里叫灾难性遗忘。我在做文本分类微调时,10 万条数据一般也就跑 3 个 epoch,再多就开始伤模型了。
2.2 batch size 与显存、速度、泛化的三角关系
batch size 是这四个参数里最需要认真对待的一个,因为它同时牵扯三件事:显存、速度、泛化。
显存角度:batch size 和显存占用基本是线性关系。batch 翻倍,激活值占用翻倍(权重本身不变)。所以显存不够时的第一反应就是降 batch size。但要注意,降低 batch size 会让某些依赖批统计的层出问题,比如 BatchNorm。当 batch size 小到 1 或 2 时,BatchNorm 算出的均值方差极不稳定,训练会抖得很厉害。这时候的正确做法是把 BatchNorm 换成 GroupNorm,或者冻结 BN 的统计量。
速度角度:这里有个反直觉的地方。很多人以为 batch size 越小越快,其实在 GPU 上恰恰相反。小 batch 会导致 GPU 的并行计算单元吃不满,每个 iteration 虽然算得快,但利用率低,单位时间内处理的样本数反而下降。我实测过 ResNet50 在单张卡上的表现,batch size 从 16 提到 128,每秒处理的图片数大概能翻一倍。
泛化角度:这个更微妙。小 batch 因为每次梯度噪声大,反而有轻微的正则化效果,有时最终精度比大 batch 还好一点。大 batch 梯度方向准,收敛平滑,但容易陷进 sharp minimum,测试集上稍差。这也是为什么有些论文用超大 batch 训练时要额外加 warmup 或者改用 LARS、LAMB 这类优化器。
综合下来,我的经验是:在显存允许范围内,把 batch size 设到能跑满 GPU 的档位,通常是 32 到 256 之间。如果碰到显存瓶颈,再考虑梯度累积。
2.3 梯度累积:小显存做大 batch 的折中
梯度累积(Gradient Accumulation)是个非常实用的技巧,值得单独讲。它的思路是:我不一次性塞 256 个样本,而是每次只塞 32 个,连续算 8 次,但前 7 次不更新参数,只把梯度累加起来,第 8 次才真正更新一次。这样参数更新的效果和 batch size 256 几乎一样,但显存只需要 batch size 32 的量。
伪代码大概长这样:
accum_steps = 8 optimizer.zero_grad() for i, (x, y) in enumerate(loader): out = model(x) loss = criterion(out, y) / accum_steps # 关键:除以累积步数 loss.backward() if (i + 1) % accum_steps == 0: optimizer.step() optimizer.zero_grad()这里有个新手特别容易踩的坑:loss 一定要除以 accum_steps。因为 PyTorch 的 backward 默认是把梯度累加的,如果不除,累积 8 次之后梯度就是原来的 8 倍,等效于把学习率放大了 8 倍,训练直接炸掉。我就见过有人这么写,loss 上来就是 nan,排查了一整天。
提示:梯度累积只是模拟大 batch 的更新频率,它并不改变 BatchNorm 的统计量。如果你的模型里有 BatchNorm,累积出来的统计量仍然是按小 batch 算的。这点要清楚。
3. loss 与 val_loss:训练中唯一值得你信任的两个数字
3.1 loss 的数学本质
loss 这个词被用得太随意了,其实它是一个非常明确的数学对象:衡量模型预测和真实标签之间差多远的一个标量函数。训练的本质就是不断调整参数让这个标量变小。
回归任务里最常见的是均方误差(MSE),把预测值和真实值的差平方后求平均。分类任务里最常见的是交叉熵(Cross Entropy),它衡量的是模型输出的概率分布和真实分布之间的差异。交叉熵的公式是:
L = -Σ y_i * log(p_i)其中 y_i 是真实标签的独热编码,p_i 是模型给出的第 i 类概率。直观理解就是:模型对真实类别给出的概率越高,log 出来的负值越小,loss 就越低;如果模型把真实类别的概率给得很低,loss 就会很大,逼着模型改。
这里我要顺带讲一个高频热词——focal loss。它是在标准交叉熵上加了一个调节因子:
FL = -α * (1 - p_t)^γ * log(p_t)其中 p_t 是模型对真实类别的预测概率,γ 是聚焦参数(通常取 2),α 是类别平衡权重。这个 (1 - p_t)^γ 的作用是:对已经分得很准的样本(p_t 接近 1)自动降低权重,把注意力集中到那些还没分对的难样本上。
它为什么火?因为在目标检测、欺诈识别这类正负样本极度不均衡的场景里,标准交叉熵会被大量的简单负样本淹没——模型只要把所有东西都预测成负类,loss 就已经很低了,梯度就没什么动力去学分得少但重要的正样本。focal loss 就是来解决这个的。我在做电商异常订单识别时,正样本占比只有 0.3%,换成 focal loss 后召回率从 0.61 提到 0.78,效果非常明显。
3.2 val_loss 为什么有时候比 loss 还小
这是新手最困惑的问题之一:明明验证集是模型没见过的数据,为什么 val_loss 反而比 train loss 还低?
第一个原因是训练时的 loss 是一个滑动平均值。你看到的是最近若干个 iteration 的均值,这里面包含了训练早期 loss 很高的那一段,所以整体被拉高了。而 val_loss 是在当前这个已经训练得不错的模型上一次性算出来的,自然更低。
第二个原因是训练时的额外扰动。训练阶段通常会开 dropout、数据增强、随机裁剪等操作,这些都会让 train loss 偏高。而验证阶段这些全部关闭,模型是在最"干净"的状态下评估,loss 自然更低。
第三个原因是正则项。如果你的 loss 里加了 L2 正则(weight decay),那 train loss 本身就含了正则项,而 val_loss 通常只算纯粹的预测误差。
如果排除了以上原因,val_loss 依然显著低于 train loss,那就要警惕是不是验证集和训练集有重叠,或者数据划分时没打乱导致分布不一致。这个坑我踩过一次,做时间序列预测时用随机划分替代了按时间划分,结果验证集里有一部分样本和训练集高度相关,val_loss 低得离谱,上线后一塌糊涂。
3.3 早停与模型保存:盯哪个指标
前面说了早停要看 val_loss,这里展开一下具体怎么做。
best_val_loss = float('inf') patience = 7 counter = 0 for epoch in range(num_epochs): train_loss = train_one_epoch(model, train_loader) val_loss = evaluate(model, val_loader) if val_loss < best_val_loss: best_val_loss = val_loss counter = 0 torch.save(model.state_dict(), 'best.pt') else: counter += 1 if counter >= patience: print(f'早停于 epoch {epoch}') break有两个细节值得说。第一,保存的模型应该是 val_loss 最低的那一版,不是最后一版。很多人训练完直接拿最后一步的权重去推理,白白浪费了早停的意义。第二,patience 的设置跟学习率调度有关。如果你用了 ReduceLROnPlateau 这种"val_loss 不降就降学习率"的策略,patience 要设得比它大,否则学习率还没降下去你就早停了,白白错过一波提升。
另外提一个容易忽略的点:val_loss 和下游指标不一定完全正相关。做分类任务时,val_loss 最低的模型,准确率或 F1 不一定最高,因为在决策边界附近的样本,概率的微小变化对 loss 影响大,但对最终分类结果没影响。所以如果你的项目对某个具体指标特别敏感(比如医疗场景的召回率),那就应该直接拿那个指标来做模型选择,而不是死盯 val_loss。
4. 损失函数选型与训练循环实操
4.1 常见 loss 选型对照表
选 loss 这件事,很多人是抄来的,看到别人用交叉熵就用交叉熵,看到别人用 Dice 就用 Dice,其实每种 loss 都有它适合的场景。我把常用的整理成一张表:
| 任务类型 | 推荐 loss | 适用场景 | 注意事项 |
|---|---|---|---|
| 单标签分类 | CrossEntropyLoss | 类别互斥 | 标签是类别索引,别自己转 one-hot |
| 多标签分类 | BCEWithLogitsLoss | 一个样本多个标签 | 内部自带 sigmoid,别再手动加 |
| 类别不均衡 | Focal Loss | 正负样本悬殊 | γ 从 2 起调,α 按类别频率倒数设 |
| 语义分割 | Dice Loss + CE | 前景占比小 | 单用 Dice 早期梯度不稳,建议联合 |
| 目标检测框回归 | Smooth L1 / GIoU | 坐标回归 | 直接 MSE 对离群框太敏感 |
| 回归预测 | MSELoss / HuberLoss | 连续值 | 有异常值时用 Huber 更稳 |
| 度量学习 | TripletLoss | 人脸、检索 | 难样本挖掘策略比 loss 本身更关键 |
这张表里的每一条我基本都在项目里用过。这里特别说一下"类别不均衡用 Focal Loss"这一行,很多人一遇到不均衡就上 focal loss,其实如果只是轻微不均衡(比如 3:1),直接给 CrossEntropy 加个 class_weight 就够了,focal loss 调参反而更麻烦。真正需要 focal loss 的是那种 100:1 甚至更极端的场景。
还有一点,多任务学习里各 loss 的加权是门手艺。我一般会先让每个任务的 loss 单独跑一段,观察它们大致的数值量级,然后让各项在总 loss 里的贡献比例大致相当。有些团队会专门定义一个 loss ratio 指标来跟踪每个损失项占总损失的比重,比如总 loss 是 1.0,其中分类项占 0.6、回归项占 0.4,如果发现某一项长期占 0.95,那基本说明其他任务被压制了,得手动调权重。这个做法在多任务项目里非常实用,比拍脑袋设权重靠谱得多。
4.2 学习率与 batch size 的联动
这是个必须讲的点,因为它直接决定了你的 loss 能不能正常下降。
有一个经验规律叫线性缩放规则:如果把 batch size 扩大 k 倍,学习率也应该相应扩大 k 倍(或者取平方根,视情况而定)。原因是 batch 变大后,梯度的噪声变小,方向更准,你可以用更大的步子走。
但这里有个配套技巧叫warmup。因为训练刚开始时参数是随机的,梯度方向很乱,如果一上来就用大学习率,loss 很容易直接炸到 nan。warmup 的做法是:前几百个 iteration 让学习率从 0 线性升到目标值,然后再按正常策略衰减。
def lr_lambda(step): if step < warmup_steps: return step / warmup_steps return 0.5 * (1 + math.cos(math.pi * (step - warmup_steps) / (total_steps - warmup_steps))) scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)我实测下来的经验是:batch size 在 64 以下时,学习率 1e-3 到 3e-4 比较稳;batch size 到了 512 以上,学习率可以上到 1e-2,但必须配 warmup。如果是微调预训练模型,学习率要再降一个数量级,通常用 2e-5 到 5e-5,这个范围和 BERT 系列论文里给的建议基本一致。
4.3 手写一个可复现的训练循环
把前面这些东西串起来,一个完整、可复现、带早停和梯度累积的训练循环大概是这样:
import torch import math model = build_model() optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.01) criterion = torch.nn.CrossEntropyLoss() accum_steps = 4 best_val_loss = float('inf') patience, counter = 7, 0 for epoch in range(50): model.train() optimizer.zero_grad() running_loss = 0.0 for i, (x, y) in enumerate(train_loader): x, y = x.cuda(), y.cuda() out = model(x) loss = criterion(out, y) / accum_steps loss.backward() running_loss += loss.item() * accum_steps if (i + 1) % accum_steps == 0: torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() optimizer.zero_grad() train_loss = running_loss / len(train_loader) model.eval() val_loss, correct, total = 0.0, 0, 0 with torch.no_grad(): for x, y in val_loader: x, y = x.cuda(), y.cuda() out = model(x) val_loss += criterion(out, y).item() correct += (out.argmax(1) == y).sum().item() total += y.size(0) val_loss /= len(val_loader) acc = correct / total print(f'Epoch {epoch}: train={train_loss:.4f} val={val_loss:.4f} acc={acc:.4f}') if val_loss < best_val_loss: best_val_loss = val_loss counter = 0 torch.save(model.state_dict(), 'best.pt') else: counter += 1 if counter >= patience: print('触发早停') break这段代码里几个点值得单独强调。梯度裁剪(clip_grad_norm_)在 RNN、Transformer 类模型里几乎是标配,能有效防止梯度爆炸;每轮结束手动切换 model.train() 和 model.eval(),忘了切换会导致 dropout 和 BatchNorm 行为不对,验证结果完全不可信;val_loss 累加后要除以 len(val_loader),不然得到的数值跟 batch 数量挂钩,没有可比性。
注意:如果你用的是 CrossEntropyLoss,标签必须是类别索引(长整型),不用做 one-hot。而 BCEWithLogitsLoss 的标签必须是浮点型,这个类型不匹配是最常见的报错来源之一,报错信息往往还很难看懂。
5. 训练曲线诊断与问题排查实录
5.1 四种典型曲线走势
训练曲线是 loss 和 val_loss 随 epoch 变化的图,读图的能力比调参技巧更重要。我总结下来常见的走势有四种:
第一种是理想型:train loss 和 val loss 同步下降,最后都趋于平缓,两条线之间保持一个稳定的间距。这说明模型容量、正则化强度、数据量都比较匹配,是最省心的状态。
第二种是过拟合型:train loss 继续降,val loss 在某个点后拐头上升,两条线开始"分叉"。这时候的动作是加正则(dropout、weight decay)、做数据增强、或者干脆减小模型。数据这种"从根上解决问题"的办法永远优先于调参。
第三种是欠拟合型:两条线都在高位平缓,降不下去。说明模型太简单或者学习率太小。这种时候换个更大的模型、调大学习率、去掉过强的正则,通常马上见效。
第四种是发散型:loss 直接往上跑或者变成 nan。这基本是学习率太大、梯度爆炸或者数据里有脏样本(标签错、数值 inf/nan)。先降学习率,再加梯度裁剪,最后检查数据。
5.2 常见问题速查表
我把这些年遇到过的问题和对应处理整理成一张表,方便对着查:
| 现象 | 可能原因 | 排查方向 | 处理办法 |
|---|---|---|---|
| loss 一直是 nan | 学习率过大、数据含 inf | 打印第一批数据 | 降 lr、加 warmup、清洗数据 |
| loss 完全不降 | lr 太小、模型没接对 | 检查参数是否在更新 | 调大 lr、打印梯度范数 |
| val_loss 剧烈震荡 | batch 太小、BN 不稳 | 看 batch size | 增大 batch 或换 GroupNorm |
| val_loss 远低于 train | 数据泄漏 | 检查划分逻辑 | 按时间/主体重新划分 |
| 训练到一半突然变差 | 学习率调度不合适 | 看 lr 曲线 | 改余弦退火或加 warmup |
| 显存突然爆掉 | 序列长度不均、缓存未释放 | 看输入 shape | 加 padding 上限、清 cache |
| 某轮起 loss 卡住不变 | 梯度消失、数据加载卡死 | 打印梯度范数 | 换激活函数、检查 dataloader |
这张表里"梯度范数"这个排查手段我要重点推荐。很多人调 loss 不降的时候只会反复改学习率,其实在 backward 之后打印一下各层的梯度范数,一眼就能看出问题:如果梯度范数接近 0,是梯度消失;如果是几十上百,是梯度爆炸;如果正好是 0,那可能是某个环节 detach 了,或者这层根本没参与计算。
5.3 踩坑记录
说几个我印象比较深的真实坑。
第一个坑是Dataloader 的 num_workers 设太大。我一度以为 num_workers 越大加载越快,设成了 32,结果训练速度反而下降,因为进程切换开销加上内存拷贝把收益吃掉了。一般的经验是 num_workers 设成 CPU 核数的 1/4 到 1/2,配合 pin_memory=True 就够了。
第二个坑是验证集的预处理跟训练集不一致。训练时我用了随机裁剪加归一化,验证时顺手复制了同一套 transform,结果验证集也做了随机裁剪,每次评估的结果都不一样,val_loss 抖得跟心电图似的。正确的做法是验证集只保留确定性的 resize 和归一化,绝对不能有随机操作。
第三个坑是滑动平均导致的"假下降"。有段时间我用了指数移动平均(EMA)来平滑曲线,结果 loss 看起来很稳,实际上是平滑掩盖了剧烈波动,真正的训练问题一直没被发现。现在我更倾向于原始曲线和滑动平均曲线都画出来,两条一起看。
提示:训练初期一定要做一次小样本过拟合测试。随便取 16 条数据,把模型跑上几十个 iteration,如果 loss 不能降到接近 0,说明模型结构、loss 或者标签对接有问题,这时候不用浪费时间跑全量。这个习惯能帮你省下大量调试时间。
6. 命名撞车:batch 和 loss 在其他领域指什么
6.1 批处理的几种常见语境
"batch" 这个词本身是"一批、一炉"的意思,所以在很多行业里都有它自己的含义,跨领域沟通时经常闹笑话。
比如在 3D 建模和游戏美术流程里,batch FBX export指的是把多个模型文件一次性批量导出成 FBX 格式。这跟深度学习的 batch 没有半点关系,只是借用了"批量"这个语义。你在搜索引擎里搜 batch,搜出来一堆 3D 软件的操作教程,就是这个原因。
工业扫描领域有batch scan wizard,指的是扫描仪软件里的批量扫描向导,用来连续扫描多张纸、多个工件。化工流程模拟里有aspen batch process,指的是间歇式(批处理)生产工艺的建模,也就是一炉一炉投料的生产方式,跟连续生产工艺相对。这些 batch 都是"批量处理"的意思,语义上是一致的,只是应用场景完全不同。
深度学习里的 batch 其实也是这个语义的延伸——把一批样本一起处理。所以你在跟非算法同事交流时,直接说"我一次喂给模型 64 张图",比说"我的 batch size 是 64"要清楚得多。
6.2 名字相同但含义完全不同的坑
更有意思的是 "loss" 和 "power loss" 这种撞车。BIOS 里有一项 AC Power Loss 的设置,指的是服务器或工控机在突然断电、之后又来电时,主板应该怎么反应——是自动开机、保持关机还是恢复断电前的状态。运维同事排查"掉电后服务器不自动开机"时,会去改这个 BIOS 选项,跟神经网络里的损失函数一点关系都没有。
类似地,电力行业的"线损"、金融行业的"损失率",翻译成英文都可能是 loss,但那是完全不同的概念。我曾经在一次跨部门会议上,听到有人在讨论"loss ratio",一边理解为模型各项损失的占比,一边理解为业务上的赔付率,聊了半天才发现双方说的不是一件事。
所以我的建议是:在跨领域协作时,第一次提到这些词一定要带上限定语。说"训练损失"而不是"loss",说"批量大小"而不是"batch",说"一轮训练"而不是"epoch"。多花几秒钟把话说全,能省掉后面几十分钟的互相误解。
关于 epoch 和 batch 的选择,我自己最后沉淀下来的默认配置是这样的:图像分类任务,batch size 从 64 起试,显存允许就往上提到 128 或 256,epoch 上限设 100 并配 patience 为 7 的早停,学习率 3e-4 配余弦退火和 500 步 warmup,loss 先跑标准交叉熵看基线,如果发现类别不均衡再换 focal loss。这套配置在我手上大部分中等规模数据集里都能跑出可用的结果,剩下的就是根据 val_loss 曲线的形状微调。真要说有什么忠告,就是别急着调参,先把数据切分、标签对齐、预处理一致性这几个基础环节确认清楚,我见过的问题里有七成以上最后都溯源到数据环节,而不是 epoch 或者 batch 本身设错了。