Skip to content
Expedify
Introduction to AI

Module · Why it stopped working

The knowledge bottleneck — what you cannot write down

Lesson 11 of 12 · 14 min

The arms race you lose showed hand-written knowledge running out of value, and then being overtaken. Both of those are arguments about effort. They say the job is bigger than you can afford.

This lesson is about a harder problem. Start by doing something easy.

Read these ten digits

Eight pixels by eight. Sixty-four numbers each.introduction-to-ai/what-you-cannot-write-down.ipynb

Ten out of ten, in about a second, with no effort. You have just outperformed every system in this path so far, on a task you did not know you were being tested on.

Now the only question that matters. How did you do that?

Write down the rule you just used

Not a vague answer. A rule of the kind The machine that reasoned ran on, that a program could execute. Here is what people actually propose.

  • how much ink there is altogether
  • how much ink is in the top, the middle, the bottom
  • how much is on the left against the right, and whether it is symmetric
  • how wide it is, how tall it is
  • whether it has a hole in it, which is surely how anyone knows a 0 from a 1

Every one of those is measurable on 64 pixels, and every one is what an expert would offer.

10 features, all sensible. Now build the best possible rules from them. For each digit, find the conditions that separate it most cleanly, tuned as hard as tuning goes.

The dashed line near the top is the one thing on this page that was not measured. It is you.introduction-to-ai/what-you-cannot-write-down.ipynb

Random guessing gets 10%%. The best tuned rules get 24.2%%. You got 100%.

The same ten digits throughout.

one condition per rule

Accuracy
2.9%%

five conditions, tuned as far as they go

Accuracy
24.2%%

guessing at random

Accuracy
10%%

you, a moment ago

Accuracy
100%

This is not a hard problem badly solved. It is a problem you solved perfectly, without trying, using knowledge you cannot get at. The written-down version of that knowledge, tuned as far as it goes, reaches 24.2%%.

Rules fall apart in two ways at once, and the fixes pull against each other

Look at what the ruleset does with each digit, rather than at the score.

  • 36%% get a clean answer. Exactly one rule fires.
  • 21%% fire no rule at all. The system has nothing to say about them.
  • 43%% fire several rules that disagree. It is a 3 and a 5 and an 8 at once.

Widen the rules to cover the gaps and you create more contradictions. Tighten them to resolve contradictions and you open more gaps. Every large rule base develops both.

The best feature on the easiest pair still overlaps 81% of the time

Take the two digits a rule-writer would most confidently separate, a 1 and a 7. Find the single best feature for telling them apart out of all 10. It turns out to be ink at the bottom.

The best single measurement available, on the easiest pair in the set.introduction-to-ai/what-you-cannot-write-down.ipynb

81%% of them fall in the range where the two overlap. Wherever you put the threshold, you are wrong about a large fraction of both.

Meanwhile you told a 1 from a 7 instantly. Asked how, you would say something like the 7 has a bar across the top. That is true, and none of the sixty-four numbers say it, because a bar across the top is not a quantity.

We can know more than we can tell

The philosopher Michael Polanyi put it in one line in 1966. He was not writing about computers. He was pointing out that most human expertise is like riding a bicycle: completely reliable, and impossible to hand over in words.

That is the knowledge bottleneck, and it is fatal to the whole approach. An expert system needs its expert to dictate. The expert cannot dictate, because what makes them expert is the part they cannot put into words.

The things we find hard, machines find easy

The machine that looked ahead built a machine that cannot lose at a game, and noted that it cannot pick up one of the pieces. Now you can see the shape of that.

Chess grandmasters were beaten decades before anyone could read a handwritten envelope reliably. This is Moravec's paradox, put plainly in 1988.

There is a reason for it. Playing chess is recent, deliberate and conscious, which is exactly why we can describe it and therefore program it. Recognising a shape is ancient and automatic.

So do not write the rules. Show the machine examples.

Classical AI ends here, and not from lack of effort or hardware. It ends because its central assumption failed. Knowledge could be extracted from experts and written down. For the problems that matter most, there is nothing to extract.

The knowledge plainly exists. You used it a moment ago. So there is one possibility left. Show the machine thousands of examples and let it work out the rule itself.

Ten digits, ten features, and the best rules anyone can tune

content/notebooks/introduction-to-ai/what-you-cannot-write-down.ipynb

Add your own feature and see whether it helps. Worth trying: pick the two digits you think are easiest to separate and find the best single feature for them.

Show the code5 cells
1,797 handwritten digits, eight pixels square
D = load_digits()
IMG, Y = D.images, D.target
record('n_digits', f'{len(Y):,}')
record('img_size', f'{IMG.shape[1]}x{IMG.shape[2]}')
record('n_pixels', IMG.shape[1] * IMG.shape[2])
record('n_classes', len(set(Y)))
Ten digits, one of each
from matplotlib.patches import Rectangle
from matplotlib.collections import PatchCollection

picks = [int(np.where(Y == d)[0][1]) for d in range(10)]

def plot(ax):
    # Rectangles, not imshow: imshow embeds a base64 raster and the page's
    # SVG guard refuses <image>. Alpha carries the ink so it reads on either theme.
    rects, alphas = [], []
    for k, idx in enumerate(picks):
        ox = k * 9
        for r in range(8):
            for c in range(8):
                v = IMG[idx][r, c] / 16.0
                if v > 0.02:
                    rects.append(Rectangle((ox + c, r), 1, 1)); alphas.append(v)
    pc = PatchCollection(rects, facecolor=(0.91, 0.41, 0.25), edgecolor='none')
    pc.set_alpha(None); pc.set_facecolors([(0.91, 0.41, 0.25, a) for a in alphas])
    ax.add_collection(pc)
    ax.set_xlim(0, 90); ax.set_ylim(8.6, -0.6); ax.set_aspect('equal')
    ax.set_xticks([]); ax.set_yticks([])
    for s in ax.spines.values(): s.set_visible(False)
save_fig('ten-digits', plot, figsize=(7, 1.5))
The features a human would name
def holes(img):
    """Enclosed background regions — the thing that makes 0 and 8 obvious."""
    bg = img < 4; H, W = bg.shape
    seen = np.zeros_like(bg, bool); n = 0
    for sr in range(H):
        for sc in range(W):
            if not bg[sr, sc] or seen[sr, sc]: continue
            stack, cells, touches = [(sr, sc)], [], False
            seen[sr, sc] = True
            while stack:
                r, c = stack.pop(); cells.append((r, c))
                if r in (0, H-1) or c in (0, W-1): touches = True
                for dr, dc in ((1,0),(-1,0),(0,1),(0,-1)):
                    nr, nc = r+dr, c+dc
                    if 0 <= nr < H and 0 <= nc < W and bg[nr, nc] and not seen[nr, nc]:
                        seen[nr, nc] = True; stack.append((nr, nc))
            if not touches: n += 1
    return n

def features(img):
    ink = img.sum()
    return {
        'total ink':        ink,
        'ink in the top':   img[:3].sum(),
        'ink in the middle':img[3:5].sum(),
        'ink at the bottom':img[5:].sum(),
        'ink on the left':  img[:, :4].sum(),
        'ink on the right': img[:, 4:].sum(),
        'left-right symmetry': -abs(img[:, :4].sum() - img[:, 4:][:, ::-1].sum()),
        'how wide it is':   np.count_nonzero(img.sum(axis=0) > 2),
        'how tall it is':   np.count_nonzero(img.sum(axis=1) > 2),
        'holes in it':      holes(img),
    }

NAMES = list(features(IMG[0]))
X = np.array([[features(im)[n] for n in NAMES] for im in IMG], float)
record('n_features', len(NAMES))
record('feature_list', ', '.join(NAMES[:4]) + ', …')
Hand-style rules, tuned to the best they can be
def best_rule(target, k):
    """Best k-condition rule for one digit: pick the k features whose bands
    separate it best, and take the tightest band that keeps 80% of them."""
    is_t = Y == target
    scored = []
    for j in range(X.shape[1]):
        lo, hi = np.percentile(X[is_t, j], [10, 90])
        fires = (X[:, j] >= lo) & (X[:, j] <= hi)
        prec = (fires & is_t).sum() / max(fires.sum(), 1)
        scored.append((prec, j, lo, hi))
    scored.sort(reverse=True)
    return [(j, lo, hi) for _, j, lo, hi in scored[:k]]

def accuracy(k, tally=None):
    rules = {d: best_rule(d, k) for d in range(10)}
    right = 0
    for i in range(len(Y)):
        votes = [d for d, conds in rules.items()
                 if all(lo <= X[i, j] <= hi for j, lo, hi in conds)]
        if tally is not None:
            tally['none' if not votes else 'many' if len(votes) > 1 else 'one'] += 1
        if len(votes) == 1 and votes[0] == Y[i]: right += 1
    return right / len(Y)

# The two failures an expert system actually suffers, counted (OUTLINE L7):
# digits NO rule covers, and digits where rules CONTRADICT each other.
tally = {'none': 0, 'one': 0, 'many': 0}
accuracy(3, tally)
record('no_rule_fires', f"{tally['none']/len(Y):.0%}")
record('rules_contradict', f"{tally['many']/len(Y):.0%}")
record('exactly_one_rule', f"{tally['one']/len(Y):.0%}")

curve = [accuracy(k) for k in range(1, 6)]
for k, a in zip(range(1, 6), curve):
    record(f'acc_{k}cond', f'{a:.1%}')
record('best_rule_acc', f'{max(curve):.1%}')
record('guessing', f'{1/10:.0%}')

def plot(ax):
    ax.plot(range(1, 6), [100*c for c in curve], marker='o', lw=2.2)
    ax.axhline(10, ls=':', lw=1.2)
    ax.text(5, 12, 'guessing', ha='right', fontsize=9)
    ax.axhline(97, ls='--', lw=1.2)
    ax.text(1.05, 92, 'you, just now, in one second', fontsize=9)
    ax.set_xticks(range(1, 6))
    ax.set_xlabel('conditions per rule')
    ax.set_ylabel('digits identified correctly (%)')
    ax.set_ylim(0, 105)
save_fig('rules-vs-you', plot, figsize=(7, 3.8))
The best feature for telling a 1 from a 7
def overlap(a, b):
    best = None
    for j in range(X.shape[1]):
        ma, mb = X[Y == a, j], X[Y == b, j]
        lo = max(ma.min(), mb.min()); hi = min(ma.max(), mb.max())
        share = (((ma >= lo) & (ma <= hi)).mean() + ((mb >= lo) & (mb <= hi)).mean()) / 2
        if best is None or share < best[0]: best = (share, j)
    return best

share, j = overlap(1, 7)
record('best_feature_1v7', NAMES[j])
record('overlap_1v7', f'{share:.0%}')

def plot(ax):
    bins = np.linspace(X[:, j].min(), X[:, j].max(), 26)
    ax.hist(X[Y == 1, j], bins=bins, alpha=.75, label='the digit 1')
    ax.hist(X[Y == 7, j], bins=bins, alpha=.75, label='the digit 7')
    ax.set_xlabel(NAMES[j])
    ax.set_ylabel('how many digits')
    ax.legend(frameon=False)
save_fig('the-overlap', plot, figsize=(7, 3.4))