Minjun Kang commited on
Commit
9d20977
ยท
1 Parent(s): 24b12a7

initial code

Browse files
Files changed (4) hide show
  1. Dockerfile +30 -0
  2. app.py +631 -0
  3. models/LLPSense.pkl +3 -0
  4. requirements.txt +14 -0
Dockerfile ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ # HF Spaces runs containers as UID 1000
4
+ RUN useradd -m -u 1000 user
5
+ USER user
6
+
7
+ ENV HOME=/home/user \
8
+ PATH=/home/user/.local/bin:$PATH \
9
+ HF_HOME=/home/user/.cache/huggingface
10
+
11
+ WORKDIR /home/user/app
12
+
13
+ # Install CPU-only PyTorch first (separate wheel index)
14
+ RUN pip install --no-cache-dir --user \
15
+ torch==2.4.0 torchvision==0.19.0 torchaudio==2.4.0 \
16
+ --index-url https://download.pytorch.org/whl/cpu
17
+
18
+ # Install remaining dependencies
19
+ COPY --chown=user requirements.txt .
20
+ RUN pip install --no-cache-dir --user -r requirements.txt
21
+
22
+ # Pre-download ProtT5-XL at build time so users never wait for it
23
+ RUN python -c "\
24
+ from huggingface_hub import snapshot_download; \
25
+ snapshot_download('Rostlab/prot_t5_xl_half_uniref50-enc')"
26
+
27
+ COPY --chown=user . .
28
+
29
+ EXPOSE 7860
30
+ CMD ["python", "app.py"]
app.py ADDED
@@ -0,0 +1,631 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LLPSense Gradio Demo
3
+ Condition-dependent protein LLPS prediction using ProtT5 + XGBoost
4
+
5
+ Pipeline:
6
+ (1) User inputs amino-acid sequence
7
+ (2) Click "Extract Feature" โ†’ mean-pool ProtT5-XL embedding (1024-dim)
8
+ (3) Tab 1 โ€“ Predict LLPS Probability: adjust temp / conc / pH sliders โ†’ predict
9
+ (4) Tab 2 โ€“ Condition Screening: pick one condition to vary, fix the rest โ†’ plot
10
+ """
11
+
12
+ import re
13
+ import sys
14
+ import warnings
15
+ from pathlib import Path
16
+ from copy import deepcopy
17
+
18
+ import numpy as np
19
+ import matplotlib
20
+ matplotlib.use("Agg")
21
+ import matplotlib.pyplot as plt
22
+ import joblib
23
+ import torch
24
+ import gradio as gr
25
+
26
+ warnings.filterwarnings("ignore")
27
+
28
+ # โ”€โ”€ Paths โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
29
+ BASE_DIR = Path(__file__).parent
30
+
31
+ # โ”€โ”€ Physical constants (from preprocess/misc.py) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
32
+ MAX_TEMP = 60.0
33
+ MAX_CONC = 1000.0
34
+ MAX_PH = 14.0
35
+ MAX_MGCL2 = 50.0
36
+ MAX_NACL = 2000.0
37
+ MAX_KCL = 1000.0
38
+ MAX_CAGENT = 50.0
39
+ MAX_GLYC = 10.0
40
+
41
+ # โ”€โ”€ Screening ranges โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
42
+ SCREEN_RANGES = {
43
+ "Temperature": np.arange(0.0, 61.0, 1.0), # 0โ€“60 ยฐC
44
+ "Concentration": np.arange(0.0, 1010.0, 10.0), # 0โ€“1000 ยตM
45
+ "pH": np.arange(4.0, 12.1, 0.1), # 4.0โ€“12.0
46
+ }
47
+ SCREEN_KEYS = {
48
+ "Temperature": "temp",
49
+ "Concentration": "conc",
50
+ "pH": "pH",
51
+ }
52
+ SCREEN_LABELS = {
53
+ "Temperature": "Temperature (ยฐC)",
54
+ "Concentration": "Concentration (ยตM)",
55
+ "pH": "pH",
56
+ }
57
+
58
+ VALID_AA = set("ACDEFGHIKLMNPQRSTVWY")
59
+ ALLOW_AA = VALID_AA | set("XBJOUZ")
60
+
61
+ # ฮฑ-synuclein (used as built-in example)
62
+ EXAMPLE_SEQ = (
63
+ "MDVFMKGLSKAKEGVVAAAEKTKQGVAEAAGKTKEGVLYVGSKTKEGVVHGVATVAEKTK"
64
+ "EQVTNVGGAVVTGVTAVAQKTVEGAGSIAAATGFVKKDQLGKNEEGAPQEGILEDMPVDP"
65
+ "DNEAYEMPSEEGYQDYEPEA"
66
+ )
67
+
68
+ # โ”€โ”€ Lazy-loaded singletons โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
69
+ _t5_tokenizer = None
70
+ _t5_model = None
71
+ _llps_model = None
72
+
73
+
74
+ def get_device() -> str:
75
+ return "cuda" if torch.cuda.is_available() else "cpu"
76
+
77
+
78
+ def load_llps_model():
79
+ global _llps_model
80
+ if _llps_model is None:
81
+ model_path = BASE_DIR / "models" / "LLPSense.pkl"
82
+ if not model_path.exists():
83
+ raise FileNotFoundError(
84
+ f"Model file not found: {model_path}\n"
85
+ "Please place 'LLPSense.pkl' inside the 'models/' directory."
86
+ )
87
+ d = joblib.load(model_path)
88
+ mdl = d["model"]
89
+ # Force XGBoost to run on CPU to avoid device-mismatch warnings
90
+ mdl.set_params(device="cpu")
91
+ _llps_model = mdl
92
+ return _llps_model
93
+
94
+
95
+ def load_t5():
96
+ global _t5_tokenizer, _t5_model
97
+ if _t5_model is None:
98
+ from transformers import AutoTokenizer, T5EncoderModel
99
+ import transformers.utils.import_utils as _hf_utils
100
+
101
+ # Rostlab/prot_t5_xl_half_uniref50-enc is only available as .bin (no safetensors).
102
+ # transformers 5.x blocks torch.load on torch < 2.6 due to CVE-2025-32434.
103
+ # We bypass that gate for this specific trusted checkpoint from the official
104
+ # HuggingFace Hub. The check lives in two places โ€” import_utils AND the
105
+ # locally-imported name in modeling_utils โ€” so both must be patched.
106
+ import transformers.modeling_utils as _modeling_utils
107
+
108
+ _noop = lambda: None
109
+ _orig_hf = _hf_utils.check_torch_load_is_safe
110
+ _orig_mdl = _modeling_utils.check_torch_load_is_safe
111
+
112
+ _hf_utils.check_torch_load_is_safe = _noop
113
+ _modeling_utils.check_torch_load_is_safe = _noop
114
+
115
+ try:
116
+ hf_name = "Rostlab/prot_t5_xl_half_uniref50-enc"
117
+ dev = get_device()
118
+ dtype = torch.float16 if dev == "cuda" else torch.float32
119
+
120
+ _t5_tokenizer = AutoTokenizer.from_pretrained(hf_name, do_lower_case=False)
121
+ _t5_model = (
122
+ T5EncoderModel.from_pretrained(hf_name, torch_dtype=dtype)
123
+ .to(dev)
124
+ .eval()
125
+ )
126
+ _t5_model.requires_grad_(False)
127
+ finally:
128
+ _hf_utils.check_torch_load_is_safe = _orig_hf # always restore
129
+ _modeling_utils.check_torch_load_is_safe = _orig_mdl
130
+
131
+ return _t5_tokenizer, _t5_model
132
+
133
+
134
+ # โ”€โ”€ Feature extraction โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
135
+ def extract_t5_feature(sequence: str) -> np.ndarray:
136
+ """
137
+ Return mean-pooled ProtT5-XL embedding for a single sequence.
138
+ Output shape: (1024,) โ€” matches the feature dimension LLPSense was trained on.
139
+ """
140
+ tok, mdl = load_t5()
141
+ dev = get_device()
142
+
143
+ # Replace ambiguous residues with X (same as original pipeline)
144
+ clean = re.sub(r"[UZOB]", "X", sequence.strip().upper())
145
+ spaced = " ".join(list(clean))
146
+
147
+ enc = tok([spaced], add_special_tokens=True, padding="longest", return_tensors="pt")
148
+ input_ids = enc["input_ids"].to(dev)
149
+ attention_mask = enc["attention_mask"].to(dev)
150
+
151
+ with torch.no_grad():
152
+ hidden = mdl(input_ids=input_ids, attention_mask=attention_mask).last_hidden_state
153
+ # Mean-pool over sequence positions (ignoring padding tokens)
154
+ feat = (hidden * attention_mask[..., None]).sum(dim=1) / \
155
+ attention_mask.sum(dim=1, keepdim=True)
156
+
157
+ return feat[0].float().cpu().numpy() # (1024,)
158
+
159
+
160
+ # โ”€โ”€ Condition vector builder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
161
+ def build_cond(temp, conc, pH,
162
+ nacl=160.0, mgcl2=0.0, kcl=0.0, glyc=0.0,
163
+ peg1=0.0, peg2=0.0, peg3=0.0,
164
+ ficoll=0.0, dext40=0.0, dext70=0.0) -> np.ndarray:
165
+ """
166
+ Normalise environmental parameters and return a 13-dim condition vector.
167
+ Order: [temp, conc, pH, PEG300-1k, PEG3k-6k, PEG8k-20k,
168
+ Ficoll, Dextranโ‰ค40, Dextranโ‰ฅ70, MgCl2, NaCl, KCl, Glycerol]
169
+ """
170
+ return np.array([
171
+ temp / MAX_TEMP,
172
+ conc / MAX_CONC,
173
+ pH / MAX_PH,
174
+ peg1 / MAX_CAGENT,
175
+ peg2 / MAX_CAGENT,
176
+ peg3 / MAX_CAGENT,
177
+ ficoll / MAX_CAGENT,
178
+ dext40 / MAX_CAGENT,
179
+ dext70 / MAX_CAGENT,
180
+ mgcl2 / MAX_MGCL2,
181
+ nacl / MAX_NACL,
182
+ kcl / MAX_KCL,
183
+ glyc / MAX_GLYC,
184
+ ], dtype=np.float32)
185
+
186
+
187
+ def model_predict(feat: np.ndarray, cond: np.ndarray) -> float:
188
+ model = load_llps_model()
189
+ x = np.concatenate([feat, cond]).reshape(1, -1)
190
+ return float(model.predict_proba(x)[0, 1])
191
+
192
+
193
+ # โ”€โ”€ Matplotlib helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
194
+ def prob_gauge_figure(prob: float) -> plt.Figure:
195
+ """Horizontal probability bar gauge."""
196
+ LLPS_COLOR = "#e74c3c"
197
+ NON_COLOR = "#2980b9"
198
+ color = LLPS_COLOR if prob >= 0.5 else NON_COLOR
199
+ label = "Phase Separating" if prob >= 0.5 else "Non-Phase Separating"
200
+
201
+ fig, ax = plt.subplots(figsize=(7, 2.8))
202
+ ax.barh([0], [prob], height=0.55, color=color, alpha=0.88, zorder=3)
203
+ ax.barh([0], [1 - prob], height=0.55, left=prob,
204
+ color="#ecf0f1", alpha=0.9, zorder=2)
205
+ ax.axvline(0.5, color="#2c3e50", lw=1.8, ls="--", label="Threshold 0.5", zorder=4)
206
+ ax.set_xlim(0, 1)
207
+ ax.set_ylim(-0.55, 0.55)
208
+ ax.set_yticks([])
209
+ ax.set_xlabel("LLPS Probability", fontsize=12)
210
+ ax.set_title(f"{label} | Probability: {prob:.4f}",
211
+ fontsize=14, fontweight="bold", color=color, pad=10)
212
+ ax.legend(fontsize=10, loc="lower right")
213
+ ax.spines[["top", "right", "left"]].set_visible(False)
214
+ plt.tight_layout()
215
+ return fig
216
+
217
+
218
+ def screening_figure(xvals: np.ndarray, probs: np.ndarray,
219
+ screen_name: str) -> plt.Figure:
220
+ """Line graph for condition screening result."""
221
+ xlabel = SCREEN_LABELS[screen_name]
222
+ LLPS_COLOR = "#e74c3c"
223
+ NON_COLOR = "#2980b9"
224
+
225
+ fig, ax = plt.subplots(figsize=(9, 5))
226
+ ax.plot(xvals, probs, lw=2.5, color=NON_COLOR, label="LLPS Probability", zorder=3)
227
+ ax.axhline(0.5, color=LLPS_COLOR, lw=1.8, ls="--",
228
+ label="Threshold 0.5", zorder=4)
229
+ ax.fill_between(xvals, probs, 0.5,
230
+ where=(probs >= 0.5), alpha=0.22,
231
+ color=LLPS_COLOR, label="LLPS region", zorder=2)
232
+ ax.fill_between(xvals, probs, 0.5,
233
+ where=(probs < 0.5), alpha=0.15,
234
+ color=NON_COLOR, label="Non-LLPS region", zorder=2)
235
+ ax.set_xlim(xvals[0], xvals[-1])
236
+ ax.set_ylim(0, 1)
237
+ ax.set_xlabel(xlabel, fontsize=13)
238
+ ax.set_ylabel("LLPS Probability", fontsize=13)
239
+ ax.set_title(f"Condition Screening โ€” {xlabel}", fontsize=14, fontweight="bold")
240
+ ax.legend(fontsize=11, loc="upper right")
241
+ ax.grid(True, alpha=0.3)
242
+ ax.spines[["top", "right"]].set_visible(False)
243
+ plt.tight_layout()
244
+ return fig
245
+
246
+
247
+ # โ”€โ”€ Status HTML templates โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
248
+ _SPINNER_HTML = """
249
+ <div style="display:flex;align-items:center;gap:14px;padding:10px 4px;">
250
+ <div style="
251
+ width:28px;height:28px;flex-shrink:0;
252
+ border:3px solid #fecaca;
253
+ border-top-color:#ef4444;
254
+ border-radius:50%;
255
+ animation:llps-spin 0.85s linear infinite;
256
+ "></div>
257
+ <span style="color:#555;font-size:14px;font-weight:500;line-height:1.5;">
258
+ ProtT5-XL feature extraction in progressโ€ฆ<br>
259
+ <span style="font-size:12px;color:#999;font-weight:400;">
260
+ First run downloads the model (~1.2 GB) โ€” this may take a few minutes.
261
+ </span>
262
+ </span>
263
+ </div>
264
+ <style>@keyframes llps-spin{to{transform:rotate(360deg)}}</style>
265
+ """
266
+
267
+ def _status_ok(seq_len: int, feat_dim: int) -> str:
268
+ pill = ("background:#f0fdf4;color:#15803d;border:1px solid #bbf7d0;"
269
+ "border-radius:9999px;padding:2px 10px;font-size:12px;font-weight:600;"
270
+ "display:inline-block;margin:0 4px 0 0;")
271
+ return (f'<div style="color:#16a34a;font-weight:600;padding:6px 0;display:flex;align-items:center;gap:6px;">'
272
+ f'<span>โœ… Feature extracted</span>'
273
+ f'<span style="{pill}">Length: {seq_len} AA</span>'
274
+ f'</div>')
275
+
276
+ def _status_warn(msg: str) -> str:
277
+ return f'<div style="color:#d97706;padding:6px 0;">โš ๏ธ {msg}</div>'
278
+
279
+ def _status_err(msg: str) -> str:
280
+ return f'<div style="color:#dc2626;padding:6px 0;">โŒ {msg}</div>'
281
+
282
+
283
+ # โ”€โ”€ Gradio callback functions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
284
+ def cb_extract(sequence: str):
285
+ """Step 2: Extract ProtT5 feature from sequence (generator โ†’ streams status).
286
+
287
+ Every yield also clears feat_state and the Step-3 result panels so a
288
+ stale feature/result from a previously extracted sequence can never
289
+ remain visible or be used once a new extraction starts.
290
+ """
291
+ seq = sequence.strip().upper()
292
+ if not seq:
293
+ yield None, _status_warn("Please enter a protein sequence."), None, "", None, ""
294
+ return
295
+
296
+ invalid = set(seq) - ALLOW_AA
297
+ if invalid:
298
+ yield None, _status_warn(f"Invalid characters: <code>{''.join(sorted(invalid))}</code>"), None, "", None, ""
299
+ return
300
+
301
+ # โ”€โ”€ invalidate old feature/results immediately, then show spinner โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
302
+ yield None, _SPINNER_HTML, None, "", None, ""
303
+
304
+ try:
305
+ feat = extract_t5_feature(seq)
306
+ yield feat, _status_ok(len(seq), feat.shape[0]), None, "", None, ""
307
+ except Exception as e:
308
+ yield None, _status_err(str(e)), None, "", None, ""
309
+
310
+
311
+ def cb_predict(feat,
312
+ temp, conc, pH,
313
+ nacl, mgcl2, kcl, glyc,
314
+ peg1, peg2, peg3, ficoll, dext40, dext70):
315
+ """Tab 1: Predict LLPS probability for a single condition point."""
316
+ if feat is None:
317
+ return None, "โš ๏ธ Please extract the T5 feature first (Step 2)."
318
+ try:
319
+ cond = build_cond(temp, conc, pH, nacl, mgcl2, kcl, glyc,
320
+ peg1, peg2, peg3, ficoll, dext40, dext70)
321
+ prob = model_predict(feat, cond)
322
+ fig = prob_gauge_figure(prob)
323
+ label = "**Phase Separating** ๐Ÿ”ด" if prob >= 0.5 else "**Non-Phase Separating** ๐Ÿ”ต"
324
+ txt = f"{label} \nLLPS Probability: **{prob:.4f}**"
325
+ return fig, txt
326
+ except Exception as e:
327
+ return None, f"โŒ Prediction failed: {e}"
328
+
329
+
330
+ def cb_screen(feat, screen_name,
331
+ fix_temp, fix_conc, fix_pH,
332
+ nacl, mgcl2, kcl, glyc,
333
+ peg1, peg2, peg3, ficoll, dext40, dext70):
334
+ """Tab 2: Screen LLPS across a range of one condition."""
335
+ if feat is None:
336
+ return None, "โš ๏ธ Please extract the T5 feature first (Step 2)."
337
+ if screen_name is None:
338
+ return None, "โš ๏ธ Please select a condition to screen."
339
+ try:
340
+ xvals = SCREEN_RANGES[screen_name]
341
+ probs = []
342
+ for v in xvals:
343
+ t = float(v) if screen_name == "Temperature" else fix_temp
344
+ c = float(v) if screen_name == "Concentration" else fix_conc
345
+ p = float(v) if screen_name == "pH" else fix_pH
346
+ cond = build_cond(t, c, p, nacl, mgcl2, kcl, glyc,
347
+ peg1, peg2, peg3, ficoll, dext40, dext70)
348
+ probs.append(model_predict(feat, cond))
349
+
350
+ probs = np.array(probs)
351
+ fig = screening_figure(xvals, probs, screen_name)
352
+
353
+ peak_idx = probs.argmax()
354
+ xlabel = SCREEN_LABELS[screen_name]
355
+ txt = (f"Screening completed. \n"
356
+ f"Peak probability **{probs[peak_idx]:.4f}** "
357
+ f"at {xlabel} = **{xvals[peak_idx]:.1f}** \n"
358
+ f"LLPS-positive range: "
359
+ f"**{(probs >= 0.5).sum()}** / {len(probs)} points โ‰ฅ 0.5")
360
+ return fig, txt
361
+ except Exception as e:
362
+ return None, f"โŒ Screening failed: {e}"
363
+
364
+
365
+
366
+
367
+ # โ”€โ”€ Gradio UI โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
368
+ DESCRIPTION = """
369
+ # ๐Ÿงฌ LLPSense โ€” Condition-Dependent LLPS Prediction
370
+
371
+ **LLPSense** predicts whether a protein undergoes liquid-liquid phase separation (LLPS)
372
+ under user-defined environmental conditions.
373
+
374
+ > Baeโ€ , Kangโ€ , et al. *"A machine learning framework for predicting and modulating
375
+ > condition-dependent protein phase separation."* bioRxiv 2025.
376
+ ---
377
+ ## Workflow
378
+ 1. **Paste your sequence** in the text box below.
379
+ 2. Click **Extract Feature** to compute the ProtT5-XL embedding.
380
+ 3. Use **Predict LLPS Probability** to query a specific condition point, or
381
+ **Condition Screening** to sweep one condition across its full range.
382
+ ---
383
+ """
384
+
385
+ CUSTOM_CSS = """
386
+ /* Import fonts from Google Fonts */
387
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap');
388
+
389
+ /* Sequence textbox: monospace font for clean AA letter display */
390
+ #seq-input textarea,
391
+ #seq-input input {
392
+ font-family: 'JetBrains Mono', 'Source Code Pro', 'Courier New', monospace !important;
393
+ font-size: 14px !important;
394
+ line-height: 1.7 !important;
395
+ letter-spacing: 0.04em !important;
396
+ }
397
+
398
+ /* Slider thumb: orange */
399
+ input[type="range"]::-webkit-slider-thumb {
400
+ background: #ef4444 !important;
401
+ border-color: #ef4444 !important;
402
+ }
403
+ input[type="range"]::-moz-range-thumb {
404
+ background: #ef4444 !important;
405
+ border-color: #ef4444 !important;
406
+ }
407
+
408
+ /* Slider numeric value input */
409
+ input[type="number"] {
410
+ font-size: 15px !important;
411
+ font-weight: 600 !important;
412
+ }
413
+
414
+ /* Initial state: hide Temperature slider column (default screen condition) */
415
+ #s-temp-col { display: none; }
416
+
417
+ /* Tab buttons */
418
+ button[role="tab"] {
419
+ background: #ffffff !important;
420
+ border: 1.5px solid #d1d5db !important;
421
+ border-radius: 8px 8px 0 0 !important;
422
+ color: #4b5563 !important;
423
+ font-weight: 500 !important;
424
+ transition: background 0.15s, color 0.15s !important;
425
+ }
426
+ button[role="tab"]:hover {
427
+ background: #eff6ff !important;
428
+ color: #1d4ed8 !important;
429
+ border-color: #93c5fd !important;
430
+ }
431
+ button[role="tab"][aria-selected="true"] {
432
+ background: #2563eb !important;
433
+ color: #ffffff !important;
434
+ border-color: #2563eb !important;
435
+ font-weight: 600 !important;
436
+ }
437
+ """
438
+
439
+ with gr.Blocks(
440
+ theme=gr.themes.Soft(
441
+ primary_hue="blue",
442
+ font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
443
+ font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "ui-monospace", "monospace"],
444
+ ),
445
+ css=CUSTOM_CSS,
446
+ title="LLPSense Demo",
447
+ ) as demo:
448
+
449
+ feat_state = gr.State(None)
450
+
451
+ gr.Markdown(DESCRIPTION)
452
+
453
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
454
+ # Step 1 + 2: Sequence input & feature extraction
455
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
456
+ with gr.Group():
457
+ gr.Markdown("## Step 1 โ€” Enter Protein Sequence")
458
+ seq_box = gr.Textbox(
459
+ label="Amino Acid Sequence (1-letter code)",
460
+ placeholder="Paste your protein sequence here (e.g. MDVFMKGLSKโ€ฆ)",
461
+ lines=5,
462
+ value=EXAMPLE_SEQ,
463
+ elem_id="seq-input",
464
+ )
465
+ gr.Examples(
466
+ examples=[[EXAMPLE_SEQ]],
467
+ inputs=[seq_box],
468
+ label="Example: ฮฑ-synuclein (SNCA)",
469
+ )
470
+
471
+ with gr.Group():
472
+ gr.Markdown("## Step 2 โ€” Extract ProtT5 Feature")
473
+ gr.Markdown(
474
+ "Runs the [**ProtT5-XL**](https://github.com/agemagician/ProtTrans) encoder to produce a 1024-dim mean-pool embedding. \n"
475
+ "โณ *First call downloads the model (~3 GB) and may take a few minutes.*"
476
+ )
477
+ extract_btn = gr.Button("๐Ÿ”ฌ Extract Feature", variant="primary", size="lg")
478
+ extract_status = gr.HTML("")
479
+
480
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
481
+ # Step 3: Prediction & Screening
482
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
483
+ with gr.Group():
484
+ gr.Markdown("## Step 3 โ€” Run Demo")
485
+ gr.Markdown(
486
+ "Predict LLPS probability for a specific condition, "
487
+ "or sweep one condition across its full range."
488
+ )
489
+
490
+ with gr.Tabs():
491
+
492
+ # โ”€โ”€ Tab 1: Single-point prediction โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
493
+ with gr.Tab("๐Ÿ”ฎ Predict LLPS Probability"):
494
+ gr.Markdown(
495
+ "Set the environmental conditions with the sliders below, "
496
+ "then click **Predict** to obtain the LLPS probability."
497
+ )
498
+
499
+ # Primary conditions
500
+ with gr.Row():
501
+ p_temp = gr.Slider(0, 60, value=25.0, step=0.5,
502
+ label="Temperature (ยฐC)")
503
+ p_conc = gr.Slider(0, 1000, value=100.0, step=5.0,
504
+ label="Concentration (ยตM)")
505
+ p_pH = gr.Slider(0, 14, value=7.3, step=0.1,
506
+ label="pH")
507
+
508
+ # Advanced conditions
509
+ with gr.Accordion("โš™๏ธ Advanced Conditions (Salts & Crowding Agents)", open=False):
510
+ gr.Markdown("Default values represent a common physiological buffer (160 mM NaCl).")
511
+ with gr.Row():
512
+ p_nacl = gr.Slider(0, 2000, value=160.0, step=10.0, label="NaCl (mM)")
513
+ p_mgcl2 = gr.Slider(0, 50, value=0.0, step=1.0, label="MgClโ‚‚ (mM)")
514
+ p_kcl = gr.Slider(0, 1000, value=0.0, step=10.0, label="KCl (mM)")
515
+ p_glyc = gr.Slider(0, 10, value=0.0, step=0.5, label="Glycerol (%)")
516
+ with gr.Row():
517
+ p_peg1 = gr.Slider(0, 50, value=0, step=1, label="PEG 300โ€“1000 (%)")
518
+ p_peg2 = gr.Slider(0, 50, value=0, step=1, label="PEG 3kโ€“6k (%)")
519
+ p_peg3 = gr.Slider(0, 50, value=0, step=1, label="PEG 8kโ€“20k (%)")
520
+ with gr.Row():
521
+ p_ficoll = gr.Slider(0, 50, value=0, step=1, label="Ficoll (%)")
522
+ p_dext40 = gr.Slider(0, 50, value=0, step=1, label="Dextran โ‰ค40 kDa (%)")
523
+ p_dext70 = gr.Slider(0, 50, value=0, step=1, label="Dextran โ‰ฅ70 kDa (%)")
524
+
525
+ pred_btn = gr.Button("โšก Predict LLPS Probability", variant="primary")
526
+ pred_plot = gr.Plot(label="Prediction Result")
527
+ pred_text = gr.Markdown("")
528
+
529
+ pred_btn.click(
530
+ fn=cb_predict,
531
+ inputs=[
532
+ feat_state,
533
+ p_temp, p_conc, p_pH,
534
+ p_nacl, p_mgcl2, p_kcl, p_glyc,
535
+ p_peg1, p_peg2, p_peg3, p_ficoll, p_dext40, p_dext70,
536
+ ],
537
+ outputs=[pred_plot, pred_text],
538
+ )
539
+
540
+ # โ”€โ”€ Tab 2: Condition Screening โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
541
+ with gr.Tab("๐Ÿ“ˆ Condition Screening"):
542
+ gr.Markdown(
543
+ "Select **one condition** to screen across its full physiological range. \n"
544
+ "The remaining conditions are held fixed at the values you specify below."
545
+ )
546
+
547
+ screen_radio = gr.Radio(
548
+ choices=["Temperature", "Concentration", "pH"],
549
+ value="Temperature",
550
+ label="Condition to Screen",
551
+ info="This condition will be swept across its full range; its slider value below is hidden.",
552
+ )
553
+
554
+ # Fixed-value sliders โ€” JS hides the swept condition's column (no Gradio re-render)
555
+ with gr.Row():
556
+ with gr.Column(elem_id="s-temp-col"):
557
+ s_temp = gr.Slider(0, 60, value=25.0, step=0.5,
558
+ label="Temperature (ยฐC) [fixed]")
559
+ with gr.Column(elem_id="s-conc-col"):
560
+ s_conc = gr.Slider(0, 1000, value=100.0, step=5.0,
561
+ label="Concentration (ยตM) [fixed]")
562
+ with gr.Column(elem_id="s-ph-col"):
563
+ s_pH = gr.Slider(0, 14, value=7.3, step=0.1,
564
+ label="pH [fixed]")
565
+
566
+ # Pure JS toggle โ€” bypasses Gradio server update, so slider fills are preserved
567
+ screen_radio.change(
568
+ fn=None,
569
+ inputs=[screen_radio],
570
+ outputs=[],
571
+ js="""(screen_name) => {
572
+ const map = {Temperature: 's-temp-col', Concentration: 's-conc-col', pH: 's-ph-col'};
573
+ for (const [cond, id] of Object.entries(map)) {
574
+ const el = document.getElementById(id);
575
+ if (el) el.style.display = (cond === screen_name) ? 'none' : 'flex';
576
+ }
577
+ }""",
578
+ )
579
+
580
+ # Advanced conditions (always fixed during screening)
581
+ with gr.Accordion("โš™๏ธ Advanced Conditions (Salts & Crowding Agents)", open=False):
582
+ gr.Markdown("Default values represent a common physiological buffer (160 mM NaCl).")
583
+ with gr.Row():
584
+ s_nacl = gr.Slider(0, 2000, value=160.0, step=10.0, label="NaCl (mM)")
585
+ s_mgcl2 = gr.Slider(0, 50, value=0.0, step=1.0, label="MgClโ‚‚ (mM)")
586
+ s_kcl = gr.Slider(0, 1000, value=0.0, step=10.0, label="KCl (mM)")
587
+ s_glyc = gr.Slider(0, 10, value=0.0, step=0.5, label="Glycerol (%)")
588
+ with gr.Row():
589
+ s_peg1 = gr.Slider(0, 50, value=0, step=1, label="PEG 300โ€“1000 (%)")
590
+ s_peg2 = gr.Slider(0, 50, value=0, step=1, label="PEG 3kโ€“6k (%)")
591
+ s_peg3 = gr.Slider(0, 50, value=0, step=1, label="PEG 8kโ€“20k (%)")
592
+ with gr.Row():
593
+ s_ficoll = gr.Slider(0, 50, value=0, step=1, label="Ficoll (%)")
594
+ s_dext40 = gr.Slider(0, 50, value=0, step=1, label="Dextran โ‰ค40 kDa (%)")
595
+ s_dext70 = gr.Slider(0, 50, value=0, step=1, label="Dextran โ‰ฅ70 kDa (%)")
596
+
597
+ screen_btn = gr.Button("๐Ÿ“Š Run Condition Screening", variant="primary")
598
+ screen_plot = gr.Plot(label="Screening Result")
599
+ screen_text = gr.Markdown("")
600
+
601
+ screen_btn.click(
602
+ fn=cb_screen,
603
+ inputs=[
604
+ feat_state, screen_radio,
605
+ s_temp, s_conc, s_pH,
606
+ s_nacl, s_mgcl2, s_kcl, s_glyc,
607
+ s_peg1, s_peg2, s_peg3, s_ficoll, s_dext40, s_dext70,
608
+ ],
609
+ outputs=[screen_plot, screen_text],
610
+ )
611
+
612
+ # Registered here (after Step 3 components exist) since cb_extract also
613
+ # clears the Predict/Screening panels so a re-extracted sequence can
614
+ # never leave a stale result from the previous sequence on screen.
615
+ extract_btn.click(
616
+ fn=cb_extract,
617
+ inputs=[seq_box],
618
+ outputs=[feat_state, extract_status, pred_plot, pred_text, screen_plot, screen_text],
619
+ )
620
+
621
+ # โ”€โ”€ Footer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
622
+ gr.Markdown("""
623
+ ---
624
+ **Authors:** Jangwon Baeโ€ , Minjun Kangโ€ , Donghyuk Lee, Kuk-Jin Yoon*, Yongwon Jung*
625
+ **Paper:** [bioRxiv 2025.12.28.696755](https://doi.org/10.64898/2025.12.28.696755)
626
+ **GitHub:** [NearNiah/LLPSense](https://github.com/NearNiah/LLPSense)
627
+ """)
628
+
629
+
630
+ if __name__ == "__main__":
631
+ demo.launch(server_name="0.0.0.0", server_port=7860)
models/LLPSense.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d2997491587855939759d3d4c508ad90b7f8c55a000846dc08585095f701aa6c
3
+ size 11188914
requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # torch/torchvision/torchaudio are installed by Dockerfile with CPU wheel
2
+ transformers>=5.0.0
3
+ sentencepiece # ProtT5 tokenizer backend
4
+
5
+ # Inference
6
+ xgboost==2.0.3
7
+ scikit-learn
8
+ joblib
9
+
10
+ # Gradio demo
11
+ gradio>=4.0.0
12
+ numpy
13
+ scipy
14
+ matplotlib