Spaces:
Running
Running
Download src/Analytics/ModelML.py from klydekushy/Vortex-Flux: direct link, hf CLI and curl.
- Browser
- Download file 10 kB
-
https://huggingface.co/spaces/klydekushy/Vortex-Flux/resolve/main/src/Analytics/ModelML.py
- Command line
-
hf download hf://spaces/klydekushy/Vortex-Flux/src/Analytics/ModelML.py
-
curl -L -o ModelML.py https://huggingface.co/spaces/klydekushy/Vortex-Flux/resolve/main/src/Analytics/ModelML.py
10 kB
| import pandas as pd | |
| import numpy as np | |
| import xgboost as xgb | |
| from sklearn.pipeline import Pipeline | |
| from sklearn.compose import ColumnTransformer | |
| from sklearn.preprocessing import OneHotEncoder | |
| from sklearn.impute import SimpleImputer | |
| from datetime import datetime | |
| import unicodedata | |
| # ============================================================================ | |
| # FONCTIONS DE NETTOYAGE (PREPROCESSING) | |
| # ============================================================================ | |
| def clean_number(x): | |
| if pd.isna(x) or str(x).strip() == "": return np.nan | |
| try: | |
| return float(str(x).replace('XOF', '').replace(' ', '').replace(',', '.')) | |
| except (ValueError, TypeError): | |
| return np.nan | |
| def parse_date(date_str): | |
| if pd.isna(date_str) or str(date_str).strip() == "": return pd.NaT | |
| try: | |
| return pd.to_datetime(str(date_str).split(' ')[0], format='%d-%m-%Y', errors='raise') | |
| except (ValueError, TypeError): | |
| return pd.to_datetime(str(date_str), dayfirst=True, errors='coerce') | |
| def calcul_age(date_str): | |
| naissance = parse_date(date_str) | |
| if pd.isna(naissance): return np.nan | |
| return (datetime.now() - naissance).days / 365.25 | |
| def normalize_text(text): | |
| if pd.isna(text) or str(text).strip() == "": return "inconnu" | |
| text = str(text).strip().lower() | |
| text = unicodedata.normalize('NFD', text).encode('ascii', 'ignore').decode('utf-8') | |
| return text | |
| # ============================================================================ | |
| # PIPELINE MACHINE LEARNING | |
| # ============================================================================ | |
| def build_and_train_xgb(df_master, df_update, df_clients, df_garants, df_remb=None): | |
| if df_remb is None: | |
| df_remb = pd.DataFrame() | |
| # 1. CONSOLIDATION HISTORIQUE ROBUSTE | |
| df_loans = df_master.copy() | |
| if not df_update.empty and 'ID_Pret' in df_update.columns: | |
| df_combined = pd.concat([df_master, df_update], ignore_index=True) | |
| date_col = 'Date_Modification' if 'Date_Modification' in df_combined.columns else 'Date_Update' | |
| df_combined['Date_Tri'] = pd.to_datetime(df_combined[date_col], format='%d-%m-%Y %H:%M:%S', errors='coerce') | |
| if 'Version' in df_combined.columns: | |
| df_combined['Version_Num'] = pd.to_numeric(df_combined['Version'], errors='coerce').fillna(1.0) | |
| else: | |
| df_combined['Version_Num'] = 1.0 | |
| df_loans = df_combined.sort_values(by=['Date_Tri', 'Version_Num'], na_position='first').drop_duplicates(subset=['ID_Pret'], keep='last') | |
| df_loans['Taux_Hebdo'] = df_loans['Taux_Hebdo'].apply(clean_number) | |
| df_loans = df_loans.dropna(subset=['Taux_Hebdo']) | |
| # 2. CALCUL DE L'HISTORIQUE CLIENT | |
| df_loans['Montant_Capital_Num'] = df_loans['Montant_Capital'].apply(clean_number) | |
| history = df_loans.groupby('ID_Client').agg( | |
| Nb_Prets_Historique=('ID_Pret', 'count') | |
| ).reset_index() | |
| history['Nb_Prets_Historique'] = (history['Nb_Prets_Historique'] - 1).clip(lower=0) | |
| # 3. JOINTURES DE BASE | |
| df_merged = df_loans.merge(history, on='ID_Client', how='left') | |
| df_merged = df_merged.merge(df_clients, on='ID_Client', how='left') | |
| if not df_garants.empty and 'ID_Garant' in df_loans.columns: | |
| df_merged = df_merged.merge(df_garants, on='ID_Garant', how='left', suffixes=('', '_Garant')) | |
| else: | |
| for col in ['Revenus_Mensuels_Garant']: | |
| df_merged[col] = np.nan | |
| # 4. INTÉGRATION DES COMPORTEMENTS DE PAIEMENT (Feuille Remboursements) | |
| if not df_remb.empty and 'ID_Client' in df_remb.columns and 'Jours_Retard' in df_remb.columns: | |
| df_remb['Jours_Retard_Num'] = df_remb['Jours_Retard'].apply(clean_number).fillna(0.0) | |
| client_remb = df_remb.groupby('ID_Client').agg( | |
| Retard_Moyen=('Jours_Retard_Num', 'mean'), | |
| Ratio_Echeances_Retard=('Jours_Retard_Num', lambda x: (x > 0).mean()) | |
| ).reset_index() | |
| df_merged = df_merged.merge(client_remb, on='ID_Client', how='left') | |
| else: | |
| df_merged['Retard_Moyen'] = 0.0 | |
| df_merged['Ratio_Echeances_Retard'] = 0.0 | |
| df_merged['Retard_Moyen'] = df_merged['Retard_Moyen'].fillna(0.0) | |
| df_merged['Ratio_Echeances_Retard'] = df_merged['Ratio_Echeances_Retard'].fillna(0.0) | |
| # 5. FEATURE ENGINEERING GLOBAL | |
| df_merged['A_Un_Garant'] = df_merged['ID_Garant'].apply(lambda x: 0 if pd.isna(x) or str(x).strip() == "" else 1) | |
| # Exercice du pret (annee de deblocage) : le modele apprend une eventuelle | |
| # derive des taux d'une annee a l'autre au lieu de melanger les exercices. | |
| from core.exercice import annee_de, exercice_courant | |
| if 'Date_Deblocage' in df_merged.columns: | |
| df_merged['Annee_Deblocage'] = annee_de(df_merged['Date_Deblocage']).astype('float') | |
| else: | |
| df_merged['Annee_Deblocage'] = np.nan | |
| df_merged['Nb_Versements'] = df_merged['Nb_Versements'].apply(clean_number).fillna(1.0) | |
| # Numériques - Client | |
| df_merged['Age'] = df_merged['Date_Naissance'].apply(calcul_age) | |
| df_merged['Anciennete_Emploi'] = df_merged['Anciennete_Emploi'].apply(clean_number) | |
| df_merged['Anciennete_Revenu'] = df_merged['Anciennete_Revenu'].apply(clean_number) | |
| df_merged['Revenus_Mensuels'] = df_merged['Revenus_Mensuels'].apply(clean_number).fillna(0) | |
| df_merged['Autres_Revenus'] = df_merged['Autres_Revenus'].apply(clean_number).fillna(0) | |
| df_merged['Revenus_Totaux'] = df_merged['Revenus_Mensuels'] + df_merged['Autres_Revenus'] | |
| df_merged['Charges_Estimees'] = df_merged['Charges_Estimees'].apply(clean_number).fillna(0) | |
| df_merged['Pers_Charge'] = df_merged['Pers_Charge'].apply(clean_number) | |
| df_merged['Endettement_Initial'] = np.where(df_merged['Revenus_Totaux'] > 0, (df_merged['Charges_Estimees'] / df_merged['Revenus_Totaux']) * 100, 0) | |
| # Ingénierie du Garant | |
| df_merged['Revenus_Mensuels_Garant'] = df_merged['Revenus_Mensuels_Garant'].apply(clean_number).fillna(0) | |
| df_merged['Ratio_Garant_Client'] = np.where( | |
| df_merged['Revenus_Mensuels'] > 0, | |
| df_merged['Revenus_Mensuels_Garant'] / df_merged['Revenus_Mensuels'], | |
| 0.0 | |
| ) | |
| # Textes (Normalisation) | |
| text_cols = ['Offre', 'Type_Pret', 'Motif', 'Statut_Pro', 'Profession', 'Statut_Logement', 'Etat_Civil'] | |
| for col in text_cols: | |
| if col not in df_merged.columns: df_merged[col] = "inconnu" | |
| df_merged[col] = df_merged[col].apply(normalize_text) | |
| # 6. SÉLECTION DES VARIABLES FINALES | |
| cat_features = text_cols | |
| num_features = [ | |
| 'Montant_Capital_Num', 'Duree_Semaines', 'Nb_Prets_Historique', | |
| 'Retard_Moyen', 'Ratio_Echeances_Retard', 'Nb_Versements', | |
| 'Age', 'Anciennete_Emploi', 'Anciennete_Revenu', 'Revenus_Totaux', 'Endettement_Initial', | |
| 'Pers_Charge', 'A_Un_Garant', 'Ratio_Garant_Client', 'Annee_Deblocage' | |
| ] | |
| X = df_merged[cat_features + num_features] | |
| y = df_merged['Taux_Hebdo'] | |
| if len(X) < 3: return None, None | |
| # Dossier "type" (medianes numeriques / modes categoriels) sur l'exact | |
| # espace de features utilise a l'entrainement, pour permettre une | |
| # prediction de sanite (tendance suggeree) sans dupliquer l'ingenierie | |
| # de features ci-dessus. | |
| dossier_type = {col: X[col].median() for col in num_features} | |
| for col in cat_features: | |
| mode = X[col].mode() | |
| dossier_type[col] = mode.iloc[0] if not mode.empty else "inconnu" | |
| # Un dossier "type" est toujours evalue pour l'exercice en cours. | |
| dossier_type['Annee_Deblocage'] = float(exercice_courant()) | |
| # 7. PIPELINE SCILIKT-LEARN | |
| numeric_transformer = SimpleImputer(strategy='median') | |
| categorical_transformer = Pipeline(steps=[ | |
| ('imputer', SimpleImputer(strategy='constant', fill_value='inconnu')), | |
| ('onehot', OneHotEncoder(handle_unknown='ignore')) | |
| ]) | |
| preprocessor = ColumnTransformer(transformers=[ | |
| ('num', numeric_transformer, num_features), | |
| ('cat', categorical_transformer, cat_features) | |
| ]) | |
| model = Pipeline(steps=[ | |
| ('preprocessor', preprocessor), | |
| ('regressor', xgb.XGBRegressor(n_estimators=50, max_depth=3, learning_rate=0.1, random_state=42)) | |
| ]) | |
| model.fit(X, y) | |
| model.vortex_erreur_par_exercice = erreur_par_exercice(model, X, y) | |
| return model, dossier_type | |
| def erreur_par_exercice(model, X, y, n_plis=3): | |
| """Erreur absolue moyenne (points de taux hebdo) des predictions HORS | |
| ECHANTILLON (validation croisee), detaillee par exercice de deblocage : | |
| {annee: {"nb_prets": n, "mae_taux_hebdo": e}}. Dict vide si trop peu de | |
| donnees. Sert a verifier que le modele reste fiable d'une annee a l'autre.""" | |
| try: | |
| from sklearn.base import clone | |
| from sklearn.model_selection import KFold, cross_val_predict | |
| if len(X) < max(6, n_plis * 2): | |
| return {} | |
| pred = cross_val_predict(clone(model), X, y, cv=KFold(n_splits=n_plis, shuffle=True, random_state=42)) | |
| erreurs = pd.DataFrame({"annee": X['Annee_Deblocage'].values, "err": np.abs(np.asarray(y, dtype=float) - pred)}) | |
| erreurs = erreurs.dropna(subset=["annee"]) | |
| return {int(a): {"nb_prets": int(len(g)), "mae_taux_hebdo": round(float(g["err"].mean()), 3)} | |
| for a, g in erreurs.groupby("annee")} | |
| except Exception: | |
| return {} | |
| def predire_taux_xgboost(modele, dossier_dict): | |
| """Transforme le dictionnaire en DataFrame avec normalisation des textes à la volée""" | |
| df_new = pd.DataFrame([dossier_dict]) | |
| if 'Annee_Deblocage' not in df_new.columns: | |
| # Dossier sans date : il est evalue pour l'exercice en cours. | |
| from core.exercice import exercice_courant | |
| df_new['Annee_Deblocage'] = float(exercice_courant()) | |
| for col in ['Offre', 'Type_Pret', 'Motif', 'Statut_Pro', 'Profession', 'Statut_Logement', 'Etat_Civil']: | |
| if col in df_new.columns: | |
| df_new[col] = df_new[col].apply(normalize_text) | |
| return float(modele.predict(df_new)[0]) |