Add anonymous reproduction code
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- code/.gitignore +7 -0
- code/LICENSE +21 -0
- code/README.md +93 -0
- code/bifurcation/__init__.py +3 -0
- code/bifurcation/config.py +100 -0
- code/bifurcation/data/__init__.py +0 -0
- code/bifurcation/data/fine.py +548 -0
- code/bifurcation/data/loaders.py +51 -0
- code/bifurcation/data/manifests.py +124 -0
- code/bifurcation/fid/__init__.py +15 -0
- code/bifurcation/fid/adapters.py +153 -0
- code/bifurcation/fid/backend.py +206 -0
- code/bifurcation/fid/euler.py +58 -0
- code/bifurcation/fid/evaluate.py +224 -0
- code/bifurcation/fid/heterogeneous_waves.py +59 -0
- code/bifurcation/fid/heterogeneous_waves_augmented.py +39 -0
- code/bifurcation/fid/nlkg.py +65 -0
- code/bifurcation/fid/passive_scalar.py +34 -0
- code/bifurcation/methods/__init__.py +3 -0
- code/bifurcation/methods/base.py +69 -0
- code/bifurcation/methods/cascaded_si/__init__.py +3 -0
- code/bifurcation/methods/cascaded_si/core.py +133 -0
- code/bifurcation/methods/cascaded_si/method.py +66 -0
- code/bifurcation/methods/direct_si/__init__.py +3 -0
- code/bifurcation/methods/direct_si/core.py +86 -0
- code/bifurcation/methods/direct_si/method.py +54 -0
- code/bifurcation/methods/edm/__init__.py +3 -0
- code/bifurcation/methods/edm/core.py +205 -0
- code/bifurcation/methods/edm/method.py +74 -0
- code/bifurcation/metrics.py +41 -0
- code/bifurcation/models/__init__.py +0 -0
- code/bifurcation/models/_common.py +16 -0
- code/bifurcation/models/spectral.py +97 -0
- code/bifurcation/models/unet2.py +248 -0
- code/bifurcation/models/unet2_2comp.py +87 -0
- code/bifurcation/models/unet2_5ch.py +50 -0
- code/bifurcation/models/unet2_5ch_coord.py +45 -0
- code/bifurcation/models/unet2_5ch_coord_aug.py +110 -0
- code/bifurcation/models/unet2_coord.py +87 -0
- code/bifurcation/registry.py +127 -0
- code/bifurcation/sample.py +142 -0
- code/bifurcation/train.py +115 -0
- code/bifurcation/trainer.py +371 -0
- code/bifurcation/utils.py +27 -0
- code/configs/euler/dataset.yaml +21 -0
- code/configs/evaluation.yaml +30 -0
- code/configs/nlkg/dataset.yaml +23 -0
- code/configs/passive_scalar/dataset.yaml +21 -0
- code/configs/sqg/dataset.yaml +21 -0
- 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: {}}
|