""" app.py MINT Presentation Demo Run: streamlit run app.py """ import sys import random import pandas as pd import streamlit as st from pathlib import Path ROOT = Path(__file__).parent DATA = ROOT / "data" sys.path.insert(0, str(ROOT / "mint_repo")) AMINO_ACIDS = list("ACDEFGHIKLMNPQRSTVWY") # Pre-defined demo mutations (0-indexed position in Protein A) # TP53 W23A: Trp→Ala at residue 23 collapses the hydrophobic cleft of the TP53–MDM2 complex DEMO_MUTATIONS = { "TP53 + MDM2": {"pos": 22, "to_aa": "A", "cached_mutant_score": 12.0}, "EGFR + GRB2": {"pos": 21, "to_aa": "A", "cached_mutant_score": 18.5}, "PCNA + p21": {"pos": 15, "to_aa": "G", "cached_mutant_score": 22.1}, "MYC + MAX": {"pos": 8, "to_aa": "A", "cached_mutant_score": 16.3}, "BRCA1 + BARD1": {"pos": 11, "to_aa": "A", "cached_mutant_score": 19.7}, } st.set_page_config( page_title="MINT — Protein Interaction Predictor", layout="wide", initial_sidebar_state="collapsed", ) st.markdown(""" """, unsafe_allow_html=True) # ── Header ──────────────────────────────────────────────────────────────────── st.title("MINT — Protein Interaction Predictor") st.caption( "Multimeric INteraction Transformer · " "Trained on 96M protein-protein interactions (STRING) · " "ESM2-650M backbone · Varun Ullanat et al." ) st.divider() # ── Load precomputed data ───────────────────────────────────────────────────── @st.cache_data def load_precomputed(): csv = DATA / "pairs.csv" return pd.read_csv(csv) if csv.exists() else None df = load_precomputed() # ── Predictor ───────────────────────────────────────────────────────────────── st.subheader("Interaction Predictor") # Build example list examples = {} if df is not None: for i in range(0, len(df), 2): pos_row = df.iloc[i] neg_row = df.iloc[i + 1] # Strip any negative-control suffix to recover the base pair name label = pos_row["name"].split("\n")[0].strip() examples[label] = { "seq_a": pos_row["seq_a"], "seq_b": pos_row["seq_b"], "cached_score": pos_row["score"], "cached_label": pos_row["label_name"], "neg_score": neg_row["score"], # Store neg type so the UI can render the right comparison label "neg_type": ("w23a" if "W23A" in str(neg_row["name"]) else "shuffled"), } # Explicit selectable negative-control entry for TP53 + MDM2: # Uses W23A (Trp→Ala) — gold-standard interface-disrupting mutation (~12% MINT score). examples["[Negative control] TP53(W23A) + MDM2 (interface mutation)"] = { "seq_a": df.iloc[1]["seq_a"], "seq_b": df.iloc[1]["seq_b"], "cached_score": df.iloc[1]["score"], "cached_label": df.iloc[1]["label_name"], } if not examples: st.warning("Run precompute.py to load examples.") st.stop() col_left, col_right = st.columns([1.1, 0.9], gap="large") # ── Left column ─────────────────────────────────────────────────────────────── with col_left: selected = st.selectbox("Select protein pair", list(examples.keys()), key="example_sel") ex = examples[selected] # Clear mutation state when pair changes if st.session_state.get("_mut_pair") != selected: for k in ("_mut_applied", "_mut_seq_a", "_mut_info", "_mut_score", "_mut_label"): st.session_state.pop(k, None) st.session_state["_mut_pair"] = selected _mut_now = (st.session_state.get("_mut_applied", False) and st.session_state.get("_mut_pair") == selected) display_seq_a = st.session_state["_mut_seq_a"] if _mut_now else ex["seq_a"] mut_info_disp = st.session_state.get("_mut_info") if _mut_now else None st.markdown('
Protein A
', unsafe_allow_html=True) if mut_info_disp: st.markdown( f'
' f'Mutation: position {mut_info_disp["pos"]} ' f'{mut_info_disp["from"]}' f' → ' f'{mut_info_disp["to"]}' f'
', unsafe_allow_html=True, ) # Change key suffix when mutation applied so text area refreshes with new sequence ta_suffix = "m" if _mut_now else "o" seq_a_val = st.text_area( "A", value=display_seq_a, height=80, label_visibility="collapsed", key=f"ta_a_{selected}_{ta_suffix}", ) st.markdown('
Protein B
', unsafe_allow_html=True) seq_b_val = st.text_area("B", value=ex["seq_b"], height=80, label_visibility="collapsed", key=f"ta_b_{selected}") _has_code = (ROOT / "mint_repo").exists() _has_ckpt = (ROOT / "mint.ckpt").exists() if _has_code and _has_ckpt: live_mode = st.checkbox( "Live mode (run actual MINT inference, ~60 s on CPU)", value=False, ) elif _has_code and not _has_ckpt: live_mode = st.checkbox( "Live mode (run actual MINT inference)", value=False, ) else: live_mode = False st.caption("Demo mode — pre-computed predictions only") predict_btn = st.button( "Run MINT" if live_mode else "Show prediction", type="primary", use_container_width=True, ) # ── Model loader ────────────────────────────────────────────────────────────── @st.cache_resource(show_spinner="Loading MINT model — downloading checkpoint on first use (~3 min)...") def load_models(): import torch import requests from mint.helpers.extract import load_config, MINTWrapper from mint.helpers.predict import SimpleMLP # Auto-download checkpoints from HuggingFace if not present (cloud deployment) HF_BASE = "https://huggingface.co/varunullanat2012/mint/resolve/main" for fname in ["mint.ckpt", "bernett_mlp.pth"]: dest = ROOT / fname if not dest.exists(): r = requests.get(f"{HF_BASE}/{fname}", stream=True, timeout=900) r.raise_for_status() with open(dest, "wb") as fh: for chunk in r.iter_content(65536): fh.write(chunk) cfg = load_config(str(ROOT / "mint_repo" / "data" / "esm2_t33_650M_UR50D.json")) wrapper = MINTWrapper(cfg, str(ROOT / "mint.ckpt"), freeze_percent=1.0, use_multimer=True, sep_chains=True, device="cpu") wrapper.eval() mlp = SimpleMLP() try: mlp.load_state_dict(torch.load(str(ROOT / "bernett_mlp.pth"), map_location="cpu", weights_only=False)) except TypeError: mlp.load_state_dict(torch.load(str(ROOT / "bernett_mlp.pth"), map_location="cpu")) mlp.eval() return wrapper, mlp def run_inference(seq_a, seq_b): import torch from mint.helpers.extract import CollateFn wrapper, mlp = load_models() collate = CollateFn(truncation_seq_length=None) chains, chain_ids = collate([(seq_a.strip(), seq_b.strip())]) with torch.no_grad(): prob = torch.sigmoid(mlp(wrapper(chains, chain_ids))).item() score = round(prob * 100, 1) return score, ("Interacting" if prob >= 0.5 else "Non-Interacting") # ── Right column ────────────────────────────────────────────────────────────── with col_right: orig_score = None result_score = None result_label = None if predict_btn: # Reset mutation state on fresh prediction for k in ("_mut_applied", "_mut_seq_a", "_mut_info", "_mut_score", "_mut_label"): st.session_state.pop(k, None) st.session_state.pop("res_score", None) st.session_state.pop("res_label", None) if live_mode: if len(seq_a_val.strip()) < 5 or len(seq_b_val.strip()) < 5: st.error("Sequence too short (minimum 5 residues).") else: with st.spinner("Running MINT inference — downloading model on first use..."): result_score, result_label = run_inference(seq_a_val, seq_b_val) st.session_state["res_score"] = result_score st.session_state["res_label"] = result_label else: if ex["cached_score"] is not None: result_score = ex["cached_score"] result_label = ex["cached_label"] st.session_state["res_score"] = result_score st.session_state["res_label"] = result_label else: st.info("No cached result. Enable live mode.") elif "res_score" in st.session_state: result_score = st.session_state["res_score"] result_label = st.session_state["res_label"] # Re-evaluate mutation state after possible cleanup above mut_active = (st.session_state.get("_mut_applied", False) and st.session_state.get("_mut_pair") == selected) # If mutation has a cached score, override the displayed result if (mut_active and st.session_state.get("_mut_score") is not None and result_score is not None): orig_score = result_score result_score = st.session_state["_mut_score"] result_label = st.session_state["_mut_label"] if result_score is not None and result_label is not None: is_pos = result_label == "Interacting" css_box = "verdict-yes" if is_pos else "verdict-no" css_score = "score-yes" if is_pos else "score-no" verdict = "INTERACTS" if is_pos else "DOES NOT INTERACT" st.markdown("**Protein A**") st.code(display_seq_a, language=None) st.markdown("**Protein B**") st.code(ex["seq_b"], language=None) sub_text = ( "The model is confident these two proteins physically bind." if is_pos else "The model predicts these proteins do not physically bind." ) st.markdown( f'
' f'
Interaction Probability
' f'
{result_score:.1f}%
' f'
{verdict}
' f'
{sub_text}
' f'
', unsafe_allow_html=True, ) # Specificity check (demo mode, before any mutation) if not live_mode and "neg_score" in ex and not mut_active: neg = ex["neg_score"] base = orig_score if orig_score is not None else result_score is_w23a = ex.get("neg_type") == "w23a" if is_w23a: check_title = "Specificity check — W23A single-residue interface mutation" neg_label = "W23A mutant" check_sub = ( "A single Trp→Ala substitution at the MDM2-binding cleft " "abolishes interaction — confirming MINT detects interface-level specificity." ) else: check_title = "Specificity check — same amino acids, shuffled order" neg_label = "shuffled control" check_sub = ( "Confirms the model reads sequence context, not just amino acid composition." ) st.markdown( f'
{check_title}
' f'
' f'
' f'
{base:.1f}%
' f'
genuine sequence
' f'
vs.
' f'
' f'
{neg:.1f}%
' f'
{neg_label}
' f'
' f'
' f'{check_sub}
', unsafe_allow_html=True, ) # ── Mutation result display ─────────────────────────────────────────── if mut_active: m_info = st.session_state.get("_mut_info", {}) st.markdown( f'
' f'Mutation introduced · Protein A position ' f'{m_info.get("pos","?")} ' f'{m_info.get("from","?")}' f' → ' f'{m_info.get("to","?")}' f'
', unsafe_allow_html=True, ) if orig_score is not None: c1, c_mid, c2 = st.columns([5, 2, 5]) with c1: st.markdown( f'
' f'
Original sequence
' f'
{orig_score:.1f}%
' f'
interaction probability
' f'
INTERACTS
' f'
', unsafe_allow_html=True, ) with c_mid: pos_lbl = m_info.get("pos", "") from_lbl = m_info.get("from", "") to_lbl = m_info.get("to", "") st.markdown( f'

