Skip to content
academia.sh

Lesson 02 / 11

Types of Learning

The same illicit-use suspicion problem is set up in four setups and the number of labels each setup asks for is counted: supervised 0.8594 on the held-out set with 883 labels, unsupervised 0.8117 with 6 labels, semi-supervised 0.8090 with 60 labels, reinforcement 0.8090 with 120 visits. The baseline is 0.7533. As the label budget falls from 883 to 30, the held-out figure drops from 0.8594 to 0.7745, while the training figure rises from 0.8709 to 0.9400. The two-cluster split sits exactly on the baseline, the six-cluster split gains 0.0584. The visit policy finds 73 illicit cases in 120 visits; if the same budget were spent at random, 25 would be found.

Published Updated

In this lesson

  • Tells the supervised, unsupervised, semi-supervised and reinforcement setups apart together with the number of labels each one asks for
  • Shows by counting how the difference over the baseline erodes as the label budget falls: 0.1061 at 883 labels, 0.0212 at 30 labels
  • Explains that the measure of the reinforcement setup is the return, not accuracy, by comparing it with the number of illicit cases found
Contents

The previous lesson used a single setup: the labels of 883 subscribers were known and the procedure saw all of them. The labels were produced for free there. In the field a label means a door visit, and visits are countable. This is what defines a setup — which piece of information is obtained, in what amount, and at what price.

This lesson sets up the same problem in four setups and counts how many labels each setup asks for. The measure and the held-out set stay the same in all four so that the numbers can be read side by side.

  • FC8. Supervised learning: the label of every example is known. The procedure searches directly for the mapping from feature to label.
  • FC9. Unsupervised learning: there is no label. The output is not a decision but a structure; turning the structure into a decision is a separate job and it asks for labels.
  • FC10. Semi-supervised learning: a small number of labeled examples is used together with a large number of unlabeled ones. The contribution of the unlabeled rows is a measurable number.
  • FC11. Reinforcement learning: the label is not given in advance, it comes as the return on an action. The procedure chooses whom to visit, sees the label only of the one it visited, and makes its next choice accordingly.
  • FC12. The label is a budget. In this lesson the unit of comparison between the setups is not accuracy but labels spent per unit of accuracy.
  • FC13. The real measure of the reinforcement setup is not accuracy but the return: the number of illicit cases found with the budget spent. Accuracy is still printed, but the number that describes the setup is the return.

Rebuilding the Fictional Dataset

# setup.py — the fictional dataset of lesson 01 is rebuilt with the same seed: the readings
# table, the subscriber summary, the fictional label, the train / held-out split and the tree learner.
import math

SEED, RAW, M32 = 20260218, 1400, 0xFFFFFFFF
REGION = [("north", 0.28, 21), ("south", 0.22, 17), ("east", 0.18, 26),
          ("west", 0.14, 14), ("central", 0.18, 23)]
FEATURES = ["mean", "per_person", "drop", "zero_readings", "reading_count",
            "satisfaction", "household_size", "highest", "lowest"]


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, _dropped, _series = [], [], {}
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}
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:
            _series.setdefault(k["subscriber_id"], []).append(t)
        r()

