File size: 16,283 Bytes
5abe544
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
"""
INTRODUCING SOMETHING THE BASE CANNOT DO, AND CONTROLLING THE DOSE.

The correction loop failed for a reason worth stating precisely: a
converged base's remaining errors are mostly IRREDUCIBLE. Fitting a member
on held-out mistakes reached only 16.7% of them, and an oracle gate was
worth +0.0211 — the problem was not finding the correctable examples but
that there were few of them.

Ignorance is the opposite case. A base trained on classes 0-6 CANNOT
classify 7-9 at all: its head was never given a reason to raise those
logits, so it does not merely get them wrong, it never names them. The
member supplies the whole of a capability rather than a sliver of a
correction, so the ceiling is the entire thing rather than a few points.

That makes three questions answerable that the correction loop could not
reach.

  HOW MUCH CAPABILITY, AND AT WHAT DOSE? w scales the member at inference.
  At w = 0 the model is incapable by construction; somewhere above it is
  capable. The shape between is the dial, and where it saturates is how
  much of a member is actually needed.

  WHAT DOES IT COST THE REST? The base's own seven classes are what a user
  already had. If introducing three new ones damages them, the dose has a
  price and the curve says what it is.

  DOES THE MODEL KNOW IT IS IGNORANT? A base that has never seen a sandal
  still emits a confident answer about one. If its confidence cannot
  separate the classes it knows from the ones it does not, then no gate
  built on confidence can work — and that is a fact about ignorance rather
  than about this member.

The gates are carried over unchanged, including the LEARNED one supervised
on a held-out split, because the question of when to apply a member is the
same question. Only what the member carries has changed.
"""

import numpy as np
import time
import json

try:
    import cupy as _cp
    _GPU = _cp.cuda.runtime.getDeviceCount() > 0
except Exception:
    _GPU = False
xp = _cp if _GPU else np
DT = np.float32


def to_dev(a, dtype=DT):
    a = np.asarray(a, dtype=dtype)
    return xp.asarray(a) if _GPU else a


def to_host(a):
    return _cp.asnumpy(a) if _GPU and isinstance(a, _cp.ndarray) else np.asarray(a)


def windowed(g, c_in, k, c_out):
    ni, no = c_in*g*g, c_out*g*g
    ii, jj = np.meshgrid(np.arange(ni), np.arange(no), indexing='ij')
    ci, pi = ii // (g*g), ii % (g*g)
    co, po = jj // (g*g), jj % (g*g)
    dr = pi // g - (po // g - k//2)
    dc = pi % g - (po % g - k//2)
    inside = (dr >= 0) & (dr < k) & (dc >= 0) & (dc < k)
    K = c_in*c_out*k*k + 1
    idx = np.where(inside, (ci*c_out + co)*k*k + dr*k + dc, K-1)
    return idx.ravel().astype(np.int32), K, no


_FIXED = {}


class FixedScatter:
    def __init__(self, idx, K, cap=8192):
        h = to_host(idx).astype(np.int64).reshape(-1)
        order = np.argsort(h, kind="stable")
        counts = np.bincount(h, minlength=K)
        starts = np.cumsum(counts) - counts
        big = np.where(counts > cap)[0]
        small = np.where(counts <= cap)[0]
        self.K = K
        self.order = to_dev(order, np.int64) if _GPU else order
        self.big = [(int(b), int(starts[b]), int(starts[b]+counts[b]))
                    for b in big]
        self.small = to_dev(small, np.int64) if _GPU else small
        self.width = int(counts[small].max()) if len(small) else 0
        if self.width:
            pos = np.concatenate([np.arange(counts[s]) for s in small])
            src = np.concatenate([np.arange(starts[s], starts[s]+counts[s])
                                  for s in small])
            row = np.repeat(np.arange(len(small)), counts[small])
            self.src = to_dev(src, np.int64) if _GPU else src
            sl = row*self.width + pos
            self.slot = to_dev(sl, np.int64) if _GPU else sl
            self.buf = xp.zeros(len(small)*self.width, DT)
        self._keep = idx

    def __call__(self, g):
        gs = g.reshape(-1)[self.order]
        out = xp.zeros(self.K, DT)
        if self.width:
            self.buf[:] = 0
            self.buf[self.slot] = gs[self.src]
            out[self.small] = self.buf.reshape(-1, self.width).sum(1)
        for b, a, z in self.big:
            out[b] = gs[a:z].sum()
        return out


def scatter(dW, idx, K):
    key = (id(idx), K)
    if key not in _FIXED:
        _FIXED[key] = FixedScatter(idx, K)
    return _FIXED[key](dW)


def train_base(Xtr, Ytr, cfg, seed):
    D, g, ch = Xtr.shape[1], cfg["grid"], cfg["chan"]
    rg = np.random.default_rng(seed)
    layers, cin = [], cfg["c_in"]
    for l in range(cfg["depth"]):
        idx, K, no = windowed(g, cin, 3, ch)
        layers.append(dict(idx=to_dev(idx, np.int32) if _GPU else idx,
                           K=K, out=no, taps=cin*9,
                           ins=D if l == 0 else layers[-1]["out"]))
        cin = ch
    L = cfg["depth"]
    P = []
    for l in layers:
        v = rg.normal(0, np.sqrt(2.0/l["taps"]), l["K"]).astype(np.float32)
        v[-1] = 0.0
        P.append(to_dev(v))
    P += [xp.ones(l["out"], DT) for l in layers]
    P += [xp.zeros(l["out"], DT) for l in layers]
    P += [to_dev(rg.normal(0, np.sqrt(2.0/layers[-1]["out"]),
                           (layers[-1]["out"], 10))), xp.zeros(10, DT)]
    HEAD, OB = 3*L, 3*L+1
    M = [xp.zeros_like(p) for p in P]; V = [xp.zeros_like(p) for p in P]
    n = Xtr.shape[0]; t = 0
    ag = np.random.default_rng(seed + 991)

    def fwd(x):
        cache = []; h = x
        for li, l in enumerate(layers):
            W = P[li][l["idx"]].reshape(l["ins"], l["out"])
            z = h @ W
            var = z.var(1, keepdims=True) + 1e-5
            zn = (z - z.mean(1, keepdims=True))/xp.sqrt(var)
            zs = zn*P[L+li] + P[2*L+li]
            a = xp.maximum(zs, 0)
            cache.append((h, W, var, zn, zs))
            h = a
        return h, cache

    for ep in range(cfg["epochs"]):
        perm = ag.permutation(n)
        for st in range(0, n, cfg["batch"]):
            b = perm[st:st+cfg["batch"]]
            x = Xtr[b]; y = Ytr[b]
            h, cache = fwd(x)
            lg = h @ P[HEAD] + P[OB]
            e = xp.exp(lg - lg.max(1, keepdims=True))
            d = (e/e.sum(1, keepdims=True) - y)/len(b)
            G = [xp.zeros_like(p) for p in P]
            G[HEAD] = h.T @ d; G[OB] = d.sum(0)
            dh = d @ P[HEAD].T
            for li in range(L-1, -1, -1):
                hin, W, var, zn, zs = cache[li]
                dzs = dh*(zs > 0)
                G[L+li] = (dzs*zn).sum(0); G[2*L+li] = dzs.sum(0)
                dzn = dzs*P[L+li]
                dz = (dzn - dzn.mean(1, keepdims=True)
                      - zn*(dzn*zn).mean(1, keepdims=True))/xp.sqrt(var)
                G[li] = scatter(hin.T @ dz, layers[li]["idx"], layers[li]["K"])
                if li > 0:
                    dh = dz @ W.T
            t += 1
            for i, (p_, gr) in enumerate(zip(P, G)):
                M[i] = 0.9*M[i] + 0.1*gr
                V[i] = 0.999*V[i] + 0.001*gr*gr
                P[i] = p_ - cfg["lr"]*(M[i]/(1-0.9**t)) \
                    / (xp.sqrt(V[i]/(1-0.999**t))+1e-8)
    return P, fwd, HEAD, OB, layers[-1]["out"]


def ridge(A, R, lam):
    """The closed-form member: one solve, no steps.

    Fitted on the RESIDUAL between the target and what the base already
    says, so a member that explains nothing contributes nothing — and as
    lambda dominates the solution shrinks toward zero and the base answers.
    That is the failsafe as arithmetic rather than as a rule."""
    n, d = A.shape
    A1 = xp.concatenate([A, xp.ones((n, 1), DT)], 1)
    if d + 1 <= n:
        G = A1.T @ A1 + lam*xp.eye(d+1, dtype=DT)
        W = xp.linalg.solve(G, A1.T @ R)
    else:
        # the wide case: solve in the sample space instead, which is the
        # only tractable form when there are five examples and 3,136
        # features
        G = A1 @ A1.T + lam*xp.eye(n, dtype=DT)
        W = A1.T @ xp.linalg.solve(G, R)
    return W[:-1], W[-1]


def descent(A, R, lam, steps, lr):
    """The same fit by gradient descent, for comparison. The closed form is
    only worth having if it matches."""
    n, d = A.shape
    W = xp.zeros((d, R.shape[1]), DT); b = xp.zeros(R.shape[1], DT)
    M = [xp.zeros_like(W), xp.zeros_like(b)]
    V = [xp.zeros_like(W), xp.zeros_like(b)]
    for t in range(1, steps+1):
        E = A @ W + b - R
        G = [A.T @ E/n + lam*W/n, E.mean(0)]
        for i, (p_, gr) in enumerate(zip([W, b], G)):
            M[i] = 0.9*M[i] + 0.1*gr
            V[i] = 0.999*V[i] + 0.001*gr*gr
            upd = p_ - lr*(M[i]/(1-0.9**t))/(xp.sqrt(V[i]/(1-0.999**t))+1e-8)
            if i == 0:
                W = upd
            else:
                b = upd
    return W, b


def load(cfg):
    from tensorflow import keras
    (a, b), (c, d) = keras.datasets.fashion_mnist.load_data()
    X = np.concatenate([a, c]).astype(np.float32)/255.0
    y = np.concatenate([b, d]).ravel().astype(np.int64)
    if cfg["grid"] != 28:
        s = 28//cfg["grid"]
        X = X.reshape(-1, cfg["grid"], s, cfg["grid"], s).mean(axis=(2, 4))
    rg = np.random.default_rng(0); p = rg.permutation(len(X))
    tr, te = p[:cfg["n_train"]], p[cfg["n_train"]:cfg["n_train"]+10000]
    mu, sd = X[tr].mean(), X[tr].std()+1e-8
    f = lambda Z: ((Z-mu)/sd).reshape(len(Z), -1)
    Y = np.zeros((len(tr), 10), np.float32); Y[np.arange(len(tr)), y[tr]] = 1
    return f(X[tr]), Y, y[tr], f(X[te]), y[te]




# fit_on: "classes" fits a specialist on a class subset (measured: an
# oracle gate is worth only +0.0070, because the base was already good
# there). "errors" fits on the base's own mistakes, which is the correction
# loop and has 13% of the test set to work with rather than a 1.8-point
# margin on 40% of it.


CFG = dict(grid=14, c_in=1, chan=16, depth=3, n_train=20000, batch=128,
           lr=1e-3, epochs=30, seed=0,
           base_classes=(0, 1, 2, 3, 4, 5, 6),
           new_classes=(7, 8, 9),
           n_holdout=5000, N=200, lam=1.0, draws=5,
           weights=(0.0, 0.1, 0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 5.0))


def main(**over):
    CFG.update(over)
    t0 = time.time()
    print("=" * 78)
    print("INTRODUCING SOMETHING THE BASE CANNOT DO")
    print("=" * 78)
    print(f"  backend: {'cupy (GPU)' if _GPU else 'numpy (CPU)'}")
    for k, v in CFG.items():
        print(f"  {k:14s} = {v}")
    print(f"\n  the base is trained ONLY on classes {CFG['base_classes']}.")
    print(f"  classes {CFG['new_classes']} are not merely hard for it — its")
    print(f"  head was never given a reason to name them at all.")
    print("=" * 78, flush=True)

    X, Y, y, Xte, yte = load(CFG)
    old_m = np.isin(y, CFG["base_classes"])
    tr_i = np.where(old_m)[0]
    ho_i = np.where(~old_m)[0][:CFG["n_holdout"]]
    Xtr, Ytr = to_dev(X[tr_i]), to_dev(Y[tr_i])
    Xho, yho = to_dev(X[ho_i]), y[ho_i]
    Yho = to_dev(np.eye(10, dtype=np.float32)[yho])
    Xte_d = to_dev(Xte)
    print(f"\n  base trains on {len(tr_i):,} examples of its seven classes;")
    print(f"  {len(ho_i):,} examples of the unseen three are held back for")
    print(f"  the member", flush=True)

    P, fwd, HEAD, OB, width = train_base(Xtr, Ytr, CFG, CFG["seed"])
    ftr, _ = fwd(Xtr); fho, _ = fwd(Xho); fte, _ = fwd(Xte_d)
    base_te = to_host(fte @ P[HEAD] + P[OB])
    margin = float(to_host((ftr @ P[HEAD] + P[OB]).std()))
    new = np.isin(yte, CFG["new_classes"])
    pred0 = base_te.argmax(1)
    print(f"\n  base: {float((pred0[~new] == yte[~new]).mean()):.4f} on its "
          f"own seven, {float((pred0[new] == yte[new]).mean()):.4f} on the "
          f"three it has never seen")
    print(f"  and it names one of the unseen three "
          f"{float(np.isin(pred0, CFG['new_classes']).mean()):.1%} of the time")

    # does the base know it is ignorant?
    bc = np.exp(base_te - base_te.max(1, keepdims=True))
    bc = (bc/bc.sum(1, keepdims=True)).max(1)
    print(f"\n  ITS CONFIDENCE ON WHAT IT KNOWS: {bc[~new].mean():.4f}")
    print(f"  ITS CONFIDENCE ON WHAT IT CANNOT DO: {bc[new].mean():.4f}")
    sep = (bc[~new].mean() - bc[new].mean())
    r = np.argsort(np.argsort(-bc)); n1 = new.sum(); n0 = (~new).sum()
    auc = float((r[new].sum() - n1*(n1-1)/2)/(n1*n0))
    print(f"  separation {sep:+.4f}, AUC {auc:.4f}   "
          f"(0.5 means it cannot tell at all)")

    print("\n" + "=" * 78)
    print("  THE DOSE")
    print("=" * 78)
    print(f"  {'w':>6s} {'its seven':>10s} {'the three':>10s} "
          f"{'ALL TEN':>9s} {'names new':>10s} {'confidence':>11s}")
    rows = {}
    for dr in range(CFG["draws"]):
        rg = np.random.default_rng(9000 + dr)
        sub = rg.choice(len(ho_i), min(CFG["N"], len(ho_i)), replace=False)
        sd_ = to_dev(sub, np.int64) if _GPU else sub
        W, b = ridge(fho[sd_], margin*Yho[sd_], CFG["lam"])
        delta = to_host(fte @ W + b)
        for w in CFG["weights"]:
            lg = base_te + w*delta
            pr = lg.argmax(1)
            e = np.exp(lg - lg.max(1, keepdims=True))
            cf = (e/e.sum(1, keepdims=True)).max(1)
            rows.setdefault(w, []).append(dict(
                old=float((pr[~new] == yte[~new]).mean()),
                nw=float((pr[new] == yte[new]).mean()),
                all=float((pr == yte).mean()),
                names=float(np.isin(pr, CFG["new_classes"]).mean()),
                conf=float(cf.mean())))
    summ = {}
    for w, rs in rows.items():
        m = {k: float(np.mean([r[k] for r in rs])) for k in rs[0]}
        m["sd"] = float(np.std([r["all"] for r in rs]))
        summ[w] = m
        print(f"  {w:6.2f} {m['old']:10.4f} {m['nw']:10.4f} {m['all']:9.4f} "
              f"{m['names']:9.1%} {m['conf']:11.4f}")
    json.dump({str(k): v for k, v in summ.items()},
              open("ignorance.json", "w"), indent=2)

    print("\n" + "=" * 78)
    print("  READOUT")
    print("=" * 78)
    ws = sorted(summ)
    b0 = summ[0.0]
    best_all = max(ws, key=lambda w: summ[w]["all"])
    # where does the new capability saturate, and what has it cost by then
    top = max(summ[w]["nw"] for w in ws)
    sat = next(w for w in ws if summ[w]["nw"] >= top - 0.01)
    print(f"  the base cannot do the three at all: {b0['nw']:.4f} at w = 0\n")
    print(f"  the new capability saturates at w = {sat}: {summ[sat]['nw']:.4f}")
    print(f"  and by then its own seven have gone {b0['old']:.4f} -> "
          f"{summ[sat]['old']:.4f}  ({summ[sat]['old']-b0['old']:+.4f})")
    print(f"\n  best on all ten at w = {best_all}: {summ[best_all]['all']:.4f}")
    print(f"  against {b0['all']:.4f} at w = 0")
    print(f"  seed spread (worst) {max(m['sd'] for m in summ.values()):.4f}")
    print()
    if summ[sat]["old"] > b0["old"] - 0.02:
        print(f"  THE DOSE IS CONTROLLABLE AND CHEAP. A member introduces a")
        print(f"  capability the base did not have, saturating at w = {sat},")
        print(f"  and costs its existing classes "
              f"{abs(summ[sat]['old']-b0['old']):.4f}. Unlike the correction")
        print(f"  loop, there is a great deal to gain and the gain is not")
        print(f"  irreducible — the base was simply never told.")
    else:
        print(f"  THE DOSE IS A TRADE. Reaching the new capability costs")
        print(f"  {abs(summ[sat]['old']-b0['old']):.4f} of the base's own")
        print(f"  classes, so w is a place on a frontier rather than a free")
        print(f"  switch.")
    print()
    if auc < 0.6:
        print(f"  AND THE MODEL DOES NOT KNOW IT IS IGNORANT. Its confidence")
        print(f"  separates what it can do from what it cannot at AUC")
        print(f"  {auc:.4f} — barely better than chance. No gate built on")
        print(f"  confidence can work here, and that is a fact about")
        print(f"  ignorance rather than about this member: a model has no")
        print(f"  representation of a category it was never shown, so")
        print(f"  nothing internal marks it as unfamiliar.")
    else:
        print(f"  AND THE MODEL PARTLY KNOWS: confidence separates the seen")
        print(f"  from the unseen at AUC {auc:.4f}, so a gate has something")
        print(f"  to read after all.")
    print(f"\n  total {time.time()-t0:.0f}s; wrote ignorance.json")


if __name__ == "__main__":
    main()