Module 7 — Téléversement de fichiers et téléchargements
Le fil rouge en est au point où l'utilisateur sait scorer un client à la fois. Une équipe commerciale n'y trouvera pas son compte : la vraie question est « donne-moi la liste triée des cinq cents clients les plus à risque, à partir de ce fichier ». Ce module ajoute le téléversement d'un CSV, la validation stricte de ses colonnes, le scoring en lot et le téléchargement du résultat. C'est la brique la plus proche d'un usage réel de l'application.
Le composant st.file_uploader
L'appel est direct : un composant, une liste de types acceptés, et l'objet renvoyé se comporte comme un fichier en lecture binaire.
import streamlit as st
import pandas as pd
fichier = st.file_uploader(
"Fichier de clients à scorer",
type=["csv", "xlsx"],
accept_multiple_files=False,
help="Colonnes attendues : identifiant, anciennete_mois, mensualite, type_contrat, fibre, paperless.",
)
if fichier is not None:
if fichier.name.endswith(".csv"):
clients = pd.read_csv(fichier)
else:
clients = pd.read_excel(fichier)
st.success(f"{len(clients)} lignes chargées depuis « {fichier.name} ».")
st.dataframe(clients.head(10), use_container_width=True)
Trois attributs sont utiles sur l'objet renvoyé : .name donne le nom d'origine, .size la taille en octets, et .type le type MIME transmis par le navigateur. L'argument type=["csv", "xlsx"] filtre les extensions côté navigateur — ce n'est pas une sécurité, seulement une aide à la sélection.
La validation, avant tout
Un fichier téléversé est une entrée non maîtrisée : colonnes manquantes, types mélangés, dates au mauvais format, doublons. Sans validation explicite, la moindre erreur produit une exception cryptique venue du fond de scikit-learn, au milieu de la boucle de scoring. On perd du temps à diagnostiquer, l'utilisateur reste avec un écran vide, et la confiance dans l'application chute.
La règle est de valider avant d'appeler quoi que ce soit. Un schéma explicite documente les attentes et permet de renvoyer un message clair.
COLONNES_REQUISES = {
"identifiant": "string",
"anciennete_mois": "integer",
"mensualite": "float",
"type_contrat": "string",
"fibre": "boolean",
"paperless": "boolean",
}
def valider(df: pd.DataFrame) -> list[str]:
"""Retourne la liste des erreurs de format ; vide si le fichier est correct."""
erreurs = []
manquantes = set(COLONNES_REQUISES) - set(df.columns)
if manquantes:
erreurs.append(f"Colonnes manquantes : {', '.join(sorted(manquantes))}.")
return erreurs # inutile d'aller plus loin
if df["anciennete_mois"].isna().any():
erreurs.append("La colonne « anciennete_mois » contient des valeurs manquantes.")
if (df["anciennete_mois"] < 0).any():
erreurs.append("La colonne « anciennete_mois » contient des valeurs négatives.")
if (df["mensualite"] <= 0).any():
erreurs.append("La colonne « mensualite » contient des valeurs nulles ou négatives.")
contrats_connus = {"Mensuel", "Annuel", "Deux ans"}
inconnus = set(df["type_contrat"].dropna().unique()) - contrats_connus
if inconnus:
erreurs.append(f"Valeurs inconnues dans « type_contrat » : {', '.join(sorted(inconnus))}.")
return erreurs
if fichier is not None:
clients = pd.read_csv(fichier)
erreurs = valider(clients)
if erreurs:
st.error("Le fichier n'est pas conforme :")
for e in erreurs:
st.write(f"- {e}")
st.stop()
st.stop() arrête proprement l'exécution du script sans lever d'exception : rien de ce qui suit n'est exécuté ni affiché, ce qui évite les cascades d'erreurs.
Le scoring en lot
Une fois le fichier validé, le scoring en lot est un simple appel vectorisé au modèle. On garde les colonnes d'entrée et on ajoute le score renvoyé.
@st.cache_resource
def charger_modele():
import joblib
return joblib.load("modeles/resiliation.joblib")
modele = charger_modele()
with st.spinner("Scoring en lot en cours..."):
caracteristiques = clients[["anciennete_mois", "mensualite", "type_contrat", "fibre", "paperless"]]
clients["score_risque"] = modele.predict_proba(caracteristiques)[:, 1]
clients["a_risque"] = (clients["score_risque"] >= 0.4).astype(int)
col_1, col_2, col_3 = st.columns(3)
col_1.metric("Lignes scorées", len(clients))
col_2.metric("Clients à risque", int(clients["a_risque"].sum()))
col_3.metric(
"Part à risque",
f"{clients['a_risque'].mean():.0%}",
delta_color="inverse",
)
st.subheader("Résultats détaillés")
st.dataframe(
clients.sort_values("score_risque", ascending=False),
use_container_width=True,
column_config={
"score_risque": st.column_config.ProgressColumn(
"Risque", min_value=0.0, max_value=1.0, format="%.0f%%"
),
},
)
Le st.spinner donne un retour visuel pendant le calcul, indispensable dès que le scoring dépasse la seconde. Sur un modèle scikit-learn, cent mille lignes se scorent en moins de cinq secondes ; au-delà, il vaut mieux découper le traitement en morceaux et afficher une barre de progression avec st.progress.
Télécharger le résultat
st.download_button produit un bouton qui déclenche le téléchargement d'un contenu construit par le script. Le contenu doit être fourni en bytes ou en texte, avec un nom de fichier et un type MIME.
import io
flux = io.BytesIO()
clients.to_csv(flux, index=False, encoding="utf-8")
flux.seek(0)
st.download_button(
label="Télécharger les résultats en CSV",
data=flux,
file_name="clients_scores.csv",
mime="text/csv",
type="primary",
)
Pour un fichier Excel, la construction change à peine :
flux = io.BytesIO()
with pd.ExcelWriter(flux, engine="openpyxl") as ecriture:
clients.to_excel(ecriture, index=False, sheet_name="Scores")
flux.seek(0)
st.download_button(
label="Télécharger les résultats en Excel",
data=flux,
file_name="clients_scores.xlsx",
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
Un point subtil : st.download_button déclenche une réexécution du script juste après le téléchargement, comme n'importe quel bouton. Si la génération du CSV est coûteuse, il faut la placer dans une fonction @st.cache_data pour ne pas la refaire à chaque affichage.
Les limites de taille
Streamlit accepte par défaut des fichiers jusqu'à 200 mégaoctets. Au-delà, le téléversement est rejeté avec un message explicite. On peut relever la limite dans le fichier de configuration .streamlit/config.toml :
[server]
maxUploadSize = 1024 # en mégaoctets, ici 1 Go
Relever cette limite n'est pas anodin. Un fichier d'un giga en RAM, lu par pandas, occupe facilement trois à cinq giga. Le serveur Streamlit héberge toutes les sessions ; deux téléversements simultanés d'utilisateurs différents suffisent à saturer une machine modeste. La bonne pratique consiste à limiter la taille au strict nécessaire, à surveiller la consommation mémoire, et à documenter la limite dans le libellé du composant :
st.file_uploader(
"Fichier de clients (jusqu'à 100 Mo)",
type=["csv"],
help="Au-delà de 100 000 lignes, utilisez plutôt l'API décrite au module 8.",
)
Un fichier téléversé porte l'extension que le navigateur veut bien annoncer. La validation du contenu doit être indépendante : lecture avec pd.read_csv dans un try / except, vérification que le résultat est un DataFrame, contrôle des colonnes. Un fichier binaire renommé en .csv produirait autrement un plantage difficile à diagnostiquer, voire, dans certains cas exotiques, un problème de sécurité si le contenu est réutilisé ailleurs.
En résumé
st.file_uploaderrenvoie un objet fichier avec les attributs.name,.size,.type; le filtre par extension aide l'utilisateur, il ne protège pas l'application.- La validation du contenu doit précéder tout traitement ;
st.stop()arrête proprement l'exécution en cas d'erreur, pour éviter les cascades d'exceptions. - Le scoring en lot combine
@st.cache_resourcesur le modèle et un appel vectorisé sur le DataFrame ; unst.spinnerdonne un retour visuel dès que l'opération dépasse la seconde. st.download_buttonfabrique le contenu au moment du clic ; passer la construction par@st.cache_dataévite de la refaire à chaque affichage.
Module suivant : appeler un modèle depuis l'application, en local puis via une API distante, avec gestion propre des erreurs.