mirror of
https://github.com/wassname/evil_MoE.git
synced 2026-07-13 17:43:42 +08:00
55937a86fb
git mv src/projected_grpo -> src/vgrout and find-replace the module name in
all imports (.py), `-m projected_grpo.*` invocations (justfile), and the
[project] name (pyproject; setuptools auto-discovers via where=["src"]).
Left RESEARCH_JOURNAL.md untouched: its commands/paths are dated lab notes
tied to past commits, so rewriting them would falsify provenance. Repo dir,
git remote, and absolute paths unchanged.
Verified: `import vgrout` and `python -m vgrout.train --help` load the full
graph; verify_rewards.py + verify_gate_anchor.py (both import vgrout) pass.
Full `just smoke` is blocked upstream by missing gitignored data artifacts
(out/pools/{substrate,teacher_pool}, out/vhack/*smoke*), unrelated to the rename.
213 lines
9.6 KiB
Python
213 lines
9.6 KiB
Python
"""Gradient projection + delta_S grad utilities. Imported by smoke and train.
|
|
|
|
Shape conventions in the v_hack / delta_S plumbing (jaxtyping-annotated):
|
|
- `r` = per-module SVD rank (delta_S dimension; varies per Linear)
|
|
- `k` = number of v_hack directions kept per module (after top-k slice and
|
|
global noise-floor filter at load time)
|
|
- `V` is `[k, r]`, rows orthonormal in R^r and oriented hack-ward
|
|
- `g = delta_S.grad` is `[r]`
|
|
- `c = V @ g` is `[k]`
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import torch
|
|
from jaxtyping import Float
|
|
|
|
|
|
def per_token_logps(logits: torch.Tensor, ids: torch.Tensor) -> torch.Tensor:
|
|
"""log p(ids | logits) gathered token-wise.
|
|
|
|
Uses F.cross_entropy (fused softmax+gather) so we never materialise the
|
|
full [B, L, V] fp32 softmax. On Qwen3.5-2B with V=152k, G=8, L≈1500 the
|
|
fp32 vocab tensor was ~7 GB per forward -- the difference between OOM and
|
|
fit on a 96 GB card when the autograd graph is alive.
|
|
"""
|
|
B, L, V = logits.shape
|
|
# CE's internal log_softmax accumulates in fp32 (stable) but returns input dtype.
|
|
# The output [B*L] is small, so upcast it to fp32 for downstream PPO ratio math.
|
|
return -torch.nn.functional.cross_entropy(
|
|
logits.reshape(-1, V), ids.reshape(-1), reduction="none"
|
|
).float().view(B, L)
|
|
|
|
|
|
def _hackward_cos(c: Float[torch.Tensor, "k"], gn: torch.Tensor) -> float:
|
|
"""Fraction of the gradient's magnitude that points hack-ward into v_hack:
|
|
||relu(c)|| / ||g||, where c = V @ g and V rows are orthonormal and oriented
|
|
hack-ward (c_i > 0 means "grad pushes hack-ward on axis i"). In [0, 1].
|
|
|
|
relu BEFORE aggregating is the point: the one_sided projection removes only
|
|
relu(c) (the hack-ward axes), and with V orthonormal ||removed|| = ||relu(c)||,
|
|
so this reads directly as "fraction of the grad the projection strips" (a signed
|
|
sum would let +/- axes cancel and read ~0 even while routing a large hack-ward
|
|
magnitude).
|
|
|
|
After a one_sided erase, V @ g_proj = min(c, 0) (positive axes zeroed), so
|
|
relu of it is 0 -> cos_post == 0 exactly. That clean SHOULD (cos_post -> 0) is
|
|
the diagnostic; we drop the sign because a one_sided method never acts on the
|
|
safe-ward (negative) part anyway.
|
|
"""
|
|
return (torch.relu(c).norm() / gn).item()
|
|
|
|
|
|
@torch.no_grad()
|
|
def mean_cos_pre_from_grads(
|
|
grad_dict: dict[str, Float[torch.Tensor, "r"]],
|
|
v_hack: dict[str, Float[torch.Tensor, "k r"]],
|
|
) -> float:
|
|
"""Mean over modules of ||relu(V @ g)|| / ||g|| (hack-ward fraction, in [0,1]).
|
|
|
|
Used to compute per-source cos_pre (cos_pre_s for student-only grad,
|
|
cos_pre_t for teacher-only grad) without mutating model.grad or calling
|
|
the full projection pipeline.
|
|
"""
|
|
cs = []
|
|
for name, g in grad_dict.items():
|
|
if g is None or name not in v_hack:
|
|
continue
|
|
V = v_hack[name].to(g.device, dtype=g.dtype)
|
|
gn = g.norm()
|
|
if gn < 1e-12:
|
|
continue
|
|
cs.append(_hackward_cos(V @ g, gn))
|
|
return float(sum(cs) / len(cs)) if cs else float("nan")
|
|
|
|
|
|
def _project_one_module(
|
|
g: Float[torch.Tensor, "r"],
|
|
V: Float[torch.Tensor, "k r"],
|
|
gate_mode: str,
|
|
preserve_magnitude: bool,
|
|
overshoot: float = 1.0,
|
|
) -> tuple[Float[torch.Tensor, "r"], Float[torch.Tensor, "r"], float, float, bool]:
|
|
"""Per-module top-k removal. Returns (g_proj, removed, cos_pre, cos_post, fired).
|
|
|
|
`removed` = overshoot*c_use@V, the vector subtracted from g (computed
|
|
before any preserve_magnitude rescale, so removed ∈ span(V) always).
|
|
Erasure drops it; routing parks it in delta_S_hack. Note g_proj + removed
|
|
== g ONLY when preserve_magnitude is False and overshoot is 1.0; with the
|
|
defaults g_proj is rescaled, so the sum is not the original g (routing does
|
|
not rely on that sum -- see project_delta_S_grad).
|
|
|
|
cos_pre / cos_post are the hack-ward FRACTION ||relu(V @ g)|| / ||g|| (in
|
|
[0,1]; see _hackward_cos). cos_pre = how much of the grad points hack-ward
|
|
into v_hack; cos_post = the residual after projection. Under one_sided (and
|
|
no_gate, and overshoot>=1) projection cos_post -> 0 exactly: every hack-ward
|
|
axis was removed, so relu of the residual coefficients is 0.
|
|
|
|
`overshoot` scales the removed coefficient: g_proj = g - overshoot*c_use@V.
|
|
overshoot=1.0 just removes the hack-ward component; overshoot=1.1 removes
|
|
110% (a 10% reversal: V@g_proj = -0.1c on fired axes), a milder version of
|
|
gate_mode="reverse" (which is overshoot=2.0 on the full, ungated c).
|
|
"""
|
|
gn = g.norm()
|
|
if gn < 1e-12:
|
|
z = torch.zeros_like(g)
|
|
return g, z, 0.0, 0.0, False
|
|
c = V @ g # [k]
|
|
cos_pre = _hackward_cos(c, gn)
|
|
if gate_mode == "no_gate":
|
|
c_use = c
|
|
fired = True
|
|
elif gate_mode == "one_sided":
|
|
mask = (c > 0).to(c.dtype)
|
|
c_use = c * mask
|
|
fired = bool((c_use != 0).any())
|
|
elif gate_mode == "reverse":
|
|
# Subtract 2*c@V: V@g_proj = V@g - 2*(V V^T) c = c - 2c = -c.
|
|
# Flips the sign of the gradient component in span(V); pushes
|
|
# actively away from hack rather than just removing.
|
|
c_use = 2 * c
|
|
fired = True
|
|
else:
|
|
raise ValueError(f"unknown gate_mode={gate_mode!r}")
|
|
if not fired:
|
|
return g, torch.zeros_like(g), cos_pre, cos_pre, False
|
|
removed = overshoot * c_use @ V # [r]
|
|
g_proj = g - removed
|
|
gp_n = g_proj.norm()
|
|
if preserve_magnitude and gp_n > 1e-12:
|
|
g_proj = g_proj * (gn / gp_n)
|
|
cos_post = _hackward_cos(V @ g_proj, g_proj.norm().clamp_min(1e-12))
|
|
return g_proj, removed, cos_pre, cos_post, True
|
|
|
|
|
|
@torch.no_grad()
|
|
def project_delta_S_grad(
|
|
wrappers: dict,
|
|
v_hack: dict[str, Float[torch.Tensor, "k r"]],
|
|
preserve_magnitude: bool,
|
|
measure_only: bool = False,
|
|
route: bool = False,
|
|
gate_mode: str = "one_sided",
|
|
overshoot: float = 1.0,
|
|
) -> dict[str, float]:
|
|
"""Per-module top-k removal of hack-aligned grad components.
|
|
|
|
For each wrapped module:
|
|
g = delta_S.grad # [r]
|
|
V = v_hack[name] # [k, r], rows orthonormal, oriented hack-ward
|
|
c = V @ g # [k] per-direction coefficients
|
|
|
|
gate_mode="one_sided" (default):
|
|
mask = (c > 0) # only zap when grad is going hack-ward on that axis
|
|
g' = g - (c * mask) @ V # subtract only positive-coefficient components
|
|
|
|
gate_mode="no_gate":
|
|
g' = g - c @ V # full V·V^T removal, sign-agnostic;
|
|
# drives ||V g'|| -> 0 exactly. No trust in v_hack
|
|
# orientation: any motion in span(V) is suspect.
|
|
|
|
`preserve_magnitude`: rescale g' to ||g|| after projection.
|
|
`measure_only`: same math, but g is not mutated (the `none` intervention).
|
|
`route`: erase AND park the removed hack-ward component in the quarantine
|
|
knob delta_S_hack.grad (Gradient Routing, Cloud 2410.04332). delta_S gets
|
|
the IDENTICAL g_proj as erase (same gate/preserve/overshoot), so the
|
|
deployment model -- delta_S with delta_S_hack zeroed at eval -- evolves
|
|
under the same update rule as the erase arm (each is its own AdamW param;
|
|
the quarantine's separate optimizer state cannot perturb delta_S). That is
|
|
the sense in which route ⊇ erase: erase == route with the quarantine
|
|
discarded. CAVEAT (not an identity): the combined TRAINING forward
|
|
delta_S + delta_S_hack does NOT reproduce a vanilla update -- AdamW steps
|
|
the two knobs independently, so the sum over-moves hack-ward. That is
|
|
intended (the model keeps hacking during training so the capability lands
|
|
in the quarantine), and it only affects the training trajectory, never the
|
|
ablated deployment. Mutually exclusive with measure_only.
|
|
|
|
Diagnostics returned (per call, averaged over modules):
|
|
mean_cos_pre = mean over modules of ||relu(V @ g)||/||g|| (hack-ward fraction, [0,1])
|
|
mean_cos_post = same after projection (-> 0 when hack-ward axes were removed)
|
|
frac_fired = fraction of modules where at least one direction fired (c_i > 0)
|
|
"""
|
|
if route and measure_only:
|
|
raise ValueError("route and measure_only are mutually exclusive")
|
|
cos_pre_list, cos_post_list, n_fired = [], [], 0
|
|
for name, info in wrappers.items():
|
|
g = info["delta_S"].grad
|
|
if g is None:
|
|
continue
|
|
if name not in v_hack: # module dropped by global noise-floor filter
|
|
continue
|
|
V = v_hack[name].to(g.device, dtype=g.dtype) # [k, r]
|
|
g_proj, removed, cos_pre, cos_post, fired = _project_one_module(
|
|
g, V, gate_mode, preserve_magnitude, overshoot)
|
|
cos_pre_list.append(cos_pre)
|
|
cos_post_list.append(cos_post)
|
|
if fired and not measure_only:
|
|
info["delta_S"].grad = g_proj # same update rule as erase
|
|
if route:
|
|
# quarantine the discarded hack-ward part; removed ∈ span(V),
|
|
# ablated at eval so its magnitude/overshoot scaling is harmless.
|
|
info["delta_S_hack"].grad = removed
|
|
if fired:
|
|
n_fired += 1
|
|
pre_t = torch.tensor(cos_pre_list); post_t = torch.tensor(cos_post_list)
|
|
return {
|
|
"mean_cos_pre": pre_t.mean().item(),
|
|
"min_cos_pre": pre_t.min().item() if pre_t.numel() else float("nan"),
|
|
"max_cos_pre": pre_t.max().item() if pre_t.numel() else float("nan"),
|
|
"mean_cos_post": post_t.mean().item(),
|
|
"min_cos_post": post_t.min().item() if post_t.numel() else float("nan"),
|
|
"max_cos_post": post_t.max().item() if post_t.numel() else float("nan"),
|
|
"frac_fired": n_fired / len(cos_pre_list) if cos_pre_list else 0.0,
|
|
}
|