Skip to content

Explain predictions

  • Lesson 22
  • 60 min
  • Module 7: Understand and choose the model

What you will be able to do

Answer two questions that Grace and Omar ask about every model: “What does it look at?” and “Why did it score this ticket so low?” Then answer the third question that they do not ask, but should: “Does that mean the feature causes the escalation?” It does not.

NotebookDo this lesson in a notebookColab · Kaggle · GitHub

The notebook has every cell, task and check of this lesson, with the instructions. Its setup cells install the packages, download the data and the course’s ticket_model.py, so it needs no local setup. Read the explanations here, and run the code there.

Colab and Kaggle open your own copy. They need a free Google or Kaggle account. Stuck? See the solution notebook.

Before you startWhat to know and to have readyAbout 60 minutes

What you need to know

What you need on your computer (only for the local route; the notebook needs none of it)

Tested on macOS with Python 3.14.6, pandas 3.0.6 and scikit-learn 1.9.1. The Windows commands are not tested. The notebook was tested in Jupyter.

Imagine that a property website estimates your flat at 300,000. You ask the obvious question: why? A good website answers in parts. The size adds the most. The district adds some. The age of the building takes some away. With that answer, you can check the facts (“my flat is 70 square metres, not 90”) and understand the number.

Then you ask a second question: “If I paint the walls, will the estimate go up?” Here you must be careful. The estimate goes up only if the website’s model uses wall colour. And even then, the model learned what goes with high prices in past sales. It did not learn what makes a price high.

Grace and Omar ask the same questions about the escalation model. Grace asks: “What does the model look at?” Omar asks about one ticket: “Why did it score this ticket so low?” Then comes the question they do not ask, but should: “If we change that feature, does the escalation go away?” The answer to the last one is no, and this lesson shows why.

Each question has its own tool. Coefficients are the weights inside a logistic regression. They answer Priya’s question about direction: which features push scores up or down, all else the same? Permutation importance answers Grace’s question: which features does the model depend on most? It works for any model. Contributions answer Omar’s question about one ticket: how much did each feature add to or take from this ticket’s score? The table sums this up.

Question Who asks Tool in this lesson Works for
Which features push scores up or down, all else the same? Priya Coefficients Linear models, such as logistic regression
Which features does the model depend on most? Grace Permutation importance Any model
Why did this ticket get this score? Omar Contributions of one ticket Linear models (other models need other tools)

All three describe the model. None of them describes the world. Keep that sentence in mind; the failure case at the end shows what happens when a team forgets it.

The final model is a logistic regression. For each ticket, it multiplies each prepared value by a weight and adds the products and an intercept (a fixed starting value). The logistic function turns the sum into a score from 0 to 1. You met this in Classification intuition. A coefficient is the weight of one prepared column.

The prepared numbers are scaled: the pipeline subtracts the train mean and divides by the train standard deviation (SD). So a scaled value of 1 means “one SD above the average train ticket”, and 0 means “average”. Here are two tickets with the real coefficients of the final model for priority (−0.64) and word_count (+0.40). The train mean and SD are 2.37 and 0.72 for priority, and 106.13 and 70.81 for word_count.

Ticket A Ticket B
priority 1 → (1 − 2.37) ÷ 0.72 = −1.91 3 → (3 − 2.37) ÷ 0.72 = +0.88
priority part = −0.64 × scaled value −0.64 × −1.91 = +1.22 −0.64 × 0.88 = −0.56
word_count 300 → (300 − 106.13) ÷ 70.81 = +2.74 40 → (40 − 106.13) ÷ 70.81 = −0.93
words part = 0.40 × scaled value 0.40 × 2.74 = +1.10 0.40 × −0.93 = −0.37
Sum of the two parts +2.32 −0.94

Ticket A, urgent with a long message, gets a much larger sum, so a higher score. Look at the priority row. The coefficient is negative, but ticket A’s part is positive. Priority 1 is the most urgent, and its scaled value is below the mean; minus times minus is plus. So never read a coefficient alone: read it with the value it multiplies.

What a coefficient means, in one sentence: if one prepared value goes up by 1 and every other value stays the same, the sum changes by the coefficient. The words “every other value stays the same” matter. You see why below.

