Spaces:
Sleeping
Sleeping
|
Download README.md from rriviere/attrition-api-dev: direct link, hf CLI and curl.
- Browser
- Download file 20.8 kB
-
https://huggingface.co/spaces/rriviere/attrition-api-dev/resolve/main/README.md
- Command line
-
hf download hf://spaces/rriviere/attrition-api-dev/README.md
-
curl -L -o README.md https://huggingface.co/spaces/rriviere/attrition-api-dev/resolve/main/README.md
20.8 kB
| title: Attrition Api | |
| emoji: 🤖 | |
| colorFrom: blue | |
| colorTo: green | |
| sdk: docker | |
| pinned: false | |
|  | |
| # API de prédiction d'attrition RH | |
| ## Description | |
| API REST de prédiction du risque d'attrition RH développée avec FastAPI. Elle expose un modèle de Régression Logistique entraîné sur le dataset IBM HR Attrition (1470 employés), prédit la probabilité de départ d'un employé, et persiste chaque appel en base de données PostgreSQL pour des analyses ultérieures. | |
| ## Structure du projet | |
| ``` | |
| ml-deployment-api/ | |
| │ | |
| ├── app/ # Code source de l'API FastAPI | |
| │ ├── main.py # Point d'entrée FastAPI, config et middlewares | |
| │ ├── routes/ | |
| │ │ └── predict.py # Endpoints /predict et /predictions | |
| │ ├── middleware/ | |
| │ │ └── logging.py # Middleware de logging HTTP (table logs) | |
| │ ├── schemas/ | |
| │ │ └── prediction.py # Modèles Pydantic input/output | |
| │ ├── db/ | |
| │ │ ├── models.py # Modèles SQLAlchemy (ORM) | |
| │ │ ├── session.py # Connexion PostgreSQL | |
| │ │ └── crud.py # Opérations CRUD | |
| │ | |
| ├── ml_model/ # Pipeline ML sérialisé et logique d'inférence | |
| │ ├── pipeline.pkl | |
| │ ├── preprocessing.py | |
| │ └── loader.py # Chargement du pipeline et inférence | |
| │ | |
| ├── scripts/ # Scripts utilitaires (BDD, données) | |
| │ ├── create_db.py # Création des tables | |
| │ ├── insert_data.py # Insère les employés (fixtures par défaut, ou dataset complet en argument) | |
| │ └── query_db.py # Requêtes analytiques | |
| │ | |
| ├── tests/ # Tests unitaires et d'intégration | |
| │ ├── conftest.py # Fixtures partagées | |
| │ ├── test_api.py | |
| │ ├── test_model.py | |
| │ └── test_db.py | |
| │ | |
| ├── docs/ # Documentation et diagrammes | |
| │ └── uml.png | |
| │ | |
| ├── fixtures/ # Données d'exemple pour le développement | |
| │ └── employees.csv # 20 employés représentatifs (structure table `employees`) | |
| │ | |
| ├── .github/workflows/ # Pipeline CI/CD GitHub Actions | |
| │ └── ci_cd.yml | |
| ├── Dockerfile | |
| ├── docker-compose.yaml | |
| ├── requirements.txt | |
| ├── .env.example # Template de configuration (variables d'environnement) | |
| ├── .gitignore | |
| └── README.md | |
| ``` | |
| ## Installation | |
| ### Prérequis | |
| - Python 3.12+ | |
| - Docker (pour PostgreSQL via docker-compose) | |
| ### Étapes | |
| 1. Cloner le repo | |
| ```bash | |
| git clone git@github.com:RaphaelRIVIERE/ml-deployment-api.git | |
| cd ml-deployment-api | |
| ``` | |
| 2. Créer et activer le venv | |
| ```bash | |
| python3 -m venv venv | |
| source venv/bin/activate | |
| ``` | |
| 3. Installer les dépendances | |
| ```bash | |
| pip install -r requirements.txt | |
| ``` | |
| 4. Lancer PostgreSQL | |
| ```bash | |
| docker-compose up -d | |
| ``` | |
| 5. Créer les tables | |
| ```bash | |
| python scripts/create_db.py | |
| ``` | |
| 6. Insérer les données dans la table `employees` | |
| ```bash | |
| # Fixtures d'exemple par défaut (20 employés représentatifs) | |
| python scripts/insert_data.py | |
| # Ou avec le dataset complet (~1470 employés) | |
| python scripts/insert_data.py data/dataset.csv | |
| ``` | |
| ## Configuration | |
| Un fichier `.env.example` est fourni à la racine du projet. Il suffit de le copier et de renseigner ta clé API : | |
| ```bash | |
| cp .env.example .env | |
| ``` | |
| Le fichier `.env.example` contient les variables suivantes : | |
| | Variable | Description | Valeur par défaut | | |
| |---|---|---| | |
| | `API_KEY` | Clé d'authentification de l'API | — | | |
| | `DB_HOST` | Hôte PostgreSQL | `localhost` | | |
| | `DB_PORT` | Port PostgreSQL | `5432` | | |
| | `DB_USER` | Utilisateur PostgreSQL | — | | |
| | `DB_PASSWORD` | Mot de passe PostgreSQL | — | | |
| | `DB_NAME` | Nom de la base de données | `attrition_db` | | |
| > **Sécurité** : Ne jamais committer `.env` (déjà listé dans `.gitignore`). Générer une clé robuste avec : | |
| > ```bash | |
| > python -c "import secrets; print(secrets.token_hex(32))" | |
| > ``` | |
| > En CI/CD, injecter les secrets via **GitHub Actions Secrets** (Settings → Secrets and variables → Actions). | |
| ## Base de données | |
| ### ORM et connexion | |
| - **ORM** : SQLAlchemy avec le style `declarative_base()` | |
| - **Driver** : `psycopg2-binary` pour la connexion PostgreSQL | |
| - **Lancement** : PostgreSQL via Docker (`docker-compose up -d`) | |
| ### Schéma des tables | |
| #### `employees` — dataset RH | |
| Stocke les employés. La colonne `a_quitte_l_entreprise` est convertie de `"Oui"/"Non"` en `BOOLEAN`. | |
| Peuplée via `scripts/insert_data.py` (fixtures de 20 employés par défaut, ou dataset complet en argument). | |
| #### `predictions` — historique des appels API | |
| Enregistre chaque appel à `POST /predict` avec les inputs envoyés, les outputs retournés et un timestamp automatique (`created_at TIMESTAMPTZ`). | |
| > Pas de fixtures pour cette table : elle se peuple **automatiquement** à chaque appel à `POST /predict`. Cela garantit que les données reflètent de vraies interactions avec le modèle. | |
| #### `logs` — traces HTTP | |
| Enregistre chaque requête HTTP reçue par l'API (endpoint, méthode, code retour, temps de réponse). Pour les appels à `/predict`, la colonne `prediction_id` référence la prédiction associée. | |
| > Pas de fixtures pour cette table : elle se peuple **automatiquement** via le middleware à chaque requête entrante. | |
| > Les tables `predictions` et `employees` sont **indépendantes** : les prédictions API ne référencent pas `employees` car les employés soumis à prédiction ne sont pas forcément dans le dataset. | |
| ### Diagramme UML | |
| ```mermaid | |
| erDiagram | |
| EMPLOYEES { | |
| int id PK | |
| timestamptz created_at | |
| int age | |
| varchar genre | |
| varchar statut_marital | |
| int revenu_mensuel | |
| varchar departement | |
| varchar poste | |
| smallint niveau_hierarchique_poste | |
| varchar domaine_etude | |
| smallint niveau_education | |
| int nombre_experiences_precedentes | |
| int annee_experience_totale | |
| int annees_dans_l_entreprise | |
| int annees_dans_le_poste_actuel | |
| int annees_sous_responsable_actuel | |
| int annees_depuis_la_derniere_promotion | |
| smallint note_evaluation_actuelle | |
| smallint note_evaluation_precedente | |
| int augmentation_salaire_precedente | |
| int nb_formations_suivies | |
| int nombre_participation_pee | |
| smallint satisfaction_employee_environnement | |
| smallint satisfaction_employee_nature_travail | |
| smallint satisfaction_employee_equipe | |
| smallint satisfaction_employee_equilibre_pro_perso | |
| varchar heure_supplementaires | |
| varchar frequence_deplacement | |
| int distance_domicile_travail | |
| boolean a_quitte_l_entreprise | |
| } | |
| PREDICTIONS { | |
| int id PK | |
| timestamptz created_at | |
| int age | |
| varchar genre | |
| varchar statut_marital | |
| varchar poste | |
| varchar domaine_etude | |
| smallint niveau_education | |
| varchar departement | |
| smallint niveau_hierarchique_poste | |
| int nombre_experiences_precedentes | |
| int annee_experience_totale | |
| int annees_dans_l_entreprise | |
| int annees_dans_le_poste_actuel | |
| int annees_sous_responsable_actuel | |
| int annees_depuis_la_derniere_promotion | |
| smallint note_evaluation_actuelle | |
| smallint note_evaluation_precedente | |
| int augmentation_salaire_precedente | |
| int nb_formations_suivies | |
| int nombre_participation_pee | |
| smallint satisfaction_employee_environnement | |
| smallint satisfaction_employee_nature_travail | |
| smallint satisfaction_employee_equipe | |
| smallint satisfaction_employee_equilibre_pro_perso | |
| varchar heure_supplementaires | |
| varchar frequence_deplacement | |
| int distance_domicile_travail | |
| int revenu_mensuel | |
| smallint prediction | |
| float probabilite | |
| } | |
| LOGS { | |
| int id PK | |
| timestamptz timestamp | |
| varchar endpoint | |
| varchar method | |
| int status_code | |
| float response_time_ms | |
| int prediction_id FK | |
| } | |
| PREDICTIONS ||--o{ LOGS : "prediction_id" | |
| ``` | |
| ### Contraintes notables | |
| | Colonne | Contrainte | | |
| |---|---| | |
| | `genre` | `VARCHAR` — `"M"` = Homme, `"F"` = Femme | | |
| | `heure_supplementaires` | `VARCHAR` — `"Oui"` ou `"Non"` | | |
| | `frequence_deplacement` | `VARCHAR` — `"Aucun"`, `"Occasionnel"` ou `"Fréquent"` | | |
| | `a_quitte_l_entreprise` | `BOOLEAN` — converti depuis `"Oui"/"Non"` du CSV | | |
| | `created_at` | `TIMESTAMPTZ` — posé par PostgreSQL (`server_default=func.now()`) | | |
| | Scores satisfaction / évaluation | `SMALLINT` — valeurs entre `0` et `5` | | |
| ## Utilisation | |
| ### Lancer l'API | |
| ```bash | |
| uvicorn app.main:app --reload | |
| ``` | |
| L'API est accessible sur `http://localhost:8000`. | |
| La documentation interactive Swagger est disponible sur `http://localhost:8000/docs`. | |
| ### Authentification | |
| Tous les endpoints (sauf `/` et `/health`) nécessitent une clé API passée dans le header HTTP : | |
| ``` | |
| X-API-Key: ta_clé_secrète | |
| ``` | |
| - Une requête sans clé ou avec une clé invalide retourne `401 Unauthorized` | |
| - L'API refuse de démarrer si `API_KEY` est vide ou absent du `.env` | |
| - Les secrets ne transitent jamais dans l'URL (header uniquement) | |
| ### Endpoints | |
| #### `GET /` — Accueil de l'API | |
| Retourne les informations générales de l'API. Pas d'authentification requise. | |
| ```bash | |
| curl http://localhost:8000/ | |
| ``` | |
| Réponse : | |
| ```json | |
| { | |
| "name": "Futurisys HR Churn API", | |
| "version": "1.3.0", | |
| "documentation": "http://localhost:8000/docs", | |
| "health": "http://localhost:8000/health" | |
| } | |
| ``` | |
| #### `GET /health` — Health check | |
| Vérifie que l'API est opérationnelle. Pas d'authentification requise. | |
| ```bash | |
| curl http://localhost:8000/health | |
| ``` | |
| Réponse : | |
| ```json | |
| { "status": "ok", "message": "API opérationnelle" } | |
| ``` | |
| #### `GET /model/info` — Informations sur le modèle | |
| Retourne les métadonnées du modèle déployé. | |
| ```bash | |
| curl http://localhost:8000/model/info \ | |
| -H "X-API-Key: ta_clé_secrète" | |
| ``` | |
| Réponse : | |
| ```json | |
| { | |
| "algorithme": "Régression Logistique", | |
| "seuil": 0.4, | |
| "description": "Classification binaire — risque de départ RH (0 = Reste, 1 = Quitte)" | |
| } | |
| ``` | |
| #### `POST /predict` — Prédiction du risque de départ | |
| Envoie les features RH d'un employé et reçoit une prédiction de départ. | |
| ```bash | |
| curl -X POST http://localhost:8000/predict \ | |
| -H "X-API-Key: ta_clé_secrète" \ | |
| -H "Content-Type: application/json" \ | |
| -d '{ | |
| "age": 35, | |
| "genre": "M", | |
| "statut_marital": "Marié(e)", | |
| "poste": "Consultant", | |
| "domaine_etude": "Infra & Cloud", | |
| "niveau_education": 3, | |
| "departement": "Ventes", | |
| "niveau_hierarchique_poste": 2, | |
| "nombre_experiences_precedentes": 2, | |
| "annee_experience_totale": 10, | |
| "annees_dans_l_entreprise": 5, | |
| "annees_dans_le_poste_actuel": 2, | |
| "annees_sous_responsable_actuel": 3, | |
| "annees_depuis_la_derniere_promotion": 1, | |
| "note_evaluation_actuelle": 3, | |
| "note_evaluation_precedente": 3, | |
| "augmentation_salaire_precedente": 15, | |
| "nb_formations_suivies": 2, | |
| "nombre_participation_pee": 1, | |
| "satisfaction_employee_environnement": 3, | |
| "satisfaction_employee_nature_travail": 4, | |
| "satisfaction_employee_equipe": 3, | |
| "satisfaction_employee_equilibre_pro_perso": 2, | |
| "heure_supplementaires": "Non", | |
| "frequence_deplacement": "Occasionnel", | |
| "distance_domicile_travail": 10, | |
| "revenu_mensuel": 5000 | |
| }' | |
| ``` | |
| Réponse : | |
| ```json | |
| { | |
| "prediction": 0, | |
| "label": "Reste", | |
| "probabilite": 0.2341 | |
| } | |
| ``` | |
| **Champs de la réponse :** | |
| | Champ | Type | Description | | |
| |---|---|---| | |
| | `prediction` | int | `0` = Reste, `1` = Quitte | | |
| | `label` | string | `"Reste"` ou `"Quitte"` | | |
| | `probabilite` | float | Probabilité de départ (entre 0 et 1) | | |
| #### `GET /predictions` — Historique des prédictions | |
| Retourne la liste des prédictions enregistrées en base, triées de la plus récente à la plus ancienne. Authentification requise. | |
| **Paramètres query (optionnels) :** | |
| | Paramètre | Type | Défaut | Description | | |
| |---|---|---|---| | |
| | `skip` | int | 0 | Décalage (pagination) | | |
| | `limit` | int | 100 | Nombre max de résultats | | |
| ```bash | |
| curl http://localhost:8000/predictions?limit=10 \ | |
| -H "X-API-Key: ta_clé_secrète" | |
| ``` | |
| ## Analyse des données & tableau de bord | |
| La table `predictions` constitue un journal structuré de toutes les interactions | |
| avec le modèle. Elle peut être exploitée pour du reporting RH en temps réel. | |
| ### Requêtes analytiques disponibles | |
| Le script `scripts/query_db.py` expose les requêtes suivantes : | |
| | Fonction | Description | | |
| |---|---| | |
| | `stats_employees(session)` | Nombre d'employés dans le dataset, taux de churn réel | | |
| | `stats_predictions(session)` | Nombre de prédictions loggées, date de la dernière | | |
| | `predict_from_db(session, id)` | Charge un employé, prédit et compare au label réel | | |
| | `apercu_employees(session, n)` | Affiche les N premiers employés du dataset | | |
| **Lancer l'analyse :** | |
| ```bash | |
| python scripts/query_db.py | |
| ``` | |
| ### Cas d'usage analytiques | |
| La table `predictions` permet de répondre aux questions métier suivantes : | |
| - **Taux de risque global** : quelle proportion d'employés soumis au modèle | |
| sont prédits "à risque" ? | |
| - **Évolution dans le temps** : le taux de prédictions "Quitte" augmente-t-il | |
| sur les dernières semaines ? | |
| - **Profils à risque dominants** : quels postes ou départements concentrent | |
| le plus de prédictions `1` ? | |
| - **Distribution des probabilités** : combien de prédictions sont proches du | |
| seuil 0.40 (zone d'incertitude) ? | |
| ### Exemple de requête SQL directe | |
| ```sql | |
| -- Taux de risque prédit par département | |
| SELECT departement, | |
| COUNT(*) AS nb_predictions, | |
| SUM(prediction) AS nb_a_risque, | |
| ROUND(100.0 * SUM(prediction) / COUNT(*), 1) AS taux_risque_pct | |
| FROM predictions | |
| GROUP BY departement | |
| ORDER BY taux_risque_pct DESC; | |
| ``` | |
| > Ces analyses peuvent alimenter un tableau de bord BI (Metabase, Superset, Power BI) connecté directement à la base PostgreSQL. | |
| ## Tests | |
| ### Lancer les tests | |
| ```bash | |
| pytest tests/ -v | |
| ``` | |
| ### Rapport de couverture | |
| Affichage dans le terminal : | |
| ```bash | |
| pytest tests/ --cov=app --cov-report=term-missing | |
| ``` | |
| Rapport HTML navigable (généré dans `htmlcov/`) : | |
| ```bash | |
| pytest tests/ --cov=app --cov-report=term-missing --cov-report=html | |
| ``` | |
| ### Résultat de couverture | |
| Dernière mesure : **96%** (18 tests, 247 instructions) | |
| | Fichier | Couverture | | |
| |---|---| | |
| | `app/db/crud.py` | 100% | | |
| | `app/db/models.py` | 100% | | |
| | `app/middleware/__init__.py` | 100% | | |
| | `app/schemas/prediction.py` | 98% | | |
| | `app/main.py` | 97% | | |
| | `app/middleware/logging.py` | 94% | | |
| | `app/routes/predict.py` | 93% | | |
| | `app/db/session.py` | 50% *(infrastructure PostgreSQL, non testée en isolation)* | | |
| ### Structure des tests | |
| | Fichier | Contenu | | |
| |---|---| | |
| | `tests/conftest.py` | Fixtures partagées (client de test, payload valide) | | |
| | `tests/test_api.py` | Tests des endpoints HTTP (health, predict, auth) | | |
| | `tests/test_model.py` | Tests du pipeline ML (chargement, prédiction, seuil) | | |
| | `tests/test_db.py` | Tests de la couche base de données (CRUD, contraintes d'intégrité) | | |
| ## Déploiement | |
| ### Environnements déployés | |
| | Environnement | Branche | URL | Base de données | | |
| |---|---|---|---| | |
| | Production | `main` | https://rriviere-attrition-api.hf.space/docs | PostgreSQL Neon (instance prod) | | |
| | Développement | `dev` | https://rriviere-attrition-api-dev.hf.space/docs | PostgreSQL Neon (instance dev) | | |
| - **Documentation Swagger (prod)** : https://rriviere-attrition-api.hf.space/docs | |
| En production et développement, PostgreSQL est hébergé sur Neon (serverless). Les credentials sont injectés via les variables/secrets HF Spaces. Les deux environnements disposent d'instances isolées | |
| ### Pipeline CI/CD | |
| Le déploiement est automatisé via GitHub Actions (`.github/workflows/ci_cd.yml`). | |
| ### Cartographie des environnements | |
| | Environnement | Déclencheur | Infrastructure | | |
| |---|---|---| | |
| | **CI / Tests unitaires** (éphémère) | Push/PR sur `main` ou `dev` | CI runner GitHub Actions (VM Ubuntu, détruite après les tests) | | |
| | **Développement** | Push sur `dev` | Hugging Face Space `dev` + PostgreSQL Neon (instance dev) | | |
| | **Production** | Push sur `main` | Hugging Face Space prod + PostgreSQL Neon (instance prod) | | |
| **Étapes :** | |
| 1. **Test** — installation des dépendances + exécution de `pytest` | |
| 2. **Deploy** — si les tests passent, push automatique vers Hugging Face Spaces qui rebuild l'image Docker | |
| **Secrets** (Settings → Secrets → Actions) : | |
| | Secret | Description | | |
| |---|---| | |
| | `HF_TOKEN` | Token Hugging Face avec droits Write | | |
| | `API_KEY` | Clé API injectée dans HF Spaces | | |
| | `DB_PASSWORD` | Mot de passe PostgreSQL injecté dans HF Spaces | | |
| **Variables** (Settings → Variables → Actions) : | |
| | Variable | Description | | |
| |---|---| | |
| | `HF_SPACE_PROD` | Nom du Space HF de production (ex : `username/space-name`) | | |
| | `HF_SPACE_DEV` | Nom du Space HF de développement | | |
| ## Sécurité | |
| ### Gestion des secrets | |
| | Contexte | Mécanisme | | |
| |---|---| | |
| | Développement local | Fichier `.env` (non commité, voir `.env.example`) | | |
| | GitHub Actions (CI/CD) | GitHub Secrets (Settings → Secrets → Actions) | | |
| | Hugging Face Spaces | HF Variables / Secrets (Settings → Variables) | | |
| Les secrets injectés au runtime sont : `API_KEY`, `HF_TOKEN`, `DB_PASSWORD`. | |
| ### Bonnes pratiques en production | |
| - Toujours servir l'API derrière HTTPS (assuré par Hugging Face Spaces) | |
| - Générer une clé robuste : `python -c "import secrets; print(secrets.token_hex(32))"` | |
| - Rotation régulière de la clé API recommandée (re-déploiement nécessaire) | |
| - Ne jamais committer `.env` (déjà listé dans `.gitignore`) | |
| ### Limitations actuelles (POC) | |
| > Cette implémentation est acceptable pour un POC / projet démonstratif. | |
| - La clé API est **statique et hachée (SHA-256)** : la comparaison se fait via `hmac.compare_digest` pour éviter les timing attacks | |
| - Pas de mécanisme de révocation ou d'expiration de token | |
| - Pas de rate limiting (protection contre le bruteforce absente) | |
| - En production réelle, préférer OAuth2 / JWT avec expiration | |
| ## Processus de stockage des données | |
| ### Flux complet d'un appel `/predict` | |
| ``` | |
| Requête HTTP (JSON) | |
| ↓ | |
| Validation Pydantic (PredictionInput) | |
| → Champs typés, valeurs bornées (ex : age entre 18 et 65) | |
| → Rejet 422 si données invalides | |
| ↓ | |
| Conversion en DataFrame pandas | |
| → Mise en forme compatible avec le pipeline scikit-learn | |
| ↓ | |
| Pipeline ML (predict_proba) | |
| → Retourne une probabilité entre 0 et 1 | |
| → Seuil 0.40 appliqué : proba ≥ 0.40 → prediction = 1 (Quitte) | |
| ↓ | |
| Log obligatoire en base de données (table predictions) | |
| → Inputs + outputs + timestamp enregistrés systématiquement | |
| ↓ | |
| Réponse JSON (PredictionOutput) | |
| ``` | |
| > **Important** : chaque appel à `/predict` est **systématiquement loggé** en base de données avant le renvoi de la réponse. Il n'est pas possible d'obtenir une prédiction sans persistance. | |
| ## Performances du modèle | |
| ### Métriques d'évaluation | |
| Le modèle est une **Régression Logistique** entraîné sur le dataset IBM HR Attrition. | |
| | Métrique | Valeur | | |
| |---|---| | |
| | Recall (classe "Quitte") | 79% | | |
| | Précision (classe "Quitte") | 36% | | |
| | F1-score (classe "Quitte") | 49% | | |
| | PR-AUC | 59% | | |
| | ROC-AUC | 83% | | |
| > Métriques évaluées sur le jeu de test (20% du dataset, stratifié). | |
| ### Justification du seuil 0.40 | |
| Le seuil de décision par défaut d'une régression logistique est 0.50. Il est ici abaissé à **0.40** pour : | |
| - **Maximiser la sensibilité (recall)** : dans un contexte RH, il est préférable d'identifier trop d'employés à risque plutôt que d'en manquer (le coût de remplacement d'un employé est élevé) | |
| - **Réduire les faux négatifs** : un employé prédit "Reste" qui part réellement est plus coûteux qu'une alerte inutile | |
| Ce choix est un compromis volontaire entre précision et recall, justifié par le domaine métier. | |
| ### Protocole de mise à jour du modèle | |
| Le modèle est actuellement statique (fichier `ml_model/pipeline.pkl` versionné dans Git). | |
| Pour le réentraîner : | |
| 1. Exporter les nouvelles données depuis la table `predictions` | |
| 2. Réentraîner le pipeline scikit-learn sur les données enrichies | |
| 3. Sauvegarder le nouveau `pipeline.pkl` | |
| 4. Ouvrir une PR sur `main` → le CI/CD redéploie automatiquement le modèle |