Coverage for ml_workbench/runner.py: 6%

748 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2026-01-06 16:09 +0200

1"""Runner for executing ML experiments.""" 

2 

3# ToDo: Add support for estimator specific transformer: https://chatgpt.com/share/e/6923056a-7a44-8012-a36d-d822b913db60 

4 

5from __future__ import annotations 

6 

7from copy import deepcopy 

8from pathlib import Path 

9from typing import TYPE_CHECKING, Any 

10import tempfile 

11import os 

12 

13import matplotlib 

14 

15# matplotlib.use("Agg") # Use non-interactive backend 

16import matplotlib.pyplot as plt 

17import mlflow 

18import numpy as np 

19import pandas as pd 

20import yaml 

21 

22try: 

23 import seaborn as sns 

24except ImportError: 

25 sns = None 

26from sklearn.compose import ColumnTransformer 

27from sklearn.impute import SimpleImputer 

28from sklearn.metrics import ( 

29 accuracy_score, 

30 confusion_matrix, 

31 f1_score, 

32 mean_absolute_error, 

33 mean_squared_error, 

34 precision_score, 

35 r2_score, 

36 recall_score, 

37) 

38from sklearn.model_selection import ( 

39 GridSearchCV, 

40 GroupShuffleSplit, 

41 RandomizedSearchCV, 

42 train_test_split, 

43) 

44from sklearn.pipeline import Pipeline 

45from sklearn.preprocessing import OneHotEncoder, StandardScaler 

46 

47from .dataset import Dataset 

48from .feature import Feature 

49from .model import Model 

50from .mlflow_conf import MlflowConf 

51 

52if TYPE_CHECKING: 52 ↛ 53line 52 didn't jump to line 53 because the condition on line 52 was never true

53 from .experiment import Experiment 

54 

55 

56class ModelRunner: 

57 """Handles training, evaluation, and MLflow logging for a specific model. 

58 

59 Responsibilities: 

60 - Create model-specific preprocessing pipeline 

61 - Create model-specific pipeline (preprocessor + model) 

62 - Train the model 

63 - Evaluate model performance 

64 - Calculate feature weights/importances 

65 - Log results to MLflow 

66 

67 Parameters 

68 ---------- 

69 model_name : str 

70 Name of the model to run 

71 numerical_features : List[str] 

72 List of numerical feature column names 

73 categorical_features : List[str] 

74 List of categorical feature column names 

75 experiment : Experiment 

76 Experiment specification 

77 X_train : pd.DataFrame 

78 Training features 

79 y_train : pd.Series 

80 Training target 

81 X_holdout : Optional[pd.DataFrame] 

82 Holdout features (optional) 

83 y_holdout : Optional[pd.Series] 

84 Holdout target (optional) 

85 verbose : bool, optional 

86 Whether to print progress information, by default True 

87 """ 

88 

89 def __init__( 

90 self, 

91 model_name: str, 

92 numerical_features: list[str], 

93 categorical_features: list[str], 

94 experiment: Experiment, 

95 X_train: pd.DataFrame, # noqa: N803 

96 y_train: pd.Series, 

97 X_holdout: pd.DataFrame | None = None, # noqa: N803 

98 y_holdout: pd.Series | None = None, 

99 verbose: bool = True, 

100 ): 

101 self.model_name = model_name 

102 self.numerical_features = numerical_features 

103 self.categorical_features = categorical_features 

104 self.experiment = experiment 

105 self.config = experiment.config 

106 self.X_train = X_train 

107 self.y_train = y_train 

108 self.X_holdout = X_holdout 

109 self.y_holdout = y_holdout 

110 self.verbose = verbose 

111 

112 # Pipeline and model storage 

113 self.full_pipeline: Pipeline | None = ( 

114 None # full pipeline with preprocessor and model 

115 ) 

116 self.best_pipeline: Pipeline | None = ( 

117 None # best pipeline after cross-validation 

118 ) 

119 self.is_cross_validation: bool = False 

120 # cross validation results on train dataset (used to select the best model) 

121 self.cv_results_: dict[str, Any] | None = None 

122 self.cv_best_params_: dict[str, Any] | None = None 

123 self.cv_best_score_: float | None = None 

124 

125 # metrics and selection score on holdout and train datasets (used to select the best model) 

126 self.metrics: dict[str, float] | None = None 

127 self.selection_score: float | None = ( 

128 None # the score used to select the best model if multiple metrics are specified 

129 ) 

130 

131 def _log(self, message: str) -> None: 

132 """Print message if verbose is enabled.""" 

133 if self.verbose: 

134 print(f"[ModelRunner:{self.model_name}] {message}") # noqa: T201 

135 

136 def _build_preprocessing_pipeline(self) -> ColumnTransformer: 

137 """Build preprocessing pipeline for features. 

138 

139 Creates separate pipelines for numerical and categorical features: 

140 - Numerical: integer-to-float conversion + imputation + standardization 

141 - Categorical: imputation + one-hot encoding 

142 

143 The integer-to-float conversion ensures MLflow schema compatibility by 

144 converting integer columns to float64, preventing schema enforcement 

145 errors when missing values are present at inference time. 

146 

147 Returns 

148 ------- 

149 ColumnTransformer 

150 Preprocessing pipeline 

151 """ 

152 self._log("Building preprocessing pipeline") 

153 

154 # Build transformers 

155 transformers = [] 

156 

157 if self.numerical_features: 

158 numerical_transformer = Pipeline( 

159 steps=[ 

160 ("imputer", SimpleImputer(strategy="median")), 

161 ("scaler", StandardScaler()), 

162 ] 

163 ) 

164 transformers.append(("num", numerical_transformer, self.numerical_features)) 

165 

166 if self.categorical_features: 

167 categorical_transformer = Pipeline( 

168 steps=[ 

169 ( 

170 "imputer", 

171 SimpleImputer(strategy="constant", fill_value="missing"), 

172 ), 

173 ( 

174 "onehot", 

175 OneHotEncoder(handle_unknown="ignore", sparse_output=False), 

176 ), 

177 ] 

178 ) 

179 transformers.append(( 

180 "cat", 

181 categorical_transformer, 

182 self.categorical_features, 

183 )) 

184 

185 if not transformers: 

186 raise ValueError("No features to process") # noqa: TRY003 

187 

188 return ColumnTransformer(transformers=transformers, remainder="drop") 

189 

190 def _create_pipeline(self) -> Pipeline: 

191 """Create full pipeline with preprocessor and model. 

192 

193 Returns 

194 ------- 

195 Pipeline 

196 Full pipeline with preprocessor and model steps 

197 """ 

198 # Build preprocessing pipeline if not already built 

199 preprocessor = self._build_preprocessing_pipeline() 

200 

201 # Load model configuration 

202 model_obj = Model(self.model_name, self.config) 

203 model_instance = model_obj.instantiate() 

204 

205 # Create full pipeline 

206 pipeline = Pipeline( 

207 steps=[("preprocessor", preprocessor), ("model", model_instance)] 

208 ) 

209 

210 # self.model = model_instance 

211 self.full_pipeline = pipeline 

212 

213 return pipeline 

214 

215 def _calculate_metrics( 

216 self, 

217 y_true: pd.Series, 

218 y_pred: np.ndarray, 

219 metrics_list: list[str] | None = None, 

220 ) -> dict[str, float]: 

221 """Calculate evaluation metrics. 

222 

223 Parameters 

224 ---------- 

225 y_true : pd.Series 

226 True target values 

227 y_pred : np.ndarray 

228 Predicted values 

229 metrics_list : Optional[List[str]] 

230 List of metric names to calculate. If None, uses experiment metrics. The first metrics will be used to select the best model if multiple specified. 

231 

232 Returns 

233 ------- 

234 Dict[str, float] 

235 Dictionary of metric names to values 

236 """ 

237 if metrics_list is None: 

238 metrics_list = self.experiment.spec.metrics or ["r2", "mse"] 

239 

240 results = {} 

241 

242 for idx, metric_name in enumerate(metrics_list): 

243 metric_name_lower = metric_name.lower() 

244 metric_value = None 

245 direction_flag = 1 

246 

247 try: 

248 if metric_name_lower.startswith("r2"): 

249 metric_value = r2_score(y_true, y_pred) 

250 results["r2_score"] = metric_value 

251 direction_flag = 1 # higher is better 

252 elif metric_name_lower in ["mse", "mean_squared_error"]: 

253 metric_value = mean_squared_error(y_true, y_pred) 

254 results["mean_squared_error"] = metric_value 

255 direction_flag = -1 # lower is better 

256 elif metric_name_lower in ["rmse", "root_mean_squared_error"]: 

257 metric_value = np.sqrt(mean_squared_error(y_true, y_pred)) 

