Skip to content

Commit 20b7f08

Browse files
committed
Address Valerian's review comments on ExcessMTL
1 parent 5a6358f commit 20b7f08

4 files changed

Lines changed: 99 additions & 83 deletions

File tree

docs/source/docs/aggregation/excess_mtl.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,8 @@
33
ExcessMTL
44
=========
55

6+
.. autoclass:: torchjd.aggregation.ExcessMTL
7+
:members: __call__, reset
8+
69
.. autoclass:: torchjd.aggregation.ExcessMTLWeighting
710
:members: __call__, reset

src/torchjd/aggregation/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
from ._constant import Constant, ConstantWeighting
4646
from ._cr_mogm import CRMOGMWeighting
4747
from ._dualproj import DualProj, DualProjWeighting
48-
from ._excess_mtl import ExcessMTLWeighting
48+
from ._excess_mtl import ExcessMTL, ExcessMTLWeighting
4949
from ._fairgrad import FairGrad, FairGradWeighting
5050
from ._graddrop import GradDrop
5151
from ._gradvac import GradVac, GradVacWeighting
@@ -75,6 +75,7 @@
7575
"CRMOGMWeighting",
7676
"DualProj",
7777
"DualProjWeighting",
78+
"ExcessMTL",
7879
"ExcessMTLWeighting",
7980
"FairGrad",
8081
"FairGradWeighting",

src/torchjd/aggregation/_excess_mtl.py

Lines changed: 82 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from torchjd.aggregation._mixins import _NonDifferentiable
1212
from torchjd.linalg import Matrix
1313

14+
from ._aggregator_bases import WeightedAggregator
1415
from ._weighting_bases import _MatrixWeighting
1516

1617

@@ -29,64 +30,37 @@ class ExcessMTLWeighting(_MatrixWeighting, Stateful, _NonDifferentiable):
2930
Must be positive.
3031
:param n_warmup_steps: Number of forward calls during which weights stay uniform
3132
(:math:`[1/m, \ldots, 1/m]`) and gradient statistics are collected. The baseline excess
32-
risk is set to the average excess risk observed during warmup. When ``0`` (default), the
33-
first call's excess risk is used as the baseline and weights are updated immediately
34-
(matching the official implementation).
33+
risk is then set to the average excess risk observed during warmup. When ``0``, the first
34+
call's excess risk is used immediately as the baseline. The default ``1`` matches the
35+
behavior of the official implementation and LibMTL. The paper (Appendix C.1) recommends
36+
collecting statistics for 3 full epochs, i.e. ``n_warmup_steps = 3 * len(dataloader)``.
3537
3638
.. warning::
3739
The state tensor :math:`S \in \mathbb{R}^{m \times n}` accumulates squared gradients
38-
across **all** calls, where :math:`n` is the total number of model parameters. For large
40+
across calls, where :math:`n` is the total number of model parameters. For large
3941
models this can be a significant memory cost. Call :meth:`reset` between experiments.
4042
4143
.. note::
4244
The weight update is adapted from the `official implementation
4345
<https://github.com/uiuctml/ExcessMTL>`_ and `LibMTL
4446
<https://github.com/median-research-group/LibMTL/blob/main/LibMTL/weighting/ExcessMTL.py>`_.
45-
The warmup strategy follows Appendix C.1 of the paper, which recommends collecting
46-
gradient statistics for several epochs before beginning weight updates; set
47-
``n_warmup_steps`` accordingly (e.g. ``3 * len(dataloader)``).
48-
49-
.. admonition:: Example
50-
51-
.. testcode::
52-
53-
import torch
54-
from torch.nn import Linear, MSELoss, ReLU, Sequential
55-
from torch.optim import SGD
56-
57-
from torchjd import autojac
58-
from torchjd.aggregation import ExcessMTLWeighting, WeightedAggregator
59-
from torchjd.autojac import jac_to_grad
60-
61-
inputs = torch.randn(8, 5)
62-
targets = torch.randn(8, 2)
63-
64-
model = Sequential(Linear(5, 4), ReLU(), Linear(4, 2))
65-
optimizer = SGD(model.parameters())
66-
criterion = MSELoss()
67-
aggregator = WeightedAggregator(ExcessMTLWeighting())
68-
69-
outputs = model(inputs)
70-
losses = [criterion(outputs[:, i], targets[:, i]) for i in range(2)]
71-
autojac.backward(losses)
72-
jac_to_grad(model.parameters(), aggregator)
73-
optimizer.step()
74-
optimizer.zero_grad()
47+
Unlike those implementations, which initialize task weights to ``1``, we follow the paper
48+
and initialize them to ``1/m`` so that they always lie on the probability simplex.
7549
"""
7650

7751
def __init__(
7852
self,
7953
robust_step_size: float = 1.0,
80-
n_warmup_steps: int = 0,
54+
n_warmup_steps: int = 1,
8155
) -> None:
8256
super().__init__()
8357
self.robust_step_size = robust_step_size
8458
self.n_warmup_steps = n_warmup_steps
8559
self.register_buffer("_weights", None)
86-
self.register_buffer("_grad_sum", None)
60+
self.register_buffer("_sq_grad_sum", None)
8761
self.register_buffer("_initial_w", None)
8862
self.register_buffer("_warmup_w_sum", None)
89-
self.register_buffer("_n_steps", torch.zeros((), dtype=torch.long))
63+
self._n_steps: int = 0
9064
self._state_key: tuple[int, int, torch.dtype, torch.device] | None = None
9165

9266
@property
@@ -118,31 +92,31 @@ def reset(self) -> None:
11892
warmup."""
11993

