Feature Extraction
PEFT
Safetensors
PyTorch
English
biology
genomics
bioinformatics
protein-language-model
lora
Instructions to use Amin-Saeidi/PhageContraMLM with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- PEFT
How to use Amin-Saeidi/PhageContraMLM with PEFT:
Task type is invalid.
- Notebooks
- Google Colab
- Kaggle
Upload folder using huggingface_hub
Browse files- README.md +110 -1
- pyproject.toml +23 -0
- requirements.txt +11 -0
- requirements_conda.txt +133 -0
- runs/test_embeddings_protrans_lora_ContraMLM_v1_1.pkl +3 -0
- runs/test_embeddings_protrans_lora_base.pkl +3 -0
- src/produce_test_data_embeddings.py +570 -0
README.md
CHANGED
|
@@ -1,3 +1,112 @@
|
|
| 1 |
---
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|