258 results["root_mean_squared_error"] = metric_value 

259 direction_flag = -1 # lower is better 

260 elif metric_name_lower in ["mae", "mean_absolute_error"]: 

261 metric_value = mean_absolute_error(y_true, y_pred) 

262 results["mean_absolute_error"] = metric_value 

263 direction_flag = -1 # lower is better 

264 elif metric_name_lower.startswith("accuracy"): 

265 metric_value = accuracy_score(y_true, y_pred) 

266 results["accuracy_score"] = metric_value 

267 direction_flag = 1 # higher is better 

268 elif metric_name_lower.startswith("precision"): 

269 metric_value = precision_score(y_true, y_pred, average="weighted") 

270 results["precision_score"] = metric_value 

271 direction_flag = 1 # higher is better 

272 elif metric_name_lower.startswith("recall"): 

273 metric_value = recall_score(y_true, y_pred, average="weighted") 

274 results["recall_score"] = metric_value 

275 direction_flag = 1 # higher is better 

276 elif metric_name_lower.startswith("f1"): 

277 metric_value = f1_score(y_true, y_pred, average="weighted") 

278 results["f1_score"] = metric_value 

279 direction_flag = 1 # higher is better 

280 else: 

281 self._log(f"Warning: Unknown metric '{metric_name}'") 

282 

283 # Always set selection_score from the *first* metric in the list 

284 if idx == 0 and metric_value is not None: 

285 results["selection_score"] = direction_flag * metric_value 

286 

287 except Exception as e: 

288 self._log(f"Error calculating metric '{metric_name}': {e}") 

289 

290 return results 

291 

292 def calculate_feature_weights(self) -> pd.DataFrame: 

293 """Calculate feature importances or coefficients. 

294 

295 Returns 

296 ------- 

297 pd.DataFrame 

298 DataFrame with feature names and their weights/importances 

299 """ 

300 # Get the fitted pipeline from the search 

301 best_pipeline = self.best_pipeline 

302 

303 # Find the column transformer and ridge model in the pipeline 

304 # Assumes the pipeline steps are: ('preprocessor', ...), ('ridge', ...) 

305 preprocessor = best_pipeline.named_steps["preprocessor"] 

306 model = best_pipeline.named_steps["model"] 

307 

308 # Get feature names from preprocessor 

309 feature_names = preprocessor.get_feature_names_out() 

310 # Drop prefix before '__' in feature names, if present 

311 feature_names = [ 

312 name.split("__", 1)[-1] if "__" in name else name for name in feature_names 

313 ] 

314 

315 # Extract weights/importances from model 

316 weights = None 

317 weight_type = None 

318 

319 if hasattr(model, "coef_"): 

320 # Linear models (coefficients) 

321 weights = model.coef_ 

322 weight_type = "coefficient" 

323 elif hasattr(model, "feature_importances_"): 

324 # Tree-based models (feature importances) 

325 weights = model.feature_importances_ 

326 weight_type = "importance" 

327 else: 

328 self._log( 

329 "Warning: Model does not have coefficients or feature importances" 

330 ) 

331 return pd.DataFrame() 

332 

333 # Handle multi-dimensional coefficients (e.g., multi-class classification) 

334 if len(weights.shape) > 1: 

335 weights = np.abs(weights).mean(axis=0) 

336 

337 # Create DataFrame 

338 df = pd.DataFrame({ 

339 "feature": feature_names[: len(weights)], 

340 "weight": weights, 

341 "type": weight_type, 

342 }) 

343 df = df.sort_values(by="weight", ascending=False, key=abs) 

344 

345 # self._log("Top 5 features by weight:") 

346 # for _idx, row in df.head(5).iterrows(): 

347 # self._log(f" {row['feature']}: {row['weight']:.6f}") 

348 

349 return df 

350 

351 def plot_feature_weights(self) -> matplotlib.figure.Figure | None: 

352 """ 

353 Plot feature importances or coefficients from the best estimator (tuned search). 

354 Reuses the calculate_feature_weights method. 

355 Returns a matplotlib Figure object for notebook display or file export. 

356 

357 Returns 

358 ------- 

359 matplotlib.figure.Figure or None 

360 The figure, or None if no feature weights are available. 

361 """ 

362 

363 try: 

364 # Reuse calculate_feature_weights to get DataFrame 

365 weights_df = self.calculate_feature_weights() 

366 except Exception as e: 

367 self._log(f"Error calculating feature weights: {e}") 

368 return None 

369 

370 if ( 

371 weights_df is None 

372 or weights_df.empty 

373 or "feature" not in weights_df 

374 or "weight" not in weights_df 

375 ): 

376 self._log("No feature weights available to plot.") 

377 return None 

378 

379 # Sort features by absolute weight/importance (descending) 

380 weights_df_sorted = weights_df.reindex( 

381 weights_df.weight.abs().sort_values(ascending=False).index 

382 ) 

383 

384 # Plot 

385 fig, ax = plt.subplots( 

386 figsize=(10, max(4, min(0.5 * len(weights_df_sorted), 16))) 

387 ) 

388 ax.barh( 

389 weights_df_sorted["feature"], 

390 weights_df_sorted["weight"], 

391 color="tab:blue", 

392 alpha=0.85, 

393 ) 

394 

395 ylabel = ( 

396 "Coefficient" 

397 if "coefficient" in weights_df_sorted.columns.to_numpy().tolist() 

398 or "coefficient" in weights_df_sorted.get("type", "") 

399 else "Importance" 

400 ) 

401 ax.set_xlabel(ylabel) 

402 ax.set_title(f"Feature {ylabel}s for Best Estimator ({self.model_name})") 

403 ax.invert_yaxis() 

404 ax.grid(axis="x", linestyle="--", alpha=0.5) 

405 fig.tight_layout() 

406 

407 return fig 

408 

409 def plot_cv_mean_score(self, figsize=(10, 10)) -> matplotlib.figure.Figure | None: 

410 """ 

411 Plot mean test score across CV splits with confidence intervals (std error). 

412 Returns 

413 ------- 

414 matplotlib.figure.Figure or None 

415 The figure, or None if no CV results are available. 

416 """ 

417 

418 if not self.cv_results_: 

419 self._log("No cross-validation results found to plot.") 

420 return None 

421 

422 cv_results = self.cv_results_ 

423 if "mean_test_score" not in cv_results or "std_test_score" not in cv_results: 

424 self._log("CV results are missing 'mean_test_score' or 'std_test_score'.") 

425 return None 

426 

427 mean_scores = np.array(cv_results["mean_test_score"]) 

428 std_scores = np.array(cv_results["std_test_score"]) 

429 x_labels = [f"{v}" for v in cv_results["params"]] 

430 

431 # Plot mean test score with confidence intervals 

432 fig, ax = plt.subplots(figsize=figsize) 

433 ax.tick_params(axis="x", rotation=90) # Make x labels vertical 

434 ax.errorbar( 

435 x_labels, 

436 mean_scores, 

437 yerr=std_scores, 

438 fmt="o", 

439 capsize=4, 

440 label="Mean Test Score ±1 Std", 

441 ) 

442 ax.set_xlabel("Hyperparameter Combination Index") 

443 ax.set_ylabel("Mean Test Score") 

444 ax.set_title(f"Hyperparameter Search Mean Test Score ±1 Std ({self.model_name})") 

445 ax.legend() 

446 ax.grid(True) 

447 fig.tight_layout() 

448 

449 return fig 

450 

451 def plot_confusion_matrix( 

452 self, 

453 y_true: pd.Series | np.ndarray, 

454 y_pred: pd.Series | np.ndarray, 

455 title_prefix: str | None = None, 

456 ) -> matplotlib.figure.Figure | None: 

457 """Create confusion matrix plot for classification. 

458 

459 Parameters 

460 ---------- 

461 y_true : pd.Series | np.ndarray 

462 True target values 

463 y_pred : pd.Series | np.ndarray 

464 Predicted values 

465 title_prefix : str, optional 

466 Prefix to add to plot title (e.g., 'holdout data', 'test data', etc.) 

467 If None, no prefix is added. 

468 

469 Returns 

470 ------- 

471 matplotlib.figure.Figure | None 

472 Matplotlib figure object with confusion matrix plot, or None if error occurs 

473 """ 

474 try: 

475 # Convert to numpy arrays if needed 

476 y_true = y_true.to_numpy() if isinstance(y_true, pd.Series) else y_true 

477 y_pred = y_pred.to_numpy() if isinstance(y_pred, pd.Series) else y_pred 

478 

479 # Get unique class labels 

480 classes = sorted(set(np.unique(y_true)) | set(np.unique(y_pred))) 

481 

482 # Calculate confusion matrix with explicit labels 

483 cm = confusion_matrix(y_true, y_pred, labels=classes) 

484 

