| 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
|
|
|
|
|
|
|
|
|
| from sklearn.metrics import (
|
| accuracy_score, f1_score, roc_auc_score,
|
| mean_squared_error, r2_score, roc_curve, confusion_matrix, ConfusionMatrixDisplay
|
| )
|
|
|
|
|
|
|
| def load_dataframe(source):
|
| if hasattr(source, '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:
|
|
|
| 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")
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| _shap_explainer_cache: dict = {}
|
|
|
|
|
| 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
|
|
|
|
|
| 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
|
|
|
|
|
| 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
|
|
|
|
|
| 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")
|
|
|
|
|
| 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
|
|
|
|
|
| def generate_feature_importance_plot(importances, save_dir=None):
|
|
|
| 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
|
|
|
|
|
| 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':
|
|
|
| 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_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':
|
|
|
| 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
|
|
|
|
|
| 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
|
| 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")
|
|
|
|
|
| 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
|
| )
|
|
|
|
|
|
|
| df_val = None
|
| df_train = df
|
| 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
|
|
|
| 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:
|
|
|
| fit_kwargs['use_bag_holdout'] = False
|
| if df_val is not None:
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
| _oof_pred = None
|
| _oof_pred_proba = None
|
|
|
| pt = predictor.problem_type
|
|
|
|
|
| _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:
|
| _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 _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}")
|
|
|
|
|
|
|
| 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
|
|
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
|
|
|
|
|
| if df_val is not None:
|
| _lb_extra = predictor.leaderboard(data=df_val, extra_metrics=_extra_metrics, silent=True)
|
| else:
|
| _lb_extra = leaderboard
|
| _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
|
|
|
|
|
|
|
| _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_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 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 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')
|
|
|
| if rmse is not None and rmse < 0:
|
| rmse = -rmse
|
| r2 = _col_val(_best_row, 'r2')
|
|
|
| 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
|
| 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
|
|
|
| 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"
|
| 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)
|
|
|
|
|
| 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)
|
|
|
|
|
| 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))
|
|
|
|
|
| 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')
|
|
|
|
|
| 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()
|
|
|
| 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']
|
|
|
| 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_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
|
|
|
| if hasattr(raw, 'models') and raw.models:
|
| raw = raw.models[0]
|
|
|
| if hasattr(raw, 'model'):
|
| return raw.model
|
|
|
| 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
|
| trainer = predictor._trainer
|
|
|
|
|
| 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])
|
| 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")
|
|
|
|
|
| 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)
|
|
|
|
|
|
|
| def generate_shap_plot(model_path, df, target_column):
|
| import shap
|
| from autogluon.tabular import TabularPredictor
|
| 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")
|
|
|
|
|
| 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
|
|
|
|
|
| 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
|
|
|
|
|
| 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
|
|
|
|
|
| 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
|
|
|
|
|
| def predict_model(csv_path, model_path):
|
| from autogluon.tabular import TabularPredictor
|
| try:
|
| predictor = TabularPredictor.load(model_path)
|
|
|
| df = load_dataframe(csv_path)
|
|
|
|
|
| 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': {}
|
| }
|
|
|
|
|
| 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")
|
|
|
|
|
|
|