supanthadey1 commited on
Commit
1d6f391
·
verified ·
1 Parent(s): 3169cde

Add BERTose and AFFINose training code release

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +6 -33
  2. MANIFEST.md +138 -0
  3. README.md +104 -0
  4. RELEASE_AUDIT.md +140 -0
  5. SHA256SUMS +128 -0
  6. code/affinose/README.md +23 -0
  7. code/benchmarks/calculate_rankings.py +57 -0
  8. code/benchmarks/consolidate_results.py +135 -0
  9. code/benchmarks/create_excluded_dataset.py +46 -0
  10. code/benchmarks/extract_all_benchmark_results.py +244 -0
  11. code/benchmarks/finetune.py +708 -0
  12. code/bertint/bertint_v8.py +809 -0
  13. code/bertint/bertint_v8_inference.py +187 -0
  14. code/bertint/build_combined_dataset.py +472 -0
  15. code/bertint/compare_ablation.py +174 -0
  16. code/bertint/dataset_v8.py +298 -0
  17. code/bertint/generate_glycan_splits.py +119 -0
  18. code/bertint/training_v8.py +653 -0
  19. code/contrastive/contrastive_trainer_v51_FINAL.py +298 -0
  20. code/contrastive/contrastive_trainer_v5_SIMPLE.py +295 -0
  21. code/contrastive/extract_resolved_glycans.py +29 -0
  22. code/contrastive/generate_negatives_v4_FINAL.py +369 -0
  23. code/contrastive/mcns_filter_75k_v3_FINAL.py +175 -0
  24. code/contrastive/score_negative_difficulty.py +214 -0
  25. code/contrastive_training/step3_train_v51_FIXED_FINAL.sh +57 -0
  26. code/data_processing/augment_pretraining_data.py +163 -0
  27. code/data_processing/convert_iupac_glycosmos.py +114 -0
  28. code/downstream_tasks/__init__.py +0 -0
  29. code/downstream_tasks/utils/__init__.py +0 -0
  30. code/downstream_tasks/utils/baseline_results.py +461 -0
  31. code/downstream_tasks/utils/dataset.py +376 -0
  32. code/downstream_tasks/utils/tokenizer.py +612 -0
  33. code/downstream_tasks/utils/wurcs_bpe_tokenizer.py +740 -0
  34. code/model/__init__.py +3 -0
  35. code/model/dataset.py +360 -0
  36. code/model/enhanced_classifier.py +189 -0
  37. code/model/glycan_bert.py +303 -0
  38. code/model/hierarchical_glycan.py +284 -0
  39. code/model/multimodal_glycan_bert_v3.py +1084 -0
  40. code/model/tokenizer.py +612 -0
  41. code/model/wurcs_bpe_tokenizer.py +740 -0
  42. code/probes/analyze_core_biology.py +467 -0
  43. code/probes/analyze_embeddings.py +276 -0
  44. code/probes/cluster_scripts/run_benchmark_embeddings.sh +54 -0
  45. code/probes/cluster_scripts/run_diag_zero.sh +23 -0
  46. code/probes/cluster_scripts/run_embedding_analysis.sh +45 -0
  47. code/probes/cluster_scripts/run_explore_bio.sh +35 -0
  48. code/probes/cluster_scripts/run_novel_probes.sh +33 -0
  49. code/probes/cluster_scripts/run_novel_probes_v2.sh +67 -0
  50. 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
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
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)"