Support Ticket Triage

The model is the easy half. The half that pays is knowing which tickets cannot be routed from their words at all, and handing exactly those to a person.

In the Real World · Brief · Deep learning · Core · 2 to 3 days

9,000 support tickets, eight queues, and an answer key that lets you score yourself against the best result anyone could get. Build the router before you read the walkthrough.

The situation

Kesterly Software runs a support desk. Every ticket is read by a person and sent to one of 8 queues before anyone can answer it. You have 9,000 tickets from 2025-08-01 to 2026-06-26, already routed, and the desk wants to know how much of that job can be handed to a model.

The data

FileWhat it holds
kesterly-tickets.csvticket_id, created_at, channel, subject, body, queue
kesterly-answer-key.csvThe intent behind each ticket, whether that intent belongs to one queue or two, whether the deciding detail is present, any second issue, and the best possible answer. Do not open it until you have a model

What the answer key is for

Build from the subject and body alone. The key is not a feature, it is the marking scheme. Used afterwards it will tell you something your own inbox never will: how much of your remaining error is reachable and how much is information that is simply not in the ticket.

What the room believes

  1. Ticket classification is a solved problem and any reasonable model will do.
  2. Accuracy on a random split tells you how it will behave next month.
  3. Pretrained embeddings are the cheap way to buy language understanding.
  4. Whatever error is left after that is a modelling problem.

Definition of done

  1. A verdict on each of the four beliefs, with the evidence.
  2. At least three models compared, including a baseline that is not a model at all.
  3. An evaluation that says something about next month’s tickets, not just this month’s, and a defence of how you split the data to get it.
  4. A statement of the best score achievable on this data, and how you worked it out.
  5. A rule for which tickets get routed automatically and which go to a person, with the cost of being wrong priced in.
  6. A recommendation the support manager could act on, including what you would not automate.

Five questions worth sitting with before you build anything

How would you know whether a ticket can be routed from its text at all? What would your model do with a phrasing nobody has used before? If the model is right eighty per cent of the time, is that good? What does it cost when a ticket lands in the wrong queue, and how does that change what you should automate? And which mistakes would you rather make?

If you want to go further

  • Split the data by phrasing rather than at random and compare the two answers.
  • Check whether your model’s confidence means anything, by bucketing predictions and measuring accuracy in each bucket.
  • Work out the highest accuracy anyone could achieve on this file, and compare your model to that rather than to 100 per cent.
  • Price a misroute, then find the confidence threshold that minimises total cost.
  • Look at the tickets your model gets wrong at high confidence. They are a different problem from the ones it gets wrong at low confidence.

When you are done, read the walkthrough. It compares a word counter, frozen sentence embeddings and a fine tuned transformer across both kinds of split, works out the best score the data allows, and prices three different answers to how much of the routing to hand over. Compare its threshold to yours, and its reasoning to yours.

In the Real World · Deep learning · Core · 2 to 3 days

Routing support tickets is the classification problem everyone thinks is solved. A word counter gets 89.69 per cent on a random split and looks finished. On next month’s wording it gets 74.62. And the best possible score on this data is 93.05, not 100, which turns out to be the useful number.

The situation. Kesterly Software runs a support desk. 9,000 tickets over 329 days across 8 queues, roughly 40 a working day. Someone reads each one and sends it to a queue before anybody can answer it.

What the business is left with. A routing model, a confidence threshold with a human fallback behind it, and a costed answer to how much of the job should be automated. Which is not all of it.

Attempt it first. The brief has the same inbox and an answer key that lets you score yourself against the best possible result.

Section 1Problem Definition

No code yet. Ticket routing looks like a benchmark task and behaves like an operations problem, and the gap between those two framings is where the value is.

The problem in one sentence

Some tickets say which queue they belong to and some genuinely do not, so the useful question is not how often the model is right but whether it knows which kind of ticket it is looking at.

