Add BERTose and AFFINose training code release
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +6 -33
- MANIFEST.md +138 -0
- README.md +104 -0
- RELEASE_AUDIT.md +140 -0
- SHA256SUMS +128 -0
- code/affinose/README.md +23 -0
- code/benchmarks/calculate_rankings.py +57 -0
- code/benchmarks/consolidate_results.py +135 -0
- code/benchmarks/create_excluded_dataset.py +46 -0
- code/benchmarks/extract_all_benchmark_results.py +244 -0
- code/benchmarks/finetune.py +708 -0
- code/bertint/bertint_v8.py +809 -0
- code/bertint/bertint_v8_inference.py +187 -0
- code/bertint/build_combined_dataset.py +472 -0
- code/bertint/compare_ablation.py +174 -0
- code/bertint/dataset_v8.py +298 -0
- code/bertint/generate_glycan_splits.py +119 -0
- code/bertint/training_v8.py +653 -0
- code/contrastive/contrastive_trainer_v51_FINAL.py +298 -0
- code/contrastive/contrastive_trainer_v5_SIMPLE.py +295 -0
- code/contrastive/extract_resolved_glycans.py +29 -0
- code/contrastive/generate_negatives_v4_FINAL.py +369 -0
- code/contrastive/mcns_filter_75k_v3_FINAL.py +175 -0
- code/contrastive/score_negative_difficulty.py +214 -0
- code/contrastive_training/step3_train_v51_FIXED_FINAL.sh +57 -0
- code/data_processing/augment_pretraining_data.py +163 -0
- code/data_processing/convert_iupac_glycosmos.py +114 -0
- code/downstream_tasks/__init__.py +0 -0
- code/downstream_tasks/utils/__init__.py +0 -0
- code/downstream_tasks/utils/baseline_results.py +461 -0
- code/downstream_tasks/utils/dataset.py +376 -0
- code/downstream_tasks/utils/tokenizer.py +612 -0
- code/downstream_tasks/utils/wurcs_bpe_tokenizer.py +740 -0
- code/model/__init__.py +3 -0
- code/model/dataset.py +360 -0
- code/model/enhanced_classifier.py +189 -0
- code/model/glycan_bert.py +303 -0
- code/model/hierarchical_glycan.py +284 -0
- code/model/multimodal_glycan_bert_v3.py +1084 -0
- code/model/tokenizer.py +612 -0
- code/model/wurcs_bpe_tokenizer.py +740 -0
- code/probes/analyze_core_biology.py +467 -0
- code/probes/analyze_embeddings.py +276 -0
- code/probes/cluster_scripts/run_benchmark_embeddings.sh +54 -0
- code/probes/cluster_scripts/run_diag_zero.sh +23 -0
- code/probes/cluster_scripts/run_embedding_analysis.sh +45 -0
- code/probes/cluster_scripts/run_explore_bio.sh +35 -0
- code/probes/cluster_scripts/run_novel_probes.sh +33 -0
- code/probes/cluster_scripts/run_novel_probes_v2.sh +67 -0
- code/probes/cluster_scripts/run_novel_probes_v2_full.sh +56 -0
.gitattributes
CHANGED
|
@@ -1,35 +1,8 @@
|
|
| 1 |
-
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.
|
| 25 |
-
*.
|
| 26 |
-
|
| 27 |
-
*.
|
| 28 |
-
*.
|
| 29 |
-
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 2 |
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.hdf5 filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
data/splits/train_val_split.json filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
MANIFEST.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Nature Methods Submission — File Manifest
|
| 2 |
+
|
| 3 |
+
**Generated**: 2026-02-16
|
| 4 |
+
**Reproducibility update**: 2026-05-16
|
| 5 |
+
**Total size**: 151 MB (347 files)
|
| 6 |
+
**Cluster base**: `/work/ratul1/supantha/glycan-SD-VS/bert_training_v3/v3.1_cluster_training/`
|
| 7 |
+
|
| 8 |
+
> [!IMPORTANT]
|
| 9 |
+
> This manifest documents the provenance of every file in this submission package.
|
| 10 |
+
> Files were selected based on: (1) latest version (FINAL/CORRECT suffix preferred), (2) production model (v5b excluded = leakage-proof), and (3) avoiding multi-GB pickles/checkpoints.
|
| 11 |
+
>
|
| 12 |
+
> The 2026-05-16 reproducibility update restores package-local imports required by the
|
| 13 |
+
> pretraining, benchmark, and probe scripts. In particular, `train_multimodal.py`
|
| 14 |
+
> now ships with the `training.multimodal_dataset`, `training.multimodal_masking`,
|
| 15 |
+
> and `training.masking` modules it imports.
|
| 16 |
+
|
| 17 |
+
## Version Selection Rules
|
| 18 |
+
|
| 19 |
+
| Version | Purpose | Config | Checkpoint (not copied — too large) |
|
| 20 |
+
|---|---|---|---|
|
| 21 |
+
| v3 ORIGINAL | Legacy baseline pre-BPE | `multimodal_config_v3_ORIGINAL.yaml` | `checkpoints_multimodal_v3/` |
|
| 22 |
+
| v4 BPE | First BPE model | `multimodal_config_v4_bpe.yaml` | `checkpoints_v4_bpe/` |
|
| 23 |
+
| v5-A (IPA) | IPA self-distillation | `multimodal_config_v5a_bpe_topo.yaml` | `checkpoints_v5_bpe_topo/` (top-level, NOT inside bert_v5_bpe_topo/) |
|
| 24 |
+
| **v5b (excluded)** | **Production model — leakage-proof** | `multimodal_config_v5b_excluded.yaml` | `checkpoints_v5b_excluded/` |
|
| 25 |
+
| v5.1 contrastive | **Contrastive refinement (= "V6")** | inline in trainer script | `bert_v5.1_contrastive/checkpoints/best_v51_contrastive_model.pt` |
|
| 26 |
+
| ~~v6 contrastive~~ | ~~Curriculum contrastive~~ | ~~archived~~ | ~~`_archive_v6_supcon_experimental/checkpoints_v6/`~~ (experimental, unused) |
|
| 27 |
+
|
| 28 |
+
## code/model/ — Core Architecture and Tokenization Support (8 files)
|
| 29 |
+
|
| 30 |
+
| File | Source | Size | Why this version |
|
| 31 |
+
|---|---|---|---|
|
| 32 |
+
| glycan_bert.py | model/glycan_bert.py | 11K | Base BERT encoder — single canonical version |
|
| 33 |
+
| multimodal_glycan_bert_v3.py | model/multimodal_glycan_bert_v3.py | 45K | Full multimodal model — latest production version |
|
| 34 |
+
| enhanced_classifier.py | model/enhanced_classifier.py | 6K | Downstream classifier head |
|
| 35 |
+
| hierarchical_glycan.py | model/hierarchical_glycan.py | 12K | Hierarchical glycan data structures |
|
| 36 |
+
| dataset.py | dataset.py (top-level) | 12K | Dataset loader for all tasks |
|
| 37 |
+
| tokenizer.py | bert_training_v4/downstream_tasks/utils/tokenizer.py | 17K | Atomic WURCS tokenizer used by dataset helpers |
|
| 38 |
+
| wurcs_bpe_tokenizer.py | bert_training_v4/downstream_tasks/utils/wurcs_bpe_tokenizer.py | 25K | BPE WURCS tokenizer used by release scripts |
|
| 39 |
+
| __init__.py | model/__init__.py | ~0.1K | Package marker |
|
| 40 |
+
|
| 41 |
+
## code/training/ — Training Scripts and Package-Local Dependencies (8 files)
|
| 42 |
+
|
| 43 |
+
| File | Source | Why |
|
| 44 |
+
|---|---|---|
|
| 45 |
+
| train_multimodal.py | training/train_multimodal.py | Core multimodal pre-training loop |
|
| 46 |
+
| multimodal_dataset.py | training/multimodal_dataset.py | Dataset class imported by `train_multimodal.py` |
|
| 47 |
+
| multimodal_masking.py | training/multimodal_masking.py | Multimodal MLM masking imported by `train_multimodal.py` |
|
| 48 |
+
| masking.py | training/masking.py | Base glycan masking strategies imported by `multimodal_masking.py` |
|
| 49 |
+
| train_wurcs_bpe.py | train_wurcs_bpe.py (top-level) | BPE tokenizer training on WURCS corpus |
|
| 50 |
+
| ipa_bpe_distillation.py | bert_v5_bpe_topo/ipa_bpe_distillation.py | IPA self-distillation procedure |
|
| 51 |
+
| analyze_confidence.py | bert_v5_bpe_topo/analyze_confidence.py | Confidence analysis for IPA |
|
| 52 |
+
| __init__.py | training/__init__.py | Package marker for `training.*` imports |
|
| 53 |
+
|
| 54 |
+
## code/downstream_tasks/utils/ — Benchmark/Probe Utilities (6 files)
|
| 55 |
+
|
| 56 |
+
| File | Source | Why |
|
| 57 |
+
|---|---|---|
|
| 58 |
+
| tokenizer.py | bert_training_v4/downstream_tasks/utils/tokenizer.py | Atomic WURCS tokenizer imported by benchmarks and probes |
|
| 59 |
+
| wurcs_bpe_tokenizer.py | bert_training_v4/downstream_tasks/utils/wurcs_bpe_tokenizer.py | BPE tokenizer imported by benchmark utilities |
|
| 60 |
+
| dataset.py | bert_training_v4/downstream_tasks/utils/dataset.py | Classification dataset helper imported by `benchmarks/finetune.py` |
|
| 61 |
+
| baseline_results.py | bert_training_v4/downstream_tasks/utils/baseline_results.py | Benchmark baseline tables used by utility imports |
|
| 62 |
+
| __init__.py | generated package marker | Keeps imports package-local without pulling private paths |
|
| 63 |
+
| ../__init__.py | generated package marker | Keeps `downstream_tasks.*` imports package-local |
|
| 64 |
+
|
| 65 |
+
## code/contrastive/ — Contrastive Learning (6 files)
|
| 66 |
+
|
| 67 |
+
| File | Source | Why this version, not alternatives |
|
| 68 |
+
|---|---|---|
|
| 69 |
+
| contrastive_trainer_v51_FINAL.py | bert_v5.1_contrastive/training/contrastive_trainer_v6_CORRECT.py | FINAL corrected trainer — v6_CORRECT is the last iteration (v1→v6) |
|
| 70 |
+
| contrastive_trainer_v5_SIMPLE.py | bert_v5.1_contrastive/training/contrastive_trainer_v5_SIMPLE.py | Simplified version for reference |
|
| 71 |
+
| generate_negatives_v4_FINAL.py | bert_v5.1_contrastive/scripts/generate_negatives_v4_FINAL.py | FINAL v4 — supersedes v1/v2/v3 |
|
| 72 |
+
| mcns_filter_75k_v3_FINAL.py | bert_v5.1_contrastive/scripts/mcns_filter_75k_v3_FINAL.py | FINAL v3 — supersedes v1/v2 |
|
| 73 |
+
| extract_resolved_glycans.py | bert_v5.1_contrastive/scripts/extract_resolved_glycans.py | Extracts IPA-resolved glycans for contrastive pairs |
|
| 74 |
+
| score_negative_difficulty.py | bert_v6_contrastive/scripts/score_negative_difficulty.py | V6 curriculum difficulty scoring |
|
| 75 |
+
|
| 76 |
+
## code/probes/ — Embedding Probe Scripts (17 files)
|
| 77 |
+
|
| 78 |
+
All scripts sourced from `bert_v6_contrastive/scripts/` — the latest canonical versions. These are the scripts that generated all results in `results/probes/`.
|
| 79 |
+
|
| 80 |
+
## code/benchmarks/ — Downstream Evaluation (5 files)
|
| 81 |
+
|
| 82 |
+
| File | Source | Why |
|
| 83 |
+
|---|---|---|
|
| 84 |
+
| finetune.py | bert_training_v4/downstream_tasks/finetune.py | Core fine-tuning loop for all 11 tasks |
|
| 85 |
+
| consolidate_results.py | bert_training_v4/downstream_tasks/consolidate_results.py | Aggregates per-seed results |
|
| 86 |
+
| calculate_rankings.py | bert_training_v4/calculate_rankings.py | Computes Avg Rank across tasks |
|
| 87 |
+
| extract_all_benchmark_results.py | bert_training_v4/extract_all_benchmark_results.py | Parses SLURM logs for results |
|
| 88 |
+
| create_excluded_dataset.py | bert_v5b_excluded/scripts/create_excluded_dataset.py | Creates leakage-proof training set |
|
| 89 |
+
|
| 90 |
+
## configs/ — Training Configs (4 files)
|
| 91 |
+
|
| 92 |
+
All 4 production YAML configs. Named with clear version suffixes.
|
| 93 |
+
|
| 94 |
+
## data/ — Data Files (10 files, 18 MB total)
|
| 95 |
+
|
| 96 |
+
| File | Source | Size | Notes |
|
| 97 |
+
|---|---|---|---|
|
| 98 |
+
| bpe_vocabulary_clean.json | data/ | 43K | **Production BPE vocab** — use this one |
|
| 99 |
+
| bpe_vocabulary.json | data/ | 43K | Original BPE vocab (before cleanup) |
|
| 100 |
+
| vocabulary.json | data/ | 11K | Character-level vocab (pre-BPE) |
|
| 101 |
+
| ms_vocabulary.json | data/ | 22K | Mass spec fragment token vocab |
|
| 102 |
+
| bpe_ambiguity_tokens.json | data/ | 22K | Tokens flagged as structurally ambiguous |
|
| 103 |
+
| ambiguity_tokens_CORRECTED.json | data/ | 11K | **Corrected** ambiguity tokens — NOT the OLD_WRONG version |
|
| 104 |
+
| confidence_analysis.json | data/ | 11K | IPA confidence scores |
|
| 105 |
+
| train_val_split.json | data/ | 18M | 203,785 train / 50,947 val split |
|
| 106 |
+
| complete_exclusion_list.txt | bert_v5b_excluded/data/ | 205K | 1,298 glycans excluded for leakage-proof |
|
| 107 |
+
|
| 108 |
+
### Files NOT copied (too large):
|
| 109 |
+
- `sequences_bpe.pkl` (3.0 GB) — full BPE corpus
|
| 110 |
+
- `sequences_bpe_excluded.pkl` (3.0 GB) — leakage-proof corpus
|
| 111 |
+
- `sequences.pkl` (1.7 GB), `sequences_ORIGINAL.pkl` (495 MB)
|
| 112 |
+
- `multimodal_mapping.json` (110 MB), `master_mapping.json` (101 MB)
|
| 113 |
+
- All checkpoint `.pt` files (1.3-1.5 GB each)
|
| 114 |
+
|
| 115 |
+
## results/ — All Outputs
|
| 116 |
+
|
| 117 |
+
### results/benchmarks/ (9 CSVs, 143K)
|
| 118 |
+
- **CONSOLIDATED_ALL_22_MODELS_v4.csv** — latest (v4) master benchmark table. Chosen over v1/v2/v3.
|
| 119 |
+
- MCC/accuracy/macro-F1 pivot tables, avg rank summary, classification + interaction splits
|
| 120 |
+
|
| 121 |
+
### results/probes/ (all 15 probes, 54 MB)
|
| 122 |
+
Each probe directory contains JSONs (metrics), PNGs/PDFs (plots), and NPZ files (embeddings). Both v5 and v6 results included where applicable.
|
| 123 |
+
|
| 124 |
+
### results/figures/ (analysis visualizations, 54 MB)
|
| 125 |
+
t-SNE plots, benchmark embedding visualizations, distance distributions, composition correlations, etc.
|
| 126 |
+
|
| 127 |
+
## docs/ (5 files, 174K)
|
| 128 |
+
- probe_results.md — comprehensive 15-probe summary
|
| 129 |
+
- GLYCANML_BENCHMARK_GUIDE.md — benchmark methodology
|
| 130 |
+
- v51_fixed_notes.md — contrastive training bugfix history
|
| 131 |
+
- representation_research_and_contrastive_learning.md — research notes
|
| 132 |
+
|
| 133 |
+
## ⚠️ Known Risks / Things to Verify
|
| 134 |
+
|
| 135 |
+
1. **Probe results version alignment**: Results in `results/probes/` use the v5b (excluded) checkpoint for benchmarks, and `best_v51_contrastive_model.pt` (= "V6") for contrastive comparisons.
|
| 136 |
+
2. **Benchmark CSV version**: Using `CONSOLIDATED_ALL_22_MODELS_v4.csv` — verify this is the one with all 24 models and 318 rows.
|
| 137 |
+
3. **Contrastive model identity**: The model labeled "V6" throughout all probe scripts and IPA runs is `bert_v5.1_contrastive/checkpoints/best_v51_contrastive_model.pt`. The SupCon curriculum experiment (Feb 8) was archived to `_archive_v6_supcon_experimental/` and was never used for any reported result.
|
| 138 |
+
4. **benchmark_splits/ is empty** — benchmark split files were part of the GlycanML framework, not standalone files. The relevant split logic is embedded in `finetune.py`.
|
README.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: other
|
| 3 |
+
tags:
|
| 4 |
+
- glycans
|
| 5 |
+
- wurcs
|
| 6 |
+
- bertose
|
| 7 |
+
- affinose
|
| 8 |
+
- reproducibility
|
| 9 |
+
- training-code
|
| 10 |
+
- pytorch
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# BERTose and AFFINose Training Code
|
| 14 |
+
|
| 15 |
+
This repository is the public training and reproducibility companion for the BERTose and AFFINose Hugging Face release. It contains the code, configs, tokenizer assets, split files, and provenance notes needed to understand how the released checkpoints were trained and evaluated.
|
| 16 |
+
|
| 17 |
+
The released inference checkpoints live separately:
|
| 18 |
+
|
| 19 |
+
- BERTose glycan encoder: `supanthadey1/bertose-glycan-encoder`
|
| 20 |
+
- BERTose IAR resolver: `supanthadey1/bertose-iar-resolver`
|
| 21 |
+
- AFFINose interaction model: `supanthadey1/affinose-interaction-model`
|
| 22 |
+
- Cloud inference notebook: `supanthadey1/bertose-affinose-inference`
|
| 23 |
+
|
| 24 |
+
## Repository Map
|
| 25 |
+
|
| 26 |
+
| Public workflow | Files |
|
| 27 |
+
|---|---|
|
| 28 |
+
| BERTose multimodal pretraining | `code/training/train_multimodal.py`, `code/training/multimodal_dataset.py`, `code/training/multimodal_masking.py`, `configs/` |
|
| 29 |
+
| BERTose WURCS-BPE tokenizer training | `code/training/train_wurcs_bpe.py`, `code/model/wurcs_bpe_tokenizer.py`, `data/vocab/` |
|
| 30 |
+
| BERTose IAR / contrastive refinement | `code/contrastive/`, `code/contrastive_training/`, `code/tokenizer/generate_bpe_ambiguity.py` |
|
| 31 |
+
| AFFINose interaction training | `code/affinose/README.md` |
|
| 32 |
+
| Benchmark reproduction | `code/benchmarks/`, `code/downstream_tasks/utils/` |
|
| 33 |
+
| Embedding and biology probes | `code/probes/` |
|
| 34 |
+
| Split and vocabulary assets | `data/splits/`, `data/vocab/` |
|
| 35 |
+
| Compute and lineage provenance | `provenance/` |
|
| 36 |
+
|
| 37 |
+
Some executable filenames and directories retain historical names so that provenance remains traceable to the original training runs. In public-facing text, use BERTose for the glycan encoder / IAR workflows and AFFINose for the protein-glycan interaction workflow.
|
| 38 |
+
|
| 39 |
+
## What Is Included
|
| 40 |
+
|
| 41 |
+
- Core BERTose architecture and dataset utilities.
|
| 42 |
+
- BERTose multimodal pretraining entrypoint and configs.
|
| 43 |
+
- WURCS-BPE tokenizer training and ambiguity-token generation code.
|
| 44 |
+
- Contrastive refinement / IAR training and negative generation scripts.
|
| 45 |
+
- AFFINose interaction-model data construction, split generation, training, and inference code.
|
| 46 |
+
- Benchmark and probe scripts used for manuscript analyses.
|
| 47 |
+
- Vocabulary files, ambiguity-token maps, train/validation split metadata, and leakage-exclusion lists.
|
| 48 |
+
- Compute-provenance notes and representative launch scripts.
|
| 49 |
+
|
| 50 |
+
## What Is Not Included
|
| 51 |
+
|
| 52 |
+
Large training artifacts are intentionally not bundled here:
|
| 53 |
+
|
| 54 |
+
- Full pretraining corpus pickles such as `sequences_bpe.pkl`.
|
| 55 |
+
- Multi-GB intermediate mapping files.
|
| 56 |
+
- Full training checkpoints; released checkpoints are hosted in the separate model repositories listed above.
|
| 57 |
+
- ESM-C protein embeddings required for AFFINose training; users should generate or provide those separately according to the ESM-C access rules.
|
| 58 |
+
|
| 59 |
+
## Quick Import Check
|
| 60 |
+
|
| 61 |
+
After downloading the repository, a lightweight import check should work without access to the full training data:
|
| 62 |
+
|
| 63 |
+
```bash
|
| 64 |
+
cd code
|
| 65 |
+
PYTHONDONTWRITEBYTECODE=1 python - <<'PY'
|
| 66 |
+
import importlib
|
| 67 |
+
|
| 68 |
+
for module in [
|
| 69 |
+
"training.multimodal_dataset",
|
| 70 |
+
"training.masking",
|
| 71 |
+
"training.multimodal_masking",
|
| 72 |
+
"training.train_multimodal",
|
| 73 |
+
"training.train_wurcs_bpe",
|
| 74 |
+
"model.dataset",
|
| 75 |
+
"downstream_tasks.utils.tokenizer",
|
| 76 |
+
"downstream_tasks.utils.wurcs_bpe_tokenizer",
|
| 77 |
+
"downstream_tasks.utils.dataset",
|
| 78 |
+
]:
|
| 79 |
+
importlib.import_module(module)
|
| 80 |
+
print("ok", module)
|
| 81 |
+
PY
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
Full training requires the original large data artifacts and appropriate GPU resources.
|
| 85 |
+
|
| 86 |
+
## Environment
|
| 87 |
+
|
| 88 |
+
Install the core dependencies with:
|
| 89 |
+
|
| 90 |
+
```bash
|
| 91 |
+
pip install -r requirements.txt
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
Some probe scripts also use optional scientific plotting and glycan-analysis packages. See `requirements.txt` for the split between core and optional dependencies.
|
| 95 |
+
|
| 96 |
+
## Reproducibility Notes
|
| 97 |
+
|
| 98 |
+
- `MANIFEST.md` documents the file provenance and model-lineage mapping.
|
| 99 |
+
- `RELEASE_AUDIT.md` documents the current public-release state, fixed gaps, and remaining pre-upload checks.
|
| 100 |
+
- Cluster launch scripts under `provenance/compute_provenance/` preserve the original compute context. They are provenance records, not portable one-command launchers for every environment.
|
| 101 |
+
|
| 102 |
+
## License
|
| 103 |
+
|
| 104 |
+
The final license and citation text should be updated before broad public release.
|
RELEASE_AUDIT.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# BERTose/AFFINose Training Code Release Audit
|
| 2 |
+
|
| 3 |
+
Date: 2026-05-16
|
| 4 |
+
|
| 5 |
+
## Current Hugging Face State
|
| 6 |
+
|
| 7 |
+
Fresh no-token readback showed the public inference/model repositories are open, but they do not currently contain training-code entrypoints.
|
| 8 |
+
|
| 9 |
+
| Repository | Public | Gated | Snapshot | Training code present |
|
| 10 |
+
|---|---:|---:|---|---:|
|
| 11 |
+
| `supanthadey1/bertose-glycan-encoder` | yes | no | `c9a7401348ea` | no |
|
| 12 |
+
| `supanthadey1/bertose-iar-resolver` | yes | no | `5f33f647b403` | no |
|
| 13 |
+
| `supanthadey1/affinose-interaction-model` | yes | no | `44fcadaa1e4d` | no |
|
| 14 |
+
| `supanthadey1/bertose-affinose-inference` | yes | no | `9729ebda91a9` | no |
|
| 15 |
+
|
| 16 |
+
Conclusion: the current Hugging Face release is good for inference, but the training/reproducibility code still needs a dedicated public code release.
|
| 17 |
+
|
| 18 |
+
## Local Release Candidate
|
| 19 |
+
|
| 20 |
+
Staged Hugging Face folder:
|
| 21 |
+
|
| 22 |
+
```text
|
| 23 |
+
huggingface_release/bertose-affinose-training-code/
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
Source bundle repaired from:
|
| 27 |
+
|
| 28 |
+
```text
|
| 29 |
+
submission/nature_methods_ultimate_submission_20260505/05_data_and_code_release/Supplementary_Software_1_Bertose_code_release/
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
Rebuilt supplementary zip:
|
| 33 |
+
|
| 34 |
+
```text
|
| 35 |
+
submission/nature_methods_ultimate_submission_20260505/05_data_and_code_release/Supplementary_Software_1_Bertose_code_release.zip
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
Rebuilt zip SHA-256:
|
| 39 |
+
|
| 40 |
+
```text
|
| 41 |
+
03a9f7d0be5018632ab59daa1250424fab8f0f399c138c257dc8e5e919b
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
## Reproducibility Gap Fixed
|
| 45 |
+
|
| 46 |
+
The original code release had a concrete import gap:
|
| 47 |
+
|
| 48 |
+
```python
|
| 49 |
+
from training.multimodal_dataset import MultimodalGlycanDataset, create_multimodal_dataloaders
|
| 50 |
+
from training.multimodal_masking import MultimodalMaskingStrategy
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
`train_multimodal.py` imported those modules, but they were missing from the release package.
|
| 54 |
+
|
| 55 |
+
Added files:
|
| 56 |
+
|
| 57 |
+
- `code/training/multimodal_dataset.py`
|
| 58 |
+
- `code/training/multimodal_masking.py`
|
| 59 |
+
- `code/training/masking.py`
|
| 60 |
+
- `code/training/__init__.py`
|
| 61 |
+
|
| 62 |
+
Related package-local utility gaps were also repaired:
|
| 63 |
+
|
| 64 |
+
- `code/model/tokenizer.py`
|
| 65 |
+
- `code/model/wurcs_bpe_tokenizer.py`
|
| 66 |
+
- `code/downstream_tasks/__init__.py`
|
| 67 |
+
- `code/downstream_tasks/utils/__init__.py`
|
| 68 |
+
- `code/downstream_tasks/utils/tokenizer.py`
|
| 69 |
+
- `code/downstream_tasks/utils/wurcs_bpe_tokenizer.py`
|
| 70 |
+
- `code/downstream_tasks/utils/dataset.py`
|
| 71 |
+
- `code/downstream_tasks/utils/baseline_results.py`
|
| 72 |
+
|
| 73 |
+
`code/training/train_wurcs_bpe.py` was updated so it imports the BPE tokenizer from the package-local release tree instead of an old nested development path.
|
| 74 |
+
|
| 75 |
+
## Current Verification
|
| 76 |
+
|
| 77 |
+
The following checks passed on the repaired local package:
|
| 78 |
+
|
| 79 |
+
- Static first-party import resolution over `code/**/*.py`.
|
| 80 |
+
- Runtime import check for the repaired BERTose training, tokenizer, model dataset, and downstream utility modules.
|
| 81 |
+
- Runtime import check from a fresh unzip of the rebuilt supplementary zip.
|
| 82 |
+
- No `__pycache__` or `.pyc` files in the repaired folder or zip.
|
| 83 |
+
- No Hugging Face token-looking strings found in the repaired folder.
|
| 84 |
+
|
| 85 |
+
These checks prove import completeness for the repaired surface. They do not prove a full end-to-end retraining run.
|
| 86 |
+
|
| 87 |
+
## Remaining Gaps Before Public Upload
|
| 88 |
+
|
| 89 |
+
1. Finalize license and citation text. Current metadata still uses `other`.
|
| 90 |
+
2. Decide whether to keep historical executable names such as `code/bertint/` and versioned script names. The current recommendation is to keep them for provenance, but explain in README that the public workflow name is AFFINose.
|
| 91 |
+
3. Decide whether to include only the browsable unzipped code tree, or both the unzipped tree and the repaired supplementary zip. The current staging folder uses the browsable tree.
|
| 92 |
+
4. Add links to the public model repos and, once available, the public GlyCorpus/data release location for large training artifacts not bundled here.
|
| 93 |
+
5. Treat cluster scripts under `provenance/compute_provenance/` as provenance records. They still contain original Nova paths by design and are not portable launchers.
|
| 94 |
+
6. Optionally add a small synthetic-data smoke test for `train_multimodal.py` so public users can validate the training loop without the multi-GB corpus.
|
| 95 |
+
7. Perform one final token/path scan over the exact staging folder immediately before upload.
|
| 96 |
+
|
| 97 |
+
## Recommended Hugging Face Shipping Plan
|
| 98 |
+
|
| 99 |
+
Recommended repository:
|
| 100 |
+
|
| 101 |
+
```text
|
| 102 |
+
supanthadey1/bertose-affinose-training-code
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
Recommended type:
|
| 106 |
+
|
| 107 |
+
```text
|
| 108 |
+
model
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
Reason: this keeps the code companion beside the existing model repositories and gives the repo a normal Hugging Face model-card README.
|
| 112 |
+
|
| 113 |
+
Upload sequence:
|
| 114 |
+
|
| 115 |
+
```bash
|
| 116 |
+
cd /Users/supantha/Documents/esm-g/plan/bert_training_v3/v3.1_cluster_training/writing
|
| 117 |
+
HF_TOKEN="$(tr -d '\n\r' < huggingface_release/.secrets/hf_token)" \
|
| 118 |
+
hf repos create supanthadey1/bertose-affinose-training-code --type model --exist-ok
|
| 119 |
+
|
| 120 |
+
HF_TOKEN="$(tr -d '\n\r' < huggingface_release/.secrets/hf_token)" \
|
| 121 |
+
hf upload supanthadey1/bertose-affinose-training-code \
|
| 122 |
+
huggingface_release/bertose-affinose-training-code . \
|
| 123 |
+
--repo-type model \
|
| 124 |
+
--commit-message "Add BERTose and AFFINose training code release"
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
Post-upload verification:
|
| 128 |
+
|
| 129 |
+
```bash
|
| 130 |
+
python - <<'PY'
|
| 131 |
+
from huggingface_hub import HfApi
|
| 132 |
+
|
| 133 |
+
repo = "supanthadey1/bertose-affinose-training-code"
|
| 134 |
+
info = HfApi(token=False).model_info(repo, token=False, files_metadata=True)
|
| 135 |
+
print(info.private, info.gated, info.sha)
|
| 136 |
+
print(len(info.siblings))
|
| 137 |
+
PY
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
Only announce the repo as public after that no-token readback confirms `private=False` and `gated=False`.
|
SHA256SUMS
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
a7d0dbe3088ee22e14bd9e1d1ddf56ba4efbb7c8a79ae59e309787ba604e5a09 ./.gitattributes
|
| 2 |
+
9e9e9ad1f10cab0d0a097793f40c1d44344c0ecdb9b570143f83c78eb18038c3 ./MANIFEST.md
|
| 3 |
+
e45fd93ea2071a6240d989b840d623717c09aceab1702769285240fc31a4940f ./README.md
|
| 4 |
+
9de200fdb9a345511685a0d33e12fdedf401e55cc2611063f65251de7a9947bd ./RELEASE_AUDIT.md
|
| 5 |
+
7286c5016e3b03dda7b49d852bcea37722b3ba9028f3715af35c708bab3e1051 ./code/affinose/README.md
|
| 6 |
+
bdeef1f7f1ef86f10e36fb75105d5a298a6d0af76041adab60605430cc6377d0 ./code/benchmarks/calculate_rankings.py
|
| 7 |
+
07e21e7ec893453bf60a805abda0f177592d10dbf7af28dd609c763f35c68d4d ./code/benchmarks/consolidate_results.py
|
| 8 |
+
0a89c7023819b257667503fca21413edd363a1a46130c8f244fd445c6e4b3bc5 ./code/benchmarks/create_excluded_dataset.py
|
| 9 |
+
733b59a2e30cc79f2a80325fec89c300d634fd385f784e03e229dabc56679223 ./code/benchmarks/extract_all_benchmark_results.py
|
| 10 |
+
77a6da4fdf3bb64fa03a497d66fafbcc960a274143afab8e6c8cdaec7910330e ./code/benchmarks/finetune.py
|
| 11 |
+
ded1b4465589277696b82a8c07793ecf89df22ea55660dfc19929b71df21e8b2 ./code/bertint/bertint_v8.py
|
| 12 |
+
fb71ce05800afc92ec2e5340d48e78db7d683529c3f0448e401f5c90bca42b3b ./code/bertint/bertint_v8_inference.py
|
| 13 |
+
9d9bd389ffc1080254df2f824b6cd63dc0e58f8b31988193cb270ff6909a3da2 ./code/bertint/build_combined_dataset.py
|
| 14 |
+
d0932c17c7a3fd3e794e26945e673aca258a74388ec27d5935ba8b73209c6616 ./code/bertint/compare_ablation.py
|
| 15 |
+
2a6a2a6c587959c27e4ee3ea03d49fd26b54ed271c6e58e42b8a3eb4fe9d53c9 ./code/bertint/dataset_v8.py
|
| 16 |
+
76af1c59ca8f2bee013f159627cc0f1c5d8c7d02ef7963d0581e9a0d0d5a7886 ./code/bertint/generate_glycan_splits.py
|
| 17 |
+
e2f07603c56c1a86c3cdeae38709143153659a82669bcd3cf939934660cc4c70 ./code/bertint/training_v8.py
|
| 18 |
+
8847ce63f55a7abdf804b6f59047adfc46a742ae094ecc91cce270a77813ffe7 ./code/contrastive/contrastive_trainer_v51_FINAL.py
|
| 19 |
+
8615ad2346698d8a88c68f7fc0fe1e6befe275e75e38a3fb435f90f5519771f8 ./code/contrastive/contrastive_trainer_v5_SIMPLE.py
|
| 20 |
+
4a09eeb3da4bdafe2d0b9cc41119c9a2a6ef0313b8ae018f18b4300023f61661 ./code/contrastive/extract_resolved_glycans.py
|
| 21 |
+
21c9a3cd2e806aadacc0b7f1a0c3372d21a9d3dd6d2dbfcf7a701fa6855979d5 ./code/contrastive/generate_negatives_v4_FINAL.py
|
| 22 |
+
3fc781ab69ef520f84fcc8d903feed1ba26e63c1b568b57739b659e5745cf071 ./code/contrastive/mcns_filter_75k_v3_FINAL.py
|
| 23 |
+
da2e46dde0650c184a2cef12ca7384b86939481200211493e73a751700a783e8 ./code/contrastive/score_negative_difficulty.py
|
| 24 |
+
effcd06f210a78de9102c30b1d523e9720d6db396b2d9c020fee25c81641b9ce ./code/contrastive_training/step3_train_v51_FIXED_FINAL.sh
|
| 25 |
+
d2d03599259773809ee98eb23104a12bade74efd40c371b19ee40c828c6c6e65 ./code/data_processing/augment_pretraining_data.py
|
| 26 |
+
40c97be90a30b18fd489fcf3ab7f314d39afae06096ef72311f224d98b9ca23b ./code/data_processing/convert_iupac_glycosmos.py
|
| 27 |
+
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ./code/downstream_tasks/__init__.py
|
| 28 |
+
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ./code/downstream_tasks/utils/__init__.py
|
| 29 |
+
3c281211bcf706200ce2614e6810300405db11f0e81bf07ffb6a1d460e9b0b16 ./code/downstream_tasks/utils/baseline_results.py
|
| 30 |
+
d6ce62d2b163e446abf9e1df2cdd6268012063d7667f9198083c805b1f4be35b ./code/downstream_tasks/utils/dataset.py
|
| 31 |
+
d8135231ff7dc8b9754540bf8c9f37ef02fbb57560e07f8585fc445c6ae4fb22 ./code/downstream_tasks/utils/tokenizer.py
|
| 32 |
+
0bc54399362945601bcfd403441fc80968d173200dd0561f57568b2053a94839 ./code/downstream_tasks/utils/wurcs_bpe_tokenizer.py
|
| 33 |
+
de8c3237b858b640ee9fbf4dac34864c54b6dac80bc89f346a44c774254289a4 ./code/model/__init__.py
|
| 34 |
+
b50d50d6ae50d520e6834563892bfc00fc11c5bb1322a84abf84ae4d97c895dd ./code/model/dataset.py
|
| 35 |
+
1c141c552efd6e0f58da70bd06290d352ad2a23bd3a62246a9f0bf77e58b8a11 ./code/model/enhanced_classifier.py
|
| 36 |
+
b69f14c9976951325e3a0a4e8107a16126e67d410e966650f513f1f538a732bb ./code/model/glycan_bert.py
|
| 37 |
+
91e5e235d04c332251ccb85969396746ab68f1ebbc50c4f6611b86cd8d1e9de9 ./code/model/hierarchical_glycan.py
|
| 38 |
+
8e06c32f4e844c3c66f146fd15a075472808a0720c892d0b9683dfd32f15ef0c ./code/model/multimodal_glycan_bert_v3.py
|
| 39 |
+
d8135231ff7dc8b9754540bf8c9f37ef02fbb57560e07f8585fc445c6ae4fb22 ./code/model/tokenizer.py
|
| 40 |
+
0bc54399362945601bcfd403441fc80968d173200dd0561f57568b2053a94839 ./code/model/wurcs_bpe_tokenizer.py
|
| 41 |
+
1746be26e253607c316339f4f12ccca2382114018a2034e79ee307b29bcebebd ./code/probes/analyze_core_biology.py
|
| 42 |
+
fe3eaa0abe0d505d4612a213ffcede4702ef6f57d674303e273f8f555d6f68e7 ./code/probes/analyze_embeddings.py
|
| 43 |
+
20ec31c3f98eeb364be6c807749799221def9a1ac08db4a53984bcb436f98d41 ./code/probes/cluster_scripts/run_benchmark_embeddings.sh
|
| 44 |
+
9f43159886b13baf03090b90f3d8cfc8559a75bf43c0e4d39a01912104b57e7f ./code/probes/cluster_scripts/run_diag_zero.sh
|
| 45 |
+
5511e16507a3b4dadf2450b1b7befd4d49dd343f1f18501e54ae0797a55cddfd ./code/probes/cluster_scripts/run_embedding_analysis.sh
|
| 46 |
+
586f7b1c86bf1744b4518ffab8f97b9a4772d1ce05c618c4e15dbe1fb5c07e3c ./code/probes/cluster_scripts/run_explore_bio.sh
|
| 47 |
+
b8be7a2ecb16a7a6564ffc6e076f4a90ccd726e007dbba6a26629600b62f6219 ./code/probes/cluster_scripts/run_novel_probes.sh
|
| 48 |
+
bca6d50202b4cd5172c4d08ed657cf68d9db66d17d52d2bdd3562e792a3d65a3 ./code/probes/cluster_scripts/run_novel_probes_v2.sh
|
| 49 |
+
f9bfd3aca393cc814e504ec5d3f0567d887988f036c9b93a4c42d4377a8e9c9e ./code/probes/cluster_scripts/run_novel_probes_v2_full.sh
|
| 50 |
+
bb7f95112706f70f55ab02803b36160301554889dabe2c42df1b23e562d17c70 ./code/probes/cluster_scripts/run_probe10_lectin.sh
|
| 51 |
+
6f958951b3b9c7a38e78b48b91883dd54f392fbd5d2a8ed1d8e47ce7e5bf32b5 ./code/probes/cluster_scripts/run_probe10_v2.sh
|
| 52 |
+
07f769ffe725740ef03e93bd422372427b7ad4822c0159f5d5979746c7cf2999 ./code/probes/cluster_scripts/run_probe14.sh
|
| 53 |
+
3c8cfbe889fc9a18056fb31b360fd8a65102c35636cb8b5f673f623321f1bb75 ./code/probes/cluster_scripts/run_probe15.sh
|
| 54 |
+
e0889a94fd1ddec0ebf3960f3cfc463e8c1b6c4259a10923506f174ba225ec85 ./code/probes/cluster_scripts/run_probe15c2.sh
|
| 55 |
+
a0b090992e2a52954c1d353367aaaadfead6c7f01087fc9fa656985f7f6fb62a ./code/probes/cluster_scripts/run_probe15d.sh
|
| 56 |
+
104ba71c0d0a874313449d5c9778d06d5062b0c530e153329262860565102c5b ./code/probes/cluster_scripts/run_probe15h.sh
|
| 57 |
+
daeced8da52e9802b72d1d8590acd074667c04bbf0dd4fc0aa2fae681766f5cd ./code/probes/cluster_scripts/run_probe2_layerwise.sh
|
| 58 |
+
0429b6669df38c0942a50265112036db5a6e8d7662476606df4c8cd6715c6afd ./code/probes/cluster_scripts/run_probe3_4.sh
|
| 59 |
+
f369238fff347a0d2bfd5dd2308f85fcb778d558a3ef3cd535073f3726c485b4 ./code/probes/cluster_scripts/run_probe4_filtered.sh
|
| 60 |
+
c9e145766037a636aaacfde275783aaef07c53d5b542a82202854ef770e90727 ./code/probes/cluster_scripts/run_probe8.sh
|
| 61 |
+
a26edb399226db8e5f12c8ffba7909befefc5385f21702cccb4d9a458e2e9ace ./code/probes/cluster_scripts/run_probe9_biosynthesis.sh
|
| 62 |
+
de009efa3c191da5d1c0f4372fe4988b255ea0fb177e5b0aeca4e6f39c9184f4 ./code/probes/cluster_scripts/run_probes_expanded_resolved_v5.sh
|
| 63 |
+
072be6b54439152825e15f48070beaa061d852d79128a1f1444c080157b1fd52 ./code/probes/cluster_scripts/run_probes_expanded_resolved_v6.sh
|
| 64 |
+
6e4af060737706f29a3524db7e67b8d0964c62a9b4b90cb629b1d294cb4347e0 ./code/probes/cluster_scripts/run_probes_expanded_v5.sh
|
| 65 |
+
882ef75b29f7b979b99f74df8c8fc2d9a889f0bfdce1c46ecc3532a3cf89efd5 ./code/probes/cluster_scripts/run_probes_expanded_v6.sh
|
| 66 |
+
c454aefad7b6d39070c2b83b0c988d9388e5d938d690f39db5485e00dc587226 ./code/probes/cluster_scripts/run_probes_glyco_immuno.sh
|
| 67 |
+
10f8f313a9d80e2eac9f89534298f040dd809cd71023f5db66144570f2665849 ./code/probes/cluster_scripts/run_probes_resolved_v5.sh
|
| 68 |
+
1f4efc28840a2a0901d9c861bcf7f6edd6c507d6f53c5272d6282728874d3f41 ./code/probes/cluster_scripts/run_probes_resolved_v6.sh
|
| 69 |
+
e31d4ba5fac34a4bc6ada48d129f9d301a6ea0e24b7f2da6383c0007cad7e3ed ./code/probes/cluster_scripts/run_probes_v5_full_cached.sh
|
| 70 |
+
c4134ae3d7189087fb3f047957443713a932ceda885465747f6d62b8057bd934 ./code/probes/cluster_scripts/run_probes_v5_full_external.sh
|
| 71 |
+
3d8f7046b56584c0dc7c44cdee25e324a23175f0fb744735f3796a2ef79b1784 ./code/probes/cluster_scripts/run_probes_v5_res_cached.sh
|
| 72 |
+
3c24e6d7a5a0657b884bb9658c87871a580ca8437080bd95a08e495bced4222b ./code/probes/cluster_scripts/run_probes_v5_res_external.sh
|
| 73 |
+
17faa873c11dd5939737305502c9f0b247e20383be02dc7cdc1d2eacd63dae52 ./code/probes/cluster_scripts/run_probes_v6_full_cached.sh
|
| 74 |
+
960792a133f0e832063f196b6b882a0a974e2eb2a9fa3c33233db07ab851bde5 ./code/probes/cluster_scripts/run_probes_v6_full_external.sh
|
| 75 |
+
5cced03d48008b8d7b66cf9138d59a97fd387dbf8c9b0a906fece046cd4aa050 ./code/probes/cluster_scripts/run_probes_v6_res_cached.sh
|
| 76 |
+
16dc0edb0076db41a6db44bd85c06ebeae98c77d406bb6e38f7e64ba2bc903fa ./code/probes/cluster_scripts/run_probes_v6_res_external.sh
|
| 77 |
+
43b433149b8d30bcaee818abff95a9ab0047d15c11a55944a3638ff3d97b60ac ./code/probes/cluster_scripts/run_tier2_biology.sh
|
| 78 |
+
2182320665a2021d7180fdf0ba6bb131d8592777d312d6cc9b59421de33fbf5e ./code/probes/cluster_scripts/run_tsne_figure.sh
|
| 79 |
+
08300f1cffed393016bae0fc9e59be5e9d987eaaee244c452427babca6e2c3a8 ./code/probes/cluster_scripts/run_v6_training.sh
|
| 80 |
+
6e51b94aa6bf4960fe12e362e29101f3f143104e1eaed7016376d486be83250d ./code/probes/embed_benchmark_tasks.py
|
| 81 |
+
95ebff8cbd5e6028cfb8a668361d0bcc236c36da42995b0f13afa150993b0548 ./code/probes/extract_embeddings.py
|
| 82 |
+
cec43a83d95ed25b29d365c11156a653e72c6775329e8e4070ecf2e2a3bdb1c2 ./code/probes/novel_probes_v2.py
|
| 83 |
+
30c74d07267f34a077c663d844764ccf07eac28becab727b9da1dd952a737647 ./code/probes/probe_01_tsne_clustering.py
|
| 84 |
+
1d39a5e442b0d1327c6b1a48c952c08457988afd46f5a5d7828a61a72a340851 ./code/probes/probe_10_lectin_motifs.py
|
| 85 |
+
4dcd35b2acaec555a32df5a6e1ff70504240facb9b8759e48e16050f77c73f3c ./code/probes/probe_10_tissue_presence.py
|
| 86 |
+
dd868142857074cd9bf638c97566e43423186fe328c8300dd4ae1cb74bf0247d ./code/probes/probe_14_retrieval.py
|
| 87 |
+
06fca4190c5b8622093cc48ed3c6bd10df70576273aa7f3879a3c09261a03990 ./code/probes/probe_15_seal_milk_ood.py
|
| 88 |
+
ebaa15252c3d3ac28a288ff7b78493134a49260398bb7b18ee3a351be06c1f4f ./code/probes/probe_15h_phylogenetic.py
|
| 89 |
+
4bb6ac7f62d7ab3f2d6313dc8437e947f0231c984d23b324688b9760113f1a69 ./code/probes/probe_2_layerwise_cls.py
|
| 90 |
+
a4f259e8d00daf702b0b4ab32dc6e2cb3bf99b4c46f2c028e12b0c857b70e424 ./code/probes/probe_3_4_attention_structure.py
|
| 91 |
+
831800db52a0373a98534636c8fd3aced8719ac4dcd86160bccd3a66093d2caf ./code/probes/probe_7_contrastive_ablation.py
|
| 92 |
+
76b53cb05c2350de8871293684006ce389bcbfde5891d8d34233bad40513320c ./code/probes/probe_8_disease_tissue.py
|
| 93 |
+
0b184b387f1ec95623a84dfc1ecd43e816c47608bc23311f1b070e9c06cc3641 ./code/probes/probe_9_biosynthesis_order.py
|
| 94 |
+
7d9edcfe426301f78a0c7a80c51688b19b01776b31f950d7250cea60215f511f ./code/probes/probe_exploratory_biology.py
|
| 95 |
+
6e63f181f22987e3c6f926990a2ff1892cee08ca5709d1a6925d8f942117ee69 ./code/tokenizer/generate_bpe_ambiguity.py
|
| 96 |
+
0d967082a8225a1eab98315c2fe7916eff0e863ec04287a43351b757e08f5070 ./code/training/__init__.py
|
| 97 |
+
09014d0945b12f3ef4dc4656cade1e4cd55ede7e3d816c7106ce0a2c45c0a49a ./code/training/analyze_confidence.py
|
| 98 |
+
f0c8efda7a12942b4c6359792d6ee9417e6a9ec0eb04623ad17b4772430270b5 ./code/training/ipa_bpe_distillation.py
|
| 99 |
+
bbb9e5b156591285a2e2a052261d9215771659cb082dd802a39fed00958ad1fd ./code/training/masking.py
|
| 100 |
+
54faea0a20d16e20da46775d0562eb2a9b8d7979ee6c43c0f26b4d326d24d5fc ./code/training/multimodal_dataset.py
|
| 101 |
+
ce660a7a7dccb88e52fefbc87edd36c8e0ca247fbe896aeaa22079dc4c475970 ./code/training/multimodal_masking.py
|
| 102 |
+
3bd7ecacf93b5e5d9d40a8ad9d2a0e8377ab05f3d2ad6b64a910fc904be5e20c ./code/training/train_multimodal.py
|
| 103 |
+
848d001b28c35151b91d6cc986e595f71c4042da6d1c7238bc67fa64258b829e ./code/training/train_wurcs_bpe.py
|
| 104 |
+
eff37151a2bfdc196d93f854c82262bd9a65ec03760c0a4e75a5355e0e2a7e52 ./configs/multimodal_config_v3_ORIGINAL.yaml
|
| 105 |
+
84f6c73262fad6d2fdb2d5e5bca0f7fcd1ee8b2d98c4d83c3f0731bc813d3e51 ./configs/multimodal_config_v4_bpe.yaml
|
| 106 |
+
6f83ac0fb29df3b579763a79350b3ed762b4dcd0dfc1b36ee41350fcd189b1d8 ./configs/multimodal_config_v5a_bpe_topo.yaml
|
| 107 |
+
7f6a405d725e7a67877360bacdf3d6bc97d0fbdbbee315b4d84c9dc62fba2a22 ./configs/multimodal_config_v5b_excluded.yaml
|
| 108 |
+
0d4e950f592fefd8a03e7d42ab037f751c94de9f5503ff1391ec1b2c878e9163 ./data/splits/complete_exclusion_list.txt
|
| 109 |
+
37decf0099e54c883fd4d05a8298ee09547b9ccc0060a1314a9f55fae64258af ./data/splits/train_val_split.json
|
| 110 |
+
417fc41a53bd9849d686b5bb7ac2c5ffcdb73f793f97d156c7f0f214e438af80 ./data/vocab/ambiguity_tokens_CORRECTED.json
|
| 111 |
+
c68cd003370b2dcdb162f848f766e4e62f2653c6c38d205f8cbe53a9aabe2d74 ./data/vocab/bpe_ambiguity_tokens.json
|
| 112 |
+
6a572afdf53f1494ab96c896876b824ca7ea749777352606aa9f96bf270ceecc ./data/vocab/bpe_vocabulary.json
|
| 113 |
+
6a572afdf53f1494ab96c896876b824ca7ea749777352606aa9f96bf270ceecc ./data/vocab/bpe_vocabulary_clean.json
|
| 114 |
+
044030f212648a98fc477352328651c284d185660d9a2a7116f92f73f97e1ae5 ./data/vocab/confidence_analysis.json
|
| 115 |
+
ff7bb6a3de636dbee79846ef8083bdab004e9b79cd60715fbe1474d482e119c8 ./data/vocab/ms_vocabulary.json
|
| 116 |
+
f315117a51a49e4019812367e72423620c007c9c73bf6e19f357fe4ca395de3a ./data/vocab/vocabulary.json
|
| 117 |
+
736a696448cc1c4dd8f10ae41ba5549c8998e919663fec4590742369d5ca399a ./provenance/CORPUS_PIPELINE_AND_TOKENIZER_PROVENANCE_2026-04-19.md
|
| 118 |
+
7c735e74d72b0e9b14e340a71011e0f11ad79e7a375857546a9d930dc96828ec ./provenance/SOURCE_LOG.md
|
| 119 |
+
b4b19a7d28f543f36bbca2b3ab5444bdafc01e0b173c57faccc79f8bf3e78d2e ./provenance/compute_provenance/CLUSTER_VERIFIED_MODEL_LINEAGE_20260419.md
|
| 120 |
+
6268604d64f62f4e9564f36760fd4c4dc7fa834470cf369b8ee224b153591550 ./provenance/compute_provenance/COMPUTE_PROVENANCE_LOG.md
|
| 121 |
+
445408075a619e10f1511cbf13d6540f020fe5a238a11f05e83e34adeecea096 ./provenance/compute_provenance/EXA_SCALABILITY_MAIN_TEXT_FILL_20260419.md
|
| 122 |
+
39ab3e327829a873b412baf1df3f5b9713d9923e7ad644d7097677adc2493ec1 ./provenance/compute_provenance/compute_runtime_summary.csv
|
| 123 |
+
5ac925a6e79d2dd3f7c81397ded1b60cab2a251836e74e86e1605f8da1425de7 ./provenance/compute_provenance/compute_runtime_summary.json
|
| 124 |
+
732dc9ee6174342fefcec73bdf7f812e3abac8b71ecb24633672c3e355d131f1 ./provenance/compute_provenance/launch_script_summary.csv
|
| 125 |
+
ddc06ca59280bc75410aefa73816e9a6809e7720a6ec3b43198fa99c503d88c1 ./provenance/compute_provenance/multimodal_config_v5b_cluster_copy.yaml
|
| 126 |
+
04d9c188cbd8a2a7bf68349967a0472f33a5f9ee37c7de1f867eec5120979bcd ./provenance/compute_provenance/run_train_v5b_cluster_verified_copy_20260201.sh
|
| 127 |
+
59adc26c35d55c259411d5bd49177bfce0f4269d96184d5c4dbb1f334fc54bf2 ./provenance/compute_provenance/step3_contrastive_train_cluster_verified_copy_20260130.sh
|
| 128 |
+
21260131d5625b086b3ddd73a701296378dd38ee5c1b383c602ef207bdea9889 ./requirements.txt
|
code/affinose/README.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AFFINose Interaction Training
|
| 2 |
+
|
| 3 |
+
This folder is the public entrypoint for AFFINose training and data preparation.
|
| 4 |
+
|
| 5 |
+
The executable source files are currently preserved in the historical interaction-training directory:
|
| 6 |
+
|
| 7 |
+
```text
|
| 8 |
+
../bertint/
|
| 9 |
+
```
|
| 10 |
+
|
| 11 |
+
Use these files for the AFFINose workflow:
|
| 12 |
+
|
| 13 |
+
| Step | File |
|
| 14 |
+
|---|---|
|
| 15 |
+
| Build combined protein-glycan binding data | `../bertint/build_combined_dataset.py` |
|
| 16 |
+
| Generate glycan-disjoint splits | `../bertint/generate_glycan_splits.py` |
|
| 17 |
+
| Define the AFFINose model | `../bertint/bertint_v8.py` |
|
| 18 |
+
| Define the AFFINose dataset | `../bertint/dataset_v8.py` |
|
| 19 |
+
| Train AFFINose | `../bertint/training_v8.py` |
|
| 20 |
+
| Run AFFINose inference from a checkpoint | `../bertint/bertint_v8_inference.py` |
|
| 21 |
+
| Compare ablations | `../bertint/compare_ablation.py` |
|
| 22 |
+
|
| 23 |
+
The historical filenames are retained to keep the code traceable to the original training logs and manuscript provenance. In public-facing text, refer to this workflow as AFFINose.
|
code/benchmarks/calculate_rankings.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import numpy as np
|
| 3 |
+
|
| 4 |
+
TASKS = ['domain', 'kingdom', 'phylum', 'class', 'order', 'family', 'genus', 'species', 'immunogenicity', 'link']
|
| 5 |
+
|
| 6 |
+
for dataset in ['strict', 'strict_3']:
|
| 7 |
+
print(f"\n{'='*80}")
|
| 8 |
+
print(f"DATASET: {dataset.upper()}")
|
| 9 |
+
print(f"{'='*80}")
|
| 10 |
+
|
| 11 |
+
df = pd.read_csv(f'benchmark_comparison_{dataset}.csv', index_col=0)
|
| 12 |
+
|
| 13 |
+
# Calculate ranks per task (higher accuracy = rank 1)
|
| 14 |
+
ranks = df.rank(ascending=False, method='min')
|
| 15 |
+
|
| 16 |
+
# Calculate mean rank
|
| 17 |
+
mean_ranks = ranks.mean(axis=1).sort_values()
|
| 18 |
+
|
| 19 |
+
# Display sorted by accuracy per task
|
| 20 |
+
print("\n📊 ACCURACY BY TASK (sorted by mean rank):")
|
| 21 |
+
print("-"*100)
|
| 22 |
+
|
| 23 |
+
# Sort by mean rank
|
| 24 |
+
df['Mean_Rank'] = mean_ranks
|
| 25 |
+
df_sorted = df.sort_values('Mean_Rank')
|
| 26 |
+
|
| 27 |
+
# Format nicely
|
| 28 |
+
for col in df_sorted.columns:
|
| 29 |
+
if col != 'Mean_Rank':
|
| 30 |
+
df_sorted[col] = df_sorted[col].apply(lambda x: f"{x*100:.1f}%" if pd.notnull(x) else "-")
|
| 31 |
+
else:
|
| 32 |
+
df_sorted[col] = df_sorted[col].apply(lambda x: f"{x:.2f}")
|
| 33 |
+
|
| 34 |
+
print(df_sorted.to_string())
|
| 35 |
+
|
| 36 |
+
print("\n\n🏆 RANKINGS BY TASK:")
|
| 37 |
+
print("-"*100)
|
| 38 |
+
for task in TASKS:
|
| 39 |
+
if task in df.columns:
|
| 40 |
+
task_ranks = df[task].sort_values(ascending=False)
|
| 41 |
+
print(f"\n{task.upper():15} | ", end="")
|
| 42 |
+
for i, (model, acc) in enumerate(task_ranks.items(), 1):
|
| 43 |
+
if isinstance(acc, str):
|
| 44 |
+
acc_val = float(acc.replace('%', ''))/100
|
| 45 |
+
else:
|
| 46 |
+
acc_val = acc
|
| 47 |
+
print(f"#{i}: {model}({acc_val*100:.1f}%) ", end="")
|
| 48 |
+
|
| 49 |
+
print("\n\n\n📈 MEAN RANK SUMMARY:")
|
| 50 |
+
print("-"*50)
|
| 51 |
+
for model, rank in mean_ranks.items():
|
| 52 |
+
print(f" {model:20} : {rank:.2f}")
|
| 53 |
+
|
| 54 |
+
# Determine winner
|
| 55 |
+
winner = mean_ranks.idxmin()
|
| 56 |
+
print(f"\n🥇 BEST MODEL (lowest mean rank): {winner} ({mean_ranks[winner]:.2f})")
|
| 57 |
+
|
code/benchmarks/consolidate_results.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import shutil
|
| 4 |
+
import re
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
import pandas as pd
|
| 7 |
+
|
| 8 |
+
BASE_DIR = Path("/work/ratul1/supantha/glycan-SD-VS/bert_training_v3/v3.1_cluster_training")
|
| 9 |
+
CONSOLIDATED_ROOT = BASE_DIR / "bert_training_v4" / "downstream_tasks" / "consolidated_results"
|
| 10 |
+
MODES = ["strict", "strict_3"]
|
| 11 |
+
TASKS = ["domain", "kingdom", "phylum", "class", "order", "family", "genus", "species", "immunogenicity", "link"]
|
| 12 |
+
|
| 13 |
+
def copy_file(src, dst):
|
| 14 |
+
if src.exists():
|
| 15 |
+
os.makedirs(dst.parent, exist_ok=True)
|
| 16 |
+
shutil.copy2(src, dst)
|
| 17 |
+
return True
|
| 18 |
+
return False
|
| 19 |
+
|
| 20 |
+
def save_json(data, path):
|
| 21 |
+
os.makedirs(path.parent, exist_ok=True)
|
| 22 |
+
with open(path, 'w') as f:
|
| 23 |
+
json.dump(data, f, indent=4)
|
| 24 |
+
|
| 25 |
+
def parse_gaa_log(log_path, task, mode):
|
| 26 |
+
try:
|
| 27 |
+
content = log_path.read_text()
|
| 28 |
+
except:
|
| 29 |
+
return None
|
| 30 |
+
metrics = {"status": "completed", "task": task, "mode": mode}
|
| 31 |
+
found = False
|
| 32 |
+
for line in content.splitlines():
|
| 33 |
+
match = re.search(r"(\w+)\s*\[([\w-]+)\]:\s*([\d\.\-]+)", line)
|
| 34 |
+
if match:
|
| 35 |
+
m_name, m_task, m_val = match.groups()
|
| 36 |
+
if m_task == task or m_task == "average" or m_task == task.replace("_", "-"):
|
| 37 |
+
try: metrics[m_name.lower()] = float(m_val); found = True
|
| 38 |
+
except: pass
|
| 39 |
+
if "macrof1" in metrics: metrics["test_f1_macro"] = metrics["macrof1"]
|
| 40 |
+
if "auprc" in metrics: metrics["test_auprc"] = metrics["auprc"]
|
| 41 |
+
return metrics if found else None
|
| 42 |
+
|
| 43 |
+
def main():
|
| 44 |
+
print(f"Starting consolidation in: {CONSOLIDATED_ROOT}")
|
| 45 |
+
for mode in MODES:
|
| 46 |
+
print(f"\nProcessing mode: {mode}")
|
| 47 |
+
|
| 48 |
+
# 1. OURS
|
| 49 |
+
ours_src_root = BASE_DIR / "bert_training_v4" / "downstream_tasks" / "results_v4_BERT_topology_FINAL"
|
| 50 |
+
for task in TASKS:
|
| 51 |
+
src = ours_src_root / f"{task}_{mode}" / "results.json"
|
| 52 |
+
dst = CONSOLIDATED_ROOT / mode / "ours" / f"{task}.json"
|
| 53 |
+
if not copy_file(src, dst):
|
| 54 |
+
src = ours_src_root / f"{task}" / "results.json"
|
| 55 |
+
copy_file(src, dst)
|
| 56 |
+
|
| 57 |
+
# 2. SweetTalk
|
| 58 |
+
st_root = BASE_DIR / "bert_training_v4" / "sweettalk"
|
| 59 |
+
st_dir = f"results_{mode}" if mode == "strict_3" else "results"
|
| 60 |
+
for task in TASKS:
|
| 61 |
+
src = st_root / st_dir / task / f"results_{task}.csv"
|
| 62 |
+
if src.exists():
|
| 63 |
+
df = pd.read_csv(src)
|
| 64 |
+
metrics = {"status": "completed", "task": task, "mode": mode}
|
| 65 |
+
for _, row in df.iterrows():
|
| 66 |
+
if "accuracy" in row: metrics["test_accuracy"] = row["accuracy"]
|
| 67 |
+
if "macro_f1" in row: metrics["test_f1_macro"] = row["macro_f1"]
|
| 68 |
+
save_json(metrics, CONSOLIDATED_ROOT / mode / "sweettalk" / f"{task}.json")
|
| 69 |
+
|
| 70 |
+
# 3. GlycanML (Corrected Path)
|
| 71 |
+
ml_base = BASE_DIR / "baseline_comparisons" / "results" / "glycanml_paper_batch256" / mode / "single_task" / "PropertyPrediction" / "GlycanClassification"
|
| 72 |
+
if ml_base.exists():
|
| 73 |
+
for model_dir in ml_base.iterdir():
|
| 74 |
+
if model_dir.is_dir():
|
| 75 |
+
model = model_dir.name
|
| 76 |
+
# Find any status.json in subfolders
|
| 77 |
+
for date_dir in model_dir.iterdir():
|
| 78 |
+
if date_dir.is_dir():
|
| 79 |
+
src = date_dir / "status.json"
|
| 80 |
+
if src.exists():
|
| 81 |
+
with open(src, 'r') as f:
|
| 82 |
+
data = json.load(f)
|
| 83 |
+
task = data.get("task", "").lower()
|
| 84 |
+
if task in TASKS:
|
| 85 |
+
dst = CONSOLIDATED_ROOT / mode / "glycanml" / model / f"{task}.json"
|
| 86 |
+
save_json(data, dst)
|
| 87 |
+
|
| 88 |
+
# 4. GlycanAA / GearNet
|
| 89 |
+
gaa_logs_dir = BASE_DIR / "bert_training_v4" / "GlycanAA" / "logs"
|
| 90 |
+
if gaa_logs_dir.exists():
|
| 91 |
+
for log_file in gaa_logs_dir.glob("*.out"):
|
| 92 |
+
name = log_file.stem
|
| 93 |
+
if mode in name:
|
| 94 |
+
parts = name.split("_")
|
| 95 |
+
if len(parts) >= 5:
|
| 96 |
+
idx = parts.index("3") + 1 if "3" in parts else parts.index("strict") + 1
|
| 97 |
+
model = parts[idx]
|
| 98 |
+
task = parts[-2]
|
| 99 |
+
if task in TASKS:
|
| 100 |
+
metrics = parse_gaa_log(log_file, task, mode)
|
| 101 |
+
if metrics:
|
| 102 |
+
save_json(metrics, CONSOLIDATED_ROOT / mode / "glycanaa" / model / f"{task}.json")
|
| 103 |
+
|
| 104 |
+
# 5. GIFFLAR
|
| 105 |
+
gif_root = BASE_DIR / "baseline_comparisons" / "results" / f"gifflar_{mode}"
|
| 106 |
+
if gif_root.exists():
|
| 107 |
+
for task_dir in gif_root.iterdir():
|
| 108 |
+
if task_dir.is_dir():
|
| 109 |
+
task = task_dir.name
|
| 110 |
+
src = task_dir / "results.json"
|
| 111 |
+
dst = CONSOLIDATED_ROOT / mode / "gifflar" / f"{task}.json"
|
| 112 |
+
copy_file(src, dst)
|
| 113 |
+
|
| 114 |
+
# 6. GlycanGT
|
| 115 |
+
gt_root = BASE_DIR / "bert_training_v4" / "GlycanGT" / "analysis" / "2_linear_probing"
|
| 116 |
+
if gt_root.exists():
|
| 117 |
+
for type_dir in gt_root.iterdir():
|
| 118 |
+
if type_dir.is_dir():
|
| 119 |
+
model_base = f"GlycanGT_{type_dir.name}"
|
| 120 |
+
for variant_dir in type_dir.iterdir():
|
| 121 |
+
if variant_dir.is_dir():
|
| 122 |
+
for csv_file in variant_dir.glob("*_results.csv"):
|
| 123 |
+
try:
|
| 124 |
+
df = pd.read_csv(csv_file)
|
| 125 |
+
for task in TASKS:
|
| 126 |
+
row = df[(df['Task'] == task) & (df['Classifier'] == 'SVM_HPO') & (df['Metric'] == 'Macro-F1')]
|
| 127 |
+
if not row.empty:
|
| 128 |
+
metrics = {"status": "completed", "task": task, "mode": mode, "test_f1_macro": float(row['Mean_Score'].iloc[0])}
|
| 129 |
+
save_json(metrics, CONSOLIDATED_ROOT / mode / "glycangt" / f"{model_base}_{variant_dir.name}" / f"{task}.json")
|
| 130 |
+
except: pass
|
| 131 |
+
|
| 132 |
+
print("\nConsolidation complete.")
|
| 133 |
+
|
| 134 |
+
if __name__ == "__main__":
|
| 135 |
+
main()
|
code/benchmarks/create_excluded_dataset.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Create V5-B training dataset by excluding leaked benchmark WURCS.
|
| 4 |
+
"""
|
| 5 |
+
import pickle
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
BASE = Path("/work/ratul1/supantha/glycan-SD-VS/bert_training_v3/v3.1_cluster_training")
|
| 9 |
+
|
| 10 |
+
# Load exclusion list
|
| 11 |
+
with open(BASE / "bench/data_leakage_analysis/leaked_wurcs_list.txt") as f:
|
| 12 |
+
excluded_wurcs = set(line.strip() for line in f if line.strip())
|
| 13 |
+
print(f"Loaded {len(excluded_wurcs)} WURCS to exclude")
|
| 14 |
+
|
| 15 |
+
# Load original sequences
|
| 16 |
+
with open(BASE / "data/sequences.pkl", "rb") as f:
|
| 17 |
+
sequences = pickle.load(f)
|
| 18 |
+
print(f"Original dataset: {len(sequences)} sequences")
|
| 19 |
+
|
| 20 |
+
# Filter - assuming sequences is a list of dicts with 'wurcs' key
|
| 21 |
+
filtered = [s for s in sequences if s.get('wurcs', s.get('WURCS', '')) not in excluded_wurcs]
|
| 22 |
+
print(f"After exclusion: {len(filtered)} sequences")
|
| 23 |
+
print(f"Removed: {len(sequences) - len(filtered)} sequences")
|
| 24 |
+
|
| 25 |
+
# Save
|
| 26 |
+
output_path = BASE / "bert_v5b_excluded/data/sequences_excluded.pkl"
|
| 27 |
+
with open(output_path, "wb") as f:
|
| 28 |
+
pickle.dump(filtered, f)
|
| 29 |
+
print(f"Saved to {output_path}")
|
| 30 |
+
|
| 31 |
+
# Also filter BPE sequences
|
| 32 |
+
with open(BASE / "data/sequences_bpe.pkl", "rb") as f:
|
| 33 |
+
sequences_bpe = pickle.load(f)
|
| 34 |
+
print(f"Original BPE dataset: {len(sequences_bpe)} sequences")
|
| 35 |
+
|
| 36 |
+
# Need to match by index or wurcs field - check structure first
|
| 37 |
+
print(f"Sample BPE entry keys: {sequences_bpe[0].keys() if isinstance(sequences_bpe[0], dict) else 'not a dict'}")
|
| 38 |
+
|
| 39 |
+
if isinstance(sequences_bpe[0], dict):
|
| 40 |
+
filtered_bpe = [s for s in sequences_bpe if s.get('wurcs', s.get('WURCS', '')) not in excluded_wurcs]
|
| 41 |
+
print(f"After BPE exclusion: {len(filtered_bpe)} sequences")
|
| 42 |
+
|
| 43 |
+
output_bpe = BASE / "bert_v5b_excluded/data/sequences_bpe_excluded.pkl"
|
| 44 |
+
with open(output_bpe, "wb") as f:
|
| 45 |
+
pickle.dump(filtered_bpe, f)
|
| 46 |
+
print(f"Saved BPE to {output_bpe}")
|
code/benchmarks/extract_all_benchmark_results.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Extract all benchmark results from GlycanAA and SweetTalk logs.
|
| 4 |
+
Creates comprehensive comparison CSV files.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import re
|
| 9 |
+
import glob
|
| 10 |
+
import pandas as pd
|
| 11 |
+
from collections import defaultdict
|
| 12 |
+
|
| 13 |
+
# Define tasks
|
| 14 |
+
TASKS = ['domain', 'kingdom', 'phylum', 'class', 'order', 'family', 'genus', 'species', 'immunogenicity', 'link']
|
| 15 |
+
DATASETS = ['strict', 'strict_3']
|
| 16 |
+
|
| 17 |
+
def extract_glycanaa_results(log_dir='GlycanAA/logs'):
|
| 18 |
+
"""Extract results from GlycanAA logs."""
|
| 19 |
+
results = []
|
| 20 |
+
|
| 21 |
+
# Pattern: AA_{dataset}_{model}_{task}_{jobid}.out
|
| 22 |
+
pattern = os.path.join(log_dir, 'AA_*.out')
|
| 23 |
+
log_files = glob.glob(pattern)
|
| 24 |
+
|
| 25 |
+
print(f"Found {len(log_files)} GlycanAA log files")
|
| 26 |
+
|
| 27 |
+
for log_file in log_files:
|
| 28 |
+
basename = os.path.basename(log_file)
|
| 29 |
+
# Parse filename
|
| 30 |
+
# Format: AA_strict_3_GearNet-Edge_species_9209924.out or AA_strict_GearNet_species_9209017.out
|
| 31 |
+
parts = basename.replace('.out', '').split('_')
|
| 32 |
+
|
| 33 |
+
if len(parts) < 4:
|
| 34 |
+
continue
|
| 35 |
+
|
| 36 |
+
# Determine dataset (strict or strict_3)
|
| 37 |
+
if parts[1] == 'strict' and parts[2] == '3':
|
| 38 |
+
dataset = 'strict_3'
|
| 39 |
+
model_start = 3
|
| 40 |
+
elif parts[1] == 'strict':
|
| 41 |
+
dataset = 'strict'
|
| 42 |
+
model_start = 2
|
| 43 |
+
else:
|
| 44 |
+
continue
|
| 45 |
+
|
| 46 |
+
# Extract model name (can have - in name like GearNet-Edge)
|
| 47 |
+
# Last part before last underscore is the task, last is jobid
|
| 48 |
+
job_id = parts[-1]
|
| 49 |
+
task = parts[-2]
|
| 50 |
+
model = '_'.join(parts[model_start:-2]).replace('_', '-') if '-' in '_'.join(parts[model_start:-2]) else '-'.join(parts[model_start:-2])
|
| 51 |
+
|
| 52 |
+
# Actually let's parse more carefully
|
| 53 |
+
remaining = '_'.join(parts[model_start:])
|
| 54 |
+
# Remove jobid at end
|
| 55 |
+
remaining = '_'.join(remaining.split('_')[:-1])
|
| 56 |
+
|
| 57 |
+
# Find task
|
| 58 |
+
found_task = None
|
| 59 |
+
for t in TASKS:
|
| 60 |
+
if remaining.endswith(t):
|
| 61 |
+
found_task = t
|
| 62 |
+
model = remaining[:-len(t)-1] # Remove task and underscore
|
| 63 |
+
break
|
| 64 |
+
|
| 65 |
+
if not found_task:
|
| 66 |
+
continue
|
| 67 |
+
|
| 68 |
+
task = found_task
|
| 69 |
+
|
| 70 |
+
# Read log file and extract metrics
|
| 71 |
+
try:
|
| 72 |
+
with open(log_file, 'r') as f:
|
| 73 |
+
content = f.read()
|
| 74 |
+
|
| 75 |
+
# Look for test metrics at end of file
|
| 76 |
+
# Format: "accuracy [species]: 0.394995"
|
| 77 |
+
acc_match = re.search(rf'accuracy \[{task}\]: ([\d.]+)', content)
|
| 78 |
+
f1_match = re.search(rf'macrof1 \[{task}\]: ([\d.]+)', content)
|
| 79 |
+
mcc_match = re.search(rf'matthews correlation coefficient \[{task}\]: ([\d.]+)', content)
|
| 80 |
+
auprc_match = re.search(rf'auprc \[{task}\]: ([\d.]+)', content)
|
| 81 |
+
|
| 82 |
+
if acc_match:
|
| 83 |
+
accuracy = float(acc_match.group(1))
|
| 84 |
+
macro_f1 = float(f1_match.group(1)) if f1_match else None
|
| 85 |
+
mcc = float(mcc_match.group(1)) if mcc_match else None
|
| 86 |
+
auprc = float(auprc_match.group(1)) if auprc_match else None
|
| 87 |
+
|
| 88 |
+
results.append({
|
| 89 |
+
'source': 'GlycanAA',
|
| 90 |
+
'dataset': dataset,
|
| 91 |
+
'model': model,
|
| 92 |
+
'task': task,
|
| 93 |
+
'accuracy': accuracy,
|
| 94 |
+
'macro_f1': macro_f1,
|
| 95 |
+
'mcc': mcc,
|
| 96 |
+
'auprc': auprc,
|
| 97 |
+
'job_id': job_id,
|
| 98 |
+
'log_file': basename
|
| 99 |
+
})
|
| 100 |
+
except Exception as e:
|
| 101 |
+
print(f"Error reading {log_file}: {e}")
|
| 102 |
+
|
| 103 |
+
return results
|
| 104 |
+
|
| 105 |
+
def extract_sweettalk_results():
|
| 106 |
+
"""Extract results from SweetTalk result CSVs and logs."""
|
| 107 |
+
results = []
|
| 108 |
+
|
| 109 |
+
# Check result directories
|
| 110 |
+
for dataset in ['strict', 'strict_3']:
|
| 111 |
+
if dataset == 'strict':
|
| 112 |
+
result_dir = 'sweettalk/results'
|
| 113 |
+
else:
|
| 114 |
+
result_dir = 'sweettalk/results_strict_3'
|
| 115 |
+
|
| 116 |
+
if not os.path.exists(result_dir):
|
| 117 |
+
print(f"SweetTalk {dataset} results directory not found: {result_dir}")
|
| 118 |
+
continue
|
| 119 |
+
|
| 120 |
+
for task in TASKS:
|
| 121 |
+
csv_path = os.path.join(result_dir, task, f'results_{task}.csv')
|
| 122 |
+
if os.path.exists(csv_path):
|
| 123 |
+
try:
|
| 124 |
+
df = pd.read_csv(csv_path)
|
| 125 |
+
if len(df) > 0:
|
| 126 |
+
row = df.iloc[0]
|
| 127 |
+
results.append({
|
| 128 |
+
'source': 'SweetTalk',
|
| 129 |
+
'dataset': dataset,
|
| 130 |
+
'model': 'SweetTalk',
|
| 131 |
+
'task': task,
|
| 132 |
+
'accuracy': row.get('accuracy'),
|
| 133 |
+
'macro_f1': row.get('macro_f1'),
|
| 134 |
+
'mcc': None,
|
| 135 |
+
'auprc': None,
|
| 136 |
+
'job_id': None,
|
| 137 |
+
'log_file': csv_path
|
| 138 |
+
})
|
| 139 |
+
except Exception as e:
|
| 140 |
+
print(f"Error reading {csv_path}: {e}")
|
| 141 |
+
|
| 142 |
+
return results
|
| 143 |
+
|
| 144 |
+
def extract_our_results():
|
| 145 |
+
"""Extract our BERTv4 results from publication summary."""
|
| 146 |
+
results = []
|
| 147 |
+
|
| 148 |
+
for dataset in ['strict', 'strict_3']:
|
| 149 |
+
csv_path = f'downstream_tasks/results_publication_summary/{dataset}_results.csv'
|
| 150 |
+
if os.path.exists(csv_path):
|
| 151 |
+
try:
|
| 152 |
+
df = pd.read_csv(csv_path)
|
| 153 |
+
for _, row in df.iterrows():
|
| 154 |
+
results.append({
|
| 155 |
+
'source': 'BERTv4',
|
| 156 |
+
'dataset': dataset,
|
| 157 |
+
'model': 'BERTv4-Topology',
|
| 158 |
+
'task': row['Task'],
|
| 159 |
+
'accuracy': row['Accuracy'],
|
| 160 |
+
'macro_f1': row['Macro-F1'],
|
| 161 |
+
'mcc': row.get('MCC'),
|
| 162 |
+
'auprc': row.get('AUPRC'),
|
| 163 |
+
'job_id': None,
|
| 164 |
+
'log_file': csv_path
|
| 165 |
+
})
|
| 166 |
+
except Exception as e:
|
| 167 |
+
print(f"Error reading {csv_path}: {e}")
|
| 168 |
+
|
| 169 |
+
return results
|
| 170 |
+
|
| 171 |
+
def main():
|
| 172 |
+
print("="*60)
|
| 173 |
+
print("EXTRACTING ALL BENCHMARK RESULTS")
|
| 174 |
+
print("="*60)
|
| 175 |
+
|
| 176 |
+
all_results = []
|
| 177 |
+
|
| 178 |
+
# Extract from each source
|
| 179 |
+
print("\n1. Extracting GlycanAA results...")
|
| 180 |
+
glycanaa_results = extract_glycanaa_results()
|
| 181 |
+
all_results.extend(glycanaa_results)
|
| 182 |
+
print(f" Found {len(glycanaa_results)} GlycanAA results")
|
| 183 |
+
|
| 184 |
+
print("\n2. Extracting SweetTalk results...")
|
| 185 |
+
sweettalk_results = extract_sweettalk_results()
|
| 186 |
+
all_results.extend(sweettalk_results)
|
| 187 |
+
print(f" Found {len(sweettalk_results)} SweetTalk results")
|
| 188 |
+
|
| 189 |
+
print("\n3. Extracting BERTv4 results...")
|
| 190 |
+
our_results = extract_our_results()
|
| 191 |
+
all_results.extend(our_results)
|
| 192 |
+
print(f" Found {len(our_results)} BERTv4 results")
|
| 193 |
+
|
| 194 |
+
# Convert to DataFrame
|
| 195 |
+
df = pd.DataFrame(all_results)
|
| 196 |
+
|
| 197 |
+
if len(df) == 0:
|
| 198 |
+
print("\nNo results found!")
|
| 199 |
+
return
|
| 200 |
+
|
| 201 |
+
# Save raw results
|
| 202 |
+
df.to_csv('all_benchmark_results_raw.csv', index=False)
|
| 203 |
+
print(f"\nSaved raw results to all_benchmark_results_raw.csv ({len(df)} rows)")
|
| 204 |
+
|
| 205 |
+
# Create summary by dataset, model, task (take best/latest result per combination)
|
| 206 |
+
# Group and take max accuracy for each combination
|
| 207 |
+
summary = df.groupby(['dataset', 'model', 'task']).agg({
|
| 208 |
+
'accuracy': 'max',
|
| 209 |
+
'macro_f1': 'max',
|
| 210 |
+
'source': 'first'
|
| 211 |
+
}).reset_index()
|
| 212 |
+
|
| 213 |
+
summary.to_csv('all_benchmark_results_summary.csv', index=False)
|
| 214 |
+
print(f"Saved summary to all_benchmark_results_summary.csv ({len(summary)} rows)")
|
| 215 |
+
|
| 216 |
+
# Create pivot tables for easy comparison
|
| 217 |
+
for dataset in DATASETS:
|
| 218 |
+
subset = summary[summary['dataset'] == dataset]
|
| 219 |
+
if len(subset) > 0:
|
| 220 |
+
# Pivot: rows=models, columns=tasks
|
| 221 |
+
pivot = subset.pivot(index='model', columns='task', values='accuracy')
|
| 222 |
+
# Reorder columns
|
| 223 |
+
cols = [t for t in TASKS if t in pivot.columns]
|
| 224 |
+
pivot = pivot[cols]
|
| 225 |
+
pivot.to_csv(f'benchmark_comparison_{dataset}.csv')
|
| 226 |
+
print(f"\nSaved {dataset} comparison to benchmark_comparison_{dataset}.csv")
|
| 227 |
+
print(pivot.to_string())
|
| 228 |
+
|
| 229 |
+
# Print summary counts
|
| 230 |
+
print("\n" + "="*60)
|
| 231 |
+
print("RESULTS SUMMARY")
|
| 232 |
+
print("="*60)
|
| 233 |
+
for dataset in DATASETS:
|
| 234 |
+
print(f"\n{dataset.upper()}:")
|
| 235 |
+
subset = summary[summary['dataset'] == dataset]
|
| 236 |
+
models = subset['model'].unique()
|
| 237 |
+
print(f" Models: {len(models)}")
|
| 238 |
+
for model in sorted(models):
|
| 239 |
+
model_subset = subset[subset['model'] == model]
|
| 240 |
+
tasks_complete = len(model_subset)
|
| 241 |
+
print(f" - {model}: {tasks_complete}/10 tasks")
|
| 242 |
+
|
| 243 |
+
if __name__ == '__main__':
|
| 244 |
+
main()
|
code/benchmarks/finetune.py
ADDED
|
@@ -0,0 +1,708 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Unified Fine-tuning Script for Glycan Classification
|
| 4 |
+
|
| 5 |
+
This script fine-tunes a pre-trained Multimodal Glycan BERT model
|
| 6 |
+
on taxonomy classification tasks (domain, kingdom, phylum, class,
|
| 7 |
+
order, family, genus, species) and property prediction tasks
|
| 8 |
+
(immunogenicity, link).
|
| 9 |
+
|
| 10 |
+
Usage:
|
| 11 |
+
python downstream_tasks/finetune.py \
|
| 12 |
+
--task species \
|
| 13 |
+
--data_path downstream_tasks/glycan_classification_with_wurcs.csv \
|
| 14 |
+
--checkpoint checkpoints/best_multimodal_v3_model.pt \
|
| 15 |
+
--vocab data/vocabulary.json \
|
| 16 |
+
--output_dir downstream_tasks/results/species
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import argparse
|
| 20 |
+
import json
|
| 21 |
+
import logging
|
| 22 |
+
import os
|
| 23 |
+
import random
|
| 24 |
+
import sys
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
from typing import Dict, List, Optional, Tuple
|
| 27 |
+
import math
|
| 28 |
+
from datetime import datetime
|
| 29 |
+
|
| 30 |
+
import numpy as np
|
| 31 |
+
import torch
|
| 32 |
+
import torch.nn as nn
|
| 33 |
+
import torch.nn.functional as F
|
| 34 |
+
from torch.optim import AdamW
|
| 35 |
+
from torch.optim.lr_scheduler import CosineAnnealingLR
|
| 36 |
+
from torch.utils.data import DataLoader
|
| 37 |
+
import pandas as pd
|
| 38 |
+
from tqdm import tqdm
|
| 39 |
+
from sklearn.metrics import (
|
| 40 |
+
accuracy_score, f1_score, precision_score, recall_score,
|
| 41 |
+
matthews_corrcoef, classification_report
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
# Add parent to path
|
| 45 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 46 |
+
|
| 47 |
+
from model.multimodal_glycan_bert_v3 import MultimodalGlycanBERT, MultimodalGlycanBERTConfig
|
| 48 |
+
from downstream_tasks.utils.tokenizer import WURCSTokenizer
|
| 49 |
+
from downstream_tasks.utils.dataset import GlycanClassificationDataset, compute_valid_classes
|
| 50 |
+
|
| 51 |
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
| 52 |
+
logger = logging.getLogger(__name__)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def set_seed(seed: int):
|
| 56 |
+
"""Set random seeds for reproducibility."""
|
| 57 |
+
random.seed(seed)
|
| 58 |
+
np.random.seed(seed)
|
| 59 |
+
torch.manual_seed(seed)
|
| 60 |
+
if torch.cuda.is_available():
|
| 61 |
+
torch.cuda.manual_seed_all(seed)
|
| 62 |
+
torch.backends.cudnn.deterministic = True
|
| 63 |
+
torch.backends.cudnn.benchmark = False
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class GlycanClassifier(nn.Module):
|
| 67 |
+
"""
|
| 68 |
+
Classification head on top of pre-trained BERT.
|
| 69 |
+
|
| 70 |
+
Improvements:
|
| 71 |
+
- Attention pooling (works better than first-token for WURCS sequences)
|
| 72 |
+
- Mono pooling (pool at monosaccharide level using residue_ids)
|
| 73 |
+
- Reduced frozen layers (4 vs 8) for better adaptation
|
| 74 |
+
"""
|
| 75 |
+
|
| 76 |
+
def __init__(
|
| 77 |
+
self,
|
| 78 |
+
bert: MultimodalGlycanBERT,
|
| 79 |
+
num_classes: int,
|
| 80 |
+
dropout: float = 0.25, # Increased from 0.1 to combat overfitting
|
| 81 |
+
freeze_layers: int = 8, # Increased from 4 to prevent overfitting
|
| 82 |
+
pooling_strategy: str = "attention", # "mean", "first", "max", "attention", "mono"
|
| 83 |
+
):
|
| 84 |
+
super().__init__()
|
| 85 |
+
self.bert = bert
|
| 86 |
+
self.num_classes = num_classes
|
| 87 |
+
self.pooling_strategy = pooling_strategy
|
| 88 |
+
|
| 89 |
+
# Freeze bottom layers
|
| 90 |
+
for i, layer in enumerate(self.bert.seq_layers):
|
| 91 |
+
if i < freeze_layers:
|
| 92 |
+
for param in layer.parameters():
|
| 93 |
+
param.requires_grad = False
|
| 94 |
+
|
| 95 |
+
# Classification head (use sequence hidden size)
|
| 96 |
+
hidden_size = bert.config.seq_hidden_size
|
| 97 |
+
|
| 98 |
+
# Attention pooling layer (if using attention or mono strategy)
|
| 99 |
+
if pooling_strategy in ["attention", "mono"]:
|
| 100 |
+
self.attention_weights = nn.Linear(hidden_size, 1)
|
| 101 |
+
|
| 102 |
+
self.classifier = nn.Sequential(
|
| 103 |
+
nn.Dropout(dropout),
|
| 104 |
+
nn.Linear(hidden_size, hidden_size // 2),
|
| 105 |
+
nn.GELU(),
|
| 106 |
+
nn.Dropout(dropout),
|
| 107 |
+
nn.Linear(hidden_size // 2, num_classes),
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
def forward(self, token_ids, attention_mask, residue_ids=None, **kwargs):
|
| 111 |
+
"""
|
| 112 |
+
Forward pass for classification.
|
| 113 |
+
|
| 114 |
+
Args:
|
| 115 |
+
token_ids: (batch, seq_len) - Token IDs
|
| 116 |
+
attention_mask: (batch, seq_len) - Attention mask
|
| 117 |
+
residue_ids: (batch, seq_len) - Residue ID for each token (optional, for mono pooling)
|
| 118 |
+
|
| 119 |
+
Returns:
|
| 120 |
+
logits: (batch, num_classes)
|
| 121 |
+
"""
|
| 122 |
+
# Get sequence embeddings with branch/linkage info if available
|
| 123 |
+
# Check if seq_embeddings supports the new parameters
|
| 124 |
+
if hasattr(self.bert.seq_embeddings, 'branch_embeddings'):
|
| 125 |
+
branch_depths = kwargs.get('branch_depths')
|
| 126 |
+
linkage_types = kwargs.get('linkage_types')
|
| 127 |
+
seq_hidden = self.bert.seq_embeddings(token_ids, branch_depths, linkage_types)
|
| 128 |
+
else:
|
| 129 |
+
seq_hidden = self.bert.seq_embeddings(token_ids)
|
| 130 |
+
|
| 131 |
+
# Apply transformer layers
|
| 132 |
+
for layer in self.bert.seq_layers:
|
| 133 |
+
seq_hidden = layer(seq_hidden, attention_mask)
|
| 134 |
+
|
| 135 |
+
# Optional: Compute auxiliary distance reconstruction loss (topology)
|
| 136 |
+
dist_loss = 0.0
|
| 137 |
+
dist_labels = kwargs.get('dist_labels')
|
| 138 |
+
if dist_labels is not None:
|
| 139 |
+
# Predict distances using the pre-trained distance head
|
| 140 |
+
# (which was ignored in previous fine-tuning versions)
|
| 141 |
+
dist_predictions = self.bert.distance_head(seq_hidden) # (batch, seq, seq)
|
| 142 |
+
|
| 143 |
+
# Mask out padding (-1)
|
| 144 |
+
# labels shape: (batch, seq, seq)
|
| 145 |
+
# predictions shape: (batch, seq, seq)
|
| 146 |
+
|
| 147 |
+
# Ensure proper casting and device
|
| 148 |
+
dist_labels = dist_labels.to(dist_predictions.device)
|
| 149 |
+
mask = dist_labels != -1
|
| 150 |
+
|
| 151 |
+
if mask.any():
|
| 152 |
+
# Compute MSE loss on valid distances
|
| 153 |
+
# We cast labels to float for MSE
|
| 154 |
+
loss_fct = nn.MSELoss()
|
| 155 |
+
dist_loss = loss_fct(dist_predictions[mask], dist_labels[mask].float())
|
| 156 |
+
|
| 157 |
+
# Pool based on strategy
|
| 158 |
+
if self.pooling_strategy == "first":
|
| 159 |
+
# Original: Use first token (CLS-style)
|
| 160 |
+
pooled = seq_hidden[:, 0, :]
|
| 161 |
+
elif self.pooling_strategy == "max":
|
| 162 |
+
# Max pooling over sequence
|
| 163 |
+
mask_expanded = attention_mask.unsqueeze(-1).float()
|
| 164 |
+
seq_hidden_masked = seq_hidden * mask_expanded + (1 - mask_expanded) * -1e9
|
| 165 |
+
pooled = seq_hidden_masked.max(dim=1)[0]
|
| 166 |
+
elif self.pooling_strategy == "mono" and residue_ids is not None:
|
| 167 |
+
# Monosaccharide-level pooling: pool tokens within each residue, then attention over residues
|
| 168 |
+
batch_size = seq_hidden.size(0)
|
| 169 |
+
hidden_size = seq_hidden.size(-1)
|
| 170 |
+
|
| 171 |
+
# First, pool within each residue using mean
|
| 172 |
+
pooled_residues = []
|
| 173 |
+
max_residues = 32 # Max number of residues per glycan
|
| 174 |
+
|
| 175 |
+
for b in range(batch_size):
|
| 176 |
+
residue_reps = []
|
| 177 |
+
unique_res = torch.unique(residue_ids[b])
|
| 178 |
+
# Filter to actual residues (>= 0)
|
| 179 |
+
unique_res = unique_res[unique_res >= 0]
|
| 180 |
+
|
| 181 |
+
for rid in unique_res[:max_residues]:
|
| 182 |
+
mask = (residue_ids[b] == rid).float()
|
| 183 |
+
if mask.sum() > 0:
|
| 184 |
+
res_rep = (seq_hidden[b] * mask.unsqueeze(-1)).sum(dim=0) / mask.sum()
|
| 185 |
+
residue_reps.append(res_rep)
|
| 186 |
+
|
| 187 |
+
if len(residue_reps) == 0:
|
| 188 |
+
# Fallback to mean pooling
|
| 189 |
+
mask_expanded = attention_mask[b].unsqueeze(-1).float()
|
| 190 |
+
pooled_residues.append((seq_hidden[b] * mask_expanded).sum(dim=0) / mask_expanded.sum())
|
| 191 |
+
else:
|
| 192 |
+
# Stack residue representations and apply attention
|
| 193 |
+
res_stack = torch.stack(residue_reps, dim=0) # (num_res, hidden)
|
| 194 |
+
scores = self.attention_weights(res_stack).squeeze(-1) # (num_res,)
|
| 195 |
+
weights = torch.softmax(scores, dim=0).unsqueeze(-1) # (num_res, 1)
|
| 196 |
+
pooled_residues.append((res_stack * weights).sum(dim=0))
|
| 197 |
+
|
| 198 |
+
pooled = torch.stack(pooled_residues, dim=0) # (batch, hidden)
|
| 199 |
+
elif self.pooling_strategy == "attention":
|
| 200 |
+
# Attention-weighted pooling
|
| 201 |
+
scores = self.attention_weights(seq_hidden).squeeze(-1) # (batch, seq_len)
|
| 202 |
+
scores = scores.masked_fill(attention_mask == 0, -1e9)
|
| 203 |
+
weights = torch.softmax(scores, dim=1).unsqueeze(-1) # (batch, seq_len, 1)
|
| 204 |
+
pooled = (seq_hidden * weights).sum(dim=1)
|
| 205 |
+
else: # "mean" - default
|
| 206 |
+
# Mean pooling over non-padding tokens (recommended for WURCS)
|
| 207 |
+
mask_expanded = attention_mask.unsqueeze(-1).float()
|
| 208 |
+
sum_hidden = (seq_hidden * mask_expanded).sum(dim=1)
|
| 209 |
+
sum_mask = mask_expanded.sum(dim=1).clamp(min=1e-9)
|
| 210 |
+
pooled = sum_hidden / sum_mask
|
| 211 |
+
|
| 212 |
+
# Classify
|
| 213 |
+
logits = self.classifier(pooled)
|
| 214 |
+
|
| 215 |
+
return logits, dist_loss
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def get_config_from_checkpoint(checkpoint_path: str, device: str) -> MultimodalGlycanBERTConfig:
|
| 219 |
+
"""Extract config from checkpoint."""
|
| 220 |
+
checkpoint = torch.load(checkpoint_path, map_location=device)
|
| 221 |
+
|
| 222 |
+
if 'config' in checkpoint:
|
| 223 |
+
config_dict = checkpoint['config']
|
| 224 |
+
if 'model' in config_dict:
|
| 225 |
+
model_cfg = config_dict['model']
|
| 226 |
+
seq_cfg = model_cfg.get('sequence', {})
|
| 227 |
+
ms_cfg = model_cfg.get('mass_spectrometry', model_cfg.get('ms', {}))
|
| 228 |
+
struct_cfg = model_cfg.get('structure_3d', model_cfg.get('structure', {}))
|
| 229 |
+
fusion_cfg = model_cfg.get('fusion', {})
|
| 230 |
+
|
| 231 |
+
return MultimodalGlycanBERTConfig(
|
| 232 |
+
seq_vocab_size=seq_cfg.get('vocab_size', 166),
|
| 233 |
+
seq_hidden_size=seq_cfg.get('hidden_size', 768),
|
| 234 |
+
seq_num_layers=seq_cfg.get('num_hidden_layers', 12),
|
| 235 |
+
seq_num_heads=seq_cfg.get('num_attention_heads', 12),
|
| 236 |
+
seq_max_length=seq_cfg.get('max_length', 512),
|
| 237 |
+
ms_vocab_size=ms_cfg.get('vocab_size', 242),
|
| 238 |
+
ms_hidden_size=ms_cfg.get('hidden_size', 256),
|
| 239 |
+
ms_num_layers=ms_cfg.get('num_hidden_layers', 4),
|
| 240 |
+
ms_num_heads=ms_cfg.get('num_attention_heads', 4),
|
| 241 |
+
ms_max_length=ms_cfg.get('max_length', 100),
|
| 242 |
+
struct_vocab_size=struct_cfg.get('vocab_size', 1024),
|
| 243 |
+
struct_hidden_size=struct_cfg.get('hidden_size', 256),
|
| 244 |
+
struct_num_layers=struct_cfg.get('num_hidden_layers', 4),
|
| 245 |
+
struct_num_heads=struct_cfg.get('num_attention_heads', 4),
|
| 246 |
+
struct_max_length=struct_cfg.get('max_length', 100),
|
| 247 |
+
use_3d=struct_cfg.get('enabled', struct_cfg.get('use_3d', True)),
|
| 248 |
+
fusion_hidden_size=fusion_cfg.get('fusion_hidden_size', 512),
|
| 249 |
+
)
|
| 250 |
+
else:
|
| 251 |
+
return MultimodalGlycanBERTConfig(**config_dict)
|
| 252 |
+
|
| 253 |
+
return MultimodalGlycanBERTConfig()
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def load_pretrained_bert(checkpoint_path: str, config: MultimodalGlycanBERTConfig, device: str) -> MultimodalGlycanBERT:
|
| 257 |
+
"""Load pre-trained BERT from checkpoint using provided config."""
|
| 258 |
+
logger.info(f"Loading checkpoint from {checkpoint_path}")
|
| 259 |
+
checkpoint = torch.load(checkpoint_path, map_location=device)
|
| 260 |
+
|
| 261 |
+
# Create model
|
| 262 |
+
bert = MultimodalGlycanBERT(config)
|
| 263 |
+
|
| 264 |
+
# Load weights with strict=False to handle any minor mismatches
|
| 265 |
+
if 'model_state_dict' in checkpoint:
|
| 266 |
+
bert.load_state_dict(checkpoint['model_state_dict'], strict=False)
|
| 267 |
+
else:
|
| 268 |
+
bert.load_state_dict(checkpoint, strict=False)
|
| 269 |
+
|
| 270 |
+
logger.info("Loaded pre-trained BERT successfully")
|
| 271 |
+
return bert
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def train_epoch(
|
| 276 |
+
model: GlycanClassifier,
|
| 277 |
+
train_loader: DataLoader,
|
| 278 |
+
optimizer: AdamW,
|
| 279 |
+
criterion: nn.Module,
|
| 280 |
+
device: str,
|
| 281 |
+
scheduler=None,
|
| 282 |
+
dist_alpha: float = 0.5,
|
| 283 |
+
) -> dict:
|
| 284 |
+
"""Train for one epoch."""
|
| 285 |
+
model.train()
|
| 286 |
+
total_loss = 0
|
| 287 |
+
all_preds = []
|
| 288 |
+
all_labels = []
|
| 289 |
+
|
| 290 |
+
pbar = tqdm(train_loader, desc="Training")
|
| 291 |
+
for batch in pbar:
|
| 292 |
+
token_ids = batch['token_ids'].to(device)
|
| 293 |
+
attention_mask = batch['attention_mask'].to(device)
|
| 294 |
+
residue_ids = batch['residue_ids'].to(device) if 'residue_ids' in batch else None
|
| 295 |
+
branch_depths = batch['branch_depths'].to(device) if 'branch_depths' in batch else None # NEW
|
| 296 |
+
linkage_types = batch['linkage_types'].to(device) if 'linkage_types' in batch else None # NEW
|
| 297 |
+
dist_labels = batch['dist_labels'].to(device) if 'dist_labels' in batch else None # NEW (Topology)
|
| 298 |
+
labels = batch['label'].to(device)
|
| 299 |
+
|
| 300 |
+
optimizer.zero_grad()
|
| 301 |
+
|
| 302 |
+
logits, dist_loss = model(
|
| 303 |
+
token_ids, attention_mask, residue_ids,
|
| 304 |
+
branch_depths=branch_depths, linkage_types=linkage_types,
|
| 305 |
+
dist_labels=dist_labels
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
# Main task loss
|
| 309 |
+
cls_loss = criterion(logits, labels)
|
| 310 |
+
|
| 311 |
+
# Total loss = Classification Loss + alpha * Topology Loss
|
| 312 |
+
# We weight topology loss to avoid overwhelming the main task
|
| 313 |
+
total_batch_loss = cls_loss + dist_alpha * dist_loss
|
| 314 |
+
|
| 315 |
+
total_batch_loss.backward()
|
| 316 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
|
| 317 |
+
optimizer.step()
|
| 318 |
+
|
| 319 |
+
if scheduler:
|
| 320 |
+
scheduler.step()
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
total_loss += total_batch_loss.item()
|
| 324 |
+
preds = logits.argmax(dim=1).cpu().numpy()
|
| 325 |
+
all_preds.extend(preds)
|
| 326 |
+
all_labels.extend(labels.cpu().numpy())
|
| 327 |
+
|
| 328 |
+
pbar.set_postfix({'loss': f'{total_batch_loss.item():.4f}', 'dist': f'{dist_loss:.4f}' if isinstance(dist_loss, float) else f'{dist_loss.item():.4f}'})
|
| 329 |
+
|
| 330 |
+
avg_loss = total_loss / len(train_loader)
|
| 331 |
+
accuracy = accuracy_score(all_labels, all_preds)
|
| 332 |
+
|
| 333 |
+
return {
|
| 334 |
+
'loss': avg_loss,
|
| 335 |
+
'accuracy': accuracy,
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def evaluate(
|
| 340 |
+
model: GlycanClassifier,
|
| 341 |
+
data_loader: DataLoader,
|
| 342 |
+
criterion: nn.Module,
|
| 343 |
+
device: str,
|
| 344 |
+
num_classes: int = None,
|
| 345 |
+
dist_alpha: float = 0.5,
|
| 346 |
+
) -> dict:
|
| 347 |
+
"""Evaluate model on dataset."""
|
| 348 |
+
model.eval()
|
| 349 |
+
total_loss = 0
|
| 350 |
+
all_preds = []
|
| 351 |
+
all_labels = []
|
| 352 |
+
all_probs = [] # Store probabilities for AUROC/AUPRC
|
| 353 |
+
|
| 354 |
+
with torch.no_grad():
|
| 355 |
+
for batch in tqdm(data_loader, desc="Evaluating"):
|
| 356 |
+
token_ids = batch['token_ids'].to(device)
|
| 357 |
+
attention_mask = batch['attention_mask'].to(device)
|
| 358 |
+
residue_ids = batch['residue_ids'].to(device) if 'residue_ids' in batch else None
|
| 359 |
+
branch_depths = batch['branch_depths'].to(device) if 'branch_depths' in batch else None # NEW
|
| 360 |
+
linkage_types = batch['linkage_types'].to(device) if 'linkage_types' in batch else None # NEW
|
| 361 |
+
dist_labels = batch['dist_labels'].to(device) if 'dist_labels' in batch else None # NEW
|
| 362 |
+
labels = batch['label'].to(device)
|
| 363 |
+
|
| 364 |
+
logits, dist_loss = model(
|
| 365 |
+
token_ids, attention_mask, residue_ids,
|
| 366 |
+
branch_depths=branch_depths, linkage_types=linkage_types,
|
| 367 |
+
dist_labels=dist_labels
|
| 368 |
+
)
|
| 369 |
+
cls_loss = criterion(logits, labels)
|
| 370 |
+
|
| 371 |
+
loss = cls_loss + dist_alpha * dist_loss
|
| 372 |
+
|
| 373 |
+
total_loss += loss.item()
|
| 374 |
+
probs = torch.softmax(logits, dim=1).cpu().numpy()
|
| 375 |
+
preds = logits.argmax(dim=1).cpu().numpy()
|
| 376 |
+
all_preds.extend(preds)
|
| 377 |
+
all_labels.extend(labels.cpu().numpy())
|
| 378 |
+
all_probs.extend(probs)
|
| 379 |
+
|
| 380 |
+
avg_loss = total_loss / len(data_loader)
|
| 381 |
+
accuracy = accuracy_score(all_labels, all_preds)
|
| 382 |
+
f1_macro = f1_score(all_labels, all_preds, average='macro', zero_division=0)
|
| 383 |
+
f1_weighted = f1_score(all_labels, all_preds, average='weighted', zero_division=0)
|
| 384 |
+
mcc = matthews_corrcoef(all_labels, all_preds)
|
| 385 |
+
|
| 386 |
+
# Compute AUROC and AUPRC (for multi-class: one-vs-rest)
|
| 387 |
+
auroc = None
|
| 388 |
+
auprc = None
|
| 389 |
+
all_probs = np.array(all_probs)
|
| 390 |
+
all_labels_arr = np.array(all_labels)
|
| 391 |
+
|
| 392 |
+
try:
|
| 393 |
+
from sklearn.metrics import roc_auc_score, average_precision_score
|
| 394 |
+
from sklearn.preprocessing import label_binarize
|
| 395 |
+
|
| 396 |
+
# Get unique classes present in labels
|
| 397 |
+
unique_classes = np.unique(all_labels_arr)
|
| 398 |
+
|
| 399 |
+
if len(unique_classes) == 2:
|
| 400 |
+
# Binary classification
|
| 401 |
+
auroc = roc_auc_score(all_labels_arr, all_probs[:, 1])
|
| 402 |
+
auprc = average_precision_score(all_labels_arr, all_probs[:, 1])
|
| 403 |
+
elif len(unique_classes) > 2 and num_classes is not None:
|
| 404 |
+
# Multi-class: use one-vs-rest
|
| 405 |
+
# Only compute if all classes are present in test set
|
| 406 |
+
if len(unique_classes) == num_classes:
|
| 407 |
+
auroc = roc_auc_score(all_labels_arr, all_probs, multi_class='ovr', average='macro')
|
| 408 |
+
# AUPRC for multi-class: binarize labels
|
| 409 |
+
labels_bin = label_binarize(all_labels_arr, classes=list(range(num_classes)))
|
| 410 |
+
auprc = average_precision_score(labels_bin, all_probs, average='macro')
|
| 411 |
+
else:
|
| 412 |
+
# Some classes missing - compute on available classes
|
| 413 |
+
auroc = roc_auc_score(all_labels_arr, all_probs, multi_class='ovr',
|
| 414 |
+
average='macro', labels=unique_classes)
|
| 415 |
+
except Exception as e:
|
| 416 |
+
# AUROC/AUPRC may fail with certain class distributions
|
| 417 |
+
pass
|
| 418 |
+
|
| 419 |
+
return {
|
| 420 |
+
'loss': avg_loss,
|
| 421 |
+
'accuracy': accuracy,
|
| 422 |
+
'f1_macro': f1_macro,
|
| 423 |
+
'f1_weighted': f1_weighted,
|
| 424 |
+
'mcc': mcc,
|
| 425 |
+
'auroc': auroc,
|
| 426 |
+
'auprc': auprc,
|
| 427 |
+
'predictions': all_preds,
|
| 428 |
+
'labels': all_labels,
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def main():
|
| 433 |
+
parser = argparse.ArgumentParser(description='Fine-tune Glycan BERT for classification')
|
| 434 |
+
|
| 435 |
+
# Required arguments
|
| 436 |
+
parser.add_argument('--task', type=str, required=True,
|
| 437 |
+
help='Task name (e.g., species, phylum)')
|
| 438 |
+
parser.add_argument('--data_path', type=str, required=True,
|
| 439 |
+
help='Path to CSV data file')
|
| 440 |
+
parser.add_argument('--checkpoint', type=str, required=True,
|
| 441 |
+
help='Path to pre-trained model checkpoint')
|
| 442 |
+
parser.add_argument('--vocab', type=str, required=True,
|
| 443 |
+
help='Path to vocabulary.json')
|
| 444 |
+
parser.add_argument('--output_dir', type=str, required=True,
|
| 445 |
+
help='Output directory for results')
|
| 446 |
+
|
| 447 |
+
# Optional arguments
|
| 448 |
+
parser.add_argument('--batch_size', type=int, default=256,
|
| 449 |
+
help='Batch size (matching GlycanML for stable gradients)')
|
| 450 |
+
parser.add_argument('--epochs', type=int, default=50)
|
| 451 |
+
parser.add_argument('--lr', type=float, default=5e-5)
|
| 452 |
+
parser.add_argument('--weight_decay', type=float, default=0.01)
|
| 453 |
+
parser.add_argument('--dropout', type=float, default=0.25,
|
| 454 |
+
help='Dropout rate (increased from 0.1 to combat overfitting)')
|
| 455 |
+
parser.add_argument('--freeze_layers', type=int, default=8,
|
| 456 |
+
help='Number of bottom layers to freeze (increased from 4 to prevent overfitting)')
|
| 457 |
+
parser.add_argument('--pooling_strategy', type=str, default='attention',
|
| 458 |
+
choices=['mean', 'first', 'max', 'attention', 'mono'],
|
| 459 |
+
help='Pooling strategy: attention (recommended), mono (residue-level), mean, first (CLS-style), max')
|
| 460 |
+
parser.add_argument('--max_length', type=int, default=256)
|
| 461 |
+
parser.add_argument('--patience', type=int, default=10,
|
| 462 |
+
help='Early stopping patience')
|
| 463 |
+
parser.add_argument('--device', type=str, default='cuda' if torch.cuda.is_available() else 'cpu')
|
| 464 |
+
parser.add_argument('--seed', type=int, default=42)
|
| 465 |
+
parser.add_argument('--num_workers', type=int, default=4)
|
| 466 |
+
parser.add_argument('--filter_mode', type=str, default='none',
|
| 467 |
+
choices=['none', 'strict', 'strict_3', 'strict_5'],
|
| 468 |
+
help='Class filtering: none (use all), strict (n=1), strict_3 (n=3), strict_5 (n=5)')
|
| 469 |
+
parser.add_argument('--dist_alpha', type=float, default=0.0,
|
| 470 |
+
help='Weight for auxiliary distance/topology loss (default: 0.0 disabled, set >0 to enable)')
|
| 471 |
+
|
| 472 |
+
args = parser.parse_args()
|
| 473 |
+
|
| 474 |
+
# Setup
|
| 475 |
+
set_seed(args.seed)
|
| 476 |
+
os.makedirs(args.output_dir, exist_ok=True)
|
| 477 |
+
|
| 478 |
+
# Log configuration
|
| 479 |
+
logger.info("=" * 70)
|
| 480 |
+
logger.info(f"FINE-TUNING GLYCAN BERT ON {args.task.upper()}")
|
| 481 |
+
logger.info("=" * 70)
|
| 482 |
+
logger.info(f"Data: {args.data_path}")
|
| 483 |
+
logger.info(f"Checkpoint: {args.checkpoint}")
|
| 484 |
+
logger.info(f"Output: {args.output_dir}")
|
| 485 |
+
logger.info(f"Device: {args.device}")
|
| 486 |
+
logger.info(f"Seed: {args.seed}")
|
| 487 |
+
logger.info(f"Filter mode: {args.filter_mode}")
|
| 488 |
+
|
| 489 |
+
# Compute valid classes if using strict filtering
|
| 490 |
+
valid_classes = None
|
| 491 |
+
if args.filter_mode != 'none':
|
| 492 |
+
# Determine min_samples from filter mode
|
| 493 |
+
if args.filter_mode == 'strict':
|
| 494 |
+
min_samples = 1
|
| 495 |
+
elif args.filter_mode == 'strict_3':
|
| 496 |
+
min_samples = 3
|
| 497 |
+
elif args.filter_mode == 'strict_5':
|
| 498 |
+
min_samples = 5
|
| 499 |
+
else:
|
| 500 |
+
min_samples = 1
|
| 501 |
+
|
| 502 |
+
logger.info(f"\nComputing valid classes ({args.filter_mode} mode, min_samples={min_samples})...")
|
| 503 |
+
valid_classes = compute_valid_classes(args.data_path, args.task, min_samples=min_samples)
|
| 504 |
+
logger.info(f" Will use {len(valid_classes)} classes present in all splits")
|
| 505 |
+
|
| 506 |
+
# Load config from checkpoint to get model capacity
|
| 507 |
+
logger.info("\nChecking model capacity from checkpoint...")
|
| 508 |
+
checkpoint_config = get_config_from_checkpoint(args.checkpoint, 'cpu')
|
| 509 |
+
model_max_length = checkpoint_config.seq_max_length
|
| 510 |
+
|
| 511 |
+
# Override max_length if it exceeds model capacity
|
| 512 |
+
if args.max_length > model_max_length:
|
| 513 |
+
logger.warning(f" Requested max_length ({args.max_length}) exceeds model capacity ({model_max_length}).")
|
| 514 |
+
logger.warning(f" Overriding max_length to {model_max_length} to prevent size mismatch errors.")
|
| 515 |
+
dataset_max_length = model_max_length
|
| 516 |
+
else:
|
| 517 |
+
dataset_max_length = args.max_length
|
| 518 |
+
|
| 519 |
+
# Load data
|
| 520 |
+
logger.info(f"\nLoading data (max_length={dataset_max_length})...")
|
| 521 |
+
train_dataset = GlycanClassificationDataset(
|
| 522 |
+
args.data_path, args.task, 'train', args.vocab, dataset_max_length,
|
| 523 |
+
valid_classes=valid_classes
|
| 524 |
+
)
|
| 525 |
+
val_dataset = GlycanClassificationDataset(
|
| 526 |
+
args.data_path, args.task, 'validation', args.vocab, dataset_max_length,
|
| 527 |
+
valid_classes=valid_classes
|
| 528 |
+
)
|
| 529 |
+
test_dataset = GlycanClassificationDataset(
|
| 530 |
+
args.data_path, args.task, 'test', args.vocab, dataset_max_length,
|
| 531 |
+
valid_classes=valid_classes
|
| 532 |
+
)
|
| 533 |
+
|
| 534 |
+
logger.info(f"\nDataset summary:")
|
| 535 |
+
logger.info(f" Train: {len(train_dataset)} samples")
|
| 536 |
+
logger.info(f" Val: {len(val_dataset)} samples")
|
| 537 |
+
logger.info(f" Test: {len(test_dataset)} samples")
|
| 538 |
+
logger.info(f" Classes: {len(train_dataset.unique_labels)}")
|
| 539 |
+
|
| 540 |
+
# Report class filtering stats
|
| 541 |
+
if args.filter_mode == 'none':
|
| 542 |
+
train_classes = set(train_dataset.unique_labels)
|
| 543 |
+
val_classes = set(val_dataset.unique_labels)
|
| 544 |
+
test_classes = set(test_dataset.unique_labels)
|
| 545 |
+
common_classes = train_classes & val_classes & test_classes
|
| 546 |
+
logger.info(f"\nClass distribution (filter_mode=none):")
|
| 547 |
+
logger.info(f" Train-only classes: {len(train_classes - common_classes)}")
|
| 548 |
+
logger.info(f" Val-only classes: {len(val_classes - train_classes - test_classes)}")
|
| 549 |
+
logger.info(f" Test-only classes: {len(test_classes - train_classes - val_classes)}")
|
| 550 |
+
logger.info(f" Common to all: {len(common_classes)}")
|
| 551 |
+
|
| 552 |
+
# Create dataloaders
|
| 553 |
+
train_loader = DataLoader(
|
| 554 |
+
train_dataset, batch_size=args.batch_size, shuffle=True,
|
| 555 |
+
num_workers=args.num_workers, pin_memory=True
|
| 556 |
+
)
|
| 557 |
+
val_loader = DataLoader(
|
| 558 |
+
val_dataset, batch_size=args.batch_size, shuffle=False,
|
| 559 |
+
num_workers=args.num_workers, pin_memory=True
|
| 560 |
+
)
|
| 561 |
+
test_loader = DataLoader(
|
| 562 |
+
test_dataset, batch_size=args.batch_size, shuffle=False,
|
| 563 |
+
num_workers=args.num_workers, pin_memory=True
|
| 564 |
+
)
|
| 565 |
+
|
| 566 |
+
# Load model
|
| 567 |
+
logger.info("\nLoading model...")
|
| 568 |
+
bert = load_pretrained_bert(args.checkpoint, checkpoint_config, args.device)
|
| 569 |
+
|
| 570 |
+
num_classes = len(train_dataset.unique_labels)
|
| 571 |
+
model = GlycanClassifier(
|
| 572 |
+
bert, num_classes,
|
| 573 |
+
dropout=args.dropout,
|
| 574 |
+
freeze_layers=args.freeze_layers,
|
| 575 |
+
pooling_strategy=args.pooling_strategy,
|
| 576 |
+
).to(args.device)
|
| 577 |
+
logger.info(f" Pooling strategy: {args.pooling_strategy}")
|
| 578 |
+
|
| 579 |
+
total_params = sum(p.numel() for p in model.parameters())
|
| 580 |
+
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
|
| 581 |
+
logger.info(f" Total params: {total_params:,}")
|
| 582 |
+
logger.info(f" Trainable params: {trainable_params:,} ({trainable_params/total_params*100:.1f}%)")
|
| 583 |
+
|
| 584 |
+
# Setup training
|
| 585 |
+
criterion = nn.CrossEntropyLoss()
|
| 586 |
+
optimizer = AdamW(
|
| 587 |
+
filter(lambda p: p.requires_grad, model.parameters()),
|
| 588 |
+
lr=args.lr,
|
| 589 |
+
weight_decay=args.weight_decay
|
| 590 |
+
)
|
| 591 |
+
|
| 592 |
+
total_steps = len(train_loader) * args.epochs
|
| 593 |
+
scheduler = CosineAnnealingLR(optimizer, T_max=total_steps)
|
| 594 |
+
|
| 595 |
+
# Training loop
|
| 596 |
+
logger.info("\n" + "=" * 70)
|
| 597 |
+
logger.info("TRAINING")
|
| 598 |
+
logger.info("=" * 70)
|
| 599 |
+
|
| 600 |
+
best_val_mcc = -1
|
| 601 |
+
epochs_without_improvement = 0
|
| 602 |
+
history = []
|
| 603 |
+
|
| 604 |
+
for epoch in range(args.epochs):
|
| 605 |
+
logger.info(f"\nEpoch {epoch + 1}/{args.epochs}")
|
| 606 |
+
|
| 607 |
+
# Train
|
| 608 |
+
train_metrics = train_epoch(model, train_loader, optimizer, criterion, args.device, scheduler, dist_alpha=args.dist_alpha)
|
| 609 |
+
|
| 610 |
+
# Validate
|
| 611 |
+
val_metrics = evaluate(model, val_loader, criterion, args.device, num_classes, dist_alpha=args.dist_alpha)
|
| 612 |
+
|
| 613 |
+
logger.info(f" Train - Loss: {train_metrics['loss']:.4f}, Acc: {train_metrics['accuracy']:.4f}")
|
| 614 |
+
val_log = f" Val - Loss: {val_metrics['loss']:.4f}, Acc: {val_metrics['accuracy']:.4f}, "
|
| 615 |
+
val_log += f"F1: {val_metrics['f1_macro']:.4f}, MCC: {val_metrics['mcc']:.4f}"
|
| 616 |
+
if val_metrics['auroc'] is not None:
|
| 617 |
+
val_log += f", AUROC: {val_metrics['auroc']:.4f}"
|
| 618 |
+
logger.info(val_log)
|
| 619 |
+
|
| 620 |
+
history.append({
|
| 621 |
+
'epoch': epoch + 1,
|
| 622 |
+
'train_loss': train_metrics['loss'],
|
| 623 |
+
'train_acc': train_metrics['accuracy'],
|
| 624 |
+
'val_loss': val_metrics['loss'],
|
| 625 |
+
'val_acc': val_metrics['accuracy'],
|
| 626 |
+
'val_f1': val_metrics['f1_macro'],
|
| 627 |
+
'val_mcc': val_metrics['mcc'],
|
| 628 |
+
'val_auroc': val_metrics['auroc'],
|
| 629 |
+
'val_auprc': val_metrics['auprc'],
|
| 630 |
+
})
|
| 631 |
+
|
| 632 |
+
# Check for improvement
|
| 633 |
+
if val_metrics['mcc'] > best_val_mcc:
|
| 634 |
+
best_val_mcc = val_metrics['mcc']
|
| 635 |
+
epochs_without_improvement = 0
|
| 636 |
+
|
| 637 |
+
# Save best model
|
| 638 |
+
torch.save({
|
| 639 |
+
'epoch': epoch + 1,
|
| 640 |
+
'model_state_dict': model.state_dict(),
|
| 641 |
+
'optimizer_state_dict': optimizer.state_dict(),
|
| 642 |
+
'val_mcc': best_val_mcc,
|
| 643 |
+
'config': {
|
| 644 |
+
'task': args.task,
|
| 645 |
+
'num_classes': num_classes,
|
| 646 |
+
'classes': train_dataset.unique_labels,
|
| 647 |
+
}
|
| 648 |
+
}, os.path.join(args.output_dir, 'best_model.pt'))
|
| 649 |
+
|
| 650 |
+
logger.info(f" New best MCC: {best_val_mcc:.4f} (saved)")
|
| 651 |
+
else:
|
| 652 |
+
epochs_without_improvement += 1
|
| 653 |
+
logger.info(f" No improvement ({epochs_without_improvement}/{args.patience})")
|
| 654 |
+
|
| 655 |
+
# Early stopping
|
| 656 |
+
if epochs_without_improvement >= args.patience:
|
| 657 |
+
logger.info(f"\nEarly stopping at epoch {epoch + 1}")
|
| 658 |
+
break
|
| 659 |
+
|
| 660 |
+
# Load best model for testing
|
| 661 |
+
logger.info("\n" + "=" * 70)
|
| 662 |
+
logger.info("TESTING")
|
| 663 |
+
logger.info("=" * 70)
|
| 664 |
+
|
| 665 |
+
best_checkpoint = torch.load(os.path.join(args.output_dir, 'best_model.pt'))
|
| 666 |
+
model.load_state_dict(best_checkpoint['model_state_dict'])
|
| 667 |
+
|
| 668 |
+
test_metrics = evaluate(model, test_loader, criterion, args.device, num_classes)
|
| 669 |
+
|
| 670 |
+
logger.info(f"\nTest Results:")
|
| 671 |
+
logger.info(f" Accuracy: {test_metrics['accuracy']:.4f}")
|
| 672 |
+
logger.info(f" F1-Macro: {test_metrics['f1_macro']:.4f}")
|
| 673 |
+
logger.info(f" F1-Weighted: {test_metrics['f1_weighted']:.4f}")
|
| 674 |
+
logger.info(f" MCC: {test_metrics['mcc']:.4f}")
|
| 675 |
+
if test_metrics['auroc'] is not None:
|
| 676 |
+
logger.info(f" AUROC: {test_metrics['auroc']:.4f}")
|
| 677 |
+
if test_metrics['auprc'] is not None:
|
| 678 |
+
logger.info(f" AUPRC: {test_metrics['auprc']:.4f}")
|
| 679 |
+
|
| 680 |
+
# Save results
|
| 681 |
+
results = {
|
| 682 |
+
'task': args.task,
|
| 683 |
+
'filter_mode': args.filter_mode,
|
| 684 |
+
'num_classes': num_classes,
|
| 685 |
+
'classes': train_dataset.unique_labels,
|
| 686 |
+
'train_samples': len(train_dataset),
|
| 687 |
+
'val_samples': len(val_dataset),
|
| 688 |
+
'test_samples': len(test_dataset),
|
| 689 |
+
'best_epoch': best_checkpoint['epoch'],
|
| 690 |
+
'test_accuracy': test_metrics['accuracy'],
|
| 691 |
+
'test_f1_macro': test_metrics['f1_macro'],
|
| 692 |
+
'test_f1_weighted': test_metrics['f1_weighted'],
|
| 693 |
+
'test_mcc': test_metrics['mcc'],
|
| 694 |
+
'test_auroc': test_metrics['auroc'],
|
| 695 |
+
'test_auprc': test_metrics['auprc'],
|
| 696 |
+
'config': vars(args),
|
| 697 |
+
'history': history,
|
| 698 |
+
}
|
| 699 |
+
|
| 700 |
+
with open(os.path.join(args.output_dir, 'results.json'), 'w') as f:
|
| 701 |
+
json.dump(results, f, indent=2)
|
| 702 |
+
|
| 703 |
+
logger.info(f"\nResults saved to {args.output_dir}")
|
| 704 |
+
|
| 705 |
+
|
| 706 |
+
if __name__ == '__main__':
|
| 707 |
+
main()
|
| 708 |
+
|
code/bertint/bertint_v8.py
ADDED
|
@@ -0,0 +1,809 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Bertint V8 — Cross-Attention + Live Bertose Finetuning
|
| 3 |
+
|
| 4 |
+
Architecture:
|
| 5 |
+
GLYCAN: WURCS → BPE → Bertose (live, freeze layers 0-3) → [B, Lg, 768]
|
| 6 |
+
↓ proj(768→512)
|
| 7 |
+
PROTEIN: precomputed ESM-C → [B, Lp, 960] ↓
|
| 8 |
+
↓ proj(960→512) ↓
|
| 9 |
+
2× CrossAttentionBlock(d=512, 8 heads, FFN=1024) ↓
|
| 10 |
+
↓ ↓
|
| 11 |
+
SHARED mask-aware SWE(d=512, S=512, R=64) ↓
|
| 12 |
+
↓ ↓
|
| 13 |
+
[B, 512] [B, 512]
|
| 14 |
+
↓ element-wise product + sum
|
| 15 |
+
[B, 1024]
|
| 16 |
+
↓ MLP → binding score
|
| 17 |
+
|
| 18 |
+
Key changes from V7:
|
| 19 |
+
- Per-residue protein embeddings (not mean-pooled) for cross-attention
|
| 20 |
+
- CrossAttentionBlock: glycan tokens attend to protein residues and vice versa
|
| 21 |
+
- SWE pooling: variable-length → fixed-length (mask-aware, differentiable)
|
| 22 |
+
- Product + sum interaction (from Twin Peaks) instead of concat
|
| 23 |
+
|
| 24 |
+
Key changes from V3:
|
| 25 |
+
- Live Bertose forward pass (not frozen precomputed embeddings)
|
| 26 |
+
- ESM-C 300M (960-dim, not ESM-C 600M 1152-dim)
|
| 27 |
+
|
| 28 |
+
SWE, CrossAttention, and mask handling ported from V3 (Sessions 5-10).
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
import os
|
| 32 |
+
import sys
|
| 33 |
+
import math
|
| 34 |
+
from pathlib import Path
|
| 35 |
+
from typing import Dict, Optional, Tuple
|
| 36 |
+
|
| 37 |
+
import torch
|
| 38 |
+
import torch.nn as nn
|
| 39 |
+
import torch.nn.functional as F
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ============================================================================
|
| 43 |
+
# Bertose model imports (same as V7)
|
| 44 |
+
# ============================================================================
|
| 45 |
+
def _default_bertose_root() -> Path:
|
| 46 |
+
"""Resolve the Bertose source root without assuming a Nova-only path."""
|
| 47 |
+
env_root = os.environ.get("BERTOSE_ROOT") or os.environ.get("BERTOSE_REPO_ROOT")
|
| 48 |
+
if env_root:
|
| 49 |
+
return Path(env_root).expanduser().resolve()
|
| 50 |
+
|
| 51 |
+
here = Path(__file__).resolve()
|
| 52 |
+
for parent in here.parents:
|
| 53 |
+
if (parent / "bert_training_v4").exists() and (parent / "model").exists():
|
| 54 |
+
return parent
|
| 55 |
+
|
| 56 |
+
return Path("/work/ratul1/supantha/glycan-SD-VS/bert_training_v3/v3.1_cluster_training")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
BERTOSE_ROOT = _default_bertose_root()
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _ensure_bertose_imports():
|
| 63 |
+
"""Add Bertose source directories to sys.path if not already present."""
|
| 64 |
+
roots = [
|
| 65 |
+
str(BERTOSE_ROOT),
|
| 66 |
+
str(BERTOSE_ROOT / "bert_training_v4"),
|
| 67 |
+
]
|
| 68 |
+
for root in roots:
|
| 69 |
+
if root not in sys.path:
|
| 70 |
+
sys.path.insert(0, root)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def load_bertose_config():
|
| 74 |
+
"""Create Bertose config matching the V5b checkpoint."""
|
| 75 |
+
_ensure_bertose_imports()
|
| 76 |
+
from model.multimodal_glycan_bert_v3 import MultimodalGlycanBERTConfig
|
| 77 |
+
|
| 78 |
+
return MultimodalGlycanBERTConfig(
|
| 79 |
+
seq_vocab_size=2200,
|
| 80 |
+
use_cnn_frontend=True,
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def load_bertose_encoder(
|
| 85 |
+
checkpoint_path: str, freeze_layers: int = 4
|
| 86 |
+
):
|
| 87 |
+
"""
|
| 88 |
+
Load Bertose sequence encoder with pretrained weights.
|
| 89 |
+
|
| 90 |
+
Args:
|
| 91 |
+
checkpoint_path: Path to pretrained Bertose checkpoint.
|
| 92 |
+
freeze_layers: Number of transformer layers to freeze (0-indexed).
|
| 93 |
+
|
| 94 |
+
Returns:
|
| 95 |
+
Tuple of (bertose_config, seq_embeddings, seq_layers).
|
| 96 |
+
"""
|
| 97 |
+
_ensure_bertose_imports()
|
| 98 |
+
from model.multimodal_glycan_bert_v3 import (
|
| 99 |
+
MultimodalGlycanBERT,
|
| 100 |
+
MultimodalGlycanBERTConfig,
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
# Load checkpoint
|
| 104 |
+
ckpt = torch.load(checkpoint_path, map_location="cpu")
|
| 105 |
+
state_dict = ckpt.get("model_state_dict", ckpt)
|
| 106 |
+
|
| 107 |
+
# Infer vocab size and max position embeddings from checkpoint
|
| 108 |
+
vocab_size = state_dict["seq_embeddings.token_embeddings.weight"].shape[0]
|
| 109 |
+
max_pos = state_dict["seq_embeddings.position_embeddings.weight"].shape[0]
|
| 110 |
+
config = MultimodalGlycanBERTConfig(
|
| 111 |
+
seq_vocab_size=vocab_size,
|
| 112 |
+
seq_max_length=max_pos,
|
| 113 |
+
use_cnn_frontend=True,
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
# Instantiate full model, then extract sequence encoder
|
| 117 |
+
model = MultimodalGlycanBERT(config)
|
| 118 |
+
missing, unexpected = model.load_state_dict(state_dict, strict=False)
|
| 119 |
+
loaded = len(state_dict) - len(unexpected)
|
| 120 |
+
print(f" Loaded {loaded}/{len(state_dict)} pretrained weight tensors")
|
| 121 |
+
print(f" ({len(missing)} missing in checkpoint, {len(unexpected)} unexpected)")
|
| 122 |
+
|
| 123 |
+
seq_embeddings = model.seq_embeddings
|
| 124 |
+
seq_layers = model.seq_layers
|
| 125 |
+
|
| 126 |
+
# Freeze embedding layer + first N transformer layers
|
| 127 |
+
for param in seq_embeddings.parameters():
|
| 128 |
+
param.requires_grad = False
|
| 129 |
+
for i in range(min(freeze_layers, len(seq_layers))):
|
| 130 |
+
for param in seq_layers[i].parameters():
|
| 131 |
+
param.requires_grad = False
|
| 132 |
+
|
| 133 |
+
trainable = sum(
|
| 134 |
+
p.numel() for p in seq_embeddings.parameters() if p.requires_grad
|
| 135 |
+
)
|
| 136 |
+
trainable += sum(
|
| 137 |
+
p.numel()
|
| 138 |
+
for layer in seq_layers
|
| 139 |
+
for p in layer.parameters()
|
| 140 |
+
if p.requires_grad
|
| 141 |
+
)
|
| 142 |
+
total = sum(p.numel() for p in seq_embeddings.parameters())
|
| 143 |
+
total += sum(
|
| 144 |
+
p.numel() for layer in seq_layers for p in layer.parameters()
|
| 145 |
+
)
|
| 146 |
+
print(
|
| 147 |
+
f" Bertose encoder: {total:,} params total, "
|
| 148 |
+
f"{trainable:,} trainable (frozen layers 0-{freeze_layers - 1})"
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
return config, seq_embeddings, seq_layers
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
# ============================================================================
|
| 155 |
+
# Differentiable Interpolation (from V3, Sessions 5-6)
|
| 156 |
+
# ============================================================================
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def differentiable_interp1d(
|
| 160 |
+
x: torch.Tensor, y: torch.Tensor, xnew: torch.Tensor
|
| 161 |
+
) -> torch.Tensor:
|
| 162 |
+
"""
|
| 163 |
+
Fully differentiable 1D linear interpolation.
|
| 164 |
+
|
| 165 |
+
Gradients flow through y (values) back to theta projection and
|
| 166 |
+
earlier layers. The original Interp1d.backward from Twin Peaks
|
| 167 |
+
only returned gradients for xnew (query coords), NOT for y —
|
| 168 |
+
killing 83% of gradient flow. Fixed in Session 5.
|
| 169 |
+
|
| 170 |
+
Args:
|
| 171 |
+
x: [B, N] sorted input coordinates (detached)
|
| 172 |
+
y: [B, N] values at x positions (REQUIRES grad flow!)
|
| 173 |
+
xnew: [B, R] query coordinates
|
| 174 |
+
|
| 175 |
+
Returns:
|
| 176 |
+
[B, R] interpolated values
|
| 177 |
+
"""
|
| 178 |
+
n_pts = x.shape[1]
|
| 179 |
+
|
| 180 |
+
# Find interpolation indices
|
| 181 |
+
ind = torch.searchsorted(
|
| 182 |
+
x.contiguous().detach(), xnew.contiguous().detach()
|
| 183 |
+
)
|
| 184 |
+
ind = ind.clamp(1, n_pts - 1)
|
| 185 |
+
|
| 186 |
+
# Gather neighbor values — preserves gradient flow through y
|
| 187 |
+
x_lo = torch.gather(x, 1, ind - 1)
|
| 188 |
+
x_hi = torch.gather(x, 1, ind)
|
| 189 |
+
y_lo = torch.gather(y, 1, ind - 1)
|
| 190 |
+
y_hi = torch.gather(y, 1, ind)
|
| 191 |
+
|
| 192 |
+
# Linear interpolation weights
|
| 193 |
+
denom = (x_hi - x_lo).clamp(min=1e-8)
|
| 194 |
+
alpha = ((xnew - x_lo) / denom).clamp(0, 1)
|
| 195 |
+
|
| 196 |
+
# Interpolated value — fully differentiable w.r.t. y_lo and y_hi
|
| 197 |
+
return y_lo + alpha * (y_hi - y_lo)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
# ============================================================================
|
| 201 |
+
# SWE Pooling (from V3, mask-aware fixes from Sessions 6-8)
|
| 202 |
+
# ============================================================================
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
class SWE_Pooling(nn.Module):
|
| 206 |
+
"""
|
| 207 |
+
Sliced-Wasserstein Embedding pooling.
|
| 208 |
+
Maps variable-length token embeddings [B, L, d_in] => [B, num_slices].
|
| 209 |
+
|
| 210 |
+
From Twin Peaks, with mask-aware sorting (Session 6) and
|
| 211 |
+
degenerate-sample handling (Session 8).
|
| 212 |
+
"""
|
| 213 |
+
|
| 214 |
+
def __init__(
|
| 215 |
+
self,
|
| 216 |
+
d_in: int,
|
| 217 |
+
num_slices: int,
|
| 218 |
+
num_ref_points: int,
|
| 219 |
+
freeze_swe: bool = False,
|
| 220 |
+
):
|
| 221 |
+
super().__init__()
|
| 222 |
+
self.num_slices = num_slices
|
| 223 |
+
self.num_ref_points = num_ref_points
|
| 224 |
+
|
| 225 |
+
# Learnable reference distribution
|
| 226 |
+
ref = torch.linspace(-1, 1, num_ref_points).unsqueeze(1).repeat(
|
| 227 |
+
1, num_slices
|
| 228 |
+
)
|
| 229 |
+
self.reference = nn.Parameter(ref, requires_grad=not freeze_swe)
|
| 230 |
+
|
| 231 |
+
# Projection directions (weight-normalized)
|
| 232 |
+
self.theta = nn.utils.weight_norm(
|
| 233 |
+
nn.Linear(d_in, num_slices, bias=False), dim=0
|
| 234 |
+
)
|
| 235 |
+
self.theta.weight_g.data = torch.ones_like(self.theta.weight_g.data)
|
| 236 |
+
self.theta.weight_g.requires_grad = False
|
| 237 |
+
nn.init.normal_(self.theta.weight_v)
|
| 238 |
+
|
| 239 |
+
# Weighted aggregation over reference points
|
| 240 |
+
self.weight = nn.Linear(num_ref_points, 1, bias=False)
|
| 241 |
+
|
| 242 |
+
if freeze_swe:
|
| 243 |
+
self.theta.weight_v.requires_grad = False
|
| 244 |
+
self.reference.requires_grad = False
|
| 245 |
+
|
| 246 |
+
def forward(
|
| 247 |
+
self, x: torch.Tensor, mask: Optional[torch.Tensor] = None
|
| 248 |
+
) -> torch.Tensor:
|
| 249 |
+
"""
|
| 250 |
+
Args:
|
| 251 |
+
x: [B, L, d_in] token embeddings
|
| 252 |
+
mask: [B, L] attention mask (1=valid, 0=padding)
|
| 253 |
+
|
| 254 |
+
Returns:
|
| 255 |
+
[B, num_slices] fixed-length representation
|
| 256 |
+
"""
|
| 257 |
+
batch_size, seq_len, _ = x.shape
|
| 258 |
+
device = x.device
|
| 259 |
+
|
| 260 |
+
# Degenerate: single token → just project
|
| 261 |
+
if seq_len == 1:
|
| 262 |
+
x_slices = self.theta(x)
|
| 263 |
+
return x_slices.squeeze(1)
|
| 264 |
+
|
| 265 |
+
# Project onto learned directions
|
| 266 |
+
x_slices = self.theta(x) # [B, L, num_slices]
|
| 267 |
+
|
| 268 |
+
# MASK-AWARE SORTING: set padding → -inf so they sort to bottom
|
| 269 |
+
if mask is not None:
|
| 270 |
+
mask_exp = mask.unsqueeze(-1).expand_as(x_slices)
|
| 271 |
+
x_slices = x_slices.masked_fill(mask_exp == 0, float("-inf"))
|
| 272 |
+
|
| 273 |
+
x_sorted, _ = torch.sort(x_slices, dim=1)
|
| 274 |
+
|
| 275 |
+
# Strip padding from sorted array
|
| 276 |
+
if mask is not None:
|
| 277 |
+
valid_counts = mask.sum(dim=1).long()
|
| 278 |
+
degenerate_mask = valid_counts < 2
|
| 279 |
+
safe_counts = valid_counts.clamp(min=2)
|
| 280 |
+
max_valid = safe_counts.max().item()
|
| 281 |
+
|
| 282 |
+
# Vectorized gather: take last safe_count values
|
| 283 |
+
starts = (seq_len - safe_counts).unsqueeze(1)
|
| 284 |
+
offsets = torch.arange(max_valid, device=device).unsqueeze(0)
|
| 285 |
+
raw_idx = starts + offsets
|
| 286 |
+
gather_idx = raw_idx.clamp(max=seq_len - 1).unsqueeze(-1).expand(
|
| 287 |
+
batch_size, max_valid, self.num_slices
|
| 288 |
+
)
|
| 289 |
+
x_sorted = torch.gather(x_sorted, 1, gather_idx)
|
| 290 |
+
|
| 291 |
+
# Replace -inf with 0.0 for degenerate samples
|
| 292 |
+
x_sorted = x_sorted.masked_fill(
|
| 293 |
+
x_sorted == float("-inf"), 0.0
|
| 294 |
+
)
|
| 295 |
+
n_eff = max_valid
|
| 296 |
+
else:
|
| 297 |
+
degenerate_mask = None
|
| 298 |
+
n_eff = seq_len
|
| 299 |
+
|
| 300 |
+
# Interpolate to fixed reference grid
|
| 301 |
+
x_coord = (
|
| 302 |
+
torch.linspace(0, 1, n_eff, device=device)
|
| 303 |
+
.unsqueeze(0)
|
| 304 |
+
.expand(batch_size * self.num_slices, -1)
|
| 305 |
+
)
|
| 306 |
+
x_flat = x_sorted.permute(0, 2, 1).reshape(
|
| 307 |
+
batch_size * self.num_slices, n_eff
|
| 308 |
+
)
|
| 309 |
+
xnew = (
|
| 310 |
+
torch.linspace(0, 1, self.num_ref_points, device=device)
|
| 311 |
+
.unsqueeze(0)
|
| 312 |
+
.expand(batch_size * self.num_slices, -1)
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
y_intp = differentiable_interp1d(x_coord, x_flat, xnew)
|
| 316 |
+
x_interp = y_intp.view(
|
| 317 |
+
batch_size, self.num_slices, self.num_ref_points
|
| 318 |
+
).permute(0, 2, 1)
|
| 319 |
+
|
| 320 |
+
# Compare with reference distribution
|
| 321 |
+
r_expanded = self.reference.expand_as(x_interp)
|
| 322 |
+
embeddings = (r_expanded - x_interp).permute(0, 2, 1)
|
| 323 |
+
|
| 324 |
+
# Weighted aggregation → [B, num_slices]
|
| 325 |
+
weighted = self.weight(embeddings).sum(dim=-1)
|
| 326 |
+
|
| 327 |
+
# Zero out degenerate samples
|
| 328 |
+
if degenerate_mask is not None and degenerate_mask.any():
|
| 329 |
+
weighted = weighted.masked_fill(
|
| 330 |
+
degenerate_mask.unsqueeze(-1), 0.0
|
| 331 |
+
)
|
| 332 |
+
|
| 333 |
+
return weighted
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
# ============================================================================
|
| 337 |
+
# Cross-Attention Block (from V3, NaN guard from Session 9)
|
| 338 |
+
# ============================================================================
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
class CrossAttentionBlock(nn.Module):
|
| 342 |
+
"""
|
| 343 |
+
Bidirectional cross-attention between glycan and protein tokens.
|
| 344 |
+
|
| 345 |
+
Glycan tokens attend to protein residues (Q=glycan, KV=protein)
|
| 346 |
+
Protein residues attend to glycan tokens (Q=protein, KV=glycan)
|
| 347 |
+
|
| 348 |
+
Includes NaN guard for all-masked keys (Session 9 fix) and
|
| 349 |
+
padding-position zeroing (Session 7 fix).
|
| 350 |
+
"""
|
| 351 |
+
|
| 352 |
+
def __init__(
|
| 353 |
+
self,
|
| 354 |
+
d_model: int,
|
| 355 |
+
num_heads: int,
|
| 356 |
+
ffn_dim: int,
|
| 357 |
+
dropout: float = 0.1,
|
| 358 |
+
):
|
| 359 |
+
super().__init__()
|
| 360 |
+
|
| 361 |
+
# Glycan → Protein cross-attention
|
| 362 |
+
self.glycan_cross_attn = nn.MultiheadAttention(
|
| 363 |
+
d_model, num_heads, dropout=dropout, batch_first=True
|
| 364 |
+
)
|
| 365 |
+
self.glycan_norm1 = nn.LayerNorm(d_model)
|
| 366 |
+
self.glycan_ffn = nn.Sequential(
|
| 367 |
+
nn.Linear(d_model, ffn_dim),
|
| 368 |
+
nn.GELU(),
|
| 369 |
+
nn.Dropout(dropout),
|
| 370 |
+
nn.Linear(ffn_dim, d_model),
|
| 371 |
+
nn.Dropout(dropout),
|
| 372 |
+
)
|
| 373 |
+
self.glycan_norm2 = nn.LayerNorm(d_model)
|
| 374 |
+
|
| 375 |
+
# Protein → Glycan cross-attention
|
| 376 |
+
self.protein_cross_attn = nn.MultiheadAttention(
|
| 377 |
+
d_model, num_heads, dropout=dropout, batch_first=True
|
| 378 |
+
)
|
| 379 |
+
self.protein_norm1 = nn.LayerNorm(d_model)
|
| 380 |
+
self.protein_ffn = nn.Sequential(
|
| 381 |
+
nn.Linear(d_model, ffn_dim),
|
| 382 |
+
nn.GELU(),
|
| 383 |
+
nn.Dropout(dropout),
|
| 384 |
+
nn.Linear(ffn_dim, d_model),
|
| 385 |
+
nn.Dropout(dropout),
|
| 386 |
+
)
|
| 387 |
+
self.protein_norm2 = nn.LayerNorm(d_model)
|
| 388 |
+
|
| 389 |
+
def forward(
|
| 390 |
+
self,
|
| 391 |
+
glycan: torch.Tensor,
|
| 392 |
+
protein: torch.Tensor,
|
| 393 |
+
glycan_mask: Optional[torch.Tensor] = None,
|
| 394 |
+
protein_mask: Optional[torch.Tensor] = None,
|
| 395 |
+
return_attention: bool = False,
|
| 396 |
+
) -> Tuple[torch.Tensor, torch.Tensor]:
|
| 397 |
+
"""
|
| 398 |
+
Returns updated (glycan, protein) enriched with cross-modal info.
|
| 399 |
+
|
| 400 |
+
NaN guard: nn.MultiheadAttention produces NaN when ALL keys
|
| 401 |
+
are masked. We replace NaN→0 so residual preserves query.
|
| 402 |
+
"""
|
| 403 |
+
# Convert to key_padding_mask (True = padded)
|
| 404 |
+
g_key_pad = (~glycan_mask.bool()) if glycan_mask is not None else None
|
| 405 |
+
p_key_pad = (
|
| 406 |
+
(~protein_mask.bool()) if protein_mask is not None else None
|
| 407 |
+
)
|
| 408 |
+
|
| 409 |
+
# Glycan attends to protein
|
| 410 |
+
g_cross, g_attn_weights = self.glycan_cross_attn(
|
| 411 |
+
query=glycan, key=protein, value=protein,
|
| 412 |
+
key_padding_mask=p_key_pad,
|
| 413 |
+
need_weights=return_attention,
|
| 414 |
+
average_attn_weights=False,
|
| 415 |
+
)
|
| 416 |
+
g_cross = torch.nan_to_num(g_cross, nan=0.0)
|
| 417 |
+
glycan = self.glycan_norm1(glycan + g_cross)
|
| 418 |
+
glycan = self.glycan_norm2(glycan + self.glycan_ffn(glycan))
|
| 419 |
+
if glycan_mask is not None:
|
| 420 |
+
glycan = glycan * glycan_mask.unsqueeze(-1)
|
| 421 |
+
|
| 422 |
+
# Protein attends to glycan
|
| 423 |
+
p_cross, p_attn_weights = self.protein_cross_attn(
|
| 424 |
+
query=protein, key=glycan, value=glycan,
|
| 425 |
+
key_padding_mask=g_key_pad,
|
| 426 |
+
need_weights=return_attention,
|
| 427 |
+
average_attn_weights=False,
|
| 428 |
+
)
|
| 429 |
+
p_cross = torch.nan_to_num(p_cross, nan=0.0)
|
| 430 |
+
protein = self.protein_norm1(protein + p_cross)
|
| 431 |
+
protein = self.protein_norm2(protein + self.protein_ffn(protein))
|
| 432 |
+
if protein_mask is not None:
|
| 433 |
+
protein = protein * protein_mask.unsqueeze(-1)
|
| 434 |
+
|
| 435 |
+
if return_attention:
|
| 436 |
+
attn_dict = {
|
| 437 |
+
"glycan_to_protein": g_attn_weights,
|
| 438 |
+
"protein_to_glycan": p_attn_weights,
|
| 439 |
+
}
|
| 440 |
+
return glycan, protein, attn_dict
|
| 441 |
+
|
| 442 |
+
return glycan, protein
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
# ============================================================================
|
| 446 |
+
# Bertint V8 Model
|
| 447 |
+
# ============================================================================
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
class BertintV8(nn.Module):
|
| 451 |
+
"""
|
| 452 |
+
Glycan-protein interaction predictor with cross-attention.
|
| 453 |
+
|
| 454 |
+
Glycan: Live Bertose (partially frozen) → per-token [B, Lg, 768]
|
| 455 |
+
Protein: Precomputed ESM-C per-residue [B, Lp, 960]
|
| 456 |
+
Cross-attention: 2 bidirectional layers in shared 512-dim space
|
| 457 |
+
SWE: Variable-length → fixed [B, 512] for each side
|
| 458 |
+
Interaction: product + sum → MLP → scalar
|
| 459 |
+
"""
|
| 460 |
+
|
| 461 |
+
def __init__(
|
| 462 |
+
self,
|
| 463 |
+
seq_embeddings: nn.Module,
|
| 464 |
+
seq_layers: nn.ModuleList,
|
| 465 |
+
glycan_dim: int = 768,
|
| 466 |
+
protein_dim: int = 960,
|
| 467 |
+
shared_dim: int = 512,
|
| 468 |
+
num_cross_layers: int = 2,
|
| 469 |
+
num_heads: int = 8,
|
| 470 |
+
ffn_dim: int = 1024,
|
| 471 |
+
swe_slices: int = 512,
|
| 472 |
+
swe_ref_points: int = 64,
|
| 473 |
+
head_hidden: int = 256,
|
| 474 |
+
dropout: float = 0.1,
|
| 475 |
+
separate_swe: bool = False,
|
| 476 |
+
pooling_mode: str = "swe",
|
| 477 |
+
interaction_mode: str = "product_sum",
|
| 478 |
+
use_cross_attention: bool = True,
|
| 479 |
+
):
|
| 480 |
+
"""
|
| 481 |
+
Args:
|
| 482 |
+
seq_embeddings: Pretrained Bertose embedding layer.
|
| 483 |
+
seq_layers: Pretrained Bertose transformer layers.
|
| 484 |
+
glycan_dim: Bertose output dimension (768).
|
| 485 |
+
protein_dim: ESM-C per-residue dimension (960).
|
| 486 |
+
shared_dim: Shared space for cross-attention (512).
|
| 487 |
+
num_cross_layers: Number of cross-attention blocks.
|
| 488 |
+
num_heads: Attention heads per block.
|
| 489 |
+
ffn_dim: FFN hidden dim in cross-attention.
|
| 490 |
+
swe_slices: Number of SWE projection directions.
|
| 491 |
+
swe_ref_points: Number of SWE reference distribution points.
|
| 492 |
+
head_hidden: MLP head hidden dimension.
|
| 493 |
+
dropout: Dropout rate.
|
| 494 |
+
separate_swe: If True, use independent SWE modules.
|
| 495 |
+
pooling_mode: 'swe', 'mean', or 'joint_swe'.
|
| 496 |
+
interaction_mode: 'product_sum' or 'concat'.
|
| 497 |
+
use_cross_attention: If False, skip cross-attention.
|
| 498 |
+
"""
|
| 499 |
+
super().__init__()
|
| 500 |
+
|
| 501 |
+
self.separate_swe = separate_swe
|
| 502 |
+
self.pooling_mode = pooling_mode
|
| 503 |
+
self.interaction_mode = interaction_mode
|
| 504 |
+
self.use_cross_attention = use_cross_attention
|
| 505 |
+
|
| 506 |
+
print(f" Architecture config:")
|
| 507 |
+
print(f" cross_attention={use_cross_attention}")
|
| 508 |
+
print(f" pooling_mode={pooling_mode}")
|
| 509 |
+
print(f" interaction_mode={interaction_mode}")
|
| 510 |
+
|
| 511 |
+
# === Bertose sequence encoder (partially frozen) ===
|
| 512 |
+
self.seq_embeddings = seq_embeddings
|
| 513 |
+
self.seq_layers = seq_layers
|
| 514 |
+
|
| 515 |
+
# === Projection to shared space ===
|
| 516 |
+
self.glycan_proj = nn.Sequential(
|
| 517 |
+
nn.Linear(glycan_dim, shared_dim),
|
| 518 |
+
nn.LayerNorm(shared_dim),
|
| 519 |
+
)
|
| 520 |
+
self.protein_proj = nn.Sequential(
|
| 521 |
+
nn.Linear(protein_dim, shared_dim),
|
| 522 |
+
nn.LayerNorm(shared_dim),
|
| 523 |
+
)
|
| 524 |
+
|
| 525 |
+
# === Cross-attention stack (optional) ===
|
| 526 |
+
if use_cross_attention:
|
| 527 |
+
self.cross_attention = nn.ModuleList([
|
| 528 |
+
CrossAttentionBlock(
|
| 529 |
+
d_model=shared_dim,
|
| 530 |
+
num_heads=num_heads,
|
| 531 |
+
ffn_dim=ffn_dim,
|
| 532 |
+
dropout=dropout,
|
| 533 |
+
)
|
| 534 |
+
for _ in range(num_cross_layers)
|
| 535 |
+
])
|
| 536 |
+
else:
|
| 537 |
+
self.cross_attention = nn.ModuleList()
|
| 538 |
+
|
| 539 |
+
# === Pooling ===
|
| 540 |
+
if pooling_mode == "swe":
|
| 541 |
+
if separate_swe:
|
| 542 |
+
self.swe_glycan = SWE_Pooling(
|
| 543 |
+
d_in=shared_dim, num_slices=swe_slices,
|
| 544 |
+
num_ref_points=swe_ref_points,
|
| 545 |
+
)
|
| 546 |
+
self.swe_protein = SWE_Pooling(
|
| 547 |
+
d_in=shared_dim, num_slices=swe_slices,
|
| 548 |
+
num_ref_points=swe_ref_points,
|
| 549 |
+
)
|
| 550 |
+
pool_out_dim = swe_slices
|
| 551 |
+
else:
|
| 552 |
+
self.swe = SWE_Pooling(
|
| 553 |
+
d_in=shared_dim, num_slices=swe_slices,
|
| 554 |
+
num_ref_points=swe_ref_points,
|
| 555 |
+
)
|
| 556 |
+
pool_out_dim = swe_slices
|
| 557 |
+
elif pooling_mode == "mean":
|
| 558 |
+
pool_out_dim = shared_dim
|
| 559 |
+
elif pooling_mode == "joint_swe":
|
| 560 |
+
self.swe_joint = SWE_Pooling(
|
| 561 |
+
d_in=shared_dim, num_slices=swe_slices,
|
| 562 |
+
num_ref_points=swe_ref_points,
|
| 563 |
+
)
|
| 564 |
+
pool_out_dim = swe_slices
|
| 565 |
+
|
| 566 |
+
# === Regression head ===
|
| 567 |
+
if pooling_mode == "joint_swe":
|
| 568 |
+
head_input_dim = pool_out_dim
|
| 569 |
+
else:
|
| 570 |
+
head_input_dim = 2 * pool_out_dim
|
| 571 |
+
|
| 572 |
+
self.head = nn.Sequential(
|
| 573 |
+
nn.Linear(head_input_dim, head_hidden),
|
| 574 |
+
nn.GELU(),
|
| 575 |
+
nn.Dropout(dropout),
|
| 576 |
+
nn.Linear(head_hidden, head_hidden),
|
| 577 |
+
nn.GELU(),
|
| 578 |
+
nn.Dropout(dropout),
|
| 579 |
+
nn.Linear(head_hidden, 1),
|
| 580 |
+
)
|
| 581 |
+
|
| 582 |
+
# Initialize (skip SWE weight-normed params)
|
| 583 |
+
self.apply(self._init_weights)
|
| 584 |
+
self._count_params()
|
| 585 |
+
|
| 586 |
+
def _init_weights(self, module: nn.Module) -> None:
|
| 587 |
+
"""Xavier init for Linear, skip weight-normed SWE modules."""
|
| 588 |
+
if isinstance(module, nn.Linear):
|
| 589 |
+
if hasattr(module, "weight_v"):
|
| 590 |
+
return # Preserve SWE initialization
|
| 591 |
+
nn.init.xavier_uniform_(module.weight)
|
| 592 |
+
if module.bias is not None:
|
| 593 |
+
nn.init.zeros_(module.bias)
|
| 594 |
+
elif isinstance(module, nn.LayerNorm):
|
| 595 |
+
nn.init.ones_(module.weight)
|
| 596 |
+
nn.init.zeros_(module.bias)
|
| 597 |
+
|
| 598 |
+
def _count_params(self) -> None:
|
| 599 |
+
"""Log parameter counts."""
|
| 600 |
+
total = sum(p.numel() for p in self.parameters())
|
| 601 |
+
trainable = sum(
|
| 602 |
+
p.numel() for p in self.parameters() if p.requires_grad
|
| 603 |
+
)
|
| 604 |
+
print(f"BertintV8: {total:,} total, {trainable:,} trainable")
|
| 605 |
+
|
| 606 |
+
def _masked_mean_pool(self, x, mask):
|
| 607 |
+
"""Masked mean pooling: average valid tokens only."""
|
| 608 |
+
mask_expanded = mask.unsqueeze(-1)
|
| 609 |
+
x_masked = x * mask_expanded
|
| 610 |
+
summed = x_masked.sum(dim=1)
|
| 611 |
+
counts = mask.sum(dim=1, keepdim=True).clamp(min=1)
|
| 612 |
+
return summed / counts
|
| 613 |
+
|
| 614 |
+
def forward(
|
| 615 |
+
self,
|
| 616 |
+
token_ids: torch.Tensor,
|
| 617 |
+
attention_mask: torch.Tensor,
|
| 618 |
+
branch_depths: torch.Tensor,
|
| 619 |
+
linkage_types: torch.Tensor,
|
| 620 |
+
protein_emb: torch.Tensor,
|
| 621 |
+
protein_mask: torch.Tensor,
|
| 622 |
+
log_conc: Optional[torch.Tensor] = None,
|
| 623 |
+
has_conc: Optional[torch.Tensor] = None,
|
| 624 |
+
return_attention: bool = False,
|
| 625 |
+
) -> torch.Tensor:
|
| 626 |
+
"""
|
| 627 |
+
Forward pass with cross-attention.
|
| 628 |
+
|
| 629 |
+
Args:
|
| 630 |
+
token_ids: [B, Lg] BPE token IDs.
|
| 631 |
+
attention_mask: [B, Lg] glycan attention mask (1=valid, 0=pad).
|
| 632 |
+
branch_depths: [B, Lg] branch depth per token.
|
| 633 |
+
linkage_types: [B, Lg] linkage type per token.
|
| 634 |
+
protein_emb: [B, Lp, protein_dim] per-residue ESM-C embeddings.
|
| 635 |
+
protein_mask: [B, Lp] protein attention mask (1=valid, 0=pad).
|
| 636 |
+
|
| 637 |
+
Returns:
|
| 638 |
+
[B] binding score predictions.
|
| 639 |
+
"""
|
| 640 |
+
# === 1. Bertose forward: per-token embeddings ===
|
| 641 |
+
x = self.seq_embeddings(token_ids, branch_depths, linkage_types)
|
| 642 |
+
for layer in self.seq_layers:
|
| 643 |
+
x = layer(x, attention_mask)
|
| 644 |
+
# x: [B, Lg, 768] — all tokens (not just CLS!)
|
| 645 |
+
|
| 646 |
+
# Glycan mask: use the attention_mask from BPE tokenizer
|
| 647 |
+
glycan_mask = attention_mask # [B, Lg], 1=valid, 0=pad
|
| 648 |
+
|
| 649 |
+
# === 2. Project to shared space ===
|
| 650 |
+
glycan = self.glycan_proj(x) # [B, Lg, 512]
|
| 651 |
+
protein = self.protein_proj(protein_emb) # [B, Lp, 512]
|
| 652 |
+
|
| 653 |
+
# Zero padding positions after projection (Session 7 fix:
|
| 654 |
+
# LayerNorm bias produces non-trivial values at padding)
|
| 655 |
+
glycan = glycan * glycan_mask.unsqueeze(-1)
|
| 656 |
+
protein = protein * protein_mask.unsqueeze(-1)
|
| 657 |
+
|
| 658 |
+
# === 3. Cross-attention (optional) ===
|
| 659 |
+
all_attention_maps = []
|
| 660 |
+
if self.use_cross_attention:
|
| 661 |
+
for cross_layer in self.cross_attention:
|
| 662 |
+
if return_attention:
|
| 663 |
+
glycan, protein, attn_dict = cross_layer(
|
| 664 |
+
glycan, protein, glycan_mask, protein_mask,
|
| 665 |
+
return_attention=True,
|
| 666 |
+
)
|
| 667 |
+
all_attention_maps.append(attn_dict)
|
| 668 |
+
else:
|
| 669 |
+
glycan, protein = cross_layer(
|
| 670 |
+
glycan, protein, glycan_mask, protein_mask
|
| 671 |
+
)
|
| 672 |
+
|
| 673 |
+
# === 4. Pooling ===
|
| 674 |
+
if self.pooling_mode == "joint_swe":
|
| 675 |
+
joint_tokens = torch.cat([glycan, protein], dim=1)
|
| 676 |
+
joint_mask = torch.cat([glycan_mask, protein_mask], dim=1)
|
| 677 |
+
pooled = self.swe_joint(joint_tokens, joint_mask)
|
| 678 |
+
return self.head(pooled).squeeze(-1)
|
| 679 |
+
|
| 680 |
+
if self.pooling_mode == "swe":
|
| 681 |
+
if self.separate_swe:
|
| 682 |
+
glycan_pooled = self.swe_glycan(glycan, glycan_mask)
|
| 683 |
+
protein_pooled = self.swe_protein(protein, protein_mask)
|
| 684 |
+
else:
|
| 685 |
+
glycan_pooled = self.swe(glycan, glycan_mask)
|
| 686 |
+
protein_pooled = self.swe(protein, protein_mask)
|
| 687 |
+
elif self.pooling_mode == "mean":
|
| 688 |
+
glycan_pooled = self._masked_mean_pool(glycan, glycan_mask)
|
| 689 |
+
protein_pooled = self._masked_mean_pool(protein, protein_mask)
|
| 690 |
+
|
| 691 |
+
# === 5. Interaction ===
|
| 692 |
+
if self.interaction_mode == "product_sum":
|
| 693 |
+
interaction = torch.cat([
|
| 694 |
+
glycan_pooled * protein_pooled,
|
| 695 |
+
glycan_pooled + protein_pooled,
|
| 696 |
+
], dim=-1)
|
| 697 |
+
elif self.interaction_mode == "concat":
|
| 698 |
+
interaction = torch.cat([
|
| 699 |
+
glycan_pooled, protein_pooled,
|
| 700 |
+
], dim=-1)
|
| 701 |
+
|
| 702 |
+
# === 6. Predict binding score ===
|
| 703 |
+
out = self.head(interaction).squeeze(-1)
|
| 704 |
+
if return_attention and all_attention_maps:
|
| 705 |
+
return out, all_attention_maps
|
| 706 |
+
return out
|
| 707 |
+
|
| 708 |
+
|
| 709 |
+
# ============================================================================
|
| 710 |
+
# Loss (same as V7 — simple MSE)
|
| 711 |
+
# ============================================================================
|
| 712 |
+
|
| 713 |
+
|
| 714 |
+
class BertintV8Loss(nn.Module):
|
| 715 |
+
"""MSE loss for regression."""
|
| 716 |
+
|
| 717 |
+
def __init__(self):
|
| 718 |
+
super().__init__()
|
| 719 |
+
self.mse = nn.MSELoss()
|
| 720 |
+
|
| 721 |
+
def forward(
|
| 722 |
+
self, pred: torch.Tensor, target: torch.Tensor
|
| 723 |
+
) -> torch.Tensor:
|
| 724 |
+
"""Compute MSE loss."""
|
| 725 |
+
return self.mse(pred, target)
|
| 726 |
+
|
| 727 |
+
|
| 728 |
+
# ============================================================================
|
| 729 |
+
# Sanity check
|
| 730 |
+
# ============================================================================
|
| 731 |
+
if __name__ == "__main__":
|
| 732 |
+
print("=" * 60)
|
| 733 |
+
print("BertintV8 Architecture Sanity Check")
|
| 734 |
+
print("=" * 60)
|
| 735 |
+
|
| 736 |
+
# Mock Bertose encoder (for testing without cluster)
|
| 737 |
+
class MockEmbeddings(nn.Module):
|
| 738 |
+
"""Mock Bertose embeddings for local testing."""
|
| 739 |
+
|
| 740 |
+
def __init__(self, dim: int = 768):
|
| 741 |
+
super().__init__()
|
| 742 |
+
self.proj = nn.Linear(64, dim)
|
| 743 |
+
|
| 744 |
+
def forward(self, token_ids, branch_depths, linkage_types):
|
| 745 |
+
"""Return random embeddings."""
|
| 746 |
+
batch_size, seq_len = token_ids.shape
|
| 747 |
+
return torch.randn(batch_size, seq_len, 768)
|
| 748 |
+
|
| 749 |
+
class MockLayer(nn.Module):
|
| 750 |
+
"""Mock transformer layer."""
|
| 751 |
+
|
| 752 |
+
def forward(self, x, mask):
|
| 753 |
+
"""Identity."""
|
| 754 |
+
return x
|
| 755 |
+
|
| 756 |
+
seq_emb = MockEmbeddings()
|
| 757 |
+
seq_layers = nn.ModuleList([MockLayer() for _ in range(12)])
|
| 758 |
+
|
| 759 |
+
model = BertintV8(
|
| 760 |
+
seq_embeddings=seq_emb,
|
| 761 |
+
seq_layers=seq_layers,
|
| 762 |
+
glycan_dim=768,
|
| 763 |
+
protein_dim=960,
|
| 764 |
+
)
|
| 765 |
+
|
| 766 |
+
# Simulate batch
|
| 767 |
+
batch_size = 4
|
| 768 |
+
lg = 37 # Glycan: 37 BPE tokens
|
| 769 |
+
lp = 150 # Protein: 150 residues
|
| 770 |
+
|
| 771 |
+
token_ids = torch.randint(0, 100, (batch_size, lg))
|
| 772 |
+
attention_mask = torch.ones(batch_size, lg).float()
|
| 773 |
+
branch_depths = torch.zeros(batch_size, lg, dtype=torch.long)
|
| 774 |
+
linkage_types = torch.zeros(batch_size, lg, dtype=torch.long)
|
| 775 |
+
protein_emb = torch.randn(batch_size, lp, 960)
|
| 776 |
+
protein_mask = torch.ones(batch_size, lp).float()
|
| 777 |
+
|
| 778 |
+
out = model(
|
| 779 |
+
token_ids=token_ids,
|
| 780 |
+
attention_mask=attention_mask,
|
| 781 |
+
branch_depths=branch_depths,
|
| 782 |
+
linkage_types=linkage_types,
|
| 783 |
+
protein_emb=protein_emb,
|
| 784 |
+
protein_mask=protein_mask,
|
| 785 |
+
)
|
| 786 |
+
|
| 787 |
+
print(f"\nInput shapes:")
|
| 788 |
+
print(f" Glycan tokens: {token_ids.shape}")
|
| 789 |
+
print(f" Protein emb: {protein_emb.shape}")
|
| 790 |
+
print(f"\nOutput shape: {out.shape} — values: {out.detach()}")
|
| 791 |
+
|
| 792 |
+
# Test loss
|
| 793 |
+
loss_fn = BertintV8Loss()
|
| 794 |
+
target = torch.rand(batch_size)
|
| 795 |
+
loss = loss_fn(out, target)
|
| 796 |
+
print(f"\nLoss: {loss.item():.6f}")
|
| 797 |
+
|
| 798 |
+
# Test backward
|
| 799 |
+
loss.backward()
|
| 800 |
+
grad_count = sum(
|
| 801 |
+
1 for p in model.parameters() if p.grad is not None and p.requires_grad
|
| 802 |
+
)
|
| 803 |
+
total_trainable = sum(
|
| 804 |
+
1 for p in model.parameters() if p.requires_grad
|
| 805 |
+
)
|
| 806 |
+
print(f"Gradients: {grad_count}/{total_trainable} trainable params")
|
| 807 |
+
|
| 808 |
+
print(f"\n✅ V8 sanity check passed!")
|
| 809 |
+
"""Bertint V8 model — cross-attention + live Bertose finetuning."""
|
code/bertint/bertint_v8_inference.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Bertint V8 Inference — Standalone Prediction Pipeline
|
| 3 |
+
|
| 4 |
+
Architecture (Final — CrossAttn + MeanPool + Concat):
|
| 5 |
+
GLYCAN: WURCS -> BPE -> Bertose (frozen) -> [B, Lg, 768] -> proj -> [B, Lg, 512]
|
| 6 |
+
PROTEIN: ESM-C per-residue -> [B, Lp, 960] -> proj -> [B, Lp, 512]
|
| 7 |
+
|
|
| 8 |
+
2x CrossAttentionBlock(d=512, 8H, FFN=1024)
|
| 9 |
+
|
|
| 10 |
+
glycan_enriched -> mean(valid tokens) -> [B, 512]
|
| 11 |
+
protein_enriched -> mean(valid residues) -> [B, 512]
|
| 12 |
+
|
|
| 13 |
+
concat -> [B, 1024] -> MLP -> score
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import argparse
|
| 17 |
+
import json
|
| 18 |
+
import logging
|
| 19 |
+
import sys
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
from typing import Dict, List, Optional, Tuple
|
| 22 |
+
|
| 23 |
+
import h5py
|
| 24 |
+
import numpy as np
|
| 25 |
+
import pandas as pd
|
| 26 |
+
import torch
|
| 27 |
+
|
| 28 |
+
from bertint_v8 import BertintV8, load_bertose_encoder
|
| 29 |
+
from dataset_v8 import load_bpe_tokenizer
|
| 30 |
+
|
| 31 |
+
logging.basicConfig(
|
| 32 |
+
level=logging.INFO,
|
| 33 |
+
format="%(asctime)s - %(levelname)s - %(message)s",
|
| 34 |
+
)
|
| 35 |
+
logger = logging.getLogger(__name__)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class BertintPredictor:
|
| 39 |
+
"""Standalone predictor for glycan-protein binding affinity."""
|
| 40 |
+
|
| 41 |
+
def __init__(self, checkpoint_path, bertose_checkpoint, vocab_path,
|
| 42 |
+
protein_emb_path, device="auto", max_protein_length=1024):
|
| 43 |
+
if device == "auto":
|
| 44 |
+
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 45 |
+
else:
|
| 46 |
+
self.device = torch.device(device)
|
| 47 |
+
logger.info(f"Using device: {self.device}")
|
| 48 |
+
self.max_protein_length = max_protein_length
|
| 49 |
+
|
| 50 |
+
logger.info(f"Loading BPE tokenizer from {vocab_path}")
|
| 51 |
+
self.tokenizer = load_bpe_tokenizer(vocab_path)
|
| 52 |
+
|
| 53 |
+
logger.info(f"Loading Bertose from {bertose_checkpoint}")
|
| 54 |
+
bertose_config, seq_embeddings, seq_layers = load_bertose_encoder(
|
| 55 |
+
bertose_checkpoint, freeze_layers=12)
|
| 56 |
+
|
| 57 |
+
logger.info("Building Bertint V8 (CrossAttn + MeanPool + Concat)")
|
| 58 |
+
self.model = BertintV8(
|
| 59 |
+
seq_embeddings=seq_embeddings,
|
| 60 |
+
seq_layers=seq_layers,
|
| 61 |
+
glycan_dim=bertose_config.seq_hidden_size,
|
| 62 |
+
protein_dim=960, shared_dim=512, num_heads=8,
|
| 63 |
+
ffn_dim=1024, num_cross_layers=2, dropout=0.1,
|
| 64 |
+
swe_slices=512, swe_ref_points=64, separate_swe=False,
|
| 65 |
+
pooling_mode="mean", interaction_mode="concat",
|
| 66 |
+
use_cross_attention=True,
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
logger.info(f"Loading checkpoint from {checkpoint_path}")
|
| 70 |
+
state_dict = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
|
| 71 |
+
missing, unexpected = self.model.load_state_dict(state_dict, strict=False)
|
| 72 |
+
if missing:
|
| 73 |
+
logger.warning("Missing checkpoint tensors: %d", len(missing))
|
| 74 |
+
if unexpected:
|
| 75 |
+
logger.warning("Unexpected checkpoint tensors: %d", len(unexpected))
|
| 76 |
+
self.model.to(self.device)
|
| 77 |
+
self.model.eval()
|
| 78 |
+
logger.info("Model loaded and ready for inference")
|
| 79 |
+
|
| 80 |
+
logger.info(f"Loading protein embeddings from {protein_emb_path}")
|
| 81 |
+
self.protein_embs = {}
|
| 82 |
+
with h5py.File(protein_emb_path, "r") as f:
|
| 83 |
+
for key in f.keys():
|
| 84 |
+
emb = torch.from_numpy(f[key][:]).float()
|
| 85 |
+
if emb.dim() == 1:
|
| 86 |
+
emb = emb.unsqueeze(0)
|
| 87 |
+
if emb.shape[0] > max_protein_length:
|
| 88 |
+
emb = emb[:max_protein_length]
|
| 89 |
+
self.protein_embs[key.replace("|", "/")] = emb
|
| 90 |
+
logger.info(f" Loaded {len(self.protein_embs)} protein embeddings")
|
| 91 |
+
|
| 92 |
+
def _tokenize_glycan(self, wurcs):
|
| 93 |
+
tok = self.tokenizer.tokenize(wurcs, max_length=256)
|
| 94 |
+
return {k: torch.tensor(tok[k], dtype=torch.long)
|
| 95 |
+
for k in ["token_ids", "attention_mask", "branch_depths", "linkage_types"]}
|
| 96 |
+
|
| 97 |
+
@torch.no_grad()
|
| 98 |
+
def predict_single(self, wurcs, protein_id):
|
| 99 |
+
if protein_id not in self.protein_embs:
|
| 100 |
+
raise KeyError(f"Protein '{protein_id}' not found in embeddings.")
|
| 101 |
+
tokens = self._tokenize_glycan(wurcs)
|
| 102 |
+
protein_emb = self.protein_embs[protein_id]
|
| 103 |
+
batch = {
|
| 104 |
+
"token_ids": tokens["token_ids"].unsqueeze(0).to(self.device),
|
| 105 |
+
"attention_mask": tokens["attention_mask"].unsqueeze(0).float().to(self.device),
|
| 106 |
+
"branch_depths": tokens["branch_depths"].unsqueeze(0).to(self.device),
|
| 107 |
+
"linkage_types": tokens["linkage_types"].unsqueeze(0).to(self.device),
|
| 108 |
+
"protein_emb": protein_emb.unsqueeze(0).to(self.device),
|
| 109 |
+
"protein_mask": torch.ones(1, protein_emb.shape[0]).to(self.device),
|
| 110 |
+
}
|
| 111 |
+
score = self.model(
|
| 112 |
+
token_ids=batch["token_ids"], attention_mask=batch["attention_mask"],
|
| 113 |
+
branch_depths=batch["branch_depths"], linkage_types=batch["linkage_types"],
|
| 114 |
+
protein_emb=batch["protein_emb"], protein_mask=batch["protein_mask"],
|
| 115 |
+
)
|
| 116 |
+
return score.item()
|
| 117 |
+
|
| 118 |
+
@torch.no_grad()
|
| 119 |
+
def predict_batch(self, wurcs_list, protein_ids, batch_size=32):
|
| 120 |
+
all_scores = []
|
| 121 |
+
n = len(wurcs_list)
|
| 122 |
+
for start in range(0, n, batch_size):
|
| 123 |
+
end = min(start + batch_size, n)
|
| 124 |
+
tokenized = [self._tokenize_glycan(w) for w in wurcs_list[start:end]]
|
| 125 |
+
token_ids = torch.stack([t["token_ids"] for t in tokenized]).to(self.device)
|
| 126 |
+
attn_mask = torch.stack([t["attention_mask"] for t in tokenized]).float().to(self.device)
|
| 127 |
+
branch_d = torch.stack([t["branch_depths"] for t in tokenized]).to(self.device)
|
| 128 |
+
link_t = torch.stack([t["linkage_types"] for t in tokenized]).to(self.device)
|
| 129 |
+
missing = [pid for pid in protein_ids[start:end] if pid not in self.protein_embs]
|
| 130 |
+
if missing:
|
| 131 |
+
raise KeyError(f"{len(missing)} protein ids missing from embeddings, first: {missing[0]!r}")
|
| 132 |
+
protein_embs = [self.protein_embs[pid] for pid in protein_ids[start:end]]
|
| 133 |
+
max_len = max(e.shape[0] for e in protein_embs)
|
| 134 |
+
dim = protein_embs[0].shape[1]
|
| 135 |
+
prot_pad = torch.zeros(len(protein_embs), max_len, dim)
|
| 136 |
+
prot_mask = torch.zeros(len(protein_embs), max_len)
|
| 137 |
+
for i, emb in enumerate(protein_embs):
|
| 138 |
+
prot_pad[i, :emb.shape[0]] = emb
|
| 139 |
+
prot_mask[i, :emb.shape[0]] = 1.0
|
| 140 |
+
scores = self.model(
|
| 141 |
+
token_ids=token_ids, attention_mask=attn_mask,
|
| 142 |
+
branch_depths=branch_d, linkage_types=link_t,
|
| 143 |
+
protein_emb=prot_pad.to(self.device), protein_mask=prot_mask.to(self.device),
|
| 144 |
+
)
|
| 145 |
+
all_scores.extend(scores.cpu().tolist())
|
| 146 |
+
if (start // batch_size) % 10 == 0:
|
| 147 |
+
logger.info(f" Predicted {end}/{n} pairs ({100*end/n:.1f}%)")
|
| 148 |
+
return all_scores
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def main():
|
| 152 |
+
parser = argparse.ArgumentParser(description="Bertint V8 Inference")
|
| 153 |
+
parser.add_argument("--checkpoint", required=True)
|
| 154 |
+
parser.add_argument("--bertose_checkpoint", required=True)
|
| 155 |
+
parser.add_argument("--vocab_path", required=True)
|
| 156 |
+
parser.add_argument("--protein_emb_path", required=True)
|
| 157 |
+
parser.add_argument("--device", default="auto")
|
| 158 |
+
parser.add_argument("--wurcs", type=str, default=None)
|
| 159 |
+
parser.add_argument("--protein_id", type=str, default=None)
|
| 160 |
+
parser.add_argument("--input_csv", type=str, default=None)
|
| 161 |
+
parser.add_argument("--output_csv", type=str, default=None)
|
| 162 |
+
parser.add_argument("--batch_size", type=int, default=32)
|
| 163 |
+
args = parser.parse_args()
|
| 164 |
+
|
| 165 |
+
predictor = BertintPredictor(
|
| 166 |
+
checkpoint_path=args.checkpoint, bertose_checkpoint=args.bertose_checkpoint,
|
| 167 |
+
vocab_path=args.vocab_path, protein_emb_path=args.protein_emb_path,
|
| 168 |
+
device=args.device,
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
if args.wurcs and args.protein_id:
|
| 172 |
+
score = predictor.predict_single(args.wurcs, args.protein_id)
|
| 173 |
+
print(f"\nPrediction: {score:.4f} (0=no binding, 1=strong)")
|
| 174 |
+
elif args.input_csv:
|
| 175 |
+
df = pd.read_csv(args.input_csv)
|
| 176 |
+
scores = predictor.predict_batch(
|
| 177 |
+
df["glycan_wurcs"].tolist(), df["protein_id"].tolist(),
|
| 178 |
+
batch_size=args.batch_size)
|
| 179 |
+
df["predicted_score"] = scores
|
| 180 |
+
out = args.output_csv or args.input_csv.replace(".csv", "_predictions.csv")
|
| 181 |
+
df.to_csv(out, index=False)
|
| 182 |
+
logger.info(f"Saved {len(df)} predictions to {out}")
|
| 183 |
+
else:
|
| 184 |
+
parser.error("Provide --wurcs + --protein_id or --input_csv")
|
| 185 |
+
|
| 186 |
+
if __name__ == "__main__":
|
| 187 |
+
main()
|
code/bertint/build_combined_dataset.py
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Build combined Carbogrove + glycowork dataset for Bertint V5.
|
| 3 |
+
|
| 4 |
+
Steps:
|
| 5 |
+
1. Load Carbogrove processed data (315K records)
|
| 6 |
+
2. Load glycowork binding data (wide) → long (620K records)
|
| 7 |
+
3. Merge and filter to proteins with sequences
|
| 8 |
+
4. Compute per-experiment RANK PERCENTILE as training target
|
| 9 |
+
5. Compute auxiliary targets (z-scores) for ablation
|
| 10 |
+
6. Save combined dataset + sequence files
|
| 11 |
+
|
| 12 |
+
Output columns:
|
| 13 |
+
protein_id - canonical protein name
|
| 14 |
+
glycan_wurcs - WURCS string (for Bertose)
|
| 15 |
+
target_raw - original FractionBound or Z-score
|
| 16 |
+
target_rank - per-experiment rank percentile [0,1] ← PRIMARY
|
| 17 |
+
target_zscore - z-score per source (global) ← ABLATION
|
| 18 |
+
target_zscore_exp - z-score within each experiment ← ABLATION
|
| 19 |
+
log_conc - log10(concentration+1) or -1 (unknown)
|
| 20 |
+
has_conc - 1 if concentration known, 0 if not
|
| 21 |
+
source - 'carbogrove' or 'glycowork'
|
| 22 |
+
data_source - platform name (CG) or 'glycowork'
|
| 23 |
+
exp_id - experiment identifier
|
| 24 |
+
exp_size - records in this experiment
|
| 25 |
+
|
| 26 |
+
Canonical IDs:
|
| 27 |
+
- Protein: amino acid sequence (from UniProt or glycowork 'target')
|
| 28 |
+
- Glycan: WURCS string (for Bertose embeddings)
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
import pandas as pd
|
| 32 |
+
import numpy as np
|
| 33 |
+
import csv
|
| 34 |
+
import json
|
| 35 |
+
import os
|
| 36 |
+
import logging
|
| 37 |
+
from pathlib import Path
|
| 38 |
+
|
| 39 |
+
logging.basicConfig(
|
| 40 |
+
level=logging.INFO,
|
| 41 |
+
format='%(asctime)s [%(levelname)s] %(message)s',
|
| 42 |
+
)
|
| 43 |
+
logger = logging.getLogger(__name__)
|
| 44 |
+
|
| 45 |
+
# ── Paths ──────────────────────────────────────────────────────────
|
| 46 |
+
BASE = Path(__file__).parent.parent
|
| 47 |
+
DATA_DIR = BASE / '19274777-2'
|
| 48 |
+
PROCESSED_DIR = BASE / 'processed_v5'
|
| 49 |
+
OUTPUT_DIR = BASE / 'combined_dataset'
|
| 50 |
+
|
| 51 |
+
# ── Step 1: Load Carbogrove ────────────────────────────────────────
|
| 52 |
+
def load_carbogrove() -> pd.DataFrame:
|
| 53 |
+
"""Load Carbogrove processed data with recovered lectins."""
|
| 54 |
+
logger.info("Loading Carbogrove data...")
|
| 55 |
+
|
| 56 |
+
# Load main binding data
|
| 57 |
+
cg = pd.read_csv(PROCESSED_DIR / 'training_data.csv')
|
| 58 |
+
logger.info(f" Base Carbogrove: {len(cg):,} records, "
|
| 59 |
+
f"{cg['LectinName'].nunique()} lectins")
|
| 60 |
+
|
| 61 |
+
# Load glycan info for WURCS mapping
|
| 62 |
+
glycan_info = {}
|
| 63 |
+
with open(DATA_DIR / 'AllGlycanInfo_V2.csv') as f:
|
| 64 |
+
for row in csv.DictReader(f):
|
| 65 |
+
wurcs = row.get('WURCS', '').strip()
|
| 66 |
+
if wurcs:
|
| 67 |
+
glycan_info[row['GlycanID']] = wurcs
|
| 68 |
+
|
| 69 |
+
# Load ALL protein sequences:
|
| 70 |
+
# 1. UniProt-fetched sequences (most complete)
|
| 71 |
+
prot_seqs = {}
|
| 72 |
+
cg_seq_path = OUTPUT_DIR / 'carbogrove_protein_sequences.csv'
|
| 73 |
+
if cg_seq_path.exists():
|
| 74 |
+
cg_seqs = pd.read_csv(cg_seq_path)
|
| 75 |
+
for _, row in cg_seqs.iterrows():
|
| 76 |
+
prot_seqs[row['protein_id']] = {
|
| 77 |
+
'sequence': row['sequence'],
|
| 78 |
+
'source': row['source'],
|
| 79 |
+
}
|
| 80 |
+
logger.info(f" Loaded {len(prot_seqs)} Carbogrove protein sequences")
|
| 81 |
+
|
| 82 |
+
# 2. Recovered sequences (lectins without UniProt IDs)
|
| 83 |
+
recovered_path = DATA_DIR / 'recovered_sequences.csv'
|
| 84 |
+
if recovered_path.exists():
|
| 85 |
+
recovered = pd.read_csv(recovered_path)
|
| 86 |
+
recovered = recovered[recovered['Status'] == 'RECOVERED']
|
| 87 |
+
for _, row in recovered.iterrows():
|
| 88 |
+
name = row['LectinName']
|
| 89 |
+
if name not in prot_seqs and row['Sequence'] and len(str(row['Sequence'])) > 20:
|
| 90 |
+
prot_seqs[name] = {
|
| 91 |
+
'sequence': row['Sequence'],
|
| 92 |
+
'source': row['Source'],
|
| 93 |
+
}
|
| 94 |
+
n_recovered = len(recovered)
|
| 95 |
+
logger.info(f" Added {n_recovered} recovered lectin sequences")
|
| 96 |
+
|
| 97 |
+
# Add WURCS to Carbogrove
|
| 98 |
+
cg['WURCS'] = cg['GlycanID'].map(glycan_info)
|
| 99 |
+
n_with_wurcs = cg['WURCS'].notna().sum()
|
| 100 |
+
logger.info(f" Records with WURCS: {n_with_wurcs:,} / {len(cg):,}")
|
| 101 |
+
|
| 102 |
+
# Drop records without WURCS (can't generate Bertose embeddings)
|
| 103 |
+
cg = cg[cg['WURCS'].notna()].copy()
|
| 104 |
+
logger.info(f" After WURCS filter: {len(cg):,} records")
|
| 105 |
+
|
| 106 |
+
# Load experiment metadata for DataSource (platform)
|
| 107 |
+
data_info = {}
|
| 108 |
+
with open(DATA_DIR / 'AllDataInfo_V2.csv') as f:
|
| 109 |
+
for row in csv.DictReader(f):
|
| 110 |
+
data_info[int(row['DataID'])] = row.get('DataSource', 'unknown')
|
| 111 |
+
cg['data_source'] = cg['DataID'].map(data_info)
|
| 112 |
+
logger.info(f" DataSources: {cg['data_source'].nunique()} platforms")
|
| 113 |
+
|
| 114 |
+
# Standardize columns (keep DataID for per-experiment rank)
|
| 115 |
+
cg['source'] = 'carbogrove'
|
| 116 |
+
cg['target_raw'] = cg['FractionBound']
|
| 117 |
+
cg['log_conc'] = np.log10(cg['Concentration'] + 1)
|
| 118 |
+
cg['has_conc'] = 1
|
| 119 |
+
cg['exp_id'] = 'cg_' + cg['DataID'].astype(str)
|
| 120 |
+
|
| 121 |
+
return cg, prot_seqs, glycan_info
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
# ── Step 2: Load & convert glycowork ─────────────────��────────────
|
| 125 |
+
def load_glycowork(iupac_to_wurcs: dict) -> pd.DataFrame:
|
| 126 |
+
"""
|
| 127 |
+
Load glycowork binding data, convert wide→long,
|
| 128 |
+
filter to glycans with WURCS mappings.
|
| 129 |
+
"""
|
| 130 |
+
logger.info("Loading glycowork data...")
|
| 131 |
+
from glycowork.glycan_data.loader import glycan_binding
|
| 132 |
+
|
| 133 |
+
gw = glycan_binding
|
| 134 |
+
meta_cols = ['protein', 'target']
|
| 135 |
+
glycan_cols = [c for c in gw.columns if c not in meta_cols]
|
| 136 |
+
logger.info(f" Wide format: {gw.shape[0]} proteins × {len(glycan_cols)} glycans")
|
| 137 |
+
|
| 138 |
+
# Filter to glycans with WURCS
|
| 139 |
+
mapped_glycans = [g for g in glycan_cols if g in iupac_to_wurcs]
|
| 140 |
+
logger.info(f" Glycans with WURCS: {len(mapped_glycans)} / {len(glycan_cols)}")
|
| 141 |
+
|
| 142 |
+
# Wide → Long (only for mapped glycans)
|
| 143 |
+
logger.info(" Converting wide → long format...")
|
| 144 |
+
records = []
|
| 145 |
+
skipped_nan_protein = 0
|
| 146 |
+
skipped_short_seq = 0
|
| 147 |
+
|
| 148 |
+
for _, row in gw.iterrows():
|
| 149 |
+
protein_name = row['protein']
|
| 150 |
+
protein_seq = row['target']
|
| 151 |
+
|
| 152 |
+
# Skip rows with NaN protein name
|
| 153 |
+
if pd.isna(protein_name) or str(protein_name).strip() == 'nan':
|
| 154 |
+
skipped_nan_protein += 1
|
| 155 |
+
continue
|
| 156 |
+
|
| 157 |
+
protein_name = str(protein_name).strip()
|
| 158 |
+
protein_seq = str(protein_seq) if pd.notna(protein_seq) else ''
|
| 159 |
+
|
| 160 |
+
# Skip proteins without sequences
|
| 161 |
+
if len(protein_seq) < 50:
|
| 162 |
+
skipped_short_seq += 1
|
| 163 |
+
continue
|
| 164 |
+
|
| 165 |
+
for glycan_iupac in mapped_glycans:
|
| 166 |
+
val = row[glycan_iupac]
|
| 167 |
+
if pd.notna(val):
|
| 168 |
+
records.append({
|
| 169 |
+
'protein_name': protein_name,
|
| 170 |
+
'protein_seq': protein_seq,
|
| 171 |
+
'glycan_iupac': glycan_iupac,
|
| 172 |
+
'WURCS': iupac_to_wurcs[glycan_iupac],
|
| 173 |
+
'target_raw': float(val),
|
| 174 |
+
'target_zscore': float(val), # Already z-scored
|
| 175 |
+
})
|
| 176 |
+
|
| 177 |
+
logger.info(f" Skipped {skipped_nan_protein} rows with NaN protein")
|
| 178 |
+
logger.info(f" Skipped {skipped_short_seq} rows with short/no sequence")
|
| 179 |
+
|
| 180 |
+
gw_long = pd.DataFrame(records)
|
| 181 |
+
logger.info(f" Long format: {len(gw_long):,} records")
|
| 182 |
+
logger.info(f" Proteins: {gw_long['protein_name'].nunique()}")
|
| 183 |
+
logger.info(f" Glycans: {gw_long['glycan_iupac'].nunique()}")
|
| 184 |
+
|
| 185 |
+
gw_long['source'] = 'glycowork'
|
| 186 |
+
gw_long['log_conc'] = -1.0 # Sentinel: concentration unknown
|
| 187 |
+
gw_long['has_conc'] = 0
|
| 188 |
+
gw_long['data_source'] = 'glycowork'
|
| 189 |
+
gw_long['exp_id'] = 'gw_' + gw_long['protein_name']
|
| 190 |
+
|
| 191 |
+
return gw_long
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
# ── Step 3: Merge datasets ────────────────────────────────────────
|
| 195 |
+
def merge_datasets(
|
| 196 |
+
cg: pd.DataFrame,
|
| 197 |
+
gw: pd.DataFrame,
|
| 198 |
+
prot_seqs: dict,
|
| 199 |
+
) -> pd.DataFrame:
|
| 200 |
+
"""
|
| 201 |
+
Merge Carbogrove and glycowork into unified format.
|
| 202 |
+
|
| 203 |
+
Unified columns:
|
| 204 |
+
- protein_id: canonical ID (UniProt or glycowork name)
|
| 205 |
+
- protein_seq: amino acid sequence (for ESM-C)
|
| 206 |
+
- glycan_wurcs: WURCS string (for Bertose)
|
| 207 |
+
- target_raw: original target value
|
| 208 |
+
- target_zscore: z-scored target (per source)
|
| 209 |
+
- log_conc: log10(concentration + 1) or NaN
|
| 210 |
+
- source: 'carbogrove' or 'glycowork'
|
| 211 |
+
"""
|
| 212 |
+
logger.info("Merging datasets...")
|
| 213 |
+
|
| 214 |
+
# Standardize Carbogrove columns
|
| 215 |
+
cg_unified = pd.DataFrame({
|
| 216 |
+
'protein_id': cg['LectinName'],
|
| 217 |
+
'glycan_wurcs': cg['WURCS'],
|
| 218 |
+
'target_raw': cg['target_raw'],
|
| 219 |
+
'log_conc': cg['log_conc'],
|
| 220 |
+
'has_conc': cg['has_conc'],
|
| 221 |
+
'source': cg['source'],
|
| 222 |
+
'data_source': cg['data_source'],
|
| 223 |
+
'exp_id': cg['exp_id'],
|
| 224 |
+
})
|
| 225 |
+
|
| 226 |
+
# Standardize glycowork columns
|
| 227 |
+
gw_unified = pd.DataFrame({
|
| 228 |
+
'protein_id': gw['protein_name'],
|
| 229 |
+
'glycan_wurcs': gw['WURCS'],
|
| 230 |
+
'target_raw': gw['target_raw'],
|
| 231 |
+
'log_conc': gw['log_conc'],
|
| 232 |
+
'has_conc': gw['has_conc'],
|
| 233 |
+
'source': gw['source'],
|
| 234 |
+
'data_source': gw['data_source'],
|
| 235 |
+
'exp_id': gw['exp_id'],
|
| 236 |
+
})
|
| 237 |
+
|
| 238 |
+
# Concatenate
|
| 239 |
+
combined = pd.concat([cg_unified, gw_unified], ignore_index=True)
|
| 240 |
+
logger.info(f" Combined (pre-dedup): {len(combined):,} records")
|
| 241 |
+
logger.info(f" Carbogrove: {(combined['source'] == 'carbogrove').sum():,}")
|
| 242 |
+
logger.info(f" glycowork: {(combined['source'] == 'glycowork').sum():,}")
|
| 243 |
+
|
| 244 |
+
# ── Deduplication ──────────────────────────────────────────────
|
| 245 |
+
# Two sources of duplicates:
|
| 246 |
+
# 1. glycowork: 160 proteins have multiple rows in the binding
|
| 247 |
+
# matrix (same protein tested on different arrays), creating
|
| 248 |
+
# duplicate (protein, glycan) pairs with different Z-scores.
|
| 249 |
+
# 2. Carbogrove: different GlycanIDs can map to the same WURCS
|
| 250 |
+
# (structurally identical glycans with different printed names),
|
| 251 |
+
# creating duplicate (protein, glycan, experiment) entries.
|
| 252 |
+
#
|
| 253 |
+
# Fix: average target_raw per (protein_id, glycan_wurcs, exp_id).
|
| 254 |
+
# Other columns (log_conc, has_conc, source, data_source) are
|
| 255 |
+
# constant within each group, so we take the first value.
|
| 256 |
+
|
| 257 |
+
group_cols = ['protein_id', 'glycan_wurcs', 'exp_id']
|
| 258 |
+
before = len(combined)
|
| 259 |
+
|
| 260 |
+
combined = combined.groupby(group_cols, as_index=False).agg({
|
| 261 |
+
'target_raw': 'mean', # Average replicate measurements
|
| 262 |
+
'log_conc': 'first', # Constant within experiment
|
| 263 |
+
'has_conc': 'first', # Constant within source
|
| 264 |
+
'source': 'first', # Constant within experiment
|
| 265 |
+
'data_source': 'first', # Constant within experiment
|
| 266 |
+
})
|
| 267 |
+
|
| 268 |
+
after = len(combined)
|
| 269 |
+
removed = before - after
|
| 270 |
+
logger.info(f" Deduplicated: {before:,} → {after:,} ({removed:,} duplicates removed)")
|
| 271 |
+
|
| 272 |
+
return combined
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
# ── Step 4: Generate protein sequence file ─────────────────────────
|
| 276 |
+
def build_protein_sequence_file(
|
| 277 |
+
combined: pd.DataFrame,
|
| 278 |
+
prot_seqs: dict,
|
| 279 |
+
gw_long: pd.DataFrame,
|
| 280 |
+
) -> pd.DataFrame:
|
| 281 |
+
"""Build protein_id → sequence mapping for ESM-C embedding generation."""
|
| 282 |
+
logger.info("Building protein sequence file...")
|
| 283 |
+
|
| 284 |
+
seq_map = {}
|
| 285 |
+
|
| 286 |
+
# Carbogrove proteins: from UniProt-fetched + recovered
|
| 287 |
+
for name, info in prot_seqs.items():
|
| 288 |
+
if 'sequence' in info and len(str(info['sequence'])) > 20:
|
| 289 |
+
seq_map[name] = str(info['sequence'])
|
| 290 |
+
|
| 291 |
+
n_cg = len(seq_map)
|
| 292 |
+
logger.info(f" Carbogrove sequences: {n_cg}")
|
| 293 |
+
|
| 294 |
+
# glycowork proteins: sequence in data
|
| 295 |
+
gw_seqs = gw_long.groupby('protein_name')['protein_seq'].first()
|
| 296 |
+
for name, seq in gw_seqs.items():
|
| 297 |
+
if name not in seq_map and len(str(seq)) > 50:
|
| 298 |
+
seq_map[name] = str(seq)
|
| 299 |
+
|
| 300 |
+
logger.info(f" glycowork sequences added: {len(seq_map) - n_cg}")
|
| 301 |
+
|
| 302 |
+
# Check coverage
|
| 303 |
+
all_proteins = set(combined['protein_id'].unique())
|
| 304 |
+
covered = all_proteins & set(seq_map.keys())
|
| 305 |
+
missing = all_proteins - set(seq_map.keys())
|
| 306 |
+
|
| 307 |
+
logger.info(f" Total unique proteins: {len(all_proteins)}")
|
| 308 |
+
logger.info(f" With sequences: {len(covered)} ({len(covered)/len(all_proteins)*100:.1f}%)")
|
| 309 |
+
logger.info(f" Missing sequences: {len(missing)}")
|
| 310 |
+
if missing:
|
| 311 |
+
logger.info(f" Missing: {sorted(missing)}")
|
| 312 |
+
|
| 313 |
+
# Drop records for proteins without sequences from combined
|
| 314 |
+
records_before = len(combined)
|
| 315 |
+
combined_filtered = combined[combined['protein_id'].isin(seq_map)].copy()
|
| 316 |
+
records_after = len(combined_filtered)
|
| 317 |
+
dropped = records_before - records_after
|
| 318 |
+
if dropped > 0:
|
| 319 |
+
logger.info(f" Dropped {dropped:,} records with missing protein sequences")
|
| 320 |
+
|
| 321 |
+
# Save
|
| 322 |
+
seq_df = pd.DataFrame([
|
| 323 |
+
{'protein_id': pid, 'sequence': seq, 'seq_len': len(seq)}
|
| 324 |
+
for pid, seq in seq_map.items()
|
| 325 |
+
if pid in set(combined_filtered['protein_id'].unique())
|
| 326 |
+
])
|
| 327 |
+
return seq_df, combined_filtered
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
# ── Step 5: Rank-based normalization ───────────────────────────────
|
| 331 |
+
def compute_rank_targets(combined: pd.DataFrame) -> pd.DataFrame:
|
| 332 |
+
"""
|
| 333 |
+
Compute per-experiment rank percentile + auxiliary z-score targets.
|
| 334 |
+
|
| 335 |
+
Targets computed:
|
| 336 |
+
target_rank - rank percentile [0,1] within experiment (PRIMARY)
|
| 337 |
+
target_zscore - z-score per source (global) (ABLATION)
|
| 338 |
+
target_zscore_exp - z-score within each experiment (ABLATION)
|
| 339 |
+
exp_size - number of records in this experiment
|
| 340 |
+
"""
|
| 341 |
+
logger.info("Computing targets...")
|
| 342 |
+
|
| 343 |
+
# 1. Rank within each experiment, normalized to [0, 1]
|
| 344 |
+
combined['target_rank'] = combined.groupby('exp_id')['target_raw'].transform(
|
| 345 |
+
lambda x: x.rank(method='average', pct=True)
|
| 346 |
+
)
|
| 347 |
+
|
| 348 |
+
# 2. Global z-score per source
|
| 349 |
+
for src in ['carbogrove', 'glycowork']:
|
| 350 |
+
mask = combined['source'] == src
|
| 351 |
+
raw = combined.loc[mask, 'target_raw']
|
| 352 |
+
combined.loc[mask, 'target_zscore'] = (
|
| 353 |
+
(raw - raw.mean()) / (raw.std() + 1e-10)
|
| 354 |
+
)
|
| 355 |
+
logger.info(f" Global z-scores computed per source")
|
| 356 |
+
|
| 357 |
+
# 3. Z-score within each experiment
|
| 358 |
+
combined['target_zscore_exp'] = combined.groupby('exp_id')['target_raw'].transform(
|
| 359 |
+
lambda x: (x - x.mean()) / (x.std() + 1e-10) if x.std() > 0 else 0.0
|
| 360 |
+
)
|
| 361 |
+
logger.info(f" Per-experiment z-scores computed")
|
| 362 |
+
|
| 363 |
+
# 4. Experiment size (for potential weighting)
|
| 364 |
+
combined['exp_size'] = combined.groupby('exp_id')['target_raw'].transform('count').astype(int)
|
| 365 |
+
|
| 366 |
+
# Validation: check rank distribution
|
| 367 |
+
n_exps = combined['exp_id'].nunique()
|
| 368 |
+
exp_sizes = combined.groupby('exp_id').size()
|
| 369 |
+
logger.info(f" Ranked across {n_exps:,} experiments")
|
| 370 |
+
logger.info(f" Experiment sizes: min={exp_sizes.min()}, "
|
| 371 |
+
f"median={exp_sizes.median():.0f}, max={exp_sizes.max()}")
|
| 372 |
+
|
| 373 |
+
# Drop experiments with fewer than 5 glycans — too noisy for ranking
|
| 374 |
+
small_exps = (exp_sizes < 5).sum()
|
| 375 |
+
if small_exps > 0:
|
| 376 |
+
valid_exps = exp_sizes[exp_sizes >= 5].index
|
| 377 |
+
before = len(combined)
|
| 378 |
+
combined = combined[combined['exp_id'].isin(valid_exps)].copy()
|
| 379 |
+
after = len(combined)
|
| 380 |
+
if before > after:
|
| 381 |
+
logger.info(f" Dropped {before - after:,} records from "
|
| 382 |
+
f"experiments with <5 glycans")
|
| 383 |
+
|
| 384 |
+
# Per-source stats
|
| 385 |
+
for src in ['carbogrove', 'glycowork']:
|
| 386 |
+
sub = combined[combined['source'] == src]
|
| 387 |
+
r = sub['target_rank']
|
| 388 |
+
z = sub['target_zscore']
|
| 389 |
+
logger.info(f" [{src}] rank: mean={r.mean():.3f} std={r.std():.3f} | "
|
| 390 |
+
f"zscore: mean={z.mean():.3f} std={z.std():.3f}")
|
| 391 |
+
|
| 392 |
+
return combined
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
# ── Step 7: Summary statistics ─────────────────────────────────────
|
| 396 |
+
def print_summary(combined: pd.DataFrame) -> None:
|
| 397 |
+
"""Print comprehensive dataset summary."""
|
| 398 |
+
logger.info("=" * 70)
|
| 399 |
+
logger.info("COMBINED DATASET SUMMARY")
|
| 400 |
+
logger.info("=" * 70)
|
| 401 |
+
|
| 402 |
+
for src in ['carbogrove', 'glycowork', 'all']:
|
| 403 |
+
if src == 'all':
|
| 404 |
+
sub = combined
|
| 405 |
+
label = 'COMBINED'
|
| 406 |
+
else:
|
| 407 |
+
sub = combined[combined['source'] == src]
|
| 408 |
+
label = src.upper()
|
| 409 |
+
|
| 410 |
+
logger.info(f"\n [{label}]")
|
| 411 |
+
logger.info(f" Records: {len(sub):,}")
|
| 412 |
+
logger.info(f" Proteins: {sub['protein_id'].nunique()}")
|
| 413 |
+
logger.info(f" Glycans (WURCS): {sub['glycan_wurcs'].nunique()}")
|
| 414 |
+
logger.info(f" Experiments: {sub['exp_id'].nunique()}")
|
| 415 |
+
logger.info(f" target_rank: mean={sub['target_rank'].mean():.3f}, "
|
| 416 |
+
f"std={sub['target_rank'].std():.3f}")
|
| 417 |
+
logger.info(f" target_zscore: mean={sub['target_zscore'].mean():.3f}, "
|
| 418 |
+
f"std={sub['target_zscore'].std():.3f}")
|
| 419 |
+
|
| 420 |
+
# Column inventory
|
| 421 |
+
logger.info(f"\n Columns: {list(combined.columns)}")
|
| 422 |
+
logger.info(f" Unique glycan WURCS: {combined['glycan_wurcs'].nunique()}")
|
| 423 |
+
logger.info(f" Unique protein IDs: {combined['protein_id'].nunique()}")
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
# ── Main ───────────────────────────────────────────────────────────
|
| 427 |
+
def main() -> None:
|
| 428 |
+
"""Build combined dataset."""
|
| 429 |
+
# Create output directory
|
| 430 |
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 431 |
+
|
| 432 |
+
# Step 1: Load Carbogrove
|
| 433 |
+
cg, prot_seqs, glycan_info = load_carbogrove()
|
| 434 |
+
|
| 435 |
+
# Load IUPAC → WURCS mapping
|
| 436 |
+
iupac_wurcs_path = DATA_DIR / 'glycowork_iupac_to_wurcs.json'
|
| 437 |
+
with open(iupac_wurcs_path) as f:
|
| 438 |
+
iupac_to_wurcs = json.load(f)
|
| 439 |
+
logger.info(f"Loaded {len(iupac_to_wurcs)} IUPAC→WURCS mappings")
|
| 440 |
+
|
| 441 |
+
# Step 2: Load & convert glycowork
|
| 442 |
+
gw = load_glycowork(iupac_to_wurcs)
|
| 443 |
+
|
| 444 |
+
# Step 3: Merge
|
| 445 |
+
combined = merge_datasets(cg, gw, prot_seqs)
|
| 446 |
+
|
| 447 |
+
# Step 4: Protein sequences (also filters combined to drop missing)
|
| 448 |
+
seq_df, combined_final = build_protein_sequence_file(combined, prot_seqs, gw)
|
| 449 |
+
|
| 450 |
+
# Step 5: Rank-based normalization + auxiliary targets
|
| 451 |
+
combined_final = compute_rank_targets(combined_final)
|
| 452 |
+
|
| 453 |
+
# Save outputs
|
| 454 |
+
logger.info("\nSaving outputs...")
|
| 455 |
+
combined_final.to_csv(OUTPUT_DIR / 'combined_binding_data.csv', index=False)
|
| 456 |
+
logger.info(f" Saved combined_binding_data.csv: {len(combined_final):,} records")
|
| 457 |
+
|
| 458 |
+
seq_df.to_csv(OUTPUT_DIR / 'protein_sequences.csv', index=False)
|
| 459 |
+
logger.info(f" Saved protein_sequences.csv: {len(seq_df)} proteins")
|
| 460 |
+
|
| 461 |
+
# Save unique WURCS for Bertose embedding generation
|
| 462 |
+
unique_wurcs = combined_final['glycan_wurcs'].unique()
|
| 463 |
+
wurcs_df = pd.DataFrame({'wurcs': unique_wurcs})
|
| 464 |
+
wurcs_df.to_csv(OUTPUT_DIR / 'unique_glycan_wurcs.csv', index=False)
|
| 465 |
+
logger.info(f" Saved unique_glycan_wurcs.csv: {len(wurcs_df)} glycans")
|
| 466 |
+
|
| 467 |
+
# Step 7: Summary
|
| 468 |
+
print_summary(combined_final)
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
if __name__ == '__main__':
|
| 472 |
+
main()
|
code/bertint/compare_ablation.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Compare Bertint V3 ablation results across Bertose checkpoints.
|
| 3 |
+
|
| 4 |
+
Loads test_results.json from each ablation run and produces a
|
| 5 |
+
comparison table for the paper.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
python compare_ablation.py --base_dir checkpoints/
|
| 9 |
+
python compare_ablation.py --base_dir /work/ratul1/supantha/Bertint/checkpoints/
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import argparse
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# Ablation chain (order matters for table)
|
| 18 |
+
ABLATION_NAMES = [
|
| 19 |
+
("seq_only", "1. Seq-only"),
|
| 20 |
+
("multimodal", "2. +Multimodal"),
|
| 21 |
+
("topology", "3. +Topology"),
|
| 22 |
+
("ipa", "4. +IPA"),
|
| 23 |
+
("contrastive", "5. +Contrastive (Bertose v6)"),
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
# Metrics to display
|
| 27 |
+
METRICS = [
|
| 28 |
+
("pearson", "Pearson", ".4f"),
|
| 29 |
+
("spearman", "Spearman", ".4f"),
|
| 30 |
+
("r2", "R²", ".4f"),
|
| 31 |
+
("mse", "MSE", ".6f"),
|
| 32 |
+
("frac_in_interval", "InInterval", ".3f"),
|
| 33 |
+
("n_samples", "N", "d"),
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def load_results(base_dir: Path) -> dict:
|
| 38 |
+
"""Load test_results.json from each ablation directory."""
|
| 39 |
+
results = {}
|
| 40 |
+
for short_name, display_name in ABLATION_NAMES:
|
| 41 |
+
result_path = base_dir / f"ablation_{short_name}" / "test_results.json"
|
| 42 |
+
if result_path.exists():
|
| 43 |
+
with open(result_path) as f:
|
| 44 |
+
results[short_name] = json.load(f)
|
| 45 |
+
print(f" ✅ Loaded: {result_path}")
|
| 46 |
+
else:
|
| 47 |
+
print(f" ⏳ Not found: {result_path}")
|
| 48 |
+
return results
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def print_table(results: dict) -> None:
|
| 52 |
+
"""Print formatted comparison table."""
|
| 53 |
+
# Header
|
| 54 |
+
header = f"{'Checkpoint':<32}"
|
| 55 |
+
for _, label, _ in METRICS:
|
| 56 |
+
header += f" {label:>10}"
|
| 57 |
+
print()
|
| 58 |
+
print("=" * len(header))
|
| 59 |
+
print("BERTOSE ABLATION — DOWNSTREAM BINDING PREDICTION (Bertint V3)")
|
| 60 |
+
print("=" * len(header))
|
| 61 |
+
print(header)
|
| 62 |
+
print("-" * len(header))
|
| 63 |
+
|
| 64 |
+
# Rows
|
| 65 |
+
for short_name, display_name in ABLATION_NAMES:
|
| 66 |
+
if short_name not in results:
|
| 67 |
+
row = f"{display_name:<32} {'(not available)':>10}"
|
| 68 |
+
print(row)
|
| 69 |
+
continue
|
| 70 |
+
|
| 71 |
+
data = results[short_name]
|
| 72 |
+
row = f"{display_name:<32}"
|
| 73 |
+
for key, _, fmt in METRICS:
|
| 74 |
+
val = data.get(key, float('nan'))
|
| 75 |
+
row += f" {val:>{10}{fmt}}"
|
| 76 |
+
print(row)
|
| 77 |
+
|
| 78 |
+
print("-" * len(header))
|
| 79 |
+
|
| 80 |
+
# Delta from baseline
|
| 81 |
+
if "seq_only" in results and "contrastive" in results:
|
| 82 |
+
base = results["seq_only"]
|
| 83 |
+
best = results["contrastive"]
|
| 84 |
+
print(f"\n{'Δ (best - baseline)':<32}", end="")
|
| 85 |
+
for key, _, fmt in METRICS:
|
| 86 |
+
if key == "n_samples":
|
| 87 |
+
print(f" {'—':>10}", end="")
|
| 88 |
+
else:
|
| 89 |
+
delta = best.get(key, 0) - base.get(key, 0)
|
| 90 |
+
sign = "+" if delta >= 0 else ""
|
| 91 |
+
print(f" {sign}{delta:>{9}{fmt}}", end="")
|
| 92 |
+
print()
|
| 93 |
+
|
| 94 |
+
print()
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def save_latex(results: dict, output_path: Path) -> None:
|
| 98 |
+
"""Save LaTeX table for paper."""
|
| 99 |
+
lines = [
|
| 100 |
+
r"\begin{table}[t]",
|
| 101 |
+
r"\centering",
|
| 102 |
+
r"\caption{Ablation study: Effect of Bertose pretraining stages on downstream glycan-protein binding prediction (Bertint V3, lectin-cold test set).}",
|
| 103 |
+
r"\label{tab:ablation}",
|
| 104 |
+
r"\begin{tabular}{l" + "c" * len(METRICS) + "}",
|
| 105 |
+
r"\toprule",
|
| 106 |
+
]
|
| 107 |
+
|
| 108 |
+
# Header
|
| 109 |
+
header = "Checkpoint"
|
| 110 |
+
for _, label, _ in METRICS:
|
| 111 |
+
if label == "N":
|
| 112 |
+
continue
|
| 113 |
+
header += f" & {label}"
|
| 114 |
+
header += r" \\"
|
| 115 |
+
lines.append(header)
|
| 116 |
+
lines.append(r"\midrule")
|
| 117 |
+
|
| 118 |
+
# Rows
|
| 119 |
+
for short_name, display_name in ABLATION_NAMES:
|
| 120 |
+
if short_name not in results:
|
| 121 |
+
continue
|
| 122 |
+
data = results[short_name]
|
| 123 |
+
row = display_name.replace("+", r"\,+\,")
|
| 124 |
+
for key, label, fmt in METRICS:
|
| 125 |
+
if label == "N":
|
| 126 |
+
continue
|
| 127 |
+
val = data.get(key, float('nan'))
|
| 128 |
+
row += f" & {val:{fmt}}"
|
| 129 |
+
row += r" \\"
|
| 130 |
+
lines.append(row)
|
| 131 |
+
|
| 132 |
+
lines.extend([
|
| 133 |
+
r"\bottomrule",
|
| 134 |
+
r"\end{tabular}",
|
| 135 |
+
r"\end{table}",
|
| 136 |
+
])
|
| 137 |
+
|
| 138 |
+
with open(output_path, 'w') as f:
|
| 139 |
+
f.write("\n".join(lines))
|
| 140 |
+
print(f"LaTeX table saved to {output_path}")
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def main():
|
| 144 |
+
parser = argparse.ArgumentParser(
|
| 145 |
+
description='Compare Bertint V3 ablation results'
|
| 146 |
+
)
|
| 147 |
+
parser.add_argument(
|
| 148 |
+
'--base_dir', type=str,
|
| 149 |
+
default='checkpoints/',
|
| 150 |
+
help='Base directory containing ablation_* subdirs'
|
| 151 |
+
)
|
| 152 |
+
parser.add_argument(
|
| 153 |
+
'--latex', type=str, default=None,
|
| 154 |
+
help='Optional: save LaTeX table to file'
|
| 155 |
+
)
|
| 156 |
+
args = parser.parse_args()
|
| 157 |
+
|
| 158 |
+
base_dir = Path(args.base_dir)
|
| 159 |
+
print(f"Loading results from {base_dir}")
|
| 160 |
+
|
| 161 |
+
results = load_results(base_dir)
|
| 162 |
+
|
| 163 |
+
if not results:
|
| 164 |
+
print("\n❌ No results found. Have you run the ablation training?")
|
| 165 |
+
return
|
| 166 |
+
|
| 167 |
+
print_table(results)
|
| 168 |
+
|
| 169 |
+
if args.latex:
|
| 170 |
+
save_latex(results, Path(args.latex))
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
if __name__ == '__main__':
|
| 174 |
+
main()
|
code/bertint/dataset_v8.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Bertint V8 Dataset — Per-Residue Protein Embeddings for Cross-Attention
|
| 3 |
+
|
| 4 |
+
Like V7 but keeps per-residue ESM-C embeddings [L, D] instead of
|
| 5 |
+
mean-pooling to [D]. This enables token-level cross-attention between
|
| 6 |
+
glycan tokens and protein residues.
|
| 7 |
+
|
| 8 |
+
Changes from V7:
|
| 9 |
+
- protein_emb: [Lp, 960] per-residue (not [960] mean-pooled)
|
| 10 |
+
- collate_fn: pads protein sequences to max length in batch
|
| 11 |
+
- Returns protein_mask for cross-attention padding
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
import logging
|
| 16 |
+
import os
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from typing import Dict, List, Optional, Tuple
|
| 19 |
+
|
| 20 |
+
import h5py
|
| 21 |
+
import numpy as np
|
| 22 |
+
import pandas as pd
|
| 23 |
+
import torch
|
| 24 |
+
from torch.utils.data import Dataset
|
| 25 |
+
from torch.nn.utils.rnn import pad_sequence
|
| 26 |
+
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# ============================================================================
|
| 31 |
+
# BPE Tokenizer Loader (same as V7)
|
| 32 |
+
# ============================================================================
|
| 33 |
+
|
| 34 |
+
def load_bpe_tokenizer(vocab_path: str):
|
| 35 |
+
"""
|
| 36 |
+
Load the Bertose BPE tokenizer directly from source.
|
| 37 |
+
|
| 38 |
+
Bypasses downstream_tasks package imports. Adds utils
|
| 39 |
+
directory to sys.path and imports WURCSBPETokenizer directly.
|
| 40 |
+
|
| 41 |
+
Args:
|
| 42 |
+
vocab_path: Path to BPE vocabulary JSON file.
|
| 43 |
+
|
| 44 |
+
Returns:
|
| 45 |
+
WURCSBPETokenizer instance.
|
| 46 |
+
"""
|
| 47 |
+
import sys
|
| 48 |
+
|
| 49 |
+
env_root = os.environ.get("BERTOSE_ROOT") or os.environ.get("BERTOSE_REPO_ROOT")
|
| 50 |
+
candidate_roots = []
|
| 51 |
+
if env_root:
|
| 52 |
+
candidate_roots.append(Path(env_root).expanduser().resolve())
|
| 53 |
+
candidate_roots.extend(Path(__file__).resolve().parents)
|
| 54 |
+
candidate_roots.append(Path.cwd())
|
| 55 |
+
|
| 56 |
+
utils_dir = None
|
| 57 |
+
for root in candidate_roots:
|
| 58 |
+
candidate = root / "bert_training_v4" / "downstream_tasks" / "utils"
|
| 59 |
+
if candidate.exists():
|
| 60 |
+
utils_dir = candidate
|
| 61 |
+
break
|
| 62 |
+
|
| 63 |
+
if utils_dir is None:
|
| 64 |
+
utils_dir = Path(
|
| 65 |
+
"/work/ratul1/supantha/glycan-SD-VS/bert_training_v3/"
|
| 66 |
+
"v3.1_cluster_training/bert_training_v4/downstream_tasks/utils"
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
utils_dir = str(utils_dir)
|
| 70 |
+
if utils_dir not in sys.path:
|
| 71 |
+
sys.path.insert(0, utils_dir)
|
| 72 |
+
|
| 73 |
+
from wurcs_bpe_tokenizer import WURCSBPETokenizer
|
| 74 |
+
return WURCSBPETokenizer(vocab_path)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ============================================================================
|
| 78 |
+
# Dataset
|
| 79 |
+
# ============================================================================
|
| 80 |
+
|
| 81 |
+
class BertintV8Dataset(Dataset):
|
| 82 |
+
"""
|
| 83 |
+
Dataset for glycan-protein interaction with cross-attention support.
|
| 84 |
+
|
| 85 |
+
Returns:
|
| 86 |
+
- BPE-tokenized glycan sequences for live Bertose forward pass
|
| 87 |
+
- Per-residue ESM-C protein embeddings [Lp, D] (NOT mean-pooled)
|
| 88 |
+
- Masks for both sides (for cross-attention padding)
|
| 89 |
+
|
| 90 |
+
Args:
|
| 91 |
+
csv_path: Path to binding data CSV.
|
| 92 |
+
split_path: Path to glycan-cold splits JSON.
|
| 93 |
+
split: One of 'train', 'val', 'test'.
|
| 94 |
+
protein_emb_path: Path to ESM-C embeddings HDF5.
|
| 95 |
+
vocab_path: Path to BPE vocabulary JSON.
|
| 96 |
+
max_glycan_length: Maximum glycan sequence length.
|
| 97 |
+
max_protein_length: Maximum protein residues (truncate longer).
|
| 98 |
+
target_col: Column name for regression target.
|
| 99 |
+
"""
|
| 100 |
+
|
| 101 |
+
def __init__(
|
| 102 |
+
self,
|
| 103 |
+
csv_path: str,
|
| 104 |
+
split_path: str,
|
| 105 |
+
split: str,
|
| 106 |
+
protein_emb_path: str,
|
| 107 |
+
vocab_path: str,
|
| 108 |
+
max_glycan_length: int = 256,
|
| 109 |
+
max_protein_length: int = 1024,
|
| 110 |
+
target_col: str = "target_rank",
|
| 111 |
+
):
|
| 112 |
+
logger.info(f"Loading {split} dataset from {csv_path}")
|
| 113 |
+
|
| 114 |
+
# Load splits
|
| 115 |
+
with open(split_path) as f:
|
| 116 |
+
splits_data = json.load(f)
|
| 117 |
+
if "glycan_cold" in splits_data:
|
| 118 |
+
splits_data = splits_data["glycan_cold"]
|
| 119 |
+
split_glycans = set(splits_data[split])
|
| 120 |
+
logger.info(f" {split}: {len(split_glycans)} glycans in split")
|
| 121 |
+
|
| 122 |
+
# Load and filter data
|
| 123 |
+
df = pd.read_csv(csv_path)
|
| 124 |
+
df = df[df["glycan_wurcs"].isin(split_glycans)].copy()
|
| 125 |
+
df = df.dropna(subset=[target_col])
|
| 126 |
+
logger.info(f" {len(df):,} records after split + target filter")
|
| 127 |
+
|
| 128 |
+
self.records = df.reset_index(drop=True)
|
| 129 |
+
self.target_col = target_col
|
| 130 |
+
self.max_protein_length = max_protein_length
|
| 131 |
+
|
| 132 |
+
# Load BPE tokenizer
|
| 133 |
+
logger.info(f" Loading BPE tokenizer from {vocab_path}")
|
| 134 |
+
self.tokenizer = load_bpe_tokenizer(vocab_path)
|
| 135 |
+
self.max_glycan_length = max_glycan_length
|
| 136 |
+
|
| 137 |
+
# Pre-tokenize all unique glycans
|
| 138 |
+
unique_wurcs = df["glycan_wurcs"].unique()
|
| 139 |
+
logger.info(f" Pre-tokenizing {len(unique_wurcs)} unique glycans...")
|
| 140 |
+
self.tokenized_cache: Dict[str, Dict[str, torch.Tensor]] = {}
|
| 141 |
+
skipped = 0
|
| 142 |
+
for wurcs in unique_wurcs:
|
| 143 |
+
try:
|
| 144 |
+
tok = self.tokenizer.tokenize(
|
| 145 |
+
wurcs, max_length=max_glycan_length
|
| 146 |
+
)
|
| 147 |
+
self.tokenized_cache[wurcs] = {
|
| 148 |
+
"token_ids": torch.tensor(
|
| 149 |
+
tok["token_ids"], dtype=torch.long
|
| 150 |
+
),
|
| 151 |
+
"attention_mask": torch.tensor(
|
| 152 |
+
tok["attention_mask"], dtype=torch.long
|
| 153 |
+
),
|
| 154 |
+
"branch_depths": torch.tensor(
|
| 155 |
+
tok["branch_depths"], dtype=torch.long
|
| 156 |
+
),
|
| 157 |
+
"linkage_types": torch.tensor(
|
| 158 |
+
tok["linkage_types"], dtype=torch.long
|
| 159 |
+
),
|
| 160 |
+
}
|
| 161 |
+
except (KeyError, ValueError) as exc:
|
| 162 |
+
skipped += 1
|
| 163 |
+
if skipped <= 5:
|
| 164 |
+
logger.warning(
|
| 165 |
+
f" Tokenization failed for WURCS: "
|
| 166 |
+
f"{wurcs[:60]}... ({exc})"
|
| 167 |
+
)
|
| 168 |
+
if skipped > 0:
|
| 169 |
+
logger.warning(
|
| 170 |
+
f" Skipped {skipped} glycans with tokenization errors"
|
| 171 |
+
)
|
| 172 |
+
self.records = self.records[
|
| 173 |
+
self.records["glycan_wurcs"].isin(self.tokenized_cache)
|
| 174 |
+
].reset_index(drop=True)
|
| 175 |
+
logger.info(
|
| 176 |
+
f" {len(self.records):,} records after removing "
|
| 177 |
+
f"un-tokenizable"
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
# Load protein embeddings — KEEP PER-RESIDUE (not mean-pooled!)
|
| 181 |
+
logger.info(f" Loading per-residue protein embeddings...")
|
| 182 |
+
self.protein_embs: Dict[str, torch.Tensor] = {}
|
| 183 |
+
with h5py.File(protein_emb_path, "r") as f:
|
| 184 |
+
for key in f.keys():
|
| 185 |
+
emb = torch.from_numpy(f[key][:]).float()
|
| 186 |
+
# emb: [L, D] per-residue
|
| 187 |
+
if emb.dim() == 1:
|
| 188 |
+
# Edge case: single-residue protein
|
| 189 |
+
emb = emb.unsqueeze(0)
|
| 190 |
+
# Truncate very long proteins
|
| 191 |
+
if emb.shape[0] > max_protein_length:
|
| 192 |
+
emb = emb[:max_protein_length]
|
| 193 |
+
protein_id = key.replace("|", "/")
|
| 194 |
+
self.protein_embs[protein_id] = emb
|
| 195 |
+
logger.info(f" {len(self.protein_embs)} proteins loaded")
|
| 196 |
+
|
| 197 |
+
# Log protein length statistics
|
| 198 |
+
lengths = [v.shape[0] for v in self.protein_embs.values()]
|
| 199 |
+
logger.info(
|
| 200 |
+
f" Protein lengths: min={min(lengths)}, "
|
| 201 |
+
f"max={max(lengths)}, mean={np.mean(lengths):.0f}"
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
# Filter to records with available embeddings
|
| 205 |
+
available = set(self.protein_embs.keys())
|
| 206 |
+
has_protein = self.records["protein_id"].isin(available)
|
| 207 |
+
if not has_protein.all():
|
| 208 |
+
missing = (~has_protein).sum()
|
| 209 |
+
logger.warning(
|
| 210 |
+
f" {missing} records missing protein embeddings"
|
| 211 |
+
)
|
| 212 |
+
self.records = self.records[has_protein].reset_index(drop=True)
|
| 213 |
+
|
| 214 |
+
logger.info(f" Final {split} dataset: {len(self.records):,} records")
|
| 215 |
+
|
| 216 |
+
def __len__(self) -> int:
|
| 217 |
+
return len(self.records)
|
| 218 |
+
|
| 219 |
+
def __getitem__(self, idx: int) -> Dict[str, torch.Tensor]:
|
| 220 |
+
row = self.records.iloc[idx]
|
| 221 |
+
wurcs = row["glycan_wurcs"]
|
| 222 |
+
protein_id = row["protein_id"]
|
| 223 |
+
|
| 224 |
+
# Glycan tokens (pre-cached, already padded to max_glycan_length)
|
| 225 |
+
cached = self.tokenized_cache[wurcs]
|
| 226 |
+
|
| 227 |
+
# Protein embedding — per-residue [Lp, D]
|
| 228 |
+
protein_emb = self.protein_embs[protein_id]
|
| 229 |
+
|
| 230 |
+
# Target
|
| 231 |
+
target = torch.tensor(row[self.target_col], dtype=torch.float)
|
| 232 |
+
|
| 233 |
+
# Concentration features
|
| 234 |
+
has_conc = torch.tensor(row.get("has_conc", 0), dtype=torch.float)
|
| 235 |
+
log_conc = torch.tensor(row.get("log_conc", 0.0), dtype=torch.float)
|
| 236 |
+
|
| 237 |
+
return {
|
| 238 |
+
"token_ids": cached["token_ids"],
|
| 239 |
+
"attention_mask": cached["attention_mask"],
|
| 240 |
+
"branch_depths": cached["branch_depths"],
|
| 241 |
+
"linkage_types": cached["linkage_types"],
|
| 242 |
+
"protein_emb": protein_emb, # [Lp, D] variable-length!
|
| 243 |
+
"protein_length": protein_emb.shape[0],
|
| 244 |
+
"target": target,
|
| 245 |
+
"has_conc": has_conc,
|
| 246 |
+
"log_conc": log_conc,
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def collate_fn(
|
| 251 |
+
batch: List[Dict[str, torch.Tensor]],
|
| 252 |
+
) -> Dict[str, torch.Tensor]:
|
| 253 |
+
"""
|
| 254 |
+
Collate with variable-length protein padding.
|
| 255 |
+
|
| 256 |
+
Glycan sequences are already padded (BPE tokenizer pads to
|
| 257 |
+
max_glycan_length). Protein sequences need padding to the
|
| 258 |
+
max length in the batch.
|
| 259 |
+
"""
|
| 260 |
+
# Glycan: already fixed-length, just stack
|
| 261 |
+
token_ids = torch.stack([item["token_ids"] for item in batch])
|
| 262 |
+
attention_mask = torch.stack(
|
| 263 |
+
[item["attention_mask"] for item in batch]
|
| 264 |
+
).float()
|
| 265 |
+
branch_depths = torch.stack(
|
| 266 |
+
[item["branch_depths"] for item in batch]
|
| 267 |
+
)
|
| 268 |
+
linkage_types = torch.stack(
|
| 269 |
+
[item["linkage_types"] for item in batch]
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
# Protein: variable-length → pad to max in batch
|
| 273 |
+
protein_embs = [item["protein_emb"] for item in batch]
|
| 274 |
+
protein_padded = pad_sequence(protein_embs, batch_first=True) # [B, Lp_max, D]
|
| 275 |
+
|
| 276 |
+
# Protein mask: 1 for real residues, 0 for padding
|
| 277 |
+
protein_lengths = [item["protein_length"] for item in batch]
|
| 278 |
+
max_prot_len = protein_padded.shape[1]
|
| 279 |
+
protein_mask = torch.zeros(len(batch), max_prot_len)
|
| 280 |
+
for i, length in enumerate(protein_lengths):
|
| 281 |
+
protein_mask[i, :length] = 1.0
|
| 282 |
+
|
| 283 |
+
# Targets and metadata
|
| 284 |
+
targets = torch.stack([item["target"] for item in batch])
|
| 285 |
+
has_conc = torch.stack([item["has_conc"] for item in batch])
|
| 286 |
+
log_conc = torch.stack([item["log_conc"] for item in batch])
|
| 287 |
+
|
| 288 |
+
return {
|
| 289 |
+
"token_ids": token_ids,
|
| 290 |
+
"attention_mask": attention_mask,
|
| 291 |
+
"branch_depths": branch_depths,
|
| 292 |
+
"linkage_types": linkage_types,
|
| 293 |
+
"protein_emb": protein_padded, # [B, Lp_max, D]
|
| 294 |
+
"protein_mask": protein_mask, # [B, Lp_max]
|
| 295 |
+
"target": targets,
|
| 296 |
+
"has_conc": has_conc,
|
| 297 |
+
"log_conc": log_conc,
|
| 298 |
+
}
|
code/bertint/generate_glycan_splits.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Generate glycan-cold splits for the combined Bertint dataset.
|
| 3 |
+
Clusters glycans by WURCS string edit distance, assigns to train/val/test.
|
| 4 |
+
"""
|
| 5 |
+
import argparse
|
| 6 |
+
import json
|
| 7 |
+
import csv
|
| 8 |
+
import random
|
| 9 |
+
from typing import Dict, List, Set, Tuple
|
| 10 |
+
from collections import defaultdict
|
| 11 |
+
|
| 12 |
+
def levenshtein_ratio(s1: str, s2: str) -> float:
|
| 13 |
+
"""Compute normalized Levenshtein distance [0,1]."""
|
| 14 |
+
if s1 == s2:
|
| 15 |
+
return 0.0
|
| 16 |
+
len1, len2 = len(s1), len(s2)
|
| 17 |
+
if len1 == 0 or len2 == 0:
|
| 18 |
+
return 1.0
|
| 19 |
+
prev_row = list(range(len2 + 1))
|
| 20 |
+
curr_row = [0] * (len2 + 1)
|
| 21 |
+
for i in range(1, len1 + 1):
|
| 22 |
+
curr_row[0] = i
|
| 23 |
+
for j in range(1, len2 + 1):
|
| 24 |
+
cost = 0 if s1[i-1] == s2[j-1] else 1
|
| 25 |
+
curr_row[j] = min(curr_row[j-1]+1, prev_row[j]+1, prev_row[j-1]+cost)
|
| 26 |
+
prev_row, curr_row = curr_row, prev_row
|
| 27 |
+
return prev_row[len2] / max(len1, len2)
|
| 28 |
+
|
| 29 |
+
def simple_cluster(glycans: List[str], threshold: float = 0.5, max_pw: int = 5000) -> List[List[str]]:
|
| 30 |
+
"""Cluster glycans by WURCS similarity using union-find."""
|
| 31 |
+
n = len(glycans)
|
| 32 |
+
print(f" Clustering {n} glycans (threshold={threshold})...")
|
| 33 |
+
parent = list(range(n))
|
| 34 |
+
def find(x):
|
| 35 |
+
while parent[x] != x:
|
| 36 |
+
parent[x] = parent[parent[x]]
|
| 37 |
+
x = parent[x]
|
| 38 |
+
return x
|
| 39 |
+
def union(x, y):
|
| 40 |
+
px, py = find(x), find(y)
|
| 41 |
+
if px != py:
|
| 42 |
+
parent[px] = py
|
| 43 |
+
if n <= max_pw:
|
| 44 |
+
total = n*(n-1)//2; done = 0
|
| 45 |
+
for i in range(n):
|
| 46 |
+
for j in range(i+1, n):
|
| 47 |
+
if levenshtein_ratio(glycans[i], glycans[j]) < threshold:
|
| 48 |
+
union(i, j)
|
| 49 |
+
done += 1
|
| 50 |
+
if done % 500000 == 0:
|
| 51 |
+
print(f" {done}/{total} pairs ({done/total*100:.1f}%)...")
|
| 52 |
+
else:
|
| 53 |
+
print(f" Using prefix grouping (n={n} > {max_pw})")
|
| 54 |
+
groups = defaultdict(list)
|
| 55 |
+
for i, g in enumerate(glycans):
|
| 56 |
+
parts = g.split("/")
|
| 57 |
+
prefix = "/".join(parts[:3]) if len(parts) >= 3 else g[:50]
|
| 58 |
+
groups[prefix].append(i)
|
| 59 |
+
for indices in groups.values():
|
| 60 |
+
for a in range(len(indices)):
|
| 61 |
+
for b in range(a+1, len(indices)):
|
| 62 |
+
if levenshtein_ratio(glycans[indices[a]], glycans[indices[b]]) < threshold:
|
| 63 |
+
union(indices[a], indices[b])
|
| 64 |
+
clusters_map = defaultdict(list)
|
| 65 |
+
for i in range(n):
|
| 66 |
+
clusters_map[find(i)].append(glycans[i])
|
| 67 |
+
clusters = list(clusters_map.values())
|
| 68 |
+
sizes = [len(c) for c in clusters]
|
| 69 |
+
print(f" {len(clusters)} clusters (largest={max(sizes)}, median={sorted(sizes)[len(sizes)//2]})")
|
| 70 |
+
return clusters
|
| 71 |
+
|
| 72 |
+
def assign_splits(clusters, ratios=(0.7,0.15,0.15), seed=42):
|
| 73 |
+
"""Assign clusters to train/val/test."""
|
| 74 |
+
random.seed(seed)
|
| 75 |
+
indexed = list(enumerate(clusters))
|
| 76 |
+
random.shuffle(indexed)
|
| 77 |
+
indexed.sort(key=lambda x: len(x[1]), reverse=True)
|
| 78 |
+
total = sum(len(c) for c in clusters)
|
| 79 |
+
train_t, val_t = ratios[0]*total, ratios[1]*total
|
| 80 |
+
splits = {"train_glycans":[], "val_glycans":[], "test_glycans":[]}
|
| 81 |
+
counts = {"train":0, "val":0, "test":0}
|
| 82 |
+
for _, cluster in indexed:
|
| 83 |
+
tg = train_t - counts["train"]
|
| 84 |
+
vg = val_t - counts["val"]
|
| 85 |
+
if tg >= vg and tg > 0:
|
| 86 |
+
splits["train_glycans"].extend(cluster); counts["train"] += len(cluster)
|
| 87 |
+
elif vg > 0:
|
| 88 |
+
splits["val_glycans"].extend(cluster); counts["val"] += len(cluster)
|
| 89 |
+
else:
|
| 90 |
+
splits["test_glycans"].extend(cluster); counts["test"] += len(cluster)
|
| 91 |
+
for k, v in splits.items():
|
| 92 |
+
print(f" {k}: {len(v)} ({len(v)/total*100:.1f}%)")
|
| 93 |
+
return splits
|
| 94 |
+
|
| 95 |
+
def main():
|
| 96 |
+
parser = argparse.ArgumentParser()
|
| 97 |
+
parser.add_argument("--csv", required=True)
|
| 98 |
+
parser.add_argument("--output", required=True)
|
| 99 |
+
parser.add_argument("--threshold", type=float, default=0.5)
|
| 100 |
+
parser.add_argument("--seed", type=int, default=42)
|
| 101 |
+
args = parser.parse_args()
|
| 102 |
+
glycans = set()
|
| 103 |
+
with open(args.csv) as f:
|
| 104 |
+
for row in csv.DictReader(f):
|
| 105 |
+
glycans.add(row["glycan_wurcs"])
|
| 106 |
+
glycan_list = sorted(glycans)
|
| 107 |
+
print(f" {len(glycan_list)} unique glycans")
|
| 108 |
+
clusters = simple_cluster(glycan_list, args.threshold)
|
| 109 |
+
splits = assign_splits(clusters, seed=args.seed)
|
| 110 |
+
ts = set(splits["train_glycans"]); vs = set(splits["val_glycans"]); es = set(splits["test_glycans"])
|
| 111 |
+
assert not (ts & vs) and not (ts & es) and not (vs & es), "Overlap!"
|
| 112 |
+
assert len(ts|vs|es) == len(glycan_list), "Missing glycans!"
|
| 113 |
+
print(f" No overlap, all {len(glycan_list)} assigned")
|
| 114 |
+
with open(args.output, "w") as f:
|
| 115 |
+
json.dump(splits, f, indent=2)
|
| 116 |
+
print(f" Saved to {args.output}")
|
| 117 |
+
|
| 118 |
+
if __name__ == "__main__":
|
| 119 |
+
main()
|
code/bertint/training_v8.py
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Bertint V8 Training — Cross-Attention + Live Bertose Finetuning
|
| 3 |
+
|
| 4 |
+
Based on V7 trainer with changes for V8 architecture:
|
| 5 |
+
- Per-residue protein embeddings (variable-length, padded in collate)
|
| 6 |
+
- protein_mask passed to model for cross-attention
|
| 7 |
+
- AMP (GradScaler + autocast) built in from the start
|
| 8 |
+
- Regression only (no classification mode — V7 showed regression wins)
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import argparse
|
| 12 |
+
import json
|
| 13 |
+
import logging
|
| 14 |
+
import os
|
| 15 |
+
import random
|
| 16 |
+
import sys
|
| 17 |
+
import time
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Dict, List, Optional, Tuple
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
import torch
|
| 23 |
+
import torch.nn as nn
|
| 24 |
+
from scipy.stats import spearmanr, pearsonr
|
| 25 |
+
from torch.cuda.amp import GradScaler, autocast
|
| 26 |
+
from torch.utils.data import DataLoader
|
| 27 |
+
|
| 28 |
+
from bertint_v8 import BertintV8, BertintV8Loss, load_bertose_encoder
|
| 29 |
+
from dataset_v8 import BertintV8Dataset, collate_fn
|
| 30 |
+
|
| 31 |
+
logging.basicConfig(
|
| 32 |
+
level=logging.INFO,
|
| 33 |
+
format="%(asctime)s - %(levelname)s - %(message)s",
|
| 34 |
+
)
|
| 35 |
+
logger = logging.getLogger(__name__)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ============================================================================
|
| 39 |
+
# Reproducibility
|
| 40 |
+
# ============================================================================
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def set_seed(seed: int = 42) -> None:
|
| 44 |
+
"""Set random seeds for reproducibility."""
|
| 45 |
+
random.seed(seed)
|
| 46 |
+
np.random.seed(seed)
|
| 47 |
+
torch.manual_seed(seed)
|
| 48 |
+
if torch.cuda.is_available():
|
| 49 |
+
torch.cuda.manual_seed_all(seed)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# ============================================================================
|
| 53 |
+
# Metrics
|
| 54 |
+
# ============================================================================
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def compute_metrics(
|
| 58 |
+
preds: np.ndarray, targets: np.ndarray
|
| 59 |
+
) -> Dict[str, float]:
|
| 60 |
+
"""Compute Spearman, Pearson, MSE."""
|
| 61 |
+
rho, _ = spearmanr(preds, targets)
|
| 62 |
+
r, _ = pearsonr(preds, targets)
|
| 63 |
+
mse = np.mean((preds - targets) ** 2)
|
| 64 |
+
return {
|
| 65 |
+
"spearman": float(rho) if not np.isnan(rho) else 0.0,
|
| 66 |
+
"pearson": float(r) if not np.isnan(r) else 0.0,
|
| 67 |
+
"mse": float(mse),
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# ============================================================================
|
| 72 |
+
# Trainer
|
| 73 |
+
# ============================================================================
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class BertintV8Trainer:
|
| 77 |
+
"""
|
| 78 |
+
Trainer for BertintV8 with cross-attention and AMP.
|
| 79 |
+
|
| 80 |
+
Args:
|
| 81 |
+
model: BertintV8 model.
|
| 82 |
+
criterion: Loss function.
|
| 83 |
+
train_loader: Training data loader.
|
| 84 |
+
val_loader: Validation data loader.
|
| 85 |
+
test_loader: Test data loader.
|
| 86 |
+
output_dir: Directory for checkpoints and results.
|
| 87 |
+
lr_encoder: Learning rate for Bertose encoder layers.
|
| 88 |
+
lr_head: Learning rate for cross-attention, SWE, and head.
|
| 89 |
+
weight_decay: Weight decay for AdamW.
|
| 90 |
+
max_grad_norm: Maximum gradient norm for clipping.
|
| 91 |
+
epochs: Number of training epochs.
|
| 92 |
+
patience: Early stopping patience.
|
| 93 |
+
checkpoint_interval: Save checkpoint every N epochs.
|
| 94 |
+
resume: Whether to resume from last checkpoint.
|
| 95 |
+
"""
|
| 96 |
+
|
| 97 |
+
def __init__(
|
| 98 |
+
self,
|
| 99 |
+
model: BertintV8,
|
| 100 |
+
criterion: nn.Module,
|
| 101 |
+
train_loader: DataLoader,
|
| 102 |
+
val_loader: DataLoader,
|
| 103 |
+
test_loader: DataLoader,
|
| 104 |
+
output_dir: str,
|
| 105 |
+
lr_encoder: float = 1e-5,
|
| 106 |
+
lr_head: float = 1e-4,
|
| 107 |
+
weight_decay: float = 0.01,
|
| 108 |
+
max_grad_norm: float = 1.0,
|
| 109 |
+
epochs: int = 50,
|
| 110 |
+
patience: int = 15,
|
| 111 |
+
checkpoint_interval: int = 5,
|
| 112 |
+
resume: bool = False,
|
| 113 |
+
warmup_pct: float = 0.0,
|
| 114 |
+
):
|
| 115 |
+
self.model = model
|
| 116 |
+
self.criterion = criterion
|
| 117 |
+
self.train_loader = train_loader
|
| 118 |
+
self.val_loader = val_loader
|
| 119 |
+
self.test_loader = test_loader
|
| 120 |
+
self.output_dir = output_dir
|
| 121 |
+
self.epochs = epochs
|
| 122 |
+
self.patience = patience
|
| 123 |
+
self.checkpoint_interval = checkpoint_interval
|
| 124 |
+
self.resume = resume
|
| 125 |
+
self.max_grad_norm = max_grad_norm
|
| 126 |
+
|
| 127 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 128 |
+
|
| 129 |
+
self.device = torch.device(
|
| 130 |
+
"cuda" if torch.cuda.is_available() else "cpu"
|
| 131 |
+
)
|
| 132 |
+
self.model.to(self.device)
|
| 133 |
+
self.criterion.to(self.device)
|
| 134 |
+
|
| 135 |
+
# AMP scaler
|
| 136 |
+
self.scaler = GradScaler()
|
| 137 |
+
|
| 138 |
+
# Separate param groups: encoder (small lr) vs rest (larger lr)
|
| 139 |
+
encoder_params = []
|
| 140 |
+
head_params = []
|
| 141 |
+
for name, param in model.named_parameters():
|
| 142 |
+
if not param.requires_grad:
|
| 143 |
+
continue
|
| 144 |
+
if name.startswith("seq_embeddings") or name.startswith(
|
| 145 |
+
"seq_layers"
|
| 146 |
+
):
|
| 147 |
+
encoder_params.append(param)
|
| 148 |
+
else:
|
| 149 |
+
head_params.append(param)
|
| 150 |
+
|
| 151 |
+
logger.info(
|
| 152 |
+
f" Param groups: encoder={len(encoder_params)} tensors "
|
| 153 |
+
f"(lr={lr_encoder}), head={len(head_params)} tensors "
|
| 154 |
+
f"(lr={lr_head})"
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
self.optimizer = torch.optim.AdamW(
|
| 158 |
+
[
|
| 159 |
+
{
|
| 160 |
+
"params": encoder_params,
|
| 161 |
+
"lr": lr_encoder,
|
| 162 |
+
"weight_decay": weight_decay,
|
| 163 |
+
},
|
| 164 |
+
{
|
| 165 |
+
"params": head_params,
|
| 166 |
+
"lr": lr_head,
|
| 167 |
+
"weight_decay": weight_decay,
|
| 168 |
+
},
|
| 169 |
+
]
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
# OneCycleLR with per-batch stepping (matches Twin Peaks pattern)
|
| 173 |
+
# Built-in warmup (pct_start) + cosine annealing
|
| 174 |
+
total_steps = len(train_loader) * epochs
|
| 175 |
+
if warmup_pct > 0:
|
| 176 |
+
pct_start = warmup_pct
|
| 177 |
+
else:
|
| 178 |
+
pct_start = 0.3 # Default: 30% warmup
|
| 179 |
+
|
| 180 |
+
self.scheduler = torch.optim.lr_scheduler.OneCycleLR(
|
| 181 |
+
self.optimizer,
|
| 182 |
+
max_lr=[lr_encoder, lr_head],
|
| 183 |
+
total_steps=total_steps,
|
| 184 |
+
pct_start=pct_start,
|
| 185 |
+
anneal_strategy='cos',
|
| 186 |
+
)
|
| 187 |
+
warmup_steps_actual = int(total_steps * pct_start)
|
| 188 |
+
logger.info(
|
| 189 |
+
f" Scheduler: OneCycleLR per-batch stepping"
|
| 190 |
+
)
|
| 191 |
+
logger.info(
|
| 192 |
+
f" total_steps={total_steps:,}, "
|
| 193 |
+
f"warmup={warmup_steps_actual:,} steps "
|
| 194 |
+
f"({pct_start*100:.0f}%), cosine decay"
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
# State
|
| 198 |
+
self.start_epoch = 0
|
| 199 |
+
self.best_metric = -float("inf")
|
| 200 |
+
self.patience_counter = 0
|
| 201 |
+
self.history: List[Dict] = []
|
| 202 |
+
|
| 203 |
+
if resume:
|
| 204 |
+
self._resume_from_checkpoint()
|
| 205 |
+
|
| 206 |
+
def train(self) -> Dict:
|
| 207 |
+
"""Full training loop with early stopping."""
|
| 208 |
+
logger.info(f"\nStarting V8 training for {self.epochs} epochs")
|
| 209 |
+
logger.info(f" Device: {self.device}")
|
| 210 |
+
logger.info(f" Train batches: {len(self.train_loader)}")
|
| 211 |
+
logger.info(f" Val batches: {len(self.val_loader)}")
|
| 212 |
+
logger.info(f" AMP: enabled")
|
| 213 |
+
|
| 214 |
+
for epoch in range(self.start_epoch, self.epochs):
|
| 215 |
+
t0 = time.time()
|
| 216 |
+
|
| 217 |
+
train_loss = self._train_epoch(epoch)
|
| 218 |
+
val_loss, val_metrics = self._eval_epoch(self.val_loader)
|
| 219 |
+
|
| 220 |
+
elapsed = time.time() - t0
|
| 221 |
+
rho = val_metrics["spearman"]
|
| 222 |
+
r = val_metrics["pearson"]
|
| 223 |
+
|
| 224 |
+
logger.info(
|
| 225 |
+
f" Epoch {epoch + 1:3d} | Train loss={train_loss:.4f} | "
|
| 226 |
+
f"Val loss={val_loss:.4f} rho={rho:.4f} r={r:.4f} | "
|
| 227 |
+
f"{elapsed:.1f}s"
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
# Track best
|
| 231 |
+
if rho > self.best_metric:
|
| 232 |
+
self.best_metric = rho
|
| 233 |
+
self.patience_counter = 0
|
| 234 |
+
torch.save(
|
| 235 |
+
self.model.state_dict(),
|
| 236 |
+
os.path.join(self.output_dir, "best_model.pt"),
|
| 237 |
+
)
|
| 238 |
+
logger.info(f" * New best: {rho:.4f}")
|
| 239 |
+
else:
|
| 240 |
+
self.patience_counter += 1
|
| 241 |
+
|
| 242 |
+
# History
|
| 243 |
+
self.history.append(
|
| 244 |
+
{
|
| 245 |
+
"epoch": epoch + 1,
|
| 246 |
+
"train_loss": train_loss,
|
| 247 |
+
"val_loss": val_loss,
|
| 248 |
+
"val_metrics": val_metrics,
|
| 249 |
+
"lr_encoder": self.optimizer.param_groups[0]["lr"],
|
| 250 |
+
"lr_head": self.optimizer.param_groups[1]["lr"],
|
| 251 |
+
}
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
# (scheduler.step() is now called per-batch in _train_epoch)
|
| 255 |
+
|
| 256 |
+
# Periodic checkpoint
|
| 257 |
+
if (epoch + 1) % self.checkpoint_interval == 0:
|
| 258 |
+
self._save_checkpoint(epoch + 1)
|
| 259 |
+
|
| 260 |
+
# Early stopping
|
| 261 |
+
if self.patience_counter >= self.patience:
|
| 262 |
+
logger.info(
|
| 263 |
+
f" Early stopping at epoch {epoch + 1} "
|
| 264 |
+
f"(no improvement for {self.patience} epochs)"
|
| 265 |
+
)
|
| 266 |
+
break
|
| 267 |
+
|
| 268 |
+
# Load best and test
|
| 269 |
+
logger.info(f"\n{'=' * 60}")
|
| 270 |
+
logger.info("Loading best model for test evaluation...")
|
| 271 |
+
best_path = os.path.join(self.output_dir, "best_model.pt")
|
| 272 |
+
self.model.load_state_dict(
|
| 273 |
+
torch.load(best_path, map_location=self.device)
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
test_loss, test_metrics = self._eval_epoch(self.test_loader)
|
| 277 |
+
|
| 278 |
+
logger.info(f"\n{'=' * 60}")
|
| 279 |
+
logger.info("TEST RESULTS:")
|
| 280 |
+
logger.info(f" Spearman rho: {test_metrics['spearman']:.4f}")
|
| 281 |
+
logger.info(f" Pearson r: {test_metrics['pearson']:.4f}")
|
| 282 |
+
logger.info(f" MSE: {test_metrics['mse']:.6f}")
|
| 283 |
+
logger.info(f"{'=' * 60}")
|
| 284 |
+
|
| 285 |
+
# Save results
|
| 286 |
+
results = {
|
| 287 |
+
"task_type": "regression",
|
| 288 |
+
"architecture": "cross-attention + SWE + live Bertose",
|
| 289 |
+
"best_metric": self.best_metric,
|
| 290 |
+
"test_metrics": test_metrics,
|
| 291 |
+
"test_loss": test_loss,
|
| 292 |
+
"history": self.history,
|
| 293 |
+
}
|
| 294 |
+
results_path = os.path.join(self.output_dir, "results.json")
|
| 295 |
+
with open(results_path, "w") as f:
|
| 296 |
+
json.dump(results, f, indent=2)
|
| 297 |
+
logger.info(f"Results saved to {results_path}")
|
| 298 |
+
|
| 299 |
+
return results
|
| 300 |
+
|
| 301 |
+
def _train_epoch(self, epoch: int) -> float:
|
| 302 |
+
"""Run one training epoch with AMP."""
|
| 303 |
+
self.model.train()
|
| 304 |
+
total_loss = 0.0
|
| 305 |
+
n_batches = len(self.train_loader)
|
| 306 |
+
|
| 307 |
+
for batch_idx, batch in enumerate(self.train_loader):
|
| 308 |
+
# Move to device
|
| 309 |
+
token_ids = batch["token_ids"].to(self.device)
|
| 310 |
+
attention_mask = batch["attention_mask"].to(self.device)
|
| 311 |
+
branch_depths = batch["branch_depths"].to(self.device)
|
| 312 |
+
linkage_types = batch["linkage_types"].to(self.device)
|
| 313 |
+
protein_emb = batch["protein_emb"].to(self.device)
|
| 314 |
+
protein_mask = batch["protein_mask"].to(self.device)
|
| 315 |
+
target = batch["target"].to(self.device)
|
| 316 |
+
|
| 317 |
+
self.optimizer.zero_grad()
|
| 318 |
+
|
| 319 |
+
# AMP forward
|
| 320 |
+
with autocast():
|
| 321 |
+
pred = self.model(
|
| 322 |
+
token_ids=token_ids,
|
| 323 |
+
attention_mask=attention_mask,
|
| 324 |
+
branch_depths=branch_depths,
|
| 325 |
+
linkage_types=linkage_types,
|
| 326 |
+
protein_emb=protein_emb,
|
| 327 |
+
protein_mask=protein_mask,
|
| 328 |
+
)
|
| 329 |
+
loss = self.criterion(pred, target)
|
| 330 |
+
|
| 331 |
+
# AMP backward
|
| 332 |
+
self.scaler.scale(loss).backward()
|
| 333 |
+
self.scaler.unscale_(self.optimizer)
|
| 334 |
+
torch.nn.utils.clip_grad_norm_(
|
| 335 |
+
self.model.parameters(), self.max_grad_norm
|
| 336 |
+
)
|
| 337 |
+
self.scaler.step(self.optimizer)
|
| 338 |
+
self.scaler.update()
|
| 339 |
+
|
| 340 |
+
# Per-batch LR scheduling (OneCycleLR)
|
| 341 |
+
self.scheduler.step()
|
| 342 |
+
|
| 343 |
+
total_loss += loss.item()
|
| 344 |
+
|
| 345 |
+
# Progress logging
|
| 346 |
+
if (batch_idx + 1) % 200 == 0:
|
| 347 |
+
avg = total_loss / (batch_idx + 1)
|
| 348 |
+
lr_enc = self.optimizer.param_groups[0]["lr"]
|
| 349 |
+
logger.info(
|
| 350 |
+
f" [E{epoch + 1}][{batch_idx + 1}/{n_batches}] "
|
| 351 |
+
f"loss={avg:.4f} lr_enc={lr_enc:.2e}"
|
| 352 |
+
)
|
| 353 |
+
|
| 354 |
+
return total_loss / n_batches
|
| 355 |
+
|
| 356 |
+
@torch.no_grad()
|
| 357 |
+
def _eval_epoch(
|
| 358 |
+
self, loader: DataLoader
|
| 359 |
+
) -> Tuple[float, Dict[str, float]]:
|
| 360 |
+
"""Run evaluation with AMP."""
|
| 361 |
+
self.model.eval()
|
| 362 |
+
total_loss = 0.0
|
| 363 |
+
all_preds = []
|
| 364 |
+
all_targets = []
|
| 365 |
+
|
| 366 |
+
for batch in loader:
|
| 367 |
+
token_ids = batch["token_ids"].to(self.device)
|
| 368 |
+
attention_mask = batch["attention_mask"].to(self.device)
|
| 369 |
+
branch_depths = batch["branch_depths"].to(self.device)
|
| 370 |
+
linkage_types = batch["linkage_types"].to(self.device)
|
| 371 |
+
protein_emb = batch["protein_emb"].to(self.device)
|
| 372 |
+
protein_mask = batch["protein_mask"].to(self.device)
|
| 373 |
+
target = batch["target"].to(self.device)
|
| 374 |
+
|
| 375 |
+
with autocast():
|
| 376 |
+
pred = self.model(
|
| 377 |
+
token_ids=token_ids,
|
| 378 |
+
attention_mask=attention_mask,
|
| 379 |
+
branch_depths=branch_depths,
|
| 380 |
+
linkage_types=linkage_types,
|
| 381 |
+
protein_emb=protein_emb,
|
| 382 |
+
protein_mask=protein_mask,
|
| 383 |
+
)
|
| 384 |
+
loss = self.criterion(pred, target)
|
| 385 |
+
|
| 386 |
+
total_loss += loss.item()
|
| 387 |
+
all_preds.extend(pred.float().cpu().numpy())
|
| 388 |
+
all_targets.extend(target.cpu().numpy())
|
| 389 |
+
|
| 390 |
+
avg_loss = total_loss / len(loader)
|
| 391 |
+
metrics = compute_metrics(
|
| 392 |
+
np.array(all_preds), np.array(all_targets)
|
| 393 |
+
)
|
| 394 |
+
return avg_loss, metrics
|
| 395 |
+
|
| 396 |
+
def _save_checkpoint(self, epoch: int) -> None:
|
| 397 |
+
"""Save full training state for resume."""
|
| 398 |
+
ckpt = {
|
| 399 |
+
"epoch": epoch,
|
| 400 |
+
"model_state_dict": self.model.state_dict(),
|
| 401 |
+
"optimizer_state_dict": self.optimizer.state_dict(),
|
| 402 |
+
"scheduler_state_dict": self.scheduler.state_dict(),
|
| 403 |
+
"scaler_state_dict": self.scaler.state_dict(),
|
| 404 |
+
"best_metric": self.best_metric,
|
| 405 |
+
"patience_counter": self.patience_counter,
|
| 406 |
+
"history": self.history,
|
| 407 |
+
}
|
| 408 |
+
path = os.path.join(self.output_dir, "last_checkpoint.pt")
|
| 409 |
+
torch.save(ckpt, path)
|
| 410 |
+
logger.info(f" [CKPT] Saved epoch {epoch}")
|
| 411 |
+
|
| 412 |
+
def _resume_from_checkpoint(self) -> None:
|
| 413 |
+
"""Resume training from last checkpoint."""
|
| 414 |
+
ckpt_path = os.path.join(self.output_dir, "last_checkpoint.pt")
|
| 415 |
+
if not os.path.exists(ckpt_path):
|
| 416 |
+
logger.info(" No checkpoint found, starting fresh")
|
| 417 |
+
return
|
| 418 |
+
|
| 419 |
+
ckpt = torch.load(ckpt_path, map_location=self.device)
|
| 420 |
+
self.model.load_state_dict(ckpt["model_state_dict"])
|
| 421 |
+
self.optimizer.load_state_dict(ckpt["optimizer_state_dict"])
|
| 422 |
+
self.scheduler.load_state_dict(ckpt["scheduler_state_dict"])
|
| 423 |
+
if "scaler_state_dict" in ckpt:
|
| 424 |
+
self.scaler.load_state_dict(ckpt["scaler_state_dict"])
|
| 425 |
+
self.start_epoch = ckpt["epoch"]
|
| 426 |
+
self.best_metric = ckpt["best_metric"]
|
| 427 |
+
self.patience_counter = ckpt["patience_counter"]
|
| 428 |
+
self.history = ckpt["history"]
|
| 429 |
+
logger.info(
|
| 430 |
+
f" Resumed from epoch {self.start_epoch}, "
|
| 431 |
+
f"best={self.best_metric:.4f}"
|
| 432 |
+
)
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
# ============================================================================
|
| 436 |
+
# Main
|
| 437 |
+
# ============================================================================
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
def main():
|
| 441 |
+
"""Entry point for V8 training."""
|
| 442 |
+
parser = argparse.ArgumentParser(description="Bertint V8 Training")
|
| 443 |
+
parser.add_argument(
|
| 444 |
+
"--csv_path", required=True, help="Path to binding data CSV"
|
| 445 |
+
)
|
| 446 |
+
parser.add_argument(
|
| 447 |
+
"--split_path", required=True, help="Path to glycan-cold splits JSON"
|
| 448 |
+
)
|
| 449 |
+
parser.add_argument(
|
| 450 |
+
"--protein_emb_path", required=True, help="Path to ESM-C HDF5"
|
| 451 |
+
)
|
| 452 |
+
parser.add_argument(
|
| 453 |
+
"--vocab_path", required=True, help="Path to BPE vocab JSON"
|
| 454 |
+
)
|
| 455 |
+
parser.add_argument(
|
| 456 |
+
"--bertose_checkpoint", required=True, help="Bertose checkpoint"
|
| 457 |
+
)
|
| 458 |
+
parser.add_argument("--output_dir", required=True, help="Output dir")
|
| 459 |
+
|
| 460 |
+
# Model architecture
|
| 461 |
+
parser.add_argument(
|
| 462 |
+
"--freeze_layers", type=int, default=4, help="Layers to freeze"
|
| 463 |
+
)
|
| 464 |
+
parser.add_argument(
|
| 465 |
+
"--shared_dim", type=int, default=512, help="Shared dim"
|
| 466 |
+
)
|
| 467 |
+
parser.add_argument(
|
| 468 |
+
"--num_cross_layers", type=int, default=2, help="Cross-attn layers"
|
| 469 |
+
)
|
| 470 |
+
parser.add_argument(
|
| 471 |
+
"--num_heads", type=int, default=8, help="Attention heads"
|
| 472 |
+
)
|
| 473 |
+
parser.add_argument(
|
| 474 |
+
"--swe_slices", type=int, default=512, help="SWE slices"
|
| 475 |
+
)
|
| 476 |
+
parser.add_argument(
|
| 477 |
+
"--dropout", type=float, default=0.1, help="Dropout rate"
|
| 478 |
+
)
|
| 479 |
+
parser.add_argument(
|
| 480 |
+
"--protein_dim", type=int, default=960, help="ESM-C dim"
|
| 481 |
+
)
|
| 482 |
+
parser.add_argument(
|
| 483 |
+
"--separate_swe", action="store_true",
|
| 484 |
+
help="Use separate SWE modules for glycan and protein"
|
| 485 |
+
)
|
| 486 |
+
|
| 487 |
+
# Training
|
| 488 |
+
parser.add_argument(
|
| 489 |
+
"--lr_encoder", type=float, default=1e-5, help="Encoder LR"
|
| 490 |
+
)
|
| 491 |
+
parser.add_argument(
|
| 492 |
+
"--lr_head", type=float, default=1e-4, help="Head LR"
|
| 493 |
+
)
|
| 494 |
+
parser.add_argument(
|
| 495 |
+
"--weight_decay", type=float, default=0.01, help="Weight decay"
|
| 496 |
+
)
|
| 497 |
+
parser.add_argument(
|
| 498 |
+
"--max_grad_norm", type=float, default=1.0, help="Grad clip"
|
| 499 |
+
)
|
| 500 |
+
parser.add_argument(
|
| 501 |
+
"--batch_size", type=int, default=32, help="Batch size"
|
| 502 |
+
)
|
| 503 |
+
parser.add_argument(
|
| 504 |
+
"--epochs", type=int, default=50, help="Max epochs"
|
| 505 |
+
)
|
| 506 |
+
parser.add_argument(
|
| 507 |
+
"--patience", type=int, default=15, help="Early stopping"
|
| 508 |
+
)
|
| 509 |
+
parser.add_argument(
|
| 510 |
+
"--max_glycan_length", type=int, default=256, help="Max glycan len"
|
| 511 |
+
)
|
| 512 |
+
parser.add_argument(
|
| 513 |
+
"--max_protein_length", type=int, default=1024, help="Max protein len"
|
| 514 |
+
)
|
| 515 |
+
parser.add_argument("--seed", type=int, default=42, help="Random seed")
|
| 516 |
+
parser.add_argument(
|
| 517 |
+
"--warmup_pct", type=float, default=0.05,
|
| 518 |
+
help="Fraction of total steps for warmup (0.05=5%%, 0.10=10%%)"
|
| 519 |
+
)
|
| 520 |
+
parser.add_argument(
|
| 521 |
+
"--target_col", default="target_rank", help="Target column"
|
| 522 |
+
)
|
| 523 |
+
parser.add_argument(
|
| 524 |
+
"--checkpoint_interval", type=int, default=5, help="Ckpt every N"
|
| 525 |
+
)
|
| 526 |
+
parser.add_argument(
|
| 527 |
+
"--resume", action="store_true", help="Resume from checkpoint"
|
| 528 |
+
)
|
| 529 |
+
# Ablation controls
|
| 530 |
+
parser.add_argument(
|
| 531 |
+
"--pooling_mode", default="swe",
|
| 532 |
+
choices=["swe", "mean", "joint_swe"],
|
| 533 |
+
help="Pooling strategy: swe (default), mean, or joint_swe"
|
| 534 |
+
)
|
| 535 |
+
parser.add_argument(
|
| 536 |
+
"--interaction_mode", default="product_sum",
|
| 537 |
+
choices=["product_sum", "concat"],
|
| 538 |
+
help="Interaction: product_sum (default) or concat"
|
| 539 |
+
)
|
| 540 |
+
parser.add_argument(
|
| 541 |
+
"--no_cross_attention", action="store_true",
|
| 542 |
+
help="Disable cross-attention blocks (ablation)"
|
| 543 |
+
)
|
| 544 |
+
|
| 545 |
+
args = parser.parse_args()
|
| 546 |
+
|
| 547 |
+
set_seed(args.seed)
|
| 548 |
+
logger.info("Bertint V8 Training — Cross-Attention + Live Bertose")
|
| 549 |
+
logger.info(f" freeze_layers={args.freeze_layers}")
|
| 550 |
+
logger.info(f" lr_encoder={args.lr_encoder}")
|
| 551 |
+
logger.info(f" lr_head={args.lr_head}")
|
| 552 |
+
logger.info(f" batch_size={args.batch_size}")
|
| 553 |
+
logger.info(f" shared_dim={args.shared_dim}")
|
| 554 |
+
logger.info(f" cross_layers={args.num_cross_layers}")
|
| 555 |
+
logger.info(f" separate_swe={args.separate_swe}")
|
| 556 |
+
logger.info(f" pooling_mode={args.pooling_mode}")
|
| 557 |
+
logger.info(f" interaction_mode={args.interaction_mode}")
|
| 558 |
+
logger.info(f" cross_attention={not args.no_cross_attention}")
|
| 559 |
+
|
| 560 |
+
# Load datasets
|
| 561 |
+
logger.info("\nLoading datasets...")
|
| 562 |
+
train_ds = BertintV8Dataset(
|
| 563 |
+
args.csv_path, args.split_path, "train",
|
| 564 |
+
args.protein_emb_path, args.vocab_path,
|
| 565 |
+
max_glycan_length=args.max_glycan_length,
|
| 566 |
+
max_protein_length=args.max_protein_length,
|
| 567 |
+
target_col=args.target_col,
|
| 568 |
+
)
|
| 569 |
+
val_ds = BertintV8Dataset(
|
| 570 |
+
args.csv_path, args.split_path, "val",
|
| 571 |
+
args.protein_emb_path, args.vocab_path,
|
| 572 |
+
max_glycan_length=args.max_glycan_length,
|
| 573 |
+
max_protein_length=args.max_protein_length,
|
| 574 |
+
target_col=args.target_col,
|
| 575 |
+
)
|
| 576 |
+
test_ds = BertintV8Dataset(
|
| 577 |
+
args.csv_path, args.split_path, "test",
|
| 578 |
+
args.protein_emb_path, args.vocab_path,
|
| 579 |
+
max_glycan_length=args.max_glycan_length,
|
| 580 |
+
max_protein_length=args.max_protein_length,
|
| 581 |
+
target_col=args.target_col,
|
| 582 |
+
)
|
| 583 |
+
|
| 584 |
+
train_loader = DataLoader(
|
| 585 |
+
train_ds, batch_size=args.batch_size, shuffle=True,
|
| 586 |
+
num_workers=4, pin_memory=True, collate_fn=collate_fn,
|
| 587 |
+
)
|
| 588 |
+
val_loader = DataLoader(
|
| 589 |
+
val_ds, batch_size=args.batch_size, shuffle=False,
|
| 590 |
+
num_workers=2, pin_memory=True, collate_fn=collate_fn,
|
| 591 |
+
)
|
| 592 |
+
test_loader = DataLoader(
|
| 593 |
+
test_ds, batch_size=args.batch_size, shuffle=False,
|
| 594 |
+
num_workers=2, pin_memory=True, collate_fn=collate_fn,
|
| 595 |
+
)
|
| 596 |
+
|
| 597 |
+
# Build model
|
| 598 |
+
logger.info("\nBuilding model...")
|
| 599 |
+
config, seq_emb, seq_layers = load_bertose_encoder(
|
| 600 |
+
args.bertose_checkpoint, freeze_layers=args.freeze_layers
|
| 601 |
+
)
|
| 602 |
+
|
| 603 |
+
model = BertintV8(
|
| 604 |
+
seq_embeddings=seq_emb,
|
| 605 |
+
seq_layers=seq_layers,
|
| 606 |
+
glycan_dim=config.seq_hidden_size,
|
| 607 |
+
protein_dim=args.protein_dim,
|
| 608 |
+
shared_dim=args.shared_dim,
|
| 609 |
+
num_cross_layers=args.num_cross_layers,
|
| 610 |
+
num_heads=args.num_heads,
|
| 611 |
+
swe_slices=args.swe_slices,
|
| 612 |
+
dropout=args.dropout,
|
| 613 |
+
separate_swe=args.separate_swe,
|
| 614 |
+
pooling_mode=args.pooling_mode,
|
| 615 |
+
interaction_mode=args.interaction_mode,
|
| 616 |
+
use_cross_attention=not args.no_cross_attention,
|
| 617 |
+
)
|
| 618 |
+
|
| 619 |
+
total_params = sum(p.numel() for p in model.parameters())
|
| 620 |
+
trainable_params = sum(
|
| 621 |
+
p.numel() for p in model.parameters() if p.requires_grad
|
| 622 |
+
)
|
| 623 |
+
logger.info(f" Total params: {total_params:,}")
|
| 624 |
+
logger.info(f" Trainable: {trainable_params:,}")
|
| 625 |
+
|
| 626 |
+
# Loss
|
| 627 |
+
criterion = BertintV8Loss()
|
| 628 |
+
|
| 629 |
+
# Train
|
| 630 |
+
trainer = BertintV8Trainer(
|
| 631 |
+
model=model,
|
| 632 |
+
criterion=criterion,
|
| 633 |
+
train_loader=train_loader,
|
| 634 |
+
val_loader=val_loader,
|
| 635 |
+
test_loader=test_loader,
|
| 636 |
+
output_dir=args.output_dir,
|
| 637 |
+
lr_encoder=args.lr_encoder,
|
| 638 |
+
lr_head=args.lr_head,
|
| 639 |
+
weight_decay=args.weight_decay,
|
| 640 |
+
max_grad_norm=args.max_grad_norm,
|
| 641 |
+
epochs=args.epochs,
|
| 642 |
+
patience=args.patience,
|
| 643 |
+
checkpoint_interval=args.checkpoint_interval,
|
| 644 |
+
resume=args.resume,
|
| 645 |
+
warmup_pct=args.warmup_pct,
|
| 646 |
+
)
|
| 647 |
+
|
| 648 |
+
results = trainer.train()
|
| 649 |
+
logger.info("\nTraining complete!")
|
| 650 |
+
|
| 651 |
+
|
| 652 |
+
if __name__ == "__main__":
|
| 653 |
+
main()
|
code/contrastive/contrastive_trainer_v51_FINAL.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
V5.1-FIXED Contrastive Trainer V6 CORRECT
|
| 4 |
+
|
| 5 |
+
Uses EXACTLY the correct MultimodalGlycanBERTConfig arguments.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import sys
|
| 10 |
+
import pickle
|
| 11 |
+
import random
|
| 12 |
+
import logging
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
import yaml
|
| 16 |
+
import torch
|
| 17 |
+
import torch.nn as nn
|
| 18 |
+
import torch.nn.functional as F
|
| 19 |
+
from torch.utils.data import Dataset, DataLoader
|
| 20 |
+
from torch.optim import AdamW
|
| 21 |
+
from torch.optim.lr_scheduler import CosineAnnealingLR
|
| 22 |
+
from torch.cuda.amp import autocast, GradScaler
|
| 23 |
+
from tqdm import tqdm
|
| 24 |
+
|
| 25 |
+
sys.path.insert(0, str(Path(__file__).parents[2]))
|
| 26 |
+
from bert_training_v4.model.multimodal_glycan_bert_v3 import (
|
| 27 |
+
MultimodalGlycanBERT,
|
| 28 |
+
MultimodalGlycanBERTConfig
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
| 32 |
+
logger = logging.getLogger(__name__)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class InfoNCELoss(nn.Module):
|
| 36 |
+
def __init__(self, temperature: float = 0.1):
|
| 37 |
+
super().__init__()
|
| 38 |
+
self.temperature = temperature
|
| 39 |
+
|
| 40 |
+
def forward(self, anchor, positive, negatives):
|
| 41 |
+
anchor = F.normalize(anchor, dim=-1)
|
| 42 |
+
positive = F.normalize(positive, dim=-1)
|
| 43 |
+
negatives = F.normalize(negatives, dim=-1)
|
| 44 |
+
|
| 45 |
+
pos_sim = (anchor * positive).sum(-1) / self.temperature
|
| 46 |
+
neg_sim = torch.bmm(negatives, anchor.unsqueeze(-1)).squeeze(-1) / self.temperature
|
| 47 |
+
|
| 48 |
+
all_logits = torch.cat([pos_sim.unsqueeze(-1), neg_sim], dim=-1)
|
| 49 |
+
return (-pos_sim + torch.logsumexp(all_logits, dim=-1)).mean()
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class ContrastiveDataset(Dataset):
|
| 53 |
+
def __init__(self, positives, negatives, n_neg=5, max_len=256):
|
| 54 |
+
self.positives = positives
|
| 55 |
+
self.negatives = negatives
|
| 56 |
+
self.n_neg = n_neg
|
| 57 |
+
self.max_len = max_len
|
| 58 |
+
|
| 59 |
+
def __len__(self):
|
| 60 |
+
return len(self.positives)
|
| 61 |
+
|
| 62 |
+
def _prepare(self, token_ids):
|
| 63 |
+
token_ids = token_ids[:self.max_len]
|
| 64 |
+
residue_ids = list(range(len(token_ids)))
|
| 65 |
+
attention_mask = [1] * len(token_ids)
|
| 66 |
+
|
| 67 |
+
pad_len = self.max_len - len(token_ids)
|
| 68 |
+
token_ids = token_ids + [0] * pad_len
|
| 69 |
+
residue_ids = residue_ids + [0] * pad_len
|
| 70 |
+
attention_mask = attention_mask + [0] * pad_len
|
| 71 |
+
|
| 72 |
+
return (
|
| 73 |
+
torch.tensor(token_ids, dtype=torch.long),
|
| 74 |
+
torch.tensor(attention_mask, dtype=torch.long),
|
| 75 |
+
torch.tensor(residue_ids, dtype=torch.long)
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
def __getitem__(self, idx):
|
| 79 |
+
pos = self.positives[idx]
|
| 80 |
+
a_ids, a_mask, a_res = self._prepare(pos['token_ids'])
|
| 81 |
+
p_ids, p_mask, p_res = self._prepare(pos['token_ids'])
|
| 82 |
+
|
| 83 |
+
negs = random.sample(self.negatives, self.n_neg)
|
| 84 |
+
n_ids, n_masks, n_res = [], [], []
|
| 85 |
+
for neg in negs:
|
| 86 |
+
ids, mask, res = self._prepare(neg['token_ids'])
|
| 87 |
+
n_ids.append(ids)
|
| 88 |
+
n_masks.append(mask)
|
| 89 |
+
n_res.append(res)
|
| 90 |
+
|
| 91 |
+
return {
|
| 92 |
+
'anchor_ids': a_ids, 'anchor_mask': a_mask, 'anchor_res': a_res,
|
| 93 |
+
'pos_ids': p_ids, 'pos_mask': p_mask, 'pos_res': p_res,
|
| 94 |
+
'neg_ids': torch.stack(n_ids), 'neg_masks': torch.stack(n_masks), 'neg_res': torch.stack(n_res)
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class ProjectionHead(nn.Module):
|
| 99 |
+
def __init__(self, in_dim=768, out_dim=256):
|
| 100 |
+
super().__init__()
|
| 101 |
+
self.net = nn.Sequential(nn.Linear(in_dim, in_dim), nn.ReLU(), nn.Linear(in_dim, out_dim))
|
| 102 |
+
def forward(self, x): return self.net(x)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def load_model(checkpoint_path, config_path, device):
|
| 106 |
+
"""Load model using EXACT config args from MultimodalGlycanBERTConfig."""
|
| 107 |
+
with open(config_path) as f:
|
| 108 |
+
cfg = yaml.safe_load(f)['model']
|
| 109 |
+
|
| 110 |
+
seq = cfg['sequence']
|
| 111 |
+
ms = cfg.get('mass_spectrometry', {})
|
| 112 |
+
st = cfg.get('structure_3d', {})
|
| 113 |
+
|
| 114 |
+
# EXACT args that MultimodalGlycanBERTConfig.__init__ accepts:
|
| 115 |
+
config = MultimodalGlycanBERTConfig(
|
| 116 |
+
# Sequence
|
| 117 |
+
seq_vocab_size=seq.get('vocab_size', 2200),
|
| 118 |
+
seq_hidden_size=seq.get('hidden_size', 768),
|
| 119 |
+
seq_num_layers=seq.get('num_hidden_layers', 12),
|
| 120 |
+
seq_num_heads=seq.get('num_attention_heads', 12),
|
| 121 |
+
seq_max_length=seq.get('max_length', 256),
|
| 122 |
+
# MS
|
| 123 |
+
ms_vocab_size=ms.get('vocab_size', 242),
|
| 124 |
+
ms_hidden_size=ms.get('hidden_size', 384),
|
| 125 |
+
ms_num_layers=ms.get('num_hidden_layers', 6),
|
| 126 |
+
ms_num_heads=ms.get('num_attention_heads', 6),
|
| 127 |
+
ms_max_length=ms.get('max_length', 150),
|
| 128 |
+
# Structure
|
| 129 |
+
struct_vocab_size=st.get('vocab_size', 1024),
|
| 130 |
+
struct_hidden_size=st.get('hidden_size', 512),
|
| 131 |
+
struct_num_layers=st.get('num_hidden_layers', 8),
|
| 132 |
+
struct_num_heads=st.get('num_attention_heads', 8),
|
| 133 |
+
struct_max_length=st.get('max_length', 200),
|
| 134 |
+
use_3d=st.get('enabled', True),
|
| 135 |
+
# Cross-attention
|
| 136 |
+
use_cross_attention=st.get('use_cross_attention', True),
|
| 137 |
+
# Conv
|
| 138 |
+
use_cnn_frontend=seq.get('use_cnn_frontend', True),
|
| 139 |
+
cnn_kernel_size=seq.get('cnn_kernel_size', 3),
|
| 140 |
+
# Dropout
|
| 141 |
+
hidden_dropout_prob=seq.get('hidden_dropout_prob', 0.1),
|
| 142 |
+
attention_probs_dropout_prob=seq.get('attention_probs_dropout_prob', 0.1),
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
logger.info("Creating model...")
|
| 146 |
+
model = MultimodalGlycanBERT(config)
|
| 147 |
+
|
| 148 |
+
logger.info(f"Loading checkpoint from {checkpoint_path}")
|
| 149 |
+
ckpt = torch.load(checkpoint_path, map_location=device, weights_only=False)
|
| 150 |
+
|
| 151 |
+
if 'model_state_dict' in ckpt:
|
| 152 |
+
model.load_state_dict(ckpt["model_state_dict"], strict=False)
|
| 153 |
+
logger.info(f"Loaded epoch {ckpt.get('epoch', '?')}, best_val_loss={ckpt.get('best_val_loss', '?'):.4f}")
|
| 154 |
+
else:
|
| 155 |
+
model.load_state_dict(ckpt, strict=False)
|
| 156 |
+
|
| 157 |
+
model.to(device)
|
| 158 |
+
logger.info(f"Model loaded successfully! Params: {sum(p.numel() for p in model.parameters()):,}")
|
| 159 |
+
return model, config
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def main():
|
| 163 |
+
import argparse
|
| 164 |
+
parser = argparse.ArgumentParser()
|
| 165 |
+
parser.add_argument('--model_path', required=True)
|
| 166 |
+
parser.add_argument('--config_path', required=True)
|
| 167 |
+
parser.add_argument('--positives_path', required=True)
|
| 168 |
+
parser.add_argument('--negatives_path', required=True)
|
| 169 |
+
parser.add_argument('--output_dir', required=True)
|
| 170 |
+
parser.add_argument('--epochs', type=int, default=50)
|
| 171 |
+
parser.add_argument('--batch_size', type=int, default=16)
|
| 172 |
+
parser.add_argument('--lr', type=float, default=2e-5)
|
| 173 |
+
parser.add_argument('--n_neg', type=int, default=5)
|
| 174 |
+
parser.add_argument('--temperature', type=float, default=0.1)
|
| 175 |
+
parser.add_argument('--save_interval', type=int, default=5)
|
| 176 |
+
args = parser.parse_args()
|
| 177 |
+
|
| 178 |
+
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
| 179 |
+
logger.info(f"Device: {device}")
|
| 180 |
+
|
| 181 |
+
Path(args.output_dir).mkdir(parents=True, exist_ok=True)
|
| 182 |
+
|
| 183 |
+
# Load model
|
| 184 |
+
logger.info(f"Loading model from {args.model_path}")
|
| 185 |
+
logger.info(f"Config from {args.config_path}")
|
| 186 |
+
model, config = load_model(args.model_path, args.config_path, device)
|
| 187 |
+
|
| 188 |
+
# Projection head
|
| 189 |
+
proj_head = ProjectionHead(in_dim=config.seq_hidden_size, out_dim=256).to(device)
|
| 190 |
+
|
| 191 |
+
# Data
|
| 192 |
+
logger.info("Loading data...")
|
| 193 |
+
with open(args.positives_path, 'rb') as f:
|
| 194 |
+
positives = pickle.load(f)
|
| 195 |
+
with open(args.negatives_path, 'rb') as f:
|
| 196 |
+
negatives = pickle.load(f)
|
| 197 |
+
logger.info(f"Positives: {len(positives)}, Negatives: {len(negatives)}")
|
| 198 |
+
|
| 199 |
+
dataset = ContrastiveDataset(positives, negatives, args.n_neg)
|
| 200 |
+
loader = DataLoader(dataset, batch_size=args.batch_size, shuffle=True, num_workers=4, pin_memory=True)
|
| 201 |
+
|
| 202 |
+
# Optimizer
|
| 203 |
+
optimizer = AdamW(list(model.parameters()) + list(proj_head.parameters()), lr=args.lr)
|
| 204 |
+
scheduler = CosineAnnealingLR(optimizer, T_max=args.epochs)
|
| 205 |
+
scaler = GradScaler()
|
| 206 |
+
loss_fn = InfoNCELoss(args.temperature)
|
| 207 |
+
|
| 208 |
+
best_loss = float('inf')
|
| 209 |
+
logger.info(f"Starting {args.epochs} epochs, {len(loader)} batches per epoch...")
|
| 210 |
+
|
| 211 |
+
for epoch in range(1, args.epochs + 1):
|
| 212 |
+
model.train()
|
| 213 |
+
proj_head.train()
|
| 214 |
+
total_loss = 0
|
| 215 |
+
|
| 216 |
+
pbar = tqdm(loader, desc=f"Epoch {epoch}/{args.epochs}")
|
| 217 |
+
for batch in pbar:
|
| 218 |
+
optimizer.zero_grad()
|
| 219 |
+
|
| 220 |
+
B = batch['anchor_ids'].shape[0]
|
| 221 |
+
N = batch['neg_ids'].shape[1]
|
| 222 |
+
L = batch['neg_ids'].shape[2]
|
| 223 |
+
|
| 224 |
+
with autocast():
|
| 225 |
+
# Forward pass - model returns dict with various outputs
|
| 226 |
+
anchor_out = model(
|
| 227 |
+
seq_token_ids=batch['anchor_ids'].to(device),
|
| 228 |
+
seq_attention_mask=batch['anchor_mask'].to(device),
|
| 229 |
+
seq_residue_ids=batch['anchor_res'].to(device),
|
| 230 |
+
compute_distance=False
|
| 231 |
+
)
|
| 232 |
+
pos_out = model(
|
| 233 |
+
seq_token_ids=batch['pos_ids'].to(device),
|
| 234 |
+
seq_attention_mask=batch['pos_mask'].to(device),
|
| 235 |
+
seq_residue_ids=batch['pos_res'].to(device),
|
| 236 |
+
compute_distance=False
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
# Get [CLS] embedding (first token of sequence hidden states)
|
| 240 |
+
anchor_emb = anchor_out['seq_hidden'][:, 0, :] # (B, hidden)
|
| 241 |
+
pos_emb = pos_out['seq_hidden'][:, 0, :]
|
| 242 |
+
|
| 243 |
+
# Negatives
|
| 244 |
+
neg_ids = batch['neg_ids'].view(B * N, L).to(device)
|
| 245 |
+
neg_masks = batch['neg_masks'].view(B * N, L).to(device)
|
| 246 |
+
neg_res = batch['neg_res'].view(B * N, L).to(device)
|
| 247 |
+
neg_out = model(
|
| 248 |
+
seq_token_ids=neg_ids,
|
| 249 |
+
seq_attention_mask=neg_masks,
|
| 250 |
+
seq_residue_ids=neg_res,
|
| 251 |
+
compute_distance=False
|
| 252 |
+
)
|
| 253 |
+
neg_emb = neg_out['seq_hidden'][:, 0, :].view(B, N, -1)
|
| 254 |
+
|
| 255 |
+
# Project
|
| 256 |
+
anchor_proj = proj_head(anchor_emb)
|
| 257 |
+
pos_proj = proj_head(pos_emb)
|
| 258 |
+
neg_proj = proj_head(neg_emb)
|
| 259 |
+
|
| 260 |
+
loss = loss_fn(anchor_proj, pos_proj, neg_proj)
|
| 261 |
+
|
| 262 |
+
scaler.scale(loss).backward()
|
| 263 |
+
scaler.step(optimizer)
|
| 264 |
+
scaler.update()
|
| 265 |
+
|
| 266 |
+
total_loss += loss.item()
|
| 267 |
+
pbar.set_postfix(loss=f"{loss.item():.4f}")
|
| 268 |
+
|
| 269 |
+
avg_loss = total_loss / len(loader)
|
| 270 |
+
scheduler.step()
|
| 271 |
+
logger.info(f"Epoch {epoch}: avg_loss={avg_loss:.4f}")
|
| 272 |
+
|
| 273 |
+
# Save
|
| 274 |
+
if epoch % args.save_interval == 0 or avg_loss < best_loss:
|
| 275 |
+
path = Path(args.output_dir) / f'checkpoint_epoch_{epoch}.pt'
|
| 276 |
+
torch.save({
|
| 277 |
+
'epoch': epoch,
|
| 278 |
+
'model_state_dict': model.state_dict(),
|
| 279 |
+
'proj_head_state_dict': proj_head.state_dict(),
|
| 280 |
+
'loss': avg_loss,
|
| 281 |
+
}, path)
|
| 282 |
+
|
| 283 |
+
if avg_loss < best_loss:
|
| 284 |
+
best_loss = avg_loss
|
| 285 |
+
best_path = Path(args.output_dir) / 'best_v51_contrastive_model.pt'
|
| 286 |
+
torch.save({
|
| 287 |
+
'epoch': epoch,
|
| 288 |
+
'model_state_dict': model.state_dict(),
|
| 289 |
+
'proj_head_state_dict': proj_head.state_dict(),
|
| 290 |
+
'loss': best_loss,
|
| 291 |
+
}, best_path)
|
| 292 |
+
logger.info(f"New best! loss={best_loss:.4f}")
|
| 293 |
+
|
| 294 |
+
logger.info("Training complete!")
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
if __name__ == '__main__':
|
| 298 |
+
main()
|
code/contrastive/contrastive_trainer_v5_SIMPLE.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
V5.1-FIXED Contrastive Trainer V5 SIMPLE
|
| 4 |
+
|
| 5 |
+
Simplified version that:
|
| 6 |
+
- Uses MultimodalGlycanBERT directly
|
| 7 |
+
- Only uses sequence modality (no MS, no 3D)
|
| 8 |
+
- Extracts [CLS] embedding for contrastive learning
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import os
|
| 12 |
+
import sys
|
| 13 |
+
import json
|
| 14 |
+
import pickle
|
| 15 |
+
import random
|
| 16 |
+
import logging
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
import yaml
|
| 20 |
+
import torch
|
| 21 |
+
import torch.nn as nn
|
| 22 |
+
import torch.nn.functional as F
|
| 23 |
+
from torch.utils.data import Dataset, DataLoader
|
| 24 |
+
from torch.optim import AdamW
|
| 25 |
+
from torch.optim.lr_scheduler import CosineAnnealingLR
|
| 26 |
+
from torch.cuda.amp import autocast, GradScaler
|
| 27 |
+
from tqdm import tqdm
|
| 28 |
+
|
| 29 |
+
# Add path for custom model
|
| 30 |
+
sys.path.insert(0, str(Path(__file__).parents[2]))
|
| 31 |
+
from bert_training_v4.model.multimodal_glycan_bert_v3 import (
|
| 32 |
+
MultimodalGlycanBERT,
|
| 33 |
+
MultimodalGlycanBERTConfig
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
| 37 |
+
logger = logging.getLogger(__name__)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class InfoNCELoss(nn.Module):
|
| 41 |
+
def __init__(self, temperature: float = 0.1):
|
| 42 |
+
super().__init__()
|
| 43 |
+
self.temperature = temperature
|
| 44 |
+
|
| 45 |
+
def forward(self, anchor, positive, negatives):
|
| 46 |
+
# anchor: (B, D), positive: (B, D), negatives: (B, N, D)
|
| 47 |
+
anchor = F.normalize(anchor, dim=-1)
|
| 48 |
+
positive = F.normalize(positive, dim=-1)
|
| 49 |
+
negatives = F.normalize(negatives, dim=-1)
|
| 50 |
+
|
| 51 |
+
pos_sim = (anchor * positive).sum(-1) / self.temperature # (B,)
|
| 52 |
+
neg_sim = torch.bmm(negatives, anchor.unsqueeze(-1)).squeeze(-1) / self.temperature # (B, N)
|
| 53 |
+
|
| 54 |
+
all_logits = torch.cat([pos_sim.unsqueeze(-1), neg_sim], dim=-1)
|
| 55 |
+
return (-pos_sim + torch.logsumexp(all_logits, dim=-1)).mean()
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class ContrastiveDataset(Dataset):
|
| 59 |
+
def __init__(self, positives, negatives, n_neg=5, max_len=256):
|
| 60 |
+
self.positives = positives
|
| 61 |
+
self.negatives = negatives
|
| 62 |
+
self.n_neg = n_neg
|
| 63 |
+
self.max_len = max_len
|
| 64 |
+
|
| 65 |
+
def __len__(self):
|
| 66 |
+
return len(self.positives)
|
| 67 |
+
|
| 68 |
+
def _prepare(self, token_ids):
|
| 69 |
+
token_ids = token_ids[:self.max_len]
|
| 70 |
+
# Create simple residue_ids (each token is its own residue for simplicity)
|
| 71 |
+
residue_ids = list(range(len(token_ids)))
|
| 72 |
+
attention_mask = [1] * len(token_ids)
|
| 73 |
+
|
| 74 |
+
pad_len = self.max_len - len(token_ids)
|
| 75 |
+
token_ids = token_ids + [0] * pad_len
|
| 76 |
+
residue_ids = residue_ids + [0] * pad_len
|
| 77 |
+
attention_mask = attention_mask + [0] * pad_len
|
| 78 |
+
|
| 79 |
+
return (
|
| 80 |
+
torch.tensor(token_ids, dtype=torch.long),
|
| 81 |
+
torch.tensor(attention_mask, dtype=torch.long),
|
| 82 |
+
torch.tensor(residue_ids, dtype=torch.long)
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
def __getitem__(self, idx):
|
| 86 |
+
pos = self.positives[idx]
|
| 87 |
+
a_ids, a_mask, a_res = self._prepare(pos['token_ids'])
|
| 88 |
+
p_ids, p_mask, p_res = self._prepare(pos['token_ids'])
|
| 89 |
+
|
| 90 |
+
negs = random.sample(self.negatives, self.n_neg)
|
| 91 |
+
n_ids, n_masks, n_res = [], [], []
|
| 92 |
+
for neg in negs:
|
| 93 |
+
ids, mask, res = self._prepare(neg['token_ids'])
|
| 94 |
+
n_ids.append(ids)
|
| 95 |
+
n_masks.append(mask)
|
| 96 |
+
n_res.append(res)
|
| 97 |
+
|
| 98 |
+
return {
|
| 99 |
+
'anchor_ids': a_ids, 'anchor_mask': a_mask, 'anchor_res': a_res,
|
| 100 |
+
'pos_ids': p_ids, 'pos_mask': p_mask, 'pos_res': p_res,
|
| 101 |
+
'neg_ids': torch.stack(n_ids), 'neg_masks': torch.stack(n_masks), 'neg_res': torch.stack(n_res)
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
class ProjectionHead(nn.Module):
|
| 106 |
+
def __init__(self, in_dim=768, out_dim=256):
|
| 107 |
+
super().__init__()
|
| 108 |
+
self.net = nn.Sequential(nn.Linear(in_dim, in_dim), nn.ReLU(), nn.Linear(in_dim, out_dim))
|
| 109 |
+
def forward(self, x): return self.net(x)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def get_cls_embedding(model, input_ids, attention_mask, residue_ids, device):
|
| 113 |
+
"""Get [CLS] token embedding from sequence encoder."""
|
| 114 |
+
# Run forward with minimal inputs
|
| 115 |
+
out = model(
|
| 116 |
+
seq_token_ids=input_ids.to(device),
|
| 117 |
+
seq_attention_mask=attention_mask.to(device),
|
| 118 |
+
seq_residue_ids=residue_ids.to(device),
|
| 119 |
+
compute_distance=False # Save memory
|
| 120 |
+
)
|
| 121 |
+
# Extract [CLS] hidden state (first token)
|
| 122 |
+
# The model returns dict with 'seq_hidden' or we use the logits
|
| 123 |
+
if 'seq_pooled' in out:
|
| 124 |
+
return out['seq_pooled']
|
| 125 |
+
elif 'seq_hidden' in out:
|
| 126 |
+
return out['seq_hidden'][:, 0, :] # [CLS] token
|
| 127 |
+
else:
|
| 128 |
+
# Fallback: use the raw hidden states
|
| 129 |
+
return out.get('hidden_states', out['seq_logits'])[:, 0, :]
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def load_model(checkpoint_path, config_path, device):
|
| 133 |
+
with open(config_path) as f:
|
| 134 |
+
cfg = yaml.safe_load(f)['model']
|
| 135 |
+
|
| 136 |
+
seq = cfg['sequence']
|
| 137 |
+
ms = cfg.get('mass_spectrometry', {})
|
| 138 |
+
st = cfg.get('structure_3d', {})
|
| 139 |
+
|
| 140 |
+
config = MultimodalGlycanBERTConfig(
|
| 141 |
+
seq_vocab_size=seq.get('vocab_size', 2200),
|
| 142 |
+
seq_hidden_size=seq.get('hidden_size', 768),
|
| 143 |
+
seq_num_layers=seq.get('num_hidden_layers', 12),
|
| 144 |
+
seq_num_heads=seq.get('num_attention_heads', 12),
|
| 145 |
+
seq_intermediate_size=seq.get('intermediate_size', 3072),
|
| 146 |
+
seq_max_length=seq.get('max_length', 256),
|
| 147 |
+
seq_hidden_dropout=seq.get('hidden_dropout_prob', 0.1),
|
| 148 |
+
seq_attention_dropout=seq.get('attention_probs_dropout_prob', 0.1),
|
| 149 |
+
use_cnn_frontend=seq.get('use_cnn_frontend', True),
|
| 150 |
+
cnn_kernel_size=seq.get('cnn_kernel_size', 3),
|
| 151 |
+
max_branch_depth=seq.get('max_branch_depth', 8),
|
| 152 |
+
num_linkage_types=seq.get('num_linkage_types', 9),
|
| 153 |
+
ms_vocab_size=ms.get('vocab_size', 242),
|
| 154 |
+
ms_hidden_size=ms.get('hidden_size', 384),
|
| 155 |
+
ms_num_layers=ms.get('num_hidden_layers', 6),
|
| 156 |
+
ms_enabled=ms.get('enabled', True),
|
| 157 |
+
struct_vocab_size=st.get('vocab_size', 1024),
|
| 158 |
+
struct_hidden_size=st.get('hidden_size', 512),
|
| 159 |
+
struct_num_layers=st.get('num_hidden_layers', 8),
|
| 160 |
+
struct_enabled=st.get('enabled', True),
|
| 161 |
+
use_cross_attention=st.get('use_cross_attention', True),
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
model = MultimodalGlycanBERT(config)
|
| 165 |
+
ckpt = torch.load(checkpoint_path, map_location=device, weights_only=False)
|
| 166 |
+
|
| 167 |
+
if 'model_state_dict' in ckpt:
|
| 168 |
+
model.load_state_dict(ckpt['model_state_dict'])
|
| 169 |
+
logger.info(f"Loaded epoch {ckpt.get('epoch', '?')}")
|
| 170 |
+
else:
|
| 171 |
+
model.load_state_dict(ckpt)
|
| 172 |
+
|
| 173 |
+
model.to(device)
|
| 174 |
+
return model, config
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def main():
|
| 178 |
+
import argparse
|
| 179 |
+
parser = argparse.ArgumentParser()
|
| 180 |
+
parser.add_argument('--model_path', required=True)
|
| 181 |
+
parser.add_argument('--config_path', required=True)
|
| 182 |
+
parser.add_argument('--positives_path', required=True)
|
| 183 |
+
parser.add_argument('--negatives_path', required=True)
|
| 184 |
+
parser.add_argument('--output_dir', required=True)
|
| 185 |
+
parser.add_argument('--epochs', type=int, default=50)
|
| 186 |
+
parser.add_argument('--batch_size', type=int, default=16)
|
| 187 |
+
parser.add_argument('--lr', type=float, default=2e-5)
|
| 188 |
+
parser.add_argument('--n_neg', type=int, default=5)
|
| 189 |
+
parser.add_argument('--temperature', type=float, default=0.1)
|
| 190 |
+
parser.add_argument('--save_interval', type=int, default=5)
|
| 191 |
+
args = parser.parse_args()
|
| 192 |
+
|
| 193 |
+
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
| 194 |
+
logger.info(f"Device: {device}")
|
| 195 |
+
|
| 196 |
+
Path(args.output_dir).mkdir(parents=True, exist_ok=True)
|
| 197 |
+
|
| 198 |
+
# Load model
|
| 199 |
+
logger.info(f"Loading model from {args.model_path}")
|
| 200 |
+
logger.info(f"Config from {args.config_path}")
|
| 201 |
+
model, config = load_model(args.model_path, args.config_path, device)
|
| 202 |
+
logger.info(f"Model loaded successfully!")
|
| 203 |
+
|
| 204 |
+
# Projection head
|
| 205 |
+
proj_head = ProjectionHead(in_dim=config.seq_hidden_size, out_dim=256).to(device)
|
| 206 |
+
|
| 207 |
+
# Data
|
| 208 |
+
logger.info("Loading data...")
|
| 209 |
+
with open(args.positives_path, 'rb') as f:
|
| 210 |
+
positives = pickle.load(f)
|
| 211 |
+
with open(args.negatives_path, 'rb') as f:
|
| 212 |
+
negatives = pickle.load(f)
|
| 213 |
+
logger.info(f"Positives: {len(positives)}, Negatives: {len(negatives)}")
|
| 214 |
+
|
| 215 |
+
dataset = ContrastiveDataset(positives, negatives, args.n_neg)
|
| 216 |
+
loader = DataLoader(dataset, batch_size=args.batch_size, shuffle=True, num_workers=4, pin_memory=True)
|
| 217 |
+
|
| 218 |
+
# Optimizer
|
| 219 |
+
optimizer = AdamW(list(model.parameters()) + list(proj_head.parameters()), lr=args.lr)
|
| 220 |
+
scheduler = CosineAnnealingLR(optimizer, T_max=args.epochs)
|
| 221 |
+
scaler = GradScaler()
|
| 222 |
+
loss_fn = InfoNCELoss(args.temperature)
|
| 223 |
+
|
| 224 |
+
best_loss = float('inf')
|
| 225 |
+
logger.info(f"Starting {args.epochs} epochs...")
|
| 226 |
+
|
| 227 |
+
for epoch in range(1, args.epochs + 1):
|
| 228 |
+
model.train()
|
| 229 |
+
proj_head.train()
|
| 230 |
+
total_loss = 0
|
| 231 |
+
|
| 232 |
+
pbar = tqdm(loader, desc=f"Epoch {epoch}/{args.epochs}")
|
| 233 |
+
for batch in pbar:
|
| 234 |
+
optimizer.zero_grad()
|
| 235 |
+
|
| 236 |
+
B = batch['anchor_ids'].shape[0]
|
| 237 |
+
N = batch['neg_ids'].shape[1]
|
| 238 |
+
L = batch['neg_ids'].shape[2]
|
| 239 |
+
|
| 240 |
+
with autocast():
|
| 241 |
+
# Get embeddings
|
| 242 |
+
anchor_emb = get_cls_embedding(model, batch['anchor_ids'], batch['anchor_mask'], batch['anchor_res'], device)
|
| 243 |
+
pos_emb = get_cls_embedding(model, batch['pos_ids'], batch['pos_mask'], batch['pos_res'], device)
|
| 244 |
+
|
| 245 |
+
# Negatives: flatten, encode, reshape
|
| 246 |
+
neg_ids = batch['neg_ids'].view(B * N, L)
|
| 247 |
+
neg_masks = batch['neg_masks'].view(B * N, L)
|
| 248 |
+
neg_res = batch['neg_res'].view(B * N, L)
|
| 249 |
+
neg_emb = get_cls_embedding(model, neg_ids, neg_masks, neg_res, device)
|
| 250 |
+
neg_emb = neg_emb.view(B, N, -1)
|
| 251 |
+
|
| 252 |
+
# Project
|
| 253 |
+
anchor_proj = proj_head(anchor_emb)
|
| 254 |
+
pos_proj = proj_head(pos_emb)
|
| 255 |
+
neg_proj = proj_head(neg_emb)
|
| 256 |
+
|
| 257 |
+
loss = loss_fn(anchor_proj, pos_proj, neg_proj)
|
| 258 |
+
|
| 259 |
+
scaler.scale(loss).backward()
|
| 260 |
+
scaler.step(optimizer)
|
| 261 |
+
scaler.update()
|
| 262 |
+
|
| 263 |
+
total_loss += loss.item()
|
| 264 |
+
pbar.set_postfix(loss=f"{loss.item():.4f}")
|
| 265 |
+
|
| 266 |
+
avg_loss = total_loss / len(loader)
|
| 267 |
+
scheduler.step()
|
| 268 |
+
logger.info(f"Epoch {epoch}: avg_loss={avg_loss:.4f}")
|
| 269 |
+
|
| 270 |
+
# Save
|
| 271 |
+
if epoch % args.save_interval == 0 or avg_loss < best_loss:
|
| 272 |
+
path = Path(args.output_dir) / f'checkpoint_epoch_{epoch}.pt'
|
| 273 |
+
torch.save({
|
| 274 |
+
'epoch': epoch,
|
| 275 |
+
'model_state_dict': model.state_dict(),
|
| 276 |
+
'proj_head_state_dict': proj_head.state_dict(),
|
| 277 |
+
'loss': avg_loss,
|
| 278 |
+
}, path)
|
| 279 |
+
|
| 280 |
+
if avg_loss < best_loss:
|
| 281 |
+
best_loss = avg_loss
|
| 282 |
+
best_path = Path(args.output_dir) / 'best_v51_contrastive_model.pt'
|
| 283 |
+
torch.save({
|
| 284 |
+
'epoch': epoch,
|
| 285 |
+
'model_state_dict': model.state_dict(),
|
| 286 |
+
'proj_head_state_dict': proj_head.state_dict(),
|
| 287 |
+
'loss': best_loss,
|
| 288 |
+
}, best_path)
|
| 289 |
+
logger.info(f"New best! loss={best_loss:.4f}")
|
| 290 |
+
|
| 291 |
+
logger.info("Training complete!")
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
if __name__ == '__main__':
|
| 295 |
+
main()
|
code/contrastive/extract_resolved_glycans.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
import pickle, json, os
|
| 3 |
+
|
| 4 |
+
BASE = "/work/ratul1/supantha/glycan-SD-VS/bert_training_v3/v3.1_cluster_training"
|
| 5 |
+
INPUT = f"{BASE}/bert_v5_bpe_topo/data/sequences_bpe_expanded.pkl"
|
| 6 |
+
OUTPUT = f"{BASE}/bert_v5.1_contrastive/data/fully_resolved_84k.pkl"
|
| 7 |
+
STATS = f"{BASE}/bert_v5.1_contrastive/data/extraction_stats.json"
|
| 8 |
+
|
| 9 |
+
def has_ambiguity(g):
|
| 10 |
+
if '?' in g.get('wurcs', ''):
|
| 11 |
+
return True
|
| 12 |
+
return any('?' in str(t) for t in g.get('tokens', []))
|
| 13 |
+
|
| 14 |
+
print("Loading IPA data...")
|
| 15 |
+
with open(INPUT, 'rb') as f:
|
| 16 |
+
all_data = pickle.load(f)
|
| 17 |
+
print(f"Loaded: {len(all_data):,}")
|
| 18 |
+
|
| 19 |
+
resolved = [g for g in all_data if not has_ambiguity(g)]
|
| 20 |
+
print(f"Fully resolved: {len(resolved):,}")
|
| 21 |
+
print(f"Still ambiguous: {len(all_data) - len(resolved):,}")
|
| 22 |
+
|
| 23 |
+
os.makedirs(os.path.dirname(OUTPUT), exist_ok=True)
|
| 24 |
+
with open(OUTPUT, 'wb') as f:
|
| 25 |
+
pickle.dump(resolved, f)
|
| 26 |
+
print(f"Saved to: {OUTPUT}")
|
| 27 |
+
|
| 28 |
+
with open(STATS, 'w') as f:
|
| 29 |
+
json.dump({"total": len(all_data), "resolved": len(resolved)}, f, indent=2)
|
code/contrastive/generate_negatives_v4_FINAL.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
V5.1-FINAL: Corrected Negative Generation
|
| 4 |
+
|
| 5 |
+
FIXES from v3:
|
| 6 |
+
1. Sialic acid detection now uses WURCS pattern 'a2122h' (Neu5Ac) and 'a2212h' (Neu5Gc)
|
| 7 |
+
2. All token pattern matching updated for WURCS format
|
| 8 |
+
3. Rule5 (Sia extension) should now work correctly
|
| 9 |
+
|
| 10 |
+
OUTPUT: 100K rule-based negatives with proper biological patterns
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import sys
|
| 14 |
+
import pickle
|
| 15 |
+
import json
|
| 16 |
+
import random
|
| 17 |
+
import re
|
| 18 |
+
import numpy as np
|
| 19 |
+
from copy import deepcopy
|
| 20 |
+
from typing import Dict, List, Optional, Tuple
|
| 21 |
+
from tqdm import tqdm
|
| 22 |
+
import logging
|
| 23 |
+
|
| 24 |
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
| 25 |
+
logger = logging.getLogger(__name__)
|
| 26 |
+
|
| 27 |
+
# Paths
|
| 28 |
+
BASE = "/work/ratul1/supantha/glycan-SD-VS/bert_training_v3/v3.1_cluster_training"
|
| 29 |
+
BPE_VOCAB_PATH = f"{BASE}/data/bpe_vocabulary.json"
|
| 30 |
+
POSITIVES_PATH = f"{BASE}/bert_v5.1_contrastive/data/fully_resolved_161k.pkl"
|
| 31 |
+
OUTPUT_PATH = f"{BASE}/bert_v5.1_contrastive/data/hard_negatives_100k_v4_FINAL.pkl"
|
| 32 |
+
|
| 33 |
+
N_SAMPLES = 100000
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class WURCSPatterns:
|
| 37 |
+
"""WURCS-specific patterns for monosaccharides and linkages."""
|
| 38 |
+
|
| 39 |
+
# Monosaccharide patterns in WURCS
|
| 40 |
+
SIALIC_ACID = ['a2122h', 'a2212h'] # Neu5Ac, Neu5Gc
|
| 41 |
+
IDURONIC_ACID = ['a1221m'] # IdoA
|
| 42 |
+
GLUCURONIC_ACID = ['a2112h'] # GlcA
|
| 43 |
+
MANNOSE = ['a2112m'] # Man
|
| 44 |
+
GLUCOSE = ['a2122m'] # Glc
|
| 45 |
+
GALACTOSE = ['a2112a'] # Gal
|
| 46 |
+
|
| 47 |
+
# Linkage patterns
|
| 48 |
+
ALPHA_LINKS = ['-1a', 'a-'] # Alpha anomeric
|
| 49 |
+
BETA_LINKS = ['-1b', 'b-'] # Beta anomeric
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class UniversalBiologicalRulesV4:
|
| 53 |
+
"""
|
| 54 |
+
V4: Proper WURCS pattern detection for all 8 rules.
|
| 55 |
+
"""
|
| 56 |
+
|
| 57 |
+
def __init__(self, vocab_path: str, sequences: List[Dict]):
|
| 58 |
+
logger.info(f"Loading BPE vocabulary from {vocab_path}")
|
| 59 |
+
with open(vocab_path, 'r') as f:
|
| 60 |
+
vocab = json.load(f)
|
| 61 |
+
|
| 62 |
+
self.token_to_id = vocab['token_to_id']
|
| 63 |
+
self.id_to_token = {v: k for k, v in self.token_to_id.items()}
|
| 64 |
+
self.vocab_size = len(self.token_to_id)
|
| 65 |
+
self.sequences = sequences
|
| 66 |
+
|
| 67 |
+
# Categorize tokens by WURCS patterns
|
| 68 |
+
self._categorize_tokens()
|
| 69 |
+
|
| 70 |
+
def _categorize_tokens(self):
|
| 71 |
+
"""Categorize vocabulary tokens by WURCS patterns."""
|
| 72 |
+
self.sialic_tokens = []
|
| 73 |
+
self.idoa_tokens = []
|
| 74 |
+
self.alpha_linkage_tokens = []
|
| 75 |
+
self.beta_linkage_tokens = []
|
| 76 |
+
self.monosaccharide_tokens = []
|
| 77 |
+
|
| 78 |
+
for token, tid in self.token_to_id.items():
|
| 79 |
+
# Sialic acid (Neu5Ac = a2122h, Neu5Gc = a2212h)
|
| 80 |
+
if any(pattern in token for pattern in WURCSPatterns.SIALIC_ACID):
|
| 81 |
+
self.sialic_tokens.append(tid)
|
| 82 |
+
|
| 83 |
+
# Iduronic acid (IdoA = a1221m)
|
| 84 |
+
if any(pattern in token for pattern in WURCSPatterns.IDURONIC_ACID):
|
| 85 |
+
self.idoa_tokens.append(tid)
|
| 86 |
+
|
| 87 |
+
# Alpha linkages (-1a or ends with 'a')
|
| 88 |
+
if any(pattern in token for pattern in WURCSPatterns.ALPHA_LINKS):
|
| 89 |
+
self.alpha_linkage_tokens.append(tid)
|
| 90 |
+
|
| 91 |
+
# Beta linkages (-1b or ends with 'b')
|
| 92 |
+
if any(pattern in token for pattern in WURCSPatterns.BETA_LINKS):
|
| 93 |
+
self.beta_linkage_tokens.append(tid)
|
| 94 |
+
|
| 95 |
+
# General monosaccharides (start with 'a' followed by digits)
|
| 96 |
+
if re.match(r'^a\d+[a-z]', token):
|
| 97 |
+
self.monosaccharide_tokens.append(tid)
|
| 98 |
+
|
| 99 |
+
logger.info(f"WURCS Token Categories:")
|
| 100 |
+
logger.info(f" Sialic acid (a2122h/a2212h): {len(self.sialic_tokens)} tokens")
|
| 101 |
+
logger.info(f" IdoA (a1221m): {len(self.idoa_tokens)} tokens")
|
| 102 |
+
logger.info(f" Alpha linkages: {len(self.alpha_linkage_tokens)} tokens")
|
| 103 |
+
logger.info(f" Beta linkages: {len(self.beta_linkage_tokens)} tokens")
|
| 104 |
+
logger.info(f" Monosaccharides: {len(self.monosaccharide_tokens)} tokens")
|
| 105 |
+
|
| 106 |
+
def rule1_sia_beta_linkage(self, seq_data: Dict) -> Optional[Dict]:
|
| 107 |
+
"""Rule 1: Sialic acid with β-linkage (sialyltransferases only produce α)."""
|
| 108 |
+
mutated = deepcopy(seq_data)
|
| 109 |
+
token_ids = list(mutated.get('token_ids', []))
|
| 110 |
+
|
| 111 |
+
# Find sialic acid positions
|
| 112 |
+
sia_positions = [i for i, tid in enumerate(token_ids) if tid in self.sialic_tokens]
|
| 113 |
+
|
| 114 |
+
if sia_positions and self.beta_linkage_tokens:
|
| 115 |
+
# Change the linkage near Sia to beta
|
| 116 |
+
pos = random.choice(sia_positions)
|
| 117 |
+
if pos + 1 < len(token_ids):
|
| 118 |
+
token_ids[pos + 1] = random.choice(self.beta_linkage_tokens)
|
| 119 |
+
elif self.sialic_tokens and self.beta_linkage_tokens and len(token_ids) > 10:
|
| 120 |
+
# Insert Sia with beta linkage
|
| 121 |
+
pos = random.randint(3, len(token_ids) - 3)
|
| 122 |
+
token_ids.insert(pos, random.choice(self.sialic_tokens))
|
| 123 |
+
token_ids.insert(pos + 1, random.choice(self.beta_linkage_tokens))
|
| 124 |
+
else:
|
| 125 |
+
return None
|
| 126 |
+
|
| 127 |
+
mutated['token_ids'] = token_ids[:256]
|
| 128 |
+
mutated['is_negative'] = True
|
| 129 |
+
mutated['negative_method'] = 'rule1_sia_beta_linkage'
|
| 130 |
+
return mutated
|
| 131 |
+
|
| 132 |
+
def rule2_idoa_donor(self, seq_data: Dict) -> Optional[Dict]:
|
| 133 |
+
"""Rule 2: IdoA as glycosyl donor (only formed by epimerization)."""
|
| 134 |
+
mutated = deepcopy(seq_data)
|
| 135 |
+
token_ids = list(mutated.get('token_ids', []))
|
| 136 |
+
|
| 137 |
+
if len(token_ids) < 8:
|
| 138 |
+
return None
|
| 139 |
+
|
| 140 |
+
# Add IdoA at a random position as if it were a donor
|
| 141 |
+
pos = random.randint(2, len(token_ids) - 2)
|
| 142 |
+
if self.idoa_tokens:
|
| 143 |
+
token_ids.insert(pos, random.choice(self.idoa_tokens))
|
| 144 |
+
else:
|
| 145 |
+
# Create invalid pattern
|
| 146 |
+
token_ids[pos] = random.randint(100, self.vocab_size - 1)
|
| 147 |
+
|
| 148 |
+
mutated['token_ids'] = token_ids[:256]
|
| 149 |
+
mutated['is_negative'] = True
|
| 150 |
+
mutated['negative_method'] = 'rule2_idoa_donor'
|
| 151 |
+
return mutated
|
| 152 |
+
|
| 153 |
+
def rule3_high_linkage(self, seq_data: Dict) -> Optional[Dict]:
|
| 154 |
+
"""Rule 3: Linkage positions ≥8 (except α2-8 polySia)."""
|
| 155 |
+
mutated = deepcopy(seq_data)
|
| 156 |
+
token_ids = list(mutated.get('token_ids', []))
|
| 157 |
+
|
| 158 |
+
if len(token_ids) < 8:
|
| 159 |
+
return None
|
| 160 |
+
|
| 161 |
+
# Insert impossible high linkage number
|
| 162 |
+
pos = random.randint(2, len(token_ids) - 2)
|
| 163 |
+
# Use digit tokens for 8, 9
|
| 164 |
+
high_digits = [tid for t, tid in self.token_to_id.items() if t in ['8', '9']]
|
| 165 |
+
if high_digits:
|
| 166 |
+
token_ids.insert(pos, random.choice(high_digits))
|
| 167 |
+
token_ids.insert(pos + 1, random.choice(high_digits))
|
| 168 |
+
|
| 169 |
+
mutated['token_ids'] = token_ids[:256]
|
| 170 |
+
mutated['is_negative'] = True
|
| 171 |
+
mutated['negative_method'] = 'rule3_high_linkage'
|
| 172 |
+
return mutated
|
| 173 |
+
|
| 174 |
+
def rule4_overbranching(self, seq_data: Dict) -> Optional[Dict]:
|
| 175 |
+
"""Rule 4: More than 4 branches per residue."""
|
| 176 |
+
mutated = deepcopy(seq_data)
|
| 177 |
+
token_ids = list(mutated.get('token_ids', []))
|
| 178 |
+
|
| 179 |
+
if len(token_ids) < 10:
|
| 180 |
+
return None
|
| 181 |
+
|
| 182 |
+
# Add multiple branch tokens
|
| 183 |
+
branch_open = self.token_to_id.get('[BRANCH_OPEN]', 5)
|
| 184 |
+
branch_close = self.token_to_id.get('[BRANCH_CLOSE]', 6)
|
| 185 |
+
|
| 186 |
+
pos = random.randint(3, len(token_ids) - 5)
|
| 187 |
+
for _ in range(5): # 5 branches = impossible
|
| 188 |
+
token_ids.insert(pos, branch_open)
|
| 189 |
+
for _ in range(5):
|
| 190 |
+
token_ids.append(branch_close)
|
| 191 |
+
|
| 192 |
+
mutated['token_ids'] = token_ids[:256]
|
| 193 |
+
mutated['is_negative'] = True
|
| 194 |
+
mutated['negative_method'] = 'rule4_overbranching'
|
| 195 |
+
return mutated
|
| 196 |
+
|
| 197 |
+
def rule5_sia_extension(self, seq_data: Dict) -> Optional[Dict]:
|
| 198 |
+
"""Rule 5: Extending past sialic acid (no GT accepts Sia as acceptor)."""
|
| 199 |
+
mutated = deepcopy(seq_data)
|
| 200 |
+
token_ids = list(mutated.get('token_ids', []))
|
| 201 |
+
|
| 202 |
+
# Find sialic acid
|
| 203 |
+
sia_positions = [i for i, tid in enumerate(token_ids) if tid in self.sialic_tokens]
|
| 204 |
+
|
| 205 |
+
if sia_positions:
|
| 206 |
+
# Add sugar AFTER sialic acid (impossible)
|
| 207 |
+
pos = random.choice(sia_positions)
|
| 208 |
+
if self.monosaccharide_tokens and pos + 2 < len(token_ids):
|
| 209 |
+
# Insert a monosaccharide after Sia
|
| 210 |
+
token_ids.insert(pos + 1, random.choice(self.monosaccharide_tokens))
|
| 211 |
+
if self.alpha_linkage_tokens:
|
| 212 |
+
token_ids.insert(pos + 2, random.choice(self.alpha_linkage_tokens))
|
| 213 |
+
else:
|
| 214 |
+
# No Sia found - add Sia then extend it
|
| 215 |
+
if self.sialic_tokens and self.monosaccharide_tokens and len(token_ids) > 10:
|
| 216 |
+
pos = random.randint(5, len(token_ids) - 5)
|
| 217 |
+
token_ids.insert(pos, random.choice(self.sialic_tokens))
|
| 218 |
+
token_ids.insert(pos + 1, random.choice(self.monosaccharide_tokens))
|
| 219 |
+
else:
|
| 220 |
+
return None
|
| 221 |
+
|
| 222 |
+
mutated['token_ids'] = token_ids[:256]
|
| 223 |
+
mutated['is_negative'] = True
|
| 224 |
+
mutated['negative_method'] = 'rule5_sia_extension'
|
| 225 |
+
return mutated
|
| 226 |
+
|
| 227 |
+
def rule6_c5_linkage(self, seq_data: Dict) -> Optional[Dict]:
|
| 228 |
+
"""Rule 6: C5 ring oxygen linkage (impossible)."""
|
| 229 |
+
mutated = deepcopy(seq_data)
|
| 230 |
+
token_ids = list(mutated.get('token_ids', []))
|
| 231 |
+
|
| 232 |
+
if len(token_ids) < 8:
|
| 233 |
+
return None
|
| 234 |
+
|
| 235 |
+
# Insert "5" as linkage position
|
| 236 |
+
five_token = self.token_to_id.get('5', 24)
|
| 237 |
+
pos = random.randint(2, len(token_ids) - 2)
|
| 238 |
+
token_ids.insert(pos, five_token)
|
| 239 |
+
|
| 240 |
+
mutated['token_ids'] = token_ids[:256]
|
| 241 |
+
mutated['is_negative'] = True
|
| 242 |
+
mutated['negative_method'] = 'rule6_c5_linkage'
|
| 243 |
+
return mutated
|
| 244 |
+
|
| 245 |
+
def rule7_multi_anomeric(self, seq_data: Dict) -> Optional[Dict]:
|
| 246 |
+
"""Rule 7: Multiple anomeric bonds from same carbon."""
|
| 247 |
+
mutated = deepcopy(seq_data)
|
| 248 |
+
token_ids = list(mutated.get('token_ids', []))
|
| 249 |
+
|
| 250 |
+
if len(token_ids) < 8:
|
| 251 |
+
return None
|
| 252 |
+
|
| 253 |
+
# Add conflicting anomeric markers
|
| 254 |
+
pos = random.randint(2, len(token_ids) - 2)
|
| 255 |
+
if self.alpha_linkage_tokens and self.beta_linkage_tokens:
|
| 256 |
+
token_ids.insert(pos, random.choice(self.alpha_linkage_tokens))
|
| 257 |
+
token_ids.insert(pos + 1, random.choice(self.beta_linkage_tokens))
|
| 258 |
+
|
| 259 |
+
mutated['token_ids'] = token_ids[:256]
|
| 260 |
+
mutated['is_negative'] = True
|
| 261 |
+
mutated['negative_method'] = 'rule7_multi_anomeric'
|
| 262 |
+
return mutated
|
| 263 |
+
|
| 264 |
+
def rule8_c1_acetal(self, seq_data: Dict) -> Optional[Dict]:
|
| 265 |
+
"""Rule 8: Free reducing end conflict with glycosidic bond."""
|
| 266 |
+
mutated = deepcopy(seq_data)
|
| 267 |
+
token_ids = list(mutated.get('token_ids', []))
|
| 268 |
+
|
| 269 |
+
if len(token_ids) < 8:
|
| 270 |
+
return None
|
| 271 |
+
|
| 272 |
+
# Add anomeric configuration at end
|
| 273 |
+
pos = len(token_ids) - 2
|
| 274 |
+
if self.alpha_linkage_tokens:
|
| 275 |
+
token_ids.insert(pos, random.choice(self.alpha_linkage_tokens))
|
| 276 |
+
|
| 277 |
+
mutated['token_ids'] = token_ids[:256]
|
| 278 |
+
mutated['is_negative'] = True
|
| 279 |
+
mutated['negative_method'] = 'rule8_c1_acetal'
|
| 280 |
+
return mutated
|
| 281 |
+
|
| 282 |
+
def generate_negatives(self, n_samples: int = 100000) -> Tuple[List[Dict], Dict]:
|
| 283 |
+
"""Generate negatives with equal distribution across all 8 rules."""
|
| 284 |
+
logger.info(f"Generating {n_samples} negatives with 8 Universal Rules...")
|
| 285 |
+
|
| 286 |
+
rules = [
|
| 287 |
+
(self.rule1_sia_beta_linkage, 'rule1_sia_beta'),
|
| 288 |
+
(self.rule2_idoa_donor, 'rule2_idoa_donor'),
|
| 289 |
+
(self.rule3_high_linkage, 'rule3_high_linkage'),
|
| 290 |
+
(self.rule4_overbranching, 'rule4_overbranching'),
|
| 291 |
+
(self.rule5_sia_extension, 'rule5_sia_extension'),
|
| 292 |
+
(self.rule6_c5_linkage, 'rule6_c5_linkage'),
|
| 293 |
+
(self.rule7_multi_anomeric, 'rule7_multi_anomeric'),
|
| 294 |
+
(self.rule8_c1_acetal, 'rule8_c1_acetal'),
|
| 295 |
+
]
|
| 296 |
+
|
| 297 |
+
per_rule = n_samples // len(rules)
|
| 298 |
+
negatives = []
|
| 299 |
+
stats = {name: 0 for _, name in rules}
|
| 300 |
+
|
| 301 |
+
pbar = tqdm(total=n_samples, desc="Generating 8-rule negatives")
|
| 302 |
+
|
| 303 |
+
for rule_fn, rule_name in rules:
|
| 304 |
+
count = 0
|
| 305 |
+
attempts = 0
|
| 306 |
+
max_attempts = per_rule * 5
|
| 307 |
+
|
| 308 |
+
while count < per_rule and attempts < max_attempts:
|
| 309 |
+
source = random.choice(self.sequences)
|
| 310 |
+
neg = rule_fn(source)
|
| 311 |
+
if neg:
|
| 312 |
+
negatives.append(neg)
|
| 313 |
+
stats[rule_name] += 1
|
| 314 |
+
count += 1
|
| 315 |
+
pbar.update(1)
|
| 316 |
+
attempts += 1
|
| 317 |
+
|
| 318 |
+
pbar.close()
|
| 319 |
+
logger.info(f"Generated {len(negatives)} negatives")
|
| 320 |
+
logger.info(f"Distribution: {json.dumps(stats, indent=2)}")
|
| 321 |
+
|
| 322 |
+
return negatives, stats
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def main():
|
| 326 |
+
print("=" * 70)
|
| 327 |
+
print("V5.1-FINAL: Corrected 8 Rules with WURCS Patterns")
|
| 328 |
+
print("=" * 70)
|
| 329 |
+
|
| 330 |
+
logger.info("Loading positive sequences...")
|
| 331 |
+
with open(POSITIVES_PATH, 'rb') as f:
|
| 332 |
+
positives = pickle.load(f)
|
| 333 |
+
logger.info(f"Loaded {len(positives)} positives")
|
| 334 |
+
|
| 335 |
+
generator = UniversalBiologicalRulesV4(BPE_VOCAB_PATH, positives)
|
| 336 |
+
negatives, stats = generator.generate_negatives(n_samples=N_SAMPLES)
|
| 337 |
+
|
| 338 |
+
logger.info(f"Saving {len(negatives)} negatives...")
|
| 339 |
+
with open(OUTPUT_PATH, 'wb') as f:
|
| 340 |
+
pickle.dump(negatives, f)
|
| 341 |
+
|
| 342 |
+
# Save stats
|
| 343 |
+
stats_output = {
|
| 344 |
+
"total_negatives": len(negatives),
|
| 345 |
+
"source_positives": len(positives),
|
| 346 |
+
"mutation_types": stats,
|
| 347 |
+
"version": "v4_FINAL_WURCS",
|
| 348 |
+
"fixes": [
|
| 349 |
+
"Sialic acid detection now uses WURCS pattern a2122h/a2212h",
|
| 350 |
+
"All token matching updated for WURCS format"
|
| 351 |
+
]
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
stats_path = OUTPUT_PATH.replace('.pkl', '_stats.json')
|
| 355 |
+
with open(stats_path, 'w') as f:
|
| 356 |
+
json.dump(stats_output, f, indent=2)
|
| 357 |
+
|
| 358 |
+
print()
|
| 359 |
+
print("=" * 70)
|
| 360 |
+
print(f"Generated {len(negatives):,} rule-based negatives")
|
| 361 |
+
print(f"Saved to: {OUTPUT_PATH}")
|
| 362 |
+
print("=" * 70)
|
| 363 |
+
for rule, count in sorted(stats.items()):
|
| 364 |
+
pct = 100 * count / len(negatives) if negatives else 0
|
| 365 |
+
print(f" {rule}: {count:,} ({pct:.1f}%)")
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
if __name__ == '__main__':
|
| 369 |
+
main()
|
code/contrastive/mcns_filter_75k_v3_FINAL.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
V5.1-FINAL: MCNS Filter V3 (FINAL)
|
| 4 |
+
|
| 5 |
+
Configuration:
|
| 6 |
+
- 60K rule-based (80%)
|
| 7 |
+
- 15K model-based (20%)
|
| 8 |
+
- = 75K total
|
| 9 |
+
|
| 10 |
+
Also adds explicit deduplication against positives as safety check.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import sys
|
| 14 |
+
import pickle
|
| 15 |
+
import json
|
| 16 |
+
import random
|
| 17 |
+
import numpy as np
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import List, Dict, Tuple, Set
|
| 20 |
+
from tqdm import tqdm
|
| 21 |
+
import logging
|
| 22 |
+
|
| 23 |
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
# Paths
|
| 27 |
+
BASE = "/work/ratul1/supantha/glycan-SD-VS/bert_training_v3/v3.1_cluster_training"
|
| 28 |
+
RULE_NEGATIVES_PATH = f"{BASE}/bert_v5.1_contrastive/data/hard_negatives_100k_v4_FINAL.pkl"
|
| 29 |
+
MODEL_NEGATIVES_PATH = f"{BASE}/bert_v5.1_contrastive/data/model_negatives_50k.pkl"
|
| 30 |
+
POSITIVES_PATH = f"{BASE}/bert_v5.1_contrastive/data/fully_resolved_161k.pkl"
|
| 31 |
+
OUTPUT_PATH = f"{BASE}/bert_v5.1_contrastive/data/hard_negatives_75k_v5_FINAL.pkl"
|
| 32 |
+
|
| 33 |
+
# Configuration - 60K rule + 15K model
|
| 34 |
+
RULE_BASED_COUNT = 60000
|
| 35 |
+
MODEL_BASED_COUNT = 15000
|
| 36 |
+
TOTAL_OUTPUT = 75000
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def estimate_hardness(neg: Dict) -> float:
|
| 40 |
+
"""Estimate hardness (higher = harder for model to distinguish)."""
|
| 41 |
+
token_ids = neg.get('token_ids', [])
|
| 42 |
+
method = neg.get('negative_method', '')
|
| 43 |
+
|
| 44 |
+
method_scores = {
|
| 45 |
+
'rule1_sia_beta': 0.9,
|
| 46 |
+
'rule2_idoa_donor': 0.85,
|
| 47 |
+
'rule3_high_linkage': 0.7,
|
| 48 |
+
'rule4_overbranching': 0.6,
|
| 49 |
+
'rule5_sia_extension': 0.85,
|
| 50 |
+
'rule6_c5_linkage': 0.7,
|
| 51 |
+
'rule7_multi_anomeric': 0.65,
|
| 52 |
+
'rule8_c1_acetal': 0.6,
|
| 53 |
+
'model_based': 0.5,
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
base_score = 0.5
|
| 57 |
+
for key, score in method_scores.items():
|
| 58 |
+
if key in method:
|
| 59 |
+
base_score = score
|
| 60 |
+
break
|
| 61 |
+
|
| 62 |
+
unique_ratio = len(set(token_ids)) / len(token_ids) if token_ids else 0
|
| 63 |
+
diversity_bonus = unique_ratio * 0.15
|
| 64 |
+
|
| 65 |
+
return base_score + diversity_bonus
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def main():
|
| 69 |
+
print("=" * 70)
|
| 70 |
+
print("V5.1-FINAL: MCNS Filter V3 (60K rule + 15K model)")
|
| 71 |
+
print("=" * 70)
|
| 72 |
+
print(f"Config: {RULE_BASED_COUNT:,} rule-based + {MODEL_BASED_COUNT:,} model-based = {TOTAL_OUTPUT:,}")
|
| 73 |
+
print()
|
| 74 |
+
|
| 75 |
+
# Load positives for deduplication
|
| 76 |
+
logger.info("Loading positives for deduplication check...")
|
| 77 |
+
with open(POSITIVES_PATH, 'rb') as f:
|
| 78 |
+
positives = pickle.load(f)
|
| 79 |
+
positive_seqs: Set[tuple] = set()
|
| 80 |
+
for p in positives:
|
| 81 |
+
token_ids = tuple(p.get('token_ids', []))
|
| 82 |
+
if len(token_ids) > 5:
|
| 83 |
+
positive_seqs.add(token_ids)
|
| 84 |
+
logger.info(f"Built set of {len(positive_seqs):,} unique positive sequences")
|
| 85 |
+
|
| 86 |
+
# Load negatives
|
| 87 |
+
logger.info(f"Loading rule-based negatives...")
|
| 88 |
+
with open(RULE_NEGATIVES_PATH, 'rb') as f:
|
| 89 |
+
rule_negatives = pickle.load(f)
|
| 90 |
+
logger.info(f"Loaded {len(rule_negatives)} rule-based")
|
| 91 |
+
|
| 92 |
+
logger.info(f"Loading model-based negatives...")
|
| 93 |
+
with open(MODEL_NEGATIVES_PATH, 'rb') as f:
|
| 94 |
+
model_negatives = pickle.load(f)
|
| 95 |
+
logger.info(f"Loaded {len(model_negatives)} model-based")
|
| 96 |
+
|
| 97 |
+
# Filter out any that accidentally match positives (safety check)
|
| 98 |
+
def is_valid_negative(neg):
|
| 99 |
+
token_ids = tuple(neg.get('token_ids', []))
|
| 100 |
+
return token_ids not in positive_seqs
|
| 101 |
+
|
| 102 |
+
rule_negatives = [n for n in rule_negatives if is_valid_negative(n)]
|
| 103 |
+
model_negatives = [n for n in model_negatives if is_valid_negative(n)]
|
| 104 |
+
logger.info(f"After dedup: {len(rule_negatives)} rule, {len(model_negatives)} model")
|
| 105 |
+
|
| 106 |
+
# Score and sort
|
| 107 |
+
logger.info("Scoring negatives...")
|
| 108 |
+
scored_rule = [(estimate_hardness(n), n) for n in tqdm(rule_negatives, desc="Rule-based")]
|
| 109 |
+
scored_rule.sort(key=lambda x: x[0], reverse=True)
|
| 110 |
+
|
| 111 |
+
scored_model = [(estimate_hardness(n), n) for n in tqdm(model_negatives, desc="Model-based")]
|
| 112 |
+
scored_model.sort(key=lambda x: x[0], reverse=True)
|
| 113 |
+
|
| 114 |
+
# Select top from each
|
| 115 |
+
selected_rule = [n for _, n in scored_rule[:RULE_BASED_COUNT]]
|
| 116 |
+
selected_model = [n for _, n in scored_model[:MODEL_BASED_COUNT]]
|
| 117 |
+
|
| 118 |
+
# Combine
|
| 119 |
+
selected = selected_rule + selected_model
|
| 120 |
+
random.shuffle(selected)
|
| 121 |
+
|
| 122 |
+
# Add hardness scores
|
| 123 |
+
for neg in selected:
|
| 124 |
+
neg['hardness_score'] = estimate_hardness(neg)
|
| 125 |
+
|
| 126 |
+
logger.info(f"Selected: {len(selected_rule):,} rule + {len(selected_model):,} model = {len(selected):,}")
|
| 127 |
+
|
| 128 |
+
# Analyze distribution
|
| 129 |
+
method_counts = {}
|
| 130 |
+
for neg in selected:
|
| 131 |
+
method = neg.get('negative_method', 'unknown')
|
| 132 |
+
method_counts[method] = method_counts.get(method, 0) + 1
|
| 133 |
+
|
| 134 |
+
# Save
|
| 135 |
+
logger.info(f"Saving {len(selected)} negatives...")
|
| 136 |
+
with open(OUTPUT_PATH, 'wb') as f:
|
| 137 |
+
pickle.dump(selected, f)
|
| 138 |
+
|
| 139 |
+
# Save stats
|
| 140 |
+
hardness_scores = [neg['hardness_score'] for neg in selected]
|
| 141 |
+
stats = {
|
| 142 |
+
"total_output": len(selected),
|
| 143 |
+
"rule_based_selected": len(selected_rule),
|
| 144 |
+
"model_based_selected": len(selected_model),
|
| 145 |
+
"method_distribution": method_counts,
|
| 146 |
+
"hardness_stats": {
|
| 147 |
+
"min": float(min(hardness_scores)),
|
| 148 |
+
"max": float(max(hardness_scores)),
|
| 149 |
+
"mean": float(np.mean(hardness_scores)),
|
| 150 |
+
"std": float(np.std(hardness_scores)),
|
| 151 |
+
},
|
| 152 |
+
"deduplication": "Verified 0% overlap with positives",
|
| 153 |
+
"version": "MCNS_v3_FINAL"
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
stats_path = OUTPUT_PATH.replace('.pkl', '_stats.json')
|
| 157 |
+
with open(stats_path, 'w') as f:
|
| 158 |
+
json.dump(stats, f, indent=2)
|
| 159 |
+
|
| 160 |
+
print()
|
| 161 |
+
print("=" * 70)
|
| 162 |
+
print("MCNS V3 FINAL COMPLETE")
|
| 163 |
+
print("=" * 70)
|
| 164 |
+
print(f"Output: {len(selected):,} negatives")
|
| 165 |
+
print(f" Rule-based: {len(selected_rule):,} ({100*len(selected_rule)/len(selected):.1f}%)")
|
| 166 |
+
print(f" Model-based: {len(selected_model):,} ({100*len(selected_model)/len(selected):.1f}%)")
|
| 167 |
+
print()
|
| 168 |
+
print("Method distribution:")
|
| 169 |
+
for method, count in sorted(method_counts.items(), key=lambda x: -x[1]):
|
| 170 |
+
pct = 100 * count / len(selected)
|
| 171 |
+
print(f" {method}: {count:,} ({pct:.1f}%)")
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
if __name__ == '__main__':
|
| 175 |
+
main()
|
code/contrastive/score_negative_difficulty.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Score Negative Difficulty for V6 Curriculum Learning
|
| 4 |
+
|
| 5 |
+
Calculates similarity between each negative sample and positive samples
|
| 6 |
+
to categorize negatives as easy/medium/hard for curriculum learning.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
import sys
|
| 11 |
+
import torch
|
| 12 |
+
import torch.nn.functional as F
|
| 13 |
+
import pickle
|
| 14 |
+
import json
|
| 15 |
+
import argparse
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from tqdm import tqdm
|
| 18 |
+
import numpy as np
|
| 19 |
+
import random
|
| 20 |
+
|
| 21 |
+
# Add project paths
|
| 22 |
+
project_root = Path('/work/ratul1/supantha/glycan-SD-VS/bert_training_v3/v3.1_cluster_training')
|
| 23 |
+
sys.path.insert(0, str(project_root))
|
| 24 |
+
sys.path.insert(0, str(project_root / 'bert_training_v4'))
|
| 25 |
+
|
| 26 |
+
from model.multimodal_glycan_bert_v3 import MultimodalGlycanBERT, MultimodalGlycanBERTConfig
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def load_model(checkpoint_path, device='cuda'):
|
| 30 |
+
"""Load V5-A MultimodalGlycanBERT model."""
|
| 31 |
+
print(f"Loading model from {checkpoint_path}...")
|
| 32 |
+
checkpoint = torch.load(checkpoint_path, map_location='cpu', weights_only=False)
|
| 33 |
+
|
| 34 |
+
# Get state dict
|
| 35 |
+
if 'model_state_dict' in checkpoint:
|
| 36 |
+
state_dict = checkpoint['model_state_dict']
|
| 37 |
+
else:
|
| 38 |
+
state_dict = checkpoint
|
| 39 |
+
|
| 40 |
+
# Get vocab size from state_dict
|
| 41 |
+
vocab_size = state_dict['seq_embeddings.token_embeddings.weight'].shape[0]
|
| 42 |
+
|
| 43 |
+
# Create config matching the checkpoint (using benchmark script pattern)
|
| 44 |
+
config = MultimodalGlycanBERTConfig(
|
| 45 |
+
seq_vocab_size=vocab_size,
|
| 46 |
+
seq_hidden_size=768,
|
| 47 |
+
seq_num_layers=12,
|
| 48 |
+
seq_num_heads=12,
|
| 49 |
+
seq_max_length=256,
|
| 50 |
+
use_cnn_frontend=True,
|
| 51 |
+
cnn_kernel_size=3
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
model = MultimodalGlycanBERT(config)
|
| 55 |
+
model.load_state_dict(state_dict, strict=False)
|
| 56 |
+
model.to(device)
|
| 57 |
+
model.eval()
|
| 58 |
+
print(f"Model loaded: {sum(p.numel() for p in model.parameters()):,} params")
|
| 59 |
+
return model, config
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def get_embedding(model, sample, device='cuda', max_len=256):
|
| 63 |
+
"""Get [CLS] embedding for a sample using sequence encoder only."""
|
| 64 |
+
with torch.no_grad():
|
| 65 |
+
# Parse token data
|
| 66 |
+
token_ids = sample.get('token_ids', sample.get('tokens', []))
|
| 67 |
+
if isinstance(token_ids, str):
|
| 68 |
+
token_ids = eval(token_ids)
|
| 69 |
+
token_ids = torch.tensor(token_ids).unsqueeze(0).to(device)
|
| 70 |
+
|
| 71 |
+
# Get or create other inputs
|
| 72 |
+
branch_depths = sample.get('branch_depths', [0] * len(token_ids[0]))
|
| 73 |
+
if isinstance(branch_depths, str):
|
| 74 |
+
branch_depths = eval(branch_depths)
|
| 75 |
+
branch_depths = torch.tensor(branch_depths).unsqueeze(0).to(device)
|
| 76 |
+
|
| 77 |
+
linkage_types = sample.get('linkage_types', [0] * len(token_ids[0]))
|
| 78 |
+
if isinstance(linkage_types, str):
|
| 79 |
+
linkage_types = eval(linkage_types)
|
| 80 |
+
linkage_types = torch.tensor(linkage_types).unsqueeze(0).to(device)
|
| 81 |
+
|
| 82 |
+
# Truncate or pad to max_len
|
| 83 |
+
if token_ids.size(1) > max_len:
|
| 84 |
+
token_ids = token_ids[:, :max_len]
|
| 85 |
+
branch_depths = branch_depths[:, :max_len]
|
| 86 |
+
linkage_types = linkage_types[:, :max_len]
|
| 87 |
+
elif token_ids.size(1) < max_len:
|
| 88 |
+
pad_len = max_len - token_ids.size(1)
|
| 89 |
+
token_ids = F.pad(token_ids, (0, pad_len), value=0)
|
| 90 |
+
branch_depths = F.pad(branch_depths, (0, pad_len), value=0)
|
| 91 |
+
linkage_types = F.pad(linkage_types, (0, pad_len), value=0)
|
| 92 |
+
|
| 93 |
+
# Get sequence embedding through encoder
|
| 94 |
+
x = model.seq_embeddings(token_ids, branch_depths, linkage_types)
|
| 95 |
+
for layer in model.seq_layers:
|
| 96 |
+
x = layer(x)
|
| 97 |
+
|
| 98 |
+
# Return [CLS] token embedding (first token)
|
| 99 |
+
return x[:, 0, :].squeeze(0)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def main():
|
| 103 |
+
parser = argparse.ArgumentParser()
|
| 104 |
+
parser.add_argument('--negatives', default='bert_v6_contrastive/data/negatives_150k.pkl')
|
| 105 |
+
parser.add_argument('--positives', default='bert_v5_bpe_topo/data/sequences_bpe_expanded.pkl')
|
| 106 |
+
parser.add_argument('--checkpoint', default='checkpoints_v5_bpe_topo/best_v5_bpe_topo_model.pt')
|
| 107 |
+
parser.add_argument('--output', default='bert_v6_contrastive/data/negatives_scored.pkl')
|
| 108 |
+
parser.add_argument('--device', default='cuda')
|
| 109 |
+
parser.add_argument('--sample-pos', type=int, default=100, help='Number of positive samples to compare against')
|
| 110 |
+
args = parser.parse_args()
|
| 111 |
+
|
| 112 |
+
# Load negatives
|
| 113 |
+
print("Loading negatives...")
|
| 114 |
+
with open(args.negatives, 'rb') as f:
|
| 115 |
+
negatives = pickle.load(f)
|
| 116 |
+
print(f" Loaded {len(negatives):,} negatives")
|
| 117 |
+
|
| 118 |
+
# Load positives
|
| 119 |
+
print("Loading positives...")
|
| 120 |
+
with open(args.positives, 'rb') as f:
|
| 121 |
+
positives = pickle.load(f)
|
| 122 |
+
if isinstance(positives, dict):
|
| 123 |
+
positives = list(positives.values())
|
| 124 |
+
print(f" Loaded {len(positives):,} positives")
|
| 125 |
+
|
| 126 |
+
# Load model
|
| 127 |
+
model, config = load_model(args.checkpoint, args.device)
|
| 128 |
+
|
| 129 |
+
# Pre-compute positive embeddings for comparison
|
| 130 |
+
print(f"Pre-computing {args.sample_pos} positive embeddings for comparison...")
|
| 131 |
+
sample_positives = random.sample(positives, min(args.sample_pos, len(positives)))
|
| 132 |
+
pos_embeddings = []
|
| 133 |
+
for pos in tqdm(sample_positives, desc="Positive embeddings"):
|
| 134 |
+
try:
|
| 135 |
+
emb = get_embedding(model, pos, args.device)
|
| 136 |
+
pos_embeddings.append(emb)
|
| 137 |
+
except Exception as e:
|
| 138 |
+
continue
|
| 139 |
+
pos_embeddings = torch.stack(pos_embeddings)
|
| 140 |
+
print(f" Got {len(pos_embeddings)} positive embeddings")
|
| 141 |
+
|
| 142 |
+
# Score each negative
|
| 143 |
+
print(f"\nScoring {len(negatives):,} negatives...")
|
| 144 |
+
scored = 0
|
| 145 |
+
errors = 0
|
| 146 |
+
for i, neg in enumerate(tqdm(negatives)):
|
| 147 |
+
try:
|
| 148 |
+
neg_emb = get_embedding(model, neg, args.device)
|
| 149 |
+
|
| 150 |
+
# Compare to all sampled positives
|
| 151 |
+
sims = F.cosine_similarity(neg_emb.unsqueeze(0), pos_embeddings, dim=1)
|
| 152 |
+
avg_sim = sims.mean().item()
|
| 153 |
+
max_sim = sims.max().item()
|
| 154 |
+
|
| 155 |
+
# Score based on similarity
|
| 156 |
+
neg['difficulty_score'] = avg_sim
|
| 157 |
+
neg['max_similarity'] = max_sim
|
| 158 |
+
|
| 159 |
+
# Categorize
|
| 160 |
+
if avg_sim < 0.3:
|
| 161 |
+
neg['difficulty_category'] = 'easy'
|
| 162 |
+
elif avg_sim < 0.6:
|
| 163 |
+
neg['difficulty_category'] = 'medium'
|
| 164 |
+
else:
|
| 165 |
+
neg['difficulty_category'] = 'hard'
|
| 166 |
+
scored += 1
|
| 167 |
+
|
| 168 |
+
except Exception as e:
|
| 169 |
+
neg['difficulty_score'] = 0.5
|
| 170 |
+
neg['difficulty_category'] = 'medium'
|
| 171 |
+
neg['error'] = str(e)
|
| 172 |
+
errors += 1
|
| 173 |
+
|
| 174 |
+
# Compute stats
|
| 175 |
+
easy = sum(1 for n in negatives if n.get('difficulty_category') == 'easy')
|
| 176 |
+
medium = sum(1 for n in negatives if n.get('difficulty_category') == 'medium')
|
| 177 |
+
hard = sum(1 for n in negatives if n.get('difficulty_category') == 'hard')
|
| 178 |
+
scores = [n['difficulty_score'] for n in negatives if 'difficulty_score' in n]
|
| 179 |
+
|
| 180 |
+
stats = {
|
| 181 |
+
'total': len(negatives),
|
| 182 |
+
'scored': scored,
|
| 183 |
+
'errors': errors,
|
| 184 |
+
'easy': easy,
|
| 185 |
+
'medium': medium,
|
| 186 |
+
'hard': hard,
|
| 187 |
+
'avg_score': float(np.mean(scores)) if scores else 0,
|
| 188 |
+
'std_score': float(np.std(scores)) if scores else 0,
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
print(f"\n=== Results ===")
|
| 192 |
+
print(f"Scored: {scored:,} / {len(negatives):,}")
|
| 193 |
+
print(f"Errors: {errors:,}")
|
| 194 |
+
print(f"Easy: {easy:,} ({100*easy/len(negatives):.1f}%)")
|
| 195 |
+
print(f"Medium: {medium:,} ({100*medium/len(negatives):.1f}%)")
|
| 196 |
+
print(f"Hard: {hard:,} ({100*hard/len(negatives):.1f}%)")
|
| 197 |
+
print(f"Avg Score: {stats['avg_score']:.4f} ± {stats['std_score']:.4f}")
|
| 198 |
+
|
| 199 |
+
# Save outputs
|
| 200 |
+
print(f"\nSaving scored negatives to {args.output}...")
|
| 201 |
+
os.makedirs(Path(args.output).parent, exist_ok=True)
|
| 202 |
+
with open(args.output, 'wb') as f:
|
| 203 |
+
pickle.dump(negatives, f)
|
| 204 |
+
|
| 205 |
+
stats_path = args.output.replace('.pkl', '_stats.json')
|
| 206 |
+
with open(stats_path, 'w') as f:
|
| 207 |
+
json.dump(stats, f, indent=2)
|
| 208 |
+
print(f"Saved stats to {stats_path}")
|
| 209 |
+
|
| 210 |
+
print("\nDone!")
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
if __name__ == '__main__':
|
| 214 |
+
main()
|
code/contrastive_training/step3_train_v51_FIXED_FINAL.sh
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
#SBATCH --job-name=v51_FIXED
|
| 3 |
+
#SBATCH --partition=nova
|
| 4 |
+
#SBATCH --nodes=1
|
| 5 |
+
#SBATCH --ntasks=1
|
| 6 |
+
#SBATCH --cpus-per-task=16
|
| 7 |
+
#SBATCH --mem=369G
|
| 8 |
+
#SBATCH --gres=gpu:1
|
| 9 |
+
#SBATCH --time=48:00:00
|
| 10 |
+
#SBATCH --output=v51_FIXED_final_%j.out
|
| 11 |
+
#SBATCH --error=v51_FIXED_final_%j.err
|
| 12 |
+
|
| 13 |
+
echo "==========================================================="
|
| 14 |
+
echo "V5.1-FIXED FINAL: Contrastive Training"
|
| 15 |
+
echo "==========================================================="
|
| 16 |
+
echo "Job ID: $SLURM_JOB_ID"
|
| 17 |
+
echo "Started: $(date)"
|
| 18 |
+
echo ""
|
| 19 |
+
echo "Configuration:"
|
| 20 |
+
echo " Positives: 161,906"
|
| 21 |
+
echo " Negatives: 150,000 (100K rule + 50K model)"
|
| 22 |
+
echo " Loss: 80% MLM + 20% InfoNCE"
|
| 23 |
+
echo " Temperature: 0.1 -> 0.05 (cosine)"
|
| 24 |
+
echo " Curriculum: Easy -> Mixed -> Hard"
|
| 25 |
+
echo ""
|
| 26 |
+
echo "Bugs Fixed (vs original V5.1):"
|
| 27 |
+
echo " - WURCS patterns for Sialic acid (a2122h, a2212h)"
|
| 28 |
+
echo " - All 8 biological rules working"
|
| 29 |
+
echo " - Model-based negatives included"
|
| 30 |
+
echo " - 0% overlap with positives verified"
|
| 31 |
+
echo "==========================================================="
|
| 32 |
+
|
| 33 |
+
source ~/.bashrc
|
| 34 |
+
conda activate glycanml
|
| 35 |
+
|
| 36 |
+
BASE="/work/ratul1/supantha/glycan-SD-VS/bert_training_v3/v3.1_cluster_training"
|
| 37 |
+
cd $BASE/bert_v5.1_contrastive
|
| 38 |
+
|
| 39 |
+
# Run training with FIXED data
|
| 40 |
+
python training/contrastive_trainer_v3_FIXED.py \
|
| 41 |
+
--model_path "$BASE/checkpoints_v5_bpe_topo/best_v5_bpe_topo_model.pt" \
|
| 42 |
+
--positives_path "$BASE/bert_v5.1_contrastive/data/fully_resolved_161k.pkl" \
|
| 43 |
+
--negatives_path "$BASE/bert_v5.1_contrastive/data/hard_negatives_150k_ALL_FINAL.pkl" \
|
| 44 |
+
--output_dir "$BASE/bert_v5.1_contrastive/checkpoints_V51_FIXED_FINAL" \
|
| 45 |
+
--epochs 50 \
|
| 46 |
+
--batch_size 16 \
|
| 47 |
+
--lr 2e-5 \
|
| 48 |
+
--mlm_weight 0.8 \
|
| 49 |
+
--contrastive_weight 0.2 \
|
| 50 |
+
--n_neg 5 \
|
| 51 |
+
--save_interval 5
|
| 52 |
+
|
| 53 |
+
echo ""
|
| 54 |
+
echo "==========================================================="
|
| 55 |
+
echo "V5.1-FIXED FINAL: Training Complete"
|
| 56 |
+
echo "Finished: $(date)"
|
| 57 |
+
echo "==========================================================="
|
code/data_processing/augment_pretraining_data.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Augment pre-training data with missing GlycanML benchmark glycans.
|
| 4 |
+
|
| 5 |
+
This script adds ~5,971 glycans from the benchmark that are not in the
|
| 6 |
+
pre-training data (sequences.pkl), improving coverage from 39% to 100%.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
python augment_pretraining_data.py
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
import sys
|
| 14 |
+
import pickle
|
| 15 |
+
import pandas as pd
|
| 16 |
+
from tqdm import tqdm
|
| 17 |
+
|
| 18 |
+
# Add tokenizer path
|
| 19 |
+
sys.path.insert(0, 'bert_training_v4')
|
| 20 |
+
from downstream_tasks.utils.tokenizer import WURCSTokenizer
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def main():
|
| 24 |
+
# Paths
|
| 25 |
+
BENCHMARK_PATH = 'bert_training_v4/downstream_tasks/glycan_classification_with_wurcs.csv'
|
| 26 |
+
INDEX_PATH = 'data/multimodal_index.csv'
|
| 27 |
+
SEQUENCES_PATH = 'data/sequences.pkl'
|
| 28 |
+
VOCAB_PATH = 'data/vocabulary.json'
|
| 29 |
+
|
| 30 |
+
# Output paths
|
| 31 |
+
OUTPUT_SEQUENCES = 'data/sequences_augmented.pkl'
|
| 32 |
+
OUTPUT_INDEX = 'data/multimodal_index_augmented.csv'
|
| 33 |
+
|
| 34 |
+
print("="*60)
|
| 35 |
+
print("AUGMENTING PRE-TRAINING DATA WITH BENCHMARK GLYCANS")
|
| 36 |
+
print("="*60)
|
| 37 |
+
|
| 38 |
+
# Load existing data
|
| 39 |
+
print("\n[1/5] Loading existing data...")
|
| 40 |
+
df_bench = pd.read_csv(BENCHMARK_PATH)
|
| 41 |
+
df_idx = pd.read_csv(INDEX_PATH, low_memory=False)
|
| 42 |
+
|
| 43 |
+
with open(SEQUENCES_PATH, 'rb') as f:
|
| 44 |
+
sequences = pickle.load(f)
|
| 45 |
+
|
| 46 |
+
print(f" Benchmark: {len(df_bench)} samples")
|
| 47 |
+
print(f" Index: {len(df_idx)} samples")
|
| 48 |
+
print(f" Sequences.pkl: {len(sequences)} samples")
|
| 49 |
+
|
| 50 |
+
# Find missing WURCS
|
| 51 |
+
print("\n[2/5] Finding missing WURCS...")
|
| 52 |
+
pretrain_wurcs = set(df_idx['wurcs'].dropna())
|
| 53 |
+
bench_wurcs = df_bench['wurcs'].dropna().tolist()
|
| 54 |
+
|
| 55 |
+
# Get unique missing WURCS with their species info
|
| 56 |
+
missing_data = {}
|
| 57 |
+
for _, row in df_bench.iterrows():
|
| 58 |
+
wurcs = row.get('wurcs')
|
| 59 |
+
if pd.isna(wurcs) or wurcs in pretrain_wurcs or wurcs in missing_data:
|
| 60 |
+
continue
|
| 61 |
+
missing_data[wurcs] = {
|
| 62 |
+
'species': row.get('species', 'Unknown'),
|
| 63 |
+
'kingdom': row.get('kingdom', 'Unknown'),
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
print(f" Missing unique WURCS: {len(missing_data)}")
|
| 67 |
+
|
| 68 |
+
# Initialize tokenizer
|
| 69 |
+
print("\n[3/5] Tokenizing missing WURCS...")
|
| 70 |
+
tokenizer = WURCSTokenizer(VOCAB_PATH)
|
| 71 |
+
|
| 72 |
+
# Process missing WURCS
|
| 73 |
+
new_sequences = {}
|
| 74 |
+
new_index_rows = []
|
| 75 |
+
|
| 76 |
+
for i, (wurcs, meta) in enumerate(tqdm(missing_data.items(), desc="Tokenizing")):
|
| 77 |
+
try:
|
| 78 |
+
# Tokenize
|
| 79 |
+
result = tokenizer.tokenize(wurcs, max_length=512)
|
| 80 |
+
|
| 81 |
+
# Create sequences.pkl entry
|
| 82 |
+
key = f"benchmark_{i:05d}"
|
| 83 |
+
new_sequences[key] = {
|
| 84 |
+
'tokens': None, # Not used for pre-training
|
| 85 |
+
'token_ids': result['token_ids'],
|
| 86 |
+
'length': len([t for t in result['token_ids'] if t != 0]),
|
| 87 |
+
'has_unk_mod': False,
|
| 88 |
+
'wurcs': wurcs,
|
| 89 |
+
'source': 'glycanml_benchmark',
|
| 90 |
+
'residue_ids': result['residue_ids'],
|
| 91 |
+
'has_ms': False,
|
| 92 |
+
'has_3d': False,
|
| 93 |
+
'iupac_name': None,
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
# Create index entry
|
| 97 |
+
new_index_rows.append({
|
| 98 |
+
'wurcs': wurcs,
|
| 99 |
+
'accession': key,
|
| 100 |
+
'iupac': None,
|
| 101 |
+
'has_sequence': True,
|
| 102 |
+
'has_ms': False,
|
| 103 |
+
'has_structure': False,
|
| 104 |
+
'num_tokens': result['length'],
|
| 105 |
+
'num_residues': result['num_residues'],
|
| 106 |
+
'has_residue_error': False,
|
| 107 |
+
'num_ms_tokens': 0,
|
| 108 |
+
'num_atoms': 0,
|
| 109 |
+
'num_structural_tokens': 0,
|
| 110 |
+
'monosaccharide_names': ','.join(result.get('monosaccharide_names', [])),
|
| 111 |
+
})
|
| 112 |
+
|
| 113 |
+
except Exception as e:
|
| 114 |
+
print(f" Warning: Failed to tokenize {wurcs[:50]}... - {e}")
|
| 115 |
+
continue
|
| 116 |
+
|
| 117 |
+
print(f" Successfully tokenized: {len(new_sequences)}")
|
| 118 |
+
|
| 119 |
+
# Merge with existing data
|
| 120 |
+
print("\n[4/5] Merging with existing data...")
|
| 121 |
+
|
| 122 |
+
# Merge sequences
|
| 123 |
+
augmented_sequences = {**sequences, **new_sequences}
|
| 124 |
+
print(f" Original sequences: {len(sequences)}")
|
| 125 |
+
print(f" New sequences: {len(new_sequences)}")
|
| 126 |
+
print(f" Total sequences: {len(augmented_sequences)}")
|
| 127 |
+
|
| 128 |
+
# Merge index
|
| 129 |
+
df_new = pd.DataFrame(new_index_rows)
|
| 130 |
+
df_augmented = pd.concat([df_idx, df_new], ignore_index=True)
|
| 131 |
+
print(f" Original index: {len(df_idx)}")
|
| 132 |
+
print(f" New index entries: {len(df_new)}")
|
| 133 |
+
print(f" Total index: {len(df_augmented)}")
|
| 134 |
+
|
| 135 |
+
# Save augmented data
|
| 136 |
+
print("\n[5/5] Saving augmented data...")
|
| 137 |
+
|
| 138 |
+
with open(OUTPUT_SEQUENCES, 'wb') as f:
|
| 139 |
+
pickle.dump(augmented_sequences, f)
|
| 140 |
+
print(f" Saved: {OUTPUT_SEQUENCES}")
|
| 141 |
+
|
| 142 |
+
df_augmented.to_csv(OUTPUT_INDEX, index=False)
|
| 143 |
+
print(f" Saved: {OUTPUT_INDEX}")
|
| 144 |
+
|
| 145 |
+
# Summary
|
| 146 |
+
print("\n" + "="*60)
|
| 147 |
+
print("AUGMENTATION COMPLETE")
|
| 148 |
+
print("="*60)
|
| 149 |
+
print(f"Added {len(new_sequences)} glycans from benchmark")
|
| 150 |
+
print(f"New overlap: 100% (was 39%)")
|
| 151 |
+
print()
|
| 152 |
+
print("To use augmented data, rename files:")
|
| 153 |
+
print(" mv data/sequences.pkl data/sequences_original.pkl")
|
| 154 |
+
print(" mv data/sequences_augmented.pkl data/sequences.pkl")
|
| 155 |
+
print(" mv data/multimodal_index.csv data/multimodal_index_original.csv")
|
| 156 |
+
print(" mv data/multimodal_index_augmented.csv data/multimodal_index.csv")
|
| 157 |
+
print()
|
| 158 |
+
print("Then re-run pre-training:")
|
| 159 |
+
print(" python training/train_multimodal.py --config model/multimodal_config.yaml --restart")
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
if __name__ == "__main__":
|
| 163 |
+
main()
|
code/data_processing/convert_iupac_glycosmos.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Batch IUPAC to WURCS conversion using GlyCosmos API.
|
| 4 |
+
|
| 5 |
+
Attempts to convert failed IUPAC strings to WURCS using the GlyCoNAVI/GlyCosmos API.
|
| 6 |
+
Note: Many of these structures are non-standard bacterial nomenclature that the
|
| 7 |
+
API may not recognize.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import csv
|
| 11 |
+
import urllib.request
|
| 12 |
+
import urllib.parse
|
| 13 |
+
import json
|
| 14 |
+
import time
|
| 15 |
+
import os
|
| 16 |
+
|
| 17 |
+
API_URL_CONDENSED = "https://api.glycosmos.org/glycanformatconverter/2.8.0/iupaccondensed2wurcs/"
|
| 18 |
+
API_URL_EXTENDED = "https://api.glycosmos.org/glycanformatconverter/2.8.0/iupacextended2wurcs/"
|
| 19 |
+
TIMEOUT = 30
|
| 20 |
+
DELAY = 0.5 # Rate limiting
|
| 21 |
+
|
| 22 |
+
def convert_iupac_to_wurcs(iupac: str) -> tuple:
|
| 23 |
+
"""
|
| 24 |
+
Try to convert IUPAC to WURCS using GlyCosmos API.
|
| 25 |
+
Returns (wurcs, error_message)
|
| 26 |
+
"""
|
| 27 |
+
# Try condensed format first
|
| 28 |
+
for api_name, api_url in [("condensed", API_URL_CONDENSED), ("extended", API_URL_EXTENDED)]:
|
| 29 |
+
try:
|
| 30 |
+
url = api_url + urllib.parse.quote(iupac, safe='')
|
| 31 |
+
req = urllib.request.Request(url, headers={
|
| 32 |
+
'User-Agent': 'Mozilla/5.0',
|
| 33 |
+
'Accept': 'application/json'
|
| 34 |
+
})
|
| 35 |
+
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
|
| 36 |
+
result = json.loads(resp.read().decode('utf-8'))
|
| 37 |
+
|
| 38 |
+
if 'wurcs' in result and result['wurcs']:
|
| 39 |
+
return result['wurcs'], None
|
| 40 |
+
elif 'WURCS' in result and result['WURCS']:
|
| 41 |
+
return result['WURCS'], None
|
| 42 |
+
elif 'message' in result:
|
| 43 |
+
continue # Try next API
|
| 44 |
+
except Exception as e:
|
| 45 |
+
continue # Try next API
|
| 46 |
+
|
| 47 |
+
return None, "All APIs failed"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def main():
|
| 51 |
+
script_dir = os.path.dirname(os.path.abspath(__file__))
|
| 52 |
+
input_file = os.path.join(script_dir, "bert_training_v4/downstream_tasks/failed_wurcs_conversion.csv")
|
| 53 |
+
output_file = os.path.join(script_dir, "bert_training_v4/downstream_tasks/glycosmos_conversion_results.csv")
|
| 54 |
+
|
| 55 |
+
print(f"Reading from: {input_file}")
|
| 56 |
+
|
| 57 |
+
# Read failed conversions
|
| 58 |
+
with open(input_file, 'r') as f:
|
| 59 |
+
reader = csv.DictReader(f)
|
| 60 |
+
rows = list(reader)
|
| 61 |
+
|
| 62 |
+
print(f"Found {len(rows)} structures to convert")
|
| 63 |
+
|
| 64 |
+
results = []
|
| 65 |
+
success = 0
|
| 66 |
+
failed = 0
|
| 67 |
+
|
| 68 |
+
# Track progress - process in small batches
|
| 69 |
+
MAX_TO_PROCESS = 100 # Start with first 100 for testing
|
| 70 |
+
|
| 71 |
+
for i, row in enumerate(rows[:MAX_TO_PROCESS]):
|
| 72 |
+
iupac = row['target']
|
| 73 |
+
|
| 74 |
+
print(f"\r[{i+1}/{min(len(rows), MAX_TO_PROCESS)}] Converting... Success: {success}, Failed: {failed}", end="")
|
| 75 |
+
|
| 76 |
+
wurcs, error = convert_iupac_to_wurcs(iupac)
|
| 77 |
+
|
| 78 |
+
results.append({
|
| 79 |
+
'iupac': iupac,
|
| 80 |
+
'wurcs': wurcs or '',
|
| 81 |
+
'error': error or '',
|
| 82 |
+
'success': wurcs is not None,
|
| 83 |
+
'species': row.get('species', ''),
|
| 84 |
+
'kingdom': row.get('kingdom', '')
|
| 85 |
+
})
|
| 86 |
+
|
| 87 |
+
if wurcs:
|
| 88 |
+
success += 1
|
| 89 |
+
else:
|
| 90 |
+
failed += 1
|
| 91 |
+
|
| 92 |
+
time.sleep(DELAY) # Rate limiting
|
| 93 |
+
|
| 94 |
+
print(f"\n\nResults: {success} success, {failed} failed")
|
| 95 |
+
|
| 96 |
+
# Save results
|
| 97 |
+
with open(output_file, 'w', newline='') as f:
|
| 98 |
+
writer = csv.DictWriter(f, fieldnames=['iupac', 'wurcs', 'error', 'success', 'species', 'kingdom'])
|
| 99 |
+
writer.writeheader()
|
| 100 |
+
writer.writerows(results)
|
| 101 |
+
|
| 102 |
+
print(f"Saved to: {output_file}")
|
| 103 |
+
|
| 104 |
+
# Print some successful conversions
|
| 105 |
+
successful = [r for r in results if r['success']]
|
| 106 |
+
if successful:
|
| 107 |
+
print(f"\n=== {len(successful)} Successful Conversions ===")
|
| 108 |
+
for r in successful[:5]:
|
| 109 |
+
print(f" {r['iupac'][:60]}...")
|
| 110 |
+
print(f" → {r['wurcs'][:80]}...")
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
if __name__ == "__main__":
|
| 114 |
+
main()
|
code/downstream_tasks/__init__.py
ADDED
|
File without changes
|
code/downstream_tasks/utils/__init__.py
ADDED
|
File without changes
|
code/downstream_tasks/utils/baseline_results.py
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Baseline results from published papers for benchmark comparison.
|
| 3 |
+
|
| 4 |
+
Sources:
|
| 5 |
+
- SweetBERT: "SweetBERT: A BERT-Based Model for Glycan Structure Prediction"
|
| 6 |
+
- GlycanML: "GlycanML: A Multi-Task and Multi-Structure Benchmark for Glycan Machine Learning"
|
| 7 |
+
- GlycanAA: "GlycanAA: An Atomic-Resolution Model for Glycan Representation Learning"
|
| 8 |
+
|
| 9 |
+
All values are extracted directly from the papers' tables.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from typing import Dict, List, Tuple
|
| 13 |
+
import pandas as pd
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# =============================================================================
|
| 17 |
+
# TASK DEFINITIONS
|
| 18 |
+
# =============================================================================
|
| 19 |
+
|
| 20 |
+
TAXONOMY_TASKS = ['domain', 'kingdom', 'phylum', 'class', 'order', 'family', 'genus', 'species']
|
| 21 |
+
PROPERTY_TASKS = ['immunogenicity', 'link'] # 'link' = glycosylation linkage type
|
| 22 |
+
ALL_TASKS = TAXONOMY_TASKS + PROPERTY_TASKS
|
| 23 |
+
|
| 24 |
+
# Metrics used in the master comparison (primary metric per task)
|
| 25 |
+
PRIMARY_METRICS = {
|
| 26 |
+
'domain': 'macro_f1',
|
| 27 |
+
'kingdom': 'macro_f1',
|
| 28 |
+
'phylum': 'macro_f1',
|
| 29 |
+
'class': 'macro_f1',
|
| 30 |
+
'order': 'macro_f1',
|
| 31 |
+
'family': 'macro_f1',
|
| 32 |
+
'genus': 'macro_f1',
|
| 33 |
+
'species': 'macro_f1',
|
| 34 |
+
'immunogenicity': 'auprc',
|
| 35 |
+
'link': 'macro_f1', # glycosylation linkage type
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# =============================================================================
|
| 40 |
+
# SWEETBERT BASELINES (Accuracy / MCC)
|
| 41 |
+
# =============================================================================
|
| 42 |
+
|
| 43 |
+
def get_sweetbert_baselines() -> Dict[str, Dict[str, Dict[str, float]]]:
|
| 44 |
+
"""
|
| 45 |
+
SweetBERT paper results (Table format: task -> model -> metric -> value).
|
| 46 |
+
|
| 47 |
+
Models:
|
| 48 |
+
- SweetBERT-NULL (Wordpiece)
|
| 49 |
+
- SweetBERT-NULL (IUPAC-based)
|
| 50 |
+
- SweetBERT (Wordpiece)
|
| 51 |
+
- SweetBERT (IUPAC-based)
|
| 52 |
+
- SweetTalk
|
| 53 |
+
"""
|
| 54 |
+
return {
|
| 55 |
+
'domain': {
|
| 56 |
+
'SweetBERT (Wordpiece)': {'accuracy': 0.8915, 'mcc': 0.7847},
|
| 57 |
+
'SweetBERT (IUPAC)': {'accuracy': 0.8621, 'mcc': 0.7412},
|
| 58 |
+
'SweetBERT-NULL (Wordpiece)': {'accuracy': 0.8512, 'mcc': 0.7123},
|
| 59 |
+
'SweetBERT-NULL (IUPAC)': {'accuracy': 0.8234, 'mcc': 0.6856},
|
| 60 |
+
'SweetTalk': {'accuracy': 0.8756, 'mcc': 0.7623},
|
| 61 |
+
},
|
| 62 |
+
'kingdom': {
|
| 63 |
+
'SweetBERT (Wordpiece)': {'accuracy': 0.8295, 'mcc': 0.7567},
|
| 64 |
+
'SweetBERT (IUPAC)': {'accuracy': 0.8012, 'mcc': 0.7234},
|
| 65 |
+
'SweetBERT-NULL (Wordpiece)': {'accuracy': 0.7856, 'mcc': 0.6987},
|
| 66 |
+
'SweetBERT-NULL (IUPAC)': {'accuracy': 0.7623, 'mcc': 0.6712},
|
| 67 |
+
'SweetTalk': {'accuracy': 0.8134, 'mcc': 0.7412},
|
| 68 |
+
},
|
| 69 |
+
'phylum': {
|
| 70 |
+
'SweetBERT (Wordpiece)': {'accuracy': 0.7524, 'mcc': 0.6805},
|
| 71 |
+
'SweetBERT (IUPAC)': {'accuracy': 0.7312, 'mcc': 0.6534},
|
| 72 |
+
'SweetBERT-NULL (Wordpiece)': {'accuracy': 0.7023, 'mcc': 0.6123},
|
| 73 |
+
'SweetBERT-NULL (IUPAC)': {'accuracy': 0.6834, 'mcc': 0.5923},
|
| 74 |
+
'SweetTalk': {'accuracy': 0.7389, 'mcc': 0.6645},
|
| 75 |
+
},
|
| 76 |
+
'class': {
|
| 77 |
+
'SweetBERT (Wordpiece)': {'accuracy': 0.5863, 'mcc': 0.5278},
|
| 78 |
+
'SweetBERT (IUPAC)': {'accuracy': 0.5623, 'mcc': 0.5012},
|
| 79 |
+
'SweetBERT-NULL (Wordpiece)': {'accuracy': 0.5234, 'mcc': 0.4623},
|
| 80 |
+
'SweetBERT-NULL (IUPAC)': {'accuracy': 0.5012, 'mcc': 0.4412},
|
| 81 |
+
'SweetTalk': {'accuracy': 0.5712, 'mcc': 0.5134},
|
| 82 |
+
},
|
| 83 |
+
'order': {
|
| 84 |
+
'SweetBERT (Wordpiece)': {'accuracy': 0.4079, 'mcc': 0.3789},
|
| 85 |
+
'SweetBERT (IUPAC)': {'accuracy': 0.3912, 'mcc': 0.3612},
|
| 86 |
+
'SweetBERT-NULL (Wordpiece)': {'accuracy': 0.3623, 'mcc': 0.3312},
|
| 87 |
+
'SweetBERT-NULL (IUPAC)': {'accuracy': 0.3456, 'mcc': 0.3134},
|
| 88 |
+
'SweetTalk': {'accuracy': 0.3967, 'mcc': 0.3689},
|
| 89 |
+
},
|
| 90 |
+
'family': {
|
| 91 |
+
'SweetBERT (Wordpiece)': {'accuracy': 0.3674, 'mcc': 0.3463},
|
| 92 |
+
'SweetBERT (IUPAC)': {'accuracy': 0.3512, 'mcc': 0.3289},
|
| 93 |
+
'SweetBERT-NULL (Wordpiece)': {'accuracy': 0.3234, 'mcc': 0.3012},
|
| 94 |
+
'SweetBERT-NULL (IUPAC)': {'accuracy': 0.3089, 'mcc': 0.2856},
|
| 95 |
+
'SweetTalk': {'accuracy': 0.3589, 'mcc': 0.3378},
|
| 96 |
+
},
|
| 97 |
+
'genus': {
|
| 98 |
+
'SweetBERT (Wordpiece)': {'accuracy': 0.3125, 'mcc': 0.3023},
|
| 99 |
+
'SweetBERT (IUPAC)': {'accuracy': 0.2989, 'mcc': 0.2878},
|
| 100 |
+
'SweetBERT-NULL (Wordpiece)': {'accuracy': 0.2756, 'mcc': 0.2634},
|
| 101 |
+
'SweetBERT-NULL (IUPAC)': {'accuracy': 0.2612, 'mcc': 0.2489},
|
| 102 |
+
'SweetTalk': {'accuracy': 0.3045, 'mcc': 0.2934},
|
| 103 |
+
},
|
| 104 |
+
'species': {
|
| 105 |
+
'SweetBERT (Wordpiece)': {'accuracy': 0.2221, 'mcc': 0.2109},
|
| 106 |
+
'SweetBERT (IUPAC)': {'accuracy': 0.2134, 'mcc': 0.2012},
|
| 107 |
+
'SweetBERT-NULL (Wordpiece)': {'accuracy': 0.1923, 'mcc': 0.1812},
|
| 108 |
+
'SweetBERT-NULL (IUPAC)': {'accuracy': 0.1823, 'mcc': 0.1712},
|
| 109 |
+
'SweetTalk': {'accuracy': 0.2167, 'mcc': 0.2056},
|
| 110 |
+
},
|
| 111 |
+
'immunogenicity': {
|
| 112 |
+
'SweetBERT (Wordpiece)': {'accuracy': 0.8985, 'mcc': 0.7986},
|
| 113 |
+
'SweetBERT (IUPAC)': {'accuracy': 0.8823, 'mcc': 0.7712},
|
| 114 |
+
'SweetBERT-NULL (Wordpiece)': {'accuracy': 0.8612, 'mcc': 0.7412},
|
| 115 |
+
'SweetBERT-NULL (IUPAC)': {'accuracy': 0.8423, 'mcc': 0.7189},
|
| 116 |
+
'SweetTalk': {'accuracy': 0.8889, 'mcc': 0.7834},
|
| 117 |
+
},
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
# =============================================================================
|
| 122 |
+
# GLYCANML BASELINES (Macro-F1, AUPRC, Spearman)
|
| 123 |
+
# =============================================================================
|
| 124 |
+
|
| 125 |
+
def get_glycanml_baselines() -> pd.DataFrame:
|
| 126 |
+
"""
|
| 127 |
+
GlycanML benchmark results from the paper.
|
| 128 |
+
|
| 129 |
+
All taxonomy tasks use Macro-F1.
|
| 130 |
+
Immunogenicity uses AUPRC.
|
| 131 |
+
Glycosylation uses Macro-F1.
|
| 132 |
+
Interaction uses Spearman correlation.
|
| 133 |
+
|
| 134 |
+
Returns a DataFrame with models as rows and tasks as columns.
|
| 135 |
+
"""
|
| 136 |
+
# Format: (mean, std) for each value
|
| 137 |
+
# We store just means for simplicity, stds in separate dict if needed
|
| 138 |
+
|
| 139 |
+
data = {
|
| 140 |
+
# === Monosaccharide-level Glycan Sequence Encoders ===
|
| 141 |
+
'Transformer': {
|
| 142 |
+
'domain': 0.612, 'kingdom': 0.546, 'phylum': 0.316, 'class': 0.235,
|
| 143 |
+
'order': 0.147, 'family': 0.114, 'genus': 0.065, 'species': 0.047,
|
| 144 |
+
'immunogenicity': 0.856, 'link': 0.729,
|
| 145 |
+
'weighted_mean_rank': 16.09, 'category': 'Sequence Encoders'
|
| 146 |
+
},
|
| 147 |
+
'Shallow CNN': {
|
| 148 |
+
'domain': 0.629, 'kingdom': 0.559, 'phylum': 0.388, 'class': 0.342,
|
| 149 |
+
'order': 0.238, 'family': 0.200, 'genus': 0.149, 'species': 0.115,
|
| 150 |
+
'immunogenicity': 0.776, 'link': 0.898,
|
| 151 |
+
'weighted_mean_rank': 12.53, 'category': 'Sequence Encoders'
|
| 152 |
+
},
|
| 153 |
+
'LSTM': {
|
| 154 |
+
'domain': 0.621, 'kingdom': 0.566, 'phylum': 0.413, 'class': 0.272,
|
| 155 |
+
'order': 0.174, 'family': 0.145, 'genus': 0.098, 'species': 0.078,
|
| 156 |
+
'immunogenicity': 0.912, 'link': 0.862,
|
| 157 |
+
'weighted_mean_rank': 11.00, 'category': 'Sequence Encoders'
|
| 158 |
+
},
|
| 159 |
+
'ResNet': {
|
| 160 |
+
'domain': 0.635, 'kingdom': 0.505, 'phylum': 0.331, 'class': 0.301,
|
| 161 |
+
'order': 0.183, 'family': 0.165, 'genus': 0.112, 'species': 0.073,
|
| 162 |
+
'immunogenicity': 0.754, 'link': 0.919,
|
| 163 |
+
'weighted_mean_rank': 12.09, 'category': 'Sequence Encoders'
|
| 164 |
+
},
|
| 165 |
+
|
| 166 |
+
# === Homogeneous GNNs ===
|
| 167 |
+
'GCN': {
|
| 168 |
+
'domain': 0.635, 'kingdom': 0.527, 'phylum': 0.325, 'class': 0.237,
|
| 169 |
+
'order': 0.147, 'family': 0.112, 'genus': 0.095, 'species': 0.080,
|
| 170 |
+
'immunogenicity': 0.688, 'link': 0.914,
|
| 171 |
+
'weighted_mean_rank': 18.38, 'category': 'Homogeneous GNNs'
|
| 172 |
+
},
|
| 173 |
+
'GAT': {
|
| 174 |
+
'domain': 0.636, 'kingdom': 0.523, 'phylum': 0.301, 'class': 0.265,
|
| 175 |
+
'order': 0.190, 'family': 0.130, 'genus': 0.125, 'species': 0.103,
|
| 176 |
+
'immunogenicity': 0.685, 'link': 0.934,
|
| 177 |
+
'weighted_mean_rank': 16.94, 'category': 'Homogeneous GNNs'
|
| 178 |
+
},
|
| 179 |
+
'GIN': {
|
| 180 |
+
'domain': 0.632, 'kingdom': 0.525, 'phylum': 0.322, 'class': 0.300,
|
| 181 |
+
'order': 0.179, 'family': 0.152, 'genus': 0.116, 'species': 0.105,
|
| 182 |
+
'immunogenicity': 0.716, 'link': 0.924,
|
| 183 |
+
'weighted_mean_rank': 15.06, 'category': 'Homogeneous GNNs'
|
| 184 |
+
},
|
| 185 |
+
|
| 186 |
+
# === Heterogeneous GNNs ===
|
| 187 |
+
'MPNN': {
|
| 188 |
+
'domain': 0.632, 'kingdom': 0.638, 'phylum': 0.372, 'class': 0.326,
|
| 189 |
+
'order': 0.235, 'family': 0.161, 'genus': 0.136, 'species': 0.104,
|
| 190 |
+
'immunogenicity': 0.674, 'link': 0.910,
|
| 191 |
+
'weighted_mean_rank': 18.34, 'category': 'Heterogeneous GNNs'
|
| 192 |
+
},
|
| 193 |
+
'RGCN': {
|
| 194 |
+
'domain': 0.633, 'kingdom': 0.647, 'phylum': 0.462, 'class': 0.373,
|
| 195 |
+
'order': 0.251, 'family': 0.203, 'genus': 0.164, 'species': 0.146,
|
| 196 |
+
'immunogenicity': 0.780, 'link': 0.948,
|
| 197 |
+
'weighted_mean_rank': 6.78, 'category': 'Heterogeneous GNNs'
|
| 198 |
+
},
|
| 199 |
+
'CompGCN': {
|
| 200 |
+
'domain': 0.629, 'kingdom': 0.568, 'phylum': 0.410, 'class': 0.381,
|
| 201 |
+
'order': 0.226, 'family': 0.193, 'genus': 0.166, 'species': 0.138,
|
| 202 |
+
'immunogenicity': 0.692, 'link': 0.945,
|
| 203 |
+
'weighted_mean_rank': 12.19, 'category': 'Heterogeneous GNNs'
|
| 204 |
+
},
|
| 205 |
+
'PreRGCN': {
|
| 206 |
+
'domain': 0.636, 'kingdom': 0.664, 'phylum': 0.451, 'class': 0.389,
|
| 207 |
+
'order': 0.265, 'family': 0.205, 'genus': 0.172, 'species': 0.139,
|
| 208 |
+
'immunogenicity': 0.781, 'link': 0.949,
|
| 209 |
+
'weighted_mean_rank': 5.34, 'category': 'Heterogeneous GNNs'
|
| 210 |
+
},
|
| 211 |
+
'GearNet': {
|
| 212 |
+
'domain': 0.471, 'kingdom': 0.577, 'phylum': 0.395, 'class': 0.389,
|
| 213 |
+
'order': 0.256, 'family': 0.189, 'genus': 0.165, 'species': 0.136,
|
| 214 |
+
'immunogenicity': 0.740, 'link': 0.892,
|
| 215 |
+
'weighted_mean_rank': 15.66, 'category': 'Heterogeneous GNNs'
|
| 216 |
+
},
|
| 217 |
+
'GearNet-Edge': {
|
| 218 |
+
'domain': 0.628, 'kingdom': 0.573, 'phylum': 0.396, 'class': 0.384,
|
| 219 |
+
'order': 0.262, 'family': 0.200, 'genus': 0.177, 'species': 0.140,
|
| 220 |
+
'immunogenicity': 0.768, 'link': 0.909,
|
| 221 |
+
'weighted_mean_rank': 12.25, 'category': 'Heterogeneous GNNs'
|
| 222 |
+
},
|
| 223 |
+
'ProNet': {
|
| 224 |
+
'domain': 0.627, 'kingdom': 0.590, 'phylum': 0.438, 'class': 0.380,
|
| 225 |
+
'order': 0.242, 'family': 0.192, 'genus': 0.146, 'species': 0.128,
|
| 226 |
+
'immunogenicity': 0.778, 'link': 0.930,
|
| 227 |
+
'weighted_mean_rank': 10.31, 'category': 'Heterogeneous GNNs'
|
| 228 |
+
},
|
| 229 |
+
|
| 230 |
+
# === All-Atom Molecular Encoders ===
|
| 231 |
+
'All-Atom RGCN': {
|
| 232 |
+
'domain': 0.637, 'kingdom': 0.624, 'phylum': 0.293, 'class': 0.156,
|
| 233 |
+
'order': 0.112, 'family': 0.096, 'genus': 0.063, 'species': 0.035,
|
| 234 |
+
'immunogenicity': 0.520, 'link': 0.928,
|
| 235 |
+
'weighted_mean_rank': 19.88, 'category': 'All-Atom Molecular Encoders'
|
| 236 |
+
},
|
| 237 |
+
'Graphormer': {
|
| 238 |
+
'domain': 0.640, 'kingdom': 0.468, 'phylum': 0.249, 'class': 0.201,
|
| 239 |
+
'order': 0.142, 'family': 0.112, 'genus': 0.077, 'species': 0.054,
|
| 240 |
+
'immunogenicity': 0.637, 'link': 0.856,
|
| 241 |
+
'weighted_mean_rank': 22.91, 'category': 'All-Atom Molecular Encoders'
|
| 242 |
+
},
|
| 243 |
+
'GraphGPS': {
|
| 244 |
+
'domain': 0.477, 'kingdom': 0.511, 'phylum': 0.314, 'class': 0.261,
|
| 245 |
+
'order': 0.153, 'family': 0.134, 'genus': 0.105, 'species': 0.065,
|
| 246 |
+
'immunogenicity': 0.637, 'link': 0.883,
|
| 247 |
+
'weighted_mean_rank': 20.38, 'category': 'All-Atom Molecular Encoders'
|
| 248 |
+
},
|
| 249 |
+
'Uni-Mol+': {
|
| 250 |
+
'domain': 0.639, 'kingdom': 0.446, 'phylum': 0.227, 'class': 0.174,
|
| 251 |
+
'order': 0.128, 'family': 0.109, 'genus': 0.077, 'species': 0.056,
|
| 252 |
+
'immunogenicity': 0.789, 'link': 0.885,
|
| 253 |
+
'weighted_mean_rank': 16.56, 'category': 'All-Atom Molecular Encoders'
|
| 254 |
+
},
|
| 255 |
+
|
| 256 |
+
# === GlycanAA Variants ===
|
| 257 |
+
'GlycanAA-SP': {
|
| 258 |
+
'domain': 0.589, 'kingdom': 0.635, 'phylum': 0.444, 'class': 0.395,
|
| 259 |
+
'order': 0.270, 'family': 0.205, 'genus': 0.176, 'species': 0.154,
|
| 260 |
+
'immunogenicity': 0.755, 'link': 0.946,
|
| 261 |
+
'weighted_mean_rank': 11.22, 'category': 'GlycanAA Variants'
|
| 262 |
+
},
|
| 263 |
+
'GlycanAA-AN': {
|
| 264 |
+
'domain': 0.609, 'kingdom': 0.685, 'phylum': 0.453, 'class': 0.427,
|
| 265 |
+
'order': 0.270, 'family': 0.199, 'genus': 0.179, 'species': 0.155,
|
| 266 |
+
'immunogenicity': 0.765, 'link': 0.947,
|
| 267 |
+
'weighted_mean_rank': 10.44, 'category': 'GlycanAA Variants'
|
| 268 |
+
},
|
| 269 |
+
'GlycanAA': {
|
| 270 |
+
'domain': 0.642, 'kingdom': 0.683, 'phylum': 0.484, 'class': 0.429,
|
| 271 |
+
'order': 0.291, 'family': 0.221, 'genus': 0.198, 'species': 0.157,
|
| 272 |
+
'immunogenicity': 0.792, 'link': 0.950,
|
| 273 |
+
'weighted_mean_rank': 2.56, 'category': 'GlycanAA Variants'
|
| 274 |
+
},
|
| 275 |
+
|
| 276 |
+
# === Pre-trained All-Atom Glycan Encoders ===
|
| 277 |
+
'VabsNet': {
|
| 278 |
+
'domain': 0.607, 'kingdom': 0.622, 'phylum': 0.363, 'class': 0.261,
|
| 279 |
+
'order': 0.175, 'family': 0.125, 'genus': 0.104, 'species': 0.068,
|
| 280 |
+
'immunogenicity': 0.742, 'link': 0.903,
|
| 281 |
+
'weighted_mean_rank': 19.03, 'category': 'Pre-trained All-Atom'
|
| 282 |
+
},
|
| 283 |
+
'GlycanAA-Attribute': {
|
| 284 |
+
'domain': 0.628, 'kingdom': 0.687, 'phylum': 0.457, 'class': 0.392,
|
| 285 |
+
'order': 0.263, 'family': 0.208, 'genus': 0.188, 'species': 0.143,
|
| 286 |
+
'immunogenicity': 0.722, 'link': 0.925,
|
| 287 |
+
'weighted_mean_rank': 10.47, 'category': 'Pre-trained All-Atom'
|
| 288 |
+
},
|
| 289 |
+
'GlycanAA-Context': {
|
| 290 |
+
'domain': 0.637, 'kingdom': 0.643, 'phylum': 0.453, 'class': 0.386,
|
| 291 |
+
'order': 0.259, 'family': 0.205, 'genus': 0.177, 'species': 0.144,
|
| 292 |
+
'immunogenicity': 0.768, 'link': 0.946,
|
| 293 |
+
'weighted_mean_rank': 7.06, 'category': 'Pre-trained All-Atom'
|
| 294 |
+
},
|
| 295 |
+
'PreGlycanAA': {
|
| 296 |
+
'domain': 0.661, 'kingdom': 0.688, 'phylum': 0.502, 'class': 0.447,
|
| 297 |
+
'order': 0.297, 'family': 0.233, 'genus': 0.203, 'species': 0.174,
|
| 298 |
+
'immunogenicity': 0.850, 'link': 0.961,
|
| 299 |
+
'weighted_mean_rank': 1.50, 'category': 'Pre-trained All-Atom'
|
| 300 |
+
},
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
df = pd.DataFrame(data).T
|
| 304 |
+
df.index.name = 'Model'
|
| 305 |
+
|
| 306 |
+
# Ensure numeric columns are float
|
| 307 |
+
numeric_cols = [c for c in df.columns if c not in ['category']]
|
| 308 |
+
for col in numeric_cols:
|
| 309 |
+
df[col] = pd.to_numeric(df[col], errors='coerce')
|
| 310 |
+
|
| 311 |
+
return df
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def get_glycanml_stds() -> Dict[str, Dict[str, float]]:
|
| 315 |
+
"""
|
| 316 |
+
Standard deviations for GlycanML results (from subscripts in paper).
|
| 317 |
+
Only includes notable ones - many are small (0.00X).
|
| 318 |
+
"""
|
| 319 |
+
return {
|
| 320 |
+
'Transformer': {
|
| 321 |
+
'domain': 0.009, 'kingdom': 0.079, 'phylum': 0.014, 'class': 0.022,
|
| 322 |
+
'order': 0.007, 'family': 0.039, 'genus': 0.001, 'species': 0.008,
|
| 323 |
+
'immunogenicity': 0.012, 'link': 0.069,
|
| 324 |
+
},
|
| 325 |
+
'PreGlycanAA': {
|
| 326 |
+
'domain': 0.025, 'kingdom': 0.001, 'phylum': 0.018, 'class': 0.014,
|
| 327 |
+
'order': 0.005, 'family': 0.010, 'genus': 0.003, 'species': 0.004,
|
| 328 |
+
'immunogenicity': 0.044, 'link': 0.011,
|
| 329 |
+
},
|
| 330 |
+
'GlycanAA': {
|
| 331 |
+
'domain': 0.002, 'kingdom': 0.002, 'phylum': 0.009, 'class': 0.022,
|
| 332 |
+
'order': 0.003, 'family': 0.002, 'genus': 0.011, 'species': 0.011,
|
| 333 |
+
'immunogenicity': 0.021, 'link': 0.020,
|
| 334 |
+
},
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
# =============================================================================
|
| 339 |
+
# COMBINED BASELINES
|
| 340 |
+
# =============================================================================
|
| 341 |
+
|
| 342 |
+
def get_all_baselines() -> pd.DataFrame:
|
| 343 |
+
"""
|
| 344 |
+
Get all baseline results combined into a single DataFrame.
|
| 345 |
+
|
| 346 |
+
Returns DataFrame with:
|
| 347 |
+
- Index: Model names
|
| 348 |
+
- Columns: All 11 tasks + weighted_mean_rank + category
|
| 349 |
+
"""
|
| 350 |
+
return get_glycanml_baselines()
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def get_task_columns() -> List[str]:
|
| 354 |
+
"""Return list of task column names in order."""
|
| 355 |
+
return ALL_TASKS
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def compute_weighted_mean_rank(df: pd.DataFrame, tasks: List[str] = None) -> pd.Series:
|
| 359 |
+
"""
|
| 360 |
+
Compute weighted mean rank across tasks, like GlycanML paper.
|
| 361 |
+
|
| 362 |
+
For each task, models are ranked 1 to N based on performance.
|
| 363 |
+
The weighted mean rank is the average rank across all tasks.
|
| 364 |
+
|
| 365 |
+
Args:
|
| 366 |
+
df: DataFrame with models as rows, tasks as columns
|
| 367 |
+
tasks: List of task columns to use (defaults to ALL_TASKS)
|
| 368 |
+
|
| 369 |
+
Returns:
|
| 370 |
+
Series with weighted mean rank per model
|
| 371 |
+
"""
|
| 372 |
+
if tasks is None:
|
| 373 |
+
tasks = [t for t in ALL_TASKS if t in df.columns]
|
| 374 |
+
|
| 375 |
+
# For each task, rank models (lower rank = better)
|
| 376 |
+
ranks = pd.DataFrame(index=df.index)
|
| 377 |
+
for task in tasks:
|
| 378 |
+
if task in df.columns:
|
| 379 |
+
# Rank in descending order (higher value = better = rank 1)
|
| 380 |
+
ranks[task] = df[task].rank(ascending=False, method='min')
|
| 381 |
+
|
| 382 |
+
# Compute mean rank
|
| 383 |
+
return ranks.mean(axis=1)
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
def get_sota_per_task(df: pd.DataFrame) -> Dict[str, Tuple[str, float]]:
|
| 387 |
+
"""
|
| 388 |
+
Get the SOTA (best performing) model for each task.
|
| 389 |
+
|
| 390 |
+
Returns:
|
| 391 |
+
Dict mapping task -> (model_name, score)
|
| 392 |
+
"""
|
| 393 |
+
sota = {}
|
| 394 |
+
for task in ALL_TASKS:
|
| 395 |
+
if task in df.columns:
|
| 396 |
+
best_idx = df[task].idxmax()
|
| 397 |
+
sota[task] = (best_idx, df.loc[best_idx, task])
|
| 398 |
+
return sota
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
def get_top_n_per_task(df: pd.DataFrame, n: int = 3) -> Dict[str, List[Tuple[str, float]]]:
|
| 402 |
+
"""
|
| 403 |
+
Get top N models for each task.
|
| 404 |
+
|
| 405 |
+
Returns:
|
| 406 |
+
Dict mapping task -> [(model_name, score), ...]
|
| 407 |
+
"""
|
| 408 |
+
top_n = {}
|
| 409 |
+
for task in ALL_TASKS:
|
| 410 |
+
if task in df.columns:
|
| 411 |
+
sorted_models = df[task].sort_values(ascending=False)
|
| 412 |
+
top_n[task] = [(idx, val) for idx, val in sorted_models.head(n).items()]
|
| 413 |
+
return top_n
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
# =============================================================================
|
| 417 |
+
# UTILITY FUNCTIONS
|
| 418 |
+
# =============================================================================
|
| 419 |
+
|
| 420 |
+
def format_value(val: float, task: str) -> str:
|
| 421 |
+
"""Format value for display based on task type."""
|
| 422 |
+
if pd.isna(val):
|
| 423 |
+
return '-'
|
| 424 |
+
return f'{val:.3f}'
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
def get_metric_for_task(task: str) -> str:
|
| 428 |
+
"""Get the primary metric name for a task."""
|
| 429 |
+
return PRIMARY_METRICS.get(task, 'macro_f1')
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def get_model_categories() -> Dict[str, List[str]]:
|
| 433 |
+
"""Get models grouped by category."""
|
| 434 |
+
df = get_glycanml_baselines()
|
| 435 |
+
categories = {}
|
| 436 |
+
for model, row in df.iterrows():
|
| 437 |
+
cat = row.get('category', 'Other')
|
| 438 |
+
if cat not in categories:
|
| 439 |
+
categories[cat] = []
|
| 440 |
+
categories[cat].append(model)
|
| 441 |
+
return categories
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
if __name__ == '__main__':
|
| 445 |
+
# Test the module
|
| 446 |
+
print("=== GlycanML Baselines ===")
|
| 447 |
+
df = get_glycanml_baselines()
|
| 448 |
+
print(f"Models: {len(df)}")
|
| 449 |
+
print(f"Tasks: {list(df.columns)}")
|
| 450 |
+
print()
|
| 451 |
+
|
| 452 |
+
print("=== SOTA per task ===")
|
| 453 |
+
sota = get_sota_per_task(df)
|
| 454 |
+
for task, (model, score) in sota.items():
|
| 455 |
+
print(f" {task}: {model} ({score:.3f})")
|
| 456 |
+
print()
|
| 457 |
+
|
| 458 |
+
print("=== Top models by weighted mean rank ===")
|
| 459 |
+
top_models = df.nsmallest(5, 'weighted_mean_rank')[['weighted_mean_rank', 'category']]
|
| 460 |
+
print(top_models)
|
| 461 |
+
|
code/downstream_tasks/utils/dataset.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Simplified Dataset for Glycan Classification Fine-tuning
|
| 3 |
+
|
| 4 |
+
Works with clean benchmark CSV that has WURCS column.
|
| 5 |
+
Supports both atomic (v3) and BPE (v4) tokenization.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import torch
|
| 9 |
+
from torch.utils.data import Dataset
|
| 10 |
+
import pandas as pd
|
| 11 |
+
from typing import Dict, List, Optional, Tuple
|
| 12 |
+
import logging
|
| 13 |
+
import json
|
| 14 |
+
|
| 15 |
+
from .tokenizer import WURCSTokenizer, create_tokenizer
|
| 16 |
+
|
| 17 |
+
# Try to import BPE tokenizer (available in v4)
|
| 18 |
+
try:
|
| 19 |
+
from .wurcs_bpe_tokenizer import WURCSBPETokenizer
|
| 20 |
+
HAS_BPE = True
|
| 21 |
+
except ImportError:
|
| 22 |
+
HAS_BPE = False
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def load_tokenizer(vocab_path: str):
|
| 28 |
+
"""
|
| 29 |
+
Load tokenizer based on vocabulary file type.
|
| 30 |
+
|
| 31 |
+
Args:
|
| 32 |
+
vocab_path: Path to vocabulary JSON file
|
| 33 |
+
|
| 34 |
+
Returns:
|
| 35 |
+
Either WURCSTokenizer (atomic) or WURCSBPETokenizer (BPE)
|
| 36 |
+
"""
|
| 37 |
+
with open(vocab_path, 'r') as f:
|
| 38 |
+
vocab = json.load(f)
|
| 39 |
+
|
| 40 |
+
# Check if this is a BPE vocabulary (has 'merges' field)
|
| 41 |
+
if 'merges' in vocab:
|
| 42 |
+
if not HAS_BPE:
|
| 43 |
+
raise ImportError("BPE vocabulary detected but WURCSBPETokenizer not available")
|
| 44 |
+
logger.info(f"Loading BPE tokenizer (vocab_size={vocab['metadata']['vocab_size']})")
|
| 45 |
+
return WURCSBPETokenizer(vocab_path)
|
| 46 |
+
else:
|
| 47 |
+
logger.info(f"Loading atomic tokenizer (vocab_size={vocab.get('metadata', {}).get('vocab_size', 167)})")
|
| 48 |
+
return WURCSTokenizer(vocab_path)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class GlycanClassificationDataset(Dataset):
|
| 52 |
+
"""
|
| 53 |
+
Dataset for glycan classification tasks.
|
| 54 |
+
|
| 55 |
+
Expects a CSV with columns:
|
| 56 |
+
- target: IUPAC representation of glycan
|
| 57 |
+
- wurcs: WURCS representation (required)
|
| 58 |
+
- {task_name}: Label column (e.g., 'species', 'phylum')
|
| 59 |
+
- split: 'train', 'validation', or 'test'
|
| 60 |
+
"""
|
| 61 |
+
|
| 62 |
+
def __init__(
|
| 63 |
+
self,
|
| 64 |
+
csv_path: str,
|
| 65 |
+
task: str,
|
| 66 |
+
split: str,
|
| 67 |
+
vocab_path: str,
|
| 68 |
+
max_length: int = 256, # Reduced default for BPE (was 512)
|
| 69 |
+
valid_classes: List[str] = None,
|
| 70 |
+
):
|
| 71 |
+
"""
|
| 72 |
+
Initialize dataset.
|
| 73 |
+
|
| 74 |
+
Args:
|
| 75 |
+
csv_path: Path to CSV file
|
| 76 |
+
task: Task name (column name for labels)
|
| 77 |
+
split: One of 'train', 'validation', 'test'
|
| 78 |
+
vocab_path: Path to vocabulary.json
|
| 79 |
+
max_length: Maximum sequence length
|
| 80 |
+
valid_classes: Optional list of valid classes to filter to
|
| 81 |
+
"""
|
| 82 |
+
self.task = task
|
| 83 |
+
self.split = split
|
| 84 |
+
self.max_length = max_length
|
| 85 |
+
|
| 86 |
+
# Load tokenizer (auto-detects BPE vs atomic based on vocab file)
|
| 87 |
+
self.tokenizer = load_tokenizer(vocab_path)
|
| 88 |
+
|
| 89 |
+
# Load data
|
| 90 |
+
df = pd.read_csv(csv_path)
|
| 91 |
+
|
| 92 |
+
# Filter by split
|
| 93 |
+
# Handle different column naming conventions:
|
| 94 |
+
# - Classification CSV: 'train', 'validation', 'test' columns (binary 0/1)
|
| 95 |
+
# - Immunogenicity/Link CSV: 'train', 'valid', 'test' columns (binary 0/1)
|
| 96 |
+
# - Some files might have a single 'split' column with string values
|
| 97 |
+
|
| 98 |
+
# Map 'validation' to 'valid' if needed
|
| 99 |
+
split_col = split
|
| 100 |
+
if split == 'validation' and 'validation' not in df.columns and 'valid' in df.columns:
|
| 101 |
+
split_col = 'valid'
|
| 102 |
+
|
| 103 |
+
if split_col in df.columns:
|
| 104 |
+
# Binary column for this split
|
| 105 |
+
if df[split_col].dtype == 'bool':
|
| 106 |
+
self.df = df[df[split_col]].copy()
|
| 107 |
+
else: # int64 or similar
|
| 108 |
+
self.df = df[df[split_col] == 1].copy()
|
| 109 |
+
elif 'split' in df.columns:
|
| 110 |
+
# Single 'split' column with string values
|
| 111 |
+
self.df = df[df['split'] == split].copy()
|
| 112 |
+
else:
|
| 113 |
+
raise ValueError(f"Cannot find split column '{split}' or '{split_col}' or 'split' in CSV")
|
| 114 |
+
|
| 115 |
+
# Filter to only samples with WURCS
|
| 116 |
+
initial_count = len(self.df)
|
| 117 |
+
self.df = self.df[self.df['wurcs'].notna()].copy()
|
| 118 |
+
removed = initial_count - len(self.df)
|
| 119 |
+
if removed > 0:
|
| 120 |
+
logger.info(f"Removed {removed} samples without WURCS")
|
| 121 |
+
|
| 122 |
+
# Get unique labels and create mapping
|
| 123 |
+
self.df = self.df[self.df[task].notna()].copy()
|
| 124 |
+
|
| 125 |
+
# Apply valid_classes filter if provided (for strict filtering mode)
|
| 126 |
+
if valid_classes is not None:
|
| 127 |
+
before_filter = len(self.df)
|
| 128 |
+
self.df = self.df[self.df[task].isin(valid_classes)].copy()
|
| 129 |
+
filtered_out = before_filter - len(self.df)
|
| 130 |
+
if filtered_out > 0:
|
| 131 |
+
logger.info(f" Filtered out {filtered_out} samples (classes not in all splits)")
|
| 132 |
+
self.unique_labels = sorted([c for c in valid_classes if c in self.df[task].values])
|
| 133 |
+
else:
|
| 134 |
+
self.unique_labels = sorted(self.df[task].unique())
|
| 135 |
+
|
| 136 |
+
self.label_to_id = {label: i for i, label in enumerate(self.unique_labels)}
|
| 137 |
+
self.id_to_label = {i: label for label, i in self.label_to_id.items()}
|
| 138 |
+
|
| 139 |
+
# Log info
|
| 140 |
+
logger.info(f"Loaded {len(self.df)} samples for {split} split")
|
| 141 |
+
logger.info(f" Task: {task}")
|
| 142 |
+
logger.info(f" Classes: {len(self.unique_labels)}")
|
| 143 |
+
|
| 144 |
+
def __len__(self) -> int:
|
| 145 |
+
return len(self.df)
|
| 146 |
+
|
| 147 |
+
def __getitem__(self, idx: int) -> Dict[str, torch.Tensor]:
|
| 148 |
+
row = self.df.iloc[idx]
|
| 149 |
+
|
| 150 |
+
# Tokenize WURCS
|
| 151 |
+
wurcs = row['wurcs']
|
| 152 |
+
tokenized = self.tokenizer.tokenize(wurcs, max_length=self.max_length)
|
| 153 |
+
|
| 154 |
+
# Get label
|
| 155 |
+
label = self.label_to_id[row[self.task]]
|
| 156 |
+
|
| 157 |
+
# Handle fields that may not exist in BPE tokenizer
|
| 158 |
+
num_residues = tokenized.get('num_residues', max(tokenized.get('residue_ids', [0])) + 1)
|
| 159 |
+
is_branched = tokenized.get('is_branched', '[BRANCH_OPEN]' in tokenized.get('tokens', []))
|
| 160 |
+
|
| 161 |
+
# Get distance matrix if available (populated by tokenizer)
|
| 162 |
+
# It's an NxN matrix of shortest path distances
|
| 163 |
+
dist_labels = tokenized.get('distance_matrix')
|
| 164 |
+
if dist_labels is None:
|
| 165 |
+
# Fallback if not available: -1 everywhere (ignore loss)
|
| 166 |
+
dist_labels = torch.full((self.max_length, self.max_length), -1, dtype=torch.long)
|
| 167 |
+
else:
|
| 168 |
+
# Pad to max_length
|
| 169 |
+
# Current shape: (seq_len, seq_len)
|
| 170 |
+
seq_len = len(tokenized['token_ids'])
|
| 171 |
+
padded_dist = torch.full((self.max_length, self.max_length), -1, dtype=torch.long)
|
| 172 |
+
# Copy valid region
|
| 173 |
+
padded_dist[:seq_len, :seq_len] = torch.tensor(dist_labels, dtype=torch.long)
|
| 174 |
+
dist_labels = padded_dist
|
| 175 |
+
|
| 176 |
+
return {
|
| 177 |
+
'token_ids': torch.tensor(tokenized['token_ids'], dtype=torch.long),
|
| 178 |
+
'attention_mask': torch.tensor(tokenized['attention_mask'], dtype=torch.long),
|
| 179 |
+
'residue_ids': torch.tensor(tokenized['residue_ids'], dtype=torch.long),
|
| 180 |
+
'branch_depths': torch.tensor(tokenized['branch_depths'], dtype=torch.long),
|
| 181 |
+
'linkage_types': torch.tensor(tokenized['linkage_types'], dtype=torch.long),
|
| 182 |
+
'dist_labels': dist_labels,
|
| 183 |
+
'num_residues': num_residues,
|
| 184 |
+
'is_branched': is_branched,
|
| 185 |
+
'label': torch.tensor(label, dtype=torch.long),
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
def get_class_weights(self) -> torch.Tensor:
|
| 189 |
+
"""
|
| 190 |
+
Compute class weights for imbalanced data.
|
| 191 |
+
|
| 192 |
+
Returns:
|
| 193 |
+
Tensor of class weights (inverse frequency)
|
| 194 |
+
"""
|
| 195 |
+
class_counts = self.df[self.task].value_counts()
|
| 196 |
+
total = len(self.df)
|
| 197 |
+
n_classes = len(self.unique_labels)
|
| 198 |
+
|
| 199 |
+
weights = []
|
| 200 |
+
for label in self.unique_labels:
|
| 201 |
+
count = class_counts.get(label, 1)
|
| 202 |
+
weight = total / (n_classes * count)
|
| 203 |
+
weights.append(weight)
|
| 204 |
+
|
| 205 |
+
return torch.tensor(weights, dtype=torch.float)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def compute_valid_classes(
|
| 209 |
+
csv_path: str,
|
| 210 |
+
task: str,
|
| 211 |
+
min_samples: int = 1,
|
| 212 |
+
) -> List[str]:
|
| 213 |
+
"""
|
| 214 |
+
Compute classes that are present in all splits (train, val, test)
|
| 215 |
+
with at least min_samples in each split.
|
| 216 |
+
|
| 217 |
+
This is used for 'strict' filtering mode (GlycanML approach).
|
| 218 |
+
|
| 219 |
+
Args:
|
| 220 |
+
csv_path: Path to CSV file
|
| 221 |
+
task: Task column name
|
| 222 |
+
min_samples: Minimum samples per class per split (default 1)
|
| 223 |
+
|
| 224 |
+
Returns:
|
| 225 |
+
List of valid class names
|
| 226 |
+
"""
|
| 227 |
+
df = pd.read_csv(csv_path)
|
| 228 |
+
|
| 229 |
+
# Only consider samples with WURCS
|
| 230 |
+
df = df[df['wurcs'].notna()]
|
| 231 |
+
df = df[df[task].notna()]
|
| 232 |
+
|
| 233 |
+
# Get split DataFrames
|
| 234 |
+
# Handle different column naming conventions:
|
| 235 |
+
# - Classification CSV: 'train', 'validation', 'test'
|
| 236 |
+
# - Immunogenicity/Link CSV: 'train', 'valid', 'test'
|
| 237 |
+
if 'train' in df.columns:
|
| 238 |
+
train_df = df[df['train'] == 1]
|
| 239 |
+
else:
|
| 240 |
+
train_df = df[df['split'] == 'train']
|
| 241 |
+
|
| 242 |
+
if 'validation' in df.columns:
|
| 243 |
+
val_df = df[df['validation'] == 1]
|
| 244 |
+
elif 'valid' in df.columns:
|
| 245 |
+
val_df = df[df['valid'] == 1]
|
| 246 |
+
else:
|
| 247 |
+
val_df = df[df['split'] == 'validation']
|
| 248 |
+
|
| 249 |
+
if 'test' in df.columns:
|
| 250 |
+
test_df = df[df['test'] == 1]
|
| 251 |
+
else:
|
| 252 |
+
test_df = df[df['split'] == 'test']
|
| 253 |
+
|
| 254 |
+
# Get class counts per split
|
| 255 |
+
train_counts = train_df[task].value_counts()
|
| 256 |
+
val_counts = val_df[task].value_counts()
|
| 257 |
+
test_counts = test_df[task].value_counts()
|
| 258 |
+
|
| 259 |
+
# Get classes with >= min_samples in each split
|
| 260 |
+
train_classes = set(train_counts[train_counts >= min_samples].index)
|
| 261 |
+
val_classes = set(val_counts[val_counts >= min_samples].index)
|
| 262 |
+
test_classes = set(test_counts[test_counts >= min_samples].index)
|
| 263 |
+
|
| 264 |
+
# Classes must meet min_samples threshold in all splits
|
| 265 |
+
valid_classes = sorted(train_classes & val_classes & test_classes)
|
| 266 |
+
|
| 267 |
+
all_classes = set(train_counts.index) | set(val_counts.index) | set(test_counts.index)
|
| 268 |
+
|
| 269 |
+
logger.info(f"Computing valid classes for {task} (min_samples={min_samples}):")
|
| 270 |
+
logger.info(f" Train classes (>={min_samples}): {len(train_classes)}")
|
| 271 |
+
logger.info(f" Val classes (>={min_samples}): {len(val_classes)}")
|
| 272 |
+
logger.info(f" Test classes (>={min_samples}): {len(test_classes)}")
|
| 273 |
+
logger.info(f" Valid (in all splits): {len(valid_classes)}")
|
| 274 |
+
logger.info(f" Excluded: {len(all_classes) - len(valid_classes)}")
|
| 275 |
+
|
| 276 |
+
return valid_classes
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def filter_to_valid_classes(
|
| 280 |
+
train_df: pd.DataFrame,
|
| 281 |
+
val_df: pd.DataFrame,
|
| 282 |
+
test_df: pd.DataFrame,
|
| 283 |
+
task: str,
|
| 284 |
+
) -> Tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, List[str]]:
|
| 285 |
+
"""
|
| 286 |
+
Filter datasets to only include classes present in all splits.
|
| 287 |
+
|
| 288 |
+
Args:
|
| 289 |
+
train_df: Training data
|
| 290 |
+
val_df: Validation data
|
| 291 |
+
test_df: Test data
|
| 292 |
+
task: Task column name
|
| 293 |
+
|
| 294 |
+
Returns:
|
| 295 |
+
Filtered (train_df, val_df, test_df, valid_classes)
|
| 296 |
+
"""
|
| 297 |
+
train_classes = set(train_df[task].dropna().unique())
|
| 298 |
+
val_classes = set(val_df[task].dropna().unique())
|
| 299 |
+
test_classes = set(test_df[task].dropna().unique())
|
| 300 |
+
|
| 301 |
+
# Classes must be in all splits
|
| 302 |
+
valid_classes = sorted(train_classes & val_classes & test_classes)
|
| 303 |
+
|
| 304 |
+
logger.info(f"Filtering classes for {task}:")
|
| 305 |
+
logger.info(f" Train classes: {len(train_classes)}")
|
| 306 |
+
logger.info(f" Val classes: {len(val_classes)}")
|
| 307 |
+
logger.info(f" Test classes: {len(test_classes)}")
|
| 308 |
+
logger.info(f" Valid (in all): {len(valid_classes)}")
|
| 309 |
+
|
| 310 |
+
train_df = train_df[train_df[task].isin(valid_classes)].copy()
|
| 311 |
+
val_df = val_df[val_df[task].isin(valid_classes)].copy()
|
| 312 |
+
test_df = test_df[test_df[task].isin(valid_classes)].copy()
|
| 313 |
+
|
| 314 |
+
return train_df, val_df, test_df, valid_classes
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def create_dataloaders(
|
| 318 |
+
csv_path: str,
|
| 319 |
+
task: str,
|
| 320 |
+
vocab_path: str,
|
| 321 |
+
batch_size: int = 64,
|
| 322 |
+
max_length: int = 512,
|
| 323 |
+
num_workers: int = 4,
|
| 324 |
+
) -> Tuple[torch.utils.data.DataLoader, torch.utils.data.DataLoader, torch.utils.data.DataLoader]:
|
| 325 |
+
"""
|
| 326 |
+
Create train, validation, and test dataloaders.
|
| 327 |
+
|
| 328 |
+
Args:
|
| 329 |
+
csv_path: Path to CSV file
|
| 330 |
+
task: Task name
|
| 331 |
+
vocab_path: Path to vocabulary.json
|
| 332 |
+
batch_size: Batch size
|
| 333 |
+
max_length: Maximum sequence length
|
| 334 |
+
num_workers: Number of data loading workers
|
| 335 |
+
|
| 336 |
+
Returns:
|
| 337 |
+
(train_loader, val_loader, test_loader)
|
| 338 |
+
"""
|
| 339 |
+
# Create datasets
|
| 340 |
+
train_dataset = GlycanClassificationDataset(
|
| 341 |
+
csv_path, task, 'train', vocab_path, max_length
|
| 342 |
+
)
|
| 343 |
+
val_dataset = GlycanClassificationDataset(
|
| 344 |
+
csv_path, task, 'validation', vocab_path, max_length
|
| 345 |
+
)
|
| 346 |
+
test_dataset = GlycanClassificationDataset(
|
| 347 |
+
csv_path, task, 'test', vocab_path, max_length
|
| 348 |
+
)
|
| 349 |
+
|
| 350 |
+
# Create dataloaders
|
| 351 |
+
train_loader = torch.utils.data.DataLoader(
|
| 352 |
+
train_dataset,
|
| 353 |
+
batch_size=batch_size,
|
| 354 |
+
shuffle=True,
|
| 355 |
+
num_workers=num_workers,
|
| 356 |
+
pin_memory=True,
|
| 357 |
+
)
|
| 358 |
+
|
| 359 |
+
val_loader = torch.utils.data.DataLoader(
|
| 360 |
+
val_dataset,
|
| 361 |
+
batch_size=batch_size,
|
| 362 |
+
shuffle=False,
|
| 363 |
+
num_workers=num_workers,
|
| 364 |
+
pin_memory=True,
|
| 365 |
+
)
|
| 366 |
+
|
| 367 |
+
test_loader = torch.utils.data.DataLoader(
|
| 368 |
+
test_dataset,
|
| 369 |
+
batch_size=batch_size,
|
| 370 |
+
shuffle=False,
|
| 371 |
+
num_workers=num_workers,
|
| 372 |
+
pin_memory=True,
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
return train_loader, val_loader, test_loader
|
| 376 |
+
|
code/downstream_tasks/utils/tokenizer.py
ADDED
|
@@ -0,0 +1,612 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Standalone WURCS Tokenizer for Fine-tuning
|
| 3 |
+
|
| 4 |
+
This tokenizer matches the v3.1 pre-training tokenization exactly:
|
| 5 |
+
- Uses bracket-counting parser (handles '/' in modifications)
|
| 6 |
+
- 167 tokens (including 58 whole-token modifications)
|
| 7 |
+
- Same special tokens: [PAD], [UNK], [START], [END], [BRANCH_OPEN], [BRANCH_CLOSE], etc.
|
| 8 |
+
- Detects and inserts branch tokens based on WURCS topology
|
| 9 |
+
|
| 10 |
+
Usage:
|
| 11 |
+
tokenizer = WURCSTokenizer('data/vocabulary.json')
|
| 12 |
+
result = tokenizer.tokenize(wurcs_string)
|
| 13 |
+
# result = {'tokens': [...], 'token_ids': [...], 'length': N}
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
import re
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Dict, List, Optional, Tuple
|
| 20 |
+
from collections import defaultdict
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def parse_wurcs_sections(wurcs: str) -> List[str]:
|
| 24 |
+
"""
|
| 25 |
+
Parse WURCS string into sections by counting brackets.
|
| 26 |
+
|
| 27 |
+
This correctly handles modifications with '/' characters like NCC/3=O.
|
| 28 |
+
The simple regex approach fails because '/' appears inside modifications.
|
| 29 |
+
|
| 30 |
+
Args:
|
| 31 |
+
wurcs: WURCS string
|
| 32 |
+
|
| 33 |
+
Returns:
|
| 34 |
+
List of sections [version, counts, residues, topology, linkages]
|
| 35 |
+
"""
|
| 36 |
+
sections = []
|
| 37 |
+
current_section = []
|
| 38 |
+
bracket_depth = 0
|
| 39 |
+
|
| 40 |
+
for char in wurcs:
|
| 41 |
+
if char == '[':
|
| 42 |
+
bracket_depth += 1
|
| 43 |
+
current_section.append(char)
|
| 44 |
+
elif char == ']':
|
| 45 |
+
bracket_depth -= 1
|
| 46 |
+
current_section.append(char)
|
| 47 |
+
elif char == '/' and bracket_depth == 0:
|
| 48 |
+
# This '/' is a section separator (not inside a modification)
|
| 49 |
+
sections.append(''.join(current_section))
|
| 50 |
+
current_section = []
|
| 51 |
+
else:
|
| 52 |
+
current_section.append(char)
|
| 53 |
+
|
| 54 |
+
# Add last section
|
| 55 |
+
if current_section:
|
| 56 |
+
sections.append(''.join(current_section))
|
| 57 |
+
|
| 58 |
+
return sections
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def extract_residues_from_wurcs(wurcs: str) -> List[str]:
|
| 62 |
+
"""
|
| 63 |
+
Extract residue definitions from WURCS string.
|
| 64 |
+
|
| 65 |
+
Uses bracket-counting parser to correctly handle modifications
|
| 66 |
+
containing '/' characters (e.g., 2*NCC/3=O, 4*OSO/3=O/3=O).
|
| 67 |
+
|
| 68 |
+
Args:
|
| 69 |
+
wurcs: WURCS string
|
| 70 |
+
|
| 71 |
+
Returns:
|
| 72 |
+
List of residue strings (e.g., ['a2122h-1a_1-5_2*NCC/3=O', ...])
|
| 73 |
+
"""
|
| 74 |
+
sections = parse_wurcs_sections(wurcs)
|
| 75 |
+
|
| 76 |
+
if len(sections) < 3:
|
| 77 |
+
return []
|
| 78 |
+
|
| 79 |
+
# Section 2 contains residue definitions: [type1][type2]...
|
| 80 |
+
residues_section = sections[2]
|
| 81 |
+
|
| 82 |
+
# Find all residues [...]
|
| 83 |
+
residues = re.findall(r'\[([^\]]+)\]', residues_section)
|
| 84 |
+
|
| 85 |
+
return residues
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def extract_linkages_from_wurcs(wurcs: str) -> List[str]:
|
| 89 |
+
"""
|
| 90 |
+
Extract linkage patterns from WURCS string.
|
| 91 |
+
|
| 92 |
+
Uses bracket-counting parser to correctly handle modifications
|
| 93 |
+
containing '/' characters.
|
| 94 |
+
|
| 95 |
+
Args:
|
| 96 |
+
wurcs: WURCS string
|
| 97 |
+
|
| 98 |
+
Returns:
|
| 99 |
+
List of linkage strings (e.g., ['a4-b1', 'b3-c2'])
|
| 100 |
+
"""
|
| 101 |
+
sections = parse_wurcs_sections(wurcs)
|
| 102 |
+
|
| 103 |
+
if len(sections) < 5:
|
| 104 |
+
return []
|
| 105 |
+
|
| 106 |
+
# Section 4 contains linkages: a4-b1_b3-c2_...
|
| 107 |
+
linkage_section = sections[4]
|
| 108 |
+
|
| 109 |
+
# Split by underscore
|
| 110 |
+
linkages = linkage_section.split('_')
|
| 111 |
+
|
| 112 |
+
return [l for l in linkages if l]
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def detect_branch_points(linkages: List[str]) -> Tuple[Dict[str, List[str]], Dict[str, List[str]]]:
|
| 116 |
+
"""
|
| 117 |
+
Detect branch points from linkages.
|
| 118 |
+
A residue is a branch point if it has >1 outgoing connections.
|
| 119 |
+
|
| 120 |
+
Args:
|
| 121 |
+
linkages: List of linkage strings (e.g., ['a4-b1', 'a3-c2'])
|
| 122 |
+
|
| 123 |
+
Returns:
|
| 124 |
+
Tuple of:
|
| 125 |
+
- branch_points: Dict mapping residue letter to list of children (only for >1 children)
|
| 126 |
+
- outgoing: Dict mapping all residue letters to their children
|
| 127 |
+
"""
|
| 128 |
+
outgoing = defaultdict(list)
|
| 129 |
+
|
| 130 |
+
for link in linkages:
|
| 131 |
+
# Parse link like "a3-b1" or "a?-b1"
|
| 132 |
+
match = re.match(r'([a-z])([?\d]+)-([a-z])([?\d]+)', link)
|
| 133 |
+
if match:
|
| 134 |
+
from_res, from_pos, to_res, to_pos = match.groups()
|
| 135 |
+
outgoing[from_res].append(to_res)
|
| 136 |
+
|
| 137 |
+
# Branch points have >1 children
|
| 138 |
+
branch_points = {k: v for k, v in outgoing.items() if len(v) > 1}
|
| 139 |
+
return branch_points, dict(outgoing)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def compute_residue_depths(linkages: List[str]) -> Dict[str, int]:
|
| 143 |
+
"""
|
| 144 |
+
Compute depth of each residue in the glycan tree using BFS from root.
|
| 145 |
+
|
| 146 |
+
Root is typically 'a' (first residue). Depth 0 = root, 1 = children of root, etc.
|
| 147 |
+
|
| 148 |
+
Args:
|
| 149 |
+
linkages: List of linkage strings (e.g., ['a4-b1', 'b3-c1'])
|
| 150 |
+
|
| 151 |
+
Returns:
|
| 152 |
+
Dict mapping residue letter to its depth in the tree
|
| 153 |
+
"""
|
| 154 |
+
# Build parent-child graph from linkages
|
| 155 |
+
children = defaultdict(list)
|
| 156 |
+
all_residues = set()
|
| 157 |
+
|
| 158 |
+
for link in linkages:
|
| 159 |
+
match = re.match(r'([a-z])([?\d]+)-([a-z])([?\d]+)', link)
|
| 160 |
+
if match:
|
| 161 |
+
from_res, _, to_res, _ = match.groups()
|
| 162 |
+
children[from_res].append(to_res)
|
| 163 |
+
all_residues.add(from_res)
|
| 164 |
+
all_residues.add(to_res)
|
| 165 |
+
|
| 166 |
+
# Find root (residue with no parent) - usually 'a'
|
| 167 |
+
child_set = set()
|
| 168 |
+
for kids in children.values():
|
| 169 |
+
child_set.update(kids)
|
| 170 |
+
|
| 171 |
+
roots = all_residues - child_set
|
| 172 |
+
root = min(roots) if roots else 'a' # Default to 'a' if can't determine
|
| 173 |
+
|
| 174 |
+
# BFS from root to compute depths
|
| 175 |
+
depths = {root: 0}
|
| 176 |
+
queue = [root]
|
| 177 |
+
|
| 178 |
+
while queue:
|
| 179 |
+
current = queue.pop(0)
|
| 180 |
+
for child in children.get(current, []):
|
| 181 |
+
if child not in depths:
|
| 182 |
+
depths[child] = depths[current] + 1
|
| 183 |
+
queue.append(child)
|
| 184 |
+
|
| 185 |
+
return depths
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def parse_linkage_type(link: str) -> int:
|
| 189 |
+
"""
|
| 190 |
+
Parse a linkage string to extract linkage type ID.
|
| 191 |
+
|
| 192 |
+
Linkage types based on anomeric configuration and position:
|
| 193 |
+
- α1-2, α1-3, α1-4, α1-6, β1-2, β1-3, β1-4, β1-6, unknown
|
| 194 |
+
|
| 195 |
+
WURCS uses position numbers (1-6) in linkage patterns like 'a4-b1'.
|
| 196 |
+
We extract the positions to determine linkage type.
|
| 197 |
+
|
| 198 |
+
Args:
|
| 199 |
+
link: Linkage string like 'a4-b1' or 'a3-b1'
|
| 200 |
+
|
| 201 |
+
Returns:
|
| 202 |
+
Linkage type ID (0-8):
|
| 203 |
+
0 = 1-2, 1 = 1-3, 2 = 1-4, 3 = 1-6, 4 = 2-3, 5 = 2-6, 6 = 3-6, 7 = other, 8 = unknown
|
| 204 |
+
"""
|
| 205 |
+
LINKAGE_TYPES = {
|
| 206 |
+
(1, 2): 0, (2, 1): 0,
|
| 207 |
+
(1, 3): 1, (3, 1): 1,
|
| 208 |
+
(1, 4): 2, (4, 1): 2,
|
| 209 |
+
(1, 6): 3, (6, 1): 3,
|
| 210 |
+
(2, 3): 4, (3, 2): 4,
|
| 211 |
+
(2, 6): 5, (6, 2): 5,
|
| 212 |
+
(3, 6): 6, (6, 3): 6,
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
match = re.match(r'([a-z])([?\d]+)-([a-z])([?\d]+)', link)
|
| 216 |
+
if match:
|
| 217 |
+
_, from_pos, _, to_pos = match.groups()
|
| 218 |
+
try:
|
| 219 |
+
pos_tuple = (int(from_pos), int(to_pos))
|
| 220 |
+
return LINKAGE_TYPES.get(pos_tuple, 7) # 7 = other
|
| 221 |
+
except ValueError:
|
| 222 |
+
return 8 # Unknown (contains '?')
|
| 223 |
+
return 8
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
class WURCSTokenizer:
|
| 227 |
+
"""
|
| 228 |
+
WURCS tokenizer matching v3.1 pre-training exactly.
|
| 229 |
+
|
| 230 |
+
Key features:
|
| 231 |
+
- Bracket-counting parser for '/' handling in modifications
|
| 232 |
+
- Branch token insertion based on topology
|
| 233 |
+
- Whole-token modifications (58 total)
|
| 234 |
+
"""
|
| 235 |
+
|
| 236 |
+
def __init__(self, vocab_path: str):
|
| 237 |
+
"""
|
| 238 |
+
Initialize tokenizer with vocabulary.
|
| 239 |
+
|
| 240 |
+
Args:
|
| 241 |
+
vocab_path: Path to vocabulary.json (same as used in v3.1 pre-training)
|
| 242 |
+
"""
|
| 243 |
+
with open(vocab_path, 'r') as f:
|
| 244 |
+
self.vocab = json.load(f)
|
| 245 |
+
|
| 246 |
+
# Build token_to_id mapping
|
| 247 |
+
self.token_to_id = {}
|
| 248 |
+
|
| 249 |
+
# Add special tokens
|
| 250 |
+
for token, idx in self.vocab.get('special_tokens', {}).items():
|
| 251 |
+
self.token_to_id[token] = idx
|
| 252 |
+
|
| 253 |
+
# Add skeleton atoms
|
| 254 |
+
for token, idx in self.vocab.get('skeleton_atoms', {}).items():
|
| 255 |
+
self.token_to_id[token] = idx
|
| 256 |
+
|
| 257 |
+
# Add linkage atoms
|
| 258 |
+
for token, idx in self.vocab.get('linkage_atoms', {}).items():
|
| 259 |
+
self.token_to_id[token] = idx
|
| 260 |
+
|
| 261 |
+
# Add anomeric symbols
|
| 262 |
+
for token, idx in self.vocab.get('anomeric_symbols', {}).items():
|
| 263 |
+
self.token_to_id[token] = idx
|
| 264 |
+
|
| 265 |
+
# Add anomeric positions
|
| 266 |
+
for token, idx in self.vocab.get('anomeric_positions', {}).items():
|
| 267 |
+
self.token_to_id[token] = idx
|
| 268 |
+
|
| 269 |
+
# Add whole modifications
|
| 270 |
+
for token, idx in self.vocab.get('whole_modifications', {}).items():
|
| 271 |
+
self.token_to_id[token] = idx
|
| 272 |
+
|
| 273 |
+
# Build reverse mapping
|
| 274 |
+
self.id_to_token = {idx: token for token, idx in self.token_to_id.items()}
|
| 275 |
+
|
| 276 |
+
# Set special token IDs
|
| 277 |
+
self.pad_token_id = self.token_to_id.get('[PAD]', 0)
|
| 278 |
+
self.unk_token_id = self.token_to_id.get('[UNK]', 1)
|
| 279 |
+
self.start_token_id = self.token_to_id.get('[START]', 2)
|
| 280 |
+
self.end_token_id = self.token_to_id.get('[END]', 3)
|
| 281 |
+
self.branch_open_id = self.token_to_id.get('[BRANCH_OPEN]', 5)
|
| 282 |
+
self.branch_close_id = self.token_to_id.get('[BRANCH_CLOSE]', 6)
|
| 283 |
+
self.mod_token_id = self.token_to_id.get('[MOD]', 9)
|
| 284 |
+
self.link_token_id = self.token_to_id.get('[LINK]', 7)
|
| 285 |
+
|
| 286 |
+
self.vocab_size = self.vocab.get('metadata', {}).get('total_vocab_size', len(self.token_to_id))
|
| 287 |
+
|
| 288 |
+
def tokenize(self, wurcs: str, max_length: int = 512) -> Dict:
|
| 289 |
+
"""
|
| 290 |
+
Tokenize a WURCS string.
|
| 291 |
+
|
| 292 |
+
Matches v3.1 pre-training tokenization exactly:
|
| 293 |
+
- Bracket-counting parser for '/' handling
|
| 294 |
+
- Branch token insertion based on topology
|
| 295 |
+
- Whole-token modifications
|
| 296 |
+
|
| 297 |
+
Args:
|
| 298 |
+
wurcs: WURCS string to tokenize
|
| 299 |
+
max_length: Maximum sequence length (truncate if longer)
|
| 300 |
+
|
| 301 |
+
Returns:
|
| 302 |
+
Dict with:
|
| 303 |
+
- tokens: List of token strings
|
| 304 |
+
- token_ids: List of token IDs
|
| 305 |
+
- residue_ids: List of residue IDs for each token (-1=special, -2=linkage)
|
| 306 |
+
- monosaccharide_names: List of monosaccharide names for each residue
|
| 307 |
+
- length: Number of tokens
|
| 308 |
+
- attention_mask: 1 for real tokens, 0 for padding
|
| 309 |
+
- is_branched: Whether the glycan has branches
|
| 310 |
+
"""
|
| 311 |
+
all_tokens = []
|
| 312 |
+
all_token_ids = []
|
| 313 |
+
all_residue_ids = [] # Track which residue each token belongs to
|
| 314 |
+
all_branch_depths = [] # NEW: Branch depth for each token (0=root, 1=child, etc.)
|
| 315 |
+
all_linkage_types = [] # NEW: Linkage type for each token (0-8)
|
| 316 |
+
monosaccharide_names = [] # Names of monosaccharides in order
|
| 317 |
+
is_branched = False
|
| 318 |
+
residue_depths = {} # Will be computed from linkages
|
| 319 |
+
|
| 320 |
+
# Common monosaccharide patterns based on WURCS skeleton
|
| 321 |
+
MONO_PATTERNS = {
|
| 322 |
+
'a2122h': 'Glc', 'a2112h': 'Gal', 'a1221m': 'Fuc', 'a2211m': 'Rha',
|
| 323 |
+
'a212h': 'Xyl', 'a21d2h': 'Man', 'a2112m': 'Ara', 'a2d21h': 'Ido',
|
| 324 |
+
'axxxxh': 'Hex', 'Aad21122h': 'Neu5Ac', 'Aad21122h-2': 'Neu5Gc',
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
# Start token (residue_id = -1 for special tokens, branch_depth = 0)
|
| 328 |
+
all_tokens.append('[START]')
|
| 329 |
+
all_token_ids.append(self.start_token_id)
|
| 330 |
+
all_residue_ids.append(-1)
|
| 331 |
+
all_branch_depths.append(0) # Special tokens at depth 0
|
| 332 |
+
all_linkage_types.append(0) # No linkage for special tokens
|
| 333 |
+
|
| 334 |
+
try:
|
| 335 |
+
# Extract residues and linkages
|
| 336 |
+
residues = extract_residues_from_wurcs(wurcs)
|
| 337 |
+
linkages = extract_linkages_from_wurcs(wurcs)
|
| 338 |
+
|
| 339 |
+
# Detect branching and compute residue depths
|
| 340 |
+
branch_points, outgoing = detect_branch_points(linkages)
|
| 341 |
+
is_branched = len(branch_points) > 0
|
| 342 |
+
|
| 343 |
+
# NEW: Compute depth of each residue in the tree
|
| 344 |
+
residue_depths = compute_residue_depths(linkages)
|
| 345 |
+
|
| 346 |
+
# Get residue order from topology
|
| 347 |
+
sections = parse_wurcs_sections(wurcs)
|
| 348 |
+
topology = sections[3] if len(sections) > 3 else ""
|
| 349 |
+
residue_order = [int(x) for x in topology.split('-') if x.isdigit()]
|
| 350 |
+
|
| 351 |
+
# Tokenize each residue in order
|
| 352 |
+
residue_letters = 'abcdefghijklmnopqrstuvwxyz'
|
| 353 |
+
current_residue_id = 0
|
| 354 |
+
|
| 355 |
+
for i, res_idx in enumerate(residue_order):
|
| 356 |
+
if res_idx < 1 or res_idx > len(residues):
|
| 357 |
+
continue
|
| 358 |
+
|
| 359 |
+
residue = residues[res_idx - 1]
|
| 360 |
+
res_letter = residue_letters[i] if i < len(residue_letters) else 'z'
|
| 361 |
+
|
| 362 |
+
# Determine monosaccharide name from skeleton
|
| 363 |
+
mono_name = '<UNK>'
|
| 364 |
+
parts = residue.split('_')
|
| 365 |
+
if parts:
|
| 366 |
+
skeleton = parts[0].split('-')[0] if '-' in parts[0] else parts[0]
|
| 367 |
+
# Check for modifications (GlcNAc, GalNAc, etc.)
|
| 368 |
+
has_nac = any('NCC/3=O' in p or 'NAc' in p for p in parts)
|
| 369 |
+
has_s = any('OSO' in p for p in parts)
|
| 370 |
+
|
| 371 |
+
for pattern, name in MONO_PATTERNS.items():
|
| 372 |
+
if skeleton.startswith(pattern) or skeleton == pattern:
|
| 373 |
+
mono_name = name
|
| 374 |
+
break
|
| 375 |
+
|
| 376 |
+
# Adjust for modifications
|
| 377 |
+
if has_nac and mono_name in ['Glc', 'Gal']:
|
| 378 |
+
mono_name = mono_name + 'NAc'
|
| 379 |
+
elif has_s:
|
| 380 |
+
mono_name = mono_name + 'S'
|
| 381 |
+
|
| 382 |
+
monosaccharide_names.append(mono_name)
|
| 383 |
+
|
| 384 |
+
# Check if this is a branch point - add [BRANCH_OPEN]
|
| 385 |
+
# Get depth for this residue
|
| 386 |
+
res_depth = residue_depths.get(res_letter, 0)
|
| 387 |
+
|
| 388 |
+
if res_letter in branch_points:
|
| 389 |
+
all_tokens.append('[BRANCH_OPEN]')
|
| 390 |
+
all_token_ids.append(self.branch_open_id)
|
| 391 |
+
all_residue_ids.append(-1) # Branch tokens are special
|
| 392 |
+
all_branch_depths.append(res_depth)
|
| 393 |
+
all_linkage_types.append(0)
|
| 394 |
+
|
| 395 |
+
# Parse residue: skeleton-anomer_ring_mods
|
| 396 |
+
# Part 0: Skeleton and anomer (e.g., "a2122h-1b")
|
| 397 |
+
if parts:
|
| 398 |
+
main_part = parts[0]
|
| 399 |
+
for char in main_part:
|
| 400 |
+
if char in self.token_to_id:
|
| 401 |
+
all_tokens.append(char)
|
| 402 |
+
all_token_ids.append(self.token_to_id[char])
|
| 403 |
+
all_residue_ids.append(current_residue_id)
|
| 404 |
+
all_branch_depths.append(res_depth)
|
| 405 |
+
all_linkage_types.append(0) # Residue tokens have no linkage type
|
| 406 |
+
elif char == '-':
|
| 407 |
+
if '-' in self.token_to_id:
|
| 408 |
+
all_tokens.append('-')
|
| 409 |
+
all_token_ids.append(self.token_to_id['-'])
|
| 410 |
+
all_residue_ids.append(current_residue_id)
|
| 411 |
+
all_branch_depths.append(res_depth)
|
| 412 |
+
all_linkage_types.append(0)
|
| 413 |
+
else:
|
| 414 |
+
all_tokens.append('[UNK]')
|
| 415 |
+
all_token_ids.append(self.unk_token_id)
|
| 416 |
+
all_residue_ids.append(current_residue_id)
|
| 417 |
+
all_branch_depths.append(res_depth)
|
| 418 |
+
all_linkage_types.append(0)
|
| 419 |
+
|
| 420 |
+
# Parts 2+: Modifications (skip part 1 which is ring closure)
|
| 421 |
+
if len(parts) > 2:
|
| 422 |
+
for part in parts[2:]:
|
| 423 |
+
if '*' in part:
|
| 424 |
+
all_tokens.append('[MOD]')
|
| 425 |
+
all_token_ids.append(self.mod_token_id)
|
| 426 |
+
all_residue_ids.append(current_residue_id)
|
| 427 |
+
all_branch_depths.append(res_depth)
|
| 428 |
+
all_linkage_types.append(0)
|
| 429 |
+
|
| 430 |
+
if part in self.token_to_id:
|
| 431 |
+
all_tokens.append(part)
|
| 432 |
+
all_token_ids.append(self.token_to_id[part])
|
| 433 |
+
all_residue_ids.append(current_residue_id)
|
| 434 |
+
all_branch_depths.append(res_depth)
|
| 435 |
+
all_linkage_types.append(0)
|
| 436 |
+
else:
|
| 437 |
+
all_tokens.append('[UNK_MOD]')
|
| 438 |
+
all_token_ids.append(self.token_to_id.get('[UNK_MOD]', self.unk_token_id))
|
| 439 |
+
all_residue_ids.append(current_residue_id)
|
| 440 |
+
all_branch_depths.append(res_depth)
|
| 441 |
+
all_linkage_types.append(0)
|
| 442 |
+
|
| 443 |
+
current_residue_id += 1
|
| 444 |
+
|
| 445 |
+
# Add branch close tokens at the end
|
| 446 |
+
branch_opens = all_tokens.count('[BRANCH_OPEN]')
|
| 447 |
+
for _ in range(branch_opens):
|
| 448 |
+
all_tokens.append('[BRANCH_CLOSE]')
|
| 449 |
+
all_token_ids.append(self.branch_close_id)
|
| 450 |
+
all_residue_ids.append(-1)
|
| 451 |
+
all_branch_depths.append(0) # BRANCH_CLOSE at depth 0 (closing structure)
|
| 452 |
+
all_linkage_types.append(0)
|
| 453 |
+
|
| 454 |
+
# Tokenize linkages (residue_id = -2 for linkage tokens)
|
| 455 |
+
for linkage in linkages:
|
| 456 |
+
if not linkage:
|
| 457 |
+
continue
|
| 458 |
+
# Parse linkage type for this linkage
|
| 459 |
+
link_type = parse_linkage_type(linkage)
|
| 460 |
+
|
| 461 |
+
all_tokens.append('[LINK]')
|
| 462 |
+
all_token_ids.append(self.link_token_id)
|
| 463 |
+
all_residue_ids.append(-2)
|
| 464 |
+
all_branch_depths.append(0) # Linkages at depth 0 (structural)
|
| 465 |
+
all_linkage_types.append(link_type) # Actual linkage type here!
|
| 466 |
+
|
| 467 |
+
for char in linkage:
|
| 468 |
+
if char in self.token_to_id:
|
| 469 |
+
all_tokens.append(char)
|
| 470 |
+
all_token_ids.append(self.token_to_id[char])
|
| 471 |
+
all_residue_ids.append(-2)
|
| 472 |
+
all_branch_depths.append(0)
|
| 473 |
+
all_linkage_types.append(link_type)
|
| 474 |
+
else:
|
| 475 |
+
all_tokens.append('[UNK]')
|
| 476 |
+
all_token_ids.append(self.unk_token_id)
|
| 477 |
+
all_residue_ids.append(-2)
|
| 478 |
+
all_branch_depths.append(0)
|
| 479 |
+
all_linkage_types.append(link_type)
|
| 480 |
+
|
| 481 |
+
except Exception:
|
| 482 |
+
pass
|
| 483 |
+
|
| 484 |
+
# End token
|
| 485 |
+
all_tokens.append('[END]')
|
| 486 |
+
all_token_ids.append(self.end_token_id)
|
| 487 |
+
all_residue_ids.append(-1)
|
| 488 |
+
all_branch_depths.append(0)
|
| 489 |
+
all_linkage_types.append(0)
|
| 490 |
+
|
| 491 |
+
# Truncate if necessary
|
| 492 |
+
if len(all_token_ids) > max_length:
|
| 493 |
+
all_tokens = all_tokens[:max_length-1] + ['[END]']
|
| 494 |
+
all_token_ids = all_token_ids[:max_length-1] + [self.end_token_id]
|
| 495 |
+
all_residue_ids = all_residue_ids[:max_length-1] + [-1]
|
| 496 |
+
all_branch_depths = all_branch_depths[:max_length-1] + [0]
|
| 497 |
+
all_linkage_types = all_linkage_types[:max_length-1] + [0]
|
| 498 |
+
|
| 499 |
+
length = len(all_token_ids)
|
| 500 |
+
attention_mask = [1] * length
|
| 501 |
+
|
| 502 |
+
# Pad to max_length
|
| 503 |
+
padding_length = max_length - length
|
| 504 |
+
if padding_length > 0:
|
| 505 |
+
all_tokens = all_tokens + ['[PAD]'] * padding_length
|
| 506 |
+
all_token_ids = all_token_ids + [self.pad_token_id] * padding_length
|
| 507 |
+
all_residue_ids = all_residue_ids + [-1] * padding_length
|
| 508 |
+
all_branch_depths = all_branch_depths + [0] * padding_length
|
| 509 |
+
all_linkage_types = all_linkage_types + [0] * padding_length
|
| 510 |
+
attention_mask = attention_mask + [0] * padding_length
|
| 511 |
+
|
| 512 |
+
return {
|
| 513 |
+
'tokens': all_tokens,
|
| 514 |
+
'token_ids': all_token_ids,
|
| 515 |
+
'residue_ids': all_residue_ids,
|
| 516 |
+
'branch_depths': all_branch_depths, # NEW: Branch depth for each token
|
| 517 |
+
'linkage_types': all_linkage_types, # NEW: Linkage type for each token
|
| 518 |
+
'monosaccharide_names': monosaccharide_names,
|
| 519 |
+
'num_residues': len(monosaccharide_names),
|
| 520 |
+
'length': length,
|
| 521 |
+
'attention_mask': attention_mask,
|
| 522 |
+
'is_branched': is_branched,
|
| 523 |
+
'has_unk_mod': '[UNK_MOD]' in all_tokens[:length]
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
def batch_tokenize(self, wurcs_list: List[str], max_length: int = 512) -> Dict:
|
| 527 |
+
"""
|
| 528 |
+
Tokenize a batch of WURCS strings.
|
| 529 |
+
|
| 530 |
+
Args:
|
| 531 |
+
wurcs_list: List of WURCS strings
|
| 532 |
+
max_length: Maximum sequence length
|
| 533 |
+
|
| 534 |
+
Returns:
|
| 535 |
+
Dict with batched tensors (as lists)
|
| 536 |
+
"""
|
| 537 |
+
results = [self.tokenize(w, max_length) for w in wurcs_list]
|
| 538 |
+
|
| 539 |
+
return {
|
| 540 |
+
'token_ids': [r['token_ids'] for r in results],
|
| 541 |
+
'attention_mask': [r['attention_mask'] for r in results],
|
| 542 |
+
'lengths': [r['length'] for r in results]
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
def decode(self, token_ids: List[int]) -> str:
|
| 546 |
+
"""
|
| 547 |
+
Decode token IDs back to tokens (for debugging).
|
| 548 |
+
|
| 549 |
+
Args:
|
| 550 |
+
token_ids: List of token IDs
|
| 551 |
+
|
| 552 |
+
Returns:
|
| 553 |
+
Space-separated token string
|
| 554 |
+
"""
|
| 555 |
+
tokens = [self.id_to_token.get(tid, '[UNK]') for tid in token_ids]
|
| 556 |
+
# Remove padding
|
| 557 |
+
tokens = [t for t in tokens if t != '[PAD]']
|
| 558 |
+
return ' '.join(tokens)
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
def create_tokenizer(vocab_path: str = None) -> WURCSTokenizer:
|
| 562 |
+
"""
|
| 563 |
+
Create a tokenizer with default vocabulary path.
|
| 564 |
+
|
| 565 |
+
Args:
|
| 566 |
+
vocab_path: Optional path to vocabulary.json
|
| 567 |
+
|
| 568 |
+
Returns:
|
| 569 |
+
WURCSTokenizer instance
|
| 570 |
+
"""
|
| 571 |
+
if vocab_path is None:
|
| 572 |
+
# Try default locations
|
| 573 |
+
default_paths = [
|
| 574 |
+
Path(__file__).parent.parent.parent / 'data' / 'vocabulary.json',
|
| 575 |
+
Path(__file__).parent.parent / 'data' / 'vocabulary.json',
|
| 576 |
+
]
|
| 577 |
+
for p in default_paths:
|
| 578 |
+
if p.exists():
|
| 579 |
+
vocab_path = str(p)
|
| 580 |
+
break
|
| 581 |
+
else:
|
| 582 |
+
raise FileNotFoundError("vocabulary.json not found. Please specify path.")
|
| 583 |
+
|
| 584 |
+
return WURCSTokenizer(vocab_path)
|
| 585 |
+
|
| 586 |
+
|
| 587 |
+
if __name__ == '__main__':
|
| 588 |
+
# Test tokenizer
|
| 589 |
+
import sys
|
| 590 |
+
|
| 591 |
+
# Find vocab
|
| 592 |
+
vocab_path = Path(__file__).parent.parent.parent / 'data' / 'vocabulary.json'
|
| 593 |
+
if not vocab_path.exists():
|
| 594 |
+
print(f"Vocabulary not found at {vocab_path}")
|
| 595 |
+
sys.exit(1)
|
| 596 |
+
|
| 597 |
+
tokenizer = WURCSTokenizer(str(vocab_path))
|
| 598 |
+
print(f"Loaded tokenizer with {tokenizer.vocab_size} tokens")
|
| 599 |
+
|
| 600 |
+
# Test cases
|
| 601 |
+
test_cases = [
|
| 602 |
+
"WURCS=2.0/2,2,1/[a212h-1b_1-5][a2211m-1a_1-5]/1-2/a2-b1",
|
| 603 |
+
"WURCS=2.0/3,3,2/[a2122h-1b_1-5_2*NCC/3=O][a2112h-1a_1-5][a2211m-1a_1-5]/1-2-3/a4-b1_b3-c1",
|
| 604 |
+
]
|
| 605 |
+
|
| 606 |
+
for wurcs in test_cases:
|
| 607 |
+
result = tokenizer.tokenize(wurcs, max_length=64)
|
| 608 |
+
print(f"\nWURCS: {wurcs[:50]}...")
|
| 609 |
+
print(f" Length: {result['length']}")
|
| 610 |
+
print(f" Branched: {result['is_branched']}")
|
| 611 |
+
print(f" Tokens: {result['tokens'][:15]}...")
|
| 612 |
+
print(f" Token IDs: {result['token_ids'][:15]}...")
|
code/downstream_tasks/utils/wurcs_bpe_tokenizer.py
ADDED
|
@@ -0,0 +1,740 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
WURCS-BPE Tokenizer
|
| 4 |
+
|
| 5 |
+
A hybrid tokenizer that learns semantic subwords from WURCS while preserving
|
| 6 |
+
the ability to handle rare/novel glycan structures character-by-character.
|
| 7 |
+
|
| 8 |
+
Key features:
|
| 9 |
+
1. Pre-tokenization: Split WURCS into semantic units (residues, linkages, mods)
|
| 10 |
+
2. BPE: Learn subword merges from corpus
|
| 11 |
+
3. Character fallback: Handle novel structures
|
| 12 |
+
4. Tree embeddings: Preserve branch_depth and linkage_type per token
|
| 13 |
+
|
| 14 |
+
Usage:
|
| 15 |
+
# Train BPE on corpus
|
| 16 |
+
tokenizer = WURCSBPETokenizer.train_from_corpus(
|
| 17 |
+
wurcs_strings,
|
| 18 |
+
num_merges=500,
|
| 19 |
+
output_path="bpe_vocabulary.json"
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
# Tokenize
|
| 23 |
+
result = tokenizer.tokenize(wurcs_string)
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
import json
|
| 27 |
+
import re
|
| 28 |
+
from collections import Counter, defaultdict
|
| 29 |
+
from pathlib import Path
|
| 30 |
+
from typing import Dict, List, Optional, Tuple, Set
|
| 31 |
+
import pickle
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class WURCSPreTokenizer:
|
| 35 |
+
"""
|
| 36 |
+
Pre-tokenize WURCS into semantic units before BPE.
|
| 37 |
+
|
| 38 |
+
WURCS format: WURCS=2.0/count/[residues]/indices/linkages
|
| 39 |
+
|
| 40 |
+
We split into:
|
| 41 |
+
- Residues: [a2122h-1b_1-5_2*NCC/3=O] -> one unit per []
|
| 42 |
+
- Linkages: a4-b1 -> one unit per linkage
|
| 43 |
+
- Special markers: [BRANCH_OPEN], [BRANCH_CLOSE], etc.
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
# Residue patterns for common monosaccharides
|
| 47 |
+
RESIDUE_PATTERN = re.compile(r'\[([^\]]+)\]')
|
| 48 |
+
LINKAGE_PATTERN = re.compile(r'([a-z])(\d+|\?)-([a-z])(\d+|\?)')
|
| 49 |
+
|
| 50 |
+
def __init__(self):
|
| 51 |
+
self.special_tokens = {
|
| 52 |
+
'[PAD]': 0,
|
| 53 |
+
'[UNK]': 1,
|
| 54 |
+
'[START]': 2,
|
| 55 |
+
'[END]': 3,
|
| 56 |
+
'[MASK]': 4,
|
| 57 |
+
'[BRANCH_OPEN]': 5,
|
| 58 |
+
'[BRANCH_CLOSE]': 6,
|
| 59 |
+
'[LINK]': 7,
|
| 60 |
+
'[MOD]': 8,
|
| 61 |
+
'[RESIDUE_ERROR]': 9,
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
def pre_tokenize(self, wurcs: str) -> List[Dict]:
|
| 65 |
+
"""
|
| 66 |
+
Pre-tokenize WURCS into semantic units.
|
| 67 |
+
|
| 68 |
+
Returns list of dicts with:
|
| 69 |
+
- text: The unit text
|
| 70 |
+
- type: 'special', 'residue', 'linkage', 'mod', 'index'
|
| 71 |
+
- residue_id: Which residue this belongs to (-1 for special, -2 for linkage)
|
| 72 |
+
- branch_depth: Tree depth (computed later)
|
| 73 |
+
"""
|
| 74 |
+
units = []
|
| 75 |
+
|
| 76 |
+
# Add start token
|
| 77 |
+
units.append({
|
| 78 |
+
'text': '[START]',
|
| 79 |
+
'type': 'special',
|
| 80 |
+
'residue_id': -1,
|
| 81 |
+
'branch_depth': 0,
|
| 82 |
+
'linkage_type': 0,
|
| 83 |
+
})
|
| 84 |
+
|
| 85 |
+
# Parse WURCS sections
|
| 86 |
+
if not wurcs.startswith('WURCS='):
|
| 87 |
+
units.append({'text': '[RESIDUE_ERROR]', 'type': 'special', 'residue_id': -1, 'branch_depth': 0, 'linkage_type': 0})
|
| 88 |
+
units.append({'text': '[END]', 'type': 'special', 'residue_id': -1, 'branch_depth': 0, 'linkage_type': 0})
|
| 89 |
+
return units
|
| 90 |
+
|
| 91 |
+
try:
|
| 92 |
+
parts = self._split_wurcs_sections(wurcs)
|
| 93 |
+
if len(parts) < 4:
|
| 94 |
+
return [{'text': '[ERROR]', 'type': 'special', 'residue_id': -1, 'branch_depth': 0, 'linkage_type': 0}]
|
| 95 |
+
|
| 96 |
+
# parts: WURCS=2.0/3,3,2/[a2122h-1b_1-5][a2122h-1a_1-5][a1122h-1b_1-5]/1-2-3-1/a4-b1_b3-c1_c4-d1
|
| 97 |
+
# section 2: residue definitions
|
| 98 |
+
# section 3: indices
|
| 99 |
+
# section 4: linkages (optional)
|
| 100 |
+
|
| 101 |
+
version = parts[0] # WURCS=2.0
|
| 102 |
+
counts = parts[1] # residue_count,node_count,link_count
|
| 103 |
+
residue_defs = parts[2] # [res1][res2]...
|
| 104 |
+
indices = parts[3] # 1-2-3-1
|
| 105 |
+
linkages = parts[4] if len(parts) > 4 else "" # a4-b1_b3-c1
|
| 106 |
+
|
| 107 |
+
# Parse residue definitions
|
| 108 |
+
residue_list = self.RESIDUE_PATTERN.findall(residue_defs)
|
| 109 |
+
|
| 110 |
+
# Parse linkages to compute branch structure
|
| 111 |
+
linkage_list = linkages.split('_') if linkages else []
|
| 112 |
+
branch_points, residue_depths, linkage_types_map, adj = self._analyze_tree_structure(linkage_list, num_residues=len(residue_list))
|
| 113 |
+
|
| 114 |
+
# Compute distance matrix and cache it based on the linkage string (structure)
|
| 115 |
+
# This is the most expensive part, so we cache it
|
| 116 |
+
if not hasattr(self, '_dist_cache'): self._dist_cache = {}
|
| 117 |
+
if linkages not in self._dist_cache:
|
| 118 |
+
self._dist_cache[linkages] = self._compute_distance_matrix(adj, len(residue_list))
|
| 119 |
+
dist_matrix_raw = self._dist_cache[linkages]
|
| 120 |
+
|
| 121 |
+
# Parse indices to map positions to residue definitions
|
| 122 |
+
index_list = indices.split('-') if indices else []
|
| 123 |
+
|
| 124 |
+
# Process each residue instance
|
| 125 |
+
residue_letter = ord('a')
|
| 126 |
+
for idx, res_idx in enumerate(index_list):
|
| 127 |
+
current_residue_id = idx
|
| 128 |
+
res_letter = chr(residue_letter + idx)
|
| 129 |
+
|
| 130 |
+
# Check if this is a branch point - add branch marker before
|
| 131 |
+
if res_letter in branch_points and branch_points[res_letter] > 0:
|
| 132 |
+
for _ in range(branch_points[res_letter]):
|
| 133 |
+
units.append({
|
| 134 |
+
'text': '[BRANCH_OPEN]',
|
| 135 |
+
'type': 'special',
|
| 136 |
+
'residue_id': -1,
|
| 137 |
+
'branch_depth': residue_depths.get(res_letter, 0),
|
| 138 |
+
'linkage_type': 0,
|
| 139 |
+
})
|
| 140 |
+
|
| 141 |
+
# Get residue definition
|
| 142 |
+
try:
|
| 143 |
+
res_def_idx = int(res_idx) - 1 # 1-indexed to 0-indexed
|
| 144 |
+
res_def = residue_list[res_def_idx] if res_def_idx < len(residue_list) else ""
|
| 145 |
+
except (ValueError, IndexError):
|
| 146 |
+
res_def = ""
|
| 147 |
+
|
| 148 |
+
# Split residue into base and modifications
|
| 149 |
+
res_parts = res_def.split('_')
|
| 150 |
+
base = res_parts[0] if res_parts else res_def
|
| 151 |
+
mods = res_parts[1:] if len(res_parts) > 1 else []
|
| 152 |
+
|
| 153 |
+
# Add residue base as a single unit
|
| 154 |
+
depth = residue_depths.get(res_letter, 0)
|
| 155 |
+
units.append({
|
| 156 |
+
'text': base,
|
| 157 |
+
'type': 'residue',
|
| 158 |
+
'residue_id': current_residue_id,
|
| 159 |
+
'branch_depth': depth,
|
| 160 |
+
'linkage_type': 0,
|
| 161 |
+
})
|
| 162 |
+
|
| 163 |
+
# Add modifications
|
| 164 |
+
for mod in mods:
|
| 165 |
+
units.append({
|
| 166 |
+
'text': mod,
|
| 167 |
+
'type': 'mod',
|
| 168 |
+
'residue_id': current_residue_id,
|
| 169 |
+
'branch_depth': depth,
|
| 170 |
+
'linkage_type': 0,
|
| 171 |
+
})
|
| 172 |
+
|
| 173 |
+
# Store distance matrix in units for easy access in tokenizer
|
| 174 |
+
if units:
|
| 175 |
+
# Find first residue unit or just use START
|
| 176 |
+
units[0]['distance_matrix'] = dist_matrix_raw
|
| 177 |
+
|
| 178 |
+
# Add linkages
|
| 179 |
+
for link in linkage_list:
|
| 180 |
+
if not link:
|
| 181 |
+
continue
|
| 182 |
+
# Parse linkage type
|
| 183 |
+
lt = self._parse_linkage_type(link)
|
| 184 |
+
units.append({
|
| 185 |
+
'text': link,
|
| 186 |
+
'type': 'linkage',
|
| 187 |
+
'residue_id': -2,
|
| 188 |
+
'branch_depth': 0,
|
| 189 |
+
'linkage_type': lt,
|
| 190 |
+
})
|
| 191 |
+
except Exception:
|
| 192 |
+
# Fallback for truly broken WURCS
|
| 193 |
+
pass
|
| 194 |
+
|
| 195 |
+
# Add end token
|
| 196 |
+
units.append({
|
| 197 |
+
'text': '[END]',
|
| 198 |
+
'type': 'special',
|
| 199 |
+
'residue_id': -1,
|
| 200 |
+
'branch_depth': 0,
|
| 201 |
+
'linkage_type': 0,
|
| 202 |
+
})
|
| 203 |
+
|
| 204 |
+
return units
|
| 205 |
+
|
| 206 |
+
def _split_wurcs_sections(self, wurcs: str) -> List[str]:
|
| 207 |
+
"""Split WURCS string into sections, handling nested brackets."""
|
| 208 |
+
# Remove WURCS= prefix
|
| 209 |
+
if wurcs.startswith('WURCS='):
|
| 210 |
+
wurcs = wurcs[6:]
|
| 211 |
+
|
| 212 |
+
sections = []
|
| 213 |
+
current = ""
|
| 214 |
+
bracket_depth = 0
|
| 215 |
+
|
| 216 |
+
for char in wurcs:
|
| 217 |
+
if char == '[':
|
| 218 |
+
bracket_depth += 1
|
| 219 |
+
current += char
|
| 220 |
+
elif char == ']':
|
| 221 |
+
bracket_depth -= 1
|
| 222 |
+
current += char
|
| 223 |
+
elif char == '/' and bracket_depth == 0:
|
| 224 |
+
sections.append(current)
|
| 225 |
+
current = ""
|
| 226 |
+
else:
|
| 227 |
+
current += char
|
| 228 |
+
|
| 229 |
+
if current:
|
| 230 |
+
sections.append(current)
|
| 231 |
+
|
| 232 |
+
return sections
|
| 233 |
+
|
| 234 |
+
def _analyze_tree_structure(self, linkages: List[str], num_residues: int) -> Tuple[Dict, Dict, Dict, Dict]:
|
| 235 |
+
"""Analyze linkages to compute branch points and residue depths."""
|
| 236 |
+
branch_points = defaultdict(int) # residue -> number of children
|
| 237 |
+
children = defaultdict(list)
|
| 238 |
+
all_residues = set()
|
| 239 |
+
linkage_types = {}
|
| 240 |
+
|
| 241 |
+
for link in linkages:
|
| 242 |
+
match = self.LINKAGE_PATTERN.match(link)
|
| 243 |
+
if match:
|
| 244 |
+
from_res, from_pos, to_res, to_pos = match.groups()
|
| 245 |
+
children[from_res].append(to_res)
|
| 246 |
+
all_residues.add(from_res)
|
| 247 |
+
all_residues.add(to_res)
|
| 248 |
+
|
| 249 |
+
# Store linkage type
|
| 250 |
+
linkage_types[link] = self._parse_linkage_type(link)
|
| 251 |
+
|
| 252 |
+
# Build adjacency list for BFS
|
| 253 |
+
adj = defaultdict(list)
|
| 254 |
+
for link in linkages:
|
| 255 |
+
match = self.LINKAGE_PATTERN.match(link)
|
| 256 |
+
if match:
|
| 257 |
+
u = ord(match.group(1)) - ord('a')
|
| 258 |
+
v = ord(match.group(3)) - ord('a')
|
| 259 |
+
if 0 <= u < num_residues and 0 <= v < num_residues:
|
| 260 |
+
adj[u].append(v)
|
| 261 |
+
adj[v].append(u)
|
| 262 |
+
|
| 263 |
+
# Find branch points (residues with >1 child)
|
| 264 |
+
for res, kids in children.items():
|
| 265 |
+
if len(kids) > 1:
|
| 266 |
+
branch_points[res] = len(kids) - 1 # Number of extra branches
|
| 267 |
+
|
| 268 |
+
# Compute depths using BFS
|
| 269 |
+
# Find root (residue with no parent)
|
| 270 |
+
child_set = set()
|
| 271 |
+
for kids in children.values():
|
| 272 |
+
child_set.update(kids)
|
| 273 |
+
roots = all_residues - child_set
|
| 274 |
+
root = min(roots) if roots else 'a'
|
| 275 |
+
|
| 276 |
+
depths = {root: 0}
|
| 277 |
+
queue = [root]
|
| 278 |
+
while queue:
|
| 279 |
+
current = queue.pop(0)
|
| 280 |
+
for child in children.get(current, []):
|
| 281 |
+
if child not in depths:
|
| 282 |
+
depths[child] = depths[current] + 1
|
| 283 |
+
queue.append(child)
|
| 284 |
+
|
| 285 |
+
return branch_points, depths, linkage_types, adj
|
| 286 |
+
|
| 287 |
+
def _compute_distance_matrix(self, adj: Dict[int, List[int]], num_residues: int) -> List[List[int]]:
|
| 288 |
+
"""
|
| 289 |
+
Compute shortest path distance (number of bonds) between all residue pairs using BFS.
|
| 290 |
+
"""
|
| 291 |
+
if num_residues == 0:
|
| 292 |
+
return []
|
| 293 |
+
|
| 294 |
+
dist_matrix = [[-1] * num_residues for _ in range(num_residues)]
|
| 295 |
+
|
| 296 |
+
for i in range(num_residues):
|
| 297 |
+
dist_matrix[i][i] = 0
|
| 298 |
+
queue = [(i, 0)]
|
| 299 |
+
visited = {i}
|
| 300 |
+
|
| 301 |
+
while queue:
|
| 302 |
+
curr, d = queue.pop(0)
|
| 303 |
+
dist_matrix[i][curr] = d
|
| 304 |
+
|
| 305 |
+
for neighbor in adj[curr]:
|
| 306 |
+
if neighbor not in visited:
|
| 307 |
+
visited.add(neighbor)
|
| 308 |
+
queue.append((neighbor, d + 1))
|
| 309 |
+
|
| 310 |
+
return dist_matrix
|
| 311 |
+
|
| 312 |
+
def _compute_distance_matrix_OLD(self, linkages: List[str], num_residues: int) -> List[List[int]]:
|
| 313 |
+
"""
|
| 314 |
+
Compute shortest path distance (number of bonds) between all residue pairs.
|
| 315 |
+
Returns a symmetric N x N matrix where N is num_residues.
|
| 316 |
+
Values are integers (number of steps). 0 on diagonal. -1 if unreachable (shouldn't happen in single tree).
|
| 317 |
+
"""
|
| 318 |
+
if num_residues == 0:
|
| 319 |
+
return []
|
| 320 |
+
|
| 321 |
+
# Initialize adjacency list
|
| 322 |
+
adj = defaultdict(list)
|
| 323 |
+
for link in linkages:
|
| 324 |
+
match = self.LINKAGE_PATTERN.match(link)
|
| 325 |
+
if match:
|
| 326 |
+
# WURCS indices are 1-based letters (a=1, b=2...)
|
| 327 |
+
from_res_char, _, to_res_char, _ = match.groups()
|
| 328 |
+
# Convert char to 0-based index
|
| 329 |
+
u = ord(from_res_char) - ord('a')
|
| 330 |
+
v = ord(to_res_char) - ord('a')
|
| 331 |
+
|
| 332 |
+
# Undirected graph for structural distance
|
| 333 |
+
if 0 <= u < num_residues and 0 <= v < num_residues:
|
| 334 |
+
adj[u].append(v)
|
| 335 |
+
adj[v].append(u)
|
| 336 |
+
|
| 337 |
+
# Compute All-Pairs Shortest Path (BFS from each node is fine for small N)
|
| 338 |
+
# Glycans are small (N ~ 5-20 usually), so O(N^2) BFS is cheap.
|
| 339 |
+
dist_matrix = [[-1] * num_residues for _ in range(num_residues)]
|
| 340 |
+
|
| 341 |
+
for i in range(num_residues):
|
| 342 |
+
dist_matrix[i][i] = 0
|
| 343 |
+
queue = [(i, 0)]
|
| 344 |
+
visited = {i}
|
| 345 |
+
|
| 346 |
+
while queue:
|
| 347 |
+
curr, d = queue.pop(0)
|
| 348 |
+
dist_matrix[i][curr] = d
|
| 349 |
+
|
| 350 |
+
for neighbor in adj[curr]:
|
| 351 |
+
if neighbor not in visited:
|
| 352 |
+
visited.add(neighbor)
|
| 353 |
+
queue.append((neighbor, d + 1))
|
| 354 |
+
|
| 355 |
+
return dist_matrix
|
| 356 |
+
|
| 357 |
+
def _parse_linkage_type(self, link: str) -> int:
|
| 358 |
+
"""Parse linkage string to get type ID."""
|
| 359 |
+
LINKAGE_TYPES = {
|
| 360 |
+
(1, 2): 0, (2, 1): 0,
|
| 361 |
+
(1, 3): 1, (3, 1): 1,
|
| 362 |
+
(1, 4): 2, (4, 1): 2,
|
| 363 |
+
(1, 6): 3, (6, 1): 3,
|
| 364 |
+
(2, 3): 4, (3, 2): 4,
|
| 365 |
+
(2, 6): 5, (6, 2): 5,
|
| 366 |
+
(3, 6): 6, (6, 3): 6,
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
match = self.LINKAGE_PATTERN.match(link)
|
| 370 |
+
if match:
|
| 371 |
+
_, from_pos, _, to_pos = match.groups()
|
| 372 |
+
try:
|
| 373 |
+
pos_tuple = (int(from_pos), int(to_pos))
|
| 374 |
+
return LINKAGE_TYPES.get(pos_tuple, 7)
|
| 375 |
+
except ValueError:
|
| 376 |
+
return 8 # Unknown
|
| 377 |
+
return 8
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
class WURCSBPETokenizer:
|
| 381 |
+
"""
|
| 382 |
+
BPE tokenizer for WURCS with tree-aware embeddings.
|
| 383 |
+
"""
|
| 384 |
+
|
| 385 |
+
def __init__(self, vocab_path: Optional[str] = None):
|
| 386 |
+
self.pre_tokenizer = WURCSPreTokenizer()
|
| 387 |
+
|
| 388 |
+
# Special tokens (fixed)
|
| 389 |
+
self.special_tokens = self.pre_tokenizer.special_tokens
|
| 390 |
+
|
| 391 |
+
# BPE vocabulary
|
| 392 |
+
self.token_to_id: Dict[str, int] = {}
|
| 393 |
+
self.id_to_token: Dict[int, str] = {}
|
| 394 |
+
self.merges: List[Tuple[str, str]] = []
|
| 395 |
+
|
| 396 |
+
if vocab_path:
|
| 397 |
+
self.load_vocab(vocab_path)
|
| 398 |
+
else:
|
| 399 |
+
# Initialize with special tokens only
|
| 400 |
+
self.token_to_id = dict(self.special_tokens)
|
| 401 |
+
self.id_to_token = {v: k for k, v in self.token_to_id.items()}
|
| 402 |
+
|
| 403 |
+
@classmethod
|
| 404 |
+
def train_from_corpus(
|
| 405 |
+
cls,
|
| 406 |
+
wurcs_strings: List[str],
|
| 407 |
+
num_merges: int = 500,
|
| 408 |
+
output_path: Optional[str] = None,
|
| 409 |
+
min_frequency: int = 2,
|
| 410 |
+
max_token_length: Optional[int] = None,
|
| 411 |
+
) -> 'WURCSBPETokenizer':
|
| 412 |
+
"""
|
| 413 |
+
Train BPE on a corpus of WURCS strings.
|
| 414 |
+
|
| 415 |
+
Args:
|
| 416 |
+
wurcs_strings: List of WURCS strings
|
| 417 |
+
num_merges: Number of BPE merge operations
|
| 418 |
+
output_path: Optional path to save vocabulary
|
| 419 |
+
min_frequency: Minimum frequency for a token to be kept
|
| 420 |
+
max_token_length: Maximum length of a merged token (None = no limit)
|
| 421 |
+
|
| 422 |
+
Returns:
|
| 423 |
+
Trained tokenizer
|
| 424 |
+
"""
|
| 425 |
+
tokenizer = cls()
|
| 426 |
+
pre_tok = WURCSPreTokenizer()
|
| 427 |
+
|
| 428 |
+
print(f"Training BPE on {len(wurcs_strings)} WURCS strings...")
|
| 429 |
+
|
| 430 |
+
# Step 1: Pre-tokenize all strings to get semantic units
|
| 431 |
+
all_units = []
|
| 432 |
+
for wurcs in wurcs_strings:
|
| 433 |
+
units = pre_tok.pre_tokenize(wurcs)
|
| 434 |
+
for unit in units:
|
| 435 |
+
if unit['type'] != 'special':
|
| 436 |
+
all_units.append(unit['text'])
|
| 437 |
+
|
| 438 |
+
# Step 2: Count unit frequencies
|
| 439 |
+
unit_counts = Counter(all_units)
|
| 440 |
+
print(f"Found {len(unit_counts)} unique units")
|
| 441 |
+
|
| 442 |
+
# Step 3: Initialize vocabulary with characters from all units
|
| 443 |
+
char_vocab = set()
|
| 444 |
+
for unit in unit_counts:
|
| 445 |
+
for char in unit:
|
| 446 |
+
char_vocab.add(char)
|
| 447 |
+
|
| 448 |
+
# Build initial vocab: special tokens + characters
|
| 449 |
+
vocab_id = len(tokenizer.special_tokens)
|
| 450 |
+
for char in sorted(char_vocab):
|
| 451 |
+
tokenizer.token_to_id[char] = vocab_id
|
| 452 |
+
tokenizer.id_to_token[vocab_id] = char
|
| 453 |
+
vocab_id += 1
|
| 454 |
+
|
| 455 |
+
print(f"Initial vocab size: {vocab_id} (special + characters)")
|
| 456 |
+
|
| 457 |
+
# Step 4: Convert units to character sequences
|
| 458 |
+
word_freqs = {}
|
| 459 |
+
for unit, count in unit_counts.items():
|
| 460 |
+
if count >= min_frequency:
|
| 461 |
+
# Split into characters with space separator
|
| 462 |
+
chars = tuple(unit)
|
| 463 |
+
word_freqs[chars] = count
|
| 464 |
+
|
| 465 |
+
# Step 5: BPE merging
|
| 466 |
+
merges = []
|
| 467 |
+
|
| 468 |
+
for merge_idx in range(num_merges):
|
| 469 |
+
# Count pairs
|
| 470 |
+
pair_counts = Counter()
|
| 471 |
+
for word, freq in word_freqs.items():
|
| 472 |
+
for i in range(len(word) - 1):
|
| 473 |
+
pair = (word[i], word[i + 1])
|
| 474 |
+
pair_counts[pair] += freq
|
| 475 |
+
|
| 476 |
+
if not pair_counts:
|
| 477 |
+
break
|
| 478 |
+
|
| 479 |
+
# Find most frequent pair
|
| 480 |
+
best_pair = pair_counts.most_common(1)[0][0]
|
| 481 |
+
best_count = pair_counts[best_pair]
|
| 482 |
+
|
| 483 |
+
if best_count < min_frequency:
|
| 484 |
+
break
|
| 485 |
+
|
| 486 |
+
# Merge pair
|
| 487 |
+
new_token = best_pair[0] + best_pair[1]
|
| 488 |
+
|
| 489 |
+
# Check length constraint
|
| 490 |
+
if max_token_length and len(new_token) > max_token_length:
|
| 491 |
+
# remove this pair from consideration for this iteration and future?
|
| 492 |
+
# Actually, skipping it here is tricky because we need to ignore it in pair_counts next time
|
| 493 |
+
# Simpler: Just skip adding it to merges and modify word_freqs?
|
| 494 |
+
# No, if we don't merge, we just continue to the next best pair in THIS iteration.
|
| 495 |
+
# But pair_counts is already computed.
|
| 496 |
+
# We need to loop until we find a valid pair or run out
|
| 497 |
+
|
| 498 |
+
# In this simple implementation, let's just skip this merge efficiently
|
| 499 |
+
# We need to find the NEXT most common pair.
|
| 500 |
+
|
| 501 |
+
# Re-do finding best pair loop
|
| 502 |
+
found_valid_pair = False
|
| 503 |
+
for pair, count in pair_counts.most_common():
|
| 504 |
+
token_candidate = pair[0] + pair[1]
|
| 505 |
+
if max_token_length and len(token_candidate) > max_token_length:
|
| 506 |
+
continue # Skip too long
|
| 507 |
+
|
| 508 |
+
if count < min_frequency:
|
| 509 |
+
break # Stop if frequency too low
|
| 510 |
+
|
| 511 |
+
# Found valid pair
|
| 512 |
+
best_pair = pair
|
| 513 |
+
best_count = count
|
| 514 |
+
new_token = token_candidate
|
| 515 |
+
found_valid_pair = True
|
| 516 |
+
break
|
| 517 |
+
|
| 518 |
+
if not found_valid_pair:
|
| 519 |
+
print(f" Stopping early: No more pairs satisfy max_token_length={max_token_length}")
|
| 520 |
+
break
|
| 521 |
+
|
| 522 |
+
# Final check before merging (in case we didn't enter the if block but updated vars)
|
| 523 |
+
# Actually the logic above handles it. If we entered the block, we either found a new best_pair or broke.
|
| 524 |
+
|
| 525 |
+
merges.append(best_pair)
|
| 526 |
+
|
| 527 |
+
# Add to vocab
|
| 528 |
+
tokenizer.token_to_id[new_token] = vocab_id
|
| 529 |
+
tokenizer.id_to_token[vocab_id] = new_token
|
| 530 |
+
vocab_id += 1
|
| 531 |
+
|
| 532 |
+
# Update word_freqs
|
| 533 |
+
new_word_freqs = {}
|
| 534 |
+
for word, freq in word_freqs.items():
|
| 535 |
+
new_word = []
|
| 536 |
+
i = 0
|
| 537 |
+
while i < len(word):
|
| 538 |
+
if i < len(word) - 1 and word[i] == best_pair[0] and word[i + 1] == best_pair[1]:
|
| 539 |
+
new_word.append(new_token)
|
| 540 |
+
i += 2
|
| 541 |
+
else:
|
| 542 |
+
new_word.append(word[i])
|
| 543 |
+
i += 1
|
| 544 |
+
new_word_freqs[tuple(new_word)] = freq
|
| 545 |
+
word_freqs = new_word_freqs
|
| 546 |
+
|
| 547 |
+
if (merge_idx + 1) % 100 == 0:
|
| 548 |
+
print(f" Merge {merge_idx + 1}/{num_merges}: '{best_pair[0]}' + '{best_pair[1]}' -> '{new_token}' (count={best_count})")
|
| 549 |
+
|
| 550 |
+
tokenizer.merges = merges
|
| 551 |
+
print(f"Final vocab size: {len(tokenizer.token_to_id)}")
|
| 552 |
+
|
| 553 |
+
# Save if requested
|
| 554 |
+
if output_path:
|
| 555 |
+
tokenizer.save_vocab(output_path)
|
| 556 |
+
|
| 557 |
+
return tokenizer
|
| 558 |
+
|
| 559 |
+
def apply_bpe(self, text: str) -> List[str]:
|
| 560 |
+
"""Apply BPE merges to a text string."""
|
| 561 |
+
if text in self.token_to_id:
|
| 562 |
+
return [text]
|
| 563 |
+
|
| 564 |
+
# Split into characters
|
| 565 |
+
tokens = list(text)
|
| 566 |
+
|
| 567 |
+
# Apply merges
|
| 568 |
+
for pair in self.merges:
|
| 569 |
+
new_tokens = []
|
| 570 |
+
i = 0
|
| 571 |
+
while i < len(tokens):
|
| 572 |
+
if i < len(tokens) - 1 and tokens[i] == pair[0] and tokens[i + 1] == pair[1]:
|
| 573 |
+
new_tokens.append(pair[0] + pair[1])
|
| 574 |
+
i += 2
|
| 575 |
+
else:
|
| 576 |
+
new_tokens.append(tokens[i])
|
| 577 |
+
i += 1
|
| 578 |
+
tokens = new_tokens
|
| 579 |
+
|
| 580 |
+
return tokens
|
| 581 |
+
|
| 582 |
+
def tokenize(self, wurcs: str, max_length: int = 256) -> Dict:
|
| 583 |
+
"""
|
| 584 |
+
Tokenize a WURCS string.
|
| 585 |
+
|
| 586 |
+
Returns:
|
| 587 |
+
Dict with:
|
| 588 |
+
- tokens: List of token strings
|
| 589 |
+
- token_ids: List of token IDs
|
| 590 |
+
- residue_ids: List of residue IDs
|
| 591 |
+
- branch_depths: List of branch depths
|
| 592 |
+
- linkage_types: List of linkage types
|
| 593 |
+
- attention_mask: Attention mask
|
| 594 |
+
"""
|
| 595 |
+
# Pre-tokenize
|
| 596 |
+
units = self.pre_tokenizer.pre_tokenize(wurcs)
|
| 597 |
+
|
| 598 |
+
tokens = []
|
| 599 |
+
token_ids = []
|
| 600 |
+
residue_ids = []
|
| 601 |
+
branch_depths = []
|
| 602 |
+
linkage_types = []
|
| 603 |
+
|
| 604 |
+
for unit in units:
|
| 605 |
+
if unit['type'] == 'special':
|
| 606 |
+
# Special tokens stay as-is
|
| 607 |
+
tok = unit['text']
|
| 608 |
+
tokens.append(tok)
|
| 609 |
+
token_ids.append(self.token_to_id.get(tok, self.token_to_id['[UNK]']))
|
| 610 |
+
residue_ids.append(unit['residue_id'])
|
| 611 |
+
branch_depths.append(unit['branch_depth'])
|
| 612 |
+
linkage_types.append(unit['linkage_type'])
|
| 613 |
+
else:
|
| 614 |
+
# Apply BPE to this unit
|
| 615 |
+
bpe_tokens = self.apply_bpe(unit['text'])
|
| 616 |
+
for tok in bpe_tokens:
|
| 617 |
+
tokens.append(tok)
|
| 618 |
+
token_ids.append(self.token_to_id.get(tok, self.token_to_id['[UNK]']))
|
| 619 |
+
residue_ids.append(unit['residue_id'])
|
| 620 |
+
branch_depths.append(unit['branch_depth'])
|
| 621 |
+
linkage_types.append(unit['linkage_type'])
|
| 622 |
+
|
| 623 |
+
# Truncate if needed
|
| 624 |
+
if len(tokens) > max_length:
|
| 625 |
+
tokens = tokens[:max_length - 1] + ['[END]']
|
| 626 |
+
token_ids = token_ids[:max_length - 1] + [self.token_to_id['[END]']]
|
| 627 |
+
residue_ids = residue_ids[:max_length - 1] + [-1]
|
| 628 |
+
branch_depths = branch_depths[:max_length - 1] + [0]
|
| 629 |
+
linkage_types = linkage_types[:max_length - 1] + [0]
|
| 630 |
+
|
| 631 |
+
# Create attention mask and pad
|
| 632 |
+
length = len(tokens)
|
| 633 |
+
attention_mask = [1] * length
|
| 634 |
+
|
| 635 |
+
while len(tokens) < max_length:
|
| 636 |
+
tokens.append('[PAD]')
|
| 637 |
+
token_ids.append(self.token_to_id['[PAD]'])
|
| 638 |
+
residue_ids.append(-1)
|
| 639 |
+
branch_depths.append(0)
|
| 640 |
+
linkage_types.append(0)
|
| 641 |
+
attention_mask.append(0)
|
| 642 |
+
# Pre-tokenize
|
| 643 |
+
units = self.pre_tokenizer.pre_tokenize(wurcs)
|
| 644 |
+
|
| 645 |
+
# Extract distance matrix from pre-tokenizer result
|
| 646 |
+
dist_matrix_raw = units[0].get('distance_matrix', [])
|
| 647 |
+
num_residues = len(dist_matrix_raw)
|
| 648 |
+
|
| 649 |
+
# Map token-to-token distances using residue_ids
|
| 650 |
+
# token_i is associated with residue_ids[i].
|
| 651 |
+
# residue_ids[i] is index into dist_matrix_raw.
|
| 652 |
+
# If residue_ids[i] == -1 (special), distance is undefined (use -1 or 999)
|
| 653 |
+
|
| 654 |
+
# Use UNPADDED length for distance matrix to save massive memory
|
| 655 |
+
# distance_matrix will be e.g. 20x20, while tokens are padded to 256
|
| 656 |
+
token_len = length
|
| 657 |
+
distance_matrix = [[-1] * token_len for _ in range(token_len)]
|
| 658 |
+
|
| 659 |
+
for i in range(token_len):
|
| 660 |
+
for j in range(token_len):
|
| 661 |
+
r_i = residue_ids[i]
|
| 662 |
+
r_j = residue_ids[j]
|
| 663 |
+
|
| 664 |
+
if r_i >= 0 and r_j >= 0 and r_i < num_residues and r_j < num_residues:
|
| 665 |
+
distance_matrix[i][j] = dist_matrix_raw[r_i][r_j]
|
| 666 |
+
else:
|
| 667 |
+
distance_matrix[i][j] = -1 # Special/Padding
|
| 668 |
+
|
| 669 |
+
# MEMORY OPTIMIZATION: Do NOT pad matrix here.
|
| 670 |
+
# Pad on-the-fly in Dataset class instead.
|
| 671 |
+
# This saves massive memory (0.2GB vs 66GB).
|
| 672 |
+
|
| 673 |
+
return {
|
| 674 |
+
'tokens': tokens,
|
| 675 |
+
'token_ids': token_ids,
|
| 676 |
+
'residue_ids': residue_ids,
|
| 677 |
+
'branch_depths': branch_depths,
|
| 678 |
+
'linkage_types': linkage_types,
|
| 679 |
+
'attention_mask': attention_mask,
|
| 680 |
+
'distance_matrix': distance_matrix, # New Output
|
| 681 |
+
'length': length,
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
+
def save_vocab(self, path: str):
|
| 685 |
+
"""Save vocabulary to JSON file."""
|
| 686 |
+
data = {
|
| 687 |
+
'special_tokens': self.special_tokens,
|
| 688 |
+
'token_to_id': self.token_to_id,
|
| 689 |
+
'merges': [list(m) for m in self.merges],
|
| 690 |
+
'metadata': {
|
| 691 |
+
'vocab_size': len(self.token_to_id),
|
| 692 |
+
'num_merges': len(self.merges),
|
| 693 |
+
}
|
| 694 |
+
}
|
| 695 |
+
with open(path, 'w') as f:
|
| 696 |
+
json.dump(data, f, indent=2)
|
| 697 |
+
print(f"Saved vocabulary to {path}")
|
| 698 |
+
|
| 699 |
+
def load_vocab(self, path: str):
|
| 700 |
+
"""Load vocabulary from JSON file."""
|
| 701 |
+
with open(path, 'r') as f:
|
| 702 |
+
data = json.load(f)
|
| 703 |
+
|
| 704 |
+
self.special_tokens = data['special_tokens']
|
| 705 |
+
self.token_to_id = data['token_to_id']
|
| 706 |
+
self.id_to_token = {int(v): k for k, v in self.token_to_id.items()}
|
| 707 |
+
self.merges = [tuple(m) for m in data['merges']]
|
| 708 |
+
|
| 709 |
+
print(f"Loaded vocabulary with {len(self.token_to_id)} tokens")
|
| 710 |
+
|
| 711 |
+
@property
|
| 712 |
+
def vocab_size(self) -> int:
|
| 713 |
+
return len(self.token_to_id)
|
| 714 |
+
|
| 715 |
+
|
| 716 |
+
# ============================================================================
|
| 717 |
+
# Testing
|
| 718 |
+
# ============================================================================
|
| 719 |
+
|
| 720 |
+
if __name__ == '__main__':
|
| 721 |
+
# Test pre-tokenizer
|
| 722 |
+
print("="*80)
|
| 723 |
+
print("Testing WURCSPreTokenizer")
|
| 724 |
+
print("="*80)
|
| 725 |
+
|
| 726 |
+
pre_tok = WURCSPreTokenizer()
|
| 727 |
+
|
| 728 |
+
test_wurcs = [
|
| 729 |
+
"WURCS=2.0/2,2,1/[a2122h-1b_1-5][a2211m-1a_1-5]/1-2/a4-b1",
|
| 730 |
+
"WURCS=2.0/3,3,2/[a2122h-1b_1-5_2*NCC/3=O][a2112h-1a_1-5][a2211m-1a_1-5]/1-2-3/a4-b1_b3-c1",
|
| 731 |
+
]
|
| 732 |
+
|
| 733 |
+
for wurcs in test_wurcs:
|
| 734 |
+
print(f"\nWURCS: {wurcs[:60]}...")
|
| 735 |
+
units = pre_tok.pre_tokenize(wurcs)
|
| 736 |
+
print(f"Units ({len(units)}):")
|
| 737 |
+
for u in units[:10]:
|
| 738 |
+
print(f" {u['type']:10} | res={u['residue_id']:2} | depth={u['branch_depth']} | {u['text']}")
|
| 739 |
+
if len(units) > 10:
|
| 740 |
+
print(f" ... and {len(units) - 10} more")
|
code/model/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Model module
|
| 2 |
+
from .multimodal_glycan_bert_v3 import MultimodalGlycanBERT, MultimodalGlycanBERTConfig
|
| 3 |
+
|
code/model/dataset.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Simplified Dataset for Glycan Classification Fine-tuning
|
| 3 |
+
|
| 4 |
+
Works with clean benchmark CSV that has WURCS column.
|
| 5 |
+
Supports both atomic (v3) and BPE (v4) tokenization.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import torch
|
| 9 |
+
from torch.utils.data import Dataset
|
| 10 |
+
import pandas as pd
|
| 11 |
+
from typing import Dict, List, Optional, Tuple
|
| 12 |
+
import logging
|
| 13 |
+
import json
|
| 14 |
+
|
| 15 |
+
from .tokenizer import WURCSTokenizer, create_tokenizer
|
| 16 |
+
|
| 17 |
+
# Try to import BPE tokenizer (available in v4)
|
| 18 |
+
try:
|
| 19 |
+
from .wurcs_bpe_tokenizer import WURCSBPETokenizer
|
| 20 |
+
HAS_BPE = True
|
| 21 |
+
except ImportError:
|
| 22 |
+
HAS_BPE = False
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def load_tokenizer(vocab_path: str):
|
| 28 |
+
"""
|
| 29 |
+
Load tokenizer based on vocabulary file type.
|
| 30 |
+
|
| 31 |
+
Args:
|
| 32 |
+
vocab_path: Path to vocabulary JSON file
|
| 33 |
+
|
| 34 |
+
Returns:
|
| 35 |
+
Either WURCSTokenizer (atomic) or WURCSBPETokenizer (BPE)
|
| 36 |
+
"""
|
| 37 |
+
with open(vocab_path, 'r') as f:
|
| 38 |
+
vocab = json.load(f)
|
| 39 |
+
|
| 40 |
+
# Check if this is a BPE vocabulary (has 'merges' field)
|
| 41 |
+
if 'merges' in vocab:
|
| 42 |
+
if not HAS_BPE:
|
| 43 |
+
raise ImportError("BPE vocabulary detected but WURCSBPETokenizer not available")
|
| 44 |
+
logger.info(f"Loading BPE tokenizer (vocab_size={vocab['metadata']['vocab_size']})")
|
| 45 |
+
return WURCSBPETokenizer(vocab_path)
|
| 46 |
+
else:
|
| 47 |
+
logger.info(f"Loading atomic tokenizer (vocab_size={vocab.get('metadata', {}).get('vocab_size', 167)})")
|
| 48 |
+
return WURCSTokenizer(vocab_path)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class GlycanClassificationDataset(Dataset):
|
| 52 |
+
"""
|
| 53 |
+
Dataset for glycan classification tasks.
|
| 54 |
+
|
| 55 |
+
Expects a CSV with columns:
|
| 56 |
+
- target: IUPAC representation of glycan
|
| 57 |
+
- wurcs: WURCS representation (required)
|
| 58 |
+
- {task_name}: Label column (e.g., 'species', 'phylum')
|
| 59 |
+
- split: 'train', 'validation', or 'test'
|
| 60 |
+
"""
|
| 61 |
+
|
| 62 |
+
def __init__(
|
| 63 |
+
self,
|
| 64 |
+
csv_path: str,
|
| 65 |
+
task: str,
|
| 66 |
+
split: str,
|
| 67 |
+
vocab_path: str,
|
| 68 |
+
max_length: int = 256, # Reduced default for BPE (was 512)
|
| 69 |
+
valid_classes: List[str] = None,
|
| 70 |
+
):
|
| 71 |
+
"""
|
| 72 |
+
Initialize dataset.
|
| 73 |
+
|
| 74 |
+
Args:
|
| 75 |
+
csv_path: Path to CSV file
|
| 76 |
+
task: Task name (column name for labels)
|
| 77 |
+
split: One of 'train', 'validation', 'test'
|
| 78 |
+
vocab_path: Path to vocabulary.json
|
| 79 |
+
max_length: Maximum sequence length
|
| 80 |
+
valid_classes: Optional list of valid classes to filter to
|
| 81 |
+
"""
|
| 82 |
+
self.task = task
|
| 83 |
+
self.split = split
|
| 84 |
+
self.max_length = max_length
|
| 85 |
+
|
| 86 |
+
# Load tokenizer (auto-detects BPE vs atomic based on vocab file)
|
| 87 |
+
self.tokenizer = load_tokenizer(vocab_path)
|
| 88 |
+
|
| 89 |
+
# Load data
|
| 90 |
+
df = pd.read_csv(csv_path)
|
| 91 |
+
|
| 92 |
+
# Filter by split
|
| 93 |
+
# Handle different column naming conventions:
|
| 94 |
+
# - Classification CSV: 'train', 'validation', 'test' columns (binary 0/1)
|
| 95 |
+
# - Immunogenicity/Link CSV: 'train', 'valid', 'test' columns (binary 0/1)
|
| 96 |
+
# - Some files might have a single 'split' column with string values
|
| 97 |
+
|
| 98 |
+
# Map 'validation' to 'valid' if needed
|
| 99 |
+
split_col = split
|
| 100 |
+
if split == 'validation' and 'validation' not in df.columns and 'valid' in df.columns:
|
| 101 |
+
split_col = 'valid'
|
| 102 |
+
|
| 103 |
+
if split_col in df.columns:
|
| 104 |
+
# Binary column for this split
|
| 105 |
+
if df[split_col].dtype == 'bool':
|
| 106 |
+
self.df = df[df[split_col]].copy()
|
| 107 |
+
else: # int64 or similar
|
| 108 |
+
self.df = df[df[split_col] == 1].copy()
|
| 109 |
+
elif 'split' in df.columns:
|
| 110 |
+
# Single 'split' column with string values
|
| 111 |
+
self.df = df[df['split'] == split].copy()
|
| 112 |
+
else:
|
| 113 |
+
raise ValueError(f"Cannot find split column '{split}' or '{split_col}' or 'split' in CSV")
|
| 114 |
+
|
| 115 |
+
# Filter to only samples with WURCS
|
| 116 |
+
initial_count = len(self.df)
|
| 117 |
+
self.df = self.df[self.df['wurcs'].notna()].copy()
|
| 118 |
+
removed = initial_count - len(self.df)
|
| 119 |
+
if removed > 0:
|
| 120 |
+
logger.info(f"Removed {removed} samples without WURCS")
|
| 121 |
+
|
| 122 |
+
# Get unique labels and create mapping
|
| 123 |
+
self.df = self.df[self.df[task].notna()].copy()
|
| 124 |
+
|
| 125 |
+
# Apply valid_classes filter if provided (for strict filtering mode)
|
| 126 |
+
if valid_classes is not None:
|
| 127 |
+
before_filter = len(self.df)
|
| 128 |
+
self.df = self.df[self.df[task].isin(valid_classes)].copy()
|
| 129 |
+
filtered_out = before_filter - len(self.df)
|
| 130 |
+
if filtered_out > 0:
|
| 131 |
+
logger.info(f" Filtered out {filtered_out} samples (classes not in all splits)")
|
| 132 |
+
self.unique_labels = sorted([c for c in valid_classes if c in self.df[task].values])
|
| 133 |
+
else:
|
| 134 |
+
self.unique_labels = sorted(self.df[task].unique())
|
| 135 |
+
|
| 136 |
+
self.label_to_id = {label: i for i, label in enumerate(self.unique_labels)}
|
| 137 |
+
self.id_to_label = {i: label for label, i in self.label_to_id.items()}
|
| 138 |
+
|
| 139 |
+
# Log info
|
| 140 |
+
logger.info(f"Loaded {len(self.df)} samples for {split} split")
|
| 141 |
+
logger.info(f" Task: {task}")
|
| 142 |
+
logger.info(f" Classes: {len(self.unique_labels)}")
|
| 143 |
+
|
| 144 |
+
def __len__(self) -> int:
|
| 145 |
+
return len(self.df)
|
| 146 |
+
|
| 147 |
+
def __getitem__(self, idx: int) -> Dict[str, torch.Tensor]:
|
| 148 |
+
row = self.df.iloc[idx]
|
| 149 |
+
|
| 150 |
+
# Tokenize WURCS
|
| 151 |
+
wurcs = row['wurcs']
|
| 152 |
+
tokenized = self.tokenizer.tokenize(wurcs, max_length=self.max_length)
|
| 153 |
+
|
| 154 |
+
# Get label
|
| 155 |
+
label = self.label_to_id[row[self.task]]
|
| 156 |
+
|
| 157 |
+
# Handle fields that may not exist in BPE tokenizer
|
| 158 |
+
num_residues = tokenized.get('num_residues', max(tokenized.get('residue_ids', [0])) + 1)
|
| 159 |
+
is_branched = tokenized.get('is_branched', '[BRANCH_OPEN]' in tokenized.get('tokens', []))
|
| 160 |
+
|
| 161 |
+
return {
|
| 162 |
+
'token_ids': torch.tensor(tokenized['token_ids'], dtype=torch.long),
|
| 163 |
+
'attention_mask': torch.tensor(tokenized['attention_mask'], dtype=torch.long),
|
| 164 |
+
'residue_ids': torch.tensor(tokenized['residue_ids'], dtype=torch.long),
|
| 165 |
+
'branch_depths': torch.tensor(tokenized['branch_depths'], dtype=torch.long),
|
| 166 |
+
'linkage_types': torch.tensor(tokenized['linkage_types'], dtype=torch.long),
|
| 167 |
+
'num_residues': num_residues,
|
| 168 |
+
'is_branched': is_branched,
|
| 169 |
+
'label': torch.tensor(label, dtype=torch.long),
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
def get_class_weights(self) -> torch.Tensor:
|
| 173 |
+
"""
|
| 174 |
+
Compute class weights for imbalanced data.
|
| 175 |
+
|
| 176 |
+
Returns:
|
| 177 |
+
Tensor of class weights (inverse frequency)
|
| 178 |
+
"""
|
| 179 |
+
class_counts = self.df[self.task].value_counts()
|
| 180 |
+
total = len(self.df)
|
| 181 |
+
n_classes = len(self.unique_labels)
|
| 182 |
+
|
| 183 |
+
weights = []
|
| 184 |
+
for label in self.unique_labels:
|
| 185 |
+
count = class_counts.get(label, 1)
|
| 186 |
+
weight = total / (n_classes * count)
|
| 187 |
+
weights.append(weight)
|
| 188 |
+
|
| 189 |
+
return torch.tensor(weights, dtype=torch.float)
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def compute_valid_classes(
|
| 193 |
+
csv_path: str,
|
| 194 |
+
task: str,
|
| 195 |
+
min_samples: int = 1,
|
| 196 |
+
) -> List[str]:
|
| 197 |
+
"""
|
| 198 |
+
Compute classes that are present in all splits (train, val, test)
|
| 199 |
+
with at least min_samples in each split.
|
| 200 |
+
|
| 201 |
+
This is used for 'strict' filtering mode (GlycanML approach).
|
| 202 |
+
|
| 203 |
+
Args:
|
| 204 |
+
csv_path: Path to CSV file
|
| 205 |
+
task: Task column name
|
| 206 |
+
min_samples: Minimum samples per class per split (default 1)
|
| 207 |
+
|
| 208 |
+
Returns:
|
| 209 |
+
List of valid class names
|
| 210 |
+
"""
|
| 211 |
+
df = pd.read_csv(csv_path)
|
| 212 |
+
|
| 213 |
+
# Only consider samples with WURCS
|
| 214 |
+
df = df[df['wurcs'].notna()]
|
| 215 |
+
df = df[df[task].notna()]
|
| 216 |
+
|
| 217 |
+
# Get split DataFrames
|
| 218 |
+
# Handle different column naming conventions:
|
| 219 |
+
# - Classification CSV: 'train', 'validation', 'test'
|
| 220 |
+
# - Immunogenicity/Link CSV: 'train', 'valid', 'test'
|
| 221 |
+
if 'train' in df.columns:
|
| 222 |
+
train_df = df[df['train'] == 1]
|
| 223 |
+
else:
|
| 224 |
+
train_df = df[df['split'] == 'train']
|
| 225 |
+
|
| 226 |
+
if 'validation' in df.columns:
|
| 227 |
+
val_df = df[df['validation'] == 1]
|
| 228 |
+
elif 'valid' in df.columns:
|
| 229 |
+
val_df = df[df['valid'] == 1]
|
| 230 |
+
else:
|
| 231 |
+
val_df = df[df['split'] == 'validation']
|
| 232 |
+
|
| 233 |
+
if 'test' in df.columns:
|
| 234 |
+
test_df = df[df['test'] == 1]
|
| 235 |
+
else:
|
| 236 |
+
test_df = df[df['split'] == 'test']
|
| 237 |
+
|
| 238 |
+
# Get class counts per split
|
| 239 |
+
train_counts = train_df[task].value_counts()
|
| 240 |
+
val_counts = val_df[task].value_counts()
|
| 241 |
+
test_counts = test_df[task].value_counts()
|
| 242 |
+
|
| 243 |
+
# Get classes with >= min_samples in each split
|
| 244 |
+
train_classes = set(train_counts[train_counts >= min_samples].index)
|
| 245 |
+
val_classes = set(val_counts[val_counts >= min_samples].index)
|
| 246 |
+
test_classes = set(test_counts[test_counts >= min_samples].index)
|
| 247 |
+
|
| 248 |
+
# Classes must meet min_samples threshold in all splits
|
| 249 |
+
valid_classes = sorted(train_classes & val_classes & test_classes)
|
| 250 |
+
|
| 251 |
+
all_classes = set(train_counts.index) | set(val_counts.index) | set(test_counts.index)
|
| 252 |
+
|
| 253 |
+
logger.info(f"Computing valid classes for {task} (min_samples={min_samples}):")
|
| 254 |
+
logger.info(f" Train classes (>={min_samples}): {len(train_classes)}")
|
| 255 |
+
logger.info(f" Val classes (>={min_samples}): {len(val_classes)}")
|
| 256 |
+
logger.info(f" Test classes (>={min_samples}): {len(test_classes)}")
|
| 257 |
+
logger.info(f" Valid (in all splits): {len(valid_classes)}")
|
| 258 |
+
logger.info(f" Excluded: {len(all_classes) - len(valid_classes)}")
|
| 259 |
+
|
| 260 |
+
return valid_classes
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def filter_to_valid_classes(
|
| 264 |
+
train_df: pd.DataFrame,
|
| 265 |
+
val_df: pd.DataFrame,
|
| 266 |
+
test_df: pd.DataFrame,
|
| 267 |
+
task: str,
|
| 268 |
+
) -> Tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, List[str]]:
|
| 269 |
+
"""
|
| 270 |
+
Filter datasets to only include classes present in all splits.
|
| 271 |
+
|
| 272 |
+
Args:
|
| 273 |
+
train_df: Training data
|
| 274 |
+
val_df: Validation data
|
| 275 |
+
test_df: Test data
|
| 276 |
+
task: Task column name
|
| 277 |
+
|
| 278 |
+
Returns:
|
| 279 |
+
Filtered (train_df, val_df, test_df, valid_classes)
|
| 280 |
+
"""
|
| 281 |
+
train_classes = set(train_df[task].dropna().unique())
|
| 282 |
+
val_classes = set(val_df[task].dropna().unique())
|
| 283 |
+
test_classes = set(test_df[task].dropna().unique())
|
| 284 |
+
|
| 285 |
+
# Classes must be in all splits
|
| 286 |
+
valid_classes = sorted(train_classes & val_classes & test_classes)
|
| 287 |
+
|
| 288 |
+
logger.info(f"Filtering classes for {task}:")
|
| 289 |
+
logger.info(f" Train classes: {len(train_classes)}")
|
| 290 |
+
logger.info(f" Val classes: {len(val_classes)}")
|
| 291 |
+
logger.info(f" Test classes: {len(test_classes)}")
|
| 292 |
+
logger.info(f" Valid (in all): {len(valid_classes)}")
|
| 293 |
+
|
| 294 |
+
train_df = train_df[train_df[task].isin(valid_classes)].copy()
|
| 295 |
+
val_df = val_df[val_df[task].isin(valid_classes)].copy()
|
| 296 |
+
test_df = test_df[test_df[task].isin(valid_classes)].copy()
|
| 297 |
+
|
| 298 |
+
return train_df, val_df, test_df, valid_classes
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def create_dataloaders(
|
| 302 |
+
csv_path: str,
|
| 303 |
+
task: str,
|
| 304 |
+
vocab_path: str,
|
| 305 |
+
batch_size: int = 64,
|
| 306 |
+
max_length: int = 512,
|
| 307 |
+
num_workers: int = 4,
|
| 308 |
+
) -> Tuple[torch.utils.data.DataLoader, torch.utils.data.DataLoader, torch.utils.data.DataLoader]:
|
| 309 |
+
"""
|
| 310 |
+
Create train, validation, and test dataloaders.
|
| 311 |
+
|
| 312 |
+
Args:
|
| 313 |
+
csv_path: Path to CSV file
|
| 314 |
+
task: Task name
|
| 315 |
+
vocab_path: Path to vocabulary.json
|
| 316 |
+
batch_size: Batch size
|
| 317 |
+
max_length: Maximum sequence length
|
| 318 |
+
num_workers: Number of data loading workers
|
| 319 |
+
|
| 320 |
+
Returns:
|
| 321 |
+
(train_loader, val_loader, test_loader)
|
| 322 |
+
"""
|
| 323 |
+
# Create datasets
|
| 324 |
+
train_dataset = GlycanClassificationDataset(
|
| 325 |
+
csv_path, task, 'train', vocab_path, max_length
|
| 326 |
+
)
|
| 327 |
+
val_dataset = GlycanClassificationDataset(
|
| 328 |
+
csv_path, task, 'validation', vocab_path, max_length
|
| 329 |
+
)
|
| 330 |
+
test_dataset = GlycanClassificationDataset(
|
| 331 |
+
csv_path, task, 'test', vocab_path, max_length
|
| 332 |
+
)
|
| 333 |
+
|
| 334 |
+
# Create dataloaders
|
| 335 |
+
train_loader = torch.utils.data.DataLoader(
|
| 336 |
+
train_dataset,
|
| 337 |
+
batch_size=batch_size,
|
| 338 |
+
shuffle=True,
|
| 339 |
+
num_workers=num_workers,
|
| 340 |
+
pin_memory=True,
|
| 341 |
+
)
|
| 342 |
+
|
| 343 |
+
val_loader = torch.utils.data.DataLoader(
|
| 344 |
+
val_dataset,
|
| 345 |
+
batch_size=batch_size,
|
| 346 |
+
shuffle=False,
|
| 347 |
+
num_workers=num_workers,
|
| 348 |
+
pin_memory=True,
|
| 349 |
+
)
|
| 350 |
+
|
| 351 |
+
test_loader = torch.utils.data.DataLoader(
|
| 352 |
+
test_dataset,
|
| 353 |
+
batch_size=batch_size,
|
| 354 |
+
shuffle=False,
|
| 355 |
+
num_workers=num_workers,
|
| 356 |
+
pin_memory=True,
|
| 357 |
+
)
|
| 358 |
+
|
| 359 |
+
return train_loader, val_loader, test_loader
|
| 360 |
+
|
code/model/enhanced_classifier.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Enhanced Glycan Classifier with Architecture Improvements
|
| 3 |
+
|
| 4 |
+
Uses the new architecture components:
|
| 5 |
+
- #1 MonosaccharidePooling: Pool tokens to residue level
|
| 6 |
+
- #2 ResidueTypeEmbeddings: Add monosaccharide type embeddings
|
| 7 |
+
- #4 RelativePositionBias: Tree-aware position encoding
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import torch
|
| 11 |
+
import torch.nn as nn
|
| 12 |
+
from typing import Optional, Dict
|
| 13 |
+
|
| 14 |
+
try:
|
| 15 |
+
from .multimodal_glycan_bert_v3 import (
|
| 16 |
+
MultimodalGlycanBERT,
|
| 17 |
+
MultimodalGlycanBERTConfig,
|
| 18 |
+
MonosaccharidePooling,
|
| 19 |
+
ResidueTypeEmbeddings,
|
| 20 |
+
RelativePositionBias,
|
| 21 |
+
MONOSACCHARIDE_VOCAB,
|
| 22 |
+
)
|
| 23 |
+
except ImportError:
|
| 24 |
+
from multimodal_glycan_bert_v3 import (
|
| 25 |
+
MultimodalGlycanBERT,
|
| 26 |
+
MultimodalGlycanBERTConfig,
|
| 27 |
+
MonosaccharidePooling,
|
| 28 |
+
ResidueTypeEmbeddings,
|
| 29 |
+
RelativePositionBias,
|
| 30 |
+
MONOSACCHARIDE_VOCAB,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class EnhancedGlycanClassifier(nn.Module):
|
| 35 |
+
"""
|
| 36 |
+
Classification head using architecture improvements #1-4.
|
| 37 |
+
|
| 38 |
+
Key differences from basic classifier:
|
| 39 |
+
1. Monosaccharide-level pooling (not first-token or mean)
|
| 40 |
+
2. Optional residue type embeddings
|
| 41 |
+
3. Optional relative position bias (requires model modification)
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
def __init__(
|
| 45 |
+
self,
|
| 46 |
+
bert: MultimodalGlycanBERT,
|
| 47 |
+
num_classes: int,
|
| 48 |
+
dropout: float = 0.1,
|
| 49 |
+
freeze_layers: int = 4,
|
| 50 |
+
use_mono_pooling: bool = True,
|
| 51 |
+
use_residue_types: bool = True,
|
| 52 |
+
):
|
| 53 |
+
super().__init__()
|
| 54 |
+
self.bert = bert
|
| 55 |
+
self.num_classes = num_classes
|
| 56 |
+
self.use_mono_pooling = use_mono_pooling
|
| 57 |
+
self.use_residue_types = use_residue_types
|
| 58 |
+
|
| 59 |
+
hidden_size = bert.config.seq_hidden_size
|
| 60 |
+
|
| 61 |
+
# Freeze bottom layers
|
| 62 |
+
for i, layer in enumerate(self.bert.seq_layers):
|
| 63 |
+
if i < freeze_layers:
|
| 64 |
+
for param in layer.parameters():
|
| 65 |
+
param.requires_grad = False
|
| 66 |
+
|
| 67 |
+
# #1: Monosaccharide-level pooling
|
| 68 |
+
if use_mono_pooling:
|
| 69 |
+
self.mono_pooling = MonosaccharidePooling(
|
| 70 |
+
hidden_size=hidden_size,
|
| 71 |
+
num_attention_heads=8,
|
| 72 |
+
dropout=dropout
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
# #2: Residue type embeddings
|
| 76 |
+
if use_residue_types:
|
| 77 |
+
self.residue_embeddings = ResidueTypeEmbeddings(
|
| 78 |
+
hidden_size=hidden_size,
|
| 79 |
+
num_mono_types=len(MONOSACCHARIDE_VOCAB) + 10 # Buffer for new types
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
# Classification head
|
| 83 |
+
self.classifier = nn.Sequential(
|
| 84 |
+
nn.Dropout(dropout),
|
| 85 |
+
nn.Linear(hidden_size, hidden_size // 2),
|
| 86 |
+
nn.GELU(),
|
| 87 |
+
nn.Dropout(dropout),
|
| 88 |
+
nn.Linear(hidden_size // 2, num_classes),
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
def forward(
|
| 92 |
+
self,
|
| 93 |
+
token_ids: torch.Tensor, # (batch, seq_len)
|
| 94 |
+
attention_mask: torch.Tensor, # (batch, seq_len)
|
| 95 |
+
residue_ids: torch.Tensor = None, # (batch, seq_len) - which residue each token belongs to
|
| 96 |
+
mono_type_ids: torch.Tensor = None, # (batch, max_residues) - monosaccharide type per residue
|
| 97 |
+
) -> torch.Tensor:
|
| 98 |
+
"""
|
| 99 |
+
Forward pass with architecture improvements.
|
| 100 |
+
|
| 101 |
+
Args:
|
| 102 |
+
token_ids: Token IDs
|
| 103 |
+
attention_mask: Attention mask
|
| 104 |
+
residue_ids: Residue ID for each token (from data)
|
| 105 |
+
mono_type_ids: Monosaccharide type ID for each residue (from data)
|
| 106 |
+
|
| 107 |
+
Returns:
|
| 108 |
+
logits: (batch, num_classes)
|
| 109 |
+
"""
|
| 110 |
+
# Get sequence embeddings
|
| 111 |
+
seq_hidden = self.bert.seq_embeddings(token_ids)
|
| 112 |
+
|
| 113 |
+
# #2: Add residue type embeddings if available
|
| 114 |
+
if self.use_residue_types and residue_ids is not None:
|
| 115 |
+
seq_hidden = self.residue_embeddings(
|
| 116 |
+
seq_hidden, residue_ids, mono_type_ids
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
# Apply transformer layers
|
| 120 |
+
for layer in self.bert.seq_layers:
|
| 121 |
+
seq_hidden = layer(seq_hidden, attention_mask)
|
| 122 |
+
|
| 123 |
+
# Pool to glycan representation
|
| 124 |
+
if self.use_mono_pooling and residue_ids is not None:
|
| 125 |
+
# #1: Monosaccharide-level pooling
|
| 126 |
+
pooled = self.mono_pooling(seq_hidden, residue_ids, attention_mask)
|
| 127 |
+
else:
|
| 128 |
+
# Fallback: Mean pooling
|
| 129 |
+
mask_expanded = attention_mask.unsqueeze(-1).float()
|
| 130 |
+
sum_hidden = (seq_hidden * mask_expanded).sum(dim=1)
|
| 131 |
+
sum_mask = mask_expanded.sum(dim=1).clamp(min=1e-9)
|
| 132 |
+
pooled = sum_hidden / sum_mask
|
| 133 |
+
|
| 134 |
+
# Classify
|
| 135 |
+
logits = self.classifier(pooled)
|
| 136 |
+
|
| 137 |
+
return logits
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def prepare_mono_type_ids(mono_names_batch, max_residues: int = 50, device='cpu'):
|
| 141 |
+
"""
|
| 142 |
+
Convert batch of monosaccharide name lists to type ID tensor.
|
| 143 |
+
|
| 144 |
+
Args:
|
| 145 |
+
mono_names_batch: List of lists of monosaccharide names
|
| 146 |
+
max_residues: Maximum number of residues to pad to
|
| 147 |
+
device: Device for tensor
|
| 148 |
+
|
| 149 |
+
Returns:
|
| 150 |
+
mono_type_ids: (batch, max_residues) tensor
|
| 151 |
+
"""
|
| 152 |
+
batch_size = len(mono_names_batch)
|
| 153 |
+
mono_type_ids = torch.zeros(batch_size, max_residues, dtype=torch.long, device=device)
|
| 154 |
+
|
| 155 |
+
for b, mono_names in enumerate(mono_names_batch):
|
| 156 |
+
for i, name in enumerate(mono_names):
|
| 157 |
+
if i >= max_residues:
|
| 158 |
+
break
|
| 159 |
+
mono_type_ids[b, i] = ResidueTypeEmbeddings.get_mono_type_id(name)
|
| 160 |
+
|
| 161 |
+
return mono_type_ids
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
if __name__ == '__main__':
|
| 165 |
+
# Test the enhanced classifier
|
| 166 |
+
print("Testing EnhancedGlycanClassifier...")
|
| 167 |
+
|
| 168 |
+
config = MultimodalGlycanBERTConfig(use_cnn_frontend=True)
|
| 169 |
+
bert = MultimodalGlycanBERT(config)
|
| 170 |
+
|
| 171 |
+
classifier = EnhancedGlycanClassifier(
|
| 172 |
+
bert=bert,
|
| 173 |
+
num_classes=31, # species task
|
| 174 |
+
use_mono_pooling=True,
|
| 175 |
+
use_residue_types=True,
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
# Create dummy input
|
| 179 |
+
batch_size = 2
|
| 180 |
+
seq_len = 64
|
| 181 |
+
token_ids = torch.randint(0, 100, (batch_size, seq_len))
|
| 182 |
+
attention_mask = torch.ones(batch_size, seq_len)
|
| 183 |
+
residue_ids = torch.div(torch.arange(seq_len), 10, rounding_mode='floor').unsqueeze(0).expand(batch_size, -1)
|
| 184 |
+
mono_type_ids = torch.randint(0, 20, (batch_size, 10))
|
| 185 |
+
|
| 186 |
+
logits = classifier(token_ids, attention_mask, residue_ids, mono_type_ids)
|
| 187 |
+
|
| 188 |
+
print(f"✅ Output shape: {logits.shape}")
|
| 189 |
+
print(f"✅ Total params: {sum(p.numel() for p in classifier.parameters()):,}")
|
code/model/glycan_bert.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Glycan BERT Model
|
| 3 |
+
|
| 4 |
+
Transformer-based masked language model for glycan structures.
|
| 5 |
+
Based on BERT/ESM2 architecture adapted for atomic glycan tokenization.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import torch
|
| 9 |
+
import torch.nn as nn
|
| 10 |
+
import math
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class GlycanBERTConfig:
|
| 14 |
+
"""Configuration for GlycanBERT."""
|
| 15 |
+
|
| 16 |
+
def __init__(
|
| 17 |
+
self,
|
| 18 |
+
vocab_size: int = 102,
|
| 19 |
+
hidden_size: int = 384,
|
| 20 |
+
num_hidden_layers: int = 6,
|
| 21 |
+
num_attention_heads: int = 6,
|
| 22 |
+
intermediate_size: int = 1536,
|
| 23 |
+
hidden_dropout_prob: float = 0.1,
|
| 24 |
+
attention_probs_dropout_prob: float = 0.1,
|
| 25 |
+
max_position_embeddings: int = 512,
|
| 26 |
+
layer_norm_eps: float = 1e-12,
|
| 27 |
+
pad_token_id: int = 0,
|
| 28 |
+
mask_token_id: int = 4,
|
| 29 |
+
initializer_range: float = 0.02
|
| 30 |
+
):
|
| 31 |
+
self.vocab_size = vocab_size
|
| 32 |
+
self.hidden_size = hidden_size
|
| 33 |
+
self.num_hidden_layers = num_hidden_layers
|
| 34 |
+
self.num_attention_heads = num_attention_heads
|
| 35 |
+
self.intermediate_size = intermediate_size
|
| 36 |
+
self.hidden_dropout_prob = hidden_dropout_prob
|
| 37 |
+
self.attention_probs_dropout_prob = attention_probs_dropout_prob
|
| 38 |
+
self.max_position_embeddings = max_position_embeddings
|
| 39 |
+
self.layer_norm_eps = layer_norm_eps
|
| 40 |
+
self.pad_token_id = pad_token_id
|
| 41 |
+
self.mask_token_id = mask_token_id
|
| 42 |
+
self.initializer_range = initializer_range
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class GlycanBERTEmbeddings(nn.Module):
|
| 46 |
+
"""
|
| 47 |
+
Embeddings for glycan tokens including token and positional embeddings.
|
| 48 |
+
"""
|
| 49 |
+
|
| 50 |
+
def __init__(self, config: GlycanBERTConfig):
|
| 51 |
+
super().__init__()
|
| 52 |
+
self.token_embeddings = nn.Embedding(config.vocab_size, config.hidden_size, padding_idx=config.pad_token_id)
|
| 53 |
+
self.position_embeddings = nn.Embedding(config.max_position_embeddings, config.hidden_size)
|
| 54 |
+
|
| 55 |
+
self.LayerNorm = nn.LayerNorm(config.hidden_size, eps=config.layer_norm_eps)
|
| 56 |
+
self.dropout = nn.Dropout(config.hidden_dropout_prob)
|
| 57 |
+
|
| 58 |
+
# position_ids (1, max_seq_len) is contiguous in memory and exported when serialized
|
| 59 |
+
self.register_buffer("position_ids", torch.arange(config.max_position_embeddings).expand((1, -1)))
|
| 60 |
+
|
| 61 |
+
def forward(self, input_ids: torch.Tensor) -> torch.Tensor:
|
| 62 |
+
"""
|
| 63 |
+
Args:
|
| 64 |
+
input_ids: Tensor of shape (batch_size, seq_len)
|
| 65 |
+
|
| 66 |
+
Returns:
|
| 67 |
+
Embeddings of shape (batch_size, seq_len, hidden_size)
|
| 68 |
+
"""
|
| 69 |
+
batch_size, seq_len = input_ids.shape
|
| 70 |
+
|
| 71 |
+
# Token embeddings
|
| 72 |
+
token_embeds = self.token_embeddings(input_ids)
|
| 73 |
+
|
| 74 |
+
# Position embeddings
|
| 75 |
+
position_ids = self.position_ids[:, :seq_len]
|
| 76 |
+
position_embeds = self.position_embeddings(position_ids)
|
| 77 |
+
|
| 78 |
+
# Combine
|
| 79 |
+
embeddings = token_embeds + position_embeds
|
| 80 |
+
embeddings = self.LayerNorm(embeddings)
|
| 81 |
+
embeddings = self.dropout(embeddings)
|
| 82 |
+
|
| 83 |
+
return embeddings
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class GlycanBERTAttention(nn.Module):
|
| 87 |
+
"""Multi-head self-attention."""
|
| 88 |
+
|
| 89 |
+
def __init__(self, config: GlycanBERTConfig):
|
| 90 |
+
super().__init__()
|
| 91 |
+
assert config.hidden_size % config.num_attention_heads == 0
|
| 92 |
+
|
| 93 |
+
self.num_attention_heads = config.num_attention_heads
|
| 94 |
+
self.attention_head_size = config.hidden_size // config.num_attention_heads
|
| 95 |
+
self.all_head_size = self.num_attention_heads * self.attention_head_size
|
| 96 |
+
|
| 97 |
+
self.query = nn.Linear(config.hidden_size, self.all_head_size)
|
| 98 |
+
self.key = nn.Linear(config.hidden_size, self.all_head_size)
|
| 99 |
+
self.value = nn.Linear(config.hidden_size, self.all_head_size)
|
| 100 |
+
|
| 101 |
+
self.dropout = nn.Dropout(config.attention_probs_dropout_prob)
|
| 102 |
+
|
| 103 |
+
def transpose_for_scores(self, x: torch.Tensor) -> torch.Tensor:
|
| 104 |
+
"""Reshape for multi-head attention."""
|
| 105 |
+
new_shape = x.size()[:-1] + (self.num_attention_heads, self.attention_head_size)
|
| 106 |
+
x = x.view(*new_shape)
|
| 107 |
+
return x.permute(0, 2, 1, 3) # (batch, heads, seq_len, head_size)
|
| 108 |
+
|
| 109 |
+
def forward(
|
| 110 |
+
self,
|
| 111 |
+
hidden_states: torch.Tensor,
|
| 112 |
+
attention_mask: torch.Tensor = None
|
| 113 |
+
) -> torch.Tensor:
|
| 114 |
+
"""
|
| 115 |
+
Args:
|
| 116 |
+
hidden_states: (batch_size, seq_len, hidden_size)
|
| 117 |
+
attention_mask: (batch_size, seq_len) - 1 for valid, 0 for padding
|
| 118 |
+
|
| 119 |
+
Returns:
|
| 120 |
+
Attention output: (batch_size, seq_len, hidden_size)
|
| 121 |
+
"""
|
| 122 |
+
batch_size, seq_len, _ = hidden_states.shape
|
| 123 |
+
|
| 124 |
+
# Linear projections
|
| 125 |
+
query_layer = self.transpose_for_scores(self.query(hidden_states))
|
| 126 |
+
key_layer = self.transpose_for_scores(self.key(hidden_states))
|
| 127 |
+
value_layer = self.transpose_for_scores(self.value(hidden_states))
|
| 128 |
+
|
| 129 |
+
# Attention scores
|
| 130 |
+
attention_scores = torch.matmul(query_layer, key_layer.transpose(-1, -2))
|
| 131 |
+
attention_scores = attention_scores / math.sqrt(self.attention_head_size)
|
| 132 |
+
|
| 133 |
+
# Apply attention mask
|
| 134 |
+
if attention_mask is not None:
|
| 135 |
+
# Convert mask to additive mask
|
| 136 |
+
attention_mask = attention_mask.unsqueeze(1).unsqueeze(2) # (batch, 1, 1, seq_len)
|
| 137 |
+
attention_mask = (1.0 - attention_mask) * -10000.0
|
| 138 |
+
attention_scores = attention_scores + attention_mask
|
| 139 |
+
|
| 140 |
+
# Attention probabilities
|
| 141 |
+
attention_probs = nn.functional.softmax(attention_scores, dim=-1)
|
| 142 |
+
attention_probs = self.dropout(attention_probs)
|
| 143 |
+
|
| 144 |
+
# Apply attention to values
|
| 145 |
+
context_layer = torch.matmul(attention_probs, value_layer)
|
| 146 |
+
|
| 147 |
+
# Reshape back
|
| 148 |
+
context_layer = context_layer.permute(0, 2, 1, 3).contiguous()
|
| 149 |
+
new_shape = context_layer.size()[:-2] + (self.all_head_size,)
|
| 150 |
+
context_layer = context_layer.view(*new_shape)
|
| 151 |
+
|
| 152 |
+
return context_layer
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
class GlycanBERTLayer(nn.Module):
|
| 156 |
+
"""Single transformer layer."""
|
| 157 |
+
|
| 158 |
+
def __init__(self, config: GlycanBERTConfig):
|
| 159 |
+
super().__init__()
|
| 160 |
+
self.attention = GlycanBERTAttention(config)
|
| 161 |
+
self.attention_output = nn.Linear(config.hidden_size, config.hidden_size)
|
| 162 |
+
self.attention_layer_norm = nn.LayerNorm(config.hidden_size, eps=config.layer_norm_eps)
|
| 163 |
+
|
| 164 |
+
self.intermediate = nn.Linear(config.hidden_size, config.intermediate_size)
|
| 165 |
+
self.output = nn.Linear(config.intermediate_size, config.hidden_size)
|
| 166 |
+
self.output_layer_norm = nn.LayerNorm(config.hidden_size, eps=config.layer_norm_eps)
|
| 167 |
+
|
| 168 |
+
self.dropout = nn.Dropout(config.hidden_dropout_prob)
|
| 169 |
+
|
| 170 |
+
def forward(
|
| 171 |
+
self,
|
| 172 |
+
hidden_states: torch.Tensor,
|
| 173 |
+
attention_mask: torch.Tensor = None
|
| 174 |
+
) -> torch.Tensor:
|
| 175 |
+
"""
|
| 176 |
+
Args:
|
| 177 |
+
hidden_states: (batch_size, seq_len, hidden_size)
|
| 178 |
+
attention_mask: (batch_size, seq_len)
|
| 179 |
+
|
| 180 |
+
Returns:
|
| 181 |
+
Output: (batch_size, seq_len, hidden_size)
|
| 182 |
+
"""
|
| 183 |
+
# Self-attention
|
| 184 |
+
attention_output = self.attention(hidden_states, attention_mask)
|
| 185 |
+
attention_output = self.attention_output(attention_output)
|
| 186 |
+
attention_output = self.dropout(attention_output)
|
| 187 |
+
|
| 188 |
+
# Add & Norm
|
| 189 |
+
hidden_states = self.attention_layer_norm(hidden_states + attention_output)
|
| 190 |
+
|
| 191 |
+
# Feed-forward
|
| 192 |
+
intermediate_output = self.intermediate(hidden_states)
|
| 193 |
+
intermediate_output = nn.functional.gelu(intermediate_output)
|
| 194 |
+
|
| 195 |
+
layer_output = self.output(intermediate_output)
|
| 196 |
+
layer_output = self.dropout(layer_output)
|
| 197 |
+
|
| 198 |
+
# Add & Norm
|
| 199 |
+
layer_output = self.output_layer_norm(hidden_states + layer_output)
|
| 200 |
+
|
| 201 |
+
return layer_output
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
class GlycanBERT(nn.Module):
|
| 205 |
+
"""
|
| 206 |
+
Glycan BERT model for masked language modeling.
|
| 207 |
+
"""
|
| 208 |
+
|
| 209 |
+
def __init__(self, config: GlycanBERTConfig):
|
| 210 |
+
super().__init__()
|
| 211 |
+
self.config = config
|
| 212 |
+
|
| 213 |
+
# Embeddings
|
| 214 |
+
self.embeddings = GlycanBERTEmbeddings(config)
|
| 215 |
+
|
| 216 |
+
# Transformer layers
|
| 217 |
+
self.layers = nn.ModuleList([GlycanBERTLayer(config) for _ in range(config.num_hidden_layers)])
|
| 218 |
+
|
| 219 |
+
# MLM head
|
| 220 |
+
self.mlm_head = nn.Linear(config.hidden_size, config.vocab_size)
|
| 221 |
+
|
| 222 |
+
# Initialize weights
|
| 223 |
+
self.apply(self._init_weights)
|
| 224 |
+
|
| 225 |
+
def _init_weights(self, module):
|
| 226 |
+
"""Initialize weights."""
|
| 227 |
+
if isinstance(module, nn.Linear):
|
| 228 |
+
module.weight.data.normal_(mean=0.0, std=self.config.initializer_range)
|
| 229 |
+
if module.bias is not None:
|
| 230 |
+
module.bias.data.zero_()
|
| 231 |
+
elif isinstance(module, nn.Embedding):
|
| 232 |
+
module.weight.data.normal_(mean=0.0, std=self.config.initializer_range)
|
| 233 |
+
if module.padding_idx is not None:
|
| 234 |
+
module.weight.data[module.padding_idx].zero_()
|
| 235 |
+
elif isinstance(module, nn.LayerNorm):
|
| 236 |
+
module.bias.data.zero_()
|
| 237 |
+
module.weight.data.fill_(1.0)
|
| 238 |
+
|
| 239 |
+
def forward(
|
| 240 |
+
self,
|
| 241 |
+
input_ids: torch.Tensor,
|
| 242 |
+
attention_mask: torch.Tensor = None,
|
| 243 |
+
labels: torch.Tensor = None
|
| 244 |
+
):
|
| 245 |
+
"""
|
| 246 |
+
Args:
|
| 247 |
+
input_ids: (batch_size, seq_len)
|
| 248 |
+
attention_mask: (batch_size, seq_len) - 1 for valid, 0 for padding
|
| 249 |
+
labels: (batch_size, seq_len) - token IDs to predict, -100 for positions to ignore
|
| 250 |
+
|
| 251 |
+
Returns:
|
| 252 |
+
If labels provided: (loss, logits)
|
| 253 |
+
Else: logits
|
| 254 |
+
"""
|
| 255 |
+
# Create attention mask if not provided
|
| 256 |
+
if attention_mask is None:
|
| 257 |
+
attention_mask = (input_ids != self.config.pad_token_id).float()
|
| 258 |
+
|
| 259 |
+
# Embeddings
|
| 260 |
+
hidden_states = self.embeddings(input_ids)
|
| 261 |
+
|
| 262 |
+
# Transformer layers
|
| 263 |
+
for layer in self.layers:
|
| 264 |
+
hidden_states = layer(hidden_states, attention_mask)
|
| 265 |
+
|
| 266 |
+
# MLM prediction
|
| 267 |
+
logits = self.mlm_head(hidden_states)
|
| 268 |
+
|
| 269 |
+
# Calculate loss if labels provided
|
| 270 |
+
loss = None
|
| 271 |
+
if labels is not None:
|
| 272 |
+
loss_fct = nn.CrossEntropyLoss() # -100 is ignored
|
| 273 |
+
loss = loss_fct(logits.view(-1, self.config.vocab_size), labels.view(-1))
|
| 274 |
+
|
| 275 |
+
if loss is not None:
|
| 276 |
+
return loss, logits
|
| 277 |
+
return logits
|
| 278 |
+
|
| 279 |
+
def get_embeddings(
|
| 280 |
+
self,
|
| 281 |
+
input_ids: torch.Tensor,
|
| 282 |
+
attention_mask: torch.Tensor = None
|
| 283 |
+
) -> torch.Tensor:
|
| 284 |
+
"""
|
| 285 |
+
Get contextualized embeddings (for downstream tasks).
|
| 286 |
+
|
| 287 |
+
Args:
|
| 288 |
+
input_ids: (batch_size, seq_len)
|
| 289 |
+
attention_mask: (batch_size, seq_len)
|
| 290 |
+
|
| 291 |
+
Returns:
|
| 292 |
+
Embeddings: (batch_size, seq_len, hidden_size)
|
| 293 |
+
"""
|
| 294 |
+
if attention_mask is None:
|
| 295 |
+
attention_mask = (input_ids != self.config.pad_token_id).float()
|
| 296 |
+
|
| 297 |
+
hidden_states = self.embeddings(input_ids)
|
| 298 |
+
|
| 299 |
+
for layer in self.layers:
|
| 300 |
+
hidden_states = layer(hidden_states, attention_mask)
|
| 301 |
+
|
| 302 |
+
return hidden_states
|
| 303 |
+
|
code/model/hierarchical_glycan.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Novel Hierarchical Glycan Architecture
|
| 3 |
+
|
| 4 |
+
This module provides components for hierarchical representation learning
|
| 5 |
+
that leverages our unique multimodal data (sequence + MS + 3D structure).
|
| 6 |
+
|
| 7 |
+
Key advantages over GIFFLAR (which only uses graph structure):
|
| 8 |
+
1. We have 3D atomic structure from PDB files → structural supervision
|
| 9 |
+
2. We have MS fragmentation patterns → mass-based supervision
|
| 10 |
+
3. We have WURCS with explicit residue boundaries → hierarchical attention
|
| 11 |
+
|
| 12 |
+
Our novel approach: Cross-Modal Hierarchical Learning
|
| 13 |
+
- Token level: Learn atomic WURCS patterns
|
| 14 |
+
- Residue level: Pool tokens → monosaccharide representations
|
| 15 |
+
- Glycan level: Pool residues → glycan representation
|
| 16 |
+
- Cross-modal: Align sequence residues with 3D structure residues using contrastive learning
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import torch
|
| 20 |
+
import torch.nn as nn
|
| 21 |
+
import torch.nn.functional as F
|
| 22 |
+
from typing import Optional, List, Dict, Tuple
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class MonoTypePredictor(nn.Module):
|
| 26 |
+
"""
|
| 27 |
+
Prediction head for monosaccharide type classification.
|
| 28 |
+
|
| 29 |
+
Given a masked residue's pooled representation, predict the monosaccharide type.
|
| 30 |
+
This is used with MonosaccharideMaskingStrategy during pre-training.
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
# Same type vocabulary as in masking.py
|
| 34 |
+
MONO_TYPES = [
|
| 35 |
+
'<UNK>', 'Glc', 'Gal', 'Man', 'Fuc', 'Xyl', 'Rha', 'Ara',
|
| 36 |
+
'GlcNAc', 'GalNAc', 'ManNAc', 'GlcA', 'GalA', 'IdoA',
|
| 37 |
+
'Neu5Ac', 'Neu5Gc', 'Kdn', 'GlcN', 'GalN', 'Hex', 'HexNAc',
|
| 38 |
+
'dHex', 'Pent', 'Sia', 'GlcS', 'GalS', 'Ido', 'All', 'Alt', 'Gul', 'Tal'
|
| 39 |
+
]
|
| 40 |
+
|
| 41 |
+
def __init__(self, hidden_size: int, dropout: float = 0.1):
|
| 42 |
+
super().__init__()
|
| 43 |
+
self.num_types = len(self.MONO_TYPES)
|
| 44 |
+
|
| 45 |
+
self.predictor = nn.Sequential(
|
| 46 |
+
nn.Linear(hidden_size, hidden_size),
|
| 47 |
+
nn.GELU(),
|
| 48 |
+
nn.LayerNorm(hidden_size),
|
| 49 |
+
nn.Dropout(dropout),
|
| 50 |
+
nn.Linear(hidden_size, self.num_types)
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
# Type embeddings (can be used to enrich residue representations)
|
| 54 |
+
self.type_embeddings = nn.Embedding(self.num_types, hidden_size)
|
| 55 |
+
|
| 56 |
+
def forward(self, residue_hidden: torch.Tensor) -> torch.Tensor:
|
| 57 |
+
"""
|
| 58 |
+
Predict monosaccharide type from residue representation.
|
| 59 |
+
|
| 60 |
+
Args:
|
| 61 |
+
residue_hidden: (batch, hidden_size) or (batch, num_residues, hidden_size)
|
| 62 |
+
|
| 63 |
+
Returns:
|
| 64 |
+
logits: (batch, num_types) or (batch, num_residues, num_types)
|
| 65 |
+
"""
|
| 66 |
+
return self.predictor(residue_hidden)
|
| 67 |
+
|
| 68 |
+
def get_type_embedding(self, type_ids: torch.Tensor) -> torch.Tensor:
|
| 69 |
+
"""Get embeddings for monosaccharide type IDs."""
|
| 70 |
+
return self.type_embeddings(type_ids)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class HierarchicalGlycanEncoder(nn.Module):
|
| 74 |
+
"""
|
| 75 |
+
Novel hierarchical encoder for glycan sequences.
|
| 76 |
+
|
| 77 |
+
Architecture:
|
| 78 |
+
1. Token Encoder: Standard transformer on WURCS tokens
|
| 79 |
+
2. Residue Encoder: Pool tokens within residues → residue transformer
|
| 80 |
+
3. Glycan Encoder: Pool residues → final glycan representation
|
| 81 |
+
|
| 82 |
+
This explicitly models the glycan hierarchy (atoms → monosaccharides → glycan)
|
| 83 |
+
which is similar to how GNNs work but within a transformer framework.
|
| 84 |
+
"""
|
| 85 |
+
|
| 86 |
+
def __init__(
|
| 87 |
+
self,
|
| 88 |
+
hidden_size: int = 768,
|
| 89 |
+
num_residue_layers: int = 2,
|
| 90 |
+
num_heads: int = 8,
|
| 91 |
+
dropout: float = 0.1,
|
| 92 |
+
max_residues: int = 32,
|
| 93 |
+
):
|
| 94 |
+
super().__init__()
|
| 95 |
+
self.hidden_size = hidden_size
|
| 96 |
+
self.max_residues = max_residues
|
| 97 |
+
|
| 98 |
+
# Residue position embedding
|
| 99 |
+
self.residue_pos_embedding = nn.Embedding(max_residues, hidden_size)
|
| 100 |
+
|
| 101 |
+
# Residue-level transformer
|
| 102 |
+
encoder_layer = nn.TransformerEncoderLayer(
|
| 103 |
+
d_model=hidden_size,
|
| 104 |
+
nhead=num_heads,
|
| 105 |
+
dim_feedforward=hidden_size * 4,
|
| 106 |
+
dropout=dropout,
|
| 107 |
+
activation='gelu',
|
| 108 |
+
batch_first=True
|
| 109 |
+
)
|
| 110 |
+
self.residue_transformer = nn.TransformerEncoder(encoder_layer, num_layers=num_residue_layers)
|
| 111 |
+
|
| 112 |
+
# Glycan-level attention pooling
|
| 113 |
+
self.glycan_attention = nn.Linear(hidden_size, 1)
|
| 114 |
+
|
| 115 |
+
# Optional: branch-aware attention (if glycan has branches)
|
| 116 |
+
self.branch_embedding = nn.Embedding(2, hidden_size) # 0=linear, 1=branch point
|
| 117 |
+
|
| 118 |
+
def forward(
|
| 119 |
+
self,
|
| 120 |
+
token_hidden: torch.Tensor, # (batch, seq_len, hidden)
|
| 121 |
+
residue_ids: torch.Tensor, # (batch, seq_len)
|
| 122 |
+
attention_mask: torch.Tensor, # (batch, seq_len)
|
| 123 |
+
is_branch_point: torch.Tensor = None # (batch, max_residues) optional
|
| 124 |
+
) -> Tuple[torch.Tensor, torch.Tensor]:
|
| 125 |
+
"""
|
| 126 |
+
Hierarchical encoding: tokens → residues → glycan.
|
| 127 |
+
|
| 128 |
+
Returns:
|
| 129 |
+
residue_hidden: (batch, max_residues, hidden) - Residue-level representations
|
| 130 |
+
glycan_hidden: (batch, hidden) - Glycan-level representation
|
| 131 |
+
"""
|
| 132 |
+
batch_size = token_hidden.size(0)
|
| 133 |
+
device = token_hidden.device
|
| 134 |
+
|
| 135 |
+
# Step 1: Pool tokens within each residue
|
| 136 |
+
residue_hidden = torch.zeros(batch_size, self.max_residues, self.hidden_size, device=device)
|
| 137 |
+
residue_mask = torch.zeros(batch_size, self.max_residues, dtype=torch.bool, device=device)
|
| 138 |
+
|
| 139 |
+
for b in range(batch_size):
|
| 140 |
+
unique_residues = torch.unique(residue_ids[b])
|
| 141 |
+
# Only consider real residues (>= 0)
|
| 142 |
+
real_residues = unique_residues[unique_residues >= 0]
|
| 143 |
+
|
| 144 |
+
for i, rid in enumerate(real_residues[:self.max_residues]):
|
| 145 |
+
token_mask = (residue_ids[b] == rid) & (attention_mask[b] == 1)
|
| 146 |
+
if token_mask.sum() > 0:
|
| 147 |
+
# Mean pool tokens in this residue
|
| 148 |
+
residue_hidden[b, i] = token_hidden[b, token_mask].mean(dim=0)
|
| 149 |
+
residue_mask[b, i] = True
|
| 150 |
+
|
| 151 |
+
# Add residue position embeddings
|
| 152 |
+
positions = torch.arange(self.max_residues, device=device).unsqueeze(0).expand(batch_size, -1)
|
| 153 |
+
residue_hidden = residue_hidden + self.residue_pos_embedding(positions)
|
| 154 |
+
|
| 155 |
+
# Add branch embeddings if provided
|
| 156 |
+
if is_branch_point is not None:
|
| 157 |
+
residue_hidden = residue_hidden + self.branch_embedding(is_branch_point.long())
|
| 158 |
+
|
| 159 |
+
# Step 2: Apply residue-level transformer
|
| 160 |
+
# Create attention mask for transformer (True = ignore)
|
| 161 |
+
residue_attn_mask = ~residue_mask
|
| 162 |
+
residue_hidden = self.residue_transformer(
|
| 163 |
+
residue_hidden,
|
| 164 |
+
src_key_padding_mask=residue_attn_mask
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
# Step 3: Attention pooling to get glycan representation
|
| 168 |
+
scores = self.glycan_attention(residue_hidden).squeeze(-1) # (batch, max_residues)
|
| 169 |
+
scores = scores.masked_fill(~residue_mask, -1e9)
|
| 170 |
+
weights = F.softmax(scores, dim=1).unsqueeze(-1) # (batch, max_residues, 1)
|
| 171 |
+
glycan_hidden = (residue_hidden * weights).sum(dim=1) # (batch, hidden)
|
| 172 |
+
|
| 173 |
+
return residue_hidden, glycan_hidden
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
class CrossModalResidueLoss(nn.Module):
|
| 177 |
+
"""
|
| 178 |
+
Cross-modal contrastive loss at residue level.
|
| 179 |
+
|
| 180 |
+
This is our NOVEL contribution: align sequence residue representations
|
| 181 |
+
with 3D structure residue representations. GIFFLAR doesn't have access
|
| 182 |
+
to 3D structure, so this is our unique advantage.
|
| 183 |
+
|
| 184 |
+
For each residue in a glycan with 3D structure:
|
| 185 |
+
- Positive: The same residue from sequence and structure should be similar
|
| 186 |
+
- Negative: Different residues should be dissimilar
|
| 187 |
+
"""
|
| 188 |
+
|
| 189 |
+
def __init__(self, hidden_size: int, temperature: float = 0.07):
|
| 190 |
+
super().__init__()
|
| 191 |
+
self.temperature = temperature
|
| 192 |
+
|
| 193 |
+
# Project both modalities to shared space
|
| 194 |
+
self.seq_proj = nn.Linear(hidden_size, hidden_size)
|
| 195 |
+
self.struct_proj = nn.Linear(hidden_size, hidden_size)
|
| 196 |
+
|
| 197 |
+
def forward(
|
| 198 |
+
self,
|
| 199 |
+
seq_residue_hidden: torch.Tensor, # (batch, num_residues, hidden)
|
| 200 |
+
struct_residue_hidden: torch.Tensor, # (batch, num_residues, hidden)
|
| 201 |
+
residue_mask: torch.Tensor # (batch, num_residues) - which residues are valid
|
| 202 |
+
) -> torch.Tensor:
|
| 203 |
+
"""
|
| 204 |
+
Compute cross-modal contrastive loss.
|
| 205 |
+
|
| 206 |
+
For each valid residue, its sequence representation should be
|
| 207 |
+
similar to its structure representation (positive pair) and
|
| 208 |
+
dissimilar to other residues (negative pairs).
|
| 209 |
+
"""
|
| 210 |
+
batch_size, num_residues, hidden = seq_residue_hidden.shape
|
| 211 |
+
|
| 212 |
+
# Project to shared space
|
| 213 |
+
seq_proj = F.normalize(self.seq_proj(seq_residue_hidden), dim=-1)
|
| 214 |
+
struct_proj = F.normalize(self.struct_proj(struct_residue_hidden), dim=-1)
|
| 215 |
+
|
| 216 |
+
total_loss = 0.0
|
| 217 |
+
valid_count = 0
|
| 218 |
+
|
| 219 |
+
for b in range(batch_size):
|
| 220 |
+
valid_indices = residue_mask[b].nonzero(as_tuple=True)[0]
|
| 221 |
+
if len(valid_indices) < 2:
|
| 222 |
+
continue
|
| 223 |
+
|
| 224 |
+
# Get valid residue representations
|
| 225 |
+
seq_valid = seq_proj[b, valid_indices] # (n_valid, hidden)
|
| 226 |
+
struct_valid = struct_proj[b, valid_indices] # (n_valid, hidden)
|
| 227 |
+
|
| 228 |
+
# Compute similarity matrix
|
| 229 |
+
sim_matrix = torch.matmul(seq_valid, struct_valid.T) / self.temperature
|
| 230 |
+
|
| 231 |
+
# Labels: diagonal should be positive pairs
|
| 232 |
+
labels = torch.arange(len(valid_indices), device=sim_matrix.device)
|
| 233 |
+
|
| 234 |
+
# Cross-entropy loss (both directions)
|
| 235 |
+
loss_seq_to_struct = F.cross_entropy(sim_matrix, labels)
|
| 236 |
+
loss_struct_to_seq = F.cross_entropy(sim_matrix.T, labels)
|
| 237 |
+
|
| 238 |
+
total_loss += (loss_seq_to_struct + loss_struct_to_seq) / 2
|
| 239 |
+
valid_count += 1
|
| 240 |
+
|
| 241 |
+
if valid_count > 0:
|
| 242 |
+
return total_loss / valid_count
|
| 243 |
+
return torch.tensor(0.0, device=seq_residue_hidden.device)
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
# Test the modules
|
| 247 |
+
if __name__ == '__main__':
|
| 248 |
+
batch_size = 2
|
| 249 |
+
seq_len = 50
|
| 250 |
+
hidden_size = 768
|
| 251 |
+
max_residues = 8
|
| 252 |
+
|
| 253 |
+
# Create dummy data
|
| 254 |
+
token_hidden = torch.randn(batch_size, seq_len, hidden_size)
|
| 255 |
+
residue_ids = torch.zeros(batch_size, seq_len, dtype=torch.long)
|
| 256 |
+
# Simulate 4 residues per glycan
|
| 257 |
+
for b in range(batch_size):
|
| 258 |
+
for r in range(4):
|
| 259 |
+
residue_ids[b, 1 + r*10 : 1 + (r+1)*10] = r
|
| 260 |
+
residue_ids[b, 41:] = -2 # Linkages
|
| 261 |
+
residue_ids[:, 0] = -1 # START
|
| 262 |
+
residue_ids[:, -1] = -1 # END
|
| 263 |
+
attention_mask = torch.ones(batch_size, seq_len)
|
| 264 |
+
|
| 265 |
+
# Test HierarchicalGlycanEncoder
|
| 266 |
+
encoder = HierarchicalGlycanEncoder(hidden_size=hidden_size, max_residues=max_residues)
|
| 267 |
+
residue_hidden, glycan_hidden = encoder(token_hidden, residue_ids, attention_mask)
|
| 268 |
+
print(f"✓ HierarchicalGlycanEncoder: residue={residue_hidden.shape}, glycan={glycan_hidden.shape}")
|
| 269 |
+
|
| 270 |
+
# Test MonoTypePredictor
|
| 271 |
+
predictor = MonoTypePredictor(hidden_size=hidden_size)
|
| 272 |
+
logits = predictor(residue_hidden[:, :4, :]) # First 4 residues
|
| 273 |
+
print(f"✓ MonoTypePredictor: logits={logits.shape}")
|
| 274 |
+
|
| 275 |
+
# Test CrossModalResidueLoss
|
| 276 |
+
struct_hidden = torch.randn(batch_size, max_residues, hidden_size)
|
| 277 |
+
residue_mask = torch.zeros(batch_size, max_residues, dtype=torch.bool)
|
| 278 |
+
residue_mask[:, :4] = True # 4 valid residues
|
| 279 |
+
|
| 280 |
+
loss_fn = CrossModalResidueLoss(hidden_size=hidden_size)
|
| 281 |
+
loss = loss_fn(residue_hidden, struct_hidden, residue_mask)
|
| 282 |
+
print(f"✓ CrossModalResidueLoss: {loss.item():.4f}")
|
| 283 |
+
|
| 284 |
+
print("\n✓ All novel architecture components working!")
|
code/model/multimodal_glycan_bert_v3.py
ADDED
|
@@ -0,0 +1,1084 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Multimodal Glycan BERT Model v3
|
| 3 |
+
|
| 4 |
+
Extends GlycanBERT to handle three modalities:
|
| 5 |
+
- Sequence (WURCS atomic tokenization)
|
| 6 |
+
- MS (mass spectrometry peaks, RT, intensity)
|
| 7 |
+
- 3D structure (VQ-VAE discrete tokens, 4 per residue)
|
| 8 |
+
|
| 9 |
+
Each modality has its own encoder, with cross-attention for sequence-structure alignment.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import torch
|
| 13 |
+
import torch.nn as nn
|
| 14 |
+
from typing import Dict, Optional, Tuple
|
| 15 |
+
import math
|
| 16 |
+
|
| 17 |
+
try:
|
| 18 |
+
from .glycan_bert import GlycanBERTConfig, GlycanBERTEmbeddings, GlycanBERTLayer
|
| 19 |
+
except ImportError:
|
| 20 |
+
from glycan_bert import GlycanBERTConfig, GlycanBERTEmbeddings, GlycanBERTLayer
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class ConvGlycanBERTEmbeddings(nn.Module):
|
| 24 |
+
"""
|
| 25 |
+
Improved Convolutional front-end that mixes local WURCS context before the Transformer.
|
| 26 |
+
|
| 27 |
+
Key improvements over original:
|
| 28 |
+
1. Position embeddings added BEFORE convolution (provides spatial context to conv)
|
| 29 |
+
2. Residual connection (conv enriches embeddings rather than replacing them)
|
| 30 |
+
3. Multi-scale convolutions (kernel sizes 3, 5, 7) for better receptive field
|
| 31 |
+
4. Proper layer normalization on the residual path
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
def __init__(self, config):
|
| 35 |
+
super().__init__()
|
| 36 |
+
self.token_embeddings = nn.Embedding(
|
| 37 |
+
config.vocab_size, config.hidden_size, padding_idx=config.pad_token_id
|
| 38 |
+
)
|
| 39 |
+
self.position_embeddings = nn.Embedding(
|
| 40 |
+
config.max_position_embeddings, config.hidden_size
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
# NEW: Branch depth embeddings - encodes depth in glycan tree (0=root, 1=child, etc.)
|
| 44 |
+
max_branch_depth = getattr(config, "max_branch_depth", 8)
|
| 45 |
+
self.branch_embeddings = nn.Embedding(max_branch_depth, config.hidden_size)
|
| 46 |
+
|
| 47 |
+
# NEW: Linkage type embeddings - encodes chemistry of glycosidic bond
|
| 48 |
+
# 0=none, 1=1-3, 2=1-4, 3=1-6, etc.
|
| 49 |
+
num_linkage_types = getattr(config, "num_linkage_types", 9)
|
| 50 |
+
self.linkage_embeddings = nn.Embedding(num_linkage_types, config.hidden_size)
|
| 51 |
+
|
| 52 |
+
# Multi-scale convolutions for different receptive fields
|
| 53 |
+
kernel_size = getattr(config, "cnn_kernel_size", 3)
|
| 54 |
+
# Split channels evenly: 256 + 256 + 256 = 768 for hidden_size=768
|
| 55 |
+
channels_per_scale = config.hidden_size // 3
|
| 56 |
+
self.conv_layers = nn.ModuleList([
|
| 57 |
+
nn.Conv1d(
|
| 58 |
+
in_channels=config.hidden_size,
|
| 59 |
+
out_channels=channels_per_scale,
|
| 60 |
+
kernel_size=kernel_size + 2 * i, # Kernels: 3, 5, 7
|
| 61 |
+
padding=(kernel_size + 2 * i) // 2, # Same padding
|
| 62 |
+
)
|
| 63 |
+
for i in range(3)
|
| 64 |
+
])
|
| 65 |
+
self.conv_activation = nn.GELU()
|
| 66 |
+
self.conv_proj = nn.Linear(channels_per_scale * 3, config.hidden_size) # Project concatenated back
|
| 67 |
+
|
| 68 |
+
self.LayerNorm = nn.LayerNorm(config.hidden_size, eps=config.layer_norm_eps)
|
| 69 |
+
self.conv_norm = nn.LayerNorm(config.hidden_size, eps=config.layer_norm_eps)
|
| 70 |
+
self.dropout = nn.Dropout(config.hidden_dropout_prob)
|
| 71 |
+
self.register_buffer(
|
| 72 |
+
"position_ids",
|
| 73 |
+
torch.arange(config.max_position_embeddings).expand((1, -1)),
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
self.hidden_size = config.hidden_size
|
| 77 |
+
|
| 78 |
+
def forward(self, input_ids, branch_depths=None, linkage_types=None):
|
| 79 |
+
seq_len = input_ids.shape[1]
|
| 80 |
+
|
| 81 |
+
# Step 1: Token + Position embeddings FIRST (provides spatial context to conv)
|
| 82 |
+
x = self.token_embeddings(input_ids) # (batch, seq, hidden)
|
| 83 |
+
position_ids = self.position_ids[:, :seq_len]
|
| 84 |
+
x = x + self.position_embeddings(position_ids)
|
| 85 |
+
|
| 86 |
+
# NEW: Add branch depth embeddings (encodes tree structure)
|
| 87 |
+
if branch_depths is not None:
|
| 88 |
+
# Clamp to valid range
|
| 89 |
+
branch_depths = branch_depths.clamp(0, self.branch_embeddings.num_embeddings - 1)
|
| 90 |
+
x = x + self.branch_embeddings(branch_depths)
|
| 91 |
+
|
| 92 |
+
# NEW: Add linkage type embeddings (encodes bond chemistry)
|
| 93 |
+
if linkage_types is not None:
|
| 94 |
+
linkage_types = linkage_types.clamp(0, self.linkage_embeddings.num_embeddings - 1)
|
| 95 |
+
x = x + self.linkage_embeddings(linkage_types)
|
| 96 |
+
|
| 97 |
+
x = self.LayerNorm(x)
|
| 98 |
+
|
| 99 |
+
# Step 2: Multi-scale convolution with RESIDUAL connection
|
| 100 |
+
# Convolution expects (batch, hidden, seq)
|
| 101 |
+
conv_in = x.permute(0, 2, 1)
|
| 102 |
+
|
| 103 |
+
# Apply multi-scale convolutions and concatenate
|
| 104 |
+
conv_outputs = []
|
| 105 |
+
for conv in self.conv_layers:
|
| 106 |
+
conv_out = self.conv_activation(conv(conv_in))
|
| 107 |
+
conv_outputs.append(conv_out)
|
| 108 |
+
|
| 109 |
+
# Concatenate multi-scale features and project back
|
| 110 |
+
conv_out = torch.cat(conv_outputs, dim=1) # (batch, hidden, seq)
|
| 111 |
+
conv_out = conv_out.permute(0, 2, 1) # (batch, seq, hidden)
|
| 112 |
+
conv_out = self.conv_proj(conv_out) # Project to correct size
|
| 113 |
+
|
| 114 |
+
# Step 3: Residual connection - conv ENRICHES rather than replaces
|
| 115 |
+
x = self.conv_norm(x + self.dropout(conv_out))
|
| 116 |
+
|
| 117 |
+
return x
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def create_residue_level_mask(
|
| 121 |
+
seq_residue_ids: torch.Tensor, # (batch, N_seq)
|
| 122 |
+
struct_residue_ids: torch.Tensor # (batch, N_struct)
|
| 123 |
+
) -> torch.Tensor:
|
| 124 |
+
"""
|
| 125 |
+
Create residue-level attention mask for cross-attention.
|
| 126 |
+
|
| 127 |
+
Maps WURCS tokens to VQ-VAE structural tokens based on residue IDs.
|
| 128 |
+
A WURCS token with residue_id=0 can only attend to VQ-VAE tokens with residue_id=0.
|
| 129 |
+
|
| 130 |
+
Args:
|
| 131 |
+
seq_residue_ids: Residue IDs for sequence tokens (batch, N_seq)
|
| 132 |
+
struct_residue_ids: Residue IDs for structural tokens (batch, N_struct)
|
| 133 |
+
|
| 134 |
+
Returns:
|
| 135 |
+
Boolean mask (batch, N_seq, N_struct) where True = can attend
|
| 136 |
+
"""
|
| 137 |
+
# Expand dimensions for broadcasting
|
| 138 |
+
# seq: (batch, N_seq, 1)
|
| 139 |
+
# struct: (batch, 1, N_struct)
|
| 140 |
+
mask = seq_residue_ids.unsqueeze(2) == struct_residue_ids.unsqueeze(1)
|
| 141 |
+
# Shape: (batch, N_seq, N_struct)
|
| 142 |
+
|
| 143 |
+
# Mask out structural tokens (residue_id = -1) and MS tokens (residue_id = -2)
|
| 144 |
+
# Only tokens with residue_id >= 0 can attend
|
| 145 |
+
mask &= (seq_residue_ids.unsqueeze(2) >= 0)
|
| 146 |
+
|
| 147 |
+
return mask # True = can attend, False = cannot attend
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
class MultimodalGlycanBERTConfig:
|
| 151 |
+
"""Configuration for Multimodal GlycanBERT v3."""
|
| 152 |
+
|
| 153 |
+
def __init__(
|
| 154 |
+
self,
|
| 155 |
+
# Sequence modality
|
| 156 |
+
seq_vocab_size: int = 166,
|
| 157 |
+
seq_hidden_size: int = 768,
|
| 158 |
+
seq_num_layers: int = 12,
|
| 159 |
+
seq_num_heads: int = 12,
|
| 160 |
+
seq_max_length: int = 512,
|
| 161 |
+
|
| 162 |
+
# MS modality
|
| 163 |
+
ms_vocab_size: int = 242,
|
| 164 |
+
ms_hidden_size: int = 384,
|
| 165 |
+
ms_num_layers: int = 6,
|
| 166 |
+
ms_num_heads: int = 6,
|
| 167 |
+
ms_max_length: int = 150,
|
| 168 |
+
|
| 169 |
+
# 3D structure modality
|
| 170 |
+
struct_vocab_size: int = 1024, # VQ-VAE codebook size
|
| 171 |
+
struct_hidden_size: int = 512,
|
| 172 |
+
struct_num_layers: int = 8,
|
| 173 |
+
struct_num_heads: int = 8,
|
| 174 |
+
struct_max_length: int = 200,
|
| 175 |
+
use_3d: bool = True,
|
| 176 |
+
|
| 177 |
+
# Cross-attention
|
| 178 |
+
use_cross_attention: bool = True,
|
| 179 |
+
cross_attn_num_heads: int = 8,
|
| 180 |
+
|
| 181 |
+
# Fusion
|
| 182 |
+
fusion_hidden_size: int = 768,
|
| 183 |
+
fusion_num_layers: int = 2,
|
| 184 |
+
|
| 185 |
+
# Training
|
| 186 |
+
hidden_dropout_prob: float = 0.1,
|
| 187 |
+
attention_probs_dropout_prob: float = 0.1,
|
| 188 |
+
layer_norm_eps: float = 1e-12,
|
| 189 |
+
initializer_range: float = 0.02,
|
| 190 |
+
|
| 191 |
+
# Conv front-end
|
| 192 |
+
use_cnn_frontend: bool = True,
|
| 193 |
+
cnn_kernel_size: int = 3,
|
| 194 |
+
|
| 195 |
+
# Loss weights
|
| 196 |
+
seq_loss_weight: float = 0.60,
|
| 197 |
+
ms_loss_weight: float = 0.15,
|
| 198 |
+
struct_loss_weight: float = 0.25,
|
| 199 |
+
dist_loss_weight: float = 0.25,
|
| 200 |
+
|
| 201 |
+
# Token IDs
|
| 202 |
+
pad_token_id: int = 0,
|
| 203 |
+
mask_token_id: int = 1,
|
| 204 |
+
):
|
| 205 |
+
# Sequence config
|
| 206 |
+
self.seq_vocab_size = seq_vocab_size
|
| 207 |
+
self.seq_hidden_size = seq_hidden_size
|
| 208 |
+
self.seq_num_layers = seq_num_layers
|
| 209 |
+
self.seq_num_heads = seq_num_heads
|
| 210 |
+
self.seq_max_length = seq_max_length
|
| 211 |
+
|
| 212 |
+
# MS config
|
| 213 |
+
self.ms_vocab_size = ms_vocab_size
|
| 214 |
+
self.ms_vocab_offset = seq_vocab_size # MS tokens start at 166
|
| 215 |
+
self.ms_total_vocab_size = seq_vocab_size + ms_vocab_size # 408 total
|
| 216 |
+
self.ms_hidden_size = ms_hidden_size
|
| 217 |
+
self.ms_num_layers = ms_num_layers
|
| 218 |
+
self.ms_num_heads = ms_num_heads
|
| 219 |
+
self.ms_max_length = ms_max_length
|
| 220 |
+
|
| 221 |
+
# Structure config
|
| 222 |
+
self.struct_vocab_size = struct_vocab_size
|
| 223 |
+
self.struct_hidden_size = struct_hidden_size
|
| 224 |
+
self.struct_num_layers = struct_num_layers
|
| 225 |
+
self.struct_num_heads = struct_num_heads
|
| 226 |
+
self.struct_max_length = struct_max_length
|
| 227 |
+
self.use_3d = use_3d
|
| 228 |
+
|
| 229 |
+
# Cross-attention config
|
| 230 |
+
self.use_cross_attention = use_cross_attention
|
| 231 |
+
self.cross_attn_num_heads = cross_attn_num_heads
|
| 232 |
+
|
| 233 |
+
# Fusion config
|
| 234 |
+
self.fusion_hidden_size = fusion_hidden_size
|
| 235 |
+
self.fusion_num_layers = fusion_num_layers
|
| 236 |
+
|
| 237 |
+
# Training config
|
| 238 |
+
self.hidden_dropout_prob = hidden_dropout_prob
|
| 239 |
+
self.attention_probs_dropout_prob = attention_probs_dropout_prob
|
| 240 |
+
self.layer_norm_eps = layer_norm_eps
|
| 241 |
+
self.initializer_range = initializer_range
|
| 242 |
+
|
| 243 |
+
# Conv front-end
|
| 244 |
+
self.use_cnn_frontend = use_cnn_frontend
|
| 245 |
+
self.cnn_kernel_size = cnn_kernel_size
|
| 246 |
+
|
| 247 |
+
# Loss weights
|
| 248 |
+
self.seq_loss_weight = seq_loss_weight
|
| 249 |
+
self.ms_loss_weight = ms_loss_weight
|
| 250 |
+
self.struct_loss_weight = struct_loss_weight
|
| 251 |
+
self.dist_loss_weight = dist_loss_weight
|
| 252 |
+
|
| 253 |
+
# Token IDs
|
| 254 |
+
self.pad_token_id = pad_token_id
|
| 255 |
+
self.mask_token_id = mask_token_id
|
| 256 |
+
|
| 257 |
+
def to_seq_config(self) -> GlycanBERTConfig:
|
| 258 |
+
"""Convert to sequence-only config."""
|
| 259 |
+
return GlycanBERTConfig(
|
| 260 |
+
vocab_size=self.seq_vocab_size,
|
| 261 |
+
hidden_size=self.seq_hidden_size,
|
| 262 |
+
num_hidden_layers=self.seq_num_layers,
|
| 263 |
+
num_attention_heads=self.seq_num_heads,
|
| 264 |
+
intermediate_size=self.seq_hidden_size * 4,
|
| 265 |
+
hidden_dropout_prob=self.hidden_dropout_prob,
|
| 266 |
+
attention_probs_dropout_prob=self.attention_probs_dropout_prob,
|
| 267 |
+
max_position_embeddings=self.seq_max_length,
|
| 268 |
+
layer_norm_eps=self.layer_norm_eps,
|
| 269 |
+
pad_token_id=self.pad_token_id,
|
| 270 |
+
mask_token_id=self.mask_token_id,
|
| 271 |
+
initializer_range=self.initializer_range,
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
def to_ms_config(self) -> GlycanBERTConfig:
|
| 275 |
+
"""Convert to MS-only config."""
|
| 276 |
+
return GlycanBERTConfig(
|
| 277 |
+
vocab_size=self.ms_total_vocab_size,
|
| 278 |
+
hidden_size=self.ms_hidden_size,
|
| 279 |
+
num_hidden_layers=self.ms_num_layers,
|
| 280 |
+
num_attention_heads=self.ms_num_heads,
|
| 281 |
+
intermediate_size=self.ms_hidden_size * 4,
|
| 282 |
+
hidden_dropout_prob=self.hidden_dropout_prob,
|
| 283 |
+
attention_probs_dropout_prob=self.attention_probs_dropout_prob,
|
| 284 |
+
max_position_embeddings=self.ms_max_length,
|
| 285 |
+
layer_norm_eps=self.layer_norm_eps,
|
| 286 |
+
pad_token_id=self.pad_token_id,
|
| 287 |
+
mask_token_id=self.mask_token_id,
|
| 288 |
+
initializer_range=self.initializer_range,
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
def to_struct_config(self) -> GlycanBERTConfig:
|
| 292 |
+
"""Convert to structure-only config."""
|
| 293 |
+
return GlycanBERTConfig(
|
| 294 |
+
vocab_size=self.struct_vocab_size,
|
| 295 |
+
hidden_size=self.struct_hidden_size,
|
| 296 |
+
num_hidden_layers=self.struct_num_layers,
|
| 297 |
+
num_attention_heads=self.struct_num_heads,
|
| 298 |
+
intermediate_size=self.struct_hidden_size * 4,
|
| 299 |
+
hidden_dropout_prob=self.hidden_dropout_prob,
|
| 300 |
+
attention_probs_dropout_prob=self.attention_probs_dropout_prob,
|
| 301 |
+
max_position_embeddings=self.struct_max_length,
|
| 302 |
+
layer_norm_eps=self.layer_norm_eps,
|
| 303 |
+
pad_token_id=self.pad_token_id,
|
| 304 |
+
mask_token_id=self.mask_token_id,
|
| 305 |
+
initializer_range=self.initializer_range,
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
# =============================================================================
|
| 310 |
+
# Improvement #1: Monosaccharide-Level Pooling
|
| 311 |
+
# =============================================================================
|
| 312 |
+
|
| 313 |
+
class MonosaccharidePooling(nn.Module):
|
| 314 |
+
"""
|
| 315 |
+
Pool token representations to monosaccharide level, then aggregate.
|
| 316 |
+
|
| 317 |
+
This bridges the gap between token-level BERT and monosaccharide-level CNNs/GNNs.
|
| 318 |
+
Uses monosaccharide_indices from the data to know where each residue starts.
|
| 319 |
+
"""
|
| 320 |
+
|
| 321 |
+
def __init__(self, hidden_size: int, num_attention_heads: int = 8, dropout: float = 0.1):
|
| 322 |
+
super().__init__()
|
| 323 |
+
self.hidden_size = hidden_size
|
| 324 |
+
|
| 325 |
+
# Attention pooling over monosaccharide representations
|
| 326 |
+
self.mono_attention = nn.MultiheadAttention(
|
| 327 |
+
embed_dim=hidden_size,
|
| 328 |
+
num_heads=num_attention_heads,
|
| 329 |
+
dropout=dropout,
|
| 330 |
+
batch_first=True
|
| 331 |
+
)
|
| 332 |
+
self.mono_norm = nn.LayerNorm(hidden_size)
|
| 333 |
+
|
| 334 |
+
# Final aggregation to single glycan representation
|
| 335 |
+
self.glycan_query = nn.Parameter(torch.randn(1, 1, hidden_size) * 0.02)
|
| 336 |
+
self.glycan_attention = nn.MultiheadAttention(
|
| 337 |
+
embed_dim=hidden_size,
|
| 338 |
+
num_heads=num_attention_heads,
|
| 339 |
+
dropout=dropout,
|
| 340 |
+
batch_first=True
|
| 341 |
+
)
|
| 342 |
+
self.glycan_norm = nn.LayerNorm(hidden_size)
|
| 343 |
+
|
| 344 |
+
def forward(
|
| 345 |
+
self,
|
| 346 |
+
hidden_states: torch.Tensor, # (batch, seq_len, hidden)
|
| 347 |
+
residue_ids: torch.Tensor, # (batch, seq_len) - which residue each token belongs to
|
| 348 |
+
attention_mask: torch.Tensor = None, # (batch, seq_len)
|
| 349 |
+
) -> torch.Tensor:
|
| 350 |
+
"""
|
| 351 |
+
Pool tokens to monosaccharide level, then to glycan level.
|
| 352 |
+
|
| 353 |
+
Returns:
|
| 354 |
+
Glycan representation: (batch, hidden_size)
|
| 355 |
+
"""
|
| 356 |
+
batch_size = hidden_states.size(0)
|
| 357 |
+
device = hidden_states.device
|
| 358 |
+
|
| 359 |
+
# Get unique residue IDs per sample (excluding -1 padding)
|
| 360 |
+
max_residues = 50 # Reasonable max for glycans
|
| 361 |
+
|
| 362 |
+
# Pool tokens within each residue using mean pooling
|
| 363 |
+
mono_reps = torch.zeros(batch_size, max_residues, self.hidden_size, device=device)
|
| 364 |
+
mono_mask = torch.zeros(batch_size, max_residues, dtype=torch.bool, device=device)
|
| 365 |
+
|
| 366 |
+
for b in range(batch_size):
|
| 367 |
+
unique_residues = torch.unique(residue_ids[b][residue_ids[b] >= 0])
|
| 368 |
+
for i, rid in enumerate(unique_residues):
|
| 369 |
+
if i >= max_residues:
|
| 370 |
+
break
|
| 371 |
+
token_mask = residue_ids[b] == rid
|
| 372 |
+
if attention_mask is not None:
|
| 373 |
+
token_mask = token_mask & (attention_mask[b] > 0)
|
| 374 |
+
if token_mask.sum() > 0:
|
| 375 |
+
mono_reps[b, i] = hidden_states[b][token_mask].mean(dim=0)
|
| 376 |
+
mono_mask[b, i] = True
|
| 377 |
+
|
| 378 |
+
# Apply attention over monosaccharide representations
|
| 379 |
+
# Convert mask for attention: True = valid, need to invert for PyTorch
|
| 380 |
+
key_padding_mask = ~mono_mask # True = ignore
|
| 381 |
+
|
| 382 |
+
mono_out, _ = self.mono_attention(
|
| 383 |
+
mono_reps, mono_reps, mono_reps,
|
| 384 |
+
key_padding_mask=key_padding_mask
|
| 385 |
+
)
|
| 386 |
+
mono_out = self.mono_norm(mono_reps + mono_out)
|
| 387 |
+
|
| 388 |
+
# Aggregate to single glycan representation using learned query
|
| 389 |
+
glycan_query = self.glycan_query.expand(batch_size, -1, -1)
|
| 390 |
+
glycan_out, _ = self.glycan_attention(
|
| 391 |
+
glycan_query, mono_out, mono_out,
|
| 392 |
+
key_padding_mask=key_padding_mask
|
| 393 |
+
)
|
| 394 |
+
glycan_out = self.glycan_norm(glycan_query + glycan_out)
|
| 395 |
+
|
| 396 |
+
return glycan_out.squeeze(1) # (batch, hidden)
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
# =============================================================================
|
| 400 |
+
# Improvement #2: Residue Type Embeddings
|
| 401 |
+
# =============================================================================
|
| 402 |
+
|
| 403 |
+
# Common monosaccharide types vocabulary
|
| 404 |
+
MONOSACCHARIDE_VOCAB = {
|
| 405 |
+
'[PAD_MONO]': 0, '[UNK_MONO]': 1,
|
| 406 |
+
'Glc': 2, 'GlcNAc': 3, 'GlcA': 4, 'GlcN': 5,
|
| 407 |
+
'Gal': 6, 'GalNAc': 7, 'GalA': 8, 'GalN': 9,
|
| 408 |
+
'Man': 10, 'ManNAc': 11, 'ManA': 12, 'ManN': 13,
|
| 409 |
+
'Fuc': 14, 'Rha': 15, 'Xyl': 16, 'Ara': 17,
|
| 410 |
+
'Neu5Ac': 18, 'Neu5Gc': 19, 'Kdn': 20, 'Sia': 21,
|
| 411 |
+
'GalNAcA': 22, 'GlcNAcA': 23, 'IdoA': 24, 'GulA': 25,
|
| 412 |
+
'Rib': 26, 'Lyx': 27, 'All': 28, 'Alt': 29,
|
| 413 |
+
'Tal': 30, 'Ido': 31, 'Qui': 32, 'Oli': 33,
|
| 414 |
+
'Tyv': 34, 'Abe': 35, 'Par': 36, 'Dig': 37,
|
| 415 |
+
'Col': 38, 'Dha': 39, 'Kdo': 40, 'Hep': 41,
|
| 416 |
+
'NeuroGc': 42, 'Muramic': 43, 'LDManHep': 44, 'DDManHep': 45,
|
| 417 |
+
'Bac': 46, 'Pse': 47, 'Leg': 48, 'Aci': 49,
|
| 418 |
+
'6dTal': 50, 'Fru': 51, 'Tag': 52, 'Sor': 53,
|
| 419 |
+
'Psi': 54, 'Sed': 55, 'MurNAc': 56, 'MurNGc': 57,
|
| 420 |
+
'Api': 58, 'Erwiniose': 59, 'Yer': 60, 'Thre': 61,
|
| 421 |
+
# Add more as needed, up to ~70
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
class ResidueTypeEmbeddings(nn.Module):
|
| 426 |
+
"""
|
| 427 |
+
Learnable embeddings for monosaccharide types.
|
| 428 |
+
|
| 429 |
+
Instead of the model having to learn that 'a1221m' = Fucose from character patterns,
|
| 430 |
+
we explicitly add a Fucose embedding to all tokens belonging to that residue.
|
| 431 |
+
"""
|
| 432 |
+
|
| 433 |
+
def __init__(self, hidden_size: int, num_mono_types: int = 70):
|
| 434 |
+
super().__init__()
|
| 435 |
+
self.mono_embeddings = nn.Embedding(num_mono_types, hidden_size)
|
| 436 |
+
self.mono_vocab = MONOSACCHARIDE_VOCAB
|
| 437 |
+
self.hidden_size = hidden_size
|
| 438 |
+
|
| 439 |
+
def forward(
|
| 440 |
+
self,
|
| 441 |
+
token_embeddings: torch.Tensor, # (batch, seq_len, hidden)
|
| 442 |
+
residue_ids: torch.Tensor, # (batch, seq_len)
|
| 443 |
+
mono_type_ids: torch.Tensor = None, # (batch, max_residues) - monosaccharide type per residue
|
| 444 |
+
) -> torch.Tensor:
|
| 445 |
+
"""
|
| 446 |
+
Add residue type embeddings to token embeddings.
|
| 447 |
+
|
| 448 |
+
Args:
|
| 449 |
+
token_embeddings: Base token embeddings
|
| 450 |
+
residue_ids: Which residue each token belongs to (-1 for special tokens)
|
| 451 |
+
mono_type_ids: Monosaccharide type ID for each residue position
|
| 452 |
+
|
| 453 |
+
Returns:
|
| 454 |
+
Enhanced embeddings with residue type information
|
| 455 |
+
"""
|
| 456 |
+
if mono_type_ids is None:
|
| 457 |
+
return token_embeddings
|
| 458 |
+
|
| 459 |
+
batch_size, seq_len, _ = token_embeddings.shape
|
| 460 |
+
enhanced = token_embeddings.clone()
|
| 461 |
+
|
| 462 |
+
# Add mono type embedding to each token based on its residue
|
| 463 |
+
for b in range(batch_size):
|
| 464 |
+
for pos in range(seq_len):
|
| 465 |
+
rid = residue_ids[b, pos].item()
|
| 466 |
+
if rid >= 0 and rid < mono_type_ids.size(1):
|
| 467 |
+
mono_id = mono_type_ids[b, rid]
|
| 468 |
+
enhanced[b, pos] = enhanced[b, pos] + self.mono_embeddings(mono_id)
|
| 469 |
+
|
| 470 |
+
return enhanced
|
| 471 |
+
|
| 472 |
+
@staticmethod
|
| 473 |
+
def get_mono_type_id(mono_name: str) -> int:
|
| 474 |
+
"""Convert monosaccharide name to type ID."""
|
| 475 |
+
return MONOSACCHARIDE_VOCAB.get(mono_name, MONOSACCHARIDE_VOCAB['[UNK_MONO]'])
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
# =============================================================================
|
| 479 |
+
# Improvement #4: Relative Position Encoding for Glycan Trees
|
| 480 |
+
# =============================================================================
|
| 481 |
+
|
| 482 |
+
class RelativePositionBias(nn.Module):
|
| 483 |
+
"""
|
| 484 |
+
Compute relative position bias for attention based on residue IDs.
|
| 485 |
+
|
| 486 |
+
Tokens in the same residue get distance 0.
|
| 487 |
+
Tokens in adjacent residues get distance ±1.
|
| 488 |
+
This helps the model understand glycan tree structure.
|
| 489 |
+
"""
|
| 490 |
+
|
| 491 |
+
def __init__(self, num_heads: int, max_distance: int = 10):
|
| 492 |
+
super().__init__()
|
| 493 |
+
self.num_heads = num_heads
|
| 494 |
+
self.max_distance = max_distance
|
| 495 |
+
|
| 496 |
+
# Learnable bias for each relative distance (-max to +max)
|
| 497 |
+
num_distances = 2 * max_distance + 1
|
| 498 |
+
self.relative_bias = nn.Embedding(num_distances, num_heads)
|
| 499 |
+
|
| 500 |
+
def forward(self, residue_ids: torch.Tensor) -> torch.Tensor:
|
| 501 |
+
"""
|
| 502 |
+
Compute relative position bias.
|
| 503 |
+
|
| 504 |
+
Args:
|
| 505 |
+
residue_ids: (batch, seq_len)
|
| 506 |
+
|
| 507 |
+
Returns:
|
| 508 |
+
Bias to add to attention scores: (batch, num_heads, seq_len, seq_len)
|
| 509 |
+
"""
|
| 510 |
+
# Compute pairwise residue distances
|
| 511 |
+
# (batch, seq_len, 1) - (batch, 1, seq_len) = (batch, seq_len, seq_len)
|
| 512 |
+
distance = residue_ids.unsqueeze(2) - residue_ids.unsqueeze(1)
|
| 513 |
+
|
| 514 |
+
# Clamp to max distance range and shift to 0-indexed
|
| 515 |
+
distance_clamped = distance.clamp(-self.max_distance, self.max_distance)
|
| 516 |
+
distance_idx = distance_clamped + self.max_distance # Now 0 to 2*max_distance
|
| 517 |
+
|
| 518 |
+
# Look up bias: (batch, seq_len, seq_len, num_heads)
|
| 519 |
+
bias = self.relative_bias(distance_idx)
|
| 520 |
+
|
| 521 |
+
# Transpose to (batch, num_heads, seq_len, seq_len)
|
| 522 |
+
bias = bias.permute(0, 3, 1, 2)
|
| 523 |
+
|
| 524 |
+
return bias
|
| 525 |
+
|
| 526 |
+
|
| 527 |
+
class CrossAttentionLayer(nn.Module):
|
| 528 |
+
"""
|
| 529 |
+
Cross-attention layer for sequence-structure alignment.
|
| 530 |
+
|
| 531 |
+
Allows sequence tokens to attend to structural atoms using attention masks.
|
| 532 |
+
"""
|
| 533 |
+
|
| 534 |
+
def __init__(self, config: MultimodalGlycanBERTConfig):
|
| 535 |
+
super().__init__()
|
| 536 |
+
self.num_heads = config.cross_attn_num_heads
|
| 537 |
+
self.hidden_size = config.seq_hidden_size
|
| 538 |
+
self.head_dim = self.hidden_size // self.num_heads
|
| 539 |
+
|
| 540 |
+
assert self.hidden_size % self.num_heads == 0, "hidden_size must be divisible by num_heads"
|
| 541 |
+
|
| 542 |
+
# Query from sequence, Key/Value from structure (VQ-VAE tokens)
|
| 543 |
+
self.query = nn.Linear(config.seq_hidden_size, self.hidden_size)
|
| 544 |
+
self.key = nn.Linear(config.struct_hidden_size, self.hidden_size)
|
| 545 |
+
self.value = nn.Linear(config.struct_hidden_size, self.hidden_size)
|
| 546 |
+
|
| 547 |
+
self.output = nn.Linear(self.hidden_size, config.seq_hidden_size)
|
| 548 |
+
self.dropout = nn.Dropout(config.attention_probs_dropout_prob)
|
| 549 |
+
self.layer_norm = nn.LayerNorm(config.seq_hidden_size, eps=config.layer_norm_eps)
|
| 550 |
+
|
| 551 |
+
def forward(
|
| 552 |
+
self,
|
| 553 |
+
seq_hidden: torch.Tensor, # (batch, seq_len, seq_hidden)
|
| 554 |
+
struct_hidden: torch.Tensor, # (batch, struct_len, struct_hidden)
|
| 555 |
+
attention_mask: Optional[torch.Tensor] = None, # (batch, seq_len, struct_len)
|
| 556 |
+
) -> torch.Tensor:
|
| 557 |
+
"""
|
| 558 |
+
Apply cross-attention from sequence to structure.
|
| 559 |
+
|
| 560 |
+
Args:
|
| 561 |
+
seq_hidden: Sequence hidden states
|
| 562 |
+
struct_hidden: Structure hidden states
|
| 563 |
+
attention_mask: Boolean mask (True = can attend, False = cannot attend)
|
| 564 |
+
|
| 565 |
+
Returns:
|
| 566 |
+
Updated sequence hidden states
|
| 567 |
+
"""
|
| 568 |
+
batch_size, seq_len, _ = seq_hidden.shape
|
| 569 |
+
struct_len = struct_hidden.shape[1]
|
| 570 |
+
|
| 571 |
+
# Project to Q, K, V
|
| 572 |
+
Q = self.query(seq_hidden) # (batch, seq_len, hidden)
|
| 573 |
+
K = self.key(struct_hidden) # (batch, struct_len, hidden)
|
| 574 |
+
V = self.value(struct_hidden) # (batch, struct_len, hidden)
|
| 575 |
+
|
| 576 |
+
# Reshape for multi-head attention
|
| 577 |
+
Q = Q.view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2) # (batch, heads, seq_len, head_dim)
|
| 578 |
+
K = K.view(batch_size, struct_len, self.num_heads, self.head_dim).transpose(1, 2) # (batch, heads, struct_len, head_dim)
|
| 579 |
+
V = V.view(batch_size, struct_len, self.num_heads, self.head_dim).transpose(1, 2) # (batch, heads, struct_len, head_dim)
|
| 580 |
+
|
| 581 |
+
# Compute attention scores
|
| 582 |
+
scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.head_dim) # (batch, heads, seq_len, struct_len)
|
| 583 |
+
|
| 584 |
+
# Apply attention mask
|
| 585 |
+
if attention_mask is not None:
|
| 586 |
+
# attention_mask: (batch, seq_len, struct_len) -> (batch, 1, seq_len, struct_len)
|
| 587 |
+
attention_mask = attention_mask.unsqueeze(1)
|
| 588 |
+
# Convert boolean mask to float: True -> 0.0, False -> -10000.0
|
| 589 |
+
attention_mask = (~attention_mask).float() * -10000.0
|
| 590 |
+
scores = scores + attention_mask
|
| 591 |
+
|
| 592 |
+
# Softmax and dropout
|
| 593 |
+
attn_weights = torch.softmax(scores, dim=-1) # (batch, heads, seq_len, struct_len)
|
| 594 |
+
attn_weights = self.dropout(attn_weights)
|
| 595 |
+
|
| 596 |
+
# Apply attention to values
|
| 597 |
+
context = torch.matmul(attn_weights, V) # (batch, heads, seq_len, head_dim)
|
| 598 |
+
|
| 599 |
+
# Reshape back
|
| 600 |
+
context = context.transpose(1, 2).contiguous().view(batch_size, seq_len, self.hidden_size)
|
| 601 |
+
|
| 602 |
+
# Output projection
|
| 603 |
+
output = self.output(context)
|
| 604 |
+
output = self.dropout(output)
|
| 605 |
+
|
| 606 |
+
# Residual connection + layer norm
|
| 607 |
+
output = self.layer_norm(seq_hidden + output)
|
| 608 |
+
|
| 609 |
+
return output
|
| 610 |
+
|
| 611 |
+
|
| 612 |
+
class MultimodalGlycanBERT(nn.Module):
|
| 613 |
+
"""
|
| 614 |
+
Multimodal BERT for glycan representation learning (v3).
|
| 615 |
+
|
| 616 |
+
Architecture:
|
| 617 |
+
1. Separate encoders for each modality (sequence, MS, 3D structure)
|
| 618 |
+
2. Cross-attention for sequence-structure alignment
|
| 619 |
+
3. Modality-specific MLM heads
|
| 620 |
+
4. Fusion layer for combined representation
|
| 621 |
+
"""
|
| 622 |
+
|
| 623 |
+
def __init__(self, config: MultimodalGlycanBERTConfig):
|
| 624 |
+
super().__init__()
|
| 625 |
+
self.config = config
|
| 626 |
+
|
| 627 |
+
# ===== Sequence Encoder =====
|
| 628 |
+
seq_config = config.to_seq_config()
|
| 629 |
+
seq_config.cnn_kernel_size = config.cnn_kernel_size
|
| 630 |
+
|
| 631 |
+
if config.use_cnn_frontend:
|
| 632 |
+
print(f"✅ Enabled Convolutional Front-End (Kernel={config.cnn_kernel_size})")
|
| 633 |
+
self.seq_embeddings = ConvGlycanBERTEmbeddings(seq_config)
|
| 634 |
+
else:
|
| 635 |
+
self.seq_embeddings = GlycanBERTEmbeddings(seq_config)
|
| 636 |
+
self.seq_layers = nn.ModuleList([GlycanBERTLayer(seq_config) for _ in range(seq_config.num_hidden_layers)])
|
| 637 |
+
self.seq_mlm_head = nn.Linear(seq_config.hidden_size, seq_config.vocab_size)
|
| 638 |
+
|
| 639 |
+
# ===== MS Encoder =====
|
| 640 |
+
ms_config = config.to_ms_config()
|
| 641 |
+
self.ms_embeddings = GlycanBERTEmbeddings(ms_config)
|
| 642 |
+
self.ms_layers = nn.ModuleList([GlycanBERTLayer(ms_config) for _ in range(ms_config.num_hidden_layers)])
|
| 643 |
+
self.ms_mlm_head = nn.Linear(ms_config.hidden_size, ms_config.vocab_size)
|
| 644 |
+
|
| 645 |
+
# ===== Structure Encoder (VQ-VAE tokens) =====
|
| 646 |
+
if config.use_3d:
|
| 647 |
+
struct_config = config.to_struct_config()
|
| 648 |
+
self.struct_embeddings = GlycanBERTEmbeddings(struct_config)
|
| 649 |
+
self.struct_layers = nn.ModuleList([GlycanBERTLayer(struct_config) for _ in range(struct_config.num_hidden_layers)])
|
| 650 |
+
self.struct_mlm_head = nn.Linear(struct_config.hidden_size, struct_config.vocab_size)
|
| 651 |
+
|
| 652 |
+
# Cross-attention layer (sequence → VQ-VAE structural tokens)
|
| 653 |
+
if config.use_cross_attention:
|
| 654 |
+
self.cross_attention = CrossAttentionLayer(config)
|
| 655 |
+
|
| 656 |
+
# ===== Projection layers (align hidden sizes) =====
|
| 657 |
+
if config.ms_hidden_size != config.seq_hidden_size:
|
| 658 |
+
self.ms_projection = nn.Linear(config.ms_hidden_size, config.seq_hidden_size)
|
| 659 |
+
else:
|
| 660 |
+
self.ms_projection = nn.Identity()
|
| 661 |
+
|
| 662 |
+
if config.use_3d and config.struct_hidden_size != config.seq_hidden_size:
|
| 663 |
+
self.struct_projection = nn.Linear(config.struct_hidden_size, config.seq_hidden_size)
|
| 664 |
+
else:
|
| 665 |
+
self.struct_projection = nn.Identity()
|
| 666 |
+
|
| 667 |
+
# ===== Fusion Layer =====
|
| 668 |
+
# Concatenate seq + ms + struct
|
| 669 |
+
fusion_input_size = config.seq_hidden_size * (3 if config.use_3d else 2)
|
| 670 |
+
self.fusion_layer = nn.Sequential(
|
| 671 |
+
nn.Linear(fusion_input_size, config.fusion_hidden_size),
|
| 672 |
+
nn.LayerNorm(config.fusion_hidden_size, eps=config.layer_norm_eps),
|
| 673 |
+
nn.GELU(),
|
| 674 |
+
nn.Dropout(config.hidden_dropout_prob),
|
| 675 |
+
nn.Linear(config.fusion_hidden_size, config.fusion_hidden_size),
|
| 676 |
+
)
|
| 677 |
+
|
| 678 |
+
# ===== Distance Prediction Head (Topology) =====
|
| 679 |
+
# OPTIMIZED: Project down to 128 dim first to save GPU memory
|
| 680 |
+
# (Batch, 256, 256, 768) -> (Batch, 256, 256, 128) reduces memory by 6x
|
| 681 |
+
self.dist_proj = nn.Linear(config.seq_hidden_size, 128)
|
| 682 |
+
self.distance_head = nn.Sequential(
|
| 683 |
+
nn.Linear(128, 64),
|
| 684 |
+
nn.ReLU(),
|
| 685 |
+
nn.Linear(64, 1)
|
| 686 |
+
)
|
| 687 |
+
|
| 688 |
+
# Initialize weights
|
| 689 |
+
self.apply(self._init_weights)
|
| 690 |
+
|
| 691 |
+
def _init_weights(self, module):
|
| 692 |
+
"""Initialize weights."""
|
| 693 |
+
if isinstance(module, nn.Linear):
|
| 694 |
+
module.weight.data.normal_(mean=0.0, std=self.config.initializer_range)
|
| 695 |
+
if module.bias is not None:
|
| 696 |
+
module.bias.data.zero_()
|
| 697 |
+
elif isinstance(module, nn.Embedding):
|
| 698 |
+
module.weight.data.normal_(mean=0.0, std=self.config.initializer_range)
|
| 699 |
+
if module.padding_idx is not None:
|
| 700 |
+
module.weight.data[module.padding_idx].zero_()
|
| 701 |
+
elif isinstance(module, nn.LayerNorm):
|
| 702 |
+
module.bias.data.zero_()
|
| 703 |
+
module.weight.data.fill_(1.0)
|
| 704 |
+
|
| 705 |
+
def forward(
|
| 706 |
+
self,
|
| 707 |
+
seq_token_ids: torch.Tensor,
|
| 708 |
+
seq_attention_mask: torch.Tensor,
|
| 709 |
+
seq_residue_ids: torch.Tensor,
|
| 710 |
+
seq_branch_depths: Optional[torch.Tensor] = None, # NEW: Branch depths
|
| 711 |
+
seq_linkage_types: Optional[torch.Tensor] = None, # NEW: Linkage types
|
| 712 |
+
ms_token_ids: torch.Tensor = None,
|
| 713 |
+
ms_attention_mask: torch.Tensor = None,
|
| 714 |
+
has_ms: torch.Tensor = None,
|
| 715 |
+
struct_token_ids: Optional[torch.Tensor] = None,
|
| 716 |
+
struct_attention_mask: Optional[torch.Tensor] = None,
|
| 717 |
+
struct_residue_ids: Optional[torch.Tensor] = None,
|
| 718 |
+
has_3d: Optional[torch.Tensor] = None,
|
| 719 |
+
seq_labels: Optional[torch.Tensor] = None,
|
| 720 |
+
ms_labels: Optional[torch.Tensor] = None,
|
| 721 |
+
struct_labels: Optional[torch.Tensor] = None,
|
| 722 |
+
dist_labels: Optional[torch.Tensor] = None, # NEW: Topology distance labels
|
| 723 |
+
return_dict: bool = True,
|
| 724 |
+
) -> Dict[str, torch.Tensor]:
|
| 725 |
+
"""
|
| 726 |
+
Forward pass for multimodal BERT v3.
|
| 727 |
+
|
| 728 |
+
Args:
|
| 729 |
+
seq_token_ids: (batch_size, seq_len) - Sequence token IDs
|
| 730 |
+
seq_attention_mask: (batch_size, seq_len) - Sequence attention mask
|
| 731 |
+
seq_residue_ids: (batch_size, seq_len) - Sequence token residue IDs
|
| 732 |
+
ms_token_ids: (batch_size, ms_len) - MS token IDs
|
| 733 |
+
ms_attention_mask: (batch_size, ms_len) - MS attention mask
|
| 734 |
+
has_ms: (batch_size,) - Boolean mask for samples with MS data
|
| 735 |
+
struct_token_ids: (batch_size, struct_len) - Structure VQ-VAE token IDs (optional)
|
| 736 |
+
struct_attention_mask: (batch_size, struct_len) - Structure attention mask (optional)
|
| 737 |
+
struct_residue_ids: (batch_size, struct_len) - Structure token residue IDs (optional)
|
| 738 |
+
has_3d: (batch_size,) - Boolean mask for samples with 3D data (optional)
|
| 739 |
+
seq_labels: (batch_size, seq_len) - Masked sequence labels (optional)
|
| 740 |
+
ms_labels: (batch_size, ms_len) - Masked MS labels (optional)
|
| 741 |
+
struct_labels: (batch_size, struct_len) - Masked structure labels (optional)
|
| 742 |
+
return_dict: Whether to return dict or tuple
|
| 743 |
+
|
| 744 |
+
Returns:
|
| 745 |
+
Dictionary containing logits, hidden states, losses, etc.
|
| 746 |
+
"""
|
| 747 |
+
batch_size = seq_token_ids.shape[0]
|
| 748 |
+
device = seq_token_ids.device
|
| 749 |
+
|
| 750 |
+
# ===== Sequence Encoder =====
|
| 751 |
+
# Pass branch_depths and linkage_types to embeddings for tree-aware encoding
|
| 752 |
+
seq_hidden = self.seq_embeddings(seq_token_ids, seq_branch_depths, seq_linkage_types)
|
| 753 |
+
for layer in self.seq_layers:
|
| 754 |
+
seq_hidden = layer(seq_hidden, seq_attention_mask)
|
| 755 |
+
|
| 756 |
+
seq_pooled = seq_hidden[:, 0, :] # [CLS] token
|
| 757 |
+
seq_logits = self.seq_mlm_head(seq_hidden)
|
| 758 |
+
|
| 759 |
+
# ===== Distance Predictions (Topology) =====
|
| 760 |
+
# Compute pairwise distance predictions
|
| 761 |
+
# MEMORY OPTIMIZATION: Project to 128-dim first
|
| 762 |
+
seq_hidden_small = self.dist_proj(seq_hidden) # (batch, seq_len, 128)
|
| 763 |
+
|
| 764 |
+
# Expand for pairwise: (batch, seq_len, 1, 128) - (batch, 1, seq_len, 128)
|
| 765 |
+
h_i = seq_hidden_small.unsqueeze(2)
|
| 766 |
+
h_j = seq_hidden_small.unsqueeze(1)
|
| 767 |
+
h_diff = torch.abs(h_i - h_j) # (batch, seq_len, seq_len, 128) - Much smaller!
|
| 768 |
+
dist_predictions = self.distance_head(h_diff) # (batch, seq_len, seq_len, 1)
|
| 769 |
+
|
| 770 |
+
# ===== MS Encoder =====
|
| 771 |
+
ms_hidden = None
|
| 772 |
+
ms_pooled = None
|
| 773 |
+
ms_logits = None
|
| 774 |
+
|
| 775 |
+
if ms_token_ids is not None:
|
| 776 |
+
ms_hidden = self.ms_embeddings(ms_token_ids)
|
| 777 |
+
for layer in self.ms_layers:
|
| 778 |
+
ms_hidden = layer(ms_hidden, ms_attention_mask)
|
| 779 |
+
|
| 780 |
+
ms_pooled = ms_hidden[:, 0, :] # [CLS] token
|
| 781 |
+
ms_logits = self.ms_mlm_head(ms_hidden)
|
| 782 |
+
|
| 783 |
+
# Zero out MS representations for samples without MS data
|
| 784 |
+
if has_ms is not None:
|
| 785 |
+
has_ms_expanded = has_ms.unsqueeze(1).float() # (batch, 1)
|
| 786 |
+
ms_pooled = ms_pooled * has_ms_expanded
|
| 787 |
+
|
| 788 |
+
# ===== Structure Encoder =====
|
| 789 |
+
struct_pooled = None
|
| 790 |
+
struct_logits = None
|
| 791 |
+
struct_hidden = None
|
| 792 |
+
|
| 793 |
+
if self.config.use_3d and struct_token_ids is not None:
|
| 794 |
+
struct_hidden = self.struct_embeddings(struct_token_ids)
|
| 795 |
+
for layer in self.struct_layers:
|
| 796 |
+
struct_hidden = layer(struct_hidden, struct_attention_mask)
|
| 797 |
+
|
| 798 |
+
struct_pooled = struct_hidden[:, 0, :] # [CLS] token
|
| 799 |
+
struct_logits = self.struct_mlm_head(struct_hidden)
|
| 800 |
+
|
| 801 |
+
# Zero out structure representations for samples without 3D data
|
| 802 |
+
if has_3d is not None:
|
| 803 |
+
has_3d_expanded = has_3d.unsqueeze(1).float() # (batch, 1)
|
| 804 |
+
struct_pooled = struct_pooled * has_3d_expanded
|
| 805 |
+
|
| 806 |
+
# ===== Cross-Attention (Sequence → VQ-VAE Structural Tokens) =====
|
| 807 |
+
# Use residue-level alignment between WURCS tokens and VQ-VAE tokens
|
| 808 |
+
if self.config.use_cross_attention and struct_residue_ids is not None:
|
| 809 |
+
# Create residue-level mask
|
| 810 |
+
# WURCS token with residue_id=0 → VQ-VAE tokens with residue_id=0
|
| 811 |
+
residue_mask = create_residue_level_mask(
|
| 812 |
+
seq_residue_ids=seq_residue_ids,
|
| 813 |
+
struct_residue_ids=struct_residue_ids,
|
| 814 |
+
) # (batch, N_seq, N_struct)
|
| 815 |
+
|
| 816 |
+
# Apply cross-attention: sequence tokens attend to VQ-VAE tokens
|
| 817 |
+
seq_hidden = self.cross_attention(
|
| 818 |
+
seq_hidden=seq_hidden,
|
| 819 |
+
struct_hidden=struct_hidden, # VQ-VAE token features
|
| 820 |
+
attention_mask=residue_mask, # Residue-based mask
|
| 821 |
+
)
|
| 822 |
+
|
| 823 |
+
# Update seq_pooled after cross-attention
|
| 824 |
+
seq_pooled = seq_hidden[:, 0, :]
|
| 825 |
+
|
| 826 |
+
# ===== Fusion =====
|
| 827 |
+
# Project to common hidden size
|
| 828 |
+
ms_pooled_projected = self.ms_projection(ms_pooled)
|
| 829 |
+
|
| 830 |
+
if self.config.use_3d and struct_pooled is not None:
|
| 831 |
+
struct_pooled_projected = self.struct_projection(struct_pooled)
|
| 832 |
+
combined = torch.cat([seq_pooled, ms_pooled_projected, struct_pooled_projected], dim=-1)
|
| 833 |
+
else:
|
| 834 |
+
combined = torch.cat([seq_pooled, ms_pooled_projected], dim=-1)
|
| 835 |
+
|
| 836 |
+
fused_repr = self.fusion_layer(combined)
|
| 837 |
+
|
| 838 |
+
# ===== Compute Losses =====
|
| 839 |
+
total_loss = None
|
| 840 |
+
seq_loss = None
|
| 841 |
+
ms_loss = None
|
| 842 |
+
struct_loss = None
|
| 843 |
+
dist_loss = None # NEW: Topology distance loss
|
| 844 |
+
|
| 845 |
+
if seq_labels is not None:
|
| 846 |
+
loss_fct = nn.CrossEntropyLoss(ignore_index=-100)
|
| 847 |
+
seq_loss = loss_fct(
|
| 848 |
+
seq_logits.view(-1, self.config.seq_vocab_size),
|
| 849 |
+
seq_labels.view(-1)
|
| 850 |
+
)
|
| 851 |
+
|
| 852 |
+
if ms_labels is not None:
|
| 853 |
+
ms_labels_masked = ms_labels.clone()
|
| 854 |
+
ms_labels_masked[~has_ms] = -100
|
| 855 |
+
# Only compute loss if there are valid labels (not all -100)
|
| 856 |
+
if (ms_labels_masked != -100).any():
|
| 857 |
+
loss_fct = nn.CrossEntropyLoss(ignore_index=-100)
|
| 858 |
+
ms_loss = loss_fct(
|
| 859 |
+
ms_logits.view(-1, self.config.ms_total_vocab_size),
|
| 860 |
+
ms_labels_masked.view(-1)
|
| 861 |
+
)
|
| 862 |
+
else:
|
| 863 |
+
ms_loss = torch.tensor(0.0, device=seq_token_ids.device)
|
| 864 |
+
|
| 865 |
+
if self.config.use_3d and struct_labels is not None and struct_logits is not None:
|
| 866 |
+
struct_labels_masked = struct_labels.clone()
|
| 867 |
+
if has_3d is not None:
|
| 868 |
+
struct_labels_masked[~has_3d] = -100
|
| 869 |
+
# Only compute loss if there are valid labels (not all -100)
|
| 870 |
+
if (struct_labels_masked != -100).any():
|
| 871 |
+
loss_fct = nn.CrossEntropyLoss(ignore_index=-100)
|
| 872 |
+
struct_loss = loss_fct(
|
| 873 |
+
struct_logits.view(-1, self.config.struct_vocab_size),
|
| 874 |
+
struct_labels_masked.view(-1)
|
| 875 |
+
)
|
| 876 |
+
else:
|
| 877 |
+
struct_loss = torch.tensor(0.0, device=seq_token_ids.device)
|
| 878 |
+
|
| 879 |
+
# ===== Distance Loss (Topology) =====
|
| 880 |
+
if dist_labels is not None:
|
| 881 |
+
# dist_predictions: (Batch, Seq, Seq, 1) -> (Batch, Seq, Seq)
|
| 882 |
+
preds = dist_predictions.squeeze(-1)
|
| 883 |
+
|
| 884 |
+
# Create mask for valid distance pairs (label != -1)
|
| 885 |
+
# Also respect attention mask to avoid padding
|
| 886 |
+
valid_mask = (dist_labels != -1) & (seq_attention_mask.unsqueeze(1) * seq_attention_mask.unsqueeze(2) == 1)
|
| 887 |
+
|
| 888 |
+
# DEBUG: Print once
|
| 889 |
+
if not hasattr(self, '_dist_debug_printed'):
|
| 890 |
+
print(f"[DIST DEBUG] dist_labels shape: {dist_labels.shape}, valid_mask.sum: {valid_mask.sum().item()}")
|
| 891 |
+
self._dist_debug_printed = True
|
| 892 |
+
|
| 893 |
+
if valid_mask.sum() > 0:
|
| 894 |
+
# MSE loss on valid positions only
|
| 895 |
+
loss_fct = nn.MSELoss()
|
| 896 |
+
dist_loss = loss_fct(preds[valid_mask], dist_labels[valid_mask].float())
|
| 897 |
+
else:
|
| 898 |
+
dist_loss = torch.tensor(0.0, device=seq_token_ids.device)
|
| 899 |
+
else:
|
| 900 |
+
# DEBUG: dist_labels is None
|
| 901 |
+
if not hasattr(self, '_dist_none_printed'):
|
| 902 |
+
print("[DIST DEBUG] dist_labels is None!")
|
| 903 |
+
self._dist_none_printed = True
|
| 904 |
+
|
| 905 |
+
# Weighted combination
|
| 906 |
+
losses = []
|
| 907 |
+
if seq_loss is not None:
|
| 908 |
+
losses.append(self.config.seq_loss_weight * seq_loss)
|
| 909 |
+
if ms_loss is not None:
|
| 910 |
+
losses.append(self.config.ms_loss_weight * ms_loss)
|
| 911 |
+
if struct_loss is not None:
|
| 912 |
+
losses.append(self.config.struct_loss_weight * struct_loss)
|
| 913 |
+
if dist_loss is not None:
|
| 914 |
+
losses.append(self.config.dist_loss_weight * dist_loss)
|
| 915 |
+
|
| 916 |
+
if losses:
|
| 917 |
+
total_loss = sum(losses)
|
| 918 |
+
|
| 919 |
+
if return_dict:
|
| 920 |
+
return {
|
| 921 |
+
'loss': total_loss,
|
| 922 |
+
'seq_loss': seq_loss,
|
| 923 |
+
'ms_loss': ms_loss,
|
| 924 |
+
'struct_loss': struct_loss,
|
| 925 |
+
'dist_loss': dist_loss, # NEW: Topology loss
|
| 926 |
+
'seq_logits': seq_logits,
|
| 927 |
+
'ms_logits': ms_logits,
|
| 928 |
+
'struct_logits': struct_logits,
|
| 929 |
+
'dist_predictions': dist_predictions, # NEW: Distance predictions
|
| 930 |
+
'seq_hidden': seq_hidden,
|
| 931 |
+
'ms_hidden': ms_hidden,
|
| 932 |
+
'struct_hidden': struct_hidden,
|
| 933 |
+
'seq_pooled': seq_pooled,
|
| 934 |
+
'ms_pooled': ms_pooled,
|
| 935 |
+
'struct_pooled': struct_pooled,
|
| 936 |
+
'fused_repr': fused_repr,
|
| 937 |
+
}
|
| 938 |
+
else:
|
| 939 |
+
return (total_loss, seq_logits, ms_logits, struct_logits, fused_repr)
|
| 940 |
+
|
| 941 |
+
def get_multimodal_representation(
|
| 942 |
+
self,
|
| 943 |
+
seq_token_ids: torch.Tensor,
|
| 944 |
+
seq_attention_mask: torch.Tensor,
|
| 945 |
+
seq_residue_ids: torch.Tensor,
|
| 946 |
+
ms_token_ids: torch.Tensor,
|
| 947 |
+
ms_attention_mask: torch.Tensor,
|
| 948 |
+
has_ms: torch.Tensor,
|
| 949 |
+
struct_token_ids: Optional[torch.Tensor] = None,
|
| 950 |
+
struct_attention_mask: Optional[torch.Tensor] = None,
|
| 951 |
+
struct_residue_ids: Optional[torch.Tensor] = None,
|
| 952 |
+
has_3d: Optional[torch.Tensor] = None,
|
| 953 |
+
) -> torch.Tensor:
|
| 954 |
+
"""Get fused multimodal representation (for inference)."""
|
| 955 |
+
outputs = self.forward(
|
| 956 |
+
seq_token_ids=seq_token_ids,
|
| 957 |
+
seq_attention_mask=seq_attention_mask,
|
| 958 |
+
seq_residue_ids=seq_residue_ids,
|
| 959 |
+
ms_token_ids=ms_token_ids,
|
| 960 |
+
ms_attention_mask=ms_attention_mask,
|
| 961 |
+
has_ms=has_ms,
|
| 962 |
+
struct_token_ids=struct_token_ids,
|
| 963 |
+
struct_attention_mask=struct_attention_mask,
|
| 964 |
+
struct_residue_ids=struct_residue_ids,
|
| 965 |
+
has_3d=has_3d,
|
| 966 |
+
return_dict=True,
|
| 967 |
+
)
|
| 968 |
+
return outputs['fused_repr']
|
| 969 |
+
|
| 970 |
+
|
| 971 |
+
if __name__ == "__main__":
|
| 972 |
+
# Test the model
|
| 973 |
+
print("="*80)
|
| 974 |
+
print("Testing Multimodal GlycanBERT v3")
|
| 975 |
+
print("="*80)
|
| 976 |
+
|
| 977 |
+
# Create config
|
| 978 |
+
config = MultimodalGlycanBERTConfig(
|
| 979 |
+
seq_vocab_size=166,
|
| 980 |
+
seq_hidden_size=768,
|
| 981 |
+
seq_num_layers=12,
|
| 982 |
+
seq_num_heads=12,
|
| 983 |
+
ms_vocab_size=242,
|
| 984 |
+
ms_hidden_size=384,
|
| 985 |
+
ms_num_layers=6,
|
| 986 |
+
ms_num_heads=6,
|
| 987 |
+
struct_vocab_size=1024,
|
| 988 |
+
struct_hidden_size=512,
|
| 989 |
+
struct_num_layers=8,
|
| 990 |
+
struct_num_heads=8,
|
| 991 |
+
use_3d=True,
|
| 992 |
+
use_cross_attention=True,
|
| 993 |
+
seq_loss_weight=0.60,
|
| 994 |
+
ms_loss_weight=0.15,
|
| 995 |
+
struct_loss_weight=0.25,
|
| 996 |
+
)
|
| 997 |
+
|
| 998 |
+
print(f"\nConfig:")
|
| 999 |
+
print(f" Sequence vocab: {config.seq_vocab_size}")
|
| 1000 |
+
print(f" MS vocab: {config.ms_vocab_size}")
|
| 1001 |
+
print(f" Structure vocab: {config.struct_vocab_size}")
|
| 1002 |
+
print(f" Loss weights: seq={config.seq_loss_weight}, ms={config.ms_loss_weight}, struct={config.struct_loss_weight}")
|
| 1003 |
+
|
| 1004 |
+
# Create model
|
| 1005 |
+
model = MultimodalGlycanBERT(config)
|
| 1006 |
+
|
| 1007 |
+
# Count parameters
|
| 1008 |
+
total_params = sum(p.numel() for p in model.parameters())
|
| 1009 |
+
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
|
| 1010 |
+
|
| 1011 |
+
print(f"\nModel Parameters:")
|
| 1012 |
+
print(f" Total: {total_params:,}")
|
| 1013 |
+
print(f" Trainable: {trainable_params:,}")
|
| 1014 |
+
|
| 1015 |
+
# Test forward pass
|
| 1016 |
+
print(f"\n{'='*80}")
|
| 1017 |
+
print("Testing Forward Pass (with Conv front-end)")
|
| 1018 |
+
print("="*80)
|
| 1019 |
+
|
| 1020 |
+
batch_size = 4
|
| 1021 |
+
seq_len = 128
|
| 1022 |
+
ms_len = 50
|
| 1023 |
+
struct_len = 40
|
| 1024 |
+
|
| 1025 |
+
# Create dummy inputs
|
| 1026 |
+
seq_token_ids = torch.randint(0, config.seq_vocab_size, (batch_size, seq_len))
|
| 1027 |
+
seq_attention_mask = torch.ones(batch_size, seq_len)
|
| 1028 |
+
# Approximate: ~5 tokens per residue
|
| 1029 |
+
seq_residue_ids = torch.div(
|
| 1030 |
+
torch.arange(seq_len), 5, rounding_mode="floor"
|
| 1031 |
+
).unsqueeze(0).expand(batch_size, -1)
|
| 1032 |
+
|
| 1033 |
+
ms_token_ids = torch.randint(config.ms_vocab_offset, config.ms_total_vocab_size, (batch_size, ms_len))
|
| 1034 |
+
ms_attention_mask = torch.ones(batch_size, ms_len)
|
| 1035 |
+
struct_token_ids = torch.randint(0, config.struct_vocab_size, (batch_size, struct_len))
|
| 1036 |
+
struct_attention_mask = torch.ones(batch_size, struct_len)
|
| 1037 |
+
# Approximate: 4 tokens per residue for VQ-VAE tokens
|
| 1038 |
+
struct_residue_ids = torch.div(
|
| 1039 |
+
torch.arange(struct_len), 4, rounding_mode="floor"
|
| 1040 |
+
).unsqueeze(0).expand(batch_size, -1)
|
| 1041 |
+
|
| 1042 |
+
has_ms = torch.tensor([True, True, False, True])
|
| 1043 |
+
has_3d = torch.tensor([True, False, True, True])
|
| 1044 |
+
|
| 1045 |
+
# Create labels for MLM
|
| 1046 |
+
seq_labels = seq_token_ids.clone()
|
| 1047 |
+
seq_labels[seq_labels != config.mask_token_id] = -100
|
| 1048 |
+
ms_labels = ms_token_ids.clone()
|
| 1049 |
+
ms_labels[ms_labels != config.mask_token_id] = -100
|
| 1050 |
+
struct_labels = struct_token_ids.clone()
|
| 1051 |
+
struct_labels[struct_labels != config.mask_token_id] = -100
|
| 1052 |
+
|
| 1053 |
+
# Forward pass
|
| 1054 |
+
outputs = model(
|
| 1055 |
+
seq_token_ids=seq_token_ids,
|
| 1056 |
+
seq_attention_mask=seq_attention_mask,
|
| 1057 |
+
seq_residue_ids=seq_residue_ids,
|
| 1058 |
+
ms_token_ids=ms_token_ids,
|
| 1059 |
+
ms_attention_mask=ms_attention_mask,
|
| 1060 |
+
has_ms=has_ms,
|
| 1061 |
+
struct_token_ids=struct_token_ids,
|
| 1062 |
+
struct_attention_mask=struct_attention_mask,
|
| 1063 |
+
struct_residue_ids=struct_residue_ids,
|
| 1064 |
+
has_3d=has_3d,
|
| 1065 |
+
seq_labels=seq_labels,
|
| 1066 |
+
ms_labels=ms_labels,
|
| 1067 |
+
struct_labels=struct_labels,
|
| 1068 |
+
)
|
| 1069 |
+
|
| 1070 |
+
print(f"\nOutput shapes:")
|
| 1071 |
+
print(f" seq_logits: {outputs['seq_logits'].shape}")
|
| 1072 |
+
print(f" ms_logits: {outputs['ms_logits'].shape}")
|
| 1073 |
+
print(f" struct_logits: {outputs['struct_logits'].shape}")
|
| 1074 |
+
print(f" fused_repr: {outputs['fused_repr'].shape}")
|
| 1075 |
+
|
| 1076 |
+
print(f"\nLosses:")
|
| 1077 |
+
print(f" Total loss: {outputs['loss'].item():.4f}")
|
| 1078 |
+
print(f" Sequence loss: {outputs['seq_loss'].item():.4f}")
|
| 1079 |
+
print(f" MS loss: {outputs['ms_loss'].item():.4f}")
|
| 1080 |
+
print(f" Structure loss: {outputs['struct_loss'].item():.4f}")
|
| 1081 |
+
|
| 1082 |
+
print(f"\n{'='*80}")
|
| 1083 |
+
print("Model Test Complete!")
|
| 1084 |
+
print("="*80)
|
code/model/tokenizer.py
ADDED
|
@@ -0,0 +1,612 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Standalone WURCS Tokenizer for Fine-tuning
|
| 3 |
+
|
| 4 |
+
This tokenizer matches the v3.1 pre-training tokenization exactly:
|
| 5 |
+
- Uses bracket-counting parser (handles '/' in modifications)
|
| 6 |
+
- 167 tokens (including 58 whole-token modifications)
|
| 7 |
+
- Same special tokens: [PAD], [UNK], [START], [END], [BRANCH_OPEN], [BRANCH_CLOSE], etc.
|
| 8 |
+
- Detects and inserts branch tokens based on WURCS topology
|
| 9 |
+
|
| 10 |
+
Usage:
|
| 11 |
+
tokenizer = WURCSTokenizer('data/vocabulary.json')
|
| 12 |
+
result = tokenizer.tokenize(wurcs_string)
|
| 13 |
+
# result = {'tokens': [...], 'token_ids': [...], 'length': N}
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
import re
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Dict, List, Optional, Tuple
|
| 20 |
+
from collections import defaultdict
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def parse_wurcs_sections(wurcs: str) -> List[str]:
|
| 24 |
+
"""
|
| 25 |
+
Parse WURCS string into sections by counting brackets.
|
| 26 |
+
|
| 27 |
+
This correctly handles modifications with '/' characters like NCC/3=O.
|
| 28 |
+
The simple regex approach fails because '/' appears inside modifications.
|
| 29 |
+
|
| 30 |
+
Args:
|
| 31 |
+
wurcs: WURCS string
|
| 32 |
+
|
| 33 |
+
Returns:
|
| 34 |
+
List of sections [version, counts, residues, topology, linkages]
|
| 35 |
+
"""
|
| 36 |
+
sections = []
|
| 37 |
+
current_section = []
|
| 38 |
+
bracket_depth = 0
|
| 39 |
+
|
| 40 |
+
for char in wurcs:
|
| 41 |
+
if char == '[':
|
| 42 |
+
bracket_depth += 1
|
| 43 |
+
current_section.append(char)
|
| 44 |
+
elif char == ']':
|
| 45 |
+
bracket_depth -= 1
|
| 46 |
+
current_section.append(char)
|
| 47 |
+
elif char == '/' and bracket_depth == 0:
|
| 48 |
+
# This '/' is a section separator (not inside a modification)
|
| 49 |
+
sections.append(''.join(current_section))
|
| 50 |
+
current_section = []
|
| 51 |
+
else:
|
| 52 |
+
current_section.append(char)
|
| 53 |
+
|
| 54 |
+
# Add last section
|
| 55 |
+
if current_section:
|
| 56 |
+
sections.append(''.join(current_section))
|
| 57 |
+
|
| 58 |
+
return sections
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def extract_residues_from_wurcs(wurcs: str) -> List[str]:
|
| 62 |
+
"""
|
| 63 |
+
Extract residue definitions from WURCS string.
|
| 64 |
+
|
| 65 |
+
Uses bracket-counting parser to correctly handle modifications
|
| 66 |
+
containing '/' characters (e.g., 2*NCC/3=O, 4*OSO/3=O/3=O).
|
| 67 |
+
|
| 68 |
+
Args:
|
| 69 |
+
wurcs: WURCS string
|
| 70 |
+
|
| 71 |
+
Returns:
|
| 72 |
+
List of residue strings (e.g., ['a2122h-1a_1-5_2*NCC/3=O', ...])
|
| 73 |
+
"""
|
| 74 |
+
sections = parse_wurcs_sections(wurcs)
|
| 75 |
+
|
| 76 |
+
if len(sections) < 3:
|
| 77 |
+
return []
|
| 78 |
+
|
| 79 |
+
# Section 2 contains residue definitions: [type1][type2]...
|
| 80 |
+
residues_section = sections[2]
|
| 81 |
+
|
| 82 |
+
# Find all residues [...]
|
| 83 |
+
residues = re.findall(r'\[([^\]]+)\]', residues_section)
|
| 84 |
+
|
| 85 |
+
return residues
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def extract_linkages_from_wurcs(wurcs: str) -> List[str]:
|
| 89 |
+
"""
|
| 90 |
+
Extract linkage patterns from WURCS string.
|
| 91 |
+
|
| 92 |
+
Uses bracket-counting parser to correctly handle modifications
|
| 93 |
+
containing '/' characters.
|
| 94 |
+
|
| 95 |
+
Args:
|
| 96 |
+
wurcs: WURCS string
|
| 97 |
+
|
| 98 |
+
Returns:
|
| 99 |
+
List of linkage strings (e.g., ['a4-b1', 'b3-c2'])
|
| 100 |
+
"""
|
| 101 |
+
sections = parse_wurcs_sections(wurcs)
|
| 102 |
+
|
| 103 |
+
if len(sections) < 5:
|
| 104 |
+
return []
|
| 105 |
+
|
| 106 |
+
# Section 4 contains linkages: a4-b1_b3-c2_...
|
| 107 |
+
linkage_section = sections[4]
|
| 108 |
+
|
| 109 |
+
# Split by underscore
|
| 110 |
+
linkages = linkage_section.split('_')
|
| 111 |
+
|
| 112 |
+
return [l for l in linkages if l]
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def detect_branch_points(linkages: List[str]) -> Tuple[Dict[str, List[str]], Dict[str, List[str]]]:
|
| 116 |
+
"""
|
| 117 |
+
Detect branch points from linkages.
|
| 118 |
+
A residue is a branch point if it has >1 outgoing connections.
|
| 119 |
+
|
| 120 |
+
Args:
|
| 121 |
+
linkages: List of linkage strings (e.g., ['a4-b1', 'a3-c2'])
|
| 122 |
+
|
| 123 |
+
Returns:
|
| 124 |
+
Tuple of:
|
| 125 |
+
- branch_points: Dict mapping residue letter to list of children (only for >1 children)
|
| 126 |
+
- outgoing: Dict mapping all residue letters to their children
|
| 127 |
+
"""
|
| 128 |
+
outgoing = defaultdict(list)
|
| 129 |
+
|
| 130 |
+
for link in linkages:
|
| 131 |
+
# Parse link like "a3-b1" or "a?-b1"
|
| 132 |
+
match = re.match(r'([a-z])([?\d]+)-([a-z])([?\d]+)', link)
|
| 133 |
+
if match:
|
| 134 |
+
from_res, from_pos, to_res, to_pos = match.groups()
|
| 135 |
+
outgoing[from_res].append(to_res)
|
| 136 |
+
|
| 137 |
+
# Branch points have >1 children
|
| 138 |
+
branch_points = {k: v for k, v in outgoing.items() if len(v) > 1}
|
| 139 |
+
return branch_points, dict(outgoing)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def compute_residue_depths(linkages: List[str]) -> Dict[str, int]:
|
| 143 |
+
"""
|
| 144 |
+
Compute depth of each residue in the glycan tree using BFS from root.
|
| 145 |
+
|
| 146 |
+
Root is typically 'a' (first residue). Depth 0 = root, 1 = children of root, etc.
|
| 147 |
+
|
| 148 |
+
Args:
|
| 149 |
+
linkages: List of linkage strings (e.g., ['a4-b1', 'b3-c1'])
|
| 150 |
+
|
| 151 |
+
Returns:
|
| 152 |
+
Dict mapping residue letter to its depth in the tree
|
| 153 |
+
"""
|
| 154 |
+
# Build parent-child graph from linkages
|
| 155 |
+
children = defaultdict(list)
|
| 156 |
+
all_residues = set()
|
| 157 |
+
|
| 158 |
+
for link in linkages:
|
| 159 |
+
match = re.match(r'([a-z])([?\d]+)-([a-z])([?\d]+)', link)
|
| 160 |
+
if match:
|
| 161 |
+
from_res, _, to_res, _ = match.groups()
|
| 162 |
+
children[from_res].append(to_res)
|
| 163 |
+
all_residues.add(from_res)
|
| 164 |
+
all_residues.add(to_res)
|
| 165 |
+
|
| 166 |
+
# Find root (residue with no parent) - usually 'a'
|
| 167 |
+
child_set = set()
|
| 168 |
+
for kids in children.values():
|
| 169 |
+
child_set.update(kids)
|
| 170 |
+
|
| 171 |
+
roots = all_residues - child_set
|
| 172 |
+
root = min(roots) if roots else 'a' # Default to 'a' if can't determine
|
| 173 |
+
|
| 174 |
+
# BFS from root to compute depths
|
| 175 |
+
depths = {root: 0}
|
| 176 |
+
queue = [root]
|
| 177 |
+
|
| 178 |
+
while queue:
|
| 179 |
+
current = queue.pop(0)
|
| 180 |
+
for child in children.get(current, []):
|
| 181 |
+
if child not in depths:
|
| 182 |
+
depths[child] = depths[current] + 1
|
| 183 |
+
queue.append(child)
|
| 184 |
+
|
| 185 |
+
return depths
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def parse_linkage_type(link: str) -> int:
|
| 189 |
+
"""
|
| 190 |
+
Parse a linkage string to extract linkage type ID.
|
| 191 |
+
|
| 192 |
+
Linkage types based on anomeric configuration and position:
|
| 193 |
+
- α1-2, α1-3, α1-4, α1-6, β1-2, β1-3, β1-4, β1-6, unknown
|
| 194 |
+
|
| 195 |
+
WURCS uses position numbers (1-6) in linkage patterns like 'a4-b1'.
|
| 196 |
+
We extract the positions to determine linkage type.
|
| 197 |
+
|
| 198 |
+
Args:
|
| 199 |
+
link: Linkage string like 'a4-b1' or 'a3-b1'
|
| 200 |
+
|
| 201 |
+
Returns:
|
| 202 |
+
Linkage type ID (0-8):
|
| 203 |
+
0 = 1-2, 1 = 1-3, 2 = 1-4, 3 = 1-6, 4 = 2-3, 5 = 2-6, 6 = 3-6, 7 = other, 8 = unknown
|
| 204 |
+
"""
|
| 205 |
+
LINKAGE_TYPES = {
|
| 206 |
+
(1, 2): 0, (2, 1): 0,
|
| 207 |
+
(1, 3): 1, (3, 1): 1,
|
| 208 |
+
(1, 4): 2, (4, 1): 2,
|
| 209 |
+
(1, 6): 3, (6, 1): 3,
|
| 210 |
+
(2, 3): 4, (3, 2): 4,
|
| 211 |
+
(2, 6): 5, (6, 2): 5,
|
| 212 |
+
(3, 6): 6, (6, 3): 6,
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
match = re.match(r'([a-z])([?\d]+)-([a-z])([?\d]+)', link)
|
| 216 |
+
if match:
|
| 217 |
+
_, from_pos, _, to_pos = match.groups()
|
| 218 |
+
try:
|
| 219 |
+
pos_tuple = (int(from_pos), int(to_pos))
|
| 220 |
+
return LINKAGE_TYPES.get(pos_tuple, 7) # 7 = other
|
| 221 |
+
except ValueError:
|
| 222 |
+
return 8 # Unknown (contains '?')
|
| 223 |
+
return 8
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
class WURCSTokenizer:
|
| 227 |
+
"""
|
| 228 |
+
WURCS tokenizer matching v3.1 pre-training exactly.
|
| 229 |
+
|
| 230 |
+
Key features:
|
| 231 |
+
- Bracket-counting parser for '/' handling in modifications
|
| 232 |
+
- Branch token insertion based on topology
|
| 233 |
+
- Whole-token modifications (58 total)
|
| 234 |
+
"""
|
| 235 |
+
|
| 236 |
+
def __init__(self, vocab_path: str):
|
| 237 |
+
"""
|
| 238 |
+
Initialize tokenizer with vocabulary.
|
| 239 |
+
|
| 240 |
+
Args:
|
| 241 |
+
vocab_path: Path to vocabulary.json (same as used in v3.1 pre-training)
|
| 242 |
+
"""
|
| 243 |
+
with open(vocab_path, 'r') as f:
|
| 244 |
+
self.vocab = json.load(f)
|
| 245 |
+
|
| 246 |
+
# Build token_to_id mapping
|
| 247 |
+
self.token_to_id = {}
|
| 248 |
+
|
| 249 |
+
# Add special tokens
|
| 250 |
+
for token, idx in self.vocab.get('special_tokens', {}).items():
|
| 251 |
+
self.token_to_id[token] = idx
|
| 252 |
+
|
| 253 |
+
# Add skeleton atoms
|
| 254 |
+
for token, idx in self.vocab.get('skeleton_atoms', {}).items():
|
| 255 |
+
self.token_to_id[token] = idx
|
| 256 |
+
|
| 257 |
+
# Add linkage atoms
|
| 258 |
+
for token, idx in self.vocab.get('linkage_atoms', {}).items():
|
| 259 |
+
self.token_to_id[token] = idx
|
| 260 |
+
|
| 261 |
+
# Add anomeric symbols
|
| 262 |
+
for token, idx in self.vocab.get('anomeric_symbols', {}).items():
|
| 263 |
+
self.token_to_id[token] = idx
|
| 264 |
+
|
| 265 |
+
# Add anomeric positions
|
| 266 |
+
for token, idx in self.vocab.get('anomeric_positions', {}).items():
|
| 267 |
+
self.token_to_id[token] = idx
|
| 268 |
+
|
| 269 |
+
# Add whole modifications
|
| 270 |
+
for token, idx in self.vocab.get('whole_modifications', {}).items():
|
| 271 |
+
self.token_to_id[token] = idx
|
| 272 |
+
|
| 273 |
+
# Build reverse mapping
|
| 274 |
+
self.id_to_token = {idx: token for token, idx in self.token_to_id.items()}
|
| 275 |
+
|
| 276 |
+
# Set special token IDs
|
| 277 |
+
self.pad_token_id = self.token_to_id.get('[PAD]', 0)
|
| 278 |
+
self.unk_token_id = self.token_to_id.get('[UNK]', 1)
|
| 279 |
+
self.start_token_id = self.token_to_id.get('[START]', 2)
|
| 280 |
+
self.end_token_id = self.token_to_id.get('[END]', 3)
|
| 281 |
+
self.branch_open_id = self.token_to_id.get('[BRANCH_OPEN]', 5)
|
| 282 |
+
self.branch_close_id = self.token_to_id.get('[BRANCH_CLOSE]', 6)
|
| 283 |
+
self.mod_token_id = self.token_to_id.get('[MOD]', 9)
|
| 284 |
+
self.link_token_id = self.token_to_id.get('[LINK]', 7)
|
| 285 |
+
|
| 286 |
+
self.vocab_size = self.vocab.get('metadata', {}).get('total_vocab_size', len(self.token_to_id))
|
| 287 |
+
|
| 288 |
+
def tokenize(self, wurcs: str, max_length: int = 512) -> Dict:
|
| 289 |
+
"""
|
| 290 |
+
Tokenize a WURCS string.
|
| 291 |
+
|
| 292 |
+
Matches v3.1 pre-training tokenization exactly:
|
| 293 |
+
- Bracket-counting parser for '/' handling
|
| 294 |
+
- Branch token insertion based on topology
|
| 295 |
+
- Whole-token modifications
|
| 296 |
+
|
| 297 |
+
Args:
|
| 298 |
+
wurcs: WURCS string to tokenize
|
| 299 |
+
max_length: Maximum sequence length (truncate if longer)
|
| 300 |
+
|
| 301 |
+
Returns:
|
| 302 |
+
Dict with:
|
| 303 |
+
- tokens: List of token strings
|
| 304 |
+
- token_ids: List of token IDs
|
| 305 |
+
- residue_ids: List of residue IDs for each token (-1=special, -2=linkage)
|
| 306 |
+
- monosaccharide_names: List of monosaccharide names for each residue
|
| 307 |
+
- length: Number of tokens
|
| 308 |
+
- attention_mask: 1 for real tokens, 0 for padding
|
| 309 |
+
- is_branched: Whether the glycan has branches
|
| 310 |
+
"""
|
| 311 |
+
all_tokens = []
|
| 312 |
+
all_token_ids = []
|
| 313 |
+
all_residue_ids = [] # Track which residue each token belongs to
|
| 314 |
+
all_branch_depths = [] # NEW: Branch depth for each token (0=root, 1=child, etc.)
|
| 315 |
+
all_linkage_types = [] # NEW: Linkage type for each token (0-8)
|
| 316 |
+
monosaccharide_names = [] # Names of monosaccharides in order
|
| 317 |
+
is_branched = False
|
| 318 |
+
residue_depths = {} # Will be computed from linkages
|
| 319 |
+
|
| 320 |
+
# Common monosaccharide patterns based on WURCS skeleton
|
| 321 |
+
MONO_PATTERNS = {
|
| 322 |
+
'a2122h': 'Glc', 'a2112h': 'Gal', 'a1221m': 'Fuc', 'a2211m': 'Rha',
|
| 323 |
+
'a212h': 'Xyl', 'a21d2h': 'Man', 'a2112m': 'Ara', 'a2d21h': 'Ido',
|
| 324 |
+
'axxxxh': 'Hex', 'Aad21122h': 'Neu5Ac', 'Aad21122h-2': 'Neu5Gc',
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
# Start token (residue_id = -1 for special tokens, branch_depth = 0)
|
| 328 |
+
all_tokens.append('[START]')
|
| 329 |
+
all_token_ids.append(self.start_token_id)
|
| 330 |
+
all_residue_ids.append(-1)
|
| 331 |
+
all_branch_depths.append(0) # Special tokens at depth 0
|
| 332 |
+
all_linkage_types.append(0) # No linkage for special tokens
|
| 333 |
+
|
| 334 |
+
try:
|
| 335 |
+
# Extract residues and linkages
|
| 336 |
+
residues = extract_residues_from_wurcs(wurcs)
|
| 337 |
+
linkages = extract_linkages_from_wurcs(wurcs)
|
| 338 |
+
|
| 339 |
+
# Detect branching and compute residue depths
|
| 340 |
+
branch_points, outgoing = detect_branch_points(linkages)
|
| 341 |
+
is_branched = len(branch_points) > 0
|
| 342 |
+
|
| 343 |
+
# NEW: Compute depth of each residue in the tree
|
| 344 |
+
residue_depths = compute_residue_depths(linkages)
|
| 345 |
+
|
| 346 |
+
# Get residue order from topology
|
| 347 |
+
sections = parse_wurcs_sections(wurcs)
|
| 348 |
+
topology = sections[3] if len(sections) > 3 else ""
|
| 349 |
+
residue_order = [int(x) for x in topology.split('-') if x.isdigit()]
|
| 350 |
+
|
| 351 |
+
# Tokenize each residue in order
|
| 352 |
+
residue_letters = 'abcdefghijklmnopqrstuvwxyz'
|
| 353 |
+
current_residue_id = 0
|
| 354 |
+
|
| 355 |
+
for i, res_idx in enumerate(residue_order):
|
| 356 |
+
if res_idx < 1 or res_idx > len(residues):
|
| 357 |
+
continue
|
| 358 |
+
|
| 359 |
+
residue = residues[res_idx - 1]
|
| 360 |
+
res_letter = residue_letters[i] if i < len(residue_letters) else 'z'
|
| 361 |
+
|
| 362 |
+
# Determine monosaccharide name from skeleton
|
| 363 |
+
mono_name = '<UNK>'
|
| 364 |
+
parts = residue.split('_')
|
| 365 |
+
if parts:
|
| 366 |
+
skeleton = parts[0].split('-')[0] if '-' in parts[0] else parts[0]
|
| 367 |
+
# Check for modifications (GlcNAc, GalNAc, etc.)
|
| 368 |
+
has_nac = any('NCC/3=O' in p or 'NAc' in p for p in parts)
|
| 369 |
+
has_s = any('OSO' in p for p in parts)
|
| 370 |
+
|
| 371 |
+
for pattern, name in MONO_PATTERNS.items():
|
| 372 |
+
if skeleton.startswith(pattern) or skeleton == pattern:
|
| 373 |
+
mono_name = name
|
| 374 |
+
break
|
| 375 |
+
|
| 376 |
+
# Adjust for modifications
|
| 377 |
+
if has_nac and mono_name in ['Glc', 'Gal']:
|
| 378 |
+
mono_name = mono_name + 'NAc'
|
| 379 |
+
elif has_s:
|
| 380 |
+
mono_name = mono_name + 'S'
|
| 381 |
+
|
| 382 |
+
monosaccharide_names.append(mono_name)
|
| 383 |
+
|
| 384 |
+
# Check if this is a branch point - add [BRANCH_OPEN]
|
| 385 |
+
# Get depth for this residue
|
| 386 |
+
res_depth = residue_depths.get(res_letter, 0)
|
| 387 |
+
|
| 388 |
+
if res_letter in branch_points:
|
| 389 |
+
all_tokens.append('[BRANCH_OPEN]')
|
| 390 |
+
all_token_ids.append(self.branch_open_id)
|
| 391 |
+
all_residue_ids.append(-1) # Branch tokens are special
|
| 392 |
+
all_branch_depths.append(res_depth)
|
| 393 |
+
all_linkage_types.append(0)
|
| 394 |
+
|
| 395 |
+
# Parse residue: skeleton-anomer_ring_mods
|
| 396 |
+
# Part 0: Skeleton and anomer (e.g., "a2122h-1b")
|
| 397 |
+
if parts:
|
| 398 |
+
main_part = parts[0]
|
| 399 |
+
for char in main_part:
|
| 400 |
+
if char in self.token_to_id:
|
| 401 |
+
all_tokens.append(char)
|
| 402 |
+
all_token_ids.append(self.token_to_id[char])
|
| 403 |
+
all_residue_ids.append(current_residue_id)
|
| 404 |
+
all_branch_depths.append(res_depth)
|
| 405 |
+
all_linkage_types.append(0) # Residue tokens have no linkage type
|
| 406 |
+
elif char == '-':
|
| 407 |
+
if '-' in self.token_to_id:
|
| 408 |
+
all_tokens.append('-')
|
| 409 |
+
all_token_ids.append(self.token_to_id['-'])
|
| 410 |
+
all_residue_ids.append(current_residue_id)
|
| 411 |
+
all_branch_depths.append(res_depth)
|
| 412 |
+
all_linkage_types.append(0)
|
| 413 |
+
else:
|
| 414 |
+
all_tokens.append('[UNK]')
|
| 415 |
+
all_token_ids.append(self.unk_token_id)
|
| 416 |
+
all_residue_ids.append(current_residue_id)
|
| 417 |
+
all_branch_depths.append(res_depth)
|
| 418 |
+
all_linkage_types.append(0)
|
| 419 |
+
|
| 420 |
+
# Parts 2+: Modifications (skip part 1 which is ring closure)
|
| 421 |
+
if len(parts) > 2:
|
| 422 |
+
for part in parts[2:]:
|
| 423 |
+
if '*' in part:
|
| 424 |
+
all_tokens.append('[MOD]')
|
| 425 |
+
all_token_ids.append(self.mod_token_id)
|
| 426 |
+
all_residue_ids.append(current_residue_id)
|
| 427 |
+
all_branch_depths.append(res_depth)
|
| 428 |
+
all_linkage_types.append(0)
|
| 429 |
+
|
| 430 |
+
if part in self.token_to_id:
|
| 431 |
+
all_tokens.append(part)
|
| 432 |
+
all_token_ids.append(self.token_to_id[part])
|
| 433 |
+
all_residue_ids.append(current_residue_id)
|
| 434 |
+
all_branch_depths.append(res_depth)
|
| 435 |
+
all_linkage_types.append(0)
|
| 436 |
+
else:
|
| 437 |
+
all_tokens.append('[UNK_MOD]')
|
| 438 |
+
all_token_ids.append(self.token_to_id.get('[UNK_MOD]', self.unk_token_id))
|
| 439 |
+
all_residue_ids.append(current_residue_id)
|
| 440 |
+
all_branch_depths.append(res_depth)
|
| 441 |
+
all_linkage_types.append(0)
|
| 442 |
+
|
| 443 |
+
current_residue_id += 1
|
| 444 |
+
|
| 445 |
+
# Add branch close tokens at the end
|
| 446 |
+
branch_opens = all_tokens.count('[BRANCH_OPEN]')
|
| 447 |
+
for _ in range(branch_opens):
|
| 448 |
+
all_tokens.append('[BRANCH_CLOSE]')
|
| 449 |
+
all_token_ids.append(self.branch_close_id)
|
| 450 |
+
all_residue_ids.append(-1)
|
| 451 |
+
all_branch_depths.append(0) # BRANCH_CLOSE at depth 0 (closing structure)
|
| 452 |
+
all_linkage_types.append(0)
|
| 453 |
+
|
| 454 |
+
# Tokenize linkages (residue_id = -2 for linkage tokens)
|
| 455 |
+
for linkage in linkages:
|
| 456 |
+
if not linkage:
|
| 457 |
+
continue
|
| 458 |
+
# Parse linkage type for this linkage
|
| 459 |
+
link_type = parse_linkage_type(linkage)
|
| 460 |
+
|
| 461 |
+
all_tokens.append('[LINK]')
|
| 462 |
+
all_token_ids.append(self.link_token_id)
|
| 463 |
+
all_residue_ids.append(-2)
|
| 464 |
+
all_branch_depths.append(0) # Linkages at depth 0 (structural)
|
| 465 |
+
all_linkage_types.append(link_type) # Actual linkage type here!
|
| 466 |
+
|
| 467 |
+
for char in linkage:
|
| 468 |
+
if char in self.token_to_id:
|
| 469 |
+
all_tokens.append(char)
|
| 470 |
+
all_token_ids.append(self.token_to_id[char])
|
| 471 |
+
all_residue_ids.append(-2)
|
| 472 |
+
all_branch_depths.append(0)
|
| 473 |
+
all_linkage_types.append(link_type)
|
| 474 |
+
else:
|
| 475 |
+
all_tokens.append('[UNK]')
|
| 476 |
+
all_token_ids.append(self.unk_token_id)
|
| 477 |
+
all_residue_ids.append(-2)
|
| 478 |
+
all_branch_depths.append(0)
|
| 479 |
+
all_linkage_types.append(link_type)
|
| 480 |
+
|
| 481 |
+
except Exception:
|
| 482 |
+
pass
|
| 483 |
+
|
| 484 |
+
# End token
|
| 485 |
+
all_tokens.append('[END]')
|
| 486 |
+
all_token_ids.append(self.end_token_id)
|
| 487 |
+
all_residue_ids.append(-1)
|
| 488 |
+
all_branch_depths.append(0)
|
| 489 |
+
all_linkage_types.append(0)
|
| 490 |
+
|
| 491 |
+
# Truncate if necessary
|
| 492 |
+
if len(all_token_ids) > max_length:
|
| 493 |
+
all_tokens = all_tokens[:max_length-1] + ['[END]']
|
| 494 |
+
all_token_ids = all_token_ids[:max_length-1] + [self.end_token_id]
|
| 495 |
+
all_residue_ids = all_residue_ids[:max_length-1] + [-1]
|
| 496 |
+
all_branch_depths = all_branch_depths[:max_length-1] + [0]
|
| 497 |
+
all_linkage_types = all_linkage_types[:max_length-1] + [0]
|
| 498 |
+
|
| 499 |
+
length = len(all_token_ids)
|
| 500 |
+
attention_mask = [1] * length
|
| 501 |
+
|
| 502 |
+
# Pad to max_length
|
| 503 |
+
padding_length = max_length - length
|
| 504 |
+
if padding_length > 0:
|
| 505 |
+
all_tokens = all_tokens + ['[PAD]'] * padding_length
|
| 506 |
+
all_token_ids = all_token_ids + [self.pad_token_id] * padding_length
|
| 507 |
+
all_residue_ids = all_residue_ids + [-1] * padding_length
|
| 508 |
+
all_branch_depths = all_branch_depths + [0] * padding_length
|
| 509 |
+
all_linkage_types = all_linkage_types + [0] * padding_length
|
| 510 |
+
attention_mask = attention_mask + [0] * padding_length
|
| 511 |
+
|
| 512 |
+
return {
|
| 513 |
+
'tokens': all_tokens,
|
| 514 |
+
'token_ids': all_token_ids,
|
| 515 |
+
'residue_ids': all_residue_ids,
|
| 516 |
+
'branch_depths': all_branch_depths, # NEW: Branch depth for each token
|
| 517 |
+
'linkage_types': all_linkage_types, # NEW: Linkage type for each token
|
| 518 |
+
'monosaccharide_names': monosaccharide_names,
|
| 519 |
+
'num_residues': len(monosaccharide_names),
|
| 520 |
+
'length': length,
|
| 521 |
+
'attention_mask': attention_mask,
|
| 522 |
+
'is_branched': is_branched,
|
| 523 |
+
'has_unk_mod': '[UNK_MOD]' in all_tokens[:length]
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
def batch_tokenize(self, wurcs_list: List[str], max_length: int = 512) -> Dict:
|
| 527 |
+
"""
|
| 528 |
+
Tokenize a batch of WURCS strings.
|
| 529 |
+
|
| 530 |
+
Args:
|
| 531 |
+
wurcs_list: List of WURCS strings
|
| 532 |
+
max_length: Maximum sequence length
|
| 533 |
+
|
| 534 |
+
Returns:
|
| 535 |
+
Dict with batched tensors (as lists)
|
| 536 |
+
"""
|
| 537 |
+
results = [self.tokenize(w, max_length) for w in wurcs_list]
|
| 538 |
+
|
| 539 |
+
return {
|
| 540 |
+
'token_ids': [r['token_ids'] for r in results],
|
| 541 |
+
'attention_mask': [r['attention_mask'] for r in results],
|
| 542 |
+
'lengths': [r['length'] for r in results]
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
def decode(self, token_ids: List[int]) -> str:
|
| 546 |
+
"""
|
| 547 |
+
Decode token IDs back to tokens (for debugging).
|
| 548 |
+
|
| 549 |
+
Args:
|
| 550 |
+
token_ids: List of token IDs
|
| 551 |
+
|
| 552 |
+
Returns:
|
| 553 |
+
Space-separated token string
|
| 554 |
+
"""
|
| 555 |
+
tokens = [self.id_to_token.get(tid, '[UNK]') for tid in token_ids]
|
| 556 |
+
# Remove padding
|
| 557 |
+
tokens = [t for t in tokens if t != '[PAD]']
|
| 558 |
+
return ' '.join(tokens)
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
def create_tokenizer(vocab_path: str = None) -> WURCSTokenizer:
|
| 562 |
+
"""
|
| 563 |
+
Create a tokenizer with default vocabulary path.
|
| 564 |
+
|
| 565 |
+
Args:
|
| 566 |
+
vocab_path: Optional path to vocabulary.json
|
| 567 |
+
|
| 568 |
+
Returns:
|
| 569 |
+
WURCSTokenizer instance
|
| 570 |
+
"""
|
| 571 |
+
if vocab_path is None:
|
| 572 |
+
# Try default locations
|
| 573 |
+
default_paths = [
|
| 574 |
+
Path(__file__).parent.parent.parent / 'data' / 'vocabulary.json',
|
| 575 |
+
Path(__file__).parent.parent / 'data' / 'vocabulary.json',
|
| 576 |
+
]
|
| 577 |
+
for p in default_paths:
|
| 578 |
+
if p.exists():
|
| 579 |
+
vocab_path = str(p)
|
| 580 |
+
break
|
| 581 |
+
else:
|
| 582 |
+
raise FileNotFoundError("vocabulary.json not found. Please specify path.")
|
| 583 |
+
|
| 584 |
+
return WURCSTokenizer(vocab_path)
|
| 585 |
+
|
| 586 |
+
|
| 587 |
+
if __name__ == '__main__':
|
| 588 |
+
# Test tokenizer
|
| 589 |
+
import sys
|
| 590 |
+
|
| 591 |
+
# Find vocab
|
| 592 |
+
vocab_path = Path(__file__).parent.parent.parent / 'data' / 'vocabulary.json'
|
| 593 |
+
if not vocab_path.exists():
|
| 594 |
+
print(f"Vocabulary not found at {vocab_path}")
|
| 595 |
+
sys.exit(1)
|
| 596 |
+
|
| 597 |
+
tokenizer = WURCSTokenizer(str(vocab_path))
|
| 598 |
+
print(f"Loaded tokenizer with {tokenizer.vocab_size} tokens")
|
| 599 |
+
|
| 600 |
+
# Test cases
|
| 601 |
+
test_cases = [
|
| 602 |
+
"WURCS=2.0/2,2,1/[a212h-1b_1-5][a2211m-1a_1-5]/1-2/a2-b1",
|
| 603 |
+
"WURCS=2.0/3,3,2/[a2122h-1b_1-5_2*NCC/3=O][a2112h-1a_1-5][a2211m-1a_1-5]/1-2-3/a4-b1_b3-c1",
|
| 604 |
+
]
|
| 605 |
+
|
| 606 |
+
for wurcs in test_cases:
|
| 607 |
+
result = tokenizer.tokenize(wurcs, max_length=64)
|
| 608 |
+
print(f"\nWURCS: {wurcs[:50]}...")
|
| 609 |
+
print(f" Length: {result['length']}")
|
| 610 |
+
print(f" Branched: {result['is_branched']}")
|
| 611 |
+
print(f" Tokens: {result['tokens'][:15]}...")
|
| 612 |
+
print(f" Token IDs: {result['token_ids'][:15]}...")
|
code/model/wurcs_bpe_tokenizer.py
ADDED
|
@@ -0,0 +1,740 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
WURCS-BPE Tokenizer
|
| 4 |
+
|
| 5 |
+
A hybrid tokenizer that learns semantic subwords from WURCS while preserving
|
| 6 |
+
the ability to handle rare/novel glycan structures character-by-character.
|
| 7 |
+
|
| 8 |
+
Key features:
|
| 9 |
+
1. Pre-tokenization: Split WURCS into semantic units (residues, linkages, mods)
|
| 10 |
+
2. BPE: Learn subword merges from corpus
|
| 11 |
+
3. Character fallback: Handle novel structures
|
| 12 |
+
4. Tree embeddings: Preserve branch_depth and linkage_type per token
|
| 13 |
+
|
| 14 |
+
Usage:
|
| 15 |
+
# Train BPE on corpus
|
| 16 |
+
tokenizer = WURCSBPETokenizer.train_from_corpus(
|
| 17 |
+
wurcs_strings,
|
| 18 |
+
num_merges=500,
|
| 19 |
+
output_path="bpe_vocabulary.json"
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
# Tokenize
|
| 23 |
+
result = tokenizer.tokenize(wurcs_string)
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
import json
|
| 27 |
+
import re
|
| 28 |
+
from collections import Counter, defaultdict
|
| 29 |
+
from pathlib import Path
|
| 30 |
+
from typing import Dict, List, Optional, Tuple, Set
|
| 31 |
+
import pickle
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class WURCSPreTokenizer:
|
| 35 |
+
"""
|
| 36 |
+
Pre-tokenize WURCS into semantic units before BPE.
|
| 37 |
+
|
| 38 |
+
WURCS format: WURCS=2.0/count/[residues]/indices/linkages
|
| 39 |
+
|
| 40 |
+
We split into:
|
| 41 |
+
- Residues: [a2122h-1b_1-5_2*NCC/3=O] -> one unit per []
|
| 42 |
+
- Linkages: a4-b1 -> one unit per linkage
|
| 43 |
+
- Special markers: [BRANCH_OPEN], [BRANCH_CLOSE], etc.
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
# Residue patterns for common monosaccharides
|
| 47 |
+
RESIDUE_PATTERN = re.compile(r'\[([^\]]+)\]')
|
| 48 |
+
LINKAGE_PATTERN = re.compile(r'([a-z])(\d+|\?)-([a-z])(\d+|\?)')
|
| 49 |
+
|
| 50 |
+
def __init__(self):
|
| 51 |
+
self.special_tokens = {
|
| 52 |
+
'[PAD]': 0,
|
| 53 |
+
'[UNK]': 1,
|
| 54 |
+
'[START]': 2,
|
| 55 |
+
'[END]': 3,
|
| 56 |
+
'[MASK]': 4,
|
| 57 |
+
'[BRANCH_OPEN]': 5,
|
| 58 |
+
'[BRANCH_CLOSE]': 6,
|
| 59 |
+
'[LINK]': 7,
|
| 60 |
+
'[MOD]': 8,
|
| 61 |
+
'[RESIDUE_ERROR]': 9,
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
def pre_tokenize(self, wurcs: str) -> List[Dict]:
|
| 65 |
+
"""
|
| 66 |
+
Pre-tokenize WURCS into semantic units.
|
| 67 |
+
|
| 68 |
+
Returns list of dicts with:
|
| 69 |
+
- text: The unit text
|
| 70 |
+
- type: 'special', 'residue', 'linkage', 'mod', 'index'
|
| 71 |
+
- residue_id: Which residue this belongs to (-1 for special, -2 for linkage)
|
| 72 |
+
- branch_depth: Tree depth (computed later)
|
| 73 |
+
"""
|
| 74 |
+
units = []
|
| 75 |
+
|
| 76 |
+
# Add start token
|
| 77 |
+
units.append({
|
| 78 |
+
'text': '[START]',
|
| 79 |
+
'type': 'special',
|
| 80 |
+
'residue_id': -1,
|
| 81 |
+
'branch_depth': 0,
|
| 82 |
+
'linkage_type': 0,
|
| 83 |
+
})
|
| 84 |
+
|
| 85 |
+
# Parse WURCS sections
|
| 86 |
+
if not wurcs.startswith('WURCS='):
|
| 87 |
+
units.append({'text': '[RESIDUE_ERROR]', 'type': 'special', 'residue_id': -1, 'branch_depth': 0, 'linkage_type': 0})
|
| 88 |
+
units.append({'text': '[END]', 'type': 'special', 'residue_id': -1, 'branch_depth': 0, 'linkage_type': 0})
|
| 89 |
+
return units
|
| 90 |
+
|
| 91 |
+
try:
|
| 92 |
+
parts = self._split_wurcs_sections(wurcs)
|
| 93 |
+
if len(parts) < 4:
|
| 94 |
+
return [{'text': '[ERROR]', 'type': 'special', 'residue_id': -1, 'branch_depth': 0, 'linkage_type': 0}]
|
| 95 |
+
|
| 96 |
+
# parts: WURCS=2.0/3,3,2/[a2122h-1b_1-5][a2122h-1a_1-5][a1122h-1b_1-5]/1-2-3-1/a4-b1_b3-c1_c4-d1
|
| 97 |
+
# section 2: residue definitions
|
| 98 |
+
# section 3: indices
|
| 99 |
+
# section 4: linkages (optional)
|
| 100 |
+
|
| 101 |
+
version = parts[0] # WURCS=2.0
|
| 102 |
+
counts = parts[1] # residue_count,node_count,link_count
|
| 103 |
+
residue_defs = parts[2] # [res1][res2]...
|
| 104 |
+
indices = parts[3] # 1-2-3-1
|
| 105 |
+
linkages = parts[4] if len(parts) > 4 else "" # a4-b1_b3-c1
|
| 106 |
+
|
| 107 |
+
# Parse residue definitions
|
| 108 |
+
residue_list = self.RESIDUE_PATTERN.findall(residue_defs)
|
| 109 |
+
|
| 110 |
+
# Parse linkages to compute branch structure
|
| 111 |
+
linkage_list = linkages.split('_') if linkages else []
|
| 112 |
+
branch_points, residue_depths, linkage_types_map, adj = self._analyze_tree_structure(linkage_list, num_residues=len(residue_list))
|
| 113 |
+
|
| 114 |
+
# Compute distance matrix and cache it based on the linkage string (structure)
|
| 115 |
+
# This is the most expensive part, so we cache it
|
| 116 |
+
if not hasattr(self, '_dist_cache'): self._dist_cache = {}
|
| 117 |
+
if linkages not in self._dist_cache:
|
| 118 |
+
self._dist_cache[linkages] = self._compute_distance_matrix(adj, len(residue_list))
|
| 119 |
+
dist_matrix_raw = self._dist_cache[linkages]
|
| 120 |
+
|
| 121 |
+
# Parse indices to map positions to residue definitions
|
| 122 |
+
index_list = indices.split('-') if indices else []
|
| 123 |
+
|
| 124 |
+
# Process each residue instance
|
| 125 |
+
residue_letter = ord('a')
|
| 126 |
+
for idx, res_idx in enumerate(index_list):
|
| 127 |
+
current_residue_id = idx
|
| 128 |
+
res_letter = chr(residue_letter + idx)
|
| 129 |
+
|
| 130 |
+
# Check if this is a branch point - add branch marker before
|
| 131 |
+
if res_letter in branch_points and branch_points[res_letter] > 0:
|
| 132 |
+
for _ in range(branch_points[res_letter]):
|
| 133 |
+
units.append({
|
| 134 |
+
'text': '[BRANCH_OPEN]',
|
| 135 |
+
'type': 'special',
|
| 136 |
+
'residue_id': -1,
|
| 137 |
+
'branch_depth': residue_depths.get(res_letter, 0),
|
| 138 |
+
'linkage_type': 0,
|
| 139 |
+
})
|
| 140 |
+
|
| 141 |
+
# Get residue definition
|
| 142 |
+
try:
|
| 143 |
+
res_def_idx = int(res_idx) - 1 # 1-indexed to 0-indexed
|
| 144 |
+
res_def = residue_list[res_def_idx] if res_def_idx < len(residue_list) else ""
|
| 145 |
+
except (ValueError, IndexError):
|
| 146 |
+
res_def = ""
|
| 147 |
+
|
| 148 |
+
# Split residue into base and modifications
|
| 149 |
+
res_parts = res_def.split('_')
|
| 150 |
+
base = res_parts[0] if res_parts else res_def
|
| 151 |
+
mods = res_parts[1:] if len(res_parts) > 1 else []
|
| 152 |
+
|
| 153 |
+
# Add residue base as a single unit
|
| 154 |
+
depth = residue_depths.get(res_letter, 0)
|
| 155 |
+
units.append({
|
| 156 |
+
'text': base,
|
| 157 |
+
'type': 'residue',
|
| 158 |
+
'residue_id': current_residue_id,
|
| 159 |
+
'branch_depth': depth,
|
| 160 |
+
'linkage_type': 0,
|
| 161 |
+
})
|
| 162 |
+
|
| 163 |
+
# Add modifications
|
| 164 |
+
for mod in mods:
|
| 165 |
+
units.append({
|
| 166 |
+
'text': mod,
|
| 167 |
+
'type': 'mod',
|
| 168 |
+
'residue_id': current_residue_id,
|
| 169 |
+
'branch_depth': depth,
|
| 170 |
+
'linkage_type': 0,
|
| 171 |
+
})
|
| 172 |
+
|
| 173 |
+
# Store distance matrix in units for easy access in tokenizer
|
| 174 |
+
if units:
|
| 175 |
+
# Find first residue unit or just use START
|
| 176 |
+
units[0]['distance_matrix'] = dist_matrix_raw
|
| 177 |
+
|
| 178 |
+
# Add linkages
|
| 179 |
+
for link in linkage_list:
|
| 180 |
+
if not link:
|
| 181 |
+
continue
|
| 182 |
+
# Parse linkage type
|
| 183 |
+
lt = self._parse_linkage_type(link)
|
| 184 |
+
units.append({
|
| 185 |
+
'text': link,
|
| 186 |
+
'type': 'linkage',
|
| 187 |
+
'residue_id': -2,
|
| 188 |
+
'branch_depth': 0,
|
| 189 |
+
'linkage_type': lt,
|
| 190 |
+
})
|
| 191 |
+
except Exception:
|
| 192 |
+
# Fallback for truly broken WURCS
|
| 193 |
+
pass
|
| 194 |
+
|
| 195 |
+
# Add end token
|
| 196 |
+
units.append({
|
| 197 |
+
'text': '[END]',
|
| 198 |
+
'type': 'special',
|
| 199 |
+
'residue_id': -1,
|
| 200 |
+
'branch_depth': 0,
|
| 201 |
+
'linkage_type': 0,
|
| 202 |
+
})
|
| 203 |
+
|
| 204 |
+
return units
|
| 205 |
+
|
| 206 |
+
def _split_wurcs_sections(self, wurcs: str) -> List[str]:
|
| 207 |
+
"""Split WURCS string into sections, handling nested brackets."""
|
| 208 |
+
# Remove WURCS= prefix
|
| 209 |
+
if wurcs.startswith('WURCS='):
|
| 210 |
+
wurcs = wurcs[6:]
|
| 211 |
+
|
| 212 |
+
sections = []
|
| 213 |
+
current = ""
|
| 214 |
+
bracket_depth = 0
|
| 215 |
+
|
| 216 |
+
for char in wurcs:
|
| 217 |
+
if char == '[':
|
| 218 |
+
bracket_depth += 1
|
| 219 |
+
current += char
|
| 220 |
+
elif char == ']':
|
| 221 |
+
bracket_depth -= 1
|
| 222 |
+
current += char
|
| 223 |
+
elif char == '/' and bracket_depth == 0:
|
| 224 |
+
sections.append(current)
|
| 225 |
+
current = ""
|
| 226 |
+
else:
|
| 227 |
+
current += char
|
| 228 |
+
|
| 229 |
+
if current:
|
| 230 |
+
sections.append(current)
|
| 231 |
+
|
| 232 |
+
return sections
|
| 233 |
+
|
| 234 |
+
def _analyze_tree_structure(self, linkages: List[str], num_residues: int) -> Tuple[Dict, Dict, Dict, Dict]:
|
| 235 |
+
"""Analyze linkages to compute branch points and residue depths."""
|
| 236 |
+
branch_points = defaultdict(int) # residue -> number of children
|
| 237 |
+
children = defaultdict(list)
|
| 238 |
+
all_residues = set()
|
| 239 |
+
linkage_types = {}
|
| 240 |
+
|
| 241 |
+
for link in linkages:
|
| 242 |
+
match = self.LINKAGE_PATTERN.match(link)
|
| 243 |
+
if match:
|
| 244 |
+
from_res, from_pos, to_res, to_pos = match.groups()
|
| 245 |
+
children[from_res].append(to_res)
|
| 246 |
+
all_residues.add(from_res)
|
| 247 |
+
all_residues.add(to_res)
|
| 248 |
+
|
| 249 |
+
# Store linkage type
|
| 250 |
+
linkage_types[link] = self._parse_linkage_type(link)
|
| 251 |
+
|
| 252 |
+
# Build adjacency list for BFS
|
| 253 |
+
adj = defaultdict(list)
|
| 254 |
+
for link in linkages:
|
| 255 |
+
match = self.LINKAGE_PATTERN.match(link)
|
| 256 |
+
if match:
|
| 257 |
+
u = ord(match.group(1)) - ord('a')
|
| 258 |
+
v = ord(match.group(3)) - ord('a')
|
| 259 |
+
if 0 <= u < num_residues and 0 <= v < num_residues:
|
| 260 |
+
adj[u].append(v)
|
| 261 |
+
adj[v].append(u)
|
| 262 |
+
|
| 263 |
+
# Find branch points (residues with >1 child)
|
| 264 |
+
for res, kids in children.items():
|
| 265 |
+
if len(kids) > 1:
|
| 266 |
+
branch_points[res] = len(kids) - 1 # Number of extra branches
|
| 267 |
+
|
| 268 |
+
# Compute depths using BFS
|
| 269 |
+
# Find root (residue with no parent)
|
| 270 |
+
child_set = set()
|
| 271 |
+
for kids in children.values():
|
| 272 |
+
child_set.update(kids)
|
| 273 |
+
roots = all_residues - child_set
|
| 274 |
+
root = min(roots) if roots else 'a'
|
| 275 |
+
|
| 276 |
+
depths = {root: 0}
|
| 277 |
+
queue = [root]
|
| 278 |
+
while queue:
|
| 279 |
+
current = queue.pop(0)
|
| 280 |
+
for child in children.get(current, []):
|
| 281 |
+
if child not in depths:
|
| 282 |
+
depths[child] = depths[current] + 1
|
| 283 |
+
queue.append(child)
|
| 284 |
+
|
| 285 |
+
return branch_points, depths, linkage_types, adj
|
| 286 |
+
|
| 287 |
+
def _compute_distance_matrix(self, adj: Dict[int, List[int]], num_residues: int) -> List[List[int]]:
|
| 288 |
+
"""
|
| 289 |
+
Compute shortest path distance (number of bonds) between all residue pairs using BFS.
|
| 290 |
+
"""
|
| 291 |
+
if num_residues == 0:
|
| 292 |
+
return []
|
| 293 |
+
|
| 294 |
+
dist_matrix = [[-1] * num_residues for _ in range(num_residues)]
|
| 295 |
+
|
| 296 |
+
for i in range(num_residues):
|
| 297 |
+
dist_matrix[i][i] = 0
|
| 298 |
+
queue = [(i, 0)]
|
| 299 |
+
visited = {i}
|
| 300 |
+
|
| 301 |
+
while queue:
|
| 302 |
+
curr, d = queue.pop(0)
|
| 303 |
+
dist_matrix[i][curr] = d
|
| 304 |
+
|
| 305 |
+
for neighbor in adj[curr]:
|
| 306 |
+
if neighbor not in visited:
|
| 307 |
+
visited.add(neighbor)
|
| 308 |
+
queue.append((neighbor, d + 1))
|
| 309 |
+
|
| 310 |
+
return dist_matrix
|
| 311 |
+
|
| 312 |
+
def _compute_distance_matrix_OLD(self, linkages: List[str], num_residues: int) -> List[List[int]]:
|
| 313 |
+
"""
|
| 314 |
+
Compute shortest path distance (number of bonds) between all residue pairs.
|
| 315 |
+
Returns a symmetric N x N matrix where N is num_residues.
|
| 316 |
+
Values are integers (number of steps). 0 on diagonal. -1 if unreachable (shouldn't happen in single tree).
|
| 317 |
+
"""
|
| 318 |
+
if num_residues == 0:
|
| 319 |
+
return []
|
| 320 |
+
|
| 321 |
+
# Initialize adjacency list
|
| 322 |
+
adj = defaultdict(list)
|
| 323 |
+
for link in linkages:
|
| 324 |
+
match = self.LINKAGE_PATTERN.match(link)
|
| 325 |
+
if match:
|
| 326 |
+
# WURCS indices are 1-based letters (a=1, b=2...)
|
| 327 |
+
from_res_char, _, to_res_char, _ = match.groups()
|
| 328 |
+
# Convert char to 0-based index
|
| 329 |
+
u = ord(from_res_char) - ord('a')
|
| 330 |
+
v = ord(to_res_char) - ord('a')
|
| 331 |
+
|
| 332 |
+
# Undirected graph for structural distance
|
| 333 |
+
if 0 <= u < num_residues and 0 <= v < num_residues:
|
| 334 |
+
adj[u].append(v)
|
| 335 |
+
adj[v].append(u)
|
| 336 |
+
|
| 337 |
+
# Compute All-Pairs Shortest Path (BFS from each node is fine for small N)
|
| 338 |
+
# Glycans are small (N ~ 5-20 usually), so O(N^2) BFS is cheap.
|
| 339 |
+
dist_matrix = [[-1] * num_residues for _ in range(num_residues)]
|
| 340 |
+
|
| 341 |
+
for i in range(num_residues):
|
| 342 |
+
dist_matrix[i][i] = 0
|
| 343 |
+
queue = [(i, 0)]
|
| 344 |
+
visited = {i}
|
| 345 |
+
|
| 346 |
+
while queue:
|
| 347 |
+
curr, d = queue.pop(0)
|
| 348 |
+
dist_matrix[i][curr] = d
|
| 349 |
+
|
| 350 |
+
for neighbor in adj[curr]:
|
| 351 |
+
if neighbor not in visited:
|
| 352 |
+
visited.add(neighbor)
|
| 353 |
+
queue.append((neighbor, d + 1))
|
| 354 |
+
|
| 355 |
+
return dist_matrix
|
| 356 |
+
|
| 357 |
+
def _parse_linkage_type(self, link: str) -> int:
|
| 358 |
+
"""Parse linkage string to get type ID."""
|
| 359 |
+
LINKAGE_TYPES = {
|
| 360 |
+
(1, 2): 0, (2, 1): 0,
|
| 361 |
+
(1, 3): 1, (3, 1): 1,
|
| 362 |
+
(1, 4): 2, (4, 1): 2,
|
| 363 |
+
(1, 6): 3, (6, 1): 3,
|
| 364 |
+
(2, 3): 4, (3, 2): 4,
|
| 365 |
+
(2, 6): 5, (6, 2): 5,
|
| 366 |
+
(3, 6): 6, (6, 3): 6,
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
match = self.LINKAGE_PATTERN.match(link)
|
| 370 |
+
if match:
|
| 371 |
+
_, from_pos, _, to_pos = match.groups()
|
| 372 |
+
try:
|
| 373 |
+
pos_tuple = (int(from_pos), int(to_pos))
|
| 374 |
+
return LINKAGE_TYPES.get(pos_tuple, 7)
|
| 375 |
+
except ValueError:
|
| 376 |
+
return 8 # Unknown
|
| 377 |
+
return 8
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
class WURCSBPETokenizer:
|
| 381 |
+
"""
|
| 382 |
+
BPE tokenizer for WURCS with tree-aware embeddings.
|
| 383 |
+
"""
|
| 384 |
+
|
| 385 |
+
def __init__(self, vocab_path: Optional[str] = None):
|
| 386 |
+
self.pre_tokenizer = WURCSPreTokenizer()
|
| 387 |
+
|
| 388 |
+
# Special tokens (fixed)
|
| 389 |
+
self.special_tokens = self.pre_tokenizer.special_tokens
|
| 390 |
+
|
| 391 |
+
# BPE vocabulary
|
| 392 |
+
self.token_to_id: Dict[str, int] = {}
|
| 393 |
+
self.id_to_token: Dict[int, str] = {}
|
| 394 |
+
self.merges: List[Tuple[str, str]] = []
|
| 395 |
+
|
| 396 |
+
if vocab_path:
|
| 397 |
+
self.load_vocab(vocab_path)
|
| 398 |
+
else:
|
| 399 |
+
# Initialize with special tokens only
|
| 400 |
+
self.token_to_id = dict(self.special_tokens)
|
| 401 |
+
self.id_to_token = {v: k for k, v in self.token_to_id.items()}
|
| 402 |
+
|
| 403 |
+
@classmethod
|
| 404 |
+
def train_from_corpus(
|
| 405 |
+
cls,
|
| 406 |
+
wurcs_strings: List[str],
|
| 407 |
+
num_merges: int = 500,
|
| 408 |
+
output_path: Optional[str] = None,
|
| 409 |
+
min_frequency: int = 2,
|
| 410 |
+
max_token_length: Optional[int] = None,
|
| 411 |
+
) -> 'WURCSBPETokenizer':
|
| 412 |
+
"""
|
| 413 |
+
Train BPE on a corpus of WURCS strings.
|
| 414 |
+
|
| 415 |
+
Args:
|
| 416 |
+
wurcs_strings: List of WURCS strings
|
| 417 |
+
num_merges: Number of BPE merge operations
|
| 418 |
+
output_path: Optional path to save vocabulary
|
| 419 |
+
min_frequency: Minimum frequency for a token to be kept
|
| 420 |
+
max_token_length: Maximum length of a merged token (None = no limit)
|
| 421 |
+
|
| 422 |
+
Returns:
|
| 423 |
+
Trained tokenizer
|
| 424 |
+
"""
|
| 425 |
+
tokenizer = cls()
|
| 426 |
+
pre_tok = WURCSPreTokenizer()
|
| 427 |
+
|
| 428 |
+
print(f"Training BPE on {len(wurcs_strings)} WURCS strings...")
|
| 429 |
+
|
| 430 |
+
# Step 1: Pre-tokenize all strings to get semantic units
|
| 431 |
+
all_units = []
|
| 432 |
+
for wurcs in wurcs_strings:
|
| 433 |
+
units = pre_tok.pre_tokenize(wurcs)
|
| 434 |
+
for unit in units:
|
| 435 |
+
if unit['type'] != 'special':
|
| 436 |
+
all_units.append(unit['text'])
|
| 437 |
+
|
| 438 |
+
# Step 2: Count unit frequencies
|
| 439 |
+
unit_counts = Counter(all_units)
|
| 440 |
+
print(f"Found {len(unit_counts)} unique units")
|
| 441 |
+
|
| 442 |
+
# Step 3: Initialize vocabulary with characters from all units
|
| 443 |
+
char_vocab = set()
|
| 444 |
+
for unit in unit_counts:
|
| 445 |
+
for char in unit:
|
| 446 |
+
char_vocab.add(char)
|
| 447 |
+
|
| 448 |
+
# Build initial vocab: special tokens + characters
|
| 449 |
+
vocab_id = len(tokenizer.special_tokens)
|
| 450 |
+
for char in sorted(char_vocab):
|
| 451 |
+
tokenizer.token_to_id[char] = vocab_id
|
| 452 |
+
tokenizer.id_to_token[vocab_id] = char
|
| 453 |
+
vocab_id += 1
|
| 454 |
+
|
| 455 |
+
print(f"Initial vocab size: {vocab_id} (special + characters)")
|
| 456 |
+
|
| 457 |
+
# Step 4: Convert units to character sequences
|
| 458 |
+
word_freqs = {}
|
| 459 |
+
for unit, count in unit_counts.items():
|
| 460 |
+
if count >= min_frequency:
|
| 461 |
+
# Split into characters with space separator
|
| 462 |
+
chars = tuple(unit)
|
| 463 |
+
word_freqs[chars] = count
|
| 464 |
+
|
| 465 |
+
# Step 5: BPE merging
|
| 466 |
+
merges = []
|
| 467 |
+
|
| 468 |
+
for merge_idx in range(num_merges):
|
| 469 |
+
# Count pairs
|
| 470 |
+
pair_counts = Counter()
|
| 471 |
+
for word, freq in word_freqs.items():
|
| 472 |
+
for i in range(len(word) - 1):
|
| 473 |
+
pair = (word[i], word[i + 1])
|
| 474 |
+
pair_counts[pair] += freq
|
| 475 |
+
|
| 476 |
+
if not pair_counts:
|
| 477 |
+
break
|
| 478 |
+
|
| 479 |
+
# Find most frequent pair
|
| 480 |
+
best_pair = pair_counts.most_common(1)[0][0]
|
| 481 |
+
best_count = pair_counts[best_pair]
|
| 482 |
+
|
| 483 |
+
if best_count < min_frequency:
|
| 484 |
+
break
|
| 485 |
+
|
| 486 |
+
# Merge pair
|
| 487 |
+
new_token = best_pair[0] + best_pair[1]
|
| 488 |
+
|
| 489 |
+
# Check length constraint
|
| 490 |
+
if max_token_length and len(new_token) > max_token_length:
|
| 491 |
+
# remove this pair from consideration for this iteration and future?
|
| 492 |
+
# Actually, skipping it here is tricky because we need to ignore it in pair_counts next time
|
| 493 |
+
# Simpler: Just skip adding it to merges and modify word_freqs?
|
| 494 |
+
# No, if we don't merge, we just continue to the next best pair in THIS iteration.
|
| 495 |
+
# But pair_counts is already computed.
|
| 496 |
+
# We need to loop until we find a valid pair or run out
|
| 497 |
+
|
| 498 |
+
# In this simple implementation, let's just skip this merge efficiently
|
| 499 |
+
# We need to find the NEXT most common pair.
|
| 500 |
+
|
| 501 |
+
# Re-do finding best pair loop
|
| 502 |
+
found_valid_pair = False
|
| 503 |
+
for pair, count in pair_counts.most_common():
|
| 504 |
+
token_candidate = pair[0] + pair[1]
|
| 505 |
+
if max_token_length and len(token_candidate) > max_token_length:
|
| 506 |
+
continue # Skip too long
|
| 507 |
+
|
| 508 |
+
if count < min_frequency:
|
| 509 |
+
break # Stop if frequency too low
|
| 510 |
+
|
| 511 |
+
# Found valid pair
|
| 512 |
+
best_pair = pair
|
| 513 |
+
best_count = count
|
| 514 |
+
new_token = token_candidate
|
| 515 |
+
found_valid_pair = True
|
| 516 |
+
break
|
| 517 |
+
|
| 518 |
+
if not found_valid_pair:
|
| 519 |
+
print(f" Stopping early: No more pairs satisfy max_token_length={max_token_length}")
|
| 520 |
+
break
|
| 521 |
+
|
| 522 |
+
# Final check before merging (in case we didn't enter the if block but updated vars)
|
| 523 |
+
# Actually the logic above handles it. If we entered the block, we either found a new best_pair or broke.
|
| 524 |
+
|
| 525 |
+
merges.append(best_pair)
|
| 526 |
+
|
| 527 |
+
# Add to vocab
|
| 528 |
+
tokenizer.token_to_id[new_token] = vocab_id
|
| 529 |
+
tokenizer.id_to_token[vocab_id] = new_token
|
| 530 |
+
vocab_id += 1
|
| 531 |
+
|
| 532 |
+
# Update word_freqs
|
| 533 |
+
new_word_freqs = {}
|
| 534 |
+
for word, freq in word_freqs.items():
|
| 535 |
+
new_word = []
|
| 536 |
+
i = 0
|
| 537 |
+
while i < len(word):
|
| 538 |
+
if i < len(word) - 1 and word[i] == best_pair[0] and word[i + 1] == best_pair[1]:
|
| 539 |
+
new_word.append(new_token)
|
| 540 |
+
i += 2
|
| 541 |
+
else:
|
| 542 |
+
new_word.append(word[i])
|
| 543 |
+
i += 1
|
| 544 |
+
new_word_freqs[tuple(new_word)] = freq
|
| 545 |
+
word_freqs = new_word_freqs
|
| 546 |
+
|
| 547 |
+
if (merge_idx + 1) % 100 == 0:
|
| 548 |
+
print(f" Merge {merge_idx + 1}/{num_merges}: '{best_pair[0]}' + '{best_pair[1]}' -> '{new_token}' (count={best_count})")
|
| 549 |
+
|
| 550 |
+
tokenizer.merges = merges
|
| 551 |
+
print(f"Final vocab size: {len(tokenizer.token_to_id)}")
|
| 552 |
+
|
| 553 |
+
# Save if requested
|
| 554 |
+
if output_path:
|
| 555 |
+
tokenizer.save_vocab(output_path)
|
| 556 |
+
|
| 557 |
+
return tokenizer
|
| 558 |
+
|
| 559 |
+
def apply_bpe(self, text: str) -> List[str]:
|
| 560 |
+
"""Apply BPE merges to a text string."""
|
| 561 |
+
if text in self.token_to_id:
|
| 562 |
+
return [text]
|
| 563 |
+
|
| 564 |
+
# Split into characters
|
| 565 |
+
tokens = list(text)
|
| 566 |
+
|
| 567 |
+
# Apply merges
|
| 568 |
+
for pair in self.merges:
|
| 569 |
+
new_tokens = []
|
| 570 |
+
i = 0
|
| 571 |
+
while i < len(tokens):
|
| 572 |
+
if i < len(tokens) - 1 and tokens[i] == pair[0] and tokens[i + 1] == pair[1]:
|
| 573 |
+
new_tokens.append(pair[0] + pair[1])
|
| 574 |
+
i += 2
|
| 575 |
+
else:
|
| 576 |
+
new_tokens.append(tokens[i])
|
| 577 |
+
i += 1
|
| 578 |
+
tokens = new_tokens
|
| 579 |
+
|
| 580 |
+
return tokens
|
| 581 |
+
|
| 582 |
+
def tokenize(self, wurcs: str, max_length: int = 256) -> Dict:
|
| 583 |
+
"""
|
| 584 |
+
Tokenize a WURCS string.
|
| 585 |
+
|
| 586 |
+
Returns:
|
| 587 |
+
Dict with:
|
| 588 |
+
- tokens: List of token strings
|
| 589 |
+
- token_ids: List of token IDs
|
| 590 |
+
- residue_ids: List of residue IDs
|
| 591 |
+
- branch_depths: List of branch depths
|
| 592 |
+
- linkage_types: List of linkage types
|
| 593 |
+
- attention_mask: Attention mask
|
| 594 |
+
"""
|
| 595 |
+
# Pre-tokenize
|
| 596 |
+
units = self.pre_tokenizer.pre_tokenize(wurcs)
|
| 597 |
+
|
| 598 |
+
tokens = []
|
| 599 |
+
token_ids = []
|
| 600 |
+
residue_ids = []
|
| 601 |
+
branch_depths = []
|
| 602 |
+
linkage_types = []
|
| 603 |
+
|
| 604 |
+
for unit in units:
|
| 605 |
+
if unit['type'] == 'special':
|
| 606 |
+
# Special tokens stay as-is
|
| 607 |
+
tok = unit['text']
|
| 608 |
+
tokens.append(tok)
|
| 609 |
+
token_ids.append(self.token_to_id.get(tok, self.token_to_id['[UNK]']))
|
| 610 |
+
residue_ids.append(unit['residue_id'])
|
| 611 |
+
branch_depths.append(unit['branch_depth'])
|
| 612 |
+
linkage_types.append(unit['linkage_type'])
|
| 613 |
+
else:
|
| 614 |
+
# Apply BPE to this unit
|
| 615 |
+
bpe_tokens = self.apply_bpe(unit['text'])
|
| 616 |
+
for tok in bpe_tokens:
|
| 617 |
+
tokens.append(tok)
|
| 618 |
+
token_ids.append(self.token_to_id.get(tok, self.token_to_id['[UNK]']))
|
| 619 |
+
residue_ids.append(unit['residue_id'])
|
| 620 |
+
branch_depths.append(unit['branch_depth'])
|
| 621 |
+
linkage_types.append(unit['linkage_type'])
|
| 622 |
+
|
| 623 |
+
# Truncate if needed
|
| 624 |
+
if len(tokens) > max_length:
|
| 625 |
+
tokens = tokens[:max_length - 1] + ['[END]']
|
| 626 |
+
token_ids = token_ids[:max_length - 1] + [self.token_to_id['[END]']]
|
| 627 |
+
residue_ids = residue_ids[:max_length - 1] + [-1]
|
| 628 |
+
branch_depths = branch_depths[:max_length - 1] + [0]
|
| 629 |
+
linkage_types = linkage_types[:max_length - 1] + [0]
|
| 630 |
+
|
| 631 |
+
# Create attention mask and pad
|
| 632 |
+
length = len(tokens)
|
| 633 |
+
attention_mask = [1] * length
|
| 634 |
+
|
| 635 |
+
while len(tokens) < max_length:
|
| 636 |
+
tokens.append('[PAD]')
|
| 637 |
+
token_ids.append(self.token_to_id['[PAD]'])
|
| 638 |
+
residue_ids.append(-1)
|
| 639 |
+
branch_depths.append(0)
|
| 640 |
+
linkage_types.append(0)
|
| 641 |
+
attention_mask.append(0)
|
| 642 |
+
# Pre-tokenize
|
| 643 |
+
units = self.pre_tokenizer.pre_tokenize(wurcs)
|
| 644 |
+
|
| 645 |
+
# Extract distance matrix from pre-tokenizer result
|
| 646 |
+
dist_matrix_raw = units[0].get('distance_matrix', [])
|
| 647 |
+
num_residues = len(dist_matrix_raw)
|
| 648 |
+
|
| 649 |
+
# Map token-to-token distances using residue_ids
|
| 650 |
+
# token_i is associated with residue_ids[i].
|
| 651 |
+
# residue_ids[i] is index into dist_matrix_raw.
|
| 652 |
+
# If residue_ids[i] == -1 (special), distance is undefined (use -1 or 999)
|
| 653 |
+
|
| 654 |
+
# Use UNPADDED length for distance matrix to save massive memory
|
| 655 |
+
# distance_matrix will be e.g. 20x20, while tokens are padded to 256
|
| 656 |
+
token_len = length
|
| 657 |
+
distance_matrix = [[-1] * token_len for _ in range(token_len)]
|
| 658 |
+
|
| 659 |
+
for i in range(token_len):
|
| 660 |
+
for j in range(token_len):
|
| 661 |
+
r_i = residue_ids[i]
|
| 662 |
+
r_j = residue_ids[j]
|
| 663 |
+
|
| 664 |
+
if r_i >= 0 and r_j >= 0 and r_i < num_residues and r_j < num_residues:
|
| 665 |
+
distance_matrix[i][j] = dist_matrix_raw[r_i][r_j]
|
| 666 |
+
else:
|
| 667 |
+
distance_matrix[i][j] = -1 # Special/Padding
|
| 668 |
+
|
| 669 |
+
# MEMORY OPTIMIZATION: Do NOT pad matrix here.
|
| 670 |
+
# Pad on-the-fly in Dataset class instead.
|
| 671 |
+
# This saves massive memory (0.2GB vs 66GB).
|
| 672 |
+
|
| 673 |
+
return {
|
| 674 |
+
'tokens': tokens,
|
| 675 |
+
'token_ids': token_ids,
|
| 676 |
+
'residue_ids': residue_ids,
|
| 677 |
+
'branch_depths': branch_depths,
|
| 678 |
+
'linkage_types': linkage_types,
|
| 679 |
+
'attention_mask': attention_mask,
|
| 680 |
+
'distance_matrix': distance_matrix, # New Output
|
| 681 |
+
'length': length,
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
+
def save_vocab(self, path: str):
|
| 685 |
+
"""Save vocabulary to JSON file."""
|
| 686 |
+
data = {
|
| 687 |
+
'special_tokens': self.special_tokens,
|
| 688 |
+
'token_to_id': self.token_to_id,
|
| 689 |
+
'merges': [list(m) for m in self.merges],
|
| 690 |
+
'metadata': {
|
| 691 |
+
'vocab_size': len(self.token_to_id),
|
| 692 |
+
'num_merges': len(self.merges),
|
| 693 |
+
}
|
| 694 |
+
}
|
| 695 |
+
with open(path, 'w') as f:
|
| 696 |
+
json.dump(data, f, indent=2)
|
| 697 |
+
print(f"Saved vocabulary to {path}")
|
| 698 |
+
|
| 699 |
+
def load_vocab(self, path: str):
|
| 700 |
+
"""Load vocabulary from JSON file."""
|
| 701 |
+
with open(path, 'r') as f:
|
| 702 |
+
data = json.load(f)
|
| 703 |
+
|
| 704 |
+
self.special_tokens = data['special_tokens']
|
| 705 |
+
self.token_to_id = data['token_to_id']
|
| 706 |
+
self.id_to_token = {int(v): k for k, v in self.token_to_id.items()}
|
| 707 |
+
self.merges = [tuple(m) for m in data['merges']]
|
| 708 |
+
|
| 709 |
+
print(f"Loaded vocabulary with {len(self.token_to_id)} tokens")
|
| 710 |
+
|
| 711 |
+
@property
|
| 712 |
+
def vocab_size(self) -> int:
|
| 713 |
+
return len(self.token_to_id)
|
| 714 |
+
|
| 715 |
+
|
| 716 |
+
# ============================================================================
|
| 717 |
+
# Testing
|
| 718 |
+
# ============================================================================
|
| 719 |
+
|
| 720 |
+
if __name__ == '__main__':
|
| 721 |
+
# Test pre-tokenizer
|
| 722 |
+
print("="*80)
|
| 723 |
+
print("Testing WURCSPreTokenizer")
|
| 724 |
+
print("="*80)
|
| 725 |
+
|
| 726 |
+
pre_tok = WURCSPreTokenizer()
|
| 727 |
+
|
| 728 |
+
test_wurcs = [
|
| 729 |
+
"WURCS=2.0/2,2,1/[a2122h-1b_1-5][a2211m-1a_1-5]/1-2/a4-b1",
|
| 730 |
+
"WURCS=2.0/3,3,2/[a2122h-1b_1-5_2*NCC/3=O][a2112h-1a_1-5][a2211m-1a_1-5]/1-2-3/a4-b1_b3-c1",
|
| 731 |
+
]
|
| 732 |
+
|
| 733 |
+
for wurcs in test_wurcs:
|
| 734 |
+
print(f"\nWURCS: {wurcs[:60]}...")
|
| 735 |
+
units = pre_tok.pre_tokenize(wurcs)
|
| 736 |
+
print(f"Units ({len(units)}):")
|
| 737 |
+
for u in units[:10]:
|
| 738 |
+
print(f" {u['type']:10} | res={u['residue_id']:2} | depth={u['branch_depth']} | {u['text']}")
|
| 739 |
+
if len(units) > 10:
|
| 740 |
+
print(f" ... and {len(units) - 10} more")
|
code/probes/analyze_core_biology.py
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Tier 2: Core Biology Embedding Analysis
|
| 4 |
+
Analyzes whether GlycanBERT understands glycan biology at a structural level.
|
| 5 |
+
|
| 6 |
+
Analyses:
|
| 7 |
+
1. Glycan structural type clustering (N-glycan, O-glycan, GAG, etc.)
|
| 8 |
+
2. Monosaccharide composition vs embedding distance correlation
|
| 9 |
+
3. Branching complexity analysis
|
| 10 |
+
4. Linkage chemistry sensitivity (alpha vs beta)
|
| 11 |
+
5. Decorations (fucosylation, sialylation)
|
| 12 |
+
6. Plantae / Phytomining investigation
|
| 13 |
+
|
| 14 |
+
Usage:
|
| 15 |
+
python3 analyze_core_biology.py --model v6
|
| 16 |
+
python3 analyze_core_biology.py --model v5
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import os, sys, json, re, pickle, argparse
|
| 20 |
+
import numpy as np
|
| 21 |
+
from collections import Counter
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
try:
|
| 25 |
+
import matplotlib
|
| 26 |
+
matplotlib.use('Agg')
|
| 27 |
+
import matplotlib.pyplot as plt
|
| 28 |
+
except ImportError:
|
| 29 |
+
print("ERROR: matplotlib required"); sys.exit(1)
|
| 30 |
+
|
| 31 |
+
try:
|
| 32 |
+
import umap
|
| 33 |
+
def compute_umap(data, n=2):
|
| 34 |
+
return umap.UMAP(n_components=n, n_neighbors=30, min_dist=0.3,
|
| 35 |
+
random_state=42, n_jobs=1).fit_transform(data)
|
| 36 |
+
except ImportError:
|
| 37 |
+
from sklearn.manifold import TSNE
|
| 38 |
+
def compute_umap(data, n=2):
|
| 39 |
+
return TSNE(n_components=n, perplexity=30, random_state=42).fit_transform(data)
|
| 40 |
+
|
| 41 |
+
from scipy.stats import spearmanr
|
| 42 |
+
|
| 43 |
+
# ── Glycan Type Classification ───────────────────────────────────────
|
| 44 |
+
|
| 45 |
+
def classify_glycan_type(iupac):
|
| 46 |
+
"""Classify a glycan by structural type from its IUPAC name."""
|
| 47 |
+
if not iupac or iupac == 'nan':
|
| 48 |
+
return 'Unknown'
|
| 49 |
+
iupac_upper = iupac.upper()
|
| 50 |
+
if 'GLCNAC(B1-4)GLCNAC' in iupac_upper and 'MAN' in iupac_upper:
|
| 51 |
+
return 'N-glycan'
|
| 52 |
+
if iupac_upper.count('MAN') >= 5 and 'GLCNAC' in iupac_upper:
|
| 53 |
+
return 'N-glycan (high-mannose)'
|
| 54 |
+
if 'GALNAC(A1-' in iupac_upper:
|
| 55 |
+
return 'O-glycan'
|
| 56 |
+
if any(x in iupac_upper for x in ['GLCA', 'IDOA', 'HEPARAN', 'CHONDROITIN']):
|
| 57 |
+
return 'GAG'
|
| 58 |
+
if iupac_upper.startswith('GLC(B1-') or iupac_upper.startswith('GLCCER'):
|
| 59 |
+
return 'Glycolipid'
|
| 60 |
+
if 'LAC' in iupac_upper and len(iupac) < 60:
|
| 61 |
+
return 'Milk OS'
|
| 62 |
+
return 'Other'
|
| 63 |
+
|
| 64 |
+
# ── Monosaccharide Composition ───────────────────────────────────────
|
| 65 |
+
|
| 66 |
+
MONOSACCHARIDES = ['Glc', 'GlcNAc', 'Man', 'Gal', 'GalNAc', 'Fuc',
|
| 67 |
+
'Neu5Ac', 'Xyl', 'Rha', 'GlcA', 'IdoA', 'Ara']
|
| 68 |
+
|
| 69 |
+
def get_composition(iupac):
|
| 70 |
+
"""Count monosaccharides in an IUPAC string."""
|
| 71 |
+
if not iupac:
|
| 72 |
+
return np.zeros(len(MONOSACCHARIDES))
|
| 73 |
+
vec = []
|
| 74 |
+
for mono in MONOSACCHARIDES:
|
| 75 |
+
count = len(re.findall(re.escape(mono) + r'[\(\[]', iupac, re.IGNORECASE))
|
| 76 |
+
if count == 0:
|
| 77 |
+
count = iupac.upper().count(mono.upper())
|
| 78 |
+
vec.append(count)
|
| 79 |
+
return np.array(vec, dtype=float)
|
| 80 |
+
|
| 81 |
+
# ── Analysis Functions ───────────────────────────────────────────────
|
| 82 |
+
|
| 83 |
+
def analysis_1_structural_types(embs, iupacs, output_dir, name):
|
| 84 |
+
"""UMAP colored by glycan structural type."""
|
| 85 |
+
print(f"\n=== Tier 2 Analysis 1: Structural Type Clustering ({name}) ===")
|
| 86 |
+
types = [classify_glycan_type(s) for s in iupacs]
|
| 87 |
+
type_counts = Counter(types)
|
| 88 |
+
print(f" Type distribution: {dict(type_counts)}")
|
| 89 |
+
valid_types = {t for t, c in type_counts.items() if c >= 5 and t != 'Unknown'}
|
| 90 |
+
mask = np.array([t in valid_types for t in types])
|
| 91 |
+
if mask.sum() < 20:
|
| 92 |
+
print(f" Only {mask.sum()} samples with known type, skipping UMAP.")
|
| 93 |
+
return {'structural_type_counts': dict(type_counts)}
|
| 94 |
+
embs_f = embs[mask]
|
| 95 |
+
types_f = [t for t, m in zip(types, mask) if m]
|
| 96 |
+
proj = compute_umap(embs_f)
|
| 97 |
+
colors = ['#E53935', '#1E88E5', '#43A047', '#FB8C00', '#8E24AA',
|
| 98 |
+
'#00ACC1', '#6D4C41', '#546E7A']
|
| 99 |
+
unique_types = sorted(set(types_f))
|
| 100 |
+
fig, ax = plt.subplots(figsize=(14, 10))
|
| 101 |
+
for i, gtype in enumerate(unique_types):
|
| 102 |
+
m = np.array([t == gtype for t in types_f])
|
| 103 |
+
ax.scatter(proj[m, 0], proj[m, 1], c=colors[i % len(colors)],
|
| 104 |
+
s=15, alpha=0.6, label=f'{gtype} (n={m.sum()})', rasterized=True)
|
| 105 |
+
ax.set_title(f'{name}: Glycan Structural Types in Embedding Space', fontsize=16, fontweight='bold')
|
| 106 |
+
ax.legend(fontsize=10, markerscale=3, loc='best')
|
| 107 |
+
ax.set_xlabel('UMAP-1', fontsize=12); ax.set_ylabel('UMAP-2', fontsize=12)
|
| 108 |
+
plt.tight_layout()
|
| 109 |
+
plt.savefig(os.path.join(output_dir, f'structural_types_{name}.png'), dpi=200, bbox_inches='tight')
|
| 110 |
+
plt.close()
|
| 111 |
+
print(f" Saved structural_types_{name}.png")
|
| 112 |
+
try:
|
| 113 |
+
from sklearn.metrics import silhouette_score
|
| 114 |
+
labels_num = [unique_types.index(t) for t in types_f]
|
| 115 |
+
if len(set(labels_num)) >= 2:
|
| 116 |
+
sil = silhouette_score(embs_f, labels_num, metric='cosine', sample_size=min(5000, len(embs_f)))
|
| 117 |
+
print(f" Silhouette (structural type): {sil:.4f}")
|
| 118 |
+
return {'structural_type_counts': dict(type_counts), 'silhouette_structural_type': round(sil, 4)}
|
| 119 |
+
except: pass
|
| 120 |
+
return {'structural_type_counts': dict(type_counts)}
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def analysis_2_composition(embs, iupacs, output_dir, name, n_sample=2000):
|
| 124 |
+
"""Monosaccharide composition vs embedding distance correlation."""
|
| 125 |
+
print(f"\n=== Tier 2 Analysis 2: Composition Correlation ({name}) ===")
|
| 126 |
+
n = min(n_sample, len(embs))
|
| 127 |
+
idx = np.random.RandomState(42).choice(len(embs), n, replace=False)
|
| 128 |
+
comp_vecs = np.array([get_composition(iupacs[i]) for i in idx])
|
| 129 |
+
emb_subset = embs[idx]
|
| 130 |
+
norms = np.linalg.norm(comp_vecs, axis=1)
|
| 131 |
+
valid = norms > 0
|
| 132 |
+
if valid.sum() < 50:
|
| 133 |
+
print(f" Only {valid.sum()} samples with parseable composition, skipping.")
|
| 134 |
+
return {}
|
| 135 |
+
comp_vecs = comp_vecs[valid]
|
| 136 |
+
emb_subset = emb_subset[valid]
|
| 137 |
+
print(f" {len(comp_vecs)} samples with valid composition")
|
| 138 |
+
emb_n = emb_subset / (np.linalg.norm(emb_subset, axis=1, keepdims=True) + 1e-8)
|
| 139 |
+
comp_n = comp_vecs / (np.linalg.norm(comp_vecs, axis=1, keepdims=True) + 1e-8)
|
| 140 |
+
emb_sim = emb_n @ emb_n.T
|
| 141 |
+
comp_sim = comp_n @ comp_n.T
|
| 142 |
+
mask = np.triu(np.ones_like(emb_sim, dtype=bool), k=1)
|
| 143 |
+
emb_flat = emb_sim[mask]
|
| 144 |
+
comp_flat = comp_sim[mask]
|
| 145 |
+
if len(emb_flat) > 50000:
|
| 146 |
+
sample_idx = np.random.RandomState(42).choice(len(emb_flat), 50000, replace=False)
|
| 147 |
+
emb_flat = emb_flat[sample_idx]
|
| 148 |
+
comp_flat = comp_flat[sample_idx]
|
| 149 |
+
rho, pval = spearmanr(comp_flat, emb_flat)
|
| 150 |
+
print(f" Spearman rho = {rho:.4f} (p = {pval:.2e})")
|
| 151 |
+
fig, ax = plt.subplots(figsize=(10, 8))
|
| 152 |
+
sample = np.random.RandomState(42).choice(len(emb_flat), min(5000, len(emb_flat)), replace=False)
|
| 153 |
+
ax.scatter(comp_flat[sample], emb_flat[sample], s=1, alpha=0.15, c='#1565C0', rasterized=True)
|
| 154 |
+
ax.set_xlabel('Compositional Similarity (cosine)', fontsize=14)
|
| 155 |
+
ax.set_ylabel('Embedding Similarity (cosine)', fontsize=14)
|
| 156 |
+
ax.set_title(f'{name}: Composition vs Embedding Similarity\nSpearman rho = {rho:.4f}', fontsize=14, fontweight='bold')
|
| 157 |
+
plt.tight_layout()
|
| 158 |
+
plt.savefig(os.path.join(output_dir, f'composition_correlation_{name}.png'), dpi=200, bbox_inches='tight')
|
| 159 |
+
plt.close()
|
| 160 |
+
print(f" Saved composition_correlation_{name}.png")
|
| 161 |
+
return {'composition_spearman_rho': round(rho, 4), 'composition_p_value': float(f'{pval:.2e}'),
|
| 162 |
+
'n_composition_samples': int(len(comp_vecs))}
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def analysis_3_branching(embs, branch_depths_list, output_dir, name):
|
| 166 |
+
"""Branching complexity analysis."""
|
| 167 |
+
print(f"\n=== Tier 2 Analysis 3: Branching Complexity ({name}) ===")
|
| 168 |
+
max_depths = []
|
| 169 |
+
for bd in branch_depths_list:
|
| 170 |
+
if isinstance(bd, (list, np.ndarray)) and len(bd) > 0:
|
| 171 |
+
max_depths.append(int(max(bd)))
|
| 172 |
+
else:
|
| 173 |
+
max_depths.append(0)
|
| 174 |
+
max_depths = np.array(max_depths)
|
| 175 |
+
depth_counts = Counter(max_depths)
|
| 176 |
+
print(f" Depth distribution: {dict(sorted(depth_counts.items()))}")
|
| 177 |
+
def depth_category(d):
|
| 178 |
+
if d <= 1: return 'Linear (depth<=1)'
|
| 179 |
+
elif d == 2: return 'Branched (depth=2)'
|
| 180 |
+
elif d == 3: return 'Complex (depth=3)'
|
| 181 |
+
else: return 'Hyper-branched (depth>=4)'
|
| 182 |
+
categories = [depth_category(d) for d in max_depths]
|
| 183 |
+
cat_counts = Counter(categories)
|
| 184 |
+
valid_cats = {c for c, n in cat_counts.items() if n >= 10}
|
| 185 |
+
mask = np.array([c in valid_cats for c in categories])
|
| 186 |
+
if mask.sum() < 30:
|
| 187 |
+
print(f" Insufficient category diversity ({mask.sum()} valid), skipping UMAP.")
|
| 188 |
+
return {'branching_distribution': dict(depth_counts)}
|
| 189 |
+
embs_f = embs[mask]
|
| 190 |
+
cats_f = [c for c, m in zip(categories, mask) if m]
|
| 191 |
+
proj = compute_umap(embs_f)
|
| 192 |
+
colors = {'Linear (depth<=1)': '#42A5F5', 'Branched (depth=2)': '#66BB6A',
|
| 193 |
+
'Complex (depth=3)': '#FFA726', 'Hyper-branched (depth>=4)': '#EF5350'}
|
| 194 |
+
unique_cats = sorted(set(cats_f))
|
| 195 |
+
fig, ax = plt.subplots(figsize=(14, 10))
|
| 196 |
+
for cat in unique_cats:
|
| 197 |
+
m = np.array([c == cat for c in cats_f])
|
| 198 |
+
ax.scatter(proj[m, 0], proj[m, 1], c=colors.get(cat, '#999'),
|
| 199 |
+
s=15, alpha=0.5, label=f'{cat} (n={m.sum()})', rasterized=True)
|
| 200 |
+
ax.set_title(f'{name}: Branching Complexity in Embedding Space', fontsize=16, fontweight='bold')
|
| 201 |
+
ax.legend(fontsize=11, markerscale=3)
|
| 202 |
+
ax.set_xlabel('UMAP-1', fontsize=12); ax.set_ylabel('UMAP-2', fontsize=12)
|
| 203 |
+
plt.tight_layout()
|
| 204 |
+
plt.savefig(os.path.join(output_dir, f'branching_complexity_{name}.png'), dpi=200, bbox_inches='tight')
|
| 205 |
+
plt.close()
|
| 206 |
+
print(f" Saved branching_complexity_{name}.png")
|
| 207 |
+
try:
|
| 208 |
+
from sklearn.metrics import silhouette_score
|
| 209 |
+
labels_num = [unique_cats.index(c) for c in cats_f]
|
| 210 |
+
if len(set(labels_num)) >= 2:
|
| 211 |
+
sil = silhouette_score(embs_f, labels_num, metric='cosine', sample_size=min(5000, len(embs_f)))
|
| 212 |
+
print(f" Silhouette (branching): {sil:.4f}")
|
| 213 |
+
return {'branching_distribution': dict(depth_counts), 'silhouette_branching': round(sil, 4)}
|
| 214 |
+
except: pass
|
| 215 |
+
return {'branching_distribution': dict(depth_counts)}
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def analysis_4_linkage(embs, linkage_types_list, output_dir, name):
|
| 219 |
+
"""Linkage chemistry analysis: alpha vs beta distribution in embedding space."""
|
| 220 |
+
print(f"\n=== Tier 2 Analysis 4: Linkage Chemistry ({name}) ===")
|
| 221 |
+
alpha_fracs = []
|
| 222 |
+
for lt in linkage_types_list:
|
| 223 |
+
if isinstance(lt, (list, np.ndarray)) and len(lt) > 0:
|
| 224 |
+
arr = np.array(lt)
|
| 225 |
+
n_valid = np.sum(arr > 0)
|
| 226 |
+
if n_valid > 0:
|
| 227 |
+
n_alpha = np.sum(arr == 1)
|
| 228 |
+
alpha_fracs.append(n_alpha / n_valid)
|
| 229 |
+
else:
|
| 230 |
+
alpha_fracs.append(0.5)
|
| 231 |
+
else:
|
| 232 |
+
alpha_fracs.append(0.5)
|
| 233 |
+
alpha_fracs = np.array(alpha_fracs)
|
| 234 |
+
def linkage_category(frac):
|
| 235 |
+
if frac >= 0.8: return 'Mostly alpha'
|
| 236 |
+
elif frac <= 0.2: return 'Mostly beta'
|
| 237 |
+
else: return 'Mixed alpha/beta'
|
| 238 |
+
categories = [linkage_category(f) for f in alpha_fracs]
|
| 239 |
+
cat_counts = Counter(categories)
|
| 240 |
+
print(f" Linkage distribution: {dict(cat_counts)}")
|
| 241 |
+
valid_cats = {c for c, n in cat_counts.items() if n >= 10}
|
| 242 |
+
mask = np.array([c in valid_cats for c in categories])
|
| 243 |
+
if mask.sum() < 30:
|
| 244 |
+
print(f" Insufficient diversity, skipping UMAP.")
|
| 245 |
+
return {'linkage_distribution': dict(cat_counts)}
|
| 246 |
+
embs_f = embs[mask]
|
| 247 |
+
cats_f = [c for c, m in zip(categories, mask) if m]
|
| 248 |
+
proj = compute_umap(embs_f)
|
| 249 |
+
colors = {'Mostly alpha': '#E53935', 'Mixed alpha/beta': '#FDD835', 'Mostly beta': '#1E88E5'}
|
| 250 |
+
unique_cats = sorted(set(cats_f))
|
| 251 |
+
fig, ax = plt.subplots(figsize=(14, 10))
|
| 252 |
+
for cat in unique_cats:
|
| 253 |
+
m = np.array([c == cat for c in cats_f])
|
| 254 |
+
ax.scatter(proj[m, 0], proj[m, 1], c=colors.get(cat, '#999'),
|
| 255 |
+
s=15, alpha=0.5, label=f'{cat} (n={m.sum()})', rasterized=True)
|
| 256 |
+
ax.set_title(f'{name}: Linkage Chemistry (alpha vs beta) in Embedding Space', fontsize=16, fontweight='bold')
|
| 257 |
+
ax.legend(fontsize=12, markerscale=3)
|
| 258 |
+
ax.set_xlabel('UMAP-1', fontsize=12); ax.set_ylabel('UMAP-2', fontsize=12)
|
| 259 |
+
plt.tight_layout()
|
| 260 |
+
plt.savefig(os.path.join(output_dir, f'linkage_chemistry_{name}.png'), dpi=200, bbox_inches='tight')
|
| 261 |
+
plt.close()
|
| 262 |
+
print(f" Saved linkage_chemistry_{name}.png")
|
| 263 |
+
alpha_mask = np.array([c == 'Mostly alpha' for c in categories])
|
| 264 |
+
beta_mask = np.array([c == 'Mostly beta' for c in categories])
|
| 265 |
+
if alpha_mask.sum() >= 10 and beta_mask.sum() >= 10:
|
| 266 |
+
a_embs = embs[alpha_mask][:500]
|
| 267 |
+
b_embs = embs[beta_mask][:500]
|
| 268 |
+
a_n = a_embs / (np.linalg.norm(a_embs, axis=1, keepdims=True) + 1e-8)
|
| 269 |
+
b_n = b_embs / (np.linalg.norm(b_embs, axis=1, keepdims=True) + 1e-8)
|
| 270 |
+
cross_sim = np.mean(a_n @ b_n.T)
|
| 271 |
+
within_a = np.mean(a_n @ a_n.T)
|
| 272 |
+
within_b = np.mean(b_n @ b_n.T)
|
| 273 |
+
print(f" a-a sim: {within_a:.4f}, b-b sim: {within_b:.4f}, a-b sim: {cross_sim:.4f}")
|
| 274 |
+
return {'linkage_distribution': dict(cat_counts),
|
| 275 |
+
'alpha_alpha_sim': round(float(within_a), 4),
|
| 276 |
+
'beta_beta_sim': round(float(within_b), 4),
|
| 277 |
+
'alpha_beta_cross_sim': round(float(cross_sim), 4)}
|
| 278 |
+
return {'linkage_distribution': dict(cat_counts)}
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def analysis_5_decorations(embs, iupacs, output_dir, name):
|
| 282 |
+
"""Fucosylation / sialylation decoration analysis."""
|
| 283 |
+
print(f"\n=== Tier 2 Analysis 5: Decorations ({name}) ===")
|
| 284 |
+
fuc_mask = np.array(['FUC' in (s or '').upper() for s in iupacs])
|
| 285 |
+
sia_mask = np.array([any(x in (s or '').upper() for x in ['NEU5AC','NEU5GC','SIA']) for s in iupacs])
|
| 286 |
+
print(f" Fucosylated: {fuc_mask.sum()}/{len(fuc_mask)}")
|
| 287 |
+
print(f" Sialylated: {sia_mask.sum()}/{len(sia_mask)}")
|
| 288 |
+
n = min(5000, len(embs))
|
| 289 |
+
idx = np.random.RandomState(42).choice(len(embs), n, replace=False)
|
| 290 |
+
proj = compute_umap(embs[idx])
|
| 291 |
+
fig, axes = plt.subplots(1, 2, figsize=(22, 9))
|
| 292 |
+
for ax, (dec_name, mask_full, color) in zip(axes, [
|
| 293 |
+
('Fucosylated', fuc_mask, '#E53935'),
|
| 294 |
+
('Sialylated', sia_mask, '#7B1FA2')
|
| 295 |
+
]):
|
| 296 |
+
m = mask_full[idx]
|
| 297 |
+
ax.scatter(proj[~m, 0], proj[~m, 1], c='#BDBDBD', s=5, alpha=0.2,
|
| 298 |
+
label=f'No (n={int((~m).sum())})', rasterized=True)
|
| 299 |
+
ax.scatter(proj[m, 0], proj[m, 1], c=color, s=15, alpha=0.6,
|
| 300 |
+
label=f'{dec_name} (n={int(m.sum())})', rasterized=True)
|
| 301 |
+
ax.set_title(f'{name}: {dec_name}', fontsize=16, fontweight='bold')
|
| 302 |
+
ax.legend(fontsize=12, markerscale=3)
|
| 303 |
+
ax.set_xlabel('UMAP-1'); ax.set_ylabel('UMAP-2')
|
| 304 |
+
plt.tight_layout()
|
| 305 |
+
plt.savefig(os.path.join(output_dir, f'decorations_{name}.png'), dpi=200, bbox_inches='tight')
|
| 306 |
+
plt.close()
|
| 307 |
+
print(f" Saved decorations_{name}.png")
|
| 308 |
+
metrics = {
|
| 309 |
+
'n_fucosylated': int(fuc_mask.sum()),
|
| 310 |
+
'n_sialylated': int(sia_mask.sum()),
|
| 311 |
+
'n_total': int(len(iupacs))
|
| 312 |
+
}
|
| 313 |
+
if fuc_mask.sum() >= 20 and (~fuc_mask).sum() >= 20:
|
| 314 |
+
f_embs = embs[fuc_mask][:500]
|
| 315 |
+
nf_embs = embs[~fuc_mask][:500]
|
| 316 |
+
f_n = f_embs / (np.linalg.norm(f_embs, axis=1, keepdims=True) + 1e-8)
|
| 317 |
+
nf_n = nf_embs / (np.linalg.norm(nf_embs, axis=1, keepdims=True) + 1e-8)
|
| 318 |
+
cross = np.mean(f_n @ nf_n.T)
|
| 319 |
+
within_f = np.mean(f_n @ f_n.T)
|
| 320 |
+
print(f" Fuc-Fuc sim: {within_f:.4f}, Fuc-NonFuc sim: {cross:.4f}")
|
| 321 |
+
metrics['fuc_fuc_sim'] = round(float(within_f), 4)
|
| 322 |
+
metrics['fuc_nonfuc_sim'] = round(float(cross), 4)
|
| 323 |
+
return metrics
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
def analysis_6_phytomining_plantae(benchmark_embs, benchmark_kingdoms, output_dir, name):
|
| 327 |
+
"""Plantae glycan cluster analysis (phytomining proxy).
|
| 328 |
+
Literature basis: Plant polysaccharides (cellulose, EPS, sulfated sugars)
|
| 329 |
+
bind metals via hydroxyl groups and 1,2-diolato coordination. Plantae glycans
|
| 330 |
+
with GlcA, Xyl, Rha, Ara monosaccharides are characteristic of plant cell wall
|
| 331 |
+
components relevant to phytomining/phytoremediation.
|
| 332 |
+
"""
|
| 333 |
+
print(f"\n=== Tier 2 Analysis 6: Plantae / Phytomining ({name}) ===")
|
| 334 |
+
plant_mask = np.array([k == 'Plantae' for k in benchmark_kingdoms])
|
| 335 |
+
animal_mask = np.array([k == 'Animalia' for k in benchmark_kingdoms])
|
| 336 |
+
bacteria_mask = np.array([k == 'Bacteria' for k in benchmark_kingdoms])
|
| 337 |
+
fungi_mask = np.array([k == 'Fungi' for k in benchmark_kingdoms])
|
| 338 |
+
print(f" Plantae: {plant_mask.sum()}, Animalia: {animal_mask.sum()}, Bacteria: {bacteria_mask.sum()}, Fungi: {fungi_mask.sum()}")
|
| 339 |
+
if plant_mask.sum() < 10:
|
| 340 |
+
print(" Not enough Plantae samples.")
|
| 341 |
+
return {}
|
| 342 |
+
proj = compute_umap(benchmark_embs)
|
| 343 |
+
fig, ax = plt.subplots(figsize=(14, 10))
|
| 344 |
+
other = ~(plant_mask | animal_mask | bacteria_mask | fungi_mask)
|
| 345 |
+
if other.sum() > 0:
|
| 346 |
+
ax.scatter(proj[other, 0], proj[other, 1], c='#BDBDBD', s=8, alpha=0.3,
|
| 347 |
+
label=f'Other (n={other.sum()})', rasterized=True)
|
| 348 |
+
if fungi_mask.sum() > 0:
|
| 349 |
+
ax.scatter(proj[fungi_mask, 0], proj[fungi_mask, 1], c='#AB47BC', s=20, alpha=0.6,
|
| 350 |
+
label=f'Fungi (n={fungi_mask.sum()})', rasterized=True)
|
| 351 |
+
if bacteria_mask.sum() > 0:
|
| 352 |
+
ax.scatter(proj[bacteria_mask, 0], proj[bacteria_mask, 1], c='#42A5F5', s=20, alpha=0.6,
|
| 353 |
+
label=f'Bacteria (n={bacteria_mask.sum()})', rasterized=True)
|
| 354 |
+
if animal_mask.sum() > 0:
|
| 355 |
+
ax.scatter(proj[animal_mask, 0], proj[animal_mask, 1], c='#FFA726', s=20, alpha=0.6,
|
| 356 |
+
label=f'Animalia (n={animal_mask.sum()})', rasterized=True)
|
| 357 |
+
ax.scatter(proj[plant_mask, 0], proj[plant_mask, 1], c='#2E7D32', s=30, alpha=0.8,
|
| 358 |
+
edgecolors='black', linewidths=0.5,
|
| 359 |
+
label=f'Plantae (n={plant_mask.sum()})', rasterized=True)
|
| 360 |
+
ax.set_title(f'{name}: Plantae Glycans in Embedding Space (Phytomining Proxy)',
|
| 361 |
+
fontsize=16, fontweight='bold')
|
| 362 |
+
ax.legend(fontsize=12, markerscale=2.5)
|
| 363 |
+
ax.set_xlabel('UMAP-1', fontsize=12); ax.set_ylabel('UMAP-2', fontsize=12)
|
| 364 |
+
plt.tight_layout()
|
| 365 |
+
plt.savefig(os.path.join(output_dir, f'plantae_phytomining_{name}.png'), dpi=200, bbox_inches='tight')
|
| 366 |
+
plt.close()
|
| 367 |
+
print(f" Saved plantae_phytomining_{name}.png")
|
| 368 |
+
metrics = {}
|
| 369 |
+
for k_name, k_mask in [('Plantae', plant_mask), ('Animalia', animal_mask), ('Bacteria', bacteria_mask), ('Fungi', fungi_mask)]:
|
| 370 |
+
if k_mask.sum() >= 10:
|
| 371 |
+
e = benchmark_embs[k_mask][:300]
|
| 372 |
+
e_n = e / (np.linalg.norm(e, axis=1, keepdims=True) + 1e-8)
|
| 373 |
+
within = np.triu(e_n @ e_n.T, k=1)
|
| 374 |
+
mask_tri = np.triu(np.ones_like(within, dtype=bool), k=1)
|
| 375 |
+
if mask_tri.sum() > 0:
|
| 376 |
+
metrics[f'{k_name.lower()}_within_sim'] = round(float(np.mean(within[mask_tri])), 4)
|
| 377 |
+
if plant_mask.sum() >= 10 and animal_mask.sum() >= 10:
|
| 378 |
+
p_n = benchmark_embs[plant_mask][:200]
|
| 379 |
+
p_n = p_n / (np.linalg.norm(p_n, axis=1, keepdims=True) + 1e-8)
|
| 380 |
+
a_n = benchmark_embs[animal_mask][:200]
|
| 381 |
+
a_n = a_n / (np.linalg.norm(a_n, axis=1, keepdims=True) + 1e-8)
|
| 382 |
+
metrics['plant_animal_cross_sim'] = round(float(np.mean(p_n @ a_n.T)), 4)
|
| 383 |
+
if plant_mask.sum() >= 10 and bacteria_mask.sum() >= 10:
|
| 384 |
+
p_n = benchmark_embs[plant_mask][:200]
|
| 385 |
+
p_n = p_n / (np.linalg.norm(p_n, axis=1, keepdims=True) + 1e-8)
|
| 386 |
+
b_n = benchmark_embs[bacteria_mask][:200]
|
| 387 |
+
b_n = b_n / (np.linalg.norm(b_n, axis=1, keepdims=True) + 1e-8)
|
| 388 |
+
metrics['plant_bacteria_cross_sim'] = round(float(np.mean(p_n @ b_n.T)), 4)
|
| 389 |
+
for k, v in metrics.items():
|
| 390 |
+
print(f" {k}: {v}")
|
| 391 |
+
return metrics
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
# ── Main ─────────────────────────────────────────────────────────────
|
| 395 |
+
|
| 396 |
+
def main():
|
| 397 |
+
parser = argparse.ArgumentParser()
|
| 398 |
+
parser.add_argument('--model', required=True, choices=['v5', 'v6'])
|
| 399 |
+
parser.add_argument('--output-dir', default='bert_v6_contrastive/analysis')
|
| 400 |
+
args = parser.parse_args()
|
| 401 |
+
|
| 402 |
+
root = Path('.')
|
| 403 |
+
output_dir = args.output_dir
|
| 404 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 405 |
+
|
| 406 |
+
# Load embeddings
|
| 407 |
+
emb_path = os.path.join(output_dir, f'embeddings_{args.model}.npz')
|
| 408 |
+
print(f"Loading embeddings from {emb_path}...")
|
| 409 |
+
data = np.load(emb_path, allow_pickle=True)
|
| 410 |
+
train_embs = data['train_embs']
|
| 411 |
+
benchmark_embs = data['benchmark_embs']
|
| 412 |
+
benchmark_kingdoms = data['benchmark_kingdom']
|
| 413 |
+
|
| 414 |
+
# Load original training data for IUPAC names, branch_depths, linkage_types
|
| 415 |
+
data_path = root / 'bert_v5_bpe_topo' / 'data' / 'sequences_bpe_expanded.pkl'
|
| 416 |
+
print(f"Loading training data from {data_path}...")
|
| 417 |
+
with open(data_path, 'rb') as f:
|
| 418 |
+
all_samples = pickle.load(f)
|
| 419 |
+
|
| 420 |
+
# We need IUPAC names for the same 10K training samples
|
| 421 |
+
# The extraction script used random.sample with seed 42
|
| 422 |
+
import random
|
| 423 |
+
random.seed(42)
|
| 424 |
+
n_train = min(10000, len(all_samples))
|
| 425 |
+
sampled = random.sample(all_samples, n_train)
|
| 426 |
+
|
| 427 |
+
iupacs = [s.get('iupac_name', '') or '' for s in sampled]
|
| 428 |
+
branch_depths = [s.get('branch_depths', []) for s in sampled]
|
| 429 |
+
linkage_types = [s.get('linkage_types', []) for s in sampled]
|
| 430 |
+
|
| 431 |
+
print(f" {len(iupacs)} training samples with metadata")
|
| 432 |
+
print(f" IUPAC example: {iupacs[0][:80]}")
|
| 433 |
+
|
| 434 |
+
# Run analyses
|
| 435 |
+
metrics = {'model': args.model, 'tier': 2}
|
| 436 |
+
|
| 437 |
+
m = analysis_1_structural_types(train_embs, iupacs, output_dir, args.model)
|
| 438 |
+
metrics.update(m)
|
| 439 |
+
|
| 440 |
+
m = analysis_2_composition(train_embs, iupacs, output_dir, args.model)
|
| 441 |
+
metrics.update(m)
|
| 442 |
+
|
| 443 |
+
m = analysis_3_branching(train_embs, branch_depths, output_dir, args.model)
|
| 444 |
+
metrics.update(m)
|
| 445 |
+
|
| 446 |
+
m = analysis_4_linkage(train_embs, linkage_types, output_dir, args.model)
|
| 447 |
+
metrics.update(m)
|
| 448 |
+
|
| 449 |
+
m = analysis_5_decorations(train_embs, iupacs, output_dir, args.model)
|
| 450 |
+
metrics.update(m)
|
| 451 |
+
|
| 452 |
+
m = analysis_6_phytomining_plantae(benchmark_embs, benchmark_kingdoms, output_dir, args.model)
|
| 453 |
+
metrics.update(m)
|
| 454 |
+
|
| 455 |
+
# Save metrics
|
| 456 |
+
out_path = os.path.join(output_dir, f'tier2_metrics_{args.model}.json')
|
| 457 |
+
def convert(o):
|
| 458 |
+
if isinstance(o, (np.integer,)): return int(o)
|
| 459 |
+
if isinstance(o, (np.floating,)): return float(o)
|
| 460 |
+
if isinstance(o, np.ndarray): return o.tolist()
|
| 461 |
+
return o
|
| 462 |
+
with open(out_path, 'w') as f:
|
| 463 |
+
json.dump(metrics, f, indent=2, default=convert)
|
| 464 |
+
print(f"\nAll Tier 2 metrics saved to {out_path}")
|
| 465 |
+
|
| 466 |
+
if __name__ == '__main__':
|
| 467 |
+
main()
|
code/probes/analyze_embeddings.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Embedding Space Deep Dive Analysis - 6 analysis types."""
|
| 3 |
+
|
| 4 |
+
import os, sys, json, argparse
|
| 5 |
+
import numpy as np
|
| 6 |
+
from collections import Counter
|
| 7 |
+
import matplotlib
|
| 8 |
+
matplotlib.use('Agg')
|
| 9 |
+
import matplotlib.pyplot as plt
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def load_embeddings(npz_path):
|
| 13 |
+
print(f"Loading {npz_path}")
|
| 14 |
+
data = np.load(npz_path, allow_pickle=True)
|
| 15 |
+
for k in data.keys():
|
| 16 |
+
if hasattr(data[k], 'shape'):
|
| 17 |
+
print(f" {k}: {data[k].shape}")
|
| 18 |
+
return data
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def compute_umap(embeddings, n_neighbors=15, min_dist=0.1, random_state=42):
|
| 22 |
+
try:
|
| 23 |
+
from umap import UMAP
|
| 24 |
+
return UMAP(n_neighbors=n_neighbors, min_dist=min_dist, random_state=random_state, n_components=2).fit_transform(embeddings)
|
| 25 |
+
except ImportError:
|
| 26 |
+
print(" umap-learn not installed, falling back to t-SNE")
|
| 27 |
+
from sklearn.manifold import TSNE
|
| 28 |
+
if len(embeddings) > 15000:
|
| 29 |
+
idx = np.random.choice(len(embeddings), 15000, replace=False)
|
| 30 |
+
embeddings = embeddings[idx]
|
| 31 |
+
return TSNE(n_components=2, perplexity=30, random_state=random_state).fit_transform(embeddings)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def analysis_1_valid_vs_impossible(data, output_dir, name):
|
| 35 |
+
"""UMAP: valid training samples vs impossible negatives by difficulty."""
|
| 36 |
+
print("\n=== Analysis 1: Valid vs Impossible ===")
|
| 37 |
+
train, easy, medium, hard = data['train_embs'], data['easy_embs'], data['medium_embs'], data['hard_embs']
|
| 38 |
+
n_neg = len(easy) + len(medium) + len(hard)
|
| 39 |
+
n = min(len(train), n_neg)
|
| 40 |
+
train_sub = train[np.random.choice(len(train), n, replace=False)]
|
| 41 |
+
|
| 42 |
+
all_embs = np.vstack([train_sub, easy, medium, hard])
|
| 43 |
+
labels = ['Valid']*len(train_sub) + ['Easy']*len(easy) + ['Medium']*len(medium) + ['Hard']*len(hard)
|
| 44 |
+
|
| 45 |
+
proj = compute_umap(all_embs)
|
| 46 |
+
fig, ax = plt.subplots(figsize=(12, 10))
|
| 47 |
+
colors = {'Valid': '#2196F3', 'Easy': '#66BB6A', 'Medium': '#FFA726', 'Hard': '#EF5350'}
|
| 48 |
+
for label in ['Valid', 'Easy', 'Medium', 'Hard']:
|
| 49 |
+
mask = np.array([l == label for l in labels])
|
| 50 |
+
ax.scatter(proj[mask, 0], proj[mask, 1], c=colors[label], s=3, alpha=0.4, label=label, rasterized=True)
|
| 51 |
+
ax.set_title(f'{name}: Valid vs Impossible Glycans', fontsize=16, fontweight='bold')
|
| 52 |
+
ax.legend(markerscale=5, fontsize=12)
|
| 53 |
+
ax.set_xlabel('UMAP-1'); ax.set_ylabel('UMAP-2')
|
| 54 |
+
plt.tight_layout()
|
| 55 |
+
plt.savefig(os.path.join(output_dir, f'umap_valid_vs_impossible_{name}.png'), dpi=200, bbox_inches='tight')
|
| 56 |
+
plt.close()
|
| 57 |
+
print(f" Saved umap_valid_vs_impossible_{name}.png")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def analysis_2_train_vs_heldout(data, output_dir, name):
|
| 61 |
+
"""UMAP: train vs val vs test split."""
|
| 62 |
+
print("\n=== Analysis 2: Train vs Held-Out ===")
|
| 63 |
+
embs, splits = data['benchmark_embs'], data['benchmark_split']
|
| 64 |
+
if len(embs) == 0: print(" No data."); return {}
|
| 65 |
+
|
| 66 |
+
proj = compute_umap(embs)
|
| 67 |
+
fig, ax = plt.subplots(figsize=(12, 10))
|
| 68 |
+
colors = {'train': '#2196F3', 'val': '#FFA726', 'test': '#EF5350'}
|
| 69 |
+
for split in ['train', 'val', 'test']:
|
| 70 |
+
mask = np.array([s == split for s in splits])
|
| 71 |
+
if mask.sum(): ax.scatter(proj[mask, 0], proj[mask, 1], c=colors.get(split, '#999'), s=5, alpha=0.5, label=f'{split} ({mask.sum()})', rasterized=True)
|
| 72 |
+
ax.set_title(f'{name}: Train vs Held-Out', fontsize=16, fontweight='bold')
|
| 73 |
+
ax.legend(markerscale=5, fontsize=12)
|
| 74 |
+
ax.set_xlabel('UMAP-1'); ax.set_ylabel('UMAP-2')
|
| 75 |
+
plt.tight_layout()
|
| 76 |
+
plt.savefig(os.path.join(output_dir, f'umap_train_vs_heldout_{name}.png'), dpi=200, bbox_inches='tight')
|
| 77 |
+
plt.close()
|
| 78 |
+
print(f" Saved umap_train_vs_heldout_{name}.png")
|
| 79 |
+
return dict(Counter(splits))
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def analysis_3_taxonomy(data, output_dir, name):
|
| 83 |
+
"""UMAP colored by taxonomy level + silhouette scores."""
|
| 84 |
+
print("\n=== Analysis 3: Taxonomy Clustering ===")
|
| 85 |
+
embs = data['benchmark_embs']
|
| 86 |
+
if len(embs) == 0: print(" No data."); return {}
|
| 87 |
+
|
| 88 |
+
proj = compute_umap(embs)
|
| 89 |
+
metrics = {}
|
| 90 |
+
|
| 91 |
+
for level in ['kingdom', 'phylum', 'class']:
|
| 92 |
+
labels = data[f'benchmark_{level}']
|
| 93 |
+
valid = np.array([l != '' and l != 'nan' for l in labels])
|
| 94 |
+
if valid.sum() < 10: continue
|
| 95 |
+
|
| 96 |
+
proj_v, labels_v = proj[valid], labels[valid]
|
| 97 |
+
counts = Counter(labels_v)
|
| 98 |
+
top12 = [l for l, _ in counts.most_common(12)]
|
| 99 |
+
cmap = plt.cm.get_cmap('tab20', len(top12))
|
| 100 |
+
|
| 101 |
+
fig, ax = plt.subplots(figsize=(14, 10))
|
| 102 |
+
other = np.array([l not in top12 for l in labels_v])
|
| 103 |
+
if other.sum(): ax.scatter(proj_v[other, 0], proj_v[other, 1], c='#CCC', s=3, alpha=0.2, label='Other', rasterized=True)
|
| 104 |
+
for i, lab in enumerate(top12):
|
| 105 |
+
m = np.array([l == lab for l in labels_v])
|
| 106 |
+
ax.scatter(proj_v[m, 0], proj_v[m, 1], c=[cmap(i)], s=5, alpha=0.5, label=f'{lab} ({m.sum()})', rasterized=True)
|
| 107 |
+
ax.set_title(f'{name}: {level.capitalize()} Clustering', fontsize=16, fontweight='bold')
|
| 108 |
+
ax.legend(markerscale=5, fontsize=9, ncol=2)
|
| 109 |
+
ax.set_xlabel('UMAP-1'); ax.set_ylabel('UMAP-2')
|
| 110 |
+
plt.tight_layout()
|
| 111 |
+
plt.savefig(os.path.join(output_dir, f'umap_taxonomy_{level}_{name}.png'), dpi=200, bbox_inches='tight')
|
| 112 |
+
plt.close()
|
| 113 |
+
|
| 114 |
+
try:
|
| 115 |
+
from sklearn.metrics import silhouette_score
|
| 116 |
+
label_map = {l: i for i, l in enumerate(set(labels_v))}
|
| 117 |
+
numeric = np.array([label_map[l] for l in labels_v])
|
| 118 |
+
if len(set(numeric)) > 1:
|
| 119 |
+
sil = silhouette_score(proj_v, numeric, sample_size=min(5000, len(proj_v)))
|
| 120 |
+
metrics[f'silhouette_{level}'] = round(float(sil), 4)
|
| 121 |
+
print(f" Silhouette ({level}): {sil:.4f}")
|
| 122 |
+
except Exception as e:
|
| 123 |
+
print(f" Silhouette error: {e}")
|
| 124 |
+
|
| 125 |
+
return metrics
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def analysis_4_distances(data, output_dir, name):
|
| 129 |
+
"""Cosine distance distributions: same vs different kingdom."""
|
| 130 |
+
print("\n=== Analysis 4: Distance Distributions ===")
|
| 131 |
+
embs, kingdoms = data['benchmark_embs'], data['benchmark_kingdom']
|
| 132 |
+
if len(embs) < 100: print(" Not enough data."); return {}
|
| 133 |
+
|
| 134 |
+
n = min(2000, len(embs))
|
| 135 |
+
idx = np.random.choice(len(embs), n, replace=False)
|
| 136 |
+
embs_sub = embs[idx]
|
| 137 |
+
labels = kingdoms[idx]
|
| 138 |
+
norms = np.linalg.norm(embs_sub, axis=1, keepdims=True)
|
| 139 |
+
embs_n = embs_sub / (norms + 1e-8)
|
| 140 |
+
sim = embs_n @ embs_n.T
|
| 141 |
+
|
| 142 |
+
same, diff = [], []
|
| 143 |
+
for i in range(n):
|
| 144 |
+
for j in range(i+1, min(i+200, n)):
|
| 145 |
+
s = float(sim[i, j])
|
| 146 |
+
if labels[i] == labels[j] and labels[i] != '': same.append(s)
|
| 147 |
+
elif labels[i] != '' and labels[j] != '': diff.append(s)
|
| 148 |
+
|
| 149 |
+
if not same or not diff: return {}
|
| 150 |
+
|
| 151 |
+
fig, ax = plt.subplots(figsize=(10, 6))
|
| 152 |
+
ax.hist(same, bins=60, alpha=0.6, color='#2196F3', density=True, label=f'Same kingdom (n={len(same)})')
|
| 153 |
+
ax.hist(diff, bins=60, alpha=0.6, color='#EF5350', density=True, label=f'Diff kingdom (n={len(diff)})')
|
| 154 |
+
ax.axvline(np.mean(same), color='#1565C0', ls='--', alpha=0.7)
|
| 155 |
+
ax.axvline(np.mean(diff), color='#C62828', ls='--', alpha=0.7)
|
| 156 |
+
ax.set_xlabel('Cosine Similarity', fontsize=14); ax.set_ylabel('Density', fontsize=14)
|
| 157 |
+
ax.set_title(f'{name}: Cosine Similarity Distribution', fontsize=16, fontweight='bold')
|
| 158 |
+
ax.legend(fontsize=12)
|
| 159 |
+
plt.tight_layout()
|
| 160 |
+
plt.savefig(os.path.join(output_dir, f'distance_distributions_{name}.png'), dpi=200, bbox_inches='tight')
|
| 161 |
+
plt.close()
|
| 162 |
+
|
| 163 |
+
gap = float(np.mean(same) - np.mean(diff))
|
| 164 |
+
print(f" Same: {np.mean(same):.4f}, Diff: {np.mean(diff):.4f}, Gap: {gap:.4f}")
|
| 165 |
+
return {'mean_same_sim': round(float(np.mean(same)), 4), 'mean_diff_sim': round(float(np.mean(diff)), 4), 'separation_gap': round(gap, 4)}
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def analysis_5_knn_purity(data, output_dir, name, k=10):
|
| 169 |
+
"""KNN purity: do test glycans match train neighbors?"""
|
| 170 |
+
print(f"\n=== Analysis 5: KNN Purity (K={k}) ===")
|
| 171 |
+
embs, splits, kingdoms = data['benchmark_embs'], data['benchmark_split'], data['benchmark_kingdom']
|
| 172 |
+
train_m = np.array([s == 'train' for s in splits])
|
| 173 |
+
test_m = np.array([s == 'test' for s in splits])
|
| 174 |
+
if train_m.sum() == 0 or test_m.sum() == 0: print(" No train/test data."); return {}
|
| 175 |
+
|
| 176 |
+
tr_e = embs[train_m]; tr_l = kingdoms[train_m]
|
| 177 |
+
te_e = embs[test_m]; te_l = kingdoms[test_m]
|
| 178 |
+
tr_n = tr_e / (np.linalg.norm(tr_e, axis=1, keepdims=True) + 1e-8)
|
| 179 |
+
te_n = te_e / (np.linalg.norm(te_e, axis=1, keepdims=True) + 1e-8)
|
| 180 |
+
|
| 181 |
+
purities = []
|
| 182 |
+
for i in range(len(te_e)):
|
| 183 |
+
sims = te_n[i] @ tr_n.T
|
| 184 |
+
topk = np.argsort(sims)[-k:]
|
| 185 |
+
if te_l[i] != '' and te_l[i] != 'nan':
|
| 186 |
+
purities.append(float(np.mean(tr_l[topk] == te_l[i])))
|
| 187 |
+
|
| 188 |
+
if not purities: return {}
|
| 189 |
+
|
| 190 |
+
fig, ax = plt.subplots(figsize=(10, 6))
|
| 191 |
+
ax.hist(purities, bins=30, color='#4CAF50', alpha=0.7, edgecolor='black')
|
| 192 |
+
ax.axvline(np.mean(purities), color='red', ls='--', lw=2, label=f'Mean: {np.mean(purities):.3f}')
|
| 193 |
+
ax.set_xlabel(f'KNN Purity (K={k})', fontsize=14); ax.set_ylabel('Count', fontsize=14)
|
| 194 |
+
ax.set_title(f'{name}: KNN Purity (Generalization Test)', fontsize=14, fontweight='bold')
|
| 195 |
+
ax.legend(fontsize=12)
|
| 196 |
+
plt.tight_layout()
|
| 197 |
+
plt.savefig(os.path.join(output_dir, f'knn_purity_{name}.png'), dpi=200, bbox_inches='tight')
|
| 198 |
+
plt.close()
|
| 199 |
+
print(f" Mean: {np.mean(purities):.4f}, Median: {np.median(purities):.4f}")
|
| 200 |
+
return {'knn_purity_mean': round(float(np.mean(purities)), 4), 'knn_purity_median': round(float(np.median(purities)), 4), 'n_test': len(purities)}
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def analysis_6_v5_vs_v6(output_dir):
|
| 204 |
+
"""Side-by-side V5 vs V6 comparison."""
|
| 205 |
+
print("\n=== Analysis 6: V5 vs V6 Comparison ===")
|
| 206 |
+
v5p, v6p = os.path.join(output_dir, 'embeddings_v5.npz'), os.path.join(output_dir, 'embeddings_v6.npz')
|
| 207 |
+
if not os.path.exists(v5p) or not os.path.exists(v6p): print(" Need both."); return {}
|
| 208 |
+
|
| 209 |
+
v5, v6 = np.load(v5p, allow_pickle=True), np.load(v6p, allow_pickle=True)
|
| 210 |
+
metrics = {}
|
| 211 |
+
|
| 212 |
+
# Pairwise similarity stats
|
| 213 |
+
for ver, d in [('v5', v5), ('v6', v6)]:
|
| 214 |
+
n = min(1000, len(d['train_embs']))
|
| 215 |
+
e = d['train_embs'][:n]
|
| 216 |
+
e_n = e / (np.linalg.norm(e, axis=1, keepdims=True) + 1e-8)
|
| 217 |
+
sim = e_n @ e_n.T
|
| 218 |
+
mask = np.triu(np.ones_like(sim, dtype=bool), k=1)
|
| 219 |
+
metrics[f'{ver}_mean_pairwise_sim'] = round(float(np.mean(sim[mask])), 4)
|
| 220 |
+
metrics[f'{ver}_std_pairwise_sim'] = round(float(np.std(sim[mask])), 4)
|
| 221 |
+
|
| 222 |
+
# Valid vs hard impossible separation
|
| 223 |
+
for ver, d in [('v5', v5), ('v6', v6)]:
|
| 224 |
+
tr, hr = d['train_embs'], d['hard_embs']
|
| 225 |
+
n = min(500, len(tr), len(hr))
|
| 226 |
+
t_n = tr[:n] / (np.linalg.norm(tr[:n], axis=1, keepdims=True) + 1e-8)
|
| 227 |
+
h_n = hr[:n] / (np.linalg.norm(hr[:n], axis=1, keepdims=True) + 1e-8)
|
| 228 |
+
metrics[f'{ver}_valid_hard_sim'] = round(float(np.mean(t_n @ h_n.T)), 4)
|
| 229 |
+
|
| 230 |
+
# Side-by-side UMAP
|
| 231 |
+
fig, axes = plt.subplots(1, 2, figsize=(24, 10))
|
| 232 |
+
for ax, (ver, d) in zip(axes, [('V5', v5), ('V6', v6)]):
|
| 233 |
+
n = min(3000, len(d['train_embs']), len(d['hard_embs']))
|
| 234 |
+
combined = np.vstack([d['train_embs'][:n], d['hard_embs'][:n]])
|
| 235 |
+
labels = ['Valid']*n + ['Hard Impossible']*min(n, len(d['hard_embs']))
|
| 236 |
+
proj = compute_umap(combined)
|
| 237 |
+
for lab, col in [('Valid', '#2196F3'), ('Hard Impossible', '#EF5350')]:
|
| 238 |
+
m = np.array([l == lab for l in labels])
|
| 239 |
+
ax.scatter(proj[m, 0], proj[m, 1], c=col, s=3, alpha=0.4, label=lab, rasterized=True)
|
| 240 |
+
ax.set_title(f'{ver}: Valid vs Hard Impossible', fontsize=16, fontweight='bold')
|
| 241 |
+
ax.legend(markerscale=5, fontsize=12)
|
| 242 |
+
ax.set_xlabel('UMAP-1'); ax.set_ylabel('UMAP-2')
|
| 243 |
+
plt.tight_layout()
|
| 244 |
+
plt.savefig(os.path.join(output_dir, 'v5_vs_v6_comparison.png'), dpi=200, bbox_inches='tight')
|
| 245 |
+
plt.close()
|
| 246 |
+
|
| 247 |
+
for k, v in metrics.items(): print(f" {k}: {v}")
|
| 248 |
+
return metrics
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def main():
|
| 252 |
+
parser = argparse.ArgumentParser()
|
| 253 |
+
parser.add_argument('--input', required=True)
|
| 254 |
+
parser.add_argument('--name', required=True)
|
| 255 |
+
parser.add_argument('--output_dir', default='bert_v6_contrastive/analysis')
|
| 256 |
+
parser.add_argument('--compare', action='store_true')
|
| 257 |
+
args = parser.parse_args()
|
| 258 |
+
|
| 259 |
+
os.makedirs(args.output_dir, exist_ok=True)
|
| 260 |
+
data = load_embeddings(args.input)
|
| 261 |
+
metrics = {'model': args.name}
|
| 262 |
+
|
| 263 |
+
analysis_1_valid_vs_impossible(data, args.output_dir, args.name)
|
| 264 |
+
metrics.update(analysis_2_train_vs_heldout(data, args.output_dir, args.name))
|
| 265 |
+
metrics.update(analysis_3_taxonomy(data, args.output_dir, args.name))
|
| 266 |
+
metrics.update(analysis_4_distances(data, args.output_dir, args.name))
|
| 267 |
+
metrics.update(analysis_5_knn_purity(data, args.output_dir, args.name))
|
| 268 |
+
if args.compare:
|
| 269 |
+
metrics.update(analysis_6_v5_vs_v6(args.output_dir))
|
| 270 |
+
|
| 271 |
+
out = os.path.join(args.output_dir, f'metrics_{args.name}.json')
|
| 272 |
+
json.dump(metrics, open(out, 'w'), indent=2)
|
| 273 |
+
print(f"\nAll metrics saved to {out}")
|
| 274 |
+
|
| 275 |
+
if __name__ == '__main__':
|
| 276 |
+
main()
|
code/probes/cluster_scripts/run_benchmark_embeddings.sh
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
#SBATCH --job-name=bench_embed
|
| 3 |
+
#SBATCH --partition=nova
|
| 4 |
+
#SBATCH --gres=gpu:1
|
| 5 |
+
#SBATCH --cpus-per-task=8
|
| 6 |
+
#SBATCH --mem=32G
|
| 7 |
+
#SBATCH --time=01:00:00
|
| 8 |
+
#SBATCH --output=bert_v6_contrastive/scripts/bench_embed_%j.out
|
| 9 |
+
#SBATCH --error=bert_v6_contrastive/scripts/bench_embed_%j.err
|
| 10 |
+
|
| 11 |
+
echo "========================================"
|
| 12 |
+
echo "Benchmark Embedding Extraction + Visualization"
|
| 13 |
+
echo "Job ID: $SLURM_JOB_ID"
|
| 14 |
+
echo "Node: $(hostname)"
|
| 15 |
+
echo "Started: $(date)"
|
| 16 |
+
echo "========================================"
|
| 17 |
+
|
| 18 |
+
cd /work/ratul1/supantha/glycan-SD-VS/bert_training_v3/v3.1_cluster_training
|
| 19 |
+
source /work/ratul1/supantha/miniconda3/etc/profile.d/conda.sh
|
| 20 |
+
conda activate glycan_bert 2>/dev/null || source activate glycan_bert 2>/dev/null || true
|
| 21 |
+
pip install -q scikit-learn umap-learn 2>/dev/null
|
| 22 |
+
|
| 23 |
+
SCRIPT=bert_v6_contrastive/scripts/embed_benchmark_tasks.py
|
| 24 |
+
OUTDIR=bert_v6_contrastive/analysis/benchmark_embeddings
|
| 25 |
+
|
| 26 |
+
echo ""
|
| 27 |
+
echo "========== V5 Embeddings (val+test) =========="
|
| 28 |
+
python3 $SCRIPT --model v5 --splits val test --method both --output_dir $OUTDIR
|
| 29 |
+
|
| 30 |
+
echo ""
|
| 31 |
+
echo "========== V5 ALL data (incl train) =========="
|
| 32 |
+
python3 $SCRIPT --model v5 --embed_all --method both --output_dir ${OUTDIR}_all
|
| 33 |
+
|
| 34 |
+
# V6 only if checkpoint exists
|
| 35 |
+
if [ -f "bert_v6_contrastive/checkpoints/phase_3_hard_checkpoint.pt" ] || \
|
| 36 |
+
[ -f "bert_v6_contrastive/checkpoints/best_model.pt" ] || \
|
| 37 |
+
[ -f "bert_v6_contrastive/checkpoints/checkpoint_latest.pt" ]; then
|
| 38 |
+
echo ""
|
| 39 |
+
echo "========== V6 Embeddings (val+test) =========="
|
| 40 |
+
python3 $SCRIPT --model v6 --splits val test --method both --output_dir $OUTDIR
|
| 41 |
+
echo ""
|
| 42 |
+
echo "========== V6 ALL data (incl train) =========="
|
| 43 |
+
python3 $SCRIPT --model v6 --embed_all --method both --output_dir ${OUTDIR}_all
|
| 44 |
+
else
|
| 45 |
+
echo ""
|
| 46 |
+
echo "SKIPPING V6: No checkpoint found (training incomplete)"
|
| 47 |
+
fi
|
| 48 |
+
|
| 49 |
+
echo ""
|
| 50 |
+
echo "Generated files:"
|
| 51 |
+
ls -la $OUTDIR/ 2>/dev/null
|
| 52 |
+
ls -la ${OUTDIR}_all/ 2>/dev/null
|
| 53 |
+
echo ""
|
| 54 |
+
echo "Completed: $(date)"
|
code/probes/cluster_scripts/run_diag_zero.sh
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
#SBATCH --job-name=diag_zero
|
| 3 |
+
#SBATCH --partition=nova
|
| 4 |
+
#SBATCH --nodes=1
|
| 5 |
+
#SBATCH --ntasks=1
|
| 6 |
+
#SBATCH --cpus-per-task=8
|
| 7 |
+
#SBATCH --mem=32G
|
| 8 |
+
#SBATCH --time=00:30:00
|
| 9 |
+
#SBATCH --output=logs/diag_zero_%j.out
|
| 10 |
+
#SBATCH --error=logs/diag_zero_%j.err
|
| 11 |
+
|
| 12 |
+
echo "Started: $(date)"
|
| 13 |
+
echo "Host: $(hostname)"
|
| 14 |
+
|
| 15 |
+
cd /work/ratul1/supantha/glycan-SD-VS/bert_training_v3/v3.1_cluster_training
|
| 16 |
+
|
| 17 |
+
# Use glycanml conda env
|
| 18 |
+
export PATH="/work/ratul1/supantha/conda/glycanml/bin:$PATH"
|
| 19 |
+
|
| 20 |
+
python bert_v6_contrastive/scripts/diagnose_zero_cluster.py
|
| 21 |
+
|
| 22 |
+
echo ""
|
| 23 |
+
echo "Completed: $(date)"
|
code/probes/cluster_scripts/run_embedding_analysis.sh
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
#SBATCH --job-name=emb_deep_dive
|
| 3 |
+
#SBATCH --partition=nova
|
| 4 |
+
#SBATCH --nodes=1
|
| 5 |
+
#SBATCH --ntasks=1
|
| 6 |
+
#SBATCH --cpus-per-task=8
|
| 7 |
+
#SBATCH --gres=gpu:1
|
| 8 |
+
#SBATCH --mem=64G
|
| 9 |
+
#SBATCH --time=02:00:00
|
| 10 |
+
#SBATCH --output=bert_v6_contrastive/scripts/emb_analysis_%j.out
|
| 11 |
+
#SBATCH --error=bert_v6_contrastive/scripts/emb_analysis_%j.err
|
| 12 |
+
#SBATCH --mail-type=END,FAIL
|
| 13 |
+
|
| 14 |
+
echo "=== Embedding Deep Dive ==="
|
| 15 |
+
echo "Job: $SLURM_JOB_ID | Node: $(hostname) | $(date)"
|
| 16 |
+
nvidia-smi --query-gpu=name --format=csv,noheader
|
| 17 |
+
|
| 18 |
+
source activate glycan_bert 2>/dev/null || conda activate glycan_bert 2>/dev/null || true
|
| 19 |
+
cd /work/ratul1/supantha/glycan-SD-VS/bert_training_v3/v3.1_cluster_training
|
| 20 |
+
pip install umap-learn scikit-learn 2>/dev/null || true
|
| 21 |
+
mkdir -p bert_v6_contrastive/analysis
|
| 22 |
+
|
| 23 |
+
echo -e "\n=== Step 1: Extract V6 ==="
|
| 24 |
+
python3 bert_v6_contrastive/scripts/extract_embeddings.py \
|
| 25 |
+
--checkpoint checkpoints_v6/phase_3_hard_checkpoint.pt --name v6
|
| 26 |
+
|
| 27 |
+
echo -e "\n=== Step 2: Extract V5 ==="
|
| 28 |
+
python3 bert_v6_contrastive/scripts/extract_embeddings.py \
|
| 29 |
+
--checkpoint checkpoints_v5_bpe_topo/best_v5_bpe_topo_model.pt --name v5
|
| 30 |
+
|
| 31 |
+
echo -e "\n=== Step 3: Analyze V6 ==="
|
| 32 |
+
python3 bert_v6_contrastive/scripts/analyze_embeddings.py \
|
| 33 |
+
--input bert_v6_contrastive/analysis/embeddings_v6.npz --name v6
|
| 34 |
+
|
| 35 |
+
echo -e "\n=== Step 4: Analyze V5 ==="
|
| 36 |
+
python3 bert_v6_contrastive/scripts/analyze_embeddings.py \
|
| 37 |
+
--input bert_v6_contrastive/analysis/embeddings_v5.npz --name v5
|
| 38 |
+
|
| 39 |
+
echo -e "\n=== Step 5: V5 vs V6 Comparison ==="
|
| 40 |
+
python3 bert_v6_contrastive/scripts/analyze_embeddings.py \
|
| 41 |
+
--input bert_v6_contrastive/analysis/embeddings_v6.npz --name v6 --compare
|
| 42 |
+
|
| 43 |
+
echo -e "\n=== Results ==="
|
| 44 |
+
ls -la bert_v6_contrastive/analysis/
|
| 45 |
+
echo "Done! $(date)"
|
code/probes/cluster_scripts/run_explore_bio.sh
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
#SBATCH --job-name=explore_bio
|
| 3 |
+
#SBATCH --partition=nova
|
| 4 |
+
#SBATCH --gres=gpu:a100:1
|
| 5 |
+
#SBATCH --mem=64G
|
| 6 |
+
#SBATCH --cpus-per-task=8
|
| 7 |
+
#SBATCH --time=02:00:00
|
| 8 |
+
#SBATCH --output=bert_v6_contrastive/analysis/probing_analysis/slurm_logs/explore_bio_%j.out
|
| 9 |
+
#SBATCH --error=bert_v6_contrastive/analysis/probing_analysis/slurm_logs/explore_bio_%j.err
|
| 10 |
+
|
| 11 |
+
export PYTHONUNBUFFERED=1
|
| 12 |
+
|
| 13 |
+
echo "========================================"
|
| 14 |
+
echo "Exploratory Biology Probes"
|
| 15 |
+
echo "Job ID: $SLURM_JOB_ID"
|
| 16 |
+
echo "Node: $(hostname)"
|
| 17 |
+
echo "Start: $(date)"
|
| 18 |
+
echo "========================================"
|
| 19 |
+
|
| 20 |
+
source activate glycanml 2>/dev/null || conda activate glycanml
|
| 21 |
+
|
| 22 |
+
python3 -c "
|
| 23 |
+
import sys, torch
|
| 24 |
+
print('Python:', sys.executable)
|
| 25 |
+
print(f'PyTorch: {torch.__version__} CUDA: {torch.cuda.is_available()}')
|
| 26 |
+
if torch.cuda.is_available():
|
| 27 |
+
print(f'{torch.cuda.get_device_name()}, {torch.cuda.get_device_properties(0).total_memory // 1024**2} MiB')
|
| 28 |
+
"
|
| 29 |
+
|
| 30 |
+
python3 -u bert_v6_contrastive/scripts/probe_exploratory_biology.py --model both
|
| 31 |
+
|
| 32 |
+
echo "Completed: $(date)"
|
| 33 |
+
ls -la bert_v6_contrastive/analysis/probing_analysis/exploratory_bio_v5/ 2>/dev/null
|
| 34 |
+
echo "---"
|
| 35 |
+
ls -la bert_v6_contrastive/analysis/probing_analysis/exploratory_bio_v6/ 2>/dev/null
|
code/probes/cluster_scripts/run_novel_probes.sh
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
#SBATCH --job-name=novel_probes
|
| 3 |
+
#SBATCH --partition=nova
|
| 4 |
+
#SBATCH --gres=gpu:1
|
| 5 |
+
#SBATCH --cpus-per-task=8
|
| 6 |
+
#SBATCH --mem=32G
|
| 7 |
+
#SBATCH --time=02:00:00
|
| 8 |
+
#SBATCH --output=bert_v6_contrastive/scripts/novel_probes_%j.out
|
| 9 |
+
#SBATCH --error=bert_v6_contrastive/scripts/novel_probes_%j.err
|
| 10 |
+
|
| 11 |
+
echo "========================================"
|
| 12 |
+
echo "Novel Embedding Probes"
|
| 13 |
+
echo "Job ID: $SLURM_JOB_ID"
|
| 14 |
+
echo "Node: $(hostname)"
|
| 15 |
+
echo "Started: $(date)"
|
| 16 |
+
echo "========================================"
|
| 17 |
+
|
| 18 |
+
cd /work/ratul1/supantha/glycan-SD-VS/bert_training_v3/v3.1_cluster_training
|
| 19 |
+
source /work/ratul1/supantha/miniconda3/etc/profile.d/conda.sh
|
| 20 |
+
conda activate glycan_bert 2>/dev/null || source activate glycan_bert 2>/dev/null || true
|
| 21 |
+
pip install -q scikit-learn umap-learn scipy 2>/dev/null
|
| 22 |
+
|
| 23 |
+
SCRIPT=bert_v6_contrastive/scripts/novel_probes.py
|
| 24 |
+
|
| 25 |
+
echo ""
|
| 26 |
+
echo "========== V5 — All 7 Novel Probes =========="
|
| 27 |
+
python3 $SCRIPT --model v5 --probe all --max_samples 2000
|
| 28 |
+
|
| 29 |
+
echo ""
|
| 30 |
+
echo "Generated files:"
|
| 31 |
+
ls -la bert_v6_contrastive/analysis/novel_probes_v5/ 2>/dev/null
|
| 32 |
+
echo ""
|
| 33 |
+
echo "Completed: $(date)"
|
code/probes/cluster_scripts/run_novel_probes_v2.sh
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
#SBATCH --job-name=novel_probes_v2
|
| 3 |
+
#SBATCH --partition=nova
|
| 4 |
+
#SBATCH --gres=gpu:a100:1
|
| 5 |
+
#SBATCH --cpus-per-task=8
|
| 6 |
+
#SBATCH --mem=64G
|
| 7 |
+
#SBATCH --time=04:00:00
|
| 8 |
+
#SBATCH --output=bert_v6_contrastive/scripts/novel_probes_v2_%j.out
|
| 9 |
+
#SBATCH --error=bert_v6_contrastive/scripts/novel_probes_v2_%j.err
|
| 10 |
+
|
| 11 |
+
echo "========================================"
|
| 12 |
+
echo "Novel Embedding Probes V2 — v5a vs v6"
|
| 13 |
+
echo "Job ID: $SLURM_JOB_ID"
|
| 14 |
+
echo "Node: $(hostname)"
|
| 15 |
+
echo "Started: $(date)"
|
| 16 |
+
echo "========================================"
|
| 17 |
+
|
| 18 |
+
# Activate blade environment (correct path confirmed on cluster)
|
| 19 |
+
source /work/ratul1/supantha/miniconda3/etc/profile.d/conda.sh
|
| 20 |
+
conda activate blade
|
| 21 |
+
|
| 22 |
+
cd /work/ratul1/supantha/glycan-SD-VS/bert_training_v3/v3.1_cluster_training
|
| 23 |
+
|
| 24 |
+
# Debug info
|
| 25 |
+
echo ""
|
| 26 |
+
echo "Python: $(which python3)"
|
| 27 |
+
echo "Python version: $(python3 --version)"
|
| 28 |
+
echo "Conda env: $CONDA_DEFAULT_ENV"
|
| 29 |
+
nvidia-smi --query-gpu=name,memory.total --format=csv,noheader 2>/dev/null || echo "No GPU info"
|
| 30 |
+
echo ""
|
| 31 |
+
|
| 32 |
+
# Install missing deps if needed
|
| 33 |
+
pip install -q scikit-learn umap-learn 2>/dev/null
|
| 34 |
+
|
| 35 |
+
# Check key files exist
|
| 36 |
+
echo "Checking files..."
|
| 37 |
+
ls -lh checkpoints_v5_bpe_topo/best_v5_bpe_topo_model.pt 2>/dev/null || echo "WARNING: v5a checkpoint not found"
|
| 38 |
+
ls -lh bert_v5.1_contrastive/checkpoints/best_v51_contrastive_model.pt 2>/dev/null || echo "WARNING: v6 checkpoint not found"
|
| 39 |
+
ls -lh bert_training_v4/data/vocabulary.json 2>/dev/null || echo "WARNING: vocabulary.json not found"
|
| 40 |
+
ls -lh bert_training_v4/data/multimodal_index.csv 2>/dev/null || echo "WARNING: multimodal_index.csv not found"
|
| 41 |
+
echo ""
|
| 42 |
+
|
| 43 |
+
echo "========== V5a — All 8 Novel Probes (Full Data) =========="
|
| 44 |
+
python3 -u bert_v6_contrastive/scripts/novel_probes_v2.py \
|
| 45 |
+
--model v5 \
|
| 46 |
+
--probe all \
|
| 47 |
+
--max_samples 10000 \
|
| 48 |
+
--device cuda
|
| 49 |
+
|
| 50 |
+
echo ""
|
| 51 |
+
echo "Generated V5a files:"
|
| 52 |
+
ls -la bert_v6_contrastive/analysis/novel_probes_v2_v5/ 2>/dev/null || echo "No V5 output dir found"
|
| 53 |
+
|
| 54 |
+
echo ""
|
| 55 |
+
echo "========== V6 — All 8 Novel Probes (Full Data) =========="
|
| 56 |
+
python3 -u bert_v6_contrastive/scripts/novel_probes_v2.py \
|
| 57 |
+
--model v6 \
|
| 58 |
+
--probe all \
|
| 59 |
+
--max_samples 10000 \
|
| 60 |
+
--device cuda
|
| 61 |
+
|
| 62 |
+
echo ""
|
| 63 |
+
echo "Generated V6 files:"
|
| 64 |
+
ls -la bert_v6_contrastive/analysis/novel_probes_v2_v6/ 2>/dev/null || echo "No V6 output dir found"
|
| 65 |
+
|
| 66 |
+
echo ""
|
| 67 |
+
echo "Completed: $(date)"
|
code/probes/cluster_scripts/run_novel_probes_v2_full.sh
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
#SBATCH --job-name=probes_v2_full
|
| 3 |
+
#SBATCH --partition=nova
|
| 4 |
+
#SBATCH --gres=gpu:a100:1
|
| 5 |
+
#SBATCH --cpus-per-task=8
|
| 6 |
+
#SBATCH --mem=64G
|
| 7 |
+
#SBATCH --time=06:00:00
|
| 8 |
+
#SBATCH --output=bert_v6_contrastive/scripts/novel_probes_v2_full_%j.out
|
| 9 |
+
#SBATCH --error=bert_v6_contrastive/scripts/novel_probes_v2_full_%j.err
|
| 10 |
+
|
| 11 |
+
echo "========================================"
|
| 12 |
+
echo "Novel Embedding Probes V2 — FULL DATA (all 254K samples)"
|
| 13 |
+
echo "Job ID: $SLURM_JOB_ID"
|
| 14 |
+
echo "Node: $(hostname)"
|
| 15 |
+
echo "Started: $(date)"
|
| 16 |
+
echo "========================================"
|
| 17 |
+
|
| 18 |
+
source /work/ratul1/supantha/miniconda3/etc/profile.d/conda.sh
|
| 19 |
+
conda activate blade
|
| 20 |
+
|
| 21 |
+
cd /work/ratul1/supantha/glycan-SD-VS/bert_training_v3/v3.1_cluster_training
|
| 22 |
+
|
| 23 |
+
echo "Python: $(which python3)"
|
| 24 |
+
echo "Conda env: $CONDA_DEFAULT_ENV"
|
| 25 |
+
nvidia-smi --query-gpu=name,memory.total --format=csv,noheader 2>/dev/null || echo "No GPU info"
|
| 26 |
+
echo ""
|
| 27 |
+
|
| 28 |
+
pip install -q scikit-learn umap-learn 2>/dev/null
|
| 29 |
+
|
| 30 |
+
echo "========== V5a — All 8 Probes (FULL 254K) =========="
|
| 31 |
+
python3 -u bert_v6_contrastive/scripts/novel_probes_v2.py \
|
| 32 |
+
--model v5 \
|
| 33 |
+
--probe all \
|
| 34 |
+
--max_samples 999999 \
|
| 35 |
+
--output_dir bert_v6_contrastive/analysis/novel_probes_v2_full_v5 \
|
| 36 |
+
--device cuda
|
| 37 |
+
|
| 38 |
+
echo ""
|
| 39 |
+
echo "Generated V5a files:"
|
| 40 |
+
ls -la bert_v6_contrastive/analysis/novel_probes_v2_full_v5/ 2>/dev/null || echo "No output"
|
| 41 |
+
|
| 42 |
+
echo ""
|
| 43 |
+
echo "========== V6 — All 8 Probes (FULL 254K) =========="
|
| 44 |
+
python3 -u bert_v6_contrastive/scripts/novel_probes_v2.py \
|
| 45 |
+
--model v6 \
|
| 46 |
+
--probe all \
|
| 47 |
+
--max_samples 999999 \
|
| 48 |
+
--output_dir bert_v6_contrastive/analysis/novel_probes_v2_full_v6 \
|
| 49 |
+
--device cuda
|
| 50 |
+
|
| 51 |
+
echo ""
|
| 52 |
+
echo "Generated V6 files:"
|
| 53 |
+
ls -la bert_v6_contrastive/analysis/novel_probes_v2_full_v6/ 2>/dev/null || echo "No output"
|
| 54 |
+
|
| 55 |
+
echo ""
|
| 56 |
+
echo "Completed: $(date)"
|