Backpropping through Cholesky

The Cholesky decomposition is the unique way to decompose a positive definite matrix $K$ into $K=LL^\top$ where $L$ is lower triangular and has a real, positive diagonal. To use Cholesky in a deep learning system, it is awkward to backpropagate through, yet possible, and we show a derivation here.

Backprop summary

Suppose we have,

We use gradients to optimize the parameters, and to obtain those gradients we use the chain rule,

$$ \theta_\ell \leftarrow \theta_\ell - \gamma \frac{\partial\mathcal{L}}{\partial g_L} \frac{\partial g_L}{\partial g_{L-1}}\cdots \frac{\partial g_\ell}{\partial \theta_\ell}. $$

When we actually compute this long chain, we do it recursively,

$$ \frac{\partial\mathcal{L}}{\partial g_k} = \frac{\partial\mathcal{L}}{\partial g_{k+1}}\frac{\partial g_{k+1}}{\partial g_{k}}. $$

We can view this as a sequence of VJPs (vector Jacobian products), since $\partial L / \partial g_{k+1}$ is an activation gradient (vector), and $\partial g_{k+1}/\partial g_{k}$ is a Jacobian.

Linear example

Linear layers are easy: $g(X; W) = XW$,

$$ \begin{align*} \frac{\partial \mathcal{L}}{\partial X} = \frac{\partial \mathcal{L}}{\partial g} W^\top,\\ \frac{\partial \mathcal{L}}{\partial W} = X^\top\frac{\partial \mathcal{L}}{\partial g}. \end{align*} $$

Cholesky?

It is not obvious how to do backprop with the Cholesky operation $\mathrm{chol}(K)$. We need to find $\partial{L}/\partial{K}$ in terms of $\partial \mathcal{L}/\partial L$. This is possible, but the solution is not very efficient (see [1]).

[1] provides an alternative. The idea is that we don't need to find $\frac{\partial{L}}{\partial{K}}$. If we can calculate $\frac{\partial{\mathcal{L}}}{\partial{K}}$, given gradients for $L$, $\frac{\partial{\mathcal{L}}}{\partial{L}}$, then we can backprop just fine.

Here is the method: by the chain rule,

$$ \begin{align*} \mathrm{d}\mathcal{L} &= \sum_{ij} \frac{\partial\mathcal{L}}{\partial L_{ij}} \mathrm{d}L_{ij} = \mathrm{Tr}\left(\left(\frac{\partial\mathcal{L}}{\partial L}\right) ^\top\mathrm{d}L \right). \end{align*} $$

If we can massage this into the form

$$ \begin{align*} \mathrm{d}\mathcal{L} &= \mathrm{Tr}\left(S \mathrm{d}K \right) \end{align*} $$

for some $S$, then we can identify

$$ \begin{align*} \frac{\partial\mathcal{L}}{\partial K} = S. \end{align*} $$

There is a complication - we want the gradient $\frac{\partial\mathcal{L}}{\partial K}$ to be symmetric because $K$ is symmetric. We are going to be careful later on in order to ensure this symmetricity.

The massaging begins. We have $\mathrm{d}K = L\mathrm{d}L^\top + \mathrm{d}L L^\top$, so

$$ \begin{align*} L^{-1}\mathrm{d}K L^{-T} = \underbrace{\mathrm{d}L^\top L^{-T}}_{\text{upper triangular}} + \underbrace{L^{-1} \mathrm{d}L}_\text{lower triangular}\\ \implies \text{tril}(L^{-1}\mathrm{d}K L^{-T}) - \tfrac{1}{2}\text{diag}(L^{-1}\mathrm{d}K L^{-T}) = L^{-1}\mathrm{d}L. \end{align*} $$

[1] calls this operation $\Phi(\cdot)$ for convenience,

$$ \begin{align*} \Phi(X) := \text{tril}(X) - \tfrac{1}{2}\text{diag}(X). \end{align*} $$

It turns out that $\Phi$ has the following nice property related to traces. Assuming that $A$ and $B$ are square, then since $\Phi(X)$ is always lower triangular,

$$ \begin{align*} \text{Tr}(A\Phi(B)) &= \sum_{ij} A_{ji}^\top \Phi(B)_{ji} \\ &= \sum_{j > i} A_{ji}^\top B_{ji} + \sum_{j} A_{jj}^\top (\tfrac{1}{2}B)_{jj}\\ &= \sum_{j > i} \Phi(A^\top)_{ji} B_{ji} + \sum_{j} \Phi(A^\top)_{jj} (B)_{jj}\\ &= \sum_{ij} \Phi(A^\top)_{ji} B_{ji} \\ &= \text{Tr}(\Phi(A^\top) B^\top). \end{align*} $$

Combining the above (using symmetry of $L^{-1}KL^{-T}$ and cyclic property of trace),

