Skip to content

Take the remaining parameter walks from the packages that own them - #249

Merged
michakraus merged 3 commits into
mainfrom
walk-the-parameter-tree-with-the-package-that-owns-it
Aug 24, 2026
Merged

Take the remaining parameter walks from the packages that own them#249
michakraus merged 3 commits into
mainfrom
walk-the-parameter-tree-with-the-package-that-owns-it

Conversation

@michakraus

@michakraus michakraus commented Aug 24, 2026

Copy link
Copy Markdown
Member

Finishes what 0.6.0 started. That release handed the HDF5 traversal of a parameter set over to
NeuralNetworkParameters and GeometricOptimizers; this one does the same for the walks that
were left, and makes the optimizer cache immune to a change GeometricOptimizers is about to make.

Nothing here needs an unreleased dependency — it builds against NeuralNetworkParameters 0.1.1 and
GeometricOptimizers 0.4.3, both in General — so it can merge on its own. It should merge before
the GeometricOptimizers container change, for the reason in the last section.

apply_toNT was Base.map

apply_toNT(f, a, b)      map(f, a, b)

map over NamedTuples takes any number of arguments and already throws
ArgumentError: Named tuple names do not match. on mismatched or reordered keys — which is what the
hand-rolled @assert keys(ps[1]) == keys(p) was approximating, except that Base's check cannot be
compiled out the way an @assert can. Heterogeneous values map fine, so a StiefelManifold beside an
ordinary Matrix is no obstacle. Checked on Julia 1.10, the compat floor, as well as 1.13.

_norm, _diff, _add and add!'s container arm call map directly. That is the faithful
translation rather than a simplification: _diff and _add recurse through their own NamedTuple
methods, and _norm applies norm one level down and divides by √length, so reaching for a deeper
walk there would change behaviour.

GeometricOptimizers carries a character-identical copy of the same function, which this package
reached by qualified call. Dropping that call is what lets the copy go in its own release.

map_to_cpu is one walk instead of eight methods

NeuralNetworkParameters.mapstorage hands a function the storage of a leaf and rebuilds the leaf
around the result, so the six methods that existed to unwrap and reconstruct a StiefelManifold, a
SymmetricMatrix, a SkewSymMatrix and the two triangular types collapse into one delegation.
GeometricOptimizers supplies the protocol for its own types, so nothing here knows which structured
types exist and one added upstream is covered without a change on this side.

mapstorage and not mapparameters: the latter hands the function whole leaves, which would still
need a method per type to reach the storage. The NeuralNetwork method stays — the docs tutorials and
several scripts call it on a whole network.

It was untested, so the rewrite comes with test/map_to_cpu_tests.jl.

_eltype becomes parameter_eltype

The two really are different functions — _eltype returned the element type of the first leaf and
read a structured leaf's dense interface, where parameter_eltype promotes across every leaf and
descends through freeparameters — but at the four call sites this package had they cannot
disagree
, and an earlier draft of this description claimed a fix that cannot fire. Corrected.

_eltype was only ever asked for a T under _use_go_cache, which requires
x isa GeometricOptimizers.OptimizerSolution, and on the ps_leaf that reaches _leaf_optim_step!,
which is such an x. OptimizerSolution{T} is homogeneous in T by construction — its NamedTuple
arm is ArrayNamedTuple{T} = NamedTuple{S,<:Tuple{Vararg{AbstractArray{T}}}}. A layer mixing
Float32 and Float64 weights therefore fails that test and recurses to one cache per weight, so
a first-leaf answer and a promoted one were never different answers. What the substitution buys is
four fewer methods to own and the upstream spelling where the walk is upstream's.

Why this should merge first

_make_optimizer_cache and _make_optimizer_state asked the capability question
(x isa GeometricOptimizers.OptimizerSolution) before the structural one, so a NetworkParameters
reached the container branch only by virtue of not being in that union yet. The moment
GeometricOptimizers adopts the container, the root of a network would match instead: one cache for
the whole network rather than one per layer, _leaf_optim_step! handed the entire tree, and
_GMLGradient handed a NetworkParameters it has no method for. A MethodError on the first step of
every training run, from a change that reads as purely additive upstream.

The NetworkParameters branch now comes first. The NamedTuple branch deliberately stays after the
capability test, and that asymmetry is the fix rather than an oversight: a layer is a NamedTuple of
arrays, which is exactly what one GeometricOptimizers cache is for, so hoisting it too would descend
into the individual weights. Behaviour today is unchanged, which is what makes it safe to land
ahead of the upstream release rather than with it.

_tree_optim_step! had the same inversion one function further down, and it is fixed the same way:
λY isa NamedTuple ? λY[k] : λY asked for one container type where the parameters are asked for two.
Today only a NamedTuple arrives, because this package's own GlobalSection(::NetworkParameters)
unwraps the container before GeometricOptimizers sees it — and that method is the one GML gives back
next, its replacement upstream returning a container of sections. Then a single isa NamedTuple hands
every layer the whole section tree instead of its own section.

