← All tutorials

Gradient Balancing Inside the Training Loop

What the loss, the optimizer, clipping and logging do to a balanced gradient.

In multitask training, each task has its own loss and gradient. A balancing method (a balancer) reweights the task losses or edits the task gradients so no task takes over the shared update. This page follows the balanced gradient through clipping, the optimizer and logging.

On this page

1 Balancing inside one training step 2 The six stages of a training step 3 Task gradients over the shared parameters 4 Where loss weighting and gradient methods act 5 Loss scale 6 Adam’s moment estimates 7 Adam’s per-coordinate scaling 8 Balancer guarantees and the optimizer 9 The optimizer and learned loss weights 10 Gradient clipping 11 Mixed precision 12 Micro-batches 13 What to log 14 References

1 Balancing inside one training step

A balancer acts once per training step, on that step’s batch. Loss-weighting methods (GradNorm, Uncertainty Weighting) change the task losses before the backward pass; gradient methods (PCGrad, GradVac, CAGrad, MGDA) replace the summed gradient after it (Figure 1).

A balancer can do exactly what it promises and the step still come out unbalanced, because the stages around it shape its input and its output.

2 The six stages of a training step

The shared layers, the trunk, are the part every task uses; each task also has its own head. K is the number of tasks.

1 · Forwardeach task computes its loss2 · Backwardper-task gradients, shared layers3 · Balancecombine the task gradients4 · Clipcap the length at max norm5 · Optimizer stepmomentum, per-coordinate scale6 · Logrecord what this step didloss-weighting methods act hereGradNorm also reads sizes at layer Wnew weightsgradient methods act herecoupled L2decoupled decay

Figure 1. Where each kind of method acts in one training step.

3 Task gradients over the shared parameters

The 2D arrows on these pages are pictures. A real task gradient has one number per shared weight, all shared layers flattened into one vector, often millions long; lengths, dot products and projections work on it as on the arrows.

shared trunk, weights θshhead 1head 2head 3L1L2L3each one a mean over the batch1one backward per task: one long vector eachg1g2g32combine: sum, PCGrad, MGDA, ...3glayer 1layer 2layer 3cut back into each shared layer's .grad4clip, then optimizer.step()5
Figure 2. 1 task losses, 2 one backward each, 3 combine, 4 write back.

Each loss is a batch mean (PyTorch’s default reduction="mean"), so gradients do not grow with batch size. One torch.autograd.grad call per task over the shared parameters returns its gradient without touching .grad; retain_graph=True keeps the graph for the next task. Every step costs K backward passes and K copies of the shared gradient.

MethodGradients taken overHow the result reaches the weights
PCGradall shared parameterswritten into the shared .grad
GradVacshared parameters, per layer in the paperwritten into the shared .grad
CAGradall shared parameterswritten into the shared .grad
MGDAall shared parameters, or only the trunk’s output (MGDA-UB)official code re-weights the losses and backpropagates again
GradNormonly the sizes, at the last shared layerlearned loss weights; the weighted loss is backpropagated
Uncertainty Weightingnonelearned loss weights; one ordinary backward

Writing into the shared .grad leaves each head its own plain gradient, the PCGrad paper’s rule and how LibMTL, a widely used library of these methods, writes back PCGrad and GradVac. Backpropagating weighted losses scales the heads’ gradients too.

This step writes into .grad, with a plain sum as the combine; vector_to_parameters would overwrite the weights, not their gradients.

import torch
from torch.autograd import grad
import torch.nn.utils as nnu

clip = nnu.clip_grad_norm_

KEEP = dict(retain_graph=True)
MAX_NORM = 1.0  # example clip cap

def flat_grad(loss, body):
    gs = grad(loss, body, **KEEP)
    flat = [g.ravel() for g in gs]
    return torch.cat(flat)  # (P,)

def fill_grads(params, vec):
    i = 0
    for p in params:  # unflatten
        n = p.numel()
        part = vec[i:i + n]
        p.grad = part.view_as(p)
        i += n

body = list(trunk.parameters())
head_ps = list(heads.parameters())

feats = trunk(x)
losses = []
for k in range(K):  # batch means
    pred = heads[k](feats)
    L = crits[k](pred, ys[k])
    losses.append(L)

rows = []
for L in losses:  # K backwards
    g_k = flat_grad(L, body)
    rows.append(g_k)
G = torch.stack(rows)  # (K, P)
g = G.sum(dim=0)  # combine here

