faisalAI27 commited on
Commit
f66847f
·
1 Parent(s): a5ebf39

heavy training

Browse files
.gitignore CHANGED
@@ -2,10 +2,12 @@
2
  .env
3
  .env.*
4
  !.env.example
 
5
 
6
  # Python
7
  __pycache__/
8
  *.py[cod]
 
9
  *.pyo
10
  *.pyd
11
  .Python
@@ -39,6 +41,7 @@ drive/
39
  wandb/
40
  lightning_logs/
41
  runs/
 
42
 
43
  # Genomics data and model artifacts
44
  data/raw/
@@ -67,6 +70,19 @@ outputs/
67
  artifacts/
68
  training/data/
69
  training/output/
 
 
 
 
 
 
 
 
 
 
 
 
 
70
 
71
  # OS/editor
72
  .DS_Store
 
2
  .env
3
  .env.*
4
  !.env.example
5
+ .env.local
6
 
7
  # Python
8
  __pycache__/
9
  *.py[cod]
10
+ *.pyc
11
  *.pyo
12
  *.pyd
13
  .Python
 
41
  wandb/
42
  lightning_logs/
43
  runs/
44
+ checkpoints/
45
 
46
  # Genomics data and model artifacts
47
  data/raw/
 
70
  artifacts/
71
  training/data/
72
  training/output/
73
+ training/outputs/
74
+ training/cache/
75
+ training/data_cache/
76
+ training/csv_files_large/
77
+ training/csv_files_large_alt/
78
+ training/csv_files_10k/
79
+ training/csv_files_10k_alt/
80
+ training/csv_files_20k/
81
+ training/csv_files_20k_alt/
82
+ training/local_dnabert2_patch/
83
+ *.zip
84
+ *.safetensors
85
+ *.bin
86
 
87
  # OS/editor
88
  .DS_Store
training/COLAB_README.md ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Colab Heavy Training Guide
2
+
3
+ This workflow is for research and education only. The model output is not a
4
+ medical diagnosis and should not be used for clinical decisions.
5
+
6
+ ## Steps
7
+
8
+ 1. Push this repository to GitHub.
9
+ 2. Open `training/colab_dnabert2_heavy_training.ipynb` in Google Colab.
10
+ 3. In the repo clone cell, replace:
11
+
12
+ ```python
13
+ REPO_URL = "PASTE_YOUR_GITHUB_REPO_URL_HERE"
14
+ ```
15
+
16
+ with your GitHub repository URL.
17
+
18
+ 4. Run the notebook cells one by one.
19
+ 5. Start with the 10k dataset:
20
+
21
+ ```bash
22
+ python training/prepare_larger_clinvar_dataset.py --target_total 10000
23
+ ```
24
+
25
+ 6. Only try the 20k dataset after the 10k dataset prepares and trains correctly.
26
+ 7. Save the final model and metrics to Google Drive from the notebook.
27
+
28
+ ## Notes
29
+
30
+ - Use a Colab GPU runtime.
31
+ - Do not add API keys or secrets to the notebook.
32
+ - Dataset preparation uses caching and progress files, so reruns can resume.
33
+ - The training script uses CUDA when available, MPS on Mac, and CPU as a slow fallback.
training/README.md CHANGED
@@ -1,16 +1,19 @@
1
  # Training
2
 
3
- This folder contains the Google Colab training pipeline for fine-tuning DNABERT-2 on ClinVar-derived GRCh38 examples.
4
 
5
- Training should be run in Google Colab or another dedicated GPU notebook environment, not on the local development machine.
6
 
7
  ## Contents
8
 
9
  - `01_prepare_clinvar_dataset.ipynb`: Colab notebook for downloading ClinVar GRCh38 VCF data and preparing binary SNV/small-indel CSV splits with sequence columns.
10
  - `colab_dnabert2_clinvar_finetune.ipynb`: Colab notebook for fine-tuning DNABERT-2 from `train_with_sequences.csv`, `val_with_sequences.csv`, and `test_with_sequences.csv`.
11
  - `requirements-colab.txt`: Python packages for the notebook.
 
12
  - `scripts/prepare_clinvar_dataset.py`: converts ClinVar GRCh38 VCF records into sequence classification examples.
13
  - `scripts/train_dnabert2_classifier.py`: fine-tunes DNABERT-2 with Hugging Face Transformers.
 
 
14
  - `utils/clinvar_parser.py`: manual gzip VCF parsing and variant filtering helpers.
15
  - `utils/label_utils.py`: ClinVar clinical-significance label mapping helpers.
16
  - `utils/sequence_fetcher.py`: UCSC API and optional local FASTA sequence extraction helpers.
@@ -25,6 +28,70 @@ Use GRCh38 consistently:
25
 
26
  The fine-tuning notebook expects the sequence CSV files from the preparation notebook.
27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  ## Colab Flow
29
 
30
  1. Open `01_prepare_clinvar_dataset.ipynb` in Google Colab.
 
1
  # Training
2
 
3
+ This folder contains the training pipeline for fine-tuning DNABERT-2 on ClinVar-derived GRCh38 examples.
4
 
5
+ The original path is Google Colab, but this project also includes Mac-friendly local scripts for small research runs and smoke tests.
6
 
7
  ## Contents
8
 
9
  - `01_prepare_clinvar_dataset.ipynb`: Colab notebook for downloading ClinVar GRCh38 VCF data and preparing binary SNV/small-indel CSV splits with sequence columns.
10
  - `colab_dnabert2_clinvar_finetune.ipynb`: Colab notebook for fine-tuning DNABERT-2 from `train_with_sequences.csv`, `val_with_sequences.csv`, and `test_with_sequences.csv`.
11
  - `requirements-colab.txt`: Python packages for the notebook.
12
+ - `requirements-mac.txt`: Python packages for local Mac training.
13
  - `scripts/prepare_clinvar_dataset.py`: converts ClinVar GRCh38 VCF records into sequence classification examples.
14
  - `scripts/train_dnabert2_classifier.py`: fine-tunes DNABERT-2 with Hugging Face Transformers.
15
+ - `train_smoke_test.py`: runs a tiny DNABERT-2 local smoke test.
16
+ - `train_local_dnabert2.py`: runs Mac-friendly local DNABERT-2 fine-tuning.
17
  - `utils/clinvar_parser.py`: manual gzip VCF parsing and variant filtering helpers.
18
  - `utils/label_utils.py`: ClinVar clinical-significance label mapping helpers.
19
  - `utils/sequence_fetcher.py`: UCSC API and optional local FASTA sequence extraction helpers.
 
28
 
29
  The fine-tuning notebook expects the sequence CSV files from the preparation notebook.
30
 
31
+ ## Dataset Check
32
+
33
+ Before local training, verify the prepared CSV files:
34
+
35
+ ```bash
36
+ python training/check_dataset.py
37
+ ```
38
+
39
+ The checker expects `train_with_sequences.csv`, `val_with_sequences.csv`, and `test_with_sequences.csv` in `data/processed/`. It also falls back to `training/csv_files/` for sample files.
40
+
41
+ ## Mac Local Setup
42
+
43
+ For local Mac training setup, see:
44
+
45
+ ```text
46
+ training/setup_mac.md
47
+ ```
48
+
49
+ Check the available PyTorch device with:
50
+
51
+ ```bash
52
+ python training/check_device.py
53
+ ```
54
+
55
+ Run a tiny smoke test first:
56
+
57
+ ```bash
58
+ python training/train_smoke_test.py
59
+ ```
60
+
61
+ Then run local DNABERT-2 fine-tuning:
62
+
63
+ ```bash
64
+ python training/train_local_dnabert2.py
65
+ ```
66
+
67
+ By default, local training uses `training/csv_files_large_alt/` when the 5,000-row alternate-sequence CSVs exist. It uses all available rows, trains for 5 epochs, freezes the DNABERT-2 encoder, applies class weights, crops long sequences around variant index 512, and tunes the final classification threshold on the validation split.
68
+
69
+ Local Mac evaluation is memory-safe by default: epoch evaluation is disabled, validation/test prediction runs in small batches, and final metrics use an evaluation subset of 300 rows per split. To evaluate all validation/test rows after training, pass `--eval_subset_size 0`.
70
+
71
+ To train the classifier plus the last encoder layer on Mac, use:
72
+
73
+ ```bash
74
+ python training/train_local_dnabert2.py --unfreeze_last_n_layers 1
75
+ ```
76
+
77
+ To build a larger balanced ClinVar dataset before training, use:
78
+
79
+ ```bash
80
+ python training/prepare_larger_clinvar_dataset.py
81
+ ```
82
+
83
+ Check the larger dataset before training:
84
+
85
+ ```bash
86
+ python training/check_large_dataset.py
87
+ ```
88
+
89
+ Then train from the larger alternate-sequence CSVs explicitly:
90
+
91
+ ```bash
92
+ python training/train_local_dnabert2.py --train_csv training/csv_files_large_alt/train_with_alt_sequences.csv --val_csv training/csv_files_large_alt/val_with_alt_sequences.csv --test_csv training/csv_files_large_alt/test_with_alt_sequences.csv
93
+ ```
94
+
95
  ## Colab Flow
96
 
97
  1. Open `01_prepare_clinvar_dataset.ipynb` in Google Colab.
