madamanastasia commited on
Commit
b873ef0
·
1 Parent(s): a25dcdb

Add revenue impact proxy and pricing integration to dashboard

Browse files
Files changed (2) hide show
  1. app.py +117 -32
  2. get_around_pricing_project.csv +0 -0
app.py CHANGED
@@ -8,13 +8,29 @@ st.set_page_config(page_title="Getaround — Late Return Buffer Analysis", layou
8
 
9
  APP_DIR = Path(__file__).resolve().parent
10
  DATA_PATH = APP_DIR / "get_around_delay_analysis.csv"
 
 
11
 
12
  @st.cache_data
13
  def load_data():
14
  df = pd.read_csv(DATA_PATH)
 
 
15
  return df
16
 
 
 
 
 
 
 
 
 
17
  df = load_data()
 
 
 
 
18
 
19
  st.title("Getaround — Late Return Buffer (2017 analysis)")
20
 
@@ -25,11 +41,16 @@ A buffer reduces friction caused by late checkouts, but may reduce marketplace u
25
  """
26
  )
27
 
28
-
29
  with st.sidebar:
30
  st.header("Policy settings")
31
  scope = st.selectbox("Scope", ["All cars", "Connect only"], index=0)
32
- threshold = st.slider("Minimum buffer (minutes)", min_value=0, max_value=360, value=120, step=5)
 
 
 
 
 
 
33
 
34
  st.header("Visualization")
35
  clip_mode = st.selectbox("Delay clipping", ["None", "Percentiles (1–99)", "Fixed range (±24h)"], index=1)
@@ -46,13 +67,12 @@ if not include_canceled:
46
  if scope == "Connect only":
47
  work = work[work["checkin_type"] == "connect"].copy()
48
 
49
-
50
- # Build previous delay mapping to estimate impact on next driver
51
  ended = df[df["state"] == "ended"][["rental_id", "delay_at_checkout_in_minutes"]].copy()
52
  ended["delay_at_checkout_in_minutes"] = ended["delay_at_checkout_in_minutes"].fillna(0)
53
 
54
- prev_delay_map = dict(zip(ended["rental_id"].astype(float), ended["delay_at_checkout_in_minutes"]))
55
  # previous_ended_rental_id is float due to NaNs in source
 
56
  work["previous_delay_min"] = work["previous_ended_rental_id"].map(prev_delay_map).fillna(0)
57
 
58
  work["gap_min"] = work["time_delta_with_previous_rental_in_minutes"].fillna(np.inf)
@@ -67,19 +87,46 @@ work["problematic"] = work["impact_on_next_driver_min"] > 0
67
  # Solved cases under policy: problematic cases among affected rentals
68
  work["solved_by_policy"] = work["problematic"] & work["affected_by_policy"]
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  total_rentals = len(work)
71
  affected = int(work["affected_by_policy"].sum())
72
  problematic = int(work["problematic"].sum())
73
  solved = int(work["solved_by_policy"].sum())
74
 
75
- pct = lambda a, b: (100*a/b) if b else 0
76
 
77
- col1, col2, col3, col4 = st.columns(4)
78
  col1.metric("Ended rentals (in scope)", f"{total_rentals:,}")
79
- col2.metric("Rentals affected by policy", f"{affected:,}", f"{pct(affected,total_rentals):.1f}%")
80
- col3.metric("Problematic cases (wait > 0)", f"{problematic:,}", f"{pct(problematic,total_rentals):.1f}%")
81
- col4.metric("Problematic cases solved", f"{solved:,}", f"{pct(solved,problematic):.1f}% of problematic" if problematic else "0%")
 
 
 
 
 
 
 
 
 
 
82
 
 
83
  st.subheader("Distribution of checkout delays (minutes)")
84
 
85
  delays = df["delay_at_checkout_in_minutes"].dropna().astype(float)
@@ -103,69 +150,107 @@ chart = (
103
  .mark_bar()
104
  .encode(
105
  x=alt.X("delay_min:Q", bin=alt.Bin(maxbins=bins), title="Checkout delay (min)"),
106
- y=alt.Y("count():Q", title="Count")
107
  )
108
  .properties(height=280)
109
  )
110
 
111
  st.altair_chart(chart, use_container_width=True)
112
 
113
-
114
-
115
-
116
  st.divider()
117
 
 
118
  st.subheader("Threshold sensitivity (quick curve)")
119
 
120
- thresholds = np.arange(0, 361, 15)
121
 
122
  def compute_curve(th):
123
- affected = (work["gap_min"] < th)
124
- solved = work["problematic"] & affected
125
- return affected.mean(), solved.sum()
 
 
 
 
 
 
 
 
 
126
 
127
  affected_share = []
128
  solved_counts = []
 
 
129
  for th in thresholds:
130
- a, s = compute_curve(th)
131
  affected_share.append(a)
132
  solved_counts.append(s)
 
133
 
134
  curve_df = pd.DataFrame({
135
  "threshold_min": thresholds,
136
  "affected_share": affected_share,
137
- "solved_problematic_cases": solved_counts
 
138
  })
 
139
 
140
- c1, c2 = st.columns([1,1])
141
  with c1:
142
  st.caption("Share of rentals affected (hidden from search)")
143
  st.line_chart(curve_df.set_index("threshold_min")["affected_share"], height=260)
144
  with c2:
145
  st.caption("Number of problematic cases solved")
146
  st.line_chart(curve_df.set_index("threshold_min")["solved_problematic_cases"], height=260)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
 
148
  st.divider()
149
 
 
150
  st.subheader("Examples of high-friction situations")
151
  examples = work[work["problematic"]].copy()
152
  examples["estimated_wait_min"] = examples["impact_on_next_driver_min"].round(0).astype(int)
153
  examples = examples.sort_values("estimated_wait_min", ascending=False).head(20)
154
 
155
  st.dataframe(
156
- examples[[
157
- "rental_id",
158
- "car_id",
159
- "checkin_type",
160
- "gap_min",
161
- "previous_delay_min",
162
- "estimated_wait_min",
163
- "affected_by_policy"
164
- ]],
165
- use_container_width=True
 
 
 
166
  )
167
 
168
  st.caption(
169
  "Interpretation: estimated_wait_min approximates how long the next driver may have to wait "
170
- "if the previous driver returns the car late and the planned gap is small."
 
171
  )
 
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
 
 
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)
 
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)
 
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)
 
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
  )
get_around_pricing_project.csv ADDED
The diff for this file is too large to render. See raw diff