Converting between them

The layout

NeuralNetworkParameters.ParameterLayoutType
ParameterLayout

Where each leaf of a parameter set lands in its flat vector, and what has to be done to put it back.

A layout is an ordinary value, built once by parameterlayout and then reusable: it can be stored in an optimizer cache, handed to a solver, or compared with ==. The alternative — returning a closure that undoes the flattening, as ParameterHandling.flatten does — is none of those things, and a chain of closures is not type stable either.

The five concrete layouts mirror the five things a parameter set is made of: ParametersLayout for a NetworkParameters, NestedLayout for a NamedTuple, TupleLayout for a Tuple, WrappedLayout for a leaf with structured storage, and LeafLayout for one whose numbers are copied directly.

source
NeuralNetworkParameters.parameterlayoutFunction
parameterlayout(ps)

Build the ParameterLayout of ps in one walk.

The leaves are laid out in the order they are encountered, depth first, so the flat vector reads like the parameter set does.

Examples

using NeuralNetworkParameters

ps = NetworkParameters((L1 = (W = [1.0 2.0], b = [3.0]), L2 = (W = [4.0;;],)))
layout = parameterlayout(ps)
(length(layout), parameterrange(layout.inner.children.L1.children.b))

# output

(4, 3:3)
source
NeuralNetworkParameters.flatlengthFunction
flatlength(ps)

The number of entries ps flattens to, without allocating the flat vector.

Deliberately not called parameterlength: AbstractNeuralNetworks has a function of that name for the parameter count of a model, and the two would collide on using.

This returns an Int, so nothing downstream of the call depends on the layout's type. Anything that wants only the size should call it; anything that has to unflatten later needs the layout and cannot.

That used to be a large difference in compile time as well — on Julia 1.11, 1.26 s here against 13.20 s through parameterlayout on a 369-leaf NetworkParameters. It is not one any more: 0.2.3 took that set to 1.05 s through parameterlayout against 1.03 s here. The reason to prefer this is the type it hands back, not the clock.

source

A layout is an ordinary value: build it once, keep it, compare it. That is the difference from returning a closure that undoes the flattening — a closure cannot be stored in an optimizer cache, compared for equality, or inferred through.

The conversions

NeuralNetworkParameters.flattenFunction
flatten(ps)
flatten(T, ps)

Copy every number of ps into one flat Vector, and return it together with the ParameterLayout needed to put it back.

Without T the element type is parameter_eltype(ps), i.e. the parameters' own — a Float32 network flattens to a Vector{Float32}.

Examples

using NeuralNetworkParameters

ps = NetworkParameters((L1 = (W = [1.0 2.0], b = [3.0]),))
v, layout = flatten(ps)
v

# output

3-element Vector{Float64}:
 1.0
 2.0
 3.0

unflatten is its inverse:

using NeuralNetworkParameters

ps = NetworkParameters((L1 = (W = [1.0 2.0], b = [3.0]),))
v, layout = flatten(ps)
unflatten(layout, v) == ps

# output

true

Implementation

The copy is a copyto! per leaf over a known range, so it runs at memory bandwidth and works unchanged for GPU arrays — no element is ever indexed individually.

Copying rather than viewing is deliberate. A flat vector of views could not carry a different element type from the parameters, and that is exactly what differentiating through the flat form needs: the unflatten on the forward pass of ForwardDiff has to produce Dual-valued parameters over a Dual-valued vector. See flatten! for the allocation-free form used in inner loops.

source
NeuralNetworkParameters.flatten!Function
flatten!(v, ps, [layout])

Write the numbers of ps into the existing vector v, and return v.

Allocation-free when the layout is supplied, which is the point: an optimizer that flattens its parameters once per step, or twice per inner product, should not allocate a fresh vector each time.

v, layout = flatten(ps)          # once
flatten!(v, ps, layout)          # per iteration, zero allocations
source
NeuralNetworkParameters.unflattenFunction
unflatten(layout, J::AbstractMatrix)

Split the rows of J into the shape of the parameter set — for a Jacobian taken with respect to the flat vector, so that the block belonging to each parameter can be read off.

Each leaf becomes the $n_\mathrm{leaf} \times \mathrm{size}(J, 2)$ row block it occupies. The leaves are not rebuilt: a block of a Jacobian is not a parameter, so there is nothing to rebuild it into.

source
unflatten(fp::FlatParameters)

The structured form of fp, using the layout it carries.

source
NeuralNetworkParameters.unflatten!Function
unflatten!(ps, layout, v)

Write the numbers of v into the leaves of the existing ps, and return ps.

Allocation-free on the same terms as flatten!, whose counterpart it is. The write goes through freeparameters, so only the storage of a structured leaf is touched — a SymmetricMatrix has its n(n+1)/2 numbers replaced and stays symmetric.

Requires mutable leaves; a parameter set with a scalar leaf has to use the out-of-place unflatten.

source

Derivatives

The flat form exists to be differentiated. Forward mode needs nothing special, because unflatten is generic in the element type of its vector:

using NeuralNetworkParameters, ForwardDiff

ps = NetworkParameters((L1 = (W = [1.0 2.0], b = [3.0]),))
v, layout = flatten(ps)

f(w) = sum(abs2, unflatten(layout, w).L1.W)
ForwardDiff.gradient(f, v)

# output

3-element Vector{Float64}:
 2.0
 4.0
 0.0

and the result is turned back into the shape of the network by unflattening it again:

unflatten(layout, ForwardDiff.gradient(f, v)).L1.W

# output

1×2 Matrix{Float64}:
 2.0  4.0

Reverse mode works through the same two functions: there are ChainRulesCore rules for both, and at a fixed layout they are linear and mutually adjoint, so each rule is the other conversion. A position the reverse pass says nothing about — a layer the loss never touched — comes back as a zero block rather than an error.

For a Jacobian, unflatten also accepts a matrix and splits its rows by parameter block:

using NeuralNetworkParameters

ps = NetworkParameters((L1 = (W = [1.0 2.0], b = [3.0]),))
_, layout = flatten(ps)
J = reshape(collect(1.0:9.0), 3, 3)
unflatten(layout, J).L1.b

# output

1×3 Matrix{Float64}:
 3.0  6.0  9.0

Cost, and why it is a copy

Flattening copies. On a network of 1.3 million Float64 parameters the round trip costs about 0.3 ms, against roughly 0.95 ms for a single forward pass at batch size 32 — so a full conversion is some 10 % of one forward pass, and less of a training step.

Sharing storage instead, so that the flat vector and the leaves were views of each other, would not work even setting cost aside: unflatten has to be able to produce parameters of a different element type from the ones the layout was built from, which is exactly what forward-mode differentiation needs. A Float64 buffer cannot be shared with a Dual-valued view.

Where repeated conversion does matter is an inner loop — an optimizer flattening twice per inner product. That is what flatten! and unflatten! are for: given a layout and a buffer, both allocate nothing, at any width of branch and any depth of nesting.

using NeuralNetworkParameters

ps = NetworkParameters((L1 = (W = [1.0 2.0], b = [3.0]),))
v, layout = flatten(ps)
buffer = similar(v)
flatten!(buffer, ps, layout)        # warm up, then this allocates nothing
buffer == v

# output

true