Skip to content

Commit 01e8599

Browse files
authored
feat(scalarization): Add DWA (#731)
1 parent 3e5b88c commit 01e8599

6 files changed

Lines changed: 305 additions & 0 deletions

File tree

CHANGELOG.md

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

1313
### Added
1414

15+
- Added `DWA` (Dynamic Weight Average) from [End-to-End Multi-Task Learning with
16+
Attention](https://openaccess.thecvf.com/content_CVPR_2019/papers/Liu_End-To-End_Multi-Task_Learning_With_Attention_CVPR_2019_paper.pdf)
17+
(CVPR 2019), a stateful `Scalarizer` that weights each value by the relative rate at which its
18+
loss decreased over the two previous epochs. It has no learnable parameters; call its `step()`
19+
method once per epoch to roll the loss history.
1520
- Added `SDMGradWeighting` from [Direction-oriented Multi-objective Learning: Simple and Provable Stochastic Algorithms](https://arxiv.org/pdf/2305.18409) (NeurIPS 2023). It is a stateful `Weighting` that solves for task weights via a simplex-projected inner loop on a cross-batch matrix `A = J_1 @ J_2.T` (computed from two independent mini-batches using `autojac.jac`), with a direction-oriented regularizer pulling the descent direction toward a preference direction.
1621
- Added `IMTL-L` (the loss-balancing variant of Impartial Multi-Task Learning) from [Towards
1722
Impartial Multi-Task Learning](https://openreview.net/pdf?id=IMPnRXEWpvr) (ICLR 2021), a stateful
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
:hide-toc:
2+
3+
DWA
4+
===
5+
6+
.. autoclass:: torchjd.scalarization.DWA
7+
:members: __call__, step, reset

docs/source/docs/scalarization/index.rst

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

1717
constant.rst
18+
dwa.rst
1819
geometric_mean.rst
1920
imtl_l.rst
2021
mean.rst

src/torchjd/scalarization/__init__.py

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

2222
from ._constant import Constant
23+
from ._dwa import DWA
2324
from ._geometric_mean import GeometricMean
2425
from ._imtl_l import IMTLL
2526
from ._mean import Mean
@@ -31,6 +32,7 @@
3132

3233
__all__ = [
3334
"Constant",
35+
"DWA",
3436
"GeometricMean",
3537
"IMTLL",
3638
"Mean",

src/torchjd/scalarization/_dwa.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import torch
2+
from torch import Tensor
3+
from torch.nn.functional import softmax
4+
5+
from torchjd._mixins import Stateful
6+
7+
from ._scalarizer_base import Scalarizer
8+
9+
10+
class DWA(Scalarizer, Stateful):
11+
r"""
12+
:class:`~torchjd.Stateful`
13+
:class:`~torchjd.scalarization.Scalarizer` that combines the input tensor of values using Dynamic
14+
Weight Average (DWA), proposed in `End-to-End Multi-Task Learning with Attention
15+
<https://openaccess.thecvf.com/content_CVPR_2019/papers/Liu_End-To-End_Multi-Task_Learning_With_Attention_CVPR_2019_paper.pdf>`_.
16+
17+
DWA weights each value by how quickly its loss has been decreasing relative to the others. At
18+
epoch :math:`t`, the current batch's values are combined as
19+
20+
.. math::
21+
\sum_k \lambda_k(t)\, \ell_k, \qquad
22+
\lambda_k(t) = \frac{K \exp(w_k(t-1) / T)}{\sum_i \exp(w_i(t-1) / T)}, \qquad
23+
w_k(t-1) = \frac{L_k(t-1)}{L_k(t-2)}
24+
25+
where:
26+
27+
- :math:`\ell_k` is the :math:`k`-th value being scalarized (typically the current batch's loss
28+
for task k);
29+
- :math:`L_k(t)` is the :math:`k`-th value averaged over epoch :math:`t` (used only for the
30+
weights);
31+
- :math:`w_k(t-1)` is the relative descending rate: the ratio of average losses over the two
32+
previous epochs;
33+
- :math:`T` is the temperature; a larger :math:`T` makes the weights more uniform;
34+
- :math:`K` is the number of values (e.g. the number of tasks); the factor :math:`K` keeps
35+
:math:`\sum_k \lambda_k = K`.
36+
37+
The weights use only the two previous epochs' average losses, so they need no gradient. At each
38+
call, the scalarization is returned and the current batch's losses are summed to the current
39+
epoch's loss sums. :meth:`step` must then be called once at the end of each epoch to finalize
40+
that epoch's average loss and roll the history forward. During the first two epochs, before two
41+
averages are available, the weights are uniform.
42+
43+
:param temperature: The temperature :math:`T`. Must be strictly positive. Larger values make the
44+
weights more uniform. The paper uses ``2.0``.
45+
46+
The following example shows how to train a model with DWA. The scalarizer is called on every
47+
batch, and :meth:`step` is called once at the end of each epoch.
48+
49+
>>> import torch
50+
>>> from torch.nn import Linear
51+
>>>
52+
>>> from torchjd.scalarization import DWA
53+
>>>
54+
>>> model = Linear(3, 2)
55+
>>> scalarizer = DWA()
56+
>>> optimizer = torch.optim.SGD(model.parameters(), lr=0.1)
57+
>>>
58+
>>> for epoch in range(3):
59+
... for _ in range(4): # Iterate over the batches of the epoch.
60+
... features = torch.randn(8, 3)
61+
... losses = model(features).pow(2).mean(dim=0) # One loss per output dimension.
62+
... loss = scalarizer(losses)
63+
... optimizer.zero_grad()
64+
... loss.backward()
65+
... optimizer.step()
66+
... scalarizer.step() # Roll the epoch history once, at the end of the epoch.
67+
68+
.. note::
69+
DWA weights each value by the ratio of its losses over consecutive epochs, which the paper
70+
defines as a descending rate in the range :math:`(0, +\infty)`. The losses are therefore
71+
expected to keep a consistent, nonzero sign across epochs (they need not be positive).
72+
"""
73+
74+
def __init__(self, temperature: float = 2.0) -> None:
75+
if temperature <= 0.0:
76+
raise ValueError(
77+
f"Parameter `temperature` should be strictly positive. Found `temperature = "
78+
f"{temperature}`."
79+
)
80+
81+
super().__init__()
82+
self.temperature = temperature
83+
self._loss_sum: Tensor | None = None
84+
self._n_batches: int = 0
85+
self._previous_averages: list[Tensor] = []
86+
87+
def forward(self, values: Tensor, /) -> Tensor:
88+
weights = self._compute_weights(values)
89+
90+
detached = values.detach()
91+
if self._loss_sum is None:
92+
self._loss_sum = detached.clone()
93+
elif self._loss_sum.shape != detached.shape:
94+
raise ValueError(
95+
f"The shape of `values` changed from {tuple(self._loss_sum.shape)} to "
96+
f"{tuple(detached.shape)} within an epoch. Call `reset()` before changing it."
97+
)
98+
else:
99+
self._loss_sum = self._loss_sum + detached
100+
self._n_batches += 1
101+
102+
return (weights * values).sum()
103+
104+
def step(self) -> None:
105+
"""
106+
Finalizes the current epoch's average loss and rolls the history forward, discarding the
107+
average from two epochs ago. Should be called once at the end of each epoch.
108+
"""
109+
110+
if self._loss_sum is None:
111+
return
112+
average = self._loss_sum / self._n_batches
113+
self._previous_averages = [*self._previous_averages, average][-2:]
114+
self._loss_sum = None
115+
self._n_batches = 0
116+
117+
def reset(self) -> None:
118+
self._loss_sum = None
119+
self._n_batches = 0
120+
self._previous_averages = []
121+
122+
def _compute_weights(self, values: Tensor) -> Tensor:
123+
if len(self._previous_averages) < 2:
124+
return torch.ones_like(values)
125+
older = self._previous_averages[0]
126+
newer = self._previous_averages[1]
127+
if older.shape != values.shape:
128+
raise ValueError(
129+
f"The shape of `values` changed from {tuple(older.shape)} to "
130+
f"{tuple(values.shape)}. Call `reset()` before changing it."
131+
)
132+
rates = (newer / older).flatten()
133+
weights = softmax(rates / self.temperature, dim=0)
134+
return values.numel() * weights.reshape(values.shape)
135+
136+
def __repr__(self) -> str:
137+
return f"{self.__class__.__name__}(temperature={self.temperature})"
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import torch
2+
from pytest import mark, raises
3+
from torch import Tensor
4+
from utils.tensors import ones_, tensor_
5+
6+
from torchjd.scalarization import DWA
7+
8+
from ._asserts import assert_grad_flow, assert_returns_scalar
9+
from ._inputs import all_inputs
10+
11+
12+
def test_uniform_weights_for_first_two_epochs() -> None:
13+
dwa = DWA(temperature=2.0)
14+
# Epoch 1: no completed epoch yet, so weights are uniform (sum).
15+
torch.testing.assert_close(dwa(tensor_([1.0, 3.0])), tensor_(4.0))
16+
dwa.step()
17+
# Epoch 2: only one completed epoch, so weights are still uniform (sum).
18+
torch.testing.assert_close(dwa(tensor_([2.0, 5.0])), tensor_(7.0))
19+
dwa.step()
20+
21+
22+
def test_weights_from_previous_two_epochs() -> None:
23+
dwa = DWA(temperature=2.0)
24+
dwa(tensor_([1.0, 1.0]))
25+
dwa.step() # Epoch 1 average = [1, 1].
26+
dwa(tensor_([1.0, 4.0]))
27+
dwa.step() # Epoch 2 average = [1, 4].
28+
# Epoch 3: rates = [1, 4] / [1, 1] = [1, 4].
29+
losses = tensor_([3.0, 5.0])
30+
result = dwa(losses)
31+
expected_weights = 2.0 * torch.softmax(tensor_([1.0, 4.0]) / 2.0, dim=0)
32+
torch.testing.assert_close(result, (expected_weights * losses).sum())
33+
34+
35+
def test_uses_per_epoch_average() -> None:
36+
# The weights use the average loss over each epoch's batches, not just the last batch.
37+
dwa = DWA(temperature=2.0)
38+
dwa(tensor_([2.0, 2.0]))
39+
dwa(tensor_([0.0, 0.0]))
40+
dwa.step() # Epoch 1 average = [1, 1].
41+
dwa(tensor_([2.0, 6.0]))
42+
dwa(tensor_([0.0, 2.0]))
43+
dwa.step() # Epoch 2 average = [1, 4].
44+
losses = tensor_([3.0, 5.0])
45+
result = dwa(losses)
46+
expected_weights = 2.0 * torch.softmax(tensor_([1.0, 4.0]) / 2.0, dim=0)
47+
torch.testing.assert_close(result, (expected_weights * losses).sum())
48+
49+
50+
def test_step_discards_oldest_epoch() -> None:
51+
dwa = DWA(temperature=2.0)
52+
dwa(tensor_([9.0, 9.0]))
53+
dwa.step() # Epoch 1 average = [9, 9]; should be discarded after epoch 3.
54+
dwa(tensor_([1.0, 1.0]))
55+
dwa.step() # Epoch 2 average = [1, 1].
56+
dwa(tensor_([1.0, 4.0]))
57+
dwa.step() # Epoch 3 average = [1, 4].
58+
# Epoch 4 uses only epochs 2 and 3: rates = [1, 4] / [1, 1] = [1, 4].
59+
losses = tensor_([3.0, 5.0])
60+
result = dwa(losses)
61+
expected_weights = 2.0 * torch.softmax(tensor_([1.0, 4.0]) / 2.0, dim=0)
62+
torch.testing.assert_close(result, (expected_weights * losses).sum())
63+
64+
65+
def test_weights_sum_to_numel() -> None:
66+
dwa = DWA()
67+
dwa(tensor_([1.0, 2.0]))
68+
dwa.step()
69+
dwa(tensor_([2.0, 1.0]))
70+
dwa.step()
71+
# The weights sum to the number of elements, so weighting a vector of ones gives that count.
72+
torch.testing.assert_close(dwa(ones_((2,))), tensor_(2.0))
73+
74+
75+
@mark.parametrize("values", all_inputs)
76+
def test_expected_structure(values: Tensor) -> None:
77+
assert_returns_scalar(DWA(), values)
78+
79+
80+
@mark.parametrize("values", all_inputs)
81+
def test_grad_flow(values: Tensor) -> None:
82+
assert_grad_flow(DWA(), values)
83+
84+
85+
def test_grad_flows_with_computed_weights() -> None:
86+
# After two epochs the weights are computed from the (detached) loss history; gradients must
87+
# still flow to the current values.
88+
dwa = DWA(temperature=2.0)
89+
dwa(tensor_([1.0, 1.0]))
90+
dwa.step()
91+
dwa(tensor_([1.0, 4.0]))
92+
dwa.step()
93+
assert_grad_flow(dwa, tensor_([3.0, 5.0]))
94+
95+
96+
def test_reset() -> None:
97+
dwa = DWA()
98+
dwa(tensor_([1.0, 2.0]))
99+
dwa.step()
100+
dwa(tensor_([3.0, 4.0]))
101+
dwa.reset()
102+
assert dwa._previous_averages == []
103+
assert dwa._loss_sum is None
104+
assert dwa._n_batches == 0
105+
106+
107+
def test_step_without_forward_is_noop() -> None:
108+
dwa = DWA()
109+
dwa.step() # No losses accumulated yet.
110+
assert dwa._previous_averages == []
111+
112+
113+
def test_supports_consistently_negative_losses() -> None:
114+
# DWA works on negative losses too, as long as each value keeps a consistent sign: the ratio of
115+
# same-sign losses is positive, so the weights match those of the equivalent positive case.
116+
dwa = DWA(temperature=2.0)
117+
dwa(tensor_([-2.0, -2.0]))
118+
dwa.step() # Epoch 1 average = [-2, -2].
119+
dwa(tensor_([-2.0, -8.0]))
120+
dwa.step() # Epoch 2 average = [-2, -8]; rates = [-2, -8] / [-2, -2] = [1, 4].
121+
losses = tensor_([3.0, 5.0])
122+
result = dwa(losses)
123+
expected_weights = 2.0 * torch.softmax(tensor_([1.0, 4.0]) / 2.0, dim=0)
124+
torch.testing.assert_close(result, (expected_weights * losses).sum())
125+
126+
127+
def test_raises_on_shape_change_within_epoch() -> None:
128+
dwa = DWA()
129+
dwa(tensor_([1.0, 2.0]))
130+
with raises(ValueError):
131+
dwa(tensor_([1.0, 2.0, 3.0]))
132+
133+
134+
def test_raises_on_shape_change_between_epochs() -> None:
135+
dwa = DWA()
136+
dwa(tensor_([1.0, 2.0]))
137+
dwa.step()
138+
dwa(tensor_([2.0, 1.0]))
139+
dwa.step()
140+
with raises(ValueError):
141+
dwa(tensor_([1.0, 2.0, 3.0]))
142+
143+
144+
@mark.parametrize("temperature", [0.0, -1.0])
145+
def test_raises_on_non_positive_temperature(temperature: float) -> None:
146+
with raises(ValueError):
147+
DWA(temperature=temperature)
148+
149+
150+
def test_representations() -> None:
151+
assert repr(DWA()) == "DWA(temperature=2.0)"
152+
assert repr(DWA(temperature=1.5)) == "DWA(temperature=1.5)"
153+
assert str(DWA()) == "DWA"

0 commit comments

Comments
 (0)