The leaf protocol
Not every parameter is a plain array. GeometricOptimizers has a family of matrices that keep $n(n\pm1)/2$ numbers behind an $n \times n$ interface, manifold elements, and horizontal lifts that keep their freedom in two blocks. Those stored numbers are the parameters: they are what belongs in the flat vector, and what an optimizer should move.
Two methods say so.
NeuralNetworkParameters.freeparameters — Function
freeparameters(x)The differentiable storage of a leaf x — the numbers that a flat parameter vector should contain, and the coordinates an optimizer should work in.
For an ordinary array this is the array itself. For a structured type it is whatever the type keeps its degrees of freedom in: an $n \times n$ symmetric matrix stores $n(n+1)/2$ numbers, and it is those that belong in the flat vector — not the $n^2$ entries of the dense interface, which do not even have the right length.
The return value may be
- the leaf itself, which marks it as terminal — the recursion stops and the numbers are copied straight out of it;
- another array or number;
- a
TupleorNamedTupleof either, for a type whose freedom lives in several blocks.
Together with rebuild this is the whole extension protocol: define the two for a type and it can be flattened, walked and written to HDF5, with nothing in this package knowing about it.
Extending
NeuralNetworkParameters.freeparameters(A::SymmetricMatrix) = A.S
NeuralNetworkParameters.rebuild(A::SymmetricMatrix, data) = SymmetricMatrix(data, A.n)Those two are the whole protocol for a leaf that is an AbstractArray subtype, which every structured parameter type in the ecosystem is. A leaf that keeps its numbers behind some other interface needs one method more, because parameter_eltype must not raise and so cannot ask an arbitrary type where its storage is:
NeuralNetworkParameters.parameter_eltype(x::MyLeaf) = parameter_eltype(freeparameters(x))Without it the leaf contributes nothing to the promoted element type, and flatten says so rather than guessing one.
GeometricOptimizers already exposes exactly this relation as Base.parent for its manifolds, VectorStorageMatrixes and horizontal lifts, so one delegating method covers all of them:
NeuralNetworkParameters.freeparameters(x::Union{Manifold, VectorStorageMatrix, AbstractLieAlgHorMatrix}) =
parent(x)Base.parent is deliberately not used as the protocol here: parent(::SubArray) is the whole underlying buffer rather than the view's own entries, so Base's relation and this one are not the same.
Examples
using NeuralNetworkParameters: freeparameters
A = [1.0 2.0; 3.0 4.0]
freeparameters(A) === A
# output
trueNeuralNetworkParameters.rebuild — Function
rebuild(prototype, data)Rebuild a leaf shaped like prototype from the storage data. The inverse of freeparameters, and for an ordinary array simply data.
prototype is the leaf the storage came from, which is what carries the non-differentiable information along: the n of a SymmetricMatrix, the N and n of a horizontal lift. Taking them from a prototype rather than from the type is what lets data have a different element type from prototype — ForwardDiff.Duals, when a flattened parameter set is differentiated through — and it is also why the concrete type comes back unchanged, where reconstructing from a type name can quietly return a sibling type instead.
See freeparameters for how to extend this.
With them defined, the type flattens, walks and saves — nothing in this package holds a list of which structured types exist. That matters because the types live upstream of the package that trains with them: GeometricOptimizers owns them, GeometricMachineLearning uses them, and a serialiser driven by a list rather than a protocol would have to be written by somebody who owns neither the types nor the generic it dispatches on.
An example
using NeuralNetworkParameters
import NeuralNetworkParameters as NNP
struct Sym{T} <: AbstractMatrix{T}
S::Vector{T} # n(n+1)/2 numbers
n::Int
end
Base.size(A::Sym) = (A.n, A.n)
Base.getindex(A::Sym, i::Int, j::Int) = A.S[(max(i,j)*(max(i,j)-1))÷2 + min(i,j)]
NNP.freeparameters(A::Sym) = A.S
NNP.rebuild(A::Sym, data) = Sym(data, A.n)
ps = NetworkParameters((L1 = (S = Sym([1.0, 2.0, 3.0], 2),),))
v, layout = flatten(ps)
v
# output
3-element Vector{Float64}:
1.0
2.0
3.0Three numbers, not four: the flat vector holds the free parameters, and the round trip returns the same type, with its n intact.
back = unflatten(layout, [10.0, 20.0, 30.0])
(typeof(back.L1.S) === typeof(ps.L1.S), back.L1.S.n)
# output
(true, 2)Why rebuild takes a prototype
rebuild is handed the leaf the storage came from, not just its type. That carries the non-differentiable information across — the n above — and it is what lets data have a different element type from the prototype, as it does under forward-mode differentiation.
It also keeps the concrete type honest. Reconstructing from a type name instead has a failure mode that has actually been hit upstream: a hardcoded reconstructor turned every manifold element into a StiefelManifold, quietly converting a GrassmannManifold on each round trip.
Adopting it for a whole family at once
GeometricOptimizers already exposes this relation, as Base.parent: parent of its VectorStorageMatrixes is the vector they store, of a Manifold its matrix, of a horizontal lift the tuple of its blocks. So one method covers the family:
NeuralNetworkParameters.freeparameters(
x::Union{Manifold, VectorStorageMatrix, AbstractLieAlgHorMatrix}) = parent(x)Base.parent is deliberately not used as the protocol here. parent of a SubArray is the whole underlying buffer rather than the view's own entries, so Base's relation and this one do not agree in general, and flattening on parent would silently take in far too much.
Multi-block storage
freeparameters may return a Tuple or NamedTuple, for a type whose freedom lives in several places; the recursion simply continues into it, and the blocks may be structured themselves.
NNP.freeparameters(g::StiefelLieAlgHorMatrix) = (A = g.A, B = g.B)
NNP.rebuild(g::StiefelLieAlgHorMatrix, data) = StiefelLieAlgHorMatrix(data.A, data.B, g.N, g.n)Element type
NeuralNetworkParameters.parameter_eltype — Function
parameter_eltype(ps)The element type to flatten ps into: promote_type over the element types of its leaves.
Float32 parameters therefore flatten to a Vector{Float32}. Defaulting to Float64 instead — as ParameterHandling.flatten does — silently doubles the width of every single-precision network that passes through, and every quantity computed from the flat vector downstream with it.
Examples
using NeuralNetworkParameters: parameter_eltype
parameter_eltype((a = Float32[1, 2], b = Float32[3;;]))
# output
Float32A mixed parameter set promotes:
using NeuralNetworkParameters: parameter_eltype
parameter_eltype((a = Float32[1, 2], b = [3.0]))
# output
Float64Implementation
This function is total, where freeparameters is not: every NetworkParameters runs its constructor through here, including sets that hold no numbers at all — a gradient tree with nothing where an untouched layer's entries would be, or SymbolicNeuralNetworks wrapping generated functions in one. A leaf this package cannot read numbers out of contributes nothing to the promotion, exactly as an empty set does, and both report Union{}. A set that cannot be flattened is still a set with an element type; the protocol error comes from parameterlayout, which decides something with it.
The recursion follows freeparameters for an AbstractArray leaf — every structured parameter type in the ecosystem — and a leaf that keeps its numbers behind another interface opts in with one method more, described under freeparameters. Asking an arbitrary type where its storage is would mean raising, which this function must not do.
It follows it at the level of values, freeparameters(x) === x, and that is a guarantee rather than an implementation detail. A structured leaf reports the element type of its storage, which need not be the eltype of the interface it presents: a leaf that is an AbstractMatrix{Float64} over a Vector{Float32} flattens into a Vector{Float32}, because that is what its numbers are. Deciding the promotion from the leaf's type instead would read the interface and be wrong about such a leaf, which is why it is not decided there — and there is no cost to buy with it.
For a NetworkParameters the answer is already on the type, put there by its constructor, so nothing is recomputed and the call folds to a constant. A bare NamedTuple or Tuple is read in place at literal indices, one @generated body per branch shape, for the reason the head of walk.jl gives — so it too costs nothing at any width, depth or shape, and flatten(ps) costs exactly what flatten(T, ps) costs.
flatten uses this, so a Float32 network flattens to a Vector{Float32}. Defaulting to Float64 instead would silently double the width of every single-precision network passing through, and of everything computed from the flat vector afterwards. It is also what a NetworkParameters derives its element-type parameter from, at construction.
Because every parameter set runs its constructor through this function, it is total where freeparameters is not: a leaf this package cannot read numbers out of contributes nothing to the promotion rather than raising, and so does a gap in a gradient tree. The protocol error comes from parameterlayout instead, which is where it decides something — a set that cannot be flattened is still a set with an element type.
That is also why the recursion follows freeparameters for an AbstractArray leaf only: asking an arbitrary type where its storage is would mean raising. So a leaf that keeps its numbers behind an interface that is not an array's has nothing to contribute, even with the two protocol methods defined:
struct Blocks # not an `AbstractArray`, so nothing about it says where the numbers are
data::Vector{Float64}
end
NNP.freeparameters(b::Blocks) = b.data
NNP.rebuild(::Blocks, data) = Blocks(data)
parameter_eltype(NetworkParameters((L1 = (B = Blocks([1.0, 2.0]),),)))
# output
Union{}flatten raises there rather than guessing an element type for numbers the promotion knows nothing about. One method more opts the leaf into the recursion, and the set flattens as itself again:
NNP.parameter_eltype(b::Blocks) = parameter_eltype(freeparameters(b))
ps = NetworkParameters((L1 = (B = Blocks([1.0, 2.0]),),))
(parameter_eltype(ps), first(flatten(ps)))
# output
(Float64, [1.0, 2.0])An AbstractArray subtype — every structured leaf in the ecosystem — needs none of this.