training/audit_variant_sequence_encoding.py ADDED
@@ -0,0 +1,416 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Audit whether sequence strings contain REF or ALT alleles near the center.
3
+
4
+ The prepared sequence files were fetched as roughly +/-512 bp around each
5
+ variant, so the variant should begin around index 512 in the sequence string.
6
+ This script checks that assumption for SNVs and small indels.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ import pandas as pd
16
+
17
+
18
+ ORIGINAL_SPLIT_FILES = {
19
+ "train": "train_with_sequences.csv",
20
+ "val": "val_with_sequences.csv",
21
+ "test": "test_with_sequences.csv",
22
+ }
23
+
24
+ ALT_SPLIT_FILES = {
25
+ "train": "train_with_alt_sequences.csv",
26
+ "val": "val_with_alt_sequences.csv",
27
+ "test": "test_with_alt_sequences.csv",
28
+ }
29
+
30
+ REQUIRED_COLUMNS = {"sequence", "REF", "ALT"}
31
+ OPTIONAL_EXAMPLE_COLUMNS = [
32
+ "variant_id",
33
+ "CHROM",
34
+ "POS",
35
+ "CLNHGVS",
36
+ "gene_symbol",
37
+ "GENEINFO",
38
+ ]
39
+
40
+ CENTER_INDEX = 512
41
+ SNIPPET_FLANK = 20
42
+ MAX_INDEL_SIZE = 50
43
+ VALID_BASES = set("ACGTN")
44
+
45
+
46
+ @dataclass
47
+ class AuditCounts:
48
+ total_rows_seen: int = 0
49
+ total_rows_checked: int = 0
50
+ snv_rows_checked: int = 0
51
+ indel_rows_checked: int = 0
52
+ reference_matches: int = 0
53
+ alternate_matches: int = 0
54
+ mismatches: int = 0
55
+ skipped_missing_values: int = 0
56
+ skipped_short_sequence: int = 0
57
+ skipped_multiple_alt: int = 0
58
+ skipped_symbolic_alt: int = 0
59
+ skipped_non_acgtn_allele: int = 0
60
+ skipped_not_snv_or_small_indel: int = 0
61
+
62
+ def add(self, other: "AuditCounts") -> None:
63
+ for field_name in self.__dataclass_fields__:
64
+ setattr(self, field_name, getattr(self, field_name) + getattr(other, field_name))
65
+
66
+
67
+ @dataclass
68
+ class DatasetChoice:
69
+ data_dir: Path
70
+ split_files: dict[str, str]
71
+ is_alternate_dataset: bool
72
+
73
+
74
+ def parse_args() -> argparse.Namespace:
75
+ parser = argparse.ArgumentParser(description=__doc__)
76
+ parser.add_argument(
77
+ "--data-dir",
78
+ type=Path,
79
+ default=None,
80
+ help=(
81
+ "Directory containing train/val/test CSV files. Defaults to "
82
+ "training/csv_files_20k_alt, training/csv_files_10k_alt, "
83
+ "training/csv_files_large_alt, training/csv_files_alt, "
84
+ "training/csv_files_20k, training/csv_files_10k, "
85
+ "training/csv_files_large, training/csv_files, then data/processed."
86
+ ),
87
+ )
88
+ parser.add_argument(
89
+ "--center-index",
90
+ type=int,
91
+ default=CENTER_INDEX,
92
+ help="0-based index where the variant is expected to start. Default: 512.",
93
+ )
94
+ return parser.parse_args()
95
+
96
+
97
+ def project_root() -> Path:
98
+ return Path(__file__).resolve().parents[1]
99
+
100
+
101
+ def has_all_files(directory: Path, split_files: dict[str, str]) -> bool:
102
+ return all((directory / filename).exists() for filename in split_files.values())
103
+
104
+
105
+ def choose_dataset(root: Path, requested_dir: Path | None) -> DatasetChoice:
106
+ if requested_dir is not None:
107
+ data_dir = requested_dir.expanduser().resolve()
108
+ if has_all_files(data_dir, ALT_SPLIT_FILES):
109
+ return DatasetChoice(data_dir, ALT_SPLIT_FILES, True)
110
+ if has_all_files(data_dir, ORIGINAL_SPLIT_FILES):
111
+ return DatasetChoice(data_dir, ORIGINAL_SPLIT_FILES, False)
112
+ raise FileNotFoundError(
113
+ "The requested directory does not contain a complete alternate or original dataset:\n"
114
+ f"{data_dir}"
115
+ )
116
+
117
+ candidates: list[tuple[Path, dict[str, str], bool]] = [
118
+ (root / "training" / "csv_files_20k_alt", ALT_SPLIT_FILES, True),
119
+ (root / "training" / "csv_files_10k_alt", ALT_SPLIT_FILES, True),
120
+ (root / "training" / "csv_files_large_alt", ALT_SPLIT_FILES, True),
121
+ (root / "training" / "csv_files_alt", ALT_SPLIT_FILES, True),
122
+ (root / "training" / "csv_files_20k", ORIGINAL_SPLIT_FILES, False),
123
+ (root / "training" / "csv_files_10k", ORIGINAL_SPLIT_FILES, False),
124
+ (root / "training" / "csv_files_large", ORIGINAL_SPLIT_FILES, False),
125
+ (root / "training" / "csv_files", ORIGINAL_SPLIT_FILES, False),
126
+ (root / "data" / "processed", ORIGINAL_SPLIT_FILES, False),
127
+ ]
128
+
129
+ for directory, split_files, is_alternate_dataset in candidates:
130
+ if has_all_files(directory, split_files):
131
+ return DatasetChoice(directory, split_files, is_alternate_dataset)
132
+
133
+ searched = "\n".join(str(path) for path, _split_files, _is_alt in candidates)
134
+ raise FileNotFoundError(
135
+ "Could not find a complete alternate or original sequence dataset.\n"
136
+ f"Searched:\n{searched}"
137
+ )
138
+
139
+
140
+ def normalize_sequence(value: object) -> str:
141
+ return str(value).strip().upper()
142
+
143
+
144
+ def normalize_allele(value: object) -> str:
145
+ return str(value).strip().upper()
146
+
147
+
148
+ def is_symbolic_alt(alt: str) -> bool:
149
+ return alt.startswith("<") or alt.endswith(">") or "[" in alt or "]" in alt
150
+
151
+
152
+ def is_acgtn(value: str) -> bool:
153
+ return bool(value) and set(value).issubset(VALID_BASES)
154
+
155
+
156
+ def is_snv(ref: str, alt: str) -> bool:
157
+ return len(ref) == 1 and len(alt) == 1
158
+
159
+
160
+ def is_small_indel(ref: str, alt: str) -> bool:
161
+ return ref != alt and abs(len(ref) - len(alt)) <= MAX_INDEL_SIZE
162
+
163
+
164
+ def center_snippet(sequence: str, center_index: int) -> str:
165
+ start = max(0, center_index - SNIPPET_FLANK)
166
+ end = min(len(sequence), center_index + SNIPPET_FLANK + 1)
167
+ snippet = sequence[start:end]
168
+ marker_position = center_index - start
169
+ if 0 <= marker_position < len(snippet):
170
+ return snippet[:marker_position] + "[" + snippet[marker_position] + "]" + snippet[marker_position + 1 :]
171
+ return snippet
172
+
173
+
174
+ def classify_row(row: pd.Series, center_index: int, is_alternate_dataset: bool) -> tuple[str, str, str]:
175
+ sequence = normalize_sequence(row["sequence"])
176
+ ref = normalize_allele(row["REF"])
177
+ alt = normalize_allele(row["ALT"])
178
+
179
+ if not sequence or not ref or not alt or sequence == "NAN" or ref == "NAN" or alt == "NAN":
180
+ return "skipped_missing_values", "", ""
181
+
182
+ if "," in alt:
183
+ return "skipped_multiple_alt", "", ""
184
+
185
+ if is_symbolic_alt(alt):
186
+ return "skipped_symbolic_alt", "", ""
187
+
188
+ if not is_acgtn(ref) or not is_acgtn(alt):
189
+ return "skipped_non_acgtn_allele", "", ""
190
+
191
+ if len(sequence) <= center_index:
192
+ return "skipped_short_sequence", "", ""
193
+
194
+ if is_snv(ref, alt):
195
+ observed = sequence[center_index]
196
+ if is_alternate_dataset:
197
+ if observed == alt:
198
+ return "alternate_match_snv", observed, center_snippet(sequence, center_index)
199
+ if observed == ref:
200
+ return "reference_match_snv", observed, center_snippet(sequence, center_index)
201
+ else:
202
+ if observed == ref:
203
+ return "reference_match_snv", observed, center_snippet(sequence, center_index)
204
+ if observed == alt:
205
+ return "alternate_match_snv", observed, center_snippet(sequence, center_index)
206
+ return "mismatch_snv", observed, center_snippet(sequence, center_index)
207
+
208
+ if is_small_indel(ref, alt):
209
+ expected_allele = alt if is_alternate_dataset else ref
210
+ if len(sequence) < center_index + len(expected_allele):
211
+ return "skipped_short_sequence", "", ""
212
+
213
+ ref_window = sequence[center_index : center_index + len(ref)] if len(sequence) >= center_index + len(ref) else ""
214
+ alt_window = sequence[center_index : center_index + len(alt)] if len(sequence) >= center_index + len(alt) else ""
215
+
216
+ if is_alternate_dataset:
217
+ if alt_window == alt:
218
+ return "alternate_match_indel", alt_window, center_snippet(sequence, center_index)
219
+ if ref_window == ref:
220
+ return "reference_match_indel", ref_window, center_snippet(sequence, center_index)
221
+ return "mismatch_indel", alt_window, center_snippet(sequence, center_index)
222
+
223
+ if ref_window == ref:
224
+ return "reference_match_indel", ref_window, center_snippet(sequence, center_index)
225
+ if alt_window == alt:
226
+ return "alternate_match_indel", alt_window, center_snippet(sequence, center_index)
227
+ return "mismatch_indel", ref_window, center_snippet(sequence, center_index)
228
+
229
+ return "skipped_not_snv_or_small_indel", "", ""
230
+
231
+
232
+ def update_counts(counts: AuditCounts, status: str) -> None:
233
+ if status.startswith("skipped_"):
234
+ current_value = getattr(counts, status)
235
+ setattr(counts, status, current_value + 1)
236
+ return
237
+
238
+ counts.total_rows_checked += 1
239
+
240
+ if status.endswith("_snv"):
241
+ counts.snv_rows_checked += 1
242
+ elif status.endswith("_indel"):
243
+ counts.indel_rows_checked += 1
244
+
245
+ if status.startswith("reference_match"):
246
+ counts.reference_matches += 1
247
+ elif status.startswith("alternate_match"):
248
+ counts.alternate_matches += 1
249
+ elif status.startswith("mismatch"):
250
+ counts.mismatches += 1
251
+
252
+
253
+ def build_example(row: pd.Series, split_name: str, status: str, observed: str, snippet: str) -> dict[str, object]:
254
+ example = {
255
+ "split": split_name,
256
+ "status": status,
257
+ "REF": normalize_allele(row["REF"]),
258
+ "ALT": normalize_allele(row["ALT"]),
259
+ "observed_at_center": observed,
260
+ "center_sequence_snippet": snippet,
261
+ }
262
+
263
+ for column in OPTIONAL_EXAMPLE_COLUMNS:
264
+ if column in row.index:
265
+ example[column] = row[column]
266
+
267
+ return example
268
+
269
+
270
+ def audit_split(
271
+ split_name: str,
272
+ csv_path: Path,
273
+ center_index: int,
274
+ is_alternate_dataset: bool,
275
+ ) -> tuple[AuditCounts, list[dict[str, object]]]:
276
+ print("=" * 80)
277
+ print(f"{split_name.upper()} SPLIT")
278
+ print("=" * 80)
279
+ print(f"Path: {csv_path}")
280
+
281
+ df = pd.read_csv(csv_path)
282
+ counts = AuditCounts(total_rows_seen=len(df))
283
+ examples: list[dict[str, object]] = []
284
+
285
+ missing_columns = sorted(REQUIRED_COLUMNS - set(df.columns))
286
+ if missing_columns:
287
+ print(f"ERROR: missing required columns: {missing_columns}")
288
+ print()
289
+ return counts, examples
290
+
291
+ for _, row in df.iterrows():
292
+ status, observed, snippet = classify_row(row, center_index, is_alternate_dataset)
293
+ update_counts(counts, status)
294
+
295
+ if len(examples) < 10 and not status.startswith("skipped_"):
296
+ examples.append(build_example(row, split_name, status, observed, snippet))
297
+
298
+ print_counts(counts)
299
+ print()
300
+ return counts, examples
301
+
302
+
303
+ def print_counts(counts: AuditCounts) -> None:
304
+ print(f"Rows seen: {counts.total_rows_seen:,}")
305
+ print(f"Total rows checked: {counts.total_rows_checked:,}")
306
+ print(f"SNV rows checked: {counts.snv_rows_checked:,}")
307
+ print(f"Indel rows checked: {counts.indel_rows_checked:,}")
308
+ print(f"Reference allele matches: {counts.reference_matches:,}")
309
+ print(f"Alternate allele matches: {counts.alternate_matches:,}")
310
+ print(f"Mismatches: {counts.mismatches:,}")
311
+ print("Skipped rows:")
312
+ print(f" missing sequence/REF/ALT: {counts.skipped_missing_values:,}")
313
+ print(f" sequence too short for center check: {counts.skipped_short_sequence:,}")
314
+ print(f" multiple ALT alleles: {counts.skipped_multiple_alt:,}")
315
+ print(f" symbolic ALT allele: {counts.skipped_symbolic_alt:,}")
316
+ print(f" non-ACGTN allele: {counts.skipped_non_acgtn_allele:,}")
317
+ print(f" not SNV or small indel: {counts.skipped_not_snv_or_small_indel:,}")
318
+
319
+
320
+ def print_examples(examples: list[dict[str, object]]) -> None:
321
+ print("=" * 80)
322
+ print("FIRST 10 CHECKED EXAMPLES")
323
+ print("=" * 80)
324
+
325
+ if not examples:
326
+ print("No checked examples available.")
327
+ print()
328
+ return
329
+
330
+ examples_df = pd.DataFrame(examples)
331
+ display_columns = [
332
+ column
333
+ for column in [
334
+ "split",
335
+ "variant_id",
336
+ "CHROM",
337
+ "POS",
338
+ "gene_symbol",
339
+ "REF",
340
+ "ALT",
341
+ "status",
342
+ "observed_at_center",
343
+ "center_sequence_snippet",
344
+ "CLNHGVS",
345
+ "GENEINFO",
346
+ ]
347
+ if column in examples_df.columns
348
+ ]
349
+
350
+ with pd.option_context("display.max_columns", None, "display.width", 220, "display.max_colwidth", 90):
351
+ print(examples_df[display_columns].to_string(index=False))
352
+ print()
353
+
354
+
355
+ def print_conclusion(counts: AuditCounts) -> None:
356
+ print("=" * 80)
357
+ print("OVERALL SUMMARY")
358
+ print("=" * 80)
359
+ print_counts(counts)
360
+
361
+ checked = counts.total_rows_checked
362
+ if checked == 0:
363
+ conclusion = "Could not determine clearly."
364
+ else:
365
+ ref_fraction = counts.reference_matches / checked
366
+ alt_fraction = counts.alternate_matches / checked
367
+ print(f"Reference match fraction: {ref_fraction:.2%}")
368
+ print(f"Alternate match fraction: {alt_fraction:.2%}")
369
+
370
+ if ref_fraction >= 0.80 and counts.reference_matches > counts.alternate_matches:
371
+ conclusion = "The sequence appears to be reference sequence."
372
+ elif alt_fraction >= 0.80 and counts.alternate_matches > counts.reference_matches:
373
+ conclusion = "The sequence appears to contain alternate alleles."
374
+ else:
375
+ conclusion = "Could not determine clearly."
376
+
377
+ print()
378
+ print(f"Conclusion: {conclusion}")
379
+
380
+
381
+ def main() -> None:
382
+ args = parse_args()
383
+ root = project_root()
384
+ dataset = choose_dataset(root, args.data_dir)
385
+
386
+ print("Variant sequence encoding audit")
387
+ print(f"Selected data directory: {dataset.data_dir}")
388
+ print(f"Auditing alternate dataset: {dataset.is_alternate_dataset}")
389
+ print(f"Expected variant start index: {args.center_index}")
390
+ print()
391
+
392
+ total_counts = AuditCounts()
393
+ all_examples: list[dict[str, object]] = []
394
+
395
+ for split_name, filename in dataset.split_files.items():
396
+ csv_path = dataset.data_dir / filename
397
+ if not csv_path.exists():
398
+ raise FileNotFoundError(f"Missing required CSV file: {csv_path}")
399
+
400
+ split_counts, split_examples = audit_split(
401
+ split_name,
402
+ csv_path,
403
+ args.center_index,
404
+ dataset.is_alternate_dataset,
405
+ )
406
+ total_counts.add(split_counts)
407
+ remaining_example_slots = 10 - len(all_examples)
408
+ if remaining_example_slots > 0:
409
+ all_examples.extend(split_examples[:remaining_example_slots])
410
+
411
+ print_examples(all_examples)
412
+ print_conclusion(total_counts)
413
+
414
+
415
+ if __name__ == "__main__":
416
+ main()
training/build_alt_sequence_dataset.py ADDED
@@ -0,0 +1,316 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Build alternate-allele sequence CSVs from reference sequence CSVs.
3
+
4
+ The current prepared CSVs contain reference genome sequence around each
5
+ variant. This script verifies that REF is present at index 512, replaces that
6
+ REF allele with ALT, and writes new CSV files with mutated/alternate sequences.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ import pandas as pd
16
+
17
+
18
+ FLANK_SIZE = 512
19
+ VALID_BASES = set("ACGTN")
20
+
21
+ SPLIT_FILES = {
22
+ "train": ("train_with_sequences.csv", "train_with_alt_sequences.csv"),
23
+ "val": ("val_with_sequences.csv", "val_with_alt_sequences.csv"),
24
+ "test": ("test_with_sequences.csv", "test_with_alt_sequences.csv"),
25
+ }
26
+
27
+ REQUIRED_COLUMNS = {"sequence", "REF", "ALT", "label"}
28
+ USEFUL_METADATA_COLUMNS = [
29
+ "variant_id",
30
+ "CHROM",
31
+ "POS",
32
+ "ID",
33
+ "variant_type",
34
+ "gene_symbol",
35
+ "GENEINFO",
36
+ "CLNSIG",
37
+ "CLNHGVS",
38
+ "CLNVC",
39
+ "label_name",
40
+ ]
41
+
42
+
43
+ @dataclass
44
+ class BuildStats:
45
+ input_rows: int = 0
46
+ successful_rows: int = 0
47
+ failed_rows: int = 0
48
+ snv_count: int = 0
49
+ indel_count: int = 0
50
+ skipped_missing_values: int = 0
51
+ skipped_multiple_alt: int = 0
52
+ skipped_symbolic_alt: int = 0
53
+ skipped_non_acgtn: int = 0
54
+ skipped_short_sequence: int = 0
55
+ failed_ref_mismatch: int = 0
56
+
57
+ def add(self, other: "BuildStats") -> None:
58
+ for field_name in self.__dataclass_fields__:
59
+ setattr(self, field_name, getattr(self, field_name) + getattr(other, field_name))
60
+
61
+
62
+ def parse_args() -> argparse.Namespace:
63
+ parser = argparse.ArgumentParser(description=__doc__)
64
+ parser.add_argument(
65
+ "--data-dir",
66
+ type=Path,
67
+ default=None,
68
+ help="Directory containing the input CSV files. Defaults to data/processed, then training/csv_files.",
69
+ )
70
+ parser.add_argument(
71
+ "--output-dir",
72
+ type=Path,
73
+ default=None,
74
+ help="Directory for alternate-sequence CSV files. Default: training/csv_files_alt.",
75
+ )
76
+ return parser.parse_args()
77
+
78
+
79
+ def project_root() -> Path:
80
+ return Path(__file__).resolve().parents[1]
81
+
82
+
83
+ def resolve_path(root: Path, path: Path) -> Path:
84
+ return path.expanduser().resolve() if path.is_absolute() else (root / path).resolve()
85
+
86
+
87
+ def choose_input_dir(root: Path, requested_dir: Path | None) -> Path:
88
+ if requested_dir is not None:
89
+ return resolve_path(root, requested_dir)
90
+
91
+ candidates = [
92
+ root / "data" / "processed",
93
+ root / "training" / "csv_files",
94
+ ]
95
+
96
+ for directory in candidates:
97
+ if all((directory / input_name).exists() for input_name, _output_name in SPLIT_FILES.values()):
98
+ return directory
99
+
100
+ searched = "\n".join(str(path) for path in candidates)
101
+ raise FileNotFoundError(
102
+ "Could not find all input sequence CSV files.\n"
103
+ f"Searched:\n{searched}"
104
+ )
105
+
106
+
107
+ def choose_output_dir(root: Path, requested_dir: Path | None) -> Path:
108
+ if requested_dir is not None:
109
+ return resolve_path(root, requested_dir)
110
+ return root / "training" / "csv_files_alt"
111
+
112
+
113
+ def normalize_text(value: object) -> str:
114
+ return str(value).strip().upper()
115
+
116
+
117
+ def is_missing_text(value: str) -> bool:
118
+ return value == "" or value == "NAN" or value == "NONE"
119
+
120
+
121
+ def is_symbolic_alt(alt: str) -> bool:
122
+ return alt.startswith("<") or alt.endswith(">") or "[" in alt or "]" in alt
123
+
124
+
125
+ def contains_only_valid_bases(value: str) -> bool:
126
+ return bool(value) and set(value).issubset(VALID_BASES)
127
+
128
+
129
+ def is_snv(ref: str, alt: str) -> bool:
130
+ return len(ref) == 1 and len(alt) == 1
131
+
132
+
133
+ def is_small_indel_or_complex(ref: str, alt: str) -> bool:
134
+ return ref != alt and abs(len(ref) - len(alt)) <= 50
135
+
136
+
137
+ def build_alt_sequence(row: pd.Series) -> tuple[dict[str, object] | None, str]:
138
+ ref_sequence = normalize_text(row["sequence"])
139
+ ref = normalize_text(row["REF"])
140
+ alt = normalize_text(row["ALT"])
141
+
142
+ if is_missing_text(ref_sequence) or is_missing_text(ref) or is_missing_text(alt):
143
+ return None, "skipped_missing_values"
144
+
145
+ if "," in alt:
146
+ return None, "skipped_multiple_alt"
147
+
148
+ if is_symbolic_alt(alt):
149
+ return None, "skipped_symbolic_alt"
150
+
151
+ if not (
152
+ contains_only_valid_bases(ref_sequence)
153
+ and contains_only_valid_bases(ref)
154
+ and contains_only_valid_bases(alt)
155
+ ):
156
+ return None, "skipped_non_acgtn"
157
+
158
+ if len(ref_sequence) < FLANK_SIZE + len(ref):
159
+ return None, "skipped_short_sequence"
160
+
161
+ upstream = ref_sequence[:FLANK_SIZE]
162
+ observed_ref = ref_sequence[FLANK_SIZE : FLANK_SIZE + len(ref)]
163
+ downstream = ref_sequence[FLANK_SIZE + len(ref) :]
164
+
165
+ if observed_ref != ref:
166
+ return None, "failed_ref_mismatch"
167
+
168
+ alt_sequence = upstream + alt + downstream
169
+ output_row = row.to_dict()
170
+ output_row["REF"] = ref
171
+ output_row["ALT"] = alt
172
+ output_row["ref_sequence"] = ref_sequence
173
+ output_row["alt_sequence"] = alt_sequence
174
+ output_row["sequence"] = alt_sequence
175
+ output_row["ref_center"] = observed_ref
176
+ output_row["alt_center"] = alt_sequence[FLANK_SIZE : FLANK_SIZE + len(alt)]
177
+ return output_row, "success"
178
+
179
+
180
+ def ordered_columns(df: pd.DataFrame) -> list[str]:
181
+ important_columns = [
182
+ column
183
+ for column in USEFUL_METADATA_COLUMNS + ["REF", "ALT", "label", "ref_sequence", "alt_sequence", "sequence"]
184
+ if column in df.columns
185
+ ]
186
+ remaining_columns = [column for column in df.columns if column not in important_columns]
187
+ return important_columns + remaining_columns
188
+
189
+
190
+ def update_failed_stat(stats: BuildStats, reason: str) -> None:
191
+ stats.failed_rows += 1
192
+ current_value = getattr(stats, reason)
193
+ setattr(stats, reason, current_value + 1)
194
+
195
+
196
+ def process_split(split_name: str, input_path: Path, output_path: Path) -> tuple[BuildStats, pd.DataFrame]:
197
+ print("=" * 80)
198
+ print(f"{split_name.upper()} SPLIT")
199
+ print("=" * 80)
200
+ print(f"Input: {input_path}")
201
+ print(f"Output: {output_path}")
202
+
203
+ df = pd.read_csv(input_path)
204
+ missing_columns = sorted(REQUIRED_COLUMNS - set(df.columns))
205
+ if missing_columns:
206
+ raise ValueError(f"{input_path} is missing required columns: {missing_columns}")
207
+
208
+ stats = BuildStats(input_rows=len(df))
209
+ output_rows: list[dict[str, object]] = []
210
+
211
+ for _, row in df.iterrows():
212
+ output_row, status = build_alt_sequence(row)
213
+ if output_row is None:
214
+ update_failed_stat(stats, status)
215
+ continue
216
+
217
+ output_rows.append(output_row)
218
+ stats.successful_rows += 1
219
+
220
+ ref = output_row["REF"]
221
+ alt = output_row["ALT"]
222
+ if is_snv(ref, alt):
223
+ stats.snv_count += 1
224
+ elif is_small_indel_or_complex(ref, alt):
225
+ stats.indel_count += 1
226
+
227
+ output_df = pd.DataFrame(output_rows)
228
+ if not output_df.empty:
229
+ output_df = output_df[ordered_columns(output_df)]
230
+
231
+ output_path.parent.mkdir(parents=True, exist_ok=True)
232
+ output_df.to_csv(output_path, index=False)
233
+
234
+ print_split_summary(stats, output_df)
235
+ print_examples(output_df)
236
+ return stats, output_df
237
+
238
+
239
+ def print_split_summary(stats: BuildStats, output_df: pd.DataFrame) -> None:
240
+ print(f"Input rows: {stats.input_rows:,}")
241
+ print(f"Successful rows: {stats.successful_rows:,}")
242
+ print(f"Failed rows: {stats.failed_rows:,}")
243
+ print(f"SNV count: {stats.snv_count:,}")
244
+ print(f"Indel count: {stats.indel_count:,}")
245
+
246
+ if not output_df.empty and "label" in output_df.columns:
247
+ print("Label distribution:")
248
+ print(output_df["label"].value_counts().sort_index().to_string())
249
+ else:
250
+ print("Label distribution: no successful rows")
251
+
252
+ if stats.failed_rows:
253
+ print("Failure reasons:")
254
+ print(f" missing sequence/REF/ALT: {stats.skipped_missing_values:,}")
255
+ print(f" multiple ALT alleles: {stats.skipped_multiple_alt:,}")
256
+ print(f" symbolic ALT allele: {stats.skipped_symbolic_alt:,}")
257
+ print(f" non-ACGTN sequence/REF/ALT: {stats.skipped_non_acgtn:,}")
258
+ print(f" sequence too short: {stats.skipped_short_sequence:,}")
259
+ print(f" REF mismatch at center: {stats.failed_ref_mismatch:,}")
260
+ print()
261
+
262
+
263
+ def print_examples(output_df: pd.DataFrame) -> None:
264
+ print("First 5 examples:")
265
+ if output_df.empty:
266
+ print("No successful rows.")
267
+ print()
268
+ return
269
+
270
+ columns = [column for column in ["variant_id", "REF", "ALT", "label", "ref_center", "alt_center"] if column in output_df.columns]
271
+ examples = output_df[columns].head(5)
272
+ with pd.option_context("display.max_columns", None, "display.width", 160, "display.max_colwidth", 80):
273
+ print(examples.to_string(index=False))
274
+ print()
275
+
276
+
277
+ def print_overall_summary(total_stats: BuildStats, all_outputs: list[pd.DataFrame]) -> None:
278
+ print("=" * 80)
279
+ print("OVERALL SUMMARY")
280
+ print("=" * 80)
281
+
282
+ combined = pd.concat(all_outputs, ignore_index=True) if all_outputs else pd.DataFrame()
283
+ print_split_summary(total_stats, combined)
284
+
285
+
286
+ def main() -> None:
287
+ args = parse_args()
288
+ root = project_root()
289
+ input_dir = choose_input_dir(root, args.data_dir)
290
+ output_dir = choose_output_dir(root, args.output_dir)
291
+
292
+ print("Build alternate-allele sequence dataset")
293
+ print(f"Input directory: {input_dir}")
294
+ print(f"Output directory: {output_dir}")
295
+ print(f"Assumed flank size / variant center index: {FLANK_SIZE}")
296
+ print()
297
+
298
+ total_stats = BuildStats()
299
+ all_outputs: list[pd.DataFrame] = []
300
+
301
+ for split_name, (input_name, output_name) in SPLIT_FILES.items():
302
+ input_path = input_dir / input_name
303
+ output_path = output_dir / output_name
304
+ if not input_path.exists():
305
+ raise FileNotFoundError(f"Missing input CSV file: {input_path}")
306
+
307
+ split_stats, output_df = process_split(split_name, input_path, output_path)
308
+ total_stats.add(split_stats)
309
+ all_outputs.append(output_df)
310
+
311
+ print_overall_summary(total_stats, all_outputs)
312
+ print(f"Alternate-sequence CSV files saved to: {output_dir}")
313
+
314
+
315
+ if __name__ == "__main__":
316
+ main()
training/check_dataset.py ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Check sequence CSV files before local model training.
3
+
4
+ Expected files:
5
+ - train_with_sequences.csv
6
+ - val_with_sequences.csv
7
+ - test_with_sequences.csv
8
+
9
+ The script prefers data/processed/ and falls back to training/csv_files/ so it
10
+ works with both the planned project layout and the current sample CSV location.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ from pathlib import Path
17
+ from urllib.parse import unquote
18
+
19
+ import pandas as pd
20
+
21
+
22
+ SPLIT_FILES = {
23
+ "train": "train_with_sequences.csv",
24
+ "val": "val_with_sequences.csv",
25
+ "test": "test_with_sequences.csv",
26
+ }
27
+
28
+ REQUIRED_COLUMNS = {"sequence", "label"}
29
+ VALID_LABELS = {0, 1}
30
+ LABEL_MEANINGS = {
31
+ 0: "Benign/Likely benign",
32
+ 1: "Pathogenic/Likely pathogenic",
33
+ }
34
+
35
+ DROP_CLNSIG_TERMS = (
36
+ "conflicting",
37
+ "uncertain",
38
+ "risk",
39
+ "association",
40
+ "drug",
41
+ "protective",
42
+ "not provided",
43
+ )
44
+
45
+
46
+ def parse_args() -> argparse.Namespace:
47
+ parser = argparse.ArgumentParser(description=__doc__)
48
+ parser.add_argument(
49
+ "--data-dir",
50
+ type=Path,
51
+ default=None,
52
+ help="Directory containing train/val/test CSV files. Defaults to data/processed, then training/csv_files.",
53
+ )
54
+ parser.add_argument(
55
+ "--min-reasonable-length",
56
+ type=int,
57
+ default=200,
58
+ help="Warn when non-empty sequences are shorter than this length.",
59
+ )
60
+ return parser.parse_args()
61
+
62
+
63
+ def project_root() -> Path:
64
+ return Path(__file__).resolve().parents[1]
65
+
66
+
67
+ def choose_data_dir(root: Path, requested_dir: Path | None) -> Path:
68
+ if requested_dir is not None:
69
+ return requested_dir.expanduser().resolve()
70
+
71
+ preferred = root / "data" / "processed"
72
+ fallback = root / "training" / "csv_files"
73
+
74
+ if all((preferred / filename).exists() for filename in SPLIT_FILES.values()):
75
+ return preferred
76
+ if all((fallback / filename).exists() for filename in SPLIT_FILES.values()):
77
+ return fallback
78
+ return preferred
79
+
80
+
81
+ def find_missing_files(data_dir: Path) -> list[Path]:
82
+ return [data_dir / filename for filename in SPLIT_FILES.values() if not (data_dir / filename).exists()]
83
+
84
+
85
+ def sequence_lengths(sequence_series: pd.Series) -> pd.Series:
86
+ cleaned = sequence_series.fillna("").astype(str).str.strip()
87
+ return cleaned.str.len()
88
+
89
+
90
+ def missing_sequence_count(sequence_series: pd.Series) -> int:
91
+ cleaned = sequence_series.fillna("").astype(str).str.strip()
92
+ return int((cleaned == "").sum())
93
+
94
+
95
+ def normalize_clnsig(value: object) -> str:
96
+ decoded = unquote(str(value))
97
+ return (
98
+ decoded.replace("_", " ")
99
+ .replace("-", " ")
100
+ .replace("/", " ")
101
+ .replace("|", " ")
102
+ .replace(",", " ")
103
+ .strip()
104
+ .lower()
105
+ )
106
+
107
+
108
+ def expected_label_from_clnsig(value: object) -> int | None:
109
+ normalized = normalize_clnsig(value)
110
+ if not normalized or normalized == "." or any(term in normalized for term in DROP_CLNSIG_TERMS):
111
+ return None
112
+
113
+ has_pathogenic = "pathogenic" in normalized
114
+ has_benign = "benign" in normalized
115
+ if has_pathogenic and has_benign:
116
+ return None
117
+ if has_pathogenic:
118
+ return 1
119
+ if has_benign:
120
+ return 0
121
+ return None
122
+
123
+
124
+ def print_label_meaning() -> None:
125
+ print("Label encoding:")
126
+ for label, meaning in LABEL_MEANINGS.items():
127
+ print(f" {label} = {meaning}")
128
+ print()
129
+
130
+
131
+ def audit_split(split_name: str, csv_path: Path, min_reasonable_length: int) -> pd.DataFrame:
132
+ print("=" * 80)
133
+ print(f"{split_name.upper()} SPLIT")
134
+ print("=" * 80)
135
+ print(f"Path: {csv_path}")
136
+
137
+ df = pd.read_csv(csv_path)
138
+ print(f"Rows: {len(df):,}")
139
+ print(f"Columns: {list(df.columns)}")
140
+
141
+ missing_columns = sorted(REQUIRED_COLUMNS - set(df.columns))
142
+ if missing_columns:
143
+ print(f"ERROR: missing required columns: {missing_columns}")
144
+ print()
145
+ return df
146
+
147
+ labels = pd.to_numeric(df["label"], errors="coerce")
148
+ label_distribution = labels.value_counts(dropna=False).sort_index()
149
+ print("Label distribution:")
150
+ print(label_distribution.to_string())
151
+
152
+ invalid_labels = sorted(set(labels.dropna().astype(int).unique()) - VALID_LABELS)
153
+ if invalid_labels:
154
+ print(f"WARNING: unexpected label values found: {invalid_labels}")
155
+ else:
156
+ print("Label check: OK, labels are encoded as 0/1.")
157
+
158
+ if "label_name" in df.columns:
159
+ label_name = df["label_name"].fillna("").astype(str).str.lower()
160
+ label_name_mismatch = int(
161
+ (((labels == 0) & ~label_name.str.contains("benign")) | ((labels == 1) & ~label_name.str.contains("pathogenic"))).sum()
162
+ )
163
+ if label_name_mismatch:
164
+ print(f"WARNING: {label_name_mismatch:,} rows have label_name values that do not match label.")
165
+ else:
166
+ print("Label name check: OK.")
167
+
168
+ if "CLNSIG" in df.columns:
169
+ expected_labels = df["CLNSIG"].apply(expected_label_from_clnsig)
170
+ unsupported_clnsig = int(expected_labels.isna().sum())
171
+ comparable = expected_labels.notna() & labels.notna()
172
+ clnsig_mismatch = int((expected_labels[comparable].astype(int) != labels[comparable].astype(int)).sum())
173
+ print(f"CLNSIG rows that should be excluded before training: {unsupported_clnsig:,}")
174
+ if clnsig_mismatch:
175
+ print(f"WARNING: {clnsig_mismatch:,} rows have CLNSIG values that disagree with label.")
176
+ if unsupported_clnsig:
177
+ examples = df.loc[expected_labels.isna(), "CLNSIG"].dropna().astype(str).unique()[:5]
178
+ print(f"Excluded CLNSIG examples: {list(examples)}")
179
+
180
+ missing_sequences = missing_sequence_count(df["sequence"])
181
+ lengths = sequence_lengths(df["sequence"])
182
+ non_empty_lengths = lengths[lengths > 0]
183
+
184
+ print(f"Missing or empty sequence count: {missing_sequences:,}")
185
+ if non_empty_lengths.empty:
186
+ print("Sequence length min/mean/max: no non-empty sequences")
187
+ else:
188
+ print(
189
+ "Sequence length min/mean/max: "
190
+ f"{int(non_empty_lengths.min())} / "
191
+ f"{float(non_empty_lengths.mean()):.2f} / "
192
+ f"{int(non_empty_lengths.max())}"
193
+ )
194
+ short_count = int((non_empty_lengths < min_reasonable_length).sum())
195
+ if short_count:
196
+ print(f"WARNING: {short_count:,} sequences are shorter than {min_reasonable_length} bp.")
197
+
198
+ print("First 3 example rows:")
199
+ example_columns = [column for column in ["variant_id", "CHROM", "POS", "REF", "ALT", "label", "label_name"] if column in df.columns]
200
+ examples = df[example_columns].head(3).copy()
201
+ examples["sequence_length"] = lengths.head(3).to_list()
202
+ examples["sequence_preview"] = df["sequence"].fillna("").astype(str).str.slice(0, 80).head(3).to_list()
203
+ with pd.option_context("display.max_columns", None, "display.width", 160, "display.max_colwidth", 80):
204
+ print(examples.to_string(index=False))
205
+ print()
206
+ return df
207
+
208
+
209
+ def print_overall_balance(frames: dict[str, pd.DataFrame]) -> None:
210
+ print("=" * 80)
211
+ print("OVERALL CLASS BALANCE")
212
+ print("=" * 80)
213
+
214
+ usable_frames = []
215
+ for split_name, df in frames.items():
216
+ if "label" not in df.columns:
217
+ continue
218
+ temp = df[["label"]].copy()
219
+ temp["split"] = split_name
220
+ usable_frames.append(temp)
221
+
222
+ if not usable_frames:
223
+ print("No label columns found, so balance cannot be checked.")
224
+ return
225
+
226
+ combined = pd.concat(usable_frames, ignore_index=True)
227
+ combined["label"] = pd.to_numeric(combined["label"], errors="coerce")
228
+ counts = combined["label"].value_counts().sort_index()
229
+ print(counts.to_string())
230
+
231
+ valid_counts = counts[counts.index.isin(list(VALID_LABELS))]
232
+ if len(valid_counts) == 2 and valid_counts.sum() > 0:
233
+ minority_fraction = float(valid_counts.min() / valid_counts.sum())
234
+ print(f"Minority class fraction: {minority_fraction:.2%}")
235
+ if minority_fraction < 0.10:
236
+ print("Balance note: very imbalanced. Consider class weights, sampling, or more data.")
237
+ elif minority_fraction < 0.25:
238
+ print("Balance note: moderately imbalanced, but usable for a demo with stratified metrics.")
239
+ else:
240
+ print("Balance note: reasonably balanced for a research demo.")
241
+ else:
242
+ print("Balance note: expected both labels 0 and 1.")
243
+
244
+
245
+ def main() -> None:
246
+ args = parse_args()
247
+ root = project_root()
248
+ data_dir = choose_data_dir(root, args.data_dir)
249
+
250
+ print("Variant Risk Explainer dataset check")
251
+ print(f"Project root: {root}")
252
+ print(f"Data directory: {data_dir}")
253
+ print()
254
+ print_label_meaning()
255
+
256
+ missing_files = find_missing_files(data_dir)
257
+ if missing_files:
258
+ print("ERROR: missing expected CSV files:")
259
+ for path in missing_files:
260
+ print(f" {path}")
261
+ raise SystemExit(1)
262
+
263
+ frames = {}
264
+ for split_name, filename in SPLIT_FILES.items():
265
+ frames[split_name] = audit_split(split_name, data_dir / filename, args.min_reasonable_length)
266
+
267
+ print_overall_balance(frames)
268
+
269
+
270
+ if __name__ == "__main__":
271
+ main()
training/check_device.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Check the best available PyTorch device for local Mac training."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import torch
7
+
8
+
9
+ def choose_device() -> str:
10
+ """Select CUDA first, then Apple MPS, then CPU."""
11
+ if torch.cuda.is_available():
12
+ return "cuda"
13
+
14
+ mps_backend = getattr(torch.backends, "mps", None)
15
+ if mps_backend is not None and mps_backend.is_available():
16
+ return "mps"
17
+
18
+ return "cpu"
19
+
20
+
21
+ def main() -> None:
22
+ device = choose_device()
23
+
24
+ print("PyTorch device check")
25
+ print(f"Torch version: {torch.__version__}")
26
+ print(f"CUDA available: {torch.cuda.is_available()}")
27
+
28
+ mps_backend = getattr(torch.backends, "mps", None)
29
+ mps_available = bool(mps_backend is not None and mps_backend.is_available())
30
+ print(f"MPS available: {mps_available}")
31
+ print(f"Using device: {device}")
32
+
33
+ if device == "mps":
34
+ print("Apple Silicon acceleration is available through MPS.")
35
+ elif device == "cuda":
36
+ print("CUDA GPU acceleration is available.")
37
+ else:
38
+ print("WARNING: CPU training will be slow. Use small smoke tests locally, or use a GPU machine for full DNABERT-2 training.")
39
+
40
+
41
+ if __name__ == "__main__":
42
+ main()
training/check_large_dataset.py ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Check the larger ClinVar datasets before DNABERT-2 training.
3
+
4
+ This script checks generated larger datasets when present:
5
+ - training/csv_files_20k_alt/ and training/csv_files_20k/
6
+ - training/csv_files_10k_alt/ and training/csv_files_10k/
7
+ - training/csv_files_large_alt/ and training/csv_files_large/
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+ from urllib.parse import unquote
14
+
15
+ import pandas as pd
16
+
17
+
18
+ DATASETS = [
19
+ (
20
+ "20k alternate-sequence dataset",
21
+ Path("training/csv_files_20k_alt"),
22
+ {
23
+ "train": "train_with_alt_sequences.csv",
24
+ "val": "val_with_alt_sequences.csv",
25
+ "test": "test_with_alt_sequences.csv",
26
+ },
27
+ ),
28
+ (
29
+ "20k reference-sequence dataset",
30
+ Path("training/csv_files_20k"),
31
+ {
32
+ "train": "train_with_sequences.csv",
33
+ "val": "val_with_sequences.csv",
34
+ "test": "test_with_sequences.csv",
35
+ },
36
+ ),
37
+ (
38
+ "10k alternate-sequence dataset",
39
+ Path("training/csv_files_10k_alt"),
40
+ {
41
+ "train": "train_with_alt_sequences.csv",
42
+ "val": "val_with_alt_sequences.csv",
43
+ "test": "test_with_alt_sequences.csv",
44
+ },
45
+ ),
46
+ (
47
+ "10k reference-sequence dataset",
48
+ Path("training/csv_files_10k"),
49
+ {
50
+ "train": "train_with_sequences.csv",
51
+ "val": "val_with_sequences.csv",
52
+ "test": "test_with_sequences.csv",
53
+ },
54
+ ),
55
+ (
56
+ "large alternate-sequence dataset",
57
+ Path("training/csv_files_large_alt"),
58
+ {
59
+ "train": "train_with_alt_sequences.csv",
60
+ "val": "val_with_alt_sequences.csv",
61
+ "test": "test_with_alt_sequences.csv",
62
+ },
63
+ ),
64
+ (
65
+ "large reference-sequence dataset",
66
+ Path("training/csv_files_large"),
67
+ {
68
+ "train": "train_with_sequences.csv",
69
+ "val": "val_with_sequences.csv",
70
+ "test": "test_with_sequences.csv",
71
+ },
72
+ ),
73
+ ]
74
+
75
+ REQUIRED_COLUMNS = {"sequence", "label"}
76
+ EXAMPLE_COLUMNS = ["variant_id", "REF", "ALT", "label"]
77
+ SUSPICIOUS_CLNSIG_TERMS = (
78
+ "conflicting",
79
+ "uncertain",
80
+ "not provided",
81
+ "not_provided",
82
+ "risk_factor",
83
+ "risk factor",
84
+ "association",
85
+ "drug_response",
86
+ "drug response",
87
+ "protective",
88
+ )
89
+
90
+
91
+ def project_root() -> Path:
92
+ return Path(__file__).resolve().parents[1]
93
+
94
+
95
+ def normalize_clnsig(value: object) -> str:
96
+ decoded = unquote(str(value))
97
+ return (
98
+ decoded.replace("_", " ")
99
+ .replace("-", " ")
100
+ .replace("/", " ")
101
+ .replace("|", " ")
102
+ .replace(",", " ")
103
+ .strip()
104
+ .lower()
105
+ )
106
+
107
+
108
+ def suspicious_clnsig_count(df: pd.DataFrame) -> int:
109
+ if "CLNSIG" not in df.columns:
110
+ return 0
111
+ normalized = df["CLNSIG"].fillna("").apply(normalize_clnsig)
112
+ return int(normalized.apply(lambda value: any(term in value for term in SUSPICIOUS_CLNSIG_TERMS)).sum())
113
+
114
+
115
+ def sequence_lengths(df: pd.DataFrame) -> pd.Series:
116
+ return df["sequence"].fillna("").astype(str).str.strip().str.len()
117
+
118
+
119
+ def missing_sequence_count(df: pd.DataFrame) -> int:
120
+ cleaned = df["sequence"].fillna("").astype(str).str.strip()
121
+ return int((cleaned == "").sum())
122
+
123
+
124
+ def print_sequence_length_summary(lengths: pd.Series) -> None:
125
+ non_empty = lengths[lengths > 0]
126
+ if non_empty.empty:
127
+ print("Sequence length min/mean/max: no non-empty sequences")
128
+ return
129
+
130
+ print(
131
+ "Sequence length min/mean/max: "
132
+ f"{int(non_empty.min())} / "
133
+ f"{float(non_empty.mean()):.2f} / "
134
+ f"{int(non_empty.max())}"
135
+ )
136
+
137
+
138
+ def print_examples(df: pd.DataFrame, lengths: pd.Series) -> None:
139
+ print("First 3 examples:")
140
+ if df.empty:
141
+ print(" no rows")
142
+ return
143
+
144
+ columns = [column for column in EXAMPLE_COLUMNS if column in df.columns]
145
+ examples = df[columns].head(3).copy()
146
+ examples["sequence_length"] = lengths.head(3).to_list()
147
+ with pd.option_context("display.max_columns", None, "display.width", 160, "display.max_colwidth", 80):
148
+ print(examples.to_string(index=False))
149
+
150
+
151
+ def check_split(split_name: str, csv_path: Path) -> tuple[pd.DataFrame | None, bool]:
152
+ print("-" * 80)
153
+ print(f"{split_name.upper()} SPLIT")
154
+ print("-" * 80)
155
+ print(f"File path: {csv_path}")
156
+
157
+ if not csv_path.exists():
158
+ print("ERROR: file is missing.")
159
+ print()
160
+ return None, False
161
+
162
+ df = pd.read_csv(csv_path)
163
+ print(f"Rows: {len(df):,}")
164
+ print(f"Columns: {list(df.columns)}")
165
+
166
+ missing_columns = sorted(REQUIRED_COLUMNS - set(df.columns))
167
+ if missing_columns:
168
+ print(f"ERROR: missing required columns: {missing_columns}")
169
+ print()
170
+ return df, False
171
+
172
+ labels = pd.to_numeric(df["label"], errors="coerce")
173
+ print("Label distribution:")
174
+ print(labels.value_counts(dropna=False).sort_index().to_string())
175
+
176
+ missing_sequences = missing_sequence_count(df)
177
+ lengths = sequence_lengths(df)
178
+ print(f"Missing sequence count: {missing_sequences:,}")
179
+ print_sequence_length_summary(lengths)
180
+
181
+ if "CLNSIG" in df.columns:
182
+ suspicious_count = suspicious_clnsig_count(df)
183
+ print(f"CLNSIG suspicious rows: {suspicious_count:,}")
184
+ else:
185
+ print("CLNSIG suspicious rows: CLNSIG column not present")
186
+
187
+ print_examples(df, lengths)
188
+ print()
189
+
190
+ valid_labels = labels.isin([0, 1]).all()
191
+ has_sequences = missing_sequences == 0 and int((lengths > 0).sum()) == len(df)
192
+ usable = len(df) > 0 and valid_labels and has_sequences
193
+ return df, usable
194
+
195
+
196
+ def check_dataset(dataset_name: str, dataset_dir: Path, split_files: dict[str, str]) -> tuple[int, bool]:
197
+ print("=" * 80)
198
+ print(dataset_name.upper())
199
+ print("=" * 80)
200
+ print(f"Dataset directory: {dataset_dir}")
201
+
202
+ if not dataset_dir.exists():
203
+ print("Dataset directory is missing, skipping.")
204
+ print()
205
+ return 0, True
206
+
207
+ total_rows = 0
208
+ dataset_usable = True
209
+ frames = []
210
+
211
+ for split_name, filename in split_files.items():
212
+ df, split_usable = check_split(split_name, dataset_dir / filename)
213
+ dataset_usable = dataset_usable and split_usable
214
+ if df is not None:
215
+ total_rows += len(df)
216
+ if "label" in df.columns:
217
+ temp = df[["label"]].copy()
218
+ temp["split"] = split_name
219
+ frames.append(temp)
220
+
221
+ print(f"Total rows across train/val/test: {total_rows:,}")
222
+
223
+ if frames:
224
+ combined = pd.concat(frames, ignore_index=True)
225
+ combined["label"] = pd.to_numeric(combined["label"], errors="coerce")
226
+ print("Combined label distribution:")
227
+ print(combined["label"].value_counts(dropna=False).sort_index().to_string())
228
+
229
+ if dataset_usable:
230
+ print("Usability check: OK, this dataset has rows, labels, and non-empty sequences.")
231
+ else:
232
+ print("Usability check: FAILED, fix the issues above before training.")
233
+ print()
234
+
235
+ return total_rows, dataset_usable
236
+
237
+
238
+ def main() -> None:
239
+ root = project_root()
240
+ print("Large ClinVar dataset check")
241
+ print(f"Project root: {root}")
242
+ print()
243
+
244
+ overall_usable = True
245
+ datasets_found = 0
246
+ for dataset_name, relative_dir, split_files in DATASETS:
247
+ dataset_dir = root / relative_dir
248
+ total_rows, usable = check_dataset(dataset_name, dataset_dir, split_files)
249
+ if dataset_dir.exists():
250
+ datasets_found += 1
251
+ overall_usable = overall_usable and usable and total_rows > 0
252
+
253
+ print("=" * 80)
254
+ print("FINAL RESULT")
255
+ print("=" * 80)
256
+ if datasets_found == 0:
257
+ print("No larger dataset folders were found yet. Run prepare_larger_clinvar_dataset.py first.")
258
+ elif overall_usable:
259
+ print("The larger dataset files found look usable for training.")
260
+ else:
261
+ print("At least one larger dataset found is not fully usable. Review the errors above.")
262
+
263
+
264
+ if __name__ == "__main__":
265
+ main()
training/colab_dnabert2_clinvar_finetune.ipynb CHANGED
@@ -204,7 +204,9 @@
204
  "source": [
205
  "## 6. Training Settings\n",
206
  "\n",
207
- "Use conservative settings first. Increase epochs or batch size later if the run is stable. For small sample CSVs, expect overfitting; this is only a research demo."
 
 
208
  ]
