Skip to content
academia.sh

Lesson 10 / 15

Inherently Interpretable Models

Measuring the cost of building the model readable from the start instead of producing the explanation afterward: a sequential list of two rules and four conditions gives 0.7897 on the test set, leaving a gap of 0.0159, that is, four subscribers, to the 0.8056 of the best family with thirty-three columns. Extending the list to three and four rules does not get closer; it drops back to 0.7857. Thirteen subgroups were examined, and in six of the twelve read, the best family cannot be approached within 0.02; the farthest is the central region, 0.6552 instead of 0.7414, a gap of 0.0862. In three subgroups the rule list beats the best family.

Published Updated

In this lesson

  • Builds a sequential rule list and reads its length and test accuracy in the same table
  • Compares the accuracy of the rule list with the baseline, the readable linear model, and the best family lines
  • Counts the subgroups in which the best family cannot be approached and writes the gap in subscribers
Contents

In four lessons the explanation was always produced from outside the model: the decision of a model that had already been built was read, permuted, imitated, and averaged afterward. Each time the explanation was an approximation, and its faithfulness had to be measured separately.

There is another way: building the model readable from the start. A model that gives the decision by applying a few rules in order does not produce the explanation separately — the model itself is the explanation, and its faithfulness is complete by definition. The question of this lesson is the cost of that way. The Linear Models topic of the Supervised Learning course had measured the cost once: 0.0040 between the model with twelve coefficients and the best family with thirty-three columns, that is, a single subscriber. That number is not repeated here, it is taken as input — because a rule list is far more readable than twelve coefficients, and how the cost grows with readability is the real question.

  • IN25. The setup is that of the previous lessons: the same fictional subscriber table, seed 20260218, split 756/252/252, baseline 0.7579 on the test set. The subgroups and the reading threshold of 25 records continue; the number of subgroups examined is 13.
  • IN26. The comparison lines come from the Supervised Learning course and are recomputed with the same seed: the linear model with twelve columns 0.8016, the best family (thirty-three columns, with a quadratic penalty) 0.8056. The number of the decision tree family from the same course is 0.7857.
  • IN27. The rule list is a sequential model: the first rule that matches gives the decision, and if none matches, the last label is given. Each rule carries at most two conditions, and the two conditions cannot be from the same column.
  • IN28. The list is built greedily: at each step, the rule that gains the most correct answers relative to the default label of the remaining records is added, and the records it covers are removed. The minimum coverage is 12 records, and the condition thresholds are taken from the deciles of the training set.
  • IN29. “Approaching the best family” means that the test accuracy gap does not exceed 0.02; on this test set, 0.02 is five subscribers.

Three Comparison Lines

The setup is that of the previous lessons; on top of it, the best family of the Supervised Learning course is rebuilt with the same seed.

# interpret.py — MODEL: the FICTIONAL subscriber table of the first seven courses of the curriculum is rebuilt with the same seed;
# the twelve-column feature set and the split stay fixed throughout the course.
import itertools
import math
import statistics

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, DATA = [], []
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, v = rng(SEED + 7000 + k["id"]), []
    for n in range(pick(r(), [0.05, 0.12, 0.21, 0.62])):
        v.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()
    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),
         "region_name": k["region"][0]}
    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"]
BASELINE = sum(x["suspicious"] == 0 for x in TEST) / len(TEST)


def squash(z):
    if z >= 0:
        return 1.0 / (1.0 + math.exp(-z)) if z < 700 else 1.0
    e = math.exp(z) if z > -700 else 0.0
    return e / (1.0 + e)


def scale(train, fields):
    return ({a: statistics.fmean(x[a] for x in train) for a in fields},
            {a: max(1e-9, statistics.pstdev([x[a] for x in train])) for a in fields})


def train_model(train, fields, o, s, rate=0.3, epoch=300, penalty=0.0):   # MODEL: logistic regression
    X = [[1.0] + [(x[a] - o[a]) / s[a] for a in fields] for x in train]
    Y = [x["suspicious"] for x in train]
    w = [0.0] * len(X[0])
    for _ in range(epoch):
        g = [0.0] * len(w)
        for i in range(len(X)):
            h = squash(sum(w[j] * X[i][j] for j in range(len(w)))) - Y[i]
            for j in range(len(w)):
                g[j] += h * X[i][j]
        for j in range(len(w)):
            w[j] -= rate * (g[j] / len(X) + (penalty * w[j] if j else 0.0))
    return w


def score(w, fields, o, s, x):
    return squash(w[0] + sum(w[j + 1] * (x[a] - o[a]) / s[a]
                             for j, a in enumerate(fields)))


