优化方法
优化器是深度学习训练的引擎——给定损失函数的梯度,优化器决定如何更新参数来最小化损失。SGD 是最朴素的 w←w-η·g,但在鞍点和狭长山谷中收敛极慢;Momentum 引入动量 v=βv+g,像小球滚下山坡一样积累速度,加速收敛并平滑震荡;AdaGrad 为每个参数维护历史梯度平方和,让高频更新的参数自动减速、低频参数加速——适合 NLP 中稀疏特征(如罕见词的 embedding);Adam 结合了 Momentum 的一阶矩估计和 RMSProp 的二阶矩估计,是 GPT、BERT、Stable Diffusion 等模型训练的默认选择。学习率调度同样关键:Warmup 在前几百步线性增大学习率避免初期参数剧变,Cosine Decay 使后期学习率平滑衰减到接近零以精细调优——这是 LLM 训练的标准策略。本节逐个解释每种优化器的数学原理,并用 f(w)=w² 对比它们的实际收敛行为。
一、优化问题的本质
目标:找到参数 θ 使得损失函数 L(θ) 最小
θ* = argmin_θ L(θ)
为什么不能直接解方程 ∇L = 0?
① 深度网络的 L(θ) 极其复杂(千万个参数的非凸函数),没有解析解
② 数据量太大(如 1400 万张 ImageNet 图片),求精确梯度不现实
→ 所以用迭代的近似方法:每次看一小批数据,算近似梯度,走一小步
θ* = argmin_θ L(θ)
为什么不能直接解方程 ∇L = 0?
① 深度网络的 L(θ) 极其复杂(千万个参数的非凸函数),没有解析解
② 数据量太大(如 1400 万张 ImageNet 图片),求精确梯度不现实
→ 所以用迭代的近似方法:每次看一小批数据,算近似梯度,走一小步
二、批量梯度下降 vs 随机梯度下降 vs 小批量
| 方法 | 每次用多少数据 | 梯度质量 | 速度 | 适用场景 |
|---|---|---|---|---|
| 批量梯度下降(BGD) | 全部 N 个样本 | 精确 | 极慢 | 小数据集、凸问题 |
| 随机梯度下降(SGD) | 1 个样本 | 噪声大 | 极快 | 在线学习 |
| 小批量 SGD(Mini-batch) | B 个样本(32~512) | 适中 | 适中 | 深度学习标配 |
为什么小批量是最佳选择——数值分析:
假设数据集有 60000 个样本,每个 epoch(历遍所有数据一次):
BGD:1 次参数更新(精确但太慢)
SGD(B=1):60000 次更新(更快但方向太随机)
Mini-batch(B=64):60000/64 = 937 次更新(折中)
Mini-batch(B=256):60000/256 = 234 次更新
内存与 GPU 效率:
B=1:GPU 利用率极低(矩阵乘法太小,大量计算核空闲)
B=32:GPU 开始并行化(利用率约 30%)
B=256:GPU 满载(利用率 90%+,大矩阵乘法效率高)
B=8192:可能超出 GPU 内存;大 batch 泛化可能变差
经验建议:计算机视觉常用 B=32~256,NLP/Transformer 常用 B=16~64。
假设数据集有 60000 个样本,每个 epoch(历遍所有数据一次):
BGD:1 次参数更新(精确但太慢)
SGD(B=1):60000 次更新(更快但方向太随机)
Mini-batch(B=64):60000/64 = 937 次更新(折中)
Mini-batch(B=256):60000/256 = 234 次更新
内存与 GPU 效率:
B=1:GPU 利用率极低(矩阵乘法太小,大量计算核空闲)
B=32:GPU 开始并行化(利用率约 30%)
B=256:GPU 满载(利用率 90%+,大矩阵乘法效率高)
B=8192:可能超出 GPU 内存;大 batch 泛化可能变差
经验建议:计算机视觉常用 B=32~256,NLP/Transformer 常用 B=16~64。
三、梯度下降(SGD)
θₜ₊₁ = θₜ - α × gₜ
其中 gₜ = ∂L/∂θₜ(当前梯度),α = 学习率
就这么简单——沿着梯度反方向走一步。
其中 gₜ = ∂L/∂θₜ(当前梯度),α = 学习率
就这么简单——沿着梯度反方向走一步。
数值演示——最小化 f(w) = w⁴ - 3w² + 2(非凸函数)
f'(w) = 4w³ - 6w,学习率 α = 0.05,初始 w₀ = 2.0
收敛到 w ≈ 1.225(即 √(3/2)),f(w) = -0.05,这是一个局部最小值。
另一个局部最小值在 w ≈ -1.225。全局最小值两个等价。
f'(w) = 4w³ - 6w,学习率 α = 0.05,初始 w₀ = 2.0
| 步数 | w | f(w) | f'(w) | 更新量 |
|---|---|---|---|---|
| 0 | 2.000 | 6.000 | 20.000 | -1.000 |
| 1 | 1.000 | 0.000 | -2.000 | +0.100 |
| 2 | 1.100 | 0.048 | -1.274 | +0.064 |
| 3 | 1.164 | -0.012 | -0.696 | +0.035 |
| 4 | 1.199 | -0.036 | -0.297 | +0.015 |
| 5 | 1.213 | -0.045 | -0.075 | +0.004 |
| 10 | 1.225 | -0.050 | -0.001 | ≈0 |
另一个局部最小值在 w ≈ -1.225。全局最小值两个等价。
SGD 的三大问题:
① 学习率选择困难:太大→震荡不收敛,太小→收敛太慢
② 所有参数用同一个学习率:稀疏特征(如罕见词"磺胺")需要大学习率,高频特征(如"的")需要小学习率
③ 脆弱的鞍点和局部最小值:在鞍点处梯度为 0,卡住不动
① 学习率选择困难:太大→震荡不收敛,太小→收敛太慢
② 所有参数用同一个学习率:稀疏特征(如罕见词"磺胺")需要大学习率,高频特征(如"的")需要小学习率
③ 脆弱的鞍点和局部最小值:在鞍点处梯度为 0,卡住不动
四、SGD + 动量(Momentum)
vₜ = β × vₜ₋₁ + gₜ (累积历史梯度方向)
θₜ₊₁ = θₜ - α × vₜ
β 通常取 0.9(保留上一步 90% 的速度)
类比:小球从山坡滚下来,不仅受当前坡度影响,还保留了之前的速度。
在一致的下坡方向上越滚越快(加速收敛),
在来回震荡的方向上正负梯度相消(减少震荡)。
θₜ₊₁ = θₜ - α × vₜ
β 通常取 0.9(保留上一步 90% 的速度)
类比:小球从山坡滚下来,不仅受当前坡度影响,还保留了之前的速度。
在一致的下坡方向上越滚越快(加速收敛),
在来回震荡的方向上正负梯度相消(减少震荡)。
数值对比——SGD vs Momentum(二维椭圆函数):
f(x,y) = 0.5×100x² + 0.5×y²(等高线是扁椭圆)
∇f = (100x, y),初始点 (3.0, 3.0),α=0.01,β=0.9
SGD(无动量)5步轨迹:
(3.0, 3.0) → (0, 2.97) → (-2.97, 2.94) → (0.03, 2.91) → ...
x 方向来回震荡(梯度 100x 很大但学习率太大)!y 方向缓慢下降。
SGD+Momentum 5步轨迹:
步1: g=(300,3), v=(300,3), 新位置(-0.0,2.97)
步2: g=(-0.3,2.97), v=0.9×(300,3)+(-0.3,2.97)=(269.7,5.67), 新位置...
x 方向:正负梯度在动量中相消 → 震荡被抑制
y 方向:梯度一直为正 → 动量不断累积 → 加速前进
结果:Momentum 在 50 步内收敛到 (0.01, 0.01),SGD 还在剧烈震荡。
f(x,y) = 0.5×100x² + 0.5×y²(等高线是扁椭圆)
∇f = (100x, y),初始点 (3.0, 3.0),α=0.01,β=0.9
SGD(无动量)5步轨迹:
(3.0, 3.0) → (0, 2.97) → (-2.97, 2.94) → (0.03, 2.91) → ...
x 方向来回震荡(梯度 100x 很大但学习率太大)!y 方向缓慢下降。
SGD+Momentum 5步轨迹:
步1: g=(300,3), v=(300,3), 新位置(-0.0,2.97)
步2: g=(-0.3,2.97), v=0.9×(300,3)+(-0.3,2.97)=(269.7,5.67), 新位置...
x 方向:正负梯度在动量中相消 → 震荡被抑制
y 方向:梯度一直为正 → 动量不断累积 → 加速前进
结果:Momentum 在 50 步内收敛到 (0.01, 0.01),SGD 还在剧烈震荡。
五、AdaGrad——自适应学习率
sₜ = sₜ₋₁ + gₜ² (累积梯度平方)
θₜ₊₁ = θₜ - α × gₜ / (√sₜ + ε)
ε = 10⁻⁸(防止除零)
核心思想:对每个参数,根据历史梯度大小自动调整学习率。
梯度一直很大的参数 → sₜ 很大 → 有效学习率变小(已经学够了)
梯度一直很小的参数 → sₜ 很小 → 有效学习率变大(还需要多学)
θₜ₊₁ = θₜ - α × gₜ / (√sₜ + ε)
ε = 10⁻⁸(防止除零)
核心思想:对每个参数,根据历史梯度大小自动调整学习率。
梯度一直很大的参数 → sₜ 很大 → 有效学习率变小(已经学够了)
梯度一直很小的参数 → sₜ 很小 → 有效学习率变大(还需要多学)
数值示例——NLP 词嵌入场景:
两个参数:w₁(对应高频词"的"),w₂(对应低频词"磺胺")
初始学习率 α=0.1
5轮训练中的梯度和 AdaGrad 更新:
高频词 "的" → 梯度大 → s₁ 快速增大 → 学习率从 0.05 降到 0.025
低频词 "磺胺" → 梯度小 → s₂ 增长很慢 → 学习率维持在 0.27~1.0
自动实现了"频繁参数小更新,罕见参数大更新"!
两个参数:w₁(对应高频词"的"),w₂(对应低频词"磺胺")
初始学习率 α=0.1
5轮训练中的梯度和 AdaGrad 更新:
| 轮次 | g₁(高频) | g₂(低频) | s₁ | s₂ | 实际lr₁ | 实际lr₂ |
|---|---|---|---|---|---|---|
| 1 | 2.0 | 0.1 | 4.0 | 0.01 | 0.1/√4=0.05 | 0.1/√0.01=1.0 |
| 2 | 1.5 | 0.0 | 6.25 | 0.01 | 0.04 | 1.0 |
| 3 | 1.8 | 0.2 | 9.49 | 0.05 | 0.032 | 0.447 |
| 4 | 2.1 | 0.0 | 13.90 | 0.05 | 0.027 | 0.447 |
| 5 | 1.3 | 0.3 | 15.59 | 0.14 | 0.025 | 0.267 |
低频词 "磺胺" → 梯度小 → s₂ 增长很慢 → 学习率维持在 0.27~1.0
自动实现了"频繁参数小更新,罕见参数大更新"!
AdaGrad 的致命问题:s 只增不减,到训练后期所有参数的学习率都趋于 0,训练提前"冻结"。
六、RMSProp——修复 AdaGrad
sₜ = ρ × sₜ₋₁ + (1-ρ) × gₜ² (指数加权滑动平均)
θₜ₊₁ = θₜ - α × gₜ / (√sₜ + ε)
ρ = 0.99(衰减率),只保留最近的梯度信息,旧的自动遗忘
解决了 AdaGrad 的 s 单调递增问题。
θₜ₊₁ = θₜ - α × gₜ / (√sₜ + ε)
ρ = 0.99(衰减率),只保留最近的梯度信息,旧的自动遗忘
解决了 AdaGrad 的 s 单调递增问题。
七、Adam——工业标配优化器
结合了 Momentum(一阶矩)和 RMSProp(二阶矩):
mₜ = β₁ × mₜ₋₁ + (1-β₁) × gₜ (一阶矩=动量)
vₜ = β₂ × vₜ₋₁ + (1-β₂) × gₜ² (二阶矩=自适应学习率)
偏差修正(训练初期 mₜ 和 vₜ 偏小,因为初始化为 0):
m̂ₜ = mₜ / (1 - β₁ᵗ)
v̂ₜ = vₜ / (1 - β₂ᵗ)
θₜ₊₁ = θₜ - α × m̂ₜ / (√v̂ₜ + ε)
推荐超参数:α=0.001, β₁=0.9, β₂=0.999, ε=10⁻⁸
mₜ = β₁ × mₜ₋₁ + (1-β₁) × gₜ (一阶矩=动量)
vₜ = β₂ × vₜ₋₁ + (1-β₂) × gₜ² (二阶矩=自适应学习率)
偏差修正(训练初期 mₜ 和 vₜ 偏小,因为初始化为 0):
m̂ₜ = mₜ / (1 - β₁ᵗ)
v̂ₜ = vₜ / (1 - β₂ᵗ)
θₜ₊₁ = θₜ - α × m̂ₜ / (√v̂ₜ + ε)
推荐超参数:α=0.001, β₁=0.9, β₂=0.999, ε=10⁻⁸
Adam 完整计算过程——3步迭代:
参数 w=5.0(初始远离最优值),损失 L=w²,∂L/∂w=2w
α=0.001, β₁=0.9, β₂=0.999, m₀=0, v₀=0
═══ 第1步 ═══
g₁ = 2×5.0 = 10.0
m₁ = 0.9×0 + 0.1×10.0 = 1.0
v₁ = 0.999×0 + 0.001×100 = 0.1
m̂₁ = 1.0/(1-0.9¹) = 1.0/0.1 = 10.0
v̂₁ = 0.1/(1-0.999¹) = 0.1/0.001 = 100.0
w₁ = 5.0 - 0.001 × 10.0/√100.0 = 5.0 - 0.001 × 1.0 = 4.999
═══ 第2步 ═══
g₂ = 2×4.999 = 9.998
m₂ = 0.9×1.0 + 0.1×9.998 = 1.900
v₂ = 0.999×0.1 + 0.001×99.96 = 0.200
m̂₂ = 1.900/(1-0.81) = 1.900/0.19 = 10.0
v̂₂ = 0.200/(1-0.998) = 0.200/0.002 = 100.0
w₂ = 4.999 - 0.001 × 10.0/10.0 = 4.998
═══ 第3步 ═══
g₃ = 9.996
m₃ = 0.9×1.900 + 0.1×9.996 = 2.710
v₃ = 0.999×0.200 + 0.001×99.92 = 0.300
m̂₃ = 2.710/(1-0.729) = 2.710/0.271 = 10.0
v̂₃ = 0.300/(1-0.997) = 0.300/0.003 = 100.0
w₃ = 4.998 - 0.001 × 10.0/10.0 = 4.997
观察:偏差修正后每步几乎走 0.001。
没有偏差修正时:第1步 m̂₁ 会是 1.0 而不是 10.0,步长太小。
参数 w=5.0(初始远离最优值),损失 L=w²,∂L/∂w=2w
α=0.001, β₁=0.9, β₂=0.999, m₀=0, v₀=0
═══ 第1步 ═══
g₁ = 2×5.0 = 10.0
m₁ = 0.9×0 + 0.1×10.0 = 1.0
v₁ = 0.999×0 + 0.001×100 = 0.1
m̂₁ = 1.0/(1-0.9¹) = 1.0/0.1 = 10.0
v̂₁ = 0.1/(1-0.999¹) = 0.1/0.001 = 100.0
w₁ = 5.0 - 0.001 × 10.0/√100.0 = 5.0 - 0.001 × 1.0 = 4.999
═══ 第2步 ═══
g₂ = 2×4.999 = 9.998
m₂ = 0.9×1.0 + 0.1×9.998 = 1.900
v₂ = 0.999×0.1 + 0.001×99.96 = 0.200
m̂₂ = 1.900/(1-0.81) = 1.900/0.19 = 10.0
v̂₂ = 0.200/(1-0.998) = 0.200/0.002 = 100.0
w₂ = 4.999 - 0.001 × 10.0/10.0 = 4.998
═══ 第3步 ═══
g₃ = 9.996
m₃ = 0.9×1.900 + 0.1×9.996 = 2.710
v₃ = 0.999×0.200 + 0.001×99.92 = 0.300
m̂₃ = 2.710/(1-0.729) = 2.710/0.271 = 10.0
v̂₃ = 0.300/(1-0.997) = 0.300/0.003 = 100.0
w₃ = 4.998 - 0.001 × 10.0/10.0 = 4.997
观察:偏差修正后每步几乎走 0.001。
没有偏差修正时:第1步 m̂₁ 会是 1.0 而不是 10.0,步长太小。
八、学习率调度
为什么要调整学习率:
训练初期:远离最优值 → 大学习率快速接近
训练后期:接近最优值 → 小学习率精细调整
常用调度策略:
① StepLR(阶梯衰减):每 N 个 epoch 乘以 γ
例:初始 lr=0.1,每 30 epoch ×0.1
epoch 0-29: lr=0.1, epoch 30-59: lr=0.01, epoch 60-89: lr=0.001
用于 ResNet 等 CNN 训练。
② CosineAnnealing(余弦退火):
lr(t) = lr_min + 0.5(lr_max - lr_min)(1 + cos(πt/T))
从 lr_max 平滑下降到 lr_min,末期下降加速。
例:lr_max=0.1, lr_min=0, T=100
t=0: lr=0.1, t=25: lr=0.085, t=50: lr=0.05, t=75: lr=0.015, t=100: lr=0
③ Warmup + Decay(GPT/BERT 标配):
前 N 步线性增大学习率(warmup),之后逐步衰减。
例:warmup=4000步
步1: lr=0.00001, 步2000: lr=0.0005, 步4000: lr=0.001(峰值)
之后余弦衰减到接近 0。
为什么需要 warmup:训练初期参数随机、梯度方差大,大学习率容易发散。
训练初期:远离最优值 → 大学习率快速接近
训练后期:接近最优值 → 小学习率精细调整
常用调度策略:
① StepLR(阶梯衰减):每 N 个 epoch 乘以 γ
例:初始 lr=0.1,每 30 epoch ×0.1
epoch 0-29: lr=0.1, epoch 30-59: lr=0.01, epoch 60-89: lr=0.001
用于 ResNet 等 CNN 训练。
② CosineAnnealing(余弦退火):
lr(t) = lr_min + 0.5(lr_max - lr_min)(1 + cos(πt/T))
从 lr_max 平滑下降到 lr_min,末期下降加速。
例:lr_max=0.1, lr_min=0, T=100
t=0: lr=0.1, t=25: lr=0.085, t=50: lr=0.05, t=75: lr=0.015, t=100: lr=0
③ Warmup + Decay(GPT/BERT 标配):
前 N 步线性增大学习率(warmup),之后逐步衰减。
例:warmup=4000步
步1: lr=0.00001, 步2000: lr=0.0005, 步4000: lr=0.001(峰值)
之后余弦衰减到接近 0。
为什么需要 warmup:训练初期参数随机、梯度方差大,大学习率容易发散。
九、优化器选择指南
| 场景 | 推荐优化器 | 典型配置 | 说明 |
|---|---|---|---|
| 通用深度学习 | Adam / AdamW | lr=1e-3~3e-4 | 开箱即用,几乎不需要调参 |
| Transformer/LLM | AdamW + Warmup | lr=3e-4, warmup=4000 | 权重衰减用 AdamW 而非 L2 |
| 图像分类(CNN) | SGD + Momentum | lr=0.1, β=0.9, StepLR | 精调时 SGD 常比 Adam 好 |
| GAN 训练 | Adam | lr=2e-4, β₁=0.5 | β₁ 通常设成 0.5 而非 0.9 |
| 微调预训练大模型 | AdamW | lr=2e-5~5e-5 | 学习率比从头训练小 10~100 倍 |
十、代码验证(C# / Rust)
C#(.NET 10)
// dotnet run 即可执行
double F(double w) => w * w;
double GradF(double w) => 2.0 * w;
// ===== SGD / Momentum / Adam 对比 =====
void Train(string name, double lr, double wInit = 5.0, int steps = 20)
{
double w = wInit, m = 0, v = 0, vel = 0;
double beta1 = 0.9, beta2 = 0.999, eps = 1e-8;
for (int t = 1; t <= steps; t++)
{
double g = GradF(w);
switch (name)
{
case "SGD": w -= lr * g; break;
case "Momentum": vel = 0.9 * vel + g; w -= lr * vel; break;
case "Adam":
m = beta1 * m + (1 - beta1) * g;
v = beta2 * v + (1 - beta2) * g * g;
double mH = m / (1 - Math.Pow(beta1, t));
double vH = v / (1 - Math.Pow(beta2, t));
w -= lr * mH / (Math.Sqrt(vH) + eps);
break;
}
}
Console.WriteLine($"{name,-10}: 最终 w={w:F6}, f(w)={F(w):F8}");
}
Train("SGD", 0.1);
Train("Momentum", 0.01);
Train("Adam", 0.5);
// ===== AdaGrad 演示 =====
Console.WriteLine("\nAdaGrad (高频 vs 低频参数):");
double w1 = 5.0, w2 = 5.0, s1 = 0.0, s2 = 0.0;
double lrAdagrad = 0.5;
double[] gW1 = { 2.0, 1.5, 1.8, 2.1, 1.3 };
double[] gW2 = { 0.1, 0.0, 0.2, 0.0, 0.3 };
for (int t = 0; t < 5; t++)
{
s1 += gW1[t] * gW1[t]; s2 += gW2[t] * gW2[t];
double eff1 = lrAdagrad / (Math.Sqrt(s1) + 1e-8);
double eff2 = lrAdagrad / (Math.Sqrt(s2) + 1e-8);
w1 -= eff1 * gW1[t]; w2 -= eff2 * gW2[t];
Console.WriteLine($" t={t + 1}: 高频lr={eff1:F4}, 低频lr={eff2:F4}");
}
// ===== Warmup + Cosine Decay =====
Console.WriteLine("\nWarmup + Cosine Decay 调度:");
int totalSteps = 100, warmup = 10;
double lrMax = 0.001;
foreach (int step in new[] { 1, 5, 10, 25, 50, 75, 100 })
{
double lrS = step <= warmup
? lrMax * step / warmup
: lrMax * 0.5 * (1 + Math.Cos(Math.PI * (step - warmup) / (double)(totalSteps - warmup)));
Console.WriteLine($" Step {step,3}: lr = {lrS:F6}");
}
Rust
fn main() {
let f = |w: f64| w * w;
let grad_f = |w: f64| 2.0 * w;
// ===== SGD / Momentum / Adam 对比 =====
for &(name, lr) in &[("SGD", 0.1), ("Momentum", 0.01), ("Adam", 0.5)] {
let (mut w, mut m, mut v, mut vel) = (5.0_f64, 0.0, 0.0, 0.0);
let (beta1, beta2, eps) = (0.9_f64, 0.999, 1e-8);
for t in 1..=20_i32 {
let g = grad_f(w);
match name {
"SGD" => w -= lr * g,
"Momentum" => { vel = 0.9_f64.mul_add(vel, g); w -= lr * vel; }
"Adam" => {
m = beta1.mul_add(m, (1.0 - beta1) * g);
v = beta2.mul_add(v, (1.0 - beta2) * g * g);
let m_hat = m / (1.0 - beta1.powi(t));
let v_hat = v / (1.0 - beta2.powi(t));
w -= lr * m_hat / (v_hat.sqrt() + eps);
}
_ => {}
}
}
println!("{name:<10}: 最终 w={w:.6}, f(w)={:.8}", f(w));
}
// ===== AdaGrad 演示 =====
println!("\nAdaGrad (高频 vs 低频参数):");
let (mut w1, mut w2, mut s1, mut s2) = (5.0_f64, 5.0, 0.0, 0.0);
let lr = 0.5_f64;
let gw1 = [2.0, 1.5, 1.8, 2.1, 1.3];
let gw2 = [0.1, 0.0, 0.2, 0.0, 0.3];
for t in 0..5 {
s1 += gw1[t] * gw1[t]; s2 += gw2[t] * gw2[t];
let eff1 = lr / (s1.sqrt() + 1e-8);
let eff2 = lr / (s2.sqrt() + 1e-8);
w1 -= eff1 * gw1[t]; w2 -= eff2 * gw2[t];
println!(" t={}: 高频lr={eff1:.4}, 低频lr={eff2:.4}", t + 1);
}
// ===== Warmup + Cosine Decay =====
println!("\nWarmup + Cosine Decay 调度:");
let (total, warmup, lr_max) = (100_i32, 10_i32, 0.001_f64);
for step in [1, 5, 10, 25, 50, 75, 100] {
let lr_s = if step <= warmup {
lr_max * step as f64 / warmup as f64
} else {
let prog = (step - warmup) as f64 / (total - warmup) as f64;
lr_max * (std::f64::consts::PI * prog).cos().mul_add(0.5, 0.5)
};
println!(" Step {step:3}: lr = {lr_s:.6}");
}
}