tensor-roll / tensor_roll /distill.py
SNAPKITTYWEST's picture
October 2026 main drop: mirror from GitHub
9e551a1 verified
Raw History Blame Contribute Delete
8.63 kB
# Tensor Roll — Recursive CUDA-Q Model Quantizer
# Copyright (C) 2026 SnapKitty Collective
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Recursive distillation with the multi-objective loss.
L = l1*L_logits + l2*L_hidden + l3*L_attention + l4*L_embedding
+ l5*L_task + l6*L_reconstruction + l7*L_roll
All seven terms are real computed quantities (no placeholders):
logits : tau^2 * KL(softmax(teacher/tau) || softmax(student/tau))
hidden : MSE(student_final @ Ph, teacher_final), Ph frozen random proj
attention : MSE(mean-head attention probs), layer-aligned
embedding : MSE(student_emb @ Pe, teacher_emb), Pe frozen random proj
task : cross-entropy on the synthetic grammar
reconstruction: MSE(student weights, projected-teacher init targets)
roll : MSE(achieved per-tensor energy, roll-plan energy) — monitored,
gradient detached (documented; proxy grad via reconstruction)
Recursive distillation metrics (spec section 8) are computed per recursion
level: model -> layer group -> tensor -> sub-block, each reporting teacher
tensor, student tensor, relative error, cosine similarity, KL divergence,
retained energy.
"""
from __future__ import annotations
import numpy as np
from . import core as C
from .model import backward, ce_loss, forward, kl_loss
DEFAULT_LAMBDAS = {
"logits": 1.0,
"hidden": 0.3,
"attention": 0.3,
"embedding": 0.1,
"task": 1.0,
"reconstruction": 0.1,
"roll": 0.1,
}
def _align(a: np.ndarray, shape: tuple) -> np.ndarray:
"""Shape alignment for comparison only: truncate or zero-pad. This is a
measurement convenience, documented here — not a claim about the model."""
a = np.asarray(a, dtype=np.float64)
out = np.zeros(shape, dtype=np.float64)
sl = tuple(slice(0, min(x, y)) for x, y in zip(a.shape, shape))
out[sl] = a[sl]
return out
def distill_step(s_params: dict, s_cfg, t_params: dict, t_cfg,
ids: np.ndarray, *, lambdas=None, tau: float = 2.0,
proj_h=None, proj_e=None, recon_targets=None,
roll_plan_energies=None, achieved_energies=None):
"""One recursive-distillation step. Returns (total_loss, terms, grads)."""
lambdas = {**DEFAULT_LAMBDAS, **(lambdas or {})}
B = ids.shape[0]
inputs, targets = ids[:, :-1], ids[:, 1:]
t_logits, t_cache = forward(t_params, t_cfg, inputs)
s_logits, s_cache = forward(s_params, s_cfg, inputs)
terms = {}
# L_task + L_logits -> dlogits
terms["task"], dl_task = ce_loss(s_logits, targets)
terms["logits"], dl_kl = kl_loss(s_logits, t_logits, tau)
dlogits = lambdas["task"] * dl_task + lambdas["logits"] * dl_kl
# L_attention: mean-over-heads, layer-aligned (first min(L) layers)
d_attn = {}
n_align = min(s_cfg.layers, t_cfg.layers)
attn_num = 0.0
for l in range(n_align):
ps = s_cache[f"L{l}"]["P"].mean(axis=1) # (B,T,T)
pt = t_cache[f"L{l}"]["P"].mean(axis=1)
diff = ps - pt
terms[f"attention_l{l}"] = float((diff ** 2).mean())
attn_num += terms[f"attention_l{l}"]
dmean = (2.0 * diff / diff.size) * lambdas["attention"] / max(n_align, 1)
d_attn[l] = np.broadcast_to(
dmean[:, None, :, :], s_cache[f"L{l}"]["P"].shape).copy()
terms["attention"] = attn_num / max(n_align, 1)
# L_hidden: final hidden, frozen random projection to teacher dim
d_hiddens = {}
if proj_h is not None:
fs = s_cache["f"] # (B,T,ds)
ft = t_cache["f"] # (B,T,dt)
pred = fs @ proj_h
diff = pred - ft
terms["hidden"] = float((diff ** 2).mean())
d_hiddens[s_cfg.layers - 1] = (
(2.0 * diff / diff.size) @ proj_h.T) * lambdas["hidden"]
else:
terms["hidden"] = 0.0
grads = backward(s_params, s_cfg, s_cache, dlogits,
d_hiddens=d_hiddens or None, d_attn=d_attn or None)
# L_embedding
if proj_e is not None:
es = s_params["emb"].astype(np.float64)
et = t_params["emb"].astype(np.float64)
diff = es @ proj_e - et
terms["embedding"] = float((diff ** 2).mean())
grads["emb"] = grads["emb"] + (
(2.0 * diff / diff.size) @ proj_e.T * lambdas["embedding"]
).astype(np.float32)
else:
terms["embedding"] = 0.0
# L_reconstruction: stay near the projected-teacher init targets
if recon_targets:
rn, rv = 0.0, 0
for k, tgt in recon_targets.items():
if k in grads and grads[k].shape == tgt.shape:
w = s_params[k].astype(np.float64)
diff = w - tgt.astype(np.float64)
rn += float((diff ** 2).mean())
rv += 1
grads[k] = grads[k] + (
2.0 * diff / diff.size * lambdas["reconstruction"]).astype(np.float32)
terms["reconstruction"] = rn / max(rv, 1)
else:
terms["reconstruction"] = 0.0
# L_roll: achieved vs planned per-tensor retained energy (monitored)
if roll_plan_energies and achieved_energies:
vals = [(achieved_energies[k] - roll_plan_energies[k]) ** 2
for k in roll_plan_energies if k in achieved_energies]
terms["roll"] = float(np.mean(vals)) if vals else 0.0
else:
terms["roll"] = 0.0
total = sum(lambdas[k] * terms[k] for k in
("logits", "hidden", "attention", "embedding", "task",
"reconstruction", "roll"))
terms["total"] = float(total)
return float(total), terms, grads
def _kl_vec(p_logits: np.ndarray, q_logits: np.ndarray) -> float:
p = p_logits.astype(np.float64).ravel()
q = q_logits.astype(np.float64).ravel()
p = p - p.max(); q = q - q.max()
lp = p - np.log(np.exp(p).sum()); lq = q - np.log(np.exp(q).sum())
ep = np.exp(lp)
return float((ep * (lp - lq)).sum())
def recursive_metrics(t_params: dict, s_params: dict) -> dict:
"""Per-recursion-level teacher/student comparison.
Levels: model -> layer_group -> tensor -> subblock. Student tensors are
shape-aligned to the teacher by truncate/zero-pad (comparison only).
Each level reports: rel_err, cosine_sim, kl_div, retained_energy,
teacher_params, student_params.
"""
out = {}
def pack(params, keys):
return np.concatenate([np.asarray(params[k]).ravel() for k in keys])
def level(name, t_keys, s_keys):
tv = pack(t_params, t_keys).astype(np.float64)
sv_raw = pack(s_params, s_keys).astype(np.float64)
sv = _align(sv_raw, tv.shape)
out[name] = {
"teacher_params": int(tv.size),
"student_params": int(sv_raw.size),
"rel_err": C.relative_error(tv, sv),
"cosine_sim": C.cosine_sim(tv, sv),
"kl_div": _kl_vec(tv / (np.abs(tv).max() + 1e-9),
sv / (np.abs(sv).max() + 1e-9)),
"retained_energy": float((sv ** 2).sum() / ((tv ** 2).sum() + 1e-300)),
}
t_keys = sorted(t_params.keys())
s_keys = sorted(s_params.keys())
level("model", t_keys, s_keys)
# layer groups
layers = sorted({k.split(".")[0] for k in t_keys if k.startswith("L")})
for i, lg in enumerate(layers):
tk = [k for k in t_keys if k.startswith(lg + ".")]
sk = [k for k in s_keys if k.startswith(lg + ".")]
if tk and sk:
level(f"layer_group_{i}", tk, sk)
# tensors (sample up to 12 for readability; full data in JSON)
for k in t_keys[:12]:
sk = [x for x in s_keys if x == k]
if sk:
level(f"tensor:{k}", [k], sk)
# sub-blocks of the largest teacher tensor
big = max(t_keys, key=lambda k: t_params[k].size)
W = np.asarray(t_params[big], dtype=np.float64).ravel()
sm = [x for x in s_keys if x == big]
if sm:
S = np.asarray(s_params[sm[0]], dtype=np.float64).ravel()
for b, (ws, ss) in enumerate(zip(np.array_split(W, 4),
np.array_split(S, 4))):
sa = _align(ss, ws.shape)
out[f"subblock:{big}[{b}]"] = {
"teacher_params": int(ws.size),
"student_params": int(ss.size),
"rel_err": C.relative_error(ws, sa),
"cosine_sim": C.cosine_sim(ws, sa),
"kl_div": _kl_vec(ws / (np.abs(ws).max() + 1e-9),
sa / (np.abs(sa).max() + 1e-9)),
"retained_energy": float((sa ** 2).sum() / ((ws ** 2).sum() + 1e-300)),
}
return out