coarse-grained-forecasting-benchmark commited on
Commit
41dd7e0
·
verified ·
1 Parent(s): 6b73fe1

Add anonymous reproduction code

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. code/.gitignore +7 -0
  2. code/LICENSE +21 -0
  3. code/README.md +93 -0
  4. code/bifurcation/__init__.py +3 -0
  5. code/bifurcation/config.py +100 -0
  6. code/bifurcation/data/__init__.py +0 -0
  7. code/bifurcation/data/fine.py +548 -0
  8. code/bifurcation/data/loaders.py +51 -0
  9. code/bifurcation/data/manifests.py +124 -0
  10. code/bifurcation/fid/__init__.py +15 -0
  11. code/bifurcation/fid/adapters.py +153 -0
  12. code/bifurcation/fid/backend.py +206 -0
  13. code/bifurcation/fid/euler.py +58 -0
  14. code/bifurcation/fid/evaluate.py +224 -0
  15. code/bifurcation/fid/heterogeneous_waves.py +59 -0
  16. code/bifurcation/fid/heterogeneous_waves_augmented.py +39 -0
  17. code/bifurcation/fid/nlkg.py +65 -0
  18. code/bifurcation/fid/passive_scalar.py +34 -0
  19. code/bifurcation/methods/__init__.py +3 -0
  20. code/bifurcation/methods/base.py +69 -0
  21. code/bifurcation/methods/cascaded_si/__init__.py +3 -0
  22. code/bifurcation/methods/cascaded_si/core.py +133 -0
  23. code/bifurcation/methods/cascaded_si/method.py +66 -0
  24. code/bifurcation/methods/direct_si/__init__.py +3 -0
  25. code/bifurcation/methods/direct_si/core.py +86 -0
  26. code/bifurcation/methods/direct_si/method.py +54 -0
  27. code/bifurcation/methods/edm/__init__.py +3 -0
  28. code/bifurcation/methods/edm/core.py +205 -0
  29. code/bifurcation/methods/edm/method.py +74 -0
  30. code/bifurcation/metrics.py +41 -0
  31. code/bifurcation/models/__init__.py +0 -0
  32. code/bifurcation/models/_common.py +16 -0
  33. code/bifurcation/models/spectral.py +97 -0
  34. code/bifurcation/models/unet2.py +248 -0
  35. code/bifurcation/models/unet2_2comp.py +87 -0
  36. code/bifurcation/models/unet2_5ch.py +50 -0
  37. code/bifurcation/models/unet2_5ch_coord.py +45 -0
  38. code/bifurcation/models/unet2_5ch_coord_aug.py +110 -0
  39. code/bifurcation/models/unet2_coord.py +87 -0
  40. code/bifurcation/registry.py +127 -0
  41. code/bifurcation/sample.py +142 -0
  42. code/bifurcation/train.py +115 -0
  43. code/bifurcation/trainer.py +371 -0
  44. code/bifurcation/utils.py +27 -0
  45. code/configs/euler/dataset.yaml +21 -0
  46. code/configs/evaluation.yaml +30 -0
  47. code/configs/nlkg/dataset.yaml +23 -0
  48. code/configs/passive_scalar/dataset.yaml +21 -0
  49. code/configs/sqg/dataset.yaml +21 -0
  50. code/configs/waves/dataset.yaml +23 -0