209
  },
210
  {
@@ -213,17 +215,38 @@
213
  "metadata": {},
214
  "outputs": [],
215
  "source": [
216
- "MODEL_NAME = 'zhihan1996/DNABERT-2-117M'\n",
217
- "OUTPUT_DIR = PROJECT_ROOT / 'training' / 'output' / 'dnabert2-clinvar-grch38'\n",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  "\n",
219
- "MAX_LENGTH = 512\n",
220
- "EPOCHS = 1\n",
221
- "BATCH_SIZE = 4\n",
222
- "LEARNING_RATE = 2e-5\n",
223
  "MIN_SEQUENCE_LENGTH = 200\n",
 
224
  "\n",
 
225
  "print(f'Model: {MODEL_NAME}')\n",
226
- "print(f'Output dir: {OUTPUT_DIR}')"
 
227
  ]
228
  },
229
  {
@@ -232,7 +255,7 @@
232
  "source": [
233
  "## 7. Fine-tune DNABERT-2\n",
234
  "\n",
235
- "This calls the repository training script using the CSV files. The script saves the best checkpoint and final model under `training/output/dnabert2-clinvar-grch38/final_model`."
236
  ]
237
  },
238
  {
@@ -241,6 +264,8 @@
241
  "metadata": {},
242
  "outputs": [],
243
  "source": [
 
 
244
  "!python training/scripts/train_dnabert2_classifier.py \\\n",
245
  " --train-csv \"{TRAIN_CSV}\" \\\n",
246
  " --val-csv \"{VAL_CSV}\" \\\n",
@@ -249,9 +274,14 @@
249
  " --model-name \"{MODEL_NAME}\" \\\n",
250
  " --max-length {MAX_LENGTH} \\\n",
251
  " --min-sequence-length {MIN_SEQUENCE_LENGTH} \\\n",
 
 
 
252
  " --epochs {EPOCHS} \\\n",
253
  " --batch-size {BATCH_SIZE} \\\n",
254
- " --learning-rate {LEARNING_RATE}"
 
 
255
  ]
256
  },
257
  {
 
204
  "source": [
205
  "## 6. Training Settings\n",
206
  "\n",
207
+ "Use conservative settings first. Increase epochs or batch size later if the run is stable. For small sample CSVs, expect overfitting; this is only a research demo.\n",
208
+ "\n",
209
+ "If you only want to test the notebook on CPU, set `CPU_SMOKE_TEST = True`. That uses a tiny model and a small number of rows so you can see progress without waiting for DNABERT-2 CPU training."
210
  ]
211
  },
212
  {
 
215
  "metadata": {},
216
  "outputs": [],
217
  "source": [
218
+ "CPU_SMOKE_TEST = False\n",
219
+ "\n",
220
+ "if CPU_SMOKE_TEST:\n",
221
+ " MODEL_NAME = 'hf-internal-testing/tiny-random-bert'\n",
222
+ " OUTPUT_DIR = PROJECT_ROOT / 'training' / 'output' / 'cpu-smoke-test'\n",
223
+ " MAX_LENGTH = 128\n",
224
+ " EPOCHS = 1\n",
225
+ " BATCH_SIZE = 8\n",
226
+ " LEARNING_RATE = 5e-5\n",
227
+ " MAX_TRAIN_SAMPLES = 64\n",
228
+ " MAX_VAL_SAMPLES = 32\n",
229
+ " MAX_TEST_SAMPLES = 32\n",
230
+ " FORCE_CPU = True\n",
231
+ "else:\n",
232
+ " MODEL_NAME = 'zhihan1996/DNABERT-2-117M'\n",
233
+ " OUTPUT_DIR = PROJECT_ROOT / 'training' / 'output' / 'dnabert2-clinvar-grch38'\n",
234
+ " MAX_LENGTH = 512\n",
235
+ " EPOCHS = 1\n",
236
+ " BATCH_SIZE = 4\n",
237
+ " LEARNING_RATE = 2e-5\n",
238
+ " MAX_TRAIN_SAMPLES = 0\n",
239
+ " MAX_VAL_SAMPLES = 0\n",
240
+ " MAX_TEST_SAMPLES = 0\n",
241
+ " FORCE_CPU = False\n",
242
  "\n",
 
 
 
 
243
  "MIN_SEQUENCE_LENGTH = 200\n",
244
+ "LOGGING_STEPS = 5\n",
245
  "\n",
246
+ "print(f'CPU smoke test: {CPU_SMOKE_TEST}')\n",
247
  "print(f'Model: {MODEL_NAME}')\n",
248
+ "print(f'Output dir: {OUTPUT_DIR}')\n",
249
+ "print(f'Max samples: train={MAX_TRAIN_SAMPLES}, val={MAX_VAL_SAMPLES}, test={MAX_TEST_SAMPLES}')"
250
  ]
251
  },
252
  {
 
255
  "source": [
256
  "## 7. Fine-tune DNABERT-2\n",
257
  "\n",
258
+ "This calls the repository training script using the CSV files. Progress bars and step logs are enabled. In normal mode, the script saves the best checkpoint and final model under `training/output/dnabert2-clinvar-grch38/final_model`."
259
  ]
260
  },
261
  {
 
264
  "metadata": {},
265
  "outputs": [],
266
  "source": [
267
+ "force_cpu_arg = '--force-cpu' if FORCE_CPU else ''\n",
268
+ "\n",
269
  "!python training/scripts/train_dnabert2_classifier.py \\\n",
270
  " --train-csv \"{TRAIN_CSV}\" \\\n",
271
  " --val-csv \"{VAL_CSV}\" \\\n",
 
274
  " --model-name \"{MODEL_NAME}\" \\\n",
275
  " --max-length {MAX_LENGTH} \\\n",
276
  " --min-sequence-length {MIN_SEQUENCE_LENGTH} \\\n",
277
+ " --max-train-samples {MAX_TRAIN_SAMPLES} \\\n",
278
+ " --max-val-samples {MAX_VAL_SAMPLES} \\\n",
279
+ " --max-test-samples {MAX_TEST_SAMPLES} \\\n",
280
  " --epochs {EPOCHS} \\\n",
281
  " --batch-size {BATCH_SIZE} \\\n",
282
+ " --learning-rate {LEARNING_RATE} \\\n",
283
+ " --logging-steps {LOGGING_STEPS} \\\n",
284
+ " {force_cpu_arg}"
285
  ]
286
  },
287
  {
training/colab_dnabert2_heavy_training.ipynb ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# DNABERT-2 Heavy Training on Google Colab\n",
8
+ "\n",
9
+ "This notebook prepares a larger ClinVar GRCh38 alternate-sequence dataset and fine-tunes DNABERT-2 on a Colab GPU.\n",
10
+ "\n",
11
+ "Research/education only. This is not a medical diagnosis tool."
12
+ ]
13
+ },
14
+ {
15
+ "cell_type": "markdown",
16
+ "metadata": {},
17
+ "source": [
18
+ "## 1. GPU Check"
19
+ ]
20
+ },
21
+ {
22
+ "cell_type": "code",
23
+ "execution_count": null,
24
+ "metadata": {},
25
+ "outputs": [],
26
+ "source": [
27
+ "import subprocess\n",
28
+ "import torch\n",
29
+ "\n",
30
+ "print(\"nvidia-smi output:\")\n",
31
+ "subprocess.run([\"nvidia-smi\"], check=False)\n",
32
+ "\n",
33
+ "cuda_available = torch.cuda.is_available()\n",
34
+ "print(\"torch CUDA available:\", cuda_available)\n",
35
+ "\n",
36
+ "if not cuda_available:\n",
37
+ " raise RuntimeError(\"No GPU is available. In Colab, go to Runtime > Change runtime type > GPU, then rerun.\")\n",
38
+ "\n",
39
+ "print(\"GPU name:\", torch.cuda.get_device_name(0))"
40
+ ]
41
+ },
42
+ {
43
+ "cell_type": "markdown",
44
+ "metadata": {},
45
+ "source": [
46
+ "## 2. Clone GitHub Repo"
47
+ ]
48
+ },
49
+ {
50
+ "cell_type": "code",
51
+ "execution_count": null,
52
+ "metadata": {},
53
+ "outputs": [],
54
+ "source": [
55
+ "from pathlib import Path\n",
56
+ "import os\n",
57
+ "import subprocess\n",
58
+ "\n",
59
+ "REPO_URL = \"PASTE_YOUR_GITHUB_REPO_URL_HERE\"\n",
60
+ "\n",
61
+ "if REPO_URL == \"PASTE_YOUR_GITHUB_REPO_URL_HERE\":\n",
62
+ " raise ValueError(\"Paste your GitHub repository URL into REPO_URL before running this cell.\")\n",
63
+ "\n",
64
+ "repo_name = REPO_URL.rstrip(\"/\").split(\"/\")[-1].replace(\".git\", \"\")\n",
65
+ "repo_path = Path(\"/content\") / repo_name\n",
66
+ "\n",
67
+ "if not repo_path.exists():\n",
68
+ " subprocess.run([\"git\", \"clone\", REPO_URL, str(repo_path)], check=True)\n",
69
+ "else:\n",
70
+ " print(f\"Repository already exists: {repo_path}\")\n",
71
+ "\n",
72
+ "os.chdir(repo_path)\n",
73
+ "print(\"Project root:\", Path.cwd())"
74
+ ]
75
+ },
76
+ {
77
+ "cell_type": "markdown",
78
+ "metadata": {},
79
+ "source": [
80
+ "## 3. Install Dependencies"
81
+ ]
82
+ },
83
+ {
84
+ "cell_type": "code",
85
+ "execution_count": null,
86
+ "metadata": {},
87
+ "outputs": [],
88
+ "source": [
89
+ "!python -m pip install --upgrade pip\n",
90
+ "!pip install -q torch transformers datasets accelerate scikit-learn pandas numpy safetensors tqdm biopython requests joblib"
91
+ ]
92
+ },
93
+ {
94
+ "cell_type": "markdown",
95
+ "metadata": {},
96
+ "source": [
97
+ "## 4. Optional Google Drive Mount"
98
+ ]
99
+ },
100
+ {
101
+ "cell_type": "code",
102
+ "execution_count": null,
103
+ "metadata": {},
104
+ "outputs": [],
105
+ "source": [
106
+ "from pathlib import Path\n",
107
+ "\n",
108
+ "USE_GOOGLE_DRIVE = True\n",
109
+ "DRIVE_OUTPUT_DIR = Path(\"/content/drive/MyDrive/variant-risk-explainer\")\n",
110
+ "\n",
111
+ "if USE_GOOGLE_DRIVE:\n",
112
+ " from google.colab import drive\n",
113
+ " drive.mount(\"/content/drive\")\n",
114
+ " DRIVE_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)\n",
115
+ " print(\"Drive output folder:\", DRIVE_OUTPUT_DIR)\n",
116
+ "else:\n",
117
+ " print(\"Google Drive mount skipped.\")"
118
+ ]
119
+ },
120
+ {
121
+ "cell_type": "markdown",
122
+ "metadata": {},
123
+ "source": [
124
+ "## 5. Prepare Larger ClinVar Dataset"
125
+ ]
126
+ },
127
+ {
128
+ "cell_type": "code",
129
+ "execution_count": null,
130
+ "metadata": {},
131
+ "outputs": [],
132
+ "source": [
133
+ "TARGET_TOTAL = 10000\n",
134
+ "!python training/prepare_larger_clinvar_dataset.py --target_total {TARGET_TOTAL}"
135
+ ]
136
+ },
137
+ {
138
+ "cell_type": "markdown",
139
+ "metadata": {},
140
+ "source": [
141
+ "Optional: only run this 20k cell after 10k preparation and training work correctly."
142
+ ]
143
+ },
144
+ {
145
+ "cell_type": "code",
146
+ "execution_count": null,
147
+ "metadata": {},
148
+ "outputs": [],
149
+ "source": [
150
+ "RUN_20K = False\n",
151
+ "\n",
152
+ "if RUN_20K:\n",
153
+ " !python training/prepare_larger_clinvar_dataset.py --target_total 20000\n",
154
+ "else:\n",
155
+ " print(\"20k preparation skipped. Set RUN_20K = True when ready.\")"
156
+ ]
157
+ },
158
+ {
159
+ "cell_type": "markdown",
160
+ "metadata": {},
161
+ "source": [
162
+ "## 6. Check Dataset"
163
+ ]
164
+ },
165
+ {
166
+ "cell_type": "code",
167
+ "execution_count": null,
168
+ "metadata": {},
169
+ "outputs": [],
170
+ "source": [
171
+ "!python training/check_large_dataset.py"
172
+ ]
173
+ },
174
+ {
175
+ "cell_type": "markdown",
176
+ "metadata": {},
177
+ "source": [
178
+ "## 7. Audit Alternate Sequence Encoding\n",
179
+ "\n",
180
+ "Expected conclusion: `The sequence appears to contain alternate alleles.`"
181
+ ]
182
+ },
183
+ {
184
+ "cell_type": "code",
185
+ "execution_count": null,
186
+ "metadata": {},
187
+ "outputs": [],
188
+ "source": [
189
+ "!python training/audit_variant_sequence_encoding.py"
190
+ ]
191
+ },
192
+ {
193
+ "cell_type": "markdown",
194
+ "metadata": {},
195
+ "source": [
196
+ "## 8. GPU Training"
197
+ ]
198
+ },
199
+ {
200
+ "cell_type": "code",
201
+ "execution_count": null,
202
+ "metadata": {},
203
+ "outputs": [],
204
+ "source": [
205
+ "%%bash\n",
206
+ "python training/train_local_dnabert2.py \\\n",
207
+ " --sample_size 0 \\\n",
208
+ " --epochs 5 \\\n",
209
+ " --batch_size 4 \\\n",
210
+ " --grad_accum_steps 4 \\\n",
211
+ " --learning_rate 2e-5 \\\n",
212
+ " --freeze_encoder true \\\n",
213
+ " --unfreeze_last_n_layers 4 \\\n",
214
+ " --use_class_weights true \\\n",
215
+ " --center_crop true \\\n",
216
+ " --tune_threshold true \\\n",
217
+ " --eval_accumulation_steps 8 \\\n",
218
+ " --save_eval_each_epoch false \\\n",
219
+ " --eval_subset_size 0"
220
+ ]
221
+ },
222
+ {
223
+ "cell_type": "markdown",
224
+ "metadata": {},
225
+ "source": [
226
+ "## 9. Full Evaluation"
227
+ ]
228
+ },
229
+ {
230
+ "cell_type": "code",
231
+ "execution_count": null,
232
+ "metadata": {},
233
+ "outputs": [],
234
+ "source": [
235
+ "!python training/evaluate_saved_model.py --tune_threshold true"
236
+ ]
237
+ },
238
+ {
239
+ "cell_type": "markdown",
240
+ "metadata": {},
241
+ "source": [
242
+ "## 10. Save Outputs to Google Drive"
243
+ ]
244
+ },
245
+ {
246
+ "cell_type": "code",
247
+ "execution_count": null,
248
+ "metadata": {},
249
+ "outputs": [],
250
+ "source": [
251
+ "from pathlib import Path\n",
252
+ "import shutil\n",
253
+ "\n",
254
+ "source_dir = Path(\"training/outputs/dnabert2_clinvar\")\n",
255
+ "drive_output = Path(\"/content/drive/MyDrive/variant-risk-explainer\")\n",
256
+ "\n",
257
+ "if not drive_output.exists():\n",
258
+ " print(\"Google Drive output folder is not available. Run the Drive mount cell first.\")\n",
259
+ "else:\n",
260
+ " drive_output.mkdir(parents=True, exist_ok=True)\n",
261
+ " items_to_copy = [\n",
262
+ " source_dir / \"final_model\",\n",
263
+ " source_dir / \"metrics.json\",\n",
264
+ " source_dir / \"full_eval_metrics.json\",\n",
265
+ " source_dir / \"final_dnabert2_clinvar_model.zip\",\n",
266
+ " ]\n",
267
+ "\n",
268
+ " for item in items_to_copy:\n",
269
+ " if not item.exists():\n",
270
+ " print(f\"Skipping missing item: {item}\")\n",
271
+ " continue\n",
272
+ "\n",
273
+ " destination = drive_output / item.name\n",
274
+ " if item.is_dir():\n",
275
+ " if destination.exists():\n",
276
+ " shutil.rmtree(destination)\n",
277
+ " shutil.copytree(item, destination)\n",
278
+ " else:\n",
279
+ " shutil.copy2(item, destination)\n",
280
+ " print(f\"Copied {item} -> {destination}\")"
281
+ ]
282
+ },
283
+ {
284
+ "cell_type": "markdown",
285
+ "metadata": {},
286
+ "source": [
287
+ "## 11. Download Model Artifacts"
288
+ ]
289
+ },
290
+ {
291
+ "cell_type": "code",
292
+ "execution_count": null,
293
+ "metadata": {},
294
+ "outputs": [],
295
+ "source": [
296
+ "from pathlib import Path\n",
297
+ "from google.colab import files\n",
298
+ "\n",
299
+ "output_dir = Path(\"training/outputs/dnabert2_clinvar\")\n",
300
+ "zip_path = output_dir / \"final_dnabert2_clinvar_model.zip\"\n",
301
+ "\n",
302
+ "if not zip_path.exists():\n",
303
+ " !cd training/outputs/dnabert2_clinvar && zip -r final_dnabert2_clinvar_model.zip final_model metrics.json full_eval_metrics.json\n",
304
+ "\n",
305
+ "files.download(str(zip_path))"
306
+ ]
307
+ }
308
+ ],
309
+ "metadata": {
310
+ "accelerator": "GPU",
311
+ "colab": {
312
+ "provenance": []
313
+ },
314
+ "kernelspec": {
315
+ "display_name": "Python 3",
316
+ "language": "python",
317
+ "name": "python3"
318
+ },
319
+ "language_info": {
320
+ "name": "python",
321
+ "version": "3.10"
322
+ }
323
+ },
324
+ "nbformat": 4,
325
+ "nbformat_minor": 5
326
+ }
training/csv_files_alt/test_with_alt_sequences.csv ADDED
The diff for this file is too large to render. See raw diff
 
training/csv_files_alt/train_with_alt_sequences.csv ADDED
The diff for this file is too large to render. See raw diff
 
training/csv_files_alt/val_with_alt_sequences.csv ADDED
The diff for this file is too large to render. See raw diff
 
