madamanastasia commited on
Commit
5df42a3
·
1 Parent(s): 2f121b8

Mise à jour

Browse files
Files changed (2) hide show
  1. README.md +12 -0
  2. app.py +74 -211
README.md ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Getaround Delay Analysis
3
+ emoji: 🚗
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: streamlit
7
+ app_file: app.py
8
+ pinned: false
9
+ ---
10
+
11
+ # Getaround - Analyse des retards
12
+ Projet d'analyse opérationnelle pour optimiser le temps de repos (buffer) entre les locations.
app.py CHANGED
@@ -4,253 +4,116 @@ import numpy as np
4
  from pathlib import Path
5
  import altair as alt
6
 
7
- st.set_page_config(page_title="Getaround — Late Return Buffer Analysis", layout="wide")
8
 
9
  APP_DIR = Path(__file__).resolve().parent
10
  DATA_PATH = APP_DIR / "get_around_delay_analysis.csv"
11
  PRICING_PATH = APP_DIR / "get_around_pricing_project.csv"
12
 
13
-
14
  @st.cache_data
15
  def load_data():
16
  df = pd.read_csv(DATA_PATH)
17
- # на всякий случай чистим индексные столбцы
18
  df = df.loc[:, ~df.columns.str.match(r"^Unnamed")]
19
  return df
20
 
21
-
22
  @st.cache_data
23
  def load_pricing():
24
  dfp = pd.read_csv(PRICING_PATH)
25
  dfp = dfp.loc[:, ~dfp.columns.str.match(r"^Unnamed")]
26
  return dfp
27
 
28
-
29
  df = load_data()
30
  pricing_df = load_pricing()
31
-
32
  MEDIAN_PRICE = float(pricing_df["rental_price_per_day"].median())
33
- MEAN_PRICE = float(pricing_df["rental_price_per_day"].mean())
34
 
35
- st.title("Getaround — Late Return Buffer (2017 analysis)")
36
 
37
  st.markdown(
38
  """
39
- This dashboard explores the trade-off of introducing a **minimum buffer time** between two consecutive rentals.
40
- A buffer reduces friction caused by late checkouts, but may reduce marketplace utilization.
41
  """
42
  )
43
 
 
44
  with st.sidebar:
45
- st.header("Policy settings")
46
- scope = st.selectbox("Scope", ["All cars", "Connect only"], index=0)
 
 
47
  threshold = st.slider(
48
- "Minimum buffer (minutes)",
49
  min_value=0,
50
- max_value=720, # data has gaps up to ~720 minutes
51
  value=120,
52
- step=5
53
  )
54
 
55
- st.header("Visualization")
56
- clip_mode = st.selectbox("Delay clipping", ["None", "Percentiles (1–99)", "Fixed range (±24h)"], index=1)
57
- bins = st.slider("Histogram bins", 20, 200, 60, step=10)
58
-
59
- st.header("Filters")
60
- include_canceled = st.checkbox("Include canceled rentals", value=False)
61
-
62
  work = df.copy()
 
 
63
 
