Batched Backpropagation as a Linear Solve

This post extends Backpropagation as a Linear Solve to batches of inputs. The main change is that the (sparse) matrices of scalars from the previous post become (sparse) matrices whose entries are linear maps.11 More often than not the matrices are sparse—which is key for the efficiency of automatic differentiation. To readers used to the usual presentation of backpropagation as a pass over a computational graph, matrices may seem an odd choice. The two views are the same: the sparsity pattern below the diagonal of a lower triangular matrix ℒ︀ is a Directed Acyclic Graph (DAG).

The post ends with a Julia implementation of a neural network whose gradient is ∇𝜃𝒥︀=ℳ︀((ℒ︀∗)−1∇𝑈ℓ)+∇𝜃ℓ, the same formula as before, written as a linear solve in the style of Edelman, Akyürek, and Wang [1]. The inverse is never formed: because ℒ︀ is unit lower triangular, ℒ︀∗ is unit upper triangular, and its inverse can be applied efficiently using back substitution.

1. The batched forward pass

The previous post considered a single input 𝑢0 and a loss ℓ(𝑢𝑃) of its outputs. However, the loss function is often based on minimizing a data-fitting term of a minibatch of 𝑚 input-output pairs (𝑢0(𝑠),𝑦(𝑠)). Due to the linearity of a sum the full gradient over the 𝑚 input-output data could be computed by simply summing the gradient contribution for each example. In practice, it is often more efficient to process the data together in a batch.22 Batching turns matrix–vector products into matrix–matrix products, which use modern hardware far better. Moreover, because the weights are shared by all the data points, they only have to be loaded into memory once for a batch, whereas processing each example separately would load them once per example. The first step is to introduce the batched input and output matrices by stacking the data points as columns, i.e.

𝑈0=(𝑢0(1)…𝑢0(𝑚)),𝑌=(𝑦(1)…𝑦(𝑚)).

(1)

The only difference between this and the single input case is that the application of layer 𝑖 now generates 𝑚 outputs. Writing the outputs with a capital 𝑈, as for the input data, layer 𝑖 gives

𝑈𝑖=Φ𝑖(𝑈𝑖−1,…,𝑈0,𝜃).

(2)

We denote the collection of all the states by 𝑈(𝜃) and the collection of all the layer maps by Φ(𝑈,𝜃). One can think of them as vectors whose entries are the states and the layer maps, respectively, so that

𝑈(𝜃)=(𝑈1,𝑈2,…,𝑈𝑁),Φ(𝑈,𝜃)=(Φ1(𝑈0,𝜃),Φ2(𝑈1,𝑈0,𝜃),…,Φ𝑁(𝑈𝑁−1,…,𝑈0,𝜃)).

(3)

However, this does not have to be the case, and the only thing we require is that 𝑈(𝜃) is an element of a vector space 𝒰︀ with an inner product. Similarly, the parameters 𝜃 live in a vector space Θ. The next section makes this precise.33 See Wikipedia for what is meant by a vector space.

2. Generalization to vector spaces

It is standard to equip the space that each layer output lives in with the Frobenius inner product. This means that for 𝑍,𝐸∈𝒰︀ the inner product is a sum of Frobenius inner products over the layers, i.e.

⟨𝑍,𝐸⟩𝒰︀=∑𝑖=1𝑁⟨𝑍𝑖,𝐸𝑖⟩𝐹=∑𝑖=1𝑁tr(𝑍𝑖⊤𝐸𝑖).

(4)

The construction of the parameter space is slightly more complicated, as parameters can be shared between layers.44 For example, in a recurrent neural network. The parameters can still be thought of as a vector whose entries are parameter blocks, that is, 𝜃=(𝜃𝑘)𝑘=1𝐾∈Θ. Layer 𝑖 uses only some of the blocks, and several layers may use the same block. A linear selection map 𝒮︀𝑖:Θ→Θ𝒮︀𝑖 picks out the blocks of layer 𝑖, so that Φ𝑖 depends on 𝜃 only through 𝜃𝒮︀𝑖=𝒮︀𝑖(𝜃). In a plain feed-forward network every layer has its own weight block 𝑊𝑖,𝑖−1 and bias block 𝑏𝑖, and 𝒮︀𝑖(𝜃)=(𝑊𝑖,𝑖−1,𝑏𝑖). Similar to 𝒰︀, we equip Θ with the Frobenius inner product block by block, so that for ℎ,𝑔∈Θ it follows that

⟨ℎ,𝑔⟩Θ=∑𝑘=1𝐾⟨ℎ𝑘,𝑔𝑘⟩𝐹=∑𝑘=1𝐾tr(ℎ𝑘⊤𝑔𝑘).

