Which incoming support messages need a human now — judged at the moment they arrive, from the customer’s first message alone — and does replying faster actually change how a conversation ends?
Built on the Customer Support on Twitter dataset: 2,811,774 real tweets, rebuilt into 798,197 conversations, narrowed to 146,506 across AmazonHelp, Delta, TMobileHelp and Tesco.
Live dashboard → · Report · Stakeholder deck
Replying faster goes with worse outcomes, not better. Every tenfold increase in first-reply time is associated with -5.9 percentage points in the rate of conversations left unanswered. Conversations answered within 0-5m end badly 25% of the time; those answered in 1-4h, 13%.
It holds within brand (-4.8pp) and within predicted-difficulty band (-3.2pp), and survives at -2.1pp when the study’s largest judgement call is reversed. It is association, not causation — nobody randomised who got a fast reply.
The triage model reaches PR-AUC 0.361 against 0.251 for the keyword rule support tools ship with, on a 23% base rate. Fast-tracking the worst 10% of the queue — 159 conversations a day — finds 44% that go badly and catches 19% of all bad outcomes, while leaving 295 a day unprioritised.
The spec asked for one transformer variant. MiniLM sentence embeddings, in place of TF-IDF and with everything else held constant, score 0.374 PR-AUC against 0.361 — a real gain of +0.012, not noise: 95% CI [+0.005, +0.019] over 400 paired bootstrap resamples, ahead in 100% of them.
It is still not what runs, for two reasons that have nothing to do with the metric:
Shipping the weaker model here is the judgement, not an oversight. It is reversible in one line if the priorities change.
Nothing in this data says a conversation went badly. Four candidate definitions were written from the brief, then 100 conversations were read by hand, blind to which candidate had fired: 67 went badly, 15 did not, 18 could not be called.
Three things came out of that hour:
Those conversations are censored — 55,057 of them excluded from training and evaluation rather than counted as “did not go badly”, because calling them fine asserts something nobody observed.
| stage | what it does |
|---|---|
src/threads.py |
reference implementation of conversation reconstruction, one dict per tweet |
src/reconstruct.py |
the same rules over flat arrays, which is what the real file runs through |
src/subset.py |
cuts the chosen brands out, with text |
src/outcomes.py |
the candidate labels, the chosen one, and the censoring rule |
src/features.py |
arrival-time features — first inbound message only |
src/audit.py · src/build_label_tool.py · src/score_audit.py |
the blind hand audit and its scoring |
src/dataset.py · src/model.py |
modelling table, baseline, TF-IDF model, calibration |
src/threshold.py |
the operating point, framed as staffing |
src/reply_speed.py · src/sensitivity.py |
the reply-speed analysis and its robustness check |
src/dashboard.py · src/deliverables.py · src/case_study.py |
the four deliverables |
Two implementations of the reconstruction exist because the readable one cannot run the real file — 2.8M tweets as Python objects is several GB, and the machine had 0.6 GB free. The array version indexes by tweet id directly (ids run 1 to 2,987,950 across 2,811,774 rows). Both are pinned to the same hand-built 15-thread fixture, so the tested code is the code that touches the data.
Triage happens before anyone replies, so features may read only the first inbound message and labels only what comes after it. This is tested by perturbation, not inspection: rewrite every tweet after the opener and the features must not move; rewrite the opener and the labels must not move. Reading code and agreeing it looks clean is how leaks survive.
pip install -r requirements.txt
python -m src.reconstruct # rebuild conversations from the reply graph
python -m src.brand_report # the evidence for choosing brands
python -m src.subset # cut the chosen brands out, with text
python -m src.audit # label the subset, draw the blind audit sample
python -m src.build_label_tool # writes label.html for the hand audit
python -m src.score_audit # score the candidates against the verdicts
python -m src.dataset # modelling table
python -m src.model # baseline, model, calibration
python -m src.threshold # the operating-point table
python -m src.reply_speed # the reply-speed analysis
python -m src.sensitivity # does the censoring decision make the result?
python -m src.dashboard && python -m src.deliverables
pytest -q # support-triage: 102 tests
The raw dataset and everything derived from it are gitignored; python -m src.reconstruct
rebuilds them from the Kaggle download.
Limitations, “What didn’t work” and the Recommendation in the report are reserved for the
author and are currently marked [DRAFT — EILEEN TO REPLACE]. Everything else is
generated from the pipeline’s own artifacts, so no figure in any document is typed by hand.