Vortex-Flux / src /Analytics /ModelML.py
jaimalklyde's picture
Claude Opus 5.5
feat(exercice): separation de toute l'application par annee civile
631cd58
Raw History Blame Contribute Delete
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])