(5)

Lastly, we also aim to equip the output space 𝒴︀ with an inner product. We view elements in 𝒴︀ as a list of outputs, 𝐴=(𝐴1,…,𝐴𝑟), where again the inner product is a sum of Frobenius inner products over the list,

⟨𝐴,𝐵⟩𝒴︀=∑𝑎=1𝑟⟨𝐴𝑎,𝐵𝑎⟩𝐹=∑𝑎=1𝑟tr(𝐴𝑎⊤𝐵𝑎).

(6)

We then define a linear output map 𝒫︀:𝒰︀→𝒴︀ such that it maps the full state 𝑈∈𝒰︀ to a corresponding output. That is 𝒫︀(𝑈)∈𝒴︀ and we sometimes simply write 𝑈𝒫︀=𝒫︀(𝑈).55 Usually 𝒫︀ picks out the last layer, 𝒫︀(𝑈)=𝑈𝑁. It can also select several layers, or combine them, for example by averaging.

2.1. The loss

We often describe the loss function as a function of the parameters 𝜃 only, but it is more natural to view it as a function of the states and the parameters. That is, 𝒥︀(𝜃)=ℓ(𝑈(𝜃),𝜃), where usually

ℓ(𝑈,𝜃)=𝑓(𝒫︀(𝑈);𝑌)+ℛ︀(𝜃),

(7)

where 𝑓 is a data-fitting term that compares the outputs with the targets 𝑌, and ℛ︀ is a regularization term. The derivation below only needs ℓ and Φ to be differentiable, which also makes 𝒥︀ differentiable. Only the gradients ∇𝑈ℓ and ∇𝜃ℓ of ℓ enter, as the right-hand side of the adjoint equation and as the last term of the gradient.

Example 1: Squared loss

Example 1 (Squared loss)   The natural extension of the scalar loss is the squared Frobenius norm of the residuals, written with the inner product on 𝒴︀,66 The scalar loss comes from the inner product⟨𝑢−𝑦,𝑢−𝑦⟩=(𝑢−𝑦)⊤(𝑢−𝑦). together with a regularization of the parameters,

𝑓(𝐴;𝑌)=12𝑚⟨𝐴−𝑌,𝐴−𝑌⟩𝒴︀,ℛ︀(𝜃)=𝛼2⟨𝜃,𝜃⟩Θ.

(8)

This is the loss of the examples and the code of this post.

2.2. Adjoints

For a linear map 𝒜︀:𝒳︀→𝒵︀ between two of these spaces, the adjoint 𝒜︀∗:𝒵︀→𝒳︀ is defined by

⟨𝒜︀(𝑥),𝑧⟩𝒵︀=⟨𝑥,𝒜︀∗(𝑧)⟩𝒳︀for all 𝑥∈𝒳︀,𝑧∈𝒵︀.

(9)

Moving an operator across an inner product therefore changes the inner product from the operator’s output space to its input space.

Example 2: Adjoint of the selection map

Example 2 (Adjoint of the selection map)   Take 𝑁=3 layers, each producing a matrix 𝑈𝑖 with 𝑚 columns, one per data point, and let the loss depend on the first and the last layer output. Then

𝑈=(𝑈1,𝑈2,𝑈3)∈𝒰︀,𝐴=(𝐴1,𝐴2)∈𝒴︀,

(10)

where 𝐴1 and 𝐴2 have the sizes of 𝑈1 and 𝑈3. The selection map 𝒫︀:𝒰︀→𝒴︀ is 𝒫︀(𝑈)=(𝑈1,𝑈3). We are free to select other layers, or the same layer more than once, as long as each selected 𝑈𝑖 has the size of the corresponding entry of 𝒴︀. For any 𝑈∈𝒰︀ and 𝐴∈𝒴︀, writing out the inner product on 𝒴︀ and adding a zero term for the unselected layer gives

⟨𝒫︀(𝑈),𝐴⟩𝒴︀=⟨𝑈1,𝐴1⟩𝐹+⟨𝑈3,𝐴2⟩𝐹=⟨𝑈1,𝐴1⟩𝐹+⟨𝑈2,0⟩𝐹+⟨𝑈3,𝐴2⟩𝐹=⟨𝑈,(𝐴1,0,𝐴2)⟩𝒰︀

(11)

