Skip to content

Commit 58d14e3

Browse files
committed
thread global mean removal state through forward/inverse explicitly
GlobalMeanRemoval used to cache per-step state (offsets / shifts and the normalized extras dict) on the instance between forward_transform and inverse_transform / extras_normalized. Correctness then depended on call order — interleaved or skipped calls silently produced wrong outputs. Refactor so forward_transform returns an opaque GlobalMeanRemovalState that callers thread through to inverse_transform and extras_normalized. The instance carries no per-step state; the RuntimeError("called before forward_transform") guard is no longer needed because the contract is enforced by the type signature. Changes: - fme.core.step.global_mean_removal: add GlobalMeanRemovalState dataclass; forward_transform returns (TensorDict, state); inverse_transform and extras_normalized take state. Remove the per-instance cached attributes. - fme.core.step.single_module.step_with_adjustments: capture state from forward_transform and pass it back to extras_normalized and inverse_transform. - Tests cover the new state-threading contract with interleaved forward/forward/inverse/inverse round-trips for both shared and per-channel variants.
1 parent 7d05620 commit 58d14e3

3 files changed

Lines changed: 150 additions & 109 deletions

File tree

fme/core/step/global_mean_removal.py

Lines changed: 83 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -45,37 +45,53 @@ def extra_channel_source_field(name: str) -> str | None:
4545
return None
4646

4747

48+
@dataclasses.dataclass
49+
class GlobalMeanRemovalState:
50+
"""Opaque token produced by ``forward_transform`` and consumed by the
51+
same ``GlobalMeanRemoval`` instance via ``inverse_transform`` and
52+
``extras_normalized``.
53+
54+
Callers should thread this value from ``forward_transform`` through
55+
to those methods, but not read its fields directly — layout is an
56+
implementation detail.
57+
"""
58+
59+
shifts: dict[str, torch.Tensor]
60+
extras: TensorDict
61+
62+
4863
class GlobalMeanRemoval(abc.ABC):
4964
"""Removes global means from fields before normalization and restores
5065
them after denormalization.
5166
5267
``forward_transform`` shifts input fields toward their climatological
53-
means. ``inverse_transform`` reverses that shift on output fields.
54-
Fields listed in ``field_names`` that appear only in the output (e.g.
55-
diagnostic temperatures) are not shifted by ``forward_transform``
56-
(they are not inputs), but *are* un-shifted by ``inverse_transform``.
57-
The network learns to compensate for this through end-to-end training,
58-
so all listed fields — whether input, output, or both — share the
59-
same global-mean offset.
68+
means and returns a ``GlobalMeanRemovalState`` that must be passed
69+
back to ``inverse_transform`` to reverse the shift on output fields.
70+
Threading the state explicitly (rather than caching it on the
71+
instance) makes the transform stateless and order-independent.
6072
6173
Optional synthetic input channels (when ``append_as_input=True``) are
6274
produced by ``forward_transform`` already in *normalized* space and
63-
exposed via ``extras_normalized()`` as a name -> tensor mapping
75+
exposed via ``extras_normalized(state)`` as a name -> tensor mapping
6476
keyed by ``extra_channel_names``. Callers append the sentinel names
6577
to their input packer's name list and merge the dict into the
6678
normalized-input dict, so the extras flow through packing and the
6779
channel-mask machinery uniformly with real input channels.
6880
81+
Fields listed in ``field_names`` that appear only in the output (e.g.
82+
diagnostic temperatures) are not shifted by ``forward_transform``
83+
(they are not inputs), but *are* un-shifted by ``inverse_transform``.
84+
The network learns to compensate for this through end-to-end training,
85+
so all listed fields — whether input, output, or both — share the
86+
same global-mean offset.
87+
6988
Note:
7089
"Global mean" here refers to a *cellwise* (unweighted) spatial
7190
mean of each sample, not the area-weighted global mean used
7291
elsewhere in ACE for stats and metrics. The two differ slightly
7392
on a non-uniform grid; this transform uses the cellwise mean
7493
for simplicity, since the network learns to compensate during
7594
end-to-end training.
76-
77-
Call sequence per step: ``forward_transform`` -> ``extras_normalized``
78-
-> ``inverse_transform``.
7995
"""
8096

8197
@property
@@ -98,26 +114,38 @@ def forward_transform(
98114
self,
99115
input: TensorMapping,
100116
data_mask: TensorMapping | None,
101-
) -> TensorDict:
117+
) -> tuple[TensorDict, GlobalMeanRemovalState]:
102118
"""Remove global means from denormalized input fields.
103119
104-
Caches internal state needed by ``inverse_transform`` and
105-
``extras_normalized``.
120+
Returns:
121+
``(shifted_input, state)``. ``state`` is an opaque value
122+
that must be passed back to ``inverse_transform`` to reverse
123+
the shift, and to ``extras_normalized`` to obtain the
124+
synthetic input channels.
106125
"""
107126

