Skip to content

Commit 0189d98

Browse files
polinabinder1claude
andcommitted
evo2 SAE steering analysis: dose-response/selectivity harness, rebased onto migrated #1622
Re-lands #1635 on the post-#1633 layout, on top of migrated #1622: the steering-eval harness (scripts/{steer,steer_analysis}.py) over #1622's generate(), with model-agnostic metrics. Validated: tests/test_steer_analysis.py -> 3 passed (CPU); harness scripts compile. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Polina Binder <pbinder@nvidia.com>
1 parent 924e4e0 commit 0189d98

3 files changed

Lines changed: 255 additions & 0 deletions

File tree

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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 ``probe.py annotate``.
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 main():
48+
"""Encode a sequence, then steer a target feature (dose-response) + control features (selectivity)."""
49+
p = argparse.ArgumentParser(description="Evo2 SAE steering harness (clamp -> continuation effect).")
50+
p.add_argument("--evo2-ckpt-dir", required=True)
51+
p.add_argument("--sae-checkpoint", required=True)
52+
p.add_argument("--layer", type=int, required=True)
53+
p.add_argument("--sequence", required=True)
54+
p.add_argument("--organism", default="None (raw DNA)")
55+
p.add_argument("--feature", type=int, default=None, help="Target feature id (default: top labeled feature).")
56+
p.add_argument("--controls", default="", help="Comma-separated control feature ids (selectivity).")
57+
p.add_argument("--strengths", default="0,50,100,200", help="Comma-separated clamp strengths to sweep.")
58+
p.add_argument("--n-tokens", type=int, default=60)
59+
p.add_argument("--device", default="cuda")
60+
p.add_argument("--out", default=None, help="write the structured steering_results JSON here")
61+
a = p.parse_args()
62+
63+
from evo2_sae.core import Evo2SAE # noqa: E402, RUF100
64+
65+
eng = Evo2SAE(a.evo2_ckpt_dir, a.sae_checkpoint, a.layer, device=a.device).load()
66+
67+
# 1. Encode -> the sequence's most-active features (pick a target if not given).
68+
codes = eng.encode(a.sequence)
69+
vals, ids = codes.max(0).values.topk(10)
70+
print(f"top features on {a.sequence[:24]}...:")
71+
target = a.feature
72+
for v, i in zip(vals.tolist(), ids.tolist()):
73+
lab = eng.labels.get(int(i))
74+
print(f" feat {int(i):6d} {str(lab):18s} max_act {v:7.2f}")
75+
if target is None and lab:
76+
target = int(i)
77+
if target is None: # no --feature and no labeled feature in the top-k: steer the most-active one
78+
target = int(ids[0].item())
79+
print(f" (no labeled feature; defaulting target to top-active {target})")
80+
try:
81+
controls = [int(c) for c in a.controls.split(",") if c.strip()]
82+
strengths = [float(s) for s in a.strengths.split(",")]
83+
except ValueError as e:
84+
raise SystemExit(f"bad --controls/--strengths (need comma-separated ints/floats): {e}")
85+
86+
# 2. Steered generation reuses the production path: Evo2SAE.generate clamps the same
87+
# decode-only sae.steering hook the server/CLI use, so the harness measures the real thing.
88+
def gen(clamps):
89+
feats = [{"feature_id": f, "strength": v} for f, v in clamps.items()]
90+
out = eng.generate(
91+
prompt=a.sequence, organism=a.organism, features=feats, n_tokens=a.n_tokens, temperature=0.0, top_k=1
92+
)
93+
return out["generation"]["sequence"]
94+
95+
base = gen({})
96+
print(f"\nbaseline: {base[:60]}")
97+
98+
# 3. Dose-response for the target feature, scored by steer_analysis.
99+
steered_by_strength = {s: gen({target: s}) for s in strengths}
100+
dose = dose_response(base, steered_by_strength)
101+
print(f"\n=== dose-response: feature {target} ({eng.labels.get(target)}) ===")
102+
for r in dose:
103+
print(
104+
f" strength {r['strength']:7.1f}: diverges@{r['first_divergence']:3d} {r['frac_changed']:6.1%} changed"
105+
)
106+
107+
# 4. Selectivity: target vs each control feature at the strongest clamp.
108+
sel = None
109+
if controls:
110+
s = strengths[-1]
111+
control_steered = {c: gen({c: s}) for c in controls}
112+
sel = selectivity(base, steered_by_strength[s], control_steered)
113+
print(f"\n=== selectivity @ strength {s} (target/control ratio {sel['selectivity_ratio']}) ===")
114+
print(f" target {target:6d}: {sel['target_frac_changed']:6.1%} changed")
115+
for c, frac in sel["control_frac_changed"].items():
116+
print(f" control {c:6d}: {frac:6.1%} changed ({eng.labels.get(c)})")
117+
118+
if a.out:
119+
Path(a.out).write_text(
120+
json.dumps(
121+
{
122+
"target_feature": target,
123+
"sequence": a.sequence[:80],
124+
"organism": a.organism,
125+
"baseline": base,
126+
"dose_response": dose,
127+
"selectivity": sel,
128+
},
129+
indent=2,
130+
)
131+
)
132+
print(f"\nwrote steering results -> {a.out}")
133+
134+
135+
if __name__ == "__main__":
136+
main()
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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+
These are pure functions of the output strings, so the steering analysis is reproducible and
26+
unit-tested without a GPU; ``steer.py`` runs the model and calls them to build the persisted
27+
result.
28+
"""
29+
30+
from __future__ import annotations
31+
32+
33+
def divergence(a: str, b: str) -> tuple[int, float]:
34+
"""Return (first differing index, fraction of differing chars) over the shared prefix length."""
35+
n = min(len(a), len(b))
36+
first = next((i for i in range(n) if a[i] != b[i]), n)
37+
diff = sum(1 for i in range(n) if a[i] != b[i]) / max(1, n)
38+
return first, diff
39+
40+
41+
def dose_response(baseline: str, steered_by_strength: dict[float, str]) -> list[dict]:
42+
"""Per clamp strength, the divergence from baseline — rows sorted by ascending strength.
43+
44+
A monotonically rising ``frac_changed`` is the signature of a feature that genuinely steers
45+
generation (stronger clamp -> larger effect).
46+
"""
47+
rows = []
48+
for s in sorted(steered_by_strength):
49+
first, frac = divergence(baseline, steered_by_strength[s])
50+
rows.append({"strength": float(s), "first_divergence": int(first), "frac_changed": round(frac, 4)})
51+
return rows
52+
53+
54+
def selectivity(baseline: str, target_steered: str, control_steered: dict[int, str]) -> dict:
55+
"""Target effect vs control features clamped to the same strength.
56+
57+
``selectivity_ratio`` > 1 means the target feature moves generation more than the average
58+
control — evidence the steering is feature-specific, not a generic "any clamp perturbs output".
59+
"""
60+
target = divergence(baseline, target_steered)[1]
61+
controls = {int(c): round(divergence(baseline, seq)[1], 4) for c, seq in control_steered.items()}
62+
mean_c = sum(controls.values()) / len(controls) if controls else None
63+
return {
64+
"target_frac_changed": round(target, 4),
65+
"control_frac_changed": controls,
66+
"mean_control_frac_changed": round(mean_c, 4) if mean_c is not None else None,
67+
# None when there are no controls (ratio undefined) or controls produced zero change
68+
"selectivity_ratio": round(target / mean_c, 2) if mean_c else None,
69+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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+
"""CPU tests for the steering-effect metrics (pure string math, no model)."""
17+
18+
import sys
19+
from pathlib import Path
20+
21+
22+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
23+
24+
from steer_analysis import divergence, dose_response, selectivity
25+
26+
27+
def test_divergence_identical_and_single_change():
28+
"""Identical strings -> no divergence; one substitution -> first index + fraction."""
29+
assert divergence("ACGTACGT", "ACGTACGT") == (8, 0.0)
30+
first, frac = divergence("ACGTACGT", "ACGAACGT") # differs only at index 3
31+
assert first == 3 and abs(frac - 0.125) < 1e-9
32+
33+
34+
def test_dose_response_sorted_and_rises_with_strength():
35+
"""Rows come back ascending in strength; a stronger clamp changes more of the continuation."""
36+
base = "AAAAAAAA"
37+
steered = {0.0: "AAAAAAAA", 100.0: "AAAACCCC", 50.0: "AAAAAACC"} # given out of order
38+
rows = dose_response(base, steered)
39+
assert [r["strength"] for r in rows] == [0.0, 50.0, 100.0]
40+
assert rows[0]["frac_changed"] == 0.0
41+
assert rows[1]["frac_changed"] < rows[2]["frac_changed"]
42+
43+
44+
def test_selectivity_ratio_high_when_target_specific():
45+
"""Target clamp moves generation, controls barely do -> ratio >> 1 (feature-specific)."""
46+
base = "AAAAAAAA"
47+
sel = selectivity(base, "CCCCCCCC", {1: "AAAAAAAA", 2: "AAAAAAAC"})
48+
assert sel["target_frac_changed"] == 1.0
49+
assert sel["mean_control_frac_changed"] < 0.1
50+
assert sel["selectivity_ratio"] > 5

0 commit comments

Comments
 (0)