Skip to content

Add translate named data streams and paired-by-time multi-resolution loading - #1394

Open
mcgibbon wants to merge 14 commits into
feature/translate-skeletonfrom
feature/translate-data-streams
Open

Add translate named data streams and paired-by-time multi-resolution loading#1394
mcgibbon wants to merge 14 commits into
feature/translate-skeletonfrom
feature/translate-data-streams

Conversation

@mcgibbon

@mcgibbon mcgibbon commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

PR 2 of the fme/translate series, targeting the long-lived base branch
feature/translate-skeleton (PR #1360) rather than main.

Translate training reads several named data streams at once — the same state at
1°/2°/4°, or ERA5 alongside SHiELD. Some of those streams must be sampled at the
same valid times (a translation objective comparing 1° and 2° fields of one state
is meaningless if the two are drawn from different times) and some must not be
(the transfer-learning program's two streams are unpaired distributions). This
adds fme/translate/data/: named streams, that pairing, and the multi-stream
GriddedDataABC implementation training will consume.

Whether two streams are sampled together is derived, never configured. Each
objective declares what it needs from each stream it consumes; every objective
ties its streams together, and the connected components of the resulting graph
are the pairing groups. Streams in a group share a time index; groups are
sampled independently. Grouping is transitive, so objectives over (a, b) and
(b, c) put all three on one index. A sampling: knob in the config could
contradict the objectives; a derivation cannot.

The requirements type is the seam PR 3 (objectives) will produce: it constructs
one ObjectiveDataRequirements per objective and calls
TranslateDataRequirements.from_objectives, which merges per-stream variable
names as an ordered union and per-stream window length as the pointwise maximum
of the objectives' IntSchedules (ace loads a window of the scheduled length and
each objective samples a prefix of it, so loading the longest window any objective
asks for serves them all). Nothing in the seam depends on the component pool: the
loader takes variable names from the requirements, not from the pool's domain
channel lists.

Alignment within a group is validated when the group is built, since pairing by
index is otherwise silently wrong: the streams must share a timestep, and their
sample start times must match index by index, with the first offending index and
its two times named in the error. A group's length is the minimum over its
streams, because objectives asking for different window lengths leave different
numbers of valid start times.

n_samples/n_batches/seeding semantics under unequal groups: n_batches is the
minimum over the groups (iteration stops with the shortest, so no group is
re-drawn mid-epoch), and n_samples is n_batches * batch_size — samples drawn
per group, not summed across groups, so it stays the count a per-batch metric
divides by. Each group's sampler is built with its own seed_offset; without
that, two equal-length groups receive the same permutation and are index-paired
in fact while claiming to be independent (a test fails if the offset is removed).

TranslateGriddedData.dataset_info is keyed by domain, not stream: that is
the pairing key ComponentPoolConfig.build consumes, and streams sharing a
domain are checked to describe compatible datasets via
DatasetInfo.assert_compatible_with, with their variable metadata and labels
unioned.

Changes:

  • fme.translate.data.requirements: StreamRequirements,
    ObjectiveDataRequirements, and TranslateDataRequirements.from_objectives
    the objective-to-data seam and its merge (name union, pointwise-max schedule,
    pairing groups).
  • fme.translate.data.config: StreamConfig (domain, dataset, optional
    name defaulting to the domain via stream_name) and
    TranslateDataLoaderConfig, mirroring fme.ace.data_loading.config.DataLoaderConfig's
    field names and validating in __post_init__.
  • fme.translate.data.batch_data: TranslateBatchData, a
    Mapping[str, BatchData] generalizing fme.coupled's two-component batch
    type, plus TranslateCollateFn.
  • fme.translate.data.dataset: PairedStreamDataset, an N-stream
    GenericDataset that pairs by index and validates timestep and start-time
    alignment at build time.
  • fme.translate.data.dataloader, .gridded_data, .getters:
    TranslateDataLoader (one per pairing group, all behavior from
    GenericDataLoader), TranslateGriddedData (zips the groups, publishes
    domain-keyed dataset_info), and get_gridded_data(config, requirements, train).
  • fme.core.distributed.Distributed.get_sampler: new seed_offset argument
    (default 0, existing callers unaffected) so several samplers over equal-length
    datasets can be shuffled independently rather than in lockstep. Added here
    rather than mutating the returned sampler so the spatial-parallel num_replicas
    and rank logic stays in one place.
  • fme/translate/examples/: the README table and the two configs' headers now
    say which blocks parse — train_data:/validation_data: with this PR, and
    component_pool: only once PR 1's remaining rework (channels-on-transforms
    with derived domain load lists, int-declared latent blocks, identity
    normalization) lands, which is tracked separately and is why the loader takes
    variable names from the requirements seam rather than the pool config.

