Spaces:
Sleeping
Sleeping
Update src/module/Forecasting.py
Browse files- src/module/Forecasting.py +205 -768
src/module/Forecasting.py
CHANGED
|
@@ -1,839 +1,276 @@
|
|
| 1 |
"""
|
| 2 |
-
MODULE: FORECASTING
|
| 3 |
-
======================================
|
| 4 |
-
Responsabilité : Entraînement,
|
| 5 |
-
|
| 6 |
-
Style : Gotham UI
|
| 7 |
-
|
| 8 |
-
Modèles Entraînés :
|
| 9 |
-
- XGBoost : Champion des compétitions, excellent sur données tabulaires
|
| 10 |
-
- LightGBM : Rapide, performant, moins de surapprentissage
|
| 11 |
-
- Random Forest : Baseline robuste et interprétable
|
| 12 |
-
|
| 13 |
-
Métriques de Performance :
|
| 14 |
-
- RMSE : Pénalise fortement les grandes erreurs (critique en santé)
|
| 15 |
-
- MAE : Robuste aux outliers
|
| 16 |
-
- MAPE : Erreur en pourcentage (compréhensible business)
|
| 17 |
-
- R² : Variance expliquée
|
| 18 |
-
- SHAP : Interprétabilité des prédictions
|
| 19 |
"""
|
| 20 |
|
| 21 |
import pandas as pd
|
| 22 |
import numpy as np
|
| 23 |
import streamlit as st
|
| 24 |
-
import plotly.express as px
|
| 25 |
import plotly.graph_objects as go
|
| 26 |
-
from
|
| 27 |
-
from datetime import datetime
|
| 28 |
import warnings
|
| 29 |
-
|
| 30 |
|
| 31 |
# ML Models
|
| 32 |
from sklearn.ensemble import RandomForestRegressor
|
| 33 |
from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score
|
| 34 |
-
from sklearn.preprocessing import LabelEncoder
|
| 35 |
import xgboost as xgb
|
| 36 |
import lightgbm as lgb
|
| 37 |
|
| 38 |
-
# SHAP
|
| 39 |
try:
|
| 40 |
import shap
|
|
|
|
| 41 |
SHAP_AVAILABLE = True
|
| 42 |
except ImportError:
|
| 43 |
SHAP_AVAILABLE = False
|
| 44 |
|
|
|
|
| 45 |
|
| 46 |
class HospitalForecaster:
|
| 47 |
-
"""Moteur de prédiction hospitalière avec comparaison de modèles"""
|
| 48 |
-
|
| 49 |
def __init__(self, ml_dataset):
|
| 50 |
self.df = ml_dataset.copy()
|
|
|
|
| 51 |
self.models = {}
|
| 52 |
-
self.predictions = {}
|
| 53 |
self.metrics = {}
|
|
|
|
| 54 |
self.best_model_name = None
|
|
|
|
| 55 |
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
def prepare_data(self, target_col='nb_admissions', test_size=0.15, val_size=0.15):
|
| 60 |
-
"""
|
| 61 |
-
Préparation des données avec split temporel (CRITIQUE pour séries temporelles).
|
| 62 |
-
|
| 63 |
-
Args:
|
| 64 |
-
target_col: Variable cible à prédire
|
| 65 |
-
test_size: Proportion pour test set
|
| 66 |
-
val_size: Proportion pour validation set
|
| 67 |
-
|
| 68 |
-
Returns:
|
| 69 |
-
Tuple (X_train, X_val, X_test, y_train, y_val, y_test, feature_names)
|
| 70 |
-
"""
|
| 71 |
-
df = self.df.copy()
|
| 72 |
-
|
| 73 |
-
# Tri chronologique (essentiel pour séries temporelles)
|
| 74 |
-
df = df.sort_values('date').reset_index(drop=True)
|
| 75 |
-
|
| 76 |
-
# Colonnes à exclure
|
| 77 |
-
exclude_cols = [
|
| 78 |
-
target_col, 'date', 'stay_id', 'patient_id',
|
| 79 |
-
'scenario', 'service_nom', 'periode_conges_label',
|
| 80 |
-
'dq_is_anomaly', 'dq_anomaly_type', 'dq_anomaly_detail',
|
| 81 |
-
'jour_semaine_nom', 'mois_nom', 'saison',
|
| 82 |
-
'pathologie_nom', 'service_code', 'materiel_code'
|
| 83 |
-
]
|
| 84 |
|
| 85 |
-
|
| 86 |
-
feature_cols = [
|
| 87 |
|
| 88 |
-
|
| 89 |
-
categorical_cols = df[feature_cols].select_dtypes(include=['object', 'category']).columns.tolist()
|
| 90 |
|
| 91 |
-
|
| 92 |
-
if col in df.columns:
|
| 93 |
-
le = LabelEncoder()
|
| 94 |
-
df[col] = le.fit_transform(df[col].astype(str))
|
| 95 |
-
self.label_encoders[col] = le
|
| 96 |
-
|
| 97 |
-
# Suppression des NaN (après feature engineering, certaines features peuvent être NaN)
|
| 98 |
-
df = df.dropna(subset=feature_cols + [target_col])
|
| 99 |
-
|
| 100 |
-
# Extraction X et y
|
| 101 |
-
X = df[feature_cols].select_dtypes(include=[np.number])
|
| 102 |
y = df[target_col]
|
| 103 |
-
dates = df['date']
|
| 104 |
-
|
| 105 |
-
# Split temporel (respecte l'ordre chronologique)
|
| 106 |
-
n = len(X)
|
| 107 |
-
train_end = int(n * (1 - test_size - val_size))
|
| 108 |
-
val_end = int(n * (1 - test_size))
|
| 109 |
-
|
| 110 |
-
X_train = X.iloc[:train_end]
|
| 111 |
-
X_val = X.iloc[train_end:val_end]
|
| 112 |
-
X_test = X.iloc[val_end:]
|
| 113 |
-
|
| 114 |
-
y_train = y.iloc[:train_end]
|
| 115 |
-
y_val = y.iloc[train_end:val_end]
|
| 116 |
-
y_test = y.iloc[val_end:]
|
| 117 |
-
|
| 118 |
-
dates_train = dates.iloc[:train_end]
|
| 119 |
-
dates_val = dates.iloc[train_end:val_end]
|
| 120 |
-
dates_test = dates.iloc[val_end:]
|
| 121 |
-
|
| 122 |
-
# Stockage pour visualisations
|
| 123 |
-
self.X_train = X_train
|
| 124 |
-
self.X_val = X_val
|
| 125 |
-
self.X_test = X_test
|
| 126 |
-
self.y_train = y_train
|
| 127 |
-
self.y_val = y_val
|
| 128 |
-
self.y_test = y_test
|
| 129 |
-
self.dates_train = dates_train
|
| 130 |
-
self.dates_val = dates_val
|
| 131 |
-
self.dates_test = dates_test
|
| 132 |
-
self.feature_names = X_train.columns.tolist()
|
| 133 |
-
|
| 134 |
-
return X_train, X_val, X_test, y_train, y_val, y_test, self.feature_names
|
| 135 |
-
|
| 136 |
-
def train_xgboost(self, X_train, X_val, y_train, y_val):
|
| 137 |
-
"""
|
| 138 |
-
Entraînement XGBoost avec early stopping.
|
| 139 |
-
XGBoost est le champion des compétitions Kaggle pour données tabulaires.
|
| 140 |
-
"""
|
| 141 |
-
model = xgb.XGBRegressor(
|
| 142 |
-
n_estimators=500,
|
| 143 |
-
learning_rate=0.05,
|
| 144 |
-
max_depth=6,
|
| 145 |
-
min_child_weight=3,
|
| 146 |
-
subsample=0.8,
|
| 147 |
-
colsample_bytree=0.8,
|
| 148 |
-
gamma=0.1,
|
| 149 |
-
random_state=42,
|
| 150 |
-
n_jobs=-1,
|
| 151 |
-
early_stopping_rounds=50,
|
| 152 |
-
eval_metric='rmse'
|
| 153 |
-
)
|
| 154 |
-
|
| 155 |
-
model.fit(
|
| 156 |
-
X_train, y_train,
|
| 157 |
-
eval_set=[(X_val, y_val)],
|
| 158 |
-
verbose=False
|
| 159 |
-
)
|
| 160 |
|
| 161 |
-
self.
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
Plus rapide que XGBoost, souvent meilleur sur grandes données.
|
| 168 |
-
"""
|
| 169 |
-
model = lgb.LGBMRegressor(
|
| 170 |
-
n_estimators=500,
|
| 171 |
-
learning_rate=0.05,
|
| 172 |
-
num_leaves=31,
|
| 173 |
-
max_depth=6,
|
| 174 |
-
min_child_samples=20,
|
| 175 |
-
subsample=0.8,
|
| 176 |
-
colsample_bytree=0.8,
|
| 177 |
-
random_state=42,
|
| 178 |
-
n_jobs=-1,
|
| 179 |
-
force_col_wise=True,
|
| 180 |
-
verbose=-1
|
| 181 |
-
)
|
| 182 |
-
|
| 183 |
-
model.fit(
|
| 184 |
-
X_train, y_train,
|
| 185 |
-
eval_set=[(X_val, y_val)],
|
| 186 |
-
eval_metric='rmse',
|
| 187 |
-
callbacks=[lgb.early_stopping(stopping_rounds=50, verbose=False)]
|
| 188 |
-
)
|
| 189 |
-
|
| 190 |
-
self.models['LightGBM'] = model
|
| 191 |
-
return model
|
| 192 |
-
|
| 193 |
-
def train_random_forest(self, X_train, y_train):
|
| 194 |
-
"""
|
| 195 |
-
Entraînement Random Forest.
|
| 196 |
-
Baseline robuste, très interprétable, résistant au surapprentissage.
|
| 197 |
-
"""
|
| 198 |
-
model = RandomForestRegressor(
|
| 199 |
-
n_estimators=300,
|
| 200 |
-
max_depth=15,
|
| 201 |
-
min_samples_split=10,
|
| 202 |
-
min_samples_leaf=5,
|
| 203 |
-
max_features='sqrt',
|
| 204 |
-
random_state=42,
|
| 205 |
-
n_jobs=-1,
|
| 206 |
-
verbose=0
|
| 207 |
-
)
|
| 208 |
-
|
| 209 |
-
model.fit(X_train, y_train)
|
| 210 |
-
|
| 211 |
-
self.models['Random Forest'] = model
|
| 212 |
-
return model
|
| 213 |
-
|
| 214 |
-
def evaluate_model(self, model_name, model, X_test, y_test):
|
| 215 |
-
"""
|
| 216 |
-
Évaluation complète d'un modèle avec toutes les métriques.
|
| 217 |
-
"""
|
| 218 |
-
y_pred = model.predict(X_test)
|
| 219 |
-
|
| 220 |
-
# Métriques de régression
|
| 221 |
-
rmse = np.sqrt(mean_squared_error(y_test, y_pred))
|
| 222 |
-
mae = mean_absolute_error(y_test, y_pred)
|
| 223 |
-
r2 = r2_score(y_test, y_pred)
|
| 224 |
-
|
| 225 |
-
# MAPE (Mean Absolute Percentage Error)
|
| 226 |
-
# Gestion du cas où y_test contient des 0
|
| 227 |
-
mask = y_test != 0
|
| 228 |
-
if mask.sum() > 0:
|
| 229 |
-
mape = np.mean(np.abs((y_test[mask] - y_pred[mask]) / y_test[mask])) * 100
|
| 230 |
-
else:
|
| 231 |
-
mape = np.nan
|
| 232 |
-
|
| 233 |
-
# Stockage des prédictions
|
| 234 |
-
self.predictions[model_name] = y_pred
|
| 235 |
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
'R²': r2,
|
| 241 |
-
'MAPE': mape
|
| 242 |
-
}
|
| 243 |
-
|
| 244 |
-
return rmse, mae, r2, mape
|
| 245 |
-
|
| 246 |
-
def train_all_models(self):
|
| 247 |
-
"""Pipeline complet d'entraînement des 3 modèles"""
|
| 248 |
-
|
| 249 |
-
st.info("🚀 **Préparation des données avec split temporel...**")
|
| 250 |
-
X_train, X_val, X_test, y_train, y_val, y_test, feature_names = self.prepare_data()
|
| 251 |
-
|
| 252 |
-
progress_bar = st.progress(0)
|
| 253 |
-
status_text = st.empty()
|
| 254 |
|
| 255 |
-
# 1.
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
self.
|
| 259 |
-
self.evaluate_model('XGBoost', self.models['XGBoost'], X_test, y_test)
|
| 260 |
-
progress_bar.progress(40)
|
| 261 |
|
| 262 |
-
# 2.
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
self.
|
| 266 |
-
progress_bar.progress(70)
|
| 267 |
|
| 268 |
# 3. Random Forest
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
self.
|
| 272 |
-
progress_bar.progress(100)
|
| 273 |
-
|
| 274 |
-
status_text.text("✅ Entraînement terminé!")
|
| 275 |
-
|
| 276 |
-
# Détermination du meilleur modèle
|
| 277 |
-
self._select_best_model()
|
| 278 |
-
|
| 279 |
-
return self.metrics
|
| 280 |
-
|
| 281 |
-
def _select_best_model(self):
|
| 282 |
-
"""
|
| 283 |
-
Sélection automatique du meilleur modèle basée sur un score composite.
|
| 284 |
|
| 285 |
-
|
| 286 |
-
- RMSE : poids 40% (critique pour éviter grandes erreurs en santé)
|
| 287 |
-
- MAE : poids 30% (robustesse)
|
| 288 |
-
- R² : poids 20% (variance expliquée)
|
| 289 |
-
- MAPE : poids 10% (compréhension business)
|
| 290 |
-
|
| 291 |
-
Plus le score est BAS, mieux c'est.
|
| 292 |
-
"""
|
| 293 |
scores = {}
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
# Pour R² : plus haut = mieux
|
| 299 |
-
|
| 300 |
-
rmse_norm = metrics['RMSE']
|
| 301 |
-
mae_norm = metrics['MAE']
|
| 302 |
-
r2_norm = 1 - metrics['R²'] # Inversé car on veut minimiser
|
| 303 |
-
mape_norm = metrics['MAPE'] if not np.isnan(metrics['MAPE']) else 100
|
| 304 |
|
| 305 |
-
#
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
0.20 * r2_norm +
|
| 310 |
-
0.10 * mape_norm
|
| 311 |
-
)
|
| 312 |
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
# Sélection du modèle avec le score le plus bas
|
| 316 |
-
self.best_model_name = min(scores, key=scores.get)
|
| 317 |
-
self.composite_scores = scores
|
| 318 |
-
|
| 319 |
-
def get_best_model_recommendation(self):
|
| 320 |
-
"""
|
| 321 |
-
Génère une recommandation détaillée du meilleur modèle avec justification.
|
| 322 |
-
"""
|
| 323 |
-
if not self.best_model_name:
|
| 324 |
-
return "Aucun modèle entraîné."
|
| 325 |
-
|
| 326 |
-
best_metrics = self.metrics[self.best_model_name]
|
| 327 |
-
|
| 328 |
-
recommendation = f"""
|
| 329 |
-
### 🏆 MODÈLE RECOMMANDÉ : **{self.best_model_name}**
|
| 330 |
-
|
| 331 |
-
#### 📊 Performances sur Test Set :
|
| 332 |
-
- **RMSE** : {best_metrics['RMSE']:.2f} (Erreur quadratique moyenne)
|
| 333 |
-
- **MAE** : {best_metrics['MAE']:.2f} (Erreur absolue moyenne)
|
| 334 |
-
- **R²** : {best_metrics['R²']:.4f} ({best_metrics['R²']*100:.2f}% de variance expliquée)
|
| 335 |
-
- **MAPE** : {best_metrics['MAPE']:.2f}% (Erreur en pourcentage)
|
| 336 |
-
|
| 337 |
-
#### 🎯 Pourquoi ce modèle ?
|
| 338 |
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
# Justification spécifique selon le modèle
|
| 342 |
-
if self.best_model_name == 'XGBoost':
|
| 343 |
-
recommendation += """
|
| 344 |
-
**Avantages de XGBoost :**
|
| 345 |
-
- 🥇 **Champion des compétitions Kaggle** sur données tabulaires
|
| 346 |
-
- 🎯 **Excellente précision** grâce au gradient boosting optimisé
|
| 347 |
-
- 🛡️ **Robuste au surapprentissage** avec régularisation L1/L2
|
| 348 |
-
- 🔍 **Gère bien les interactions complexes** entre features (hospitalières)
|
| 349 |
-
- ⚡ **Early stopping** : arrêt automatique dès que validation cesse de s'améliorer
|
| 350 |
-
|
| 351 |
-
**Idéal pour :** Prédictions critiques nécessitant la meilleure précision possible.
|
| 352 |
-
"""
|
| 353 |
-
|
| 354 |
-
elif self.best_model_name == 'LightGBM':
|
| 355 |
-
recommendation += """
|
| 356 |
-
**Avantages de LightGBM :**
|
| 357 |
-
- ⚡ **Rapidité exceptionnelle** : 10-20x plus rapide que XGBoost
|
| 358 |
-
- 📈 **Excellent sur grandes données** grâce à l'algorithme leaf-wise
|
| 359 |
-
- 💾 **Faible consommation mémoire** : idéal pour production
|
| 360 |
-
- 🎯 **Précision comparable à XGBoost** avec moins de ressources
|
| 361 |
-
- 🌳 **Moins de surapprentissage** naturellement
|
| 362 |
-
|
| 363 |
-
**Idéal pour :** Déploiement en production avec contraintes de ressources.
|
| 364 |
-
"""
|
| 365 |
-
|
| 366 |
-
elif self.best_model_name == 'Random Forest':
|
| 367 |
-
recommendation += """
|
| 368 |
-
**Avantages de Random Forest :**
|
| 369 |
-
- 🌲 **Très robuste** : résiste bien au surapprentissage
|
| 370 |
-
- 📖 **Facilement interprétable** : importance des features claire
|
| 371 |
-
- 🛡️ **Stable** : moins sensible aux hyperparamètres que boosting
|
| 372 |
-
- 🎲 **Gère bien les outliers** grâce au vote d'ensemble
|
| 373 |
-
- 🔧 **Facile à maintenir** : peu de tuning nécessaire
|
| 374 |
|
| 375 |
-
|
| 376 |
-
"""
|
| 377 |
-
|
| 378 |
-
# Comparaison avec les autres modèles
|
| 379 |
-
recommendation += "\n#### 📉 Comparaison avec les autres modèles :\n\n"
|
| 380 |
-
|
| 381 |
-
for model_name, metrics in self.metrics.items():
|
| 382 |
-
if model_name != self.best_model_name:
|
| 383 |
-
rmse_diff = ((metrics['RMSE'] - best_metrics['RMSE']) / best_metrics['RMSE']) * 100
|
| 384 |
-
mae_diff = ((metrics['MAE'] - best_metrics['MAE']) / best_metrics['MAE']) * 100
|
| 385 |
-
|
| 386 |
-
recommendation += f"- **{model_name}** : RMSE {rmse_diff:+.1f}%, MAE {mae_diff:+.1f}% vs {self.best_model_name}\n"
|
| 387 |
-
|
| 388 |
-
# Niveau de confiance
|
| 389 |
-
best_r2 = best_metrics['R²']
|
| 390 |
-
if best_r2 > 0.85:
|
| 391 |
-
confidence = "🟢 EXCELLENTE"
|
| 392 |
-
elif best_r2 > 0.70:
|
| 393 |
-
confidence = "🟡 BONNE"
|
| 394 |
-
else:
|
| 395 |
-
confidence = "🔴 MODÉRÉE"
|
| 396 |
-
|
| 397 |
-
recommendation += f"\n#### 🎖️ Niveau de Confiance : {confidence}\n"
|
| 398 |
-
recommendation += f"Le modèle explique **{best_r2*100:.1f}%** de la variance des admissions.\n"
|
| 399 |
-
|
| 400 |
-
return recommendation
|
| 401 |
-
|
| 402 |
-
def plot_predictions_comparison(self):
|
| 403 |
-
"""Visualisation des prédictions des 3 modèles sur le test set"""
|
| 404 |
-
|
| 405 |
-
fig = go.Figure()
|
| 406 |
-
|
| 407 |
-
# Valeurs réelles
|
| 408 |
-
fig.add_trace(go.Scatter(
|
| 409 |
-
x=self.dates_test,
|
| 410 |
-
y=self.y_test,
|
| 411 |
-
mode='lines+markers',
|
| 412 |
-
name='Réel',
|
| 413 |
-
line=dict(color='black', width=3),
|
| 414 |
-
marker=dict(size=6)
|
| 415 |
-
))
|
| 416 |
-
|
| 417 |
-
# Prédictions de chaque modèle
|
| 418 |
-
colors = {'XGBoost': '#1f77b4', 'LightGBM': '#ff7f0e', 'Random Forest': '#2ca02c'}
|
| 419 |
-
|
| 420 |
-
for model_name, y_pred in self.predictions.items():
|
| 421 |
-
is_best = (model_name == self.best_model_name)
|
| 422 |
-
|
| 423 |
-
fig.add_trace(go.Scatter(
|
| 424 |
-
x=self.dates_test,
|
| 425 |
-
y=y_pred,
|
| 426 |
-
mode='lines',
|
| 427 |
-
name=f'{model_name}{" 🏆" if is_best else ""}',
|
| 428 |
-
line=dict(
|
| 429 |
-
color=colors.get(model_name, 'gray'),
|
| 430 |
-
width=3 if is_best else 1.5,
|
| 431 |
-
dash='solid' if is_best else 'dash'
|
| 432 |
-
),
|
| 433 |
-
opacity=1.0 if is_best else 0.6
|
| 434 |
-
))
|
| 435 |
-
|
| 436 |
-
fig.update_layout(
|
| 437 |
-
title="📈 Comparaison des Prédictions sur Test Set",
|
| 438 |
-
xaxis_title="Date",
|
| 439 |
-
yaxis_title="Nombre d'Admissions",
|
| 440 |
-
hovermode='x unified',
|
| 441 |
-
template='plotly_white',
|
| 442 |
-
height=500,
|
| 443 |
-
legend=dict(
|
| 444 |
-
yanchor="top",
|
| 445 |
-
y=0.99,
|
| 446 |
-
xanchor="left",
|
| 447 |
-
x=0.01
|
| 448 |
-
)
|
| 449 |
-
)
|
| 450 |
-
|
| 451 |
-
return fig
|
| 452 |
-
|
| 453 |
-
def plot_metrics_comparison(self):
|
| 454 |
-
"""Graphique radar comparant les métriques des 3 modèles"""
|
| 455 |
-
|
| 456 |
-
# Normalisation des métriques pour le radar (0-100 scale)
|
| 457 |
-
metrics_normalized = {}
|
| 458 |
-
|
| 459 |
-
all_rmse = [m['RMSE'] for m in self.metrics.values()]
|
| 460 |
-
all_mae = [m['MAE'] for m in self.metrics.values()]
|
| 461 |
-
all_r2 = [m['R²'] for m in self.metrics.values()]
|
| 462 |
-
all_mape = [m['MAPE'] for m in self.metrics.values() if not np.isnan(m['MAPE'])]
|
| 463 |
|
| 464 |
-
|
| 465 |
-
max_mae = max(all_mae)
|
| 466 |
-
max_mape = max(all_mape) if all_mape else 100
|
| 467 |
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
rmse_score = (1 - metrics['RMSE'] / max_rmse) * 100
|
| 471 |
-
mae_score = (1 - metrics['MAE'] / max_mae) * 100
|
| 472 |
-
mape_score = (1 - metrics['MAPE'] / max_mape) * 100 if not np.isnan(metrics['MAPE']) else 0
|
| 473 |
-
r2_score = metrics['R²'] * 100 # Déjà entre 0-1
|
| 474 |
-
|
| 475 |
-
metrics_normalized[model_name] = {
|
| 476 |
-
'RMSE': rmse_score,
|
| 477 |
-
'MAE': mae_score,
|
| 478 |
-
'R²': r2_score,
|
| 479 |
-
'MAPE': mape_score
|
| 480 |
-
}
|
| 481 |
-
|
| 482 |
-
# Création du radar chart
|
| 483 |
-
categories = ['RMSE', 'MAE', 'R²', 'MAPE']
|
| 484 |
-
|
| 485 |
-
fig = go.Figure()
|
| 486 |
-
|
| 487 |
-
colors = {'XGBoost': '#1f77b4', 'LightGBM': '#ff7f0e', 'Random Forest': '#2ca02c'}
|
| 488 |
-
|
| 489 |
-
for model_name, norm_metrics in metrics_normalized.items():
|
| 490 |
-
values = [norm_metrics[cat] for cat in categories]
|
| 491 |
-
values.append(values[0]) # Fermeture du radar
|
| 492 |
-
|
| 493 |
-
is_best = (model_name == self.best_model_name)
|
| 494 |
-
|
| 495 |
-
fig.add_trace(go.Scatterpolar(
|
| 496 |
-
r=values,
|
| 497 |
-
theta=categories + [categories[0]],
|
| 498 |
-
name=f'{model_name}{" 🏆" if is_best else ""}',
|
| 499 |
-
fill='toself',
|
| 500 |
-
line=dict(
|
| 501 |
-
color=colors.get(model_name, 'gray'),
|
| 502 |
-
width=3 if is_best else 2
|
| 503 |
-
),
|
| 504 |
-
opacity=0.8 if is_best else 0.4
|
| 505 |
-
))
|
| 506 |
-
|
| 507 |
-
fig.update_layout(
|
| 508 |
-
polar=dict(
|
| 509 |
-
radialaxis=dict(
|
| 510 |
-
visible=True,
|
| 511 |
-
range=[0, 100]
|
| 512 |
-
)
|
| 513 |
-
),
|
| 514 |
-
showlegend=True,
|
| 515 |
-
title="🎯 Radar de Performance des Modèles (Score sur 100)",
|
| 516 |
-
height=500
|
| 517 |
-
)
|
| 518 |
-
|
| 519 |
-
return fig
|
| 520 |
-
|
| 521 |
-
def plot_feature_importance(self, top_n=15):
|
| 522 |
-
"""Importance des features pour le meilleur modèle"""
|
| 523 |
-
|
| 524 |
-
best_model = self.models[self.best_model_name]
|
| 525 |
-
|
| 526 |
-
if hasattr(best_model, 'feature_importances_'):
|
| 527 |
-
importances = best_model.feature_importances_
|
| 528 |
-
|
| 529 |
-
# Création du DataFrame d'importance
|
| 530 |
-
importance_df = pd.DataFrame({
|
| 531 |
-
'Feature': self.feature_names,
|
| 532 |
-
'Importance': importances
|
| 533 |
-
}).sort_values('Importance', ascending=False).head(top_n)
|
| 534 |
-
|
| 535 |
-
fig = px.bar(
|
| 536 |
-
importance_df,
|
| 537 |
-
x='Importance',
|
| 538 |
-
y='Feature',
|
| 539 |
-
orientation='h',
|
| 540 |
-
title=f"🔍 Top {top_n} Features Importantes - {self.best_model_name}",
|
| 541 |
-
color='Importance',
|
| 542 |
-
color_continuous_scale='Blues'
|
| 543 |
-
)
|
| 544 |
-
|
| 545 |
-
fig.update_layout(
|
| 546 |
-
height=500,
|
| 547 |
-
yaxis={'categoryorder': 'total ascending'},
|
| 548 |
-
template='plotly_white'
|
| 549 |
-
)
|
| 550 |
-
|
| 551 |
-
return fig
|
| 552 |
-
else:
|
| 553 |
-
return None
|
| 554 |
-
|
| 555 |
-
def plot_shap_summary(self, max_display=15):
|
| 556 |
-
"""SHAP summary plot pour le meilleur modèle"""
|
| 557 |
-
|
| 558 |
-
if not SHAP_AVAILABLE:
|
| 559 |
-
st.warning("⚠️ SHAP n'est pas installé. Installer avec : `pip install shap`")
|
| 560 |
-
return None
|
| 561 |
|
| 562 |
try:
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
return True
|
| 592 |
-
|
| 593 |
-
except Exception as e:
|
| 594 |
-
st.error(f"Erreur SHAP : {str(e)}")
|
| 595 |
-
return None
|
| 596 |
-
|
| 597 |
-
def plot_residuals(self):
|
| 598 |
-
"""Analyse des résidus pour le meilleur modèle"""
|
| 599 |
-
|
| 600 |
-
best_pred = self.predictions[self.best_model_name]
|
| 601 |
-
residuals = self.y_test - best_pred
|
| 602 |
-
|
| 603 |
-
# Création de subplots
|
| 604 |
-
fig = make_subplots(
|
| 605 |
-
rows=1, cols=2,
|
| 606 |
-
subplot_titles=('Distribution des Résidus', 'Résidus vs Prédictions')
|
| 607 |
-
)
|
| 608 |
-
|
| 609 |
-
# Histogramme des résidus
|
| 610 |
-
fig.add_trace(
|
| 611 |
-
go.Histogram(
|
| 612 |
-
x=residuals,
|
| 613 |
-
name='Résidus',
|
| 614 |
-
marker_color='steelblue',
|
| 615 |
-
nbinsx=30
|
| 616 |
-
),
|
| 617 |
-
row=1, col=1
|
| 618 |
-
)
|
| 619 |
-
|
| 620 |
-
# Scatter résidus vs prédictions
|
| 621 |
-
fig.add_trace(
|
| 622 |
-
go.Scatter(
|
| 623 |
-
x=best_pred,
|
| 624 |
-
y=residuals,
|
| 625 |
-
mode='markers',
|
| 626 |
-
name='Résidus',
|
| 627 |
-
marker=dict(color='coral', size=5, opacity=0.6)
|
| 628 |
-
),
|
| 629 |
-
row=1, col=2
|
| 630 |
-
)
|
| 631 |
-
|
| 632 |
-
# Ligne horizontale à 0
|
| 633 |
-
fig.add_hline(y=0, line_dash="dash", line_color="red", row=1, col=2)
|
| 634 |
-
|
| 635 |
-
fig.update_xaxes(title_text="Résidus", row=1, col=1)
|
| 636 |
-
fig.update_xaxes(title_text="Prédictions", row=1, col=2)
|
| 637 |
-
fig.update_yaxes(title_text="Fréquence", row=1, col=1)
|
| 638 |
-
fig.update_yaxes(title_text="Résidus", row=1, col=2)
|
| 639 |
-
|
| 640 |
-
fig.update_layout(
|
| 641 |
-
title_text=f"📊 Analyse des Résidus - {self.best_model_name}",
|
| 642 |
-
height=400,
|
| 643 |
-
showlegend=False,
|
| 644 |
-
template='plotly_white'
|
| 645 |
-
)
|
| 646 |
-
|
| 647 |
-
return fig
|
| 648 |
|
|
|
|
|
|
|
| 649 |
|
| 650 |
# ============================================================================
|
| 651 |
-
# INTERFACE STREAMLIT (
|
| 652 |
# ============================================================================
|
| 653 |
|
| 654 |
-
def show_forecasting_module():
|
| 655 |
-
""
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
"""
|
| 659 |
-
|
| 660 |
-
st.markdown("## 🎯 FORECASTING ENGINE")
|
| 661 |
-
st.caption("Entraînement, comparaison et sélection automatique du meilleur modèle ML.")
|
| 662 |
-
st.divider()
|
| 663 |
-
|
| 664 |
-
# Vérification des features dans session_state
|
| 665 |
if 'ml_features' not in st.session_state:
|
| 666 |
-
st.error("❌
|
| 667 |
-
st.info("💡 Veuillez d'abord exécuter le module **Feature Engineering** pour générer les features.")
|
| 668 |
-
|
| 669 |
-
if st.button("🔄 Aller au Feature Engineering"):
|
| 670 |
-
st.switch_page("pages/03_Features.py") # Adapter selon votre structure
|
| 671 |
return
|
| 672 |
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
st.success(f"✅ Dataset ML chargé : **{len(ml_dataset):,}** échantillons")
|
| 678 |
-
|
| 679 |
-
col1, col2, col3 = st.columns(3)
|
| 680 |
-
col1.metric("Features Disponibles", len(ml_dataset.columns))
|
| 681 |
-
col2.metric("Période", f"{ml_dataset['date'].min().strftime('%Y-%m-%d')} → {ml_dataset['date'].max().strftime('%Y-%m-%d')}")
|
| 682 |
-
col3.metric("Services", ml_dataset['service_code'].nunique() if 'service_code' in ml_dataset.columns else "N/A")
|
| 683 |
-
|
| 684 |
-
st.divider()
|
| 685 |
-
|
| 686 |
-
# Configuration
|
| 687 |
-
with st.expander("⚙️ Configuration de l'Entraînement"):
|
| 688 |
col1, col2 = st.columns(2)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 689 |
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
options=['nb_admissions', 'taux_occupation', 'tension_personnel', 'lits_occupes'],
|
| 694 |
-
index=0,
|
| 695 |
-
help="Variable à prédire"
|
| 696 |
-
)
|
| 697 |
-
|
| 698 |
-
with col2:
|
| 699 |
-
test_size = st.slider(
|
| 700 |
-
"Taille Test Set (%)",
|
| 701 |
-
min_value=10,
|
| 702 |
-
max_value=30,
|
| 703 |
-
value=15,
|
| 704 |
-
step=5,
|
| 705 |
-
help="Proportion des données pour le test final"
|
| 706 |
-
)
|
| 707 |
-
|
| 708 |
-
# Bouton d'entraînement
|
| 709 |
-
if st.button("🚀 **LANCER L'ENTRAÎNEMENT DES 3 MODÈLES**", type="primary", use_container_width=True):
|
| 710 |
-
|
| 711 |
-
# Initialisation du forecaster
|
| 712 |
-
forecaster = HospitalForecaster(ml_dataset)
|
| 713 |
-
|
| 714 |
-
# Entraînement
|
| 715 |
-
with st.spinner("🔥 Entraînement en cours..."):
|
| 716 |
-
metrics = forecaster.train_all_models()
|
| 717 |
-
|
| 718 |
-
# Stockage dans session_state
|
| 719 |
-
st.session_state['forecaster'] = forecaster
|
| 720 |
-
st.session_state['metrics'] = metrics
|
| 721 |
-
|
| 722 |
-
st.success("✅ **Entraînement terminé avec succès !**")
|
| 723 |
-
st.balloons()
|
| 724 |
-
|
| 725 |
-
# Affichage des résultats si déjà entraîné
|
| 726 |
-
if 'forecaster' in st.session_state:
|
| 727 |
-
forecaster = st.session_state['forecaster']
|
| 728 |
-
metrics = st.session_state['metrics']
|
| 729 |
-
|
| 730 |
-
st.divider()
|
| 731 |
-
|
| 732 |
-
# ========== RECOMMANDATION DU MEILLEUR MODÈLE ==========
|
| 733 |
-
st.markdown("---")
|
| 734 |
-
recommendation = forecaster.get_best_model_recommendation()
|
| 735 |
-
st.markdown(recommendation)
|
| 736 |
-
st.markdown("---")
|
| 737 |
-
|
| 738 |
-
# ========== TABLEAU COMPARATIF ==========
|
| 739 |
-
st.subheader("📊 Tableau Comparatif des Performances")
|
| 740 |
-
|
| 741 |
-
metrics_df = pd.DataFrame(metrics).T
|
| 742 |
-
metrics_df = metrics_df.round(4)
|
| 743 |
-
|
| 744 |
-
# Mise en évidence du meilleur modèle
|
| 745 |
-
def highlight_best(s):
|
| 746 |
-
if s.name == forecaster.best_model_name:
|
| 747 |
-
return ['background-color: #90EE90'] * len(s)
|
| 748 |
-
else:
|
| 749 |
-
return [''] * len(s)
|
| 750 |
-
|
| 751 |
-
styled_metrics = metrics_df.style.apply(highlight_best, axis=1)
|
| 752 |
-
st.dataframe(styled_metrics, use_container_width=True)
|
| 753 |
-
|
| 754 |
-
# KPIs du meilleur modèle
|
| 755 |
-
st.subheader(f"🏆 KPIs - {forecaster.best_model_name}")
|
| 756 |
-
kpi1, kpi2, kpi3, kpi4 = st.columns(4)
|
| 757 |
-
|
| 758 |
-
best_metrics = metrics[forecaster.best_model_name]
|
| 759 |
-
kpi1.metric("RMSE", f"{best_metrics['RMSE']:.2f}", help="Root Mean Squared Error")
|
| 760 |
-
kpi2.metric("MAE", f"{best_metrics['MAE']:.2f}", help="Mean Absolute Error")
|
| 761 |
-
kpi3.metric("R²", f"{best_metrics['R²']:.4f}", help="Coefficient de détermination")
|
| 762 |
-
kpi4.metric("MAPE", f"{best_metrics['MAPE']:.2f}%", help="Mean Absolute Percentage Error")
|
| 763 |
-
|
| 764 |
-
st.divider()
|
| 765 |
|
| 766 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 767 |
|
| 768 |
-
#
|
| 769 |
-
|
| 770 |
-
fig_pred = forecaster.plot_predictions_comparison()
|
| 771 |
-
st.plotly_chart(fig_pred, use_container_width=True)
|
| 772 |
|
| 773 |
-
#
|
| 774 |
-
|
| 775 |
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
|
|
|
|
|
|
| 780 |
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
|
|
|
| 785 |
|
| 786 |
-
#
|
| 787 |
-
st.
|
| 788 |
-
|
| 789 |
-
if fig_importance:
|
| 790 |
-
st.plotly_chart(fig_importance, use_container_width=True)
|
| 791 |
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
| 799 |
|
| 800 |
-
|
| 801 |
-
st.divider()
|
| 802 |
-
st.subheader("💾 Téléchargement des Résultats")
|
| 803 |
|
| 804 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 805 |
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
)
|
| 815 |
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
if
|
| 839 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
MODULE: FORECASTING ENGINE (GOTHAM UI - EXPERT EDITION)
|
| 3 |
+
=======================================================
|
| 4 |
+
Responsabilité : Entraînement ML, Audit Comparatif (3 Modèles) et Prédiction.
|
| 5 |
+
Fonctionnalités : Comparaison Visuelle, SHAP, et Projection Multi-Horizon.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
import pandas as pd
|
| 9 |
import numpy as np
|
| 10 |
import streamlit as st
|
|
|
|
| 11 |
import plotly.graph_objects as go
|
| 12 |
+
from datetime import timedelta
|
|
|
|
| 13 |
import warnings
|
| 14 |
+
import copy
|
| 15 |
|
| 16 |
# ML Models
|
| 17 |
from sklearn.ensemble import RandomForestRegressor
|
| 18 |
from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score
|
|
|
|
| 19 |
import xgboost as xgb
|
| 20 |
import lightgbm as lgb
|
| 21 |
|
| 22 |
+
# Sécurité SHAP (Graphiques d'interprétabilité)
|
| 23 |
try:
|
| 24 |
import shap
|
| 25 |
+
import matplotlib.pyplot as plt # Requis pour certains plots SHAP
|
| 26 |
SHAP_AVAILABLE = True
|
| 27 |
except ImportError:
|
| 28 |
SHAP_AVAILABLE = False
|
| 29 |
|
| 30 |
+
warnings.filterwarnings('ignore')
|
| 31 |
|
| 32 |
class HospitalForecaster:
|
|
|
|
|
|
|
| 33 |
def __init__(self, ml_dataset):
|
| 34 |
self.df = ml_dataset.copy()
|
| 35 |
+
self.df['date'] = pd.to_datetime(self.df['date'])
|
| 36 |
self.models = {}
|
|
|
|
| 37 |
self.metrics = {}
|
| 38 |
+
self.test_predictions = {} # Pour stocker les courbes des 3 modèles
|
| 39 |
self.best_model_name = None
|
| 40 |
+
self.last_train_date = self.df['date'].max()
|
| 41 |
|
| 42 |
+
def prepare_data(self, target_col='nb_admissions', test_days=60): # 60 jours pour bien voir la comparaison
|
| 43 |
+
"""Split Temporel pour validation"""
|
| 44 |
+
df = self.df.sort_values('date').reset_index(drop=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
+
exclude_cols = [target_col, 'date', 'stay_id', 'patient_id']
|
| 47 |
+
feature_cols = [c for c in df.columns if c not in exclude_cols and np.issubdtype(df[c].dtype, np.number)]
|
| 48 |
|
| 49 |
+
split_idx = len(df) - test_days
|
|
|
|
| 50 |
|
| 51 |
+
X = df[feature_cols]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
y = df[target_col]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
+
self.X_train = X.iloc[:split_idx]
|
| 55 |
+
self.X_test = X.iloc[split_idx:]
|
| 56 |
+
self.y_train = y.iloc[:split_idx]
|
| 57 |
+
self.y_test = y.iloc[split_idx:]
|
| 58 |
+
self.dates_test = df['date'].iloc[split_idx:]
|
| 59 |
+
self.feature_names = feature_cols
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
+
return self.X_train, self.X_test, self.y_train, self.y_test
|
| 62 |
+
|
| 63 |
+
def train_models(self):
|
| 64 |
+
"""Entraîne les 3 champions et stocke leurs prédictions pour comparaison"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
+
# 1. LightGBM
|
| 67 |
+
lgb_model = lgb.LGBMRegressor(n_estimators=300, learning_rate=0.05, verbose=-1, random_state=42)
|
| 68 |
+
lgb_model.fit(self.X_train, self.y_train)
|
| 69 |
+
self.models['LightGBM'] = lgb_model
|
|
|
|
|
|
|
| 70 |
|
| 71 |
+
# 2. XGBoost
|
| 72 |
+
xgb_model = xgb.XGBRegressor(n_estimators=300, learning_rate=0.05, n_jobs=-1, random_state=42)
|
| 73 |
+
xgb_model.fit(self.X_train, self.y_train)
|
| 74 |
+
self.models['XGBoost'] = xgb_model
|
|
|
|
| 75 |
|
| 76 |
# 3. Random Forest
|
| 77 |
+
rf_model = RandomForestRegressor(n_estimators=100, max_depth=10, n_jobs=-1, random_state=42)
|
| 78 |
+
rf_model.fit(self.X_train, self.y_train)
|
| 79 |
+
self.models['Random Forest'] = rf_model
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
+
# Évaluation Comparative
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
scores = {}
|
| 83 |
+
for name, model in self.models.items():
|
| 84 |
+
# On stocke la prédiction sur le test set pour le graphique
|
| 85 |
+
pred = model.predict(self.X_test)
|
| 86 |
+
self.test_predictions[name] = pred
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
+
# Métriques
|
| 89 |
+
rmse = np.sqrt(mean_squared_error(self.y_test, pred))
|
| 90 |
+
mae = mean_absolute_error(self.y_test, pred)
|
| 91 |
+
r2 = r2_score(self.y_test, pred)
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
+
self.metrics[name] = {'RMSE': rmse, 'MAE': mae, 'R2': r2}
|
| 94 |
+
scores[name] = rmse
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
+
self.best_model_name = min(scores, key=scores.get)
|
| 97 |
+
return self.metrics
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
+
def compute_shap(self):
|
| 100 |
+
"""Calcule l'importance des features via SHAP (sur le meilleur modèle)"""
|
| 101 |
+
if not SHAP_AVAILABLE: return None, None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
+
model = self.models[self.best_model_name]
|
|
|
|
|
|
|
| 104 |
|
| 105 |
+
# On échantillonne pour la vitesse (max 200 points)
|
| 106 |
+
X_sample = self.X_test.sample(n=min(200, len(self.X_test)), random_state=42)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
try:
|
| 109 |
+
# TreeExplainer est optimisé pour XGB/LGBM/RF
|
| 110 |
+
explainer = shap.TreeExplainer(model)
|
| 111 |
+
shap_values = explainer.shap_values(X_sample)
|
| 112 |
+
return explainer, shap_values, X_sample
|
| 113 |
+
except:
|
| 114 |
+
return None, None, None
|
| 115 |
+
|
| 116 |
+
def generate_future_forecast(self, months=3):
|
| 117 |
+
"""Prédiction Récursive vers le Futur"""
|
| 118 |
+
days = months * 30
|
| 119 |
+
future_dates = [self.last_train_date + timedelta(days=i) for i in range(1, days + 1)]
|
| 120 |
+
model = self.models[self.best_model_name]
|
| 121 |
+
|
| 122 |
+
# Reconstruction simplifiée des vecteurs futurs (Vectorisée pour vitesse)
|
| 123 |
+
future_df = pd.DataFrame({'date': future_dates})
|
| 124 |
+
future_df['jour_semaine'] = future_df['date'].dt.dayofweek
|
| 125 |
+
future_df['mois'] = future_df['date'].dt.month
|
| 126 |
+
future_df['mois_sin'] = np.sin(2 * np.pi * future_df['mois']/12)
|
| 127 |
+
future_df['mois_cos'] = np.cos(2 * np.pi * future_df['mois']/12)
|
| 128 |
+
|
| 129 |
+
# Moyenne des autres features (Hypothèse de stabilité)
|
| 130 |
+
mean_values = self.df[self.feature_names].mean()
|
| 131 |
+
X_future = pd.DataFrame([mean_values] * len(future_df), columns=self.feature_names)
|
| 132 |
+
|
| 133 |
+
# Update temporel
|
| 134 |
+
for col in ['jour_semaine', 'mois_sin', 'mois_cos']:
|
| 135 |
+
if col in X_future.columns:
|
| 136 |
+
X_future[col] = future_df[col].values
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
+
y_future = model.predict(X_future)
|
| 139 |
+
return future_dates, y_future
|
| 140 |
|
| 141 |
# ============================================================================
|
| 142 |
+
# INTERFACE STREAMLIT (UPDATE : NON-TECH FRIENDLY)
|
| 143 |
# ============================================================================
|
| 144 |
|
| 145 |
+
def show_forecasting_module(client, sheet_name):
|
| 146 |
+
st.markdown("## 🔮 ORACLE : FORECASTING & AUDIT")
|
| 147 |
+
st.caption("Intelligence Artificielle de prédiction des flux hospitaliers.")
|
| 148 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
if 'ml_features' not in st.session_state:
|
| 150 |
+
st.error("❌ Données ML absentes. Allez dans 'Features Eng.' d'abord.")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
return
|
| 152 |
|
| 153 |
+
df = st.session_state['ml_features']
|
| 154 |
+
|
| 155 |
+
# --- 1. CONFIGURATION RAPIDE ---
|
| 156 |
+
with st.expander("⚙️ Paramètres", expanded=False):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
col1, col2 = st.columns(2)
|
| 158 |
+
target = col1.selectbox("Cible à Prédire", ['nb_admissions', 'taux_occupation'], index=0)
|
| 159 |
+
test_days = col2.slider("Période de Test (Jours)", 30, 90, 60)
|
| 160 |
+
|
| 161 |
+
# --- 2. ENTRAÎNEMENT (AUTO) ---
|
| 162 |
+
if 'forecaster' not in st.session_state:
|
| 163 |
+
forecaster = HospitalForecaster(df)
|
| 164 |
+
with st.spinner("🧠 Le système compare 3 intelligences (XGBoost, LightGBM, RF)..."):
|
| 165 |
+
forecaster.prepare_data(target_col=target, test_days=test_days)
|
| 166 |
+
metrics = forecaster.train_models()
|
| 167 |
+
st.session_state['forecaster'] = forecaster
|
| 168 |
+
st.session_state['metrics'] = metrics
|
| 169 |
+
st.rerun()
|
| 170 |
+
|
| 171 |
+
forecaster = st.session_state['forecaster']
|
| 172 |
+
metrics = st.session_state['metrics']
|
| 173 |
+
best = forecaster.best_model_name
|
| 174 |
+
best_mae = metrics[best]['MAE']
|
| 175 |
+
|
| 176 |
+
# --- 3. AFFICHAGE PAR ONGLETS ---
|
| 177 |
+
tab_future, tab_audit = st.tabs(["🔮 Le Futur (Décision)", "⚖️ Le Verdict (Preuve)"])
|
| 178 |
+
|
| 179 |
+
# === ONGLET 1 : LE FUTUR ===
|
| 180 |
+
with tab_future:
|
| 181 |
+
st.markdown(f"### 🚀 Projections : {target}")
|
| 182 |
|
| 183 |
+
# Sélecteur d'horizon
|
| 184 |
+
horizon_label = st.radio("Voir dans...", ["3 Mois", "6 Mois", "9 Mois", "12 Mois"], horizontal=True)
|
| 185 |
+
months_map = {"3 Mois": 3, "6 Mois": 6, "9 Mois": 9, "12 Mois": 12}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
|
| 187 |
+
with st.spinner("Calcul de la trajectoire optimale..."):
|
| 188 |
+
f_dates, f_values = forecaster.generate_future_forecast(months_map[horizon_label])
|
| 189 |
+
|
| 190 |
+
# Graphique Futur
|
| 191 |
+
fig = go.Figure()
|
| 192 |
+
hist = df.iloc[-90:] # 3 derniers mois historiques
|
| 193 |
|
| 194 |
+
# Zone Historique
|
| 195 |
+
fig.add_trace(go.Scatter(x=hist['date'], y=hist[target], name="Passé Réel", line=dict(color='gray'), opacity=0.5))
|
|
|
|
|
|
|
| 196 |
|
| 197 |
+
# Zone Futur
|
| 198 |
+
fig.add_trace(go.Scatter(x=f_dates, y=f_values, name="Projection IA", line=dict(color='#00E676', width=3)))
|
| 199 |
|
| 200 |
+
# Ligne verticale "Aujourd'hui"
|
| 201 |
+
fig.add_vline(x=hist['date'].max(), line_width=1, line_dash="dash", line_color="white")
|
| 202 |
+
fig.add_annotation(x=hist['date'].max(), y=hist[target].mean(), text="Aujourd'hui", showarrow=True, arrowhead=1)
|
| 203 |
+
|
| 204 |
+
fig.update_layout(title=f"Horizon {horizon_label}", template="plotly_dark", height=450, hovermode="x unified")
|
| 205 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 206 |
|
| 207 |
+
st.caption(f"Prédiction générée par le modèle **{best}** (Fiabilité : {metrics[best]['R2']*100:.0f}%)")
|
| 208 |
+
|
| 209 |
+
# === ONGLET 2 : LE VERDICT (Pour les non-techs) ===
|
| 210 |
+
with tab_audit:
|
| 211 |
+
st.markdown("### 🏆 Qui est le meilleur prédicteur ?")
|
| 212 |
|
| 213 |
+
# --- A. LE VERDICT EN FRANÇAIS ---
|
| 214 |
+
st.info(f"""
|
| 215 |
+
**Résultat du match :** L'intelligence artificielle **{best}** a battu les autres modèles.
|
|
|
|
|
|
|
| 216 |
|
| 217 |
+
🔎 **Pourquoi ?** Sur les {test_days} derniers jours, elle a suivi la courbe réelle avec le plus de précision.
|
| 218 |
+
En moyenne, elle se trompe de seulement **{best_mae:.1f}** unités par jour (contre {metrics['Random Forest']['MAE']:.1f} pour le moins bon).
|
| 219 |
+
""")
|
| 220 |
+
|
| 221 |
+
# --- B. PREUVE VISUELLE ---
|
| 222 |
+
st.markdown("#### 👁️ Preuve Visuelle : Modèles vs Réalité")
|
| 223 |
+
st.caption("Regardez quelle ligne colorée suit le mieux la ligne blanche pointillée (La Réalité).")
|
| 224 |
|
| 225 |
+
fig_comp = go.Figure()
|
|
|
|
|
|
|
| 226 |
|
| 227 |
+
# 1. La Réalité (Blanche, très visible)
|
| 228 |
+
fig_comp.add_trace(go.Scatter(
|
| 229 |
+
x=forecaster.dates_test, y=forecaster.y_test,
|
| 230 |
+
name="🔴 LA RÉALITÉ", line=dict(color='white', width=4, dash='dot')
|
| 231 |
+
))
|
| 232 |
|
| 233 |
+
# 2. Le Vainqueur (Vert fluo)
|
| 234 |
+
best_pred = forecaster.test_predictions[best]
|
| 235 |
+
fig_comp.add_trace(go.Scatter(
|
| 236 |
+
x=forecaster.dates_test, y=best_pred,
|
| 237 |
+
name=f"🏆 {best} (Gagnant)",
|
| 238 |
+
line=dict(color='#00E676', width=3),
|
| 239 |
+
opacity=0.9
|
| 240 |
+
))
|
|
|
|
| 241 |
|
| 242 |
+
# 3. Les Perdants (Grisés pour ne pas polluer)
|
| 243 |
+
for name, pred in forecaster.test_predictions.items():
|
| 244 |
+
if name != best:
|
| 245 |
+
fig_comp.add_trace(go.Scatter(
|
| 246 |
+
x=forecaster.dates_test, y=pred,
|
| 247 |
+
name=name,
|
| 248 |
+
line=dict(color='gray', width=1),
|
| 249 |
+
opacity=0.4
|
| 250 |
+
))
|
| 251 |
|
| 252 |
+
fig_comp.update_layout(template="plotly_dark", height=450)
|
| 253 |
+
st.plotly_chart(fig_comp, use_container_width=True)
|
| 254 |
+
|
| 255 |
+
# --- C. SHAP (Bonus Explicatif) ---
|
| 256 |
+
col_why, col_reset = st.columns([3, 1])
|
| 257 |
+
with col_why:
|
| 258 |
+
if SHAP_AVAILABLE:
|
| 259 |
+
with st.expander("🧠 Comprendre le cerveau de l'IA (Pourquoi ces chiffres ?)"):
|
| 260 |
+
st.write("Ce graphique montre ce qui influence le plus la hausse ou la baisse des admissions :")
|
| 261 |
+
if st.button("Calculer les Facteurs d'Influence"):
|
| 262 |
+
with st.spinner("Analyse..."):
|
| 263 |
+
explainer, shap_vals, X_samp = forecaster.compute_shap()
|
| 264 |
+
if shap_vals is not None:
|
| 265 |
+
fig_shap, ax = plt.subplots(figsize=(8, 5))
|
| 266 |
+
fig_shap.patch.set_facecolor('#0e1117')
|
| 267 |
+
ax.set_facecolor('#0e1117')
|
| 268 |
+
shap.summary_plot(shap_vals, X_samp, max_display=8, plot_type="bar", show=False)
|
| 269 |
+
ax.tick_params(colors='white', labelsize=10)
|
| 270 |
+
ax.xaxis.label.set_color('white')
|
| 271 |
+
st.pyplot(fig_shap)
|
| 272 |
+
|
| 273 |
+
with col_reset:
|
| 274 |
+
if st.button("♻️ Reset"):
|
| 275 |
+
del st.session_state['forecaster']
|
| 276 |
+
st.rerun()
|