What arrivesShare of the inboxWhat a classifier should do
A ticket with one clear problem71.7% of ticketsRoute it. This is the easy part
A ticket whose intent spans two queues, with the detail that settles it12.7% of ticketsRead the detail and route it. Harder, and still decidable
The same intent without that detail15.6% of ticketsGuess, and know that it is guessing. No model can do better
Two unrelated problems in one message27.8% of ticketsRoute on the first one, which needs word order rather than word counts

Business objective

Route automatically where it is safe to, hand the rest to a person, and put a number on where that line should sit.

Hypotheses

  1. H1. Ticket classification is a solved problem and any reasonable model will do.
  2. H2. Accuracy on a random split tells you how it will behave next month.
  3. H3. Pretrained embeddings are the cheap way to buy language understanding.
  4. H4. Whatever error is left after that is a modelling problem.

This inbox knows its own answer

The tickets are simulated, and every one records the intent behind it, whether that intent belongs to one queue or two, and whether the message contains the detail that settles it. From those three facts the best score any model could possibly achieve is arithmetic rather than opinion. It is 93.05 per cent on the hard split. On a real inbox that number does not exist, which is why teams keep tuning models that are already finished.

Section 2Data Collection

import numpy as np
import pandas as pd

t = pd.read_csv('data/kesterly-tickets.csv')
key = pd.read_csv('data/kesterly-answer-key.csv')
d = t.merge(key, on='ticket_id')
d['text'] = d['subject'].fillna('') + '. ' + d['body']

print('tickets  :', len(d))
print('queues   :', d['queue'].nunique())
print('intents  :', d['intent'].nunique())
print('window   :', d['created_at'].min()[:10], 'to', d['created_at'].max()[:10])
print('body words: mean %.1f  median %d  p95 %d'
      % (d['body'].str.split().str.len().mean(),
         d['body'].str.split().str.len().median(),
         d['body'].str.split().str.len().quantile(0.95)))
tickets  : 9000
queues   : 8
intents  : 31
window   : 2025-08-01 to 2026-06-26
body words: mean 28.1  median 27  p95 47
FileWhat it is
kesterly-tickets.csvThe inbox export. ticket_id, created_at, channel, subject, body, queue. This is all you would have in real life
kesterly-answer-key.csvWhy each ticket is what it is: the intent, whether that intent is ambiguous, whether the deciding detail is present, the second issue if there is one, and the best possible answer

Short messages, a median of 27 words. That matters for what follows: there is not much text per ticket, so a model that needs a lot of context to be useful will not get it here.

Section 3Data Preprocessing

3aDuplicates and schema checks

print('duplicate ticket ids :', int(d['ticket_id'].duplicated().sum()))
print('missing bodies       :', int(d['body'].isna().sum()))
print('queues outside the taxonomy :',
      int((~d['queue'].isin(sorted(d['queue'].unique()))).sum()))
print('answer key rows match tickets :', len(d) == len(t))
duplicate ticket ids : 0
missing bodies       : 0
queues outside the taxonomy : 0
answer key rows match tickets : True

3bHandling categorical mess

Three categorical columns. The queue is the target. The channel is nearly useless and is left out on purpose, because a model that learns to route web form tickets differently from email has learned about the form and not about the problem. The subject is the interesting one, and section 5a explains why.

3cDealing with outliers

Nothing to trim. The longest tickets are the ones raising two issues, and those are not outliers, they are 27.8% of the inbox and one of the things the model has to handle. Truncating at 96 tokens covers well past the 95th percentile of 47 words.

3dHandling missing values

Nothing is null, but 40.2% of subject lines say nothing at all: Help, Urgent, Question, no subject. That is not missing data to be imputed, it is a realistic feature of every support inbox, and it is the reason the body has to carry the decision.

3eHandling skewed data

