Skip to content
academia.sh

Lesson 16 / 20

Probability Calibration

Measuring the difference between the stated probability and the realized share: the calibration error of the linear model is 0.0242, that of the sharpened score, whose ranking is exactly equal to it, is 0.1512, and the area under the curve of the two models is also 0.8058. The calibration error of the depth 8 tree is 0.0000 on the training set by definition and 0.1435 on the test set; the calibration mapping learned from training converges to the identity mapping and corrects nothing. The single-variable mapping learned from the validation set lowers the error of the sharpened score from 0.1512 to 0.0463 and leaves the area under the curve at 0.8058.

Published Updated

In this lesson

  • Splits the probabilities into bins, prints the stated probability and the realized share side by side in every bin and computes the calibration error
  • Shows the existence of a model whose calibration breaks while its ranking does not: the area under the curve stays the same while the calibration error rises to six times
  • Builds the correction as a single-variable mapping and shows with numbers why the mapping can be learned only from the validation set
Contents

The whole of the previous lesson rested on the ranking. The threshold was swept, two curves were printed, the operating point was chosen with the cost calculation; at no step did the number the model says have to have a counterpart of its own. If the scores were passed through a strictly increasing transform, the ranking, the curves and the chosen set of subscribers would have stayed unchanged.

But when the model says 0.80 for a subscriber, that number claims something: about eighty percent of the subscribers given 0.80 should turn out to be suspicious. This claim is independent of the ranking and is measured separately. Calibration is the difference between the stated probability and the realized share.

  • ET23. Calibration is a measure independent of the ranking; two models can give the same ranking and produce different calibration errors.
  • ET24. The probabilities are split into five bins of equal width. The number of bins is a choice: as the number grows, the number of subscribers in each bin falls and the realized share becomes volatile.
  • ET25. The calibration error is computed by summing, in each bin, the absolute difference between the sum of the stated probabilities and the number of suspicious subscribers realized, and dividing by the number of observations.
  • ET26. The sharpened score is a strictly increasing transform; it preserves the ranking, and therefore the area under the curve, exactly.
  • ET27. The calibration mapping sees no feature other than the score of the model; its only input is the score and its two parameters are the slope and the shift. As long as the slope stays positive, the ranking does not change.
  • ET28. The mapping is learned from the validation set; the other two sets are printed for comparison.
  • ET29. The leaf probability of a tree is the share of suspicious subscribers among the training rows that fall into that leaf; for this reason the calibration error of the tree on the training set is zero by definition.
  • ET30. The baseline gives a number here too: the procedure that tells every subscriber the suspicious share of the training set is measured on calibration, and its area under the curve is 0.5000.

Stated and Realized

To measure calibration the probabilities are split into bins, and in each bin the mean probability the model states and the realized suspicious share of the subscribers in that bin are written side by side. This table is called the reliability curve, and it need not be drawn; it is read as numbers.

# evaluation.py — MODEL: the FICTIONAL subscriber table is rebuilt with the same seed.
import math

SEED, RAW, M32 = 20260218, 1400, 0xFFFFFFFF
REGION = [("north", 0.28, 21, 0.00), ("south", 0.22, 17, -0.10),
          ("east", 0.18, 26, 0.30), ("west", 0.14, 14, -0.05),
          ("central", 0.18, 23, 0.05)]


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u, w):
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


SUBSCRIBERS, READINGS = [], {}
for i in range(RAW):
    r = rng(SEED + i)
    b = REGION[pick(r(), [x[1] for x in REGION])]
    household = 1 + pick(r(), [0.06, 0.24, 0.30, 0.24, 0.11, 0.05])
    rating = 1 + pick(r(), [0.08, 0.14, 0.27, 0.34, 0.17])
    if r() >= 0.046:
        SUBSCRIBERS.append({"id": 10001 + i, "region": b, "household": household,
                            "satisfaction": rating})
