File size: 4,807 Bytes
62a01bd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e4ecaa0
 
 
 
 
 
 
 
 
 
62a01bd
e4ecaa0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62a01bd
e4ecaa0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62a01bd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
"""Shared ProtT5-XL loading / feature-extraction / h5 I/O helpers.

Used by both app.py (Gradio callbacks, wrapped in @spaces.GPU) and
preprocess/extract_example_feat.py (offline pre-computation of the built-in
example sequences' features into assets/). Keeping this logic in one place
means the cached features in assets/ are guaranteed to match what the app
would compute on the fly.
"""

import re
from pathlib import Path

import h5py
import numpy as np
import torch

T5_REPO_ID = "Rostlab/prot_t5_xl_half_uniref50-enc"

_t5_tokenizer = None
_t5_model = None


def get_device() -> str:
    return "cuda" if torch.cuda.is_available() else "cpu"


def preload_t5_cpu():
    """Deserialize tokenizer + model weights onto CPU, outside any @spaces.GPU
    context.

    This is the slow step (reading ~2.9GB of weights off disk into RAM) and
    it does not need a GPU. Calling it eagerly at app startup means the first
    @spaces.GPU-decorated call only has to do a device transfer (fast), not a
    full from_pretrained() load, so it no longer risks running past the
    ZeroGPU time budget.
    """
    global _t5_tokenizer, _t5_model
    if _t5_model is not None:
        return

    from transformers import AutoTokenizer, T5EncoderModel
    import transformers.utils.import_utils as _hf_utils
    import transformers.modeling_utils as _modeling_utils

    # Rostlab/prot_t5_xl_half_uniref50-enc is only available as .bin (no
    # safetensors). transformers 5.x blocks torch.load on torch < 2.6 due
    # to CVE-2025-32434. We bypass that gate for this specific trusted
    # checkpoint from the official HuggingFace Hub. The check lives in
    # two places β€” import_utils AND the locally-imported name in
    # modeling_utils β€” so both must be patched.
    _noop = lambda: None
    _orig_hf = _hf_utils.check_torch_load_is_safe
    _orig_mdl = _modeling_utils.check_torch_load_is_safe

    _hf_utils.check_torch_load_is_safe = _noop
    _modeling_utils.check_torch_load_is_safe = _noop

    try:
        repo_id = T5_REPO_ID
        _t5_tokenizer = AutoTokenizer.from_pretrained(
            repo_id, do_lower_case=False, local_files_only=True
        )
        _t5_model = (
            T5EncoderModel.from_pretrained(
                repo_id, torch_dtype=torch.float32, local_files_only=True
            )
            .eval()
        )
        _t5_model.requires_grad_(False)
    finally:
        _hf_utils.check_torch_load_is_safe = _orig_hf
        _modeling_utils.check_torch_load_is_safe = _orig_mdl


def load_t5():
    """Return (tokenizer, model) with the model on the current device.

    Assumes preload_t5_cpu() has already been called (at app startup). The
    only work left here is moving the already-deserialized model to the GPU
    on the first @spaces.GPU call β€” a fast operation compared to loading it
    from disk.
    """
    global _t5_model
    preload_t5_cpu()

    dev = get_device()
    if next(_t5_model.parameters()).device.type != dev:
        if dev == "cuda":
            _t5_model = _t5_model.half().to(dev).eval()
        else:
            _t5_model = _t5_model.float().to(dev).eval()

    return _t5_tokenizer, _t5_model


def extract_t5_feature(sequence: str) -> np.ndarray:
    """
    Return mean-pooled ProtT5-XL embedding for a single sequence.
    Output shape: (1024,) β€” matches the feature dimension LLPSense was trained on.
    """
    tok, mdl = load_t5()
    dev = get_device()

    # Replace ambiguous residues with X (same as original pipeline)
    clean = re.sub(r"[UZOB]", "X", sequence.strip().upper())
    spaced = " ".join(list(clean))

    enc = tok([spaced], add_special_tokens=True, padding="longest", return_tensors="pt")
    input_ids = enc["input_ids"].to(dev)
    attention_mask = enc["attention_mask"].to(dev)

    with torch.no_grad():
        hidden = mdl(input_ids=input_ids, attention_mask=attention_mask).last_hidden_state
        # Mean-pool over sequence positions (ignoring padding tokens)
        feat = (hidden * attention_mask[..., None]).sum(dim=1) / \
               attention_mask.sum(dim=1, keepdim=True)

    return feat[0].float().cpu().numpy()  # (1024,)


# ── h5 cache I/O ──────────────────────────────────────────────────────────────
FEATURE_TAG = "protein_feat"


def write_feature_h5(filepath, feat: np.ndarray, tag: str = FEATURE_TAG) -> None:
    filepath = Path(filepath)
    filepath.parent.mkdir(parents=True, exist_ok=True)
    with h5py.File(filepath, "w") as f:
        f.create_dataset(tag, data=feat, dtype="f", compression="gzip")


def read_feature_h5(filepath, tag: str = FEATURE_TAG) -> np.ndarray:
    with h5py.File(filepath, "r") as f:
        return f[tag][:]