Skip to content

Commit 68d7e87

Browse files
polinabinder1clauderoot
authored
evo2 SAE eval: label producers + probing harness (on #1629) (#1636)
## Summary Evo2 SAE **eval: label producers + probing harness** — turn DNA into an `ActivationBuffer` (the one model-touching step) and score it through the probing CLI (AUROC / annotate / linear / domain-F1 / loss-recovered). Lives in the **`evo2_sae.eval.probing`** package, alongside the #1629 primitives. **Stacked on #1629** (→ #1622). #1630 supplies the eval labels. ## Contents — `evo2_sae.eval.probing` - `evo2_buffer.py` — DNA → `ActivationBuffer` (the only model-touching code: Evo2 → layer-L residual → `SAE.encode` + per-token labels) - `labelers.py` — per-token biological labelers (genetic code / CDS frame; prokaryotic gene calling via `pyrodigal`) - `annot_tracks.py` — BED/GFF interval-track loader → per-token masks (RefSeq / Rfam / JASPAR / ENCODE) - `euk_windows.py` — eukaryotic gene-structure windows - `probe.py` — the probing CLI (`extract` / `auroc` / `annotate` / `linear` / `euk-f1` / `domain-eval`) - `probe_loss_recovered.py` — SAE fidelity (loss recovered); reuses the shared `sae.eval.loss_recovered` Imports are package-relative; the primitives come from `evo2_sae.eval.probing`. `loss_recovered` stays in the shared `sae` lib (used by esm2/codonfm too). ## How to run ```bash cd interpretability/sparse_autoencoders/recipes/evo2 bash .ci_build.sh && source .ci_test_env.sh # or: PYTHONPATH=src:../../sae/src pytest tests/test_probe_integration.py tests/test_labelers.py tests/test_annot_tracks.py tests/test_euk_windows.py # CLI: python -m evo2_sae.eval.probing.probe extract|auroc|annotate|linear|domain-eval ... ``` No dedicated CI lane (deferred — see #1622; CI should fold into the repo-wide recipe lane later). ## Tests - **CPU (no model):** label producers (`labelers` / `annot_tracks` / `euk_windows`) + the probe-CLI integration (buffer save/load roundtrip incl. the dense twin, AUROC/annotate/linear over a planted feature, `domain_f1` over interval tracks). **35 passed.** - **GPU:** the real-engine buffer/loss-recovered path is gated by `@pytest.mark.skipif(not torch.cuda.is_available())` — runs on a GPU box, skips otherwise. --------- Signed-off-by: Polina Binder <pbinder@nvidia.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: root <root@nvidia-lepton040.cm.cluster>
1 parent 73c261f commit 68d7e87

13 files changed

Lines changed: 1954 additions & 1 deletion

File tree

interpretability/sparse_autoencoders/recipes/evo2/pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ dependencies = [
1313
"torch>=2.0",
1414
"numpy>=1.20",
1515
"pyarrow>=23.0.0",
16+
"biopython>=1.80", # genetic code / translation in labelers.py
17+
"pyrodigal>=3.0", # prokaryotic gene calling in labelers.predict_cds / predict_codons
1618
]
1719

1820
# The `evo2_sae` package (src/) holds the live inference engine + steering hook;

interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/eval/probing.py renamed to interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/eval/probing/__init__.py

File renamed without changes.
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
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"""Generic interval-track loader for the "user-supplied annotated dataset" eval.
17+
18+
The user hands in an annotated dataset: a FASTA of sequences + one or more annotation
19+
tracks (BED or GFF) naming intervals — RefSeq genes/exons, Rfam ncRNA, JASPAR TFBS,
20+
ENCODE cCREs, etc. Each interval is one annotation **instance**. This module tiles the
21+
sequences into windows and produces, per concept, a per-token boolean mask + per-token
22+
**global** instance IDs (stable across the windows an interval spans) — exactly the
23+
inputs `evo2_sae.eval.probing.domain_f1` (recall-per-instance) and `auroc_all` (per-feature)
24+
consume. No model here; the SAE-encode step lives in the probe CLI (`probe.py domain-eval`).
25+
26+
This is the generic sibling of `euk_windows.py` (which decomposes RefSeq gene models into
27+
exon/intron/cds). Both feed the same shared scorers.
28+
"""
29+
30+
from __future__ import annotations
31+
32+
import gzip
33+
from collections import defaultdict
34+
35+
36+
def _open(path):
37+
"""Open a path for text reading, transparently handling ``.gz``."""
38+
return (gzip.open if str(path).endswith(".gz") else open)(path, "rt")
39+
40+
41+
def read_fasta_dict(path: str) -> dict[str, str]:
42+
"""Read a (multi-record) FASTA into ``{seq_id: sequence}`` (``.gz`` transparent).
43+
44+
``seq_id`` is the first whitespace token of the header — matches the chrom/seqid of BED/GFF.
45+
"""
46+
seqs: dict[str, str] = {}
47+
name, parts = None, []
48+
with _open(path) as fh:
49+
for line in fh:
50+
line = line.rstrip()
51+
if line.startswith(">"):
52+
if name is not None:
53+
seqs[name] = "".join(parts)
54+
tok = line[1:].split()
55+
name, parts = (tok[0] if tok else f"seq_{len(seqs)}"), []
56+
elif line:
57+
parts.append(line)
58+
if name is not None:
59+
seqs[name] = "".join(parts)
60+
return seqs
61+
62+
63+
def _intervals(path, fmt, feature_type=None):
64+
"""Yield (seqid, start0, end0) from BED (0-based) or GFF/GTF (1-based -> 0-based half-open).
65+
66+
GFF rows are optionally filtered to a single column-3 ``feature_type`` (e.g. ``exon``).
67+
"""
68+
chrom_i, start_i, end_i, off = (0, 1, 2, 0) if fmt == "bed" else (0, 3, 4, 1)
69+
with _open(path) as fh:
70+
for line in fh:
71+
if not line.strip() or line[0] == "#" or line.startswith(("track", "browser")):
72+
continue
73+
f = line.split("\t")
74+
if len(f) <= end_i or (feature_type and fmt != "bed" and f[2] != feature_type):
75+
continue
76+
yield f[chrom_i], int(f[start_i]) - off, int(f[end_i])
77+
78+
79+
def load_track(path, feature_type=None, fmt=None):
80+
"""Load one annotation track into ``{seqid: [(start0, end0), ...]}`` (0-based half-open, sorted).
81+
82+
``fmt`` (``bed``/``gff``) is inferred from the extension; ``feature_type`` filters GFF column 3.
83+
Every interval is one annotation instance.
84+
"""
85+
fmt = fmt or ("gff" if str(path).replace(".gz", "").endswith((".gff", ".gff3", ".gtf")) else "bed")
86+
by_seq = defaultdict(list)
87+
for chrom, s, e in _intervals(path, fmt, feature_type):
88+
if e > s:
89+
by_seq[chrom].append((s, e))
90+
return {k: sorted(v) for k, v in by_seq.items()}
91+
92+
93+
def label_windows(seqs, tracks, seq_len=1024, stride=None, max_tokens=None, min_n_frac=0.5):
94+
"""Tile sequences into windows, labeling each position per concept (mask + global instance id).
95+
96+
Args:
97+
seqs: ``{seqid: dna_str}``.
98+
tracks: ``{concept: {seqid: [(start0, end0), ...]}}`` (e.g. from `load_track`).
99+
seq_len: window length in bp.
100+
stride: step between windows (defaults to non-overlapping = seq_len).
101+
max_tokens: stop once this many positions are emitted (None = all).
102+
min_n_frac: skip windows whose ``N`` fraction exceeds this.
103+
104+
Returns:
105+
(windows, stats). Each window is ``{"dna": str, "labels": {concept: bool[L]},
106+
"instances": {concept: int32[L]}}``. Each interval gets one global id, stable across
107+
the windows it spans, so `domain_f1`'s recall-per-instance counts a split interval once.
108+
"""
109+
import numpy as np
110+
111+
stride = stride or seq_len
112+
concepts = list(tracks.keys())
113+
# assign a global instance id to every interval, per concept
114+
concept_iv: dict[str, dict[str, list[tuple[int, int, int]]]] = {}
115+
n_inst: dict[str, int] = {}
116+
for concept in concepts:
117+
gid = 0
118+
cc: dict[str, list[tuple[int, int, int]]] = {}
119+
for seqid, ivs in tracks[concept].items():
120+
cc[seqid] = [(s, e, (gid := gid + 1) - 1) for (s, e) in ivs]
121+
concept_iv[concept] = cc
122+
n_inst[concept] = gid
123+
124+
windows, tot = [], 0
125+
for seqid, dna in seqs.items():
126+
dna = dna.upper()
127+
N = len(dna)
128+
for w0 in range(0, max(1, N - seq_len + 1), stride):
129+
w1 = min(N, w0 + seq_len)
130+
sub = dna[w0:w1]
131+
L = w1 - w0
132+
if L < 60 or sub.count("N") > min_n_frac * L:
133+
continue
134+
labels = {c: np.zeros(L, bool) for c in concepts}
135+
inst = {c: np.full(L, -1, np.int32) for c in concepts}
136+
for c in concepts:
137+
for s, e, gid in concept_iv[c].get(seqid, []):
138+
if e <= w0 or s >= w1:
139+
continue
140+
labels[c][max(s, w0) - w0 : min(e, w1) - w0] = True
141+
inst[c][max(s, w0) - w0 : min(e, w1) - w0] = gid
142+
windows.append({"dna": sub, "labels": labels, "instances": inst})
143+
tot += L
144+
if max_tokens and tot >= max_tokens:
145+
return windows, {"tokens": tot, "n_inst": n_inst, "concepts": concepts}
146+
return windows, {"tokens": tot, "n_inst": n_inst, "concepts": concepts}

0 commit comments

Comments
 (0)