$$ \begin{align*} \mathrm{Tr}\left(\left(\frac{\partial\mathcal{L}}{\partial L}\right)^\top \mathrm{d}L \right) &= \mathrm{Tr}\left(\left(\frac{\partial\mathcal{L}}{\partial L}\right)^\top L \Phi(L^{-1}\mathrm{d}K L^{-T}) \right)\\ &=\mathrm{Tr}\left(\Phi\left(L^\top\frac{\partial\mathcal{L}}{\partial L}\right) L^{-1}\mathrm{d}K L^{-T} \right)\\ &=\mathrm{Tr}\left(L^{-T}\Phi\left(L^\top\frac{\partial\mathcal{L}}{\partial L}\right) L^{-1} \mathrm{d}K \right)\\ \end{align*} $$

We are almost done, however, we said earlier that we would be careful to ensure that the gradient of $K$ is symmetric. The implied gradient here, $S=L^{-T}\Phi\left(L^\top\frac{\partial\mathcal{L}}{\partial L}\right) L^{-1}$, is not symmetric. As a solution, we do some more manipulation. We enforce that $\mathrm{d}K = \mathrm{d}K^\top$, and use some trace properties,

$$ \begin{align*} \mathrm{Tr}\left(\left(\frac{\partial\mathcal{L}}{\partial L}\right)^\top \mathrm{d}L \right)=\mathrm{Tr}\left(L^{-T}\Phi\left(L^\top\frac{\partial\mathcal{L}}{\partial L}\right) L^{-1} \mathrm{d}K \right)\\ = \frac{1}{2}\mathrm{Tr}\left(L^{-T}\Phi\left(L^\top\frac{\partial\mathcal{L}}{\partial L}\right) L^{-1} \mathrm{d}K \right)+ \frac{1}{2}\mathrm{Tr}\left(\mathrm{d}K^\top L^{-T}\Phi\left(L^\top\frac{\partial\mathcal{L}}{\partial L}\right)^\top L^{-1} \right)\\ = \mathrm{Tr}\left(\frac{1}{2}L^{-T}\left[\Phi\left(L^\top\frac{\partial\mathcal{L}}{\partial L}\right) + \Phi\left(L^\top\frac{\partial\mathcal{L}}{\partial L}\right)^\top\right] L^{-1} \mathrm{d}K \right) \end{align*} $$

Therefore the gradient of the loss w.r.t. $K$ is,

$$ \begin{align*} \frac{\partial\mathcal{L}}{\partial K} &= \frac{1}{2}L^{-T}\left[\Phi\left(L^\top\frac{\partial\mathcal{L}}{\partial L}\right) + \Phi\left(L^\top\frac{\partial\mathcal{L}}{\partial L}\right)^\top\right] L^{-1}. \end{align*} $$

As we can see, backpropping through the Cholesky operation is slightly painful, because we need to do a matrix multiplication as well as two triangular solves. But it is do-able!

Pytorch implementation / sanity check

As a sanity check, we implement the above in pytorch, showing that it gives the same gradients as the pytorch Cholesky function. We also show that we get identical gradients when using a mathematically equivalent, but computationally different operation.

import torch

def Phi(x): return x.tril() - 0.5 * torch.diag_embed(x.diag())

class MyCholesky(torch.autograd.Function):
    @staticmethod
    def forward(ctx, K):
        L = torch.linalg.cholesky(K)
        ctx.save_for_backward(L)
        return L

    @staticmethod
    def backward(ctx, gradL):
        L, = ctx.saved_tensors

        S  = Phi(L.mT @ gradL)
        S = S + S.T
        S = torch.linalg.solve_triangular(L.mT, S, upper=True, left=True) # = L^{-T} @ S
        S = torch.linalg.solve_triangular(L, S, upper=False, left=False)  # = S @ L^{-1}
        return 0.5 * S

V = torch.randn(4, 4, dtype=torch.float64)
K = (V @ V.T).add_(torch.eye(4, dtype=torch.float64))

## we calculate f = trace(K^{-1}) in three different ways.
## then we show that calling f.backward() gives the same gradients on K
## in all three cases

K1 = K.detach().clone().requires_grad_(True)
K2 = K.detach().clone().requires_grad_(True)
K3 = K.detach().clone().requires_grad_(True)

K1inv = K1.inverse()  # A^{-1} = A^{-1}
cholK2 = K2.cholesky()
cholK3 = MyCholesky.apply(K3)

cholK2inv = cholK2.inverse()
K2inv = cholK2inv.T @ cholK2inv
cholK3inv = cholK3.inverse()
K3inv = cholK3inv.T @ cholK3inv

K1inv.trace().backward()
K2inv.trace().backward()
K3inv.trace().backward()

print('method 1\n', K1.grad)
print('method 2\n', K2.grad)
print('method 3\n', K3.grad)
## these 3 print statements should give the same gradients :)

References