opt.zero_grad()
fill_grads(body, g)
total = sum(losses)
total.backward(inputs=head_ps)
params = body + head_ps
pre_clip = clip(params, MAX_NORM)
opt.step()
  1. 1flat_grad flattens one task’s gradient; fill_grads writes a flat vector back into each .grad.
  2. 2The shared parameters and the head parameters, kept apart.
  3. 3Each task loss as a batch mean, marker 1 in Figure 2.
  4. 4K backward passes give the rows of G, marker 2.
  5. 5The combine, marker 3. A gradient method replaces this line.
  6. 6Write back into the trunk, and the heads’ plain gradients, marker 4.
  7. 7Clip and step, marker 5.

4 Where loss weighting and gradient methods act

Loss weights w act before the backward passes and change a task gradient’s length, never its direction; gradient methods act after and can turn it. The question each one answers:

MethodThe question it answers
GradNormHow much weight should each task's loss get so all tasks train at a similar rate? (learned during training from how fast each loss falls)
Uncertainty WeightingHow much weight should each task's loss get, given how noisy that task's targets are? (the noise level is learned along with the model)
PCGradHow should a task gradient be edited when it conflicts with another task's gradient?
GradVacHow should each pair of task gradients be moved toward a target cosine, a running average of that pair's past cosines?
CAGradHow can a step raise the smallest per-task improvement while staying near the average gradient?
MGDAWhich convex combination of task gradients lowers every task's loss at once, and when does no such combination exist?

5 Loss scale

A loss's scale, from its reduction (mean or sum over the batch) or a constant factor, changes the gradient's length, never its direction, and length is what balancers compare.

A constant in front of a loss multiplies its gradient by the same constant.
∇(c L) = c ∇L
The gradient grows by exactly the factor the loss was scaled by and keeps its direction: multiply the loss L by a constant c and ∇L comes out c times longer, pointing the same way. The constant can be a weight in front of the loss or the batch reduction, since a sum over n elements is n times the mean. Balancers compare gradient lengths, so changing c changes what they see even though no task's direction has moved.

These two loss functions (PyTorch calls them criteria) differ only in reduction; the sum’s gradient is n times longer for n elements.

crit_mean = nn.MSELoss()  # mean
kw = dict(reduction="sum")
crit_sum = nn.MSELoss(**kw)
Task 1's loss averageddrawn 4 times largertask 1task 2true size Summed over 10 itemstask 1, ten times longertask 2
Figure 3. Summing keeps task 1’s direction but makes it ten times longer.

Loss-weighting methods undo a constant scale once their weights adapt. Gradient methods feel it directly.

1×
task 1's gradient length (log scale)
task 1 task 2
GradNorm, weights adapted PCGrad MGDA

Figure 4. A larger task 1 loss turns PCGrad and MGDA but not GradNorm.

6 Adam’s moment estimates

Plain SGD (stochastic gradient descent) steps against the gradient times the learning rate. Adam keeps two running averages for each coordinate of the gradient; each coordinate is one weight.

Adam turns the balanced gradient g into a step in three lines: a running average of g, a running average of g squared, and their ratio.
mt = β1 mt−1 + (1 − β1) gt
The first moment m, Adam's momentum, is a running average of past balanced gradients g: each step it keeps β1 of its old value and mixes in 1 − β1 of the new g. With β1 at 0.9 it spans about 10 steps, one over (1 − β1), so this step's g gets 10 percent of the weight. A larger β1 smooths more noise but lags further behind when the balanced direction turns.
vt = β2 vt−1 + (1 − β2) gt2
The second moment v is the same kind of average for the squared gradient, taken coordinate by coordinate, so it tracks how large each coordinate's gradient usually is. With β2 at 0.999 it spans about 1000 steps, a hundred times longer than m, so it changes slowly; a larger β2 makes the per-coordinate scale slower still, a smaller one lets it follow each step’s gradient. 0.9 and 0.999 are the Adam paper’s suggested values and PyTorch’s defaults.
Δθ = −η m̂t√v̂t + ε
The step Δθ is the learning rate η times the momentum divided by the per-coordinate scale, the square root of v̂. The division is done coordinate by coordinate, so a coordinate whose past gradients were small gets a larger step than its gradient alone would give, and that is how the step can tilt off the balanced direction. The hats mark Adam's early-step correction, and ε is a tiny number that avoids dividing by zero.

The ten-step average smooths noise but lags: after a 90 degree turn the momentum takes 7 steps to turn 45 degrees, while Adam's step can turn in 1, onto a coordinate whose gradient had been small.

