Multi-Task Learning: One Backbone, Many Objectives

6 minute read

An autonomous vehicle needs a lot from a single camera frame: where the other road users are, which pixels are drivable, how far away everything is, where the lane boundaries run. Training a separate network per question is the simplest thing that works, and it is also the thing you cannot afford — the compute budget on the vehicle is fixed, and every model you add spends part of it re-extracting features that the other models already computed.

Multi-task learning takes the other route: one shared backbone, several lightweight heads. Beyond the obvious latency win, the tasks act as regularizers on each other. A representation good enough to predict depth is usually a better detection feature than one trained on boxes alone.

The catch is that you now have one number to descend on, and it is a sum of things that have no business being added together.

Shared backbone with per-task headsA camera frame feeds one shared backbone. Four lightweight heads branch from it — detection, segmentation, depth and lanes — and their losses are combined into a single weighted sum. Gradients from every head flow back through the shared trunk, which is where the tasks compete. Camera frame Shared backbone one forward pass, one compute budget Detection focal, ~1e-1 Segmentation CE, ~1e0 Depth L1 in m, ~1e1 Lanes CE, ~1e0 L = Σ wᵢ Lᵢ gradients from every head meet in the shared trunk
One trunk, several heads. Note the loss magnitudes on each head — they differ by two orders of magnitude before any weighting, and every one of those gradients flows back through the same shared parameters.

The combined loss

The usual formulation is a weighted sum over tasks:

\[\mathcal{L}(\theta) = \sum_{i=1}^{T} w_i \, \mathcal{L}_i(\theta)\]

Everything hard about multi-task learning lives in $w_i$.

The first problem is scale. A focal loss for classification settles around $10^{-1}$; an L1 depth loss in meters may start in the tens. Left alone, depth owns the gradient and classification is noise. So you tune the weights — and now you have $T-1$ extra hyperparameters that interact, on a model that takes a day to train.

The second problem is conflict, and it is the one that does not go away with tuning. Two tasks can want the same parameter to move in opposite directions. Writing $g_i = \nabla_\theta \mathcal{L}_i$, the tasks conflict when

\[g_i \cdot g_j < 0\]

No choice of positive scalar weights fixes a negative inner product. Scaling changes the magnitude of the disagreement, not its sign.

Gradient conflict and the PCGrad projectionOn the left, two task gradients point in partly opposing directions: their inner product is negative, and their sum is much shorter than either. On the right, the first gradient has been projected onto the plane normal to the second, removing the conflicting component so the two no longer cancel. CONFLICT AFTER PROJECTION gᵢ gⱼ gᵢ + gⱼ gᵢ · gⱼ < 0 — the sum is shorter than either gᵢ gⱼ gᵢ′ gᵢ′ · gⱼ = 0 — no cancellation
Left: two task gradients disagree, and their sum is shorter than either of them — the update barely moves. Right: removing the conflicting component leaves an update that helps one task without actively undoing the other.

Ways to set the weights

Uncertainty weighting (Kendall, Gal & Cipolla) learns them, by treating each task’s weight as a homoscedastic noise term:

\[\mathcal{L} = \sum_i \frac{1}{2\sigma_i^2}\mathcal{L}_i + \log\sigma_i\]

The $\log\sigma_i$ term is what keeps it honest — without it the optimizer would drive every $\sigma_i \to \infty$ and declare victory. In practice you parameterize $s_i = \log \sigma_i^2$ for stability:

class UncertaintyWeighting(nn.Module):
    def __init__(self, num_tasks):
        super().__init__()
        # s = log(sigma^2), learned jointly with the network
        self.s = nn.Parameter(torch.zeros(num_tasks))

    def forward(self, losses):
        total = 0.0
        for i, loss in enumerate(losses):
            total = total + 0.5 * torch.exp(-self.s[i]) * loss + 0.5 * self.s[i]
        return total

This handles scale well and costs almost nothing. It does not address gradient conflict.

GradNorm targets the training dynamics instead, tuning weights so that each task’s gradient magnitude at the shared layer stays proportional to how fast that task is still improving. Tasks that have converged get quieter; stragglers get louder.

PCGrad attacks conflict directly. When $g_i \cdot g_j < 0$, it projects one gradient onto the normal plane of the other before summing:

\[g_i \leftarrow g_i - \frac{g_i \cdot g_j}{\|g_j\|^2} g_j\]
def pcgrad(grads):
    """grads: list of flattened per-task gradients. Returns the summed update."""
    projected = []
    for i, g_i in enumerate(grads):
        g = g_i.clone()
        # project away the conflicting component of every other task, in random
        # order -- the result depends on order, so do not fix it
        for j in torch.randperm(len(grads)).tolist():
            if j == i:
                continue
            overlap = torch.dot(g, grads[j])
            if overlap < 0:                       # conflict, and only then
                g -= overlap / grads[j].norm().pow(2) * grads[j]
        projected.append(g)
    return torch.stack(projected).sum(dim=0)

It is more expensive — you need per-task gradients, not just per-task losses, which means $T$ backward passes or a batched trick — but it is the only one of the three that changes the direction of the update rather than its scale.

What actually bites in practice

Negative transfer is real, and it is not symmetric. Adding a task can make another one worse. Semantic segmentation and depth tend to help each other; a task with a very different label distribution often just steals capacity. Before assuming a task belongs in the model, train it alone and keep that number as the baseline you have to beat.

Watch per-task metrics, never the aggregate. Total loss going down is compatible with one head quietly collapsing. Plot each task separately and set a per-task regression gate in CI.

Datasets are rarely co-labelled. Most frames have boxes or masks, not both. You end up sampling per-batch by task, which means the effective weight on a task is w_i × (its share of batches) — two knobs pointing at the same thing. Fix the sampling ratio first, then tune weights.

Give heads their own learning rates. A head training from scratch on a backbone that is being fine-tuned wants a higher LR than the backbone does. This is a one-line change that often does more than a week of weight search.

Where I would start

For a new stack: uncertainty weighting, per-task learning rates, per-task metrics on the dashboard from day one. That combination gets you most of the way with very little machinery. Reach for GradNorm or PCGrad only once you can show a conflict — a per-task metric that is worse in the joint model than it was standalone. Otherwise you are adding complexity to solve a problem you have not confirmed you have.