Skip to content
academia.sh

Lesson 01 / 11

What Is Machine Learning

The same illicit-use suspicion problem is solved with three procedures and three numbers are printed side by side: the non-learning baseline scores 0.7533 on the held-out set, the hand-written rule set written by the field expert scores 0.7507, and the three-question decision tree that derives its rules from data scores 0.8594. The hand-written rule falls 0.0027 below the baseline, the tree rises 0.1061 above it. The difference does not come from the thresholds: when the same thresholds are moved into the hand-written rule, the number drops to 0.7480. Of the 377 subscribers in the held-out set, 93 are suspicious; the hand-written rule flags 151 subscribers and 76 turn out empty, the tree flags 66 subscribers and 13 turn out empty. The dataset is reduced from a readings table of 3,199 rows to a subscriber table of 1,260 rows, seed 20260218.

Published Updated

In this lesson

  • Reads the number a model produces by comparing it with a non-learning baseline and justifies the choice of baseline
  • Measures the difference between a hand-written rule set and a learner that derives its rules from data on the same problem
  • Shows by counting separately whether the difference comes from the thresholds or from the way the conditions are combined
Contents

Over ninety lessons, the Data Analytics curriculum measured every step of the path from data to decision and showed at each step that the decision moves the number. One question stayed open at the close: every one of those decisions was made by a human. Which rows to count, which threshold to choose, which channel to use. Which number a procedure that learns its decision from data moves, and where that procedure’s own decisions stop, was never asked.

This lesson asks that question and establishes the unit of measurement for the curriculum. From here on, no number a model produces is written alone; a baseline stands beside it — the number, on the same measure, of a procedure that tries to solve the same problem without learning. The curriculum’s rule follows from this: a model is as good as the margin by which it beats a procedure that is dumber than itself. A performance with no baseline written next to it counts as unmeasured.

  • FC1. The dataset is fictional and carried over from the Data Analytics curriculum: a readings table in long format with 3,199 rows, 1,329 subscribers, three periods, five regions, seed 20260218. The label is added in this curriculum and it is fictional too; the rule that produces the label is written openly in the code and is never given to any model.
  • FC2. Machine learning is deriving the rule from examples instead of writing it by hand. The input is not a rule but a set of examples made of input–output pairs; the output is a decision, and the structure that makes that decision is called a model.
  • FC3. A feature is a column given to the model, and a label is the column the model is asked to predict. Which column is a feature and which is the label in the same table comes not from the data but from the question being asked.
  • FC4. A baseline is a non-learning procedure and it is chosen explicitly in every lesson. In this lesson it is predicting the most frequent class; because the classes are imbalanced, this procedure produces a high accuracy even without learning, and it honestly raises the bar the model has to clear.
  • FC5. Every number is measured on two sets: the training set on which the procedure is built and the held-out set that the procedure has never seen. Why the split is made this way is not the subject of this topic; the Generalization topic covers it. Here the split is applied as the course’s rule.
  • FC6. The measure itself is a decision. Accuracy counts a visit made for nothing and a missed illicit case with the same weight; that weight is a choice and it is written down where it is made.
  • FC7. In this curriculum the models are written with the standard library and at small scale. The aim is not to imitate a library but to make the behavior of the procedure measurable line by line.

The Problem and the Fictional Dataset

The question is this: from meter readings, to tell apart the subscriber carrying illicit-use or fault suspicion before the field team goes to the door. What exists is the readings table of the measurement network; what is wanted is a decision for each subscriber. The table is first regenerated with the same seed.

# data.py — MODEL: the regional measurement network is regenerated with the same seed.
# The dataset is fictional; no real subscriber, meter or region is represented.
import math

SEED, RAW, M32 = 20260218, 1400, 0xFFFFFFFF
PERIODS = ["2026-01", "2026-02", "2026-03"]
REGION = [("north", 0.28, 21), ("south", 0.22, 17), ("east", 0.18, 26),
          ("west", 0.14, 14), ("central", 0.18, 23)]


def rng(t: int):
    """Linear congruential generator: the seed is written in the code, the output is the same from run to run."""
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

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


def pick(u: float, w: list[float]) -> int:
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


_subscribers, _dropped = [], []
for i in range(RAW):
    r = rng(SEED + i)
    b = REGION[pick(r(), [x[1] for x in REGION])]
    k = {"subscriber_id": 10001 + i, "region": b[0], "base": b[2],
         "household_size": pick(r(), [0.06, 0.24, 0.30, 0.24, 0.11, 0.05]),
         "satisfaction": pick(r(), [0.08, 0.14, 0.27, 0.34, 0.17]) + 1}
    (_dropped if r() < 0.046 else _subscribers).append(k)
_valid = {a["subscriber_id"] for a in _subscribers}

