Skip to content

Commit f46e112

Browse files
authored
Dambreak optuna example (#26)
* added Hyperparameter example with Optuna.jl * add CI and improve code coverage * remove history path * boundary changes and last rollout changes * bump version * bump julia version * update documenter * CPU path ns error fix
1 parent ca3af00 commit f46e112

24 files changed

Lines changed: 1003 additions & 686 deletions

.github/workflows/CI.yml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
tags: ['*']
8+
pull_request:
9+
branches:
10+
- main
11+
workflow_dispatch:
12+
13+
concurrency:
14+
group: ${{ github.workflow }}-${{ github.ref }}
15+
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
16+
17+
jobs:
18+
test:
19+
name: Test - Julia ${{ matrix.version }}
20+
runs-on: ubuntu-latest
21+
timeout-minutes: 120
22+
strategy:
23+
fail-fast: false
24+
matrix:
25+
version:
26+
- '1.12' # minimum supported (Octopus requires Julia ≥ 1.12; see Project.toml [compat])
27+
- '1' # latest stable 1.x
28+
steps:
29+
- uses: actions/checkout@v7
30+
- uses: julia-actions/setup-julia@v3
31+
with:
32+
version: ${{ matrix.version }}
33+
arch: x64
34+
- uses: julia-actions/cache@v3
35+
- uses: julia-actions/julia-buildpkg@v1
36+
# The suite auto-detects the absence of a GPU (CUDA.functional() == false
37+
# on GitHub-hosted runners) and runs the CPU code path, so no GPU is needed.
38+
- uses: julia-actions/julia-runtest@v1
39+
- uses: julia-actions/julia-processcoverage@v1
40+
if: always()
41+
- uses: codecov/codecov-action@v4
42+
if: always()
43+
with:
44+
files: lcov.info
45+
fail_ci_if_error: false
46+
env:
47+
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

.github/workflows/Documenter.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ jobs:
2828
- name: "Set up Julia"
2929
uses: julia-actions/setup-julia@v3
3030
with:
31-
version: '1.11'
31+
version: '1.12' # Octopus 0.2 requires Julia ≥ 1.12 (see Project.toml [compat])
3232
arch: x64
3333

3434
- name: "Copy readme to doc"

Project.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name = "GraphNetSim"
22
uuid = "5ff66f56-808c-48e7-ac84-dd29877231f8"
3-
version = "0.1.2"
3+
version = "0.2.0"
44
authors = ["Josef Jouaux <Josef.Kircher@uni-a.de>", "JT <julian.trommer@uni-a.de>"]
55

66
[deps]
@@ -79,7 +79,7 @@ Statistics = "1"
7979
Test = "1"
8080
Zygote = "0.6, 0.7"
8181
cuDNN = "1.4 - 1"
82-
julia = "1.11"
82+
julia = "1.12"
8383

8484
[extras]
8585
Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595"

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ The package is build upon [**GraphNetCore.jl**](https://github.com/una-auxme/Gra
2323

2424
## Requirements
2525

26-
- **Julia ≥ 1.11**
26+
- **Julia ≥ 1.12**
2727
- Built on [GraphNetCore.jl](https://github.com/una-auxme/GraphNetCore.jl) **v0.4**, which uses a
2828
[Lux.jl](https://github.com/LuxDL/Lux.jl) `TrainState` and pulls in a CUDA-capable stack.
2929
- A CUDA-capable GPU is recommended for training (falls back to CPU when CUDA is unavailable).

docs/Project.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
[deps]
22
Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
3+
GraphNetSim = "5ff66f56-808c-48e7-ac84-dd29877231f8"
34
Optuna = "a5d0552b-b2dc-4f08-ac5c-85ca7d701b92"
45

56
[compat]
6-
julia = "1.11"
7+
julia = "1.12"

docs/make.jl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ makedocs(;
2121
pages=[
2222
"Home" => "index.md",
2323
"Loading Data" => "loading_data.md",
24+
"Examples" => "examples.md",
25+
"Hyperparameter Optimization" => "hyperparameter_optimization.md",
2426
"API Reference" => "api.md",
2527
],
2628
)

docs/src/examples.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Examples
2+
3+
Runnable examples live in the [`example/`](https://github.com/una-auxme/GraphNetSim.jl/tree/main/example)
4+
directory of the repository. The two smallest — [BallisticSmall](#BallisticSmall) and
5+
[DamBreakSmall](#DamBreakSmall) — are self-contained: on first run they generate their dataset into
6+
`data/` via the generators in `test/generators.jl`, so no external download is required. Run them from
7+
the repository root with the package's own project environment:
8+
9+
```bash
10+
julia --project example/BallisticSmall/BallisticSmall.jl
11+
```
12+
13+
## BallisticSmall
14+
15+
A tiny ballistic dataset (10 particles, no boundary nodes, linear drag physics) — the simplest
16+
end-to-end example, and a good first run to confirm your setup works.
17+
18+
[`example/BallisticSmall/BallisticSmall.jl`](https://github.com/una-auxme/GraphNetSim.jl/tree/main/example/BallisticSmall/BallisticSmall.jl)
19+
walks through the recommended multi-phase workflow:
20+
21+
1. **DerivativeTraining** — fast initial training against precomputed derivatives (no ODE solve per step).
22+
2. **[`BatchingStrategy`](@ref GraphNetSim.BatchingStrategy)** fine-tuning — ODE-based loss over the trajectory.
23+
3. **[`MultipleShooting`](@ref GraphNetSim.MultipleShooting)** fine-tuning — trajectory split into intervals with a continuity penalty.
24+
4. **[`eval_network`](@ref GraphNetSim.eval_network)** — long-horizon rollout on the test split, then `visualize_eval` to export VTK HDF5 for ParaView.
25+
26+
Because there are no boundary particles, `types_updated = [1]` predicts every particle.
27+
28+
## DamBreakSmall
29+
30+
A tiny 2D weakly-compressible SPH dam break (9 fluid + 9 boundary particles). Like
31+
[BallisticSmall](#BallisticSmall), but with boundary nodes — so it is the reference environment for
32+
both a complete training run and a hyperparameter search.
33+
34+
### Full training pipeline
35+
36+
[`example/DamBreakSmall/DamBreakSmall.jl`](https://github.com/una-auxme/GraphNetSim.jl/tree/main/example/DamBreakSmall/DamBreakSmall.jl)
37+
runs the same four-step pipeline as BallisticSmall, updating only the fluid particles
38+
(`types_updated = [2]`). Offline normalization statistics are precomputed once with
39+
[`data_minmax`](@ref GraphNetSim.data_minmax) and [`data_meanstd`](@ref GraphNetSim.data_meanstd) so
40+
training can run with `norm_steps=0`.
41+
42+
### Hyperparameter optimization with Optuna
43+
44+
[`example/DamBreakSmall/DamBreakSmall_optuna.jl`](https://github.com/una-auxme/GraphNetSim.jl/tree/main/example/DamBreakSmall/DamBreakSmall_optuna.jl)
45+
runs an automated hyperparameter search over the same dataset using
46+
[Optuna.jl](https://github.com/una-auxme/Optuna.jl). It uses the ask/tell interface: each trial trains
47+
a GNN with [`DerivativeTraining`](@ref GraphNetSim.DerivativeTraining) for a fixed number of steps and
48+
reports the best validation loss returned by [`train_network`](@ref GraphNetSim.train_network).
49+
50+
Optuna is an extra dependency, provided by this example's own `Project.toml`, so run it with that
51+
environment activated:
52+
53+
```bash
54+
julia --project=example/DamBreakSmall example/DamBreakSmall/DamBreakSmall_optuna.jl
55+
```
56+
57+
Searched hyperparameters:
58+
59+
| Group | Parameters |
60+
| --- | --- |
61+
| Architecture | `mps`, `layer_size`, `hidden_layers` |
62+
| Optimiser | `optimizer` (Adam / AdamW / RAdam), `lr`, `lr_decay_ratio`, `weight_decay` (AdamW only) |
63+
| Regularisation | `noise_std` |
64+
| Normalisation | `norm_type` (`:minmax` / `:meanstd`) |
65+
| Training | `random_sampling`, `window_size` |
66+
67+
Key properties:
68+
69+
- **Sampler / pruner** — a TPE sampler with a median pruner drops unpromising trials early.
70+
- **Both normalization statistics are precomputed** with [`update_meta!`](@ref GraphNetSim.update_meta!)
71+
(once for `:minmax`, once for `:meanstd`), so a trial only selects between them via `norm_type`.
72+
- **Resumable** — the study is persisted in a SQLite database and trial artifacts on disk, so re-running
73+
the script continues from where it left off until the target trial count is reached.
74+
75+
When the run finishes, the best trial, its parameters, and its validation loss are printed. Adjust
76+
`n_trials` and the per-trial `n_steps` at the top of the script to trade search breadth against
77+
wall-clock time.
78+
79+
## Further scripts
80+
81+
The remaining subfolders of [`example/`](https://github.com/una-auxme/GraphNetSim.jl/tree/main/example)
82+
`Ballistics`, `DamBreak`, `Duese`, `GradientDiagnostics`, `RuntimeBenchmark`, and `WaterRamps`
83+
hold research and benchmarking material: training variants, ablations, evaluation/visualization
84+
utilities, SLURM (`.sbatch`) cluster job scripts, and comparison harnesses. They target larger
85+
datasets that are **not** bundled with the repository and often assume specific hardware, so treat
86+
them as references rather than turnkey tutorials. Notably, `WaterRamps/WaterRamps_optuna.jl` mirrors
87+
the DamBreakSmall Optuna search for the (external) WaterRamps dataset.
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
# Hyperparameter Optimization
2+
3+
GraphNetSim integrates with [Optuna.jl](https://github.com/una-auxme/Optuna.jl) to automate the search
4+
for good GNN-simulator hyperparameters. This page documents the workflow implemented by the runnable
5+
example
6+
[`example/DamBreakSmall/DamBreakSmall_optuna.jl`](https://github.com/una-auxme/GraphNetSim.jl/tree/main/example/DamBreakSmall/DamBreakSmall_optuna.jl);
7+
the same pattern applies to any dataset.
8+
9+
## Requirements
10+
11+
Optuna is an extra dependency, so activate an environment that provides it. The DamBreakSmall example
12+
ships its own `Project.toml` with Optuna, OrdinaryDiffEq, and Optimisers, so run the script with that
13+
environment from the repository root:
14+
15+
```bash
16+
julia --project=example/DamBreakSmall example/DamBreakSmall/DamBreakSmall_optuna.jl
17+
```
18+
19+
## Workflow overview
20+
21+
The workflow has four parts: a **persistent study**, an **objective** that trains one model per trial,
22+
an **ask/tell loop** that samples the search space, and **result inspection**. A single trial trains a
23+
GNN with sampled hyperparameters and reports the best validation loss returned by
24+
[`train_network`](@ref GraphNetSim.train_network) — that is, the loss of the periodic ODE rollout on
25+
the validation split. Minimizing that value across trials is the optimization objective.
26+
27+
### 1. A persistent, resumable study
28+
29+
The study is backed by a SQLite database and an on-disk artifact store, so re-running the script
30+
continues an existing study rather than starting over:
31+
32+
```julia
33+
storage_url = create_sqlite_url(database_url, database_name)
34+
storage = RDBStorage(storage_url)
35+
artifact_store = FileSystemArtifactStore(artifact_path)
36+
37+
study = Study(
38+
study_name,
39+
artifact_store,
40+
storage;
41+
sampler=TPESampler(), # Tree-structured Parzen Estimator
42+
pruner=MedianPruner(5, 1), # stop trials worse than the running median
43+
direction="minimize",
44+
load_if_exists=true, # resume an existing study of the same name
45+
)
46+
```
47+
48+
- **Sampler**`TPESampler` models the relationship between hyperparameters and loss and proposes
49+
promising configurations; swap in another sampler to change the search strategy.
50+
- **Pruner**`MedianPruner` terminates unpromising trials early (after a startup grace period) by
51+
comparing a trial's reported loss against previous trials.
52+
- **`load_if_exists=true`** — combined with the SQLite storage, this is what makes the run resumable.
53+
54+
### 2. The objective — one training run per trial
55+
56+
The objective converts sampled parameters into a training configuration, runs
57+
[`train_network`](@ref GraphNetSim.train_network), and returns the best validation loss. Each trial
58+
trains into a fresh temporary checkpoint directory so trials do not interfere:
59+
60+
```julia
61+
function objective(trial::Trial; params)
62+
cp_path = mktempdir()
63+
64+
opt = if params[:optimizer] == "Adam"
65+
Adam(params[:lr])
66+
elseif params[:optimizer] == "AdamW"
67+
AdamW(; eta=params[:lr], lambda=params[:weight_decay])
68+
else
69+
RAdam(params[:lr])
70+
end
71+
72+
min_val_loss = train_network(
73+
opt, ds_path, cp_path;
74+
training_strategy=DerivativeTraining(;
75+
random=params[:random_sampling], window_size=params[:window_size]
76+
),
77+
steps=n_steps, checkpoint=cp_interval,
78+
mps=params[:mps], layer_size=params[:layer_size], hidden_layers=params[:hidden_layers],
79+
noise_stddevs=[params[:noise_std]],
80+
norm_steps=0, norm_type=params[:norm_type],
81+
optimizer_learning_rate_start=params[:lr],
82+
optimizer_learning_rate_stop=params[:lr] * params[:lr_decay_ratio],
83+
# ... fixed args: types_updated, types_noisy, solver_valid, use_cuda, ...
84+
)
85+
86+
report(trial, Float64(min_val_loss), 1) # feed the pruner
87+
should_prune(trial) && return nothing
88+
89+
upload_artifact(study, trial, Dict(String(k) => v for (k, v) in pairs(params)))
90+
return Float64(min_val_loss)
91+
end
92+
```
93+
94+
`report` hands the trial's loss to the pruner; `should_prune` then decides whether to abandon it;
95+
`upload_artifact` records the trial's hyperparameters for later inspection.
96+
97+
### 3. The ask/tell loop — sampling the search space
98+
99+
Each iteration `ask`s the study for a trial, draws hyperparameters with the `suggest_*` family, runs
100+
the objective, and `tell`s the study the result (or that it was pruned):
101+
102+
```julia
103+
trial = ask(study)
104+
105+
mps = suggest_int(trial, "mps", 3, 10)
106+
layer_size = suggest_categorical(trial, "layer_size", [32, 64, 128])
107+
lr = suggest_float(trial, "lr", 1.0e-5, 1.0e-3; log=true) # log-scale
108+
norm_type = Symbol(suggest_categorical(trial, "norm_type", ["minmax", "meanstd"]))
109+
# ... remaining suggestions ...
110+
111+
params = (; mps, layer_size, lr=Float32(lr), norm_type, #= ... =#)
112+
score = objective(trial; params)
113+
114+
if isnothing(score)
115+
tell(study, trial; prune=true)
116+
else
117+
tell(study, trial, score)
118+
end
119+
```
120+
121+
Use `suggest_int` / `suggest_categorical` / `suggest_float` (with `log=true` for scale-free
122+
quantities like learning rates) to declare each dimension. The example searches architecture
123+
(`mps`, `layer_size`, `hidden_layers`), optimiser (`optimizer`, `lr`, `lr_decay_ratio`,
124+
`weight_decay`), regularisation (`noise_std`), normalisation (`norm_type`), and training strategy
125+
(`random_sampling`, `window_size`).
126+
127+
### 4. Resume-awareness and results
128+
129+
Because the study is persistent, the loop counts already-completed trials so restarts converge on a
130+
fixed total instead of adding a fresh batch each time:
131+
132+
```julia
133+
n_completed = length(study.study.trials)
134+
n_remaining = max(0, n_trials - n_completed)
135+
```
136+
137+
When the run finishes, inspect the outcome with `best_trial(study)`, `best_params(study)`, and
138+
`best_value(study)`.
139+
140+
## Adapting it to your own dataset
141+
142+
1. **Point at your data** — set `ds_path`, and generate or provide `train.h5` / `valid.h5` /
143+
`test.h5` + `meta.json`.
144+
2. **Precompute normalization** — if you search `norm_type`, run [`update_meta!`](@ref GraphNetSim.update_meta!)
145+
once per statistic you want available (`:minmax` and/or `:meanstd`); a trial then only selects
146+
between them.
147+
3. **Set the fixed budget**`n_steps` per trial, `cp_interval` (validation cadence), `n_trials`,
148+
and the simulation interval (`dt`, `tstop`). These trade search breadth against wall-clock time.
149+
4. **Edit the search space** — add or remove `suggest_*` calls, mirror them in the `params`
150+
NamedTuple, and forward them to `train_network`.
151+
152+
!!! warning "Only tune parameters the API actually exposes"
153+
Every keyword forwarded to `train_network` must be an [`Args`](@ref GraphNetSim.Args) field, and
154+
every keyword to a strategy constructor must exist on that strategy (for example,
155+
[`DerivativeTraining`](@ref GraphNetSim.DerivativeTraining) accepts only `window_size` and
156+
`random`). Passing an unknown keyword errors when the trial builds its configuration.
157+
158+
## Another instance
159+
160+
[`example/WaterRamps/WaterRamps_optuna.jl`](https://github.com/una-auxme/GraphNetSim.jl/tree/main/example/WaterRamps/WaterRamps_optuna.jl)
161+
applies this same workflow to the (external) WaterRamps dataset, which is useful as a larger-scale
162+
reference — see [Examples](@ref) for why those research scripts are not turnkey.

0 commit comments

Comments
 (0)