Skip to content

LAprop as an optimizer along with frobenius weight normalization + associated tests#206

Merged
skyw merged 8 commits into
NVIDIA-NeMo:mainfrom
mkhona-nvidia:mkhona/laprop
May 22, 2026
Merged

LAprop as an optimizer along with frobenius weight normalization + associated tests#206
skyw merged 8 commits into
NVIDIA-NeMo:mainfrom
mkhona-nvidia:mkhona/laprop

Conversation

@mkhona-nvidia

Copy link
Copy Markdown
Contributor

Convert the LAProp update function into a scalar optimizer

@copy-pr-bot

copy-pr-bot Bot commented May 22, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@skyw

skyw commented May 22, 2026

Copy link
Copy Markdown
Contributor

Shall we seek more programmable way to make optimizer out of scalar update function? Instead of adding them one by one (with a lot of duplicated code) when we need it?

@greptile-apps

greptile-apps Bot commented May 22, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces LaProp as a new scalar optimizer class, wrapping the pre-existing calculate_laprop_update update function, and adds an optional Frobenius weight normalization feature alongside a full test suite.

  • New optimizer class (laprop.py): follows the same WeightDecayMixin + torch.optim.Optimizer pattern as Lion, with per-step lazy state initialization, bias-corrected first/second moment EMAs, and an optional frob_normalize flag that rescales each parameter's Frobenius norm back to its pre-update value.
  • Tests (test_laprop.py): cover smoke execution, state initialization, delegate-to-update-function correctness, norm preservation, and all constructor validation errors; the PR also registers laprop in test_registry.py.

Confidence Score: 3/5

The optimizer has several unresolved correctness issues raised in the current PR thread that need to be addressed before merging.

The closure is invoked under @torch.no_grad() without torch.enable_grad(), which silently breaks any training loop that passes a closure. The frob_normalize feature silently renders zero-initialized parameters permanently untrainable, and its interaction with various weight_decay_method settings produces undocumented, inconsistent regularization behavior. These issues are actively discussed in the thread but remain unresolved in the current head commit.

emerging_optimizers/scalar_optimizers/laprop.py — the closure call site, the frob_normalize rescaling path, and the weight-decay/frob-normalize interaction all need attention before this is ready to merge.

Important Files Changed

Filename Overview
emerging_optimizers/scalar_optimizers/laprop.py New LaProp optimizer with Frobenius weight normalization; has a known closure/enable_grad bug and frob_normalize edge cases being discussed in the PR thread.
tests/test_laprop.py Test suite for LaProp; covers smoke, state init, update correctness, norm preservation, and validation errors — gradient coverage gaps and formula fragility are being discussed in the PR thread.
emerging_optimizers/scalar_optimizers/init.py Adds LaProp import in alphabetical order before Lion; no issues.
tests/test_registry.py Adds laprop registry entry to the parameterized get_optimizer test; straightforward and correct.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[LaProp.step called] --> B{closure?}
    B -- yes --> C[loss = closure
No enable_grad!]
    B -- no --> D[loss = None]
    C --> E[for each param group]
    D --> E
    E --> F[_init_group: lazy init
exp_avg, exp_avg_sq, step=0]
    F --> G[for each param p
with gradient]
    G --> H[step += 1]
    H --> I{frob_normalize?}
    I -- yes --> J[pre_norm = p.data.norm
before weight decay]
    I -- no --> K[pre_norm = None]
    J --> L[_apply_weight_decay_inplace
modifies p.data or grad]
    K --> L
    L --> M[calculate_laprop_update
updates exp_avg_sq, exp_avg in-place
returns bias-corrected EMA]
    M --> N[p.data += -lr * update]
    N --> O{frob_normalize?}
    O -- yes --> P[p.data *= pre_norm /
max p.data.norm, eps]
    O -- no --> Q[next param]
    P --> Q
    Q --> G
Loading

Reviews (7): Last reviewed commit: "removed enable grad from closure path" | Re-trigger Greptile

Comment thread tests/test_laprop.py Outdated
"""LaProp initializes first moment, second moment, and step state."""
beta1, beta2 = 0.5, 0.75
param = torch.nn.Parameter(torch.randint(1, 5, shape, device=self.device, dtype=torch.float32))
optimizer = LaProp([param], lr=0.25, betas=(beta1, beta2), weight_decay=0.0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The normalized_grad formula in the expected-value calculation is only algebraically equivalent to the actual implementation for step 1 with correct_bias=True. At that step, bias_correction2 equals (1 - beta2), which exactly cancels the lerp coefficient, reducing the denominator to |grad| + eps. Passing correct_bias=False (or any step > 1) would produce a different denominator in calculate_laprop_update, but the hardcoded formula here would not change — so the assertion would silently fail for incorrect reasons. Adding an explicit correct_bias=True makes the assumption visible and protects against future regressions.

Suggested change
optimizer = LaProp([param], lr=0.25, betas=(beta1, beta2), weight_decay=0.0)
optimizer = LaProp([param], lr=0.25, betas=(beta1, beta2), weight_decay=0.0, correct_bias=True)

Comment thread tests/test_laprop.py Outdated
Comment on lines +78 to +88
{"shape": (3, 3)},
{"shape": (15, 31)},
{"shape": (127, 255)},
)
def test_optimizer_step_matches_update_function(self, shape) -> None:
"""LaProp optimizer delegates update math to calculate_laprop_update."""
lr = 0.25
betas = (0.5, 0.75)
eps = 1e-8
param = torch.nn.Parameter(torch.randint(-5, 5, shape, device=self.device, dtype=torch.float32))
grad = torch.randint(1, 5, shape, device=self.device, dtype=torch.float32)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Test only exercises positive gradients

