yushuang88 commited on
Commit
c92f17c
·
verified ·
1 Parent(s): b173e4e

Upload folder using huggingface_hub

Browse files
README.md ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ language:
4
+ - en
5
+ tags:
6
+ - OneScience
7
+ - fluid-mechanics
8
+ - flow-field-prediction
9
+ - graph-neural-network
10
+ - partial-differential-equations
11
+ frameworks: PyTorch
12
+ ---
13
+ <p align="center">
14
+ <strong>
15
+ <span style="font-size: 30px;">MP_PDE</span>
16
+ </strong>
17
+ </p>
18
+
19
+ # Model Introduction
20
+
21
+ MP-PDE (Message Passing Neural PDE Solver) is a neural PDE solver based on message passing, proposed by researchers from the University of Amsterdam and other institutions and published at ICLR 2022. It targets the spatiotemporal prediction of parameterized partial differential equations (PDEs). By learning local interactions between spatial nodes, it predicts numerical PDE solutions and adapts well to different equation parameters, spatial resolutions, and mesh structures.
22
+
23
+ Using the OneScience skill workflow, this project independently reproduces the E3 parameterized PDE prediction experiment from the MP-PDE paper to evaluate spatiotemporal evolution predictions under different PDE parameter settings.
24
+
25
+ Paper: [Message Passing Neural PDE Solvers](https://arxiv.org/abs/2202.03376)
26
+
27
+ # Model Description
28
+
29
+ MP-PDE uses an encoder-processor-decoder architecture consisting of an MLP encoder, a multilayer message-passing graph neural network (GNN processor), and a one-dimensional CNN decoder. It encodes PDE states from a historical time window together with spatial coordinates and equation parameters as node features. Multiple message-passing layers learn local spatial relationships, while temporal bundling predicts several future time steps at once for autoregressive spatiotemporal PDE forecasting.
30
+
31
+ ## Use Cases
32
+
33
+ | Use case | Description |
34
+ | --- | --- |
35
+ | Cross-resolution PDE prediction | Graph structures describe relationships between spatial nodes with less dependence on fixed convolutional grids, supporting training and generalization across spatial discretization resolutions. |
36
+ | Irregular-mesh computation | Message passing uses relative spatial positions between nodes and can handle nonuniform or irregular spatial discretizations, as demonstrated in the paper's wave-equation experiment. |
37
+ | Long-horizon autoregressive prediction | Techniques such as temporal bundling and the pushforward trick reduce error accumulation during autoregressive prediction and improve the stability of long PDE rollouts. |
38
+
39
+ # Usage
40
+
41
+ ## 1. Using OneCode
42
+
43
+ Try intelligent, one-click AI4S programming in the OneCode online environment:
44
+
45
+ [Try intelligent, one-click AI4S programming](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
46
+
47
+ ## 2. Manual Installation and Usage
48
+
49
+ **Hardware requirements**
50
+
51
+ - A GPU or DCU is recommended.
52
+ - A CPU can be used for import checks and small-scale connectivity tests, but full training and inference will be slow.
53
+ - DCU users must install DTK in advance. DTK 25.04.2 or later, or the OneScience-recommended version for the current cluster, is recommended.
54
+
55
+ ### Download the Model Package
56
+
57
+ ```bash
58
+ modelscope download --model OneScience/MP_PDE --local_dir ./MP_PDE
59
+ cd MP_PDE
60
+ ```
61
+
62
+ ### Set Up the Runtime Environment
63
+
64
+ **DCU environment**
65
+
66
+ ```bash
67
+ # Activate DTK and Conda first
68
+ conda create -n onescience311 python=3.11 -y
69
+ conda activate onescience311
70
+ # Installation with uv is also supported
71
+ pip install onescience[cfd-dcu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
72
+ ```
73
+
74
+ **GPU environment**
75
+
76
+ ```bash
77
+ # Activate Conda first
78
+ conda create -n onescience311 python=3.11 -y libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
79
+ conda activate onescience311
80
+ # Installation with uv is also supported
81
+ pip install onescience[cfd-gpu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
82
+ ```
83
+
84
+ ### Training Data
85
+
86
+ This project does not depend on an external dataset. Training data is synthesized on the fly by `models/dataset.py` according to `config.yaml`. The default input function $u$ is sampled from a zero-mean Gaussian random field (GRF) with covariance kernel:
87
+
88
+ $$
89
+ k_l(x_1,x_2)
90
+ =
91
+ \exp\left(
92
+ -\frac{\lVert x_1-x_2\rVert^2}{2l^2}
93
+ \right),
94
+ $$
95
+
96
+ where the correlation length is $l=0.2$. Input functions are first generated on a fine grid of 1,000 points, then sampled at 100 equally spaced sensor locations using cubic interpolation. These discrete samples are used as the Branch Net input in MP_PDE.
97
+
98
+ Each supervised-learning sample is represented as:
99
+
100
+ $$
101
+ \left(u,\ y,\ G(u)(y)\right),
102
+ $$
103
+
104
+ where:
105
+
106
+ - $u$: discrete values of the input function at 100 sensor locations;
107
+ - $y$: the query location to predict—one-dimensional coordinates for ODE experiments and two-dimensional spatiotemporal coordinates $(x,t)$ for PDE experiments;
108
+ - $G(u)(y)$: the reference solution computed by the corresponding differential-equation solver, representing the output of operator $G$ applied to input function $u$ at location $y$.
109
+
110
+ ### Training
111
+
112
+ The default configuration reproduces the E3 parameterized PDE experiment from the MP-PDE paper. Training data is generated on the fly by `models/dataset.py` according to `config/config.yaml`, and the model uses a historical window of 25 time steps to predict the next 25 steps.
113
+
114
+ Run E3 training with:
115
+
116
+ ```bash
117
+ python scripts/train.py \
118
+ --config config/config.yaml \
119
+ --generate-data \
120
+ --device auto
121
+ ```
122
+
123
+ ### Trained Weights
124
+
125
+ `weight/best_model.pth` contains the best weights from the full training run and can be used directly for inference or fine-tuning.
126
+
127
+ ### Inference
128
+
129
+ Before running inference, make sure the E3 dataset `data/e3.h5` and trained weights `weight/best_model.pth` exist.
130
+
131
+ ```bash
132
+ python scripts/inference.py \
133
+ --config config/config.yaml \
134
+ --device auto
135
+ ```
136
+
137
+ ### Evaluation and Visualization
138
+
139
+ After training and inference, generate prediction visualizations with:
140
+
141
+ ```bash
142
+ python scripts/result.py \
143
+ --config config/config.yaml
144
+ ```
145
+
146
+ # Official OneScience Resources
147
+
148
+ | Platform | OneScience Main Repository | Skills Repository |
149
+ | --- | --- | --- |
150
+ | Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
151
+ | GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
152
+
153
+ # Citation and License
154
+
155
+ - Original paper: [Message Passing Neural PDE Solvers](https://arxiv.org/abs/2202.03376)
156
+ - This project is an independent reproduction of the MP_PDE paper. The project code, model weights, training data, and third-party dependencies remain subject to their respective license terms.
config/config.yaml ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ experiment:
2
+ name: E3
3
+ model: MP-PDE
4
+ paper: https://arxiv.org/abs/2202.03376
5
+ evidence_priority: paper_then_official_repo_for_missing_details
6
+ ambiguity_policy: paper_strict # paper_strict: omega=[-0.4,-0.4]; official_consistency: [-0.4,0.4]
7
+
8
+ paths:
9
+ data: data/e3.h5
10
+ checkpoint: weight/best_model.pth
11
+ results: results
12
+ train_history: results/train_history.json
13
+ predictions: results/predictions.npz
14
+ metrics: results/metrics.json
15
+
16
+ data:
17
+ domain_length: 16.0
18
+ final_time: 4.0
19
+ num_time_points: 250 # E3 Table 1; nearby prose says 200
20
+ high_resolution_nx: 200
21
+ resolution: 100
22
+ supported_resolutions: [100, 50, 40]
23
+ sample_fraction: 0.3333333333333333 # user-authorized reduced run, 784/2352 trajectories exactly
24
+ full_experiment_samples:
25
+ train: 2096 # paper; official README says 2048
26
+ valid: 128 # paper missing; official README reference
27
+ test: 128 # paper missing; official README reference
28
+ train_samples: 698
29
+ valid_samples: 43
30
+ test_samples: 43
31
+ seed: 0
32
+ equation:
33
+ alpha_range: [0.0, 3.0]
34
+ beta_range: [0.0, 0.4]
35
+ gamma_range: [0.0, 1.0]
36
+ forcing:
37
+ terms: 5
38
+ amplitude_range: [-0.5, 0.5]
39
+ paper_omega_range: [-0.4, -0.4]
40
+ official_consistency_omega_range: [-0.4, 0.4]
41
+ modes: [1, 2, 3]
42
+ phase_range: [0.0, 6.283185307179586]
43
+ generation:
44
+ cfl: 0.2
45
+ min_dt: 1.0e-8
46
+ max_substeps: 10000
47
+ weno_epsilon: 1.0e-6
48
+ compression: gzip
49
+ compression_level: 4
50
+ parallel_generation:
51
+ workers: 8
52
+ max_in_flight: 16
53
+ flush_every: 8
54
+ resume_partial: true
55
+
56
+ model:
57
+ time_window: 25 # paper missing; official training command reference
58
+ hidden_dim: 164
59
+ message_passing_layers: 6
60
+ neighbor_offsets: [-3, -2, -1, 1, 2, 3]
61
+ aggregation: sum
62
+ instance_norm_affine: false
63
+ decoder:
64
+ middle_channels: 8
65
+ kernel_sizes: [16, 26]
66
+ strides: [3, 1]
67
+ scaling:
68
+ coordinates: true
69
+ parameters: true
70
+ solution: none
71
+
72
+ training:
73
+ epochs: 20
74
+ batch_size: 16
75
+ num_workers: 0
76
+ optimizer: AdamW
77
+ learning_rate: 1.0e-4
78
+ weight_decay: 1.0e-8
79
+ loss: rmse
80
+ loss_epsilon: 1.0e-12
81
+ max_no_grad_pushes: 1
82
+ scheduler:
83
+ enabled: true
84
+ milestones: [1, 5, 10, 15]
85
+ gamma: 0.4
86
+ precision: float32
87
+ amp: false
88
+ gradient_clip: null
89
+ seed: 0
90
+ deterministic: true
91
+ device: auto
92
+ print_interval: 10
93
+ validation_rollout_samples: null
94
+ test_rollout_samples: null
95
+
96
+ inference:
97
+ batch_size: 16
98
+ split: test
99
+ max_samples: null
100
+ device: auto
101
+ forecast_start_index: 25
102
+
103
+ visualization:
104
+ sample_index: 0
105
+ time_indices: [25, 75, 150, 249]
106
+ dpi: 160
107
+
108
+ paper_reference:
109
+ metric: forecast_accumulated_mse
110
+ values:
111
+ 100: 4.26
112
+ 50: 3.74
113
+ 40: 3.70
114
+ note: provenance_only_not_an_assertion
models/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Independent MP-PDE E3 reproduction components."""
2
+
3
+ from .dataset import E3Dataset, generate_e3_hdf5
4
+ from .pde import MPPDESolver, periodic_neighbor_indices
5
+
6
+ __all__ = ["E3Dataset", "MPPDESolver", "generate_e3_hdf5", "periodic_neighbor_indices"]
models/dataset.py ADDED
@@ -0,0 +1,572 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """E3 trajectory generation and HDF5 loading for MP-PDE.
2
+
3
+ This is an independent implementation from the equations and numerical-method
4
+ description in arXiv:2202.03376. No official repository source is used.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ from concurrent.futures import FIRST_COMPLETED, ProcessPoolExecutor, wait
11
+ import json
12
+ import multiprocessing
13
+ import os
14
+ from pathlib import Path
15
+ from typing import Any, Dict, Iterable, Mapping, Optional, Tuple
16
+
17
+ import h5py
18
+ import numpy as np
19
+ import torch
20
+ import yaml
21
+ from torch.utils.data import Dataset
22
+
23
+
24
+ SPLITS = ("train", "valid", "test")
25
+ PARAMETER_ORDER = ("alpha", "beta", "gamma")
26
+ PARALLEL_SCHEMA_VERSION = "mp_pde_e3_parallel_resume_v1"
27
+ _WORKER_X: Optional[np.ndarray] = None
28
+ _WORKER_TIMES: Optional[np.ndarray] = None
29
+ _WORKER_CONFIG: Optional[Dict[str, Any]] = None
30
+ _WORKER_STRIDE: Optional[int] = None
31
+
32
+
33
+ def _shift(values: np.ndarray, offset: int) -> np.ndarray:
34
+ """Return values[i + offset] under periodic indexing."""
35
+ return np.roll(values, -offset, axis=-1)
36
+
37
+
38
+ def _weno5_left(values: np.ndarray, epsilon: float) -> np.ndarray:
39
+ """Fifth-order WENO left state at every i+1/2 interface."""
40
+ um2, um1, u0 = _shift(values, -2), _shift(values, -1), values
41
+ up1, up2 = _shift(values, 1), _shift(values, 2)
42
+ p0 = (2.0 * um2 - 7.0 * um1 + 11.0 * u0) / 6.0
43
+ p1 = (-um1 + 5.0 * u0 + 2.0 * up1) / 6.0
44
+ p2 = (2.0 * u0 + 5.0 * up1 - up2) / 6.0
45
+ b0 = (13.0 / 12.0) * (um2 - 2.0 * um1 + u0) ** 2 + 0.25 * (um2 - 4.0 * um1 + 3.0 * u0) ** 2
46
+ b1 = (13.0 / 12.0) * (um1 - 2.0 * u0 + up1) ** 2 + 0.25 * (um1 - up1) ** 2
47
+ b2 = (13.0 / 12.0) * (u0 - 2.0 * up1 + up2) ** 2 + 0.25 * (3.0 * u0 - 4.0 * up1 + up2) ** 2
48
+ alpha = np.stack((0.1 / (epsilon + b0) ** 2, 0.6 / (epsilon + b1) ** 2, 0.3 / (epsilon + b2) ** 2))
49
+ weights = alpha / np.sum(alpha, axis=0, keepdims=True)
50
+ return weights[0] * p0 + weights[1] * p1 + weights[2] * p2
51
+
52
+
53
+ def _weno5_right(values: np.ndarray, epsilon: float) -> np.ndarray:
54
+ """Fifth-order WENO right state at every i+1/2 interface."""
55
+ um1, u0 = _shift(values, -1), values
56
+ up1, up2, up3 = _shift(values, 1), _shift(values, 2), _shift(values, 3)
57
+ p0 = (2.0 * up3 - 7.0 * up2 + 11.0 * up1) / 6.0
58
+ p1 = (-up2 + 5.0 * up1 + 2.0 * u0) / 6.0
59
+ p2 = (2.0 * up1 + 5.0 * u0 - um1) / 6.0
60
+ b0 = (13.0 / 12.0) * (up1 - 2.0 * up2 + up3) ** 2 + 0.25 * (3.0 * up1 - 4.0 * up2 + up3) ** 2
61
+ b1 = (13.0 / 12.0) * (u0 - 2.0 * up1 + up2) ** 2 + 0.25 * (u0 - up2) ** 2
62
+ b2 = (13.0 / 12.0) * (um1 - 2.0 * u0 + up1) ** 2 + 0.25 * (um1 - 4.0 * u0 + 3.0 * up1) ** 2
63
+ alpha = np.stack((0.1 / (epsilon + b0) ** 2, 0.6 / (epsilon + b1) ** 2, 0.3 / (epsilon + b2) ** 2))
64
+ weights = alpha / np.sum(alpha, axis=0, keepdims=True)
65
+ return weights[0] * p0 + weights[1] * p1 + weights[2] * p2
66
+
67
+
68
+ def godunov_quadratic_flux(left: np.ndarray, right: np.ndarray) -> np.ndarray:
69
+ """Godunov flux for the convex scalar flux f(u)=u**2."""
70
+ left_flux, right_flux = left * left, right * right
71
+ rarefaction = left <= right
72
+ rare_flux = np.where((left <= 0.0) & (right >= 0.0), 0.0, np.minimum(left_flux, right_flux))
73
+ shock_flux = np.maximum(left_flux, right_flux)
74
+ return np.where(rarefaction, rare_flux, shock_flux)
75
+
76
+
77
+ def weno5_flux_derivative(values: np.ndarray, dx: float, epsilon: float = 1.0e-6) -> np.ndarray:
78
+ """Conservative derivative d_x(u**2) on a periodic uniform grid."""
79
+ interface_flux = godunov_quadratic_flux(_weno5_left(values, epsilon), _weno5_right(values, epsilon))
80
+ return (interface_flux - np.roll(interface_flux, 1, axis=-1)) / dx
81
+
82
+
83
+ def fourth_order_second_derivative(values: np.ndarray, dx: float) -> np.ndarray:
84
+ return (-_shift(values, 2) + 16.0 * _shift(values, 1) - 30.0 * values + 16.0 * _shift(values, -1) - _shift(values, -2)) / (12.0 * dx**2)
85
+
86
+
87
+ def fourth_order_third_derivative(values: np.ndarray, dx: float) -> np.ndarray:
88
+ return (_shift(values, -3) - 8.0 * _shift(values, -2) + 13.0 * _shift(values, -1) - 13.0 * _shift(values, 1) + 8.0 * _shift(values, 2) - _shift(values, 3)) / (8.0 * dx**3)
89
+
90
+
91
+ def sample_e3_parameters(rng: np.random.Generator, cfg: Mapping[str, Any]) -> Tuple[np.ndarray, Dict[str, np.ndarray]]:
92
+ equation, forcing = cfg["equation"], cfg["forcing"]
93
+ params = np.array(
94
+ [rng.uniform(*equation["alpha_range"]), rng.uniform(*equation["beta_range"]), rng.uniform(*equation["gamma_range"])],
95
+ dtype=np.float64,
96
+ )
97
+ policy = cfg["ambiguity_policy"]
98
+ if policy == "paper_strict":
99
+ omega_range = forcing["paper_omega_range"]
100
+ elif policy == "official_consistency":
101
+ omega_range = forcing["official_consistency_omega_range"]
102
+ else:
103
+ raise ValueError(f"Unknown ambiguity_policy={policy!r}")
104
+ terms = int(forcing["terms"])
105
+ provenance = {
106
+ "amplitude": rng.uniform(*forcing["amplitude_range"], size=terms),
107
+ "omega": rng.uniform(*omega_range, size=terms),
108
+ "mode": rng.choice(np.asarray(forcing["modes"], dtype=np.int64), size=terms),
109
+ "phase": rng.uniform(*forcing["phase_range"], size=terms),
110
+ }
111
+ return params, provenance
112
+
113
+
114
+ def evaluate_forcing(time: float, x: np.ndarray, forcing: Mapping[str, np.ndarray], domain_length: float) -> np.ndarray:
115
+ phase = (
116
+ forcing["omega"][:, None] * time
117
+ + 2.0 * np.pi * forcing["mode"][:, None] * x[None, :] / domain_length
118
+ + forcing["phase"][:, None]
119
+ )
120
+ return np.sum(forcing["amplitude"][:, None] * np.sin(phase), axis=0)
121
+
122
+
123
+ def e3_rhs(
124
+ time: float,
125
+ state: np.ndarray,
126
+ x: np.ndarray,
127
+ params: np.ndarray,
128
+ forcing: Mapping[str, np.ndarray],
129
+ domain_length: float,
130
+ weno_epsilon: float,
131
+ ) -> np.ndarray:
132
+ alpha, beta, gamma = params
133
+ dx = domain_length / state.shape[-1]
134
+ return (
135
+ evaluate_forcing(time, x, forcing, domain_length)
136
+ - alpha * weno5_flux_derivative(state, dx, weno_epsilon)
137
+ + beta * fourth_order_second_derivative(state, dx)
138
+ - gamma * fourth_order_third_derivative(state, dx)
139
+ )
140
+
141
+
142
+ def _stable_step(state: np.ndarray, params: np.ndarray, dx: float, cfl: float) -> float:
143
+ alpha, beta, gamma = np.abs(params)
144
+ limits = []
145
+ wave_speed = 2.0 * alpha * float(np.max(np.abs(state)))
146
+ if wave_speed > 1.0e-14:
147
+ limits.append(dx / wave_speed)
148
+ if beta > 1.0e-14:
149
+ limits.append(dx**2 / (2.0 * beta))
150
+ if gamma > 1.0e-14:
151
+ limits.append(dx**3 / (6.0 * gamma))
152
+ return cfl * min(limits) if limits else np.inf
153
+
154
+
155
+ def generate_trajectory(
156
+ x: np.ndarray,
157
+ save_times: np.ndarray,
158
+ params: np.ndarray,
159
+ forcing: Mapping[str, np.ndarray],
160
+ cfg: Mapping[str, Any],
161
+ ) -> np.ndarray:
162
+ """Integrate one trajectory using RK4 and stability-limited substeps."""
163
+ domain_length = float(cfg["domain_length"])
164
+ generation = cfg["generation"]
165
+ dx = domain_length / x.size
166
+ state = evaluate_forcing(float(save_times[0]), x, forcing, domain_length).astype(np.float64)
167
+ trajectory = np.empty((save_times.size, x.size), dtype=np.float32)
168
+ trajectory[0] = state
169
+ current_time = float(save_times[0])
170
+ for output_index, target_time in enumerate(save_times[1:], start=1):
171
+ substeps = 0
172
+ while current_time < float(target_time) - 1.0e-14:
173
+ stable = _stable_step(state, params, dx, float(generation["cfl"]))
174
+ remaining = float(target_time) - current_time
175
+ step = min(stable, remaining)
176
+ if step < float(generation["min_dt"]) and remaining > float(generation["min_dt"]):
177
+ raise RuntimeError(f"Stable RK4 step {step:.3e} fell below min_dt at t={current_time:.6g}")
178
+ step = remaining if remaining <= float(generation["min_dt"]) else step
179
+ rhs_args = (x, params, forcing, domain_length, float(generation["weno_epsilon"]))
180
+ k1 = e3_rhs(current_time, state, *rhs_args)
181
+ k2 = e3_rhs(current_time + 0.5 * step, state + 0.5 * step * k1, *rhs_args)
182
+ k3 = e3_rhs(current_time + 0.5 * step, state + 0.5 * step * k2, *rhs_args)
183
+ k4 = e3_rhs(current_time + step, state + step * k3, *rhs_args)
184
+ state = state + (step / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4)
185
+ current_time += step
186
+ substeps += 1
187
+ if substeps > int(generation["max_substeps"]):
188
+ raise RuntimeError(f"max_substeps exceeded while advancing to t={target_time:.6g}")
189
+ if not np.all(np.isfinite(state)):
190
+ raise FloatingPointError(f"Non-finite E3 state at t={current_time:.6g}")
191
+ current_time = float(target_time)
192
+ trajectory[output_index] = state
193
+ return trajectory
194
+
195
+
196
+ def _generation_config(config: Mapping[str, Any]) -> Dict[str, Any]:
197
+ data = config["data"]
198
+ return {
199
+ "ambiguity_policy": config["experiment"]["ambiguity_policy"],
200
+ "domain_length": data["domain_length"],
201
+ "equation": data["equation"],
202
+ "forcing": data["forcing"],
203
+ "generation": data["generation"],
204
+ }
205
+
206
+
207
+ def _initialize_generation_worker(
208
+ x_high: np.ndarray, times: np.ndarray, generation_cfg: Mapping[str, Any], stride: int
209
+ ) -> None:
210
+ """Initialize immutable state used by one trajectory worker process."""
211
+ global _WORKER_X, _WORKER_TIMES, _WORKER_CONFIG, _WORKER_STRIDE
212
+ _WORKER_X = x_high
213
+ _WORKER_TIMES = times
214
+ _WORKER_CONFIG = dict(generation_cfg)
215
+ _WORKER_STRIDE = int(stride)
216
+
217
+
218
+ def _generate_sample_worker(payload: Tuple[int, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]) -> Tuple[int, np.ndarray]:
219
+ """Generate one independent trajectory; HDF5 remains owned by the parent."""
220
+ if _WORKER_X is None or _WORKER_TIMES is None or _WORKER_CONFIG is None or _WORKER_STRIDE is None:
221
+ raise RuntimeError("Parallel E3 worker was not initialized")
222
+ sample_index, params, amplitude, omega, mode, phase = payload
223
+ forcing = {"amplitude": amplitude, "omega": omega, "mode": mode, "phase": phase}
224
+ trajectory = generate_trajectory(_WORKER_X, _WORKER_TIMES, params, forcing, _WORKER_CONFIG)
225
+ return sample_index, trajectory[:, ::_WORKER_STRIDE]
226
+
227
+
228
+ def _sample_payloads(group: h5py.Group, indices: Iterable[int]) -> Iterable[Tuple[int, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]]:
229
+ for sample_index in indices:
230
+ yield (
231
+ sample_index,
232
+ np.asarray(group["params"][sample_index], dtype=np.float64),
233
+ np.asarray(group["forcing_amplitude"][sample_index], dtype=np.float64),
234
+ np.asarray(group["forcing_omega"][sample_index], dtype=np.float64),
235
+ np.asarray(group["forcing_mode"][sample_index], dtype=np.int64),
236
+ np.asarray(group["forcing_phase"][sample_index], dtype=np.float64),
237
+ )
238
+
239
+
240
+ def _parallel_results(
241
+ payloads: Iterable[Tuple[int, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]],
242
+ workers: int,
243
+ max_in_flight: int,
244
+ x_high: np.ndarray,
245
+ times: np.ndarray,
246
+ generation_cfg: Mapping[str, Any],
247
+ stride: int,
248
+ ) -> Iterable[Tuple[int, np.ndarray]]:
249
+ """Yield completed trajectories while keeping the process queue bounded."""
250
+ if workers == 1:
251
+ _initialize_generation_worker(x_high, times, generation_cfg, stride)
252
+ for payload in payloads:
253
+ yield _generate_sample_worker(payload)
254
+ return
255
+
256
+ thread_variables = ("OMP_NUM_THREADS", "MKL_NUM_THREADS", "OPENBLAS_NUM_THREADS", "NUMEXPR_NUM_THREADS")
257
+ previous_environment = {name: os.environ.get(name) for name in thread_variables}
258
+ for name in thread_variables:
259
+ os.environ[name] = "1"
260
+ executor = ProcessPoolExecutor(
261
+ max_workers=workers,
262
+ mp_context=multiprocessing.get_context("spawn"),
263
+ initializer=_initialize_generation_worker,
264
+ initargs=(x_high, times, dict(generation_cfg), stride),
265
+ )
266
+ iterator = iter(payloads)
267
+ futures = set()
268
+ try:
269
+ for _ in range(max_in_flight):
270
+ try:
271
+ futures.add(executor.submit(_generate_sample_worker, next(iterator)))
272
+ except StopIteration:
273
+ break
274
+ while futures:
275
+ completed, futures = wait(futures, return_when=FIRST_COMPLETED)
276
+ for future in completed:
277
+ yield future.result()
278
+ try:
279
+ futures.add(executor.submit(_generate_sample_worker, next(iterator)))
280
+ except StopIteration:
281
+ pass
282
+ finally:
283
+ executor.shutdown(wait=True, cancel_futures=True)
284
+ for name, value in previous_environment.items():
285
+ if value is None:
286
+ os.environ.pop(name, None)
287
+ else:
288
+ os.environ[name] = value
289
+
290
+
291
+ def _generation_signature(
292
+ config: Mapping[str, Any], counts: Mapping[str, int], nt: int, high_nx: int, target_nx: int
293
+ ) -> str:
294
+ payload = {
295
+ "schema_version": PARALLEL_SCHEMA_VERSION,
296
+ "paper": str(config["experiment"]["paper"]),
297
+ "ambiguity_policy": str(config["experiment"]["ambiguity_policy"]),
298
+ "generation_config": _generation_config(config),
299
+ "counts": {name: int(counts[name]) for name in SPLITS},
300
+ "nt": int(nt),
301
+ "high_resolution_nx": int(high_nx),
302
+ "resolution": int(target_nx),
303
+ "seed": int(config["data"]["seed"]),
304
+ }
305
+ return json.dumps(payload, sort_keys=True, separators=(",", ":"))
306
+
307
+
308
+ def _initialize_partial_file(
309
+ partial_path: Path,
310
+ config: Mapping[str, Any],
311
+ counts: Mapping[str, int],
312
+ nt: int,
313
+ high_nx: int,
314
+ target_nx: int,
315
+ x: np.ndarray,
316
+ times: np.ndarray,
317
+ generation_cfg: Mapping[str, Any],
318
+ compression: Optional[str],
319
+ compression_opts: Optional[int],
320
+ signature: str,
321
+ ) -> None:
322
+ data = config["data"]
323
+ base_seed = int(data["seed"])
324
+ with h5py.File(partial_path, "w") as handle:
325
+ handle.attrs["schema_version"] = PARALLEL_SCHEMA_VERSION
326
+ handle.attrs["generation_signature"] = signature
327
+ handle.attrs["experiment"] = "MP-PDE E3"
328
+ handle.attrs["paper"] = str(config["experiment"]["paper"])
329
+ handle.attrs["ambiguity_policy"] = str(config["experiment"]["ambiguity_policy"])
330
+ handle.attrs["parameter_order"] = json.dumps(PARAMETER_ORDER)
331
+ handle.attrs["generation_config"] = json.dumps(generation_cfg, sort_keys=True)
332
+ handle.attrs["seed"] = base_seed
333
+ handle.attrs["high_resolution_nx"] = high_nx
334
+ handle.attrs["resolution"] = target_nx
335
+ for split_index, split in enumerate(SPLITS):
336
+ count = int(counts[split])
337
+ rng = np.random.default_rng(np.random.SeedSequence([base_seed, split_index]))
338
+ group = handle.create_group(split)
339
+ group.attrs["seed_derivation"] = json.dumps([base_seed, split_index])
340
+ group.create_dataset("x", data=x)
341
+ group.create_dataset("t", data=times.astype(np.float32))
342
+ group.create_dataset(
343
+ "u", shape=(count, nt, target_nx), dtype="f4", chunks=(1, min(nt, 32), target_nx),
344
+ compression=compression, compression_opts=compression_opts,
345
+ )
346
+ params_ds = group.create_dataset("params", shape=(count, 3), dtype="f4")
347
+ terms = int(data["forcing"]["terms"])
348
+ amplitude_ds = group.create_dataset("forcing_amplitude", shape=(count, terms), dtype="f4")
349
+ omega_ds = group.create_dataset("forcing_omega", shape=(count, terms), dtype="f4")
350
+ mode_ds = group.create_dataset("forcing_mode", shape=(count, terms), dtype="i8")
351
+ phase_ds = group.create_dataset("forcing_phase", shape=(count, terms), dtype="f4")
352
+ group.create_dataset("completed", shape=(count,), dtype="bool", data=np.zeros(count, dtype=bool))
353
+ for sample_index in range(count):
354
+ params, forcing = sample_e3_parameters(rng, generation_cfg)
355
+ params_ds[sample_index] = params
356
+ amplitude_ds[sample_index] = forcing["amplitude"]
357
+ omega_ds[sample_index] = forcing["omega"]
358
+ mode_ds[sample_index] = forcing["mode"]
359
+ phase_ds[sample_index] = forcing["phase"]
360
+ handle.flush()
361
+
362
+
363
+ def generate_e3_hdf5(
364
+ config: Mapping[str, Any],
365
+ output_path: Path | str,
366
+ *,
367
+ sample_counts: Optional[Mapping[str, int]] = None,
368
+ nt: Optional[int] = None,
369
+ high_resolution_nx: Optional[int] = None,
370
+ resolution: Optional[int] = None,
371
+ workers: Optional[int] = None,
372
+ max_in_flight: Optional[int] = None,
373
+ flush_every: Optional[int] = None,
374
+ resume_partial: Optional[bool] = None,
375
+ overwrite: bool = False,
376
+ ) -> Path:
377
+ """Generate E3 splits with process workers and a single resumable HDF5 writer."""
378
+ output_path = Path(output_path)
379
+ if output_path.exists() and not overwrite:
380
+ raise FileExistsError(f"Refusing to overwrite existing dataset: {output_path}")
381
+ output_path.parent.mkdir(parents=True, exist_ok=True)
382
+ partial_path = output_path.with_suffix(output_path.suffix + ".partial")
383
+
384
+ data = config["data"]
385
+ parallel = data.get("parallel_generation", {})
386
+ workers = int(workers if workers is not None else parallel.get("workers", 1))
387
+ max_in_flight = int(max_in_flight if max_in_flight is not None else parallel.get("max_in_flight", 2 * workers))
388
+ flush_every = int(flush_every if flush_every is not None else parallel.get("flush_every", workers))
389
+ resume_partial = bool(resume_partial if resume_partial is not None else parallel.get("resume_partial", True))
390
+ if workers < 1 or max_in_flight < workers or flush_every < 1:
391
+ raise ValueError("workers>=1, max_in_flight>=workers, and flush_every>=1 are required")
392
+ nt = int(nt or data["num_time_points"])
393
+ high_nx = int(high_resolution_nx or data["high_resolution_nx"])
394
+ target_nx = int(resolution or data["resolution"])
395
+ if high_nx % target_nx != 0:
396
+ raise ValueError(f"high_resolution_nx={high_nx} must be divisible by resolution={target_nx}")
397
+ if high_nx < 7 or nt < 2:
398
+ raise ValueError("Generation requires high_resolution_nx>=7 and nt>=2")
399
+ counts = dict(sample_counts or {name: int(data[f"{name}_samples"]) for name in SPLITS})
400
+ if set(counts) != set(SPLITS) or any(int(counts[name]) <= 0 for name in SPLITS):
401
+ raise ValueError(f"sample_counts must provide positive counts for {SPLITS}")
402
+
403
+ domain_length, final_time = float(data["domain_length"]), float(data["final_time"])
404
+ x_high = np.linspace(0.0, domain_length, high_nx, endpoint=False, dtype=np.float64)
405
+ stride = high_nx // target_nx
406
+ x = x_high[::stride].astype(np.float32)
407
+ times = np.linspace(0.0, final_time, nt, dtype=np.float64)
408
+ generation_cfg = _generation_config(config)
409
+ compression = data["generation"].get("compression")
410
+ compression_opts = int(data["generation"].get("compression_level", 4)) if compression == "gzip" else None
411
+ signature = _generation_signature(config, counts, nt, high_nx, target_nx)
412
+ if partial_path.exists() and overwrite:
413
+ partial_path.unlink()
414
+ if partial_path.exists() and not resume_partial:
415
+ raise FileExistsError(f"Partial dataset exists; enable resume_partial or use --overwrite: {partial_path}")
416
+ if not partial_path.exists():
417
+ _initialize_partial_file(
418
+ partial_path, config, counts, nt, high_nx, target_nx, x, times, generation_cfg,
419
+ compression, compression_opts, signature,
420
+ )
421
+
422
+ try:
423
+ with h5py.File(partial_path, "r+") as handle:
424
+ stored_signature = str(handle.attrs.get("generation_signature", ""))
425
+ if stored_signature != signature:
426
+ raise ValueError("Partial dataset configuration does not match this run; archive it or use --overwrite")
427
+ print(
428
+ f"[generate] workers={workers} max_in_flight={max_in_flight} flush_every={flush_every} "
429
+ f"resume_partial={resume_partial}", flush=True,
430
+ )
431
+ for split in SPLITS:
432
+ count = int(counts[split])
433
+ group = handle[split]
434
+ completed_ds = group["completed"]
435
+ pending_indices = np.flatnonzero(~np.asarray(completed_ds[:], dtype=bool)).tolist()
436
+ completed_count = count - len(pending_indices)
437
+ if pending_indices:
438
+ print(f"[generate] split={split} resume_completed={completed_count}/{count}", flush=True)
439
+ payloads = _sample_payloads(group, pending_indices)
440
+ for sample_index, trajectory in _parallel_results(
441
+ payloads, workers, max_in_flight, x_high, times, generation_cfg, stride
442
+ ):
443
+ group["u"][sample_index] = trajectory
444
+ completed_ds[sample_index] = True
445
+ completed_count += 1
446
+ if completed_count % flush_every == 0 or completed_count == count:
447
+ handle.flush()
448
+ print(
449
+ f"[generate] split={split} completed={completed_count}/{count} "
450
+ f"sample_index={sample_index} workers={workers}", flush=True,
451
+ )
452
+ if not np.all(np.asarray(completed_ds[:], dtype=bool)):
453
+ raise RuntimeError(f"Split {split} is incomplete after generation")
454
+ handle.flush()
455
+ os.replace(partial_path, output_path)
456
+ except Exception:
457
+ print(f"Generation failed; partial file retained at {partial_path}", flush=True)
458
+ raise
459
+ return output_path
460
+
461
+
462
+ class E3Dataset(Dataset):
463
+ """Lazy, process-safe reader for one E3 HDF5 split."""
464
+
465
+ def __init__(self, path: Path | str, split: str, expected_nt: Optional[int] = None, expected_nx: Optional[int] = None):
466
+ self.path = Path(path)
467
+ self.split = split
468
+ self._handle: Optional[h5py.File] = None
469
+ if split not in SPLITS:
470
+ raise ValueError(f"split must be one of {SPLITS}, got {split!r}")
471
+ if not self.path.is_file():
472
+ raise FileNotFoundError(f"E3 dataset not found: {self.path}")
473
+ with h5py.File(self.path, "r") as handle:
474
+ if split not in handle:
475
+ raise KeyError(f"Missing HDF5 group {split!r}")
476
+ group = handle[split]
477
+ required = {"u", "x", "t", "params", "forcing_amplitude", "forcing_omega", "forcing_mode", "forcing_phase"}
478
+ missing = required.difference(group.keys())
479
+ if missing:
480
+ raise KeyError(f"Missing HDF5 fields in {split}: {sorted(missing)}")
481
+ shape = group["u"].shape
482
+ if len(shape) != 3 or group["params"].shape != (shape[0], 3) or group["x"].shape != (shape[2],) or group["t"].shape != (shape[1],):
483
+ raise ValueError(f"Inconsistent E3 schema in split={split}: u={shape}")
484
+ if expected_nt is not None and shape[1] != expected_nt:
485
+ raise ValueError(f"Expected nt={expected_nt}, found {shape[1]}")
486
+ if expected_nx is not None and shape[2] != expected_nx:
487
+ raise ValueError(f"Expected nx={expected_nx}, found {shape[2]}")
488
+ self.length, self.nt, self.nx = shape
489
+ x, t = group["x"][:], group["t"][:]
490
+ if not np.all(np.isfinite(x)) or not np.all(np.isfinite(t)) or np.any(np.diff(t) <= 0.0):
491
+ raise ValueError("Grid/time metadata are non-finite or non-monotone")
492
+ if x.size > 1 and not np.allclose(np.diff(x), np.diff(x)[0], rtol=1e-5, atol=1e-7):
493
+ raise ValueError("E3 x grid must be uniform")
494
+ if t.size > 1 and not np.allclose(np.diff(t), np.diff(t)[0], rtol=1e-5, atol=1e-7):
495
+ raise ValueError("E3 saved times must be uniform")
496
+
497
+ def _group(self) -> h5py.Group:
498
+ if self._handle is None:
499
+ self._handle = h5py.File(self.path, "r")
500
+ return self._handle[self.split]
501
+
502
+ def __len__(self) -> int:
503
+ return self.length
504
+
505
+ def __getitem__(self, index: int) -> Dict[str, torch.Tensor]:
506
+ group = self._group()
507
+ trajectory = np.asarray(group["u"][index], dtype=np.float32)
508
+ params = np.asarray(group["params"][index], dtype=np.float32)
509
+ if not np.all(np.isfinite(trajectory)) or not np.all(np.isfinite(params)):
510
+ raise FloatingPointError(f"Non-finite data at split={self.split}, sample={index}")
511
+ return {
512
+ "u": torch.from_numpy(trajectory),
513
+ "x": torch.from_numpy(np.asarray(group["x"][:], dtype=np.float32)),
514
+ "t": torch.from_numpy(np.asarray(group["t"][:], dtype=np.float32)),
515
+ "params": torch.from_numpy(params),
516
+ "index": torch.tensor(index, dtype=torch.long),
517
+ }
518
+
519
+ def close(self) -> None:
520
+ if self._handle is not None:
521
+ self._handle.close()
522
+ self._handle = None
523
+
524
+ def __del__(self) -> None:
525
+ self.close()
526
+
527
+
528
+ def _load_yaml(path: Path) -> Dict[str, Any]:
529
+ with path.open("r", encoding="utf-8") as stream:
530
+ config = yaml.safe_load(stream)
531
+ if not isinstance(config, dict):
532
+ raise ValueError(f"Config must contain a mapping: {path}")
533
+ return config
534
+
535
+
536
+ def main() -> None:
537
+ project_root = Path(__file__).resolve().parents[1]
538
+ parser = argparse.ArgumentParser(description="Generate the MP-PDE E3 HDF5 dataset")
539
+ parser.add_argument("--config", type=Path, default=project_root / "config/config.yaml")
540
+ parser.add_argument("--output", type=Path, default=None)
541
+ parser.add_argument("--overwrite", action="store_true")
542
+ parser.add_argument("--train-samples", type=int)
543
+ parser.add_argument("--valid-samples", type=int)
544
+ parser.add_argument("--test-samples", type=int)
545
+ parser.add_argument("--nt", type=int)
546
+ parser.add_argument("--high-resolution-nx", type=int)
547
+ parser.add_argument("--resolution", type=int)
548
+ parser.add_argument("--workers", type=int)
549
+ parser.add_argument("--max-in-flight", type=int)
550
+ parser.add_argument("--flush-every", type=int)
551
+ parser.add_argument("--no-resume-partial", action="store_true")
552
+ args = parser.parse_args()
553
+ config = _load_yaml(args.config.resolve())
554
+ configured = Path(config["paths"]["data"])
555
+ output = args.output or (configured if configured.is_absolute() else project_root / configured)
556
+ default_counts = {name: int(config["data"][f"{name}_samples"]) for name in SPLITS}
557
+ counts = {
558
+ "train": args.train_samples or default_counts["train"],
559
+ "valid": args.valid_samples or default_counts["valid"],
560
+ "test": args.test_samples or default_counts["test"],
561
+ }
562
+ generated = generate_e3_hdf5(
563
+ config, output, sample_counts=counts, nt=args.nt, high_resolution_nx=args.high_resolution_nx,
564
+ resolution=args.resolution, workers=args.workers, max_in_flight=args.max_in_flight,
565
+ flush_every=args.flush_every, resume_partial=False if args.no_resume_partial else None,
566
+ overwrite=args.overwrite,
567
+ )
568
+ print(f"Generated E3 dataset: {generated}", flush=True)
569
+
570
+
571
+ if __name__ == "__main__":
572
+ main()
models/pde.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Paper-priority MP-PDE model for experiment E3."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Iterable, Sequence, Tuple
6
+
7
+ import torch
8
+ from torch import Tensor, nn
9
+
10
+
11
+ class Swish(nn.Module):
12
+ def forward(self, values: Tensor) -> Tensor:
13
+ return values * torch.sigmoid(values)
14
+
15
+
16
+ class TwoLayerMLP(nn.Module):
17
+ def __init__(self, input_dim: int, hidden_dim: int, output_dim: int):
18
+ super().__init__()
19
+ self.network = nn.Sequential(
20
+ nn.Linear(input_dim, hidden_dim),
21
+ Swish(),
22
+ nn.Linear(hidden_dim, output_dim),
23
+ Swish(),
24
+ )
25
+
26
+ def forward(self, values: Tensor) -> Tensor:
27
+ return self.network(values)
28
+
29
+
30
+ def periodic_neighbor_indices(num_nodes: int, offsets: Iterable[int], device: torch.device | None = None) -> Tensor:
31
+ """Return source indices [num_nodes, num_neighbors] for each target node."""
32
+ offsets_tensor = torch.as_tensor(tuple(offsets), dtype=torch.long, device=device)
33
+ if num_nodes < 7:
34
+ raise ValueError(f"The six-neighbor periodic graph requires num_nodes>=7, found {num_nodes}")
35
+ if offsets_tensor.numel() != 6 or offsets_tensor.unique().numel() != 6 or torch.any(offsets_tensor == 0):
36
+ raise ValueError("MP-PDE E3 requires exactly six distinct non-zero neighbor offsets")
37
+ target = torch.arange(num_nodes, dtype=torch.long, device=device)[:, None]
38
+ return torch.remainder(target + offsets_tensor[None, :], num_nodes)
39
+
40
+
41
+ class MessagePassingLayer(nn.Module):
42
+ """Equation (8)--(9) processor layer with sum aggregation."""
43
+
44
+ def __init__(self, hidden_dim: int, history_dim: int, parameter_dim: int, affine_norm: bool):
45
+ super().__init__()
46
+ edge_dim = 2 * hidden_dim + history_dim + 1 + parameter_dim
47
+ node_dim = 2 * hidden_dim + parameter_dim
48
+ self.edge_mlp = TwoLayerMLP(edge_dim, hidden_dim, hidden_dim)
49
+ self.node_mlp = TwoLayerMLP(node_dim, hidden_dim, hidden_dim)
50
+ self.norm = nn.InstanceNorm1d(hidden_dim, affine=affine_norm, track_running_stats=False)
51
+
52
+ def forward(
53
+ self,
54
+ hidden: Tensor,
55
+ history: Tensor,
56
+ x: Tensor,
57
+ parameters: Tensor,
58
+ neighbors: Tensor,
59
+ domain_length: float,
60
+ ) -> Tensor:
61
+ batch, num_nodes, hidden_dim = hidden.shape
62
+ num_neighbors = neighbors.shape[1]
63
+ source_hidden = hidden[:, neighbors, :]
64
+ target_hidden = hidden[:, :, None, :].expand(-1, -1, num_neighbors, -1)
65
+ source_history = history[:, neighbors, :]
66
+ history_difference = history[:, :, None, :] - source_history
67
+ source_x = x[:, neighbors]
68
+ displacement = x[:, :, None] - source_x
69
+ displacement = torch.remainder(displacement + 0.5 * domain_length, domain_length) - 0.5 * domain_length
70
+ theta_edges = parameters[:, None, None, :].expand(-1, num_nodes, num_neighbors, -1)
71
+ edge_input = torch.cat(
72
+ (target_hidden, source_hidden, history_difference, displacement[..., None], theta_edges), dim=-1
73
+ )
74
+ messages = self.edge_mlp(edge_input)
75
+ aggregated = messages.sum(dim=2)
76
+ theta_nodes = parameters[:, None, :].expand(-1, num_nodes, -1)
77
+ node_update = self.node_mlp(torch.cat((hidden, aggregated, theta_nodes), dim=-1))
78
+ return self.norm((hidden + node_update).transpose(1, 2)).transpose(1, 2)
79
+
80
+
81
+ class TemporalDecoder(nn.Module):
82
+ def __init__(
83
+ self,
84
+ hidden_dim: int,
85
+ time_window: int,
86
+ middle_channels: int = 8,
87
+ kernels: Sequence[int] = (16, 26),
88
+ strides: Sequence[int] = (3, 1),
89
+ ):
90
+ super().__init__()
91
+ if len(kernels) != 2 or len(strides) != 2:
92
+ raise ValueError("The paper-priority decoder requires exactly two convolutions")
93
+ length_after_first = (hidden_dim - int(kernels[0])) // int(strides[0]) + 1
94
+ output_length = (length_after_first - int(kernels[1])) // int(strides[1]) + 1
95
+ if output_length != time_window:
96
+ raise ValueError(
97
+ f"Decoder does not close hidden={hidden_dim} to K={time_window}: output length={output_length}"
98
+ )
99
+ self.network = nn.Sequential(
100
+ nn.Conv1d(1, middle_channels, kernel_size=int(kernels[0]), stride=int(strides[0])),
101
+ Swish(),
102
+ nn.Conv1d(middle_channels, 1, kernel_size=int(kernels[1]), stride=int(strides[1])),
103
+ )
104
+
105
+ def forward(self, hidden: Tensor) -> Tensor:
106
+ batch, num_nodes, hidden_dim = hidden.shape
107
+ decoded = self.network(hidden.reshape(batch * num_nodes, 1, hidden_dim))
108
+ return decoded.reshape(batch, num_nodes, decoded.shape[-1])
109
+
110
+
111
+ class MPPDESolver(nn.Module):
112
+ """Message-passing neural solver mapping K E3 states to the next K states."""
113
+
114
+ def __init__(
115
+ self,
116
+ time_window: int = 25,
117
+ hidden_dim: int = 164,
118
+ message_passing_layers: int = 6,
119
+ neighbor_offsets: Sequence[int] = (-3, -2, -1, 1, 2, 3),
120
+ domain_length: float = 16.0,
121
+ final_time: float = 4.0,
122
+ parameter_maxima: Sequence[float] = (3.0, 0.4, 1.0),
123
+ scale_coordinates: bool = True,
124
+ scale_parameters: bool = True,
125
+ instance_norm_affine: bool = False,
126
+ decoder_middle_channels: int = 8,
127
+ decoder_kernels: Sequence[int] = (16, 26),
128
+ decoder_strides: Sequence[int] = (3, 1),
129
+ ):
130
+ super().__init__()
131
+ if time_window <= 0 or hidden_dim <= 0 or message_passing_layers <= 0:
132
+ raise ValueError("time_window, hidden_dim, and message_passing_layers must be positive")
133
+ self.time_window = int(time_window)
134
+ self.hidden_dim = int(hidden_dim)
135
+ self.neighbor_offsets: Tuple[int, ...] = tuple(int(value) for value in neighbor_offsets)
136
+ self.domain_length = float(domain_length)
137
+ self.final_time = float(final_time)
138
+ self.scale_coordinates = bool(scale_coordinates)
139
+ self.scale_parameters = bool(scale_parameters)
140
+ maxima = torch.tensor(tuple(float(value) for value in parameter_maxima), dtype=torch.float32)
141
+ if maxima.shape != (3,) or torch.any(maxima <= 0.0):
142
+ raise ValueError("parameter_maxima must contain three positive values")
143
+ self.register_buffer("parameter_maxima", maxima, persistent=False)
144
+ self.encoder = TwoLayerMLP(self.time_window + 1 + 1 + 3, self.hidden_dim, self.hidden_dim)
145
+ self.processor = nn.ModuleList(
146
+ MessagePassingLayer(self.hidden_dim, self.time_window, 3, instance_norm_affine)
147
+ for _ in range(int(message_passing_layers))
148
+ )
149
+ self.decoder = TemporalDecoder(
150
+ self.hidden_dim, self.time_window, decoder_middle_channels, decoder_kernels, decoder_strides
151
+ )
152
+
153
+ def _canonicalize_inputs(
154
+ self, history: Tensor, x: Tensor, current_time: Tensor | float, parameters: Tensor
155
+ ) -> Tuple[Tensor, Tensor, Tensor, Tensor]:
156
+ if history.ndim != 3:
157
+ raise ValueError(f"history must have shape [B,N,K], found {tuple(history.shape)}")
158
+ batch, num_nodes, window = history.shape
159
+ if window != self.time_window:
160
+ raise ValueError(f"Expected history K={self.time_window}, found {window}")
161
+ if num_nodes < 7:
162
+ raise ValueError(f"MP-PDE requires N>=7, found {num_nodes}")
163
+ if parameters.shape != (batch, 3):
164
+ raise ValueError(f"parameters must have shape [{batch},3], found {tuple(parameters.shape)}")
165
+ if x.ndim == 1:
166
+ if x.shape[0] != num_nodes:
167
+ raise ValueError(f"x length {x.shape[0]} does not match N={num_nodes}")
168
+ x = x[None, :].expand(batch, -1)
169
+ elif x.shape != (batch, num_nodes):
170
+ raise ValueError(f"x must have shape [N] or [B,N], found {tuple(x.shape)}")
171
+ time = torch.as_tensor(current_time, dtype=history.dtype, device=history.device)
172
+ if time.ndim == 0:
173
+ time = time.expand(batch)
174
+ elif time.shape == (batch, 1):
175
+ time = time[:, 0]
176
+ elif time.shape != (batch,):
177
+ raise ValueError(f"current_time must be scalar or shape [B], found {tuple(time.shape)}")
178
+ return history, x.to(history), time, parameters.to(history)
179
+
180
+ def forward(
181
+ self,
182
+ history: Tensor,
183
+ x: Tensor,
184
+ current_time: Tensor | float,
185
+ parameters: Tensor,
186
+ dt: Tensor | float,
187
+ *,
188
+ return_derivative: bool = False,
189
+ ) -> Tensor | Tuple[Tensor, Tensor]:
190
+ history, x, current_time, parameters = self._canonicalize_inputs(history, x, current_time, parameters)
191
+ batch, num_nodes, _ = history.shape
192
+ scaled_x = x / self.domain_length if self.scale_coordinates else x
193
+ scaled_time = current_time / self.final_time if self.scale_coordinates else current_time
194
+ scaled_parameters = parameters / self.parameter_maxima.to(parameters) if self.scale_parameters else parameters
195
+ time_feature = scaled_time[:, None, None].expand(-1, num_nodes, 1)
196
+ parameter_features = scaled_parameters[:, None, :].expand(-1, num_nodes, -1)
197
+ encoded = torch.cat((history, scaled_x[..., None], time_feature, parameter_features), dim=-1)
198
+ hidden = self.encoder(encoded)
199
+ neighbors = periodic_neighbor_indices(num_nodes, self.neighbor_offsets, history.device)
200
+ for layer in self.processor:
201
+ hidden = layer(hidden, history, x, scaled_parameters, neighbors, self.domain_length)
202
+ derivative = self.decoder(hidden)
203
+ step = torch.as_tensor(dt, dtype=history.dtype, device=history.device)
204
+ if step.ndim == 0:
205
+ step = step.expand(batch)
206
+ elif step.shape != (batch,):
207
+ raise ValueError(f"dt must be scalar or shape [B], found {tuple(step.shape)}")
208
+ offsets = torch.arange(1, self.time_window + 1, dtype=history.dtype, device=history.device)
209
+ delta_times = step[:, None, None] * offsets[None, None, :]
210
+ prediction = history[:, :, -1:] + delta_times * derivative
211
+ return (prediction, derivative) if return_derivative else prediction
scripts/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Command-line entrypoints for the MP-PDE E3 reproduction."""
scripts/inference.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Autoregressive MP-PDE E3 rollout and evaluation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+ import tempfile
10
+ from pathlib import Path
11
+ from typing import Any, Dict, Iterable, Mapping
12
+
13
+ import numpy as np
14
+ import torch
15
+ from torch.utils.data import DataLoader
16
+
17
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
18
+ if str(PROJECT_ROOT) not in sys.path:
19
+ sys.path.insert(0, str(PROJECT_ROOT))
20
+
21
+ from models.dataset import E3Dataset # noqa: E402
22
+ from scripts.train import build_model, choose_device, load_config, project_path, rollout_batch, set_seed # noqa: E402
23
+
24
+
25
+ CRITICAL_CONFIG_KEYS = (
26
+ ("model", "time_window"),
27
+ ("model", "hidden_dim"),
28
+ ("model", "message_passing_layers"),
29
+ ("model", "neighbor_offsets"),
30
+ ("model", "aggregation"),
31
+ ("model", "decoder", "kernel_sizes"),
32
+ ("model", "decoder", "strides"),
33
+ ("model", "scaling", "coordinates"),
34
+ ("model", "scaling", "parameters"),
35
+ ("data", "equation", "alpha_range"),
36
+ ("data", "equation", "beta_range"),
37
+ ("data", "equation", "gamma_range"),
38
+ )
39
+
40
+
41
+ def _nested(mapping: Mapping[str, Any], key: Iterable[str]) -> Any:
42
+ value: Any = mapping
43
+ for component in key:
44
+ value = value[component]
45
+ return value
46
+
47
+
48
+ def verify_checkpoint_config(runtime: Mapping[str, Any], stored: Mapping[str, Any]) -> None:
49
+ mismatches = []
50
+ for key in CRITICAL_CONFIG_KEYS:
51
+ runtime_value, stored_value = _nested(runtime, key), _nested(stored, key)
52
+ if runtime_value != stored_value:
53
+ mismatches.append(f"{'.'.join(key)}: runtime={runtime_value!r}, checkpoint={stored_value!r}")
54
+ if mismatches:
55
+ raise ValueError("Checkpoint/config architecture mismatch:\n " + "\n ".join(mismatches))
56
+
57
+
58
+ def compute_metrics(prediction: np.ndarray, target: np.ndarray, forecast_start: int) -> Dict[str, Any]:
59
+ if prediction.shape != target.shape or prediction.ndim != 3:
60
+ raise ValueError(f"prediction/target must share [S,T,N] shape, found {prediction.shape}/{target.shape}")
61
+ error = prediction[:, forecast_start:] - target[:, forecast_start:]
62
+ if error.size == 0 or not np.all(np.isfinite(error)):
63
+ raise FloatingPointError("Forecast error is empty or non-finite")
64
+ squared = error.astype(np.float64) ** 2
65
+ per_time_mse = np.mean(squared, axis=(0, 2))
66
+ numerator = np.linalg.norm(error.reshape(error.shape[0], -1), axis=1)
67
+ denominator = np.linalg.norm(target[:, forecast_start:].reshape(error.shape[0], -1), axis=1) + 1.0e-12
68
+ return {
69
+ "accumulated_mse": float(np.sum(per_time_mse)),
70
+ "rmse": float(np.sqrt(np.mean(squared))),
71
+ "mae": float(np.mean(np.abs(error))),
72
+ "relative_l2": float(np.mean(numerator / denominator)),
73
+ "per_time_mse": per_time_mse,
74
+ }
75
+
76
+
77
+ def atomic_npz(destination: Path, **arrays: Any) -> None:
78
+ destination.parent.mkdir(parents=True, exist_ok=True)
79
+ with tempfile.NamedTemporaryFile(dir=destination.parent, prefix=destination.stem + ".", suffix=".npz", delete=False) as stream:
80
+ temporary = Path(stream.name)
81
+ try:
82
+ np.savez_compressed(temporary, **arrays)
83
+ os.replace(temporary, destination)
84
+ finally:
85
+ temporary.unlink(missing_ok=True)
86
+
87
+
88
+ def atomic_json(destination: Path, payload: Mapping[str, Any]) -> None:
89
+ destination.parent.mkdir(parents=True, exist_ok=True)
90
+ with tempfile.NamedTemporaryFile("w", dir=destination.parent, prefix=destination.name + ".", suffix=".tmp", delete=False, encoding="utf-8") as stream:
91
+ json.dump(payload, stream, indent=2, sort_keys=True)
92
+ stream.write("\n")
93
+ temporary = Path(stream.name)
94
+ os.replace(temporary, destination)
95
+
96
+
97
+ def main() -> None:
98
+ parser = argparse.ArgumentParser(description="Run MP-PDE E3 autoregressive inference")
99
+ parser.add_argument("--config", type=Path, default=PROJECT_ROOT / "config/config.yaml")
100
+ parser.add_argument("--checkpoint", type=Path)
101
+ parser.add_argument("--data", type=Path)
102
+ parser.add_argument("--split", type=str)
103
+ parser.add_argument("--output-dir", type=Path)
104
+ parser.add_argument("--device", type=str)
105
+ parser.add_argument("--batch-size", type=int)
106
+ parser.add_argument("--max-samples", type=int)
107
+ parser.add_argument("--seed", type=int)
108
+ args = parser.parse_args()
109
+
110
+ config = load_config(args.config.resolve())
111
+ inference_cfg = config["inference"]
112
+ checkpoint_path = args.checkpoint or project_path(config["paths"]["checkpoint"])
113
+ data_path = args.data or project_path(config["paths"]["data"])
114
+ output_dir = args.output_dir or project_path(config["paths"]["results"])
115
+ if not checkpoint_path.is_file():
116
+ raise FileNotFoundError(f"Real trained checkpoint not found: {checkpoint_path}. Run scripts/train.py first.")
117
+ if not data_path.is_file():
118
+ raise FileNotFoundError(f"E3 HDF5 data not found: {data_path}. Generate data before inference.")
119
+ seed = int(args.seed if args.seed is not None else config["training"]["seed"])
120
+ set_seed(seed, bool(config["training"]["deterministic"]))
121
+ device = choose_device(args.device or str(inference_cfg["device"]))
122
+ checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=False)
123
+ if "model_state" not in checkpoint or "resolved_config" not in checkpoint:
124
+ raise KeyError("Checkpoint must contain model_state and resolved_config")
125
+ verify_checkpoint_config(config, checkpoint["resolved_config"])
126
+ model = build_model(config).to(device)
127
+ model.load_state_dict(checkpoint["model_state"], strict=True)
128
+ model.eval()
129
+
130
+ split = args.split or str(inference_cfg["split"])
131
+ dataset = E3Dataset(
132
+ data_path, split, expected_nt=int(config["data"]["num_time_points"]), expected_nx=int(config["data"]["resolution"])
133
+ )
134
+ batch_size = int(args.batch_size or inference_cfg["batch_size"])
135
+ configured_max = inference_cfg.get("max_samples")
136
+ max_samples = args.max_samples if args.max_samples is not None else configured_max
137
+ if max_samples is not None and int(max_samples) <= 0:
138
+ raise ValueError("max_samples must be positive")
139
+ loader = DataLoader(dataset, batch_size=batch_size, shuffle=False, num_workers=0)
140
+ window = int(config["model"]["time_window"])
141
+ predictions, targets, parameters, sample_indices = [], [], [], []
142
+ first_x, first_t = None, None
143
+ processed = 0
144
+ with torch.inference_mode():
145
+ for batch_index, batch in enumerate(loader, start=1):
146
+ if max_samples is not None:
147
+ remaining = int(max_samples) - processed
148
+ if remaining <= 0:
149
+ break
150
+ if batch["u"].shape[0] > remaining:
151
+ batch = {key: value[:remaining] for key, value in batch.items()}
152
+ prediction = rollout_batch(model, batch, device, window).cpu().numpy()
153
+ predictions.append(prediction)
154
+ targets.append(batch["u"].numpy())
155
+ parameters.append(batch["params"].numpy())
156
+ sample_indices.append(batch["index"].numpy())
157
+ if first_x is None:
158
+ first_x, first_t = batch["x"][0].numpy(), batch["t"][0].numpy()
159
+ processed += prediction.shape[0]
160
+ print(f"inference batch={batch_index} processed={processed}", flush=True)
161
+ if not predictions or first_x is None or first_t is None:
162
+ raise RuntimeError("Inference produced no samples")
163
+ prediction_array = np.concatenate(predictions)
164
+ target_array = np.concatenate(targets)
165
+ parameter_array = np.concatenate(parameters)
166
+ index_array = np.concatenate(sample_indices)
167
+ forecast_start = int(inference_cfg["forecast_start_index"])
168
+ if forecast_start != window:
169
+ raise ValueError("E3 inference forecast_start_index must equal model.time_window")
170
+ if not np.array_equal(prediction_array[:, :forecast_start], target_array[:, :forecast_start]):
171
+ raise AssertionError("Rollout seed differs from the first K ground-truth states")
172
+ metrics = compute_metrics(prediction_array, target_array, forecast_start)
173
+ per_time_mse = metrics.pop("per_time_mse")
174
+ reference_values = config["paper_reference"]["values"]
175
+ nx = int(prediction_array.shape[2])
176
+ paper_reference = reference_values.get(nx, reference_values.get(str(nx)))
177
+ metrics_payload: Dict[str, Any] = {
178
+ **metrics,
179
+ "samples": int(prediction_array.shape[0]),
180
+ "split": split,
181
+ "nx": nx,
182
+ "nt": int(prediction_array.shape[1]),
183
+ "forecast_start_index": forecast_start,
184
+ "forecast_end_index_inclusive": int(prediction_array.shape[1] - 1),
185
+ "metric_scope": "forecast_only; accumulated_mse=sum_time(mean_sample_space(error^2))",
186
+ "checkpoint": str(checkpoint_path),
187
+ "checkpoint_epoch": int(checkpoint.get("epoch", -1)),
188
+ "checkpoint_validation_accumulated_mse": float(checkpoint.get("best_validation_accumulated_mse", float("nan"))),
189
+ "ambiguity_policy": config["experiment"]["ambiguity_policy"],
190
+ "paper_reference": {"value": paper_reference, "provenance_only_not_assertion": True},
191
+ }
192
+ predictions_path = output_dir / Path(config["paths"]["predictions"]).name
193
+ metrics_path = output_dir / Path(config["paths"]["metrics"]).name
194
+ atomic_npz(
195
+ predictions_path,
196
+ prediction=prediction_array,
197
+ target=target_array,
198
+ x=first_x,
199
+ t=first_t,
200
+ params=parameter_array,
201
+ sample_indices=index_array,
202
+ forecast_start_index=np.asarray(forecast_start, dtype=np.int64),
203
+ per_time_mse=per_time_mse,
204
+ )
205
+ atomic_json(metrics_path, metrics_payload)
206
+ print(
207
+ f"metrics samples={metrics_payload['samples']} accumulated_mse={metrics['accumulated_mse']:.8e} "
208
+ f"rmse={metrics['rmse']:.8e} mae={metrics['mae']:.8e} relative_l2={metrics['relative_l2']:.8e}",
209
+ flush=True,
210
+ )
211
+ print(f"Saved predictions: {predictions_path}", flush=True)
212
+ print(f"Saved metrics: {metrics_path}", flush=True)
213
+
214
+
215
+ if __name__ == "__main__":
216
+ main()
scripts/result.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Create MP-PDE E3 visualizations from real inference/training artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any, Dict
9
+
10
+ import matplotlib
11
+ matplotlib.use("Agg")
12
+ import matplotlib.pyplot as plt
13
+ import numpy as np
14
+ import yaml
15
+
16
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
17
+
18
+
19
+ def load_config(path: Path) -> Dict[str, Any]:
20
+ with path.open("r", encoding="utf-8") as stream:
21
+ return yaml.safe_load(stream)
22
+
23
+
24
+ def project_path(value: str | Path) -> Path:
25
+ path = Path(value)
26
+ return path if path.is_absolute() else PROJECT_ROOT / path
27
+
28
+
29
+ def main() -> None:
30
+ parser = argparse.ArgumentParser(description="Plot real MP-PDE E3 rollout results")
31
+ parser.add_argument("--config", type=Path, default=PROJECT_ROOT / "config/config.yaml")
32
+ parser.add_argument("--predictions", type=Path)
33
+ parser.add_argument("--metrics", type=Path)
34
+ parser.add_argument("--history", type=Path)
35
+ parser.add_argument("--output-dir", type=Path)
36
+ parser.add_argument("--sample-index", type=int)
37
+ args = parser.parse_args()
38
+ config = load_config(args.config.resolve())
39
+ predictions_path = args.predictions or project_path(config["paths"]["predictions"])
40
+ metrics_path = args.metrics or project_path(config["paths"]["metrics"])
41
+ history_path = args.history or project_path(config["paths"]["train_history"])
42
+ output_dir = args.output_dir or project_path(config["paths"]["results"])
43
+ if not predictions_path.is_file() or not metrics_path.is_file():
44
+ raise FileNotFoundError(
45
+ f"Real inference artifacts are required: {predictions_path} and {metrics_path}. "
46
+ "Run scripts/inference.py after training; placeholder data will not be generated."
47
+ )
48
+ with np.load(predictions_path, allow_pickle=False) as archive:
49
+ required = {"prediction", "target", "x", "t", "params", "sample_indices", "forecast_start_index", "per_time_mse"}
50
+ missing = required.difference(archive.files)
51
+ if missing:
52
+ raise KeyError(f"predictions.npz is missing fields: {sorted(missing)}")
53
+ prediction, target = archive["prediction"], archive["target"]
54
+ x, t, per_time_mse = archive["x"], archive["t"], archive["per_time_mse"]
55
+ forecast_start = int(archive["forecast_start_index"])
56
+ with metrics_path.open("r", encoding="utf-8") as stream:
57
+ metrics = json.load(stream)
58
+ if prediction.shape != target.shape or prediction.ndim != 3:
59
+ raise ValueError(f"Expected matching [S,T,N] arrays, found {prediction.shape}/{target.shape}")
60
+ if prediction.shape[1:] != (t.size, x.size) or not np.all(np.isfinite(prediction)) or not np.all(np.isfinite(target)):
61
+ raise ValueError("Prediction axes do not match x/t or contain non-finite values")
62
+ recomputed = np.mean((prediction[:, forecast_start:] - target[:, forecast_start:]) ** 2, axis=(0, 2))
63
+ if per_time_mse.shape != recomputed.shape or not np.allclose(per_time_mse, recomputed, rtol=2e-5, atol=1e-8):
64
+ raise ValueError("Stored per_time_mse is inconsistent with prediction and target")
65
+ if not np.isclose(float(metrics["accumulated_mse"]), float(np.sum(recomputed)), rtol=2e-5, atol=1e-8):
66
+ raise ValueError("metrics.json accumulated_mse is inconsistent with predictions.npz")
67
+
68
+ output_dir.mkdir(parents=True, exist_ok=True)
69
+ sample = int(args.sample_index if args.sample_index is not None else config["visualization"]["sample_index"])
70
+ if sample < 0 or sample >= prediction.shape[0]:
71
+ raise IndexError(f"sample_index={sample} outside [0,{prediction.shape[0]})")
72
+ dpi = int(config["visualization"]["dpi"])
73
+ time_indices = [int(index) for index in config["visualization"]["time_indices"]]
74
+ if any(index < 0 or index >= t.size for index in time_indices):
75
+ raise IndexError(f"Configured time_indices exceed nt={t.size}")
76
+
77
+ figure, axes = plt.subplots(len(time_indices), 1, figsize=(9, 2.4 * len(time_indices)), sharex=True)
78
+ axes = np.atleast_1d(axes)
79
+ for axis, time_index in zip(axes, time_indices):
80
+ axis.plot(x, target[sample, time_index], color="black", linewidth=1.5, label="target")
81
+ axis.plot(x, prediction[sample, time_index], color="tab:blue", linewidth=1.2, linestyle="--", label="MP-PDE")
82
+ axis.set_ylabel("u")
83
+ axis.set_title(f"t={t[time_index]:.4f}, index={time_index}")
84
+ axis.grid(alpha=0.2)
85
+ axes[0].legend(loc="best")
86
+ axes[-1].set_xlabel("x")
87
+ figure.tight_layout()
88
+ rollout_path = output_dir / "e3_rollout.png"
89
+ figure.savefig(rollout_path, dpi=dpi)
90
+ plt.close(figure)
91
+
92
+ absolute_error = np.abs(prediction[sample] - target[sample])
93
+ figure, axes = plt.subplots(2, 1, figsize=(10, 7), gridspec_kw={"height_ratios": [2.2, 1.0]})
94
+ image = axes[0].imshow(
95
+ absolute_error.T, origin="lower", aspect="auto", extent=(float(t[0]), float(t[-1]), float(x[0]), float(x[-1])), cmap="magma"
96
+ )
97
+ axes[0].axvline(float(t[forecast_start]), color="white", linestyle="--", linewidth=1.0, label="forecast start")
98
+ axes[0].set_ylabel("x")
99
+ axes[0].set_title("Absolute rollout error")
100
+ axes[0].legend(loc="upper right")
101
+ figure.colorbar(image, ax=axes[0], label="|prediction-target|")
102
+ axes[1].plot(t[forecast_start:], per_time_mse, color="tab:red")
103
+ axes[1].set_xlabel("t")
104
+ axes[1].set_ylabel("MSE")
105
+ axes[1].set_title(f"Per-time MSE; accumulated={metrics['accumulated_mse']:.6g}")
106
+ axes[1].grid(alpha=0.2)
107
+ figure.tight_layout()
108
+ error_path = output_dir / "e3_error.png"
109
+ figure.savefig(error_path, dpi=dpi)
110
+ plt.close(figure)
111
+
112
+ created = [rollout_path, error_path]
113
+ if history_path.is_file():
114
+ with history_path.open("r", encoding="utf-8") as stream:
115
+ history = json.load(stream)
116
+ if not isinstance(history, list) or not history:
117
+ raise ValueError(f"Training history is empty or malformed: {history_path}")
118
+ epochs = [int(item["epoch"]) + 1 for item in history]
119
+ figure, axes = plt.subplots(1, 2, figsize=(10, 4))
120
+ axes[0].plot(epochs, [item["train_rmse"] for item in history], label="train bundle RMSE")
121
+ axes[0].plot(epochs, [item["validation_bundle_rmse"] for item in history], label="validation bundle RMSE")
122
+ axes[0].set_yscale("log")
123
+ axes[0].set_xlabel("epoch")
124
+ axes[0].set_ylabel("RMSE")
125
+ axes[0].legend()
126
+ axes[0].grid(alpha=0.2)
127
+ axes[1].plot(epochs, [item["validation_accumulated_mse"] for item in history], color="tab:purple")
128
+ axes[1].set_yscale("log")
129
+ axes[1].set_xlabel("epoch")
130
+ axes[1].set_ylabel("validation accumulated MSE")
131
+ axes[1].grid(alpha=0.2)
132
+ figure.tight_layout()
133
+ training_path = output_dir / "training_curve.png"
134
+ figure.savefig(training_path, dpi=dpi)
135
+ plt.close(figure)
136
+ created.append(training_path)
137
+ else:
138
+ print(f"Training history not found; skipped training curve: {history_path}", flush=True)
139
+ for path in created:
140
+ print(f"Saved figure: {path}", flush=True)
141
+
142
+
143
+ if __name__ == "__main__":
144
+ main()
scripts/smoke_test.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bounded integration smoke test; creates artifacts only in a temporary directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import json
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+ import torch
14
+ import yaml
15
+ from torch.utils.data import DataLoader
16
+
17
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
18
+ if str(PROJECT_ROOT) not in sys.path:
19
+ sys.path.insert(0, str(PROJECT_ROOT))
20
+
21
+ from models.dataset import E3Dataset, generate_e3_hdf5 # noqa: E402
22
+ from models.pde import periodic_neighbor_indices # noqa: E402
23
+ from scripts.inference import atomic_json, atomic_npz, compute_metrics # noqa: E402
24
+ from scripts.train import build_model, load_config, rmse_loss, rollout_batch # noqa: E402
25
+
26
+
27
+ def main() -> None:
28
+ canonical = load_config(PROJECT_ROOT / "config/config.yaml")
29
+ assert canonical["model"]["hidden_dim"] == 164
30
+ assert canonical["model"]["time_window"] == 25
31
+ neighbors = periodic_neighbor_indices(40, canonical["model"]["neighbor_offsets"])
32
+ assert neighbors.shape == (40, 6)
33
+ assert torch.all(torch.tensor([row.unique().numel() == 6 for row in neighbors]))
34
+
35
+ with tempfile.TemporaryDirectory(prefix="mp_pde_smoke_") as temporary_directory:
36
+ root = Path(temporary_directory)
37
+ config = copy.deepcopy(canonical)
38
+ config["data"]["num_time_points"] = 50
39
+ config["data"]["high_resolution_nx"] = 40
40
+ config["data"]["resolution"] = 40
41
+ config["data"]["train_samples"] = 1
42
+ config["data"]["valid_samples"] = 1
43
+ config["data"]["test_samples"] = 1
44
+ config["data"]["parallel_generation"].update({"workers": 2, "max_in_flight": 2, "flush_every": 1})
45
+ config["visualization"]["time_indices"] = [25, 30, 40, 49]
46
+ data_path = root / "e3_smoke.h5"
47
+ generate_e3_hdf5(config, data_path, sample_counts={"train": 1, "valid": 1, "test": 1})
48
+ partial_path = data_path.with_suffix(data_path.suffix + ".partial")
49
+ data_path.replace(partial_path)
50
+ generate_e3_hdf5(config, data_path, sample_counts={"train": 1, "valid": 1, "test": 1})
51
+ dataset = E3Dataset(data_path, "test", expected_nt=50, expected_nx=40)
52
+ batch = next(iter(DataLoader(dataset, batch_size=1, shuffle=False)))
53
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
54
+ model = build_model(config).to(device)
55
+ window = 25
56
+ history = batch["u"][:, :window].transpose(1, 2).to(device)
57
+ target = batch["u"][:, window : 2 * window].transpose(1, 2).to(device)
58
+ times = batch["t"]
59
+ prediction = model(
60
+ history, batch["x"].to(device), times[:, window - 1].to(device), batch["params"].to(device),
61
+ (times[:, 1] - times[:, 0]).to(device),
62
+ )
63
+ assert prediction.shape == (1, 40, 25)
64
+ loss = rmse_loss(prediction, target, 1.0e-12)
65
+ loss.backward()
66
+ assert np.isfinite(float(loss.detach().cpu()))
67
+ checkpoint_path = root / "smoke_checkpoint.pth"
68
+ torch.save({"model_state": model.state_dict(), "resolved_config": config}, checkpoint_path)
69
+ reloaded = build_model(config).to(device)
70
+ reloaded.load_state_dict(torch.load(checkpoint_path, map_location=device, weights_only=False)["model_state"], strict=True)
71
+ reloaded.eval()
72
+ rollout = rollout_batch(reloaded, batch, device, window).cpu().numpy()
73
+ target_array = batch["u"].numpy()
74
+ metrics = compute_metrics(rollout, target_array, window)
75
+ assert np.isfinite(metrics["accumulated_mse"])
76
+
77
+ results = root / "results"
78
+ per_time_mse = metrics.pop("per_time_mse")
79
+ atomic_npz(
80
+ results / "predictions.npz", prediction=rollout, target=target_array, x=batch["x"][0].numpy(),
81
+ t=batch["t"][0].numpy(), params=batch["params"].numpy(), sample_indices=batch["index"].numpy(),
82
+ forecast_start_index=np.asarray(window, dtype=np.int64), per_time_mse=per_time_mse,
83
+ )
84
+ atomic_json(results / "metrics.json", {**metrics, "samples": 1})
85
+ with (results / "train_history.json").open("w", encoding="utf-8") as stream:
86
+ json.dump([{"epoch": 0, "train_rmse": float(loss.detach().cpu()), "validation_bundle_rmse": float(loss.detach().cpu()), "validation_accumulated_mse": metrics["accumulated_mse"]}], stream)
87
+ config["paths"].update(
88
+ {"predictions": str(results / "predictions.npz"), "metrics": str(results / "metrics.json"), "train_history": str(results / "train_history.json"), "results": str(results)}
89
+ )
90
+ smoke_config = root / "config.yaml"
91
+ with smoke_config.open("w", encoding="utf-8") as stream:
92
+ yaml.safe_dump(config, stream, sort_keys=False)
93
+ subprocess.run([sys.executable, str(PROJECT_ROOT / "scripts/result.py"), "--config", str(smoke_config)], check=True)
94
+ for name in ("e3_rollout.png", "e3_error.png", "training_curve.png"):
95
+ assert (results / name).is_file() and (results / name).stat().st_size > 0
96
+ print(
97
+ f"smoke_ok device={device} loss={float(loss.detach().cpu()):.8e} "
98
+ f"accumulated_mse={metrics['accumulated_mse']:.8e}", flush=True,
99
+ )
100
+
101
+
102
+ if __name__ == "__main__":
103
+ main()
scripts/train.py ADDED
@@ -0,0 +1,395 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Train the paper-priority MP-PDE E3 solver."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import random
9
+ import sys
10
+ import tempfile
11
+ from pathlib import Path
12
+ from typing import Any, Dict, Iterable, Mapping, Optional, Tuple
13
+
14
+ import numpy as np
15
+ import torch
16
+ import yaml
17
+ from torch import Tensor
18
+ from torch.utils.data import DataLoader
19
+
20
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
21
+ if str(PROJECT_ROOT) not in sys.path:
22
+ sys.path.insert(0, str(PROJECT_ROOT))
23
+
24
+ from models.dataset import E3Dataset, generate_e3_hdf5 # noqa: E402
25
+ from models.pde import MPPDESolver # noqa: E402
26
+
27
+
28
+ def load_config(path: Path) -> Dict[str, Any]:
29
+ with path.open("r", encoding="utf-8") as stream:
30
+ config = yaml.safe_load(stream)
31
+ if not isinstance(config, dict):
32
+ raise ValueError(f"Config must contain a mapping: {path}")
33
+ return config
34
+
35
+
36
+ def project_path(value: str | Path) -> Path:
37
+ path = Path(value)
38
+ return path if path.is_absolute() else PROJECT_ROOT / path
39
+
40
+
41
+ def choose_device(requested: str) -> torch.device:
42
+ if requested == "auto":
43
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
44
+ device = torch.device(requested)
45
+ if device.type == "cuda" and not torch.cuda.is_available():
46
+ raise RuntimeError(f"CUDA device requested but CUDA is unavailable: {requested}")
47
+ return device
48
+
49
+
50
+ def set_seed(seed: int, deterministic: bool) -> None:
51
+ random.seed(seed)
52
+ np.random.seed(seed)
53
+ torch.manual_seed(seed)
54
+ if torch.cuda.is_available():
55
+ torch.cuda.manual_seed_all(seed)
56
+ if deterministic:
57
+ torch.use_deterministic_algorithms(True, warn_only=True)
58
+ if torch.backends.cudnn.is_available():
59
+ torch.backends.cudnn.benchmark = False
60
+ torch.backends.cudnn.deterministic = True
61
+
62
+
63
+ def build_model(config: Mapping[str, Any]) -> MPPDESolver:
64
+ model_cfg, data_cfg = config["model"], config["data"]
65
+ decoder, scaling = model_cfg["decoder"], model_cfg["scaling"]
66
+ parameter_maxima = [data_cfg["equation"][name][1] for name in ("alpha_range", "beta_range", "gamma_range")]
67
+ if model_cfg["aggregation"] != "sum":
68
+ raise ValueError("Paper Eq. (9) requires model.aggregation=sum")
69
+ if scaling["solution"] != "none":
70
+ raise ValueError("The paper does not specify solution normalization; model.scaling.solution must be none")
71
+ return MPPDESolver(
72
+ time_window=int(model_cfg["time_window"]),
73
+ hidden_dim=int(model_cfg["hidden_dim"]),
74
+ message_passing_layers=int(model_cfg["message_passing_layers"]),
75
+ neighbor_offsets=model_cfg["neighbor_offsets"],
76
+ domain_length=float(data_cfg["domain_length"]),
77
+ final_time=float(data_cfg["final_time"]),
78
+ parameter_maxima=parameter_maxima,
79
+ scale_coordinates=bool(scaling["coordinates"]),
80
+ scale_parameters=bool(scaling["parameters"]),
81
+ instance_norm_affine=bool(model_cfg["instance_norm_affine"]),
82
+ decoder_middle_channels=int(decoder["middle_channels"]),
83
+ decoder_kernels=decoder["kernel_sizes"],
84
+ decoder_strides=decoder["strides"],
85
+ )
86
+
87
+
88
+ def rmse_loss(prediction: Tensor, target: Tensor, epsilon: float) -> Tensor:
89
+ return torch.sqrt(torch.mean((prediction - target) ** 2) + epsilon)
90
+
91
+
92
+ def _extract_windows(trajectories: Tensor, starts: Tensor, window: int, no_grad_pushes: int) -> Tuple[Tensor, Tensor]:
93
+ histories, targets = [], []
94
+ target_offset = window * (no_grad_pushes + 1)
95
+ for sample, start_tensor in zip(trajectories, starts):
96
+ start = int(start_tensor)
97
+ histories.append(sample[start : start + window].transpose(0, 1))
98
+ targets.append(sample[start + target_offset : start + target_offset + window].transpose(0, 1))
99
+ return torch.stack(histories), torch.stack(targets)
100
+
101
+
102
+ def train_step(
103
+ batch: Mapping[str, Tensor],
104
+ model: MPPDESolver,
105
+ optimizer: torch.optim.Optimizer,
106
+ device: torch.device,
107
+ epoch: int,
108
+ config: Mapping[str, Any],
109
+ window_generator: torch.Generator,
110
+ ) -> Tuple[float, int]:
111
+ training = config["training"]
112
+ window = int(config["model"]["time_window"])
113
+ max_pushes = int(training["max_no_grad_pushes"])
114
+ no_grad_pushes = 0 if epoch == 0 else int(torch.randint(0, max_pushes + 1, (1,), generator=window_generator))
115
+ trajectories = batch["u"]
116
+ num_times = trajectories.shape[1]
117
+ max_start = num_times - window * (no_grad_pushes + 2)
118
+ if max_start < 0:
119
+ raise ValueError(f"nt={num_times} is too short for K={window} and pushes={no_grad_pushes}")
120
+ starts = torch.randint(0, max_start + 1, (trajectories.shape[0],), generator=window_generator)
121
+ history, target = _extract_windows(trajectories, starts, window, no_grad_pushes)
122
+ history, target = history.to(device), target.to(device)
123
+ x, parameters = batch["x"].to(device), batch["params"].to(device)
124
+ times = batch["t"]
125
+ dt = (times[:, 1] - times[:, 0]).to(device)
126
+ current_time = torch.stack([times[index, int(start) + window - 1] for index, start in enumerate(starts)]).to(device)
127
+ for _ in range(no_grad_pushes):
128
+ with torch.no_grad():
129
+ history = model(history, x, current_time, parameters, dt).detach()
130
+ current_time = current_time + window * dt
131
+ optimizer.zero_grad(set_to_none=True)
132
+ prediction = model(history, x, current_time, parameters, dt)
133
+ loss = rmse_loss(prediction, target, float(training["loss_epsilon"]))
134
+ loss.backward()
135
+ clip = training.get("gradient_clip")
136
+ if clip is not None:
137
+ torch.nn.utils.clip_grad_norm_(model.parameters(), float(clip))
138
+ optimizer.step()
139
+ return float(loss.detach().cpu()), no_grad_pushes
140
+
141
+
142
+ @torch.inference_mode()
143
+ def evaluate_bundle_rmse(
144
+ model: MPPDESolver, loader: Iterable[Mapping[str, Tensor]], device: torch.device, window: int, epsilon: float
145
+ ) -> float:
146
+ model.eval()
147
+ squared_error, count = 0.0, 0
148
+ for batch in loader:
149
+ history = batch["u"][:, :window].transpose(1, 2).to(device)
150
+ target = batch["u"][:, window : 2 * window].transpose(1, 2).to(device)
151
+ x, times, parameters = batch["x"].to(device), batch["t"], batch["params"].to(device)
152
+ dt = (times[:, 1] - times[:, 0]).to(device)
153
+ current_time = times[:, window - 1].to(device)
154
+ prediction = model(history, x, current_time, parameters, dt)
155
+ squared_error += float(torch.sum((prediction - target) ** 2).cpu())
156
+ count += prediction.numel()
157
+ return float(np.sqrt(squared_error / max(count, 1) + epsilon))
158
+
159
+
160
+ @torch.inference_mode()
161
+ def rollout_batch(model: MPPDESolver, batch: Mapping[str, Tensor], device: torch.device, window: int) -> Tensor:
162
+ target = batch["u"].to(device)
163
+ x, parameters = batch["x"].to(device), batch["params"].to(device)
164
+ times = batch["t"].to(device)
165
+ dt = times[:, 1] - times[:, 0]
166
+ total_times = target.shape[1]
167
+ prediction = torch.empty_like(target)
168
+ prediction[:, :window] = target[:, :window]
169
+ for start in range(window, total_times, window):
170
+ history = prediction[:, start - window : start].transpose(1, 2)
171
+ current_time = times[:, start - 1]
172
+ bundle = model(history, x, current_time, parameters, dt).transpose(1, 2)
173
+ length = min(window, total_times - start)
174
+ prediction[:, start : start + length] = bundle[:, :length]
175
+ if not torch.all(torch.isfinite(bundle)):
176
+ raise FloatingPointError(f"Non-finite rollout output at forecast index {start}")
177
+ return prediction
178
+
179
+
180
+ @torch.inference_mode()
181
+ def evaluate_rollout(
182
+ model: MPPDESolver,
183
+ loader: Iterable[Mapping[str, Tensor]],
184
+ device: torch.device,
185
+ window: int,
186
+ max_samples: Optional[int] = None,
187
+ ) -> Dict[str, float]:
188
+ model.eval()
189
+ per_time_sse: Optional[Tensor] = None
190
+ element_count_per_time = 0
191
+ total_samples = 0
192
+ for batch in loader:
193
+ if max_samples is not None:
194
+ remaining = int(max_samples) - total_samples
195
+ if remaining <= 0:
196
+ break
197
+ if batch["u"].shape[0] > remaining:
198
+ batch = {key: value[:remaining] for key, value in batch.items()}
199
+ prediction = rollout_batch(model, batch, device, window)
200
+ target = batch["u"].to(device)
201
+ error = prediction[:, window:] - target[:, window:]
202
+ current = torch.sum(error.double() ** 2, dim=(0, 2)).cpu()
203
+ per_time_sse = current if per_time_sse is None else per_time_sse + current
204
+ element_count_per_time += error.shape[0] * error.shape[2]
205
+ total_samples += error.shape[0]
206
+ if per_time_sse is None or total_samples == 0:
207
+ raise RuntimeError("No samples were available for rollout evaluation")
208
+ per_time_mse = per_time_sse / element_count_per_time
209
+ return {
210
+ "accumulated_mse": float(per_time_mse.sum()),
211
+ "rmse": float(torch.sqrt(per_time_mse.mean())),
212
+ "samples": float(total_samples),
213
+ }
214
+
215
+
216
+ def _rng_state() -> Dict[str, Any]:
217
+ return {
218
+ "python": random.getstate(),
219
+ "numpy": np.random.get_state(),
220
+ "torch": torch.get_rng_state(),
221
+ "cuda": torch.cuda.get_rng_state_all() if torch.cuda.is_available() else None,
222
+ }
223
+
224
+
225
+ def _restore_rng_state(state: Mapping[str, Any]) -> None:
226
+ random.setstate(state["python"])
227
+ np.random.set_state(state["numpy"])
228
+ torch.set_rng_state(state["torch"])
229
+ if state.get("cuda") is not None and torch.cuda.is_available():
230
+ torch.cuda.set_rng_state_all(state["cuda"])
231
+
232
+
233
+ def atomic_torch_save(payload: Mapping[str, Any], destination: Path) -> None:
234
+ destination.parent.mkdir(parents=True, exist_ok=True)
235
+ with tempfile.NamedTemporaryFile(dir=destination.parent, prefix=destination.name + ".", suffix=".tmp", delete=False) as stream:
236
+ temporary = Path(stream.name)
237
+ try:
238
+ torch.save(dict(payload), temporary)
239
+ os.replace(temporary, destination)
240
+ finally:
241
+ temporary.unlink(missing_ok=True)
242
+
243
+
244
+ def atomic_json_dump(payload: Any, destination: Path) -> None:
245
+ destination.parent.mkdir(parents=True, exist_ok=True)
246
+ with tempfile.NamedTemporaryFile("w", dir=destination.parent, prefix=destination.name + ".", suffix=".tmp", delete=False, encoding="utf-8") as stream:
247
+ json.dump(payload, stream, indent=2, sort_keys=True)
248
+ stream.write("\n")
249
+ temporary = Path(stream.name)
250
+ os.replace(temporary, destination)
251
+
252
+
253
+ def main() -> None:
254
+ parser = argparse.ArgumentParser(description="Train the MP-PDE E3 model")
255
+ parser.add_argument("--config", type=Path, default=PROJECT_ROOT / "config/config.yaml")
256
+ parser.add_argument("--generate-data", action="store_true", help="Generate configured data first when it is absent")
257
+ parser.add_argument("--overwrite-data", action="store_true")
258
+ parser.add_argument("--resume", type=Path)
259
+ parser.add_argument("--init-checkpoint", type=Path)
260
+ parser.add_argument("--device", type=str)
261
+ parser.add_argument("--epochs", type=int, help="Explicit run override; does not modify config.yaml")
262
+ args = parser.parse_args()
263
+ if args.resume is not None and args.init_checkpoint is not None:
264
+ parser.error("--resume and --init-checkpoint are mutually exclusive")
265
+
266
+ config = load_config(args.config.resolve())
267
+ training = config["training"]
268
+ if training["optimizer"] != "AdamW" or training["loss"] != "rmse":
269
+ raise ValueError("Paper-priority E3 requires AdamW and RMSE")
270
+ data_path = project_path(config["paths"]["data"])
271
+ if args.generate_data and (not data_path.exists() or args.overwrite_data):
272
+ generate_e3_hdf5(config, data_path, overwrite=args.overwrite_data)
273
+ if not data_path.is_file():
274
+ raise FileNotFoundError(f"Dataset missing: {data_path}. Run with --generate-data or invoke models/dataset.py")
275
+
276
+ seed = int(training["seed"])
277
+ set_seed(seed, bool(training["deterministic"]))
278
+ device = choose_device(args.device or str(training["device"]))
279
+ window = int(config["model"]["time_window"])
280
+ expected_nt, expected_nx = int(config["data"]["num_time_points"]), int(config["data"]["resolution"])
281
+ datasets = {split: E3Dataset(data_path, split, expected_nt, expected_nx) for split in ("train", "valid", "test")}
282
+ loader_generator = torch.Generator().manual_seed(seed)
283
+ loader_kwargs = dict(batch_size=int(training["batch_size"]), num_workers=int(training["num_workers"]), pin_memory=device.type == "cuda")
284
+ train_loader = DataLoader(datasets["train"], shuffle=True, generator=loader_generator, **loader_kwargs)
285
+ valid_loader = DataLoader(datasets["valid"], shuffle=False, **loader_kwargs)
286
+ test_loader = DataLoader(datasets["test"], shuffle=False, **loader_kwargs)
287
+ window_generator = torch.Generator().manual_seed(seed + 104729)
288
+
289
+ model = build_model(config).to(device)
290
+ optimizer = torch.optim.AdamW(model.parameters(), lr=float(training["learning_rate"]), weight_decay=float(training["weight_decay"]))
291
+ scheduler_cfg = training["scheduler"]
292
+ scheduler = torch.optim.lr_scheduler.MultiStepLR(
293
+ optimizer, milestones=[int(value) for value in scheduler_cfg["milestones"]], gamma=float(scheduler_cfg["gamma"])
294
+ ) if scheduler_cfg["enabled"] else None
295
+ start_epoch, best_metric, history = 0, float("inf"), []
296
+
297
+ if args.init_checkpoint is not None:
298
+ checkpoint = torch.load(args.init_checkpoint, map_location=device, weights_only=False)
299
+ state = checkpoint.get("model_state", checkpoint)
300
+ model.load_state_dict(state, strict=True)
301
+ print(f"Initialized model weights from {args.init_checkpoint}; optimizer and scheduler were reset", flush=True)
302
+ if args.resume is not None:
303
+ checkpoint = torch.load(args.resume, map_location=device, weights_only=False)
304
+ required = {
305
+ "model_state", "optimizer_state", "epoch", "best_validation_accumulated_mse", "rng_state",
306
+ "loader_generator_state", "window_generator_state",
307
+ }
308
+ missing = required.difference(checkpoint)
309
+ if missing:
310
+ raise KeyError(f"Resume checkpoint lacks training state: {sorted(missing)}")
311
+ model.load_state_dict(checkpoint["model_state"], strict=True)
312
+ optimizer.load_state_dict(checkpoint["optimizer_state"])
313
+ if scheduler is not None:
314
+ if checkpoint.get("scheduler_state") is None:
315
+ raise KeyError("Resume checkpoint lacks scheduler_state")
316
+ scheduler.load_state_dict(checkpoint["scheduler_state"])
317
+ start_epoch = int(checkpoint["epoch"]) + 1
318
+ best_metric = float(checkpoint["best_validation_accumulated_mse"])
319
+ history = list(checkpoint.get("history", []))
320
+ _restore_rng_state(checkpoint["rng_state"])
321
+ loader_generator.set_state(checkpoint["loader_generator_state"])
322
+ window_generator.set_state(checkpoint["window_generator_state"])
323
+ print(f"Resumed training from {args.resume} at epoch {start_epoch}", flush=True)
324
+
325
+ epochs = int(args.epochs or training["epochs"])
326
+ if start_epoch >= epochs:
327
+ raise ValueError(f"Resume epoch {start_epoch} is not below requested epochs={epochs}")
328
+ checkpoint_path = project_path(config["paths"]["checkpoint"])
329
+ history_path = project_path(config["paths"]["train_history"])
330
+ print(f"Training MP-PDE E3 on device={device}, parameters={sum(p.numel() for p in model.parameters())}", flush=True)
331
+
332
+ for epoch in range(start_epoch, epochs):
333
+ model.train()
334
+ step_losses = []
335
+ for step, batch in enumerate(train_loader, start=1):
336
+ loss, pushes = train_step(batch, model, optimizer, device, epoch, config, window_generator)
337
+ step_losses.append(loss)
338
+ if step == 1 or step % int(training["print_interval"]) == 0 or step == len(train_loader):
339
+ learning_rate = optimizer.param_groups[0]["lr"]
340
+ print(
341
+ f"epoch={epoch + 1}/{epochs} step={step}/{len(train_loader)} pushes={pushes} "
342
+ f"loss_rmse={loss:.8e} lr={learning_rate:.3e}", flush=True,
343
+ )
344
+ validation_rmse = evaluate_bundle_rmse(model, valid_loader, device, window, float(training["loss_epsilon"]))
345
+ validation_rollout = evaluate_rollout(model, valid_loader, device, window, training.get("validation_rollout_samples"))
346
+ train_rmse = float(np.mean(step_losses))
347
+ learning_rate = float(optimizer.param_groups[0]["lr"])
348
+ epoch_record: Dict[str, Any] = {
349
+ "epoch": epoch,
350
+ "train_rmse": train_rmse,
351
+ "validation_bundle_rmse": validation_rmse,
352
+ "validation_accumulated_mse": validation_rollout["accumulated_mse"],
353
+ "learning_rate": learning_rate,
354
+ }
355
+ print(
356
+ f"epoch={epoch + 1}/{epochs} train_rmse={train_rmse:.8e} val_rmse={validation_rmse:.8e} "
357
+ f"val_accumulated_mse={validation_rollout['accumulated_mse']:.8e} lr={learning_rate:.3e}", flush=True,
358
+ )
359
+ improved = validation_rollout["accumulated_mse"] < best_metric
360
+ if scheduler is not None:
361
+ scheduler.step()
362
+ if improved:
363
+ best_metric = validation_rollout["accumulated_mse"]
364
+ test_rollout = evaluate_rollout(model, test_loader, device, window, training.get("test_rollout_samples"))
365
+ epoch_record["test_accumulated_mse_on_new_best"] = test_rollout["accumulated_mse"]
366
+ epoch_record["test_rmse_on_new_best"] = test_rollout["rmse"]
367
+ print(
368
+ f"new_best epoch={epoch + 1} val_accumulated_mse={best_metric:.8e} "
369
+ f"test_accumulated_mse={test_rollout['accumulated_mse']:.8e} test_rmse={test_rollout['rmse']:.8e}",
370
+ flush=True,
371
+ )
372
+ history.append(epoch_record)
373
+ atomic_json_dump(history, history_path)
374
+ if improved:
375
+ atomic_torch_save(
376
+ {
377
+ "model_state": model.state_dict(),
378
+ "optimizer_state": optimizer.state_dict(),
379
+ "scheduler_state": scheduler.state_dict() if scheduler is not None else None,
380
+ "epoch": epoch,
381
+ "best_validation_accumulated_mse": best_metric,
382
+ "resolved_config": config,
383
+ "rng_state": _rng_state(),
384
+ "loader_generator_state": loader_generator.get_state(),
385
+ "window_generator_state": window_generator.get_state(),
386
+ "history": history,
387
+ },
388
+ checkpoint_path,
389
+ )
390
+ print(f"Saved best checkpoint: {checkpoint_path}", flush=True)
391
+ print(f"Training complete; best_validation_accumulated_mse={best_metric:.8e}", flush=True)
392
+
393
+
394
+ if __name__ == "__main__":
395
+ main()
weight/best_model.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9c8dcb4530bf2f014cde8bc2ef55720eb5ade41ce44c52d7969a22ef23d45c31
3
+ size 12535402