Spaces:
Runtime error
Runtime error
| import streamlit as st | |
| import pandas as pd | |
| import numpy as np | |
| from pathlib import Path | |
| import altair as alt | |
| from sklearn.ensemble import RandomForestRegressor | |
| from sklearn.compose import ColumnTransformer | |
| from sklearn.preprocessing import OneHotEncoder | |
| from sklearn.pipeline import Pipeline | |
| st.set_page_config(page_title="Getaround — Analyse du délai de retour", layout="wide") | |
| APP_DIR = Path(__file__).resolve().parent | |
| DATA_PATH = APP_DIR / "get_around_delay_analysis.csv" | |
| PRICING_PATH = APP_DIR / "get_around_pricing_project.csv" | |
| def load_data(): | |
| df = pd.read_csv(DATA_PATH) | |
| df = df.loc[:, ~df.columns.str.match(r"^Unnamed")] | |
| return df | |
| def load_pricing(): | |
| dfp = pd.read_csv(PRICING_PATH) | |
| dfp = dfp.loc[:, ~dfp.columns.str.match(r"^Unnamed")] | |
| return dfp | |
| df = load_data() | |
| pricing_df = load_pricing() | |
| MEDIAN_PRICE = float(pricing_df["rental_price_per_day"].median()) | |
| MEAN_PRICE = float(pricing_df["rental_price_per_day"].mean()) | |
| st.title("Getaround — Analyse du tampon de retard (2017)") | |
| st.markdown( | |
| """ | |
| Ce tableau de bord explore le compromis lié à l'introduction d'un **délai minimum** entre deux locations consécutives. | |
| Un tampon réduit les frictions causées par les retours tardifs, mais peut diminuer l'utilisation de la plateforme. | |
| """ | |
| ) | |
| st.subheader("Statistiques générales des retards") | |
| total_with_delay = df[df["state"] == "ended"]["delay_at_checkout_in_minutes"].dropna() | |
| late = total_with_delay[total_with_delay > 0] | |
| pct_late = len(late) / len(total_with_delay) * 100 | |
| median_late = late.median() | |
| has_prev_pct = df["previous_ended_rental_id"].notna().sum() / len(df) * 100 | |
| col_a, col_b, col_c = st.columns(3) | |
| col_a.metric( | |
| "Locations rendues en retard", | |
| f"{pct_late:.1f}%", | |
| f"{len(late):,} sur {len(total_with_delay):,}" | |
| ) | |
| col_b.metric( | |
| "Retard médian (retardataires)", | |
| f"{median_late:.0f} min", | |
| "parmi les locations en retard" | |
| ) | |
| col_c.metric( | |
| "Locations avec une précédente", | |
| f"{has_prev_pct:.1f}%", | |
| f"{df['previous_ended_rental_id'].notna().sum():,} locations" | |
| ) | |
| with st.sidebar: | |
| st.header("Paramètres de la politique") | |
| scope = st.selectbox( | |
| "Périmètre", | |
| ["Tous les véhicules", "Connect uniquement", "Mobile uniquement"], | |
| index=0 | |
| ) | |
| threshold = st.slider( | |
| "Délai minimum (minutes)", | |
| min_value=0, | |
| max_value=720, | |
| value=120, | |
| step=5 | |
| ) | |
| st.header("Visualisation") | |
| clip_mode = st.selectbox( | |
| "Écrêtage des retards", | |
| ["Aucun", "Percentiles (1–99)", "Plage fixe (±24h)"], | |
| index=1 | |
| ) | |
| bins = st.slider("Nombre de classes (histogramme)", 20, 200, 60, step=10) | |
| st.header("Filtres") | |
| include_canceled = st.checkbox("Inclure les locations annulées", value=False) | |
| work = df.copy() | |
| if not include_canceled: | |
| work = work[work["state"] == "ended"].copy() | |
| if scope == "Connect uniquement": | |
| work = work[work["checkin_type"] == "connect"].copy() | |
| elif scope == "Mobile uniquement": | |
| work = work[work["checkin_type"] == "mobile"].copy() | |
| ended = df[df["state"] == "ended"][["rental_id", "delay_at_checkout_in_minutes"]].copy() | |
| ended["delay_at_checkout_in_minutes"] = ended["delay_at_checkout_in_minutes"].fillna(0) | |
| prev_delay_map = dict(zip(ended["rental_id"].astype(float), ended["delay_at_checkout_in_minutes"])) | |
| work["previous_delay_min"] = work["previous_ended_rental_id"].map(prev_delay_map).fillna(0) | |
| work["gap_min"] = work["time_delta_with_previous_rental_in_minutes"].fillna(np.inf) | |
| work["impact_on_next_driver_min"] = np.maximum(0, work["previous_delay_min"] - work["gap_min"]) | |
| work["affected_by_policy"] = work["gap_min"] < threshold | |
| work["problematic"] = work["impact_on_next_driver_min"] > 0 | |
| work["solved_by_policy"] = work["problematic"] & work["affected_by_policy"] | |
| eligible = np.isfinite(work["gap_min"]) | |
| gap_pos = work["gap_min"].where(eligible, 0).clip(lower=0) | |
| work["blocked_minutes"] = np.where(eligible, np.maximum(0, threshold - gap_pos), 0) | |
| total_gap_minutes = float(gap_pos.sum()) | |
| total_blocked_minutes = float(work["blocked_minutes"].sum()) | |
| revenue_at_risk_pct = (100 * total_blocked_minutes / total_gap_minutes) if total_gap_minutes > 0 else 0.0 | |
| blocked_days = total_blocked_minutes / 1440 | |
| estimated_revenue_loss_eur = blocked_days * MEDIAN_PRICE | |
| total_rentals = len(work) | |
| affected = int(work["affected_by_policy"].sum()) | |
| problematic = int(work["problematic"].sum()) | |
| solved = int(work["solved_by_policy"].sum()) | |
| pct = lambda a, b: (100 * a / b) if b else 0 | |
| col1, col2, col3, col4, col5 = st.columns(5) | |
| col1.metric("Locations terminées (périmètre)", f"{total_rentals:,}") | |
| col2.metric("Locations affectées par la politique", f"{affected:,}", f"{pct(affected, total_rentals):.1f}%") | |
| col3.metric("Cas problématiques (attente > 0)", f"{problematic:,}", f"{pct(problematic, total_rentals):.1f}%") | |
| col4.metric( | |
| "Cas problématiques résolus", | |
| f"{solved:,}", | |
| f"{pct(solved, problematic):.1f}% des cas problématiques" if problematic else "0%" | |
| ) | |
| col5.metric("Revenu à risque (estimation)", f"{revenue_at_risk_pct:.1f}%", f"≈ €{estimated_revenue_loss_eur:,.0f} est.") | |
| st.caption( | |
| f"Estimation en € basée sur le prix journalier médian " | |
| f"(médiane = €{MEDIAN_PRICE:.0f}, moyenne = €{MEAN_PRICE:.0f}). " | |
| "Le revenu à risque est calculé à partir du temps de créneau bloqué " | |
| "entre deux locations consécutives. " | |
| ) | |
| st.caption( | |
| "ℹ️ Ce calcul est basé sur les 1 841 locations consécutives du même véhicule " | |
| "(seuls cas où le buffer a un effet réel sur la disponibilité). " | |
| "Les locations sans précédent immédiat ne sont pas concernées par la politique de buffer." | |
| ) | |
| st.subheader("Distribution des retards au check-out (minutes)") | |
| delays = df["delay_at_checkout_in_minutes"].dropna().astype(float) | |
| if clip_mode == "Percentiles (1–99)": | |
| lo, hi = delays.quantile([0.01, 0.99]) | |
| delays_plot = delays.clip(lo, hi) | |
| st.caption(f"Écrêté aux percentiles 1–99 : [{lo:.0f} min, {hi:.0f} min]") | |
| elif clip_mode == "Plage fixe (±24h)": | |
| lo, hi = -1440, 1440 | |
| delays_plot = delays.clip(lo, hi) | |
| st.caption("Écrêté à ±24 heures : [-1440 min, 1440 min]") | |
| else: | |
| delays_plot = delays | |
| st.caption("Aucun écrêtage (valeurs brutes)") | |
| hist_df = pd.DataFrame({"delay_min": delays_plot}) | |
| chart = ( | |
| alt.Chart(hist_df) | |
| .mark_bar() | |
| .encode( | |
| x=alt.X("delay_min:Q", bin=alt.Bin(maxbins=bins), title="Retard au check-out (minutes)"), | |
| y=alt.Y("count():Q", title="Nombre de locations"), | |
| ) | |
| .properties(height=280) | |
| ) | |
| st.altair_chart(chart, use_container_width=True) | |
| st.divider() | |
| st.subheader("Sensibilité au seuil (courbe rapide)") | |
| thresholds = np.arange(0, 721, 15) | |
| def compute_curve(th): | |
| affected_mask = (work["gap_min"] < th) | |
| solved_mask = work["problematic"] & affected_mask | |
| eligible = np.isfinite(work["gap_min"]) | |
| gap_pos = work["gap_min"].where(eligible, 0).clip(lower=0) | |
| blocked = np.where(eligible, np.maximum(0, th - gap_pos), 0) | |
| total_gap = float(gap_pos.sum()) | |
| total_blocked = float(blocked.sum()) | |
| revenue_risk_share = (total_blocked / total_gap) if total_gap > 0 else 0.0 | |
| return float(affected_mask.mean()), int(solved_mask.sum()), float(revenue_risk_share) | |
| affected_share = [] | |
| solved_counts = [] | |
| revenue_risk_share = [] | |
| for th in thresholds: | |
| a, s, r = compute_curve(th) | |
| affected_share.append(a) | |
| solved_counts.append(s) | |
| revenue_risk_share.append(r) | |
| curve_df = pd.DataFrame({ | |
| "threshold_min": thresholds, | |
| "affected_share": affected_share, | |
| "solved_problematic_cases": solved_counts, | |
| "revenue_at_risk_share": revenue_risk_share, | |
| }) | |
| curve_df["revenue_at_risk_pct"] = 100 * curve_df["revenue_at_risk_share"] | |
| curve_df["affected_pct"] = 100 * curve_df["affected_share"] | |
| c1, c2, c3 = st.columns([1, 1, 1]) | |
| with c1: | |
| st.caption("Part des locations affectées (masquées dans la recherche)") | |
| affected_chart = ( | |
| alt.Chart(curve_df) | |
| .mark_line(color="#4C78A8") | |
| .encode( | |
| x=alt.X("threshold_min:Q", title="Seuil (minutes)"), | |
| y=alt.Y("affected_pct:Q", title="Locations affectées (%)"), | |
| ) | |
| .properties(height=260) | |
| ) | |
| st.altair_chart(affected_chart, use_container_width=True) | |
| with c2: | |
| st.caption("Nombre de cas problématiques résolus") | |
| solved_chart = ( | |
| alt.Chart(curve_df) | |
| .mark_line(color="#F58518") | |
| .encode( | |
| x=alt.X("threshold_min:Q", title="Seuil (minutes)"), | |
| y=alt.Y("solved_problematic_cases:Q", title="Cas résolus (nombre)"), | |
| ) | |
| .properties(height=260) | |
| ) | |
| st.altair_chart(solved_chart, use_container_width=True) | |
| with c3: | |
| st.caption("Revenu à risque (estimation) — part du créneau bloqué") | |
| revenue_chart = ( | |
| alt.Chart(curve_df) | |
| .mark_line(color="#E45756") | |
| .encode( | |
| x=alt.X("threshold_min:Q", title="Seuil (minutes)"), | |
| y=alt.Y("revenue_at_risk_pct:Q", title="Revenu à risque (%)"), | |
| ) | |
| .properties(height=260) | |
| ) | |
| st.altair_chart(revenue_chart, use_container_width=True) | |
| st.subheader("Vue en coude : frictions résolues vs revenu à risque") | |
| scatter = ( | |
| alt.Chart(curve_df) | |
| .mark_circle(size=70) | |
| .encode( | |
| x=alt.X("revenue_at_risk_pct:Q", title="Revenu à risque (%)"), | |
| y=alt.Y("solved_problematic_cases:Q", title="Cas problématiques résolus (nombre)"), | |
| tooltip=[ | |
| alt.Tooltip("threshold_min:Q", title="Seuil (min)"), | |
| alt.Tooltip("revenue_at_risk_pct:Q", title="Revenu à risque (%)", format=".2f"), | |
| alt.Tooltip("solved_problematic_cases:Q", title="Cas résolus"), | |
| alt.Tooltip("affected_pct:Q", title="Locations affectées (%)", format=".1f"), | |
| ], | |
| ) | |
| .properties(height=320) | |
| ) | |
| st.altair_chart(scatter, use_container_width=True) | |
| st.divider() | |
| st.subheader("Facteurs influençant le prix journalier") | |
| def compute_feature_importance(): | |
| df = pricing_df.copy() | |
| df = df.dropna() | |
| target = "rental_price_per_day" | |
| X = df.drop(columns=[target]) | |
| y = df[target] | |
| cat_cols = ["model_key", "fuel", "paint_color", "car_type"] | |
| num_cols = [c for c in X.columns if c not in cat_cols] | |
| preprocess = ColumnTransformer([ | |
| ("cat", OneHotEncoder(handle_unknown="ignore"), cat_cols), | |
| ("num", "passthrough", num_cols), | |
| ]) | |
| model = RandomForestRegressor(n_estimators=100, random_state=42, n_jobs=-1) | |
| pipe = Pipeline([("preprocess", preprocess), ("model", model)]) | |
| pipe.fit(X, y) | |
| ohe = pipe["preprocess"].named_transformers_["cat"] | |
| cat_names = ohe.get_feature_names_out(cat_cols).tolist() | |
| all_names = cat_names + num_cols | |
| feat_df = pd.DataFrame({ | |
| "feature": all_names, | |
| "importance": pipe["model"].feature_importances_ | |
| }).sort_values("importance", ascending=False).head(12) | |
| return feat_df | |
| feat_df = compute_feature_importance() | |
| chart = ( | |
| alt.Chart(feat_df) | |
| .mark_bar() | |
| .encode( | |
| x=alt.X("importance:Q", title="Importance (score)"), | |
| y=alt.Y("feature:N", sort="-x", title="Caractéristique"), | |
| color=alt.Color("importance:Q", scale=alt.Scale(scheme="blues"), legend=None), | |
| ) | |
| .properties(height=380) | |
| ) | |
| feat_df["feature"] = feat_df["feature"].replace({ | |
| "engine_power": "Puissance moteur", | |
| "mileage": "Kilométrage", | |
| "has_gps": "GPS", | |
| "has_getaround_connect": "Connect (sans clé)", | |
| "automatic_car": "Boîte automatique", | |
| "private_parking_available": "Parking privé", | |
| "has_air_conditioning": "Climatisation", | |
| "has_speed_regulator": "Régulateur de vitesse", | |
| "winter_tires": "Pneus hiver", | |
| "car_type_suv": "Type: SUV", | |
| "car_type_estate": "Type: Break", | |
| "car_type_sedan": "Type: Berline", | |
| "car_type_hatchback": "Type: Citadine", | |
| "model_key_BMW": "Marque: BMW", | |
| "model_key_Citroën": "Marque: Citroën", | |
| "model_key_Mitsubishi": "Marque: Mitsubishi", | |
| "paint_color_blue": "Couleur: Bleu", | |
| "paint_color_black": "Couleur: Noir", | |
| "paint_color_white": "Couleur: Blanc", | |
| }) | |
| st.altair_chart(chart, use_container_width=True) | |
| st.caption("Importance calculée via Random Forest sur le jeu de données tarifaire complet.") |