print(d['queue'].value_counts(normalize=True).mul(100).round(1))
queue
Login and access                 23.9
Billing and invoices             17.5
Data import                      13.0
Performance and outages          11.7
Product feedback                 11.2
Account admin and permissions     9.0
Integrations and API              7.0
Reporting and exports             6.7
Name: proportion, dtype: float64
Share of the inbox by queue05.6411.2816.9222.5628.223.9%Login17.5%Billing13.0%Data11.7%Performance11.2%Product9.0%Account7.0%Integrations6.7%Reportingimbalanced, but not so imbalanced that accuracy is meaningless

The largest queue takes 23.9% of tickets, so always guessing it scores 23.87 per cent. That is the floor every number below has to clear, and it clears it easily, which is exactly why accuracy alone is not going to settle anything.

3fData types and normalisation

Subject and body are joined into one string. No stemming, no stopword removal and no lowercasing, because two of the three models are pretrained and expect text the way people wrote it. Stripping it back is a habit from the word counting era that actively costs accuracy with a transformer.

Section 4Exploratory Data Analysis

4aTarget variable analysis

Eight queues, no hierarchy, one label per ticket. The interesting structure is not in the target column, it is in how much of the target the text actually determines.

4bNumerical variables

There is one numeric column worth anything and it is derived: message length. Long tickets are more likely to raise two issues, which makes them harder rather than more informative.

4cCategorical variables

print(d['channel'].value_counts())
print()
print('distinct subject lines :', d['subject'].nunique())
print('tickets whose subject is generic :',
      round(100 * d['subject'].isin(['Help', 'Question', 'Urgent', 'Issue', 'Problem',
                                     'Support needed', 'Hello', 'Query', 'Please help',
                                     'Not working', 'Assistance', 'Quick question',
                                     'Issue with the platform', '(no subject)']).mean(), 1))
Email          4926
Web form       2709
In app chat    1365

distinct subject lines : 107
tickets whose subject is generic : 40.2

4dRelationships between variables

This is the section that decides the project. The answer key says, for every ticket, whether the text contains enough to identify the queue.

und = (d['is_ambiguous'] == 1) & (d['deciding_detail_present'] == 0)
print('tickets with a single queue intent      :',
      round(100 * (d['is_ambiguous'] == 0).mean(), 1))
print('two queue intent, deciding detail given :',
      round(100 * ((d['is_ambiguous'] == 1) &
                   (d['deciding_detail_present'] == 1)).mean(), 1))
print('two queue intent, detail absent         :', round(100 * und.mean(), 1))
print()
print('best possible accuracy on the whole file:',
      round(100 * d['posterior_max'].mean(), 2))
print('best possible on the undecidable slice  :',
      round(100 * d.loc[und, 'posterior_max'].mean(), 2))
tickets with a single queue intent      : 71.7
two queue intent, deciding detail given : 12.7
two queue intent, detail absent         : 15.6

best possible accuracy on the whole file: 93.14
best possible on the undecidable slice  : 56.0
How much of the queue the text actually determines016.9233.8450.7667.6884.6171.7%single queue intent12.7%two queues, detail given15.6%two queues, no detailthe last bar is where every model’s remaining error lives

15.6% of tickets carry an intent that belongs to two queues without the sentence that separates them. On those, the best any model can do is pick the more common of the two, which is right 56.0 per cent of the time. That single fact caps the whole problem at 93.05 per cent.

The ceiling is the most useful number in this project

Every accuracy below is reported next to it. A model at 92.89 on the random split is not 7.11 points from perfect, it is 0.52 points from finished. Those are completely different situations and only one of them justifies another sprint.

4eTesting our hypotheses

HypothesisVerdictEvidence
H1. Ticket classification is solvedOn a random split, nearlyWord counts reach 89.69, which is 96.0% of the reachable headroom
H2. A random split tells you what happens next monthWrongThe same model drops to 74.62 on wording it has not seen, a fall of 15.07 points
H3. Frozen embeddings are the cheap way inNoThey score 88.62 on the random split, behind word counts, because one vector per ticket throws away the order that multi issue tickets turn on
H4. The remaining error is a modelling problemMostly notAt the chosen model, 11.19 points of the remaining 18.14 are reachable. The rest is the ceiling

