Skip to content

Commit 36ed34d

Browse files
Add laprop as an optimizer along with tests (#206)
* add laprop as an optimizer along with tests Signed-off-by: mikail <mkhona@nvidia.com>
1 parent 1338dc4 commit 36ed34d

4 files changed

Lines changed: 367 additions & 0 deletions

File tree

emerging_optimizers/scalar_optimizers/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@
1212
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
15+
from emerging_optimizers.scalar_optimizers.laprop import LaProp
1516
from emerging_optimizers.scalar_optimizers.lion import Lion
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
from collections.abc import Callable
16+
from typing import TYPE_CHECKING, override
17+
18+
19+
if TYPE_CHECKING:
20+
from typing import overload
21+
22+
import torch
23+
from absl import logging
24+
from torch.optim.optimizer import ParamsT
25+
26+
from emerging_optimizers import registry
27+
from emerging_optimizers.mixin import WeightDecayMixin, WeightDecayT
28+
from emerging_optimizers.scalar_optimizers.update_functions import calculate_laprop_update
29+
30+
31+
__all__ = [
32+
"LaProp",
33+
]
34+
35+
36+
@registry.register_optimizer("laprop")
37+
class LaProp(WeightDecayMixin, torch.optim.Optimizer):
38+
"""LaProp optimizer.
39+
40+
LAProp can be seen as RMSProp with a momentum term, or normalized SGD with momentum.
41+
This optimizer tracks Adam-style first and second moments, but normalizes the gradient
42+
before the first-moment update.
43+
44+
The update rule below assumes ``weight_decay_method="decoupled"`` (the default).
45+
See :class:`~emerging_optimizers.mixin.WeightDecayMixin` for the other modes.
46+
47+
.. math::
48+
p = p \\cdot (1 - \\text{lr} \\cdot \\lambda) \\\\
49+
v_t = \\beta_2 v_{t-1} + (1 - \\beta_2) g_t^2 \\\\
50+
\\hat{v}_t = \\frac{v_t}{1 - \\beta_2^t} \\\\
51+
g'_t = \\frac{g_t}{\\sqrt{\\hat{v}_t} + \\epsilon} \\\\
52+
m_t = \\beta_1 m_{t-1} + (1 - \\beta_1) g'_t \\\\
53+
\\hat{m}_t = \\frac{m_t}{1 - \\beta_1^t} \\\\
54+
p = p - \\text{lr} \\cdot \\hat{m}_t
55+
56+
References:
57+
- Ziyin, L., Wang, Z. T., & Ueda, M. *LaProp: Separating Momentum and
58+
Adaptivity in Adam.* arXiv:2002.04839 (2020).
59+
[`arXiv:2002.04839 <https://arxiv.org/abs/2002.04839>`_]
60+
61+
Args:
62+
params: Iterable of parameters to optimize or dicts defining parameter groups.
63+
lr: Learning rate.
64+
betas: Coefficients (beta1, beta2) for first and second moment EMAs.
65+
eps: Term added to the denominator for numerical stability.
66+
weight_decay: Weight decay coefficient.
67+
correct_bias: Whether to apply bias correction to the first and second moment EMAs.
68+
frob_normalize: Whether to normalize each updated parameter back to its pre-update Frobenius norm.
69+
weight_decay_method: Method to apply weight decay, see
70+
:class:`~emerging_optimizers.mixin.WeightDecayMixin` for more details.
71+
"""
72+
73+
def __init__(
74+
self,
75+
params: ParamsT,
76+
lr: float = 1e-3,
77+
betas: tuple[float, float] = (0.9, 0.999),
78+
eps: float = 1e-8,
79+
weight_decay: float = 0.0,
80+
correct_bias: bool = True,
81+
frob_normalize: bool = False,
82+
weight_decay_method: WeightDecayT = "decoupled",
83+
) -> None:
84+
if not 0.0 <= lr:
85+
raise ValueError(f"Invalid learning rate: {lr}")
86+
if not 0.0 <= betas[0] < 1.0:
87+
raise ValueError(f"Invalid beta at index 0: {betas[0]}")
88+
if not 0.0 <= betas[1] < 1.0:
89+
raise ValueError(f"Invalid beta at index 1: {betas[1]}")
90+
if not 0.0 <= eps:
91+
raise ValueError(f"Invalid epsilon value: {eps}")
92+
if not 0.0 <= weight_decay:
93+
raise ValueError(f"Invalid weight_decay value: {weight_decay}")
94+
if frob_normalize and weight_decay != 0.0:
95+
logging.warning("LaProp with frob_normalize=True is intended to be used with weight_decay=0.0.")
96+
defaults = dict(lr=lr, betas=betas, eps=eps, weight_decay=weight_decay, correct_bias=correct_bias)
97+
self.weight_decay_method = weight_decay_method
98+
self.frob_normalize = frob_normalize
99+
super().__init__(params, defaults)
100+
101+
@torch.no_grad()
102+
def _init_group(
103+
self,
104+
group: dict,
105+
skip_non_grad_params: bool = True,
106+
) -> None:
107+
"""Performs lazy state initialization for parameters.
108+
109+
Args:
110+
group: Parameter group dictionary.
111+
skip_non_grad_params: If True, skip parameters without gradients.
112+
"""
113+
for p in group["params"]:
114+
if skip_non_grad_params and p.grad is None:
115+
continue
116+
state = self.state[p]
117+
118+
if len(state) == 0:
119+
state["step"] = 0
120+
state["exp_avg"] = torch.zeros_like(p.data)
121+
state["exp_avg_sq"] = torch.zeros_like(p.data)
122+
123+
if TYPE_CHECKING:
124+
125+
@overload
126+
def step(self, closure: None = ...) -> None: ...
127+
128+
@overload
129+
def step(self, closure: Callable[[], float]) -> float: ...
130+
131+
@torch.no_grad() # type: ignore[misc]
132+
@override
133+
def step(self, closure: Callable[[], float] | None = None) -> float | None:
134+
"""Perform a single optimization step.
135+
136+
Note:
137+
When ``weight_decay_method="l2"``, ``p.grad`` is modified in-place
138+
(the L2 penalty ``weight_decay * p`` is added to the gradient).
139+
If you need the original gradient after this call, clone it beforehand.
140+
141+
Args:
142+
closure: A closure that reevaluates the model and returns the loss (optional).
143+
144+
Returns:
145+
The loss from the closure, if provided.
146+
"""
147+
if closure is None:
148+
loss = None
149+
else:
150+
loss = closure()
151+
152+
for group in self.param_groups:
153+
self._init_group(group)
154+
155+
lr = group["lr"]
156+
betas = group["betas"]
157+
eps = group["eps"]
158+
weight_decay = group["weight_decay"]
159+
correct_bias = group["correct_bias"]
160+
161+
for p in group["params"]:
162+
if p.grad is None:
163+
continue # pragma: no cover
164+
165+
grad = p.grad
166+
state = self.state[p]
167+
state["step"] += 1
168+
pre_norm = p.data.norm() if self.frob_normalize else None
169+
170+
self._apply_weight_decay_inplace(p.data, grad, lr, weight_decay)
171+
172+
update = calculate_laprop_update(
173+
grad,
174+
state["exp_avg"],
175+
state["exp_avg_sq"],
176+
correct_bias,
177+
betas,
178+
state["step"],
179+
eps,
180+
)
181+
p.data.add_(update, alpha=-lr)
182+
if self.frob_normalize:
183+
assert pre_norm is not None
184+
p.data.mul_(pre_norm / p.data.norm().clamp_min(eps))
185+
186+
return loss

tests/test_laprop.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
import torch
17+
from absl import flags, logging
18+
from absl.testing import absltest, parameterized
19+
20+
from emerging_optimizers.scalar_optimizers import LaProp
21+
from emerging_optimizers.scalar_optimizers.update_functions import calculate_laprop_update
22+
23+
24+
flags.DEFINE_enum("device", "cpu", ["cpu", "cuda"], "Device to run tests on")
25+
flags.DEFINE_integer("seed", None, "Random seed for reproducible tests")
26+
FLAGS = flags.FLAGS
27+
28+
29+
def setUpModule() -> None:
30+
if FLAGS.seed is not None:
31+
logging.info("Setting random seed to %d", FLAGS.seed)
32+
torch.manual_seed(FLAGS.seed)
33+
if torch.cuda.is_available():
34+
torch.cuda.manual_seed_all(FLAGS.seed)
35+
36+
37+
class LaPropOptimizerTest(parameterized.TestCase):
38+
def setUp(self):
39+
self.device = FLAGS.device
40+
41+
@parameterized.parameters(
42+
{"shape": (3, 3)},
43+
{"shape": (15, 31)},
44+
{"shape": (127, 255)},
45+
)
46+
def test_smoke(self, shape) -> None:
47+
"""LaProp optimizer can be instantiated and stepped."""
48+
param = torch.nn.Parameter(torch.randn(*shape, device=self.device))
49+
optimizer = LaProp([param], lr=1e-4)
50+
param.grad = torch.randn_like(param)
51+
optimizer.step()
52+
53+
@parameterized.parameters(
54+
{"shape": (3, 3)},
55+
{"shape": (15, 31)},
56+
{"shape": (127, 255)},
57+
)
58+
def test_state_initialization(self, shape) -> None:
59+
"""LaProp initializes first moment, second moment, and step state."""
60+
beta1, beta2 = 0.5, 0.75
61+
param = torch.nn.Parameter(torch.randint(1, 5, shape, device=self.device, dtype=torch.float32))
62+
optimizer = LaProp([param], lr=0.25, betas=(beta1, beta2), weight_decay=0.0, correct_bias=True)
63+
grad = torch.randint_like(param, 1, 5)
64+
param.grad = grad.clone()
65+
optimizer.step()
66+
67+
self.assertEqual(optimizer.state[param]["step"], 1)
68+
self.assertIn("exp_avg", optimizer.state[param])
69+
self.assertIn("exp_avg_sq", optimizer.state[param])
70+
71+
expected_exp_avg_sq = (1 - beta2) * grad.square()
72+
normalized_grad = grad / (grad.abs() + optimizer.param_groups[0]["eps"])
73+
expected_exp_avg = (1 - beta1) * normalized_grad
74+
torch.testing.assert_close(optimizer.state[param]["exp_avg_sq"], expected_exp_avg_sq, atol=0, rtol=0)
75+
torch.testing.assert_close(optimizer.state[param]["exp_avg"], expected_exp_avg, atol=0, rtol=0)
76+
77+
@parameterized.parameters(
78+
{"shape": (3, 3)},
79+
{"shape": (15, 31)},
80+
{"shape": (127, 255)},
81+
)
82+
def test_optimizer_step_matches_update_function(self, shape) -> None:
83+
"""LaProp optimizer delegates update math to calculate_laprop_update."""
84+
lr = 0.25
85+
betas = (0.5, 0.75)
86+
eps = 1e-8
87+
param = torch.nn.Parameter(torch.randint(-5, 5, shape, device=self.device, dtype=torch.float32))
88+
grad = torch.randint(-5, 5, shape, device=self.device, dtype=torch.float32)
89+
optimizer = LaProp([param], lr=lr, betas=betas, eps=eps, weight_decay=0.0)
90+
91+
old_param = param.detach().clone()
92+
exp_avg = torch.zeros_like(param)
93+
exp_avg_sq = torch.zeros_like(param)
94+
expected_update = calculate_laprop_update(grad, exp_avg, exp_avg_sq, True, betas, 1, eps)
95+
96+
param.grad = grad.clone()
97+
optimizer.step()
98+
99+
torch.testing.assert_close(param, old_param - lr * expected_update, atol=0, rtol=0)
100+
torch.testing.assert_close(optimizer.state[param]["exp_avg"], exp_avg, atol=0, rtol=0)
101+
torch.testing.assert_close(optimizer.state[param]["exp_avg_sq"], exp_avg_sq, atol=0, rtol=0)
102+
103+
@parameterized.parameters(
104+
{"shape": (3, 3)},
105+
{"shape": (15, 31)},
106+
{"shape": (127, 255)},
107+
)
108+
def test_no_grad_no_update_params_unchanged(self, shape) -> None:
109+
"""Parameters without gradients are not updated."""
110+
param = torch.nn.Parameter(torch.randn(*shape, device=self.device))
111+
original = param.detach().clone()
112+
optimizer = LaProp([param], lr=1e-4)
113+
optimizer.step()
114+
torch.testing.assert_close(param, original, atol=0, rtol=0)
115+
116+
@parameterized.parameters(
117+
{"shape": (3, 3)},
118+
{"shape": (15, 31)},
119+
{"shape": (127, 255)},
120+
)
121+
def test_frob_normalize_preserves_parameter_norm(self, shape) -> None:
122+
"""LaProp can normalize updated parameters back to their pre-update norm."""
123+
param = torch.nn.Parameter(torch.randint(1, 5, shape, device=self.device, dtype=torch.float32))
124+
optimizer = LaProp([param], lr=0.25, weight_decay=0.0, frob_normalize=True)
125+
param.grad = torch.randint(1, 5, shape, device=self.device, dtype=torch.float32)
126+
original_norm = param.norm()
127+
128+
optimizer.step()
129+
130+
torch.testing.assert_close(param.norm(), original_norm)
131+
132+
@parameterized.parameters(True, False)
133+
def test_init_group_skip_non_grad_params(self, skip_non_grad_params) -> None:
134+
"""Test _init_group with skip_non_grad_params flag."""
135+
param_with_grad = torch.nn.Parameter(torch.randn(5, 7, device=self.device))
136+
param_without_grad = torch.nn.Parameter(torch.randn(5, 7, device=self.device))
137+
param_with_grad.grad = torch.randn_like(param_with_grad)
138+
139+
opt = LaProp([param_with_grad, param_without_grad], lr=1e-4)
140+
opt._init_group(opt.param_groups[0], skip_non_grad_params=skip_non_grad_params)
141+
142+
self.assertIn("exp_avg", opt.state[param_with_grad])
143+
self.assertIn("exp_avg_sq", opt.state[param_with_grad])
144+
self.assertEqual(opt.state[param_with_grad]["step"], 0)
145+
self.assertEqual("exp_avg" in opt.state[param_without_grad], not skip_non_grad_params)
146+
147+
def test_negative_lr_raises_value_error(self) -> None:
148+
"""Test that LaProp raises ValueError for negative learning rate."""
149+
param = torch.nn.Parameter(torch.randn(3, 3, device=self.device))
150+
with self.assertRaisesRegex(ValueError, "Invalid learning rate"):
151+
LaProp([param], lr=-1.0)
152+
153+
def test_beta0_out_of_range_raises_value_error(self) -> None:
154+
"""Test that LaProp raises ValueError for invalid beta at index 0."""
155+
param = torch.nn.Parameter(torch.randn(3, 3, device=self.device))
156+
with self.assertRaisesRegex(ValueError, "Invalid beta at index 0"):
157+
LaProp([param], betas=(1.0, 0.999))
158+
159+
def test_beta1_out_of_range_raises_value_error(self) -> None:
160+
"""Test that LaProp raises ValueError for invalid beta at index 1."""
161+
param = torch.nn.Parameter(torch.randn(3, 3, device=self.device))
162+
with self.assertRaisesRegex(ValueError, "Invalid beta at index 1"):
163+
LaProp([param], betas=(0.9, 1.0))
164+
165+
def test_negative_eps_raises_value_error(self) -> None:
166+
"""Test that LaProp raises ValueError for negative eps."""
167+
param = torch.nn.Parameter(torch.randn(3, 3, device=self.device))
168+
with self.assertRaisesRegex(ValueError, "Invalid epsilon"):
169+
LaProp([param], eps=-1e-8)
170+
171+
def test_negative_weight_decay_raises_value_error(self) -> None:
172+
"""Test that LaProp raises ValueError for negative weight_decay."""
173+
param = torch.nn.Parameter(torch.randn(3, 3, device=self.device))
174+
with self.assertRaisesRegex(ValueError, "Invalid weight_decay"):
175+
LaProp([param], weight_decay=-0.1)
176+
177+
178+
if __name__ == "__main__":
179+
absltest.main()

tests/test_registry.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ def __init__(self, params, lr=0.01):
6161
("scion", scion.Scion),
6262
("soap", soap.SOAP),
6363
("lion", scalar_optimizers.Lion),
64+
("laprop", scalar_optimizers.LaProp),
6465
)
6566
def test_get_optimizer(self, opt_name, expected_opt_cls):
6667
opt_cls = registry.get_optimizer_cls(opt_name)

0 commit comments

Comments
 (0)