Hence the adjoint 𝒫︀∗ places the selected blocks back in their original positions, with zeros elsewhere, i.e. 𝒫︀∗(𝐴)=(𝐴1,0,𝐴2). If a layer is selected more than once, 𝒫︀∗ sums the corresponding blocks of 𝐴 in its position. The inner product starts in 𝒴︀, the output space of 𝒫︀, and ends in 𝒰︀, its input space. In the common case where only the last layer is selected, 𝒫︀(𝑈)=𝑈𝑁 and 𝒫︀∗(𝐴)=(0,…,0,𝐴).

2.3. Gradients

The gradient is defined through an inner product. For a differentiable scalar function of 𝜃, such as the loss 𝒥︀, the derivative 𝐷𝒥︀(𝜃)∈Θ∗ is a linear map from directions ℎ∈Θ to numbers.77 Θ∗ denotes the dual space of Θ, which is the space of all linear functionals on Θ. This is a technicality that we revisit in Which Way Is Down? First-Order Optimizers as a Choice of Norm. The Riesz representation theorem88 In a finite-dimensional inner product space every linear functional 𝜓 has the form 𝜓(ℎ)=⟨𝑔,ℎ⟩Θ for exactly one 𝑔∈Θ, its Riesz representative. says that such a map is an inner product with a fixed element of Θ. That element is the gradient

𝐷𝒥︀(𝜃)[ℎ]=⟨∇𝜃𝒥︀,ℎ⟩Θfor every direction ℎ.

(12)

So to find a gradient, we write the derivative in the form ⟨⋅,ℎ⟩Θ and read off the other argument. The same definition applies on 𝒰︀ and 𝒴︀, each with its own inner product. Changing the inner product therefore changes the gradient, but not the derivative.

Example 3: Gradients of the loss

Example 3 (Gradients of the loss)   A state change 𝐸∈𝒰︀ changes the outputs by 𝒫︀(𝐸), since 𝒫︀ is linear. Moving 𝒫︀ across the inner product with its adjoint gives

𝐷𝑈ℓ[𝐸]=⟨∇𝑓,𝒫︀(𝐸)⟩𝒴︀=⟨𝒫︀∗(∇𝑓),𝐸⟩𝒰︀,so∇𝑈ℓ=𝒫︀∗(∇𝑓),

(13)

with ∇𝑓 evaluated at 𝑈𝒫︀. For the squared loss, ∇𝑓=(𝑈𝒫︀−𝑌)/𝑚,99 By the product rule and the symmetry of the inner product, 𝐷⟨𝑎,𝑎⟩[𝐸]=2⟨𝑎,𝐷𝑎[𝐸]⟩ [2]. and 𝐷𝜃ℓ[ℎ]=𝛼⟨𝜃,ℎ⟩Θ gives ∇𝜃ℓ=𝛼𝜃.

The last step depends on the inner product on Θ. With ⟨𝑔,ℎ⟩𝒜︀=⟨𝒜︀(𝑔),ℎ⟩Θ instead, for a self-adjoint and positive definite 𝒜︀, the same derivative reads

𝐷𝜃ℓ[ℎ]=𝛼⟨𝜃,ℎ⟩Θ=𝛼⟨𝒜︀(𝒜︀−1(𝜃)),ℎ⟩Θ=⟨𝛼𝒜︀−1(𝜃),ℎ⟩𝒜︀,

(14)

so the gradient becomes 𝛼𝒜︀−1(𝜃). In the same way, the gradient of 𝒥︀ becomes the preconditioned 𝒜︀−1(∇𝜃𝒥︀), which Which Way Is Down? builds optimizers from.

3. The batched gradient

Note that the parameters enter the loss function 𝒥︀(𝜃)=ℓ(𝑈(𝜃),𝜃) twice: directly as 𝜃, and through the states 𝑈(𝜃). Differentiating in a parameter direction ℎ∈Θ, the chain rule gives one term for each,

𝐷𝒥︀(𝜃)[ℎ]=𝐷𝑈ℓ[𝑍ℎ]+𝐷𝜃ℓ[ℎ]=⟨∇𝑈ℓ,𝑍ℎ⟩𝒰︀+⟨∇𝜃ℓ,ℎ⟩Θ,𝑍ℎ=𝐷𝑈(𝜃)[ℎ]∈𝒰︀.

(15)

In order to find the gradient ∇𝜃𝒥︀, we need to write the first term in the form ⟨⋅,ℎ⟩Θ so that we can read off the other argument—the Riesz representative of the derivative.

3.1. The forward relation

To relate 𝑍ℎ to ℎ, differentiate the forward relation 𝑈(𝜃)=Φ(𝑈(𝜃),𝜃) in the direction ℎ. The left side gives 𝑍ℎ. On the right, 𝜃 again enters both directly and through 𝑈(𝜃), and the chain rule gives

