Skip to content

Commit 2ecdda9

Browse files
RemiLeheclaudepre-commit-ci[bot]EZoni
authored
Add automated ML train/save/load test scripts (#404)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Edoardo Zoni <59625522+EZoni@users.noreply.github.com>
1 parent 2622912 commit 2ecdda9

7 files changed

Lines changed: 399 additions & 37 deletions

File tree

dashboard/app.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from trame.ui.vuetify3 import SinglePageWithDrawerLayout
77
from trame.widgets import plotly, router, vuetify3 as vuetify, html
88

9-
from model_manager import ModelManager
9+
from model_manager import ModelManager, model_type_tag_dict
1010
from outputs_manager import OutputManager
1111
from optimization_manager import OptimizationManager
1212
from parameters_manager import ParametersManager
@@ -16,6 +16,7 @@
1616
from error_manager import error_panel, add_error
1717
from utils import (
1818
data_depth_panel,
19+
load_config_dict,
1920
load_experiments,
2021
load_database,
2122
load_data,
@@ -64,14 +65,18 @@ def update(
6465
state.experiment
6566
)
6667
# load data
67-
db = load_database(state.experiment)
68-
exp_data, sim_data = load_data(db)
68+
config_dict = load_config_dict(state.experiment)
69+
db = load_database(config_dict)
70+
exp_data, sim_data = load_data(db, state.experiment, state.experiment_date_range)
6971
# reset output
7072
if reset_output:
7173
out_manager = OutputManager(output_variables)
7274
# reset model
7375
if reset_model:
74-
mod_manager = ModelManager()
76+
mod_manager = ModelManager(
77+
config_dict=config_dict,
78+
model_type_tag=model_type_tag_dict[state.model_type],
79+
)
7580
opt_manager = OptimizationManager(mod_manager)
7681
# reset parameters
7782
if reset_parameters:
@@ -257,7 +262,8 @@ def find_simulation(event, db):
257262

258263

259264
def open_simulation_dialog(event):
260-
db = load_database(state.experiment)
265+
config_dict = load_config_dict(state.experiment)
266+
db = load_database(config_dict)
261267
try:
262268
data_directory, file_path = find_simulation(event, db)
263269
state.simulation_video = file_path.endswith(".mp4")

dashboard/error_manager.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
from trame.widgets import vuetify3 as vuetify, html
2-
from state_manager import state
2+
from state_manager import state, server
33

44

55
def add_error(title, msg):
6+
if not server.running:
7+
# Outside of a Trame app (e.g. check_model.py), raise a Python error
8+
# to surface the error to the caller.
9+
raise RuntimeError(f"{title}: {msg}")
10+
# Otherwise: Inside a Trame app, add the error to the state.
611
state.errors.append(
712
{
813
"id": state.error_counter,

dashboard/model_manager.py

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -65,29 +65,17 @@ def patched(host_creds, endpoint, method, *args, **kwargs):
6565

6666

6767
class ModelManager:
68-
def __init__(self):
68+
def __init__(self, config_dict, model_type_tag):
6969
print("Initializing model manager...")
70-
# Set initial default values
7170
self.__model = None
7271
self.__is_neural_network = False
7372
self.__is_gaussian_process = False
7473
self.__is_neural_network_ensemble = False
75-
76-
model_type_tag = model_type_tag_dict[state.model_type]
77-
try:
78-
config_dict = load_config_dict(state.experiment)
79-
except Exception as e:
80-
title = "Unable to load experiment configuration"
81-
msg = (
82-
f"Error occurred when loading configuration for {state.experiment}: {e}"
83-
)
84-
add_error(title, msg)
85-
print(msg)
86-
return
74+
self.__model_type_tag = model_type_tag
8775

8876
if "mlflow" not in config_dict or not config_dict["mlflow"].get("tracking_uri"):
8977
print(
90-
f"No mlflow.tracking_uri in configuration file for {state.experiment}; cannot load model from MLflow."
78+
f"No mlflow.tracking_uri in configuration file for {config_dict['experiment']}; cannot load model from MLflow."
9179
)
9280
return
9381

@@ -99,7 +87,9 @@ def __init__(self):
9987
== "https://mlflow.american-science-cloud.org"
10088
):
10189
enable_amsc_x_api_key(config_dict)
102-
model_name = f"{state.experiment}_{model_type_tag}"
90+
91+
experiment = config_dict["experiment"]
92+
model_name = f"{experiment}_{model_type_tag}"
10393

10494
try:
10595
# Download model from MLflow server
@@ -108,16 +98,16 @@ def __init__(self):
10898
.unwrap_python_model()
10999
.model
110100
)
111-
if state.model_type == "Neural Network (single)":
101+
if model_type_tag == "NN":
112102
self.__is_neural_network = True
113-
elif state.model_type == "Neural Network (ensemble)":
103+
elif model_type_tag == "ensemble_NN":
114104
self.__is_neural_network_ensemble = True
115-
elif state.model_type == "Gaussian Process":
105+
elif model_type_tag == "GP":
116106
self.__is_gaussian_process = True
117107
else:
118-
raise ValueError(f"Unsupported model type: {state.model_type}")
108+
raise ValueError(f"Unsupported model type: {model_type_tag}")
119109
except Exception as e:
120-
title = f"Unable to load model {state.model_type}"
110+
title = f"Unable to load model {model_type_tag}"
121111
msg = f"Error occurred when loading model from MLflow: {e}"
122112
add_error(title, msg)
123113
print(msg)
@@ -156,7 +146,7 @@ def evaluate(self, parameters, output):
156146
std_dev = output_dict[output].variance.sqrt().detach()
157147
mean_error = 2.0 * std_dev
158148
else:
159-
raise ValueError(f"Unsupported model type: {state.model_type}")
149+
raise ValueError(f"Unsupported model type: {self.__model_type_tag}")
160150
# compute lower/upper bounds for error range
161151
lower = mean - mean_error
162152
upper = mean + mean_error

dashboard/utils.py

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -103,15 +103,13 @@ def create_date_filter(experiment_date_range):
103103

104104

105105
@timer
106-
def load_data(db):
106+
def load_data(db, experiment, date_range=None):
107107
print("Loading data from database...")
108108
# create date filter if date range is set
109-
date_filter = create_date_filter(state.experiment_date_range)
109+
date_filter = create_date_filter(date_range)
110110
# load experiment and simulation data points in dataframes
111-
exp_data = pd.DataFrame(
112-
db[state.experiment].find({"experiment_flag": 1, **date_filter})
113-
)
114-
sim_data = pd.DataFrame(db[state.experiment].find({"experiment_flag": 0}))
111+
exp_data = pd.DataFrame(db[experiment].find({"experiment_flag": 1, **date_filter}))
112+
sim_data = pd.DataFrame(db[experiment].find({"experiment_flag": 0}))
115113
# Store '_id', 'date' as string
116114
for key in ["_id", "date"]:
117115
if key in exp_data.columns:
@@ -122,10 +120,8 @@ def load_data(db):
122120

123121

124122
@timer
125-
def load_database(experiment):
123+
def load_database(config_dict):
126124
print("Loading database...")
127-
# load configuration dictionary
128-
config_dict = load_config_dict(experiment)
129125
# read database information from configuration dictionary
130126
db_host = config_dict["database"]["host"]
131127
db_port = config_dict["database"]["port"]

ml/README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,30 @@ This section describes how to train ML models locally.
7272
python train_model.py --test --model <your_model> --config_file <your_config_file>
7373
```
7474

75+
### Test the full train/save/load cycle: `test_ml_pipeline.py`
76+
77+
[`tests/test_ml_pipeline.py`](../tests/test_ml_pipeline.py) exercises the full ML lifecycle: training → upload to MLflow → download → accuracy check. It requires a local, empty MLflow server to avoid touching any production server.
78+
79+
1. Start a local MLflow server, e.g. with Docker:
80+
```bash
81+
docker run -p 127.0.0.1:5000:5000 ghcr.io/mlflow/mlflow mlflow server --host 0.0.0.0
82+
```
83+
84+
2. Run the test script from the root of the repository (by default this expects the MLflow server to run on `localhost:5000`):
85+
```bash
86+
python tests/test_ml_pipeline.py
87+
```
88+
89+
Optionally, restrict to a specific model type or config file:
90+
```bash
91+
python tests/test_ml_pipeline.py --model NN --config_file experiments/synapse-bella-ip2
92+
```
93+
94+
If your MLflow server is running on a different port (e.g. 5001 instead of 5000), pass it explicitly:
95+
```bash
96+
python tests/test_ml_pipeline.py --test-mlflow-uri http://localhost:5001
97+
```
98+
7599
## With Docker
76100

77101
Coming soon.

tests/check_model.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
#!/usr/bin/env python
2+
"""
3+
Check that a model stored in MLflow loads and evaluates correctly,
4+
using the same logic as the dashboard.
5+
6+
Usage:
7+
python check_model.py --config_file <path/to/config.yaml> --model <GP|NN|ensemble_NN>
8+
"""
9+
10+
import argparse
11+
import os
12+
import sys
13+
import torch
14+
import yaml
15+
16+
DASHBOARD_DIR = os.path.join(
17+
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "dashboard"
18+
) # similar to "cd ../dashboard"
19+
sys.path.insert(0, DASHBOARD_DIR)
20+
from model_manager import ModelManager # noqa: E402
21+
from utils import load_database, load_data # noqa: E402
22+
23+
24+
MODEL_TYPES = ["GP", "NN", "ensemble_NN"]
25+
ACCURACY_TOLERANCE = 0.80
26+
27+
28+
def load_experimental_data(config_dict):
29+
"""Fetch all experimental points from the database."""
30+
input_names = [v["name"] for v in config_dict["inputs"].values()]
31+
output_names = [v["name"] for v in config_dict["outputs"].values()]
32+
33+
db = load_database(config_dict)
34+
exp_data, _ = load_data(db, config_dict["experiment"])
35+
36+
return exp_data, input_names, output_names
37+
38+
39+
def check_evaluate(config_dict, model_type):
40+
"""Load model and evaluate with experimental data; verify accuracy (relative RMSE <= threshold)."""
41+
# Load model
42+
mm = ModelManager(config_dict=config_dict, model_type_tag=model_type)
43+
# Load experimental data
44+
df_exp, input_names, output_names = load_experimental_data(config_dict)
45+
46+
# Skip accuracy check if no experimental data available
47+
if len(df_exp) == 0:
48+
print(
49+
f"[SKIP] No experimental data available for {config_dict['experiment']}; skipping accuracy check."
50+
)
51+
return
52+
53+
# Convert input to the format expected by the model manager
54+
inputs = {n: torch.tensor(df_exp[n].values) for n in input_names}
55+
56+
# Check accuracy
57+
all_passed = True
58+
for output_name in output_names:
59+
actual = torch.tensor(df_exp[output_name].values)
60+
if actual.isnan().all():
61+
print(
62+
f" [SKIP] Output '{output_name}': all actual values are NaN; skipping."
63+
)
64+
continue
65+
prediction, _, _ = mm.evaluate(inputs, output_name)
66+
rel_errors = (prediction - actual) / torch.max(
67+
torch.abs(actual), torch.abs(prediction)
68+
)
69+
rmse = torch.sqrt(torch.nanmean(rel_errors**2)).item()
70+
if rmse <= ACCURACY_TOLERANCE:
71+
status = "PASS"
72+
else:
73+
status = "FAIL"
74+
all_passed = False
75+
print(
76+
f" [{status}] Output '{output_name}': relative RMSE = {rmse:.1%} (tolerance {ACCURACY_TOLERANCE:.0%})"
77+
)
78+
79+
if not all_passed:
80+
raise RuntimeError(
81+
f"Accuracy check failed: relative RMSE exceeded {ACCURACY_TOLERANCE:.0%} for one or more outputs."
82+
)
83+
84+
85+
if __name__ == "__main__":
86+
parser = argparse.ArgumentParser(
87+
description="Verify that an MLflow model loads and evaluates correctly."
88+
)
89+
parser.add_argument(
90+
"--config_file",
91+
help="Path to the configuration file",
92+
type=str,
93+
required=True,
94+
)
95+
parser.add_argument(
96+
"--model",
97+
help="Model type: GP, NN, or ensemble_NN",
98+
choices=MODEL_TYPES,
99+
required=True,
100+
)
101+
args = parser.parse_args()
102+
103+
# Load configuration
104+
with open(args.config_file) as f:
105+
config_dict = yaml.safe_load(f)
106+
print(f"Experiment: {config_dict['experiment']}")
107+
108+
# Load model and evaluate with experimental data
109+
try:
110+
check_evaluate(config_dict, args.model)
111+
except Exception as e:
112+
print(f"[FAIL] {e}")
113+
sys.exit(1)
114+
115+
print("[PASS] Model loaded and evaluated successfully.")

0 commit comments

Comments
 (0)