4fSubgroups

Every ticket is written in one of two vocabularies that mean the same thing and share almost no words. 34.8% of the inbox uses the second one. Training on the first and testing on the second is the honest version of the question every classifier is really being asked, which is how it will do on the phrasings that have not arrived yet.

Section 5Feature Engineering

5aThe leakage trap

This project leaked, in the first build, and the ceiling is what caught it.

A word counter scored above the theoretical maximum

The first version of this inbox drew each ticket’s subject line from a list attached to its queue. Perfectly innocent looking, and it put the answer in the text. Word counts plus logistic regression scored 99.87 per cent against a ceiling of 92.69, which is impossible, and impossible is the only reason anybody looks.

On the undecidable slice it scored 98.1 per cent against a best possible 56.1. The subject was keyed to the queue rather than to the intent, so a model could read off the answer without reading the ticket. Subjects are now keyed to the intent, and 40.2% of them say nothing at all.

Without a ceiling this ships. A 99.87 per cent classifier gets congratulated.

The leakHow it looksHow it is caught
A field keyed to the labelA subject line, a folder, a tag applied after triage. All of them written down after the decision was madeA score that beats the best possible score
Vocabulary that only appears in one classAccuracy far higher on a random split than on held out phrasingsSplit by wording rather than at random
Using the answer key as a featurePerfect results, no modelThe key scores the exercise, it is not an input

5bNew features

RepresentationWhat it keepsWhat it discards
Word and bigram countsWhich words appeared, and how unusual they areOrder, and any word not seen in training
One frozen sentence embeddingMeaning, including words never seen in trainingOrder within the ticket, because the whole message collapses to one vector
Fine tuned transformer tokensMeaning and order, adapted to these eight queuesNothing that matters here, at the cost of twenty minutes of training

5cEncoding

Bigrams for the word counter, so that at least some short phrases survive. A 384 dimension sentence vector for the frozen embedding. Wordpiece tokens truncated at 96 for the transformer, which covers the 95th percentile ticket with room to spare.

5dFeature selection

None. With eight classes and short text there is nothing to prune, and the interesting choice is between representations rather than within one.

Section 6Model Selection

ModelWhy it is hereCost
Most common queueThe floor. Any model that cannot beat it is not a modelNothing
Word counts plus logistic regressionWhat a competent analyst builds in an afternoon, and what deep learning has to beatSeconds
Frozen sentence embeddings plus logistic regressionThe popular middle road: pretrained understanding without training a networkSeconds, after a one off encode
DistilBERT, fine tunedActual deep learning, adapted to this taxonomyAbout twenty minutes on a laptop CPU

Why the middle option is in the comparison

Frozen embeddings plus a linear head is the recommendation in most practical guides, because it is cheap and it sounds like it should capture most of the benefit. It is in this bake-off to be tested rather than assumed, and section 7b is why that was worth doing.

Section 7Model Training

7aBaselines

Two splits, run side by side throughout. A random quarter held out, and a split by vocabulary where the model trains on one way of phrasing things and is tested on the other.

random split      train 6,750  test 2,250  ceiling 93.41
unseen wording    train 5,868  test 3,132  ceiling 93.05

7bComparing candidates

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, f1_score
from sklearn.model_selection import train_test_split
from sentence_transformers import SentenceTransformer

QS = sorted(d['queue'].unique())
y = d['queue'].map({q: i for i, q in enumerate(QS)}).values
idx = np.arange(len(d))

# Two splits. The second trains on one vocabulary and tests on the other, which
# is the closest this file gets to asking about next month's tickets.
tr_r, te_r = train_test_split(idx, test_size=0.25, stratify=d['queue'], random_state=1)
tr_u = idx[d['phrasing_set'].values == 'A']
te_u = idx[d['phrasing_set'].values == 'B']
SPLITS = {'random': (tr_r, te_r), 'unseen wording': (tr_u, te_u)}

E = SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2', device='cpu').encode(
    d['text'].tolist(), batch_size=128, normalize_embeddings=True)
import torch
from transformers import AutoTokenizer, AutoModelForSequenceClassification

def finetune(tr, te, epochs=3, seed=0):
    torch.set_num_threads(8); torch.manual_seed(seed); np.random.seed(seed)
    tok = AutoTokenizer.from_pretrained('distilbert-base-uncased')
    enc = tok(d['text'].tolist(), truncation=True, max_length=96,
              padding='max_length', return_tensors='pt')
    m = AutoModelForSequenceClassification.from_pretrained(
        'distilbert-base-uncased', num_labels=len(QS))
    opt = torch.optim.AdamW(m.parameters(), lr=3e-5)
    ids, att, yt = enc['input_ids'], enc['attention_mask'], torch.tensor(y)
    m.train()
    for _ in range(epochs):
        for i in range(0, len(tr), 32):
            b = torch.tensor(np.random.permutation(tr)[i:i + 32])
            opt.zero_grad()
            m(input_ids=ids[b], attention_mask=att[b], labels=yt[b]).loss.backward()
            opt.step()
    m.eval(); out = []
    with torch.no_grad():
        for i in range(0, len(te), 64):
            b = te[i:i + 64]
            out.append(m(input_ids=ids[b], attention_mask=att[b]).logits.softmax(-1))
    return torch.cat(out).numpy()

PROB = {}
for split, (tr, te) in SPLITS.items():
    ceiling = d['posterior_max'].iloc[te].mean()
    v = TfidfVectorizer(ngram_range=(1, 2), min_df=2, sublinear_tf=True)
    A = v.fit_transform(d['text'].iloc[tr])
    PROB[(split, 'counts')] = LogisticRegression(max_iter=3000, C=4.0).fit(
        A, y[tr]).predict_proba(v.transform(d['text'].iloc[te]))
    PROB[(split, 'embeddings')] = LogisticRegression(max_iter=3000, C=8.0).fit(
        E[tr], y[tr]).predict_proba(E[te])
    PROB[(split, 'distilbert')] = finetune(tr, te)
    for name in ('counts', 'embeddings', 'distilbert'):
        acc = accuracy_score(y[te], PROB[(split, name)].argmax(1))
        print('%-16s %-12s acc %6.2f  ceiling %6.2f  headroom %5.1f'
              % (split, name, 100 * acc, 100 * ceiling, 100 * acc / ceiling))
random           Most common queue                acc  23.87  ceiling  93.41  headroom  25.6
random           Word counts plus logistic        acc  89.69  ceiling  93.41  headroom  96.0
random           Frozen embeddings plus logistic  acc  88.62  ceiling  93.41  headroom  94.9
random           DistilBERT, fine tuned           acc  92.89  ceiling  93.41  headroom  99.4
unseen wording   Most common queue                acc  23.18  ceiling  93.05  headroom  24.9
unseen wording   Word counts plus logistic        acc  74.62  ceiling  93.05  headroom  80.2
unseen wording   Frozen embeddings plus logistic  acc  76.02  ceiling  93.05  headroom  81.7
unseen wording   DistilBERT, fine tuned           acc  81.86  ceiling  93.05  headroom  88.0
Accuracy on a random split and on wording never seen in training-13.934+10.218+34.369+58.521+82.672+106.823most commonword countsembeddingsDistilBERTrandom splitunseen wordingthe gap between the pairs is the part nobody reports

On a random split the word counter is within 3.2 points of the transformer and looks like the sensible choice. Change the split so the test tickets are phrased in words the model has never seen, and it falls 15.07 points while the transformer falls 11.03.

ModelRandom splitUnseen wordingFall
Word counts plus logistic89.6974.6215.07
Frozen embeddings plus logistic88.6276.0212.6
DistilBERT, fine tuned92.8981.8611.03