training/evaluate_saved_model.py ADDED
@@ -0,0 +1,598 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Memory-safe full evaluation for a saved DNABERT-2 ClinVar model.
3
+
4
+ This script does not train. It loads the saved model, evaluates the full
5
+ validation and test CSV files in small batches, and writes metrics to JSON.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import gc
12
+ import json
13
+ import os
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ import numpy as np
18
+ import pandas as pd
19
+ import torch
20
+ from safetensors.torch import load_file as load_safetensors_file
21
+ from sklearn.metrics import (
22
+ accuracy_score,
23
+ confusion_matrix,
24
+ f1_score,
25
+ matthews_corrcoef,
26
+ precision_score,
27
+ recall_score,
28
+ roc_auc_score,
29
+ )
30
+ from tqdm.auto import tqdm
31
+ from transformers import AutoConfig, AutoModelForSequenceClassification, AutoTokenizer
32
+
33
+
34
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
35
+ if str(PROJECT_ROOT) not in sys.path:
36
+ sys.path.insert(0, str(PROJECT_ROOT))
37
+
38
+ from training.train_smoke_test import ( # noqa: E402
39
+ LOCAL_DNABERT2_PATCH_DIR,
40
+ clear_local_patch_module_cache,
41
+ create_local_dnabert2_patch,
42
+ disable_flash_attention_on_config,
43
+ load_sequence_classification_model,
44
+ )
45
+
46
+ OUTPUT_DIR = PROJECT_ROOT / "training" / "outputs" / "dnabert2_clinvar"
47
+ MODEL_DIR = OUTPUT_DIR / "final_model"
48
+ TRAINING_METRICS_PATH = OUTPUT_DIR / "metrics.json"
49
+ FULL_EVAL_METRICS_PATH = OUTPUT_DIR / "full_eval_metrics.json"
50
+
51
+ ALT_SPLIT_FILES = {
52
+ "validation": "val_with_alt_sequences.csv",
53
+ "test": "test_with_alt_sequences.csv",
54
+ }
55
+
56
+ DATASET_CANDIDATES = [
57
+ PROJECT_ROOT / "training" / "csv_files_20k_alt",
58
+ PROJECT_ROOT / "training" / "csv_files_10k_alt",
59
+ PROJECT_ROOT / "training" / "csv_files_large_alt",
60
+ PROJECT_ROOT / "training" / "csv_files_alt",
61
+ PROJECT_ROOT / "data" / "processed",
62
+ PROJECT_ROOT / "training" / "csv_files",
63
+ ]
64
+
65
+ SEQUENCE_COLUMN = "sequence"
66
+ LABEL_COLUMN = "label"
67
+ MAX_LENGTH = 512
68
+ VARIANT_CENTER_INDEX = 512
69
+ BATCH_SIZE = 1
70
+ MPS_CACHE_EVERY = 25
71
+
72
+
73
+ def parse_bool(value: str | bool) -> bool:
74
+ if isinstance(value, bool):
75
+ return value
76
+
77
+ normalized = value.strip().lower()
78
+ if normalized in {"true", "1", "yes", "y"}:
79
+ return True
80
+ if normalized in {"false", "0", "no", "n"}:
81
+ return False
82
+
83
+ raise argparse.ArgumentTypeError("Use true or false.")
84
+
85
+
86
+ def parse_args() -> argparse.Namespace:
87
+ parser = argparse.ArgumentParser(
88
+ description="Memory-safe full validation/test evaluation for the saved DNABERT-2 ClinVar model.",
89
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
90
+ )
91
+ parser.add_argument(
92
+ "--tune_threshold",
93
+ type=parse_bool,
94
+ nargs="?",
95
+ const=True,
96
+ default=True,
97
+ help="Tune the decision threshold on the full validation set.",
98
+ )
99
+ parser.add_argument(
100
+ "--threshold",
101
+ type=float,
102
+ default=None,
103
+ help="Use this fixed decision threshold instead of tuning.",
104
+ )
105
+ parser.add_argument(
106
+ "--threshold_min",
107
+ type=float,
108
+ default=0.1,
109
+ help="Minimum threshold to test when tuning.",
110
+ )
111
+ parser.add_argument(
112
+ "--threshold_max",
113
+ type=float,
114
+ default=0.9,
115
+ help="Maximum threshold to test when tuning.",
116
+ )
117
+ parser.add_argument(
118
+ "--threshold_step",
119
+ type=float,
120
+ default=0.01,
121
+ help="Threshold step size when tuning.",
122
+ )
123
+ return parser.parse_args()
124
+
125
+
126
+ def validate_args(args: argparse.Namespace) -> None:
127
+ if args.threshold is not None and not 0.0 <= args.threshold <= 1.0:
128
+ raise ValueError("--threshold must be between 0 and 1.")
129
+
130
+ if args.threshold_step <= 0:
131
+ raise ValueError("--threshold_step must be greater than 0.")
132
+
133
+ if args.threshold_min > args.threshold_max:
134
+ raise ValueError("--threshold_min must be less than or equal to --threshold_max.")
135
+
136
+
137
+ def resolve_project_path(path: Path) -> Path:
138
+ if path.is_absolute():
139
+ return path
140
+ return PROJECT_ROOT / path
141
+
142
+
143
+ def choose_device() -> str:
144
+ if torch.cuda.is_available():
145
+ return "cuda"
146
+
147
+ mps_backend = getattr(torch.backends, "mps", None)
148
+ if mps_backend is not None and mps_backend.is_available():
149
+ return "mps"
150
+
151
+ return "cpu"
152
+
153
+
154
+ def find_dataset_dir() -> Path:
155
+ for directory in DATASET_CANDIDATES:
156
+ if all((directory / filename).exists() for filename in ALT_SPLIT_FILES.values()):
157
+ return directory
158
+
159
+ searched = "\n".join(str(directory) for directory in DATASET_CANDIDATES)
160
+ raise FileNotFoundError(
161
+ "Could not find validation/test alternate-sequence CSV files.\n"
162
+ f"Searched:\n{searched}"
163
+ )
164
+
165
+
166
+ def load_threshold() -> float:
167
+ if not TRAINING_METRICS_PATH.exists():
168
+ return 0.5
169
+
170
+ try:
171
+ metrics = json.loads(TRAINING_METRICS_PATH.read_text(encoding="utf-8"))
172
+ except json.JSONDecodeError:
173
+ return 0.5
174
+
175
+ threshold = metrics.get("selected_threshold")
176
+ if threshold is None:
177
+ return 0.5
178
+
179
+ try:
180
+ return float(threshold)
181
+ except (TypeError, ValueError):
182
+ return 0.5
183
+
184
+
185
+ def clean_sequence(value: object) -> str:
186
+ return str(value).strip().upper()
187
+
188
+
189
+ def crop_sequence_around_variant(sequence: str, max_length: int, variant_center_index: int) -> str:
190
+ if len(sequence) <= max_length:
191
+ return sequence
192
+
193
+ start = max(0, variant_center_index - max_length // 2)
194
+ end = start + max_length
195
+ if end > len(sequence):
196
+ end = len(sequence)
197
+ start = max(0, end - max_length)
198
+
199
+ return sequence[start:end]
200
+
201
+
202
+ def load_eval_dataframe(csv_path: Path, split_name: str) -> pd.DataFrame:
203
+ df = pd.read_csv(csv_path)
204
+ required_columns = {SEQUENCE_COLUMN, LABEL_COLUMN}
205
+ missing_columns = sorted(required_columns - set(df.columns))
206
+ if missing_columns:
207
+ raise ValueError(f"{csv_path} is missing required columns: {missing_columns}")
208
+
209
+ df = df.copy()
210
+ df[LABEL_COLUMN] = pd.to_numeric(df[LABEL_COLUMN], errors="coerce")
211
+ df = df.loc[df[LABEL_COLUMN].isin([0, 1])].copy()
212
+ df[LABEL_COLUMN] = df[LABEL_COLUMN].astype(int)
213
+
214
+ df[SEQUENCE_COLUMN] = df[SEQUENCE_COLUMN].fillna("").apply(clean_sequence)
215
+ df = df.loc[df[SEQUENCE_COLUMN] != ""].copy()
216
+ df[SEQUENCE_COLUMN] = df[SEQUENCE_COLUMN].apply(
217
+ lambda sequence: crop_sequence_around_variant(sequence, MAX_LENGTH, VARIANT_CENTER_INDEX)
218
+ )
219
+
220
+ print(f"{split_name} CSV: {csv_path}")
221
+ print(f"{split_name} rows loaded for full evaluation: {len(df):,}")
222
+ print(f"{split_name} label distribution:")
223
+ print(df[LABEL_COLUMN].value_counts().sort_index().to_string())
224
+ print()
225
+
226
+ if df.empty:
227
+ raise ValueError(f"No usable rows remain for {split_name}.")
228
+
229
+ return df.reset_index(drop=True)
230
+
231
+
232
+ def clear_mps_cache_if_needed(device: str) -> None:
233
+ if device != "mps":
234
+ return
235
+ mps_backend = getattr(torch, "mps", None)
236
+ if mps_backend is not None and hasattr(mps_backend, "empty_cache"):
237
+ mps_backend.empty_cache()
238
+
239
+
240
+ def extract_logits(outputs) -> torch.Tensor:
241
+ if hasattr(outputs, "logits"):
242
+ return outputs.logits
243
+
244
+ if isinstance(outputs, (tuple, list)):
245
+ for item in outputs:
246
+ if torch.is_tensor(item) and item.ndim >= 2 and item.shape[-1] == 2:
247
+ return item
248
+ return outputs[0]
249
+
250
+ raise TypeError("Could not find logits in model outputs.")
251
+
252
+
253
+ def local_patch_is_ready(patch_dir: Path) -> bool:
254
+ required_files = [
255
+ "config.json",
256
+ "configuration_bert.py",
257
+ "bert_layers.py",
258
+ "bert_padding.py",
259
+ "tokenizer.json",
260
+ "tokenizer_config.json",
261
+ ]
262
+ if not all((patch_dir / filename).exists() for filename in required_files):
263
+ return False
264
+
265
+ bert_layers_text = (patch_dir / "bert_layers.py").read_text(encoding="utf-8")
266
+ return "from .flash_attn_triton import" not in bert_layers_text
267
+
268
+
269
+ def create_patch_from_project_root() -> Path:
270
+ previous_cwd = Path.cwd()
271
+ try:
272
+ os.chdir(PROJECT_ROOT)
273
+ patch_dir = create_local_dnabert2_patch()
274
+ finally:
275
+ os.chdir(previous_cwd)
276
+
277
+ return resolve_project_path(patch_dir)
278
+
279
+
280
+ def get_local_patch_dir() -> Path:
281
+ patch_dir = resolve_project_path(LOCAL_DNABERT2_PATCH_DIR)
282
+ if patch_dir.exists() and local_patch_is_ready(patch_dir):
283
+ return patch_dir
284
+
285
+ print("Local Mac-safe DNABERT-2 patch was not found or is incomplete.")
286
+ return create_patch_from_project_root()
287
+
288
+
289
+ def load_saved_state_dict(model_dir: Path) -> dict[str, torch.Tensor]:
290
+ safetensors_path = model_dir / "model.safetensors"
291
+ pytorch_path = model_dir / "pytorch_model.bin"
292
+
293
+ if safetensors_path.exists():
294
+ print(f"Loading fine-tuned weights: {safetensors_path}")
295
+ return load_safetensors_file(str(safetensors_path), device="cpu")
296
+
297
+ if pytorch_path.exists():
298
+ print(f"Loading fine-tuned weights: {pytorch_path}")
299
+ return torch.load(pytorch_path, map_location="cpu")
300
+
301
+ raise FileNotFoundError(
302
+ "Could not find saved model weights. Expected model.safetensors or pytorch_model.bin in "
303
+ f"{model_dir}"
304
+ )
305
+
306
+
307
+ def load_saved_model_with_local_patch(model_dir: Path):
308
+ """Load Mac-safe DNABERT-2 code, then load the saved fine-tuned weights."""
309
+ patch_dir = get_local_patch_dir()
310
+ print(f"Using local Mac-safe DNABERT-2 code: {patch_dir}")
311
+ print("Triton/flash attention disabled for Mac.")
312
+
313
+ clear_local_patch_module_cache()
314
+ config = AutoConfig.from_pretrained(str(patch_dir), trust_remote_code=True)
315
+ config = disable_flash_attention_on_config(config)
316
+
317
+ saved_config_path = model_dir / "config.json"
318
+ if saved_config_path.exists():
319
+ saved_config = json.loads(saved_config_path.read_text(encoding="utf-8"))
320
+ if saved_config.get("id2label"):
321
+ config.id2label = {int(key): value for key, value in saved_config["id2label"].items()}
322
+ if saved_config.get("label2id"):
323
+ config.label2id = saved_config["label2id"]
324
+
325
+ model = load_sequence_classification_model(str(patch_dir), config)
326
+ state_dict = load_saved_state_dict(model_dir)
327
+ missing_keys, unexpected_keys = model.load_state_dict(state_dict, strict=False)
328
+
329
+ if missing_keys:
330
+ print(f"Warning: missing keys while loading saved weights: {len(missing_keys)}")
331
+ print(missing_keys[:10])
332
+ if unexpected_keys:
333
+ print(f"Warning: unexpected keys while loading saved weights: {len(unexpected_keys)}")
334
+ print(unexpected_keys[:10])
335
+
336
+ print("Model loaded successfully.")
337
+ return model
338
+
339
+
340
+ def load_saved_model(model_dir: Path, device: str):
341
+ if device == "cuda":
342
+ print("CUDA detected. Trying to load saved model directly first.")
343
+ try:
344
+ model = AutoModelForSequenceClassification.from_pretrained(
345
+ str(model_dir),
346
+ trust_remote_code=True,
347
+ low_cpu_mem_usage=False,
348
+ )
349
+ print("Saved model loaded successfully without the local Mac patch.")
350
+ return model
351
+ except Exception as error:
352
+ print("Direct saved-model load failed.")
353
+ print(f"Direct load error: {error}")
354
+ print("Falling back to local no-Triton DNABERT-2 code.")
355
+
356
+ return load_saved_model_with_local_patch(model_dir)
357
+
358
+
359
+ def predict_in_small_batches(model, tokenizer, df: pd.DataFrame, device: str) -> tuple[np.ndarray, np.ndarray]:
360
+ device_object = torch.device(device)
361
+ model.to(device_object)
362
+ model.eval()
363
+
364
+ probabilities: list[float] = []
365
+ labels: list[int] = []
366
+
367
+ for row_number, row in enumerate(tqdm(df.itertuples(index=False), total=len(df), desc="Evaluating"), start=1):
368
+ sequence = getattr(row, SEQUENCE_COLUMN)
369
+ label = int(getattr(row, LABEL_COLUMN))
370
+
371
+ encoded = tokenizer(
372
+ sequence,
373
+ max_length=MAX_LENGTH,
374
+ padding="max_length",
375
+ truncation=True,
376
+ return_tensors="pt",
377
+ )
378
+ encoded = {key: value.to(device_object) for key, value in encoded.items()}
379
+
380
+ with torch.no_grad():
381
+ outputs = model(**encoded)
382
+ logits = extract_logits(outputs)
383
+ probability = torch.softmax(logits.float(), dim=-1)[0, 1].detach().cpu().item()
384
+
385
+ probabilities.append(float(probability))
386
+ labels.append(label)
387
+
388
+ del encoded, outputs, logits
389
+ if row_number % MPS_CACHE_EVERY == 0:
390
+ clear_mps_cache_if_needed(device)
391
+
392
+ gc.collect()
393
+ clear_mps_cache_if_needed(device)
394
+
395
+ return np.asarray(probabilities, dtype=np.float64), np.asarray(labels, dtype=int)
396
+
397
+
398
+ def metrics_at_threshold(probabilities: np.ndarray, labels: np.ndarray, threshold: float) -> dict:
399
+ predictions = (probabilities >= threshold).astype(int)
400
+ matrix = confusion_matrix(labels, predictions, labels=[0, 1]).astype(int)
401
+
402
+ metrics = {
403
+ "threshold": float(threshold),
404
+ "accuracy": float(accuracy_score(labels, predictions)),
405
+ "precision": float(precision_score(labels, predictions, zero_division=0)),
406
+ "recall": float(recall_score(labels, predictions, zero_division=0)),
407
+ "f1": float(f1_score(labels, predictions, zero_division=0)),
408
+ "mcc": float(matthews_corrcoef(labels, predictions)),
409
+ "auc_roc": None,
410
+ "confusion_matrix": matrix.tolist(),
411
+ "rows": int(len(labels)),
412
+ }
413
+
414
+ if len(np.unique(labels)) == 2:
415
+ try:
416
+ metrics["auc_roc"] = float(roc_auc_score(labels, probabilities))
417
+ except ValueError:
418
+ metrics["auc_roc"] = None
419
+
420
+ return metrics
421
+
422
+
423
+ def build_threshold_grid(threshold_min: float, threshold_max: float, threshold_step: float) -> np.ndarray:
424
+ thresholds = np.arange(threshold_min, threshold_max + threshold_step / 2.0, threshold_step)
425
+ thresholds = thresholds[thresholds <= threshold_max + 1e-12]
426
+ return np.round(thresholds, 10)
427
+
428
+
429
+ def tune_threshold_on_validation(
430
+ probabilities: np.ndarray,
431
+ labels: np.ndarray,
432
+ threshold_min: float,
433
+ threshold_max: float,
434
+ threshold_step: float,
435
+ ) -> tuple[float, dict]:
436
+ thresholds = build_threshold_grid(threshold_min, threshold_max, threshold_step)
437
+ if len(thresholds) == 0:
438
+ raise ValueError("No thresholds were generated. Check threshold_min, threshold_max, and threshold_step.")
439
+
440
+ best_threshold = float(thresholds[0])
441
+ best_metrics = metrics_at_threshold(probabilities, labels, best_threshold)
442
+
443
+ for threshold in thresholds[1:]:
444
+ candidate_metrics = metrics_at_threshold(probabilities, labels, float(threshold))
445
+ if candidate_metrics["mcc"] > best_metrics["mcc"]:
446
+ best_threshold = float(threshold)
447
+ best_metrics = candidate_metrics
448
+
449
+ tuning_summary = {
450
+ "threshold_min": float(threshold_min),
451
+ "threshold_max": float(threshold_max),
452
+ "threshold_step": float(threshold_step),
453
+ "thresholds_tested": int(len(thresholds)),
454
+ "best_threshold": best_threshold,
455
+ "best_validation_mcc": float(best_metrics["mcc"]),
456
+ }
457
+
458
+ print(f"Best full-validation threshold: {best_threshold:.4f}")
459
+ print(f"Best full-validation MCC: {best_metrics['mcc']:.4f}")
460
+ print()
461
+
462
+ return best_threshold, tuning_summary
463
+
464
+
465
+ def choose_threshold(args: argparse.Namespace, probabilities: np.ndarray, labels: np.ndarray) -> tuple[float, dict]:
466
+ if args.threshold is not None:
467
+ print(f"Using threshold provided by --threshold: {args.threshold:.4f}")
468
+ print()
469
+ return float(args.threshold), {
470
+ "mode": "manual",
471
+ "selected_threshold": float(args.threshold),
472
+ }
473
+
474
+ if args.tune_threshold:
475
+ print("Tuning threshold on the full validation set.")
476
+ threshold, tuning_summary = tune_threshold_on_validation(
477
+ probabilities,
478
+ labels,
479
+ args.threshold_min,
480
+ args.threshold_max,
481
+ args.threshold_step,
482
+ )
483
+ tuning_summary["mode"] = "full_validation_mcc"
484
+ tuning_summary["selected_threshold"] = threshold
485
+ return threshold, tuning_summary
486
+
487
+ saved_threshold = load_threshold()
488
+ print("--tune_threshold is false and no --threshold was provided.")
489
+ print(f"Falling back to threshold from metrics.json/default: {saved_threshold:.4f}")
490
+ print()
491
+ return saved_threshold, {
492
+ "mode": "saved_or_default",
493
+ "selected_threshold": float(saved_threshold),
494
+ }
495
+
496
+
497
+ def print_metrics(split_name: str, metrics: dict) -> None:
498
+ print("=" * 80)
499
+ print(f"{split_name.upper()} FULL EVALUATION")
500
+ print("=" * 80)
501
+ print(f"Rows: {metrics['rows']:,}")
502
+ print(f"Threshold used: {metrics['threshold']:.4f}")
503
+ for key in ["accuracy", "precision", "recall", "f1", "mcc", "auc_roc"]:
504
+ value = metrics[key]
505
+ if value is None:
506
+ print(f"{key}: n/a")
507
+ else:
508
+ print(f"{key}: {value:.4f}")
509
+
510
+ matrix = metrics["confusion_matrix"]
511
+ print("Confusion matrix:")
512
+ print(" predicted_0 predicted_1")
513
+ print(f"actual_0 {matrix[0][0]:>11} {matrix[0][1]:>11}")
514
+ print(f"actual_1 {matrix[1][0]:>11} {matrix[1][1]:>11}")
515
+ print()
516
+
517
+
518
+ def main() -> None:
519
+ args = parse_args()
520
+ validate_args(args)
521
+
522
+ if not MODEL_DIR.exists():
523
+ raise FileNotFoundError(f"Saved model directory not found: {MODEL_DIR}")
524
+
525
+ dataset_dir = find_dataset_dir()
526
+ device = choose_device()
527
+
528
+ print("Memory-safe saved model evaluation")
529
+ print(f"Saved model directory: {MODEL_DIR}")
530
+ print(f"Selected dataset directory: {dataset_dir}")
531
+ print(f"Selected device: {device}")
532
+ print(f"Tune threshold on full validation set: {args.tune_threshold}")
533
+ if args.threshold is not None:
534
+ print(f"Manual threshold requested: {args.threshold:.4f}")
535
+ else:
536
+ print(
537
+ "Threshold search range: "
538
+ f"{args.threshold_min:.4f} to {args.threshold_max:.4f} "
539
+ f"by {args.threshold_step:.4f}"
540
+ )
541
+ print("Using manual small-batch evaluation. No retraining. No HuggingFace Trainer evaluation.")
542
+ print()
543
+
544
+ print("Loading tokenizer and model.")
545
+ tokenizer = AutoTokenizer.from_pretrained(str(MODEL_DIR), trust_remote_code=True)
546
+ model = load_saved_model(MODEL_DIR, device)
547
+
548
+ all_metrics = {
549
+ "model_dir": str(MODEL_DIR),
550
+ "dataset_dir": str(dataset_dir),
551
+ "device": device,
552
+ "max_length": MAX_LENGTH,
553
+ "variant_center_index": VARIANT_CENTER_INDEX,
554
+ "batch_size": BATCH_SIZE,
555
+ "threshold_args": {
556
+ "tune_threshold": bool(args.tune_threshold),
557
+ "threshold": args.threshold,
558
+ "threshold_min": float(args.threshold_min),
559
+ "threshold_max": float(args.threshold_max),
560
+ "threshold_step": float(args.threshold_step),
561
+ },
562
+ }
563
+
564
+ predictions_by_split = {}
565
+ for split_name, filename in ALT_SPLIT_FILES.items():
566
+ csv_path = dataset_dir / filename
567
+ df = load_eval_dataframe(csv_path, split_name)
568
+ probabilities, labels = predict_in_small_batches(model, tokenizer, df, device)
569
+ predictions_by_split[split_name] = {
570
+ "probabilities": probabilities,
571
+ "labels": labels,
572
+ }
573
+
574
+ validation_predictions = predictions_by_split["validation"]
575
+ threshold, threshold_selection = choose_threshold(
576
+ args,
577
+ validation_predictions["probabilities"],
578
+ validation_predictions["labels"],
579
+ )
580
+
581
+ all_metrics["threshold"] = threshold
582
+ all_metrics["selected_threshold"] = threshold
583
+ all_metrics["threshold_selection"] = threshold_selection
584
+
585
+ for split_name, prediction_data in predictions_by_split.items():
586
+ probabilities = prediction_data["probabilities"]
587
+ labels = prediction_data["labels"]
588
+ split_metrics = metrics_at_threshold(probabilities, labels, threshold)
589
+ all_metrics[f"{split_name}_metrics"] = split_metrics
590
+ print_metrics(split_name, split_metrics)
591
+
592
+ FULL_EVAL_METRICS_PATH.parent.mkdir(parents=True, exist_ok=True)
593
+ FULL_EVAL_METRICS_PATH.write_text(json.dumps(all_metrics, indent=2), encoding="utf-8")
594
+ print(f"Saved full evaluation metrics to: {FULL_EVAL_METRICS_PATH}")
595
+
596
+
597
+ if __name__ == "__main__":
598
+ main()
training/prepare_larger_clinvar_dataset.py ADDED
@@ -0,0 +1,742 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Prepare a larger balanced ClinVar sequence dataset for DNABERT-2.
3
+
4
+ The script prefers existing parsed/filtered CSV data when enough rows are
5
+ available. If not, it downloads/parses the ClinVar GRCh38 VCF, samples a more
6
+ balanced binary dataset, fetches GRCh38 reference sequence windows with resume
7
+ support, and writes both reference-sequence and alternate-sequence CSV files.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import gzip
14
+ import shutil
15
+ import sys
16
+ from pathlib import Path
17
+ from urllib.parse import unquote
18
+
19
+ import pandas as pd
20
+ import requests
21
+ from sklearn.model_selection import train_test_split
22
+ from tqdm.auto import tqdm
23
+
24
+
25
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
26
+ if str(PROJECT_ROOT) not in sys.path:
27
+ sys.path.insert(0, str(PROJECT_ROOT))
28
+
29
+ from training.utils.clinvar_parser import (
30
+ add_variant_id,
31
+ classify_variant_type,
32
+ extract_gene_symbol,
33
+ has_multiple_alt,
34
+ is_sequence_allele,
35
+ is_symbolic_alt,
36
+ parse_info_field,
37
+ )
38
+ from training.utils.label_utils import assign_binary_label, label_name
39
+ from training.utils.sequence_fetcher import build_sequence_fetcher, clean_sequence
40
+
41
+
42
+ CLINVAR_GRCH38_VCF_URL = "https://ftp.ncbi.nlm.nih.gov/pub/clinvar/vcf_GRCh38/clinvar.vcf.gz"
43
+ FLANK_SIZE_DEFAULT = 512
44
+ MIN_SEQUENCE_LENGTH = 200
45
+ PROGRESS_EVERY = 100
46
+
47
+ ORIGINAL_SPLIT_FILES = {
48
+ "train": "train_with_sequences.csv",
49
+ "val": "val_with_sequences.csv",
50
+ "test": "test_with_sequences.csv",
51
+ }
52
+
53
+ ALT_SPLIT_FILES = {
54
+ "train": "train_with_alt_sequences.csv",
55
+ "val": "val_with_alt_sequences.csv",
56
+ "test": "test_with_alt_sequences.csv",
57
+ }
58
+
59
+ DROP_OUTPUT_COLUMNS = {
60
+ "_source_split",
61
+ "_source_kind",
62
+ "ref_sequence",
63
+ "alt_sequence",
64
+ "ref_center",
65
+ "alt_center",
66
+ }
67
+
68
+
69
+ def parse_args() -> argparse.Namespace:
70
+ parser = argparse.ArgumentParser(description=__doc__)
71
+ parser.add_argument("--target_total", type=int, default=5000)
72
+ parser.add_argument("--max_pathogenic", type=int, default=None)
73
+ parser.add_argument("--max_benign", type=int, default=None)
74
+ parser.add_argument("--flank_size", type=int, default=FLANK_SIZE_DEFAULT)
75
+ parser.add_argument(
76
+ "--output_dir",
77
+ type=Path,
78
+ default=None,
79
+ help=(
80
+ "Reference-sequence output directory. If omitted, target_total 5000 writes "
81
+ "training/csv_files_large, 10000 writes training/csv_files_10k, and 20000 "
82
+ "writes training/csv_files_20k."
83
+ ),
84
+ )
85
+ parser.add_argument("--random_state", type=int, default=42)
86
+
87
+ parser.add_argument("--clinvar_vcf", type=Path, default=None, help="Optional local ClinVar GRCh38 VCF.GZ path.")
88
+ parser.add_argument("--fetch_mode", choices=["ucsc", "fasta"], default="ucsc")
89
+ parser.add_argument("--fasta_path", type=Path, default=None, help="Optional local GRCh38 FASTA for --fetch_mode fasta.")
90
+ parser.add_argument("--sequence_cache", type=Path, default=None, help="Optional JSON sequence cache path.")
91
+ parser.add_argument("--sleep_seconds", type=float, default=0.15)
92
+ parser.add_argument("--max_retries", type=int, default=3)
93
+ return parser.parse_args()
94
+
95
+
96
+ def project_root() -> Path:
97
+ return PROJECT_ROOT
98
+
99
+
100
+ def resolve_path(root: Path, path: Path | None) -> Path | None:
101
+ if path is None:
102
+ return None
103
+ return path.expanduser().resolve() if path.is_absolute() else (root / path).resolve()
104
+
105
+
106
+ def alt_output_dir_for(output_dir: Path) -> Path:
107
+ return output_dir.with_name(f"{output_dir.name}_alt")
108
+
109
+
110
+ def default_output_dir_for_target(target_total: int) -> Path:
111
+ if target_total == 5000:
112
+ return Path("training/csv_files_large")
113
+ if target_total == 10000:
114
+ return Path("training/csv_files_10k")
115
+ if target_total == 20000:
116
+ return Path("training/csv_files_20k")
117
+ return Path(f"training/csv_files_{target_total}")
118
+
119
+
120
+ def has_complete_split(directory: Path, split_files: dict[str, str]) -> bool:
121
+ return all((directory / filename).exists() for filename in split_files.values())
122
+
123
+
124
+ def read_split_directory(directory: Path, split_files: dict[str, str], source_kind: str) -> pd.DataFrame:
125
+ frames = []
126
+ for split_name, filename in split_files.items():
127
+ path = directory / filename
128
+ df = pd.read_csv(path)
129
+ df["_source_split"] = split_name
130
+ df["_source_kind"] = source_kind
131
+
132
+ # Alternate CSVs keep the true reference sequence in ref_sequence.
133
+ if source_kind == "alternate_split" and "ref_sequence" in df.columns:
134
+ df["sequence"] = df["ref_sequence"]
135
+
136
+ frames.append(df)
137
+ return pd.concat(frames, ignore_index=True)
138
+
139
+
140
+ def existing_source_candidates(root: Path) -> list[tuple[str, pd.DataFrame]]:
141
+ candidates: list[tuple[str, pd.DataFrame]] = []
142
+
143
+ for path in [
144
+ root / "data" / "processed" / "clinvar_binary_variants.csv",
145
+ root / "training" / "csv_files" / "clinvar_binary_variants.csv",
146
+ root / "training" / "csv_files_20k" / "clinvar_binary_variants.csv",
147
+ root / "training" / "csv_files_10k" / "clinvar_binary_variants.csv",
148
+ root / "training" / "csv_files_large" / "clinvar_binary_variants.csv",
149
+ ]:
150
+ if path.exists():
151
+ candidates.append((str(path), pd.read_csv(path)))
152
+
153
+ for directory in [
154
+ root / "training" / "csv_files_20k",
155
+ root / "training" / "csv_files_10k",
156
+ root / "training" / "csv_files_large",
157
+ root / "training" / "csv_files",
158
+ root / "data" / "processed",
159
+ ]:
160
+ if has_complete_split(directory, ORIGINAL_SPLIT_FILES):
161
+ candidates.append((str(directory), read_split_directory(directory, ORIGINAL_SPLIT_FILES, "reference_split")))
162
+
163
+ for directory in [
164
+ root / "training" / "csv_files_20k_alt",
165
+ root / "training" / "csv_files_10k_alt",
166
+ root / "training" / "csv_files_large_alt",
167
+ root / "training" / "csv_files_alt",
168
+ ]:
169
+ if has_complete_split(directory, ALT_SPLIT_FILES):
170
+ candidates.append((str(directory), read_split_directory(directory, ALT_SPLIT_FILES, "alternate_split")))
171
+
172
+ return candidates
173
+
174
+
175
+ def normalize_allele(value: object) -> str:
176
+ return str(value).strip().upper()
177
+
178
+
179
+ def normalize_clnsig_display(value: object) -> str:
180
+ return unquote(str(value)).strip()
181
+
182
+
183
+ def filter_clear_variants(df: pd.DataFrame) -> pd.DataFrame:
184
+ required = {"CHROM", "POS", "REF", "ALT"}
185
+ missing = sorted(required - set(df.columns))
186
+ if missing:
187
+ raise ValueError(f"Variant table is missing required columns: {missing}")
188
+
189
+ filtered = df.copy()
190
+ filtered["REF"] = filtered["REF"].apply(normalize_allele)
191
+ filtered["ALT"] = filtered["ALT"].apply(normalize_allele)
192
+ filtered["POS"] = pd.to_numeric(filtered["POS"], errors="coerce")
193
+ filtered = filtered.loc[filtered["POS"].notna()].copy()
194
+ filtered["POS"] = filtered["POS"].astype(int)
195
+
196
+ if "CLNSIG" in filtered.columns:
197
+ filtered["CLNSIG"] = filtered["CLNSIG"].apply(normalize_clnsig_display)
198
+ filtered["label"] = filtered["CLNSIG"].apply(assign_binary_label)
199
+ elif "label" in filtered.columns:
200
+ filtered["label"] = pd.to_numeric(filtered["label"], errors="coerce")
201
+ filtered = filtered.loc[filtered["label"].isin([0, 1])].copy()
202
+ else:
203
+ raise ValueError("Variant table needs either CLNSIG or label.")
204
+
205
+ filtered["variant_type"] = filtered.apply(lambda row: classify_variant_type(row["REF"], row["ALT"]), axis=1)
206
+
207
+ keep_mask = (
208
+ filtered["label"].notna()
209
+ & ~filtered["ALT"].apply(has_multiple_alt)
210
+ & ~filtered["ALT"].apply(is_symbolic_alt)
211
+ & filtered["REF"].apply(is_sequence_allele)
212
+ & filtered["ALT"].apply(is_sequence_allele)
213
+ & filtered["variant_type"].notna()
214
+ )
215
+ filtered = filtered.loc[keep_mask].copy()
216
+ filtered["label"] = filtered["label"].astype(int)
217
+
218
+ if "GENEINFO" in filtered.columns and "gene_symbol" not in filtered.columns:
219
+ filtered["gene_symbol"] = filtered["GENEINFO"].apply(extract_gene_symbol)
220
+ if "label_name" not in filtered.columns:
221
+ filtered["label_name"] = filtered["label"].apply(label_name)
222
+ if "variant_id" not in filtered.columns:
223
+ filtered["variant_id"] = filtered.apply(add_variant_id, axis=1)
224
+
225
+ filtered = filtered.drop_duplicates(subset=["variant_id"]).reset_index(drop=True)
226
+ return filtered
227
+
228
+
229
+ def download_clinvar_vcf(vcf_path: Path) -> Path:
230
+ vcf_path.parent.mkdir(parents=True, exist_ok=True)
231
+ tmp_path = vcf_path.with_suffix(vcf_path.suffix + ".tmp")
232
+
233
+ print(f"Downloading ClinVar GRCh38 VCF to: {vcf_path}")
234
+ with requests.get(CLINVAR_GRCH38_VCF_URL, stream=True, timeout=60) as response:
235
+ response.raise_for_status()
236
+ total = int(response.headers.get("content-length", 0))
237
+ with tmp_path.open("wb") as handle, tqdm(total=total, unit="B", unit_scale=True, desc="Downloading VCF") as bar:
238
+ for chunk in response.iter_content(chunk_size=1024 * 1024):
239
+ if not chunk:
240
+ continue
241
+ handle.write(chunk)
242
+ bar.update(len(chunk))
243
+
244
+ shutil.move(str(tmp_path), str(vcf_path))
245
+ return vcf_path
246
+
247
+
248
+ def find_or_download_vcf(root: Path, requested_vcf: Path | None) -> Path:
249
+ if requested_vcf is not None:
250
+ vcf_path = resolve_path(root, requested_vcf)
251
+ if vcf_path is None or not vcf_path.exists():
252
+ raise FileNotFoundError(f"ClinVar VCF not found: {vcf_path}")
253
+ return vcf_path
254
+
255
+ candidates = [
256
+ root / "data" / "raw" / "clinvar.vcf.gz",
257
+ root / "training" / "data" / "raw" / "clinvar.vcf.gz",
258
+ root / "clinvar.vcf.gz",
259
+ ]
260
+ for path in candidates:
261
+ if path.exists():
262
+ return path
263
+
264
+ return download_clinvar_vcf(root / "data" / "raw" / "clinvar.vcf.gz")
265
+
266
+
267
+ def parse_and_filter_vcf(vcf_path: Path) -> tuple[pd.DataFrame, int]:
268
+ records: list[dict[str, object]] = []
269
+ total_records = 0
270
+
271
+ print(f"Parsing ClinVar VCF: {vcf_path}")
272
+ with gzip.open(vcf_path, "rt", encoding="utf-8") as handle:
273
+ for line in tqdm(handle, desc="Parsing VCF records"):
274
+ if line.startswith("#"):
275
+ continue
276
+
277
+ total_records += 1
278
+ fields = line.rstrip("\n").split("\t")
279
+ if len(fields) < 8:
280
+ continue
281
+
282
+ chrom, pos_raw, clinvar_id, ref, alt, _qual, _filter, info_raw = fields[:8]
283
+ info = parse_info_field(info_raw)
284
+ clnsig = info.get("CLNSIG")
285
+ label = assign_binary_label(clnsig)
286
+ ref = ref.upper()
287
+ alt = alt.upper()
288
+ variant_type = classify_variant_type(ref, alt)
289
+
290
+ if (
291
+ label is None
292
+ or has_multiple_alt(alt)
293
+ or is_symbolic_alt(alt)
294
+ or not is_sequence_allele(ref)
295
+ or not is_sequence_allele(alt)
296
+ or variant_type is None
297
+ ):
298
+ continue
299
+
300
+ gene_info = info.get("GENEINFO")
301
+ record = {
302
+ "CHROM": chrom,
303
+ "POS": int(pos_raw),
304
+ "ID": clinvar_id,
305
+ "REF": ref,
306
+ "ALT": alt,
307
+ "variant_type": variant_type,
308
+ "gene_symbol": extract_gene_symbol(gene_info),
309
+ "GENEINFO": gene_info,
310
+ "CLNSIG": clnsig,
311
+ "CLNHGVS": info.get("CLNHGVS"),
312
+ "CLNVC": info.get("CLNVC"),
313
+ "label": int(label),
314
+ "label_name": label_name(label),
315
+ }
316
+ record["variant_id"] = add_variant_id(pd.Series(record))
317
+ records.append(record)
318
+
319
+ filtered = pd.DataFrame.from_records(records)
320
+ if not filtered.empty:
321
+ filtered = filtered.drop_duplicates(subset=["variant_id"]).reset_index(drop=True)
322
+ return filtered, total_records
323
+
324
+
325
+ def choose_variant_source(root: Path, target_total: int, requested_vcf: Path | None) -> tuple[pd.DataFrame, str, int, int]:
326
+ best_source_name = ""
327
+ best_source = pd.DataFrame()
328
+ best_before = 0
329
+
330
+ for source_name, source_df in existing_source_candidates(root):
331
+ before = len(source_df)
332
+ filtered = filter_clear_variants(source_df)
333
+ print(f"Existing source candidate: {source_name}")
334
+ print(f" rows before filtering: {before:,}")
335
+ print(f" rows after filtering: {len(filtered):,}")
336
+
337
+ if len(filtered) > len(best_source):
338
+ best_source_name = source_name
339
+ best_source = filtered
340
+ best_before = before
341
+
342
+ if len(filtered) >= target_total:
343
+ return filtered, source_name, before, len(filtered)
344
+
345
+ if not best_source.empty and len(best_source) >= target_total:
346
+ return best_source, best_source_name, best_before, len(best_source)
347
+
348
+ if not best_source.empty:
349
+ print(
350
+ "Existing data is available but smaller than target_total. "
351
+ "Parsing ClinVar VCF to build a larger candidate set."
352
+ )
353
+
354
+ vcf_path = find_or_download_vcf(root, requested_vcf)
355
+ parsed_df, total_records = parse_and_filter_vcf(vcf_path)
356
+ return parsed_df, str(vcf_path), total_records, len(parsed_df)
357
+
358
+
359
+ def validate_positive_int(name: str, value: int | None) -> None:
360
+ if value is not None and value <= 0:
361
+ raise ValueError(f"{name} must be positive when provided.")
362
+
363
+
364
+ def sample_or_all(df: pd.DataFrame, n: int, random_state: int) -> pd.DataFrame:
365
+ if len(df) <= n:
366
+ return df.copy()
367
+ return df.sample(n=n, random_state=random_state)
368
+
369
+
370
+ def balanced_sample(
371
+ df: pd.DataFrame,
372
+ target_total: int,
373
+ max_pathogenic: int | None,
374
+ max_benign: int | None,
375
+ random_state: int,
376
+ ) -> pd.DataFrame:
377
+ if target_total <= 0:
378
+ raise ValueError("--target_total must be positive.")
379
+ validate_positive_int("--max_pathogenic", max_pathogenic)
380
+ validate_positive_int("--max_benign", max_benign)
381
+
382
+ pathogenic = df.loc[df["label"] == 1].copy()
383
+ benign = df.loc[df["label"] == 0].copy()
384
+ target_per_class = max(1, target_total // 2)
385
+
386
+ pathogenic_limit = min(len(pathogenic), target_per_class)
387
+ if max_pathogenic is not None:
388
+ pathogenic_limit = min(pathogenic_limit, max_pathogenic)
389
+
390
+ pathogenic_sample = sample_or_all(pathogenic, pathogenic_limit, random_state)
391
+
392
+ benign_limit = min(len(benign), len(pathogenic_sample))
393
+ if max_benign is not None:
394
+ benign_limit = min(benign_limit, max_benign)
395
+
396
+ benign_sample = sample_or_all(benign, benign_limit, random_state)
397
+
398
+ sampled = pd.concat([pathogenic_sample, benign_sample], ignore_index=True)
399
+ sampled = sampled.sample(frac=1.0, random_state=random_state).reset_index(drop=True)
400
+ return sampled
401
+
402
+
403
+ def sequence_matches_ref(sequence: str | None, ref: str, flank_size: int) -> bool:
404
+ sequence = clean_sequence(sequence)
405
+ if sequence is None or len(sequence) < flank_size + len(ref):
406
+ return False
407
+ return sequence[flank_size : flank_size + len(ref)] == ref
408
+
409
+
410
+ def load_progress_sequences(progress_path: Path) -> dict[str, str]:
411
+ if not progress_path.exists():
412
+ return {}
413
+
414
+ progress_df = pd.read_csv(progress_path)
415
+ if "variant_id" not in progress_df.columns or "sequence" not in progress_df.columns:
416
+ return {}
417
+
418
+ sequences: dict[str, str] = {}
419
+ for row in progress_df.itertuples(index=False):
420
+ variant_id = str(getattr(row, "variant_id"))
421
+ sequence = clean_sequence(getattr(row, "sequence"))
422
+ if sequence is not None:
423
+ sequences[variant_id] = sequence
424
+ return sequences
425
+
426
+
427
+ def save_progress(records: list[dict[str, object]], progress_path: Path) -> None:
428
+ progress_path.parent.mkdir(parents=True, exist_ok=True)
429
+ pd.DataFrame.from_records(records).to_csv(progress_path, index=False)
430
+
431
+
432
+ def save_dataframe(df: pd.DataFrame, path: Path) -> Path:
433
+ path.parent.mkdir(parents=True, exist_ok=True)
434
+ df.to_csv(path, index=False)
435
+ return path
436
+
437
+
438
+ def save_fetcher_cache(fetcher) -> None:
439
+ if hasattr(fetcher, "save_cache"):
440
+ fetcher.save_cache()
441
+
442
+
443
+ def fetch_reference_sequences(
444
+ df: pd.DataFrame,
445
+ output_dir: Path,
446
+ flank_size: int,
447
+ fetch_mode: str,
448
+ fasta_path: Path | None,
449
+ sequence_cache: Path | None,
450
+ sleep_seconds: float,
451
+ max_retries: int,
452
+ ) -> tuple[pd.DataFrame, dict[str, int]]:
453
+ output_dir.mkdir(parents=True, exist_ok=True)
454
+ progress_path = output_dir / "sequence_progress.csv"
455
+ cache_path = sequence_cache or (output_dir / f"sequence_cache_flank{flank_size}.json")
456
+ progress_sequences = load_progress_sequences(progress_path)
457
+
458
+ fetcher = build_sequence_fetcher(
459
+ mode=fetch_mode,
460
+ fasta_path=fasta_path,
461
+ cache_path=cache_path,
462
+ sleep_seconds=sleep_seconds,
463
+ max_retries=max_retries,
464
+ )
465
+
466
+ records: list[dict[str, object]] = []
467
+ failed_fetches = 0
468
+ ref_mismatches = 0
469
+ too_short = 0
470
+ reused_source_sequences = 0
471
+ reused_progress_sequences = 0
472
+
473
+ for processed, row_dict in enumerate(tqdm(df.to_dict("records"), total=len(df), desc="Fetching reference sequences"), start=1):
474
+ variant_id = str(row_dict["variant_id"])
475
+ ref = str(row_dict["REF"]).upper()
476
+
477
+ sequence = clean_sequence(row_dict.get("sequence"))
478
+ if sequence_matches_ref(sequence, ref, flank_size):
479
+ reused_source_sequences += 1
480
+ else:
481
+ sequence = clean_sequence(progress_sequences.get(variant_id))
482
+ if sequence_matches_ref(sequence, ref, flank_size):
483
+ reused_progress_sequences += 1
484
+ else:
485
+ sequence = fetcher.fetch(row_dict["CHROM"], int(row_dict["POS"]), ref, flank_size)
486
+ sequence = clean_sequence(sequence)
487
+
488
+ if sequence is None:
489
+ failed_fetches += 1
490
+ elif len(sequence) < MIN_SEQUENCE_LENGTH:
491
+ too_short += 1
492
+ elif not sequence_matches_ref(sequence, ref, flank_size):
493
+ ref_mismatches += 1
494
+ else:
495
+ row_dict["sequence"] = sequence
496
+ records.append(row_dict)
497
+
498
+ if processed % PROGRESS_EVERY == 0:
499
+ save_progress(records, progress_path)
500
+ save_fetcher_cache(fetcher)
501
+ print(f"Saved sequence progress after {processed:,} variants: {progress_path}")
502
+
503
+ save_progress(records, progress_path)
504
+ save_fetcher_cache(fetcher)
505
+
506
+ stats = {
507
+ "input_rows": int(len(df)),
508
+ "output_rows": int(len(records)),
509
+ "failed_fetches": int(failed_fetches),
510
+ "too_short": int(too_short),
511
+ "ref_mismatches": int(ref_mismatches),
512
+ "reused_source_sequences": int(reused_source_sequences),
513
+ "reused_progress_sequences": int(reused_progress_sequences),
514
+ }
515
+ return pd.DataFrame.from_records(records), stats
516
+
517
+
518
+ def stratified_train_val_test_split(df: pd.DataFrame, random_state: int) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
519
+ stratify = df["label"] if df["label"].nunique() == 2 and df["label"].value_counts().min() >= 3 else None
520
+ train_df, temp_df = train_test_split(
521
+ df,
522
+ test_size=0.30,
523
+ random_state=random_state,
524
+ stratify=stratify,
525
+ )
526
+
527
+ temp_stratify = temp_df["label"] if temp_df["label"].nunique() == 2 and temp_df["label"].value_counts().min() >= 2 else None
528
+ val_df, test_df = train_test_split(
529
+ temp_df,
530
+ test_size=0.50,
531
+ random_state=random_state,
532
+ stratify=temp_stratify,
533
+ )
534
+ return train_df.reset_index(drop=True), val_df.reset_index(drop=True), test_df.reset_index(drop=True)
535
+
536
+
537
+ def clean_reference_output(df: pd.DataFrame) -> pd.DataFrame:
538
+ columns_to_drop = [column for column in DROP_OUTPUT_COLUMNS if column in df.columns]
539
+ return df.drop(columns=columns_to_drop, errors="ignore")
540
+
541
+
542
+ def build_alt_sequences(df: pd.DataFrame, flank_size: int) -> tuple[pd.DataFrame, int]:
543
+ records: list[dict[str, object]] = []
544
+ failed = 0
545
+
546
+ for row_dict in df.to_dict("records"):
547
+ ref_sequence = clean_sequence(row_dict["sequence"])
548
+ ref = str(row_dict["REF"]).upper()
549
+ alt = str(row_dict["ALT"]).upper()
550
+
551
+ if ref_sequence is None or not sequence_matches_ref(ref_sequence, ref, flank_size):
552
+ failed += 1
553
+ continue
554
+
555
+ upstream = ref_sequence[:flank_size]
556
+ downstream = ref_sequence[flank_size + len(ref) :]
557
+ alt_sequence = upstream + alt + downstream
558
+
559
+ output_row = row_dict.copy()
560
+ output_row["ref_sequence"] = ref_sequence
561
+ output_row["alt_sequence"] = alt_sequence
562
+ output_row["sequence"] = alt_sequence
563
+ output_row["ref_center"] = ref_sequence[flank_size : flank_size + len(ref)]
564
+ output_row["alt_center"] = alt_sequence[flank_size : flank_size + len(alt)]
565
+ records.append(output_row)
566
+
567
+ return pd.DataFrame.from_records(records), failed
568
+
569
+
570
+ def save_splits(
571
+ train_df: pd.DataFrame,
572
+ val_df: pd.DataFrame,
573
+ test_df: pd.DataFrame,
574
+ output_dir: Path,
575
+ alt_output_dir: Path,
576
+ flank_size: int,
577
+ ) -> tuple[dict[str, Path], dict[str, Path], int]:
578
+ output_dir.mkdir(parents=True, exist_ok=True)
579
+ alt_output_dir.mkdir(parents=True, exist_ok=True)
580
+
581
+ reference_paths: dict[str, Path] = {}
582
+ alt_paths: dict[str, Path] = {}
583
+ total_alt_failures = 0
584
+
585
+ for split_name, split_df in [("train", train_df), ("val", val_df), ("test", test_df)]:
586
+ reference_path = output_dir / ORIGINAL_SPLIT_FILES[split_name]
587
+ reference_df = clean_reference_output(split_df)
588
+ reference_df.to_csv(reference_path, index=False)
589
+ reference_paths[split_name] = reference_path
590
+
591
+ alt_df, alt_failures = build_alt_sequences(reference_df, flank_size)
592
+ total_alt_failures += alt_failures
593
+ alt_path = alt_output_dir / ALT_SPLIT_FILES[split_name]
594
+ alt_df.to_csv(alt_path, index=False)
595
+ alt_paths[split_name] = alt_path
596
+
597
+ return reference_paths, alt_paths, total_alt_failures
598
+
599
+
600
+ def print_distribution(title: str, df: pd.DataFrame) -> None:
601
+ print(title)
602
+ if df.empty or "label" not in df.columns:
603
+ print(" no rows")
604
+ else:
605
+ print(df["label"].value_counts().sort_index().to_string())
606
+ print()
607
+
608
+
609
+ def warn_if_imbalanced(df: pd.DataFrame) -> None:
610
+ counts = df["label"].value_counts().sort_index()
611
+ if len(counts) < 2:
612
+ print("WARNING: selected sample contains only one class. Training will not be useful.")
613
+ return
614
+ if counts.iloc[0] != counts.iloc[1]:
615
+ print("WARNING: exact class balance was not possible with the available/capped variants.")
616
+ print(counts.to_string())
617
+ print()
618
+
619
+
620
+ def main() -> None:
621
+ args = parse_args()
622
+ root = project_root()
623
+ output_dir_arg = args.output_dir or default_output_dir_for_target(args.target_total)
624
+ output_dir = resolve_path(root, output_dir_arg)
625
+ if output_dir is None:
626
+ raise ValueError("--output_dir is required")
627
+ alt_output_dir = alt_output_dir_for(output_dir)
628
+ requested_vcf = resolve_path(root, args.clinvar_vcf)
629
+ fasta_path = resolve_path(root, args.fasta_path)
630
+ sequence_cache = resolve_path(root, args.sequence_cache)
631
+
632
+ print("Prepare larger ClinVar dataset")
633
+ print(f"Target total rows: {args.target_total:,}")
634
+ print(f"Output reference directory: {output_dir}")
635
+ print(f"Output alternate directory: {alt_output_dir}")
636
+ print(f"Flank size: {args.flank_size}")
637
+ print()
638
+
639
+ variants_df, source_name, before_filter_count, after_filter_count = choose_variant_source(
640
+ root=root,
641
+ target_total=args.target_total,
642
+ requested_vcf=requested_vcf,
643
+ )
644
+
645
+ print("=" * 80)
646
+ print("FILTERING SUMMARY")
647
+ print("=" * 80)
648
+ print(f"Selected source: {source_name}")
649
+ print(f"Total variants before filtering: {before_filter_count:,}")
650
+ print(f"Total variants after filtering: {after_filter_count:,}")
651
+ print_distribution("Class distribution after filtering:", variants_df)
652
+ filtered_variants_path = save_dataframe(variants_df, output_dir / "clinvar_binary_variants.csv")
653
+ print(f"Saved filtered variant table: {filtered_variants_path}")
654
+ print()
655
+
656
+ if variants_df.empty:
657
+ raise RuntimeError("No variants remained after filtering. Check the input VCF/CSV and CLNSIG labels.")
658
+
659
+ sampled_df = balanced_sample(
660
+ variants_df,
661
+ target_total=args.target_total,
662
+ max_pathogenic=args.max_pathogenic,
663
+ max_benign=args.max_benign,
664
+ random_state=args.random_state,
665
+ )
666
+
667
+ print("=" * 80)
668
+ print("SAMPLING SUMMARY")
669
+ print("=" * 80)
670
+ print(f"Selected sample size: {len(sampled_df):,}")
671
+ print_distribution("Class distribution after balanced sampling:", sampled_df)
672
+ warn_if_imbalanced(sampled_df)
673
+ selected_variants_path = save_dataframe(sampled_df, output_dir / "selected_variants.csv")
674
+ print(f"Saved selected variant table: {selected_variants_path}")
675
+ print()
676
+
677
+ if sampled_df.empty or sampled_df["label"].nunique() < 2:
678
+ raise RuntimeError("Balanced sampling did not produce both classes. Adjust target_total/max caps.")
679
+
680
+ sequenced_df, fetch_stats = fetch_reference_sequences(
681
+ sampled_df,
682
+ output_dir=output_dir,
683
+ flank_size=args.flank_size,
684
+ fetch_mode=args.fetch_mode,
685
+ fasta_path=fasta_path,
686
+ sequence_cache=sequence_cache,
687
+ sleep_seconds=args.sleep_seconds,
688
+ max_retries=args.max_retries,
689
+ )
690
+
691
+ print("=" * 80)
692
+ print("SEQUENCE FETCH SUMMARY")
693
+ print("=" * 80)
694
+ print(f"Rows requested for sequence fetching: {fetch_stats['input_rows']:,}")
695
+ print(f"Rows with usable reference sequences: {fetch_stats['output_rows']:,}")
696
+ print(f"Failed sequence fetches: {fetch_stats['failed_fetches']:,}")
697
+ print(f"Too-short sequences: {fetch_stats['too_short']:,}")
698
+ print(f"Reference mismatches at variant center: {fetch_stats['ref_mismatches']:,}")
699
+ print(f"Reused source sequences: {fetch_stats['reused_source_sequences']:,}")
700
+ print(f"Reused progress sequences: {fetch_stats['reused_progress_sequences']:,}")
701
+ print_distribution("Class distribution after sequence filtering:", sequenced_df)
702
+
703
+ if sequenced_df.empty:
704
+ raise RuntimeError("No usable sequences were created. Check VCF/input data and sequence fetching settings.")
705
+
706
+ train_df, val_df, test_df = stratified_train_val_test_split(sequenced_df, args.random_state)
707
+
708
+ print("=" * 80)
709
+ print("SPLIT SUMMARY")
710
+ print("=" * 80)
711
+ print(f"Train rows: {len(train_df):,}")
712
+ print(f"Validation rows: {len(val_df):,}")
713
+ print(f"Test rows: {len(test_df):,}")
714
+ print_distribution("Train label distribution:", train_df)
715
+ print_distribution("Validation label distribution:", val_df)
716
+ print_distribution("Test label distribution:", test_df)
717
+
718
+ reference_paths, alt_paths, alt_failures = save_splits(
719
+ train_df=train_df,
720
+ val_df=val_df,
721
+ test_df=test_df,
722
+ output_dir=output_dir,
723
+ alt_output_dir=alt_output_dir,
724
+ flank_size=args.flank_size,
725
+ )
726
+
727
+ print("=" * 80)
728
+ print("OUTPUT SUMMARY")
729
+ print("=" * 80)
730
+ print(f"Alternate sequence build failures: {alt_failures:,}")
731
+ print("Reference-sequence output paths:")
732
+ for split_name, path in reference_paths.items():
733
+ print(f" {split_name}: {path}")
734
+ print("Alternate-sequence output paths:")
735
+ for split_name, path in alt_paths.items():
736
+ print(f" {split_name}: {path}")
737
+ print()
738
+ print("Larger ClinVar dataset preparation completed.")
739
+
740
+
741
+ if __name__ == "__main__":
742
+ main()
training/requirements-colab.txt CHANGED
@@ -8,7 +8,9 @@ pandas>=2.2.0
8
  pyfaidx>=0.8.1
9
  pysam>=0.22.0
10
  requests>=2.32.0
 
11
  scikit-learn>=1.4.0
12
  torch>=2.2.0
13
  tqdm>=4.66.0
14
  transformers>=4.41.0
 
 
8
  pyfaidx>=0.8.1
9
  pysam>=0.22.0
10
  requests>=2.32.0
11
+ safetensors>=0.4.3
12
  scikit-learn>=1.4.0
13
  torch>=2.2.0
14
  tqdm>=4.66.0
15
  transformers>=4.41.0
16
+ joblib>=1.4.0
training/requirements-mac.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ torch
2
+ transformers
3
+ datasets
4
+ accelerate
5
+ scikit-learn
6
+ pandas
7
+ numpy
8
+ evaluate
9
+ einops
10
+ tqdm
11
+ safetensors
training/scripts/train_dnabert2_classifier.py CHANGED
@@ -57,6 +57,12 @@ def parse_args() -> argparse.Namespace:
57
  parser.add_argument("--batch-size", type=int, default=8)
58
  parser.add_argument("--learning-rate", type=float, default=2e-5)
59
  parser.add_argument("--seed", type=int, default=42)
 
 
 
 
 
 
60
  parser.add_argument("--no-clean-clnsig", action="store_true", help="Do not re-filter CLNSIG labels in CSV inputs.")
61
  return parser.parse_args()
62
 
@@ -196,6 +202,15 @@ def compute_metrics(eval_pred):
196
  }
197
 
198
 
 
 
 
 
 
 
 
 
 
199
  def build_training_args(args: argparse.Namespace, output_dir: Path) -> TrainingArguments:
200
  kwargs = {
201
  "output_dir": str(output_dir / "checkpoints"),
@@ -212,6 +227,25 @@ def build_training_args(args: argparse.Namespace, output_dir: Path) -> TrainingA
212
  }
213
 
214
  signature = inspect.signature(TrainingArguments.__init__)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  if "eval_strategy" in signature.parameters:
216
  kwargs["eval_strategy"] = "epoch"
217
  else:
@@ -244,6 +278,10 @@ def main() -> None:
244
  else:
245
  raise ValueError("Provide either --dataset-jsonl or all of --train-csv, --val-csv, and --test-csv.")
246
 
 
 
 
 
247
  tokenizer = AutoTokenizer.from_pretrained(args.model_name, trust_remote_code=True)
248
 
249
  def tokenize(batch):
 
57
  parser.add_argument("--batch-size", type=int, default=8)
58
  parser.add_argument("--learning-rate", type=float, default=2e-5)
59
  parser.add_argument("--seed", type=int, default=42)
60
+ parser.add_argument("--logging-steps", type=int, default=10)
61
+ parser.add_argument("--max-train-samples", type=int, default=0, help="Optional cap for quick smoke tests. 0 means all rows.")
62
+ parser.add_argument("--max-val-samples", type=int, default=0, help="Optional cap for quick smoke tests. 0 means all rows.")
63
+ parser.add_argument("--max-test-samples", type=int, default=0, help="Optional cap for quick smoke tests. 0 means all rows.")
64
+ parser.add_argument("--max-steps", type=int, default=0, help="Optional Trainer max_steps for smoke tests. 0 means disabled.")
65
+ parser.add_argument("--force-cpu", action="store_true", help="Force CPU training even when a GPU is visible.")
66
  parser.add_argument("--no-clean-clnsig", action="store_true", help="Do not re-filter CLNSIG labels in CSV inputs.")
67
  return parser.parse_args()
68
 
 
202
  }