12094
self._weights = None
121-
self._grad_sum = None
95+
self._sq_grad_sum = None
12296
self._initial_w = None
12397
self._warmup_w_sum = None
124-
self._n_steps.zero_()
98+
self._n_steps = 0
12599
self._state_key = None
126100

127101
def forward(self, matrix: Matrix, /) -> Tensor:
128102
self._ensure_state(matrix)
129103

104+
sq_matrix = matrix.detach() ** 2
105+
130106
# Accumulate squared gradients for AdaGrad-style diagonal Hessian (Equation 7)
131-
grad_sum = cast(Tensor, self._grad_sum)
132-
grad_sum = grad_sum + matrix.detach() ** 2
133-
self._grad_sum = grad_sum
107+
sq_grad_sum = cast(Tensor, self._sq_grad_sum)
108+
sq_grad_sum += sq_matrix
134109

135110
# Excess risk proxy: Ê_i ≈ g_i^T H_i^{-1} g_i (Equation 6)
136-
h = torch.sqrt(grad_sum + 1e-7)
137-
w = (matrix.detach() ** 2 / h).sum(dim=1) # shape [m]
111+
h = torch.sqrt(sq_grad_sum + 1e-7)
112+
w = (sq_matrix / h).sum(dim=1) # shape [m]
138113

139-
n_steps = int(self._n_steps.item())
140-
self._n_steps = self._n_steps + 1
114+
n_steps = self._n_steps
115+
self._n_steps += 1
141116

142117
# Warmup: collect excess risk stats but return uniform weights
143118
if n_steps < self._n_warmup_steps:
144-
warmup_w_sum = self._warmup_w_sum
145-
self._warmup_w_sum = w if warmup_w_sum is None else cast(Tensor, warmup_w_sum) + w
119+
cast(Tensor, self._warmup_w_sum).add_(w)
146120
return cast(Tensor, self._weights)
147121

148122
# Set baseline on the first non-warmup call
@@ -152,7 +126,7 @@ def forward(self, matrix: Matrix, /) -> Tensor:
152126
self._initial_w = cast(Tensor, self._warmup_w_sum) / self._n_warmup_steps
153127
w = w / (cast(Tensor, self._initial_w) + 1e-7)
154128
else:
155-
# Official impl behaviour: first call's excess is the baseline; use w raw
129+
# Official impl behavior: first call's excess is the baseline; use w raw
156130
self._initial_w = w
157131
else:
158132
w = w / (cast(Tensor, self._initial_w) + 1e-7)
@@ -166,14 +140,14 @@ def forward(self, matrix: Matrix, /) -> Tensor:
166140