def decision(w, fields, o, s, x):
    return int(score(w, fields, o, s, x) > 0.5)


O, S = scale(TRAIN, FIELDS)
W = train_model(TRAIN, FIELDS, O, S)
ACCURACY = sum(decision(W, FIELDS, O, S, x) == x["suspicious"] for x in TEST) / len(TEST)
print(f"train {len(TRAIN)}, validation {len(VAL)}, test {len(TEST)}")


def tariff_tier(x):
    return ("tariff 10" if x["mean_consumption"] <= 10 else
            "tariff 25" if x["mean_consumption"] <= 25 else "tariff 40")


def household_group(x):
    return "household 1-2" if x["household"] <= 2 else ("household 3-4" if x["household"] <= 4
                                                        else "household 5+")


def reading_group(x):
    return "readings few" if x["period"] <= 2 else "readings all"


def equals(f, value):
    return lambda x: f(x) == value


SUBGROUPS = ([("region " + b[0], equals(lambda x: x["region_name"], b[0])) for b in REGION]
             + [(t, equals(tariff_tier, t)) for t in ("tariff 10", "tariff 25", "tariff 40")]
             + [(h, equals(household_group, h)) for h in ("household 1-2", "household 3-4", "household 5+")]
             + [(o, equals(reading_group, o)) for o in ("readings few", "readings all")])
MIN_SIZE = 25
SIZES = {name: sum(1 for x in TEST if f(x)) for name, f in SUBGROUPS}
print(f"subgroup count {len(SUBGROUPS)}, threshold {MIN_SIZE} records, below the threshold "
      f"{[a for a in SIZES if SIZES[a] < MIN_SIZE]}")
PRODUCT = ["mean_consumption", "volatility", "household", "satisfaction", "period", "per_person"]


def expand(x):
    d = dict(x)
    for a, b in itertools.combinations(PRODUCT, 2):
        d[a + "*" + b] = x[a] * x[b]
    for a in PRODUCT:
        d[a + "^2"] = x[a] * x[a]
    return d


EXPANDED = (FIELDS + [a + "*" + b for a, b in itertools.combinations(PRODUCT, 2)]
            + [a + "^2" for a in PRODUCT])
EX_TRAIN, EX_TEST = [expand(x) for x in TRAIN], [expand(x) for x in TEST]
OE, SE = scale(EX_TRAIN, EXPANDED)
WE = train_model(EX_TRAIN, EXPANDED, OE, SE, penalty=0.05)          # the best family of the Supervised Learning course
BEST_FAMILY = sum(decision(WE, EXPANDED, OE, SE, x) == x["suspicious"] for x in EX_TEST) / len(EX_TEST)
LINEAR_ACCURACY = sum(decision(W, FIELDS, O, S, x) == x["suspicious"] for x in TEST) / len(TEST)
print(f"baseline {BASELINE:.4f}, twelve-column linear model {LINEAR_ACCURACY:.4f}, "
      f"best family ({len(EXPANDED)} columns) {BEST_FAMILY:.4f}")
train 756, validation 252, test 252
subgroup count 13, threshold 25 records, below the threshold ['tariff 10']
baseline 0.7579, twelve-column linear model 0.8016, best family (33 columns) 0.8056

As the Rule List Grows

The list is built greedily, and the length is swept from one to six.

RULE_COLUMNS = ["mean_consumption", "volatility", "household", "satisfaction", "period",
                "zero_readings", "region_name"]
SORT_KEY = {"west": 0, "east": 1, "south": 2, "north": 3, "central": 4}   # keeps the source's order of the region names, because the first of two equally good splits wins


def matches(k, x):
    a, op, e = k
    return x[a] == e if op == "=" else (x[a] < e if op == "<" else x[a] >= e)


def conditions(subset):
    for a in RULE_COLUMNS:
        d = sorted({x[a] for x in subset}, key=lambda v: SORT_KEY.get(v, v))
        if a == "region_name":
            for v in d:
                yield (a, "=", v)
        else:
            for v in (d if len(d) < 10 else
                      [d[int(i * len(d) / 10)] for i in range(1, 10)]):
                yield (a, "<", v)
                yield (a, ">=", v)


def gain(g, v0, min_cover):              # number of correct answers gained relative to the default
    if len(g) < min_cover:
        return None
    pos = sum(x["suspicious"] for x in g)
    label = int(pos * 2 > len(g))
    return ((pos if label else len(g) - pos)
            - (pos if v0 else len(g) - pos), label)


