LAprop as an optimizer along with frobenius weight normalization + associated tests#206
Conversation
|
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 SummaryThis PR introduces
Confidence Score: 3/5The 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
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
Reviews (7): Last reviewed commit: "removed enable grad from closure path" | Re-trigger Greptile |
| """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) |
There was a problem hiding this comment.
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.
| 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) |
| {"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) |
There was a problem hiding this comment.
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.
| weight_decay: float = 0.0, | ||
| correct_bias: bool = True, | ||
| normalize: bool = False, | ||
| normalize_eps: float = 1e-8, |
There was a problem hiding this comment.
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(): |
There was a problem hiding this comment.
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.
| ) | ||
| pre_norm = p.data.norm() if self.normalize else None | ||
| p.data.add_(update, alpha=-lr) | ||
| if pre_norm is not None: |
There was a problem hiding this comment.
Use self.normalize as condition.
| eps: float = 1e-8, | ||
| weight_decay: float = 0.0, | ||
| correct_bias: bool = True, | ||
| normalize: bool = False, |
There was a problem hiding this comment.
Nit: Consider a more informative name to indicate what normalization the flag enables.
| 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) |
| optimizer.step() | ||
|
|
||
| torch.testing.assert_close(param, old_param - lr * expected_update) | ||
| torch.testing.assert_close(optimizer.state[param]["exp_avg"], exp_avg) |
There was a problem hiding this comment.
Test name suggest "match", both atol and rtol needs to be 0.
| if pre_norm is not None: | ||
| p.data.mul_(pre_norm / p.data.norm().clamp_min(self.normalize_eps)) |
There was a problem hiding this comment.
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.
| 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)) |
skyw
left a comment
There was a problem hiding this comment.
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.
|
/ok to test 9f2c49e |
5853790 to
9ab27ce
Compare
|
/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>
9ab27ce to
d6c0cee
Compare
|
/ok to test d6c0cee |
Convert the LAProp update function into a scalar optimizer