CI Bot commited on
Commit
7de1562
·
1 Parent(s): bc6e002

CI deploy Fri Nov 21 11:21:49 UTC 2025

Browse files
Dockerfile CHANGED
@@ -2,9 +2,9 @@ FROM python:3.13-slim
2
 
3
  WORKDIR /app
4
 
5
- # Installer dépendances système
6
  RUN apt-get update && apt-get install -y \
7
- gcc build-essential libpq-dev libssl-dev libffi-dev \
8
  && rm -rf /var/lib/apt/lists/*
9
 
10
  # Installer Poetry
@@ -23,6 +23,21 @@ COPY ./ ./
23
  # Installer le projet
24
  RUN poetry install --only-root --no-interaction --no-ansi
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  EXPOSE 7860
27
 
28
- CMD ["uvicorn", "src.api.main:app", "--host", "0.0.0.0", "--port", "7860"]
 
2
 
3
  WORKDIR /app
4
 
5
+ # Installer dépendances système + cron
6
  RUN apt-get update && apt-get install -y \
7
+ gcc build-essential libpq-dev libssl-dev libffi-dev cron \
8
  && rm -rf /var/lib/apt/lists/*
9
 
10
  # Installer Poetry
 
23
  # Installer le projet
24
  RUN poetry install --only-root --no-interaction --no-ansi
25
 
26
+ # Créer le fichier cron : exécution le 1er de chaque mois à 02:00
27
+ RUN echo "0 2 1 * * cd /app && /usr/local/bin/poetry run python -m src.drift.monitoring >> /var/log/drift_cron.log 2>&1" > /etc/cron.d/drift_cron
28
+
29
+ # Donner les bons droits au fichier cron
30
+ RUN chmod 0644 /etc/cron.d/drift_cron && crontab /etc/cron.d/drift_cron
31
+
32
+ # Créer le fichier de log
33
+ RUN touch /var/log/drift_cron.log
34
+
35
+ # Script de démarrage qui lance à la fois l'API et cron
36
+ RUN echo '#!/bin/bash\n\
37
+ cron\n\
38
+ exec uvicorn src.api.main:app --host 0.0.0.0 --port 7860' > /start.sh && \
39
+ chmod +x /start.sh
40
+
41
  EXPOSE 7860
42
 
43
+ CMD ["/start.sh"]
README.md CHANGED
@@ -124,4 +124,25 @@ Il est nécessaire de préconfigurer cet environnement avec les variables suivan
124
  * `HF_PIPELINE` : pré-rempli dans `.env.dist`
125
  * `XDG_CACHE_HOME` : `/tmp/.cache`
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
 
 
124
  * `HF_PIPELINE` : pré-rempli dans `.env.dist`
125
  * `XDG_CACHE_HOME` : `/tmp/.cache`
126
 
127
+ ## Détection automatique du Datadrift
128
+
129
+ Le code permettant de gérer le rapport HTML du datadrift est inclus dans `src/drift/monitoring.py`
130
+ Voici son fonctionnement :
131
+
132
+ * Récupère les données de logs de prédictions depuis la base de données (`predict_logs`)
133
+ * Compare ces données de production au jeu d'entrainement local avec **Evidently**
134
+ * Génère le rapport au format HTML dans `.data/drift/report.html`
135
+
136
+ Ce script de monitoring s'exécute en production comme en local une fois par mois
137
+ La configuration est faite dans le `Dockerfile`, par l'utilisation de `crontab`
138
+
139
+ ## Profiling automatique du code de prédiction du modèle
140
+
141
+
142
+ Le profiling du code s'effectue automatiquement, il lancé lors de la création de la stack locale avec `make start` (il n'est pas utile de l'exporter en production), il permet :
143
+ * de simuler un appel à `/predict` et analyse la stack d'appels avec `cProfile`
144
+ * de générer un rapport d'analyse dans `.data/profiling/`, en html et en `.prof`
145
+ * de lancer le container docker `p8-snakeviz` pour visualiser les résultats du rapports à l'adresse suivante :
146
+ * http://localhost:8082/snakeviz/%2Fapp%2Fprofiling%2Fpredict_inference.prof (**port à adapter à votre configuration**)
147
+
148
 
coverage.xml CHANGED
@@ -1,5 +1,5 @@
1
  <?xml version="1.0" ?>
2
- <coverage version="7.12.0" timestamp="1763654250338" lines-valid="290" lines-covered="242" line-rate="0.8345" branches-valid="16" branches-covered="7" branch-rate="0.4375" complexity="0">
3
  <!-- Generated by coverage.py: https://coverage.readthedocs.io/en/7.12.0 -->
4
  <!-- Based on https://raw.githubusercontent.com/cobertura/web/master/htdocs/xml/coverage-04.dtd -->
5
  <sources>
 
1
  <?xml version="1.0" ?>
2
+ <coverage version="7.12.0" timestamp="1763724058110" lines-valid="290" lines-covered="242" line-rate="0.8345" branches-valid="16" branches-covered="7" branch-rate="0.4375" complexity="0">
3
  <!-- Generated by coverage.py: https://coverage.readthedocs.io/en/7.12.0 -->
4
  <!-- Based on https://raw.githubusercontent.com/cobertura/web/master/htdocs/xml/coverage-04.dtd -->
5
  <sources>
src/data/models/__init__.py CHANGED
@@ -3,5 +3,5 @@ from .predict_logs import PredictLogs
3
 
4
  __all__ = [
5
  "Base",
6
- "PredictLogs",
7
  ]
 
3
 
4
  __all__ = [
5
  "Base",
6
+ "PredictLogs"
7
  ]
src/drift/__init__.py ADDED
File without changes
src/drift/monitoring.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ import pandas as pd
5
+ from sqlalchemy import text
6
+ from evidently import Report
7
+ from evidently.presets import DataDriftPreset
8
+
9
+ # Ajuste ce chemin à ton projet si besoin
10
+ ROOT_DIR = Path(__file__).resolve().parents[2]
11
+ sys.path.insert(0, str(ROOT_DIR))
12
+
13
+ from src.data.database import get_db
14
+
15
+ # Config
16
+ DATA_DIR = ROOT_DIR / ".data"
17
+ TRAIN_PATH = DATA_DIR / "application_train.csv"
18
+ WINDOW_DAYS = 100
19
+ REPORT_OUTPUT = DATA_DIR / "drift" / "report.html"
20
+
21
+ def extract_prod_data() -> pd.DataFrame:
22
+ """Extrait les données prod depuis la table predict_logs."""
23
+ db = next(get_db())
24
+
25
+ query = f"""
26
+ SELECT input_payload->'application_data' AS data
27
+ FROM public.predict_logs
28
+ WHERE status = 'success'
29
+ AND date >= NOW() - INTERVAL '{WINDOW_DAYS} days'
30
+ """
31
+
32
+ rows = db.execute(text(query)).fetchall()
33
+ db.close()
34
+
35
+ if not rows:
36
+ return pd.DataFrame()
37
+
38
+ data = [row[0] for row in rows]
39
+ return pd.DataFrame(data)
40
+
41
+
42
+ def load_reference_data() -> pd.DataFrame:
43
+ """Charge les données d'entraînement comme référence."""
44
+ print(f"Chargement des données de référence depuis {TRAIN_PATH}")
45
+ return pd.read_csv(TRAIN_PATH)
46
+
47
+
48
+ def generate_drift_report(reference_data: pd.DataFrame, current_data: pd.DataFrame) -> None:
49
+ """Génère un rapport HTML de drift avec Evidently."""
50
+
51
+ # Créer le dossier reports s'il n'existe pas
52
+ REPORT_OUTPUT.parent.mkdir(parents=True, exist_ok=True)
53
+
54
+ # Aligner les colonnes entre référence et production
55
+ common_cols = list(set(reference_data.columns) & set(current_data.columns))
56
+
57
+ if not common_cols:
58
+ print("Aucune colonne commune entre les données de référence et de production!")
59
+ return
60
+
61
+ print(f"Colonnes communes détectées: {len(common_cols)}")
62
+
63
+ reference_subset = reference_data[common_cols]
64
+ current_subset = current_data[common_cols]
65
+
66
+ print("Génération du rapport de drift...")
67
+
68
+ # Ici : API Evidently "nouvelle" avec Report + metric_preset
69
+ report = Report(
70
+ metrics=[
71
+ DataDriftPreset(),
72
+ ]
73
+ )
74
+
75
+ eval = report.run(
76
+ reference_data=reference_subset,
77
+ current_data=current_subset,
78
+ )
79
+
80
+ # Cette version‑là de Report a bien save_html
81
+ eval.save_html(str(REPORT_OUTPUT))
82
+
83
+ print(f"Rapport de drift généré: {REPORT_OUTPUT}")
84
+
85
+
86
+ def main():
87
+ """Point d'entrée principal."""
88
+ print("=" * 60)
89
+ print("ANALYSE DE DRIFT DU MODÈLE")
90
+ print("=" * 60)
91
+
92
+ # 1. Charger les données de référence (train)
93
+ reference_data = load_reference_data()
94
+ print(f"Données de référence: {reference_data.shape}")
95
+
96
+ # 2. Extraire les données de production
97
+ print(f"\nExtraction des données de production ({WINDOW_DAYS} derniers jours)...")
98
+ current_data = extract_prod_data()
99
+
100
+ if current_data.empty:
101
+ print("Aucune donnée de production trouvée!")
102
+ return
103
+
104
+ print(f"Données de production: {current_data.shape}")
105
+
106
+ # 3. Générer le rapport de drift
107
+ generate_drift_report(reference_data, current_data)
108
+
109
+ print("\n" + "=" * 60)
110
+ print("ANALYSE TERMINÉE")
111
+ print("=" * 60)
112
+
113
+
114
+ if __name__ == "__main__":
115
+ main()
src/scripts/api_simulation.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ import requests
3
+ from pathlib import Path
4
+ from datetime import datetime
5
+ import time
6
+ import os
7
+
8
+ # Configuration
9
+ API_URL = os.getenv("API_URL")
10
+ API_TOKEN = os.getenv("API_TOKEN")
11
+ DATA_DIR = Path(".data")
12
+
13
+
14
+ def load_data():
15
+ """Charge les données application_test et bureau"""
16
+ app_test = pd.read_csv(DATA_DIR / "application_test.csv")
17
+ bureau = pd.read_csv(DATA_DIR / "bureau.csv")
18
+ return app_test, bureau
19
+
20
+
21
+ def get_bureau_records(sk_id_curr, bureau_df):
22
+ """Récupère tous les enregistrements bureau pour un SK_ID_CURR donné"""
23
+ bureau_records = bureau_df[bureau_df['SK_ID_CURR'] == sk_id_curr]
24
+
25
+ if bureau_records.empty:
26
+ return []
27
+
28
+ # Convertir en liste de dictionnaires
29
+ bureau_list = bureau_records.to_dict('records')
30
+
31
+ # Remplacer les NaN par None pour le JSON
32
+ for record in bureau_list:
33
+ for key, value in record.items():
34
+ if pd.isna(value):
35
+ record[key] = None
36
+
37
+ return bureau_list
38
+
39
+
40
+ def create_api_payload(app_row, bureau_records):
41
+ """Crée le payload JSON pour l'API"""
42
+ # Convertir la ligne application en dict
43
+ app_dict = app_row.to_dict()
44
+
45
+ # Remplacer les NaN par None
46
+ for key, value in app_dict.items():
47
+ if pd.isna(value):
48
+ app_dict[key] = None
49
+
50
+ payload = {
51
+ "application_data": app_dict,
52
+ "bureau_data": bureau_records
53
+ }
54
+
55
+ return payload
56
+
57
+
58
+ def call_api(payload):
59
+ """Appelle l'API et retourne la réponse"""
60
+ try:
61
+ start_time = time.time()
62
+ response = requests.post(
63
+ API_URL+'/predict',
64
+ json=payload,headers={
65
+ "Content-Type": "application/json",
66
+ "Authorization": f"Bearer {API_TOKEN}"
67
+ },
68
+ timeout=30
69
+ )
70
+ inference_time = time.time() - start_time
71
+
72
+ if response.status_code == 200:
73
+ result = response.json()
74
+ result['inference_time_ms'] = round(inference_time * 1000, 2)
75
+ result['status'] = 'success'
76
+ return result
77
+ else:
78
+ return {
79
+ 'status': 'error',
80
+ 'status_code': response.status_code,
81
+ 'error_message': response.text,
82
+ 'inference_time_ms': round(inference_time * 1000, 2)
83
+ }
84
+ except Exception as e:
85
+ return {
86
+ 'status': 'error',
87
+ 'error_message': str(e),
88
+ 'inference_time_ms': None
89
+ }
90
+
91
+
92
+ def generate_production_data(start_index=0, num_records=10):
93
+ """
94
+ Génère des données de production en appelant l'API
95
+
96
+ Args:
97
+ start_index: Index de départ dans application_test.csv
98
+ num_records: Nombre d'enregistrements à traiter
99
+ api_url: URL de l'API
100
+ """
101
+ print(f"🚀 Génération de {num_records} prédictions à partir de l'index {start_index}")
102
+
103
+ # Charger les données
104
+ print("Chargement des données...")
105
+ app_test, bureau = load_data()
106
+
107
+ # Sélectionner les lignes
108
+ end_index = start_index + num_records
109
+ selected_rows = app_test.iloc[start_index:end_index]
110
+
111
+ print(f"{len(selected_rows)} lignes sélectionnées (index {start_index} à {end_index - 1})")
112
+
113
+ # Préparer les résultats
114
+ results = []
115
+ payloads = []
116
+
117
+ # Traiter chaque ligne
118
+ for idx, (_, row) in enumerate(selected_rows.iterrows(), 1):
119
+ sk_id_curr = row['SK_ID_CURR']
120
+ print(f"\n[{idx}/{num_records}] Traitement de SK_ID_CURR: {sk_id_curr}")
121
+
122
+ # Récupérer les données bureau
123
+ bureau_records = get_bureau_records(sk_id_curr, bureau)
124
+ print(f" 📋 {len(bureau_records)} enregistrements bureau trouvés")
125
+
126
+ # Créer le payload
127
+ payload = create_api_payload(row, bureau_records)
128
+ payloads.append(payload)
129
+
130
+ # Appeler l'API
131
+ print(f"Appel de l'API...")
132
+ api_response = call_api(payload)
133
+
134
+ # Ajouter les métadonnées
135
+ result = {
136
+ 'timestamp': datetime.now().isoformat(),
137
+ 'sk_id_curr': int(sk_id_curr),
138
+ 'start_index': start_index,
139
+ 'record_index': start_index + idx - 1,
140
+ 'api_response': api_response
141
+ }
142
+
143
+ results.append(result)
144
+
145
+ if api_response['status'] == 'success':
146
+ print(f" ✅ Succès - Temps: {api_response.get('inference_time_ms', 'N/A')} ms")
147
+ else:
148
+ print(f" ❌ Erreur: {api_response.get('error_message', 'Unknown')}")
149
+
150
+ # Pause pour ne pas surcharger l'API
151
+ #time.sleep(0.1)
152
+ return results
153
+
154
+
155
+ if __name__ == "__main__":
156
+
157
+ # Génère 100 prédictions à partir de l'index 0
158
+ results = generate_production_data(
159
+ start_index=0,
160
+ num_records=500
161
+ )