File size: 14,709 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
"""
THE HEAD HAS BEEN MOST OF THE MODEL ALL ALONG.

Every script in this programme ends with a head that reads EVERY hidden
unit: 3,136 of them here, so 31,370 parameters. Measured against it, the
folded body is 1.9% of a five-layer model at 28x28 and 82.5% at 7x7 — and
the depth sweep that produced those numbers could not separate "deeper
suits a larger grid" from "layers are cheap at a larger grid", because
scaling channels inversely with area made a layer cost 36,865 values at
7x7 and 145 at 28x28.

A conventional convolutional network does not do this. It POOLS over
spatial positions before the head, so the head reads one number per channel
rather than one per channel per position. Here that would take the head
from 31,370 parameters to CHANNELS x 10 — 650 at 7x7, 170 at 14x14, 50 at
28x28. The body would become the model instead of a rounding error.

Nothing in this programme has ever used pooling. That is not a small
omission: it is most of why these models are head-dominated, most of why
the storage ratios were inflated, and possibly why depth has looked so
weak.

So the same depth sweep is run twice, with and without pooling before the
head, and three things are read from it.

  DOES POOLING COST ACCURACY?   it discards where a feature fired and
                                keeps only how much, which on a centred
                                dataset may be free and may not be
  DOES IT CHANGE THE DEPTH STORY?  if depth only ever looked useful
                                because layers were cheap relative to a
                                huge head, pooling should change which
                                depth wins
  WHAT DOES IT COST TO STORE?   the head shrinks by a factor of the grid
                                area, which is 49 to 784 times

The honest expectation is that pooling costs some accuracy on a task this
small, and that the question is whether it costs less than the storage it
saves.
"""

import numpy as np
import time
import json
import os

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, no*(c_in*k*k)


DETERMINISTIC = True
_FIXED = {}


class FixedScatter:
    """Fixed-order accumulation, so a rerun reproduces exactly."""

    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):
    if not DETERMINISTIC:
        g = xp.zeros(K, DT)
        if _GPU:
            import cupyx
            cupyx.scatter_add(g, idx, dW.reshape(-1))
        else:
            np.add.at(g, idx, dW.reshape(-1))
        return g
    key = (id(idx), K)
    if key not in _FIXED:
        _FIXED[key] = FixedScatter(idx, K)
    return _FIXED[key](dW)


def train(Xtr, Ytr, Xte, yte, g, chan, depth, cfg, seed, pool=False):
    D = Xtr.shape[1]
    rg = np.random.default_rng(seed)
    layers, cin = [], 1
    for l in range(depth):
        idx, K, no, macs = windowed(g, cin, 3, chan)
        layers.append(dict(idx=to_dev(idx, np.int32) if _GPU else idx,
                           K=K, out=no, macs=macs,
                           ins=D if l == 0 else layers[-1]["out"],
                           taps=cin*9))
        cin = chan
    L = 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]
    hw = chan if pool else layers[-1]["out"]
    P += [to_dev(rg.normal(0, np.sqrt(2.0/hw), (hw, 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)

    npos = g*g

    def fwd(x, keep=False):
        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)
            if keep:
                cache.append((h, W, var, zn, zs))
            h = a
        if pool:
            # average over spatial positions: the head then reads one
            # number per channel instead of one per channel per position
            h = h.reshape(h.shape[0], chan, npos).mean(2)
        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, keep=True)
            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
            if pool:
                dh = xp.broadcast_to(dh[:, :, None]/npos,
                                     (dh.shape[0], chan, npos)
                                     ).reshape(dh.shape[0], chan*npos)
            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)
    out = []
    for s in range(0, Xte.shape[0], 4096):
        h, _ = fwd(Xte[s:s+4096])
        out.append(to_host(h @ P[HEAD] + P[OB]))
    acc = float((np.concatenate(out).argmax(1) == yte).mean())
    # COUNT THE HEAD. It is dense and unfolded, and on Fashion at 14x14 it
    # is 99.5% of a one-layer model's parameters — reporting the folded
    # body alone made a 15% storage difference look like 65x.
    head = (chan if pool else layers[-1]["out"])*10 + 10
    return (acc, sum(l["K"] for l in layers) + head,
            sum(l["macs"] for l in layers) + head - 10, head)


def load(grid, 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 grid != 28:
        s = 28//grid
        X = X.reshape(-1, grid, s, 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, f(X[te]), y[te]


CFG = dict(n_train=20000, batch=128, lr=1e-3, epochs=40, seeds=(0, 1, 2),
           hidden_target=3136, grids=(7, 14, 28), depths=(1, 2, 3, 4, 5))


def main(**over):
    CFG.update(over)
    t0 = time.time()
    print("=" * 78)
    print("THE HEAD HAS BEEN MOST OF THE MODEL ALL ALONG")
    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  channels scale inversely with area to hold the hidden width")
    print(f"  near {CFG['hidden_target']}, so no arm is favoured by having a")
    print(f"  bigger matrix product:")
    for g in CFG["grids"]:
        c = max(1, CFG["hidden_target"]//(g*g))
        print(f"    {g:2d}x{g:<2d} -> {c:3d} channels, hidden {c*g*g:,}")
    print(f"\n  reach says L >= (n-1)/2 to cover the grid: "
          + ", ".join(f"{g}x{g} needs {int(np.ceil((g-1)/2))}"
                      for g in CFG["grids"]))
    print("=" * 78, flush=True)

    res = {}
    for g in CFG["grids"]:
        chan = max(1, CFG["hidden_target"]//(g*g))
        Xtr, Ytr, Xte, yte = load(g, CFG)
        Xtr, Ytr, Xte = to_dev(Xtr), to_dev(Ytr), to_dev(Xte)
        print(f"\n  {g}x{g}, {chan} channels")
        print(f"  (the head alone is "
              f"{chan*g*g*10 + 10:,} parameters, counted in every row)")
        print(f"  {'head':>7s} {'L':>4s} {'TOTAL vals':>11s} "
              f"{'multiplies':>12s} {'accuracy':>9s} {'sd':>7s} {'reach':>7s}")
        for pool, L in [(p, L) for p in (False, True)
                        for L in CFG["depths"]]:
            # the fixed-order tables are keyed by the index array's identity
            # and each arm builds new ones, so the cache would grow across
            # fifteen arms until the device ran out
            _FIXED.clear()
            if _GPU:
                _cp.get_default_memory_pool().free_all_blocks()
            out = [train(Xtr, Ytr, Xte, yte, g, chan, L, CFG, s,
                         pool=pool)
                   for s in CFG["seeds"]]
            acc = [o[0] for o in out]
            K, mc, hd = out[0][1], out[0][2], out[0][3]
            reach = min(g, 2*L+1)
            res[f"{g}/{L}/{int(pool)}"] = dict(acc=float(np.mean(acc)),
                                   sd=float(np.std(acc)), K=K, macs=mc,
                                   reach=reach, chan=chan, head=hd)
            print(f"  {'pooled' if pool else 'flat':>7s} {L:4d} "
                  f"{K:11,} {mc:12,} {np.mean(acc):9.4f} "
                  f"{np.std(acc):7.4f} {reach:5d}x{reach:<2d}"
                  f"   [{time.time()-t0:.0f}s]", flush=True)
            json.dump(res, open("pooling.json", "w"), indent=2)

    print("\n" + "=" * 78)
    print("  WHAT DOES POOLING COST, AND WHAT DOES IT SAVE?")
    print("=" * 78)
    print(f"  {'grid':>5s} {'L':>3s} {'flat':>8s} {'pooled':>8s} "
          f"{'accuracy':>9s} {'head flat':>10s} {'head pooled':>12s} "
          f"{'saved':>7s}")
    for g in CFG["grids"]:
        for L in CFG["depths"]:
            a = res.get(f"{g}/{L}/0"); b = res.get(f"{g}/{L}/1")
            if not (a and b):
                continue
            print(f"  {g:5d} {L:3d} {a['acc']:8.4f} {b['acc']:8.4f} "
                  f"{b['acc']-a['acc']:+9.4f} {a['head']:10,} "
                  f"{b['head']:12,} {a['head']/b['head']:6.0f}x")

    print("\n" + "=" * 78)
    print("  READOUT")
    print("=" * 78)
    sd = max(r["sd"] for r in res.values())
    d = [res[f"{g}/{L}/1"]["acc"] - res[f"{g}/{L}/0"]["acc"]
         for g in CFG["grids"] for L in CFG["depths"]
         if f"{g}/{L}/1" in res and f"{g}/{L}/0" in res]
    d = np.array(d)
    print(f"  pooling changes accuracy by {d.mean():+.4f} on average, "
          f"range {d.min():+.4f} to {d.max():+.4f}")
    print(f"  seed spread (worst) {sd:.4f}\n")
    print(f"  best depth, flat head vs pooled:")
    for g in CFG["grids"]:
        bf = max(CFG["depths"], key=lambda L: res[f"{g}/{L}/0"]["acc"])
        bp = max(CFG["depths"], key=lambda L: res[f"{g}/{L}/1"]["acc"])
        print(f"    {g:2d}x{g:<2d}  flat {bf}   pooled {bp}"
              + ("   <- the depth story CHANGES" if bf != bp else ""))
    print()
    if d.mean() > -2*sd:
        print("  POOLING IS FREE. The head shrinks by the grid area and")
        print("  accuracy does not move, so every model in this programme")
        print("  has been carrying a head one to two orders of magnitude")
        print("  larger than it needed — which is most of why the storage")
        print("  ratios were inflated and the body looked negligible.")
    elif d.mean() > -0.02:
        print(f"  POOLING COSTS {abs(d.mean()):.4f} AND SAVES THE HEAD. Whether")
        print("  that is worth it depends on the budget, but it should be a")
        print("  stated choice rather than an omission — and the ratios in")
        print("  the paper were computed without it.")
    else:
        print(f"  POOLING COSTS TOO MUCH ({d.mean():+.4f}). Discarding WHERE a")
        print("  feature fired matters on this task, so the large head is")
        print("  earning its storage and the programme's design was right by")
        print("  accident rather than wrong.")
    print(f"\n  total {time.time()-t0:.0f}s; wrote pooling.json")


if __name__ == "__main__":
    main()