485 # Create plot 

486 fig, ax = plt.subplots(figsize=(8, 6)) 

487 

488 # Use seaborn if available for better visualization 

489 if sns is not None: 

490 sns.heatmap( 

491 cm, 

492 annot=True, 

493 fmt="d", 

494 cmap="Blues", 

495 ax=ax, 

496 cbar_kws={"label": "Count"}, 

497 xticklabels=classes, 

498 yticklabels=classes, 

499 ) 

500 else: 

501 # Fallback to matplotlib if seaborn not available 

502 im = ax.imshow(cm, interpolation="nearest", cmap="Blues") 

503 ax.figure.colorbar(im, ax=ax) 

504 

505 # Add text annotations 

506 thresh = cm.max() / 2.0 

507 for i in range(cm.shape[0]): 

508 for j in range(cm.shape[1]): 

509 ax.text( 

510 j, 

511 i, 

512 format(cm[i, j], "d"), 

513 ha="center", 

514 va="center", 

515 color="white" if cm[i, j] > thresh else "black", 

516 ) 

517 

518 # Build title suffix 

519 title_suffix = f" ({title_prefix})" if title_prefix else "" 

520 

521 ax.set( 

522 xlabel="Predicted Label", 

523 ylabel="True Label", 

524 title=f"Confusion Matrix{title_suffix}", 

525 ) 

526 ax.set_xticks(np.arange(len(classes))) 

527 ax.set_yticks(np.arange(len(classes))) 

528 ax.set_xticklabels(classes) 

529 ax.set_yticklabels(classes) 

530 

531 fig.tight_layout() 

532 

533 except Exception as e: 

534 self._log(f"Error creating confusion matrix plot: {e}") 

535 return None 

536 else: 

537 return fig 

538 

539 def plot_regression( 

540 self, 

541 y_true: pd.Series | np.ndarray, 

542 y_pred: pd.Series | np.ndarray, 

543 title_prefix: str | None = None, 

544 ) -> matplotlib.figure.Figure | None: 

545 """Create actual vs predicted and residuals vs predicted plots for regression. 

546 

547 Parameters 

548 ---------- 

549 y_true : pd.Series | np.ndarray 

550 True target values 

551 y_pred : pd.Series | np.ndarray 

552 Predicted values 

553 title_prefix : str, optional 

554 Prefix to add to plot titles (e.g., 'holdout data', 'test data', etc.) 

555 If None, no prefix is added. 

556 

557 Returns 

558 ------- 

559 matplotlib.figure.Figure | None 

560 Matplotlib figure object with two subplots, or None if error occurs 

561 """ 

562 try: 

563 y_true = y_true.to_numpy() if isinstance(y_true, pd.Series) else y_true 

564 y_pred = y_pred.to_numpy() if isinstance(y_pred, pd.Series) else y_pred 

565 # Calculate residuals 

566 residuals = y_true - y_pred 

567 

568 # Create figure with two subplots 

569 fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6)) 

570 

571 # Build title suffix 

572 title_suffix = f" ({title_prefix})" if title_prefix else "" 

573 

574 # Plot 1: Actual vs Predicted 

575 ax1.scatter(y_true, y_pred, alpha=0.6, s=20) 

576 

577 # Add diagonal line (perfect prediction) 

578 min_val = min(y_true.min(), y_pred.min()) 

579 max_val = max(y_true.max(), y_pred.max()) 

580 ax1.plot( 

581 [min_val, max_val], 

582 [min_val, max_val], 

583 "r--", 

584 lw=2, 

585 label="Perfect prediction", 

586 ) 

587 

588 ax1.set_xlabel("Actual Values") 

589 ax1.set_ylabel("Predicted Values") 

590 ax1.set_title(f"Actual vs Predicted{title_suffix}") 

591 ax1.legend() 

592 ax1.grid(True, alpha=0.3) 

593 

594 # Plot 2: Residuals vs Predicted 

595 ax2.scatter(y_pred, residuals, alpha=0.6, s=20) 

596 

597 # Add horizontal line at y=0 

598 ax2.axhline(y=0, color="r", linestyle="--", lw=2, label="Zero residual") 

599 

600 ax2.set_xlabel("Predicted Values") 

601 ax2.set_ylabel("Residuals") 

602 ax2.set_title(f"Residuals vs Predicted{title_suffix}") 

603 ax2.legend() 

604 ax2.grid(True, alpha=0.3) 

605 

606 fig.tight_layout() 

607 

608 except Exception as e: 

609 self._log(f"Error creating regression plots: {e}") 

610 return None 

611 else: 

612 return fig 

613 

614 def plot_distribution( 

615 self, 

616 y_true: pd.Series | np.ndarray, 

617 y_pred: pd.Series | np.ndarray, 

618 title_prefix: str | None = None, 

619 ) -> matplotlib.figure.Figure | None: 

620 """ 

621 Plot the distribution (KDE) of actual and predicted values. 

622 

623 Parameters 

624 ---------- 

625 y_true : pd.Series | np.ndarray 

626 True target values 

627 y_pred : pd.Series | np.ndarray 

628 Predicted values 

629 title_prefix : str or None 

630 Optional prefix for the plot title. 

631 

632 Returns 

633 ------- 

634 matplotlib.figure.Figure or None 

635 The figure, or None if an error occurs. 

636 """ 

637 try: 

638 y_true = y_true.to_numpy() if isinstance(y_true, pd.Series) else y_true 

639 y_pred = y_pred.to_numpy() if isinstance(y_pred, pd.Series) else y_pred 

640 

641 fig, ax = plt.subplots(figsize=(8, 6)) 

642 title_suffix = f" ({title_prefix})" if title_prefix else "" 

643 

644 # Plot the KDE for true values 

645 pd.Series(y_true).plot(kind="kde", ax=ax, label="Actual", color="tab:blue") 

646 # Plot the KDE for predicted values 

647 pd.Series(y_pred).plot( 

648 kind="kde", ax=ax, label="Predicted", color="tab:orange" 

649 ) 

650 

651 ax.set_xlabel("Value") 

652 ax.set_ylabel("Density") 

653 ax.set_title( 

654 f"Distribution (KDE) of Actual and Predicted Values{title_suffix}" 

655 ) 

656 ax.legend() 

657 ax.grid(True, alpha=0.3) 

658 fig.tight_layout() 

659 

660 except Exception as e: 

661 self._log(f"Error plotting distributions: {e}") 

662 return None 

663 else: 

664 return fig 

665 

666 def fit_and_evaluate(self) -> float: 

667 """Train the model and evaluate it. 

668 

669 This method encapsulates the full model lifecycle: 

670 - Creates the pipeline 

671 - If tuning is configured, performs cross-validation hyperparameter search 

672 - Fits the model (or best tuned model) on training data 

673 - Evaluates on holdout sets 

674 - Calculates feature weights 

675 

676 Returns 

677 ------- 

678 float 

679 R2 score of the model - used to select the best model 

680 """ 

681 self._log("Starting model training and evaluation") 

682 

683 # Load model configuration to check for tuning 

684 model_obj = Model(self.model_name, self.config) 

685 tuning_config = model_obj.spec.tuning 

686 

687 # Create initial pipeline (integer-to-float conversion is handled in the pipeline) 

688 self._create_pipeline() 

689 

690 # Check if tuning is configured (non-empty dict) 

691 if tuning_config and len(tuning_config) > 0: 

692 self._log("Tuning configuration detected - performing cross-validation") 

693 self.is_cross_validation = True 

694 

695 # Extract tuning parameters 

696 method = tuning_config.get("method", "grid_search") 

697 inner_cv = tuning_config.get("inner_cv", 5) 

698 scoring = tuning_config.get("scoring", "neg_mean_squared_error") 

699 param_grid = tuning_config.get("param_grid", {}) 

700 

701 # Build parameter grid with proper prefix for pipeline 

702 # Parameters need to be prefixed with "model__" since model is a step in the pipeline 

703 pipeline_param_grid = {} 

704 

705 # Handle logspace if present as standalone key (overwrites alpha) 

706 if "logspace" in param_grid: 

707 logspace_config = param_grid["logspace"] 

708 start = logspace_config.get("start", -2) 

709 stop = logspace_config.get("stop", 2) 

710 num = logspace_config.get("num", 50) 

711 logspace_values = np.logspace(start, stop, num).tolist() 

712 # Apply logspace to alpha (most common use case) 

713 pipeline_param_grid["model__alpha"] = logspace_values 

714 self._log( 

715 f"Using logspace for alpha: {num} values from 10^{start} to 10^{stop}" 

716 ) 

717 

718 # Process regular parameters 

719 for param_name, param_values in param_grid.items(): 

720 if ( 

721 param_name == "logspace" 

722 ): # Skip standalone logspace key (already handled) 

