Capstone 4 - Ticket Triage with Text
The decision
Support tickets arrive as free text and have to reach one of three queues: billing, shipping or technical. Today a person reads each one.
| Cost of triaging by hand | EUR 2 per ticket |
| Cost of a misrouted ticket | EUR 12 — it sits in the wrong queue, then gets re-triaged |
| Volume | 1,800 tickets in the test window |
The model is allowed a third option: abstain. Route the ticket automatically when confident, hand it to a person otherwise. So the deliverable is not an accuracy — it is a confidence threshold, a coverage figure, and the accuracy on the covered portion.
This capstone composes the text pipeline, the coefficient audit and the cost reasoning from Capstone 1.
Step 1 — the classifier, and its ceiling
TfidfVectorizer(min_df=2)TfidfVectorizer(min_df=2) plus multinomial logistic regression on 4,200 training tickets:
| Value | |
|---|---|
| test tickets | 1,800 |
| overall accuracy | 0.8672 |
| label noise in the data | 6% of tickets are filed in the wrong queue |
| confidence (max predicted probability) | min 0.3386, median 0.7816, max 0.9994 |
The 6% label noise matters more than any modelling choice here: a ticket whose recorded queue is wrong cannot be predicted correctly, so no threshold and no model can reach 1.0. Knowing that number is what makes the next step honest.
Step 2 — let the model abstain
| Confidence threshold | Coverage | Accuracy on routed | Misrouted | To human | Total cost |
|---|---|---|---|---|---|
| 0.00 (route everything) | 1.0000 | 0.8672 | 239 | 0 | EUR 2,868 |
| 0.40 | 0.9561 | 0.8902 | 189 | 79 | EUR 2,426 |
| 0.50 | 0.8400 | 0.9392 | 92 | 288 | EUR 1,680 |
| 0.60 | 0.7378 | 0.9548 | 60 | 472 | EUR 1,664 |
| 0.70 | 0.6128 | 0.9610 | 43 | 697 | EUR 1,910 |
| 0.80 | 0.4717 | 0.9600 | 34 | 951 | EUR 2,310 |
| 0.90 | 0.2717 | 0.9611 | 19 | 1,311 | EUR 2,850 |
| 1.01 (route nothing) | 0.0000 | — | 0 | 1,800 | EUR 3,600 |
Four things to take from this table.
Abstention is worth more than a better model. The classifier’s accuracy is 0.8672 and nothing about it changed. Allowing it to decline the hard 26% took cost from EUR 2,868 to EUR 1,664 — a saving larger than any plausible modelling improvement on this corpus.
The operating point is a cost question, not an accuracy question. At 0.50 the model covers 84.00% at 0.9392; at 0.60 it covers 73.78% at 0.9548. Their costs are EUR 1,680 and EUR 1,664 — a tie. Choose between them on operational grounds: how many tickets your team can absorb, and whether a misroute has a reputational cost the EUR 12 does not capture.
Accuracy on the routed portion plateaus at about 0.96. Beyond a 0.70 threshold, raising the bar buys almost nothing: 0.9610 at 61% coverage, 0.9611 at 27% coverage. The remaining errors are confident errors, which is what 6% label noise produces — the model is confidently right and the recorded label is wrong. No amount of abstention fixes a mislabelled ticket.
The cost curve is flat between 50% and 84% coverage. Costs are EUR 1,664–1,910 across that whole band, which is good news operationally: you can set the threshold from staffing capacity and lose very little.
See it move
The table has eight rows; the decision has two independent inputs. The sketch lets you move the confidence threshold along the measured curve and re-price the two costs, so you can see the operating point move for reasons that have nothing to do with the model.
Two experiments worth running in it, both leaving the classifier untouched:
| Costs (human / misroute) | Cheapest threshold | Cost there | Coverage |
|---|---|---|---|
| EUR 2 / EUR 12 (the project’s numbers) | 0.60 | EUR 1,664 | 73.78% |
| EUR 2 / EUR 30 | 0.70 | EUR 2,684 | 61.28% |
| EUR 6 / EUR 12 | 0.40 | EUR 2,742 | 95.61% |
| EUR 2 / EUR 4 | 0.40 | EUR 914 | 95.61% |
Make misroutes more expensive and the model is trusted with less; make human triage more expensive and
it is trusted with more — coverage swings from 61% to 96% across those rows. Same accuracy, same
confidence scores, four different systems. The operating point is an economic quantity that happens to
be implemented as a number compared against predict_probapredict_proba.
Step 3 — audit the tokens before trusting the routing
The text page found
that this corpus contains ref_billref_bill, a template artefact carried by 30% of billing tickets. Its
coefficient is +5.459 — larger than any real billing word — and the model’s accuracy on those tickets
drops from 0.9482 to 0.7927 if the ticketing system stops emitting it.
For a triage system that is a specific, dated risk: the next migration of the ticket template silently degrades one queue’s routing. Two mitigations, both cheap:
- Put the artefact on a watchlist. Log the share of tickets containing
ref_billref_billper day. When it changes, the routing accuracy for billing is about to change too. - Train a variant without it and keep the measurement. Removing
ref_billref_billfrom the vocabulary and refitting scores 0.8528 against 0.8672 — a cost of 1.4 points of accuracy. That prices the dependency exactly: the artefact buys 1.4 points today and owes you one migration-shaped outage.
Step 4 — the routing service
CONFIG = {"min_confidence": 0.60, "human_cost": 2.0, "misroute_cost": 12.0,
"model_version": "triage-2.1.0"}
def triage(ticket_text, artefact, config):
"""Route automatically when confident, otherwise send to a human."""
features = artefact["vectorizer"].transform([ticket_text])
proba = artefact["model"].predict_proba(features)[0]
queue = artefact["classes"][int(proba.argmax())]
confidence = float(proba.max())
decision = "auto" if confidence >= config["min_confidence"] else "human"
return {
"queue": queue if decision == "auto" else None,
"decision": decision,
"confidence": round(confidence, 4),
"runner_up": artefact["classes"][int(proba.argsort()[-2])],
"margin": round(float(np.diff(np.sort(proba)[-2:])[0]), 4),
"unknown_token_share": artefact["unknown_share"](ticket_text),
"model_version": config["model_version"],
}CONFIG = {"min_confidence": 0.60, "human_cost": 2.0, "misroute_cost": 12.0,
"model_version": "triage-2.1.0"}
def triage(ticket_text, artefact, config):
"""Route automatically when confident, otherwise send to a human."""
features = artefact["vectorizer"].transform([ticket_text])
proba = artefact["model"].predict_proba(features)[0]
queue = artefact["classes"][int(proba.argmax())]
confidence = float(proba.max())
decision = "auto" if confidence >= config["min_confidence"] else "human"
return {
"queue": queue if decision == "auto" else None,
"decision": decision,
"confidence": round(confidence, 4),
"runner_up": artefact["classes"][int(proba.argsort()[-2])],
"margin": round(float(np.diff(np.sort(proba)[-2:])[0]), 4),
"unknown_token_share": artefact["unknown_share"](ticket_text),
"model_version": config["model_version"],
}Two fields earn their place beyond the obvious ones. marginmargin — the gap between the top two
probabilities — is a better abstention signal than the maximum alone when the classes are unbalanced, and
it is what a reviewer wants to see next to a borderline decision. unknown_token_shareunknown_token_share is the
fraction of the ticket’s words that were not in the training vocabulary: it rises when the product
launches a new feature, and it rises before accuracy falls, which makes it the earliest available
warning on a text model.
One ticket’s path through the service, with the three signals that get logged whether or not a human ever sees it:
flowchart TD T["a ticket arrives"] --> VEC["TfidfVectorizer.transform
fitted vocabulary, min_df=2"] VEC --> UNK["unknown_token_share:
words outside the vocabulary"] VEC --> P["predict_proba over the queues"] P --> C{"max probability >= 0.60?"} C -->|"yes -- 73.78% of tickets"| AUTO["route automatically.
0.9548 accurate here."] C -->|"no -- 26.22%"| HUM["send to a human,
with the top two queues
and the margin attached"] P --> MARG["margin: gap between
the top two probabilities"] AUTO --> LOG["log queue, confidence, margin,
unknown_token_share, model_version"] HUM --> LOG LOG --> W1{{"ref_bill share per day"}} LOG --> W2{{"unknown_token_share
trending up"}} LOG --> W3{{"coverage drifting from 73.78%"}} W1 -->|"template migration"| ACT["billing queue accuracy is
about to drop 0.9482 -> 0.7927.
Switch to the variant that
costs 1.4 points and does
not depend on it."] W2 -->|"new product vocabulary"| RETRAIN["refit the vectorizer.
This rises BEFORE accuracy falls."] W3 -->|"more abstentions than staffed for"| OPS["re-derive the threshold from
current costs and capacity"] HUM --> CORR["the human's chosen queue
is a fresh, correct label"] CORR --> RETRAIN
The bottom edge is the part that compounds. Every abstention produces a human decision on exactly the tickets the model found hardest, which is the highest-value training data available — so a selective classifier is also a labelling pipeline, provided somebody wires the corrections back.
Step 5 — what to monitor
| Signal | Available | What it catches |
|---|---|---|
| coverage (share auto-routed) | immediately | vocabulary drift, template changes |
| mean confidence | immediately | the same, earlier |
| unknown-token share | immediately | new products, new jargon, a new locale |
ref_billref_bill frequency | immediately | the template migration on the watchlist |
| re-triage rate (a human moves the ticket) | days | the real misroute rate |
The re-triage rate is the only ground truth, and it arrives late — so the first four are what you page on. A drop in coverage from 73.78% to 55% means the model is seeing tickets it does not recognise, and it means it today, not after the labels arrive.
What this project does not solve
- The 6% label noise is the ceiling and nobody has measured it in production. Estimating it — by double-labelling a sample of tickets — would tell you whether 0.96 is the ceiling or 0.99 is.
- Word order carries no information in this corpus by construction, so the finding that bigrams add nothing does not transfer to real tickets.
- Three queues, single-label. Real triage is hierarchical (queue → team → skill) and often multi-label, and abstention interacts with that in ways not modelled here.
- The EUR 12 is an average. A misrouted billing dispute and a misrouted password reset do not cost the same, and per-class costs would give per-class thresholds — exactly like the per-row thresholds in Phase 10.
Recap
- 1,800 test tickets, three queues, overall accuracy 0.8672, with 6% of labels wrong by construction.
- Routing everything costs EUR 2,868; routing nothing costs EUR 3,600.
- Routing the 73.78% above 0.60 confidence costs EUR 1,664 at 0.9548 accuracy — a 54% saving against all-human.
- Selective accuracy plateaus near 0.96 above a 0.70 threshold: the remaining errors are confident ones caused by label noise.
- The cost curve is flat between 50% and 84% coverage, so the threshold can follow staffing capacity.
- The
ref_billref_billtemplate token has coefficient +5.459 and costs 0.9482 → 0.7927 on affected tickets when it disappears; it belongs on a watchlist.
Your triage model is 0.8672 accurate. What is the cheapest way to make the system useful?
Nothing about the classifier changed. Selective prediction converts a mediocre model into a useful one by matching the decision to the confidence, and the saving is larger than any plausible modelling gain on this corpus.
Show answer
B — Let it abstain: routing the 73.78% of tickets above 0.60 confidence at 0.9548 accuracy costs EUR 1,664 against EUR 2,868 for routing everything — Nothing about the classifier changed. Selective prediction converts a mediocre model into a useful one by matching the decision to the confidence, and the saving is larger than any plausible modelling gain on this corpus.
Accuracy on the auto-routed tickets stops improving above a 0.70 confidence threshold — 0.9610 at 61% coverage, 0.9611 at 27%. Why?
Label noise sets a ceiling that abstention cannot raise. Knowing the noise rate tells you whether 0.96 is the ceiling or whether there is room, which is why measuring it — by double-labelling a sample — is worth more than more modelling.
Show answer
B — The remaining errors are confident errors: with 6% of labels recorded wrong, the model is sometimes confidently right about a ticket whose recorded queue is wrong — Label noise sets a ceiling that abstention cannot raise. Knowing the noise rate tells you whether 0.96 is the ceiling or whether there is room, which is why measuring it — by double-labelling a sample — is worth more than more modelling.
How should a selective classifier's performance be reported?
'95% accurate' and '73.78% coverage at 95% accuracy' describe very different systems. The curve also shows the reader that the cost is flat between 50% and 84% coverage, which is the fact that lets operations pick the threshold.
Show answer
B — As a coverage and accuracy pair, ideally the whole risk-coverage curve, plus the chosen operating point and why — '95% accurate' and '73.78% coverage at 95% accuracy' describe very different systems. The curve also shows the reader that the cost is flat between 50% and 84% coverage, which is the fact that lets operations pick the threshold.
The largest coefficient in the billing class is a template token worth +5.459. What do you do before shipping?
Deleting it costs about 1.7 points of overall accuracy; keeping it costs a migration-shaped outage. Pricing both and watching the token's frequency is the response that survives either decision.
Show answer
B — Both measure the dependency (0.9482 to 0.7927 on affected tickets when it disappears) and put the token's daily frequency on a monitoring watchlist — Deleting it costs about 1.7 points of overall accuracy; keeping it costs a migration-shaped outage. Pricing both and watching the token's frequency is the response that survives either decision.
Which signal warns you earliest that a text triage model is degrading?
The re-triage rate is ground truth and arrives days late. Unknown-token share, mean confidence and coverage are all available on the first ticket of a new vocabulary, which is when you want to know.
Show answer
B — The share of tokens in each ticket that were absent from the training vocabulary — it rises when new jargon appears, before accuracy moves — The re-triage rate is ground truth and arrives days late. Unknown-token share, mean confidence and coverage are all available on the first ticket of a new vocabulary, which is when you want to know.
🧪 Try It Yourself
Exercise 1 – Fit the triage classifier
Exercise 2 – Build the risk–coverage curve
Exercise 3 – Price each operating point
Exercise 4 – Find the coverage at a required accuracy
Exercise 5 – Add the abstention signals a reviewer needs
Next
Capstone 5 - A Recommender with an Honest Evaluation — the last project, where the hardest engineering is not the model but the measurement.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
