Skip to content

Stop Optim.step(scale_by) mutating the caller's gradients - #92

Merged
cemde merged 1 commit into
liukidar:mainfrom
cemde:fix/67-optim-scale-by
Aug 9, 2026
Merged

Stop Optim.step(scale_by) mutating the caller's gradients#92
cemde merged 1 commit into
liukidar:mainfrom
cemde:fix/67-optim-scale-by

Conversation

@cemde

@cemde cemde commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Bug

Optim.step(scale_by=k) scaled each gradient with set(g, g * scale_by). set writes the new value into the Param object it is given, and that object is the caller's, not a copy. So the call mutated the gradient tree the caller still holds.

Impact

Stepping the same gradient tree twice compounds the scaling. With lr=1.0, scale_by=0.5, g=1.0, w0=1.0, two steps must give 1 - 2*(1.0*0.5*1.0) = 0.0; PCX gives 0.25, because the gradient is 0.5 on the second step and 0.25 on the third.

Every tutorial hits this: they compute one gradient tree and feed it to both optim_h and optim_w with scale_by=1.0/batch_size. The effective learning rate shrinks geometrically with nothing to show for it but slow convergence.

The same mechanism also compounds within a single step on a pxnn.shared model, since the outer tree_map visits an aliased Param once per reference.

Fix

return jtu.tree_map(lambda _g: _g * scale_by, g)

tree_map descends into the Param (it is a registered pytree), scales the value, and rebuilds a new Param via the registered unflatten. The caller's object is untouched.

Why tree_map and not type(g)(g * scale_by)

The registered unflatten copies __dict__ minus _value, so the subclass and all its attributes survive. Constructing through __init__ does not: a rebuilt VodeParam would lose frozen, the attribute the tutorials set on the output node and that M_hasnot(VodeParam, frozen=True) reads. A mask over the scaled tree would then select the wrong parameters.

OptimTree.step delegates here, so it inherits the fix.

Closes #67

@liukidar liukidar left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

LGTM. Big fix :)

'set(g, g * scale_by)' wrote the scaled value back into the caller's
gradient Params, so reusing a gradient tree compounded the scaling.
Return a scaled copy instead.
@cemde
cemde force-pushed the fix/67-optim-scale-by branch from 51f8d8f to c1ce300 Compare August 9, 2026 18:23
@cemde
cemde merged commit 6968fb9 into liukidar:main Aug 9, 2026
31 of 33 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.

Optim.step(scale_by=k) mutates the caller's gradients

2 participants