READINGS = []
for k in _subscribers + _dropped:
    r = rng(SEED + 7000 + k["subscriber_id"])
    for d in range(pick(r(), [0.05, 0.12, 0.21, 0.62])):
        t = 0.0 if r() < 0.038 else math.floor(k["base"] * math.exp(
            (r() + r() + r() - 1.5) * 0.62) * (1 - d * 0.05) * 100 + 0.5) / 100
        if k["subscriber_id"] in _valid:
            READINGS.append({"subscriber_id": k["subscriber_id"], "region": k["region"],
                             "period": PERIODS[d], "consumption_m3": t})
        r()
print(f"fictional readings table (seed {SEED}): {len(READINGS)} rows, {len(_valid)} subscribers")
fictional readings table (seed 20260218): 3199 rows, 1329 subscribers

This table cannot be given to a model directly. The decision is per subscriber, whereas the table is per reading, and a subscriber is represented by one, two or three rows. The shape the model will see must be a single row per subscriber; each row carries nine numbers that summarize a subscriber and one label.

# summary.py — the readings table is reduced to a single row per subscriber and the label is added.
# The rule that produces the label is FICTIONAL, written openly in the code and not given to the model.
FEATURES = ["mean", "per_person", "drop", "zero_readings", "reading_count",
            "satisfaction", "household_size", "highest", "lowest"]

_groups = {}
for o in READINGS:
    _groups.setdefault(o["subscriber_id"], []).append(o)

SUBSCRIBERS = []
for k in _subscribers:
    o = _groups.get(k["subscriber_id"])
    if not o:
        continue
    t = [x["consumption_m3"] for x in o]
    mean = sum(t) / len(t)
    a = {"subscriber_id": k["subscriber_id"], "region": k["region"],
         "household_size": k["household_size"], "satisfaction": k["satisfaction"],
         "reading_count": len(t), "mean": mean, "highest": max(t),
         "lowest": min(t), "zero_readings": sum(1 for v in t if v == 0),
         "per_person": mean / max(1, k["household_size"]),
         "drop": (t[0] - t[-1]) / t[0] if len(t) > 1 and t[0] > 0 else 0.0}
    r = rng(SEED + 31000 + a["subscriber_id"])
    latent = (1.30 * a["zero_readings"] + 1.25 * (a["drop"] > 0.45)
              + 1.05 * (a["per_person"] > 12.0) + 0.65 * (a["reading_count"] < 3)
              + 0.45 * (a["satisfaction"] <= 2) - 1.55)
    a["suspicion"] = 1 if latent + (r() + r() - 1.0) * 1.1 > 0 else 0
    SUBSCRIBERS.append(a)

r = rng(SEED + 90001)
TRAIN, HELD_OUT = [], []
for a in SUBSCRIBERS:
    (TRAIN if r() < 0.70 else HELD_OUT).append(a)


def accuracy(predict, split: list[dict]) -> float:
    return sum(1 for a in split if predict(a) == a["suspicion"]) / len(split)


def positives(k: list[dict]) -> int:
    return sum(a["suspicion"] for a in k)


print(f"model table: {len(SUBSCRIBERS)} subscribers, {len(FEATURES)} features, 1 label")
print(f"label: {positives(SUBSCRIBERS)} suspicious, {len(SUBSCRIBERS) - positives(SUBSCRIBERS)} clean, "
      f"suspicious rate {100 * positives(SUBSCRIBERS) / len(SUBSCRIBERS):.1f} percent")
print(f"train {len(TRAIN)} subscribers ({positives(TRAIN)} suspicious), "
      f"held-out {len(HELD_OUT)} subscribers ({positives(HELD_OUT)} suspicious)")
model table: 1260 subscribers, 9 features, 1 label
label: 327 suspicious, 933 clean, suspicious rate 26.0 percent
train 883 subscribers (234 suspicious), held-out 377 subscribers (93 suspicious)

Of the 1,329 subscribers, 1,260 entered the model table; 69 subscribers have no readings at all and there is nothing to summarize for them. The suspicious rate is 26.0 percent. This rate directly sets the bar the model has to clear: a procedure that says “clean” to everyone without thinking is right for 74 percent of the subscribers.

The rule that produces the label sits in the block and is not a real illicit-use detection. Zero readings, a sharp drop between periods, high consumption per person, a missing period and low satisfaction are gathered into one score, noise is added on top, and if the score passes zero the subscriber counts as suspicious. The noise is deliberate: a noiseless label turns into a puzzle that any procedure which splits the features finely enough hits exactly, and nothing is measured.

The Baseline and the Hand-Written Rule

Two procedures are written. The first learns nothing: it looks at the most frequent class in the training set and says that to everyone. The second is the rule in the field expert’s head — if there is a zero reading, if consumption dropped sharply, or if consumption per person is high, the subscriber is flagged. The thresholds come from experience.