Worked example: the coefficients of the final model

Section titled “Worked example: the coefficients of the final model”

Priya fits the final model on the train set, as in the earlier lessons, and scores the validation month:

from ticket_model import FEATURES, TARGET, build_pipeline, load
train, valid = load("train"), load("valid")
pipeline = build_pipeline().fit(train[FEATURES], train[TARGET])
valid["score"] = pipeline.predict_proba(valid[FEATURES])[:, 1]
print("fitted on", len(train), "tickets; scored", len(valid), "validation tickets")

The output:

fitted on 16939 tickets; scored 1179 validation tickets

The pipeline names every prepared column. get_feature_names_out() returns the names, and coef_ holds the coefficients in the same order. Priya sorts them from the most negative to the most positive. Before you look, predict: which team has the most negative coefficient?

names = pipeline.named_steps["prepare"].get_feature_names_out()
coefficients = pd.Series(pipeline.named_steps["model"].coef_[0], index=names).sort_values()
print(coefficients.round(2).to_string())

The output:

categories__team_account -1.02
categories__segment_home -0.71
numbers__priority -0.64
categories__channel_web_form -0.51
categories__team_delivery -0.39
categories__team_returns -0.38
categories__region_north -0.37
categories__channel_chat -0.30
categories__channel_email -0.30
numbers__prior_tickets_90d -0.29
categories__segment_trade -0.26
categories__channel_unknown -0.25
categories__region_unknown -0.23
categories__region_south -0.21
categories__region_east -0.17
categories__region_west -0.14
categories__segment_business -0.14
numbers__missingindicator_customer_tenure_days -0.07
numbers__created_hour -0.04
categories__segment_guest -0.02
numbers__customer_tenure_days -0.02
numbers__missingindicator_order_value 0.02
numbers__order_value 0.15
categories__channel_phone 0.23
categories__team_warranty 0.28
categories__team_payment 0.38
numbers__word_count 0.40
numbers__prior_escalations_90d 0.43

How to read the two kinds of names:

  • categories__team_account −1.02. A one-hot column is 1 when the ticket has this category and 0 otherwise. Compare the categories of one column with each other: an account ticket gets −1.02, a payment ticket +0.38. The difference is 1.40. An account ticket gets a sum 1.40 lower than a payment ticket with the same other values. That matches the slices of the last lesson: account escalates least, payment most.
  • numbers__prior_escalations_90d +0.43. One SD more earlier escalations (2.16 escalations) adds 0.43 to the sum, with every other value the same.
  • numbers__priority −0.64. A larger priority number means less urgent (priority 1 is the most urgent). So a negative coefficient means: more urgent, more risk.

The coefficients agree with what Grace knows about her teams. That is a useful check: a coefficient with a surprising sign is a reason to look closer. One of them is surprising.

prior_tickets_90d has a coefficient of −0.29: more tickets in the last 90 days lower the score. Tomás reads it as: “Customers who write often are calm. They escalate less.” Priya checks the train data before she agrees. She puts the earlier tickets in four bands. For each band, she computes the escalation rate of all tickets, and of the tickets with no escalation in the 90 days before.

band = pd.cut(train["prior_tickets_90d"], [-1, 0, 2, 5, 1000], labels=["0", "1-2", "3-5", "6 or more"])
none_before = train["prior_escalations_90d"] == 0
print("correlation:", round(train["prior_tickets_90d"].corr(train["prior_escalations_90d"]), 2))
print(pd.DataFrame({
"all tickets": train.groupby(band, observed=True)[TARGET].mean(),
"no escalation before": train[none_before].groupby(band[none_before], observed=True)[TARGET].mean(),
}).round(3))

The output:

correlation: 0.73
all tickets no escalation before
prior_tickets_90d
0 0.104 0.104
1-2 0.101 0.092
3-5 0.100 0.082
6 or more 0.137 0.086

Read the two columns:

  • All tickets: customers with 6 or more earlier tickets escalate more (13.7%) than customers with none (10.4%). Tomás is wrong about the plain data.
  • No escalation before: among customers whose earlier tickets did not escalate, more tickets go with fewer escalations (10.4% down to 8.6%).
  • Correlation 0.73: customers with many tickets also have many earlier escalations. The two columns move together.

