Spaces:
Sleeping
Sleeping
Deploy API connected to MLflow
Browse files- .gitignore +16 -0
- Dockerfile +29 -0
- app.py +129 -0
- processing.py +87 -0
- requirements.txt +6 -0
.gitignore
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Ignorer le dossier de cache Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
|
| 5 |
+
# Ignorer les modèles locaux
|
| 6 |
+
models/
|
| 7 |
+
*.json
|
| 8 |
+
*.xgb
|
| 9 |
+
|
| 10 |
+
# Ignorer l'environnement virtuel
|
| 11 |
+
venv/
|
| 12 |
+
env/
|
| 13 |
+
.env/
|
| 14 |
+
|
| 15 |
+
# Ignorer les secrets locaux
|
| 16 |
+
.env
|
Dockerfile
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 1. Utiliser une image Python légère
|
| 2 |
+
FROM python:3.9-slim
|
| 3 |
+
|
| 4 |
+
# 2. Définir le répertoire de travail
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# 3. Installer les dépendances système nécessaires
|
| 8 |
+
RUN apt-get update && apt-get install -y \
|
| 9 |
+
libgomp1 \
|
| 10 |
+
&& rm -rf /var/lib/apt/lib/lists/*
|
| 11 |
+
|
| 12 |
+
# 4. Créer un utilisateur non-root
|
| 13 |
+
RUN useradd -m -u 1000 user
|
| 14 |
+
USER user
|
| 15 |
+
ENV PATH="/home/user/.local/bin:$PATH"
|
| 16 |
+
|
| 17 |
+
# 5. Copier les fichiers de dépendances et installer
|
| 18 |
+
COPY --chown=user requirements.txt .
|
| 19 |
+
RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
| 20 |
+
|
| 21 |
+
# 6. Copier tout le reste du code et les modèles
|
| 22 |
+
COPY --chown=user . .
|
| 23 |
+
|
| 24 |
+
# 7. Exposer le port par défaut de Hugging Face
|
| 25 |
+
EXPOSE 7860
|
| 26 |
+
|
| 27 |
+
# 8. Lancer l'application avec Uvicorn
|
| 28 |
+
|
| 29 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
app.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import numpy as np
|
| 4 |
+
import xgboost as xgb
|
| 5 |
+
import pandas as pd
|
| 6 |
+
import mlflow.xgboost
|
| 7 |
+
from fastapi import FastAPI, HTTPException, Body
|
| 8 |
+
from dotenv import load_dotenv
|
| 9 |
+
from processing import prepare_input, calculate_survival_risk, map_statut_expert, get_sigma
|
| 10 |
+
|
| 11 |
+
# --- 1. CONFIGURATION MLFLOW ---
|
| 12 |
+
load_dotenv()
|
| 13 |
+
|
| 14 |
+
# MLflow utilise automatiquement MLFLOW_TRACKING_USERNAME et MLFLOW_TRACKING_PASSWORD
|
| 15 |
+
mlflow.set_tracking_uri(os.getenv("MLFLOW_TRACKING_URI"))
|
| 16 |
+
|
| 17 |
+
RUN_ID = "674d07aab0b0493a838310da47c71a95"
|
| 18 |
+
MODEL_URI = f"runs:/{RUN_ID}/model"
|
| 19 |
+
|
| 20 |
+
# --- 2. INITIALISATION DE L'API ---
|
| 21 |
+
app = FastAPI(
|
| 22 |
+
title="Business Risk API - Test MLflow Remote",
|
| 23 |
+
description="API de simulation utilisant un modèle stocké sur un serveur MLflow distant.",
|
| 24 |
+
version="3.5.0"
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
# Variables globales pour le modèle et Sigma
|
| 28 |
+
model = None
|
| 29 |
+
SIGMA = None
|
| 30 |
+
|
| 31 |
+
# Dans ton bloc startup, ajoute ces prints pour débugger :
|
| 32 |
+
@app.on_event("startup")
|
| 33 |
+
async def load_model():
|
| 34 |
+
global model, SIGMA
|
| 35 |
+
try:
|
| 36 |
+
print(f"🚀 Connexion à MLflow : {os.getenv('MLFLOW_TRACKING_URI')}")
|
| 37 |
+
|
| 38 |
+
# 1. On charge l'objet
|
| 39 |
+
loaded_model = mlflow.xgboost.load_model(MODEL_URI)
|
| 40 |
+
|
| 41 |
+
# 2. CORRECTION ICI :
|
| 42 |
+
# Si c'est déjà un Booster, on l'utilise directement.
|
| 43 |
+
# Si c'est un wrapper XGBModel, on appelle get_booster().
|
| 44 |
+
if isinstance(loaded_model, xgb.Booster):
|
| 45 |
+
model = loaded_model
|
| 46 |
+
else:
|
| 47 |
+
model = loaded_model.get_booster()
|
| 48 |
+
|
| 49 |
+
# 3. On extrait Sigma
|
| 50 |
+
SIGMA = get_sigma(model)
|
| 51 |
+
|
| 52 |
+
print(f"✅ Modèle chargé avec succès (Sigma: {round(SIGMA, 4)})")
|
| 53 |
+
except Exception as e:
|
| 54 |
+
print(f"❌ Erreur lors du chargement : {e}")
|
| 55 |
+
|
| 56 |
+
# --- 3. ROUTES ---
|
| 57 |
+
|
| 58 |
+
@app.get("/")
|
| 59 |
+
def home():
|
| 60 |
+
return {
|
| 61 |
+
"status": "online",
|
| 62 |
+
"model_source": "MLflow Remote",
|
| 63 |
+
"run_id": RUN_ID
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
@app.post("/predict")
|
| 67 |
+
async def predict(
|
| 68 |
+
data: dict = Body(..., example={
|
| 69 |
+
"age_estime": 4.5,
|
| 70 |
+
"Tranche_effectif_num": 3,
|
| 71 |
+
"code_departement": "26",
|
| 72 |
+
"code_ape": "43",
|
| 73 |
+
"categorie_juridique": "5499",
|
| 74 |
+
"is_ess": 0
|
| 75 |
+
})
|
| 76 |
+
):
|
| 77 |
+
"""
|
| 78 |
+
Simule le risque de fermeture d'une entreprise.
|
| 79 |
+
|
| 80 |
+
Exemple fourni :
|
| 81 |
+
- Age : 4.5 ans
|
| 82 |
+
- Effectif : Tranche 3
|
| 83 |
+
- Localisation : Drôme (26)
|
| 84 |
+
- Secteur : Construction (43)
|
| 85 |
+
"""
|
| 86 |
+
if model is None:
|
| 87 |
+
raise HTTPException(status_code=503, detail="Modèle non chargé")
|
| 88 |
+
|
| 89 |
+
try:
|
| 90 |
+
# 1. Préparation des données (Mapping APE 2 chiffres inclus)
|
| 91 |
+
dmatrix = prepare_input(data)
|
| 92 |
+
|
| 93 |
+
# 2. Inférence (Score MU)
|
| 94 |
+
mu = float(model.predict(dmatrix)[0])
|
| 95 |
+
|
| 96 |
+
# 3. Calcul des probabilités de fermeture aux horizons 1, 2 et 3 ans
|
| 97 |
+
p1 = calculate_survival_risk(mu, 1, SIGMA)
|
| 98 |
+
p2 = calculate_survival_risk(mu, 2, SIGMA)
|
| 99 |
+
p3 = calculate_survival_risk(mu, 3, SIGMA)
|
| 100 |
+
|
| 101 |
+
return {
|
| 102 |
+
"diagnostic": {
|
| 103 |
+
"profil_global": map_statut_expert(p2),
|
| 104 |
+
"indice_confiance_mu": round(mu, 4)
|
| 105 |
+
},
|
| 106 |
+
"probabilites_fermeture": {
|
| 107 |
+
"1_an": f"{p1}%",
|
| 108 |
+
"2_ans": f"{p2}%",
|
| 109 |
+
"3_ans": f"{p3}%"
|
| 110 |
+
},
|
| 111 |
+
"entrees_reçues": {
|
| 112 |
+
"division_ape": data.get("code_ape"),
|
| 113 |
+
"departement": data.get("code_departement")
|
| 114 |
+
},
|
| 115 |
+
"metadonnees": {
|
| 116 |
+
"run_id": RUN_ID,
|
| 117 |
+
"sigma_utilise": round(SIGMA, 6)
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
except Exception as e:
|
| 122 |
+
raise HTTPException(
|
| 123 |
+
status_code=500,
|
| 124 |
+
detail=f"Erreur interne lors du calcul : {str(e)}"
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
if __name__ == "__main__":
|
| 128 |
+
import uvicorn
|
| 129 |
+
uvicorn.run(app, host="0.0.0.0", port=7860)
|
processing.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import numpy as np
|
| 3 |
+
import xgboost as xgb
|
| 4 |
+
import json
|
| 5 |
+
|
| 6 |
+
# --- 1. DICTIONNAIRE DE MAPPING DIVISION (2 chiffres) -> SECTION (Ton Modèle) ---
|
| 7 |
+
# Ce dictionnaire permet à l'utilisateur de saisir "56" et au modèle de recevoir "Hébergement et restauration"
|
| 8 |
+
NAF_TO_SECTION = {
|
| 9 |
+
"01": "Agriculture, sylviculture et pêche", "02": "Agriculture, sylviculture et pêche", "03": "Agriculture, sylviculture et pêche",
|
| 10 |
+
"10": "Industrie manufacturière", "11": "Industrie manufacturière", "12": "Industrie manufacturière",
|
| 11 |
+
"41": "Construction", "42": "Construction", "43": "Travaux de construction spécialisés",
|
| 12 |
+
"45": "Commerce", "46": "Commerce", "47": "Commerce de détail, à l’exception des automobi...",
|
| 13 |
+
"49": "Transports et entreposage", "55": "Hébergement et restauration", "56": "Hébergement et restauration",
|
| 14 |
+
"58": "Information et communication", "61": "Information et communication",
|
| 15 |
+
"64": "Activités financières et d'assurance", "66": "Activités auxiliaires de services financiers e...",
|
| 16 |
+
"68": "Activités immobilières",
|
| 17 |
+
"69": "Activités juridiques, comptables, de gestion, d'études de conseil",
|
| 18 |
+
"71": "Activités d'architecture et d'ingénierie",
|
| 19 |
+
"85": "Enseignement",
|
| 20 |
+
"86": "Santé humaine et action sociale",
|
| 21 |
+
"96": "Autres services personnels"
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
# Chargement des colonnes exactes du modèle
|
| 25 |
+
with open('models/features_config.json', 'r') as f:
|
| 26 |
+
FEATURES = json.load(f)
|
| 27 |
+
|
| 28 |
+
def get_sigma(model):
|
| 29 |
+
"""Extrait le paramètre scale (sigma) du JSON du modèle XGBoost"""
|
| 30 |
+
config = json.loads(model.save_config())
|
| 31 |
+
def find_key(obj, key):
|
| 32 |
+
if isinstance(obj, dict):
|
| 33 |
+
for k, v in obj.items():
|
| 34 |
+
if k == key: return v
|
| 35 |
+
res = find_key(v, key)
|
| 36 |
+
if res is not None: return res
|
| 37 |
+
elif isinstance(obj, list):
|
| 38 |
+
for item in obj:
|
| 39 |
+
res = find_key(item, key)
|
| 40 |
+
if res is not None: return res
|
| 41 |
+
return None
|
| 42 |
+
scale = find_key(config, 'aft_loss_distribution_scale')
|
| 43 |
+
return float(scale) if scale else 0.8
|
| 44 |
+
|
| 45 |
+
def calculate_survival_risk(mu, horizon, s):
|
| 46 |
+
"""Formule de survie pour distribution Logistique (AFT)"""
|
| 47 |
+
z = (np.log(horizon) - mu) / s
|
| 48 |
+
z = np.clip(z, -50, 50)
|
| 49 |
+
return round((1 / (1 + np.exp(-z))) * 100, 2)
|
| 50 |
+
|
| 51 |
+
def map_statut_expert(p2):
|
| 52 |
+
"""Traduction de la probabilité à 2 ans en libellé métier"""
|
| 53 |
+
if p2 > 20: return '🔴 CRITIQUE'
|
| 54 |
+
if p2 > 10: return '🟠 VIGILANCE'
|
| 55 |
+
if p2 > 5: return '🟡 OBSERVATION'
|
| 56 |
+
return '🟢 SAIN'
|
| 57 |
+
|
| 58 |
+
def prepare_input(data):
|
| 59 |
+
"""Prépare le DMatrix avec mapping automatique des codes APE et CJ"""
|
| 60 |
+
df = pd.DataFrame(0.0, index=[0], columns=FEATURES)
|
| 61 |
+
|
| 62 |
+
# 1. Variables numériques directes
|
| 63 |
+
df['age_au_diagnostic'] = float(data.get('age_estime', 0))
|
| 64 |
+
df['Tranche_effectif_num'] = float(data.get('Tranche_effectif_num', 0))
|
| 65 |
+
df['risque_departemental'] = float(data.get('code_departement', 0))
|
| 66 |
+
df['is_ess'] = int(data.get('is_ess', 0))
|
| 67 |
+
|
| 68 |
+
# 2. Mapping APE (Division -> Section)
|
| 69 |
+
code_ape_2 = str(data.get('code_ape', ''))[:2]
|
| 70 |
+
section_name = NAF_TO_SECTION.get(code_ape_2)
|
| 71 |
+
|
| 72 |
+
if section_name:
|
| 73 |
+
col_ape = f"APE_{section_name}"
|
| 74 |
+
if col_ape in df.columns:
|
| 75 |
+
df[col_ape] = 1.0
|
| 76 |
+
elif 'APE_Autres_Secteurs' in df.columns:
|
| 77 |
+
df['APE_Autres_Secteurs'] = 1.0
|
| 78 |
+
|
| 79 |
+
# 3. Mapping Catégorie Juridique (CJ_Prefix)
|
| 80 |
+
cj_prefix = str(data.get('categorie_juridique', ''))[:4]
|
| 81 |
+
col_cj = f"CJ_{cj_prefix}"
|
| 82 |
+
if col_cj in df.columns:
|
| 83 |
+
df[col_cj] = 1.0
|
| 84 |
+
elif 'CJ_Autres_Status' in df.columns:
|
| 85 |
+
df['CJ_Autres_Status'] = 1.0
|
| 86 |
+
|
| 87 |
+
return xgb.DMatrix(df)
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
xgboost
|
| 4 |
+
pandas
|
| 5 |
+
numpy
|
| 6 |
+
python-multipart
|