# rule.py — two procedures: the non-learning baseline and the hand-written rule set.
from collections import Counter

most_frequent = Counter(a["suspicion"] for a in TRAIN).most_common(1)[0][0]


def baseline(a: dict) -> int:
    return most_frequent


def hand_written(a: dict) -> int:
    """The rule set the field expert wrote: the thresholds come from experience."""
    if a["zero_readings"] >= 1:
        return 1
    if a["drop"] > 0.50:
        return 1
    if a["per_person"] > 15.0:
        return 1
    return 0


W = [31, 12, 12]
fmt = lambda h: str(h[0]).ljust(W[0]) + str(h[1]).rjust(W[1]) + str(h[2]).rjust(W[2])
print(fmt(["procedure", "train", "held-out"]))
for name, f in [("baseline: most frequent class", baseline), ("hand-written rule set", hand_written)]:
    print(fmt([name, f"{accuracy(f, TRAIN):.4f}", f"{accuracy(f, HELD_OUT):.4f}"]))
procedure                             train    held-out
baseline: most frequent class        0.7350      0.7533
hand-written rule set                0.7622      0.7507

The expert’s rule beats the baseline by 0.0272 on the training set, but on the held-out set it falls 0.0027 below it. Three reasonable rules, three reasonable thresholds and domain knowledge were not enough to beat a procedure that learns nothing. This result is a finding in itself: had the baseline not been written, the 0.7507 in the table could have been reported as “the rule set makes the right decision for three in four subscribers.”

The Learner That Derives Its Rules from Data

The third procedure takes the form of the rule from the human and the content from the data. The form is this: pick a feature, pick a threshold, split the set in two, and repeat the same step in each part. Which feature to pick with which threshold is found by search; the quantity searched for is how mixed the two parts are in terms of the label after the split. This structure is called a decision tree.

# tree.py — the learner that derives its rules from data: a three-question decision tree
# built by greedy splitting. No library is called; the split criterion and stopping rule are here.
DEPTH, MIN_SIZE = 3, 40


def impurity(k: list[dict]) -> float:
    """How mixed the two labels are in a set: zero if it has a single label."""
    if not k:
        return 0.0
    p = positives(k) / len(k)
    return 2 * p * (1 - p)


def best_split(k: list[dict]):
    best, choice = impurity(k) * len(k), None
    for feat in FEATURES:
        for threshold in sorted({a[feat] for a in k})[1:]:
            left = [a for a in k if a[feat] < threshold]
            right = [a for a in k if a[feat] >= threshold]
            m = impurity(left) * len(left) + impurity(right) * len(right)
            if m < best - 1e-9:  # floating-point equality is not tested, a small margin is left
                best, choice = m, (feat, threshold, left, right)
    return choice


def majority(k: list[dict]) -> int:
    return 1 if 2 * positives(k) > len(k) else 0


def fit(k: list[dict], depth: int):
    b = best_split(k) if depth and len(k) >= MIN_SIZE else None
    if b is None:
        return majority(k)
    left, right = fit(b[2], depth - 1), fit(b[3], depth - 1)
    return left if left == right else (b[0], b[1], left, right)


def apply(node, a: dict) -> int:
    while isinstance(node, tuple):
        node = node[2] if a[node[0]] < node[1] else node[3]
    return node


def show(d, indent: str = "") -> None:
    if not isinstance(d, tuple):
        print(f"{indent}-> {'suspicious' if d else 'clean'}")
        return
    print(f"{indent}{d[0]} < {d[1]:.3f}")
    show(d[2], indent + "  ")
    show(d[3], indent + "  ")


TREE = fit(TRAIN, DEPTH)
show(TREE)
print()
print(fmt(["procedure", "train", "held-out"]))
FIRST_THREE = [("baseline: most frequent class", baseline), ("hand-written rule set", hand_written),
               ("tree learned from data", lambda a: apply(TREE, a))]
for name, f in FIRST_THREE:
    print(fmt([name, f"{accuracy(f, TRAIN):.4f}", f"{accuracy(f, HELD_OUT):.4f}"]))
drop < 0.461
  per_person < 12.060
    -> clean
    reading_count < 3.000
      -> suspicious
      -> clean
  lowest < 6.000
    -> suspicious
    per_person < 13.052
      -> clean
      -> suspicious

procedure                             train    held-out
baseline: most frequent class        0.7350      0.7533
hand-written rule set                0.7622      0.7507
tree learned from data               0.8709      0.8594

The tree beats the baseline by 0.1061 on the held-out set. This is the number the lesson measures, and it carries meaning not alone but inside the three-row table: on the same problem the non-learning procedure gives 0.7533, the rule written with domain knowledge 0.7507, and the rule derived from data 0.8594.

