Tokescope / projection_map.py
anotheruserishere's picture
harden bf16 detection (no-op on cpu) + README: ungated Qwen default
204fbd3 verified
Raw
History Blame Contribute Delete
16.1 kB
"""
Projection Map — Condensed view of layer, head, and head-combination projections.
One table: 26 rows (one per layer).
Columns: H0-H3 (individual heads), all pairs, all triples, full layer projection.
Format: #LL|tok PP%|tok PP%|...|laytok PP%|
"""
import argparse
import unicodedata
from itertools import combinations
import torch
import torch.nn.functional as F
from transformers import AutoModelForCausalLM, AutoTokenizer
DEFAULT_MODEL_ID = "google/gemma-3-1b-it"
C_H = '\033[95m'
C_B = '\033[94m'
C_G = '\033[92m'
C_Y = '\033[93m'
C_C = '\033[96m'
C_E = '\033[0m'
C_BLD = '\033[1m'
C_DIM = '\033[2m'
def safe_print(text):
try:
print(text)
except UnicodeEncodeError:
print(text.encode('ascii', 'replace').decode('ascii'))
def char_width(ch):
"""Terminal column width of a single character."""
cat = unicodedata.category(ch)
if cat in ('Mn', 'Me'):
return 0
if ord(ch) in (0x200B, 0x200C, 0x200D, 0xFEFF):
return 0
eaw = unicodedata.east_asian_width(ch)
if eaw in ('W', 'F'):
return 2
return 1
def display_width(s):
return sum(char_width(ch) for ch in s)
def fmt_tok(s, width=9):
"""Format token to exactly `width` terminal columns."""
s = s.replace('\n', '\\n').replace('\r', '\\r').replace('\t', '\\t')
s = s.lstrip('\u2581')
s = s.lstrip(' ')
if not s:
s = '·'
if display_width(s) <= width:
pad = width - display_width(s)
return s + ' ' * pad
result = []
used = 0
for ch in s:
ch_w = char_width(ch)
if ch_w > 0 and used + ch_w > width - 1:
break
result.append(ch)
used += ch_w
result.append('.')
used += 1
while used < width:
result.append(' ')
used += 1
return ''.join(result)
def fmt_pct(p):
pct = int(p * 100)
if pct > 99:
return "99"
return f"{pct:02d}"
def prob_color(p):
if p > 0.5: return C_G
if p > 0.1: return C_Y
return C_DIM
def combo_label(indices):
"""Short label for a head combination."""
return ''.join(str(i) for i in indices)
def load_model(model_id):
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"{C_H}Loading {model_id}...{C_E}")
tokenizer = AutoTokenizer.from_pretrained(model_id)
tokenizer.padding_side = "left"
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
# Use bfloat16 — Gemma 3's RMSNorm breaks with float16 (NaN logits).
# bfloat16 works on RTX 30+ / A100 / H100. Fall back to float32 if unavailable.
try:
use_bf16 = torch.cuda.is_available() and torch.cuda.is_bf16_supported()
except Exception:
use_bf16 = False
dtype = torch.bfloat16 if use_bf16 else torch.float32
model = AutoModelForCausalLM.from_pretrained(
model_id, device_map="auto", torch_dtype=dtype
)
model.eval()
num_layers = model.config.num_hidden_layers
num_heads = model.config.num_attention_heads
print(f"{C_C}Architecture: {num_layers} layers, {num_heads} heads{C_E}")
return model, tokenizer, device, num_layers, num_heads
def project_to_token(projected_hidden, final_norm, lm_head, tokenizer):
"""Take a projected hidden state, norm -> lm_head -> argmax, return (token_str, prob)."""
normed = final_norm(projected_hidden)
logits = lm_head(normed)
probs = F.softmax(logits, dim=-1)
p, tid = torch.max(probs, dim=-1)
tok = tokenizer.decode(tid.item())
return tok, p.item()
def project_to_token_rich(projected_hidden, final_norm, lm_head, tokenizer, top_k=5):
"""Rich projection: top-k tokens, entropy, full distribution shape.
Returns dict with:
'top_k': list of (token_str, prob) for top k
'entropy': Shannon entropy of the distribution (bits)
'argmax': (token_str, prob) — same as project_to_token
'mass_top5': total probability mass in top 5
'mass_top50': total probability mass in top 50
"""
normed = final_norm(projected_hidden)
logits = lm_head(normed)
probs = F.softmax(logits, dim=-1)
# Entropy in bits
log_probs = torch.log2(probs + 1e-10)
entropy = -(probs * log_probs).sum().item()
# Top-k
topk_probs, topk_ids = torch.topk(probs.squeeze(), top_k)
top_k_list = []
for i in range(top_k):
tok = tokenizer.decode(topk_ids[i].item())
top_k_list.append((tok, topk_probs[i].item()))
# Mass in top-50
top50_probs, _ = torch.topk(probs.squeeze(), min(50, probs.shape[-1]))
mass_top50 = top50_probs.sum().item()
return {
'top_k': top_k_list,
'entropy': entropy,
'argmax': top_k_list[0],
'mass_top5': sum(p for _, p in top_k_list),
'mass_top50': mass_top50,
}
def project_to_softmax(projected_hidden, final_norm, lm_head):
"""Project hidden state to full softmax distribution.
Returns the raw probability tensor (vocab_size,) on CPU as float16.
This is the complete recording — every possible analysis is a post-hoc query.
"""
normed = final_norm(projected_hidden)
logits = lm_head(normed)
probs = F.softmax(logits, dim=-1)
return probs.squeeze().detach().cpu().half()
def project_full(projected_hidden, final_norm, lm_head):
"""Project hidden state and return BOTH the projected vector and softmax.
Returns: (projected_vector, softmax_probs)
- projected_vector: (hidden_dim,) float32 on CPU — the normed hidden state,
directly comparable to lm_head weight vectors (i.e. token embeddings).
This is what you search against the Lance token index.
- softmax_probs: (vocab_size,) float16 on CPU — the full distribution.
"""
normed = final_norm(projected_hidden)
logits = lm_head(normed)
probs = F.softmax(logits, dim=-1)
return (
normed.squeeze().detach().cpu().float(),
probs.squeeze().detach().cpu().half(),
)
def make_cell(tok, prob):
"""Format a single table cell."""
pc = prob_color(prob)
return f"{pc}{fmt_tok(tok)}{fmt_pct(prob)}%{C_E}"
def build_col_specs(num_heads):
"""Build column specifications for the projection table."""
head_indices = list(range(num_heads))
pairs = list(combinations(head_indices, 2))
triples = list(combinations(head_indices, 3))
col_specs = []
for i in head_indices:
col_specs.append(('single', (i,), f"H{i}"))
for combo in pairs:
col_specs.append(('combo', combo, f"H{''.join(str(i) for i in combo)}"))
for combo in triples:
col_specs.append(('combo', combo, f"H{''.join(str(i) for i in combo)}"))
col_specs.append(('layer', None, 'Layer'))
return col_specs, head_indices, pairs, triples
def capture_projection_map(model, tokenizer, input_ids, device, num_layers, num_heads,
scale_mode='full', rich=False, top_k=5, full_softmax=False):
"""
Run forward pass and return structured projection data.
If rich=False: list of dicts, one per layer, each containing {col_label: (token, prob)}
If rich=True: list of dicts, one per layer, each containing {col_label: rich_dict}
where rich_dict has top_k, entropy, argmax, mass_top5, mass_top50
If full_softmax=True: also returns softmax_data — list of dicts per layer,
each {col_label: tensor(vocab_size)} as float16 on CPU.
This is the complete MRI — every query is post-hoc.
Returns: (layer_data, col_specs) or (layer_data, col_specs, softmax_data) if full_softmax
"""
final_norm = model.model.norm
lm_head = model.lm_head
captured = {}
def make_hook(layer_idx):
def hook_fn(module, args, output):
captured[layer_idx] = args[0].detach()
return hook_fn
handles = []
for l in range(num_layers):
layer = model.model.layers[l]
h = layer.self_attn.o_proj.register_forward_hook(make_hook(l))
handles.append(h)
try:
with torch.no_grad():
outputs = model(input_ids, output_hidden_states=True)
finally:
for h in handles:
h.remove()
col_specs, head_indices, _, _ = build_col_specs(num_heads)
layer_data = []
softmax_data = [] if full_softmax else None
for l_idx in range(num_layers):
layer_state = outputs.hidden_states[l_idx + 1]
last_token = layer_state[:, -1, :]
if rich:
lay_rich = project_to_token_rich(last_token, final_norm, lm_head, tokenizer, top_k)
else:
lay_tok, lay_prob = project_to_token(last_token, final_norm, lm_head, tokenizer)
row = {}
if l_idx in captured:
input_tensor = captured[l_idx]
layer = model.model.layers[l_idx]
o_proj_weight = layer.self_attn.o_proj.weight
hidden_size = o_proj_weight.shape[0]
attn_out_dim = o_proj_weight.shape[1]
head_dim = attn_out_dim // num_heads
batch, seq, _ = input_tensor.shape
multi_head = input_tensor.view(batch, seq, num_heads, head_dim)
weight_view = o_proj_weight.view(hidden_size, num_heads, head_dim)
head_projected = {}
for h_idx in head_indices:
head_output = multi_head[:, :, h_idx, :]
head_weights = weight_view[:, h_idx, :]
projected = torch.matmul(head_output, head_weights.t())
head_projected[h_idx] = projected[:, -1, :]
for col_type, col_heads, col_label in col_specs:
if col_type == 'layer':
if rich:
row[col_label] = lay_rich
else:
row[col_label] = (lay_tok, lay_prob)
else:
combined = sum(head_projected[h] for h in col_heads)
if scale_mode == 'full':
combined = combined * (num_heads / len(col_heads))
elif scale_mode == 'mean':
combined = combined / len(col_heads)
if rich:
row[col_label] = project_to_token_rich(combined, final_norm, lm_head, tokenizer, top_k)
else:
tok, prob = project_to_token(combined, final_norm, lm_head, tokenizer)
row[col_label] = (tok, prob)
# If full softmax capture requested, store raw distributions
if full_softmax:
softmax_row = {}
for col_type, col_heads, col_label in col_specs:
if col_type == 'layer':
softmax_row[col_label] = project_to_softmax(last_token, final_norm, lm_head)
else:
combined = sum(head_projected[h] for h in col_heads)
if scale_mode == 'full':
combined = combined * (num_heads / len(col_heads))
elif scale_mode == 'mean':
combined = combined / len(col_heads)
softmax_row[col_label] = project_to_softmax(combined, final_norm, lm_head)
softmax_data.append(softmax_row)
else:
for _, _, col_label in col_specs:
if rich:
row[col_label] = {'top_k': [('--', 0.0)]*top_k, 'entropy': 0.0,
'argmax': ('--', 0.0), 'mass_top5': 0.0, 'mass_top50': 0.0}
else:
row[col_label] = ('--', 0.0)
if full_softmax:
softmax_data.append({})
layer_data.append(row)
if full_softmax:
return layer_data, col_specs, softmax_data
return layer_data, col_specs
def print_projection_map(layer_data, col_specs):
"""Print the projection map table from structured data."""
header_cells = [f"{C_BLD}{C_C}# {C_E}"]
for _, _, label in col_specs:
header_cells.append(f"{C_BLD}{C_C}{label:^12}{C_E}")
header = '|'.join(header_cells) + '|'
sep_width = 4 + len(col_specs) * 13
sep = f"{C_DIM}{'─' * sep_width}{C_E}"
print()
safe_print(header)
safe_print(sep)
for l_idx, row in enumerate(layer_data):
cells = []
for _, _, col_label in col_specs:
tok, prob = row[col_label]
cells.append(make_cell(tok, prob))
row_num = f"{C_BLD}#{l_idx:02d}{C_E}"
safe_print(f"{row_num}|{'|'.join(cells)}|")
safe_print(sep)
def format_map_plain(layer_data, col_specs):
"""Format projection map as plain text (no ANSI) for LLM consumption."""
labels = [label for _, _, label in col_specs]
header = f"# |{'|'.join(f'{l:^12}' for l in labels)}|"
sep = '─' * len(header)
lines = [header, sep]
for l_idx, row in enumerate(layer_data):
cells = []
for _, _, col_label in col_specs:
tok, prob = row[col_label]
tok_clean = tok.replace('\n', '\\n').replace('\r', '\\r')
if len(tok_clean) > 9:
tok_clean = tok_clean[:8] + '.'
cell = f"{tok_clean:<9}{int(prob*100):02d}%"
cells.append(cell)
lines.append(f"#{l_idx:02d}|{'|'.join(cells)}|")
lines.append(sep)
return '\n'.join(lines)
def run(model, tokenizer, input_ids, device, num_layers, num_heads, scale_mode='full'):
"""Capture and print the projection map. Returns structured data."""
layer_data, col_specs = capture_projection_map(
model, tokenizer, input_ids, device, num_layers, num_heads, scale_mode
)
print_projection_map(layer_data, col_specs)
# Legend
_, head_indices, pairs, triples = build_col_specs(num_heads)
print()
safe_print(f"{C_DIM}Singles: {', '.join(f'H{i}' for i in head_indices)}{C_E}")
safe_print(f"{C_DIM}Pairs: {', '.join('H' + ''.join(str(i) for i in c) for c in pairs)}{C_E}")
safe_print(f"{C_DIM}Triples: {', '.join('H' + ''.join(str(i) for i in c) for c in triples)}{C_E}")
return layer_data, col_specs
def main():
parser = argparse.ArgumentParser(description="Projection map — layer × head × combos")
parser.add_argument("--model", "-m", default=DEFAULT_MODEL_ID,
help=f"Model ID (default: {DEFAULT_MODEL_ID})")
parser.add_argument("--prompt", "-p", default="",
help="Prompt text (default: empty, just BOS)")
parser.add_argument("--scale", "-s", default="full",
choices=["raw", "full", "mean"],
help="Head scaling: raw (no scaling), full (scale to full-head magnitude), mean (average). Default: full")
args = parser.parse_args()
model, tokenizer, device, num_layers, num_heads = load_model(args.model)
if args.prompt:
input_ids = tokenizer(args.prompt, return_tensors="pt").input_ids.to(device)
safe_print(f"\n{C_Y}Prompt: \"{args.prompt}\"{C_E}")
safe_print(f"{C_DIM}Tokens: {input_ids.shape[1]}{C_E}")
else:
bos_id = tokenizer.bos_token_id
if bos_id is None:
input_ids = tokenizer("", return_tensors="pt").input_ids.to(device)
else:
input_ids = torch.tensor([[bos_id]], device=device)
safe_print(f"\n{C_Y}No prompt — raw BOS token only{C_E}")
safe_print(f"{C_DIM}Scale mode: {args.scale}{C_E}")
run(model, tokenizer, input_ids, device, num_layers, num_heads, scale_mode=args.scale)
print(f"\n{C_G}Done.{C_E}")
if __name__ == "__main__":
main()