203
 
204
 
205
+ def limit_dataset(dataset: Dataset, max_samples: int, split_name: str, seed: int) -> Dataset:
206
+ """Optionally limit a dataset for fast CPU smoke tests."""
207
+ if max_samples <= 0 or len(dataset) <= max_samples:
208
+ return dataset
209
+ limited = dataset.shuffle(seed=seed).select(range(max_samples))
210
+ print(f"{split_name}: using {len(limited):,}/{len(dataset):,} rows for this run")
211
+ return limited
212
+
213
+
214
  def build_training_args(args: argparse.Namespace, output_dir: Path) -> TrainingArguments:
215
  kwargs = {
216
  "output_dir": str(output_dir / "checkpoints"),
 
227
  }
228
 
229
  signature = inspect.signature(TrainingArguments.__init__)
230
+ optional_kwargs = {
231
+ "disable_tqdm": False,
232
+ "logging_strategy": "steps",
233
+ "logging_steps": args.logging_steps,
234
+ "logging_first_step": True,
235
+ "save_total_limit": 2,
236
+ }
237
+ if args.max_steps > 0:
238
+ optional_kwargs["max_steps"] = args.max_steps
239
+ if args.force_cpu:
240
+ if "use_cpu" in signature.parameters:
241
+ optional_kwargs["use_cpu"] = True
242
+ elif "no_cuda" in signature.parameters:
243
+ optional_kwargs["no_cuda"] = True
244
+
245
+ for key, value in optional_kwargs.items():
246
+ if key in signature.parameters:
247
+ kwargs[key] = value
248
+
249
  if "eval_strategy" in signature.parameters:
250
  kwargs["eval_strategy"] = "epoch"
251
  else:
 
278
  else:
279
  raise ValueError("Provide either --dataset-jsonl or all of --train-csv, --val-csv, and --test-csv.")
280
 
281
+ train_dataset = limit_dataset(train_dataset, args.max_train_samples, "train", args.seed)
282
+ eval_dataset = limit_dataset(eval_dataset, args.max_val_samples, "val", args.seed)
283
+ test_dataset = limit_dataset(test_dataset, args.max_test_samples, "test", args.seed)
284
+
285
  tokenizer = AutoTokenizer.from_pretrained(args.model_name, trust_remote_code=True)
286
 
287
  def tokenize(batch):
training/setup_mac.md ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Mac Local Training Setup
2
+
3
+ This guide sets up a Mac-friendly Python environment for local DNABERT-2 experiments.
4
+
5
+ Full DNABERT-2 training can be slow on a Mac, especially on CPU. Start with small smoke tests before running larger jobs.
6
+
7
+ ## CSV File Locations
8
+
9
+ Training scripts should look for sequence CSVs in this order:
10
+
11
+ 1. `data/processed/`
12
+ 2. `training/csv_files/`
13
+
14
+ Your current CSV files are in:
15
+
16
+ ```text
17
+ training/csv_files/train_with_sequences.csv
18
+ training/csv_files/val_with_sequences.csv
19
+ training/csv_files/test_with_sequences.csv
20
+ ```
21
+
22
+ That location is supported.
23
+
24
+ ## 1. Create Virtual Environment
25
+
26
+ Run this from the project root:
27
+
28
+ ```bash
29
+ python3 -m venv .venv
30
+ ```
31
+
32
+ ## 2. Activate It
33
+
34
+ ```bash
35
+ source .venv/bin/activate
36
+ ```
37
+
38
+ Your terminal prompt should now show `.venv`.
39
+
40
+ ## 3. Upgrade pip
41
+
42
+ ```bash
43
+ python -m pip install --upgrade pip
44
+ ```
45
+
46
+ ## 4. Install Requirements
47
+
48
+ ```bash
49
+ pip install -r training/requirements-mac.txt
50
+ ```
51
+
52
+ ## 5. Check PyTorch Device
53
+
54
+ ```bash
55
+ python training/check_device.py
56
+ ```
57
+
58
+ Expected output includes one of:
59
+
60
+ ```text
61
+ Using device: mps
62
+ ```
63
+
64
+ or:
65
+
66
+ ```text
67
+ Using device: cpu
68
+ ```
69
+
70
+ If you see `Using device: cpu`, the project will still work, but local training will be slow. Use very small smoke tests locally and use a GPU environment for full model training.
71
+
72
+ ## 6. Check Dataset
73
+
74
+ Before training, verify the CSV files:
75
+
76
+ ```bash
77
+ python training/check_dataset.py
78
+ ```
79
+
80
+ This confirms that the `sequence` and `label` columns exist, labels are encoded as `0` and `1`, sequence values are present, and class balance is reasonable.
training/train_local_dnabert2.py ADDED
@@ -0,0 +1,1212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Fine-tune DNABERT-2 on prepared ClinVar sequence CSV files.
3
+
4
+ This script works on CUDA, Mac MPS, and CPU. CUDA tries the standard
5
+ Hugging Face DNABERT-2 model first; MPS and fallback paths use the local
6
+ no-Triton patch from train_smoke_test.py.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import gc
13
+ import inspect
14
+ import json
15
+ import os
16
+ import sys
17
+ import zipfile
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ import numpy as np
22
+ import pandas as pd
23
+ import torch
24
+ from datasets import Dataset
25
+ from sklearn.metrics import (
26
+ accuracy_score,
27
+ confusion_matrix,
28
+ f1_score,
29
+ matthews_corrcoef,
30
+ precision_score,
31
+ recall_score,
32
+ roc_auc_score,
33
+ )
34
+ from sklearn.model_selection import train_test_split
35
+ from tqdm.auto import tqdm
36
+ from transformers import (
37
+ AutoConfig,
38
+ AutoTokenizer,
39
+ DataCollatorWithPadding,
40
+ Trainer,
41
+ TrainingArguments,
42
+ default_data_collator,
43
+ )
44
+
45
+
46
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
47
+ if str(PROJECT_ROOT) not in sys.path:
48
+ sys.path.insert(0, str(PROJECT_ROOT))
49
+
50
+ from training.train_smoke_test import ( # noqa: E402
51
+ LOCAL_DNABERT2_PATCH_DIR,
52
+ MODEL_NAME,
53
+ choose_device,
54
+ clear_local_patch_module_cache,
55
+ create_local_dnabert2_patch,
56
+ disable_flash_attention_on_config,
57
+ load_sequence_classification_model,
58
+ should_drop_clnsig,
59
+ )
60
+
61
+
62
+ ORIGINAL_SPLIT_FILES = {
63
+ "train": "train_with_sequences.csv",
64
+ "val": "val_with_sequences.csv",
65
+ "test": "test_with_sequences.csv",
66
+ }
67
+
68
+ ALT_SPLIT_FILES = {
69
+ "train": "train_with_alt_sequences.csv",
70
+ "val": "val_with_alt_sequences.csv",
71
+ "test": "test_with_alt_sequences.csv",
72
+ }
73
+
74
+ MIN_SEQUENCE_LENGTH = 50
75
+ RANDOM_STATE = 42
76
+
77
+
78
+ @dataclass
79
+ class CsvSelection:
80
+ train_csv: Path
81
+ val_csv: Path
82
+ test_csv: Path
83
+ dataset_dir: Path
84
+ is_alt_dataset: bool
85
+ is_large_alt_dataset: bool
86
+ is_10k_alt_dataset: bool = False
87
+ is_20k_alt_dataset: bool = False
88
+ dataset_name: str = ""
89
+
90
+
91
+ def parse_bool(value: str | bool) -> bool:
92
+ if isinstance(value, bool):
93
+ return value
94
+ normalized = value.strip().lower()
95
+ if normalized in {"true", "1", "yes", "y"}:
96
+ return True
97
+ if normalized in {"false", "0", "no", "n"}:
98
+ return False
99
+ raise argparse.ArgumentTypeError("Expected true or false.")
100
+
101
+
102
+ def parse_args() -> argparse.Namespace:
103
+ parser = argparse.ArgumentParser(description=__doc__)
104
+ parser.add_argument("--train_csv", type=Path, default=None)
105
+ parser.add_argument("--val_csv", type=Path, default=None)
106
+ parser.add_argument("--test_csv", type=Path, default=None)
107
+ parser.add_argument("--dataset_dir", type=Path, default=None)
108
+ parser.add_argument("--output_dir", type=Path, default=Path("training/outputs/dnabert2_clinvar"))
109
+ parser.add_argument("--sequence_column", type=str, default="sequence")
110
+ parser.add_argument("--sample_size", type=int, default=0)
111
+ parser.add_argument("--max_length", type=int, default=512)
112
+ parser.add_argument("--variant_center_index", type=int, default=512)
113
+ parser.add_argument("--epochs", type=float, default=5)
114
+ parser.add_argument("--batch_size", type=int, default=1)
115
+ parser.add_argument("--grad_accum_steps", type=int, default=8)
116
+ parser.add_argument("--learning_rate", type=float, default=2e-5)
117
+ parser.add_argument("--eval_accumulation_steps", type=int, default=1)
118
+ parser.add_argument("--eval_subset_size", type=int, default=0)
119
+ parser.add_argument("--save_eval_each_epoch", type=parse_bool, default=False)
120
+ parser.add_argument("--freeze_encoder", type=parse_bool, default=True)
121
+ parser.add_argument("--unfreeze_last_n_layers", type=int, default=0)
122
+ parser.add_argument("--freeze_embeddings", type=parse_bool, default=True)
123
+ parser.add_argument("--use_class_weights", type=parse_bool, default=True)
124
+ parser.add_argument("--center_crop", type=parse_bool, default=True)
125
+ parser.add_argument("--tune_threshold", type=parse_bool, default=True)
126
+ parser.add_argument("--resume_from_checkpoint", type=str, default=None)
127
+ return parser.parse_args()
128
+
129
+
130
+ def resolve_path(path: Path) -> Path:
131
+ return path if path.is_absolute() else PROJECT_ROOT / path
132
+
133
+
134
+ def has_all_split_files(directory: Path, split_files: dict[str, str]) -> bool:
135
+ return all((directory / filename).exists() for filename in split_files.values())
136
+
137
+
138
+ def infer_alt_dataset_from_paths(paths: list[Path]) -> bool:
139
+ return any("csv_files_alt" in path.parts or "with_alt_sequences" in path.name for path in paths)
140
+
141
+
142
+ def infer_large_alt_dataset_from_paths(paths: list[Path]) -> bool:
143
+ return any(
144
+ directory_name in path.parts
145
+ for path in paths
146
+ for directory_name in ["csv_files_large_alt", "csv_files_10k_alt", "csv_files_20k_alt"]
147
+ )
148
+
149
+
150
+ def infer_10k_alt_dataset_from_paths(paths: list[Path]) -> bool:
151
+ return any("csv_files_10k_alt" in path.parts for path in paths)
152
+
153
+
154
+ def infer_20k_alt_dataset_from_paths(paths: list[Path]) -> bool:
155
+ return any("csv_files_20k_alt" in path.parts for path in paths)
156
+
157
+
158
+ def selection_from_directory(directory: Path, dataset_name: str = "") -> CsvSelection:
159
+ if has_all_split_files(directory, ALT_SPLIT_FILES):
160
+ selected_paths = [
161
+ directory / ALT_SPLIT_FILES["train"],
162
+ directory / ALT_SPLIT_FILES["val"],
163
+ directory / ALT_SPLIT_FILES["test"],
164
+ ]
165
+ return CsvSelection(
166
+ train_csv=selected_paths[0],
167
+ val_csv=selected_paths[1],
168
+ test_csv=selected_paths[2],
169
+ dataset_dir=directory,
170
+ is_alt_dataset=True,
171
+ is_large_alt_dataset=infer_large_alt_dataset_from_paths(selected_paths),
172
+ is_10k_alt_dataset=infer_10k_alt_dataset_from_paths(selected_paths),
173
+ is_20k_alt_dataset=infer_20k_alt_dataset_from_paths(selected_paths),
174
+ dataset_name=dataset_name or directory.name,
175
+ )
176
+
177
+ if has_all_split_files(directory, ORIGINAL_SPLIT_FILES):
178
+ selected_paths = [
179
+ directory / ORIGINAL_SPLIT_FILES["train"],
180
+ directory / ORIGINAL_SPLIT_FILES["val"],
181
+ directory / ORIGINAL_SPLIT_FILES["test"],
182
+ ]
183
+ return CsvSelection(
184
+ train_csv=selected_paths[0],
185
+ val_csv=selected_paths[1],
186
+ test_csv=selected_paths[2],
187
+ dataset_dir=directory,
188
+ is_alt_dataset=False,
189
+ is_large_alt_dataset=False,
190
+ dataset_name=dataset_name or directory.name,
191
+ )
192
+
193
+ raise FileNotFoundError(
194
+ "Dataset directory does not contain a complete alternate or reference split:\n"
195
+ f"{directory}"
196
+ )
197
+
198
+
199
+ def find_default_csv_selection() -> CsvSelection:
200
+ """Prefer larger alternate-sequence datasets, then smaller fallbacks."""
201
+ candidates = [
202
+ (PROJECT_ROOT / "training" / "csv_files_20k_alt", ALT_SPLIT_FILES, "20k alternate-sequence dataset"),
203
+ (PROJECT_ROOT / "training" / "csv_files_10k_alt", ALT_SPLIT_FILES, "10k alternate-sequence dataset"),
204
+ (PROJECT_ROOT / "training" / "csv_files_large_alt", ALT_SPLIT_FILES, "large alternate-sequence dataset"),
205
+ (PROJECT_ROOT / "training" / "csv_files_alt", ALT_SPLIT_FILES, "alternate-sequence dataset"),
206
+ (PROJECT_ROOT / "data" / "processed", ORIGINAL_SPLIT_FILES, "data/processed reference dataset"),
207
+ (PROJECT_ROOT / "training" / "csv_files", ORIGINAL_SPLIT_FILES, "reference-sequence dataset"),
208
+ (PROJECT_ROOT / "training" / "csv_files_large", ORIGINAL_SPLIT_FILES, "large reference-sequence dataset"),
209
+ ]
210
+
211
+ for directory, split_files, dataset_name in candidates:
212
+ if has_all_split_files(directory, split_files):
213
+ return selection_from_directory(directory, dataset_name)
214
+
215
+ searched = "\n".join(str(directory) for directory, _split_files, _dataset_name in candidates)
216
+ raise FileNotFoundError(
217
+ "Could not find train/val/test CSV files in the default locations.\n"
218
+ f"Searched:\n{searched}"
219
+ )
220
+
221
+
222
+ def resolve_csv_paths(args: argparse.Namespace) -> CsvSelection:
223
+ default_selection = None
224
+ if args.dataset_dir is not None and not (args.train_csv or args.val_csv or args.test_csv):
225
+ return selection_from_directory(resolve_path(args.dataset_dir), args.dataset_dir.name)
226
+
227
+ if not (args.train_csv and args.val_csv and args.test_csv):
228
+ default_selection = find_default_csv_selection()
229
+
230
+ train_csv = resolve_path(args.train_csv) if args.train_csv else default_selection.train_csv
231
+ val_csv = resolve_path(args.val_csv) if args.val_csv else default_selection.val_csv
232
+ test_csv = resolve_path(args.test_csv) if args.test_csv else default_selection.test_csv
233
+
234
+ for csv_path in [train_csv, val_csv, test_csv]:
235
+ if not csv_path.exists():
236
+ raise FileNotFoundError(f"CSV file not found: {csv_path}")
237
+
238
+ selected_paths = [train_csv, val_csv, test_csv]
239
+ is_alt_dataset = (
240
+ default_selection.is_alt_dataset
241
+ if default_selection is not None
242
+ else infer_alt_dataset_from_paths(selected_paths)
243
+ )
244
+
245
+ if infer_alt_dataset_from_paths(selected_paths):
246
+ is_alt_dataset = True
247
+
248
+ is_large_alt_dataset = (
249
+ default_selection.is_large_alt_dataset
250
+ if default_selection is not None
251
+ else infer_large_alt_dataset_from_paths(selected_paths)
252
+ )
253
+
254
+ if infer_large_alt_dataset_from_paths(selected_paths):
255
+ is_large_alt_dataset = True
256
+
257
+ is_10k_alt_dataset = (
258
+ default_selection.is_10k_alt_dataset
259
+ if default_selection is not None
260
+ else infer_10k_alt_dataset_from_paths(selected_paths)
261
+ )
262
+ if infer_10k_alt_dataset_from_paths(selected_paths):
263
+ is_10k_alt_dataset = True
264
+
265
+ is_20k_alt_dataset = (
266
+ default_selection.is_20k_alt_dataset
267
+ if default_selection is not None
268
+ else infer_20k_alt_dataset_from_paths(selected_paths)
269
+ )
270
+ if infer_20k_alt_dataset_from_paths(selected_paths):
271
+ is_20k_alt_dataset = True
272
+
273
+ common_parent = selected_paths[0].parent
274
+ if any(path.parent != common_parent for path in selected_paths):
275
+ common_parent = PROJECT_ROOT
276
+
277
+ return CsvSelection(
278
+ train_csv=train_csv,
279
+ val_csv=val_csv,
280
+ test_csv=test_csv,
281
+ dataset_dir=common_parent,
282
+ is_alt_dataset=is_alt_dataset,
283
+ is_large_alt_dataset=is_large_alt_dataset,
284
+ is_10k_alt_dataset=is_10k_alt_dataset,
285
+ is_20k_alt_dataset=is_20k_alt_dataset,
286
+ dataset_name=default_selection.dataset_name if default_selection is not None else common_parent.name,
287
+ )
288
+
289
+
290
+ def clean_sequence(value: object) -> str:
291
+ return str(value).strip().upper()
292
+
293
+
294
+ def crop_sequence_around_variant(sequence: str, max_length: int, variant_center_index: int) -> str:
295
+ """Crop a long sequence while keeping the variant position in the window."""
296
+ if len(sequence) <= max_length:
297
+ return sequence
298
+
299
+ start = max(0, variant_center_index - max_length // 2)
300
+ end = start + max_length
301
+ if end > len(sequence):
302
+ end = len(sequence)
303
+ start = max(0, end - max_length)
304
+
305
+ return sequence[start:end]
306
+
307
+
308
+ def apply_center_crop(
309
+ df: pd.DataFrame,
310
+ max_length: int,
311
+ variant_center_index: int,
312
+ split_name: str,
313
+ enabled: bool,
314
+ ) -> pd.DataFrame:
315
+ if not enabled:
316
+ print(f"{split_name} variant-centered crop: disabled")
317
+ return df.reset_index(drop=True)
318
+
319
+ df = df.copy()
320
+ original_lengths = df["sequence"].str.len()
321
+ cropped_count = int((original_lengths > max_length).sum())
322
+ df["sequence"] = df["sequence"].apply(
323
+ lambda sequence: crop_sequence_around_variant(sequence, max_length, variant_center_index)
324
+ )
325
+ cropped_lengths = df["sequence"].str.len()
326
+
327
+ print(
328
+ f"{split_name} variant-centered crop: "
329
+ f"{cropped_count:,}/{len(df):,} sequences cropped to {max_length} characters "
330
+ f"around input index {variant_center_index}"
331
+ )
332
+ print(
333
+ f"{split_name} sequence lengths after crop: "
334
+ f"min={int(cropped_lengths.min())}, "
335
+ f"mean={cropped_lengths.mean():.1f}, "
336
+ f"max={int(cropped_lengths.max())}"
337
+ )
338
+ print()
339
+ return df.reset_index(drop=True)
340
+
341
+
342
+ def load_and_filter_dataframe(csv_path: Path, split_name: str, sequence_column: str) -> pd.DataFrame:
343
+ df = pd.read_csv(csv_path)
344
+ before_rows = len(df)
345
+
346
+ required_columns = {sequence_column, "label"}
347
+ missing_columns = sorted(required_columns - set(df.columns))
348
+ if missing_columns:
349
+ raise ValueError(f"{csv_path} is missing required columns: {missing_columns}")
350
+
351
+ df = df.copy()
352
+
353
+ if "CLNSIG" in df.columns:
354
+ df = df.loc[~df["CLNSIG"].fillna("").apply(should_drop_clnsig)].copy()
355
+
356
+ df["label"] = pd.to_numeric(df["label"], errors="coerce")
357
+ df = df.loc[df["label"].isin([0, 1])].copy()
358
+ df["label"] = df["label"].astype(int)
359
+
360
+ df["sequence"] = df[sequence_column].fillna("").apply(clean_sequence)
361
+ df = df.loc[df["sequence"] != ""].copy()
362
+ df = df.loc[df["sequence"].str.len() >= MIN_SEQUENCE_LENGTH].copy()
363
+
364
+ print(f"{split_name} CSV: {csv_path}")
365
+ print(f"{split_name} rows before filtering: {before_rows:,}")
366
+ print(f"{split_name} rows after filtering: {len(df):,}")
367
+ print(f"{split_name} label distribution after filtering:")
368
+ print(df["label"].value_counts().sort_index().to_string())
369
+ print()
370
+
371
+ if df.empty:
372
+ raise ValueError(f"No usable rows remain in {split_name}.")
373
+
374
+ return df.reset_index(drop=True)
375
+
376
+
377
+ def stratified_sample(df: pd.DataFrame, sample_size: int, split_name: str) -> pd.DataFrame:
378
+ if sample_size <= 0 or len(df) <= sample_size:
379
+ return df.reset_index(drop=True)
380
+
381
+ label_counts = df["label"].value_counts()
382
+ if df["label"].nunique() == 2 and label_counts.min() >= 2 and sample_size >= 2:
383
+ try:
384
+ sampled, _unused = train_test_split(
385
+ df,
386
+ train_size=sample_size,
387
+ stratify=df["label"],
388
+ random_state=RANDOM_STATE,
389
+ )
390
+ sampled = sampled.sort_index()
391
+ except ValueError:
392
+ sampled = df.sample(n=sample_size, random_state=RANDOM_STATE).sort_index()
393
+ else:
394
+ sampled = df.sample(n=sample_size, random_state=RANDOM_STATE).sort_index()
395
+
396
+ print(f"{split_name} sampled rows: {len(sampled):,}/{len(df):,}")
397
+ print(f"{split_name} label distribution after sampling:")
398
+ print(sampled["label"].value_counts().sort_index().to_string())
399
+ print()
400
+ return sampled.reset_index(drop=True)
401
+
402
+
403
+ def make_eval_subset(df: pd.DataFrame, eval_subset_size: int, split_name: str) -> pd.DataFrame:
404
+ if eval_subset_size <= 0 or len(df) <= eval_subset_size:
405
+ print(f"{split_name} evaluation rows: {len(df):,}")
406
+ print()
407
+ return df.reset_index(drop=True)
408
+
409
+ subset = stratified_sample(df, eval_subset_size, f"{split_name} evaluation")
410
+ print(f"{split_name} evaluation subset enabled: {len(subset):,}/{len(df):,}")
411
+ print()
412
+ return subset
413
+
414
+
415
+ def dataframe_to_dataset(df: pd.DataFrame) -> Dataset:
416
+ dataset_df = df[["sequence", "label"]].rename(columns={"label": "labels"})
417
+ return Dataset.from_pandas(dataset_df, preserve_index=False)
418
+
419
+
420
+ def tokenize_dataset(tokenizer, dataset: Dataset, max_length: int, split_name: str) -> Dataset:
421
+ def tokenize_batch(batch):
422
+ return tokenizer(
423
+ batch["sequence"],
424
+ max_length=max_length,
425
+ padding="max_length",
426
+ truncation=True,
427
+ )
428
+
429
+ tokenized = dataset.map(tokenize_batch, batched=True, remove_columns=["sequence"], desc=f"Tokenizing {split_name}")
430
+ return tokenized
431
+
432
+
433
+ def extract_logits(eval_pred) -> tuple[np.ndarray, np.ndarray]:
434
+ if hasattr(eval_pred, "predictions"):
435
+ logits = eval_pred.predictions
436
+ labels = eval_pred.label_ids
437
+ else:
438
+ logits, labels = eval_pred
439
+
440
+ if isinstance(logits, (tuple, list)):
441
+ for item in logits:
442
+ candidate = np.asarray(item)
443
+ if candidate.ndim >= 2 and candidate.shape[-1] == 2:
444
+ logits = candidate
445
+ break
446
+ else:
447
+ logits = np.asarray(logits[0])
448
+ else:
449
+ logits = np.asarray(logits)
450
+
451
+ return logits, np.asarray(labels)
452
+
453
+
454
+ def softmax(logits: np.ndarray) -> np.ndarray:
455
+ shifted = logits - np.max(logits, axis=-1, keepdims=True)
456
+ exp = np.exp(shifted)
457
+ return exp / np.sum(exp, axis=-1, keepdims=True)
458
+
459
+
460
+ def extract_predictions_and_labels(eval_pred) -> tuple[np.ndarray, np.ndarray]:
461
+ if hasattr(eval_pred, "predictions"):
462
+ predictions = eval_pred.predictions
463
+ labels = eval_pred.label_ids
464
+ else:
465
+ predictions, labels = eval_pred
466
+
467
+ if isinstance(predictions, (tuple, list)):
468
+ for item in predictions:
469
+ candidate = np.asarray(item)
470
+ if candidate.ndim == 1 or (candidate.ndim >= 2 and candidate.shape[-1] in {1, 2}):
471
+ predictions = candidate
472
+ break
473
+ else:
474
+ predictions = np.asarray(predictions[0])
475
+ else:
476
+ predictions = np.asarray(predictions)
477
+
478
+ return predictions, np.asarray(labels)
479
+
480
+
481
+ def probabilities_from_predictions(predictions: np.ndarray) -> np.ndarray:
482
+ predictions = np.asarray(predictions)
483
+ if predictions.ndim == 1:
484
+ return predictions.astype(np.float64)
485
+ if predictions.ndim >= 2 and predictions.shape[-1] == 1:
486
+ return predictions.reshape(-1).astype(np.float64)
487
+ if predictions.ndim >= 2 and predictions.shape[-1] == 2:
488
+ return softmax(predictions.astype(np.float64))[:, 1]
489
+ raise ValueError(f"Unsupported prediction shape for binary classification: {predictions.shape}")
490
+
491
+
492
+ def class_predictions_from_scores(predictions: np.ndarray) -> np.ndarray:
493
+ predictions = np.asarray(predictions)
494
+ if predictions.ndim == 1 or (predictions.ndim >= 2 and predictions.shape[-1] == 1):
495
+ probabilities = probabilities_from_predictions(predictions)
496
+ return (probabilities >= 0.5).astype(int)
497
+ return np.argmax(predictions, axis=-1)
498
+
499
+
500
+ def positive_class_probabilities(logits: np.ndarray) -> np.ndarray:
501
+ return probabilities_from_predictions(logits)
502
+
503
+
504
+ def preprocess_logits_for_metrics(logits, labels):
505
+ """Keep only class-1 probabilities during Trainer evaluation to reduce memory."""
506
+ logits = extract_torch_logits(logits)
507
+ if logits.ndim >= 2 and logits.shape[-1] == 2:
508
+ return torch.softmax(logits.float(), dim=-1)[:, 1]
509
+ if logits.ndim >= 2 and logits.shape[-1] == 1:
510
+ return logits.squeeze(-1)
511
+ return logits
512
+
513
+
514
+ def compute_metrics(eval_pred):
515
+ predictions, labels = extract_predictions_and_labels(eval_pred)
516
+ class_predictions = class_predictions_from_scores(predictions)
517
+
518
+ metrics = {
519
+ "accuracy": float(accuracy_score(labels, class_predictions)),
520
+ "precision": float(precision_score(labels, class_predictions, zero_division=0)),
521
+ "recall": float(recall_score(labels, class_predictions, zero_division=0)),
522
+ "f1": float(f1_score(labels, class_predictions, zero_division=0)),
523
+ "mcc": float(matthews_corrcoef(labels, class_predictions)),
524
+ }
525
+
526
+ if len(np.unique(labels)) == 2:
527
+ probabilities = probabilities_from_predictions(predictions)
528
+ try:
529
+ metrics["auc_roc"] = float(roc_auc_score(labels, probabilities))
530
+ except ValueError:
531
+ metrics["auc_roc"] = None
532
+
533
+ return metrics
534
+
535
+
536
+ def tune_threshold_for_mcc(labels: np.ndarray, probabilities: np.ndarray) -> tuple[float, float]:
537
+ """Try thresholds from 0.10 to 0.90 and keep the best validation MCC."""
538
+ best_threshold = 0.5
539
+ best_mcc = -1.0
540
+ best_f1 = -1.0
541
+
542
+ for threshold in np.round(np.arange(0.10, 0.901, 0.01), 2):
543
+ predictions = (probabilities >= threshold).astype(int)
544
+ mcc = matthews_corrcoef(labels, predictions)
545
+ f1 = f1_score(labels, predictions, zero_division=0)
546
+
547
+ if mcc > best_mcc or (np.isclose(mcc, best_mcc) and f1 > best_f1):
548
+ best_threshold = float(threshold)
549
+ best_mcc = float(mcc)
550
+ best_f1 = float(f1)
551
+
552
+ return best_threshold, best_mcc
553
+
554
+
555
+ def metrics_at_threshold(labels: np.ndarray, probabilities: np.ndarray, threshold: float) -> dict:
556
+ predictions = (probabilities >= threshold).astype(int)
557
+ matrix = confusion_matrix(labels, predictions, labels=[0, 1]).astype(int)
558
+
559
+ metrics = {
560
+ "selected_threshold": float(threshold),
561
+ "accuracy": float(accuracy_score(labels, predictions)),
562
+ "precision": float(precision_score(labels, predictions, zero_division=0)),
563
+ "recall": float(recall_score(labels, predictions, zero_division=0)),
564
+ "f1": float(f1_score(labels, predictions, zero_division=0)),
565
+ "mcc": float(matthews_corrcoef(labels, predictions)),
566
+ "auc_roc": None,
567
+ "confusion_matrix": matrix.tolist(),
568
+ }
569
+
570
+ if len(np.unique(labels)) == 2:
571
+ try:
572
+ metrics["auc_roc"] = float(roc_auc_score(labels, probabilities))
573
+ except ValueError:
574
+ metrics["auc_roc"] = None
575
+
576
+ return metrics
577
+
578
+
579
+ def predict_probabilities(trainer: Trainer, dataset: Dataset) -> tuple[np.ndarray, np.ndarray]:
580
+ prediction_output = trainer.predict(dataset)
581
+ logits, labels = extract_logits(prediction_output)
582
+ probabilities = positive_class_probabilities(logits)
583
+ return labels.astype(int), probabilities
584
+
585
+
586
+ def clear_mps_cache_if_needed(device: str) -> None:
587
+ if device != "mps":
588
+ return
589
+ mps_backend = getattr(torch, "mps", None)
590
+ if mps_backend is not None and hasattr(mps_backend, "empty_cache"):
591
+ mps_backend.empty_cache()
592
+
593
+
594
+ def predict_in_small_batches(model, dataset: Dataset, batch_size: int = 1, device: str = "cpu") -> tuple[np.ndarray, np.ndarray]:
595
+ """Memory-safe prediction loop that immediately moves scores to CPU."""
596
+ device_object = torch.device(device)
597
+ model.to(device_object)
598
+ model.eval()
599
+
600
+ loader = torch.utils.data.DataLoader(
601
+ dataset,
602
+ batch_size=batch_size,
603
+ shuffle=False,
604
+ collate_fn=default_data_collator,
605
+ )
606
+
607
+ all_probabilities: list[np.ndarray] = []
608
+ all_labels: list[np.ndarray] = []
609
+
610
+ for batch in tqdm(loader, desc="Memory-safe prediction"):
611
+ labels = batch.pop("labels")
612
+ batch = {key: value.to(device_object) for key, value in batch.items()}
613
+
614
+ with torch.no_grad():
615
+ outputs = model(**batch)
616
+ logits = extract_torch_logits(outputs)
617
+ probabilities = torch.softmax(logits.float(), dim=-1)[:, 1]
618
+
619
+ all_probabilities.append(probabilities.detach().cpu().numpy())
620
+ all_labels.append(labels.detach().cpu().numpy())
621
+
622
+ del outputs, logits, probabilities, labels, batch
623
+ clear_mps_cache_if_needed(device)
624
+
625
+ gc.collect()
626
+ clear_mps_cache_if_needed(device)
627
+
628
+ return np.concatenate(all_probabilities), np.concatenate(all_labels).astype(int)
629
+
630
+
631
+ def print_confusion_matrix(split_name: str, matrix: list[list[int]]) -> None:
632
+ print(f"{split_name} confusion matrix")
633
+ print(" predicted_0 predicted_1")
634
+ print(f"actual_0 {matrix[0][0]:>11} {matrix[0][1]:>11}")
635
+ print(f"actual_1 {matrix[1][0]:>11} {matrix[1][1]:>11}")
636
+
637
+
638
+ def print_threshold_metrics(split_name: str, metrics: dict) -> None:
639
+ print(f"{split_name} metrics at threshold {metrics['selected_threshold']:.2f}")
640
+ for key in ["accuracy", "precision", "recall", "f1", "mcc", "auc_roc"]:
641
+ value = metrics[key]
642
+ if value is None:
643
+ print(f"{key}: n/a")
644
+ else:
645
+ print(f"{key}: {value:.4f}")
646
+ print_confusion_matrix(split_name, metrics["confusion_matrix"])
647
+ print()
648
+
649
+
650
+ def compute_class_weights(train_df: pd.DataFrame) -> torch.Tensor:
651
+ """Balanced class weights: total_rows / (num_classes * class_count)."""
652
+ counts = train_df["label"].value_counts().sort_index()
653
+ if not {0, 1}.issubset(set(counts.index)):
654
+ print("Class weights requested, but both classes are not present. Using equal weights.")
655
+ return torch.tensor([1.0, 1.0], dtype=torch.float32)
656
+
657
+ total = float(counts.sum())
658
+ weights = [
659
+ total / (2.0 * float(counts.loc[0])),
660
+ total / (2.0 * float(counts.loc[1])),
661
+ ]
662
+ return torch.tensor(weights, dtype=torch.float32)
663
+
664
+
665
+ def maybe_compute_class_weights(train_df: pd.DataFrame, enabled: bool) -> torch.Tensor | None:
666
+ if not enabled:
667
+ print("Class weights: disabled")
668
+ print()
669
+ return None
670
+
671
+ class_weights = compute_class_weights(train_df)
672
+ print("Class weights: enabled")
673
+ print(f"label 0 weight: {class_weights[0].item():.4f}")
674
+ print(f"label 1 weight: {class_weights[1].item():.4f}")
675
+ print()
676
+ return class_weights
677
+
678
+
679
+ def extract_torch_logits(outputs) -> torch.Tensor:
680
+ if hasattr(outputs, "logits"):
681
+ return outputs.logits
682
+
683
+ if isinstance(outputs, (tuple, list)):
684
+ for item in outputs:
685
+ if torch.is_tensor(item) and item.ndim >= 2 and item.shape[-1] == 2:
686
+ return item
687
+ return outputs[0]
688
+
689
+ raise TypeError("Could not find logits in model outputs.")
690
+
691
+
692
+ class WeightedLossTrainer(Trainer):
693
+ """Trainer that uses weighted cross entropy for imbalanced labels."""
694
+
695
+ def __init__(self, *args, class_weights: torch.Tensor | None = None, **kwargs):
696
+ super().__init__(*args, **kwargs)
697
+ self.class_weights = class_weights
698
+
699
+ def compute_loss(self, model, inputs, return_outputs=False, **kwargs):
700
+ labels = inputs.pop("labels")
701
+ outputs = model(**inputs)
702
+ logits = extract_torch_logits(outputs)
703
+
704
+ weight = self.class_weights.to(logits.device) if self.class_weights is not None else None
705
+ loss_fn = torch.nn.CrossEntropyLoss(weight=weight)
706
+ loss = loss_fn(logits.view(-1, logits.shape[-1]), labels.view(-1).long())
707
+
708
+ if return_outputs:
709
+ return loss, outputs
710
+ return loss
711
+
712
+
713
+ def local_patch_is_ready(patch_dir: Path) -> bool:
714
+ required_files = [
715
+ "config.json",
716
+ "configuration_bert.py",
717
+ "bert_layers.py",
718
+ "bert_padding.py",
719
+ "tokenizer.json",
720
+ "tokenizer_config.json",
721
+ ]
722
+ if not all((patch_dir / filename).exists() for filename in required_files):
723
+ return False
724
+
725
+ bert_layers = (patch_dir / "bert_layers.py").read_text(encoding="utf-8")
726
+ return "from .flash_attn_triton import" not in bert_layers and "getattr(self.alibi, 'is_meta', False)" in bert_layers
727
+
728
+
729
+ def create_patch_from_project_root() -> Path:
730
+ """Run the smoke-test patch creator from the project root."""
731
+ previous_cwd = Path.cwd()
732
+ try:
733
+ os.chdir(PROJECT_ROOT)
734
+ patch_dir = create_local_dnabert2_patch()
735
+ finally:
736
+ os.chdir(previous_cwd)
737
+ return resolve_path(patch_dir)
738
+
739
+
740
+ def load_model_from_source(model_source: str | Path):
741
+ config = AutoConfig.from_pretrained(str(model_source), trust_remote_code=True)
742
+ config = disable_flash_attention_on_config(config)
743
+ return load_sequence_classification_model(str(model_source), config)
744
+
745
+
746
+ def load_mac_safe_model() -> tuple[object, Path | str]:
747
+ patch_dir = resolve_path(LOCAL_DNABERT2_PATCH_DIR)
748
+
749
+ if patch_dir.exists() and local_patch_is_ready(patch_dir):
750
+ print(f"Using local patched DNABERT-2: {patch_dir}")
751
+ clear_local_patch_module_cache()
752
+ model = load_model_from_source(patch_dir)
753
+ print("Triton/flash attention disabled for Mac.")
754
+ print("Model loaded successfully.")
755
+ return model, patch_dir
756
+
757
+ print("No usable local patched DNABERT-2 found. Trying DNABERT-2 with eager attention...")
758
+ try:
759
+ model = load_model_from_source(MODEL_NAME)
760
+ print("Triton/flash attention disabled for Mac.")
761
+ print("Model loaded successfully.")
762
+ return model, MODEL_NAME
763
+ except Exception as eager_error:
764
+ print("Direct eager DNABERT-2 load failed. Creating local Mac-safe patch.")
765
+ print(f"Direct load error: {eager_error}")
766
+
767
+ patch_dir = create_patch_from_project_root()
768
+ print(f"Using local patched DNABERT-2: {patch_dir}")
769
+ model = load_model_from_source(patch_dir)
770
+ print("Triton/flash attention disabled for Mac.")
771
+ print("Model loaded successfully.")
772
+ return model, patch_dir
773
+
774
+
775
+ def load_dnabert2_model(device: str) -> tuple[object, Path | str]:
776
+ """Load DNABERT-2 using the best strategy for the selected device."""
777
+ if device == "cuda":
778
+ print("CUDA detected. Trying standard Hugging Face DNABERT-2 first.")
779
+ try:
780
+ model = load_model_from_source(MODEL_NAME)
781
+ print("Model loaded successfully from Hugging Face.")
782
+ return model, MODEL_NAME
783
+ except Exception as error:
784
+ print("Standard Hugging Face DNABERT-2 load failed.")
785
+ print(f"Direct load error: {error}")
786
+ print("Falling back to the local no-Triton DNABERT-2 patch.")
787
+
788
+ return load_mac_safe_model()
789
+
790
+
791
+ def get_module_by_path(model, path: str):
792
+ current = model
793
+ for part in path.split("."):
794
+ if not hasattr(current, part):
795
+ return None
796
+ current = getattr(current, part)
797
+ return current
798
+
799
+
800
+ def find_encoder_layers(model) -> tuple[str | None, object | None]:
801
+ layer_paths = [
802
+ "bert.encoder.layer",
803
+ "encoder.layer",
804
+ "base_model.encoder.layer",
805
+ "bert.encoder.layers",
806
+ ]
807
+
808
+ for path in layer_paths:
809
+ layers = get_module_by_path(model, path)
810
+ if layers is not None and hasattr(layers, "__len__") and hasattr(layers, "__getitem__"):
811
+ return f"model.{path}", layers
812
+
813
+ return None, None
814
+
815
+
816
+ def find_embedding_module(model):
817
+ embedding_paths = [
818
+ "bert.embeddings",
819
+ "embeddings",
820
+ "base_model.embeddings",
821
+ "base_model.bert.embeddings",
822
+ ]
823
+
824
+ for path in embedding_paths:
825
+ embeddings = get_module_by_path(model, path)
826
+ if embeddings is not None and hasattr(embeddings, "parameters"):
827
+ return f"model.{path}", embeddings
828
+
829
+ return None, None
830
+
831
+
832
+ def unfreeze_classifier_parameters(model) -> list[str]:
833
+ trainable_names = []
834
+ for name, parameter in model.named_parameters():
835
+ if name.startswith("classifier") or ".classifier" in name:
836
+ parameter.requires_grad = True
837
+ trainable_names.append(name)
838
+ return trainable_names
839
+
840
+
841
+ def unfreeze_pooler_parameters(model) -> list[str]:
842
+ trainable_names = []
843
+ for name, parameter in model.named_parameters():
844
+ if name.startswith("pooler") or ".pooler" in name:
845
+ parameter.requires_grad = True
846
+ trainable_names.append(name)
847
+ return trainable_names
848
+
849
+
850
+ def print_trainable_parameter_summary(model) -> None:
851
+ total_params = sum(parameter.numel() for parameter in model.parameters())
852
+ trainable_params = sum(parameter.numel() for parameter in model.parameters() if parameter.requires_grad)
853
+ trainable_percentage = (trainable_params / total_params * 100.0) if total_params else 0.0
854
+ trainable_names = [name for name, parameter in model.named_parameters() if parameter.requires_grad]
855
+
856
+ print(f"Total parameters: {total_params:,}")
857
+ print(f"Trainable parameters: {trainable_params:,}")
858
+ print(f"Trainable percentage: {trainable_percentage:.4f}%")
859
+ print("First 20 trainable parameter names:")
860
+ for name in trainable_names[:20]:
861
+ print(f" {name}")
862
+ if len(trainable_names) > 20:
863
+ print(f" ... {len(trainable_names) - 20:,} more")
864
+ print()
865
+
866
+
867
+ def freeze_encoder_if_requested(
868
+ model,
869
+ freeze_encoder: bool,
870
+ unfreeze_last_n_layers: int,
871
+ freeze_embeddings: bool,
872
+ device: str,
873
+ ) -> None:
874
+ if unfreeze_last_n_layers < 0:
875
+ raise ValueError("--unfreeze_last_n_layers must be 0 or greater.")
876
+
877
+ if not freeze_encoder:
878
+ for parameter in model.parameters():
879
+ parameter.requires_grad = True
880
+ print("Encoder frozen: false")
881
+ print("All model parameters are trainable.")
882
+ print_trainable_parameter_summary(model)
883
+ return
884
+
885
+ for parameter in model.parameters():
886
+ parameter.requires_grad = False
887
+
888
+ classifier_names = unfreeze_classifier_parameters(model)
889
+ print("Encoder frozen: true")
890
+
891
+ if unfreeze_last_n_layers == 0:
892
+ print("Partial unfreezing: disabled")
893
+ print("Trainable modules: classifier head only")
894
+ if not classifier_names:
895
+ print("WARNING: no classifier parameters were found to unfreeze.")
896
+ print_trainable_parameter_summary(model)
897
+ return
898
+
899
+ if device == "mps":
900
+ print("WARNING: unfreezing encoder layers on MPS may be slower and may use more memory.")
901
+
902
+ print(f"Partial unfreezing: last {unfreeze_last_n_layers} encoder layer(s)")
903
+ pooler_names = unfreeze_pooler_parameters(model)
904
+ if pooler_names:
905
+ print("Pooler unfrozen: true")
906
+ else:
907
+ print("Pooler unfrozen: false, no pooler parameters found")
908
+
909
+ layer_path, layers = find_encoder_layers(model)
910
+ if layers is None:
911
+ print("Encoder layer path found: none")
912
+ print("WARNING: no supported encoder layer path was found. Only classifier/pooler parameters are trainable.")
913
+ else:
914
+ layer_count = len(layers)
915
+ layers_to_unfreeze = min(unfreeze_last_n_layers, layer_count)
916
+ start_index = layer_count - layers_to_unfreeze
917
+ print(f"Encoder layer path found: {layer_path}")
918
+ print(f"Encoder layers found: {layer_count}")
919
+ print(f"Encoder layer indexes unfrozen: {start_index} to {layer_count - 1}")
920
+
921
+ for layer in list(layers)[start_index:]:
922
+ for parameter in layer.parameters():
923
+ parameter.requires_grad = True
924
+
925
+ if freeze_embeddings:
926
+ print("Embeddings frozen: true")
927
+ else:
928
+ embedding_path, embeddings = find_embedding_module(model)
929
+ if embeddings is None:
930
+ print("Embeddings frozen: false requested, but no embedding module was found")
931
+ else:
932
+ for parameter in embeddings.parameters():
933
+ parameter.requires_grad = True
934
+ print(f"Embeddings frozen: false")
935
+ print(f"Embedding path found: {embedding_path}")
936
+
937
+ if not classifier_names:
938
+ print("WARNING: no classifier parameters were found to unfreeze.")
939
+
940
+ print_trainable_parameter_summary(model)
941
+
942
+
943
+ def make_training_arguments(args: argparse.Namespace, output_dir: Path, device: str) -> TrainingArguments:
944
+ eval_batch_size = args.batch_size if device == "cuda" else 1
945
+ base_kwargs = {
946
+ "output_dir": str(output_dir),
947
+ "num_train_epochs": args.epochs,
948
+ "learning_rate": args.learning_rate,
949
+ "per_device_train_batch_size": args.batch_size,
950
+ "per_device_eval_batch_size": eval_batch_size,
951
+ "gradient_accumulation_steps": args.grad_accum_steps,
952
+ "eval_accumulation_steps": args.eval_accumulation_steps,
953
+ "save_strategy": "epoch",
954
+ "load_best_model_at_end": args.save_eval_each_epoch,
955
+ "save_total_limit": 2,
956
+ "logging_steps": 20,
957
+ "report_to": "none",
958
+ "dataloader_num_workers": 0,
959
+ "dataloader_pin_memory": device == "cuda",
960
+ "remove_unused_columns": False,
961
+ "fp16": device == "cuda",
962
+ "bf16": False,
963
+ }
964
+
965
+ if args.save_eval_each_epoch:
966
+ base_kwargs["metric_for_best_model"] = "eval_mcc"
967
+ base_kwargs["greater_is_better"] = True
968
+ eval_strategy = "epoch"
969
+ else:
970
+ eval_strategy = "no"
971
+
972
+ try:
973
+ return TrainingArguments(**base_kwargs, eval_strategy=eval_strategy)
974
+ except TypeError:
975
+ return TrainingArguments(**base_kwargs, evaluation_strategy=eval_strategy)
976
+
977
+
978
+ def make_trainer(
979
+ model,
980
+ tokenizer,
981
+ training_args,
982
+ train_dataset: Dataset,
983
+ val_dataset: Dataset,
984
+ class_weights: torch.Tensor | None,
985
+ ) -> Trainer:
986
+ trainer_kwargs = {
987
+ "model": model,
988
+ "args": training_args,
989
+ "train_dataset": train_dataset,
990
+ "eval_dataset": val_dataset,
991
+ "data_collator": DataCollatorWithPadding(tokenizer=tokenizer),
992
+ "compute_metrics": compute_metrics,
993
+ }
994
+
995
+ trainer_class = Trainer
996
+ if class_weights is not None:
997
+ trainer_class = WeightedLossTrainer
998
+ trainer_kwargs["class_weights"] = class_weights
999
+
1000
+ trainer_signature = inspect.signature(Trainer.__init__)
1001
+ if "preprocess_logits_for_metrics" in trainer_signature.parameters:
1002
+ trainer_kwargs["preprocess_logits_for_metrics"] = preprocess_logits_for_metrics
1003
+ if "processing_class" in trainer_signature.parameters:
1004
+ trainer_kwargs["processing_class"] = tokenizer
1005
+ else:
1006
+ trainer_kwargs["tokenizer"] = tokenizer
1007
+
1008
+ return trainer_class(**trainer_kwargs)
1009
+
1010
+
1011
+ def save_metrics(output_dir: Path, metrics: dict) -> Path:
1012
+ metrics_path = output_dir / "metrics.json"
1013
+ metrics_path.write_text(json.dumps(metrics, indent=2), encoding="utf-8")
1014
+ return metrics_path
1015
+
1016
+
1017
+ def zip_final_model(output_dir: Path, final_model_dir: Path) -> Path:
1018
+ zip_path = output_dir / "final_dnabert2_clinvar_model.zip"
1019
+ if zip_path.exists():
1020
+ zip_path.unlink()
1021
+
1022
+ with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
1023
+ for file_path in final_model_dir.rglob("*"):
1024
+ if file_path.is_file():
1025
+ archive.write(file_path, arcname=file_path.relative_to(final_model_dir.parent))
1026
+
1027
+ return zip_path
1028
+
1029
+
1030
+ def main() -> None:
1031
+ args = parse_args()
1032
+ output_dir = resolve_path(args.output_dir)
1033
+ output_dir.mkdir(parents=True, exist_ok=True)
1034
+ final_model_dir = output_dir / "final_model"
1035
+
1036
+ csv_selection = resolve_csv_paths(args)
1037
+ device = choose_device()
1038
+
1039
+ print("DNABERT-2 ClinVar training")
1040
+ print(f"Selected dataset directory: {csv_selection.dataset_dir}")
1041
+ print(f"Selected dataset name: {csv_selection.dataset_name}")
1042
+ print(f"Using 20k alt-sequence dataset: {csv_selection.is_20k_alt_dataset}")
1043
+ print(f"Using 10k alt-sequence dataset: {csv_selection.is_10k_alt_dataset}")
1044
+ print(f"Using large alt-sequence dataset: {csv_selection.is_large_alt_dataset}")
1045
+ print(f"Selected train CSV path: {csv_selection.train_csv}")
1046
+ print(f"Selected validation CSV path: {csv_selection.val_csv}")
1047
+ print(f"Selected test CSV path: {csv_selection.test_csv}")
1048
+ print(f"Selected sequence column: {args.sequence_column}")
1049
+ print(f"Using alt-sequence dataset: {csv_selection.is_alt_dataset}")
1050
+ print(f"Selected device: {device}")
1051
+ if device == "cuda":
1052
+ print("CUDA training enabled. fp16 will be enabled in TrainingArguments.")
1053
+ if device == "mps":
1054
+ print("MPS training enabled. fp16/bf16 stay disabled for Mac compatibility.")
1055
+ if device == "cpu":
1056
+ print("WARNING: CPU training will be slow. Consider using sample_size > 0.")
1057
+ print(f"Output path: {output_dir}")
1058
+ print()
1059
+
1060
+ train_df = load_and_filter_dataframe(csv_selection.train_csv, "train", args.sequence_column)
1061
+ val_df = load_and_filter_dataframe(csv_selection.val_csv, "validation", args.sequence_column)
1062
+ test_df = load_and_filter_dataframe(csv_selection.test_csv, "test", args.sequence_column)
1063
+
1064
+ if args.sample_size > 0:
1065
+ train_df = stratified_sample(train_df, args.sample_size, "train")
1066
+ eval_sample_size = min(200, args.sample_size)
1067
+ val_df = stratified_sample(val_df, min(eval_sample_size, len(val_df)), "validation")
1068
+ test_df = stratified_sample(test_df, min(eval_sample_size, len(test_df)), "test")
1069
+
1070
+ train_df = apply_center_crop(
1071
+ train_df,
1072
+ args.max_length,
1073
+ args.variant_center_index,
1074
+ "train",
1075
+ args.center_crop,
1076
+ )
1077
+ val_df = apply_center_crop(
1078
+ val_df,
1079
+ args.max_length,
1080
+ args.variant_center_index,
1081
+ "validation",
1082
+ args.center_crop,
1083
+ )
1084
+ test_df = apply_center_crop(
1085
+ test_df,
1086
+ args.max_length,
1087
+ args.variant_center_index,
1088
+ "test",
1089
+ args.center_crop,
1090
+ )
1091
+ class_weights = maybe_compute_class_weights(train_df, args.use_class_weights)
1092
+ eval_val_df = make_eval_subset(val_df, args.eval_subset_size, "validation")
1093
+ eval_test_df = make_eval_subset(test_df, args.eval_subset_size, "test")
1094
+
1095
+ print(f"Final train rows: {len(train_df):,}")
1096
+ print(f"Final validation rows: {len(val_df):,}")
1097
+ print(f"Final test rows: {len(test_df):,}")
1098
+ print(f"Validation rows used for evaluation: {len(eval_val_df):,}")
1099
+ print(f"Test rows used for evaluation: {len(eval_test_df):,}")
1100
+ print(f"Save/evaluate each epoch: {args.save_eval_each_epoch}")
1101
+ print(f"Eval accumulation steps: {args.eval_accumulation_steps}")
1102
+ print("Using memory-safe evaluation mode.")
1103
+ print()
1104
+
1105
+ model, model_path = load_dnabert2_model(device)
1106
+ freeze_encoder_if_requested(
1107
+ model,
1108
+ args.freeze_encoder,
1109
+ args.unfreeze_last_n_layers,
1110
+ args.freeze_embeddings,
1111
+ device,
1112
+ )
1113
+
1114
+ print(f"Loading tokenizer from: {model_path}")
1115
+ tokenizer = AutoTokenizer.from_pretrained(str(model_path), trust_remote_code=True)
1116
+
1117
+ train_dataset = tokenize_dataset(tokenizer, dataframe_to_dataset(train_df), args.max_length, "train")
1118
+ val_dataset = tokenize_dataset(tokenizer, dataframe_to_dataset(eval_val_df), args.max_length, "validation")
1119
+ test_dataset = tokenize_dataset(tokenizer, dataframe_to_dataset(eval_test_df), args.max_length, "test")
1120
+
1121
+ training_args = make_training_arguments(args, output_dir, device)
1122
+ trainer = make_trainer(model, tokenizer, training_args, train_dataset, val_dataset, class_weights)
1123
+
1124
+ print("Starting training.")
1125
+ train_output = trainer.train(resume_from_checkpoint=args.resume_from_checkpoint)
1126
+
1127
+ print("Predicting validation split for threshold tuning.")
1128
+ validation_probabilities, validation_labels = predict_in_small_batches(
1129
+ trainer.model,
1130
+ val_dataset,
1131
+ batch_size=1,
1132
+ device=device,
1133
+ )
1134
+ if args.tune_threshold:
1135
+ selected_threshold, best_validation_mcc = tune_threshold_for_mcc(validation_labels, validation_probabilities)
1136
+ print(f"Selected threshold from validation MCC: {selected_threshold:.2f}")
1137
+ print(f"Best validation MCC during threshold tuning: {best_validation_mcc:.4f}")
1138
+ else:
1139
+ selected_threshold = 0.5
1140
+ best_validation_mcc = None
1141
+ print("Threshold tuning disabled. Using threshold: 0.50")
1142
+ print()
1143
+
1144
+ validation_metrics = metrics_at_threshold(validation_labels, validation_probabilities, selected_threshold)
1145
+ print_threshold_metrics("validation", validation_metrics)
1146
+
1147
+ print("Predicting test split with selected threshold.")
1148
+ test_probabilities, test_labels = predict_in_small_batches(
1149
+ trainer.model,
1150
+ test_dataset,
1151
+ batch_size=1,
1152
+ device=device,
1153
+ )
1154
+ test_metrics = metrics_at_threshold(test_labels, test_probabilities, selected_threshold)
1155
+ print_threshold_metrics("test", test_metrics)
1156
+
1157
+ final_model_dir.mkdir(parents=True, exist_ok=True)
1158
+ trainer.save_model(str(final_model_dir))
1159
+ tokenizer.save_pretrained(str(final_model_dir))
1160
+
1161
+ metrics = {
1162
+ "model_name": MODEL_NAME,
1163
+ "model_path": str(model_path),
1164
+ "train_csv": str(csv_selection.train_csv),
1165
+ "validation_csv": str(csv_selection.val_csv),
1166
+ "test_csv": str(csv_selection.test_csv),
1167
+ "dataset_dir": str(csv_selection.dataset_dir),
1168
+ "dataset_name": csv_selection.dataset_name,
1169
+ "sequence_column": args.sequence_column,
1170
+ "is_alt_sequence_dataset": csv_selection.is_alt_dataset,
1171
+ "is_large_alt_sequence_dataset": csv_selection.is_large_alt_dataset,
1172
+ "is_10k_alt_sequence_dataset": csv_selection.is_10k_alt_dataset,
1173
+ "is_20k_alt_sequence_dataset": csv_selection.is_20k_alt_dataset,
1174
+ "device": device,
1175
+ "freeze_encoder": args.freeze_encoder,
1176
+ "unfreeze_last_n_layers": args.unfreeze_last_n_layers,
1177
+ "freeze_embeddings": args.freeze_embeddings,
1178
+ "use_class_weights": args.use_class_weights,
1179
+ "class_weights": class_weights.tolist() if class_weights is not None else None,
1180
+ "center_crop": args.center_crop,
1181
+ "tune_threshold": args.tune_threshold,
1182
+ "save_eval_each_epoch": args.save_eval_each_epoch,
1183
+ "eval_accumulation_steps": args.eval_accumulation_steps,
1184
+ "eval_subset_size": args.eval_subset_size,
1185
+ "selected_threshold": selected_threshold,
1186
+ "best_validation_mcc_for_threshold": best_validation_mcc,
1187
+ "sample_size": args.sample_size,
1188
+ "epochs": args.epochs,
1189
+ "max_length": args.max_length,
1190
+ "variant_center_index": args.variant_center_index,
1191
+ "train_metrics": train_output.metrics,
1192
+ "train_rows": len(train_df),
1193
+ "validation_rows": len(val_df),
1194
+ "test_rows": len(test_df),
1195
+ "validation_eval_rows": len(eval_val_df),
1196
+ "test_eval_rows": len(eval_test_df),
1197
+ "validation_metrics": validation_metrics,
1198
+ "test_metrics": test_metrics,
1199
+ }
1200
+ metrics_path = save_metrics(output_dir, metrics)
1201
+ zip_path = zip_final_model(output_dir, final_model_dir)
1202
+
1203
+ print("Final metrics:")
1204
+ print(json.dumps(metrics, indent=2))
1205
+ print(f"Saved final model to: {final_model_dir}")
1206
+ print(f"Saved metrics to: {metrics_path}")
1207
+ print(f"Created zip file: {zip_path}")
1208
+ print("Local DNABERT-2 training completed successfully.")
1209
+
1210
+
1211
+ if __name__ == "__main__":
1212
+ main()
training/train_smoke_test.py ADDED
@@ -0,0 +1,462 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Run a tiny local DNABERT-2 smoke test on Mac.
3
+
4
+ This script checks that the CSV files, tokenizer, model loading, Trainer setup,
5
+ device selection, and model saving all work before attempting a larger run.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import inspect
11
+ import json
12
+ import shutil
13
+ from pathlib import Path
14
+ from urllib.parse import unquote
15
+
16
+ import numpy as np
17
+ import pandas as pd
18
+ import torch
19
+ from datasets import Dataset
20
+ from huggingface_hub import snapshot_download
21
+ from sklearn.metrics import accuracy_score, f1_score, matthews_corrcoef
22
+ from transformers import (
23
+ AutoConfig,
24
+ AutoModelForSequenceClassification,
25
+ AutoTokenizer,
26
+ DataCollatorWithPadding,
27
+ Trainer,
28
+ TrainingArguments,
29
+ )
30
+
31
+
32
+ MODEL_NAME = "zhihan1996/DNABERT-2-117M"
33
+ OUTPUT_DIR = Path("training/outputs/smoke_test")
34
+ FINAL_MODEL_DIR = OUTPUT_DIR / "final_model"
35
+ LOCAL_DNABERT2_PATCH_DIR = Path("training/local_dnabert2_patch")
36
+
37
+ TRAIN_FILENAME = "train_with_sequences.csv"
38
+ VAL_FILENAME = "val_with_sequences.csv"
39
+
40
+ MAX_TRAIN_ROWS = 64
41
+ MAX_VAL_ROWS = 32
42
+ MIN_SEQUENCE_LENGTH = 50
43
+ MAX_LENGTH = 512
44
+
45
+ DROP_CLNSIG_VALUES = (
46
+ "Conflicting_classifications_of_pathogenicity",
47
+ "Conflicting_interpretations",
48
+ "Uncertain_significance",
49
+ "not_provided",
50
+ "risk_factor",
51
+ "association",
52
+ "drug_response",
53
+ "protective",
54
+ )
55
+
56
+ FLASH_DISABLE_FLAGS = {
57
+ "use_flash_attn": False,
58
+ "use_flash_attention": False,
59
+ "flash_attn": False,
60
+ "attn_implementation": "eager",
61
+ "attention_implementation": "eager",
62
+ }
63
+
64
+
65
+ def project_root() -> Path:
66
+ return Path(__file__).resolve().parents[1]
67
+
68
+
69
+ def find_dataset_dir(root: Path) -> Path:
70
+ """Prefer data/processed, then fall back to training/csv_files."""
71
+ candidates = [
72
+ root / "data" / "processed",
73
+ root / "training" / "csv_files",
74
+ ]
75
+
76
+ for directory in candidates:
77
+ train_path = directory / TRAIN_FILENAME
78
+ val_path = directory / VAL_FILENAME
79
+ if train_path.exists() and val_path.exists():
80
+ return directory
81
+
82
+ searched = "\n".join(str(path) for path in candidates)
83
+ raise FileNotFoundError(
84
+ "Could not find train/validation CSV files. Searched:\n"
85
+ f"{searched}\n"
86
+ f"Required files: {TRAIN_FILENAME}, {VAL_FILENAME}"
87
+ )
88
+
89
+
90
+ def choose_device() -> str:
91
+ if torch.cuda.is_available():
92
+ return "cuda"
93
+
94
+ mps_backend = getattr(torch.backends, "mps", None)
95
+ if mps_backend is not None and mps_backend.is_available():
96
+ return "mps"
97
+
98
+ return "cpu"
99
+
100
+
101
+ def disable_flash_attention_on_config(config):
102
+ """Set common config flags that disable flash/triton attention."""
103
+ for field_name, value in FLASH_DISABLE_FLAGS.items():
104
+ setattr(config, field_name, value)
105
+ config.pad_token_id = getattr(config, "pad_token_id", None) or 3
106
+ config.num_labels = 2
107
+ config.id2label = {0: "benign_or_likely_benign", 1: "pathogenic"}
108
+ config.label2id = {"benign_or_likely_benign": 0, "pathogenic": 1}
109
+ return config
110
+
111
+
112
+ def normalize_text(value: object) -> str:
113
+ return unquote(str(value)).strip().lower()
114
+
115
+
116
+ def should_drop_clnsig(value: object) -> bool:
117
+ text = normalize_text(value)
118
+ return any(drop_value.lower() in text for drop_value in DROP_CLNSIG_VALUES)
119
+
120
+
121
+ def clean_sequence(value: object) -> str:
122
+ return str(value).strip().upper()
123
+
124
+
125
+ def load_and_filter_csv(path: Path, split_name: str, max_rows: int) -> Dataset:
126
+ df = pd.read_csv(path)
127
+ before_rows = len(df)
128
+
129
+ required_columns = {"sequence", "label"}
130
+ missing_columns = sorted(required_columns - set(df.columns))
131
+ if missing_columns:
132
+ raise ValueError(f"{path} is missing required columns: {missing_columns}")
133
+
134
+ df = df.copy()
135
+
136
+ if "CLNSIG" in df.columns:
137
+ df = df.loc[~df["CLNSIG"].fillna("").apply(should_drop_clnsig)].copy()
138
+
139
+ df["label"] = pd.to_numeric(df["label"], errors="coerce")
140
+ df = df.loc[df["label"].isin([0, 1])].copy()
141
+ df["label"] = df["label"].astype(int)
142
+
143
+ df["sequence"] = df["sequence"].fillna("").apply(clean_sequence)
144
+ df = df.loc[df["sequence"] != ""].copy()
145
+ df = df.loc[df["sequence"].str.len() >= MIN_SEQUENCE_LENGTH].copy()
146
+
147
+ after_filter_rows = len(df)
148
+ df = df.head(max_rows).copy()
149
+
150
+ print(f"{split_name} CSV: {path}")
151
+ print(f"{split_name} rows before filtering: {before_rows:,}")
152
+ print(f"{split_name} rows after filtering: {after_filter_rows:,}")
153
+ print(f"{split_name} rows used for smoke test: {len(df):,}")
154
+ print(f"{split_name} label distribution:")
155
+ print(df["label"].value_counts().sort_index().to_string())
156
+ print()
157
+
158
+ if df.empty:
159
+ raise ValueError(f"No usable rows remain for {split_name}.")
160
+
161
+ dataset_df = df[["sequence", "label"]].rename(columns={"label": "labels"})
162
+ return Dataset.from_pandas(dataset_df, preserve_index=False)
163
+
164
+
165
+ def tokenize_datasets(tokenizer, train_dataset: Dataset, val_dataset: Dataset) -> tuple[Dataset, Dataset]:
166
+ def tokenize_batch(batch):
167
+ return tokenizer(
168
+ batch["sequence"],
169
+ max_length=MAX_LENGTH,
170
+ padding="max_length",
171
+ truncation=True,
172
+ )
173
+
174
+ train_dataset = train_dataset.map(tokenize_batch, batched=True, remove_columns=["sequence"])
175
+ val_dataset = val_dataset.map(tokenize_batch, batched=True, remove_columns=["sequence"])
176
+ return train_dataset, val_dataset
177
+
178
+
179
+ def compute_metrics(eval_pred):
180
+ if hasattr(eval_pred, "predictions"):
181
+ logits = eval_pred.predictions
182
+ labels = eval_pred.label_ids
183
+ else:
184
+ logits, labels = eval_pred
185
+
186
+ if isinstance(logits, (tuple, list)):
187
+ for item in logits:
188
+ candidate = np.asarray(item)
189
+ if candidate.ndim >= 2 and candidate.shape[-1] == 2:
190
+ logits = candidate
191
+ break
192
+ else:
193
+ logits = np.asarray(logits[0])
194
+ else:
195
+ logits = np.asarray(logits)
196
+
197
+ labels = np.asarray(labels)
198
+ predictions = np.argmax(logits, axis=-1)
199
+ return {
200
+ "accuracy": accuracy_score(labels, predictions),
201
+ "f1": f1_score(labels, predictions, zero_division=0),
202
+ "matthews_corrcoef": matthews_corrcoef(labels, predictions),
203
+ }
204
+
205
+
206
+ def make_training_arguments() -> TrainingArguments:
207
+ base_kwargs = {
208
+ "output_dir": str(OUTPUT_DIR),
209
+ "num_train_epochs": 1,
210
+ "learning_rate": 2e-5,
211
+ "per_device_train_batch_size": 1,
212
+ "per_device_eval_batch_size": 1,
213
+ "gradient_accumulation_steps": 4,
214
+ "save_strategy": "epoch",
215
+ "logging_steps": 5,
216
+ "save_total_limit": 1,
217
+ "report_to": "none",
218
+ "dataloader_num_workers": 0,
219
+ "dataloader_pin_memory": False,
220
+ "remove_unused_columns": False,
221
+ "fp16": False,
222
+ "bf16": False,
223
+ }
224
+
225
+ try:
226
+ return TrainingArguments(**base_kwargs, eval_strategy="epoch")
227
+ except TypeError:
228
+ return TrainingArguments(**base_kwargs, evaluation_strategy="epoch")
229
+
230
+
231
+ def make_trainer(model, tokenizer, training_args, train_dataset: Dataset, val_dataset: Dataset) -> Trainer:
232
+ trainer_kwargs = {
233
+ "model": model,
234
+ "args": training_args,
235
+ "train_dataset": train_dataset,
236
+ "eval_dataset": val_dataset,
237
+ "data_collator": DataCollatorWithPadding(tokenizer=tokenizer),
238
+ "compute_metrics": compute_metrics,
239
+ }
240
+
241
+ trainer_signature = inspect.signature(Trainer.__init__)
242
+ if "processing_class" in trainer_signature.parameters:
243
+ trainer_kwargs["processing_class"] = tokenizer
244
+ else:
245
+ trainer_kwargs["tokenizer"] = tokenizer
246
+
247
+ return Trainer(**trainer_kwargs)
248
+
249
+
250
+ def load_dnabert2_with_eager_attention():
251
+ """Approach A: ask DNABERT-2 remote code to use eager attention."""
252
+ print("Trying DNABERT-2 with eager attention...")
253
+ config = AutoConfig.from_pretrained(MODEL_NAME, trust_remote_code=True)
254
+ config = disable_flash_attention_on_config(config)
255
+ print("Triton/flash attention disabled for Mac.")
256
+
257
+ model = load_sequence_classification_model(MODEL_NAME, config)
258
+ print("Model loaded successfully.")
259
+ return model
260
+
261
+
262
+ def load_sequence_classification_model(model_source: str, config):
263
+ """Load a classifier, retrying without num_labels for older custom classes."""
264
+ common_kwargs = {
265
+ "config": config,
266
+ "trust_remote_code": True,
267
+ "low_cpu_mem_usage": False,
268
+ }
269
+ try:
270
+ return AutoModelForSequenceClassification.from_pretrained(
271
+ model_source,
272
+ num_labels=2,
273
+ **common_kwargs,
274
+ )
275
+ except TypeError as error:
276
+ if "num_labels" not in str(error):
277
+ raise
278
+ return AutoModelForSequenceClassification.from_pretrained(
279
+ model_source,
280
+ **common_kwargs,
281
+ )
282
+
283
+
284
+ def patch_bert_layers_for_mac(source_path: Path, destination_path: Path) -> None:
285
+ """Remove the flash_attn_triton import so Transformers does not require Triton."""
286
+ source_text = source_path.read_text(encoding="utf-8")
287
+ flash_import_block = """try:
288
+ from .flash_attn_triton import flash_attn_qkvpacked_func
289
+ except ImportError as e:
290
+ flash_attn_qkvpacked_func = None
291
+ """
292
+ patched_text = source_text.replace(
293
+ flash_import_block,
294
+ "# Mac-safe local patch: always use the PyTorch attention fallback.\n"
295
+ "flash_attn_qkvpacked_func = None\n",
296
+ )
297
+
298
+ if "from .flash_attn_triton import" in patched_text:
299
+ raise RuntimeError("Could not patch flash_attn_triton import from bert_layers.py.")
300
+
301
+ # Newer Transformers versions may instantiate custom models under a meta
302
+ # device context. The original ALiBi code can then multiply a CPU tensor by
303
+ # a meta tensor. Keep both tensors on the same device.
304
+ patched_text = patched_text.replace(
305
+ " slopes = torch.Tensor(_get_alibi_head_slopes(n_heads)).to(device)\n"
306
+ " alibi = slopes.unsqueeze(1).unsqueeze(1) * -relative_position\n",
307
+ " slope_device = device if device is not None else relative_position.device\n"
308
+ " slopes = torch.tensor(_get_alibi_head_slopes(n_heads), device=slope_device)\n"
309
+ " alibi = slopes.unsqueeze(1).unsqueeze(1) * -relative_position\n",
310
+ )
311
+ patched_text = patched_text.replace(
312
+ " elif self.alibi.device != hidden_states.device:\n"
313
+ " # Device catch-up\n"
314
+ " self.alibi = self.alibi.to(hidden_states.device)\n",
315
+ " elif getattr(self.alibi, 'is_meta', False) or self.alibi.device != hidden_states.device:\n"
316
+ " # Device catch-up. Under newer Transformers, the buffer may be created\n"
317
+ " # on the meta device during low-level loading, so rebuild it instead of\n"
318
+ " # copying it.\n"
319
+ " self.rebuild_alibi_tensor(size=self._current_alibi_size, device=hidden_states.device)\n",
320
+ )
321
+
322
+ destination_path.write_text(patched_text, encoding="utf-8")
323
+
324
+
325
+ def clear_local_patch_module_cache() -> None:
326
+ """Clear Transformers' cached dynamic module for the local patch."""
327
+ cache_dir = Path.home() / ".cache" / "huggingface" / "modules" / "transformers_modules" / "local_dnabert2_patch"
328
+ if cache_dir.exists():
329
+ shutil.rmtree(cache_dir)
330
+
331
+
332
+ def create_local_dnabert2_patch() -> Path:
333
+ """Approach B: create a local DNABERT-2 copy without the Triton import."""
334
+ print("Creating local Mac-safe DNABERT-2 patch...")
335
+ snapshot_path = Path(
336
+ snapshot_download(
337
+ MODEL_NAME,
338
+ allow_patterns=[
339
+ "config.json",
340
+ "configuration_bert.py",
341
+ "bert_layers.py",
342
+ "bert_padding.py",
343
+ "tokenizer.json",
344
+ "tokenizer_config.json",
345
+ "pytorch_model.bin",
346
+ "model.safetensors",
347
+ "generation_config.json",
348
+ ],
349
+ )
350
+ )
351
+
352
+ if LOCAL_DNABERT2_PATCH_DIR.exists():
353
+ shutil.rmtree(LOCAL_DNABERT2_PATCH_DIR)
354
+ LOCAL_DNABERT2_PATCH_DIR.mkdir(parents=True, exist_ok=True)
355
+
356
+ files_to_copy = [
357
+ "config.json",
358
+ "configuration_bert.py",
359
+ "bert_padding.py",
360
+ "tokenizer.json",
361
+ "tokenizer_config.json",
362
+ "generation_config.json",
363
+ "pytorch_model.bin",
364
+ "model.safetensors",
365
+ ]
366
+ for filename in files_to_copy:
367
+ source = snapshot_path / filename
368
+ if source.exists():
369
+ shutil.copy2(source, LOCAL_DNABERT2_PATCH_DIR / filename)
370
+
371
+ patch_bert_layers_for_mac(snapshot_path / "bert_layers.py", LOCAL_DNABERT2_PATCH_DIR / "bert_layers.py")
372
+ clear_local_patch_module_cache()
373
+
374
+ config_path = LOCAL_DNABERT2_PATCH_DIR / "config.json"
375
+ config_json = json.loads(config_path.read_text(encoding="utf-8"))
376
+ config_json.update(FLASH_DISABLE_FLAGS)
377
+ config_json["pad_token_id"] = config_json.get("pad_token_id") or 3
378
+ config_json["num_labels"] = 2
379
+ config_json["id2label"] = {"0": "benign_or_likely_benign", "1": "pathogenic"}
380
+ config_json["label2id"] = {"benign_or_likely_benign": 0, "pathogenic": 1}
381
+ config_path.write_text(json.dumps(config_json, indent=2), encoding="utf-8")
382
+
383
+ print(f"Local Mac-safe DNABERT-2 patch ready: {LOCAL_DNABERT2_PATCH_DIR}")
384
+ return LOCAL_DNABERT2_PATCH_DIR
385
+
386
+
387
+ def load_dnabert2_from_local_patch():
388
+ """Load the local patched copy that avoids flash_attn_triton."""
389
+ patch_dir = create_local_dnabert2_patch()
390
+ config = AutoConfig.from_pretrained(str(patch_dir), trust_remote_code=True)
391
+ config = disable_flash_attention_on_config(config)
392
+
393
+ model = load_sequence_classification_model(str(patch_dir), config)
394
+ print("Triton/flash attention disabled for Mac.")
395
+ print("Model loaded successfully.")
396
+ return model
397
+
398
+
399
+ def load_mac_safe_dnabert2_model():
400
+ """Load DNABERT-2 without requiring Triton on Mac."""
401
+ try:
402
+ return load_dnabert2_with_eager_attention()
403
+ except Exception as eager_error:
404
+ print("Approach A failed. Falling back to local Mac-safe DNABERT-2 patch.")
405
+ print(f"Approach A error: {eager_error}")
406
+
407
+ try:
408
+ return load_dnabert2_from_local_patch()
409
+ except Exception as patch_error:
410
+ raise RuntimeError(
411
+ "DNABERT-2 could not be loaded without Triton. "
412
+ "The Mac-safe eager and local patch strategies both failed."
413
+ ) from patch_error
414
+
415
+
416
+ def main() -> None:
417
+ root = project_root()
418
+ dataset_dir = find_dataset_dir(root)
419
+ train_csv = dataset_dir / TRAIN_FILENAME
420
+ val_csv = dataset_dir / VAL_FILENAME
421
+ device = choose_device()
422
+
423
+ print("DNABERT-2 local smoke test")
424
+ print(f"Selected CSV directory: {dataset_dir}")
425
+ print(f"Selected train CSV path: {train_csv}")
426
+ print(f"Selected validation CSV path: {val_csv}")
427
+ print(f"Selected device: {device}")
428
+ if device == "cpu":
429
+ print("WARNING: CPU training will be slow. This script uses a tiny subset only.")
430
+ print()
431
+
432
+ train_dataset = load_and_filter_csv(train_csv, "train", MAX_TRAIN_ROWS)
433
+ val_dataset = load_and_filter_csv(val_csv, "validation", MAX_VAL_ROWS)
434
+
435
+ print(f"Loading tokenizer: {MODEL_NAME}")
436
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, trust_remote_code=True)
437
+
438
+ print(f"Loading model: {MODEL_NAME}")
439
+ model = load_mac_safe_dnabert2_model()
440
+
441
+ train_dataset, val_dataset = tokenize_datasets(tokenizer, train_dataset, val_dataset)
442
+
443
+ training_args = make_training_arguments()
444
+ trainer = make_trainer(model, tokenizer, training_args, train_dataset, val_dataset)
445
+
446
+ print("Starting 1-epoch smoke-test training.")
447
+ trainer.train()
448
+
449
+ print("Running validation evaluation.")
450
+ metrics = trainer.evaluate()
451
+ print(metrics)
452
+
453
+ FINAL_MODEL_DIR.mkdir(parents=True, exist_ok=True)
454
+ trainer.save_model(str(FINAL_MODEL_DIR))
455
+ tokenizer.save_pretrained(str(FINAL_MODEL_DIR))
456
+
457
+ print(f"Saved final model to: {FINAL_MODEL_DIR}")
458
+ print("Smoke test completed successfully.")
459
+
460
+
461
+ if __name__ == "__main__":
462
+ main()