cCarebun Reading‹ All reading
Carebun Interactive/Decision models Decision labCheat sheet Study view

Part II · Apprentice

Chapter 12

Thresholds and routing

A probability does nothing until code draws a line on it. We price the two kinds of mistake, choose the line on one half of the data, judge it on the other, send the unsure cases to Rosa, and test whether a bigger model would read them better.

13 min read · interactive

On this page
  1. A threshold turns a probability into an action
  2. Two kinds of mistake
  3. Choose on dev, report on test
  4. A line for each action
  5. Routing on uncertainty
  6. Composite scoring in code
  7. Try it yourself
  8. Common questions
  9. Carry this
  10. Check yourself

In this chapter, we will learn how the back office turns the clerk's probabilities into actions. We will define a threshold, meet the two mistakes it can make, choose one from labelled data and check it on data it has not seen. Then we will route the requests the clerk was unsure about to Rosa, test whether a larger model would do better with them, and combine several answers into one priority number in code.

A threshold turns a probability into an action

A threshold is a line drawn on a probability: at or above it the code does one thing, and below it another. The stamp gives a number. The threshold makes it an if.

Our example is one Noul from experiment 0010: "The situation described is a danger to people or to the building." The clerk returns the probability that this is true, and code like this sketch compares it with a line.

if answers["danger"]["noul"] >= 0.41:
    send_emergency_trade()     # act now

The rest of this chapter is about where the 0.41 comes from.

Two kinds of mistake

A threshold can be wrong in two ways: a missed danger, when a dangerous request falls below the line, and a false alarm, when a harmless one lands above it. Moving the line trades one mistake for the other. On our data no line removes both.

Really dangerousNot dangerous
At or above the linecaughtfalse alarm
Below the linemissed dangercorrectly left alone

The two mistakes do not cost the same. A false alarm sends an electrician to a flat with a flickering bulb. A missed danger leaves a sparking socket until the next working day.

Two numbers summarise a line. Precision is the share of alarms that were real. Recall is the share of real dangers that were caught. Here they are for the line 0.50 on one half of our data: 25 dangers caught, 15 false alarms, 1 danger missed.

precision   caught / (caught + false alarms)   =  25 / (25 + 15)  =  25 / 40  =  62.5%
recall      caught / (caught + missed)         =  25 / (25 + 1)   =  25 / 26  =  96.2%

Choose on dev, report on test

A threshold is chosen on one set of labelled examples, the dev half, and its quality is reported on another, the test half, which played no part in the choice. A labelled example is a request for which we already know the right answer. A line that was tuned on some rows tends to look better on those rows than it really is.

Experiment 0010 asked the danger question about 281 requests: the 215 agreed ones and 66 contested ones, on which the writer and a second reader disagreed about severity. The truth is "severity level 3". On the 66 it is the writer's level, so the labels are noisy. All the data is synthetic.

requests: 281 (59 dangerous).  dev 140 (26 dangerous), test 141 (33 dangerous)

DEV half
threshold   caught (TP)   false alarms (FP)   missed (FN)   precision   recall
   0.05         26               98               0         21.0%    100.0%
   0.10         26               67               0         28.0%    100.0%
   0.20         26               38               0         40.6%    100.0%
   0.30         26               27               0         49.1%    100.0%
   0.50         25               15               1         62.5%     96.2%
   0.70         23                5               3         82.1%     88.5%
   0.80         20                3               6         87.0%     76.9%
   0.90         16                2              10         88.9%     61.5%
   0.95          8                0              18        100.0%     30.8%

rule: the highest threshold that misses nothing dangerous on dev -> 0.41
TEST half at 0.41:  caught 33, false alarms 23, missed 0, correctly left alone 85   precision 58.9%  recall 100.0%
TEST half at 0.50:  caught 33, false alarms 17, missed 0, correctly left alone 91   precision 66.0%  recall 100.0%

TP, FP and FN in the headings stand for true positive, false positive and false negative. As the line rises, false alarms fall from 98 to 0 and missed dangers rise from 0 to 18. The rule we fixed in advance was "the highest threshold that misses nothing dangerous on dev", and it gave 0.41.

On the test half, 0.41 caught 33 of 33 dangers with 23 false alarms. The natural-looking 0.50 also caught 33 of 33 there, with 17 false alarms. So was 0.41 a mistake? On dev, 0.50 missed one danger, and we had no way to know that the test half would be kinder. With 26 dangers on dev, one request moves the line, so treat 0.41 as "somewhere near 0.4".

A second reason to use data: the same experiment found that the 30 requests the clerk put between 0.5 and 0.7 were dangerous only 20% of the time by our labels (Chapter 6). Part of that gap is the label. When three other models read the same statement in experiment 0029, their majority called 73.3% of that band dangerous. A line picked by feel can sit in the wrong place, and so can a label.

Putting prices on the mistakes

"Miss nothing" is one rule. A more general one is to give each mistake a price and pick the cheapest line. Say, as an illustration, that a missed danger costs $2,000 and a false alarm $40.

