Hello Model Services
← Guides

Text classification · 4 min read

How to route support tickets to the right team

Send each new ticket straight to the team that can solve it, and send the unclear ones to a person, so customers stop waiting while tickets bounce between queues.

What you’ll build

  • A model that reads a ticket and picks the team that should handle it
  • A confidence threshold: sure tickets route themselves, unsure ones go to triage
  • A feedback loop from tickets that get moved to another team

Tools

Python, pandas, scikit-learn

Step 1: Count what misrouting costs

Before any modeling, measure the problem. Look at last month’s tickets: how long did they wait before reaching the right team, and how many were moved from one team to another? A ticket that bounces twice can wait a day longer than it needs to.

That number shapes the goal. You don’t need every ticket routed automatically. Routing 70% of tickets correctly and instantly, and sending the other 30% to a person, can already halve the waiting time. Write the target down, for example: at least 95% of automatically routed tickets go to the right team.

Step 2: Learn from tickets you’ve already solved

Your help desk already holds the labels: every closed ticket records which team solved it. Use the final team, not the first one it was sent to, because the first assignment is exactly the guess you want to improve on. Export the ticket text, when it was created and the final team.

CodeLoad past tickets
pythonRun in Colab
# pip install pandas scikit-learn
import numpy as np
import pandas as pd

tickets = pd.read_csv("tickets.csv", parse_dates=["created_at"])   # created_at, subject, body, team
tickets["text"] = tickets.subject.fillna("") + " " + tickets.body.fillna("")
print(tickets.team.value_counts())

Expect some teams to get far more tickets than others; that’s class imbalance. Teams with only a handful of tickets can’t be learned well, so group them under “other” and let a person handle those for now.

CodeGroup rare teams
pythonRun in Colab
counts = tickets.team.value_counts()
rare = counts[counts < 50].index
tickets["team"] = tickets.team.where(~tickets.team.isin(rare), "other")
Tip Remove signatures, quoted earlier emails and standard footers if you can. They’re the same in every ticket, and a long footer can drown out the one sentence that says what’s wrong.

Step 3: Test on the most recent tickets

Products, features and common problems change over time, so test the model the way it will be used: train on older tickets and test on the latest two months. A random split would let the model learn about issues that only appear in the test period.

CodeSplit by date
pythonRun in Colab
cutoff = tickets.created_at.max() - pd.DateOffset(months=2)
train, test = tickets[tickets.created_at <= cutoff], tickets[tickets.created_at > cutoff]
print(len(train), "training tickets,", len(test), "test tickets")

Step 4: Beat the busiest-team baseline

The simplest router sends every ticket to the busiest team. Its accuracy is that team’s share of tickets, and it’s the baseline to beat. Then train a real model: TF-IDF turns each ticket into word weights, and logistic regression learns which words point to which team. It trains in seconds and handles dozens of teams.

CodeBaseline and a text model
pythonRun in Colab
from sklearn.dummy import DummyClassifier
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, classification_report
from sklearn.pipeline import make_pipeline

baseline = DummyClassifier(strategy="most_frequent").fit(train.text, train.team)
print("Busiest-team baseline:", round(accuracy_score(test.team, baseline.predict(test.text)), 3))

model = make_pipeline(
    TfidfVectorizer(ngram_range=(1, 2), min_df=2, sublinear_tf=True),
    LogisticRegression(max_iter=2000, C=5),
)
model.fit(train.text, train.team)
print(classification_report(test.team, model.predict(test.text), digits=3, zero_division=0))

Read the report team by team. recall shows how many of a team’s tickets reach it; precision shows how many tickets sent to a team really belong there. A team with low precision is receiving other teams’ work.

Step 5: See which teams get mixed up

List the pairs of teams the model confuses most often. They usually point to overlapping responsibilities rather than a weak model.

CodeThe most common mix-ups
pythonRun in Colab
pred = model.predict(test.text)
mix = pd.crosstab(test.team.values, pred, rownames=["true"], colnames=["predicted"]).stack()
off = mix[[a != b for a, b in mix.index]]
print(off[off > 0].sort_values(ascending=False).head(5))

If billing and accounts are often swapped, read some of those tickets. You may find they really need both teams, or that the boundary between the teams isn’t clear to people either. Fixing the process can help more than any model change.

Step 6: Route only the confident predictions

The model gives each team a probability. When the top one is high, routing automatically is safe; when it’s low, a person should decide. Try a few thresholds and see the trade-off between how many tickets route themselves and how many of those land in the right place.

CodeThe confidence threshold
pythonRun in Colab
proba = model.predict_proba(test.text)
confidence = proba.max(axis=1)
right = model.classes_[proba.argmax(axis=1)] == test.team.values

for t in [0.3, 0.4, 0.5, 0.6, 0.7, 0.8]:
    auto = confidence >= t
    if auto.any():
        print(f"threshold {t:.1f}: {auto.mean():.0%} routed automatically, {right[auto].mean():.1%} of them correct")

Pick the lowest threshold that meets your target from Step 1. For the tickets below it, show the person doing triage the model’s top two suggestions; picking from two is much faster than reading the whole team list.

CodeTop-two suggestions for triage
pythonRun in Colab
top2 = model.classes_[np.argsort(proba, axis=1)[:, -2:][:, ::-1]]
in_top2 = [team in row for team, row in zip(test.team, top2)]
print(f"Right team among the top two: {np.mean(in_top2):.0%}")

Step 7: Route new tickets

Retrain on all your tickets, then save the model with the threshold you chose, so the two always travel together. Your help desk can call the routing function when a ticket arrives, or a small scheduled job can route new tickets every minute.

CodeSave the router and use it
pythonRun in Colab
import joblib

THRESHOLD = 0.6   # the value you chose in Step 6
model.fit(tickets.text, tickets.team)
joblib.dump({"model": model, "threshold": THRESHOLD}, "router.joblib")

def route(subject, body):
    router = joblib.load("router.joblib")
    proba = router["model"].predict_proba([f"{subject} {body}"])[0]
    best = proba.argmax()
    if proba[best] < router["threshold"]:
        return "triage"
    return router["model"].classes_[best]

print(route("Charged twice", "I was billed two times for my October invoice."))

No labeled tickets yet? A language model can sort tickets with no training, given only the team names and a sentence about each. That’s zero-shot classification. It’s a good way to start, and the tickets it routes become the labels for the model above.

Step 8: Learn from re-routes

Every time an agent moves an automatically routed ticket to another team, you get a free, up-to-date label. Track the re-route rate every week: it’s the live version of your test score. If it rises, look at which teams are involved. Often a new product or a reorganized team is the cause; that’s data drift, and retraining on the latest tickets fixes most of it.

When a new team is created, the model can’t send anything there until it has examples. Route its tickets by hand, or with a simple keyword rule, for a few weeks, then retrain.

Common pitfalls

  • Training on the first team a ticket was assigned to, instead of the team that solved it.
  • Routing every ticket automatically, including the ones the model is unsure about.
  • Testing on a random split, which hides how the model copes with new products and new problems.
  • Keeping teams with a handful of tickets as separate classes the model can’t learn.
  • Never retraining after teams change, so tickets keep going to a queue that no longer exists.

Check what you learned

4 quick questions. Choose an answer to see why each option is right or wrong.

  1. Which team should a past ticket be labeled with?

  2. What happens to tickets the model is unsure about?

  3. Billing and accounts are the pair most often mixed up. What’s a good first step?

  4. Why does the guide test on the last two months instead of a random split?

Want this tailored to your data, team and budget? Create a plan for your project

More on Text classification · Next guide: How to forecast call volume and plan staffing

Create a plan