The average step can help one task and hurt another.
Method
Each step, weight the tasks so the least-helped one gains most, and move the average gradient along that blend’s direction by a set fraction of its length.
Guarantee
While that fraction stays below 1, the average loss’s gradient provably shrinks toward zero.
Cost
One backward pass per task each step, plus a small solve over the task weights.
1 What CAGrad does each step
A task’s loss measures how badly the model performs that task; its gradient points where that loss rises fastest, and training steps against it.
Each training step, CAGrad takes the gradient gi of each task i over the shared weights, one entry per shared parameter, often millions (the figures draw two entries). It keeps training on the average loss and changes only the update’s direction, in three moves.
It searches a ball around the average gradient, the vectors within one radius of it. The radius is a set fraction of that gradient’s length, so the ball grows and shrinks with it (Section 2).
In the ball it looks for the update that most helps the least-helped task, the one whose loss drops least (Section 3).
That search comes down to choosing one weight per task, each zero or more and adding up to 1, so the least-helped task gains most. The weighted task gradients add up to the weighted gradient, and the update moves from the average gradient one radius along its direction (Section 4, Section 5).
CAGrad keeps no state between steps; the official CAGrad code restarts the weight solve from equal weights each step.
2 A ball around the average gradient
The average gradientg0, the mean of the task gradients, can raise one task’s loss.
Figure 1.g0 is over 90° from g2, so a step along it raises task 2’s loss.
Searching only the ball around g0 keeps the update near g0, so the average loss still falls. ‖g0‖ is the length of g0. The hyperparameter c sets the radius as a fraction of that length, so the radius is c‖g0‖.
At c = 0 CAGrad is plain gradient descent (GD); very large c gives the direction MGDA (Multiple Gradient Descent Algorithm) picks, the shortest weighted average of the task gradients. Raising c favors the least-helped task; lowering it stays near the average.
Figure 2.1c = 0 is plain GD, 2 CAGrad is in between, 3 very large c is MGDA.Figure 3. All sit below the dashed limit at 1. For Cityscapes the paper’s text gives 0.2 (hollow dot); its run script tries 0.4 and 0.6.
3 The worst-case improvement objective
A candidate direction d works like a gradient: parameters move against it.
Both pairs are illustrative, with c = 0.5. The NYUv2 pair is one network on indoor photos learning seg (semantic segmentation, a class per pixel) and depth (meters per pixel); the other is the pair Figure 4 draws. A task’s improvement is how much a small step against d lowers its loss.
CAGrad looks for the step that helps its least-helped task the most, without straying far from the average gradient.
maxdmini⟨gi, d⟩
For a small step against d, ⟨gi, d⟩ is approximately how much task i’s loss drops per unit of step size, its improvement. The angle brackets are the dot product: multiply the two vectors entry by entry and add. It is large when d points along gi, zero at right angles, and negative when the step raises task i’s loss. min over i keeps the smallest per-task improvement, the worst-case improvement. max over d picks the direction where it is largest.
s.t. ‖d − g0‖ ≤ c‖g0‖
s.t. means such that. The constraint keeps d within c‖g0‖ of g0, so d stays inside the ball. While c is below 1, every direction in the ball still lowers the average loss, to first order. If some direction in the ball lowers every task’s loss, CAGrad’s choice does too; with small c none may exist, and the least-helped task’s loss can rise.
So CAGrad trades a little average progress for the worst task’s sake: on NYUv2 the average step gives seg −2.5, so CAGrad searches the ball of radius 1.25 for a direction whose worst-case improvement is higher.
Best direction in the ball
Figure 4. Drag d to the ring, where the least-helped task gains most.
Worst-case improvement2.00
Task 1
4.50least helped
Task 2
2.00least helped
4 Solving for one weight per task
A direction d has millions of entries, so CAGrad solves its dual problem instead: the same answer, found by searching over one weight per task. The weighted sum of the task gradients is the weighted gradientgw.
Figure 5.1 Equal weights, 2 CAGrad’s weights, all on task 2, 3 any blend.
The weights solved for the two illustrative pairs of Section 3.
Instead of searching every direction, CAGrad solves for one weight per task and scores each weight list by the best value it allows.
gw = w1g1 + w2g2
This replaces the search over directions of Section 3. w1 and w2 are the task weights. Each is zero or more, and they add up to 1. So gw is a weighted average of the task gradients g1 and g2. The line shows two tasks, as in Figure 5. K is the number of tasks; in general the sum runs over all K.
w* = argminw∈ΔF(w)
F(w) is the largest value of ⟨gw, d⟩ over directions d in the ball. ⟨gw, d⟩ is a weighted average of the task improvements, so it is never below the worst one. So F(w) is an upper bound on the best worst-case improvement. Δ is the set of allowed weights, and w ∈ Δ (read ∈ as “is in”) says w is one of them. argmin picks the weights w* with the lowest upper bound. The paper shows this bound is reached, so w* also solves the search over directions.
F(w) = ⟨gw, g0⟩
At the ball’s center, the direction g0 gives gw the improvement ⟨gw, g0⟩. This term is small when the weights favor the task g0 helps least. So on its own it moves the weights toward that task.
+ c‖g0‖‖gw‖
Moving the full radius c‖g0‖ along gw adds the radius times the length of gw. This term favors short weighted gradients. As c grows it dominates, and w* approaches the weights of the shortest weighted gradient, MGDA’s choice.
So the weights say which task the step must lean toward: on NYUv2 all the weight goes to seg, the task the average step hurt, and F(w*) = 0.295.
G holds one task gradient per row. solve_weights takes the Gram matrixGG (the K×K table of dot products between task gradients) and c and returns w*.
import numpy as np
from scipy import optimize
def F(w, GG, c):
g0_len = np.sqrt(GG.mean())
center = w @ GG.mean(axis=1)
gw_sq = max(w @ GG @ w, 0.0)
gw_len = np.sqrt(gw_sq)
ball = c * g0_len * gw_len
return center + ball
CONSTRAINT = {"type": "eq", "fun": lambda w: w.sum() - 1}
def solve_weights(GG, c):
K = len(GG)
res = optimize.minimize(F, np.full(K, 1 / K), args=(GG, c), method="SLSQP",
bounds=[(0, 1)] * K, constraints=CONSTRAINT)
return res.x
g1 = [3.0, 1.0]
g2 = [-1.0, 2.0]
G = np.array([g1, g2]) # (K, P)
c = 0.5
w_star = solve_weights(G @ G.T, c)
1Computes F(w) from GG, lines 3 and 4 of the weight solve in Section 4; max stops rounding from going below zero.
2Keeps the weights adding up to 1, the set Δ in line 2 of that solve.
3Minimizes F from equal weights, each in [0, 1], the argmin in line 2.
4Uses the two tasks of Figure 5 and returns w* = (0, 1).
5 The CAGrad update
In the illustrative NYUv2 pair, w* puts all the weight on seg, so the blend is gseg = (2, 1), of direction (0.894, 0.447).
value
start, the average g0
(−2, 1.5)
one radius along seg
1.25 × (0.894, 0.447)
step d*
(−0.882, 2.059)
improvements, seg and depth
0.295 and 9.410
The step starts at the average gradient and moves one full radius along the solved blend’s direction.
d* = g0
w* is the weight solve of Section 4. d* is the update direction, the star marking a solved quantity. It starts at the ball’s center, g0.
+ c‖g0‖gw*‖gw*‖
The fraction is gw*, the weighted gradient built from w*, divided by its own length, so only its direction counts. Multiplied by the radius c‖g0‖, the step is exactly one radius long, so d* lands on the ball’s edge. This d* is the best point in Figure 4. The exception is a gw* of length zero, where the weighted task gradients cancel. That point is Pareto stationary: no small step lowers every task’s loss at once.
So the least-helped task goes from −2.5 to 0.295, and both improvements are now positive.
Continuing from the solver of Section 4, this code turns w* into d*.
g_w = w_star @ G # optimal g_w
g0 = G.mean(axis=0)
radius = c * np.linalg.norm(g0)
EPS = 1e-12 # zero-length guard
gw_len = np.linalg.norm(g_w)
unit = g_w / (gw_len + EPS)
d_star = g0 + radius * unit
1Builds gw* from w*, line 1 of the weight solve in Section 4.
2The center g0, line 1 of the update in Section 5, and the radius c‖g0‖, line 2.
3The fraction in line 2, guarded against length zero.
4One radius out to d*, the best point in Figure 4.
6 One CAGrad update, step by step
The walkthrough in Figure 6 uses the illustrative pair Figure 4 draws, g1 = (3, 1) and g2 = (−1, 2), with c = 0.5.
at the center
at d*
weights
equal
all on task 2
improvements
4.50 and 2.00
4.10 and 4.02
worst-case improvement
2.00
4.02
Figure 6. The four steps of one CAGrad update: find the least-helped task, draw the ball, solve the weights, move to the ball’s edge.
One CAGrad update finds the best worst-case direction inside the ball by solving one weight per task.
maxdmini⟨gi, d⟩
This is the objective of Section 3. Each direction d gets the smaller of its two task improvements; the largest such value wins.
s.t. ‖d − g0‖ ≤ c‖g0‖
The shaded region is the ball of radius c‖g0‖ around g0, with c = 0.5 here.
gw = w1g1 + w2g2
This starts the weight solve of Section 4. The bars show the weights w1 and w2, which add up to 1.
w* = argminw∈ΔF(w)
Solving moves all the weight onto task 2, and the worst-case improvement readout rises to its highest value.
F(w) = ⟨gw, g0⟩
This term is the improvement gw gets at the ball’s center.
+ c‖g0‖‖gw‖
This term adds the radius times the length of gw.
d* = g0
This is the update of Section 5. The arrow starts at the center g0.
+ c‖g0‖gw*‖gw*‖
It moves one radius along the direction of g2 and ends on the ball’s edge at d*.
So the walkthrough runs the four steps as one update, and the worst-case improvement rises from 2.00 to 4.02.
d* the update directiong0 the average gradientc the radius as a fraction of ‖g0‖w1, g1 task 1’s weight and gradientw2, g2 task 2’s weight and gradient⟨gi, d⟩ task i’s improvement along d
7 Convergence guarantee
CAGrad provably reaches a stationary point of the average loss (zero gradient, possibly a saddle point), assuming H-Lipschitz gradients (the gradient changes at most H times as fast as the parameters), a loss bounded below, and step size α ≤ 1/H.
Take c = 0.5, the value in the worked example. The convergence bound has 1 − c² in its denominator, and 1 − 0.5² = 0.75 where plain gradient descent (c = 0) has 1. So CAGrad’s bound is 1 ÷ 0.75 = 1.333 times plain gradient descent’s.
Summed over all steps, the average gradient’s squared length stays below a fixed bound, so CAGrad still converges on the average loss.
Σt=0T ‖∇L0(θt)‖²
This sums the squared length of the average gradient while the update of Section 5 runs every step; c is the radius factor of Section 2. L0 is the average loss and θt the parameters at step t, so ∇L0(θt) is the average gradient at step t; the double bars squared give its squared length. Σ adds these over steps t = 0 to T.
≤ 2(L0(θ0) − L0*)α(1 − c²)
L0* is the lowest the average loss can go. Only c < 1 keeps the denominator positive. The bound stays fixed as T grows, so the average gradient’s length must shrink toward zero. A larger c raises the bound; that is the cost of favoring the least-helped task.
So caring for the least-helped task costs only a constant factor in convergence: in the c = 0.5 example the denominator holds 0.75 instead of 1, and the bound is 1.333 times plain gradient descent’s.
Other proofs are weaker. MGDA proves only a Pareto-stationary end point; PCGrad, which removes the part of each task gradient that conflicts with another task’s, has a proof that covers two tasks with convex (bowl-shaped) losses.
At c of 1 or more, the guarantee is gone. The update can then settle where the average loss’s gradient is not zero, at a point that is only Pareto stationary.
8 Toy trajectories in loss space
The paper’s toy has two parameters and two task losses, L1 and L2, run from five starts. Figures 7 to 9 plot loss space, one axis per task loss, with each start’s trail. The gray Pareto front holds the loss pairs where neither loss can drop without the other rising; the star is its lowest average loss.
Figure 7. Trails fade orange to yellow. GD stalls on 2 of 5 starts; MGDA stops where it first meets the front. Panels 1 and 2 of the paper’s Figure 3.
Figure 8. PCGrad also stops at the front; CAGrad, c = 0.5, takes all five to the star. Panels 3 and 4 of the paper’s Figure 3.
Figure 9. From one hard start, c = 0 stalls; c = 10 and 0.2 to 0.8 reach the star. Panels 5 and 6 of the paper’s Figure 3.
9 Six methods on the toy problem
The race in Figure 10 runs six methods on the paper’s toy code: each loss has a narrow curved valley in the upper half and a bowl in the lower half, with a ridge between. The Pareto set holds the parameters whose losses lie on the front.
All six use Adam (each parameter’s step scaled by its recent gradient size) for 60,000 steps. GradVac moves task gradients that agree less than usual toward a target angle. GradNorm reweights the task losses so they train at similar rates. The race runs the official toy code. That code divides CAGrad’s step by 1 + c, and one sign in its second loss differs from the formula printed in the paper.
Start
Done at step 60,000.
second parameter, −10 to 10 →
first parameter, −10 to 10 →
Figure 10.1 The star, 2 GD’s end, 3 the Pareto set.
10 Results over five starts
Figure 11. Over all five starts, only GradVac and CAGrad always reach the star.
Of the two, only CAGrad has a convergence proof (Section 7). This GradNorm run is approximate, since GradNorm divides each loss by its starting value and these toy losses go below zero.
11 When to use CAGrad, and its cost
Use it when task gradients conflict and the average loss still matters.
Time per step is K backward passes plus the small solve. CAGrad-Fast keeps the guarantee while sampling a random task subset per step, 4 of 10 and 8 of 50 on Meta-World (Figure 12).
Memory holds K flattened shared gradients plus each task’s computation graph (what autograd keeps for a backward pass).
Figure 12. CAGrad-Fast’s speedup over PCGrad grows with the number of tasks.
12 CAGrad in a PyTorch training step
Figure 13. CAGrad acts after the backward pass, before clipping and the optimizer step.
The listing stacks the task gradients into G (Section 3 of Gradient Balancing Inside the Training Loop shows how each is computed), calls solve_weights (Section 4), and writes d* into the .grad of body, the shared backbone’s parameters. heads, each task’s output layers, get the average loss’s gradient.
import torch
from torch.nn.utils import clip_grad_norm_
c = 0.4 # radius c (NYUv2)
EPS = 1e-12 # zero-length guard
def flat_grad(loss, body):
grads = torch.autograd.grad(loss, body, retain_graph=True)
return torch.cat([g.ravel() for g in grads])
def fill_grads(params, vec):
i = 0
for p in params:
n = p.numel()
part = vec[i:i + n]
p.grad = part.view_as(p)
i += n
def cagrad(losses, parts, opt):
body, heads = parts
K = len(losses)
rows = [flat_grad(L, body) for L in losses]
G = torch.stack(rows) # K x P
GG = (G @ G.T).cpu().numpy()
w_star = solve_weights(GG, c)
w = torch.tensor(w_star).to(G)
g0 = G.mean(dim=0)
g_w = w @ G
gw_len = g_w.norm()
unit = g_w / (gw_len + EPS)
radius = c * g0.norm()
d_star = g0 + radius * unit
opt.zero_grad()
fill_grads(body, d_star)
avg = sum(losses) / K
avg.backward(inputs=heads)
clip_grad_norm_(body + heads, max_norm=1.0)
opt.step()
1One task’s body gradient gi, line 1 of the objective in Section 3, as one row of G.
2Writes a flat vector into the .grad of body, which box 5 of Figure 13 reads.
3One backward pass per task, box 2 of Figure 13, stacked into G.
6d* to the body, the average loss’s gradient to the heads, clip, step (Figure 13).
Global-norm clipping rescales all gradients together when their combined length exceeds max_norm, keeping their direction. A larger max_norm clips less often, a huge one never. 1.0 is a common value, not from the CAGrad paper. Gradient Balancing Inside the Training Loop compares other clipping types.
Log each task’s unweighted loss and its improvement ⟨gi, d*⟩. The smallest marks the least-helped task.
Each kept graph costs memory.retain_graph=True keeps every task’s graph in memory; the final backward pass, for the heads, frees them all.
Many tasks need a different solver. For many tasks the official README suggests writing the weights as a softmax of free numbers (positive, adding up to 1) and taking a few SGD steps on them. The official Meta-World code does this, with 20 SGD steps per training step.
Implementations differ. The official NYUv2 code divides the update by 1 + c² and multiplies by K, the toy code divides by 1 + c, and LibMTL rescales by default (its rescale option defaults to 1); this page uses none. Under Adam a constant scale barely matters; under SGD it rescales the learning rate.
The official CAGrad repository holds the paper’s code; LibMTL, an open-source PyTorch multitask library, ships it as --weighting CAGrad.
13 Comparison with other multitask methods
Reweight the losses when task gradients differ in size; edit the direction when they conflict.