ManPla commited on
Commit
80da69a
·
verified ·
1 Parent(s): df4083c

v1.0: demo montada desde el tag v1.0 del repositorio

Browse files
README.md CHANGED
@@ -1,13 +1,33 @@
1
  ---
2
- title: Gauge Orbit Quantization Demo
3
- emoji: 👀
4
  colorFrom: indigo
5
  colorTo: gray
6
  sdk: gradio
7
- sdk_version: 6.28.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Gauge equivalence does not survive quantisation
3
+ emoji: "\U0001FA9E"
4
  colorFrom: indigo
5
  colorTo: gray
6
  sdk: gradio
7
+ sdk_version: 6.22.0
 
8
  app_file: app.py
9
  pinned: false
10
+ license: apache-2.0
11
  ---
12
 
13
+ # Gauge equivalence does not survive quantisation
14
+
15
+ Interactive companion to the note *Gauge equivalence does not survive
16
+ quantisation: value-output orbit, norm-product bound, and
17
+ consequences for rotational methods*.
18
+
19
+ Pick a point of the value-output gauge orbit and see what
20
+ quantisation does to the OV circuit. The gauge leaves the circuit
21
+ exactly fixed in real arithmetic; the demo shows the error staying at
22
+ the fp64 floor before quantising and exploding after it.
23
+
24
+ Nothing is reimplemented here: the app runs the note's own
25
+ `src/gauges.py`, `src/quantizer.py` and `src/metrics.py` on a light
26
+ carrier of the value-output sector. `scripts/paridad_demo.py`
27
+ certifies the demo cell by cell against the published sweeps.
28
+
29
+ The parity gate is not decoration: it replays the published sweeps'
30
+ own gauges and requires the demo to return the same numbers to
31
+ $3\times10^{-15}$, against a tolerance of $10^{-12}$.
32
+
33
+ Code and paper: https://github.com/mmunozpl/gauge-orbit-quantization
app.py ADDED
@@ -0,0 +1,642 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """demo interactiva: la órbita de gauge valor-salida bajo cuantización.
2
+
3
+ tres pestañas. la órbita, donde se elige un punto y se ve qué le hace
4
+ la cuantización al circuito; el interruptor, con la validación
5
+ end-to-end ya medida; y qué se mide, con el método.
6
+
7
+ toda la aritmética la ejecuta el código del paper ---`src/gauges.py`,
8
+ `src/quantizer.py`, `src/metrics.py`--- sobre el portador ligero de
9
+ `demo/portador.py`. aquí no se reimplementa nada, y por eso
10
+ `scripts/paridad_demo.py` puede certificar la demo celda a celda
11
+ contra los csv publicados.
12
+ """
13
+
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ import gradio as gr
18
+ import matplotlib
19
+ import torch
20
+
21
+ matplotlib.use("Agg") # sin servidor gráfico en el Space
22
+
23
+ from matplotlib.figure import Figure # noqa: E402
24
+
25
+
26
+ def _raiz() -> Path:
27
+ """la raíz del árbol, igual en el repo que en el Space.
28
+
29
+ en el repositorio este fichero vive en `demo/`; en el Space, la
30
+ app viaja a la raíz para que Gradio la encuentre. se sube hasta
31
+ dar con el directorio que contiene `src` y `demo`, y así el mismo
32
+ código sirve en los dos sitios sin condicionales.
33
+
34
+ returns:
35
+ el directorio raíz del árbol.
36
+ """
37
+ aqui = Path(__file__).resolve()
38
+ for cand in (aqui.parent, *aqui.parents):
39
+ if (cand / "src").is_dir() and (cand / "demo").is_dir():
40
+ return cand
41
+ return aqui.parent
42
+
43
+
44
+ sys.path.insert(0, str(_raiz()))
45
+
46
+ from demo.portador import carga, par # noqa: E402
47
+ from src.gauges import (aplica_gauge, gauge_balanceado, # noqa: E402
48
+ gl_condicionado, gl_general, ortogonal,
49
+ producto_normas)
50
+ from src.metrics import circuito, error_relativo # noqa: E402
51
+ from src.quantizer import rtn_cuantiza_descuantiza # noqa: E402
52
+
53
+ DOI = "https://doi.org/10.5281/zenodo.21630534"
54
+ REPO = "https://github.com/mmunozpl/gauge-orbit-quantization"
55
+ # gr.Markdown no compone matemáticas salvo que se le declaren los
56
+ # delimitadores; sin esto la prosa enseña los dólares en crudo.
57
+ LATEX = [{"left": "$$", "right": "$$", "display": True},
58
+ {"left": "$", "right": "$", "display": False}]
59
+ # exponente publicado del ajuste conjunto sobre las dos familias
60
+ # (artifacts/tables/colapso_producto.csv, variable=producto, bits=4).
61
+ EXPONENTE = 0.9979
62
+ IDIOMAS = {"English": "en", "Español": "es"}
63
+ MODELOS = {"vitb": {"n_capas": 12, "n_cabezas": 12, "dim_cabeza": 64},
64
+ "pythia": {"n_capas": 24, "n_cabezas": 16, "dim_cabeza": 64}}
65
+ # rejilla de fuerzas que la figura recorre en vivo, por familia
66
+ REJILLA = {"gl_escala": [32.0, 16.0, 8.0, 4.0, 2.0],
67
+ "gl_kappa": [2.0, 10.0, 50.0, 200.0, 1000.0],
68
+ "conforme": [0.1, 0.5, 1.0, 5.0, 50.0],
69
+ "orto": [0.0, 1.0, 2.0, 3.0, 4.0]}
70
+ _CACHE: dict[str, dict] = {}
71
+
72
+ FAM = {
73
+ "en": {"identidad": "identity (the trained gauge)",
74
+ "conforme": "conformal orthogonal c·Q",
75
+ "orto": "Haar orthogonal",
76
+ "balanceado": "balanced point (constructed)",
77
+ "gl_escala": "general GL, by scale",
78
+ "gl_kappa": "general GL, by condition number κ"},
79
+ "es": {"identidad": "identidad (el gauge entrenado)",
80
+ "conforme": "conforme ortogonal c·Q",
81
+ "orto": "ortogonal de Haar",
82
+ "balanceado": "punto balanceado (construido)",
83
+ "gl_escala": "GL general, por escala",
84
+ "gl_kappa": "GL general, por número de condición κ"},
85
+ }
86
+ PARAM = {
87
+ "en": {"identidad": "unused", "conforme": "coefficient c",
88
+ "orto": "unused", "balanceado": "unused",
89
+ "gl_escala": "scale (low = strong gauge)",
90
+ "gl_kappa": "target κ"},
91
+ "es": {"identidad": "sin uso", "conforme": "coeficiente c",
92
+ "orto": "sin uso", "balanceado": "sin uso",
93
+ "gl_escala": "escala (baja = gauge fuerte)",
94
+ "gl_kappa": "κ objetivo"},
95
+ }
96
+ DEFECTO = {"identidad": 1.0, "conforme": 1.0, "orto": 1.0,
97
+ "balanceado": 1.0, "gl_escala": 8.0, "gl_kappa": 50.0}
98
+
99
+
100
+ def portador(modelo: str) -> dict:
101
+ """carga el portador de un modelo, con caché de proceso.
102
+
103
+ args:
104
+ modelo: 'vitb' o 'pythia'.
105
+
106
+ returns:
107
+ el dict de `demo.portador.carga`.
108
+ """
109
+ if modelo not in _CACHE:
110
+ _CACHE[modelo] = carga(modelo)
111
+ return _CACHE[modelo]
112
+
113
+
114
+ def construye_r(
115
+ familia: str,
116
+ parametro: float,
117
+ dim_cabeza: int,
118
+ gen: torch.Generator,
119
+ ) -> torch.Tensor | None:
120
+ """el gauge de una familia, en fp64.
121
+
122
+ args:
123
+ familia: clave de `FAM`.
124
+ parametro: el mando de la familia; sin uso en varias.
125
+ dim_cabeza: d_h.
126
+ gen: generador de torch.
127
+
128
+ returns:
129
+ tensor [d_h, d_h] en float64, o None para la identidad.
130
+
131
+ raises:
132
+ ValueError: si la familia no existe.
133
+ """
134
+ if familia == "identidad":
135
+ return None
136
+ if familia == "conforme":
137
+ return float(parametro) * ortogonal(dim_cabeza, gen)
138
+ if familia == "orto":
139
+ return ortogonal(dim_cabeza, gen)
140
+ if familia == "gl_escala":
141
+ return gl_general(dim_cabeza, float(parametro), gen)
142
+ if familia == "gl_kappa":
143
+ return gl_condicionado(dim_cabeza, float(parametro), gen)
144
+ raise ValueError(f"familia desconocida: {familia}")
145
+
146
+
147
+ def mide_capa(
148
+ modelo: str,
149
+ capa: int,
150
+ familia: str,
151
+ parametro: float,
152
+ semilla: int,
153
+ bits: int,
154
+ ) -> list[dict]:
155
+ """mide todas las cabezas de una capa bajo un punto de la órbita.
156
+
157
+ por cabeza devuelve p, d(R), cond(R), el error del circuito sin
158
+ cuantizar ---el suelo de fp64, que la invariancia exige--- y el
159
+ error tras cuantizar, con su cociente contra la identidad.
160
+
161
+ args:
162
+ modelo: 'vitb' o 'pythia'.
163
+ capa: índice de capa.
164
+ familia: clave de `FAM`.
165
+ parametro: el mando de la familia.
166
+ semilla: semilla del muestreo.
167
+ bits: 4 u 8.
168
+
169
+ returns:
170
+ lista de dicts, una por cabeza.
171
+ """
172
+ info = MODELOS[modelo]
173
+ port = portador(modelo)
174
+ gen = torch.Generator().manual_seed(int(semilla))
175
+ filas = []
176
+ for h in range(info["n_cabezas"]):
177
+ w_v, w_o = par(port, capa, h)
178
+ m_ref = circuito(w_v, w_o)
179
+ e_ident = error_relativo(rtn_cuantiza_descuantiza(w_v, bits),
180
+ rtn_cuantiza_descuantiza(w_o, bits),
181
+ m_ref)
182
+ if familia == "balanceado":
183
+ r, _ = gauge_balanceado(w_v, w_o)
184
+ else:
185
+ r = construye_r(familia, parametro, info["dim_cabeza"], gen)
186
+ if r is None:
187
+ a, b = w_v, w_o
188
+ else:
189
+ a, b = aplica_gauge(w_v, w_o, r)
190
+ e_exacto = error_relativo(a, b, m_ref)
191
+ e_quant = error_relativo(rtn_cuantiza_descuantiza(a, bits),
192
+ rtn_cuantiza_descuantiza(b, bits),
193
+ m_ref)
194
+ if r is None:
195
+ p, d_r, cond = 1.0, 0.0, 1.0
196
+ else:
197
+ p = producto_normas(w_v, w_o, r)
198
+ s = torch.linalg.svdvals(r)
199
+ d_r = float((s - s.mean()).norm() / s.norm())
200
+ cond = float(s[0] / s[-1])
201
+ filas.append({"cabeza": h, "p": p, "d_r": d_r, "cond": cond,
202
+ "e_exacto": e_exacto, "e_quant": e_quant,
203
+ "e_ident": e_ident,
204
+ "cociente": e_quant / e_ident})
205
+ return filas
206
+
207
+
208
+ def _mediana(filas: list[dict], clave: str) -> float:
209
+ """mediana de una columna de `mide_capa`."""
210
+ v = sorted(f[clave] for f in filas)
211
+ n = len(v)
212
+ return v[n // 2] if n % 2 else 0.5 * (v[n // 2 - 1] + v[n // 2])
213
+
214
+
215
+ def figura(
216
+ modelo: str,
217
+ capa: int,
218
+ familia: str,
219
+ semilla: int,
220
+ bits: int,
221
+ punto: tuple[float, float] | None,
222
+ idi: str,
223
+ ) -> Figure:
224
+ """la ley del producto recorrida en vivo, con el punto elegido.
225
+
226
+ se barre la rejilla de fuerzas de la familia sobre las cabezas de
227
+ la capa y se dibuja $e/e_0$ contra $p$ en log-log, junto a la recta
228
+ de exponente uno que el ajuste publicado da sobre 743 424 medidas.
229
+
230
+ args:
231
+ modelo: 'vitb' o 'pythia'.
232
+ capa: índice de capa.
233
+ familia: clave de `FAM`.
234
+ semilla: semilla del muestreo.
235
+ bits: 4 u 8.
236
+ punto: (p, cociente) del punto elegido, o None.
237
+ idi: 'en' o 'es'.
238
+
239
+ returns:
240
+ la figura de matplotlib.
241
+ """
242
+ fig = Figure(figsize=(6.4, 4.2))
243
+ ax = fig.subplots()
244
+ rejilla = REJILLA.get(familia, [DEFECTO[familia]])
245
+ xs, ys = [], []
246
+ for valor in rejilla:
247
+ for f in mide_capa(modelo, capa, familia, valor, semilla, bits):
248
+ if f["p"] > 0 and f["cociente"] > 0:
249
+ xs.append(f["p"])
250
+ ys.append(f["cociente"])
251
+ if xs:
252
+ ax.scatter(xs, ys, s=14, alpha=0.45,
253
+ label="barrido en vivo" if idi == "es"
254
+ else "live sweep")
255
+ lo, hi = min(xs + [0.9]), max(xs + [1.1])
256
+ rec = [lo, hi]
257
+ ax.plot(rec, [x ** EXPONENTE for x in rec], color="crimson",
258
+ linewidth=1.4,
259
+ label=(f"ajuste publicado, exponente {EXPONENTE}"
260
+ if idi == "es"
261
+ else f"published fit, exponent {EXPONENTE}"))
262
+ if punto is not None and punto[0] > 0 and punto[1] > 0:
263
+ ax.scatter([punto[0]], [punto[1]], s=140, marker="*",
264
+ color="black", zorder=5,
265
+ label="tu punto" if idi == "es" else "your point")
266
+ ax.set_xscale("log")
267
+ ax.set_yscale("log")
268
+ ax.set_xlabel("$p(R)$")
269
+ ax.set_ylabel("$e/e_0$")
270
+ ax.set_title("La ley del producto" if idi == "es"
271
+ else "The product law")
272
+ ax.legend(fontsize=8)
273
+ fig.tight_layout()
274
+ return fig
275
+
276
+
277
+ LEMA = {
278
+ "en": ("### Same function before quantisation; different error "
279
+ "after it\n\n"
280
+ "$p(R)$ tracks the quantisation sensitivity of the "
281
+ "value-output factorisation."),
282
+ "es": ("### La misma función antes de cuantizar; distinto error "
283
+ "después\n\n"
284
+ "$p(R)$ mide la sensibilidad a la cuantización de la "
285
+ "factorización valor-salida."),
286
+ }
287
+ # de dónde sale cada número: la demo mezcla medida en vivo sobre una
288
+ # capa con el ajuste publicado sobre el barrido entero, y quien mire
289
+ # tiene derecho a saber cuál es cuál sin abrir el código.
290
+ ALCANCE = {
291
+ "en": ("**Live**, computed now over the chosen model, layer and "
292
+ "point: the table above, the per-head table and the scattered "
293
+ "points below. **Published**, measured offline over the full "
294
+ "sweep of $743\\,424$ points across two models, two bit "
295
+ "widths and two gauge families: the red line of exponent "
296
+ f"{EXPONENTE}. A live sample of one layer is not the "
297
+ "experiment; it is one slice of it."),
298
+ "es": ("**En vivo**, calculado ahora sobre el modelo, la capa y el "
299
+ "punto elegidos: la tabla de arriba, la tabla por cabeza y "
300
+ "los puntos dispersos de abajo. **Publicado**, medido aparte "
301
+ "sobre el barrido entero de $743\\,424$ puntos en dos "
302
+ "modelos, dos anchuras y dos familias de gauge: la recta roja "
303
+ f"de exponente {EXPONENTE}. Una muestra en vivo de una capa "
304
+ "no es el experimento, es una rebanada suya."),
305
+ }
306
+
307
+ T = {
308
+ "en": {
309
+ "titulo": (
310
+ "## Gauge equivalence does not survive quantisation\n\n"
311
+ "The value-output pair of an attention head is not unique: for "
312
+ "every invertible $R$, the substitution $W_v\\to R^\\top W_v$, "
313
+ "$W_O\\to W_O R^{-\\top}$ leaves the OV circuit "
314
+ "$M=W_O W_v$ **exactly** fixed. Pick a point of that orbit below "
315
+ "and watch what quantisation does to it.\n\n"
316
+ f"Paper and code: [repository]({REPO}) · previous work: "
317
+ f"[{DOI.split('/')[-1]}]({DOI})"),
318
+ "tab1": "The orbit", "tab2": "The switch", "tab3": "What is measured",
319
+ "modelo": "Model", "capa": "Layer", "familia": "Gauge family",
320
+ "param": "Parameter", "sem": "Seed", "bits": "Bit width",
321
+ "btn": "Measure this point", "fig": "The product law",
322
+ "alcance": "What is live and what is published",
323
+ "porcabeza": "Per head", "res": "Result",
324
+ "cab": ["head", "p(R)", "d(R)", "cond(R)", "e unquantised",
325
+ f"e quantised", "e / e0"],
326
+ },
327
+ "es": {
328
+ "titulo": (
329
+ "## La equivalencia de gauge no sobrevive a la cuantización\n\n"
330
+ "El par valor-salida de una cabeza de atención no es único: para "
331
+ "toda $R$ invertible, la sustitución $W_v\\to R^\\top W_v$, "
332
+ "$W_O\\to W_O R^{-\\top}$ deja el circuito OV $M=W_O W_v$ "
333
+ "**exactamente** fijo. Elige abajo un punto de esa órbita y mira "
334
+ "qué le hace la cuantización.\n\n"
335
+ f"Artículo y código: [repositorio]({REPO}) · trabajo previo: "
336
+ f"[{DOI.split('/')[-1]}]({DOI})"),
337
+ "tab1": "La órbita", "tab2": "El interruptor", "tab3": "Qué se mide",
338
+ "modelo": "Modelo", "capa": "Capa", "familia": "Familia de gauge",
339
+ "param": "Parámetro", "sem": "Semilla", "bits": "Anchura",
340
+ "btn": "Medir este punto", "fig": "La ley del producto",
341
+ "alcance": "Qué es en vivo y qué es publicado",
342
+ "porcabeza": "Por cabeza", "res": "Resultado",
343
+ "cab": ["cabeza", "p(R)", "d(R)", "cond(R)", "e sin cuantizar",
344
+ "e cuantizado", "e / e0"],
345
+ },
346
+ }
347
+
348
+ QSM = {
349
+ "en": (
350
+ "### What is measured\n\n"
351
+ "**The circuit error.** $e=\\lVert\\hat M-M\\rVert_F/"
352
+ "\\lVert M\\rVert_F$, with $\\hat M$ recomposed in fp64 from the "
353
+ "quantised pair. All gauge arithmetic is fp64.\n\n"
354
+ "**The bound.** With a per-row quantiser bounded by $\\delta$, "
355
+ "$e(R)\\le(\\delta_v+\\delta_O+\\delta_v\\delta_O)\\,g(R)$, and "
356
+ "with $g$ the only gauge-dependent factor. Normalising it by its "
357
+ "value at the identity gives $p=\\lVert W_OR^{-\\top}\\rVert_F"
358
+ "\\lVert R^{\\top}W_v\\rVert_F/"
359
+ "(\\lVert W_O\\rVert_F\\lVert W_v\\rVert_F)$. Two bounds do not "
360
+ "divide: $e/e_0\\simeq p$ holds if the effective quantisation "
361
+ "factor varies little across gauges. It is the hypothesis, not a "
362
+ "corollary.\n\n"
363
+ "**Two analytic ends.** The conformal orthogonal class $\\{cQ\\}$ "
364
+ "gives $p\\equiv1$ for every $c$ and $Q$, so it is neutral for "
365
+ "the bound. The "
366
+ "balanced point minimises $p$ over the whole orbit, and the "
367
+ "minimisers are that family up to a reciprocal rescaling.\n\n"
368
+ "**$d(R)$** is the standard deviation of the singular values over "
369
+ "their norm, the distance to $\\{cQ\\}$. It is exactly zero on the "
370
+ "neutral class.\n\n"
371
+ "**Nothing is reimplemented here.** The demo runs `src/gauges.py`, "
372
+ "`src/quantizer.py` and `src/metrics.py` on a light carrier of the "
373
+ "value-output sector, and `scripts/paridad_demo.py` certifies it "
374
+ "cell by cell against the published sweeps.\n\n"
375
+ "**What this demo does not do.** It does not run forwards. The "
376
+ "end-to-end numbers in the second tab were measured offline over "
377
+ "the 5000 images of the clean ImageNet-100 validation split."),
378
+ "es": (
379
+ "### Qué se mide\n\n"
380
+ "**El error del circuito.** $e=\\lVert\\hat M-M\\rVert_F/"
381
+ "\\lVert M\\rVert_F$, con $\\hat M$ recompuesto en fp64 desde el "
382
+ "par cuantizado. Toda la aritmética de gauges es fp64.\n\n"
383
+ "**La cota.** Con un cuantizador por fila acotado por $\\delta$, "
384
+ "$e(R)\\le(\\delta_v+\\delta_O+\\delta_v\\delta_O)\\,g(R)$, y "
385
+ "con $g$ el único factor que depende del gauge. Normalizarlo por "
386
+ "su valor en la identidad da $p=\\lVert W_OR^{-\\top}\\rVert_F"
387
+ "\\lVert R^{\\top}W_v\\rVert_F/"
388
+ "(\\lVert W_O\\rVert_F\\lVert W_v\\rVert_F)$. Dos cotas no se "
389
+ "dividen: $e/e_0\\simeq p$ vale si el factor efectivo de "
390
+ "cuantización varía poco entre gauges. Es la hipótesis, no un "
391
+ "corolario.\n\n"
392
+ "**Dos extremos analíticos.** La clase conforme ortogonal "
393
+ "$\\{cQ\\}$ da $p\\equiv1$ para toda $c$ y toda $Q$, así que es "
394
+ "neutra para la cota. El punto balanceado minimiza $p$ sobre la "
395
+ "órbita entera, "
396
+ "y los mínimos son esa familia salvo un escalado recíproco.\n\n"
397
+ "**$d(R)$** es la desviación típica de los valores singulares "
398
+ "partida por su norma, la distancia a $\\{cQ\\}$. Vale cero "
399
+ "exactamente sobre la clase neutra.\n\n"
400
+ "**Aquí no se reimplementa nada.** La demo ejecuta "
401
+ "`src/gauges.py`, `src/quantizer.py` y `src/metrics.py` sobre un "
402
+ "portador ligero del sector valor-salida, y "
403
+ "`scripts/paridad_demo.py` la certifica celda a celda contra los "
404
+ "barridos publicados.\n\n"
405
+ "**Qué no hace esta demo.** No corre forwards. Las cifras "
406
+ "end-to-end de la segunda pestaña se midieron aparte sobre las "
407
+ "5000 imágenes del val limpio de ImageNet-100."),
408
+ }
409
+
410
+
411
+ def tabla_e2e(idi: str) -> str:
412
+ """la validación end-to-end medida, como markdown.
413
+
414
+ args:
415
+ idi: 'en' o 'es'.
416
+
417
+ returns:
418
+ el bloque de markdown de la pestaña del interruptor.
419
+ """
420
+ import csv
421
+
422
+ ruta = _raiz() / "artifacts" / "tables" / "validacion_e2e.csv"
423
+ if not ruta.exists():
424
+ return "_sin tabla end-to-end_" if idi == "es" else "_no table_"
425
+ filas = list(csv.DictReader(ruta.open()))
426
+ nom = {"identidad_fp32": ("Identity, not quantised",
427
+ "Identidad, sin cuantizar"),
428
+ "identidad": ("Identity, int4", "Identidad, int4"),
429
+ "mejor_orto_muestreado": ("Best sampled orthogonal, int4",
430
+ "Mejor ortogonal muestreado, int4"),
431
+ "peor_orto_muestreado": ("Worst sampled orthogonal, int4",
432
+ "Peor ortogonal muestreado, int4"),
433
+ "balanceado": ("Balanced point, int4",
434
+ "Punto balanceado, int4"),
435
+ "gl_escala2_fp32": ("GL scale 2, NOT quantised",
436
+ "GL escala 2, SIN cuantizar"),
437
+ "gl_escala2": ("GL scale 2, int4", "GL escala 2, int4")}
438
+ cab = ("| Condition | Top-1 |\n|---|---|"
439
+ if idi == "en" else "| Condición | Top-1 |\n|---|---|")
440
+ j = 0 if idi == "en" else 1
441
+ cuerpo = "\n".join(
442
+ f"| {nom.get(f['condicion'], (f['condicion'],) * 2)[j]} "
443
+ f"| {float(f['top1']):.4f} |" for f in filas)
444
+ intro = {
445
+ "en": ("### The switch\n\nSeven forwards of ViT-B/16 over the "
446
+ "5000 images of the clean ImageNet-100 validation split. "
447
+ "The same gauge is measured twice, with and without "
448
+ "quantisation. That pair of rows is the whole argument: "
449
+ "the gauge leaves the function alone and quantisation "
450
+ "destroys it. Unquantised, that gauge predicts the same "
451
+ "class as the identity on **all 5000 images**, without a "
452
+ "single discrepancy; quantised, it agrees on 44.\n\n"),
453
+ "es": ("### El interruptor\n\nSiete forwards de ViT-B/16 sobre "
454
+ "las 5000 imágenes del val limpio de ImageNet-100. El "
455
+ "mismo gauge se mide dos veces, con y sin cuantizar. Ese "
456
+ "par de filas es el argumento entero: el gauge deja la "
457
+ "función quieta y la cuantización la destruye. Sin "
458
+ "cuantizar, ese gauge predice la misma clase que la "
459
+ "identidad en **las 5000 imágenes**, sin una sola "
460
+ "discrepancia; cuantizado, coincide en 44.\n\n"),
461
+ }
462
+ return intro[idi] + cab + "\n" + cuerpo
463
+
464
+
465
+ def orbita(
466
+ idioma: str,
467
+ modelo: str,
468
+ capa: int,
469
+ familia: str,
470
+ parametro: float,
471
+ semilla: int,
472
+ bits: int,
473
+ ):
474
+ """mide el punto elegido y compone resultado, tabla y figura.
475
+
476
+ args:
477
+ idioma: clave de `IDIOMAS`.
478
+ modelo: 'vitb' o 'pythia'.
479
+ capa: índice de capa.
480
+ familia: clave de `FAM`.
481
+ parametro: el mando de la familia.
482
+ semilla: semilla del muestreo.
483
+ bits: 4 u 8.
484
+
485
+ returns:
486
+ (markdown del resultado, filas de la tabla, figura).
487
+ """
488
+ idi = IDIOMAS[idioma]
489
+ filas = mide_capa(modelo, int(capa), familia, parametro,
490
+ int(semilla), int(bits))
491
+ p_med = _mediana(filas, "p")
492
+ coc = _mediana(filas, "cociente")
493
+ e_ex = max(f["e_exacto"] for f in filas)
494
+ e_q = _mediana(filas, "e_quant")
495
+ d_med = _mediana(filas, "d_r")
496
+ cond_med = _mediana(filas, "cond")
497
+ if idi == "es":
498
+ res = (
499
+ f"**En vivo** · {FAM['es'][familia]}, capa {int(capa)}, "
500
+ f"int{int(bits)}. Medianas sobre las {len(filas)} cabezas "
501
+ f"de esa capa.\n\n"
502
+ f"| | |\n|---|---|\n"
503
+ f"| $p(R)$ | {p_med:.4f} |\n"
504
+ f"| $d(R)$, distancia a la clase neutra | {d_med:.4f} |\n"
505
+ f"| $\\operatorname{{cond}}(R)$ | {cond_med:.3f} |\n"
506
+ f"| error del circuito **sin cuantizar** (peor cabeza) "
507
+ f"| {e_ex:.2e} |\n"
508
+ f"| error del circuito en int{int(bits)} | {e_q:.4f} |\n"
509
+ f"| $e/e_0$ medido | {coc:.4f} |\n"
510
+ f"| $e/e_0$ que la cota predice | {p_med:.4f} |\n\n"
511
+ f"Sin cuantizar el error se queda en el suelo de fp64: el "
512
+ f"gauge no toca la función. Cuantizado, el mismo punto "
513
+ f"multiplica el error por {coc:.2f}.")
514
+ else:
515
+ res = (
516
+ f"**Live** · {FAM['en'][familia]}, layer {int(capa)}, "
517
+ f"int{int(bits)}. Medians over the {len(filas)} heads of "
518
+ f"that layer.\n\n"
519
+ f"| | |\n|---|---|\n"
520
+ f"| $p(R)$ | {p_med:.4f} |\n"
521
+ f"| $d(R)$, distance to the neutral class | {d_med:.4f} |\n"
522
+ f"| $\\operatorname{{cond}}(R)$ | {cond_med:.3f} |\n"
523
+ f"| circuit error **unquantised** (worst head) "
524
+ f"| {e_ex:.2e} |\n"
525
+ f"| circuit error at int{int(bits)} | {e_q:.4f} |\n"
526
+ f"| $e/e_0$ measured | {coc:.4f} |\n"
527
+ f"| $e/e_0$ predicted by the bound | {p_med:.4f} |\n\n"
528
+ f"Unquantised the error stays at the fp64 floor: the gauge "
529
+ f"does not touch the function. Quantised, the same point "
530
+ f"multiplies the error by {coc:.2f}.")
531
+ tabla = [[f["cabeza"], round(f["p"], 4), round(f["d_r"], 4),
532
+ round(f["cond"], 3), f"{f['e_exacto']:.2e}",
533
+ round(f["e_quant"], 5), round(f["cociente"], 4)]
534
+ for f in filas]
535
+ fig = figura(modelo, int(capa), familia, int(semilla), int(bits),
536
+ (p_med, coc), idi)
537
+ return res, tabla, fig
538
+
539
+
540
+ def capas_de(modelo: str):
541
+ """actualiza el desplegable de capas al cambiar de modelo.
542
+
543
+ args:
544
+ modelo: 'vitb' o 'pythia'.
545
+
546
+ returns:
547
+ la actualización de gradio para el desplegable.
548
+ """
549
+ n = MODELOS[modelo]["n_capas"]
550
+ return gr.update(choices=list(range(n)), value=min(5, n - 1))
551
+
552
+
553
+ def cambia_familia(idioma: str, familia: str):
554
+ """reetiqueta el mando de la familia y le pone su valor por defecto.
555
+
556
+ args:
557
+ idioma: clave de `IDIOMAS`.
558
+ familia: clave de `FAM`.
559
+
560
+ returns:
561
+ la actualización de gradio para el mando.
562
+ """
563
+ idi = IDIOMAS[idioma]
564
+ return gr.update(label=PARAM[idi][familia], value=DEFECTO[familia])
565
+
566
+
567
+ def cambia_idioma(idioma: str, familia: str):
568
+ """reetiqueta la interfaz entera.
569
+
570
+ args:
571
+ idioma: clave de `IDIOMAS`.
572
+ familia: clave de `FAM` vigente.
573
+
574
+ returns:
575
+ la tupla de actualizaciones, en el orden del cableado.
576
+ """
577
+ idi = IDIOMAS[idioma]
578
+ d = T[idi]
579
+ fam_op = [(v, k) for k, v in FAM[idi].items()]
580
+ return (gr.update(value=d["titulo"]),
581
+ gr.update(value=LEMA[idi]),
582
+ gr.update(label=d["modelo"]),
583
+ gr.update(label=d["capa"]),
584
+ gr.update(choices=fam_op, label=d["familia"]),
585
+ gr.update(label=PARAM[idi][familia]),
586
+ gr.update(label=d["sem"]),
587
+ gr.update(label=d["bits"]),
588
+ gr.update(value=d["btn"]),
589
+ gr.update(label=d["res"]),
590
+ gr.update(headers=d["cab"], label=d["porcabeza"]),
591
+ gr.update(label=d["fig"]),
592
+ gr.update(value=ALCANCE[idi], label=d["alcance"]),
593
+ gr.update(value=tabla_e2e(idi)),
594
+ gr.update(value=QSM[idi]),
595
+ gr.update(label=d["tab1"]),
596
+ gr.update(label=d["tab2"]),
597
+ gr.update(label=d["tab3"]))
598
+
599
+
600
+ TITULO = ("Gauge equivalence does not survive quantisation - "
601
+ "La equivalencia de gauge no sobrevive a la cuantización")
602
+
603
+ with gr.Blocks(title=TITULO) as demo:
604
+ idi_o = gr.Radio(list(IDIOMAS), value="English",
605
+ label="Idioma / Language")
606
+ cab = gr.Markdown(T["en"]["titulo"], latex_delimiters=LATEX)
607
+ lema = gr.Markdown(LEMA["en"], latex_delimiters=LATEX)
608
+ with gr.Tab(T["en"]["tab1"]) as pes1:
609
+ with gr.Row():
610
+ mod_o = gr.Dropdown(list(MODELOS), value="vitb",
611
+ label=T["en"]["modelo"])
612
+ capa_o = gr.Dropdown(list(range(12)), value=5,
613
+ label=T["en"]["capa"])
614
+ fam_o = gr.Dropdown([(v, k) for k, v in FAM["en"].items()],
615
+ value="gl_escala",
616
+ label=T["en"]["familia"])
617
+ par_o = gr.Number(value=8.0, label=PARAM["en"]["gl_escala"])
618
+ sem_o = gr.Number(value=0, precision=0, label=T["en"]["sem"])
619
+ bit_o = gr.Radio([4, 8], value=4, label=T["en"]["bits"])
620
+ btn_o = gr.Button(T["en"]["btn"], variant="primary")
621
+ res_o = gr.Markdown(label=T["en"]["res"], latex_delimiters=LATEX)
622
+ fig_o = gr.Plot(label=T["en"]["fig"])
623
+ alc_o = gr.Markdown(ALCANCE["en"], label=T["en"]["alcance"],
624
+ latex_delimiters=LATEX)
625
+ tab_o = gr.Dataframe(headers=T["en"]["cab"],
626
+ label=T["en"]["porcabeza"])
627
+ mod_o.change(capas_de, mod_o, capa_o)
628
+ fam_o.change(cambia_familia, [idi_o, fam_o], par_o)
629
+ btn_o.click(orbita,
630
+ [idi_o, mod_o, capa_o, fam_o, par_o, sem_o, bit_o],
631
+ [res_o, tab_o, fig_o])
632
+ with gr.Tab(T["en"]["tab2"]) as pes2:
633
+ e2e_o = gr.Markdown(tabla_e2e("en"), latex_delimiters=LATEX)
634
+ with gr.Tab(T["en"]["tab3"]) as pes3:
635
+ qsm_o = gr.Markdown(QSM["en"], latex_delimiters=LATEX)
636
+ idi_o.change(cambia_idioma, [idi_o, fam_o],
637
+ [cab, lema, mod_o, capa_o, fam_o, par_o, sem_o,
638
+ bit_o, btn_o, res_o, tab_o, fig_o, alc_o, e2e_o,
639
+ qsm_o, pes1, pes2, pes3])
640
+
641
+ if __name__ == "__main__":
642
+ demo.launch()
artifacts/demo/sector_vo_pythia.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "modelo": "pythia",
3
+ "n_capas": 24,
4
+ "n_cabezas": 16,
5
+ "dim_cabeza": 64,
6
+ "formas": {
7
+ "w_v": [
8
+ 24,
9
+ 16,
10
+ 64,
11
+ 1024
12
+ ],
13
+ "w_o": [
14
+ 24,
15
+ 16,
16
+ 1024,
17
+ 64
18
+ ]
19
+ },
20
+ "dtype": "float32 en disco, float64 al cargar",
21
+ "sha256": "60d47a1947aa620dbca68fae9d7a832405f4f554d6cd17a5b83705b41061966e",
22
+ "bytes": 201326760
23
+ }
artifacts/demo/sector_vo_pythia.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:60d47a1947aa620dbca68fae9d7a832405f4f554d6cd17a5b83705b41061966e
3
+ size 201326760
artifacts/demo/sector_vo_vitb.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "modelo": "vitb",
3
+ "n_capas": 12,
4
+ "n_cabezas": 12,
5
+ "dim_cabeza": 64,
6
+ "formas": {
7
+ "w_v": [
8
+ 12,
9
+ 12,
10
+ 64,
11
+ 768
12
+ ],
13
+ "w_o": [
14
+ 12,
15
+ 12,
16
+ 768,
17
+ 64
18
+ ]
19
+ },
20
+ "dtype": "float32 en disco, float64 al cargar",
21
+ "sha256": "10ef9ef42a14cd3e6d3ddcd3ee3bd84a80dd3242ee3a2891766ead931607e4ad",
22
+ "bytes": 56623272
23
+ }
artifacts/demo/sector_vo_vitb.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:10ef9ef42a14cd3e6d3ddcd3ee3bd84a80dd3242ee3a2891766ead931607e4ad
3
+ size 56623272
artifacts/tables/validacion_e2e.csv ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ condicion,bits,top1,desv_invariancia,post_hoc_declarado
2
+ identidad_fp32,,0.9314,,False
3
+ identidad,4,0.9308,,False
4
+ mejor_orto_muestreado,4,0.9306,3.976204823175067e-07,False
5
+ peor_orto_muestreado,4,0.9298,4.014851526790153e-07,False
6
+ balanceado,4,0.9306,4.2091673435606936e-07,True
7
+ gl_escala2_fp32,,0.9314,1.1825894034700468e-05,True
8
+ gl_escala2,4,0.0082,1.1825894034700468e-05,True
demo/portador.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """carga del portador del sector valor-salida.
2
+
3
+ lo construye `scripts/extraer_sector_vo.py`. aquí solo se lee y se
4
+ verifica, porque el Space no lleva los modelos originales.
5
+
6
+ la regla de esta demo: no se reimplementa nada. el portador devuelve
7
+ los mismos tensores que `src.weights.w_v_w_o_*` entregaba al barrido, y
8
+ sobre ellos corren `src.gauges`, `src.quantizer` y `src.metrics` sin
9
+ un byte cambiado. si la demo y el paper discreparan, el culpable sería
10
+ el portador, y por eso lleva manifiesto con sha256.
11
+ """
12
+
13
+ import hashlib
14
+ import json
15
+ from pathlib import Path
16
+
17
+ from safetensors.torch import load_file
18
+
19
+ def _raiz() -> Path:
20
+ """la raíz del árbol, igual en el repo que en el Space.
21
+
22
+ returns:
23
+ el directorio que contiene `src` y `demo`.
24
+ """
25
+ aqui = Path(__file__).resolve()
26
+ for cand in (aqui.parent, *aqui.parents):
27
+ if (cand / "src").is_dir() and (cand / "demo").is_dir():
28
+ return cand
29
+ return aqui.parent
30
+
31
+
32
+ RAIZ_DEFECTO = _raiz() / "artifacts" / "demo"
33
+
34
+
35
+ def _sha256(ruta: Path) -> str:
36
+ """digest del fichero del portador.
37
+
38
+ args:
39
+ ruta: fichero a resumir.
40
+
41
+ returns:
42
+ sha256 en hexadecimal.
43
+ """
44
+ h = hashlib.sha256()
45
+ with ruta.open("rb") as fh:
46
+ for bloque in iter(lambda: fh.read(1 << 20), b""):
47
+ h.update(bloque)
48
+ return h.hexdigest()
49
+
50
+
51
+ def carga(modelo: str, raiz: Path | None = None) -> dict:
52
+ """lee el portador de un modelo y lo sube a float64.
53
+
54
+ el fichero guarda float32, que es la dtype de origen; subirlo a
55
+ float64 reproduce bit a bit lo que la extracción del barrido
56
+ entregaba, y deja la aritmética de gauges en fp64.
57
+
58
+ args:
59
+ modelo: 'vitb' o 'pythia'.
60
+ raiz: directorio del portador; por defecto artifacts/demo.
61
+
62
+ returns:
63
+ dict con 'w_v' [capas, cabezas, d_h, d], 'w_o' [capas,
64
+ cabezas, d, d_h] en float64, y el manifiesto bajo 'meta'.
65
+
66
+ raises:
67
+ FileNotFoundError: si falta el portador o su manifiesto.
68
+ """
69
+ raiz = Path(raiz) if raiz is not None else RAIZ_DEFECTO
70
+ fichero = raiz / f"sector_vo_{modelo}.safetensors"
71
+ manif = raiz / f"sector_vo_{modelo}.json"
72
+ if not fichero.exists() or not manif.exists():
73
+ raise FileNotFoundError(
74
+ f"falta el portador de {modelo} en {raiz}; constrúyelo con "
75
+ f"scripts/extraer_sector_vo.py --modelo {modelo}")
76
+ meta = json.loads(manif.read_text())
77
+ t = load_file(str(fichero))
78
+ return {"w_v": t["w_v"].double(), "w_o": t["w_o"].double(),
79
+ "meta": meta}
80
+
81
+
82
+ def verifica_manifiesto(modelo: str, raiz: Path | None = None) -> None:
83
+ """comprueba sha256 y formas contra el manifiesto.
84
+
85
+ args:
86
+ modelo: 'vitb' o 'pythia'.
87
+ raiz: directorio del portador.
88
+
89
+ raises:
90
+ RuntimeError: si el digest o alguna forma no coinciden.
91
+ """
92
+ raiz = Path(raiz) if raiz is not None else RAIZ_DEFECTO
93
+ fichero = raiz / f"sector_vo_{modelo}.safetensors"
94
+ meta = json.loads((raiz / f"sector_vo_{modelo}.json").read_text())
95
+ digest = _sha256(fichero)
96
+ if digest != meta["sha256"]:
97
+ raise RuntimeError(
98
+ f"el portador de {modelo} no es el del manifiesto: "
99
+ f"{digest[:16]} frente a {meta['sha256'][:16]}")
100
+ t = load_file(str(fichero))
101
+ for clave, forma in meta["formas"].items():
102
+ if list(t[clave].shape) != forma:
103
+ raise RuntimeError(
104
+ f"forma inesperada en {clave}: {list(t[clave].shape)} "
105
+ f"frente a {forma}")
106
+
107
+
108
+ def par(portador: dict, capa: int, cabeza: int) -> tuple:
109
+ """(w_v, w_o) de una cabeza, en float64.
110
+
111
+ args:
112
+ portador: el dict que devuelve `carga`.
113
+ capa: índice de capa.
114
+ cabeza: índice de cabeza.
115
+
116
+ returns:
117
+ (w_v [d_h, d], w_o [d, d_h]) en float64.
118
+ """
119
+ return portador["w_v"][capa, cabeza], portador["w_o"][capa, cabeza]
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # entorno del Space: la demo es álgebra sobre pesos. no necesita
2
+ # transformers, timm ni pandas; torch en su rueda de cpu basta.
3
+ --extra-index-url https://download.pytorch.org/whl/cpu
4
+ gradio==6.22.0
5
+ torch==2.11.0
6
+ safetensors==0.6.2
7
+ numpy
8
+ matplotlib
src/__init__.py ADDED
File without changes
src/config.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """cargador único de configuraciones YAML."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ import yaml
7
+
8
+
9
+ class _Config(dict):
10
+ """configuración que exige valor a las rutas sin declarar."""
11
+
12
+ def __getitem__(self, clave: str) -> object:
13
+ """devuelve el valor de una clave, o explica que falta.
14
+
15
+ args:
16
+ clave: nombre de la entrada de configuración.
17
+
18
+ returns:
19
+ el valor asociado a la clave.
20
+
21
+ raises:
22
+ ValueError: si la clave existe pero está sin declarar.
23
+ """
24
+ valor = super().__getitem__(clave)
25
+ if valor in (None, ""):
26
+ raise ValueError(
27
+ f"«{clave}» está sin declarar: es una ruta del árbol "
28
+ f"propio y no se versiona. Se declara en el fichero "
29
+ f"«.local.yaml» que acompaña a la configuración, o por "
30
+ f"variable de entorno. Ver LEEME.md."
31
+ )
32
+ return valor
33
+
34
+
35
+ def _lee(ruta: Path) -> dict:
36
+ """lee un yaml y devuelve su contenido, vacío si no trae nada.
37
+
38
+ args:
39
+ ruta: ruta al fichero .yaml.
40
+
41
+ returns:
42
+ diccionario con el contenido del yaml.
43
+ """
44
+ with open(ruta, encoding="utf-8") as fh:
45
+ return yaml.safe_load(fh) or {}
46
+
47
+
48
+ def _expande(valor: str | None) -> str | None:
49
+ """expande variables de entorno en un valor de texto.
50
+
51
+ args:
52
+ valor: entrada de la configuración.
53
+
54
+ returns:
55
+ el valor con las variables de entorno ya sustituidas.
56
+ """
57
+ return os.path.expandvars(valor) if isinstance(valor, str) else valor
58
+
59
+
60
+ def load_config(path: str) -> dict:
61
+ """lee un yaml y le superpone su variante local, si existe.
62
+
63
+ las rutas que apuntan al árbol propio no se versionan: las aporta
64
+ el fichero «<nombre>.local.yaml» que acompaña al de la
65
+ configuración, con prioridad sobre él.
66
+
67
+ args:
68
+ path: ruta al fichero .yaml.
69
+
70
+ returns:
71
+ configuración ya superpuesta y con las variables de entorno
72
+ expandidas.
73
+ """
74
+ base = Path(path)
75
+ cfg = _lee(base)
76
+ local = base.with_suffix(".local.yaml")
77
+ if local.exists():
78
+ cfg.update(_lee(local))
79
+ return _Config({k: _expande(v) for k, v in cfg.items()})
src/datos.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """loader mínimo del val limpio de imagenet-100, solo para la
2
+ validación end-to-end (sec. 2.4 del spec). reimplementación
3
+ autocontenida (val-only) del loader ya usado en el paper 1.
4
+ """
5
+
6
+ from pathlib import Path
7
+
8
+ import torch
9
+ from torch.utils.data import DataLoader, Dataset
10
+ from torchvision import datasets, transforms
11
+
12
+ _NORM = transforms.Normalize(
13
+ mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225))
14
+
15
+
16
+ class _RemapEtiquetas(Dataset):
17
+ """envuelve un imagefolder filtrado a los wnids canónicos y
18
+ remapea las etiquetas al orden de la lista (0..99)."""
19
+
20
+ def __init__(self, base: datasets.ImageFolder,
21
+ indices: list[int], mapa: dict[int, int]) -> None:
22
+ self.base = base
23
+ self.indices = indices
24
+ self.mapa = mapa
25
+
26
+ def __len__(self) -> int:
27
+ return len(self.indices)
28
+
29
+ def __getitem__(self, i: int) -> tuple[torch.Tensor, int]:
30
+ img, lbl = self.base[self.indices[i]]
31
+ return img, self.mapa[int(lbl)]
32
+
33
+
34
+ def val_loader_imagenet100(
35
+ root: str,
36
+ wnids_path: str,
37
+ image_size: int = 224,
38
+ batch_size: int = 128,
39
+ num_workers: int = 8,
40
+ ) -> DataLoader:
41
+ """loader del split val de imagenet-100, orden canónico cmc.
42
+
43
+ args:
44
+ root: carpeta con `val/<wnid>/...` (estructura imagefolder).
45
+ wnids_path: fichero con un synset por línea, orden canónico.
46
+ image_size: lado del crop final.
47
+ batch_size: tamaño de lote.
48
+ num_workers: procesos de carga.
49
+
50
+ returns:
51
+ dataloader sin barajar sobre el val completo.
52
+ """
53
+ wnids = [ln.strip() for ln in Path(wnids_path).read_text().splitlines()
54
+ if ln.strip()]
55
+ tfm = transforms.Compose([
56
+ transforms.Resize(int(image_size * 256 / 224)),
57
+ transforms.CenterCrop(image_size),
58
+ transforms.ToTensor(),
59
+ _NORM,
60
+ ])
61
+ base = datasets.ImageFolder(Path(root) / "val", transform=tfm)
62
+ wnid_a_idx = {w: i for i, w in enumerate(wnids)}
63
+ orig_a_nuevo = {
64
+ orig: wnid_a_idx[synset]
65
+ for synset, orig in base.class_to_idx.items()
66
+ if synset in wnid_a_idx
67
+ }
68
+ indices = [i for i, (_, lbl) in enumerate(base.samples)
69
+ if lbl in orig_a_nuevo]
70
+ ds = _RemapEtiquetas(base, indices, orig_a_nuevo)
71
+ return DataLoader(ds, batch_size=batch_size, shuffle=False,
72
+ num_workers=num_workers, pin_memory=True)
73
+
74
+
75
+ @torch.no_grad()
76
+ def top1(modelo: torch.nn.Module, loader: DataLoader,
77
+ disp: str = "cuda") -> float:
78
+ """exactitud top-1 del modelo sobre el loader.
79
+
80
+ args:
81
+ modelo: en eval, sobre `disp`.
82
+ loader: dataloader de validación.
83
+ disp: dispositivo.
84
+
85
+ returns:
86
+ fracción de aciertos.
87
+ """
88
+ modelo.eval()
89
+ aciertos, total = 0, 0
90
+ for imgs, lbls in loader:
91
+ logits = modelo(imgs.to(disp, non_blocking=True))
92
+ pred = logits.argmax(dim=1).cpu()
93
+ aciertos += int((pred == lbls).sum())
94
+ total += int(lbls.numel())
95
+ return aciertos / total
96
+
97
+
98
+ @torch.no_grad()
99
+ def predicciones_y_aciertos(
100
+ modelo: torch.nn.Module,
101
+ loader: DataLoader,
102
+ disp: str = "cuda",
103
+ ) -> tuple[torch.Tensor, torch.Tensor]:
104
+ """clase predicha y acierto por imagen, en el orden del loader.
105
+
106
+ las dos cosas, y no solo el acierto, porque responden a preguntas
107
+ distintas. el acierto sostiene el contraste pareado de mcnemar:
108
+ dos condiciones sobre las mismas imágenes se comparan por sus
109
+ discordantes. la clase predicha sostiene una afirmación más
110
+ fuerte: cero discordantes de mcnemar dice que ambos modelos
111
+ aciertan y fallan las mismas imágenes, y deja abierto que fallen
112
+ prediciendo clases distintas. quien quiera afirmar «predice lo
113
+ mismo» necesita comparar el argmax.
114
+
115
+ args:
116
+ modelo: en eval, sobre `disp`.
117
+ loader: dataloader de validación, sin barajar.
118
+ disp: dispositivo.
119
+
120
+ returns:
121
+ (pred [n] int64 con la clase predicha, ok [n] bool).
122
+ """
123
+ modelo.eval()
124
+ preds, oks = [], []
125
+ for imgs, lbls in loader:
126
+ pred = modelo(imgs.to(disp, non_blocking=True)).argmax(dim=1).cpu()
127
+ preds.append(pred)
128
+ oks.append(pred == lbls)
129
+ return torch.cat(preds), torch.cat(oks)
130
+
131
+
132
+ @torch.no_grad()
133
+ def aciertos_por_imagen(
134
+ modelo: torch.nn.Module,
135
+ loader: DataLoader,
136
+ disp: str = "cuda",
137
+ ) -> torch.Tensor:
138
+ """solo el vector de aciertos; envoltorio de la función anterior.
139
+
140
+ args:
141
+ modelo: en eval, sobre `disp`.
142
+ loader: dataloader de validación, sin barajar.
143
+ disp: dispositivo.
144
+
145
+ returns:
146
+ tensor bool [n], True donde la predicción acierta.
147
+ """
148
+ return predicciones_y_aciertos(modelo, loader, disp)[1]
src/gauges.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """construcción de gauges valor-salida en fp64.
2
+
3
+ reutiliza la construcción del paper 1 (angular-separation-vit):
4
+ ortogonales vía qr de una gaussiana (haar), gl generales vía
5
+ randn + escala·identidad. toda la aritmética en float64 —la lección
6
+ del r casi-singular del paper 1 es constitutiva aquí, no una nota al
7
+ pie.
8
+ """
9
+
10
+ import torch
11
+
12
+
13
+ def ortogonal(
14
+ dim_cabeza: int,
15
+ generador: torch.Generator,
16
+ ) -> torch.Tensor:
17
+ """muestra una rotación haar-aleatoria en o(dim_cabeza), fp64.
18
+
19
+ args:
20
+ dim_cabeza: d_h, tamaño de la matriz cuadrada.
21
+ generador: generador de torch para reproducibilidad.
22
+
23
+ returns:
24
+ tensor [d_h, d_h] ortogonal en float64.
25
+ """
26
+ a = torch.randn(dim_cabeza, dim_cabeza, generator=generador,
27
+ dtype=torch.float64)
28
+ q, r = torch.linalg.qr(a)
29
+ # signo de la diagonal de r fija el signo de q (convención qr no
30
+ # es única si no se corrige); sin esto la distribución no es haar.
31
+ signos = torch.sign(torch.diagonal(r))
32
+ signos[signos == 0] = 1.0
33
+ return q * signos
34
+
35
+
36
+ def gl_general(
37
+ dim_cabeza: int,
38
+ escala: float,
39
+ generador: torch.Generator,
40
+ ) -> torch.Tensor:
41
+ """muestra r = randn + escala·i en gl(dim_cabeza), fp64.
42
+
43
+ escala alta acerca r a un múltiplo escalar de la identidad (gauge
44
+ débil, bien condicionado); escala baja lo aleja (gauge fuerte,
45
+ condición alta, la cola que la nota espera que cuantice mal).
46
+
47
+ args:
48
+ dim_cabeza: d_h.
49
+ escala: coeficiente de la identidad.
50
+ generador: generador de torch para reproducibilidad.
51
+
52
+ returns:
53
+ tensor [d_h, d_h] en float64, invertible con probabilidad 1.
54
+ """
55
+ ident = torch.eye(dim_cabeza, dtype=torch.float64)
56
+ ruido = torch.randn(dim_cabeza, dim_cabeza, generator=generador,
57
+ dtype=torch.float64)
58
+ return ruido + escala * ident
59
+
60
+
61
+ def aplica_gauge(
62
+ w_v_h: torch.Tensor,
63
+ w_o_h: torch.Tensor,
64
+ r: torch.Tensor,
65
+ ) -> tuple[torch.Tensor, torch.Tensor]:
66
+ """transforma (w_v^(h), w_o^(h)) bajo el gauge r, sin tocar el circuito.
67
+
68
+ convención: w_v^(h) es [d_h, d] (filas de valor), w_o^(h) es
69
+ [d, d_h] (columnas de salida). w_v <- r^t w_v, w_o <- w_o r^-t;
70
+ el circuito m = w_o w_v es invariante para cualquier r en
71
+ gl(d_h) (no solo ortogonal): m' = (w_o r^-t)(r^t w_v)
72
+ = w_o (r^-t r^t) w_v = w_o w_v, ya que r^-t r^t = (r^t)^-1 r^t = i.
73
+
74
+ args:
75
+ w_v_h: tensor [d_h, d] en float64.
76
+ w_o_h: tensor [d, d_h] en float64.
77
+ r: gauge [d_h, d_h] en float64, invertible.
78
+
79
+ returns:
80
+ (w_v_h transformado, w_o_h transformado), misma forma.
81
+ """
82
+ r_inv_t = torch.linalg.inv(r).transpose(-2, -1)
83
+ return r.transpose(-2, -1) @ w_v_h, w_o_h @ r_inv_t
84
+
85
+
86
+ def gl_condicionado(
87
+ dim_cabeza: int,
88
+ kappa: float,
89
+ generador: torch.Generator,
90
+ ) -> torch.Tensor:
91
+ """muestra r en gl(dim_cabeza) con número de condición kappa exacto.
92
+
93
+ se toma una gaussiana, se le extrae la svd y se sustituye su
94
+ espectro por uno geométrico entre 1 y kappa: las bases quedan
95
+ haar-aleatorias y el condicionamiento pasa a ser la variable
96
+ controlada, en vez de una consecuencia de la escala. la
97
+ alternativa declarada y no elegida es el espectro de un solo
98
+ outlier (sigma_1 = kappa, resto 1).
99
+
100
+ kappa = 1 devuelve una matriz ortogonal, que es el ancla del
101
+ suelo e_0 del ajuste.
102
+
103
+ args:
104
+ dim_cabeza: d_h.
105
+ kappa: número de condición objetivo, >= 1.
106
+ generador: generador de torch para reproducibilidad.
107
+
108
+ returns:
109
+ tensor [d_h, d_h] en float64 con cond(r) = kappa.
110
+
111
+ raises:
112
+ ValueError: si kappa < 1.
113
+ """
114
+ if kappa < 1.0:
115
+ raise ValueError(f"kappa debe ser >= 1: {kappa}")
116
+ a = torch.randn(dim_cabeza, dim_cabeza, generator=generador,
117
+ dtype=torch.float64)
118
+ u, _, vh = torch.linalg.svd(a)
119
+ # espectro geométrico descendente de kappa a 1; el cociente entre
120
+ # el mayor y el menor es kappa por construcción.
121
+ exponentes = torch.linspace(1.0, 0.0, dim_cabeza, dtype=torch.float64)
122
+ s = torch.pow(torch.tensor(kappa, dtype=torch.float64), exponentes)
123
+ return (u * s) @ vh
124
+
125
+
126
+ def producto_normas(
127
+ w_v_h: torch.Tensor,
128
+ w_o_h: torch.Tensor,
129
+ r: torch.Tensor | None = None,
130
+ ) -> float:
131
+ """p(r) = ||r^t w_v||_f ||w_o r^-t||_f / (||w_v||_f ||w_o||_f).
132
+
133
+ la variable de la ley del producto: el inflado del error de
134
+ cuantización a primer orden. vale 1 en la identidad y 1 en toda
135
+ la clase conforme ortogonal {c·q}, porque ||w_v (cq)|| escala por
136
+ c y ||(cq)^-1 w_o|| por 1/c.
137
+
138
+ args:
139
+ w_v_h: tensor [d_h, d] en float64, sin gauge.
140
+ w_o_h: tensor [d, d_h] en float64, sin gauge.
141
+ r: gauge [d_h, d_h]; None denota la identidad (p = 1).
142
+
143
+ returns:
144
+ p(r), escalar float.
145
+ """
146
+ base = float(w_v_h.norm() * w_o_h.norm())
147
+ if r is None:
148
+ return 1.0
149
+ w_v_g, w_o_g = aplica_gauge(w_v_h, w_o_h, r)
150
+ return float(w_v_g.norm() * w_o_g.norm()) / base
151
+
152
+
153
+ def gauge_balanceado(
154
+ w_v_h: torch.Tensor,
155
+ w_o_h: torch.Tensor,
156
+ tol_rango: float = 1e-10,
157
+ ) -> tuple[torch.Tensor, float]:
158
+ """el gauge que lleva el par a su factorización balanceada.
159
+
160
+ el punto balanceado del paper 1 (lema del punto balanceado)
161
+ minimiza ||w_v r||_f^2 + ||r^-1 w_o||_f^2 sobre la órbita a
162
+ circuito fijo, y se construye por svd del circuito: con
163
+ m = w_o w_v = u s v^t de rango d_h, el par balanceado es
164
+ a = u s^{1/2} y b = s^{1/2} v^t.
165
+
166
+ el enlace con esta nota es exacto: ||a||_f ||b||_f = tr(s) =
167
+ ||m||_*, que es el mínimo del producto de normas sobre la órbita
168
+ entera (desigualdad de la norma nuclear, ||m||_* <= ||a||_f
169
+ ||b||_f para toda factorización m = ab). el punto balanceado es
170
+ entonces el óptimo que la ley del producto predice, y es un gauge
171
+ NO ortogonal en general: el contraejemplo medido a «la
172
+ restricción ortogonal es necesaria».
173
+
174
+ convención de este repo: m = w_o w_v, y el gauge actúa como
175
+ w_v <- r^t w_v, w_o <- w_o r^-t. con s = r^t se pide
176
+ w_o s^-1 = a y s w_v = b.
177
+
178
+ args:
179
+ w_v_h: tensor [d_h, d] en float64.
180
+ w_o_h: tensor [d, d_h] en float64.
181
+ tol_rango: valor singular mínimo relativo para declarar el
182
+ circuito de rango completo.
183
+
184
+ returns:
185
+ (r [d_h, d_h] en float64, p_balanceado), donde p_balanceado
186
+ es ||m||_* / (||w_v||_f ||w_o||_f) <= 1, el valor de la ley
187
+ del producto en ese punto.
188
+
189
+ raises:
190
+ ValueError: si el circuito no tiene rango d_h, donde el punto
191
+ balanceado no es alcanzable por un gauge de gl(d_h).
192
+ """
193
+ d_h = w_v_h.shape[0]
194
+ m = w_o_h @ w_v_h
195
+ u, s, vh = torch.linalg.svd(m, full_matrices=False)
196
+ u, s, vh = u[:, :d_h], s[:d_h], vh[:d_h]
197
+ if float(s[-1]) <= tol_rango * float(s[0]):
198
+ raise ValueError(
199
+ f"circuito de rango deficiente: s_min/s_max = "
200
+ f"{float(s[-1]) / float(s[0]):.2e} <= {tol_rango:.0e}")
201
+ raiz = s.sqrt()
202
+ a_bal = u * raiz # [d, d_h]
203
+ # s^-1 = w_o^+ a_bal; r = s^t = (s^-1)^-t
204
+ s_inv = torch.linalg.pinv(w_o_h) @ a_bal
205
+ r = torch.linalg.inv(s_inv).transpose(-2, -1)
206
+ p_bal = float(s.sum()) / float(w_v_h.norm() * w_o_h.norm())
207
+ return r, p_bal
src/metrics.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """error relativo del circuito ov, recompuesto siempre en fp64.
2
+
3
+ el circuito m = w_o^(h) w_v^(h) es la cantidad gauge-invariante (ver
4
+ paper 1, sec:gauge). la nota mide cuánto se aleja la versión
5
+ cuantizada-y-recompuesta de la referencia exacta.
6
+ """
7
+
8
+ import torch
9
+
10
+
11
+ def circuito(w_v_h: torch.Tensor, w_o_h: torch.Tensor) -> torch.Tensor:
12
+ """circuito m = w_o^(h) w_v^(h), [d, d].
13
+
14
+ args:
15
+ w_v_h: tensor [d_h, d].
16
+ w_o_h: tensor [d, d_h].
17
+
18
+ returns:
19
+ tensor [d, d] en la dtype de entrada.
20
+ """
21
+ return w_o_h @ w_v_h
22
+
23
+
24
+ def error_relativo(
25
+ w_v_hat: torch.Tensor,
26
+ w_o_hat: torch.Tensor,
27
+ m_referencia: torch.Tensor,
28
+ ) -> float:
29
+ """e = ||m_hat - m_referencia||_f / ||m_referencia||_f, en fp64.
30
+
31
+ args:
32
+ w_v_hat: w_v^(h) tras gauge y cuantización, [d_h, d].
33
+ w_o_hat: w_o^(h) tras gauge y cuantización, [d, d_h].
34
+ m_referencia: circuito exacto de referencia (sin gauge, sin
35
+ cuantizar), [d, d], en float64.
36
+
37
+ returns:
38
+ error relativo de frobenius, escalar float.
39
+ """
40
+ m_hat = circuito(w_v_hat.double(), w_o_hat.double())
41
+ return float(
42
+ (m_hat - m_referencia).norm() / m_referencia.norm())
src/quantizer.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """cuantizador rtn simétrico por canal, con des-cuantización.
2
+
3
+ round-to-nearest puro: sin gptq, sin rotaciones aprendidas, sin
4
+ calibración con datos. la simplicidad es deliberada —aísla la
5
+ variable de interés (el gauge), la nota no compite con métodos de
6
+ cuantización, los caracteriza (ver spec, sec. 2.2).
7
+ """
8
+
9
+ import torch
10
+
11
+ _QMAX = {4: 7, 8: 127} # 2^(bits-1) - 1, rango simétrico con signo
12
+
13
+
14
+ def rtn_cuantiza_descuantiza(
15
+ w: torch.Tensor,
16
+ bits: int,
17
+ ) -> torch.Tensor:
18
+ """cuantiza y descuantiza w por canal (fila), simétrico, rtn.
19
+
20
+ cada fila obtiene su propia escala —el "canal" es la fila, la
21
+ convención estándar para pesos [salida, entrada]—. la aritmética
22
+ es en la dtype de entrada (se espera float64, el suelo numérico
23
+ del resto de la nota); no hay almacenamiento entero real, se
24
+ simula la rejilla de cuantización.
25
+
26
+ args:
27
+ w: tensor [filas, columnas].
28
+ bits: 4 u 8.
29
+
30
+ returns:
31
+ tensor de la misma forma y dtype, redondeado a la rejilla de
32
+ `bits` y devuelto a escala real (des-cuantizado).
33
+ """
34
+ if bits not in _QMAX:
35
+ raise ValueError(f"bits no soportado: {bits}")
36
+ qmax = _QMAX[bits]
37
+ escala = w.abs().amax(dim=-1, keepdim=True) / qmax
38
+ # fila nula: escala 0/0 -> nan; se fija a 1 (la fila cuantizada es
39
+ # 0 de todas formas, la escala no importa).
40
+ escala = torch.where(escala > 0, escala, torch.ones_like(escala))
41
+ q = torch.round(w / escala).clamp(min=-qmax, max=qmax)
42
+ return q * escala
src/reconstruccion.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """reconstruye un gauge concreto del barrido, re-jugando la misma
2
+ secuencia de muestreo que ``scripts/sweep.py::barre_capa`` con el
3
+ mismo generador y semilla. necesario para la validación end-to-end
4
+ (sec. 2.4 del spec): identifica el mejor/peor gauge MUESTREADO por el
5
+ barrido y lo reconstruye exactamente, sin persistir cada r en el csv.
6
+ """
7
+
8
+ import torch
9
+
10
+ from src.gauges import gl_general, ortogonal
11
+
12
+ K_MUESTRAS = 32
13
+ ESCALAS_GL = [2.0, 8.0, 32.0]
14
+
15
+
16
+ def reconstruye_r(
17
+ capa_objetivo: int,
18
+ cabeza_objetivo: int,
19
+ regimen_objetivo: str,
20
+ muestra_objetivo: int,
21
+ n_cabezas: int,
22
+ dim_cabeza: int,
23
+ n_capas_previas_por_capa: dict[int, int] | None = None,
24
+ semilla: int = 0,
25
+ escala_objetivo: float | None = None,
26
+ ) -> torch.Tensor:
27
+ """re-juega el muestreo hasta dar con el r exacto pedido.
28
+
29
+ reproduce el orden de ``barre_capa``: por capa, primero los k
30
+ gauges compartidos (orto_comp), luego por cabeza los k orto_ph
31
+ seguidos de los k·3 gl_e (por escala). se recorre capa a capa
32
+ desde 0 hasta la objetivo, consumiendo el generador exactamente
33
+ como el barrido original, y se descartan los r que no hacen falta.
34
+
35
+ args:
36
+ capa_objetivo: capa del gauge buscado.
37
+ cabeza_objetivo: cabeza del gauge buscado (ignorada si
38
+ regimen_objetivo es 'orto_comp', que es por-capa).
39
+ regimen_objetivo: 'orto_ph', 'orto_comp' o 'gl_e'.
40
+ muestra_objetivo: índice de muestra (0..31).
41
+ n_cabezas: cabezas por capa del modelo (constante).
42
+ dim_cabeza: d_h.
43
+ n_capas_previas_por_capa: sin uso (todas las capas del modelo
44
+ tienen el mismo n_cabezas); se mantiene por si algún
45
+ modelo futuro varía cabezas por capa.
46
+ semilla: semilla global del barrido (por defecto la del
47
+ barrido real, ver scripts/sweep.py --semilla).
48
+ escala_objetivo: escala de gl_e buscada (solo si
49
+ regimen_objetivo == 'gl_e').
50
+
51
+ returns:
52
+ tensor [d_h, d_h] en float64, idéntico al usado en el barrido.
53
+ """
54
+ gen = torch.Generator().manual_seed(semilla)
55
+ for capa in range(capa_objetivo + 1):
56
+ compartidos = [ortogonal(dim_cabeza, gen)
57
+ for _ in range(K_MUESTRAS)]
58
+ if capa == capa_objetivo and regimen_objetivo == "orto_comp":
59
+ return compartidos[muestra_objetivo]
60
+ ultima_capa = capa == capa_objetivo
61
+ for h in range(n_cabezas):
62
+ orto_ph = [ortogonal(dim_cabeza, gen)
63
+ for _ in range(K_MUESTRAS)]
64
+ if (ultima_capa and h == cabeza_objetivo
65
+ and regimen_objetivo == "orto_ph"):
66
+ return orto_ph[muestra_objetivo]
67
+ for escala in ESCALAS_GL:
68
+ gl = [gl_general(dim_cabeza, escala, gen)
69
+ for _ in range(K_MUESTRAS)]
70
+ if (ultima_capa and h == cabeza_objetivo
71
+ and regimen_objetivo == "gl_e"
72
+ and escala == escala_objetivo):
73
+ return gl[muestra_objetivo]
74
+ raise ValueError(
75
+ f"no se encontró capa={capa_objetivo} cabeza={cabeza_objetivo} "
76
+ f"regimen={regimen_objetivo} muestra={muestra_objetivo}")
77
+
78
+
79
+ def reconstruye_muchos(
80
+ objetivos: set[tuple],
81
+ n_capas: int,
82
+ n_cabezas: int,
83
+ dim_cabeza: int,
84
+ semilla: int = 0,
85
+ ) -> dict[tuple, torch.Tensor]:
86
+ """como `reconstruye_r`, pero en una sola pasada para muchos r's.
87
+
88
+ evita rejugar la secuencia desde cero por cada objetivo (o(n) en
89
+ vez de o(n²)): recorre el modelo entero una vez y captura cada r
90
+ pedido al pasar por su punto exacto.
91
+
92
+ args:
93
+ objetivos: conjunto de tuplas
94
+ (capa, cabeza, regimen, muestra, escala) — escala es
95
+ ``None`` salvo para regimen='gl_e'. en 'orto_comp' la r es
96
+ por capa y la misma para todas las cabezas, pero la clave
97
+ exige el índice real de la cabeza: se registra una entrada
98
+ por cabeza, no un comodín.
99
+ n_capas: capas totales del modelo (recorre 0..n_capas-1).
100
+ n_cabezas: cabezas por capa.
101
+ dim_cabeza: d_h.
102
+ semilla: semilla global del barrido.
103
+
104
+ returns:
105
+ dict objetivo -> tensor [d_h, d_h] en float64, con todas las
106
+ claves de `objetivos` presentes (lanza si falta alguna).
107
+ """
108
+ restantes = set(objetivos)
109
+ hallados: dict[tuple, torch.Tensor] = {}
110
+ gen = torch.Generator().manual_seed(semilla)
111
+ for capa in range(n_capas):
112
+ compartidos = [ortogonal(dim_cabeza, gen)
113
+ for _ in range(K_MUESTRAS)]
114
+ for m, r in enumerate(compartidos):
115
+ for cab in range(n_cabezas):
116
+ clave = (capa, cab, "orto_comp", m, None)
117
+ if clave in restantes:
118
+ hallados[clave] = r
119
+ for h in range(n_cabezas):
120
+ orto_ph = [ortogonal(dim_cabeza, gen)
121
+ for _ in range(K_MUESTRAS)]
122
+ for m, r in enumerate(orto_ph):
123
+ clave = (capa, h, "orto_ph", m, None)
124
+ if clave in restantes:
125
+ hallados[clave] = r
126
+ for escala in ESCALAS_GL:
127
+ gl = [gl_general(dim_cabeza, escala, gen)
128
+ for _ in range(K_MUESTRAS)]
129
+ for m, r in enumerate(gl):
130
+ clave = (capa, h, "gl_e", m, escala)
131
+ if clave in restantes:
132
+ hallados[clave] = r
133
+ faltan = restantes - set(hallados)
134
+ if faltan:
135
+ raise ValueError(f"objetivos no encontrados: {faltan}")
136
+ return hallados
src/weights.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """carga de modelos y extracción por-cabeza de (w_v, w_o).
2
+
3
+ reimplementación autocontenida, para este repo, de la extracción ya
4
+ verificada en el paper 1 (angular-separation-vit): gpt-neox fusiona
5
+ q,k,v por cabeza intercalado (``query_key_value``); timm concatena
6
+ [q,k,v] por bloque y v ocupa el último tercio (``qkv``). convención
7
+ de forma en todo el repo: w_v^(h) es [d_h, d], w_o^(h) es [d, d_h].
8
+ """
9
+
10
+ import torch
11
+
12
+
13
+ def cargar_pythia(
14
+ model_id: str = "EleutherAI/pythia-410m",
15
+ dtype: torch.dtype = torch.float32,
16
+ ):
17
+ """carga pythia-410m y verifica su anatomía contra el config real.
18
+
19
+ args:
20
+ model_id: identificador del modelo en hugging face hub.
21
+ dtype: precisión de carga.
22
+
23
+ returns:
24
+ el modelo en modo evaluación.
25
+ """
26
+ from transformers import AutoModelForCausalLM
27
+
28
+ modelo = AutoModelForCausalLM.from_pretrained(
29
+ model_id, dtype=dtype, attn_implementation="eager")
30
+ modelo.eval()
31
+ cfg = modelo.config
32
+ dim_cabeza = cfg.hidden_size // cfg.num_attention_heads
33
+ assert dim_cabeza == 64, f"dim_cabeza inesperada: {dim_cabeza}"
34
+ assert cfg.num_attention_heads == 16, "num_attention_heads != 16"
35
+ assert cfg.hidden_size == 1024, "hidden_size != 1024"
36
+ assert len(modelo.gpt_neox.layers) == 24, "num capas != 24"
37
+ return modelo
38
+
39
+
40
+ @torch.no_grad()
41
+ def w_v_w_o_pythia(
42
+ modelo,
43
+ capa: int,
44
+ n_cabezas: int = 16,
45
+ dim_cabeza: int = 64,
46
+ ) -> tuple[torch.Tensor, torch.Tensor]:
47
+ """(w_v, w_o) por cabeza de una capa de pythia, en float64.
48
+
49
+ query_key_value fusiona q, k y v por cabeza: cada cabeza ocupa
50
+ ``3*dim_cabeza`` filas contiguas ``[q_h|k_h|v_h]``; v_h es el
51
+ último tercio. dense es la proyección de salida (columnas por
52
+ cabeza).
53
+
54
+ args:
55
+ modelo: modelo gpt-neox (pythia) cargado.
56
+ capa: índice de capa.
57
+ n_cabezas: cabezas h.
58
+ dim_cabeza: d_h.
59
+
60
+ returns:
61
+ (w_v [h, d_h, d], w_o [h, d, d_h]), float64.
62
+ """
63
+ attn = modelo.gpt_neox.layers[capa].attention
64
+ w_qkv = attn.query_key_value.weight # [3d, d]
65
+ w_dense = attn.dense.weight # [d, d]
66
+ w_v, w_o = [], []
67
+ for h in range(n_cabezas):
68
+ base = h * 3 * dim_cabeza + 2 * dim_cabeza
69
+ w_v.append(w_qkv[base:base + dim_cabeza, :].double())
70
+ w_o.append(w_dense[:, h * dim_cabeza:(h + 1) * dim_cabeza]
71
+ .double())
72
+ return torch.stack(w_v), torch.stack(w_o)
73
+
74
+
75
+ def cargar_vitb(
76
+ ckpt_path: str,
77
+ model_name: str = "vit_base_patch16_224",
78
+ num_classes: int = 100,
79
+ img_size: int = 224,
80
+ ):
81
+ """carga la columna vit-b con los pesos de un checkpoint de a.
82
+
83
+ args:
84
+ ckpt_path: ruta al checkpoint (.pt con clave 'model',
85
+ prefijo 'backbone.' en las claves).
86
+ model_name: identificador timm de la columna.
87
+ num_classes: clases de la cabeza.
88
+ img_size: resolución de entrada.
89
+
90
+ returns:
91
+ el vit interno de timm (el que expone .blocks), en eval.
92
+ """
93
+ import timm
94
+
95
+ modelo = timm.create_model(
96
+ model_name, pretrained=False, num_classes=num_classes,
97
+ img_size=img_size)
98
+ blob = torch.load(ckpt_path, map_location="cpu", weights_only=False)
99
+ # el checkpoint viene de un wrapper HeadProjections del paper 1:
100
+ # las claves llevan 'backbone.model.' antes de la state_dict plana
101
+ # de timm ('backbone.' del checkpoint de entrenamiento, 'model.'
102
+ # del atributo submódulo del wrapper).
103
+ sd = {k.replace("backbone.model.", "", 1): v
104
+ for k, v in blob["model"].items()}
105
+ modelo.load_state_dict(sd)
106
+ return modelo.eval()
107
+
108
+
109
+ @torch.no_grad()
110
+ def w_v_w_o_vitb(
111
+ modelo,
112
+ capa: int,
113
+ n_cabezas: int = 12,
114
+ dim_cabeza: int = 64,
115
+ ) -> tuple[torch.Tensor, torch.Tensor]:
116
+ """(w_v, w_o) por cabeza de una capa de vit-b, en float64.
117
+
118
+ en timm el qkv concatena [q, k, v] por bloque; v ocupa el último
119
+ tercio y se reordena por cabeza. proj es la proyección de salida.
120
+
121
+ args:
122
+ modelo: el vit interno de timm.
123
+ capa: índice de capa.
124
+ n_cabezas: cabezas h.
125
+ dim_cabeza: d_h.
126
+
127
+ returns:
128
+ (w_v [h, d_h, d], w_o [h, d, d_h]), float64.
129
+ """
130
+ attn = modelo.blocks[capa].attn
131
+ w_qkv = attn.qkv.weight # [3d, d]
132
+ w_proj = attn.proj.weight # [d, d]
133
+ base_v = 2 * w_qkv.shape[1]
134
+ w_v, w_o = [], []
135
+ for h in range(n_cabezas):
136
+ fil = slice(base_v + h * dim_cabeza, base_v + (h + 1) * dim_cabeza)
137
+ col = slice(h * dim_cabeza, (h + 1) * dim_cabeza)
138
+ w_v.append(w_qkv[fil, :].double())
139
+ w_o.append(w_proj[:, col].double())
140
+ return torch.stack(w_v), torch.stack(w_o)
141
+
142
+
143
+ @torch.no_grad()
144
+ def escribe_v_o_vitb(
145
+ modelo,
146
+ capa: int,
147
+ h: int,
148
+ w_v_h: torch.Tensor,
149
+ w_o_h: torch.Tensor,
150
+ dim_cabeza: int = 64,
151
+ ) -> None:
152
+ """escribe (w_v, w_o) de una cabeza de vuelta en el modelo, in situ.
153
+
154
+ inversa de `w_v_w_o_vitb` para una sola cabeza: usada por la
155
+ validación end-to-end para materializar un modelo con los pesos
156
+ ya gaugeados-y-cuantizados antes del forward real.
157
+
158
+ args:
159
+ modelo: el vit interno de timm, modificado in situ.
160
+ capa: índice de capa.
161
+ h: índice de cabeza.
162
+ w_v_h: tensor [d_h, d] (cualquier dtype, se castea al del
163
+ modelo).
164
+ w_o_h: tensor [d, d_h].
165
+ dim_cabeza: d_h.
166
+ """
167
+ attn = modelo.blocks[capa].attn
168
+ w_qkv = attn.qkv.weight
169
+ w_proj = attn.proj.weight
170
+ base_v = 2 * w_qkv.shape[1]
171
+ fil = slice(base_v + h * dim_cabeza, base_v + (h + 1) * dim_cabeza)
172
+ col = slice(h * dim_cabeza, (h + 1) * dim_cabeza)
173
+ w_qkv[fil, :] = w_v_h.to(w_qkv.dtype)
174
+ w_proj[:, col] = w_o_h.to(w_proj.dtype)
175
+
176
+
177
+ @torch.no_grad()
178
+ def b_v_vitb(
179
+ modelo,
180
+ capa: int,
181
+ n_cabezas: int = 12,
182
+ dim_cabeza: int = 64,
183
+ ) -> torch.Tensor | None:
184
+ """sesgo de valor por cabeza de una capa de vit-b, en float64.
185
+
186
+ el sesgo de valor entra en la invariancia de gauge: como
187
+ v = x w_v^t + b_v, la compensación de w_o exige que b_v gire igual
188
+ que la weight (b_v <- r^t b_v). omitirlo en una columna con
189
+ qkv_bias rompe la invariancia, y el forward deja de medir el
190
+ gauge para medir otro modelo.
191
+
192
+ args:
193
+ modelo: el vit interno de timm.
194
+ capa: índice de capa.
195
+ n_cabezas: cabezas h.
196
+ dim_cabeza: d_h.
197
+
198
+ returns:
199
+ tensor [h, d_h] en float64, o None si la columna no lleva
200
+ qkv_bias.
201
+ """
202
+ attn = modelo.blocks[capa].attn
203
+ if attn.qkv.bias is None:
204
+ return None
205
+ b_qkv = attn.qkv.bias
206
+ base_v = 2 * (b_qkv.shape[0] // 3)
207
+ return torch.stack([
208
+ b_qkv[base_v + h * dim_cabeza:
209
+ base_v + (h + 1) * dim_cabeza].double()
210
+ for h in range(n_cabezas)])
211
+
212
+
213
+ @torch.no_grad()
214
+ def escribe_b_v_vitb(
215
+ modelo,
216
+ capa: int,
217
+ h: int,
218
+ b_v_h: torch.Tensor,
219
+ dim_cabeza: int = 64,
220
+ ) -> None:
221
+ """escribe el sesgo de valor de una cabeza, in situ.
222
+
223
+ inversa de `b_v_vitb` para una sola cabeza. el sesgo no se
224
+ cuantiza: el objeto cuantizado de la nota es el par de weights
225
+ (w_v, w_o), y el sesgo solo viaja para que el gauge siga siendo
226
+ un cambio de coordenadas y no un cambio de función.
227
+
228
+ args:
229
+ modelo: el vit interno de timm, modificado in situ.
230
+ capa: índice de capa.
231
+ h: índice de cabeza.
232
+ b_v_h: tensor [d_h], se castea al dtype del modelo.
233
+ dim_cabeza: d_h.
234
+ """
235
+ attn = modelo.blocks[capa].attn
236
+ if attn.qkv.bias is None:
237
+ raise ValueError("la columna no lleva qkv_bias")
238
+ b_qkv = attn.qkv.bias
239
+ base_v = 2 * (b_qkv.shape[0] // 3)
240
+ fil = slice(base_v + h * dim_cabeza, base_v + (h + 1) * dim_cabeza)
241
+ b_qkv[fil] = b_v_h.to(b_qkv.dtype)