6.1 Momentum after a change of direction

At β1 = 0 the momentum keeps only this step; at 0.5 it spans about 2 steps, at 0.9 about 10 and at 0.99 about 100.

First-moment rate β1
remembers about 10 steps

Figure 5. The noise-free line turns at the tick; a larger β1 delays the momentum. this frame is 19 steps later.

7 Adam’s per-coordinate scaling

The per-coordinate division cancels some of the balancer's changes, delays others, and tilts every step.

7.1 Steady and sudden changes of gradient scale

A steady factor multiplying every gradient leaves Adam's step unchanged.
c m̂t√(c2 v̂t) = m̂t√v̂t
The factor cancels in the ratio: multiply every gradient by a steady positive c, and the momentum grows by c while the per-coordinate scale grows by √c2 = c, so the step does not change (ignoring ε). This holds only while c stays steady over the second moment's 1000 or so steps. A sudden change reaches the momentum within about 10 steps but the per-coordinate scale only slowly, so for a while the step grows or shrinks with it.

A change in the task mix is not a common factor: Adam’s step turns with it, then turns back as each coordinate’s scale catches up. Below, task 1’s gradient triples, with fixed gradients (3, 1) and (−1, 2) summed and Adam at PyTorch’s defaults.

Optimizer, time after the changeTurn of the step
SGD, at once and afterwardsabout 24 degrees
Adam, 10 steps laterabout 18 degrees
Adam, 500 steps laterabout 4 degrees
Adam, 1000 steps laterabout 2 degrees
Ten times larger from the start10 ×1 ×plain SGD stepAdam's stepsame at 1 × and 10 × The task mix changesbeforebeforeand laterplain SGD stepAdam's stepturns, then back
Figure 6. Adam cancels a steady scale and fades a mix change; SGD follows both.

7.2 Tilt of Adam’s step

In a two-bowl example, two tasks share two weights; each loss is a bowl, lowest at (3, 0) and (0, 3), ten times steeper along one weight. PCGrad balances them; SGD and Adam run at learning rate 0.01 for 20,000 steps. The Pareto set holds the points where neither loss can improve without the other getting worse.

SGDAdam
Angle of the step off the balanced direction (median, far from the Pareto set)0 degreesabout 8 degrees, almost all from the per-coordinate scale
Task losses at step 20,0004.03 and 4.153.66 and 4.61
task 1's besttask 2's beststartPareto settask 1 losstask 2 lossstep 301, far awaybalanceddirectionAdam'sstepSGD endsAdam endsweight 1 →weight 2 →
SGD Adam
Figure 7. Adam's tilted steps settle at a different point of the Pareto set than SGD's.

8 Balancer guarantees and the optimizer

PCGrad's and MGDA's guarantees cover the direction they hand over, for one batch; momentum and the per-coordinate scale change the step after.

8.1 Momentum and the component PCGrad removes

Momentum, here as in PyTorch's SGD (Optimizers from SGD to Adam), carries the past into every step.

Momentum adds this step's PCGrad output to a decayed copy of the last momentum.
m2 = β m1 + g2PC
The momentum m2 is β, here 0.9, times last step's momentum m1 plus this step's PCGrad output g2PC. PCGrad edits only g2PC, so whatever m1 already holds passes through: if m1 holds a part of task 1's gradient that points against task 2's gradient, removing it from this step's gradient does not remove it from m1. That part shrinks by a factor β each step, so it lingers for about 10 steps.
task 2's gradientlast step'sPCGrad outputthis step'sPCGrad outputmomentumthe part PCGrad removed
Figure 8. PCGrad removed the conflict, yet the momentum still leans against task 2.

Training still converges: for two smooth, strongly bowl-shaped losses with exact gradients and tuned step sizes, the PCGrad paper proves (Theorem 4, Appendix A.4) that PCGrad with heavy-ball momentum reaches either the optimum or a point where the two gradients point exactly opposite.

8.2 MGDA’s gradient norm as a stopping signal

Near the Pareto front, the task losses of the Pareto set from Section 7.2 plotted against each other, MGDA’s combined gradient stops shrinking once it reaches the size of the batch-to-batch noise in the gradients, instead of going to zero. Below, the step size over the last 1000 of 6000 steps as a share of the first, in the two-bowl example with gradient noise (5 runs, learning rate 0.02).

OptimizerStep size near the front
SGD2 to 5 percent
Adam13 to 19 percent

Watch the per-task losses to decide when to stop, not a gradient size.

