Skip to content

Commit 474650d

Browse files
polinabinder1claude
andcommitted
evo2 SAE steering analysis: dose-response/selectivity harness, rebased onto migrated #1622
Clamp an SAE feature via the production Evo2SAE.generate path and quantify the causal effect: dose-response (effect vs strength) + selectivity (target vs control features), persisted to a structured steering_results.json. Review fixes: * metric: replace positional Hamming with normalized edit (Levenshtein) distance. Greedy decode is autoregressive, so one early flipped token shifts every downstream base and pins Hamming at ~1.0 — erasing the dose curve. Edit distance is shift-robust; first_divergence (shared-prefix length) is the complementary monotone signal. Tested with the shift case. * surface the clamp cap: generate() silently caps |strength| to MAX_CLAMP_STRENGTH, so two requests above it produce an identical clamp (a fake plateau). run_steering now warns, steers at the effective value, and records max_clamp_strength + capped_strengths. * fix dangling doc reference (probe.py -> extract.py, which exists). * refactor steer.py into injectable pick_target()/run_steering() and add CPU test_steer.py (fake engine, local not in conftest) covering target picking, dose monotonicity, selectivity, and cap reporting. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Polina Binder <pbinder@nvidia.com>
1 parent 59d395e commit 474650d

4 files changed

Lines changed: 464 additions & 0 deletions

