Skip to content

Commit f644c47

Browse files
committed
safe conditional eval flexmdm
1 parent bb2499b commit f644c47

3 files changed

Lines changed: 282 additions & 1 deletion

File tree

src/xlm/commands/push_to_hub.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,14 @@ def instantiate_model(
6969
) -> Harness:
7070
"""Instantiate a model from checkpoint for pushing to Hub.
7171
72+
Uses Harness.from_checkpoint(apply_ema=True) when a full Lightning
73+
checkpoint is available. This applies EMA weights AFTER load_state_dict
74+
completes, avoiding a Lightning quirk where on_load_checkpoint modifications
75+
to the state_dict are overwritten by the subsequent load_state_dict call.
76+
77+
Falls back to load_model_for_inference for model-only checkpoints (which
78+
have no EMA state to apply).
79+
7280
Args:
7381
cfg: Hydra config
7482
datamodule: Datamodule instance
@@ -77,12 +85,28 @@ def instantiate_model(
7785
Returns:
7886
Harness: The instantiated model ready to push to Hub
7987
"""
88+
hub_ckpt_path = cfg.get("hub_checkpoint_path", None)
89+
90+
if hub_ckpt_path is not None:
91+
harness_cls = hydra.utils.get_class(cfg.lightning_module._target_)
92+
module = harness_cls.from_checkpoint(
93+
checkpoint_path=hub_ckpt_path,
94+
cfg=cfg,
95+
tokenizer=tokenizer,
96+
datamodule=datamodule,
97+
apply_ema=True,
98+
map_location="cuda",
99+
)
100+
module.eval()
101+
return module
102+
103+
# model-only checkpoint path: no EMA state available
80104
module, _ = load_model_for_inference(
81105
cfg,
82106
datamodule,
83107
tokenizer,
84108
config_prefix="",
85-
manual_ema_restore=True,
109+
manual_ema_restore=False,
86110
move_to_device="cuda",
87111
set_eval_mode=True,
88112
enable_hub_support=False,
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
"""Tests verifying that EMA weights are correctly applied when loading from checkpoint.
2+
3+
These tests ensure that the `from_checkpoint(apply_ema=True)` path correctly
4+
overwrites model parameters with EMA shadow params, and that the broken
5+
`on_load_checkpoint` + `manual_ema_restore` path does NOT achieve this
6+
(documenting the Lightning quirk where load_state_dict overwrites
7+
on_load_checkpoint modifications).
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from pathlib import Path
13+
from unittest.mock import MagicMock
14+
15+
import pytest
16+
import torch
17+
from torch import nn
18+
19+
from torch_ema import ExponentialMovingAverage
20+
from xlm.utils.ema import EMACallback
21+
22+
23+
class _SimpleModel(nn.Module):
24+
"""Minimal model with enough parameters to distinguish EMA from raw."""
25+
26+
def __init__(self):
27+
super().__init__()
28+
self.embed = nn.Embedding(10, 8)
29+
self.linear = nn.Linear(8, 4)
30+
self.head = nn.Linear(4, 10)
31+
32+
33+
def _create_checkpoint_with_divergent_ema(model: nn.Module) -> dict:
34+
"""Create a fake Lightning checkpoint where EMA shadow params differ from raw.
35+
36+
Simulates a training run: start with raw weights, create EMA, then
37+
perturb the raw weights so they diverge from the EMA shadow.
38+
"""
39+
# Create EMA from current model weights
40+
trainable = [p for p in model.parameters() if p.requires_grad]
41+
ema = ExponentialMovingAverage(trainable, decay=0.99, use_num_updates=True)
42+
43+
# Perturb raw weights so they diverge from EMA shadows
44+
with torch.no_grad():
45+
for p in model.parameters():
46+
p.add_(torch.randn_like(p) * 0.5)
47+
48+
# Build checkpoint
49+
state_dict = {f"model.{k}": v.clone() for k, v in model.state_dict().items()}
50+
ema_state = ema.state_dict()
51+
52+
checkpoint = {
53+
"epoch": 5,
54+
"global_step": 1000,
55+
"pytorch-lightning_version": "2.0.0",
56+
"state_dict": state_dict,
57+
"loops": {},
58+
"callbacks": {},
59+
"optimizer_states": [],
60+
"lr_schedulers": [],
61+
"hparams_name": "cfg",
62+
"hyper_parameters": {},
63+
"ema": ema_state,
64+
}
65+
return checkpoint
66+
67+
68+
class TestApplyEmaWeightsCorrectness:
69+
"""Test that _apply_ema_weights correctly overwrites model params with EMA."""
70+
71+
def test_apply_ema_overwrites_params_with_shadow(self):
72+
"""After _apply_ema_weights, model params must match EMA shadow_params."""
73+
model = _SimpleModel()
74+
75+
# Record original weights (these become EMA shadows)
76+
original_params = [p.data.clone() for p in model.parameters()]
77+
78+
# Create EMA from current weights
79+
trainable = [p for p in model.parameters() if p.requires_grad]
80+
ema = ExponentialMovingAverage(trainable, decay=0.99, use_num_updates=True)
81+
ema_state = ema.state_dict()
82+
shadow_params = ema_state["shadow_params"]
83+
84+
# Perturb model weights so they differ from EMA
85+
with torch.no_grad():
86+
for p in model.parameters():
87+
p.add_(torch.randn_like(p) * 0.5)
88+
89+
# Verify model weights are now different from shadows
90+
for param, shadow in zip(model.parameters(), shadow_params):
91+
assert not torch.allclose(param.data, shadow, atol=1e-6), (
92+
"Test setup error: model weights should differ from EMA"
93+
)
94+
95+
# Apply EMA via the same mechanism as Harness._apply_ema_weights
96+
ema_restore = ExponentialMovingAverage(
97+
[p for p in model.parameters() if p.requires_grad],
98+
decay=ema_state["decay"],
99+
use_num_updates=ema_state.get("num_updates") is not None,
100+
)
101+
ema_restore.load_state_dict(ema_state)
102+
ema_restore.copy_to()
103+
104+
# Verify model weights now match EMA shadows
105+
for param, shadow in zip(model.parameters(), shadow_params):
106+
assert torch.allclose(param.data, shadow, atol=1e-7), (
107+
f"After _apply_ema_weights, model params must match EMA shadows. "
108+
f"Max diff: {(param.data - shadow).abs().max().item()}"
109+
)
110+
111+
def test_on_load_checkpoint_ema_gets_overwritten_by_load_state_dict(self):
112+
"""Demonstrate the Lightning quirk: copy_to() in on_load_checkpoint is
113+
overwritten by the subsequent load_state_dict call.
114+
115+
This documents WHY from_checkpoint(apply_ema=True) applies EMA AFTER
116+
load_state_dict rather than inside on_load_checkpoint.
117+
"""
118+
import lightning as L
119+
120+
class _MinimalLightningModule(L.LightningModule):
121+
def __init__(self):
122+
super().__init__()
123+
self.model = _SimpleModel()
124+
self._apply_ema_in_on_load = False
125+
126+
def on_load_checkpoint(self, checkpoint):
127+
if self._apply_ema_in_on_load and "ema" in checkpoint:
128+
ema_state = checkpoint["ema"]
129+
ema = ExponentialMovingAverage(
130+
[p for p in self.parameters() if p.requires_grad],
131+
decay=ema_state["decay"],
132+
use_num_updates=ema_state.get("num_updates") is not None,
133+
)
134+
ema.load_state_dict(ema_state)
135+
ema.copy_to()
136+
137+
# Create module and checkpoint with divergent EMA
138+
module = _MinimalLightningModule()
139+
ckpt = _create_checkpoint_with_divergent_ema(module.model)
140+
141+
# Save checkpoint to disk
142+
import tempfile
143+
with tempfile.NamedTemporaryFile(suffix=".ckpt", delete=False) as f:
144+
ckpt_path = f.name
145+
torch.save(ckpt, f)
146+
147+
try:
148+
# Load with on_load_checkpoint EMA application
149+
loaded = _MinimalLightningModule()
150+
loaded._apply_ema_in_on_load = True
151+
loaded = loaded.__class__.load_from_checkpoint(
152+
ckpt_path,
153+
map_location="cpu",
154+
)
155+
156+
# The loaded model should have RAW weights (not EMA) because
157+
# load_state_dict overwrites the EMA applied in on_load_checkpoint
158+
raw_state = ckpt["state_dict"]
159+
for name, param in loaded.named_parameters():
160+
if name in raw_state:
161+
assert torch.allclose(param.data, raw_state[name], atol=1e-7), (
162+
f"Expected on_load_checkpoint EMA to be overwritten by "
163+
f"load_state_dict for key {name}"
164+
)
165+
finally:
166+
Path(ckpt_path).unlink(missing_ok=True)
167+
168+
def test_post_load_ema_application_works(self):
169+
"""Verify that applying EMA AFTER load_state_dict completes works correctly.
170+
171+
This is the mechanism used by Harness.from_checkpoint(apply_ema=True).
172+
"""
173+
import lightning as L
174+
175+
class _MinimalLightningModule(L.LightningModule):
176+
def __init__(self):
177+
super().__init__()
178+
self.model = _SimpleModel()
179+
180+
# Create module and checkpoint with divergent EMA
181+
module = _MinimalLightningModule()
182+
ckpt = _create_checkpoint_with_divergent_ema(module.model)
183+
shadow_params = ckpt["ema"]["shadow_params"]
184+
185+
# Save checkpoint
186+
import tempfile
187+
with tempfile.NamedTemporaryFile(suffix=".ckpt", delete=False) as f:
188+
ckpt_path = f.name
189+
torch.save(ckpt, f)
190+
191+
try:
192+
# Load normally (no EMA in on_load_checkpoint)
193+
loaded = _MinimalLightningModule.load_from_checkpoint(
194+
ckpt_path, map_location="cpu"
195+
)
196+
197+
# Now apply EMA AFTER load (same as from_checkpoint does)
198+
ema_state = ckpt["ema"]
199+
ema = ExponentialMovingAverage(
200+
[p for p in loaded.parameters() if p.requires_grad],
201+
decay=ema_state["decay"],
202+
use_num_updates=ema_state.get("num_updates") is not None,
203+
)
204+
ema.load_state_dict(ema_state)
205+
ema.copy_to()
206+
207+
# Verify params now match EMA shadows
208+
for param, shadow in zip(
209+
(p for p in loaded.parameters() if p.requires_grad),
210+
shadow_params,
211+
):
212+
assert torch.allclose(param.data, shadow, atol=1e-7), (
213+
f"Post-load EMA application failed. "
214+
f"Max diff: {(param.data - shadow).abs().max().item()}"
215+
)
216+
217+
# Verify params do NOT match raw state_dict
218+
raw_state = ckpt["state_dict"]
219+
mismatches = 0
220+
for name, param in loaded.named_parameters():
221+
if name in raw_state:
222+
if not torch.allclose(param.data, raw_state[name], atol=1e-6):
223+
mismatches += 1
224+
assert mismatches > 0, (
225+
"After EMA application, params should differ from raw state_dict"
226+
)
227+
finally:
228+
Path(ckpt_path).unlink(missing_ok=True)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# @package _global_
2+
# Fragment eval overlay — compose on top of a de novo experiment, e.g.
3+
# experiment='[safe_flexmdm,safe_conditional_eval]'
4+
defaults:
5+
- override /datamodule: safe_flexmdm_fragment
6+
- override /post_hoc_evaluator: fragment
7+
8+
num_conditional_prediction_samples: 10
9+
molgen_task_name: motif_extension
10+
11+
log_predictions:
12+
inject_target: target_ids
13+
additional_fields_from_batch:
14+
- fragment_smiles
15+
- original_smiles
16+
fields_to_keep_in_output:
17+
- text
18+
- smiles
19+
- truth
20+
- truth_smiles
21+
- diversity
22+
- validity
23+
- uniqueness
24+
- qed
25+
- sa
26+
- quality
27+
- distance
28+
- fragment_smiles
29+
- original_smiles

0 commit comments

Comments
 (0)