The coefficient answers the second question: “with prior_escalations_90d held fixed, what do more tickets add?” Among customers with the same escalation history, a customer who writes often about small things is a little less risky. The first question, “do customers with many tickets escalate more?”, has the answer yes. The model gets that risk through prior_escalations_90d (+0.43).

The proof that the coefficient depends on the other features: Priya fits prior_tickets_90d in three models.

Model Coefficient of prior_tickets_90d
prior_tickets_90d alone +0.18
The 10 features from the SQL course (no prior_escalations_90d) +0.07
The final model, 11 features −0.29

The same column changes its sign when a related column joins the model. So a coefficient is never “the effect of a feature”. It is the effect given the other features in this model.

How do you find out how much a football team depends on one player? Watch a game without that player. If the team plays much worse, it depends on the player; if nothing changes, it does not.

Coefficients work only for linear models, and their size depends on the scale. Grace’s question, “what does the model depend on?”, has a tool that works for any model. It uses the same idea as the football game. Permutation importance shuffles one column, so it no longer matches its tickets, and measures how much worse the model gets. A large loss means the model depends on that column.

First, eight tickets and the rule “flag priority 1”, scored with accuracy. To shuffle by hand, reverse the order of one column.

tiny = pd.DataFrame({"priority": [1, 3, 1, 2, 3, 1, 2, 3],
"word_count": [120, 40, 200, 80, 60, 150, 90, 30],
"escalated": [1, 0, 1, 0, 0, 0, 0, 0]})
def rule_accuracy(df):
"""The share of tickets where the rule "flag priority 1" is right."""
return ((df["priority"] == 1).astype(int) == tiny["escalated"]).mean()
print("as recorded:", rule_accuracy(tiny))
print("priority shuffled:", rule_accuracy(tiny.assign(priority=tiny["priority"].values[::-1])))
print("word_count shuffled:", rule_accuracy(tiny.assign(word_count=tiny["word_count"].values[::-1])))

The output:

as recorded: 0.875
priority shuffled: 0.625
word_count shuffled: 0.875

Check it by hand. As recorded, the rule flags tickets 1, 3 and 6. Tickets 1 and 3 escalated, ticket 6 did not, and the five others are correctly passed: 7 of 8 right. Reversed, the priorities are 3, 2, 1, 3, 2, 1, 3, 1. Now the rule flags tickets 3, 6 and 8, and misses ticket 1: 5 of 8 right. The importance of priority is 0.875 − 0.625 = 0.25. The rule never reads word_count, so shuffling it costs 0.

On the real model, scikit-learn’s permutation_importance shuffles each of the 11 columns 10 times at random. It reports the mean and the SD of the loss. Priya uses average precision, the ranking metric from Module 3, on the validation set. She predicts first: priority will be first. It takes about 10 seconds.

from sklearn.inspection import permutation_importance
result = permutation_importance(pipeline, valid[FEATURES], valid[TARGET], scoring="average_precision",
n_repeats=10, random_state=0)
importance = pd.DataFrame({"mean": result.importances_mean, "std": result.importances_std}, index=FEATURES)
print(importance.sort_values("mean", ascending=False).round(3))

The output:

mean std
priority 0.111 0.016
prior_escalations_90d 0.075 0.014
team 0.074 0.009
word_count 0.041 0.011
prior_tickets_90d 0.015 0.004
segment 0.014 0.007
order_value 0.013 0.004
channel 0.005 0.008
region 0.002 0.002
created_hour 0.001 0.002
customer_tenure_days -0.002 0.001
Permutation importance of the five most important columns of the final model, on the validation month (mean of 10 shuffles). The model's average precision is 0.391.
Text description of the chart

A column chart of the average precision lost when one column is shuffled, for the five most important columns. priority: 0.111. prior_escalations_90d: 0.075. team: 0.074. word_count: 0.041. prior_tickets_90d: 0.015. The six other columns lose 0.014 or less, and customer_tenure_days loses nothing (−0.002).

