1111from torchjd .aggregation ._mixins import _NonDifferentiable
1212from torchjd .linalg import Matrix
1313
14+ from ._aggregator_bases import WeightedAggregator
1415from ._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+ )
0 commit comments