def rule_list(train, length, min_cover=12, beam=8):   # MODEL: sequential rule list
    remaining, rules = list(train), []
    for _ in range(length):
        v0 = int(sum(x["suspicious"] for x in remaining) * 2 > len(remaining))
        candidates = []
        for k1 in conditions(remaining):
            g1 = [x for x in remaining if matches(k1, x)]
            z = gain(g1, v0, min_cover)
            if z:
                p = sum(x["suspicious"] for x in g1) / len(g1)
                candidates.append((p if v0 == 0 else 1 - p, z[0], k1, g1, z[1]))
        if not candidates:
            break
        candidates.sort(key=lambda t: -t[0])
        best = max(((t[1], [t[2]], t[4]) for t in candidates[:beam]), key=lambda t: t[0])
        for _, _, k1, g1, _ in candidates[:beam]:
            for k2 in conditions(g1):
                if k2[0] == k1[0]:
                    continue
                z = gain([x for x in g1 if matches(k2, x)], v0, min_cover)
                if z and z[0] > best[0]:
                    best = (z[0], [k1, k2], z[1])
        if best[0] <= 0:
            break
        rules.append((best[1], best[2]))
        remaining = [x for x in remaining if not all(matches(k, x) for k in best[1])]
        if not remaining:
            break
    p = sum(x["suspicious"] for x in remaining)
    return rules, (int(p * 2 > len(remaining)) if remaining else 0)


def rule_predict(rules, fallback, x):
    for condition, label in rules:
        if all(matches(k, x) for k in condition):
            return label
    return fallback


def describe(condition):
    return " and ".join(f"{a} {o} {e}" for a, o, e in condition)


CLOSE = 0.02                      # five subscribers on the test set
print(f"{'rules':>6}{'conditions':>11}{'test':>9}{'gap to the best family':>24}")
SWEEP = []
for n in range(1, 7):
    rules, fallback = rule_list(TRAIN, n)
    d = sum(rule_predict(rules, fallback, x) == x["suspicious"] for x in TEST) / len(TEST)
    k = sum(len(c) for c, e in rules)
    SWEEP.append((d, -len(rules), rules, fallback, k))
    print(f"{len(rules):>6}{k:>11}{d:>9.4f}{BEST_FAMILY - d:>+24.4f}")
CHOICE = max(SWEEP, key=lambda t: (t[0], t[1]))
print(f"\nlist closest to the best family {-CHOICE[1]} rules, {CHOICE[4]} conditions, "
      f"test {CHOICE[0]:.4f}, gap {BEST_FAMILY - CHOICE[0]:+.4f} "
      f"({round((BEST_FAMILY - CHOICE[0]) * len(TEST))} subscribers)")
for condition, label in CHOICE[2]:
    print(f"  if {describe(condition)} then {label}")
print(f"  otherwise {CHOICE[3]}")
 rules conditions     test  gap to the best family
     1          2   0.7857                 +0.0198
     2          4   0.7897                 +0.0159
     3          6   0.7857                 +0.0198
     4          8   0.7857                 +0.0198
     4          8   0.7857                 +0.0198
     4          8   0.7857                 +0.0198

list closest to the best family 2 rules, 4 conditions, test 0.7897, gap +0.0159 (4 subscribers)
  if region_name = east and volatility >= 11.86 then 1
  if zero_readings >= 1 and volatility >= 20.06 then 1
  otherwise 0

A single rule with two conditions gives 0.7857; this is the number of the decision tree family pruned in the Supervised Learning course. The second rule raises the number to 0.7897, and a gap of 0.0159, that is, four subscribers, remains to the best family. The third and fourth rules do not get closer, they drop back to 0.7857; at the fifth step the greedy procedure finds no gain and stops. There is no one-directional relationship between length and accuracy.

The size of the cost is where the real reading lies. The Supervised Learning course had measured the gap for twelve coefficients as 0.0040, that is, a single subscriber; when it comes down to four conditions, the same gap rises to four subscribers. Readability is a continuous axis, and its cost grows along that axis; which point to choose is not a measurement but a decision, yet the decision can be made only once the numbers at both ends have been written down.

The list itself is read in three lines, and each line is a sentence. The faithfulness of the explanation of this model is 1 by definition, because the explanation is the model itself; the 41 empty explanations of the third lesson do not occur here.

In Which Subgroup the Best Family Cannot Be Approached

The 0.0159 measured in the whole is not read alone. The same two models are put side by side in thirteen subgroups.

