Code / src/localvm/quality/decision_stats.py
src/localvm/quality/decision_stats.py
103 lines
# =============================================================================
# Project : localvm-research
# File : src/localvm/quality/decision_stats.py
# Purpose : Shared decision-stability measurement utilities (greedy
# trajectories, teacher-forced margins/agreement, AUROC, curves)
# Author : Simon-Pierre Boucher
# Contact : contact@spboucher.ai
# Created : 2026-08-12
# Modified : 2026-08-12
# Platform : macOS / Apple Silicon (arm64) — MLX / Metal
# License : All rights reserved (research code)
# =============================================================================
"""Decision-stability measurement utilities shared by expG/expD and successors.
First used (inlined) by experiments/micro/expG_decision_stability/benchmark.py
(commit 42c7b3d); extracted here unchanged so later experiments reuse one
implementation. expG's committed copy is kept as-is for reproducibility.
"""
from __future__ import annotations
import mlx.core as mx
import numpy as np
def greedy_generate(model, tokenizer, prompt_ids: list[int], n_tokens: int) -> list[int]:
"""Deterministic greedy generation; returns generated token ids."""
from mlx_lm.models.cache import make_prompt_cache
cache = make_prompt_cache(model)
generated: list[int] = []
inp = mx.array(list(prompt_ids))[None]
for _ in range(n_tokens):
logits = model(inp, cache=cache)
nxt = int(mx.argmax(logits[0, -1]).item())
if nxt == tokenizer.eos_token_id:
break
generated.append(nxt)
inp = mx.array([[nxt]])
return generated
def teacher_forced_stats(model, full_ids: list[int], start: int) -> dict:
"""Forward full_ids once; per-position stats for predictions of tokens
[start, len). Returns margin (top-1 minus top-2 logit gap), argmax, and
float16 logprobs (float16 keeps 48 x (128, ~152k) around 2 GB)."""
logits = model(mx.array(full_ids)[None])[0]
sel = logits[start - 1 : len(full_ids) - 1].astype(mx.float32)
top2 = mx.topk(sel, 2, axis=-1)
argmax = mx.argmax(sel, axis=-1)
logprobs = sel - mx.logsumexp(sel, axis=-1, keepdims=True)
mx.eval(top2, argmax, logprobs)
v = np.array(top2)
return {
"margin": np.abs(v[:, 1] - v[:, 0]),
"argmax": np.array(argmax),
"logprobs": np.array(logprobs).astype(np.float16),
}
def auroc(scores: np.ndarray, labels: np.ndarray) -> float:
"""Rank-based AUROC with tie handling (scores: higher = predicted 1)."""
pos, neg = scores[labels == 1], scores[labels == 0]
if len(pos) == 0 or len(neg) == 0:
return float("nan")
allv = np.concatenate([pos, neg])
order = np.argsort(allv, kind="mergesort")
ranks = np.empty(len(order))
ranks[order] = np.arange(1, len(order) + 1)
sorted_v = allv[order]
i = 0
while i < len(sorted_v):
j = i
while j + 1 < len(sorted_v) and sorted_v[j + 1] == sorted_v[i]:
j += 1
if j > i:
ranks[order[i : j + 1]] = ranks[order[i : j + 1]].mean()
i = j + 1
r_pos = ranks[: len(pos)].sum()
return float((r_pos - len(pos) * (len(pos) + 1) / 2) / (len(pos) * len(neg)))
def escalation_curve(margins: np.ndarray, agree: np.ndarray, points: int = 200) -> list[dict]:
"""Escalate tokens with margin < tau (escalated decision assumed exact);
report escalated fraction vs residual disagreement."""
qs = np.quantile(margins, np.linspace(0, 1, points))
out, n = [], len(margins)
for tau in qs:
esc = margins < tau
out.append({
"tau": float(tau),
"escalated_frac": float(esc.mean()),
"residual_disagree": float(np.sum((~esc) & (agree == 0)) / n),
})
return out
def kl_ref_vs(model_logprobs_f16: np.ndarray, ref_logprobs_f16: np.ndarray) -> np.ndarray:
"""Per-position KL(ref || model), computed in float32."""
ref = ref_logprobs_f16.astype(np.float32)
q = model_logprobs_f16.astype(np.float32)
return np.sum(np.exp(ref) * (ref - q), axis=-1)