import os import io import pandas as pd import matplotlib import matplotlib.patches as mpatches import logging import traceback matplotlib.use('Agg') import matplotlib.pyplot as plt import seaborn as sns import base64 import numpy as np from datetime import datetime # autogluon and shap are imported lazily (inside functions) to avoid loading # ~2-4 GB of dependencies at Flask startup, which crashes low-memory containers. from sklearn.metrics import ( accuracy_score, f1_score, roc_auc_score, mean_squared_error, r2_score, roc_curve, confusion_matrix, ConfusionMatrixDisplay ) # Load a dataset from a file path or file-like object into a DataFrame. # Accepts .csv, .xls, .xlsx, .xlsm, and .arff files. def load_dataframe(source): if hasattr(source, 'filename'): # Flask FileStorage: read bytes, detect extension from filename filename = source.filename ext = os.path.splitext(filename)[1].lower() raw = source.read() if ext == '.csv': return pd.read_csv(io.BytesIO(raw)) elif ext in ('.xls', '.xlsx', '.xlsm'): return pd.read_excel(io.BytesIO(raw)) elif ext == '.arff': from scipy.io import arff as scipy_arff data, _ = scipy_arff.loadarff(io.StringIO(raw.decode('utf-8'))) df = pd.DataFrame(data) else: raise UserError("error_unsupported_file_format") else: # File path string ext = os.path.splitext(source)[1].lower() if ext == '.csv': return pd.read_csv(source) elif ext in ('.xls', '.xlsx', '.xlsm'): return pd.read_excel(source) elif ext == '.arff': from scipy.io import arff as scipy_arff data, _ = scipy_arff.loadarff(source) df = pd.DataFrame(data) else: raise UserError("error_unsupported_file_format") # Decode byte strings produced by scipy arff loader for col in df.select_dtypes([object]).columns: df[col] = df[col].apply(lambda x: x.decode('utf-8') if isinstance(x, bytes) else x) return df # Cache for SHAP explainers keyed by model_path to avoid rebuilding on every request _shap_explainer_cache: dict = {} # Custom exception for user errors class UserError(Exception): def __init__(self, message_key, status_code=400): super().__init__(message_key) self.message_key = message_key self.status_code = status_code # Save a matplotlib plot to disk def save_plot_to_disk(plt_obj, save_path, filename): full_path = os.path.join(save_path, filename) plt_obj.savefig(full_path, format='png', bbox_inches='tight') return full_path # Convert a SHAP plot to a base64-encoded PNG image def shap_plot_to_base64(plot_func, *args, **kwargs): plt.figure() plot_func(*args, **kwargs, show=False) buffer = io.BytesIO() plt.savefig(buffer, format='png', bbox_inches='tight') buffer.seek(0) image_base64 = base64.b64encode(buffer.read()).decode("utf-8") plt.close() return image_base64 # Generate a histogram of prediction errors (RMSE) and return as base64 image def generate_rmse_error_histogram_base64(y_true, y_pred, save_dir=None): errors = y_pred - y_true plt.figure(figsize=(8, 6)) sns.histplot(errors, bins=30, kde=True, color='orange', stat='density') plt.axvline(0, color='blue', linestyle='--', label='Zero error') plt.title('Distribution of Prediction Errors') plt.xlabel('Error (Predicted - Actual)') plt.ylabel('Density') plt.legend() plt.tight_layout() if save_dir: save_plot_to_disk(plt, save_dir, "rmse_error_distribution.png") # Encode as base64 image buffer = io.BytesIO() plt.savefig(buffer, format='png') buffer.seek(0) image_base64 = base64.b64encode(buffer.read()).decode('utf-8') plt.close() return image_base64 # Generate a feature importance bar plot and return as base64 image def generate_feature_importance_plot(importances, save_dir=None): # Keep only features with non-trivial importance; fall back to top-20 if all near zero significant = importances[importances['importance'].abs() > 1e-6] importances = (significant if not significant.empty else importances).head(20) n = len(importances) fig_height = max(4, n * 0.35) plt.figure(figsize=(10, fig_height)) all_near_zero = importances['importance'].abs().max() < 1e-4 if all_near_zero: plt.text(0.5, 0.5, 'Feature importance not available\n(all values ≈ 0)', ha='center', va='center', transform=plt.gca().transAxes, fontsize=13, color='gray') plt.axis('off') else: sns.barplot(x=importances['importance'], y=importances.index, palette='viridis') plt.xlabel('Importance') plt.ylabel('Features') plt.title('Feature Importance') plt.tight_layout() if save_dir: save_plot_to_disk(plt, save_dir, "feature_importance.png") buffer = io.BytesIO() plt.savefig(buffer, format='png') buffer.seek(0) image_base64 = base64.b64encode(buffer.read()).decode('utf-8') plt.close() return image_base64 # Generate metric-specific plots (confusion matrix, ROC, F1, RMSE) and return as base64 image def generate_metric_plot(metric_name, y_true=None, y_pred=None, y_proba=None, class_labels=None, save_dir=None): fig, ax = plt.subplots() metric_name = metric_name.lower() filename = f"{metric_name}.png" if metric_name == 'accuracy': # Display confusion matrix cm = confusion_matrix(y_true, y_pred, labels=class_labels) disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=class_labels) disp.plot(ax=ax, cmap='Blues', colorbar=False) ax.set_title("Confusion Matrix") elif metric_name == 'roc_auc' and y_proba is not None: pos_label = class_labels[1] if class_labels else 1 fpr, tpr, _ = roc_curve(y_true, y_proba, pos_label=pos_label) ax.plot(fpr, tpr, label='ROC Curve') ax.plot([0, 1], [0, 1], 'k--', label='Random') ax.set_xlabel('False Positive Rate') ax.set_ylabel('True Positive Rate') ax.set_title('ROC Curve') ax.legend() elif metric_name == 'f1_macro': # F1 score bar plot per class f1_per_class = f1_score(y_true, y_pred, average=None, labels=class_labels) ax.bar(class_labels, f1_per_class, color='lightgreen') ax.set_title("F1 Score per Class") ax.set_ylabel("F1 Score") elif metric_name == 'rmse': # Scatter plot for regression predictions vs actual values ax.scatter(y_true, y_pred, alpha=0.5, color='coral', label='Predictions') min_val = min(min(y_true), min(y_pred)) max_val = max(max(y_true), max(y_pred)) rmse_value = mean_squared_error(y_true, y_pred) ** 0.5 ax.plot([min_val, max_val], [min_val, max_val], 'k--', label='y = x') ax.set_xlabel("Actual Values") ax.set_ylabel("Predicted Values") ax.set_title(f"Predictions vs Actual (RMSE = {rmse_value:.4f})") ax.legend() else: ax.text(0.5, 0.5, f"No plot available for {metric_name}", ha='center', va='center') ax.set_axis_off() plt.tight_layout() if save_dir: save_plot_to_disk(plt, save_dir, filename) buffer = io.BytesIO() plt.savefig(buffer, format='png') buffer.seek(0) image_base64 = base64.b64encode(buffer.read()).decode('utf-8') plt.close() return image_base64 # Train an AutoGluon model and return training results and plots def train_model(df, target_column, time_limit, save_path, stop_event=None, time_column=None, prediction_length_percent=None, eval_metric=None, excluded_model_types=None, num_bag_folds=None, val_size=None): from autogluon.tabular import TabularPredictor, TabularDataset # lazy import try: save_path = os.path.abspath(save_path) plot_dir = os.path.join(save_path, "plot_train_results") os.makedirs(plot_dir, exist_ok=True) if df[target_column].isnull().any(): raise UserError("error_missing_target_values") # Time series branch if time_column: return train_model_timeseries( df, target_column, time_column, time_limit, save_path, stop_event, plot_dir, prediction_length_percent, eval_metric=eval_metric, excluded_model_types=excluded_model_types ) # When bagging is disabled, split off a held-out validation set. # AutoGluon trains on df_train; metrics and plots come from df_val (unseen). df_val = None df_train = df # keep reference to training data for feature importance if not num_bag_folds and val_size: from sklearn.model_selection import train_test_split df_train, df_val = train_test_split( df, test_size=val_size, random_state=42, stratify=df[target_column] if df[target_column].nunique() < 50 else None ) logging.info(f"Validation split: {len(df_train)} train / {len(df_val)} val ({val_size*100:.0f}%)") df = df_train # train AutoGluon on the smaller set predictor = TabularPredictor(label=target_column, path=save_path, eval_metric=eval_metric) if stop_event and stop_event.is_set(): raise UserError("training_interrupted") fit_kwargs = {'time_limit': time_limit} if excluded_model_types: fit_kwargs['excluded_model_types'] = excluded_model_types if num_bag_folds is not None: fit_kwargs['num_bag_folds'] = num_bag_folds if num_bag_folds: # All rows participate in OOF evaluation — no holdout withheld. fit_kwargs['use_bag_holdout'] = False if df_val is not None: # Tell AutoGluon to use the same val split for its own model selection, # so leaderboard score_val and our metrics are on identical data. fit_kwargs['tuning_data'] = df_val try: predictor.fit(df, **fit_kwargs) except ValueError as ve: if eval_metric and 'not a valid metric' in str(ve): logging.warning(f"eval_metric '{eval_metric}' incompatible with detected task type, retrying without it.") predictor = TabularPredictor(label=target_column, path=save_path) fit_kwargs_retry = {k: v for k, v in fit_kwargs.items()} predictor.fit(df, **fit_kwargs_retry) else: raise # ── Capture OOF predictions right after fit() ──────────────────────────── # Used ONLY for plots (confusion matrix, ROC, F1 bar, RMSE scatter). # Scalar metric values come from leaderboard(extra_metrics=...) instead. _oof_pred = None # predicted class labels (or regression values) _oof_pred_proba = None # probability DataFrame (classification only) pt = predictor.problem_type # predict_proba_oof → probability DataFrame → derive labels via argmax _proba_method = getattr(predictor, 'predict_proba_oof', None) if _proba_method is not None: for _mkw in [{}, {'silent': True}]: try: _raw = _proba_method(**_mkw) if pt == 'regression': _oof_pred = _raw elif isinstance(_raw, pd.DataFrame): _oof_pred_proba = _raw _oof_pred = _raw.idxmax(axis=1) else: # binary Series of positive-class probabilities _oof_pred_proba = _raw pos = predictor.class_labels[1] neg = predictor.class_labels[0] _oof_pred = _raw.map(lambda p: pos if p >= 0.5 else neg) logging.info(f"OOF OK via predict_proba_oof({list(_mkw)}): {len(_oof_pred)} rows") break except Exception as _e: logging.warning(f"predict_proba_oof({list(_mkw)}): {type(_e).__name__}: {_e}") # If proba failed, fall back to predict_oof which returns labels directly if _oof_pred is None: _pred_method = getattr(predictor, 'predict_oof', None) if _pred_method is not None: for _mkw in [{}, {'silent': True}]: try: _oof_pred = _pred_method(**_mkw) logging.info(f"OOF OK via predict_oof({list(_mkw)}): {len(_oof_pred)} rows") break except Exception as _e: logging.warning(f"predict_oof({list(_mkw)}): {type(_e).__name__}: {_e}") # When bagging is disabled: use explicit val split (unseen) if available, # otherwise fall back to in-sample predictions. if _oof_pred is None: eval_df = df_val if df_val is not None else df source = "validation set" if df_val is not None else "in-sample (no val split provided)" logging.info(f"OOF unavailable — generating predictions on {source}.") try: X_plot = eval_df.drop(columns=[target_column]) if pt == 'regression': _oof_pred = predictor.predict(X_plot) else: _raw = predictor.predict_proba(X_plot) if isinstance(_raw, pd.DataFrame): _oof_pred_proba = _raw _oof_pred = _raw.idxmax(axis=1) else: _oof_pred_proba = _raw pos = predictor.class_labels[1] neg = predictor.class_labels[0] _oof_pred = _raw.map(lambda p: pos if p >= 0.5 else neg) _oof_pred.index = eval_df.index if _oof_pred_proba is not None: _oof_pred_proba.index = eval_df.index # Point df to eval_df so y_test_eval is taken from the right rows df = eval_df logging.info(f"Predictions generated: {len(_oof_pred)} rows") except Exception as _e: logging.warning(f"Prediction fallback failed: {_e}") # ───────────────────────────────────────────────────────────────────────── if stop_event and stop_event.is_set(): raise UserError("training_interrupted") feature_importance_df = predictor.feature_importance(df_train) feature_importance_plot = generate_feature_importance_plot(feature_importance_df, save_dir=plot_dir) leaderboard = predictor.leaderboard(silent=True) best_model = predictor.model_best task_type = predictor.problem_type class_labels = predictor.class_labels if hasattr(predictor, 'class_labels') else None score_metric = predictor.eval_metric.name if hasattr(predictor.eval_metric, 'name') else str(predictor.eval_metric) # ── Fetch all OOF scalar metrics via leaderboard(extra_metrics=[...]) ────── # AutoGluon 1.3 computes these internally from the same OOF folds used during # training. Values are returned in natural sign (positive RMSE, 0–1 accuracy). # This is the most reliable approach — no need to call get_oof_pred* at all # for scalar metrics. if task_type == 'binary': _extra_metrics = ['accuracy', 'balanced_accuracy', 'f1_macro', 'roc_auc', 'precision_macro', 'recall_macro'] elif task_type == 'multiclass': _extra_metrics = ['accuracy', 'balanced_accuracy', 'f1_macro', 'precision_macro', 'recall_macro', 'roc_auc_ovo_macro'] else: _extra_metrics = ['root_mean_squared_error', 'mean_absolute_error', 'r2'] try: # extra_metrics are only requested when we have an explicit holdout val set. # With bagging ON, score_val is already OOF-honest; passing training data to # extra_metrics would evaluate in-sample (inflated). Secondary metrics for # bagging mode are computed directly from _oof_pred further below instead. if df_val is not None: _lb_extra = predictor.leaderboard(data=df_val, extra_metrics=_extra_metrics, silent=True) else: _lb_extra = leaderboard # no extra columns — fall through to _col_val → None _best_row = _lb_extra[_lb_extra['model'] == best_model].iloc[0] logging.info(f"leaderboard(extra_metrics) OK. Columns: {list(_lb_extra.columns)}") except Exception as _e: logging.warning(f"leaderboard(extra_metrics) failed: {type(_e).__name__}: {_e}. Using score_val only.") _lb_extra = leaderboard _best_row = leaderboard[leaderboard['model'] == best_model].iloc[0] def _col_val(row, *names): """Return the first column present and finite, else None.""" for n in names: if n in row.index: try: v = float(row[n]) if pd.notna(v): return v except Exception: pass return None # Fallback primary value from score_val with sign fix (used when extra_metrics # column is absent or NaN). _best_score_raw = float(leaderboard[leaderboard['model'] == best_model]['score_val'].values[0]) _em = predictor.eval_metric _gib = getattr(_em, 'greater_is_better', None) if _gib is None: _gib = (1 == getattr(_em, 'sign', 1)) if _gib is None: _gib = _best_score_raw >= 0 _primary_val_fallback = _best_score_raw if _gib else -_best_score_raw # OOF predictions — used for plots only (not for scalar metric values). oof_ok = False y_pred = None y_proba = None y_test_eval = None if _oof_pred is not None: y_pred = _oof_pred y_test_eval = df[target_column].loc[y_pred.index] y_proba = _oof_pred_proba.loc[y_pred.index] if _oof_pred_proba is not None else None oof_ok = True else: logging.warning("OOF predictions not available — plots will be skipped.") perf_data = {} summary = "" if task_type == 'binary': if class_labels is None or len(class_labels) < 2: raise UserError("error_unexpected_training") _acc_raw = _col_val(_best_row, 'accuracy') acc = _acc_raw if _acc_raw is not None else _primary_val_fallback auc = _col_val(_best_row, 'roc_auc') _pos_label = class_labels[1] if class_labels and len(class_labels) > 1 else None # If extra_metrics didn't give AUC, compute from OOF probabilities if auc is None and oof_ok and y_proba is not None and _pos_label is not None: try: auc = float(roc_auc_score(y_test_eval, y_proba[_pos_label])) except Exception as _e: logging.warning(f"roc_auc_score fallback failed: {_e}") perf_data = { 'accuracy': { 'value': acc, 'plot': generate_metric_plot('accuracy', y_true=y_test_eval, y_pred=y_pred, class_labels=class_labels, save_dir=plot_dir) if oof_ok else None }, } if auc is not None: perf_data['roc_auc'] = { 'value': auc, 'plot': generate_metric_plot('roc_auc', y_true=y_test_eval, y_proba=y_proba[_pos_label] if (oof_ok and y_proba is not None and _pos_label is not None) else None, class_labels=class_labels, save_dir=plot_dir) if (oof_ok and y_proba is not None and _pos_label is not None) else None } summary = f"Tâche détectée : classification binaire\n\n" summary += f"Modèle sélectionné : {best_model}\n" summary += f"Temps d'entraînement : {float(leaderboard['fit_time'].sum()):.2f} seconds\n\n" summary += f"Résultat de la métrique accuracy : {acc:.4f}\n" summary += f"Résultat de la métrique ROC AUC : {auc:.4f}\n" if auc is not None else "" elif task_type == 'multiclass': _acc_raw = _col_val(_best_row, 'accuracy') acc = _acc_raw if _acc_raw is not None else _primary_val_fallback f1 = _col_val(_best_row, 'f1_macro') # If extra_metrics didn't give F1, compute from OOF predictions if f1 is None and oof_ok: try: f1 = float(f1_score(y_test_eval, y_pred, average='macro')) except Exception as _e: logging.warning(f"f1_score fallback failed: {_e}") perf_data = { 'accuracy': { 'value': acc, 'plot': generate_metric_plot('accuracy', y_true=y_test_eval, y_pred=y_pred, class_labels=class_labels, save_dir=plot_dir) if oof_ok else None }, } if f1 is not None: perf_data['f1_macro'] = { 'value': f1, 'plot': generate_metric_plot('f1_macro', y_true=y_test_eval, y_pred=y_pred, class_labels=class_labels, save_dir=plot_dir) if oof_ok else None } summary = f"Tâche détectée : classification multiclasse\n\n" summary += f"Modèle sélectionné : {best_model}\n" summary += f"Temps d'entraînement : {float(leaderboard['fit_time'].sum()):.2f} seconds\n\n" summary += f"Résultat de la métrique accuracy : {acc:.4f}\n" summary += f"Résultat de la métrique F1 macro : {f1:.4f}\n" if f1 is not None else "" elif task_type == 'regression': rmse = _col_val(_best_row, 'root_mean_squared_error', 'rmse') # AutoGluon stores error metrics negated in all leaderboard columns if rmse is not None and rmse < 0: rmse = -rmse r2 = _col_val(_best_row, 'r2') # Fallbacks: compute from OOF predictions or use score_val if rmse is None: if oof_ok: try: rmse = float(mean_squared_error(y_test_eval, y_pred) ** 0.5) except Exception as _e: logging.warning(f"rmse fallback failed: {_e}") if rmse is None: rmse = _primary_val_fallback # always has a value if r2 is None and oof_ok: try: r2 = float(r2_score(y_test_eval, y_pred)) except Exception as _e: logging.warning(f"r2_score fallback failed: {_e}") if r2 is None and score_metric in ('r2', 'r2_score'): r2 = _primary_val_fallback perf_data = { 'rmse': { 'value': rmse, 'plot': generate_metric_plot('rmse', y_true=y_test_eval, y_pred=y_pred, save_dir=plot_dir) if oof_ok else None, 'plot_hist': generate_rmse_error_histogram_base64(y_true=y_test_eval, y_pred=y_pred, save_dir=plot_dir) if oof_ok else None } } if r2 is not None: perf_data['r2'] = {'value': r2} summary = f"Tâche détectée : regression\n\n" summary += f"Modèle sélectionné : {best_model}\n" summary += f"Temps d'entraînement : {float(leaderboard['fit_time'].sum()):.2f} seconds\n\n" summary += f"Résultat de la métrique R2 : {r2:.4f}\n" if r2 is not None else "" summary += f"Résultat de la métrique RMSE : {rmse:.4f}" if rmse is not None else "" results = { 'best_model': best_model, 'train_time': float(leaderboard['fit_time'].sum()), 'task_type': task_type, 'metrics': perf_data, 'feature_importance_plot': feature_importance_plot, 'model_path': save_path, 'leaderboard': leaderboard.to_dict(orient='records'), 'summary_LLM' : summary } return results except UserError: raise except Exception: logging.exception("Unexpected error during training") raise UserError("error_unexpected_training") def train_model_timeseries(df, target_column, time_column, time_limit, save_path, stop_event, plot_dir, prediction_length_percent=None, eval_metric=None, excluded_model_types=None): from autogluon.timeseries import TimeSeriesDataFrame, TimeSeriesPredictor # lazy import # Ensure datetime try: df[time_column] = pd.to_datetime(df[time_column]) except Exception: raise UserError("error_invalid_time_column") df = df.sort_values(time_column) df["item_id"] = "series_1" # single series df = df.rename(columns={target_column: "target"}) ts_df = TimeSeriesDataFrame.from_data_frame( df, id_column="item_id", timestamp_column=time_column ) try: percent = float(prediction_length_percent) if prediction_length_percent is not None else 10.0 except Exception: percent = 10.0 percent = max(5.0, min(30.0, percent)) prediction_length = max(1, int(len(ts_df) * (percent / 100.0))) predictor = TimeSeriesPredictor( prediction_length=prediction_length, target="target", path=save_path, eval_metric=eval_metric, ) if stop_event and stop_event.is_set(): raise UserError("training_interrupted") fit_start = datetime.now() ts_fit_kwargs = {'time_limit': time_limit, 'presets': 'medium_quality'} if excluded_model_types: ts_fit_kwargs['excluded_model_types'] = excluded_model_types predictor.fit(train_data=ts_df, **ts_fit_kwargs) fit_elapsed = (datetime.now() - fit_start).total_seconds() if stop_event and stop_event.is_set(): raise UserError("training_interrupted") forecasts = predictor.predict(ts_df) test_slice = ts_df.slice_by_timestep(-prediction_length, None) # forecasts columns usually contain quantiles; use mean if available, else first column if "mean" in forecasts.columns: forecast_series = forecasts["mean"] else: forecast_series = forecasts.iloc[:, 0] y_true = test_slice["target"].to_numpy() y_pred = forecast_series.to_numpy() mae = float(np.mean(np.abs(y_true - y_pred))) rmse = float(np.sqrt(np.mean((y_true - y_pred) ** 2))) mape = float(np.mean(np.abs((y_true - y_pred) / np.maximum(np.abs(y_true), 1e-8))) * 100) # Plot using plain columns df_sorted = df.sort_values(time_column) actual_time = df_sorted[time_column].reset_index(drop=True) actual_values = df_sorted["target"].reset_index(drop=True) forecast_time = forecasts.index.get_level_values("timestamp") forecast_values = forecast_series.to_numpy() total_len = len(actual_time) train_end_idx = max(0, total_len - prediction_length - 1) val_start_time = actual_time.iloc[total_len - prediction_length] if total_len > prediction_length else actual_time.iloc[0] val_end_time = actual_time.iloc[-1] forecast_start = forecast_time[0] if len(forecast_time) > 0 else val_end_time forecast_end = forecast_time[-1] if len(forecast_time) > 0 else val_end_time fig, ax = plt.subplots(figsize=(10, 4)) # Background region shading ax.axvspan(actual_time.iloc[0], actual_time.iloc[train_end_idx], alpha=0.08, color='steelblue', label='_train_region') ax.axvspan(val_start_time, val_end_time, alpha=0.12, color='orange', label='_val_region') ax.axvspan(forecast_start, forecast_end, alpha=0.12, color='green', label='_forecast_region') # Dummy patches for region legend entries train_patch = mpatches.Patch(color='steelblue', alpha=0.3, label='Train') val_patch = mpatches.Patch(color='orange', alpha=0.4, label='Validation') fc_patch = mpatches.Patch(color='green', alpha=0.4, label='Forecast region') ax.plot(forecast_time, forecast_values, color='tomato', linewidth=1.5, linestyle='--', label='Forecast', zorder=2) ax.plot(actual_time, actual_values, color='steelblue', linewidth=1.5, label='Actual', zorder=3) ax.set_xlabel(time_column, fontsize=10) ax.set_ylabel(target_column, fontsize=10) ax.set_title("Forecast vs Actual", fontsize=12) ax.tick_params(axis='x', rotation=45) plt.setp(ax.get_xticklabels(), ha='right', fontsize=8) handles, labels = ax.get_legend_handles_labels() # Remove the dummy axvspan entries (prefixed with _) handles = [h for h, l in zip(handles, labels) if not l.startswith('_')] labels = [l for l in labels if not l.startswith('_')] handles += [train_patch, val_patch, fc_patch] labels += ['Train', 'Validation', 'Forecast region'] ax.legend(handles, labels, fontsize=8, loc='best') plt.tight_layout() save_plot_to_disk(plt, plot_dir, "forecast_vs_actual.png") buffer = io.BytesIO() fig.savefig(buffer, format='png', dpi=120) buffer.seek(0) forecast_plot_b64 = base64.b64encode(buffer.read()).decode("utf-8") plt.close(fig) best_model_name = None leaderboard_records = None try: lb = predictor.leaderboard(silent=True) if not lb.empty and 'model' in lb.columns: best_model_name = lb.iloc[0]['model'] # Rename columns to match tabular leaderboard format expected by the frontend lb_out = lb[['model']].copy() score_col = next((c for c in ['score_val', 'score', 'val_score'] if c in lb.columns), None) fit_col = next((c for c in ['fit_time', 'fit_time_marginal'] if c in lb.columns), None) pred_col = next((c for c in ['pred_time_val', 'predict_time', 'inference_time'] if c in lb.columns), None) lb_out['score_val'] = lb[score_col].round(4) if score_col else None lb_out['fit_time'] = lb[fit_col].round(2) if fit_col else None lb_out['pred_time_val'] = lb[pred_col].round(4) if pred_col else None leaderboard_records = lb_out.to_dict(orient='records') except Exception as _e: logging.warning(f"Time series leaderboard failed: {_e}") results = { "best_model": best_model_name, "train_time": fit_elapsed, "task_type": "timeseries", "metrics": { "mae": {"value": mae}, "rmse": {"value": rmse}, "mape": {"value": mape}, }, "feature_importance_plot": None, "model_path": save_path, "leaderboard": leaderboard_records, "summary_LLM": None, "forecast_plot": forecast_plot_b64, "prediction_length": prediction_length, } return results # Tree-based model class name keywords that support shap.TreeExplainer _TREE_MODEL_KEYWORDS = ( 'ExtraTrees', 'RandomForest', 'XGBoost', 'XGB', 'LightGBM', 'LGBM', 'CatBoost', 'GradientBoosting', 'DecisionTree', ) def _unwrap_ag_model(ag_model): """Extract the raw sklearn/xgb/lgb estimator from an AutoGluon model wrapper. Returns None if the structure is not recognised.""" try: raw = ag_model # BaggedEnsembleModel stores fold models in .models if hasattr(raw, 'models') and raw.models: raw = raw.models[0] # Most AutoGluon wrappers store the actual estimator in .model if hasattr(raw, 'model'): return raw.model # Return raw itself as a last attempt (may already be an sklearn estimator) return raw except Exception: pass return None def _build_shap_explainer(predictor, X): """Return the fastest SHAP explainer available for this predictor. Prefers shap.TreeExplainer (milliseconds) when the best base model is a tree ensemble; falls back to shap.Explainer (PermutationExplainer, slow) otherwise. """ import shap # lazy import trainer = predictor._trainer # Find best non-ensemble model (WeightedEnsemble wraps others, not a tree itself) try: lb = trainer.leaderboard(silent=True) non_ens = lb[~lb['model'].str.contains('WeightedEnsemble', na=False)] best_base_name = non_ens.iloc[0]['model'] base_model = trainer.load_model(best_base_name) except Exception: base_model = trainer.load_model(trainer.model_best) raw = _unwrap_ag_model(base_model) if raw is not None and any(k in type(raw).__name__ for k in _TREE_MODEL_KEYWORDS): try: explainer = shap.TreeExplainer(raw) explainer(X.iloc[:1]) # quick smoke-test logging.info(f"SHAP: using TreeExplainer with {type(raw).__name__}") return explainer except Exception as exc: logging.warning(f"TreeExplainer failed ({exc}), falling back to PermutationExplainer") # Universal fallback — slow but works with any model best_model = trainer.load_model(trainer.model_best) if predictor.problem_type in ('binary', 'multiclass'): fn = best_model.predict_proba else: fn = best_model.predict logging.info("SHAP: using PermutationExplainer (generic fallback)") return shap.Explainer(fn, X) # Generate SHAP summary plot for model explanations def generate_shap_plot(model_path, df, target_column): import shap # lazy import from autogluon.tabular import TabularPredictor # lazy import try: predictor = TabularPredictor.load(model_path) if target_column not in df.columns: raise UserError("error_missing_target_column") X = df.drop(columns=[target_column]) X = X.sample(n=min(100, len(X))) if model_path not in _shap_explainer_cache: _shap_explainer_cache[model_path] = _build_shap_explainer(predictor, X) explainer = _shap_explainer_cache[model_path] shap_values = explainer(X) shap_summary_plot = shap_plot_to_base64(shap.summary_plot, shap_values, X) return {'shap_summary_plot': shap_summary_plot} except UserError: raise except Exception: raise UserError("error_shap") # Plot distribution of predicted values for regression def plot_regression_distribution(y_pred): plt.figure(figsize=(8, 6)) sns.histplot(y_pred, bins=30, kde=True, color='skyblue') plt.title("Distribution of Predicted Values") plt.xlabel("Predicted Values") plt.ylabel("Frequency") plt.tight_layout() buffer = io.BytesIO() plt.savefig(buffer, format="png") buffer.seek(0) image_base64 = base64.b64encode(buffer.read()).decode("utf-8") plt.close() return image_base64 # Plot boxplot to detect outliers in predictions def plot_prediction_outliers(y_pred): plt.figure(figsize=(8, 6)) sns.boxplot(x=y_pred, color="tomato") plt.title("Outlier Detection in Predictions") plt.xlabel("Predicted Value") plt.tight_layout() buffer = io.BytesIO() plt.savefig(buffer, format="png") buffer.seek(0) image_base64 = base64.b64encode(buffer.read()).decode("utf-8") plt.close() return image_base64 # Plot distribution of predicted classes for classification def plot_predicted_class_distribution(y_pred): plt.figure(figsize=(8, 6)) sns.countplot(x=y_pred, palette='pastel') plt.title("Predicted Class Distribution") plt.xlabel("Predicted Class") plt.ylabel("Number of Occurrences") plt.tight_layout() buffer = io.BytesIO() plt.savefig(buffer, format="png") buffer.seek(0) image_base64 = base64.b64encode(buffer.read()).decode("utf-8") plt.close() return image_base64 # Plot predicted probability distributions for each class def plot_prediction_confidence(y_proba_df): plt.figure(figsize=(10, 6)) sns.boxplot(data=y_proba_df, orient='h', palette='Set2') plt.title("Predicted Probability Distribution by Class") plt.xlabel("Predicted Probability") plt.ylabel("Class") plt.tight_layout() buffer = io.BytesIO() plt.savefig(buffer, format="png") buffer.seek(0) image_base64 = base64.b64encode(buffer.read()).decode("utf-8") plt.close() return image_base64 # Predict using a trained model and generate relevant plots def predict_model(csv_path, model_path): from autogluon.tabular import TabularPredictor # lazy import try: predictor = TabularPredictor.load(model_path) df = load_dataframe(csv_path) # Remove target column if present if predictor.label in df.columns: df = df.drop(columns=[predictor.label]) expected_cols = predictor.feature_metadata.get_features() missing_cols = set(expected_cols) - set(df.columns) extra_cols = set(df.columns) - set(expected_cols) if missing_cols: raise UserError("error_missing_columns") if extra_cols: raise UserError("error_extra_columns") predictions = predictor.predict(df) task_type = predictor.problem_type output_data = { 'predictions': predictions.reset_index().to_dict(orient='records'), 'plots': {} } # Generate plots based on task type if task_type == 'regression': output_data['plots'] = { 'distribution': plot_regression_distribution(predictions), } elif task_type == 'binary': y_proba = predictor.predict_proba(df) output_data['plots'] = { 'class_distribution': plot_predicted_class_distribution(predictions), 'confidence': plot_prediction_confidence(y_proba) } elif task_type == 'multiclass': y_proba = predictor.predict_proba(df) output_data['plots'] = { 'class_distribution': plot_predicted_class_distribution(predictions), 'confidence': plot_prediction_confidence(y_proba) } return output_data except UserError: raise except Exception: raise UserError("error_unexpected_prediction")