madamanastasia
Mise à jour
b1c31bf
Raw
History Blame Contribute Delete
12.4 kB
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"
@st.cache_data
def load_data():
df = pd.read_csv(DATA_PATH)
df = df.loc[:, ~df.columns.str.match(r"^Unnamed")]
return df
@st.cache_data
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")
@st.cache_data
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.")