antigen-model 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- antiGen/__init__.py +1 -0
- antiGen/baselines/__init__.py +0 -0
- antiGen/baselines/covfit.py +259 -0
- antiGen/baselines/msa_transformer.py +428 -0
- antiGen/config.py +607 -0
- antiGen/configs/esm2_default.yaml +21 -0
- antiGen/configs/esm2_mutation_fitness.yaml +22 -0
- antiGen/configs/transformer_default.yaml +24 -0
- antiGen/datasets.py +86 -0
- antiGen/eval.py +760 -0
- antiGen/eval_utils.py +404 -0
- antiGen/infer.py +168 -0
- antiGen/load.py +175 -0
- antiGen/models/__init__.py +12 -0
- antiGen/models/base.py +84 -0
- antiGen/models/blosum62_fitness_predictor.py +78 -0
- antiGen/models/esm2_fitness_predictor.py +223 -0
- antiGen/models/evcouplings_fitness_predictor.py +310 -0
- antiGen/models/eve_fitness_predictor.py +439 -0
- antiGen/models/factory.py +230 -0
- antiGen/models/fitness_predictor.py +89 -0
- antiGen/models/gated_fitness_predictor.py +166 -0
- antiGen/models/pssm_fitness_predictor.py +69 -0
- antiGen/models/tranception_fitness_predictor.py +553 -0
- antiGen/train.py +561 -0
- antiGen/validate_examples.py +67 -0
- antigen_model-0.1.0.dist-info/METADATA +264 -0
- antigen_model-0.1.0.dist-info/RECORD +32 -0
- antigen_model-0.1.0.dist-info/WHEEL +5 -0
- antigen_model-0.1.0.dist-info/entry_points.txt +5 -0
- antigen_model-0.1.0.dist-info/licenses/LICENSE +21 -0
- antigen_model-0.1.0.dist-info/top_level.txt +1 -0
antiGen/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""antiGen: viral fitness prediction — train models and score substitutions."""
|
|
File without changes
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""
|
|
3
|
+
Generate all single-mutant spike sequences from the BA.2 testing root
|
|
4
|
+
and score them with CoVFit CLI to produce a baseline fitness matrix.
|
|
5
|
+
|
|
6
|
+
Steps:
|
|
7
|
+
1. Extract the BA.2 testing root sequence from the examples file.
|
|
8
|
+
2. Generate all single-amino-acid mutant sequences (keeping gaps intact).
|
|
9
|
+
3. Write them as a FASTA file and run CoVFit CLI.
|
|
10
|
+
4. Parse CoVFit output into a (seq_len, vocab_size) baseline CSV.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
python python/baselines/covfit.py \
|
|
14
|
+
--examples /path/to/examples_cutoff_1.txt \
|
|
15
|
+
--covfit-cli /path/to/covfit_cli \
|
|
16
|
+
--output ./data/baselines/CoVFit.csv \
|
|
17
|
+
--variant BA.2 \
|
|
18
|
+
[--fold 0] [--batch 16] [--gpu]
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import csv
|
|
23
|
+
import os
|
|
24
|
+
import subprocess
|
|
25
|
+
import tempfile
|
|
26
|
+
|
|
27
|
+
ALLOWED_AAS = "ACDEFGHIKLMNPQRSTVWY*-"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def extract_testing_root(examples_path, variant):
|
|
31
|
+
"""Extract the aligned sequence of the testing root for a given variant."""
|
|
32
|
+
with open(examples_path) as f:
|
|
33
|
+
lines = f.readlines()
|
|
34
|
+
|
|
35
|
+
i = 0
|
|
36
|
+
while i < len(lines):
|
|
37
|
+
line = lines[i].strip()
|
|
38
|
+
if line == "IS_VARIANT_ROOT:":
|
|
39
|
+
i += 1
|
|
40
|
+
is_root = False
|
|
41
|
+
while i < len(lines) and lines[i].strip().endswith(("True", "False")):
|
|
42
|
+
label, val = lines[i].strip().rsplit(",", 1)
|
|
43
|
+
if (label == variant or f"({variant})" in label) and val == "True":
|
|
44
|
+
is_root = True
|
|
45
|
+
i += 1
|
|
46
|
+
if is_root:
|
|
47
|
+
seq = lines[i].strip()
|
|
48
|
+
return seq
|
|
49
|
+
else:
|
|
50
|
+
i += 1
|
|
51
|
+
|
|
52
|
+
raise ValueError(f"No testing root found for variant '{variant}'")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def generate_mutants_fasta(seq, fasta_path):
|
|
56
|
+
"""Write wildtype + all single-mutant sequences to a FASTA file.
|
|
57
|
+
|
|
58
|
+
Every position is mutated to all 21 other characters in ALLOWED_AAS.
|
|
59
|
+
Sequence naming: WT for wildtype, {pos}_{wt}{alt} for mutants (0-indexed).
|
|
60
|
+
Returns the number of sequences written.
|
|
61
|
+
"""
|
|
62
|
+
n_written = 0
|
|
63
|
+
with open(fasta_path, "w") as f:
|
|
64
|
+
f.write(f">WT\n{seq}\n")
|
|
65
|
+
n_written += 1
|
|
66
|
+
|
|
67
|
+
for pos, wt_aa in enumerate(seq):
|
|
68
|
+
for alt_aa in ALLOWED_AAS:
|
|
69
|
+
if alt_aa == wt_aa:
|
|
70
|
+
continue
|
|
71
|
+
mutant_seq = seq[:pos] + alt_aa + seq[pos + 1:]
|
|
72
|
+
f.write(f">{pos}_{wt_aa}{alt_aa}\n{mutant_seq}\n")
|
|
73
|
+
n_written += 1
|
|
74
|
+
|
|
75
|
+
return n_written
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def run_covfit(covfit_cli, fasta_path, output_dir, fold=0, batch=16, gpu=False):
|
|
79
|
+
"""Run CoVFit CLI on a FASTA file."""
|
|
80
|
+
cmd = [
|
|
81
|
+
covfit_cli,
|
|
82
|
+
"-i", fasta_path,
|
|
83
|
+
"-o", output_dir,
|
|
84
|
+
"-f", str(fold),
|
|
85
|
+
"-b", str(batch),
|
|
86
|
+
]
|
|
87
|
+
if gpu:
|
|
88
|
+
cmd.append("-g")
|
|
89
|
+
|
|
90
|
+
print(f"Running: {' '.join(cmd)}")
|
|
91
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
92
|
+
if result.returncode != 0:
|
|
93
|
+
print(f"STDOUT:\n{result.stdout}")
|
|
94
|
+
print(f"STDERR:\n{result.stderr}")
|
|
95
|
+
raise RuntimeError(f"CoVFit exited with code {result.returncode}")
|
|
96
|
+
print(result.stdout)
|
|
97
|
+
|
|
98
|
+
output_file = os.path.join(output_dir, f"CoVFit_Predictions_fold_{fold}.tsv")
|
|
99
|
+
if not os.path.exists(output_file):
|
|
100
|
+
raise FileNotFoundError(f"Expected output not found: {output_file}")
|
|
101
|
+
return output_file
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parse_covfit_output(tsv_path, fasta_path):
|
|
105
|
+
"""Parse CoVFit TSV output. Returns dict of sequence_name -> mean_fitness.
|
|
106
|
+
|
|
107
|
+
CoVFit output may or may not include sequence names. If the first column
|
|
108
|
+
is non-numeric, treat it as a sequence name; otherwise assume rows are in
|
|
109
|
+
the same order as the input FASTA.
|
|
110
|
+
"""
|
|
111
|
+
# Read FASTA names to use as fallback ordering
|
|
112
|
+
fasta_names = []
|
|
113
|
+
with open(fasta_path) as f:
|
|
114
|
+
for line in f:
|
|
115
|
+
if line.startswith(">"):
|
|
116
|
+
fasta_names.append(line.strip()[1:])
|
|
117
|
+
|
|
118
|
+
scores = {}
|
|
119
|
+
with open(tsv_path) as f:
|
|
120
|
+
reader = csv.reader(f, delimiter="\t")
|
|
121
|
+
header = next(reader)
|
|
122
|
+
|
|
123
|
+
print(f"CoVFit output columns ({len(header)}): {header[:5]}...")
|
|
124
|
+
|
|
125
|
+
# Detect whether first column is a name or numeric
|
|
126
|
+
rows = list(reader)
|
|
127
|
+
if not rows:
|
|
128
|
+
raise ValueError("CoVFit output is empty")
|
|
129
|
+
|
|
130
|
+
first_val = rows[0][0]
|
|
131
|
+
try:
|
|
132
|
+
float(first_val)
|
|
133
|
+
has_name_col = False
|
|
134
|
+
except ValueError:
|
|
135
|
+
has_name_col = True
|
|
136
|
+
|
|
137
|
+
for row_idx, row in enumerate(rows):
|
|
138
|
+
if has_name_col:
|
|
139
|
+
name = row[0]
|
|
140
|
+
fitness = float(row[1]) # mean fitness is second column
|
|
141
|
+
else:
|
|
142
|
+
name = fasta_names[row_idx] if row_idx < len(fasta_names) else str(row_idx)
|
|
143
|
+
fitness = float(row[0]) # mean fitness is first column
|
|
144
|
+
scores[name] = fitness
|
|
145
|
+
|
|
146
|
+
return scores
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def build_baseline_matrix(scores, seq):
|
|
150
|
+
"""Build (seq_len, vocab_size) matrix from CoVFit scores.
|
|
151
|
+
|
|
152
|
+
Values are delta fitness (mutant - wildtype). Positions beyond CoVFit's
|
|
153
|
+
1022-residue limit (ESM2 max_length=1024 minus CLS/EOS) are marked as
|
|
154
|
+
-1 sentinel since the model cannot distinguish mutations there.
|
|
155
|
+
"""
|
|
156
|
+
wt_fitness = scores["WT"]
|
|
157
|
+
print(f"Wildtype fitness: {wt_fitness:.6f}")
|
|
158
|
+
|
|
159
|
+
# CoVFit truncates at 1024 tokens = 1022 residues + CLS + EOS
|
|
160
|
+
MAX_RESIDUES = 1022
|
|
161
|
+
|
|
162
|
+
seq_len = len(seq)
|
|
163
|
+
vocab_size = len(ALLOWED_AAS)
|
|
164
|
+
matrix = [[-1.0] * vocab_size for _ in range(seq_len)]
|
|
165
|
+
|
|
166
|
+
n_truncated = 0
|
|
167
|
+
for pos, wt_aa in enumerate(seq):
|
|
168
|
+
if pos >= MAX_RESIDUES:
|
|
169
|
+
n_truncated += 1
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
wt_col = ALLOWED_AAS.index(wt_aa)
|
|
173
|
+
matrix[pos][wt_col] = 0.0
|
|
174
|
+
|
|
175
|
+
for alt_aa in ALLOWED_AAS:
|
|
176
|
+
if alt_aa == wt_aa:
|
|
177
|
+
continue
|
|
178
|
+
key = f"{pos}_{wt_aa}{alt_aa}"
|
|
179
|
+
if key in scores:
|
|
180
|
+
alt_col = ALLOWED_AAS.index(alt_aa)
|
|
181
|
+
matrix[pos][alt_col] = scores[key] - wt_fitness
|
|
182
|
+
|
|
183
|
+
if n_truncated > 0:
|
|
184
|
+
print(f" {n_truncated} positions beyond CoVFit's {MAX_RESIDUES}-residue limit marked as -1")
|
|
185
|
+
|
|
186
|
+
# Shift all valid scores so the minimum is 0, keeping -1 sentinels intact
|
|
187
|
+
valid_vals = [v for row in matrix for v in row if v != -1.0]
|
|
188
|
+
min_val = min(valid_vals)
|
|
189
|
+
if min_val < 0:
|
|
190
|
+
for row in matrix:
|
|
191
|
+
for j in range(len(row)):
|
|
192
|
+
if row[j] != -1.0:
|
|
193
|
+
row[j] -= min_val
|
|
194
|
+
print(f" Shifted scores by {-min_val:.8f} so all valid values are >= 0")
|
|
195
|
+
|
|
196
|
+
return matrix
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def write_baseline_csv(matrix, output_path):
|
|
200
|
+
"""Write baseline matrix as CSV with amino acid header."""
|
|
201
|
+
with open(output_path, "w", newline="") as f:
|
|
202
|
+
writer = csv.writer(f)
|
|
203
|
+
writer.writerow(list(ALLOWED_AAS))
|
|
204
|
+
for row in matrix:
|
|
205
|
+
writer.writerow([f"{v:.8f}" for v in row])
|
|
206
|
+
print(f"Baseline written to {output_path} ({len(matrix)} x {len(ALLOWED_AAS)})")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def main():
|
|
210
|
+
parser = argparse.ArgumentParser(description="Score all single mutants with CoVFit")
|
|
211
|
+
parser.add_argument("--examples", required=True, help="Path to examples_cutoff_*.txt")
|
|
212
|
+
parser.add_argument("--covfit-cli", required=True, help="Path to covfit_cli executable")
|
|
213
|
+
parser.add_argument("--output", required=True, help="Output baseline CSV path")
|
|
214
|
+
parser.add_argument("--variant", default="BA.2", help="Variant name for testing root")
|
|
215
|
+
parser.add_argument("--fold", type=int, default=0, help="CoVFit model fold (0-4)")
|
|
216
|
+
parser.add_argument("--batch", type=int, default=16, help="CoVFit batch size")
|
|
217
|
+
parser.add_argument("--gpu", action="store_true", help="Use GPU for CoVFit")
|
|
218
|
+
parser.add_argument("--fasta-out", default=None,
|
|
219
|
+
help="Save generated FASTA to this path (optional, for debugging)")
|
|
220
|
+
args = parser.parse_args()
|
|
221
|
+
|
|
222
|
+
# Step 1: Extract testing root
|
|
223
|
+
print(f"Extracting testing root for variant '{args.variant}'...")
|
|
224
|
+
seq = extract_testing_root(args.examples, args.variant)
|
|
225
|
+
seq_len = len(seq)
|
|
226
|
+
print(f"Sequence length: {seq_len}")
|
|
227
|
+
|
|
228
|
+
# Step 2: Generate all single-mutant sequences (gaps kept intact)
|
|
229
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
230
|
+
fasta_path = args.fasta_out or os.path.join(tmpdir, "mutants.fasta")
|
|
231
|
+
n_seqs = generate_mutants_fasta(seq, fasta_path)
|
|
232
|
+
print(f"Generated {n_seqs} sequences ({n_seqs - 1} mutants + 1 wildtype)")
|
|
233
|
+
|
|
234
|
+
if args.fasta_out:
|
|
235
|
+
print(f"FASTA saved to {args.fasta_out}")
|
|
236
|
+
|
|
237
|
+
# Step 3: Run CoVFit
|
|
238
|
+
covfit_outdir = os.path.join(tmpdir, "covfit_output")
|
|
239
|
+
os.makedirs(covfit_outdir, exist_ok=True)
|
|
240
|
+
tsv_path = run_covfit(
|
|
241
|
+
args.covfit_cli, fasta_path, covfit_outdir,
|
|
242
|
+
fold=args.fold, batch=args.batch, gpu=args.gpu,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# Step 4: Parse output
|
|
246
|
+
scores = parse_covfit_output(tsv_path, fasta_path)
|
|
247
|
+
print(f"Parsed {len(scores)} scores from CoVFit output")
|
|
248
|
+
|
|
249
|
+
if len(scores) != n_seqs:
|
|
250
|
+
print(f"WARNING: expected {n_seqs} scores, got {len(scores)}")
|
|
251
|
+
|
|
252
|
+
# Step 5: Build baseline matrix and write CSV
|
|
253
|
+
matrix = build_baseline_matrix(scores, seq)
|
|
254
|
+
os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True)
|
|
255
|
+
write_baseline_csv(matrix, args.output)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
if __name__ == "__main__":
|
|
259
|
+
main()
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""Score mutations using the MSA Transformer (esm_msa1b_t12_100M_UR50S).
|
|
2
|
+
|
|
3
|
+
Takes a protein MSA in FASTA format and outputs a (seq_len, alphabet_size)
|
|
4
|
+
CSV of per-position, per-amino-acid probabilities from masked marginal
|
|
5
|
+
scoring of the first sequence in the MSA.
|
|
6
|
+
|
|
7
|
+
Masked marginal scoring: for each position i in the query sequence (row 0),
|
|
8
|
+
mask that position, run a forward pass, and record the softmax probabilities
|
|
9
|
+
over the vocabulary. This gives the model's predicted distribution at each
|
|
10
|
+
position conditioned on the rest of the sequence and the MSA context.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
python baselines/msa_transformer.py \
|
|
14
|
+
--msa path/to/alignment.fasta \
|
|
15
|
+
--output scores.csv \
|
|
16
|
+
[--metadata path/to/metadata.tsv] \
|
|
17
|
+
[--date-cutoff 2020-01-01] \
|
|
18
|
+
[--max-seqs 256] \
|
|
19
|
+
[--device cuda]
|
|
20
|
+
|
|
21
|
+
Output format matches data/baselines/Pre_Wuhan1_MSATransformer.csv:
|
|
22
|
+
- Header: A,C,D,E,F,G,H,I,K,L,M,N,P,Q,R,S,T,V,W,Y,*,-
|
|
23
|
+
- One row per position in the query sequence
|
|
24
|
+
- Values are softmax probabilities
|
|
25
|
+
- '*' column is -1 (not in MSA Transformer vocabulary)
|
|
26
|
+
- '-' column contains the model's gap token probability
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import re
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
import numpy as np
|
|
35
|
+
import torch
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Column order for output CSV (matches allowed_aas in train.py)
|
|
39
|
+
OUTPUT_ALPHABET = "ACDEFGHIKLMNPQRSTVWY*-"
|
|
40
|
+
|
|
41
|
+
# Regex for a complete YYYY-MM-DD date
|
|
42
|
+
_FULL_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_date_filter(metadata_path: str, date_cutoff: str) -> set[str]:
|
|
46
|
+
"""Load strain names with collection date strictly before a cutoff.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
metadata_path: Path to TSV with columns 'strain' and 'date'.
|
|
50
|
+
date_cutoff: YYYY-MM-DD string. Only sequences with a complete
|
|
51
|
+
date strictly before this cutoff are included.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Set of strain names passing the filter.
|
|
55
|
+
"""
|
|
56
|
+
allowed = set()
|
|
57
|
+
with open(metadata_path) as f:
|
|
58
|
+
header = f.readline().strip().split("\t")
|
|
59
|
+
strain_col = header.index("strain")
|
|
60
|
+
date_col = header.index("date")
|
|
61
|
+
for line in f:
|
|
62
|
+
fields = line.strip().split("\t")
|
|
63
|
+
if len(fields) <= max(strain_col, date_col):
|
|
64
|
+
continue
|
|
65
|
+
date_str = fields[date_col]
|
|
66
|
+
if not _FULL_DATE_RE.match(date_str):
|
|
67
|
+
continue
|
|
68
|
+
if date_str < date_cutoff:
|
|
69
|
+
allowed.add(fields[strain_col])
|
|
70
|
+
return allowed
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def read_msa_fasta(
|
|
74
|
+
path: str,
|
|
75
|
+
allowed_strains: set[str] | None = None,
|
|
76
|
+
) -> list[tuple[str, str]]:
|
|
77
|
+
"""Read a protein MSA from a FASTA file.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
path: Path to FASTA-formatted MSA file.
|
|
81
|
+
allowed_strains: If provided, only include sequences whose
|
|
82
|
+
header name (up to first whitespace) is in this set.
|
|
83
|
+
The first sequence is always included regardless.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
List of (name, sequence) tuples.
|
|
87
|
+
"""
|
|
88
|
+
sequences = []
|
|
89
|
+
current_name = None
|
|
90
|
+
current_seq = []
|
|
91
|
+
is_first = True
|
|
92
|
+
|
|
93
|
+
with open(path) as f:
|
|
94
|
+
for line in f:
|
|
95
|
+
line = line.strip()
|
|
96
|
+
if not line:
|
|
97
|
+
continue
|
|
98
|
+
if line.startswith(">"):
|
|
99
|
+
if current_name is not None:
|
|
100
|
+
seq = "".join(current_seq)
|
|
101
|
+
sequences.append((current_name, seq))
|
|
102
|
+
current_name = line[1:].split()[0]
|
|
103
|
+
current_seq = []
|
|
104
|
+
|
|
105
|
+
# After the first sequence, filter by allowed_strains
|
|
106
|
+
if not is_first and allowed_strains is not None:
|
|
107
|
+
if current_name not in allowed_strains:
|
|
108
|
+
current_name = None # skip this sequence
|
|
109
|
+
is_first = False
|
|
110
|
+
else:
|
|
111
|
+
if current_name is not None:
|
|
112
|
+
current_seq.append(line)
|
|
113
|
+
|
|
114
|
+
if current_name is not None:
|
|
115
|
+
sequences.append((current_name, "".join(current_seq)))
|
|
116
|
+
|
|
117
|
+
if not sequences:
|
|
118
|
+
raise ValueError(f"No sequences found in {path}")
|
|
119
|
+
|
|
120
|
+
return sequences
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def write_filtered_fasta(
|
|
124
|
+
sequences: list[tuple[str, str]],
|
|
125
|
+
output_path: str,
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Write sequences to a FASTA file."""
|
|
128
|
+
with open(output_path, "w") as f:
|
|
129
|
+
for name, seq in sequences:
|
|
130
|
+
f.write(f">{name}\n{seq}\n")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def score_msa(
|
|
134
|
+
msa_path: str,
|
|
135
|
+
max_seqs: int = 256,
|
|
136
|
+
device: str = "cuda",
|
|
137
|
+
metadata_path: str | None = None,
|
|
138
|
+
date_cutoff: str | None = None,
|
|
139
|
+
filtered_fasta_path: str | None = None,
|
|
140
|
+
seq_len: int | None = None,
|
|
141
|
+
query_seq: str | None = None,
|
|
142
|
+
) -> np.ndarray:
|
|
143
|
+
"""Compute masked marginal probabilities using MSA Transformer.
|
|
144
|
+
|
|
145
|
+
For each position in the query sequence (row 0 of the MSA), masks
|
|
146
|
+
that position across all sequences in the MSA column, runs a
|
|
147
|
+
forward pass, and extracts the softmax probabilities for row 0.
|
|
148
|
+
|
|
149
|
+
To amortize cost, multiple positions are masked simultaneously in
|
|
150
|
+
a single forward pass (one batch element per masked position).
|
|
151
|
+
|
|
152
|
+
For sequences longer than 1023 residues (MSA Transformer limit),
|
|
153
|
+
uses a sliding window: two chunks covering the full length with
|
|
154
|
+
averaged probabilities in the overlap region.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
msa_path: Path to FASTA-formatted MSA file.
|
|
158
|
+
max_seqs: Maximum number of MSA sequences to use. The MSA
|
|
159
|
+
Transformer has memory constraints; 256 is a reasonable
|
|
160
|
+
default. The query (row 0) is always included.
|
|
161
|
+
device: PyTorch device string.
|
|
162
|
+
metadata_path: Optional path to metadata TSV for date filtering.
|
|
163
|
+
date_cutoff: Optional YYYY-MM-DD cutoff date (exclusive).
|
|
164
|
+
Requires metadata_path.
|
|
165
|
+
filtered_fasta_path: If provided, write the date-filtered MSA
|
|
166
|
+
to this path before scoring.
|
|
167
|
+
seq_len: Expected output length (e.g. 1273 for spike without
|
|
168
|
+
stop codon). If None, inferred from the query sequence
|
|
169
|
+
after stripping trailing '*'.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Array of shape (seq_len, len(OUTPUT_ALPHABET)) with softmax
|
|
173
|
+
probabilities. The '*' column is -1 (not in vocabulary).
|
|
174
|
+
"""
|
|
175
|
+
# Date filtering
|
|
176
|
+
allowed_strains = None
|
|
177
|
+
if date_cutoff is not None:
|
|
178
|
+
if metadata_path is None:
|
|
179
|
+
raise ValueError("--metadata is required when using --date-cutoff")
|
|
180
|
+
print(f"Loading metadata and filtering to dates before {date_cutoff}...")
|
|
181
|
+
allowed_strains = load_date_filter(metadata_path, date_cutoff)
|
|
182
|
+
print(f" {len(allowed_strains)} strains pass date filter")
|
|
183
|
+
|
|
184
|
+
# Read MSA (with optional filtering)
|
|
185
|
+
print(f"Reading MSA from {msa_path}...")
|
|
186
|
+
msa = read_msa_fasta(msa_path, allowed_strains)
|
|
187
|
+
print(f" {len(msa)} sequences after filtering")
|
|
188
|
+
|
|
189
|
+
# Write filtered FASTA if requested
|
|
190
|
+
if filtered_fasta_path is not None:
|
|
191
|
+
write_filtered_fasta(msa, filtered_fasta_path)
|
|
192
|
+
print(f" Wrote filtered MSA to {filtered_fasta_path}")
|
|
193
|
+
|
|
194
|
+
if len(msa) > max_seqs:
|
|
195
|
+
msa = [msa[0]] + msa[1:max_seqs]
|
|
196
|
+
print(f"MSA: {len(msa)} sequences, length {len(msa[0][1])}")
|
|
197
|
+
|
|
198
|
+
# Load model
|
|
199
|
+
model, alphabet = torch.hub.load(
|
|
200
|
+
"facebookresearch/esm:main", "esm_msa1b_t12_100M_UR50S"
|
|
201
|
+
)
|
|
202
|
+
model = model.eval().to(device)
|
|
203
|
+
batch_converter = alphabet.get_batch_converter()
|
|
204
|
+
|
|
205
|
+
# Trim trailing '*' (stop codon) and truncate to seq_len so all
|
|
206
|
+
# rows have equal length. Replace any remaining unsupported
|
|
207
|
+
# characters (e.g. mid-sequence '*') with 'X' (unknown AA).
|
|
208
|
+
if seq_len is None:
|
|
209
|
+
seq_len = len(msa[0][1].rstrip("*"))
|
|
210
|
+
valid_tokens = set(alphabet.all_toks)
|
|
211
|
+
msa = [
|
|
212
|
+
(name, "".join(
|
|
213
|
+
ch if ch in valid_tokens else "X"
|
|
214
|
+
for ch in seq.rstrip("*")[:seq_len]
|
|
215
|
+
))
|
|
216
|
+
for name, seq in msa
|
|
217
|
+
]
|
|
218
|
+
print(f" Sequence length after processing: {seq_len}")
|
|
219
|
+
|
|
220
|
+
# Optionally prepend a query sequence as row 0 (after trimming so lengths match)
|
|
221
|
+
if query_seq is not None:
|
|
222
|
+
query_processed = "".join(
|
|
223
|
+
ch if ch in valid_tokens else "X"
|
|
224
|
+
for ch in query_seq[:seq_len]
|
|
225
|
+
)
|
|
226
|
+
msa = [("query", query_processed)] + msa[:max_seqs - 1]
|
|
227
|
+
print(f" Prepended query sequence as row 0 (length {len(query_processed)})")
|
|
228
|
+
|
|
229
|
+
# Verify alignment
|
|
230
|
+
lengths = {len(seq) for _, seq in msa}
|
|
231
|
+
if len(lengths) > 1:
|
|
232
|
+
raise ValueError(
|
|
233
|
+
f"MSA sequences have different lengths: {lengths}. "
|
|
234
|
+
"All sequences must be aligned to the same length."
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# Build token-index-to-output-column mapping
|
|
238
|
+
tok_to_col = {}
|
|
239
|
+
for col_idx, aa in enumerate(OUTPUT_ALPHABET):
|
|
240
|
+
if aa == "*":
|
|
241
|
+
continue # Not in MSA Transformer vocab
|
|
242
|
+
tok_idx = alphabet.get_idx(aa)
|
|
243
|
+
tok_to_col[tok_idx] = col_idx
|
|
244
|
+
|
|
245
|
+
mask_idx = alphabet.mask_idx
|
|
246
|
+
|
|
247
|
+
# MSA Transformer max sequence length is 1023 residues (+ CLS = 1024 tokens).
|
|
248
|
+
# For longer sequences, use a sliding window with two chunks and
|
|
249
|
+
# average probabilities in the overlap region.
|
|
250
|
+
MAX_RESIDUES = 1023
|
|
251
|
+
|
|
252
|
+
output = np.full((seq_len, len(OUTPUT_ALPHABET)), -1.0)
|
|
253
|
+
|
|
254
|
+
if seq_len <= MAX_RESIDUES:
|
|
255
|
+
chunks = [(0, seq_len)]
|
|
256
|
+
else:
|
|
257
|
+
# Chunk 1: positions 0..MAX_RESIDUES-1
|
|
258
|
+
# Chunk 2: positions (seq_len - MAX_RESIDUES)..seq_len-1
|
|
259
|
+
chunk2_start = seq_len - MAX_RESIDUES
|
|
260
|
+
chunks = [(0, MAX_RESIDUES), (chunk2_start, seq_len)]
|
|
261
|
+
overlap_start = chunk2_start
|
|
262
|
+
overlap_end = MAX_RESIDUES
|
|
263
|
+
print(f" Sequence exceeds {MAX_RESIDUES} limit; using 2 chunks "
|
|
264
|
+
f"with overlap at positions {overlap_start}-{overlap_end - 1}")
|
|
265
|
+
|
|
266
|
+
for chunk_idx, (chunk_start, chunk_end) in enumerate(chunks):
|
|
267
|
+
chunk_len = chunk_end - chunk_start
|
|
268
|
+
|
|
269
|
+
# Extract chunk from each MSA sequence
|
|
270
|
+
chunk_msa = [
|
|
271
|
+
(name, seq[chunk_start:chunk_end])
|
|
272
|
+
for name, seq in msa
|
|
273
|
+
]
|
|
274
|
+
|
|
275
|
+
# Tokenize chunk
|
|
276
|
+
_, _, tokens = batch_converter([chunk_msa])
|
|
277
|
+
base_tokens = tokens[0].to(device) # (num_seqs, chunk_len + 1)
|
|
278
|
+
|
|
279
|
+
# Determine batch size for this chunk
|
|
280
|
+
batch_size = _find_batch_size(model, base_tokens, device)
|
|
281
|
+
if len(chunks) > 1:
|
|
282
|
+
print(f" Chunk {chunk_idx + 1}/{len(chunks)} "
|
|
283
|
+
f"(positions {chunk_start}-{chunk_end - 1}), "
|
|
284
|
+
f"batch size: {batch_size}")
|
|
285
|
+
else:
|
|
286
|
+
print(f" Using batch size: {batch_size} positions per forward pass")
|
|
287
|
+
|
|
288
|
+
print(f" Running masked marginal scoring for {chunk_len} positions...")
|
|
289
|
+
with torch.no_grad():
|
|
290
|
+
for batch_start in range(0, chunk_len, batch_size):
|
|
291
|
+
batch_end_local = min(batch_start + batch_size, chunk_len)
|
|
292
|
+
batch_positions = list(range(batch_start, batch_end_local))
|
|
293
|
+
B = len(batch_positions)
|
|
294
|
+
|
|
295
|
+
batched = base_tokens.unsqueeze(0).expand(B, -1, -1).clone()
|
|
296
|
+
for i, pos in enumerate(batch_positions):
|
|
297
|
+
tok_pos = pos + 1 # +1 for CLS
|
|
298
|
+
batched[i, :, tok_pos] = mask_idx
|
|
299
|
+
|
|
300
|
+
result = model(batched)
|
|
301
|
+
logits = result["logits"]
|
|
302
|
+
|
|
303
|
+
for i, pos in enumerate(batch_positions):
|
|
304
|
+
tok_pos = pos + 1
|
|
305
|
+
pos_logits = logits[i, 0, tok_pos]
|
|
306
|
+
probs = torch.softmax(pos_logits, dim=-1).cpu().numpy()
|
|
307
|
+
|
|
308
|
+
global_pos = chunk_start + pos
|
|
309
|
+
row = np.full(len(OUTPUT_ALPHABET), -1.0)
|
|
310
|
+
for tok_idx, col_idx in tok_to_col.items():
|
|
311
|
+
row[col_idx] = probs[tok_idx]
|
|
312
|
+
|
|
313
|
+
# In overlap region, average with existing values
|
|
314
|
+
if len(chunks) > 1 and chunk_idx > 0 and output[global_pos, 0] != -1.0:
|
|
315
|
+
output[global_pos] = (output[global_pos] + row) / 2.0
|
|
316
|
+
else:
|
|
317
|
+
output[global_pos] = row
|
|
318
|
+
|
|
319
|
+
global_end = chunk_start + batch_end_local
|
|
320
|
+
print(f" Positions {chunk_start + batch_start + 1}-"
|
|
321
|
+
f"{global_end}/{seq_len}")
|
|
322
|
+
|
|
323
|
+
return output
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _find_batch_size(
|
|
327
|
+
model: torch.nn.Module,
|
|
328
|
+
base_tokens: torch.Tensor,
|
|
329
|
+
device: str,
|
|
330
|
+
) -> int:
|
|
331
|
+
"""Find the largest batch size that fits in GPU memory.
|
|
332
|
+
|
|
333
|
+
Tries powers of 2 starting from a reasonable guess, falling back
|
|
334
|
+
on OOM. On CPU, defaults to 1.
|
|
335
|
+
"""
|
|
336
|
+
if device == "cpu":
|
|
337
|
+
return 1
|
|
338
|
+
|
|
339
|
+
num_seqs, tok_len = base_tokens.shape
|
|
340
|
+
candidates = [32, 16, 8, 4, 2, 1]
|
|
341
|
+
|
|
342
|
+
for bs in candidates:
|
|
343
|
+
try:
|
|
344
|
+
torch.cuda.empty_cache()
|
|
345
|
+
dummy = base_tokens.unsqueeze(0).expand(bs, -1, -1).clone()
|
|
346
|
+
with torch.no_grad():
|
|
347
|
+
model(dummy)
|
|
348
|
+
torch.cuda.empty_cache()
|
|
349
|
+
return bs
|
|
350
|
+
except torch.cuda.OutOfMemoryError:
|
|
351
|
+
torch.cuda.empty_cache()
|
|
352
|
+
continue
|
|
353
|
+
|
|
354
|
+
return 1
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def main():
|
|
358
|
+
parser = argparse.ArgumentParser(
|
|
359
|
+
description="Score mutations using MSA Transformer masked marginals."
|
|
360
|
+
)
|
|
361
|
+
parser.add_argument(
|
|
362
|
+
"--msa", type=str, required=True,
|
|
363
|
+
help="Path to MSA in FASTA format. First sequence is the query."
|
|
364
|
+
)
|
|
365
|
+
parser.add_argument(
|
|
366
|
+
"--output", type=str, required=True,
|
|
367
|
+
help="Output CSV path."
|
|
368
|
+
)
|
|
369
|
+
parser.add_argument(
|
|
370
|
+
"--metadata", type=str, default=None,
|
|
371
|
+
help="Path to metadata TSV with 'strain' and 'date' columns."
|
|
372
|
+
)
|
|
373
|
+
parser.add_argument(
|
|
374
|
+
"--date-cutoff", type=str, default=None,
|
|
375
|
+
help="Only include sequences with collection date before this "
|
|
376
|
+
"date (YYYY-MM-DD, exclusive). Requires --metadata."
|
|
377
|
+
)
|
|
378
|
+
parser.add_argument(
|
|
379
|
+
"--filtered-fasta", type=str, default=None,
|
|
380
|
+
help="Write the date-filtered MSA to this FASTA path."
|
|
381
|
+
)
|
|
382
|
+
parser.add_argument(
|
|
383
|
+
"--max-seqs", type=int, default=256,
|
|
384
|
+
help="Maximum number of MSA sequences to use (default: 256)."
|
|
385
|
+
)
|
|
386
|
+
parser.add_argument(
|
|
387
|
+
"--seq-len", type=int, default=None,
|
|
388
|
+
help="Expected alignment length (e.g. 1273 for spike without "
|
|
389
|
+
"stop codon). If not set, inferred from the query sequence."
|
|
390
|
+
)
|
|
391
|
+
parser.add_argument(
|
|
392
|
+
"--query-seq", type=str, default=None,
|
|
393
|
+
help="Override the query (row 0) sequence. The original MSA is "
|
|
394
|
+
"used only for context. Useful for scoring a specific variant."
|
|
395
|
+
)
|
|
396
|
+
parser.add_argument(
|
|
397
|
+
"--device", type=str, default=None,
|
|
398
|
+
help="PyTorch device (default: cuda if available, else cpu)."
|
|
399
|
+
)
|
|
400
|
+
args = parser.parse_args()
|
|
401
|
+
|
|
402
|
+
if args.device is None:
|
|
403
|
+
args.device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
404
|
+
|
|
405
|
+
if args.date_cutoff is not None and args.metadata is None:
|
|
406
|
+
parser.error("--date-cutoff requires --metadata")
|
|
407
|
+
|
|
408
|
+
scores = score_msa(
|
|
409
|
+
args.msa, args.max_seqs, args.device,
|
|
410
|
+
metadata_path=args.metadata,
|
|
411
|
+
date_cutoff=args.date_cutoff,
|
|
412
|
+
filtered_fasta_path=args.filtered_fasta,
|
|
413
|
+
seq_len=args.seq_len,
|
|
414
|
+
query_seq=args.query_seq,
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
# Write CSV matching the format of data/baselines/Pre_Wuhan1_MSATransformer.csv
|
|
418
|
+
header = ",".join(OUTPUT_ALPHABET)
|
|
419
|
+
np.savetxt(
|
|
420
|
+
args.output, scores,
|
|
421
|
+
delimiter=",", header=header, comments="",
|
|
422
|
+
fmt="%.15g",
|
|
423
|
+
)
|
|
424
|
+
print(f"Saved {scores.shape} score matrix to {args.output}")
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
if __name__ == "__main__":
|
|
428
|
+
main()
|