Design points worth a reviewer's attention:

  • PairedStreamDataset is translate-local rather than a reuse of
    fme.downscaling's FineCoarsePairedDataset, which is hard-wired to a
    fine/coarse pair and to single-timestep items (its BatchItemDatasetAdapter
    squeezes the time dimension away) and so cannot carry the time windows a
    forward-prediction objective needs. fme.coupled.CoupledDataset is the closer
    analogue but is likewise fixed at two named components. What is reused is the
    machinery under both: GenericDataset/GenericDataLoader supply subsetting,
    epoch propagation, shuffle control, batch_size and n_samples.

  • Unequal but integer-ratio timesteps within a group (fme.coupled's
    n_steps_fast index scaling) is a deliberate non-feature; the docstring names
    it as the path if an objective ever needs it.

  • Batches are moved to the device but not spatially scattered, following
    fme.coupled: spatial model parallelism would need each stream scattered
    against its own grid, which no translate module supports yet. Data-parallel
    sharding is handled. get_gridded_data calls
    Distributed.require_no_spatial_parallelism, so a spatially-parallel run
    fails rather than handing every co-rank the whole global field.

  • DataRequirements.allow_missing_variables is refused rather than ignored:
    StreamConfig.get_dataset does not plumb it to DatasetABC.build, so a
    requirements object carrying it would load every variable and report success.

  • ace's time_buffer window-reuse options are not offered: they draw several
    output batches from one preloaded window, which would break the index pairing.

  • A configured stream that no objective consumes is an error, not a silent
    unused load.

  • Tests added

  • If dependencies changed, "deps only" image rebuilt and "latest_deps_only_image.txt" file updated

mcgibbon added 10 commits July 28, 2026 23:47
Lets several samplers over equal-length datasets be shuffled
independently of one another rather than in lockstep, which the
translate multi-stream loader needs for its independently-sampled
pairing groups.
Defines what the objectives will declare they need from each named data
stream (StreamRequirements, ObjectiveDataRequirements) and the merge the
data layer consumes (TranslateDataRequirements.from_objectives):
per-stream variable names as an ordered union, window length as the
pointwise maximum of the objectives' schedules, and the pairing groups
as connected components of the stream co-occurrence graph.

Deriving pairing from co-occurrence rather than configuring it means a
sampling knob cannot contradict the objectives: streams compared in some
objective are sampled at the same valid times, streams that never
co-occur are sampled independently.
Generalizes fme.coupled's CoupledBatchData from two fixed components to
an arbitrary set of named streams: a dict[str, BatchData] with per-stream
access, device movement, the epoch ace's LossSchedule consumes, and a
merge that assembles independently sampled groups' batches into one.

Time-window manipulation is deliberately not fanned out over all streams
— the objectives apply it per stream, knowing which of theirs is input,
target and forcing.
TranslateDataLoaderConfig is a list of streams, each binding an ace
dataset config to the domain it serves; a stream's name defaults to its
domain. There is no sampling knob: get_gridded_data builds one dataset
per stream and one PairedStreamDataset per derived pairing group, so
streams an objective compares are sampled at the same valid times and
streams no objective compares are sampled independently.

A group validates its alignment when it is built — equal timesteps, and
sample start times matching index by index, with the first offending
index and its two times named — because pairing by index is otherwise
silently wrong. A group's length is the minimum over its streams, since
objectives asking for different window lengths leave different numbers of
valid start times.

TranslateGriddedData zips the groups' loaders, giving each group's
sampler its own seed offset: without that, two equal-length groups get
the same permutation and are index-paired in fact while claiming to be
independent. n_batches is the minimum over groups and n_samples counts
samples per group. It also publishes dataset_info keyed by domain, the
pairing key ComponentPoolConfig.build consumes, validating that streams
sharing a domain describe compatible datasets.
Builds a 1°/2°/4° component pool from the loader's dataset_info and
pushes a loaded 1° batch through a transform built that way, which is the
one path where a stream/domain misbinding would otherwise be silent.
Updates the example configs to stop saying only component_pool: is
implemented.
The component_pool: blocks do not parse until the channels-on-transforms
rework lands, so say so rather than calling the block implemented.
The duplicate-pairing-group check was unreachable: a stream in two groups
also fails the partition check, which ran first and reported it as a
partition mismatch. Reorder so each error names what is actually wrong.