dev at 0.30    0 missed x 2,000  +  27 false alarms x 40   =  $1,080
dev at 0.50    1 missed x 2,000  +  15 false alarms x 40   =  $2,600
dev at 0.70    3 missed x 2,000  +   5 false alarms x 40   =  $6,200

test at 0.41   0 missed x 2,000  +  23 false alarms x 40   =    $920
test at 0.50   0 missed x 2,000  +  17 false alarms x 40   =    $680

Before you move anything below, predict: if a false alarm cost as much as a missed danger, would the cheapest line move left or right, and would it still catch all 33 dangers on the test half?

A line for each action

A system has one threshold for each action, set by what a wrong action costs. The documentation puts it this way: "A confidence threshold is not one number. Different actions within the same system should be gated at different levels depending on the consequences of getting it wrong."

It also describes three paths, not two. High confidence: "Act automatically." Medium: "Proceed with caution". Low: "Do not act. Route to a human, request clarification, or fall back to a different system."

For Rosa's desk the lines could look like this. The first row comes from experiment 0010 and the other two from experiment 0017. None is a recommendation for another building.

ActionRests onLineWhy there
Send the emergency trade nowdanger (Noul)at or above 0.41a miss costs far more than a wasted call-out, so the line is low
Enter with the master keymay_enter (Noul)at or above 0.8; from 0.2 to 0.8 a person decidesa false yes means entering a home without permission, so the line is high
Send the reply without a personconfidence of trade and severityat or above 0.6a wrong promise is awkward but can be corrected

Routing on uncertainty

Routing is sending each request down one of several paths, and routing on uncertainty means that the path depends on how sure the clerk was, not only on the answer. In Chapter 11 the desk of experiment 0017 handed 13 of 107 requests to Rosa. This rule, its gate, did it.

MIN_CONF = 0.6          # below this, a person reads the request. Chosen before looking at the test half.
unsure = a["severity"]["confidence"] < MIN_CONF or a["trade"]["confidence"] < MIN_CONF or 0.2 < enter < 0.8

A Noul has no confidence field, so the rule uses a band on the probability itself. Here is what the gate bought, from the numbers the experiment printed.

handed to Rosa                      13 / 107          =  12.1% of requests
wrong levels among those 13          3
wrong levels among the 94 sent       1
wrong levels in all                  3 + 1             =  4
the gate caught                      3 / 4             =  75% of them
handed over although the level was right   13 - 3      =  10 requests

wrong response times sent, with the gate       1 / 94    =  1.1%
wrong response times sent, without it          4 / 107   =  3.7%

Rosa reads 13 requests so that 3 wrong promises are not sent. Ten of her 13 readings were not needed, and one wrong reply still got through.

Does low confidence find the wrong answers?

For severity, experiment 0004 looked at the 215 agreed requests.

agreed set: exact-level accuracy by confidence bucket
confidence      n    correct
 0.00-0.50     13     38.5%
 0.50-0.70     27     77.8%
 0.70-0.90     28     96.4%
 0.90-0.97     30     96.7%
 0.97-1.00    117    100.0%

Accuracy rises with confidence, from 38.5% in the lowest bucket to 100% in the highest. The same experiment found that the line 0.6 flags 34.8% of the 66 contested requests and 11.6% of the agreed ones. So confidence is a useful detector and an imperfect one.

For the trade, experiment 0026 asked the question about 291 requests: the 215 agreed ones and all 76 on which the two readings disagreed about anything. For those, the truth is the writer's trade.

requests: 291   trade correct: 282   wrong: 9

trade confidence      n    wrong   correct
  0.00 - 0.60        6      1     83.3%
  0.60 - 0.80        9      5     44.4%
  0.80 - 0.95       12      2     83.3%
  0.95 - 1.00      264      1     99.6%
...
a gate at confidence 0.6 would send 6 requests to a person and catch 1 of the wrong ones; a gate at 0.8 would send 15 and catch 6
line at 0.6     6 handed over     6 / 291   =  2.1%     catches 1 of the 9 wrong trades
line at 0.8    15 handed over    15 / 291   =  5.2%     catches 6 of the 9
still sent wrong at 0.8          9 - 6      =  3        at confidence 0.90, 0.92 and 0.99

The desk's 0.6 is too low for the trade: five of the nine mistakes sit just above it. A line at 0.8 looks better, but we read it off all 291 rows, so by this chapter's own rule it must be confirmed on rows it has not seen. And a line would have to sit above 0.99 to catch them all: one wrong trade came with confidence 0.99.

A bigger model for the unsure ones

The documentation's path for low confidence includes "fall back to a different system". A cascade does that: a cheap model answers everything, and only its unsure cases go on to a slower, dearer one. Experiment 0024 had gpt-5.1 answer all 107 test requests and used its answers for the 13 gated ones. "Right" means trade, severity level and permission all correct.

                                     right   $ / 1,000   mean ms
jev alone                            96.3%       0.029       284
gpt-5.1 alone                        95.3%       0.757      1087
cascade: jev, then gpt-5.1 if unsure   96.3%       0.120       420