723 continue 

724 

725 # Check if this parameter has nested logspace structure 

726 if isinstance(param_values, dict) and "logspace" in param_values: 

727 logspace_config = param_values["logspace"] 

728 start = logspace_config.get("start", -2) 

729 stop = logspace_config.get("stop", 2) 

730 num = logspace_config.get("num", 50) 

731 logspace_values = np.logspace(start, stop, num).tolist() 

732 pipeline_param_grid[f"model__{param_name}"] = logspace_values 

733 self._log( 

734 f"Using logspace for {param_name}: {num} values from 10^{start} to 10^{stop}" 

735 ) 

736 elif isinstance(param_values, list): 

737 # Regular list of values 

738 pipeline_param_grid[f"model__{param_name}"] = param_values 

739 

740 # Choose search method 

741 if method == "random_search": 

742 n_iter = tuning_config.get("n_iter", 10) 

743 search = RandomizedSearchCV( 

744 self.full_pipeline, 

745 pipeline_param_grid, 

746 cv=inner_cv, 

747 scoring=scoring, 

748 n_iter=n_iter, 

749 verbose=1 if self.verbose else 0, 

750 n_jobs=-1, 

751 ) 

752 else: # Default to grid_search 

753 search = GridSearchCV( 

754 self.full_pipeline, 

755 pipeline_param_grid, 

756 cv=inner_cv, 

757 scoring=scoring, 

758 verbose=1 if self.verbose else 0, 

759 n_jobs=-1, 

760 ) 

761 

762 # Perform cross-validation search 

763 self._log(f"Performing {method} with {inner_cv}-fold CV") 

764 search.fit(self.X_train, self.y_train) 

765 

766 # Store CV results for future analysis 

767 self.cv_results_ = search.cv_results_ 

768 self.cv_best_params_ = search.best_params_ 

769 self.cv_best_score_ = search.best_score_ 

770 

771 # Set best_pipeline as the best tuned pipeline 

772 self.best_pipeline = search.best_estimator_ 

773 self._log(f"Best parameters: {search.best_params_}") 

774 self._log(f"Best CV score: {search.best_score_:.6f}") 

775 

776 # Fit best_pipeline on full training data 

777 self._log("Fitting best pipeline on full training data") 

778 self.best_pipeline.fit(self.X_train, self.y_train) 

779 

780 else: 

781 # No tuning - standard fit 

782 self.best_pipeline = self.full_pipeline 

783 self._log("Training model") 

784 self.best_pipeline.fit(self.X_train, self.y_train) 

785 

786 self._log("Model training completed") 

787 

788 # start evaluation part of the model (if holdout exists) 

789 metrics = {} 

790 selection_score = None 

791 # evaluate on train set 

792 if self.X_train is not None and self.y_train is not None: 

793 self._log("Evaluating model on test set") 

794 y_pred_train = self.best_pipeline.predict(self.X_train) 

795 train_metrics = self._calculate_metrics( 

796 self.y_train, y_pred_train, self.experiment.spec.metrics 

797 ) 

798 for metric, value in train_metrics.items(): 

799 self._log(f" train_{metric}: {value:.6f}") 

800 if "selection_score" in train_metrics: 

801 selection_score = train_metrics["selection_score"] 

802 self._log(f"Train Selection score: {selection_score:.6f}") 

803 train_metrics = {f"train_{k}": v for k, v in train_metrics.items()} 

804 metrics.update(train_metrics) 

805 # evaluate on holdout set 

806 if self.X_holdout is not None and self.y_holdout is not None: 

807 self._log("Evaluating model on holdout set") 

808 y_pred_holdout = self.best_pipeline.predict(self.X_holdout) 

809 holdout_metrics = self._calculate_metrics( 

810 self.y_holdout, y_pred_holdout, self.experiment.spec.metrics 

811 ) 

812 for metric, value in holdout_metrics.items(): 

813 self._log(f" holdout_{metric}: {value:.6f}") 

814 if "selection_score" in holdout_metrics: 

815 selection_score = holdout_metrics["selection_score"] 

816 self._log(f"Holdout Selection score: {selection_score:.6f}") 

817 holdout_metrics = {f"holdout_{k}": v for k, v in holdout_metrics.items()} 

818 metrics.update(holdout_metrics) 

819 

820 self.metrics = metrics 

821 self.selection_score = selection_score 

822 return self.selection_score 

823 

824 def predict(self, X): # noqa: N803 

825 """ 

826 Make predictions using the best trained pipeline. 

827 

828 Parameters 

829 ---------- 

830 X : pd.DataFrame or np.ndarray 

831 Input features for prediction. 

832 

833 Returns 

834 ------- 

835 np.ndarray 

836 Predicted outputs. 

837 """ 

838 if not hasattr(self, "best_pipeline") or self.best_pipeline is None: 

839 raise RuntimeError("The model pipeline is not trained yet.") # noqa: TRY003 

840 return self.best_pipeline.predict(X) 

841 

842 def mlflow_store(self) -> None: 

843 """ 

844 Store the model in MLflow. 

845 Assuming to run with active run 

846 """ 

847 mlflow.log_param("model_name", self.model_name) 

848 mlflow.log_param("model_pipeline", str(self.best_pipeline)) 

849 

850 # Get the parameters from the 'model' step of the pipeline and log each as a parameter 

851 model_params = self.best_pipeline.named_steps['model'].get_params() 

852 for param_name, param_value in model_params.items(): 

853 mlflow.log_param(f"model__{param_name}", param_value) 

854 

855 # log metrics 

856 for k, v in self.metrics.items(): 

857 mlflow.log_metric(k, v) 

858 

859 # log model with a signature 

860 # Get a DataFrame containing the first 10 rows of X_train 

861 sample_df = self.X_train.head(10).copy() 

862 signature = mlflow.models.infer_signature(sample_df, self.best_pipeline.predict(sample_df)) 

863 mlflow.sklearn.log_model(self.best_pipeline, name="model", signature=signature) 

864 

865 # create temp directory to create artifacts and load them into MlFlow 

866 with tempfile.TemporaryDirectory() as temp_dir: 

867 # log cross validation results 

868 if self.is_cross_validation and self.cv_results_ is not None: 

869 mlflow.log_param("cross_validation",True) 

870 fig = self.plot_cv_mean_score() 

871 if fig: 

872 fig.savefig(os.path.join(temp_dir, "cv_mean_score.png")) 

873 mlflow.log_artifact(os.path.join(temp_dir, "cv_mean_score.png")) 

874 else: 

875 mlflow.log_param("cross_validation",False) 

876 

877 # log plot_feature_weights 

878 weights_df = self.calculate_feature_weights() 

879 if weights_df is not None and not weights_df.empty: 

880 weights_df.to_csv(os.path.join(temp_dir, "feature_weights.csv"), index=False) 

881 mlflow.log_artifact(os.path.join(temp_dir, "feature_weights.csv")) 

882 fig = self.plot_feature_weights() 

883 if fig: 

884 fig.savefig(os.path.join(temp_dir, "feature_weights.png")) 

885 mlflow.log_artifact(os.path.join(temp_dir, "feature_weights.png")) 

886 

887 # Check if it is regression or classification experiment 

888 # INSERT_YOUR_CODE 

889 # Precompute predictions for train and holdout to avoid redundant calls 

890 y_pred_train = self.predict(self.X_train) 

891 y_pred_holdout = self.predict(self.X_holdout) if self.X_holdout is not None else None 

892 

893 task_type = self.experiment.get_type() 

894 if task_type == "classification": 

895 # log plot_confusion_matrix 

896 fig = self.plot_confusion_matrix(self.y_train, y_pred_train, "train") 

897 if fig: 

898 fig.savefig(os.path.join(temp_dir, "train_confusion_matrix.png")) 

899 mlflow.log_artifact(os.path.join(temp_dir, "train_confusion_matrix.png")) 

900 # log plot_confusion_matrix 

901 if y_pred_holdout is not None: 

902 fig = self.plot_confusion_matrix(self.y_holdout, y_pred_holdout, "holdout") 

903 if fig: 

904 fig.savefig(os.path.join(temp_dir, "holdout_confusion_matrix.png")) 

905 mlflow.log_artifact(os.path.join(temp_dir, "holdout_confusion_matrix.png")) 

906 else: 

907 # log plot_regression 

908 fig = self.plot_regression(self.y_train, y_pred_train, "train") 

909 if fig: 

910 fig.savefig(os.path.join(temp_dir, "train_regression.png")) 

911 mlflow.log_artifact(os.path.join(temp_dir, "train_regression.png")) 

912 

913 # log plot_distribution 

914 fig = self.plot_distribution(self.y_train, y_pred_train, "train") 

915 if fig: 

