""" LLPSense Gradio Demo Condition-dependent protein LLPS prediction using ProtT5 + XGBoost Pipeline: (1) User inputs amino-acid sequence (2) Click "Extract Feature" → mean-pool ProtT5-XL embedding (1024-dim) (3) Tab 1 – Predict LLPS Probability: adjust temp / conc / pH sliders → predict (4) Tab 2 – Condition Screening: pick one condition to vary, fix the rest → plot """ import sys import warnings from pathlib import Path from copy import deepcopy import numpy as np import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import joblib import gradio as gr import spaces from huggingface_hub import snapshot_download from examples import EXAMPLES, feature_path, find_example_by_seq # Only surface examples that actually have a pre-computed T5 feature cached # in assets/ — an entry added via preprocess/add_example.py but not yet run # through preprocess/extract_example_feat.py would otherwise show up in the # picker and silently fall back to a slow/GPU extraction on first click. AVAILABLE_EXAMPLES = [example for example in EXAMPLES if feature_path(example["id"]).exists()] from t5_utils import ( T5_REPO_ID, extract_t5_feature as _extract_t5_feature_core, preload_t5_cpu, read_feature_h5, ) warnings.filterwarnings("ignore") # ── Paths ───────────────────────────────────────────────────────────────────── BASE_DIR = Path(__file__).parent # ── ProtT5 prefetch ──────────────────────────────────────────────────────────── # This SDK-gradio Space builds from requirements.txt, not the repo's Dockerfile, # so there is no build-time prefetch step. Download the weights here instead, # at module import (app startup, plain CPU context) — before any @spaces.GPU # call — so extract_t5_feature() never blocks on a network download while # holding a ZeroGPU allocation (which has a short time budget). snapshot_download(T5_REPO_ID) # Also deserialize the weights into CPU memory here, still outside any # @spaces.GPU context. Without this, T5EncoderModel.from_pretrained() (loading # ~2.9GB from disk) would run lazily inside the first GPU-decorated call and # could eat enough of the ZeroGPU time budget to abort the task. With this, # the first GPU call only needs a fast .to("cuda") transfer. preload_t5_cpu() # ── Physical constants (from preprocess/misc.py) ────────────────────────────── MAX_TEMP = 60.0 MAX_CONC = 1000.0 MAX_PH = 14.0 MAX_MGCL2 = 50.0 MAX_NACL = 2000.0 MAX_KCL = 1000.0 MAX_CAGENT = 50.0 MAX_GLYC = 10.0 # ── Screening ranges ───────────────────────────────────────────────────────── SCREEN_RANGES = { "Temperature": np.arange(0.0, 61.0, 1.0), # 0–60 °C "Concentration": np.arange(0.0, 1010.0, 10.0), # 0–1000 µM "pH": np.arange(4.0, 12.1, 0.1), # 4.0–12.0 } SCREEN_KEYS = { "Temperature": "temp", "Concentration": "conc", "pH": "pH", } SCREEN_LABELS = { "Temperature": "Temperature (°C)", "Concentration": "Concentration (µM)", "pH": "pH", } VALID_AA = set("ACDEFGHIKLMNPQRSTVWY") ALLOW_AA = VALID_AA | set("XBJOUZ") # ── Lazy-loaded singletons ──────────────────────────────────────────────────── _llps_model = None def load_llps_model(): global _llps_model if _llps_model is None: model_path = BASE_DIR / "models" / "LLPSense.pkl" if not model_path.exists(): raise FileNotFoundError( f"Model file not found: {model_path}\n" "Please place 'LLPSense.pkl' inside the 'models/' directory." ) d = joblib.load(model_path) mdl = d["model"] # Force XGBoost to run on CPU to avoid device-mismatch warnings mdl.set_params(device="cpu") _llps_model = mdl return _llps_model # ── Feature extraction ──────────────────────────────────────────────────────── # The actual tokenize/forward/mean-pool logic lives in t5_utils.py, shared # with preprocess/extract_example_feat.py so the offline-cached example # features in assets/ always match what this would compute live. Only the # @spaces.GPU wrapping (ZeroGPU allocation) is app-specific, and cb_extract() # below skips calling this entirely when a cached feature is available. @spaces.GPU(duration=180) def extract_t5_feature(sequence: str) -> np.ndarray: return _extract_t5_feature_core(sequence) # ── Condition vector builder ────────────────────────────────────────────────── def build_cond(temp, conc, pH, nacl=160.0, mgcl2=0.0, kcl=0.0, glyc=0.0, peg1=0.0, peg2=0.0, peg3=0.0, ficoll=0.0, dext40=0.0, dext70=0.0) -> np.ndarray: """ Normalise environmental parameters and return a 13-dim condition vector. Order: [temp, conc, pH, PEG300-1k, PEG3k-6k, PEG8k-20k, Ficoll, Dextran≤40, Dextran≥70, MgCl2, NaCl, KCl, Glycerol] """ return np.array([ temp / MAX_TEMP, conc / MAX_CONC, pH / MAX_PH, peg1 / MAX_CAGENT, peg2 / MAX_CAGENT, peg3 / MAX_CAGENT, ficoll / MAX_CAGENT, dext40 / MAX_CAGENT, dext70 / MAX_CAGENT, mgcl2 / MAX_MGCL2, nacl / MAX_NACL, kcl / MAX_KCL, glyc / MAX_GLYC, ], dtype=np.float32) def model_predict(feat: np.ndarray, cond: np.ndarray) -> float: model = load_llps_model() x = np.concatenate([feat, cond]).reshape(1, -1) return float(model.predict_proba(x)[0, 1]) # ── Smoothing (same as LLPSXG.py's moving_average) ──────────────────────────── def moving_average(y: np.ndarray, window_size: int) -> np.ndarray: if window_size % 2 == 0: raise ValueError("Window size should be odd to ensure symmetry.") window = np.ones(int(window_size)) / float(window_size) y_padded = np.pad(y, (window_size // 2, window_size // 2), mode="edge") return np.convolve(y_padded, window, "valid") # ── Matplotlib helpers ──────────────────────────────────────────────────────── def prob_gauge_figure(prob: float) -> plt.Figure: """Horizontal probability bar gauge.""" LLPS_COLOR = "#e74c3c" NON_COLOR = "#2980b9" color = LLPS_COLOR if prob >= 0.5 else NON_COLOR label = "Phase Separating" if prob >= 0.5 else "Non-Phase Separating" fig, ax = plt.subplots(figsize=(7, 2.8)) ax.barh([0], [prob], height=0.55, color=color, alpha=0.88, zorder=3) ax.barh([0], [1 - prob], height=0.55, left=prob, color="#ecf0f1", alpha=0.9, zorder=2) ax.axvline(0.5, color="#2c3e50", lw=1.8, ls="--", label="Threshold 0.5", zorder=4) ax.set_xlim(0, 1) ax.set_ylim(-0.55, 0.55) ax.set_yticks([]) ax.set_xlabel("LLPS Probability", fontsize=12) ax.set_title(f"{label} | Probability: {prob:.4f}", fontsize=14, fontweight="bold", color=color, pad=10) ax.legend(fontsize=10, loc="lower right") ax.spines[["top", "right", "left"]].set_visible(False) plt.tight_layout() return fig def screening_figure(xvals: np.ndarray, probs: np.ndarray, screen_name: str, probs_smooth: np.ndarray = None) -> plt.Figure: """Line graph for condition screening result. If probs_smooth differs from probs (smoothing window > 1), the raw curve is drawn as a faint dotted reference line and the smoothed curve becomes the main plotted/filled line. """ xlabel = SCREEN_LABELS[screen_name] LLPS_COLOR = "#e74c3c" NON_COLOR = "#2980b9" smoothed = probs_smooth is not None and not np.array_equal(probs, probs_smooth) plot_probs = probs_smooth if smoothed else probs fig, ax = plt.subplots(figsize=(9, 5)) if smoothed: ax.plot(xvals, probs, lw=1.2, color=NON_COLOR, alpha=0.35, ls=":", label="Raw", zorder=2) ax.plot(xvals, plot_probs, lw=2.5, color=NON_COLOR, label="Smoothed LLPS Probability" if smoothed else "LLPS Probability", zorder=3) ax.axhline(0.5, color=LLPS_COLOR, lw=1.8, ls="--", label="Threshold 0.5", zorder=4) ax.fill_between(xvals, plot_probs, 0.5, where=(plot_probs >= 0.5), alpha=0.22, color=LLPS_COLOR, label="LLPS region", zorder=2) ax.fill_between(xvals, plot_probs, 0.5, where=(plot_probs < 0.5), alpha=0.15, color=NON_COLOR, label="Non-LLPS region", zorder=2) ax.set_xlim(xvals[0], xvals[-1]) ax.set_ylim(0, 1) ax.set_xlabel(xlabel, fontsize=13) ax.set_ylabel("LLPS Probability", fontsize=13) ax.set_title(f"Condition Screening — {xlabel}", fontsize=14, fontweight="bold") ax.legend(fontsize=11, loc="upper right") ax.grid(True, alpha=0.3) ax.spines[["top", "right"]].set_visible(False) plt.tight_layout() return fig # ── Status HTML templates ───────────────────────────────────────────────────── _SPINNER_HTML = """
{''.join(sorted(invalid))}"), None, "", None, ""
return
# ── invalidate old feature/results immediately, then show spinner ─────────
yield None, _SPINNER_HTML, None, "", None, ""
try:
# Known example sequence with a pre-computed feature? Skip the T5
# model/GPU call entirely and load it straight from assets/.
example = find_example_by_seq(seq)
cached_path = feature_path(example["id"]) if example else None
if cached_path and cached_path.exists():
feat = read_feature_h5(cached_path)
else:
feat = extract_t5_feature(seq)
yield feat, _status_ok(len(seq), feat.shape[0]), None, "", None, ""
except Exception as e:
yield None, _status_err(str(e)), None, "", None, ""
def cb_predict(feat,
temp, conc, pH,
nacl, mgcl2, kcl, glyc,
peg1, peg2, peg3, ficoll, dext40, dext70):
"""Tab 1: Predict LLPS probability for a single condition point."""
if feat is None:
return None, "⚠️ Please extract the T5 feature first (Step 2)."
try:
cond = build_cond(temp, conc, pH, nacl, mgcl2, kcl, glyc,
peg1, peg2, peg3, ficoll, dext40, dext70)
prob = model_predict(feat, cond)
fig = prob_gauge_figure(prob)
label = "**Phase Separating** 🔴" if prob >= 0.5 else "**Non-Phase Separating** 🔵"
txt = f"{label} \nLLPS Probability: **{prob:.4f}**"
return fig, txt
except Exception as e:
return None, f"❌ Prediction failed: {e}"
def cb_screen(feat, screen_name,
fix_temp, fix_conc, fix_pH,
nacl, mgcl2, kcl, glyc,
peg1, peg2, peg3, ficoll, dext40, dext70,
smooth_window):
"""Tab 2: Screen LLPS across a range of one condition."""
if feat is None:
return None, "⚠️ Please extract the T5 feature first (Step 2)."
if screen_name is None:
return None, "⚠️ Please select a condition to screen."
try:
xvals = SCREEN_RANGES[screen_name]
probs = []
for v in xvals:
t = float(v) if screen_name == "Temperature" else fix_temp
c = float(v) if screen_name == "Concentration" else fix_conc
p = float(v) if screen_name == "pH" else fix_pH
cond = build_cond(t, c, p, nacl, mgcl2, kcl, glyc,
peg1, peg2, peg3, ficoll, dext40, dext70)
probs.append(model_predict(feat, cond))
probs = np.array(probs)
# Slider step keeps this odd (1, 3, 5, ...); window=1 is a no-op average.
window = int(smooth_window)
probs_plot = moving_average(probs, window) if window > 1 else probs
fig = screening_figure(xvals, probs, screen_name, probs_plot)
peak_idx = probs_plot.argmax()
xlabel = SCREEN_LABELS[screen_name]
txt = (f"Screening completed. \n"
f"Peak probability **{probs_plot[peak_idx]:.4f}** "
f"at {xlabel} = **{xvals[peak_idx]:.1f}** \n"
f"LLPS-positive range: "
f"**{(probs_plot >= 0.5).sum()}** / {len(probs_plot)} points ≥ 0.5")
if window > 1:
txt += f" \n*(Smoothed with moving-average window size {window})*"
return fig, txt
except Exception as e:
return None, f"❌ Screening failed: {e}"
# ── Gradio UI ─────────────────────────────────────────────────────────────────
DESCRIPTION = """
# 🧬 LLPSense — Condition-Dependent LLPS Prediction
**LLPSense** predicts whether a protein undergoes liquid-liquid phase separation (LLPS)
under user-defined environmental conditions.
> Bae†, Kang†, et al. *"A machine learning framework for predicting and modulating
> condition-dependent protein phase separation."* bioRxiv 2025.
---
## Workflow
1. **Paste your sequence** in the text box below.
2. Click **Extract Feature** to compute the ProtT5-XL embedding.
3. Use **Predict LLPS Probability** to query a specific condition point, or
**Condition Screening** to sweep one condition across its full range.
---
"""
CUSTOM_CSS = """
/* Import fonts from Google Fonts */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap');
/* Sequence textbox: monospace font for clean AA letter display */
#seq-input textarea,
#seq-input input {
font-family: 'JetBrains Mono', 'Source Code Pro', 'Courier New', monospace !important;
font-size: 14px !important;
line-height: 1.7 !important;
letter-spacing: 0.04em !important;
}
/* Slider thumb: orange */
input[type="range"]::-webkit-slider-thumb {
background: #ef4444 !important;
border-color: #ef4444 !important;
}
input[type="range"]::-moz-range-thumb {
background: #ef4444 !important;
border-color: #ef4444 !important;
}
/* Slider numeric value input */
input[type="number"] {
font-size: 15px !important;
font-weight: 600 !important;
}
/* Initial state: hide Temperature slider column (default screen condition) */
#s-temp-col { display: none; }
/* Tab buttons */
button[role="tab"] {
background: #ffffff !important;
border: 1.5px solid #d1d5db !important;
border-radius: 8px 8px 0 0 !important;
color: #4b5563 !important;
font-weight: 500 !important;
transition: background 0.15s, color 0.15s !important;
}
button[role="tab"]:hover {
background: #eff6ff !important;
color: #1d4ed8 !important;
border-color: #93c5fd !important;
}
button[role="tab"][aria-selected="true"] {
background: #2563eb !important;
color: #ffffff !important;
border-color: #2563eb !important;
font-weight: 600 !important;
}
"""
with gr.Blocks(
theme=gr.themes.Soft(
primary_hue="blue",
font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "ui-monospace", "monospace"],
),
css=CUSTOM_CSS,
title="LLPSense Demo",
) as demo:
feat_state = gr.State(None)
gr.Markdown(DESCRIPTION)
# ────────────────────────────────────────────────────────────────
# Step 1 + 2: Sequence input & feature extraction
# ────────────────────────────────────────────────────────────────
with gr.Group():
gr.Markdown("## Step 1 — Enter Protein Sequence")
seq_box = gr.Textbox(
label="Amino Acid Sequence (1-letter code)",
placeholder="Paste your protein sequence here (e.g. MDVFMKGLSK…)",
lines=5,
value=AVAILABLE_EXAMPLES[0]["seq"] if AVAILABLE_EXAMPLES else "",
elem_id="seq-input",
)
# Clicking an example fills seq_box; the matching Step-2 extraction
# is chained onto it further down (once the Step-3 output components
# exist) so selecting an example runs extraction automatically.
# Skipped entirely if no example has a cached feature yet.
example_picker = None
if AVAILABLE_EXAMPLES:
with gr.Accordion("🧪 Examples (We provide preprocessed T5 feature)", open=False):
example_picker = gr.Examples(
examples=[[example["seq"]] for example in AVAILABLE_EXAMPLES],
example_labels=[example["name"] for example in AVAILABLE_EXAMPLES],
inputs=[seq_box],
)
with gr.Group():
gr.Markdown("## Step 2 — Extract ProtT5 Feature")
gr.Markdown(
"Runs the [**ProtT5-XL**](https://github.com/agemagician/ProtTrans) encoder to produce a 1024-dim mean-pool embedding. \n"
"⏳ *First call loads the model onto the GPU and may take a moment.*"
)
extract_btn = gr.Button("🔬 Extract Feature", variant="primary", size="lg")
extract_status = gr.HTML("")
# ────────────────────────────────────────────────────────────────
# Step 3: Prediction & Screening
# ────────────────────────────────────────────────────────────────
with gr.Group():
gr.Markdown("## Step 3 — Run Demo")
gr.Markdown(
"Predict LLPS probability for a specific condition, "
"or sweep one condition across its full range."
)
with gr.Tabs():
# ── Tab 1: Single-point prediction ───────────────────────────
with gr.Tab("🔮 Predict LLPS Probability"):
gr.Markdown(
"Set the environmental conditions with the sliders below, "
"then click **Predict** to obtain the LLPS probability."
)
# Primary conditions
with gr.Row():
p_temp = gr.Slider(0, 60, value=25.0, step=0.5,
label="Temperature (°C)")
p_conc = gr.Slider(0, 1000, value=100.0, step=5.0,
label="Concentration (µM)")
p_pH = gr.Slider(0, 14, value=7.3, step=0.1,
label="pH")
# Advanced conditions
with gr.Accordion("⚙️ Advanced Conditions (Salts & Crowding Agents)", open=False):
gr.Markdown("Default values represent a common physiological buffer (160 mM NaCl).")
with gr.Row():
p_nacl = gr.Slider(0, 2000, value=160.0, step=10.0, label="NaCl (mM)")
p_mgcl2 = gr.Slider(0, 50, value=0.0, step=1.0, label="MgCl₂ (mM)")
p_kcl = gr.Slider(0, 1000, value=0.0, step=10.0, label="KCl (mM)")
p_glyc = gr.Slider(0, 10, value=0.0, step=0.5, label="Glycerol (%)")
with gr.Row():
p_peg1 = gr.Slider(0, 50, value=0, step=1, label="PEG 300–1000 (%)")
p_peg2 = gr.Slider(0, 50, value=0, step=1, label="PEG 3k–6k (%)")
p_peg3 = gr.Slider(0, 50, value=0, step=1, label="PEG 8k–20k (%)")
with gr.Row():
p_ficoll = gr.Slider(0, 50, value=0, step=1, label="Ficoll (%)")
p_dext40 = gr.Slider(0, 50, value=0, step=1, label="Dextran ≤40 kDa (%)")
p_dext70 = gr.Slider(0, 50, value=0, step=1, label="Dextran ≥70 kDa (%)")
pred_btn = gr.Button("⚡ Predict LLPS Probability", variant="primary")
pred_plot = gr.Plot(label="Prediction Result")
pred_text = gr.Markdown("")
pred_btn.click(
fn=cb_predict,
inputs=[
feat_state,
p_temp, p_conc, p_pH,
p_nacl, p_mgcl2, p_kcl, p_glyc,
p_peg1, p_peg2, p_peg3, p_ficoll, p_dext40, p_dext70,
],
outputs=[pred_plot, pred_text],
)
# ── Tab 2: Condition Screening ────────────────────────────────
with gr.Tab("📈 Condition Screening"):
gr.Markdown(
"Select **one condition** to screen across its full physiological range. \n"
"The remaining conditions are held fixed at the values you specify below."
)
screen_radio = gr.Radio(
choices=["Temperature", "Concentration", "pH"],
value="Temperature",
label="Condition to Screen",
info="This condition will be swept across its full range; its slider value below is hidden.",
)
# Fixed-value sliders — JS hides the swept condition's column (no Gradio re-render)
with gr.Row():
with gr.Column(elem_id="s-temp-col"):
s_temp = gr.Slider(0, 60, value=25.0, step=0.5,
label="Temperature (°C) [fixed]")
with gr.Column(elem_id="s-conc-col"):
s_conc = gr.Slider(0, 1000, value=100.0, step=5.0,
label="Concentration (µM) [fixed]")
with gr.Column(elem_id="s-ph-col"):
s_pH = gr.Slider(0, 14, value=7.3, step=0.1,
label="pH [fixed]")
# Pure JS toggle — bypasses Gradio server update, so slider fills are preserved
screen_radio.change(
fn=None,
inputs=[screen_radio],
outputs=[],
js="""(screen_name) => {
const map = {Temperature: 's-temp-col', Concentration: 's-conc-col', pH: 's-ph-col'};
for (const [cond, id] of Object.entries(map)) {
const el = document.getElementById(id);
if (el) el.style.display = (cond === screen_name) ? 'none' : 'flex';
}
}""",
)
# Advanced conditions (always fixed during screening)
with gr.Accordion("⚙️ Advanced Conditions (Salts & Crowding Agents)", open=False):
gr.Markdown("Default values represent a common physiological buffer (160 mM NaCl).")
with gr.Row():
s_nacl = gr.Slider(0, 2000, value=160.0, step=10.0, label="NaCl (mM)")
s_mgcl2 = gr.Slider(0, 50, value=0.0, step=1.0, label="MgCl₂ (mM)")
s_kcl = gr.Slider(0, 1000, value=0.0, step=10.0, label="KCl (mM)")
s_glyc = gr.Slider(0, 10, value=0.0, step=0.5, label="Glycerol (%)")
with gr.Row():
s_peg1 = gr.Slider(0, 50, value=0, step=1, label="PEG 300–1000 (%)")
s_peg2 = gr.Slider(0, 50, value=0, step=1, label="PEG 3k–6k (%)")
s_peg3 = gr.Slider(0, 50, value=0, step=1, label="PEG 8k–20k (%)")
with gr.Row():
s_ficoll = gr.Slider(0, 50, value=0, step=1, label="Ficoll (%)")
s_dext40 = gr.Slider(0, 50, value=0, step=1, label="Dextran ≤40 kDa (%)")
s_dext70 = gr.Slider(0, 50, value=0, step=1, label="Dextran ≥70 kDa (%)")
s_smooth = gr.Slider(
1, 21, value=15, step=2,
label="Smoothing Window Size",
info="Odd window size for moving-average smoothing of the screening curve (1 = no smoothing).",
)
screen_btn = gr.Button("📊 Run Condition Screening", variant="primary")
screen_plot = gr.Plot(label="Screening Result")
screen_text = gr.Markdown("")
screen_btn.click(
fn=cb_screen,
inputs=[
feat_state, screen_radio,
s_temp, s_conc, s_pH,
s_nacl, s_mgcl2, s_kcl, s_glyc,
s_peg1, s_peg2, s_peg3, s_ficoll, s_dext40, s_dext70,
s_smooth,
],
outputs=[screen_plot, screen_text],
)
# Registered here (after Step 3 components exist) since cb_extract also
# clears the Predict/Screening panels so a re-extracted sequence can
# never leave a stale result from the previous sequence on screen.
extract_btn.click(
fn=cb_extract,
inputs=[seq_box],
outputs=[feat_state, extract_status, pred_plot, pred_text, screen_plot, screen_text],
)
# Selecting a built-in example runs extraction automatically (no manual
# Step-2 click needed) — cache-hit examples resolve near-instantly since
# cb_extract reads their pre-computed feature from assets/ instead of
# calling the T5 model. example_picker is None when no example has a
# cached feature yet (see AVAILABLE_EXAMPLES above).
if example_picker is not None:
example_picker.load_input_event.then(
fn=cb_extract,
inputs=[seq_box],
outputs=[feat_state, extract_status, pred_plot, pred_text, screen_plot, screen_text],
)
# ── Footer ────────────────────────────────────────────────────────────────
gr.Markdown("""
---
**Authors:** Jangwon Bae†, Minjun Kang†, Donghyuk Lee, Kuk-Jin Yoon*, Yongwon Jung*
**Paper:** [bioRxiv 2025.12.28.696755](https://doi.org/10.64898/2025.12.28.696755)
**GitHub:** [NearNiah/LLPSense](https://github.com/NearNiah/LLPSense)
""")
if __name__ == "__main__":
demo.launch(server_name="0.0.0.0", server_port=7860)