for k in SUBSCRIBERS:
    r = rng(SEED + 7000 + k["id"])
    for n in range(pick(r(), [0.05, 0.12, 0.21, 0.62])):
        READINGS.setdefault(k["id"], []).append(0.0 if r() < 0.038 else math.floor(
            k["region"][2] * math.exp((r() + r() + r() - 1.5) * 0.62)
            * (1 - n * 0.05) * 100 + 0.5) / 100)
        r()

DATA = []
for k in SUBSCRIBERS:
    v = READINGS.get(k["id"])
    if not v:
        continue
    mean, vol, r = sum(v) / len(v), max(v) - min(v), rng(SEED + 51000 + k["id"])
    z = (0.052 * (mean - 20) + 0.85 * (min(v) == 0.0) + 0.026 * vol
         + 0.24 * (k["household"] >= 5) - 0.20 * (k["satisfaction"] >= 4) + k["region"][3]
         + 0.9 * (k["id"] - 10001) / 1399 + (r() + r() + r() - 1.5) * 1.30)
    x = {"household": k["household"], "satisfaction": k["satisfaction"], "period": len(v),
         "mean_consumption": round(mean, 2), "volatility": round(vol, 2),
         "zero_readings": int(min(v) == 0.0), "suspicious": int(z > 1.35)}
    x["per_person"] = round(x["mean_consumption"] / x["household"], 3)
    for b in REGION:
        x["area_" + b[0]] = int(k["region"][0] == b[0])
    DATA.append(x)


def shuffle(data, seed):
    r, s = rng(seed), list(range(len(data)))
    for i in range(len(s) - 1, 0, -1):
        j = int(r() * (i + 1))
        s[i], s[j] = s[j], s[i]
    return [data[i] for i in s]


K = shuffle(DATA, SEED + 90000)
TRAIN, VAL, TEST = K[:756], K[756:1008], K[1008:]
FIELDS = ["mean_consumption", "volatility", "household", "satisfaction", "period",
          "zero_readings", "per_person", "area_north", "area_south", "area_east", "area_west",
          "area_central"]


def impurity(rows):
    p = sum(x["suspicious"] for x in rows) / len(rows)
    return 2 * p * (1 - p)


def tree(rows, depth, fields, min_size=2):        # MODEL: decision tree
    n = len(rows)
    p = sum(x["suspicious"] for x in rows) / n
    best = None
    if depth and n >= 2 * min_size and 0.0 < p < 1.0:
        for f in fields:
            values = sorted({x[f] for x in rows})
            for v in (values[1:] if len(values) < 10 else
                      [values[int(i * len(values) / 10)] for i in range(1, 10)]):
                left = [x for x in rows if x[f] < v]
                right = [x for x in rows if x[f] >= v]
                if min(len(left), len(right)) < min_size:
                    continue
                gain = impurity(rows) - (len(left) * impurity(left)
                                         + len(right) * impurity(right)) / n
                if best is None or gain > best[0]:
                    best = (gain, f, v, left, right)
    if best is None or best[0] <= 1e-9:
        return {"p": p}
    return {"field": best[1], "threshold": best[2],
            "left": tree(best[3], depth - 1, fields, min_size),
            "right": tree(best[4], depth - 1, fields, min_size)}


def probability(node, x):
    while "p" not in node:
        node = node["left"] if x[node["field"]] < node["threshold"] else node["right"]
    return node["p"]


def linear(train, fields, w, epochs=300, step=0.5):   # MODEL: gradient-based linear model
    mu = {f: sum(x[f] for x in train) / len(train) for f in fields}
    sd = {f: (sum((x[f] - mu[f]) ** 2 for x in train) / len(train)) ** 0.5 or 1.0
          for f in fields}
    scale = lambda x: [(x[f] - mu[f]) / sd[f] for f in fields]
    coef, b = [0.0] * len(fields), 0.0
    X = [scale(x) for x in train]
    y = [x["suspicious"] for x in train]
    sw = [(w if x["suspicious"] else 1.0) for x in train]
    total = sum(sw)
    for _ in range(epochs):
        gc, gb = [0.0] * len(fields), 0.0
        for i, xi in enumerate(X):
            z = b + sum(coef[j] * xi[j] for j in range(len(coef)))
            e = (1 / (1 + math.exp(-max(-30.0, min(30.0, z)))) - y[i]) * sw[i]
            gb += e
            for j in range(len(coef)):
                gc[j] += e * xi[j]
        b -= step * gb / total
        for j in range(len(coef)):
            coef[j] -= step * gc[j] / total

    def score(x):
        v = scale(x)
        z = b + sum(coef[j] * v[j] for j in range(len(coef)))
        return 1 / (1 + math.exp(-max(-30.0, min(30.0, z))))
    return score