The reordering also had no test: structured_array_parameters.jl checks that training runs, which
it does either way. test/optimizers/utils/optimization_step.jl now pins the shape — a network's
cache and state are NamedTuples keyed by its layers, with one GeometricOptimizers cache and one
state per layer. Simulated against a widened OptimizerSolution, the old branch order turns out not
merely to give the root one cache: OptimizerCache(::Adam, ::NetworkParameters) is a MethodError
outright.

add! and _add, which the plan asked for and an earlier draft passed over

add!'s NamedTuple arm goes. Nothing in the package, the tests, the docs or the scripts called it,
and AbstractNeuralNetworks.add! — whose generic it was a method of — is about a destination and two
summands, which a parameter tree is not.

_add and add! leave the export list, so that line goes entirely. _add's siblings _norm and
_diff were never exported and are the two of the three anything in src/ calls; add! is reachable
from the package that owns the generic, this one only adding methods for the structured matrix types:

using AbstractNeuralNetworks: add!

And _add(::History, ::SingleHistory), which shared nothing with the parameter-tree _add but the
name, is _push_history! — which also says that it mutates its first argument, as the old name did
not.

Not done, deliberately

_tree_optim_step! stays hand-written, and now says why. It walks the cache tree, which stops at
the layer where a cache sits, whereas foreachparameters walks the leaf protocol and would descend
past the layer into individual weights, re-pairing every cache with the wrong object. And λY is
broadcast rather than zipped — a single GlobalSection may stand in for a whole subtree — which
foreachparameters cannot express, since it takes values of each trailing argument.

_GMLGradient(::NetworkParameters) and _gml_rgrad(::NetworkParameters, ::NetworkParameters), which
the plan listed alongside the reordering, are subsumed by it: with the structural question asked
first, a NetworkParameters always recurses and can no longer arrive at a leaf, so neither method has
a caller in any version of GeometricOptimizers. Adding them would be dead code.

The README example is the one thing outside src/ that used apply_toNT; it is map there now.

Verification

Full suite green locally (56 blocks, zero failures), which is also what the pre-push hook ran.
test/optimizers/utils/optimization_step.jl is the regression net for the cache reordering and pins
its shape; test/optimizers/structured_array_parameters.jl — four architectures × four methods —
covers the step itself. test/map_to_cpu_tests.jl is new. The three test/transformer_related/ files
that used apply_toNT pass unchanged in substance.

Base.map's key checking was verified directly rather than assumed: on 1.10 (the compat floor)
and 1.13, map(+, (a=1,b=2), (b=1,a=2)) and map(+, (a=1,b=2), (a=1,c=2)) both throw
ArgumentError("Named tuple names do not match."), and the three-argument form maps as expected.

The three failing nightly CI jobs are pre-existing: main fails the same way at
test/hamiltonian_neural_network_tests.jl:44, and the matrix marks nightly continue-on-error.

🤖 Generated with Claude Code

`map_to_cpu` loses six per-type methods to `NeuralNetworkParameters.mapstorage`, which reaches the
storage of a leaf and rebuilds the leaf around the result. `GeometricOptimizers` supplies that
protocol for its own structured types, so nothing here has to know which of them exist. It was
untested, so `test/map_to_cpu_tests.jl` comes with it.

`apply_toNT` turns out to be `Base.map`: same behaviour at any arity, and Base already rejects
mismatched or reordered keys with an `ArgumentError` where the hand-rolled `@assert` could be
compiled out. `_norm`, `_diff`, `_add` and `add!`'s container arm call `map` directly, which is the
faithful translation rather than a simplification -- two of them recurse through their own
`NamedTuple` methods and `_norm` divides by `√length` one level down. `GeometricOptimizers` carries a
character-identical copy, reached from here by qualified call; dropping that call is what lets it go
in its own release.

`_eltype` becomes `parameter_eltype`, which is not a rename: the old one returned the element type of
the *first* leaf and read a structured leaf's dense interface, so a layer mixing `Float32` and
`Float64` weights picked whichever came first and handed it to `Adam(T)`.

Finally, `_make_optimizer_cache` and `_make_optimizer_state` ask the structural question before the
capability one. They tested `x isa GeometricOptimizers.OptimizerSolution` first, so a
`NetworkParameters` reached the container branch only by virtue of not being in that union yet. Once
`GeometricOptimizers` adopts the container, the root of a network would have matched instead and been
given one cache rather than one per layer, with `_GMLGradient` handed a container it has no method
for. Behaviour today is unchanged, which is what makes it safe to land ahead of that release rather
than with it.

