Trip incident attribution
When a trip ends with a low rating or a complaint, which event in that trip's timeline most likely caused it?
Summary
We give support agents and ops teams a ranked, explained "likely cause" for every bad trip in a ride-hailing or delivery marketplace. HeapDB ranks the events of that one trip, from request to dropoff, and learns from the resolution codes agents already write.
Agents ask this when a ticket opens after a low rating, a complaint or a cancellation. Ops asks it after a rider-app release or a bad-weather night. Today both start from dashboards that show which slice moved. With HeapDB each ticket arrives with a suggested cause, and the same learned links roll up into a release-level risk view.
Model
The entity is the trip, not the rider or the driver. A trip has a short, closed timeline with a clear start and end, and every candidate cause sits inside it. Rider and driver traits, such as app version or hours online, become features on the trip's events. That keeps a per-person entity from carrying blame across unrelated trips.
| HeapDB | Mobility |
|---|---|
| Entity | One trip, trip-<id>, from request to dropoff. |
| Events | request, pin_edit, dispatch, eta_revision, pickup, reroute, gps_gap. |
| Features | Rider app version app:rider-5.1 or app:rider-5.2 (on the request and on pin edits), app:driver-3.9, pin:edit, zone:<name>, tod:<bucket>, weather:rain or weather:dry, surge:shown, driver:hours-0-4, driver:hours-4-10, driver:hours-10plus, eta:revised-up, pickup:long or pickup:normal, route:reroute, gps:gap. |
| Weight (dose) | register_event_type("pin_edit", weight=...): grows with how far the pin was dragged, 1.0 at 300 m, capped at 2.0. Other events weigh 1.0. |
| Effects and kernels | low_rating with LogNormalKernel(median=30 * M, sigma=0.8, tail_sigmas=2.0): peaks about 16 minutes before the rating, support about 2.5 hours. A complaint or rider_cancel effect would get its own, shorter kernel. |
| Leak | leak=0.05: low ratings that no event in the trip explains. |
| Rules | tiny pin nudge x0.2 for pin edits under 30 m. |
Links start from default_prior=(1, 9). A trip has no history of its own, so global_pseudocount=1000.0 lets each trip borrow almost fully from the population layer.
Learning
- Feedback. When an agent resolves a ticket, the resolution code (for example
PIN_WRONG_LOCATIONorLONG_PICKUP) maps to one event of the trip.feedback(trip, "low_rating", at, confirmed_event_id=..., rejected_ids=...)confirms that event and rejects the trip's other events. - Observe. Low ratings without a ticket call
observe(trip, "low_rating", at). Each event in the window gets soft credit in proportion to its score, and the leak takes the rest. - Settle. Trips that end without a low rating call
settle(until, effects="low_rating", entity=trip). "The window passed" meansuntilis past the trip's last event plus the kernel support; the example uses dropoff plus 3 hours. - Priors. A sceptical default prior per feature, so most trip events start as innocent. A team could register stronger priors for known failure modes, such as long pickups.
Sidecar architecture
The system of record stays the trip service, the dispatch and telemetry pipeline, and the support tool. Trip events reach HeapDB from the existing event stream or from change data capture on the trip tables. Ratings and ticket resolutions arrive the same way.
HeapDB writes back a "likely cause" field, with its explanation, on each ticket, and a periodic link_report and feature_risk view per release. It holds no data the source systems do not have. It can be dropped and rebuilt by replaying the trip and ticket logs.
Worked example
A synthetic marketplace runs three weeks of trips. Rider app 5.2 ships on day 10 and misplaces the pickup pin, so riders have to move it, wait longer and rate low. Rain causes both surge and low ratings, so surge looks guilty on a dashboard though it does nothing.
Output of 0004-trip-incident-attribution.py
SCENARIO
4,000 trips over 21 days, 15,035 events; rider app 5.2 rolls out from day 10.
584 low ratings: 194 support tickets (feedback), 390 observed; 3,416 trips settled.
Seeded cause: 5.2 misplaces the pickup pin. Distractor: surge (rain causes surge).
LIKELY CAUSE ON SUPPORT TICKETS (ranked before the agent resolves them)
T-01250 likely: pin_edit [app:rider-5.2,pin:edit] 0.099 agent code: PIN_WRONG_LOCATION
T-02814 likely: pin_edit [app:rider-5.2,pin:edit] 0.083 agent code: PIN_WRONG_LOCATION
T-01057 likely: pin_edit [app:rider-5.2,pin:edit] 0.126 agent code: DRIVER_CONDUCT
top-1 matches the agent's code on 40/88 tickets after day 14 (45%); last-event baseline 15/88 (17%)
ONE TRIP, EXPLAINED (trip-01621: rider 5.2, rain, surge shown; not learned from yet)
Top causes of 'low_rating' for trip-01621 at t=2026-09-27T23:42:11Z:
1. #15015 pin_edit@2026-09-27T23:06:26Z [app:rider-5.2,pin:edit] w=1.56 score=0.1338
lag 0.60h -> kernel 0.595 x weight 1.56 x link 0.144 [noisy-OR of app:rider-5.2=0.12 (a=120.64, b=889.36), pin:edit=0.03 (a=27.93, b=982.07)]
2. #15018 pickup@2026-09-27T23:30:56Z [pickup:long] w=1 score=0.1055
lag 0.19h -> kernel 0.913 x weight 1 x link 0.116 [noisy-OR of pickup:long=0.12 (a=90.57, b=693)]
3. #15014 request@2026-09-27T23:04:12Z [app:rider-5.2,surge:shown,tod:night,weather:rain,zone:suburbs] w=1 score=0.0863
lag 0.63h -> kernel 0.549 x weight 1 x link 0.157 [noisy-OR of app:rider-5.2=0.12 (a=120.64, b=889.36), weather:rain=0.03 (a=19.98, b=620.59), tod:night=0.01 (a=3.14, b=600.13), surge:shown=0.00 (a=3.53, b=829.17), zone:suburbs=0.00 (a=2.38, b=927.65)]
4. #15017 eta_revision@2026-09-27T23:16:59Z [eta:revised-up] w=1 score=0.0426
lag 0.42h -> kernel 0.844 x weight 1 x link 0.050 [noisy-OR of eta:revised-up=0.05 (a=25.26, b=476)]
[segments: 3 scanned / 0 pruned / 3 in window; events scored: 5]
ACROSS ALL TRIPS (link_report, global view, top 8)
Links of 'low_rating' for (global) (baseline 0.018; associations under the model, not causal effects):
feature mean 90% CI lift n alpha beta
app:rider-5.2 0.120 [0.108, 0.132] 6.57 1951.5 235.00 1726.48
pickup:long 0.116 [0.098, 0.135] 6.36 775.0 90.95 694.00
eta:revised-up 0.051 [0.036, 0.068] 2.78 491.4 25.42 476.00
route:reroute 0.036 [0.021, 0.055] 1.98 313.7 11.66 312.00
weather:rain 0.031 [0.021, 0.043] 1.71 632.8 20.02 622.75
pin:edit 0.028 [0.020, 0.036] 1.52 1161.5 32.40 1139.10
driver:hours-10plus 0.025 [0.016, 0.035] 1.36 682.4 17.15 675.29
gps:gap 0.007 [0.002, 0.015] 0.39 427.1 3.14 434.00
RELEASE VIEW (feature_risk, global links; no timing term)
app:rider-5.2 0.120 vs app:rider-5.1 0.001; a pin edit on 5.2 0.144 vs on 5.1 0.029 (x5.0)
dashboard slice after day 14: low-rating rate 5.2 30.0% vs 5.1 8.4%
confounder: low-rating rate with surge 18.6% vs without 13.4%,
yet surge:shown ranks #10 of 25 links (weather:rain #5)
CHECK
explained trip: #1 is the 5.2 pin edit: True; 5.2 risk above 2x 5.1 (feature and pin edit): True
ticket top-1 beats last-event baseline: True; pin:edit link rank #6, app:rider-5.2 #1, surge:shown #10Show the full script (261 lines)
"""Trip incident attribution: what most likely went wrong on THIS trip?
Simulates three weeks of a ride-hailing marketplace: 4,000 trips across four
zones. Each trip is one HeapDB entity, and its events are the trip's own
timeline from request to dropoff: the request (rider app version, zone, time
of day, weather, surge shown), pickup-pin edits, the dispatch (driver app
version, hours online), upward ETA revisions, the pickup (long or normal),
reroutes and GPS gaps. The effect is a low rating (1-2 stars), submitted a
few minutes after dropoff.
Hidden ground truth (the seeded cause):
* Rider app 5.2 ships on day 10 (staged rollout to 70% of riders over three
days). It misplaces the pickup pin. Riders on 5.2 edit the pin far more
often and by hundreds of meters, and each such edit causes a low rating
with probability 0.30 (0.02 for an ordinary edit on 5.1). Wrong pins also
make long pickups more likely, which cause low ratings on their own (0.12).
* Smaller true causes: rain 0.08, ETA revised upward 0.05, reroute 0.03,
driver online 10h or more 0.05. Background (no event): 3% of trips.
* Distractor and honest confounder: surge has NO effect, but rain triggers
surge, so surge is shown on many low-rated trips. Downtown has more long
pickups and more GPS gaps; GPS gaps have no effect either.
Learning, in time order, as a support team would see it:
* 35% of low ratings become a support ticket. Before the agent works it,
HeapDB ranks the trip's events ("likely cause" on the ticket). The agent's
resolution code names the real cause: feedback() confirms that event and
rejects the trip's other events.
* Low ratings without a ticket are observe()d (no label).
* Trips that end without a low rating are settle()d once their window passed.
Run:
pip install heapdb
python 0004-trip-incident-attribution.py
"""
from __future__ import annotations
import math
import random
from heapdb import HeapDB, LogNormalKernel, format_result
M, H, D = 60.0, 3600.0, 86400.0
SEED = 11
N_TRIPS = 4000
DAYS = 21
T0 = 1788739200.0 # 2026-09-07T00:00:00Z; times are seconds after T0
RELEASE_DAY = 10 # rider app 5.2 starts rolling out
EFFECT = "low_rating"
ZONES = { # zone -> (share of trips, P(long pickup), P(GPS gap))
"zone:downtown": (0.35, 0.22, 0.25),
"zone:airport": (0.15, 0.10, 0.05),
"zone:eastside": (0.25, 0.08, 0.06),
"zone:suburbs": (0.25, 0.12, 0.04),
}
TRUTH = { # (event type, feature) -> P(this event causes a low rating)
"pin_bug": 0.30, "pin_ok": 0.02, "pickup:long": 0.12, "weather:rain": 0.08,
"eta:revised-up": 0.05, "route:reroute": 0.03, "driver:hours-10plus": 0.05,
}
BACKGROUND = 0.03
CODES = {"pin_bug": "PIN_WRONG_LOCATION", "pin_ok": "PIN_WRONG_LOCATION",
"pickup:long": "LONG_PICKUP", "weather:rain": "WEATHER", "eta:revised-up": "ETA_MISSED",
"route:reroute": "ROUTE", "driver:hours-10plus": "DRIVER_CONDUCT", None: "OTHER"}
def rider_on_52(rng: random.Random, t: float) -> bool:
day = t / D
if day < RELEASE_DAY:
return False
return rng.random() < 0.70 * min(1.0, (day - RELEASE_DAY) / 3.0)
def tod_bucket(t: float) -> str:
h = (t % D) / H
return ("tod:late-night" if h < 5 else "tod:morning-peak" if h < 10 else
"tod:midday" if h < 16 else "tod:evening-peak" if h < 20 else "tod:night")
def simulate(rng: random.Random):
"""Return a list of trips: dict(id, events=[(t, type, feats, attrs, cause_key)], ...)."""
rain_days = {d for d in range(DAYS) if rng.random() < 0.3}
trips = []
for i in range(N_TRIPS):
t = rng.uniform(0, DAYS * D)
zone = rng.choices(list(ZONES), [z[0] for z in ZONES.values()])[0]
_, p_long, p_gps = ZONES[zone]
rain = int(t // D) in rain_days and rng.random() < 0.8
surge = rng.random() < (0.60 if rain else 0.15)
v52 = rider_on_52(rng, t)
app = "app:rider-5.2" if v52 else "app:rider-5.1"
tod = tod_bucket(t)
evs = []
req = {app, zone, tod, "weather:rain" if rain else "weather:dry"}
if surge:
req.add("surge:shown")
evs.append((t, "request", req, {}, "weather:rain" if rain else None))
# Pin edits: the 5.2 bug misplaces the pin, so riders drag it a long way.
n_edits = (rng.choice([1, 1, 2, 3]) if rng.random() < 0.55 else 0) if v52 else \
(1 if rng.random() < 0.12 else 0)
tp = t
for _ in range(n_edits):
tp += rng.uniform(0.3, 2.5) * M
moved = rng.uniform(150, 700) if v52 else rng.uniform(10, 160)
evs.append((tp, "pin_edit", {"pin:edit", app}, {"moved_m": round(moved)},
"pin_bug" if v52 else "pin_ok"))
td = tp + rng.uniform(0.5, 2) * M
hours = rng.choices(["driver:hours-0-4", "driver:hours-4-10", "driver:hours-10plus"],
[0.45, 0.40, 0.15 if tod != "tod:late-night" else 0.45])[0]
drv = rng.choice(["app:driver-3.8", "app:driver-3.9"])
evs.append((td, "dispatch", {drv, hours}, {}, hours if hours.endswith("10plus") else None))
if n_edits and v52:
p_long = max(p_long, 0.55)
if rain:
p_long += 0.10
long_pickup = rng.random() < p_long
wait = rng.uniform(12, 25) * M if long_pickup else rng.uniform(3, 9) * M
if long_pickup and rng.random() < 0.7:
evs.append((td + 0.4 * wait, "eta_revision", {"eta:revised-up"}, {}, "eta:revised-up"))
tpk = td + wait
evs.append((tpk, "pickup", {"pickup:long" if long_pickup else "pickup:normal"}, {},
"pickup:long" if long_pickup else None))
ride = rng.uniform(8, 30) * M
if rng.random() < 0.08:
evs.append((tpk + rng.uniform(0.2, 0.8) * ride, "reroute", {"route:reroute"}, {},
"route:reroute"))
if rng.random() < p_gps:
evs.append((tpk + rng.uniform(0.1, 0.9) * ride, "gps_gap", {"gps:gap"}, {}, None))
dropoff = tpk + ride
# Outcome: noisy-OR over the trip's events, plus background.
fired = []
for ev in evs:
key = ev[4]
if key and rng.random() < TRUTH[key]:
fired.append(ev)
low = bool(fired) or rng.random() < BACKGROUND
trips.append({"id": f"trip-{i:05d}", "events": sorted(evs, key=lambda e: e[0]),
"v52": v52, "rain": rain, "surge": surge, "end": dropoff,
"rating_at": dropoff + rng.uniform(1, 8) * M if low else None,
"cause": rng.choice(fired) if fired else None})
trips.sort(key=lambda tr: tr["end"])
return trips
def make_db() -> HeapDB:
db = HeapDB(bucket_seconds=10 * M)
# Dose of a pin edit grows with how far the pin was dragged (1.0 at 300 m).
db.register_event_type("pin_edit", weight=lambda a: min(2.0, 0.4 + a["moved_m"] / 500))
# Rating lags its cause by minutes to about an hour; a trip has no history of
# its own, so each trip borrows almost fully from the population layer.
db.register_effect(EFFECT, LogNormalKernel(median=30 * M, sigma=0.8, tail_sigmas=2.0),
default_prior=(1, 9), global_pseudocount=1000.0, leak=0.05)
db.add_rule(EFFECT, "tiny pin nudge", lambda e: e.attrs.get("moved_m", 999) < 30, 0.2,
types={"pin_edit"})
return db
def main() -> None:
rng = random.Random(SEED)
trips = simulate(rng)
db = make_db()
pick = max((tr for tr in trips if tr["rating_at"] and tr["v52"] and tr["rain"]
and tr["surge"] and tr["cause"] and tr["cause"][4] == "pin_bug"),
key=lambda tr: tr["end"])
ids = {}
n_ticket = n_obs = n_settle = 0
hits = base_hits = n_eval = 0
ticket_lines, case_text = [], None
for tr in trips:
tid = tr["id"]
ids[tid] = [db.insert(tid, T0 + t, typ, feats, attrs=attrs).id
for t, typ, feats, attrs, _ in tr["events"]]
if tr["rating_at"] is None:
db.settle(T0 + tr["end"] + 3 * H, effects=EFFECT, entity=tid)
n_settle += 1
continue
at = T0 + tr["rating_at"]
if tr is pick:
case_text = format_result(db.top_causes(tid, EFFECT, at, k=4, explain=True))
if rng.random() < 0.35 and tr is not pick: # a support ticket
n_ticket += 1
res = db.top_causes(tid, EFFECT, at, k=3) # "likely cause" on the ticket
cause = tr["cause"]
true_id = ids[tid][tr["events"].index(cause)] if cause else None
if tr["end"] > (RELEASE_DAY + 4) * D and cause is not None:
n_eval += 1
hits += res.top is not None and res.top.event.id == true_id
base_hits += ids[tid][-1] == true_id # baseline: last event before rating
if len(ticket_lines) < 3 and tr["v52"]:
top = res.top.event
ticket_lines.append(f" T-{tid[5:]} likely: {top.type} "
f"[{','.join(sorted(top.features))}] {res.top.score:.3f}"
f" agent code: {CODES[cause[4]]}")
others = [e for e in ids[tid] if e != true_id]
db.feedback(tid, EFFECT, at, confirmed_event_id=true_id, rejected_ids=others)
else:
db.observe(tid, EFFECT, at)
n_obs += 1
low = [tr for tr in trips if tr["rating_at"]]
post = [tr for tr in trips if tr["end"] > (RELEASE_DAY + 4) * D]
def rate(rows):
return sum(1 for tr in rows if tr["rating_at"]) / max(1, len(rows))
print("SCENARIO")
print(f" {len(trips):,} trips over {DAYS} days, {sum(len(tr['events']) for tr in trips):,} events; "
f"rider app 5.2 rolls out from day {RELEASE_DAY}.")
print(f" {len(low)} low ratings: {n_ticket} support tickets (feedback), {n_obs} observed; "
f"{n_settle:,} trips settled.")
print(" Seeded cause: 5.2 misplaces the pickup pin. Distractor: surge (rain causes surge).")
print()
print("LIKELY CAUSE ON SUPPORT TICKETS (ranked before the agent resolves them)")
print("\n".join(ticket_lines))
print(f" top-1 matches the agent's code on {hits}/{n_eval} tickets after day {RELEASE_DAY + 4} "
f"({hits / n_eval:.0%}); last-event baseline {base_hits}/{n_eval} ({base_hits / n_eval:.0%})")
print()
print(f"ONE TRIP, EXPLAINED ({pick['id']}: rider 5.2, rain, surge shown; not learned from yet)")
print(case_text)
print()
print("ACROSS ALL TRIPS (link_report, global view, top 8)")
rep = db.link_report(EFFECT, min_support=20)
lines = str(rep).splitlines()
print("\n".join(lines[:2 + 8]))
feats = [r.feature for r in rep]
print()
print("RELEASE VIEW (feature_risk, global links; no timing term)")
r52 = db.feature_risk(EFFECT, {"app:rider-5.2"})
r51 = db.feature_risk(EFFECT, {"app:rider-5.1"})
e52 = db.feature_risk(EFFECT, {"pin:edit", "app:rider-5.2"}, weight=1.0, type="pin_edit")
e51 = db.feature_risk(EFFECT, {"pin:edit", "app:rider-5.1"}, weight=1.0, type="pin_edit")
print(f" app:rider-5.2 {r52:.3f} vs app:rider-5.1 {r51:.3f}; "
f"a pin edit on 5.2 {e52:.3f} vs on 5.1 {e51:.3f} (x{e52 / e51:.1f})")
v52 = [tr for tr in post if tr["v52"]]
v51 = [tr for tr in post if not tr["v52"]]
print(f" dashboard slice after day {RELEASE_DAY + 4}: low-rating rate 5.2 {rate(v52):.1%} "
f"vs 5.1 {rate(v51):.1%}")
surge = [tr for tr in trips if tr["surge"]]
calm = [tr for tr in trips if not tr["surge"]]
s_rank = feats.index("surge:shown") + 1 if "surge:shown" in feats else None
print(f" confounder: low-rating rate with surge {rate(surge):.1%} vs without {rate(calm):.1%},")
print(f" yet surge:shown ranks #{s_rank} of {len(feats)} links "
f"(weather:rain #{feats.index('weather:rain') + 1})")
print()
print("CHECK")
top = case_text.splitlines()[1]
case_ok = " pin_edit@" in top
risk_ok = r52 > 2 * r51 and e52 > 2 * e51
print(f" explained trip: #1 is the 5.2 pin edit: {case_ok}; "
f"5.2 risk above 2x 5.1 (feature and pin edit): {risk_ok}")
print(f" ticket top-1 beats last-event baseline: {hits > base_hits}; "
f"pin:edit link rank #{feats.index('pin:edit') + 1}, "
f"app:rider-5.2 #{feats.index('app:rider-5.2') + 1}, surge:shown #{s_rank}")
if not (case_ok and risk_ok and hits > base_hits and s_rank > feats.index("pin:edit") + 1):
raise SystemExit(1)
if __name__ == "__main__":
main()- The ticket view ranks causes before the agent resolves the ticket. Top-1 matches the agent's code on 40/88 tickets after day 14 (45%), against 15/88 (17%) for "blame the last event". The third sample ticket is a miss: the agent coded
DRIVER_CONDUCT. - In the explained trip, the 5.2 pin edit ranks first with score 0.1338, ahead of the long pickup at 0.1055. The request event, which carries rain and surge, ranks third.
app:rider-5.2has the strongest link, with lift 6.57. The credit lands on the version, not onpin:edit: ordinary pin edits on 5.1 rarely matter, and the bug only exists on 5.2.- The release view gives a pin edit on 5.2 a risk of 0.144 against 0.029 on 5.1. The dashboard slice agrees: a low-rating rate of 30.0% on 5.2 against 8.4% on 5.1 after day 14.
- Surge is the honest confounder. The low-rating rate is 18.6% with surge against 13.4% without, yet
surge:shownranks #10 of 25 links, belowweather:rainat #5.
Run it:
pip install heapdb
python 0004-trip-incident-attribution.py # copy the script from "Show the full script" aboveEvaluation
- Metric. Top-1 and top-3 agreement of the ticket "likely cause" with the agent's resolution code, on tickets held out from feedback. For releases, the rank of the release feature in
link_reportwithin a day of rollout. - Baseline. "Blame the last event before the rating", and a per-slice rate comparison by app version, zone and weather. HeapDB must beat the last-event rule per ticket and agree with the slice view on releases.
- Ground truth. Agent resolution codes are noisy labels, so audit a sample by a second reviewer. For releases, staged rollouts and rollbacks give a natural comparison between versions on the same days.
Limits and risks
- Associations, not causes. HeapDB ranks events under its model. A ranked cause is a lead for a person, not a finding of fault.
- Never auto-penalize a driver. A driver-side feature at #1, such as hours online, must not trigger deactivation, pay changes or rating adjustments. A human reviews every action that affects a person.
- Confounders must be features. Neighborhood, time of day and weather drive both conditions and ratings. If they are missing, their credit leaks to whatever co-occurs, such as surge or a zone's long pickups.
- Telemetry gaps. GPS gaps and dropped events remove candidates. A trip with a missing pin edit cannot blame it, so gaps should be recorded as events, as the example does.
- Release credit can stick to the version. As the example shows, the version feature can absorb credit from the event that is really broken. Engineers still need the session data to find the bug.
- Location privacy. Trips encode where people live and work. Store zones and buckets, not coordinates, keep retention short, and use
forget_entity(trip, scrub_global=True)on deletion requests. - Label quality. Agents under time pressure pick default codes. Biased codes teach biased links.
Prior art
Marketplace analytics teams already run anomaly detection on cancellations, wait times and ratings, with drill-down by dimension. The StarTree ThirdEye rideshare recipe is one example: it detects anomalies in cancellations and wait time and finds the contributing dimensions.
That answers "which slice moved". HeapDB answers "what went wrong on this trip", from that trip's own timeline, and learns from ticket resolutions. The two complement each other: slice-level detection finds the incident, and per-trip attribution explains individual tickets and checks a release hypothesis. See also Code changes for release attribution on the engineering side.
Status and open questions
In design. The example on this page runs today. Your answers to these open questions shape what we build.
- Should the entity be the trip alone, or should rider and driver devices be second entities for causes that span trips, such as a broken phone GPS?
- How should resolution codes map to events when one code fits several events, such as two pin edits?
- Should
complaintandrider_cancelshare links withlow_rating, or learn separately with their own kernels? - How can release credit be pushed from the version feature to the broken interaction, for example with version-specific event features?