How to read it:

  • The scale. The model’s average precision on May is 0.391. Without priority, it drops by 0.111, to about 0.28. Without customer_tenure_days, nothing changes. Its −0.002 means that the shuffled column made the model very slightly better, which can only be chance. The model does not depend on tenure.
  • A category counts once. team is shuffled as one column, so its five one-hot columns change together. That is why importance has 11 rows and the coefficients have 28.
  • Related columns share credit. prior_tickets_90d is low (0.015), partly because prior_escalations_90d carries most of the same information. If you shuffle one of two related columns, the model still has the other. So a low importance does not prove that a column is useless.

Omar asks about one ticket: T-117033, a priority 3 returns ticket that escalated in May. The model gave it a score of 0.029, so it is one of the 55 missed escalations with a low score. “Why so low?”

For a linear model, one ticket’s score splits exactly. The contribution of a column is its coefficient times this ticket’s prepared value. The intercept plus all contributions is the sum, and the logistic function turns the sum into the score:

score=11+e−(intercept+contribution1+contribution2+… )\text{score} = \frac{1}{1 + e^{-(\text{intercept} + \text{contribution}_1 + \text{contribution}_2 + \dots)}}

Priya writes contributions() and shows the columns where the ticket’s value is not 0 (the other one-hot columns are 0, so they contribute nothing):

def contributions(pipeline, ticket):
"""Coefficient times prepared value, for each column of one ticket."""
prepare, model = pipeline.named_steps["prepare"], pipeline.named_steps["model"]
values = np.asarray(prepare.transform(ticket[FEATURES]))[0]
table = pd.DataFrame({"value": values, "coefficient": model.coef_[0]},
index=prepare.get_feature_names_out())
table["contribution"] = table["value"] * table["coefficient"]
return table
ticket = valid[valid["ticket_id"] == "T-117033"]
table = contributions(pipeline, ticket)
print(table[table["value"] != 0].sort_values("contribution").round(2))

The output:

value coefficient contribution
numbers__prior_tickets_90d 2.04 -0.29 -0.59
numbers__priority 0.88 -0.64 -0.57
numbers__word_count -1.00 0.40 -0.40
categories__team_returns 1.00 -0.38 -0.38
categories__channel_email 1.00 -0.30 -0.30
categories__segment_trade 1.00 -0.26 -0.26
categories__region_east 1.00 -0.17 -0.17
numbers__customer_tenure_days 1.87 -0.02 -0.03
numbers__missingindicator_order_value -0.41 0.02 -0.01
numbers__created_hour -0.14 -0.04 0.01
numbers__missingindicator_customer_tenure_days -0.11 -0.07 0.01
numbers__order_value 0.67 0.15 0.10
numbers__prior_escalations_90d 0.84 0.43 0.36

Then she adds them up, and checks the sum against the model:

intercept = pipeline.named_steps["model"].intercept_[0]
total = intercept + table["contribution"].sum()
print("intercept", round(intercept, 2), "+ contributions", round(table["contribution"].sum(), 2), "=", round(total, 2))
print("score from the sum:", round(1 / (1 + np.exp(-total)), 3), "| predict_proba:", round(ticket["score"].iloc[0], 3))

The output:

intercept -1.28 + contributions -2.23 = -3.52
score from the sum: 0.029 | predict_proba: 0.029

The split is exact: −1.28 − 2.23 = −3.52, and 1 ÷ (1 + e3.52) = 0.029, the model’s score. Read the ticket:

  • The customer wrote 25 tickets in the last 90 days (2.04 SD above the mean). That pushes the sum down by 0.59, the largest push. It is the sign trap of the last section, in one ticket.
  • The customer also had 3 escalations in the last 90 days. That pushes the sum up by 0.36, but not enough.
  • Priority 3 (−0.57), a short message of 35 words (−0.40), and the returns team (−0.38) push it down further.

What Priya tells Omar: “The model scored this ticket low for four main reasons: priority 3, a customer with many recent tickets, a short message, and the returns team. For this customer, the many tickets were not a sign of calm.” That is a true statement about the model. It is not a statement about why the ticket escalated.