64
- if not include_canceled:
65
- work = work[work["state"] == "ended"].copy()
66
-
67
- if scope == "Connect only":
68
- work = work[work["checkin_type"] == "connect"].copy()
69
-
70
- # --- Build previous delay mapping to estimate impact on next driver ---
71
- ended = df[df["state"] == "ended"][["rental_id", "delay_at_checkout_in_minutes"]].copy()
72
- ended["delay_at_checkout_in_minutes"] = ended["delay_at_checkout_in_minutes"].fillna(0)
73
-
74
- # previous_ended_rental_id is float due to NaNs in source
75
- prev_delay_map = dict(zip(ended["rental_id"].astype(float), ended["delay_at_checkout_in_minutes"]))
76
- work["previous_delay_min"] = work["previous_ended_rental_id"].map(prev_delay_map).fillna(0)
77
-
78
- work["gap_min"] = work["time_delta_with_previous_rental_in_minutes"].fillna(np.inf)
79
- work["impact_on_next_driver_min"] = np.maximum(0, work["previous_delay_min"] - work["gap_min"])
80
-
81
- # Policy effect: rentals that would be hidden because gap < threshold
82
- work["affected_by_policy"] = work["gap_min"] < threshold
83
-
84
- # Problematic cases: when next driver would be impacted (wait time > 0)
85
- work["problematic"] = work["impact_on_next_driver_min"] > 0
86
-
87
- # Solved cases under policy: problematic cases among affected rentals
88
- work["solved_by_policy"] = work["problematic"] & work["affected_by_policy"]
89
-
90
- # --- Revenue impact proxy (time-based) ---
91
- # We only know "slack time" between consecutive rentals (gap_min) when previous rental exists.
92
- eligible = np.isfinite(work["gap_min"])
93
- gap_pos = work["gap_min"].where(eligible, 0).clip(lower=0)
94
-
95
- # How much of the slack gets blocked by applying a buffer threshold
96
- work["blocked_minutes"] = np.where(eligible, np.maximum(0, threshold - gap_pos), 0)
97
-
98
- total_gap_minutes = float(gap_pos.sum())
99
- total_blocked_minutes = float(work["blocked_minutes"].sum())
100
-
101
- revenue_at_risk_pct = (100 * total_blocked_minutes / total_gap_minutes) if total_gap_minutes > 0 else 0.0
102
- blocked_days = total_blocked_minutes / 1440
103
- estimated_revenue_loss_eur = blocked_days * MEDIAN_PRICE
104
-
105
- # --- Summary metrics ---
106
- total_rentals = len(work)
107
- affected = int(work["affected_by_policy"].sum())
108
- problematic = int(work["problematic"].sum())
109
- solved = int(work["solved_by_policy"].sum())
110
-
111
- pct = lambda a, b: (100 * a / b) if b else 0
112
-
113
- col1, col2, col3, col4, col5 = st.columns(5)
114
- col1.metric("Ended rentals (in scope)", f"{total_rentals:,}")
115
- col2.metric("Rentals affected by policy", f"{affected:,}", f"{pct(affected, total_rentals):.1f}%")
116
- col3.metric("Problematic cases (wait > 0)", f"{problematic:,}", f"{pct(problematic, total_rentals):.1f}%")
117
- col4.metric(
118
- "Problematic cases solved",
119
- f"{solved:,}",
120
- f"{pct(solved, problematic):.1f}% of problematic" if problematic else "0%"
121
- )
122
- col5.metric("Revenue at risk (proxy)", f"{revenue_at_risk_pct:.1f}%", f"≈ €{estimated_revenue_loss_eur:,.0f} est.")
123
-
124
- st.caption(
125
- f"€ estimate uses median daily price from pricing dataset (median = €{MEDIAN_PRICE:.0f}, mean = €{MEAN_PRICE:.0f}). "
126
- "Revenue-at-risk proxy is based on blocked inter-rental slack (time between consecutive rentals)."
127
- )
128
-
129
- # --- Delay distribution ---
130
- st.subheader("Distribution of checkout delays (minutes)")
131
-
132
- delays = df["delay_at_checkout_in_minutes"].dropna().astype(float)
133
-
134
- if clip_mode == "Percentiles (1–99)":
135
- lo, hi = delays.quantile([0.01, 0.99])
136
- delays_plot = delays.clip(lo, hi)
137
- st.caption(f"Clipped to 1st–99th percentiles: [{lo:.0f}, {hi:.0f}] min")
138
- elif clip_mode == "Fixed range (±24h)":
139
- lo, hi = -1440, 1440
140
- delays_plot = delays.clip(lo, hi)
141
- st.caption("Clipped to ±24 hours: [-1440, 1440] min")
142
- else:
143
- delays_plot = delays
144
- st.caption("No clipping (raw values)")
145
-
146
- hist_df = pd.DataFrame({"delay_min": delays_plot})
147
 
148
- chart = (
149
- alt.Chart(hist_df)
150
- .mark_bar()
151
- .encode(
152
- x=alt.X("delay_min:Q", bin=alt.Bin(maxbins=bins), title="Checkout delay (min)"),
153
- y=alt.Y("count():Q", title="Count"),
154
- )
155
- .properties(height=280)
156
- )
157
-
158
- st.altair_chart(chart, use_container_width=True)
159
-
160
- st.divider()
161
-
162
- # --- Threshold sensitivity curve ---
163
- st.subheader("Threshold sensitivity (quick curve)")
164
-
165
- thresholds = np.arange(0, 721, 15)
166
-
167
- def compute_curve(th):
168
- affected_mask = (work["gap_min"] < th)
169
- solved_mask = work["problematic"] & affected_mask
170
-
171
- eligible = np.isfinite(work["gap_min"])
172
- gap_pos = work["gap_min"].where(eligible, 0).clip(lower=0)
173
- blocked = np.where(eligible, np.maximum(0, th - gap_pos), 0)
174
-
175
- total_gap = float(gap_pos.sum())
176
- total_blocked = float(blocked.sum())
177
- revenue_risk_share = (total_blocked / total_gap) if total_gap > 0 else 0.0
178
-
179
- return float(affected_mask.mean()), int(solved_mask.sum()), float(revenue_risk_share)
180
-
181
- affected_share = []
182
- solved_counts = []
183
- revenue_risk_share = []
184
-
185
- for th in thresholds:
186
- a, s, r = compute_curve(th)
187
- affected_share.append(a)
188
- solved_counts.append(s)
189
- revenue_risk_share.append(r)
190
-
191
- curve_df = pd.DataFrame({
192
- "threshold_min": thresholds,
193
- "affected_share": affected_share,
194
- "solved_problematic_cases": solved_counts,
195
- "revenue_at_risk_share": revenue_risk_share,
196
- })
197
- curve_df["revenue_at_risk_pct"] = 100 * curve_df["revenue_at_risk_share"]
198
-
199
- c1, c2, c3 = st.columns([1, 1, 1])
200
  with c1:
201
- st.caption("Share of rentals affected (hidden from search)")
202
- st.line_chart(curve_df.set_index("threshold_min")["affected_share"], height=260)
203
- with c2:
204
- st.caption("Number of problematic cases solved")
205
- st.line_chart(curve_df.set_index("threshold_min")["solved_problematic_cases"], height=260)
206
- with c3:
207
- st.caption("Revenue at risk (proxy) — share of blocked slack time")
208
- st.line_chart(curve_df.set_index("threshold_min")["revenue_at_risk_share"], height=260)
209
-
210
- st.subheader("Elbow view: solved friction vs revenue-at-risk")
211
- scatter = (
212
- alt.Chart(curve_df)
213
- .mark_circle(size=70)
214
- .encode(
215
- x=alt.X("revenue_at_risk_pct:Q", title="Revenue at risk (proxy, %)"),
216
- y=alt.Y("solved_problematic_cases:Q", title="Problematic cases solved"),
217
- tooltip=[
218
- alt.Tooltip("threshold_min:Q", title="Threshold (min)"),
219
- alt.Tooltip("revenue_at_risk_pct:Q", title="Revenue at risk (%)", format=".2f"),
220
- alt.Tooltip("solved_problematic_cases:Q", title="Solved cases"),
221
- alt.Tooltip("affected_share:Q", title="Affected share", format=".3f"),
222
- ],
223
- )
224
- .properties(height=320)
225
- )
226
- st.altair_chart(scatter, use_container_width=True)
227
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  st.divider()
229
-
230
- # --- Examples ---
231
- st.subheader("Examples of high-friction situations")
232
- examples = work[work["problematic"]].copy()
233
- examples["estimated_wait_min"] = examples["impact_on_next_driver_min"].round(0).astype(int)
234
- examples = examples.sort_values("estimated_wait_min", ascending=False).head(20)
235
-
236
- st.dataframe(
237
- examples[
238
- [
239
- "rental_id",
240
- "car_id",
241
- "checkin_type",
242
- "gap_min",
243
- "previous_delay_min",
244
- "estimated_wait_min",
245
- "affected_by_policy",
246
- "blocked_minutes",
247
- ]
248
- ],
249
- use_container_width=True,
250
- )
251
-
252
- st.caption(
253
- "Interpretation: estimated_wait_min approximates how long the next driver may have to wait "
254
- "if the previous driver returns the car late and the planned gap is small. "
255
- "blocked_minutes is the additional slack time removed by the buffer threshold."
256
- )
 
4
  from pathlib import Path
5
  import altair as alt
6
 
7
+ st.set_page_config(page_title="Getaround — Analyse du Seuil de Buffer", layout="wide")
8
 
9
  APP_DIR = Path(__file__).resolve().parent
10
  DATA_PATH = APP_DIR / "get_around_delay_analysis.csv"
11
  PRICING_PATH = APP_DIR / "get_around_pricing_project.csv"
12
 
 
13
  @st.cache_data
14
  def load_data():
15
  df = pd.read_csv(DATA_PATH)
 
16
  df = df.loc[:, ~df.columns.str.match(r"^Unnamed")]
17
  return df
18
 
 
19
  @st.cache_data
20
  def load_pricing():
21
  dfp = pd.read_csv(PRICING_PATH)
22
  dfp = dfp.loc[:, ~dfp.columns.str.match(r"^Unnamed")]
23
  return dfp
24
 
 
25
  df = load_data()
26
  pricing_df = load_pricing()
 
27
  MEDIAN_PRICE = float(pricing_df["rental_price_per_day"].median())
 
28
 
29
+ st.title("Getaround — Analyse opérationnelle du Buffer")
30
 
31
  st.markdown(
32
  """
33
+ Ce tableau de bord explore le compromis lié à l'introduction d'un **temps de repos minimum (buffer)** entre deux locations.
 
34
  """
35
  )
36
 
37
+ # --- SIDEBAR (Configuration sans les réglages de bins) ---
38
  with st.sidebar:
39
+ st.header("Paramètres")
40
+ # Добавлен выбор обоих типов
41
+ scope = st.selectbox("Périmètre (Scope)", ["All", "Connect", "Mobile"])
42
+
43
  threshold = st.slider(
44
+ "Seuil de Buffer (Minutes)",
45
  min_value=0,
46
+ max_value=720,
47
  value=120,
48
+ step=10
49
  )
50
 
51
+ # --- DATA PROCESSING ---
 
 
 
 
 
 
52
  work = df.copy()
53
+ if scope != "All":
54
+ work = work[work["checkin_type"] == scope.lower()]
55
 
56
+ # --- VISUALISATION DES DISTRIBUTIONS (LES BINS SONT FIXES MAINTENANT) ---
57
+ st.subheader("Distribution des retards et des écarts")
58
+ c1, c2 = st.columns(2)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  with c1:
61
+ # График задержек (Checkout delays)
62
+ delay_hist = alt.Chart(work[work['delay_at_checkout_min'] > 0]).mark_bar(color="#94a3b8").encode(
63
+ alt.X("delay_at_checkout_min:Q", bin=alt.Bin(maxbins=50), title="Retard au check-out (Minutes)"),
64
+ alt.Y('count()', title="Nombre de locations"),
65
+ ).properties(height=300)
66
+ st.altair_chart(delay_hist, use_container_width=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
+ with c2:
69
+ # График интервалов (Gaps between rentals)
70
+ gap_hist = alt.Chart(work[work['time_delta_with_next_rental_min'].notnull()]).mark_bar(color="#cbd5e1").encode(
71
+ alt.X("time_delta_with_next_rental_min:Q", bin=alt.Bin(maxbins=50), title="Écart entre locations (Minutes)"),
72
+ alt.Y('count()', title="Nombre de locations"),
73
+ ).properties(height=300)
74
+ st.altair_chart(gap_hist, use_container_width=True)
75
+
76
+ # --- LOGIQUE DE SIMULATION ---
77
+ thresholds = np.arange(0, 730, 10)
78
+ sim_results = []
79
+ problematic_all = work[(work["delay_at_checkout_min"] > work["time_delta_with_next_rental_min"]) & (work["time_delta_with_next_rental_min"].notnull())]
80
+
81
+ for t in thresholds:
82
+ num_affected = len(work[work["time_delta_with_next_rental_min"] < t])
83
+ solved = problematic_all[problematic_all["time_delta_with_next_rental_min"] < t]
84
+
85
+ sim_results.append({
86
+ "threshold_min": t,
87
+ "affected_count": num_affected,
88
+ "solved_pct": (len(solved) / len(problematic_all) * 100) if len(problematic_all) > 0 else 0,
89
+ "revenue_at_risk_pct": (num_affected / len(work) * 100) if len(work) > 0 else 0
90
+ })
91
+ sim_df = pd.DataFrame(sim_results)
92
+
93
+ # --- GRAPHIQUES D'IMPACT ---
94
  st.divider()
95
+ col1, col2 = st.columns(2)
96
+
97
+ with col1:
98
+ st.subheader("Efficacité : Problèmes résolus")
99
+ line_solved = alt.Chart(sim_df).mark_line(color="#2563eb", strokeWidth=3).encode(
100
+ x=alt.X("threshold_min:Q", title="Seuil de Buffer (Minutes)"),
101
+ y=alt.Y("solved_pct:Q", title="Cas résolus (%)"),
102
+ ).properties(height=350)
103
+ st.altair_chart(line_solved, use_container_width=True)
104
+
105
+ with col2:
106
+ st.subheader("Impact : Revenu à risque")
107
+ line_impact = alt.Chart(sim_df).mark_line(color="#e11d48", strokeWidth=3).encode(
108
+ x=alt.X("threshold_min:Q", title="Seuil de Buffer (Minutes)"),
109
+ y=alt.Y("revenue_at_risk_pct:Q", title="Locations affectées (%)"),
110
+ ).properties(height=350)
111
+ st.altair_chart(line_impact, use_container_width=True)
112
+
113
+ # --- METRIQUES FINALES ---
114
+ st.divider()
115
+ res_at_t = sim_df[sim_df["threshold_min"] == threshold].iloc[0]
116
+ m1, m2, m3 = st.columns(3)
117
+ m1.metric("Problèmes résolus", f"{res_at_t['solved_pct']:.1f}%")
118
+ m2.metric("Locations affectées", f"{res_at_t['revenue_at_risk_pct']:.1f}%")
119
+ m3.metric("Perte CA estimée", f"~{res_at_t['affected_count'] * MEDIAN_PRICE:,.0f} ")