Upload folder using huggingface_hub
Browse files- README.md +197 -0
- config/config.yaml +202 -0
- models/DeepONet.py +269 -0
- models/__pycache__/DeepONet.cpython-311.pyc +0 -0
- models/__pycache__/dataset.cpython-311.pyc +0 -0
- models/dataset.py +637 -0
- scripts/__pycache__/train.cpython-311.pyc +0 -0
- scripts/inference.py +269 -0
- scripts/result.py +236 -0
- scripts/train.py +369 -0
- weight/best_model.pth +3 -0
README.md
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
language:
|
| 4 |
+
- en
|
| 5 |
+
tags:
|
| 6 |
+
- OneScience
|
| 7 |
+
- fluid-mechanics
|
| 8 |
+
- flow-field-prediction
|
| 9 |
+
- neural-operator
|
| 10 |
+
frameworks: PyTorch
|
| 11 |
+
---
|
| 12 |
+
<p align="center">
|
| 13 |
+
<strong>
|
| 14 |
+
<span style="font-size: 30px;">DeepONet</span>
|
| 15 |
+
</strong>
|
| 16 |
+
</p>
|
| 17 |
+
|
| 18 |
+
# Model Introduction
|
| 19 |
+
|
| 20 |
+
DeepONet is a deep neural network for operator learning proposed by a research team affiliated with Brown University and published in *Nature Machine Intelligence* in 2021. Unlike conventional neural networks, which primarily learn mappings between finite-dimensional vectors, DeepONet learns nonlinear operator mappings directly between function spaces, establishing a relationship between an input function and its corresponding output function. It can approximate solution operators defined by ordinary differential equations, partial differential equations, and other physical systems, providing an efficient data-driven modeling approach for complex dynamical systems and scientific computing.
|
| 21 |
+
|
| 22 |
+
Using the OneScience skill workflow, this project independently reproduces experiments related to the DeepONet paper.
|
| 23 |
+
|
| 24 |
+
Paper: [DeepONet: Learning nonlinear operators for identifying differential equations based on the universal approximation theorem of operators](https://arxiv.org/abs/1910.03193)
|
| 25 |
+
|
| 26 |
+
# Model Description
|
| 27 |
+
|
| 28 |
+
DeepONet uses a dual-network architecture consisting of a Branch Net and a Trunk Net. The Branch Net encodes discrete samples of an input function at fixed sensor locations, while the Trunk Net encodes the spatial or spatiotemporal coordinates at which predictions are requested. The output of the target operator at a specified location is obtained from the inner product of the two feature vectors plus a bias term.
|
| 29 |
+
|
| 30 |
+
## Use Cases
|
| 31 |
+
|
| 32 |
+
| Use case | Description |
|
| 33 |
+
| --- | --- |
|
| 34 |
+
| Operator learning | Learns mappings between function spaces, directly predicting an output function \(G(u)\) from an input function \(u\). This is useful for function-to-function mappings that conventional neural networks cannot easily handle. |
|
| 35 |
+
| Spatiotemporal field prediction | Provides fast surrogate predictions for fluid problems such as Navier–Stokes and compressible Euler equations. |
|
| 36 |
+
| Multiscale physical-field modeling | The Trunk Net can directly accept multidimensional coordinates such as \((x,t)\), making it suitable for predicting temperature, concentration, diffusion, and other fields that vary in space and time. |
|
| 37 |
+
| Multiple query-point prediction | For a fixed input function, solutions at different spatial or temporal locations can be predicted by changing only the query coordinates supplied to the Trunk Net. |
|
| 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/DeepONet --local_dir ./DeepONet
|
| 59 |
+
cd DeepONet
|
| 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. The E3 data is generated on the fly by `models/dataset.py` according to `config/config.yaml` and corresponds to the following parameterized one-dimensional PDE:
|
| 87 |
+
|
| 88 |
+
$$
|
| 89 |
+
\frac{\partial u}{\partial t}
|
| 90 |
+
+\alpha\frac{\partial(u^2)}{\partial x}
|
| 91 |
+
-\beta\frac{\partial^2u}{\partial x^2}
|
| 92 |
+
+\gamma\frac{\partial^3u}{\partial x^3}
|
| 93 |
+
=\delta(t,x),
|
| 94 |
+
$$
|
| 95 |
+
|
| 96 |
+
where $x\in[0,16)$ and $t\in[0,4]$, with periodic boundary conditions in the spatial dimension. The equation parameters are sampled independently:
|
| 97 |
+
|
| 98 |
+
$$
|
| 99 |
+
\alpha\sim\mathcal U(0,3),\quad
|
| 100 |
+
\beta\sim\mathcal U(0,0.4),\quad
|
| 101 |
+
\gamma\sim\mathcal U(0,1).
|
| 102 |
+
$$
|
| 103 |
+
|
| 104 |
+
The forcing term and initial condition are defined as:
|
| 105 |
+
|
| 106 |
+
$$
|
| 107 |
+
\delta(t,x)=\sum_{j=1}^{5}
|
| 108 |
+
A_j\sin\left(
|
| 109 |
+
\omega_jt+\frac{2\pi k_jx}{16}+\phi_j
|
| 110 |
+
\right),
|
| 111 |
+
\qquad
|
| 112 |
+
u(0,x)=\delta(0,x),
|
| 113 |
+
$$
|
| 114 |
+
|
| 115 |
+
where:
|
| 116 |
+
|
| 117 |
+
- $A_j\sim\mathcal U(-0.5,0.5)$;
|
| 118 |
+
- $\omega_j=-0.4$;
|
| 119 |
+
- $k_j\in\{1,2,3\}$;
|
| 120 |
+
- $\phi_j\sim\mathcal U(0,2\pi)$.
|
| 121 |
+
|
| 122 |
+
Reference solutions are first generated on 200 spatial grid points and then downsampled to 100 points. The nonlinear flux is discretized using a fifth-order WENO scheme, and time integration uses a fourth-order Runge–Kutta method. Each trajectory contains 250 time points and has shape:
|
| 123 |
+
|
| 124 |
+
$$
|
| 125 |
+
u\in\mathbb R^{250\times100}.
|
| 126 |
+
$$
|
| 127 |
+
|
| 128 |
+
The model uses a history window of length $K=25$ to predict the next 25 time steps. One supervised sample can be written as:
|
| 129 |
+
|
| 130 |
+
$$
|
| 131 |
+
\left(
|
| 132 |
+
u_{i:i+K-1},\,x,\,t,\,(\alpha,\beta,\gamma);
|
| 133 |
+
\ u_{i+K:i+2K-1}
|
| 134 |
+
\right).
|
| 135 |
+
$$
|
| 136 |
+
|
| 137 |
+
### Training
|
| 138 |
+
|
| 139 |
+
The default configuration reproduces four operator-learning experiments from the DeepONet paper: antiderivative, nonlinear ODE, forced pendulum, and diffusion–reaction equation. By default, the training script runs the antiderivative experiment, with input functions and reference solutions generated on the fly by `models/dataset.py`.
|
| 140 |
+
|
| 141 |
+
Run the default antiderivative experiment:
|
| 142 |
+
|
| 143 |
+
```bash
|
| 144 |
+
python scripts/train.py \
|
| 145 |
+
--config config/config.yaml \
|
| 146 |
+
--experiment antiderivative \
|
| 147 |
+
--device auto
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
Set `--experiment` to `all` to run all four main experiments from the paper sequentially.
|
| 151 |
+
|
| 152 |
+
During training, the training loss, test MSE, and relative L2 error are printed at the intervals configured in `config/config.yaml`. When the test MSE improves, the model weights, optimizer state, current iteration, evaluation metrics, and effective runtime configuration are saved to `weight/best_model.pth`.
|
| 153 |
+
|
| 154 |
+
### Trained Weights
|
| 155 |
+
|
| 156 |
+
`weight/best_model.pth` contains the best weights from the full antiderivative experiment and can be used directly for inference or fine-tuning.
|
| 157 |
+
|
| 158 |
+
### Inference
|
| 159 |
+
|
| 160 |
+
Before running inference, make sure the configured data path is valid and `weight/best_model.pth` exists.
|
| 161 |
+
|
| 162 |
+
```bash
|
| 163 |
+
python scripts/inference.py \
|
| 164 |
+
--config config/config.yaml \
|
| 165 |
+
--experiment <experiment-name> \
|
| 166 |
+
--variant unstacked_bias \
|
| 167 |
+
--mode <inference-mode> \
|
| 168 |
+
--device auto
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
Available inference modes are:
|
| 172 |
+
|
| 173 |
+
- `random_test`: random test set;
|
| 174 |
+
- `ood`: out-of-distribution ODE inputs;
|
| 175 |
+
- `pde_grid`: two-dimensional spatiotemporal field for the diffusion–reaction equation.
|
| 176 |
+
|
| 177 |
+
The default batch size is 8,192 and can be changed with `--batch-size`. Predictions and evaluation metrics are saved to the `results` directory.
|
| 178 |
+
|
| 179 |
+
### Evaluation and Visualization
|
| 180 |
+
|
| 181 |
+
After training and inference, summarize existing experiment results and generate training curves, prediction comparisons, and an evaluation report with:
|
| 182 |
+
|
| 183 |
+
```bash
|
| 184 |
+
python scripts/result.py --config config/config.yaml
|
| 185 |
+
```
|
| 186 |
+
|
| 187 |
+
# Official OneScience Resources
|
| 188 |
+
|
| 189 |
+
| Platform | OneScience Main Repository | Skills Repository |
|
| 190 |
+
| --- | --- | --- |
|
| 191 |
+
| Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
|
| 192 |
+
| GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
|
| 193 |
+
|
| 194 |
+
# Citation and License
|
| 195 |
+
|
| 196 |
+
- Original paper: [DeepONet: Learning nonlinear operators for identifying differential equations based on the universal approximation theorem of operators](https://arxiv.org/abs/1910.03193)
|
| 197 |
+
- This project is an independent reproduction of the DeepONet paper. The official implementation is licensed under the MIT License. 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,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
project:
|
| 2 |
+
name: DeepONet
|
| 3 |
+
paper: "DeepONet: Learning nonlinear operators for identifying differential equations based on the universal approximation theorem of operators"
|
| 4 |
+
arxiv: "1910.03193v3"
|
| 5 |
+
paper_url: "https://arxiv.org/abs/1910.03193"
|
| 6 |
+
implementation_policy: "paper_first_independent_reimplementation"
|
| 7 |
+
paper_scale: true
|
| 8 |
+
|
| 9 |
+
paths:
|
| 10 |
+
checkpoint: weight/best_model.pth
|
| 11 |
+
results: results
|
| 12 |
+
cache: results/cache
|
| 13 |
+
|
| 14 |
+
runtime:
|
| 15 |
+
seed: 0 # ASSUMPTION: the paper does not report seeds.
|
| 16 |
+
device: auto
|
| 17 |
+
dtype: float32 # ASSUMPTION: precision is not reported.
|
| 18 |
+
num_workers: 0
|
| 19 |
+
|
| 20 |
+
function_space:
|
| 21 |
+
type: grf
|
| 22 |
+
length_scale: 0.2 # PAPER: default RBF-GRF length scale.
|
| 23 |
+
grf_grid_size: 1000 # OFFICIAL_REPO_REFERENCE: paper omits this discretization.
|
| 24 |
+
jitter: 1.0e-13 # OFFICIAL_REPO_REFERENCE: numerical Cholesky stabilizer.
|
| 25 |
+
interpolation: cubic # OFFICIAL_REPO_REFERENCE.
|
| 26 |
+
generation_chunk_size: 128
|
| 27 |
+
chebyshev:
|
| 28 |
+
degree: 10
|
| 29 |
+
coefficient_bound: 1.0
|
| 30 |
+
|
| 31 |
+
model_defaults:
|
| 32 |
+
architecture: deeponet
|
| 33 |
+
activation: relu # OFFICIAL_REPO_REFERENCE: paper does not state activation.
|
| 34 |
+
initializer: xavier_normal # OFFICIAL_REPO_REFERENCE: PyTorch equivalent of Glorot normal.
|
| 35 |
+
dense_bias: true
|
| 36 |
+
branch_depth: 2 # ASSUMPTION: number of Dense layers after the input.
|
| 37 |
+
trunk_depth: 3 # ASSUMPTION: number of Dense layers after the input.
|
| 38 |
+
width: 40
|
| 39 |
+
latent_dim: 40
|
| 40 |
+
|
| 41 |
+
variants:
|
| 42 |
+
unstacked_bias:
|
| 43 |
+
architecture: deeponet
|
| 44 |
+
stacked: false
|
| 45 |
+
branch_output_bias: true
|
| 46 |
+
global_bias: true
|
| 47 |
+
unstacked_no_bias:
|
| 48 |
+
architecture: deeponet
|
| 49 |
+
stacked: false
|
| 50 |
+
branch_output_bias: false
|
| 51 |
+
global_bias: false
|
| 52 |
+
stacked_bias:
|
| 53 |
+
architecture: deeponet
|
| 54 |
+
stacked: true
|
| 55 |
+
branch_output_bias: true
|
| 56 |
+
global_bias: true
|
| 57 |
+
stacked_no_bias:
|
| 58 |
+
architecture: deeponet
|
| 59 |
+
stacked: true
|
| 60 |
+
branch_output_bias: false
|
| 61 |
+
global_bias: false
|
| 62 |
+
fnn:
|
| 63 |
+
architecture: fnn
|
| 64 |
+
depth: 3
|
| 65 |
+
width: 40
|
| 66 |
+
output_bias: true
|
| 67 |
+
|
| 68 |
+
training_defaults:
|
| 69 |
+
optimizer: adam
|
| 70 |
+
learning_rate: 1.0e-3 # PAPER.
|
| 71 |
+
batch_size: null # OFFICIAL_REPO_REFERENCE: null means full batch.
|
| 72 |
+
evaluation_batch_size: 8192
|
| 73 |
+
weight_decay: 0.0 # No paper evidence; disabled.
|
| 74 |
+
print_every: 1000
|
| 75 |
+
evaluate_every: 1000
|
| 76 |
+
checkpoint_metric: test_mse
|
| 77 |
+
resume: false
|
| 78 |
+
|
| 79 |
+
solver_defaults:
|
| 80 |
+
method: RK45 # PAPER: Runge-Kutta (4,5); method name from official reference.
|
| 81 |
+
rtol: 1.0e-7 # ASSUMPTION: solver tolerances are not reported.
|
| 82 |
+
atol: 1.0e-9
|
| 83 |
+
pde_newton_tolerance: 1.0e-10
|
| 84 |
+
pde_newton_max_iterations: 20
|
| 85 |
+
|
| 86 |
+
experiments:
|
| 87 |
+
antiderivative:
|
| 88 |
+
equation: "ds/dx = u(x), s(0) = 0"
|
| 89 |
+
sensor_points: 100
|
| 90 |
+
trunk_dim: 1
|
| 91 |
+
domain_end: 1.0
|
| 92 |
+
train_size: 10000
|
| 93 |
+
test_size: 100000
|
| 94 |
+
iterations: 50000
|
| 95 |
+
default_variant: unstacked_bias
|
| 96 |
+
sweeps:
|
| 97 |
+
architectures: [fnn, unstacked_no_bias, unstacked_bias, stacked_no_bias, stacked_bias]
|
| 98 |
+
fnn_depths: [2, 3, 4]
|
| 99 |
+
fnn_widths: [20, 40, 100]
|
| 100 |
+
learning_rates: [0.0001, 0.001, 0.01]
|
| 101 |
+
|
| 102 |
+
nonlinear_ode:
|
| 103 |
+
equation: "ds/dx = -s^2 + u(x), s(0) = 0"
|
| 104 |
+
sensor_points: 100
|
| 105 |
+
trunk_dim: 1
|
| 106 |
+
domain_end: 1.0
|
| 107 |
+
train_size: 10000
|
| 108 |
+
test_size: 100000
|
| 109 |
+
iterations: 100000
|
| 110 |
+
default_variant: unstacked_bias
|
| 111 |
+
trim_fraction: 0.001
|
| 112 |
+
ood_functions: [linear, sin_pi, sin_2pi]
|
| 113 |
+
sweeps:
|
| 114 |
+
architectures: [stacked_no_bias, stacked_bias, unstacked_no_bias, unstacked_bias]
|
| 115 |
+
|
| 116 |
+
pendulum:
|
| 117 |
+
equation: "ds1/dt = s2; ds2/dt = -k*sin(s1) + u(t); s(0) = (0,0)"
|
| 118 |
+
sensor_points: 100
|
| 119 |
+
trunk_dim: 1
|
| 120 |
+
domain_end: 1.0
|
| 121 |
+
k: 1.0
|
| 122 |
+
train_size: 10000
|
| 123 |
+
test_size: 100000
|
| 124 |
+
iterations: 100000
|
| 125 |
+
default_variant: unstacked_bias
|
| 126 |
+
convergence_iterations: 500000
|
| 127 |
+
convergence_test_size: 1000000
|
| 128 |
+
sweeps:
|
| 129 |
+
sensor_points: [10, 20, 50, 100, 200]
|
| 130 |
+
domain_end: [1.0, 2.0, 3.0, 4.0]
|
| 131 |
+
length_scale: [0.1, 0.2, 0.5, 1.0]
|
| 132 |
+
k: [0.5, 1.0, 2.0]
|
| 133 |
+
width: [20, 40, 100]
|
| 134 |
+
train_size: [1000, 5000, 10000]
|
| 135 |
+
chebyshev_degree: [5, 10, 20]
|
| 136 |
+
|
| 137 |
+
diffusion_reaction:
|
| 138 |
+
equation: "s_t = D*s_xx + k*s^2 + u(x)"
|
| 139 |
+
sensor_points: 100
|
| 140 |
+
trunk_dim: 2
|
| 141 |
+
domain_end: 1.0
|
| 142 |
+
train_functions: 100
|
| 143 |
+
points_per_function: 1000
|
| 144 |
+
test_functions: 1000
|
| 145 |
+
test_points_per_function: 1000
|
| 146 |
+
test_size: 1000000
|
| 147 |
+
iterations: 500000
|
| 148 |
+
default_variant: unstacked_bias
|
| 149 |
+
diffusion: 0.01
|
| 150 |
+
reaction: 0.01
|
| 151 |
+
space_points: 100
|
| 152 |
+
time_points: 100
|
| 153 |
+
branch_depth: 2
|
| 154 |
+
trunk_depth: 3
|
| 155 |
+
width: 100
|
| 156 |
+
latent_dim: 100
|
| 157 |
+
sweeps:
|
| 158 |
+
train_functions: [50, 100, 200, 400]
|
| 159 |
+
points_per_function: [10, 100, 1000, 10000]
|
| 160 |
+
|
| 161 |
+
inference:
|
| 162 |
+
batch_size: 8192
|
| 163 |
+
ood_query_points: 100
|
| 164 |
+
save_inputs: true
|
| 165 |
+
|
| 166 |
+
smoke_test:
|
| 167 |
+
project:
|
| 168 |
+
paper_scale: false
|
| 169 |
+
function_space:
|
| 170 |
+
grf_grid_size: 32
|
| 171 |
+
generation_chunk_size: 16
|
| 172 |
+
training_defaults:
|
| 173 |
+
batch_size: 16
|
| 174 |
+
evaluation_batch_size: 32
|
| 175 |
+
print_every: 1
|
| 176 |
+
evaluate_every: 1
|
| 177 |
+
experiments:
|
| 178 |
+
antiderivative:
|
| 179 |
+
sensor_points: 16
|
| 180 |
+
train_size: 32
|
| 181 |
+
test_size: 24
|
| 182 |
+
iterations: 2
|
| 183 |
+
nonlinear_ode:
|
| 184 |
+
sensor_points: 16
|
| 185 |
+
train_size: 24
|
| 186 |
+
test_size: 16
|
| 187 |
+
iterations: 2
|
| 188 |
+
pendulum:
|
| 189 |
+
sensor_points: 16
|
| 190 |
+
train_size: 24
|
| 191 |
+
test_size: 16
|
| 192 |
+
iterations: 2
|
| 193 |
+
diffusion_reaction:
|
| 194 |
+
sensor_points: 16
|
| 195 |
+
train_functions: 4
|
| 196 |
+
points_per_function: 8
|
| 197 |
+
test_functions: 2
|
| 198 |
+
test_points_per_function: 8
|
| 199 |
+
test_size: 16
|
| 200 |
+
space_points: 12
|
| 201 |
+
time_points: 12
|
| 202 |
+
iterations: 2
|
models/DeepONet.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Independent PyTorch implementation of the DeepONet in arXiv:1910.03193.
|
| 2 |
+
|
| 3 |
+
The paper is the architectural authority. No implementation from the official
|
| 4 |
+
repository is imported or copied. ReLU and Xavier-normal initialization are
|
| 5 |
+
configurable details used only because the paper leaves them unspecified.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import Any, Dict, Mapping
|
| 11 |
+
|
| 12 |
+
import torch
|
| 13 |
+
from torch import Tensor, nn
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _activation(name: str) -> nn.Module:
|
| 17 |
+
choices = {
|
| 18 |
+
"relu": nn.ReLU,
|
| 19 |
+
"tanh": nn.Tanh,
|
| 20 |
+
"gelu": nn.GELU,
|
| 21 |
+
"silu": nn.SiLU,
|
| 22 |
+
}
|
| 23 |
+
try:
|
| 24 |
+
return choices[name.lower()]()
|
| 25 |
+
except KeyError as exc:
|
| 26 |
+
raise ValueError(f"Unsupported activation {name!r}; choose {sorted(choices)}") from exc
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class DenseNetwork(nn.Module):
|
| 30 |
+
"""A dense network where ``depth`` counts all Linear layers."""
|
| 31 |
+
|
| 32 |
+
def __init__(
|
| 33 |
+
self,
|
| 34 |
+
input_dim: int,
|
| 35 |
+
output_dim: int,
|
| 36 |
+
depth: int,
|
| 37 |
+
width: int,
|
| 38 |
+
activation: str,
|
| 39 |
+
*,
|
| 40 |
+
activate_output: bool,
|
| 41 |
+
dense_bias: bool = True,
|
| 42 |
+
output_bias: bool = True,
|
| 43 |
+
) -> None:
|
| 44 |
+
super().__init__()
|
| 45 |
+
if depth < 1:
|
| 46 |
+
raise ValueError("depth must be at least one")
|
| 47 |
+
if min(input_dim, output_dim, width) < 1:
|
| 48 |
+
raise ValueError("input_dim, output_dim and width must be positive")
|
| 49 |
+
|
| 50 |
+
layers = []
|
| 51 |
+
current_dim = input_dim
|
| 52 |
+
for layer_index in range(depth):
|
| 53 |
+
is_output = layer_index == depth - 1
|
| 54 |
+
next_dim = output_dim if is_output else width
|
| 55 |
+
layers.append(
|
| 56 |
+
nn.Linear(
|
| 57 |
+
current_dim,
|
| 58 |
+
next_dim,
|
| 59 |
+
bias=output_bias if is_output else dense_bias,
|
| 60 |
+
)
|
| 61 |
+
)
|
| 62 |
+
if not is_output or activate_output:
|
| 63 |
+
layers.append(_activation(activation))
|
| 64 |
+
current_dim = next_dim
|
| 65 |
+
self.layers = nn.Sequential(*layers)
|
| 66 |
+
|
| 67 |
+
def forward(self, inputs: Tensor) -> Tensor:
|
| 68 |
+
return self.layers(inputs)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class DeepONet(nn.Module):
|
| 72 |
+
"""Stacked or unstacked DeepONet with the paper's branch/trunk fusion."""
|
| 73 |
+
|
| 74 |
+
def __init__(
|
| 75 |
+
self,
|
| 76 |
+
branch_input_dim: int,
|
| 77 |
+
trunk_input_dim: int,
|
| 78 |
+
latent_dim: int,
|
| 79 |
+
*,
|
| 80 |
+
branch_depth: int = 2,
|
| 81 |
+
trunk_depth: int = 3,
|
| 82 |
+
width: int = 40,
|
| 83 |
+
activation: str = "relu",
|
| 84 |
+
stacked: bool = False,
|
| 85 |
+
dense_bias: bool = True,
|
| 86 |
+
branch_output_bias: bool = True,
|
| 87 |
+
global_bias: bool = True,
|
| 88 |
+
initializer: str = "xavier_normal",
|
| 89 |
+
) -> None:
|
| 90 |
+
super().__init__()
|
| 91 |
+
self.branch_input_dim = int(branch_input_dim)
|
| 92 |
+
self.trunk_input_dim = int(trunk_input_dim)
|
| 93 |
+
self.latent_dim = int(latent_dim)
|
| 94 |
+
self.stacked = bool(stacked)
|
| 95 |
+
|
| 96 |
+
branch_kwargs = dict(
|
| 97 |
+
input_dim=self.branch_input_dim,
|
| 98 |
+
output_dim=1 if self.stacked else self.latent_dim,
|
| 99 |
+
depth=branch_depth,
|
| 100 |
+
width=width,
|
| 101 |
+
activation=activation,
|
| 102 |
+
activate_output=False,
|
| 103 |
+
dense_bias=dense_bias,
|
| 104 |
+
output_bias=branch_output_bias,
|
| 105 |
+
)
|
| 106 |
+
if self.stacked:
|
| 107 |
+
self.branch = nn.ModuleList(
|
| 108 |
+
DenseNetwork(**branch_kwargs) for _ in range(self.latent_dim)
|
| 109 |
+
)
|
| 110 |
+
else:
|
| 111 |
+
self.branch = DenseNetwork(**branch_kwargs)
|
| 112 |
+
|
| 113 |
+
self.trunk = DenseNetwork(
|
| 114 |
+
input_dim=self.trunk_input_dim,
|
| 115 |
+
output_dim=self.latent_dim,
|
| 116 |
+
depth=trunk_depth,
|
| 117 |
+
width=width,
|
| 118 |
+
activation=activation,
|
| 119 |
+
activate_output=True,
|
| 120 |
+
dense_bias=dense_bias,
|
| 121 |
+
output_bias=dense_bias,
|
| 122 |
+
)
|
| 123 |
+
if global_bias:
|
| 124 |
+
self.output_bias = nn.Parameter(torch.zeros(1))
|
| 125 |
+
else:
|
| 126 |
+
self.register_parameter("output_bias", None)
|
| 127 |
+
self.reset_parameters(initializer)
|
| 128 |
+
|
| 129 |
+
def reset_parameters(self, initializer: str = "xavier_normal") -> None:
|
| 130 |
+
for module in self.modules():
|
| 131 |
+
if not isinstance(module, nn.Linear):
|
| 132 |
+
continue
|
| 133 |
+
if initializer == "xavier_normal":
|
| 134 |
+
nn.init.xavier_normal_(module.weight)
|
| 135 |
+
elif initializer == "xavier_uniform":
|
| 136 |
+
nn.init.xavier_uniform_(module.weight)
|
| 137 |
+
else:
|
| 138 |
+
raise ValueError(f"Unsupported initializer {initializer!r}")
|
| 139 |
+
if module.bias is not None:
|
| 140 |
+
nn.init.zeros_(module.bias)
|
| 141 |
+
|
| 142 |
+
def encode_branch(self, branch_inputs: Tensor) -> Tensor:
|
| 143 |
+
if branch_inputs.ndim != 2 or branch_inputs.shape[1] != self.branch_input_dim:
|
| 144 |
+
raise ValueError(
|
| 145 |
+
f"branch input must have shape [N,{self.branch_input_dim}], "
|
| 146 |
+
f"got {tuple(branch_inputs.shape)}"
|
| 147 |
+
)
|
| 148 |
+
if self.stacked:
|
| 149 |
+
return torch.cat([head(branch_inputs) for head in self.branch], dim=-1)
|
| 150 |
+
return self.branch(branch_inputs)
|
| 151 |
+
|
| 152 |
+
def forward(self, branch_inputs: Tensor, trunk_inputs: Tensor) -> Tensor:
|
| 153 |
+
if trunk_inputs.ndim != 2 or trunk_inputs.shape[1] != self.trunk_input_dim:
|
| 154 |
+
raise ValueError(
|
| 155 |
+
f"trunk input must have shape [N,{self.trunk_input_dim}], "
|
| 156 |
+
f"got {tuple(trunk_inputs.shape)}"
|
| 157 |
+
)
|
| 158 |
+
if branch_inputs.shape[0] != trunk_inputs.shape[0]:
|
| 159 |
+
raise ValueError("branch and trunk batches must contain the same number of rows")
|
| 160 |
+
branch_features = self.encode_branch(branch_inputs)
|
| 161 |
+
trunk_features = self.trunk(trunk_inputs)
|
| 162 |
+
prediction = torch.sum(branch_features * trunk_features, dim=-1, keepdim=True)
|
| 163 |
+
if self.output_bias is not None:
|
| 164 |
+
prediction = prediction + self.output_bias
|
| 165 |
+
return prediction
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
class FNNBaseline(nn.Module):
|
| 169 |
+
"""Paper baseline that concatenates sensor values and the query coordinate."""
|
| 170 |
+
|
| 171 |
+
def __init__(
|
| 172 |
+
self,
|
| 173 |
+
branch_input_dim: int,
|
| 174 |
+
trunk_input_dim: int,
|
| 175 |
+
*,
|
| 176 |
+
depth: int = 3,
|
| 177 |
+
width: int = 40,
|
| 178 |
+
activation: str = "relu",
|
| 179 |
+
output_bias: bool = True,
|
| 180 |
+
initializer: str = "xavier_normal",
|
| 181 |
+
) -> None:
|
| 182 |
+
super().__init__()
|
| 183 |
+
self.branch_input_dim = int(branch_input_dim)
|
| 184 |
+
self.trunk_input_dim = int(trunk_input_dim)
|
| 185 |
+
self.network = DenseNetwork(
|
| 186 |
+
input_dim=self.branch_input_dim + self.trunk_input_dim,
|
| 187 |
+
output_dim=1,
|
| 188 |
+
depth=depth,
|
| 189 |
+
width=width,
|
| 190 |
+
activation=activation,
|
| 191 |
+
activate_output=False,
|
| 192 |
+
output_bias=output_bias,
|
| 193 |
+
)
|
| 194 |
+
for module in self.modules():
|
| 195 |
+
if isinstance(module, nn.Linear):
|
| 196 |
+
if initializer == "xavier_normal":
|
| 197 |
+
nn.init.xavier_normal_(module.weight)
|
| 198 |
+
elif initializer == "xavier_uniform":
|
| 199 |
+
nn.init.xavier_uniform_(module.weight)
|
| 200 |
+
else:
|
| 201 |
+
raise ValueError(f"Unsupported initializer {initializer!r}")
|
| 202 |
+
if module.bias is not None:
|
| 203 |
+
nn.init.zeros_(module.bias)
|
| 204 |
+
|
| 205 |
+
def forward(self, branch_inputs: Tensor, trunk_inputs: Tensor) -> Tensor:
|
| 206 |
+
if branch_inputs.ndim != 2 or branch_inputs.shape[1] != self.branch_input_dim:
|
| 207 |
+
raise ValueError("invalid branch input shape")
|
| 208 |
+
if trunk_inputs.ndim != 2 or trunk_inputs.shape[1] != self.trunk_input_dim:
|
| 209 |
+
raise ValueError("invalid trunk input shape")
|
| 210 |
+
return self.network(torch.cat((branch_inputs, trunk_inputs), dim=-1))
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def _merged_model_config(config: Mapping[str, Any], experiment: str) -> Dict[str, Any]:
|
| 214 |
+
if experiment not in config.get("experiments", {}):
|
| 215 |
+
raise KeyError(f"Unknown experiment {experiment!r}")
|
| 216 |
+
merged = dict(config.get("model_defaults", {}))
|
| 217 |
+
for key in ("branch_depth", "trunk_depth", "width", "latent_dim"):
|
| 218 |
+
if key in config["experiments"][experiment]:
|
| 219 |
+
merged[key] = config["experiments"][experiment][key]
|
| 220 |
+
return merged
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def build_model(
|
| 224 |
+
config: Mapping[str, Any],
|
| 225 |
+
experiment: str,
|
| 226 |
+
variant: str | None = None,
|
| 227 |
+
) -> nn.Module:
|
| 228 |
+
"""Build a model from the YAML-compatible configuration mapping."""
|
| 229 |
+
|
| 230 |
+
experiment_config = config["experiments"][experiment]
|
| 231 |
+
variant_name = variant or experiment_config["default_variant"]
|
| 232 |
+
try:
|
| 233 |
+
variant_config = config["variants"][variant_name]
|
| 234 |
+
except KeyError as exc:
|
| 235 |
+
raise KeyError(f"Unknown model variant {variant_name!r}") from exc
|
| 236 |
+
model_config = _merged_model_config(config, experiment)
|
| 237 |
+
common = dict(
|
| 238 |
+
branch_input_dim=int(experiment_config["sensor_points"]),
|
| 239 |
+
trunk_input_dim=int(experiment_config["trunk_dim"]),
|
| 240 |
+
activation=str(model_config["activation"]),
|
| 241 |
+
initializer=str(model_config["initializer"]),
|
| 242 |
+
)
|
| 243 |
+
if variant_config["architecture"] == "fnn":
|
| 244 |
+
return FNNBaseline(
|
| 245 |
+
**common,
|
| 246 |
+
depth=int(variant_config["depth"]),
|
| 247 |
+
width=int(variant_config["width"]),
|
| 248 |
+
output_bias=bool(variant_config.get("output_bias", True)),
|
| 249 |
+
)
|
| 250 |
+
if variant_config["architecture"] != "deeponet":
|
| 251 |
+
raise ValueError(f"Unsupported architecture {variant_config['architecture']!r}")
|
| 252 |
+
return DeepONet(
|
| 253 |
+
**common,
|
| 254 |
+
latent_dim=int(model_config["latent_dim"]),
|
| 255 |
+
branch_depth=int(model_config["branch_depth"]),
|
| 256 |
+
trunk_depth=int(model_config["trunk_depth"]),
|
| 257 |
+
width=int(model_config["width"]),
|
| 258 |
+
stacked=bool(variant_config["stacked"]),
|
| 259 |
+
dense_bias=bool(model_config.get("dense_bias", True)),
|
| 260 |
+
branch_output_bias=bool(variant_config["branch_output_bias"]),
|
| 261 |
+
global_bias=bool(variant_config["global_bias"]),
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def count_parameters(model: nn.Module) -> int:
|
| 266 |
+
return sum(parameter.numel() for parameter in model.parameters() if parameter.requires_grad)
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
__all__ = ["DeepONet", "FNNBaseline", "build_model", "count_parameters"]
|
models/__pycache__/DeepONet.cpython-311.pyc
ADDED
|
Binary file (15.2 kB). View file
|
|
|
models/__pycache__/dataset.cpython-311.pyc
ADDED
|
Binary file (41.4 kB). View file
|
|
|
models/dataset.py
ADDED
|
@@ -0,0 +1,637 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Synthetic datasets for the four main experiments in arXiv:1910.03193.
|
| 2 |
+
|
| 3 |
+
All equations and split rules follow the paper. The numerical choices that the
|
| 4 |
+
paper omits are configurable and documented in ``config/config.yaml``.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import copy
|
| 10 |
+
import hashlib
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
import tempfile
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from typing import Any, Dict, Mapping, Sequence, Tuple
|
| 16 |
+
|
| 17 |
+
import numpy as np
|
| 18 |
+
import torch
|
| 19 |
+
from torch.utils.data import Dataset
|
| 20 |
+
|
| 21 |
+
try:
|
| 22 |
+
from scipy.integrate import cumulative_trapezoid, solve_ivp
|
| 23 |
+
from scipy.interpolate import CubicSpline
|
| 24 |
+
except ImportError: # Deferred error keeps model-only imports usable.
|
| 25 |
+
cumulative_trapezoid = None
|
| 26 |
+
solve_ivp = None
|
| 27 |
+
CubicSpline = None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def deep_update(base: Dict[str, Any], override: Mapping[str, Any]) -> Dict[str, Any]:
|
| 31 |
+
"""Recursively update ``base`` without mutating the caller's mapping."""
|
| 32 |
+
|
| 33 |
+
for key, value in override.items():
|
| 34 |
+
if isinstance(value, Mapping) and isinstance(base.get(key), Mapping):
|
| 35 |
+
base[key] = deep_update(dict(base[key]), value)
|
| 36 |
+
else:
|
| 37 |
+
base[key] = copy.deepcopy(value)
|
| 38 |
+
return base
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def resolve_config(config: Mapping[str, Any], smoke_test: bool = False) -> Dict[str, Any]:
|
| 42 |
+
resolved = copy.deepcopy(dict(config))
|
| 43 |
+
smoke_override = resolved.pop("smoke_test", {})
|
| 44 |
+
if smoke_test:
|
| 45 |
+
resolved = deep_update(resolved, smoke_override)
|
| 46 |
+
resolved.setdefault("project", {})["paper_scale"] = not smoke_test
|
| 47 |
+
return resolved
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class OperatorDataset(Dataset):
|
| 51 |
+
"""Triplets with compact storage for repeated PDE branch functions."""
|
| 52 |
+
|
| 53 |
+
def __init__(
|
| 54 |
+
self,
|
| 55 |
+
branch_functions: np.ndarray,
|
| 56 |
+
trunk: np.ndarray,
|
| 57 |
+
target: np.ndarray,
|
| 58 |
+
function_index: np.ndarray | None = None,
|
| 59 |
+
metadata: Mapping[str, Any] | None = None,
|
| 60 |
+
) -> None:
|
| 61 |
+
branch_functions = np.asarray(branch_functions, dtype=np.float32)
|
| 62 |
+
trunk = np.asarray(trunk, dtype=np.float32)
|
| 63 |
+
target = np.asarray(target, dtype=np.float32)
|
| 64 |
+
if function_index is None:
|
| 65 |
+
function_index = np.arange(len(trunk), dtype=np.int64)
|
| 66 |
+
function_index = np.asarray(function_index, dtype=np.int64)
|
| 67 |
+
if branch_functions.ndim != 2 or trunk.ndim != 2 or target.ndim != 2:
|
| 68 |
+
raise ValueError("branch, trunk and target arrays must all have rank two")
|
| 69 |
+
if target.shape[1] != 1 or len(trunk) != len(target) or len(trunk) != len(function_index):
|
| 70 |
+
raise ValueError("trunk, target and function_index lengths must agree")
|
| 71 |
+
if len(function_index) and (
|
| 72 |
+
function_index.min() < 0 or function_index.max() >= len(branch_functions)
|
| 73 |
+
):
|
| 74 |
+
raise ValueError("function_index refers outside branch_functions")
|
| 75 |
+
self.branch_functions = branch_functions
|
| 76 |
+
self.trunk = trunk
|
| 77 |
+
self.target = target
|
| 78 |
+
self.function_index = function_index
|
| 79 |
+
self.metadata = dict(metadata or {})
|
| 80 |
+
|
| 81 |
+
def __len__(self) -> int:
|
| 82 |
+
return len(self.trunk)
|
| 83 |
+
|
| 84 |
+
def __getitem__(self, index: int) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
| 85 |
+
function_id = self.function_index[index]
|
| 86 |
+
return (
|
| 87 |
+
torch.from_numpy(self.branch_functions[function_id]),
|
| 88 |
+
torch.from_numpy(self.trunk[index]),
|
| 89 |
+
torch.from_numpy(self.target[index]),
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
def expanded_branch(self) -> np.ndarray:
|
| 93 |
+
return self.branch_functions[self.function_index]
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class FunctionSpaceSampler:
|
| 97 |
+
"""GRF or Chebyshev function sampler on a reusable fine grid."""
|
| 98 |
+
|
| 99 |
+
def __init__(self, config: Mapping[str, Any], domain_end: float) -> None:
|
| 100 |
+
self.config = dict(config)
|
| 101 |
+
self.domain_end = float(domain_end)
|
| 102 |
+
self.grid_size = int(self.config["grf_grid_size"])
|
| 103 |
+
self.grid = np.linspace(0.0, self.domain_end, self.grid_size, dtype=np.float64)
|
| 104 |
+
self.kind = str(self.config.get("type", "grf")).lower()
|
| 105 |
+
self._cholesky: np.ndarray | None = None
|
| 106 |
+
if self.kind == "grf":
|
| 107 |
+
length_scale = float(self.config["length_scale"])
|
| 108 |
+
distances = self.grid[:, None] - self.grid[None, :]
|
| 109 |
+
covariance = np.exp(-(distances**2) / (2.0 * length_scale**2))
|
| 110 |
+
jitter = float(self.config.get("jitter", 1.0e-13))
|
| 111 |
+
identity = np.eye(self.grid_size, dtype=np.float64)
|
| 112 |
+
for attempt in range(6):
|
| 113 |
+
try:
|
| 114 |
+
self._cholesky = np.linalg.cholesky(covariance + jitter * identity)
|
| 115 |
+
break
|
| 116 |
+
except np.linalg.LinAlgError:
|
| 117 |
+
jitter *= 10.0
|
| 118 |
+
if self._cholesky is None:
|
| 119 |
+
raise np.linalg.LinAlgError("GRF covariance Cholesky failed after jitter fallback")
|
| 120 |
+
elif self.kind != "chebyshev":
|
| 121 |
+
raise ValueError(f"Unsupported function space {self.kind!r}")
|
| 122 |
+
|
| 123 |
+
def sample(self, count: int, rng: np.random.Generator) -> np.ndarray:
|
| 124 |
+
if self.kind == "grf":
|
| 125 |
+
standard_normal = rng.standard_normal((self.grid_size, count))
|
| 126 |
+
return (self._cholesky @ standard_normal).T
|
| 127 |
+
cheb = self.config.get("chebyshev", {})
|
| 128 |
+
degree = int(cheb.get("degree", 10))
|
| 129 |
+
bound = float(cheb.get("coefficient_bound", 1.0))
|
| 130 |
+
coefficients = rng.uniform(-bound, bound, size=(count, degree + 1))
|
| 131 |
+
mapped_grid = 2.0 * self.grid / self.domain_end - 1.0
|
| 132 |
+
return np.stack(
|
| 133 |
+
[np.polynomial.chebyshev.chebval(mapped_grid, row) for row in coefficients], axis=0
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
def interpolate(self, values: np.ndarray, points: np.ndarray) -> np.ndarray:
|
| 137 |
+
values = np.asarray(values, dtype=np.float64)
|
| 138 |
+
points = np.asarray(points, dtype=np.float64)
|
| 139 |
+
method = str(self.config.get("interpolation", "cubic")).lower()
|
| 140 |
+
if method == "cubic":
|
| 141 |
+
_require_scipy("cubic GRF interpolation")
|
| 142 |
+
return np.asarray(CubicSpline(self.grid, values, axis=-1)(points))
|
| 143 |
+
if method != "linear":
|
| 144 |
+
raise ValueError(f"Unsupported interpolation method {method!r}")
|
| 145 |
+
if values.ndim == 1:
|
| 146 |
+
return np.interp(points, self.grid, values)
|
| 147 |
+
return np.stack([np.interp(points, self.grid, row) for row in values], axis=0)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _require_scipy(operation: str) -> None:
|
| 151 |
+
if solve_ivp is None or CubicSpline is None or cumulative_trapezoid is None:
|
| 152 |
+
raise ImportError(f"SciPy is required for {operation}; install it in the execution environment")
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _rowwise_linear_interpolation(
|
| 156 |
+
grid: np.ndarray, values: np.ndarray, points: np.ndarray
|
| 157 |
+
) -> np.ndarray:
|
| 158 |
+
points = np.clip(np.asarray(points), grid[0], grid[-1])
|
| 159 |
+
right = np.searchsorted(grid, points, side="right")
|
| 160 |
+
right = np.clip(right, 1, len(grid) - 1)
|
| 161 |
+
left = right - 1
|
| 162 |
+
fraction = (points - grid[left]) / (grid[right] - grid[left])
|
| 163 |
+
rows = np.arange(len(points))
|
| 164 |
+
return values[rows, left] * (1.0 - fraction) + values[rows, right] * fraction
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def solve_antiderivative(
|
| 168 |
+
input_grid: np.ndarray, input_values: np.ndarray, query_points: np.ndarray
|
| 169 |
+
) -> np.ndarray:
|
| 170 |
+
_require_scipy("antiderivative reference generation")
|
| 171 |
+
integral = cumulative_trapezoid(input_values, input_grid, axis=-1, initial=0.0)
|
| 172 |
+
return _rowwise_linear_interpolation(input_grid, integral, query_points)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def solve_nonlinear_ode(
|
| 176 |
+
input_grid: np.ndarray,
|
| 177 |
+
input_values: np.ndarray,
|
| 178 |
+
query_points: np.ndarray,
|
| 179 |
+
solver_config: Mapping[str, Any],
|
| 180 |
+
) -> np.ndarray:
|
| 181 |
+
_require_scipy("nonlinear ODE reference generation")
|
| 182 |
+
query_points = np.asarray(query_points, dtype=np.float64)
|
| 183 |
+
if query_points.ndim != 1:
|
| 184 |
+
raise ValueError("query_points must be one-dimensional")
|
| 185 |
+
if not len(query_points):
|
| 186 |
+
return np.empty(0, dtype=np.float64)
|
| 187 |
+
interpolant = CubicSpline(input_grid, input_values)
|
| 188 |
+
maximum = float(np.max(query_points))
|
| 189 |
+
if maximum == 0.0:
|
| 190 |
+
return np.zeros_like(query_points)
|
| 191 |
+
solution = solve_ivp(
|
| 192 |
+
lambda x, state: -state**2 + interpolant(x),
|
| 193 |
+
(0.0, maximum),
|
| 194 |
+
np.zeros(1, dtype=np.float64),
|
| 195 |
+
method=str(solver_config.get("method", "RK45")),
|
| 196 |
+
rtol=float(solver_config.get("rtol", 1.0e-7)),
|
| 197 |
+
atol=float(solver_config.get("atol", 1.0e-9)),
|
| 198 |
+
dense_output=True,
|
| 199 |
+
)
|
| 200 |
+
if not solution.success:
|
| 201 |
+
raise RuntimeError(f"nonlinear ODE solve failed: {solution.message}")
|
| 202 |
+
return np.asarray(solution.sol(query_points)[0])
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def solve_pendulum(
|
| 206 |
+
input_grid: np.ndarray,
|
| 207 |
+
input_values: np.ndarray,
|
| 208 |
+
query_points: np.ndarray,
|
| 209 |
+
k: float,
|
| 210 |
+
solver_config: Mapping[str, Any],
|
| 211 |
+
) -> np.ndarray:
|
| 212 |
+
_require_scipy("pendulum reference generation")
|
| 213 |
+
query_points = np.asarray(query_points, dtype=np.float64)
|
| 214 |
+
if not len(query_points):
|
| 215 |
+
return np.empty(0, dtype=np.float64)
|
| 216 |
+
interpolant = CubicSpline(input_grid, input_values)
|
| 217 |
+
maximum = float(np.max(query_points))
|
| 218 |
+
if maximum == 0.0:
|
| 219 |
+
return np.zeros_like(query_points)
|
| 220 |
+
|
| 221 |
+
def right_hand_side(time: float, state: np.ndarray) -> np.ndarray:
|
| 222 |
+
return np.asarray((state[1], -k * np.sin(state[0]) + interpolant(time)))
|
| 223 |
+
|
| 224 |
+
solution = solve_ivp(
|
| 225 |
+
right_hand_side,
|
| 226 |
+
(0.0, maximum),
|
| 227 |
+
np.zeros(2, dtype=np.float64),
|
| 228 |
+
method=str(solver_config.get("method", "RK45")),
|
| 229 |
+
rtol=float(solver_config.get("rtol", 1.0e-7)),
|
| 230 |
+
atol=float(solver_config.get("atol", 1.0e-9)),
|
| 231 |
+
dense_output=True,
|
| 232 |
+
)
|
| 233 |
+
if not solution.success:
|
| 234 |
+
raise RuntimeError(f"pendulum solve failed: {solution.message}")
|
| 235 |
+
return np.asarray(solution.sol(query_points)[0])
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def _solve_tridiagonal(
|
| 239 |
+
lower: np.ndarray, diagonal: np.ndarray, upper: np.ndarray, rhs: np.ndarray
|
| 240 |
+
) -> np.ndarray:
|
| 241 |
+
"""Thomas algorithm for a nonsingular tridiagonal system."""
|
| 242 |
+
|
| 243 |
+
lower = np.asarray(lower, dtype=np.float64).copy()
|
| 244 |
+
diagonal = np.asarray(diagonal, dtype=np.float64).copy()
|
| 245 |
+
upper = np.asarray(upper, dtype=np.float64).copy()
|
| 246 |
+
rhs = np.asarray(rhs, dtype=np.float64).copy()
|
| 247 |
+
for index in range(1, len(diagonal)):
|
| 248 |
+
if abs(diagonal[index - 1]) < np.finfo(np.float64).eps:
|
| 249 |
+
raise np.linalg.LinAlgError("zero pivot in tridiagonal solve")
|
| 250 |
+
multiplier = lower[index - 1] / diagonal[index - 1]
|
| 251 |
+
diagonal[index] -= multiplier * upper[index - 1]
|
| 252 |
+
rhs[index] -= multiplier * rhs[index - 1]
|
| 253 |
+
output = np.empty_like(rhs)
|
| 254 |
+
output[-1] = rhs[-1] / diagonal[-1]
|
| 255 |
+
for index in range(len(diagonal) - 2, -1, -1):
|
| 256 |
+
output[index] = (rhs[index] - upper[index] * output[index + 1]) / diagonal[index]
|
| 257 |
+
return output
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def solve_diffusion_reaction(
|
| 261 |
+
spatial_input: np.ndarray,
|
| 262 |
+
*,
|
| 263 |
+
diffusion: float,
|
| 264 |
+
reaction: float,
|
| 265 |
+
space_points: int,
|
| 266 |
+
time_points: int,
|
| 267 |
+
solver_config: Mapping[str, Any],
|
| 268 |
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
| 269 |
+
"""Fully implicit time stepping with second-order centered spatial differences."""
|
| 270 |
+
|
| 271 |
+
x_grid = np.linspace(0.0, 1.0, int(space_points), dtype=np.float64)
|
| 272 |
+
t_grid = np.linspace(0.0, 1.0, int(time_points), dtype=np.float64)
|
| 273 |
+
source = np.asarray(spatial_input, dtype=np.float64)
|
| 274 |
+
if source.shape != x_grid.shape:
|
| 275 |
+
raise ValueError(f"spatial_input must have shape {(len(x_grid),)}, got {source.shape}")
|
| 276 |
+
field = np.zeros((len(t_grid), len(x_grid)), dtype=np.float64)
|
| 277 |
+
if len(x_grid) < 3 or len(t_grid) < 2:
|
| 278 |
+
raise ValueError("PDE grid requires at least 3 spatial and 2 temporal points")
|
| 279 |
+
dx = x_grid[1] - x_grid[0]
|
| 280 |
+
dt = t_grid[1] - t_grid[0]
|
| 281 |
+
ratio = float(diffusion) * dt / (dx * dx)
|
| 282 |
+
tolerance = float(solver_config.get("pde_newton_tolerance", 1.0e-10))
|
| 283 |
+
max_iterations = int(solver_config.get("pde_newton_max_iterations", 20))
|
| 284 |
+
interior_source = source[1:-1]
|
| 285 |
+
interior_size = len(interior_source)
|
| 286 |
+
off_diagonal = np.full(interior_size - 1, -ratio, dtype=np.float64)
|
| 287 |
+
|
| 288 |
+
for time_index in range(1, len(t_grid)):
|
| 289 |
+
old = field[time_index - 1, 1:-1]
|
| 290 |
+
estimate = old.copy()
|
| 291 |
+
for _ in range(max_iterations):
|
| 292 |
+
padded = np.pad(estimate, (1, 1), mode="constant")
|
| 293 |
+
laplacian_term = padded[:-2] - 2.0 * estimate + padded[2:]
|
| 294 |
+
residual = (
|
| 295 |
+
estimate
|
| 296 |
+
- old
|
| 297 |
+
- ratio * laplacian_term
|
| 298 |
+
- dt * float(reaction) * estimate**2
|
| 299 |
+
- dt * interior_source
|
| 300 |
+
)
|
| 301 |
+
diagonal = 1.0 + 2.0 * ratio - 2.0 * dt * float(reaction) * estimate
|
| 302 |
+
update = _solve_tridiagonal(
|
| 303 |
+
off_diagonal, diagonal, off_diagonal, -residual
|
| 304 |
+
)
|
| 305 |
+
estimate += update
|
| 306 |
+
if np.max(np.abs(update)) <= tolerance:
|
| 307 |
+
break
|
| 308 |
+
else:
|
| 309 |
+
raise RuntimeError(
|
| 310 |
+
f"PDE Newton solve did not converge at time index {time_index}"
|
| 311 |
+
)
|
| 312 |
+
field[time_index, 1:-1] = estimate
|
| 313 |
+
if not np.isfinite(field).all():
|
| 314 |
+
raise FloatingPointError("PDE solver produced NaN or infinity")
|
| 315 |
+
return x_grid, t_grid, field
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def _generate_ode_like(
|
| 319 |
+
config: Mapping[str, Any],
|
| 320 |
+
experiment: str,
|
| 321 |
+
count: int,
|
| 322 |
+
seed: int,
|
| 323 |
+
) -> OperatorDataset:
|
| 324 |
+
experiment_config = config["experiments"][experiment]
|
| 325 |
+
function_config = config["function_space"]
|
| 326 |
+
solver_config = config["solver_defaults"]
|
| 327 |
+
rng = np.random.default_rng(seed)
|
| 328 |
+
sampler = FunctionSpaceSampler(function_config, float(experiment_config["domain_end"]))
|
| 329 |
+
sensors = np.linspace(
|
| 330 |
+
0.0,
|
| 331 |
+
float(experiment_config["domain_end"]),
|
| 332 |
+
int(experiment_config["sensor_points"]),
|
| 333 |
+
dtype=np.float64,
|
| 334 |
+
)
|
| 335 |
+
branch = np.empty((count, len(sensors)), dtype=np.float32)
|
| 336 |
+
trunk = rng.uniform(0.0, float(experiment_config["domain_end"]), size=(count, 1))
|
| 337 |
+
target = np.empty((count, 1), dtype=np.float32)
|
| 338 |
+
chunk_size = int(function_config.get("generation_chunk_size", 128))
|
| 339 |
+
for start in range(0, count, chunk_size):
|
| 340 |
+
stop = min(count, start + chunk_size)
|
| 341 |
+
fine_values = sampler.sample(stop - start, rng)
|
| 342 |
+
branch[start:stop] = sampler.interpolate(fine_values, sensors).astype(np.float32)
|
| 343 |
+
local_queries = trunk[start:stop, 0]
|
| 344 |
+
if experiment == "antiderivative":
|
| 345 |
+
target[start:stop, 0] = solve_antiderivative(
|
| 346 |
+
sampler.grid, fine_values, local_queries
|
| 347 |
+
).astype(np.float32)
|
| 348 |
+
continue
|
| 349 |
+
for local_index, values in enumerate(fine_values):
|
| 350 |
+
query = np.asarray([local_queries[local_index]])
|
| 351 |
+
if experiment == "nonlinear_ode":
|
| 352 |
+
answer = solve_nonlinear_ode(sampler.grid, values, query, solver_config)
|
| 353 |
+
elif experiment == "pendulum":
|
| 354 |
+
answer = solve_pendulum(
|
| 355 |
+
sampler.grid,
|
| 356 |
+
values,
|
| 357 |
+
query,
|
| 358 |
+
float(experiment_config["k"]),
|
| 359 |
+
solver_config,
|
| 360 |
+
)
|
| 361 |
+
else:
|
| 362 |
+
raise ValueError(f"Unsupported ODE-like experiment {experiment!r}")
|
| 363 |
+
target[start + local_index, 0] = answer[0]
|
| 364 |
+
return OperatorDataset(
|
| 365 |
+
branch,
|
| 366 |
+
trunk.astype(np.float32),
|
| 367 |
+
target,
|
| 368 |
+
metadata={"experiment": experiment, "seed": seed, "function_count": count},
|
| 369 |
+
)
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
def _generate_pde(
|
| 373 |
+
config: Mapping[str, Any],
|
| 374 |
+
split: str,
|
| 375 |
+
seed: int,
|
| 376 |
+
) -> OperatorDataset:
|
| 377 |
+
experiment_config = config["experiments"]["diffusion_reaction"]
|
| 378 |
+
if split == "train":
|
| 379 |
+
function_count = int(experiment_config["train_functions"])
|
| 380 |
+
points_per_function = int(experiment_config["points_per_function"])
|
| 381 |
+
else:
|
| 382 |
+
function_count = int(experiment_config["test_functions"])
|
| 383 |
+
points_per_function = int(experiment_config["test_points_per_function"])
|
| 384 |
+
rng = np.random.default_rng(seed)
|
| 385 |
+
sampler = FunctionSpaceSampler(config["function_space"], 1.0)
|
| 386 |
+
sensors = np.linspace(0.0, 1.0, int(experiment_config["sensor_points"]))
|
| 387 |
+
branch = np.empty((function_count, len(sensors)), dtype=np.float32)
|
| 388 |
+
total_points = function_count * points_per_function
|
| 389 |
+
trunk = np.empty((total_points, 2), dtype=np.float32)
|
| 390 |
+
target = np.empty((total_points, 1), dtype=np.float32)
|
| 391 |
+
function_index = np.repeat(np.arange(function_count, dtype=np.int64), points_per_function)
|
| 392 |
+
chunk_size = int(config["function_space"].get("generation_chunk_size", 128))
|
| 393 |
+
cursor = 0
|
| 394 |
+
for start in range(0, function_count, chunk_size):
|
| 395 |
+
stop = min(function_count, start + chunk_size)
|
| 396 |
+
fine_batch = sampler.sample(stop - start, rng)
|
| 397 |
+
branch[start:stop] = sampler.interpolate(fine_batch, sensors).astype(np.float32)
|
| 398 |
+
for values in fine_batch:
|
| 399 |
+
spatial_grid = np.linspace(0.0, 1.0, int(experiment_config["space_points"]))
|
| 400 |
+
spatial_input = sampler.interpolate(values, spatial_grid)
|
| 401 |
+
x_grid, t_grid, field = solve_diffusion_reaction(
|
| 402 |
+
spatial_input,
|
| 403 |
+
diffusion=float(experiment_config["diffusion"]),
|
| 404 |
+
reaction=float(experiment_config["reaction"]),
|
| 405 |
+
space_points=int(experiment_config["space_points"]),
|
| 406 |
+
time_points=int(experiment_config["time_points"]),
|
| 407 |
+
solver_config=config["solver_defaults"],
|
| 408 |
+
)
|
| 409 |
+
grid_size = len(x_grid) * len(t_grid)
|
| 410 |
+
flat_indices = rng.choice(
|
| 411 |
+
grid_size,
|
| 412 |
+
size=points_per_function,
|
| 413 |
+
replace=points_per_function > grid_size,
|
| 414 |
+
)
|
| 415 |
+
time_indices, space_indices = np.divmod(flat_indices, len(x_grid))
|
| 416 |
+
next_cursor = cursor + points_per_function
|
| 417 |
+
trunk[cursor:next_cursor, 0] = x_grid[space_indices]
|
| 418 |
+
trunk[cursor:next_cursor, 1] = t_grid[time_indices]
|
| 419 |
+
target[cursor:next_cursor, 0] = field[time_indices, space_indices]
|
| 420 |
+
cursor = next_cursor
|
| 421 |
+
return OperatorDataset(
|
| 422 |
+
branch,
|
| 423 |
+
trunk,
|
| 424 |
+
target,
|
| 425 |
+
function_index,
|
| 426 |
+
metadata={
|
| 427 |
+
"experiment": "diffusion_reaction",
|
| 428 |
+
"split": split,
|
| 429 |
+
"seed": seed,
|
| 430 |
+
"function_count": function_count,
|
| 431 |
+
"points_per_function": points_per_function,
|
| 432 |
+
"group_isolated": True,
|
| 433 |
+
},
|
| 434 |
+
)
|
| 435 |
+
|
| 436 |
+
|
| 437 |
+
def _fingerprint(config: Mapping[str, Any], experiment: str, split: str, seed: int) -> str:
|
| 438 |
+
payload = json.dumps(
|
| 439 |
+
{"config": config, "experiment": experiment, "split": split, "seed": seed},
|
| 440 |
+
sort_keys=True,
|
| 441 |
+
separators=(",", ":"),
|
| 442 |
+
).encode("utf-8")
|
| 443 |
+
return hashlib.sha256(payload).hexdigest()
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
def _cache_path(
|
| 447 |
+
config: Mapping[str, Any], project_root: Path, experiment: str, split: str, seed: int
|
| 448 |
+
) -> Tuple[Path, str]:
|
| 449 |
+
fingerprint = _fingerprint(config, experiment, split, seed)
|
| 450 |
+
relative_root = Path(config["paths"]["cache"])
|
| 451 |
+
root = relative_root if relative_root.is_absolute() else project_root / relative_root
|
| 452 |
+
return root / f"{experiment}_{split}_{fingerprint[:16]}.npz", fingerprint
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
def _save_cache(path: Path, dataset: OperatorDataset, fingerprint: str) -> None:
|
| 456 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 457 |
+
metadata = dict(dataset.metadata)
|
| 458 |
+
metadata["fingerprint"] = fingerprint
|
| 459 |
+
with tempfile.NamedTemporaryFile(dir=path.parent, suffix=".npz", delete=False) as handle:
|
| 460 |
+
temporary_path = Path(handle.name)
|
| 461 |
+
try:
|
| 462 |
+
np.savez_compressed(
|
| 463 |
+
temporary_path,
|
| 464 |
+
branch_functions=dataset.branch_functions,
|
| 465 |
+
trunk=dataset.trunk,
|
| 466 |
+
target=dataset.target,
|
| 467 |
+
function_index=dataset.function_index,
|
| 468 |
+
metadata=np.asarray(json.dumps(metadata, sort_keys=True)),
|
| 469 |
+
)
|
| 470 |
+
os.replace(temporary_path, path)
|
| 471 |
+
finally:
|
| 472 |
+
if temporary_path.exists():
|
| 473 |
+
temporary_path.unlink()
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
def _load_cache(path: Path, fingerprint: str) -> OperatorDataset:
|
| 477 |
+
with np.load(path, allow_pickle=False) as payload:
|
| 478 |
+
metadata = json.loads(str(payload["metadata"].item()))
|
| 479 |
+
if metadata.get("fingerprint") != fingerprint:
|
| 480 |
+
raise ValueError(f"cache fingerprint mismatch for {path}")
|
| 481 |
+
return OperatorDataset(
|
| 482 |
+
payload["branch_functions"],
|
| 483 |
+
payload["trunk"],
|
| 484 |
+
payload["target"],
|
| 485 |
+
payload["function_index"],
|
| 486 |
+
metadata,
|
| 487 |
+
)
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
def build_split(
|
| 491 |
+
config: Mapping[str, Any],
|
| 492 |
+
experiment: str,
|
| 493 |
+
split: str,
|
| 494 |
+
project_root: str | Path,
|
| 495 |
+
*,
|
| 496 |
+
use_cache: bool = True,
|
| 497 |
+
) -> OperatorDataset:
|
| 498 |
+
"""Build or load one independent train/test split."""
|
| 499 |
+
|
| 500 |
+
if split not in {"train", "test"}:
|
| 501 |
+
raise ValueError("split must be 'train' or 'test'")
|
| 502 |
+
if experiment not in config.get("experiments", {}):
|
| 503 |
+
raise KeyError(f"Unknown experiment {experiment!r}")
|
| 504 |
+
root = Path(project_root).resolve()
|
| 505 |
+
base_seed = int(config["runtime"]["seed"])
|
| 506 |
+
seed = base_seed + (0 if split == "train" else 100_000)
|
| 507 |
+
path, fingerprint = _cache_path(config, root, experiment, split, seed)
|
| 508 |
+
if use_cache and path.exists():
|
| 509 |
+
return _load_cache(path, fingerprint)
|
| 510 |
+
if experiment == "diffusion_reaction":
|
| 511 |
+
dataset = _generate_pde(config, split, seed)
|
| 512 |
+
else:
|
| 513 |
+
size_key = "train_size" if split == "train" else "test_size"
|
| 514 |
+
dataset = _generate_ode_like(
|
| 515 |
+
config, experiment, int(config["experiments"][experiment][size_key]), seed
|
| 516 |
+
)
|
| 517 |
+
dataset.metadata.update(
|
| 518 |
+
{
|
| 519 |
+
"split": split,
|
| 520 |
+
"paper_scale": bool(config["project"]["paper_scale"]),
|
| 521 |
+
"fingerprint": fingerprint,
|
| 522 |
+
}
|
| 523 |
+
)
|
| 524 |
+
if use_cache:
|
| 525 |
+
_save_cache(path, dataset, fingerprint)
|
| 526 |
+
return dataset
|
| 527 |
+
|
| 528 |
+
|
| 529 |
+
def build_datasets(
|
| 530 |
+
config: Mapping[str, Any], experiment: str, project_root: str | Path
|
| 531 |
+
) -> Tuple[OperatorDataset, OperatorDataset]:
|
| 532 |
+
train = build_split(config, experiment, "train", project_root)
|
| 533 |
+
test = build_split(config, experiment, "test", project_root)
|
| 534 |
+
return train, test
|
| 535 |
+
|
| 536 |
+
|
| 537 |
+
def analytic_input(name: str, coordinates: np.ndarray) -> np.ndarray:
|
| 538 |
+
if name == "linear":
|
| 539 |
+
return coordinates
|
| 540 |
+
if name == "sin_pi":
|
| 541 |
+
return np.sin(np.pi * coordinates)
|
| 542 |
+
if name == "sin_2pi":
|
| 543 |
+
return np.sin(2.0 * np.pi * coordinates)
|
| 544 |
+
if name == "x_sin_2pi":
|
| 545 |
+
return coordinates * np.sin(2.0 * np.pi * coordinates)
|
| 546 |
+
raise KeyError(f"Unknown analytic input {name!r}")
|
| 547 |
+
|
| 548 |
+
|
| 549 |
+
def generate_ood_data(
|
| 550 |
+
config: Mapping[str, Any], experiment: str, query_points: int | None = None
|
| 551 |
+
) -> Dict[str, np.ndarray]:
|
| 552 |
+
if experiment == "diffusion_reaction":
|
| 553 |
+
raise ValueError("Use generate_pde_grid_case for the PDE")
|
| 554 |
+
experiment_config = config["experiments"][experiment]
|
| 555 |
+
count = int(query_points or config["inference"]["ood_query_points"])
|
| 556 |
+
domain_end = float(experiment_config["domain_end"])
|
| 557 |
+
sensors = np.linspace(0.0, domain_end, int(experiment_config["sensor_points"]))
|
| 558 |
+
queries = np.linspace(0.0, domain_end, count)
|
| 559 |
+
fine_grid = np.linspace(0.0, domain_end, max(1000, count))
|
| 560 |
+
names = experiment_config.get("ood_functions", ["linear", "sin_pi", "sin_2pi"])
|
| 561 |
+
all_branch, all_trunk, all_target, all_labels = [], [], [], []
|
| 562 |
+
for name in names:
|
| 563 |
+
fine_values = analytic_input(str(name), fine_grid)
|
| 564 |
+
branch = analytic_input(str(name), sensors)
|
| 565 |
+
if experiment == "antiderivative":
|
| 566 |
+
_require_scipy("antiderivative OOD reference")
|
| 567 |
+
integral = cumulative_trapezoid(fine_values, fine_grid, initial=0.0)
|
| 568 |
+
target = np.interp(queries, fine_grid, integral)
|
| 569 |
+
elif experiment == "nonlinear_ode":
|
| 570 |
+
target = solve_nonlinear_ode(
|
| 571 |
+
fine_grid, fine_values, queries, config["solver_defaults"]
|
| 572 |
+
)
|
| 573 |
+
elif experiment == "pendulum":
|
| 574 |
+
target = solve_pendulum(
|
| 575 |
+
fine_grid,
|
| 576 |
+
fine_values,
|
| 577 |
+
queries,
|
| 578 |
+
float(experiment_config["k"]),
|
| 579 |
+
config["solver_defaults"],
|
| 580 |
+
)
|
| 581 |
+
else:
|
| 582 |
+
raise ValueError(f"Unsupported experiment {experiment!r}")
|
| 583 |
+
all_branch.append(np.repeat(branch[None, :], count, axis=0))
|
| 584 |
+
all_trunk.append(queries[:, None])
|
| 585 |
+
all_target.append(target[:, None])
|
| 586 |
+
all_labels.extend([str(name)] * count)
|
| 587 |
+
return {
|
| 588 |
+
"branch": np.concatenate(all_branch).astype(np.float32),
|
| 589 |
+
"trunk": np.concatenate(all_trunk).astype(np.float32),
|
| 590 |
+
"target": np.concatenate(all_target).astype(np.float32),
|
| 591 |
+
"labels": np.asarray(all_labels),
|
| 592 |
+
}
|
| 593 |
+
|
| 594 |
+
|
| 595 |
+
def generate_pde_grid_case(config: Mapping[str, Any], seed: int) -> Dict[str, np.ndarray]:
|
| 596 |
+
experiment_config = config["experiments"]["diffusion_reaction"]
|
| 597 |
+
sampler = FunctionSpaceSampler(config["function_space"], 1.0)
|
| 598 |
+
rng = np.random.default_rng(seed)
|
| 599 |
+
fine_values = sampler.sample(1, rng)[0]
|
| 600 |
+
sensors = np.linspace(0.0, 1.0, int(experiment_config["sensor_points"]))
|
| 601 |
+
branch_vector = sampler.interpolate(fine_values, sensors).astype(np.float32)
|
| 602 |
+
spatial_grid = np.linspace(0.0, 1.0, int(experiment_config["space_points"]))
|
| 603 |
+
source = sampler.interpolate(fine_values, spatial_grid)
|
| 604 |
+
x_grid, t_grid, field = solve_diffusion_reaction(
|
| 605 |
+
source,
|
| 606 |
+
diffusion=float(experiment_config["diffusion"]),
|
| 607 |
+
reaction=float(experiment_config["reaction"]),
|
| 608 |
+
space_points=int(experiment_config["space_points"]),
|
| 609 |
+
time_points=int(experiment_config["time_points"]),
|
| 610 |
+
solver_config=config["solver_defaults"],
|
| 611 |
+
)
|
| 612 |
+
x_mesh, t_mesh = np.meshgrid(x_grid, t_grid)
|
| 613 |
+
trunk = np.column_stack((x_mesh.ravel(), t_mesh.ravel())).astype(np.float32)
|
| 614 |
+
return {
|
| 615 |
+
"branch": np.repeat(branch_vector[None, :], len(trunk), axis=0),
|
| 616 |
+
"trunk": trunk,
|
| 617 |
+
"target": field.reshape(-1, 1).astype(np.float32),
|
| 618 |
+
"source": source.astype(np.float32),
|
| 619 |
+
"x": x_grid.astype(np.float32),
|
| 620 |
+
"t": t_grid.astype(np.float32),
|
| 621 |
+
"grid_shape": np.asarray(field.shape, dtype=np.int64),
|
| 622 |
+
}
|
| 623 |
+
|
| 624 |
+
|
| 625 |
+
__all__ = [
|
| 626 |
+
"OperatorDataset",
|
| 627 |
+
"FunctionSpaceSampler",
|
| 628 |
+
"resolve_config",
|
| 629 |
+
"build_split",
|
| 630 |
+
"build_datasets",
|
| 631 |
+
"generate_ood_data",
|
| 632 |
+
"generate_pde_grid_case",
|
| 633 |
+
"solve_antiderivative",
|
| 634 |
+
"solve_nonlinear_ode",
|
| 635 |
+
"solve_pendulum",
|
| 636 |
+
"solve_diffusion_reaction",
|
| 637 |
+
]
|
scripts/__pycache__/train.cpython-311.pyc
ADDED
|
Binary file (24.3 kB). View file
|
|
|
scripts/inference.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Inference for random-test, OOD-curve, and PDE-grid DeepONet cases."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import csv
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
import sys
|
| 11 |
+
import tempfile
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any, Dict, Mapping, Tuple
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
import torch
|
| 17 |
+
import yaml
|
| 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.DeepONet import build_model # noqa: E402
|
| 25 |
+
from models.dataset import ( # noqa: E402
|
| 26 |
+
OperatorDataset,
|
| 27 |
+
build_split,
|
| 28 |
+
generate_ood_data,
|
| 29 |
+
generate_pde_grid_case,
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def parse_args() -> argparse.Namespace:
|
| 34 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 35 |
+
parser.add_argument("--config", type=Path, default=PROJECT_ROOT / "config/config.yaml")
|
| 36 |
+
parser.add_argument("--checkpoint", type=Path, default=None)
|
| 37 |
+
parser.add_argument("--experiment", default="antiderivative")
|
| 38 |
+
parser.add_argument("--variant", default="unstacked_bias")
|
| 39 |
+
parser.add_argument("--mode", choices=("random_test", "ood", "pde_grid"), default="random_test")
|
| 40 |
+
parser.add_argument("--device", default=None)
|
| 41 |
+
parser.add_argument("--batch-size", type=int, default=None)
|
| 42 |
+
parser.add_argument("--seed", type=int, default=None)
|
| 43 |
+
parser.add_argument("--output-dir", type=Path, default=None)
|
| 44 |
+
parser.add_argument("--smoke-test", action="store_true")
|
| 45 |
+
return parser.parse_args()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def load_yaml(path: Path) -> Dict[str, Any]:
|
| 49 |
+
with path.expanduser().resolve().open("r", encoding="utf-8") as handle:
|
| 50 |
+
value = yaml.safe_load(handle)
|
| 51 |
+
if not isinstance(value, dict):
|
| 52 |
+
raise ValueError(f"Configuration {path} must contain a mapping")
|
| 53 |
+
return value
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def resolve_path(value: str | Path) -> Path:
|
| 57 |
+
path = Path(value).expanduser()
|
| 58 |
+
return path if path.is_absolute() else PROJECT_ROOT / path
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def torch_load(path: Path, map_location: str | torch.device = "cpu") -> Any:
|
| 62 |
+
try:
|
| 63 |
+
return torch.load(path, map_location=map_location, weights_only=False)
|
| 64 |
+
except TypeError:
|
| 65 |
+
return torch.load(path, map_location=map_location)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def load_entry(checkpoint_path: Path, experiment: str, variant: str) -> Mapping[str, Any]:
|
| 69 |
+
if not checkpoint_path.exists():
|
| 70 |
+
raise FileNotFoundError(
|
| 71 |
+
f"Checkpoint {checkpoint_path} does not exist. Train the requested entry first; "
|
| 72 |
+
"inference never evaluates random weights."
|
| 73 |
+
)
|
| 74 |
+
bundle = torch_load(checkpoint_path)
|
| 75 |
+
if not isinstance(bundle, dict) or not isinstance(bundle.get("entries"), dict):
|
| 76 |
+
raise ValueError(f"Checkpoint {checkpoint_path} is not a DeepONet indexed bundle")
|
| 77 |
+
key = f"{experiment}/{variant}"
|
| 78 |
+
if key not in bundle["entries"]:
|
| 79 |
+
available = sorted(bundle["entries"])
|
| 80 |
+
raise KeyError(f"Checkpoint entry {key!r} is missing; available entries: {available}")
|
| 81 |
+
entry = bundle["entries"][key]
|
| 82 |
+
if not isinstance(entry.get("run_config"), dict):
|
| 83 |
+
raise ValueError(f"Checkpoint entry {key!r} has no resolved run_config")
|
| 84 |
+
return entry
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def select_device(requested: str) -> torch.device:
|
| 88 |
+
if requested == "auto":
|
| 89 |
+
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 90 |
+
device = torch.device(requested)
|
| 91 |
+
if device.type == "cuda" and not torch.cuda.is_available():
|
| 92 |
+
raise RuntimeError(f"CUDA device {requested!r} requested but CUDA is unavailable")
|
| 93 |
+
return device
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def metrics(prediction: np.ndarray, target: np.ndarray, trim_fraction: float) -> Dict[str, float]:
|
| 97 |
+
error = prediction.astype(np.float64).reshape(-1) - target.astype(np.float64).reshape(-1)
|
| 98 |
+
squared = error**2
|
| 99 |
+
output = {
|
| 100 |
+
"test_mse": float(np.mean(squared)),
|
| 101 |
+
"relative_l2": float(
|
| 102 |
+
np.linalg.norm(error)
|
| 103 |
+
/ max(np.linalg.norm(target.astype(np.float64).reshape(-1)), np.finfo(np.float64).eps)
|
| 104 |
+
),
|
| 105 |
+
}
|
| 106 |
+
if trim_fraction > 0.0:
|
| 107 |
+
remove_count = min(len(squared) - 1, int(np.ceil(len(squared) * trim_fraction)))
|
| 108 |
+
kept_count = len(squared) - remove_count
|
| 109 |
+
output["trimmed_test_mse"] = float(np.mean(np.partition(squared, kept_count - 1)[:kept_count]))
|
| 110 |
+
return output
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
@torch.inference_mode()
|
| 114 |
+
def predict_dataset(
|
| 115 |
+
model: torch.nn.Module,
|
| 116 |
+
dataset: OperatorDataset,
|
| 117 |
+
device: torch.device,
|
| 118 |
+
batch_size: int,
|
| 119 |
+
) -> Tuple[np.ndarray, np.ndarray]:
|
| 120 |
+
loader = DataLoader(dataset, batch_size=batch_size, shuffle=False, num_workers=0)
|
| 121 |
+
predictions, targets = [], []
|
| 122 |
+
model.eval()
|
| 123 |
+
for branch, trunk, target in loader:
|
| 124 |
+
prediction = model(branch.to(device), trunk.to(device))
|
| 125 |
+
predictions.append(prediction.detach().cpu().numpy())
|
| 126 |
+
targets.append(target.numpy())
|
| 127 |
+
prediction_array = np.concatenate(predictions).astype(np.float32)
|
| 128 |
+
target_array = np.concatenate(targets).astype(np.float32)
|
| 129 |
+
if prediction_array.shape != target_array.shape or prediction_array.ndim != 2:
|
| 130 |
+
raise ValueError(
|
| 131 |
+
f"prediction/target shape mismatch: {prediction_array.shape} versus {target_array.shape}"
|
| 132 |
+
)
|
| 133 |
+
if not np.isfinite(prediction_array).all():
|
| 134 |
+
raise FloatingPointError("model prediction contains NaN or infinity")
|
| 135 |
+
return prediction_array, target_array
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _safe_output_dir(
|
| 139 |
+
requested: Path | None,
|
| 140 |
+
results_root: Path,
|
| 141 |
+
experiment: str,
|
| 142 |
+
variant: str,
|
| 143 |
+
mode: str,
|
| 144 |
+
) -> Path:
|
| 145 |
+
output = results_root / experiment / variant / mode if requested is None else resolve_path(requested)
|
| 146 |
+
results_root = results_root.resolve()
|
| 147 |
+
output = output.resolve()
|
| 148 |
+
if os.path.commonpath((str(results_root), str(output))) != str(results_root):
|
| 149 |
+
raise ValueError(f"Inference outputs must remain below {results_root}, got {output}")
|
| 150 |
+
output.mkdir(parents=True, exist_ok=True)
|
| 151 |
+
return output
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def _atomic_json(path: Path, value: Mapping[str, Any]) -> None:
|
| 155 |
+
with tempfile.NamedTemporaryFile(
|
| 156 |
+
mode="w", encoding="utf-8", dir=path.parent, suffix=".json", delete=False
|
| 157 |
+
) as handle:
|
| 158 |
+
json.dump(value, handle, indent=2, sort_keys=True)
|
| 159 |
+
handle.write("\n")
|
| 160 |
+
temporary_path = Path(handle.name)
|
| 161 |
+
try:
|
| 162 |
+
os.replace(temporary_path, path)
|
| 163 |
+
finally:
|
| 164 |
+
if temporary_path.exists():
|
| 165 |
+
temporary_path.unlink()
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _atomic_npz(path: Path, arrays: Mapping[str, np.ndarray]) -> None:
|
| 169 |
+
with tempfile.NamedTemporaryFile(dir=path.parent, suffix=".npz", delete=False) as handle:
|
| 170 |
+
temporary_path = Path(handle.name)
|
| 171 |
+
try:
|
| 172 |
+
np.savez_compressed(temporary_path, **arrays)
|
| 173 |
+
os.replace(temporary_path, path)
|
| 174 |
+
finally:
|
| 175 |
+
if temporary_path.exists():
|
| 176 |
+
temporary_path.unlink()
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def final_train_loss(results_root: Path, experiment: str, variant: str) -> float | None:
|
| 180 |
+
history_path = results_root / experiment / variant / "history.csv"
|
| 181 |
+
if not history_path.exists():
|
| 182 |
+
return None
|
| 183 |
+
with history_path.open("r", encoding="utf-8", newline="") as handle:
|
| 184 |
+
rows = list(csv.DictReader(handle))
|
| 185 |
+
return float(rows[-1]["train_loss"]) if rows else None
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def prepare_data(
|
| 189 |
+
config: Mapping[str, Any], experiment: str, mode: str, seed: int
|
| 190 |
+
) -> Tuple[OperatorDataset, Dict[str, np.ndarray]]:
|
| 191 |
+
if mode == "random_test":
|
| 192 |
+
dataset = build_split(config, experiment, "test", PROJECT_ROOT)
|
| 193 |
+
return dataset, {
|
| 194 |
+
"branch_functions": dataset.branch_functions,
|
| 195 |
+
"function_index": dataset.function_index,
|
| 196 |
+
"trunk": dataset.trunk,
|
| 197 |
+
}
|
| 198 |
+
if mode == "ood":
|
| 199 |
+
payload = generate_ood_data(config, experiment)
|
| 200 |
+
dataset = OperatorDataset(payload["branch"], payload["trunk"], payload["target"])
|
| 201 |
+
return dataset, {"branch": payload["branch"], "trunk": payload["trunk"], "labels": payload["labels"]}
|
| 202 |
+
if experiment != "diffusion_reaction":
|
| 203 |
+
raise ValueError("pde_grid mode is only valid for diffusion_reaction")
|
| 204 |
+
payload = generate_pde_grid_case(config, seed)
|
| 205 |
+
dataset = OperatorDataset(payload["branch"], payload["trunk"], payload["target"])
|
| 206 |
+
extras = {key: value for key, value in payload.items() if key != "target"}
|
| 207 |
+
return dataset, extras
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def main() -> None:
|
| 211 |
+
args = parse_args()
|
| 212 |
+
file_config = load_yaml(args.config)
|
| 213 |
+
checkpoint_path = resolve_path(args.checkpoint or file_config["paths"]["checkpoint"])
|
| 214 |
+
entry = load_entry(checkpoint_path, args.experiment, args.variant)
|
| 215 |
+
config = entry["run_config"]
|
| 216 |
+
stored_paper_scale = bool(entry.get("paper_scale", config["project"].get("paper_scale", True)))
|
| 217 |
+
if args.smoke_test and stored_paper_scale:
|
| 218 |
+
raise ValueError("--smoke-test was requested, but the selected checkpoint is paper-scale")
|
| 219 |
+
device_name = args.device or str(config["runtime"].get("device", "auto"))
|
| 220 |
+
device = select_device(device_name)
|
| 221 |
+
model = build_model(config, args.experiment, args.variant).to(device)
|
| 222 |
+
model.load_state_dict(entry["model_state"], strict=True)
|
| 223 |
+
seed = int(args.seed if args.seed is not None else config["runtime"]["seed"] + 200_000)
|
| 224 |
+
dataset, extra_arrays = prepare_data(config, args.experiment, args.mode, seed)
|
| 225 |
+
batch_size = int(args.batch_size or config["inference"]["batch_size"])
|
| 226 |
+
prediction, target = predict_dataset(model, dataset, device, batch_size)
|
| 227 |
+
trim_fraction = float(config["experiments"][args.experiment].get("trim_fraction", 0.0))
|
| 228 |
+
result_metrics = metrics(prediction, target, trim_fraction)
|
| 229 |
+
|
| 230 |
+
results_root = resolve_path(config["paths"]["results"])
|
| 231 |
+
output_dir = _safe_output_dir(
|
| 232 |
+
args.output_dir, results_root, args.experiment, args.variant, args.mode
|
| 233 |
+
)
|
| 234 |
+
train_loss = final_train_loss(results_root, args.experiment, args.variant)
|
| 235 |
+
if train_loss is not None and args.mode == "random_test":
|
| 236 |
+
result_metrics["generalization_error"] = result_metrics["test_mse"] - train_loss
|
| 237 |
+
metadata = {
|
| 238 |
+
"experiment": args.experiment,
|
| 239 |
+
"variant": args.variant,
|
| 240 |
+
"mode": args.mode,
|
| 241 |
+
"checkpoint": str(checkpoint_path),
|
| 242 |
+
"checkpoint_iteration": int(entry["iteration"]),
|
| 243 |
+
"checkpoint_best_metric": float(entry["best_metric"]),
|
| 244 |
+
"seed": seed,
|
| 245 |
+
"paper_scale": stored_paper_scale,
|
| 246 |
+
"sample_count": len(dataset),
|
| 247 |
+
}
|
| 248 |
+
arrays: Dict[str, np.ndarray] = {
|
| 249 |
+
"prediction": prediction,
|
| 250 |
+
"target": target,
|
| 251 |
+
"metadata": np.asarray(json.dumps(metadata, sort_keys=True)),
|
| 252 |
+
}
|
| 253 |
+
arrays.update(extra_arrays)
|
| 254 |
+
_atomic_npz(output_dir / "predictions.npz", arrays)
|
| 255 |
+
_atomic_json(output_dir / "metrics.json", {**metadata, **result_metrics})
|
| 256 |
+
metric_text = " ".join(f"{name}={value:.8e}" for name, value in result_metrics.items())
|
| 257 |
+
print(
|
| 258 |
+
f"INFERENCE experiment={args.experiment} variant={args.variant} mode={args.mode} "
|
| 259 |
+
f"paper_scale={stored_paper_scale} {metric_text}",
|
| 260 |
+
flush=True,
|
| 261 |
+
)
|
| 262 |
+
print(
|
| 263 |
+
f"SAVED metrics={output_dir / 'metrics.json'} predictions={output_dir / 'predictions.npz'}",
|
| 264 |
+
flush=True,
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
if __name__ == "__main__":
|
| 269 |
+
main()
|
scripts/result.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Create figures and a summary from existing DeepONet result artifacts."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import csv
|
| 8 |
+
import json
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any, Dict, Iterable, List, Mapping, Tuple
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
import yaml
|
| 14 |
+
|
| 15 |
+
import matplotlib
|
| 16 |
+
|
| 17 |
+
matplotlib.use("Agg")
|
| 18 |
+
import matplotlib.pyplot as plt # noqa: E402
|
| 19 |
+
|
| 20 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def parse_args() -> argparse.Namespace:
|
| 24 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 25 |
+
parser.add_argument("--config", type=Path, default=PROJECT_ROOT / "config/config.yaml")
|
| 26 |
+
parser.add_argument("--experiment", default=None)
|
| 27 |
+
parser.add_argument("--variant", default=None)
|
| 28 |
+
return parser.parse_args()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def load_config(path: Path) -> Dict[str, Any]:
|
| 32 |
+
with path.expanduser().resolve().open("r", encoding="utf-8") as handle:
|
| 33 |
+
value = yaml.safe_load(handle)
|
| 34 |
+
if not isinstance(value, dict):
|
| 35 |
+
raise ValueError(f"Configuration {path} must contain a mapping")
|
| 36 |
+
return value
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def resolve_path(value: str | Path) -> Path:
|
| 40 |
+
path = Path(value).expanduser()
|
| 41 |
+
return path if path.is_absolute() else PROJECT_ROOT / path
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def candidate_runs(results_root: Path, experiment: str | None, variant: str | None) -> Iterable[Tuple[str, str, Path]]:
|
| 45 |
+
experiment_dirs = [results_root / experiment] if experiment else sorted(results_root.iterdir())
|
| 46 |
+
for experiment_dir in experiment_dirs:
|
| 47 |
+
if not experiment_dir.is_dir() or experiment_dir.name in {"cache", "figures"}:
|
| 48 |
+
continue
|
| 49 |
+
variant_dirs = [experiment_dir / variant] if variant else sorted(experiment_dir.iterdir())
|
| 50 |
+
for variant_dir in variant_dirs:
|
| 51 |
+
if variant_dir.is_dir():
|
| 52 |
+
yield experiment_dir.name, variant_dir.name, variant_dir
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def read_history(path: Path) -> Dict[str, np.ndarray]:
|
| 56 |
+
with path.open("r", encoding="utf-8", newline="") as handle:
|
| 57 |
+
rows = list(csv.DictReader(handle))
|
| 58 |
+
if not rows:
|
| 59 |
+
raise ValueError(f"History {path} is empty")
|
| 60 |
+
columns: Dict[str, np.ndarray] = {}
|
| 61 |
+
for key in rows[0]:
|
| 62 |
+
if key == "paper_scale":
|
| 63 |
+
continue
|
| 64 |
+
try:
|
| 65 |
+
columns[key] = np.asarray([float(row[key]) for row in rows if row.get(key, "") != ""])
|
| 66 |
+
except ValueError:
|
| 67 |
+
continue
|
| 68 |
+
return columns
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def plot_history(history_path: Path, output_path: Path, title: str) -> None:
|
| 72 |
+
columns = read_history(history_path)
|
| 73 |
+
iterations = columns["iteration"]
|
| 74 |
+
fig, axis = plt.subplots(figsize=(7.2, 4.5))
|
| 75 |
+
axis.plot(iterations, columns["train_loss"], label="train MSE", linewidth=1.8)
|
| 76 |
+
axis.plot(iterations, columns["test_mse"], label="test MSE", linewidth=1.8)
|
| 77 |
+
if "trimmed_test_mse" in columns:
|
| 78 |
+
axis.plot(iterations, columns["trimmed_test_mse"], label="trimmed test MSE", linewidth=1.5)
|
| 79 |
+
axis.set_yscale("log")
|
| 80 |
+
axis.set_xlabel("Iteration")
|
| 81 |
+
axis.set_ylabel("Mean squared error")
|
| 82 |
+
axis.set_title(title)
|
| 83 |
+
axis.grid(True, which="both", alpha=0.25)
|
| 84 |
+
axis.legend()
|
| 85 |
+
fig.tight_layout()
|
| 86 |
+
fig.savefig(output_path, dpi=180)
|
| 87 |
+
plt.close(fig)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _metadata(payload: Mapping[str, np.ndarray]) -> Dict[str, Any]:
|
| 91 |
+
return json.loads(str(payload["metadata"].item())) if "metadata" in payload else {}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def plot_ood(payload: Mapping[str, np.ndarray], output_path: Path, title: str) -> None:
|
| 95 |
+
labels = payload["labels"].astype(str)
|
| 96 |
+
trunk = payload["trunk"][:, 0]
|
| 97 |
+
target = payload["target"][:, 0]
|
| 98 |
+
prediction = payload["prediction"][:, 0]
|
| 99 |
+
unique_labels = list(dict.fromkeys(labels.tolist()))
|
| 100 |
+
fig, axes = plt.subplots(len(unique_labels), 1, figsize=(7.2, 3.0 * len(unique_labels)), squeeze=False)
|
| 101 |
+
for axis, label in zip(axes[:, 0], unique_labels):
|
| 102 |
+
selected = labels == label
|
| 103 |
+
order = np.argsort(trunk[selected])
|
| 104 |
+
axis.plot(trunk[selected][order], target[selected][order], label="reference", linewidth=2.0)
|
| 105 |
+
axis.plot(trunk[selected][order], prediction[selected][order], "--", label="DeepONet", linewidth=1.8)
|
| 106 |
+
axis.set_title(label)
|
| 107 |
+
axis.set_xlabel("Query coordinate")
|
| 108 |
+
axis.set_ylabel("Solution")
|
| 109 |
+
axis.grid(True, alpha=0.25)
|
| 110 |
+
axis.legend()
|
| 111 |
+
fig.suptitle(title)
|
| 112 |
+
fig.tight_layout()
|
| 113 |
+
fig.savefig(output_path, dpi=180)
|
| 114 |
+
plt.close(fig)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def plot_pde(payload: Mapping[str, np.ndarray], output_path: Path, title: str) -> None:
|
| 118 |
+
shape = tuple(int(value) for value in payload["grid_shape"])
|
| 119 |
+
target = payload["target"].reshape(shape)
|
| 120 |
+
prediction = payload["prediction"].reshape(shape)
|
| 121 |
+
absolute_error = np.abs(prediction - target)
|
| 122 |
+
x_grid = payload["x"]
|
| 123 |
+
t_grid = payload["t"]
|
| 124 |
+
extent = (float(x_grid.min()), float(x_grid.max()), float(t_grid.min()), float(t_grid.max()))
|
| 125 |
+
shared_min = float(min(target.min(), prediction.min()))
|
| 126 |
+
shared_max = float(max(target.max(), prediction.max()))
|
| 127 |
+
fig, axes = plt.subplots(1, 3, figsize=(13.5, 3.8), constrained_layout=True)
|
| 128 |
+
first = axes[0].imshow(target, origin="lower", aspect="auto", extent=extent, vmin=shared_min, vmax=shared_max)
|
| 129 |
+
axes[0].set_title("Reference")
|
| 130 |
+
axes[1].imshow(prediction, origin="lower", aspect="auto", extent=extent, vmin=shared_min, vmax=shared_max)
|
| 131 |
+
axes[1].set_title("Prediction")
|
| 132 |
+
error_image = axes[2].imshow(absolute_error, origin="lower", aspect="auto", extent=extent)
|
| 133 |
+
axes[2].set_title("Absolute error")
|
| 134 |
+
for axis in axes:
|
| 135 |
+
axis.set_xlabel("x")
|
| 136 |
+
axis.set_ylabel("t")
|
| 137 |
+
fig.colorbar(first, ax=axes[:2], shrink=0.82, label="s(x,t)")
|
| 138 |
+
fig.colorbar(error_image, ax=axes[2], shrink=0.82, label="absolute error")
|
| 139 |
+
fig.suptitle(title)
|
| 140 |
+
fig.savefig(output_path, dpi=180)
|
| 141 |
+
plt.close(fig)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def plot_generic(payload: Mapping[str, np.ndarray], output_path: Path, title: str) -> None:
|
| 145 |
+
prediction = payload["prediction"].reshape(-1)
|
| 146 |
+
target = payload["target"].reshape(-1)
|
| 147 |
+
fig, axes = plt.subplots(1, 2, figsize=(10.5, 4.2))
|
| 148 |
+
limit_min = float(min(prediction.min(), target.min()))
|
| 149 |
+
limit_max = float(max(prediction.max(), target.max()))
|
| 150 |
+
axes[0].scatter(target, prediction, s=7, alpha=0.45)
|
| 151 |
+
axes[0].plot((limit_min, limit_max), (limit_min, limit_max), "k--", linewidth=1)
|
| 152 |
+
axes[0].set_xlabel("Reference")
|
| 153 |
+
axes[0].set_ylabel("Prediction")
|
| 154 |
+
axes[0].set_title("Prediction parity")
|
| 155 |
+
axes[1].hist(np.abs(prediction - target), bins=40)
|
| 156 |
+
axes[1].set_xlabel("Absolute error")
|
| 157 |
+
axes[1].set_ylabel("Count")
|
| 158 |
+
axes[1].set_title("Error distribution")
|
| 159 |
+
fig.suptitle(title)
|
| 160 |
+
fig.tight_layout()
|
| 161 |
+
fig.savefig(output_path, dpi=180)
|
| 162 |
+
plt.close(fig)
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def write_summary(path: Path, records: List[Mapping[str, Any]]) -> None:
|
| 166 |
+
with path.open("w", encoding="utf-8") as handle:
|
| 167 |
+
json.dump({"runs": records}, handle, indent=2, sort_keys=True)
|
| 168 |
+
handle.write("\n")
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def main() -> None:
|
| 172 |
+
args = parse_args()
|
| 173 |
+
config = load_config(args.config)
|
| 174 |
+
results_root = resolve_path(config["paths"]["results"])
|
| 175 |
+
if not results_root.exists():
|
| 176 |
+
raise FileNotFoundError(f"Results directory {results_root} does not exist")
|
| 177 |
+
figures_root = results_root / "figures"
|
| 178 |
+
figures_root.mkdir(parents=True, exist_ok=True)
|
| 179 |
+
records: List[Mapping[str, Any]] = []
|
| 180 |
+
artifact_count = 0
|
| 181 |
+
for experiment, variant, run_dir in candidate_runs(results_root, args.experiment, args.variant):
|
| 182 |
+
title = f"{experiment} / {variant}"
|
| 183 |
+
history_path = run_dir / "history.csv"
|
| 184 |
+
if history_path.exists():
|
| 185 |
+
output = figures_root / f"{experiment}_{variant}_loss.png"
|
| 186 |
+
plot_history(history_path, output, title)
|
| 187 |
+
print(f"FIGURE {output}", flush=True)
|
| 188 |
+
artifact_count += 1
|
| 189 |
+
mode_dirs = sorted(
|
| 190 |
+
path
|
| 191 |
+
for path in run_dir.iterdir()
|
| 192 |
+
if path.is_dir() and ((path / "predictions.npz").exists() or (path / "metrics.json").exists())
|
| 193 |
+
)
|
| 194 |
+
# Backward-compatible fallback for results created before mode directories were introduced.
|
| 195 |
+
artifact_dirs = mode_dirs or [run_dir]
|
| 196 |
+
for artifact_dir in artifact_dirs:
|
| 197 |
+
prediction_path = artifact_dir / "predictions.npz"
|
| 198 |
+
metadata: Dict[str, Any] = {}
|
| 199 |
+
if prediction_path.exists():
|
| 200 |
+
with np.load(prediction_path, allow_pickle=False) as payload_file:
|
| 201 |
+
payload = {key: payload_file[key] for key in payload_file.files}
|
| 202 |
+
metadata = _metadata(payload)
|
| 203 |
+
suffix = str(metadata.get("mode", artifact_dir.name))
|
| 204 |
+
output = figures_root / f"{experiment}_{variant}_{suffix}.png"
|
| 205 |
+
mode_title = f"{title} / {suffix}"
|
| 206 |
+
if "labels" in payload:
|
| 207 |
+
plot_ood(payload, output, mode_title)
|
| 208 |
+
elif "grid_shape" in payload:
|
| 209 |
+
plot_pde(payload, output, mode_title)
|
| 210 |
+
else:
|
| 211 |
+
plot_generic(payload, output, mode_title)
|
| 212 |
+
print(f"FIGURE {output}", flush=True)
|
| 213 |
+
artifact_count += 1
|
| 214 |
+
metrics_path = artifact_dir / "metrics.json"
|
| 215 |
+
if metrics_path.exists():
|
| 216 |
+
with metrics_path.open("r", encoding="utf-8") as handle:
|
| 217 |
+
record = json.load(handle)
|
| 218 |
+
records.append(record)
|
| 219 |
+
metric_names = ("test_mse", "trimmed_test_mse", "relative_l2", "generalization_error")
|
| 220 |
+
metric_text = " ".join(
|
| 221 |
+
f"{name}={float(record[name]):.8e}" for name in metric_names if name in record
|
| 222 |
+
)
|
| 223 |
+
print(
|
| 224 |
+
f"METRICS experiment={experiment} variant={variant} "
|
| 225 |
+
f"mode={record.get('mode', metadata.get('mode', artifact_dir.name))} {metric_text}",
|
| 226 |
+
flush=True,
|
| 227 |
+
)
|
| 228 |
+
if artifact_count == 0 and not records:
|
| 229 |
+
raise FileNotFoundError("No history.csv, predictions.npz, or metrics.json matched the request")
|
| 230 |
+
summary_path = figures_root / "summary.json"
|
| 231 |
+
write_summary(summary_path, records)
|
| 232 |
+
print(f"SUMMARY {summary_path} runs={len(records)} figures={artifact_count}", flush=True)
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
if __name__ == "__main__":
|
| 236 |
+
main()
|
scripts/train.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Train the paper-scale or explicitly reduced DeepONet experiments."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import copy
|
| 8 |
+
import csv
|
| 9 |
+
import os
|
| 10 |
+
import random
|
| 11 |
+
import sys
|
| 12 |
+
import tempfile
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import Any, Dict, Iterable, Mapping, Tuple
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
import torch
|
| 18 |
+
import yaml
|
| 19 |
+
from torch import Tensor, nn
|
| 20 |
+
from torch.utils.data import DataLoader
|
| 21 |
+
|
| 22 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 23 |
+
if str(PROJECT_ROOT) not in sys.path:
|
| 24 |
+
sys.path.insert(0, str(PROJECT_ROOT))
|
| 25 |
+
|
| 26 |
+
from models.DeepONet import build_model, count_parameters # noqa: E402
|
| 27 |
+
from models.dataset import OperatorDataset, build_datasets, resolve_config # noqa: E402
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def parse_args() -> argparse.Namespace:
|
| 31 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 32 |
+
parser.add_argument("--config", type=Path, default=PROJECT_ROOT / "config/config.yaml")
|
| 33 |
+
parser.add_argument(
|
| 34 |
+
"--experiment",
|
| 35 |
+
default="antiderivative",
|
| 36 |
+
help="One experiment name or 'all' for all four paper experiments.",
|
| 37 |
+
)
|
| 38 |
+
parser.add_argument(
|
| 39 |
+
"--variant",
|
| 40 |
+
default=None,
|
| 41 |
+
help="Variant name, 'all', or omit to use each experiment's paper default.",
|
| 42 |
+
)
|
| 43 |
+
parser.add_argument("--smoke-test", action="store_true", help="Run reduced non-paper settings.")
|
| 44 |
+
parser.add_argument("--device", default=None, help="cpu, cuda, cuda:N, or auto")
|
| 45 |
+
parser.add_argument("--seed", type=int, default=None)
|
| 46 |
+
parser.add_argument("--resume", action="store_true")
|
| 47 |
+
parser.add_argument("--no-cache", action="store_true")
|
| 48 |
+
return parser.parse_args()
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def load_config(path: Path, smoke_test: bool) -> Dict[str, Any]:
|
| 52 |
+
with path.expanduser().resolve().open("r", encoding="utf-8") as handle:
|
| 53 |
+
raw = yaml.safe_load(handle)
|
| 54 |
+
if not isinstance(raw, Mapping):
|
| 55 |
+
raise ValueError(f"Configuration {path} must contain a mapping")
|
| 56 |
+
return resolve_config(raw, smoke_test=smoke_test)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def resolve_path(path_value: str, root: Path = PROJECT_ROOT) -> Path:
|
| 60 |
+
path = Path(path_value).expanduser()
|
| 61 |
+
return path if path.is_absolute() else root / path
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def select_device(requested: str) -> torch.device:
|
| 65 |
+
if requested == "auto":
|
| 66 |
+
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 67 |
+
device = torch.device(requested)
|
| 68 |
+
if device.type == "cuda" and not torch.cuda.is_available():
|
| 69 |
+
raise RuntimeError(f"CUDA device {requested!r} requested but CUDA is unavailable")
|
| 70 |
+
return device
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def set_seed(seed: int) -> None:
|
| 74 |
+
random.seed(seed)
|
| 75 |
+
np.random.seed(seed)
|
| 76 |
+
torch.manual_seed(seed)
|
| 77 |
+
if torch.cuda.is_available():
|
| 78 |
+
torch.cuda.manual_seed_all(seed)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def make_loader(
|
| 82 |
+
dataset: OperatorDataset,
|
| 83 |
+
batch_size: int | None,
|
| 84 |
+
*,
|
| 85 |
+
shuffle: bool,
|
| 86 |
+
num_workers: int,
|
| 87 |
+
pin_memory: bool,
|
| 88 |
+
) -> DataLoader:
|
| 89 |
+
effective_batch = len(dataset) if batch_size is None else int(batch_size)
|
| 90 |
+
if effective_batch < 1:
|
| 91 |
+
raise ValueError("batch size must be positive")
|
| 92 |
+
return DataLoader(
|
| 93 |
+
dataset,
|
| 94 |
+
batch_size=effective_batch,
|
| 95 |
+
shuffle=shuffle,
|
| 96 |
+
num_workers=num_workers,
|
| 97 |
+
pin_memory=pin_memory,
|
| 98 |
+
drop_last=False,
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def metric_values(prediction: np.ndarray, target: np.ndarray, trim_fraction: float = 0.0) -> Dict[str, float]:
|
| 103 |
+
squared_error = np.square(prediction.astype(np.float64) - target.astype(np.float64)).reshape(-1)
|
| 104 |
+
mse = float(np.mean(squared_error))
|
| 105 |
+
denominator = float(np.linalg.norm(target.astype(np.float64).reshape(-1)))
|
| 106 |
+
relative_l2 = float(
|
| 107 |
+
np.linalg.norm(prediction.astype(np.float64).reshape(-1) - target.astype(np.float64).reshape(-1))
|
| 108 |
+
/ max(denominator, np.finfo(np.float64).eps)
|
| 109 |
+
)
|
| 110 |
+
metrics = {"test_mse": mse, "relative_l2": relative_l2}
|
| 111 |
+
if trim_fraction > 0.0:
|
| 112 |
+
remove_count = min(len(squared_error) - 1, int(np.ceil(len(squared_error) * trim_fraction)))
|
| 113 |
+
kept = np.partition(squared_error, len(squared_error) - remove_count - 1)[
|
| 114 |
+
: len(squared_error) - remove_count
|
| 115 |
+
]
|
| 116 |
+
metrics["trimmed_test_mse"] = float(np.mean(kept))
|
| 117 |
+
return metrics
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
@torch.inference_mode()
|
| 121 |
+
def evaluate(
|
| 122 |
+
model: nn.Module,
|
| 123 |
+
dataset: OperatorDataset,
|
| 124 |
+
device: torch.device,
|
| 125 |
+
batch_size: int,
|
| 126 |
+
trim_fraction: float,
|
| 127 |
+
num_workers: int,
|
| 128 |
+
) -> Dict[str, float]:
|
| 129 |
+
loader = make_loader(
|
| 130 |
+
dataset,
|
| 131 |
+
batch_size,
|
| 132 |
+
shuffle=False,
|
| 133 |
+
num_workers=num_workers,
|
| 134 |
+
pin_memory=device.type == "cuda",
|
| 135 |
+
)
|
| 136 |
+
predictions, targets = [], []
|
| 137 |
+
model.eval()
|
| 138 |
+
for branch, trunk, target in loader:
|
| 139 |
+
output = model(branch.to(device), trunk.to(device))
|
| 140 |
+
predictions.append(output.detach().cpu().numpy())
|
| 141 |
+
targets.append(target.numpy())
|
| 142 |
+
return metric_values(np.concatenate(predictions), np.concatenate(targets), trim_fraction)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _cpu_copy(value: Any) -> Any:
|
| 146 |
+
if isinstance(value, Tensor):
|
| 147 |
+
return value.detach().cpu()
|
| 148 |
+
if isinstance(value, dict):
|
| 149 |
+
return {key: _cpu_copy(item) for key, item in value.items()}
|
| 150 |
+
if isinstance(value, list):
|
| 151 |
+
return [_cpu_copy(item) for item in value]
|
| 152 |
+
if isinstance(value, tuple):
|
| 153 |
+
return tuple(_cpu_copy(item) for item in value)
|
| 154 |
+
return value
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def torch_load(path: Path, map_location: str | torch.device = "cpu") -> Any:
|
| 158 |
+
try:
|
| 159 |
+
return torch.load(path, map_location=map_location, weights_only=False)
|
| 160 |
+
except TypeError:
|
| 161 |
+
return torch.load(path, map_location=map_location)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def load_bundle(path: Path) -> Dict[str, Any]:
|
| 165 |
+
if not path.exists():
|
| 166 |
+
return {"format_version": 1, "entries": {}}
|
| 167 |
+
bundle = torch_load(path)
|
| 168 |
+
if not isinstance(bundle, dict) or not isinstance(bundle.get("entries"), dict):
|
| 169 |
+
raise ValueError(f"Checkpoint {path} is not a DeepONet indexed bundle")
|
| 170 |
+
return bundle
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def save_bundle_entry(path: Path, key: str, entry: Mapping[str, Any]) -> None:
|
| 174 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 175 |
+
bundle = load_bundle(path)
|
| 176 |
+
bundle["format_version"] = 1
|
| 177 |
+
bundle["entries"][key] = _cpu_copy(dict(entry))
|
| 178 |
+
with tempfile.NamedTemporaryFile(dir=path.parent, suffix=".pth", delete=False) as handle:
|
| 179 |
+
temporary_path = Path(handle.name)
|
| 180 |
+
try:
|
| 181 |
+
torch.save(bundle, temporary_path)
|
| 182 |
+
os.replace(temporary_path, path)
|
| 183 |
+
finally:
|
| 184 |
+
if temporary_path.exists():
|
| 185 |
+
temporary_path.unlink()
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def append_history(path: Path, row: Mapping[str, Any]) -> None:
|
| 189 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 190 |
+
exists = path.exists()
|
| 191 |
+
with path.open("a", newline="", encoding="utf-8") as handle:
|
| 192 |
+
writer = csv.DictWriter(handle, fieldnames=list(row.keys()))
|
| 193 |
+
if not exists:
|
| 194 |
+
writer.writeheader()
|
| 195 |
+
writer.writerow(row)
|
| 196 |
+
handle.flush()
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def variants_for(config: Mapping[str, Any], experiment: str, requested: str | None) -> Iterable[str]:
|
| 200 |
+
if requested is None:
|
| 201 |
+
return [str(config["experiments"][experiment]["default_variant"])]
|
| 202 |
+
if requested == "all":
|
| 203 |
+
return list(config["variants"])
|
| 204 |
+
if requested not in config["variants"]:
|
| 205 |
+
raise KeyError(f"Unknown variant {requested!r}; choose from {list(config['variants'])}")
|
| 206 |
+
return [requested]
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def run_training(
|
| 210 |
+
config: Dict[str, Any],
|
| 211 |
+
experiment: str,
|
| 212 |
+
variant: str,
|
| 213 |
+
*,
|
| 214 |
+
no_cache: bool,
|
| 215 |
+
resume: bool,
|
| 216 |
+
) -> None:
|
| 217 |
+
experiment_config = config["experiments"][experiment]
|
| 218 |
+
training_config = config["training_defaults"]
|
| 219 |
+
device = select_device(str(config["runtime"]["device"]))
|
| 220 |
+
seed = int(config["runtime"]["seed"])
|
| 221 |
+
set_seed(seed)
|
| 222 |
+
|
| 223 |
+
if no_cache:
|
| 224 |
+
from models.dataset import build_split
|
| 225 |
+
|
| 226 |
+
train_dataset = build_split(config, experiment, "train", PROJECT_ROOT, use_cache=False)
|
| 227 |
+
test_dataset = build_split(config, experiment, "test", PROJECT_ROOT, use_cache=False)
|
| 228 |
+
else:
|
| 229 |
+
train_dataset, test_dataset = build_datasets(config, experiment, PROJECT_ROOT)
|
| 230 |
+
|
| 231 |
+
model = build_model(config, experiment, variant).to(device)
|
| 232 |
+
optimizer = torch.optim.Adam(
|
| 233 |
+
model.parameters(),
|
| 234 |
+
lr=float(training_config["learning_rate"]),
|
| 235 |
+
weight_decay=float(training_config.get("weight_decay", 0.0)),
|
| 236 |
+
)
|
| 237 |
+
criterion = nn.MSELoss(reduction="mean")
|
| 238 |
+
checkpoint_path = resolve_path(str(config["paths"]["checkpoint"]))
|
| 239 |
+
entry_key = f"{experiment}/{variant}"
|
| 240 |
+
start_iteration = 0
|
| 241 |
+
best_metric = float("inf")
|
| 242 |
+
if resume and checkpoint_path.exists():
|
| 243 |
+
entry = load_bundle(checkpoint_path)["entries"].get(entry_key)
|
| 244 |
+
if entry is None:
|
| 245 |
+
raise KeyError(f"Cannot resume: {entry_key!r} is absent from {checkpoint_path}")
|
| 246 |
+
model.load_state_dict(entry["model_state"], strict=True)
|
| 247 |
+
optimizer.load_state_dict(entry["optimizer_state"])
|
| 248 |
+
start_iteration = int(entry["iteration"])
|
| 249 |
+
best_metric = float(entry["best_metric"])
|
| 250 |
+
|
| 251 |
+
batch_size = training_config.get("batch_size")
|
| 252 |
+
train_loader = make_loader(
|
| 253 |
+
train_dataset,
|
| 254 |
+
None if batch_size is None else int(batch_size),
|
| 255 |
+
shuffle=True,
|
| 256 |
+
num_workers=int(config["runtime"].get("num_workers", 0)),
|
| 257 |
+
pin_memory=device.type == "cuda",
|
| 258 |
+
)
|
| 259 |
+
train_iterator = iter(train_loader)
|
| 260 |
+
iterations = int(experiment_config["iterations"])
|
| 261 |
+
print_every = int(training_config["print_every"])
|
| 262 |
+
evaluate_every = int(training_config["evaluate_every"])
|
| 263 |
+
eval_batch_size = int(training_config["evaluation_batch_size"])
|
| 264 |
+
trim_fraction = float(experiment_config.get("trim_fraction", 0.0))
|
| 265 |
+
history_path = resolve_path(str(config["paths"]["results"])) / experiment / variant / "history.csv"
|
| 266 |
+
paper_scale = bool(config["project"]["paper_scale"])
|
| 267 |
+
if not resume and history_path.exists():
|
| 268 |
+
history_path.unlink()
|
| 269 |
+
|
| 270 |
+
print(
|
| 271 |
+
f"START experiment={experiment} variant={variant} device={device} "
|
| 272 |
+
f"parameters={count_parameters(model)} train_points={len(train_dataset)} "
|
| 273 |
+
f"test_points={len(test_dataset)} iterations={iterations} paper_scale={paper_scale}",
|
| 274 |
+
flush=True,
|
| 275 |
+
)
|
| 276 |
+
for iteration in range(start_iteration + 1, iterations + 1):
|
| 277 |
+
try:
|
| 278 |
+
branch, trunk, target = next(train_iterator)
|
| 279 |
+
except StopIteration:
|
| 280 |
+
train_iterator = iter(train_loader)
|
| 281 |
+
branch, trunk, target = next(train_iterator)
|
| 282 |
+
model.train()
|
| 283 |
+
optimizer.zero_grad(set_to_none=True)
|
| 284 |
+
prediction = model(branch.to(device), trunk.to(device))
|
| 285 |
+
loss = criterion(prediction, target.to(device))
|
| 286 |
+
if not torch.isfinite(loss):
|
| 287 |
+
raise FloatingPointError(f"non-finite training loss at iteration {iteration}")
|
| 288 |
+
loss.backward()
|
| 289 |
+
optimizer.step()
|
| 290 |
+
train_loss = float(loss.detach().cpu())
|
| 291 |
+
|
| 292 |
+
should_evaluate = iteration == 1 or iteration % evaluate_every == 0 or iteration == iterations
|
| 293 |
+
should_print = iteration == 1 or iteration % print_every == 0 or should_evaluate
|
| 294 |
+
metrics: Dict[str, float] = {}
|
| 295 |
+
if should_evaluate:
|
| 296 |
+
metrics = evaluate(
|
| 297 |
+
model,
|
| 298 |
+
test_dataset,
|
| 299 |
+
device,
|
| 300 |
+
eval_batch_size,
|
| 301 |
+
trim_fraction,
|
| 302 |
+
int(config["runtime"].get("num_workers", 0)),
|
| 303 |
+
)
|
| 304 |
+
row: Dict[str, Any] = {
|
| 305 |
+
"iteration": iteration,
|
| 306 |
+
"train_loss": train_loss,
|
| 307 |
+
"test_mse": metrics["test_mse"],
|
| 308 |
+
"relative_l2": metrics["relative_l2"],
|
| 309 |
+
"generalization_error": metrics["test_mse"] - train_loss,
|
| 310 |
+
"paper_scale": paper_scale,
|
| 311 |
+
}
|
| 312 |
+
if "trimmed_test_mse" in metrics:
|
| 313 |
+
row["trimmed_test_mse"] = metrics["trimmed_test_mse"]
|
| 314 |
+
append_history(history_path, row)
|
| 315 |
+
if metrics["test_mse"] < best_metric:
|
| 316 |
+
best_metric = metrics["test_mse"]
|
| 317 |
+
save_bundle_entry(
|
| 318 |
+
checkpoint_path,
|
| 319 |
+
entry_key,
|
| 320 |
+
{
|
| 321 |
+
"experiment": experiment,
|
| 322 |
+
"variant": variant,
|
| 323 |
+
"iteration": iteration,
|
| 324 |
+
"best_metric": best_metric,
|
| 325 |
+
"metric_name": "test_mse",
|
| 326 |
+
"model_state": model.state_dict(),
|
| 327 |
+
"optimizer_state": optimizer.state_dict(),
|
| 328 |
+
"run_config": copy.deepcopy(config),
|
| 329 |
+
"paper_scale": paper_scale,
|
| 330 |
+
},
|
| 331 |
+
)
|
| 332 |
+
if should_print:
|
| 333 |
+
metric_text = " ".join(f"{name}={value:.8e}" for name, value in metrics.items())
|
| 334 |
+
print(
|
| 335 |
+
f"TRAIN experiment={experiment} variant={variant} iteration={iteration}/{iterations} "
|
| 336 |
+
f"train_loss={train_loss:.8e} {metric_text}".rstrip(),
|
| 337 |
+
flush=True,
|
| 338 |
+
)
|
| 339 |
+
print(
|
| 340 |
+
f"DONE experiment={experiment} variant={variant} best_test_mse={best_metric:.8e} "
|
| 341 |
+
f"checkpoint={checkpoint_path} history={history_path}",
|
| 342 |
+
flush=True,
|
| 343 |
+
)
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def main() -> None:
|
| 347 |
+
args = parse_args()
|
| 348 |
+
config = load_config(args.config, args.smoke_test)
|
| 349 |
+
if args.device is not None:
|
| 350 |
+
config["runtime"]["device"] = args.device
|
| 351 |
+
if args.seed is not None:
|
| 352 |
+
config["runtime"]["seed"] = args.seed
|
| 353 |
+
experiments = list(config["experiments"]) if args.experiment == "all" else [args.experiment]
|
| 354 |
+
unknown = [name for name in experiments if name not in config["experiments"]]
|
| 355 |
+
if unknown:
|
| 356 |
+
raise KeyError(f"Unknown experiments: {unknown}; choose from {list(config['experiments'])}")
|
| 357 |
+
for experiment in experiments:
|
| 358 |
+
for variant in variants_for(config, experiment, args.variant):
|
| 359 |
+
run_training(
|
| 360 |
+
config,
|
| 361 |
+
experiment,
|
| 362 |
+
variant,
|
| 363 |
+
no_cache=args.no_cache,
|
| 364 |
+
resume=args.resume or bool(config["training_defaults"].get("resume", False)),
|
| 365 |
+
)
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
if __name__ == "__main__":
|
| 369 |
+
main()
|
weight/best_model.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3a04948321ca665138644c2dc968df72fd025fb0c5a5322d3a29835f503dd5b7
|
| 3 |
+
size 702114
|