"""
Webapp Streamlit orientée storytelling et exploration pour analyser
le lien entre effort culinaire et popularité des recettes.
"""
from __future__ import annotations
import base64
import inspect
import logging
import re
import sys
from pathlib import Path
import numpy as np
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import streamlit as st
from PIL import Image
# Ajouter le répertoire parent au path pour importer les modules locaux.
sys.path.append(str(Path(__file__).resolve().parents[1]))
from webapp_utils import ( # noqa: E402
compute_effort_pattern,
compute_histogram_figure,
compute_quartile_pattern,
fit_simple_regression,
infer_filter_options,
)
logger: logging.Logger | None = None
def _setup_logging() -> logging.Logger:
"""
Initialise le logger de l'application.
:returns: Logger configuré (ou logger générique si l'import échoue).
:rtype: logging.Logger
"""
global logger
try:
from src.logger import logger as custom_logger
logger = custom_logger
except ImportError:
logger = logging.getLogger(__name__)
return logger
logger = _setup_logging()
try:
_PLOTLY_CHART_PARAMS = inspect.signature(st.plotly_chart).parameters # type: ignore[arg-type]
except (ValueError, TypeError):
_PLOTLY_CHART_PARAMS = {}
_PLOTLY_SUPPORTS_WIDTH = "width" in _PLOTLY_CHART_PARAMS
_PLOTLY_SUPPORTS_USE_CONTAINER = "use_container_width" in _PLOTLY_CHART_PARAMS
def _plotly_config(width: str) -> dict:
"""
Mappe la notion de largeur souhaitée vers la configuration Plotly appropriée.
:param width: Largeur souhaitée (`'stretch'` ou `'content'`).
:type width: str
:returns: Paramètres de configuration Plotly correspondant.
:rtype: dict
"""
responsive = width == "stretch"
return {
"responsive": responsive,
"displaylogo": False,
"modeBarButtonsToRemove": ["lasso2d", "select2d"],
}
def _plotly_display(fig: go.Figure, *, width: str = "stretch") -> None:
"""
Affiche un graphique Plotly en respectant la convention de largeur demandée.
:param fig: Figure Plotly à représenter.
:type fig: go.Figure
:param width: Largeur d'affichage (`'stretch'` ou `'content'`).
:type width: str
:returns: ``None``.
:rtype: None
:raises ValueError: Si ``width`` n'est pas reconnu.
"""
if width not in {"stretch", "content"}:
raise ValueError("La largeur doit être 'stretch' ou 'content'.")
config = _plotly_config(width)
if _PLOTLY_SUPPORTS_WIDTH:
st.plotly_chart(fig, config=config, width=width)
elif _PLOTLY_SUPPORTS_USE_CONTAINER:
st.plotly_chart(
fig,
config=config,
use_container_width=width == "stretch",
)
else:
st.plotly_chart(fig, config=config)
def _import_analysis_modules() -> tuple[bool, object | None]:
"""
Tente d'importer les modules d'analyse réels.
:returns: Tuple ``(disponible, build_analysis_dataset)``.
:rtype: tuple[bool, object | None]
"""
try:
from dataset_analysis.dataset_preprocessing import build_analysis_dataset
logger.info("Modules d'analyse chargés avec succès.")
return True, build_analysis_dataset
except ImportError as exc:
logger.warning(
"Modules d'analyse indisponibles, bascule sur des données simulées : %s", exc
)
return False, None
[docs]
@st.cache_data
def load_real_datasets(_build_analysis_dataset_func):
"""
Charge les datasets nettoyés lorsqu'ils sont disponibles.
:param _build_analysis_dataset_func: Fonction permettant de construire les datasets préparés.
:type _build_analysis_dataset_func: Callable[..., tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]]
:returns: Tuple ``(recipes_df, interactions_df, analysis_df, succès)`` où chaque DataFrame peut être ``None``.
:rtype: tuple[pd.DataFrame | None, pd.DataFrame | None, pd.DataFrame | None, bool]
"""
try:
recipes_df, interactions_df, analysis_df = _build_analysis_dataset_func(save=False)
logger.info(
"Flux réel chargé (%d recettes, %d interactions).",
len(recipes_df),
len(interactions_df),
)
return recipes_df, interactions_df, analysis_df, True
except FileNotFoundError as exc:
logger.error("Fichiers de données introuvables : %s", exc)
st.error(
"Impossible de charger les données réelles. "
"Exécutez d'abord le pipeline de préparation."
)
except Exception as exc: # pylint: disable=broad-except
logger.exception("Erreur inattendue lors du chargement des données réelles : %s", exc)
st.error(f"Erreur lors du chargement des données réelles : {exc}")
return None, None, None, False
[docs]
@st.cache_data
def generate_sample_data(n_recipes: int = 1000) -> pd.DataFrame:
"""
Génère un dataset simulé pour illustrer la narration lorsqu'aucune donnée réelle
n'est disponible localement.
:param n_recipes: Nombre de recettes synthétiques à créer.
:type n_recipes: int
:returns: DataFrame simulé reprenant les principales variables d'analyse.
:rtype: pd.DataFrame
"""
logger.info("Génération de %d recettes simulées...", n_recipes)
np.random.seed(42)
n_ingredients = np.random.poisson(8, n_recipes) + 3
n_steps = np.random.poisson(6, n_recipes) + 2
minutes = np.random.lognormal(3.5, 0.8, n_recipes)
log_minutes = np.log(minutes)
avg_words_per_step = np.random.normal(15, 5, n_recipes).clip(5, 30)
effort_score = (
(n_ingredients - 3) / 15 * 0.25
+ (n_steps - 2) / 20 * 0.25
+ (log_minutes - 2) / 4 * 0.30
+ (avg_words_per_step - 5) / 25 * 0.20
) * 100
base_rating = 4.2 - 0.3 * (effort_score / 100) + np.random.normal(0, 0.3, n_recipes)
avg_rating = np.clip(base_rating, 1, 5)
bayes_mean = np.clip(avg_rating + np.random.normal(0, 0.1, n_recipes), 1, 5)
wilson_lb = np.random.beta(2, 1, n_recipes) * 0.8 + 0.1
n_interactions = np.random.poisson(
20 * np.exp(-0.5 * (effort_score / 100)), n_recipes
) + 1
age_months = np.random.exponential(24, n_recipes)
interactions_per_month = n_interactions / np.maximum(1, age_months)
log1p_interactions_per_month_w = np.log1p(interactions_per_month)
def categorize_effort(score: float) -> str:
if score <= 15:
return "Très Facile"
if score <= 20:
return "Facile"
if score <= 25:
return "Modéré"
if score <= 30:
return "Difficile"
return "Très Difficile"
effort_category = [categorize_effort(score) for score in effort_score]
simulated_df = pd.DataFrame(
{
"id": range(1, n_recipes + 1),
"n_ingredients": n_ingredients,
"n_steps": n_steps,
"minutes": minutes,
"log_minutes": log_minutes,
"avg_words_per_step": avg_words_per_step,
"effort_score": effort_score,
"effort_category": effort_category,
"avg_rating": avg_rating,
"bayes_mean": bayes_mean,
"wilson_lb": wilson_lb,
"age_months": age_months,
"n_interactions": n_interactions,
"interactions_per_month": interactions_per_month,
"log1p_interactions_per_month_w": log1p_interactions_per_month_w,
}
)
simulated_df["log_n_ingredients"] = np.log(simulated_df["n_ingredients"].clip(lower=1))
try:
simulated_df["effort_quartile"] = pd.qcut(
simulated_df["effort_score"],
4,
labels=["Q1", "Q2", "Q3", "Q4"],
duplicates="drop",
)
except ValueError:
simulated_df["effort_quartile"] = pd.Series(["Q1"] * len(simulated_df))
return simulated_df
def _configure_streamlit() -> None:
"""
Applique les réglages généraux de la page Streamlit.
:returns: ``None``.
:rtype: None
"""
st.set_page_config(
page_title="Effort culinaire & Popularité",
page_icon="assets/logo_MTM.png",
layout="wide",
initial_sidebar_state="expanded",
)
def _prepare_analysis_data() -> tuple[pd.DataFrame, str]:
"""
Charge les données d'analyse réelles ou bascule sur un échantillon simulé.
:returns: Tuple ``(dataset, origine)`` où ``origine`` vaut ``"réelles"`` ou ``"simulées"``.
:rtype: tuple[pd.DataFrame, str]
"""
has_real_data, build_analysis_dataset = _import_analysis_modules()
if has_real_data and build_analysis_dataset:
_, _, analysis_df, success = load_real_datasets(build_analysis_dataset)
if success and analysis_df is not None and not analysis_df.empty:
return analysis_df, "réelles"
fallback_df = generate_sample_data()
return fallback_df, "simulées"
def _render_sidebar(data_origin: str, dataset: pd.DataFrame) -> pd.DataFrame:
"""
Affiche les filtres latéraux et retourne le dataset filtré.
:param data_origin: Provenance des données (``"réelles"`` ou ``"simulées"``).
:type data_origin: str
:param dataset: Jeu de données initial à filtrer.
:type dataset: pd.DataFrame
:returns: DataFrame filtré selon les choix de l'utilisateur.
:rtype: pd.DataFrame
"""
# Charger l’image logo
logo = Image.open("assets/logo_MTM.png")
st.sidebar.image(logo) # ajuste la taille de l'image
st.sidebar.header("Filtres")
filtered = dataset.copy()
options = infer_filter_options(dataset)
if "minutes" in dataset.columns:
minutes_series = dataset["minutes"].dropna()
if not minutes_series.empty:
min_minutes = int(np.floor(minutes_series.quantile(0.01)))
max_minutes = int(np.ceil(minutes_series.quantile(0.99)))
if min_minutes < max_minutes:
default_high = int(np.ceil(minutes_series.quantile(0.75)))
default_range = (
min_minutes,
min(max_minutes, max(min_minutes + 1, default_high)),
)
step_minutes = max(1, int((max_minutes - min_minutes) // 12))
step_minutes = min(step_minutes, max_minutes - min_minutes)
step_minutes = max(1, step_minutes)
selected_minutes = st.sidebar.slider(
"Temps de préparation (minutes)",
min_value=min_minutes,
max_value=max_minutes,
value=default_range,
step=step_minutes,
)
filtered = filtered[
(filtered["minutes"] >= selected_minutes[0])
& (filtered["minutes"] <= selected_minutes[1])
]
if options.age_range and options.age_range[0] < options.age_range[1]:
selected_age = st.sidebar.slider(
"Âge des recettes (mois) à partir d'aujourd'hui",
min_value=float(options.age_range[0]),
max_value=float(options.age_range[1]),
value=options.age_range,
step=1.0,
)
filtered = filtered[
(filtered["age_months"] >= selected_age[0])
& (filtered["age_months"] <= selected_age[1])
]
if options.interactions_range and options.interactions_range[0] < options.interactions_range[1]:
min_inter, max_inter = options.interactions_range
threshold = st.sidebar.slider(
"Interactions minimales",
min_value=int(min_inter),
max_value=int(max_inter),
value=int(min_inter),
step=1,
)
filtered = filtered[filtered["n_interactions"] >= threshold]
if options.effort_categories:
selected_categories = st.sidebar.multiselect(
"Catégories d'effort",
options=options.effort_categories,
default=options.effort_categories,
)
if selected_categories:
filtered = filtered[filtered["effort_category"].isin(selected_categories)]
else:
filtered = filtered.iloc[0:0]
if "bayes_mean" in filtered.columns:
bayes_min = float(filtered["bayes_mean"].min())
bayes_max = float(filtered["bayes_mean"].max())
if bayes_max > bayes_min:
min_rating = st.sidebar.slider(
"Note bayésienne minimale",
min_value=bayes_min,
max_value=bayes_max,
value=bayes_min,
step=0.1,
)
filtered = filtered[filtered["bayes_mean"] >= min_rating]
return filtered
def _is_valid_number(value: float | None) -> bool:
"""
Vérifie qu'une valeur numérique est définie et non ``NaN``.
:param value: Valeur à tester.
:type value: float | None
:returns: ``True`` si la valeur est exploitable, ``False`` sinon.
:rtype: bool
"""
return value is not None and not np.isnan(value)
def _compute_story_indicators(data: pd.DataFrame, quartile_pattern) -> dict:
"""
Calcule les indicateurs chiffrés affichés en tête de page.
:param data: Dataset filtré actuellement visible.
:type data: pd.DataFrame
:param quartile_pattern: Résultat du calcul de pattern en quartiles (ou ``None``).
:type quartile_pattern: webapp_utils.QuartilePatternData | None
:returns: Dictionnaire des indicateurs (recettes, efforts, corrélations, etc.).
:rtype: dict
"""
indicators: dict = {"recipes": len(data)}
indicators["avg_popularity"] = (
float(data["bayes_mean"].mean()) if "bayes_mean" in data.columns else np.nan
)
indicators["avg_effort"] = (
float(data["effort_score"].mean()) if "effort_score" in data.columns else np.nan
)
if "effort_category" in data.columns:
distribution = data["effort_category"].value_counts(normalize=True)
indicators["share_very_easy"] = float(distribution.get("Très Facile", np.nan))
indicators["share_very_hard"] = float(distribution.get("Très Difficile", np.nan))
else:
indicators["share_very_easy"] = np.nan
indicators["share_very_hard"] = np.nan
if {"effort_score", "bayes_mean"}.issubset(data.columns):
subset = data[["effort_score", "bayes_mean"]].dropna()
if len(subset) > 2:
indicators["spearman"] = float(
subset["effort_score"].corr(subset["bayes_mean"], method="spearman")
)
else:
indicators["spearman"] = np.nan
else:
indicators["spearman"] = np.nan
if quartile_pattern and not quartile_pattern.means.empty:
amplitude = float(quartile_pattern.means.max() - quartile_pattern.means.min())
indicators["quartile_amplitude"] = amplitude
indicators["quartile_means"] = quartile_pattern.means
else:
indicators["quartile_amplitude"] = np.nan
indicators["quartile_means"] = None
return indicators
def _render_metrics(indicators: dict, total_count: int | None = None) -> None:
"""
Affiche les indicateurs clés sous forme de compteurs Streamlit.
:param indicators: Indicateurs calculés via ``_compute_story_indicators``.
:type indicators: dict
:param total_count: Taille totale du dataset avant filtrage.
:type total_count: int | None
:returns: ``None``.
:rtype: None
"""
col1, col2, col3 = st.columns(3)
col1.metric("Recettes analysées", f"{indicators['recipes']:,}")
if _is_valid_number(indicators["avg_popularity"]):
col2.metric(
"Popularité moyenne",
f"{indicators['avg_popularity']:.2f} / 5",
)
else:
col2.metric("Popularité moyenne", "—")
if _is_valid_number(indicators["avg_effort"]):
col3.metric(
"Effort moyen",
f"{indicators['avg_effort']:.0f} / 100",
)
else:
col3.metric("Effort moyen", "—")
if total_count is not None and total_count != indicators["recipes"]:
st.caption(
f"Filtres actifs : {indicators['recipes']:,} recettes visibles sur "
f"{total_count:,}."
)
if all(
_is_valid_number(indicators[key]) for key in ("share_very_easy", "share_very_hard")
):
st.caption(
f"Répartition : {indicators['share_very_easy']:.0%} très faciles · "
f"{indicators['share_very_hard']:.0%} très difficiles."
)
def _render_patterns(pattern, quartile_pattern) -> None:
"""
Affiche les visualisations principales du storytelling.
:param pattern: Données agrégées par catégorie d'effort (ou ``None``).
:type pattern: webapp_utils.EffortPatternData | None
:param quartile_pattern: Données agrégées par quartile d'effort (ou ``None``).
:type quartile_pattern: webapp_utils.QuartilePatternData | None
:returns: ``None``.
:rtype: None
"""
if pattern is None and quartile_pattern is None:
st.info(
"Les colonnes nécessaires aux visualisations (effort_category, bayes_mean) "
"sont absentes du dataset courant."
)
return
if pattern is not None:
fig = go.Figure()
fig.add_trace(
go.Scatter(
x=pattern.categories,
y=pattern.expected,
mode="lines+markers",
name="Intuition : plus d'effort, moins de popularité",
line=dict(color="#A0AEC0", dash="dash"),
marker=dict(size=8),
)
)
fig.add_trace(
go.Scatter(
x=pattern.categories,
y=pattern.observed,
mode="lines+markers",
name="Données observées",
line=dict(color="#EF553B", width=3),
marker=dict(size=9),
)
)
fig.update_layout(
title="Popularité moyenne par niveau d'effort perçu",
yaxis_title="Note moyenne (bayes_mean)",
xaxis_title="Niveau d'effort",
height=420,
legend_orientation="h",
template="plotly_white",
)
_plotly_display(fig, width="stretch")
# Encart d'observations figées pour le graphique d'effort
with st.container():
st.markdown(
"""
<div style="
background-color: #f8f9fa;
padding: 20px;
border-radius: 10px;
border-left: 4px solid #FF6B6B;
margin: 20px 0;
">
<h5 style="color: #2c3e50; margin-top: 0;">
Contexte - Graphique d'effort
</h5>
<p style="color: #34495e; line-height: 1.6;">
Ce graphique compare la
<strong>popularité moyenne des recettes</strong> (axe vertical)
en fonction du <strong>niveau d'effort perçu</strong>
(axe horizontal).
</p>
<p style="color: #34495e; line-height: 1.6;">
Deux lignes sont présentées :
</p>
<ul style="color: #34495e; line-height: 1.6;">
<li>
<strong>Ligne grise pointillée</strong> : une intuition
courante — <em>plus une recette demande d'effort, moins
elle est populaire</em>.
</li>
<li>
<strong>Ligne rouge</strong> :
les <strong>données observées</strong>.
</li>
</ul>
<h5 style="color: #2c3e50; margin-top: 15px;">Observations</h5>
<ul style="color: #34495e; line-height: 1.6;">
<li>
<strong>Stabilité inattendue :</strong>
contrairement à l'intuition, la popularité observée reste
<strong>stable</strong> quel que soit le niveau de
difficulté perçue.
</li>
<li>
<strong>Niveau de popularité :</strong>
les notes moyennes sont proches, y compris pour les
recettes jugées « très difficiles ».
</li>
<li>
<strong>Pas de pénalité pour la complexité :</strong>
les recettes exigeantes ne sont
<strong>pas moins appréciées</strong> que les recettes
faciles.
</li>
</ul>
<h5 style="color: #2c3e50; margin-top: 15px;">Interprétations</h5>
<p style="color: #34495e; line-height: 1.6;">
Il semblerait que la
<strong>perception de l'effort</strong> n'influence pas
directement la popularité des recettes — du moins
<strong>pas de manière linéaire ou négative</strong>.
</p>
</div>
""",
unsafe_allow_html=True,
)
if quartile_pattern is not None and not quartile_pattern.means.empty:
fig_quartile = go.Figure()
fig_quartile.add_trace(
go.Scatter(
x=list(quartile_pattern.labels),
y=list(quartile_pattern.means.values),
mode="lines+markers",
line=dict(color="#636EFA", width=3),
marker=dict(size=9),
name="Popularité moyenne",
)
)
fig_quartile.update_layout(
title="Pattern en U : popularité par quartile d'effort",
yaxis_title="Note bayésienne",
xaxis_title="Quartile d'effort",
height=380,
showlegend=False,
template="plotly_white",
)
_plotly_display(fig_quartile, width="stretch")
# Encart d'observations figées pour le pattern en U
with st.container():
st.markdown(
"""
<div style="
background-color: #f8f9fa;
padding: 20px;
border-radius: 10px;
border-left: 4px solid #FF6B6B;
margin: 20px 0;
">
<h5 style="color: #2c3e50; margin-top: 15px;">
Contexte - Pattern en U
</h5>
<ul style="color: #34495e; line-height: 1.6;">
<li>
L’axe horizontal découpe les recettes en 4 groupes d’effort
(quartiles) : Q1 = les plus simples … Q4 = les plus
complexes.
</li>
<li>
L’axe vertical indique la note moyenne de popularité des
recettes.
</li>
</ul>
<h5 style="color: #2c3e50; margin-top: 15px;">Observations</h5>
<ul style="color: #34495e; line-height: 1.6;">
<li>
La courbe forme un petit “U” : les recettes très simples
(Q1) et très complexes (Q4) ont une popularité légèrement
plus élevée que celles d’effort moyen (Q2–Q3).
</li>
<li>
L’écart est minuscule entre le minimum (Q3) et le maximum
(Q1/Q4). Autrement dit, toutes les catégories ont
quasiment la même note.
</li>
</ul>
<h5 style="color: #2c3e50; margin-top: 15px;">Interprétations</h5>
<ul style="color: #34495e; line-height: 1.6;">
<li>
On peut parler d’un “pattern en U” (statistiquement
détectable), mais l’amplitude est tellement faible qu’elle
est sans impact concret pour l'utilisateur.
</li>
<p>
Ainsi, même si la forme en U existe, la popularité reste
quasi identique quel que soit le niveau d’effort.
</p>
</ul>
</div>
""",
unsafe_allow_html=True,
)
def _render_methodology_section() -> None:
"""
Présente les encarts méthodologiques et contextuels dans des volets repliables.
:returns: ``None``.
:rtype: None
"""
with st.expander("Comment avons-nous préparé les données ?", expanded=False):
st.markdown(
"- Nettoyage des temps extrêmes : suppression des recettes < 1 minute "
"ou > 6 heures, transformation logarithmique sur `minutes`.\n"
"- Construction d'un score d'effort combinant durée, étapes, ingrédients "
"et complexité textuelle (`avg_words_per_step`).\n"
"- Calcul d'indicateurs de popularité robustes (`bayes_mean`, "
"`wilson_lb`, interactions mensuelles) avec transformations log / winsorisation.\n"
"- Stratification par quartiles d'effort pour tester statistiquement les "
"différences (ANOVA + Kruskal-Wallis)."
)
with st.expander("Liens avec le cahier des charges", expanded=False):
st.markdown(
"- Storytelling clair et accessible pour le grand public.\n"
"- Visualisations dynamiques (Plotly) et widgets interactifs Streamlit.\n"
"- Mise en avant des hypothèses, résultats et limites directement dans "
"l'interface.\n"
"- Possibilité de filtrer les données selon l'effort, les interactions et les "
"notes pour s'approprier l'analyse."
)
def _render_correlation_matrix(data: pd.DataFrame) -> None:
"""
Affiche la matrice de corrélation sur un sous-ensemble de variables cibles.
:param data: Dataset filtré contenant les colonnes numériques.
:type data: pd.DataFrame
:returns: ``None``.
:rtype: None
"""
target_vars = [
"log_minutes",
"n_steps",
"n_ingredients",
"effort_score",
"bayes_mean",
"wilson_lb",
]
numeric_df = data.select_dtypes(include=[np.number])
available_vars = [col for col in target_vars if col in numeric_df.columns]
if len(available_vars) < 2:
st.info(
"La matrice de corrélation nécessite au moins deux variables parmi : "
"`log_minutes`, `n_steps`, `n_ingredients`, `effort_score`, "
"`bayes_mean`, `wilson_lb`."
)
return
numeric_df = numeric_df[available_vars].dropna(how="all", axis=1)
if numeric_df.shape[1] < 2:
st.info(
"Après nettoyage des colonnes vides, il ne reste pas assez de variables pour "
"calculer une matrice de corrélation."
)
return
method_map = {
"Spearman": "spearman",
"Pearson": "pearson",
"Kendall": "kendall",
}
method_choice = st.radio(
"Méthode de calcul",
options=list(method_map.keys()),
index=0,
horizontal=True,
key="corr_method",
)
st.caption(
"""
**Pearson** permet de mesurer la force d’une **relation linéaire entre
deux variables numériques**. Il est conseillé de l’appliquer sur des
**valeurs brutes**, qui contiennent **peu d’outliers** ou de fortes
asymétries.
**Spearman** permet de mesurer la force d’une **relation monotone
croissante ou décroissante**, même si elle n’est pas linéaire. La méthode
de calcul de Spearman est plus **robuste aux outliers** et **adaptée aux
distributions non normales** notamment.
**Kendall** permet d’évaluer la monotonicité, mais à partir de la
**proportion de paires concordantes/discordantes**. Cette méthode de
calcul est **très robuste sur petits échantillons** et en **présence de
nombreux ex-æquo**.
"""
)
corr = numeric_df.corr(method=method_map[method_choice]).replace([np.inf, -np.inf], np.nan)
corr = corr.fillna(0.0)
heatmap_height = min(1200, 120 + 32 * corr.shape[0])
fig = px.imshow(
corr,
text_auto=".2f",
color_continuous_scale="RdBu",
zmin=-1,
zmax=1,
aspect="auto",
title=f"Matrice de corrélation ({method_choice})",
)
fig.update_layout(
height=heatmap_height,
xaxis=dict(side="bottom"),
yaxis=dict(autorange="reversed"),
margin=dict(l=0, r=0, t=80, b=0),
)
_plotly_display(fig, width="stretch")
with st.container():
st.markdown(
"""
<div style="
background-color: #f8f9fa;
padding: 20px;
border-radius: 10px;
border-left: 4px solid #FF6B6B;
margin: 20px 0;
">
<h4 style="color: #2c3e50; margin-top: 0;">
Contexte - Matrice de corrélation
</h4>
<ul style="color: #34495e; line-height: 1.6;">
<p>
Chaque case mesure à quel point deux variables évoluent
ensemble (de –1 à +1).
</p>
<li>
<strong>+1</strong> : elles montent/descendent
<strong>ensemble</strong> (corrélation positive parfaite).
</li>
<li>
<strong>0</strong> :
<strong>aucun lien linéaire</strong> détecté.
</li>
<li>
<strong>–1</strong> : quand l’une monte, l’autre
<strong>baisse</strong> (corrélation négative parfaite).
</li>
<p>
La <strong>couleur</strong> et le <strong>nombre</strong>
indiquent la force du lien.
</p>
</ul>
<h4 style="color: #2c3e50; margin-top: 0;">
Observations avec interprétations
</h4>
<ul style="color: #34495e; line-height: 1.6;">
<li>
<strong>Les indicateurs d’effort</strong>
<p>
<code>log_minutes</code> (durée) ↔
<code>effort_score</code> → très forte association.<br>
<code>n_ingredients</code> ↔ <code>effort_score</code> →
forte association.<br>
<code>log_minutes</code> ↔ <code>n_ingredients</code> →
association modérée.<br>
Une déduction possible serait que plus il y a
d’ingrédients et plus c’est long, plus le
<strong>score d’effort</strong> est élevé (cohérent).
Mais le coefficient de corrélation reste trop faible
pour valider cette déduction.
</p>
</li>
</ul>
<ul style="color: #34495e; line-height: 1.6;">
<li>
<strong>La popularité et l’effort</strong>
<p>
<code>bayes_mean</code> (note moyenne “robuste”) et
<code>wilson_lb</code> (borne de confiance) ont des
corrélations <strong>qui tendent vers 0</strong> avec
<code>effort_score</code>, <code>log_minutes</code> et
<code>n_ingredients</code>.<br>
<strong>
Il n'y a donc pas de corrélation entre l'effort et
la popularité des recettes.
</strong>
</p>
</li>
</ul>
<ul style="color: #34495e; line-height: 1.6;">
<li>
<strong>Activité des utilisateurs et note donnée</strong>
<p>
<code>n_interactions</code> (nombre d’avis/notations) ↔
<code>wilson_lb</code> (modéré).<br>
Plus il y a d’interactions, plus l’estimation de la
popularité est <strong>faiblement</strong>
meilleure/plus fiable.<br>
<em>
Cependant les notes données par les utilisateurs
étant majoritairement hautes, nos interprétations
sont donc biaisées.
</em>
</p>
</li>
</ul>
<ul style="color: #34495e; line-height: 1.6;">
<li>
<strong>Texte et longueur des étapes</strong>
<p>
<code>avg_words_per_step</code> a des corrélations
<strong>faibles</strong> avec le reste des variables
(proches de zéro).<br>
La <strong>verbosité</strong> des instructions
n’explique donc pas la popularité.
</p>
</li>
</ul>
</div>
""",
unsafe_allow_html=True,
)
def _render_explorer(data: pd.DataFrame) -> None:
"""
Affiche la section d'exploration libre effort/popularité.
:param data: Dataset filtré courant.
:type data: pd.DataFrame
:returns: ``None``.
:rtype: None
"""
if data.empty:
st.info("Aucune donnée disponible avec les filtres actuels.")
return
st.write(
"Choisissez vos axes pour visualiser la relation effort/popularité et ajustez "
"les distributions en direct."
)
effort_candidates = [
"effort_score",
"log_minutes",
"n_steps",
"n_ingredients",
"avg_words_per_step",
]
popularity_candidates = [
"bayes_mean",
"wilson_lb",
"avg_rating",
"log1p_interactions_per_month_w",
"interactions_per_month",
"n_interactions",
]
available_effort = [col for col in effort_candidates if col in data.columns]
available_popularity = [col for col in popularity_candidates if col in data.columns]
if not available_effort or not available_popularity:
st.info("Les colonnes nécessaires à l'exploration ne sont pas présentes.")
return
col_controls = st.columns(3)
effort_axis = col_controls[0].selectbox(
"Axe effort", available_effort, index=0, key="explorer_effort"
)
popularity_axis = col_controls[1].selectbox(
"Axe popularité", available_popularity, index=0, key="explorer_popularity"
)
color_options = ["Aucune"]
color_options.extend(
[col for col in ("effort_category", "effort_quartile") if col in data.columns]
)
color_choice = col_controls[2].selectbox(
"Colorer par", color_options, index=0, key="explorer_color"
)
sample_data = data.sample(min(5000, len(data)), random_state=42) if len(data) > 5000 else data
fig = px.scatter(
sample_data,
x=effort_axis,
y=popularity_axis,
color=color_choice if color_choice != "Aucune" else None,
size="n_interactions" if "n_interactions" in data.columns else None,
hover_data=[col for col in ["id", "minutes", "n_ingredients"] if col in data.columns],
opacity=0.7,
template="plotly_white",
title="Explorer librement effort et popularité",
)
if st.checkbox("Afficher la tendance linéaire", value=True, key="explorer_trend"):
regression = fit_simple_regression(sample_data, effort_axis, popularity_axis)
if regression:
fig.add_trace(
go.Scatter(
x=regression.x_curve,
y=regression.y_curve,
mode="lines",
name="Régression linéaire",
line=dict(color="#EB6F92", width=2),
)
)
fig.add_annotation(
x=0.99,
y=0.02,
xref="paper",
yref="paper",
text=f"R² = {regression.r_squared:.3f}",
showarrow=False,
font=dict(color="#EB6F92"),
)
_plotly_display(fig, width="stretch")
# Encart d'descriptif
with st.container():
st.markdown(
"""
<div style="
background-color: #f8f9fa;
padding: 20px;
border-radius: 10px;
border-left: 4px solid #FF6B6B;
margin: 20px 0;
">
<ul style="color: #34495e; line-height: 1.6;">
<p>
Exploration utile pour visualiser la régression linéaire
entre deux variables.
</p>
<p>
Une régression linéaire est une méthode statistique qui
modélise la relation entre une variable cible y et une ou
plusieurs variables explicatives x (le nuage de points),
par une droite (ici rouge).
</p>
<p>
Le fait d'observer une droite quasiment horizontale permet
de comprendre qu'il n'y a aucun effet linéaire de x sur y.
En d'autres termes, les variations de x n'impliquent pas
de variations sur y.
</p>
</ul>
</div>
""",
unsafe_allow_html=True,
)
hist_var = st.selectbox(
"Distribution à explorer",
available_effort + available_popularity,
index=0,
key="hist_var",
)
hist_fig = compute_histogram_figure(data[hist_var], var_label=hist_var)
_plotly_display(hist_fig, width="stretch")
# Encart d'descriptif
with st.container():
st.markdown(
"""
<div style="
background-color: #f8f9fa;
padding: 20px;
border-radius: 10px;
border-left: 4px solid #FF6B6B;
margin: 20px 0;
">
<ul style="color: #34495e; line-height: 1.6;">
<p>
Exploration utile pour visualiser la distribution des
valeurs. Pour certaines valeurs, la distribution est
difficilement interprétable.
</p>
</ul>
</div>
""",
unsafe_allow_html=True,
)
if "effort_category" in data.columns:
effort_order = ["Très Facile", "Facile", "Modéré", "Difficile", "Très Difficile"]
cat_counts = (
data["effort_category"]
.value_counts(normalize=True)
.rename_axis("Effort")
.reset_index(name="Part")
)
order_mapping = {label: idx for idx, label in enumerate(effort_order)}
cat_counts = cat_counts.sort_values(
by="Effort",
key=lambda series: series.map(order_mapping).fillna(len(order_mapping)),
)
fig_categories = px.bar(
cat_counts,
x="Effort",
y="Part",
text="Part",
title="Répartition des recettes par niveau d'effort",
template="plotly_white",
)
fig_categories.update_traces(texttemplate="%{text:.0%}", textposition="outside")
fig_categories.update_yaxes(tickformat=".0%")
_plotly_display(fig_categories, width="stretch")
# Encart d'descriptif
with st.container():
st.markdown(
"""
<div style="
background-color: #f8f9fa;
padding: 20px;
border-radius: 10px;
border-left: 4px solid #FF6B6B;
margin: 20px 0;
">
<ul style="color: #34495e; line-height: 1.6;">
<p>
Exploration utile pour comprendre la répartition des
recettes par niveau d'effort. Cette répartition n'est
pas homogène.
</p>
</ul>
</div>
""",
unsafe_allow_html=True,
)
def _render_scenario_planner(data: pd.DataFrame) -> None:
"""
Propose un assistant interactif pour identifier des recettes respectant des contraintes.
:param data: Dataset filtré contenant au minimum ``minutes``, ``n_ingredients`` et ``bayes_mean``.
:type data: pd.DataFrame
:returns: ``None``.
:rtype: None
"""
required_cols = {"minutes", "n_ingredients", "bayes_mean"}
st.write(
"Définissez vos contraintes et découvrez les recettes qui tiennent la promesse "
"popularité vs effort."
)
if not required_cols.issubset(data.columns):
st.info(
"Certaines colonnes indispensables à cet assistant ne sont pas disponibles "
"dans le dataset courant."
)
return
minutes_series = data["minutes"].dropna()
ingredients_series = data["n_ingredients"].dropna()
if minutes_series.empty or ingredients_series.empty:
st.info("Impossible d'estimer le temps ou les ingrédients avec les filtres actuels.")
return
max_minutes = int(np.ceil(minutes_series.quantile(0.95)))
max_minutes = max(max_minutes, 15)
max_ingredients = int(np.ceil(ingredients_series.quantile(0.95)))
max_ingredients = max(max_ingredients, 5)
col1, col2, col3 = st.columns(3)
minutes_cap = col1.slider(
"Temps maximum disponible (minutes)",
min_value=0,
max_value=max_minutes,
value=min(45, max_minutes),
step=5,
key="scenario_minutes",
)
ingredient_cap = col2.slider(
"Nombre max d'ingrédients",
min_value=1,
max_value=max_ingredients,
value=min(10, max_ingredients),
step=1,
key="scenario_ingredients",
)
min_rating = col3.slider(
"Note minimale souhaitée",
min_value=1.0,
max_value=5.0,
value=4.0,
step=0.1,
key="scenario_rating",
)
effort_pref = "Peu importe"
if "effort_category" in data.columns:
effort_options = ["Peu importe"] + sorted(data["effort_category"].dropna().unique())
effort_pref = st.radio(
"Niveau d'effort recherché",
options=effort_options,
horizontal=True,
key="scenario_effort",
)
scenario_df = data.copy()
scenario_df = scenario_df[scenario_df["minutes"] <= minutes_cap]
scenario_df = scenario_df[scenario_df["n_ingredients"] <= ingredient_cap]
scenario_df = scenario_df[scenario_df["bayes_mean"] >= min_rating]
if effort_pref != "Peu importe" and "effort_category" in scenario_df.columns:
scenario_df = scenario_df[scenario_df["effort_category"] == effort_pref]
st.markdown("---")
if scenario_df.empty:
st.warning(
"Aucune recette ne correspond à ces critères. Desserrez légèrement les curseurs."
)
return
scenario_mean_effort = (
scenario_df["effort_score"].mean() if "effort_score" in scenario_df.columns else np.nan
)
metrics_cols = st.columns(3)
metrics_cols[0].metric("Recettes compatibles", f"{len(scenario_df):,}")
metrics_cols[1].metric("Note moyenne", f"{scenario_df['bayes_mean'].mean():.2f} / 5")
if _is_valid_number(scenario_mean_effort):
metrics_cols[2].metric("Effort moyen", f"{scenario_mean_effort:.0f} / 100")
elif "n_steps" in scenario_df.columns:
metrics_cols[2].metric("Étapes moyennes", f"{scenario_df['n_steps'].mean():.1f}")
else:
metrics_cols[2].metric("Effort moyen", "—")
display_cols = [
col
for col in (
"id",
"minutes",
"n_ingredients",
"effort_score",
"effort_category",
"bayes_mean",
"wilson_lb",
"n_interactions",
)
if col in scenario_df.columns
]
top_candidates = (
scenario_df.sort_values(["bayes_mean", "n_interactions"], ascending=[False, False])
.head(5)
.reset_index(drop=True)
)
st.dataframe(top_candidates[display_cols], width="stretch")
if "effort_category" in scenario_df.columns:
category_view = (
scenario_df["effort_category"]
.value_counts(normalize=True)
.rename_axis("Effort")
.reset_index(name="Part")
)
fig_reco = px.bar(
category_view,
x="Effort",
y="Part",
text="Part",
title="Répartition effort des suggestions retenues",
template="plotly_white",
)
fig_reco.update_traces(texttemplate="%{text:.0%}", textposition="outside")
fig_reco.update_yaxes(tickformat=".0%")
_plotly_display(fig_reco, width="stretch")
st.caption(
"Astuce : augmentez légèrement la note minimale pour des recettes premium, "
"ou détendez le temps pour trouver plus d'options."
)
[docs]
def display_about_tab() -> None:
"""
Affiche le contenu de l'onglet "À propos".
:returns: ``None``.
:rtype: None
.. note::
Charge le fichier ``rapport_analyse_effort_popularite.md`` ou affiche des informations par défaut.
"""
logger.debug("Chargement de l'onglet À propos")
rapport_path = Path(__file__).resolve().parents[1] / "docs" / "rapport_analyse_effort_popularite.md"
images_dir = rapport_path.parent / "images"
try:
rapport_content = rapport_path.read_text(encoding="utf-8")
def replace_local_images(match: re.Match[str]) -> str:
"""
Remplace un lien d'image locale par une version encodée en base64.
:param match: Résultat de la correspondance regex sur un lien d'image.
:type match: re.Match[str]
:returns: Lien Markdown mis à jour (ou original si l'image est introuvable).
:rtype: str
"""
img_path = images_dir / match.group(1)
if img_path.exists():
data = base64.b64encode(img_path.read_bytes()).decode()
suffix = img_path.suffix.lower().lstrip(".")
return f""
logger.warning("Image introuvable : %s", img_path)
return match.group(0)
pattern = r"!\[[^\]]*\]\((?:\.\/)?images\/([^)]+)\)"
rapport_content = re.sub(pattern, replace_local_images, rapport_content)
st.markdown(rapport_content, unsafe_allow_html=True)
logger.debug("rapport_analyse_effort_popularite chargé avec succès")
except FileNotFoundError:
logger.warning("Fichier rapport_analyse_effort_popularite.md non trouvé")
st.error("Impossible de charger `docs/rapport_analyse_effort_popularite.md`.")
st.markdown(
"""
## À propos du projet
Cette application raconte l'analyse de la relation entre effort culinaire et popularité.
Consultez le dépôt GitHub pour accéder au rapport détaillé et aux scripts d'analyse.
"""
)
except Exception as exc: # pylint: disable=broad-except
logger.error("Erreur lors du chargement du rapport_analyse_effort_popularite : %s", exc)
st.error(f"Erreur : {exc}")
[docs]
def render_storytelling(data: pd.DataFrame, data_origin: str, total_recipes: int) -> None:
"""
Orchestre le storytelling principal destiné à un public non spécialiste.
:param data: Jeu de données filtré actuel.
:type data: pd.DataFrame
:param data_origin: Indique si les données sont ``"réelles"`` ou ``"simulées"``.
:type data_origin: str
:param total_recipes: Nombre total de recettes disponibles avant filtrage.
:type total_recipes: int
:returns: ``None``.
:rtype: None
"""
if data.empty:
st.warning(
"Aucune recette ne correspond aux filtres sélectionnés dans la barre latérale."
)
return
pattern = compute_effort_pattern(data)
quartile_pattern = compute_quartile_pattern(data)
indicators = _compute_story_indicators(data, quartile_pattern)
st.title("En quoi l'effort culinaire influence-t-il la popularité des recettes ?")
st.write(
"On part de l'intuition qu'une recette très exigeante décourage les cuisiniers, clients du site web. "
"Voyons si l'analyse des données confirment ce ressenti, et explorons les à l'aide "
"de divers filtres."
)
story_tab, explorer_tab, scenario_tab, about_tab = st.tabs(
["Storytelling", "Explorer les données", "Trouver ma recette idéale", "À propos"]
)
with story_tab:
st.subheader("1. Vue d'ensemble de l'étude")
st.write(
"Chaque recette combine un score d'effort (temps, étapes, ingrédients, complexité) "
"et des indicateurs de popularité (notes, confiance, interactions). "
"Les compteurs ci-dessous reflètent la sélection actuelle."
)
_render_metrics(indicators, total_recipes)
st.subheader("2. Intuition vs réalité")
_render_patterns(pattern, quartile_pattern)
st.write(
"Valeurs précises des affichages"
)
if _is_valid_number(indicators["spearman"]):
st.caption(
f"Corrélation effort/popularité (Spearman) : {indicators['spearman']:.3f} "
"(tend vers zéro : il n'y a donc pas de lien linéaire direct)."
)
if _is_valid_number(indicators["quartile_amplitude"]):
st.caption(
f"Écart maximal entre quartiles : {indicators['quartile_amplitude']:.3f} point "
"sur une échelle de 5. La variation est faible ⇒ l'effort seul ne suffit pas à expliquer "
"la popularité."
)
st.subheader("3. Matrice de corrélation complète")
st.write(
"Retrouvez ci-dessous toutes les corrélations entre variables numériques, pour "
"repérer les couples qui évoluent de concert ou au contraire s'opposent."
)
_render_correlation_matrix(data)
st.subheader("4. Notre regard critique")
with st.container():
st.markdown(
"""
<div style="
background-color: #f8f9fa;
padding: 20px;
border-radius: 10px;
border-left: 4px solid #FF6B6B;
margin: 20px 0;
">
<ul style="color: #34495e; line-height: 1.6;">
<p>
Dans cette partie, nous complétons notre constat de
non-corrélation entre l’effort culinaire et la popularité
des recettes, par un regard critique quant aux limites
de cette étude, dont nous sommes conscients.
</p>
<p>Les différentes limites abordées :</p>
</ul>
<h5 style="color: #2c3e50; margin-top: 15px;">
1. Contexte de la plateforme food.com (GeniusKitchen)
</h5>
<ul style="color: #34495e; line-height: 1.6;">
<p>
Les interactions et les notes attribuées aux recettes
dépendent du comportement des utilisateurs et de la
construction de la plateforme elle-même. Dans notre analyse,
nous n’avons pas accès au contexte d’établissement des notes
et du référencement des recettes mis en place sur la
plateforme. Ainsi, nous constatons que l’uniformité des notes
et les biais d’engagement observés confortent le rejet de
notre hypothèse de départ.
</p>
</ul>
<h5 style="color: #2c3e50; margin-top: 15px;">
2. Méthodologies liées au choix des variables
</h5>
<ul style="color: #34495e; line-height: 1.6;">
<p>
Notre définition de popularité se base sur les variables
<code>bayes_mean</code> et <code>wilson_lb</code>, qui
reposent sur des modèles statistiques atténuant la variance
(sorte de lissage). Ainsi, des différences fines entre
recettes ont pu être masquées. À l’avenir, des variables
telles que le temps passé sur la page ou une pondération
accrue pour les utilisateurs très actifs pourraient mieux
contextualiser la définition de popularité.
</p>
<p>
Notre définition d’effort culinaire se base sur une
pondération arbitraire, définie par le groupe. Cependant,
elle constitue une hypothèse subjective quant au rapport
entre temps, complexité et ingrédients.
</p>
</ul>
<h5 style="color: #2c3e50; margin-top: 15px;">
3. Contexte du jeu de données
</h5>
<ul style="color: #34495e; line-height: 1.6;">
<p>
Comme étudié lors de nos TPs, le contexte dans lequel
les données sont extraites joue un rôle important, car
il influence leurs résultats (accessibilité du site,
facteurs de popularité supplémentaires tels que la mise
en forme personnalisée des recettes, etc.).
</p>
</ul>
<h5 style="color: #2c3e50; margin-top: 15px;">
Ouvertures
</h5>
<ul style="color: #34495e; line-height: 1.6;">
<p>
En conséquence des limites énoncées, nous souhaiterions
intégrer à notre future étude de nouvelles variables qui
traduiraient au mieux l’expérience utilisateur vis-à-vis
de la plateforme, afin de mieux contextualiser les données
extraites : temps passé sur la page, mise en avant de la
recette, etc. Nous pourrions également la compléter par
une analyse sémantique des commentaires (notamment en
étudiant la polarisation de ceux-ci), afin de consolider
notre définition de la popularité d’une recette.
</p>
</ul>
</div>
""",
unsafe_allow_html=True,
)
conclusions = [
(
"Pour vous convaincre et vous approprier l'analyse, interagissez "
"avec les données dans le second onglet ! Notre rapport d'analyse"
"est présent dans le dernier onglet 'À propos'."
)
]
st.markdown("\n".join(f"- {item}" for item in conclusions))
_render_methodology_section()
st.info(
f"Histoire construite à partir de données {data_origin}. "
"Consultez le rapport détaillé (`docs/rapport_analyse_effort_popularite.md`) "
"pour approfondir la méthodologie et les tests statistiques."
)
with explorer_tab:
_render_explorer(data)
with scenario_tab:
_render_scenario_planner(data)
with about_tab:
display_about_tab()
[docs]
def main() -> None:
"""
Point d'entrée de la webapp Streamlit.
:returns: ``None``.
:rtype: None
"""
_configure_streamlit()
analysis_df, data_origin = _prepare_analysis_data()
if analysis_df.empty:
st.error("Aucune donnée disponible pour raconter l'histoire. Vérifiez le pipeline.")
return
filtered_df = _render_sidebar(data_origin, analysis_df)
render_storytelling(filtered_df, data_origin, total_recipes=len(analysis_df))
if __name__ == "__main__":
main()