A contribution is easy to read as a reason. It is not one, for three reasons:

  1. It compares with an average ticket, not with “nothing”. A scaled value of 0 means “average train ticket”. The −0.59 of prior_tickets_90d means “25 tickets is more than the average of 7.71”. Choose another reference, and every contribution changes. For the one-hot columns, the reference is not even clear. Each category has its own coefficient, and only the differences between categories have a fixed meaning.
  2. Related columns split the credit arbitrarily. prior_tickets_90d and prior_escalations_90d go together (correlation 0.73). The model gives one −0.59 and the other +0.36 for this ticket. Another fit on slightly different data could split the same information another way.
  3. It is the model’s view, not the ticket’s story. The ticket escalated for a reason that none of the 11 columns records. Perhaps the customer’s problem was serious, or the customer has a tendency to escalate that no column shows. The contributions explain why the model missed it. They cannot explain why the customer escalated.

So the honest name is “what moved the model’s score”. Write it that way in a report.

Do the same for T-117014, a priority 1 payment ticket from the phone channel, which the model flagged and which escalated.

  1. Run the cells of the worked example, so that pipeline, contributions() and intercept exist.
  2. Select the ticket: ticket2 = valid[valid["ticket_id"] == "T-117014"].
  3. Make its table: table2 = contributions(pipeline, ticket2).
  4. Find the column with the largest contribution: table2["contribution"].idxmax(). You should see numbers__priority.
  5. Add the intercept and all contributions, then apply the logistic function. You should see a score of 0.38.
  6. Print the rows where the value is not 0, sorted by contribution. Find the largest negative contribution.
Show the answer
value coefficient contribution
categories__segment_home 1.00 -0.71 -0.71
categories__region_east 1.00 -0.17 -0.17
numbers__word_count -0.40 0.40 -0.16
numbers__prior_escalations_90d -0.09 0.43 -0.04
numbers__created_hour 0.90 -0.04 -0.04
numbers__order_value -0.12 0.15 -0.02
numbers__missingindicator_order_value -0.41 0.02 -0.01
numbers__customer_tenure_days 0.17 -0.02 -0.00
numbers__missingindicator_customer_tenure_days -0.11 -0.07 0.01
numbers__prior_tickets_90d -0.32 -0.29 0.09
categories__channel_phone 1.00 0.23 0.23
categories__team_payment 1.00 0.38 0.38
numbers__priority -1.91 -0.64 1.23

The sum is −1.28 + 0.80 = −0.49, and 1 ÷ (1 + e0.49) = 0.380. That is above the threshold of 0.19, so the model flags the ticket. Priority 1 adds +1.23, the largest push by far. The payment team (+0.38) and the phone channel (+0.23) add more. The home segment pushes it down the most (−0.71): home customers escalate less than trade and business customers.

Change one condition: the metric. Run permutation_importance again with scoring="roc_auc", and keep the other settings. Before you run it, predict: does the order of the top three columns stay the same?

Show the answer
mean std
priority 0.089 0.016
team 0.048 0.011
prior_escalations_90d 0.040 0.005
word_count 0.030 0.010
prior_tickets_90d 0.011 0.005
channel 0.008 0.004
segment 0.007 0.003
order_value 0.005 0.003
region 0.002 0.001
created_hour 0.001 0.001
customer_tenure_days -0.000 0.001

Priority stays first. team and prior_escalations_90d change places: with ROC AUC, team loses 0.048 and prior_escalations_90d 0.040. With average precision, they were 0.074 and 0.075, a tie within their SDs (0.009 and 0.014).

The reason: ROC AUC rewards the ranking of all tickets, and average precision rewards the ranking at the top, where the flagged tickets are. A likely reason for the change: prior_escalations_90d helps most at the top, where customers with many earlier escalations get the highest scores. So importance is always “importance for a metric”. Use the metric that matches the decision, as in Module 3: for Larkfield, average precision.

Common mistake

Change the feature, change the outcome?

Symptom: Tomás reads the importance table and makes a plan: “Priority is the most important feature. If agents set fewer tickets to priority 1, the model flags fewer tickets, and fewer will escalate.” Omar has a second plan: “Long messages raise the score. Let us limit the contact form to 50 words.” Priya tests the first plan on T-117014:

