Match genome-scale inference windowing and batching

#3
by kashif HF Staff - opened
Files changed (3) hide show
  1. INFERENCE.md +8 -6
  2. README.md +12 -13
  3. infer_genbank.py +129 -64
INFERENCE.md CHANGED
@@ -18,11 +18,13 @@ python infer_genbank.py genome.fna.gz --format fasta --out-dir genome-prediction
18
 
19
  Both commands process every sequence record in the file. Use `--revision <model-commit>` to pin the model, or `--threshold 0.7` to change the binary-call threshold (default 0.5).
20
 
21
- The script uses 98,304-base windows with a 49,152-base stride and averages CDS probabilities where windows overlap. The last window may be shorter. No BOS/EOS tokens are added. Short records are supported, but the training data used full-length 98,304-base windows. This example uses overlap averaging; it does not reproduce any separate production genome-annotation pipeline.
22
 
23
- Outputs are one compressed NumPy file per record, named with its accession and record name, plus `summary.json`. Each file contains forward/reverse CDS probabilities and calls, a combined `cds_label_any` track, a `valid` mask, and the number of contributing windows per base (`window_count`). Array position `i` corresponds to record base `i + 1` on **both** strands; the reverse track is not reversed. These are coding-occupancy predictions, not GFF gene or transcript models.
24
 
25
- Any 6-mer containing an ambiguous nucleotide is excluded from the output. An incomplete final 6-mer is padded with `N` and also excluded. Unscored positions have probability `NaN`, call `-1`, and `valid=False`. Record sequences must be present in the file; annotation-only records with undefined sequence cannot be used. The example retains per-base arrays for one record in memory, so large chromosomes require more host RAM.
 
 
26
 
27
  ```python
28
  import json
@@ -34,9 +36,9 @@ record = json.loads((out / "summary.json").read_text())["records"][0]
34
  with np.load(out / record["file"]) as prediction:
35
  plus = prediction["cds_probability_plus"]
36
  minus = prediction["cds_probability_minus"]
37
- coding = prediction["cds_label_any"] # 0, 1, or -1 (unscored)
38
- valid = prediction["valid"]
39
- print(record["accession"], plus.shape, int(valid.sum()))
40
  ```
41
 
42
  ## Run inference on packed data
 
18
 
19
  Both commands process every sequence record in the file. Use `--revision <model-commit>` to pin the model, or `--threshold 0.7` to change the binary-call threshold (default 0.5).
20
 
21
+ The script uses the same windowing and batching as Carbon-A's genome-scale predictions. Windows are 98,304 bases (16,384 tokens) and consecutive windows overlap by 6,144 bases (`--overlap-length`, default 1,024 tokens). The last window of a longer record is aligned to the record's end, so all windows are full length. A record shorter than one window is covered by one window from its start, of whole 6-mers, plus, when its length is not a multiple of six, a second one aligned to its end. CDS probabilities from the two strand heads are averaged where windows overlap. No BOS/EOS tokens or `N` bases are added; windows shorter than 16,384 tokens are padded with the pad token and masked. Records shorter than six bases get probability 0. Short records are supported, but the training data used full-length 98,304-base windows.
22
 
23
+ In BF16, probabilities depend slightly on the attention kernel and on how windows are batched. The script therefore batches the same way: records are processed longest first in groups of four (`--dispatch-chunk-size`), and full-length windows and shorter, padded windows are run in separate batches of eight (`--batch-size`). The genome-scale predictions used FlashAttention-2. With `flash-attn`, Transformers 4.x, and `--attn-implementation flash_attention_2`, the script reproduced those probabilities bit for bit in our tests. The default, PyTorch SDPA, needs no extra package; in the same tests, single-base probabilities differed by up to 0.11, and at least 99.8% of 0.5 calls were unchanged. A smaller `--batch-size` uses less GPU memory but also changes results slightly.
24
 
25
+ Outputs are one compressed NumPy file per record, named with its accession and record name, plus `summary.json`. Each file contains forward/reverse CDS probabilities and calls, a combined `cds_label_any` track, an `ambiguous` mask for bases other than A, C, G, and T, and the number of contributing windows per base (`window_count`). Array position `i` corresponds to record base `i + 1` on **both** strands; the reverse track is not reversed. These are coding-occupancy predictions, not GFF gene or transcript models.
26
+
27
+ Every base receives a probability. A 6-mer containing an ambiguous nucleotide is tokenized as an unknown token and still predicted; its predictions are less reliable. Record sequences must be present in the file; annotation-only records with undefined sequence cannot be used. The example retains per-base arrays for one record in memory, so large chromosomes require more host RAM.
28
 
