Amin-Saeidi commited on
Commit
953486a
·
verified ·
1 Parent(s): fdb34c1

Upload folder using huggingface_hub

Browse files
README.md CHANGED
@@ -1,3 +1,112 @@
1
  ---
2
- license: mit
 
 
 
 
 
 
 
 
 
 
 
3
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ pipeline_tag: feature-extraction
3
+ tags:
4
+ - biology
5
+ - genomics
6
+ - bioinformatics
7
+ - protein-language-model
8
+ - pytorch
9
+ - peft
10
+ - lora
11
+ license: apache-2.0
12
+ language:
13
+ - en
14
  ---
15
+
16
+ # ContraMLM: Contrastive Learning for Phage Protein Representations
17
+
18
+ ContraMLM is a fine-tuned version of the `Rostlab/prot_t5_xl_uniref50` protein language model, trained with Low-Rank Adaptation (LoRA) using a hybrid objective that combines standard Masked Language Modeling (MLM) with a contrastive loss.
19
+
20
+ The model is built to improve the embedding space for bacteriophage proteins, clustering them by functional group and PHROG family in a zero-shot setting.
21
+
22
+ ## Intended Use
23
+
24
+ ContraMLM is intended for researchers in computational biology and virology who need function-aware embeddings for phage protein sequences.
25
+
26
+ **Primary use cases:**
27
+ - **Zero-shot functional retrieval:** querying unknown phage proteins against a database of known PHROG families using cosine similarity or L2 distance.
28
+ - **Embedding extraction:** generating dense representations of sequences for downstream tasks such as functional annotation or clustering.
29
+
30
+ ## How to Use
31
+
32
+ The model relies on the Hugging Face `transformers` and `peft` libraries.
33
+
34
+ ```python
35
+ import torch
36
+ from transformers import T5Tokenizer, T5ForConditionalGeneration
37
+ from peft import PeftModel
38
+
39
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
40
+ base_model_name = "Rostlab/prot_t5_xl_uniref50"
41
+ adapter_dir = "Amin-Saeidi/PhageContraMLM" # update to your exact repo ID
42
+
43
+ # 1. Load tokenizer and base model
44
+ tokenizer = T5Tokenizer.from_pretrained(base_model_name, do_lower_case=False)
45
+ model = T5ForConditionalGeneration.from_pretrained(base_model_name, torch_dtype=torch.float16)
46
+
47
+ # 2. Attach LoRA adapters and merge
48
+ model = PeftModel.from_pretrained(model, adapter_dir)
49
+ model = model.merge_and_unload().to(device).eval()
50
+
51
+ # 3. Prepare sequence (space-separated, rare amino acids replaced)
52
+ seq = "M A K K L K I L L L A A S L V S L S P S V F A"
53
+ inputs = tokenizer(seq, return_tensors="pt").to(device)
54
+
55
+ # 4. Extract mean-pooled embeddings
56
+ with torch.no_grad():
57
+ outputs = model.encoder(**inputs)
58
+ hidden = outputs.last_hidden_state
59
+ mask = inputs.attention_mask.unsqueeze(-1).to(hidden.dtype)
60
+ pooled_embedding = (hidden * mask).sum(dim=1) / mask.sum(dim=1).clamp(min=1.0)
61
+
62
+ print(pooled_embedding.shape)
63
+ ```
64
+
65
+ ## Training Data and Process
66
+
67
+ - **Data:** `envhog_phrog2` dataset.
68
+ - **Architecture:** ProtT5-XL (encoder-decoder).
69
+ - **Fine-tuning method:** LoRA (rank=32, alpha=64, target modules: q, k, v, o).
70
+ - **Loss function:** convex combination of curriculum-aware MLM loss (80%) and temperature-scaled contrastive loss (20%).
71
+ - **Positive sampling:** the contrastive adjacency matrix is built on-the-fly from a known VISEQ pair graph, pulling positive pairs that share identical VISEQs or cross-VISEQ structural similarities.
72
+
73
+ ## Repository Structure & Scripts
74
+
75
+ This repository contains everything needed to reproduce training and evaluation, organized around `src/`, `data/`, and `runs/` directories.
76
+
77
+ **`src/train.py`**
78
+ Main training loop. Implements a custom `PairGraphCollator` that samples positive protein pairs on-the-fly and builds a dynamic adjacency matrix for the contrastive loss, combined with a curriculum-aware MLM objective.
79
+
80
+ **`src/produce_test_data_embeddings.py`**
81
+ High-throughput script for generating mean-pooled encoder embeddings. Loads the base ProtT5 model, attaches the best LoRA adapters from your checkpoints, and processes raw FASTA/CSV sequences in batches, saving results as `.pkl` and `.csv` files.
82
+
83
+ **`src/eval_EmbeddingSpace.py`**
84
+ Generates publication-quality plots analyzing the embedding space:
85
+ - t-SNE grids colored by PhrogCat category
86
+ - Pairwise L2 and cosine scatter plots comparing the base model against the fine-tuned ContraMLM model
87
+
88
+ **`src/eval_PhrogRetrieval.py`**
89
+ Zero-shot functional retrieval benchmarking using `hnswlib` (Hierarchical Navigable Small World graphs):
90
+ - Precision@k (k = 5, 10, 50)
91
+ - Per-family and per-size-bin (rare, medium, common) statistics
92
+ - Functional group confusion matrices and Seaborn clustermaps
93
+
94
+ ## Dependencies
95
+
96
+ ```
97
+ torch==2.6.0
98
+ transformers==4.37.2
99
+ peft==0.10.0
100
+ pandas==3.0.1
101
+ numpy==2.3.5
102
+ matplotlib==3.10.8
103
+ seaborn==0.13.2
104
+ scikit-learn==1.8.0
105
+ hnswlib==0.8.0
106
+ safetensors==0.7.0
107
+ sentencepiece==0.2.0
108
+ ```
109
+
110
+ ## Acknowledgments
111
+
112
+ This work was conducted by Amin SaeidiKelishami during an internship at the Laboratoire Microorganismes: Génome et Environnement (LMGE), under the supervision of Professor Clovis Galiez and Professor Francois ENAULT.
pyproject.toml ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "phage-contramlm"
7
+ version = "1.1.0"
8
+ description = "A fine-tuned ProtT5 model using Contrastive + MLM loss for phage protein sequence representation."
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ dependencies = [
12
+ "torch==2.6.0",
13
+ "transformers==4.37.2",
14
+ "peft==0.10.0",
15
+ "pandas==3.0.1",
16
+ "numpy==2.3.5",
17
+ "matplotlib==3.10.8",
18
+ "seaborn==0.13.2",
19
+ "scikit-learn==1.8.0",
20
+ "hnswlib==0.8.0",
21
+ "safetensors==0.7.0",
22
+ "sentencepiece==0.2.0"
23
+ ]
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ torch==2.6.0
2
+ transformers==4.37.2
3
+ peft==0.10.0
4
+ pandas==3.0.1
5
+ numpy==2.3.5
6
+ matplotlib==3.10.8
7
+ seaborn==0.13.2
8
+ scikit-learn==1.8.0
9
+ hnswlib==0.8.0
10
+ safetensors==0.7.0
11
+ sentencepiece==0.2.0
requirements_conda.txt ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # This file may be used to create an environment using:
2
+ # $ conda create --name <env> --file <this file>
3
+ # platform: linux-64
4
+ _libgcc_mutex=0.1=main
5
+ _openmp_mutex=5.1=1_gnu
6
+ accelerate=0.22.0=pypi_0
7
+ aiohappyeyeballs=2.6.1=pypi_0
8
+ aiohttp=3.13.3=pypi_0
9
+ aiosignal=1.4.0=pypi_0
10
+ annotated-doc=0.0.4=pypi_0
11
+ anyio=4.12.1=pypi_0
12
+ attrs=25.4.0=pypi_0
13
+ biopython=1.86=pypi_0
14
+ bzip2=1.0.8=h5eee18b_6
15
+ ca-certificates=2026.2.25=hbd8a1cb_0
16
+ certifi=2026.2.25=pypi_0
17
+ charset-normalizer=3.4.5=pypi_0
18
+ click=8.3.1=pypi_0
19
+ contourpy=1.3.3=pypi_0
20
+ cycler=0.12.1=pypi_0
21
+ datasets=4.7.0=pypi_0
22
+ dill=0.4.0=pypi_0
23
+ et-xmlfile=2.0.0=pypi_0
24
+ expat=2.7.4=h7354ed3_0
25
+ filelock=3.20.0=pypi_0
26
+ fonttools=4.62.0=pypi_0
27
+ frozenlist=1.8.0=pypi_0
28
+ fsspec=2025.12.0=pypi_0
29
+ h11=0.16.0=pypi_0
30
+ hf-xet=1.4.0=pypi_0
31
+ hnswlib=0.8.0=py312hf890105_1
32
+ httpcore=1.0.9=pypi_0
33
+ httpx=0.28.1=pypi_0
34
+ huggingface-hub=0.36.2=pypi_0
35
+ idna=3.11=pypi_0
36
+ jinja2=3.1.6=pypi_0
37
+ joblib=1.5.3=pypi_0
38
+ kiwisolver=1.5.0=pypi_0
39
+ ld_impl_linux-64=2.44=h9e0c5a2_3
40
+ libblas=3.11.0=6_h4a7cf45_openblas
41
+ libcblas=3.11.0=6_h0358290_openblas
42
+ libexpat=2.7.4=h7354ed3_0
43
+ libffi=3.4.4=h6a678d5_1
44
+ libgcc=15.2.0=h69a1729_7
45
+ libgcc-ng=15.2.0=h166f726_7
46
+ libgfortran=15.2.0=h69a702a_18
47
+ libgfortran5=15.2.0=h68bc16d_18
48
+ libgomp=15.2.0=h4751f2c_7
49
+ liblapack=3.11.0=6_h47877c9_openblas
50
+ libnsl=2.0.0=h5eee18b_0
51
+ libopenblas=0.3.32=pthreads_h94d23a6_0
52
+ libstdcxx=15.2.0=h39759b7_7
53
+ libstdcxx-ng=15.2.0=hc03a8fd_7
54
+ libuuid=1.41.5=h5eee18b_0
55
+ libxcb=1.17.0=h9b100fa_0
56
+ libzlib=1.3.1=hb25bd0a_0
57
+ markdown-it-py=4.0.0=pypi_0
58
+ markupsafe=3.0.2=pypi_0
59
+ matplotlib=3.10.8=pypi_0
60
+ mdurl=0.1.2=pypi_0
61
+ mpmath=1.3.0=pypi_0
62
+ multidict=6.7.1=pypi_0
63
+ multiprocess=0.70.18=pypi_0
64
+ ncurses=6.5=h7934f7d_0
65
+ networkx=3.6.1=pypi_0
66
+ numpy=2.3.5=pypi_0
67
+ nvidia-cublas-cu12=12.4.5.8=pypi_0
68
+ nvidia-cuda-cupti-cu12=12.4.127=pypi_0
69
+ nvidia-cuda-nvrtc-cu12=12.4.127=pypi_0
70
+ nvidia-cuda-runtime-cu12=12.4.127=pypi_0
71
+ nvidia-cudnn-cu12=9.1.0.70=pypi_0
72
+ nvidia-cufft-cu12=11.2.1.3=pypi_0
73
+ nvidia-curand-cu12=10.3.5.147=pypi_0
74
+ nvidia-cusolver-cu12=11.6.1.9=pypi_0
75
+ nvidia-cusparse-cu12=12.3.1.170=pypi_0
76
+ nvidia-cusparselt-cu12=0.6.2=pypi_0
77
+ nvidia-nccl-cu12=2.21.5=pypi_0
78
+ nvidia-nvjitlink-cu12=12.4.127=pypi_0
79
+ nvidia-nvtx-cu12=12.4.127=pypi_0
80
+ openpyxl=3.1.5=pypi_0
81
+ openssl=3.6.1=h35e630c_1
82
+ packaging=25.0=py312h06a4308_1
83
+ pandas=3.0.1=pypi_0
84
+ peft=0.10.0=pypi_0
85
+ pillow=12.0.0=pypi_0
86
+ pip=26.0.1=pyhc872135_0
87
+ propcache=0.4.1=pypi_0
88
+ protobuf=3.20.3=pypi_0
89
+ psutil=7.2.2=pypi_0
90
+ pthread-stubs=0.3=h0ce48e5_1
91
+ pyarrow=23.0.1=pypi_0
92
+ pygments=2.19.2=pypi_0
93
+ pyparsing=3.3.2=pypi_0
94
+ python=3.12.12=hd17a9e1_1
95
+ python-dateutil=2.9.0.post0=pypi_0
96
+ python_abi=3.12=3_cp312
97
+ pyyaml=6.0.3=pypi_0
98
+ readline=8.3=hc2a1206_0
99
+ regex=2026.2.28=pypi_0
100
+ requests=2.32.5=pypi_0
101
+ rich=14.3.3=pypi_0
102
+ safetensors=0.7.0=pypi_0
103
+ scikit-learn=1.8.0=pypi_0
104
+ scipy=1.17.1=pypi_0
105
+ seaborn=0.13.2=pypi_0
106
+ sentencepiece=0.2.0=pypi_0
107
+ setuptools=80.10.2=py312h06a4308_0
108
+ shellingham=1.5.4=pypi_0
109
+ six=1.17.0=pypi_0
110
+ sqlite=3.51.1=h3e8d24a_1
111
+ sympy=1.13.1=pypi_0
112
+ threadpoolctl=3.6.0=pypi_0
113
+ tk=8.6.15=h54e0aa7_0
114
+ tokenizers=0.15.2=pypi_0
115
+ torch=2.6.0+cu124=pypi_0
116
+ torchaudio=2.6.0+cu124=pypi_0
117
+ torchvision=0.21.0+cu124=pypi_0
118
+ tqdm=4.67.3=pypi_0
119
+ transformers=4.37.2=pypi_0
120
+ triton=3.2.0=pypi_0
121
+ typer=0.24.1=pypi_0
122
+ typing-extensions=4.15.0=pypi_0
123
+ tzdata=2026a=he532380_0
124
+ urllib3=2.6.3=pypi_0
125
+ wheel=0.46.3=py312h06a4308_0
126
+ xorg-libx11=1.8.12=h9b100fa_1
127
+ xorg-libxau=1.0.12=h9b100fa_0
128
+ xorg-libxdmcp=1.1.5=h9b100fa_0
129
+ xorg-xorgproto=2024.1=h5eee18b_1
130
+ xxhash=3.6.0=pypi_0
131
+ xz=5.8.2=h448239c_0
132
+ yarl=1.23.0=pypi_0
133
+ zlib=1.3.1=hb25bd0a_0
runs/test_embeddings_protrans_lora_ContraMLM_v1_1.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6c84c5728b5bc876b571a7e8dddb75c4cbaa7d0242798f393968e623ac0a6717
3
+ size 872815878
runs/test_embeddings_protrans_lora_base.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:70723f01c1ab0e4164beb6065e00e90611acd7c1aba0de5c52e26c9b9ee99a30
3
+ size 872815878
src/produce_test_data_embeddings.py ADDED
@@ -0,0 +1,570 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Generate protein embeddings using a fine-tuned ProtT5 XL LoRA model.
4
+
5
+ It loads the base model, attaches LoRA adapters from a local directory or checkpoint, extracts encoder
6
+ representations, applies attention-masked mean pooling, and writes a pandas
7
+ pickle with protein IDs as index and embedding dimensions as integer columns.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import glob
14
+ import json
15
+ import math
16
+ import re
17
+ import sys
18
+ import time
19
+ from pathlib import Path
20
+ from typing import Dict, List, Optional, Tuple
21
+
22
+ import numpy as np
23
+ import pandas as pd
24
+ import torch
25
+ from transformers import T5ForConditionalGeneration, T5Tokenizer
26
+
27
+ # Ensure progress logs are written immediately in batch jobs (e.g. SLURM/PBS).
28
+ if hasattr(sys.stdout, "reconfigure"):
29
+ sys.stdout.reconfigure(line_buffering=True)
30
+ if hasattr(sys.stderr, "reconfigure"):
31
+ sys.stderr.reconfigure(line_buffering=True)
32
+
33
+ try:
34
+ from peft import PeftModel
35
+ except ImportError as exc:
36
+ raise ImportError(
37
+ "Missing dependency 'peft'. Install with: pip install peft"
38
+ ) from exc
39
+
40
+ try:
41
+ from safetensors.torch import load_file as load_safetensors
42
+ except ImportError:
43
+ load_safetensors = None
44
+
45
+ ROOT_DIR = Path(__file__).resolve().parent.parent
46
+ DEFAULT_VERSION = "ContraMLM_v1_1"
47
+ DEFAULT_CSV_NAME = "envhog_test_final_no_leakage.csv"
48
+
49
+
50
+ def fmt_seconds(total_seconds: float) -> str:
51
+ total_seconds = max(0, int(total_seconds))
52
+ hours, rem = divmod(total_seconds, 3600)
53
+ minutes, seconds = divmod(rem, 60)
54
+ return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
55
+
56
+
57
+ def parse_args() -> argparse.Namespace:
58
+ parser = argparse.ArgumentParser(description="Generate embeddings with ProTrans LoRA model.")
59
+
60
+ # We remove --workdir since paths are now strictly routed to 'data' and 'runs'
61
+
62
+ parser.add_argument(
63
+ "--version",
64
+ default=DEFAULT_VERSION,
65
+ type=str,
66
+ help=(
67
+ "Model version string used to resolve default paths. "
68
+ "Use 'base' to run inference with the base model (no LoRA adapters)."
69
+ ),
70
+ )
71
+ parser.add_argument(
72
+ "--csv",
73
+ default=str(ROOT_DIR / "data" / DEFAULT_CSV_NAME),
74
+ type=str,
75
+ help="Path to input CSV file with 'id' and 'sequence' columns. Default: data/envhog_test_final_no_leakage.csv",
76
+ )
77
+ parser.add_argument(
78
+ "--output",
79
+ default=None,
80
+ type=str,
81
+ help="Path to output pickle file (.pkl). Default: runs/test_embeddings_protrans_lora_<version>.pkl",
82
+ )
83
+ parser.add_argument(
84
+ "--base-model",
85
+ default="Rostlab/prot_t5_xl_uniref50",
86
+ type=str,
87
+ help="Base Hugging Face model name.",
88
+ )
89
+ parser.add_argument(
90
+ "--adapter-dir",
91
+ default=None,
92
+ type=str,
93
+ help="Path to LoRA adapter directory. Default: <checkpoint-root>/lora_adapters",
94
+ )
95
+ parser.add_argument(
96
+ "--checkpoint-root",
97
+ default=None,
98
+ type=str,
99
+ help="Root path containing checkpoint-* folders. Default: runs/protrans_XL_Full_lora_envhog_<version>",
100
+ )
101
+ parser.add_argument(
102
+ "--batch-size",
103
+ default=2,
104
+ type=int,
105
+ help="Batch size for inference.",
106
+ )
107
+ parser.add_argument(
108
+ "--max-length",
109
+ default=512,
110
+ type=int,
111
+ help="Tokenizer max_length (truncation enabled).",
112
+ )
113
+ parser.add_argument(
114
+ "--max-seqs",
115
+ default=None,
116
+ type=int,
117
+ help="Optional cap on number of sequences (for test runs).",
118
+ )
119
+ parser.add_argument(
120
+ "--progress-every",
121
+ default=50,
122
+ type=int,
123
+ help="Print progress every N batches.",
124
+ )
125
+ parser.add_argument(
126
+ "--save-every-batches",
127
+ default=1000,
128
+ type=int,
129
+ help="Save a checkpoint chunk every N batches. Use 0 to disable.",
130
+ )
131
+ parser.add_argument(
132
+ "--chunk-dir",
133
+ default=None,
134
+ type=str,
135
+ help="Directory for chunk checkpoints. Default: <output_stem>_chunks next to output file.",
136
+ )
137
+ parser.add_argument(
138
+ "--no-final-merge",
139
+ action="store_true",
140
+ help="Do not merge chunks into a single output pickle at the end.",
141
+ )
142
+ parser.add_argument(
143
+ "--overwrite",
144
+ action="store_true",
145
+ help="Overwrite output file if it already exists.",
146
+ )
147
+ parser.add_argument(
148
+ "--save-csv",
149
+ default=None,
150
+ type=str,
151
+ help="Optional path to also export CSV embeddings.",
152
+ )
153
+ parser.add_argument(
154
+ "--report-json",
155
+ default=None,
156
+ type=str,
157
+ help="Path to write adapter validation report JSON. Default: runs/adapter_validation_<version>.json",
158
+ )
159
+
160
+ args = parser.parse_args()
161
+
162
+ version = args.version
163
+ args.use_base_model = version.lower() == "base"
164
+
165
+ default_output_name = f"test_embeddings_protrans_lora_{version}.pkl"
166
+ if args.output is None:
167
+ args.output = str(ROOT_DIR / "runs" / default_output_name)
168
+
169
+ default_model_dirname = f"protrans_XL_Full_lora_envhog_{version}"
170
+ if args.checkpoint_root is None:
171
+ args.checkpoint_root = str(ROOT_DIR / "runs" / default_model_dirname)
172
+
173
+ if args.adapter_dir is None:
174
+ args.adapter_dir = str(Path(args.checkpoint_root) / "lora_adapters")
175
+
176
+ default_report_name = f"adapter_validation_{version}.json"
177
+ if args.report_json is None:
178
+ args.report_json = str(ROOT_DIR / "runs" / default_report_name)
179
+
180
+ return args
181
+
182
+
183
+ def read_csv_sequences(csv_path: Path) -> Dict[str, str]:
184
+ df = pd.read_csv(csv_path, usecols=["id", "sequence"])
185
+ if df["id"].duplicated().any():
186
+ n_dups = int(df["id"].duplicated().sum())
187
+ print(f"Warning: {n_dups} duplicate IDs found in CSV; keeping first occurrence.")
188
+ df = df.drop_duplicates(subset="id", keep="first")
189
+ df["sequence"] = (
190
+ df["sequence"]
191
+ .astype(str)
192
+ .str.replace(" ", "", regex=False)
193
+ .str.upper()
194
+ .str.replace("-", "", regex=False)
195
+ )
196
+ return dict(zip(df["id"], df["sequence"]))
197
+
198
+
199
+ def prepare_t5_seq(seq: str) -> str:
200
+ seq = str(seq).replace(" ", "")
201
+ seq = seq.replace("U", "X").replace("Z", "X").replace("O", "X")
202
+ return " ".join(list(seq))
203
+
204
+
205
+ def get_encoder(model):
206
+ if hasattr(model, "encoder"):
207
+ return model.encoder
208
+
209
+ get_encoder_fn = getattr(model, "get_encoder", None)
210
+ if callable(get_encoder_fn):
211
+ return get_encoder_fn()
212
+
213
+ base_model = getattr(model, "base_model", None)
214
+ if base_model is not None:
215
+ if hasattr(base_model, "encoder"):
216
+ return base_model.encoder
217
+ base_get_encoder_fn = getattr(base_model, "get_encoder", None)
218
+ if callable(base_get_encoder_fn):
219
+ return base_get_encoder_fn()
220
+
221
+ inner_model = getattr(model, "model", None)
222
+ if inner_model is not None:
223
+ if hasattr(inner_model, "encoder"):
224
+ return inner_model.encoder
225
+ inner_get_encoder_fn = getattr(inner_model, "get_encoder", None)
226
+ if callable(inner_get_encoder_fn):
227
+ return inner_get_encoder_fn()
228
+
229
+ return model
230
+
231
+
232
+ def checkpoint_step(path: str) -> int:
233
+ match = re.search(r"checkpoint-(\d+)", str(path))
234
+ return int(match.group(1)) if match else -1
235
+
236
+
237
+ def load_adapter_state_dict(candidate_dir: Path):
238
+ safe_path = candidate_dir / "adapter_model.safetensors"
239
+ bin_path = candidate_dir / "adapter_model.bin"
240
+
241
+ if safe_path.exists() and load_safetensors is not None:
242
+ return load_safetensors(str(safe_path), device="cpu")
243
+ if bin_path.exists():
244
+ return torch.load(str(bin_path), map_location="cpu")
245
+ return None
246
+
247
+
248
+ def is_valid_adapter_state_dict(state_dict) -> bool:
249
+ if state_dict is None:
250
+ return False
251
+
252
+ for _, tensor in state_dict.items():
253
+ if not torch.is_tensor(tensor):
254
+ continue
255
+ if torch.isnan(tensor).any().item() or torch.isinf(tensor).any().item():
256
+ return False
257
+ return True
258
+
259
+
260
+ def select_best_adapter_dir(
261
+ adapter_dir: Path,
262
+ checkpoint_root: Path,
263
+ ) -> Tuple[Path, List[dict]]:
264
+ candidates: List[Path] = []
265
+ reports: List[dict] = []
266
+
267
+ if adapter_dir.is_dir():
268
+ candidates.append(adapter_dir)
269
+
270
+ checkpoint_dirs = sorted(
271
+ [Path(p) for p in glob.glob(str(checkpoint_root / "checkpoint-*")) if Path(p).is_dir()],
272
+ key=lambda p: checkpoint_step(str(p)),
273
+ reverse=True,
274
+ )
275
+ candidates.extend(checkpoint_dirs)
276
+
277
+ unique_candidates: List[Path] = []
278
+ seen = set()
279
+ for candidate in candidates:
280
+ resolved = str(candidate.resolve())
281
+ if resolved not in seen:
282
+ seen.add(resolved)
283
+ unique_candidates.append(candidate)
284
+
285
+ if not unique_candidates:
286
+ raise FileNotFoundError("No adapter/checkpoint directories found.")
287
+
288
+ for candidate in unique_candidates:
289
+ state_dict = load_adapter_state_dict(candidate)
290
+ valid = is_valid_adapter_state_dict(state_dict)
291
+ reports.append(
292
+ {
293
+ "candidate": str(candidate),
294
+ "valid": bool(valid),
295
+ "has_state_dict": state_dict is not None,
296
+ }
297
+ )
298
+
299
+ if valid:
300
+ print(f"Selected adapter directory: {candidate}")
301
+ return candidate, reports
302
+
303
+ print(f"Rejected adapter directory: {candidate}")
304
+
305
+ raise RuntimeError("No valid adapter/checkpoint directory found (all missing or NaN/Inf).")
306
+
307
+
308
+ def generate_embeddings(
309
+ id2seq: Dict[str, str],
310
+ base_model_name: str,
311
+ adapter_dir: Path,
312
+ checkpoint_root: Path,
313
+ batch_size: int,
314
+ max_length: int,
315
+ max_seqs: Optional[int],
316
+ progress_every: int,
317
+ report_json: Optional[Path],
318
+ output_path: Path,
319
+ save_every_batches: int,
320
+ chunk_dir: Optional[Path],
321
+ final_merge: bool,
322
+ use_base_model: bool = False,
323
+ ) -> tuple[Optional[pd.DataFrame], List[Path]]:
324
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
325
+ model_dtype = torch.float16 if torch.cuda.is_available() else torch.float32
326
+
327
+ print(f"Device: {device}")
328
+ if torch.cuda.is_available():
329
+ print(f"GPU: {torch.cuda.get_device_name(0)}")
330
+
331
+ print("Loading tokenizer...")
332
+ tokenizer = T5Tokenizer.from_pretrained(base_model_name, do_lower_case=False, legacy=True)
333
+
334
+ print("Loading base model...")
335
+ model = T5ForConditionalGeneration.from_pretrained(
336
+ base_model_name,
337
+ torch_dtype=model_dtype,
338
+ low_cpu_mem_usage=True,
339
+ )
340
+
341
+ if use_base_model:
342
+ print("Using base model (no LoRA adapters).")
343
+ else:
344
+ print("Selecting best adapter/checkpoint...")
345
+ selected_adapter, reports = select_best_adapter_dir(adapter_dir, checkpoint_root)
346
+
347
+ if report_json is not None:
348
+ report_json.parent.mkdir(parents=True, exist_ok=True)
349
+ with report_json.open("w", encoding="utf-8") as f:
350
+ json.dump(reports, f, indent=2)
351
+ print(f"Adapter validation report saved: {report_json}")
352
+
353
+ print("Attaching LoRA adapters...")
354
+ model = PeftModel.from_pretrained(model, str(selected_adapter))
355
+
356
+ merge_fn = getattr(model, "merge_and_unload", None)
357
+ if callable(merge_fn):
358
+ model = merge_fn()
359
+ print("Merged LoRA adapters into base weights.")
360
+
361
+ model = model.to(device).eval()
362
+ encoder = get_encoder(model).to(device).eval()
363
+
364
+ all_ids = list(id2seq.keys())
365
+ if max_seqs is not None:
366
+ all_ids = all_ids[:max_seqs]
367
+
368
+ if batch_size <= 0:
369
+ raise ValueError("--batch-size must be > 0")
370
+
371
+ total_sequences = len(all_ids)
372
+ total_batches = math.ceil(total_sequences / batch_size)
373
+
374
+ lengths = [len(id2seq[pid]) for pid in all_ids]
375
+ print(
376
+ "Input length stats | "
377
+ f"mean={np.mean(lengths):.1f}, median={np.median(lengths):.1f}, "
378
+ f"p95={np.percentile(lengths, 95):.1f}, max={np.max(lengths)}"
379
+ )
380
+
381
+ print(f"Generating embeddings for {total_sequences} proteins in ~{total_batches} batches...")
382
+ chunk_arrays: List[np.ndarray] = []
383
+ chunk_ids: List[str] = []
384
+ chunk_paths: List[Path] = []
385
+
386
+ if chunk_dir is None:
387
+ chunk_dir = output_path.parent / f"{output_path.stem}_chunks"
388
+ chunk_dir.mkdir(parents=True, exist_ok=True)
389
+ print(f"Checkpoint chunk directory: {chunk_dir}")
390
+
391
+ start = time.time()
392
+ processed = 0
393
+
394
+ def flush_chunk() -> None:
395
+ if not chunk_arrays:
396
+ return
397
+
398
+ chunk_index = len(chunk_paths)
399
+ chunk_path = chunk_dir / f"chunk_{chunk_index:06d}.pkl"
400
+ chunk_emb = np.vstack(chunk_arrays)
401
+ chunk_df = pd.DataFrame(chunk_emb, index=chunk_ids)
402
+ chunk_df.columns = list(range(chunk_df.shape[1]))
403
+ chunk_df.to_pickle(chunk_path)
404
+
405
+ chunk_paths.append(chunk_path)
406
+ print(
407
+ f"[checkpoint] saved {chunk_path.name} with {chunk_df.shape[0]} proteins "
408
+ f"(total processed={processed}/{total_sequences})"
409
+ )
410
+
411
+ chunk_arrays.clear()
412
+ chunk_ids.clear()
413
+
414
+ with torch.no_grad():
415
+ for i in range(0, len(all_ids), batch_size):
416
+ batch_ids = all_ids[i : i + batch_size]
417
+ batch_seqs = [prepare_t5_seq(id2seq[pid]) for pid in batch_ids]
418
+
419
+ inputs = tokenizer(
420
+ batch_seqs,
421
+ return_tensors="pt",
422
+ padding=True,
423
+ truncation=True,
424
+ max_length=max_length,
425
+ )
426
+ inputs = {k: v.to(device) for k, v in inputs.items()}
427
+
428
+ outputs = encoder(
429
+ input_ids=inputs["input_ids"],
430
+ attention_mask=inputs["attention_mask"],
431
+ )
432
+ hidden = outputs.last_hidden_state
433
+ mask = inputs["attention_mask"].unsqueeze(-1).to(hidden.dtype)
434
+ pooled = (hidden * mask).sum(dim=1) / mask.sum(dim=1).clamp(min=1.0)
435
+ chunk_arrays.append(pooled.float().cpu().numpy())
436
+ chunk_ids.extend(batch_ids)
437
+ processed += len(batch_ids)
438
+
439
+ batch_idx = i // batch_size
440
+ if (
441
+ progress_every > 0
442
+ and ((batch_idx + 1) % progress_every == 0 or processed == total_sequences or batch_idx == 0)
443
+ ):
444
+ elapsed = time.time() - start
445
+ seq_per_sec = processed / elapsed if elapsed > 0 else 0.0
446
+ remaining = total_sequences - processed
447
+ eta_seconds = (remaining / seq_per_sec) if seq_per_sec > 0 else float("inf")
448
+
449
+ msg = (
450
+ f"[{batch_idx + 1}/{total_batches}] "
451
+ f"processed={processed}/{total_sequences} ({processed/total_sequences:.1%}) | "
452
+ f"throughput={seq_per_sec:.2f} seq/s | "
453
+ f"elapsed={fmt_seconds(elapsed)} | "
454
+ f"eta={fmt_seconds(eta_seconds) if math.isfinite(eta_seconds) else 'inf'}"
455
+ )
456
+
457
+ if torch.cuda.is_available():
458
+ alloc_gb = torch.cuda.memory_allocated() / (1024 ** 3)
459
+ reserved_gb = torch.cuda.memory_reserved() / (1024 ** 3)
460
+ msg += f" | gpu_mem={alloc_gb:.2f}/{reserved_gb:.2f} GB"
461
+
462
+ print(msg)
463
+
464
+ if save_every_batches > 0 and ((batch_idx + 1) % save_every_batches == 0):
465
+ flush_chunk()
466
+
467
+ flush_chunk()
468
+
469
+ if not chunk_paths:
470
+ raise RuntimeError("No embeddings were generated.")
471
+
472
+ elapsed = time.time() - start
473
+ print(f"Embedding generation completed in {elapsed:.1f}s")
474
+ print(f"Average throughput: {processed / elapsed:.2f} seq/s")
475
+ print(f"Saved chunk files: {len(chunk_paths)}")
476
+
477
+ if not final_merge:
478
+ print("Skipping final merge (--no-final-merge set).")
479
+ return None, chunk_paths
480
+
481
+ print("Merging chunk files into final DataFrame...")
482
+ frames: List[pd.DataFrame] = []
483
+ for i, chunk_path in enumerate(chunk_paths, start=1):
484
+ frames.append(pd.read_pickle(chunk_path))
485
+ if i == 1 or i % 50 == 0 or i == len(chunk_paths):
486
+ print(f"[merge] loaded {i}/{len(chunk_paths)} chunks")
487
+
488
+ emb_df = pd.concat(frames, axis=0)
489
+ print(f"Merged output shape: {emb_df.shape}")
490
+ return emb_df, chunk_paths
491
+
492
+
493
+ def main() -> None:
494
+ args = parse_args()
495
+
496
+ csv_path = Path(args.csv)
497
+ output_path = Path(args.output)
498
+ adapter_dir = Path(args.adapter_dir)
499
+ checkpoint_root = Path(args.checkpoint_root)
500
+ report_json = Path(args.report_json) if args.report_json else None
501
+ chunk_dir = Path(args.chunk_dir) if args.chunk_dir else None
502
+
503
+ print("Resolved configuration:")
504
+ print(f" workdir: {Path(args.workdir)}")
505
+ print(f" version: {args.version}")
506
+ print(f" use_base_model: {args.use_base_model}")
507
+ print(f" csv: {csv_path}")
508
+ print(f" output: {output_path}")
509
+ print(f" adapter_dir: {adapter_dir}")
510
+ print(f" checkpoint_root: {checkpoint_root}")
511
+ print(f" report_json: {report_json}")
512
+ print(f" save_every_batches: {args.save_every_batches}")
513
+ print(f" chunk_dir: {chunk_dir}")
514
+ print(f" no_final_merge: {args.no_final_merge}")
515
+
516
+ if not csv_path.exists():
517
+ raise FileNotFoundError(f"CSV file not found: {csv_path}")
518
+
519
+ if output_path.exists() and not args.overwrite:
520
+ raise FileExistsError(
521
+ f"Output already exists: {output_path}. Use --overwrite to replace it."
522
+ )
523
+
524
+ output_path.parent.mkdir(parents=True, exist_ok=True)
525
+
526
+ print("Reading CSV...")
527
+ id2seq = read_csv_sequences(csv_path)
528
+ print(f"Loaded {len(id2seq)} protein sequences.")
529
+
530
+ emb_df, chunk_paths = generate_embeddings(
531
+ id2seq=id2seq,
532
+ base_model_name=args.base_model,
533
+ adapter_dir=adapter_dir,
534
+ checkpoint_root=checkpoint_root,
535
+ batch_size=args.batch_size,
536
+ max_length=args.max_length,
537
+ max_seqs=args.max_seqs,
538
+ progress_every=args.progress_every,
539
+ report_json=report_json,
540
+ output_path=output_path,
541
+ save_every_batches=args.save_every_batches,
542
+ chunk_dir=chunk_dir,
543
+ final_merge=not args.no_final_merge,
544
+ use_base_model=args.use_base_model,
545
+ )
546
+
547
+ if emb_df is not None:
548
+ emb_df.to_pickle(output_path)
549
+ print(f"Saved pickle embeddings: {output_path}")
550
+ else:
551
+ print("Final pickle not written because final merge was skipped.")
552
+
553
+ if args.save_csv:
554
+ save_csv_path = Path(args.save_csv)
555
+ save_csv_path.parent.mkdir(parents=True, exist_ok=True)
556
+ if emb_df is not None:
557
+ emb_df.to_csv(save_csv_path)
558
+ print(f"Saved csv embeddings: {save_csv_path}")
559
+ else:
560
+ # Stream chunk files to csv when final merge is skipped.
561
+ header_written = False
562
+ for chunk_path in chunk_paths:
563
+ chunk_df = pd.read_pickle(chunk_path)
564
+ chunk_df.to_csv(save_csv_path, mode="a" if header_written else "w", header=not header_written)
565
+ header_written = True
566
+ print(f"Saved csv embeddings from chunks: {save_csv_path}")
567
+
568
+
569
+ if __name__ == "__main__":
570
+ main()