torch.randint(1, 5, ...) produces values in [1, 4], so grad.abs() == grad for every element. The normalization formula grad / (grad.abs() + eps) is therefore never tested with negative values, where the sign of the normalized gradient is the critical part of LaProp's behaviour. A torch.randn-generated gradient (or explicitly mixed-sign integers) would provide better coverage.

@mkhona-nvidia mkhona-nvidia changed the title add laprop as an optimizer along with tests Laprop as an optimizer along with frobenius normalization + tests May 22, 2026
@mkhona-nvidia mkhona-nvidia self-assigned this May 22, 2026
@mkhona-nvidia mkhona-nvidia requested a review from skyw May 22, 2026 16:36
Comment thread emerging_optimizers/scalar_optimizers/laprop.py
weight_decay: float = 0.0,
correct_bias: bool = True,
normalize: bool = False,
normalize_eps: float = 1e-8,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Do we need two eps? They are for different things, but I feel having two of them only increase probability to mess up one of them.

"""
loss = None
if closure is not None:
with torch.enable_grad():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know greptile like to signal this, but all of our optimizer are not supposed to have gradient in it. can just assert closure is None and ignore greptile.

Comment thread emerging_optimizers/scalar_optimizers/laprop.py
)
pre_norm = p.data.norm() if self.normalize else None
p.data.add_(update, alpha=-lr)
if pre_norm is not None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use self.normalize as condition.

eps: float = 1e-8,
weight_decay: float = 0.0,
correct_bias: bool = True,
normalize: bool = False,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Consider a more informative name to indicate what normalization the flag enables.

Comment thread tests/test_laprop.py Outdated
expected_exp_avg_sq = (1 - beta2) * grad.square()
normalized_grad = grad / (grad.abs() + optimizer.param_groups[0]["eps"])
expected_exp_avg = (1 - beta1) * normalized_grad
torch.testing.assert_close(optimizer.state[param]["exp_avg_sq"], expected_exp_avg_sq)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: Should they exact match?

Comment thread tests/test_laprop.py Outdated
optimizer.step()

torch.testing.assert_close(param, old_param - lr * expected_update)
torch.testing.assert_close(optimizer.state[param]["exp_avg"], exp_avg)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test name suggest "match", both atol and rtol needs to be 0.

Comment on lines +187 to +188
if pre_norm is not None:
p.data.mul_(pre_norm / p.data.norm().clamp_min(self.normalize_eps))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 When normalize=True and a parameter has been zero-initialized, pre_norm is 0.0. After the LaProp gradient update produces non-zero values, the line below multiplies the whole tensor by 0 / clamp_min(positive, eps) = 0, silently zeroing the parameter back out on every step. The parameter can never leave zero, so the normalization feature renders zero-initialized parameters completely untrainable without any warning.

Suggested change
if pre_norm is not None:
p.data.mul_(pre_norm / p.data.norm().clamp_min(self.normalize_eps))
if pre_norm is not None and pre_norm >= self.normalize_eps:
p.data.mul_(pre_norm / p.data.norm().clamp_min(self.normalize_eps))

Comment thread emerging_optimizers/scalar_optimizers/laprop.py
skyw
skyw previously approved these changes May 22, 2026

@skyw skyw left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should do a full repo clean up for the closure. We don't support it in any of our optimizer, i.e. none was tested, should just error out if closure is not none.

@mkhona-nvidia

Copy link
Copy Markdown
Contributor Author

/ok to test 9f2c49e

@mkhona-nvidia mkhona-nvidia changed the title Laprop as an optimizer along with frobenius normalization + tests LAprop as an optimizer along with frobenius weight normalization + associated tests May 22, 2026
@mkhona-nvidia

Copy link
Copy Markdown
Contributor Author

/ok to test 9ab27ce

Signed-off-by: mikail <mkhona@nvidia.com>
Signed-off-by: mikail <mkhona@nvidia.com>
Signed-off-by: mikail <mkhona@nvidia.com>
Signed-off-by: mikail <mkhona@nvidia.com>
Signed-off-by: mikail <mkhona@nvidia.com>
Signed-off-by: mikail <mkhona@nvidia.com>
Signed-off-by: mikail <mkhona@nvidia.com>
Signed-off-by: mikail <mkhona@nvidia.com>
@mkhona-nvidia

Copy link
Copy Markdown
Contributor Author

/ok to test d6c0cee

@skyw skyw enabled auto-merge (squash) May 22, 2026 18:11
@skyw skyw merged commit 36ed34d into NVIDIA-NeMo:main May 22, 2026
24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants