FeatureLens / experiments /run_causal.py
ArchitSharma's picture
Finalize FeatureLens causal position study
b784950
Raw
History Blame Contribute Delete
18.2 kB
from __future__ import annotations
import argparse
import csv
import math
from pathlib import Path
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from experiments.common import ARTIFACT_DIR, DATA_DIR, load_jsonl, set_seed
from featurelens.config import SETTINGS
from featurelens.interventions import InterventionSpec, normalized_random_control, residual_delta
from featurelens.metrics import js_divergence_from_logits, sequence_logprob_summary
from featurelens.sae import SAEStore, SparseEncoding
POSITION_POLICIES = ('final_token', 'max_feature_activation')
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description='Run held-out causal SAE interventions.')
parser.add_argument('--tasks', type=Path, default=DATA_DIR / 'causal_tasks.jsonl')
parser.add_argument('--catalog', type=Path, default=ARTIFACT_DIR / 'feature_catalog.csv')
parser.add_argument('--output', type=Path, default=None)
parser.add_argument('--position-policy', choices=POSITION_POLICIES, default='final_token')
parser.add_argument('--seed', type=int, default=42)
parser.add_argument('--random-controls', type=int, default=8)
parser.add_argument(
'--resume',
action='store_true',
help='Resume from task-level rows already checkpointed in --output.',
)
return parser.parse_args()
def default_output(policy: str) -> Path:
if policy == 'final_token':
return ARTIFACT_DIR / 'causal_results_final_token.csv'
return ARTIFACT_DIR / 'causal_results_max_active.csv'
def _completion_marker(path: Path) -> Path:
return path.with_suffix(path.suffix + '.complete')
def _write_rows_atomic(path: Path, rows: list[dict]) -> None:
if not rows:
return
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + '.tmp')
with temporary.open('w', newline='', encoding='utf-8') as handle:
writer = csv.DictWriter(handle, fieldnames=list(rows[0].keys()))
writer.writeheader()
writer.writerows(rows)
temporary.replace(path)
def _load_checkpoint_rows(path: Path) -> list[dict]:
if not path.exists():
return []
with path.open(newline='', encoding='utf-8') as handle:
return list(csv.DictReader(handle))
def load_selected_features(path: Path) -> dict[str, dict]:
with path.open(newline='', encoding='utf-8') as handle:
rows = list(csv.DictReader(handle))
selected: dict[str, dict] = {}
for row in rows:
concept = row['concept']
score = float(row['train_auroc'])
contrast = float(row['activation_rate_pos']) - float(row['activation_rate_neg'])
key = (score, contrast)
if concept not in selected or key > selected[concept]['_key']:
selected[concept] = {
'_key': key,
'layer': int(row['layer']),
'feature_id': int(row['feature_id']),
'train_auroc': score,
'test_auroc': float(row['auroc']),
'test_f1': float(row['f1']),
}
return selected
def hidden_from_output(output):
return output[0] if isinstance(output, tuple) else output
def replace_hidden(output, hidden):
return (hidden, *output[1:]) if isinstance(output, tuple) else hidden
def _make_capture_hook(capture: dict):
def capture_hook(_module, _inp, output):
if 'hidden' not in capture:
capture['hidden'] = hidden_from_output(output).detach()
return capture_hook
def _make_batch_edit_hook(
applied: dict[str, bool],
intervention_token_index: int,
deltas: torch.Tensor,
):
def batch_edit_hook(_module, _inp, output):
if applied['done']:
return output
hidden = hidden_from_output(output)
modified = hidden.clone()
modified[:, intervention_token_index, :] = (
modified[:, intervention_token_index, :]
+ deltas.to(hidden.device, hidden.dtype)
)
applied['done'] = True
return replace_hidden(output, modified)
return batch_edit_hook
def append_target(inputs: dict[str, torch.Tensor], target_ids: list[int]) -> dict[str, torch.Tensor]:
prompt_ids = inputs['input_ids']
target = torch.tensor(target_ids, dtype=prompt_ids.dtype, device=prompt_ids.device).unsqueeze(0)
full_ids = torch.cat([prompt_ids, target], dim=1)
attention = inputs.get('attention_mask', torch.ones_like(prompt_ids))
target_mask = torch.ones((1, len(target_ids)), dtype=attention.dtype, device=attention.device)
return {
'input_ids': full_ids,
'attention_mask': torch.cat([attention, target_mask], dim=1),
}
def make_random_controls(delta: torch.Tensor, seed: int, count: int) -> list[torch.Tensor]:
if count < 1:
raise ValueError('--random-controls must be at least 1.')
return [
normalized_random_control(delta, seed=int(seed) + 104729 * idx)
for idx in range(int(count))
]
def feature_activation_trace(encoding: SparseEncoding, feature_id: int) -> torch.Tensor:
"""Return one TopK feature activation per encoded token."""
indices = encoding.indices
values = encoding.values
if indices.ndim != 2 or values.ndim != 2:
raise ValueError('Expected tokenwise sparse encoding with shape [tokens, top_k].')
mask = indices == int(feature_id)
return torch.where(mask, values, torch.zeros_like(values)).max(dim=-1).values
def choose_intervention_position(
token_activations: torch.Tensor,
*,
prompt_len: int,
position_policy: str,
) -> tuple[int, float, bool]:
if prompt_len < 1:
raise ValueError('Prompt must contain at least one token.')
if position_policy == 'final_token':
index = prompt_len - 1
activation = float(token_activations[index].item())
return index, activation, activation > 0.0
if position_policy != 'max_feature_activation':
raise ValueError(f'Unknown position policy: {position_policy}')
max_activation, max_index = torch.max(token_activations[:prompt_len], dim=0)
activation = float(max_activation.item())
if activation <= 0.0:
# No selected feature is represented in TopK anywhere in the prompt.
# Keep a deterministic final-token location; the feature delta is zero.
return prompt_len - 1, 0.0, False
return int(max_index.item()), activation, True
@torch.inference_mode()
def main() -> None:
args = parse_args()
if args.output is None:
args.output = default_output(args.position_policy)
set_seed(args.seed)
tasks = load_jsonl(args.tasks)
selected = load_selected_features(args.catalog)
missing = sorted({task['concept'] for task in tasks}.difference(selected))
if missing:
raise RuntimeError(f'No selected SAE features for concepts: {missing}')
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model_dtype = torch.float16 if device.type == 'cuda' else torch.float32
tokenizer = AutoTokenizer.from_pretrained(SETTINGS.model_id)
if tokenizer.pad_token_id is None:
tokenizer.pad_token = tokenizer.eos_token
model = AutoModelForCausalLM.from_pretrained(
SETTINGS.model_id,
torch_dtype=model_dtype,
low_cpu_mem_usage=True,
).to(device)
model.eval()
selected_layers = sorted({item['layer'] for item in selected.values()})
sae_store = SAEStore(
SETTINGS.sae_repo_id,
layers=selected_layers,
device=device,
dtype=torch.float32,
top_k=SETTINGS.sae_top_k,
)
marker = _completion_marker(args.output)
if args.resume:
results: list[dict] = _load_checkpoint_rows(args.output)
else:
results = []
args.output.unlink(missing_ok=True)
marker.unlink(missing_ok=True)
expected_rows_per_task = 2 * (1 + int(args.random_controls))
completed_counts: dict[str, int] = {}
for row in results:
task_id = str(row.get('task_id', ''))
completed_counts[task_id] = completed_counts.get(task_id, 0) + 1
for task_idx, task in enumerate(tasks):
task_id = str(task['id'])
if args.resume and completed_counts.get(task_id, 0) == expected_rows_per_task:
print(
f'SKIP {args.position_policy} causal task {task_idx + 1}/{len(tasks)}: {task_id}',
flush=True,
)
continue
if args.resume and completed_counts.get(task_id, 0):
results = [row for row in results if str(row.get('task_id', '')) != task_id]
concept = task['concept']
choice = selected[concept]
layer = int(choice['layer'])
feature_id = int(choice['feature_id'])
sae = sae_store.get(layer)
prompt_inputs = tokenizer(task['prompt'], return_tensors='pt', truncation=True, max_length=192)
prompt_inputs = {key: value.to(device) for key, value in prompt_inputs.items()}
prompt_len = int(prompt_inputs['input_ids'].shape[1])
target_ids = tokenizer(task['target'], add_special_tokens=False)['input_ids']
if not target_ids:
raise RuntimeError(f"Target tokenization empty for task {task['id']}")
target_ids = [int(x) for x in target_ids]
full_inputs = append_target(prompt_inputs, target_ids)
capture: dict = {}
handle = model.model.layers[layer].register_forward_hook(_make_capture_hook(capture))
single_baseline_out = model(**full_inputs, use_cache=False)
handle.remove()
single_baseline_logits = single_baseline_out.logits[0]
_, single_baseline_mean, _ = sequence_logprob_summary(
single_baseline_logits,
prompt_length=prompt_len,
target_ids=target_ids,
)
prompt_hidden = capture['hidden'][0, :prompt_len]
token_encoding = sae.encode(prompt_hidden)
token_activations = feature_activation_trace(token_encoding, feature_id)
final_token_activation = float(token_activations[prompt_len - 1].item())
max_activation_value, max_activation_index = torch.max(token_activations, dim=0)
max_prompt_activation = float(max_activation_value.item())
max_prompt_index = int(max_activation_index.item()) if max_prompt_activation > 0 else prompt_len - 1
active_anywhere = max_prompt_activation > 0.0
intervention_index, original_activation, active_at_intervention = choose_intervention_position(
token_activations,
prompt_len=prompt_len,
position_policy=args.position_policy,
)
prompt_token_ids = prompt_inputs['input_ids'][0]
intervention_token_text = tokenizer.decode([int(prompt_token_ids[intervention_index].item())])
max_prompt_token_text = tokenizer.decode([int(prompt_token_ids[max_prompt_index].item())])
final_token_text = tokenizer.decode([int(prompt_token_ids[prompt_len - 1].item())])
specs = [
('ablate', InterventionSpec('ablate', 0.0)),
('amplify_2x', InterventionSpec('scale', 2.0)),
]
condition_meta: list[tuple[str, str, int, InterventionSpec, torch.Tensor, float]] = []
for spec_idx, (intervention_name, spec) in enumerate(specs):
delta = residual_delta(sae.decoder_direction(feature_id), original_activation, spec)
condition_meta.append(
(
intervention_name,
'sae_feature',
-1,
spec,
delta,
float(spec.delta_activation(original_activation)),
)
)
controls = make_random_controls(
delta,
seed=args.seed + task_idx * 1009 + spec_idx * 100_003,
count=args.random_controls,
)
for control_id, control_delta in enumerate(controls):
condition_meta.append(
(
intervention_name,
'random_norm_matched',
control_id,
spec,
control_delta,
math.nan,
)
)
zero = torch.zeros_like(condition_meta[0][4])
deltas = torch.stack([zero, *[item[4] for item in condition_meta]], dim=0)
repeated = {key: value.repeat(deltas.shape[0], 1) for key, value in full_inputs.items()}
applied = {'done': False}
hook = model.model.layers[layer].register_forward_hook(
_make_batch_edit_hook(applied, intervention_index, deltas)
)
edited_out = model(**repeated, use_cache=False)
hook.remove()
baseline_logits = edited_out.logits[0]
baseline_next = baseline_logits[prompt_len - 1]
baseline_seq, baseline_mean, _ = sequence_logprob_summary(
baseline_logits,
prompt_length=prompt_len,
target_ids=target_ids,
)
execution_drift_mean = float(baseline_mean - single_baseline_mean)
execution_drift_js = js_divergence_from_logits(
single_baseline_logits[prompt_len - 1], baseline_next
)
target_id = target_ids[0]
baseline_prob = float(torch.softmax(baseline_next.float(), dim=-1)[target_id].item())
baseline_rank = int((baseline_next > baseline_next[target_id]).sum().item()) + 1
baseline_top1 = int(torch.argmax(baseline_next).item())
for row_idx, (
intervention_name,
condition,
control_id,
_spec,
applied_delta,
delta_activation,
) in enumerate(condition_meta, start=1):
modified_logits = edited_out.logits[row_idx]
modified_next = modified_logits[prompt_len - 1]
modified_prob = float(torch.softmax(modified_next.float(), dim=-1)[target_id].item())
modified_rank = int((modified_next > modified_next[target_id]).sum().item()) + 1
modified_top1 = int(torch.argmax(modified_next).item())
modified_seq, modified_mean, _ = sequence_logprob_summary(
modified_logits,
prompt_length=prompt_len,
target_ids=target_ids,
)
results.append(
{
'task_id': task['id'],
'concept': concept,
'prompt': task['prompt'],
'target_text': task['target'],
'target_first_token': tokenizer.decode([target_id]),
'target_token_count': len(target_ids),
'layer': layer,
'feature_id': feature_id,
'feature_train_auroc': choice['train_auroc'],
'feature_test_auroc': choice['test_auroc'],
'feature_test_f1': choice['test_f1'],
'position_policy': args.position_policy,
'intervention_token_index': intervention_index,
'intervention_token_text': intervention_token_text,
'final_token_index': prompt_len - 1,
'final_token_text': final_token_text,
'max_prompt_feature_token_index': max_prompt_index,
'max_prompt_feature_token_text': max_prompt_token_text,
'feature_activation': original_activation,
'final_token_feature_activation': final_token_activation,
'max_prompt_feature_activation': max_prompt_activation,
'feature_active_at_intervention': int(active_at_intervention),
'feature_active_at_final_token': int(final_token_activation > 0.0),
'feature_active_anywhere': int(active_anywhere),
'intervention': intervention_name,
'condition': condition,
'control_id': control_id,
'random_control_count': args.random_controls,
'delta_activation': delta_activation,
'perturbation_l2': float(torch.linalg.vector_norm(applied_delta.float()).item()),
'execution_context_mean_logprob_drift': execution_drift_mean,
'execution_context_js_drift': execution_drift_js,
'baseline_target_prob': baseline_prob,
'modified_target_prob': modified_prob,
'target_prob_delta': modified_prob - baseline_prob,
'target_logprob_delta': float(
torch.log_softmax(modified_next.float(), dim=-1)[target_id].item()
- torch.log_softmax(baseline_next.float(), dim=-1)[target_id].item()
),
'baseline_target_rank': baseline_rank,
'modified_target_rank': modified_rank,
'target_rank_delta': modified_rank - baseline_rank,
'baseline_target_sequence_logprob': baseline_seq,
'modified_target_sequence_logprob': modified_seq,
'target_sequence_logprob_delta': modified_seq - baseline_seq,
'baseline_target_mean_logprob': baseline_mean,
'modified_target_mean_logprob': modified_mean,
'target_mean_logprob_delta': modified_mean - baseline_mean,
'js_divergence': js_divergence_from_logits(baseline_next, modified_next),
'top1_changed': int(modified_top1 != baseline_top1),
}
)
_write_rows_atomic(args.output, results)
print(
f'Causal {args.position_policy} task {task_idx + 1}/{len(tasks)}: '
f'{concept} @ token {intervention_index} (activation {original_activation:.4f})',
flush=True,
)
_write_rows_atomic(args.output, results)
marker.write_text('complete\n', encoding='utf-8')
print(f'Wrote {len(results)} {args.position_policy} causal rows to {args.output}')
if __name__ == '__main__':
main()