𝑍ℎ=𝐷𝑈Φ[𝑍ℎ]+𝐷𝜃Φ[ℎ].

(16)

The first term accounts for changes through the states, while the second accounts for the explicit parameter dependence.1010 Both partial derivatives are evaluated at the current (𝑈,𝜃). In 𝐷𝑈Φ, 𝜃 is held fixed; in 𝐷𝜃Φ, 𝑈 is held fixed. The total state dependence enters through 𝑍ℎ. Moving the state term to the left gives the linear equation

(ℐ︀−𝐷𝑈Φ)⏟ℒ︀(𝑍ℎ)=𝐷𝜃Φ⏟ℳ︀∗[ℎ],ℒ︀:𝒰︀→𝒰︀,ℳ︀∗:Θ→𝒰︀,

(17)

where ℒ︀ and ℳ︀∗ are the operator versions of 𝐿 and 𝑀⊤ from the previous post. Solving it, ℒ︀(𝑍ℎ)=ℳ︀∗(ℎ), gives 𝑍ℎ for one direction ℎ. This is forward-mode differentiation, and the full gradient this way would need one solve per direction in a basis of Θ, that is, dimΘ solves.

3.2. The adjoint equation

Solving (17) gives 𝑍ℎ=ℒ︀−1(ℳ︀∗(ℎ)). Substituting this into the first term of (15) and moving first ℒ︀−1, which maps 𝒰︀ to itself, and then ℳ︀∗, which maps Θ to 𝒰︀, across the inner product using their respective adjoints gives

⟨∇𝑈ℓ,ℒ︀−1(ℳ︀∗(ℎ))⟩𝒰︀=⟨(ℒ︀−1)∗(∇𝑈ℓ),ℳ︀∗(ℎ)⟩𝒰︀=⟨ℳ︀(Λ),ℎ⟩Θ,

(18)

where Λ=(ℒ︀−1)∗(∇𝑈ℓ). Rather than forming the inverse, we compute Λ by solving the adjoint equation1111 For invertible ℒ︀, the inverse and adjoint commute:(ℒ︀−1)∗=(ℒ︀∗)−1.Taking adjoints of ℒ︀−1ℒ︀=ℐ︀ reverses the order, giving ℒ︀∗(ℒ︀−1)∗=ℐ︀; the reverse product is also the identity. Moving ℒ︀−1 across the inner product therefore gives a solve with ℒ︀∗.

ℒ︀∗(Λ)=∇𝑈ℓ,

(19)

whose right-hand side does not depend on ℎ, so a single solve serves every direction. This is reverse-mode differentiation: as shown below, ℒ︀ is lower block triangular, so ℒ︀∗ is upper block triangular, and the solve runs from the last layer back to the first. Collecting the terms,

𝐷𝒥︀(𝜃)[ℎ]=⟨ℳ︀(Λ)+∇𝜃ℓ,ℎ⟩Θfor every ℎ.

(20)

Reading off the Riesz representative of 𝐷𝒥︀(𝜃) gives

∇𝜃𝒥︀=ℳ︀(Λ)+∇𝜃ℓ∈Θ.

(21)

3.3. The operators as block (sparse) matrices

Viewing the states and the parameters as lists, ℒ︀ and ℳ︀∗ can be written as block matrices whose entries are linear maps. For a state change 𝐸∈𝒰︀, layer 𝑖 can only respond to the states that come strictly before it, so entry 𝑖 of ℒ︀(𝐸)1212 Recall that ℒ︀:𝒰︀→𝒰︀ maps state changes to state changes, so ℒ︀(𝐸) is again a list with one entry per layer. is

