Spaces:
Running
Running
File size: 10,029 Bytes
1c43a9f b5af1da a3f0108 82c087a 1c43a9f b5af1da 82c087a 397b23d 82c087a a3f0108 82c087a b5af1da 82c087a 397b23d 82c087a b5af1da 5685c70 8336b65 b5af1da a3f0108 f0cca35 b5af1da 82c087a f0cca35 8336b65 82c087a 8336b65 82c087a 5685c70 82c087a b5af1da 8336b65 82c087a b5af1da 5685c70 82c087a 631cd58 5685c70 82c087a a3f0108 82c087a a3f0108 82c087a 5685c70 82c087a 8336b65 82c087a b5af1da 5685c70 82c087a f509f5e 5685c70 82c087a 631cd58 82c087a b5af1da 4f24ce4 631cd58 82c087a 5685c70 82c087a b5af1da 82c087a b5af1da 82c087a b5af1da 631cd58 4f24ce4 b5af1da 8336b65 631cd58 b5af1da 82c087a b5af1da 631cd58 82c087a 8336b65 82c087a b5af1da | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 | 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]) |