Skip to content

Commit d759aed

Browse files
authored
feat(aggregation): Add IMTL-L (#725)
1 parent 9c4d562 commit d759aed

6 files changed

Lines changed: 218 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ changelog does not include internal changes that do not affect the user.
1010

1111
### Added
1212

13+
- Added `IMTL-L` (the loss-balancing variant of Impartial Multi-Task Learning) from [Towards
14+
Impartial Multi-Task Learning](https://openreview.net/pdf?id=IMPnRXEWpvr) (ICLR 2021), a stateful
15+
`Scalarizer` that learns a per-task scale `s_i` and combines the values as
16+
`Σ (exp(s_i) · L_i − s_i)`.
1317
- Added `UW` (Uncertainty Weighting) from [Multi-Task Learning Using Uncertainty to Weigh Losses
1418
for Scene Geometry and
1519
Semantics](https://openaccess.thecvf.com/content_cvpr_2018/papers/Kendall_Multi-Task_Learning_Using_CVPR_2018_paper.pdf),
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
:hide-toc:
2+
3+
IMTL-L
4+
======
5+
6+
.. autoclass:: torchjd.scalarization.IMTLL
7+
:members: __call__, reset

docs/source/docs/scalarization/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Abstract base class
1616

1717
constant.rst
1818
geometric_mean.rst
19+
imtl_l.rst
1920
mean.rst
2021
random.rst
2122
stch.rst

src/torchjd/scalarization/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
from ._constant import Constant
2323
from ._geometric_mean import GeometricMean
24+
from ._imtl_l import IMTLL
2425
from ._mean import Mean
2526
from ._random import Random
2627
from ._scalarizer_base import Scalarizer
@@ -31,6 +32,7 @@
3132
__all__ = [
3233
"Constant",
3334
"GeometricMean",
35+
"IMTLL",
3436
"Mean",
3537
"Random",
3638
"Scalarizer",
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
from collections.abc import Sequence
2+
3+
import torch
4+
from torch import Tensor, nn
5+
6+
from torchjd._mixins import Stateful
7+
8+
from ._scalarizer_base import Scalarizer
9+
10+
11+
class IMTLL(Scalarizer, Stateful):
12+
r"""
13+
:class:`~torchjd.Stateful`
14+
:class:`~torchjd.scalarization.Scalarizer` that combines the input tensor of values using learned
15+
per-task scales. ``IMTL-L`` is the loss-balancing variant of Impartial
16+
Multi-Task Learning, proposed in `Towards Impartial Multi-Task Learning
17+
<https://openreview.net/pdf?id=IMPnRXEWpvr>`_.
18+
19+
Each value :math:`L_i` is assigned a learnable scale parameter :math:`s_i`, and the values are
20+
combined as
21+
22+
.. math::
23+
\sum_i \left( e^{s_i} L_i - s_i \right)
24+
25+
where:
26+
27+
- :math:`L_i` is the :math:`i`-th value (typically the loss of task :math:`i`);
28+
- :math:`s_i` is the learnable scale parameter of task :math:`i`.
29+
30+
The factor :math:`e^{s_i}` rescales each loss so that the scaled losses stay at a comparable
31+
magnitude across tasks, while the :math:`- s_i` term is a regularizer that prevents the trivial
32+
solution :math:`s_i \to -\infty`. The :math:`s_i` are stored as an ``nn.Parameter``, so the
33+
parameters of this scalarizer must be passed to the optimizer to be learned jointly with the
34+
model.
35+
36+
Although it is derived without any distribution assumption (unlike
37+
:class:`~torchjd.scalarization.UW`, which is derived from Gaussian/Laplace likelihoods), IMTL-L
38+
is in fact almost equivalent to :class:`~torchjd.scalarization.UW`: this scalarization equals
39+
:math:`2\,\mathrm{UW}` evaluated at the negated parameter, so the two differ only by a constant
40+
factor of two and the sign convention of the learned parameter, and share the same per-task
41+
weighting and the same optima.
42+
43+
The complementary gradient-balancing variant (IMTL-G) is provided as the
44+
:class:`~torchjd.aggregation.IMTLG` aggregator.
45+
46+
:param shape: The shape of the values to scalarize, used to create one scale per value. An
47+
``int`` ``n`` is interpreted as the shape ``(n,)``.
48+
49+
The following example shows how to train a model with Impartial Multi-Task Learning (loss
50+
balance), as described in the paper.
51+
52+
>>> import torch
53+
>>> from torch.nn import Linear
54+
>>>
55+
>>> from torchjd.scalarization import IMTLL
56+
>>>
57+
>>> model = Linear(3, 2)
58+
>>> scalarizer = IMTLL(2) # Move to the right device with e.g. IMTLL(2).to(device="cuda")
59+
>>> optimizer = torch.optim.SGD([*model.parameters(), *scalarizer.parameters()], lr=0.1)
60+
>>>
61+
>>> features = torch.randn(8, 3)
62+
>>> # Compute some dummy losses just for the sake of the example
63+
>>> losses = model(features).pow(2).mean(dim=0) # One loss per output dimension.
64+
>>> loss = scalarizer(losses)
65+
>>> loss.backward()
66+
>>> optimizer.step()
67+
68+
.. note::
69+
The scales are initialized to ``0``, so at the start of training the scalarization reduces to
70+
the plain sum of the values (since :math:`e^0 = 1`). Following the paper, IMTL-L is designed
71+
to balance positive losses.
72+
"""
73+
74+
def __init__(self, shape: int | Sequence[int]) -> None:
75+
super().__init__()
76+
self.log_scale = nn.Parameter(torch.zeros(shape))
77+
78+
def forward(self, values: Tensor, /) -> Tensor:
79+
if values.shape != self.log_scale.shape:
80+
raise ValueError(
81+
f"Parameter `values` should have shape {tuple(self.log_scale.shape)} (matching the "
82+
f"shape of the scales). Found `values.shape = {tuple(values.shape)}`.",
83+
)
84+
return (torch.exp(self.log_scale) * values - self.log_scale).sum()
85+
86+
def reset(self) -> None:
87+
with torch.no_grad():
88+
self.log_scale.zero_()
89+
90+
def __repr__(self) -> str:
91+
return f"{self.__class__.__name__}(shape={tuple(self.log_scale.shape)})"
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
from contextlib import nullcontext as does_not_raise
2+
3+
import torch
4+
from pytest import mark, raises
5+
from settings import DEVICE, DTYPE
6+
from torch import Tensor
7+
from utils.contexts import ExceptionContext
8+
from utils.tensors import ones_, tensor_, zeros_
9+
10+
from torchjd.scalarization import IMTLL, UW
11+
12+
from ._asserts import assert_grad_flow, assert_returns_scalar
13+
from ._inputs import all_inputs
14+
15+
16+
def _imtl_l(shape: int | tuple[int, ...]) -> IMTLL:
17+
"""Builds an `IMTLL` whose scales live on the test device and dtype."""
18+
return IMTLL(shape).to(device=DEVICE, dtype=DTYPE)
19+
20+
21+
def test_value() -> None:
22+
# With scales initialized to 0, exp(0)=1 and -0=0, so the result is sum(values).
23+
values = tensor_([1.0, 2.0, 4.0])
24+
torch.testing.assert_close(_imtl_l((3,))(values), tensor_(7.0))
25+
26+
27+
def test_int_shape_matches_tuple_shape() -> None:
28+
values = tensor_([1.0, 2.0, 4.0])
29+
assert IMTLL(3).log_scale.shape == (3,)
30+
torch.testing.assert_close(_imtl_l(3)(values), _imtl_l((3,))(values))
31+
32+
33+
@mark.parametrize("values", all_inputs)
34+
def test_expected_structure(values: Tensor) -> None:
35+
assert_returns_scalar(_imtl_l(tuple(values.shape)), values)
36+
37+
38+
@mark.parametrize("values", all_inputs)
39+
def test_grad_flow(values: Tensor) -> None:
40+
assert_grad_flow(_imtl_l(tuple(values.shape)), values)
41+
42+
43+
@mark.parametrize("values", all_inputs)
44+
def test_grad_flows_to_log_scale(values: Tensor) -> None:
45+
scalarizer = _imtl_l(tuple(values.shape))
46+
scalarizer(values).backward()
47+
assert scalarizer.log_scale.grad is not None
48+
assert scalarizer.log_scale.grad.isfinite().all()
49+
50+
51+
@mark.parametrize(
52+
["param_shape", "values_shape", "expectation"],
53+
[
54+
((5,), (5,), does_not_raise()),
55+
((3, 4), (3, 4), does_not_raise()),
56+
((), (), does_not_raise()),
57+
((5,), (4,), raises(ValueError)),
58+
((5,), (5, 1), raises(ValueError)),
59+
((3, 4), (4, 3), raises(ValueError)),
60+
],
61+
)
62+
def test_shape_check(
63+
param_shape: tuple[int, ...],
64+
values_shape: tuple[int, ...],
65+
expectation: ExceptionContext,
66+
) -> None:
67+
scalarizer = _imtl_l(param_shape)
68+
values = ones_(values_shape)
69+
with expectation:
70+
_ = scalarizer(values)
71+
72+
73+
def test_reset_restores_initial_log_scale() -> None:
74+
scalarizer = _imtl_l((3,))
75+
with torch.no_grad():
76+
scalarizer.log_scale.add_(1.0)
77+
scalarizer.reset()
78+
torch.testing.assert_close(scalarizer.log_scale.detach(), zeros_((3,)))
79+
80+
81+
def test_does_not_raise_on_negative_input() -> None:
82+
# IMTL-L is designed for positive losses but does not enforce a positivity precondition.
83+
values = tensor_([-1.0, -2.0, 3.0])
84+
assert_returns_scalar(_imtl_l((3,)), values)
85+
86+
87+
def test_is_trainable() -> None:
88+
scalarizer = _imtl_l((2,))
89+
optimizer = torch.optim.SGD(scalarizer.parameters(), lr=0.1)
90+
values = tensor_([2.0, 5.0])
91+
optimizer.zero_grad()
92+
scalarizer(values).backward()
93+
optimizer.step()
94+
assert not torch.equal(scalarizer.log_scale.detach(), zeros_((2,)))
95+
96+
97+
def test_equivalent_to_uw_up_to_factor_and_sign() -> None:
98+
# Locks the documented relationship: IMTL-L(s) == 2 * UW(-s), i.e. the two scalarizations are
99+
# equal up to a constant factor of 2 and the sign of the learned parameter.
100+
values = tensor_([0.5, 2.0, 4.0])
101+
imtl_l = _imtl_l((3,))
102+
uw = UW((3,)).to(device=DEVICE, dtype=DTYPE)
103+
with torch.no_grad():
104+
s = tensor_([0.3, -0.7, 1.2])
105+
imtl_l.log_scale.copy_(s)
106+
uw.log_var.copy_(-s)
107+
torch.testing.assert_close(imtl_l(values), 2.0 * uw(values))
108+
109+
110+
def test_representations() -> None:
111+
assert repr(IMTLL(3)) == "IMTLL(shape=(3,))"
112+
assert repr(IMTLL((2, 3))) == "IMTLL(shape=(2, 3))"
113+
assert str(IMTLL(3)) == "IMTLL"

0 commit comments

Comments
 (0)