code/.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .pytest_cache/
4
+ .venv/
5
+ *.egg-info/
6
+ /runs/
7
+ /outputs/
code/LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The Authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
code/README.md ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Stabilizing Probabilistic Forecasts via Stochastic Interpolation Cascades
2
+
3
+ Code for reproducing the experiments accompanying an anonymous research submission on conditional generative forecasting.
4
+
5
+ We compare Direct Stochastic Interpolation (Direct SI), Cascaded Stochastic Interpolation (Cascaded SI), and Elucidating Diffusion Models (EDM) on five dynamical systems: 2-D Euler, surface quasi-geostrophic flow, heterogeneous waves, nonlinear Klein–Gordon, and passive scalar advection–diffusion.
6
+
7
+ The repository contains the model implementations (`bifurcation/`), configurations (`configs/`), recorded experimental results (`records/`), and reproduction scripts (`reproduce/`).
8
+
9
+ ## Installation
10
+
11
+ From the extracted code directory:
12
+
13
+ ```bash
14
+ uv sync
15
+ export CLEAN_FID_CACHE=$HOME/.cache/clean-fid
16
+ uv run python -c "from bifurcation.fid import backend as B; B.build_extractor('cpu')"
17
+ ```
18
+
19
+ The dependencies are pinned in `uv.lock`. The original experiments used different PyTorch builds for some datasets, so minor numerical differences across environments are possible.
20
+
21
+ ## Data and checkpoints
22
+
23
+ The datasets and trained models are available on Hugging Face:
24
+
25
+ - [Euler](https://huggingface.co/datasets/coarse-grained-forecasting-benchmark/euler)
26
+ - [SQG](https://huggingface.co/datasets/coarse-grained-forecasting-benchmark/sqg)
27
+ - [Heterogeneous waves](https://huggingface.co/datasets/coarse-grained-forecasting-benchmark/het-waves)
28
+ - [Nonlinear Klein–Gordon](https://huggingface.co/datasets/coarse-grained-forecasting-benchmark/nonlinear-kg)
29
+ - [Passive scalar](https://huggingface.co/datasets/coarse-grained-forecasting-benchmark/passive-scalar)
30
+ - [Model checkpoints](https://huggingface.co/coarse-grained-forecasting-benchmark/forecasting-models)
31
+
32
+ For example, download the Euler dataset and the checkpoints:
33
+
34
+ ```bash
35
+ hf download coarse-grained-forecasting-benchmark/euler \
36
+ --repo-type dataset --local-dir data/euler
37
+
38
+ hf download coarse-grained-forecasting-benchmark/forecasting-models \
39
+ --local-dir models
40
+
41
+ export EULER_DATA="$PWD/data/euler/data"
42
+ export EULER_TRUTH_BANK="$PWD/data/euler/truth_bank"
43
+ ```
44
+
45
+ The other systems follow the same directory structure.
46
+
47
+ ## Reproducing the results
48
+
49
+ To display the recorded results of Tables 2, 5, and 6:
50
+
51
+ ```bash
52
+ uv run python -m reproduce.tables
53
+ ```
54
+
55
+ To generate 16 conditional forecasts for one Euler test condition using Cascaded SI:
56
+
57
+ ```bash
58
+ uv run python -m reproduce.table2 generate \
59
+ --system euler --method cascaded_si \
60
+ --checkpoint models/euler/cascaded-si-S10/model.pt \
61
+ --out runs/example --gids 36 --n-samples 16 --device cpu
62
+ ```
63
+
64
+ To reproduce a Table 2 entry, generating 10,000 forecasts per test condition and computing csFID-10k:
65
+
66
+ ```bash
67
+ uv run python -m reproduce.table2 run \
68
+ --system euler --method cascaded_si \
69
+ --checkpoint models/euler/cascaded-si-S10/model.pt \
70
+ --out runs/euler_cascaded_si --device cuda
71
+ ```
72
+
73
+ Use `--method direct_si` or `--method edm` for the baselines. For Cascaded SI, `--S` selects a specific cascade depth.
74
+
75
+ The scripts in `reproduce/figures/` and `reproduce/appendix/` generate the paper's figures and additional analyses from the recorded results, datasets, or forecast samples, as applicable.
76
+
77
+ ## Training
78
+
79
+ Models can also be trained using the configurations of the selected experiments:
80
+
81
+ ```bash
82
+ uv run python -m bifurcation.train \
83
+ --config records/euler/runs/cascaded_si_S10/config.yaml \
84
+ --data "$EULER_DATA" --out runs/euler_S10
85
+ ```
86
+
87
+ The training configurations, model hyperparameters, and evaluation settings are provided in `records/` and `configs/`.
88
+
89
+ ## License
90
+
91
+ MIT License.
92
+
93
+ The EDM implementation follows [Karras et al. (2022)](https://github.com/NVlabs/edm). Image evaluation uses [Clean-FID](https://github.com/GaParmar/clean-fid).
code/bifurcation/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """bifurcation — models, methods, data loading, sampling and the csFID metric for conditional forecasting of PDE ensembles."""
2
+
3
+ __version__ = "0.1.0"
code/bifurcation/config.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration files of the repository: the dataset cards, the evaluation settings and the recorded runs.
2
+
3
+ * ``configs/<system>/dataset.yaml`` names the data of one system; ``${VAR}`` entries are read from the environment.
4
+ * ``configs/evaluation.yaml`` holds the csFID settings and the sampling seeds shared by all systems.
5
+ * ``records/<system>/units.json`` lists the models of the paper and their recorded scores.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import re
12
+ from pathlib import Path
13
+
14
+ import yaml
15
+
16
+ REPO = Path(__file__).resolve().parents[1]
17
+ CONFIGS = REPO / "configs"
18
+ RECORDS = REPO / "records"
19
+ SYSTEMS = ("euler", "sqg", "waves", "nlkg", "passive_scalar")
20
+
21
+ _ENV_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}")
22
+
23
+
24
+ def expand_env(value):
25
+ """``${VAR}`` / ``${VAR:-default}`` expansion in strings (recursively in dicts and lists)."""
26
+ if isinstance(value, str):
27
+ def sub(m):
28
+ var, default = m.group(1), m.group(2)
29
+ v = os.environ.get(var)
30
+ if v is None or v == "":
31
+ if default is None:
32
+ raise KeyError(f"environment variable {var} is not set (used in a config)")
33
+ return default
34
+ return v
35
+ return _ENV_RE.sub(sub, value)
36
+ if isinstance(value, dict):
37
+ return {k: expand_env(v) for k, v in value.items()}
38
+ if isinstance(value, list):
39
+ return [expand_env(v) for v in value]
40
+ return value
41
+
42
+
43
+ def load_yaml(path: str | os.PathLike) -> dict:
44
+ with open(path) as f:
45
+ return yaml.safe_load(f) or {}
46
+
47
+
48
+ def dataset_card(system: str, expand: bool = False) -> dict:
49
+ """The dataset card of ``system``; with ``expand`` the ``${VAR}`` paths are resolved from the environment."""
50
+ if system not in SYSTEMS:
51
+ raise KeyError(f"unknown system {system!r} (one of {SYSTEMS})")
52
+ card = load_yaml(CONFIGS / system / "dataset.yaml")
53
+ return expand_env(card) if expand else card
54
+
55
+
56
+ def data_paths(system: str, data: str | None = None, truth_bank: str | None = None) -> tuple[Path, Path]:
57
+ """The package and truth-bank directories: the given values, else the environment variables of the card."""
58
+ card = dataset_card(system)
59
+ if data is None:
60
+ data = expand_env(card["data"])
61
+ if truth_bank is None:
62
+ truth_bank = expand_env(card["truth_bank"])
63
+ return Path(data).resolve(), Path(truth_bank).resolve()
64
+
65
+
66
+ def evaluation_settings() -> dict:
67
+ return load_yaml(CONFIGS / "evaluation.yaml")
68
+
69
+
70
+ def records(system: str) -> dict:
71
+ return json.loads((RECORDS / system / "units.json").read_text())
72
+
73
+
74
+ def unit(system: str, method: str, S: int | None = None) -> tuple[dict, dict]:
75
+ """The recorded model of ``system`` for a paper method (``direct_si``, ``edm``, ``cascaded_si``); with ``S`` the
76
+ Cascaded SI model of that depth (Table 6). Returns (records, unit)."""
77
+ rec = records(system)
78
+ if method == "edm":
79
+ if S is not None:
80
+ raise SystemExit("--S applies to cascaded_si only")
81
+ key = "edm"
82
+ elif method == "direct_si":
83
+ if S not in (None, 1):
84
+ raise SystemExit("direct_si is S = 1; use --method cascaded_si --S <S> for other depths")
85
+ key = rec["table2"]["direct_si"]
86
+ elif method == "cascaded_si":
87
+ key = rec["table2"]["cascaded_si"] if S is None else f"cascaded_si_S{int(S):02d}"
88
+ else:
89
+ raise SystemExit(f"unknown method {method!r}")
90
+ u = next((u for u in rec["units"] if u["key"] == key), None)
91
+ if u is None:
92
+ raise SystemExit(f"{system}: no recorded model {key} (depths: {[u['S'] for u in rec['units'] if u['key'] != 'edm']})")
93
+ return rec, u
94
+
95
+
96
+ def run_config(system: str, u: dict, data: Path) -> dict:
97
+ """The recorded run configuration of a model with the dataset path filled in."""
98
+ cfg = load_yaml(REPO / u["config"])
99
+ cfg["data"]["packaged_dataset_path"] = str(Path(data).resolve()) + "/"
100
+ return cfg
code/bifurcation/data/__init__.py ADDED
File without changes
code/bifurcation/data/fine.py ADDED
@@ -0,0 +1,548 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Loaders of a dataset package: ``trajectories_fine.npz`` holds the blocks ``vecs_train`` (conditions, realizations,
2
+ snapshots, D), ``vecs_eval`` and optionally ``vecs_new``, keyed by ``gids_train`` / ``gids_eval`` / ``gids_new``, and
3
+ the snapshot times ``t_values``; ``split.json`` names the training, validation and test conditions;
4
+ ``normalization.npz`` the per-coordinate mean and std.
5
+
6
+ * training pairs are read from ``vecs_train`` only, restricted to the (condition, realization) rows of a pair
7
+ manifest (``bifurcation.data.manifests``); segments are ``boundary_idx`` snapshot pairs and the conditioning
8
+ times are the physical times of the stored grid;
9
+ * validation and test inputs are ``vecs_eval`` at the snapshot ``fine_horizon_idx``;
10
+ * x_0 of a condition is member 0 at t = 0 (all members share it; asserted at 1e-9).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ import os
18
+ import zipfile
19
+ from typing import List, Tuple
20
+
21
+ import numpy as np
22
+ import torch
23
+ from torch.utils.data import Dataset
24
+
25
+ FINE_DT = 0.025
26
+ FINE_FILE = "trajectories_fine.npz"
27
+ ENDPOINT_SCHEMA = "endpoint_pairs_v1" # the endpoint manifests of bifurcation.data.manifests
28
+
29
+
30
+ # --------------------------------------------------------------------------- #
31
+ # npz member access (uncompressed members -> memmap; else plain load)
32
+ # --------------------------------------------------------------------------- #
33
+
34
+ def _npz_member_memmap(npz_path: str, name: str) -> np.ndarray:
35
+ """Memory-map one array stored in an UNCOMPRESSED ``.npz``.
36
+
37
+ The packages store every member with ``compress_type == 0``, so the raw ``.npy`` bytes sit contiguously
38
+ inside the zip and can be memmapped: fancy-indexing then reads only the pages of the selected trajectories.
39
+
40
+ Falls back to ``np.load`` (full read) if the member is compressed.
41
+ """
42
+ member = name if name.endswith(".npy") else name + ".npy"
43
+ with zipfile.ZipFile(npz_path) as zf:
44
+ info = zf.getinfo(member)
45
+ if info.compress_type != zipfile.ZIP_STORED:
46
+ return np.load(npz_path)[name]
47
+ with open(npz_path, "rb") as f:
48
+ f.seek(info.header_offset)
49
+ local = f.read(30)
50
+ if local[:4] != b"PK\x03\x04":
51
+ raise IOError(f"bad local zip header for {member} in {npz_path}")
52
+ name_len = int.from_bytes(local[26:28], "little")
53
+ extra_len = int.from_bytes(local[28:30], "little")
54
+ f.seek(info.header_offset + 30 + name_len + extra_len)
55
+ version = np.lib.format.read_magic(f)
56
+ np.lib.format._check_version(version)
57
+ shape, fortran, dtype = np.lib.format._read_array_header(f, version)
58
+ offset = f.tell()
59
+ order = "F" if fortran else "C"
60
+ return np.memmap(npz_path, dtype=dtype, mode="r", offset=offset,
61
+ shape=shape, order=order)
62
+
63
+
64
+ def _sha256_file(path: str) -> str:
65
+ h = hashlib.sha256()
66
+ with open(path, "rb") as f:
67
+ for chunk in iter(lambda: f.read(1 << 20), b""):
68
+ h.update(chunk)
69
+ return h.hexdigest()
70
+
71
+
72
+ def _resolve_path(path: str) -> str:
73
+ """Absolute paths pass through; relative paths resolve against the repository root."""
74
+ if os.path.isabs(path):
75
+ return path
76
+ return os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), path)
77
+
78
+
79
+ def _load_manifest(slice_manifest: str) -> dict:
80
+ path = _resolve_path(slice_manifest)
81
+ with open(path) as f:
82
+ man = json.load(f)
83
+ man["_manifest_path"] = path
84
+ # Provenance guard: the manifest must certify the master ordering it was
85
+ # cut from, and that file must still hash to the recorded value.
86
+ master = os.path.join(os.path.dirname(path), "master_order.npz")
87
+ if not os.path.isfile(master):
88
+ raise FileNotFoundError(
89
+ f"master_order.npz not found next to slice manifest {path} — "
90
+ "slice provenance cannot be verified")
91
+ got = _sha256_file(master)
92
+ want = man["master_order_sha256"]
93
+ if got != want:
94
+ raise AssertionError(
95
+ f"master_order.npz hash mismatch for {path}: {got} != {want}")
96
+ return man
97
+
98
+
99
+ # --------------------------------------------------------------------------- #
100
+ # training dataset
101
+ # --------------------------------------------------------------------------- #
102
+
103
+ class SegmentPairDataset(Dataset):
104
+ """``(x_tk, x_tk1, t_k, t_k1)`` pairs from ``vecs_train`` per a pair manifest.
105
+
106
+ Checks: exactly ``S`` pairs per selected trajectory, exactly ``B == N_traj * S`` pairs in total,
107
+ boundaries on stored snapshots, physical times consistent with the manifest, no validation or test gid.
108
+ """
109
+
110
+ def __init__(self, packaged_dataset_path: str, slice_manifest: str,
111
+ seed: int = 0,
112
+ expected_t_values: list[float] | None = None):
113
+ man = _load_manifest(slice_manifest)
114
+ fine_path = os.path.join(packaged_dataset_path, FINE_FILE)
115
+
116
+ boundary_idx = np.asarray(man["boundary_idx"], dtype=np.int64)
117
+ t_vals_man = np.asarray(man["t_values"], dtype=np.float64)
118
+ S = int(man["S"])
119
+ n_traj = int(man["N_traj"])
120
+ B = int(man["B"])
121
+ rows = np.asarray(man["trajectories"], dtype=np.int64) # (N,3)
122
+
123
+ # --- manifest self-consistency ---
124
+ if rows.shape != (n_traj, 3):
125
+ raise AssertionError(
126
+ f"manifest trajectories shape {rows.shape} != ({n_traj}, 3)")
127
+ if n_traj * S != B:
128
+ raise AssertionError(f"N_traj*S = {n_traj * S} != B = {B}")
129
+ if len(boundary_idx) != S + 1 or boundary_idx[0] != 0:
130
+ raise AssertionError(f"boundary_idx must be S+1 long, start at 0; "
131
+ f"got {boundary_idx.tolist()}")
132
+ if np.any(np.diff(boundary_idx) != boundary_idx[1] - boundary_idx[0]):
133
+ raise AssertionError("boundary_idx must be uniformly strided")
134
+ uniq = np.unique(rows[:, :2], axis=0)
135
+ if uniq.shape[0] != n_traj:
136
+ raise AssertionError("duplicate (group_row, member_col) in slice")
137
+
138
+ gids_train = _npz_member_memmap(fine_path, "gids_train")
139
+ t_values = np.asarray(_npz_member_memmap(fine_path, "t_values"))
140
+ if boundary_idx[-1] > t_values.shape[0] - 1:
141
+ raise AssertionError("boundary_idx exceeds fine snapshot grid")
142
+ if not np.allclose(t_values[boundary_idx], t_vals_man, atol=1e-12):
143
+ raise AssertionError(
144
+ f"manifest t_values {t_vals_man.tolist()} do not match the "
145
+ f"stored grid at boundary_idx {boundary_idx.tolist()}")
146
+ if expected_t_values is not None:
147
+ exp = np.asarray([float(t) for t in expected_t_values])
148
+ if len(exp) != len(t_vals_man) or not np.allclose(exp, t_vals_man,
149
+ atol=1e-9):
150
+ raise AssertionError(
151
+ f"sampling.t_values {exp.tolist()} != slice-manifest "
152
+ f"t_values {t_vals_man.tolist()} — the sampler chain and "
153
+ "the training segments must describe the same physics")
154
+ # gid column must be exactly gids_train[group_row] and never an
155
+ # eval/new gid (train block only contains canonical train gids, but
156
+ # assert the mapping so a corrupted manifest fails loudly).
157
+ if not np.array_equal(np.asarray(gids_train)[rows[:, 0]], rows[:, 2]):
158
+ raise AssertionError("manifest gid column inconsistent with "
159
+ "gids_train[group_row]")
160
+ with open(os.path.join(packaged_dataset_path, "split.json")) as f:
161
+ split = json.load(f)
162
+ forbidden = set(split["val"]) | set(split["test"])
163
+ if forbidden & set(rows[:, 2].tolist()):
164
+ raise AssertionError("slice manifest contains val/test gids")
165
+
166
+ # --- gather (N_traj, S+1, D) raw states, normalize, build pairs ---
167
+ vecs = _npz_member_memmap(fine_path, "vecs_train") # (80,200,41,D)
168
+ seg = np.asarray(vecs[rows[:, 0], rows[:, 1]])[:, boundary_idx, :]
169
+ if not np.all(np.isfinite(seg)):
170
+ raise AssertionError("non-finite values in selected trajectories")
171
+ D = seg.shape[-1]
172
+
173
+ norm = np.load(os.path.join(packaged_dataset_path, "normalization.npz"))
174
+ mean = norm["mean"].astype(np.float32)
175
+ std = norm["std"].astype(np.float32)
176
+
177
+ starts = seg[:, :-1, :].reshape(-1, D) # (N*S, D)
178
+ ends = seg[:, 1:, :].reshape(-1, D)
179
+ # float32 normalization arithmetic
180
+ x_tk = ((starts.astype(np.float32) - mean) / std).astype(np.float32)
181
+ x_tk1 = ((ends.astype(np.float32) - mean) / std).astype(np.float32)
182
+
183
+ t_k_grid = np.broadcast_to(t_vals_man[:-1], (n_traj, S)).reshape(-1)
184
+ t_k1_grid = np.broadcast_to(t_vals_man[1:], (n_traj, S)).reshape(-1)
185
+
186
+ self.x_tk = torch.from_numpy(x_tk)
187
+ self.x_tk1 = torch.from_numpy(x_tk1)
188
+ self.t_k = torch.from_numpy(t_k_grid.astype(np.float32))
189
+ self.t_k1 = torch.from_numpy(t_k1_grid.astype(np.float32))
190
+ if self.x_tk.shape[0] != B:
191
+ raise AssertionError(f"built {self.x_tk.shape[0]} pairs, expected {B}")
192
+
193
+ # Provenance of each pair (gid, member, segment).
194
+ self.pair_gid = np.repeat(rows[:, 2], S)
195
+ self.pair_member = np.repeat(rows[:, 1], S)
196
+ self.pair_segment = np.tile(np.arange(S), n_traj)
197
+ self.n_segments = S
198
+ self.manifest = man
199
+ self.seed = int(seed)
200
+
201
+ def __len__(self) -> int:
202
+ return self.x_tk.shape[0]
203
+
204
+ def __getitem__(self, idx: int):
205
+ return self.x_tk[idx], self.x_tk1[idx], self.t_k[idx], self.t_k1[idx]
206
+
207
+
208
+ class ValSegmentPairDataset(Dataset):
209
+ """Validation pairs of the training objective: the run's segmentation applied to the validation gids of
210
+ ``vecs_eval``, first ``n_members`` members per gid (the ``val_loss`` column of the log)."""
211
+
212
+ def __init__(self, packaged_dataset_path: str, slice_manifest: str,
213
+ n_members: int = 20):
214
+ man = _load_manifest(slice_manifest)
215
+ fine_path = os.path.join(packaged_dataset_path, FINE_FILE)
216
+ boundary_idx = np.asarray(man["boundary_idx"], dtype=np.int64)
217
+ t_vals_man = np.asarray(man["t_values"], dtype=np.float64)
218
+ S = int(man["S"])
219
+
220
+ with open(os.path.join(packaged_dataset_path, "split.json")) as f:
221
+ split = json.load(f)
222
+ val_gids = sorted(split["val"])
223
+
224
+ gids_eval = np.asarray(_npz_member_memmap(fine_path, "gids_eval"))
225
+ vecs_eval = _npz_member_memmap(fine_path, "vecs_eval")
226
+ rows = []
227
+ for gid in val_gids:
228
+ where = np.nonzero(gids_eval == gid)[0]
229
+ if len(where) != 1:
230
+ raise AssertionError(f"val gid {gid} not found once in gids_eval")
231
+ rows.append(int(where[0]))
232
+ n_members = int(n_members)
233
+ if not (1 <= n_members <= vecs_eval.shape[1]):
234
+ raise ValueError(f"n_members {n_members} out of range")
235
+
236
+ seg = np.asarray(vecs_eval[rows][:, :n_members, boundary_idx, :])
237
+ if not np.all(np.isfinite(seg)):
238
+ raise AssertionError("non-finite values in vecs_eval slice")
239
+ n_traj = seg.shape[0] * seg.shape[1]
240
+ D = seg.shape[-1]
241
+ seg = seg.reshape(n_traj, S + 1, D)
242
+
243
+ norm = np.load(os.path.join(packaged_dataset_path, "normalization.npz"))
244
+ mean = norm["mean"].astype(np.float32)
245
+ std = norm["std"].astype(np.float32)
246
+ starts = seg[:, :-1, :].reshape(-1, D)
247
+ ends = seg[:, 1:, :].reshape(-1, D)
248
+ self.x_tk = torch.from_numpy(((starts.astype(np.float32) - mean) / std)
249
+ .astype(np.float32))
250
+ self.x_tk1 = torch.from_numpy(((ends.astype(np.float32) - mean) / std)
251
+ .astype(np.float32))
252
+ self.t_k = torch.from_numpy(np.broadcast_to(t_vals_man[:-1],
253
+ (n_traj, S)).reshape(-1).astype(np.float32))
254
+ self.t_k1 = torch.from_numpy(np.broadcast_to(t_vals_man[1:],
255
+ (n_traj, S)).reshape(-1).astype(np.float32))
256
+ self.n_segments = S
257
+
258
+ def __len__(self) -> int:
259
+ return self.x_tk.shape[0]
260
+
261
+ def __getitem__(self, idx: int):
262
+ return self.x_tk[idx], self.x_tk1[idx], self.t_k[idx], self.t_k1[idx]
263
+
264
+
265
+ # --------------------------------------------------------------------------- #
266
+ # endpoint datasets
267
+ # --------------------------------------------------------------------------- #
268
+
269
+ class EndpointPairDataset(Dataset):
270
+ """``(x_0, x_T)`` endpoint pairs from ``vecs_train`` (block 0) and/or ``vecs_new`` (block 1) per an endpoint
271
+ manifest (rows ``[block, group_row, member_col, gid]``), what the Direct SI and EDM losses consume.
272
+ Normalization: float64 arithmetic, then cast to float32 (as ``get_fine_split_set``).
273
+
274
+ Checks: exactly ``B == N_traj`` pairs (S = 1), boundaries exactly ``[0, idx_T]`` on stored snapshots, the gid
275
+ mapping against the package's own key arrays, every gid in the training split, no duplicate row.
276
+ """
277
+
278
+ _BLOCKS = {0: ("vecs_train", "gids_train"), 1: ("vecs_new", "gids_new")}
279
+
280
+ def __init__(self, packaged_dataset_path: str, slice_manifest: str,
281
+ seed: int = 0,
282
+ expected_t_values: list[float] | None = None):
283
+ man = _load_manifest(slice_manifest)
284
+ if man.get("schema") != ENDPOINT_SCHEMA:
285
+ raise AssertionError(
286
+ f"expected schema {ENDPOINT_SCHEMA}, got "
287
+ f"{man.get('schema')!r} in {slice_manifest}")
288
+ fine_path = os.path.join(packaged_dataset_path, FINE_FILE)
289
+
290
+ boundary_idx = np.asarray(man["boundary_idx"], dtype=np.int64)
291
+ t_vals_man = np.asarray(man["t_values"], dtype=np.float64)
292
+ S = int(man["S"])
293
+ n_traj = int(man["N_traj"])
294
+ B = int(man["B"])
295
+ rows = np.asarray(man["trajectories"], dtype=np.int64) # (N,4)
296
+
297
+ # --- manifest self-consistency ---
298
+ if S != 1 or n_traj != B:
299
+ raise AssertionError(f"endpoint slice must have S=1, N_traj=B; "
300
+ f"got S={S} N_traj={n_traj} B={B}")
301
+ if rows.shape != (n_traj, 4):
302
+ raise AssertionError(
303
+ f"manifest trajectories shape {rows.shape} != ({n_traj}, 4)")
304
+ if len(boundary_idx) != 2 or boundary_idx[0] != 0:
305
+ raise AssertionError(
306
+ f"endpoint boundary_idx must be [0, idx_T]; got "
307
+ f"{boundary_idx.tolist()}")
308
+ if not set(np.unique(rows[:, 0]).tolist()) <= set(self._BLOCKS):
309
+ raise AssertionError(
310
+ f"unknown block ids {np.unique(rows[:, 0]).tolist()}")
311
+ if np.unique(rows[:, :3], axis=0).shape[0] != n_traj:
312
+ raise AssertionError(
313
+ "duplicate (block, group_row, member_col) in slice")
314
+
315
+ t_values = np.asarray(_npz_member_memmap(fine_path, "t_values"))
316
+ if boundary_idx[-1] > t_values.shape[0] - 1:
317
+ raise AssertionError("boundary_idx exceeds fine snapshot grid")
318
+ if not np.allclose(t_values[boundary_idx], t_vals_man, atol=1e-12):
319
+ raise AssertionError(
320
+ f"manifest t_values {t_vals_man.tolist()} do not match the "
321
+ f"stored grid at boundary_idx {boundary_idx.tolist()}")
322
+ if expected_t_values is not None:
323
+ exp = np.asarray([float(t) for t in expected_t_values])
324
+ if len(exp) != len(t_vals_man) or not np.allclose(exp, t_vals_man,
325
+ atol=1e-9):
326
+ raise AssertionError(
327
+ f"sampling.t_values {exp.tolist()} != slice-manifest "
328
+ f"t_values {t_vals_man.tolist()}")
329
+
330
+ # --- gid guards against the dataset's own key arrays + split.json ---
331
+ with open(os.path.join(packaged_dataset_path, "split.json")) as f:
332
+ split = json.load(f)
333
+ train_set = set(split["train"])
334
+ forbidden = set(split["val"]) | set(split["test"])
335
+ for blk, (_, gid_key) in self._BLOCKS.items():
336
+ sub = rows[rows[:, 0] == blk]
337
+ if sub.shape[0] == 0:
338
+ continue
339
+ block_gids = np.asarray(_npz_member_memmap(fine_path, gid_key))
340
+ if not np.array_equal(block_gids[sub[:, 1]], sub[:, 3]):
341
+ raise AssertionError(
342
+ f"manifest gid column inconsistent with "
343
+ f"{gid_key}[group_row] (block {blk})")
344
+ man_gids = set(rows[:, 3].tolist())
345
+ if man_gids & forbidden:
346
+ raise AssertionError("slice manifest contains val/test gids")
347
+ if not man_gids <= train_set:
348
+ raise AssertionError("slice manifest contains gids outside "
349
+ "split.train")
350
+
351
+ # --- gather (N, 2, D) raw endpoints in manifest row order ---
352
+ first_blk = int(rows[0, 0])
353
+ probe = _npz_member_memmap(fine_path, self._BLOCKS[first_blk][0])
354
+ D = probe.shape[-1]
355
+ seg = np.empty((n_traj, 2, D), dtype=np.float64)
356
+ for blk, (vec_key, _) in self._BLOCKS.items():
357
+ sel = np.nonzero(rows[:, 0] == blk)[0]
358
+ if sel.size == 0:
359
+ continue
360
+ vecs = _npz_member_memmap(fine_path, vec_key)
361
+ seg[sel] = np.asarray(
362
+ vecs[rows[sel, 1], rows[sel, 2]])[:, boundary_idx, :]
363
+ if not np.all(np.isfinite(seg)):
364
+ raise AssertionError("non-finite values in selected trajectories")
365
+
366
+ norm = np.load(os.path.join(packaged_dataset_path, "normalization.npz"))
367
+ mean, std = norm["mean"], norm["std"]
368
+ # Shared endpoint convention: float64 arithmetic, then float32 cast.
369
+ self.x0 = torch.from_numpy(((seg[:, 0] - mean) / std).astype(np.float32))
370
+ self.xT = torch.from_numpy(((seg[:, 1] - mean) / std).astype(np.float32))
371
+ if self.x0.shape[0] != B:
372
+ raise AssertionError(f"built {self.x0.shape[0]} pairs, expected {B}")
373
+
374
+ # Provenance of each pair (block, member, gid).
375
+ self.pair_block = rows[:, 0].copy()
376
+ self.pair_member = rows[:, 2].copy()
377
+ self.pair_gid = rows[:, 3].copy()
378
+ self.n_segments = 1
379
+ self.manifest = man
380
+ self.seed = int(seed)
381
+
382
+ def __len__(self) -> int:
383
+ return self.x0.shape[0]
384
+
385
+ def __getitem__(self, idx: int):
386
+ return self.x0[idx], self.xT[idx]
387
+
388
+
389
+ class ValEndpointPairDataset(Dataset):
390
+ """Validation endpoint pairs ``(x_0, x_T)`` at the manifest's ``[0, idx_T]`` boundaries from the validation gids
391
+ of ``vecs_eval``, first ``n_members`` members per gid (the ``val_loss`` column of the log)."""
392
+
393
+ def __init__(self, packaged_dataset_path: str, slice_manifest: str,
394
+ n_members: int = 20):
395
+ man = _load_manifest(slice_manifest)
396
+ if man.get("schema") != ENDPOINT_SCHEMA:
397
+ raise AssertionError(
398
+ f"expected schema {ENDPOINT_SCHEMA}, got "
399
+ f"{man.get('schema')!r} in {slice_manifest}")
400
+ fine_path = os.path.join(packaged_dataset_path, FINE_FILE)
401
+ boundary_idx = np.asarray(man["boundary_idx"], dtype=np.int64)
402
+
403
+ with open(os.path.join(packaged_dataset_path, "split.json")) as f:
404
+ split = json.load(f)
405
+ val_gids = sorted(split["val"])
406
+
407
+ gids_eval = np.asarray(_npz_member_memmap(fine_path, "gids_eval"))
408
+ vecs_eval = _npz_member_memmap(fine_path, "vecs_eval")
409
+ rows = []
410
+ for gid in val_gids:
411
+ where = np.nonzero(gids_eval == gid)[0]
412
+ if len(where) != 1:
413
+ raise AssertionError(f"val gid {gid} not found once in gids_eval")
414
+ rows.append(int(where[0]))
415
+ n_members = int(n_members)
416
+ if not (1 <= n_members <= vecs_eval.shape[1]):
417
+ raise ValueError(f"n_members {n_members} out of range")
418
+
419
+ seg = np.asarray(vecs_eval[rows][:, :n_members][:, :, boundary_idx, :])
420
+ if not np.all(np.isfinite(seg)):
421
+ raise AssertionError("non-finite values in vecs_eval slice")
422
+ D = seg.shape[-1]
423
+ seg = seg.reshape(-1, 2, D).astype(np.float64)
424
+
425
+ norm = np.load(os.path.join(packaged_dataset_path, "normalization.npz"))
426
+ mean, std = norm["mean"], norm["std"]
427
+ self.x0 = torch.from_numpy(((seg[:, 0] - mean) / std).astype(np.float32))
428
+ self.xT = torch.from_numpy(((seg[:, 1] - mean) / std).astype(np.float32))
429
+ self.n_segments = 1
430
+
431
+ def __len__(self) -> int:
432
+ return self.x0.shape[0]
433
+
434
+ def __getitem__(self, idx: int):
435
+ return self.x0[idx], self.xT[idx]
436
+
437
+
438
+ # --------------------------------------------------------------------------- #
439
+ # sampling and validation inputs at the forecast horizon
440
+ # --------------------------------------------------------------------------- #
441
+
442
+ def _x0_and_cloud_from_bank(bank_dir: str, gid: int, idx_T: int, cloud_n: int = 200):
443
+ """x_0 and a truth cloud of a test condition that the package does not carry (waves, NLKG), read from the
444
+ truth bank (group_<gid>.npz); used only for the test split when ``x0_bank`` is given. Fails loudly on any
445
+ inconsistency."""
446
+ p = os.path.join(bank_dir, f"group_{int(gid):04d}.npz")
447
+ if not os.path.isfile(p):
448
+ raise FileNotFoundError(
449
+ f"test-x0-bank fallback: no group_{int(gid):04d}.npz under {bank_dir} for gid {gid}")
450
+ with np.load(p) as z:
451
+ if int(z["gid"]) != int(gid):
452
+ raise AssertionError(
453
+ f"test-x0-bank fallback: {p} declares gid {int(z['gid'])}, expected {gid} "
454
+ "(bank file must be looked up and verified by gid, never by row position)")
455
+ vecs = np.asarray(z["vecs"]) # (N, n_snapshots, D)
456
+ if not (0 <= idx_T < vecs.shape[1]):
457
+ raise ValueError(f"test-x0-bank fallback: fine_horizon_idx {idx_T} outside bank grid for gid {gid}")
458
+ x0_members = vecs[:, 0, :]
459
+ spread = float(np.max(np.abs(x0_members - x0_members[0])))
460
+ if spread > 1e-9:
461
+ raise AssertionError(
462
+ f"test-x0-bank fallback: gid {gid} x0 differs across bank members by {spread:.3e} — "
463
+ "shared-IC assumption violated")
464
+ n = min(int(cloud_n), vecs.shape[0])
465
+ return x0_members[0], np.asarray(vecs[:n, idx_T, :])
466
+
467
+
468
+ def get_fine_split_set(cfg: dict, split_name: str, seed: int = 0, x0_bank: str | None = None
469
+ ) -> Tuple[torch.Tensor, torch.Tensor, List[int]]:
470
+ """Per-gid x0 + truth cloud at the snapshot ``cfg.method.fine_horizon_idx``:
471
+ ``(x0_per_gid (n,D) f32, truth_clouds (n,V,D) f32, gids list)``.
472
+
473
+ x0 convention: member 0 at t=0. Cross-member spread at t=0 is asserted < 1e-9.
474
+
475
+ ``x0_bank`` (test split only): a truth-bank directory (group_<gid>.npz) used for a test gid absent from the
476
+ package's ``vecs_eval`` block; it changes nothing for the other splits or for a gid the package carries.
477
+ """
478
+ packaged_dir = cfg["data"]["packaged_dataset_path"]
479
+ method_cfg = cfg.get("method", {})
480
+ idx_T = int(method_cfg["fine_horizon_idx"])
481
+ fine_path = os.path.join(packaged_dir, FINE_FILE)
482
+
483
+ t_values = np.asarray(_npz_member_memmap(fine_path, "t_values"))
484
+ if not (1 <= idx_T <= t_values.shape[0] - 1):
485
+ raise ValueError(f"fine_horizon_idx {idx_T} outside grid")
486
+ # The horizon index must agree with the sampler chain's terminal time.
487
+ samp_t = method_cfg.get("sampling", {}).get("t_values")
488
+ if samp_t is not None:
489
+ if abs(float(samp_t[-1]) - float(t_values[idx_T])) > 1e-9:
490
+ raise AssertionError(
491
+ f"fine_horizon_idx={idx_T} (t={t_values[idx_T]}) != "
492
+ f"sampling.t_values[-1]={samp_t[-1]}")
493
+
494
+ with open(os.path.join(packaged_dir, "split.json")) as f:
495
+ split = json.load(f)
496
+ gids_override = cfg["data"].get("gids_override")
497
+ if gids_override is not None:
498
+ gids = sorted(int(g) for g in gids_override)
499
+ else:
500
+ if split_name not in split:
501
+ raise KeyError(f"split '{split_name}' not in split.json")
502
+ gids = sorted(split[split_name])
503
+ if split_name == "train":
504
+ # the training block carries the conditions of vecs_train only (not vecs_new)
505
+ canonical = set(np.asarray(
506
+ _npz_member_memmap(fine_path, "gids_train")).tolist())
507
+ gids = [g for g in gids if g in canonical]
508
+
509
+ if split_name in ("val", "test") and gids_override is None:
510
+ block, gid_key = "vecs_eval", "gids_eval"
511
+ else:
512
+ # the training conditions of vecs_train; any other gid fails loudly below
513
+ block, gid_key = "vecs_train", "gids_train"
514
+ block_gids = np.asarray(_npz_member_memmap(fine_path, gid_key))
515
+ vecs = _npz_member_memmap(fine_path, block)
516
+
517
+ norm = np.load(os.path.join(packaged_dir, "normalization.npz"))
518
+ mean, std = norm["mean"], norm["std"]
519
+
520
+ x0_list, cloud_list = [], []
521
+ for gid in gids:
522
+ where = np.nonzero(block_gids == gid)[0]
523
+ if len(where) != 1:
524
+ if split_name == "test" and x0_bank is not None:
525
+ x0_g, cloud_g = _x0_and_cloud_from_bank(x0_bank, gid, idx_T)
526
+ x0_list.append(x0_g); cloud_list.append(cloud_g)
527
+ continue
528
+ raise KeyError(
529
+ f"gid {gid} not found in {gid_key}: the package serves the conditions of "
530
+ "vecs_train and the validation and test conditions of vecs_eval")
531
+ r = int(where[0])
532
+ x0_members = np.asarray(vecs[r, :, 0, :]) # (V, D)
533
+ spread = float(np.max(np.abs(x0_members - x0_members[0])))
534
+ if spread > 1e-9:
535
+ raise AssertionError(
536
+ f"gid {gid}: x0 differs across members by {spread:.3e} — "
537
+ "shared-IC assumption violated")
538
+ x0_list.append(x0_members[0])
539
+ cloud_list.append(np.asarray(vecs[r, :, idx_T, :]))
540
+ x0_raw = np.stack(x0_list, axis=0)
541
+ clouds_raw = np.stack(cloud_list, axis=0)
542
+ if not (np.all(np.isfinite(x0_raw)) and np.all(np.isfinite(clouds_raw))):
543
+ raise AssertionError("non-finite values in fine split set")
544
+
545
+ # Same normalization arithmetic as get_split_set (float64 then cast).
546
+ x0 = torch.from_numpy(((x0_raw - mean) / std).astype(np.float32))
547
+ clouds = torch.from_numpy(((clouds_raw - mean) / std).astype(np.float32))
548
+ return x0, clouds, gids
code/bifurcation/data/loaders.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Training pairs and evaluation inputs of a dataset package (``trajectories_fine.npz``, ``split.json``,
2
+ ``normalization.npz``).
3
+
4
+ * ``get_dataloaders(cfg, seed)`` -> ``(train_loader, val_loader)``: segment pairs ``(x_tk, x_tk1, t_k, t_k1)`` for
5
+ Cascaded SI (``method.data_mode: fine_segment_pairs``) or endpoint pairs ``(x_0, x_T)`` for Direct SI and EDM
6
+ (``fine_endpoint_pairs``), from the pair manifest named by ``method.fine_slice_manifest``.
7
+ * ``get_split_set(cfg, split, seed, x0_bank)`` -> ``(x0_per_gid, truth_clouds, gids)`` at the forecast horizon
8
+ ``method.fine_horizon_idx``, the inputs of sampling and of validation.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from typing import Tuple
13
+
14
+ import torch
15
+ from torch.utils.data import DataLoader
16
+
17
+ from .fine import (EndpointPairDataset, SegmentPairDataset, ValEndpointPairDataset, # noqa: F401
18
+ ValSegmentPairDataset, get_fine_split_set)
19
+
20
+ DATA_MODES = ("fine_segment_pairs", "fine_endpoint_pairs")
21
+
22
+
23
+ def get_dataloaders(cfg: dict, seed: int = 0) -> Tuple[DataLoader, DataLoader]:
24
+ packaged_dir = cfg["data"]["packaged_dataset_path"]
25
+ batch_size = int(cfg["training"]["batch_size"])
26
+ method_cfg = cfg.get("method", {})
27
+ data_mode = method_cfg.get("data_mode")
28
+ num_workers = int(cfg["training"].get("num_workers", 0))
29
+ if data_mode == "fine_segment_pairs":
30
+ train_ds = SegmentPairDataset(packaged_dir, method_cfg["fine_slice_manifest"], seed=seed,
31
+ expected_t_values=method_cfg.get("sampling", {}).get("t_values"))
32
+ val_ds = ValSegmentPairDataset(packaged_dir, method_cfg["fine_slice_manifest"],
33
+ n_members=int(method_cfg.get("fine_val_members", 20)))
34
+ elif data_mode == "fine_endpoint_pairs":
35
+ train_ds = EndpointPairDataset(packaged_dir, method_cfg["fine_slice_manifest"], seed=seed,
36
+ expected_t_values=method_cfg.get("sampling", {}).get("t_values"))
37
+ val_ds = ValEndpointPairDataset(packaged_dir, method_cfg["fine_slice_manifest"],
38
+ n_members=int(method_cfg.get("fine_val_members", 20)))
39
+ else:
40
+ raise ValueError(f"method.data_mode must be one of {DATA_MODES}, got {data_mode!r}")
41
+ g = torch.Generator()
42
+ g.manual_seed(seed)
43
+ train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True, num_workers=num_workers, generator=g,
44
+ pin_memory=num_workers > 0)
45
+ val_loader = DataLoader(val_ds, batch_size=batch_size, shuffle=False, num_workers=num_workers,
46
+ pin_memory=num_workers > 0)
47
+ return train_loader, val_loader
48
+
49
+
50
+ def get_split_set(cfg: dict, split_name: str, seed: int = 0, x0_bank: str | None = None):
51
+ return get_fine_split_set(cfg, split_name, seed=seed, x0_bank=x0_bank)
code/bifurcation/data/manifests.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pair manifests: which trajectories of ``trajectories_fine.npz`` a training run reads, and at which snapshots.
2
+
3
+ A manifest directory holds ``slice_<name>.json`` and ``master_order.npz`` (the same rows; the loaders re-hash it).
4
+ Rows are ``[group_row, member_col, gid]`` into ``vecs_train`` (segment pairs) or ``[block, group_row, member_col, gid]``
5
+ with block 0 = ``vecs_train`` (endpoint pairs). ``bifurcation.train`` writes the manifest of a run before training.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import os
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+
15
+ import numpy as np
16
+
17
+ from .fine import ENDPOINT_SCHEMA as _fine_endpoint_schema
18
+ from .fine import FINE_FILE, _npz_member_memmap, _sha256_file
19
+
20
+ SEGMENT_SCHEMA = "segment_pairs_v1"
21
+ ENDPOINT_SCHEMA = _fine_endpoint_schema
22
+
23
+
24
+ def block_rows_segment(gids_train: np.ndarray, n_ic: int, members: int) -> np.ndarray:
25
+ """Training conditions 0..n_ic-1 x realizations 0..members-1; rows [group_row, member_col, gid]."""
26
+ rows = np.empty((n_ic * members, 3), dtype=np.int64)
27
+ i = 0
28
+ for gi in range(n_ic):
29
+ for mj in range(members):
30
+ rows[i] = (gi, mj, int(gids_train[gi]))
31
+ i += 1
32
+ return rows
33
+
34
+
35
+ def block_rows_endpoint(gids_train: np.ndarray, n_ic: int, members: int) -> np.ndarray:
36
+ """The same enumeration for endpoint pairs; rows [block, group_row, member_col, gid] with block 0 = vecs_train."""
37
+ rows = np.empty((n_ic * members, 4), dtype=np.int64)
38
+ i = 0
39
+ for gi in range(n_ic):
40
+ for mj in range(members):
41
+ rows[i] = (0, gi, mj, int(gids_train[gi]))
42
+ i += 1
43
+ return rows
44
+
45
+
46
+ def segment_geometry(T: float, S: int, t_values: np.ndarray) -> dict:
47
+ """Uniform segmentation of [0, T] on the stored snapshot grid: n_T = index of T (an exact snapshot), S must
48
+ divide n_T; boundary_idx = [0, stride, ..., n_T]; the segment times are read from the stored grid."""
49
+ t_values = np.asarray(t_values, dtype=np.float64)
50
+ hits = np.nonzero(np.isclose(t_values, T, atol=1e-9))[0]
51
+ if len(hits) != 1:
52
+ raise ValueError(f"T={T} is not a stored snapshot time (grid {t_values[:3]}...)")
53
+ n_T = int(hits[0])
54
+ if n_T % S != 0:
55
+ raise ValueError(f"S={S} does not divide n_T={n_T} (T={T}); admissible S: "
56
+ f"{[s for s in range(1, n_T + 1) if n_T % s == 0]}")
57
+ stride = n_T // S
58
+ boundary_idx = [q * stride for q in range(S + 1)]
59
+ return {"T": float(T), "S": int(S), "n_T": n_T, "stride": stride, "delta": float(T) / S,
60
+ "boundary_idx": boundary_idx, "t_values": [float(t_values[b]) for b in boundary_idx]}
61
+
62
+
63
+ def _now() -> str:
64
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
65
+
66
+
67
+ def _write_master(slice_dir: Path, rows: np.ndarray, note: str) -> str:
68
+ slice_dir.mkdir(parents=True, exist_ok=True)
69
+ path = slice_dir / "master_order.npz"
70
+ if rows.shape[1] == 3:
71
+ payload = dict(group_row=rows[:, 0].astype(np.int32), member_col=rows[:, 1].astype(np.int32),
72
+ gid=rows[:, 2].astype(np.int32))
73
+ else:
74
+ payload = dict(block=rows[:, 0].astype(np.int32), group_row=rows[:, 1].astype(np.int32),
75
+ member_col=rows[:, 2].astype(np.int32), gid=rows[:, 3].astype(np.int32))
76
+ payload["algorithm"] = np.bytes_(note.encode())
77
+ np.savez(path, **payload)
78
+ return _sha256_file(str(path))
79
+
80
+
81
+ def dataset_provenance(packaged_dir: str | os.PathLike) -> dict:
82
+ packaged_dir = Path(packaged_dir)
83
+ return {"dataset_path": str(packaged_dir / FINE_FILE),
84
+ "split_sha256": _sha256_file(str(packaged_dir / "split.json")),
85
+ "normalization_sha256": _sha256_file(str(packaged_dir / "normalization.npz"))}
86
+
87
+
88
+ def write_segment_manifest(slice_dir: Path, rows3: np.ndarray, geometry: dict, name: str,
89
+ packaged_dir: str | os.PathLike, extra: dict | None = None) -> Path:
90
+ S = int(geometry["S"])
91
+ master_sha = _write_master(slice_dir, rows3, f"complete block ({name}): canonical enumeration")
92
+ man = {"schema": SEGMENT_SCHEMA, "arm": name, "cell": name, **geometry,
93
+ "N_traj": int(rows3.shape[0]), "B": int(rows3.shape[0]) * S,
94
+ "trajectories": rows3.tolist(), "master_order_sha256": master_sha,
95
+ **dataset_provenance(packaged_dir), "built_at": _now(), **(extra or {})}
96
+ out = slice_dir / f"slice_{name}.json"
97
+ out.write_text(json.dumps(man))
98
+ return out
99
+
100
+
101
+ def write_endpoint_manifest(slice_dir: Path, rows4: np.ndarray, idx_T: int, t_values: np.ndarray, name: str,
102
+ packaged_dir: str | os.PathLike, extra: dict | None = None) -> Path:
103
+ master_sha = _write_master(slice_dir, rows4, f"complete block ({name}): canonical enumeration, block 0 = vecs_train")
104
+ man = {"schema": ENDPOINT_SCHEMA, "arm": name, "S": 1,
105
+ "T": float(t_values[idx_T]), "n_T": int(idx_T),
106
+ "N_traj": int(rows4.shape[0]), "B": int(rows4.shape[0]),
107
+ "boundary_idx": [0, int(idx_T)], "t_values": [0.0, float(t_values[idx_T])],
108
+ "blocks": {"0": "vecs_train"},
109
+ "trajectories": rows4.tolist(), "master_order_sha256": master_sha,
110
+ **dataset_provenance(packaged_dir), "built_at": _now(), **(extra or {})}
111
+ out = slice_dir / f"slice_{name}.json"
112
+ out.write_text(json.dumps(man))
113
+ return out
114
+
115
+
116
+ def fine_grid(packaged_dir: str | os.PathLike) -> dict:
117
+ """Key arrays of the fine file (memmapped; cheap)."""
118
+ fine = str(Path(packaged_dir) / FINE_FILE)
119
+ out = {"t_values": np.asarray(_npz_member_memmap(fine, "t_values")),
120
+ "gids_train": np.asarray(_npz_member_memmap(fine, "gids_train")),
121
+ "gids_eval": np.asarray(_npz_member_memmap(fine, "gids_eval"))}
122
+ for k in ("vecs_train", "vecs_eval"):
123
+ out[k + "_shape"] = tuple(_npz_member_memmap(fine, k).shape)
124
+ return out
code/bifurcation/fid/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The image metrics: csFID (the paper's metric), cFID and cKID.
2
+
3
+ backend.py the pinned Clean-FID pipeline: one resize (PIL bicubic, 299x299), the TF-Inception-v3 weights
4
+ (2015-12-05), the pool3 feature (cFID, cKID) and the spatial mixed_6/conv feature (csFID)
5
+ adapters.py the dataset adapter psi_D: physical state -> uint8 (H, W, 3) image, with constants fitted on
6
+ training data only (records/<system>/fid_adapter.json)
7
+ euler.py, passive_scalar.py, heterogeneous_waves(_augmented).py, nlkg.py the adapters of the five systems
8
+ evaluate.py the metrics of all test conditions of a model against the truth bank
9
+ """
10
+ from .adapters import ADAPTERS, FIDAdapter, get_adapter, register_adapter # noqa: F401
11
+ from . import euler as _euler # noqa: F401 (registers euler_coarse_vorticity)
12
+ from . import passive_scalar as _passive_scalar # noqa: F401 (registers passive_scalar_coarse)
13
+ from . import heterogeneous_waves as _heterogeneous_waves # noqa: F401 (base class of the waves adapter)
14
+ from . import heterogeneous_waves_augmented as _heterogeneous_waves_augmented # noqa: F401 (registers heterogeneous_waves_augmented_coarse: waves)
15
+ from . import nlkg as _nlkg # noqa: F401 (registers nlkg_coarse_u: NLKG; reads the displacement u)
code/bifurcation/fid/adapters.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dataset adapters psi_D: physical state -> three-channel uint8 image.
2
+
3
+ Contract (enforced by the base class):
4
+ * ``fit(train_states_raw)`` computes the adapter's numerical constants from training data only and fixes
5
+ them (``params``; records/<system>/fid_adapter.json);
6
+ * ``to_images(states_raw)`` is deterministic given ``params``; no per-image normalization;
7
+ * signed fields with a meaningful zero: symmetric clip [-c, c], c = Q_q(|x_train|); nonnegative fields:
8
+ [0, c], c = Q_q(x_train); the map to uint8 is the fixed linear map of the range onto [0, 255];
9
+ * scalar fields -> three identical channels unless the dataset card defines a physical 3-channel construction.
10
+
11
+ States enter raw (physical units, un-standardized). configs/<system>/dataset.yaml names the adapter and its parameters.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from pathlib import Path
17
+
18
+ import numpy as np
19
+
20
+ ADAPTERS: dict[str, type] = {}
21
+
22
+
23
+ def register_adapter(name: str):
24
+ def deco(cls):
25
+ cls.name = name
26
+ ADAPTERS[name] = cls
27
+ return cls
28
+ return deco
29
+
30
+
31
+ def get_adapter(name: str, **kwargs) -> "FIDAdapter":
32
+ if name not in ADAPTERS:
33
+ raise KeyError(f"unknown FID adapter {name!r} (registered: {sorted(ADAPTERS)})")
34
+ return ADAPTERS[name](**kwargs)
35
+
36
+
37
+ def quantize_symmetric(fields: np.ndarray, c: float) -> tuple[np.ndarray, float]:
38
+ """Signed field -> uint8 grayscale: rint((clip(f, -c, c) + c) / (2c) * 255); zero -> 128. Returns the
39
+ clipped-pixel fraction too."""
40
+ f = np.asarray(fields, dtype=np.float64)
41
+ clipped = float(np.mean((f > c) | (f < -c))) if f.size else 0.0
42
+ g = np.rint((np.clip(f, -c, c) + c) / (2.0 * c) * 255.0)
43
+ return np.clip(g, 0, 255).astype(np.uint8), clipped
44
+
45
+
46
+ def quantize_nonnegative(fields: np.ndarray, c: float) -> tuple[np.ndarray, float]:
47
+ f = np.asarray(fields, dtype=np.float64)
48
+ clipped = float(np.mean(f > c)) if f.size else 0.0
49
+ g = np.rint(np.clip(f, 0.0, c) / c * 255.0)
50
+ return np.clip(g, 0, 255).astype(np.uint8), clipped
51
+
52
+
53
+ def gray_to_rgb(g: np.ndarray) -> np.ndarray:
54
+ return np.repeat(g[..., None], 3, axis=-1)
55
+
56
+
57
+ class FIDAdapter:
58
+ name = "abstract"
59
+ field_kind = "signed" # 'signed' (symmetric clip) | 'nonnegative' ([0, c])
60
+
61
+ def __init__(self, quantile: float = 0.9999, **kwargs):
62
+ self.quantile = float(quantile)
63
+ self.params: dict | None = None
64
+ self.kwargs = dict(kwargs)
65
+
66
+ # dataset-specific: raw state vectors (N, D) -> scalar fields (N, H, W)
67
+ def to_fields(self, states_raw: np.ndarray) -> np.ndarray:
68
+ raise NotImplementedError
69
+
70
+ def fit(self, train_states_raw: np.ndarray, source: dict | None = None, pixel_stride: int = 1,
71
+ chunk: int = 4000) -> dict:
72
+ """Constants from TRAINING data only. Freezes ``self.params``. ``pixel_stride`` subsamples the field
73
+ pixels (every k-th in each axis);
74
+ states are converted in chunks to bound memory."""
75
+ k = int(pixel_stride)
76
+ vals, shape = [], None
77
+ for i in range(0, len(train_states_raw), int(chunk)):
78
+ f = self.to_fields(train_states_raw[i:i + int(chunk)])
79
+ shape = list(f.shape[1:])
80
+ f = f[:, ::k, ::k] if f.ndim == 3 else f
81
+ vals.append((np.abs(f) if self.field_kind == "signed" else f).astype(np.float32).ravel())
82
+ vals = np.concatenate(vals)
83
+ c = float(np.quantile(vals, self.quantile))
84
+ rng = [-c, c] if self.field_kind == "signed" else [0.0, c]
85
+ self.params = {"adapter": self.name, "field_kind": self.field_kind, "quantile": self.quantile, "clip_c": c,
86
+ "range": rng, "n_train_states": int(len(train_states_raw)), "field_shape": shape,
87
+ "pixel_stride": k, "n_pixel_values": int(vals.size),
88
+ "map": ("uint8 = rint((clip(w,-c,c)+c)/(2c)*255)" if self.field_kind == "signed"
89
+ else "uint8 = rint(clip(w,0,c)/c*255)"),
90
+ "channels": "3 identical (grayscale scalar field), no colormap",
91
+ "source": source or {}, **self.card()}
92
+ return self.params
93
+
94
+ def card(self) -> dict:
95
+ """Dataset-card text of the adapter (channel construction, reconstruction) — override."""
96
+ return {}
97
+
98
+ def load_params(self, params: dict) -> "FIDAdapter":
99
+ if params.get("adapter") != self.name:
100
+ raise ValueError(f"params belong to adapter {params.get('adapter')!r}, not {self.name!r}")
101
+ self.params = dict(params)
102
+ self.quantile = float(params.get("quantile", self.quantile))
103
+ return self
104
+
105
+ def to_images(self, states_raw: np.ndarray) -> tuple[np.ndarray, float]:
106
+ """RAW states -> uint8 (N, H, W, 3) and the clipped-pixel fraction. Requires fitted params."""
107
+ if self.params is None:
108
+ raise RuntimeError("adapter not fitted / loaded (constants come from training data only)")
109
+ fields = self.to_fields(states_raw)
110
+ c = float(self.params["clip_c"])
111
+ g, frac = (quantize_symmetric(fields, c) if self.field_kind == "signed" else quantize_nonnegative(fields, c))
112
+ return gray_to_rgb(g), frac
113
+
114
+ def physical_diagnostics(self, gen_raw: np.ndarray, true_raw: np.ndarray) -> dict:
115
+ """Optional physical diagnostics of a generated ensemble against the truth. Default: none."""
116
+ return {}
117
+
118
+ def save(self, path: Path) -> Path:
119
+ Path(path).write_text(json.dumps(self.params, indent=1) + "\n")
120
+ return Path(path)
121
+
122
+ @classmethod
123
+ def load(cls, path: Path, **kwargs) -> "FIDAdapter":
124
+ params = json.loads(Path(path).read_text())
125
+ return get_adapter(params["adapter"], quantile=params.get("quantile", 0.9999), **kwargs).load_params(params)
126
+
127
+
128
+ @register_adapter("vector_grid")
129
+ class VectorGridAdapter(FIDAdapter):
130
+ """Generic adapter for vector states with no field structure: the D coordinates are laid out row-major on
131
+ the smallest square grid (zero padding), each cell drawn as a ``cell`` x ``cell`` block (nearest
132
+ upsampling, no interpolation), symmetric clip from the training quantile. Used for datasets whose state is
133
+ a plain vector and for the synthetic test dataset."""
134
+ field_kind = "signed"
135
+
136
+ def __init__(self, quantile: float = 0.9999, cell: int = 8, **kwargs):
137
+ super().__init__(quantile, **kwargs)
138
+ self.cell = int(cell)
139
+
140
+ def to_fields(self, states_raw: np.ndarray) -> np.ndarray:
141
+ x = np.asarray(states_raw, dtype=np.float64)
142
+ if x.ndim == 1:
143
+ x = x[None]
144
+ N, D = x.shape
145
+ side = int(np.ceil(np.sqrt(D)))
146
+ grid = np.zeros((N, side * side), dtype=np.float64)
147
+ grid[:, :D] = x
148
+ grid = grid.reshape(N, side, side)
149
+ return np.repeat(np.repeat(grid, self.cell, axis=1), self.cell, axis=2)
150
+
151
+ def card(self) -> dict:
152
+ return {"reconstruction": f"row-major {int(np.ceil(np.sqrt(1)))}x? square grid of the state coordinates, zero-padded, "
153
+ f"each coordinate a {self.cell}x{self.cell} block (nearest)", "cell": self.cell}
code/bifurcation/fid/backend.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The Clean-FID pipeline, pinned and checked.
2
+
3
+ Everything after the dataset adapter is fixed here for every dataset:
4
+ * images arrive as uint8 (N, H, W, 3) in [0, 255] (one fixed image range);
5
+ * a single resize to 299x299 with clean-fid's "clean" resizer (PIL bicubic, float output, antialiasing is
6
+ PIL's default for its resampling filters); the TorchScript Inception is built with resize_inside=False
7
+ and asserts 299x299 input, so a second resize cannot happen silently;
8
+ * the pinned Inception-v3 weights (inception-2015-12-05.pt, sha256 checked), pool3 features (2048-D);
9
+ * FID = clean-fid's frechet_distance formula; KID = clean-fid's kernel_distance (unbiased polynomial-kernel
10
+ MMD^2, num_subsets / max_subset_size from the evaluation settings).
11
+
12
+ ``pins()`` returns the record that every FID manifest carries; ``check_pins(expected)`` raises on any drift.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import importlib.metadata as _md
18
+ import os
19
+ from pathlib import Path
20
+
21
+ import numpy as np
22
+
23
+ INCEPTION_DIR = Path(os.environ.get("CLEAN_FID_CACHE", Path.home() / ".cache" / "clean-fid"))
24
+ INCEPTION_FILE = "inception-2015-12-05.pt"
25
+ IMAGE_SIZE = 299
26
+
27
+ # ── the spatial Inception feature of csFID (Nash et al., ICML 2021) ─────────────────────
28
+ # The Inception-v3 activation "mixed_6/conv" -- the FIRST branch of the mixed_6 Inception block, BEFORE its
29
+ # concatenation with tower/tower_1/tower_2 (openai/guided-diffusion evaluator.py FID_SPATIAL_NAME) -- is a
30
+ # 192 x 17 x 17 tensor; its first 7 channels are kept and flattened NHWC to 17*17*7 = 2023 components per image,
31
+ # without spatial average pooling (that pooling is what makes the 2048-D pool3 cFID feature layout-tolerant).
32
+ # In this TorchScript port the tensor is the submodule layers.mixed_6.conv; the module names come from the TF op
33
+ # names. The stepwise traversal used to read it reproduces layers.forward exactly on CPU (verify_spatial_tap).
34
+ SPATIAL_LAYER = "mixed_6/conv"
35
+ SPATIAL_CHANNELS = 7
36
+ SPATIAL_GRID = 17
37
+ SPATIAL_DIM = SPATIAL_GRID * SPATIAL_GRID * SPATIAL_CHANNELS # 2023
38
+ POOL3_DIM = 2048
39
+ _MIXED5_SHAPE = (768, SPATIAL_GRID, SPATIAL_GRID) # canonical input of the mixed_6 block
40
+
41
+
42
+ def _sha256(path: Path) -> str:
43
+ h = hashlib.sha256()
44
+ with open(path, "rb") as f:
45
+ for chunk in iter(lambda: f.read(1 << 22), b""):
46
+ h.update(chunk)
47
+ return h.hexdigest()
48
+
49
+
50
+ def pins() -> dict:
51
+ import PIL
52
+ import torch
53
+ p = INCEPTION_DIR / INCEPTION_FILE
54
+ return {"backend": "clean-fid", "clean_fid_version": _md.version("clean-fid"), "mode": "clean",
55
+ "resize": {"backend": "PIL", "filter": "bicubic", "size": IMAGE_SIZE, "once": True,
56
+ "implementation": "cleanfid.resize.build_resizer('clean') = make_resizer('PIL', False, 'bicubic', (299, 299))"},
57
+ "inception": {"file": INCEPTION_FILE, "path": str(p), "sha256": _sha256(p) if p.is_file() else None,
58
+ "feature_layer": "pool3", "feature_dim": POOL3_DIM, "resize_inside": False},
59
+ "spatial": {"feature_layer": SPATIAL_LAYER, "submodule": "layers.mixed_6.conv",
60
+ "tap": "first branch of the mixed_6 block, before concatenation with tower/tower_1/tower_2",
61
+ "channels_kept": SPATIAL_CHANNELS, "grid": [SPATIAL_GRID, SPATIAL_GRID], "feature_dim": SPATIAL_DIM,
62
+ "flatten": "NHWC", "spatial_pooling": "none",
63
+ "reference": "Nash et al., ICML 2021; openai/guided-diffusion evaluator.py FID_SPATIAL_NAME='mixed_6/conv:0'"},
64
+ "fid": "cleanfid.fid.frechet_distance (scipy sqrtm, eps 1e-6 fallback)",
65
+ "kid": "cleanfid.fid.kernel_distance (unbiased polynomial-kernel MMD^2)",
66
+ "torch": torch.__version__, "pillow": PIL.__version__, "numpy": np.__version__}
67
+
68
+
69
+ def check_pins(expected: dict) -> dict:
70
+ """``expected`` = the fid settings (clean_fid_version, inception.sha256, inception.file). Raises on drift."""
71
+ got = pins()
72
+ probs = []
73
+ if str(expected.get("clean_fid_version")) != got["clean_fid_version"]:
74
+ probs.append(f"clean-fid {got['clean_fid_version']} != pinned {expected.get('clean_fid_version')}")
75
+ exp_inc = expected.get("inception") or {}
76
+ if exp_inc.get("file") and exp_inc["file"] != got["inception"]["file"]:
77
+ probs.append("inception file name differs")
78
+ if got["inception"]["sha256"] is None:
79
+ probs.append(f"Inception weights not found at {got['inception']['path']} (download once with cleanfid on a node with network)")
80
+ elif exp_inc.get("sha256") and exp_inc["sha256"] != got["inception"]["sha256"]:
81
+ probs.append(f"Inception weights sha256 {got['inception']['sha256'][:12]} != pinned {exp_inc['sha256'][:12]}")
82
+ if probs:
83
+ raise RuntimeError("FID pipeline pin violated: " + "; ".join(probs))
84
+ return got
85
+
86
+
87
+ def build_extractor(device):
88
+ """clean-fid 'clean' extractor: TorchScript TF-Inception, weights from INCEPTION_DIR, no internal resize."""
89
+ from cleanfid.inception_torchscript import InceptionV3W
90
+ INCEPTION_DIR.mkdir(parents=True, exist_ok=True)
91
+ model = InceptionV3W(str(INCEPTION_DIR), download=not (INCEPTION_DIR / INCEPTION_FILE).is_file(),
92
+ resize_inside=False).to(device).eval()
93
+ return model
94
+
95
+
96
+ def inception_features(imgs_u8: np.ndarray, extractor, device, batch: int = 200) -> np.ndarray:
97
+ """uint8 (N, H, W, 3) -> float32 (N, 2048): a single clean resize per image, then the pinned network."""
98
+ import torch
99
+ from cleanfid.resize import build_resizer
100
+ if imgs_u8.dtype != np.uint8 or imgs_u8.ndim != 4 or imgs_u8.shape[-1] != 3:
101
+ raise ValueError(f"adapter output must be uint8 (N, H, W, 3); got {imgs_u8.dtype} {imgs_u8.shape}")
102
+ resizer = build_resizer("clean")
103
+ out = []
104
+ with torch.no_grad():
105
+ for i in range(0, len(imgs_u8), int(batch)):
106
+ xs = [resizer(im) for im in imgs_u8[i:i + int(batch)]] # float32 (299, 299, 3) in [0, 255]
107
+ x = torch.from_numpy(np.stack(xs).transpose(0, 3, 1, 2).copy()).to(device)
108
+ if tuple(x.shape[-2:]) != (IMAGE_SIZE, IMAGE_SIZE):
109
+ raise AssertionError("resizer did not produce 299x299")
110
+ out.append(extractor(x).cpu().numpy())
111
+ return np.concatenate(out).astype(np.float32)
112
+
113
+
114
+ def fid_from_features(f1: np.ndarray, f2: np.ndarray) -> float:
115
+ from cleanfid.fid import frechet_distance
116
+ f1 = np.asarray(f1, np.float64); f2 = np.asarray(f2, np.float64)
117
+ return float(frechet_distance(f1.mean(0), np.cov(f1, rowvar=False), f2.mean(0), np.cov(f2, rowvar=False)))
118
+
119
+
120
+ def kid_from_features(f1: np.ndarray, f2: np.ndarray, num_subsets: int = 100, max_subset_size: int = 1000) -> float:
121
+ from cleanfid.fid import kernel_distance
122
+ return float(kernel_distance(np.asarray(f1, np.float64), np.asarray(f2, np.float64),
123
+ num_subsets=int(num_subsets), max_subset_size=int(max_subset_size)))
124
+
125
+
126
+ # ── csFID feature extraction ────────────────────────────────────────────────────────────────────────────────
127
+
128
+ def _pre_mixed6_stages(layers):
129
+ """The layer sequence of layers.forward up to (not including) mixed_6 -- mirrors its TorchScript source."""
130
+ return [layers.conv, layers.conv_1, layers.conv_2, layers.pool0, layers.conv_3, layers.conv_4, layers.pool1,
131
+ layers.mixed, layers.mixed_1, layers.mixed_2, layers.mixed_3, layers.mixed_4, layers.mixed_5]
132
+
133
+
134
+ def _post_mixed6_stages(layers):
135
+ return [layers.mixed_6, layers.mixed_7, layers.mixed_8, layers.mixed_9, layers.mixed_10, layers.pool2]
136
+
137
+
138
+ def spatial_tap(extractor, x_normalized):
139
+ """x already resized to 299x299 and normalized as InceptionV3W does ((x - 128) / 128) -> the mixed_6/conv
140
+ activation (N, 192, 17, 17)."""
141
+ L = extractor.layers
142
+ h = x_normalized
143
+ for st in _pre_mixed6_stages(L):
144
+ h = st.forward(h)
145
+ return L.mixed_6.conv.forward(h)
146
+
147
+
148
+ def spatial_features(imgs_u8: np.ndarray, extractor, device, batch: int = 200) -> np.ndarray:
149
+ """uint8 (N, H, W, 3) -> float32 (N, 2023): the canonical sFID feature. Same clean resize and normalization
150
+ as inception_features; only the tapped tensor differs."""
151
+ import torch
152
+ from cleanfid.resize import build_resizer
153
+ if imgs_u8.dtype != np.uint8 or imgs_u8.ndim != 4 or imgs_u8.shape[-1] != 3:
154
+ raise ValueError(f"adapter output must be uint8 (N, H, W, 3); got {imgs_u8.dtype} {imgs_u8.shape}")
155
+ resizer = build_resizer("clean")
156
+ out = []
157
+ with torch.no_grad():
158
+ for i in range(0, len(imgs_u8), int(batch)):
159
+ xs = [resizer(im) for im in imgs_u8[i:i + int(batch)]]
160
+ x = torch.from_numpy(np.stack(xs).transpose(0, 3, 1, 2).copy()).to(device)
161
+ if tuple(x.shape[-2:]) != (IMAGE_SIZE, IMAGE_SIZE):
162
+ raise AssertionError("resizer did not produce 299x299")
163
+ sp = spatial_tap(extractor, (x - 128) / 128)[:, :SPATIAL_CHANNELS]
164
+ out.append(sp.permute(0, 2, 3, 1).reshape(sp.shape[0], -1).cpu().numpy())
165
+ feats = np.concatenate(out).astype(np.float32)
166
+ if feats.shape[1] != SPATIAL_DIM:
167
+ raise AssertionError(f"spatial feature dim {feats.shape[1]} != {SPATIAL_DIM}")
168
+ return feats
169
+
170
+
171
+ def verify_spatial_tap(extractor=None) -> dict:
172
+ """Check on CPU that (a) the stepwise traversal reproduces layers.forward exactly (so the tap sits on the
173
+ cFID path) and (b) the tapped tensor is the canonical 17x17 mixed_6/conv representation. Raises on failure;
174
+ returns the record that MANIFEST_CSFID.json carries."""
175
+ import torch
176
+ ext = extractor if extractor is not None else build_extractor(torch.device("cpu"))
177
+ L = ext.layers
178
+ if not (hasattr(L, "mixed_5") and hasattr(L, "mixed_6") and hasattr(L.mixed_6, "conv")):
179
+ raise RuntimeError("this Inception build does not expose layers.mixed_6.conv -- canonical sFID unavailable")
180
+ x = (torch.rand(3, 3, IMAGE_SIZE, IMAGE_SIZE) * 255.0 - 128) / 128
181
+ with torch.no_grad():
182
+ h = x
183
+ for st in _pre_mixed6_stages(L):
184
+ h = st.forward(h)
185
+ mixed5 = h
186
+ sp = L.mixed_6.conv.forward(mixed5)
187
+ for st in _post_mixed6_stages(L):
188
+ h = st.forward(h)
189
+ diff = float((h.view(3, POOL3_DIM) - L.forward(x).view(3, POOL3_DIM)).abs().max())
190
+ flat_dim = int(sp[:, :SPATIAL_CHANNELS].permute(0, 2, 3, 1).reshape(3, -1).shape[1])
191
+ rec = {"mixed_5_output_shape_CHW": list(sp.shape[1:]) and list(mixed5.shape[1:]),
192
+ "mixed_6_conv_output_shape_CHW": list(sp.shape[1:]), "channels_total": int(sp.shape[1]),
193
+ "channels_kept": SPATIAL_CHANNELS, "feature_dim": flat_dim, "flatten": "NHWC",
194
+ "stepwise_vs_layers_forward_max_abs_diff_cpu": diff, "bitexact_cpu": diff == 0.0}
195
+ probs = []
196
+ if tuple(mixed5.shape[1:]) != _MIXED5_SHAPE:
197
+ probs.append(f"mixed_5 output {tuple(mixed5.shape[1:])} != canonical {_MIXED5_SHAPE}")
198
+ if tuple(sp.shape[2:]) != (SPATIAL_GRID, SPATIAL_GRID):
199
+ probs.append(f"mixed_6/conv spatial {tuple(sp.shape[2:])} != {SPATIAL_GRID}x{SPATIAL_GRID}")
200
+ if flat_dim != SPATIAL_DIM:
201
+ probs.append(f"feature dim {flat_dim} != {SPATIAL_DIM}")
202
+ if diff != 0.0:
203
+ probs.append(f"stepwise traversal differs from layers.forward on CPU by {diff}")
204
+ if probs:
205
+ raise RuntimeError("spatial tap verification failed: " + "; ".join(probs))
206
+ return rec
code/bifurcation/fid/euler.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Euler FID adapter: 196-D coarse spectral vorticity (|k| <= 8, Hermitian half, raw coefficients
2
+ omega_hat / N_sim^2) -> the EXACT low-pass vorticity field on a 128x128 grid -> symmetric clip -> uint8 -> 3
3
+ identical channels.
4
+
5
+ The reconstruction is the same inverse FFT as ``bifurcation.models.spectral.FieldReconstructor`` (the mode
6
+ index map is imported from there — one source of truth), vectorized over N states; equality with the
7
+ reference per-vector reconstruction.
8
+
9
+ Constant: c = Q_0.9999(|omega|) over the training pool (records/euler/fid_adapter.json). Physical diagnostic:
10
+ the enstrophy ratio (mean enstrophy of the generated ensemble / mean enstrophy of the truth, from the coefficients).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import numpy as np
15
+
16
+ from ..models.spectral import K_C, mode_int_wavenumbers
17
+ from .adapters import FIDAdapter, register_adapter
18
+
19
+
20
+ @register_adapter("euler_coarse_vorticity")
21
+ class EulerVorticityAdapter(FIDAdapter):
22
+ field_kind = "signed"
23
+
24
+ def __init__(self, quantile: float = 0.9999, n_disp: int = 128, k_c: int = K_C, **kwargs):
25
+ super().__init__(quantile, **kwargs)
26
+ self.n_disp, self.k_c = int(n_disp), int(k_c)
27
+ kx, ky = mode_int_wavenumbers(self.k_c)
28
+ self.n_modes = len(kx)
29
+ self.ix, self.iy = kx % self.n_disp, ky % self.n_disp
30
+ self.ix_c, self.iy_c = (-kx) % self.n_disp, (-ky) % self.n_disp
31
+ if self.k_c * 2 >= self.n_disp:
32
+ raise ValueError("n_disp must exceed 2 k_c (alias-free reconstruction)")
33
+
34
+ def to_fields(self, states_raw: np.ndarray) -> np.ndarray:
35
+ x = np.asarray(states_raw, dtype=np.float64)
36
+ if x.ndim == 1:
37
+ x = x[None]
38
+ if x.shape[1] != 2 * self.n_modes:
39
+ raise ValueError(f"expected {2 * self.n_modes}-D coarse spectral states, got D={x.shape[1]}")
40
+ N = x.shape[0]
41
+ coeff = x[:, :self.n_modes] + 1j * x[:, self.n_modes:]
42
+ OH = np.zeros((N, self.n_disp, self.n_disp), dtype=np.complex128)
43
+ OH[:, self.ix, self.iy] += coeff
44
+ OH[:, self.ix_c, self.iy_c] += np.conj(coeff)
45
+ return (self.n_disp ** 2) * np.fft.ifft2(OH, axes=(1, 2)).real
46
+
47
+ def card(self) -> dict:
48
+ return {"state": "196-D coarse spectral vorticity (98 complex modes |k| <= 8, Hermitian half, omega_hat / N_sim^2)",
49
+ "reconstruction": f"exact inverse FFT of the |k| <= {self.k_c} modes on a {self.n_disp}x{self.n_disp} grid (alias-free)",
50
+ "n_disp": self.n_disp, "k_c": self.k_c, "clip": "symmetric [-c, c], c = Q_q(|omega_T|) over the training pool's final-time fields",
51
+ "physical_diagnostic": "enstrophy_ratio = mean_gen sum|omega_hat_k|^2 / mean_truth sum|omega_hat_k|^2"}
52
+
53
+ def physical_diagnostics(self, gen_raw: np.ndarray, true_raw: np.ndarray) -> dict:
54
+ def ens(v):
55
+ v = np.asarray(v, np.float64)
56
+ return float(np.mean(np.sum(v ** 2, axis=1))) # sum over Re/Im of all Hermitian-half modes
57
+ et = ens(true_raw)
58
+ return {"enstrophy_ratio": ens(gen_raw) / et if et > 0 else float("nan")}
code/bifurcation/fid/evaluate.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Conditional image metrics per test condition: csFID (the paper's metric), cFID and cKID.
2
+
3
+ generated_states(samples, variant, packaged, n) RAW generated states of every condition from a samples directory
4
+ truth_features / truth_spatial_features cached features of the first n_truth bank members of one condition
5
+ evaluate_csfid_run(...) csFID of all conditions -> csfid_per_ic.csv, csfid_summary.json
6
+ evaluate_run(...) cFID and cKID (pool3 features) -> per_ic_fid.csv, summary.json
7
+
8
+ The truth bank holds ``<bank_dir>/group_<gid:04d>.npz`` with ``vecs`` (n_members, n_snapshots, D) in raw units; the
9
+ first n_truth members are the reference ensemble of every method.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+
17
+ import numpy as np
18
+ import pandas as pd
19
+
20
+ from . import backend as BE
21
+ from .adapters import FIDAdapter
22
+
23
+
24
+ def generated_states(samples_dir: Path, variant: str, packaged: Path, n: int) -> dict:
25
+ """{gid: RAW generated states (n, D)} of ``samples_dir/<variant>/group_<gid>.npz`` (``gen_xT_norm``, un-normalized
26
+ with the package's normalization.npz)."""
27
+ z = np.load(Path(packaged) / "normalization.npz")
28
+ mean, std = np.asarray(z["mean"], np.float64), np.asarray(z["std"], np.float64)
29
+ out = {}
30
+ for p in sorted((Path(samples_dir) / variant).glob("group_[0-9][0-9][0-9][0-9].npz")):
31
+ with np.load(p) as f:
32
+ g = np.asarray(f["gen_xT_norm"], np.float64)[: int(n)]
33
+ out[int(f["gid"])] = g * std + mean
34
+ return out
35
+
36
+
37
+ def bank_members(bank_dir: Path, gid: int, t_idx: int, n: int | None = None) -> np.ndarray:
38
+ """RAW states (n, D) of the first n members of test condition ``gid`` at snapshot ``t_idx``."""
39
+ p = Path(bank_dir) / f"group_{int(gid):04d}.npz"
40
+ if not p.is_file():
41
+ raise FileNotFoundError(f"truth bank has no group_{int(gid):04d}.npz under {bank_dir}")
42
+ with np.load(p) as z:
43
+ vecs = z["vecs"]
44
+ n_have = int(vecs.shape[0])
45
+ if n is not None and n_have < int(n):
46
+ raise ValueError(f"bank group {gid}: {n_have} members < required {n}")
47
+ return np.asarray(vecs[: (int(n) if n is not None else n_have), int(t_idx), :], dtype=np.float64)
48
+
49
+
50
+ def bank_inventory(bank_dir: Path, gids: list[int]) -> dict:
51
+ out = {}
52
+ for g in gids:
53
+ p = Path(bank_dir) / f"group_{int(g):04d}.npz"
54
+ if not p.is_file():
55
+ out[int(g)] = 0
56
+ continue
57
+ with np.load(p) as z:
58
+ out[int(g)] = int(z["vecs"].shape[0])
59
+ return out
60
+
61
+
62
+ def features_for(states_raw: np.ndarray, adapter: FIDAdapter, extractor, device, batch: int = 200) -> tuple[np.ndarray, float]:
63
+ imgs, clipped = adapter.to_images(states_raw)
64
+ return BE.inception_features(imgs, extractor, device, batch=batch), clipped
65
+
66
+
67
+ def truth_features(bank_dir: Path, gid: int, t_idx: int, n_truth: int, adapter: FIDAdapter, extractor, device,
68
+ cache_dir: Path, batch: int = 200) -> tuple[np.ndarray, dict]:
69
+ """Cached per (gid, n_truth, adapter clip_c): the truth side does not depend on the model."""
70
+ cache_dir = Path(cache_dir); cache_dir.mkdir(parents=True, exist_ok=True)
71
+ tag = f"gid{int(gid):04d}_t{int(t_idx)}_n{int(n_truth)}_c{adapter.params['clip_c']:.6g}"
72
+ p = cache_dir / f"truth_{tag}.npz"
73
+ if p.is_file():
74
+ with np.load(p) as z:
75
+ return np.asarray(z["feats"]), {"cache": str(p), "clipped_frac": float(z["clipped_frac"]), "reused": True}
76
+ states = bank_members(bank_dir, gid, t_idx, n_truth)
77
+ feats, clipped = features_for(states, adapter, extractor, device, batch)
78
+ np.savez(p, feats=feats, clipped_frac=np.float64(clipped), gid=np.int64(gid), n=np.int64(n_truth))
79
+ return feats, {"cache": str(p), "clipped_frac": clipped, "reused": False}
80
+
81
+
82
+ def spatial_features_for(states_raw: np.ndarray, adapter: FIDAdapter, extractor, device, batch: int = 200) -> tuple[np.ndarray, float]:
83
+ imgs, clipped = adapter.to_images(states_raw)
84
+ return BE.spatial_features(imgs, extractor, device, batch=batch), clipped
85
+
86
+
87
+ def truth_spatial_features(bank_dir: Path, gid: int, t_idx: int, n_truth: int, adapter: FIDAdapter, extractor, device,
88
+ cache_dir: Path, batch: int = 200) -> tuple[np.ndarray, dict]:
89
+ """Like truth_features, for the spatial feature (a separate cache file)."""
90
+ cache_dir = Path(cache_dir); cache_dir.mkdir(parents=True, exist_ok=True)
91
+ tag = f"gid{int(gid):04d}_t{int(t_idx)}_n{int(n_truth)}_c{adapter.params['clip_c']:.6g}"
92
+ p = cache_dir / f"truth_spatial_{tag}.npz"
93
+ if p.is_file():
94
+ with np.load(p) as z:
95
+ return np.asarray(z["feats"]), {"cache": str(p), "clipped_frac": float(z["clipped_frac"]), "reused": True}
96
+ states = bank_members(bank_dir, gid, t_idx, n_truth)
97
+ feats, clipped = spatial_features_for(states, adapter, extractor, device, batch)
98
+ np.savez(p, feats=feats, clipped_frac=np.float64(clipped), gid=np.int64(gid), n=np.int64(n_truth))
99
+ return feats, {"cache": str(p), "clipped_frac": clipped, "reused": False}
100
+
101
+
102
+ def fid_kid(f_truth: np.ndarray, f_gen: np.ndarray, kid_cfg: dict) -> dict:
103
+ return {"fid": BE.fid_from_features(f_truth, f_gen),
104
+ "kid": BE.kid_from_features(f_truth, f_gen, int(kid_cfg.get("num_subsets", 100)),
105
+ int(kid_cfg.get("max_subset_size", 1000)))}
106
+
107
+
108
+ def summarize_per_ic(df: pd.DataFrame, cols=("fid", "kid")) -> dict:
109
+ out = {"n_ic": int(len(df))}
110
+ for c in cols:
111
+ v = df[c].to_numpy(dtype=float); v = v[np.isfinite(v)]
112
+ out[c] = {"mean": float(v.mean()) if len(v) else float("nan"),
113
+ "sem": float(v.std(ddof=1) / np.sqrt(len(v))) if len(v) > 1 else float("nan"),
114
+ "sd": float(v.std(ddof=1)) if len(v) > 1 else float("nan"), "n": int(len(v))}
115
+ return out
116
+
117
+
118
+ def evaluate_run(gen_by_gid: dict, bank_dir: Path, t_idx: int, n_truth: int, adapter: FIDAdapter, out_dir: Path,
119
+ cache_dir: Path, fid_cfg: dict, device, provenance: dict | None = None) -> dict:
120
+ """cFID and cKID. ``gen_by_gid``: {gid: RAW generated states (n_gen, D)}; writes out_dir/{per_ic_fid.csv,
121
+ summary.json, MANIFEST_FID.json, features/gen_gid<gid>.npz} and returns the summary (mean ± SEM over conditions)."""
122
+ out_dir = Path(out_dir); (out_dir / "features").mkdir(parents=True, exist_ok=True)
123
+ pins = BE.check_pins(fid_cfg)
124
+ extractor = BE.build_extractor(device)
125
+ batch = int(fid_cfg.get("feature_batch", 200))
126
+ rows, prov_ics = [], {}
127
+ for gid in sorted(gen_by_gid):
128
+ gen = np.asarray(gen_by_gid[gid], dtype=np.float64)
129
+ finite = np.isfinite(gen).all(axis=1)
130
+ n_bad = int((~finite).sum())
131
+ ft, tinfo = truth_features(bank_dir, gid, t_idx, n_truth, adapter, extractor, device, cache_dir, batch)
132
+ if n_bad == len(gen):
133
+ rows.append({"gid": int(gid), "fid": float("nan"), "kid": float("nan"), "n_truth": int(len(ft)),
134
+ "n_gen": 0, "n_gen_nonfinite": n_bad, "clipped_frac_gen": float("nan"), "clipped_frac_truth": tinfo["clipped_frac"]})
135
+ continue
136
+ fg, clipped_g = features_for(gen[finite], adapter, extractor, device, batch)
137
+ np.savez(out_dir / "features" / f"gen_gid{int(gid):04d}.npz", feats=fg, gid=np.int64(gid))
138
+ m = fid_kid(ft, fg, fid_cfg.get("kid", {}))
139
+ rows.append({"gid": int(gid), **m, "n_truth": int(len(ft)), "n_gen": int(len(fg)), "n_gen_nonfinite": n_bad,
140
+ "clipped_frac_gen": clipped_g, "clipped_frac_truth": tinfo["clipped_frac"]})
141
+ prov_ics[int(gid)] = tinfo
142
+ df = pd.DataFrame(rows).sort_values("gid")
143
+ df.to_csv(out_dir / "per_ic_fid.csv", index=False)
144
+ summary = summarize_per_ic(df)
145
+ summary["cFID"] = summary["fid"]; summary["cKID"] = summary["kid"]
146
+ summary["n_truth_per_ic"] = int(n_truth)
147
+ (out_dir / "summary.json").write_text(json.dumps(summary, indent=1) + "\n")
148
+ man = {"schema": "fid_v1", "pins": pins, "adapter": adapter.params, "fid_settings": fid_cfg, "bank_dir": str(bank_dir),
149
+ "t_idx": int(t_idx), "n_truth": int(n_truth), "truth_features": prov_ics, "provenance": provenance or {},
150
+ "at": datetime.now(timezone.utc).isoformat(timespec="seconds")}
151
+ (out_dir / "MANIFEST_FID.json").write_text(json.dumps(man, indent=1, default=str) + "\n")
152
+ return summary
153
+
154
+
155
+ def evaluate_csfid_run(gen_by_gid: dict, bank_dir: Path, t_idx: int, n_truth: int, adapter: FIDAdapter, out_dir: Path,
156
+ cache_dir: Path, fid_cfg: dict, device, provenance: dict | None = None) -> dict:
157
+ """csFID: the Frechet distance of the spatial Inception feature (Nash et al., 2021) between the generated states
158
+ and the truth bank, per condition; writes out_dir/{csfid_per_ic.csv, csfid_summary.json, MANIFEST_CSFID.json} and
159
+ returns the summary (mean ± SEM over conditions)."""
160
+ out_dir = Path(out_dir); out_dir.mkdir(parents=True, exist_ok=True)
161
+ pins = BE.check_pins(fid_cfg)
162
+ sp = fid_cfg.get("spatial") or {}
163
+ want = {"layer": BE.SPATIAL_LAYER, "channels": BE.SPATIAL_CHANNELS, "grid": BE.SPATIAL_GRID, "dim": BE.SPATIAL_DIM,
164
+ "flatten": "NHWC", "pooling": "none"}
165
+ bad = {k: (sp[k], v) for k, v in want.items() if k in sp and sp[k] != v}
166
+ if bad:
167
+ raise RuntimeError(f"the fid.spatial settings disagree with the csFID implementation: {bad}")
168
+ pins["spatial_tap_check"] = BE.verify_spatial_tap()
169
+ extractor = BE.build_extractor(device)
170
+ batch = int(fid_cfg.get("feature_batch", 200))
171
+ rows, prov_ics = [], {}
172
+ for gid in sorted(gen_by_gid):
173
+ gen = np.asarray(gen_by_gid[gid], dtype=np.float64)
174
+ finite = np.isfinite(gen).all(axis=1)
175
+ n_bad = int((~finite).sum())
176
+ ft, tinfo = truth_spatial_features(bank_dir, gid, t_idx, n_truth, adapter, extractor, device, cache_dir, batch)
177
+ if n_bad == len(gen):
178
+ rows.append({"gid": int(gid), "sfid": float("nan"), "n_truth": int(len(ft)), "n_gen": 0, "n_gen_nonfinite": n_bad})
179
+ continue
180
+ fg, _ = spatial_features_for(gen[finite], adapter, extractor, device, batch)
181
+ rows.append({"gid": int(gid), "sfid": BE.fid_from_features(ft, fg), "n_truth": int(len(ft)), "n_gen": int(len(fg)),
182
+ "n_gen_nonfinite": n_bad})
183
+ prov_ics[int(gid)] = tinfo
184
+ df = pd.DataFrame(rows).sort_values("gid")
185
+ df.to_csv(out_dir / "csfid_per_ic.csv", index=False)
186
+ summary = summarize_per_ic(df, cols=("sfid",))
187
+ summary["csFID"] = summary["sfid"]
188
+ summary["n_truth_per_ic"] = int(n_truth)
189
+ (out_dir / "csfid_summary.json").write_text(json.dumps(summary, indent=1) + "\n")
190
+ man = {"schema": "csfid_v1", "pins": pins, "adapter": adapter.params, "fid_settings": fid_cfg,
191
+ "bank_dir": str(bank_dir), "t_idx": int(t_idx), "n_truth": int(n_truth), "truth_features": prov_ics,
192
+ "provenance": provenance or {}, "at": datetime.now(timezone.utc).isoformat(timespec="seconds")}
193
+ (out_dir / "MANIFEST_CSFID.json").write_text(json.dumps(man, indent=1, default=str) + "\n")
194
+ return summary
195
+
196
+
197
+ def training_states_at(packaged_dir: Path, n_ic: int, members: int, snapshots) -> np.ndarray:
198
+ """RAW states of the training pool (the first ``n_ic`` training conditions x the first ``members`` realizations) at
199
+ the requested snapshots ('all' or a list of indices): the only data the adapter constants are fitted on."""
200
+ from ..data.fine import FINE_FILE, _npz_member_memmap
201
+ v = _npz_member_memmap(str(Path(packaged_dir) / FINE_FILE), "vecs_train")
202
+ if v.shape[0] < n_ic or v.shape[1] < members:
203
+ raise ValueError(f"vecs_train {tuple(v.shape)} smaller than the requested {n_ic} x {members} pool")
204
+ if snapshots == "all":
205
+ block = np.asarray(v[:n_ic, :members, :, :], dtype=np.float64)
206
+ else:
207
+ idx = [int(t) for t in (snapshots if isinstance(snapshots, (list, tuple)) else [snapshots])]
208
+ block = np.asarray(v[:n_ic, :members, :, :][:, :, idx, :], dtype=np.float64)
209
+ return block.reshape(-1, v.shape[-1])
210
+
211
+
212
+ def fit_adapter_from_training(adapter: FIDAdapter, packaged_dir: Path, n_ic: int, pool: dict, t_idx_final: int) -> dict:
213
+ """``pool`` (dataset card): {members: int | 'all', snapshots: 'all' | 'final' | [indices], pixel_stride: int}."""
214
+ from ..data.fine import FINE_FILE, _npz_member_memmap
215
+ v = _npz_member_memmap(str(Path(packaged_dir) / FINE_FILE), "vecs_train")
216
+ members = int(v.shape[1]) if str(pool.get("members", "all")) == "all" else int(pool["members"])
217
+ snaps = pool.get("snapshots", "all")
218
+ snaps = [int(t_idx_final)] if snaps == "final" else snaps
219
+ states = training_states_at(packaged_dir, n_ic, members, snaps)
220
+ return adapter.fit(states, pixel_stride=int(pool.get("pixel_stride", 1)),
221
+ source={"packaged_dataset_path": str(packaged_dir), "block": "vecs_train", "ics": f"canonical rows 0..{n_ic - 1}",
222
+ "members": f"j < {members}", "snapshots": ("all" if snaps == "all" else list(snaps)),
223
+ "pixel_stride": int(pool.get("pixel_stride", 1)), "n_states": int(len(states)),
224
+ "rule": "training data only; validation, test and generated data never enter"})
code/bifurcation/fid/heterogeneous_waves.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Heterogeneous-wave FID adapter: the SAME 196-D coarse spectral observable as Euler and the passive scalar (98 complex modes |k| <= 8, Hermitian half, coefficients / N^2, shared mode order from
2
+ ``bifurcation.models.spectral``) -> the exact low-pass DISPLACEMENT field u on a 128x128 grid -> symmetric clip ->
3
+ uint8 -> 3 identical channels.
4
+
5
+ The reconstruction is byte-identical to the Euler adapter's (one inverse FFT, one mode-index map); only the physical
6
+ reading differs. The observed field is the wave displacement u of
7
+
8
+ u_t = v, v_t = div(c^2 grad u) - gamma v, c^2(x, y) = 1 + 0.5 sin(2 pi x) sin(2 pi y),
9
+
10
+ which is signed and zero-mean (the DC mode is not retained by the observable), so the symmetric clip about zero is
11
+ the right quantization (a nonnegative [0, c] range would throw away half of every wave crest-trough pair).
12
+
13
+ The velocity v and the unresolved band 8 < |k| <= 16 are hidden, which is what makes p(x_T | x_0) non-degenerate;
14
+ neither enters the image, which is a function of the observed state alone.
15
+
16
+ Physical diagnostics, both computed from the coefficient vectors:
17
+ * ``displacement_variance_ratio`` — mean sum |u_hat_k|^2, the direct analogue of Euler's enstrophy ratio and the
18
+ passive scalar's scalar-variance ratio;
19
+ * ``gradient_energy_ratio`` — mean sum |k|^2 |u_hat_k|^2, the resolved part of the potential energy density
20
+ c^2 |grad u|^2 up to the medium factor; a wave field can match the first moment while carrying its energy at
21
+ the wrong wavenumber, and this separates the two.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import numpy as np
26
+
27
+ from ..models.spectral import K_C, mode_int_wavenumbers
28
+ from .adapters import register_adapter
29
+ from .euler import EulerVorticityAdapter
30
+
31
+
32
+ @register_adapter("heterogeneous_waves_coarse")
33
+ class HeterogeneousWavesAdapter(EulerVorticityAdapter):
34
+ field_kind = "signed"
35
+
36
+ def __init__(self, quantile: float = 0.9999, n_disp: int = 128, k_c: int = K_C, **kwargs):
37
+ super().__init__(quantile, n_disp=n_disp, k_c=k_c, **kwargs)
38
+ kx, ky = mode_int_wavenumbers(self.k_c)
39
+ self._k2 = kx.astype(np.float64) ** 2 + ky.astype(np.float64) ** 2
40
+
41
+ def card(self) -> dict:
42
+ c = super().card()
43
+ c.update({"state": "196-D coarse spectral wave displacement (98 complex modes |k| <= 8, Hermitian half, u_hat / N^2)",
44
+ "clip": "symmetric [-c, c], c = Q_q(|u|) over the training pool",
45
+ "physical_diagnostic": "displacement_variance_ratio = mean_gen sum|u_hat_k|^2 / mean_truth sum|u_hat_k|^2; "
46
+ "gradient_energy_ratio = mean_gen sum|k|^2|u_hat_k|^2 / mean_truth sum|k|^2|u_hat_k|^2"})
47
+ return c
48
+
49
+ def _moments(self, v: np.ndarray) -> tuple[float, float]:
50
+ v = np.asarray(v, np.float64)
51
+ n = self.n_modes
52
+ power = v[:, :n] ** 2 + v[:, n:] ** 2 # |u_hat_k|^2 per retained mode
53
+ return float(np.mean(np.sum(power, axis=1))), float(np.mean(np.sum(self._k2 * power, axis=1)))
54
+
55
+ def physical_diagnostics(self, gen_raw: np.ndarray, true_raw: np.ndarray) -> dict:
56
+ dg, gg = self._moments(gen_raw)
57
+ dt, gt = self._moments(true_raw)
58
+ return {"displacement_variance_ratio": dg / dt if dt > 0 else float("nan"),
59
+ "gradient_energy_ratio": gg / gt if gt > 0 else float("nan")}
code/bifurcation/fid/heterogeneous_waves_augmented.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FID adapter for the augmented state of the heterogeneous waves dataset.
2
+
3
+ The raw state is 392-D = concat(u_coarse[196], v_hat_coarse[196]). The image and the physical diagnostics read
4
+ the observed displacement field u only (the velocity is a model input, not the observable). This adapter slices the first 196
5
+ coordinates and otherwise delegates to ``HeterogeneousWavesAdapter`` (identical reconstruction,
6
+ clip rule, physical diagnostics) -- no numerical difference from the plain-u adapter once sliced.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+
12
+ from .adapters import register_adapter
13
+ from .heterogeneous_waves import HeterogeneousWavesAdapter
14
+
15
+ _D_U = 196
16
+
17
+
18
+ @register_adapter("heterogeneous_waves_augmented_coarse")
19
+ class HeterogeneousWavesAugmentedAdapter(HeterogeneousWavesAdapter):
20
+ def to_fields(self, states_raw: np.ndarray) -> np.ndarray:
21
+ x = np.asarray(states_raw, dtype=np.float64)
22
+ if x.ndim == 1:
23
+ x = x[None]
24
+ if x.shape[1] not in (_D_U, 2 * _D_U):
25
+ raise ValueError(f"expected {_D_U}-D or {2 * _D_U}-D (augmented) states, got D={x.shape[1]}")
26
+ return super().to_fields(x[:, :_D_U])
27
+
28
+ def _moments(self, v: np.ndarray) -> tuple[float, float]:
29
+ v = np.asarray(v, np.float64)
30
+ if v.shape[1] == 2 * _D_U:
31
+ v = v[:, :_D_U]
32
+ return super()._moments(v)
33
+
34
+ def card(self) -> dict:
35
+ c = super().card()
36
+ c.update({"state": "392-D augmented state (196-D coarse displacement u + 196-D finite-difference "
37
+ "coarse velocity v_hat); this adapter reads only the first 196 (displacement) "
38
+ "coordinates -- identical reconstruction to heterogeneous_waves_coarse"})
39
+ return c
code/bifurcation/fid/nlkg.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """NLKG image adapter: score displacement while tracking both displacement and velocity diagnostics.
2
+
3
+ The 392-dimensional state contains 196 displacement coordinates followed by 196 velocity
4
+ coordinates scaled by 1/4. Images reconstruct the displacement on a 128 x 128 grid.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import numpy as np
9
+
10
+ from ..models.spectral import K_C, mode_int_wavenumbers
11
+ from .adapters import register_adapter
12
+ from .euler import EulerVorticityAdapter
13
+
14
+ _D_C = 196
15
+
16
+
17
+ @register_adapter("nlkg_coarse_u")
18
+ class NLKGCoarseUAdapter(EulerVorticityAdapter):
19
+ field_kind = "signed"
20
+
21
+ def __init__(self, quantile: float = 0.9999, n_disp: int = 128, k_c: int = K_C, **kwargs):
22
+ super().__init__(quantile, n_disp=n_disp, k_c=k_c, **kwargs)
23
+ kx, ky = mode_int_wavenumbers(self.k_c)
24
+ self._k2 = kx.astype(np.float64) ** 2 + ky.astype(np.float64) ** 2
25
+ if 2 * self.n_modes != _D_C:
26
+ raise ValueError("nlkg_coarse_u assumes the 196-D shared coarse operator per component")
27
+
28
+ @staticmethod
29
+ def _split(x: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
30
+ x = np.asarray(x, dtype=np.float64)
31
+ if x.ndim == 1:
32
+ x = x[None]
33
+ if x.shape[1] == 2 * _D_C:
34
+ return x[:, :_D_C], x[:, _D_C:]
35
+ if x.shape[1] == _D_C:
36
+ return x, None
37
+ raise ValueError(f"expected {2 * _D_C}-D two-component (u, v/4) states or {_D_C}-D displacement-only states, got D={x.shape[1]}")
38
+
39
+ def to_fields(self, states_raw: np.ndarray) -> np.ndarray:
40
+ re, _ = self._split(states_raw)
41
+ return super().to_fields(re)
42
+
43
+ def card(self) -> dict:
44
+ c = super().card()
45
+ c.update({"state": "392-D coarse state (196-D u + 196-D v/4, shared coarse operator |k| <= 8 on each); "
46
+ "the image reads only the first component u -- identical reconstruction to euler_coarse_vorticity",
47
+ "clip": "symmetric [-c, c], c = Q_q(|u_coarse|) over the training pool's final-time fields",
48
+ "physical_diagnostic": "coarse_u_variance_ratio, gradient_energy_ratio (on u), coarse_v_variance_ratio (on v/4)"})
49
+ return c
50
+
51
+
52
+ def _power(self, v: np.ndarray) -> tuple[float, float]:
53
+ n = self.n_modes
54
+ power = v[:, :n] ** 2 + v[:, n:] ** 2
55
+ return float(np.mean(np.sum(power, axis=1))), float(np.mean(np.sum(self._k2 * power, axis=1)))
56
+
57
+ def physical_diagnostics(self, gen_raw: np.ndarray, true_raw: np.ndarray) -> dict:
58
+ gr, gi = self._split(gen_raw); tr, ti = self._split(true_raw)
59
+ vg, eg = self._power(gr); vt, et = self._power(tr)
60
+ out = {"coarse_u_variance_ratio": vg / vt if vt > 0 else float("nan"),
61
+ "gradient_energy_ratio": eg / et if et > 0 else float("nan")}
62
+ if gi is not None and ti is not None:
63
+ vgi, _ = self._power(gi); vti, _ = self._power(ti)
64
+ out["coarse_v_variance_ratio"] = vgi / vti if vti > 0 else float("nan")
65
+ return out
code/bifurcation/fid/passive_scalar.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Passive-scalar FID adapter: the SAME 196-D coarse spectral observable as Euler (98 complex modes
2
+ |k| <= 8, Hermitian half, coefficients / N^2, shared mode order from ``bifurcation.models.spectral``) -> the exact
3
+ low-pass scalar field theta on a 128x128 grid -> symmetric clip -> uint8 -> 3 identical channels.
4
+
5
+ The reconstruction is byte-identical to the Euler adapter's (one inverse FFT, one mode-index map); only the
6
+ physical reading differs: the field is a passive scalar concentration anomaly, signed and zero-mean, so the
7
+ symmetric clip about zero is the right quantization. Physical diagnostic: the scalar-variance ratio (mean sum|theta_hat_k|^2 of the generated cloud over the truth's) — the passive scalar's analogue of
8
+ Euler's enstrophy ratio.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import numpy as np
13
+
14
+ from .adapters import register_adapter
15
+ from .euler import EulerVorticityAdapter
16
+
17
+
18
+ @register_adapter("passive_scalar_coarse")
19
+ class PassiveScalarAdapter(EulerVorticityAdapter):
20
+ field_kind = "signed"
21
+
22
+ def card(self) -> dict:
23
+ c = super().card()
24
+ c.update({"state": "196-D coarse spectral passive scalar (98 complex modes |k| <= 8, Hermitian half, theta_hat / N^2)",
25
+ "clip": "symmetric [-c, c], c = Q_q(|theta|) over the training pool",
26
+ "physical_diagnostic": "scalar_variance_ratio = mean_gen sum|theta_hat_k|^2 / mean_truth sum|theta_hat_k|^2"})
27
+ return c
28
+
29
+ def physical_diagnostics(self, gen_raw: np.ndarray, true_raw: np.ndarray) -> dict:
30
+ def var(v):
31
+ v = np.asarray(v, np.float64)
32
+ return float(np.mean(np.sum(v ** 2, axis=1)))
33
+ vt = var(true_raw)
34
+ return {"scalar_variance_ratio": var(gen_raw) / vt if vt > 0 else float("nan")}
code/bifurcation/methods/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .base import Method
2
+
3
+ __all__ = ["Method"]
code/bifurcation/methods/base.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Method interface. EDM, Cascaded SI and Direct SI subclass this."""
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+ import torch
6
+
7
+
8
+ class Method(ABC):
9
+ """Training-method interface.
10
+
11
+ A method bundles together (a) the training-time loss, (b) the sampling
12
+ procedure used at eval time, and (c) the optimizer for the model. The
13
+ trainer is method-agnostic — it calls these three hooks and nothing else.
14
+ """
15
+
16
+ @abstractmethod
17
+ def loss(self, model: torch.nn.Module, batch: tuple[torch.Tensor, ...]) -> torch.Tensor:
18
+ """Per-batch training loss. batch = (x_0, x_T) tensors."""
19
+
20
+ @abstractmethod
21
+ def sample(self, model: torch.nn.Module, x_0: torch.Tensor, n_samples: int,
22
+ **variant_kwargs) -> torch.Tensor:
23
+ """Generate n_samples per x_0 conditioning vector. Returns [B, n_samples, D].
24
+
25
+ Methods that support multiple eval variants accept variant-specific
26
+ overrides as keyword arguments (e.g. ``noise_scale=0.25`` for SI). The
27
+ legal kwargs are whatever ``variants_for_eval()`` produces.
28
+ """
29
+
30
+ def variants_for_eval(self) -> dict[str, dict]:
31
+ """Eval-time sampling variants this method exposes.
32
+
33
+ Returns a dict ``{variant_label: sample_kwargs}``; ``bifurcation.sample`` writes
34
+ one directory per variant.
35
+
36
+ Default: one variant called ``default`` with no kwarg overrides — used
37
+ by EDM (the Heun sampler is deterministic; there's no analogous
38
+ noise-scale knob).
39
+ """
40
+ return {"default": {}}
41
+
42
+ def configure_optimizer(self, model: torch.nn.Module, cfg: dict) -> torch.optim.Optimizer:
43
+ """Build the optimizer from ``self.optimizer_cfg`` (set by the method's __init__).
44
+
45
+ Supported types:
46
+ - ``adam`` (default) → ``torch.optim.Adam`` ; weight_decay ignored
47
+ - ``adamw`` → ``torch.optim.AdamW``; ``weight_decay`` honoured
48
+ (true decoupled decay — the right way to regularise
49
+ adaptive-LR optimisers)
50
+
51
+ Default (no ``optimizer`` block in the method section, or
52
+ ``optimizer.type == "adam"`` with ``weight_decay`` 0): plain Adam.
53
+
54
+ A non-None ``cfg["weight_decay"]`` (the training section, passed by
55
+ the trainer) overrides the method's block and selects AdamW with that decay.
56
+ """
57
+ opt_cfg = getattr(self, "optimizer_cfg", None) or {}
58
+ opt_type = str(opt_cfg.get("type", "adam")).lower()
59
+ wd = float(opt_cfg.get("weight_decay", 0.0))
60
+ params = {"lr": cfg["lr"], "betas": (0.9, 0.999), "eps": 1e-8}
61
+ wd_override = cfg.get("weight_decay")
62
+ if wd_override is not None:
63
+ return torch.optim.AdamW(model.parameters(),
64
+ weight_decay=float(wd_override), **params)
65
+ if opt_type == "adam":
66
+ return torch.optim.Adam(model.parameters(), **params)
67
+ if opt_type == "adamw":
68
+ return torch.optim.AdamW(model.parameters(), weight_decay=wd, **params)
69
+ raise ValueError(f"unknown optimizer.type: {opt_type!r}")
code/bifurcation/methods/cascaded_si/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .method import CascadedSIMethod
2
+
3
+ __all__ = ["CascadedSIMethod"]
code/bifurcation/methods/cascaded_si/core.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Cascaded SI: the Wiener-path (Foellmer) stochastic interpolant per segment, the segment-wise mirror of
3
+ ``methods.direct_si.core``. The trained drift b is the matched SDE drift (b = b_ODE + 0.5 sigma_tau^2 * score,
4
+ baked in), so the sampler needs no score term and no second network.
5
+
6
+ Identical Wiener coefficients to the direct construction; the differences:
7
+ - the anchor is the segment start x_start (= x_tk), not x0;
8
+ - the network takes (x, tau, x_start, t_k, t_k1), the five-channel segment head;
9
+ - sampling chains n_segments per-segment SDE integrations.
10
+
11
+ sigma = amplitude; beta_power = 1 -> beta=tau (linear), 2 -> beta=tau^2 (b'(0)=0).
12
+ Schedule: alpha=1-tau, sigma_tau = sigma*(1-tau). Realize W_tau = sqrt(tau)*z.
13
+ Three distinct noise coefficients (not a derivative pair):
14
+ interpolant noise (coeff on z in x_tau) : sigma_tau*sqrt(tau) = sigma(1-tau)sqrt(tau)
15
+ target noise (coeff on z in R) : sigma_tau_dot*sqrt(tau) = -sigma*sqrt(tau)
16
+ sampler diffusion (coeff on dW) : sigma_tau = sigma(1-tau)
17
+ beta_power only changes the DETERMINISTIC interpolant/target:
18
+ beta=tau : I=(1-tau)x_tk+tau x_tk1, dtIt=-x_tk+x_tk1
19
+ beta=tau^2 : I=(1-tau)x_tk+tau^2 x_tk1, dtIt=-x_tk+2 tau x_tk1
20
+ The Wiener target coeff -sigma*sqrt(tau) is bounded, so t_eps may be 0.0 (no
21
+ endpoint singularity) and the sampler grid starts at tau=0.
22
+ """
23
+ from __future__ import annotations
24
+ import math
25
+ import torch
26
+ from torch import vmap
27
+
28
+ def sigma_tau(tau, sigma):
29
+ return sigma * (1.0 - tau)
30
+
31
+ def sigma_tau_dot(tau, sigma):
32
+ return -sigma * torch.ones_like(tau)
33
+
34
+ def interp_It(tau, x0, x1, beta_power=1):
35
+ beta = tau if beta_power == 1 else tau * tau
36
+ return (1.0 - tau) * x0 + beta * x1
37
+
38
+ def interp_dtIt(tau, x0, x1, beta_power=1):
39
+ beta_dot = 1.0 if beta_power == 1 else 2.0 * tau
40
+ return -x0 + beta_dot * x1
41
+
42
+ def b_loss_per_sample(b_net, x_tk, x_tk1, tau, t_k, t_k1, sigma, beta_power=1):
43
+ """Wiener antithetic b-loss for one segment example.
44
+
45
+ Shapes:
46
+ x_tk, x_tk1 : (D,)
47
+ tau : scalar
48
+ t_k, t_k1 : scalars (segment physical start/end)
49
+ Returns: scalar loss.
50
+ """
51
+ z = torch.randn_like(x_tk)
52
+ sqrt_t = torch.sqrt(tau)
53
+ It = interp_It(tau, x_tk, x_tk1, beta_power)
54
+ env = sigma_tau(tau, sigma) * sqrt_t # interpolant noise coeff
55
+ xtp = It + env * z
56
+ xtm = It - env * z
57
+ tau_s = tau.unsqueeze(0)
58
+ dtIt = interp_dtIt(tau, x_tk, x_tk1, beta_power)
59
+ tcoeff = sigma_tau_dot(tau, sigma) * sqrt_t # target noise coeff = -sigma*sqrt(tau)
60
+ tgt_p = dtIt + tcoeff * z
61
+ tgt_m = dtIt - tcoeff * z
62
+ btp = b_net(xtp.unsqueeze(0), tau_s,
63
+ x_tk.unsqueeze(0), t_k.view(1, 1), t_k1.view(1, 1)).squeeze(0)
64
+ btm = b_net(xtm.unsqueeze(0), tau_s,
65
+ x_tk.unsqueeze(0), t_k.view(1, 1), t_k1.view(1, 1)).squeeze(0)
66
+ return (0.5 * (btp ** 2).sum() - (btp * tgt_p).sum()
67
+ + 0.5 * (btm ** 2).sum() - (btm * tgt_m).sum())
68
+
69
+ _batched_b_loss = vmap(
70
+ b_loss_per_sample,
71
+ in_dims=(None, 0, 0, 0, 0, 0, None, None),
72
+ randomness="different",
73
+ )
74
+
75
+ def local_si_loss(b_net, x_tk_b, x_tk1_b, t_k_b, t_k1_b, sigma,
76
+ t_eps=0.0, beta_power=1):
77
+ # tau ~ U(t_eps, 1-t_eps). t_eps may be 0.0: the Wiener target coeff
78
+ # -sigma*sqrt(tau) is bounded (no endpoint singularity).
79
+ B = x_tk_b.shape[0]
80
+ device = x_tk_b.device
81
+ tau_batch = torch.empty(B, device=device).uniform_(t_eps, 1.0 - t_eps)
82
+ losses = _batched_b_loss(b_net, x_tk_b, x_tk1_b, tau_batch, t_k_b, t_k1_b,
83
+ sigma, beta_power)
84
+ return losses.mean()
85
+
86
+ def sde_segment(b_net, x_start_batch, t_k_val, t_k1_val,
87
+ n_steps, sigma, noise_scale=1.0):
88
+ # dY = b dtau + sigma_tau dW over one segment, anchor x_start. Trained drift
89
+ # already contains the score, so noise_scale = 1 is the matched sampler;
90
+ # diffusion = sigma_tau (not the envelope sigma_tau*sqrt(tau)). For Wiener,
91
+ # noise_scale != 1 is not marginal-preserving (no recovered score) -> use 1.
92
+ # beta_power does not enter here.
93
+ N = x_start_batch.shape[0]
94
+ device = x_start_batch.device
95
+ dt = 1.0 / n_steps
96
+ ts = torch.linspace(0.0, 1.0 - dt, n_steps, device=device)
97
+
98
+ t_k_t = torch.tensor([[t_k_val]], dtype=torch.float32, device=device).expand(N, 1)
99
+ t_k1_t = torch.tensor([[t_k1_val]], dtype=torch.float32, device=device).expand(N, 1)
100
+
101
+ x_start = x_start_batch.clone()
102
+ x = x_start.clone()
103
+
104
+ with torch.no_grad():
105
+ for t_val in ts:
106
+ tau = t_val.view(1, 1).expand(N, 1)
107
+ drift = b_net(x, tau, x_start, t_k_t, t_k1_t)
108
+ g = noise_scale * sigma_tau(t_val, sigma)
109
+ x = x + dt * drift + g * math.sqrt(dt) * torch.randn_like(x)
110
+ return x
111
+
112
+ def rollout(b_net, x0, n_samples, t_values, n_sde_steps_per_seg, sigma,
113
+ noise_scale=1.0):
114
+ """Roll out n_samples trajectories from x0 across all segments.
115
+
116
+ Args
117
+ ----
118
+ x0 : (D,) or (1, D) — conditioning state.
119
+ t_values : 1-D iterable of n_segments+1 physical times (e.g. 0.0..1.0).
120
+ Returns
121
+ -------
122
+ x_T : (n_samples, D) — endpoint cloud.
123
+ """
124
+ if x0.ndim == 1:
125
+ x = x0.unsqueeze(0).expand(n_samples, -1).clone()
126
+ else:
127
+ x = x0.expand(n_samples, -1).clone()
128
+ n_segments = len(t_values) - 1
129
+ for k in range(n_segments):
130
+ x = sde_segment(b_net, x,
131
+ float(t_values[k]), float(t_values[k + 1]),
132
+ n_sde_steps_per_seg, sigma, noise_scale)
133
+ return x
code/bifurcation/methods/cascaded_si/method.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import torch
3
+ from ..base import Method
4
+ from . import core
5
+
6
+ class CascadedSIMethod(Method):
7
+ def __init__(self, sigma_train, t_eps, sampling, beta_power=1,
8
+ eval_noise_scales=None, optimizer_cfg=None):
9
+ self.sigma_train = float(sigma_train)
10
+ self.t_eps = float(t_eps)
11
+ self.beta_power = int(beta_power)
12
+ self.n_sde_steps_per_seg = int(sampling["n_sde_steps_per_seg"])
13
+ self.n_segments = int(sampling.get("n_segments", 10))
14
+ self.noise_scale = float(sampling.get("noise_scale", 1.0))
15
+ # Segment boundaries — uniform partition of [0, 1] by default, matching
16
+ # the normalized physical forecast horizon.
17
+ if "t_values" in sampling:
18
+ self.t_values = list(sampling["t_values"])
19
+ else:
20
+ self.t_values = [k / self.n_segments for k in range(self.n_segments + 1)]
21
+ self.eval_noise_scales = ([float(s) for s in eval_noise_scales]
22
+ if eval_noise_scales is not None else [self.noise_scale])
23
+ self.optimizer_cfg = optimizer_cfg or {}
24
+
25
+ @classmethod
26
+ def from_config(cls, cfg):
27
+ if cfg.get("anchor_ic", False):
28
+ raise ValueError("This release supports the paper's SI models without retained initial-state inputs")
29
+ return cls(
30
+ sigma_train=cfg["sigma_train"],
31
+ t_eps=cfg.get("t_eps", 0.0),
32
+ sampling=cfg["sampling"],
33
+ beta_power=cfg.get("beta_power", 1),
34
+ eval_noise_scales=cfg.get("eval_noise_scales"),
35
+ optimizer_cfg=cfg.get("optimizer"),
36
+ )
37
+
38
+ def variants_for_eval(self):
39
+ return {f"noise_scale_{_fmt(s)}": {"noise_scale": s} for s in self.eval_noise_scales}
40
+
41
+ def loss(self, model, batch):
42
+ x_tk, x_tk1, t_k, t_k1 = batch
43
+ return core.local_si_loss(model, x_tk, x_tk1, t_k, t_k1,
44
+ sigma=self.sigma_train, t_eps=self.t_eps,
45
+ beta_power=self.beta_power)
46
+
47
+ def sample(self, model, x_0, n_samples, noise_scale=None):
48
+ if noise_scale is None:
49
+ noise_scale = self.noise_scale
50
+ if x_0.ndim == 1:
51
+ x_0 = x_0.unsqueeze(0)
52
+ B = x_0.shape[0]
53
+ out = []
54
+ for i in range(B):
55
+ cloud = core.rollout(model, x_0[i], n_samples=n_samples,
56
+ t_values=self.t_values,
57
+ n_sde_steps_per_seg=self.n_sde_steps_per_seg,
58
+ sigma=self.sigma_train,
59
+ noise_scale=noise_scale)
60
+ out.append(cloud)
61
+ return torch.stack(out, dim=0)
62
+
63
+ def _fmt(s):
64
+ if s == int(s):
65
+ return f"{int(s)}p0"
66
+ return f"{s}".replace(".", "p")
code/bifurcation/methods/direct_si/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .method import DirectSIMethod
2
+
3
+ __all__ = ["DirectSIMethod"]
code/bifurcation/methods/direct_si/core.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Direct SI: the Wiener-path (Foellmer) stochastic interpolant from x_0 to x_T. The trained drift b is the
3
+ matched SDE drift (b = b_ODE + 0.5 sigma_tau^2 * score, baked in), so the sampler
4
+ needs no score term and no second network.
5
+
6
+ sigma = amplitude; beta_power = 1 -> beta=tau (linear), 2 -> beta=tau^2 (b'(0)=0).
7
+ Schedule: alpha=1-tau, sigma_tau = sigma*(1-tau). Realize W_tau = sqrt(tau)*z.
8
+ Three distinct noise coefficients (not a derivative pair):
9
+ interpolant noise (coeff on z in x_tau) : sigma_tau*sqrt(tau) = sigma(1-tau)sqrt(tau)
10
+ target noise (coeff on z in R) : sigma_tau_dot*sqrt(tau) = -sigma*sqrt(tau)
11
+ sampler diffusion (coeff on dW) : sigma_tau = sigma(1-tau)
12
+ beta_power only changes the DETERMINISTIC interpolant/target:
13
+ beta=tau : I=(1-tau)x0+tau x1, dtIt=-x0+x1 = x1-x0
14
+ beta=tau^2 : I=(1-tau)x0+tau^2 x1, dtIt=-x0+2 tau x1
15
+ """
16
+ from __future__ import annotations
17
+ import math
18
+ import torch
19
+ from torch import vmap
20
+
21
+ def sigma_tau(tau, sigma):
22
+ return sigma * (1.0 - tau)
23
+
24
+ def sigma_tau_dot(tau, sigma):
25
+ return -sigma * torch.ones_like(tau)
26
+
27
+ def interp_It(tau, x0, x1, beta_power=1):
28
+ beta = tau if beta_power == 1 else tau * tau
29
+ return (1.0 - tau) * x0 + beta * x1
30
+
31
+ def interp_dtIt(tau, x0, x1, beta_power=1):
32
+ beta_dot = 1.0 if beta_power == 1 else 2.0 * tau
33
+ return -x0 + beta_dot * x1
34
+
35
+ def b_loss_per_sample(b_net, x0, x1, tau, sigma, beta_power=1):
36
+ z = torch.randn_like(x0)
37
+ sqrt_t = torch.sqrt(tau)
38
+ It = interp_It(tau, x0, x1, beta_power)
39
+ env = sigma_tau(tau, sigma) * sqrt_t # interpolant noise coeff
40
+ xtp = It + env * z
41
+ xtm = It - env * z
42
+ tau_s = tau.unsqueeze(0)
43
+ dtIt = interp_dtIt(tau, x0, x1, beta_power)
44
+ tcoeff = sigma_tau_dot(tau, sigma) * sqrt_t # target noise coeff = -sigma*sqrt(tau)
45
+ tgt_p = dtIt + tcoeff * z
46
+ tgt_m = dtIt - tcoeff * z
47
+ btp = b_net(xtp.unsqueeze(0), tau_s, x0.unsqueeze(0)).squeeze(0)
48
+ btm = b_net(xtm.unsqueeze(0), tau_s, x0.unsqueeze(0)).squeeze(0)
49
+ return (0.5 * (btp ** 2).sum() - (btp * tgt_p).sum()
50
+ + 0.5 * (btm ** 2).sum() - (btm * tgt_m).sum())
51
+
52
+ _batched_b_loss = vmap(
53
+ b_loss_per_sample,
54
+ in_dims=(None, 0, 0, 0, None, None),
55
+ randomness="different",
56
+ )
57
+
58
+ def global_si_loss(b_net, x_0_batch, x_T_batch, sigma, t_eps=0.0, beta_power=1):
59
+ # tau ~ U(t_eps, 1-t_eps). t_eps may be 0.0: the Wiener target coeff
60
+ # -sigma*sqrt(tau) is bounded (no endpoint singularity).
61
+ B = x_0_batch.shape[0]
62
+ device = x_0_batch.device
63
+ tau_batch = torch.empty(B, device=device).uniform_(t_eps, 1.0 - t_eps)
64
+ losses = _batched_b_loss(b_net, x_0_batch, x_T_batch, tau_batch, sigma, beta_power)
65
+ return losses.mean()
66
+
67
+ def sde_sample(b_net, x_0, n_samples, n_steps, sigma, noise_scale=1.0):
68
+ # dY = b dtau + sigma_tau dW, Y_0 = x0. Trained drift already contains the score,
69
+ # so noise_scale = 1 is the matched sampler; diffusion = sigma_tau (not the
70
+ # envelope sigma_tau*sqrt(tau)). For Wiener, noise_scale != 1 is not
71
+ # marginal-preserving (no recovered score) -> use 1. beta_power does not enter here.
72
+ device = x_0.device
73
+ dt = 1.0 / n_steps
74
+ ts = torch.linspace(0.0, 1.0 - dt, n_steps, device=device)
75
+ if x_0.ndim == 1:
76
+ x0_e = x_0.unsqueeze(0).expand(n_samples, -1)
77
+ else:
78
+ x0_e = x_0.expand(n_samples, -1)
79
+ x = x0_e.clone()
80
+ with torch.no_grad():
81
+ for t_val in ts:
82
+ tau = t_val.expand(n_samples)
83
+ drift = b_net(x, tau, x0_e)
84
+ g = noise_scale * sigma_tau(t_val, sigma)
85
+ x = x + dt * drift + g * math.sqrt(dt) * torch.randn_like(x)
86
+ return x
code/bifurcation/methods/direct_si/method.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import torch
3
+ from ..base import Method
4
+ from . import core
5
+
6
+ class DirectSIMethod(Method):
7
+ def __init__(self, sigma_train, t_eps, sampling, beta_power=1,
8
+ eval_noise_scales=None, optimizer_cfg=None):
9
+ self.sigma_train = float(sigma_train)
10
+ self.t_eps = float(t_eps)
11
+ self.beta_power = int(beta_power)
12
+ self.n_sde_steps = int(sampling["n_sde_steps"])
13
+ self.noise_scale = float(sampling.get("noise_scale", 1.0))
14
+ self.eval_noise_scales = ([float(s) for s in eval_noise_scales]
15
+ if eval_noise_scales is not None else [self.noise_scale])
16
+ self.optimizer_cfg = optimizer_cfg or {}
17
+
18
+ @classmethod
19
+ def from_config(cls, cfg):
20
+ return cls(
21
+ sigma_train=cfg["sigma_train"],
22
+ t_eps=cfg.get("t_eps", 0.0),
23
+ sampling=cfg["sampling"],
24
+ beta_power=cfg.get("beta_power", 1),
25
+ eval_noise_scales=cfg.get("eval_noise_scales"),
26
+ optimizer_cfg=cfg.get("optimizer"),
27
+ )
28
+
29
+ def variants_for_eval(self):
30
+ return {f"noise_scale_{_fmt(s)}": {"noise_scale": s} for s in self.eval_noise_scales}
31
+
32
+ def loss(self, model, batch):
33
+ x_0, x_T = batch
34
+ return core.global_si_loss(model, x_0, x_T, sigma=self.sigma_train,
35
+ t_eps=self.t_eps, beta_power=self.beta_power)
36
+
37
+ def sample(self, model, x_0, n_samples, noise_scale=None):
38
+ if noise_scale is None:
39
+ noise_scale = self.noise_scale
40
+ if x_0.ndim == 1:
41
+ x_0 = x_0.unsqueeze(0)
42
+ B = x_0.shape[0]
43
+ out = []
44
+ for i in range(B):
45
+ cloud = core.sde_sample(model, x_0[i], n_samples=n_samples,
46
+ n_steps=self.n_sde_steps, sigma=self.sigma_train,
47
+ noise_scale=noise_scale)
48
+ out.append(cloud)
49
+ return torch.stack(out, dim=0)
50
+
51
+ def _fmt(s):
52
+ if s == int(s):
53
+ return f"{int(s)}p0"
54
+ return f"{s}".replace(".", "p")
code/bifurcation/methods/edm/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .method import EDMMethod
2
+
3
+ __all__ = ["EDMMethod"]
code/bifurcation/methods/edm/core.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ core.py — EDM core math for conditional diffusion.
3
+
4
+ Reimplements the design of NVlabs/edm
5
+ (https://github.com/NVlabs/edm; specifically ``training/training_loop.py``)
6
+ following Karras et al. 2022, "Elucidating the Design Space of
7
+ Diffusion-Based Generative Models", NeurIPS 2022.
8
+
9
+ Method-only. No architecture, no data loading, no training loop.
10
+
11
+ Six components:
12
+ - σ sampling at training time
13
+ - preconditioning (c_skip, c_out, c_in, c_noise)
14
+ - denoiser wrapper D_θ
15
+ - training loss with λ(σ) weighting
16
+ - σ schedule for sampling (ρ-parameterized)
17
+ - Heun's 2nd-order sampler with Euler fallback at σ=0
18
+ """
19
+
20
+ import torch
21
+ import math
22
+
23
+
24
+ # --- 1. Training-time σ sampling ---
25
+
26
+ def sample_training_sigma(batch_size, P_mean, P_std, device):
27
+ """ see Table 1 in EDM paper. Formula for noise distribution under Training section.
28
+ Returns: tensor of shape (batch_size,).
29
+ """
30
+ ln_sigma = torch.empty(batch_size, device=device).normal_(mean=P_mean, std=P_std)
31
+ sigma = torch.exp(ln_sigma)
32
+ return sigma
33
+
34
+
35
+ # --- 2. Preconditioning (EDM Table 1) ---
36
+
37
+ def c_skip(sigma, sigma_data):
38
+ """Skip-connection scaling."""
39
+ c_skip = sigma_data**2 / (sigma**2 +sigma_data**2)
40
+ return c_skip
41
+
42
+ def c_out(sigma, sigma_data):
43
+ """Output scaling."""
44
+ c_out = sigma * sigma_data / (torch.sqrt(sigma_data**2+sigma**2))
45
+ return c_out
46
+
47
+ def c_in(sigma, sigma_data):
48
+ """Input scaling."""
49
+ c_in=1 / (torch.sqrt(sigma**2+sigma_data**2))
50
+ return c_in
51
+
52
+ def c_noise(sigma):
53
+ """Time-like scalar fed into the network."""
54
+ c_noise=0.25 * torch.log(sigma)
55
+ return c_noise
56
+
57
+
58
+
59
+
60
+ # --- 3. Denoiser wrapper ---
61
+ def denoise(F_theta, x_noisy, sigma, x_0, sigma_data):
62
+ """
63
+ Wraps the raw network F_theta into the preconditioned denoiser D_theta. This corresponds to equation 7 in the EDM paper.
64
+ D_θ(x, σ, x_0) = c_skip(σ)·x + c_out(σ) · F_θ(c_in(σ)·x, c_noise(σ), x_0)
65
+ Used identically in both training (target reconstruction) and sampling (Heuns method)
66
+ Returns predicted clean x_T (same shape as x_noisy).
67
+ """
68
+ # Step 1: normalize the noisy input
69
+ x_normalized = c_in(sigma, sigma_data) * x_noisy
70
+
71
+ # Step 2: embed the noise level
72
+ sigma_embedded = c_noise(sigma)
73
+
74
+ # Step 3: feed normalized inputs + conditioning through the network
75
+ F_out = F_theta(x_normalized, sigma_embedded, x_0)
76
+
77
+ # Step 4: combine via skip connection + scaled output
78
+ D_theta = c_skip(sigma, sigma_data) * x_noisy + c_out(sigma, sigma_data) * F_out
79
+
80
+ return D_theta
81
+
82
+
83
+ # --- 4. Loss ---
84
+
85
+ def lambda_weight(sigma, sigma_data):
86
+ """Per-σ loss weighting (EDM Table 1, section training)."""
87
+ lambda_sigma = (sigma**2 + sigma_data**2)/(sigma*sigma_data)**2
88
+ return lambda_sigma
89
+
90
+ def edm_loss(F_theta, x_T_batch, x_0_batch, sigma_data, P_mean, P_std):
91
+ """
92
+ Compute the EDM training loss for one batch.
93
+
94
+ Args:
95
+ F_theta: the raw network
96
+ x_T_batch: clean data, shape [B, D]
97
+ x_0_batch: conditioning, shape [B, D]
98
+ sigma_data: fixed scalar
99
+ P_mean, P_std: log-normal sampling hyperparameters
100
+
101
+ Returns:
102
+ scalar loss
103
+ """
104
+ batch_size = x_T_batch.shape[0]
105
+ device = x_T_batch.device
106
+
107
+ # Step 1: sample sigma per example
108
+ sigma = sample_training_sigma(batch_size, P_mean, P_std, device)
109
+ # reshape for broadcasting: [B] -> [B, 1] for vector data
110
+ sigma = sigma.view(-1, 1)
111
+
112
+ # Step 2: sample noise
113
+ epsilon = torch.randn_like(x_T_batch)
114
+
115
+ # Step 3: add noise (EDM's simple forward process)
116
+ x_noisy = x_T_batch + sigma * epsilon
117
+
118
+ # Step 4: run through denoiser
119
+ D_theta = denoise(F_theta, x_noisy, sigma, x_0_batch, sigma_data)
120
+
121
+ # Step 5: compute weighted squared error
122
+ squared_error = (D_theta - x_T_batch).pow(2).sum(dim=-1) # [B]
123
+ weighted_loss = lambda_weight(sigma.squeeze(-1), sigma_data) * squared_error # [B]
124
+
125
+ # Step 6: average over batch
126
+ return weighted_loss.mean()
127
+
128
+
129
+ # --- 5. Sampling-time σ schedule ---
130
+
131
+ def sampling_sigma_schedule(N, sigma_min, sigma_max, rho, device):
132
+ """ρ-parameterized power-law schedule.
133
+
134
+ Returns tensor of length N+1 with σ_0 = σ_max, σ_N = 0,
135
+ and σ_i for 0 < i < N as in EDM Eq. 5.
136
+ """
137
+ # i goes from 0 to N-1
138
+ i = torch.arange(N, device=device, dtype=torch.float32)
139
+
140
+ # Apply the power-law formula
141
+ inv_rho = 1.0 / rho
142
+ sigma_max_inv = sigma_max ** inv_rho
143
+ sigma_min_inv = sigma_min ** inv_rho
144
+ sigmas = (sigma_max_inv + (i / (N - 1)) * (sigma_min_inv - sigma_max_inv)) ** rho
145
+
146
+ # Append the final σ = 0 (we want to reach truly clean data)
147
+ sigmas = torch.cat([sigmas, torch.zeros(1, device=device)])
148
+ return sigmas
149
+
150
+
151
+ # --- 6. Heun sampler (EDM Algorithm 1) ---
152
+
153
+ def heun_sample(network, x_0, n_samples, N, sigma_min, sigma_max, rho, sigma_data, device):
154
+ """Sample from p(x_T | x_0) via Heun's 2nd-order method.
155
+
156
+ Initializes at x ~ N(0, σ_max² · I), iterates the σ schedule with
157
+ Heun steps, falls back to Euler at the final step where σ_next = 0.
158
+
159
+ Returns: tensor of shape (n_samples, x_0.shape[-1]).
160
+ """
161
+ # 1. Build the σ schedule (length N+1, ending at 0)
162
+ sigmas = sampling_sigma_schedule(N, sigma_min, sigma_max, rho, device)
163
+
164
+ # 2. Broadcast x_0 to (n_samples, D) so every generated sample uses
165
+ # the same conditioning but different starting noise.
166
+ if x_0.ndim == 1:
167
+ x_0_batch = x_0.unsqueeze(0).expand(n_samples, -1)
168
+ else:
169
+ x_0_batch = x_0
170
+ D_dim = x_0_batch.shape[-1]
171
+
172
+ # 3. Initialize at pure noise scaled to σ_max.
173
+ # y₀ ~ N(0, σ_max²·I) means y₀ = σ_max · ε for ε ~ N(0, I).
174
+ y = sigma_max * torch.randn(n_samples, D_dim, device=device)
175
+
176
+ # 4. Iterate Heun steps along the schedule.
177
+ for i in range(N):
178
+ sigma_cur = sigmas[i] # current noise level
179
+ sigma_next = sigmas[i + 1] # target noise level for this step
180
+
181
+ # Reshape σ to match batch shape for broadcasting
182
+ sigma_cur_b = sigma_cur.expand(n_samples, 1)
183
+
184
+ # --- Predictor: Euler step ---
185
+ # Evaluate the denoiser at the current point
186
+ D_cur = denoise(network, y, sigma_cur_b, x_0_batch, sigma_data)
187
+ # Compute the slope dy/dσ at the current point
188
+ d_cur = (y - D_cur) / sigma_cur
189
+ # Euler step toward σ_next (note: σ_next − σ_cur is negative since σ decreases)
190
+ y_pred = y + (sigma_next - sigma_cur) * d_cur
191
+
192
+ # --- Corrector: Heun's average slope ---
193
+ if sigma_next > 0:
194
+ # Evaluate the denoiser at the predicted next point
195
+ sigma_next_b = sigma_next.expand(n_samples, 1)
196
+ D_next = denoise(network, y_pred, sigma_next_b, x_0_batch, sigma_data)
197
+ # Slope at the predicted point
198
+ d_next = (y_pred - D_next) / sigma_next
199
+ # Take the average slope and redo the step
200
+ y = y + (sigma_next - sigma_cur) * 0.5 * (d_cur + d_next)
201
+ else:
202
+ # Final step: σ_next = 0 would divide by zero in d_next.
203
+ # Just use the Euler estimate. This is the only Euler-style step.
204
+ y = y_pred
205
+ return y
code/bifurcation/methods/edm/method.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """EDMMethod — wraps the math in core.py."""
2
+
3
+ import torch
4
+
5
+ from ..base import Method
6
+ from . import core
7
+
8
+
9
+ class EDMMethod(Method):
10
+ """EDM (Karras et al. 2022) for conditional endpoint prediction.
11
+
12
+ Hyperparameters
13
+ ---------------
14
+ sigma_data : float
15
+ EDM σ_data; matches the empirical per-feature std of the (normalized)
16
+ training x_T. For data normalized to unit variance per feature this is
17
+ 1.0.
18
+ P_mean, P_std : float
19
+ Log-normal training-σ sampler (Table 1 of the EDM paper).
20
+ sampling : dict
21
+ Keys: n_steps, sigma_min, sigma_max, rho. Used by Heun's sampler.
22
+ """
23
+
24
+ def __init__(self, sigma_data: float, P_mean: float, P_std: float, sampling: dict,
25
+ optimizer_cfg: dict | None = None):
26
+ self.sigma_data = float(sigma_data)
27
+ self.P_mean = float(P_mean)
28
+ self.P_std = float(P_std)
29
+ self.n_steps = int(sampling["n_steps"])
30
+ self.sigma_min = float(sampling["sigma_min"])
31
+ self.sigma_max = float(sampling["sigma_max"])
32
+ self.rho = float(sampling["rho"])
33
+ # Picked up by base Method.configure_optimizer; defaults to plain Adam.
34
+ self.optimizer_cfg = optimizer_cfg or {}
35
+
36
+ @classmethod
37
+ def from_config(cls, cfg: dict) -> "EDMMethod":
38
+ return cls(
39
+ sigma_data=cfg["sigma_data"],
40
+ P_mean=cfg["P_mean"],
41
+ P_std=cfg["P_std"],
42
+ sampling=cfg["sampling"],
43
+ optimizer_cfg=cfg.get("optimizer"),
44
+ )
45
+
46
+ def loss(self, model: torch.nn.Module, batch) -> torch.Tensor:
47
+ x_0, x_T = batch
48
+ return core.edm_loss(model, x_T, x_0, self.sigma_data, self.P_mean, self.P_std)
49
+
50
+ def sample(self, model: torch.nn.Module, x_0: torch.Tensor, n_samples: int) -> torch.Tensor:
51
+ """Return Heun-sampled clouds, shape [B, n_samples, D].
52
+
53
+ x_0 may be either a single conditioning vector of shape (D,) or a
54
+ batch of shape (B, D). The output is always (B, n_samples, D).
55
+ """
56
+ device = next(model.parameters()).device
57
+ if x_0.ndim == 1:
58
+ x_0 = x_0.unsqueeze(0)
59
+ B = x_0.shape[0]
60
+ clouds = []
61
+ for i in range(B):
62
+ cloud = core.heun_sample(
63
+ model,
64
+ x_0[i],
65
+ n_samples=n_samples,
66
+ N=self.n_steps,
67
+ sigma_min=self.sigma_min,
68
+ sigma_max=self.sigma_max,
69
+ rho=self.rho,
70
+ sigma_data=self.sigma_data,
71
+ device=device,
72
+ )
73
+ clouds.append(cloud)
74
+ return torch.stack(clouds, dim=0)
code/bifurcation/metrics.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Distributional metrics in state space.
2
+
3
+ ``energy_distance`` is the validation metric of the paper's model selection (a V-statistic: ``md(X, X)`` and
4
+ ``md(Y, Y)`` average over all pairs including self-pairs; ``2 md(X, Y) - md(X, X) - md(Y, Y)``).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import numpy as np
9
+
10
+
11
+ def _mean_pairwise_dist(A: np.ndarray, B: np.ndarray) -> float:
12
+ return np.mean(np.linalg.norm(A[:, None] - B[None, :], axis=-1))
13
+
14
+
15
+ def energy_distance(X: np.ndarray, Y: np.ndarray) -> float:
16
+ md = _mean_pairwise_dist
17
+ return float(2 * md(X, Y) - md(X, X) - md(Y, Y))
18
+
19
+
20
+ def energy_distance_cached_self(X: np.ndarray, Y: np.ndarray, md_yy: float) -> float:
21
+ """``energy_distance`` with the truth-self term ``md(Y, Y)`` supplied (it does not depend on X)."""
22
+ md = _mean_pairwise_dist
23
+ return float(2 * md(X, Y) - md(X, X) - float(md_yy))
24
+
25
+
26
+ def energy_distance_u(X: np.ndarray, Y: np.ndarray) -> float:
27
+ """The unbiased U-statistic: the self terms exclude the diagonal, ``ED_U = ED_V - md(X,X)/(M-1) - md(Y,Y)/(N-1)``."""
28
+ md = _mean_pairwise_dist
29
+ mxx, myy = md(X, X), md(Y, Y)
30
+ M, N = X.shape[0], Y.shape[0]
31
+ return float(2 * md(X, Y) - mxx - myy - (mxx / (M - 1) if M > 1 else 0.0) - (myy / (N - 1) if N > 1 else 0.0))
32
+
33
+
34
+ def effective_rank(X: np.ndarray) -> float:
35
+ """Effective rank of the sample covariance, tr(S)^2 / ||S||_F^2; NaN for fewer than 2 samples."""
36
+ X = np.asarray(X, dtype=np.float64)
37
+ if X.shape[0] < 2:
38
+ return float("nan")
39
+ S = np.cov(X.T)
40
+ fro2 = float((S * S).sum())
41
+ return float(S.trace() ** 2 / fro2) if fro2 > 0 else float("nan")
code/bifurcation/models/__init__.py ADDED
File without changes
code/bifurcation/models/_common.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared helpers for backbones."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import torch
6
+
7
+
8
+ def _pack_scalar(s: torch.Tensor, B: int) -> torch.Tensor:
9
+ """Normalize a scalar / per-batch scalar to shape (B, 1)."""
10
+ if s.ndim == 0 or (s.ndim == 1 and s.numel() == 1):
11
+ return s.view(1, 1).expand(B, 1)
12
+ return s.reshape(B, 1)
13
+
14
+
15
+ def count_parameters(model: torch.nn.Module) -> int:
16
+ return sum(p.numel() for p in model.parameters() if p.requires_grad)
code/bifurcation/models/spectral.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Coarse spectral <-> field maps for the 196-dim state (numpy, no plotting).
2
+
3
+ The (kx, ky) <-> index map is the single source of truth for the 196-dim coarse spectral vector shared by the
4
+ five datasets.
5
+
6
+ Reconstruction (exact inverse of extract_group.get_coarse_vec):
7
+ vec = [Re(oh_norm[mask]), Im(oh_norm[mask])], oh_norm = omega_hat / N_sim^2,
8
+ mask = (|k| <= k_c) & Hermitian-half. Un-normalize (clouds are per-feature
9
+ standardized), place each complex coefficient and its conjugate partner on an
10
+ N_disp grid, field = N_disp^2 * ifft2(OH).real.
11
+
12
+ There is deliberately no default dataset path in this module: the normalization
13
+ statistics that the wrapper un/re-normalizes with must come from the dataset
14
+ the model is trained on (the registry injects ``data.packaged_dataset_path``).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from pathlib import Path
20
+
21
+ import numpy as np
22
+
23
+ K_C = 8
24
+ N_SIM = 1024 # the resolution get_coarse_vec normalized by (omega_hat / N_sim^2)
25
+
26
+
27
+ def mode_int_wavenumbers(k_c: int = K_C, M: int = N_SIM):
28
+ """Integer (kx, ky) for each of the 98 complex modes, in get_coarse_vec
29
+ order. axis0<->kx, axis1<->ky (matches extract_group.make_kgrids)."""
30
+ k1d = np.fft.fftfreq(M, d=1.0 / M).astype(int) # 0,1,..,-1
31
+ ky_grid, kx_grid = np.meshgrid(k1d, k1d)
32
+ k_mag = np.sqrt(kx_grid**2 + ky_grid**2)
33
+ hermitian = (kx_grid > 0) | ((kx_grid == 0) & (ky_grid > 0))
34
+ mask = (k_mag <= k_c) & hermitian
35
+ return kx_grid[mask], ky_grid[mask] # each (n_modes,)
36
+
37
+
38
+ class FieldReconstructor:
39
+ def __init__(self, packaged: str | Path,
40
+ n_disp: int = 128, k_c: int = K_C):
41
+ norm = np.load(Path(packaged) / "normalization.npz")
42
+ self.mean, self.std = norm["mean"], norm["std"]
43
+ self.n_disp = n_disp
44
+ self.kx, self.ky = mode_int_wavenumbers(k_c)
45
+ self.n_modes = len(self.kx)
46
+ # precompute grid placement indices (mod N for negative wavenumbers)
47
+ self.ix = self.kx % n_disp
48
+ self.iy = self.ky % n_disp
49
+ self.ix_c = (-self.kx) % n_disp
50
+ self.iy_c = (-self.ky) % n_disp
51
+
52
+ def _spectral_grid(self, vec_norm: np.ndarray) -> np.ndarray:
53
+ """(196,) standardized coarse vector -> full Hermitian ω̂ grid (n_disp²)."""
54
+ vec = np.asarray(vec_norm, float) * self.std + self.mean # un-normalize
55
+ coeff = vec[:self.n_modes] + 1j * vec[self.n_modes:] # oh_norm[mask]
56
+ OH = np.zeros((self.n_disp, self.n_disp), dtype=complex)
57
+ OH[self.ix, self.iy] += coeff
58
+ OH[self.ix_c, self.iy_c] += np.conj(coeff)
59
+ return OH
60
+
61
+ def reconstruct_field(self, vec_norm: np.ndarray,
62
+ field: str = "vorticity") -> np.ndarray:
63
+ """(196,) standardized coarse vector -> (N_disp, N_disp) real field.
64
+
65
+ Only the 'vorticity' branch (the observed scalar itself) exists.
66
+ """
67
+ OH = self._spectral_grid(vec_norm)
68
+ n2 = self.n_disp ** 2
69
+ if field == "vorticity":
70
+ return n2 * np.fft.ifft2(OH).real
71
+ raise ValueError(f"unknown field: {field!r} (only 'vorticity' is supported here)")
72
+
73
+ def reconstruct_many(self, vecs_norm: np.ndarray,
74
+ field: str = "vorticity") -> np.ndarray:
75
+ """(N, 196) -> (N, N_disp, N_disp)."""
76
+ return np.stack([self.reconstruct_field(v, field) for v in vecs_norm])
77
+
78
+ def extract_vec(self, field: np.ndarray) -> np.ndarray:
79
+ """(N_disp, N_disp) real field -> (2*n_modes,) standardized coarse vector.
80
+
81
+ Exact left-inverse of reconstruct_field when the field is band-limited
82
+ to |k| <= k_c on this grid (no aliasing): OH = fft2(field)/N_disp^2 undoes
83
+ the N_disp^2 scaling in reconstruct_field, the placed coefficients are
84
+ read back at the mode indices, and the per-feature standardization is
85
+ re-applied. Used to verify the FFT wrapper round-trips and as the forward
86
+ leg of the unet2 spectral<->field map."""
87
+ OH = np.fft.fft2(np.asarray(field, float)) / (self.n_disp ** 2)
88
+ coeff = OH[self.ix, self.iy]
89
+ vec = np.concatenate([coeff.real, coeff.imag])
90
+ return (vec - self.mean) / self.std
91
+
92
+ def roundtrip_maxerr(self, vecs_norm: np.ndarray) -> float:
93
+ """max |extract_vec(reconstruct_field(v)) - v| over the given vectors."""
94
+ vecs_norm = np.atleast_2d(np.asarray(vecs_norm, float))
95
+ errs = [np.abs(self.extract_vec(self.reconstruct_field(v)) - v).max()
96
+ for v in vecs_norm]
97
+ return float(max(errs))
code/bifurcation/models/unet2.py ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FFT-wrapped 2-D circular-convolution U-Net backbones: the endpoint head (3 channels) and the segment head (5 channels).
2
+
3
+ The 196-dim coarse spectral state is not a periodic spatial signal, so the network reconstructs the field on a torus
4
+ from the spectral vector, runs a 2-D circular-conv U-Net on it (spatial locality and translation equivariance) and
5
+ maps the output back; the operator is wrapped in an exact FFT/iFFT pair, so input and output are the same 196-dim
6
+ normalized spectral vector::
7
+
8
+ v_norm -> un-normalize -> Hermitian spectrum on N_g^2 -> ifft2 -> field
9
+ -> 2-D circular-conv U-Net -> fft2 -> extract |k|<=k_c -> re-normalize
10
+
11
+ Noise and all metrics stay in the 196-dim spectral space. Grid N_g = 32 is alias-free for |k| <= k_c = 8
12
+ (Nyquist 16 > 8), so the mode placement has no self-conjugate in-band mode and the wrapper is an exact identity
13
+ on R^196 (round-trip at machine precision in float64, float32-eps in float32). The (kx, ky) <-> index map comes
14
+ from ``bifurcation.models.spectral``.
15
+
16
+ - ``Conditional2D``: 3 field channels [x, x_0, sigma_broadcast]; EDM and Direct SI.
17
+ - ``LocalDrift2D``: 5 field channels [x_tau, x_tk, tau, t_k, t_k1]; Cascaded SI.
18
+
19
+ The scalar conditioning is injected as a broadcast channel.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import numpy as np
25
+ import torch
26
+ import torch.nn as nn
27
+
28
+ from ._common import _pack_scalar
29
+ from .spectral import FieldReconstructor, K_C
30
+
31
+
32
+ class PeriodicConv2d(nn.Module):
33
+ """Conv2d with cat-based circular padding on both spatial dims.
34
+
35
+ Cat-based (not ``padding_mode='circular'``) to stay vmap-safe under PyTorch 2.1. The manual wrap makes
36
+ the convolution genuinely periodic on the torus."""
37
+
38
+ def __init__(self, in_c: int, out_c: int, kernel_size: int, bias: bool = True):
39
+ super().__init__()
40
+ assert kernel_size % 2 == 1, "Use odd kernel_size for symmetric circular padding"
41
+ self.pad = kernel_size // 2
42
+ self.conv = nn.Conv2d(in_c, out_c, kernel_size, padding=0, bias=bias)
43
+
44
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
45
+ p = self.pad
46
+ x = torch.cat([x[..., -p:, :], x, x[..., :p, :]], dim=-2) # wrap rows
47
+ x = torch.cat([x[..., :, -p:], x, x[..., :, :p]], dim=-1) # wrap cols
48
+ return self.conv(x)
49
+
50
+
51
+ def _upsample2(x: torch.Tensor) -> torch.Tensor:
52
+ """Nearest-neighbour 2x upsample on both spatial dims."""
53
+ return x.repeat_interleave(2, dim=-2).repeat_interleave(2, dim=-1)
54
+
55
+
56
+ class _SpectralFieldWrapper(nn.Module):
57
+ """Fixed, invertible reparameterization: normalized spectral vec <-> field.
58
+
59
+ The spectral->field map (un-normalize -> Hermitian placement -> ifft2 -> real)
60
+ and its inverse are both **exactly affine** in their input (the FFT and mode
61
+ placement are linear; un/re-normalization is affine). We therefore precompute
62
+ the affine maps as dense matrices once at construction — by passing the
63
+ standard basis through the verified-exact numpy ``FieldReconstructor`` (single
64
+ source of truth for the (kx,ky)<->index map) — and apply them as plain
65
+ matmuls at runtime. Matmul is **vmap-safe** (the SI losses vmap per-sample)
66
+ and differentiable; the maps are registered buffers so they move with
67
+ ``.to(device)`` / ``.double()``. Round-trip is exact to machine precision."""
68
+
69
+ def __init__(self, packaged: str, n_grid: int = 32,
70
+ k_c: int = K_C, component: int | None = None):
71
+ super().__init__()
72
+ rec = FieldReconstructor(packaged=packaged, n_disp=n_grid, k_c=k_c)
73
+ ng = int(n_grid)
74
+ D = 2 * rec.n_modes
75
+ if component is not None:
76
+ # multi-component state (e.g. (u, v)): this wrapper owns one component, so it takes
77
+ # that component's own slice of the package's frozen normalization. component=None is the
78
+ # single-field behaviour and is untouched.
79
+ sl = slice(int(component) * D, (int(component) + 1) * D)
80
+ if len(rec.mean) < sl.stop:
81
+ raise ValueError(f"normalization has {len(rec.mean)} entries, too few for component {component}")
82
+ rec.mean, rec.std = rec.mean[sl], rec.std[sl]
83
+ # inv (affine, exact): field_flat = A_inv @ v_norm + c_inv
84
+ c_inv = rec.reconstruct_field(np.zeros(D)).reshape(-1)
85
+ A_inv = np.empty((ng * ng, D))
86
+ e = np.zeros(D)
87
+ for j in range(D):
88
+ e[j] = 1.0
89
+ A_inv[:, j] = rec.reconstruct_field(e).reshape(-1) - c_inv
90
+ e[j] = 0.0
91
+ # fwd (affine, exact): v_norm = A_fwd @ field_flat + c_fwd
92
+ c_fwd = rec.extract_vec(np.zeros((ng, ng)))
93
+ A_fwd = np.empty((D, ng * ng))
94
+ fz = np.zeros(ng * ng)
95
+ for kk in range(ng * ng):
96
+ fz[kk] = 1.0
97
+ A_fwd[:, kk] = rec.extract_vec(fz.reshape(ng, ng)) - c_fwd
98
+ fz[kk] = 0.0
99
+ # exact-identity sanity check (catches any self-conjugate-mode projection)
100
+ zc = np.random.default_rng(0).standard_normal(D)
101
+ rt = np.abs(A_fwd @ (A_inv @ zc + c_inv) + c_fwd - zc).max()
102
+ assert rt < 1e-9, f"wrapper is not an identity on R^{D} (round-trip {rt:.2e})"
103
+ self.n_grid, self.D, self.n_modes = ng, D, rec.n_modes
104
+ # store at full (float64) precision; cast to the working dtype per call so
105
+ # the round-trip is exact in float64 and float32-eps in float32 training.
106
+ self.register_buffer("A_inv", torch.from_numpy(A_inv))
107
+ self.register_buffer("c_inv", torch.from_numpy(c_inv))
108
+ self.register_buffer("A_fwd", torch.from_numpy(A_fwd))
109
+ self.register_buffer("c_fwd", torch.from_numpy(c_fwd))
110
+
111
+ def inv(self, v_norm: torch.Tensor) -> torch.Tensor:
112
+ """(B, D) normalized spectral -> (B, N_g, N_g) real vorticity field."""
113
+ field_flat = v_norm @ self.A_inv.t().to(v_norm) + self.c_inv.to(v_norm)
114
+ return field_flat.view(v_norm.shape[0], self.n_grid, self.n_grid)
115
+
116
+ def fwd(self, field: torch.Tensor) -> torch.Tensor:
117
+ """(B, N_g, N_g) real field -> (B, D) normalized spectral vector.
118
+
119
+ Exact left-inverse of ``inv`` for band-limited fields."""
120
+ flat = field.reshape(field.shape[0], -1)
121
+ return flat @ self.A_fwd.t().to(flat) + self.c_fwd.to(flat)
122
+
123
+
124
+ class _PeriodicUNet2Core(nn.Module):
125
+ """N_g -> N_g/2 -> N_g/4 (bottleneck) -> N_g/2 -> N_g, periodic padding, skip connections."""
126
+
127
+ def __init__(self, in_channels: int, base_channels: int, dropout: float = 0.0,
128
+ out_channels: int = 1):
129
+ super().__init__()
130
+ c = base_channels
131
+
132
+ # channel dropout (Dropout2d) after each block's SiLU, inserted only when dropout > 0
133
+ def _act():
134
+ return [nn.SiLU()] + ([nn.Dropout2d(float(dropout))] if dropout > 0 else [])
135
+
136
+ self.enc0 = nn.Sequential(PeriodicConv2d(in_channels, c, 3), *_act())
137
+ self.enc1 = nn.Sequential(PeriodicConv2d(c, c * 2, 3), *_act())
138
+ self.enc2 = nn.Sequential(
139
+ PeriodicConv2d(c * 2, c * 4, 3), *_act(),
140
+ PeriodicConv2d(c * 4, c * 4, 3), *_act(),
141
+ )
142
+ self.pool = nn.AvgPool2d(2)
143
+ self.dec1 = nn.Sequential(PeriodicConv2d(c * 4 + c * 2, c * 2, 3), *_act())
144
+ self.dec0 = nn.Sequential(PeriodicConv2d(c * 2 + c, c, 3), *_act())
145
+ self.out = nn.Conv2d(c, int(out_channels), kernel_size=1)
146
+
147
+ def _forward_core(self, x: torch.Tensor) -> torch.Tensor:
148
+ s0 = self.enc0(x)
149
+ s1 = self.enc1(self.pool(s0))
150
+ s2 = self.enc2(self.pool(s1))
151
+ u1 = self.dec1(torch.cat([_upsample2(s2), s1], dim=1))
152
+ u0 = self.dec0(torch.cat([_upsample2(u1), s0], dim=1))
153
+ y = self.out(u0)
154
+ return y.squeeze(1) if y.shape[1] == 1 else y # (B, N_g, N_g) or (B, C, N_g, N_g)
155
+
156
+ def count_parameters(self) -> int:
157
+ return sum(p.numel() for p in self.parameters() if p.requires_grad)
158
+
159
+
160
+ def _broadcast_scalar(s: torch.Tensor, B: int, ng: int) -> torch.Tensor:
161
+ """scalar / per-batch scalar -> (B, 1, N_g, N_g) constant field channel."""
162
+ return _pack_scalar(s, B).view(B, 1, 1, 1).expand(B, 1, ng, ng)
163
+
164
+
165
+ def _require_packaged(cfg: dict) -> str:
166
+ """The dataset whose normalization.npz the FFT wrapper un/re-normalizes with. Injected by
167
+ ``bifurcation.registry.build_model`` from the data config; deliberately no default."""
168
+ if "packaged_dataset_path" not in cfg:
169
+ raise KeyError("model config lacks 'packaged_dataset_path' — build the model through "
170
+ "bifurcation.registry.build_model(model_cfg, method_cfg, data_cfg)")
171
+ return str(cfg["packaged_dataset_path"])
172
+
173
+
174
+ class Conditional2D(_PeriodicUNet2Core):
175
+ """3-channel-input FFT-wrapped 2-D U-Net for endpoint conditioning.
176
+
177
+ forward(x, sigma_embedded, x_0) -> (B, D). EDM passes sigma_embedded = c_noise(sigma), Direct SI
178
+ passes tau. Field channels: [x_field, x0_field, sigma_broadcast]."""
179
+
180
+ def __init__(self, D: int = 196, base_channels: int = 64, n_grid: int = 32,
181
+ k_c: int = K_C, packaged: str | None = None,
182
+ dropout: float = 0.0):
183
+ super().__init__(in_channels=3, base_channels=base_channels, dropout=dropout)
184
+ self.D = D
185
+ if packaged is None:
186
+ raise ValueError(
187
+ "unet2 needs the training dataset's normalization: pass packaged (the "
188
+ "registry injects data.packaged_dataset_path)")
189
+ self.wrap = _SpectralFieldWrapper(packaged, n_grid, k_c)
190
+ assert self.wrap.D == D, f"wrapper D {self.wrap.D} != model D {D}"
191
+
192
+ def forward(self, x: torch.Tensor, sigma_embedded: torch.Tensor,
193
+ x_0: torch.Tensor) -> torch.Tensor:
194
+ B, ng = x.shape[0], self.wrap.n_grid
195
+ x_f = self.wrap.inv(x).unsqueeze(1)
196
+ x0_f = self.wrap.inv(x_0).unsqueeze(1)
197
+ sig_bc = _broadcast_scalar(sigma_embedded, B, ng)
198
+ field = self._forward_core(torch.cat([x_f, x0_f, sig_bc], dim=1))
199
+ return self.wrap.fwd(field)
200
+
201
+ @classmethod
202
+ def from_config(cls, cfg: dict) -> "Conditional2D":
203
+ return cls(D=int(cfg.get("D", 196)),
204
+ base_channels=int(cfg.get("base_channels", 64)),
205
+ n_grid=int(cfg.get("n_grid", 32)),
206
+ k_c=int(cfg.get("k_c", K_C)),
207
+ packaged=_require_packaged(cfg),
208
+ dropout=float(cfg.get("dropout", 0.0)))
209
+
210
+
211
+ class LocalDrift2D(_PeriodicUNet2Core):
212
+ """5-channel-input FFT-wrapped 2-D U-Net for the segment drift of Cascaded SI.
213
+
214
+ forward(x_tau, tau, x_tk, t_k, t_k1) -> (B, D). Field channels: [x_tau_field, x_tk_field, tau_bc,
215
+ t_k_bc, t_k1_bc]; the segment is identified by the two physical-time scalars broadcast spatially."""
216
+
217
+ def __init__(self, D: int = 196, base_channels: int = 64, n_grid: int = 32,
218
+ k_c: int = K_C, packaged: str | None = None,
219
+ dropout: float = 0.0):
220
+ super().__init__(in_channels=5, base_channels=base_channels, dropout=dropout)
221
+ self.D = D
222
+ if packaged is None:
223
+ raise ValueError(
224
+ "unet2 needs the training dataset's normalization: pass packaged (the "
225
+ "registry injects data.packaged_dataset_path)")
226
+ self.wrap = _SpectralFieldWrapper(packaged, n_grid, k_c)
227
+ assert self.wrap.D == D, f"wrapper D {self.wrap.D} != model D {D}"
228
+
229
+ def forward(self, x_tau: torch.Tensor, tau: torch.Tensor, x_tk: torch.Tensor,
230
+ t_k: torch.Tensor, t_k1: torch.Tensor) -> torch.Tensor:
231
+ B, ng = x_tau.shape[0], self.wrap.n_grid
232
+ xtau_f = self.wrap.inv(x_tau).unsqueeze(1)
233
+ xtk_f = self.wrap.inv(x_tk).unsqueeze(1)
234
+ tau_bc = _broadcast_scalar(tau, B, ng)
235
+ tk_bc = t_k.view(B, 1, 1, 1).expand(B, 1, ng, ng)
236
+ tk1_bc = t_k1.view(B, 1, 1, 1).expand(B, 1, ng, ng)
237
+ field = self._forward_core(
238
+ torch.cat([xtau_f, xtk_f, tau_bc, tk_bc, tk1_bc], dim=1))
239
+ return self.wrap.fwd(field)
240
+
241
+ @classmethod
242
+ def from_config(cls, cfg: dict) -> "LocalDrift2D":
243
+ return cls(D=int(cfg.get("D", 196)),
244
+ base_channels=int(cfg.get("base_channels", 64)),
245
+ n_grid=int(cfg.get("n_grid", 32)),
246
+ k_c=int(cfg.get("k_c", K_C)),
247
+ packaged=_require_packaged(cfg),
248
+ dropout=float(cfg.get("dropout", 0.0)))
code/bifurcation/models/unet2_2comp.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Two-component U-Net for nonlinear Klein-Gordon forecasts.
2
+
3
+ The state contains 196 coarse displacement coordinates and 196 scaled velocity
4
+ coordinates. The segment head receives both components of the noisy state and
5
+ conditioning state, together with interpolation time and segment boundaries.
6
+ It emits both components through the shared periodic U-Net and frozen spectral
7
+ normalization wrappers. Internal parameter names are retained for checkpoint loading."""
8
+ from __future__ import annotations
9
+
10
+ import torch
11
+
12
+ from ..registry import register_model
13
+ from .unet2 import K_C, _broadcast_scalar, _PeriodicUNet2Core, _require_packaged, _SpectralFieldWrapper
14
+
15
+
16
+ class _TwoComponent(_PeriodicUNet2Core):
17
+ """Shared plumbing: one frozen wrapper applied to each half of the 2*D_c state vector."""
18
+
19
+ def __init__(self, D: int = 392, base_channels: int = 64, n_grid: int = 32, k_c: int = K_C,
20
+ packaged: str | None = None, dropout: float = 0.0, in_channels: int = 7):
21
+ super().__init__(in_channels=in_channels, base_channels=base_channels, dropout=dropout,
22
+ out_channels=2)
23
+ self.D = int(D)
24
+ if packaged is None:
25
+ raise ValueError("unet2_2comp needs the training dataset's normalization: pass packaged")
26
+ # one wrapper per component, each with that component's slice of the frozen normalization
27
+ self.wrap = _SpectralFieldWrapper(packaged, n_grid, k_c, component=0)
28
+ self.wrap_im = _SpectralFieldWrapper(packaged, n_grid, k_c, component=1)
29
+ if self.wrap.D * 2 != self.D:
30
+ raise ValueError(f"two-component state needs D = 2 * {self.wrap.D}, got {self.D}")
31
+ self.Dc = self.wrap.D
32
+
33
+ def _fields(self, v: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
34
+ """(B, 2*Dc) -> two (B, 1, Ng, Ng) field channels."""
35
+ return (self.wrap.inv(v[:, :self.Dc]).unsqueeze(1),
36
+ self.wrap_im.inv(v[:, self.Dc:]).unsqueeze(1))
37
+
38
+ def _vec(self, field2: torch.Tensor) -> torch.Tensor:
39
+ """(B, 2, Ng, Ng) -> (B, 2*Dc)."""
40
+ return torch.cat([self.wrap.fwd(field2[:, 0]), self.wrap_im.fwd(field2[:, 1])], dim=1)
41
+
42
+
43
+ class LocalDrift2Comp(_TwoComponent):
44
+ """forward(x_tau, tau, x_tk, t_k, t_k1) -> (B, D); channels [Re/Im x_tau, Re/Im x_tk, tau, t_k, t_k1]."""
45
+
46
+ def forward(self, x_tau: torch.Tensor, tau: torch.Tensor, x_tk: torch.Tensor,
47
+ t_k: torch.Tensor, t_k1: torch.Tensor) -> torch.Tensor:
48
+ B, ng = x_tau.shape[0], self.wrap.n_grid
49
+ a_re, a_im = self._fields(x_tau)
50
+ b_re, b_im = self._fields(x_tk)
51
+ tau_bc = _broadcast_scalar(tau, B, ng)
52
+ tk_bc = t_k.view(B, 1, 1, 1).expand(B, 1, ng, ng)
53
+ tk1_bc = t_k1.view(B, 1, 1, 1).expand(B, 1, ng, ng)
54
+ out = self._forward_core(torch.cat([a_re, a_im, b_re, b_im, tau_bc, tk_bc, tk1_bc], dim=1))
55
+ return self._vec(out)
56
+
57
+ @classmethod
58
+ def from_config(cls, cfg: dict) -> "LocalDrift2Comp":
59
+ return cls(D=int(cfg.get("D", 392)), base_channels=int(cfg.get("base_channels", 64)),
60
+ n_grid=int(cfg.get("n_grid", 32)), k_c=int(cfg.get("k_c", K_C)),
61
+ packaged=_require_packaged(cfg), dropout=float(cfg.get("dropout", 0.0)))
62
+
63
+
64
+ class Conditional2Comp(LocalDrift2Comp):
65
+ """Endpoint head with the segment head's interface (t_k = t_start, t_k1 = t_end), as unet2_5ch."""
66
+
67
+ def __init__(self, *args, t_start: float = 0.0, t_end: float = 1.0, **kwargs):
68
+ super().__init__(*args, **kwargs)
69
+ self.t_start, self.t_end = float(t_start), float(t_end)
70
+
71
+ def forward(self, x: torch.Tensor, sigma_embedded: torch.Tensor, x_0: torch.Tensor) -> torch.Tensor: # type: ignore[override]
72
+ B = x.shape[0]
73
+ tk = torch.full((B,), self.t_start, dtype=x.dtype, device=x.device)
74
+ tk1 = torch.full((B,), self.t_end, dtype=x.dtype, device=x.device)
75
+ return super().forward(x, sigma_embedded, x_0, tk, tk1)
76
+
77
+ @classmethod
78
+ def from_config(cls, cfg: dict) -> "Conditional2Comp":
79
+ return cls(D=int(cfg.get("D", 392)), base_channels=int(cfg.get("base_channels", 64)),
80
+ n_grid=int(cfg.get("n_grid", 32)), k_c=int(cfg.get("k_c", K_C)),
81
+ packaged=_require_packaged(cfg), dropout=float(cfg.get("dropout", 0.0)),
82
+ t_start=float(cfg.get("t_start", 0.0)), t_end=float(cfg.get("t_end", 1.0)))
83
+
84
+
85
+ register_model("unet2_2comp", endpoint_cls=Conditional2Comp, segment_cls=LocalDrift2Comp,
86
+ needs_dataset_norm=True,
87
+ description="two-component (Re,Im) coarse complex state; unet2 core, per-component FFT wrapper")
code/bifurcation/models/unet2_5ch.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``unet2_5ch``: the backbone of the Euler and SQG models.
2
+
3
+ Channel parity between the endpoint and the local arms: the endpoint head IS the local head
4
+ (``LocalDrift2D``, 5 input channels [x_τ, x_0, τ, t_start, t_end]) with the segment's start and end
5
+ time fixed to the forecast horizon (t_start = 0, t_end = T). Both heads are therefore the identical
6
+ module class with the identical parameter count (1,515,265 at base_channels 64); the two extra
7
+ planes carry no information for an endpoint arm (constants) — the fix removes an *interface*
8
+ asymmetry, not a capacity difference.
9
+
10
+ ``t_end`` must equal the forecast horizon: ``bifurcation.registry.build_model`` checks the model
11
+ configuration's ``t_start`` / ``t_end`` against ``method.sampling.t_values`` and raises on disagreement.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import torch
16
+
17
+ from ..registry import register_model
18
+ from .unet2 import K_C, LocalDrift2D, _require_packaged
19
+
20
+
21
+ class Conditional2D5ch(LocalDrift2D):
22
+ """Endpoint head with the segment head's exact interface: t_k = t_start, t_{k+1} = t_end.
23
+
24
+ forward(x, sigma_embedded, x_0) -> (B, D): global SI passes sigma_embedded = tau."""
25
+
26
+ def __init__(self, *args, t_start: float = 0.0, t_end: float = 1.0, **kwargs):
27
+ super().__init__(*args, **kwargs)
28
+ self.t_start, self.t_end = float(t_start), float(t_end)
29
+
30
+ def forward(self, x: torch.Tensor, sigma_embedded: torch.Tensor, x_0: torch.Tensor) -> torch.Tensor: # type: ignore[override]
31
+ B = x.shape[0]
32
+ tk = torch.full((B,), self.t_start, dtype=x.dtype, device=x.device)
33
+ tk1 = torch.full((B,), self.t_end, dtype=x.dtype, device=x.device)
34
+ return super().forward(x, sigma_embedded, x_0, tk, tk1)
35
+
36
+ @classmethod
37
+ def from_config(cls, cfg: dict) -> "Conditional2D5ch":
38
+ return cls(D=int(cfg.get("D", 196)),
39
+ base_channels=int(cfg.get("base_channels", 64)),
40
+ n_grid=int(cfg.get("n_grid", 32)),
41
+ k_c=int(cfg.get("k_c", K_C)),
42
+ packaged=_require_packaged(cfg),
43
+ dropout=float(cfg.get("dropout", 0.0)),
44
+ t_start=float(cfg.get("t_start", 0.0)),
45
+ t_end=float(cfg.get("t_end", 1.0)))
46
+
47
+
48
+ register_model("unet2_5ch", Conditional2D5ch, LocalDrift2D, needs_dataset_norm=True,
49
+ description="unet2 with channel parity: both heads are LocalDrift2D (5 ch); the endpoint head "
50
+ "fixes (t_start, t_end) = (0, T). The backbone of the Euler and SQG models.")
code/bifurcation/models/unet2_5ch_coord.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``unet2_5ch_coord``: the backbone for datasets without translation symmetry (the passive scalar).
2
+
3
+ The passive scalar's fixed cellular flow is not translation invariant, so the equivariant ``unet2`` cannot represent
4
+ its conditional-mean map. This variant is ``unet2_5ch`` built on the ``unet2_coord`` core, which adds two coordinate
5
+ input planes: both heads are ``LocalDrift2DCoord`` with 7 input channels [x_tau, x_tk, tau, t_start, t_end, X, Y],
6
+ and the endpoint head fixes (t_start, t_end) = (0, T) as ``Conditional2D5ch`` does. The parameter count is that of
7
+ unet2_5ch plus the two extra 3x3 input filters (1,152 at base_channels 64), identical for both heads, so EDM, Direct
8
+ SI and Cascaded SI share one module. ``t_end`` must equal the forecast horizon (checked by
9
+ ``bifurcation.registry.build_model``).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import torch
14
+
15
+ from ..registry import register_model
16
+ from .spectral import K_C
17
+ from .unet2 import _require_packaged
18
+ from .unet2_coord import LocalDrift2DCoord
19
+
20
+
21
+ class Conditional2D5chCoord(LocalDrift2DCoord):
22
+ """Endpoint head with the segment head's exact interface: t_k = t_start, t_{k+1} = t_end."""
23
+
24
+ def __init__(self, *args, t_start: float = 0.0, t_end: float = 1.0, **kwargs):
25
+ super().__init__(*args, **kwargs)
26
+ self.t_start, self.t_end = float(t_start), float(t_end)
27
+
28
+ def forward(self, x: torch.Tensor, sigma_embedded: torch.Tensor, x_0: torch.Tensor) -> torch.Tensor: # type: ignore[override]
29
+ B = x.shape[0]
30
+ tk = torch.full((B,), self.t_start, dtype=x.dtype, device=x.device)
31
+ tk1 = torch.full((B,), self.t_end, dtype=x.dtype, device=x.device)
32
+ return super().forward(x, sigma_embedded, x_0, tk, tk1)
33
+
34
+ @classmethod
35
+ def from_config(cls, cfg: dict) -> "Conditional2D5chCoord":
36
+ return cls(D=int(cfg.get("D", 196)), base_channels=int(cfg.get("base_channels", 64)),
37
+ n_grid=int(cfg.get("n_grid", 32)), k_c=int(cfg.get("k_c", K_C)),
38
+ packaged=_require_packaged(cfg), dropout=float(cfg.get("dropout", 0.0)),
39
+ t_start=float(cfg.get("t_start", 0.0)), t_end=float(cfg.get("t_end", 1.0)))
40
+
41
+
42
+ register_model("unet2_5ch_coord", Conditional2D5chCoord, LocalDrift2DCoord, needs_dataset_norm=True,
43
+ description="unet2_coord with channel parity: both heads are LocalDrift2DCoord (7 ch, not translation-"
44
+ "equivariant); the endpoint head fixes (t_start, t_end) = (0, T). The backbone for "
45
+ "datasets without translation symmetry (passive scalar).")
code/bifurcation/models/unet2_5ch_coord_aug.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Augmented-state variant of unet2_5ch_coord: the backbone of the heterogeneous-waves models.
2
+
3
+ State x = (u, v_hat) in R^392: two independent 196-D spectral fields (coarse displacement, finite-
4
+ difference coarse velocity), each with its OWN _SpectralFieldWrapper / normalization (velocity has a
5
+ different physical scale than displacement). Everything else -- the periodic-conv trunk, coordinate
6
+ planes, channel structure -- is IDENTICAL to unet2_5ch_coord; only the input/output channel counts
7
+ differ (9 in / 2 out instead of 7 in / 1 out) to carry both fields through together.
8
+
9
+ Standard single-path dataset-norm injection (needs_dataset_norm=True, like every other backbone): the
10
+ u/v wrappers are built from TWO SUBDIRECTORIES of the one injected ``packaged_dataset_path``,
11
+
12
+ <packaged_dataset_path>/u_norm/normalization.npz (196-D, displacement)
13
+ <packaged_dataset_path>/v_norm/normalization.npz (196-D, velocity)
14
+
15
+ so the run configuration names one dataset path, as for every other model.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import torch
20
+ import torch.nn as nn
21
+
22
+ from .spectral import K_C
23
+ from .unet2 import _PeriodicUNet2Core, _SpectralFieldWrapper, _broadcast_scalar, _upsample2, _require_packaged
24
+ from .unet2_coord import _coord_planes
25
+
26
+
27
+ class _DualHeadCore(_PeriodicUNet2Core):
28
+ """Same trunk as _PeriodicUNet2Core; final 1x1 conv widened from 1 to 2 output channels (predict
29
+ u_next and v_next together), and _forward_core no longer squeezes the channel axis away."""
30
+
31
+ def __init__(self, in_channels: int, base_channels: int, dropout: float = 0.0):
32
+ super().__init__(in_channels=in_channels, base_channels=base_channels, dropout=dropout)
33
+ self.out = nn.Conv2d(base_channels, 2, kernel_size=1)
34
+
35
+ def _forward_core(self, x: torch.Tensor) -> torch.Tensor:
36
+ s0 = self.enc0(x)
37
+ s1 = self.enc1(self.pool(s0))
38
+ s2 = self.enc2(self.pool(s1))
39
+ u1 = self.dec1(torch.cat([_upsample2(s2), s1], dim=1))
40
+ u0 = self.dec0(torch.cat([_upsample2(u1), s0], dim=1))
41
+ return self.out(u0) # (B, 2, ng, ng) -- no squeeze
42
+
43
+
44
+ class LocalDrift2DCoordAug(nn.Module):
45
+ """forward(x_tau, tau, x_tk, t_k, t_k1) -> (B, 392); x_tau/x_tk are (B, 392) = concat(u, v)."""
46
+
47
+ def __init__(self, D: int = 196, base_channels: int = 64, n_grid: int = 32, k_c: int = K_C,
48
+ packaged: str | None = None, dropout: float = 0.0):
49
+ super().__init__()
50
+ if packaged is None:
51
+ raise ValueError("unet2_5ch_coord_aug needs the training dataset's normalization: pass packaged")
52
+ self.core = _DualHeadCore(in_channels=9, base_channels=base_channels, dropout=dropout)
53
+ self.wrap_u = _SpectralFieldWrapper(str(packaged) + "/u_norm", n_grid, k_c)
54
+ self.wrap_v = _SpectralFieldWrapper(str(packaged) + "/v_norm", n_grid, k_c)
55
+ assert self.wrap_u.D == D and self.wrap_v.D == D
56
+ self.D = D
57
+
58
+ def forward(self, x_tau, tau, x_tk, t_k, t_k1):
59
+ B, ng, D = x_tau.shape[0], self.wrap_u.n_grid, self.D
60
+ u_tau, v_tau = x_tau[:, :D], x_tau[:, D:]
61
+ u_tk, v_tk = x_tk[:, :D], x_tk[:, D:]
62
+ xt_u = self.wrap_u.inv(u_tau).unsqueeze(1); xt_v = self.wrap_v.inv(v_tau).unsqueeze(1)
63
+ xk_u = self.wrap_u.inv(u_tk).unsqueeze(1); xk_v = self.wrap_v.inv(v_tk).unsqueeze(1)
64
+ inp = torch.cat([xt_u, xt_v, xk_u, xk_v,
65
+ _broadcast_scalar(tau, B, ng).to(xt_u),
66
+ t_k.view(B, 1, 1, 1).expand(B, 1, ng, ng).to(xt_u),
67
+ t_k1.view(B, 1, 1, 1).expand(B, 1, ng, ng).to(xt_u),
68
+ _coord_planes(B, ng, xt_u)], dim=1)
69
+ field = self.core._forward_core(inp) # (B, 2, ng, ng)
70
+ u_next = self.wrap_u.fwd(field[:, 0]); v_next = self.wrap_v.fwd(field[:, 1])
71
+ return torch.cat([u_next, v_next], dim=1)
72
+
73
+ def count_parameters(self):
74
+ return sum(p.numel() for p in self.parameters() if p.requires_grad)
75
+
76
+ @classmethod
77
+ def from_config(cls, cfg: dict) -> "LocalDrift2DCoordAug":
78
+ return cls(D=int(cfg.get("D", 196)), base_channels=int(cfg.get("base_channels", 64)),
79
+ n_grid=int(cfg.get("n_grid", 32)), k_c=int(cfg.get("k_c", K_C)),
80
+ packaged=_require_packaged(cfg), dropout=float(cfg.get("dropout", 0.0)))
81
+
82
+
83
+ class Conditional2D5chCoordAug(LocalDrift2DCoordAug):
84
+ """Endpoint head with the segment head's exact interface (mirrors Conditional2D5chCoord):
85
+ forward(x, sigma_embedded, x_0) fixes (t_start, t_end) = (0, T)."""
86
+
87
+ def __init__(self, *args, t_start: float = 0.0, t_end: float = 1.0, **kwargs):
88
+ super().__init__(*args, **kwargs)
89
+ self.t_start, self.t_end = float(t_start), float(t_end)
90
+
91
+ def forward(self, x, sigma_embedded, x_0):
92
+ B = x.shape[0]
93
+ tk = torch.full((B,), self.t_start, dtype=x.dtype, device=x.device)
94
+ tk1 = torch.full((B,), self.t_end, dtype=x.dtype, device=x.device)
95
+ return super().forward(x, sigma_embedded, x_0, tk, tk1)
96
+
97
+ @classmethod
98
+ def from_config(cls, cfg: dict) -> "Conditional2D5chCoordAug":
99
+ return cls(D=int(cfg.get("D", 196)), base_channels=int(cfg.get("base_channels", 64)),
100
+ n_grid=int(cfg.get("n_grid", 32)), k_c=int(cfg.get("k_c", K_C)),
101
+ packaged=_require_packaged(cfg), dropout=float(cfg.get("dropout", 0.0)),
102
+ t_start=float(cfg.get("t_start", 0.0)), t_end=float(cfg.get("t_end", 1.0)))
103
+
104
+
105
+ from ..registry import register_model # noqa: E402
106
+
107
+ register_model("unet2_5ch_coord_aug", Conditional2D5chCoordAug, LocalDrift2DCoordAug, needs_dataset_norm=True,
108
+ description="Augmented-state (u, v_hat) backbone of the heterogeneous-waves models: two "
109
+ "_SpectralFieldWrapper instances read from <packaged>/u_norm and <packaged>/v_norm; "
110
+ "otherwise identical trunk and parameter count to unet2_5ch_coord.")
code/bifurcation/models/unet2_coord.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """unet2 with coordinate channels — a NON-equivariant variant of the FFT-wrapped 2-D U-Net.
2
+
3
+ The passive scalar's fixed cellular flow
4
+ psi_K is not translation-invariant, so its conditional-mean map x_0 -> M_T x_0 is not
5
+ translation-equivariant; ``unet2`` (exactly equivariant to shifts by its pooling stride)
6
+ provably cannot represent it. This variant keeps unet2's field reconstruction, circular
7
+ convolutions and heads but appends two constant coordinate planes (x, y in [-1, 1)) to the
8
+ input channels, which breaks translation equivariance and lets position-dependent filters
9
+ form.
10
+
11
+ - ``Conditional2DCoord`` forward(x, sigma_embedded, x_0) -> (B, D) (5 input channels)
12
+ - ``LocalDrift2DCoord`` forward(x_tau, tau, x_tk, t_k, t_k1) -> (B, D) (7 input channels)
13
+
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import torch
19
+
20
+ from .spectral import K_C
21
+ from .unet2 import _PeriodicUNet2Core, _SpectralFieldWrapper, _broadcast_scalar, _require_packaged
22
+
23
+
24
+ def _coord_planes(B: int, ng: int, like: torch.Tensor) -> torch.Tensor:
25
+ """(B, 2, ng, ng) constant planes with x and y coordinates in [-1, 1)."""
26
+ c = torch.arange(ng, device=like.device, dtype=like.dtype) * (2.0 / ng) - 1.0
27
+ X = c.view(1, 1, ng, 1).expand(B, 1, ng, ng)
28
+ Y = c.view(1, 1, 1, ng).expand(B, 1, ng, ng)
29
+ return torch.cat([X, Y], dim=1)
30
+
31
+
32
+ class Conditional2DCoord(_PeriodicUNet2Core):
33
+ def __init__(self, D: int = 196, base_channels: int = 64, n_grid: int = 32,
34
+ k_c: int = K_C, packaged: str | None = None, dropout: float = 0.0):
35
+ super().__init__(in_channels=5, base_channels=base_channels, dropout=dropout)
36
+ self.D = D
37
+ if packaged is None:
38
+ raise ValueError("unet2_coord needs the training dataset's normalization: pass packaged")
39
+ self.wrap = _SpectralFieldWrapper(packaged, n_grid, k_c)
40
+ assert self.wrap.D == D
41
+
42
+ def forward(self, x: torch.Tensor, sigma_embedded: torch.Tensor, x_0: torch.Tensor) -> torch.Tensor:
43
+ B, ng = x.shape[0], self.wrap.n_grid
44
+ x_f = self.wrap.inv(x).unsqueeze(1)
45
+ field = self._forward_core(torch.cat([x_f, self.wrap.inv(x_0).unsqueeze(1),
46
+ _broadcast_scalar(sigma_embedded, B, ng).to(x_f),
47
+ _coord_planes(B, ng, x_f)], dim=1))
48
+ return self.wrap.fwd(field)
49
+
50
+ @classmethod
51
+ def from_config(cls, cfg: dict) -> "Conditional2DCoord":
52
+ return cls(D=int(cfg.get("D", 196)), base_channels=int(cfg.get("base_channels", 64)),
53
+ n_grid=int(cfg.get("n_grid", 32)), k_c=int(cfg.get("k_c", K_C)),
54
+ packaged=_require_packaged(cfg), dropout=float(cfg.get("dropout", 0.0)))
55
+
56
+
57
+ class LocalDrift2DCoord(_PeriodicUNet2Core):
58
+ def __init__(self, D: int = 196, base_channels: int = 64, n_grid: int = 32,
59
+ k_c: int = K_C, packaged: str | None = None, dropout: float = 0.0):
60
+ super().__init__(in_channels=7, base_channels=base_channels, dropout=dropout)
61
+ self.D = D
62
+ if packaged is None:
63
+ raise ValueError("unet2_coord needs the training dataset's normalization: pass packaged")
64
+ self.wrap = _SpectralFieldWrapper(packaged, n_grid, k_c)
65
+ assert self.wrap.D == D
66
+
67
+ def forward(self, x_tau: torch.Tensor, tau: torch.Tensor, x_tk: torch.Tensor,
68
+ t_k: torch.Tensor, t_k1: torch.Tensor) -> torch.Tensor:
69
+ B, ng = x_tau.shape[0], self.wrap.n_grid
70
+ xt = self.wrap.inv(x_tau).unsqueeze(1)
71
+ field = self._forward_core(torch.cat([
72
+ xt, self.wrap.inv(x_tk).unsqueeze(1), _broadcast_scalar(tau, B, ng).to(xt),
73
+ t_k.view(B, 1, 1, 1).expand(B, 1, ng, ng).to(xt), t_k1.view(B, 1, 1, 1).expand(B, 1, ng, ng).to(xt),
74
+ _coord_planes(B, ng, xt)], dim=1))
75
+ return self.wrap.fwd(field)
76
+
77
+ @classmethod
78
+ def from_config(cls, cfg: dict) -> "LocalDrift2DCoord":
79
+ return cls(D=int(cfg.get("D", 196)), base_channels=int(cfg.get("base_channels", 64)),
80
+ n_grid=int(cfg.get("n_grid", 32)), k_c=int(cfg.get("k_c", K_C)),
81
+ packaged=_require_packaged(cfg), dropout=float(cfg.get("dropout", 0.0)))
82
+
83
+
84
+ from ..registry import register_model # noqa: E402
85
+
86
+ register_model("unet2_coord", Conditional2DCoord, LocalDrift2DCoord, needs_dataset_norm=True,
87
+ description="unet2 + two coordinate input planes (not translation-equivariant; passive scalar)")
code/bifurcation/registry.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Model and method factories shared by training, sampling and evaluation.
2
+
3
+ Methods: ``direct_si``, ``cascaded_si`` and ``edm``. Models: ``unet2_5ch`` (Euler, SQG), ``unet2_5ch_coord``
4
+ (passive scalar), ``unet2_5ch_coord_aug`` (heterogeneous waves) and ``unet2_2comp`` (NLKG); each run configuration
5
+ under records/<system>/runs/ names its model and method."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Callable
11
+
12
+ from .methods.edm import EDMMethod
13
+ from .methods.direct_si import DirectSIMethod
14
+ from .methods.cascaded_si import CascadedSIMethod
15
+ from .models.unet2 import Conditional2D, LocalDrift2D
16
+
17
+ SEGMENT_DATA_MODES = ("segment_pairs", "fine_segment_pairs")
18
+
19
+ # reserved transient key: the method's segment boundaries in physical time, injected by
20
+ # build_model for backbones that declare physical times in their own YAML (see module docstring)
21
+ T_VALUES_KEY = "_method_t_values"
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ModelSpec:
26
+ name: str
27
+ endpoint_cls: type
28
+ segment_cls: type
29
+ needs_dataset_norm: bool = False
30
+ description: str = ""
31
+
32
+
33
+ MODELS: dict[str, ModelSpec] = {}
34
+ METHODS: dict[str, Callable[[dict], object]] = {}
35
+
36
+
37
+ def register_model(name: str, endpoint_cls: type, segment_cls: type,
38
+ needs_dataset_norm: bool = False, description: str = "",
39
+ overwrite: bool = False) -> ModelSpec:
40
+ if name in MODELS and not overwrite:
41
+ raise KeyError(f"model {name!r} already registered")
42
+ spec = ModelSpec(name, endpoint_cls, segment_cls, needs_dataset_norm, description)
43
+ MODELS[name] = spec
44
+ return spec
45
+
46
+
47
+ def register_method(name: str, factory: Callable[[dict], object],
48
+ overwrite: bool = False) -> None:
49
+ if name in METHODS and not overwrite:
50
+ raise KeyError(f"method {name!r} already registered")
51
+ METHODS[name] = factory
52
+
53
+
54
+ def head_for_data_mode(data_mode: str | None) -> str:
55
+ return "segment" if data_mode in SEGMENT_DATA_MODES else "endpoint"
56
+
57
+
58
+ def build_model(model_cfg: dict, method_cfg: dict | None = None,
59
+ data_cfg: dict | None = None):
60
+ """Construct the backbone named by ``model_cfg['name']``.
61
+
62
+ ``method_cfg['data_mode']`` selects the head. For models with ``needs_dataset_norm`` the dataset path is
63
+ taken from ``data_cfg`` (``packaged_dataset_path``); a model configuration that carries its own copy of the
64
+ key is accepted only if it agrees with the data config.
65
+ """
66
+ name = model_cfg["name"]
67
+ if name not in MODELS:
68
+ raise ValueError(f"unknown model: {name!r} (registered: {sorted(MODELS)})")
69
+ spec = MODELS[name]
70
+ cfg = dict(model_cfg)
71
+ if spec.needs_dataset_norm:
72
+ data_path = (data_cfg or {}).get("packaged_dataset_path")
73
+ model_path = cfg.get("packaged_dataset_path")
74
+ if data_path is None and model_path is None:
75
+ raise KeyError(
76
+ f"model {name!r} needs the dataset normalization: pass data_cfg with "
77
+ "'packaged_dataset_path' (model configurations do not carry dataset paths)")
78
+ if data_path is not None and model_path is not None:
79
+ if _norm_path(data_path) != _norm_path(model_path):
80
+ raise ValueError(
81
+ f"model.packaged_dataset_path ({model_path}) != data.packaged_dataset_path "
82
+ f"({data_path}) — the FFT wrapper must use the training dataset's statistics")
83
+ cfg["packaged_dataset_path"] = str(data_path if data_path is not None else model_path)
84
+ t_values = ((method_cfg or {}).get("sampling") or {}).get("t_values")
85
+ if t_values:
86
+ cfg[T_VALUES_KEY] = list(t_values)
87
+ data_mode = (method_cfg or {}).get("data_mode")
88
+ # a model configuration that declares the horizon planes must agree with the method's time grid:
89
+ # t_start = t_values[0], t_end = t_values[-1]
90
+ tv = ((method_cfg or {}).get("sampling") or {}).get("t_values")
91
+ if tv is not None and ("t_start" in cfg or "t_end" in cfg):
92
+ t0, t1 = float(tv[0]), float(tv[-1])
93
+ if "t_start" in cfg and abs(float(cfg["t_start"]) - t0) > 1e-9:
94
+ raise ValueError(f"model {name!r}: t_start {cfg['t_start']} != method t_values[0] = {t0}")
95
+ if "t_end" in cfg and abs(float(cfg["t_end"]) - t1) > 1e-9:
96
+ raise ValueError(f"model {name!r}: t_end {cfg['t_end']} != method horizon t_values[-1] = {t1} "
97
+ f"(the model constant must equal the forecast horizon)")
98
+ cls = spec.segment_cls if head_for_data_mode(data_mode) == "segment" else spec.endpoint_cls
99
+ return cls.from_config(cfg)
100
+
101
+
102
+ def build_method(method_cfg: dict):
103
+ name = method_cfg["name"]
104
+ if name not in METHODS:
105
+ raise ValueError(f"unknown method: {name!r} (registered: {sorted(METHODS)})")
106
+ return METHODS[name](method_cfg)
107
+
108
+
109
+ def _norm_path(p: str) -> str:
110
+ import os
111
+ return os.path.normpath(os.path.abspath(str(p)))
112
+
113
+
114
+ # ── built-ins ────────────────────────────────────────────────────────────────
115
+ register_model("unet2", Conditional2D, LocalDrift2D, needs_dataset_norm=True,
116
+ description="FFT-wrapped 2-D circular-conv U-Net on the 32x32 coarse field "
117
+ "(Conditional2D 3-ch / LocalDrift2D 5-ch)")
118
+ register_method("edm", EDMMethod.from_config)
119
+ register_method("direct_si", DirectSIMethod.from_config)
120
+ register_method("cascaded_si", CascadedSIMethod.from_config)
121
+
122
+ # the backbones register themselves on import; unet2_coord provides the coordinate planes of unet2_5ch_coord(_aug)
123
+ from .models import unet2_coord as _unet2_coord # noqa: F401,E402
124
+ from .models import unet2_5ch as _unet2_5ch # noqa: F401,E402
125
+ from .models import unet2_5ch_coord as _unet2_5ch_coord # noqa: F401,E402
126
+ from .models import unet2_5ch_coord_aug as _unet2_5ch_coord_aug # noqa: F401,E402
127
+ from .models import unet2_2comp as _unet2_2comp # noqa: F401,E402
code/bifurcation/sample.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Draw forecasts from a trained model: for every initial condition of a split, ``n_samples`` samples of x_T.
2
+
3
+ python -m bifurcation.sample --config CONFIG --data DIR --checkpoint FILE --out DIR [--split test] [--gids 5,16]
4
+ [--n-samples 10000] [--n-steps N] [--seed-base 777] [--chunk 2000] [--truth-bank DIR] [--device cuda]
5
+
6
+ CONFIG is a run configuration (records/<system>/runs/<model>/config.yaml, or the config_resolved.yaml of a run trained
7
+ here). The EMA weights of the checkpoint are used when the method keeps an EMA. ``--n-steps`` overrides the sampler
8
+ resolution (steps per segment for Cascaded SI, SDE steps for Direct SI, Heun steps for EDM). The noise of condition
9
+ ``gid`` comes from the stream seeded with ``seed_base * 100003 + gid`` and is drawn in chunks of ``--chunk`` samples.
10
+ ``--truth-bank`` supplies x_0 of test conditions that are not in the package (waves, NLKG).
11
+
12
+ Output: ``OUT/<variant>/group_<gid>.npz`` with ``gen_xT_norm`` (n_samples, D), ``x0_norm`` (D,), ``true_xT_norm``
13
+ (the package's truth members at the horizon) and ``gid``; states are in the package's normalized units.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import hashlib
19
+ import json
20
+ import os
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+
24
+ import numpy as np
25
+ import torch
26
+
27
+ from .data.loaders import get_split_set
28
+ from .registry import build_method, build_model
29
+ from .utils import load_yaml
30
+
31
+
32
+ def parse_args(argv=None):
33
+ p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
34
+ p.add_argument("--config", required=True, help="run configuration (YAML)")
35
+ p.add_argument("--data", required=True, help="dataset package directory")
36
+ p.add_argument("--checkpoint", required=True)
37
+ p.add_argument("--out", required=True, help="output directory")
38
+ p.add_argument("--split", default="test", choices=("train", "val", "test"))
39
+ p.add_argument("--gids", default=None, help="comma-separated subset of the split's conditions")
40
+ p.add_argument("--n-samples", type=int, default=10000)
41
+ p.add_argument("--n-steps", type=int, default=None, help="sampler resolution override")
42
+ p.add_argument("--seed-base", type=int, default=777)
43
+ p.add_argument("--chunk", type=int, default=None, help="draw the samples of a condition in chunks of this size")
44
+ p.add_argument("--truth-bank", default=None, help="truth bank with the x_0 of test conditions absent from the package")
45
+ p.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
46
+ return p.parse_args(argv)
47
+
48
+
49
+ def _sha256(path: Path) -> str:
50
+ h = hashlib.sha256()
51
+ with open(path, "rb") as f:
52
+ for chunk in iter(lambda: f.read(1 << 24), b""):
53
+ h.update(chunk)
54
+ return h.hexdigest()
55
+
56
+
57
+ def main(argv=None) -> int:
58
+ args = parse_args(argv)
59
+ cfg = load_yaml(args.config)
60
+ cfg["data"]["packaged_dataset_path"] = str(Path(args.data).resolve()) + "/"
61
+ device = str(args.device)
62
+
63
+ allow_tf32 = cfg.get("training", {}).get("allow_tf32")
64
+ if allow_tf32 is not None: # the arithmetic class the model was trained under
65
+ torch.backends.cuda.matmul.allow_tf32 = bool(allow_tf32)
66
+ torch.backends.cudnn.allow_tf32 = bool(allow_tf32)
67
+ seed = int(cfg.get("seed", cfg["data"].get("seed", 0)))
68
+
69
+ ckpt_path = Path(args.checkpoint).resolve()
70
+ ckpt = torch.load(ckpt_path, map_location=device)
71
+ model = build_model(cfg["model"], cfg["method"], cfg["data"])
72
+ ema_halflife = cfg.get("training", {}).get("ema_halflife_kimg", cfg["method"].get("ema_halflife_kimg"))
73
+ method_uses_ema = ema_halflife is not None and float(ema_halflife) > 0
74
+ if "ema_state_dict" in ckpt and method_uses_ema:
75
+ model.load_state_dict(ckpt["ema_state_dict"])
76
+ weights = "ema"
77
+ else:
78
+ model.load_state_dict(ckpt["model_state_dict"])
79
+ weights = "raw"
80
+ model.to(device).eval()
81
+ print(f"checkpoint: {ckpt_path.name} (step {ckpt.get('step')}, {weights} weights)")
82
+
83
+ if args.n_steps is not None:
84
+ sampling = cfg["method"]["sampling"]
85
+ for key in ("n_sde_steps", "n_sde_steps_per_seg", "n_steps"):
86
+ if key in sampling:
87
+ break
88
+ else:
89
+ raise KeyError("--n-steps: method.sampling has none of n_sde_steps / n_sde_steps_per_seg / n_steps")
90
+ print(f"sampler resolution: method.sampling.{key} {sampling[key]} -> {args.n_steps}")
91
+ sampling[key] = args.n_steps
92
+ method = build_method(cfg["method"])
93
+
94
+ split_x0, truth_clouds, gids = get_split_set(cfg, args.split, seed=seed, x0_bank=args.truth_bank)
95
+ if args.gids is not None:
96
+ wanted = sorted(int(g) for g in args.gids.split(","))
97
+ missing = [g for g in wanted if g not in gids]
98
+ if missing:
99
+ raise ValueError(f"--gids not in split '{args.split}': {missing}")
100
+ sel = [gids.index(g) for g in wanted]
101
+ split_x0, truth_clouds, gids = split_x0[sel], truth_clouds[sel], wanted
102
+ split_x0, truth_clouds = split_x0.to(device), truth_clouds.to(device)
103
+ n_gids = split_x0.shape[0]
104
+ print(f"split {args.split}: {n_gids} conditions, {args.n_samples} samples each")
105
+
106
+ out = Path(args.out).resolve()
107
+ out.mkdir(parents=True, exist_ok=True)
108
+ split_offset = {"train": 1000, "val": 2000, "test": 3000}[args.split]
109
+ variants = method.variants_for_eval()
110
+ for v_idx, (label, sample_kwargs) in enumerate(variants.items()):
111
+ bundle_dir = out / label
112
+ bundle_dir.mkdir(parents=True, exist_ok=True)
113
+ torch.manual_seed(seed + split_offset + 100 * v_idx)
114
+ with torch.no_grad():
115
+ for i in range(n_gids):
116
+ x0_i, gid = split_x0[i], int(gids[i])
117
+ torch.manual_seed(int(args.seed_base) * 100003 + gid)
118
+ if args.chunk is not None and args.n_samples > int(args.chunk):
119
+ parts, left = [], int(args.n_samples)
120
+ while left > 0:
121
+ m = min(int(args.chunk), left)
122
+ parts.append(method.sample(model, x0_i, n_samples=m, **sample_kwargs).squeeze(0))
123
+ left -= m
124
+ gen_cloud = torch.cat(parts, dim=0)
125
+ else:
126
+ gen_cloud = method.sample(model, x0_i, n_samples=int(args.n_samples), **sample_kwargs).squeeze(0)
127
+ np.savez(bundle_dir / f"group_{gid:04d}.npz", true_xT_norm=truth_clouds[i].cpu().numpy(),
128
+ gen_xT_norm=gen_cloud.cpu().numpy(), x0_norm=x0_i.cpu().numpy(), gid=np.int64(gid))
129
+ print(f" {label}: condition {gid} ({i + 1}/{n_gids})", flush=True)
130
+
131
+ record = {"config": str(Path(args.config).resolve()), "checkpoint": str(ckpt_path), "checkpoint_sha256": _sha256(ckpt_path),
132
+ "weights": weights, "split": args.split, "gids": list(gids), "n_samples": int(args.n_samples),
133
+ "sampling": {k: cfg["method"]["sampling"].get(k) for k in ("n_sde_steps", "n_sde_steps_per_seg", "n_steps",
134
+ "n_segments", "noise_scale") if k in cfg["method"]["sampling"]},
135
+ "seed_base": int(args.seed_base), "chunk": args.chunk, "variants": list(variants), "device": device,
136
+ "allow_tf32": allow_tf32, "at": datetime.now(timezone.utc).isoformat(timespec="seconds")}
137
+ (out / "SAMPLES.json").write_text(json.dumps(record, indent=1) + "\n")
138
+ return 0
139
+
140
+
141
+ if __name__ == "__main__":
142
+ raise SystemExit(main())
code/bifurcation/train.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Train one model from a run configuration.
2
+
3
+ python -m bifurcation.train --config records/euler/runs/cascaded_si_S10/config.yaml --data $EULER_DATA \\
4
+ --out runs/euler_cascaded_si_S10 [--seed 0] [--device cuda] [--resume]
5
+
6
+ The configuration names the dataset, the model, the method with its hyperparameters and the training schedule
7
+ (records/<system>/runs/<model>/config.yaml are the configurations of the paper's models). Before training, the
8
+ training pairs are written to OUT/manifest/: Cascaded SI with S segments reads the first 200 / S realizations of each
9
+ of the 80 training conditions, cut into S segments (16,000 pairs for every S; S = 1 is Direct SI); EDM reads the
10
+ endpoint pair (x_0, x_T) of all 200 realizations. Checkpoints are saved every ``training.val_every_updates`` updates
11
+ under OUT/checkpoints/ (bifurcation.trainer); ``--resume`` continues an interrupted run exactly from its
12
+ ``restart_latest.pt``.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import os
18
+ from pathlib import Path
19
+
20
+ import numpy as np
21
+ import torch
22
+
23
+ from .data import manifests as MF
24
+ from .data.loaders import get_dataloaders
25
+ from .registry import build_method, build_model
26
+ from .trainer import Trainer
27
+ from .utils import load_yaml, save_resolved_config, seed_everything
28
+
29
+ # a restart may only continue a run with the same data, method, model and seed
30
+ RESUME_IDENTITY_KEYS = (("data", "name"), ("data", "packaged_dataset_path"), ("method", "name"), ("method", "data_mode"),
31
+ ("method", "sigma_train"), ("method", "P_mean"), ("method", "lr"), ("training", "batch_size"),
32
+ ("model", "name"))
33
+
34
+
35
+ def parse_args(argv=None):
36
+ p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
37
+ p.add_argument("--config", required=True, help="run configuration (YAML)")
38
+ p.add_argument("--data", required=True, help="dataset package directory")
39
+ p.add_argument("--out", required=True, help="run directory (new, or the one to resume)")
40
+ p.add_argument("--seed", type=int, default=0)
41
+ p.add_argument("--device", default=None, help="default: cuda if available, else cpu")
42
+ p.add_argument("--resume", action="store_true", help="continue from OUT/checkpoints/restart_latest.pt")
43
+ return p.parse_args(argv)
44
+
45
+
46
+ def write_training_manifest(cfg: dict, out: Path) -> Path:
47
+ """The training pairs of the run (see the module docstring); returns the manifest file."""
48
+ packaged = cfg["data"]["packaged_dataset_path"]
49
+ n_ic, R = int(cfg["data"]["train_ics"]), int(cfg["data"]["train_realizations"])
50
+ g = MF.fine_grid(packaged)
51
+ if len(g["gids_train"]) < n_ic or g["vecs_train_shape"][1] < R:
52
+ raise ValueError(f"the package holds {len(g['gids_train'])} training conditions x {g['vecs_train_shape'][1]} "
53
+ f"realizations, the configuration needs {n_ic} x {R}")
54
+ m = cfg["method"]
55
+ T = float(m["sampling"]["t_values"][-1])
56
+ if m["data_mode"] == "fine_segment_pairs":
57
+ S = int(m["sampling"].get("n_segments", 1))
58
+ if R % S:
59
+ raise ValueError(f"S = {S} must divide the {R} realizations per training condition")
60
+ geo = MF.segment_geometry(T, S, g["t_values"])
61
+ rows = MF.block_rows_segment(g["gids_train"], n_ic, R // S)
62
+ name = f"segments_S{S:02d}"
63
+ path = MF.write_segment_manifest(out / "manifest", rows, geo, name, packaged,
64
+ extra={"realizations_per_condition": R // S})
65
+ elif m["data_mode"] == "fine_endpoint_pairs":
66
+ geo = MF.segment_geometry(T, 1, g["t_values"])
67
+ rows = MF.block_rows_endpoint(g["gids_train"], n_ic, R)
68
+ path = MF.write_endpoint_manifest(out / "manifest", rows, geo["n_T"], g["t_values"], "endpoints", packaged,
69
+ extra={"realizations_per_condition": R})
70
+ else:
71
+ raise ValueError(f"unknown method.data_mode {m['data_mode']!r}")
72
+ if int(m["fine_horizon_idx"]) != int(geo["n_T"]):
73
+ raise ValueError(f"method.fine_horizon_idx {m['fine_horizon_idx']} != the index {geo['n_T']} of T = {T}")
74
+ return path
75
+
76
+
77
+ def main(argv=None) -> int:
78
+ args = parse_args(argv)
79
+ cfg = load_yaml(args.config)
80
+ cfg["data"]["packaged_dataset_path"] = str(Path(args.data).resolve()) + "/"
81
+ cfg["seed"] = int(args.seed)
82
+ run_dir = Path(args.out).resolve()
83
+ run_dir.mkdir(parents=True, exist_ok=True)
84
+ cfg["method"]["fine_slice_manifest"] = str(write_training_manifest(cfg, run_dir))
85
+ cfg["training"]["device"] = str(args.device or os.environ.get("BIFURCATION_DEVICE")
86
+ or ("cuda" if torch.cuda.is_available() else "cpu"))
87
+
88
+ resume_payload = None
89
+ if args.resume:
90
+ restart_path = run_dir / "checkpoints" / "restart_latest.pt"
91
+ if not restart_path.is_file():
92
+ raise FileNotFoundError(f"--resume: no restart checkpoint at {restart_path}")
93
+ resume_payload = torch.load(restart_path, map_location="cpu", weights_only=False)
94
+ old = resume_payload.get("config", {})
95
+ for section, key in RESUME_IDENTITY_KEYS:
96
+ a, b = old.get(section, {}).get(key), cfg.get(section, {}).get(key)
97
+ if a is not None and a != b:
98
+ raise AssertionError(f"--resume: {section}.{key} was {a!r}, now {b!r}; refusing to continue a different run")
99
+ if int(old.get("seed", args.seed)) != int(args.seed):
100
+ raise AssertionError(f"--resume: the run has seed {old.get('seed')}, --seed {args.seed}")
101
+ print(f"resume: restart_latest.pt at step {resume_payload['step']}")
102
+ save_resolved_config(cfg, run_dir / "config_resolved.yaml")
103
+
104
+ seed_everything(int(args.seed))
105
+ train_loader, val_loader = get_dataloaders(cfg, seed=int(args.seed))
106
+ model = build_model(cfg["model"], cfg["method"], cfg["data"])
107
+ method = build_method(cfg["method"])
108
+ Trainer(model=model, method=method, train_loader=train_loader, val_loader=val_loader, cfg=cfg, run_dir=run_dir,
109
+ device=str(cfg["training"]["device"])).fit(resume=resume_payload)
110
+ print(f"\nrun: {run_dir}\ncheckpoints: {run_dir / 'checkpoints'}\nlog: {run_dir / 'train_log.csv'}")
111
+ return 0
112
+
113
+
114
+ if __name__ == "__main__":
115
+ raise SystemExit(main())
code/bifurcation/trainer.py ADDED
@@ -0,0 +1,371 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Training loop: exactly ``training.max_steps`` optimizer updates, with EMA weights, gradient clipping and
2
+ validation events.
3
+
4
+ A validation event fires after global update ``step`` when ``step % val_every_updates == 0``, at ``max_steps`` and
5
+ at every step listed in ``val_extra_steps`` (0 = the untrained model). An event evaluates the training objective on
6
+ the validation pairs (``val_loss``, and ``val_loss_ema`` for the EMA weights), writes one row of ``train_log.csv``
7
+ (``event`` = 1), saves ``checkpoints/checkpoint_step<NNNNNNNN>.pt`` and the full-state ``restart_latest.pt``. With
8
+ ``log_loss_every_epochs: k`` a loss-only row (``event`` = 0, no checkpoint) is written at the end of every k-th epoch.
9
+ ``train_loss`` of a row is the mean over the updates since the previous row. The validation passes run under a
10
+ forked RNG with a fixed seed and restore the loader's generator state, so they never perturb the training
11
+ trajectory. ``checkpoints/checkpoint.pt`` holds the final weights; every checkpoint stores the raw and the EMA
12
+ weights and the run configuration.
13
+
14
+ EMA and LR ramp-up follow the convention of NVlabs/edm (``training/training_loop.py``): ``ema_halflife_kimg``,
15
+ ``ema_rampup_ratio`` and ``lr_rampup_kimg`` come from the training section (overrides) or the method section.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import copy
20
+ import os
21
+ import random
22
+ import time
23
+ from pathlib import Path
24
+ from typing import Any, Dict
25
+
26
+ import numpy as np
27
+ import torch
28
+
29
+ RESTART_SCHEMA = "restart_v1"
30
+ RESTART_NAME = "restart_latest.pt"
31
+ LOG_COLUMNS = "step,epoch,batch_index,event,train_loss,val_loss,val_loss_ema,train_eval_loss,train_eval_loss_ema,lr,elapsed_s"
32
+
33
+
34
+ def _ema_beta(cur_nimg: int, batch_size: int, ema_halflife_kimg: float, ema_rampup_ratio: float | None) -> float:
35
+ ema_halflife_nimg = ema_halflife_kimg * 1000
36
+ if ema_rampup_ratio is not None:
37
+ ema_halflife_nimg = min(ema_halflife_nimg, cur_nimg * ema_rampup_ratio)
38
+ return 0.5 ** (batch_size / max(ema_halflife_nimg, 1e-8))
39
+
40
+
41
+ def _set_rampup_lr(optimizer: torch.optim.Optimizer, base_lr: float, cur_nimg: int, lr_rampup_kimg: float) -> None:
42
+ factor = min(cur_nimg / max(lr_rampup_kimg * 1000, 1e-8), 1.0)
43
+ for g in optimizer.param_groups:
44
+ g["lr"] = base_lr * factor
45
+
46
+
47
+ def _eval_loss(model, loader, method, device, max_batches=None) -> float:
48
+ losses = []
49
+ with torch.no_grad():
50
+ for i, batch in enumerate(loader):
51
+ if max_batches is not None and i >= max_batches:
52
+ break
53
+ batch = tuple(t.to(device) for t in batch)
54
+ losses.append(method.loss(model, batch).item())
55
+ return float(np.mean(losses)) if losses else float("nan")
56
+
57
+
58
+ def _save_checkpoint(path: Path, model, ema_model, cfg: Dict[str, Any], epoch: int, step: int) -> None:
59
+ payload = {"model_state_dict": model.state_dict(), "epoch": epoch, "config": cfg, "step": int(step)}
60
+ if ema_model is not None:
61
+ payload["ema_state_dict"] = ema_model.state_dict()
62
+ torch.save(payload, path)
63
+
64
+
65
+ def _save_restart(path: Path, payload: Dict[str, Any]) -> None:
66
+ tmp = path.with_name(path.name + ".tmp") # atomic: a job killed mid-save leaves no truncated file
67
+ torch.save(payload, tmp)
68
+ os.replace(tmp, path)
69
+
70
+
71
+ def _truncate_log(log_path: Path, keep_step: int) -> int:
72
+ """Drop the rows after ``keep_step`` (and a partial final line)."""
73
+ lines = log_path.read_text().splitlines()
74
+ header, rows = lines[0], lines[1:]
75
+ kept = []
76
+ for line in rows:
77
+ try:
78
+ if int(line.split(",", 1)[0]) <= keep_step:
79
+ kept.append(line)
80
+ except ValueError:
81
+ continue
82
+ log_path.write_text("\n".join([header] + kept) + "\n")
83
+ return len(rows) - len(kept)
84
+
85
+
86
+ class Trainer:
87
+ def __init__(self, model, method, train_loader, val_loader, cfg: Dict[str, Any], run_dir: Path, device: str):
88
+ self.model = model
89
+ self.method = method
90
+ self.train_loader = train_loader
91
+ self.val_loader = val_loader
92
+ self.cfg = cfg
93
+ self.run_dir = Path(run_dir)
94
+ self.device = device
95
+
96
+ tr, m = cfg["training"], cfg["method"]
97
+ allow_tf32 = tr.get("allow_tf32")
98
+ if allow_tf32 is not None: # one arithmetic class on every GPU (False = strict FP32)
99
+ torch.backends.cuda.matmul.allow_tf32 = bool(allow_tf32)
100
+ torch.backends.cudnn.allow_tf32 = bool(allow_tf32)
101
+ print(f"precision: allow_tf32={bool(allow_tf32)} (matmul + cudnn)")
102
+ if bool(tr.get("deterministic", False)): # a resumed run is bit-identical to an uninterrupted one
103
+ os.environ.setdefault("CUBLAS_WORKSPACE_CONFIG", ":4096:8")
104
+ torch.backends.cudnn.deterministic = True
105
+ torch.backends.cudnn.benchmark = False
106
+ torch.use_deterministic_algorithms(True)
107
+ print("determinism: use_deterministic_algorithms(True), cudnn.deterministic, benchmark off, "
108
+ "CUBLAS_WORKSPACE_CONFIG=" + os.environ["CUBLAS_WORKSPACE_CONFIG"])
109
+
110
+ self.batch_size = int(tr["batch_size"])
111
+ if "lr" in tr:
112
+ self.base_lr = float(tr["lr"])
113
+ elif "lr" in m:
114
+ self.base_lr = float(m["lr"])
115
+ else:
116
+ raise KeyError("'lr' must be set in the training or the method section")
117
+ self.val_seed = int(tr.get("val_seed", 777))
118
+ self.restart_checkpointing = bool(tr.get("restart_checkpointing", True))
119
+
120
+ if tr.get("max_steps") is None or tr.get("val_every_updates") is None:
121
+ raise KeyError("training.max_steps and training.val_every_updates are required")
122
+ self.max_steps = int(tr["max_steps"])
123
+ self.val_every_updates = int(tr["val_every_updates"])
124
+ if self.max_steps <= 0 or self.val_every_updates <= 0:
125
+ raise ValueError("max_steps and val_every_updates must be positive")
126
+ self.val_extra_steps = tr.get("val_extra_steps")
127
+ if self.val_extra_steps is not None:
128
+ if not isinstance(self.val_extra_steps, (list, tuple)) or \
129
+ any(isinstance(s, bool) or int(s) != s for s in self.val_extra_steps):
130
+ raise ValueError("training.val_extra_steps must be a list of integers")
131
+ xs = sorted({int(s) for s in self.val_extra_steps})
132
+ bad = [s for s in xs if s < 0 or s >= self.max_steps]
133
+ if bad:
134
+ raise ValueError(f"training.val_extra_steps must lie in [0, max_steps): {bad}")
135
+ self.val_extra_steps = xs
136
+ self.log_loss_every_epochs = tr.get("log_loss_every_epochs")
137
+ if self.log_loss_every_epochs is not None:
138
+ k = self.log_loss_every_epochs
139
+ if isinstance(k, bool) or int(k) != k or int(k) <= 0:
140
+ raise ValueError("training.log_loss_every_epochs must be a positive integer")
141
+ self.log_loss_every_epochs = int(k)
142
+
143
+ def _opt(key, default=None): # training section overrides the method section
144
+ return tr[key] if key in tr else m.get(key, default)
145
+
146
+ self.ema_halflife_kimg = _opt("ema_halflife_kimg") # None: no EMA
147
+ self.ema_rampup_ratio = _opt("ema_rampup_ratio", 0.05)
148
+ self.lr_rampup_kimg = _opt("lr_rampup_kimg") # None: no LR ramp
149
+ self.max_grad_norm = _opt("max_grad_norm") # None: no clipping
150
+ self.weight_decay = _opt("weight_decay")
151
+ self.use_ema = self.ema_halflife_kimg is not None and float(self.ema_halflife_kimg) > 0
152
+ self.use_lr_rampup = self.lr_rampup_kimg is not None and float(self.lr_rampup_kimg) > 0
153
+
154
+ def fit(self, resume: Dict[str, Any] | None = None) -> None:
155
+ self.run_dir.mkdir(parents=True, exist_ok=True)
156
+ ckpt_dir = self.run_dir / "checkpoints"
157
+ ckpt_dir.mkdir(parents=True, exist_ok=True)
158
+
159
+ model = self.model.to(self.device)
160
+ if self.use_ema:
161
+ ema_model = copy.deepcopy(model).eval().to(self.device)
162
+ for p in ema_model.parameters():
163
+ p.requires_grad_(False)
164
+ else:
165
+ ema_model = None
166
+ optimizer = self.method.configure_optimizer(model, {"lr": self.base_lr, "weight_decay": self.weight_decay})
167
+
168
+ start_epoch = 1
169
+ if resume is not None:
170
+ if resume.get("schema") != RESTART_SCHEMA:
171
+ raise AssertionError(f"restart checkpoint schema {resume.get('schema')!r} != {RESTART_SCHEMA}")
172
+ model.load_state_dict(resume["model_state_dict"])
173
+ if ema_model is not None:
174
+ if resume.get("ema_state_dict") is None:
175
+ raise AssertionError("EMA is on but the restart checkpoint has no EMA state")
176
+ ema_model.load_state_dict(resume["ema_state_dict"])
177
+ optimizer.load_state_dict(resume["optimizer_state_dict"])
178
+ start_epoch = int(resume["epoch"]) # the interrupted epoch is replayed
179
+
180
+ n_train_pairs = len(self.train_loader.dataset)
181
+ steps_per_epoch = len(self.train_loader)
182
+ n_epochs = max(1, -(-self.max_steps // steps_per_epoch))
183
+ print(f"schedule: max_steps = {self.max_steps}, validation + checkpoint every {self.val_every_updates} updates "
184
+ f"(+ at max_steps); {n_train_pairs} training pairs, batch {self.batch_size}, {steps_per_epoch} steps/epoch -> "
185
+ f"{n_epochs} epochs (last one partial: {self.max_steps - (n_epochs - 1) * steps_per_epoch} steps)")
186
+ if self.use_ema:
187
+ seen_kimg = (self.max_steps - 1) * self.batch_size / 1000.0
188
+ eff_kimg = min(float(self.ema_halflife_kimg), float(self.ema_rampup_ratio) * seen_kimg)
189
+ print(f"EMA: half-life {self.ema_halflife_kimg} kimg, ramp-up ratio {self.ema_rampup_ratio} "
190
+ f"(effective half-life at the last update ~= {eff_kimg:.1f} kimg)")
191
+ else:
192
+ print("EMA: disabled")
193
+ print(f"LR ramp-up: {self.lr_rampup_kimg if self.use_lr_rampup else 'disabled'}; "
194
+ f"gradient clipping: {self.max_grad_norm if self.max_grad_norm is not None else 'disabled'}")
195
+
196
+ log_path = self.run_dir / "train_log.csv"
197
+ if resume is not None:
198
+ if int(resume["step"]) >= self.max_steps:
199
+ raise AssertionError(f"resume step {int(resume['step'])} >= max_steps {self.max_steps} (nothing to continue)")
200
+ if not log_path.exists():
201
+ raise FileNotFoundError(f"--resume requires the existing {log_path}")
202
+ dropped = _truncate_log(log_path, int(resume["step"]))
203
+ print(f"resume: continuing at step {int(resume['step'])} (epoch {start_epoch}, batch "
204
+ f"{int(resume['batch_index'])}; {dropped} log row(s) dropped)")
205
+ else:
206
+ log_path.write_text(LOG_COLUMNS + "\n")
207
+
208
+ cur_nimg = 0
209
+ if resume is not None: # RNG streams restored last, after every setup step
210
+ cur_nimg = int(resume["cur_nimg"])
211
+ torch.set_rng_state(resume["torch_rng_state"])
212
+ if str(self.device).startswith("cuda"):
213
+ torch.cuda.set_rng_state_all(resume["cuda_rng_state_all"])
214
+ np.random.set_state(resume["numpy_rng_state"])
215
+ random.setstate(resume["python_rng_state"])
216
+ gen = self.train_loader.generator
217
+ if resume.get("loader_generator_state_epoch_start") is not None:
218
+ if gen is None:
219
+ raise AssertionError("restart checkpoint has a loader generator state but the train loader has no generator")
220
+ gen.set_state(resume["loader_generator_state_epoch_start"])
221
+ print(f"resume: restored optimizer, EMA, counters (cur_nimg={cur_nimg}), RNG streams and loader generator", flush=True)
222
+ self._fit_updates(model=model, ema_model=ema_model, optimizer=optimizer, ckpt_dir=ckpt_dir, log_path=log_path,
223
+ resume=resume, start_epoch=start_epoch, cur_nimg=cur_nimg, n_val_batches=len(self.val_loader))
224
+
225
+ def _fit_updates(self, *, model, ema_model, optimizer, ckpt_dir, log_path, resume, start_epoch, cur_nimg, n_val_batches):
226
+ max_steps, every = self.max_steps, self.val_every_updates
227
+ extra_steps = set(self.val_extra_steps or [])
228
+ loss_every = int(self.log_loss_every_epochs) if self.log_loss_every_epochs else 0
229
+ devices_all = [self.device] if str(self.device).startswith("cuda") else []
230
+ losses_since_epoch_start: list[float] = []
231
+ last_event_step = -1
232
+ t_row = time.time()
233
+ if resume is not None:
234
+ losses_since_epoch_start = [float(x) for x in resume.get("losses_since_epoch_start", [])]
235
+ last_event_step = int(resume["step"])
236
+ step, start_batch = 0, 0
237
+ if resume is not None:
238
+ step, start_batch = int(resume["step"]), int(resume["batch_index"])
239
+ epoch = start_epoch
240
+ losses_since_event: list[float] = []
241
+ t_event = time.time()
242
+ done = False
243
+ gen = self.train_loader.generator
244
+
245
+ def _event(epoch_i: int, batch_i: int, gen_state_epoch_start):
246
+ nonlocal losses_since_event, t_event, last_event_step, t_row
247
+ train_loss = float(np.mean(losses_since_event)) if losses_since_event else float("nan")
248
+ cur_lr = optimizer.param_groups[0]["lr"]
249
+ model.eval()
250
+ g_state = gen.get_state() if gen is not None else None
251
+ with torch.random.fork_rng(devices=devices_all):
252
+ torch.manual_seed(self.val_seed)
253
+ val_loss = _eval_loss(model, self.val_loader, self.method, self.device)
254
+ train_eval = _eval_loss(model, self.train_loader, self.method, self.device, max_batches=n_val_batches)
255
+ if self.use_ema:
256
+ val_loss_ema = _eval_loss(ema_model, self.val_loader, self.method, self.device)
257
+ train_eval_ema = _eval_loss(ema_model, self.train_loader, self.method, self.device, max_batches=n_val_batches)
258
+ else:
259
+ val_loss_ema = train_eval_ema = float("nan")
260
+ if g_state is not None:
261
+ gen.set_state(g_state)
262
+ elapsed = time.time() - t_event
263
+ t_event = t_row = time.time()
264
+ last_event_step = step
265
+ print(f"step {step} (epoch {epoch_i}, batch {batch_i}): train_loss={train_loss:.6f} val={val_loss:.6f} "
266
+ f"val_ema={val_loss_ema:.6f} lr={cur_lr:.2e} elapsed={elapsed:.2f}s", flush=True)
267
+ with open(log_path, "a") as f:
268
+ f.write(f"{step},{epoch_i},{batch_i},1,{train_loss},{val_loss},{val_loss_ema},"
269
+ f"{train_eval},{train_eval_ema},{cur_lr},{elapsed}\n")
270
+ losses_since_event = []
271
+ ckpt = ckpt_dir / f"checkpoint_step{step:08d}.pt"
272
+ _save_checkpoint(ckpt, model, ema_model, self.cfg, epoch_i, step=step)
273
+ print(f" saved: {ckpt.name}", flush=True)
274
+ if self.restart_checkpointing:
275
+ _save_restart(ckpt_dir / RESTART_NAME, {
276
+ "schema": RESTART_SCHEMA, "epoch": epoch_i, "step": step, "batch_index": batch_i, "cur_nimg": cur_nimg,
277
+ "model_state_dict": model.state_dict(),
278
+ "ema_state_dict": (ema_model.state_dict() if ema_model is not None else None),
279
+ "optimizer_state_dict": optimizer.state_dict(),
280
+ "torch_rng_state": torch.get_rng_state(),
281
+ "cuda_rng_state_all": (torch.cuda.get_rng_state_all() if str(self.device).startswith("cuda") else []),
282
+ "numpy_rng_state": np.random.get_state(), "python_rng_state": random.getstate(),
283
+ "loader_generator_state_epoch_start": gen_state_epoch_start,
284
+ "losses_since_epoch_start": list(losses_since_epoch_start),
285
+ "config": self.cfg})
286
+ model.train()
287
+
288
+ def _loss_row(epoch_i: int, batch_i: int):
289
+ nonlocal losses_since_epoch_start, t_row
290
+ if not losses_since_epoch_start or step == last_event_step:
291
+ losses_since_epoch_start = []
292
+ return # nothing to log, or an event just logged this step
293
+ train_loss = float(np.mean(losses_since_epoch_start))
294
+ cur_lr = optimizer.param_groups[0]["lr"]
295
+ model.eval()
296
+ g_state = gen.get_state() if gen is not None else None
297
+ with torch.random.fork_rng(devices=devices_all):
298
+ torch.manual_seed(self.val_seed)
299
+ val_loss = _eval_loss(model, self.val_loader, self.method, self.device)
300
+ val_loss_ema = (_eval_loss(ema_model, self.val_loader, self.method, self.device) if self.use_ema else float("nan"))
301
+ if g_state is not None:
302
+ gen.set_state(g_state)
303
+ model.train()
304
+ nan = float("nan")
305
+ elapsed = time.time() - t_row
306
+ t_row = time.time()
307
+ with open(log_path, "a") as f:
308
+ f.write(f"{step},{epoch_i},{batch_i},0,{train_loss},{val_loss},{val_loss_ema},{nan},{nan},{cur_lr},{elapsed}\n")
309
+ losses_since_epoch_start = []
310
+
311
+ if 0 in extra_steps and resume is None and step == 0:
312
+ # the untrained model: its train_loss is a forward-only loss on the first batch; RNG and loader untouched
313
+ g_state = gen.get_state() if gen is not None else None
314
+ model.eval()
315
+ with torch.random.fork_rng(devices=devices_all):
316
+ torch.manual_seed(self.val_seed)
317
+ with torch.no_grad():
318
+ b0 = tuple(t.to(self.device) for t in next(iter(self.train_loader)))
319
+ loss0 = float(self.method.loss(model, b0).item())
320
+ model.train()
321
+ if g_state is not None:
322
+ gen.set_state(g_state)
323
+ losses_since_event = [loss0]
324
+ _event(epoch, 0, gen.get_state() if gen is not None else None)
325
+
326
+ while not done:
327
+ gen_state_epoch_start = gen.get_state() if gen is not None else None
328
+ replay = resume is not None and epoch == start_epoch and start_batch > 0
329
+ if replay: # the interrupted epoch: same permutation, consumed batches skipped
330
+ gen_state_epoch_start = resume["loader_generator_state_epoch_start"]
331
+ model.train()
332
+ if not replay:
333
+ losses_since_epoch_start = []
334
+ it = iter(self.train_loader)
335
+ skip = start_batch if (resume is not None and epoch == start_epoch) else 0
336
+ for _ in range(skip):
337
+ next(it)
338
+ batch_i = skip
339
+ for batch in it:
340
+ batch = tuple(t.to(self.device) for t in batch)
341
+ if self.use_lr_rampup:
342
+ _set_rampup_lr(optimizer, self.base_lr, cur_nimg, float(self.lr_rampup_kimg))
343
+ optimizer.zero_grad()
344
+ loss = self.method.loss(model, batch)
345
+ loss.backward()
346
+ if self.max_grad_norm is not None:
347
+ torch.nn.utils.clip_grad_norm_(model.parameters(), float(self.max_grad_norm))
348
+ optimizer.step()
349
+ if self.use_ema:
350
+ beta = _ema_beta(cur_nimg, self.batch_size, float(self.ema_halflife_kimg), self.ema_rampup_ratio)
351
+ for p_ema, p_net in zip(ema_model.parameters(), model.parameters()):
352
+ p_ema.copy_(p_net.detach().lerp(p_ema, beta))
353
+ for b_ema, b_net in zip(ema_model.buffers(), model.buffers()):
354
+ b_ema.copy_(b_net)
355
+ cur_nimg += batch[0].shape[0]
356
+ _li = loss.item()
357
+ losses_since_event.append(_li)
358
+ losses_since_epoch_start.append(_li)
359
+ step += 1
360
+ batch_i += 1
361
+ if step % every == 0 or step == max_steps or step in extra_steps:
362
+ _event(epoch, batch_i, gen_state_epoch_start)
363
+ if step >= max_steps:
364
+ done = True
365
+ break
366
+ if not done and loss_every and epoch % loss_every == 0:
367
+ _loss_row(epoch, batch_i)
368
+ epoch += 1
369
+
370
+ print(f"trained exactly {max_steps} updates", flush=True)
371
+ _save_checkpoint(ckpt_dir / "checkpoint.pt", model, ema_model, self.cfg, epoch - 1, step=max_steps)
code/bifurcation/utils.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Seeding and YAML helpers."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import random
6
+ from typing import Any, Dict
7
+
8
+ import numpy as np
9
+ import torch
10
+ import yaml
11
+
12
+
13
+ def seed_everything(seed: int) -> None:
14
+ random.seed(seed)
15
+ np.random.seed(seed)
16
+ torch.manual_seed(seed)
17
+ torch.cuda.manual_seed_all(seed)
18
+
19
+
20
+ def load_yaml(path: str | os.PathLike) -> Dict[str, Any]:
21
+ with open(path) as f:
22
+ return yaml.safe_load(f)
23
+
24
+
25
+ def save_resolved_config(cfg: Dict[str, Any], path: str | os.PathLike) -> None:
26
+ with open(path, "w") as f:
27
+ yaml.safe_dump(cfg, f, sort_keys=False)
code/configs/euler/dataset.yaml ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 2-D Euler (incompressible, periodic): the package `euler_v2_uniform`. Set EULER_DATA (the package directory:
2
+ # trajectories_fine.npz, split.json, normalization.npz) and EULER_TRUTH_BANK (group_<gid>.npz of the ten test
3
+ # conditions, 10,000 realizations each).
4
+ name: euler_v2_uniform
5
+ system: euler
6
+ data: ${EULER_DATA}
7
+ truth_bank: ${EULER_TRUTH_BANK}
8
+ expected_sha256: {split.json: 0040ecf525ba7c6b5cfe7d874812e04c69ccc4396ddbdc2db09e5489d8280b6e, normalization.npz: 06573da0f073b5bfabf884ac497f642c5d717824afd5644272239030dd7d4a7f,
9
+ trajectories_fine.npz: 664c3be66eee8b63dbad1fb379d72f6c7dd9a1094e3125c1e8c4223a4e2e7e66}
10
+ state:
11
+ dim: 196
12
+ observed: coarse spectral vorticity, |k| <= 8, Hermitian half (98 complex modes), omega_hat / N_sim^2
13
+ hidden: the unresolved vorticity 8 < |k| <= 64 of the initial condition
14
+ time_grid: {n_snapshots: 41, dt: 0.025, T: 1.0}
15
+ package: {vecs_train: [80, 200, 41, 196], vecs_eval: [20, 100, 41, 196], vecs_new: [720, 20, 41, 196]}
16
+ splits:
17
+ train: 80 conditions x 200 realizations (vecs_train); vecs_new holds 720 further conditions x 20 realizations, unused by the paper's models
18
+ val: 10 conditions x 100 realizations
19
+ test: 10 conditions x 100 realizations in the package; 10,000 realizations each in the truth bank
20
+ normalization: per-coordinate mean and std of the 80 training conditions x realizations j < 20 x all 41 snapshots
21
+ fid_adapter: {name: euler_coarse_vorticity, params: {n_disp: 128, k_c: 8}}
code/configs/evaluation.yaml ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Evaluation settings shared by the five systems: csFID-10k (the paper's image metric) and the sampling of the test read.
2
+
3
+ fid:
4
+ n_truth: 10000 # truth realizations per test condition (the first 10,000 members of the truth bank)
5
+ n_generated: 10000 # generated samples per model and test condition
6
+ backend: clean-fid
7
+ clean_fid_version: "0.1.35" # checked at run time
8
+ mode: clean # clean-fid "clean" mode: one PIL bicubic resize to 299 x 299
9
+ inception:
10
+ file: inception-2015-12-05.pt # the TF Inception-v3 weights distributed with clean-fid (sha256 checked)
11
+ sha256: f58cb9b6ec323ed63459aa4fb441fe750cfe39fafad6da5cb504a16f19e958f4
12
+ feature_layer: pool3 # 2048-D, used by cFID / cKID
13
+ resize: {size: 299, filter: bicubic, backend: PIL, antialias: true, once: true}
14
+ kid: {num_subsets: 100, max_subset_size: 1000}
15
+ clip_quantile: 0.9999 # image range: c = Q_0.9999(|x|) over the training fields (records/<system>/fid_adapter.json)
16
+ feature_batch: 200
17
+ primary_metric: csfid # csFID: the Frechet distance of the spatial Inception feature below
18
+ secondary_metrics: [cfid, ckid]
19
+ spatial:
20
+ layer: mixed_6/conv # the first branch of the mixed_6 block, before concatenation (192 x 17 x 17)
21
+ channels: 7 # the first 7 channels
22
+ grid: 17
23
+ dim: 2023 # 17 * 17 * 7, flattened NHWC, no spatial pooling
24
+ flatten: NHWC
25
+ pooling: none
26
+ reference: "Nash et al., ICML 2021; openai/guided-diffusion evaluations/evaluator.py FID_SPATIAL_NAME"
27
+
28
+ sampling:
29
+ crn_base_seed: 777 # the noise of test condition gid is drawn from the stream seeded with 777 * 100003 + gid
30
+ sample_chunk: 2000 # the 10,000 samples of a condition are drawn in chunks of 2,000 from that stream
code/configs/nlkg/dataset.yaml ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Nonlinear Klein-Gordon (2-D, cubic, defocusing; Dedalus): the package `nlkg_v3_candidate`, 392-D coarse state of
2
+ # (u, v/4). Set NLKG_DATA and NLKG_TRUTH_BANK. The ten test conditions are only in the truth bank (their x_0 is read
3
+ # from it).
4
+ name: nlkg_v3_candidate
5
+ system: nlkg
6
+ data: ${NLKG_DATA}
7
+ truth_bank: ${NLKG_TRUTH_BANK}
8
+ expected_sha256: {trajectories_fine.npz: 258cdda0a2707a494d2f4bb34ae96b2074ebbe70c0960e7f586702d771ac8a22, split.json: 95c8d315d18dd94a8cc32c2d810e00d69e374a338a315ce2dab9858d77566de3,
9
+ normalization.npz: 94e78f3fc04a40b3f71c883b97cf492c3763853c8432736112ec0167e30543ca}
10
+ state:
11
+ dim: 392
12
+ observed: the coarse operator (|k| <= 8, Hermitian half, [Re, Im]) applied to u and to v/4; the image metric reads u
13
+ hidden: equipartition modes 8 < |k| <= 24 of the initial displacement (rms 0.20)
14
+ physics: {equation: 'u_t = v, v_t = c^2 lap(u) - m^2 u - lambda u^3', c: 1.0, m: 1.0, lambda: 64.0, L: 6.283185307179586,
15
+ n: 128, dt: 0.0025, hidden_rms: 0.2, v_scale: 4.0, T: 12.0}
16
+ time_grid: {n_snapshots: 41, dt: 0.3, T: 12.0}
17
+ package: {vecs_train: [80, 200, 41, 392], vecs_eval: [20, 200, 41, 392]}
18
+ splits:
19
+ train: conditions 0-79 x 200 realizations
20
+ val: conditions 80-89 x 200 realizations
21
+ test: conditions 100-109, only in the truth bank (10,000 realizations each)
22
+ normalization: per-coordinate mean and std of the 80 training conditions x realizations j < 20 x all 41 snapshots
23
+ fid_adapter: {name: nlkg_coarse_u, params: {n_disp: 128, k_c: 8}}
code/configs/passive_scalar/dataset.yaml ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Passive scalar (advection-diffusion in a fixed cellular flow): the package `passive_scalar_v1`. Set PASSIVE_SCALAR_DATA
2
+ # and PASSIVE_SCALAR_TRUTH_BANK.
3
+ name: passive_scalar_v1
4
+ system: passive_scalar
5
+ data: ${PASSIVE_SCALAR_DATA}
6
+ truth_bank: ${PASSIVE_SCALAR_TRUTH_BANK}
7
+ expected_sha256: {split.json: fb8ca7914199c2ae19ce61c0a6baed8736c98f2db77e19f5fa128003a2979081, normalization.npz: 7f79196a94bba0509e1a307df39a4f572a1f8100dbd5ba9aad82f53aae02095e,
8
+ trajectories_fine.npz: 7a810b232414307ade1cebc0ecff4961d381561e400e034fc356cbf12f764a52}
9
+ state:
10
+ dim: 196
11
+ observed: coarse spectral scalar, |k| <= 8, Hermitian half (98 complex modes), theta_hat / N^2
12
+ hidden: the unresolved part of the initial scalar field, advected by the fixed cellular flow
13
+ physics: {K: 2, V: 0.5, kappa: 6.18415427504503e-06, N: 512, m_substeps: 6}
14
+ time_grid: {n_snapshots: 41, dt: 0.025, T: 1.0}
15
+ package: {vecs_train: [80, 400, 41, 196], vecs_eval: [20, 4096, 41, 196], vecs_new: [1520, 20, 41, 196]}
16
+ splits:
17
+ train: 80 conditions x 400 realizations (the paper's models read the first 200); vecs_new holds 1,520 further conditions x 20 realizations, unused
18
+ val: 10 conditions x 4,096 realizations
19
+ test: 10 conditions (gid 0 is one of them) x 4,096 realizations in the package; 10,000 each in the truth bank
20
+ normalization: per-coordinate mean and std of the 80 training conditions x realizations j < 20 x all 41 snapshots
21
+ fid_adapter: {name: passive_scalar_coarse, params: {n_disp: 128, k_c: 8}}
code/configs/sqg/dataset.yaml ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Surface quasi-geostrophy (pyqg): the package `sqg_v3_candidate_v2`. Set SQG_DATA and SQG_TRUTH_BANK.
2
+ #
3
+ name: sqg_v3_candidate_v2
4
+ system: sqg
5
+ data: ${SQG_DATA}
6
+ truth_bank: ${SQG_TRUTH_BANK}
7
+ expected_sha256: {trajectories_fine.npz: 510b027125b30065489edbae1d0ac23c3cb15765630be4113c119af993c0dee4, split.json: fb482ffd0353ba47dfca67a34466f7026278962ca9c8fb44839aa039935d08e2,
8
+ normalization.npz: 46f94fc794069c9ad985d23ae4cc2b8ed866fe13d6a0a5463f1c8384352898f8}
9
+ state:
10
+ dim: 196
11
+ observed: coarse spectral surface buoyancy b, |k| <= 8, Hermitian half (98 complex modes), b_hat / N^2
12
+ hidden: a Gaussian buoyancy field on 8 < |k| <= 32 with |b_k|^2 ~ k^-5/3 and rms 0.1, drawn per realization
13
+ physics: {solver: pyqg.SQGModel, nx: 256, dt: 0.005, L: 2*pi, H: 1.0, Nb: 1.0, f_0: 1.0, beta: 0.0, rek: 0.0, T_phys: 20.0}
14
+ time_grid: {n_snapshots: 41, dt: 0.025, T: 1.0, note: benchmark time 1.0 = 20 SQG time units}
15
+ package: {vecs_train: [80, 200, 41, 196], vecs_eval: [20, 200, 41, 196]}
16
+ splits:
17
+ train: 80 conditions x 200 realizations
18
+ val: 10 conditions x 200 realizations
19
+ test: conditions 100-109, 200 realizations in the package; 10,000 each in the truth bank
20
+ normalization: per-coordinate mean and std of the 80 training conditions x realizations j < 20 x all 41 snapshots
21
+ fid_adapter: {name: passive_scalar_coarse, params: {n_disp: 128, k_c: 8}}
code/configs/waves/dataset.yaml ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Heterogeneous waves: the package `heterogeneous_waves_v3_candidate` (augmented state (u, v), 392-D). Set WAVES_DATA
2
+ # and WAVES_TRUTH_BANK. The ten test conditions are only in the truth bank (their x_0 is read from it).
3
+ #
4
+ name: heterogeneous_waves_v3_candidate
5
+ system: heterogeneous_waves
6
+ data: ${WAVES_DATA}
7
+ truth_bank: ${WAVES_TRUTH_BANK}
8
+ expected_sha256: {trajectories_fine.npz: 4211aa62b2e6e61066519396e20bdf0a3b002b02a27641dd39031cbb680c9e53, split.json: ccb5fb3fba13a8f0e5528c3757fb54ba4044ca180328da99a53b58fbf6b8e88c,
9
+ normalization.npz: 23123f0b0547197bbd6275ac4a34d894a91ab98e4f1810b80416f6b891d67cb7, u_norm/normalization.npz: 55e9a00ddcf36eece80a88bf1f012026f4934989ae8a2969b1cdcb5c78228d2a,
10
+ v_norm/normalization.npz: 54441e3917067932261ac67dacb3b15dd76458147d9ff41775d1148b20b06ddf}
11
+ state:
12
+ dim: 392
13
+ observed: coarse displacement u (196-D, |k| <= 8) and the finite-difference coarse velocity v (196-D); the image metric reads u
14
+ hidden: the velocity and the band 8 < |k| <= 16 of the initial condition
15
+ physics: {equation: 'u_t = v, v_t = div(c^2 grad u) - gamma v', c2: '1 + 0.5 sin(2 pi x) sin(2 pi y)'}
16
+ time_grid: {n_snapshots: 41, dt: 0.025, T: 1.0}
17
+ package: {vecs_train: [80, 200, 41, 392], vecs_eval: [10, 200, 41, 392]}
18
+ splits:
19
+ train: 80 conditions x 200 realizations
20
+ val: 10 conditions x 200 realizations
21
+ test: 10 conditions, only in the truth bank (10,000 realizations each)
22
+ normalization: per-coordinate mean and std of the 80 training conditions x realizations j < 20 x all 41 snapshots, fitted separately for u (u_norm/) and v (v_norm/); normalization.npz is their concatenation
23
+ fid_adapter: {name: heterogeneous_waves_augmented_coarse, params: {}}