167141
def _ensure_state(self, matrix: Matrix) -> None:
168142
key = (matrix.shape[0], matrix.shape[1], matrix.dtype, matrix.device)
169-
if self._state_key == key and self._grad_sum is not None:
143+
if self._state_key == key and self._sq_grad_sum is not None:
170144
return
171145
m, n = matrix.shape
172-
self._grad_sum = matrix.new_zeros(m, n)
146+
self._sq_grad_sum = matrix.new_zeros(m, n)
147+
self._warmup_w_sum = matrix.new_zeros(m)
173148
self._weights = matrix.new_full((m,), 1.0 / m)
174149
self._initial_w = None
175-
self._warmup_w_sum = None
176-
self._n_steps.zero_()
150+
self._n_steps = 0
177151
self._state_key = key
178152

179153
def __repr__(self) -> str:
@@ -182,3 +156,58 @@ def __repr__(self) -> str:
182156
f"robust_step_size={self.robust_step_size!r}, "
183157
f"n_warmup_steps={self.n_warmup_steps!r})"
184158
)
159+
160+
161+
class ExcessMTL(WeightedAggregator, Stateful, _NonDifferentiable):
162+
r"""
163+
:class:`~torchjd.aggregation.WeightedAggregator` from `Robust Multi-Task Learning with Excess
164+
Risks <https://proceedings.mlr.press/v235/he24n.html>`_ (ICML 2024).
165+
166+
At each call, task weights are updated via an exponentiated gradient step (Equation 9) driven
167+
by per-task excess risk estimates. See :class:`~torchjd.aggregation.ExcessMTLWeighting` for
168+
details on the algorithm and state management.
169+
170+
:param robust_step_size: Step size :math:`\eta_\alpha` for the exponentiated weight update.
171+
Must be positive.
172+
:param n_warmup_steps: Number of forward calls during which weights stay uniform
173+
(:math:`[1/m, \ldots, 1/m]`) and gradient statistics are collected. When ``0``, the first
174+
call's excess risk is used as the baseline immediately. Defaults to ``1``.
175+
"""
176+
177+
weighting: ExcessMTLWeighting
178+
179+
def __init__(
180+
self,
181+
robust_step_size: float = 1.0,
182+
n_warmup_steps: int = 1,
183+
) -> None:
184+
super().__init__(ExcessMTLWeighting(robust_step_size, n_warmup_steps))
185+
186+
@property
187+
def robust_step_size(self) -> float:
188+
return self.weighting.robust_step_size
189+
190+
@robust_step_size.setter
191+
def robust_step_size(self, value: float) -> None:
192+
self.weighting.robust_step_size = value
193+
194+
@property
195+
def n_warmup_steps(self) -> int:
196+
return self.weighting.n_warmup_steps
197+
198+
@n_warmup_steps.setter
199+
def n_warmup_steps(self, value: int) -> None:
200+
self.weighting.n_warmup_steps = value
201+
202+
def reset(self) -> None:
203+
"""Clears all state so the next forward starts from uniform weights and re-enters
204+
warmup."""
205+
206+
self.weighting.reset()
207+
208+
def __repr__(self) -> str:
209+
return (
210+
f"{self.__class__.__name__}("
211+
f"robust_step_size={self.robust_step_size!r}, "
212+
f"n_warmup_steps={self.n_warmup_steps!r})"
213+
)

tests/unit/aggregation/test_excess_mtl.py

Lines changed: 12 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -88,23 +88,6 @@ def test_weights_change_after_warmup() -> None:
8888
assert weights[0] > weights[1]
8989

9090

91-
def test_update_recurrence() -> None:
92-
"""Verify the first weight update manually (n_warmup_steps=0, LibMTL behaviour).
93-
94-
With J = [[2., 0.], [1., 0.]] and robust_step_size=1.0:
95-
grad_sum = [[4., 0.], [1., 0.]]
96-
h ≈ [[2., sqrt(eps)], [1., sqrt(eps)]] (eps = 1e-7, negligible in float32 for nonzero entries)
97-
w = [4/2 + 0, 1/1 + 0] = [2, 1]
98-
initial_w = [2, 1] (first call: save raw excess as baseline)
99-
weights = [exp(2), exp(1)] / (exp(2) + exp(1))
100-
"""
101-
J = tensor_([[2.0, 0.0], [1.0, 0.0]])
102-
W = ExcessMTLWeighting(robust_step_size=1.0)
103-
e2 = torch.exp(tensor_(2.0))
104-
e1 = torch.exp(tensor_(1.0))
105-
assert_close(W(J), tensor_([e2 / (e2 + e1), e1 / (e2 + e1)]))
106-
107-
10891
def test_two_consecutive_steps() -> None:
10992
"""Verify warm-started carry-over across two calls.
11093
@@ -119,7 +102,7 @@ def test_two_consecutive_steps() -> None:
119102
"""
120103
J1 = tensor_([[2.0, 0.0], [1.0, 0.0]])
121104
J2 = tensor_([[1.0, 0.0], [2.0, 0.0]])
122-
W = ExcessMTLWeighting(robust_step_size=1.0)
105+
W = ExcessMTLWeighting(robust_step_size=1.0, n_warmup_steps=0)
123106

