File size: 14,141 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
"""
CAN A BODY SUPPORT A MEMBER IT NEVER SAW?

Everything measured about families so far trains them TOGETHER. The body
sees every member's data, the heads specialise on top, and the storage
economics follow: forty-five members share one body at seventeen times less
storage for a thousandth of a point.

An injection architecture needs something the programme has never tested.
A body is trained, frozen, shipped. Later a member arrives carrying
information the body never saw, and is added by fitting a head alone. If
that works, a family is a corpus one can extend after the fact; if it does
not, the body must have seen the domain and everything downstream needs a
pretraining step first.

The failsafe is what makes it safe to attempt. A member is a DELTA on a
permanent base head:

    output = base(a) + delta_m(a)

so no member injected means delta is zero and the base answers; an
unidentifiable member means ridge shrinks its coefficients toward zero and
the base answers again. There is no branch, no confidence threshold, no
code path that runs only sometimes. Injection is the addition of something
that may be zero, and the mechanism never changes.

The test is deliberately harsh. The family is trained on classes 0-6 ONLY.
The injected member covers 7-9, which the body has never seen in any form.
Four arms:

  FAMILY MEMBER    a member of the original family, for reference
  INJECTED         the new member's head fitted against the FROZEN body
  OWN BODY         the same member trained from scratch, its own ceiling
  RETRAINED        the body retrained with the new member included, which
                   is what one would have to do if injection fails

and a check that injecting changes nothing for the existing members, which
should hold exactly rather than approximately, since the body is frozen and
the heads are separate.
"""

import numpy as np
import time
import json
from itertools import combinations

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)


_MAPS = {}


def tie(shape, K, tag):
    key = (tuple(shape), K, tag)
    if key not in _MAPS:
        import hashlib
        h = int(hashlib.md5(str(key).encode()).hexdigest()[:8], 16)
        rg = np.random.default_rng(h)
        idx = rg.integers(0, K, int(np.prod(shape)))
        _MAPS[key] = to_dev(idx, np.int64) if _GPU else idx.astype(np.int64)
    return _MAPS[key]


def fold(W, idx, K):
    flat = W.reshape(-1)
    s = xp.zeros(K, DT); c = xp.zeros(K, DT)
    if _GPU:
        import cupyx
        cupyx.scatter_add(s, idx, flat); cupyx.scatter_add(c, idx, xp.ones_like(flat))
    else:
        np.add.at(s, idx, flat); np.add.at(c, idx, np.ones_like(flat))
    return (s / xp.maximum(c, 1.0))[idx].reshape(W.shape)


def folded_init(rg, fan_in, shape, K):
    """Draw so the weights carry the intended scale AFTER folding: the mean
    of n samples has standard deviation sigma/sqrt(n), so projecting a
    fresh matrix shrinks it by the square root of the tying ratio."""
    s = np.sqrt(2.0/fan_in)*np.sqrt(max(1.0, np.prod(shape)/K))
    return rg.normal(0, s, shape)


class Hive:
    """A shared body, a permanent BASE head, and a delta head per member.

    The base head is what answers when no member is active, and a member is
    a correction on top of it rather than a replacement for it. That is the
    whole failsafe: a missing or unidentifiable member contributes zero."""

    def __init__(self, D, W, nc, S, K, seed=0, tag="inj"):
        rg = np.random.default_rng(seed)
        self.D, self.W, self.nc, self.S, self.K = D, W, nc, S, K
        self.i0 = tie((D, W), K, tag+"0")
        self.i1 = tie((W, W), K, tag+"1")
        self.W0 = to_dev(folded_init(rg, D, (D, W), K))
        self.W1 = to_dev(folded_init(rg, W, (W, W), K))
        self.base = to_dev(rg.normal(0, np.sqrt(2.0/W), (W, nc)))
        self.delta = xp.zeros((S, W, nc), DT)      # members start at ZERO
        self.b0 = xp.zeros(W, DT); self.b1 = xp.zeros(W, DT)
        self.bb = xp.zeros(nc, DT); self.bd = xp.zeros((S, nc), DT)
        self.project()

    def project(self):
        self.W0 = fold(self.W0, self.i0, self.K)
        self.W1 = fold(self.W1, self.i1, self.K)

    def body(self, X):
        z1 = X @ self.W0 + self.b0; a1 = xp.maximum(z1, 0)
        z2 = a1 @ self.W1 + self.b1; a2 = xp.maximum(z2, 0)
        return z1, a1, z2, a2

    def logits(self, a2, members=None):
        """base + delta. With members=None only the base answers, which is
        exactly what happens when nothing is injected."""
        lg = a2 @ self.base + self.bb
        if members is None:
            return lg[None]
        return lg[None] + xp.einsum('ni,sio->sno', a2,
                                    self.delta[members]) + self.bd[members][:, None, :]


def train(m, X, Y, masks, epochs, batch, lr, seed, freeze_body=False,
          only=None):
    """Fit the hive. freeze_body leaves the body untouched, which is what
    injection does; only restricts which member deltas move."""
    rg = np.random.default_rng(seed)
    n = X.shape[0]; S = m.S
    sel = np.arange(S) if only is None else np.asarray(only)
    P = [m.W0, m.W1, m.base, m.delta, m.b0, m.b1, m.bb, m.bd]
    M = [xp.zeros_like(p) for p in P]; V = [xp.zeros_like(p) for p in P]
    t = 0
    for ep in range(epochs):
        perm = rg.permutation(n)
        for st in range(0, n, batch):
            b = perm[st:st+batch]
            bi = to_dev(b, np.int64) if _GPU else b
            x = X[bi]; y = Y[bi]; mk = masks[:, bi][sel]
            z1, a1, z2, a2 = m.body(x)
            lg = m.logits(a2, sel)
            e = xp.exp(lg - lg.max(-1, keepdims=True))
            d = (e/e.sum(-1, keepdims=True) - y[None])*mk[:, :, None]
            d = d/max(1, len(b))
            gd = xp.einsum('ni,sno->sio', a2, d)
            gbd = d.sum(1)
            gbase = xp.einsum('ni,sno->io', a2, d)
            gbb = d.sum((0, 1))
            da2 = (xp.einsum('sno,io->ni', d, m.base)
                   + xp.einsum('sno,sio->ni', d, m.delta[sel]))
            G = [None]*8
            G[2] = gbase; G[6] = gbb
            G[3] = xp.zeros_like(m.delta); G[3][sel] = gd
            G[7] = xp.zeros_like(m.bd); G[7][sel] = gbd
            if freeze_body:
                G[0] = xp.zeros_like(m.W0); G[1] = xp.zeros_like(m.W1)
                G[4] = xp.zeros_like(m.b0); G[5] = xp.zeros_like(m.b1)
                G[2] = xp.zeros_like(m.base); G[6] = xp.zeros_like(m.bb)
            else:
                d2 = da2*(z2 > 0)
                G[1] = a1.T @ d2; G[5] = d2.sum(0)
                d1 = (d2 @ m.W1.T)*(z1 > 0)
                G[0] = x.T @ d1; G[4] = d1.sum(0)
            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_ - lr*(M[i]/(1-0.9**t))/(xp.sqrt(V[i]/(1-0.999**t))+1e-8)
            m.W0, m.W1, m.base, m.delta, m.b0, m.b1, m.bb, m.bd = P
            if not freeze_body:
                m.project()
                P[0], P[1] = m.W0, m.W1
    return m


def evaluate(m, X, y, masks, members):
    _, _, _, a2 = m.body(X)
    lg = to_host(m.logits(a2, np.asarray(members)))
    mk = to_host(masks[members]).astype(bool)
    out = []
    for i in range(len(members)):
        s = mk[i]
        out.append(float((lg[i][s].argmax(1) == y[s]).mean()) if s.any() else np.nan)
    return np.array(out)


def load(name="fashion"):
    if name == "fashion":
        from tensorflow import keras
        (a, b), (c, d) = keras.datasets.fashion_mnist.load_data()
        X = np.concatenate([a, c]).reshape(-1, 784).astype(np.float32)/255.0
        y = np.concatenate([b, d]).ravel().astype(np.int64)
    else:
        from sklearn.datasets import load_digits
        dd = load_digits()
        X = dd.data.astype(np.float32)/16.0; y = dd.target.astype(np.int64)
    return X, y


CFG = dict(dataset="fashion", width=64, K=512, n_train=8000, epochs=40,
           batch=128, lr=1e-3, seeds=(0, 1, 2),
           old_classes=(0, 1, 2, 3, 4, 5, 6), new_classes=(7, 8, 9))


def one_seed(X, y, seed, cfg):
    rg = np.random.default_rng(seed)
    old, new = list(cfg["old_classes"]), list(cfg["new_classes"])
    subs = [tuple(sorted(c)) for c in combinations(old, 4)]
    subs = [subs[i] for i in rg.choice(len(subs), 8, replace=False)]
    newsub = tuple(sorted(new))

    keep_old = np.isin(y, old); keep_new = np.isin(y, new)
    io = np.where(keep_old)[0]; inew = np.where(keep_new)[0]
    rg.shuffle(io); rg.shuffle(inew)
    tro, teo = io[:cfg["n_train"]], io[cfg["n_train"]:cfg["n_train"]+3000]
    trn, ten = inew[:cfg["n_train"]//3], inew[cfg["n_train"]//3:][:2000]

    D = X.shape[1]
    def mk(idx, ss):
        return to_dev(np.stack([np.isin(y[idx], s) for s in ss]).astype(np.float32))
    Y = lambda idx: to_dev(np.eye(10, dtype=np.float32)[y[idx]])

    Xtro, Xteo = to_dev(X[tro]), to_dev(X[teo])
    Xtrn, Xten = to_dev(X[trn]), to_dev(X[ten])
    S = len(subs) + 1                      # the new member is the last slot
    allsubs = subs + [newsub]

    # 1. the family, trained on the OLD classes only
    m = Hive(D, cfg["width"], 10, S, cfg["K"], seed)
    train(m, Xtro, Y(tro), mk(tro, allsubs), cfg["epochs"], cfg["batch"],
          cfg["lr"], seed, only=list(range(len(subs))))
    fam_before = evaluate(m, Xteo, y[teo], mk(teo, allsubs),
                          list(range(len(subs))))

    # 2. INJECTION: fit the new member's head against the FROZEN body
    train(m, Xtrn, Y(trn), mk(trn, allsubs), cfg["epochs"], cfg["batch"],
          cfg["lr"], seed+1, freeze_body=True, only=[S-1])
    inj = evaluate(m, Xten, y[ten], mk(ten, allsubs), [S-1])[0]
    fam_after = evaluate(m, Xteo, y[teo], mk(teo, allsubs),
                         list(range(len(subs))))

    # 3. the new member with a body of its own: its ceiling
    m2 = Hive(D, cfg["width"], 10, 1, cfg["K"], seed+2, tag="own")
    train(m2, Xtrn, Y(trn), mk(trn, [newsub]), cfg["epochs"], cfg["batch"],
          cfg["lr"], seed+2)
    own = evaluate(m2, Xten, y[ten], mk(ten, [newsub]), [0])[0]

    # 4. the body retrained with the new member included
    m3 = Hive(D, cfg["width"], 10, S, cfg["K"], seed+3, tag="re")
    Xb = xp.concatenate([Xtro, Xtrn])
    yb = np.concatenate([y[tro], y[trn]])
    Yb = to_dev(np.eye(10, dtype=np.float32)[yb])
    mkb = to_dev(np.stack([np.isin(yb, s) for s in allsubs]).astype(np.float32))
    train(m3, Xb, Yb, mkb, cfg["epochs"], cfg["batch"], cfg["lr"], seed+3)
    retr = evaluate(m3, Xten, y[ten], mk(ten, allsubs), [S-1])[0]

    return dict(family=float(np.nanmean(fam_before)), injected=float(inj),
                own_body=float(own), retrained=float(retr),
                family_after=float(np.nanmean(fam_after)))


def main(**over):
    CFG.update(over)
    t0 = time.time()
    print("=" * 78)
    print("CAN A BODY SUPPORT A MEMBER IT NEVER SAW?")
    print("=" * 78)
    print(f"  backend: {'cupy (GPU)' if _GPU else 'numpy (CPU)'}")
    for k, v in CFG.items():
        print(f"  {k:12s} = {v}")
    print(f"\n  the family is trained on classes {CFG['old_classes']} only.")
    print(f"  the injected member covers {CFG['new_classes']}, which the body")
    print(f"  has never seen in any form.")
    print("=" * 78, flush=True)

    X, y = load(CFG["dataset"])
    runs = []
    for s in CFG["seeds"]:
        r = one_seed(X, y, s, CFG)
        runs.append(r)
        print(f"  seed {s}: " + "  ".join(f"{k} {v:.4f}" for k, v in r.items())
              + f"   [{time.time()-t0:.0f}s]", flush=True)
        json.dump(runs, open("injection.json", "w"), indent=2)

    keys = list(runs[0])
    mean = {k: float(np.mean([r[k] for r in runs])) for k in keys}
    sd = {k: float(np.std([r[k] for r in runs])) for k in keys}
    print("\n" + "=" * 78)
    print("  READOUT")
    print("=" * 78)
    lab = {"family": "existing family member", "injected": "INJECTED, frozen body",
           "own_body": "new member, own body", "retrained": "body retrained with it",
           "family_after": "family after injection"}
    for k in ("family", "family_after", "injected", "own_body", "retrained"):
        print(f"  {lab[k]:>26s} {mean[k]:.4f}  sd {sd[k]:.4f}")
    dist = abs(mean["family"] - mean["family_after"])
    print(f"\n  injecting disturbed the existing members by {dist:.6f}")
    print(f"  (it should be EXACTLY zero: the body is frozen and each")
    print(f"   member's delta is its own, so nothing shared can move)")
    gap = mean["injected"] - mean["own_body"]
    gap2 = mean["injected"] - mean["retrained"]
    print(f"\n  the injected member against its own ceiling: {gap:+.4f}")
    print(f"  against a body retrained to include it:      {gap2:+.4f}")
    print()
    tol = 2*max(sd.values())
    if gap > -tol and gap2 > -tol:
        print("  INJECTION WORKS. A frozen body supports a member whose")
        print("  classes it never saw, as well as a body built for it. A")
        print("  family can be extended after the fact, which is what an")
        print("  injection architecture needs and what nothing here had")
        print("  tested.")
    elif gap2 > -tol:
        print("  INJECTION MATCHES RETRAINING but not a dedicated model, so")
        print("  the shared body costs something a private one would not —")
        print("  and it costs the same whether the member was there from the")
        print("  start or added later, which is the useful half.")
    else:
        print("  INJECTION FALLS SHORT. A frozen body does not support a")
        print("  member whose domain it never saw, so the body must see the")
        print("  domain first and an injection architecture needs a")
        print("  pretraining step rather than an empty one.")
    print(f"\n  total {time.time()-t0:.0f}s; wrote injection.json")


if __name__ == "__main__":
    main()