| 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 |
|
|
| |
| |
| |
| BASE_DIR = os.path.dirname(os.path.abspath(__file__)) |
| _JSON_PATH_GZ = os.path.join(BASE_DIR, "data", "proteins.json.gz") |
| _JSON_PATH = os.path.join(BASE_DIR, "data", "proteins.json") |
| JSON_PATH = _JSON_PATH_GZ if os.path.isfile(_JSON_PATH_GZ) else _JSON_PATH |
| METADATA_PATH = os.path.join(BASE_DIR, "data", "metadata.json") |
| GRID_BASE = os.path.join(BASE_DIR, "data", "clustering") |
|
|
| |
| |
| |
| 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) |
|
|
| |
| 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} |
| row.update({k: (v if v is not None else "") for k, v in protein.items()}) |
| 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_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), |
| ] |
|
|
| |
| 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"] |
| TABLE_FIELDS = EXTRACTION_FIELDS + PAPER_FIELDS + UNIPROT_FIELDS |
|
|
| |
| |
| |
| 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)) |
| |
| _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"] |
|
|
| |
| |
| |
| 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 [] |
|
|
| |
| |
| |
| _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", |
| } |
|
|
| |
| _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() |
|
|
| |
| |
| |
| _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 = {} |
| |
| 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." |
| ) |
| |
| 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 |
| |
| 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 |
| |
| 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() |
|
|
| |
| |
| |
| 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 |
|
|
|
|
| @server.route("/grid_files/<path:filepath>") |
| 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)) |
|
|
|
|
| |
| |
| |
| 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") |
|
|
|
|
| |
| |
| |
| 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", |
| }) |
|
|
|
|
| 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": "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"), |
| |
| 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, |
| ), |
| |
| 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", |
| ) |
|
|
| |
| 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) |
| |
| row_order = ( |
| sil_agg.reindex(pivot.index, fill_value=0) |
| .sort_values(ascending=True) |
| .index.tolist() |
| ) |
| pivot = pivot.loc[row_order] |
|
|
| |
| 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([ |
| |
| 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([ |
|
|
| |
| 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), |
| ]), |
|
|
| |
| 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", |
| ), |
| ]), |
|
|
| |
| 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"), |
| ]), |
|
|
| |
| 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"), |
| ]), |
|
|
| |
| 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), |
| ]), |
|
|
| |
| 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), |
| ]), |
|
|
| |
| 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([ |
|
|
| |
| dbc.AccordionItem( |
| title="1 · Purification Conditions Evaluation", |
| children=[ |
| |
| 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"), |
|
|
| |
| 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"), |
| ], |
| ), |
|
|
| |
| 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"), |
| ], |
| ), |
|
|
| |
| 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([ |
|
|
| |
| 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", |
| ), |
| ]), |
|
|
| |
| 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", |
| ), |
| ]), |
|
|
| |
| 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), |
| ]), |
|
|
| |
| 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), |
| ]), |
|
|
| |
| 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), |
| ]), |
|
|
| |
| dbc.AccordionItem(title="Contact", children=[contact_cards()]), |
|
|
| ], start_collapsed=True, always_open=True), |
| ], fluid=True, className="pt-3") |
|
|
|
|
| |
| |
| |
| 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) |
|
|
|
|
| |
| |
| |
| @app.callback(Output("tab-content", "children"), Input("main-tabs", "active_tab")) |
| 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 |
|
|
|
|
| @app.callback( |
| Output("table-cross-value", "options"), |
| Output("table-cross-value", "disabled"), |
| Output("table-cross-value", "value"), |
| Input("table-cross-field", "value"), |
| ) |
| 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 |
|
|
|
|
| @app.callback( |
| Output("protein-table", "data"), |
| Output("protein-table", "hidden_columns"), |
| Output("protein-table", "tooltip_data"), |
| Output("protein-count", "children"), |
| Input("filter-group", "value"), |
| Input("search-field", "value"), |
| Input("search-value", "value"), |
| Input("col-selector", "value"), |
| Input("table-cross-field", "value"), |
| Input("table-cross-value", "value"), |
| ) |
| 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 |
|
|
|
|
| @app.callback( |
| Output("detail-panel", "children"), |
| Input("protein-table", "selected_rows"), |
| Input("filter-group", "value"), |
| Input("search-field", "value"), |
| Input("search-value", "value"), |
| Input("table-cross-field", "value"), |
| Input("table-cross-value", "value"), |
| prevent_initial_call=True, |
| ) |
| 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 [] |
|
|
| |
| extraction_items = [] |
| for field in EXTRACTION_FIELDS[1:]: |
| val = row.get(field, "") |
| if val: |
| item = detail_field(field.replace("_", " ").title(), val) |
| if item: |
| extraction_items.append(item) |
|
|
| |
| 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_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_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") |
|
|
| |
| 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 = 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 |
| ] |
| |
| 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(paper_card, width=12), |
| ]), |
| dbc.Row([ |
| dbc.Col(conditions_card, width=12), |
| ]), |
| ]) |
|
|
|
|
| |
| _PALETTE = [ |
| "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", |
| "#8c564b", "#e377c2", "#17becf", "#bcbd22", "#393b79", |
| "#637939", "#8c6d31", "#843c39", "#7b4173", "#3182bd", |
| "#e6550d", "#31a354", "#756bb1", "#636363", "#6baed6", |
| ] |
| _NOISE_COLOR = "#c0c0c0" |
|
|
|
|
| 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 |
| 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] |
|
|
| |
| 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() |
|
|
| |
| 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_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, |
| )) |
|
|
| |
| 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 |
|
|
|
|
| @app.callback( |
| Output("dd-cross-value", "options"), |
| Output("dd-cross-value", "disabled"), |
| Output("dd-cross-value", "value"), |
| Input("dd-cross-field", "value"), |
| Input("dd-model", "value"), |
| Input("dd-min", "value"), |
| Input("dd-threshold", "value"), |
| ) |
| 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 |
|
|
| 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") |
|
|
|
|
| @app.callback( |
| Output("cluster-graph", "figure"), |
| Output("plot-status", "children"), |
| Output("diversity-cards", "children"), |
| Input("dd-model", "value"), |
| Input("dd-min", "value"), |
| Input("dd-threshold", "value"), |
| Input("dd-field", "value"), |
| Input("plot-type", "value"), |
| Input("top-n-clusters", "value"), |
| Input("cluster-group-filter", "value"), |
| Input("dd-cross-field", "value"), |
| Input("dd-cross-value", "value"), |
| Input("dd-era-filter", "value"), |
| ) |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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", |
| ] |
|
|
|
|
| @app.callback( |
| Output("cluster-point-detail", "children"), |
| Output("cluster-table-store", "data"), |
| Input("cluster-graph", "clickData"), |
| State("dd-field", "value"), |
| State("dd-model", "value"), |
| State("dd-min", "value"), |
| State("dd-threshold", "value"), |
| State("plot-type", "value"), |
| ) |
| 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] |
|
|
| |
| 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) |
|
|
| |
| 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 |
|
|
| |
| pmid = str(point.get("text", "")) |
| customdata = point.get("customdata", []) |
|
|
| |
| 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] |
|
|
| |
| clicked_value = str(customdata[0] if customdata else "") |
|
|
| |
| 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"), |
| _uniprot_card(pmid), |
| ]), None |
|
|
|
|
| @app.callback( |
| Output("download-cluster-csv", "data"), |
| Input("download-cluster-btn", "n_clicks"), |
| State("cluster-table-store", "data"), |
| prevent_initial_call=True, |
| ) |
| 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) |
|
|
|
|
| @app.callback( |
| Output("metrics-summary-graph", "figure"), |
| Input("dd-summary-metric", "value"), |
| Input("dd-summary-field", "value"), |
| Input("dd-summary-minsize", "value"), |
| ) |
| def update_summary_graph(metric, field, min_size): |
| return _build_grid_summary_figure(field, min_size, metric) |
|
|
|
|
| @app.callback( |
| Output("metrics-table-container", "children"), |
| Input("dd-model", "value"), |
| Input("dd-min", "value"), |
| Input("dd-threshold", "value"), |
| ) |
| 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_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 |
|
|
|
|
| @app.callback( |
| Output("pc-parcats-graph", "figure"), |
| Output("pc-config-table", "children"), |
| Input("pc-dd-model", "value"), |
| Input("pc-dd-min", "value"), |
| Input("pc-dd-threshold", "value"), |
| Input("pc-dd-topn", "value"), |
| Input("pc-filter-groups", "value"), |
| Input("pc-filter-host", "value"), |
| Input("pc-filter-inducer", "value"), |
| Input("pc-filter-medium", "value"), |
| Input("pc-filter-plasmid", "value"), |
| ) |
| 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") |
|
|
| |
| |
| 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)] |
|
|
| |
| |
| _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] |
| w4 = wide.dropna(subset=core) |
|
|
| |
| 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, |
| )) |
|
|
| |
| 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), |
| ) |
|
|
| |
| |
| |
| |
| |
| 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) |
|
|
| 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 |
|
|
|
|
| @app.callback( |
| Output("metrics-heatmap", "figure"), |
| Output("metrics-model-compare", "figure"), |
| Output("metrics-best-table", "children"), |
| Input("dd-metrics-field", "value"), |
| Input("dd-metrics-type", "value"), |
| Input("dd-metrics-model", "value"), |
| ) |
| 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" |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
|
|
| |
| @app.callback( |
| Output("search-value", "value"), |
| Output("filter-group", "value"), |
| Input("clear-search", "n_clicks"), |
| prevent_initial_call=True, |
| ) |
| def clear_filters(_): |
| return "", [] |
|
|
|
|
| @app.callback( |
| Output("download-csv", "data"), |
| Input("download-btn", "n_clicks"), |
| State("protein-table", "data"), |
| prevent_initial_call=True, |
| ) |
| 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) |
|
|
|
|
| @app.callback( |
| Output("eval-classif-graph", "figure"), |
| Input("classif-group", "value"), |
| ) |
| 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) |
| |
| 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 |
|
|
|
|
| @app.callback( |
| Output("eval-classif-examples", "children"), |
| Input("eval-classif-graph", "clickData"), |
| State("classif-group", "value"), |
| prevent_initial_call=True, |
| ) |
| 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 |
|
|
|
|
| @app.callback( |
| Output("eval-llm-graph", "figure"), |
| Input("eval-group", "value"), |
| Input("eval-model-checklist", "value"), |
| ) |
| 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) |
|
|
|
|
| @app.callback( |
| Output("eval-nlp-graph", "figure"), |
| Input("eval-group", "value"), |
| Input("eval-model-checklist", "value"), |
| Input("eval-nlp-metric", "value"), |
| ) |
| 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) |
|
|
|
|
| @app.callback( |
| Output("eval-llm-examples", "children"), |
| Input("eval-llm-graph", "clickData"), |
| State("eval-group", "value"), |
| prevent_initial_call=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") |
| |
| dist_fig = _dist_figure( |
| pool["similarity_score"].tolist(), |
| x_label="Similarity score (0–10)", |
| x_range=[0, 10], |
| nbinsx=10, |
| ) |
| |
| 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"), |
| ]) |
|
|
|
|
| @app.callback( |
| Output("eval-nlp-examples", "children"), |
| Input("eval-nlp-graph", "clickData"), |
| State("eval-group", "value"), |
| State("eval-nlp-metric", "value"), |
| prevent_initial_call=True, |
| ) |
| 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) |
| |
| dist_fig = _dist_figure( |
| (pool["value"] / scale).tolist(), |
| 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"], 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, |
| ) |
|
|
|
|
| @app.callback( |
| Output("confmat-cards", "children"), |
| Output("confmat-heatmap", "figure"), |
| Input("confmat-group", "value"), |
| ) |
| 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 |
|
|
|
|
| @app.callback( |
| Output("confmat-examples", "children"), |
| Input("confmat-heatmap", "clickData"), |
| State("confmat-group", "value"), |
| prevent_initial_call=True, |
| ) |
| 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", "") |
|
|
| |
| 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") |
|
|
| |
| 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), |
| ]) |
|
|
|
|
| |
| |
| 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) |
|
|