Skip to content

Commit c455c66

Browse files
authored
feat(scalarization): Add cosmos (#745)
1 parent 7a39365 commit c455c66

6 files changed

Lines changed: 172 additions & 0 deletions

File tree

CHANGELOG.md

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

99
## [Unreleased]
1010

11+
### Added
12+
13+
- Added `COSMOS` from [Scalable Pareto Front Approximation for Deep Multi-Objective
14+
Learning](https://arxiv.org/pdf/2103.13392) (ICDM 2021), a `Scalarizer` that combines a linear
15+
scalarization with a cosine-similarity penalty pulling the vector of values toward a preference
16+
direction.
17+
1118
## [0.15.0] - 2026-06-15
1219

1320
### Added
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
:hide-toc:
2+
3+
COSMOS
4+
======
5+
6+
.. autoclass:: torchjd.scalarization.COSMOS
7+
:members: __call__

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+
cosmos.rst
1819
dwa.rst
1920
famo.rst
2021
geometric_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 ._cosmos import COSMOS
2324
from ._dwa import DWA
2425
from ._famo import FAMO
2526
from ._geometric_mean import GeometricMean
@@ -33,6 +34,7 @@
3334

3435
__all__ = [
3536
"Constant",
37+
"COSMOS",
3638
"DWA",
3739
"FAMO",
3840
"GeometricMean",
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
from torch import Tensor
2+
from torch.nn.functional import cosine_similarity
3+
4+
from ._scalarizer_base import Scalarizer
5+
6+
7+
class COSMOS(Scalarizer):
8+
r"""
9+
:class:`~torchjd.scalarization.Scalarizer` that combines the input tensor of values using the
10+
COSMOS scalarization, proposed in `Scalable Pareto Front Approximation for Deep Multi-Objective
11+
Learning <https://arxiv.org/pdf/2103.13392>`_.
12+
13+
It returns a linear scalarization penalized by the cosine similarity between the values and the
14+
preference vector:
15+
16+
.. math::
17+
\sum_i r_i L_i - \lambda \frac{\sum_i r_i L_i}{\lVert r \rVert \, \lVert L \rVert},
18+
19+
where:
20+
21+
- :math:`L_i` is the :math:`i`-th input value (the :math:`i`-th objective);
22+
- :math:`r_i` is its preference weight (the ``weights`` parameter);
23+
- :math:`\lambda` is the cosine-similarity penalty coefficient (the ``lambda_`` parameter);
24+
- the subtracted term is :math:`\lambda \cos(r, L)`, which rewards aligning the vector of values
25+
with the preference direction and is what spreads the approximated Pareto front.
26+
27+
:param lambda_: The cosine-similarity penalty coefficient :math:`\lambda`. Must be non-negative.
28+
A value of ``0`` reduces COSMOS to a plain linear scalarization. The paper uses values
29+
ranging from ``0.01`` to ``8`` depending on the dataset, with no single best value.
30+
:param weights: The preference vector :math:`r` applied to the values. It must have the same
31+
shape as the values passed at call time. To approximate the whole Pareto front rather than a
32+
single trade-off, it should be re-sampled from a Dirichlet distribution and reassigned before
33+
every call, as in the paper, e.g. for ``m`` objectives
34+
``cosmos.weights = torch.distributions.Dirichlet(torch.ones(m)).sample()`` (a uniform
35+
distribution over the probability simplex; a concentration smaller than one spreads the
36+
samples toward the corners of the simplex).
37+
38+
.. note::
39+
The full COSMOS method also conditions the model on the preference vector by concatenating it
40+
to the input; that is a modeling choice left to the user. This scalarizer only implements the
41+
objective.
42+
"""
43+
44+
def __init__(self, lambda_: float, weights: Tensor) -> None:
45+
if lambda_ < 0.0:
46+
raise ValueError(
47+
f"Parameter `lambda_` should be non-negative. Found `lambda_ = {lambda_}`."
48+
)
49+
50+
super().__init__()
51+
self.lambda_ = lambda_
52+
self.weights = weights
53+
54+
def forward(self, values: Tensor, /) -> Tensor:
55+
if self.weights.shape != values.shape:
56+
raise ValueError(
57+
f"Parameter `weights` should have the same shape as `values`. Found "
58+
f"`weights.shape = {tuple(self.weights.shape)}` and `values.shape = "
59+
f"{tuple(values.shape)}`."
60+
)
61+
62+
weighted_sum = (self.weights * values).sum()
63+
cosine = cosine_similarity(self.weights.flatten(), values.flatten(), dim=0)
64+
return weighted_sum - self.lambda_ * cosine
65+
66+
def __repr__(self) -> str:
67+
return f"{self.__class__.__name__}(lambda_={self.lambda_}, weights={self.weights!r})"
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import torch
2+
from pytest import mark, raises
3+
from torch import Tensor
4+
from torch.nn.functional import cosine_similarity
5+
from utils.tensors import tensor_
6+
7+
from torchjd.scalarization import COSMOS
8+
9+
from ._asserts import (
10+
assert_grad_flow,
11+
assert_permutation_invariant,
12+
assert_returns_scalar,
13+
)
14+
from ._inputs import all_inputs
15+
16+
17+
def _uniform(values: Tensor) -> Tensor:
18+
"""Uniform preference vector matching the shape of `values`."""
19+
return torch.full_like(values, 1.0 / values.numel())
20+
21+
22+
def test_value_aligned_gives_zero() -> None:
23+
# Uniform weights on equal values are perfectly aligned, so cos(r, L) = 1. The result is the
24+
# weighted sum (1) minus lambda (1): 0.
25+
out = COSMOS(lambda_=1.0, weights=tensor_([0.5, 0.5]))(tensor_([1.0, 1.0]))
26+
torch.testing.assert_close(out, tensor_(0.0))
27+
28+
29+
def test_value_lambda_zero_is_linear_scalarization() -> None:
30+
# With lambda = 0 there is no cosine penalty, so COSMOS is just the weighted sum.
31+
weights = tensor_([1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0])
32+
out = COSMOS(lambda_=0.0, weights=weights)(tensor_([1.0, 2.0, 4.0]))
33+
torch.testing.assert_close(out, tensor_(7.0 / 3.0))
34+
35+
36+
def test_value_with_weights() -> None:
37+
# With lambda = 0, only the linear term remains: 2*3 + 1*4 = 10.
38+
out = COSMOS(lambda_=0.0, weights=tensor_([2.0, 1.0]))(tensor_([3.0, 4.0]))
39+
torch.testing.assert_close(out, tensor_(10.0))
40+
41+
42+
def test_full_formula() -> None:
43+
values = tensor_([1.0, 2.0, 4.0])
44+
weights = tensor_([0.5, 0.3, 0.2])
45+
lambda_ = 2.0
46+
expected = (weights * values).sum() - lambda_ * cosine_similarity(weights, values, dim=0)
47+
torch.testing.assert_close(COSMOS(lambda_, weights=weights)(values), expected)
48+
49+
50+
@mark.parametrize("values", all_inputs)
51+
def test_expected_structure(values: Tensor) -> None:
52+
assert_returns_scalar(COSMOS(lambda_=1.0, weights=_uniform(values)), values)
53+
54+
55+
@mark.parametrize("values", all_inputs)
56+
def test_grad_flow(values: Tensor) -> None:
57+
assert_grad_flow(COSMOS(lambda_=1.0, weights=_uniform(values)), values)
58+
59+
60+
@mark.parametrize("values", all_inputs)
61+
def test_permutation_invariant(values: Tensor) -> None:
62+
# With uniform weights, both the weighted sum and the cosine term are symmetric in the inputs.
63+
assert_permutation_invariant(COSMOS(lambda_=1.0, weights=_uniform(values)), values)
64+
65+
66+
def test_zero_values_returns_zero() -> None:
67+
# `cosine_similarity` is numerically stable for the zero vector, so all-zero values give 0 (no
68+
# nan), regardless of lambda.
69+
out = COSMOS(lambda_=1.0, weights=tensor_([0.5, 0.5]))(tensor_([0.0, 0.0]))
70+
torch.testing.assert_close(out, tensor_(0.0))
71+
72+
73+
@mark.parametrize("lambda_", [-1.0, -0.5])
74+
def test_raises_on_negative_lambda(lambda_: float) -> None:
75+
with raises(ValueError):
76+
COSMOS(lambda_=lambda_, weights=tensor_([0.5, 0.5]))
77+
78+
79+
def test_raises_on_weights_shape_mismatch() -> None:
80+
scalarizer = COSMOS(lambda_=1.0, weights=tensor_([1.0, 1.0, 1.0]))
81+
with raises(ValueError):
82+
scalarizer(tensor_([1.0, 1.0]))
83+
84+
85+
def test_representations() -> None:
86+
s = COSMOS(lambda_=0.5, weights=torch.tensor([0.5, 0.5]))
87+
assert repr(s) == "COSMOS(lambda_=0.5, weights=tensor([0.5000, 0.5000]))"
88+
assert str(s) == "COSMOS"

0 commit comments

Comments
 (0)