916 fig.savefig(os.path.join(temp_dir, "train_distribution.png")) 

917 mlflow.log_artifact(os.path.join(temp_dir, "train_distribution.png")) 

918 

919 if y_pred_holdout is not None: 

920 # log plot_regression 

921 fig = self.plot_regression(self.y_holdout, y_pred_holdout, "holdout") 

922 if fig: 

923 fig.savefig(os.path.join(temp_dir, "holdout_regression.png")) 

924 mlflow.log_artifact(os.path.join(temp_dir, "holdout_regression.png")) 

925 

926 # log plot_distribution 

927 fig = self.plot_distribution(self.y_holdout, y_pred_holdout, "holdout") 

928 if fig: 

929 fig.savefig(os.path.join(temp_dir, "holdout_distribution.png")) 

930 mlflow.log_artifact(os.path.join(temp_dir, "holdout_distribution.png")) 

931 

932 plt.close('all') 

933 

934class Runner: 

935 """Execute ML experiments with full lifecycle management. 

936 

937 Responsibilities: 

938 - Load datasets 

939 - Build preprocessing pipelines 

940 - Handle train/validation/test splits 

941 - Coordinate ModelRunner instances for each model 

942 

943 Parameters 

944 ---------- 

945 experiment : Experiment 

946 Experiment specification to run 

947 verbose : bool, optional 

948 Whether to print progress information, by default True 

949 """ 

950 

951 def __init__(self, experiment: Experiment, verbose: bool = True): 

952 self.experiment = experiment 

953 self.verbose = verbose 

954 self.config = experiment.config 

955 

956 # Data storage 

957 self.dataset: pd.DataFrame | None = None 

958 self.X_train: pd.DataFrame | None = None 

959 self.y_train: pd.Series | None = None 

960 self.X_holdout: pd.DataFrame | None = None 

961 self.y_holdout: pd.Series | None = None 

962 

963 # Feature tracking 

964 self.numerical_features: list[str] = [] 

965 self.categorical_features: list[str] = [] 

966 self.all_features: list[str] = [] 

967 

968 # ModelRunner instances 

969 self.model_runners: list[ModelRunner] = [] 

970 

971 self.best_model: ModelRunner | None = None 

972 self.best_model_score: str | None = None 

973 

974 def get_models(self) -> list[ModelRunner]: 

975 return self.model_runners 

976 

977 def get_best_model(self) -> ModelRunner | None: 

978 return self.best_model 

979 

980 def get_best_model_score(self) -> float: 

981 return self.best_model_score 

982 

983 def _log(self, message: str) -> None: 

984 """Print message if verbose is enabled.""" 

985 if self.verbose: 

986 print(f"[Runner] {message}") # noqa: T201 

987 

988 def _load_dataset(self) -> pd.DataFrame: 

989 """Load dataset specified in experiment. 

990 

991 Also infers experiment type from target column if not specified in configuration. 

992 

993 Returns 

994 ------- 

995 pd.DataFrame 

996 Loaded dataset 

997 """ 

998 self._log(f"Loading dataset: {self.experiment.spec.dataset}") 

999 dataset = Dataset(self.experiment.spec.dataset, self.config) 

1000 self.dataset = dataset.read_pandas() 

1001 self._log( 

1002 f"Dataset loaded: {len(self.dataset)} rows, {len(self.dataset.columns)} columns" 

1003 ) 

1004 

1005 # Infer experiment type from target if not specified 

1006 if self.experiment.spec.type is None: 

1007 inferred_type = self.experiment.infer_type_from_dataset(self.dataset) 

1008 self._log( 

1009 f"Inferred experiment type: {inferred_type} (from target column dtype)" 

1010 ) 

1011 else: 

1012 self._log(f"Using configured experiment type: {self.experiment.spec.type}") 

1013 

1014 return self.dataset 

1015 

1016 def _infer_column_types(self, df: pd.DataFrame) -> tuple[list[str], list[str]]: 

1017 """Infer which columns are numerical vs categorical based on dtypes. 

1018 

1019 Parameters 

1020 ---------- 

1021 df : pd.DataFrame 

1022 DataFrame to infer from 

1023 columns : List[str] 

1024 Columns to classify 

1025 

1026 Returns 

1027 ------- 

1028 Tuple[List[str], List[str]] 

1029 Lists of (numerical_features, categorical_features) 

1030 """ 

1031 # Fill numerical and categorical list from spec (if available) 

1032 feature_name = getattr(self.experiment.spec, "features", None) 

1033 if feature_name and hasattr(self, "config"): 

1034 try: 

1035 feature = Feature(feature_name, self.config) 

1036 # Add columns from 'numerical' and 'categorical' in spec if present 

1037 numerical = ( 

1038 list(feature.spec.numerical) 

1039 if hasattr(feature.spec, "numerical") and feature.spec.numerical 

1040 else [] 

1041 ) 

1042 categorical = ( 

1043 list(feature.spec.categorical) 

1044 if hasattr(feature.spec, "categorical") and feature.spec.categorical 

1045 else [] 

1046 ) 

1047 column = ( 

1048 list(feature.spec.column) 

1049 if hasattr(feature.spec, "column") and feature.spec.column 

1050 else [] 

1051 ) 

1052 except Exception: 

1053 # Fall back to type inference if Feature cannot be constructed 

1054 numerical = [] 

1055 categorical = [] 

1056 column = [] 

1057 else: 

1058 numerical = [] 

1059 categorical = [] 

1060 column = [] 

1061 

1062 # handle a special keywork __all__ in the column list 

1063 if "__all__" in column: 

1064 column = [ 

1065 col 

1066 for col in df.columns 

1067 if col not in numerical and col not in categorical 

1068 ] 

1069 

1070 # just to be in safe side, remove "target" column" 

1071 target = self.experiment.spec.target 

1072 column = [col for col in column if col != target] 

1073 numerical = [col for col in numerical if col not in column] 

1074 categorical = [col for col in categorical if col not in column] 

1075 

1076 # Check that all specified numerical and categorical columns exist in df 

1077 missing_numerical = [col for col in numerical if col not in df.columns] 

1078 missing_categorical = [col for col in categorical if col not in df.columns] 

1079 if missing_numerical: 

1080 self._log( 

1081 f"Warning: The following numerical feature(s) are not in the dataset: {missing_numerical}" 

1082 ) 

1083 numerical = [col for col in numerical if col in df.columns] 

1084 if missing_categorical: 

1085 self._log( 

1086 f"Warning: The following categorical feature(s) are not in the dataset: {missing_categorical}" 

1087 ) 

1088 categorical = [col for col in categorical if col in df.columns] 

1089 

1090 # Process columns from the 'column' parameter (excluding those already specified) 

1091 for col in column: 

1092 if col not in df.columns: 

1093 self._log(f"Warning: Column '{col}' not found in dataset") 

1094 continue 

1095 if col in numerical or col in categorical: 

1096 continue 

1097 

1098 dtype = df[col].dtype 

1099 if pd.api.types.is_numeric_dtype(dtype): 

1100 numerical.append(col) 

1101 else: 

1102 categorical.append(col) 

1103 

1104 return numerical, categorical 

1105 

1106 def _drop_outliers(self) -> None: 

1107 """Detect outliers in numeric features and target, and mark them in the dataset. 

1108 

1109 This method: 

1110 - Detects outliers using Z-score method (default threshold: 3.0 standard deviations) 

1111 - Sets 'is_outlier' boolean column in the dataset dataframe 

1112 - Logs the number of records before and after outlier removal 

1113 - Only processes numeric features and target column 

1114 

1115 The 'is_outlier' column is always created, even if outlier removal is disabled 

1116 (drop_outliers is None or 0.0). Rows with is_outlier==True will be excluded 

1117 from train/holdout splits in _split_data() and data_load(). 

1118 """ 

1119 if self.dataset is None: 

1120 raise RuntimeError("Dataset must be loaded before dropping outliers") # noqa: TRY003 

1121 

1122 # Get drop_outliers threshold from experiment config 

1123 threshold = self.experiment.spec.drop_outliers 

1124 

1125 # Initialize is_outlier column to False 

1126 self.dataset["is_outlier"] = False 

1127 

1128 # If threshold is None or 0.0, outlier detection is disabled 

1129 if threshold is None or threshold == 0.0: 

1130 self._log("Outlier detection disabled (drop_outliers is None or 0.0)") 

1131 return 

1132 

1133 self._log(f"Detecting outliers using Z-score threshold: {threshold}") 

1134 

1135 # Get numeric columns (features + target) 

1136 target = self.experiment.spec.target 

1137 numeric_columns = [] 

1138 

1139 # Add numeric features 

1140 if hasattr(self, "numerical_features") and self.numerical_features: 

1141 numeric_columns.extend(self.numerical_features) 

1142 

