FocalLoRA, Should-It-Be-Executed-Or-Processed, and topicattack were nested git repos (with an inner FocalLoRA/data/FocalLoRA/.git as well). Drop their .git history and track the contents directly in this repo instead of as submodules/gitlinks. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
345 lines
12 KiB
Python
345 lines
12 KiB
Python
# coding: utf-8
|
|
|
|
import os, json, argparse, importlib.util, re
|
|
from pathlib import Path
|
|
from functools import lru_cache
|
|
import torch
|
|
import numpy as np
|
|
import seaborn as sns
|
|
import matplotlib.pyplot as plt
|
|
from tqdm import tqdm
|
|
from transformers import AutoTokenizer, AutoModelForCausalLM, AutoConfig
|
|
from peft import PeftModel # LoRA support
|
|
|
|
def find_subsequence(full, sub):
|
|
n, m = len(full), len(sub)
|
|
if m == 0 or m > n:
|
|
return -1
|
|
for i in range(n - m + 1):
|
|
if full[i : i + m] == sub:
|
|
return i
|
|
return -1
|
|
|
|
@lru_cache(maxsize=None)
|
|
def clean_token(tok: str) -> str:
|
|
return tok.lstrip("▁")
|
|
|
|
def build_token_ranges(tokenizer, full_ids, sys_ids, usr_ids):
|
|
# First try exact sub-sequence match
|
|
s0 = find_subsequence(full_ids, sys_ids)
|
|
if s0 != -1:
|
|
s1 = s0 + len(sys_ids) - 1
|
|
if usr_ids:
|
|
u0 = find_subsequence(full_ids, usr_ids)
|
|
u1 = u0 + len(usr_ids) - 1 if u0 != -1 else None
|
|
usr_range = (u0, u1) if u0 != -1 else None
|
|
else:
|
|
usr_range = None
|
|
return (s0, s1), usr_range
|
|
|
|
# Try known chat templates
|
|
tid = tokenizer.convert_tokens_to_ids
|
|
start_header = tid("<|start_header_id|>")
|
|
end_header = tid("<|end_header_id|>")
|
|
eot_id = tokenizer.eos_token_id
|
|
sys_tok = tid("<|system|>")
|
|
end_tok = tid("<|end|>")
|
|
im_start = tid("<|im_start|>")
|
|
im_end = tid("<|im_end|>")
|
|
inst_start = tid("[INST]")
|
|
inst_end = tid("[/INST]")
|
|
row = full_ids
|
|
|
|
def find_token_range(row, start_token, end_token, start_offset=1):
|
|
try:
|
|
s = row.index(start_token) + start_offset
|
|
e = row.index(end_token, s)
|
|
return s, e
|
|
except ValueError:
|
|
return None
|
|
|
|
if start_header in row:
|
|
try:
|
|
s = row.index(end_header) + 1
|
|
e = row.index(eot_id)
|
|
return (s, e), None
|
|
except ValueError:
|
|
pass
|
|
|
|
if sys_tok in row:
|
|
try:
|
|
s = row.index(sys_tok) + 1
|
|
e = row.index(end_tok, s)
|
|
return (s, e), None
|
|
except ValueError:
|
|
pass
|
|
|
|
if im_start in row and im_end in row:
|
|
for pos in [i for i, t in enumerate(row) if t == im_start]:
|
|
if pos + 1 < len(row) and tokenizer.decode([row[pos + 1]]).strip() == "system":
|
|
s = pos + 2
|
|
try:
|
|
e = row.index(im_end, s)
|
|
return (s, e), None
|
|
except ValueError:
|
|
pass
|
|
|
|
if inst_start in row and inst_end in row:
|
|
try:
|
|
ist = row.index(inst_start) + 1
|
|
iend = row.index(inst_end)
|
|
split = None
|
|
for i in range(ist, iend - 1):
|
|
if row[i] == eot_id and row[i + 1] == eot_id:
|
|
split = i
|
|
break
|
|
if split is None:
|
|
for i in range(ist, iend):
|
|
if tokenizer.decode([row[i]]).isspace():
|
|
split = i
|
|
break
|
|
if split and ist < split:
|
|
return (ist, split), (split + 1, iend)
|
|
else:
|
|
return (ist, iend), None
|
|
except ValueError:
|
|
pass
|
|
|
|
# Fallback
|
|
s0 = find_subsequence(row, sys_ids)
|
|
s1 = s0 + len(sys_ids) - 1 if s0 != -1 else -1
|
|
u0 = find_subsequence(row, usr_ids) if usr_ids else -1
|
|
u1 = u0 + len(usr_ids) - 1 if u0 != -1 else -1
|
|
sys_range = (s0, s1) if s0 != -1 else None
|
|
usr_range = (u0, u1) if u0 != -1 else None
|
|
return sys_range, usr_range
|
|
|
|
def load_important_heads(path):
|
|
suffix = Path(path).suffix.lower()
|
|
if suffix == ".py":
|
|
spec = importlib.util.spec_from_file_location("viz_heads_cfg", path)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module) # type: ignore[attr-defined]
|
|
raw_heads = getattr(module, "HEADS", None)
|
|
if raw_heads is None:
|
|
raise ValueError(f"HEADS not defined in {path}")
|
|
head_list = raw_heads
|
|
else:
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
head_list = data.get("important_heads")
|
|
if head_list is None:
|
|
raise ValueError(f"important_heads missing in {path}")
|
|
|
|
pairs, tags = [], []
|
|
for entry in head_list:
|
|
if isinstance(entry, (list, tuple)) and len(entry) == 2:
|
|
tag, _ = entry
|
|
elif isinstance(entry, dict) and "tag" in entry:
|
|
tag = entry["tag"]
|
|
else:
|
|
raise ValueError(f"Invalid head entry: {entry}")
|
|
l = int(tag.split("_")[0][1:])
|
|
h = int(tag.split("_")[1][1:])
|
|
pairs.append((l, h))
|
|
tags.append(tag)
|
|
return pairs, tags
|
|
|
|
def last_token_selected_heads(attentions, selected_pairs):
|
|
S = attentions[0].shape[-1]
|
|
last = S - 1
|
|
rows = []
|
|
for (l, h) in selected_pairs:
|
|
vec = attentions[l][0, h, last, :].to(torch.float32).cpu().numpy()
|
|
rows.append(vec)
|
|
return np.stack(rows)
|
|
|
|
def average_heads_last_token(attentions):
|
|
L = len(attentions)
|
|
S = attentions[0].shape[-1]
|
|
last = S - 1
|
|
mat = np.zeros((L, S), dtype=np.float32)
|
|
for l, attn in enumerate(attentions):
|
|
mat[l] = attn[0, :, last, :].mean(dim=0).to(torch.float32).cpu().numpy()
|
|
return mat
|
|
|
|
def plot_heatmap(mat, tokens, row_labels, out_path, title):
|
|
from matplotlib.colors import LinearSegmentedColormap
|
|
custom_cmap = LinearSegmentedColormap.from_list("custom_red", ["#FEFFDA", "#CC3F39"], N=256)
|
|
cbar_font = {'size': 18}
|
|
xtick_font = {'fontsize': 10}
|
|
ytick_font = {'fontsize': 10}
|
|
|
|
plt.figure(figsize=(max(6, mat.shape[0] * 0.6), max(4, len(tokens) * 0.35)))
|
|
ax = sns.heatmap(
|
|
mat.T,
|
|
cmap=custom_cmap,
|
|
vmin=0.0,
|
|
vmax=1,
|
|
xticklabels=row_labels,
|
|
yticklabels=[clean_token(t) for t in tokens],
|
|
cbar_kws={"label": "Attention Score", "format": '%.2f'}
|
|
)
|
|
ax.set_xlabel("Important Heads (x)")
|
|
ax.set_ylabel("Input Tokens (y)")
|
|
ax.set_title(title, fontsize=14)
|
|
ax.tick_params(axis='x', labelsize=xtick_font["fontsize"])
|
|
ax.tick_params(axis='y', labelsize=ytick_font["fontsize"])
|
|
cbar = ax.collections[0].colorbar
|
|
cbar.ax.tick_params(labelsize=cbar_font["size"])
|
|
cbar.set_label("Attention Score", fontsize=cbar_font["size"])
|
|
|
|
plt.tight_layout()
|
|
plt.savefig(out_path, dpi=300)
|
|
plt.close()
|
|
print(f"✅ Saved heatmap to: {out_path}")
|
|
|
|
|
|
def visualize_samples(model, tokenizer, samples, out_dir, device, selected_pairs, head_tags, prefix=""):
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
has_selected = bool(selected_pairs)
|
|
prefix = (prefix or "").strip()
|
|
fname_prefix = f"{prefix}_" if prefix else ""
|
|
|
|
for sample in tqdm(samples, desc=f"Processing Samples → {Path(out_dir).name}"):
|
|
sys_msg = sample["system_message"]
|
|
usr_msg = sample.get("user_message", "") or ""
|
|
messages = [{"role": "system", "content": sys_msg}]
|
|
if usr_msg.strip():
|
|
messages.append({"role": "user", "content": usr_msg})
|
|
|
|
with torch.no_grad():
|
|
chat_input = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)
|
|
inputs = tokenizer(chat_input, return_tensors="pt")
|
|
if device is not None:
|
|
inputs = {k: v.to(device) for k, v in inputs.items()}
|
|
|
|
sys_ids = tokenizer(sys_msg, add_special_tokens=False)["input_ids"]
|
|
usr_ids = tokenizer(usr_msg, add_special_tokens=False)["input_ids"] if usr_msg.strip() else []
|
|
full_ids = inputs["input_ids"][0].tolist()
|
|
sys_range, usr_range = build_token_ranges(tokenizer, full_ids, sys_ids, usr_ids)
|
|
s0, s1 = sys_range
|
|
|
|
with torch.no_grad():
|
|
outputs = model(**inputs, output_attentions=True)
|
|
|
|
mat = average_heads_last_token(outputs.attentions)
|
|
tokens = [tokenizer.decode([t]) for t in full_ids]
|
|
wanted_idx = list(range(len(tokens)))
|
|
sub_mat = mat[:, wanted_idx]
|
|
sub_tokens = [tokens[i] for i in wanted_idx]
|
|
|
|
sample_id = sample.get('id', 'unknown')
|
|
title = f"Last-Token → System/User Tokens (sample id: {sample_id})"
|
|
|
|
row_labels = head_tags if has_selected else [f"L{l}" for l in range(mat.shape[0])]
|
|
plot_heatmap(
|
|
sub_mat,
|
|
sub_tokens,
|
|
row_labels,
|
|
os.path.join(out_dir, f"{fname_prefix}{sample_id}_attn_map.png"),
|
|
title,
|
|
)
|
|
|
|
if has_selected:
|
|
mat_all = last_token_selected_heads(outputs.attentions, selected_pairs)
|
|
sub_mat2 = mat_all[:, wanted_idx]
|
|
sub_tokens2 = [tokens[i] for i in wanted_idx]
|
|
plot_heatmap(
|
|
sub_mat2,
|
|
sub_tokens2,
|
|
head_tags,
|
|
os.path.join(out_dir, f"{fname_prefix}{sample_id}_imp_heads.png"),
|
|
" ",
|
|
)
|
|
|
|
|
|
def main(args):
|
|
json_file = Path(args.json_file)
|
|
if not json_file.exists():
|
|
raise RuntimeError(f"JSON file not found: {json_file}")
|
|
with open(json_file, "r", encoding="utf-8") as f:
|
|
samples = json.load(f)
|
|
|
|
if not isinstance(samples, list):
|
|
raise ValueError("Expected a list of samples in the JSON file.")
|
|
|
|
if len(args.cuda) > 1:
|
|
os.environ["CUDA_VISIBLE_DEVICES"] = ",".join(map(str, args.cuda))
|
|
device = None
|
|
else:
|
|
device = torch.device(f"cuda:{args.cuda[0]}" if torch.cuda.is_available() else "cpu")
|
|
|
|
print(f"🔵 Loading base model from {args.model_path}")
|
|
config = AutoConfig.from_pretrained(args.model_path, trust_remote_code=True)
|
|
tokenizer = AutoTokenizer.from_pretrained(args.model_path, trust_remote_code=True)
|
|
model = AutoModelForCausalLM.from_pretrained(
|
|
args.model_path,
|
|
config=config,
|
|
torch_dtype=torch.bfloat16,
|
|
device_map="auto" if device is None else {"": device.index},
|
|
trust_remote_code=True,
|
|
attn_implementation="eager"
|
|
)
|
|
model.eval()
|
|
|
|
if args.important_file and os.path.exists(args.important_file):
|
|
selected_pairs, head_tags = load_important_heads(args.important_file)
|
|
print("✔ Loaded important heads:", head_tags)
|
|
else:
|
|
selected_pairs, head_tags = [], []
|
|
print("⚠ No important_heads.json found, visualizing average over all heads")
|
|
|
|
base_out = args.output_path
|
|
visualize_samples(
|
|
model,
|
|
tokenizer,
|
|
samples,
|
|
base_out,
|
|
device,
|
|
selected_pairs,
|
|
head_tags,
|
|
prefix=args.base_prefix,
|
|
)
|
|
|
|
lora_path = (args.lora_path or "").strip()
|
|
if lora_path:
|
|
print(f"🟣 Applying LoRA adapter from {lora_path}")
|
|
model = PeftModel.from_pretrained(
|
|
model,
|
|
lora_path,
|
|
device_map="auto" if device is None else {"": device.index}
|
|
)
|
|
lora_out = args.lora_output_path or base_out
|
|
lora_prefix = args.lora_prefix.strip() if args.lora_prefix else Path(lora_path.rstrip("/")).name
|
|
visualize_samples(
|
|
model,
|
|
tokenizer,
|
|
samples,
|
|
lora_out,
|
|
device,
|
|
selected_pairs,
|
|
head_tags,
|
|
prefix=lora_prefix,
|
|
)
|
|
else:
|
|
print("⚪️ No LoRA adapter applied, using base model only.")
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--important_file", type=str, default="important_heads.json",
|
|
help="Path to important_heads.json file with selected attention heads.")
|
|
parser.add_argument("--model_path", type=str, default="/home/user/models/Llama-3-8B",
|
|
help="Path to base pretrained model.")
|
|
parser.add_argument("--lora_path", type=str, default="", help="Optional LoRA adapter path.")
|
|
parser.add_argument("--json_file", type=str, default="samples.json", help="Input JSON file.")
|
|
parser.add_argument("--output_path", type=str, default="./attn_vis", help="Base output folder for heatmaps.")
|
|
parser.add_argument("--lora_output_path", type=str, default="",
|
|
help="Optional output folder for the LoRA adapter visualizations.")
|
|
parser.add_argument("--base_prefix", type=str, default="", help="Filename prefix for base outputs.")
|
|
parser.add_argument("--lora_prefix", type=str, default="", help="Filename prefix for LoRA outputs.")
|
|
parser.add_argument("--cuda", type=int, nargs='+', default=[0],
|
|
help="CUDA device indices, e.g. 0 or 0 1.")
|
|
args = parser.parse_args()
|
|
main(args)
|