from __future__ import annotations
"""Streamlit page: Analyse de popularité des recettes.
Analyse des relations entre popularité, notes et caractéristiques structurelles.
"""
from dataclasses import dataclass
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns
import streamlit as st
from core.data_loader import DataLoader
from core.interactions_analyzer import InteractionsAnalyzer, PreprocessingConfig
from core.logger import get_logger
[docs]
@dataclass
class PopularityAnalysisConfig:
interactions_path: Path
recipes_path: Path
[docs]
class PopularityAnalysisPage:
def __init__(self, interactions_path: str | Path, recipes_path: str | Path):
self.config = PopularityAnalysisConfig(
interactions_path=Path(interactions_path),
recipes_path=Path(recipes_path),
)
self.logger = get_logger()
# ---------------- Sidebar ---------------- #
def _sidebar(self):
st.sidebar.markdown("### 📊 Visualisation")
plot_type = st.sidebar.selectbox(
"Type de graphique",
["Scatter", "Histogram"],
help="Scatter: points individuels, Histogram: nombre d'observations par bins",
)
if plot_type == "Histogram":
n_bins = st.sidebar.slider("Nombre de bins", 10, 50, 20)
bin_agg = "count" # Fixé à count seulement
else:
n_bins = 20
bin_agg = "count"
alpha = st.sidebar.slider("Transparence", 0.1, 1.0, 0.6, 0.1)
# Preprocessing section
st.sidebar.markdown("### ⚙️ Preprocessing optimisé")
st.sidebar.info("**IQR fixe 5.0** - Filtre optimal : 95.1% des données conservées")
return {
"plot_type": plot_type,
"n_bins": n_bins,
"bin_agg": bin_agg,
"alpha": alpha,
"outlier_threshold": 5.0, # Fixed optimal value for test compatibility
}
def _render_cache_controls(self, analyzer: InteractionsAnalyzer):
"""Render cache management controls in sidebar."""
st.sidebar.markdown("### Cache Management")
# Get cache info
cache_info = analyzer.get_cache_info()
# Cache status
cache_enabled = cache_info["cache_enabled"]
cache_exists = cache_info["cache_exists"]
if cache_enabled:
if cache_exists:
st.sidebar.success("Cache disponible")
# Show cache details
if "cache_age_minutes" in cache_info:
age_str = f"{cache_info['cache_age_minutes']:.1f} min"
size_str = f"{cache_info['cache_size_mb']:.1f} MB"
st.sidebar.info(f"Age: {age_str}, Taille: {size_str}")
else:
st.sidebar.info("Cache sera créé après preprocessing")
# Cache management buttons
col1, col2 = st.sidebar.columns(2)
with col1:
if st.button("🗑️ Clear Cache", help="Supprimer tous les fichiers de cache"):
if analyzer.clear_cache():
st.sidebar.success("Cache effacé!")
st.rerun()
else:
st.sidebar.error("Erreur lors de l'effacement")
with col2:
if st.button("ℹ️ Info Cache", help="Afficher les détails du cache"):
st.sidebar.json(cache_info)
# Show total cache files
if cache_info["cache_files_count"] > 0:
st.sidebar.caption(f"📁 {cache_info['cache_files_count']} fichier(s) de cache")
else:
st.sidebar.warning("Cache désactivé")
def _render_popularity_segmentation(self, analyzer: InteractionsAnalyzer, pop_rating: pd.DataFrame):
"""Render popularity segmentation analysis."""
st.subheader("📋 Segmentation par popularité")
# Create popularity segments
segmented_data = analyzer.create_popularity_segments(pop_rating)
# Visualization of segments
self._plot_popularity_segments(segmented_data, analyzer)
def _plot_popularity_segments(self, segmented_data: pd.DataFrame, analyzer: InteractionsAnalyzer):
"""Create visualization for popularity segments."""
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(15, 6))
# Plot 1: Boxplot of ratings by segment
segment_order = ["Low", "Medium", "High", "Viral"]
segments_present = [s for s in segment_order if s in segmented_data["popularity_segment"].values]
if segments_present:
sns.boxplot(
data=segmented_data,
x="popularity_segment",
y="avg_rating",
order=segments_present,
ax=ax1,
)
ax1.set_title("Distribution des notes par segment de popularité")
ax1.set_xlabel("Segment de popularité")
ax1.set_ylabel("Note moyenne")
ax1.tick_params(axis="x", rotation=45)
# Plot 2: Distribution des recettes par nombre d'interactions
# Créons un histogramme pour visualiser la vraie distribution
interactions_counts = segmented_data["interaction_count"].value_counts().sort_index()
# Limitons à 30 interactions max pour la lisibilité
max_interactions = min(30, interactions_counts.index.max())
interactions_limited = interactions_counts[interactions_counts.index <= max_interactions]
# Créons le graphique en barres
ax2.bar(
interactions_limited.index,
interactions_limited.values,
alpha=0.7,
color="steelblue",
edgecolor="black",
linewidth=0.5,
)
# Ajoutons les lignes de seuils de segmentation
thresholds = analyzer._popularity_segments_info["thresholds"]
ax2.axvline(
thresholds["low_max"],
color="blue",
linestyle="--",
alpha=0.8,
label=f'P25 = {thresholds["low_max"]:.0f}',
)
ax2.axvline(
thresholds["medium_max"],
color="green",
linestyle="--",
alpha=0.8,
label=f'P75 = {thresholds["medium_max"]:.0f}',
)
ax2.axvline(
thresholds["high_max"],
color="red",
linestyle="--",
alpha=0.8,
label=f'P95 = {thresholds["high_max"]:.0f}',
)
ax2.set_xlabel("Nombre d'interactions")
ax2.set_ylabel("Nombre de recettes")
ax2.set_title("Distribution: Combien de recettes pour chaque niveau d'interactions")
ax2.legend()
ax2.grid(True, alpha=0.3)
# Calculons les pourcentages réels dynamiquement
segment_counts = segmented_data["popularity_segment"].value_counts()
total_recipes = len(segmented_data)
segment_percentages = {}
for segment in ["Low", "Medium", "High", "Viral"]:
if segment in segment_counts.index:
segment_percentages[segment] = (segment_counts[segment] / total_recipes) * 100
else:
segment_percentages[segment] = 0.0
# Ajoutons des annotations pour les zones avec pourcentages dynamiques
ax2.text(
0.5,
ax2.get_ylim()[1] * 0.8,
f'Low\n{segment_percentages["Low"]:.1f}%',
ha="center",
va="center",
bbox=dict(boxstyle="round", facecolor="lightblue", alpha=0.7),
)
ax2.text(
2.5,
ax2.get_ylim()[1] * 0.6,
f'Medium\n{segment_percentages["Medium"]:.1f}%',
ha="center",
va="center",
bbox=dict(boxstyle="round", facecolor="lightgreen", alpha=0.7),
)
ax2.text(
8,
ax2.get_ylim()[1] * 0.4,
f'High\n{segment_percentages["High"]:.1f}%',
ha="center",
va="center",
bbox=dict(boxstyle="round", facecolor="orange", alpha=0.7),
)
if max_interactions > 14:
ax2.text(
20,
ax2.get_ylim()[1] * 0.2,
f'Viral\n{segment_percentages["Viral"]:.1f}%',
ha="center",
va="center",
bbox=dict(boxstyle="round", facecolor="lightcoral", alpha=0.7),
)
plt.tight_layout()
st.pyplot(fig)
# Calculons les statistiques pour l'explication (éviter de recalculer)
segment_counts = segmented_data["popularity_segment"].value_counts()
total_recipes = len(segmented_data)
low_pct = (segment_counts.get("Low", 0) / total_recipes) * 100
viral_pct = (segment_counts.get("Viral", 0) / total_recipes) * 100
low_count = segment_counts.get("Low", 0)
thresholds = analyzer._popularity_segments_info["thresholds"]
# Explication de la distribution observée avec pourcentages dynamiques
with st.expander("**🔍 Lecture de la distribution (graphique de droite) :**", expanded=False):
st.markdown(
f"""
Ce graphique révèle la **réalité de l'engagement** sur les plateformes de contenu :
- **Très haute colonne à 1 interaction** : {low_pct:.1f}% des recettes (~{low_count // 1000}k) n'ont qu'une seule interaction
- **Décroissance rapide** : Plus le nombre d'interactions augmente, moins il y a de recettes
- **Rareté du viral** : Très peu de recettes dépassent {thresholds['high_max']:.0f} interactions (seuil viral P95)
Cette distribution de type **"longue traîne"** est typique des plateformes de contenu et
**renforce la valeur** de notre analyse : identifier les facteurs qui distinguent les {viral_pct:.1f}%
de recettes virales des {low_pct:.1f}% à faible engagement devient d'autant plus précieux !
**📐 Pourquoi pas exactement 25%/50%/75%/95% ?**
Les percentiles P25/P75/P95 sont corrects, mais avec des **données discrètes entières**
(1, 2, 3... interactions), les segments ne peuvent pas être exactement équilibrés :
- **P25 = {thresholds['low_max']:.0f}** : mais {low_pct:.1f}% des recettes ont exactement {thresholds['low_max']:.0f} interaction
- **Impossible d'avoir exactement 25%** sans utiliser des seuils fractionnaires (1.5, 2.3...)
- **C'est mathématiquement normal** : les percentiles indiquent les valeurs, pas forcément des répartitions égales
"""
)
def _render_step_1(
self,
analyzer: InteractionsAnalyzer,
plot_type: str,
n_bins: int,
bin_agg: str,
alpha: float,
):
"""Render step 1: Quality-popularity relationship analysis."""
st.markdown("---")
st.header("📈 ÉTAPE 1 : Relation qualité-popularité")
st.markdown(
"""
**Question :** Les recettes bien notées génèrent-elles plus d'interactions ?
Cette première analyse croise la note moyenne des recettes avec leur nombre d'interactions
pour évaluer la corrélation entre qualité perçue et engagement utilisateur.
**Métrique :** Corrélation entre note moyenne et nombre d'interactions par recette.
"""
)
try:
pop_rating = analyzer.popularity_vs_rating()
fig1 = self._create_compact_plot(
pop_rating,
x="avg_rating",
y="interaction_count",
plot_type=plot_type,
n_bins=n_bins,
bin_agg=bin_agg,
alpha=alpha,
)
st.pyplot(fig1)
# Analyse des résultats
st.markdown(
"""
Cette distribution non-linéaire indique que la popularité s'organise
en segments distincts plutôt qu'en progression continue. Cependant une grande majorité des recettes possède une bonne note.
Les utilisateurs sont peut-être bienveillant entre eux ou les recettes sont peut-être toutes délicieuses.
Nous allons donc plutôt nous focaliser dans la suite sur l'étude du nombre de fois où une recette a été faite évaluant donc
sa popularité pour qualifier son succés avec un autre point de vue que la note moyenne. Quand nous parlerons du nombre d'interactions,
nous parlerons du nombre de fois où la recette a été faite.
"""
)
return pop_rating # Return for use in step 2
except ValueError as e:
st.info(f"Impossible de tracer Note vs Popularité: {e}")
return None
def _render_step_2(self, analyzer: InteractionsAnalyzer, pop_rating):
"""Render step 2: Popularity segmentation analysis."""
st.markdown("---")
st.header("📈 ÉTAPE 2 : Segmentation par engagement")
st.markdown(
"""
**Objectif :** Identifier et caractériser les différents segments de popularité.
La distribution observée suggère l'existence de groupes distincts de recettes. Nous appliquons
une segmentation basée sur les percentiles pour révéler la structure naturelle de la popularité.
**Méthode :** Segmentation par percentiles (25e, 75e, 95e) du nombre d'interactions.
"""
)
# Segmentation par popularité avec contexte narratif
self._render_popularity_segmentation(analyzer, pop_rating)
# Obtenir les seuils de segmentation pour l'explication
segmented_data = analyzer.create_popularity_segments(pop_rating)
thresholds = analyzer._popularity_segments_info["thresholds"]
# Calculer les pourcentages réels de chaque segment
segment_counts = segmented_data["popularity_segment"].value_counts()
total_recipes = len(segmented_data)
segment_percentages = {}
for segment in ["Low", "Medium", "High", "Viral"]:
if segment in segment_counts.index:
segment_percentages[segment] = (segment_counts[segment] / total_recipes) * 100
else:
segment_percentages[segment] = 0.0
st.markdown(
f"""
**📋 Caractérisation des segments identifiés :**
L'analyse révèle donc quatre segments distincts basés sur le niveau d'engagement :
- **Engagement Faible** : 1 à {int(thresholds['low_max'])} interactions
({segment_percentages['Low']:.1f}% des recettes - souvent de qualité mais visibilité limitée)
- **Engagement Modéré** : {int(thresholds['low_max']) + 1} à {int(thresholds['medium_max'])} interactions
({segment_percentages['Medium']:.1f}% des recettes - performance stable et audience fidèle)
- **Engagement Élevé** : {int(thresholds['medium_max']) + 1} à {int(thresholds['high_max'])} interactions
({segment_percentages['High']:.1f}% des recettes - forte popularité établie)
- **Engagement Viral** : Plus de {int(thresholds['high_max'])} interactions
({segment_percentages['Viral']:.1f}% des recettes - phénomènes d'adoption exceptionnelle)
"""
)
def _render_step_3(
self,
analyzer: InteractionsAnalyzer,
agg: pd.DataFrame,
plot_type: str,
n_bins: int,
bin_agg: str,
alpha: float,
pop_rating,
):
"""Render step 3: Technical factors influence analysis."""
st.markdown("---")
st.header("📈 ÉTAPE 3 : Facteurs d'influence")
st.markdown(
"""
**Objectif :** Identifier les caractéristiques intrinsèques des recettes qui corrèlent
avec une popularité élevée.
Au-delà de la qualité, trois dimensions techniques peuvent influencer l'adoption d'une recette :
le temps de préparation, la complexité (nombre d'étapes) et les ingrédients requis.
**Méthode :** Analyse de corrélation entre caractéristiques techniques et niveau d'engagement.
"""
)
# Caractéristiques (feature) vs Popularité avec la note comme taille
feature_order = ["minutes", "n_steps", "n_ingredients"]
features = [f for f in feature_order if f in agg.columns]
if features:
for feat in features:
# Contexte analytique pour chaque caractéristique
if feat == "minutes":
st.markdown(
"""
#### ⏱️ Impact du temps de préparation
**Hypothèse :** Les recettes rapides sont plus populaires dans une société pressée.
**Variable :** Temps de préparation en minutes vs nombre d'interactions.
"""
)
elif feat == "n_steps":
st.markdown(
"""
#### 🧩 Influence de la complexité procédurale
**Hypothèse :** La complexité (nombre d'étapes) peut freiner l'adoption mais améliorer la satisfaction.
**Variable :** Nombre d'étapes vs nombre d'interactions.
**Observation :** Équilibre entre accessibilité et sophistication.
"""
)
elif feat == "n_ingredients":
st.markdown(
"""
#### 🥘 Effet de la diversité des ingrédients
**Hypothèse :** Plus d'ingrédients = recette plus complexe et potentiellement dissuasive.
**Variable :** Nombre d'ingrédients vs nombre d'interactions.
**Analyse :** Impact de la richesse compositionnelle sur l'engagement.
"""
)
try:
df_pop_feat = analyzer.popularity_vs_feature(feat)
# Merge pour récupérer la note moyenne si disponible
if pop_rating is not None:
# Limiter les colonnes pour éviter suffixes _x / _y sur
# interaction_count
pr_min = pop_rating[["recipe_id", "avg_rating"]].copy()
merged = df_pop_feat.merge(pr_min, on="recipe_id", how="left")
else:
merged = df_pop_feat
# Normalisation de nom si interaction_count a été suffixé
# accidentellement
if "interaction_count_x" in merged.columns and "interaction_count" not in merged.columns:
merged.rename(
columns={"interaction_count_x": "interaction_count"},
inplace=True,
)
if "interaction_count_y" in merged.columns and "interaction_count" not in merged.columns:
merged.rename(
columns={"interaction_count_y": "interaction_count"},
inplace=True,
)
y_col = "interaction_count" if "interaction_count" in merged.columns else merged.columns[1]
if "avg_rating" in merged.columns:
fig = self._create_compact_plot(
merged,
x=feat,
y=y_col,
size="avg_rating",
plot_type=plot_type,
n_bins=n_bins,
bin_agg=bin_agg,
alpha=alpha,
)
else:
fig = self._create_compact_plot(
merged,
x=feat,
y=y_col,
plot_type=plot_type,
n_bins=n_bins,
bin_agg=bin_agg,
alpha=alpha,
)
st.pyplot(fig)
# Analyse narrative spécifique pour chaque caractéristique
if feat == "minutes":
st.markdown(
"""
**Ce que révèle le graphique du temps :**
L'analyse de la distribution révèle une concentration
de recettes bien notées dans certaines zones de temps, indiquant les "sweet spots"
temporels. Les recettes ultra-rapides (moins de 15 minutes) peuvent manquer de sophistication,
tandis que les préparations longues (plus de 2 heures) peuvent décourager les utilisateurs.
Les données suggèrent un équilibre entre temps suffisant pour créer de la valeur et durée raisonnable pour maintenir l'engagement.
En regardant l'histogramme avec suffisament de bins (>30), on voit clairement que les recettes les plus refaites
sont les recettes prenant moins d'une heure.
"""
)
elif feat == "n_steps":
st.markdown(
"""
**Le verdict sur la complexité :**
L'analyse révèle l'un des paradoxes les plus significatifs de la cuisine.
Une concentration de recettes bien notées (gros points) autour de 5-8 étapes
confirme l'existence d'un "niveau de défi optimal".
L'engagement utilisateur optimal se situe entre
accomplissement satisfaisant et complexité gérable. Cette zone représente l'équilibre
entre "trop simple", "ennuyeux" et "trop complexe","décourageant".
En regardant l'histogramme avec suffisament de bins (>30), on voit clairement que les recettes les plus refaites
sont les recettes ayant moins de 15 étapes environ.
"""
)
elif feat == "n_ingredients":
st.markdown(
"""
**La révélation des ingrédients :**
L'analyse révèle la relation entre nombre d'ingrédients et satisfaction utilisateur.
Cette distribution montre comment la perception de "richesse" d'une recette influence
son succès.
Les données révèlent un optimum entre richesse perçue
et accessibilité pratique. Un nombre trop faible d'ingrédients peut sembler "basique",
tandis qu'un nombre excessif peut paraître "intimidant" ou "coûteux".
La concentration des meilleures notes révèle le nombre optimal
qui équilibre richesse et accessibilité.
En regardant l'histogramme avec suffisament de bins (>30), on voit clairement que les recettes les plus refaites
sont les recettes demandant moins de 15 ingrédients environ.
"""
)
except ValueError as e:
st.caption(f"{feat}: {e}")
else:
st.info("Aucune des colonnes minutes / n_steps / n_ingredients n'est présente dans l'agrégat.")
def _render_viral_recipe_analysis(
self,
analyzer: InteractionsAnalyzer,
agg: pd.DataFrame,
interactions_df: pd.DataFrame,
recipes_df: pd.DataFrame,
):
"""Render temporal analysis of viral recipes with 3D visualization."""
st.markdown("---")
st.header("📈 ÉTAPE 4 : Analyse temporelle")
st.markdown(
"""
**Question :** Comment évoluent les recettes à fort engagement dans le temps ?
Cette analyse examine l'évolution temporelle de la qualité et du nombre d'interactions
pour les recettes du segment viral (>95e percentile).
**Approche :** Visualisation 3D des trajectoires temporelles pour identifier les phases
d'accélération de l'engagement.
"""
)
# Identify viral recipes
pop_rating = analyzer.popularity_vs_rating()
segmented_data = analyzer.create_popularity_segments(pop_rating)
viral_recipes = segmented_data[segmented_data["popularity_segment"] == "Viral"]
if len(viral_recipes) == 0:
st.warning("Aucune recette virale détectée dans les données actuelles.")
return
# TOP 10 des recettes les plus virales
st.markdown("### 📋 Top 10 des recettes les plus virales")
# Merge with recipe names and sort by interaction count
top_viral = viral_recipes.copy()
if "name" in recipes_df.columns:
top_viral = top_viral.merge(
recipes_df[["id", "name"]],
left_on="recipe_id",
right_on="id",
how="left",
)
# Sort by interaction count (most viral first) and take top 10
top_viral = top_viral.sort_values("interaction_count", ascending=False).head(10)
# Recalculer les stats avec les mêmes filtres que le graphique 3D
# Recalculate stats using only complete data (same as 3D graph)
corrected_stats = []
for _, row in top_viral.iterrows():
recipe_id = row["recipe_id"]
recipe_interactions = interactions_df[interactions_df["recipe_id"] == recipe_id].copy()
if len(recipe_interactions) > 0:
# Apply same filtering as 3D graph
date_columns = [col for col in interactions_df.columns if "date" in col.lower()]
if date_columns:
date_col = date_columns[0]
recipe_interactions[date_col] = pd.to_datetime(recipe_interactions[date_col], errors="coerce")
complete_data = recipe_interactions.dropna(subset=[date_col, "rating"]).copy()
if len(complete_data) > 0:
corrected_stats.append(
{
"recipe_id": recipe_id,
"interaction_count_filtered": len(complete_data),
"avg_rating_filtered": complete_data["rating"].mean(),
"name": row.get("name", f"Recipe {recipe_id}"),
}
)
# Create corrected display dataframe
if corrected_stats:
corrected_df = pd.DataFrame(corrected_stats)
corrected_df = corrected_df.sort_values("interaction_count_filtered", ascending=False)
display_cols = [
"name",
"recipe_id",
"interaction_count_filtered",
"avg_rating_filtered",
]
top_viral_display = corrected_df[display_cols].copy()
top_viral_display.columns = [
"Nom de la recette",
"ID",
"Nb interactions (dates valides)",
"Note moyenne (dates valides)",
]
else:
# Fallback to original data if no corrected data available
display_cols = ["recipe_id", "interaction_count", "avg_rating"]
if "name" in top_viral.columns:
display_cols = ["name", "recipe_id", "interaction_count", "avg_rating"]
top_viral_display = top_viral[display_cols].copy()
top_viral_display.columns = (
["Nom de la recette", "ID", "Nb interactions", "Note moyenne"]
if "name" in top_viral.columns
else ["ID", "Nb interactions", "Note moyenne"]
)
# Add rank column
top_viral_display.insert(0, "Rang", range(1, len(top_viral_display) + 1))
# Format the display
if "Nom de la recette" in top_viral_display.columns:
top_viral_display["Nom de la recette"] = top_viral_display["Nom de la recette"].apply(
lambda x: x[:60] + "..." if len(str(x)) > 60 else str(x)
)
# Format interaction count column (handle both old and new column names)
interaction_col = next(
(col for col in top_viral_display.columns if "interactions" in col.lower()),
None,
)
if interaction_col:
top_viral_display[interaction_col] = top_viral_display[interaction_col].apply(lambda x: f"{x:,.0f}")
# Format rating column (handle both old and new column names)
rating_col = next(
(col for col in top_viral_display.columns if "note moyenne" in col.lower()),
None,
)
if rating_col:
top_viral_display[rating_col] = top_viral_display[rating_col].apply(lambda x: f"{x:.2f} ⭐")
# Display the table
st.dataframe(top_viral_display, width="stretch", hide_index=True)
# Recipe selection interface for 3D analysis
st.markdown("### 📋 Pattern Commun du Top 3")
st.markdown(
"""
**Observation du pattern commun sur les recettes les plus populaires :**
En analysant le top 3 des recettes virales, nous observons un **pattern commun** :
- **Phase 1** : Croissance progressive par effet boule de neige
- **Phase 2** : Forte accélération quand la recette devient tendance
- **Phase 3** : Stagnation puis déclin quand la mode passe
Ce phénomène reflète le cycle naturel des tendances culinaires.
"""
)
# Sélection du top 3 pour illustrer le pattern commun
representative_examples = {
"top_1": 2886, # best banana bread - #1 des interactions
"top_2": 27208, # to die for crock pot roast - #2 des interactions
"top_3": 39087, # creamy cajun chicken pasta - #3 des interactions
}
# Afficher le tableau des exemples sélectionnés
examples_data = []
for pattern, recipe_id in representative_examples.items():
# Chercher dans les données
recipe_interactions = interactions_df[interactions_df["recipe_id"] == recipe_id]
if len(recipe_interactions) > 0:
recipe_name = "N/A"
if "name" in recipes_df.columns:
recipe_match = recipes_df[recipes_df["id"] == recipe_id]
if len(recipe_match) > 0:
recipe_name = recipe_match["name"].iloc[0]
# Calculer les stats pour cet exemple
avg_rating = recipe_interactions["rating"].mean()
total_interactions = len(recipe_interactions)
examples_data.append(
{
"Rang": f"#{list(representative_examples.keys()).index(pattern) + 1}",
"ID": recipe_id,
"Nom": (recipe_name[:50] + "..." if len(recipe_name) > 50 else recipe_name),
"Interactions": f"{total_interactions:,}",
"Note Moyenne": f"{avg_rating:.2f} ⭐",
}
)
if examples_data:
examples_df = pd.DataFrame(examples_data)
st.dataframe(examples_df, width="stretch", hide_index=True)
# Utiliser ces exemples pour la visualisation 3D
selected_recipe_ids = list(representative_examples.values())
recipe_display = [f"Top {i + 1}: {examples_data[i]['Nom']}" for i, pattern in enumerate(representative_examples.keys())]
selected_indices = list(range(len(selected_recipe_ids)))
# Temporal analysis
st.markdown("### 📊 Visualisation 3D du Pattern Commun")
st.markdown(
"""
**Lecture du graphique :**
- **X** : Date de l'interaction
- **Y** : Note attribuée (1-5 étoiles)
- **Z** : Densité d'interactions (nombre d'avis par période)
🔍 **L'effet boule de neige** : démarrage lent, accélération, puis stabilisation/déclin
"""
)
# Create 3D visualization using RAW data to preserve all recipes
# Note: 3D visualization shows actual recipe trajectories without preprocessing
# to ensure no recipes are excluded from visualization
# IMPROVEMENT: Enhanced temporal sampling for smoother trajectories
self.logger.info("Using raw data for 3D visualization with improved temporal sampling")
self._create_3d_visualization_real(selected_recipe_ids, interactions_df, recipe_display, selected_indices)
def _create_3d_visualization_real(
self,
recipe_ids: list,
interactions_df: pd.DataFrame,
recipe_display: list,
selected_indices: list,
):
"""Create 3D visualization with real temporal data from the dataset."""
# Check for available date columns
date_columns = [col for col in interactions_df.columns if "date" in col.lower()]
if not date_columns:
st.error("Aucune colonne de date trouvée. Colonnes disponibles : " + ", ".join(interactions_df.columns.tolist()))
return
# Use the first date column found, or let user choose if multiple
if len(date_columns) == 1:
date_col = date_columns[0]
else:
st.info(f"Colonnes de date disponibles : {', '.join(date_columns)}")
date_col = st.selectbox("Choisissez la colonne de date :", date_columns)
# Ensure date column is in datetime format
try:
interactions_df[date_col] = pd.to_datetime(interactions_df[date_col])
except Exception as e:
st.error(f"Erreur lors de la conversion de la colonne '{date_col}' en date : {str(e)}")
return
# Add interval selection for temporal sampling
st.subheader("⚙️ Paramètres temporels")
interval_days = st.selectbox(
"Afficher un point tous les :",
[1, 7, 14, 30, 90],
index=1, # Default: tous les 7 jours
format_func=lambda x: (f"{x} jour{'s' if x > 1 else ''}" if x < 30 else f"{x // 30} mois"),
)
fig = plt.figure(figsize=(14, 10))
ax = fig.add_subplot(111, projection="3d")
colors = [
"#FF6B6B",
"#4ECDC4",
"#DDA0DD",
"#45B7D1",
"#96CEB4",
"#FFEAA7",
"#98D8C8",
]
for i, recipe_id in enumerate(recipe_ids):
# Filter interactions for this recipe
recipe_interactions = interactions_df[interactions_df["recipe_id"] == recipe_id].copy()
if len(recipe_interactions) == 0:
continue
# Ensure date column is properly converted to datetime
recipe_interactions[date_col] = pd.to_datetime(recipe_interactions[date_col], errors="coerce")
# Filter out rows with missing essential data (date, rating, recipe_id)
complete_data = recipe_interactions.dropna(subset=[date_col, "rating"]).copy()
if len(complete_data) == 0:
st.warning(f"Aucune donnée complète trouvée pour la recette {recipe_id}")
continue
# Sort by date to ensure strict chronological order
complete_data = complete_data.sort_values(date_col).reset_index(drop=True)
# Convert dates to numeric for plotting (days since first interaction)
first_date = complete_data[date_col].min()
complete_data["days_since_start"] = (complete_data[date_col] - first_date).dt.days
# Filter based on temporal interval (every X days) - IMPROVED SAMPLING
if interval_days > 1:
# Group by interval periods and take a representative point
complete_data["period"] = complete_data["days_since_start"] // interval_days
# Take the point closest to the middle of each period for better
# representation
def get_middle_point(group):
period_start = group["days_since_start"].min()
period_end = group["days_since_start"].max()
period_middle = (period_start + period_end) / 2
# Find the interaction closest to the middle of the period
closest_idx = (group["days_since_start"] - period_middle).abs().idxmin()
return group.loc[closest_idx]
display_data = complete_data.groupby("period").apply(get_middle_point).reset_index(drop=True)
else:
# Show ALL points (every single interaction)
display_data = complete_data.copy()
# Calculate cumulative statistics correctly
display_data = display_data.sort_values("days_since_start").reset_index(drop=True)
# For each point, calculate the cumulative average rating from ALL
# previous interactions
cumulative_avg_ratings = []
cumulative_interactions = []
for idx in range(len(display_data)):
current_day = display_data.iloc[idx]["days_since_start"]
# Get all interactions up to and including current day from original
# complete_data
interactions_up_to_day = complete_data[complete_data["days_since_start"] <= current_day]
# Calculate cumulative average rating (from day 1 to current day)
cumulative_avg_rating = interactions_up_to_day["rating"].mean()
cumulative_avg_ratings.append(cumulative_avg_rating)
# Cumulative interaction count
cumulative_interactions.append(len(interactions_up_to_day))
display_data["cumulative_avg_rating"] = cumulative_avg_ratings
display_data["cumulative_interactions"] = cumulative_interactions
# Extract coordinates for 3D plot - only real data points
x_dates = display_data["days_since_start"].values
y_ratings = display_data["cumulative_avg_rating"].values # Cumulative average ratings
z_cumulative = display_data["cumulative_interactions"].values
# Ensure we have valid data to plot
if len(x_dates) == 0 or np.any(np.isnan(x_dates)) or np.any(np.isnan(y_ratings)):
st.warning(f"Données invalides pour la recette {recipe_id}")
continue
# Plot the 3D trajectory
ax.plot(
x_dates,
y_ratings,
z_cumulative,
color=colors[i % len(colors)],
marker="o",
markersize=5,
label=(
f"{recipe_display[selected_indices[i]][:30]}..."
if len(recipe_display[selected_indices[i]]) > 30
else recipe_display[selected_indices[i]]
),
linewidth=2.5,
alpha=0.8,
)
# Add trajectory start and end markers
if len(x_dates) > 1:
# Start point (green)
ax.scatter(
x_dates[0],
y_ratings[0],
z_cumulative[0],
color="green",
s=100,
alpha=0.8,
marker="^",
)
# End point (red)
ax.scatter(
x_dates[-1],
y_ratings[-1],
z_cumulative[-1],
color="red",
s=100,
alpha=0.8,
marker="v",
)
# Add dotted lines to show final coordinates instead of projections
# Vertical line from bottom to final point
ax.plot(
[x_dates[-1], x_dates[-1]],
[y_ratings[-1], y_ratings[-1]],
[0, z_cumulative[-1]],
"k--",
alpha=0.6,
linewidth=1,
)
# Horizontal line from Y-axis to final point
ax.plot(
[0, x_dates[-1]],
[y_ratings[-1], y_ratings[-1]],
[z_cumulative[-1], z_cumulative[-1]],
"k--",
alpha=0.6,
linewidth=1,
)
# Line from X-axis to final point
ax.plot(
[x_dates[-1], x_dates[-1]],
[0, y_ratings[-1]],
[z_cumulative[-1], z_cumulative[-1]],
"k--",
alpha=0.6,
linewidth=1,
)
# Formatting and labels
ax.set_xlabel("Jours depuis la première interaction", fontsize=12)
ax.set_ylabel("Note moyenne cumulative", fontsize=12)
ax.set_zlabel("Interactions cumulées", fontsize=12)
ax.set_title("Évolution Temporelle des Recettes Virales", fontsize=14, fontweight="bold")
# Set explicit axis limits to ensure correct scaling
# Collect all data points to determine proper axis limits
all_x, all_y, all_z = [], [], []
for i, recipe_id in enumerate(recipe_ids):
recipe_interactions = interactions_df[interactions_df["recipe_id"] == recipe_id].copy()
if len(recipe_interactions) > 0:
date_columns = [col for col in interactions_df.columns if "date" in col.lower()]
if date_columns:
date_col = date_columns[0]
recipe_interactions[date_col] = pd.to_datetime(recipe_interactions[date_col], errors="coerce")
complete_data = recipe_interactions.dropna(subset=[date_col, "rating"]).copy()
if len(complete_data) > 0:
# Calculate actual final values
complete_data = complete_data.sort_values(date_col)
first_date = complete_data[date_col].min()
final_days = (complete_data[date_col].max() - first_date).days
final_rating = complete_data["rating"].mean() # Overall average
final_interactions = len(complete_data)
all_x.append(final_days)
all_y.append(final_rating)
all_z.append(final_interactions)
# Set axis limits with some padding
if all_x and all_y and all_z:
x_margin = max(all_x) * 0.05
z_margin = max(all_z) * 0.05
ax.set_xlim(0, max(all_x) + x_margin)
ax.set_ylim(0, 5) # Note moyenne toujours de 0 à 5 pour référence standardisée
ax.set_zlim(0, max(all_z) + z_margin)
# Disable default shadow projections on XZ and YZ planes
ax.xaxis.pane.fill = False
ax.yaxis.pane.fill = False
ax.zaxis.pane.fill = False
# Make panes transparent
ax.xaxis.pane.set_edgecolor("gray")
ax.yaxis.pane.set_edgecolor("gray")
ax.zaxis.pane.set_edgecolor("gray")
ax.xaxis.pane.set_alpha(0.1)
ax.yaxis.pane.set_alpha(0.1)
ax.zaxis.pane.set_alpha(0.1)
# Legend with custom positioning
legend = ax.legend(bbox_to_anchor=(1.05, 1), loc="upper left", fontsize=10)
legend.set_title("Recettes", prop={"weight": "bold"})
# Add grid for better readability
ax.grid(True, alpha=0.3)
# Custom viewing angle for better perspective
ax.view_init(elev=20, azim=45)
plt.tight_layout()
st.pyplot(fig)
# Analysis Summary
# Legend and axes explanation
with st.expander("📊 Lecture du graphique", expanded=False):
st.markdown(
"""
**Légende :**
- 🟢 Point de démarrage - 🔴 Point final
- Lignes pointillées : repères pour lecture des coordonnées
**Axes d'analyse :**
- **X** : Temps (jours depuis première interaction)
- **Y** : Qualité cumulative (note moyenne évolutive)
- **Z** : Volume d'adoption (interactions cumulées)
"""
)
st.markdown("### 💡 Analyse Synthétique : Le Pattern Universel du Succès")
st.markdown(
"""
**Analyse morphologique des trajectoires 3D :** L'examen des courbures et dérivées révèle une
signature temporelle commune correspondant au cycle naturel des tendances virales.
**Pattern universel identifié :**
"""
)
# Single unified analysis about the common pattern
st.markdown(
"""
**📈 Pattern Universel - Effet Boule de Neige**
**Morphologie observée sur les 3 recettes les plus virales :**
- **Phase 1** : Accumulation lente (dZ/dt faible) - période d'émergence
- **Phase 2** : Accélération massive (d²Z/dt² > 0) - explosion virale
- **Phase 3** : Plateau puis déclin possible à prévoir (dZ/dt → 0 puis négatif) - fin de mode
**Explication simple :** Comme toute tendance, les recettes virales suivent
le même cycle : émergence discrète, explosion quand elles deviennent "à la mode",
puis retour progressif à la normale quand l'effet de nouveauté s'estompe.
"""
)
# Display detailed statistics
st.markdown("### 📊 Statistiques par recette")
stats_data = []
for i, recipe_id in enumerate(recipe_ids):
recipe_interactions = interactions_df[interactions_df["recipe_id"] == recipe_id].copy()
if len(recipe_interactions) > 0:
# Filter only complete data (same logic as 3D plot)
recipe_interactions[date_col] = pd.to_datetime(recipe_interactions[date_col], errors="coerce")
complete_data = recipe_interactions.dropna(subset=[date_col, "rating"]).copy()
if len(complete_data) > 0:
first_interaction = complete_data[date_col].min()
last_interaction = complete_data[date_col].max()
duration = (last_interaction - first_interaction).days
total_interactions = len(complete_data)
avg_rating = complete_data["rating"].mean()
stats_data.append(
{
"ID": recipe_id,
"Recette": (
recipe_display[selected_indices[i]][:40] + "..."
if len(recipe_display[selected_indices[i]]) > 40
else recipe_display[selected_indices[i]]
),
"Première interaction": first_interaction.strftime("%Y-%m-%d"),
"Dernière interaction": last_interaction.strftime("%Y-%m-%d"),
"Durée (jours)": duration,
"Total interactions (complètes)": total_interactions,
"Note moyenne": f"{avg_rating:.2f}",
"Interactions/jour": f"{total_interactions / max(duration, 1):.1f}",
}
)
if stats_data:
stats_df = pd.DataFrame(stats_data)
st.dataframe(stats_df, width="stretch")
# ---------------- Data Loading ---------------- #
def _load_data(self) -> tuple[pd.DataFrame, pd.DataFrame]:
inter_loader = DataLoader(self.config.interactions_path)
rec_loader = DataLoader(self.config.recipes_path)
interactions_df = inter_loader.load_data()
recipes_df = rec_loader.load_data()
return interactions_df, recipes_df
# ---------------- Visualization helpers ---------------- #
def _get_plot_title(self, x: str, y: str, plot_type: str, bin_agg: str = "count") -> str:
"""Get predefined French titles based on plot type and variables."""
# Titres prédéfinis pour les graphiques les plus courants
predefined_titles = {
# Scatter plots
(
"avg_rating",
"interaction_count",
"Scatter",
): "Note moyenne selon le nombre d'interactions",
(
"interaction_count",
"avg_rating",
"Scatter",
): "Nombre d'interactions selon la note moyenne",
(
"minutes",
"avg_rating",
"Scatter",
): "Note moyenne selon la durée de préparation",
(
"n_steps",
"avg_rating",
"Scatter",
): "Note moyenne selon le nombre d'étapes",
(
"n_ingredients",
"avg_rating",
"Scatter",
): "Note moyenne selon le nombre d'ingrédients",
(
"minutes",
"interaction_count",
"Scatter",
): "Nombre d'interactions selon la durée de préparation",
(
"n_steps",
"interaction_count",
"Scatter",
): "Nombre d'interactions selon le nombre d'étapes",
(
"n_ingredients",
"interaction_count",
"Scatter",
): "Nombre d'interactions selon le nombre d'ingrédients",
# Histograms avec count
("avg_rating", "", "Histogram"): "Distribution des notes moyennes",
(
"interaction_count",
"",
"Histogram",
): "Distribution du nombre d'interactions",
("minutes", "", "Histogram"): "Distribution des durées de préparation",
("n_steps", "", "Histogram"): "Distribution du nombre d'étapes",
("n_ingredients", "", "Histogram"): "Distribution du nombre d'ingrédients",
("rating", "", "Histogram"): "Distribution des notes",
}
# Recherche du titre prédéfini
key = (x, y, plot_type)
if key in predefined_titles:
return predefined_titles[key]
# Titre par défaut pour les histogrammes
if plot_type == "Histogram":
key_hist = (x, "", plot_type)
if key_hist in predefined_titles:
return predefined_titles[key_hist]
# Fallback : génération simple
var_labels = {
"avg_rating": "Note moyenne",
"interaction_count": "Nombre d'interactions",
"minutes": "Durée (minutes)",
"n_steps": "Nombre d'étapes",
"n_ingredients": "Nombre d'ingrédients",
"rating": "Note",
}
x_label = var_labels.get(x, x.replace("_", " ").title())
y_label = var_labels.get(y, y.replace("_", " ").title())
if plot_type == "Histogram":
return f"Distribution de {x_label}"
else: # Scatter
return f"{y_label} selon {x_label}"
def _create_plot(
self,
data: pd.DataFrame,
x: str,
y: str,
size: str | None = None,
title: str = "",
plot_type: str = "Scatter",
n_bins: int = 20,
bin_agg: str = "count",
alpha: float = 0.6,
):
"""Create plot based on selected type with improved visualization."""
fig, ax = plt.subplots(figsize=(8, 6))
if plot_type == "Scatter":
self._scatter_plot(data, x, y, size, ax, alpha)
elif plot_type == "Histogram":
self._histogram_plot(data, x, y, size, ax, n_bins, bin_agg)
# Utiliser un titre prédéfini si aucun titre n'est fourni
if not title:
title = self._get_plot_title(x, y, plot_type, bin_agg)
ax.set_title(title, fontsize=14, fontweight="bold")
# Labels des axes en français
var_labels = {
"avg_rating": "Note moyenne",
"interaction_count": "Nombre d'interactions",
"minutes": "Durée (minutes)",
"n_steps": "Nombre d'étapes",
"n_ingredients": "Nombre d'ingrédients",
"rating": "Note",
}
x_label = var_labels.get(x, x.replace("_", " ").title())
y_label = var_labels.get(y, y.replace("_", " ").title())
# Pour les histogrammes, l'axe Y affiche toujours le nombre d'observations
if plot_type == "Histogram":
y_label = "Nombre d'observations"
ax.set_xlabel(x_label, fontsize=12)
ax.set_ylabel(y_label, fontsize=12)
ax.grid(True, alpha=0.3)
plt.tight_layout()
return fig
def _create_compact_plot(
self,
data: pd.DataFrame,
x: str,
y: str,
size: str | None = None,
title: str = "",
plot_type: str = "Scatter",
n_bins: int = 20,
bin_agg: str = "count",
alpha: float = 0.6,
):
"""Create compact plot for steps 1 and 3 - smaller size for better screen fit."""
# Taille compacte pour s'adapter à l'écran
fig, ax = plt.subplots(figsize=(10, 5))
if plot_type == "Scatter":
self._scatter_plot(data, x, y, size, ax, alpha)
elif plot_type == "Histogram":
self._histogram_plot(data, x, y, size, ax, n_bins, bin_agg)
# Utiliser un titre prédéfini si aucun titre n'est fourni
if not title:
title = self._get_plot_title(x, y, plot_type, bin_agg)
ax.set_title(title, fontsize=12, fontweight="bold")
# Labels des axes en français
var_labels = {
"avg_rating": "Note moyenne",
"interaction_count": "Nombre d'interactions",
"minutes": "Durée (minutes)",
"n_steps": "Nombre d'étapes",
"n_ingredients": "Nombre d'ingrédients",
"rating": "Note",
}
x_label = var_labels.get(x, x.replace("_", " ").title())
y_label = var_labels.get(y, y.replace("_", " ").title())
# Pour les histogrammes, l'axe Y affiche toujours le nombre d'observations
if plot_type == "Histogram":
y_label = "Nombre d'observations"
ax.set_xlabel(x_label, fontsize=10)
ax.set_ylabel(y_label, fontsize=10)
ax.grid(True, alpha=0.3)
plt.tight_layout()
return fig
def _scatter_plot(self, data: pd.DataFrame, x: str, y: str, size: str | None, ax, alpha: float):
"""Enhanced scatter plot with all data points displayed."""
if size is not None:
# Plot all data points without sampling
scatter = ax.scatter(
data[x],
data[y],
s=data[size] * 10, # Scale size
c=data[size],
cmap="viridis",
alpha=alpha,
edgecolors="none",
)
cbar = plt.colorbar(scatter, ax=ax)
# Label plus explicite pour la colorbar
if size == "avg_rating":
cbar.set_label("Moyenne des notes")
else:
size_label = size.replace("_", " ").title()
cbar.set_label(size_label)
else:
# Plot all data points without sampling
ax.scatter(data[x], data[y], alpha=alpha, s=30, c="steelblue", edgecolors="none")
def _histogram_plot(
self,
data: pd.DataFrame,
x: str,
y: str,
size: str | None,
ax,
n_bins: int,
bin_agg: str,
):
"""Create histogram counting observations per bin."""
# Create bins for x-axis
data_clean = data.dropna(subset=[x, y])
if len(data_clean) == 0:
ax.text(
0.5,
0.5,
"Pas de données valides",
ha="center",
va="center",
transform=ax.transAxes,
)
return
# Create proper bins with explicit edges
x_min, x_max = data_clean[x].min(), data_clean[x].max()
bin_edges = np.linspace(x_min, x_max, n_bins + 1)
# Assign each point to a bin
data_clean = data_clean.copy()
data_clean["bin_idx"] = pd.cut(data_clean[x], bins=bin_edges, include_lowest=True, labels=False)
# Calculate bin centers for plotting
bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
bin_width = (x_max - x_min) / n_bins
# Count observations per bin
agg_data = data_clean.groupby("bin_idx").size().reset_index(name="count")
y_values = agg_data["count"]
y_label = "Nombre d'observations"
# Handle size aggregation if provided (count observations with that size)
size_values = None
if size and size in data_clean.columns:
size_agg = data_clean.groupby("bin_idx")[size].mean() # Moyenne de la variable size par bin
# Align with y_values
size_values = []
for idx in agg_data["bin_idx"]:
if idx in size_agg.index:
size_values.append(size_agg[idx])
else:
size_values.append(0)
# Create the bar plot
valid_indices = agg_data["bin_idx"].dropna()
plot_x = [bin_centers[int(idx)] for idx in valid_indices if int(idx) < len(bin_centers)]
plot_y = y_values[: len(plot_x)]
if size_values and len(size_values) >= len(plot_x):
# Color bars by size value using the same colormap as scatter plots
size_plot = size_values[: len(plot_x)]
# Normalize size values for coloring
if len(size_plot) > 0 and np.max(size_plot) > np.min(size_plot):
norm = plt.Normalize(vmin=np.min(size_plot), vmax=np.max(size_plot))
colors = [plt.cm.viridis(norm(s)) for s in size_plot]
else:
colors = ["steelblue"] * len(plot_x)
ax.bar(
plot_x,
plot_y,
width=bin_width * 0.8,
color=colors,
alpha=0.7,
edgecolor="black",
linewidth=0.5,
)
# Add colorbar consistent with scatter plots
if len(size_plot) > 0 and np.max(size_plot) > np.min(size_plot):
sm = plt.cm.ScalarMappable(cmap=plt.cm.viridis, norm=norm)
sm.set_array([])
cbar = plt.colorbar(sm, ax=ax)
# Label plus explicite pour la colorbar
size_label = size.replace("_", " ").title()
if size == "avg_rating":
cbar.set_label("Moyenne des notes (du bin)")
else:
cbar.set_label(f"Moyenne {size_label} (du bin)")
else:
# Simple histogram without size coloring
ax.bar(
plot_x,
plot_y,
width=bin_width * 0.8,
alpha=0.7,
color="steelblue",
edgecolor="black",
linewidth=0.5,
)
# Add value labels on top of bars (more readable)
for i, (x_pos, height) in enumerate(zip(plot_x, plot_y)):
if not pd.isna(height) and height > 0:
ax.text(
x_pos,
height + height * 0.02,
f"{int(height)}",
ha="center",
va="bottom",
fontsize=9,
fontweight="bold",
)
# Improve axis labels
ax.set_ylabel(y_label)
# Set proper x-axis limits and ticks
ax.set_xlim(x_min - bin_width / 2, x_max + bin_width / 2)
# Add subtle grid for better readability
ax.grid(True, alpha=0.3, axis="y")
# ---------------- Main Render ---------------- #
[docs]
def run(self):
# Introduction analytique
with st.expander("🎯 Objectifs et méthodologie de l'analyse", expanded=True):
st.markdown(
"""
### Qu'est-ce qui rend une recette populaire ?
Cette analyse explore la relation entre la qualité des recettes (notes des utilisateurs) et leur
succès (nombre d'interactions soit le nombre d'utilisateur ayant review la recette). Nous examinons comment les caractéristiques des recettes influencent
leur adoption par la communauté.
**Questions centrales :** La qualité garantit-elle la popularité ? Quels sont les facteurs
déterminants du succès d'une recette ? Existe-t-il des profils de recettes particulièrement
attractifs ?
**Approche :** Analyse comparative entre qualité et engagement, segmentation des recettes par
popularité, identification des caractéristiques discriminantes.
**Données :** Preprocessing sélectif qui conserve toutes les interactions authentiques tout en
filtrant les anomalies techniques.
"""
)
# Section explicative du preprocessing optimisé
with st.expander("⚙️ Configuration du preprocessing optimisé", expanded=False):
st.markdown(
"""
### Preprocessing optimisé : IQR 5.0 fixe
**🎯 Objectif :** Conservation maximale des données avec filtrage ciblé des aberrations techniques.
**🔧 Configuration choisie :**
- **Méthode :** IQR (Interquartile Range)
- **Seuil :** 5.0 (fixe, optimisé)
- **Conservation :** 95.1% des données
- **Outliers supprimés :** ~55,000 aberrations techniques
**🧹 Gestion des valeurs manquantes :**
- **Suppression sélective :** Seules les lignes avec des données essentielles manquantes sont supprimées
- **Colonnes critiques :** date, rating, recipe_id doivent être présentes
- **Conservation maximale :** Les recettes avec quelques attributs manquants sont conservées
- **Nettoyage ciblé :** Utilisation de `dropna(subset=[colonnes_essentielles])` pour préserver le maximum de données
**✅ Avantages de cette approche :**
- **Performance maximale :** Calcul unique, cache CSV automatique
- **Conservation optimale :** Garde toutes les recettes légitimes
- **Filtrage ciblé :** Supprime uniquement les vraies aberrations (ex: 500h de cuisson)
- **Gestion intelligente des NaN :** Conservation des recettes avec données partielles mais utilisables
- **Simplicité :** Plus de paramètre à ajuster, configuration scientifiquement validée
"""
)
# Vérification des données
st.markdown("#### � Validation de la configuration")
# Chargement temporaire pour vérification
temp_interactions_df, temp_recipes_df = self._load_data()
col1, col2, col3 = st.columns(3)
with col1:
st.metric(
"💾 Interactions totales",
f"{len(temp_interactions_df):,}",
help="Nombre total d'interactions dans le dataset"
)
with col2:
st.metric(
"📋 Recettes totales",
f"{len(temp_recipes_df):,}",
help="Nombre total de recettes dans le dataset"
)
with col3:
st.metric(
"🎯 Conservation attendue",
"95.1%",
delta="Optimal",
help="Pourcentage de données conservées avec IQR 5.0"
)
st.markdown(
"""
**🔧 Détails techniques :**
- **Méthode IQR :** Q1 - 5.0×IQR ≤ valeurs ≤ Q3 + 5.0×IQR
- **Variables ciblées :** minutes, n_steps, n_ingredients (pas les ratings)
- **Cache automatique :** CSV sauvegardés pour chargement instantané
**📈 Justification scientifique :**
- **Seuil 5.0 :** Optimal basé sur analyse empirique de ~55,000 outliers
- **Conservation 95.1% :** Équilibre parfait entre qualité et exhaustivité
- **Performance :** Calcul unique, réutilisation via cache CSV
"""
)
params = self._sidebar()
plot_type = params["plot_type"]
n_bins = params["n_bins"]
bin_agg = params["bin_agg"]
alpha = params["alpha"]
with st.spinner("Chargement des données..."):
self.logger.info("Loading data for popularity analysis")
interactions_df, recipes_df = self._load_data()
self.logger.debug(
f"Loaded interactions: {interactions_df.shape}, recipes: {recipes_df.shape}"
)
# Configuration preprocessing optimisée : IQR 5.0 fixe pour conservation optimale
config_optimized = PreprocessingConfig(
enable_preprocessing=True,
outlier_method="iqr", # Méthode IQR
outlier_threshold=5.0, # Seuil fixe optimal (95.1% conservation)
)
analyzer = InteractionsAnalyzer(
interactions=interactions_df,
recipes=recipes_df,
preprocessing=config_optimized,
cache_enabled=True, # Cache activé pour de meilleures performances
)
self.logger.info("Initialized InteractionsAnalyzer with optimized IQR 5.0 preprocessing")
# Affichage de l'impact du preprocessing avec détails statistiques
try:
merged_df = analyzer._df # type: ignore[attr-defined]
original_count = len(interactions_df)
filtered_count = len(merged_df)
filtered_percentage = (filtered_count / original_count) * 100
# Récupération des stats de preprocessing
preprocessing_stats = analyzer.get_preprocessing_stats()
col1, col2, col3 = st.columns(3)
with col1:
st.metric(
"📊 Observations conservées",
f"{filtered_count:,}",
delta=f"{filtered_percentage:.1f}% du total",
)
with col2:
st.metric(
"⚙️ Configuration optimisée",
"IQR 5.0",
delta="95.1% conservé",
help="Seuil IQR fixe optimisé pour conservation maximale",
)
# Détails des features traitées
if preprocessing_stats and "features_processed" in preprocessing_stats:
features_processed = preprocessing_stats["features_processed"]
if features_processed:
st.info(f"**Features analysées :** {', '.join(features_processed)}")
except Exception as e:
st.info(f"**Preprocessing optimisé** (IQR 5.0) - Détails non disponibles: {str(e)}")
# Cache management in sidebar
self._render_cache_controls(analyzer)
agg = analyzer.aggregate()
# Aperçu du dataframe fusionné avant agrégation
st.subheader("Aperçu du dataframe fusionné (interactions et recettes)")
try:
merged_df = analyzer._df # type: ignore[attr-defined]
col1, col2 = st.columns(2)
with col1:
st.metric("Lignes", f"{len(merged_df):,}")
with col2:
st.metric("Colonnes", f"{len(merged_df.columns)}")
with st.expander("Colonnes du merged df"):
st.code(", ".join(list(merged_df.columns)))
st.dataframe(merged_df.head(20))
except Exception as e:
st.info(f"Impossible d'afficher le merged df: {e}")
st.subheader("Table d'agrégation (Top 20)")
st.dataframe(agg.head(20))
# Execute the 4 analysis steps using dedicated render methods
pop_rating = self._render_step_1(analyzer, plot_type, n_bins, bin_agg, alpha)
if pop_rating is not None:
self._render_step_2(analyzer, pop_rating)
self._render_step_3(analyzer, agg, plot_type, n_bins, bin_agg, alpha, pop_rating)
self._render_viral_recipe_analysis(analyzer, agg, interactions_df, recipes_df)
# Synthèse et conclusions
st.markdown("---")
st.subheader("📋 Synthèse des résultats")
st.markdown(
"""
### Conclusions principales
**1. Relation qualité-popularité :** Non-linéaire avec formation de clusters distincts
selon le niveau d'engagement, confirmant que l'excellence seule ne garantit pas la viralité.
**2. Segmentation comportementale :** Segmentation par percentiles révélant 4 segments
(faible/modéré/élevé/viral) avec des dynamiques d'adoption distinctes.
**3. Facteurs d'optimisation :** Les caractéristiques techniques révèlent des zones
d'équilibre optimal entre accessibilité et valeur perçue :
- Temps de préparation : équilibre entre simplicité et satisfaction
- Complexité procédurale : niveau de défi optimal pour l'engagement
- Richesse compositionnelle : balance entre richesse et accessibilité
**4. Pattern universel de viralité :** Les recettes les plus populaires suivent
un cycle commun : émergence progressive, explosion virale, stabilisation ou déclin.
Ce pattern reflète les mécanismes naturels des tendances culturelles.
"""
)
st.markdown("---")
st.caption(
"💡 **Configuration** : Ajustez les paramètres de preprocessing et visualisation pour explorer différentes perspectives analytiques."
)