1143 # Add target if it's numeric 

1144 if target in self.dataset.columns and pd.api.types.is_numeric_dtype( 

1145 self.dataset[target].dtype 

1146 ): 

1147 numeric_columns.append(target) 

1148 

1149 # Remove duplicates and ensure columns exist 

1150 numeric_columns = list({ 

1151 col for col in numeric_columns if col in self.dataset.columns 

1152 }) 

1153 

1154 if not numeric_columns: 

1155 self._log("No numeric columns found for outlier detection") 

1156 return 

1157 

1158 self._log(f"Checking outliers in columns: {numeric_columns}") 

1159 

1160 # Record initial number of rows 

1161 initial_count = len(self.dataset) 

1162 

1163 # Calculate Z-scores for each numeric column 

1164 outlier_mask = pd.Series(False, index=self.dataset.index) 

1165 

1166 for col in numeric_columns: 

1167 # Calculate Z-scores: (value - mean) / std 

1168 col_mean = self.dataset[col].mean() 

1169 col_std = self.dataset[col].std() 

1170 

1171 # Skip if std is 0 (constant column) or NaN 

1172 if col_std == 0 or pd.isna(col_std): 

1173 self._log(f" {col}: skipped (constant or NaN std)") 

1174 continue 

1175 

1176 z_scores = np.abs((self.dataset[col] - col_mean) / col_std) 

1177 # Mark as outlier if Z-score exceeds threshold 

1178 col_outliers = z_scores > threshold 

1179 outlier_mask |= col_outliers 

1180 

1181 # Log column-specific outlier counts 

1182 col_outlier_count = col_outliers.sum() 

1183 if col_outlier_count > 0: 

1184 self._log(f" {col}: {col_outlier_count} outliers detected") 

1185 

1186 # Set is_outlier column 

1187 self.dataset["is_outlier"] = outlier_mask 

1188 

1189 # Count total outliers 

1190 total_outliers = outlier_mask.sum() 

1191 final_count = initial_count - total_outliers 

1192 

1193 # Log results 

1194 self._log("Outlier detection complete:") 

1195 self._log(f" Initial records: {initial_count}") 

1196 self._log( 

1197 f" Outliers detected: {total_outliers} ({total_outliers / initial_count * 100:.2f}%)" 

1198 ) 

1199 self._log(f" Records after removal: {final_count}") 

1200 

1201 def _prepare_features(self) -> tuple[list[str], list[str]]: 

1202 """Prepare and classify features from feature set. 

1203 

1204 Collects features from feature specifications and classifies them 

1205 as numerical or categorical. If feature specifications don't include 

1206 types, infers from DataFrame dtypes. 

1207 

1208 Returns 

1209 ------- 

1210 Tuple[List[str], List[str]] 

1211 Lists of (numerical_features, categorical_features) 

1212 """ 

1213 if self.dataset is None: 

1214 raise RuntimeError("Dataset must be loaded before preparing features") # noqa: TRY003 

1215 

1216 self._log("Preparing features") 

1217 

1218 # Collect features from feature specifications 

1219 numerical_cols, categorical_cols = self._infer_column_types(self.dataset) 

1220 

1221 # If no explicit type specifications, infer from all feature columns 

1222 all_feature_cols = numerical_cols + categorical_cols 

1223 

1224 # Remove target column if present 

1225 target = self.experiment.spec.target 

1226 # Check if target accidentally appears in feature columns (should not occur) 

1227 if ( 

1228 target in all_feature_cols 

1229 or target in numerical_cols 

1230 or target in categorical_cols 

1231 ): 

1232 raise ValueError( # noqa: TRY003 

1233 f"Target column '{target}' was included in the feature set" 

1234 ) 

1235 

1236 if not (numerical_cols or categorical_cols): 

1237 raise ValueError( # noqa: TRY003 

1238 "No features were found in the feature set. At least one feature must be specified." 

1239 ) 

1240 

1241 self.numerical_features = numerical_cols 

1242 self.categorical_features = categorical_cols 

1243 self.all_features = numerical_cols + categorical_cols 

1244 

1245 # Convert column types: categorical to string, numerical to numeric 

1246 self.dataset = self._convert_column_types(self.dataset) 

1247 

1248 self._log(f"Numerical features ({len(numerical_cols)}): {numerical_cols}") 

1249 self._log(f"Categorical features ({len(categorical_cols)}): {categorical_cols}") 

1250 

1251 return numerical_cols, categorical_cols 

1252 

1253 def _convert_column_types(self, df: pd.DataFrame) -> pd.DataFrame: 

1254 """Convert categorical columns to string and numerical columns to numeric types. 

1255 

1256 Parameters 

1257 ---------- 

1258 df : pd.DataFrame 

1259 DataFrame to convert column types for 

1260 

1261 Returns 

1262 ------- 

1263 pd.DataFrame 

1264 DataFrame with converted column types 

1265 """ 

1266 df = df.copy() 

1267 

1268 # Convert categorical columns to string 

1269 if self.categorical_features: 

1270 for col in self.categorical_features: 

1271 if col in df.columns: 

1272 df[col] = df[col].astype(str) 

1273 

1274 # Convert numerical columns to numeric (handles mixed types) 

1275 if self.numerical_features: 

1276 for col in self.numerical_features: 

1277 if col in df.columns: 

1278 df[col] = pd.to_numeric(df[col], errors="coerce") 

1279 

1280 return df 

1281 

1282 def _get_grouping_values(self, df: pd.DataFrame, name: str) -> pd.Series: 

1283 """Get grouping values from column or index. 

1284 

1285 Parameters 

1286 ---------- 

1287 df : pd.DataFrame 

1288 DataFrame to extract grouping values from 

1289 name : str 

1290 Name of the column or index to use for grouping 

1291 

1292 Returns 

1293 ------- 

1294 pd.Series 

1295 Series containing grouping values aligned with df.index 

1296 

1297 Raises 

1298 ------ 

1299 ValueError 

1300 If the name is not found in columns or index name 

1301 """ 

1302 if name in df.columns: 

1303 return df[name] 

1304 if df.index.name is not None and df.index.name == name: 

1305 # Convert index to Series with same index as df for alignment 

1306 return pd.Series(df.index, index=df.index, name=name) 

1307 index_info = ( 

1308 f"Index name: {df.index.name}" if df.index.name else "Index has no name" 

1309 ) 

1310 raise ValueError( # noqa: TRY003 

1311 f"Grouping column/index '{name}' not found in dataset. " 

1312 f"Available columns: {list(df.columns)}, " 

1313 f"{index_info}" 

1314 ) 

1315 

1316 def _split_data(self) -> None: 

1317 """Split data into training and hold-out sets. 

1318 

1319 Respects: 

1320 - hold_out configuration for creating a separate hold-out set 

1321 - do_not_split_by for grouped splitting (prevents data leakage) 

1322 

1323 Adds a binary column 'is_holdout' to self.dataset indicating hold-out samples. 

1324 """ 

1325 if self.dataset is None: 

1326 raise RuntimeError("Dataset must be loaded before splitting") # noqa: TRY003 

1327 

1328 self._log("Splitting data") 

1329 

1330 target = self.experiment.spec.target 

1331 if target not in self.dataset.columns: 

1332 raise ValueError(f"Target column '{target}' not found in dataset") # noqa: TRY003 

1333 

1334 df = self.dataset.copy() 

1335 # Initialize hold-out indicator column 

1336 df["is_holdout"] = False 

1337 

1338 # Filter out outliers before splitting 

1339 if "is_outlier" in df.columns: 

1340 outlier_mask = df["is_outlier"] 

1341 outliers_count = outlier_mask.sum() 

1342 if outliers_count > 0: 

1343 self._log( 

1344 f"Excluding {outliers_count} outlier rows from train/holdout splits" 

1345 ) 

1346 df = df[~outlier_mask].copy() 

1347 else: 

1348 self._log("No outliers to exclude from splits") 

1349 else: 

1350 # If is_outlier column doesn't exist, create it as False 

1351 df["is_outlier"] = False 

1352 

1353 # Separate features and target 

1354 y = df[target] 

1355 X = df[self.all_features] # noqa: N806 

1356 

1357 # Handle hold-out set if specified 

1358 hold_out_config = self.experiment.spec.hold_out 

1359 hold_out_fraction = ( 

1360 hold_out_config.get("fraction", 0.0) if hold_out_config else 0.0 

1361 ) 

1362 

1363 do_not_split_by = self.experiment.spec.do_not_split_by 

1364 

1365 if hold_out_fraction > 0: 

1366 self._log(f"Creating hold-out set: {hold_out_fraction:.1%}") 

1367 hold_out_random_state = hold_out_config.get("random_state", 42) 

1368 

1369 # Check if we need grouped splitting for hold-out 

