File size: 15,905 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 | """
ORDERED, CRITICAL, OR CHAOTIC — WHERE DO THESE STACKS ACTUALLY SIT?
A network has a critical initialisation scale where a signal neither dies
nor explodes as it passes through layers, and networks near it train far
deeper than networks that are not. Below it a stack is ORDERED: each layer
contracts, and by the fourth or fifth there is nothing left to work with.
Above it the stack is CHAOTIC: nearby inputs diverge and nothing
generalises.
This programme has been there once without naming it. The fold-aware
initialisation fix was exactly a criticality repair — getting the scale
wrong by the square root of the tying ratio made every weight 85 times too
large, deep in the chaotic regime, and training collapsed to 0.168 where
the corrected scale gave 0.390. It was recorded as a bug. It is the
criticality condition, found the hard way.
And there is a live question it might answer. Depth stops paying early
here: three layers beat five on Fashion, and five layers monotonically LOST
accuracy at 7x7. If these stacks are sub-critical, the fourth and fifth
layers are receiving a signal that has already decayed, and that is a
different diagnosis from "the model has enough capacity" — it would mean
depth is being wasted rather than unneeded.
TWO QUANTITIES, measured per layer on real activations.
CHI, the expansion factor. Perturb a layer's input by a small random
vector and see how much the output moves: chi = E[||J v||^2 / ||v||^2].
Below one the layer contracts, above one it expands, near one it
preserves. This is the mean squared singular value of the layer's
Jacobian, estimated by probing rather than by forming it.
PARTICIPATION RATIO, how many directions survive. A layer can preserve
the average scale while collapsing everything onto a few directions, and
that is invisible to chi. Estimated from the Gram matrix of the probe
responses: near one means the layer projects onto a line, near the probe
count means it preserves the space.
Both are measured AT INITIALISATION and AFTER TRAINING, because a stack
that starts critical and trains itself into contraction is a different
story from one that was never critical at all.
Layer normalisation pins the forward norm, so measuring how big the
activations get would say nothing. The Jacobian is what carries the answer.
"""
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)
class Stack:
def __init__(self, g, c_in, chan, depth, nc, seed):
self.g, self.chan, self.depth = g, chan, depth
self.D = c_in*g*g
rg = np.random.default_rng(seed)
self.layers, cin = [], c_in
for l in range(depth):
idx, K, no = windowed(g, cin, 3, chan)
self.layers.append(dict(
idx=to_dev(idx, np.int32) if _GPU else idx, K=K, out=no,
ins=self.D if l == 0 else self.layers[-1]["out"],
taps=cin*9))
cin = chan
L = depth
self.P = []
for l in self.layers:
v = rg.normal(0, np.sqrt(2.0/l["taps"]), l["K"]).astype(np.float32)
v[-1] = 0.0
self.P.append(to_dev(v))
self.P += [xp.ones(l["out"], DT) for l in self.layers]
self.P += [xp.zeros(l["out"], DT) for l in self.layers]
self.P += [to_dev(rg.normal(0, np.sqrt(2.0/self.layers[-1]["out"]),
(self.layers[-1]["out"], nc))),
xp.zeros(nc, DT)]
self.L = L
self.M = [xp.zeros_like(p) for p in self.P]
self.V = [xp.zeros_like(p) for p in self.P]
self.t = 0
def layer(self, li, h):
"""One layer, exactly as training applies it."""
L, P, l = self.L, self.P, self.layers[li]
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)
return xp.maximum(zn*P[L+li] + P[2*L+li], 0)
def acts(self, x):
"""The activations entering each layer, and leaving the last."""
out = [x]; h = x
for li in range(self.L):
h = self.layer(li, h)
out.append(h)
return out
def acc(self, Xte, yte):
L = self.L
preds = []
for s in range(0, Xte.shape[0], 4096):
h = self.acts(Xte[s:s+4096])[-1]
preds.append(to_host(h @ self.P[3*L] + self.P[3*L+1]))
return float((np.concatenate(preds).argmax(1) == yte).mean())
def fit(self, Xtr, Ytr, cfg, seed, epochs):
L = self.L
rg = np.random.default_rng(seed + 991)
n = Xtr.shape[0]
for ep in range(epochs):
perm = rg.permutation(n)
for st in range(0, n, cfg["batch"]):
b = perm[st:st+cfg["batch"]]
x = Xtr[b]; y = Ytr[b]
cache = []; h = x
for li, l in enumerate(self.layers):
W = self.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*self.P[L+li] + self.P[2*L+li]
a = xp.maximum(zs, 0)
cache.append((h, W, var, zn, zs)); h = a
lg = h @ self.P[3*L] + self.P[3*L+1]
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 self.P]
G[3*L] = h.T @ d; G[3*L+1] = d.sum(0)
dh = d @ self.P[3*L].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*self.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, self.layers[li]["idx"],
self.layers[li]["K"])
if li > 0:
dh = dz @ W.T
self.t += 1
for i, (p_, gr) in enumerate(zip(self.P, G)):
self.M[i] = 0.9*self.M[i] + 0.1*gr
self.V[i] = 0.999*self.V[i] + 0.001*gr*gr
self.P[i] = p_ - cfg["lr"]*(self.M[i]/(1-0.9**self.t)) \
/ (xp.sqrt(self.V[i]/(1-0.999**self.t))+1e-8)
return self
def probe(model, X, n_probe=48, rel=1e-3, seed=0):
"""chi and participation ratio per layer, by finite differences.
With ReLU the map is piecewise linear, so a small enough step is exact
as long as no unit changes sign — and small enough is set relative to
the activation's own scale rather than absolutely, because layer
normalisation fixes that scale and a fixed epsilon would be wrong at
the input and right nowhere else."""
rg = np.random.default_rng(seed)
A = model.acts(X)
out = []
for li in range(model.L):
h = A[li]
base = model.layer(li, h)
hn = float(to_host(xp.linalg.norm(h, axis=1).mean()))
R = []
for p in range(n_probe):
v = to_dev(rg.normal(size=h.shape))
v = v/xp.linalg.norm(v, axis=1, keepdims=True)
eps = rel*hn
r = (model.layer(li, h + eps*v) - base)/eps
R.append(r)
# chi: how much a unit perturbation grows
norms = xp.stack([xp.linalg.norm(r, axis=1)**2 for r in R])
chi = float(to_host(norms.mean()))
# participation ratio of the response Gram matrix, averaged over
# examples: how many directions the layer actually keeps
prs = []
for i in range(0, min(16, h.shape[0])):
M = xp.stack([r[i] for r in R])
Gm = to_host(M @ M.T)
w = np.linalg.eigvalsh(Gm)
w = np.clip(w, 0, None)
if w.sum() > 0:
prs.append(float(w.sum()**2/(w**2).sum()))
out.append(dict(chi=chi, pr=float(np.mean(prs)) if prs else np.nan,
n_probe=n_probe))
return out
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"]+5000]
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(grid=14, c_in=1, chan=16, n_train=20000, batch=128, lr=1e-3,
epochs=40, depth=5, n_probe=48, seed=0)
def main(**over):
CFG.update(over)
t0 = time.time()
print("=" * 78)
print("ORDERED, CRITICAL, OR CHAOTIC?")
print("=" * 78)
print(f" backend: {'cupy (GPU)' if _GPU else 'numpy (CPU)'}")
for k, v in CFG.items():
print(f" {k:9s} = {v}")
print(f"\n chi is the mean squared singular value of a layer's Jacobian:")
print(f" chi < 1 the layer CONTRACTS, and a signal dies with depth")
print(f" chi ~ 1 CRITICAL, the regime where deep stacks train")
print(f" chi > 1 the layer EXPANDS, and nearby inputs diverge")
print(f" the participation ratio says how many of {CFG['n_probe']} probe")
print(f" directions survive — a layer can hold the scale and still")
print(f" collapse everything onto a line")
print("=" * 78, flush=True)
Xtr, Ytr, Xte, yte = load(CFG)
Xtr, Ytr, Xte = to_dev(Xtr), to_dev(Ytr), to_dev(Xte)
probe_x = Xte[:256]
m = Stack(CFG["grid"], CFG["c_in"], CFG["chan"], CFG["depth"], 10,
CFG["seed"])
at_init = probe(m, probe_x, CFG["n_probe"], seed=CFG["seed"])
acc0 = m.acc(Xte, yte)
print(f"\n at initialisation (accuracy {acc0:.4f}):")
print(f" {'layer':>6s} {'chi':>8s} {'cumulative':>11s} "
f"{'participation':>14s}")
cum = 1.0
for i, r in enumerate(at_init):
cum *= r["chi"]
print(f" {i+1:6d} {r['chi']:8.4f} {cum:11.3e} {r['pr']:14.1f}")
m.fit(Xtr, Ytr, CFG, CFG["seed"], CFG["epochs"])
trained = probe(m, probe_x, CFG["n_probe"], seed=CFG["seed"])
acc1 = m.acc(Xte, yte)
print(f"\n after {CFG['epochs']} epochs (accuracy {acc1:.4f}):")
print(f" {'layer':>6s} {'chi':>8s} {'cumulative':>11s} "
f"{'participation':>14s} {'chi moved':>10s}")
cum = 1.0
for i, r in enumerate(trained):
cum *= r["chi"]
print(f" {i+1:6d} {r['chi']:8.4f} {cum:11.3e} {r['pr']:14.1f} "
f"{r['chi']-at_init[i]['chi']:+10.4f}")
json.dump(dict(init=at_init, trained=trained, acc_init=acc0,
acc_trained=acc1), open("criticality.json", "w"), indent=2)
print("\n" + "=" * 78)
print(" READOUT")
print("=" * 78)
ci = np.array([r["chi"] for r in trained])
pr = np.array([r["pr"] for r in trained])
cum = float(np.prod(ci))
print(f" chi by layer, trained: "
+ " ".join(f"{c:.3f}" for c in ci))
print(f" cumulative through {CFG['depth']} layers: {cum:.3e}")
print(f" participation: " + " ".join(f"{p:.0f}" for p in pr)
+ f" (of {CFG['n_probe']} probes)")
print()
if cum < 0.1:
print(f" SUB-CRITICAL — THE STACK CONTRACTS. A perturbation at the")
print(f" input is {1/cum:.0f} times smaller by the last layer, so the")
print(f" later layers see a signal that has largely died. That is a")
print(f" different diagnosis from 'enough capacity': depth here is")
print(f" being WASTED rather than being unneeded, and holding chi")
print(f" near one is a specific, testable repair.")
elif cum > 10:
print(f" SUPER-CRITICAL — THE STACK EXPANDS by {cum:.1f} times over")
print(f" {CFG['depth']} layers, so nearby inputs diverge as they pass")
print(f" through and the later layers are amplifying noise.")
else:
print(f" NEAR CRITICAL. The stack neither dies nor explodes over")
print(f" {CFG['depth']} layers, so signal propagation is NOT why depth")
print(f" stops paying — the ceiling is somewhere else, and the")
print(f" edge-of-chaos angle has nothing to fix here.")
drop = pr[0]/max(pr[-1], 1e-9)
print()
if drop > 3:
print(f" AND THE RANK COLLAPSES: {pr[0]:.0f} directions at the first")
print(f" layer against {pr[-1]:.0f} at the last, a factor of {drop:.1f}.")
print(f" The stack is funnelling everything onto a few directions,")
print(f" which chi alone would not have shown.")
else:
print(f" and the rank holds up: {pr[0]:.0f} directions at the first")
print(f" layer against {pr[-1]:.0f} at the last, so the layers are not")
print(f" collapsing the space.")
print(f"\n total {time.time()-t0:.0f}s; wrote criticality.json")
if __name__ == "__main__":
main()
|