SCORE_FN = linear(TRAIN, FIELDS, 1.0)        # MODEL: probability-producing gradient-based linear model
BINS = 5


def reliability(score, rows, n=BINS):     # per bin: count, stated, realized
    bins = [[0, 0.0, 0] for _ in range(n)]
    for x in rows:
        p = score(x)
        k = bins[min(n - 1, int(p * n))]
        k[0], k[1], k[2] = k[0] + 1, k[1] + p, k[2] + x["suspicious"]
    return bins


def calibration_error(score, rows, n=BINS):    # bin-weighted mean absolute difference
    return sum(abs(k[1] - k[2]) for k in reliability(score, rows, n)) / len(rows)


def print_reliability(name, score, rows):
    print(f"{name}  (calibration error {calibration_error(score, rows):.4f})")
    for i, k in enumerate(reliability(score, rows)):
        if k[0]:
            print(f"  {i / BINS:.1f}-{(i + 1) / BINS:.1f}  subscribers {k[0]:>3}  "
                  f"said {k[1] / k[0]:.4f}  realized {k[2] / k[0]:.4f}  "
                  f"difference {k[1] / k[0] - k[2] / k[0]:+.4f}")


print_reliability("linear model, test set", SCORE_FN, TEST)
linear model, test set  (calibration error 0.0242)
  0.0-0.2  subscribers 152  said 0.0891  realized 0.1053  difference -0.0161
  0.2-0.4  subscribers  53  said 0.2837  realized 0.3019  difference -0.0182
  0.4-0.6  subscribers  23  said 0.4918  realized 0.4783  difference +0.0136
  0.6-0.8  subscribers  16  said 0.7079  realized 0.6250  difference +0.0829
  0.8-1.0  subscribers   8  said 0.8698  realized 1.0000  difference -0.1302

In the first three bins the difference is below the 0.02 level and the calibration error is 0.0242. The last two bins are more volatile, because there are a total of 24 subscribers there: in the 0.6-0.8 bin the model says 0.7079 while 0.6250 is realized, in the 0.8-1.0 bin it says 0.8698 while all eight of the eight subscribers turn out to be suspicious. A deviation of 0.13 in a share computed from eight subscribers is the volatility of the measure itself; as the number of bins grows this volatility grows, as it shrinks the table goes blind.

Good Ranking, Bad Calibration

If the scores are passed through a strictly increasing transform, no subscriber’s position changes. Every measure that rests on the ranking stays constant; calibration does not.

def area_roc(score, rows):                  # rank sum averaging ties
    p = sorted(((score(x), x["suspicious"]) for x in rows))
    P = sum(y for _, y in p)
    t, i = 0.0, 0
    while i < len(p):
        j = i
        while j < len(p) and p[j][0] == p[i][0]:
            j += 1
        t += (i + j + 1) / 2.0 * sum(y for _, y in p[i:j])
        i = j
    return (t - P * (P + 1) / 2) / (P * (len(p) - P))


def sharpen(p, g=3.0):                      # strictly increasing, rank-preserving transform
    return p ** g / (p ** g + (1 - p) ** g)


