Skip to content

Commit 5d2d7b6

Browse files
committed
feat(jepa): add frozen/EMA target encoder + compute_jepa_loss for FutureState
FutureState (autowarefoundation#80) predicts future BEV features and FeatureReconstructionLoss scores them, but the target side was missing. Per @ryotayamada's 'make it useful' list (autowarefoundation#56/autowarefoundation#13), add JepaTargetEncoder: a stop-gradient target encoder supporting both modes he named ('frozen or EMA'), plus compute_jepa_loss to fold the term into a training step. Additive and optional; does not touch AutoE2E.forward or the training loop. Frequency/horizons/weighting/data-pipeline remain open design decisions (Zain, 17-06 action item). Signed-off-by: GABRIELA CORDOVA <100548769@alumnos.uc3m.es>
1 parent 2d50805 commit 5d2d7b6

2 files changed

Lines changed: 303 additions & 0 deletions

File tree

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""Frozen / EMA target encoder for the JEPA feature-reconstruction objective.
2+
3+
Background
4+
----------
5+
Feature B (merged, #80) added the *prediction* side of the JEPA objective:
6+
7+
* ``FutureState`` predicts future BEV feature maps — a list of
8+
``num_future_steps`` tensors, each ``[B, C, H, W]``.
9+
* ``losses.FeatureReconstructionLoss`` scores those predictions against
10+
*target* feature maps of identical shape. Its own docstring states the
11+
targets are "extracted by a frozen copy of the image backbone (no gradient)
12+
applied to the future frames at +1.6s, +3.2s, +4.8s and +6.4s".
13+
14+
That **target encoder** was the missing piece. In Joint-Embedding Predictive
15+
Architectures (I-JEPA / V-JEPA) the targets come from an encoder that is *not*
16+
trained by backprop from the loss — it is either a **frozen** copy of the
17+
online encoder or an **exponential-moving-average (EMA)** of it, and its output
18+
is detached (stop-gradient). This is what prevents representational collapse
19+
(the predictor cannot win by driving every feature to zero, because the target
20+
encoder is not pulled along).
21+
22+
This module implements exactly that, supporting BOTH modes @RyotaYamada listed
23+
in #56 / #13 ("frozen or EMA") so the choice stays a *configuration* rather than
24+
a hard-coded decision.
25+
26+
Deliberately left open (owned by Zain — 17-06 action item, #13)
27+
---------------------------------------------------------------
28+
This module does NOT decide: the input frequency (1 Hz / TBD), the predictor
29+
space (BEV vs feature), the exact future horizons, the JEPA-vs-imitation loss
30+
weighting, or the data pipeline that supplies the future frames. It only
31+
provides the collapse-safe target generator those decisions will plug into.
32+
"""
33+
34+
import copy
35+
36+
import torch
37+
import torch.nn as nn
38+
39+
40+
class JepaTargetEncoder(nn.Module):
41+
"""Produce stop-gradient target features for the JEPA loss.
42+
43+
Wraps a *copy* of an online encoder (e.g. the image backbone, or the
44+
backbone+fusion path) and runs it without gradients to generate the target
45+
feature maps that ``FeatureReconstructionLoss`` compares ``FutureState``'s
46+
predictions against.
47+
48+
Args:
49+
encoder: the online encoder to mirror. A deep copy is taken at
50+
construction; the original is never modified by this module.
51+
mode: ``"frozen"`` (a fixed copy, never updated) or ``"ema"`` (updated
52+
from the online encoder via :meth:`update`). Default ``"ema"``.
53+
ema_decay: EMA momentum in ``[0, 1]``; only used in ``"ema"`` mode.
54+
``target ← decay * target + (1 - decay) * online``.
55+
56+
The wrapped encoder's parameters always have ``requires_grad=False`` and
57+
:meth:`forward` returns **detached** outputs, so no gradient ever reaches
58+
the target branch regardless of how the caller composes the loss.
59+
"""
60+
61+
def __init__(self, encoder: nn.Module, mode: str = "ema",
62+
ema_decay: float = 0.999):
63+
super().__init__()
64+
if mode not in ("frozen", "ema"):
65+
raise ValueError(f"mode must be 'frozen' or 'ema', got {mode!r}")
66+
if not 0.0 <= ema_decay <= 1.0:
67+
raise ValueError(f"ema_decay must be in [0, 1], got {ema_decay}")
68+
self.mode = mode
69+
self.ema_decay = ema_decay
70+
71+
# A detached, non-trainable mirror of the online encoder.
72+
self.encoder = copy.deepcopy(encoder)
73+
self.encoder.requires_grad_(False)
74+
self.encoder.eval()
75+
76+
@torch.no_grad()
77+
def update(self, online_encoder: nn.Module) -> None:
78+
"""EMA-update the target weights toward ``online_encoder``.
79+
80+
No-op in ``"frozen"`` mode. Buffers (e.g. BatchNorm running stats) are
81+
copied directly rather than averaged. Call once per optimizer step,
82+
after the online encoder has been updated.
83+
"""
84+
if self.mode != "ema":
85+
return
86+
d = self.ema_decay
87+
for t_p, o_p in zip(self.encoder.parameters(),
88+
online_encoder.parameters()):
89+
t_p.mul_(d).add_(o_p.detach(), alpha=1.0 - d)
90+
for t_b, o_b in zip(self.encoder.buffers(), online_encoder.buffers()):
91+
t_b.copy_(o_b)
92+
93+
@torch.no_grad()
94+
def forward(self, future_observations):
95+
"""Encode each future observation into a detached target feature map.
96+
97+
Args:
98+
future_observations: an iterable of ``num_future_steps`` tensors,
99+
each a valid input to the wrapped encoder (e.g. future camera
100+
frames or future fused features), ordered by horizon.
101+
102+
Returns:
103+
list of ``num_future_steps`` detached feature maps ``[B, C, H, W]``,
104+
ready to pass as ``target_features`` to
105+
:class:`FeatureReconstructionLoss`.
106+
"""
107+
self.encoder.eval()
108+
return [self.encoder(obs).detach() for obs in future_observations]
109+
110+
111+
def compute_jepa_loss(predicted_features, future_observations, target_encoder,
112+
loss_fn, weight: float = 1.0):
113+
"""Glue helper: targets ← target_encoder, then weighted JEPA loss.
114+
115+
This is the single call a training loop adds to fold the JEPA objective in
116+
alongside the trajectory imitation loss, e.g.::
117+
118+
total = imitation_loss + compute_jepa_loss(
119+
future, future_frames, target_encoder, recon_loss, weight=0.1)
120+
121+
Args:
122+
predicted_features: ``FutureState`` output (list of ``[B, C, H, W]``).
123+
future_observations: inputs for ``target_encoder`` (one per horizon).
124+
target_encoder: a :class:`JepaTargetEncoder`.
125+
loss_fn: a :class:`FeatureReconstructionLoss` (or compatible callable).
126+
weight: scalar coefficient for the JEPA term (``lambda``).
127+
128+
Returns:
129+
``weight * loss_fn(predicted_features, targets)`` — a scalar tensor
130+
differentiable w.r.t. ``predicted_features`` only (targets are detached).
131+
"""
132+
targets = target_encoder(future_observations)
133+
return weight * loss_fn(predicted_features, targets)
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
"""Unit tests for the JEPA target encoder (frozen / EMA, stop-gradient).
2+
3+
Verifies the collapse-safety guarantees of the target branch and that it
4+
plugs into the already-merged ``FutureState`` + ``FeatureReconstructionLoss``:
5+
- the target encoder never carries gradient (frozen params, detached output),
6+
- "frozen" mode is a stable deep copy independent of the online encoder,
7+
- "ema" mode moves the target toward the online encoder on ``update()``,
8+
- ``compute_jepa_loss`` is differentiable w.r.t. the prediction ONLY,
9+
- end-to-end with the real FutureState world-model head.
10+
"""
11+
12+
import pytest
13+
import torch
14+
import torch.nn as nn
15+
16+
from model_components.future_state import FutureState
17+
from model_components.jepa_target_encoder import (
18+
JepaTargetEncoder,
19+
compute_jepa_loss,
20+
)
21+
from model_components.losses.feature_reconstruction_loss import (
22+
FeatureReconstructionLoss,
23+
)
24+
25+
EMBED_DIM = 256
26+
NUM_FUTURE = 4
27+
28+
29+
class _TinyEncoder(nn.Module):
30+
"""Maps a feature map [B, C, H, W] -> [B, C, H, W] (stand-in for backbone)."""
31+
32+
def __init__(self, c=EMBED_DIM):
33+
super().__init__()
34+
self.conv = nn.Conv2d(c, c, 3, padding=1)
35+
self.bn = nn.BatchNorm2d(c)
36+
37+
def forward(self, x):
38+
return self.bn(self.conv(x))
39+
40+
41+
def _future_obs(batch, device, h=8, w=8):
42+
return [torch.randn(batch, EMBED_DIM, h, w, device=device)
43+
for _ in range(NUM_FUTURE)]
44+
45+
46+
def test_invalid_mode_and_decay():
47+
enc = _TinyEncoder()
48+
with pytest.raises(ValueError, match="mode must be"):
49+
JepaTargetEncoder(enc, mode="bogus")
50+
with pytest.raises(ValueError, match="ema_decay"):
51+
JepaTargetEncoder(enc, mode="ema", ema_decay=1.5)
52+
53+
54+
def test_target_params_are_frozen(device):
55+
target = JepaTargetEncoder(_TinyEncoder(), mode="frozen").to(device)
56+
assert all(not p.requires_grad for p in target.parameters())
57+
58+
59+
def test_forward_output_is_detached(device):
60+
target = JepaTargetEncoder(_TinyEncoder(), mode="ema").to(device)
61+
outs = target(_future_obs(2, device))
62+
assert len(outs) == NUM_FUTURE
63+
for o in outs:
64+
assert not o.requires_grad
65+
assert o.grad_fn is None
66+
assert o.shape == (2, EMBED_DIM, 8, 8)
67+
68+
69+
def test_frozen_is_independent_deep_copy(device):
70+
online = _TinyEncoder().to(device)
71+
target = JepaTargetEncoder(online, mode="frozen").to(device)
72+
before = [p.clone() for p in target.encoder.parameters()]
73+
74+
# Mutate the ONLINE encoder; the frozen target must not change.
75+
with torch.no_grad():
76+
for p in online.parameters():
77+
p.add_(1.0)
78+
target.update(online) # no-op in frozen mode
79+
80+
for b, p in zip(before, target.encoder.parameters()):
81+
assert torch.equal(b, p)
82+
83+
84+
def test_ema_update_moves_target_toward_online(device):
85+
torch.manual_seed(0)
86+
online = _TinyEncoder().to(device)
87+
target = JepaTargetEncoder(online, mode="ema", ema_decay=0.9).to(device)
88+
89+
# Push the online encoder far away, then EMA-update once.
90+
with torch.no_grad():
91+
for p in online.parameters():
92+
p.add_(10.0)
93+
94+
t_before = [p.clone() for p in target.encoder.parameters()]
95+
target.update(online)
96+
97+
for tb, t_after, o_p in zip(t_before, target.encoder.parameters(),
98+
online.parameters()):
99+
moved = (t_after - tb).abs().sum().item()
100+
assert moved > 0.0, "ema target did not move"
101+
# decay=0.9 -> target should move ~10% of the way, not all the way.
102+
assert not torch.allclose(t_after, o_p), "target jumped fully to online"
103+
expected = 0.9 * tb + 0.1 * o_p
104+
assert torch.allclose(t_after, expected, atol=1e-5)
105+
106+
107+
def test_compute_jepa_loss_zero_when_prediction_matches_target(device):
108+
target = JepaTargetEncoder(_TinyEncoder(), mode="frozen").to(device)
109+
loss_fn = FeatureReconstructionLoss(num_future_steps=NUM_FUTURE).to(device)
110+
obs = _future_obs(2, device)
111+
targets = target(obs)
112+
predicted = [t.clone().requires_grad_(True) for t in targets]
113+
loss = compute_jepa_loss(predicted, obs, target, loss_fn, weight=1.0)
114+
assert loss.ndim == 0
115+
assert loss.item() == pytest.approx(0.0, abs=1e-6)
116+
117+
118+
def test_compute_jepa_loss_weight_scales_linearly(device):
119+
target = JepaTargetEncoder(_TinyEncoder(), mode="frozen").to(device)
120+
loss_fn = FeatureReconstructionLoss(num_future_steps=NUM_FUTURE).to(device)
121+
obs = _future_obs(3, device)
122+
predicted = [torch.randn(3, EMBED_DIM, 8, 8, device=device)
123+
for _ in range(NUM_FUTURE)]
124+
l1 = compute_jepa_loss(predicted, obs, target, loss_fn, weight=1.0)
125+
l2 = compute_jepa_loss(predicted, obs, target, loss_fn, weight=0.25)
126+
assert l2.item() == pytest.approx(0.25 * l1.item(), rel=1e-5)
127+
128+
129+
def test_gradient_flows_to_prediction_only(device):
130+
target = JepaTargetEncoder(_TinyEncoder(), mode="ema").to(device)
131+
loss_fn = FeatureReconstructionLoss(num_future_steps=NUM_FUTURE).to(device)
132+
obs = _future_obs(2, device)
133+
predicted = [torch.randn(2, EMBED_DIM, 8, 8, device=device,
134+
requires_grad=True) for _ in range(NUM_FUTURE)]
135+
136+
loss = compute_jepa_loss(predicted, obs, target, loss_fn, weight=0.5)
137+
loss.backward()
138+
139+
assert all(p.grad is not None for p in predicted), \
140+
"prediction must receive gradient"
141+
assert all(p.grad is None for p in target.parameters()), \
142+
"target encoder must NOT receive gradient (stop-gradient)"
143+
144+
145+
def test_end_to_end_with_future_state(device):
146+
"""FutureState (predictor) + JepaTargetEncoder (targets) + recon loss.
147+
148+
Mirrors the intended training step: the world-model head predicts future
149+
features, targets come from the frozen/EMA encoder on future observations,
150+
and the JEPA loss back-props into FutureState but never into the target."""
151+
torch.manual_seed(0)
152+
future_state = FutureState(embed_dim=EMBED_DIM,
153+
ego_hidden_dim=EMBED_DIM).to(device)
154+
target = JepaTargetEncoder(_TinyEncoder(), mode="ema").to(device)
155+
loss_fn = FeatureReconstructionLoss(num_future_steps=NUM_FUTURE).to(device)
156+
157+
fused = torch.randn(2, EMBED_DIM, 8, 8, device=device)
158+
ego_hidden = torch.randn(2, EMBED_DIM, device=device)
159+
predicted = future_state(fused, ego_hidden)
160+
assert len(predicted) == NUM_FUTURE
161+
162+
future_obs = _future_obs(2, device)
163+
loss = compute_jepa_loss(predicted, future_obs, target, loss_fn, weight=0.1)
164+
assert loss.ndim == 0 and torch.isfinite(loss)
165+
166+
loss.backward()
167+
assert any(p.grad is not None for p in future_state.parameters()), \
168+
"FutureState must receive gradient from the JEPA loss"
169+
assert all(p.grad is None for p in target.parameters()), \
170+
"target encoder must stay gradient-free"

0 commit comments

Comments
 (0)