Upload folder using huggingface_hub
Browse files- README.md +156 -0
- config/config.yaml +114 -0
- models/__init__.py +6 -0
- models/dataset.py +572 -0
- models/pde.py +211 -0
- scripts/__init__.py +1 -0
- scripts/inference.py +216 -0
- scripts/result.py +144 -0
- scripts/smoke_test.py +103 -0
- scripts/train.py +395 -0
- weight/best_model.pth +3 -0
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
|