yushuang88 commited on
Commit
d46980f
·
verified ·
1 Parent(s): d80d008

Upload folder using huggingface_hub

Browse files
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