9 The optimizer and learned loss weights

The balancer's own learned numbers, GradNorm's task weights and Uncertainty Weighting's noise levels σ, meet the optimizer too.

9.1 Renormalizing GradNorm’s weights

The GradNorm paper renormalizes after every step “to decouple gradient normalization from the global learning rate”; without it the weights’ sum drifts and acts as a learning-rate change. The weights get their own optimizer opt_w, in the paper Adam at 0.025 against 2×10−5 for the network, about 1250 times larger. In the fragment, Lgrad trains only w; the last two lines rescale w to sum to K inside no_grad, so the rescale is not trained.

# GradNorm's own loss L_grad,
# backpropagated into w only
L_grad.backward(inputs=[w])
opt_w.step()
opt_w.zero_grad()
# rescale so the weights sum to K
with torch.no_grad():
    w.mul_(K / w.sum())

9.2 Weight decay

Weight decay shrinks the weights a little each step inside opt.step(), after the balancer wrote the gradient, so the balancer never sees it; a larger wd shrinks them faster. Adam adds wd times the weights to the gradient before m and v; AdamW shrinks the weights directly, by the learning rate times wd, outside m and v.

import torch.optim as optim

kw = dict(lr=lr, weight_decay=wd)
# coupled L2: wd * weights joins
# the gradient before m and v
opt = optim.Adam(params, **kw)
# decoupled: shrinks the weights
opt = optim.AdamW(params, **kw)

An L2 penalty written into a task's loss does pass through the balancer. Decay on Uncertainty Weighting's log σ pulls each task's weight toward its value at σ = 1, which is 0.5 for regression and 1 for classification.

9.3 Uncertainty and the effective learning rate

Under SGD, a regression task's effective learning rate is the learning rate times its loss weight.
ηk = η2 σk2
Task k's effective learning rate ηk, how far one step moves the weights for that task's error, is the learning rate η times its loss weight 1/(2σk²). The noise level enters squared and in the denominator, so halving σk makes ηk four times larger. As σk falls during training, ηk rises, with or without momentum.

The Uncertainty Weighting paper (three street-scene tasks, SGD with momentum) saw all three uncertainties fall and lowered the learning rate on a schedule. Under Adam, the per-coordinate scale on a task's head largely absorbs a slow weight change.

10 Gradient clipping

Global-norm clipping shrinks the whole gradient by one factor when it is too long.
g ← g · min(1, τ‖g‖)
Clipping leaves a short gradient alone and shrinks a long one to the max norm τ: the factor min(1, τ/‖g‖) is 1 while ‖g‖ is at most τ, and τ/‖g‖ above it. One factor multiplies every number in g, so its direction, and the balancing, survive; only its length changes.

A smaller τ clips more steps and shortens them more; a very large τ never clips. PyTorch has no default; it is set per model. The three blocks clip globally, per task and per value, reusing clip, nnu, G and params from Section 3; only the first scales the whole gradient by one factor.

# tau: the max norm
# global: direction kept
pre_clip = clip(params, tau)

# per task, before the combine
for k in range(K):
    f = tau / G[k].norm()
    G[k] *= f.clamp(max=1.0)

# per value: direction turns
nnu.clip_grad_value_(params, cv)
  1. 1Global clipping, the eq card in Section 10.
  2. 2Per-task clipping of each row of G before the combine.
  3. 3Per-value clipping of each coordinate.
Global clipshorter, same directionclippedunclippedlimit 2.3 Per-task clipthe result turnstask 1,cappedtask 27.8°limit 2.3 Per-value clipthe result turns11.4°limit 2.3
Figure 9. Global clipping only shortens the step; the other two turn it.

11 Mixed precision

Mixed precision runs most arithmetic in 16-bit floats; to stop small gradients rounding to zero, it multiplies every loss by one scale s. Every task carries the same s, so no balancer's direction changes.

When a scaled gradient overflows, the scaler skips the step; a balancer with its own state (GradNorm’s weights, GradVac’s targets) should update it only on steps not skipped. Any size compared with a fixed number, such as the max norm or a logged size, is first divided by s; unscale_ does that for .grad. Below is the Section 3 step with a GradScaler.

scaler = torch.amp.GradScaler()
rows = []
for L in losses:  # s times big
    big = scaler.scale(L)
    g_k = flat_grad(big, body)
    rows.append(g_k)