The `NamedTuple` branch stays after the capability test, and that asymmetry is deliberate: a layer is
a `NamedTuple` of arrays, which is exactly what one `GeometricOptimizers` cache is for.
Copilot AI lite review requested due to automatic review settings August 24, 2026 03:32

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@codecov

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.19048% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.02%. Comparing base (9e88b7c) to head (b14326f).

Files with missing lines Patch % Lines
src/utils.jl 0.00% 3 Missing ⚠️
src/nnsolution/history.jl 0.00% 1 Missing ⚠️
src/nnsolution/neural_net_solution.jl 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #249      +/-   ##
==========================================
+ Coverage   66.51%   67.02%   +0.51%     
==========================================
  Files          99       99              
  Lines        2888     2869      -19     
==========================================
+ Hits         1921     1923       +2     
+ Misses        967      946      -21     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

`README.md`'s headline example still called `GeometricMachineLearning.apply_toNT`, which this branch
deletes -- the one surviving reference to the name outside the changelog. It is `map` there too.

`_tree_optim_step!` had the same inversion this branch fixes in `_make_optimizer_cache`, one function
further down: `λY isa NamedTuple ? λY[k] : λY` asks for one container type where the parameters are
asked for two. Today only a `NamedTuple` arrives, because this package's own
`GlobalSection(::NetworkParameters)` unwraps the container before `GeometricOptimizers` sees it --
and that method is the one it gives back next, its replacement upstream returning a container. Then
a single `isa NamedTuple` hands every layer the whole section tree instead of its own section.

The cache reordering had no test: `structured_array_parameters.jl` checks that training runs, which
it does either way. `test/optimizers/utils/optimization_step.jl` now pins the *shape* -- a network's
cache and state are `NamedTuple`s keyed by its layers, one `GeometricOptimizers` cache and one state
per layer. Simulated against a widened `OptimizerSolution`, the old branch order does not merely
give the root one cache: `OptimizerCache(::Adam, ::NetworkParameters)` is a `MethodError` outright.

`_eltype` -> `parameter_eltype` was described as a fix, and it is not one. The two really do differ,
but not at the four call sites this package had: `_eltype` was only ever asked for a `T` under
`_use_go_cache`, and `OptimizerSolution{T}` is homogeneous in `T` by construction --
`ArrayNamedTuple{T} = NamedTuple{S,<:Tuple{Vararg{AbstractArray{T}}}}`. A layer mixing `Float32` and
`Float64` weights fails that test and recurses to one cache per weight, so a first-leaf answer and a
promoted one were never different answers. What the change buys is four fewer methods to own.

Finally the two items the plan asked for and this branch had passed over. `add!`'s `NamedTuple` arm
goes: nothing called it, and `AbstractNeuralNetworks.add!` is about a destination and two summands,
which a parameter tree is not. `_add` and `add!` leave the export list -- `_add`'s siblings `_norm`
and `_diff` were never on it and are the two of the three `src/` uses, and `add!` is reachable from
the package that owns the generic. And `_add(::History, ::SingleHistory)`, which shared nothing but
the name, is `_push_history!`, which also says that it mutates.

@michakraus michakraus left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Reviewed the whole diff against the local environment (NNP 0.1.1, GO 0.4.2, ANN 0.7.0) and on Julia
1.10 and 1.13. The substance is right, and the two central claims hold up under direct checking
rather than assumption:

  • Base.map over NamedTuples does throw ArgumentError("Named tuple names do not match.") on
    mismatched, reordered and differently-sized keys, on 1.10 (the compat floor) as well as 1.13, and
    the three-argument form maps as expected. apply_toNT really was Base.map.
  • mapstorage reproduces the five structured map_to_cpu methods exactly. GO's
    ext/NeuralNetworkParametersExt.jl supplies freeparameters/rebuild for all of them, and that
    extension landed in GO 0.4.1 — which is this package's compat floor, so the floor holds without
    a bump. NeuralNetworkParameters = "0.1" likewise covers mapstorage and parameter_eltype; both
    are in 0.1.0.
  • The branch reordering is a no-op today, as claimed: ArrayNamedTuple{T} is homogeneous in T, so a
    NetworkParameters never satisfied _use_go_cache in the first place.

The three failing nightly jobs are pre-existing — main fails the same way at
test/hamiltonian_neural_network_tests.jl:44, and the matrix marks nightly continue-on-error.

Six things came out of the review. All are fixed in b14326f, and the PR body is updated.

1. README.md:33 called GeometricMachineLearning.apply_toNT

The headline SympNet-on-GPU example, and the one surviving reference to the name outside the
changelog. Broken as of 0.7.0. It is map there now, with no package qualification needed.

2. _tree_optim_step! had the same inversion this PR fixes, one function further down

λY_k = λY isa NamedTuple ? λY[k] : λY

