Spaces:
Runtime error
Runtime error
faisalAI27 commited on
Commit ·
f66847f
1
Parent(s): a5ebf39
heavy training
Browse files- .gitignore +16 -0
- training/COLAB_README.md +33 -0
- training/README.md +69 -2
- training/audit_variant_sequence_encoding.py +416 -0
- training/build_alt_sequence_dataset.py +316 -0
- training/check_dataset.py +271 -0
- training/check_device.py +42 -0
- training/check_large_dataset.py +265 -0
- training/colab_dnabert2_clinvar_finetune.ipynb +40 -10
- training/colab_dnabert2_heavy_training.ipynb +326 -0
- training/csv_files_alt/test_with_alt_sequences.csv +0 -0
- training/csv_files_alt/train_with_alt_sequences.csv +0 -0
- training/csv_files_alt/val_with_alt_sequences.csv +0 -0
- training/evaluate_saved_model.py +598 -0
- training/prepare_larger_clinvar_dataset.py +742 -0
- training/requirements-colab.txt +2 -0
- training/requirements-mac.txt +11 -0
- training/scripts/train_dnabert2_classifier.py +38 -0
- training/setup_mac.md +80 -0
- training/train_local_dnabert2.py +1212 -0
- training/train_smoke_test.py +462 -0
.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
|
| 4 |
|
| 5 |
-
|
| 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 |
-
"
|
| 217 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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()
|