SHARP = lambda x: sharpen(SCORE_FN(x))      # MODEL: sharpened score
TREE_D8 = tree(TRAIN, 8, FIELDS)            # MODEL: depth 8 decision tree
BASELINE = sum(x["suspicious"] for x in TRAIN) / len(TRAIN)
print(f"{'model':<25}{'ROC':>8}{'cal.error':>10}{'mean score':>11}{'suspicious share':>17}")
for name, s in (("linear", SCORE_FN), ("sharpened", SHARP),
                ("tree d8", lambda x: probability(TREE_D8, x)),
                ("baseline (training rate)", lambda x: BASELINE)):
    print(f"{name:<25}{area_roc(s, TEST):>8.4f}{calibration_error(s, TEST):>10.4f}"
          f"{sum(s(x) for x in TEST) / len(TEST):>11.4f}"
          f"{sum(x['suspicious'] for x in TEST) / len(TEST):>17.4f}")
print()
print_reliability("sharpened score, test set", SHARP, TEST)
model                         ROC cal.error mean score suspicious share
linear                     0.8058    0.0242     0.2309           0.2421
sharpened                  0.8058    0.1512     0.1504           0.2421
tree d8                    0.7391    0.1435     0.2586           0.2421
baseline (training rate)   0.5000    0.0198     0.2222           0.2421

sharpened score, test set  (calibration error 0.1512)
  0.0-0.2  subscribers 204  said 0.0199  realized 0.1569  difference -0.1369
  0.2-0.4  subscribers  10  said 0.3033  realized 0.3000  difference +0.0033
  0.4-0.6  subscribers   7  said 0.4756  realized 0.8571  difference -0.3816
  0.6-0.8  subscribers   8  said 0.7001  realized 0.3750  difference +0.3251
  0.8-1.0  subscribers  23  said 0.9509  realized 0.7391  difference +0.2117

The first two rows of the first table are the proof of the claim: the area under the curve of the two models is the same, 0.8058, because both order the 252 subscribers in exactly the same sequence. The calibration error, on the other hand, rises from 0.0242 to 0.1512, to more than six times.

The third row carries a separate warning. The mean score of the depth 8 tree is 0.2586 and the real suspicious share on the test set is 0.2421 — the difference between them is only 0.0165. A model that looks right in total gives an error of 0.1435 at the bin level. A mean that holds is not calibration; it hides overconfidence and underconfidence that cancel each other.

The fourth row says what calibration alone does not measure. The baseline that tells every subscriber the suspicious share of the training set, 0.2222, has a calibration error of 0.0198, the smallest number in the table; its area under the curve is 0.5000, that is, its ranking is the same as random. A procedure that has learned nothing can come first in calibration. The two measures answer separate questions and both need to be written.

The second table shows where the sharpened score breaks. In the most crowded bin the model says 0.0199 and 0.1569 is realized — more than seven times. At the top end it says 0.9509 and 0.7391 is realized. The model is overly timid on one side and overly confident on the other; the two errors cancel each other somewhat on average, at the bin level both stand.

The Correction: A Single-Variable Mapping

The breakage is not in the ranking but in the numeric value of the score. The correction should not touch the ranking either: a mapping that takes the score as input, is strictly increasing and has two parameters is enough.

def logit(p, e=1e-6):
    p = min(1 - e, max(e, p))
    return math.log(p / (1 - p))


def mapping(score, rows, epochs=600, step=0.5):  # MODEL: single-variable calibration mapping
    z = [logit(score(x)) for x in rows]
    y = [x["suspicious"] for x in rows]
    a, b = 1.0, 0.0
    for _ in range(epochs):
        ga = gb = 0.0
        for i, zi in enumerate(z):
            e = 1 / (1 + math.exp(-max(-30.0, min(30.0, a * zi + b)))) - y[i]
            ga, gb = ga + e * zi, gb + e
        a, b = a - step * ga / len(z), b - step * gb / len(z)
    return lambda x: 1 / (1 + math.exp(-max(-30.0, min(30.0, a * logit(score(x)) + b)))), a, b


print(f"{'mapping learned on':<28}{'a':>8}{'b':>9}{'ROC':>8}{'cal.error':>10}")
print(f"{'no correction':<28}{'-':>8}{'-':>9}{area_roc(SHARP, TEST):>8.4f}"
      f"{calibration_error(SHARP, TEST):>10.4f}")