29
  ```python
30
  import json
 
36
  with np.load(out / record["file"]) as prediction:
37
  plus = prediction["cds_probability_plus"]
38
  minus = prediction["cds_probability_minus"]
39
+ coding = prediction["cds_label_any"] # 1 if either strand passes the threshold
40
+ ambiguous = prediction["ambiguous"]
41
+ print(record["accession"], plus.shape, int(ambiguous.sum()))
42
  ```
43
 
44
  ## Run inference on packed data
README.md CHANGED
@@ -48,9 +48,12 @@ Full-length 98,304-base windows rely on PyTorch's fused SDPA kernels (FlashAtten
48
 
49
  ### Use from Python
50
 
 
 
51
  ```python
52
  import torch
53
  from transformers import AutoModelForTokenClassification, AutoTokenizer
 
54
 
55
  model_id = "HuggingFaceBio/Carbon-A-1.2B"
56
  tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
@@ -58,16 +61,12 @@ model = AutoModelForTokenClassification.from_pretrained(
58
  model_id, trust_remote_code=True, dtype=torch.bfloat16, attn_implementation="sdpa"
59
  ).to("cuda").eval()
60
 
61
- sequence = "ATGTCTGCTACACAAAAGCAACACTAA" # up to 98,304 bases
62
- sequence = sequence.upper()
63
- sequence += "N" * (-len(sequence) % 6) # complete the final 6-mer
64
- inputs = tokenizer(sequence, add_special_tokens=False, return_tensors="pt").to("cuda")
65
- with torch.inference_mode():
66
- logits = model(**inputs).logits # (1, 2 * len(sequence), 2)
67
- cds_plus, cds_minus = logits.float().softmax(-1)[0, :, 1].reshape(2, -1).cpu()
68
  ```
69
 
70
- Logits are per base: all forward-strand positions, then all reverse-strand positions, both in input coordinates. Positions covered by a 6-mer containing `N` or another ambiguous base are not meaningful; the example scripts exclude them. Pass `attention_mask`, which the tokenizer returns, because the model requires it. Uppercase the sequence and use `add_special_tokens=False`: the tokenizer adds a BOS token by default, and lowercase (soft-masked) bases are not grouped into 6-mers, which shifts every later position.
71
 
72
  ### Annotate a GenBank or FASTA file
73
 
@@ -81,7 +80,7 @@ python infer_genbank.py genome.gbff.gz --out-dir predictions
81
  python infer_genbank.py genome.fna.gz --format fasta --out-dir predictions-fasta
82
  ```
83
 
84
- Long records are processed in **98,304-base windows with 50% overlap**. Probabilities are averaged where windows overlap. Existing GenBank annotations are not model inputs.
85
 
86
  Each record produces an `.npz` file named with its accession and record name, plus an entry in `summary.json`. To read the predictions:
87
 
@@ -96,10 +95,10 @@ with np.load(out / record["file"]) as pred:
96
  plus = pred["cds_probability_plus"]
97
  minus = pred["cds_probability_minus"]
98
  coding = pred["cds_label_any"] # 1 if either strand passes the threshold
99
- valid = pred["valid"]
100
  ```
101
 
102
- Array index `i` corresponds to record base `i + 1` on both strands. Unscored positions have probability `NaN`, call `-1`, and `valid=False`.
103
 
104
  ### Run on packed training or evaluation data
105
 
