Spaces:
Running
Running
| # ============================================================ | |
| # 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() |