• 0. Linear operator
  • 1. Gradient of a function
  • 2. Jacobian of a function
  • 3. Key matrix derivatives
  • 4. Function is a DAG
  • 5. Autodiff on a DAG
  • 6. VJP and JVP
  • 7. Hessian of a function
unitgridOpacitycanvasColororiginXoriginY

Hessian and the bilinear form

  • 0. Calculating the Jacobian matrix
    • 0.1 Faster calculation by batching
    • 0.2 Calculating rows vs columns
  • 1. Hessian matrix
    • 1.1 Hessian is symmetric
    • 1.2 Bilinear form
  • 2. Calculating a Hessian in PyTorch

Hessian of a function is the Jacobian of the Jacobian of the function. A function f(x):Rn→Rf(x): R^n \rightarrow Rf(x):Rn→R has a Jacobian JfJ_fJf​ of shape (,n)(,n)(,n) which is a function of xxx. Thus one can imagine a mapping x→Jf:=g(x):Rn→Rnx \rightarrow J_f := g(x): R^n \rightarrow R^nx→Jf​:=g(x):Rn→Rn. The Jacobian of ggg will have a shape of (n,n)(n,n)(n,n). This is the Hessian of the function fff.

We start with calculation of the entire Jacobian matrix, something we have avoided so far.


0. Calculating the Jacobian matrix

We use the inverse function f(A)=A−1f(A) = A^{-1}f(A)=A−1 to calculate the Jacobian matrix. First let's calculate the Jacobian using PyTorch as a reference:

Python implementation

import torch
from torch.autograd.functional import jacobian

def inverse(x):
    return torch.linalg.inv(x)

x = torch.randn(100,100)

j_x = jacobian(inverse, x)

Now remember the JVP of the inverse function:

jvpf(x,v)=−x−1vx−1\text{jvp}_f(x,\textcolor{#b45309}{v}) = -x^{-1}\textcolor{#b45309}{v}x^{-1}jvpf​(x,v)=−x−1vx−1

This is the compressed (or reshaped) form of the vectorized form of the JVP. Now note that Jei⏟jvp(x,ei)=i-th column of J\underbrace{Je_i}_{\text{jvp}(x, e_i)} = \text{i-th column of J}jvp(x,ei​)Jei​​​=i-th column of J. Using this trick, we can calculate the i-th column of the Jacobian matrix:

Verification using Python

i = 1234
# construct a basis vector
basis_vector_i = torch.zeros(100,100)
row = i % 100
col = i // 100
basis_vector_i[row,col] = 1

jacobian_column_i = -y @ basis_vector_i @ y
# need to transpose since pytorch follows row-major order
# also need to transpose the indices of `j_x`
assert torch.allclose(jacobian_column_i, j_x[col, row].t())

One could then iterate over the columns to calculate the full Jacobian matrix.


0.1 Faster calculation by batching

A faster way to calculate the Jacobian matrix is to use batching and calculate J[e1,e2,...,en]=J I=JJ[e_1, e_2, ..., e_n] = J\,I = JJ[e1​,e2​,...,en​]=JI=J in one go.

Verification using Python

eye = torch.eye(10000,10000).view(10000,100,100)
j_manual = -y.unsqueeze(0) @ eye @ y.unsqueeze(0)

# the first two dimensions of `j_x` seem to be arranged in column-major order
# `j_manual.view(100,100, ...)` reshapes in row-major order
# so we need to transpose the first two dimensions
j_manual = j_manual.view(100,100,100,100).transpose(0,1)

assert torch.allclose(j_manual, j_x.transpose(-1,-2))

0.2 Calculating rows vs columns

We used JVP to calculate each column of the Jacobian at a time. One could also calculate each row of the Jacobian at a time using the VJP since eiTJ=i-th row of Jacobiane_i^TJ = \text{i-th row of Jacobian}eiT​J=i-th row of Jacobian.

Remember that the VJP of the inverse function is:

vjpf(x,v)=−x−Tvx−T\text{vjp}_f(x,\textcolor{#b45309}{v}) = -x^{-T}\textcolor{#b45309}{v}x^{-T}vjpf​(x,v)=−x−Tvx−T

One can then use the same procedure as above to calculate the rows of the Jacobian matrix (try it yourself).


1. Hessian matrix

Let g(x)∈Rng(x) \in R^ng(x)∈Rn be the gradient of f(x):Rn→Rf(x): R^n \rightarrow Rf(x):Rn→R. Consider the function h(x):Rn→Rn=f→gh(x): R^n \rightarrow R^n = f \rightarrow gh(x):Rn→Rn=f→g written in matrix notation:

[x1x2⋮xn]→h[g1g2⋮gn]=[∂f/∂x1∂f/∂x2⋮∂f/∂xn]\begin{bmatrix} x_1 \\ x_2 \\ \vdots \\ x_n \end{bmatrix} \xrightarrow{h} \begin{bmatrix} g_1 \\ g_2 \\ \vdots \\ g_n \end{bmatrix} = \begin{bmatrix} \partial f / \partial x_1 \\ \partial f / \partial x_2 \\ \vdots \\ \partial f / \partial x_n \end{bmatrix}​x1​x2​⋮xn​​​h​​g1​g2​⋮gn​​​=​∂f/∂x1​∂f/∂x2​⋮∂f/∂xn​​​

The Jacobian of hhh is the Hessian of fff:

Jh=[∂g1/∂x1∂g1/∂x2⋯∂g1/∂xn∂g2/∂x1∂g2/∂x2⋯∂g2/∂xn⋮⋮⋱⋮∂gn/∂x1∂gn/∂x2⋯∂gn/∂xn]⏟Hessian of fJ_h = \underbrace{\begin{bmatrix} \partial g_1 / \partial x_1 & \partial g_1 / \partial x_2 & \cdots & \partial g_1 / \partial x_n \\ \partial g_2 / \partial x_1 & \partial g_2 / \partial x_2 & \cdots & \partial g_2 / \partial x_n \\ \vdots & \vdots & \ddots & \vdots \\ \partial g_n / \partial x_1 & \partial g_n / \partial x_2 & \cdots & \partial g_n / \partial x_n \end{bmatrix}}_{\textcolor{#b45309}{\text{Hessian of } f}}Jh​=Hessian of f​∂g1​/∂x1​∂g2​/∂x1​⋮∂gn​/∂x1​​∂g1​/∂x2​∂g2​/∂x2​⋮∂gn​/∂x2​​⋯⋯⋱⋯​∂g1​/∂xn​∂g2​/∂xn​⋮∂gn​/∂xn​​​​​

We could expand each term ∂gi/∂xj=∂2f/∂xj∂xi\partial g_i / \partial x_j = \partial^2 f / \partial x_j \partial x_i∂gi​/∂xj​=∂2f/∂xj​∂xi​ in the above matrix to get a more standard form.


1.1 Hessian is symmetric

There are two ways to look at it. First, the second derivative is symmetric:

∂2f∂xj∂xi⏟Hij=∂2f∂xi∂xj⏟Hji\underbrace{\frac{\partial^2 f}{\partial \textcolor{#b45309}{x_j} \partial \textcolor{#e11d48}{x_i}}}_{{H_{ij}}} = \underbrace{\frac{\partial^2 f}{\partial \textcolor{#e11d48}{x_i} \partial \textcolor{#b45309}{x_j}}}_{{H_{ji}}}Hij​∂xj​∂xi​∂2f​​​=Hji​∂xi​∂xj​∂2f​​​

Second, we can calculate how a change in ggg affects a change in fff:

dg=g(x+dxg)−g(x)⇒H(dxg)⏟∈Rn=g(x+dxg)−g(x)⇒dxfTH(dxg)⏟∈R=⟨dxf,g(x+dxg)⟩⏟change in f(x+dxg)−⟨dxf,g(x)⟩⏟change in f(x)=f(x+dxg+dxf)−f(x+dxg)−f(x+dxf)+f(x)=dxgTH(dxf)\begin{aligned} dg &= g(x + \textcolor{#b45309}{dx_g}) - g(x) \\ \Rightarrow \underbrace{H(\textcolor{#b45309}{dx_g})}_{\in \textcolor{#b45309}{R^n}} &= g(x + \textcolor{#b45309}{dx_g}) - g(x) \\ \Rightarrow \underbrace{\textcolor{#e11d48}{dx_f^T}H(\textcolor{#b45309}{dx_g})}_{\in R} &= \underbrace{\langle \textcolor{#e11d48}{dx_f}, g(x + \textcolor{#b45309}{dx_g}) \rangle}_{\text{change in } f(x + \textcolor{#b45309}{dx_g})} - \underbrace{\langle \textcolor{#e11d48}{dx_f}, g(x) \rangle}_{\text{change in } f(x)} \\ &= f(x + \textcolor{#b45309}{dx_g} + \textcolor{#e11d48}{dx_f}) - f(x + \textcolor{#b45309}{dx_g}) - f(x + \textcolor{#e11d48}{dx_f}) + f(x) \\ &= \textcolor{#b45309}{dx_g^T}H(\textcolor{#e11d48}{dx_f}) \end{aligned}dg⇒∈RnH(dxg​)​​⇒∈RdxfT​H(dxg​)​​​=g(x+dxg​)−g(x)=g(x+dxg​)−g(x)=change in f(x+dxg​)⟨dxf​,g(x+dxg​)⟩​​−change in f(x)⟨dxf​,g(x)⟩​​=f(x+dxg​+dxf​)−f(x+dxg​)−f(x+dxf​)+f(x)=dxgT​H(dxf​)​

But since a scalar is its own transpose, we get H=HTH = H^TH=HT.


1.2 Bilinear form

Now let's take a look at the LHS of the above expression:

dxfTH(dxg)=[⋯dxfT⋯][ ⋮ ⋯H⋯ ⋮ ][⋮dxgT⋮]\textcolor{#e11d48}{dx_f^T}H(\textcolor{#b45309}{dx_g}) = \begin{bmatrix} \cdots & dx_f^T & \cdots \end{bmatrix} \begin{bmatrix} \ & \vdots & \ \\ \cdots & H & \cdots \\ \ & \vdots & \ \end{bmatrix} \begin{bmatrix} \vdots \\ dx_g^T \\ \vdots \end{bmatrix}dxfT​H(dxg​)=[⋯​dxfT​​⋯​]​ ⋯ ​⋮H⋮​ ⋯ ​​​⋮dxgT​⋮​​

The obvious interpretation of this value is that it is the change in (change in f) at two points xxx and x+dxgx + \textcolor{#b45309}{dx_g}x+dxg​. At each point you change the input by dxf\textcolor{#e11d48}{dx_f}dxf​ and measure the change in output, say df1,df2df_1, df_2df1​,df2​. The bilinear form is the difference between the two: df1−df2df_1 - df_2df1​−df2​. Since dxg,dxf\textcolor{#b45309}{dx_g}, \textcolor{#e11d48}{dx_f}dxg​,dxf​ are interchangeable, we can swap them in the above argument, so the bilinear form can be seen as the difference dy2−dy1dy_2 - dy_1dy2​−dy1​.

pie.clear()
pie.scalar(40, out='unit')
pie.scalar(0, out='gridOpacity')
AMBER, PINK, TEAL, GRAY, FAINT = '#b45309', '#be185d', '#0d9488', '#71717a', '#a1a1aa'

def arrow(name, a, b, color):
    p1 = pie.point(a[0], a[1], out=f'{name}_a', hidden=True)
    p2 = pie.point(b[0], b[1], out=f'{name}_b', hidden=True)
    return pie.annotate_arrow(p1, p2, 0, out=name, stroke=color)

# Left: apply dx_g first, then dx_f at each end
arrow('eLg', (-1.2, -1.5), (-1.2, 1.5), GRAY)
arrow('eLbf', (-1.2, -1.5), (-3.5, -0.6), GRAY)
arrow('eLbd', (-1.2, -1.5), (-3.5, -0.1), FAINT)
arrow('eLtf', (-1.2, 1.5), (-3.5, 2.4), GRAY)
arrow('eLtd', (-1.2, 1.5), (-3.5, 2.9), FAINT)
pie.text('dx_g', -2.0, 0, 20, out='mLdxg', stroke=AMBER)
pie.text('dx_f', -2.6, -1.85, 20, out='mLbf', stroke=PINK)
pie.text('dx_f', -2.7, 1.05, 20, out='mLtf', stroke=PINK)
pie.text('dy₁ {', -4.2, -0.35, 18, out='mLdy1')

# Right: apply dx_f first, then dx_g
arrow('eRg', (0.4, -1.6), (3.2, -1.6), GRAY)
arrow('eRlf', (0.4, -1.6), (0.4, 0.6), GRAY)
arrow('eRld', (0.4, -1.6), (-0.1, 1.0), FAINT)
arrow('eRrf', (3.2, -1.6), (3.2, 0.6), GRAY)
arrow('eRrd', (3.2, -1.6), (2.7, 1.0), FAINT)
pie.text('dx_g', 1.8, -2.3, 20, out='mRdxg', stroke=AMBER)
pie.text('dx_f', 1.25, 0.0, 20, out='mRlf', stroke=PINK)
pie.text('dx_f', 4.05, 0.0, 20, out='mRrf', stroke=PINK)
pie.text('dy₁', -0.3, 1.5, 18, out='mRdy1')
pie.text('dy₂', 2.4, 1.5, 18, out='mRdy2')

# Caption
pie.text('The bilinear form is the difference', 0, -3.1, 18, out='mCap', stroke=GRAY)
pie.text('dy₂', -0.55, -3.95, 18, out='mCdy2', stroke=TEAL)
pie.text('−', 0, -3.95, 18, out='mCminus', stroke=GRAY)
cdy1 = pie.text('dy₁', 0.55, -3.95, 18, out='mCdy1', stroke=TEAL)
pie.text('dy₂ {', -4.2, 2.65, 18, out='mLdy2')

First apply dxg\textcolor{#b45309}{dx_g}dxg​, then measure the change dxf\textcolor{#e11d48}{dx_f}dxf​ at each end: this gives dy1dy_1dy1​ and dy2dy_2dy2​.

Now swap the order, applying dxf\textcolor{#e11d48}{dx_f}dxf​ first and then dxg\textcolor{#b45309}{dx_g}dxg​: the bilinear form is the same difference dy2−dy1dy_2 - dy_1dy2​−dy1​.

Hessian is used in optimization by using second order approximation:

f(x+dx)=f(x)+∇f(x)T⏟gradientdx+12dxTH⏟Hessiandxf(x + dx) = f(x) + \underbrace{\textcolor{#b45309}{\nabla f(x)^T}}_{\textcolor{#b45309}{\text{gradient}}} dx + \frac{1}{2} dx^T \underbrace{\textcolor{#b45309}{H}}_{\textcolor{#b45309}{\text{Hessian}}} dxf(x+dx)=f(x)+gradient∇f(x)T​​dx+21​dxTHessianH​​dx

In machine learning, it is used in quantization of large models. Check out optimal brain compression for more information.


2. Calculating a Hessian in PyTorch

Since Hessian is just the grad of the grad of a scalar-valued function, one can simply take the gradient of the gradient of a scalar-valued function. The only trick to be aware of is that one needs to be able to have a DAG for a function that maps the input to the gradient of the function. Once that is taken care of, the rest is standard PyTorch autograd.grad stuff.

In the example below, we calculate the Hessian of f(x,y)=x2y+sin⁡(x+y)f(x,y) = x^2y + \sin(x+y)f(x,y)=x2y+sin(x+y):

Verification using Python

import torch
from torch.autograd import grad
from torch.autograd.functional import hessian

def f(x):
    return x[0]**2 * x[1] + torch.sin(x.sum())

x = torch.rand(2, requires_grad=True)
y = f(x)

# `create_graph=True` is important!
g = grad(y, x, create_graph=True)[0]
first_row_of_hessian = grad(g, x, grad_outputs=torch.Tensor([1,0]), retain_graph=True)[0]
second_row_of_hessian = grad(g, x, grad_outputs=torch.Tensor([0,1]))[0]

hessian_manual = torch.stack([first_row_of_hessian, second_row_of_hessian], dim=0)
hessian_torch = hessian(f, x)
assert torch.allclose(hessian_manual, hessian_torch)

One may have noticed that since we calculated each row of the Hessian at a time, we actually used the vjp function to calculate the gradient of the gradient of a scalar-valued function. Since the Hessian is a square matrix, it may be better to use the jvp function to calculate each column of the Hessian at a time since it does not have any memory overhead. JAX documentation provides more details on how and when to use the jvp and vjp functions.


← VJP and JVP