1370 if do_not_split_by: 

1371 # Create composite grouping key from multiple columns/index if needed 

1372 # GroupShuffleSplit needs a single array-like, so we combine multiple columns 

1373 if len(do_not_split_by) == 1: 

1374 groups = self._get_grouping_values(df, do_not_split_by[0]) 

1375 else: 

1376 # Create a composite group identifier from multiple columns/index 

1377 # Get each grouping value (column or index) 

1378 group_series = [ 

1379 self._get_grouping_values(df, name) for name in do_not_split_by 

1380 ] 

1381 # Combine into DataFrame for easy joining 

1382 group_df = pd.concat(group_series, axis=1) 

1383 # Convert to string tuples to create unique group identifiers 

1384 groups = group_df.apply( 

1385 lambda row: "_".join(str(val) for val in row), axis=1 

1386 ) 

1387 splitter = GroupShuffleSplit( 

1388 n_splits=1, 

1389 test_size=hold_out_fraction, 

1390 random_state=hold_out_random_state, 

1391 ) 

1392 train_idx, holdout_idx = next(splitter.split(X, y, groups=groups)) 

1393 # Split data using positional indices 

1394 self.X_train = X.iloc[train_idx].copy() 

1395 self.y_train = y.iloc[train_idx].copy() 

1396 self.X_holdout = X.iloc[holdout_idx].copy() 

1397 self.y_holdout = y.iloc[holdout_idx].copy() 

1398 # Mark hold-out samples using original DataFrame indices 

1399 holdout_original_idx = X.index[holdout_idx] 

1400 df.loc[holdout_original_idx, "is_holdout"] = True 

1401 else: 

1402 # Use train_test_split for non-grouped splitting 

1403 X_train_temp, X_holdout_temp, y_train_temp, y_holdout_temp = ( # noqa: N806 

1404 train_test_split( 

1405 X, 

1406 y, 

1407 test_size=hold_out_fraction, 

1408 random_state=hold_out_random_state, 

1409 shuffle=True, 

1410 ) 

1411 ) 

1412 # Assign split data 

1413 self.X_train = X_train_temp.copy() 

1414 self.y_train = y_train_temp.copy() 

1415 self.X_holdout = X_holdout_temp.copy() 

1416 self.y_holdout = y_holdout_temp.copy() 

1417 # Mark hold-out samples in the dataset 

1418 df.loc[X_holdout_temp.index, "is_holdout"] = True 

1419 

1420 self._log(f"Hold-out set size: {len(self.X_holdout)}") 

1421 else: 

1422 # No hold-out split, all data is training 

1423 self.X_train = X.copy() 

1424 self.y_train = y.copy() 

1425 self.X_holdout = None 

1426 self.y_holdout = None 

1427 

1428 # Update self.dataset with the hold-out indicator (but keep original rows including outliers) 

1429 # Only update is_holdout column, don't filter out outliers from self.dataset 

1430 # Use direct assignment to avoid deprecation warning 

1431 # Initialize is_holdout column to False 

1432 self.dataset["is_holdout"] = False 

1433 # Then update based on df 

1434 for idx in df.index: 

1435 self.dataset.loc[idx, "is_holdout"] = df.loc[idx, "is_holdout"] 

1436 

1437 self._log(f"Train set size: {len(self.X_train)}") 

1438 if self.X_holdout is not None: 

1439 self._log(f"Hold-out set size: {len(self.X_holdout)}") 

1440 

1441 def data_save(self, filepath: str | None = None) -> str: 

1442 """Save self.dataset to a parquet file. 

1443 

1444 Parameters 

1445 ---------- 

1446 filepath : str, optional 

1447 Path where to save the parquet file. If None, generates a default 

1448 filename based on experiment name. 

1449 

1450 Returns 

1451 ------- 

1452 str 

1453 Path to the saved parquet file 

1454 

1455 Raises 

1456 ------ 

1457 RuntimeError 

1458 If dataset is None (not loaded yet) 

1459 """ 

1460 if self.dataset is None: 

1461 raise RuntimeError("Dataset must be loaded before saving") # noqa: TRY003 

1462 

1463 if filepath is None: 

1464 # Generate default filename based on experiment name 

1465 filepath = f"{self.experiment.name}_dataset.parquet" 

1466 

1467 self._log(f"Saving dataset to {filepath}") 

1468 self.dataset.to_parquet(filepath, index=False) 

1469 self._log( 

1470 f"Dataset saved successfully: {len(self.dataset)} rows, {len(self.dataset.columns)} columns" 

1471 ) 

1472 

1473 return filepath 

1474 

1475 def data_load(self, filepath: str) -> None: 

1476 """Load dataset from parquet file, prepare features, and split based on is_holdout column. 

1477 

1478 This method loads a previously saved dataset (from data_save), prepares features, 

1479 and recreates the train/holdout splits based on the is_holdout column. The result 

1480 is equivalent to calling data_preparation + data_save, then loading the saved file. 

1481 

1482 Parameters 

1483 ---------- 

1484 filepath : str 

1485 Path to the parquet file to load 

1486 

1487 Raises 

1488 ------ 

1489 FileNotFoundError 

1490 If the parquet file doesn't exist 

1491 ValueError 

1492 If required columns (target, is_holdout) are missing from the loaded dataset 

1493 """ 

1494 self._log(f"Loading dataset from {filepath}") 

1495 

1496 # Load dataset from parquet 

1497 if not Path(filepath).exists(): 

1498 raise FileNotFoundError(f"Parquet file not found: {filepath}") # noqa: TRY003 

1499 

1500 self.dataset = pd.read_parquet(filepath) 

1501 self._log( 

1502 f"Dataset loaded: {len(self.dataset)} rows, {len(self.dataset.columns)} columns" 

1503 ) 

1504 

1505 # Verify is_holdout column exists 

1506 if "is_holdout" not in self.dataset.columns: 

1507 raise ValueError( # noqa: TRY003 

1508 "Dataset must contain 'is_holdout' column. " 

1509 "Please ensure the file was saved using data_save() after data_preparation()." 

1510 ) 

1511 

1512 # Step 1: Prepare features (classify as numerical/categorical) 

1513 self._prepare_features() 

1514 

1515 # Step 2: Split data based on is_holdout column 

1516 target = self.experiment.spec.target 

1517 if target not in self.dataset.columns: 

1518 raise ValueError(f"Target column '{target}' not found in dataset") # noqa: TRY003 

1519 

1520 df = self.dataset.copy() 

1521 

1522 # Filter out outliers before splitting 

1523 if "is_outlier" in df.columns: 

1524 outlier_mask = df["is_outlier"] 

1525 outliers_count = outlier_mask.sum() 

1526 if outliers_count > 0: 

1527 self._log( 

1528 f"Excluding {outliers_count} outlier rows from train/holdout splits" 

1529 ) 

1530 df = df[~outlier_mask].copy() 

1531 else: 

1532 self._log("No outliers to exclude from splits") 

1533 else: 

1534 # If is_outlier column doesn't exist, create it as False 

1535 df["is_outlier"] = False 

1536 

1537 # Separate features and target 

1538 y = df[target] 

1539 X = df[self.all_features] # noqa: N806 

1540 

1541 # Split based on is_holdout column 

1542 is_holdout = df["is_holdout"] 

1543 

1544 # Training set: rows where is_holdout is False 

1545 train_mask = ~is_holdout 

1546 self.X_train = X.loc[train_mask].copy() 

1547 self.y_train = y.loc[train_mask].copy() 

1548 

1549 # Holdout set: rows where is_holdout is True 

1550 holdout_mask = is_holdout 

1551 if holdout_mask.any(): 

1552 self.X_holdout = X.loc[holdout_mask].copy() 

1553 self.y_holdout = y.loc[holdout_mask].copy() 

1554 self._log(f"Hold-out set size: {len(self.X_holdout)}") 

1555 else: 

1556 self.X_holdout = None 

1557 self.y_holdout = None 

1558 self._log("No hold-out set found in loaded dataset") 

1559 

1560 self._log(f"Train set size: {len(self.X_train)}") 

1561 if self.X_holdout is not None: 

1562 self._log(f"Hold-out set size: {len(self.X_holdout)}") 

1563 

1564 def data_preparation(self) -> None: 

1565 """Prepare data for the experiment. 

1566 

1567 This method performs the initial data preparation steps: 

1568 1. Load dataset 

1569 2. Prepare features (classify as numerical/categorical) 

1570 3. Split data into train/validation/test sets 

1571 """ 

1572 self._log("Preparing data for experiment") 

1573 

1574 # Step 1: Load dataset 

1575 self._load_dataset() 

1576 

1577 # Step 2: Prepare features (classify as numerical/categorical) 

1578 self._prepare_features() 

1579 