108127
@abc.abstractmethod
109-
def inverse_transform(self, output: TensorDict) -> TensorDict:
110-
"""Restore global means on denormalized output fields."""
128+
def inverse_transform(
129+
self,
130+
output: TensorDict,
131+
state: GlobalMeanRemovalState,
132+
) -> TensorDict:
133+
"""Restore global means on denormalized output fields using
134+
``state`` produced by ``forward_transform``.
135+
"""
111136

112-
@abc.abstractmethod
113-
def extras_normalized(self) -> TensorDict:
137+
def extras_normalized(
138+
self,
139+
state: GlobalMeanRemovalState,
140+
) -> TensorDict:
114141
"""Return the synthetic input channels in *normalized* space,
115142
keyed by the names in ``extra_channel_names``.
116143
117-
Each value has shape ``[batch, *spatial]`` (no channel dim).
118-
Returns an empty dict when no extras are configured. Must be
119-
called after ``forward_transform``.
144+
Each value has shape ``[batch, *spatial]`` (no channel dim) so
145+
it can be fed to the same packer that handles real inputs.
146+
Returns an empty dict if no extra channels are configured.
120147
"""
148+
return dict(state.extras)
121149

122150

123151
class NoGlobalMeanRemoval(GlobalMeanRemoval):
@@ -131,15 +159,16 @@ def forward_transform(
131159
self,
132160
input: TensorMapping,
133161
data_mask: TensorMapping | None,
134-
) -> TensorDict:
135-
return dict(input)
162+
) -> tuple[TensorDict, GlobalMeanRemovalState]:
163+
return dict(input), GlobalMeanRemovalState(shifts={}, extras={})
136164

137-
def inverse_transform(self, output: TensorDict) -> TensorDict:
165+
def inverse_transform(
166+
self,
167+
output: TensorDict,
168+
state: GlobalMeanRemovalState,
169+
) -> TensorDict:
138170
return output
139171

140-
def extras_normalized(self) -> TensorDict:
141-
return {}
142-
143172

