"""
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"
)