1580 # Step 3: Drop outliers 

1581 self._drop_outliers() 

1582 

1583 # Step 4: Split data 

1584 self._split_data() 

1585 

1586 def get_config(self) -> dict[str, Any]: 

1587 """Return dictionary similar to original YAML config with all fields populated. 

1588 

1589 Includes inferred feature types (numerical/categorical) and experiment type 

1590 (regression/classification). The returned dictionary has the same structure 

1591 as the original YAML configuration file. 

1592 

1593 Returns 

1594 ------- 

1595 Dict[str, Any] 

1596 Dictionary with same structure as YAML config, with inferred fields populated. 

1597 Includes: 

1598 - All original config sections (datasets, features, models, experiments, etc.) 

1599 - Inferred experiment type in experiments section 

1600 - Inferred feature types (numerical/categorical) in features section 

1601 

1602 Raises 

1603 ------ 

1604 RuntimeError 

1605 If features have not been prepared yet (need to call prepare_features() first) 

1606 """ 

1607 # Start with a deep copy of the original config 

1608 config_dict = deepcopy(self.config.to_dict()) 

1609 

1610 # Get experiment name 

1611 exp_name = self.experiment.name 

1612 

1613 # Update experiment section with inferred type 

1614 if "experiments" in config_dict and exp_name in config_dict["experiments"]: 

1615 exp_dict = config_dict["experiments"][exp_name] 

1616 if isinstance(exp_dict, dict): 

1617 # Add inferred type if available (either configured or inferred) 

1618 inferred_type = self.experiment.get_type() 

1619 if inferred_type is not None: 

1620 exp_dict["type"] = inferred_type 

1621 

1622 # Update features section with inferred types 

1623 feature_name = self.experiment.spec.features 

1624 if "features" in config_dict and feature_name in config_dict["features"]: 

1625 feature_dict = config_dict["features"][feature_name] 

1626 if isinstance(feature_dict, dict): 

1627 # Check if features have been prepared 

1628 if not hasattr(self, "numerical_features") or not hasattr( 

1629 self, "categorical_features" 

1630 ): 

1631 raise RuntimeError( # noqa: TRY003 

1632 "Features have not been prepared yet. " 

1633 "Call prepare_features() or run() first before calling get_config()." 

1634 ) 

1635 

1636 # If features have been prepared, update with inferred types 

1637 # Remove 'columns' field if present (replaced by numerical/categorical) 

1638 if "columns" in feature_dict: 

1639 # Check if it was using __all__ - if so, we've expanded it 

1640 # Remove columns key as we've inferred types from it 

1641 feature_dict.pop("columns") 

1642 # If it was just __all__, we can note that it was inferred 

1643 # Otherwise, we've inferred types from the columns list 

1644 

1645 # Update with inferred types 

1646 feature_dict["numerical"] = self.numerical_features.copy() 

1647 feature_dict["categorical"] = self.categorical_features.copy() 

1648 

1649 # Ensure lists are not empty (remove empty lists if both are empty) 

1650 # But keep them if they exist - empty lists are valid 

1651 

1652 return config_dict 

1653 

1654 def run(self, skip_mlflow: bool = False) -> dict[str, Any]: 

1655 """Execute the complete experiment workflow. 

1656 

1657 Parameters 

1658 ---------- 

1659 skip_mlflow : bool, optional 

1660 Whether to skip MLflow logging, by default False 

1661 If True, the experiment will be run without logging to MLflow 

1662 Returns 

1663 ------- 

1664 Dict[str, Any] 

1665 Results dictionary containing metrics and artifacts for all models 

1666 """ 

1667 self._log(f"Starting experiment: {self.experiment.name}") 

1668 

1669 # Check if data is already prepared 

1670 if self.X_train is None: 

1671 self.data_preparation() 

1672 

1673 # Step 4: Create ModelRunner instances for each model 

1674 if not self.numerical_features and not self.categorical_features: 

1675 raise RuntimeError("No features prepared before creating ModelRunners") # noqa: TRY003 

1676 if self.X_train is None or self.y_train is None: 

1677 raise RuntimeError("Data must be split before creating ModelRunners") # noqa: TRY003 

1678 

1679 self.model_runners = [] 

1680 for model_name in self.experiment.spec.models: 

1681 model_runner = ModelRunner( 

1682 model_name=model_name, 

1683 numerical_features=self.numerical_features, 

1684 categorical_features=self.categorical_features, 

1685 experiment=self.experiment, 

1686 X_train=self.X_train, 

1687 y_train=self.y_train, 

1688 X_holdout=self.X_holdout, 

1689 y_holdout=self.y_holdout, 

1690 verbose=self.verbose, 

1691 ) 

1692 self.model_runners.append(model_runner) 

1693 

1694 # Step 5: Run each ModelRunner 

1695 results = {} 

1696 results["models"] = {} 

1697 results["best_model"] = None 

1698 results["best_model_score"] = None 

1699 

1700 best_model_score = float("-inf") 

1701 for model_runner in self.model_runners: 

1702 self._log(f"\n{'=' * 60}") 

1703 self._log(f"Processing model: {model_runner.model_name}") 

1704 self._log(f"{'=' * 60}") 

1705 

1706 model_score = model_runner.fit_and_evaluate() 

1707 self._log(f"Model {model_runner.model_name} score: {model_score:.6f}") 

1708 if model_score > best_model_score: 

1709 best_model_score = model_score 

1710 self.best_model = model_runner 

1711 results["models"][model_runner.model_name] = model_runner.metrics 

1712 

1713 self.best_model_score = best_model_score 

1714 results["best_model"] = self.best_model.model_name 

1715 results["best_model_score"] = self.best_model_score 

1716 

1717 self._log(f"\n{'=' * 60}") 

1718 self._log("Experiment completed successfully!") 

1719 self._log(f"Best model: {self.best_model.model_name}") 

1720 self._log(f"Best model score: {self.best_model_score:.6f}") 

1721 self._log(f"{'=' * 60}") 

1722 

1723 if not skip_mlflow: 

1724 self.mlflow_store() 

1725 

1726 return results 

1727 

1728 def mlflow_store(self) -> None: 

1729 """ 

1730 Store the experiment in MLflow. 

1731 """ 

1732 

1733 mlflow_config = MlflowConf(self.config) 

1734 if not mlflow_config.is_enabled(): 

1735 return 

1736 

1737 mlflow_experiment_name = mlflow_config.get_name(self.experiment.name) 

1738 if mlflow_config.get_type() == "databricks": 

1739 mlflow.set_tracking_uri("databricks") 

1740 if not mlflow_experiment_name.startswith("/Shared/"): 

1741 mlflow_experiment_name = f"/Shared/{mlflow_experiment_name}" 

1742 mlflow.set_experiment(mlflow_experiment_name) 

1743 else: 

1744 # Only set tracking URI if not already set (e.g., by test fixtures) 

1745 current_uri = mlflow.get_tracking_uri() 

1746 if current_uri is None or current_uri == "": 

1747 mlflow.set_tracking_uri("sqlite:///mlflow.db") 

1748 mlflow.set_experiment(mlflow_experiment_name) 

1749 

1750 # Get experiment description 

1751 experiment_description = self.experiment.spec.description or "" 

1752 

1753 with mlflow.start_run(description=experiment_description) as parent_run: 

1754 parent_run_id = parent_run.info.run_id 

1755 

1756 models = self.get_models() 

1757 if len(models) > 1: 

1758 # log multiple models as nested run 

1759 for model in models: 

1760 

1761 if self.best_model == model : 

1762 name = f"BEST-{model.model_name}" 

1763 else: 

1764 name = model.model_name 

1765 

1766 with mlflow.start_run(run_name=name, nested=True) as child_run: 

1767 model.mlflow_store() 

1768 

1769 # log the best model as parent run 

1770 self.best_model.mlflow_store() 

1771 

1772 # add addtional parameters to the parent run 

1773 mlflow.log_param("experiment_name", self.experiment.name) 

1774 mlflow.log_param("experiment_type", self.experiment.get_type()) 

1775 if experiment_description: 

1776 mlflow.log_param("experiment_description", experiment_description) 

1777 

1778 # add tags to the parent run 

1779 mlflow.set_tags(mlflow_config.get_tags()) 

1780 

1781 # store experiment config into temp directory and upload it to mlflow 

1782 with tempfile.TemporaryDirectory() as temp_dir: 

1783 config_path = os.path.join(temp_dir, "experiment_config.yaml") 

1784 config_dict = self.get_config() 

1785 with open(config_path, 'w') as f: 

1786 yaml.dump(config_dict, f, sort_keys=False, default_flow_style=False) 

1787 mlflow.log_artifact(config_path) 

1788 

1789__all__ = ["Runner", "ModelRunner"]