The frozen embedding is the interesting failure. It is worse than word counting on the random split, at 88.62 against 89.69, because collapsing a ticket into a single vector loses the ordering that 27.8% of tickets depend on. It generalises slightly better to new wording and still finishes behind the fine tuned model on both. Pretrained is not the same as adapted.

7cHyperparameter tuning

There is one parameter worth tuning and it is not in the model. It is the confidence above which a ticket gets routed without a person looking at it. Before that can be set, the confidence has to mean something.

te = SPLITS['unseen wording'][1]
Pb = PROB[('unseen wording', 'distilbert')]
sub = d.iloc[te].copy()
sub['conf'] = Pb.max(1)
sub['ok'] = Pb.argmax(1) == y[te]

und = (sub['is_ambiguous'] == 1) & (sub['deciding_detail_present'] == 0)
print('mean confidence, decidable tickets   :', round(sub.loc[~und, 'conf'].mean(), 3))
print('mean confidence, undecidable tickets :', round(sub.loc[und, 'conf'].mean(), 3))
print('accuracy, decidable                  :', round(100 * sub.loc[~und, 'ok'].mean(), 1))
print('accuracy, undecidable                :', round(100 * sub.loc[und, 'ok'].mean(), 1))
print('best possible, undecidable           :',
      round(100 * sub.loc[und, 'posterior_max'].mean(), 1))
mean confidence, decidable tickets   : 0.877
mean confidence, undecidable tickets : 0.713
accuracy, decidable                  : 87.9
accuracy, undecidable                : 49.9
best possible, undecidable           : 56.0
Accuracy by the model’s own confidence023.647.270.894.411839.1%0.00 to 0.5048.6%0.50 to 0.7077.3%0.70 to 0.9094.7%0.90 to 0.99100.0%0.99 to 1.00monotonic, which is what makes a threshold possible at all

The model is well calibrated. Above 0.99 confidence it is right 100.0% of the time on 23.8% of tickets, and none of those are undecidable. Below 0.70 it is right less than half the time and 44.0% of that band is the undecidable slice. The ceiling is not spread evenly across the inbox, it is concentrated exactly where the model already says it is unsure.

ConfidenceShare of ticketsAccuracyOf which undecidable
0.00 to 0.505.0%39.1%15.4%
0.50 to 0.7016.4%48.6%44.0%
0.70 to 0.9021.7%77.3%26.1%
0.90 to 0.9933.1%94.7%6.6%
0.99 to 1.0023.8%100.0%0.0%

7dFinal evaluation

Now the threshold can be priced. A person routing a ticket costs 40 seconds at 21.00 pounds an hour, so 0.233 pounds. A misroute costs 6 minutes of somebody picking it up, reassigning it and the customer waiting longer, so 2.10 pounds. Nine routing decisions per mistake.

RATE, HUMAN_SECONDS, MISROUTE_MINUTES = 21.0, 40, 6
C_HUMAN = RATE / 3600 * HUMAN_SECONDS
C_MISROUTE = RATE / 60 * MISROUTE_MINUTES
conf, corr = sub['conf'].values, sub['ok'].values

def cost(threshold, c_misroute=C_MISROUTE):
    auto = conf >= threshold
    wrong = (1 - corr[auto].mean()) if auto.sum() else 0.0
    return (auto.sum() * wrong * c_misroute + (~auto).sum() * C_HUMAN) / len(conf)

grid = [(round(t, 2), cost(round(t, 2))) for t in np.arange(0.30, 1.02, 0.02)]
best_t, best_c = min(grid, key=lambda r: r[1])
print('route everything by hand  : %.4f per ticket' % C_HUMAN)
print('route everything by model : %.4f per ticket' % cost(0.0))
print('threshold at %.2f          : %.4f per ticket' % (best_t, best_c))
print('automated share at that threshold : %.1f percent'
      % (100 * (conf >= best_t).mean()))
