Module 8 — Appel d'un modèle depuis l'application
Jusqu'ici, la fonction de scoring est une formule factice écrite à la main. Ce module la remplace enfin par un vrai modèle. Deux voies existent : charger le modèle sérialisé directement dans le processus Streamlit, ou déléguer le calcul à une API distante que couvre le cours 40. Les deux ont leur usage, et un tableau de bord de production finit souvent par les combiner. Ce module montre les deux avec la même rigueur : gestion des erreurs, délais, retour visuel à l'utilisateur.
Charger un modèle local avec joblib
Un modèle scikit-learn — pipeline compris — se sérialise en un seul fichier avec joblib.dump. Côté application, le chargement passe par un décorateur @st.cache_resource, pour que le modèle ne soit lu qu'une seule fois par processus.
import streamlit as st
import joblib
from pathlib import Path
CHEMIN_MODELE = Path("modeles/resiliation.joblib")
@st.cache_resource(show_spinner="Chargement du modèle...")
def charger_modele_local():
if not CHEMIN_MODELE.exists():
raise FileNotFoundError(
f"Le modèle est introuvable : {CHEMIN_MODELE}. "
"Le fichier doit être livré à côté de l'application."
)
modele = joblib.load(CHEMIN_MODELE)
version = getattr(modele, "__version__", "inconnue")
st.session_state.version_modele = version
return modele
try:
modele = charger_modele_local()
except FileNotFoundError as e:
st.error(str(e))
st.stop()
Trois points importent. Le chemin est relatif au dossier de l'application, pas au dossier courant du terminal, sans quoi le lancement depuis un autre répertoire échoue mystérieusement. On lève une exception explicite si le fichier manque, plutôt que de laisser joblib en cracher une opaque. Et l'on stocke la version du modèle dans l'état de session pour l'afficher en pied de page ou en journal, ce qui est capital pour tracer les prédictions en cas d'incident.
Appeler un modèle via une API distante
Quand le modèle est hébergé séparément — parce qu'il est trop lourd, qu'il partage sa capacité entre plusieurs applications ou qu'il est managé par une autre équipe — on l'appelle via HTTP. La bibliothèque requests fait l'affaire pour le premier essai.
import requests
URL_API = "https://api.exemple.fr/v1/resiliation/predire"
CLE_API = st.secrets["api_resiliation"]["cle"] # cf. module 10
def predire_via_api(caracteristiques: dict, delai: float = 5.0) -> float:
reponse = requests.post(
URL_API,
headers={
"Authorization": f"Bearer {CLE_API}",
"Content-Type": "application/json",
},
json=caracteristiques,
timeout=delai,
)
reponse.raise_for_status()
return reponse.json()["probabilite_resiliation"]
Le timeout est non négociable. Sans lui, un appel qui ne reçoit pas de réponse pend l'application jusqu'à la fin des temps, ce qui bloque tous les utilisateurs de la même session serveur. Cinq secondes est un plafond raisonnable pour une prédiction unitaire ; au-delà, il vaut mieux considérer l'API comme indisponible et le signaler à l'utilisateur.
raise_for_status() transforme une réponse HTTP 4xx ou 5xx en exception, qu'on peut ensuite intercepter proprement.
La gestion des erreurs, cœur de l'ergonomie
Ce qui différencie une application qu'on veut réutiliser d'une application qu'on redoute, c'est ce qui se passe quand ça se passe mal. Voici un motif à recopier :
from requests.exceptions import Timeout, ConnectionError, HTTPError
def scorer_prudemment(caracteristiques: dict) -> float | None:
try:
return predire_via_api(caracteristiques, delai=5.0)
except Timeout:
st.error("L'API n'a pas répondu à temps (5 s). Réessayez dans un instant.")
except ConnectionError:
st.error("Impossible de joindre l'API. Vérifiez votre connexion réseau.")
except HTTPError as e:
st.error(f"Erreur API ({e.response.status_code}) : {e.response.text[:200]}")
except Exception as e:
# Filet ultime : rien ne doit remonter en trace non filtrée à l'utilisateur.
st.error("Une erreur inattendue est survenue. Contactez l'équipe si elle persiste.")
st.exception(e) # journal détaillé pour la mise au point
return None
resultat = scorer_prudemment({"anciennete_mois": 24, "mensualite": 60.0})
if resultat is not None:
st.metric("Probabilité", f"{resultat:.0%}")
Chaque famille d'erreur reçoit un message adapté : un délai dépassé n'est pas un bogue de l'application mais un problème d'infrastructure temporaire, et il faut le dire. st.exception affiche la trace complète, utile pendant le développement mais qu'on masquera derrière un if st.session_state.get("mode_debug"): en production.
L'indicateur de chargement
Deux composants signalent à l'utilisateur qu'un calcul est en cours. st.spinner affiche un rond qui tourne dans un bloc contextuel :
with st.spinner("Consultation du modèle..."):
resultat = scorer_prudemment(caracteristiques)
st.progress affiche une barre qu'on incrémente manuellement, adaptée aux traitements découpés en étapes :
barre = st.progress(0, text="Scoring en lot...")
for i, ligne in enumerate(lot.itertuples(), 1):
resultats.append(scorer_prudemment(ligne._asdict()))
barre.progress(i / len(lot), text=f"Scoring en lot ({i}/{len(lot)})")
barre.empty()
L'appel barre.empty() retire la barre une fois le traitement terminé, sans quoi elle reste affichée à 100 %, ce qui est trompeur.
Local ou distant, comment choisir
Aucune des deux approches n'est supérieure dans l'absolu. La décision se prend sur trois critères mesurables.
| Critère | Local | Distant |
|---|---|---|
| Poids du modèle | ≤ 500 Mo | tout poids |
| Latence par prédiction | 1 à 20 ms | 20 à 200 ms |
| Nombre d'utilisateurs simultanés | faible | fort |
| Mise à jour du modèle | redéploiement de l'application | déploiement séparé de l'API |
| Coût d'hébergement | inclus dans Streamlit | serveur d'inférence à part |
| Complexité de développement | minimale | modérée |
Un tableau de bord interne consulté par cinq commerciaux : le local est plus simple et plus rapide. Une application publique consultée par mille utilisateurs simultanés : l'API distante est indispensable, sans quoi la charge du modèle sature le processus Streamlit. La vraie question à se poser est : « le modèle est-il partagé avec d'autres applications ? » Si oui, il doit vivre derrière une API pour éviter les duplications.
Combiner les deux
L'architecture qui a le meilleur rapport bénéfice/coût combine les deux : un modèle local léger pour la réponse immédiate en scoring individuel, et un appel API pour un scoring en lot plus lourd exécuté sur un serveur d'inférence dédié.
def scorer(caracteristiques: dict) -> float:
if st.session_state.mode == "immediat":
# Modèle léger chargé en local, réponse en 5 ms.
return float(modele.predict_proba([list(caracteristiques.values())])[0][1])
# Modèle profond hébergé, réponse en 100 ms mais bien meilleure.
return scorer_prudemment(caracteristiques)
Ce motif se retrouve dans beaucoup d'applications réelles : le rapide et pas trop faux pour l'interactif, le lent et précis pour le lot.
Ajoutez systématiquement, dans un pied de page ou une barre latérale, la version du modèle utilisé : st.caption(f"Modèle v{st.session_state.version_modele}"). Quand un utilisateur signale « le score de ce client m'étonne », vous saurez de quel modèle il parle et sur quel jeu d'entraînement il a été construit. Sans cette trace, chaque diagnostic prend des heures.
En résumé
- Un modèle local se charge une seule fois par processus avec
@st.cache_resourceautour dejoblib.load; le chemin est relatif au dossier de l'application. - Un modèle distant s'appelle via
requestsavec un timeout obligatoire ;raise_for_status()convertit les codes HTTP d'erreur en exception à intercepter. - Chaque famille d'exception (
Timeout,ConnectionError,HTTPError) reçoit un message dédié à l'utilisateur ; un filet ultime évite la fuite d'une trace brute. - Le choix local ou distant se tranche sur poids, latence, concurrence et politique de mise à jour ; les architectures matures combinent souvent les deux.
Module suivant : le thème, l'apparence et l'organisation en pages, pour que l'application ressemble à ce qu'attend l'entreprise.