6. Custom Multi-Objective Loss with Configurable Weights
Problem
RLHF-style training pipelines rarely optimize a single loss. A typical step combines a task loss, a KL penalty against a reference policy, and maybe an auxiliary regularizer, each with its own weight that gets tuned independently.
The combining function itself is simple arithmetic, but it is easy to break the one property that actually matters: the combined scalar must still support .backward() flowing gradients back into every term that produced it, with none of the pieces accidentally detached along the way.
Source: src/6_weighted_multi_objective_loss.py
def combined_loss(losses: dict[str, torch.Tensor], weights: dict[str, float]) -> torch.Tensor: ...
>>> losses = {"task": torch.tensor(2.0), "kl": torch.tensor(0.5)}
>>> weights = {"task": 1.0, "kl": 0.1}
>>> combined_loss(losses, weights).item()
2.049999952316284
Step-by-Step Approach
- Initialize an accumulator to
None, not to a plain0.0or a freshtorch.tensor(0.0)— starting from the first real term keeps dtype/device consistent with whatever tensors were actually passed in. - Iterate the
lossesdict; for each name, multiply the loss tensor by its matching weight from theweightsdict. - Never call
.item(),float(...), or.detach()on any term along the way — any of those breaks the autograd graph for that term. - Accumulate by tensor addition (
total + term), which preserves the graph for every summed term simultaneously. - Handle the empty-dict edge case explicitly: return a plain scalar zero tensor rather than crashing or returning
None. - Sanity check: build two toy leaf tensors with
requires_grad=True, combine losses derived from each, call.backward()once on the result, and confirm both leaves received the expected gradient.
The key insight is that "weighted sum" is trivial arithmetic — the actual skill being tested is not breaking the autograd graph while doing it, which is exactly the kind of subtle bug that silently produces a model that trains but never actually receives gradient from one of its loss terms.
Reference solution
import torch
def combined_loss(losses: dict, weights: dict) -> torch.Tensor:
# start from None, not 0.0 or torch.tensor(0.0): a plain python/tensor
# zero would still work numerically here, but starting from the first
# real term keeps dtype/device inference correct and avoids an
# unnecessary extra add op for the common non-empty case
total = None
for name, loss in losses.items():
term = weights[name] * loss # weight * tensor keeps the graph attached
total = term if total is None else total + term
if total is None:
return torch.tensor(0.0)
return total
Key Functions & Tricks
weight * loss— multiplying a Python float by a tensor keeps the result a tensor with the graph intact; no manual wrapping needed.total = term if total is None else total + term— the standard "fold with a None seed" idiom, avoiding a spurious extra addition against a zero of the wrong dtype/device.torch.tensor(0.0)— the explicit fallback for the empty-input edge case, so the function always returns a tensor, neverNone.result.backward()then readingleaf.grad— the standard way to verify a combining function didn't silently detach one of its inputs.
How to Recognize This Pattern
Recognize this pattern whenever a training loop needs to merge several named loss terms with independently tunable weights — it shows up under "multi-task loss," "auxiliary loss," or "regularization term" framings just as often as under RLHF framing. Common variations normalize weights to sum to 1, apply a schedule that ramps a weight up over training steps, or require every key in losses to have a matching key in weights (raising KeyError on a mismatch, as this implementation does, rather than silently defaulting a missing weight to 0 or 1). A common pitfall is accumulating with a Python sum() or an f-string/print debugging call that implicitly calls .item() on an intermediate term, which silently converts that term to a plain float and drops it out of the gradient computation without raising any error.