DataRequirements carries allow_missing_variables, but StreamConfig.get_dataset
does not pass it to DatasetABC.build, so a requirements object with it set
would load every variable and claim success. Refuse it instead.
Batches are moved to the device but never scattered spatially, so under
spatial parallelism every co-rank would receive the whole global field
instead of its shard. require_no_spatial_parallelism exists for exactly
this kind of known-incorrect path; fail rather than run it.
A group's length and its start-time alignment are computed once, when the
PairedStreamDataset is built, so both are only correct because a stream's
number of valid start times is set by its schedule's longest window
(XarrayDataset uses n_timesteps.max_value) rather than by the current
epoch. Nothing was holding that; a change to epoch-dependent lengths
would have made the cached length and alignment prefix silently stale.
num_data_workers buys one worker pool per group, so process count and
prefetched host memory scale with the number of groups. And
TranslateGriddedData's docstring claimed a stream partition it does not
check; name where the guarantee actually comes from.

@mcgibbon mcgibbon left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude: pre-review pass (independent agent, no authoring context). Verdict: ready for review after the four fixes I pushed58c0a7a. I found no correctness bug in the pairing, alignment, merge or sizing logic; the things I changed are a silently-wrong path under spatial parallelism, two requirements that were silently ignored, one unreachable error branch, and a test locking in an invariant the design depends on. Details in the thread comments; two design questions I deliberately left for Jeremy rather than settling here.

What I verified rather than took on trust:

  • seed_offset placement and back-compat. Every existing caller (fme/ace/data_loading/getters.py:71, fme/downscaling/data/config.py, the two test modules) passes no offset and gets seed=self._seed exactly as before. The stated reason for not offsetting the epoch instead holds, and is in fact stronger than the description says: GenericDataLoader.set_epoch calls self._dataset.set_epoch(epoch), which reaches XarrayDataset._global_epoch; that feeds both DatasetItem's epoch (→ BatchData.epochLossSchedule.init_for_epoch at fme/ace/stepper/single_module.py:1632) and _ensure_epoch_synchronized's _n_timesteps_schedule.get_value(epoch). So an epoch offset would corrupt the loss schedule and shift the window-length schedule per group. Offsetting the seed is right.
  • Independent-group sampling. The independence test is load-bearing: patching seed_offset=group_index to seed_offset=0 in getters.py makes test_independently_sampled_streams_are_not_time_locked fail (assert not True), everything else still passing. On the RandomSampler question — there is no such path here. Distributed.get_sampler always returns a DistributedSampler, and translate always goes through it, so the mechanism is uniform serially and distributed (ace only reaches RandomSampler via sample_with_replacement, which this config deliberately does not expose). That also means set_epoch/alternate_shuffle's isinstance(..., DistributedSampler) branches are always taken.
  • Pointwise-max schedule. Correct at every epoch, including 0 and epochs between milestones: each input schedule is piecewise-constant with breakpoints only at its own milestones, so the max is piecewise-constant with breakpoints only in the union — evaluating at the union epochs and holding between them is exact. ValidatedMilestones rejects a milestone at epoch ≤ 0, so start_value = max(get_value(0)) is not shadowed by an epoch-0 milestone.
  • Group length is not off by one. XarrayDataset._n_initial_conditions = total_timesteps - n_timesteps.max_value + 1, so min(len(dataset)) over a group is exactly the shared valid-start count (8 times / window 4 → 5, matching the test), and the alignment prefix compare is over equal-length slices. Alignment and timestep are validated in PairedStreamDataset.__init__, which runs before the loader exists, so no sample can be drawn misaligned.
  • GriddedDataABC conformance. All six abstract members implemented; n_samples/n_batches/batch_size match fme.ace's GriddedData semantics (local batch size, n_batches * batch_size). TranslateDataLoader adds no __init__ args, so GenericDataLoader.subset's self.__class__(dataset=, sampler=, collate_fn=, **kwargs) re-instantiation is safe — and subset freezes each group's sampler order independently, which keeps within-group pairing while leaving groups independent.
  • Tests pass (79 in fme/translate, 479 in fme/core/distributed + fme/core/test_rand.py + fme/ace/data_loading), pre-commit run --files clean on every changed file.

