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.freeparametersFunction
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 Tuple or NamedTuple of 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

true
source
NeuralNetworkParameters.rebuildFunction
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 prototypeForwardDiff.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.

source

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.0

Three 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_eltypeFunction
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

Float32

A mixed parameter set promotes:

using NeuralNetworkParameters: parameter_eltype

parameter_eltype((a = Float32[1, 2], b = [3.0]))

# output

Float64

Implementation

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.

source

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.