Skip to content

Commit 47018db

Browse files
chore: Add aggregators reference for implement-method skill (#758)
1 parent 1d854be commit 47018db

1 file changed

Lines changed: 257 additions & 0 deletions

File tree

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
# Aggregators Reference
2+
3+
This document describes the architecture, conventions, and patterns for implementing a new
4+
aggregator in `torchjd.aggregation`. Read this alongside the existing implementations
5+
(`_cr_mogm.py`, `_modo.py`, `_sdmgrad.py`, `_excess_mtl.py`, `_gradvac.py`, `_nash_mtl.py`)
6+
before writing any code.
7+
8+
---
9+
10+
## What is an Aggregator?
11+
12+
An `Aggregator` maps a Jacobian matrix `J ∈ ℝ^{m×n}` (m tasks × n parameters) to a single
13+
gradient vector `g ∈ ℝ^n`. It answers: *given the per-task gradients, which single direction
14+
should we update the model parameters in?*
15+
16+
Most aggregators work by computing a weight vector `λ ∈ ℝ^m` and returning `λ @ J`. This is
17+
the `WeightedAggregator` pattern.
18+
19+
---
20+
21+
## Class Hierarchy
22+
23+
```
24+
nn.Module
25+
└── Aggregator # ABC: validates input, calls forward()
26+
├── WeightedAggregator # forward = weighting(J) @ J
27+
└── GramianWeightedAggregator # forward = gramian_weighting(J @ J^T) @ J
28+
# (pre-computes the Gramian before calling weighting)
29+
30+
Weighting[_T] # ABC: takes a statistic _T, returns weights [m]
31+
├── _MatrixWeighting # _T = Matrix → takes raw Jacobian J
32+
└── _GramianWeighting # _T = PSDMatrix → takes Gramian J @ J^T
33+
```
34+
35+
**Key rule:** if your method needs coordinate-wise information (e.g. per-element gradient
36+
history like ExcessMTL) or a cross-Jacobian product `J_1 @ J_2^T` (like MoDo, SDMGrad),
37+
use `_MatrixWeighting`. If your method only needs task-task inner products (the Gramian),
38+
use `_GramianWeighting`.
39+
40+
---
41+
42+
## The Paired Class Convention
43+
44+
Almost every algorithm ships as **two classes**:
45+
46+
1. `FooWeighting(_MatrixWeighting or _GramianWeighting, ...)` — the core computation.
47+
Prefixed with `_` to signal if it is private. Takes either `Matrix` or `PSDMatrix` as input.
48+
2. `Foo(WeightedAggregator or GramianWeightedAggregator, ...)` — the public-facing aggregator
49+
wrapping the weighting.
50+
51+
**Exception:** if the method is a *modifier* that wraps any existing weighting (like CR-MOGM),
52+
ship only the `*Weighting` class. Do not create a convenience aggregator — the user composes
53+
it themselves with `WeightedAggregator` or `GramianWeightedAggregator`.
54+
55+
---
56+
57+
## Stateful Aggregators
58+
59+
If the weighting maintains state across calls (e.g. EMA of weights, accumulated gradient
60+
history, warm-started weights), inherit from `Stateful` and implement `reset()`.
61+
62+
```python
63+
from torchjd._mixins import Stateful
64+
65+
class _FooWeighting(_MatrixWeighting, Stateful, _NonDifferentiable):
66+
def __init__(self, ...):
67+
super().__init__()
68+
# Register ALL state tensors as buffers — never as plain Python attributes.
69+
# register_buffer ensures .to(device) moves them correctly.
70+
self.register_buffer("_my_state", None) # None = lazily initialized
71+
self._state_key: tuple[...] | None = None # plain attribute, not a tensor
72+
73+
def reset(self) -> None:
74+
"""Clears all state so the next forward starts fresh."""
75+
self._my_state = None
76+
self._state_key = None
77+
```
78+
79+
**State key:** use `(m, dtype, device)` when state shape depends only on `m` (number of
80+
tasks). Use `(m, n, dtype, device)` when state shape is `[m, n]` (e.g. ExcessMTL's
81+
`_grad_sum`). Auto-reset state when the key changes — never raise an error.
82+
83+
**Lazy initialisation:** since `m` (and sometimes `n`) is only known at `forward` time,
84+
initialize state tensors in `_ensure_state`, not in `__init__`.
85+
86+
```python
87+
def _ensure_state(self, matrix: Matrix) -> None:
88+
key = (matrix.shape[0], matrix.dtype, matrix.device)
89+
if self._state_key == key and self._my_state is not None:
90+
return
91+
m = matrix.shape[0]
92+
self._my_state = matrix.new_zeros(m) # or new_full, etc.
93+
self._state_key = key
94+
```
95+
96+
---
97+
98+
## Non-Differentiable Weightings
99+
100+
If the weights are computed from detached statistics (no gradient should flow through the
101+
weighting), inherit from `_NonDifferentiable`:
102+
103+
```python
104+
from torchjd.aggregation._mixins import _NonDifferentiable
105+
```
106+
107+
This is the case for most stateful aggregators (GradVac, NashMTL, CR-MOGM, MoDo, SDMGrad,
108+
ExcessMTL). The `_NonDifferentiable` wraps the `__call__` in a `torch.no_grad` context, so it's useless to detach the input matrix in such a method.
109+
110+
---
111+
112+
## Property-Based Validation
113+
114+
All constructor parameters must be exposed as properties with validating setters. This
115+
allows safe mutation after construction and gives immediate, clear error messages.
116+
117+
```python
118+
@property
119+
def alpha(self) -> float:
120+
return self._alpha
121+
122+
@alpha.setter
123+
def alpha(self, value: float) -> None:
124+
if not (0.0 <= value <= 1.0):
125+
raise ValueError(
126+
f"Attribute `alpha` must be in [0, 1]. Found alpha={value!r}."
127+
)
128+
self._alpha = value
129+
```
130+
131+
Common constraints:
132+
- Step sizes / learning rates: `> 0`
133+
- Momentum: `in [0, 1)`
134+
- Regularisation coefficients: `>= 0`
135+
- Iteration counts: `>= 1` (int)
136+
- Preference vectors: `ndim == 1`, non-negative, sums to 1
137+
138+
---
139+
140+
## Extra Inputs via Setters
141+
142+
If the method needs information beyond the Jacobian matrix (e.g. raw loss values, auxiliary
143+
statistics), expose it via a setter rather than adding arguments to `forward`. This keeps the
144+
`forward` signature uniform across all weightings. CONTRIBUTING.md explicitly anticipates this
145+
pattern.
146+
147+
```python
148+
def set_losses(self, losses: Tensor) -> None:
149+
"""Must be called before each forward with the current per-task losses."""
150+
self._losses = losses.detach()
151+
```
152+
153+
The same principle applies to any other extra input (e.g. task-specific learning rates,
154+
reference gradients). Document clearly in the docstring that the setter must be called before
155+
each training step.
156+
157+
---
158+
159+
## `__repr__`
160+
161+
Override `__repr__` to show all hyperparameters. Do not override `__str__``Aggregator`
162+
already defines `__str__` to return just the class name, and `Weighting` inherits
163+
`nn.Module.__repr__` as its `__str__`. Concrete example:
164+
165+
```python
166+
def __repr__(self) -> str:
167+
return (
168+
f"{self.__class__.__name__}("
169+
f"alpha={self.alpha!r}, "
170+
f"rho={self.rho!r})"
171+
)
172+
```
173+
174+
---
175+
176+
## Docstring Usage Examples
177+
178+
If the method has **non-standard usage** (e.g. requires double-sampling, a `set_losses` call
179+
before each forward, or manual state management), add a `.. testcode::` example in the
180+
docstring showing a realistic training step. Methods with standard usage (construct, call with
181+
a Jacobian matrix, done) should **not** add an example — the class hierarchy and parameter
182+
descriptions are sufficient.
183+
184+
Non-standard examples include:
185+
- Cross-batch `A = J_1 @ J_2.T` input (MoDo, SDMGrad) — show both `autojac.jac` calls and
186+
how to pass `A`.
187+
- `set_losses` requirement (e.g. GradNorm) — show the `weighting.set_losses(losses)` call
188+
immediately before the aggregation step.
189+
190+
---
191+
192+
## Attribution
193+
194+
If any code is adapted from an external implementation, add:
195+
1. A comment at the top of `_foo.py`:
196+
```python
197+
# Partly adapted from https://github.com/... — MIT License, Copyright (c) ...
198+
# See NOTICES for the full license text.
199+
```
200+
2. An entry in the `NOTICES` file following the existing template.
201+
202+
---
203+
204+
## Files to Create or Modify
205+
206+
| File | Action |
207+
|---|---|
208+
| `src/torchjd/aggregation/_foo.py` | Create — the implementation |
209+
| `src/torchjd/aggregation/__init__.py` | Add import + `__all__` entry (alphabetical) |
210+
| `tests/unit/aggregation/test_foo.py` | Create — mirror `test_modo.py` or `test_excess_mtl.py` |
211+
| `docs/source/docs/aggregation/foo.rst` | Create — mirror `modo.rst` |
212+
| `docs/source/docs/aggregation/index.rst` | Add `foo.rst` to toctree (alphabetical) |
213+
| `CHANGELOG.md` | Add entry under `[Unreleased] → Added` |
214+
| `NOTICES` | Add entry if code is adapted from an external source |
215+
216+
---
217+
218+
## Tests
219+
220+
Mirror `tests/unit/aggregation/test_modo.py` (for matrix-weighting) or
221+
`tests/unit/aggregation/test_excess_mtl.py` (for stateful + set_losses). At minimum cover:
222+
223+
- `test_representations` — verify `repr(...)` string exactly
224+
- `test_expected_structure_*` — use `assert_expected_structure` from `_asserts.py`,
225+
parametrize over `typical_matrices + scaled_matrices` from `_inputs.py`
226+
- `test_reset_restores_first_step_behavior` — call → call → `reset()` → call; third == first
227+
- setter tests — `*_accepts_valid` and `*_rejects_*` for every hyperparameter
228+
- `test_output_lies_on_simplex` — returned weights sum to 1 and are ≥ 0
229+
- `test_update_recurrence` — manually verify the formula for one step
230+
- `test_two_consecutive_steps` — verify warm-start carry-over if stateful
231+
- `test_changing_m_auto_resets` — state resets when number of tasks changes
232+
- `test_non_differentiable` — weights have no grad if `_NonDifferentiable`
233+
- `test_zero_columns``(m, 0)` input → output shape `(0,)`
234+
235+
**Always use `utils.tensors` partials** (`randn_`, `tensor_`, `ones_`, etc.) — never raw
236+
`torch.*`. This ensures tests run on CUDA/float64 via environment variables.
237+
238+
---
239+
240+
## Examples by Pattern
241+
242+
### Simple stateful wrapper (CR-MOGM)
243+
Wraps any `Weighting[_T]` generically, applies EMA to the output weights.
244+
`_CRMOGMWeighting(Weighting[_T], Stateful)` — generic over `_T`, no convenience aggregator.
245+
State: `_lambda: Tensor | None`, `_initial_weights: Tensor | None`.
246+
247+
### Cross-Gramian / double-sampling (MoDo, SDMGrad)
248+
Receives `A = J_1 @ J_2^T` (not PSD → use `_MatrixWeighting`, NOT `_GramianWeighting`).
249+
Users compute `A` via `autojac.jac` on two independent mini-batches.
250+
State: `_w: Tensor | None` (warm-started weights).
251+
Returns `w + λ·w̃` (SDMGrad) or `w` (MoDo) normalized to sum = 1.
252+
253+
### Stateful with Jacobian-sized state (ExcessMTL)
254+
`_ExcessMTLWeighting(_MatrixWeighting, Stateful, _NonDifferentiable)`.
255+
State key: `(m, n, dtype, device)` — because `_grad_sum` has shape `[m, n]`.
256+
Memory warning: `_grad_sum` is Jacobian-sized, held persistently. Document this.
257+
Uses `set_losses` if loss values are needed (ExcessMTL does not, but GradNorm would).

0 commit comments

Comments
 (0)