Cost per ticket against the auto-routing threshold0.1080.1830.2590.3340.4090.30.320.340.360.380.40.420.440.460.480.50.520.540.560.580.60.620.640.660.680.70.720.740.760.780.80.820.840.860.880.90.920.940.960.98cost per ticketroute automatically above this confidencepounds per ticketflat and wrong at both ends, cheapest in the middle

Route nothing automatically and every ticket costs 0.233 pounds in someone’s time. Route everything and the misroutes cost 0.381 pounds, which is worse than doing it all by hand. The minimum sits at 0.9.

PolicyAutomatedCost per ticketCost per year
Every ticket routed by a person0.0%0.233 pounds2,333 pounds
Every ticket routed by the model100.0%0.381 pounds3,808 pounds
Model above 0.9, person below56.9%0.137 pounds1,374 pounds

Automating everything costs more than automating nothing

Full automation runs at 0.381 pounds a ticket against 0.233 pounds for doing it entirely by hand, which is 63 per cent worse. The threshold policy routes 56.9% of tickets automatically at 96.9% accuracy and costs 0.137 pounds.

All of the value is in the fallback. The model is not worth having because it is accurate. It is worth having because it is accurate and it knows which 43.1% to hand back.

The fallback catches what it is supposed to. Of the undecidable tickets, 86.3% end up in the human queue, and they make up 31.7% of it. A person sees roughly 17 tickets a day instead of all 40.

What the answer depends on

Every number above rests on a misroute costing 6 minutes. That assumption deserves a test rather than a footnote.

# Everything above rests on a misroute costing six minutes. Price that assumption.
print('break even misroute cost: %.2f pounds, or %.1f minutes'
      % (C_HUMAN / (1 - corr.mean()), C_HUMAN / (1 - corr.mean()) / RATE * 60))
for mins in (1, 2, 3, 4, 6, 10, 20):
    cm = RATE / 60 * mins
    everything = (1 - corr.mean()) * cm
    gated = min(cost(round(t, 2), cm) for t in np.arange(0.30, 1.02, 0.02))
    print('misroute %2d min   all model %.4f   all human %.4f   gated %.4f'
          % (mins, everything, C_HUMAN, gated))
Cost per ticket as the price of a misroute changes-0.0810.2920.6661.0401.414123461020route everything by modelroute everything by handthreshold with a fallbackminutes wasted by a misroutepounds per ticketthe fallback policy is never the wrong choice

Full automation only beats doing it by hand if a misroute costs less than 3.7 minutes, which for a support ticket that lands in the wrong queue is optimistic. The threshold policy is the cheapest option at every price tested, and its advantage grows as misroutes get more expensive. That is the finding to take away, because it does not depend on getting the assumption right.

Where the model still struggles

QueueF1Tickets in test
Product feedback65.6%356
Reporting and exports66.0%214
Integrations and API70.0%222
Account admin and permissions75.4%297
Data import76.1%425
Performance and outages80.4%359
Billing and invoices92.9%533
Login and access93.8%726

The weakest queues are the ones that share intents with another queue. Product feedback and Reporting and exports both take custom report requests, and nothing in the text separates them unless the writer says whether they have already looked. That is the ceiling showing up queue by queue rather than a defect to be fixed.

Section 8Documentation and Handoff

Ship the fine tuned model behind a confidence gate at 0.9

DistilBERT reaches 81.86 per cent on wording it has never seen, against 74.62 for word counts, a gap of 7.24 points. On a random split that gap is only 3.2, which is why the split matters more than the model choice here.

Route automatically above 0.9 confidence. That is 56.9% of tickets at 96.9% accuracy. Send the rest to a person, which is about 17 tickets a day.

Do not chase the remaining error. Of the 18.14 points the model is missing, 11.19 are reachable and the rest is information that is not in the ticket.

What to do, and who owns it

