Skip to content

Commit d3ba97f

Browse files
author
NullPointer-cell
committed
fix: save eval predictions to zarr (multi-GPU safe, correct time semantics)
- Multi-GPU safe: rank-0 writes metadata template (compute=False), then barrier(), then all ranks write via region='auto' - Correct start_time: analysis_time = batch_times[:,0] - step_length - Rescale predictions to original scale in test_step before writing - Drop raw 'time' coord; explicit int64 encoding for time variables - Cache WeatherDataset per (split, category) to avoid O(N*T) re-instantiation - Add --save-eval-to-zarr-path CLI flag - Add tests/test_zarr_eval.py single-GPU integration test Refs mllam#104 Part of mllam#138
1 parent fa45696 commit d3ba97f

3 files changed

Lines changed: 279 additions & 4 deletions

File tree

neural_lam/models/ar_model.py

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
# Third-party
77
import matplotlib.pyplot as plt
88
import numpy as np
9+
import pandas as pd
910
import pytorch_lightning as pl
1011
import torch
1112
import xarray as xr
13+
from loguru import logger
1214

1315
# First-party
1416
from neural_lam.utils import get_integer_time
@@ -160,6 +162,8 @@ def __init__(
160162
self._datastore.step_length
161163
)
162164

165+
self._weather_dataset_cache: Dict[tuple, Any] = {}
166+
163167
def _create_dataarray_from_tensor(
164168
self,
165169
tensor: torch.Tensor,
@@ -191,7 +195,12 @@ def _create_dataarray_from_tensor(
191195
# TODO: creating an instance of WeatherDataset here on every call is
192196
# not how this should be done but whether WeatherDataset should be
193197
# provided to ARModel or where to put plotting still needs discussion
194-
weather_dataset = WeatherDataset(datastore=self._datastore, split=split)
198+
cache_key = (split, category)
199+
if cache_key not in self._weather_dataset_cache:
200+
self._weather_dataset_cache[cache_key] = WeatherDataset(
201+
datastore=self._datastore, split=split
202+
)
203+
weather_dataset = self._weather_dataset_cache[cache_key]
195204
time = np.array(time.cpu(), dtype="datetime64[ns]")
196205
da = weather_dataset.create_dataarray_from_tensor(
197206
tensor=tensor, time=time, category=category
@@ -381,6 +390,113 @@ def on_validation_epoch_end(self):
381390
for metric_list in self.val_metrics.values():
382391
metric_list.clear()
383392

393+
def _save_predictions_to_zarr(
394+
self,
395+
batch_times: torch.Tensor,
396+
batch_predictions: torch.Tensor,
397+
batch_idx: int,
398+
zarr_output_path: str,
399+
):
400+
"""
401+
Save state predictions for a single batch to a Zarr dataset.
402+
403+
Predictions must already be in the original (un-standardized) scale
404+
before calling this method. The resulting dataset contains a variable
405+
named ``state`` with coordinates
406+
``(start_time, elapsed_forecast_duration, grid_index, state_feature)``
407+
(or ``x``/``y`` instead of ``grid_index`` for regular-grid datastores).
408+
409+
Multi-GPU safe: rank 0 writes the full-extent metadata template on the
410+
first batch, all ranks then synchronise via ``barrier()``, and every
411+
rank writes its slice using ``region="auto"``.
412+
413+
Parameters
414+
----------
415+
batch_times : torch.Tensor
416+
Forecast times, shape ``(B, pred_steps)`` as int64 nanoseconds
417+
since epoch. These are the *predicted* times, so
418+
``batch_times[:, 0]`` is ``analysis_time + step_length``.
419+
batch_predictions : torch.Tensor
420+
Predictions in the **original** data scale, shape
421+
``(B, pred_steps, num_grid_nodes, d_f)``.
422+
batch_idx : int
423+
Index of the current batch in the epoch.
424+
zarr_output_path : str
425+
Filesystem path where the Zarr store will be written.
426+
"""
427+
batch_predictions = batch_predictions.cpu()
428+
batch_times_cpu = batch_times.cpu()
429+
batch_size = batch_predictions.shape[0]
430+
431+
step_ns = int(pd.Timedelta(self._datastore.step_length).value)
432+
433+
analysis_times_ns = (
434+
batch_times_cpu[:, 0].numpy().astype("int64") - step_ns
435+
) # (B,)
436+
analysis_times = analysis_times_ns.astype("datetime64[ns]")
437+
438+
pred_steps = batch_times_cpu.shape[1]
439+
offsets_ns = np.arange(1, pred_steps + 1, dtype="int64") * step_ns
440+
elapsed = offsets_ns.astype("timedelta64[ns]")
441+
442+
time_encoding = {
443+
"start_time": {
444+
"units": "Seconds since 1970-01-01 00:00:00",
445+
"dtype": "int64",
446+
},
447+
"elapsed_forecast_duration": {
448+
"units": "seconds",
449+
"dtype": "int64",
450+
},
451+
}
452+
453+
das = []
454+
for i in range(batch_size):
455+
da_i = self._create_dataarray_from_tensor(
456+
tensor=batch_predictions[i],
457+
time=batch_times_cpu[i],
458+
split="test",
459+
category="state",
460+
)
461+
da_i = da_i.assign_coords(
462+
elapsed_forecast_duration=(
463+
"time",
464+
elapsed,
465+
)
466+
)
467+
da_i = da_i.swap_dims({"time": "elapsed_forecast_duration"})
468+
da_i = da_i.drop_vars("time", errors="ignore")
469+
da_i.name = "state"
470+
das.append(da_i)
471+
472+
da_batch = xr.concat(das, dim="start_time")
473+
da_batch = da_batch.assign_coords(
474+
start_time=("start_time", analysis_times)
475+
)
476+
da_batch = da_batch.chunk({"start_time": batch_size})
477+
ds_batch = da_batch.to_dataset(name="state")
478+
479+
if self.trainer.is_global_zero and batch_idx == 0:
480+
logger.info(f"Creating Zarr store at {zarr_output_path}")
481+
all_times = self._datastore.get_dataarray(
482+
category="state", split="test"
483+
).coords["time"].values
484+
template_da = da_batch.reindex(
485+
start_time=all_times, fill_value=np.nan
486+
)
487+
template_ds = template_da.to_dataset(name="state")
488+
template_ds.to_zarr(
489+
zarr_output_path,
490+
compute=False,
491+
mode="w",
492+
encoding=time_encoding,
493+
consolidated=True,
494+
)
495+
496+
self.trainer.strategy.barrier()
497+
498+
ds_batch.to_zarr(zarr_output_path, region="auto")
499+
384500
# pylint: disable-next=unused-argument
385501
def test_step(self, batch, batch_idx):
386502
"""
@@ -445,6 +561,17 @@ def test_step(self, batch, batch_idx):
445561
self.spatial_loss_maps.append(log_spatial_losses)
446562
# (B, N_log, num_grid_nodes)
447563

564+
# Save predictions to Zarr if requested
565+
if getattr(self.args, "save_eval_to_zarr_path", None):
566+
# Rescale from standardized space to original data scale
567+
pred_rescaled = prediction * self.state_std + self.state_mean
568+
self._save_predictions_to_zarr(
569+
batch_times=batch_times,
570+
batch_predictions=pred_rescaled,
571+
batch_idx=batch_idx,
572+
zarr_output_path=self.args.save_eval_to_zarr_path,
573+
)
574+
448575
# Plot example predictions (on rank 0 only)
449576
if (
450577
self.trainer.is_global_zero

neural_lam/train_model.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,15 @@ def main(input_args=None):
170170
default=1,
171171
help="Number of example predictions to plot during evaluation",
172172
)
173+
parser.add_argument(
174+
"--save-eval-to-zarr-path",
175+
type=str,
176+
default=None,
177+
dest="save_eval_to_zarr_path",
178+
help="If set, save evaluation predictions (in original data scale) "
179+
"to a Zarr store at this path. Multi-GPU safe: rank 0 writes the "
180+
"metadata template, then all ranks write their slices in parallel.",
181+
)
173182

174183
# Logger Settings
175184
parser.add_argument(
@@ -307,13 +316,24 @@ def main(input_args=None):
307316
datastore=datastore, args=args, run_name=run_name
308317
)
309318

310-
checkpoint_callback = pl.callbacks.ModelCheckpoint(
319+
val_checkpoint = pl.callbacks.ModelCheckpoint(
311320
dirpath=f"saved_models/{run_name}",
312321
filename="min_val_loss",
313322
monitor="val_mean_loss",
314323
mode="min",
315-
save_last=True,
324+
save_top_k=1,
316325
)
326+
327+
latest_checkpoint = pl.callbacks.ModelCheckpoint(
328+
dirpath=f"saved_models/{run_name}",
329+
filename="last",
330+
monitor=None,
331+
save_top_k=1,
332+
every_n_epochs=1,
333+
save_on_train_epoch_end=True,
334+
enable_version_counter=False,
335+
)
336+
317337
trainer = pl.Trainer(
318338
max_epochs=args.epochs,
319339
deterministic=True,
@@ -323,7 +343,7 @@ def main(input_args=None):
323343
devices=devices,
324344
logger=training_logger,
325345
log_every_n_steps=1,
326-
callbacks=[checkpoint_callback],
346+
callbacks=[val_checkpoint, latest_checkpoint],
327347
check_val_every_n_epoch=args.val_interval,
328348
precision=args.precision,
329349
)

tests/test_zarr_eval.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# Standard library
2+
from pathlib import Path
3+
4+
# Third-party
5+
import numpy as np
6+
import pytest
7+
import pytorch_lightning as pl
8+
import torch
9+
import xarray as xr
10+
11+
# First-party
12+
from neural_lam import config as nlconfig
13+
from neural_lam.create_graph import create_graph_from_datastore
14+
from neural_lam.models.graph_lam import GraphLAM
15+
from neural_lam.weather_dataset import WeatherDataModule
16+
from tests.conftest import init_datastore_example
17+
18+
19+
class ModelArgs:
20+
"""Minimal args object for testing zarr eval saving."""
21+
22+
output_std = False
23+
loss = "mse"
24+
restore_opt = False
25+
n_example_pred = 0 # skip plotting to keep the test fast
26+
graph = "1level"
27+
hidden_dim = 4
28+
hidden_layers = 1
29+
processor_layers = 2
30+
mesh_aggr = "sum"
31+
lr = 1.0e-3
32+
val_steps_to_log = [1, 2]
33+
metrics_watch = []
34+
num_past_forcing_steps = 1
35+
num_future_forcing_steps = 1
36+
save_eval_to_zarr_path = None # overridden per test
37+
38+
39+
def run_zarr_eval(datastore, zarr_path, tmp_path):
40+
"""
41+
Run one test epoch using the given datastore and save predictions to
42+
*zarr_path*. Returns the opened ``xr.Dataset``.
43+
"""
44+
device_name = "cuda" if torch.cuda.is_available() else "cpu"
45+
46+
trainer = pl.Trainer(
47+
max_epochs=1,
48+
deterministic=True,
49+
accelerator=device_name,
50+
devices=1,
51+
log_every_n_steps=1,
52+
logger=False,
53+
enable_checkpointing=False,
54+
)
55+
56+
graph_name = "1level"
57+
graph_dir_path = Path(datastore.root_path) / "graph" / graph_name
58+
if not graph_dir_path.exists():
59+
create_graph_from_datastore(
60+
datastore=datastore,
61+
output_root_path=str(graph_dir_path),
62+
n_max_levels=1,
63+
)
64+
65+
data_module = WeatherDataModule(
66+
datastore=datastore,
67+
ar_steps_train=3,
68+
ar_steps_eval=3,
69+
standardize=True,
70+
batch_size=2,
71+
num_workers=0,
72+
num_past_forcing_steps=1,
73+
num_future_forcing_steps=1,
74+
)
75+
76+
model_args = ModelArgs()
77+
model_args.save_eval_to_zarr_path = str(zarr_path)
78+
79+
config = nlconfig.NeuralLAMConfig(
80+
datastore=nlconfig.DatastoreSelection(
81+
kind=datastore.SHORT_NAME, config_path=datastore.root_path
82+
)
83+
)
84+
85+
model = GraphLAM(args=model_args, datastore=datastore, config=config)
86+
trainer.test(model=model, datamodule=data_module)
87+
88+
return xr.open_zarr(str(zarr_path))
89+
90+
91+
def test_zarr_eval_single_gpu(tmp_path):
92+
"""
93+
Single-GPU integration test: run eval on DummyDatastore and assert that
94+
the Zarr output has the expected structure and correct time semantics.
95+
"""
96+
datastore = init_datastore_example("dummydata")
97+
zarr_path = tmp_path / "eval_preds.zarr"
98+
99+
ds = run_zarr_eval(datastore, zarr_path, tmp_path)
100+
101+
# 1. Store must exist and contain "state"
102+
assert zarr_path.exists(), "Zarr store was not created"
103+
assert "state" in ds.data_vars
104+
105+
# 2. Required dimensions
106+
required_dims = {"start_time", "elapsed_forecast_duration"}
107+
missing = required_dims - set(ds.dims)
108+
assert not missing, f"Missing dimensions: {missing}"
109+
110+
# 3. Raw 'time' coord must be absent
111+
assert "time" not in ds.coords
112+
113+
# 4. start_time must be analysis_time (= first forecast time - step_length)
114+
step_ns = int(
115+
ds["elapsed_forecast_duration"].values[0] / np.timedelta64(1, "ns")
116+
)
117+
for t0 in ds["start_time"].values:
118+
first_fcst_abs = t0 + ds["elapsed_forecast_duration"].values[0]
119+
expected_t0 = first_fcst_abs - np.timedelta64(step_ns, "ns")
120+
assert t0 == expected_t0, (
121+
f"start_time {t0} != expected {expected_t0}"
122+
)
123+
124+
# 5. Values must be finite (confirms rescaling happened)
125+
state_sample = float(
126+
ds["state"].isel(start_time=0, elapsed_forecast_duration=0).mean()
127+
)
128+
assert np.isfinite(state_sample), "Zarr output contains NaN/Inf"

0 commit comments

Comments
 (0)