escalated to gpt-5.1: 13 of 107 (12.1%).  On those, Jev alone was right on 10, gpt-5.1 on 10.

The cascade bought nothing. On the 13 unsure requests each model was right on 10, so the pair scored the same 96.3% as Jev alone, at four times the cost (0.120 / 0.029 = 4.1). The larger model did no better on them, although gpt-5.1 wrote this data. A cascade pays only when the second model is better on exactly the cases that are passed on.

A line is not a razor

The model does not always return the same numbers twice (Chapter 15). Experiment 0032 sent 100 test requests ten times each and applied the gate every time.

requests where a repeat changed the trade                  0
requests where a repeat changed the nearest severity level 4
requests where a repeat changed permission at 0.5          0
requests where a repeat changed the route (auto or person) 5
...
mean spread where mean confidence is 0.9 or more (70 requests): 0.008;  where it is below 0.6 (9 requests): 0.111

Five requests in 100 went to Rosa on one call and out automatically on another. The spread is the gap between the highest and the lowest severity score that one request received in ten calls, and the answers that wobble are the unsure ones: 0.111 against 0.008. For a line this means two things. A request close to the line can land on either side, so a count such as "13 handed over" is good to a few requests. And the wobble sits where the gate already looks.

Composite scoring in code

A composite score is one number that code computes from several answers and facts, each put on a scale from 0 to 1 and multiplied by a weight. The documentation calls the pattern Composite Scoring and says to "divide each score by its top level number" before weighting.

Here is a priority number for Rosa's reading pile. The inputs and the weights are an illustration.

severity score     2.61 on levels 0 to 3      2.61 / 3            =  0.87     a stamp
danger             Noul                                           =  0.62     a stamp
days_open          16, capped at 14           min(16, 14) / 14    =  1.00     a fact

priority   0.5 x 0.87  +  0.3 x 0.62  +  0.2 x 1.00
        =  0.435       +  0.186       +  0.200                    =  0.82

Rosa reads the pile from the highest number down. If waiting time comes to matter more, someone changes 0.2 to 0.3 and no question is touched.

Try it yourself

Run the threshold experiment. It is cached, so it is free.

cd experiments
python3 0010-thresholds/main.py

Then use the interactive above. Set the false alarm to $0 and note the line. Set both prices equal and note it again. Why is the first line low and the second high?

Common questions

Why not simply use 0.5?

The documentation says to use 0.5 "when yes and no are equally easy to act on." For danger they are not. On our dev half 0.50 missed one dangerous request that 0.41 caught.

Can I tune the threshold on the test half if the result looks better?

No. Once a row has helped to choose the line, it can no longer tell you how the line does on unseen rows. Our 0.50 looked better on test, and choosing it for that reason would be this mistake.

Is a low confidence the same as a wrong answer?

No. Of the 13 requests handed to Rosa, the clerk's level was right on 10. The documentation notes that confidence describes the model's answer and is not a guarantee that the answer is correct. Low confidence is a reason to look, not a verdict.

Who should own the thresholds?

The people who own the costs. Keep all the lines in one place in the code, where they can be read and reviewed.

Carry this

  • A threshold turns a probability into an action. It is code, and it belongs to people.
  • Precision is the share of alarms that were real. Recall is the share of real dangers caught.
  • Choose on dev, report on test. Measured: 0.41 from dev caught 33 of 33 on test with 23 false alarms.
  • One line per action, set by what a wrong action costs. Three paths: act, proceed with caution, do not act.
  • The desk's gate sent 12.1% of requests to Rosa and caught 3 of the 4 wrong levels. Sending those 12.1% to gpt-5.1 gave the same 96.3% at four times the cost. Repeated calls moved the route of 5 requests in 100.

Check yourself

1. On the dev half at 0.70, the table shows 23 caught, 5 false alarms and 3 missed. Compute precision, recall, and the cost at $2,000 per miss and $40 per false alarm.

Answer
precision   23 / (23 + 5)   =  23 / 28  =  82.1%
recall      23 / (23 + 3)   =  23 / 26  =  88.5%
cost        3 x 2,000 + 5 x 40  =  6,000 + 200  =  $6,200

High precision, and far dearer than the line at 0.30, which cost $1,080.

2. The desk gates "enter with the master key" at 0.8 and "send the emergency trade" at 0.41. Why is one line high and the other low?

Answer

For the key, the expensive mistake is a false yes: entering without permission. A high line makes false yeses rare. For danger, the expensive mistake is a miss. A low line makes misses rare.

3. A request has severity score 1.50 on levels 0 to 3, danger 0.10 and has been open 7 days. With the weights 0.5, 0.3 and 0.2 from this chapter, what is its priority?

Answer
severity   1.50 / 3           =  0.50
danger                        =  0.10
days_open  min(7, 14) / 14    =  0.50
priority   0.5 x 0.50 + 0.3 x 0.10 + 0.2 x 0.50  =  0.250 + 0.030 + 0.100  =  0.38

Well below the 0.82 of the worked example, so it is read later.