训练配置中的学习率,究竟是多少?🤔
在深度学习模型训练中,OneCycleLR 学习率调度器因其出色的收敛加速和性能优化能力而备受青睐。然而,当开发者初次接触它,尤其是在像 MMDetection 这样的高级框架中进行配置时,常常会遇到一个看似矛盾的问题。
让我们来看一个典型的配置片段:
# 典型的模型训练配置片段
optimizer = dict(type="AdamW", lr=0.002, weight_decay=0.005)
scheduler = dict(
type="OneCycleLR",
max_lr=[0.002, 0.0002],
pct_start=0.04,
anneal_strategy="cos",
div_factor=10.0,
final_div_factor=100.0,
)
param_dicts = [dict(keyword="block", lr=0.0002)]
初看之下,一切似乎都很合理。但一个疑问随之浮现:
optimizer.lr已经设置了0.002,param_dicts.lr也明确是0.0002。- 但
OneCycleLR的max_lr列表中,也包含了这两个值。
问题来了: 既然 OneCycleLR 已经有了明确的 max_lr 值,那么 optimizer 和 param_dicts 中的 lr 值还有什么意义?如果我把它们改成其他任意值,比如 optimizer.lr=999.0,训练会不会受到影响?
要回答这个问题,我们不能只停留在表面配置,必须深入到 PyTorch 和高层框架的底层实现中去寻找答案。
OneCycleLR 核心原理与参数详解
在深入探讨配置奥秘之前,我们先来透彻理解 OneCycleLR 的工作原理和各个参数的含义。
1. OneCycleLR 工作原理
OneCycleLR 的核心思想源于 Leslie N. Smith 的论文《A Disciplined Approach to Neural Network Hyper-parameters: Part 1 – Learning Rate, Batch Size, Momentum, and Weight Decay》。它模拟了一个周期性学习率的变化过程,主要分为两个阶段:
- 上升阶段(Phase 1):在训练的前
pct_start比例的步骤中,学习率从一个很小的值线性或余弦上升到max_lr。这一阶段也称为“学习率预热”(warm-up),有助于模型快速逃离损失函数的平坦区域。 - 下降阶段(Phase 2):在剩下的
(1 - pct_start)比例的步骤中,学习率从max_lr平滑地下降到一个极低的值。这一阶段也称为“学习率退火”(annealing),旨在让模型在训练末期进行精细调整,找到更优的解。
2. OneCycleLR 关键参数说明
max_lr:学习率在上升阶段将达到的最高值。如果模型参数被分为多个组,可以传入一个列表,为每个组指定不同的最大学习率。pct_start:学习率从初始值上升到max_lr所占总训练步数的比例。一个较小的值(如 0.04)意味着一个快速的预热过程。div_factor:用于计算初始学习率。初始学习率 =max_lr/div_factor。一个较大的div_factor意味着一个更小的初始学习率,从而提供更长的预热过程。final_div_factor:用于计算最终学习率。最终学习率 = 初始学习率 /final_div_factor。一个较大的final_div_factor意味着学习率在训练结束时会变得非常小。anneal_strategy:学习率从max_lr下降到最终学习率的策略。通常使用'cos'(余弦退火),它能提供平滑的下降曲线,被认为比线性下降更有效。
核心原理:一场关于“值”与“顺序”的终极辩论
要理解配置的奥秘,我们需要检视两个关键过程:构建优化器和 OneCycleLR 的内部工作。
1. 第一幕:框架如何构建优化器——源码深度剖析
类似MMDetection 这类框架处理配置的实现。它的核心目标是将一个单一的模型参数列表,根据配置拆分成多个独立的参数组(param_groups)。
def build_optimizer(cfg, model, param_dicts=None):
cfg = copy.deepcopy(cfg)
if param_dicts is None:
cfg.params = model.parameters()
else:
# Step 1: Create the default parameter group
# This group's 'lr' is set to the value from the main optimizer config
cfg.params = [dict(names=[], params=[], lr=cfg.lr)]
# Step 2: Create additional parameter groups based on param_dicts
for i in range(len(param_dicts)):
param_group = dict(names=[], params=[])
if "lr" in param_dicts[i].keys():
param_group["lr"] = param_dicts[i].lr
...
cfg.params.append(param_group)
# Step 3: Assign model parameters to their respective groups
for n, p in model.named_parameters():
flag = False
for i in range(len(param_dicts)):
if param_dicts[i].keyword in n:
cfg.params[i + 1]["names"].append(n)
cfg.params[i + 1]["params"].append(p)
flag = True
break
if not flag:
# Assign parameters to the default group
cfg.params[0]["names"].append(n)
cfg.params[0]["params"].append(p)
# Log and remove temporary 'names' key
for i in range(len(cfg.params)):
param_names = cfg.params[i].pop("names")
...
logger.info(...)
return OPTIMIZERS.build(cfg=cfg)
代码解读:
- Step 1(第5-6行):框架首先创建了一个默认参数组。它的
lr被初始化为optimizer配置中的lr值(即0.002)。这个默认组被放在cfg.params列表的第一个位置。 - Step 2(第8-15行):接着,框架遍历
param_dicts列表。它根据keyword创建新的参数组,并将param_dicts中指定的lr值(即0.0002)赋给这个新参数组。这些新组被按顺序添加到了cfg.params的末尾。 - Step 3(第18-29行):最后,框架遍历模型的每一个参数,并根据名称中的
keyword将其归类到相应的参数组中。没有匹配任何keyword的参数,则被放入第一个默认参数组。
关键结论:
在这一步之后,我们得到了一个组织好的 param_groups 列表,它的顺序是固定的:默认组在前,param_dicts 中指定的组在后。同时,每个组内部的 lr 键也已经根据你的配置被正确赋值。
2. 第二幕:OneCycleLR 的内部逻辑
现在,这个组织好的 optimizer 对象被传递给了 PyTorch 的 OneCycleLR 调度器。我们来看其源码:
# 摘自 torch.optim.lr_scheduler
class OneCycleLR(LRScheduler):
def __init__(self, optimizer, max_lr, ...):
# ...
# Initialize learning rate variables
max_lrs = self._format_param('max_lr', self.optimizer, max_lr)
if last_epoch == -1:
# This is the core loop
for idx, group in enumerate(self.optimizer.param_groups):
# The scheduler uses 'idx' to match groups with the max_lrs list
group['initial_lr'] = max_lrs[idx] / div_factor
group['max_lr'] = max_lrs[idx]
group['min_lr'] = group['initial_lr'] / final_div_factor
# ...
def _format_param(self, name, optimizer, param):
"""Return correctly formatted lr/momentum for each param group."""
if isinstance(param, (list, tuple)):
if len(param) != len(optimizer.param_groups):
raise ValueError(f"expected {len(optimizer.param_groups)} values for {name}, got {len(param)}")
return param
else:
return [param] * len(optimizer.param_groups)
源码解读:
_format_param函数:这个函数首先检查scheduler.max_lr列表的长度是否与optimizer.param_groups的长度一致。如果不一致,直接抛出ValueError。 这证明了长度匹配是强制性的。for idx, group in enumerate(self.optimizer.param_groups)::这正是最核心的一行。OneCycleLR调度器遍历优化器中的参数组,并使用索引idx来从max_lrs列表中获取相应的最大学习率。group['initial_lr'] = max_lrs[idx] / div_factor:在这里,OneCycleLR真正计算了初始学习率。它使用了max_lrs列表中的值和div_factor,而完全没有用到group['lr'](即optimizer.lr或param_dicts.lr)的值。
关键结论:
OneCycleLR的学习率分配逻辑是基于顺序的。optimizer.lr和param_dicts.lr的值,在OneCycleLR的核心计算中,功能上是冗余的。
3. 计算真正的学习率
根据我们的配置和源码分析,可以计算出各学习率的值:
-
默认参数组(排在第一个):
max_lr=scheduler.max_lr[0]= 0.002initial_lr=max_lr/div_factor= 0.002 / 10.0 = 0.0002min_lr=initial_lr/final_div_factor= 0.0002 / 100.0 = 0.000002
-
"block"参数组(排在第二个):
max_lr=scheduler.max_lr[1]= 0.0002initial_lr=max_lr/div_factor= 0.0002 / 10.0 = 0.00002min_lr=initial_lr/final_div_factor= 0.00002 / 100.0 = 0.0000002
最终结论:一个关于“工程实践”的终极答案
既然 optimizer.lr 和 param_dicts.lr 的值可以随便设置而不影响功能,那我们是不是真的可以乱写呢?
答案是:不可以(但你真随便写了,对功能上没有任何影响,因为这两个值没有使用)
尽管从技术实现上它们是冗余的,但在软件工程中,它们扮演着至关重要的约定和文档角色。它们是高层框架配置系统中的标识符,用来确保配置的可读性、可维护性和健壮性。
想象一下,如果一个团队的工程师看到 optimizer.lr = 999.0 但实际最大学习率是 0.002,这会立即引起困惑和误解。为了避免这种“代码层面的欺骗”,最佳实践是:
将 optimizer.lr 和 param_dicts.lr 的值,与 scheduler.max_lr 列表中相应的值保持完全一致。 这样做,你的配置就如同在自我解释,任何看到它的开发者都能立刻明白学习率的分配策略。
注意事项与建议
- 不要挑战约定:在严肃的工程项目中,始终遵守这种配置约定,它能让你远离难以调试的 bug。
- 理解优先级:在你的配置中,
param_dicts中的lr会覆盖默认的optimizer.lr,确保你指定的特定参数组能获得正确的学习率策略。 - 源码是最好的老师:当你对某个配置感到困惑时,勇敢地去查阅源码。源码是唯一不会说谎的地方。

9万+

被折叠的 条评论
为什么被折叠?