File tree

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: LicenseRef-Apache2
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
r"""Evo2 SAE steering harness — clamp a feature and measure the causal effect on generation.
17+
18+
Reuses ``Evo2SAE.generate`` (the production decode-only ``sae.steering`` clamp the server/CLI
19+
use), then quantifies the effect with the pure, CPU-tested metrics in ``steer_analysis``:
20+
**dose-response** (effect vs clamp strength) for a target feature and **selectivity** (target
21+
vs control features). Writes a structured ``steering_results.json`` so the evidence is
22+
persisted and reproducible — the steering analog of how ``extract.py`` persists its outputs.
23+
24+
GPU harness — run on an H100 with the inference engine available; this is not a CPU unit test.
25+
26+
python steer.py --evo2-ckpt-dir <mbridge> --sae-checkpoint <sae.pt> --layer 26 \
27+
--sequence ATGGCC... --feature 29244 --controls 12345,54321 --strengths 0,50,100,200 \
28+
--out steering_results.json
29+
"""
30+
31+
from __future__ import annotations
32+
33+
import argparse
34+
import json
35+
import sys
36+
from pathlib import Path
37+
38+
39+
_HERE = Path(__file__).resolve().parent
40+
sys.path.insert(0, str(_HERE))
41+
sys.path.insert(0, str(_HERE.parent / "src")) # recipes/evo2/src -> evo2_sae package
42+
sys.path.insert(0, str(_HERE.parents[2] / "sae" / "src"))
43+
44+
from steer_analysis import dose_response, selectivity # noqa: E402
45+
46+
47+
def pick_target(eng, sequence: str, feature: int | None) -> tuple[int, list[tuple[int, str | None, float]]]:
48+
"""Return ``(target_feature, top_rows)``: the steered feature + the printable top-k table.
49+
50+
Honors an explicit ``feature``; otherwise picks the top-active *labeled* feature, falling
51+
back to the single most-active feature when none of the top-k carry a label.
52+
"""
53+
codes = eng.encode(sequence)
54+
vals, ids = codes.max(0).values.topk(min(10, codes.shape[1]))
55+
rows = [(int(i), eng.labels.get(int(i)), float(v)) for v, i in zip(vals.tolist(), ids.tolist())]
56+
target = feature
57+
if target is None:
58+
target = next((fid for fid, lab, _ in rows if lab), None)
59+
if target is None: # no --feature and no labeled feature in the top-k: steer the most-active one
60+
target = int(ids[0].item())
61+
return target, rows
62+
63+
64+
def run_steering(eng, sequence, organism, target, controls, strengths, n_tokens, max_clamp):
65+
"""Drive the (real or fake) engine to build the steering result dict — no argparse, no I/O.
66+
67+
Generation reuses the production path: ``Evo2SAE.generate`` clamps the same decode-only
68+
``sae.steering`` hook the server/CLI use, so the harness measures the real thing. Engine is
69+
injected so this is unit-testable on CPU with a stub.
70+
71+
``max_clamp`` is the engine's ``MAX_CLAMP_STRENGTH``: requested strengths beyond it are
72+
*silently capped inside* ``generate``, which would make two requested strengths produce an
73+
identical clamp (a fake "plateau"). We surface that here — clamp to the effective strength
74+
used for generation, warn, and record both the cap and which requests were capped.
75+
"""
76+
77+
def gen(clamps): # clamps already effective (within +/- max_clamp)
78+
feats = [{"feature_id": f, "strength": v} for f, v in clamps.items()]
79+
out = eng.generate(
80+
prompt=sequence, organism=organism, features=feats, n_tokens=n_tokens, temperature=0.0, top_k=1
81+
)
82+
return out["generation"]["sequence"]
83+
84+
def effective(s: float) -> float:
85+
return max(-max_clamp, min(max_clamp, s))
86+
87+
capped = sorted({s for s in strengths if effective(s) != s})
88+
if capped:
89+
print(
90+
f" WARNING: strength(s) {capped} exceed MAX_CLAMP_STRENGTH={max_clamp}; capped before steering "
91+
"(equal-after-cap requests will look like a plateau)."
92+
)
93+
94+
base = gen({})
95+
# Dose-response: key rows by the *requested* strength (so the sweep reads as asked), but steer
96+
# with the effective (capped) value so the result matches what the engine actually applies.
97+
steered_by_strength = {s: gen({target: effective(s)}) for s in strengths}
98+
dose = dose_response(base, steered_by_strength)
99+
100+
sel = None
101+
if controls:
102+
s = strengths[-1]
103+
control_steered = {c: gen({c: effective(s)}) for c in controls}
104+
sel = selectivity(base, steered_by_strength[s], control_steered)
105+
106+
return {
107+
"target_feature": target,
108+
"sequence": sequence[:80],
109+
"organism": organism,
110+
"baseline": base,
111+
"max_clamp_strength": max_clamp,
112+
"capped_strengths": capped,
113+
"dose_response": dose,
114+
"selectivity": sel,
115+
}
116+
117+
118+
def main():
119+
"""Encode a sequence, then steer a target feature (dose-response) + control features (selectivity)."""
120+
p = argparse.ArgumentParser(description="Evo2 SAE steering harness (clamp -> continuation effect).")
121+
p.add_argument("--evo2-ckpt-dir", required=True)
122+
p.add_argument("--sae-checkpoint", required=True)
123+
p.add_argument("--layer", type=int, required=True)
124+
p.add_argument("--sequence", required=True)
125+
p.add_argument("--organism", default="None (raw DNA)")
126+
p.add_argument("--feature", type=int, default=None, help="Target feature id (default: top labeled feature).")
127+
p.add_argument("--controls", default="", help="Comma-separated control feature ids (selectivity).")
128+
p.add_argument("--strengths", default="0,50,100,200", help="Comma-separated clamp strengths to sweep.")
129+
p.add_argument("--n-tokens", type=int, default=60)
130+
p.add_argument("--device", default="cuda")
131+
p.add_argument("--out", default=None, help="write the structured steering_results JSON here")
132+
a = p.parse_args()
133+
134+
from evo2_sae.core import MAX_CLAMP_STRENGTH, Evo2SAE # noqa: E402, RUF100
135+
136+
try:
137+
controls = [int(c) for c in a.controls.split(",") if c.strip()]
138+
strengths = [float(s) for s in a.strengths.split(",")]
139+
except ValueError as e:
140+
raise SystemExit(f"bad --controls/--strengths (need comma-separated ints/floats): {e}")
141+
if not strengths:
142+
raise SystemExit("--strengths must list at least one clamp strength")
143+
144+
eng = Evo2SAE(a.evo2_ckpt_dir, a.sae_checkpoint, a.layer, device=a.device).load()
145+
146+
# 1. Encode -> the sequence's most-active features (pick a target if not given).
147+
target, rows = pick_target(eng, a.sequence, a.feature)
148+
print(f"top features on {a.sequence[:24]}...:")
149+
for fid, lab, v in rows:
150+
print(f" feat {fid:6d} {str(lab):18s} max_act {v:7.2f}")
151+
if a.feature is None and not any(lab for _, lab, _ in rows):
152+
print(f" (no labeled feature; defaulting target to top-active {target})")
153+
154+
# 2-4. Run the sweep (reuses the production generate path; pure metrics score it).
155+
result = run_steering(
156+
eng, a.sequence, a.organism, target, controls, strengths, a.n_tokens, MAX_CLAMP_STRENGTH
157+
)
158+
159+
print(f"\nbaseline: {result['baseline'][:60]}")
160+
print(f"\n=== dose-response: feature {target} ({eng.labels.get(target)}) ===")
161+
for r in result["dose_response"]:
162+
print(
163+
f" strength {r['strength']:7.1f}: prefix@{r['first_divergence']:3d} {r['frac_changed']:6.1%} rewritten"
164+
)
165+
sel = result["selectivity"]
166+
if sel is not None:
167+
print(f"\n=== selectivity @ strength {strengths[-1]} (target/control ratio {sel['selectivity_ratio']}) ===")
168+
print(f" target {target:6d}: {sel['target_frac_changed']:6.1%} rewritten")
169+
for c, frac in sel["control_frac_changed"].items():
170+
print(f" control {int(c):6d}: {frac:6.1%} rewritten ({eng.labels.get(int(c))})")
171+
172+
if a.out:
173+
Path(a.out).write_text(json.dumps(result, indent=2))
174+
print(f"\nwrote steering results -> {a.out}")
175+
176+
177+
if __name__ == "__main__":
178+
main()
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: LicenseRef-Apache2
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""Pure metrics for quantifying SAE feature-steering effects (no model — CPU-testable).
17+
18+
Given a baseline generation and steered generations (produced by ``steer.py`` via
19+
``Evo2SAE.generate``), quantify the causal effect of a feature clamp:
20+
21+
- divergence: how far a steered continuation departs from the baseline
22+
- dose_response: how that effect scales with clamp strength
23+
- selectivity: target vs control features at one strength (is the effect feature-specific?)
24+
25+
**Why edit distance, not positional Hamming.** ``steer.py`` decodes greedily (``temperature=0``),
26+
so generation is deterministic and autoregressive: the first token a clamp flips shifts every
27+
downstream token, which would pin a position-by-position mismatch fraction at ~1.0 and erase any
28+
dose curve. We therefore measure effect magnitude with a *normalized edit (Levenshtein) distance*,
29+
which does not saturate from a single early shift, and report ``first_divergence`` (the length of
30+
the shared prefix — how many leading bases survive the clamp; smaller = the effect bites earlier)
31+
as the complementary, monotone-friendly signal.
32+
33+
These are pure functions of the output strings, so the steering analysis is reproducible and
34+
unit-tested without a GPU; ``steer.py`` runs the model and calls them to build the persisted
35+
result.
36+
"""
37+
38+
from __future__ import annotations
39+
40+
41+
def common_prefix_len(a: str, b: str) -> int:
42+
"""Number of leading characters ``a`` and ``b`` share (the shared-prefix length)."""
43+
n = min(len(a), len(b))
44+
i = 0
45+
while i < n and a[i] == b[i]:
46+
i += 1
47+
return i
48+
49+
50+
def edit_distance(a: str, b: str) -> int:
51+
"""Levenshtein edit distance between two strings (insert/delete/substitute = cost 1)."""
52+
if a == b:
53+
return 0
54+
if not a:
55+
return len(b)
56+
if not b:
57+
return len(a)
58+
prev = list(range(len(b) + 1))
59+
for i, ca in enumerate(a, 1):
60+
cur = [i]
61+
for j, cb in enumerate(b, 1):
62+
cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
63+
prev = cur
64+
return prev[-1]
65+
66+
67+
def divergence(a: str, b: str) -> tuple[int, float]:
68+
"""Return ``(shared-prefix length, normalized edit distance)``.
69+
70+
The first element is how many leading characters survive unchanged (``len`` when identical);
71+
the second is the edit distance normalized by the longer string's length, in ``[0, 1]`` — an
72+
insertion-robust measure of how much of the continuation the clamp rewrote.
73+
"""
74+
first = common_prefix_len(a, b)
75+
n = max(len(a), len(b))
76+
frac = edit_distance(a, b) / n if n else 0.0
77+
return first, frac
78+
79+
80+
def dose_response(baseline: str, steered_by_strength: dict[float, str]) -> list[dict]:
81+
"""Per clamp strength, the divergence from baseline — rows sorted by ascending strength.
82+
83+
``frac_changed`` (normalized edit distance) rising and ``first_divergence`` (shared-prefix
84+
length) shrinking as strength grows is the signature of a feature that genuinely steers
85+
generation (a stronger clamp rewrites more, and bites earlier).
86+
"""
87+
rows = []
88+
for s in sorted(steered_by_strength):
89+
first, frac = divergence(baseline, steered_by_strength[s])
90+
rows.append({"strength": float(s), "first_divergence": int(first), "frac_changed": round(frac, 4)})
91+
return rows
92+
93+
94+
def selectivity(baseline: str, target_steered: str, control_steered: dict[int, str]) -> dict:
95+
"""Target effect vs control features clamped to the same strength.
96+
97+
Effect magnitude is the normalized edit distance from baseline (see module docstring).
98+
``selectivity_ratio`` > 1 means the target feature rewrites generation more than the average
99+
control — evidence the steering is feature-specific, not a generic "any clamp perturbs output".
100+
"""
101+
target = divergence(baseline, target_steered)[1]
102+
controls = {int(c): round(divergence(baseline, seq)[1], 4) for c, seq in control_steered.items()}
103+
mean_c = sum(controls.values()) / len(controls) if controls else None
104+
return {
105+
"target_frac_changed": round(target, 4),
106+
"control_frac_changed": controls,
107+
"mean_control_frac_changed": round(mean_c, 4) if mean_c is not None else None,
108+
# None when there are no controls (ratio undefined) or controls produced zero change
109+
"selectivity_ratio": round(target / mean_c, 2) if mean_c else None,
110+
}

0 commit comments

Comments
 (0)