mirror of
https://github.com/wassname/weight-steering.git
synced 2026-08-08 11:29:23 +08:00
v10 functional projection falsifier for act-oracle overlap
This commit is contained in:
@@ -1,3 +1,169 @@
|
||||
# Research log - append to bottom only, ideally give commit / branch time. newest first
|
||||
|
||||
# init 2026-04-26 06:32:46
|
||||
|
||||
see @fork_plan.md and head of README.md for overall plan and principles.
|
||||
|
||||
# made notebook 2026-04-26 10:32:40
|
||||
|
||||
Where does the LoRA's Δa live?
|
||||
|
||||
In a task-derived hidden subspace that is not the lm_head readout. Held-out energy ratios (mean over LoRA-active layers 8–27, rank-8 each):
|
||||
|
||||
I just want to check you have the framing right
|
||||
|
||||
we are searching for how to modify pretrained LLM's from scratch
|
||||
|
||||
so we have A)
|
||||
|
||||
W the pretrained weights for a given linear layer
|
||||
and hs_diff, the output diff between cho and rej, the persona preffixed inputs strings
|
||||
we can do thinks like project this onto the write_not_read, supresed or churn subspace. As well as via U into the S space
|
||||
|
||||
B)
|
||||
now in terms of clues and label or whatever we have
|
||||
deltaW how the two lora differed
|
||||
and hs_diff2 how steering left and right differ on the same task (no persona prefix)
|
||||
|
||||
we are comparing A the hypothesis to B the label
|
||||
|
||||
# lora lite 2026-04-26 12:32:10
|
||||
|
||||
the peft library is not very hackable and htere is not lora light library we can make one by
|
||||
|
||||
|
||||
- using pytorch or transformers forward hooks
|
||||
- adding weights or param dicts or buffers to the linear layers we modify, but each has a prefix like `lora_` so we can save and load them via full path
|
||||
- we can use all layer, all residual writers, all residual readers with simple logic that looks at isinstance(linear). and if the W.shape is assymetric seeing if the input of output matching residual stream shape, otherwise falling back on hardcoded prefixes like o_out, out_proj, ml_down, etc
|
||||
- make heavy use of einops, jaxtyping, eumsum to make dims obvious and allow beartyping
|
||||
- psueodcode like, fail first, no defensive, no fallback, links to papers
|
||||
- simple single dataclass config
|
||||
- not 4bit or 8bit unless we can think of a simple way that works for all (cast all to bf16 in hook?)
|
||||
- model requires no grad except what we add
|
||||
- obviouslly easy to add new ones! easy to add special init's
|
||||
- look at what made PiSSA, SSVD, DeLora and other papers code hard to implement and make sure we have are comptabable
|
||||
|
||||
## 2026-04-26: hyperparameter correction + 0.6B vs 1.7B comparison
|
||||
|
||||
Paper Axolotl config shows `lr=2e-4, lora_alpha=64` (alpha/rank=2.0).
|
||||
Our previous runs used `lr=1e-5, alpha=16` (alpha/rank=0.5) — 20x too slow, wrong alpha.
|
||||
|
||||
Three-run comparison (all: r32, 3 epochs, sycophancy, lora, 1000 pairs, 20×5×10):
|
||||
|
||||
| run | lr / alpha | \|\|w\|\| | spread (α:-2→+2) | val_loss@ep3 | converged? |
|
||||
|-----|-----------|---------|-----------------|------------|------------|
|
||||
| task-50: 0.6B bad-lr | 1e-5 / 16 | 0.165 | 5.16 | 1.035 | no (still dropping) |
|
||||
| task-53: 0.6B paper-lr | 2e-4 / 64 | 5.981 | 12.85 | 0.706 | yes (U-curve ep2) |
|
||||
| task-54: 1.7B paper-lr | 2e-4 / 64 | 9.262 | 36.61 | 0.873 | no (still dropping) |
|
||||
|
||||
Key findings:
|
||||
- Corrected hyperparams: 36x larger ||w||, 2.5x more steering spread for 0.6B.
|
||||
- 0.6B val_loss bottoms at ep~1.94 (0.701), mild U-curve; best to stop at epoch 2.
|
||||
- 1.7B base is anti-sycophantic at α=0 (logratio=-3.58 vs +2.73 for 0.6B) — larger model has
|
||||
better calibration and correctly says No to false claims without steering.
|
||||
- 1.7B on-policy CoT saturates at ±20 nats; off-policy non-monotone at negative alpha
|
||||
(α=-2 less negative than α=-1). Linear approximation breaks down at ||w||=9.26, |alpha|=2.
|
||||
Tighter alpha range (±0.5, ±1.0) needed for 1.7B to stay in linear regime.
|
||||
- 1.7B val_loss higher (0.873 vs 0.706) — training data generated by 0.6B, distribution
|
||||
mismatch reduces signal. Should regen data from 1.7B for clean comparison.
|
||||
- OOD generalization (tabs/spaces claim) strong for both models at paper hyperparams.
|
||||
|
||||
# 2026-04-26: 1.7B own-data regen + adapter-family sweep (tasks 58-59)
|
||||
|
||||
## 1.7B with own-data + tighter alpha (task 58)
|
||||
|
||||
Re-ran 1.7B training with data generated by 1.7B itself (out/data/1.7B, 1000 pairs) and
|
||||
tighter coeff sweep (-1.0 to +1.0) to stay in the linear regime.
|
||||
|
||||
| coeff | mean_logratio | std | pmass |
|
||||
|-------|--------------|--------|--------|
|
||||
| -1.0 | -17.10 | 2.996 | 1.000 |
|
||||
| -0.5 | -12.28 | 7.025 | 1.000 |
|
||||
| 0.0 | -3.58 | 10.698 | 1.000 |
|
||||
| +0.5 | +7.73 | 10.564 | 1.000 |
|
||||
| +1.0 | +16.27 | 5.877 | 1.000 |
|
||||
|
||||
logratio_spread = +33.375, pmass_min = 1.000. Fully monotone, no linear-regime breakdown.
|
||||
Baseline at alpha=0 is -3.58 (1.7B naturally less sycophantic than 0.6B at alpha=0 which was +2.73).
|
||||
|
||||
## Adapter-family sweep (task 59): lora vs dora vs pissa vs delora on 0.6B
|
||||
|
||||
| adapter | logratio_spread | pmass_min | ratio_weak_write | wall_s |
|
||||
|---------|----------------|-----------|-----------------|--------|
|
||||
| lora | +9.76 | 1.000 | 0.885 | 259 |
|
||||
| dora | +9.76 | 1.000 | 0.879 | 321 |
|
||||
| pissa | +17.40 | 0.999 | 1.086 | 326 |
|
||||
| delora | +23.85 | 0.788 | 0.890 | 267 |
|
||||
|
||||
Key findings:
|
||||
- LoRA ≈ DoRA within 0.1% spread; DoRA adds no steering information. Expected <20% variation holds.
|
||||
- PiSSA gives 78% more spread than LoRA (17.4 vs 9.76) AND ratio_weak_write > 1 (1.086 vs 0.885).
|
||||
This is the first case where a non-LoRA adapter outperforms on both spread AND subspace alignment.
|
||||
PiSSA initializes from SVD of W0, which may place the diff more in the task-relevant subspace.
|
||||
- DeLoRA gives the most spread (+23.85) but pmass drops to 0.788 — outside the linear regime.
|
||||
The larger diff norm likely saturates the logratio. ratio_weak_write (0.890) similar to LoRA/DoRA.
|
||||
- Note: LoRA spread is 9.76 here vs 12.85 in task 53. Both use same data (out/data) but different
|
||||
random seeds and output dirs. ~24% run-to-run variation is within expected training noise.
|
||||
|
||||
Verdict: adapter family does matter, contrary to null hypothesis. PiSSA is the Pareto winner:
|
||||
more spread, better subspace alignment, pmass stays near 1. DeLoRA is strongest raw but unreliable.
|
||||
|
||||
## Daily dilemmas OOD honesty transfer (tasks 66-67, corrected)
|
||||
|
||||
Three bugs found and fixed before getting valid results:
|
||||
1. HF datasets caching: `.map()` returned stale tokenized sequences when EVAL_HEADER changed.
|
||||
Fix: `load_from_cache_file=False`.
|
||||
2. `</think>` as text string ≠ the special close token. Must inject the actual token ID.
|
||||
Fix: in `_format_row`, after `apply_chat_template`, detect open `<think>` (id 151667) without
|
||||
matching `</think>` (id 151668) and inject close token + `\n\n` before the answer anchor.
|
||||
3. DataLoader right-padding: `DataCollatorWithPadding` pads on right by default, so `logits[:, -1]`
|
||||
hit a padding token for shorter sequences. Fix: `tok.padding_side = "left"` before DataLoader.
|
||||
This was the main cause of pmass=0.17 -- reading logits at a padding position, not the anchor.
|
||||
|
||||
### 0.6B results (per-persona breakdown):
|
||||
|
||||
| persona | coeff | mean_lrh | pmass |
|
||||
|-----------------|-------|----------|--------|
|
||||
| honest_engineer | 0.0 | +0.851 | 0.957 |
|
||||
| base | -2.0 | -0.786 | 0.975 |
|
||||
| base | -1.0 | +0.299 | 0.954 |
|
||||
| base | 0.0 | +1.316 | 0.938 |
|
||||
| base | +1.0 | +1.828 | 0.962 |
|
||||
| base | +2.0 | +1.645 | 0.989 |
|
||||
|
||||
- pmass: 0.938-0.989. frac_low_pmass = 0.
|
||||
- Mostly monotone; slight dip at alpha=+2 suggests approaching linear-regime boundary.
|
||||
- base@alpha=0 = +1.316 (0.6B is already quite honest at baseline on this dataset).
|
||||
- AxBench: steering@+1 (+1.828) vs honest_engineer persona (+0.851) → weight diff 2.15× stronger.
|
||||
PASS: the weight diff adds information beyond prompting.
|
||||
|
||||
### 1.7B results (per-persona breakdown):
|
||||
|
||||
| persona | coeff | mean_lrh | pmass |
|
||||
|-----------------|-------|----------|--------|
|
||||
| honest_engineer | 0.0 | -1.817 | 1.000 |
|
||||
| base | -1.0 | -0.760 | 1.000 |
|
||||
| base | -0.5 | -0.590 | 1.000 |
|
||||
| base | 0.0 | -0.299 | 1.000 |
|
||||
| base | +0.5 | +0.526 | 1.000 |
|
||||
| base | +1.0 | +1.504 | 1.000 |
|
||||
|
||||
- pmass: 1.000 across all rows (1.7B is more decisive than 0.6B).
|
||||
- Perfectly monotone sweep. Spread -1.0→+1.0 = 2.264 nats (smaller than 0.6B's 2.614 over same
|
||||
range, likely because 1.7B has better baseline calibration reducing the margin for steering).
|
||||
- Persona BACKFIRES for 1.7B: honest_engineer (-1.817) is worse than base (-0.299).
|
||||
Hypothesis: 1.7B has a more nuanced distinction between "software factual honesty" and
|
||||
"moral honesty in dilemmas". The persona activates factual-accuracy behavior, not moral-choice
|
||||
behavior. This is a dataset-persona confound, not a failure of the steering approach.
|
||||
- AxBench: steering@+1 (+1.504) still clearly beats persona (-1.817). PASS.
|
||||
|
||||
### Cross-model comparison:
|
||||
|
||||
Weight steering transfers sycophancy diff to OOD honesty dilemmas for both models.
|
||||
0.6B shows higher absolute effect (base already honest, persona helps), while 1.7B shows cleaner
|
||||
monotonicity and perfect pmass but the "honest engineer" persona backfires at 1.7B scale.
|
||||
|
||||
|
||||
# Research journal — weight-steering
|
||||
|
||||
## 2026-04-27 — v9 cross-adapter results: DeLoRA wins; subspace-finding methods fail
|
||||
@@ -119,3 +285,79 @@ subspace overlap.
|
||||
env var.
|
||||
- nbs/cross_adapter_v9.py — aggregator across the 6 adapter families.
|
||||
- All 18 pueue jobs (88-105) finished Success.
|
||||
|
||||
## 2026-04-27 — v10 functional projection: overlap metric failed, but act_oracle is not the trained steering subspace
|
||||
|
||||
Question: maybe the ~3% overlap between `w_oracle` and `act_oracle_block` is
|
||||
still "the right 3%". If yes, projecting `dW` onto the act_oracle basis should
|
||||
preserve daily-dilemmas steering.
|
||||
|
||||
v10 changed the metric from geometric overlap to behavior:
|
||||
|
||||
1. Build block-local `act_oracle` from the adapter's sycophancy probe effect.
|
||||
2. Decompose residual-output tensors (`o_proj`, `down_proj`) into:
|
||||
- `project_act_block`: $P_{act,K} dW$
|
||||
- `complement_act_block`: $(I - P_{act,K}) dW$
|
||||
- `project_act_block_normmatched`: same projection scaled to residual-write norm
|
||||
3. Run the same daily-dilemmas honesty logratio.
|
||||
|
||||
### Main K=32 result (n=40 dilemmas / 80 rows)
|
||||
|
||||
| adapter | full Δ | residual-write Δ | raw projection / residual | normmatched / residual | complement / residual | read |
|
||||
|---------|--------|------------------|---------------------------|------------------------|-----------------------|------|
|
||||
| delora | +0.628 | +0.844 | 0.07 | 0.30 | 0.89 | clean counterexample: trained behavior mostly in complement |
|
||||
| pissa | +0.373 | +0.242 | 0.47 | 1.14 | 0.64 | mixed: act projection is functional but not sole carrier |
|
||||
| oft | +0.216 | +0.148 | -0.01 | 1.57 | 0.69 | act direction potent only after amplification |
|
||||
| dora | +0.370 | +0.031 | -0.50 | 1.15 | 1.20 | residual-write split barely explains full effect |
|
||||
| lora | +0.173 | -0.022 | noisy | noisy | noisy | residual-write split wrong-sign |
|
||||
| ia3 | -0.048 | +0.002 | noisy | noisy | noisy | denominator too small |
|
||||
|
||||
So v10 mostly kills the strongest loophole for DeLoRA: the act_oracle projection
|
||||
at trained scale does not carry the steering; the complement does. PiSSA/OFT
|
||||
are subtler: the act_oracle directions are real and causal when amplified, but
|
||||
the trained adapter did not put enough norm there for them to explain behavior
|
||||
at alpha=1.
|
||||
|
||||
### Alpha sweep at K=32 (informative adapters only)
|
||||
|
||||
Δ = daily-dilemmas honesty logratio minus base.
|
||||
|
||||
| adapter | alpha | residual | raw projection | normmatched projection | complement |
|
||||
|---------|-------|----------|----------------|------------------------|------------|
|
||||
| delora | 0.5 | +0.387 | -0.020 | +0.100 | +0.361 |
|
||||
| delora | 1.0 | +0.844 | +0.061 | +0.252 | +0.755 |
|
||||
| delora | 2.0 | +1.858 | +0.097 | +0.503 | +1.680 |
|
||||
| delora | 4.0 | +2.150 | +0.183 | +0.430 | +2.739 |
|
||||
| pissa | 0.5 | +0.159 | +0.044 | +0.217 | +0.036 |
|
||||
| pissa | 1.0 | +0.242 | +0.114 | +0.277 | +0.155 |
|
||||
| pissa | 2.0 | +0.327 | +0.206 | +0.394 | +0.270 |
|
||||
| pissa | 4.0 | +0.578 | +0.350 | +0.473 | +0.380 |
|
||||
| oft | 0.5 | +0.059 | -0.020 | +0.077 | +0.034 |
|
||||
| oft | 1.0 | +0.148 | -0.002 | +0.233 | +0.103 |
|
||||
| oft | 2.0 | +0.309 | +0.084 | +0.478 | +0.184 |
|
||||
| oft | 4.0 | +0.564 | +0.166 | +0.864 | +0.381 |
|
||||
|
||||
Interpretation sequence:
|
||||
|
||||
- v9 overlap was too strict as a *potency* metric. Normmatched act projections
|
||||
can steer strongly, especially OFT/PiSSA.
|
||||
- v9 overlap was not wrong as a *trained-scale carrier* metric. Raw act
|
||||
projection usually carries little of the actual trained behavior.
|
||||
- DeLoRA is the cleanest finding: residual-write Δ is strong (+0.844), raw
|
||||
act projection is weak (+0.061), complement is strong (+0.755). That is hard
|
||||
to reconcile with "the right 3% explains the behavior".
|
||||
- PiSSA/OFT suggest the act_oracle subspace is a useful *intervention target*,
|
||||
not the subspace the adapter naturally chose.
|
||||
|
||||
My current model: PCA of activation differences finds directions where small
|
||||
weight writes can be high-gain, but trained adapter behavior is distributed
|
||||
through a larger residual-write complement plus read/gate/up paths. The word
|
||||
"planning subspace" is probably misleading unless we define it causally (what
|
||||
intervention changes behavior), not geometrically (what basis overlaps).
|
||||
|
||||
### Artifacts
|
||||
|
||||
- nbs/functional_projection_v10.py
|
||||
- docs/spec/20260427_v10_functional_projection.md
|
||||
- out/sycophancy/v10_functional_projection/{behavior_summary.csv, behavior_by_coeff.csv, spectra_and_projection.csv}
|
||||
- out/sycophancy/v10_alpha_sweep/{behavior_summary.csv, behavior_by_coeff.csv, spectra_and_projection.csv}
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
# %% [markdown]
|
||||
# # v10 functional projection falsifier
|
||||
#
|
||||
# v9 measured geometric span overlap. This script asks the load-bearing question:
|
||||
# if we keep only the part of `dW` that writes inside the block-local
|
||||
# activation oracle, does daily-dilemmas steering survive?
|
||||
#
|
||||
# Interpretation:
|
||||
# - high retention at small K: v9 overlap metric was the wrong norm.
|
||||
# - low retention even at K=32: act_oracle PCA is not the functional steering subspace.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import polars as pl
|
||||
import torch
|
||||
from datasets import load_dataset
|
||||
from loguru import logger
|
||||
from torch import Tensor
|
||||
from torch.utils.data import DataLoader
|
||||
from transformers import AutoModelForCausalLM, AutoTokenizer, DataCollatorWithPadding
|
||||
|
||||
from ws.data import SYCOPHANCY_TOPICS
|
||||
from ws.diff import load_diff
|
||||
from ws.eval.dilemmas import DilemmasCfg, _eval_at_coeff, _format_row, summarize
|
||||
from ws.eval.sycophancy import get_choice_ids
|
||||
from ws.steer import weight_steer
|
||||
|
||||
|
||||
MODEL_ID = "Qwen/Qwen3-0.6B"
|
||||
RESIDUAL_WRITE_RE = re.compile(r"model\.layers\.(\d+)\.(self_attn\.o_proj|mlp\.down_proj)\.weight")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Cli:
|
||||
out: Path = Path("out/sycophancy/v10_functional_projection")
|
||||
adapters: tuple[str, ...] = ("lora", "dora", "pissa", "delora", "oft", "ia3")
|
||||
ks: tuple[int, ...] = (1, 2, 4, 8, 16, 32)
|
||||
alphas: tuple[float, ...] = (1.0,)
|
||||
n_dilemmas: int = 40
|
||||
batch_size: int = 8
|
||||
max_tokens: int = 512
|
||||
model_id: str = MODEL_ID
|
||||
|
||||
|
||||
def setup_logger() -> None:
|
||||
logger.remove()
|
||||
logger.add(sys.stdout, level="INFO", colorize=False, format="{message}")
|
||||
|
||||
|
||||
def sycophancy_probe_prompts() -> list[str]:
|
||||
return [f"I think {claim}. Do you agree? Answer Yes or No." for claim, _q in SYCOPHANCY_TOPICS]
|
||||
|
||||
|
||||
def encode_last_token(tok, prompts: list[str], device: torch.device):
|
||||
enc = tok(prompts, return_tensors="pt", padding=True, truncation=True, max_length=256).to(device)
|
||||
seq_idx = enc.attention_mask.sum(-1) - 1
|
||||
return enc, seq_idx
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def capture_pre_post(model, tok, w: dict[str, Tensor], prompts: list[str], alpha: float) -> tuple[Tensor, Tensor]:
|
||||
enc, seq_idx = encode_last_token(tok, prompts, model.device)
|
||||
with weight_steer(model, w, alpha):
|
||||
out = model(**enc, output_hidden_states=True)
|
||||
if out.hidden_states is None:
|
||||
raise RuntimeError("output_hidden_states is None")
|
||||
|
||||
b = enc.input_ids.shape[0]
|
||||
d_model = out.hidden_states[0].shape[-1]
|
||||
idx = seq_idx.cpu().view(b, 1, 1).expand(b, 1, d_model)
|
||||
pre, post = [], []
|
||||
for layer in range(model.config.num_hidden_layers):
|
||||
hs_pre = out.hidden_states[layer].float().cpu()
|
||||
hs_post = out.hidden_states[layer + 1].float().cpu()
|
||||
pre.append(hs_pre.gather(1, idx).squeeze(1))
|
||||
post.append(hs_post.gather(1, idx).squeeze(1))
|
||||
return torch.stack(pre), torch.stack(post)
|
||||
|
||||
|
||||
def right_svd_basis(samples: Tensor, k: int) -> tuple[Tensor, Tensor]:
|
||||
norms = samples.norm(dim=1, keepdim=True).clamp(min=1e-12)
|
||||
samples_unit = samples.float().cpu() / norms
|
||||
_u, s, vh = torch.linalg.svd(samples_unit, full_matrices=False)
|
||||
return vh[: min(k, vh.shape[0])].T.contiguous(), s
|
||||
|
||||
|
||||
def block_act_oracle_bases(model, tok, w: dict[str, Tensor], max_k: int) -> tuple[list[Tensor], list[Tensor]]:
|
||||
prompts = sycophancy_probe_prompts()
|
||||
pre_pos, post_pos = capture_pre_post(model, tok, w, prompts, alpha=+1.0)
|
||||
pre_neg, post_neg = capture_pre_post(model, tok, w, prompts, alpha=-1.0)
|
||||
block_diff = (post_pos - pre_pos) - (post_neg - pre_neg)
|
||||
bases, spectra = [], []
|
||||
for layer in range(model.config.num_hidden_layers):
|
||||
B, s = right_svd_basis(block_diff[layer], max_k)
|
||||
bases.append(B)
|
||||
spectra.append(s)
|
||||
return bases, spectra
|
||||
|
||||
|
||||
def residual_write_layer(key: str) -> int | None:
|
||||
match = RESIDUAL_WRITE_RE.fullmatch(key)
|
||||
return None if match is None else int(match.group(1))
|
||||
|
||||
|
||||
def project_w_to_layer_bases(w: dict[str, Tensor], bases: list[Tensor], k: int) -> dict[str, Tensor]:
|
||||
projected = {}
|
||||
for key, value in w.items():
|
||||
layer = residual_write_layer(key)
|
||||
if layer is None:
|
||||
continue
|
||||
B = bases[layer][:, : min(k, bases[layer].shape[1])]
|
||||
projected[key] = (B @ (B.T @ value.float().cpu())).to(value.dtype)
|
||||
if not projected:
|
||||
raise ValueError("projected diff is empty; no residual-output weight keys matched")
|
||||
return projected
|
||||
|
||||
|
||||
def residual_write_only_w(w: dict[str, Tensor]) -> dict[str, Tensor]:
|
||||
residual = {key: value for key, value in w.items() if residual_write_layer(key) is not None}
|
||||
if not residual:
|
||||
raise ValueError("residual-write diff is empty; no o_proj/down_proj weight keys matched")
|
||||
return residual
|
||||
|
||||
|
||||
def complement_w_to_layer_bases(w: dict[str, Tensor], bases: list[Tensor], k: int) -> dict[str, Tensor]:
|
||||
complement = {}
|
||||
for key, value in w.items():
|
||||
layer = residual_write_layer(key)
|
||||
if layer is None:
|
||||
continue
|
||||
B = bases[layer][:, : min(k, bases[layer].shape[1])]
|
||||
W = value.float().cpu()
|
||||
complement[key] = (W - B @ (B.T @ W)).to(value.dtype)
|
||||
if not complement:
|
||||
raise ValueError("complement diff is empty; no residual-output weight keys matched")
|
||||
return complement
|
||||
|
||||
|
||||
def diff_norm(w: dict[str, Tensor]) -> float:
|
||||
return sum(tensor_energy(v) for v in w.values()) ** 0.5
|
||||
|
||||
|
||||
def scale_diff(w: dict[str, Tensor], scale: float) -> dict[str, Tensor]:
|
||||
return {key: (value.float().cpu() * scale).to(value.dtype) for key, value in w.items()}
|
||||
|
||||
|
||||
def tensor_energy(value: Tensor) -> float:
|
||||
return float(value.float().pow(2).sum().item())
|
||||
|
||||
|
||||
def spectra_rows(adapter: str, w: dict[str, Tensor], bases: list[Tensor], act_spectra: list[Tensor], ks: tuple[int, ...]) -> list[dict]:
|
||||
rows = []
|
||||
for key, value in w.items():
|
||||
layer = residual_write_layer(key)
|
||||
if layer is None:
|
||||
continue
|
||||
W = value.float().cpu()
|
||||
dW_s = torch.linalg.svdvals(W)
|
||||
dW_s2 = dW_s.pow(2)
|
||||
act_s = act_spectra[layer]
|
||||
act_s2 = act_s.pow(2)
|
||||
dW_total = dW_s2.sum().clamp(min=1e-12)
|
||||
act_total = act_s2.sum().clamp(min=1e-12)
|
||||
dW_participation_rank = float(dW_s2.sum().pow(2) / dW_s2.pow(2).sum().clamp(min=1e-12))
|
||||
act_participation_rank = float(act_s2.sum().pow(2) / act_s2.pow(2).sum().clamp(min=1e-12))
|
||||
for k in ks:
|
||||
B = bases[layer][:, : min(k, bases[layer].shape[1])]
|
||||
dW_in_act = (B.T @ W).pow(2).sum() / W.pow(2).sum().clamp(min=1e-12)
|
||||
rows.append({
|
||||
"adapter": adapter,
|
||||
"layer": layer,
|
||||
"tensor": key,
|
||||
"k": k,
|
||||
"act_rank_available": bases[layer].shape[1],
|
||||
"act_energy_topk_frac": float(act_s2[: min(k, act_s2.numel())].sum() / act_total),
|
||||
"act_participation_rank": act_participation_rank,
|
||||
"dW_energy_topk_frac": float(dW_s2[: min(k, dW_s2.numel())].sum() / dW_total),
|
||||
"dW_participation_rank": dW_participation_rank,
|
||||
"dW_energy_in_actK_frac": float(dW_in_act),
|
||||
"dW_norm": float(W.pow(2).sum().sqrt()),
|
||||
"dW_projected_norm": float((B.T @ W).pow(2).sum().sqrt()),
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def load_dilemmas_eval(tok, cfg: DilemmasCfg):
|
||||
ds = load_dataset("wassname/daily_dilemmas-self-honesty", "honesty_eval", split="test")
|
||||
honesty_labels = {(r["dilemma_idx"], r["action_type"]): r["honesty_label"] for r in ds}
|
||||
keep = set(sorted(set(ds["dilemma_idx"]))[: cfg.n_dilemmas])
|
||||
ds_eval = ds.filter(lambda x: x["dilemma_idx"] in keep)
|
||||
ds_pt = ds_eval.map(
|
||||
lambda x: _format_row(x, tok, cfg.max_tokens, cfg.system_prompt),
|
||||
remove_columns=ds_eval.column_names,
|
||||
load_from_cache_file=False,
|
||||
)
|
||||
ds_pt = ds_pt.with_format("torch", columns=["input_ids", "dilemma_idx", "idx"])
|
||||
dl = DataLoader(ds_pt, batch_size=cfg.batch_size, shuffle=False, collate_fn=DataCollatorWithPadding(tokenizer=tok, padding="longest"))
|
||||
meta = pl.DataFrame([
|
||||
{"idx": r["idx"], "action_type": r["action_type"], "honesty_label": float(honesty_labels[(r["dilemma_idx"], r["action_type"])])}
|
||||
for r in ds_eval
|
||||
])
|
||||
return dl, meta
|
||||
|
||||
|
||||
def rows_with_honesty(rows: list[dict], meta: pl.DataFrame, *, adapter: str, variant: str, k: int | None) -> pl.DataFrame:
|
||||
return pl.DataFrame(rows).join(meta, on="idx", how="left").with_columns(
|
||||
(pl.col("logratio") * pl.col("honesty_label")).alias("logratio_honesty"),
|
||||
pl.lit(adapter).alias("adapter"),
|
||||
pl.lit(variant).alias("variant"),
|
||||
pl.lit(k).cast(pl.Int64).alias("k"),
|
||||
)
|
||||
|
||||
|
||||
def behavior_summary(df: pl.DataFrame) -> pl.DataFrame:
|
||||
by_coeff = behavior_by_coeff(df)
|
||||
base = by_coeff.select("adapter", "variant", "k", "coeff", "logratio_at_0")
|
||||
pos = (
|
||||
by_coeff.filter(pl.col("coeff") == 1.0)
|
||||
.select("adapter", "variant", "k", "logratio_at_pos", "logratio_at_0", "delta_pos_minus_zero", "mean_pmass", "frac_low_pmass", "n")
|
||||
)
|
||||
full_delta = pos.filter(pl.col("variant") == "full_all_tensors").select(
|
||||
"adapter", pl.col("delta_pos_minus_zero").alias("full_delta")
|
||||
)
|
||||
resid_delta = pos.filter(pl.col("variant") == "residual_write_full").select(
|
||||
"adapter", pl.col("delta_pos_minus_zero").alias("residual_write_delta")
|
||||
)
|
||||
return (
|
||||
pos.join(full_delta, on="adapter", how="left")
|
||||
.join(resid_delta, on="adapter", how="left")
|
||||
.with_columns(
|
||||
(pl.col("delta_pos_minus_zero") / pl.col("full_delta")).alias("retention_vs_full"),
|
||||
(pl.col("delta_pos_minus_zero") / pl.col("residual_write_delta")).alias("retention_vs_residual_write"),
|
||||
)
|
||||
.rename({"logratio_at_pos": "logratio_at_pos1"})
|
||||
.sort("adapter", "variant", "k")
|
||||
)
|
||||
|
||||
|
||||
def behavior_by_coeff(df: pl.DataFrame) -> pl.DataFrame:
|
||||
by_coeff = (
|
||||
df.group_by("adapter", "variant", "k", "coeff")
|
||||
.agg(
|
||||
pl.col("logratio_honesty").mean().alias("mean_logratio_honesty"),
|
||||
pl.col("pmass").mean().alias("mean_pmass"),
|
||||
pl.col("low_pmass").mean().alias("frac_low_pmass"),
|
||||
pl.len().alias("n"),
|
||||
)
|
||||
)
|
||||
base = (
|
||||
by_coeff.filter((pl.col("variant") == "base") & (pl.col("coeff") == 0.0))
|
||||
.select("adapter", pl.col("mean_logratio_honesty").alias("logratio_at_0"))
|
||||
)
|
||||
return (
|
||||
by_coeff.filter(pl.col("variant") != "base")
|
||||
.rename({"mean_logratio_honesty": "logratio_at_pos"})
|
||||
.join(base, on="adapter", how="left")
|
||||
.with_columns((pl.col("logratio_at_pos") - pl.col("logratio_at_0")).alias("delta_pos_minus_zero"))
|
||||
.sort("adapter", "variant", "k", "coeff")
|
||||
)
|
||||
|
||||
|
||||
def eval_variant(model, dl, choice_ids, cfg: DilemmasCfg, w_variant: dict[str, Tensor], alphas: tuple[float, ...]) -> list[dict]:
|
||||
rows = []
|
||||
for alpha in alphas:
|
||||
rows.extend(_eval_at_coeff(model, dl, float(alpha), w_variant, choice_ids, cfg.pmass_threshold))
|
||||
return rows
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import tyro
|
||||
|
||||
setup_logger()
|
||||
cli = tyro.cli(Cli)
|
||||
cli.out.mkdir(parents=True, exist_ok=True)
|
||||
max_k = max(cli.ks)
|
||||
|
||||
tok = AutoTokenizer.from_pretrained(cli.model_id)
|
||||
if tok.pad_token is None:
|
||||
tok.pad_token = tok.eos_token
|
||||
tok.padding_side = "left"
|
||||
model = AutoModelForCausalLM.from_pretrained(cli.model_id, torch_dtype=torch.bfloat16, device_map="auto", attn_implementation="eager")
|
||||
model.eval()
|
||||
|
||||
cfg = DilemmasCfg(model_id=cli.model_id, coeffs=(0.0, 1.0), n_dilemmas=cli.n_dilemmas, batch_size=cli.batch_size, max_tokens=cli.max_tokens)
|
||||
dl, meta = load_dilemmas_eval(tok, cfg)
|
||||
choice_ids = get_choice_ids(tok)
|
||||
|
||||
per_row_parts = []
|
||||
spectra_parts = []
|
||||
for adapter in cli.adapters:
|
||||
w_path = Path("out") / "sycophancy" / adapter / "w.pt"
|
||||
if not w_path.exists():
|
||||
raise FileNotFoundError(w_path)
|
||||
logger.info(f"adapter={adapter}: loading {w_path}")
|
||||
w = load_diff(w_path)
|
||||
w_resid = residual_write_only_w(w)
|
||||
bases, act_spectra = block_act_oracle_bases(model, tok, w, max_k=max_k)
|
||||
spectra_parts.extend(spectra_rows(adapter, w, bases, act_spectra, cli.ks))
|
||||
|
||||
base_rows = _eval_at_coeff(model, dl, 0.0, {}, choice_ids, cfg.pmass_threshold)
|
||||
per_row_parts.append(rows_with_honesty(base_rows, meta, adapter=adapter, variant="base", k=None))
|
||||
|
||||
full_rows = eval_variant(model, dl, choice_ids, cfg, w, cli.alphas)
|
||||
per_row_parts.append(rows_with_honesty(full_rows, meta, adapter=adapter, variant="full_all_tensors", k=None))
|
||||
logger.info(f"adapter={adapter}: full rows={len(full_rows)}")
|
||||
|
||||
resid_rows = eval_variant(model, dl, choice_ids, cfg, w_resid, cli.alphas)
|
||||
per_row_parts.append(rows_with_honesty(resid_rows, meta, adapter=adapter, variant="residual_write_full", k=None))
|
||||
resid_norm = diff_norm(w_resid)
|
||||
logger.info(f"adapter={adapter}: residual_write_norm/full_norm={resid_norm / diff_norm(w):.4f}")
|
||||
|
||||
for k in cli.ks:
|
||||
projected = project_w_to_layer_bases(w, bases, k)
|
||||
complement = complement_w_to_layer_bases(w, bases, k)
|
||||
projected_norm = diff_norm(projected)
|
||||
normmatched = scale_diff(projected, resid_norm / max(projected_norm, 1e-12))
|
||||
logger.info(f"adapter={adapter} k={k}: projected_resid_norm/full_resid_norm={projected_norm / resid_norm:.4f}")
|
||||
rows = eval_variant(model, dl, choice_ids, cfg, projected, cli.alphas)
|
||||
per_row_parts.append(rows_with_honesty(rows, meta, adapter=adapter, variant="project_act_block", k=k))
|
||||
rows = eval_variant(model, dl, choice_ids, cfg, normmatched, cli.alphas)
|
||||
per_row_parts.append(rows_with_honesty(rows, meta, adapter=adapter, variant="project_act_block_normmatched", k=k))
|
||||
rows = eval_variant(model, dl, choice_ids, cfg, complement, cli.alphas)
|
||||
per_row_parts.append(rows_with_honesty(rows, meta, adapter=adapter, variant="complement_act_block", k=k))
|
||||
|
||||
per_row = pl.concat(per_row_parts, how="vertical")
|
||||
spectra = pl.DataFrame(spectra_parts)
|
||||
by_coeff = behavior_by_coeff(per_row)
|
||||
summary = behavior_summary(per_row)
|
||||
|
||||
per_row.write_csv(cli.out / "behavior_per_row.csv")
|
||||
spectra.write_csv(cli.out / "spectra_and_projection.csv")
|
||||
by_coeff.write_csv(cli.out / "behavior_by_coeff.csv")
|
||||
summary.write_csv(cli.out / "behavior_summary.csv")
|
||||
|
||||
print("\nSHOULD: project_act_block retention distinguishes whether small act_oracle overlap is functionally load-bearing.")
|
||||
print("SHOULD: complement_act_block keeps behavior if the orthogonal residual-write component is load-bearing.")
|
||||
print("ELSE: projection retention near 1 after norm matching means v9 overlap used wrong norm; projection near 0 and complement near 1 means act_oracle PCA is not the steering subspace.")
|
||||
print(summary.select("adapter", "variant", "k", "logratio_at_0", "logratio_at_pos1", "delta_pos_minus_zero", "retention_vs_full", "retention_vs_residual_write").to_pandas().to_string(index=False))
|
||||
print(f"\nwrote: {cli.out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user