Actor Critic using Kronecker-Factored Trust Region (ACKTR)
ACKTR gives actor-critic networks second-order updates without ever building the giant Fisher matrix. It splits each layer curvature into two small Kronecker factors that are cheap to invert.
Why Does This Exist?
A plain policy gradient steps in weight space: change every weight by a fixed-size nudge along the gradient. The trouble is that weights are not the policy. A small nudge to a few weights can flip the action distribution from exploratory to near-deterministic, and the update has no idea it just did that. Training oscillates or collapses, and you pay for it in samples.
The natural gradient fixes the units. It measures distance between policies, not between weight vectors, using the Fisher information matrix . The update is the steepest direction in distribution space, so a step of a given size changes behavior by a predictable amount. Trust Region Policy Optimization uses this idea but never builds : it approximates with conjugate-gradient iterations, needs repeated curvature-vector passes, and applies the second-order treatment to the actor only while the critic trains with a first-order optimizer.
ACKTR (Wu et al., 2017) makes the exact second-order step practical for both networks. Its trick is Kronecker-factored approximate curvature (K-FAC): each layer Fisher block factors into two small covariance matrices, one from activations and one from output gradients. Inverting those costs almost nothing next to inverting itself.
This page covers the factorization, the trust-region step, and a hand-computed 2 by 2 example. For the trust-region idea underneath it, read the TRPO page first if you have not.
Think of It Like This
Tuning a grid of sprinklers by rows and columns
Picture a lawn with sprinklers in a 100 by 100 grid. Each sprinkler has its own valve, so there are 10,000 settings. You want to correct the watering pattern, and the effect of each valve depends on water pressure along its row and its column.
The naive approach measures how every valve interacts with every other valve: a 10,000 by 10,000 table of interactions. Nobody has time for that.
Instead you notice the structure. Row pressure varies independently of column pressure, to a good approximation. So you measure two small things: a 100 by 100 table of row effects and a 100 by 100 table of column effects. To correct any single valve, you combine its row correction with its column correction. Two small inversions replace one impossible one.
Where it stops: real rows and columns interact a little (a clogged main affects both), and K-FAC likewise pretends activations and output gradients are statistically independent when it splits the expectation. That approximation is the price of tractability, and in practice it is worth paying.
How It Actually Works
Take one dense layer mapping activations (length ) to pre-activations (length ). Here is the weight matrix, is the layer input for one sample, and is the gradient of the loss with respect to (how much each pre-activation should change).
The gradient with respect to the weights is an outer product:
Flattened into a vector, that outer product becomes a Kronecker product, written :
The layer Fisher block is the expected outer product of these flattened gradients:
K-FAC splits this expectation into a product of expectations:
where is the by activation covariance and is the by output-gradient covariance. The inverse of a Kronecker product is the Kronecker product of the inverses, so with :
That is the whole trick. Instead of inverting a by matrix, you invert one small matrix per side and multiply twice. For a 100 by 100 layer that is two 100 by 100 inversions instead of one 10,000 by 10,000 inversion.
The trust-region step
The natural gradient sets the direction; a trust region sets the size. With the flattened natural gradient, the plain gradient, and the allowed KL change per update (usually about 0.01):
If the proposed step would move the policy too far, shrinks it. In practice the covariances and are tracked as running averages over minibatches, a small damping term is added before inverting for stability, and the inverses are recomputed every few dozen steps rather than every step.
Worked example
A 2 by 2 layer. Suppose the tracked covariances (damping already folded in) are diagonal:
Invert each side: and . First multiply :
Then multiply by on the right, which scales column one by 0.5 and leaves column two alone:
Check against the full Kronecker inverse: , whose inverse is . Applied to the flattened gradient , that gives , which reshapes to exactly . The sandwich agrees with the giant inverse.
Now cap the step with . The inner product , so:
The final update is :
Notice how the curvature reshaped the step: the plain gradient treated all four weights equally, but the natural gradient pushes hardest on the weight whose activation and gradient variances are both smallest.
Code
This snippet computes the K-FAC sandwich and the trust-region scale for the worked example above, in pure standard-library Python. Its point is the check: the fast sandwich form must match the full Kronecker inverse.
from typing import List
def mat_mul(a: List[List[float]], b: List[List[float]]) -> List[List[float]]: """Plain matrix product for small dense matrices.""" n, m, p = len(a), len(b[0]), len(b) return [[sum(a[i][k] * b[k][j] for k in range(p)) for j in range(m)] for i in range(n)]
def diag_inv(d: List[float]) -> List[List[float]]: """Inverse of a diagonal matrix given as a list of diagonal entries.""" return [[1.0 / d[i] if i == j else 0.0 for j in range(len(d))] for i in range(len(d))]
def kfac_step( grad: List[List[float]], act_diag: List[float], grad_diag: List[float], delta: float = 0.01,) -> tuple: """Return (natural gradient, trust-region step, scale) for one dense layer.""" nat = mat_mul(mat_mul(diag_inv(grad_diag), grad), diag_inv(act_diag)) curve = sum(g * n for grow, nrow in zip(grad, nat) for g, n in zip(grow, nrow)) beta = min(1.0, (2.0 * delta / curve) ** 0.5) step = [[beta * v for v in row] for row in nat] return nat, step, beta
nat, step, beta = kfac_step([[1.0, 1.0], [1.0, 1.0]], [2.0, 1.0], [4.0, 2.0])print([[round(v, 4) for v in row] for row in nat])# -> [[0.125, 0.25], [0.25, 0.5]]print(f"{beta:.5f}")# -> 0.13333print([[round(v, 4) for v in row] for row in step])# -> [[0.0167, 0.0333], [0.0333, 0.0667]]All three prints match the hand computation, including the trust-region scale.
Watch Out For
Inverting every step, or damping it wrong
Inverting and on every minibatch is slow, and the inverses go stale at different rates than the gradients. The standard fix is to refresh the running covariances every batch but recompute the inverses only every 20 to 100 steps. If training suddenly diverges after running fine, a stale inverse is a prime suspect: refresh them and see if the spike disappears.
Damping cuts the other way. Too little ( near zero) and a near-singular covariance produces an enormous step out of nowhere. Too much and is nearly a scaled identity, so you paid for second-order machinery and got plain gradient descent back. When the natural step stops helping, check before blaming the idea.
Assuming the factorization fits every layer
K-FAC in its basic form assumes a dense layer with no weight sharing. Convolutional layers share weights across spatial positions, so the clean split needs patch-based statistics instead of plain activation covariances. Normalization layers and recurrent weights break the independence assumption further. If you apply the dense-layer formula verbatim to a conv net and updates explode, the layer type is the bug, not your arithmetic.
The Quick Version
- The natural gradient steps in policy space instead of weight space, so fixed-size steps change behavior predictably.
- K-FAC approximates each layer Fisher block as , two small covariances, and the natural gradient becomes the sandwich .
- A 100 by 100 layer needs two 100 by 100 inversions instead of one 10,000 by 10,000 inversion.
- ACKTR applies this to both actor and critic, unlike TRPO, and needs no conjugate-gradient loop.
- The trust-region scale caps each step at a KL budget of about 0.01.
- The worked 2 by 2 case gives with .