124107
e2 = torch.exp(tensor_(2.0))
125108
e1 = torch.exp(tensor_(1.0))
@@ -140,17 +123,17 @@ def test_warmup_baseline_is_average() -> None:
140123
141124
With n_warmup_steps=2 and J1=[[2,0],[1,0]], J2=[[1,0],[2,0]]:
142125
143-
Warmup call 1 — grad_sum_1 = J1**2 = [[4,0],[1,0]]:
126+
Warmup call 1 — sq_grad_sum_1 = J1**2 = [[4,0],[1,0]]:
144127
h_1 ≈ [[2, sqrt(eps)], [1, sqrt(eps)]]
145128
w_1 = [4/2, 1/1] = [2, 1]
146129
147-
Warmup call 2 — grad_sum_2 = J1**2 + J2**2 = [[5,0],[5,0]]:
130+
Warmup call 2 — sq_grad_sum_2 = J1**2 + J2**2 = [[5,0],[5,0]]:
148131
h_2 ≈ [[sqrt(5), sqrt(eps)], [sqrt(5), sqrt(eps)]]
149132
w_2 = [1/sqrt(5), 4/sqrt(5)]
150133
151134
initial_w = (w_1 + w_2) / 2 (Appendix C.1 average)
152135
153-
Post-warmup call 3 with J3 = J1 — grad_sum_3 = [[9,0],[6,0]]:
136+
Post-warmup call 3 with J3 = J1 — sq_grad_sum_3 = [[9,0],[6,0]]:
154137
h_3 ≈ [[3, sqrt(eps)], [sqrt(6), sqrt(eps)]]
155138
w_3 = [4/3, 1/sqrt(6)]
156139
w_norm = w_3 / (initial_w + 1e-7)
@@ -162,21 +145,21 @@ def test_warmup_baseline_is_average() -> None:
162145
J3 = tensor_([[2.0, 0.0], [1.0, 0.0]])
163146
W = ExcessMTLWeighting(n_warmup_steps=2, robust_step_size=1.0)
164147

165-
W(J1) # warmup step 1 — grad_sum becomes J1**2
166-
W(J2) # warmup step 2 — grad_sum becomes J1**2 + J2**2
148+
W(J1) # warmup step 1 — sq_grad_sum becomes J1**2
149+
W(J2) # warmup step 2 — sq_grad_sum becomes J1**2 + J2**2
167150

168-
grad_sum_1 = J1**2
169-
h_1 = torch.sqrt(grad_sum_1 + 1e-7)
151+
sq_grad_sum_1 = J1**2
152+
h_1 = torch.sqrt(sq_grad_sum_1 + 1e-7)
170153
w_1 = (J1**2 / h_1).sum(dim=1)
171154

172-
grad_sum_2 = grad_sum_1 + J2**2
173-
h_2 = torch.sqrt(grad_sum_2 + 1e-7)
155+
sq_grad_sum_2 = sq_grad_sum_1 + J2**2
156+
h_2 = torch.sqrt(sq_grad_sum_2 + 1e-7)
174157
w_2 = (J2**2 / h_2).sum(dim=1)
175158

176159
initial_w = (w_1 + w_2) / 2 # Appendix C.1 baseline
177160

178-
grad_sum_3 = grad_sum_2 + J3**2
179-
h_3 = torch.sqrt(grad_sum_3 + 1e-7)
161+
sq_grad_sum_3 = sq_grad_sum_2 + J3**2
162+
h_3 = torch.sqrt(sq_grad_sum_3 + 1e-7)
180163
w_3 = (J3**2 / h_3).sum(dim=1)
181164
w_norm = w_3 / (initial_w + 1e-7)
182165
pre_norm = tensor_([0.5, 0.5]) * torch.exp(w_norm)

0 commit comments

Comments
 (0)