TokenTrace / backend /core /tiny_nla.py
cccmmd
feat: add Tiny-NLA activation explanation with trained model weights
9d0d4e9
Raw
History Blame Contribute Delete
7.53 kB
"""Tiny-NLA 引擎:LoRA + AR head 模型管理(lazy 单例)。
职责:
- lazy 加载 Qwen/Qwen3-0.6B-Base(float32 + eager,对齐训练)+ LoRA adapter + AR head
- extract_activation(text, token_index) → layer 19 残差流
- explain(activation) → 注入激活 → generate → 自然语言解释
- reconstruct_cosine(activation, explanation) → AR head 重建 → cosine
所有模型均独立加载(float32 + eager),不复用 base 槽(float16),确保与训练精度对齐。
"""
import threading
import time
from pathlib import Path
import torch
import torch.nn.functional as F
import yaml
from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer
from backend.models.device import DeviceManager
tiny_nla_lock = threading.Lock()
TINY_NLA_LOCK_TIMEOUT = 30.0
REPO_ROOT = Path(__file__).resolve().parents[2]
SIDECAR_PATH = REPO_ROOT / "experiments" / "tiny_nla" / "nla_meta.yaml"
CHECKPOINT_DIR = REPO_ROOT / "artifacts" / "tiny_nla" / "checkpoints"
AV_CHECKPOINT = CHECKPOINT_DIR / "av"
AR_CHECKPOINT = CHECKPOINT_DIR / "ar" / "best_ar_head.pt"
class TinyNLAEngine:
"""LoRA + AR head 单例引擎。首次调用时 lazy 加载全部模型。"""
_instance = None
_init_done = False
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if self._init_done:
return
with open(SIDECAR_PATH, "r") as f:
self.meta = yaml.safe_load(f)
self.base_model_name = self.meta["base_model"]
self.av_model_name = self.meta["av_init_model"]
self.layer_idx = self.meta["layer_index"]
self.d_model = self.meta["d_model"]
self.inj_char = self.meta["tokens"]["injection_char"]
self.inj_token_id = self.meta["tokens"]["injection_token_id"]
self.inj_scale = self.meta["extraction"]["injection_scale"]
self.device = DeviceManager.get_device()
self.dtype = torch.float32
self._base_model = None
self._base_tokenizer = None
self._av_model = None
self._av_tokenizer = None
self._ar_head = None
self._init_done = True
def _ensure_loaded(self):
if self._base_model is not None:
return
t0 = time.perf_counter()
print(f" [TinyNLA] Loading base ({self.base_model_name})...")
self._base_model = AutoModelForCausalLM.from_pretrained(
self.base_model_name,
trust_remote_code=True,
torch_dtype=self.dtype,
low_cpu_mem_usage=True,
attn_implementation="eager",
).to(self.device)
self._base_model.eval()
self._base_tokenizer = AutoTokenizer.from_pretrained(
self.base_model_name, trust_remote_code=True
)
DeviceManager.print_model_load_stats(self._base_model, time.perf_counter() - t0)
t1 = time.perf_counter()
print(f" [TinyNLA] Loading AV base ({self.av_model_name}) + LoRA...")
av_base = AutoModelForCausalLM.from_pretrained(
self.av_model_name,
trust_remote_code=True,
torch_dtype=self.dtype,
low_cpu_mem_usage=True,
attn_implementation="eager",
).to(self.device)
self._av_model = PeftModel.from_pretrained(av_base, str(AV_CHECKPOINT))
self._av_model.eval()
self._av_tokenizer = AutoTokenizer.from_pretrained(
str(AV_CHECKPOINT), trust_remote_code=True
)
DeviceManager.print_model_load_stats(self._av_model, time.perf_counter() - t1)
if AR_CHECKPOINT.exists():
print(f" [TinyNLA] Loading AR head...")
state = torch.load(
str(AR_CHECKPOINT), map_location=self.device, weights_only=True
)
if "linear.weight" in state:
state = {"weight": state["linear.weight"]}
self._ar_head = torch.nn.Linear(self.d_model, self.d_model, bias=False)
self._ar_head.load_state_dict(state)
self._ar_head.to(self.device)
self._ar_head.eval()
else:
print(f" [TinyNLA] ⚠ AR checkpoint not found at {AR_CHECKPOINT}")
self._ar_head = None
def extract_activation(self, text: str, token_index: int) -> torch.Tensor:
"""用 float32 base 模型提取 layer 19 残差流。"""
self._ensure_loaded()
inputs = self._base_tokenizer(text, return_tensors="pt").to(self.device)
seq_len = inputs["input_ids"].shape[1]
if token_index >= seq_len:
raise ValueError(f"token_index {token_index} out of range (seq_len={seq_len})")
with torch.no_grad():
outputs = self._base_model(**inputs, output_hidden_states=True, use_cache=False)
DeviceManager.synchronize(self.device)
activation = outputs.hidden_states[self.layer_idx][0, token_index, :].cpu()
return activation
def explain(self, activation: torch.Tensor, max_new_tokens: int = 64) -> str:
"""注入激活向量 → generate → 返回 explanation 文本。"""
self._ensure_loaded()
prompt = f"<concept>{self.inj_char}</concept>\n<explanation>"
inputs = self._av_tokenizer(prompt, return_tensors="pt").to(self.device)
embeds = self._av_model.get_input_embeddings()(inputs["input_ids"])
inj_positions = (inputs["input_ids"][0] == self.inj_token_id).nonzero(as_tuple=True)[0]
if len(inj_positions) > 0:
inj_pos = inj_positions[0].item()
norm = activation.norm()
if norm > 0:
activation = activation / norm * self.inj_scale
scaled_act = activation.to(embeds.dtype).to(self.device)
embeds[0, inj_pos, :] = scaled_act
with torch.no_grad():
output_ids = self._av_model.generate(
inputs_embeds=embeds,
max_new_tokens=max_new_tokens,
do_sample=False,
pad_token_id=self._av_tokenizer.pad_token_id or self._av_tokenizer.eos_token_id,
)
DeviceManager.synchronize(self.device)
prompt_len_tokens = inputs["input_ids"].shape[1]
gen_token_ids = output_ids[0][prompt_len_tokens:]
explanation = self._av_tokenizer.decode(gen_token_ids, skip_special_tokens=True).strip()
return explanation
def reconstruct_cosine(self, activation: torch.Tensor, explanation: str) -> float:
"""AR head 重建 → 计算 cosine。用 float32 base 模型提取 explanation 的 last hidden。"""
self._ensure_loaded()
if self._ar_head is None:
return 0.0
inputs = self._base_tokenizer(
explanation, return_tensors="pt", truncation=True, max_length=128
).to(self.device)
with torch.no_grad():
outputs = self._base_model(**inputs, output_hidden_states=True, use_cache=False)
last_hidden = outputs.hidden_states[-1]
seq_len = inputs["attention_mask"].sum(dim=1) - 1
last_token_hidden = last_hidden[0, seq_len[0], :]
reconstructed = self._ar_head(last_token_hidden)
DeviceManager.synchronize(self.device)
orig_n = F.normalize(activation.unsqueeze(0).to(self.device), dim=-1)
recon_n = F.normalize(reconstructed.unsqueeze(0), dim=-1)
cosine = (orig_n * recon_n).sum(dim=-1).item()
return cosine