changed = ticket2.assign(priority=3)
print("score as written:", round(pipeline.predict_proba(ticket2[FEATURES])[0, 1], 3))
print("score with priority 3:", round(pipeline.predict_proba(changed[FEATURES])[0, 1], 3))
score as written: 0.38
score with priority 3: 0.092

The score falls from 0.380 to 0.092, below the threshold. The plan “works” on the score.

Cause: the model learned which values go with escalation in the past data, not what causes it. An agent sets priority 1 because the problem looks serious; the serious problem leads to the escalation. Change the priority, and the customer’s payment problem is the same. The ticket now waits in the normal queue, and it still needs a senior agent. The same is true for word count. In train, tickets with over 200 words escalate at 20.4%, and tickets with 1 to 50 words at 6.0%. A long message is a sign of a complex problem. A shorter form hides the sign; it does not remove the problem. This is the misconception “interpreting feature importance as causation”.

Fix: say what the numbers are: “the model depends on priority; it is the strongest signal of risk”. To learn what causes escalations, you need another kind of study, such as a controlled change with a comparison group, which Compare alternatives in the mathematics course introduced. Before anyone changes how a feature is recorded, check what the change does to the model’s inputs. If agents change how they set priority, the model’s most important input changes meaning. Then you must check the model again.

Consequence in AI work: a team that treats importance as a cause changes the inputs instead of the problem. The model’s numbers improve, and customers wait longer. Worse, the change moves the data away from what the model learned, so its scores stop meaning what they meant.

Check your understanding

The coefficient of prior_tickets_90d is −0.29. Omar concludes that customers with many tickets escalate less. What does the data say, and what does the coefficient really mean?

Show the answer

The data says the opposite for all tickets: customers with 6 or more earlier tickets escalate at 13.7%, customers with none at 10.4%. The coefficient means: with prior_escalations_90d and the other features held fixed, more earlier tickets lower the sum a little. The two columns are related (correlation 0.73), and the model gets the extra risk of busy customers through prior_escalations_90d (+0.43). Alone, prior_tickets_90d has a coefficient of +0.18. A coefficient depends on the other features in the model.

Check your understanding

Predict: you shuffle customer_tenure_days in the validation set and score the model again. How much average precision do you lose? What does that tell you, and what does it not tell you?

Show the answer

Nothing: the permutation importance is −0.002. A shuffled column cannot really help the model, so a small negative value is chance. It tells you that this model does not depend on tenure. It does not tell you that tenure has no link with escalation, or that it is useless in every model. A related column could carry the same information, and another model could use it.

Check your understanding

The contributions of T-117033 show that prior_tickets_90d pushed its score down by 0.59. Omar writes in a report: the ticket was not flagged because the customer writes often, which made the escalation unlikely. Correct his sentence.

Show the answer

A better sentence: “The model gave this ticket a low score (0.029). The largest push down came from the customer’s 25 tickets in the last 90 days, compared with an average of about 8; priority 3 and a short message also pushed it down.” Three corrections. First, the contribution compares with the average train ticket, not with zero. Second, it describes what moved the model’s score, not why the ticket escalated or did not. Third, the split between related columns (prior_tickets_90d, prior_escalations_90d) is not a fact about the customer. The ticket did escalate, for a reason no column records.

Recap

  • A coefficient is the change in the sum when one prepared value goes up by 1 and the others stay the same. Read it with the value: priority 1 has a negative coefficient and a positive part.
  • The sign trap: prior_tickets_90d is +0.18 alone, +0.07 with the 10 features from the SQL course, and −0.29 in the final model. The final model holds prior_escalations_90d (correlation 0.73) fixed.
  • Permutation importance shuffles one column and measures the metric lost: priority 0.111, prior_escalations_90d 0.075 and team 0.074 in average precision. It works for any model, depends on the metric, and splits credit between related columns.
  • A ticket’s contributions add up exactly to its score: T-117033 gets −1.28 − 2.23 = −3.52, a score of 0.029. They describe the model, compared with an average ticket. They are not a reason and not a cause.
  • Changing a feature changes the score, not the customer’s problem.

Next, you use these explanations to answer a bigger question: when is a simple model, one that people can read and check, the better choice? Banks, hospitals and insurers face that question every day, and some laws ask for answers that only a readable model gives easily.

Next: When a simple model is better