@@ -130,7 +129,7 @@ The **[packed-data bucket](https://huggingface.co/buckets/HuggingFaceBio/Carbon-
130
  The inference examples have passed functional checks on an **NVIDIA H100**, using BF16, PyTorch 2.7.1, and Transformers 4.56.0:
131
 
132
  - Two full-length packed evaluation windows, including strand outputs and thresholded calls.
133
- - The complete **230,218-base yeast chromosome I** record, processed in four overlapping windows.
134
  - Identical predictions when the same sequence is supplied as GenBank or FASTA.
135
  - Independent checks of overlap averaging, coordinates, ambiguous bases, and short records.
136
 
@@ -140,7 +139,7 @@ These checks verify the inference code. **They do not measure agreement with ref
140
 
141
  - Predictions represent **CDS occupancy**. Transcript paths, alternative isoforms, and complete gene structures are not resolved.
142
  - The model is intended for **eukaryotic sequence**. Bacteria and viruses are outside its training scope.
143
- - The GenBank/FASTA example excludes any 6-mer containing an ambiguous nucleotide, including an incomplete final 6-mer padded with `N`.
144
  - Shorter records can be processed, but training used full-length 98,304-base windows.
145
  - The supplied evaluation panel was used during training; it is not a newly held-out test set.
146
  - Full-length inference on CPU is slow. The file example holds per-base arrays for one record in memory, so large chromosomes need more host RAM.
 
48
 
49
  ### Use from Python
50
 
51
+ Run from the downloaded repository, so that `infer_genbank.py` can be imported:
52
+
53
  ```python
54
  import torch
55
  from transformers import AutoModelForTokenClassification, AutoTokenizer
56
+ from infer_genbank import predict_record
57
 
58
  model_id = "HuggingFaceBio/Carbon-A-1.2B"
59
  tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
 
61
  model_id, trust_remote_code=True, dtype=torch.bfloat16, attn_implementation="sdpa"
62
  ).to("cuda").eval()
63
 
64
+ sequence = "ATGTCTGCTACACAAAAGCAACACTAA" # any length
65
+ probs, window_count, num_windows = predict_record(sequence, model, tokenizer, "cuda")
66
+ cds_plus, cds_minus = probs # per-base P(CDS) on the forward and reverse strand
 
 
 
 
67
  ```
68
 
69
+ `predict_record` handles windowing, including records whose length is not a multiple of six. To call the model directly, pass one window: an uppercase sequence of whole 6-mers, at most 98,304 bases, tokenized with `add_special_tokens=False`, together with the `attention_mask` the tokenizer returns. The tokenizer otherwise adds a BOS token, and lowercase (soft-masked) bases are not grouped into 6-mers, which shifts every later position. The logits are per base: all forward-strand positions, then all reverse-strand positions, both in input coordinates.
70
 
71
  ### Annotate a GenBank or FASTA file
72
 
 
80
  python infer_genbank.py genome.fna.gz --format fasta --out-dir predictions-fasta
81
  ```
82
 
83
+ Records are split into **98,304-base windows that overlap by 6,144 bases** (1,024 tokens). The last window is aligned to the end of the record, so every window is full length and no `N` bases are added. A record shorter than one window, whose length is not a multiple of six, is run twice: once aligned to its start and once to its end. Probabilities are averaged where windows overlap. Windowing and batching match Carbon-A's genome-scale predictions; see the inference guide for reproducing them exactly. Existing GenBank annotations are not model inputs.
84
 
85
  Each record produces an `.npz` file named with its accession and record name, plus an entry in `summary.json`. To read the predictions:
86
 
 
95
  plus = pred["cds_probability_plus"]
96
  minus = pred["cds_probability_minus"]
97
  coding = pred["cds_label_any"] # 1 if either strand passes the threshold
98
+ ambiguous = pred["ambiguous"] # bases other than A, C, G, T
99
  ```
100
 
101
+ Array index `i` corresponds to record base `i + 1` on both strands. Every base has a probability; records shorter than six bases get 0.
102
 
103
  ### Run on packed training or evaluation data
104
 
 
129
  The inference examples have passed functional checks on an **NVIDIA H100**, using BF16, PyTorch 2.7.1, and Transformers 4.56.0:
130
 
131
  - Two full-length packed evaluation windows, including strand outputs and thresholded calls.
132
+ - The complete **230,218-base yeast chromosome I** record.
133
  - Identical predictions when the same sequence is supplied as GenBank or FASTA.
134
  - Independent checks of overlap averaging, coordinates, ambiguous bases, and short records.
135
 
 
139
 
140
  - Predictions represent **CDS occupancy**. Transcript paths, alternative isoforms, and complete gene structures are not resolved.
141
  - The model is intended for **eukaryotic sequence**. Bacteria and viruses are outside its training scope.
142
+ - A 6-mer containing an ambiguous nucleotide (`N` or another IUPAC code) is an unknown token. It still receives predictions, but they are less reliable; the `ambiguous` array marks these bases.
143
  - Shorter records can be processed, but training used full-length 98,304-base windows.
144
  - The supplied evaluation panel was used during training; it is not a newly held-out test set.
145
  - Full-length inference on CPU is slow. The file example holds per-base arrays for one record in memory, so large chromosomes need more host RAM.
infer_genbank.py CHANGED
@@ -11,59 +11,96 @@ import torch
11
  from transformers import AutoModelForTokenClassification, AutoTokenizer
12
 
13
  MODEL_ID = 'HuggingFaceBio/Carbon-A-1.2B'
14
- WINDOW = 98304
15
- STRIDE = 49152
 
 
 
16
 
17
 
18
  def safe_name(value):
19
  return re.sub(r'[^A-Za-z0-9_.-]+', '_', value).strip('._')[:100] or 'record'
20
 
21
 
22
- def predict_record(sequence, model, tokenizer, device, threshold):
23
- """Average probabilities across overlapping windows in record coordinates."""
24
- sequence = sequence.upper()
25
- length = len(sequence)
26
- if not length:
27
- raise ValueError('Cannot predict an empty sequence')
28
- sums = np.zeros((2, length), dtype=np.float32)
29
- counts = np.zeros(length, dtype=np.uint32)
30
- windows = 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  with torch.inference_mode():
32
- for start in range(0, length, STRIDE):
33
- stop = min(start + WINDOW, length)
34
- fragment = sequence[start:stop]
35
- # Complete the final 6-mer with N; its positions will be unscored.
36
- fragment += 'N' * (-len(fragment) % 6)
37
- encoded = tokenizer(fragment, add_special_tokens=False, return_tensors='pt',
38
- return_attention_mask=True)
39
- ids = encoded['input_ids']
40
- n_tokens = len(fragment) // 6
41
- if ids.shape != (1, n_tokens):
42
- raise ValueError('Tokenizer did not produce one token per six bases')
43
- # Match packed-data inference: OOV tokens remain visible context;
44
- # only their output positions are excluded, not the attention mask.
45
- mask = torch.ones_like(ids)
46
- logits = model(input_ids=ids.to(device), attention_mask=mask.to(device)).logits.float()
47
- if logits.shape != (1, 2 * n_tokens * 6, 2) or not torch.isfinite(logits).all():
48
- raise ValueError('Unexpected shape or nonfinite model predictions')
49
- probs = logits.softmax(-1)[0, :, 1].cpu().numpy().reshape(2, -1)
50
- width = stop - start
51
- valid = np.repeat(ids[0].numpy() >= 32, 6)[:width]
52
- sums[:, start:stop] += np.where(valid[None, :], probs[:, :width], 0)
53
- counts[start:stop] += valid.astype(np.uint32)
54
- windows += 1
55
- if stop == length:
56
- break
57
- valid = counts > 0
58
- np.divide(sums, counts[None, :], out=sums, where=valid[None, :])
59
- sums[:, ~valid] = np.nan
60
- calls = (sums >= threshold).astype(np.int8)
61
- calls[:, ~valid] = -1
62
- any_call = np.any(calls == 1, axis=0).astype(np.int8)
63
- any_call[~valid] = -1
64
- return {'cds_probability_plus': sums[0], 'cds_probability_minus': sums[1],
65
- 'cds_label_plus': calls[0], 'cds_label_minus': calls[1],
66
- 'cds_label_any': any_call, 'valid': valid, 'window_count': counts}, windows
 
 
 
 
 
 
 
 
 
 
 
67
 
68
 
69
  def main():
@@ -74,10 +111,22 @@ def main():
74
  parser.add_argument('--revision', default='main', help='Use a model commit for repeatable runs.')
75
  parser.add_argument('--device', choices=['cuda', 'cpu'], default='cuda')
76
  parser.add_argument('--threshold', type=float, default=0.5)
 
 
 
 
 
 
 
 
77
  parser.add_argument('--out-dir', type=Path, default=Path('genbank-predictions'))
78
  args = parser.parse_args()
79
  if not 0 <= args.threshold <= 1:
80
  parser.error('Threshold must be between 0 and 1.')
 
 
 
 
81
  if not args.input.is_file():
82
  parser.error('Input file does not exist.')
83
  if args.device == 'cuda' and not torch.cuda.is_available():
@@ -87,35 +136,51 @@ def main():
87
  dtype = torch.float32
88
  if args.device == 'cuda':
89
  dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
 
 
 
 
 
 
 
 
 
90
  tokenizer = AutoTokenizer.from_pretrained(args.model, revision=args.revision, trust_remote_code=True)
91
  model = AutoModelForTokenClassification.from_pretrained(
92
  args.model, revision=args.revision, trust_remote_code=True,
93
- dtype=dtype, attn_implementation='sdpa',
94
  ).to(args.device).eval()
95
- if (model.config.k, model.config.num_prediction_heads, model.config.num_labels) != (6, 2, 2):
96
  raise ValueError('Expected a 6-mer, two-strand binary CDS model.')
97
  args.out_dir.mkdir(parents=True, exist_ok=True)
98
  summary = {'model':args.model, 'revision_requested':args.revision,
99
  'revision_resolved':getattr(model.config, '_commit_hash', None),
100
  'input_file':str(args.input), 'input_format':args.format,
101
- 'dtype':str(dtype), 'threshold':args.threshold,
102
- 'window_bp':WINDOW, 'stride_bp':STRIDE,
 
 
103
  'coordinates':'Array index i corresponds to record base i+1 on both strands.',
104
  'records':[]}
105
- opener = gzip.open if args.input.suffix.lower() == '.gz' else open
106
- with opener(args.input, 'rt') as handle:
107
- for index, record in enumerate(SeqIO.parse(handle, args.format)):
108
- if not len(record):
109
- raise ValueError(f'Empty sequence in record {record.id}')
110
- arrays, windows = predict_record(str(record.seq), model, tokenizer, args.device, args.threshold)
111
- filename = f'{index:04d}_{safe_name(record.id)}_{safe_name(record.name)}.npz'
112
- np.savez_compressed(args.out_dir / filename, **arrays)
113
- summary['records'].append({'accession':record.id, 'name':record.name,
114
- 'length':len(record), 'windows':windows,
115
- 'valid_bases':int(arrays['valid'].sum()), 'file':filename})
116
- print(f'Saved {filename}: {len(record):,} bases, {windows} windows', flush=True)
117
- if not summary['records']:
118
- raise ValueError('No sequence records found; check the input format.')
 
 
 
 
 
119
  (args.out_dir / 'summary.json').write_text(json.dumps(summary, indent=2)+'\n')
120
 
121
 
 
11
  from transformers import AutoModelForTokenClassification, AutoTokenizer
12
 
13
  MODEL_ID = 'HuggingFaceBio/Carbon-A-1.2B'
14
+ CONTEXT_TOKENS = 16384
15
+ OVERLAP_TOKENS = 1024
16
+ BATCH_SIZE = 8
17
+ DISPATCH_CHUNK_SIZE = 4
18
+ K = 6
19
 
20
 
21
  def safe_name(value):
22
  return re.sub(r'[^A-Za-z0-9_.-]+', '_', value).strip('._')[:100] or 'record'
23
 
24
 
25
+ def windows_for_sequence(length, window=CONTEXT_TOKENS * K, overlap=OVERLAP_TOKENS * K):
26
+ """(start, length) windows, each a whole number of 6-mers; the last is right-aligned."""
27
+ if length < window:
28
+ windows = [(0, length - length % K)] if length >= K else []
29
+ if length % K and length >= K:
30
+ windows.append((length % K, length - length % K))
31
+ return windows
32
+ windows, start, remaining = [], 0, length
33
+ while True:
34
+ size = min(window, remaining)
35
+ windows.append((start, size))
36
+ if size == remaining:
37
+ return windows
38
+ if remaining - window + overlap < window:
39
+ start, remaining = length - window, window
40
+ else:
41
+ start, remaining = start + window - overlap, remaining - (window - overlap)
42
+
43
+
44
+ def predict_records(sequences, model, tokenizer, device, overlap_tokens=OVERLAP_TOKENS,
45
+ batch_size=BATCH_SIZE, dispatch_chunk_size=DISPATCH_CHUNK_SIZE):
46
+ """Yield (index, probabilities, window_count, num_windows) for each sequence.
47
+
48
+ Probabilities have shape (2, length): forward, reverse strand. Overlapping windows are
49
+ averaged; sequences shorter than six bases get probability 0. Sequences are processed
50
+ longest first, in groups of `dispatch_chunk_size`, with full-length and padded windows
51
+ batched separately. Batch composition changes BF16 results slightly, so keep the
52
+ defaults to reproduce Carbon-A's genome-scale predictions.
53
+ """
54
+ sequences = [sequence.upper() for sequence in sequences]
55
+ order = sorted(range(len(sequences)), key=lambda i: len(sequences[i]), reverse=True)
56
+ pad_id = tokenizer.pad_token_id
57
  with torch.inference_mode():
58
+ for chunk_start in range(0, len(order), dispatch_chunk_size):
59
+ chunk = order[chunk_start:chunk_start + dispatch_chunk_size]
60
+ sums = {i: torch.zeros((2, len(sequences[i])), dtype=torch.float32) for i in chunk}
61
+ counts = {i: torch.zeros(len(sequences[i]), dtype=torch.long) for i in chunk}
62
+ num_windows = {}
63
+ full, padded = [], []
64
+
65
+ def run_batch(batch):
66
+ ids = [tokens + [pad_id] * (CONTEXT_TOKENS - len(tokens)) for *_, tokens in batch]
67
+ mask = [[1] * len(tokens) + [0] * (CONTEXT_TOKENS - len(tokens)) for *_, tokens in batch]
68
+ logits = model(input_ids=torch.tensor(ids, device=device),
69
+ attention_mask=torch.tensor(mask, device=device)).logits
70
+ probs = logits.softmax(dim=-1).cpu()
71
+ for row, (i, start, size, _) in enumerate(batch):
72
+ sums[i][:, start:start + size] += probs[row, :2 * size].view(2, size, -1)[:, :, 1].float()
73
+ counts[i][start:start + size] += 1
74
+
75
+ for i in chunk:
76
+ windows = windows_for_sequence(len(sequences[i]), overlap=overlap_tokens * K)
77
+ num_windows[i] = len(windows)
78
+ for start, size in windows:
79
+ tokens = tokenizer(sequences[i][start:start + size], add_special_tokens=False)['input_ids']
80
+ if len(tokens) * K != size:
81
+ raise ValueError(f'Tokenizer produced {len(tokens)} tokens for {size} bases')
82
+ pending = full if len(tokens) == CONTEXT_TOKENS else padded
83
+ pending.append((i, start, size, tokens))
84
+ if len(pending) >= batch_size:
85
+ run_batch(pending[:batch_size])
86
+ del pending[:batch_size]
87
+ for pending in (full, padded):
88
+ while pending:
89
+ run_batch(pending[:batch_size])
90
+ del pending[:batch_size]
91
+
92
+ for i in chunk:
93
+ if num_windows[i] and torch.any(counts[i] == 0):
94
+ raise RuntimeError('Some bases were not covered by any window')
95
+ probs = (sums[i] / counts[i].clamp(min=1).float()).numpy()
96
+ yield i, probs, counts[i].numpy().astype(np.uint32), num_windows[i]
97
+
98
+
99
+ def predict_record(sequence, model, tokenizer, device, overlap_tokens=OVERLAP_TOKENS):
100
+ """Return (probabilities, window_count, num_windows) for one sequence; see predict_records."""
101
+ _, probs, counts, num_windows = next(predict_records([sequence], model, tokenizer, device,
102
+ overlap_tokens))
103
+ return probs, counts, num_windows
104
 
105
 
106
  def main():
 
111
  parser.add_argument('--revision', default='main', help='Use a model commit for repeatable runs.')
112
  parser.add_argument('--device', choices=['cuda', 'cpu'], default='cuda')
113
  parser.add_argument('--threshold', type=float, default=0.5)
114
+ parser.add_argument('--overlap-length', type=int, default=OVERLAP_TOKENS,
115
+ help='Overlap between consecutive windows, in 6-mer tokens.')
116
+ parser.add_argument('--batch-size', type=int, default=BATCH_SIZE,
117
+ help='Windows per forward pass; lower it if GPU memory is short.')
118
+ parser.add_argument('--dispatch-chunk-size', type=int, default=DISPATCH_CHUNK_SIZE,
119
+ help='Records whose windows are batched together.')
120
+ parser.add_argument('--attn-implementation', choices=['sdpa', 'flash_attention_2'], default='sdpa',
121
+ help='flash_attention_2 requires the flash-attn package.')
122
  parser.add_argument('--out-dir', type=Path, default=Path('genbank-predictions'))
123
  args = parser.parse_args()
124
  if not 0 <= args.threshold <= 1:
125
  parser.error('Threshold must be between 0 and 1.')
126
+ if not 0 <= args.overlap_length < CONTEXT_TOKENS:
127
+ parser.error(f'Overlap length must be between 0 and {CONTEXT_TOKENS - 1} tokens.')
128
+ if args.batch_size < 1 or args.dispatch_chunk_size < 1:
129
+ parser.error('Batch size and dispatch chunk size must be positive.')
130
  if not args.input.is_file():
131
  parser.error('Input file does not exist.')
132
  if args.device == 'cuda' and not torch.cuda.is_available():
 
136
  dtype = torch.float32
137
  if args.device == 'cuda':
138
  dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
139
+ opener = gzip.open if args.input.suffix.lower() == '.gz' else open
140
+ with opener(args.input, 'rt') as handle:
141
+ records = [(record.id, record.name, str(record.seq).upper())
142
+ for record in SeqIO.parse(handle, args.format)]
143
+ if not records:
144
+ parser.error('No sequence records found; check the input format.')
145
+ for accession, _, sequence in records:
146
+ if not sequence:
147
+ raise ValueError(f'Empty sequence in record {accession}')
148
  tokenizer = AutoTokenizer.from_pretrained(args.model, revision=args.revision, trust_remote_code=True)
149
  model = AutoModelForTokenClassification.from_pretrained(
150
  args.model, revision=args.revision, trust_remote_code=True,
151
+ dtype=dtype, attn_implementation=args.attn_implementation,
152
  ).to(args.device).eval()
153
+ if (model.config.k, model.config.num_prediction_heads, model.config.num_labels) != (K, 2, 2):
154
  raise ValueError('Expected a 6-mer, two-strand binary CDS model.')
155
  args.out_dir.mkdir(parents=True, exist_ok=True)
156
  summary = {'model':args.model, 'revision_requested':args.revision,
157
  'revision_resolved':getattr(model.config, '_commit_hash', None),
158
  'input_file':str(args.input), 'input_format':args.format,
159
+ 'dtype':str(dtype), 'attn_implementation':args.attn_implementation,
160
+ 'threshold':args.threshold, 'window_bp':CONTEXT_TOKENS * K,
161
+ 'overlap_bp':args.overlap_length * K, 'batch_size':args.batch_size,
162
+ 'dispatch_chunk_size':args.dispatch_chunk_size,
163
  'coordinates':'Array index i corresponds to record base i+1 on both strands.',
164
  'records':[]}
165
+ entries = {}
166
+ predictions = predict_records([sequence for *_, sequence in records], model, tokenizer,
167
+ args.device, args.overlap_length, args.batch_size,
168
+ args.dispatch_chunk_size)
169
+ for index, probs, counts, windows in predictions:
170
+ accession, name, sequence = records[index]
171
+ calls = (probs >= args.threshold).astype(np.int8)
172
+ ambiguous = ~np.isin(np.frombuffer(sequence.encode(), dtype=np.uint8),
173
+ np.frombuffer(b'ACGT', dtype=np.uint8))
174
+ arrays = {'cds_probability_plus': probs[0], 'cds_probability_minus': probs[1],
175
+ 'cds_label_plus': calls[0], 'cds_label_minus': calls[1],
176
+ 'cds_label_any': calls.max(axis=0), 'ambiguous': ambiguous,
177
+ 'window_count': counts}
178
+ filename = f'{index:04d}_{safe_name(accession)}_{safe_name(name)}.npz'
179
+ np.savez_compressed(args.out_dir / filename, **arrays)
180
+ entries[index] = {'accession':accession, 'name':name, 'length':len(sequence),
181
+ 'windows':windows, 'ambiguous_bases':int(ambiguous.sum()), 'file':filename}
182
+ print(f'Saved {filename}: {len(sequence):,} bases, {windows} windows', flush=True)
183
+ summary['records'] = [entries[index] for index in sorted(entries)]
184
  (args.out_dir / 'summary.json').write_text(json.dumps(summary, indent=2)+'\n')
185
 
186