SUBSCRIBERS = []
for k in _subscribers:
    t = _series.get(k["subscriber_id"])
    if not t:
        continue
    mean = sum(t) / len(t)
    a = {"subscriber_id": k["subscriber_id"], "region": k["region"], "series": t,
         "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"])
    a["suspicion"] = 1 if (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
                           + (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)
positives = lambda k: sum(a["suspicion"] for a in k)
accuracy = lambda f, k: sum(1 for a in k if f(a) == a["suspicion"]) / len(k)
_impurity = lambda k: 2 * (positives(k) / len(k)) * (1 - positives(k) / len(k)) if k else 0.0


def fit(k, depth=3, min_size=40):
    """The greedy decision tree of lesson 01: the split criterion and stopping rule are there."""
    best, choice = _impurity(k) * len(k), None
    if depth and len(k) >= min_size:
        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:
                    best, choice = m, (feat, threshold, left, right)
    if choice is None:
        return 1 if 2 * positives(k) > len(k) else 0
    lt, rt = fit(choice[2], depth - 1, min_size), fit(choice[3], depth - 1, min_size)
    return lt if lt == rt else (choice[0], choice[1], lt, rt)


def apply(d, a):
    while isinstance(d, tuple):
        d = d[2] if a[d[0]] < d[1] else d[3]
    return d


print(f"fictional dataset (seed {SEED}): {len(SUBSCRIBERS)} subscribers, {len(TRAIN)} train, "
      f"{len(HELD_OUT)} held-out, {positives(SUBSCRIBERS)} suspicious")
fictional dataset (seed 20260218): 1260 subscribers, 883 train, 377 held-out, 327 suspicious

Supervised: As the Label Budget Shrinks

In the supervised setup the only thing that changes is how many labels are given. With small budgets a single draw is misleading, so five separate subsets are drawn for each budget and the numbers are averaged.

# supervised.py — the number of labels is a budget: the same procedure is built with more labels.
# Five separate random subsets are drawn for each budget and the two numbers are averaged.
import statistics
from collections import Counter

BUDGET, DRAWS = [30, 60, 150, 400, 883], 5
most_frequent = Counter(a["suspicion"] for a in TRAIN).most_common(1)[0][0]
BASE_TRAIN = accuracy(lambda a: most_frequent, TRAIN)
BASE_HELD = accuracy(lambda a: most_frequent, HELD_OUT)
W = [20, 10, 12, 12]
fmt = lambda h: str(h[0]).ljust(W[0]) + "".join(str(v).rjust(W[i + 1])
                                                for i, v in enumerate(h[1:]))
print(fmt(["labels", "train", "held-out", "vs baseline"]))
print(fmt(["baseline (0 labels)", f"{BASE_TRAIN:.4f}", f"{BASE_HELD:.4f}", "0.0000"]))
for n in BUDGET:
    inside, outside = [], []
    for j in range(DRAWS):
        r = rng(SEED + 4100 + j)
        subset = sorted(TRAIN, key=lambda a: r())[:n]
        d = fit(subset, 3, max(12, n // 20))
        inside.append(accuracy(lambda a: apply(d, a), subset))
        outside.append(accuracy(lambda a: apply(d, a), HELD_OUT))
    o = statistics.fmean(outside)
    print(fmt([n, f"{statistics.fmean(inside):.4f}", f"{o:.4f}", f"{o - BASE_HELD:+.4f}"]))
labels                   train    held-out vs baseline
baseline (0 labels)     0.7350      0.7533      0.0000
30                      0.9400      0.7745     +0.0212
60                      0.9267      0.7841     +0.0308
150                     0.8947      0.7873     +0.0340
400                     0.8725      0.8419     +0.0886
883                     0.8709      0.8594     +0.1061

The difference over the baseline erodes together with the budget: 0.1061 at 883 labels, 0.0886 at 400, 0.0212 at 30. A model built with thirty labels does beat the baseline, but the margin by which it beats it is about one fifth of that of the model with 883 labels.

The two columns move in opposite directions. As labels decrease the held-out figure falls while the training figure rises: 0.9400 at 30 labels, 0.8709 at 883 labels. In a set of thirty rows the tree can classify almost every row correctly; on the held-out set the same tree stays at 0.7745. This opposite movement is the subject of the Generalization topic; here it is only put on record.

Unsupervised: Unlabeled Structure and the Cost of Naming

In the unsupervised setup the label column is never read. The procedure looks only at the feature space and divides subscribers who are close to one another into clusters. Because the scales differ, each feature has its mean subtracted and is divided by its own variance; otherwise a column in cubic meters crushes the small-scale columns.

The output of this setup is not a decision. The sentence “this subscriber is in the fourth cluster” tells the field team nothing; the name of the cluster is needed. Naming asks for labels: one subscriber is visited from each cluster, the name of the cluster becomes the label of that subscriber, and the cost is K labels.

# unsupervised.py — no label is used and the cluster structure is derived. The output is not a
# decision but a structure; to turn it into a decision one subscriber is visited from each
# cluster and the name of the cluster becomes the label of that subscriber: cost K labels.
MEANS = {o: statistics.fmean(a[o] for a in TRAIN) for o in FEATURES}
STDS = {o: statistics.pstdev(a[o] for a in TRAIN) or 1.0 for o in FEATURES}
r = rng(SEED + 4100)
MIXED = sorted(TRAIN, key=lambda a: r())
vec = lambda a: [(a[o] - MEANS[o]) / STDS[o] for o in FEATURES]
dist = lambda v, m: sum((x - y) ** 2 for x, y in zip(v, m))


def cluster(K):
    """K centers are picked at random and pulled to the mean of their members over twenty rounds."""
    r = rng(SEED + 4202)
    centers = [vec(MIXED[int(r() * len(MIXED))]) for _ in range(K)]
    for _ in range(20):
        groups = [[] for _ in range(K)]
        for a in TRAIN:
            v = vec(a)
            groups[min(range(K), key=lambda i: dist(v, centers[i]))].append(v)
        for i in range(K):
            if groups[i]:
                centers[i] = [sum(c) / len(c) for c in zip(*groups[i])]
    return lambda a: min(range(K), key=lambda i: dist(vec(a), centers[i]))


print(fmt(["clusters", "labels", "train", "held-out"]))
CLUSTERS = {}
for K in (2, 6):
    which, names = cluster(K), {}
    for a in MIXED:
        names.setdefault(which(a), a["suspicion"])
    f = lambda a, h=which, i=names: i[h(a)]
    CLUSTERS[K] = (which, f, names)
    print(fmt([K, K, f"{accuracy(f, TRAIN):.4f}", f"{accuracy(f, HELD_OUT):.4f}"]))
which6 = CLUSTERS[6][0]
print("K=6 cluster sizes",
      sorted(Counter(which6(a) for a in TRAIN).values(), reverse=True),
      "size of the cluster named suspicious",
      sum(1 for a in TRAIN if CLUSTERS[6][2][which6(a)] == 1))
clusters                labels       train    held-out
2                            2      0.7350      0.7533
6                            6      0.7780      0.8117
K=6 cluster sizes [268, 157, 147, 131, 102, 78] size of the cluster named suspicious 78

The two-cluster split sits exactly on the baseline: 0.7533, a difference of zero. Both clusters were named clean, which means the procedure made the same decision as the baseline that says “clean” to everyone. Clustering does not split by suspicion; it splits by the largest variance, and in this set the largest variance lies in the size of consumption, not in illicit use.

With the six-cluster split the picture changes: 0.8117, 0.0584 above the baseline. The smallest cluster, with 78 subscribers, was named suspicious, and one of the six labels was enough to establish this. The number of clusters is a decision and its effect is 0.0584 — a difference produced not by the clustering but by how many clusters were asked for.

Semi-Supervised: The Contribution of Unlabeled Rows

In the semi-supervised setup the labels of 60 subscribers are known and those of 823 are not. The unlabeled rows are not discarded: according to the cluster structure each one is given a pseudo-label, and the tree is built on the sum of 60 true labels and 823 pseudo-labels.

# semi.py — 60 labels + 823 unlabeled rows: the unlabeled rows get a pseudo-label from the
# cluster structure and the tree is built with 60 true labels and 823 pseudo-labels.
LABELED, UNLABELED = MIXED[:60], MIXED[60:]
name60 = {}
for i in range(6):
    group = [x["suspicion"] for x in LABELED if which6(x) == i]
    name60[i] = 1 if group and 2 * sum(group) > len(group) else 0
PSEUDO = [{**a, "suspicion": name60[which6(a)]} for a in UNLABELED]
tree60, tree_semi = fit(LABELED, 3, 12), fit(LABELED + PSEUDO, 3, 40)
f60, f_semi = lambda a: apply(tree60, a), lambda a: apply(tree_semi, a)
agree = sum(1 for p, a in zip(PSEUDO, UNLABELED) if p["suspicion"] == a["suspicion"])
print(f"pseudo-labels {len(PSEUDO)}, matching the true label {agree}, "
      f"rate {agree / len(PSEUDO):.4f}")
print(fmt(["setup", "labels", "train", "held-out"]))
for setup, f in [("60 labels only", f60), ("60 + 823 unlabeled", f_semi)]:
    print(fmt([setup, 60, f"{accuracy(f, TRAIN):.4f}", f"{accuracy(f, HELD_OUT):.4f}"]))
pseudo-labels 823, matching the true label 633, rate 0.7691
setup                   labels       train    held-out
60 labels only              60      0.7792      0.7905
60 + 823 unlabeled          60      0.7780      0.8090

The same 60 labels, two setups, two numbers. When the 823 unlabeled rows are added, the held-out figure goes from 0.7905 to 0.8090: 0.0185. Only 76.91 percent of the pseudo-labels match the true label, which means nearly a quarter of them are wrong, and the contribution is still positive. What the unlabeled rows carry is not a label but density information: which combinations of features are frequent and which are rare.

Reinforcement: Earning the Label with an Action

In the fourth setup the label column is closed. The procedure picks a subscriber, that subscriber is visited, and only that subscriber’s label is opened. The budget is six rounds of twenty visits each. The first round is random; in the following rounds the choice is made by the vote of five trees built with the labels seen up to that point.

# reinforcement.py — the label is seen only once a visit is made. Six rounds of twenty
# visits each; every round is chosen by the vote of five trees built with what has been seen so far.
ROUNDS, STEP = 6, 20
r = rng(SEED + 4404)
POOL = sorted(TRAIN, key=lambda a: r())
visited, seen = [], set()
print(fmt(["round", "visits", "found", "total"]))
for t in range(ROUNDS):
    if not visited:
        chosen = POOL[:STEP]
    else:
        trees = [fit([visited[int(r() * len(visited))] for _ in visited], 3, 10)
                 for _ in range(5)]
        chosen = sorted((a for a in POOL if a["subscriber_id"] not in seen),
                        key=lambda a: (-sum(apply(d, a) for d in trees),
                                       a["subscriber_id"]))[:STEP]
    seen.update(a["subscriber_id"] for a in chosen)
    visited += chosen
    print(fmt([t + 1, STEP, positives(chosen), positives(visited)]))
tree_rl, tree_full = fit(visited, 3, 20), fit(TRAIN)
print(f"\nillicit cases found in {len(visited)} visits: {positives(visited)}; "
      f"if the same budget were spent at random: {positives(POOL[:ROUNDS * STEP])}")
print(fmt(["setup", "labels", "train", "held-out"]))
FINAL = [("supervised", 883, lambda a: apply(tree_full, a)), ("unsupervised", 6, CLUSTERS[6][1]),
         ("semi-supervised", 60, f_semi),
         ("reinforcement", ROUNDS * STEP, lambda a: apply(tree_rl, a))]
for setup, n, f in FINAL:
    print(fmt([setup, n, f"{accuracy(f, TRAIN):.4f}", f"{accuracy(f, HELD_OUT):.4f}"]))
round                   visits       found       total
1                           20           4           4
2                           20          11          15
3                           20           8          23
4                           20          11          34
5                           20          19          53
6                           20          20          73

illicit cases found in 120 visits: 73; if the same budget were spent at random: 25
setup                   labels       train    held-out
supervised                 883      0.8709      0.8594
unsupervised                 6      0.7780      0.8117
semi-supervised             60      0.7780      0.8090
reinforcement              120      0.7973      0.8090

The found column fluctuates from round to round — 4, 11, 8, 11, 19, 20 — but the return, that is the total, rises by accumulating: 4, 15, 23, 34, 53, 73. In the sixth round all twenty of the twenty subscribers chosen are illicit cases. In total 73 illicit cases were found in 120 visits; had the same budget been spent at random, 25 would have been found. The baseline here is not the most frequent class but the random visit, because what is measured is not classification accuracy but the return on the budget, and the random visit is the counterpart of the non-learning procedure on this measure. The difference is 48 illicit cases.

The last table puts the four setups side by side. In the accuracy column three setups stand very close — 0.8117, 0.8090, 0.8090 — but their label columns are 6, 60 and 120. Producing the same number with a budget twenty times different is not the same thing. Of the 120 labels of the reinforcement setup, 73 came back as illicit cases found in the field; its number is read on the return row.

Summary

  • The four setups solve the same problem and all four are measured on the same held-out set: supervised 0.8594 with 883 labels, unsupervised 0.8117 with 6 labels, semi-supervised 0.8090 with 60 labels, reinforcement 0.8090 with 120 visits; the baseline is 0.7533.
  • As the label budget shrinks, the difference over the baseline erodes: 0.1061 at 883 labels, 0.0886 at 400, 0.0212 at 30. In the same fall the training figure rises in the opposite direction, from 0.8709 to 0.9400.
  • The output of the unsupervised setup is not a decision but a structure; turning the structure into a decision asks for one visit per cluster. The two-cluster split sits exactly on the baseline, the six-cluster split gains 0.0584.
  • In the semi-supervised setup the 823 unlabeled rows carry the model built with the same 60 labels from 0.7905 to 0.8090; only 76.91 percent of the pseudo-labels are correct.
  • The measure of the reinforcement setup is the return: 73 illicit cases in 120 visits, 25 with random visits; the difference is 48 illicit cases, and this number does not appear in the accuracy column.

Next Step

Three of the four setups paid for labels and the fourth earned them with visits. None of them asked this: can the label be derived from the data. In the readings table there is a structure that no one labeled — the path each subscriber follows across the periods. Predicting the next period from the reading of one period asks for no label, because the answer is already in the table. The next lesson builds this setup: a representation is learned through an unlabeled pretext task, that representation is given to the main task as features, and it is measured how many points the pretext task adds over the baseline in the main task.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close