The thresholds the tree found are close to the thresholds of the rule that produces the label. The label rule uses 0.45 for the drop and 12.0 for consumption per person; the tree finds 0.461 and 12.060. The tree never saw these numbers; it looked only at the features and labels of 883 subscribers.

Where the Difference Comes From

The difference looks as if it comes from the thresholds — the expert guessed 0.50 and 15.0, the tree found 0.461 and 12.060. This explanation can be tested: the thresholds in the expert’s rule are replaced with the tree’s, and the number is measured again.

# separate.py — does the difference come from the thresholds: the expert's rule keeps the same form,
# its thresholds are replaced with the values the tree found.
def hand_written_tree_thresholds(a: dict) -> int:
    return 1 if (a["zero_readings"] >= 1 or a["drop"] > 0.461
                 or a["per_person"] > 12.060) else 0


W2 = [31, 13, 8, 14, 8]
fmt2 = lambda h: str(h[0]).ljust(W2[0]) + "".join(str(v).rjust(W2[i + 1])
                                                  for i, v in enumerate(h[1:]))
print(f"held-out set: {len(HELD_OUT)} subscribers, {positives(HELD_OUT)} suspicious")
print(fmt2(["procedure", "flagged", "correct", "empty visit", "missed"]))
PROCEDURES = FIRST_THREE + [("hand-written, tree thresholds", hand_written_tree_thresholds)]
for name, f in PROCEDURES:
    flagged = [a for a in HELD_OUT if f(a) == 1]
    d = positives(flagged)
    print(fmt2([name, len(flagged), d, len(flagged) - d, positives(HELD_OUT) - d]))
print()
for name, f in PROCEDURES:
    print(f"{name:<31}held-out accuracy {accuracy(f, HELD_OUT):.4f}")
held-out set: 377 subscribers, 93 suspicious
procedure                            flagged correct   empty visit  missed
baseline: most frequent class              0       0             0      93
hand-written rule set                    151      75            76      18
tree learned from data                    66      53            13      40
hand-written, tree thresholds            184      91            93       2

baseline: most frequent class  held-out accuracy 0.7533
hand-written rule set          held-out accuracy 0.7507
tree learned from data         held-out accuracy 0.8594
hand-written, tree thresholds  held-out accuracy 0.7480

The explanation collapsed. When the expert’s rule is run with the tree’s thresholds, the number dropped from 0.7507 to 0.7480. The difference is not in the thresholds but in the way the conditions are combined. The expert’s rule joins three conditions with “or”: every subscriber for whom any one holds is flagged. The tree uses the same conditions nested — it looks at consumption per person only in the branch where the drop is small, asks the number of readings as a second condition in that branch, and in the branch where the drop is large it moves to an entirely different feature.

The count table gives the result. The expert’s rule flags 151 subscribers and finds 75 of them correctly; 76 visits go to waste. The tree flags 66 subscribers, finds 53 of them correctly, and 13 visits go to waste. The tree catches fewer illicit cases — 53 against 75 — but it produces about six times fewer empty visits, and because the accuracy measure counts an empty visit with the same weight as a missed illicit case, the tree wins.

This is the concrete counterpart of FC6. If the cost of an empty visit is lower than the cost of a missed illicit case, the measure changes, and when the measure changes the ranking can change too: the procedure in the last row catches 91 of the 93 suspicious subscribers and misses only 2, but with 93 empty visits. Which procedure is “good” is determined together with the weighting buried inside accuracy.

Summary

  • Machine learning is deriving the rule from examples instead of writing it by hand; in this lesson the rule is a three-question decision tree derived from a training set of 883 subscribers.
  • In this lesson the baseline is predicting the most frequent class and it scores 0.7533 on the held-out set; because the suspicious rate is 26.0 percent, even a non-learning procedure produces a number that looks high.
  • The hand-written rule set scores 0.7507 on the held-out set and stays below the baseline; the tree learned from data scores 0.8594 and beats the baseline by 0.1061.
  • The difference does not come from the thresholds: when the same thresholds are moved into the hand-written rule, the number drops to 0.7480. The difference comes from whether the conditions are nested or joined with “or”.
  • The accuracy measure counts an empty visit and a missed illicit case equally; while the tree catches 53 illicit cases and produces 13 empty visits, the expert’s rule catches 75 and produces 76 empty visits, and if the measure changes the ranking can change too.

Next Step

This lesson used a single setup: every subscriber’s label was known and the procedure saw all 883 labels. In this curriculum the label was produced for free, not in the field. In reality a label means a field visit, and visits are countable. The next lesson builds the same problem in four separate setups — supervised, unsupervised, semi-supervised and reinforcement — and counts how many labels each setup asks for: how much of the difference above the baseline remains when the work is done with 60 labels instead of all 883, what an unlabeled setup can extract from the same set, and how a procedure that learns the label by visiting spends its budget.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close