Coverage for backend/ahuora-builder/src/ahuora_builder/ml_wizard.py: 94%
60 statements
« prev ^ index » next coverage.py v7.10.7, created at 2026-07-22 05:22 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2026-07-22 05:22 +0000
1import idaes
2from pydantic import BaseModel
3from ahuora_builder_types.payloads.ml_request_schema import MLTrainRequestPayload, MLTrainingCompletionPayload
4import contextlib
5import json
6from dataclasses import dataclass
7from io import StringIO
8from typing import Any
11import pandas as pd
12import numpy as np
13from sklearn.model_selection import train_test_split
14from sklearn.metrics import mean_squared_error, r2_score
15from idaes.core.surrogate.pysmo_surrogate import PysmoRBFTrainer, PysmoPolyTrainer, PysmoSurrogate
17from ahuora_builder_types.payloads.ml_request_schema import (
18 MLTrainingChartPayload as MLChart,
19 MLTrainingRegressionMetricPayload as RegressionMetric,
20 MLTrainingResultPayload as MLResult,
21)
24MAX_QQ_CHART_POINTS = 250
26@dataclass
27class TrainingOutput:
28 """Intermediate ML training artefacts before object-storage upload."""
30 result_payload: MLResult
31 test_inputs_df: pd.DataFrame
32 test_outputs_df: pd.DataFrame
35def _json_indexed_records(frame: pd.DataFrame) -> dict[str, dict[str, float]]:
36 """Convert a dataframe to a JSON-safe indexed mapping with string row keys."""
37 return {
38 str(index): {
39 str(column): float(value)
40 for column, value in row.items()
41 }
42 for index, row in frame.to_dict(orient="index").items()
43 }
46def train_dataframe(
47 df: pd.DataFrame,
48 *,
49 input_labels: list[str],
50 output_labels: list[str],
51 model_type: str,
52) -> TrainingOutput:
53 """Train a PySMO surrogate model from a dataframe and return upload-ready artefacts."""
54 train_df, test_df = train_test_split(df, test_size=0.2, random_state=42)
56 if model_type == "polynomial_regression": 56 ↛ 57line 56 didn't jump to line 57 because the condition on line 56 was never true
57 trainer = PysmoPolyTrainer(
58 input_labels=input_labels, output_labels=output_labels, training_dataframe=train_df)
59 elif model_type == "rbf_regression": 59 ↛ 65line 59 didn't jump to line 65 because the condition on line 59 was always true
60 trainer = PysmoRBFTrainer(
61 input_labels=input_labels, output_labels=output_labels, training_dataframe=train_df)
62 trainer.config.basis_function = 'gaussian'
64 # Train surrogate (calls PySMO through IDAES Python wrapper)
65 stream = StringIO()
66 with contextlib.redirect_stdout(stream):
67 train = trainer.train_surrogate()
69 # create callable surrogate model
70 surr = PysmoSurrogate(train , input_labels,
71 output_labels, input_bounds=None)
72 f = StringIO()
73 surr.save(f)
74 content = f.getvalue()
75 json_data = json.loads(content)
77 df_evaluate = surr.evaluate_surrogate(test_df)
79 metrics: list[RegressionMetric] = []
80 charts: list[MLChart] = []
82 for output_label in output_labels:
83 charts.append(compute_chart(
84 test_df[output_label], df_evaluate[output_label], output_label))
85 metrics.append(
86 RegressionMetric(
87 mean_squared_error=round(
88 mean_squared_error(df_evaluate[output_label], test_df[output_label]),
89 4,
90 ),
91 r2_score=round(
92 r2_score(df_evaluate[output_label], test_df[output_label]),
93 4,
94 ),
95 )
96 )
98 return TrainingOutput(
99 result_payload=MLResult(
100 surrogate_model=json_data,
101 charts=charts,
102 metrics=metrics,
103 test_results_bucket="",
104 test_results_key="",
105 timing={},
106 ),
107 test_inputs_df=test_df[input_labels].copy(),
108 test_outputs_df=df_evaluate[output_labels].copy(),
109 )
112def compute_chart(
113 test_data_df: pd.Series,
114 eval_data_df: pd.Series,
115 output_label: str,
116) -> MLChart:
117 """Build the chart payload for one output label."""
118 minn = round(np.min([np.min(test_data_df), np.min(eval_data_df)]), 4)
119 maxx = round(np.max([np.max(test_data_df), np.max(eval_data_df)]), 4)
120 qq_plot_data = compute_qq_coordinates(test_data_df, eval_data_df)
122 return MLChart(
123 min=minn,
124 max=maxx,
125 qq_plot_data=qq_plot_data,
126 output_label=output_label,
127 )
130def compute_qq_coordinates(test_data: pd.Series, eval_data: pd.Series) -> str:
131 """Compute QQ plot coordinates for two datasets as a frontend-ready JSON string."""
132 test_values = test_data.to_numpy().flatten()
133 eval_values = eval_data.to_numpy().flatten()
135 # Sort values
136 test_values.sort()
137 eval_values.sort()
139 # Generate QQ plot data points
140 n_points = min(len(test_values), MAX_QQ_CHART_POINTS)
141 quantiles = np.linspace(0, 1, n_points)
142 test_quantiles = np.quantile(test_values, quantiles)
143 eval_quantiles = np.quantile(eval_values, quantiles)
145 # Prepare JSON response for frontend
146 qq_data = [{"x": round(float(t), 4), "y": round(float(e), 4)}
147 for t, e in zip(test_quantiles, eval_quantiles)]
148 return json.dumps(qq_data)