""" 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()