Download app.py from richiam/ProtoPure: direct link, hf CLI and curl.
- Browser
- Download file 174 kB
-
https://huggingface.co/spaces/richiam/ProtoPure/resolve/main/app.py
- Command line
-
hf download hf://spaces/richiam/ProtoPure/app.py
-
curl -L -o app.py https://huggingface.co/spaces/richiam/ProtoPure/resolve/main/app.py
174 kB
| import json | |
| import math | |
| import os | |
| import re | |
| import glob | |
| import pandas as pd | |
| import plotly.graph_objects as go | |
| from plotly.subplots import make_subplots | |
| import dash | |
| from dash import dcc, html, dash_table, Input, Output, State | |
| import dash_bootstrap_components as dbc | |
| from flask import send_from_directory, abort | |
| # --------------------------------------------------------------------------- | |
| # Paths | |
| # --------------------------------------------------------------------------- | |
| BASE_DIR = os.path.dirname(os.path.abspath(__file__)) | |
| _JSON_CANDIDATES = [ | |
| os.path.join(BASE_DIR, "data", "proteins_with_uniprot.json.gz"), | |
| os.path.join(BASE_DIR, "data", "proteins.json.gz"), | |
| os.path.join(BASE_DIR, "data", "proteins.json"), | |
| ] | |
| JSON_PATH = next((p for p in _JSON_CANDIDATES if os.path.isfile(p)), _JSON_CANDIDATES[1]) | |
| METADATA_PATH = os.path.join(BASE_DIR, "data", "metadata.json") | |
| GRID_BASE = os.path.join(BASE_DIR, "data", "clustering") | |
| # Per-protein UniProt mapping (proteins_with_uniprot.json.gz) → flat columns | |
| UNIPROT_MAP_COLS = { | |
| "accession": "up_accession", | |
| "protein_name": "up_protein_name", | |
| "gene": "up_gene", | |
| "organism": "up_organism", | |
| "ec_number": "up_ec_number", | |
| "localization": "up_localization", | |
| "subcellular_location_raw": "up_location_raw", | |
| "signal_peptide": "up_signal_peptide", | |
| "transmembrane": "up_transmembrane", | |
| "match_status": "up_match_status", | |
| "protein_family": "up_family", | |
| "length": "up_length", | |
| "mass": "up_mass", | |
| "lineage": "up_lineage", | |
| } | |
| # list-valued keys in the same block, joined for display and substring search | |
| UNIPROT_LIST_COLS = { | |
| "keywords": ("up_keywords", "; "), | |
| "cofactors": ("up_cofactors", ", "), | |
| "ec_class": ("up_ec_class", ", "), | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Load protein data (includes PMID-level UniProt metadata) | |
| # --------------------------------------------------------------------------- | |
| def load_data(): | |
| import gzip as _gzip | |
| _open = _gzip.open if JSON_PATH.endswith(".gz") else open | |
| with _open(JSON_PATH, "rt", encoding="utf-8") as f: | |
| raw = json.load(f) | |
| with open(METADATA_PATH) as f: | |
| meta = json.load(f) | |
| rows = [] | |
| for pmid, entry in raw.items(): | |
| uniprot_ids = entry.get("Uniprot_IDS", []) or [] | |
| protein_names = entry.get("Protein_names", []) or [] | |
| organisms = entry.get("Organisms", []) or [] | |
| sequences = entry.get("Sequences", []) or [] | |
| n_collected = entry.get("Number_of_proteins_collected", 0) | |
| # Paper-level metadata from metadata.json | |
| m = meta.get(str(pmid), {}) | |
| groups = m.get("groups", []) | |
| title = m.get("title", "") | |
| pub_date = m.get("pub_date", "") | |
| source = m.get("source", "") | |
| url = m.get("url", "") | |
| for protein in entry.get("proteins", []): | |
| row = {"pmid": pmid} | |
| up = protein.get("uniprot") or {} | |
| row.update({k: (v if v is not None else "") | |
| for k, v in protein.items() if k != "uniprot"}) | |
| for src, dst in UNIPROT_MAP_COLS.items(): | |
| v = up.get(src) | |
| row[dst] = "" if v is None else v | |
| for src, (dst, sep) in UNIPROT_LIST_COLS.items(): | |
| v = up.get(src) | |
| row[dst] = sep.join(v) if v else "" | |
| # only show a localization we trust; keep the raw one searchable | |
| row["localization"] = ( | |
| row["up_localization"] if up.get("localization_confident") else "" | |
| ) | |
| row["n_uniprot_entries"] = len(uniprot_ids) | |
| row["uniprot_ids"] = ", ".join(uniprot_ids) | |
| row["n_proteins_collected"] = n_collected | |
| row["groups"] = ", ".join(groups) if groups else "unknown" | |
| row["title"] = title | |
| row["pub_date"] = pub_date | |
| row["source"] = source | |
| row["url"] = url | |
| rows.append(row) | |
| return pd.DataFrame(rows), raw, meta | |
| df, RAW, META = load_data() | |
| ALL_GROUPS = sorted({ | |
| g for entry in META.values() for g in entry.get("groups", []) | |
| }) | |
| # PMID → set of groups, for fast cluster filtering | |
| PMID_GROUPS = { | |
| pmid: set(entry.get("groups", [])) | |
| for pmid, entry in META.items() | |
| } | |
| PMID_ERA_BINS = [ | |
| ("pre-2001", 0, 10_000_000), | |
| ("2001–2005", 10_000_000, 15_000_000), | |
| ("2006–2009", 15_000_000, 20_000_000), | |
| ("2010–2013", 20_000_000, 25_000_000), | |
| ("2014–2017", 25_000_000, 30_000_000), | |
| ("2018–2020", 30_000_000, 35_000_000), | |
| ("2021–2023", 35_000_000, 40_000_000), | |
| ] | |
| # Column sets | |
| EXTRACTION_FIELDS = [ | |
| "pmid", "enzyme_name", "organism_source", "strain", "expression_strain", | |
| "plasmid", "molecular_weight", "medium_name", "inducer", | |
| "induction_temperature", "lysis_buffer", "elution_buffer", "desalting_process", | |
| ] | |
| PAPER_FIELDS = ["title", "pub_date", "source", "groups"] | |
| UNIPROT_FIELDS = ["uniprot_ids", "n_uniprot_entries", "n_proteins_collected"] | |
| MAPPED_FIELDS = [ | |
| "localization", "up_accession", "up_gene", "up_protein_name", "up_organism", | |
| "up_ec_number", "up_ec_class", "up_keywords", "up_cofactors", "up_family", | |
| "up_length", "up_mass", "up_signal_peptide", "up_transmembrane", | |
| "up_match_status", | |
| ] | |
| TABLE_FIELDS = EXTRACTION_FIELDS + PAPER_FIELDS + UNIPROT_FIELDS + MAPPED_FIELDS | |
| # --------------------------------------------------------------------------- | |
| # Scan grid directory structure | |
| # --------------------------------------------------------------------------- | |
| def scan_grid(): | |
| models, mins, thresholds, fields = set(), set(), set(), set() | |
| for pattern in ( | |
| os.path.join(GRID_BASE, "model=*", "min=*", "t=*_ALL_FIELDS.csv.gz"), | |
| os.path.join(GRID_BASE, "model=*", "min=*", "t=*_ALL_FIELDS.csv"), | |
| ): | |
| for fpath in glob.glob(pattern): | |
| parts = fpath.split(os.sep) | |
| for p in parts: | |
| if p.startswith("model="): | |
| models.add(p.replace("model=", "")) | |
| elif p.startswith("min="): | |
| mins.add(p.replace("min=", "")) | |
| fname = os.path.basename(fpath) | |
| m = re.match(r"t=([\d.]+)_ALL_FIELDS\.csv", fname) | |
| if m: | |
| thresholds.add(m.group(1)) | |
| # Discover fields from sample ALL_FIELDS files — try until one succeeds | |
| _FALLBACK_FIELDS = [ | |
| "desalting_process", "elution_buffer", "enzyme_name", "expression_strain", | |
| "inducer", "induction_temperature", "lysis_buffer", "medium_name", | |
| "molecular_weight", "organism_source", "plasmid", "source_key", "strain", | |
| ] | |
| for sample in ( | |
| glob.glob(os.path.join(GRID_BASE, "model=*", "min=*", "t=*_ALL_FIELDS.csv.gz")) + | |
| glob.glob(os.path.join(GRID_BASE, "model=*", "min=*", "t=*_ALL_FIELDS.csv")) | |
| ): | |
| try: | |
| compression = "gzip" if sample.endswith(".gz") else None | |
| fields = set(pd.read_csv(sample, compression=compression, usecols=["field"])["field"].unique()) | |
| if fields: | |
| break | |
| except Exception: | |
| continue | |
| if not fields: | |
| fields = set(_FALLBACK_FIELDS) | |
| return ( | |
| sorted(models), | |
| sorted(mins, key=float), | |
| sorted(thresholds, key=float), | |
| sorted(fields), | |
| ) | |
| MODELS, MINS, THRESHOLDS, CLUSTER_FIELDS = scan_grid() | |
| print(f"[startup] GRID_BASE={GRID_BASE}") | |
| print(f"[startup] Models found: {MODELS}") | |
| print(f"[startup] Fields found: {CLUSTER_FIELDS}") | |
| METRIC_TYPES = ["silhouette_cosine", "davies_bouldin", "n_clusters"] | |
| # --------------------------------------------------------------------------- | |
| # Load all metrics CSVs into one DataFrame at startup | |
| # --------------------------------------------------------------------------- | |
| def load_all_metrics(): | |
| rows = [] | |
| for fpath in glob.glob(os.path.join(GRID_BASE, "model=*", "min=*", "t=*_FIELD_CLUSTER_METRICS.csv")): | |
| parts = fpath.replace(GRID_BASE + os.sep, "").split(os.sep) | |
| model = parts[0].replace("model=", "") | |
| try: | |
| chunk = pd.read_csv(fpath) | |
| chunk["model"] = model | |
| rows.append(chunk) | |
| except Exception: | |
| pass | |
| if not rows: | |
| return pd.DataFrame() | |
| return pd.concat(rows, ignore_index=True) | |
| METRICS_DF = load_all_metrics() | |
| METRIC_FIELDS = sorted(METRICS_DF["field"].unique()) if not METRICS_DF.empty else [] | |
| METRIC_MODELS = sorted(METRICS_DF["model"].unique()) if not METRICS_DF.empty else [] | |
| # --------------------------------------------------------------------------- | |
| # Evaluation data | |
| # --------------------------------------------------------------------------- | |
| _EVAL_BASE = os.path.join(BASE_DIR, "data", "evaluation_llm_results") | |
| _PURIF_FIELDS = [ | |
| "enzyme_name", "organism_source", "strain", "expression_strain", "plasmid", | |
| "molecular_weight", "medium_name", "inducer", "induction_temperature", | |
| "lysis_buffer", "elution_buffer", "desalting_process", | |
| ] | |
| _NLP_METRICS = { | |
| "bertscore_f1": "BERTScore F1", | |
| "rouge1_f": "ROUGE-1 F", | |
| "bleu": "BLEU", | |
| "meteor": "METEOR", | |
| "cosine_similarity":"Cosine Similarity", | |
| } | |
| # BLEU and METEOR are stored 0–100; divide by 100 to normalise to 0–1 for display | |
| _NLP_SCALE = {"bleu": 100.0, "meteor": 100.0} | |
| _LLM_EVAL_CONFIGS = [ | |
| ("azoreductases","gpt-4.1", False, os.path.join(_EVAL_BASE,"cleaned","results_azo_gpt-4.1_cleaned.json")), | |
| ("azoreductases","gpt-5-mini",False,os.path.join(_EVAL_BASE,"cleaned","results_azo_gpt-5-mini_cleaned.json")), | |
| ("azoreductases","gpt-5", False, os.path.join(_EVAL_BASE,"cleaned","results_azo_gpt-5_cleaned.json")), | |
| ("azoreductases","gpt-4.1", True, os.path.join(_EVAL_BASE,"cleaned","results_azo_gpt-4.1_rag_600_100_cleaned.json")), | |
| ("azoreductases","gpt-5-mini",True, os.path.join(_EVAL_BASE,"cleaned","results_azo_gpt-5-mini_rag_600_100_cleaned.json")), | |
| ("azoreductases","gpt-5", True, os.path.join(_EVAL_BASE,"cleaned","results_azo_gpt-5_rag_600_100_cleaned.json")), | |
| ("sams","gpt-4.1", False, os.path.join(_EVAL_BASE,"sams","cleaned","sams_gpt-4.1_cleaned.json")), | |
| ("sams","gpt-5-mini",False, os.path.join(_EVAL_BASE,"sams","cleaned","sams_gpt-5-mini_cleaned.json")), | |
| ("sams","gpt-5", False, os.path.join(_EVAL_BASE,"sams","cleaned","sams_gpt-5_cleaned.json")), | |
| ("sams","gpt-4.1", True, os.path.join(_EVAL_BASE,"sams","cleaned","sams_gpt-4.1_rag_600_100_cleaned.json")), | |
| ("sams","gpt-5-mini",True, os.path.join(_EVAL_BASE,"sams","cleaned","sams_gpt-5-mini_rag_600_100_cleaned.json")), | |
| ("sams","gpt-5", True, os.path.join(_EVAL_BASE,"sams","cleaned","sams_gpt-5_rag_600_100_cleaned.json")), | |
| ] | |
| _NLP_EVAL_CONFIGS = [ | |
| ("azoreductases","gpt-4.1", False, os.path.join(_EVAL_BASE,"results_azo_purification_nlp_gpt-4.1_cleaned.json")), | |
| ("azoreductases","gpt-5-mini",False,os.path.join(_EVAL_BASE,"results_azo_purification_nlp_gpt-5-mini_cleaned.json")), | |
| ("azoreductases","gpt-5", False, os.path.join(_EVAL_BASE,"results_azo_purification_nlp_gpt-5_cleaned.json")), | |
| ("azoreductases","gpt-4.1", True, os.path.join(_EVAL_BASE,"results_azo_purification_nlp_gpt-4.1_rag_600_100_cleaned.json")), | |
| ("azoreductases","gpt-5-mini",True, os.path.join(_EVAL_BASE,"results_azo_purification_nlp_gpt-5-mini_rag_600_100_cleaned.json")), | |
| ("azoreductases","gpt-5", True, os.path.join(_EVAL_BASE,"results_azo_purification_nlp_gpt-5_rag_600_100_cleaned.json")), | |
| ("sams","gpt-4.1", False, os.path.join(_EVAL_BASE,"sams","results_nlp_sams_purification_gpt-4.1_cleaned.json")), | |
| ("sams","gpt-5-mini",False, os.path.join(_EVAL_BASE,"sams","results_nlp_sams_purification_gpt-5-mini_cleaned.json")), | |
| ("sams","gpt-5", False, os.path.join(_EVAL_BASE,"sams","results_nlp_sams_purification_gpt-5_cleaned.json")), | |
| ("sams","gpt-4.1", True, os.path.join(_EVAL_BASE,"sams","results_nlp_sams_purification_gpt-4.1_rag_600_100_cleaned.json")), | |
| ("sams","gpt-5-mini",True, os.path.join(_EVAL_BASE,"sams","results_nlp_sams_purification_gpt-5-mini_rag_600_100_cleaned.json")), | |
| ("sams","gpt-5", True, os.path.join(_EVAL_BASE,"sams","results_nlp_sams_purification_gpt-5_rag_600_100_cleaned.json")), | |
| ] | |
| _EVAL_LABELS = [ | |
| "gpt-4.1 (no-RAG)", "gpt-4.1 (RAG)", | |
| "gpt-5-mini (no-RAG)", "gpt-5-mini (RAG)", | |
| "gpt-5 (no-RAG)", "gpt-5 (RAG)", | |
| ] | |
| def _flatten_purif_eval(configs, score_key): | |
| rows = [] | |
| for group, model, rag, fpath in configs: | |
| if not os.path.exists(fpath): | |
| continue | |
| try: | |
| with open(fpath) as f: | |
| data = json.load(f) | |
| except Exception: | |
| continue | |
| label = f"{model} ({'RAG' if rag else 'no-RAG'})" | |
| for pmid, entry in data.items(): | |
| for pair in entry.get("evaluated_protein_pairs", []): | |
| gt_prot = pair.get("gt_protein", {}) | |
| llm_prot = pair.get("llm_protein", {}) | |
| for field, scores in pair.get("evaluation_result", {}).items(): | |
| if field not in _PURIF_FIELDS or not isinstance(scores, dict): | |
| continue | |
| val = scores.get(score_key) | |
| if val is None: | |
| continue | |
| rows.append({ | |
| "group": group, "model": model, "rag": rag, | |
| "label": label, "pmid": pmid, "field": field, | |
| score_key: float(val), | |
| "gt_text": str(gt_prot.get(field) or ""), | |
| "llm_text": str(llm_prot.get(field) or ""), | |
| "explanation": str(scores.get("explanation", "")), | |
| }) | |
| return pd.DataFrame(rows) if rows else pd.DataFrame( | |
| columns=["group","model","rag","label","pmid","field",score_key, | |
| "gt_text","llm_text","explanation"]) | |
| def load_eval_data(): | |
| llm_df = _flatten_purif_eval(_LLM_EVAL_CONFIGS, "similarity_score") | |
| nlp_rows = [] | |
| for group, model, rag, fpath in _NLP_EVAL_CONFIGS: | |
| if not os.path.exists(fpath): | |
| continue | |
| try: | |
| with open(fpath) as f: | |
| data = json.load(f) | |
| except Exception: | |
| continue | |
| label = f"{model} ({'RAG' if rag else 'no-RAG'})" | |
| for pmid, entry in data.items(): | |
| for pair in entry.get("evaluated_protein_pairs", []): | |
| for field, scores in pair.get("evaluation_result", {}).items(): | |
| if field not in _PURIF_FIELDS or not isinstance(scores, dict): | |
| continue | |
| gt_text = str(scores.get("GT", "") or "") | |
| llm_text = str(scores.get("LLM", "") or "") | |
| for metric in _NLP_METRICS: | |
| val = scores.get(metric) | |
| if val is None: | |
| continue | |
| nlp_rows.append({"group": group, "model": model, "rag": rag, | |
| "label": label, "pmid": pmid, "field": field, | |
| "metric": metric, "value": float(val), | |
| "gt_text": gt_text, "llm_text": llm_text}) | |
| nlp_df = pd.DataFrame(nlp_rows) if nlp_rows else pd.DataFrame( | |
| columns=["group","model","rag","label","pmid","field","metric","value"]) | |
| _classif_sources = [ | |
| ("azoreductases", os.path.join(os.path.dirname(_EVAL_BASE), | |
| "results_method_extraction", | |
| "results_azo_method_extraction_metrics.json")), | |
| ("sams", os.path.join(BASE_DIR, "data", "sams", "extracted_methods_sams_metrics.json")), | |
| ] | |
| classif_rows = [] | |
| for group, fpath in _classif_sources: | |
| if not os.path.exists(fpath): | |
| continue | |
| try: | |
| with open(fpath) as f: | |
| data = json.load(f) | |
| for pmid, scores in data.items(): | |
| if not isinstance(scores, dict): | |
| continue | |
| gt_text = str(scores.get("GT", "") or "") | |
| llm_text = str(scores.get("LLM", "") or "") | |
| for metric in _NLP_METRICS: | |
| val = scores.get(metric) | |
| if val is not None: | |
| classif_rows.append({ | |
| "group": group, "pmid": pmid, | |
| "metric": metric, "value": float(val), | |
| "gt_text": gt_text, "llm_text": llm_text, | |
| }) | |
| except Exception: | |
| pass | |
| classif_df = pd.DataFrame(classif_rows) if classif_rows else pd.DataFrame( | |
| columns=["group","pmid","metric","value","gt_text","llm_text"]) | |
| return llm_df, nlp_df, classif_df | |
| EVAL_LLM_DF, EVAL_NLP_DF, CLASSIF_DF = load_eval_data() | |
| # --------------------------------------------------------------------------- | |
| # Classification confusion matrices | |
| # --------------------------------------------------------------------------- | |
| _CONFMAT_AZO_PATH = os.path.join(BASE_DIR, "data", "azoreductases_gt", "azo_metadata.json") | |
| _CONFMAT_SAMS_PATH = os.path.join(BASE_DIR, "data", "confusion_matrix", "confusion_matrix_counts.csv") | |
| _SAMS_ALL_PATH = os.path.join(BASE_DIR, "data", "sams", "sam_pdfs_enzymology.json") | |
| _SAMS_FILTERED_PATH = os.path.join(BASE_DIR, "data", "sams", "filtered.json") | |
| def _compute_metrics(tp, fp, fn, tn): | |
| precision = tp / (tp + fp) if (tp + fp) > 0 else None | |
| recall = tp / (tp + fn) if (tp + fn) > 0 else None | |
| f1 = (2 * precision * recall / (precision + recall) | |
| if precision is not None and recall is not None | |
| and (precision + recall) > 0 else None) | |
| accuracy = (tp + tn) / (tp + fp + fn + tn) if (tp + fp + fn + tn) > 0 else None | |
| return {"TP": tp, "FP": fp, "FN": fn, "TN": tn, | |
| "Precision": precision, "Recall": recall, "F1": f1, "Accuracy": accuracy} | |
| def load_confusion_matrices(): | |
| result = {} | |
| # Azoreductases: all 31 GT papers classified as enzymology | |
| try: | |
| with open(_CONFMAT_AZO_PATH) as f: | |
| azo_meta = json.load(f) | |
| n = len(azo_meta) | |
| result["azoreductases"] = _compute_metrics(tp=n, fp=0, fn=0, tn=0) | |
| result["azoreductases"]["note"] = ( | |
| f"All {n} ground-truth papers were classified as enzymology. " | |
| "No negative set available, so TN and FP are not applicable." | |
| ) | |
| # All are TP | |
| result["azoreductases"]["papers"] = { | |
| pmid: {"actual": True, "predicted": True, | |
| "title": v.get("title", ""), | |
| "pub_date": v.get("pub_date", ""), | |
| "source": v.get("source", "")} | |
| for pmid, v in azo_meta.items() | |
| } | |
| except Exception: | |
| pass | |
| # SAMs: full confusion matrix from CSV (rows=Actual, cols=Predicted) | |
| try: | |
| cm = pd.read_csv(_CONFMAT_SAMS_PATH, index_col=0) | |
| cm.index = cm.index.map(lambda v: bool(v) if not isinstance(v, bool) else v) | |
| cm.columns = cm.columns.map(lambda v: bool(v) if v == "True" else (False if v == "False" else v)) | |
| tp = int(cm.loc[True, True]) | |
| fn = int(cm.loc[True, False]) | |
| fp = int(cm.loc[False, True]) | |
| tn = int(cm.loc[False, False]) | |
| result["sams"] = _compute_metrics(tp=tp, fp=fp, fn=fn, tn=tn) | |
| result["sams"]["matrix"] = cm | |
| # Per-paper category assignments | |
| with open(_SAMS_ALL_PATH) as f: | |
| all_papers = json.load(f) | |
| with open(_SAMS_FILTERED_PATH) as f: | |
| filtered = json.load(f) | |
| pred_pos = set(filtered.keys()) | |
| papers = {} | |
| for pmid, v in all_papers.items(): | |
| actual = bool(v.get("Count_Enzymology", False)) | |
| predicted = pmid in pred_pos | |
| papers[pmid] = { | |
| "actual": actual, | |
| "predicted": predicted, | |
| "title": v.get("title", ""), | |
| "pub_date": v.get("pub_date", ""), | |
| "source": v.get("source", ""), | |
| "url": v.get("url", ""), | |
| } | |
| result["sams"]["papers"] = papers | |
| except Exception as e: | |
| print("SAMs confmat load error:", e) | |
| return result | |
| CONF_MATRICES = load_confusion_matrices() | |
| # --------------------------------------------------------------------------- | |
| # Dash app | |
| # --------------------------------------------------------------------------- | |
| CUSTOM_CSS = """ | |
| /* ── Global ── */ | |
| body { background-color: #f4f6f9; } | |
| /* ── Navbar ── */ | |
| .navbar-brand { font-size: 1.2rem; font-weight: 700; letter-spacing: 0.02em; } | |
| .navbar-subtitle { font-size: 0.75rem; opacity: 0.75; display: block; line-height: 1.2; } | |
| /* ── Tabs ── */ | |
| .nav-tabs .nav-link { color: #495057; font-weight: 500; border-radius: 6px 6px 0 0; } | |
| .nav-tabs .nav-link.active { color: #1a73e8; font-weight: 700; border-bottom: 3px solid #1a73e8; } | |
| .nav-tabs .nav-link:hover { color: #1a73e8; } | |
| /* ── Filter panel ── */ | |
| .filter-panel { | |
| background: #ffffff; | |
| border: 1px solid #dee2e6; | |
| border-radius: 8px; | |
| padding: 16px 20px 12px; | |
| margin-bottom: 16px; | |
| box-shadow: 0 1px 4px rgba(0,0,0,.06); | |
| } | |
| /* ── Contact avatar ── */ | |
| .avatar-circle { | |
| width: 56px; height: 56px; border-radius: 50%; | |
| display: flex; align-items: center; justify-content: center; | |
| font-size: 1.2rem; font-weight: 700; color: #fff; | |
| margin-bottom: 10px; | |
| } | |
| .person-card { transition: transform .15s, box-shadow .15s; } | |
| .person-card:hover { transform: translateY(-3px); box-shadow: 0 6px 18px rgba(0,0,0,.12) !important; } | |
| /* ── Accordion ── */ | |
| .accordion-button { font-weight: 600; } | |
| .accordion-item { border-left: 4px solid #1a73e8 !important; margin-bottom: 6px; border-radius: 6px !important; } | |
| /* ── Tab fade-in animation ── */ | |
| @keyframes tabFadeIn { | |
| from { opacity: 0; transform: translateY(8px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| #tab-content > * { | |
| animation: tabFadeIn 0.25s ease forwards; | |
| } | |
| .tab-content > .tab-pane.active { | |
| animation: tabFadeIn 0.25s ease forwards; | |
| } | |
| """ | |
| app = dash.Dash( | |
| __name__, | |
| external_stylesheets=[dbc.themes.LUX], | |
| suppress_callback_exceptions=True, | |
| title="ProtoPure", | |
| ) | |
| app.index_string = app.index_string.replace( | |
| "</head>", f"<style>{CUSTOM_CSS}</style></head>" | |
| ) | |
| server = app.server | |
| def serve_grid_file(filepath): | |
| full = os.path.realpath(os.path.join(GRID_BASE, filepath)) | |
| if not full.startswith(os.path.realpath(GRID_BASE)): | |
| abort(403) | |
| if not os.path.isfile(full): | |
| abort(404) | |
| return send_from_directory(os.path.dirname(full), os.path.basename(full)) | |
| # --------------------------------------------------------------------------- | |
| # Layout helpers | |
| # --------------------------------------------------------------------------- | |
| def make_dropdown(label, id_, options, value=None, multi=False, clearable=True): | |
| return dbc.Col([ | |
| html.Label(label, className="fw-semibold small mb-1"), | |
| dcc.Dropdown( | |
| id=id_, | |
| options=[{"label": o, "value": o} for o in options], | |
| value=value if value is not None else (options[0] if options else None), | |
| multi=multi, | |
| clearable=clearable, | |
| style={"fontSize": "13px"}, | |
| ), | |
| ]) | |
| def detail_field(label, value): | |
| """Single labeled field for the detail panel.""" | |
| if not value: | |
| return None | |
| return html.Div([ | |
| html.Span(label + ": ", className="fw-semibold text-muted small"), | |
| html.Span(str(value), className="small"), | |
| ], className="mb-1") | |
| # --------------------------------------------------------------------------- | |
| # Tab layouts | |
| # --------------------------------------------------------------------------- | |
| DEFAULT_COLS = ["pmid", "enzyme_name", "organism_source"] | |
| FIELD_LABELS = {c: c.replace("_", " ").title() for c in TABLE_FIELDS} | |
| FIELD_LABELS.update({ | |
| "pmid": "PMID", "enzyme_name": "Enzyme Name", "organism_source": "Organism", | |
| "expression_strain": "Expression Strain", "uniprot_ids": "UniProt IDs", | |
| "n_uniprot_entries": "# UniProt", "n_proteins_collected": "# Proteins", | |
| "pub_date": "Publication Date", "source": "Journal", | |
| "localization": "Cellular Localization", | |
| "up_accession": "UniProt Accession (mapped)", "up_gene": "Gene", | |
| "up_protein_name": "UniProt Protein Name", "up_organism": "UniProt Organism", | |
| "up_ec_number": "EC Number", "up_signal_peptide": "Signal Peptide", | |
| "up_transmembrane": "Transmembrane", "up_match_status": "Mapping Status", | |
| "up_ec_class": "Enzyme Class", "up_keywords": "UniProt Keywords", | |
| "up_cofactors": "Cofactor", "up_family": "Protein Family", | |
| "up_length": "Length (aa)", "up_mass": "Mass (Da)", "up_lineage": "Taxonomic Lineage", | |
| }) | |
| def proteins_tab(): | |
| search_fields = [ | |
| {"label": "PMID", "value": "pmid"}, | |
| {"label": "Enzyme name", "value": "enzyme_name"}, | |
| {"label": "Organism", "value": "organism_source"}, | |
| {"label": "Expression strain", "value": "expression_strain"}, | |
| {"label": "Plasmid", "value": "plasmid"}, | |
| {"label": "Inducer", "value": "inducer"}, | |
| {"label": "UniProt ID", "value": "uniprot_ids"}, | |
| {"label": "Cellular localization", "value": "localization"}, | |
| {"label": "UniProt accession (mapped)", "value": "up_accession"}, | |
| {"label": "Gene", "value": "up_gene"}, | |
| {"label": "UniProt keyword", "value": "up_keywords"}, | |
| {"label": "Cofactor", "value": "up_cofactors"}, | |
| {"label": "Enzyme class", "value": "up_ec_class"}, | |
| {"label": "Protein family", "value": "up_family"}, | |
| {"label": "Journal", "value": "source"}, | |
| {"label": "Paper title", "value": "title"}, | |
| ] | |
| col_options = [{"label": FIELD_LABELS.get(c, c), "value": c} for c in TABLE_FIELDS] | |
| return dbc.Container([ | |
| html.Div([ | |
| dbc.Row([ | |
| dbc.Col([ | |
| html.Label("Filter by group", className="fw-semibold small mb-1"), | |
| dcc.Dropdown( | |
| id="filter-group", | |
| options=[{"label": g, "value": g} for g in ALL_GROUPS], | |
| multi=True, | |
| placeholder="All groups", | |
| style={"fontSize": "13px"}, | |
| ), | |
| ], width=4), | |
| dbc.Col([ | |
| html.Label("Search by", className="fw-semibold small mb-1"), | |
| dcc.Dropdown( | |
| id="search-field", | |
| options=search_fields, | |
| value="enzyme_name", | |
| clearable=False, | |
| style={"fontSize": "13px"}, | |
| ), | |
| ], width=2), | |
| dbc.Col([ | |
| html.Label("Search value", className="fw-semibold small mb-1"), | |
| dbc.InputGroup([ | |
| dbc.Input(id="search-value", placeholder="Type to filter…", debounce=True, size="sm"), | |
| dbc.Button("✕ Clear", id="clear-search", size="sm", color="secondary", outline=True), | |
| ]), | |
| ], width=6), | |
| ], className="mb-2"), | |
| dbc.Row([ | |
| dbc.Col([ | |
| html.Label("Cross-filter: field", className="fw-semibold small mb-1"), | |
| dcc.Dropdown( | |
| id="table-cross-field", | |
| options=[{"label": f.replace("_"," ").title(), "value": f} | |
| for f in EXTRACTION_FIELDS[1:]], | |
| placeholder="Select a field…", | |
| clearable=True, | |
| style={"fontSize": "13px"}, | |
| ), | |
| ], width=3), | |
| dbc.Col([ | |
| html.Label("Cross-filter: value", className="fw-semibold small mb-1"), | |
| dcc.Dropdown( | |
| id="table-cross-value", | |
| options=[], | |
| placeholder="Select a value…", | |
| clearable=True, | |
| style={"fontSize": "13px"}, | |
| ), | |
| ], width=5), | |
| dbc.Col([ | |
| html.Label("Show columns", className="fw-semibold small mb-1"), | |
| dcc.Dropdown( | |
| id="col-selector", | |
| options=col_options, | |
| value=DEFAULT_COLS, | |
| multi=True, | |
| placeholder="Select columns…", | |
| style={"fontSize": "13px"}, | |
| ), | |
| ], width=4), | |
| ]), | |
| ], className="filter-panel"), | |
| dbc.Row([ | |
| dbc.Col(html.Div(id="protein-count", className="text-muted small"), width=10), | |
| dbc.Col( | |
| dbc.Button("⬇ Download CSV", id="download-btn", size="sm", color="success", outline=True), | |
| width=2, className="text-end", | |
| ), | |
| ], className="mb-2 align-items-center"), | |
| dcc.Download(id="download-csv"), | |
| # Table | |
| dash_table.DataTable( | |
| id="protein-table", | |
| columns=[{"name": FIELD_LABELS.get(c, c), "id": c} for c in TABLE_FIELDS], | |
| hidden_columns=[c for c in TABLE_FIELDS if c not in DEFAULT_COLS], | |
| data=df[TABLE_FIELDS].to_dict("records"), | |
| page_size=25, | |
| page_action="native", | |
| sort_action="native", | |
| sort_by=[{"column_id": "pmid", "direction": "asc"}], | |
| filter_action="none", | |
| row_selectable="single", | |
| selected_rows=[], | |
| style_table={"overflowX": "auto"}, | |
| style_cell={ | |
| "fontSize": "12px", | |
| "padding": "6px 10px", | |
| "textAlign": "left", | |
| "maxWidth": "220px", | |
| "overflow": "hidden", | |
| "textOverflow": "ellipsis", | |
| "whiteSpace": "nowrap", | |
| }, | |
| style_header={ | |
| "fontWeight": "700", | |
| "backgroundColor": "#1a3a5c", | |
| "color": "#ffffff", | |
| "borderBottom": "2px solid #1a3a5c", | |
| "fontSize": "11px", | |
| "textTransform": "uppercase", | |
| "letterSpacing": "0.04em", | |
| }, | |
| style_data_conditional=[ | |
| {"if": {"row_index": "odd"}, "backgroundColor": "#eef4fd"}, | |
| {"if": {"state": "selected"}, "backgroundColor": "#cfe2ff", "border": "1px solid #9ec5fe"}, | |
| ], | |
| tooltip_delay=0, | |
| tooltip_duration=None, | |
| ), | |
| # Detail panel (shown on row click) | |
| html.Div(id="detail-panel", className="mt-3"), | |
| ], fluid=True, className="pt-3") | |
| def clustering_tab(): | |
| return dbc.Container([ | |
| dbc.Row([ | |
| make_dropdown("Model", "dd-model", MODELS, value="kamalkraj__BioSimCSE-BioLinkBERT-BASE" if "kamalkraj__BioSimCSE-BioLinkBERT-BASE" in MODELS else (MODELS[0] if MODELS else None)), | |
| make_dropdown("Min community size", "dd-min", MINS, value="2" if "2" in MINS else (MINS[0] if MINS else None)), | |
| make_dropdown("Threshold", "dd-threshold", THRESHOLDS, value="0.9" if "0.9" in THRESHOLDS else (THRESHOLDS[0] if THRESHOLDS else None)), | |
| make_dropdown("Field", "dd-field", CLUSTER_FIELDS, value="organism_source" if "organism_source" in CLUSTER_FIELDS else (CLUSTER_FIELDS[0] if CLUSTER_FIELDS else None)), | |
| ], className="mb-3 g-3"), | |
| dbc.Row([ | |
| dbc.Col([ | |
| html.Label("Filter by group", className="fw-semibold small mb-1"), | |
| dcc.Dropdown( | |
| id="cluster-group-filter", | |
| options=[{"label": g, "value": g} for g in ALL_GROUPS], | |
| multi=True, | |
| placeholder="All groups", | |
| style={"fontSize": "13px"}, | |
| ), | |
| ], width=3), | |
| dbc.Col([ | |
| html.Label("Cross-filter: field", className="fw-semibold small mb-1"), | |
| dcc.Dropdown( | |
| id="dd-cross-field", | |
| options=[{"label": f.replace("_"," ").title(), "value": f} | |
| for f in CLUSTER_FIELDS], | |
| placeholder="No cross-filter", | |
| clearable=True, | |
| style={"fontSize": "13px"}, | |
| ), | |
| ], width=3), | |
| dbc.Col([ | |
| html.Label("Cross-filter: value", className="fw-semibold small mb-1"), | |
| dcc.Dropdown( | |
| id="dd-cross-value", | |
| options=[], | |
| placeholder="Select a value…", | |
| clearable=True, | |
| disabled=True, | |
| style={"fontSize": "13px"}, | |
| ), | |
| ], width=3), | |
| dbc.Col([ | |
| dbc.RadioItems( | |
| id="plot-type", | |
| options=[ | |
| {"label": " UMAP", "value": "cluster"}, | |
| {"label": " Distribution", "value": "distribution"}, | |
| ], | |
| value="cluster", | |
| inline=True, | |
| className="mb-2 mt-4", | |
| ), | |
| ], width=2), | |
| dbc.Col([ | |
| html.Label("Show top N clusters", className="fw-semibold small mb-1"), | |
| dcc.Dropdown( | |
| id="top-n-clusters", | |
| options=[{"label": str(n), "value": n} for n in [10, 15, 20, 30, 50]] + | |
| [{"label": "All", "value": 0}], | |
| value=20, | |
| clearable=False, | |
| style={"fontSize": "13px"}, | |
| ), | |
| ], width=2), | |
| ], className="mb-2 align-items-end"), | |
| dbc.Row([ | |
| dbc.Col([ | |
| html.Label("Filter by publication era", className="fw-semibold small mb-1"), | |
| dcc.Dropdown( | |
| id="dd-era-filter", | |
| options=[{"label": label, "value": label} | |
| for label, _, _ in PMID_ERA_BINS], | |
| placeholder="All time periods", | |
| clearable=True, | |
| style={"fontSize": "13px"}, | |
| ), | |
| ], width=4), | |
| ], className="mb-2"), | |
| html.Div(id="diversity-cards", className="mb-2"), | |
| dbc.Row([dbc.Col(html.Div(id="plot-status", className="text-danger small mb-1"))]), | |
| dbc.Row([ | |
| dbc.Col( | |
| dcc.Loading( | |
| dcc.Graph( | |
| id="cluster-graph", | |
| config={"displayModeBar": True, "toImageButtonOptions": {"format": "svg"}}, | |
| style={"height": "650px"}, | |
| ), | |
| type="circle", color="#1a73e8", | |
| ), | |
| width=12, | |
| ), | |
| ]), | |
| html.Div(id="cluster-point-detail", className="mt-2"), | |
| dcc.Store(id="cluster-table-store"), | |
| dcc.Download(id="download-cluster-csv"), | |
| html.Hr(), | |
| html.H6("Cluster metrics for selected parameters", className="mt-2 mb-2 fw-semibold"), | |
| dbc.Row([dbc.Col(html.Div(id="metrics-table-container"), width=12)]), | |
| ], fluid=True, className="pt-3") | |
| _METRIC_LABELS = { | |
| "silhouette_cosine": "Silhouette Score", | |
| "davies_bouldin": "Davies-Bouldin Index", | |
| "n_clusters": "Number of Clusters", | |
| } | |
| _OVERVIEW_METRICS = { | |
| "silhouette_cosine": ("Silhouette Score", True, "RdYlGn", "Mean<br>Silhouette"), | |
| "davies_bouldin": ("Davies-Bouldin Index", False, "RdYlGn_r", "Mean<br>Davies-Bouldin"), | |
| "n_clusters": ("N Clusters", True, "Blues", "Mean<br>N Clusters"), | |
| } | |
| def _build_grid_summary_figure(field=None, min_size=None, metric="silhouette_cosine"): | |
| """Heatmap of a clustering metric per (model, threshold). | |
| field=None/'__global__' → average over all fields. | |
| min_size=None/'__all__' → average over all min community sizes. | |
| metric: one of silhouette_cosine | davies_bouldin | n_clusters. | |
| """ | |
| if METRICS_DF.empty: | |
| return go.Figure() | |
| if metric not in _OVERVIEW_METRICS: | |
| metric = "silhouette_cosine" | |
| metric_label, higher_better, colorscale, cb_title = _OVERVIEW_METRICS[metric] | |
| def _short(m): | |
| return m.split("__")[-1] if "__" in m else m | |
| src = METRICS_DF.dropna(subset=[metric]) | |
| if field and field != "__global__": | |
| src = src[src["field"] == field] | |
| if src.empty: | |
| return go.Figure() | |
| if min_size and min_size != "__all__": | |
| try: | |
| src = src[src["min_community_size"] == int(min_size)] | |
| except (ValueError, TypeError): | |
| pass | |
| if src.empty: | |
| return go.Figure() | |
| agg = ( | |
| src | |
| .groupby(["model", "threshold"])[metric] | |
| .mean() | |
| .reset_index() | |
| ) | |
| agg["short_model"] = agg["model"].apply(_short) | |
| pivot = agg.pivot_table( | |
| index="short_model", columns="threshold", | |
| values=metric, aggfunc="mean", | |
| ) | |
| # Always sort rows by silhouette score so all three heatmaps share the same model order | |
| sil_src = METRICS_DF.dropna(subset=["silhouette_cosine"]) | |
| if field and field != "__global__": | |
| sil_src = sil_src[sil_src["field"] == field] | |
| if min_size and min_size != "__all__": | |
| try: | |
| sil_src = sil_src[sil_src["min_community_size"] == int(min_size)] | |
| except (ValueError, TypeError): | |
| pass | |
| sil_agg = sil_src.groupby("model")["silhouette_cosine"].mean() | |
| sil_agg.index = sil_agg.index.map(_short) | |
| # Models present in pivot but missing from silhouette get ranked last | |
| row_order = ( | |
| sil_agg.reindex(pivot.index, fill_value=0) | |
| .sort_values(ascending=True) # ascending=True → worst at bottom, best at top in Plotly | |
| .index.tolist() | |
| ) | |
| pivot = pivot.loc[row_order] | |
| # Identify best cell and embed star in cell text | |
| best_idx = agg[metric].idxmax() if higher_better else agg[metric].idxmin() | |
| best = agg.loc[best_idx] | |
| best_short = _short(best["model"]) | |
| best_thresh = best["threshold"] | |
| rows_list = list(pivot.index) | |
| cols_list = list(pivot.columns) | |
| fmt = ".0f" if metric == "n_clusters" else ".3f" | |
| text = [] | |
| for ri, row_name in enumerate(rows_list): | |
| row_text = [] | |
| for ci, col_val in enumerate(cols_list): | |
| v = pivot.iloc[ri, ci] | |
| if pd.notna(v): | |
| cell = f"★ {v:{fmt}}" if (row_name == best_short and col_val == best_thresh) else f"{v:{fmt}}" | |
| else: | |
| cell = "" | |
| row_text.append(cell) | |
| text.append(row_text) | |
| field_label = "all fields" if (not field or field == "__global__") else field.replace("_", " ").title() | |
| min_label = "all min-sizes" if (not min_size or min_size == "__all__") else f"min={min_size}" | |
| n_models = len(pivot.index) | |
| fig = go.Figure(go.Heatmap( | |
| z=pivot.values, | |
| x=[str(c) for c in pivot.columns], | |
| y=list(pivot.index), | |
| colorscale=colorscale, | |
| text=text, | |
| texttemplate="%{text}", | |
| textfont=dict(size=11), | |
| colorbar=dict(title=dict(text=cb_title, font=dict(size=11)), thickness=14), | |
| hovertemplate=f"Model: %{{y}}<br>Threshold: %{{x}}<br>{metric_label}: %{{z:{fmt}}}<extra></extra>", | |
| )) | |
| better_str = "higher = better" if higher_better else "lower = better" | |
| fig.update_layout( | |
| title=dict( | |
| text=f"Grid Search Overview — {metric_label} ({better_str}) · {field_label} · {min_label} ★ = best", | |
| font=dict(size=13), | |
| ), | |
| xaxis=dict(title="Threshold", type="category", tickfont=dict(size=11)), | |
| yaxis=dict(title="", type="category", tickfont=dict(size=10)), | |
| paper_bgcolor="#ffffff", | |
| plot_bgcolor="#f9fafc", | |
| height=max(360, n_models * 30 + 100), | |
| margin=dict(l=240, r=30, t=55, b=50), | |
| ) | |
| return fig | |
| _SUMMARY_FIELD_OPTIONS = ( | |
| [{"label": "All fields (mean)", "value": "__global__"}] | |
| + [{"label": f.replace("_", " ").title(), "value": f} for f in sorted(METRIC_FIELDS)] | |
| ) | |
| _SUMMARY_MINSIZE_OPTIONS = ( | |
| [{"label": "All min-sizes (mean)", "value": "__all__"}] | |
| + [{"label": f"min = {m}", "value": str(m)} | |
| for m in sorted(METRICS_DF["min_community_size"].unique().tolist()) | |
| if not METRICS_DF.empty] | |
| ) | |
| def grid_metrics_tab(): | |
| if METRICS_DF.empty: | |
| return dbc.Container([html.P("No metrics data found.", className="text-muted mt-3")]) | |
| return dbc.Container([ | |
| # ── Grid search overview (callback-driven) ──────────────────────────── | |
| dbc.Card([ | |
| dbc.CardBody([ | |
| dbc.Row([ | |
| dbc.Col([ | |
| html.Label("Metric", className="fw-semibold small mb-1"), | |
| dcc.Dropdown( | |
| id="dd-summary-metric", | |
| options=[{"label": v[0], "value": k} | |
| for k, v in _OVERVIEW_METRICS.items()], | |
| value="silhouette_cosine", | |
| clearable=False, | |
| style={"fontSize": "13px"}, | |
| ), | |
| ], width=3), | |
| dbc.Col([ | |
| html.Label("Field", className="fw-semibold small mb-1"), | |
| dcc.Dropdown( | |
| id="dd-summary-field", | |
| options=_SUMMARY_FIELD_OPTIONS, | |
| value="__global__", | |
| clearable=False, | |
| style={"fontSize": "13px"}, | |
| ), | |
| ], width=4), | |
| dbc.Col([ | |
| html.Label("Min community size", className="fw-semibold small mb-1"), | |
| dcc.Dropdown( | |
| id="dd-summary-minsize", | |
| options=_SUMMARY_MINSIZE_OPTIONS, | |
| value="__all__", | |
| clearable=False, | |
| style={"fontSize": "13px"}, | |
| ), | |
| ], width=3), | |
| ], className="mb-2"), | |
| dcc.Loading( | |
| dcc.Graph( | |
| id="metrics-summary-graph", | |
| config={"displayModeBar": True, | |
| "toImageButtonOptions": {"format": "svg", "filename": "grid_search_summary"}}, | |
| ), | |
| type="circle", color="#1a73e8", | |
| ), | |
| ], className="p-2"), | |
| ], className="mb-3 shadow-sm border-0"), | |
| html.Hr(className="my-2"), | |
| html.H6("Detailed view — drill down by field", className="fw-semibold mb-2"), | |
| html.Div([ | |
| dbc.Row([ | |
| dbc.Col([ | |
| html.Label("Field", className="fw-semibold small mb-1"), | |
| dcc.Dropdown( | |
| id="dd-metrics-field", | |
| options=[{"label": f.replace("_", " ").title(), "value": f} | |
| for f in METRIC_FIELDS], | |
| value=METRIC_FIELDS[0] if METRIC_FIELDS else None, | |
| clearable=False, | |
| style={"fontSize": "13px"}, | |
| ), | |
| ], width=4), | |
| dbc.Col([ | |
| html.Label("Metric", className="fw-semibold small mb-1"), | |
| dcc.Dropdown( | |
| id="dd-metrics-type", | |
| options=[{"label": v, "value": k} for k, v in _METRIC_LABELS.items()], | |
| value="silhouette_cosine", | |
| clearable=False, | |
| style={"fontSize": "13px"}, | |
| ), | |
| ], width=4), | |
| dbc.Col([ | |
| html.Label("Model (heatmap)", className="fw-semibold small mb-1"), | |
| dcc.Dropdown( | |
| id="dd-metrics-model", | |
| options=[{"label": m, "value": m} for m in METRIC_MODELS], | |
| value=METRIC_MODELS[0] if METRIC_MODELS else None, | |
| clearable=False, | |
| style={"fontSize": "13px"}, | |
| ), | |
| ], width=4), | |
| ]), | |
| ], className="filter-panel"), | |
| dcc.Loading( | |
| dbc.Row([ | |
| dbc.Col( | |
| dcc.Graph(id="metrics-heatmap", | |
| config={"displayModeBar": True, | |
| "toImageButtonOptions": {"format": "svg"}}, | |
| style={"height": "420px"}), | |
| width=6, | |
| ), | |
| dbc.Col( | |
| dcc.Graph(id="metrics-model-compare", | |
| config={"displayModeBar": True, | |
| "toImageButtonOptions": {"format": "svg"}}, | |
| style={"height": "420px"}), | |
| width=6, | |
| ), | |
| ], className="mt-3"), | |
| type="circle", color="#1a73e8", | |
| ), | |
| html.Hr(), | |
| html.H6("Best parameter combinations", className="fw-semibold mt-2 mb-2"), | |
| dcc.Loading(html.Div(id="metrics-best-table"), type="circle", color="#1a73e8"), | |
| ], fluid=True, className="pt-3") | |
| AVATAR_COLORS = ["#1a73e8", "#e8711a", "#1aa85c", "#8e1ae8"] | |
| def person_card(name, email, role, departments, color="#1a73e8"): | |
| initials = "".join(p[0].upper() for p in name.split()[:2]) | |
| return dbc.Card([ | |
| dbc.CardBody([ | |
| html.Div(initials, className="avatar-circle", style={"backgroundColor": color}), | |
| html.H6(name, className="mb-0 fw-bold"), | |
| html.A(email, href=f"mailto:{email}", className="text-muted small d-block mb-2"), | |
| html.Span(role, className="badge rounded-pill mb-2", | |
| style={"backgroundColor": color, "fontSize": "11px"}), | |
| html.Ul([html.Li(d, className="small text-muted") for d in departments], | |
| className="mb-0 ps-3") if departments else None, | |
| ]) | |
| ], className="h-100 shadow-sm person-card border-0") | |
| TEAM = [ | |
| dict( | |
| name="Ricardo Almada Monter", | |
| email="ralmadamonter@ucsd.edu", | |
| role="Graduate Student Researcher", | |
| departments=["Department of Chemistry & Biochemistry, UC San Diego"], | |
| ), | |
| dict( | |
| name="Jose Martinez Lomeli", | |
| email="lomeli90@gmail.com", | |
| role="Independent Researcher", | |
| departments=[], | |
| ), | |
| dict( | |
| name="Erika Garay", | |
| email="ecgaray@health.ucsd.edu", | |
| role="Staff Scientist", | |
| departments=[ | |
| "Skaggs School of Pharmacy and Pharmaceutical Sciences, UC San Diego", | |
| ], | |
| ), | |
| dict( | |
| name="Adrian Jinich, PhD", | |
| email="ajinich@health.ucsd.edu", | |
| role="Assistant Professor", | |
| departments=[ | |
| "Skaggs School of Pharmacy and Pharmaceutical Sciences, UC San Diego", | |
| "Department of Chemistry & Biochemistry, UC San Diego", | |
| ], | |
| ), | |
| ] | |
| def contact_cards(): | |
| return dbc.Row( | |
| [dbc.Col(person_card(**m, color=AVATAR_COLORS[i % len(AVATAR_COLORS)]), width=3) | |
| for i, m in enumerate(TEAM)], | |
| className="g-4", | |
| ) | |
| def pipeline_tab(): | |
| code_block = ( | |
| "bash scripts/run_pipeline_extraction.sh \\\n" | |
| " -s scripts/ \\\n" | |
| " -u uniprot_tables/your_table.tsv.gz \\\n" | |
| " -a api_keys.txt \\\n" | |
| " -l api_keys_llama.txt \\\n" | |
| " -e \"your@email.com\" \\\n" | |
| " -U \"your@email.com\" \\\n" | |
| " -j jsons/output/ \\\n" | |
| " -o artifacts/ \\\n" | |
| " -g norag \\\n" | |
| " -M gpt-4.1-mini" | |
| ) | |
| cluster_block = ( | |
| "python scripts/create_clustering_plots.py \\\n" | |
| " -j jsons/output/your_table_purification_methods_no_rag.json \\\n" | |
| " -o clusters/my_run \\\n" | |
| " -m neuml/pubmedbert-base-embeddings \\\n" | |
| " --clustering \\\n" | |
| " -min 5 \\\n" | |
| " -t 0.75" | |
| ) | |
| return dbc.Container([ | |
| html.H3("How to Run the LLM Protein Purification Extraction Pipeline", className="mt-3 mb-1"), | |
| html.P([ | |
| "Step-by-step instructions for running the automated extraction pipeline — " | |
| "from a UniProt table to structured protein purification conditions. " | |
| "All code is available at ", | |
| html.A("github.com/jinichlab/llm_extractor", | |
| href="https://github.com/jinichlab/llm_extractor", | |
| target="_blank"), | |
| ".", | |
| ], className="text-muted mb-4"), | |
| dbc.Accordion([ | |
| # ── Step 0: prerequisites ─────────────────────────────────────── | |
| dbc.AccordionItem(title="0 · Prerequisites", children=[ | |
| dbc.ListGroup([ | |
| dbc.ListGroupItem([html.Code("conda"), " installed (Anaconda or Miniconda)."]), | |
| dbc.ListGroupItem([ | |
| html.Strong("OpenAI API key"), " set as environment variable:", | |
| html.Pre("export OPENAI_API_KEY=\"sk-...\"", | |
| className="bg-light p-2 rounded mt-1 mb-0"), | |
| ]), | |
| dbc.ListGroupItem([ | |
| html.Strong("LlamaCloud API key"), " — sign in at ", | |
| html.A("cloud.llamaindex.ai", href="https://cloud.llamaindex.ai", | |
| target="_blank"), | |
| ", generate a key, and save it to ", html.Code("api_keys_llama.txt"), ".", | |
| ]), | |
| dbc.ListGroupItem([ | |
| html.Strong("Publisher API keys"), " (Elsevier / Wiley) — save them to ", | |
| html.Code("api_keys.txt"), " one per line:", | |
| html.Pre("elsevier your-key\nwiley your-key", | |
| className="bg-light p-2 rounded mt-1 mb-0"), | |
| ]), | |
| ], flush=True), | |
| ]), | |
| # ── Step 1: install ───────────────────────────────────────────── | |
| dbc.AccordionItem(title="1 · Install the environment", children=[ | |
| html.Pre( | |
| "conda env create -f environment.yml\nconda activate llm_extractor_enviroment", | |
| className="bg-light p-3 rounded mb-0", | |
| ), | |
| ]), | |
| # ── Step 2: prepare input ─────────────────────────────────────── | |
| dbc.AccordionItem(title="2 · Prepare the UniProt input table", children=[ | |
| html.P([ | |
| "Download a UniProt table for your protein family (TSV or TSV.GZ) and place it in ", | |
| html.Code("uniprot_tables/"), | |
| ". The table must include a ", | |
| html.Code("PubMed ID"), | |
| " column so the pipeline can fetch the papers.", | |
| ], className="mb-0"), | |
| ]), | |
| # ── Step 3: run the pipeline ──────────────────────────────────── | |
| dbc.AccordionItem(title="3 · Run the full pipeline", children=[ | |
| html.P("From the repository root:", className="mb-2"), | |
| html.Pre(code_block, className="bg-light p-3 rounded mb-3"), | |
| dbc.Table([ | |
| html.Thead(html.Tr([html.Th("Flag"), html.Th("Required"), html.Th("Description")])), | |
| html.Tbody([ | |
| html.Tr([html.Td(html.Code("-s")), html.Td("yes"), html.Td("Path to the scripts/ directory")]), | |
| html.Tr([html.Td(html.Code("-u")), html.Td("yes"), html.Td("UniProt table (.tsv or .tsv.gz)")]), | |
| html.Tr([html.Td(html.Code("-a")), html.Td("yes"), html.Td("Publisher API keys file (api_keys.txt)")]), | |
| html.Tr([html.Td(html.Code("-l")), html.Td("yes"), html.Td("LlamaCloud API key file (api_keys_llama.txt)")]), | |
| html.Tr([html.Td(html.Code("-e")), html.Td("yes"), html.Td("Email for NCBI Entrez")]), | |
| html.Tr([html.Td(html.Code("-U")), html.Td("yes"), html.Td("User hint passed to the extraction step")]), | |
| html.Tr([html.Td(html.Code("-j")), html.Td("yes"), html.Td("Output directory for all JSON files")]), | |
| html.Tr([html.Td(html.Code("-o")), html.Td("yes"), html.Td("Output directory for PDFs and artifacts")]), | |
| html.Tr([html.Td(html.Code("-g")), html.Td("yes"), html.Td("Extraction mode: rag or norag")]), | |
| html.Tr([html.Td(html.Code("-m")), html.Td("no"), html.Td("Max papers to download (default: 15)")]), | |
| html.Tr([html.Td(html.Code("-M")), html.Td("no"), html.Td("OpenAI model name (default: gpt-4.1-mini)")]), | |
| ]), | |
| ], bordered=True, size="sm", className="mb-0"), | |
| ]), | |
| # ── Step 4: pipeline stages ───────────────────────────────────── | |
| dbc.AccordionItem(title="4 · What the pipeline does (stages)", children=[ | |
| dbc.ListGroup([ | |
| dbc.ListGroupItem([html.Strong("1. Download papers"), " — fetches PDFs/XMLs from PubMed via paperscraper."]), | |
| dbc.ListGroupItem([html.Strong("2. Classify papers"), " — LlamaParse decides whether each paper reports experimental enzymology."]), | |
| dbc.ListGroupItem([html.Strong("3. Filter positives"), " — keeps only papers classified as enzymology."]), | |
| dbc.ListGroupItem([html.Strong("4. Extract Methods sections"), " — OpenAI structured output identifies the Methods text."]), | |
| dbc.ListGroupItem([ | |
| html.Strong("5. Extract purification conditions"), " — structured JSON with 12 fields per protein " | |
| "(organism, strain, plasmid, inducer, buffers, etc.). ", | |
| html.Span("norag", className="badge bg-secondary me-1"), | |
| "sends the full Methods text; ", | |
| html.Span("rag", className="badge bg-primary"), | |
| " retrieves relevant chunks from a ChromaDB vector store first.", | |
| ]), | |
| ], flush=True), | |
| ]), | |
| # ── Step 5: clustering ────────────────────────────────────────── | |
| dbc.AccordionItem(title="5 · Run clustering (optional)", children=[ | |
| html.P( | |
| "After extraction, embed and cluster each field with a biomedical language model. " | |
| "Outputs are loaded by this dashboard.", | |
| className="mb-2", | |
| ), | |
| html.Pre(cluster_block, className="bg-light p-3 rounded mb-3"), | |
| dbc.ListGroup([ | |
| dbc.ListGroupItem([html.Code("-t"), " — cosine similarity threshold (lower = broader clusters)"]), | |
| dbc.ListGroupItem([html.Code("-min"), " — minimum entries to form a cluster (lower = more clusters)"]), | |
| dbc.ListGroupItem([html.Code("-m"), " — embedding model; default ", | |
| html.Code("neuml/pubmedbert-base-embeddings"), | |
| " is optimised for biomedical text"]), | |
| ], flush=True), | |
| ]), | |
| # ── Output files ──────────────────────────────────────────────── | |
| dbc.AccordionItem(title="Output files", children=[ | |
| dbc.Table([ | |
| html.Thead(html.Tr([html.Th("File"), html.Th("Description")])), | |
| html.Tbody([ | |
| html.Tr([html.Td(html.Code("*_papers.json")), html.Td("Paper metadata and download status")]), | |
| html.Tr([html.Td(html.Code("df_classification_*.json")),html.Td("LlamaCloud classification results")]), | |
| html.Tr([html.Td(html.Code("filtered_*.json")), html.Td("Enzymology-positive papers only")]), | |
| html.Tr([html.Td(html.Code("*_method_extraction.json")),html.Td("Extracted Methods sections")]), | |
| html.Tr([html.Td(html.Code("*_purification_methods_*.json")), html.Td("Final structured purification data")]), | |
| html.Tr([html.Td(html.Code("pdfs_*/")), html.Td("Downloaded PDF/XML files")]), | |
| html.Tr([html.Td(html.Code("*_FIELD_CLUSTER_METRICS.csv")), html.Td("Silhouette / Davies-Bouldin scores per field")]), | |
| html.Tr([html.Td(html.Code("*_ALL_FIELDS.csv")), html.Td("Combined clustering table across all fields")]), | |
| ]), | |
| ], bordered=True, size="sm", className="mb-0"), | |
| ]), | |
| ], start_collapsed=True, className="mb-4"), | |
| ], fluid=True, className="pt-3") | |
| def evaluation_tab(): | |
| label_options = [{"label": lbl, "value": lbl} for lbl in _EVAL_LABELS] | |
| return dbc.Container([ | |
| html.H3("Evaluation Results", className="mt-3 mb-1"), | |
| html.P( | |
| "Pipeline evaluation across protein groups and GPT models: " | |
| "methods extraction quality (NLP metrics) and purification conditions accuracy " | |
| "(LLM-based scoring and NLP metrics).", | |
| className="text-muted mb-4", | |
| ), | |
| dbc.Accordion([ | |
| # ── Section 1: Purification conditions ────────────────────────── | |
| dbc.AccordionItem( | |
| title="1 · Purification Conditions Evaluation", | |
| children=[ | |
| # Controls row | |
| dbc.Row([ | |
| dbc.Col([ | |
| html.Label("Protein group", className="fw-semibold small mb-1"), | |
| dcc.RadioItems( | |
| id="eval-group", | |
| options=[ | |
| {"label": " Azoreductases", "value": "azoreductases"}, | |
| {"label": " SAMs", "value": "sams"}, | |
| ], | |
| value="azoreductases", | |
| inline=True, | |
| inputStyle={"marginRight": "4px"}, | |
| labelStyle={"marginRight": "16px"}, | |
| ), | |
| ], width=12, md=3), | |
| dbc.Col([ | |
| html.Label("Models / configurations", className="fw-semibold small mb-1"), | |
| dcc.Checklist( | |
| id="eval-model-checklist", | |
| options=label_options, | |
| value=_EVAL_LABELS, | |
| inline=True, | |
| inputStyle={"marginRight": "4px"}, | |
| labelStyle={"marginRight": "14px", "fontSize": "0.85rem"}, | |
| ), | |
| ], width=12, md=9), | |
| ], className="mb-4 align-items-start"), | |
| # Sub-tabs | |
| dbc.Tabs([ | |
| dbc.Tab(label="LLM-based Evaluation", tab_id="eval-tab-llm", children=[ | |
| html.P( | |
| "Mean LLM similarity score (0–10) per extraction field. " | |
| "Higher = more similar to the ground truth. " | |
| "Click a bar to see examples.", | |
| className="text-muted small mt-2 mb-1", | |
| ), | |
| dcc.Loading( | |
| dcc.Graph(id="eval-llm-graph", | |
| config={"displayModeBar": True, "toImageButtonOptions": {"format": "svg", "filename": "purification_conditions_llm"}}, | |
| style={"height": "460px"}), | |
| type="circle", color="#1a73e8", | |
| ), | |
| dcc.Loading(html.Div(id="eval-llm-examples", className="mt-3"), type="circle", color="#1a73e8"), | |
| ]), | |
| dbc.Tab(label="NLP-based Evaluation", tab_id="eval-tab-nlp", children=[ | |
| dbc.Row([ | |
| dbc.Col([ | |
| html.Label("Metric", className="fw-semibold small mt-2 mb-1"), | |
| dcc.Dropdown( | |
| id="eval-nlp-metric", | |
| options=[{"label": v, "value": k} | |
| for k, v in _NLP_METRICS.items()], | |
| value="bertscore_f1", | |
| clearable=False, | |
| ), | |
| ], width=12, md=3), | |
| ], className="mb-2"), | |
| dcc.Loading( | |
| dcc.Graph(id="eval-nlp-graph", | |
| config={"displayModeBar": True, "toImageButtonOptions": {"format": "svg", "filename": "purification_conditions_nlp"}}, | |
| style={"height": "460px"}), | |
| type="circle", color="#1a73e8", | |
| ), | |
| dcc.Loading(html.Div(id="eval-nlp-examples", className="mt-3"), type="circle", color="#1a73e8"), | |
| ]), | |
| ], id="eval-sub-tabs", active_tab="eval-tab-llm"), | |
| ], | |
| ), | |
| # ── Section 2: Methods extraction ─────────────────────────────── | |
| dbc.AccordionItem( | |
| title="2 · Methods Extraction Quality", | |
| children=[ | |
| html.P( | |
| "Average NLP metrics comparing the extracted Methods section text " | |
| "to the curated ground truth. Click a bar to see the distribution " | |
| "and examples.", | |
| className="text-muted small mb-3", | |
| ), | |
| dcc.RadioItems( | |
| id="classif-group", | |
| options=[ | |
| {"label": " Azoreductases", "value": "azoreductases"}, | |
| {"label": " SAMs", "value": "sams"}, | |
| ], | |
| value="azoreductases", | |
| inline=True, | |
| inputStyle={"marginRight": "4px"}, | |
| labelStyle={"marginRight": "16px"}, | |
| className="mb-3", | |
| ), | |
| dcc.Loading( | |
| dcc.Graph(id="eval-classif-graph", | |
| config={"displayModeBar": False}, | |
| style={"height": "320px"}), | |
| type="circle", color="#1a73e8", | |
| ), | |
| dcc.Loading(html.Div(id="eval-classif-examples", className="mt-3"), type="circle", color="#1a73e8"), | |
| ], | |
| ), | |
| # ── Section 3: Classification confusion matrix ─────────────────── | |
| dbc.AccordionItem( | |
| title="3 · Classification Performance (Enzymology Detection)", | |
| children=[ | |
| html.P( | |
| "Confusion matrix and classification metrics for the LlamaParse " | |
| "paper classification step (enzymology vs. non-enzymology).", | |
| className="text-muted small mb-3", | |
| ), | |
| dcc.RadioItems( | |
| id="confmat-group", | |
| options=[ | |
| {"label": " Azoreductases", "value": "azoreductases"}, | |
| {"label": " SAMs", "value": "sams"}, | |
| ], | |
| value="azoreductases", | |
| inline=True, | |
| inputStyle={"marginRight": "4px"}, | |
| labelStyle={"marginRight": "16px"}, | |
| className="mb-3", | |
| ), | |
| dcc.Loading(html.Div(id="confmat-cards"), type="circle", color="#1a73e8"), | |
| dcc.Loading( | |
| dcc.Graph(id="confmat-heatmap", | |
| config={"displayModeBar": False}, | |
| style={"height": "340px"}), | |
| type="circle", color="#1a73e8", | |
| ), | |
| dcc.Loading(html.Div(id="confmat-examples", className="mt-3"), type="circle", color="#1a73e8"), | |
| ], | |
| ), | |
| ], start_collapsed=False), | |
| ], fluid=True, className="pt-3") | |
| def protocol_configs_tab(): | |
| _PC_FIELDS = ["expression_strain", "inducer", "medium_name", | |
| "plasmid", "lysis_buffer", "elution_buffer"] | |
| _PC_LABELS = { | |
| "expression_strain": "Expression host", | |
| "inducer": "Inducer", | |
| "medium_name": "Growth medium", | |
| "plasmid": "Plasmid", | |
| "lysis_buffer": "Lysis buffer", | |
| "elution_buffer": "Elution buffer", | |
| } | |
| topn_opts = [{"label": f"Top {n} clusters / field", "value": n} | |
| for n in [10, 20, 30, 50]] | |
| return dbc.Container([ | |
| html.H3("Protocol Configurations", className="mt-3 mb-1"), | |
| html.P( | |
| "Most common multi-field protocol combinations derived from BioSimCSE clusters. " | |
| "Each line in the flow diagram represents proteins sharing the same cluster " | |
| "assignments across fields.", | |
| className="text-muted mb-3", | |
| ), | |
| dbc.Row([ | |
| make_dropdown("Model", "pc-dd-model", MODELS, | |
| value="kamalkraj__BioSimCSE-BioLinkBERT-BASE" | |
| if "kamalkraj__BioSimCSE-BioLinkBERT-BASE" in MODELS | |
| else (MODELS[0] if MODELS else None)), | |
| make_dropdown("Min community size", "pc-dd-min", MINS, | |
| value="2" if "2" in MINS else (MINS[0] if MINS else None)), | |
| make_dropdown("Threshold", "pc-dd-threshold", THRESHOLDS, | |
| value="0.9" if "0.9" in THRESHOLDS else (THRESHOLDS[0] if THRESHOLDS else None)), | |
| dbc.Col([ | |
| html.Label("Clusters per field", className="fw-semibold small mb-1"), | |
| dcc.Dropdown( | |
| id="pc-dd-topn", | |
| options=topn_opts, | |
| value=20, | |
| clearable=False, | |
| style={"fontSize": "13px"}, | |
| ), | |
| ]), | |
| ], className="mb-3 g-2"), | |
| dbc.Accordion([ | |
| dbc.AccordionItem(title="Filters", children=[ | |
| dbc.Row([ | |
| dbc.Col([ | |
| html.Label("Protein group", className="fw-semibold small mb-1"), | |
| dcc.Dropdown( | |
| id="pc-filter-groups", | |
| options=[{"label": g, "value": g} for g in ALL_GROUPS], | |
| multi=True, placeholder="All groups", | |
| style={"fontSize": "13px"}, | |
| ), | |
| ], width=12, className="mb-2"), | |
| ]), | |
| dbc.Row([ | |
| dbc.Col([ | |
| html.Label("Expression host", className="fw-semibold small mb-1"), | |
| dcc.Dropdown( | |
| id="pc-filter-host", | |
| options=[{"label": v, "value": v} for v in | |
| ["BL21(DE3)", "Rosetta", "C41/C43", "Other E. coli", | |
| "Human/CHO", "Insect (Sf9)", "Yeast"]], | |
| multi=True, placeholder="Any", | |
| style={"fontSize": "13px"}, | |
| ), | |
| ]), | |
| dbc.Col([ | |
| html.Label("Inducer", className="fw-semibold small mb-1"), | |
| dcc.Dropdown( | |
| id="pc-filter-inducer", | |
| options=[{"label": v, "value": v} for v in | |
| ["IPTG", "Not reported", "Arabinose", "Other inducer"]], | |
| multi=True, placeholder="Any", | |
| style={"fontSize": "13px"}, | |
| ), | |
| ]), | |
| dbc.Col([ | |
| html.Label("Growth medium", className="fw-semibold small mb-1"), | |
| dcc.Dropdown( | |
| id="pc-filter-medium", | |
| options=[{"label": v, "value": v} for v in | |
| ["LB", "TB", "2xYT", "Auto-ind.", "Minimal", | |
| "Other medium"]], | |
| multi=True, placeholder="Any", | |
| style={"fontSize": "13px"}, | |
| ), | |
| ]), | |
| dbc.Col([ | |
| html.Label("Plasmid", className="fw-semibold small mb-1"), | |
| dcc.Dropdown( | |
| id="pc-filter-plasmid", | |
| options=[{"label": v, "value": v} for v in | |
| ["pET", "pGEX", "pQE", "pMAL", | |
| "Other plasmid"]], | |
| multi=True, placeholder="Any", | |
| style={"fontSize": "13px"}, | |
| ), | |
| ]), | |
| ], className="g-2"), | |
| ]), | |
| ], start_collapsed=True, className="mb-3"), | |
| dcc.Loading( | |
| dcc.Graph(id="pc-parcats-graph", style={"height": "560px"}), | |
| type="circle", color="#1a73e8", | |
| ), | |
| html.H5("Top configurations", className="mt-4 mb-2"), | |
| html.P( | |
| "Cluster labels are grouped into top-level categories (BL21, IPTG, LB, pET…) " | |
| "to aggregate across fine-grained t=0.9 clusters. % is relative to all proteins with 4 core fields.", | |
| className="text-muted small mb-2", | |
| ), | |
| dcc.Loading(html.Div(id="pc-config-table"), type="circle", color="#1a73e8"), | |
| ], fluid=True, className="pt-3") | |
| def contact_tab(): | |
| return dbc.Container([ | |
| html.H3("Research Team", className="mt-3 mb-4"), | |
| contact_cards(), | |
| ], fluid=True, className="pt-3") | |
| def readme_tab(): | |
| return dbc.Container([ | |
| html.H3("ProtoPure — Dashboard Guide", className="mt-3 mb-1"), | |
| html.P( | |
| "ProtoPure displays protein purification conditions extracted from the scientific " | |
| "literature by an LLM pipeline. Use the tabs to explore the data, inspect " | |
| "clustering results, compare models, and find instructions for running the " | |
| "pipeline yourself. Click a section below to expand it.", | |
| className="text-muted mb-4", | |
| ), | |
| dbc.Accordion([ | |
| # ── Extraction Data ─────────────────────────────────────────────── | |
| dbc.AccordionItem(title="Extraction Data", children=[ | |
| html.P( | |
| "One row per extracted protein. Each row combines the 12 structured " | |
| "purification fields (organism, strain, plasmid, inducer, buffers, etc.) " | |
| "with paper metadata and linked UniProt entries.", | |
| className="mb-3", | |
| ), | |
| html.H6("Filters", className="fw-bold"), | |
| dbc.ListGroup([ | |
| dbc.ListGroupItem([ | |
| html.Span("Filter by group ", className="fw-semibold"), | |
| "Restrict the table to one or more protein families " | |
| "(azoreductases, sdrs, sams, etc.). Multiple groups can be selected simultaneously.", | |
| ]), | |
| dbc.ListGroupItem([ | |
| html.Span("Search by / Search value ", className="fw-semibold"), | |
| "Choose a field (PMID, Enzyme name, Organism, Expression strain, Plasmid, " | |
| "Inducer, UniProt ID, Journal, or Paper title) and type any text. " | |
| "Matching is case-insensitive and partial (e.g. 'coli' matches 'Escherichia coli').", | |
| ]), | |
| dbc.ListGroupItem([ | |
| html.Span("✕ Clear ", className="fw-semibold"), | |
| "Resets the search value and group filter, returning to the full dataset.", | |
| ]), | |
| dbc.ListGroupItem([ | |
| html.Span("Cross-filter: field / value ", className="fw-semibold"), | |
| "Select a purification field and one of its most common values to restrict " | |
| "the table to proteins where that field matches. For example, set field = " | |
| "Organism Source and value = 'Escherichia coli', then add Elution Buffer " | |
| "to the visible columns to see all elution buffers used with E. coli proteins. " | |
| "Combines with group and search filters.", | |
| ]), | |
| ], flush=True, className="mb-3"), | |
| html.H6("Table", className="fw-bold mt-2"), | |
| dbc.ListGroup([ | |
| dbc.ListGroupItem([ | |
| html.Span("Default columns ", className="fw-semibold"), | |
| "PMID, Enzyme name, and Organism source are shown by default. " | |
| "Use the column selector to add or hide any of the 12 extracted fields.", | |
| ]), | |
| dbc.ListGroupItem([ | |
| html.Span("Hover ", className="fw-semibold"), | |
| "over a truncated cell to see its full text.", | |
| ]), | |
| dbc.ListGroupItem([ | |
| html.Span("Click a column header ", className="fw-semibold"), | |
| "to sort ascending/descending.", | |
| ]), | |
| dbc.ListGroupItem([ | |
| html.Span("Click a row ", className="fw-semibold"), | |
| "to open the detail panel below the table. The panel shows four cards: " | |
| "all extracted purification fields, linked UniProt entries (with links to uniprot.org), " | |
| "paper metadata (title, journal, date, group badges, PubMed link, full-text link), " | |
| "and a full conditions table for every protein in that paper.", | |
| ]), | |
| ], flush=True, className="mb-3"), | |
| html.H6("Download CSV", className="fw-bold mt-2"), | |
| html.P( | |
| "Downloads the currently visible (filtered) table as a CSV. " | |
| "Apply filters first — the download reflects exactly what is shown on screen.", | |
| className="mb-0", | |
| ), | |
| ]), | |
| # ── Clustering Explorer ─────────────────────────────────────────── | |
| dbc.AccordionItem(title="Clustering Explorer", children=[ | |
| html.P([ | |
| "Explore semantic clusters of extracted field values. Embeddings are computed " | |
| "with biomedical language models; community detection groups semantically similar " | |
| "entries into clusters. All plots are rendered natively in Plotly — hover, zoom, " | |
| "and pan are fully interactive. ", | |
| html.Span("Recommended settings: ", className="fw-semibold"), | |
| "Model = BioSimCSE-BioLinkBERT-BASE, Threshold = 0.9, Min community size = 2 " | |
| "(best mean silhouette across all fields).", | |
| ], | |
| className="mb-3", | |
| ), | |
| html.H6("Parameter dropdowns", className="fw-bold"), | |
| dbc.ListGroup([ | |
| dbc.ListGroupItem([ | |
| html.Span("Model ", className="fw-semibold"), | |
| "Sentence-embedding model used to encode field values. " | |
| "BioSimCSE-BioLinkBERT-BASE achieves the highest mean silhouette (0.899) " | |
| "and is selected by default.", | |
| ]), | |
| dbc.ListGroupItem([ | |
| html.Span("Min community size ", className="fw-semibold"), | |
| "Minimum entries required to form a cluster. Lower = more, smaller clusters.", | |
| ]), | |
| dbc.ListGroupItem([ | |
| html.Span("Threshold ", className="fw-semibold"), | |
| "Cosine similarity threshold for cluster membership. Higher = tighter clusters. " | |
| "0.9 is the recommended value for BioSimCSE.", | |
| ]), | |
| dbc.ListGroupItem([ | |
| html.Span("Field ", className="fw-semibold"), | |
| "Which extraction field to display: enzyme name, organism, lysis buffer, " | |
| "inducer, etc. Clustering is performed independently per field.", | |
| ]), | |
| dbc.ListGroupItem([ | |
| html.Span("Filter by group ", className="fw-semibold"), | |
| "Restrict the plot to entries from one or more protein families.", | |
| ]), | |
| dbc.ListGroupItem([ | |
| html.Span("Show top N clusters ", className="fw-semibold"), | |
| "The largest N clusters each get a distinct colour; all remaining clusters " | |
| "are merged into a light-gray \"Other\" trace. Choose \"All\" to colour every " | |
| "cluster individually (may be slow for large fields).", | |
| ]), | |
| dbc.ListGroupItem([ | |
| html.Span("Cross-filter: field / value ", className="fw-semibold"), | |
| "Restrict the plot to proteins that belong to a specific cluster in a " | |
| "different field. For example, set cross-filter field = Organism Source " | |
| "and value = 'Escherichia coli', then set Field = Elution Buffer — the " | |
| "UMAP and distribution chart will show elution buffer clusters only for " | |
| "E. coli proteins. The value dropdown is populated with the top 200 most " | |
| "common cluster labels for the selected cross-filter field and current " | |
| "model / threshold settings.", | |
| ]), | |
| dbc.ListGroupItem([ | |
| html.Span("Filter by publication era ", className="fw-semibold"), | |
| "Restrict all clusters to papers published in a specific time window " | |
| "(approximated from PMID ranges: pre-2001 through 2021–2023). " | |
| "Leave blank to include all time periods. " | |
| "Use this to explore how purification practices evolved over time — " | |
| "for example, set Field = Induction Temperature and step through eras " | |
| "to see the shift from 37 °C to low-temperature induction.", | |
| ]), | |
| ], flush=True, className="mb-3"), | |
| html.H6("UMAP cluster plot", className="fw-bold mt-2"), | |
| dbc.ListGroup([ | |
| dbc.ListGroupItem( | |
| "2D projection of all field values coloured by cluster. " | |
| "Hover over a point to see the original text, PMID, and cluster label." | |
| ), | |
| dbc.ListGroupItem( | |
| "Noise points (not assigned to any cluster) are shown in light gray at low opacity." | |
| ), | |
| dbc.ListGroupItem([ | |
| html.Span("Click a point ", className="fw-semibold"), | |
| "to show a detail panel with that protein's full purification conditions.", | |
| ]), | |
| ], flush=True, className="mb-3"), | |
| html.H6("Cluster distribution (bar chart)", className="fw-bold mt-2"), | |
| dbc.ListGroup([ | |
| dbc.ListGroupItem( | |
| "Bar chart of the top-N clusters sorted by size. " | |
| "Quickly see which conditions are most common across the dataset." | |
| ), | |
| dbc.ListGroupItem([ | |
| html.Span("Click a bar ", className="fw-semibold"), | |
| "to show a table of all proteins in that cluster, with a Download CSV button.", | |
| ]), | |
| ], flush=True, className="mb-3"), | |
| html.H6("Metrics table", className="fw-bold mt-2"), | |
| html.P( | |
| "Shows Silhouette score (cosine) and Davies-Bouldin index for every field " | |
| "under the selected model / min-size / threshold combination. " | |
| "Silhouette > 0.6 (highlighted green) indicates well-separated clusters; " | |
| "lower Davies-Bouldin indicates more compact, better-separated clusters.", | |
| className="mb-0", | |
| ), | |
| ]), | |
| # ── Grid Metrics ────────────────────────────────────────────────── | |
| dbc.AccordionItem(title="Grid Metrics", children=[ | |
| html.P( | |
| "Interactive charts comparing clustering quality across all combinations of " | |
| "embedding model, similarity threshold, and min community size " | |
| "(14 models × 5 thresholds × 5 min-sizes). " | |
| "Loaded from the pre-computed metrics CSVs at startup.", | |
| className="mb-3", | |
| ), | |
| html.H6("Grid Search Overview heatmap", className="fw-bold"), | |
| html.P([ | |
| "The top heatmap shows mean silhouette score for every (model, threshold) pair. " | |
| "Rows are sorted by best score; the best cell is marked ★. " | |
| "Use the ", | |
| html.Span("View by field ", className="fw-semibold"), | |
| "dropdown to switch between a global view (mean over all fields and min-sizes) " | |
| "and any individual extraction field (e.g. Elution Buffer, Induction Temperature). " | |
| "The figure can be downloaded as SVG via the camera icon.", | |
| ], className="mb-3"), | |
| html.H6("Detailed view controls", className="fw-bold"), | |
| dbc.ListGroup([ | |
| dbc.ListGroupItem([ | |
| html.Span("Field ", className="fw-semibold"), | |
| "Which extraction field to display metrics for.", | |
| ]), | |
| dbc.ListGroupItem([ | |
| html.Span("Metric ", className="fw-semibold"), | |
| html.Ul([ | |
| html.Li([html.Span("Silhouette Score ", className="fw-semibold"), | |
| "— ranges −1 to 1; higher is better."]), | |
| html.Li([html.Span("Davies-Bouldin Index ", className="fw-semibold"), | |
| "— non-negative; lower is better."]), | |
| html.Li([html.Span("Number of Clusters ", className="fw-semibold"), | |
| "— how many clusters were found at each parameter combination."]), | |
| ], className="mb-0 mt-1"), | |
| ]), | |
| dbc.ListGroupItem([ | |
| html.Span("Model (heatmap) ", className="fw-semibold"), | |
| "Select the embedding model shown in the threshold × min-size heatmap below.", | |
| ]), | |
| ], flush=True, className="mb-3"), | |
| html.H6("Detailed charts", className="fw-bold mt-2"), | |
| dbc.ListGroup([ | |
| dbc.ListGroupItem([ | |
| html.Span("Threshold × min-size heatmap ", className="fw-semibold"), | |
| "For the selected model and field: threshold (x) vs min community size (y) " | |
| "coloured by the chosen metric. Hover to see exact values.", | |
| ]), | |
| dbc.ListGroupItem([ | |
| html.Span("Model comparison bar chart ", className="fw-semibold"), | |
| "Best metric value per model for the selected field, across all parameter combos.", | |
| ]), | |
| dbc.ListGroupItem([ | |
| html.Span("Top-10 configurations table ", className="fw-semibold"), | |
| "The ten parameter combinations with the best metric score for the selected field.", | |
| ]), | |
| ], flush=True), | |
| ]), | |
| # ── Evaluation Results ──────────────────────────────────────────── | |
| dbc.AccordionItem(title="Evaluation Results", children=[ | |
| html.P( | |
| "Pipeline evaluation across two protein groups (Azoreductases and SAMs) " | |
| "and six GPT model configurations (gpt-4.1, gpt-5-mini, gpt-5 × no-RAG / RAG). " | |
| "Three sections are available.", | |
| className="mb-3", | |
| ), | |
| html.H6("1 · Purification Conditions Evaluation", className="fw-bold"), | |
| dbc.ListGroup([ | |
| dbc.ListGroupItem([ | |
| html.Span("LLM-based evaluation ", className="fw-semibold"), | |
| "Mean LLM similarity score (0–10) per extraction field, with standard-error bars. " | |
| "Click a bar to see the score distribution (box plot + histogram) and " | |
| "the 5 worst / 5 best protein pairs for that field and model.", | |
| ]), | |
| dbc.ListGroupItem([ | |
| html.Span("NLP-based evaluation ", className="fw-semibold"), | |
| "Same layout using NLP metrics (BERTScore F1, ROUGE-1 F, BLEU, METEOR, " | |
| "Cosine Similarity). All metrics normalised to 0–1. " | |
| "Use the metric dropdown to switch between metrics. " | |
| "Click a bar for distribution and examples.", | |
| ]), | |
| ], flush=True, className="mb-3"), | |
| html.H6("2 · Methods Extraction Quality", className="fw-bold"), | |
| html.P( | |
| "Average NLP metrics comparing the extracted Methods section text to the curated " | |
| "ground truth (Azoreductases: 31 papers; SAMs: 292 papers). " | |
| "All metrics normalised to 0–1. " | |
| "Click a bar to see the distribution and 5 worst / 5 best paper excerpts.", | |
| className="mb-3", | |
| ), | |
| html.H6("3 · Classification Performance (Enzymology Detection)", className="fw-bold"), | |
| dbc.ListGroup([ | |
| dbc.ListGroupItem([ | |
| html.Span("Metric cards ", className="fw-semibold"), | |
| "Precision, Recall, F1 Score, and Accuracy for the LlamaParse " | |
| "enzymology classification step.", | |
| ]), | |
| dbc.ListGroupItem([ | |
| html.Span("Confusion matrix ", className="fw-semibold"), | |
| "Interactive heatmap (Actual × Predicted) with counts and percentages. " | |
| "Click any cell (TP / FP / FN / TN) to see up to 10 example papers " | |
| "from that category with title, date, journal, and PubMed link.", | |
| ]), | |
| dbc.ListGroupItem([ | |
| html.Span("Azoreductases ", className="fw-semibold"), | |
| "All 31 ground-truth papers were classified as enzymology " | |
| "(Precision = Recall = F1 = 1.0; no negative set available).", | |
| ]), | |
| dbc.ListGroupItem([ | |
| html.Span("SAMs ", className="fw-semibold"), | |
| "Full 2×2 confusion matrix from 292 papers " | |
| "(Precision ≈ 0.69, Recall ≈ 0.88, F1 ≈ 0.78).", | |
| ]), | |
| ], flush=True), | |
| ]), | |
| # ── Extraction Pipeline Instructions ────────────────────────────── | |
| dbc.AccordionItem(title="Extraction Pipeline Instructions", children=[ | |
| html.P([ | |
| "Step-by-step guide for running the LLM extraction pipeline locally " | |
| "to produce your own dataset. Full details are in the ", | |
| html.A("Extraction Pipeline Instructions", | |
| href="#", id="readme-pipeline-link"), | |
| " tab. Source code: ", | |
| html.A("github.com/jinichlab/llm_extractor", | |
| href="https://github.com/jinichlab/llm_extractor", | |
| target="_blank"), | |
| ".", | |
| ], className="mb-3"), | |
| dbc.ListGroup([ | |
| dbc.ListGroupItem([html.Span("0 · Prerequisites ", className="fw-semibold"), | |
| "conda, OpenAI key, LlamaCloud key, publisher API keys."]), | |
| dbc.ListGroupItem([html.Span("1 · Install ", className="fw-semibold"), | |
| html.Code("conda env create -f environment.yml"), | |
| " + ", html.Code("conda activate llm_extractor_enviroment"), "."]), | |
| dbc.ListGroupItem([html.Span("2 · Input ", className="fw-semibold"), | |
| "UniProt TSV with a PubMed ID column in ", | |
| html.Code("uniprot_tables/"), "."]), | |
| dbc.ListGroupItem([html.Span("3 · Run ", className="fw-semibold"), | |
| html.Code("scripts/run_pipeline_extraction.sh"), | |
| " — downloads papers, classifies them, extracts Methods, " | |
| "and outputs structured purification JSON."]), | |
| dbc.ListGroupItem([html.Span("4 · Cluster (optional) ", className="fw-semibold"), | |
| html.Code("scripts/create_clustering_plots.py"), | |
| " — embeds and clusters each field; outputs loaded by this dashboard."]), | |
| ], flush=True), | |
| ]), | |
| # ── Contact ─────────────────────────────────────────────────────── | |
| dbc.AccordionItem(title="Contact", children=[contact_cards()]), | |
| ], start_collapsed=True, always_open=True), | |
| ], fluid=True, className="pt-3") | |
| # --------------------------------------------------------------------------- | |
| # App layout | |
| # --------------------------------------------------------------------------- | |
| app.layout = dbc.Container([ | |
| dbc.Navbar( | |
| dbc.Container([ | |
| html.Div([ | |
| html.Span("⚗ ProtoPure", className="navbar-brand text-white"), | |
| html.Span("LLM-Enhanced Systematic Extraction and Comparison of Protein Purification Conditions", | |
| className="navbar-subtitle text-white"), | |
| ]), | |
| ], fluid=True), | |
| color="#1a3a5c", | |
| dark=True, | |
| className="mb-3 rounded shadow-sm px-3 py-2", | |
| ), | |
| dbc.Tabs([ | |
| dbc.Tab(label="Extraction Data", tab_id="tab-proteins"), | |
| dbc.Tab(label="Clustering Explorer",tab_id="tab-clustering"), | |
| dbc.Tab(label="Grid Metrics", tab_id="tab-grid-metrics"), | |
| dbc.Tab(label="Protocol Configurations",tab_id="tab-protocol-configs"), | |
| dbc.Tab(label="Evaluation Results", tab_id="tab-evaluation"), | |
| dbc.Tab(label="Extraction Pipeline Instructions", tab_id="tab-pipeline"), | |
| dbc.Tab(label="README", tab_id="tab-readme"), | |
| dbc.Tab(label="Contact", tab_id="tab-contact"), | |
| ], id="main-tabs", active_tab="tab-proteins"), | |
| html.Div(id="tab-content", className="mt-2"), | |
| ], fluid=True) | |
| # --------------------------------------------------------------------------- | |
| # Callbacks | |
| # --------------------------------------------------------------------------- | |
| def render_tab(tab): | |
| if tab == "tab-proteins": | |
| return proteins_tab() | |
| elif tab == "tab-readme": | |
| return readme_tab() | |
| elif tab == "tab-contact": | |
| return contact_tab() | |
| elif tab == "tab-clustering": | |
| return clustering_tab() | |
| elif tab == "tab-grid-metrics": | |
| return grid_metrics_tab() | |
| elif tab == "tab-protocol-configs": | |
| return protocol_configs_tab() | |
| elif tab == "tab-evaluation": | |
| return evaluation_tab() | |
| elif tab == "tab-pipeline": | |
| return pipeline_tab() | |
| return html.Div() | |
| def _apply_filters(groups, search_field, search_value, cross_field=None, cross_value=None): | |
| filtered = df.copy() | |
| if groups: | |
| filtered = filtered[filtered["groups"].apply( | |
| lambda g: any(sel in g.split(", ") for sel in groups) | |
| )] | |
| if search_value and search_field and search_field in filtered.columns: | |
| filtered = filtered[ | |
| filtered[search_field].astype(str).str.contains(search_value, case=False, na=False) | |
| ] | |
| if cross_field and cross_value and cross_field in filtered.columns: | |
| filtered = filtered[ | |
| filtered[cross_field].astype(str).str.contains(cross_value, case=False, na=False) | |
| ] | |
| return filtered | |
| def populate_table_cross_values(cross_field): | |
| if not cross_field or cross_field not in df.columns: | |
| return [], True, None | |
| vals = ( | |
| df[cross_field].dropna().astype(str) | |
| .loc[lambda s: s.str.strip() != ""] | |
| .value_counts() | |
| .head(200) | |
| .index.tolist() | |
| ) | |
| return [{"label": v, "value": v} for v in vals], False, None | |
| def filter_proteins(groups, search_field, search_value, selected_cols, cross_field, cross_value): | |
| filtered = _apply_filters(groups, search_field, search_value, cross_field, cross_value) | |
| cols = selected_cols if selected_cols else DEFAULT_COLS | |
| hidden = [c for c in TABLE_FIELDS if c not in cols] | |
| records = filtered[TABLE_FIELDS].to_dict("records") | |
| tooltips = [ | |
| {c: {"value": str(row.get(c, "")), "type": "markdown"} for c in cols} | |
| for row in records | |
| ] | |
| label = f"Showing {len(filtered):,} of {len(df):,} proteins" | |
| return records, hidden, tooltips, label | |
| def show_detail(selected_rows, groups, search_field, search_value, cross_field, cross_value): | |
| if not selected_rows: | |
| return html.Div() | |
| filtered = _apply_filters(groups, search_field, search_value, cross_field, cross_value) | |
| clicked = selected_rows[0] | |
| if clicked >= len(filtered): | |
| return dash.no_update | |
| row = filtered.iloc[clicked].to_dict() | |
| pmid = str(row.get("pmid", "")) | |
| entry = RAW.get(pmid, {}) | |
| uniprot_ids = entry.get("Uniprot_IDS", []) or [] | |
| protein_names = entry.get("Protein_names", []) or [] | |
| organisms = entry.get("Organisms", []) or [] | |
| sequences = entry.get("Sequences", []) or [] | |
| # --- Extracted protein card --- | |
| extraction_items = [] | |
| for field in EXTRACTION_FIELDS[1:]: # skip pmid | |
| val = row.get(field, "") | |
| if val: | |
| item = detail_field(field.replace("_", " ").title(), val) | |
| if item: | |
| extraction_items.append(item) | |
| # Paper metadata from META | |
| m = META.get(str(pmid), {}) | |
| pubmed_link = html.A( | |
| f"PubMed: {pmid}", | |
| href=f"https://pubmed.ncbi.nlm.nih.gov/{pmid}/", | |
| target="_blank", | |
| className="small", | |
| ) | |
| # --- Extracted protein card --- | |
| extracted_card = dbc.Card([ | |
| dbc.CardHeader(html.Span("Extracted Protein", className="fw-bold")), | |
| dbc.CardBody(extraction_items or [html.Span("No extraction data.", className="text-muted small")]), | |
| ], className="mb-3", color="light") | |
| # --- Paper metadata card --- | |
| paper_links = [pubmed_link] | |
| if m.get("url"): | |
| paper_links += [ | |
| html.Span(" · ", className="text-muted mx-1"), | |
| html.A("Full text", href=m["url"], target="_blank", className="small"), | |
| ] | |
| paper_body = [] | |
| if m.get("title"): | |
| paper_body.append(html.P(m["title"], className="fw-semibold small mb-2")) | |
| meta_line = " · ".join(filter(None, [m.get("source", ""), m.get("pub_date", "")])) | |
| if meta_line: | |
| paper_body.append(html.P(meta_line, className="text-muted small mb-1")) | |
| if m.get("groups"): | |
| paper_body.append(html.P( | |
| [html.Span("Groups: ", className="fw-semibold")] + | |
| [dbc.Badge(g, color="primary", className="me-1") for g in m["groups"]], | |
| className="mb-0" | |
| )) | |
| paper_card = dbc.Card([ | |
| dbc.CardHeader(html.Div(paper_links)), | |
| dbc.CardBody(paper_body or [html.Span("No paper metadata available.", className="text-muted small")]), | |
| ], className="mb-3") | |
| # --- UniProt entries card --- | |
| if uniprot_ids: | |
| uniprot_rows = [] | |
| for i, uid in enumerate(uniprot_ids): | |
| name = protein_names[i] if i < len(protein_names) else "—" | |
| org = organisms[i] if i < len(organisms) else "—" | |
| seq = sequences[i] if i < len(sequences) else None | |
| seq_info = f"{len(seq)} aa" if seq else "—" | |
| uniprot_rows.append( | |
| dbc.ListGroupItem([ | |
| dbc.Row([ | |
| dbc.Col([ | |
| html.A(uid, | |
| href=f"https://www.uniprot.org/uniprot/{uid}", | |
| target="_blank", | |
| className="fw-bold small me-2"), | |
| html.Span(org, className="text-muted small"), | |
| ], width=4), | |
| dbc.Col(html.Span(name, className="small"), width=6), | |
| dbc.Col(html.Span(seq_info, className="text-muted small"), width=2), | |
| ], align="center"), | |
| ]) | |
| ) | |
| uniprot_card = dbc.Card([ | |
| dbc.CardHeader(html.Span( | |
| f"UniProt Entries for PMID {pmid} ({len(uniprot_ids)} entries)", | |
| className="fw-bold", | |
| )), | |
| dbc.CardBody([ | |
| dbc.Row([ | |
| dbc.Col(html.Span("UniProt ID / Organism", className="fw-semibold small"), width=4), | |
| dbc.Col(html.Span("Protein Name", className="fw-semibold small"), width=6), | |
| dbc.Col(html.Span("Sequence", className="fw-semibold small"), width=2), | |
| ], className="px-3 mb-1"), | |
| dbc.ListGroup(uniprot_rows, flush=True), | |
| ]), | |
| ], className="mb-3") | |
| else: | |
| uniprot_card = dbc.Card([ | |
| dbc.CardHeader("UniProt Entries"), | |
| dbc.CardBody(html.Span("No UniProt entries linked to this PMID.", className="text-muted small")), | |
| ], className="mb-3", color="light") | |
| # --- All proteins from this PMID (conditions table) --- | |
| all_proteins = entry.get("proteins", []) or [] | |
| CONDITION_COLS = [ | |
| "enzyme_name", "organism_source", "strain", "expression_strain", | |
| "plasmid", "molecular_weight", "medium_name", "inducer", | |
| "induction_temperature", "lysis_buffer", "elution_buffer", "desalting_process", | |
| ] | |
| cond_records = [ | |
| {c: (p.get(c) or "") for c in CONDITION_COLS} | |
| for p in all_proteins | |
| ] | |
| # Mark the selected protein by matching enzyme_name + organism_source | |
| sel_idx = next( | |
| (i for i, p in enumerate(all_proteins) | |
| if str(p.get("enzyme_name", "") or "") == str(row.get("enzyme_name", "") or "") | |
| and str(p.get("organism_source", "") or "") == str(row.get("organism_source", "") or "")), | |
| None, | |
| ) | |
| cond_col_defs = [ | |
| {"name": c.replace("_", " ").title(), "id": c} for c in CONDITION_COLS | |
| ] | |
| conditions_card = dbc.Card([ | |
| dbc.CardHeader( | |
| html.Span( | |
| f"All purification conditions from PMID {pmid} ({len(all_proteins)} protein{'s' if len(all_proteins) != 1 else ''})", | |
| className="fw-bold", | |
| ) | |
| ), | |
| dbc.CardBody( | |
| dash_table.DataTable( | |
| columns=cond_col_defs, | |
| data=cond_records, | |
| page_size=10, | |
| sort_action="native", | |
| style_table={"overflowX": "auto"}, | |
| style_cell={ | |
| "fontSize": "12px", | |
| "padding": "5px 10px", | |
| "textAlign": "left", | |
| "maxWidth": "260px", | |
| "overflow": "hidden", | |
| "textOverflow": "ellipsis", | |
| "whiteSpace": "nowrap", | |
| }, | |
| style_header={ | |
| "fontWeight": "700", | |
| "backgroundColor": "#1a3a5c", | |
| "color": "#ffffff", | |
| "fontSize": "11px", | |
| "textTransform": "uppercase", | |
| "letterSpacing": "0.04em", | |
| }, | |
| style_data_conditional=( | |
| [{"if": {"row_index": "odd"}, "backgroundColor": "#eef4fd"}] + | |
| ([{"if": {"row_index": sel_idx}, "backgroundColor": "#cfe2ff", | |
| "border": "1px solid #9ec5fe"}] if sel_idx is not None else []) | |
| ), | |
| tooltip_data=[ | |
| {c: {"value": str(r.get(c, "")), "type": "markdown"} for c in CONDITION_COLS} | |
| for r in cond_records | |
| ], | |
| tooltip_delay=0, | |
| tooltip_duration=None, | |
| ) if cond_records else html.Span("No protein conditions available.", className="text-muted small") | |
| ), | |
| ], className="mb-3") | |
| return html.Div([ | |
| html.Hr(), | |
| html.H6("Selected Row Detail", className="fw-semibold mb-3"), | |
| dbc.Row([ | |
| dbc.Col(extracted_card, width=5), | |
| dbc.Col(uniprot_card, width=7), | |
| ]), | |
| dbc.Row([ | |
| dbc.Col(_mapped_uniprot_card(row), width=12), | |
| ]), | |
| dbc.Row([ | |
| dbc.Col(paper_card, width=12), | |
| ]), | |
| dbc.Row([ | |
| dbc.Col(conditions_card, width=12), | |
| ]), | |
| ]) | |
| # Qualitative color palette — high-contrast, colorblind-friendly base | |
| _PALETTE = [ | |
| "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", | |
| "#8c564b", "#e377c2", "#17becf", "#bcbd22", "#393b79", | |
| "#637939", "#8c6d31", "#843c39", "#7b4173", "#3182bd", | |
| "#e6550d", "#31a354", "#756bb1", "#636363", "#6baed6", | |
| ] | |
| _NOISE_COLOR = "#c0c0c0" | |
| # Curation-status keywords: they say how well studied an entry is, not what the | |
| # protein is. Kept in the searchable column, hidden from the detail badges. | |
| KW_CURATION = { | |
| "Reference proteome", "3D-structure", "Direct protein sequencing", | |
| "Proteomics identification", "Complete proteome", "Sequencing", | |
| } | |
| LOC_COLORS = { | |
| "Cytoplasm / cytosol": "secondary", "Cell membrane": "info", | |
| "Cell inner membrane": "info", "Cell outer membrane": "info", | |
| "Secreted": "success", "Periplasm": "warning", | |
| } | |
| def _row_from_uniprot_block(protein): | |
| """Row-shaped dict from a raw protein record's `uniprot` block.""" | |
| up = (protein or {}).get("uniprot") or {} | |
| row = {dst: (up.get(src) or "") for src, dst in UNIPROT_MAP_COLS.items()} | |
| for src, (dst, sep) in UNIPROT_LIST_COLS.items(): | |
| row[dst] = sep.join(up.get(src) or []) | |
| row["localization"] = row["up_localization"] if up.get("localization_confident") else "" | |
| return row | |
| def _mapped_uniprot_card(row): | |
| """Card for the per-protein UniProt mapping (accession + cellular localization).""" | |
| acc = row.get("up_accession", "") | |
| if not acc: | |
| status = row.get("up_match_status", "") or "not mapped" | |
| return dbc.Card([ | |
| dbc.CardHeader(html.Span("Mapped UniProt Entry", className="fw-bold")), | |
| dbc.CardBody(html.Span( | |
| f"This protein could not be mapped to a Swiss-Prot entry ({status}).", | |
| className="text-muted small")), | |
| ], className="mb-3", color="light") | |
| loc = row.get("localization", "") | |
| loc_raw = row.get("up_location_raw", "") | |
| body = [ | |
| html.Div([ | |
| html.A(acc, href=f"https://www.uniprot.org/uniprotkb/{acc}", | |
| target="_blank", className="fw-bold small me-2"), | |
| html.Span(row.get("up_protein_name", ""), className="small"), | |
| ], className="mb-2"), | |
| ] | |
| if loc: | |
| body.append(html.Div([ | |
| html.Span("Cellular localization: ", className="fw-semibold text-muted small"), | |
| dbc.Badge(loc, color=LOC_COLORS.get(loc, "primary"), className="me-1"), | |
| ], className="mb-1")) | |
| elif loc_raw: | |
| body.append(html.Div([ | |
| html.Span("Cellular localization: ", className="fw-semibold text-muted small"), | |
| html.Span("ambiguous across candidate entries", className="small fst-italic"), | |
| ], className="mb-1")) | |
| else: | |
| body.append(html.Div([ | |
| html.Span("Cellular localization: ", className="fw-semibold text-muted small"), | |
| html.Span("not annotated in UniProt", className="small fst-italic"), | |
| ], className="mb-1")) | |
| kws = [k for k in str(row.get("up_keywords", "")).split("; ") | |
| if k and k not in KW_CURATION] | |
| if kws: | |
| body.append(html.Div( | |
| [html.Span("Keywords: ", className="fw-semibold text-muted small")] + | |
| [dbc.Badge(k, color="light", text_color="dark", | |
| className="me-1 mb-1 border") for k in kws], | |
| className="mb-1")) | |
| def _num(key, unit): | |
| v = row.get(key, "") | |
| return f"{int(v):,} {unit}" if isinstance(v, (int, float)) and v else "" | |
| for label, val in (("Gene", row.get("up_gene", "")), | |
| ("UniProt organism", row.get("up_organism", "")), | |
| ("Enzyme class", row.get("up_ec_class", "")), | |
| ("EC number", row.get("up_ec_number", "")), | |
| ("Cofactor", row.get("up_cofactors", "")), | |
| ("Protein family", row.get("up_family", "")), | |
| ("Length", _num("up_length", "aa")), | |
| ("Mass", _num("up_mass", "Da")), | |
| ("Signal peptide", row.get("up_signal_peptide", "")), | |
| ("Transmembrane", row.get("up_transmembrane", "")), | |
| ("Mapping", row.get("up_match_status", ""))): | |
| item = detail_field(label, val) | |
| if item: | |
| body.append(item) | |
| if loc_raw: | |
| body.append(html.Details([ | |
| html.Summary("UniProt subcellular location (raw)", | |
| className="text-muted small"), | |
| html.Div(str(loc_raw), className="small text-muted mt-1"), | |
| ], className="mt-2")) | |
| if row.get("up_lineage", ""): | |
| body.append(html.Details([ | |
| html.Summary("Taxonomic lineage", className="text-muted small"), | |
| html.Div(str(row["up_lineage"]), className="small text-muted mt-1"), | |
| ], className="mt-1")) | |
| return dbc.Card([ | |
| dbc.CardHeader(html.Span("Mapped UniProt Entry", className="fw-bold")), | |
| dbc.CardBody(body), | |
| ], className="mb-3") | |
| def _uniprot_card(pmid): | |
| """Build a UniProt entries card for a given PMID.""" | |
| entry = RAW.get(str(pmid), {}) | |
| uniprot_ids = entry.get("Uniprot_IDS", []) or [] | |
| protein_names = entry.get("Protein_names", []) or [] | |
| organisms = entry.get("Organisms", []) or [] | |
| sequences = entry.get("Sequences", []) or [] | |
| if not uniprot_ids: | |
| return dbc.Card([ | |
| dbc.CardHeader(html.Span("UniProt Entries", className="fw-bold")), | |
| dbc.CardBody(html.Span("No UniProt entries linked to this PMID.", | |
| className="text-muted small")), | |
| ], className="mb-3", color="light") | |
| rows = [] | |
| for i, uid in enumerate(uniprot_ids): | |
| name = protein_names[i] if i < len(protein_names) else "—" | |
| org = organisms[i] if i < len(organisms) else "—" | |
| seq = sequences[i] if i < len(sequences) else None | |
| seq_info = f"{len(seq)} aa" if seq else "—" | |
| rows.append(dbc.ListGroupItem([ | |
| dbc.Row([ | |
| dbc.Col([ | |
| html.A(uid, href=f"https://www.uniprot.org/uniprot/{uid}", | |
| target="_blank", className="fw-bold small me-2"), | |
| html.Span(org, className="text-muted small"), | |
| ], width=4), | |
| dbc.Col(html.Span(name, className="small"), width=6), | |
| dbc.Col(html.Span(seq_info, className="text-muted small"), width=2), | |
| ], align="center"), | |
| ])) | |
| return dbc.Card([ | |
| dbc.CardHeader(html.Span( | |
| f"UniProt Entries for PMID {pmid} ({len(uniprot_ids)} entries)", | |
| className="fw-bold", | |
| )), | |
| dbc.CardBody([ | |
| dbc.Row([ | |
| dbc.Col(html.Span("UniProt ID / Organism", className="fw-semibold small"), width=4), | |
| dbc.Col(html.Span("Protein Name", className="fw-semibold small"), width=6), | |
| dbc.Col(html.Span("Sequence", className="fw-semibold small"), width=2), | |
| ], className="px-3 mb-1"), | |
| dbc.ListGroup(rows, flush=True), | |
| ]), | |
| ], className="mb-3") | |
| _HF_REPO = "richiam/ProtoPure" | |
| _HF_APP_ROOT = "/app" | |
| def _resolve_xet_file(local_path): | |
| """If local_path is an HF Xet pointer, download actual content and overwrite it.""" | |
| try: | |
| with open(local_path, "rb") as f: | |
| magic = f.read(2) | |
| if magic == b"\x1f\x8b": | |
| return # already proper gzip | |
| rel = os.path.relpath(local_path, _HF_APP_ROOT) | |
| print(f"[xet] Downloading {rel} from HF Hub...", flush=True) | |
| from huggingface_hub import hf_hub_download | |
| import shutil | |
| actual = hf_hub_download(repo_id=_HF_REPO, filename=rel, repo_type="space") | |
| shutil.copy2(actual, local_path) | |
| print(f"[xet] Cached {rel}", flush=True) | |
| except Exception as e: | |
| print(f"[xet] Failed to resolve {local_path}: {e}", flush=True) | |
| def _load_cluster_csv(model, min_val, threshold): | |
| """Return the ALL_FIELDS dataframe or None. Supports .csv.gz and .csv.""" | |
| base = os.path.join(GRID_BASE, f"model={model}", f"min={min_val}", | |
| f"t={threshold}_ALL_FIELDS") | |
| for ext in (".csv.gz", ".csv"): | |
| path = base + ext | |
| if not os.path.isfile(path): | |
| continue | |
| if ext == ".csv.gz": | |
| _resolve_xet_file(path) | |
| try: | |
| return pd.read_csv(path, compression="gzip" if ext == ".csv.gz" else None) | |
| except Exception: | |
| try: | |
| return pd.read_csv(path, compression=None) | |
| except Exception: | |
| continue | |
| return None | |
| def _umap_figure(cdf, field_name, top_n=20): | |
| """Build a UMAP scatter figure from a filtered cluster DataFrame.""" | |
| cdf = cdf.reset_index(drop=True) | |
| noise_mask = cdf["cluster_id"] == -1 | |
| non_noise = cdf[~noise_mask] | |
| # Rank clusters by size; optionally cap at top_n | |
| cluster_sizes = non_noise.groupby("cluster_id").size().sort_values(ascending=False) | |
| if top_n and top_n > 0: | |
| top_ids = set(cluster_sizes.index[:top_n]) | |
| else: | |
| top_ids = set(cluster_sizes.index) | |
| fig = go.Figure() | |
| # NOISE — thin gray, low opacity, drawn first | |
| if noise_mask.any(): | |
| nd = cdf[noise_mask] | |
| fig.add_trace(go.Scattergl( | |
| x=nd["x"], y=nd["y"], | |
| mode="markers", | |
| name="Noise", | |
| marker=dict(color=_NOISE_COLOR, size=4, opacity=0.25), | |
| hovertemplate="<b>Noise</b><br>%{customdata[0]}<br>PMID: %{text}<extra></extra>", | |
| customdata=list(zip(nd["value"].str[:80].tolist(), | |
| nd["protein_index"].astype(str).tolist())), | |
| text=nd["key"].astype(str), | |
| showlegend=True, | |
| )) | |
| # "Other clusters" bucket — light gray, slightly more visible than noise | |
| other_mask = ~noise_mask & ~cdf["cluster_id"].isin(top_ids) | |
| if other_mask.any(): | |
| od = cdf[other_mask] | |
| fig.add_trace(go.Scattergl( | |
| x=od["x"], y=od["y"], | |
| mode="markers", | |
| name=f"Other ({len(cluster_sizes) - len(top_ids)} clusters)", | |
| marker=dict(color="#adb5bd", size=5, opacity=0.35), | |
| hovertemplate="<b>%{customdata[0]}</b><br>%{customdata[2]}<br>PMID: %{text}<extra></extra>", | |
| customdata=list(zip( | |
| ("Cluster " + od["cluster_id"].astype(str)).tolist(), | |
| od["protein_index"].astype(str).tolist(), | |
| od["value"].str[:80].tolist(), | |
| )), | |
| text=od["key"].astype(str), | |
| showlegend=True, | |
| )) | |
| # Top-N named clusters — distinct colors, larger markers | |
| for rank, cid in enumerate(cluster_sizes.index[:len(top_ids)]): | |
| cd = non_noise[non_noise["cluster_id"] == cid] | |
| label = cd["cluster_label_short"].iloc[0][:35] if len(cd) else f"C{cid}" | |
| color = _PALETTE[rank % len(_PALETTE)] | |
| fig.add_trace(go.Scattergl( | |
| x=cd["x"], y=cd["y"], | |
| mode="markers", | |
| name=f"[{cid}] {label}", | |
| marker=dict(color=color, size=7, opacity=0.80, | |
| line=dict(width=0.4, color="rgba(255,255,255,0.6)")), | |
| hovertemplate=( | |
| "<b>[%{meta}] %{customdata[0]}</b><br>" | |
| "%{customdata[2]}<br>" | |
| "PMID: %{text}<extra></extra>" | |
| ), | |
| meta=cid, | |
| customdata=list(zip( | |
| cd["cluster_label_short"].str[:50].tolist(), | |
| cd["protein_index"].astype(str).tolist(), | |
| cd["value"].str[:100].tolist(), | |
| )), | |
| text=cd["key"].astype(str), | |
| )) | |
| n_total = len(cluster_sizes) | |
| n_shown = len(top_ids) | |
| n_noise = noise_mask.sum() | |
| title_txt = ( | |
| f"UMAP — <b>{field_name.replace('_', ' ').title()}</b>" | |
| f" · top {n_shown}/{n_total} clusters shown · {n_noise:,} noise pts" | |
| ) | |
| fig.update_layout( | |
| title=dict(text=title_txt, font=dict(size=13)), | |
| plot_bgcolor="#f9fafc", | |
| paper_bgcolor="#ffffff", | |
| xaxis=dict(showgrid=False, zeroline=False, showticklabels=False, title=""), | |
| yaxis=dict(showgrid=False, zeroline=False, showticklabels=False, title=""), | |
| legend=dict( | |
| title=dict(text="Cluster", font=dict(size=11)), | |
| font=dict(size=10), | |
| itemsizing="constant", | |
| bordercolor="#dee2e6", borderwidth=1, | |
| tracegroupgap=1, | |
| ), | |
| updatemenus=[dict( | |
| type="buttons", | |
| showactive=False, | |
| direction="right", | |
| x=1.01, xanchor="left", | |
| y=1.06, yanchor="top", | |
| pad={"r": 4, "t": 0}, | |
| bgcolor="#f8f9fa", | |
| bordercolor="#ced4da", | |
| font=dict(size=11), | |
| buttons=[ | |
| dict( | |
| label="Deselect all", | |
| method="restyle", | |
| args=[{"visible": "legendonly"}], | |
| ), | |
| dict( | |
| label="Select all", | |
| method="restyle", | |
| args=[{"visible": True}], | |
| ), | |
| ], | |
| )], | |
| margin=dict(l=20, r=200, t=50, b=20), | |
| hoverlabel=dict(bgcolor="white", font_size=12, namelength=-1), | |
| ) | |
| return fig | |
| def _distribution_figure(cdf, field_name, top_n=20): | |
| """Build a cluster-size bar chart from a filtered cluster DataFrame.""" | |
| cdf = cdf.reset_index(drop=True) | |
| counts = ( | |
| cdf[cdf["cluster_id"] != -1] | |
| .groupby(["cluster_id", "cluster_label_short"], sort=False) | |
| .size() | |
| .reset_index(name="count") | |
| .sort_values("count", ascending=False) | |
| ) | |
| if top_n and top_n > 0: | |
| counts = counts.head(top_n) | |
| colors = [_PALETTE[i % len(_PALETTE)] for i in range(len(counts))] | |
| labels = counts["cluster_label_short"].str[:45] | |
| fig = go.Figure(go.Bar( | |
| x=labels, | |
| y=counts["count"], | |
| marker_color=colors, | |
| marker_line_color="rgba(255,255,255,0.6)", | |
| marker_line_width=0.8, | |
| opacity=0.88, | |
| customdata=counts["cluster_id"].tolist(), | |
| hovertemplate="<b>%{x}</b><br>Count: %{y:,}<br><i>Click to see proteins</i><extra></extra>", | |
| )) | |
| fig.update_layout( | |
| title=dict( | |
| text=f"Cluster Sizes — <b>{field_name.replace('_', ' ').title()}</b>" | |
| + (f" (top {top_n})" if top_n else ""), | |
| font=dict(size=13), | |
| ), | |
| plot_bgcolor="#f9fafc", | |
| paper_bgcolor="#ffffff", | |
| xaxis=dict( | |
| showgrid=False, zeroline=False, | |
| tickangle=-45, tickfont=dict(size=10), | |
| ), | |
| yaxis=dict( | |
| showgrid=True, gridcolor="#e5e7eb", zeroline=False, | |
| title="# entries", | |
| ), | |
| margin=dict(l=50, r=20, t=50, b=160), | |
| hoverlabel=dict(bgcolor="white", font_size=12), | |
| bargap=0.25, | |
| ) | |
| return fig | |
| def populate_cross_filter_values(cross_field, model, min_val, threshold): | |
| if not cross_field or not all([model, min_val, threshold]): | |
| return [], True, None | |
| cdf = _load_cluster_csv(model, min_val, threshold) | |
| if cdf is None: | |
| return [], True, None | |
| labels = ( | |
| cdf[(cdf["field"] == cross_field) & (cdf["cluster_id"] != -1)]["cluster_label"] | |
| .value_counts() | |
| .head(200) | |
| .index.tolist() | |
| ) | |
| options = [{"label": l, "value": l} for l in labels] | |
| return options, False, None | |
| def _compute_diversity_cards(field_df): | |
| """Return a dbc.Row of diversity metric cards from a filtered field DataFrame.""" | |
| n_total = len(field_df) | |
| if n_total == 0: | |
| return html.Div() | |
| clustered = field_df[field_df["cluster_id"] != -1] | |
| n_clustered = len(clustered) | |
| coverage = n_clustered / n_total if n_total > 0 else 0.0 | |
| if n_clustered == 0: | |
| return _diversity_row(n_total, 0, 0, 0.0, 0.0, coverage, 0.0) | |
| sizes = clustered["cluster_id"].value_counts() | |
| n_clusters = len(sizes) | |
| p = sizes / n_clustered | |
| entropy = -sum(pi * math.log(pi) for pi in p if pi > 0) | |
| effective_n = math.exp(entropy) if entropy > 0 else 1.0 | |
| dominance = sizes.iloc[0] / n_clustered # sizes is sorted descending | |
| return _diversity_row(n_total, n_clustered, n_clusters, entropy, effective_n, coverage, dominance) | |
| def _diversity_row(n_total, n_clustered, n_clusters, entropy, effective_n, coverage, dominance): | |
| def _card(label, value, tooltip, color="#1a3a5c"): | |
| return dbc.Col( | |
| dbc.Card( | |
| dbc.CardBody([ | |
| html.Div(label, className="text-muted mb-1", | |
| style={"fontSize": "11px", "textTransform": "uppercase", | |
| "letterSpacing": "0.05em"}), | |
| html.Div(value, className="fw-bold", | |
| style={"fontSize": "20px", "color": color}), | |
| html.Div(tooltip, className="text-muted", | |
| style={"fontSize": "10px", "lineHeight": "1.3"}), | |
| ], className="p-2 text-center"), | |
| className="border-0 shadow-sm h-100", | |
| ), | |
| xs=6, sm=4, md=2, | |
| ) | |
| return dbc.Row([ | |
| _card("Entries", f"{n_total:,}", | |
| "total in field after filters"), | |
| _card("Clusters", f"{n_clusters:,}", | |
| "distinct clusters (excl. noise)"), | |
| _card("Coverage", f"{coverage:.1%}", | |
| "assigned to a cluster (not noise)", | |
| "#198754" if coverage >= 0.7 else "#dc3545"), | |
| _card("Shannon H", f"{entropy:.2f}", | |
| "entropy of cluster-size distribution"), | |
| _card("Effective N", f"{effective_n:.1f}", | |
| "exp(H) — diversity-adjusted cluster count"), | |
| _card("Dominance", f"{dominance:.1%}", | |
| "entries in the single largest cluster", | |
| "#dc3545" if dominance >= 0.5 else "#1a3a5c"), | |
| ], className="g-2") | |
| def update_cluster_plot(model, min_val, threshold, field, plot_type, top_n, groups, cross_field, cross_value, era): | |
| empty_fig = go.Figure() | |
| empty_fig.update_layout(paper_bgcolor="#ffffff", plot_bgcolor="#f9fafc") | |
| no_cards = html.Div() | |
| if not all([model, min_val, threshold, field, plot_type]): | |
| return empty_fig, "Select all parameters above.", no_cards | |
| cdf = _load_cluster_csv(model, min_val, threshold) | |
| if cdf is None: | |
| return empty_fig, f"Data file not found for model={model} min={min_val} t={threshold}", no_cards | |
| # Apply era filter based on PMID as publication year proxy | |
| if era: | |
| era_range = {label: (lo, hi) for label, lo, hi in PMID_ERA_BINS} | |
| if era in era_range: | |
| lo, hi = era_range[era] | |
| pmid_num = pd.to_numeric(cdf["key"], errors="coerce") | |
| cdf = cdf[(pmid_num >= lo) & (pmid_num < hi)] | |
| if cdf.empty: | |
| return empty_fig, f"No data found for era '{era}'.", no_cards | |
| # Apply cross-field filter: keep only (key, protein_index) in the selected cross-field cluster | |
| if cross_field and cross_value: | |
| keep = cdf[(cdf["field"] == cross_field) & (cdf["cluster_label"] == cross_value)][["key", "protein_index"]] | |
| if keep.empty: | |
| return empty_fig, f"No proteins found for {cross_field} = '{cross_value}'.", no_cards | |
| cdf = cdf.merge(keep, on=["key", "protein_index"], how="inner") | |
| field_df = cdf[cdf["field"] == field].copy() | |
| if field_df.empty: | |
| return empty_fig, f"No data for field '{field}' in this parameter combination.", no_cards | |
| # Filter by group if selected | |
| if groups: | |
| sel = set(groups) | |
| field_df = field_df[ | |
| field_df["key"].astype(str).apply( | |
| lambda pmid: bool(PMID_GROUPS.get(pmid, set()) & sel) | |
| ) | |
| ] | |
| if field_df.empty: | |
| return empty_fig, f"No data for the selected group(s) in this field.", no_cards | |
| diversity = _compute_diversity_cards(field_df) | |
| n = top_n or 0 | |
| suffix_parts = [] | |
| if era: | |
| suffix_parts.append(era) | |
| if groups: | |
| suffix_parts.append(", ".join(groups)) | |
| if cross_field and cross_value: | |
| suffix_parts.append(f"{cross_field.replace('_',' ')}={cross_value}") | |
| suffix = f" — {' | '.join(suffix_parts)}" if suffix_parts else "" | |
| if plot_type == "cluster": | |
| return _umap_figure(field_df, field + suffix, top_n=n), "", diversity | |
| else: | |
| return _distribution_figure(field_df, field + suffix, top_n=n), "", diversity | |
| _CONDITION_COLS = [ | |
| "enzyme_name", "organism_source", "strain", "expression_strain", | |
| "plasmid", "molecular_weight", "medium_name", "inducer", | |
| "induction_temperature", "lysis_buffer", "elution_buffer", "desalting_process", | |
| ] | |
| def show_cluster_point_detail(click_data, field, model, min_val, threshold, plot_type): | |
| if not click_data: | |
| return dash.no_update, dash.no_update | |
| point = click_data["points"][0] | |
| # ── Bar chart click: show all proteins in that cluster ────────────────── | |
| if plot_type == "distribution": | |
| cluster_id = point.get("customdata") | |
| cluster_label = str(point.get("x", "")) | |
| count = point.get("y", 0) | |
| cdf = _load_cluster_csv(model, min_val, threshold) | |
| if cdf is None: | |
| return html.Div("Could not load cluster data.", className="text-muted small mt-2"), None | |
| members = cdf[(cdf["field"] == field) & (cdf["cluster_id"] == cluster_id)] | |
| rows = [] | |
| for _, r in members.iterrows(): | |
| pmid = str(r["key"]) | |
| pidx = int(r["protein_index"]) | |
| entry = RAW.get(pmid, {}) | |
| proteins = entry.get("proteins", []) or [] | |
| protein = proteins[pidx] if pidx < len(proteins) else {} | |
| m = META.get(pmid, {}) | |
| row = {"pmid": pmid} | |
| row.update({c: str(protein.get(c) or "") for c in _CONDITION_COLS}) | |
| row["field_value"] = str(r.get("value", "")) | |
| row["journal"] = m.get("source", "") | |
| row["pub_date"] = m.get("pub_date", "") | |
| rows.append(row) | |
| # Column order: pmid, enzyme_name, organism_source, [clustered field], rest, journal, pub_date | |
| fixed = ["pmid", "enzyme_name", "organism_source"] | |
| field_col = "field_value" | |
| remaining = [c for c in _CONDITION_COLS if c not in fixed and c != field] | |
| table_cols = fixed + [field_col] + remaining + ["journal", "pub_date"] | |
| field_label = field.replace("_", " ").title() | |
| col_defs = [ | |
| {"name": (field_label if c == field_col else c.replace("_", " ").title()), "id": c} | |
| for c in table_cols | |
| ] | |
| return html.Div([ | |
| dbc.Card([ | |
| dbc.CardHeader( | |
| dbc.Row([ | |
| dbc.Col([ | |
| html.Span("Proteins in cluster — ", className="fw-bold"), | |
| html.Span(f'"{cluster_label}"', className="fst-italic"), | |
| dbc.Badge(f"{count} proteins", color="primary", className="ms-2"), | |
| ], width=10), | |
| dbc.Col( | |
| dbc.Button("⬇ Download CSV", id="download-cluster-btn", | |
| size="sm", color="success", outline=True), | |
| width=2, className="text-end", | |
| ), | |
| ], align="center"), | |
| ), | |
| dbc.CardBody( | |
| dash_table.DataTable( | |
| columns=col_defs, | |
| data=rows, | |
| page_size=15, | |
| sort_action="native", | |
| style_table={"overflowX": "auto"}, | |
| style_cell={ | |
| "fontSize": "12px", | |
| "padding": "5px 10px", | |
| "textAlign": "left", | |
| "maxWidth": "260px", | |
| "overflow": "hidden", | |
| "textOverflow": "ellipsis", | |
| "whiteSpace": "nowrap", | |
| }, | |
| style_cell_conditional=[ | |
| {"if": {"column_id": field_col}, | |
| "backgroundColor": "#fff8e1", "fontWeight": "500"}, | |
| ], | |
| style_header={ | |
| "fontWeight": "700", | |
| "backgroundColor": "#1a3a5c", | |
| "color": "#ffffff", | |
| "fontSize": "11px", | |
| "textTransform": "uppercase", | |
| "letterSpacing": "0.04em", | |
| }, | |
| style_data_conditional=[ | |
| {"if": {"row_index": "odd"}, "backgroundColor": "#eef4fd"}, | |
| ], | |
| tooltip_data=[ | |
| {c: {"value": str(r.get(c, "")), "type": "markdown"} for c in table_cols} | |
| for r in rows | |
| ], | |
| tooltip_delay=0, | |
| tooltip_duration=None, | |
| ) if rows else html.Span("No proteins found.", className="text-muted small") | |
| ), | |
| ], className="mb-3"), | |
| ]), rows | |
| # ── UMAP scatter click: show single protein conditions ────────────────── | |
| pmid = str(point.get("text", "")) | |
| customdata = point.get("customdata", []) | |
| # customdata layout: [label_or_value, protein_index, value_text] (noise: [value, protein_index]) | |
| try: | |
| protein_index = int(customdata[1]) | |
| except (IndexError, ValueError, TypeError): | |
| protein_index = 0 | |
| entry = RAW.get(pmid, {}) | |
| proteins = entry.get("proteins", []) or [] | |
| if not proteins: | |
| return html.Div(f"No protein data for PMID {pmid}.", className="text-muted small mt-2") | |
| protein_index = min(protein_index, len(proteins) - 1) | |
| protein = proteins[protein_index] | |
| # Field value that was clicked (used for context header) | |
| clicked_value = str(customdata[0] if customdata else "") | |
| # Conditions table: condition → value, skip empty | |
| cond_rows = [ | |
| {"Condition": c.replace("_", " ").title(), "Value": str(protein.get(c) or "")} | |
| for c in _CONDITION_COLS | |
| if protein.get(c) | |
| ] | |
| m = META.get(pmid, {}) | |
| pubmed_link = html.A(f"PMID {pmid}", href=f"https://pubmed.ncbi.nlm.nih.gov/{pmid}/", | |
| target="_blank", className="small") | |
| paper_info = " · ".join(filter(None, [m.get("source", ""), m.get("pub_date", "")])) | |
| return html.Div([ | |
| dbc.Card([ | |
| dbc.CardHeader([ | |
| html.Span("Purification Conditions — ", className="fw-bold"), | |
| pubmed_link, | |
| html.Span(f" · {paper_info}", className="text-muted small") if paper_info else None, | |
| html.Span(f" · {field.replace('_', ' ').title()}: ", className="text-muted small ms-2"), | |
| html.Span(f'"{clicked_value[:80]}"', className="small fst-italic"), | |
| ]), | |
| dbc.CardBody( | |
| dash_table.DataTable( | |
| columns=[{"name": c, "id": c} for c in ["Condition", "Value"]], | |
| data=cond_rows, | |
| style_table={"overflowX": "auto"}, | |
| style_cell={ | |
| "fontSize": "12px", | |
| "padding": "5px 10px", | |
| "textAlign": "left", | |
| }, | |
| style_cell_conditional=[ | |
| {"if": {"column_id": "Condition"}, | |
| "fontWeight": "600", "width": "200px", "minWidth": "200px", | |
| "backgroundColor": "#f8f9fa"}, | |
| {"if": {"column_id": "Value"}, | |
| "whiteSpace": "normal", "height": "auto"}, | |
| ], | |
| style_header={ | |
| "fontWeight": "700", | |
| "backgroundColor": "#1a3a5c", | |
| "color": "#ffffff", | |
| "fontSize": "11px", | |
| "textTransform": "uppercase", | |
| "letterSpacing": "0.04em", | |
| }, | |
| style_data_conditional=[ | |
| {"if": {"row_index": "odd"}, "backgroundColor": "#eef4fd"}, | |
| ], | |
| ) if cond_rows else html.Span("No conditions recorded for this protein.", className="text-muted small") | |
| ), | |
| ], className="mb-3"), | |
| _mapped_uniprot_card(_row_from_uniprot_block(protein)), | |
| _uniprot_card(pmid), | |
| ]), None | |
| def download_cluster_table(n_clicks, rows): | |
| if not n_clicks or not rows: | |
| return None | |
| return dcc.send_data_frame(pd.DataFrame(rows).to_csv, "cluster_proteins.csv", index=False) | |
| def update_summary_graph(metric, field, min_size): | |
| return _build_grid_summary_figure(field, min_size, metric) | |
| def update_metrics_table(model, min_val, threshold): | |
| if not all([model, min_val, threshold]): | |
| return html.Div() | |
| csv_path = os.path.join( | |
| GRID_BASE, f"model={model}", f"min={min_val}", f"t={threshold}_FIELD_CLUSTER_METRICS.csv" | |
| ) | |
| if not os.path.isfile(csv_path): | |
| return html.Div("Metrics file not found.", className="text-muted small") | |
| metrics_df = pd.read_csv(csv_path).round(4) | |
| return dash_table.DataTable( | |
| columns=[{"name": c.replace("_", " ").title(), "id": c} for c in metrics_df.columns], | |
| data=metrics_df.to_dict("records"), | |
| sort_action="native", | |
| style_table={"overflowX": "auto"}, | |
| style_cell={"fontSize": "12px", "padding": "5px 10px", "textAlign": "left"}, | |
| style_header={"fontWeight": "bold", "backgroundColor": "#f8f9fa"}, | |
| style_data_conditional=[ | |
| {"if": {"row_index": "odd"}, "backgroundColor": "#f8f9fa"}, | |
| { | |
| "if": {"filter_query": "{silhouette_cosine} > 0.6", "column_id": "silhouette_cosine"}, | |
| "color": "#198754", "fontWeight": "bold", | |
| }, | |
| { | |
| "if": {"filter_query": "{silhouette_cosine} < 0.3", "column_id": "silhouette_cosine"}, | |
| "color": "#dc3545", | |
| }, | |
| ], | |
| ) | |
| _PC_PROTOCOL_FIELDS = ["expression_strain", "inducer", "medium_name", | |
| "plasmid", "lysis_buffer", "elution_buffer"] | |
| _PC_FIELD_LABELS = { | |
| "expression_strain": "Expression host", | |
| "inducer": "Inducer", | |
| "medium_name": "Growth medium", | |
| "plasmid": "Plasmid", | |
| "lysis_buffer": "Lysis buffer", | |
| "elution_buffer": "Elution buffer", | |
| } | |
| def _grp(field, label): | |
| """Map a cluster label_short to a readable top-level category.""" | |
| if not label or label == "Other clusters": | |
| return None | |
| sl = str(label).lower() | |
| if field == "expression_strain": | |
| if "bl21" in sl: return "BL21(DE3)" | |
| if "rosetta" in sl: return "Rosetta" | |
| if "c41" in sl or "c43" in sl: return "C41/C43" | |
| if "hek" in sl or "cho" in sl or "293" in sl: return "Human/CHO" | |
| if "sf9" in sl or "sf21" in sl: return "Insect (Sf9)" | |
| if "yeast" in sl or "pichia" in sl: return "Yeast" | |
| return "Other E. coli" | |
| if field == "inducer": | |
| if "not mentioned" in sl: return "Not reported" | |
| if "iptg" in sl: return "IPTG" | |
| if "arabinose" in sl: return "Arabinose" | |
| return "Other inducer" | |
| if field == "medium_name": | |
| if re.search(r"\blb\b|luria.bertani|luria broth", sl): return "LB" | |
| if "terrific" in sl: return "TB" | |
| if "2xyt" in sl or "2x yt" in sl: return "2xYT" | |
| if "minimal" in sl or "m9" in sl: return "Minimal" | |
| return "Other medium" | |
| if field == "plasmid": | |
| if re.search(r"\bpet", sl): return "pET" | |
| if "pgex" in sl: return "pGEX" | |
| if "pqe" in sl: return "pQE" | |
| if "pmal" in sl: return "pMAL" | |
| return "Other plasmid" | |
| if field == "lysis_buffer": | |
| if re.search(r"\btris\b", sl): return "Tris" | |
| if "pbs" in sl: return "PBS" | |
| if "hepes" in sl: return "HEPES" | |
| if re.search(r"\bphosphate\b", sl): return "Phosphate" | |
| return "Other lysis" | |
| if field == "elution_buffer": | |
| if "imidazole" in sl: return "Imidazole" | |
| if "glutathione" in sl: return "Glutathione" | |
| if "maltose" in sl: return "Maltose" | |
| return "Other elution" | |
| return label | |
| _PC_COLORS = [ | |
| "#1a73e8", "#4db8ff", "#80ccff", "#1aa85c", "#f4a55a", | |
| "#e8711a", "#c0392b", "#8e1ae8", "#2ecc71", "#adb5bd", | |
| "#dee2e6", | |
| ] | |
| def _pc_build_wide(all_fields_df, top_n): | |
| """Pivot ALL_FIELDS into wide format, keeping top_n clusters per field.""" | |
| sub = all_fields_df[all_fields_df["field"].isin(_PC_PROTOCOL_FIELDS)].copy() | |
| rows = [] | |
| for field in _PC_PROTOCOL_FIELDS: | |
| fd = sub[sub["field"] == field] | |
| clustered = fd[fd["cluster_id"] != -1] | |
| # Top N clusters by size | |
| top_ids = ( | |
| clustered.groupby("cluster_id").size() | |
| .sort_values(ascending=False) | |
| .head(top_n).index | |
| ) | |
| def label_row(r): | |
| if r["cluster_id"] == -1: | |
| return None | |
| if r["cluster_id"] in top_ids: | |
| lbl = r["cluster_label_short"] | |
| return lbl[:40] + "…" if len(lbl) > 40 else lbl | |
| return "Other clusters" | |
| fd = fd.copy() | |
| fd["cat"] = fd.apply(label_row, axis=1) | |
| rows.append(fd[["key", "protein_index", "cat"]].rename(columns={"cat": field})) | |
| wide = rows[0] | |
| for r in rows[1:]: | |
| wide = wide.merge(r, on=["key", "protein_index"], how="outer") | |
| return wide | |
| def update_protocol_configs(model, min_val, threshold, top_n, | |
| filter_groups, filter_host, filter_inducer, | |
| filter_medium, filter_plasmid): | |
| if not all([model, min_val, threshold, top_n]): | |
| empty = go.Figure() | |
| empty.update_layout(paper_bgcolor="white", | |
| annotations=[dict(text="Select model, min and threshold.", | |
| showarrow=False, font=dict(size=14))]) | |
| return empty, html.Div() | |
| adf = _load_cluster_csv(model, min_val, threshold) | |
| if adf is None: | |
| empty = go.Figure() | |
| empty.update_layout(paper_bgcolor="white", | |
| annotations=[dict(text="Data file not found.", | |
| showarrow=False, font=dict(size=14))]) | |
| return empty, html.Div("Data file not found.", className="text-muted small") | |
| # ── Apply filters ──────────────────────────────────────────────────────── | |
| # Group filter: keep only PMIDs belonging to selected groups | |
| if filter_groups: | |
| allowed_pmids = {int(pmid) for pmid, grps in PMID_GROUPS.items() | |
| if grps & set(filter_groups)} | |
| adf = adf[adf["key"].isin(allowed_pmids)] | |
| # Per-field category pre-filters: build category col for core fields, | |
| # then restrict to rows whose category matches the selected values. | |
| _field_filter_map = { | |
| "expression_strain": filter_host, | |
| "inducer": filter_inducer, | |
| "medium_name": filter_medium, | |
| "plasmid": filter_plasmid, | |
| } | |
| for fld, sel in _field_filter_map.items(): | |
| if not sel: | |
| continue | |
| fld_sub = adf[adf["field"] == fld].copy() | |
| fld_sub["cat"] = fld_sub["cluster_label_short"].apply( | |
| lambda v, _f=fld: _grp(_f, v) | |
| ) | |
| keep = fld_sub[fld_sub["cat"].isin(sel)][["key", "protein_index"]].drop_duplicates() | |
| adf = adf.merge(keep, on=["key", "protein_index"], how="inner") | |
| if adf.empty: | |
| empty = go.Figure() | |
| empty.update_layout(paper_bgcolor="white", | |
| annotations=[dict(text="No data for selected filters.", | |
| showarrow=False, font=dict(size=14))]) | |
| return empty, html.Div("No proteins match the selected filters.", | |
| className="text-muted small") | |
| wide = _pc_build_wide(adf, top_n) | |
| core = _PC_PROTOCOL_FIELDS[:4] # host, inducer, medium, plasmid | |
| w4 = wide.dropna(subset=core) | |
| # ── Parcats figure ────────────────────────────────────────────────────── | |
| dims = [] | |
| for field in _PC_PROTOCOL_FIELDS: | |
| if field not in wide.columns: | |
| continue | |
| col = w4[field].fillna("N/A") if field in w4.columns else None | |
| if col is None: | |
| continue | |
| from collections import Counter as _Ctr | |
| cnt = _Ctr(col.tolist()) | |
| cat_order = [c for c, _ in cnt.most_common() if c != "N/A"] + ["N/A"] | |
| dims.append(go.parcats.Dimension( | |
| values=col.tolist(), | |
| label=_PC_FIELD_LABELS.get(field, field), | |
| categoryorder="array", | |
| categoryarray=cat_order, | |
| )) | |
| # Color by expression host category index | |
| host_col = w4["expression_strain"].fillna("N/A").tolist() | |
| all_hosts = list(dict.fromkeys(host_col)) | |
| host_idx = {h: i for i, h in enumerate(all_hosts)} | |
| color_vals = [host_idx.get(h, 0) for h in host_col] | |
| n_hosts = max(len(all_hosts), 1) | |
| colorscale = [[i / max(n_hosts - 1, 1), _PC_COLORS[i % len(_PC_COLORS)]] | |
| for i in range(n_hosts)] | |
| fig = go.Figure(go.Parcats( | |
| dimensions=dims, | |
| line=dict(color=color_vals, colorscale=colorscale, shape="hspline"), | |
| labelfont=dict(size=12, family="Arial"), | |
| tickfont=dict(size=10, family="Arial"), | |
| arrangement="freeform", | |
| hoverinfo="count+probability", | |
| )) | |
| short_model = model.split("__")[-1] if "__" in model else model | |
| fig.update_layout( | |
| title=dict( | |
| text=f"Protocol configuration flows — {short_model} t={threshold} min={min_val}", | |
| font=dict(size=13, family="Arial"), x=0.5, | |
| ), | |
| paper_bgcolor="white", | |
| font=dict(family="Arial", size=11), | |
| margin=dict(l=60, r=60, t=60, b=40), | |
| ) | |
| # ── Top-config table — category-level grouping ────────────────────────── | |
| # Map fine-grained cluster labels to readable top-level categories so | |
| # combinations survive the specificity of t=0.9 clusters. | |
| # Build category table from all clustered entries (not just top-N) | |
| # to get meaningful combination counts. | |
| sub_all = adf[adf["field"].isin(core) & (adf["cluster_id"] != -1)].copy() | |
| sub_all["cat"] = sub_all.apply( | |
| lambda r: _grp(r["field"], r["cluster_label_short"]), axis=1 | |
| ) | |
| sub_all = sub_all[sub_all["cat"].notna()] | |
| wide_cat = sub_all.pivot_table( | |
| index=["key", "protein_index"], columns="field", | |
| values="cat", aggfunc="first" | |
| ).reset_index() | |
| w4_grp = wide_cat.dropna(subset=core) | |
| from collections import Counter as _Ctr | |
| combos = _Ctr(tuple(r) for r in w4_grp[core].itertuples(index=False)) | |
| total = len(w4_grp) # proteins with all 4 fields in a named cluster | |
| table_rows = [] | |
| for rank, (combo, cnt) in enumerate(combos.most_common(20), 1): | |
| row = {"Rank": rank, "Count": cnt, "%": f"{cnt/total*100:.1f}%"} | |
| for field, val in zip(core, combo): | |
| row[_PC_FIELD_LABELS[field]] = val | |
| table_rows.append(row) | |
| cols = ["Rank", "Count", "%"] + [_PC_FIELD_LABELS[f] for f in core] | |
| table = dash_table.DataTable( | |
| columns=[{"name": c, "id": c} for c in cols], | |
| data=table_rows, | |
| sort_action="native", | |
| style_table={"overflowX": "auto"}, | |
| style_cell={"fontSize": "12px", "padding": "5px 10px", "textAlign": "left", | |
| "maxWidth": "220px", "overflow": "hidden", "textOverflow": "ellipsis"}, | |
| style_header={"fontWeight": "bold", "backgroundColor": "#f8f9fa"}, | |
| style_data_conditional=[ | |
| {"if": {"row_index": "odd"}, "backgroundColor": "#f8f9fa"}, | |
| {"if": {"row_index": 0}, "backgroundColor": "#e8f0fe", "fontWeight": "600"}, | |
| ], | |
| tooltip_data=[ | |
| {c: {"value": str(row.get(c, "")), "type": "markdown"} for c in cols} | |
| for row in table_rows | |
| ], | |
| tooltip_duration=None, | |
| ) | |
| return fig, table | |
| def update_grid_metrics(field, metric, model): | |
| empty = go.Figure() | |
| empty.update_layout(paper_bgcolor="#ffffff", plot_bgcolor="#f9fafc") | |
| if METRICS_DF.empty or not field or not metric or not model: | |
| return empty, empty, html.Div() | |
| metric_label = _METRIC_LABELS.get(metric, metric) | |
| higher_better = metric == "silhouette_cosine" | |
| # ── Heatmap: threshold × min_size for selected model + field ───────────── | |
| sub = METRICS_DF[(METRICS_DF["model"] == model) & (METRICS_DF["field"] == field)] | |
| if not sub.empty: | |
| pivot = sub.pivot_table(index="min_community_size", columns="threshold", | |
| values=metric, aggfunc="mean") | |
| pivot = pivot.sort_index(ascending=False) | |
| colorscale = "RdYlGn" if higher_better else "RdYlGn_r" | |
| heatmap_fig = go.Figure(go.Heatmap( | |
| z=pivot.values, | |
| x=[str(c) for c in pivot.columns], | |
| y=[str(r) for r in pivot.index], | |
| colorscale=colorscale, | |
| text=[[f"{v:.3f}" for v in row] for row in pivot.values], | |
| texttemplate="%{text}", | |
| hovertemplate="Threshold: %{x}<br>Min size: %{y}<br>" + metric_label + ": %{z:.3f}<extra></extra>", | |
| colorbar=dict(title=metric_label, thickness=14), | |
| )) | |
| heatmap_fig.update_layout( | |
| title=dict(text=f"{metric_label} — <b>{field.replace('_',' ').title()}</b><br>" | |
| f"<sup>{model}</sup>", font=dict(size=13)), | |
| xaxis=dict(title="Threshold", type="category"), | |
| yaxis=dict(title="Min community size", type="category"), | |
| paper_bgcolor="#ffffff", plot_bgcolor="#f9fafc", | |
| margin=dict(l=60, r=20, t=70, b=50), | |
| ) | |
| else: | |
| heatmap_fig = empty | |
| # ── Bar chart: compare all models for selected field at best threshold ──── | |
| field_df = METRICS_DF[METRICS_DF["field"] == field] | |
| if not field_df.empty: | |
| best = (field_df.groupby("model")[metric] | |
| .apply(lambda x: x.max() if higher_better else x.min()) | |
| .reset_index() | |
| .sort_values(metric, ascending=not higher_better)) | |
| colors = [_PALETTE[i % len(_PALETTE)] for i in range(len(best))] | |
| compare_fig = go.Figure(go.Bar( | |
| x=best["model"], | |
| y=best[metric], | |
| marker_color=colors, | |
| marker_line_color="rgba(255,255,255,0.6)", | |
| marker_line_width=0.8, | |
| opacity=0.88, | |
| hovertemplate="<b>%{x}</b><br>" + metric_label + ": %{y:.3f}<extra></extra>", | |
| )) | |
| compare_fig.update_layout( | |
| title=dict(text=f"Best {metric_label} per model — <b>{field.replace('_',' ').title()}</b>", | |
| font=dict(size=13)), | |
| xaxis=dict(tickangle=-35, tickfont=dict(size=10)), | |
| yaxis=dict(title=metric_label, gridcolor="#e5e7eb"), | |
| paper_bgcolor="#ffffff", plot_bgcolor="#f9fafc", | |
| margin=dict(l=60, r=20, t=60, b=120), | |
| showlegend=False, | |
| ) | |
| else: | |
| compare_fig = empty | |
| # ── Best combinations table ─────────────────────────────────────────────── | |
| top = (METRICS_DF[METRICS_DF["field"] == field] | |
| .sort_values(metric, ascending=not higher_better) | |
| .head(10)[["model", "threshold", "min_community_size", "n_clusters", | |
| "silhouette_cosine", "davies_bouldin"]] | |
| .round(4)) | |
| best_table = dash_table.DataTable( | |
| columns=[{"name": c.replace("_", " ").title(), "id": c} for c in top.columns], | |
| data=top.to_dict("records"), | |
| sort_action="native", | |
| style_table={"overflowX": "auto"}, | |
| style_cell={"fontSize": "12px", "padding": "5px 10px", "textAlign": "left"}, | |
| style_header={ | |
| "fontWeight": "700", "backgroundColor": "#1a3a5c", | |
| "color": "#ffffff", "fontSize": "11px", | |
| "textTransform": "uppercase", "letterSpacing": "0.04em", | |
| }, | |
| style_data_conditional=[ | |
| {"if": {"row_index": "odd"}, "backgroundColor": "#eef4fd"}, | |
| {"if": {"row_index": 0}, "backgroundColor": "#d4edda", "fontWeight": "bold"}, | |
| {"if": {"filter_query": "{silhouette_cosine} > 0.6", "column_id": "silhouette_cosine"}, | |
| "color": "#198754", "fontWeight": "bold"}, | |
| {"if": {"filter_query": "{silhouette_cosine} < 0.3", "column_id": "silhouette_cosine"}, | |
| "color": "#dc3545"}, | |
| ], | |
| ) | |
| return heatmap_fig, compare_fig, best_table | |
| # --------------------------------------------------------------------------- | |
| def clear_filters(_): | |
| return "", [] | |
| def download_csv(_, table_data): | |
| filtered_df = pd.DataFrame(table_data) | |
| return dcc.send_data_frame(filtered_df.to_csv, "llm_extractor_results.csv", index=False) | |
| def update_classif_chart(group): | |
| if CLASSIF_DF.empty or not group: | |
| return go.Figure() | |
| sub = CLASSIF_DF[CLASSIF_DF["group"] == group] | |
| if sub.empty: | |
| return go.Figure() | |
| grp = sub.groupby("metric")["value"] | |
| avg = grp.mean().rename("value").reset_index() | |
| sem = grp.sem().rename("sem").reset_index() | |
| avg = avg.merge(sem, on="metric") | |
| avg["label"] = avg["metric"].map(_NLP_METRICS) | |
| # Normalise BLEU / METEOR from 0–100 → 0–1 | |
| avg["scale"] = avg["metric"].map(lambda m: _NLP_SCALE.get(m, 1.0)) | |
| avg["value"] = avg["value"] / avg["scale"] | |
| avg["sem"] = avg["sem"] / avg["scale"] | |
| fig = go.Figure(go.Bar( | |
| x=avg["label"], y=avg["value"], | |
| customdata=avg[["metric"]].values.tolist(), | |
| error_y=dict(type="data", array=avg["sem"].fillna(0).tolist(), visible=True), | |
| marker_color="#1a6090", | |
| hovertemplate="%{x}<br>mean: %{y:.3f}<br>SEM: %{error_y.array:.3f}<extra></extra>", | |
| )) | |
| fig.update_layout( | |
| yaxis=dict(title="Mean score (0–1)", range=[0, 1], gridcolor="#eeeeee"), | |
| xaxis_title="Metric", | |
| margin=dict(t=20, b=40, l=60, r=20), | |
| plot_bgcolor="white", paper_bgcolor="white", | |
| ) | |
| return fig | |
| def show_classif_examples(click_data, group): | |
| if not click_data or CLASSIF_DF.empty: | |
| return dash.no_update | |
| pt = click_data["points"][0] | |
| metric = pt["customdata"][0] | |
| pool = CLASSIF_DF[ | |
| (CLASSIF_DF["group"] == group) & | |
| (CLASSIF_DF["metric"] == metric) | |
| ] | |
| if pool.empty: | |
| return html.P("No examples found.", className="text-muted small") | |
| metric_label = _NLP_METRICS.get(metric, metric) | |
| scale = _NLP_SCALE.get(metric, 1.0) | |
| values = (pool["value"] / scale).tolist() | |
| dist_fig = _dist_figure(values, x_label=f"{metric_label} (0–1)") | |
| sub = _extremes(pool, "value") | |
| rows = [] | |
| prev_rank = None | |
| for _, r in sub.iterrows(): | |
| if r["_rank"] != prev_rank: | |
| label_text = "5 Worst" if r["_rank"] == "worst" else "5 Best" | |
| color = "danger" if r["_rank"] == "worst" else "success" | |
| rows.append(html.Tr([ | |
| html.Td(dbc.Badge(label_text, color=color, className="me-1"), | |
| colSpan=4, className="fw-semibold small py-1 table-active"), | |
| ])) | |
| prev_rank = r["_rank"] | |
| rows.append(html.Tr([ | |
| html.Td(r["pmid"], className="text-muted small", style={"whiteSpace": "nowrap"}), | |
| html.Td(r["gt_text"][:300], className="small"), | |
| html.Td(r["llm_text"][:300], className="small"), | |
| html.Td(f"{r['value']/scale:.3f}", className="small text-center"), | |
| ])) | |
| return html.Div([ | |
| html.H6(f"Examples · {metric_label} · {group.title()}", className="fw-semibold mb-2"), | |
| dcc.Graph(figure=dist_fig, config={"displayModeBar": False}), | |
| html.P(f"Methods text truncated to 300 chars for display.", | |
| className="text-muted small mb-2 mt-3"), | |
| dbc.Table([ | |
| html.Thead(html.Tr([ | |
| html.Th("PMID"), html.Th("Ground truth (excerpt)"), | |
| html.Th("LLM extraction (excerpt)"), html.Th(metric_label), | |
| ])), | |
| html.Tbody(rows), | |
| ], bordered=True, size="sm", hover=True, responsive=True), | |
| ]) | |
| def _eval_bar_figure(stats_df, y_col, err_col, y_label, models): | |
| """Grouped bar chart over _PURIF_FIELDS with standard-error bars.""" | |
| fig = go.Figure() | |
| for lbl in models: | |
| d = stats_df[stats_df["label"] == lbl].copy() | |
| d = d.set_index("field").reindex(_PURIF_FIELDS).reset_index() | |
| fig.add_trace(go.Bar( | |
| name=lbl, | |
| x=d["field"], | |
| y=d[y_col], | |
| customdata=[[lbl]] * len(d), | |
| error_y=dict(type="data", array=d[err_col].fillna(0).tolist(), visible=True), | |
| hovertemplate="%{x}<br>mean: %{y:.3f}<br>SEM: %{error_y.array:.3f}<extra>" + lbl + "</extra>", | |
| )) | |
| fig.update_layout( | |
| barmode="group", | |
| yaxis_title=y_label, | |
| xaxis_title="Field", | |
| legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), | |
| margin=dict(t=60, b=110, l=60, r=20), | |
| plot_bgcolor="white", | |
| paper_bgcolor="white", | |
| yaxis=dict(gridcolor="#eeeeee"), | |
| ) | |
| fig.update_xaxes(tickangle=35) | |
| return fig | |
| def update_eval_llm(group, models): | |
| if EVAL_LLM_DF.empty or not group or not models: | |
| return go.Figure() | |
| sub = EVAL_LLM_DF[ | |
| (EVAL_LLM_DF["group"] == group) & (EVAL_LLM_DF["label"].isin(models)) | |
| ] | |
| if sub.empty: | |
| return go.Figure() | |
| grp = sub.groupby(["label", "field"])["similarity_score"] | |
| stats = grp.mean().rename("similarity_score").reset_index() | |
| stats["sem"] = grp.sem().values | |
| return _eval_bar_figure(stats, "similarity_score", "sem", | |
| "Mean similarity score (0–10)", models) | |
| def update_eval_nlp(group, models, metric): | |
| if EVAL_NLP_DF.empty or not group or not models or not metric: | |
| return go.Figure() | |
| sub = EVAL_NLP_DF[ | |
| (EVAL_NLP_DF["group"] == group) & | |
| (EVAL_NLP_DF["label"].isin(models)) & | |
| (EVAL_NLP_DF["metric"] == metric) | |
| ] | |
| if sub.empty: | |
| return go.Figure() | |
| grp = sub.groupby(["label", "field"])["value"] | |
| stats = grp.mean().rename("value").reset_index() | |
| stats["sem"] = grp.sem().values | |
| scale = _NLP_SCALE.get(metric, 1.0) | |
| stats["value"] /= scale | |
| stats["sem"] /= scale | |
| metric_label = _NLP_METRICS.get(metric, metric) | |
| return _eval_bar_figure(stats, "value", "sem", | |
| f"Mean {metric_label} (0–1)", models) | |
| def _examples_header(field, label): | |
| return html.H6( | |
| f"Examples · {field.replace('_', ' ').title()} · {label}", | |
| className="fw-semibold mb-2", | |
| ) | |
| def _dist_figure(values, x_label, x_range=None, nbinsx=20): | |
| """Box plot (top) + histogram (bottom) for a 1-D series of values.""" | |
| fig = make_subplots( | |
| rows=2, cols=1, | |
| shared_xaxes=True, | |
| row_heights=[0.25, 0.75], | |
| vertical_spacing=0.04, | |
| ) | |
| color = "#1a6090" | |
| fig.add_trace(go.Box( | |
| x=values, orientation="h", | |
| marker_color=color, line_color=color, | |
| boxmean="sd", | |
| hovertemplate=( | |
| "min: %{x[0]:.3f}<br>" | |
| "Q1: %{x[1]:.3f}<br>" | |
| "median: %{x[2]:.3f}<br>" | |
| "Q3: %{x[3]:.3f}<br>" | |
| "max: %{x[4]:.3f}<extra></extra>" | |
| ), | |
| ), row=1, col=1) | |
| mean_val = float(pd.Series(values).mean()) | |
| fig.add_trace(go.Histogram( | |
| x=values, nbinsx=nbinsx, | |
| marker_color=color, opacity=0.8, | |
| hovertemplate=f"{x_label}: %{{x:.3f}}<br>Count: %{{y}}<extra></extra>", | |
| ), row=2, col=1) | |
| fig.add_vline(x=mean_val, line_dash="dash", line_color="crimson", | |
| annotation_text=f"mean={mean_val:.3f}", | |
| annotation_position="top right") | |
| layout = dict( | |
| showlegend=False, | |
| xaxis2_title=x_label, | |
| yaxis2_title="Count", | |
| margin=dict(t=20, b=40, l=50, r=20), | |
| height=280, | |
| plot_bgcolor="white", | |
| paper_bgcolor="white", | |
| yaxis=dict(showticklabels=False, gridcolor="#eeeeee"), | |
| yaxis2=dict(gridcolor="#eeeeee"), | |
| ) | |
| if x_range: | |
| layout["xaxis2"] = dict(title=x_label, range=x_range) | |
| layout["xaxis"] = dict(range=x_range) | |
| fig.update_layout(**layout) | |
| return fig | |
| def _extremes(df, score_col, n=5): | |
| """Return bottom-n and top-n rows by score_col, labelled.""" | |
| worst = df.nsmallest(n, score_col).copy() | |
| best = df.nlargest(n, score_col).copy() | |
| worst["_rank"] = "worst" | |
| best["_rank"] = "best" | |
| return pd.concat([worst, best], ignore_index=True) | |
| def show_eval_llm_examples(click_data, group): | |
| if not click_data or EVAL_LLM_DF.empty: | |
| return dash.no_update | |
| pt = click_data["points"][0] | |
| field = pt["x"] | |
| label = pt["customdata"][0] | |
| pool = EVAL_LLM_DF[ | |
| (EVAL_LLM_DF["group"] == group) & | |
| (EVAL_LLM_DF["label"] == label) & | |
| (EVAL_LLM_DF["field"] == field) | |
| ] | |
| if pool.empty: | |
| return html.P("No examples found.", className="text-muted small") | |
| # Distribution chart | |
| dist_fig = _dist_figure( | |
| pool["similarity_score"].tolist(), | |
| x_label="Similarity score (0–10)", | |
| x_range=[0, 10], | |
| nbinsx=10, | |
| ) | |
| # Examples table | |
| sub = _extremes(pool, "similarity_score") | |
| rows = [] | |
| prev_rank = None | |
| for _, r in sub.iterrows(): | |
| if r["_rank"] != prev_rank: | |
| label_text = "5 Worst" if r["_rank"] == "worst" else "5 Best" | |
| color = "danger" if r["_rank"] == "worst" else "success" | |
| rows.append(html.Tr([ | |
| html.Td(dbc.Badge(label_text, color=color, className="me-1"), | |
| colSpan=5, className="fw-semibold small py-1 table-active"), | |
| ])) | |
| prev_rank = r["_rank"] | |
| rows.append(html.Tr([ | |
| html.Td(r["pmid"], className="text-muted small", style={"whiteSpace":"nowrap"}), | |
| html.Td(r["gt_text"], className="small"), | |
| html.Td(r["llm_text"], className="small"), | |
| html.Td(f"{r['similarity_score']:.0f} / 10", className="small text-center"), | |
| html.Td(r["explanation"], className="small text-muted"), | |
| ])) | |
| return html.Div([ | |
| _examples_header(field, label), | |
| dcc.Graph(figure=dist_fig, config={"displayModeBar": False}), | |
| dbc.Table([ | |
| html.Thead(html.Tr([ | |
| html.Th("PMID"), html.Th("Ground truth"), html.Th("LLM extraction"), | |
| html.Th("Score"), html.Th("Explanation"), | |
| ])), | |
| html.Tbody(rows), | |
| ], bordered=True, size="sm", hover=True, responsive=True, className="mt-3"), | |
| ]) | |
| def show_eval_nlp_examples(click_data, group, metric): | |
| if not click_data or EVAL_NLP_DF.empty: | |
| return dash.no_update | |
| pt = click_data["points"][0] | |
| field = pt["x"] | |
| label = pt["customdata"][0] | |
| pool = EVAL_NLP_DF[ | |
| (EVAL_NLP_DF["group"] == group) & | |
| (EVAL_NLP_DF["label"] == label) & | |
| (EVAL_NLP_DF["field"] == field) & | |
| (EVAL_NLP_DF["metric"] == metric) | |
| ] | |
| if pool.empty: | |
| return html.P("No examples found.", className="text-muted small") | |
| metric_label = _NLP_METRICS.get(metric, metric) | |
| scale = _NLP_SCALE.get(metric, 1.0) | |
| # Distribution chart | |
| dist_fig = _dist_figure( | |
| (pool["value"] / scale).tolist(), | |
| x_label=f"{metric_label} (0–1)", | |
| ) | |
| # Examples table | |
| sub = _extremes(pool, "value") | |
| rows = [] | |
| prev_rank = None | |
| for _, r in sub.iterrows(): | |
| if r["_rank"] != prev_rank: | |
| label_text = "5 Worst" if r["_rank"] == "worst" else "5 Best" | |
| color = "danger" if r["_rank"] == "worst" else "success" | |
| rows.append(html.Tr([ | |
| html.Td(dbc.Badge(label_text, color=color, className="me-1"), | |
| colSpan=4, className="fw-semibold small py-1 table-active"), | |
| ])) | |
| prev_rank = r["_rank"] | |
| rows.append(html.Tr([ | |
| html.Td(r["pmid"], className="text-muted small", style={"whiteSpace":"nowrap"}), | |
| html.Td(r["gt_text"], className="small"), | |
| html.Td(r["llm_text"], className="small"), | |
| html.Td(f"{r['value']/scale:.3f}", className="small text-center"), | |
| ])) | |
| return html.Div([ | |
| _examples_header(field, label), | |
| dcc.Graph(figure=dist_fig, config={"displayModeBar": False}), | |
| dbc.Table([ | |
| html.Thead(html.Tr([ | |
| html.Th("PMID"), html.Th("Ground truth"), | |
| html.Th("LLM extraction"), html.Th(metric_label), | |
| ])), | |
| html.Tbody(rows), | |
| ], bordered=True, size="sm", hover=True, responsive=True, className="mt-3"), | |
| ]) | |
| def _metric_card(label, value, color="primary"): | |
| body = f"{value:.3f}" if isinstance(value, float) else (str(value) if value is not None else "N/A") | |
| return dbc.Col( | |
| dbc.Card([ | |
| dbc.CardBody([ | |
| html.P(label, className="text-muted small mb-1"), | |
| html.H4(body, className=f"text-{color} mb-0 fw-bold"), | |
| ], className="text-center p-2"), | |
| ], className="shadow-sm"), | |
| xs=6, sm=4, md=2, | |
| ) | |
| def update_confmat(group): | |
| data = CONF_MATRICES.get(group) | |
| if not data: | |
| return html.P("No data available.", className="text-muted"), go.Figure() | |
| tp, fp, fn, tn = data["TP"], data["FP"], data["FN"], data["TN"] | |
| cards = dbc.Row([ | |
| _metric_card("Precision", data["Precision"], "success"), | |
| _metric_card("Recall", data["Recall"], "primary"), | |
| _metric_card("F1 Score", data["F1"], "warning"), | |
| _metric_card("Accuracy", data["Accuracy"], "info"), | |
| _metric_card("TP", tp, "secondary"), | |
| _metric_card("FP", fp, "danger"), | |
| _metric_card("FN", fn, "danger"), | |
| _metric_card("TN", tn, "secondary"), | |
| ], className="g-2 mb-3") | |
| note = data.get("note") | |
| if note: | |
| fig = go.Figure() | |
| fig.add_annotation(text=note, xref="paper", yref="paper", | |
| x=0.5, y=0.5, showarrow=False, | |
| font=dict(size=13), align="center") | |
| fig.update_layout(plot_bgcolor="white", paper_bgcolor="white", | |
| xaxis_visible=False, yaxis_visible=False) | |
| else: | |
| total = tp + fp + fn + tn | |
| z = [[tn, fp], [fn, tp]] | |
| cdata = [[[False, False], [False, True]], | |
| [[True, False], [True, True]]] | |
| text = [ | |
| [f"<b>TN</b><br>{tn} ({tn/total*100:.1f}%)", | |
| f"<b>FP</b><br>{fp} ({fp/total*100:.1f}%)"], | |
| [f"<b>FN</b><br>{fn} ({fn/total*100:.1f}%)", | |
| f"<b>TP</b><br>{tp} ({tp/total*100:.1f}%)"], | |
| ] | |
| fig = go.Figure(go.Heatmap( | |
| z=z, | |
| x=["Predicted: Non-Enzymology", "Predicted: Enzymology"], | |
| y=["Actual: Non-Enzymology", "Actual: Enzymology"], | |
| customdata=cdata, | |
| text=text, | |
| texttemplate="%{text}", | |
| colorscale="Blues", | |
| showscale=True, | |
| hovertemplate=( | |
| "%{y}<br>%{x}<br>Count: %{z}<br>" | |
| "<i>Click to see examples</i><extra></extra>" | |
| ), | |
| )) | |
| fig.update_layout( | |
| margin=dict(t=20, b=60, l=160, r=20), | |
| xaxis=dict(side="bottom"), | |
| plot_bgcolor="white", paper_bgcolor="white", | |
| ) | |
| return cards, fig | |
| def show_confmat_examples(click_data, group): | |
| if not click_data: | |
| return dash.no_update | |
| data = CONF_MATRICES.get(group, {}) | |
| papers = data.get("papers", {}) | |
| if not papers: | |
| return html.P("No paper-level data available.", className="text-muted small") | |
| pt = click_data["points"][0] | |
| actual = "Non-Enzymology" not in pt.get("y", "") | |
| predicted = "Non-Enzymology" not in pt.get("x", "") | |
| # Label for the cell | |
| cell_label = { | |
| (True, True): ("TP", "success", "True Positives — correctly classified as enzymology"), | |
| (True, False): ("FN", "warning", "False Negatives — enzymology papers missed by classifier"), | |
| (False, True): ("FP", "danger", "False Positives — non-enzymology classified as enzymology"), | |
| (False, False): ("TN", "secondary","True Negatives — correctly classified as non-enzymology"), | |
| }.get((actual, predicted), ("?", "light", "")) | |
| abbr, color, description = cell_label | |
| subset = [(pmid, v) for pmid, v in papers.items() | |
| if v["actual"] == actual and v["predicted"] == predicted] | |
| if not subset: | |
| return html.P("No papers in this category.", className="text-muted small") | |
| # Show up to 10 examples | |
| import random | |
| sample = random.sample(subset, min(10, len(subset))) | |
| rows = [] | |
| for pmid, v in sample: | |
| url = v.get("url", "") | |
| pmid_cell = html.A(pmid, href=url, target="_blank") if url else pmid | |
| rows.append(html.Tr([ | |
| html.Td(pmid_cell, className="text-muted small", style={"whiteSpace": "nowrap"}), | |
| html.Td(v["title"], className="small"), | |
| html.Td(v["pub_date"], className="small text-muted", style={"whiteSpace": "nowrap"}), | |
| html.Td(v["source"], className="small text-muted"), | |
| ])) | |
| return html.Div([ | |
| html.H6([ | |
| dbc.Badge(abbr, color=color, className="me-2"), | |
| description, | |
| html.Span(f" ({len(subset)} total, showing {len(sample)})", | |
| className="text-muted small fw-normal"), | |
| ], className="fw-semibold mb-2"), | |
| dbc.Table([ | |
| html.Thead(html.Tr([ | |
| html.Th("PMID"), html.Th("Title"), | |
| html.Th("Date"), html.Th("Journal"), | |
| ])), | |
| html.Tbody(rows), | |
| ], bordered=True, size="sm", hover=True, responsive=True), | |
| ]) | |
| # Run | |
| # --------------------------------------------------------------------------- | |
| if __name__ == "__main__": | |
| port = int(os.environ.get("PORT", 8050)) | |
| debug = os.environ.get("DASH_DEBUG", "false").lower() == "true" | |
| app.run(debug=debug, host="0.0.0.0", port=port) | |