Fixes pushed (each its own commit):

  1. 9ff7dbb — the duplicate-pairing-group check in TranslateDataRequirements.__post_init__ was unreachable (a stream in two groups also fails the partition check, which ran first and misreported it), and DataRequirements.allow_missing_variables was silently dropped by the loader. Reordered, and refuse the flag.
  2. 84ddd70require_no_spatial_parallelism guard in get_gridded_data.
  3. 4a0c199 — test that a scheduled window keeps the group's start-time count.
  4. 58c0a7anum_data_workers is per pairing group; drop a docstring guarantee that isn't checked.

Revert any of these freely if you disagree — 2 in particular is a behavior change I judged worth making, not an obvious one.

Two things I think are Jeremy's call, not mine, both flagged in threads below: whether n_batches = min over groups is the right definition of an epoch when groups are very unequal, and whether an unconsumed configured stream should error.

num_replicas=self._distributed.total_data_parallel_ranks,
rank=self._distributed.data_parallel_rank,
seed=self._seed,
seed=self._seed + seed_offset,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude: this is the right place — the alternative (mutating the returned sampler's seed) would either duplicate the num_replicas/rank logic or reach into DistributedSampler internals, and adding a full seed= override would break set_seed's single point of control. Back-compatible for all four existing call sites.

One property worth a line of comment, because it is easy to over-claim from the docstring: DistributedSampler.__iter__ seeds with self.seed + self.epoch, so offset k at epoch e yields the same permutation as offset 0 at epoch e+k. Within any single epoch the offsets differ and the groups are genuinely independent, which is the thing that matters here (and alternate_shuffle's alternate_seed(epoch) keeps them distinct too). But the offsets are a shift along one seed axis, not independent streams, so nothing later should rely on cross-epoch independence between two groups. If you want that property outright, self._seed + seed_offset * <large stride> gets it for free.

raise ValueError("At least one pairing group's loader is required.")
self._loaders = list(loaders)

def __len__(self) -> int:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude: for Jeremy, not settled in pre-review. min over groups makes the epoch as long as the shortest pairing group. The reasoning given (a shorter group re-drawn mid-epoch correlates its samples within the epoch) is right, but the cost lands squarely on the transfer-learning program, where the two streams are independent groups with no reason to be the same length: if ERA5 has 10x the samples of SHiELD, an "epoch" touches a tenth of ERA5, and everything keyed to epochs (LR schedule, n_forward_steps milestones, max_epochs, validation cadence) is now denominated in the short stream. Coverage over many epochs is fine, since each epoch reshuffles — this is about what "epoch" means, not about wasted data.

The alternatives are all worse in their own way (cycling the short group correlates it; padding it duplicates samples; per-group epoch counters make the schedule ambiguous), so I don't think min is wrong. But it deserves to be a decision you made rather than a consequence you find out about when the first transfer-learning run's LR schedule finishes 10x early. Worth at least a line in the config docstring telling the user to size the streams comparably.

unused = sorted(configured - required)
if unused:
raise ValueError(
f"These data streams are configured but no objective consumes them: "

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude: on the debatable one — I think the error is right, and for a stronger reason than "don't silently load unused data". Since pairing is derived from the objectives, a stream no objective names has no group, so the failure mode of tolerating it is not a wasted read, it's a stream that is configured, never sampled, and never mentioned again. The overwhelmingly likely cause is a typo in a stream name or an objective edited without its stream, and both are exactly what you want to fail on. It is also the easy direction to relax later: turning an error into a warning breaks nobody.

The one case to keep an eye on is PR 3 introducing a train-only objective, since train_data: and validation_data: are validated against the same requirements — a stream only a train-side objective consumes would still have to appear in validation_data:. That's a fine constraint today (both example configs mirror their stream lists) but it is worth remembering when the objective list grows a train/validation distinction.

)


def _domain_dataset_info(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude: two things about comparing every stream against entries[0] rather than pairwise or cumulatively.

assert_compatible_with skips a field entirely when either side is None (fme/core/dataset_info.py:100-135). So if the first stream has infer_timestep: false (timestep=None) or a NullVerticalCoordinate, that field goes unchecked against every other stream serving the domain, and streams 2 and 3 are never compared to each other — three streams can pass while two of them disagree. Then the returned DatasetInfo takes horizontal_coordinates, vertical_coordinate, spatial_mask_provider and timestep from the reference alone, so the domain can be published with timestep=None even though the streams that were compatible-by-omission all carry one.

Not reachable from either example config (both name real datasets with inferred timesteps), so I left it. If you want it airtight, compare each new stream against the accumulated info and rebuild info as you go, rather than holding the reference fixed.

raise ValueError("StreamRequirements requires at least one variable name.")

@property
def n_timesteps_schedule(self) -> IntSchedule:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude: nit — this constructs a whole DataRequirements (and copies names) to read a two-branch property. IntSchedule.from_constant is right there, and the union is normalized in one place either way:

if isinstance(self.n_timesteps, IntSchedule):
    return self.n_timesteps
return IntSchedule.from_constant(self.n_timesteps)

The delegation reads as if it were load-bearing (the comment says it is) when what it buys is avoiding a duplicated three-line branch, at the cost of a reader chasing into fme.ace.requirements to find out that's all it does. Your call; I didn't want to churn the seam.

Comment thread fme/translate/data/dataset.py Outdated
)

@property
def stream_names(self) -> list[str]:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude: stream_names has no caller and no test. Its natural use would have been letting TranslateGriddedData check that its group loaders' streams partition stream_properties — the guarantee its docstring claimed — but GenericDataLoader doesn't expose its dataset, so the check can't be made from there. I softened that docstring in 58c0a7a instead. Either drop this property or wait for PR 3 to actually need it; as it stands it reads like an API someone forgot to wire up.

Same observation, lower stakes, for TranslateGriddedData.variable_metadata and TranslateBatchData.to_cpu — no consumer yet. Those two have direct counterparts in fme.ace/fme.coupled that PRs 3-4 will plausibly want, so forward-declaring them is more defensible.

streams={name: batch.to_cpu() for name, batch in self.streams.items()}
)

def pin_memory(self) -> "TranslateBatchData":

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude: pin_memory mutates self.streams and returns self, while to_device/to_cpu build a new TranslateBatchData. BatchData's own methods are all copy-returning, so the odd one out here is a reader trap even though torch's pin-memory hook doesn't care which you do. Making it return TranslateBatchData(streams={...}) costs nothing and makes the three read the same.

Separately, on the surface as a whole: this defines __getitem__/__contains__/__iter__/__len__ with Mapping semantics (__iter__ yields keys) without being a Mapping, so batch.keys(), batch.items() and **batch all fail while sorted(batch) and name in batch work. Either inherit collections.abc.Mapping[str, BatchData] and get the rest for free, or drop the dunders and let callers use .streams — the half-Mapping is the version a reader has to test to understand. Not blocking; the tests only use the dunders that exist.

mcgibbon added 4 commits July 29, 2026 00:47
Checking each stream against the first one only was not transitive:
assert_compatible_with skips a comparison either side declines to make, so
a stream with a NullVerticalCoordinate is compatible with any two streams
that disagree with each other. And the timestep comparison is guarded on
the caller's timestep being set, so a stream with no inferred timestep
passed against one that had it — after which the domain published that
None for every stream serving it, since the returned info takes its
coordinates from the first entry.

Comparing every ordered pair fixes both, and makes taking those shared
fields from the first entry sound.
Building a DataRequirements to read a two-branch property was indirection
for its own sake; IntSchedule.from_constant is the same normalization.
Neither is referenced. Wiring stream_names into a partition check in
TranslateGriddedData would not enforce more than is already enforced:
GenericDataLoader keeps its dataset private and wraps it in a _Subset on
subset(), which forwards no such property, so the check would have to read
the streams back from the caller — and the caller derives them from
TranslateDataRequirements, whose __post_init__ already validates that the
pairing groups partition its streams. __iter__ is likewise unused, and no
other DatasetABC defines one; torch reaches the samples through
__getitem__.
It had __getitem__/__iter__/__len__ but no keys()/items()/values(), so
iterating yielded names while ** unpacking failed — an awkward half-mapping
for the code that will use it most, PR 3's objectives. Inheriting
collections.abc.Mapping supplies the rest from __getitem__ and keeps them
in agreement with it.

Also documents at pin_memory why it mutates in place while to_device/to_cpu
copy: BatchData's and fme.coupled's do the same, and torch's DataLoader
calls it on a batch it has just collated and is about to hand over.
@mcgibbon
mcgibbon marked this pull request as ready for review July 29, 2026 00:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant