torch-type-nn 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,546 @@
1
+ """The type-nn adapter: :class:`~torch_type_nn.network.TypeNN` as a :class:`Scalable`.
2
+
3
+ Addresses:
4
+
5
+ * ``Item(WIDTH, (i, k))``: output coordinate ``k`` of layer ``i`` (the unit
6
+ and the column of layer ``i + 1`` that reads it); sites are junctions ``i``.
7
+ * ``Item(DEGREE, (i, k, r))``: Or slot ``r`` of unit ``k`` of layer ``i``;
8
+ sites are units ``(i, k)``.
9
+ * ``Item(DEPTH, (i,))``: layer ``i``; sites are gaps ``g`` (in front of layer
10
+ ``g``), counted in the stack without the depth probe.
11
+
12
+ Width grows on *every* junction (``width_through_depth_probe=True``, the
13
+ default): a width probe on a junction that crosses the depth probe passes
14
+ through the probe layer on a carrier Or (w = e_k, b = 0, a = 1) and the real
15
+ consumer beyond it reads it with weights of exactly 0. The C reference skips
16
+ such junctions, which leaves a network born with depth <= 2 no width site
17
+ while a depth probe exists (see docs/DIFFERENCES.md);
18
+ ``width_through_depth_probe=False`` reproduces it.
19
+
20
+ The numerical content is ``type_nn_scale.c``: distances from the identity,
21
+ exact ablations, affine folds for depth edits and the exact parameter count
22
+ of a trial model (see docs/DIFFERENCES.md).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import math
28
+ from collections.abc import Sequence
29
+
30
+ import torch
31
+
32
+ from ..layer import AndOr, uniform
33
+ from ..network import TypeNN
34
+ from ..protocol import DEGREE, DEPTH, WIDTH, EditContext, Item, Trial
35
+
36
+ __all__ = ["TypeNNAdapter", "dev_or", "dev_column", "dev_layer"]
37
+
38
+
39
+ # ------------------------------------------------------- distances from identity
40
+
41
+ def dev_or(layer: AndOr, k: int, r: int) -> float:
42
+ """RMS distance of an Or from the unit identity (w = 0, b = 1, a = 1)."""
43
+ w, b, a = (layer.weight.detach()[k, r], layer.bias.detach()[k, r],
44
+ layer.assembly.detach()[k, r])
45
+ s = float((w * w).sum() + (b - 1) ** 2 + (a - 1) ** 2)
46
+ return math.sqrt(s / (layer.in_features + 2))
47
+
48
+
49
+ def dev_column(consumer: AndOr, j: int) -> float:
50
+ """RMS of the weights with which ``consumer`` reads coordinate ``j``."""
51
+ live = consumer.mask
52
+ c = int(live.sum())
53
+ if not c:
54
+ return 0.0
55
+ w = consumer.weight.detach()[..., j][live]
56
+ return math.sqrt(float((w * w).sum()) / c)
57
+
58
+
59
+ def _sq_one(layer: AndOr) -> torch.Tensor:
60
+ w, b, a = layer.weight.detach(), layer.bias.detach(), layer.assembly.detach()
61
+ return (w * w).sum(-1) + (b - 1) ** 2 + (a - 1) ** 2 # (m, R)
62
+
63
+
64
+ def _sq_carrier(layer: AndOr) -> torch.Tensor:
65
+ # carrier of coordinate k: w = e_k, b = 0, a = 1 (the Or equals x_k)
66
+ m = layer.out_features
67
+ eye = torch.eye(m, layer.in_features, dtype=layer.weight.dtype,
68
+ device=layer.weight.device).unsqueeze(1) # (m, 1, n)
69
+ d = layer.weight.detach() - eye
70
+ return (d * d).sum(-1) + layer.bias.detach() ** 2 + (layer.assembly.detach() - 1) ** 2
71
+
72
+
73
+ def dev_layer(layer: AndOr) -> float:
74
+ """RMS distance from the identity layer; inf if the layer is not square."""
75
+ if layer.in_features != layer.out_features:
76
+ return math.inf
77
+ live = layer.mask
78
+ one, car = _sq_one(layer), _sq_carrier(layer)
79
+ all_one = torch.where(live, one, torch.zeros_like(one)).sum(1, keepdim=True)
80
+ v = torch.where(live, all_one - one + car, torch.full_like(one, math.inf))
81
+ s = float(v.min(1).values.sum()) if layer.degree else math.inf
82
+ c = int(live.sum()) * (layer.in_features + 2)
83
+ return math.sqrt(s / c) if c else math.inf
84
+
85
+
86
+ # ------------------------------------------------------- exact ablations
87
+
88
+ def _snap(layer: AndOr, *names: str) -> dict:
89
+ return {n: getattr(layer, n).detach().clone() for n in names}
90
+
91
+
92
+ @torch.no_grad()
93
+ def _restore(layer: AndOr, snap: dict) -> None:
94
+ for n, v in snap.items():
95
+ getattr(layer, n).copy_(v)
96
+
97
+
98
+ @torch.no_grad()
99
+ def _ablate_or(layer: AndOr, k: int, r: int):
100
+ s = (layer.weight[k, r].clone(), layer.bias[k, r].clone(), layer.assembly[k, r].clone())
101
+ layer.weight[k, r] = 0.0
102
+ layer.bias[k, r] = 1.0
103
+ layer.assembly[k, r] = 1.0
104
+
105
+ def undo() -> None:
106
+ with torch.no_grad():
107
+ layer.weight[k, r], layer.bias[k, r], layer.assembly[k, r] = s
108
+ return undo
109
+
110
+
111
+ @torch.no_grad()
112
+ def _ablate_col(consumer: AndOr, j: int):
113
+ s = consumer.weight[..., j].clone()
114
+ consumer.weight[..., j] = 0.0
115
+
116
+ def undo() -> None:
117
+ with torch.no_grad():
118
+ consumer.weight[..., j] = s
119
+ return undo
120
+
121
+
122
+ @torch.no_grad()
123
+ def _ablate_layer(layer: AndOr):
124
+ """Reset a square layer to its identity: per unit the live Or nearest e_k
125
+ becomes the carrier, every other Or the unit identity."""
126
+ snap = _snap(layer, "weight", "bias", "assembly")
127
+ live = layer.mask
128
+ d = torch.where(live, _sq_carrier(layer) - _sq_one(layer),
129
+ torch.full_like(layer.bias, math.inf))
130
+ best = d.argmin(1) # (m,)
131
+ layer.weight.masked_fill_(live.unsqueeze(-1), 0.0)
132
+ layer.bias.masked_fill_(live, 1.0)
133
+ layer.assembly.fill_(1.0)
134
+ ks = torch.arange(layer.out_features, device=best.device)
135
+ has = live[ks, best]
136
+ ks, best = ks[has], best[has]
137
+ layer.weight[ks, best, ks] = 1.0
138
+ layer.bias[ks, best] = 0.0
139
+ return lambda: _restore(layer, snap)
140
+
141
+
142
+ # ------------------------------------------------------- folds
143
+
144
+ def fold_from_stats(at: AndOr, u_to_x: bool) -> tuple[torch.Tensor, torch.Tensor]:
145
+ """A type-identity layer emits u = F(x), which is x only to first order.
146
+ Insert (u_to_x): x ~ alpha u + beta. Remove: u ~ alpha x + beta.
147
+ Measured on the epoch that just ran."""
148
+ n = at.in_features
149
+ one, zero = at.weight.new_ones(n), at.weight.new_zeros(n)
150
+ ns = float(at.stat_ns)
151
+ if ns < 2:
152
+ return one, zero
153
+ mx, mu = at.stat_sx / ns, at.stat_su / ns
154
+ vxx = at.stat_sxx / ns - mx * mx
155
+ vuu = at.stat_suu / ns - mu * mu
156
+ vxu = at.stat_sxu / ns - mx * mu
157
+ if u_to_x:
158
+ alpha = torch.where(vuu > 0, vxu / torch.where(vuu > 0, vuu, one), one)
159
+ beta = mx - alpha * mu
160
+ else:
161
+ alpha = torch.where(vxx > 0, vxu / torch.where(vxx > 0, vxx, one), one)
162
+ beta = mu - alpha * mx
163
+ bad = ~(alpha > 0) | ~torch.isfinite(alpha)
164
+ return torch.where(bad, one, alpha), torch.where(bad, zero, beta)
165
+
166
+
167
+ @torch.no_grad()
168
+ def fold_apply(c: AndOr, alpha: torch.Tensor, beta: torch.Tensor) -> None:
169
+ J = min(alpha.shape[0], c.in_features)
170
+ a, b = alpha[:J], beta[:J]
171
+ c.bias += (c.weight[..., :J] * b).sum(-1)
172
+ c.weight[..., :J] *= a
173
+ c.adam_m_weight[..., :J] /= a
174
+ c.adam_v_weight[..., :J] /= a * a
175
+
176
+
177
+ # ------------------------------------------------------- the adapter
178
+
179
+ class TypeNNAdapter:
180
+ """:class:`TypeNN` under the :class:`~torch_type_nn.protocol.Scalable` protocol."""
181
+
182
+ def __init__(self, model: TypeNN, *, width_through_depth_probe: bool = True) -> None:
183
+ self.model = model
184
+ self.through = bool(width_through_depth_probe)
185
+ self._sink: list | None = None
186
+ self._tracking = False
187
+
188
+ @property
189
+ def layers(self):
190
+ return self.model.layers
191
+
192
+ # ------------------------------------------------------------ bookkeeping
193
+
194
+ def num_params(self) -> int:
195
+ return self.model.num_params()
196
+
197
+ def set_tracking(self, on: bool) -> None:
198
+ self._tracking = on
199
+ for layer in self.layers:
200
+ layer.track_stats = on
201
+
202
+ def reset_stats(self) -> None:
203
+ for layer in self.layers:
204
+ layer.reset_stats()
205
+
206
+ def set_edit_sink(self, sink: list | None) -> None:
207
+ self._sink = sink
208
+ for layer in self.layers:
209
+ layer.edit_sink = sink
210
+
211
+ def finalize(self) -> None:
212
+ for layer in self.layers:
213
+ layer.compact()
214
+
215
+ # ----------------------------------------------------------------- probes
216
+
217
+ def _probe_layer(self) -> int | None:
218
+ for i, layer in enumerate(self.layers):
219
+ if layer.probe:
220
+ return i
221
+ return None
222
+
223
+ @staticmethod
224
+ def _probe_unit(layer: AndOr) -> int | None:
225
+ k = layer.unit_probe.nonzero()
226
+ return int(k[0]) if len(k) else None
227
+
228
+ @staticmethod
229
+ def _probe_or(layer: AndOr, k: int) -> int | None:
230
+ r = (layer.or_probe[k] & layer.mask[k]).nonzero()
231
+ return int(r[0]) if len(r) else None
232
+
233
+ def _crosses_probe(self, i: int) -> bool:
234
+ return self.through and self.layers[i + 1].probe
235
+
236
+ def _consumer(self, i: int) -> AndOr:
237
+ """The layer that reads the output coordinates of layer i."""
238
+ return self.layers[i + 2] if self._crosses_probe(i) else self.layers[i + 1]
239
+
240
+ def probe_sites(self, axis: str) -> Sequence:
241
+ L = self.layers
242
+ if axis == WIDTH:
243
+ if self.through: # every non-probe producer; the probe passes units on
244
+ return [i for i in range(len(L) - 1) if not L[i].probe]
245
+ # the reference rule: junctions not touching the depth probe
246
+ return [i for i in range(len(L) - 1) if not (L[i].probe or L[i + 1].probe)]
247
+ if axis == DEGREE:
248
+ return [(i, k) for i, l in enumerate(L) for k in range(l.out_features)]
249
+ return []
250
+
251
+ def probe_at(self, axis: str, site=None) -> Item | None:
252
+ if axis == WIDTH:
253
+ k = self._probe_unit(self.layers[site])
254
+ return None if k is None else Item(WIDTH, (site, k))
255
+ if axis == DEGREE:
256
+ i, k = site
257
+ r = self._probe_or(self.layers[i], k)
258
+ return None if r is None else Item(DEGREE, (i, k, r))
259
+ p = self._probe_layer()
260
+ return None if p is None else Item(DEPTH, (p,))
261
+
262
+ def add_probe(self, axis: str, site, ctx: EditContext) -> None:
263
+ if axis == WIDTH:
264
+ self._add_width_probe(site, ctx)
265
+ elif axis == DEGREE:
266
+ i, k = site
267
+ layer = self.layers[i]
268
+ n = layer.in_features
269
+ w = uniform((n,), ctx.noise, ctx.generator, layer.weight)
270
+ b = 1.0 + float(uniform((), ctx.noise, ctx.generator, layer.bias))
271
+ layer.add_or(k, w, b, 1.0, probe=True, born=ctx.step)
272
+ else:
273
+ self._insert_depth_probe(site, ctx)
274
+
275
+ def promote(self, item: Item) -> None:
276
+ if item.axis == WIDTH:
277
+ i, k = item.address
278
+ self.layers[i].unit_probe[k] = False
279
+ elif item.axis == DEGREE:
280
+ i, k, r = item.address
281
+ self.layers[i].or_probe[k, r] = False
282
+ else:
283
+ self.layers[item.address[0]].probe = False
284
+
285
+ def born(self, item: Item) -> int:
286
+ if item.axis == WIDTH:
287
+ i, k = item.address
288
+ return int(self.layers[i].unit_born[k])
289
+ if item.axis == DEGREE:
290
+ i, k, r = item.address
291
+ return int(self.layers[i].or_born[k, r])
292
+ return self.layers[item.address[0]].born
293
+
294
+ def displacement(self, item: Item) -> float:
295
+ if item.axis == WIDTH:
296
+ i, k = item.address
297
+ return dev_column(self._consumer(i), k)
298
+ if item.axis == DEGREE:
299
+ i, k, r = item.address
300
+ return dev_or(self.layers[i], k, r)
301
+ return dev_layer(self.layers[item.address[0]])
302
+
303
+ def best_site(self, axis: str, moving: Item | None = None):
304
+ """Gap with the largest mean |dL/dx| over the epoch, counted in the
305
+ stack without the depth probe. The gap behind the last layer has no
306
+ consumer to absorb a fold and is not a candidate."""
307
+ probe = None if moving is None else moving.address[0]
308
+
309
+ def loud(layer: AndOr) -> float:
310
+ ns = float(layer.stat_ns)
311
+ return float(layer.stat_gin) / ns if ns else 0.0
312
+
313
+ best_g, best, g = 0, -1.0, 0
314
+ for i, layer in enumerate(self.layers):
315
+ if i == probe:
316
+ continue
317
+ v = loud(layer)
318
+ if probe is not None and i == probe + 1:
319
+ v = max(v, loud(self.layers[probe]))
320
+ if v > best:
321
+ best, best_g = v, g
322
+ g += 1
323
+ return best_g
324
+
325
+ def site_of(self, item: Item):
326
+ return item.address[0]
327
+
328
+ def remove_probe(self, item: Item) -> None:
329
+ self._remove_identity_layer(item.address[0])
330
+
331
+ # ------------------------------------------------------ structural edits
332
+
333
+ def _add_width_probe(self, i: int, ctx: EditContext) -> None:
334
+ """The producing layer grows a new, trained output unit; the consuming
335
+ layer meets it with weights born at exactly 0."""
336
+ p, c = self.layers[i], self._consumer(i)
337
+ k = p.add_unit()
338
+ bound = 1.0 / math.sqrt(max(p.in_features, 1))
339
+ w = uniform((p.in_features,), bound, ctx.generator, p.weight)
340
+ b = float(uniform((), bound, ctx.generator, p.bias))
341
+ p.add_or(k, w, b, 1.0, born=ctx.step)
342
+ p.unit_probe[k] = True
343
+ p.unit_born[k] = ctx.step
344
+ if self._crosses_probe(i):
345
+ # the depth probe carries it: a new input and a unit whose one Or
346
+ # is the carrier of that input (w = e_k, b = 0, a = 1)
347
+ P = self.layers[i + 1]
348
+ P.add_input()
349
+ kp = P.add_unit()
350
+ e = P.weight.new_zeros(P.in_features)
351
+ e[kp] = 1.0
352
+ P.add_or(kp, e, 0.0, 1.0, born=ctx.step)
353
+ c.add_input()
354
+
355
+ def _input_means(self, i: int) -> torch.Tensor | None:
356
+ """E[x] of the consumer of junction i over the epoch (None without data)."""
357
+ c = self._consumer(i)
358
+ ns = float(c.stat_ns)
359
+ return (c.stat_sx / ns).clone() if ns > 0 else None
360
+
361
+ @torch.no_grad()
362
+ def _drop_coordinate(self, i: int, k: int, mean: float | None = None) -> None:
363
+ """Drop output coordinate k of layer i; the consumer keeps the mean of
364
+ what it contributed (b += w E[x_k]). ``mean`` is E[x_k] taken before
365
+ any other drop on the junction (a drop resets the consumer's statistics)."""
366
+ p, c = self.layers[i], self._consumer(i)
367
+ if mean is None:
368
+ means = self._input_means(i)
369
+ mean = None if means is None else means[k]
370
+ if mean is not None:
371
+ c.bias += c.weight[..., k] * mean
372
+ if self._crosses_probe(i):
373
+ P = self.layers[i + 1]
374
+ P.drop_unit(k)
375
+ P.drop_input(k)
376
+ p.drop_unit(k)
377
+ c.drop_input(k)
378
+
379
+ @torch.no_grad()
380
+ def _insert_depth_probe(self, g: int, ctx: EditContext) -> None:
381
+ """Identity layer of width d at gap g (in front of layer g)."""
382
+ layers = self.layers
383
+ c = layers[g]
384
+ alpha, beta = fold_from_stats(c, u_to_x=True)
385
+ # A width probe on the junction in front of c would be a coordinate
386
+ # the identity layer has to carry; the reference retires it first.
387
+ # (Passing units through the probe, the identity carries it.)
388
+ if g > 0 and not layers[g - 1].probe and not self.through:
389
+ k = self._probe_unit(layers[g - 1])
390
+ if k is not None:
391
+ self._drop_coordinate(g - 1, k)
392
+ keep = [j for j in range(alpha.shape[0]) if j != k]
393
+ alpha, beta = alpha[keep], beta[keep]
394
+ d = c.in_features
395
+ l = AndOr(d, d, 1, device=c.weight.device, dtype=c.weight.dtype)
396
+ w = uniform((d, 1, d), ctx.noise, ctx.generator, l.weight)
397
+ w[:, 0, :] += torch.eye(d, dtype=w.dtype, device=w.device)
398
+ l.weight.copy_(w)
399
+ l.bias.copy_(uniform((d, 1), ctx.noise, ctx.generator, l.bias))
400
+ l.assembly.fill_(1.0)
401
+ l.or_born.fill_(ctx.step)
402
+ l.probe, l.born, l.track_stats = True, ctx.step, self._tracking
403
+ l.edit_sink = self._sink
404
+ fold_apply(c, alpha, beta)
405
+ c.reset_stats()
406
+ self.model.insert_layer(g, l)
407
+
408
+ def _remove_identity_layer(self, i: int) -> None:
409
+ """Remove a (near-)identity layer; layer i+1 absorbs it."""
410
+ l, c = self.layers[i], self.layers[i + 1]
411
+ alpha, beta = fold_from_stats(l, u_to_x=False)
412
+ fold_apply(c, alpha, beta)
413
+ c.reset_stats()
414
+ self.model.remove_layer(i)
415
+
416
+ # -------------------------------------------------- measurement / pruning
417
+
418
+ def ablate(self, item: Item):
419
+ if item.axis == WIDTH:
420
+ i, k = item.address
421
+ return _ablate_col(self._consumer(i), k)
422
+ if item.axis == DEGREE:
423
+ i, k, r = item.address
424
+ return _ablate_or(self.layers[i], k, r)
425
+ layer = self.layers[item.address[0]]
426
+ if layer.in_features != layer.out_features:
427
+ return None
428
+ return _ablate_layer(layer)
429
+
430
+ def cost(self, item: Item) -> int:
431
+ if item.axis == WIDTH:
432
+ i, j = item.address
433
+ p, c = self.layers[i], self._consumer(i)
434
+ cost = int(p.mask[j].sum()) * (p.in_features + 2) + int(c.mask.sum())
435
+ if self._crosses_probe(i): # its carrier Or and its probe column
436
+ P = self.layers[i + 1]
437
+ cost += (P.in_features + 2) + int(P.mask.sum())
438
+ return cost
439
+ if item.axis == DEGREE:
440
+ return self.layers[item.address[0]].in_features + 2
441
+ return self.layers[item.address[0]].num_params()
442
+
443
+ def trial_remove(self, item: Item) -> Trial | None:
444
+ """Remove square layer i; layer i+1 absorbs the fold."""
445
+ i = item.address[0]
446
+ l, c = self.layers[i], self.layers[i + 1]
447
+ if l.in_features != l.out_features:
448
+ return None
449
+ snap = _snap(c, "weight", "bias", "adam_m_weight", "adam_v_weight")
450
+ alpha, beta = fold_from_stats(l, u_to_x=False)
451
+ fold_apply(c, alpha, beta)
452
+ self.model.remove_layer(i)
453
+
454
+ def undo() -> None:
455
+ self.model.insert_layer(i, l)
456
+ _restore(c, snap)
457
+
458
+ return Trial(undo=undo, commit=c.reset_stats)
459
+
460
+ def removal_order(self, axis: str) -> Sequence[Item]:
461
+ if axis == DEPTH: # never the output layer
462
+ return [Item(DEPTH, (i,)) for i in range(len(self.layers) - 1)]
463
+ return []
464
+
465
+ def prune_candidates(self) -> Sequence[Item]:
466
+ out = []
467
+ L = self.layers
468
+ for i, l in enumerate(L):
469
+ out += [Item(DEGREE, (i, k, r)) for k, r in l.mask.nonzero().tolist()]
470
+ if i + 1 < len(L):
471
+ out += [Item(WIDTH, (i, k)) for k in range(l.out_features)]
472
+ return out
473
+
474
+ @staticmethod
475
+ def _gone(removed: Sequence[Item]):
476
+ units = {it.address for it in removed if it.axis == WIDTH}
477
+ ors = {it.address for it in removed if it.axis == DEGREE}
478
+ return units, ors
479
+
480
+ def can_remove(self, item: Item, removed: Sequence[Item]) -> bool:
481
+ units, ors = self._gone(removed)
482
+ i, k = item.address[:2]
483
+ if (i, k) in units:
484
+ return False
485
+ if item.axis == DEGREE: # keep at least one Or per unit
486
+ left = int(self.layers[i].mask[k].sum()) - sum(
487
+ 1 for a in ors if a[0] == i and a[1] == k)
488
+ return left > 1
489
+ left = self.layers[i].out_features - sum(1 for a in units if a[0] == i)
490
+ return left > 1 # keep at least one coordinate per junction
491
+
492
+ def params_without(self, removed: Sequence[Item]) -> int:
493
+ """(live Ors) x (live inputs + 2) per surviving unit. (The C reference
494
+ sums a per-item tally that can count an Or twice; see DIFFERENCES.md.)"""
495
+ units, ors = self._gone(removed)
496
+ total, n_in = 0, self.layers[0].in_features
497
+ for i, l in enumerate(self.layers):
498
+ n_out = 0
499
+ for k, n_or in enumerate(l.num_ors().tolist()):
500
+ if (i, k) in units:
501
+ continue
502
+ n_out += 1
503
+ gone = sum(1 for a in ors if a[0] == i and a[1] == k)
504
+ total += (n_or - gone) * (n_in + 2)
505
+ n_in = n_out
506
+ return total
507
+
508
+ def commit_removals(self, removed: Sequence[Item],
509
+ axes: Sequence[str] = (WIDTH, DEGREE)) -> dict[str, int]:
510
+ """Remove the chosen items, then clear the probe flags of what stays, on
511
+ the given axes only. Under the BIC rule the items were ablated (an
512
+ identity: exact); under the threshold rule they sit inside the band."""
513
+ units, ors = self._gone(removed)
514
+ c = {"or_add": 0, "or_drop": 0, "and_add": 0, "and_drop": 0}
515
+ layers = self.layers
516
+ do_width, do_degree = WIDTH in axes, DEGREE in axes
517
+ means = {i: self._input_means(i) for i in range(len(layers) - 1)
518
+ if any(a[0] == i for a in units)}
519
+ for i in reversed(range(len(layers))):
520
+ l = layers[i]
521
+ for k in reversed(range(l.out_features)):
522
+ gone = (i, k) in units
523
+ for r in reversed(range(l.degree if do_degree else 0)):
524
+ if not bool(l.mask[k, r]):
525
+ continue
526
+ was_probe = bool(l.or_probe[k, r])
527
+ if (i, k, r) in ors and not gone:
528
+ l.drop_or(k, r)
529
+ if not was_probe:
530
+ c["and_drop"] += 1
531
+ elif was_probe and not gone:
532
+ l.or_probe[k, r] = False
533
+ c["and_add"] += 1
534
+ if do_width and i + 1 < len(layers):
535
+ was_probe = bool(l.unit_probe[k])
536
+ if gone:
537
+ m = means.get(i)
538
+ self._drop_coordinate(i, k, None if m is None else m[k])
539
+ if not was_probe:
540
+ c["or_drop"] += 1
541
+ elif was_probe:
542
+ l.unit_probe[k] = False
543
+ c["or_add"] += 1
544
+ for l in layers:
545
+ l.compact()
546
+ return c
torch_type_nn/edits.py ADDED
@@ -0,0 +1,99 @@
1
+ """Keep a stock ``torch.optim`` optimizer in step with structural edits.
2
+
3
+ PyTorch optimizers key their state by the ``Parameter`` object, and
4
+ structural edits replace parameters with tensors of a new shape. An
5
+ :class:`Edit` records one such replacement together with an *index map*
6
+ along one dimension: ``index[j]`` is the position in the old tensor that
7
+ slice ``j`` of the new tensor came from, or ``-1`` for a new slice.
8
+
9
+ :func:`follow_structure` applies a list of edits to an optimizer. Every state
10
+ tensor with the old parameter's shape (Adam's ``exp_avg``/``exp_avg_sq``,
11
+ SGD's ``momentum_buffer``, ...) is remapped with the same index map, new
12
+ slices starting at 0. Scalar state (Adam's ``step``) is kept as is, so a new
13
+ slice inherits the tensor's step count; :class:`~torch_type_nn.optim.TypeAdam`
14
+ avoids that with per-Or step counts. Parameters that appeared or disappeared
15
+ without an edit (a new or removed layer) are added to or dropped from the
16
+ optimizer.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass
22
+
23
+ import torch
24
+ from torch import nn
25
+
26
+ __all__ = ["Edit", "follow_structure", "grow_index", "keep_index"]
27
+
28
+
29
+ @dataclass
30
+ class Edit:
31
+ """``old`` was replaced by ``new``; along ``dim``, ``new[j]`` came from ``old[index[j]]``."""
32
+
33
+ old: nn.Parameter
34
+ new: nn.Parameter
35
+ dim: int
36
+ index: torch.Tensor
37
+
38
+
39
+ def grow_index(n: int, device=None) -> torch.Tensor:
40
+ """Index map of ``n`` kept slices followed by one new slice."""
41
+ return torch.cat([torch.arange(n, device=device), torch.full((1,), -1, device=device)])
42
+
43
+
44
+ def keep_index(keep: torch.Tensor) -> torch.Tensor:
45
+ """Index map of a selection (``index_select`` with ``keep``)."""
46
+ return keep.clone()
47
+
48
+
49
+ def remap_tensor(t: torch.Tensor, new_shape: torch.Size, dim: int,
50
+ index: torch.Tensor) -> torch.Tensor:
51
+ """``t`` rearranged by the index map, new slices zero."""
52
+ out = t.new_zeros(new_shape)
53
+ index = index.to(t.device)
54
+ src = index >= 0
55
+ if bool(src.any()):
56
+ pos = src.nonzero().flatten()
57
+ out.index_copy_(dim, pos, t.index_select(dim, index[src]))
58
+ return out
59
+
60
+
61
+ @torch.no_grad()
62
+ def follow_structure(optimizer: torch.optim.Optimizer, model: nn.Module,
63
+ edits: list[Edit]) -> None:
64
+ """Bring ``optimizer`` in line with ``model`` after structural edits.
65
+
66
+ 1. Each edit, in order: the old parameter is replaced by the new one in
67
+ its param group, and its state is remapped along the edit's index map.
68
+ 2. Parameters of ``model`` the optimizer does not know get added to the
69
+ first param group, with fresh state.
70
+ 3. Parameters the optimizer holds that are no longer in ``model`` are
71
+ dropped with their state.
72
+ """
73
+ for e in edits:
74
+ for group in optimizer.param_groups:
75
+ for i, p in enumerate(group["params"]):
76
+ if p is e.old:
77
+ group["params"][i] = e.new
78
+ st = optimizer.state.pop(e.old, None)
79
+ if st:
80
+ optimizer.state[e.new] = {
81
+ k: (remap_tensor(v, e.new.shape, e.dim, e.index)
82
+ if isinstance(v, torch.Tensor) and v.shape == e.old.shape and v.dim() > 0
83
+ else v)
84
+ for k, v in st.items()}
85
+ live = list(model.parameters())
86
+ live_ids = {id(p) for p in live}
87
+ held = set()
88
+ for group in optimizer.param_groups:
89
+ kept = []
90
+ for p in group["params"]:
91
+ if id(p) in live_ids and id(p) not in held:
92
+ kept.append(p)
93
+ held.add(id(p))
94
+ else:
95
+ optimizer.state.pop(p, None)
96
+ group["params"] = kept
97
+ missing = [p for p in live if id(p) not in held]
98
+ if missing:
99
+ optimizer.param_groups[0]["params"].extend(missing)