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 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()