Skip to content

Commit 3e5b88c

Browse files
feat(aggregation): Add SDMGradWeighting (#728)
1 parent aa19096 commit 3e5b88c

11 files changed

Lines changed: 464 additions & 32 deletions

File tree

CHANGELOG.md

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

1313
### Added
1414

15+
- 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.
1516
- Added `IMTL-L` (the loss-balancing variant of Impartial Multi-Task Learning) from [Towards
1617
Impartial Multi-Task Learning](https://openreview.net/pdf?id=IMPnRXEWpvr) (ICLR 2021), a stateful
1718
`Scalarizer` that learns a per-task scale `s_i` and combines the values as

NOTICES

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,31 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
140140
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
141141
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
142142
SOFTWARE.
143+
144+
-------------------------------------------------------------------------------
145+
146+
Project: SDMGrad
147+
Source: https://github.com/OptMN-Lab/SDMGrad/blob/main/methods/weight_methods.py
148+
Used in: src/torchjd/aggregation/_sdmgrad.py
149+
150+
MIT License
151+
152+
Copyright (c) 2023 ml-opt-lab
153+
154+
Permission is hereby granted, free of charge, to any person obtaining a copy
155+
of this software and associated documentation files (the "Software"), to deal
156+
in the Software without restriction, including without limitation the rights
157+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
158+
copies of the Software, and to permit persons to whom the Software is
159+
furnished to do so, subject to the following conditions:
160+
161+
The above copyright notice and this permission notice shall be included in all
162+
copies or substantial portions of the Software.
163+
164+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
165+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
166+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
167+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
168+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
169+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
170+
SOFTWARE.

docs/source/docs/aggregation/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,6 @@ Abstract base classes
4141
nash_mtl.rst
4242
pcgrad.rst
4343
random.rst
44+
sdmgrad.rst
4445
sum.rst
4546
trimmed_mean.rst
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
:hide-toc:
2+
3+
SDMGrad
4+
=======
5+
6+
.. autoclass:: torchjd.aggregation.SDMGradWeighting
7+
:members: __call__, reset

src/torchjd/aggregation/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
from ._nash_mtl import NashMTL
5757
from ._pcgrad import PCGrad, PCGradWeighting
5858
from ._random import Random, RandomWeighting
59+
from ._sdmgrad import SDMGradWeighting
5960
from ._sum import Sum, SumWeighting
6061
from ._trimmed_mean import TrimmedMean
6162
from ._upgrad import UPGrad, UPGradWeighting
@@ -93,6 +94,7 @@
9394
"PCGradWeighting",
9495
"Random",
9596
"RandomWeighting",
97+
"SDMGradWeighting",
9698
"Sum",
9799
"SumWeighting",
98100
"TrimmedMean",

src/torchjd/aggregation/_modo.py

Lines changed: 2 additions & 17 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 ._utils.simplex import _projection2simplex
1415
from ._weighting_bases import _MatrixWeighting
1516

1617

@@ -166,27 +167,11 @@ def forward(self, matrix: Matrix, /) -> Tensor:
166167
lambd = cast(Tensor, self._lambda)
167168

168169
grad = matrix @ lambd + self._rho * lambd
169-
lambd = self._projection2simplex(lambd - self._gamma * grad)
170+
lambd = _projection2simplex(lambd - self._gamma * grad)
170171

171172
self._lambda = lambd
172173
return lambd
173174

174-
@staticmethod
175-
def _projection2simplex(y: Tensor) -> Tensor:
176-
"""Euclidean projection of ``y`` onto the probability simplex."""
177-
178-
m = len(y)
179-
sorted_y = torch.sort(y, descending=True)[0]
180-
tmpsum = y.new_zeros(())
181-
tmax_f = (torch.sum(y) - 1.0) / m
182-
for i in range(m - 1):
183-
tmpsum = tmpsum + sorted_y[i]
184-
tmax = (tmpsum - 1.0) / (i + 1.0)
185-
if tmax > sorted_y[i + 1]:
186-
tmax_f = tmax
187-
break
188-
return torch.max(y - tmax_f, y.new_zeros(m))
189-
190175
def _ensure_state(self, matrix: Matrix) -> None:
191176
key = (matrix.shape[0], matrix.dtype, matrix.device)
192177
if self._state_key == key and self._lambda is not None:
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
# Partly adapted from https://github.com/OptMN-Lab/SDMGrad — MIT License, Copyright (c) 2023 ml-opt-lab.
2+
# See NOTICES for the full license text.
3+
from __future__ import annotations
4+
5+
from typing import cast
6+
7+
import torch
8+
from torch import Tensor
9+
10+
from torchjd._mixins import Stateful
11+
from torchjd.aggregation._mixins import _NonDifferentiable
12+
from torchjd.linalg import Matrix
13+
14+
from ._utils.simplex import _projection2simplex
15+
from ._weighting_bases import _MatrixWeighting
16+
17+
18+
class SDMGradWeighting(_MatrixWeighting, Stateful, _NonDifferentiable):
19+
r"""
20+
:class:`~torchjd.Stateful`
21+
:class:`~torchjd.aggregation.Weighting` [:class:`~torchjd.linalg.Matrix`] from `Direction-oriented
22+
Multi-objective Learning: Simple and Provable Stochastic Algorithms
23+
<https://arxiv.org/pdf/2305.18409>`_ (NeurIPS 2023).
24+
25+
.. warning::
26+
The input matrix must be :math:`A = J_1 J_2^\top`, computed from two **independent**
27+
mini-batches via :func:`torchjd.autojac.jac`. It is **not** a Gramian and is not symmetric
28+
or positive semi-definite in general. See the usage examples below.
29+
30+
:param lr: Learning rate of the inner SGD that solves for the task weights. Must be positive.
31+
:param momentum: Momentum of the inner SGD. Must be in :math:`[0, 1)`.
32+
:param n_iter: Number of inner SGD iterations performed at each call. Must be positive.
33+
:param lambda_: Non-negative coefficient controlling how strongly the descent direction is pulled
34+
toward the preference direction. Must be non-negative.
35+
:param pref_vector: The preference vector :math:`\tilde w` defining the target direction. If not
36+
provided, defaults to the uniform vector :math:`[1/m, \ldots, 1/m]` (i.e. the target diection is the average gradient).
37+
38+
.. note::
39+
The inner simplex-projected solver is adapted from the `official implementation
40+
<https://github.com/OptMN-Lab/SDMGrad/blob/main/methods/weight_methods.py>`_. Note that the
41+
official class default for this coefficient is ``0.6``, overridden to ``0.3`` in their own
42+
experiments, which is the value used here (and in `LibMTL
43+
<https://github.com/median-research-group/LibMTL/blob/main/LibMTL/weighting/SDMGrad.py>`_).
44+
45+
Before the inner solve, the input matrix is scale-normalized by the mean of the square
46+
roots of its non-negative diagonal entries (following both the official implementation and
47+
LibMTL). This makes the inner SGD learning rate scale-invariant with respect to gradient
48+
magnitude. The normalization is briefly described in section 6.1 of the paper.
49+
50+
.. admonition:: Example (three batches per step)
51+
52+
The following example shows how to train with the SDMGrad algorithm.
53+
54+
.. testcode::
55+
56+
import torch
57+
from torch.nn import Linear, MSELoss, ReLU, Sequential
58+
from torch.optim import SGD
59+
60+
from torchjd.aggregation import SDMGradWeighting
61+
from torchjd.autojac import jac
62+
63+
# Generate data (9 batches of 16 examples of dim 5) for the sake of the example.
64+
inputs = torch.randn(9, 16, 5)
65+
targets = torch.randn(9, 16)
66+
67+
model = Sequential(Linear(5, 4), ReLU(), Linear(4, 1))
68+
optimizer = SGD(model.parameters())
69+
criterion = MSELoss(reduction="none")
70+
weighting = SDMGradWeighting(lambda_=0.3)
71+
params = list(model.parameters())
72+
73+
# Consume three consecutive (independent) batches per step.
74+
for i in range(len(inputs) // 3):
75+
# Batches corresponding to ξ, ξ' and ζ in the paper's algorithm.
76+
input_1, input_2, input_3 = inputs[3 * i], inputs[3 * i + 1], inputs[3 * i + 2]
77+
target_1, target_2, target_3 = targets[3 * i], targets[3 * i + 1], targets[3 * i + 2]
78+
79+
losses_1 = criterion(model(input_1).squeeze(dim=1), target_1)
80+
jacs_1 = jac(losses_1, params)
81+
J_1 = torch.cat([j.flatten(1) for j in jacs_1], dim=1)
82+
83+
losses_2 = criterion(model(input_2).squeeze(dim=1), target_2)
84+
jacs_2 = jac(losses_2, params)
85+
J_2 = torch.cat([j.flatten(1) for j in jacs_2], dim=1)
86+
87+
A = J_1 @ J_2.T
88+
weights = weighting(A)
89+
90+
losses_3 = criterion(model(input_3).squeeze(dim=1), target_3)
91+
losses_3.backward(weights)
92+
optimizer.step()
93+
optimizer.zero_grad()
94+
"""
95+
96+
def __init__(
97+
self,
98+
lr: float = 10.0,
99+
momentum: float = 0.5,
100+
n_iter: int = 20,
101+
lambda_: float = 0.3,
102+
pref_vector: Tensor | None = None,
103+
) -> None:
104+
super().__init__()
105+
self.lr = lr
106+
self.momentum = momentum
107+
self.n_iter = n_iter
108+
self.lambda_ = lambda_
109+
self.pref_vector = pref_vector
110+
self._w: Tensor | None = None
111+
self._state_key: tuple[int, torch.dtype, torch.device] | None = None
112+
113+
@property
114+
def lr(self) -> float:
115+
return self._lr
116+
117+
@lr.setter
118+
def lr(self, value: float) -> None:
119+
if value <= 0.0:
120+
raise ValueError(f"Attribute `lr` must be positive. Found lr={value!r}.")
121+
self._lr = value
122+
123+
@property
124+
def momentum(self) -> float:
125+
return self._momentum
126+
127+
@momentum.setter
128+
def momentum(self, value: float) -> None:
129+
if not (0.0 <= value < 1.0):
130+
raise ValueError(f"Attribute `momentum` must be in [0, 1). Found momentum={value!r}.")
131+
self._momentum = value
132+
133+
@property
134+
def n_iter(self) -> int:
135+
return self._n_iter
136+
137+
@n_iter.setter
138+
def n_iter(self, value: int) -> None:
139+
if value < 1:
140+
raise ValueError(
141+
f"Attribute `n_iter` must be a positive integer. Found n_iter={value!r}."
142+
)
143+
self._n_iter = value
144+
145+
@property
146+
def lambda_(self) -> float:
147+
return self._lambda
148+
149+
@lambda_.setter
150+
def lambda_(self, value: float) -> None:
151+
if value < 0.0:
152+
raise ValueError(f"Attribute `lambda_` must be non-negative. Found lambda_={value!r}.")
153+
self._lambda = value
154+
155+
@property
156+
def pref_vector(self) -> Tensor | None:
157+
return self._pref_vector
158+
159+
@pref_vector.setter
160+
def pref_vector(self, value: Tensor | None) -> None:
161+
if value is not None and value.ndim != 1:
162+
raise ValueError(
163+
"Parameter `pref_vector` must be a vector (1D Tensor). Found `pref_vector.ndim = "
164+
f"{value.ndim}`."
165+
)
166+
self._pref_vector = value
167+
168+
def reset(self) -> None:
169+
"""Clears the stored task weights so the next forward starts from uniform."""
170+
171+
self._w = None
172+
self._state_key = None
173+
174+
def forward(self, matrix: Matrix, /) -> Tensor:
175+
self._ensure_state(matrix)
176+
w = cast(Tensor, self._w)
177+
w_tilde = self._resolve_w_tilde(matrix)
178+
179+
diag = torch.diag(matrix).clamp(min=0.0)
180+
scale = diag.sqrt().mean()
181+
a = matrix / (scale.pow(2) + 1e-8)
182+
183+
velocity: Tensor | None = None
184+
for _ in range(self._n_iter):
185+
grad = a @ (w + self._lambda * w_tilde)
186+
velocity = grad if velocity is None else self._momentum * velocity + grad
187+
w = _projection2simplex(w - self._lr * velocity)
188+
189+
self._w = w
190+
return (w + self._lambda * w_tilde) / (1.0 + self._lambda)
191+
192+
def _resolve_w_tilde(self, matrix: Matrix) -> Tensor:
193+
m = matrix.shape[0]
194+
if self._pref_vector is None:
195+
return matrix.new_full((m,), 1.0 / m)
196+
if self._pref_vector.shape[0] != m:
197+
raise ValueError(
198+
"The length of `pref_vector` must match the number of rows of the input matrix. "
199+
f"Found len(pref_vector)={self._pref_vector.shape[0]} and matrix.shape[0]={m}."
200+
)
201+
return self._pref_vector.to(dtype=matrix.dtype, device=matrix.device)
202+
203+
def _ensure_state(self, matrix: Matrix) -> None:
204+
key = (matrix.shape[0], matrix.dtype, matrix.device)
205+
if self._state_key == key and self._w is not None:
206+
return
207+
self._w = matrix.new_full((matrix.shape[0],), 1.0 / matrix.shape[0])
208+
self._state_key = key
209+
210+
def __repr__(self) -> str:
211+
return (
212+
f"{self.__class__.__name__}(lr={self.lr!r}, momentum={self.momentum!r}, "
213+
f"n_iter={self.n_iter!r}, lambda_={self.lambda_!r}, pref_vector={self.pref_vector!r})"
214+
)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import torch
2+
from torch import Tensor
3+
4+
5+
def _projection2simplex(y: Tensor) -> Tensor:
6+
"""Euclidean projection of ``y`` onto the probability simplex."""
7+
8+
m = len(y)
9+
sorted_y = torch.sort(y, descending=True)[0]
10+
tmpsum = y.new_zeros(())
11+
tmax_f = (torch.sum(y) - 1.0) / m
12+
for i in range(m - 1):
13+
tmpsum = tmpsum + sorted_y[i]
14+
tmax = (tmpsum - 1.0) / (i + 1.0)
15+
if tmax > sorted_y[i + 1]:
16+
tmax_f = tmax
17+
break
18+
return torch.max(y - tmax_f, y.new_zeros(m))
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from torch.testing import assert_close
2+
from utils.tensors import tensor_
3+
4+
from torchjd.aggregation._utils.simplex import _projection2simplex
5+
6+
7+
def test_projection2simplex_known_values() -> None:
8+
"""The simplex projection matches hand-computed Euclidean projections."""
9+
10+
# Already-positive input: the deficit (1 - sum) is spread equally, no clamping.
11+
assert_close(
12+
_projection2simplex(tensor_([0.5, 0.1, 0.1])),
13+
tensor_([0.6, 0.2, 0.2]),
14+
)
15+
# Input with a negative entry: it gets clamped to zero.
16+
assert_close(
17+
_projection2simplex(tensor_([1.0, 0.0, -0.5])),
18+
tensor_([1.0, 0.0, 0.0]),
19+
)

tests/unit/aggregation/test_modo.py

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -170,18 +170,3 @@ def test_non_symmetric_input() -> None:
170170
assert_close(W(G), expected)
171171
assert W(G).shape == (m,)
172172
assert (W(G) >= 0).all()
173-
174-
175-
def test_projection2simplex_known_values() -> None:
176-
"""The simplex projection matches hand-computed Euclidean projections."""
177-
178-
# Already-positive input: the deficit (1 - sum) is spread equally, no clamping.
179-
assert_close(
180-
MoDoWeighting._projection2simplex(tensor_([0.5, 0.1, 0.1])),
181-
tensor_([0.6, 0.2, 0.2]),
182-
)
183-
# Input with a negative entry: it gets clamped to zero.
184-
assert_close(
185-
MoDoWeighting._projection2simplex(tensor_([1.0, 0.0, -0.5])),
186-
tensor_([1.0, 0.0, 0.0]),
187-
)

0 commit comments

Comments
 (0)