LIST, FALLBACK = CHOICE[2], CHOICE[3]
print(f"{'subgroup':<16}{'records':>8}{'rule list':>15}{'best family':>13}"
      f"{'gap':>9}")
FAR, READ = [], 0
for name, f in SUBGROUPS:
    g = [i for i, x in enumerate(TEST) if f(x)]
    rl = sum(rule_predict(LIST, FALLBACK, TEST[i]) == TEST[i]["suspicious"] for i in g) / len(g)
    bf = sum(decision(WE, EXPANDED, OE, SE, EX_TEST[i]) == TEST[i]["suspicious"]
             for i in g) / len(g)
    if SIZES[name] >= MIN_SIZE:
        READ += 1
        if bf - rl > CLOSE:
            FAR.append((name, rl, bf))
    print(f"{name:<16}{len(g):>8}{rl:>15.4f}{bf:>13.4f}{bf - rl:>+9.4f}")
FAR.sort(key=lambda t: t[1] - t[2])
print(f"\nsubgroups read {READ}/{len(SUBGROUPS)}; subgroups that cannot be brought within {CLOSE} of "
      f"the best family {len(FAR)}, the farthest {FAR[0][0]} "
      f"({FAR[0][1]:.4f} instead of {FAR[0][2]:.4f}, gap {FAR[0][2] - FAR[0][1]:+.4f})")
subgroup         records      rule list  best family      gap
region north          68         0.8088       0.7794  -0.0294
region south          58         0.8621       0.8448  -0.0172
region east           36         0.6667       0.7222  +0.0556
region west           32         1.0000       1.0000  +0.0000
region central        58         0.6552       0.7414  +0.0862
tariff 10             12         0.6667       0.6667  +0.0000
tariff 25            193         0.8290       0.8342  +0.0052
tariff 40             47         0.6596       0.7234  +0.0638
household 1-2         73         0.7945       0.8356  +0.0411
household 3-4        143         0.8042       0.7972  -0.0070
household 5+          36         0.7222       0.7778  +0.0556
readings few          85         0.7529       0.7765  +0.0235
readings all         167         0.8084       0.8204  +0.0120

subgroups read 12/13; subgroups that cannot be brought within 0.02 of the best family 6, the farthest region central (0.6552 instead of 0.7414, gap +0.0862)

The gap of four subscribers in the whole is spread across the subgroups. Thirteen subgroups were examined, the 12 that passed the threshold were read, and in six of them the best family cannot be approached within 0.02. The farthest is the central region: 0.6552 against 0.7414, a gap of 0.0862 — more than five times the gap in the whole. In the top tariff tier the gap is 0.0638, in the east region and among large households 0.0556.

The reason can be read from the list itself. One of the two rules depends on the east region and the other on zero readings; the suspicious subscribers of the central region enter neither, and all of them receive the default label of the last line. Brevity is paid for by the whole, and the bill falls on some subgroups.

The opposite direction is in the same table too. In three subgroups the rule list beats the best family: by 0.0294 in the north, 0.0172 in the south, and 0.0070 among middle-sized households. The single number for the whole does not say which model is better for whom.

Summary

  • The sequential list of two rules and four conditions gives 0.7897 on the test set; a gap of 0.0159, that is, four subscribers, remains to the 0.8056 of the best family.
  • Extending the list does not get closer: three and four rules drop it to 0.7857, and the list saturates after the fourth rule. A single rule gives 0.7857, the number of the decision tree family.
  • The cost of readability grows along that axis: one subscriber for twelve coefficients in the Supervised Learning course, four subscribers for four conditions here.
  • The faithfulness of the rule list is 1 by definition; the 41 empty explanations of the third lesson do not occur here.
  • Thirteen subgroups were examined and 12 were read: in six the best family cannot be approached within 0.02, the farthest in the central region at 0.0862; in three subgroups, however, the rule list beats the best family.

Next Step

In ten lessons, what a number is, for whom it is, and what it rests on were measured: the share inflated by the split, the operating point moved by the cost matrix, the choice of the baseline, the margin of the gap, the distribution shift, the number of closed questions, the disagreement of two importance rankings, the faithfulness of the explanation, the interaction the average hides, and the cost of the rule list. In all of them, what was measured was accuracy or readability.

None of them was a measure of harm. The gap of 0.0862 in the central region means that the subscribers there receive the suspicious flag wrongly more often; who receives how much of a wrong flag and what that flag does to them was never asked in ten lessons. The next topic opens with this question: where in the set the error comes from, how many separate definitions of being fair there are, whether those definitions can be satisfied at the same time, and what the model has learned leaks about individual records.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close