Skip to content

Commit f035535

Browse files
polinabinder1claude
andcommitted
evo2 SAE recipe: streaming extract + train (7B layer-26)
Add the Evo2 SAE recipe under recipes/evo2: a streaming activation extractor (extract.py -- reuses predict_evo2 but writes a parquet ActivationStore directly, no intermediate .pt) and the SAE trainer (train.py), chained by 7b.sh which reproduces the layer-26 7B normalize_input run. train.py wires the merged sae training-quality fixes as opt-in flags (--aggregate-loss / --dead-count-global / --mix-shards / --presample-shards), all defaulting to the previous behavior so a baseline run is reproduced exactly. Assumes a local Evo2 MBridge checkpoint (NGC pull / nemo2 convert is a documented prerequisite, not recipe code). CPU tests cover arg defaults + SAE construction. Supersedes #1579 and #1583. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Polina Binder <pbinder@nvidia.com>
1 parent 8e5a865 commit f035535

6 files changed

Lines changed: 913 additions & 0 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Evo2 SAE recipe
2+
3+
Train a Sparse Autoencoder (SAE) on [Evo2](https://github.com/ArcInstitute/evo2)
4+
residual-stream activations. The pipeline streams layer-L activations out of an Evo2
5+
model into a parquet `ActivationStore`, then trains a TopK SAE on them:
6+
7+
```
8+
chunk FASTA -> stream-extract (extract.py) -> train (train.py)
9+
```
10+
11+
`scripts/7b.sh` runs all three and reproduces our layer-26 7B (`normalize_input`) run.
12+
13+
## Prerequisites
14+
15+
This recipe assumes you already have the model weights and the runtime; it does **not**
16+
download or convert anything.
17+
18+
- An Evo2 **MBridge checkpoint directory**. Obtain it from NGC, e.g.
19+
`ngc registry model download-version "nvidia/clara/evo2:7b_<ver>" --dest <dir>`,
20+
or convert a nemo2 checkpoint with the `evo2_megatron` converter.
21+
- `bionemo-recipes/recipes/evo2_megatron` built (`.ci_build.sh`) with its `.venv` active —
22+
this provides `predict_evo2`, which `extract.py` drives.
23+
- The `sae` workspace package importable in that same venv.
24+
25+
## Quickstart (7B, layer 26)
26+
27+
```bash
28+
CKPT_DIR=<evo2-7b-mbridge-ckpt> FASTA=<prok_euk_sequences.fasta> bash scripts/7b.sh
29+
```
30+
31+
## Steps (manual)
32+
33+
1. **Chunk** sequences to the extraction context:
34+
`python scripts/chunk_fasta.py --input seqs.fasta --output chunked.fasta --window 8192`
35+
2. **Extract** (streaming, no intermediate `.pt`):
36+
`torchrun --nproc_per_node 8 scripts/extract.py --ckpt-dir <ckpt> --embedding-layer 26 \`
37+
`--fasta chunked.fasta --activation-store-dir OUT --max-tokens 1000000000 --dtype fp32`
38+
3. **Train**:
39+
`torchrun --nproc_per_node 8 scripts/train.py --cache-dir OUT --model-path <ckpt> --layer 26 \`
40+
`--expansion-factor 16 --top-k 128 --normalize-input --auxk 2048 --init-pre-bias --dp-size 8 ...`
41+
42+
`train.py` never loads the model — it reads only the activation cache; `--model-path`/`--layer`
43+
are used solely to validate the cache metadata.
44+
45+
## Opt-in training-quality fixes
46+
47+
These come from the `sae` package and **all default to the previous behavior**, so omitting
48+
them reproduces a baseline run exactly. Opt in when you want them (the 7B recipe does):
49+
50+
| flag | effect |
51+
| ---------------------- | --------------------------------------------------------------------------- |
52+
| `--aggregate-loss` | batch-level FVU + AuxK loss instead of the per-token ratio |
53+
| `--dead-count-global` | count dead-latent inactivity in total tokens (× world_size) under DDP |
54+
| `--mix-shards N` | shuffle + blend N shards per batch (replaces the old `--shards-per-buffer`) |
55+
| `--presample-shards N` | spread the pre-bias-init sample across N shards (needs `--init-pre-bias`) |
56+
57+
## Tests
58+
59+
```bash
60+
pytest tests/ # CPU only: arg parsing + SAE construction (no GPU, no model)
61+
```
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
[build-system]
2+
requires = ["setuptools>=61.0"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "evo2-sae"
7+
version = "0.1.0"
8+
description = "Sparse Autoencoders for the Evo2 DNA language model"
9+
readme = "README.md"
10+
requires-python = ">=3.10"
11+
12+
dependencies = [
13+
"sae",
14+
"torch>=2.0",
15+
"numpy>=1.20",
16+
"tqdm>=4.60",
17+
"pyarrow>=10.0",
18+
]
19+
20+
# No package code lives here yet — the recipe is just an entry-point for
21+
# scripts/ that depends on the shared `sae` workspace package. Declare no
22+
# packages so setuptools doesn't try to discover anything.
23+
[tool.setuptools]
24+
packages = []
25+
26+
[tool.uv.sources]
27+
sae = { workspace = true }
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
#!/bin/bash
2+
# Evo2 7B layer-26 SAE recipe: chunk FASTA -> stream-extract activations -> train SAE.
3+
# This reproduces the layer26_7B (normalize_input) run.
4+
#
5+
# Prerequisites (this recipe does NOT download or convert the model):
6+
# - An Evo2 7B *MBridge* checkpoint directory (CKPT_DIR). Obtain it from NGC, e.g.:
7+
# ngc registry model download-version "nvidia/clara/evo2:7b_<ver>" --dest "${WORK_ROOT}/checkpoints"
8+
# (or convert a nemo2 checkpoint to MBridge with the evo2_megatron converter).
9+
# - bionemo-recipes/recipes/evo2_megatron built (.ci_build.sh) with its .venv active,
10+
# providing `predict_evo2`.
11+
# - The `sae` workspace package importable in that same venv.
12+
#
13+
# Override any of these by exporting before invocation.
14+
15+
set -euo pipefail
16+
17+
EVO2_MEGATRON_DIR="${EVO2_MEGATRON_DIR:-/workspace/bionemo-framework/bionemo-recipes/recipes/evo2_megatron}"
18+
RECIPE_DIR="$(cd "$(dirname "$0")/.." && pwd)"
19+
20+
LAYER="${LAYER:-26}"
21+
# Context length the activations were extracted at (the model is context-extended; we
22+
# trained the SAE on 8192-bp chunks).
23+
CHUNK_BP="${CHUNK_BP:-8192}"
24+
25+
# An Evo2 7B MBridge checkpoint directory (see prerequisites above).
26+
CKPT_DIR="${CKPT_DIR:?Set CKPT_DIR to an Evo2 7B MBridge checkpoint directory (see header)}"
27+
FASTA="${FASTA:?Set FASTA to the (prok+euk) input sequences}"
28+
WORK_ROOT="${WORK_ROOT:-/data/interp/evo2}"
29+
30+
NPROC="${NPROC:-8}" # GPUs / DP ranks
31+
MAX_TOKENS="${MAX_TOKENS:-1000000000}"
32+
33+
PARQUET_DIR="${WORK_ROOT}/activations/evo2_7b_layer${LAYER}_parquet"
34+
OUTPUT_DIR="${WORK_ROOT}/sae/evo2_7b_layer${LAYER}"
35+
36+
source "${EVO2_MEGATRON_DIR}/.venv/bin/activate"
37+
38+
echo "============================================================"
39+
echo "STEP 0: Chunk FASTA to <=${CHUNK_BP} bp"
40+
echo "============================================================"
41+
INPUT_STEM="$(basename "$FASTA")"; INPUT_STEM="${INPUT_STEM%.gz}"; INPUT_STEM="${INPUT_STEM%.fasta}"
42+
CHUNKED_FASTA="${WORK_ROOT}/scratch/${INPUT_STEM}_chunked${CHUNK_BP}.fasta"
43+
if [[ -f "$CHUNKED_FASTA" ]]; then
44+
echo "Reusing existing chunked FASTA: $CHUNKED_FASTA"
45+
else
46+
python "${RECIPE_DIR}/scripts/chunk_fasta.py" --input "$FASTA" --output "$CHUNKED_FASTA" --window "$CHUNK_BP"
47+
fi
48+
49+
echo "============================================================"
50+
echo "STEP 1: Stream-extract layer-${LAYER} activations -> parquet ActivationStore (no .pt)"
51+
echo "============================================================"
52+
if [[ -f "${PARQUET_DIR}/metadata.json" ]]; then
53+
echo "Reusing existing parquet shards at $PARQUET_DIR"
54+
else
55+
torchrun --nproc_per_node="$NPROC" "${RECIPE_DIR}/scripts/extract.py" \
56+
--ckpt-dir "$CKPT_DIR" \
57+
--embedding-layer "$LAYER" \
58+
--fasta "$CHUNKED_FASTA" \
59+
--activation-store-dir "$PARQUET_DIR" \
60+
--max-tokens "$MAX_TOKENS" \
61+
--micro-batch-size 4 \
62+
--dtype fp32
63+
fi
64+
65+
echo "============================================================"
66+
echo "STEP 2: Train TopK SAE (layer26_7B normalize_input config)"
67+
echo "============================================================"
68+
# unset a leaked key so ~/.netrc wins; clara-discovery is the wandb entity.
69+
unset WANDB_API_KEY || true
70+
export WANDB_ENTITY="${WANDB_ENTITY:-clara-discovery}"
71+
torchrun --nproc_per_node="$NPROC" "${RECIPE_DIR}/scripts/train.py" \
72+
--cache-dir "$PARQUET_DIR" \
73+
--model-path "$CKPT_DIR" \
74+
--layer "$LAYER" \
75+
--model-type topk \
76+
--expansion-factor 16 --top-k 128 \
77+
--normalize-input \
78+
--auxk 2048 --auxk-coef 0.03125 \
79+
--dead-tokens-threshold 10000000 \
80+
--init-pre-bias \
81+
--n-epochs 1 \
82+
--batch-size 1024 \
83+
--lr 1e-4 --lr-schedule cosine --lr-min 1e-5 --warmup-steps 1000 \
84+
--max-grad-norm 1.0 \
85+
--mix-shards 10 \
86+
--dp-size "$NPROC" \
87+
--log-interval 100 \
88+
--wandb --wandb-project evo2-sae-v2-diverse --wandb-run-name "layer${LAYER}_7B_normalize_input" \
89+
--output-dir "$OUTPUT_DIR" \
90+
--checkpoint-dir "${OUTPUT_DIR}/checkpoints" \
91+
--checkpoint-steps 2000
92+
93+
echo "============================================================"
94+
echo "DONE: SAE checkpoint at ${OUTPUT_DIR}/checkpoints/checkpoint_final.pt"
95+
echo "============================================================"
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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+
"""Chunk a FASTA into <=N-bp windows so predict_evo2 stays inside the model's trained context.
17+
18+
Evo2 1B was trained with seq_length=8192; longer inputs OOM in the Hyena
19+
fftconv path (intermediates scale super-linearly with L). For 7B/40B raise
20+
--window to whatever those checkpoints were context-extended to.
21+
22+
Non-overlapping windows by default. Each chunk gets a header of the form
23+
">{orig_id}:{start}-{end}" so downstream parquet can be back-mapped.
24+
"""
25+
26+
import argparse
27+
import gzip
28+
from pathlib import Path
29+
30+
31+
def parse_fasta(path: Path):
32+
"""Yield (seq_id, sequence) tuples from a FASTA file (transparently handles .gz)."""
33+
opener = gzip.open if path.suffix == ".gz" else open
34+
seq_id, parts = None, []
35+
with opener(path, "rt") as f:
36+
for line in f:
37+
line = line.rstrip()
38+
if line.startswith(">"):
39+
if seq_id is not None:
40+
yield seq_id, "".join(parts)
41+
seq_id = line[1:].split()[0]
42+
parts = []
43+
else:
44+
parts.append(line)
45+
if seq_id is not None:
46+
yield seq_id, "".join(parts)
47+
48+
49+
def main():
50+
"""Read input FASTA, write non-overlapping <=window-bp chunks to output FASTA."""
51+
p = argparse.ArgumentParser()
52+
p.add_argument("--input", type=Path, required=True)
53+
p.add_argument("--output", type=Path, required=True)
54+
p.add_argument("--window", type=int, default=8192)
55+
args = p.parse_args()
56+
57+
n_in = n_out = bp_out = 0
58+
args.output.parent.mkdir(parents=True, exist_ok=True)
59+
with open(args.output, "w") as out:
60+
for seq_id, seq in parse_fasta(args.input):
61+
n_in += 1
62+
for start in range(0, len(seq), args.window):
63+
end = min(start + args.window, len(seq))
64+
chunk = seq[start:end]
65+
out.write(f">{seq_id}:{start}-{end}\n{chunk}\n")
66+
n_out += 1
67+
bp_out += len(chunk)
68+
69+
print(f"Chunked {n_in} sequences -> {n_out} chunks ({bp_out:,} bp) at window={args.window}")
70+
71+
72+
if __name__ == "__main__":
73+
main()

0 commit comments

Comments
 (0)