(ℒ︀(𝐸))𝑖=𝐸𝑖−∑𝑗=1𝑖−1𝐷𝑈𝑗Φ𝑖[𝐸𝑗]=∑𝑗=1𝑖ℒ︀𝑖𝑗(𝐸𝑗),ℒ︀𝑖𝑗={ℐ︀if𝑗=𝑖−𝐷𝑈𝑗Φ𝑖if𝑗<𝑖

(22)

so block (𝑖,𝑗) of ℒ︀ is nonzero only if layer 𝑖 reads the state 𝑈𝑗. In the same way, for a parameter direction ℎ∈Θ, layer 𝑖 only sees the parameters 𝜃𝒮︀𝑖=𝒮︀𝑖(𝜃), so by the chain rule entry 𝑖 of ℳ︀∗(ℎ)1313 Recall that ℳ︀∗:Θ→𝒰︀ maps parameter changes to the state changes they cause directly: it takes a list over the 𝐾 parameter blocks to a list over the 𝑁 layers. Its adjoint ℳ︀:𝒰︀→Θ goes the other way. is

(ℳ︀∗(ℎ))𝑖=𝐷𝜃𝒮︀𝑖Φ𝑖[𝒮︀𝑖(ℎ)]=∑𝑘=1𝐾ℳ︀𝑘𝑖∗(ℎ𝑘),ℳ︀𝑘𝑖∗=𝐷𝜃𝑘Φ𝑖,

(23)

so block (𝑖,𝑘) of ℳ︀∗ is nonzero only if 𝒮︀𝑖 selects the parameter block 𝜃𝑘. The indices of ℳ︀𝑘𝑖∗ are written in the order 𝑘𝑖 because it is the adjoint of the block ℳ︀𝑘𝑖 of ℳ︀, at position (𝑘,𝑖), as shown below. Written out, with 𝒪︀ denoting the zero map,

ℒ︀=(ℐ︀𝒪︀…𝒪︀ℒ︀21ℐ︀…𝒪︀⋮⋮⋱⋮ℒ︀𝑁1ℒ︀𝑁2…ℐ︀),ℳ︀∗=(ℳ︀11∗…ℳ︀𝐾1∗⋮⋱⋮ℳ︀1𝑁∗…ℳ︀𝐾𝑁∗).

(24)

Layer 𝑖 only reads earlier states, so ℒ︀ is lower block triangular with identities on the diagonal. In practice a layer reads only a few of the states, and most parameter blocks are used by a single layer, so most blocks are 𝒪︀, and ℒ︀ and ℳ︀∗ are sparse. Since the inner products on 𝒰︀ and Θ are sums over the layers and the parameter blocks, the adjoints follow the same pattern as for matrices: transpose the block pattern and take the adjoint of each block, (ℒ︀∗)𝑗𝑖=ℒ︀𝑖𝑗∗.1414 This relies on the inner products treating the blocks separately. An inner product that couples the blocks, such as ⟨ℎ,𝒜︀(𝑔)⟩Θ with the Fisher information 𝒜︀ of natural gradient descent, gives an adjoint that also involves 𝒜︀. In practice one keeps the separable inner product for the derivation and applies 𝒜︀−1 to the resulting gradient afterwards. This gives

ℒ︀∗=(ℐ︀ℒ︀21∗…ℒ︀𝑁1∗𝒪︀ℐ︀…ℒ︀𝑁2∗⋮⋮⋱⋮𝒪︀𝒪︀…ℐ︀),ℳ︀=(ℳ︀11…ℳ︀1𝑁⋮⋱⋮ℳ︀𝐾1…ℳ︀𝐾𝑁).

(25)

Since ℒ︀∗ is upper block triangular, the adjoint equation is solved by back substitution from the last layer to the first. Both this solve and the application of ℳ︀ only require the action of the adjoint blocks on the entries Λ𝑖.

Example 4: Affine layers

Example 4 (Affine layers)   Let every layer apply an affine map to the states it reads, followed by an entrywise activation 𝜎. Layer 𝑖 reads the state 𝑈𝑗 through a weight 𝑊𝑖𝑗 and adds a bias 𝑏𝑖, so that

Φ𝑖(𝑈,𝜃)=𝜎(𝑍𝑖),𝑍𝑖=∑𝑗𝑊𝑖𝑗𝑈𝑗+𝑏𝑖𝟏⊤,Δ𝑖=𝜎′(𝑍𝑖),

(26)

where the sum runs over the states that layer 𝑖 reads, and 𝑈0 is the input data. The selection map 𝒮︀𝑖 picks out the weights 𝑊𝑖𝑗 and the bias 𝑏𝑖 of layer 𝑖 among the parameter blocks 𝜃𝑘, and two layers share a block if, for example, 𝑊32=𝑊43. A plain feed-forward network only has the weights 𝑊𝑖,𝑖−1. The product rule and the entrywise linearization of 𝜎 give

ℒ︀𝑖𝑗(𝐸𝑗)=−Δ𝑖⊙(𝑊𝑖𝑗𝐸𝑗),ℳ︀𝑘𝑖∗(ℎ𝑘)={Δ𝑖⊙(ℎ𝑘𝑈𝑗)if𝜃𝑘=𝑊𝑖𝑗Δ𝑖⊙(ℎ𝑘𝟏⊤)if𝜃𝑘=𝑏𝑖

(27)

where ⊙ denotes entrywise multiplication.1515 The weights 𝑊𝑖0 read the input data 𝑈0, which is fixed and not part of 𝒰︀, so they give no block in ℒ︀, but they still give blocks of ℳ︀∗. Treating 𝑈0 as a state would add the blocks ℒ︀𝑖0 and give the gradient with respect to the input. Their adjoints follow from two properties of the Frobenius inner product: entrywise multiplication is self-adjoint, ⟨𝑍,Δ⊙𝑋⟩𝐹=⟨Δ⊙𝑍,𝑋⟩𝐹, and a factor of a matrix product moves to the other side, ⟨𝑍,𝐴𝐵⟩𝐹=⟨𝐴⊤𝑍,𝐵⟩𝐹=⟨𝑍𝐵⊤,𝐴⟩𝐹.1616 Both equalities follow from ⟨𝑋,𝑌⟩𝐹=tr(𝑋⊤𝑌). The first only regroups the product,tr(𝑍⊤𝐴𝐵)=tr((𝐴⊤𝑍)⊤𝐵),and the second uses the cyclic property of the trace,tr(𝑍⊤𝐴𝐵)=tr(𝐵𝑍⊤𝐴)=tr((𝑍𝐵⊤)⊤𝐴). Hence

ℒ︀𝑖𝑗∗(Λ𝑖)=−𝑊𝑖𝑗⊤(Δ𝑖⊙Λ𝑖),ℳ︀𝑘𝑖(Λ𝑖)={(Δ𝑖⊙Λ𝑖)𝑈𝑗⊤if𝜃𝑘=𝑊𝑖𝑗(Δ𝑖⊙Λ𝑖)𝟏if𝜃𝑘=𝑏𝑖.

(28)

4. A code example

The code is adapted from the Julia notebook Backprop with Backslash accompanying [1], which fixes a block bidiagonal ℒ︀ and a block diagonal ℳ︀. Here both are general sparse matrices of operators, so that a layer can read any earlier state and share parameter blocks with other layers.

We introduce a network of 𝑁=6 layers, which produce the states 𝑈1,…,𝑈6 from the input 𝑈0, and include 𝑚=10 data points. The layers are those of Example 4. Besides the chain weights 𝑊𝑖,𝑖−1, layers 3, 5, and 6 also read 𝑈1, 𝑈2, and 𝑈3 through the skip weights 𝑊31, 𝑊52, and 𝑊63, and layers 3 and 4 share both their chain weight, 𝑊32=𝑊43, and their bias, 𝑏3=𝑏4. With 8 weight blocks and 5 bias blocks, 𝐾=13.1717 Each of the 6 layers has a chain weight and a bias, but layers 3 and 4 share theirs, which leaves 6−1=5 pairs. Each of the 3 skip connections adds a weight block of its own, so 𝐾=2(6−1)+3=13. The operator ℒ︀ then has the pattern

ℒ︀=(ℐ︀𝒪︀𝒪︀𝒪︀𝒪︀𝒪︀ℒ︀2,1ℐ︀𝒪︀𝒪︀𝒪︀𝒪︀ℒ︀3,1ℒ︀3,2ℐ︀𝒪︀𝒪︀𝒪︀𝒪︀𝒪︀ℒ︀4,3ℐ︀𝒪︀𝒪︀𝒪︀ℒ︀5,2𝒪︀ℒ︀5,4ℐ︀𝒪︀𝒪︀𝒪︀ℒ︀6,3𝒪︀ℒ︀6,5ℐ︀).

(29)

Already here one can see that ℒ︀ is sparse, with only 14 nonzero blocks out of 36, and only 8 of the 15 blocks below the diagonal. The deeper the network, the sparser it becomes, as each layer reads only a few of the earlier states.

Column 𝑖 of ℳ︀ has a block in row 𝑘 for every parameter block 𝜃𝑘 that 𝒮︀𝑖 selects.1818 We number the weights first, 𝜃1=𝑊10, 𝜃2=𝑊21, 𝜃3=𝑊32=𝑊43, 𝜃4=𝑊54, and 𝜃5=𝑊65 along the chain and 𝜃6=𝑊31, 𝜃7=𝑊52, and 𝜃8=𝑊63 for the skip connections, and the biases 𝜃9,…,𝜃13 last, so that, for example, 𝒮︀3(𝜃)=(𝜃3,𝜃6,𝜃11) and 𝒮︀4(𝜃)=(𝜃3,𝜃11). This gives

ℳ︀=(ℳ︀1,1𝒪︀𝒪︀𝒪︀𝒪︀𝒪︀𝒪︀ℳ︀2,2𝒪︀𝒪︀𝒪︀𝒪︀𝒪︀𝒪︀ℳ︀3,3ℳ︀3,4𝒪︀𝒪︀𝒪︀𝒪︀𝒪︀𝒪︀ℳ︀4,5𝒪︀𝒪︀𝒪︀𝒪︀𝒪︀𝒪︀ℳ︀5,6𝒪︀𝒪︀ℳ︀6,3𝒪︀𝒪︀𝒪︀𝒪︀𝒪︀𝒪︀𝒪︀ℳ︀7,5𝒪︀𝒪︀𝒪︀𝒪︀𝒪︀𝒪︀ℳ︀8,6ℳ︀9,1𝒪︀𝒪︀𝒪︀𝒪︀𝒪︀𝒪︀ℳ︀10,2𝒪︀𝒪︀𝒪︀𝒪︀𝒪︀𝒪︀ℳ︀11,3ℳ︀11,4𝒪︀𝒪︀𝒪︀𝒪︀𝒪︀𝒪︀ℳ︀12,5𝒪︀𝒪︀𝒪︀𝒪︀𝒪︀𝒪︀ℳ︀13,6).

(30)

The rows of the shared weight 𝜃3 and of the shared bias 𝜃11 each hold two blocks, so these two entries of ℳ︀(Λ) sum the contributions of layers 3 and 4.

In the code, each block is an operator that stores the map and its adjoint, and ℒ︀ and ℳ︀∗ are sparse matrices of these operators. The only new methods are the back substitution with ℒ︀∗ and the application of ℳ︀, which both visit only the stored blocks. The script prints the patterns of ℒ︀ and ℳ︀∗ (the transpose of the pattern of ℳ︀ above) and compares the gradient with a central difference along a random direction.

julia
backbackback.jl
1
using LinearAlgebra, Random, SparseArrays
2
Random.seed!(42)
3
4
# A linear map stored together with its adjoint
5
struct Operator
6
operator
7
adjoint
8
sym::String
9
end
10
Base.adjoint(A::Operator) = Operator(A.adjoint, A.operator, "$(A.sym)′")
11
Base.:*(A::Operator, X) = A.operator(X)
12
Base.show(io::IO, A::Operator) = print(io, A.sym)
13
Base.zero(::Type{Operator}) = Operator(zero, zero, "𝒪") # For printing
14
const ℐ = Operator(identity, identity, "ℐ")
15
16
# Adjoints of sparse matrices whose entries are operators.
17
const AdjointOperators = Adjoint{<:Any, <:SparseMatrixCSC{Operator}}
18
19
# Back substitution with ℒ*: Λ_j = g_j - ∑_{i>j} ℒ_ij* Λ_i.
20
function Base.:\(A::AdjointOperators, g::Vector)
21
ℒ = parent(A) # Get the non-adjoint, but apply adjoint later
22
rows, blocks = rowvals(ℒ), nonzeros(ℒ) # Column j: blocks[r], rows[r]
23
Λ = copy(g)
24
for j in length(g):-1:1, r in nzrange(ℒ, j)
25
i = rows[r]
26
if i > j
27
Λ[j] -= blocks[r]' * Λ[i]
28
end
29
end
30
return Λ
31
end
32
33
# ℳ = (ℳ′)*: entry k sums the contributions of the layers that use θ_k.
34
function Base.:*(A::AdjointOperators, Λ::Vector)
35
ℳ′ = parent(A) # Get the non-adjoint, but apply adjoint later
36
rows, blocks = rowvals(ℳ′), nonzeros(ℳ′)
37
return [sum(blocks[r]' * Λ[rows[r]] for r in nzrange(ℳ′, k))
38
for k in axes(ℳ′, 2)]
39
end
40
41
# Defining the activation function and its derivative
42
σ(x) = exp(-x)
43
σ′(x) = -exp(-x)
44
45
# Layer i reads the state U_j through the weight W_ij = θ_k for every
46
# j => k in W[i], and adds the bias b_i = θ_b[i]. Layers 3 and 4 share
47
# both their weight θ₃ and their bias θ₁₁.
48
d = [5, 4, 4, 4, 4, 3, 1] # Feature sizes of U₀, …, U₆
49
W = [[0 => 1], [1 => 2], [2 => 3, 1 => 6], [3 => 3], [4 => 4, 2 => 7],
50
[5 => 5, 3 => 8]]
51
b = [9, 10, 11, 11, 12, 13]
52
N, K = 6, 13 # Layers, parameter blocks
53
54
function network(θ)
55
U, Δ = Matrix{Float64}[], Matrix{Float64}[]
56
state(j) = j == 0 ? U₀ : U[j]
57
for i in 1:N
58
Z = sum(θ[k] * state(j) for (j, k) in W[i]) .+ θ[b[i]]
59
push!(U, σ.(Z))
60
push!(Δ, σ′.(Z))
61
end
62
return U, Δ
63
end
64
65
# The sparse matrices ℒ = I - D_U Φ and ℳ′ = D_θ Φ, block by block.
66
function operators(θ, U, Δ)
67
state(j) = j == 0 ? U₀ : U[j]
68
ℒ, ℳ′ = spzeros(Operator, N, N), spzeros(Operator, N, K)
69
for i in 1:N
70
ℒ[i, i] = ℐ
71
for (j, k) in W[i]
72
Wij, Uj = θ[k], state(j)
73
if j > 0
74
ℒ[i, j] = Operator(E -> -(Δ[i] .* (Wij * E)),
75
Λ -> -(Wij' * (Δ[i] .* Λ)), "ℒ")
76
end
77
ℳ′[i, k] = Operator(h -> Δ[i] .* (h * Uj),
78
Λ -> (Δ[i] .* Λ) * Uj', "ℳ′")
79
end
80
ℳ′[i, b[i]] = Operator(h -> Δ[i] .* h,
81
Λ -> vec(sum(Δ[i] .* Λ, dims=2)), "ℳ′")
82
end
83
return ℒ, ℳ′
84
end
85
86
# The data and the parameters.
87
m = 10 # Data points
88
α = 1e-2 # Weight of the regularization α/2 ⟨θ, θ⟩
89
init(dims...) = 0.01 * randn(dims...)
90
U₀, Y = init(d[1], m), init(d[end], m)
91
θ = Vector{Array{Float64}}(undef, K)
92
for i in 1:N
93
for (j, k) in W[i]
94
θ[k] = init(d[i+1], d[j+1])
95
end
96
θ[b[i]] = init(d[i+1])
97
end
98
99
# The gradient of J(θ) = ‖U_N - Y‖²/(2m) + α/2 ⟨θ, θ⟩.
100
J(θ) = norm(network(θ)[1][N] - Y)^2 / (2m) + α / 2 * dot(θ, θ)
101
U, Δ = network(θ)
102
ℒ, ℳ′ = operators(θ, U, Δ)
103
∇Uℓ = [i == N ? (U[N] - Y) / m : zero(U[i]) for i in 1:N] # 𝒫*(∇f)
104
Λ = ℒ' \ ∇Uℓ
105
∇J = ℳ′' * Λ + α * θ # ℳ(Λ) + αθ
106
display(ℒ)
107
display(ℳ′)
108
109
# Central difference of the loss along a random direction.
110
h = [randn(size(p)...) for p in θ]
111
ε = 1e-6
112
directional = (J(θ + ε * h) - J(θ - ε * h)) / (2ε)
113
versus_finite_difference = abs(directional - dot(∇J, h)) / abs(dot(∇J, h))
114
@show versus_finite_difference

5. Conclusion

Going from a single input to a batch only changed the entries of the matrices, from scalars to linear maps. The overall structure stayed the same: ℒ︀ is a unit lower block triangular matrix whose sparsity pattern is a DAG, and both ℒ︀ and ℳ︀ are most often sparse. The formula for the gradient also kept the same form, ∇𝜃𝒥︀=ℳ︀((ℒ︀∗)−1∇𝑈ℓ)+∇𝜃ℓ,1919 In the previous post the gradient reads ∇𝜃𝒥︀=𝑀𝐿−⊤𝑃⊤∇𝑢𝑃ℓ+𝜇∇𝜃ℛ︀. The matrices 𝑀 and 𝐿 become the operators ℳ︀ and ℒ︀, the transpose becomes the adjoint, 𝑃⊤∇𝑢𝑃ℓ becomes ∇𝑈ℓ=𝒫︀∗(∇𝑓), and the regularization is part of ∇𝜃ℓ. and can still be computed with a single back substitution and a single sparse product.

Cite this post

bibtex
@misc{paltorp2026batchedbackpropagation,
author = {Paltorp, Mikkel},
title = {{Batched Backpropagation as a Linear Solve}},
year = {2026},
month = oct,
url = {https://mipals.github.io/pdf/2026/10-07-batched-backpropagation/batched-backpropagation.pdf},
note = {Blog post}
}

Bibliography

  • [1] A. Edelman, E. Akyürek, and Y. Wang, “Backpropagation through Back Substitution with a Backslash,” SIAM Journal on Matrix Analysis and Applications, vol. 45, no. 1, pp. 429–449, 2024, doi: 10.1137/22M1532871.
  • [2] P. Bright, A. Edelman, and S. G. Johnson, “Matrix Calculus (for Machine Learning and Beyond).” [Online]. Available: https://arxiv.org/abs/2501.14787