@@ -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+
4863class 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
123151class 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
144173def _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
244269class 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
334352class SharedGlobalMeanRemovalConfig :
0 commit comments