ActionDetailOwner
Fine tune rather than freezeFrozen embeddings scored below word counting on the random split. The adaptation is the part that matters, not the pretrainingAnalytics
Hold out phrasings, not rowsA random split overstated the word counter by 15.07 points. Split so the test set says things the training set never saidAnalytics
Gate on confidence and mean itRoute above 0.9, queue the rest. The gate is worth more than the modelProduct
Report accuracy next to a ceilingIf you cannot compute one, estimate it by having two people route the same hundred tickets and measuring how often they disagreeAnalytics
Ask for the deciding detail in the formOne question on the web form collapses most of the undecidable slice. It is worth more than any further modellingProduct
Re-measure when the product changesNew features bring new intents, and an intent the model has never seen arrives with high confidence and a wrong answerAnalytics

What not to do

  • Do not report accuracy without a ceiling. 81.86 sounds like there is work left. Against 93.05 it does not.
  • Do not evaluate on a random split. It flattered the word counter by 15.07 points and would have chosen the wrong model.
  • Do not automate everything because the model is good. It costs 63 per cent more than automating nothing.
  • Do not reach for frozen embeddings as a shortcut. Here they were worse than word counting and cost more to serve.
  • Do not strip the text back. Stemming and stopword removal help a word counter and hurt a pretrained model.
  • Do not treat the ambiguous tickets as a modelling failure. They are a form design problem, and one extra question fixes more than a bigger model.

Reproducibility

ItemValue
Fileskesterly-tickets.csv, kesterly-answer-key.csv
Window2025-08-01 to 2026-06-26, 329 days
SplitsRandom 25 per cent, and a split on phrasing vocabulary: train on set A, test on set B
Modeldistilbert-base-uncased, 3 epochs, learning rate 3e-5, batch 32, max length 96, seed 0
Threshold0.9, chosen by cost rather than by accuracy
Cost model0.233 pounds to route, 2.10 pounds to misroute, 10,000 tickets a year
Ground truthIntent, ambiguity and deciding detail per ticket, from the generator
Librariespandas, numpy, scikit-learn, sentence-transformers, transformers, torch

What to take from this

  • Work out the ceiling before you tune anything. It is the difference between a model that needs work and a model that is finished.
  • A random split measures memorisation as much as understanding. Hold out phrasings and the ranking changes.
  • Pretrained is not adapted. A frozen sentence vector lost to word counting here, because it discards order.
  • A calibrated model is worth more than an accurate one. The value is in knowing which tickets to hand back.
  • Full automation is a choice, not a destination. Here it costs more than doing nothing, and the sensitivity test says that holds across any plausible misroute cost.
  • Some error belongs to the form, not the model. One extra question beats another sprint.

The desk asked for a classifier and the useful deliverable was a threshold. The model is the easy half: a fine tuned transformer gets within 11.19 points of everything the text allows. The half that pays is knowing that 15.6% of the inbox cannot be routed from the words alone, and building the path that sends exactly those tickets to a person.

See where meaning and word matching come apart

Embedding Similarity Explorer

The reason the word counter collapses on new phrasing is that it has no notion that two different sentences mean the same thing. This tool shows real cosine similarity between sentences and the exact point where matching on words stops working.

Open the explorer

Free, no signup. Runs in the browser.

The architectures behind the model that won

Deep Learning Cheatsheet

DistilBERT is a stack of transformer blocks with a classification head bolted on, and fine tuning it is three lines once you know which three. This is every architecture, layer and training concept on one page, including the attention mechanism that lets it read word order.

Get the cheatsheet

Free, one page, LAD branded. No signup.

Companion projects. Anomaly Detection When Positives Are Rare is the other project here where the threshold matters more than the model, sized to what a team can actually review. Segmentation That Survives Scrutiny is the other one where a result that looked finished did not survive being measured a second way.

View Comments (2)

Leave a Reply

Subscribe to My Newsletter

Subscribe to my email newsletter to get the latest posts delivered right to your email. Pure inspiration, zero spam.

Discover more from Discuss Data Science, Machine Learning and Analytics

Subscribe now to keep reading and get access to the full archive.

Continue reading