144173
def _broadcast_to_spatial(
145174
scalar_per_sample: torch.Tensor, spatial_shape: tuple[int, ...]
@@ -174,8 +203,6 @@ def __init__(
174203
self._append_as_input = append_as_input
175204
self._reference_mean = reference_mean
176205
self._reference_std = reference_std
177-
self._cached_offset: torch.Tensor | None = None
178-
self._cached_extras: TensorDict = {}
179206

180207
@property
181208
def extra_channel_names(self) -> list[str]:
@@ -187,7 +214,7 @@ def forward_transform(
187214
self,
188215
input: TensorMapping,
189216
data_mask: TensorMapping | None,
190-
) -> TensorDict:
217+
) -> tuple[TensorDict, GlobalMeanRemovalState]:
191218
ref_name = self._reference_field
192219
if ref_name not in input:
193220
raise ValueError(
@@ -202,18 +229,14 @@ def forward_transform(
202229
ref = input[ref_name]
203230
sample_mean = ref.mean(dim=tuple(range(1, ref.ndim)))
204231
offset = self._reference_mean - sample_mean
205-
self._cached_offset = offset
206232
spatial_shape = tuple(ref.shape[1:])
207233

234+
extras: TensorDict = {}
208235
if self._append_as_input:
209236
normalized_mean = -offset / self._reference_std
210-
self._cached_extras = {
211-
_extra_channel_name(ref_name): _broadcast_to_spatial(
212-
normalized_mean, spatial_shape
213-
)
214-
}
215-
else:
216-
self._cached_extras = {}
237+
extras[_extra_channel_name(ref_name)] = _broadcast_to_spatial(
238+
normalized_mean, spatial_shape
239+
)
217240

218241
result = dict(input)
219242
for name in self._field_names:
@@ -222,24 +245,26 @@ def forward_transform(
222245
t = result[name]
223246
broadcast = offset.view(offset.shape[0], *(1,) * (t.ndim - 1))
224247
result[name] = t + broadcast
225-
return result
226248

227-
def inverse_transform(self, output: TensorDict) -> TensorDict:
228-
if self._cached_offset is None:
229-
raise RuntimeError("inverse_transform() called before forward_transform().")
230-
offset = self._cached_offset
249+
# All listed fields share the same offset; inverse will un-shift
250+
# those that appear in the output (including output-only fields).
251+
shifts = {name: offset for name in self._field_names}
252+
return result, GlobalMeanRemovalState(shifts=shifts, extras=extras)
253+
254+
def inverse_transform(
255+
self,
256+
output: TensorDict,
257+
state: GlobalMeanRemovalState,
258+
) -> TensorDict:
231259
result = dict(output)
232-
for name in self._field_names:
260+
for name, shift in state.shifts.items():
233261
if name not in result:
234262
continue
235263
t = result[name]
236-
broadcast = offset.view(offset.shape[0], *(1,) * (t.ndim - 1))
264+
broadcast = shift.view(shift.shape[0], *(1,) * (t.ndim - 1))
237265
result[name] = t - broadcast
238266
return result
239267

240-
def extras_normalized(self) -> TensorDict:
241-
return dict(self._cached_extras)
242-
243268

244269
class PerChannelGlobalMeanRemoval(GlobalMeanRemoval):
245270
"""Shift each field's per-sample cellwise spatial mean to its climatology.
@@ -265,8 +290,6 @@ def __init__(
265290
self._append_as_input = append_as_input
266291
self._means = means
267292
self._stds = stds
268-
self._cached_shifts: dict[str, torch.Tensor] | None = None
269-
self._cached_extras: TensorDict = {}
270293

271294
@property
272295
def extra_channel_names(self) -> list[str]:
@@ -278,7 +301,7 @@ def forward_transform(
278301
self,
279302
input: TensorMapping,
280303
data_mask: TensorMapping | None,
281-
) -> TensorDict:
304+
) -> tuple[TensorDict, GlobalMeanRemovalState]:
282305
result = dict(input)
283306
shifts: dict[str, torch.Tensor] = {}
284307
spatial_shape: tuple[int, ...] | None = None
@@ -298,8 +321,6 @@ def forward_transform(
298321
broadcast = shift.view(shift.shape[0], *(1,) * (t.ndim - 1))
299322
result[name] = t + broadcast
300323

301-
self._cached_shifts = shifts
302-
303324
extras: TensorDict = {}
304325
if self._append_as_input and spatial_shape is not None:
305326
for name in self._field_names:
@@ -309,26 +330,23 @@ def forward_transform(
309330
extras[_extra_channel_name(name)] = _broadcast_to_spatial(
310331
normalized, spatial_shape
311332
)
312-
self._cached_extras = extras
313333

314-
return result
334+
return result, GlobalMeanRemovalState(shifts=shifts, extras=extras)
315335

316-
def inverse_transform(self, output: TensorDict) -> TensorDict:
317-
if self._cached_shifts is None:
318-
raise RuntimeError("inverse_transform() called before forward_transform().")
336+
def inverse_transform(
337+
self,
338+
output: TensorDict,
339+
state: GlobalMeanRemovalState,
340+
) -> TensorDict:
319341
result = dict(output)
320-
for name in self._field_names:
321-
if name not in result or name not in self._cached_shifts:
342+
for name, shift in state.shifts.items():
343+
if name not in result:
322344
continue
323345
t = result[name]
324-
sh = self._cached_shifts[name]
325-
broadcast = sh.view(sh.shape[0], *(1,) * (t.ndim - 1))
346+
broadcast = shift.view(shift.shape[0], *(1,) * (t.ndim - 1))
326347
result[name] = t - broadcast
327348
return result
328349

329-
def extras_normalized(self) -> TensorDict:
330-
return dict(self._cached_extras)
331-
332350

333351
@dataclasses.dataclass
334352
class SharedGlobalMeanRemovalConfig:

fme/core/step/single_module.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from fme.core.step.global_mean_removal import (
2626
GlobalMeanRemoval,
2727
GlobalMeanRemovalConfigUnion,
28+
GlobalMeanRemovalState,
2829
NoGlobalMeanRemoval,
2930
extra_channel_source_field,
3031
)
@@ -550,24 +551,25 @@ def step_with_adjustments(
550551
"""
551552
if prescribed_prognostic_names is None:
552553
prescribed_prognostic_names = []
554+
gmr_state: GlobalMeanRemovalState | None = None
553555
if global_mean_removal is not None:
554-
network_input: TensorMapping = global_mean_removal.forward_transform(
556+
network_input, gmr_state = global_mean_removal.forward_transform(
555557
input, data_mask
556558
)
557559
else:
558-
network_input = input
560+
network_input = dict(input)
559561
input_norm = normalizer.normalize(network_input)
560-
if global_mean_removal is not None:
562+
if global_mean_removal is not None and gmr_state is not None:
561563
# Synthetic GMR channels are produced in normalized space; merge
562564
# them in after normalization so the network sees a single uniform
563565
# input dict.
564-
input_norm = {**input_norm, **global_mean_removal.extras_normalized()}
566+
input_norm = {**input_norm, **global_mean_removal.extras_normalized(gmr_state)}
565567
output_norm = network_calls(input_norm)
566568
if residual_prediction:
567569
output_norm = add_names(input_norm, output_norm, prognostic_names)
568570
output = normalizer.denormalize(output_norm)
569-
if global_mean_removal is not None:
570-
output = global_mean_removal.inverse_transform(output)
571+
if global_mean_removal is not None and gmr_state is not None:
572+
output = global_mean_removal.inverse_transform(output, gmr_state)
571573
if corrector is not None:
572574
corrector_state: CorrectorState | None = (
573575
stepper_state.corrector_state if stepper_state is not None else None

0 commit comments

Comments
 (0)