THP-NanoTarget / app.py
ashaddams's picture
Update app.py
4104958 verified
Raw
History Blame Contribute Delete
174 kB
# ============================================================
# DASHBOARD: THP-NanoTarget
# ============================================================
import os
import re
import json
import pickle
import random
import warnings
from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
try:
import plotly.graph_objects as go
PLOTLY_AVAILABLE = True
except Exception as e:
print("[WARN] Plotly could not be imported. Interactive PPIN graph will be disabled:", e)
go = None
PLOTLY_AVAILABLE = False
try:
import networkx as nx
NETWORKX_AVAILABLE = True
except Exception as e:
print("[WARN] NetworkX could not be imported. PPIN graph will use circular layout:", e)
nx = None
NETWORKX_AVAILABLE = False
from pandas.errors import EmptyDataError, ParserError
warnings.filterwarnings("ignore")
# ============================================================
# 0. Install/load dependencies
# ============================================================
import gradio as gr
# RDKit is installed through requirements.txt on Hugging Face Spaces.
try:
from rdkit import Chem, DataStructs, RDLogger
from rdkit.Chem import AllChem, Descriptors, Lipinski, Crippen, rdMolDescriptors
from rdkit.Chem.Draw import rdMolDraw2D
try:
from rdkit.Chem import rdFingerprintGenerator
MORGAN_GENERATOR_AVAILABLE = True
except Exception:
rdFingerprintGenerator = None
MORGAN_GENERATOR_AVAILABLE = False
RDLogger.DisableLog("rdApp.*")
RDKIT_AVAILABLE = True
except Exception as e:
print("[WARN] RDKit could not be imported. Structure rendering and cheminformatics tools will be disabled:", e)
RDKIT_AVAILABLE = False
MORGAN_GENERATOR_AVAILABLE = False
rdFingerprintGenerator = None
print("[INFO] Gradio version:", gr.__version__)
print("[INFO] RDKit available:", RDKIT_AVAILABLE)
print("[INFO] MorganGenerator available:", MORGAN_GENERATOR_AVAILABLE)
# ============================================================
# 1. Project paths — FINAL V2 dashboard wiring
# ============================================================
# Works both in Hugging Face Spaces / normal Python scripts and in Colab notebooks.
try:
APP_ROOT = Path(__file__).resolve().parent
except NameError:
APP_ROOT = Path.cwd()
# Prefer a bundled project folder for Hugging Face Spaces, then Colab.
BASE_CANDIDATES = [
APP_ROOT / "tumor_homing_peptide_ai",
Path("/content/tumor_homing_peptide_ai"),
Path.cwd() / "tumor_homing_peptide_ai",
Path("/mnt/data/tumor_homing_peptide_ai"),
]
BASE = next((p for p in BASE_CANDIDATES if p.exists()), BASE_CANDIDATES[0])
print("[INFO] Project BASE:", BASE)
def pick_existing_path(paths):
"""
Return the first existing non-empty path; otherwise return the first path.
This keeps the dashboard compatible with final-v2 outputs while retaining
a graceful fallback to older local assets if a Space was built from them.
"""
paths = [Path(p) for p in paths]
for p in paths:
try:
if p.exists() and p.stat().st_size > 0:
return p
except Exception:
pass
return paths[0]
PROC = BASE / "data_processed"
# Final-v2 table folders
TAB_OOF = BASE / "tables_oof_final_v2"
TAB_REC = BASE / "tables_recommendation_final_v2"
TAB_TARGET = BASE / "tables_targetability_final_v2"
TAB_ENSEMBLE = BASE / "tables_ensemble_final_v2"
TAB_VAL1 = BASE / "tables_validation_level1_final_v2"
TAB_VAL2 = BASE / "tables_validation_level2_final_v2"
TAB_VAL2_STRICT = BASE / "tables_validation_level2_strict_final_v2"
TAB_FAM = BASE / "tables_validation_family_holdout_final_v2"
TAB_VAL3 = BASE / "tables_validation_level3_final_v2"
# Validation-suite outputs from Cell 20 final-v2
TAB_VALIDATION_SUITE = BASE / "validation_suite_final_v2" / "tables"
TAB_EXTERNAL_STRICT = BASE / "external_validation_strict_final_v2" / "tables"
# Final-v2 model folders
MODEL_DIR = BASE / "models_final_v2"
MODEL_ENSEMBLE_DIR = BASE / "models_ensemble_final_v2"
OOF_DISCOVERY_MODEL_PATH = pick_existing_path([
MODEL_DIR / "OOF_TFIDF_kmer_LogisticRegression_final_v2_full_primary.pkl",
MODEL_DIR / "OOF_TFIDF_kmer_LogisticRegression_final_full_primary_final_v2.pkl",
MODEL_DIR / "OOF_TFIDF_kmer_LogisticRegression_final_full_primary.pkl",
BASE / "models" / "OOF_TFIDF_kmer_LogisticRegression_final_full_primary.pkl",
])
OOF_CONSERVATIVE_MODEL_PATH = pick_existing_path([
MODEL_DIR / "OOF_TFIDF_kmer_LinearSVM_calibrated_final_v2_full_primary.pkl",
MODEL_DIR / "OOF_TFIDF_kmer_LinearSVM_calibrated_final_full_primary_final_v2.pkl",
MODEL_DIR / "OOF_TFIDF_kmer_LinearSVM_calibrated_final_full_primary.pkl",
BASE / "models" / "OOF_TFIDF_kmer_LinearSVM_calibrated_final_full_primary.pkl",
])
ENSEMBLE_MODEL_PATHS = {
"tfidf_logistic_2_4": pick_existing_path([
MODEL_ENSEMBLE_DIR / "tfidf_logistic_2_4_final_full_primary_final_v2.pkl",
MODEL_ENSEMBLE_DIR / "tfidf_logistic_2_4_final_v2.pkl",
MODEL_ENSEMBLE_DIR / "tfidf_logistic_2_4_final.pkl",
BASE / "models_ensemble" / "tfidf_logistic_2_4_final.pkl",
]),
"count_logistic_2_4": pick_existing_path([
MODEL_ENSEMBLE_DIR / "count_logistic_2_4_final_full_primary_final_v2.pkl",
MODEL_ENSEMBLE_DIR / "count_logistic_2_4_final_v2.pkl",
MODEL_ENSEMBLE_DIR / "count_logistic_2_4_final.pkl",
BASE / "models_ensemble" / "count_logistic_2_4_final.pkl",
]),
"tfidf_logistic_3_5": pick_existing_path([
MODEL_ENSEMBLE_DIR / "tfidf_logistic_3_5_final_full_primary_final_v2.pkl",
MODEL_ENSEMBLE_DIR / "tfidf_logistic_3_5_final_v2.pkl",
MODEL_ENSEMBLE_DIR / "tfidf_logistic_3_5_final.pkl",
BASE / "models_ensemble" / "tfidf_logistic_3_5_final.pkl",
]),
"tfidf_svm_2_4": pick_existing_path([
MODEL_ENSEMBLE_DIR / "tfidf_svm_2_4_final_full_primary_final_v2.pkl",
MODEL_ENSEMBLE_DIR / "tfidf_svm_2_4_final_v2.pkl",
MODEL_ENSEMBLE_DIR / "tfidf_svm_2_4_final.pkl",
BASE / "models_ensemble" / "tfidf_svm_2_4_final.pkl",
]),
}
OOF_RECOMMENDATION_PATH = pick_existing_path([
TAB_REC / "peptide_receptor_cancer_recommendation_matrix_oof_final_v2.csv",
BASE / "tables_recommendation" / "peptide_receptor_cancer_recommendation_matrix_oof.csv",
])
ENSEMBLE_RECOMMENDATION_PATH = pick_existing_path([
TAB_REC / "peptide_receptor_cancer_recommendation_matrix_ensemble_final_v2.csv",
BASE / "tables_recommendation" / "peptide_receptor_cancer_recommendation_matrix_ensemble.csv",
])
OOF_LIGAND_PATH = pick_existing_path([
TAB_REC / "peptide_ligand_suitability_scores_with_oof_final_v2.csv",
BASE / "tables_recommendation" / "peptide_ligand_suitability_scores_with_oof.csv",
])
ENSEMBLE_LIGAND_PATH = pick_existing_path([
TAB_REC / "peptide_ligand_suitability_scores_with_ensemble_final_v2.csv",
TAB_REC / "peptide_ligand_suitability_scores_with_oof_final_v2.csv",
TAB_REC / "peptide_ligand_suitability_scores_final_v2.csv",
BASE / "tables_recommendation" / "peptide_ligand_suitability_scores_with_ensemble.csv",
BASE / "tables_recommendation" / "peptide_ligand_suitability_scores_with_oof.csv",
])
TARGETABILITY_PATH = pick_existing_path([
TAB_TARGET / "receptor_cancer_targetability_scores_final_v2.csv",
BASE / "tables_targetability" / "receptor_cancer_targetability_scores.csv",
])
OOF_PERFORMANCE_PATH = pick_existing_path([
TAB_OOF / "cluster_oof_performance_kmer_final_v2.csv",
BASE / "tables_oof" / "cluster_oof_performance_kmer.csv",
])
ENSEMBLE_PERFORMANCE_PATH = pick_existing_path([
TAB_ENSEMBLE / "ensemble_oof_performance_final_v2.csv",
BASE / "tables_ensemble" / "ensemble_oof_performance.csv",
])
OOF_CHALLENGE_PATH = pick_existing_path([
TAB_OOF / "cancerppd_predictions_final_kmer_final_v2.csv",
BASE / "tables_oof" / "cancerppd_predictions_final_kmer.csv",
])
ENSEMBLE_CHALLENGE_SUMMARY_PATH = pick_existing_path([
TAB_ENSEMBLE / "ensemble_cancerppd_challenge_summary_final_v2.csv",
BASE / "tables_ensemble" / "ensemble_cancerppd_challenge_summary.csv",
])
# Cheminformatics extension outputs generated by Cell 17 final-v2.
CHEMI_DIR = BASE / "cheminformatics_extension_final_v2"
CHEMI_TAB = CHEMI_DIR / "tables"
CHEMI_FP = CHEMI_DIR / "fingerprints"
CHEMI_SIM = CHEMI_DIR / "similarity_search"
CHEMI_DESCRIPTOR_PATH = pick_existing_path([
CHEMI_TAB / "cheminformatics_rdkit_descriptors_final_v2.csv",
BASE / "cheminformatics_extension" / "tables" / "cheminformatics_rdkit_descriptors.csv",
])
CHEMI_DESCRIPTOR_SCORE_PATH = pick_existing_path([
CHEMI_TAB / "cheminformatics_descriptors_with_model_scores_final_v2.csv",
BASE / "cheminformatics_extension" / "tables" / "cheminformatics_descriptors_with_model_scores.csv",
])
CHEMI_EMBEDDING_PATH = pick_existing_path([
CHEMI_TAB / "chemical_space_embedding_final_v2.csv",
BASE / "cheminformatics_extension" / "tables" / "chemical_space_embedding.csv",
])
CHEMI_MOTIF_BASELINE_PATH = pick_existing_path([
CHEMI_TAB / "motif_rule_baseline_vs_ml_final_v2.csv",
CHEMI_TAB / "motif_rule_baseline_vs_ml_models_final_v2.csv",
BASE / "cheminformatics_extension" / "tables" / "motif_rule_baseline_vs_ml_models.csv",
])
CHEMI_SIM_META_PATH = pick_existing_path([
CHEMI_SIM / "similarity_index_metadata_final_v2.csv",
BASE / "cheminformatics_extension" / "similarity_search" / "similarity_index_metadata.csv",
])
CHEMI_SIM_FP_PATH = pick_existing_path([
CHEMI_SIM / "similarity_index_morgan_ecfp_final_v2.npy",
BASE / "cheminformatics_extension" / "similarity_search" / "similarity_index_morgan_ecfp.npy",
])
# Protein-interaction / receptor-network resources from the HPA+STRING repair.
RECEPTOR_PRIORITY_PATH = pick_existing_path([
PROC / "final_receptor_prioritization_table_FINAL_HPA_STRING.csv",
PROC / "final_receptor_prioritization_table.csv",
])
STRING_PPI_SUMMARY_PATH = pick_existing_path([
PROC / "string_receptor_physical_ppi_summary.csv",
PROC / "string_receptor_ppi_summary.csv",
])
STRING_PPI_EDGES_PATH = pick_existing_path([
PROC / "string_receptor_physical_ppi_edges.csv",
PROC / "string_receptor_ppi_edges.csv",
])
STRING_PPI_STATUS_PATH = pick_existing_path([
BASE / "manifests" / "string_ppi_receptor_network_status.json",
BASE / "manifests" / "final_hpa_string_receptor_prioritization_status.json",
])
# ============================================================
# 2. Pickle-safe k-mer analyzers
# ============================================================
def peptide_kmer_analyzer(seq):
seq = str(seq)
toks = []
for k in range(2, 5):
toks.extend([seq[i:i+k] for i in range(0, len(seq) - k + 1)])
return toks
def peptide_kmer_analyzer_2_4(seq):
seq = str(seq)
toks = []
for k in range(2, 5):
toks.extend([seq[i:i+k] for i in range(0, len(seq) - k + 1)])
return toks
def peptide_kmer_analyzer_3_5(seq):
seq = str(seq)
toks = []
for k in range(3, 6):
toks.extend([seq[i:i+k] for i in range(0, len(seq) - k + 1)])
return toks
# ============================================================
# 3. Robust utility functions
# ============================================================
def normalize_columns(df):
df = df.copy()
df.columns = [re.sub(r"\s+", "_", str(c).strip()) for c in df.columns]
return df
def safe_read_csv(path, default=None):
path = Path(path)
if default is None:
default = pd.DataFrame()
if not path.exists():
print(f"[INFO] Optional file missing: {path}")
return default.copy()
try:
if path.stat().st_size == 0:
print(f"[WARN] Empty file skipped: {path}")
return default.copy()
df = pd.read_csv(path)
if df is None or df.shape[1] == 0:
print(f"[WARN] No columns found in file: {path}")
return default.copy()
return normalize_columns(df)
except EmptyDataError:
print(f"[WARN] EmptyDataError skipped file: {path}")
return default.copy()
except ParserError as e:
print(f"[WARN] ParserError reading file: {path}")
print("[WARN]", e)
return default.copy()
except Exception as e:
print(f"[WARN] Could not read file: {path}")
print("[WARN]", e)
return default.copy()
def safe_read_first(paths, default=None):
"""
Read the first existing, non-empty CSV from a list of candidate paths.
Useful because the final-v2 notebook may write the same table either into
a module-specific folder or into validation_suite_final_v2/tables.
"""
if default is None:
default = pd.DataFrame()
for p in paths:
p = Path(p)
if p.exists() and p.stat().st_size > 0:
return safe_read_csv(p, default=default)
if paths:
print(f"[INFO] None of the optional candidate files were found. First expected: {paths[0]}")
return default.copy()
def ensure_sequence_column(df, df_name="dataframe"):
df = normalize_columns(df)
if "sequence" in df.columns:
return df
candidates = []
for c in df.columns:
cl = c.lower()
if cl in ["sequence", "seq", "peptide", "peptides", "peptide_sequence"]:
candidates.append(c)
elif "sequence" in cl and "hash" not in cl:
candidates.append(c)
if not candidates:
print(f"[DEBUG] Columns in {df_name}: {list(df.columns)}")
raise KeyError(f"No sequence-like column found in {df_name}.")
chosen = candidates[0]
df = df.rename(columns={chosen: "sequence"})
print(f"[OK] {df_name}: renamed '{chosen}' to 'sequence'")
return df
def safe_read_uploaded_file(file_obj):
if file_obj is None:
raise ValueError("No file uploaded.")
if isinstance(file_obj, str):
path = file_obj
elif hasattr(file_obj, "name"):
path = file_obj.name
elif isinstance(file_obj, dict) and "name" in file_obj:
path = file_obj["name"]
elif isinstance(file_obj, dict) and "path" in file_obj:
path = file_obj["path"]
else:
path = str(file_obj)
path = str(path)
if path.lower().endswith(".csv"):
return pd.read_csv(path)
if path.lower().endswith((".xlsx", ".xls")):
return pd.read_excel(path)
try:
return pd.read_csv(path)
except Exception:
return pd.read_excel(path)
def safe_round_table(df, decimals=4):
if df is None:
return pd.DataFrame()
if not isinstance(df, pd.DataFrame):
return pd.DataFrame(df)
df = df.copy()
if df.empty:
return df
for c in df.columns:
if pd.api.types.is_numeric_dtype(df[c]):
df[c] = df[c].round(decimals)
return df
def load_pickle_if_exists(path):
path = Path(path)
if not path.exists():
return None
with open(path, "rb") as f:
return pickle.load(f)
def dark_empty_plot(message="No data available"):
fig, ax = plt.subplots(figsize=(7, 4))
fig.patch.set_facecolor("#0b1120")
ax.set_facecolor("#111827")
ax.text(
0.5,
0.5,
message,
ha="center",
va="center",
fontsize=13,
color="#dbeafe",
wrap=True,
)
ax.set_xticks([])
ax.set_yticks([])
for spine in ax.spines.values():
spine.set_color("#334155")
fig.tight_layout()
return fig
def apply_dark_axis(ax, title=None, xlabel=None, ylabel=None):
ax.set_facecolor("#111827")
if title:
ax.set_title(title, color="#bfdbfe", fontsize=13, fontweight="bold")
if xlabel:
ax.set_xlabel(xlabel, color="#dbeafe")
if ylabel:
ax.set_ylabel(ylabel, color="#dbeafe")
ax.tick_params(colors="#cbd5e1")
for spine in ax.spines.values():
spine.set_color("#475569")
ax.grid(True, alpha=0.18, color="#64748b")
if ax.get_legend() is not None:
legend = ax.get_legend()
legend.get_frame().set_facecolor("#1e293b")
legend.get_frame().set_edgecolor("#475569")
for text in legend.get_texts():
text.set_color("#dbeafe")
return ax
def finalize_dark_fig(fig):
fig.patch.set_facecolor("#0b1120")
fig.tight_layout()
return fig
# ============================================================
# 4. Load models
# ============================================================
ensemble_models = {}
for name, path in ENSEMBLE_MODEL_PATHS.items():
model = load_pickle_if_exists(path)
if model is not None:
ensemble_models[name] = model
USE_ENSEMBLE = len(ensemble_models) == len(ENSEMBLE_MODEL_PATHS)
if USE_ENSEMBLE:
print("[OK] Loaded ensemble base models:")
for k in ensemble_models:
print(" -", k)
else:
print("[WARN] Complete ensemble model set not found. Falling back to old OOF models.")
print("[INFO] Found ensemble models:", list(ensemble_models.keys()))
old_discovery_model = load_pickle_if_exists(OOF_DISCOVERY_MODEL_PATH)
old_conservative_model = load_pickle_if_exists(OOF_CONSERVATIVE_MODEL_PATH)
if not USE_ENSEMBLE:
if old_discovery_model is None or old_conservative_model is None:
raise FileNotFoundError(
"Neither complete ensemble models nor old OOF models were found. "
"Run the model/ensemble cells first."
)
def predict_ensemble_probs(seq):
if USE_ENSEMBLE:
p = {}
for name, model in ensemble_models.items():
p[name] = float(model.predict_proba([seq])[:, 1][0])
discovery = (
0.35 * p["tfidf_logistic_2_4"]
+ 0.30 * p["count_logistic_2_4"]
+ 0.25 * p["tfidf_logistic_3_5"]
+ 0.10 * p["tfidf_svm_2_4"]
)
conservative = 0.50 * discovery + 0.50 * p["tfidf_svm_2_4"]
p["ensemble_discovery_weighted"] = float(discovery)
p["ensemble_conservative_gated"] = float(conservative)
return float(discovery), float(conservative), p
discovery = float(old_discovery_model.predict_proba([seq])[:, 1][0])
conservative = float(old_conservative_model.predict_proba([seq])[:, 1][0])
p = {
"OOF_TFIDF_kmer_LogisticRegression": discovery,
"OOF_TFIDF_kmer_LinearSVM_calibrated": conservative,
}
return discovery, conservative, p
# ============================================================
# 5. Load main tables — FINAL V2 robust mode
# ============================================================
if ENSEMBLE_RECOMMENDATION_PATH.exists():
RECOMMENDATION_PATH = ENSEMBLE_RECOMMENDATION_PATH
recommendation_mode = "ensemble_final_v2"
elif OOF_RECOMMENDATION_PATH.exists():
RECOMMENDATION_PATH = OOF_RECOMMENDATION_PATH
recommendation_mode = "oof_final_v2"
else:
RECOMMENDATION_PATH = ENSEMBLE_RECOMMENDATION_PATH
recommendation_mode = "missing_recommendation_matrix"
if ENSEMBLE_LIGAND_PATH.exists():
LIGAND_PATH = ENSEMBLE_LIGAND_PATH
else:
LIGAND_PATH = OOF_LIGAND_PATH
if ENSEMBLE_PERFORMANCE_PATH.exists():
PERFORMANCE_PATH = ENSEMBLE_PERFORMANCE_PATH
performance_mode = "ensemble_final_v2"
else:
PERFORMANCE_PATH = OOF_PERFORMANCE_PATH
performance_mode = "oof_final_v2"
if ENSEMBLE_CHALLENGE_SUMMARY_PATH.exists():
CHALLENGE_SUMMARY_PATH = ENSEMBLE_CHALLENGE_SUMMARY_PATH
challenge_mode = "ensemble_summary_final_v2"
else:
CHALLENGE_SUMMARY_PATH = OOF_CHALLENGE_PATH
challenge_mode = "oof_predictions_final_v2"
# Only the targetability atlas is truly required for atlas mode.
# Recommendation rows may be empty in this project because peptide-level
# receptor metadata are sparse. Do not fail the dashboard for that reason.
if not TARGETABILITY_PATH.exists():
raise FileNotFoundError(
"Missing targetability atlas. Expected final-v2 file:\n"
f"{TAB_TARGET / 'receptor_cancer_targetability_scores_final_v2.csv'}\n"
"Run the targetability/receptor atlas cells first."
)
recommendation_df = safe_read_csv(RECOMMENDATION_PATH)
if recommendation_df.empty:
recommendation_df = pd.DataFrame(columns=[
"sequence", "matched_receptor_gene", "receptor", "cancer_type",
"ensemble_preferred_prob", "oof_prob_discovery_model",
"recommendation_score_ensemble", "recommendation_score_oof",
"ligand_suitability_score", "targetability_score_0_100",
])
else:
recommendation_df = ensure_sequence_column(recommendation_df, "recommendation_df")
ligand_df = safe_read_csv(LIGAND_PATH)
if ligand_df.empty:
ligand_df = pd.DataFrame(columns=["sequence", "ligand_suitability_score", "motif_annotation"])
else:
ligand_df = ensure_sequence_column(ligand_df, "ligand_df")
target_df = normalize_columns(pd.read_csv(TARGETABILITY_PATH))
performance_df = safe_read_csv(PERFORMANCE_PATH)
challenge_df = safe_read_csv(CHALLENGE_SUMMARY_PATH)
# Load protein-interaction / receptor-network resources.
receptor_priority_df = safe_read_csv(RECEPTOR_PRIORITY_PATH)
string_ppi_summary_df = safe_read_csv(STRING_PPI_SUMMARY_PATH)
string_ppi_edges_df = safe_read_csv(STRING_PPI_EDGES_PATH)
string_ppi_status = {}
if STRING_PPI_STATUS_PATH.exists():
try:
with open(STRING_PPI_STATUS_PATH, "r") as f:
string_ppi_status = json.load(f)
except Exception as e:
print("[WARN] Could not load STRING PPI status JSON:", e)
string_ppi_status = {}
print("[OK] Dashboard assets loaded.")
print("[OK] Scoring mode:", "ensemble" if USE_ENSEMBLE else "old_oof")
print("[OK] Recommendation mode:", recommendation_mode)
print("[OK] Performance mode:", performance_mode)
print("[OK] Challenge mode:", challenge_mode)
print("[OK] Recommendation rows:", recommendation_df.shape)
print("[OK] Ligand rows:", ligand_df.shape)
print("[OK] Targetability rows:", target_df.shape)
print("[OK] Receptor priority rows:", receptor_priority_df.shape)
print("[OK] STRING PPI summary rows:", string_ppi_summary_df.shape)
print("[OK] STRING PPI edge rows:", string_ppi_edges_df.shape)
if "gene_symbol" in target_df.columns:
print("[OK] Targetability receptors:", sorted(target_df["gene_symbol"].dropna().astype(str).unique().tolist()))
elif "gene" in target_df.columns:
print("[OK] Targetability receptors:", sorted(target_df["gene"].dropna().astype(str).unique().tolist()))
print("[OK] Performance rows:", performance_df.shape)
print("[OK] Challenge rows:", challenge_df.shape)
# ============================================================
# 6. Load validation tables — FINAL V2 robust mode
# ============================================================
val1_perm_summary = safe_read_first([
TAB_VAL1 / "label_permutation_summary_final_v2.csv",
TAB_VALIDATION_SUITE / "label_permutation_summary_final_v2.csv",
])
val1_perm_raw = safe_read_first([
TAB_VAL1 / "label_permutation_control_final_v2.csv",
TAB_VALIDATION_SUITE / "label_permutation_control_final_v2.csv",
])
val1_motif_ablation = safe_read_first([
TAB_VAL1 / "motif_ablation_summary_final_v2.csv",
TAB_VALIDATION_SUITE / "motif_ablation_validation_summary_final_v2.csv",
TAB_VALIDATION_SUITE / "motif_ablation_summary_final_v2.csv",
])
val1_motif_ablation_raw = safe_read_first([
TAB_VAL1 / "motif_ablation_test_final_v2.csv",
TAB_VALIDATION_SUITE / "motif_ablation_validation_final_v2.csv",
TAB_VALIDATION_SUITE / "motif_ablation_test_final_v2.csv",
])
val1_length = safe_read_first([
TAB_VAL1 / "length_stratified_evaluation_final_v2.csv",
TAB_VALIDATION_SUITE / "length_stratified_evaluation_final_v2.csv",
])
val1_negative = safe_read_first([
TAB_VAL1 / "negative_type_specific_evaluation_final_v2.csv",
TAB_VALIDATION_SUITE / "negative_type_specific_evaluation_final_v2.csv",
])
val1_threshold = safe_read_first([
TAB_VAL1 / "threshold_operating_points_final_v2.csv",
TAB_VALIDATION_SUITE / "threshold_operating_points_final_v2.csv",
])
val1_threshold_raw = safe_read_first([
TAB_VAL1 / "threshold_sweep_final_v2.csv",
TAB_VALIDATION_SUITE / "threshold_sweep_final_v2.csv",
])
val2_metrics = safe_read_first([
TAB_VAL2 / "external_validation_metrics_final_v2.csv",
TAB_VALIDATION_SUITE / "external_literature_thp_validation_summary_final_v2.csv",
])
val2_strict_metrics = safe_read_first([
TAB_VAL2_STRICT / "strict_external_validation_metrics_final_v2.csv",
TAB_EXTERNAL_STRICT / "strict_external_validation_primary_manuscript_run_final_v2.csv",
TAB_EXTERNAL_STRICT / "strict_external_validation_summary_all_runs_final_v2.csv",
], default=pd.DataFrame({
"message": [
"Strict external validation metrics are unavailable or no strict external positives remained after overlap filtering."
]
}))
val2_strict_candidates = safe_read_first([
TAB_VAL2_STRICT / "expanded_literature_positive_candidates_final_v2.csv",
TAB_EXTERNAL_STRICT / "strict_external_input_panel_cleaned_final_v2.csv",
TAB_EXTERNAL_STRICT / "strict_external_panels_all_thresholds_final_v2.csv",
])
family_holdout_metrics = safe_read_first([
TAB_FAM / "family_holdout_validation_metrics_final_v2.csv",
TAB_VALIDATION_SUITE / "leave_family_out_validation_summary_final_v2.csv",
])
val3_motif = safe_read_first([
TAB_VAL3 / "motif_enrichment_top_vs_bottom_final_v2.csv",
])
val3_targetability = safe_read_first([
TAB_VAL3 / "recommendation_targetability_plausibility_summary_final_v2.csv",
])
val3_receptor = safe_read_first([
TAB_VAL3 / "top_decile_receptor_consistency_summary_final_v2.csv",
])
val3_cancer = safe_read_first([
TAB_VAL3 / "top_decile_cancer_consistency_summary_final_v2.csv",
])
val3_peptide = safe_read_first([
TAB_VAL3 / "top_decile_peptide_consistency_summary_final_v2.csv",
])
val3_top100 = safe_read_first([
TAB_VAL3 / "top_100_biologically_plausible_recommendations_final_v2.csv",
])
print("[OK] Validation tables loaded safely.")
# ============================================================
# 6B. Load optional cheminformatics extension tables
# ============================================================
chemi_desc = safe_read_csv(CHEMI_DESCRIPTOR_PATH)
chemi_desc_scores = safe_read_csv(CHEMI_DESCRIPTOR_SCORE_PATH)
chemi_embedding = safe_read_csv(CHEMI_EMBEDDING_PATH)
chemi_motif_baseline = safe_read_csv(CHEMI_MOTIF_BASELINE_PATH)
chemi_sim_meta = safe_read_csv(CHEMI_SIM_META_PATH)
chemi_sim_fp = None
if CHEMI_SIM_FP_PATH.exists():
try:
chemi_sim_fp = np.load(CHEMI_SIM_FP_PATH)
print("[OK] Cheminformatics fingerprint matrix loaded:", chemi_sim_fp.shape)
except Exception as e:
print("[WARN] Could not load cheminformatics fingerprint matrix:", e)
chemi_sim_fp = None
CHEMI_AVAILABLE = (not chemi_desc.empty) or (not chemi_embedding.empty) or (not chemi_motif_baseline.empty)
print("[OK] Cheminformatics extension available:", CHEMI_AVAILABLE)
print("[OK] Cheminformatics descriptors:", chemi_desc.shape)
print("[OK] Chemical-space embedding:", chemi_embedding.shape)
print("[OK] Motif-rule baseline table:", chemi_motif_baseline.shape)
print("[OK] Similarity metadata:", chemi_sim_meta.shape)
# ============================================================
# 7. Peptide utilities
# ============================================================
AA_SET = set("ACDEFGHIKLMNPQRSTVWY")
def clean_sequence(seq):
if seq is None:
return None
seq = str(seq).strip().upper()
seq = re.sub(r"\s+", "", seq)
seq = seq.replace("-", "")
seq = seq.replace("*", "")
seq = re.sub(r"[^A-Z]", "", seq)
if len(seq) < 3:
return None
if any(a not in AA_SET for a in seq):
return None
return seq
def peptide_descriptors(seq):
seq = clean_sequence(seq)
if seq is None:
return {}
hydrophobic = set("AILMFWYV")
positive = set("KRH")
negative = set("DE")
aromatic = set("FWY")
L = len(seq)
return {
"sequence": seq,
"length": L,
"frac_hydrophobic": round(sum(seq.count(a) for a in hydrophobic) / L, 4),
"frac_positive": round(sum(seq.count(a) for a in positive) / L, 4),
"frac_negative": round(sum(seq.count(a) for a in negative) / L, 4),
"frac_aromatic": round(sum(seq.count(a) for a in aromatic) / L, 4),
"net_charge_proxy": sum(seq.count(a) for a in positive) - sum(seq.count(a) for a in negative),
"has_RGD": int("RGD" in seq),
"has_NGR": int("NGR" in seq),
"has_cysteine": int("C" in seq),
"has_lysine": int("K" in seq),
"n_cysteine": seq.count("C"),
"n_lysine": seq.count("K"),
}
def motif_annotation(seq):
seq = clean_sequence(seq)
if seq is None:
return "Invalid sequence"
motifs = []
if "RGD" in seq:
motifs.append("RGD/integrin-like motif")
if "DGR" in seq:
motifs.append("DGR/RGD-related motif")
if "NGR" in seq:
motifs.append("NGR/CD13-like motif")
if "CNGRC" in seq or "CNGR" in seq:
motifs.append("cyclic-NGR-like motif")
if re.search(r"[RK][A-Z]{2}[RK]$", seq):
motifs.append("CendR-like C-terminal motif")
if "C" in seq:
motifs.append("cysteine-containing; useful for thiol/maleimide conjugation")
if "K" in seq:
motifs.append("lysine-containing; useful for amine/NHS conjugation")
if not motifs:
motifs.append("no canonical tumor-homing motif detected")
return "; ".join(motifs)
def infer_receptors(seq, metadata=""):
seq = clean_sequence(seq)
if seq is None:
return []
meta = str(metadata).lower()
hits = []
if "RGD" in seq or "DGR" in seq or "integrin" in meta:
hits += ["ITGAV", "ITGB3"]
if "NGR" in seq or "CNGRC" in seq or "CNGR" in seq or "cd13" in meta or "anpep" in meta:
hits.append("ANPEP")
if re.search(r"[RK][A-Z]{2}[RK]$", seq) or "cendr" in meta:
hits.append("NRP1_like_not_in_current_panel")
receptor_keywords = {
"EGFR": ["egfr", "epidermal growth factor"],
"ERBB2": ["her2", "erbb2"],
"MUC1": ["muc1"],
"FOLR1": ["folate", "folr1", "folate receptor"],
"FOLH1": ["psma", "folh1"],
"NCL": ["nucleolin"],
"TFRC": ["transferrin", "tfrc"],
"CXCR4": ["cxcr4"],
"CD44": ["cd44"],
"KDR": ["vegfr", "vegfr2", "kdr"],
"MSLN": ["mesothelin"],
"MET": ["c-met", "met receptor"],
"EPCAM": ["epcam"],
}
for gene, keys in receptor_keywords.items():
if any(k in meta for k in keys):
hits.append(gene)
return sorted(set(hits))
def ligand_suitability_score(seq):
seq = clean_sequence(seq)
if seq is None:
return 0.0
desc = peptide_descriptors(seq)
L = desc["length"]
score = 50.0
if 5 <= L <= 20:
score += 15
elif 21 <= L <= 35:
score += 8
elif L > 50:
score -= 15
if "RGD" in seq:
score += 10
if "NGR" in seq or "CNGR" in seq:
score += 10
if re.search(r"[RK][A-Z]{2}[RK]$", seq):
score += 6
if "C" in seq:
score += 8
if "K" in seq:
score += 4
if abs(desc["net_charge_proxy"]) <= 4:
score += 5
elif abs(desc["net_charge_proxy"]) >= 8:
score -= 8
if desc["n_cysteine"] > 4:
score -= 8
return float(max(0, min(100, score)))
def risk_interpretation(prob):
if prob >= 0.75:
return "High model-estimated tumor-homing signal"
if prob >= 0.55:
return "Moderate model-estimated tumor-homing signal"
if prob >= 0.35:
return "Borderline or uncertain model-estimated tumor-homing signal"
return "Low model-estimated tumor-homing signal"
def suitability_interpretation(score):
if score >= 85:
return "High-priority nanocarrier-ligand candidate"
if score >= 70:
return "Potential nanocarrier-ligand candidate"
if score >= 50:
return "Moderate ligand suitability; review conjugation chemistry and stability"
return "Low ligand suitability under current heuristic criteria"
# ============================================================
# 8. Ligand structure and sequence visualization
# ============================================================
def peptide_structure_svg(seq):
seq = clean_sequence(seq)
if seq is None:
return '<div class="structure-box">Invalid canonical peptide sequence.</div>'
if not RDKIT_AVAILABLE:
return '<div class="structure-box">RDKit is not available in this runtime. The dashboard still works without structure rendering.</div>'
if len(seq) > 45:
return f"""
<div class="structure-box">
Sequence length = {len(seq)} residues. 2D rendering is limited to ≤45 residues for readability.
</div>
"""
try:
mol = Chem.MolFromFASTA(seq)
if mol is None:
return '<div class="structure-box">RDKit could not construct a peptide molecule from this sequence.</div>'
AllChem.Compute2DCoords(mol)
drawer = rdMolDraw2D.MolDraw2DSVG(760, 430)
opts = drawer.drawOptions()
opts.backgroundColour = (0.07, 0.11, 0.18)
opts.bondLineWidth = 1.8
opts.minFontSize = 10
opts.maxFontSize = 16
drawer.DrawMolecule(mol)
drawer.FinishDrawing()
svg = drawer.GetDrawingText()
svg = svg.replace("svg:", "")
svg = svg.replace("#FFFFFF", "#0b1120")
svg = svg.replace("white", "#0b1120")
return f"""
<div class="structure-box">
<div class="structure-title">2D peptide ligand structure</div>
<div class="structure-subtitle">Canonical peptide representation generated from sequence using RDKit MolFromFASTA.</div>
<div class="svg-wrap">{svg}</div>
</div>
"""
except Exception as e:
return f'<div class="structure-box">Structure rendering failed: {str(e)}</div>'
def motif_highlight_html(seq):
seq = clean_sequence(seq)
if seq is None:
return '<div class="sequence-box">Invalid sequence.</div>'
motifs = []
for pattern, label, color in [
("RGD", "RGD/integrin", "#38bdf8"),
("DGR", "DGR/RGD-related", "#60a5fa"),
("NGR", "NGR/CD13", "#facc15"),
("CNGR", "CNGR/CD13", "#f59e0b"),
]:
for m in re.finditer(pattern, seq):
motifs.append((m.start(), m.end(), label, color))
cendr = re.search(r"[RK][A-Z]{2}[RK]$", seq)
if cendr:
motifs.append((cendr.start(), cendr.end(), "CendR-like", "#a78bfa"))
html_chars = []
for i, aa in enumerate(seq):
aa_color = "#cbd5e1"
bg = "#1e293b"
label = ""
for start, end, motif_label, color in motifs:
if start <= i < end:
aa_color = "#0b1120"
bg = color
label = motif_label
break
if aa == "C":
border = "1px solid #22c55e"
elif aa == "K":
border = "1px solid #fb7185"
else:
border = "1px solid #334155"
html_chars.append(
f'<span title="{label}" style="display:inline-block; margin:3px; padding:7px 9px; '
f'border-radius:8px; background:{bg}; color:{aa_color}; border:{border}; '
f'font-weight:800; font-family:monospace;">{aa}</span>'
)
motif_text = motif_annotation(seq)
return f"""
<div class="sequence-box">
<div class="structure-title">Motif-highlighted peptide sequence</div>
<div class="structure-subtitle">{motif_text}</div>
<div style="line-height:42px; margin-top:10px;">{''.join(html_chars)}</div>
<div class="legend-row">
<span class="legend-chip" style="border-color:#38bdf8;">RGD</span>
<span class="legend-chip" style="border-color:#facc15;">NGR/CNGR</span>
<span class="legend-chip" style="border-color:#a78bfa;">CendR-like</span>
<span class="legend-chip" style="border-color:#22c55e;">Cysteine</span>
<span class="legend-chip" style="border-color:#fb7185;">Lysine</span>
</div>
</div>
"""
# ============================================================
# 9. Plot functions
# ============================================================
def plot_component_probabilities(component_probs):
if not component_probs:
return dark_empty_plot("No component model probabilities available.")
df = pd.DataFrame({
"model": list(component_probs.keys()),
"probability": list(component_probs.values())
})
df = df.sort_values("probability", ascending=True)
fig, ax = plt.subplots(figsize=(8, max(4, 0.35 * len(df))))
fig.patch.set_facecolor("#0b1120")
ax.barh(df["model"], df["probability"], color="#60a5fa", edgecolor="#1e40af")
ax.set_xlim(0, 1)
apply_dark_axis(
ax,
title="Component and ensemble model probabilities",
xlabel="Model-estimated tumor-homing score",
ylabel="Model"
)
for i, v in enumerate(df["probability"]):
ax.text(min(v + 0.02, 0.98), i, f"{v:.3f}", color="#dbeafe", va="center")
return finalize_dark_fig(fig)
def build_top_contexts(seq, top_n=10):
seq = clean_sequence(seq)
if seq is None:
return pd.DataFrame()
discovery_prob, conservative_prob, _ = predict_ensemble_probs(seq)
receptors = infer_receptors(seq)
suitability = ligand_suitability_score(seq)
motif_text = motif_annotation(seq)
valid_receptors = [r for r in receptors if r != "NRP1_like_not_in_current_panel"]
if not valid_receptors or "gene_symbol" not in target_df.columns:
return pd.DataFrame()
sub = target_df[target_df["gene_symbol"].astype(str).str.upper().isin(valid_receptors)].copy()
if sub.empty:
return pd.DataFrame()
sub["custom_recommendation_score"] = (
0.40 * discovery_prob * 100
+ 0.30 * suitability
+ 0.30 * sub["targetability_score_0_100"]
)
sub["custom_conservative_score"] = (
0.35 * conservative_prob * 100
+ 0.30 * suitability
+ 0.35 * sub["targetability_score_0_100"]
)
sub["sequence"] = seq
sub["motif_annotation"] = motif_text
keep_cols = [
"sequence",
"gene_symbol",
"receptor",
"cancer_type",
"targetability_score_0_100",
"custom_recommendation_score",
"custom_conservative_score",
"motif_annotation"
]
keep_cols = [c for c in keep_cols if c in sub.columns]
return safe_round_table(
sub.sort_values("custom_recommendation_score", ascending=False)
.head(int(top_n))[keep_cols]
.reset_index(drop=True)
)
def plot_top_contexts(context_df):
if context_df is None or context_df.empty or "custom_recommendation_score" not in context_df.columns:
return dark_empty_plot("No receptor-specific cancer-context recommendation available for this peptide.")
df = context_df.copy()
df["label"] = df["gene_symbol"].astype(str) + " → " + df["cancer_type"].astype(str)
df = df.sort_values("custom_recommendation_score", ascending=True)
fig, ax = plt.subplots(figsize=(8, max(4, 0.42 * len(df))))
fig.patch.set_facecolor("#0b1120")
ax.barh(df["label"], df["custom_recommendation_score"], color="#38bdf8", edgecolor="#0e7490")
ax.set_xlim(0, 100)
apply_dark_axis(
ax,
title="Top receptor–cancer recommendation contexts",
xlabel="Recommendation score",
ylabel="Context"
)
for i, v in enumerate(df["custom_recommendation_score"]):
ax.text(min(v + 1.0, 98), i, f"{v:.1f}", color="#dbeafe", va="center")
return finalize_dark_fig(fig)
def plot_model_performance():
if performance_df.empty:
return dark_empty_plot("Model performance table not found.")
df = performance_df.copy()
if "fold" in df.columns:
df = df[df["fold"].astype(str) == "overall_oof"].copy()
metrics = [m for m in ["AUROC", "AUPRC", "MCC", "F1"] if m in df.columns]
if not metrics or "model" not in df.columns:
return dark_empty_plot("Compatible model performance columns not found.")
df = df[["model"] + metrics].copy()
df = df.sort_values(metrics[0], ascending=False)
x = np.arange(len(df))
width = 0.18
fig, ax = plt.subplots(figsize=(11, 5))
fig.patch.set_facecolor("#0b1120")
colors = ["#60a5fa", "#38bdf8", "#a78bfa", "#f59e0b"]
for i, metric in enumerate(metrics):
ax.bar(
x + (i - len(metrics)/2) * width + width/2,
df[metric],
width,
label=metric,
color=colors[i % len(colors)]
)
ax.set_xticks(x)
ax.set_xticklabels(df["model"], rotation=45, ha="right")
ax.set_ylim(0, 1)
apply_dark_axis(ax, title="Cluster-aware model performance", ylabel="Metric value")
ax.legend()
return finalize_dark_fig(fig)
def plot_challenge_summary():
if challenge_df.empty:
return dark_empty_plot("CancerPPD challenge table not found.")
df = challenge_df.copy()
if "false_positive_rate_at_0.5" not in df.columns:
return dark_empty_plot("Challenge summary does not contain false_positive_rate_at_0.5.")
df = df.sort_values("false_positive_rate_at_0.5", ascending=True)
fig, ax = plt.subplots(figsize=(10, 5))
fig.patch.set_facecolor("#0b1120")
ax.barh(df["model"].astype(str), df["false_positive_rate_at_0.5"], color="#f59e0b", edgecolor="#92400e")
ax.set_xlim(0, max(0.6, df["false_positive_rate_at_0.5"].max() * 1.2))
apply_dark_axis(
ax,
title="CancerPPD hard-negative false-positive rate",
xlabel="False-positive rate at threshold 0.5",
ylabel="Model"
)
for i, v in enumerate(df["false_positive_rate_at_0.5"]):
ax.text(v + 0.01, i, f"{v:.3f}", color="#dbeafe", va="center")
return finalize_dark_fig(fig)
def plot_threshold_calibration():
if val1_threshold_raw.empty:
return dark_empty_plot("Threshold sweep table not found.")
df = val1_threshold_raw.copy()
required = ["threshold", "precision", "recall", "F1", "MCC"]
if not all(c in df.columns for c in required):
return dark_empty_plot("Threshold sweep does not contain required columns.")
fig, ax = plt.subplots(figsize=(8, 5))
fig.patch.set_facecolor("#0b1120")
ax.plot(df["threshold"], df["precision"], label="Precision", color="#38bdf8", linewidth=2)
ax.plot(df["threshold"], df["recall"], label="Recall", color="#f59e0b", linewidth=2)
ax.plot(df["threshold"], df["F1"], label="F1", color="#a78bfa", linewidth=2)
ax.plot(df["threshold"], df["MCC"], label="MCC", color="#22c55e", linewidth=2)
apply_dark_axis(ax, title="Threshold calibration", xlabel="Decision threshold", ylabel="Metric")
ax.legend()
return finalize_dark_fig(fig)
def plot_permutation_control():
if val1_perm_raw.empty or "AUROC" not in val1_perm_raw.columns:
return dark_empty_plot("Permutation control table not found.")
observed_auc = None
if not val1_perm_summary.empty and "observed_AUROC" in val1_perm_summary.columns:
observed_auc = float(val1_perm_summary["observed_AUROC"].iloc[0])
fig, ax = plt.subplots(figsize=(7, 5))
fig.patch.set_facecolor("#0b1120")
ax.hist(val1_perm_raw["AUROC"], bins=16, color="#60a5fa", edgecolor="#1e40af", alpha=0.85)
if observed_auc is not None:
ax.axvline(
observed_auc,
color="#f59e0b",
linewidth=3,
linestyle="--",
label=f"Observed AUROC = {observed_auc:.3f}"
)
ax.legend()
apply_dark_axis(ax, title="Label permutation control", xlabel="Permuted-label AUROC", ylabel="Frequency")
return finalize_dark_fig(fig)
def plot_motif_ablation():
if val1_motif_ablation_raw.empty:
return dark_empty_plot("Motif-ablation raw table not found.")
col = "delta_prob_original_minus_ablated"
if col not in val1_motif_ablation_raw.columns:
return dark_empty_plot("Motif-ablation delta column not found.")
fig, ax = plt.subplots(figsize=(7, 5))
fig.patch.set_facecolor("#0b1120")
ax.hist(val1_motif_ablation_raw[col], bins=30, color="#a78bfa", edgecolor="#5b21b6", alpha=0.9)
ax.axvline(0, color="#f59e0b", linestyle="--", linewidth=2)
apply_dark_axis(
ax,
title="Motif-ablation sensitivity",
xlabel="Original probability - ablated probability",
ylabel="Number of peptides"
)
return finalize_dark_fig(fig)
def plot_length_stratified():
if val1_length.empty or "length_bin" not in val1_length.columns or "AUROC" not in val1_length.columns:
return dark_empty_plot("Length-stratified evaluation table not found.")
df = val1_length.copy()
fig, ax = plt.subplots(figsize=(7, 5))
fig.patch.set_facecolor("#0b1120")
ax.bar(df["length_bin"].astype(str), df["AUROC"], color="#38bdf8", edgecolor="#0e7490")
ax.set_ylim(0, 1)
apply_dark_axis(ax, title="Length-stratified AUROC", xlabel="Peptide length bin", ylabel="AUROC")
for i, v in enumerate(df["AUROC"]):
ax.text(i, min(v + 0.03, 0.98), f"{v:.3f}", color="#dbeafe", ha="center")
return finalize_dark_fig(fig)
def plot_negative_type():
if val1_negative.empty or "negative_type" not in val1_negative.columns or "AUROC" not in val1_negative.columns:
return dark_empty_plot("Negative-type-specific evaluation table not found.")
df = val1_negative.copy()
df = df.sort_values("AUROC", ascending=True)
fig, ax = plt.subplots(figsize=(8, 4))
fig.patch.set_facecolor("#0b1120")
ax.barh(df["negative_type"].astype(str), df["AUROC"], color="#22c55e", edgecolor="#166534")
ax.set_xlim(0, 1)
apply_dark_axis(ax, title="Negative-type-specific AUROC", xlabel="AUROC", ylabel="Negative type")
return finalize_dark_fig(fig)
def plot_family_holdout():
if family_holdout_metrics.empty or "family_holdout" not in family_holdout_metrics.columns:
return dark_empty_plot("Family-holdout validation table not found.")
metric_col = "discovery_AUROC"
if metric_col not in family_holdout_metrics.columns:
return dark_empty_plot("Family-holdout AUROC column not found.")
df = family_holdout_metrics.copy().sort_values(metric_col, ascending=True)
fig, ax = plt.subplots(figsize=(9, max(4, 0.45 * len(df))))
fig.patch.set_facecolor("#0b1120")
ax.barh(df["family_holdout"].astype(str), df[metric_col], color="#f59e0b", edgecolor="#92400e")
ax.axvline(0.5, color="#ef4444", linestyle="--", linewidth=2)
ax.set_xlim(0, 1)
apply_dark_axis(
ax,
title="Leave-family-out pseudo-external validation",
xlabel="Discovery AUROC",
ylabel="Withheld family"
)
return finalize_dark_fig(fig)
def plot_level3_motif_enrichment():
if val3_motif.empty:
return dark_empty_plot("Level 3 motif enrichment table not found.")
required = ["motif", "top_quartile_frequency", "bottom_quartile_frequency"]
if not all(c in val3_motif.columns for c in required):
return dark_empty_plot("Motif enrichment table lacks required columns.")
df = val3_motif.copy()
x = np.arange(len(df))
width = 0.36
fig, ax = plt.subplots(figsize=(10, 5))
fig.patch.set_facecolor("#0b1120")
ax.bar(x - width/2, df["top_quartile_frequency"], width, label="Top quartile", color="#38bdf8")
ax.bar(x + width/2, df["bottom_quartile_frequency"], width, label="Bottom quartile", color="#64748b")
ax.set_xticks(x)
ax.set_xticklabels(df["motif"], rotation=45, ha="right")
apply_dark_axis(
ax,
title="Motif enrichment in high-scoring peptides",
xlabel="Motif",
ylabel="Frequency"
)
ax.legend()
return finalize_dark_fig(fig)
def plot_level3_targetability():
if val3_targetability.empty:
return dark_empty_plot("Level 3 targetability summary not found.")
if "group" not in val3_targetability.columns or "mean_targetability" not in val3_targetability.columns:
return dark_empty_plot("Targetability summary lacks required columns.")
df = val3_targetability.copy()
fig, ax = plt.subplots(figsize=(8, 5))
fig.patch.set_facecolor("#0b1120")
ax.bar(df["group"].astype(str), df["mean_targetability"], color="#60a5fa", edgecolor="#1d4ed8")
ax.set_ylim(0, max(100, df["mean_targetability"].max() * 1.15))
apply_dark_axis(
ax,
title="HPA-derived targetability in recommendation strata",
xlabel="Recommendation group",
ylabel="Mean targetability score"
)
ax.tick_params(axis="x", rotation=25)
return finalize_dark_fig(fig)
# ============================================================
# 10. Fixed atlas-aware recommendation plots
# ============================================================
def get_full_atlas_filtered(cancer_type="All", receptor_gene="All"):
df = target_df.copy()
required = ["gene_symbol", "cancer_type", "targetability_score_0_100"]
missing_cols = [c for c in required if c not in df.columns]
if missing_cols:
return pd.DataFrame(), missing_cols
if cancer_type != "All":
df = df[df["cancer_type"].astype(str) == str(cancer_type)]
if receptor_gene != "All":
df = df[df["gene_symbol"].astype(str) == str(receptor_gene)]
df["targetability_score_0_100"] = pd.to_numeric(df["targetability_score_0_100"], errors="coerce")
df = df.dropna(subset=["gene_symbol", "cancer_type", "targetability_score_0_100"])
return df, []
def get_peptide_inferred_filtered(cancer_type="All", receptor_gene="All", min_discovery_prob=0.5, min_score=60):
df = recommendation_df.copy()
if cancer_type != "All" and "cancer_type" in df.columns:
df = df[df["cancer_type"].astype(str) == str(cancer_type)]
if receptor_gene != "All" and "matched_receptor_gene" in df.columns:
df = df[df["matched_receptor_gene"].astype(str) == str(receptor_gene)]
prob_col = None
for c in ["ensemble_preferred_prob", "oof_prob_discovery_model"]:
if c in df.columns:
prob_col = c
break
score_col = None
for c in ["recommendation_score_ensemble", "recommendation_score_oof"]:
if c in df.columns:
score_col = c
break
if prob_col is not None:
df = df[pd.to_numeric(df[prob_col], errors="coerce") >= float(min_discovery_prob)]
if score_col is not None:
df = df[pd.to_numeric(df[score_col], errors="coerce") >= float(min_score)]
df = df.sort_values(score_col, ascending=False)
return df, score_col, prob_col
def plot_recommendation_bar(
cancer_type="All",
receptor_gene="All",
min_discovery_prob=0.5,
min_score=60,
top_n=20,
heatmap_mode="Full receptor targetability atlas"
):
"""
Fixed bar plot:
- Full receptor targetability atlas mode shows receptor-cancer targetability rows from target_df.
- Motif-supported mode shows peptide-receptor-cancer rows from recommendation_df.
"""
# ========================================================
# Full receptor targetability atlas mode
# ========================================================
if heatmap_mode == "Full receptor targetability atlas":
df, missing_cols = get_full_atlas_filtered(cancer_type, receptor_gene)
if missing_cols:
return dark_empty_plot(f"target_df missing columns: {missing_cols}")
if df.empty:
return dark_empty_plot("No full receptor targetability rows match selected filters.")
df = df.sort_values("targetability_score_0_100", ascending=False).head(int(top_n)).copy()
df["label"] = df["gene_symbol"].astype(str) + " → " + df["cancer_type"].astype(str)
df = df.sort_values("targetability_score_0_100", ascending=True)
fig, ax = plt.subplots(figsize=(10, max(5, 0.38 * len(df))))
fig.patch.set_facecolor("#0b1120")
ax.barh(df["label"], df["targetability_score_0_100"], color="#22c55e", edgecolor="#166534")
ax.set_xlim(0, 100)
apply_dark_axis(
ax,
title="Top full receptor–cancer targetability contexts",
xlabel="HPA-derived targetability score",
ylabel="Receptor–cancer context"
)
for i, v in enumerate(df["targetability_score_0_100"]):
ax.text(min(v + 1, 98), i, f"{v:.1f}", color="#dbeafe", va="center", fontsize=8)
return finalize_dark_fig(fig)
# ========================================================
# Motif-supported peptide-context mode
# ========================================================
df, score_col, prob_col = get_peptide_inferred_filtered(
cancer_type=cancer_type,
receptor_gene=receptor_gene,
min_discovery_prob=min_discovery_prob,
min_score=min_score
)
if df.empty:
return dark_empty_plot("No motif-supported peptide contexts match selected filters.")
if score_col is None:
return dark_empty_plot("No recommendation score column found.")
plot_df = df.head(int(top_n)).copy()
plot_df["label"] = (
plot_df["sequence"].astype(str)
+ " | "
+ plot_df["matched_receptor_gene"].astype(str)
+ " → "
+ plot_df["cancer_type"].astype(str)
)
plot_df = plot_df.sort_values(score_col, ascending=True)
fig, ax = plt.subplots(figsize=(10, max(5, 0.35 * len(plot_df))))
fig.patch.set_facecolor("#0b1120")
ax.barh(plot_df["label"], plot_df[score_col], color="#38bdf8", edgecolor="#0e7490")
ax.set_xlim(0, 100)
apply_dark_axis(
ax,
title="Top motif-supported peptide contexts",
xlabel="Recommendation score",
ylabel="Peptide-context"
)
return finalize_dark_fig(fig)
def plot_recommendation_heatmap(
cancer_type="All",
receptor_gene="All",
top_n=20,
heatmap_mode="Full receptor targetability atlas"
):
"""
Fixed heatmap:
- Full receptor targetability atlas uses target_df and shows all receptors in the atlas.
- Motif-supported peptide contexts uses recommendation_df and may show only motif-inferred receptors.
"""
if heatmap_mode == "Full receptor targetability atlas":
df, missing_cols = get_full_atlas_filtered(cancer_type, receptor_gene)
if missing_cols:
return dark_empty_plot(f"target_df missing columns: {missing_cols}")
if df.empty:
return dark_empty_plot("No full receptor targetability data available for selected filters.")
row_col = "gene_symbol"
col_col = "cancer_type"
score_col = "targetability_score_0_100"
title = "Full HPA-derived receptor targetability atlas"
else:
df, score_col, prob_col = get_peptide_inferred_filtered(
cancer_type=cancer_type,
receptor_gene=receptor_gene,
min_discovery_prob=0.0,
min_score=0
)
if df.empty:
return dark_empty_plot("No motif-supported peptide-context data available for selected filters.")
if score_col is None:
return dark_empty_plot("No recommendation score column found.")
row_col = "matched_receptor_gene"
col_col = "cancer_type"
title = "Motif-supported mean candidate-prioritization score"
df[score_col] = pd.to_numeric(df[score_col], errors="coerce")
df = df.dropna(subset=[score_col, row_col, col_col]).copy()
if df.empty:
return dark_empty_plot("No numeric heatmap data after filtering.")
n_receptors = min(int(top_n), df[row_col].nunique())
n_cancers = min(int(top_n), df[col_col].nunique())
top_receptors = (
df.groupby(row_col)[score_col]
.mean()
.sort_values(ascending=False)
.head(max(1, n_receptors))
.index
)
top_cancers = (
df.groupby(col_col)[score_col]
.mean()
.sort_values(ascending=False)
.head(max(1, n_cancers))
.index
)
sub = df[df[row_col].isin(top_receptors) & df[col_col].isin(top_cancers)].copy()
pivot = sub.pivot_table(
index=row_col,
columns=col_col,
values=score_col,
aggfunc="mean"
)
if pivot.empty:
return dark_empty_plot("No heatmap could be generated for selected filters.")
pivot = pivot.loc[pivot.mean(axis=1).sort_values(ascending=False).index]
pivot = pivot[pivot.mean(axis=0).sort_values(ascending=False).index]
fig_width = max(9, 0.58 * len(pivot.columns) + 4)
fig_height = max(5, 0.45 * len(pivot.index) + 2)
fig, ax = plt.subplots(figsize=(fig_width, fig_height))
fig.patch.set_facecolor("#0b1120")
ax.set_facecolor("#111827")
im = ax.imshow(pivot.values, aspect="auto", cmap="viridis")
ax.set_xticks(np.arange(len(pivot.columns)))
ax.set_yticks(np.arange(len(pivot.index)))
ax.set_xticklabels(
pivot.columns,
rotation=45,
ha="right",
color="#dbeafe",
fontsize=9
)
ax.set_yticklabels(
pivot.index,
color="#dbeafe",
fontsize=9
)
ax.set_title(
title,
color="#bfdbfe",
fontsize=13,
fontweight="bold",
pad=12
)
if pivot.shape[0] <= 20 and pivot.shape[1] <= 20:
for i in range(pivot.shape[0]):
for j in range(pivot.shape[1]):
val = pivot.values[i, j]
if not np.isnan(val):
ax.text(
j,
i,
f"{val:.1f}",
ha="center",
va="center",
color="#e0f2fe",
fontsize=7,
fontweight="bold"
)
cbar = fig.colorbar(im, ax=ax)
cbar.ax.yaxis.set_tick_params(color="#cbd5e1")
plt.setp(plt.getp(cbar.ax.axes, "yticklabels"), color="#cbd5e1")
cbar.outline.set_edgecolor("#475569")
for spine in ax.spines.values():
spine.set_color("#475569")
fig.tight_layout()
return fig
def get_explorer_table(
cancer_type,
receptor_gene,
min_discovery_prob,
min_score,
top_n,
heatmap_mode
):
if heatmap_mode == "Full receptor targetability atlas":
df, missing_cols = get_full_atlas_filtered(cancer_type, receptor_gene)
if missing_cols:
return pd.DataFrame({"message": [f"target_df missing columns: {missing_cols}"]})
if df.empty:
return pd.DataFrame({"message": ["No full receptor targetability rows match selected filters."]})
keep_cols = [
"gene_symbol",
"receptor",
"cancer_type",
"protein_expression_score",
"cancer_celline_rna",
"normal_penalty_raw",
"targetability_score_0_100"
]
keep_cols = [c for c in keep_cols if c in df.columns]
return safe_round_table(
df.sort_values("targetability_score_0_100", ascending=False)
.head(int(top_n))[keep_cols]
.reset_index(drop=True)
)
df, score_col, prob_col = get_peptide_inferred_filtered(
cancer_type,
receptor_gene,
min_discovery_prob,
min_score
)
if df.empty:
return pd.DataFrame({"message": ["No motif-supported peptide contexts match the selected filters."]})
keep_cols = [
"sequence",
"motif_annotation",
"matched_receptor_gene",
"receptor",
"cancer_type",
"ensemble_preferred_prob",
"ensemble_conservative_prob",
"recommendation_score_ensemble",
"recommendation_score_ensemble_conservative",
"oof_prob_discovery_model",
"oof_prob_conservative_model",
"ligand_suitability_score",
"targetability_score_0_100",
"recommendation_score_oof",
"recommendation_score_conservative",
"evidence_level"
]
keep_cols = [c for c in keep_cols if c in df.columns]
return safe_round_table(df[keep_cols].head(int(top_n)).reset_index(drop=True))
# ============================================================
# 10B. Cheminformatics extension functions
# ============================================================
MORGAN_N_BITS = 2048
MORGAN_RADIUS = 2
if RDKIT_AVAILABLE and MORGAN_GENERATOR_AVAILABLE:
try:
morgan_generator = rdFingerprintGenerator.GetMorganGenerator(
radius=MORGAN_RADIUS,
fpSize=MORGAN_N_BITS
)
except Exception:
morgan_generator = None
else:
morgan_generator = None
def get_morgan_fp_for_app(mol):
if mol is None or not RDKIT_AVAILABLE:
return None
if morgan_generator is not None:
return morgan_generator.GetFingerprint(mol)
try:
return AllChem.GetMorganFingerprintAsBitVect(mol, radius=MORGAN_RADIUS, nBits=MORGAN_N_BITS)
except Exception:
return None
def fp_to_numpy_for_app(fp, n_bits=MORGAN_N_BITS):
arr = np.zeros((n_bits,), dtype=np.int8)
if fp is not None:
DataStructs.ConvertToNumpyArray(fp, arr)
return arr
def tanimoto_arrays_for_app(a, b):
a = np.asarray(a).astype(bool)
b = np.asarray(b).astype(bool)
denom = np.logical_or(a, b).sum()
if denom == 0:
return 0.0
return float(np.logical_and(a, b).sum() / denom)
def peptide_mol_for_app(seq):
seq = clean_sequence(seq)
if seq is None or not RDKIT_AVAILABLE:
return None
try:
mol = Chem.MolFromFASTA(seq)
if mol is None:
return None
Chem.SanitizeMol(mol)
return mol
except Exception:
return None
def motif_rule_score_for_app(seq):
seq = clean_sequence(seq)
if seq is None:
return 0.0
score = 0.0
if "RGD" in seq:
score += 1.0
if "DGR" in seq:
score += 0.8
if "NGR" in seq or "CNGR" in seq or "CNGRC" in seq:
score += 1.0
if re.search(r"[RK][A-Z]{2}[RK]$", seq):
score += 0.8
if seq.count("C") >= 2:
score += 0.6
if seq.count("C") >= 4:
score += 0.4
if 5 <= len(seq) <= 25:
score += 0.3
return float(min(score / 2.5, 1.0))
def compute_query_cheminformatics(seq):
seq = clean_sequence(seq)
if seq is None:
return pd.DataFrame({"message": ["Invalid canonical peptide sequence."]})
if not RDKIT_AVAILABLE:
return pd.DataFrame({"message": ["RDKit is not available in this runtime."]})
mol = peptide_mol_for_app(seq)
if mol is None:
return pd.DataFrame({"message": ["RDKit could not construct a peptide molecule from this sequence."]})
try:
smiles = Chem.MolToSmiles(mol, canonical=True)
except Exception:
smiles = None
try:
formal_charge = sum(atom.GetFormalCharge() for atom in mol.GetAtoms())
except Exception:
formal_charge = np.nan
row = {
"sequence": seq,
"canonical_smiles": smiles,
"sequence_length": len(seq),
"motif_rule_score": motif_rule_score_for_app(seq),
"mol_weight": Descriptors.MolWt(mol),
"heavy_atom_count": Descriptors.HeavyAtomCount(mol),
"num_atoms": mol.GetNumAtoms(),
"num_bonds": mol.GetNumBonds(),
"tpsa": rdMolDescriptors.CalcTPSA(mol),
"hbd": Lipinski.NumHDonors(mol),
"hba": Lipinski.NumHAcceptors(mol),
"rotatable_bonds": Lipinski.NumRotatableBonds(mol),
"logp": Crippen.MolLogP(mol),
"num_rings": rdMolDescriptors.CalcNumRings(mol),
"formal_charge": formal_charge,
"fraction_csp3": rdMolDescriptors.CalcFractionCSP3(mol),
"scope_note": "Computed RDKit representation from canonical sequence; not an experimental conformation or binding result.",
}
return safe_round_table(pd.DataFrame([row]))
def query_similarity_app(seq, top_k=20, class_filter="All"):
seq = clean_sequence(seq)
if seq is None:
return pd.DataFrame({"message": ["Invalid canonical peptide sequence."]})
if not RDKIT_AVAILABLE:
return pd.DataFrame({"message": ["RDKit is not available in this runtime."]})
if chemi_sim_meta.empty or chemi_sim_fp is None:
return pd.DataFrame({"message": ["Similarity index not found. Run the cheminformatics extension cell and include its outputs in the Space."]})
mol = peptide_mol_for_app(seq)
if mol is None:
return pd.DataFrame({"message": ["RDKit could not construct a peptide molecule from this sequence."]})
qfp = get_morgan_fp_for_app(mol)
qarr = fp_to_numpy_for_app(qfp)
df = chemi_sim_meta.copy().reset_index(drop=True)
fp = chemi_sim_fp.copy()
if class_filter and class_filter != "All" and "cheminfo_class" in df.columns:
mask = df["cheminfo_class"].astype(str).eq(str(class_filter)).values
df = df.loc[mask].reset_index(drop=True)
fp = fp[mask]
if df.empty or fp.shape[0] == 0:
return pd.DataFrame({"message": ["No records match the selected similarity-search filter."]})
scores = []
for i in range(fp.shape[0]):
try:
if "sequence" in df.columns and str(df.iloc[i]["sequence"]) == seq:
continue
scores.append((i, tanimoto_arrays_for_app(qarr, fp[i])))
except Exception:
continue
scores = sorted(scores, key=lambda x: x[1], reverse=True)[:int(top_k)]
rows = []
for i, sim in scores:
row = df.iloc[i].to_dict()
row["query_sequence"] = seq
row["tanimoto_similarity"] = float(sim)
rows.append(row)
out = pd.DataFrame(rows)
keep_cols = [
"query_sequence", "sequence", "tanimoto_similarity", "cheminfo_class", "dataset_source",
"binary_label_tumor_homing", "peptide_class", "negative_type", "sequence_length",
"canonical_smiles", "mol_weight", "tpsa", "hbd", "hba", "rotatable_bonds",
"logp", "motif_rule_score",
]
keep_cols = [c for c in keep_cols if c in out.columns]
return safe_round_table(out[keep_cols].copy())
def plot_similarity_app(sim_df):
if sim_df is None or not isinstance(sim_df, pd.DataFrame) or sim_df.empty:
return dark_empty_plot("No similarity-search results to plot.")
if "tanimoto_similarity" not in sim_df.columns or "sequence" not in sim_df.columns:
return dark_empty_plot("Similarity table lacks required columns.")
df = sim_df.copy().sort_values("tanimoto_similarity", ascending=True)
fig, ax = plt.subplots(figsize=(9, max(5, 0.35 * len(df))))
fig.patch.set_facecolor("#0b1120")
ax.barh(df["sequence"].astype(str), pd.to_numeric(df["tanimoto_similarity"], errors="coerce"), color="#38bdf8", edgecolor="#0e7490")
ax.set_xlim(0, 1)
apply_dark_axis(ax, title="Morgan/ECFP nearest-neighbor search", xlabel="Tanimoto similarity", ylabel="Neighbor peptide")
return finalize_dark_fig(fig)
def run_similarity_search_visual(seq, top_k, class_filter):
sim_df = query_similarity_app(seq, top_k=top_k, class_filter=class_filter)
return sim_df, plot_similarity_app(sim_df), compute_query_cheminformatics(seq)
def plot_chemical_space_app(color_by="cheminfo_class", max_points=5000):
if chemi_embedding.empty:
return dark_empty_plot("Chemical-space embedding not found. Run the cheminformatics extension cell first.")
df = chemi_embedding.copy()
coord_pairs = []
for c in df.columns:
if c.endswith("_1"):
prefix = c[:-2]
if f"{prefix}_2" in df.columns:
coord_pairs.append((prefix, c, f"{prefix}_2"))
if not coord_pairs:
return dark_empty_plot("Chemical-space embedding columns were not found.")
method, xcol, ycol = coord_pairs[0]
if len(df) > int(max_points):
df = df.sample(n=int(max_points), random_state=42).copy()
fig, ax = plt.subplots(figsize=(8.5, 7))
fig.patch.set_facecolor("#0b1120")
if color_by in df.columns and pd.api.types.is_numeric_dtype(df[color_by]):
sc = ax.scatter(df[xcol], df[ycol], c=df[color_by], s=18, alpha=0.80)
cbar = fig.colorbar(sc, ax=ax)
cbar.ax.yaxis.set_tick_params(color="#cbd5e1")
plt.setp(plt.getp(cbar.ax.axes, "yticklabels"), color="#cbd5e1")
cbar.set_label(color_by, color="#dbeafe")
elif color_by in df.columns:
for label, sub in df.groupby(color_by):
ax.scatter(sub[xcol], sub[ycol], s=18, alpha=0.72, label=str(label)[:45])
ax.legend(fontsize=8, frameon=True)
else:
ax.scatter(df[xcol], df[ycol], s=18, alpha=0.72)
apply_dark_axis(ax, title=f"Chemical-space map from Morgan/ECFP fingerprints ({method})", xlabel=f"{method} 1", ylabel=f"{method} 2")
return finalize_dark_fig(fig)
def plot_descriptor_distribution_app(descriptor="mol_weight"):
if chemi_desc.empty:
return dark_empty_plot("RDKit descriptor table not found. Run the cheminformatics extension cell first.")
if descriptor not in chemi_desc.columns:
return dark_empty_plot(f"Descriptor '{descriptor}' not found.")
if "cheminfo_class" not in chemi_desc.columns:
return dark_empty_plot("cheminfo_class column not found in descriptor table.")
data = []
labels = []
for cls, sub in chemi_desc.groupby("cheminfo_class"):
vals = pd.to_numeric(sub[descriptor], errors="coerce").dropna()
if len(vals):
data.append(vals.values)
labels.append(str(cls))
if not data:
return dark_empty_plot("No numeric descriptor values available for selected descriptor.")
fig, ax = plt.subplots(figsize=(9, 5))
fig.patch.set_facecolor("#0b1120")
ax.boxplot(data, labels=[str(x)[:24] for x in labels], showfliers=False)
apply_dark_axis(ax, title=f"RDKit descriptor distribution: {descriptor}", xlabel="Class", ylabel=descriptor)
ax.tick_params(axis="x", rotation=25)
return finalize_dark_fig(fig)
def plot_motif_rule_baseline_app():
if chemi_motif_baseline.empty:
return dark_empty_plot("Motif-rule baseline table not found. Run the cheminformatics extension cell first.")
df = chemi_motif_baseline.copy()
metrics = [m for m in ["AUROC", "AUPRC", "MCC", "F1", "balanced_accuracy"] if m in df.columns]
if not metrics or "model" not in df.columns:
return dark_empty_plot("Motif-rule baseline table lacks required columns.")
df = df.dropna(subset=metrics, how="all").head(14).copy()
x = np.arange(len(df))
width = 0.8 / max(1, len(metrics))
fig, ax = plt.subplots(figsize=(max(10, 1.1 * len(df)), 6))
fig.patch.set_facecolor("#0b1120")
colors = ["#60a5fa", "#38bdf8", "#a78bfa", "#f59e0b", "#22c55e"]
for i, metric in enumerate(metrics):
ax.bar(x + i * width - 0.4 + width / 2, pd.to_numeric(df[metric], errors="coerce"), width, label=metric, color=colors[i % len(colors)])
ax.set_xticks(x)
ax.set_xticklabels(df["model"].astype(str), rotation=35, ha="right")
ax.set_ylim(0, 1)
apply_dark_axis(ax, title="Motif-rule baseline versus machine-learning models", ylabel="Metric value")
ax.legend()
return finalize_dark_fig(fig)
# ============================================================
# 10C. Protein-interaction / receptor-network functions
# ============================================================
# Fully robust final-v2 implementation.
# Fixes duplicate-column cases where df[col] returns a DataFrame instead of a Series.
# This prevents pandas.to_numeric(...): TypeError: arg must be a list, tuple, 1-d array, or Series.
# STRING/PPI is used only as receptor network/module context, not peptide-binding evidence.
# ============================================================
def _drop_duplicate_columns_network(df):
if df is None or not isinstance(df, pd.DataFrame) or df.empty:
return pd.DataFrame()
return df.loc[:, ~df.columns.duplicated()].copy()
def _one_series_network(df, col, default=np.nan):
"""Return one Series even if duplicate column names make df[col] a DataFrame."""
if df is None or not isinstance(df, pd.DataFrame) or df.empty:
return pd.Series(dtype="object")
if col not in df.columns:
return pd.Series([default] * len(df), index=df.index)
obj = df[col]
if isinstance(obj, pd.DataFrame):
obj = obj.iloc[:, 0]
return obj
def _find_column_network(df, exact_candidates=None, contains_any=None, exclude_contains=None):
if df is None or not isinstance(df, pd.DataFrame) or df.empty:
return None
exact_candidates = exact_candidates or []
contains_any = contains_any or []
exclude_contains = exclude_contains or []
cols = list(df.columns)
for c in exact_candidates:
if c in cols:
return c
for c in cols:
cl = str(c).lower()
if exclude_contains and any(x.lower() in cl for x in exclude_contains):
continue
if contains_any and any(x.lower() in cl for x in contains_any):
return c
return None
def _standardize_ppi_summary(df):
"""Standardize receptor-level PPI/network support table."""
df = _drop_duplicate_columns_network(df)
if df.empty:
return df
gene_col = _find_column_network(
df,
exact_candidates=["gene_symbol", "gene", "query_gene", "receptor_gene", "preferred_name", "preferredName"],
contains_any=["gene", "receptor", "preferred"],
exclude_contains=["family", "context"]
)
# Prefer biologically meaningful network-support columns over generic IDs.
support_col = _find_column_network(
df,
exact_candidates=[
"network_support_score",
"string_network_support_score",
"n_high_confidence_physical_interactors",
"n_high_confidence_interactors",
"high_confidence_interactor_count",
"n_receptor_panel_physical_interactors",
"n_receptor_panel_interactions",
"n_high_confidence_edges",
"n_edges",
"degree",
"network_degree",
"interactor_count",
],
contains_any=["network_support", "high_confidence", "interactor", "degree", "edge"]
)
panel_col = _find_column_network(
df,
exact_candidates=[
"n_receptor_panel_physical_interactors",
"n_receptor_panel_interactions",
"receptor_panel_interaction_count",
"panel_interactions",
],
contains_any=["receptor_panel", "panel_interaction"]
)
out = df.copy()
if gene_col is not None:
out["ppi_gene_symbol"] = _one_series_network(out, gene_col).astype(str).str.upper().str.strip()
else:
out["ppi_gene_symbol"] = [f"RECEPTOR_{i}" for i in range(len(out))]
if support_col is not None:
out["ppi_network_support"] = pd.to_numeric(_one_series_network(out, support_col), errors="coerce")
else:
out["ppi_network_support"] = 0.0
if panel_col is not None:
out["ppi_receptor_panel_edges"] = pd.to_numeric(_one_series_network(out, panel_col), errors="coerce")
else:
out["ppi_receptor_panel_edges"] = 0.0
out["ppi_network_support"] = out["ppi_network_support"].fillna(0)
out["ppi_receptor_panel_edges"] = out["ppi_receptor_panel_edges"].fillna(0)
return out
def _looks_like_string_protein_id_series(s):
"""Return True when a column is mostly STRING ENSP protein IDs rather than gene symbols."""
try:
vals = s.dropna().astype(str).str.upper().head(200)
if len(vals) == 0:
return False
return bool(vals.str.contains(r"^9606\\.ENSP|^ENSP", regex=True).mean() > 0.50)
except Exception:
return False
def _clean_gene_label_series(s):
"""Clean graph node labels and strip STRING 9606. prefix when needed."""
s = s.astype(str).str.upper().str.strip()
s = s.str.replace(r"^9606\\.", "", regex=True)
return s
def _standardize_ppi_edges(df):
"""
Standardize STRING physical edge table and force real gene-symbol node labels.
Important fix:
STRING raw edge files often contain columns like protein1/protein2 with ENSP IDs,
while the THP-NanoTarget extraction also writes receptor_gene and partner_gene.
For the dashboard, we must prefer receptor_gene/partner_gene over protein1/protein2;
otherwise the interactive PPIN displays ENSP0000... nodes instead of gene symbols.
"""
df = _drop_duplicate_columns_network(df)
if df.empty:
return df
# Prefer already-mapped gene-symbol columns first.
src_col = _find_column_network(
df,
exact_candidates=[
"receptor_gene", "gene_a", "source_gene", "preferred_name_a", "preferredName_A",
"protein1_gene", "gene1", "node_a", "source"
],
contains_any=["receptor_gene", "gene_a", "source_gene", "preferred_name_a", "protein1_gene", "gene1", "node_a"]
)
tgt_col = _find_column_network(
df,
exact_candidates=[
"partner_gene", "interactor_gene", "gene_b", "target_gene", "preferred_name_b", "preferredName_B",
"protein2_gene", "gene2", "node_b", "target"
],
contains_any=["partner_gene", "interactor_gene", "gene_b", "target_gene", "preferred_name_b", "protein2_gene", "gene2", "node_b"]
)
# Avoid choosing raw ENSP ID columns if mapped gene-symbol alternatives exist.
for candidate in ["receptor_gene", "gene_a", "source_gene", "preferred_name_a", "gene1"]:
if candidate in df.columns and not _looks_like_string_protein_id_series(_one_series_network(df, candidate)):
src_col = candidate
break
for candidate in ["partner_gene", "interactor_gene", "gene_b", "target_gene", "preferred_name_b", "gene2"]:
if candidate in df.columns and not _looks_like_string_protein_id_series(_one_series_network(df, candidate)):
tgt_col = candidate
break
# Last-resort fallback to raw STRING protein IDs only if no gene-symbol columns exist.
if src_col is None:
src_col = _find_column_network(
df,
exact_candidates=["protein1", "receptor_string_id", "string_id_a", "protein1_id"],
contains_any=["protein1", "receptor_string", "string_id_a"]
)
if tgt_col is None:
tgt_col = _find_column_network(
df,
exact_candidates=["protein2", "partner_string_id", "string_id_b", "protein2_id"],
contains_any=["protein2", "partner_string", "string_id_b"]
)
score_col = _find_column_network(
df,
exact_candidates=[
"combined_score", "score", "string_score", "physical_score", "confidence_score",
"experimental_score", "experimental", "database_score", "textmining_score"
],
contains_any=["combined", "score", "confidence"]
)
out = df.copy()
if src_col is not None:
out["ppi_gene_a"] = _clean_gene_label_series(_one_series_network(out, src_col))
else:
out["ppi_gene_a"] = "UNKNOWN_A"
if tgt_col is not None:
out["ppi_gene_b"] = _clean_gene_label_series(_one_series_network(out, tgt_col))
else:
out["ppi_gene_b"] = "UNKNOWN_B"
if score_col is not None:
out["ppi_combined_score"] = pd.to_numeric(_one_series_network(out, score_col), errors="coerce")
else:
out["ppi_combined_score"] = np.arange(len(out), 0, -1, dtype=float)
out["ppi_combined_score"] = out["ppi_combined_score"].fillna(0)
# If any endpoint is still an ENSP ID but mapped columns exist, repair row-wise.
if "receptor_gene" in out.columns:
mask = _one_series_network(out, "ppi_gene_a").astype(str).str.contains(r"^ENSP", regex=True, na=False)
out.loc[mask, "ppi_gene_a"] = _clean_gene_label_series(_one_series_network(out.loc[mask], "receptor_gene"))
if "partner_gene" in out.columns:
mask = _one_series_network(out, "ppi_gene_b").astype(str).str.contains(r"^ENSP", regex=True, na=False)
out.loc[mask, "ppi_gene_b"] = _clean_gene_label_series(_one_series_network(out.loc[mask], "partner_gene"))
# Remove unusable rows after mapping.
bad = {"", "NAN", "NONE", "UNKNOWN_A", "UNKNOWN_B"}
out = out[~out["ppi_gene_a"].isin(bad) & ~out["ppi_gene_b"].isin(bad)].copy()
out = out[out["ppi_gene_a"] != out["ppi_gene_b"]].copy()
out["ppi_edge_label"] = out["ppi_gene_a"].astype(str) + " — " + out["ppi_gene_b"].astype(str)
return out
# Standardized PPI tables used by all network functions.
ppi_summary_std = _standardize_ppi_summary(string_ppi_summary_df)
ppi_edges_std = _standardize_ppi_edges(string_ppi_edges_df)
print("[OK] Standardized PPI summary:", ppi_summary_std.shape)
print("[OK] Standardized PPI edges:", ppi_edges_std.shape)
def get_network_summary_table(top_n=30):
"""
Returns receptor-level STRING/PPI support with HPA+STRING priority fields.
This is network-context evidence only, not peptide-receptor binding evidence.
"""
net = ppi_summary_std.copy()
pri = _drop_duplicate_columns_network(receptor_priority_df)
if net.empty and pri.empty:
return pd.DataFrame({"message": ["No receptor-network table found. Run the HPA+STRING receptor prioritization cell first."]})
if not pri.empty:
pri_gene_col = _find_column_network(
pri,
exact_candidates=["gene_symbol", "gene", "query_gene", "receptor_gene"],
contains_any=["gene"]
)
if pri_gene_col is not None:
pri["gene_key"] = _one_series_network(pri, pri_gene_col).astype(str).str.upper().str.strip()
if not net.empty:
net["gene_key"] = _one_series_network(net, "ppi_gene_symbol").astype(str).str.upper().str.strip()
if not net.empty and not pri.empty and "gene_key" in net.columns and "gene_key" in pri.columns:
merged = pri.merge(net, on="gene_key", how="left", suffixes=("", "_ppi"))
elif not pri.empty:
merged = pri.copy()
if "gene_key" in merged.columns and "ppi_gene_symbol" not in merged.columns:
merged["ppi_gene_symbol"] = merged["gene_key"]
if "ppi_network_support" not in merged.columns:
merged["ppi_network_support"] = 0.0
if "ppi_receptor_panel_edges" not in merged.columns:
merged["ppi_receptor_panel_edges"] = 0.0
else:
merged = net.copy()
merged = _drop_duplicate_columns_network(merged)
if "gene_symbol" not in merged.columns:
if "gene" in merged.columns:
merged["gene_symbol"] = _one_series_network(merged, "gene").astype(str).str.upper().str.strip()
elif "gene_key" in merged.columns:
merged["gene_symbol"] = _one_series_network(merged, "gene_key").astype(str).str.upper().str.strip()
elif "ppi_gene_symbol" in merged.columns:
merged["gene_symbol"] = _one_series_network(merged, "ppi_gene_symbol").astype(str).str.upper().str.strip()
for c in [
"receptor_priority_score", "network_support_score", "ppi_network_support",
"ppi_receptor_panel_edges", "n_positive_evidence_layers", "n_high_confidence_physical_interactors",
"n_receptor_panel_physical_interactors", "n_high_confidence_edges", "n_edges"
]:
if c in merged.columns:
merged[c] = pd.to_numeric(_one_series_network(merged, c), errors="coerce")
sort_cols = [c for c in ["receptor_priority_score", "network_support_score", "ppi_network_support", "ppi_receptor_panel_edges"] if c in merged.columns]
if sort_cols:
merged = merged.sort_values(sort_cols, ascending=False)
keep_cols = [
"gene_symbol", "gene", "receptor", "receptor_family", "receptor_priority_tier",
"receptor_priority_score", "network_support_score", "ppi_network_support",
"ppi_receptor_panel_edges", "n_positive_evidence_layers",
"n_high_confidence_physical_interactors", "n_receptor_panel_physical_interactors",
"n_high_confidence_edges", "n_edges", "tumor_targeting_relevance",
"typical_peptide_or_ligand_context",
]
keep_cols = [c for c in keep_cols if c in merged.columns]
out = merged[keep_cols].head(int(top_n)).copy() if keep_cols else merged.head(int(top_n)).copy()
out["scope_note"] = (
"STRING/PPI support indicates receptor network/module context only; "
"it is not evidence of peptide-receptor binding or nanoparticle targeting."
)
return safe_round_table(out)
def plot_network_support(top_n=25):
tbl = get_network_summary_table(top_n=top_n)
if tbl is None or tbl.empty or "message" in tbl.columns:
return dark_empty_plot("No receptor-network summary available.")
gene_col = "gene_symbol" if "gene_symbol" in tbl.columns else ("ppi_gene_symbol" if "ppi_gene_symbol" in tbl.columns else ("gene" if "gene" in tbl.columns else None))
score_col = None
for c in ["network_support_score", "ppi_network_support", "n_high_confidence_physical_interactors", "n_receptor_panel_physical_interactors", "n_high_confidence_edges", "n_edges"]:
if c in tbl.columns:
score_col = c
break
if gene_col is None or score_col is None:
return dark_empty_plot("Network table lacks gene or support-score columns.")
plot_df = tbl.copy()
plot_df[score_col] = pd.to_numeric(_one_series_network(plot_df, score_col), errors="coerce").fillna(0)
plot_df = plot_df.sort_values(score_col, ascending=False).head(int(top_n))
plot_df = plot_df.sort_values(score_col, ascending=True)
fig, ax = plt.subplots(figsize=(9, max(5, 0.38 * len(plot_df))))
fig.patch.set_facecolor("#0b1120")
ax.barh(plot_df[gene_col].astype(str), plot_df[score_col], color="#22c55e", edgecolor="#166534")
apply_dark_axis(
ax,
title="STRING physical PPI support for receptor candidates",
xlabel=score_col.replace("_", " "),
ylabel="Receptor"
)
return finalize_dark_fig(fig)
def get_interaction_edges_table(receptor_gene="All", min_score=700, top_n=100):
if ppi_edges_std.empty:
return pd.DataFrame({"message": ["No STRING/PPI edge table found. Run STRING physical PPI extraction first."]})
df = ppi_edges_std.copy()
df["ppi_combined_score"] = pd.to_numeric(_one_series_network(df, "ppi_combined_score"), errors="coerce").fillna(0)
df = df[df["ppi_combined_score"] >= float(min_score)].copy()
if receptor_gene and receptor_gene != "All":
rg = str(receptor_gene).upper().strip()
df = df[
_one_series_network(df, "ppi_gene_a").astype(str).str.upper().eq(rg)
| _one_series_network(df, "ppi_gene_b").astype(str).str.upper().eq(rg)
].copy()
if df.empty:
return pd.DataFrame({"message": ["No STRING/PPI edges match the selected filters."]})
df = df.sort_values("ppi_combined_score", ascending=False)
keep_cols = [
"ppi_gene_a", "ppi_gene_b", "ppi_combined_score", "ppi_edge_label",
"protein1", "protein2", "combined_score", "score", "experimental_score",
"experimental", "database_score", "database", "textmining_score", "textmining"
]
keep_cols = [c for c in keep_cols if c in df.columns]
out = df[keep_cols].head(int(top_n)).copy() if keep_cols else df.head(int(top_n)).copy()
out["scope_note"] = "Physical PPI edge context only; not peptide-receptor binding or delivery validation."
return safe_round_table(out)
def _safe_numeric_series_network(df, col, default=0.0):
vals = pd.to_numeric(_one_series_network(df, col, default=default), errors="coerce")
return vals.fillna(default)
def _node_priority_lookup():
"""Build a receptor metadata lookup for PPIN hover labels."""
lookup = {}
pri = _drop_duplicate_columns_network(receptor_priority_df)
tgt = _drop_duplicate_columns_network(target_df)
net = _drop_duplicate_columns_network(ppi_summary_std)
def add_from_df(df, gene_candidates):
if df is None or df.empty:
return
gene_col = _find_column_network(df, exact_candidates=gene_candidates, contains_any=["gene"])
if gene_col is None:
return
for _, row in df.iterrows():
gene = str(row.get(gene_col, "")).upper().strip()
if not gene or gene in ["NAN", "NONE"]:
continue
lookup.setdefault(gene, {})
for c in [
"receptor", "receptor_family", "receptor_priority_tier",
"receptor_priority_score", "targetability_score_0_100",
"network_support_score", "ppi_network_support",
"ppi_receptor_panel_edges", "tumor_targeting_relevance"
]:
if c in df.columns and c not in lookup[gene] and pd.notna(row.get(c, np.nan)):
lookup[gene][c] = row.get(c)
add_from_df(pri, ["gene_symbol", "gene", "query_gene", "receptor_gene"])
add_from_df(tgt, ["gene_symbol", "gene", "query_gene", "receptor_gene"])
add_from_df(net, ["ppi_gene_symbol", "gene_symbol", "gene", "query_gene", "receptor_gene"])
return lookup
def _get_ppin_edges_for_graph(receptor_gene="All", min_score=700, max_edges=75, include_receptor_neighbors=True):
"""
Return a standardized edge subset for the interactive PPIN graph.
If receptor_gene != All, keep one-hop STRING physical edges touching that receptor.
If All, show the top scoring receptor-associated edges.
"""
if ppi_edges_std.empty:
return pd.DataFrame()
df = ppi_edges_std.copy()
df["ppi_combined_score"] = _safe_numeric_series_network(df, "ppi_combined_score", default=0.0)
df["ppi_gene_a"] = _one_series_network(df, "ppi_gene_a", default="UNKNOWN_A").astype(str).str.upper().str.strip()
df["ppi_gene_b"] = _one_series_network(df, "ppi_gene_b", default="UNKNOWN_B").astype(str).str.upper().str.strip()
df = df[(df["ppi_combined_score"] >= float(min_score)) & (df["ppi_gene_a"] != df["ppi_gene_b"])].copy()
if receptor_gene and receptor_gene != "All":
rg = str(receptor_gene).upper().strip()
df = df[(df["ppi_gene_a"].eq(rg)) | (df["ppi_gene_b"].eq(rg))].copy()
if df.empty:
return df
df = df.sort_values("ppi_combined_score", ascending=False).head(int(max_edges)).copy()
df["ppi_edge_label"] = df["ppi_gene_a"] + " — " + df["ppi_gene_b"]
return df.reset_index(drop=True)
def get_ppin_nodes_edges_table(receptor_gene="All", min_score=700, max_edges=75):
"""Return the node table and edge table used by the interactive PPIN graph."""
edges = _get_ppin_edges_for_graph(receptor_gene, min_score, max_edges)
if edges.empty:
return (
pd.DataFrame({"message": ["No PPIN edges match the selected filters."]}),
pd.DataFrame({"message": ["No PPIN edges match the selected filters."]})
)
genes = sorted(set(edges["ppi_gene_a"].dropna().astype(str)) | set(edges["ppi_gene_b"].dropna().astype(str)))
degree_counts = {g: 0 for g in genes}
weighted_degree = {g: 0.0 for g in genes}
for _, r in edges.iterrows():
a, b, s = r["ppi_gene_a"], r["ppi_gene_b"], float(r["ppi_combined_score"])
degree_counts[a] = degree_counts.get(a, 0) + 1
degree_counts[b] = degree_counts.get(b, 0) + 1
weighted_degree[a] = weighted_degree.get(a, 0.0) + s
weighted_degree[b] = weighted_degree.get(b, 0.0) + s
lookup = _node_priority_lookup()
node_rows = []
selected = None if receptor_gene == "All" else str(receptor_gene).upper().strip()
for g in genes:
meta = lookup.get(g, {})
node_rows.append({
"gene_symbol": g,
"degree_in_displayed_network": degree_counts.get(g, 0),
"weighted_degree_in_displayed_network": weighted_degree.get(g, 0.0),
"is_selected_receptor": bool(selected and g == selected),
"receptor": meta.get("receptor", ""),
"receptor_family": meta.get("receptor_family", ""),
"receptor_priority_tier": meta.get("receptor_priority_tier", ""),
"receptor_priority_score": meta.get("receptor_priority_score", np.nan),
"targetability_score_0_100": meta.get("targetability_score_0_100", np.nan),
"network_support_score": meta.get("network_support_score", np.nan),
"ppi_network_support": meta.get("ppi_network_support", np.nan),
"scope_note": "Displayed STRING physical PPI node; network/module context only, not peptide-receptor binding evidence."
})
nodes = pd.DataFrame(node_rows).sort_values(
["is_selected_receptor", "degree_in_displayed_network", "weighted_degree_in_displayed_network"],
ascending=[False, False, False]
).reset_index(drop=True)
edge_keep = ["ppi_gene_a", "ppi_gene_b", "ppi_combined_score", "ppi_edge_label"]
edge_keep = [c for c in edge_keep if c in edges.columns]
edge_out = edges[edge_keep].copy()
edge_out["scope_note"] = "STRING physical PPI edge; network/module context only, not peptide-receptor binding evidence."
return safe_round_table(nodes), safe_round_table(edge_out)
def plot_interactive_ppin_network(receptor_gene="All", min_score=700, max_edges=75):
"""
Interactive Plotly PPIN graph from STRING physical PPI edges.
Nodes are receptor/interactor proteins; edges are STRING physical protein-protein links.
This is network context only, not peptide-receptor binding validation.
"""
if not PLOTLY_AVAILABLE:
return dark_empty_plot("Plotly is not available. Install plotly to use the interactive PPIN graph.")
edges = _get_ppin_edges_for_graph(receptor_gene, min_score, max_edges)
if edges.empty:
return dark_empty_plot("No PPIN edges match the selected receptor and STRING score threshold.")
# Build graph.
edge_records = []
nodes_set = set()
for _, r in edges.iterrows():
a = str(r["ppi_gene_a"]).upper().strip()
b = str(r["ppi_gene_b"]).upper().strip()
s = float(r["ppi_combined_score"])
if not a or not b or a == b:
continue
nodes_set.add(a)
nodes_set.add(b)
edge_records.append((a, b, s))
if not edge_records:
return dark_empty_plot("No valid PPIN edge records after cleaning.")
if NETWORKX_AVAILABLE:
G = nx.Graph()
for a, b, s in edge_records:
G.add_edge(a, b, weight=s)
# Deterministic force-directed layout.
try:
pos = nx.spring_layout(G, seed=42, weight="weight", k=None, iterations=100)
except Exception:
pos = nx.circular_layout(G)
degree_dict = dict(G.degree())
weighted_degree = dict(G.degree(weight="weight"))
else:
nodes = sorted(nodes_set)
angle = np.linspace(0, 2 * np.pi, len(nodes), endpoint=False)
pos = {node: (float(np.cos(a)), float(np.sin(a))) for node, a in zip(nodes, angle)}
degree_dict = {node: 0 for node in nodes}
weighted_degree = {node: 0.0 for node in nodes}
for a, b, s in edge_records:
degree_dict[a] += 1
degree_dict[b] += 1
weighted_degree[a] += s
weighted_degree[b] += s
selected = None if receptor_gene == "All" else str(receptor_gene).upper().strip()
lookup = _node_priority_lookup()
# Edge traces: draw one trace for all edges and use hover via invisible midpoint markers.
edge_x, edge_y = [], []
mid_x, mid_y, mid_text, mid_score = [], [], [], []
for a, b, s in edge_records:
x0, y0 = pos[a]
x1, y1 = pos[b]
edge_x += [x0, x1, None]
edge_y += [y0, y1, None]
mid_x.append((x0 + x1) / 2)
mid_y.append((y0 + y1) / 2)
mid_score.append(s)
mid_text.append(f"{a}{b}<br>STRING combined score: {s:.0f}<br>Physical PPI edge; not peptide-binding evidence")
edge_trace = go.Scatter(
x=edge_x,
y=edge_y,
line=dict(width=1.2, color="rgba(148, 163, 184, 0.55)"),
hoverinfo="none",
mode="lines",
name="STRING physical PPI edges"
)
edge_hover_trace = go.Scatter(
x=mid_x,
y=mid_y,
mode="markers",
marker=dict(
size=np.clip(np.array(mid_score, dtype=float) / 60, 6, 18),
color=mid_score,
colorscale="Blues",
showscale=True,
colorbar=dict(title="STRING score"),
opacity=0.35,
line=dict(width=0)
),
text=mid_text,
hoverinfo="text",
name="edge score"
)
node_x, node_y, node_text, node_size, node_color, node_label = [], [], [], [], [], []
for node in sorted(nodes_set):
x, yv = pos[node]
meta = lookup.get(node, {})
deg = int(degree_dict.get(node, 0))
wdeg = float(weighted_degree.get(node, 0.0))
priority = meta.get("receptor_priority_score", np.nan)
targetability = meta.get("targetability_score_0_100", np.nan)
support = meta.get("network_support_score", meta.get("ppi_network_support", np.nan))
is_selected = bool(selected and node == selected)
size = 16 + 4 * min(deg, 8)
if is_selected:
size += 12
node_size.append(size)
node_color.append(1 if is_selected else min(deg, 10))
node_x.append(x)
node_y.append(yv)
node_label.append(node)
node_text.append(
f"<b>{node}</b>"
f"<br>Displayed degree: {deg}"
f"<br>Weighted degree: {wdeg:.0f}"
f"<br>Receptor: {meta.get('receptor', '')}"
f"<br>Family: {meta.get('receptor_family', '')}"
f"<br>Priority tier: {meta.get('receptor_priority_tier', '')}"
f"<br>Priority score: {priority if pd.notna(priority) else 'NA'}"
f"<br>Targetability score: {targetability if pd.notna(targetability) else 'NA'}"
f"<br>Network support score: {support if pd.notna(support) else 'NA'}"
f"<br><br>Boundary: network/module context only; not peptide-receptor binding evidence."
)
node_trace = go.Scatter(
x=node_x,
y=node_y,
mode="markers+text",
text=node_label,
textposition="top center",
hoverinfo="text",
hovertext=node_text,
marker=dict(
showscale=False,
color=node_color,
colorscale="Viridis",
size=node_size,
line=dict(width=1.2, color="rgba(226, 232, 240, 0.9)")
),
name="proteins / receptors"
)
title = "Interactive STRING physical Protein-Protein Interaction Network"
if receptor_gene and receptor_gene != "All":
title += f" centered on {str(receptor_gene).upper()}"
title += f"<br><sup>Edges shown: {len(edge_records)}; minimum STRING score: {min_score}. Network context only, not binding validation.</sup>"
fig = go.Figure(data=[edge_trace, edge_hover_trace, node_trace])
fig.update_layout(
title=title,
template="plotly_dark",
paper_bgcolor="#0b1120",
plot_bgcolor="#111827",
hovermode="closest",
showlegend=True,
margin=dict(l=10, r=10, t=75, b=10),
height=max(620, min(950, 420 + 4 * len(nodes_set))),
xaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
yaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
)
return fig
def plot_interaction_edges(receptor_gene="All", min_score=700, top_n=30):
edges = get_interaction_edges_table(receptor_gene, min_score, top_n)
if edges is None or edges.empty or "message" in edges.columns:
return dark_empty_plot("No STRING/PPI edges available for the selected receptor.")
if "ppi_gene_a" not in edges.columns or "ppi_gene_b" not in edges.columns or "ppi_combined_score" not in edges.columns:
return dark_empty_plot("STRING/PPI edge table lacks standardized endpoint/score columns.")
labels = _one_series_network(edges, "ppi_gene_a").astype(str) + " — " + _one_series_network(edges, "ppi_gene_b").astype(str)
vals = pd.to_numeric(_one_series_network(edges, "ppi_combined_score"), errors="coerce").fillna(0)
order = np.argsort(vals.values)
labels = labels.iloc[order]
vals = vals.iloc[order]
fig, ax = plt.subplots(figsize=(10, max(5, 0.32 * len(edges))))
fig.patch.set_facecolor("#0b1120")
ax.barh(labels, vals, color="#38bdf8", edgecolor="#0e7490")
ax.set_xlim(0, max(1000, float(vals.max()) * 1.05 if len(vals) else 1000))
apply_dark_axis(
ax,
title=f"High-confidence STRING/PPI edges ({receptor_gene})",
xlabel="STRING combined physical-interaction score",
ylabel="Interaction edge"
)
return finalize_dark_fig(fig)
def get_network_status_markdown():
lines = ["### Protein-interaction / receptor-network status\n"]
lines.append(f"- Receptor PPI summary rows: **{len(ppi_summary_std)}**")
lines.append(f"- STRING/PPI edge rows: **{len(ppi_edges_std)}**")
if string_ppi_status:
network_layer = string_ppi_status.get("network_layer", {})
final_repair = string_ppi_status.get("final_repair", {})
if network_layer:
lines.append(f"- Source: **{network_layer.get('source', 'STRING')}**")
lines.append(f"- PPI type: **{network_layer.get('ppi_type', 'physical protein-protein links')}**")
lines.append(f"- Score threshold: **{network_layer.get('score_threshold', 'combined_score >= 700')}**")
if "total_edges_scanned" in network_layer:
lines.append(f"- Total edges scanned: **{network_layer.get('total_edges_scanned')}**")
if "receptor_edges_kept" in network_layer:
lines.append(f"- Receptor-associated edges kept: **{network_layer.get('receptor_edges_kept')}**")
if "mapped_receptors_to_STRING" in network_layer:
lines.append(f"- Mapped receptors: **{network_layer.get('mapped_receptors_to_STRING')}**")
if final_repair:
lines.append(f"- Final receptor table receptors: **{final_repair.get('n_receptors', 'NA')}**")
lines.append(f"- Receptors with STRING network score: **{final_repair.get('n_receptors_with_string_network_score', 'NA')}**")
lines.append("\n**Boundary:** STRING/PPI support indicates network proximity and module coherence only. It does not validate peptide-receptor binding, nanoparticle uptake, biodistribution, therapeutic efficacy, safety, or clinical actionability.")
return "\n".join(lines)
# ============================================================
# 11. Dashboard backend functions
# ============================================================
def predict_single_visual(seq, top_n=10):
seq = clean_sequence(seq)
if seq is None:
return (
"### Invalid input\nPlease enter a canonical amino-acid peptide sequence with at least 3 residues.",
'<div class="structure-box">Invalid sequence.</div>',
'<div class="sequence-box">Invalid sequence.</div>',
dark_empty_plot("Invalid sequence."),
dark_empty_plot("Invalid sequence."),
pd.DataFrame({"message": ["Invalid sequence."]})
)
discovery_prob, conservative_prob, component_probs = predict_ensemble_probs(seq)
suitability = ligand_suitability_score(seq)
motifs = motif_annotation(seq)
receptors = infer_receptors(seq)
model_label = "Ensemble model" if USE_ENSEMBLE else "OOF k-mer model"
summary = f"""
### Peptide Triage Summary
| Field | Result |
|---|---|
| Input sequence | `{seq}` |
| Active scoring mode | **{model_label}** |
| Sequence model score | **{discovery_prob:.4f}** |
| Sequence-score interpretation | {risk_interpretation(discovery_prob)} |
| Specificity-oriented score | **{conservative_prob:.4f}** |
| Specificity-oriented interpretation | {risk_interpretation(conservative_prob)} |
| Nanocarrier ligand suitability | **{suitability:.2f} / 100** |
| Ligand-suitability interpretation | {suitability_interpretation(suitability)} |
| Detected motifs | {motifs} |
| Motif-supported receptor hypotheses | {", ".join(receptors) if receptors else "No receptor-specific motif detected"} |
**Scope note:** These values are computational triage scores. They are not experimental evidence of receptor binding, nanoparticle delivery, tumor selectivity, pharmacokinetics, safety, or therapeutic efficacy.
"""
structure_html = peptide_structure_svg(seq)
motif_html = motif_highlight_html(seq)
comp_fig = plot_component_probabilities(component_probs)
context_df = build_top_contexts(seq, top_n=top_n)
context_fig = plot_top_contexts(context_df)
return (
summary,
structure_html,
motif_html,
comp_fig,
context_fig,
context_df
)
def explore_recommendations_visual(
cancer_type,
receptor_gene,
min_discovery_prob,
min_score,
top_n,
heatmap_mode
):
bar_fig = plot_recommendation_bar(
cancer_type=cancer_type,
receptor_gene=receptor_gene,
min_discovery_prob=min_discovery_prob,
min_score=min_score,
top_n=top_n,
heatmap_mode=heatmap_mode
)
heatmap_fig = plot_recommendation_heatmap(
cancer_type=cancer_type,
receptor_gene=receptor_gene,
top_n=top_n,
heatmap_mode=heatmap_mode
)
out_df = get_explorer_table(
cancer_type=cancer_type,
receptor_gene=receptor_gene,
min_discovery_prob=min_discovery_prob,
min_score=min_score,
top_n=top_n,
heatmap_mode=heatmap_mode
)
return bar_fig, heatmap_fig, out_df
def batch_predict_visual(file_obj, top_n=5):
if file_obj is None:
return pd.DataFrame({"error": ["Please upload a CSV or Excel file with a sequence column."]}), dark_empty_plot("No file uploaded.")
try:
df = safe_read_uploaded_file(file_obj)
except Exception as e:
return pd.DataFrame({"error": [f"Could not read uploaded file: {e}"]}), dark_empty_plot("Could not read uploaded file.")
df = normalize_columns(df)
seq_col = None
for c in df.columns:
if str(c).strip().lower() in ["sequence", "seq", "peptide", "peptide_sequence"]:
seq_col = c
break
if seq_col is None:
return (
pd.DataFrame({"error": ["No sequence column found. Use sequence, seq, peptide, or peptide_sequence."]}),
dark_empty_plot("No sequence column found.")
)
rows = []
for _, row in df.iterrows():
original = row[seq_col]
seq = clean_sequence(original)
if seq is None:
rows.append({
"input_sequence": original,
"clean_sequence": None,
"valid": False,
"sequence_model_score": np.nan,
"specificity_oriented_score": np.nan,
"ligand_suitability_score": np.nan,
"motif_annotation": "Invalid sequence",
"inferred_receptors": "",
"top_receptor_cancer_contexts": ""
})
continue
discovery_prob, conservative_prob, _ = predict_ensemble_probs(seq)
suitability = ligand_suitability_score(seq)
motifs = motif_annotation(seq)
receptors = infer_receptors(seq)
valid_receptors = [r for r in receptors if r != "NRP1_like_not_in_current_panel"]
top_contexts = []
if valid_receptors and "gene_symbol" in target_df.columns:
target_sub = target_df[target_df["gene_symbol"].astype(str).str.upper().isin(valid_receptors)].copy()
if len(target_sub):
target_sub["custom_recommendation_score"] = (
0.40 * discovery_prob * 100
+ 0.30 * suitability
+ 0.30 * target_sub["targetability_score_0_100"]
)
top_contexts = (
target_sub
.sort_values("custom_recommendation_score", ascending=False)
.head(int(top_n))
.apply(
lambda r: f"{r['gene_symbol']}{r['cancer_type']}({r['custom_recommendation_score']:.1f})",
axis=1
)
.tolist()
)
out = {
"input_sequence": original,
"clean_sequence": seq,
"valid": True,
"sequence_model_score": round(discovery_prob, 4),
"specificity_oriented_score": round(conservative_prob, 4),
"sequence_score_interpretation": risk_interpretation(discovery_prob),
"ligand_suitability_score": round(suitability, 2),
"ligand_suitability_interpretation": suitability_interpretation(suitability),
"motif_annotation": motifs,
"inferred_receptors": ";".join(receptors),
"top_receptor_cancer_contexts": " | ".join(top_contexts)
}
rows.append(out)
out_df = pd.DataFrame(rows)
valid_df = out_df[out_df["valid"] == True].copy()
if valid_df.empty:
return out_df, dark_empty_plot("No valid sequences found.")
fig, ax = plt.subplots(figsize=(8, 5))
fig.patch.set_facecolor("#0b1120")
ax.scatter(
valid_df["sequence_model_score"],
valid_df["ligand_suitability_score"],
s=70,
color="#38bdf8",
edgecolor="#0e7490",
alpha=0.85
)
apply_dark_axis(
ax,
title="Batch triage: sequence score vs ligand suitability",
xlabel="Sequence model score",
ylabel="Ligand suitability score"
)
ax.set_xlim(0, 1)
ax.set_ylim(0, 100)
return safe_round_table(out_df), finalize_dark_fig(fig)
# ============================================================
# 12. Dropdown values and metric labels
# ============================================================
rec_cancers = set(
recommendation_df.get("cancer_type", pd.Series(dtype=str))
.dropna()
.astype(str)
.unique()
.tolist()
)
target_cancers = set(
target_df.get("cancer_type", pd.Series(dtype=str))
.dropna()
.astype(str)
.unique()
.tolist()
)
cancer_options = ["All"] + sorted(rec_cancers.union(target_cancers))
rec_receptors = set(
recommendation_df.get("matched_receptor_gene", pd.Series(dtype=str))
.dropna()
.astype(str)
.unique()
.tolist()
)
target_receptors = set(
target_df.get("gene_symbol", pd.Series(dtype=str))
.dropna()
.astype(str)
.unique()
.tolist()
)
receptor_options = ["All"] + sorted(rec_receptors.union(target_receptors))
print("[OK] Cancer dropdown options:", len(cancer_options))
print("[OK] Receptor dropdown options:", len(receptor_options), receptor_options)
example_peptides = [
["CRGDK"],
["CPNGRC"],
["RGDCTAMID"],
["RGDPAYNGRFL"],
["CDPSRGKNC"],
["WRNTIA"],
["AAAAAAAAAA"],
]
def get_metric_for_model(model_name_contains, metric, fallback="NA"):
try:
perf = performance_df.copy()
if "fold" in perf.columns:
perf = perf[perf["fold"].astype(str) == "overall_oof"]
if "model" in perf.columns:
perf = perf[perf["model"].astype(str).str.contains(model_name_contains, case=False, na=False)]
if len(perf) and metric in perf.columns:
return f"{float(perf.iloc[0][metric]):.4f}"
except Exception:
pass
return fallback
if USE_ENSEMBLE and performance_mode == "ensemble":
disc_label = "Ensemble discovery-weighted"
cons_label = "Ensemble conservative-gated"
disc_auroc = get_metric_for_model("ensemble_discovery_weighted", "AUROC", "0.7036")
disc_auprc = get_metric_for_model("ensemble_discovery_weighted", "AUPRC", "0.6065")
cons_mcc = get_metric_for_model("ensemble_conservative_gated", "MCC", "0.3158")
else:
disc_label = "OOF TF-IDF k-mer logistic"
cons_label = "OOF calibrated linear SVM"
disc_auroc = get_metric_for_model("Logistic", "AUROC", "0.7016")
disc_auprc = get_metric_for_model("Logistic", "AUPRC", "0.5962")
cons_mcc = get_metric_for_model("SVM", "MCC", "0.2627")
ligand_n = f"{len(ligand_df):,}"
rec_n = f"{len(recommendation_df):,}"
target_n = f"{len(target_df):,}"
target_receptor_n = f"{target_df.get('gene_symbol', pd.Series(dtype=str)).nunique():,}"
mode_label = "Ensemble" if USE_ENSEMBLE else "OOF single-model"
# ============================================================
# 13. Dark CSS
# ============================================================
APP_TITLE = "THP-NanoTarget"
APP_SUBTITLE = "A Cheminformatics Framework for Leakage-Aware Benchmarking and Prioritization of Tumor-Homing Peptides"
custom_css = """
.gradio-container {
max-width: 1500px !important;
margin: auto !important;
background: #0b1120 !important;
color: #dbeafe !important;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important;
}
.gradio-container,
.gradio-container * {
color: #dbeafe !important;
}
body,
main,
section,
article,
form,
.block,
.contain,
.wrap,
.panel,
.tabitem,
.tabs,
.accordion,
.accordion-item,
.prose,
.markdown,
.markdown-body {
background: #0b1120 !important;
}
#hero-box {
background: linear-gradient(135deg, #111827 0%, #1e293b 45%, #1d4ed8 100%) !important;
color: #dbeafe !important;
padding: 28px 34px;
border-radius: 24px;
margin-bottom: 18px;
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.65);
border: 1px solid #334155;
}
#hero-box,
#hero-box * {
color: #dbeafe !important;
}
#hero-box h1 {
font-size: 38px;
line-height: 1.1;
margin: 0 0 8px 0;
color: #bfdbfe !important;
font-weight: 850;
letter-spacing: -0.03em;
}
#hero-box h2 {
font-size: 18px;
line-height: 1.4;
margin: 0 0 14px 0;
color: #93c5fd !important;
font-weight: 500;
}
#hero-box p {
font-size: 14px;
color: #cbd5e1 !important;
max-width: 1050px;
margin: 0;
}
.badge-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 16px;
}
.badge {
background: #1e293b !important;
border: 1px solid #475569 !important;
color: #bfdbfe !important;
border-radius: 999px;
padding: 7px 12px;
font-size: 12px;
font-weight: 650;
}
.panel-card,
.structure-box,
.sequence-box {
background: #111827 !important;
color: #dbeafe !important;
border: 1px solid #334155 !important;
border-radius: 18px;
padding: 18px 20px;
box-shadow: 0 10px 28px rgba(2, 6, 23, 0.5);
margin-bottom: 14px;
}
.panel-card *,
.structure-box *,
.sequence-box * {
color: #dbeafe !important;
}
.structure-title {
color: #93c5fd !important;
font-size: 18px;
font-weight: 800;
margin-bottom: 4px;
}
.structure-subtitle {
color: #cbd5e1 !important;
font-size: 13px;
margin-bottom: 10px;
}
.svg-wrap {
background: #0b1120 !important;
border-radius: 14px;
padding: 12px;
overflow-x: auto;
border: 1px solid #334155;
}
.legend-row {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.legend-chip {
display: inline-block;
border: 1px solid #475569;
border-radius: 999px;
padding: 5px 10px;
background: #1e293b;
color: #dbeafe !important;
font-size: 12px;
font-weight: 700;
}
.metric-title,
.section-title {
color: #93c5fd !important;
font-weight: 780;
}
.metric-title {
font-size: 17px;
margin-bottom: 5px;
}
.section-title {
font-size: 20px;
margin-bottom: 6px;
}
.metric-subtitle,
.section-subtitle {
color: #cbd5e1 !important;
font-size: 13px;
margin-bottom: 12px;
}
.metric-value {
font-size: 13px;
color: #e0f2fe !important;
}
.tab-nav,
.tabs > div:first-child,
[role="tablist"] {
background: #020617 !important;
border: 1px solid #334155 !important;
border-radius: 16px !important;
padding: 6px !important;
gap: 6px !important;
}
button[role="tab"],
[role="tab"],
.tab-nav button,
.tabs button {
background: #1e293b !important;
color: #cbd5e1 !important;
border: 1px solid #334155 !important;
border-radius: 12px !important;
font-weight: 750 !important;
padding: 10px 14px !important;
}
button[role="tab"] *,
[role="tab"] *,
.tab-nav button *,
.tabs button * {
color: #cbd5e1 !important;
}
button[role="tab"][aria-selected="true"],
[role="tab"][aria-selected="true"],
.tab-nav button.selected,
.tabs button.selected {
background: #1d4ed8 !important;
color: #dbeafe !important;
border: 1px solid #60a5fa !important;
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.25) !important;
}
button[role="tab"][aria-selected="true"] *,
[role="tab"][aria-selected="true"] *,
.tab-nav button.selected *,
.tabs button.selected * {
color: #dbeafe !important;
}
button[role="tab"]:hover,
[role="tab"]:hover,
.tab-nav button:hover,
.tabs button:hover {
background: #2563eb !important;
color: #dbeafe !important;
border-color: #93c5fd !important;
}
.accordion,
.accordion *,
details,
summary {
background: #111827 !important;
color: #dbeafe !important;
border-color: #334155 !important;
}
summary {
font-weight: 750 !important;
color: #93c5fd !important;
}
input,
textarea,
select,
.input,
.textbox,
.dropdown,
[data-testid="textbox"],
[data-testid="dropdown"] {
background: #020617 !important;
color: #dbeafe !important;
border: 1px solid #475569 !important;
border-radius: 12px !important;
}
input::placeholder,
textarea::placeholder {
color: #94a3b8 !important;
}
ul,
li,
[role="listbox"],
[role="option"] {
background: #020617 !important;
color: #dbeafe !important;
}
input[type="range"] {
accent-color: #60a5fa !important;
}
button {
border-radius: 12px !important;
font-weight: 750 !important;
color: #dbeafe !important;
background: #1e293b !important;
border: 1px solid #475569 !important;
}
button:hover {
background: #2563eb !important;
color: #dbeafe !important;
border-color: #93c5fd !important;
}
table,
thead,
tbody,
tr,
td,
th {
background: #020617 !important;
color: #dbeafe !important;
border-color: #334155 !important;
}
th {
background: #1e293b !important;
color: #93c5fd !important;
font-weight: 800 !important;
}
td {
background: #0f172a !important;
color: #dbeafe !important;
}
.dataframe,
.dataframe *,
.table-wrap,
.table-wrap * {
background: #020617 !important;
color: #dbeafe !important;
border-color: #334155 !important;
}
.result-md,
.result-md *,
.markdown-body,
.markdown-body *,
.prose,
.prose *,
.markdown,
.markdown * {
background: #111827 !important;
color: #dbeafe !important;
}
.result-md {
border: 1px solid #334155 !important;
border-radius: 16px !important;
padding: 14px 16px !important;
}
/* Explicit checkbox styling for Gradio/Hugging Face dark theme.
Without this, the global input styling can make checkbox toggles appear non-clickable or invisible. */
input[type="checkbox"] {
appearance: checkbox !important;
-webkit-appearance: checkbox !important;
width: 18px !important;
height: 18px !important;
min-width: 18px !important;
min-height: 18px !important;
accent-color: #38bdf8 !important;
cursor: pointer !important;
opacity: 1 !important;
}
label:has(input[type="checkbox"]),
#analog_preserve_motifs_checkbox,
#analog_preserve_cysteine_checkbox,
#analog_preserve_motifs_checkbox *,
#analog_preserve_cysteine_checkbox * {
cursor: pointer !important;
}
code,
pre {
color: #fde68a !important;
background: #1e1b4b !important;
border-radius: 4px;
padding: 1px 4px;
}
#footer-note {
text-align: center;
color: #94a3b8 !important;
font-size: 12px;
padding: 18px;
background: #0b1120 !important;
}
a,
a * {
color: #60a5fa !important;
}
"""
# ============================================================
# 13B. Conservative THP-like analog generation functions
# ============================================================
AA_LIST_GENERATOR = list("ACDEFGHIKLMNPQRSTVWY")
SUBSTITUTION_GROUPS_GENERATOR = [
set("AVLIM"), # hydrophobic aliphatic
set("FYW"), # aromatic
set("STNQ"), # polar uncharged
set("KRH"), # positive/basic
set("DE"), # acidic
set("GP"), # conformational
set("C"), # cysteine
]
AA_TO_GROUP_GENERATOR = {}
for _group in SUBSTITUTION_GROUPS_GENERATOR:
for _aa in _group:
AA_TO_GROUP_GENERATOR[_aa] = _group
def parse_seed_sequences(seed_text):
if seed_text is None:
return []
parts = re.split(r"[,;\s]+", str(seed_text).strip())
seqs = []
for p in parts:
s = clean_sequence(p)
if s is not None and s not in seqs:
seqs.append(s)
return seqs
def analog_protected_positions(seq, preserve_motifs=True, preserve_cysteines=False):
seq = clean_sequence(seq)
protected = set()
if seq is None:
return protected
if preserve_motifs:
for motif in ["CNGRC", "CNGR", "NGR", "RGD", "DGR"]:
start = 0
while True:
idx = seq.find(motif, start)
if idx == -1:
break
protected.update(range(idx, idx + len(motif)))
start = idx + 1
cendr = re.search(r"[RK][A-Z]{2}[RK]$", seq)
if cendr:
protected.update(range(cendr.start(), cendr.end()))
if preserve_cysteines:
for i, aa in enumerate(seq):
if aa == "C":
protected.add(i)
return protected
def analog_substitutions(aa, conservative=True):
if conservative:
group = AA_TO_GROUP_GENERATOR.get(aa, set(AA_LIST_GENERATOR))
choices = sorted([x for x in group if x != aa])
if choices:
return choices
return sorted([x for x in AA_LIST_GENERATOR if x != aa])
def mutate_sequence_at(seq, pos, aa):
return seq[:pos] + aa + seq[pos + 1:]
def approximate_edit_distance(a, b):
a = str(a)
b = str(b)
return sum(x != y for x, y in zip(a, b)) + abs(len(a) - len(b))
def generate_analogs_from_seed(seed, n_candidates=100, max_mutations=2, preserve_motifs=True, preserve_cysteines=False, include_terminal_variants=True):
"""Generate conservative THP-like analogs from one seed sequence.
This is a constrained analog generator, not a de novo THP discovery model.
"""
seed = clean_sequence(seed)
if seed is None:
return []
n_candidates = int(max(10, min(int(n_candidates), 500)))
max_mutations = int(max(1, min(int(max_mutations), 3)))
protected = analog_protected_positions(seed, preserve_motifs=preserve_motifs, preserve_cysteines=preserve_cysteines)
mutable_positions = [i for i in range(len(seed)) if i not in protected]
variants = set()
records = []
rng = random.Random(abs(hash((seed, n_candidates, max_mutations, preserve_motifs, preserve_cysteines))) % (2**32))
# Single/double/triple substitution variants.
attempts = 0
while len(variants) < n_candidates and attempts < n_candidates * 80:
attempts += 1
if not mutable_positions:
break
k = rng.randint(1, min(max_mutations, len(mutable_positions)))
positions = rng.sample(mutable_positions, k)
chars = list(seed)
for pos in positions:
conservative = rng.random() < 0.80
choices = analog_substitutions(chars[pos], conservative=conservative)
chars[pos] = rng.choice(choices)
cand = clean_sequence("".join(chars))
if cand and cand != seed:
variants.add((cand, f"{k}_position_motif_aware_substitution"))
# Conservative terminal variants.
if include_terminal_variants:
terminal_aas = ["G", "S", "A", "K", "R", "C", "N", "T"]
for aa in terminal_aas:
variants.add((clean_sequence(aa + seed), "N_terminal_extension"))
variants.add((clean_sequence(seed + aa), "C_terminal_extension"))
if len(seed) > 7:
variants.add((clean_sequence(seed[1:]), "N_terminal_trim"))
variants.add((clean_sequence(seed[:-1]), "C_terminal_trim"))
seen = set()
for cand, op in variants:
if cand is None or cand in seen:
continue
if len(cand) < 5 or len(cand) > 35:
continue
seen.add(cand)
records.append({
"generated_sequence": cand,
"seed_sequence": seed,
"generation_operation": op,
"generated_length": len(cand),
"edit_distance_from_seed_approx": approximate_edit_distance(seed, cand),
"protected_positions_n": len(protected),
})
return records[:n_candidates]
def candidate_rdkit_descriptor_row(seq):
seq = clean_sequence(seq)
row = {"generated_sequence": seq}
row.update(peptide_descriptors(seq) if seq else {})
row["motif_annotation"] = motif_annotation(seq) if seq else "Invalid sequence"
row["motif_rule_score"] = motif_rule_score_for_app(seq) if seq else 0.0
row["ligand_suitability_score"] = ligand_suitability_score(seq) if seq else 0.0
if seq is None or not RDKIT_AVAILABLE:
row.update({
"mol_ok": 0,
"canonical_smiles": None,
"mol_weight": np.nan,
"tpsa": np.nan,
"hbd": np.nan,
"hba": np.nan,
"rotatable_bonds": np.nan,
"logp": np.nan,
"formal_charge": np.nan,
"fraction_csp3": np.nan,
})
return row
mol = peptide_mol_for_app(seq)
if mol is None:
row.update({
"mol_ok": 0,
"canonical_smiles": None,
"mol_weight": np.nan,
"tpsa": np.nan,
"hbd": np.nan,
"hba": np.nan,
"rotatable_bonds": np.nan,
"logp": np.nan,
"formal_charge": np.nan,
"fraction_csp3": np.nan,
})
return row
try:
formal_charge = sum(atom.GetFormalCharge() for atom in mol.GetAtoms())
except Exception:
formal_charge = np.nan
try:
smiles = Chem.MolToSmiles(mol, canonical=True)
except Exception:
smiles = None
row.update({
"mol_ok": 1,
"canonical_smiles": smiles,
"mol_weight": Descriptors.MolWt(mol),
"tpsa": rdMolDescriptors.CalcTPSA(mol),
"hbd": Lipinski.NumHDonors(mol),
"hba": Lipinski.NumHAcceptors(mol),
"rotatable_bonds": Lipinski.NumRotatableBonds(mol),
"logp": Crippen.MolLogP(mol),
"formal_charge": formal_charge,
"fraction_csp3": rdMolDescriptors.CalcFractionCSP3(mol),
})
return row
def build_positive_descriptor_envelope_for_app():
descriptor_cols = ["sequence_length", "mol_weight", "tpsa", "hbd", "hba", "rotatable_bonds", "logp", "formal_charge"]
envelope = {}
if chemi_desc.empty:
return envelope
df = chemi_desc.copy()
if "binary_label_tumor_homing" in df.columns:
df = df[pd.to_numeric(df["binary_label_tumor_homing"], errors="coerce") == 1]
if df.empty:
return envelope
for col in descriptor_cols:
if col in df.columns:
vals = pd.to_numeric(df[col], errors="coerce").dropna()
if len(vals) >= 10:
envelope[col] = (float(vals.quantile(0.025)), float(vals.quantile(0.975)))
return envelope
POSITIVE_DESCRIPTOR_ENVELOPE_APP = build_positive_descriptor_envelope_for_app()
def descriptor_plausibility_for_app(row):
if not POSITIVE_DESCRIPTOR_ENVELOPE_APP:
# Fallback heuristic for app-only use.
length_ok = 5 <= float(row.get("generated_length", row.get("length", 0))) <= 35
mw = row.get("mol_weight", np.nan)
mw_ok = True if pd.isna(mw) else (300 <= float(mw) <= 5000)
return float(np.mean([length_ok, mw_ok]))
checks = []
for col, (lo, hi) in POSITIVE_DESCRIPTOR_ENVELOPE_APP.items():
if col not in row:
continue
try:
val = float(row.get(col))
except Exception:
continue
if pd.isna(val):
continue
checks.append(lo <= val <= hi)
return float(np.mean(checks)) if checks else 0.5
def nearest_similarity_for_candidate(seq, class_filter=None):
seq = clean_sequence(seq)
if seq is None or not RDKIT_AVAILABLE or chemi_sim_meta.empty or chemi_sim_fp is None:
return None, np.nan
mol = peptide_mol_for_app(seq)
if mol is None:
return None, np.nan
qfp = get_morgan_fp_for_app(mol)
qarr = fp_to_numpy_for_app(qfp)
df = chemi_sim_meta.copy().reset_index(drop=True)
fp = chemi_sim_fp.copy()
if class_filter is not None and "cheminfo_class" in df.columns:
mask = df["cheminfo_class"].astype(str).eq(str(class_filter)).values
df = df.loc[mask].reset_index(drop=True)
fp = fp[mask]
if df.empty or fp.shape[0] == 0:
return None, np.nan
sims = []
for i in range(fp.shape[0]):
try:
if "sequence" in df.columns and str(df.iloc[i]["sequence"]) == seq:
continue
sims.append((i, tanimoto_arrays_for_app(qarr, fp[i])))
except Exception:
continue
if not sims:
return None, np.nan
best_i, best_sim = max(sims, key=lambda x: x[1])
return str(df.iloc[best_i].get("sequence", "")), float(best_sim)
def novelty_bucket_for_app(sim):
if pd.isna(sim):
return "unknown"
if sim >= 0.98:
return "duplicate-like ≥0.98"
if sim >= 0.90:
return "near-duplicate 0.90–0.98"
if sim >= 0.70:
return "close analog 0.70–0.90"
if sim >= 0.50:
return "moderate analog 0.50–0.70"
return "more distinct <0.50"
def novelty_score_for_app(sim):
if pd.isna(sim):
return 0.30
if sim >= 0.98:
return 0.05
if sim >= 0.90:
return 0.35
if sim >= 0.70:
return 0.95
if sim >= 0.50:
return 0.75
return 0.40
def hard_negative_safety_for_app(pos_sim, challenge_sim):
if pd.isna(challenge_sim):
return 0.75
if not pd.isna(pos_sim) and challenge_sim > pos_sim + 0.10:
return 0.25
if challenge_sim >= 0.90:
return 0.40
if challenge_sim >= 0.70:
return 0.65
return 0.90
def rank_generated_analogs(seed_text, n_per_seed=80, max_mutations=2, preserve_motifs=True, preserve_cysteines=False, top_n=100):
seeds = parse_seed_sequences(seed_text)
if not seeds:
return (
"### Invalid seed input\nEnter one or more canonical peptide sequences separated by commas, spaces, or new lines.",
pd.DataFrame({"message": ["No valid seed sequences supplied."]}),
dark_empty_plot("No valid seed sequences."),
dark_empty_plot("No valid seed sequences.")
)
all_records = []
for seed in seeds:
all_records.extend(generate_analogs_from_seed(
seed,
n_candidates=int(n_per_seed),
max_mutations=int(max_mutations),
preserve_motifs=bool(preserve_motifs),
preserve_cysteines=bool(preserve_cysteines),
include_terminal_variants=True,
))
if not all_records:
return (
"### No candidates generated\nThe selected constraints may be too strict for the supplied seed sequence(s).",
pd.DataFrame({"message": ["No candidates generated."]}),
dark_empty_plot("No candidates generated."),
dark_empty_plot("No candidates generated.")
)
gen_df = pd.DataFrame(all_records).drop_duplicates("generated_sequence").reset_index(drop=True)
known_sequences_app = set()
if not chemi_sim_meta.empty and "sequence" in chemi_sim_meta.columns:
known_sequences_app = set(chemi_sim_meta["sequence"].dropna().astype(str))
rows = []
for _, base_row in gen_df.iterrows():
seq = base_row["generated_sequence"]
row = base_row.to_dict()
row.update(candidate_rdkit_descriptor_row(seq))
try:
disc_prob, cons_prob, _ = predict_ensemble_probs(seq)
except Exception:
disc_prob, cons_prob = np.nan, np.nan
pos_seq, pos_sim = nearest_similarity_for_candidate(seq, "tumor_homing_positive")
neg_seq, neg_sim = nearest_similarity_for_candidate(seq, "primary_negative")
chal_seq, chal_sim = nearest_similarity_for_candidate(seq, "CancerPPD_hard_negative_challenge")
row["sequence_model_score"] = disc_prob
row["specificity_oriented_score"] = cons_prob
row["descriptor_plausibility_score"] = descriptor_plausibility_for_app(row)
row["nearest_positive_sequence"] = pos_seq
row["nearest_positive_tanimoto"] = pos_sim
row["nearest_primary_negative_sequence"] = neg_seq
row["nearest_primary_negative_tanimoto"] = neg_sim
row["nearest_CancerPPD_sequence"] = chal_seq
row["nearest_CancerPPD_tanimoto"] = chal_sim
row["novelty_bucket"] = novelty_bucket_for_app(pos_sim)
row["novelty_score"] = novelty_score_for_app(pos_sim)
row["hard_negative_safety_score"] = hard_negative_safety_for_app(pos_sim, chal_sim)
row["is_exact_known_sequence"] = int(seq in known_sequences_app)
row["passes_conservative_filter"] = int(
row.get("mol_ok", 0) == 1
and row["is_exact_known_sequence"] == 0
and row["descriptor_plausibility_score"] >= 0.65
and (pd.isna(pos_sim) or pos_sim < 0.98)
)
model_component = disc_prob if not pd.isna(disc_prob) else row["motif_rule_score"]
score = 100 * (
0.40 * float(np.clip(model_component, 0, 1))
+ 0.20 * float(np.clip(row["motif_rule_score"], 0, 1))
+ 0.15 * float(np.clip(row["ligand_suitability_score"] / 100, 0, 1))
+ 0.15 * float(np.clip(row["descriptor_plausibility_score"], 0, 1))
+ 0.07 * float(np.clip(row["novelty_score"], 0, 1))
+ 0.03 * float(np.clip(row["hard_negative_safety_score"], 0, 1))
)
row["analog_prioritization_score_0_100"] = float(np.clip(score, 0, 100))
rows.append(row)
out = pd.DataFrame(rows)
out = out.sort_values(
["passes_conservative_filter", "analog_prioritization_score_0_100", "novelty_score"],
ascending=[False, False, False]
).reset_index(drop=True)
out["rank"] = np.arange(1, len(out) + 1)
# Compact table for dashboard.
keep_cols = [
"rank", "generated_sequence", "seed_sequence", "generation_operation",
"analog_prioritization_score_0_100", "passes_conservative_filter",
"sequence_model_score", "specificity_oriented_score", "motif_rule_score",
"ligand_suitability_score", "descriptor_plausibility_score",
"nearest_positive_tanimoto", "novelty_bucket", "nearest_positive_sequence",
"nearest_CancerPPD_tanimoto", "nearest_CancerPPD_sequence",
"edit_distance_from_seed_approx", "generated_length", "canonical_smiles"
]
keep_cols = [c for c in keep_cols if c in out.columns]
display_df = safe_round_table(out[keep_cols].head(int(top_n)))
n_total = len(out)
n_pass = int(out["passes_conservative_filter"].sum()) if "passes_conservative_filter" in out.columns else 0
n_exact = int(out["is_exact_known_sequence"].sum()) if "is_exact_known_sequence" in out.columns else 0
median_sim = pd.to_numeric(out["nearest_positive_tanimoto"], errors="coerce").median() if "nearest_positive_tanimoto" in out.columns else np.nan
median_score = pd.to_numeric(out["analog_prioritization_score_0_100"], errors="coerce").median() if "analog_prioritization_score_0_100" in out.columns else np.nan
summary = f"""
### Conservative analog generation summary
| Field | Result |
|---|---:|
| Valid seed sequences | {len(seeds)} |
| Unique generated analogs | {n_total} |
| Passed conservative filters | {n_pass} |
| Exact known sequences flagged | {n_exact} |
| Median nearest-positive Tanimoto | {median_sim:.3f} |
| Median analog-prioritization score | {median_score:.2f} |
**Interpretation boundary:** These are computationally generated THP-like analog candidates. They are not experimentally validated THPs and should not be interpreted as evidence of receptor binding, tumor delivery, safety, efficacy, or clinical actionability.
"""
return summary, display_df, plot_generated_analog_scores(out), plot_generated_analog_similarity(out)
def plot_generated_analog_scores(df):
if df is None or df.empty or "analog_prioritization_score_0_100" not in df.columns:
return dark_empty_plot("No generated analog scores available.")
fig, ax = plt.subplots(figsize=(8, 5))
fig.patch.set_facecolor("#0b1120")
ax.hist(pd.to_numeric(df["analog_prioritization_score_0_100"], errors="coerce").dropna(), bins=30, color="#38bdf8", edgecolor="#0e7490")
apply_dark_axis(ax, title="Generated analog prioritization scores", xlabel="Score (0–100)", ylabel="Number of candidates")
return finalize_dark_fig(fig)
def plot_generated_analog_similarity(df):
if df is None or df.empty or "nearest_positive_tanimoto" not in df.columns:
return dark_empty_plot("No generated analog similarity audit available.")
fig, ax = plt.subplots(figsize=(8, 5))
fig.patch.set_facecolor("#0b1120")
x = pd.to_numeric(df["nearest_positive_tanimoto"], errors="coerce")
y = pd.to_numeric(df["analog_prioritization_score_0_100"], errors="coerce")
ax.scatter(x, y, s=28, alpha=0.70, color="#a78bfa")
ax.axvline(0.70, linestyle="--", color="#38bdf8", linewidth=1.5)
ax.axvline(0.90, linestyle="--", color="#f59e0b", linewidth=1.5)
ax.axvline(0.98, linestyle="--", color="#ef4444", linewidth=1.5)
apply_dark_axis(ax, title="Generated analog novelty audit", xlabel="Nearest known-positive Tanimoto", ylabel="Analog prioritization score")
return finalize_dark_fig(fig)
# ============================================================
# 14. Build visual Gradio app
# ============================================================
try:
app_theme = gr.themes.Soft(
primary_hue="blue",
secondary_hue="slate",
neutral_hue="slate",
font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"],
)
except Exception:
app_theme = gr.themes.Soft()
with gr.Blocks(css=custom_css, theme=app_theme, title="THP-NanoTarget") as demo:
gr.HTML(
f"""
<div id="hero-box">
<h1>{APP_TITLE}</h1>
<h2>{APP_SUBTITLE}</h2>
<p>
A public-data research dashboard integrating leakage-aware peptide sequence scoring,
ensemble-based candidate prioritization, peptide ligand visualization, nanocarrier-ligand suitability,
motif-supported receptor hypotheses, HPA-derived receptor–cancer targetability evidence, and validation summaries.
</p>
<div class="badge-row">
<div class="badge">{mode_label} sequence scoring</div>
<div class="badge">Peptide structure visualization</div>
<div class="badge">Bioactive hard-negative challenge</div>
<div class="badge">Full HPA receptor atlas: {target_receptor_n} receptors</div>
<div class="badge">Leakage-aware validation views</div>
<div class="badge">Research use only</div>
</div>
</div>
"""
)
with gr.Row():
gr.HTML(
f"""
<div class="panel-card">
<div class="metric-title">Sequence scoring model</div>
<div class="metric-subtitle">{disc_label}</div>
<div class="metric-value">
<b>AUROC:</b> {disc_auroc}<br>
<b>AUPRC:</b> {disc_auprc}<br>
<b>Use:</b> exploratory candidate triage
</div>
</div>
"""
)
gr.HTML(
f"""
<div class="panel-card">
<div class="metric-title">Specificity-oriented model</div>
<div class="metric-subtitle">{cons_label}</div>
<div class="metric-value">
<b>MCC:</b> {cons_mcc}<br>
<b>Use:</b> specificity-oriented triage<br>
<b>Role:</b> reduces over-prioritization of bioactive hard negatives
</div>
</div>
"""
)
gr.HTML(
f"""
<div class="panel-card">
<div class="metric-title">Data resources</div>
<div class="metric-subtitle">Candidate-context matrix + HPA targetability atlas</div>
<div class="metric-value">
<b>Ligands:</b> {ligand_n}<br>
<b>Candidate-context rows:</b> {rec_n}<br>
<b>Targetability rows:</b> {target_n}<br>
<b>HPA receptor targets:</b> {target_receptor_n}
</div>
</div>
"""
)
with gr.Tabs():
# ----------------------------------------------------
# Single peptide triage
# ----------------------------------------------------
with gr.Tab("Single Peptide Triage"):
with gr.Row(equal_height=True):
with gr.Column(scale=1, min_width=360):
gr.HTML(
"""
<div class="panel-card">
<div class="section-title">Input peptide</div>
<div class="section-subtitle">
Enter a canonical amino-acid peptide sequence. Structure rendering supports readable 2D display for peptides up to ~45 residues when RDKit is available.
</div>
</div>
"""
)
seq_input = gr.Textbox(
label="Peptide sequence",
placeholder="Example: CRGDK",
value="CRGDK",
lines=1,
max_lines=1
)
top_n_input = gr.Slider(
label="Number of receptor–cancer contexts to display",
minimum=3,
maximum=30,
step=1,
value=10
)
with gr.Row():
predict_btn = gr.Button("Run Peptide Triage", variant="primary")
reset_btn = gr.Button("Reset", variant="secondary")
gr.Examples(
examples=example_peptides,
inputs=[seq_input],
label="Example peptide candidates"
)
with gr.Column(scale=2):
summary_md = gr.Markdown(
value="Run a peptide triage query to view the sequence score, ligand features, and context evidence.",
elem_classes=["result-md"]
)
with gr.Row():
with gr.Column(scale=1):
structure_html = gr.HTML()
with gr.Column(scale=1):
motif_html = gr.HTML()
with gr.Row():
component_plot = gr.Plot(label="Sequence-score model components")
context_plot = gr.Plot(label="Prioritized receptor–cancer contexts")
with gr.Accordion("Receptor–cancer context evidence table", open=False):
context_table = gr.Dataframe(interactive=False, wrap=True)
predict_btn.click(
fn=predict_single_visual,
inputs=[seq_input, top_n_input],
outputs=[
summary_md,
structure_html,
motif_html,
component_plot,
context_plot,
context_table
]
)
reset_btn.click(
fn=lambda: (
"Run a peptide triage query to view the sequence score, ligand features, and context evidence.",
"",
"",
dark_empty_plot("Run a peptide triage query."),
dark_empty_plot("Run a peptide triage query."),
pd.DataFrame()
),
inputs=[],
outputs=[
summary_md,
structure_html,
motif_html,
component_plot,
context_plot,
context_table
]
)
# ----------------------------------------------------
# Conservative Analog Generator
# ----------------------------------------------------
with gr.Tab("Analog Generator"):
gr.HTML(
"""
<div class="panel-card">
<div class="section-title">Conservative THP-like analog generator</div>
<div class="section-subtitle">
Generate motif-aware peptide analogs from one or more seed sequences, then rank them using the sequence model score, motif-rule score, RDKit descriptor plausibility, nearest-neighbor novelty audit, and CancerPPD hard-negative similarity. This module supports computational analog exploration only; it does not validate new THPs.
</div>
</div>
"""
)
with gr.Row(equal_height=True):
with gr.Column(scale=1, min_width=360):
analog_seed_input = gr.Textbox(
label="Seed peptide sequence(s)",
value="CRGDK, CNGRC, CDPSRGKNC",
placeholder="Enter one or more sequences separated by commas, spaces, or new lines",
lines=4,
max_lines=8,
)
analog_n_per_seed = gr.Slider(
label="Maximum analogs per seed",
minimum=20,
maximum=300,
step=20,
value=80,
)
analog_max_mutations = gr.Slider(
label="Maximum substitutions per analog",
minimum=1,
maximum=3,
step=1,
value=2,
)
analog_top_n = gr.Slider(
label="Top ranked analogs to display",
minimum=20,
maximum=200,
step=20,
value=100,
)
preserve_motifs_box = gr.Checkbox(
label="Preserve recognized motifs such as RGD, NGR/CNGR, CNGRC, and CendR-like termini",
value=True,
interactive=True,
elem_id="analog_preserve_motifs_checkbox",
)
preserve_cys_box = gr.Checkbox(
label="Preserve cysteine positions for stricter cyclic-like analog design",
value=False,
interactive=True,
elem_id="analog_preserve_cysteine_checkbox",
)
analog_generate_btn = gr.Button("Generate and Rank Analogs", variant="primary")
with gr.Column(scale=2):
analog_summary_md = gr.Markdown(
value="Generate motif-aware analogs to view conservative prioritization and novelty-audit results.",
elem_classes=["result-md"],
)
with gr.Row():
analog_score_plot = gr.Plot(label="Generated analog score distribution")
analog_similarity_plot = gr.Plot(label="Nearest-positive novelty audit")
analog_ranked_table = gr.Dataframe(
label="Ranked generated analog candidates",
interactive=False,
wrap=True,
)
analog_generate_btn.click(
fn=rank_generated_analogs,
inputs=[
analog_seed_input,
analog_n_per_seed,
analog_max_mutations,
preserve_motifs_box,
preserve_cys_box,
analog_top_n,
],
outputs=[
analog_summary_md,
analog_ranked_table,
analog_score_plot,
analog_similarity_plot,
],
)
# ----------------------------------------------------
# Cheminformatics Space
# ----------------------------------------------------
with gr.Tab("Cheminformatics Space"):
gr.HTML(
"""
<div class="panel-card">
<div class="section-title">Cheminformatics representation and similarity search</div>
<div class="section-subtitle">
Explore RDKit-derived peptide descriptors, Morgan/ECFP chemical-space maps, Tanimoto nearest neighbors, and motif-rule baseline comparisons. These are computational representations generated from canonical peptide sequences, not experimental conformations or binding measurements.
</div>
</div>
"""
)
with gr.Row():
with gr.Column(scale=1, min_width=360):
chemi_seq_input = gr.Textbox(
label="Peptide sequence for descriptor and similarity search",
value="CRGDK",
placeholder="Example: CRGDK",
lines=1,
max_lines=1,
)
chemi_top_k = gr.Slider(
label="Top-k chemically similar peptides",
minimum=5,
maximum=50,
step=5,
value=20,
)
sim_class_options = ["All"]
if not chemi_sim_meta.empty and "cheminfo_class" in chemi_sim_meta.columns:
sim_class_options += sorted(chemi_sim_meta["cheminfo_class"].dropna().astype(str).unique().tolist())
chemi_class_filter = gr.Dropdown(
label="Similarity-search class filter",
choices=sim_class_options,
value="All",
)
chemi_search_btn = gr.Button("Run Cheminformatics Search", variant="primary")
with gr.Column(scale=2):
chemi_descriptor_table = gr.Dataframe(
label="Query peptide RDKit descriptors",
interactive=False,
wrap=True,
)
with gr.Row():
chemi_similarity_plot = gr.Plot(label="Morgan/ECFP nearest-neighbor plot")
chemi_similarity_table = gr.Dataframe(
label="Nearest-neighbor similarity results",
interactive=False,
wrap=True,
)
chemi_search_btn.click(
fn=run_similarity_search_visual,
inputs=[chemi_seq_input, chemi_top_k, chemi_class_filter],
outputs=[chemi_similarity_table, chemi_similarity_plot, chemi_descriptor_table],
)
with gr.Accordion("Chemical-space and descriptor visualizations", open=True):
with gr.Row():
color_choices = ["cheminfo_class", "motif_rule_score"]
for c in ["sequence_model_score", "ensemble_preferred_prob", "oof_prob_discovery_model", "mol_weight", "tpsa", "logp"]:
if c in chemi_embedding.columns and c not in color_choices:
color_choices.append(c)
chemi_color_dropdown = gr.Dropdown(
label="Color chemical-space plot by",
choices=color_choices,
value="cheminfo_class",
)
desc_choices = [c for c in ["mol_weight", "tpsa", "hbd", "hba", "rotatable_bonds", "logp", "sequence_length", "motif_rule_score"] if c in chemi_desc.columns]
chemi_desc_dropdown = gr.Dropdown(
label="Descriptor distribution",
choices=desc_choices or ["mol_weight"],
value=(desc_choices[0] if desc_choices else "mol_weight"),
)
chemi_plot_btn = gr.Button("Update Cheminformatics Plots", variant="secondary")
with gr.Row():
chemi_space_plot = gr.Plot(value=plot_chemical_space_app("cheminfo_class"), label="Chemical-space map")
chemi_desc_plot = gr.Plot(value=plot_descriptor_distribution_app(desc_choices[0] if desc_choices else "mol_weight"), label="Descriptor distribution")
chemi_plot_btn.click(
fn=lambda color_by, descriptor: (plot_chemical_space_app(color_by), plot_descriptor_distribution_app(descriptor)),
inputs=[chemi_color_dropdown, chemi_desc_dropdown],
outputs=[chemi_space_plot, chemi_desc_plot],
)
with gr.Accordion("Motif-rule baseline versus ML models", open=True):
motif_rule_plot = gr.Plot(value=plot_motif_rule_baseline_app(), label="Motif-rule baseline comparison")
motif_rule_table = gr.Dataframe(
value=safe_round_table(chemi_motif_baseline),
label="Motif-rule baseline table",
interactive=False,
wrap=True,
)
# ----------------------------------------------------
# Protein Interaction / Receptor Network
# ----------------------------------------------------
with gr.Tab("Protein Interaction Network"):
gr.HTML(
"""
<div class="panel-card">
<div class="section-title">STRING physical PPI receptor-network layer</div>
<div class="section-subtitle">
Explore receptor-level STRING physical PPI support and HPA+STRING receptor-priority context.
This is network/module evidence only and is not peptide-receptor binding validation.
</div>
</div>
"""
)
network_status_md = gr.Markdown(
value=get_network_status_markdown(),
elem_classes=["result-md"]
)
with gr.Row():
network_top_n = gr.Slider(
label="Number of receptors/edges to show",
minimum=5,
maximum=100,
step=5,
value=30
)
network_receptor = gr.Dropdown(
label="Receptor for edge filtering",
choices=["All"] + sorted(
set(
list(target_df.get("gene_symbol", pd.Series(dtype=str)).dropna().astype(str).unique())
+ list(receptor_priority_df.get("gene", pd.Series(dtype=str)).dropna().astype(str).unique())
+ list(receptor_priority_df.get("gene_symbol", pd.Series(dtype=str)).dropna().astype(str).unique())
)
),
value="All"
)
network_min_score = gr.Slider(
label="Minimum STRING combined score",
minimum=0,
maximum=1000,
step=50,
value=700
)
network_btn = gr.Button("Update Network Evidence", variant="primary")
interactive_ppin_plot = gr.Plot(
value=plot_interactive_ppin_network("All", 700, 75),
label="Interactive Protein-Protein Interaction Network (PPIN)"
)
with gr.Row():
network_plot = gr.Plot(
value=plot_network_support(30),
label="Receptor-level network support"
)
edge_plot = gr.Plot(
value=plot_interaction_edges("All", 700, 30),
label="High-confidence STRING/PPI edges"
)
with gr.Accordion("PPIN nodes and displayed edges", open=False):
ppin_nodes_table, ppin_edges_table_initial = get_ppin_nodes_edges_table("All", 700, 75)
ppin_nodes_table_component = gr.Dataframe(
value=ppin_nodes_table,
label="Displayed PPIN nodes",
interactive=False,
wrap=True
)
ppin_edges_table_component = gr.Dataframe(
value=ppin_edges_table_initial,
label="Displayed PPIN edges",
interactive=False,
wrap=True
)
with gr.Accordion("Receptor network-support summary", open=True):
network_table = gr.Dataframe(
value=get_network_summary_table(30),
interactive=False,
wrap=True
)
with gr.Accordion("STRING physical PPI edges", open=False):
edge_table = gr.Dataframe(
value=get_interaction_edges_table("All", 700, 100),
interactive=False,
wrap=True
)
def update_network_evidence(top_n, receptor_gene, min_score):
ppin_nodes, ppin_edges = get_ppin_nodes_edges_table(receptor_gene, min_score, max(25, int(top_n)))
return (
plot_interactive_ppin_network(receptor_gene, min_score, max(25, int(top_n))),
plot_network_support(top_n),
plot_interaction_edges(receptor_gene, min_score, top_n),
ppin_nodes,
ppin_edges,
get_network_summary_table(top_n),
get_interaction_edges_table(receptor_gene, min_score, 100),
)
network_btn.click(
fn=update_network_evidence,
inputs=[network_top_n, network_receptor, network_min_score],
outputs=[
interactive_ppin_plot,
network_plot,
edge_plot,
ppin_nodes_table_component,
ppin_edges_table_component,
network_table,
edge_table,
],
)
# ----------------------------------------------------
# Recommendation Explorer
# ----------------------------------------------------
with gr.Tab("Recommendation Explorer"):
gr.HTML(
"""
<div class="panel-card">
<div class="section-title">Candidate-context explorer</div>
<div class="section-subtitle">
Use <b>Full receptor targetability atlas</b> to inspect HPA-derived receptor–cancer contexts. Use <b>Motif-supported peptide contexts</b> only when you specifically want peptide-to-receptor mappings supported by motif rules or metadata.
</div>
</div>
"""
)
with gr.Row():
cancer_dropdown = gr.Dropdown(
label="Cancer type",
choices=cancer_options,
value="All"
)
receptor_dropdown = gr.Dropdown(
label="Receptor / target gene",
choices=receptor_options,
value="All"
)
heatmap_mode_dropdown = gr.Dropdown(
label="Evidence view",
choices=[
"Full receptor targetability atlas",
"Motif-supported peptide contexts"
],
value="Full receptor targetability atlas"
)
with gr.Row():
min_prob_slider = gr.Slider(
label="Minimum sequence-model score for motif-supported mode",
minimum=0.0,
maximum=1.0,
step=0.01,
value=0.50
)
min_score_slider = gr.Slider(
label="Minimum candidate-prioritization score for motif-supported mode",
minimum=0,
maximum=100,
step=1,
value=60
)
explorer_top_n = gr.Slider(
label="Top receptors, cancers, or candidate rows to display",
minimum=5,
maximum=40,
step=5,
value=20
)
explorer_btn = gr.Button("Generate Candidate Contexts", variant="primary")
with gr.Row():
rec_bar_plot = gr.Plot(label="Top prioritized contexts")
rec_heatmap_plot = gr.Plot(label="Receptor–cancer evidence heatmap")
with gr.Accordion("Displayed data", open=False):
explorer_table = gr.Dataframe(interactive=False, wrap=True)
explorer_btn.click(
fn=explore_recommendations_visual,
inputs=[
cancer_dropdown,
receptor_dropdown,
min_prob_slider,
min_score_slider,
explorer_top_n,
heatmap_mode_dropdown
],
outputs=[rec_bar_plot, rec_heatmap_plot, explorer_table]
)
# ----------------------------------------------------
# Batch Triage
# ----------------------------------------------------
with gr.Tab("Batch Triage"):
gr.HTML(
"""
<div class="panel-card">
<div class="section-title">Batch peptide triage</div>
<div class="section-subtitle">
Upload a CSV or Excel file containing a peptide column named sequence, seq, peptide, or peptide_sequence. The output is intended for computational triage, not experimental confirmation.
</div>
</div>
"""
)
with gr.Row():
batch_file = gr.File(
label="Upload CSV/XLSX file",
file_types=[".csv", ".xlsx", ".xls"]
)
batch_top_n = gr.Slider(
label="Top cancer contexts per peptide",
minimum=1,
maximum=10,
step=1,
value=5
)
batch_btn = gr.Button("Run Batch Triage", variant="primary")
batch_plot = gr.Plot(label="Batch sequence score vs ligand suitability")
batch_output = gr.Dataframe(label="Batch triage output", interactive=False, wrap=True)
batch_btn.click(
fn=batch_predict_visual,
inputs=[batch_file, batch_top_n],
outputs=[batch_output, batch_plot]
)
# ----------------------------------------------------
# Model Evidence
# ----------------------------------------------------
with gr.Tab("Model & Validation Evidence"):
gr.HTML(
"""
<div class="panel-card">
<div class="section-title">Model evidence plots</div>
<div class="section-subtitle">
Visual summary of leakage-aware model performance, bioactive hard-negative behavior, and threshold calibration.
</div>
</div>
"""
)
with gr.Row():
perf_plot = gr.Plot(value=plot_model_performance(), label="Model performance")
challenge_plot = gr.Plot(value=plot_challenge_summary(), label="Bioactive hard-negative challenge")
threshold_plot = gr.Plot(value=plot_threshold_calibration(), label="Threshold calibration")
# ----------------------------------------------------
# Level 1
# ----------------------------------------------------
with gr.Tab("Level 1: Computational Validity"):
gr.HTML(
"""
<div class="panel-card">
<div class="section-title">Level 1 computational validity</div>
<div class="section-subtitle">
Checks whether model performance reflects reproducible sequence-dependent signal rather than random labels, motif artifacts, or negative-control artifacts.
</div>
</div>
"""
)
with gr.Row():
gr.Plot(value=plot_permutation_control(), label="Permutation control")
gr.Plot(value=plot_motif_ablation(), label="Motif ablation")
with gr.Row():
gr.Plot(value=plot_length_stratified(), label="Length-stratified AUROC")
gr.Plot(value=plot_negative_type(), label="Negative-type-specific AUROC")
with gr.Accordion("Level 1 summary tables", open=False):
gr.Dataframe(value=safe_round_table(val1_perm_summary), label="Permutation summary", interactive=False, wrap=True)
gr.Dataframe(value=safe_round_table(val1_motif_ablation), label="Motif-ablation summary", interactive=False, wrap=True)
gr.Dataframe(value=safe_round_table(val1_threshold), label="Threshold operating points", interactive=False, wrap=True)
# ----------------------------------------------------
# Level 2
# ----------------------------------------------------
with gr.Tab("Level 2: Literature Overlap / Family Validation"):
gr.HTML(
"""
<div class="panel-card">
<div class="section-title">Level 2 external and family-level validation</div>
<div class="section-subtitle">
Strict independent literature-positive validation is constrained because many canonical tumor-homing peptides are already represented in public peptide databases. Family-holdout validation is therefore presented as a pseudo-external generalization stress test.
</div>
</div>
"""
)
gr.Plot(value=plot_family_holdout(), label="Leave-family-out validation")
with gr.Accordion("Literature and family validation tables", open=False):
gr.Dataframe(value=safe_round_table(val2_metrics), label="Literature case-study metrics", interactive=False, wrap=True)
gr.Dataframe(value=safe_round_table(val2_strict_candidates), label="Strict external candidate check", interactive=False, wrap=True)
gr.Dataframe(value=safe_round_table(val2_strict_metrics), label="Strict external validation metrics", interactive=False, wrap=True)
gr.Dataframe(value=safe_round_table(family_holdout_metrics), label="Family-holdout metrics", interactive=False, wrap=True)
# ----------------------------------------------------
# Level 3
# ----------------------------------------------------
with gr.Tab("Level 3: Biological Plausibility"):
gr.HTML(
"""
<div class="panel-card">
<div class="section-title">Level 3 biological plausibility checks</div>
<div class="section-subtitle">
Tests whether high-scoring peptide candidates are enriched for known homing-associated motifs and whether prioritized contexts align with receptor/cancer targetability evidence.
</div>
</div>
"""
)
with gr.Row():
gr.Plot(value=plot_level3_motif_enrichment(), label="Motif enrichment")
gr.Plot(value=plot_level3_targetability(), label="Targetability plausibility")
with gr.Accordion("Top biological plausibility summaries", open=False):
gr.Dataframe(value=safe_round_table(val3_receptor), label="Top receptor-context consistency", interactive=False, wrap=True)
gr.Dataframe(value=safe_round_table(val3_cancer), label="Top cancer-context consistency", interactive=False, wrap=True)
gr.Dataframe(value=safe_round_table(val3_peptide), label="Top peptide-candidate consistency", interactive=False, wrap=True)
gr.Dataframe(value=safe_round_table(val3_top100), label="Top 100 recommendations", interactive=False, wrap=True)
# ----------------------------------------------------
# Method Notes
# ----------------------------------------------------
with gr.Tab("Method Notes"):
gr.HTML(
"""
<div class="panel-card">
<div class="section-title">Scope and evidence boundaries</div>
<div class="section-subtitle">
THP-NanoTarget is a computational triage and hypothesis-generation framework. It is not an experimental validation platform and should not be interpreted as evidence of binding, delivery, or efficacy.
</div>
</div>
"""
)
gr.Markdown(
"""
## Why the full atlas view shows more receptors
The dashboard has two visualization modes:
| Mode | Data source | Meaning |
|---|---|---|
| Full receptor targetability atlas | HPA-derived receptor targetability table | Shows all receptor–cancer targetability pairs available in the receptor atlas |
| Motif-supported peptide contexts | Candidate-context matrix | Shows only receptors supported by peptide motifs or metadata |
If the motif-supported view shows mostly ITGAV, ITGB3, and ANPEP, that is expected because the current receptor-mapping rules map RGD/DGR motifs to integrins and NGR/CNGR motifs to ANPEP/CD13.
## Evidence boundaries
This tool does not prove receptor binding, nanoparticle uptake, tumor selectivity, pharmacokinetics, safety, therapeutic efficacy, or clinical relevance.
The appropriate use is computational triage, candidate triage, analog exploration, and hypothesis generation for future experimental validation.
## Protein-interaction network layer
The Protein Interaction Network tab uses STRING physical protein-protein interaction outputs from the HPA+STRING receptor-prioritization workflow. This layer supports receptor network/module context and receptor-priority interpretation only. It must not be described as peptide-receptor binding, nanoparticle uptake, biodistribution, therapeutic efficacy, safety, or clinical actionability.
## Conservative analog generator
The analog-generation tab creates motif-aware THP-like sequence analogs from user-supplied seed peptides. It ranks candidates using model score, motif-rule score, RDKit descriptor plausibility, nearest-positive novelty audit, and hard-negative similarity checks.
Generated analogs should be described as **computational THP-like analog candidates**, not as newly discovered or experimentally validated tumor-homing peptides.
"""
)
gr.HTML(
"""
<div id="footer-note">
THP-NanoTarget | Public-data peptide cheminformatics and computational triage tool | Developed by Ashutosh Tiwari, Taipei Medical University | Research use only | Requires experimental validation
</div>
"""
)
# ============================================================
# 15. Launch dashboard
# ============================================================
demo.queue()
demo.launch()