Skip to content

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 handEUR 2 per ticket
Cost of a misrouted ticketEUR 12 — it sits in the wrong queue, then gets re-triaged
Volume1,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 tickets1,800
overall accuracy0.8672
label noise in the data6% 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

figureTriage by hand costs EUR 2; a misrouted ticket costs EUR 12. Neither extreme is optimal.matplotlib
Left: accuracy on routed tickets against coverage, falling from 0.9623 at 14.7% coverage to 0.8672 at full coverage, with a 95% target line crossed at 73.8% coverage. Right: total cost against coverage, a U-shape with a minimum of EUR 1,664 at 73.8% coverage, against EUR 3,600 for all-human and EUR 2,868 for all-automatic.Left: accuracy on routed tickets against coverage, falling from 0.9623 at 14.7% coverage to 0.8672 at full coverage, with a 95% target line crossed at 73.8% coverage. Right: total cost against coverage, a U-shape with a minimum of EUR 1,664 at 73.8% coverage, against EUR 3,600 for all-human and EUR 2,868 for all-automatic.
Routing everything automatically costs EUR 2,868 in misroutes; routing nothing costs EUR 3,600 in human time. Routing the 73.78% of tickets the model is at least 0.60 confident about costs EUR 1,664 — a 54% saving against all-human — and the automatically routed tickets are 0.9548 accurate. Above 0.70 confidence accuracy plateaus at about 0.96, because the remaining errors are confident ones.
Confidence thresholdCoverageAccuracy on routedMisroutedTo humanTotal cost
0.00 (route everything)1.00000.86722390EUR 2,868
0.400.95610.890218979EUR 2,426
0.500.84000.939292288EUR 1,680
0.600.73780.954860472EUR 1,664
0.700.61280.961043697EUR 1,910
0.800.47170.960034951EUR 2,310
0.900.27170.9611191,311EUR 2,850
1.01 (route nothing)0.000001,800EUR 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.

sketch Where to stop trusting the model p5.js
Measured coverage and selective accuracy at eight confidence thresholds on 1,800 test tickets. Drag the threshold to move along the curve; drag the two cost sliders to re-price human triage and misroutes. The cheapest operating point moves with the costs while the model stays identical.

Two experiments worth running in it, both leaving the classifier untouched:

Costs (human / misroute)Cheapest thresholdCost thereCoverage
EUR 2 / EUR 12 (the project’s numbers)0.60EUR 1,66473.78%
EUR 2 / EUR 300.70EUR 2,68461.28%
EUR 6 / EUR 120.40EUR 2,74295.61%
EUR 2 / EUR 40.40EUR 91495.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_bill per 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_bill from 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

triage.py
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"],
    }
triage.py
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:

diagram Diagram mermaid

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

SignalAvailableWhat it catches
coverage (share auto-routed)immediatelyvocabulary drift, template changes
mean confidenceimmediatelythe same, earlier
unknown-token shareimmediatelynew products, new jargon, a new locale
ref_billref_bill frequencyimmediatelythe template migration on the watchlist
re-triage rate (a human moves the ticket)daysthe 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_bill template token has coefficient +5.459 and costs 0.9482 → 0.7927 on affected tickets when it disappears; it belongs on a watchlist.
quizCheck yourself
  1. Your triage model is 0.8672 accurate. What is the cheapest way to make the system useful?

    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.

  2. Accuracy on the auto-routed tickets stops improving above a 0.70 confidence threshold — 0.9610 at 61% coverage, 0.9611 at 27%. Why?

    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.

  3. How should a selective classifier's performance be reported?

    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.

  4. The largest coefficient in the billing class is a template token worth +5.459. What do you do before shipping?

    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.

  5. Which signal warns you earliest that a text triage model is degrading?

    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 coffee

Was this page helpful?

Let us know how we did