One container type where the parameters are asked for two. Today only a NamedTuple arrives, and the
reason it does is GeometricOptimizers.GlobalSection(ps::NetworkParameters) at the top of the same
file, which unwraps the container before GO sees it. That method is the one PR C hands back, and its
replacement upstream is expected to return a container of sections (plan A6: GlobalSectionNamedTuple
gets a sibling) — at which point isa NamedTuple is false and every layer is handed the whole section
tree instead of its own section. Same failure mode as the cache, same one-line prophylactic, and it
belongs in the PR whose stated purpose is forward-compatibility.

3. The headline fix had no test

structured_array_parameters.jl checks that training runs and the losses are finite, which is true
under either branch order. The cache shape is assertable today, so
test/optimizers/utils/optimization_step.jl now pins it: a network's cache and state are
NamedTuples keyed exactly by its layers, with one GeometricOptimizers cache and one state per
layer — not one for the root, and not one per weight.

I checked the counterfactual by simulating a widened OptimizerSolution (_use_go_cache extended to
accept NetworkParameters) against both branch orders. It is worse than the description says: under
the old order the root does not merely get one cache, it does not get one at all —
OptimizerCache(::Adam{Float32}, ::NetworkParameters{...}) is a MethodError outright. Under the new
order the same simulation still gives (:L1, :L2) with an AdamCache at each.

4. The _eltypeparameter_eltype story was wrong

The CHANGELOG, the PR body and the commit message all said a layer mixing Float32 and Float64
weights "used to pick whichever came first in the NamedTuple and hand that to Adam(T)". That
cannot happen.
_eltype was only ever asked for a T in two places — under _use_go_cache, which
requires x isa GeometricOptimizers.OptimizerSolution, and on the ps_leaf reaching
_leaf_optim_step!/_go_update_leaf!, which is such an x. And OptimizerSolution{T} is homogeneous
in T by construction: its NamedTuple arm is
ArrayNamedTuple{T} = NamedTuple{S,<:Tuple{Vararg{AbstractArray{T}}}}. A mixed layer fails that
test and recurses to one cache per weight (each a GMLEuclideanState, since OptimizerSolution has no
AbstractMatrix arm), so _eltype never saw a mixed set.

The substitution is behaviour-preserving, and the better claim is the true one: parameter_eltype is
the upstream spelling, is strictly more careful, and agrees with _eltype at every call site this
package has because GO's homogeneity gate guarantees it. What the change buys is four fewer methods
to own. Rewritten in the CHANGELOG and the body; the paragraph in d4e85f3's message stays as it is,
since the branch is not being rewritten.

5. Two plan items were passed over without the PR saying so

Plan B4 asked for both, and both are done now:

  • add!(::NamedTuple, ::NamedTuple, ::NamedTuple) is deleted. Nothing called it, and
    AbstractNeuralNetworks.add! — whose generic it was a method of — is about a destination and two
    summands, which a parameter tree is not.
  • _add and add! leave the export list, so that line goes entirely. _add's siblings _norm and
    _diff were never exported and are the two of the three that src/reduced_system/ actually calls;
    add! is reachable from the package that owns the generic. Migration is one line
    (using AbstractNeuralNetworks: add!) and is in the CHANGELOG.
  • _add(::History, ::SingleHistory), which shared nothing with the parameter-tree _add but the name,
    is _push_history! — which also records that it mutates its first argument, as the old name did not.

The third plan item, _GMLGradient(::NetworkParameters) and
_gml_rgrad(::NetworkParameters, ::NetworkParameters), is genuinely subsumed by the reordering: a
NetworkParameters can no longer reach a leaf under any version of GO, so both would be dead code.
Worth stating rather than leaving as a silent omission, which the body now does.

6. Counts and a stale note

"the six methods that existed to unwrap and reconstruct a StiefelManifold, a SymmetricMatrix, a
SkewSymMatrix and the two triangular types" names five; the sixth counted was the plain-array method,
which reconstructs nothing and survives as _to_host. Fixed in the CHANGELOG and in
src/map_to_cpu.jl's comment, and the arithmetic of the eight now closes. And the reconstruction note
at the top of the CHANGELOG still said "the 0.5.0 and 0.6.0 sections below, which were written
alongside the work" — 0.7.0 is one of them now.

No other findings. map is not shadowed anywhere in the package; the three
test/transformer_related/ conversions are all single-argument recursive helpers, where map and
apply_toNT agree exactly (zip of one NamedTuple yields 1-tuples, so fun(p...) was fun(value));
test/map_to_cpu_tests.jl passes as written, and parameter_eltype handles every leaf shape the
package can produce, Tuple included.

@michakraus
michakraus merged commit 45f228e into main Aug 24, 2026
12 of 15 checks passed
@michakraus
michakraus deleted the walk-the-parameter-tree-with-the-package-that-owns-it branch August 24, 2026 13:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

2 participants