for name, rows in (("train", TRAIN), ("validation", VAL), ("test", TEST)):
    d, a, b = mapping(SHARP, rows)
    print(f"{name:<28}{a:>8.4f}{b:>9.4f}{area_roc(d, TEST):>8.4f}"
          f"{calibration_error(d, TEST):>10.4f}")
TREE_FN = lambda x: probability(TREE_D8, x)
d8, a8, b8 = mapping(TREE_FN, TRAIN)
print(f"\ntree d8 calibration error: train {calibration_error(TREE_FN, TRAIN):.4f}, "
      f"validation {calibration_error(TREE_FN, VAL):.4f}, test {calibration_error(TREE_FN, TEST):.4f}")
print(f"mapping learned on train: a {a8:.4f}, b {b8:.4f}, calibration error on test "
      f"{calibration_error(d8, TEST):.4f}")
mapping learned on                 a        b     ROC cal.error
no correction                      -        -  0.8058    0.1512
train                         0.3340   0.0015  0.8058    0.0244
validation                    0.3013  -0.3695  0.8058    0.0463
test                          0.3038  -0.0043  0.8058    0.0208

tree d8 calibration error: train 0.0000, validation 0.1624, test 0.1435
mapping learned on train: a 1.0002, b 0.0001, calibration error on test 0.1435

The slope of the mapping is around 0.30 on all three sets: as far as the sharpening pushed the scores toward the extremes, the mapping takes that much back. The area under the curve is 0.8058 on all four rows — as long as the slope is positive the ranking does not change and the correction does not move any subscriber from its place. The calibration error falls from 0.1512 to 0.0463 with the validation set, that is, to a third.

The mapping learned from the test set gives 0.0208; this number is not a performance but the result of the correction having been learned from the set it is measured on, and the 0.0255 difference between it and 0.0463 is not an estimate but an optimism.

The training row comes out well on this set, 0.0244; because the linear model does not fit the training set tightly, its probabilities there are not overconfident either. The reason for the rule is in the last two lines. The calibration error of the depth 8 tree is 0.0000 on the training set and 0.1435 on the test set — the leaf probability of the tree is the training share of that leaf, so on the training set the stated and the realized are equal by definition. The mapping learned from training finds a slope of 1.0002 and a shift of 0.0001 and carries the error on the test set from 0.1435 to 0.1435: it has seen nothing to correct, because there is nothing to correct in the set it looked at.

Summary

  • Calibration is the difference between the stated probability and the realized share and is measured by splitting the probabilities into bins; the calibration error of the linear model on the test set is 0.0242.
  • A strictly increasing transform preserves the ranking: the area under the curve of the sharpened score is also 0.8058, while its calibration error is 0.1512, more than six times.
  • It is not enough for the mean score to be close to the real share: the mean of the depth 8 tree is 0.2586, the real share is 0.2421, and its error at the bin level is 0.1435. The baseline that says a constant 0.2222, on the other hand, is the best in calibration with 0.0198 and the worst in the area under the curve with 0.5000.
  • The single-variable mapping learned from validation lowers the calibration error from 0.1512 to 0.0463 and leaves the area under the curve at 0.8058; because the slope is positive, no position in the ranking changes.
  • The calibration error of the tree on the training set is 0.0000 by definition; the mapping learned from there converges to the identity mapping and leaves the error of 0.1435 on the test set as it is.

Next Step

In four lessons each of the measures was for classification and all of them came out of a single operation: counting. The confusion matrix was four counts, precision and recall two ratios, the curves a pair of counts per threshold, calibration a comparison of counts per bin. Because the target was a class, right and wrong were two discrete boxes. In the same fictional set, when the target is the mean consumption of period 3, there is no box left to count: the model says 22.40, the truth is 26.15, and this estimate is neither right nor wrong. The next lesson turns the measure into distance and rebuilds the baseline as the training mean.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close