G = torch.stack(rows)
s = scaler.get_scale()
sizes = G.norm(dim=1) / s  # true
opt.zero_grad()
fill_grads(body, G.sum(dim=0))
total = scaler.scale(sum(losses))
total.backward(inputs=head_ps)
scaler.unscale_(opt)  # .grad / s
pre_clip = clip(params, MAX_NORM)
scaler.step(opt)  # skips inf, NaN
scaler.update()
skipped = scaler.get_scale() < s
  1. 1Task gradients of the scaled losses, each s times too large.
  2. 2True gradient sizes, divided by s.
  3. 3Write back and the heads’ gradients, as in Section 3.
  4. 4Unscale, clip, step, and skipped for steps the scaler dropped.
Loss × scaleEvery task's gradient × scaleDirectionsratios and cosinesbetween tasksstay the sameSizesdivide by the scalebefore max norm, logsand GradNorm's sizesIf a gradient overflows, the stepis skipped and balancer state waits
Figure 10. One shared scale leaves every direction alone; only sizes need dividing back.

12 Micro-batches

A batch too big for memory is split into micro-batches, balanced in one of two orders: average the micro-batch gradients and balance once, or balance each and average. two_orders computes both; bal is any balancer mapping a (K, P) matrix of task gradients to one vector, such as pcgrad on the PCGrad page.

def two_orders(bal, parts):
    # parts: (K, P) per part
    stack = torch.stack(parts)
    g_once = bal(stack.mean(0))
    each = [bal(G) for G in parts]
    g_each = torch.stack(each)
    return g_once, g_each.mean(0)

Balancing is not linear, so with noise the two orders give steps about 7 degrees apart for PCGrad and 19 for MGDA (median, two-bowl example, 4 micro-batches, gradient noise about as large as the gradient). Averaging first and balancing once gives the balancer the same gradients a full batch would.

micro-batch Atask 1task 2micro-batch Bthe two routesbalance each part with PCGrad, then averageaverage the parts, then balance once
Figure 11. The two routes end in different places.

13 What to log

The weighted total loss hides all of this, so this block logs its parts every step. G, s, pre_clip and skipped come from the mixed precision step; writer is a torch.utils.tensorboard.SummaryWriter.

add = writer.add_scalar
true_G = G / s  # undo AMP scale
for k in range(K):
    t = f"task{k}"
    raw = raw_losses[k].item()
    add(f"loss/{t}", raw, step)
    size = true_G[k].norm().item()
    add(f"size/{t}", size, step)
    add(f"w/{t}", w[k], step)
g1, g2 = true_G[0], true_G[1]
den = g1.norm() * g2.norm()
cos = (g1 @ g2 / den).item()
add("cos/task0_task1", cos, step)
add("pre_clip", pre_clip, step)
add("amp_skipped", skipped, step)
  1. 1Per task, the raw loss before weighting, the true gradient size and the weight.
  2. 2The cosine similarity of tasks 0 and 1: 1 when they agree, 0 at a right angle, negative when opposed.
  3. 3The norm before clipping and whether the step was skipped.

Compare each raw loss with that task trained alone, and log how much each number varies, not only its average.

14 References

  1. Chen, Z., et al. (2018). GradNorm: Gradient Normalization for Adaptive Loss Balancing in Deep Multitask Networks. ICML. arXiv:1711.02257.
  2. Kendall, A., et al. (2018). Multi-Task Learning Using Uncertainty to Weigh Losses for Scene Geometry and Semantics. CVPR. arXiv:1705.07115.
  3. Sener, O., et al. (2018). Multi-Task Learning as Multi-Objective Optimization. NeurIPS. arXiv:1810.04650.
  4. Yu, T., et al. (2020). Gradient Surgery for Multi-Task Learning. NeurIPS. arXiv:2001.06782.
  5. Wang, Z., et al. (2021). Gradient Vaccine: Investigating and Improving Multi-task Optimization in Massively Multilingual Models. ICLR. arXiv:2010.05874.
  6. Liu, B., et al. (2021). Conflict-Averse Gradient Descent for Multi-task Learning. NeurIPS. arXiv:2110.14048.
  7. Kingma, D. P., et al. (2015). Adam: A Method for Stochastic Optimization. ICLR. arXiv:1412.6980.
  8. Lin, B., and Zhang, Y. (2023). LibMTL: A Python Library for Multi-Task Learning. JMLR. github.com/median-research-group/LibMTL.
  9. Loshchilov, I., et al. (2019). Decoupled Weight Decay Regularization. ICLR. arXiv:1711.05101.
  10. PyTorch documentation. Automatic Mixed Precision examples, and gradient clipping utilities.