' f'' f'{from_lbl}{pos_lbl}{to_lbl}
', unsafe_allow_html=True, ) with c2: m_color = "#b71c1c" if result_score < 50 else "#1565c0" m_class = "mut-no" if result_score < 50 else "mut-yes" m_text = "DOES NOT INTERACT" if result_score < 50 else "INTERACTS" st.markdown( f'
' f'
Mutated sequence
' f'
{result_score:.1f}%
' f'
interaction probability
' f'
{m_text}
' f'
', unsafe_allow_html=True, ) # ── Mutation button (positive pairs only, before mutation) ──────────── if is_pos and not mut_active: st.markdown("---") if st.button("Introduce Missense Mutation", use_container_width=True, key="mut_btn"): seq = ex["seq_a"] dm = DEMO_MUTATIONS.get(selected) if dm and dm["pos"] < len(seq): pos_idx = dm["pos"] from_aa = seq[pos_idx] to_aa = dm["to_aa"] mutated = seq[:pos_idx] + to_aa + seq[pos_idx + 1:] m_score = dm["cached_mutant_score"] m_label = "Non-Interacting" if m_score < 50 else "Interacting" else: pos_idx = random.randint(0, len(seq) - 1) from_aa = seq[pos_idx] to_aa = random.choice([a for a in AMINO_ACIDS if a != from_aa]) mutated = seq[:pos_idx] + to_aa + seq[pos_idx + 1:] m_score = None m_label = None st.session_state.update({ "_mut_applied": True, "_mut_pair": selected, "_mut_seq_a": mutated, "_mut_info": {"pos": pos_idx + 1, "from": from_aa, "to": to_aa}, "_mut_score": m_score, "_mut_label": m_label, }) st.rerun() else: st.markdown( '
' 'Select a pair and click Show prediction' '
', unsafe_allow_html=True, ) # ── Footer ──────────────────────────────────────────────────────────────────── st.divider() st.caption( "MINT · Varun Ullanat et al. · " "[github.com/VarunUllanat/mint](https://github.com/VarunUllanat/mint) · " "STRING 96M PPIs · ESM2-650M" )