diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -1,20 +1,29 @@ from __future__ import annotations +import hashlib import html import math +import platform +import secrets import tempfile -from functools import lru_cache +import time +import zipfile +from datetime import datetime +from functools import lru_cache, partial from pathlib import Path +import numpy as np import spaces import gradio as gr import pandas as pd -import plotly.express as px import plotly.graph_objects as go +import torch +from sklearn.metrics import roc_auc_score -from biolmnet.artifacts import load_bundle, save_bundle +from biolmnet.artifacts import EXPECTED_FILES, load_bundle, save_bundle from biolmnet.data import ( PreparedWorkspace, + UPSTREAM_REPOSITORY, attach_embeddings_and_pathways, build_biological_mask, github_dataset_sources, @@ -31,6 +40,8 @@ from biolmnet.training import ( predict, train, ) +from biolmnet.ui import components as ui +from biolmnet.ui.styles import CSS GENEPT_OPTIONS = { @@ -45,151 +56,21 @@ GENEPT_OPTIONS = { ), } +STAGE_KEYS = ["data", "train", "export", "predict", "results"] +STAGE_LABELS = ["Data & Priors", "Train Model", "Export Artifacts", "Predict", "Results"] +STAGE_TOTAL = len(STAGE_KEYS) -CSS = """ -:root { - --ink: #102824; - --muted: #60726e; - --line: #d7e2de; - --mint: #e8f4ee; - --leaf: #167c5a; - --leaf-dark: #0e5b43; - --amber: #e5a63c; -} -.gradio-container { - max-width: 1260px !important; - margin: 0 auto !important; - background: - radial-gradient(circle at 92% 4%, rgba(209, 235, 224, .7), transparent 26rem), - #f8fbf9 !important; - color: var(--ink) !important; -} -.biolm-hero { - position: relative; - overflow: hidden; - padding: 34px 36px 30px; - margin: 12px 0 18px; - border: 1px solid #cfe0d9; - border-radius: 24px; - background: linear-gradient(135deg, #0b2f28 0%, #124b3d 70%, #17694f 100%); - box-shadow: 0 18px 50px rgba(19, 63, 52, .12); -} -.biolm-hero::after { - content: ""; - position: absolute; - width: 250px; - height: 250px; - right: -60px; - top: -90px; - border: 1px solid rgba(255,255,255,.2); - border-radius: 50%; - box-shadow: 0 0 0 34px rgba(255,255,255,.035), 0 0 0 68px rgba(255,255,255,.025); -} -.eyebrow { - color: #a9e2ca; - font: 700 12px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; - letter-spacing: .14em; - text-transform: uppercase; -} -.biolm-hero h1 { - color: white; - font-size: clamp(34px, 5vw, 58px); - line-height: .98; - letter-spacing: -.045em; - margin: 12px 0 14px; -} -.biolm-hero p { - max-width: 780px; - color: #d6e8e1; - font-size: 17px; - line-height: 1.55; - margin: 0; -} -.hero-meta { - display: flex; - gap: 10px; - flex-wrap: wrap; - margin-top: 22px; -} -.hero-chip { - color: #e9f7f1; - border: 1px solid rgba(255,255,255,.23); - background: rgba(255,255,255,.07); - border-radius: 999px; - padding: 7px 11px; - font: 600 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace; -} -.phase-card { - border: 1px solid var(--line) !important; - border-radius: 18px !important; - background: rgba(255,255,255,.86) !important; - box-shadow: 0 9px 26px rgba(27, 68, 57, .05) !important; -} -.phase-intro { - border-left: 3px solid var(--leaf); - padding: 2px 0 2px 15px; - color: var(--muted); -} -.phase-intro strong { color: var(--ink); } -.status-box { - border-radius: 15px; - padding: 14px 16px; - background: var(--mint); - border: 1px solid #cbe3d8; - color: #254d41; -} -.error-box { - border-radius: 15px; - padding: 14px 16px; - background: #fff1ee; - border: 1px solid #f0cac2; - color: #793b31; -} -.metric-grid { - display: grid; - grid-template-columns: repeat(4, minmax(0,1fr)); - gap: 10px; -} -.metric { - padding: 13px 14px; - border: 1px solid #d4e2dd; - border-radius: 14px; - background: white; -} -.metric span { - display:block; - color: var(--muted); - font-size: 11px; - font-weight: 700; - letter-spacing: .07em; - text-transform: uppercase; -} -.metric b { - display:block; - margin-top: 3px; - color: var(--ink); - font-size: 22px; -} -button.primary { - background: var(--leaf) !important; - border-color: var(--leaf) !important; -} -button.primary:hover { background: var(--leaf-dark) !important; } -.footnote { - color: #6d7e79; - font-size: 12px; - line-height: 1.55; -} -@media (max-width: 720px) { - .biolm-hero { padding: 26px 22px; border-radius: 18px; } - .metric-grid { grid-template-columns: repeat(2, minmax(0,1fr)); } -} -""" + +# ═══════════════════════════════════════════════════════════════════════ +# Small presentation-only helpers (no scientific logic below this line +# calls into biolmnet.model / biolmnet.data / biolmnet.training / the +# artifacts module for anything other than reading the values they +# already computed). +# ═══════════════════════════════════════════════════════════════════════ def _status(message: str, error: bool = False) -> str: - class_name = "error-box" if error else "status-box" - return f'
{html.escape(message)}
' + return ui.simple_status_html(html.escape(message), error=error) def _source_visibility(mode: str): @@ -200,6 +81,10 @@ def _source_visibility(mode: str): ) +def _workflow_visibility(active_page: str): + return tuple(gr.update(visible=page == active_page) for page in STAGE_KEYS) + + @lru_cache(maxsize=1) def _upstream_interactions() -> tuple[pd.DataFrame, pd.DataFrame]: pdi_url, ppi_url = upstream_interaction_sources() @@ -212,20 +97,186 @@ def _read_required_upload(path: str | None, label: str) -> pd.DataFrame: return read_csv(path) -def _resolve_embedding_file( - option: str, source_mode: str, example_dataset: str -) -> str: +def _resolve_embedding_file(option: str, source_mode: str, example_dataset: str) -> str: selected = GENEPT_OPTIONS[option] if selected != "auto": return selected if source_mode == "BioLM-NET examples" and example_dataset == "scTrioseq2": - return ( - "embedding_associations_cell_type_tissue_drug_pathway_" - "openai_large.parquet" - ) + return "embedding_associations_cell_type_tissue_drug_pathway_openai_large.parquet" return "embedding_original_large_3.parquet" +def _graph_preview_html(mask_density: str, gene_nodes: str, pathway_units: str, genept_dim: str) -> str: + return ui.blueprint_div( + '
' + + ui.mini_stat_html(mask_density, "Mask density") + + ui.mini_stat_html(gene_nodes, "Gene nodes") + + ui.mini_stat_html(pathway_units, "Pathway units") + + ui.mini_stat_html(genept_dim, "GenePT dim") + + "
" + ) + + +# ── rail / chrome state machine (presentation only) ───────────────────── + + +def _rail_states(active: str, workspace_ready: bool, model_ready: bool, predicted_ready: bool): + states = [] + states.append( + ("on", "Active") if active == "data" else (("done", "Done") if workspace_ready else ("next", "Next")) + ) + if active == "train": + states.append(("on", "Active")) + elif model_ready: + states.append(("done", "Done")) + elif workspace_ready: + states.append(("next", "Next")) + else: + states.append(("off", "Locked")) + if active == "export": + states.append(("on", "Active")) + elif model_ready: + states.append(("done", "Done")) + else: + states.append(("off", "Locked")) + if active == "predict": + states.append(("on", "Active")) + elif predicted_ready: + states.append(("done", "Done")) + elif model_ready: + states.append(("done", "Ready")) + else: + states.append(("off", "Available")) + if active == "results": + states.append(("on", "Active")) + elif model_ready: + states.append(("done", "Ready")) + elif workspace_ready: + states.append(("off", "Partial")) + else: + states.append(("off", "Available")) + return states + + +def _run_state_rows(active: str, workspace, bundle, run_meta: dict, align: dict, predicted: dict): + dataset = (run_meta or {}).get("source_name") or "—" + arch_tone = "accent" if workspace else "muted" + arch_text = "Built" if workspace else "None" + model_tone = "accent" if bundle else "muted" + model_text = "Trained" if bundle else "Untrained" + + if active == "export": + return [ + ("Dataset", dataset, None), + ("Architecture", arch_text, arch_tone), + ("Model", model_text, model_tone), + ("Val accuracy", f"{bundle.metrics['accuracy']:.3f}" if bundle else "—", None), + ("Artifact", "Ready" if bundle else "None", "accent" if bundle else "muted"), + ] + if active == "predict": + align = align or {} + source_label = align.get("source_label", "Session model") + required = align.get("required") + matched = align.get("matched") + ok = align.get("ok") + return [ + ("Scoring with", source_label, None), + ("Model", model_text, model_tone), + ("Features required", f"{required:,}" if required is not None else "—", None), + ( + "Features matched", + f"{matched:,}" if matched is not None else "—", + None if ok in (None, True) else "error", + ), + ("Inference", "Ready" if ok else ("Blocked" if ok is not None else "—"), "accent" if ok else ("error" if ok is not None else None)), + ] + if active == "results": + predicted = predicted or {} + rows = [ + ("Run", (run_meta or {}).get("session_id", "—"), None), + ( + "Epochs", + f"{bundle.metrics['epochs_completed']}" if bundle else "—", + None, + ), + ("Val accuracy", f"{bundle.metrics['accuracy']:.3f}" if bundle else "—", "accent" if bundle else None), + ("Macro F1", f"{bundle.metrics['f1_macro']:.3f}" if bundle else "—", None), + ( + "Predictions", + f"{predicted['n_samples']:,} scored" if predicted.get("n_samples") else "None yet", + None, + ), + ] + return rows + if active == "data": + if workspace: + genes_aligned = f"{len(workspace.gene_branch.input_genes):,} · {len(workspace.dna_branch.input_genes):,}" + pathways_kept = f"{len(workspace.gene_branch.pathways) + len(workspace.dna_branch.pathways):,}" + else: + genes_aligned = "—" + pathways_kept = "—" + return [ + ("Dataset", dataset, None), + ("Genes aligned", genes_aligned, None), + ("Pathways kept", pathways_kept, None), + ("Architecture", arch_text, arch_tone), + ("Model", model_text, model_tone), + ] + # train + rows = [ + ("Dataset", dataset, None), + ("Architecture", arch_text, arch_tone), + ("Model", model_text, model_tone), + ] + if bundle: + rows.append(("Epochs", f"{bundle.metrics['epochs_completed']}", None)) + rows.append(("Device", (bundle.metrics.get("device") if bundle else "ZeroGPU") or "ZeroGPU", None)) + return rows + + +def _rail_row_updates(active, workspace, bundle, predicted_ready): + states = _rail_states(active, bool(workspace), bool(bundle), predicted_ready) + return [ui.rail_row_html(i + 1, label, state, word) for i, (label, (state, word)) in enumerate(zip(STAGE_LABELS, states))] + + +def _session_note(bundle) -> str: + if bundle: + device = bundle.metrics.get("device", "cpu") + return "ZeroGPU idle" if device == "cuda" else "CPU" + return "ZeroGPU idle" + + +def _refresh_chrome(active, workspace, bundle, run_meta, align, predicted, session_id): + run_meta = run_meta or {} + predicted = predicted or {} + predicted_ready = bool(predicted.get("n_samples")) + rail_html = _rail_row_updates(active, workspace, bundle, predicted_ready) + plate = ui.run_state_plate(_run_state_rows(active, workspace, bundle, run_meta, align, predicted)) + stage_index = STAGE_KEYS.index(active) + 1 + topbar = ui.topbar_html( + stage_index, STAGE_TOTAL, STAGE_LABELS[stage_index - 1], session_id or "——————", _session_note(bundle) + ) + train_nav_ok = bool(workspace) + export_nav_ok = bool(bundle) + return (*rail_html, plate, topbar, gr.update(interactive=train_nav_ok), gr.update(interactive=export_nav_ok)) + + +CHROME_OUTPUT_NAMES = [ + "rail_1", "rail_2", "rail_3", "rail_4", "rail_5", "run_plate", "topbar", "train_click", "export_click", +] + + +def _enter_page(page_key, workspace, bundle, run_meta, align, predicted, session_id): + visibility = tuple(gr.update(visible=page == page_key) for page in STAGE_KEYS) + chrome = _refresh_chrome(page_key, workspace, bundle, run_meta, align, predicted, session_id) + return (page_key, *visibility, *chrome) + + +# ═══════════════════════════════════════════════════════════════════════ +# Data & Priors +# ═══════════════════════════════════════════════════════════════════════ + + def prepare_workspace( source_mode: str, example_dataset: str, @@ -241,11 +292,13 @@ def prepare_workspace( embedding_option: str, progress=gr.Progress(track_tqdm=False), ): + empty = pd.DataFrame() try: progress(0.03, desc="Reading omics data") if source_mode == "BioLM-NET examples": sources = upstream_example_sources(example_dataset) frames = {name: read_csv(url) for name, url in sources.items()} + file_labels = {name: Path(url).name for name, url in sources.items()} source_name = f"BioLM-NET / {example_dataset}" precomputed_significant = True allow_preset_trim = True @@ -254,84 +307,59 @@ def prepare_workspace( raise ValueError("Enter a GitHub dataset folder URL.") sources = github_dataset_sources(github_folder) frames = {name: read_csv(url) for name, url in sources.items()} + file_labels = {name: Path(url).name for name, url in sources.items()} source_name = github_folder.strip() precomputed_significant = pathway_files_are_significant allow_preset_trim = False else: - frames = { - "gene": _read_required_upload( - uploaded_gene, "Gene_Expression.csv" - ), - "dna": _read_required_upload( - uploaded_dna, "DNA_Methylation.csv" - ), - "labels": _read_required_upload( - uploaded_labels, "label.csv" - ), - "gene_pathways": _read_required_upload( - uploaded_gene_pathway, - "the gene-expression pathway mapping CSV", - ), - "dna_pathways": _read_required_upload( - uploaded_dna_pathway, - "the DNA-methylation pathway mapping CSV", - ), + uploads = { + "gene": (uploaded_gene, "Gene_Expression.csv"), + "dna": (uploaded_dna, "DNA_Methylation.csv"), + "labels": (uploaded_labels, "label.csv"), + "gene_pathways": (uploaded_gene_pathway, "the gene-expression pathway mapping CSV"), + "dna_pathways": (uploaded_dna_pathway, "the DNA-methylation pathway mapping CSV"), } + frames = {key: _read_required_upload(path, label) for key, (path, label) in uploads.items()} + file_labels = {key: Path(path).name for key, (path, _) in uploads.items()} source_name = "Uploaded dataset" precomputed_significant = pathway_files_are_significant allow_preset_trim = False - ( - gene_frame, - dna_frame, - labels, - label_names, - warnings, - ) = validate_and_align_omics( - frames["gene"], - frames["dna"], - frames["labels"], - allow_preset_trim=allow_preset_trim, + (gene_frame, dna_frame, labels, label_names, warnings) = validate_and_align_omics( + frames["gene"], frames["dna"], frames["labels"], allow_preset_trim=allow_preset_trim ) progress(0.18, desc="Loading PDI and PPI priors") if uploaded_pdi or uploaded_ppi: if not uploaded_pdi or not uploaded_ppi: - raise ValueError( - "To override the repository priors, upload both PDI and PPI files." - ) + raise ValueError("To override the repository priors, upload both PDI and PPI files.") pdi_frame = read_csv(uploaded_pdi) ppi_frame = read_csv(uploaded_ppi) else: pdi_frame, ppi_frame = _upstream_interactions() progress(0.38, desc="Constructing sparse biological masks") - gene_branch = build_biological_mask( - list(gene_frame.columns), pdi_frame, ppi_frame - ) - dna_branch = build_biological_mask( - list(dna_frame.columns), pdi_frame, ppi_frame - ) + gene_branch = build_biological_mask(list(gene_frame.columns), pdi_frame, ppi_frame) + dna_branch = build_biological_mask(list(dna_frame.columns), pdi_frame, ppi_frame) - embedding_file = _resolve_embedding_file( - embedding_option, source_mode, example_dataset - ) + embedding_file = _resolve_embedding_file(embedding_option, source_mode, example_dataset) progress(0.53, desc="Retrieving GenePT embeddings") embeddings = load_genept_embeddings(embedding_file) progress(0.72, desc="Building enriched pathway connections") gene_enrichment = attach_embeddings_and_pathways( - gene_branch, - embeddings, - frames["gene_pathways"], - precomputed_significant=precomputed_significant, + gene_branch, embeddings, frames["gene_pathways"], precomputed_significant=precomputed_significant ) + # Snapshot the unmatched pathway symbols before dna's own attach call + # mutates dna_branch.hidden_genes in place — presentation only, used + # by the "Show symbols" reveal. + dna_hidden_before = list(dna_branch.hidden_genes) dna_enrichment = attach_embeddings_and_pathways( - dna_branch, - embeddings, - frames["dna_pathways"], - precomputed_significant=precomputed_significant, + dna_branch, embeddings, frames["dna_pathways"], precomputed_significant=precomputed_significant ) + dna_pathway_symbols = set(frames["dna_pathways"]["SYMBOL"].astype(str).str.strip()) + unmatched_symbols = sorted(set(dna_hidden_before) - dna_pathway_symbols) + workspace = PreparedWorkspace( gene_expression=gene_frame.to_numpy(dtype="float32"), dna_methylation=dna_frame.to_numpy(dtype="float32"), @@ -353,9 +381,7 @@ def prepare_workspace( "PPI edges": gene_branch.ppi_edges, "hidden genes": len(gene_branch.hidden_genes), "pathways": len(gene_branch.pathways), - "mask density": ( - gene_branch.biological_mask.astype(bool).mean() - ), + "mask density": gene_branch.biological_mask.astype(bool).mean(), }, { "branch": "DNA methylation", @@ -365,79 +391,127 @@ def prepare_workspace( "PPI edges": dna_branch.ppi_edges, "hidden genes": len(dna_branch.hidden_genes), "pathways": len(dna_branch.pathways), - "mask density": ( - dna_branch.biological_mask.astype(bool).mean() - ), + "mask density": dna_branch.biological_mask.astype(bool).mean(), }, ] ) enrichments = pd.concat( - [ - gene_enrichment.assign(branch="Gene expression"), - dna_enrichment.assign(branch="DNA methylation"), - ], + [gene_enrichment.assign(branch="Gene expression"), dna_enrichment.assign(branch="DNA methylation")], ignore_index=True, ) - warning_text = ( - "
" + " · ".join(html.escape(item) for item in warnings) + "" - if warnings - else "" + + final_len = len(gene_frame) + resolved_rows = [] + for key in ("gene", "dna", "labels"): + raw = frames[key] + aligned = len(raw) == final_len + resolved_rows.append( + { + "File": file_labels[key], + "Shape": f"{len(raw):,} × {raw.shape[1]}", + "Rows matched": f"{final_len:,}", + "Status": "Aligned" if aligned else "Trimmed", + } + ) + for key, branch in (("gene_pathways", gene_branch), ("dna_pathways", dna_branch)): + raw = frames[key] + resolved_rows.append( + { + "File": file_labels[key], + "Shape": f"{len(raw):,} × {raw.shape[1]}", + "Rows matched": f"{len(branch.hidden_genes):,}", + "Status": "Enriched", + } + ) + resolved_files = pd.DataFrame(resolved_rows) + + warning_html = ( + "
" + " · ".join(html.escape(item) for item in warnings) if warnings else "" + ) + summary = ui.simple_status_html( + f"Architecture ready. {len(gene_frame):,} paired samples · " + f"{len(label_names)} classes · {len(gene_branch.pathways) + len(dna_branch.pathways):,} " + f"branch-specific pathways · GenePT: {html.escape(embedding_file)}{warning_html}" + ) + note = ( + '
' + + ui.esc( + f"{len(resolved_rows)} of {len(resolved_rows)} inputs resolved" + + (f" · {len(warnings)} warning(s)" if warnings else "") + + f" · architecture built {datetime.now().strftime('%H:%M:%S')}" + ) + + "
" + ) + run_meta = { + "source_name": source_name, + "embedding_file": embedding_file, + "unmatched_pathway_symbols": unmatched_symbols, + } + mean_density = float( + np.mean( + [ + gene_branch.biological_mask.astype(bool).mean(), + dna_branch.biological_mask.astype(bool).mean(), + ] + ) ) - summary = ( - '
Architecture ready. ' - f"{len(gene_frame):,} paired samples · {len(label_names)} classes · " - f"{len(gene_branch.pathways) + len(dna_branch.pathways):,} " - f"branch-specific pathways · GenePT: {html.escape(embedding_file)}" - f"{warning_text}
" + genept_dim = gene_branch.embeddings.shape[1] if gene_branch.embeddings is not None else 0 + graph_preview_html = _graph_preview_html( + f"{mean_density * 100:.1f}%", + f"{len(gene_branch.hidden_genes) + len(dna_branch.hidden_genes):,}", + f"{len(gene_branch.pathways) + len(dna_branch.pathways):,}", + f"{genept_dim:,}", ) progress(1.0, desc="Ready to train") - return workspace, summary, architecture, enrichments.head(100) + return workspace, summary, architecture, enrichments.head(100), resolved_files, run_meta, note, graph_preview_html except Exception as exc: - return None, _status(str(exc), error=True), pd.DataFrame(), pd.DataFrame() + fallback_note = '
' + ui.esc( + "Choose a source, then build the biological architecture." + ) + "
" + empty_graph_preview = _graph_preview_html("—", "—", "—", "—") + return None, _status(str(exc), error=True), empty, empty, empty, {}, fallback_note, empty_graph_preview -def _training_plots(history: list[dict[str, float]], confusion, labels): - history_frame = pd.DataFrame(history) - loss_figure = go.Figure() - loss_figure.add_trace( +def _reveal_symbols(run_meta: dict): + symbols = (run_meta or {}).get("unmatched_pathway_symbols") or [] + if not symbols: + return gr.update(visible=True, value=ui.empty_note_html("No unmatched pathway symbols were recorded.")) + shown = ", ".join(symbols[:40]) + more = f" … and {len(symbols) - 40:,} more" if len(symbols) > 40 else "" + return gr.update(visible=True, value=f'
{html.escape(shown)}{more}
') + + +# ═══════════════════════════════════════════════════════════════════════ +# Train Model +# ═══════════════════════════════════════════════════════════════════════ + + +def _loss_plot(history: list[dict[str, float]]) -> go.Figure: + frame = pd.DataFrame(history) + figure = go.Figure() + figure.add_trace( go.Scatter( - x=history_frame["epoch"], - y=history_frame["training_loss"], - mode="lines", - name="Training", - line={"color": "#167c5a", "width": 3}, + x=frame["epoch"], y=frame["training_loss"], mode="lines", name="Training", + line={"color": "#5980a6", "width": 2.5}, ) ) - loss_figure.add_trace( + figure.add_trace( go.Scatter( - x=history_frame["epoch"], - y=history_frame["validation_loss"], - mode="lines", - name="Validation", - line={"color": "#e5a63c", "width": 3}, + x=frame["epoch"], y=frame["validation_loss"], mode="lines", name="Validation", + line={"color": "#2c455d", "width": 2.5}, ) ) - loss_figure.update_layout( - title="Loss by epoch", - xaxis_title="Epoch", - yaxis_title="Cross-entropy loss", + figure.update_layout( template="plotly_white", - margin={"l": 30, "r": 15, "t": 50, "b": 35}, + paper_bgcolor="#f2f2f3", + plot_bgcolor="#f2f2f3", + font={"family": "Barlow, sans-serif", "color": "#1d1f20"}, + margin={"l": 40, "r": 15, "t": 15, "b": 35}, legend={"orientation": "h", "y": 1.12}, + xaxis={"title": "Epoch", "gridcolor": "rgba(29,31,32,.1)"}, + yaxis={"title": "Cross-entropy loss", "gridcolor": "rgba(29,31,32,.1)"}, ) - confusion_figure = px.imshow( - confusion, - x=labels, - y=labels, - text_auto=True, - color_continuous_scale=[[0, "#eef6f2"], [1, "#167c5a"]], - labels={"x": "Predicted", "y": "Observed", "color": "Samples"}, - title="Validation confusion matrix", - ) - confusion_figure.update_layout( - template="plotly_white", margin={"l": 30, "r": 15, "t": 50, "b": 35} - ) - return loss_figure, confusion_figure + return figure def estimate_training_duration( @@ -464,9 +538,8 @@ def estimate_training_duration( if workspace is None: return 10 samples = max(int(len(workspace.labels)), 1) - biological_parameters = ( - int(workspace.gene_branch.biological_mask.size) - + int(workspace.dna_branch.biological_mask.size) + biological_parameters = int(workspace.gene_branch.biological_mask.size) + int( + workspace.dna_branch.biological_mask.size ) sample_factor = max(samples / 875.0, 0.25) graph_factor = max(math.sqrt(biological_parameters / 1_850_000.0), 0.3) @@ -475,6 +548,52 @@ def estimate_training_duration( return int(min(300, max(30, math.ceil(seconds)))) +def _architecture_audit(bundle: ModelBundle) -> pd.DataFrame: + """Read-only introspection of the trained model's own tensors — no change + to biolmnet.model, just reporting the shapes/sparsity it already has.""" + model = bundle.model + rows = [] + + def add(name: str, tensor: torch.Tensor, nonzero: int | None = None): + total = tensor.numel() + nnz = int((tensor != 0).sum().item()) if nonzero is None else nonzero + rows.append( + { + "Layer": name, + "Shape": " × ".join(str(d) for d in tensor.shape), + "Nonzero": nnz, + "Density": nnz / total if total else 0.0, + } + ) + + for branch_name, branch in (("gene", model.gene_branch), ("dna", model.dna_branch)): + add(f"{branch_name} · biological mask", branch.biological.mask) + pw_mask = branch.pathway_attention.pathway_mask + add(f"{branch_name} · pathway attention", pw_mask, nonzero=int(pw_mask.sum().item())) + add(f"{branch_name} · branch projection", branch.projection.weight, nonzero=branch.projection.weight.numel()) + add("fusion", model.fusion.weight, nonzero=model.fusion.weight.numel()) + add("classifier", model.output.weight, nonzero=model.output.weight.numel()) + return pd.DataFrame(rows) + + +def _macro_auc(validation_predictions: pd.DataFrame, label_names: list[str]) -> float | None: + """Macro-averaged one-vs-rest AUC, computed here from the stored validation + probabilities — presentation-layer only; does not change what train() + computes or returns.""" + try: + probability_columns = [f"P({label})" for label in label_names] + y_true = validation_predictions["observed"] + y_score = validation_predictions[probability_columns].to_numpy() + y_true_indices = y_true.map({label: index for index, label in enumerate(label_names)}).to_numpy() + if len(label_names) < 2 or len(set(y_true_indices)) < 2: + return None + return float( + roc_auc_score(y_true_indices, y_score, multi_class="ovr", average="macro", labels=list(range(len(label_names)))) + ) + except Exception: + return None + + @spaces.GPU(duration=estimate_training_duration) def train_workspace( workspace: PreparedWorkspace | None, @@ -490,493 +609,1117 @@ def train_workspace( class_weighting: bool, progress=gr.Progress(track_tqdm=False), ): + """Signature intentionally mirrors ``estimate_training_duration`` exactly + (both take the same positional hyperparameters) — ``@spaces.GPU`` calls + the duration estimator with the same *args it received here, so the two + argument lists must stay aligned. Presentation-only metadata (source + dataset, elapsed time, …) is threaded through a separate ``gr.State`` + merge step in the Blocks wiring instead of a new parameter here.""" + empty = pd.DataFrame() + run_meta_update: dict = {} if workspace is None: return ( - None, - _status("Prepare data and priors in Phase 1 before training.", True), - None, - None, - pd.DataFrame(), - None, - pd.DataFrame(), + None, _status("Prepare data and priors in Phase 1 before training.", True), None, empty, None, + empty, empty, run_meta_update, ) try: parameters = Hyperparameters( - epochs=int(epochs), - batch_size=int(batch_size), - learning_rate=float(learning_rate), - weight_decay=float(weight_decay), - dropout=float(dropout), - projection_dim=int(projection_dim), - fusion_dim=int(fusion_dim), - validation_fraction=float(validation_fraction), - optimizer=optimizer, + epochs=int(epochs), batch_size=int(batch_size), learning_rate=float(learning_rate), + weight_decay=float(weight_decay), dropout=float(dropout), projection_dim=int(projection_dim), + fusion_dim=int(fusion_dim), validation_fraction=float(validation_fraction), optimizer=optimizer, class_weighting=bool(class_weighting), ) def report(fraction: float, description: str) -> None: progress(fraction, desc=description) + started = time.monotonic() result = train(workspace, parameters, progress=report) + elapsed_seconds = time.monotonic() - started bundle = result.bundle - artifact = save_bundle(bundle) + artifact_path = save_bundle(bundle) metrics = bundle.metrics - metrics_html = f""" -
Training complete. - Best validation checkpoint restored; the downloadable artifact includes - architecture, preprocessing, weights, and metrics.
-
-
Macro F1{metrics['f1_macro']:.3f}
-
Accuracy{metrics['accuracy']:.3f}
-
Macro precision{metrics['precision_macro']:.3f}
-
Macro recall{metrics['recall_macro']:.3f}
-
- """ - loss_plot, confusion_plot = _training_plots( - bundle.history, result.confusion, bundle.label_names + macro_auc = _macro_auc(result.validation_predictions, bundle.label_names) + + minutes, seconds = divmod(int(elapsed_seconds), 60) + metrics_html = ui.simple_status_html( + f"Training complete. Validation accuracy {metrics['accuracy']:.3f} · " + f"elapsed {minutes:02d}:{seconds:02d}. Best validation checkpoint restored; the " + "downloadable artifact includes architecture, preprocessing, weights and metrics." ) + loss_figure = _loss_plot(bundle.history) importance = pathway_importance(bundle).head(100) + architecture_audit = _architecture_audit(bundle) + confusion_html = ui.blueprint_div( + f'
Confusion matrix · validation split
' + + ui.confusion_matrix_html(result.confusion.tolist(), bundle.label_names) + ) + + run_meta_update = { + "elapsed": f"{minutes:02d}:{seconds:02d}", + "finished_at": datetime.now().strftime("%H:%M:%S"), + "macro_auc": macro_auc, + "session_id": secrets.token_hex(3), + "train_loss": bundle.history[-1]["training_loss"], + } return ( - bundle, - metrics_html, - loss_plot, - confusion_plot, - result.validation_predictions, - artifact, - importance, + bundle, metrics_html, loss_figure, result.validation_predictions, artifact_path, + importance, architecture_audit, run_meta_update, confusion_html, ) except Exception as exc: return ( - None, - _status(str(exc), error=True), - None, - None, - pd.DataFrame(), - None, - pd.DataFrame(), + None, _status(str(exc), error=True), None, empty, None, empty, empty, run_meta_update, + ui.empty_note_html("No confusion matrix yet."), ) +# ═══════════════════════════════════════════════════════════════════════ +# Export Artifacts +# ═══════════════════════════════════════════════════════════════════════ + + +def _export_panel(bundle: ModelBundle | None, artifact_path: str | None, run_meta: dict): + run_meta = run_meta or {} + if not bundle or not artifact_path or not Path(artifact_path).exists(): + empty_manifest = ui.table_html(["Entry", "Format", "Size", "Reproduces"], []) + empty_note = ui.empty_note_html("No trained artifact yet. Train a model to populate this page.") + stats = "".join(ui.stat_plate("—", label) for label in ("Gene features", "DNA features", "Pathways", "Classes")) + return empty_manifest, empty_note, "", stats, gr.update(value=None) + + archive = Path(artifact_path) + with zipfile.ZipFile(archive) as zf: + infos = {info.filename: info for info in zf.infolist()} + reproduces = { + "config.json": "Hyperparameters & architecture", + "arrays.npz": "Biological masks, embeddings & scalers", + "model.safetensors": "Trained weights", + "metrics.json": "Metrics & training history", + } + rows = [] + for name in sorted(EXPECTED_FILES): + info = infos.get(name) + size = info.file_size if info else 0 + rows.append( + [ + html.escape(name), + Path(name).suffix.lstrip(".") or "—", + ui.human_bytes(size), + reproduces.get(name, "—"), + ] + ) + manifest = ui.table_html( + ["Entry", "Format", "Size", "Reproduces"], rows, aligns=["left", "left", "left", "right"], + numeric_cols=[False, True, True, False], + ) + + sha256 = hashlib.sha256(archive.read_bytes()).hexdigest() + total_size = archive.stat().st_size + provenance = "".join( + [ + f'
' + f'
Checksum · sha256
' + f'
{sha256}
', + f'
' + f'
Torch / Python
' + f'
{html.escape(torch.__version__)} / {html.escape(platform.python_version())}
', + f'
' + f'
Upstream repository
' + f'
{html.escape(UPSTREAM_REPOSITORY)}
', + f'
' + f'
GenePT embedding
' + f'
{html.escape(run_meta.get("embedding_file", "—"))}
', + ] + ) + + stats = "".join( + [ + ui.stat_plate(f"{len(bundle.gene_features):,}", "Gene features"), + ui.stat_plate(f"{len(bundle.dna_features):,}", "DNA features"), + ui.stat_plate( + f"{len(bundle.config['gene_pathways']) + len(bundle.config['dna_pathways']):,}", "Pathways" + ), + ui.stat_plate(f"{len(bundle.label_names):,}", "Classes"), + ] + ) + artifact_panel = ui.blueprint_div( + '
Artifact
' + f'
Trained bundle ready
' + f'
' + f'Produced by the run that finished at {html.escape(run_meta.get("finished_at", "—"))} with ' + f'validation accuracy {bundle.metrics["accuracy"]:.3f} over {len(bundle.label_names)} classes. ' + f'Bundle size {ui.human_bytes(total_size)}.
', + extra_class="", + ) + return manifest, provenance, artifact_panel, stats, gr.update(value=artifact_path) + + +# ═══════════════════════════════════════════════════════════════════════ +# Predict +# ═══════════════════════════════════════════════════════════════════════ + + +def _resolve_predict_bundle(model_state, artifact_path, use_upload: bool): + if use_upload and artifact_path: + return load_bundle(artifact_path) + return model_state + + +def _workspace_prediction_frames(workspace: PreparedWorkspace) -> tuple[pd.DataFrame, pd.DataFrame]: + return ( + pd.DataFrame(workspace.gene_expression, columns=workspace.gene_branch.input_genes), + pd.DataFrame(workspace.dna_methylation, columns=workspace.dna_branch.input_genes), + ) + + +def refresh_alignment(model_state, workspace, artifact_path, use_upload, use_prepared, gene_path, dna_path): + """Presentation-only pre-flight check: mirrors the two structural checks + ``validate_prediction_frames`` performs (row-count match, required + columns present) purely to render the alignment table live. The actual + gating decision on submit still goes through the real, untouched + ``predict()`` call.""" + align = {"ok": None, "required": None, "matched": None, "source_label": "Prepared dataset" if use_prepared else "Session model"} + try: + bundle = _resolve_predict_bundle(model_state, artifact_path, use_upload) + except Exception as exc: + rows = [[html.escape("Artifact"), "—", "—", f'{html.escape(str(exc))}']] + table = ui.table_html(["Check", "Artifact", "Uploaded", "Result"], rows, aligns=["left", "left", "left", "right"]) + return table, gr.update(visible=False), gr.update(value="Run inference", interactive=False, elem_classes=["actbar-primary-btn"]), align + + if use_upload: + align["source_label"] = "Uploaded artifact" if artifact_path else "Upload artifact" + if bundle is None: + table = ui.table_html( + ["Check", "Artifact", "Uploaded", "Result"], + [["Trained model available", "—", "—", 'Fail']], + aligns=["left", "left", "left", "right"], + ) + return table, gr.update(visible=False), gr.update(value="Run inference", interactive=False, elem_classes=["actbar-primary-btn"]), align + + align["required"] = len(bundle.gene_features) + len(bundle.dna_features) + + rows = [] + ok = True + gene_frame = dna_frame = None + if use_prepared: + if workspace is None: + rows.append(["Prepared dataset available", "—", "—", 'Fail']) + ok = False + else: + gene_frame, dna_frame = _workspace_prediction_frames(workspace) + rows.append(["Prepared dataset available", "—", f"{len(gene_frame):,} samples", _pass_fail(True)]) + elif gene_path and dna_path: + try: + gene_frame = read_csv(gene_path) + dna_frame = read_csv(dna_path) + except Exception as exc: + rows.append(["Reading uploaded files", "—", "—", f'{html.escape(str(exc))}']) + ok = False + + if gene_frame is not None and dna_frame is not None: + rows_match = len(gene_frame) == len(dna_frame) + ok = ok and rows_match + rows.append( + [ + "Samples paired across matrices", "—", f"{len(gene_frame):,} / {len(dna_frame):,}", + _pass_fail(rows_match), + ] + ) + missing_gene = sorted(set(bundle.gene_features) - set(gene_frame.columns)) + missing_dna = sorted(set(bundle.dna_features) - set(dna_frame.columns)) + gene_ok = not missing_gene + dna_ok = not missing_dna + ok = ok and gene_ok and dna_ok + rows.append(["Expression columns", f"{len(bundle.gene_features):,}", f"{len(gene_frame.columns):,}", _pass_fail(gene_ok)]) + rows.append(["Methylation columns", f"{len(bundle.dna_features):,}", f"{len(dna_frame.columns):,}", _pass_fail(dna_ok)]) + matched = align["required"] - len(missing_gene) - len(missing_dna) + align["matched"] = matched + align["missing_gene"] = missing_gene + align["missing_dna"] = missing_dna + else: + rows.append(["Gene expression uploaded", "—", "—", _pass_fail(gene_path is not None)]) + rows.append(["DNA methylation uploaded", "—", "—", _pass_fail(dna_path is not None)]) + ok = False + + align["ok"] = ok + table = ui.table_html(["Check", "Artifact", "Uploaded", "Result"], rows, aligns=["left", "left", "left", "right"]) + missing_total = len(align.get("missing_gene", [])) + len(align.get("missing_dna", [])) + strip_visible = not ok and (gene_frame is not None and dna_frame is not None) + return table, gr.update(visible=strip_visible, value=( + ui.strip_text_html("Blocking · features missing", f"{missing_total:,} required column(s) are missing from the uploaded files.") + if strip_visible else "" + )), gr.update(value="Run inference", interactive=bool(ok), elem_classes=["actbar-primary-btn"]), align + + +def _pass_fail(ok: bool) -> str: + return 'Pass' if ok else 'Fail' + + +def _list_missing(align: dict): + missing = list((align or {}).get("missing_gene", [])) + list((align or {}).get("missing_dna", [])) + if not missing: + return gr.update(visible=True, value=ui.empty_note_html("Nothing missing.")) + shown = ", ".join(missing[:40]) + more = f" … and {len(missing) - 40:,} more" if len(missing) > 40 else "" + return gr.update(visible=True, value=f'
{html.escape(shown)}{more}
') + + def run_prediction( bundle: ModelBundle | None, + workspace: PreparedWorkspace | None, uploaded_artifact: str | None, + use_upload: bool, + use_prepared: bool, gene_file: str | None, dna_file: str | None, + predict_meta: dict, ): + predict_meta = dict(predict_meta or {}) try: - active_bundle = ( - load_bundle(uploaded_artifact) if uploaded_artifact else bundle - ) + active_bundle = load_bundle(uploaded_artifact) if (use_upload and uploaded_artifact) else bundle if active_bundle is None: - raise ValueError( - "Train a model in Phase 2 or upload a BioLM-NET model artifact." - ) - gene_frame = _read_required_upload( - gene_file, "a prediction gene-expression CSV" - ) - dna_frame = _read_required_upload( - dna_file, "a prediction DNA-methylation CSV" - ) + raise ValueError("Train a model in Phase 2 or upload a BioLM-NET model artifact.") + if use_prepared: + if workspace is None: + raise ValueError("Prepare data and priors before scoring the prepared dataset.") + gene_frame, dna_frame = _workspace_prediction_frames(workspace) + else: + gene_frame = _read_required_upload(gene_file, "a prediction gene-expression CSV") + dna_frame = _read_required_upload(dna_file, "a prediction DNA-methylation CSV") output = predict(gene_frame, dna_frame, active_bundle) - destination = ( - Path(tempfile.mkdtemp(prefix="biolmnet-prediction-")) - / "biolm-net-predictions.csv" - ) + destination = Path(tempfile.mkdtemp(prefix="biolmnet-prediction-")) / "biolm-net-predictions.csv" output.to_csv(destination, index=False) - counts = ( - output["predicted_class"] - .value_counts() - .rename_axis("class") - .reset_index(name="samples") - ) - figure = px.bar( - counts, - x="class", - y="samples", - color="class", - color_discrete_sequence=[ - "#167c5a", - "#e5a63c", - "#497f93", - "#8d6fa8", - "#be6f55", - ], - title="Predicted class distribution", + + counts = output["predicted_class"].value_counts() + total = int(counts.sum()) + bars = "".join( + ui.labeled_bar_row(str(label), (count / total) * 100 if total else 0, f"{count:,}") + for label, count in counts.items() ) - figure.update_layout( - showlegend=False, - template="plotly_white", - margin={"l": 30, "r": 15, "t": 50, "b": 35}, + distribution_panel = ui.blueprint_div( + f'
Class distribution · {total:,} samples
' + f'
{bars}
' ) - status = _status( - f"Predicted {len(output):,} samples. Mean confidence: " - f"{output['confidence'].mean():.3f}." + status = _status(f"Predicted {len(output):,} samples. Mean confidence: {output['confidence'].mean():.3f}.") + predict_meta.update( + { + "n_samples": total, + "mean_confidence": float(output["confidence"].mean()), + "predicted_at": datetime.now().strftime("%H:%M:%S"), + } ) - return active_bundle, status, output, figure, str(destination) + return active_bundle, status, output, distribution_panel, str(destination), predict_meta except Exception as exc: - return bundle, _status(str(exc), True), pd.DataFrame(), None, None + return bundle, _status(str(exc), True), pd.DataFrame(), ui.empty_note_html("No predictions yet."), None, predict_meta -THEME = gr.themes.Base( - primary_hue="emerald", - neutral_hue="slate", -) +# ═══════════════════════════════════════════════════════════════════════ +# Results +# ═══════════════════════════════════════════════════════════════════════ + + +def _pathway_attention_table(bundle: ModelBundle) -> pd.DataFrame: + importance = pathway_importance(bundle).head(20) + model = bundle.model + gene_counts = model.gene_branch.pathway_attention.pathway_mask.sum(axis=0).cpu().numpy() + dna_counts = model.dna_branch.pathway_attention.pathway_mask.sum(axis=0).cpu().numpy() + gene_index = {pathway: int(count) for pathway, count in zip(bundle.config["gene_pathways"], gene_counts)} + dna_index = {pathway: int(count) for pathway, count in zip(bundle.config["dna_pathways"], dna_counts)} + + def gene_count(row): + table = gene_index if row["branch"] == "Gene expression" else dna_index + return table.get(row["pathway"], 0) + + importance = importance.copy() + importance["genes"] = importance.apply(gene_count, axis=1) + return importance[["branch", "pathway", "genes", "peak_gene_attention", "attention_entropy"]] + + +def refresh_results(bundle: ModelBundle | None, run_meta: dict): + run_meta = run_meta or {} + if bundle is None: + stats = ( + '
' + + "".join(ui.stat_plate("—", label) for label in ("Validation accuracy", "Macro F1", "Macro AUC", "Retained pathways")) + + "
" + ) + return ( + stats, + gr.update(visible=False), + ui.empty_note_html( + "Build the biological architecture, train a model, or run prediction to populate this page." + ), + gr.update(visible=False), + ) + + macro_auc = run_meta.get("macro_auc") + retained = len(bundle.config["gene_pathways"]) + len(bundle.config["dna_pathways"]) + stats = ( + '
' + + "".join( + [ + ui.stat_plate(f"{bundle.metrics['accuracy']:.3f}", "Validation accuracy"), + ui.stat_plate(f"{bundle.metrics['f1_macro']:.3f}", "Macro F1"), + ui.stat_plate(f"{macro_auc:.3f}" if macro_auc is not None else "—", "Macro AUC"), + ui.stat_plate(f"{retained:,}", "Retained pathways"), + ] + ) + + "
" + ) + return stats, gr.update(visible=True), "", gr.update(visible=True) + + +THEME = gr.themes.Base(primary_hue="gray", neutral_hue="gray") -with gr.Blocks(title="BioLM-NET Workbench") as demo: +# ═══════════════════════════════════════════════════════════════════════ +# Blocks +# ═══════════════════════════════════════════════════════════════════════ + +with gr.Blocks(title="BioLM-NET Workbench", fill_width=True) as demo: workspace_state = gr.State(None) model_state = gr.State(None) + artifact_path_state = gr.State(None) + run_meta_state = gr.State({}) + align_state = gr.State({}) + predict_meta_state = gr.State({}) + active_page_state = gr.State("data") + session_id_state = gr.State("") + intro_step_state = gr.State(0) + intro_dismissed_state = gr.BrowserState(False, storage_key="biolmnet_intro_dismissed") - gr.HTML( - """ -
-
Interpretable multi-omics modeling
-

BioLM-NET
Workbench

-

Build a biologically masked network from paired gene expression and - DNA methylation, train it with GenePT-guided pathway attention, then - carry the exact preprocessing and architecture into prediction.

-
- PDI · DoRothEA - PPI · STRING - Pathways · KEGG - Context · GenePT - Compute · ZeroGPU on demand -
-
- """ - ) + with gr.Row(elem_classes=["app-shell"]): + with gr.Column(elem_classes=["rail-col"]): + with gr.Column(elem_classes=["rail"]): + gr.HTML(ui.brand_block_html()) + rail_row_htmls = [] + rail_row_buttons = [] + with gr.Column(elem_classes=["rail-nav"]): + for index, label in enumerate(STAGE_LABELS): + with gr.Column(elem_classes=["rail-row-wrap"]): + row_html = gr.HTML(ui.rail_row_html(index + 1, label, "on" if index == 0 else "off", "Active" if index == 0 else "Locked")) + row_button = gr.Button("", elem_classes=["rail-row-click"]) + rail_row_htmls.append(row_html) + rail_row_buttons.append(row_button) + with gr.Column(elem_classes=["rail-spacer", "rail-plate-wrap"]): + gr.HTML('
Run state
') + run_state_plate_html = gr.HTML(ui.run_state_plate(_run_state_rows("data", None, None, {}, {}, {}))) + gr.HTML(ui.footnote_html("Research use only", "ZeroGPU on demand · py 3.12")) - with gr.Tabs(): - with gr.Tab("1 · Data & priors", id="data"): - gr.HTML( - """ -

Assemble the model graph. - Select an upstream example, point to a GitHub folder that follows - the BioLM-NET file convention, or upload paired omics files.

- """ - ) - with gr.Row(): - with gr.Column(scale=7, elem_classes=["phase-card"]): - source_mode = gr.Radio( - [ - "BioLM-NET examples", - "GitHub folder", - "Upload files", - ], - value="BioLM-NET examples", - label="Dataset source", + with gr.Column(elem_classes=["workspace-col"]): + with gr.Row(elem_classes=["topbar-host"]): + topbar_html_component = gr.HTML(ui.topbar_html(1, STAGE_TOTAL, STAGE_LABELS[0], "——————", "ZeroGPU idle")) + intro_open_button = gr.Button("Introduction", size="sm", variant="secondary", elem_classes=["btn-ghost", "intro-open-btn"]) + + # ── Data & Priors ──────────────────────────────────────── + with gr.Column(visible=True) as data_page: + with gr.Row(elem_classes=["pghd"]): + gr.HTML( + ui.title_block_html( + "Data & Priors", + "Resolve paired omics and pathway inputs, then assemble the biologically masked " + "graph that training and prediction both reuse.", + ) ) - with gr.Column(visible=True) as example_group: - example_dataset = gr.Dropdown( - ["BRCA", "COAD", "GBM", "scTrioseq2"], - value="BRCA", - label="Repository dataset", + with gr.Row(elem_classes=["stage-grid"], equal_height=False): + with gr.Column(scale=7): + with gr.Row(elem_classes=["section-heading-row"]): + gr.HTML('

Dataset source

') + gr.HTML('
Samples in rows · HGNC symbols in columns
') + source_mode = gr.Radio( + ["BioLM-NET examples", "GitHub folder", "Upload files"], + value="BioLM-NET examples", show_label=False, container=False, + elem_classes=["seg-radio"], ) - with gr.Column(visible=False) as github_group: - github_folder = gr.Textbox( - label="GitHub dataset folder", - placeholder=( - "https://github.com/owner/repo/tree/main/Dataset/BRCA" - ), - info=( - "The folder must contain the five standard " - "BioLM-NET CSV filenames." - ), + with gr.Row(elem_classes=["srow-row", "first"]): + with gr.Column(scale=1, min_width=0): + with gr.Column(visible=True) as example_group: + with gr.Row(elem_classes=["field-pair"]): + with gr.Column(min_width=0): + gr.HTML('
Repository dataset
') + example_dataset = gr.Dropdown( + ["BRCA", "COAD", "GBM", "scTrioseq2"], value="BRCA", + show_label=False, container=False, + ) + with gr.Column(visible=False) as github_group: + github_folder = gr.Textbox( + label="GitHub dataset folder", + placeholder="https://github.com/owner/repo/tree/main/Dataset/BRCA", + info="The folder must contain the five standard BioLM-NET CSV filenames.", + ) + with gr.Column(visible=False) as upload_group: + with gr.Row(): + uploaded_gene = gr.File(label="Gene expression", file_types=[".csv"], type="filepath", elem_classes=["file-slot"]) + uploaded_dna = gr.File(label="DNA methylation", file_types=[".csv"], type="filepath", elem_classes=["file-slot"]) + uploaded_labels = gr.File(label="Labels", file_types=[".csv"], type="filepath", elem_classes=["file-slot"]) + with gr.Row(): + uploaded_gene_pathway = gr.File(label="Gene → pathway mapping", file_types=[".csv"], type="filepath", elem_classes=["file-slot"]) + uploaded_dna_pathway = gr.File(label="DNA → pathway mapping", file_types=[".csv"], type="filepath", elem_classes=["file-slot"]) + + gr.HTML('
Resolved files
') + resolved_files_table = gr.Dataframe( + headers=["File", "Shape", "Rows matched", "Status"], interactive=False, wrap=False, + value=pd.DataFrame(), ) - with gr.Column(visible=False) as upload_group: - with gr.Row(): - uploaded_gene = gr.File( - label="Gene expression", - file_types=[".csv"], - type="filepath", - ) - uploaded_dna = gr.File( - label="DNA methylation", - file_types=[".csv"], - type="filepath", - ) - uploaded_labels = gr.File( - label="Labels", - file_types=[".csv"], - type="filepath", - ) - with gr.Row(): - uploaded_gene_pathway = gr.File( - label="Gene → pathway mapping", - file_types=[".csv"], - type="filepath", - ) - uploaded_dna_pathway = gr.File( - label="DNA → pathway mapping", - file_types=[".csv"], - type="filepath", - ) - pathway_files_are_significant = gr.Checkbox( - value=True, - label="Pathway files already contain significant pathways", - info=( - "Turn off for a full SYMBOL/PathwayID annotation " - "catalog; enrichment will use BH-adjusted p < 0.05." - ), - ) - with gr.Column(scale=5, elem_classes=["phase-card"]): - embedding_option = gr.Dropdown( - list(GENEPT_OPTIONS), - value=list(GENEPT_OPTIONS)[0], - label="GenePT context", - ) - with gr.Accordion("Interaction priors", open=False): - gr.Markdown( - "By default, the app retrieves `PDI.csv` and `PPI.csv` " - "from `bozdaglab/BioLM-NET`. Upload both only to override." + with gr.Row(elem_classes=["strip", "error"], visible=False) as unmatched_strip: + unmatched_note = gr.HTML("") + show_symbols_btn = gr.Button("Show symbols", size="sm", elem_classes=["btn-ghost"]) + unmatched_reveal = gr.HTML(visible=False) + + with gr.Column(scale=5, min_width=0): + gr.HTML('
Priors
') + with gr.Row(elem_classes=["srow-row", "first"]): + with gr.Column(scale=196, min_width=140): + gr.HTML(ui.esc("Pathway files already significant")) + with gr.Column(scale=300, min_width=0): + pathway_files_are_significant = gr.Checkbox( + value=True, label="Yes — skip enrichment", container=False, + ) + gr.HTML( + '
Turn off for a full SYMBOL/PathwayID ' + 'annotation catalog; enrichment will use BH-adjusted p < 0.05.
' + ) + with gr.Row(elem_classes=["srow-row"]): + with gr.Column(scale=196, min_width=140): + gr.HTML(ui.esc("Enrichment cutoff")) + with gr.Column(scale=300, min_width=0): + gr.HTML( + 'BH-adjusted p ' + '< 0.05' + ) + with gr.Row(elem_classes=["srow-row"]): + with gr.Column(scale=196, min_width=140): + gr.HTML(ui.esc("GenePT context")) + with gr.Column(scale=300, min_width=0): + embedding_option = gr.Dropdown( + list(GENEPT_OPTIONS), value=list(GENEPT_OPTIONS)[0], show_label=False, container=False, + ) + with gr.Row(elem_classes=["srow-row"]): + with gr.Column(scale=196, min_width=140): + gr.HTML(ui.esc("PDI source")) + with gr.Column(scale=300, min_width=0): + with gr.Row(elem_classes=["srow-value-row"]): + gr.HTML('DoRothEA') + pdi_override_btn = gr.Button("Override", size="sm", elem_classes=["btn-ghost"]) + uploaded_pdi = gr.File( + label="Custom PDI.csv (needs TF, Target columns)", file_types=[".csv"], + type="filepath", visible=False, + ) + with gr.Row(elem_classes=["srow-row"]): + with gr.Column(scale=196, min_width=140): + gr.HTML(ui.esc("PPI source")) + with gr.Column(scale=300, min_width=0): + with gr.Row(elem_classes=["srow-value-row"]): + gr.HTML('STRING > 0.7') + ppi_override_btn = gr.Button("Override", size="sm", elem_classes=["btn-ghost"]) + uploaded_ppi = gr.File( + label="Custom PPI.csv (needs protein1, protein2, combined_score)", + file_types=[".csv"], type="filepath", visible=False, + ) + with gr.Row(elem_classes=["srow-row", "last"]): + with gr.Column(scale=196, min_width=140): + gr.HTML(ui.esc("PPI retention")) + with gr.Column(scale=300, min_width=0): + gr.HTML( + 'Top decile ' + 'as in paper' + ) + gr.HTML( + '
Upload both PDI and PPI to override — the repository priors are ' + 'used unless both files are present.
' ) - uploaded_pdi = gr.File( - label="Custom PDI.csv", - file_types=[".csv"], - type="filepath", + + gr.HTML('
Graph preview
') + graph_preview = gr.HTML(_graph_preview_html("—", "—", "—", "—")) + + with gr.Row(visible=False): + architecture_table = gr.Dataframe(interactive=False, wrap=False, value=pd.DataFrame(), headers=[""], column_count=(1, "dynamic")) + enrichment_table = gr.Dataframe(interactive=False, wrap=False, value=pd.DataFrame(), headers=[""], column_count=(1, "dynamic")) + + with gr.Row(elem_classes=["actbar-row"]): + data_actionbar_note = gr.HTML(f'
{ui.esc("Choose a source, then build the biological architecture.")}
') + with gr.Row(elem_classes=["actbar-buttons"]): + revalidate_button = gr.Button( + "Re-validate inputs", size="sm", variant="secondary", elem_classes=["actbar-secondary-btn"] ) - uploaded_ppi = gr.File( - label="Custom PPI.csv", - file_types=[".csv"], - type="filepath", + prepare_button = gr.Button( + "Build biological architecture", size="sm", variant="primary", elem_classes=["actbar-primary-btn"] + ) + preparation_status = gr.HTML(visible=False) + + # ── Train Model ────────────────────────────────────────── + with gr.Column(visible=False) as train_page: + with gr.Row(elem_classes=["pghd"]): + gr.HTML( + ui.title_block_html( + "Train Model", + "Fit the prepared architecture with a stratified validation split and balanced " + "loss options.", ) - prepare_button = gr.Button( - "Build biological architecture", - variant="primary", - size="lg", ) + with gr.Row(elem_classes=["stage-grid"], equal_height=False): + with gr.Column(scale=7, min_width=0): + gr.HTML('
A · Optimisation
') + with gr.Row(elem_classes=["srow-row", "first"]): + with gr.Column(min_width=140): + gr.HTML(ui.esc("Epochs")) + with gr.Column(): + epochs = gr.Slider(5, 200, value=50, step=5, show_label=False, container=False) + with gr.Row(elem_classes=["srow-row"]): + with gr.Column(min_width=140): + gr.HTML(ui.esc("Batch size")) + with gr.Column(): + batch_size = gr.Dropdown([8, 16, 32, 64, 128], value=16, show_label=False, container=False) + with gr.Row(elem_classes=["srow-row"]): + with gr.Column(min_width=140): + gr.HTML(ui.esc("Learning rate")) + with gr.Column(): + learning_rate = gr.Number(value=0.001, minimum=0.000001, show_label=False, container=False) + with gr.Row(elem_classes=["srow-row"]): + with gr.Column(min_width=140): + gr.HTML(ui.esc("L2 weight decay")) + with gr.Column(): + weight_decay = gr.Number(value=0.01, minimum=0, show_label=False, container=False) + with gr.Row(elem_classes=["srow-row", "last"]): + with gr.Column(min_width=140): + gr.HTML(ui.esc("Dropout")) + with gr.Column(): + dropout = gr.Slider(0, 0.8, value=0.3, step=0.05, show_label=False, container=False) - preparation_status = gr.HTML( - _status("Choose a source, then build the biological architecture.") - ) - with gr.Row(): - architecture_table = gr.Dataframe( - label="Sparse architecture audit", - interactive=False, - wrap=True, - ) - enrichment_table = gr.Dataframe( - label="Retained enriched pathways (first 100)", - interactive=False, - wrap=True, - ) - gr.HTML( - """ -

Expected orientation: samples in rows and - HGNC gene symbols in columns. PDI requires TF, Target; - PPI requires protein1, protein2, combined_score; - pathways require SYMBOL, PathwayID. PPI is filtered - to score > 0.7 and the retained top decile, following the paper.

- """ - ) + gr.HTML('
B · Validation & loss
') + with gr.Row(elem_classes=["srow-row", "first"]): + with gr.Column(min_width=140): + gr.HTML(ui.esc("Branch projection")) + with gr.Column(): + projection_dim = gr.Dropdown([16, 32, 64, 128], value=64, show_label=False, container=False) + with gr.Row(elem_classes=["srow-row"]): + with gr.Column(min_width=140): + gr.HTML(ui.esc("Fusion layer")) + with gr.Column(): + fusion_dim = gr.Dropdown([8, 12, 16, 32], value=12, show_label=False, container=False) + with gr.Row(elem_classes=["srow-row"]): + with gr.Column(min_width=140): + gr.HTML(ui.esc("Validation fraction")) + with gr.Column(): + validation_fraction = gr.Slider(0.1, 0.4, value=0.2, step=0.05, show_label=False, container=False) + with gr.Row(elem_classes=["srow-row"]): + with gr.Column(min_width=140): + gr.HTML(ui.esc("Optimizer")) + with gr.Column(): + optimizer = gr.Radio(["Adam", "SGD"], value="Adam", show_label=False, container=False, elem_classes=["seg-radio"]) + with gr.Row(elem_classes=["srow-row", "last"]): + with gr.Column(min_width=140): + gr.HTML(ui.esc("Class weighting")) + with gr.Column(): + class_weighting = gr.Checkbox( + value=True, label="Balance classes in the loss", + info="Uses N / (classes × samples in class), as in the paper.", + ) - with gr.Tab("2 · Train", id="train"): - gr.HTML( - """ -

Fit and evaluate. The - split is stratified; scaling is fit on training samples only; - the best validation checkpoint is exported as a safe, - self-contained model artifact. A shared GPU is requested only - while this training callback is running.

- """ - ) - with gr.Row(): - with gr.Column(scale=4, elem_classes=["phase-card"]): - epochs = gr.Slider(5, 200, value=50, step=5, label="Epochs") - batch_size = gr.Dropdown( - [8, 16, 32, 64, 128], value=16, label="Batch size" - ) - learning_rate = gr.Number( - value=0.001, label="Learning rate", minimum=0.000001 - ) - weight_decay = gr.Number( - value=0.01, label="L2 weight decay", minimum=0 - ) - dropout = gr.Slider( - 0, 0.8, value=0.3, step=0.05, label="Dropout" - ) - with gr.Column(scale=4, elem_classes=["phase-card"]): - projection_dim = gr.Dropdown( - [16, 32, 64, 128], value=64, label="Branch projection" - ) - fusion_dim = gr.Dropdown( - [8, 12, 16, 32], value=12, label="Fusion layer" - ) - validation_fraction = gr.Slider( - 0.1, - 0.4, - value=0.2, - step=0.05, - label="Validation fraction", - ) - optimizer = gr.Radio( - ["Adam", "SGD"], value="Adam", label="Optimizer" - ) - class_weighting = gr.Checkbox( - value=True, - label="Balance classes in the loss", - info="Uses N / (classes × samples in class), as in the paper.", - ) - with gr.Column(scale=4, elem_classes=["phase-card"]): - gr.Markdown( - """ - **Paper-faithful defaults** - - - First layer: trainable `W ⊙ M` - - PDI weights: binary - - PPI weights: normalized STRING score - - Pathway attention: GenePT query attention - - Fusion: dual branch → dense → softmax - """ - ) - train_button = gr.Button( - "Train BioLM-NET on ZeroGPU", variant="primary", size="lg" - ) - model_download = gr.File( - label="Trained model artifact", interactive=False - ) - gr.Markdown( - "ZeroGPU reserves 30–300 seconds according to dataset " - "size and epochs. Visitors use their own daily quota." - ) - training_status = gr.HTML( - _status("Phase 2 unlocks after the architecture is prepared.") - ) - with gr.Row(): - loss_plot = gr.Plot(label="Training history") - confusion_plot = gr.Plot(label="Confusion matrix") - with gr.Row(): - validation_table = gr.Dataframe( - label="Validation predictions", - interactive=False, - wrap=True, - ) - importance_table = gr.Dataframe( - label="Pathway attention audit (first 100)", - interactive=False, - wrap=True, - ) - - with gr.Tab("3 · Predict", id="predict"): - gr.HTML( - """ -

Apply a trained model. - Continue with the model from this session or upload a previous - artifact. Feature names are validated and reordered exactly as - they were during training.

- """ - ) - with gr.Row(): - with gr.Column(scale=4, elem_classes=["phase-card"]): - prediction_artifact = gr.File( - label="Optional trained model artifact", - file_types=[".zip"], - type="filepath", - ) - prediction_gene = gr.File( - label="Prediction gene expression", - file_types=[".csv"], - type="filepath", + with gr.Column(scale=5, min_width=0): + gr.HTML('
Training result
') + training_status = gr.HTML(_status("Prepare the architecture before training.")) + loss_plot = gr.Plot(label="Loss by epoch", show_label=False) + gr.HTML('
Paper-faithful defaults
') + gr.HTML( + "".join( + [ + ui.kv_plain_row("First layer", "trainable W ⊙ M"), + ui.kv_plain_row("PDI weights", "binary"), + ui.kv_plain_row("PPI weights", "STRING norm."), + ui.kv_plain_row("Attention", "GenePT query"), + ui.kv_plain_row("Fusion", "dual → dense → softmax", last=True), + ] + ) + ) + + with gr.Row(elem_classes=["actbar-row"]): + train_actionbar_note = gr.HTML('
Set hyperparameters, then train.
') + with gr.Row(elem_classes=["actbar-buttons"]): + train_button = gr.Button( + "Train BioLM-NET on ZeroGPU", size="sm", variant="primary", elem_classes=["actbar-primary-btn"] + ) + + # ── Export Artifacts ───────────────────────────────────── + with gr.Column(visible=False) as export_page: + with gr.Row(elem_classes=["pghd"]): + gr.HTML( + ui.title_block_html( + "Export Artifacts", + "One bundle carries the trained weights, the fitted preprocessing and the " + "architecture — enough to reproduce prediction without rebuilding the graph.", + ) ) - prediction_dna = gr.File( - label="Prediction DNA methylation", - file_types=[".csv"], - type="filepath", + with gr.Row(elem_classes=["stage-grid"], equal_height=False): + with gr.Column(scale=7, min_width=0): + gr.HTML('
Bundle contents
') + bundle_manifest = gr.HTML(ui.table_html(["Entry", "Format", "Size", "Reproduces"], [])) + gr.HTML('
Provenance
') + provenance_html = gr.HTML("") + with gr.Column(scale=5, min_width=0): + artifact_panel = gr.HTML(ui.empty_note_html("No trained artifact yet. Train a model to generate the download.")) + model_download = gr.File(label="Trained model artifact", interactive=False, elem_classes=["file-slot"]) + gr.HTML('
Graph carried in the bundle
') + export_stats = gr.HTML( + '
' + + "".join(ui.stat_plate("—", label) for label in ("Gene features", "DNA features", "Pathways", "Classes")) + + "
" + ) + with gr.Row(elem_classes=["actbar-row"]): + gr.HTML('
Bundle written to a temp file on export · not persisted between Space restarts
') + + # ── Predict ────────────────────────────────────────────── + with gr.Column(visible=False) as predict_page: + with gr.Row(elem_classes=["pghd"]): + gr.HTML( + ui.title_block_html( + "Predict", + "Score new paired omics with the session model, or upload a previous bundle. " + "Features are aligned to the artifact before inference runs.", + ) ) - predict_button = gr.Button( - "Make predictions", variant="primary", size="lg" + predict_source_mode = gr.Radio( + ["Session model", "Upload artifact"], value="Session model", show_label=False, + container=False, elem_classes=["seg-radio"], ) - prediction_download = gr.File( - label="Prediction CSV", interactive=False + with gr.Row(elem_classes=["stage-grid"], equal_height=False): + with gr.Column(scale=6, min_width=0): + gr.HTML('
Inputs
') + prediction_input_mode = gr.Radio( + ["Prepared dataset", "Upload files"], value="Prepared dataset", show_label=False, + container=False, elem_classes=["seg-radio"], + ) + with gr.Row(elem_classes=["srow-row", "first"], visible=True) as session_model_row: + with gr.Column(min_width=100): + gr.HTML(ui.esc("Trained artifact")) + with gr.Column(): + session_model_note = gr.HTML('
No session model yet — train one, or switch to Upload artifact.
') + with gr.Row(elem_classes=["srow-row", "first"], visible=False) as artifact_upload_row: + with gr.Column(min_width=100): + gr.HTML(ui.esc("Trained artifact")) + with gr.Column(): + prediction_artifact = gr.File(label="", show_label=False, file_types=[".zip"], type="filepath", elem_classes=["file-slot"]) + with gr.Row(elem_classes=["srow-row"]) as prepared_dataset_row: + with gr.Column(min_width=100): + gr.HTML(ui.esc("Prediction cohort")) + with gr.Column(): + gr.HTML('
Use the dataset prepared in Data & Priors.
') + with gr.Row(elem_classes=["srow-row"], visible=False) as prediction_gene_row: + with gr.Column(min_width=100): + gr.HTML(ui.esc("Gene expression")) + with gr.Column(): + prediction_gene = gr.File(label="", show_label=False, file_types=[".csv"], type="filepath", elem_classes=["file-slot"]) + with gr.Row(elem_classes=["srow-row", "last"], visible=False) as prediction_dna_row: + with gr.Column(min_width=100): + gr.HTML(ui.esc("DNA methylation")) + with gr.Column(): + prediction_dna = gr.File(label="", show_label=False, file_types=[".csv"], type="filepath", elem_classes=["file-slot"]) + + gr.HTML('
Feature alignment
') + alignment_table = gr.HTML(ui.table_html(["Check", "Artifact", "Uploaded", "Result"], [])) + with gr.Row(elem_classes=["strip", "error"], visible=False) as alignment_strip: + alignment_note = gr.HTML("") + list_missing_btn = gr.Button("List missing", size="sm", elem_classes=["btn-ghost"]) + missing_reveal = gr.HTML(visible=False) + + with gr.Column(scale=6, min_width=0): + gr.HTML('
Predictions · last successful run
') + distribution_panel = gr.HTML(ui.empty_note_html("No predictions yet.")) + prediction_table = gr.Dataframe(interactive=False, wrap=False, value=pd.DataFrame(), headers=[""], column_count=(1, "dynamic")) + prediction_download = gr.File(label="Prediction CSV", interactive=False, elem_classes=["file-slot", "btn-block"]) + + with gr.Row(elem_classes=["actbar-row"]): + predict_actionbar_note = gr.HTML('
Use the current trained model or upload an artifact.
') + with gr.Row(elem_classes=["actbar-buttons"]): + predict_button = gr.Button( + "Run inference", size="sm", variant="primary", interactive=False, + elem_classes=["actbar-primary-btn"], + ) + prediction_status = gr.HTML(visible=False) + + # ── Results ────────────────────────────────────────────── + with gr.Column(visible=False) as results_page: + with gr.Row(elem_classes=["pghd"]): + gr.HTML( + ui.title_block_html( + "Results", + "Everything the run produced, read in one place: architecture audit, " + "validation outputs, pathway attention and the scored cohort.", + ) ) - with gr.Column(scale=8): - prediction_status = gr.HTML( - _status("Use the current trained model or upload an artifact.") + results_empty_note = gr.HTML(ui.empty_note_html("Build the biological architecture, train a model, or run prediction to populate this page.")) + with gr.Row(elem_classes=["results-stat-row"], visible=False) as results_stats_row: + results_stats = gr.HTML("") + with gr.Row(elem_classes=["results-grid"], equal_height=False, visible=False) as results_content: + with gr.Column(scale=6, min_width=0): + gr.HTML('
Training history
') + results_loss_plot = gr.Plot(show_label=False) + gr.HTML('
Sparse architecture audit
') + results_architecture_table = gr.Dataframe(interactive=False, wrap=False, value=pd.DataFrame(), headers=[""], column_count=(1, "dynamic")) + with gr.Column(scale=6, min_width=0): + results_confusion = gr.HTML("") + gr.HTML('
Pathway attention · top retained
') + results_pathway_table = gr.Dataframe(interactive=False, wrap=False, value=pd.DataFrame(), headers=[""], column_count=(1, "dynamic")) + with gr.Column(visible=False) as results_tables_bottom: + gr.HTML('
Validation predictions
') + results_validation_table = gr.Dataframe(interactive=False, wrap=False, value=pd.DataFrame(), headers=[""], column_count=(1, "dynamic")) + gr.HTML('
Predictions and class probabilities
') + results_prediction_table = gr.Dataframe(interactive=False, wrap=False, value=pd.DataFrame(), headers=[""], column_count=(1, "dynamic")) + with gr.Row(elem_classes=["actbar-row"]): + gr.HTML( + '
Validate cohorts, preprocessing and performance before ' + "drawing biological or clinical conclusions.
" ) - prediction_plot = gr.Plot(label="Class distribution") - prediction_table = gr.Dataframe( - label="Predictions and class probabilities", - interactive=False, - wrap=True, - ) - gr.Markdown( - """ -

Research use only. This interface reproduces the - architecture described by Rifat et al. and uses the upstream BioLM-NET - repository and GenePT embeddings. Validate cohorts, preprocessing, and - model performance before drawing biological or clinical conclusions.

- """ + with gr.Group(elem_id="intro", visible=True) as intro_group: + intro_cards = [] + intro_next_buttons = [] + intro_back_buttons = [] + intro_skip_buttons = [] + intro_start_buttons = [] + with gr.Column(elem_classes=["intro-card"]): + for index, (_, _, _, _, note) in enumerate(ui.INTRO_CARDS): + with gr.Column(visible=index == 0, elem_classes=["intro-card-page"]) as intro_card: + gr.HTML(ui.intro_card_html(index)) + with gr.Row(elem_classes=["intro-card-foot"]): + if index == 0: + intro_dont_show = gr.Checkbox(value=False, label="Don't show again", container=False) + else: + gr.HTML(f'{ui.esc(note)}') + with gr.Row(elem_classes=["intro-card-actions"]): + gr.HTML(ui.intro_dots_html(index)) + if index == 0: + skip_btn = gr.Button("Skip", size="sm", variant="secondary", elem_classes=["btn-ghost"]) + next_btn = gr.Button("Next", size="sm", variant="primary") + intro_skip_buttons.append(skip_btn) + intro_next_buttons.append(next_btn) + elif index < len(ui.INTRO_CARDS) - 1: + back_btn = gr.Button("Back", size="sm", variant="secondary", elem_classes=["btn-ghost"]) + next_btn = gr.Button("Next", size="sm", variant="primary") + intro_back_buttons.append(back_btn) + intro_next_buttons.append(next_btn) + else: + back_btn = gr.Button("Back", size="sm", variant="secondary", elem_classes=["btn-ghost"]) + start_btn = gr.Button("Start with the BRCA example", size="sm", variant="primary") + intro_back_buttons.append(back_btn) + intro_start_buttons.append(start_btn) + intro_cards.append(intro_card) + + # ═════════════════════════════════════════════════════════════════ + # Wiring + # ═════════════════════════════════════════════════════════════════ + + chrome_outputs = [*rail_row_htmls, run_state_plate_html, topbar_html_component, rail_row_buttons[1], rail_row_buttons[2]] + page_columns = [data_page, train_page, export_page, predict_page, results_page] + chrome_inputs_tail = [workspace_state, model_state, run_meta_state, align_state, predict_meta_state, session_id_state] + + def _nav(page_key): + def _handler(workspace, bundle, run_meta, align, predicted, session_id): + return _enter_page(page_key, workspace, bundle, run_meta, align, predicted, session_id) + + return _handler + + for button, key in zip(rail_row_buttons, STAGE_KEYS): + button.click( + _nav(key), inputs=chrome_inputs_tail, outputs=[active_page_state, *page_columns, *chrome_outputs] + ) + + def _intro_updates(index: int, visible: bool = True): + index = max(0, min(len(ui.INTRO_CARDS) - 1, int(index))) + cards = [gr.update(visible=i == index) for i in range(len(ui.INTRO_CARDS))] + return index, gr.update(visible=visible), *cards + + def _intro_load(dismissed): + return secrets.token_hex(3), *_intro_updates(0, visible=not bool(dismissed)) + + def _intro_close(dont_show): + return 0, gr.update(visible=False), bool(dont_show) + + def _intro_start_brca(dont_show): + return ( + 0, + gr.update(visible=False), + bool(dont_show), + "BioLM-NET examples", + "BRCA", + *_source_visibility("BioLM-NET examples"), + ) + + intro_outputs = [intro_step_state, intro_group, *intro_cards] + + demo.load( + _intro_load, inputs=[intro_dismissed_state], outputs=[session_id_state, *intro_outputs] + ).then( + lambda sid, *rest: _refresh_chrome("data", *rest, sid), inputs=[session_id_state, *chrome_inputs_tail[:-1]], outputs=chrome_outputs ) + intro_open_button.click( + lambda: _intro_updates(0, True), outputs=intro_outputs, show_progress="hidden" + ) + for index, button in enumerate(intro_next_buttons): + button.click( + lambda i=index: _intro_updates(i + 1, True), outputs=intro_outputs, show_progress="hidden" + ) + for index, button in enumerate(intro_back_buttons, start=1): + button.click( + lambda i=index: _intro_updates(i - 1, True), outputs=intro_outputs, show_progress="hidden" + ) + for button in intro_skip_buttons: + button.click( + _intro_close, inputs=[intro_dont_show], + outputs=[intro_step_state, intro_group, intro_dismissed_state], + show_progress="hidden", + ) + for button in intro_start_buttons: + button.click( + _intro_start_brca, inputs=[intro_dont_show], + outputs=[ + intro_step_state, intro_group, intro_dismissed_state, + source_mode, example_dataset, example_group, github_group, upload_group, + ], + show_progress="hidden", + ) + source_mode.change( - _source_visibility, - inputs=[source_mode], - outputs=[example_group, github_group, upload_group], + _source_visibility, inputs=[source_mode], outputs=[example_group, github_group, upload_group] ) + + # The one deliberate loading indicator (a thin animated bar on the busy + # button itself — see `.is-busy` in styles.py) is toggled only through + # these two helpers, explicitly, on exactly the button that was clicked. + # It is never left to Gradio's own per-component pending state, which is + # what caused several of these to appear at once for one click. + def _busy(label, base_classes): + return gr.update(value=label, interactive=False, elem_classes=[*base_classes, "is-busy"]) + + def _idle(label, base_classes, interactive=True): + return gr.update(value=label, interactive=interactive, elem_classes=list(base_classes)) + + prepare_inputs = [ + source_mode, example_dataset, github_folder, uploaded_gene, uploaded_dna, uploaded_labels, + uploaded_gene_pathway, uploaded_dna_pathway, uploaded_pdi, uploaded_ppi, + pathway_files_are_significant, embedding_option, + ] + + def _after_prepare(workspace, run_meta, session_id): + chrome = _refresh_chrome("data", workspace, None, run_meta, {}, {}, session_id) + return ( + _idle("Build biological architecture", ["actbar-primary-btn"]), + _idle("Re-validate inputs", ["actbar-secondary-btn"]), + *chrome, + ) + + # Only the button actually clicked gets the sweep; its sibling just goes + # inert (disabled, unchanged label) — two buttons both sweeping for one + # action would itself be the "more than one indicator" problem. + def _busy_pair(which: str): + if which == "primary": + return ( + _busy("Building…", ["actbar-primary-btn"]), + gr.update(interactive=False), + ) + return ( + gr.update(interactive=False), + _busy("Building…", ["actbar-secondary-btn"]), + ) + prepare_button.click( - prepare_workspace, - inputs=[ - source_mode, - example_dataset, - github_folder, - uploaded_gene, - uploaded_dna, - uploaded_labels, - uploaded_gene_pathway, - uploaded_dna_pathway, - uploaded_pdi, - uploaded_ppi, - pathway_files_are_significant, - embedding_option, + lambda: _busy_pair("primary"), outputs=[prepare_button, revalidate_button], + show_progress="hidden", + ).then( + prepare_workspace, inputs=prepare_inputs, + outputs=[ + workspace_state, preparation_status, architecture_table, enrichment_table, + resolved_files_table, run_meta_state, data_actionbar_note, graph_preview, ], + show_progress="hidden", + ).then( + _after_prepare, inputs=[workspace_state, run_meta_state, session_id_state], + outputs=[prepare_button, revalidate_button, *chrome_outputs], + show_progress="hidden", + ) + + revalidate_button.click( + lambda: _busy_pair("secondary"), outputs=[prepare_button, revalidate_button], + show_progress="hidden", + ).then( + prepare_workspace, inputs=prepare_inputs, outputs=[ - workspace_state, - preparation_status, - architecture_table, - enrichment_table, + workspace_state, preparation_status, architecture_table, enrichment_table, + resolved_files_table, run_meta_state, data_actionbar_note, graph_preview, ], + show_progress="hidden", + ).then( + _after_prepare, inputs=[workspace_state, run_meta_state, session_id_state], + outputs=[prepare_button, revalidate_button, *chrome_outputs], + show_progress="hidden", ) + + show_symbols_btn.click(_reveal_symbols, inputs=[run_meta_state], outputs=[unmatched_reveal]) + pdi_override_btn.click(lambda: gr.update(visible=True), outputs=[uploaded_pdi]) + ppi_override_btn.click(lambda: gr.update(visible=True), outputs=[uploaded_ppi]) + + def _predict_source_toggle(mode): + return gr.update(visible=mode == "Session model"), gr.update(visible=mode == "Upload artifact") + + predict_source_mode.change( + _predict_source_toggle, inputs=[predict_source_mode], outputs=[session_model_row, artifact_upload_row] + ) + + def _predict_input_toggle(mode): + use_uploads = mode == "Upload files" + return ( + gr.update(visible=not use_uploads), + gr.update(visible=use_uploads), + gr.update(visible=use_uploads), + ) + + prediction_input_mode.change( + _predict_input_toggle, + inputs=[prediction_input_mode], + outputs=[prepared_dataset_row, prediction_gene_row, prediction_dna_row], + ) + + align_inputs = [ + model_state, workspace_state, prediction_artifact, predict_source_mode, + prediction_input_mode, prediction_gene, prediction_dna, + ] + + def _align_wrapper(bundle, workspace, artifact_path, model_mode, input_mode, gene_path, dna_path): + return refresh_alignment( + bundle, workspace, artifact_path, model_mode == "Upload artifact", + input_mode == "Prepared dataset", gene_path, dna_path, + ) + + # `train_workspace`'s positional signature intentionally mirrors + # `estimate_training_duration` exactly for `@spaces.GPU` — session + # metadata (dataset name, elapsed time, …) is threaded through + # separately via `run_meta_update_state` and merged below, rather than + # passed into/out of `train_workspace` itself. + train_inputs = [ + workspace_state, epochs, batch_size, learning_rate, weight_decay, dropout, projection_dim, + fusion_dim, validation_fraction, optimizer, class_weighting, + ] + + def _after_train(bundle, run_meta, session_id): + chrome = _refresh_chrome("train", bundle is not None and True, bundle, run_meta, {}, {}, session_id) + return _idle("Train BioLM-NET on ZeroGPU", ["actbar-primary-btn"]), *chrome + + validation_predictions_state = gr.State(pd.DataFrame()) + importance_state = gr.State(pd.DataFrame()) + architecture_audit_state = gr.State(pd.DataFrame()) + confusion_html_state = gr.State("") + run_meta_update_state = gr.State({}) + train_button.click( - train_workspace, - inputs=[ - workspace_state, - epochs, - batch_size, - learning_rate, - weight_decay, - dropout, - projection_dim, - fusion_dim, - validation_fraction, - optimizer, - class_weighting, - ], + lambda: _busy("Training on ZeroGPU…", ["actbar-primary-btn"]), outputs=[train_button], + show_progress="hidden", + ).then( + train_workspace, inputs=train_inputs, outputs=[ - model_state, - training_status, - loss_plot, - confusion_plot, - validation_table, - model_download, - importance_table, + model_state, training_status, loss_plot, validation_predictions_state, artifact_path_state, + importance_state, architecture_audit_state, run_meta_update_state, confusion_html_state, ], + show_progress="hidden", + ).then( + lambda old, new: {**(old or {}), **(new or {})}, + inputs=[run_meta_state, run_meta_update_state], outputs=[run_meta_state], + show_progress="hidden", + ).then( + _after_train, inputs=[model_state, run_meta_state, session_id_state], + outputs=[train_button, *chrome_outputs], + show_progress="hidden", + ).then( + _align_wrapper, inputs=align_inputs, + outputs=[alignment_table, alignment_strip, predict_button, align_state], + show_progress="hidden", + ).then( + lambda bundle, artifact_path, run_meta: _export_panel(bundle, artifact_path, run_meta), + inputs=[model_state, artifact_path_state, run_meta_state], + outputs=[bundle_manifest, provenance_html, artifact_panel, export_stats, model_download], + show_progress="hidden", + ).then( + refresh_results, + inputs=[model_state, run_meta_state], + outputs=[results_stats, results_stats_row, results_empty_note, results_content], + show_progress="hidden", + ).then( + lambda df: gr.update(value=df), inputs=[validation_predictions_state], outputs=[results_validation_table], + show_progress="hidden", + ).then( + lambda df: gr.update(value=df), inputs=[architecture_audit_state], outputs=[results_architecture_table], + show_progress="hidden", + ).then( + lambda bundle: gr.update(value=_pathway_attention_table(bundle)) if bundle else gr.update(value=pd.DataFrame()), + inputs=[model_state], outputs=[results_pathway_table], + show_progress="hidden", + ).then( + lambda html_value: gr.update(value=html_value), inputs=[confusion_html_state], outputs=[results_confusion], + show_progress="hidden", + ).then( + lambda bundle: gr.update(visible=bundle is not None), inputs=[model_state], outputs=[results_tables_bottom], + show_progress="hidden", ) + # training_status is NOT re-set here: train_workspace's own return + # already carries the final "Training complete · accuracy · elapsed" + # message (it has the elapsed time on hand already), so writing it + # again from run_meta_state afterwards was a second, redundant paint + # of the same component for one click. + + # Deliberately NOT including `model_state` here: it only ever changes as + # part of the train/predict chains below, and both of those already call + # `_align_wrapper` explicitly as their own finalize step. Adding it here + # too would fire alignment twice per click (train_workspace/_predict_ + # wrapper set model_state -> this listener fires -> the chain's own + # explicit call also fires) — the exact "loading bar twice" symptom. + # `workspace_state` stays: nothing else re-checks alignment when the + # user rebuilds the architecture while already on the Predict page. + for control in (predict_source_mode, prediction_input_mode, prediction_artifact, prediction_gene, prediction_dna, workspace_state): + control.change( + _align_wrapper, inputs=align_inputs, + outputs=[alignment_table, alignment_strip, predict_button, align_state], + ) + + list_missing_btn.click(_list_missing, inputs=[align_state], outputs=[missing_reveal]) + + predict_inputs = [ + model_state, workspace_state, prediction_artifact, predict_source_mode, + prediction_input_mode, prediction_gene, prediction_dna, predict_meta_state, + ] + + def _predict_wrapper(bundle, workspace, artifact_path, model_mode, input_mode, gene_path, dna_path, predict_meta): + return run_prediction( + bundle, workspace, artifact_path, model_mode == "Upload artifact", + input_mode == "Prepared dataset", gene_path, dna_path, predict_meta, + ) + predict_button.click( - run_prediction, - inputs=[ - model_state, - prediction_artifact, - prediction_gene, - prediction_dna, - ], - outputs=[ - model_state, - prediction_status, - prediction_table, - prediction_plot, - prediction_download, - ], + lambda: _busy("Running inference…", ["actbar-primary-btn"]), outputs=[predict_button], + show_progress="hidden", + ).then( + _predict_wrapper, inputs=predict_inputs, + outputs=[model_state, prediction_status, prediction_table, distribution_panel, prediction_download, predict_meta_state], + show_progress="hidden", + ).then( + # This one call both resets the button's label back to "Run + # inference" (out of its transient "Running inference…" busy state) + # and sets the correct interactive flag — no separate blind + # re-enable step first, which used to write predict_button twice. + _align_wrapper, inputs=align_inputs, outputs=[alignment_table, alignment_strip, predict_button, align_state], + show_progress="hidden", + ).then( + lambda page, workspace, bundle, run_meta, align, predicted, session_id: _refresh_chrome(page, workspace, bundle, run_meta, align, predicted, session_id), + inputs=[active_page_state, workspace_state, model_state, run_meta_state, align_state, predict_meta_state, session_id_state], + outputs=chrome_outputs, + show_progress="hidden", + ).then( + lambda df: gr.update(value=df), inputs=[prediction_table], outputs=[results_prediction_table], + show_progress="hidden", + ).then( + refresh_results, inputs=[model_state, run_meta_state], + outputs=[results_stats, results_stats_row, results_empty_note, results_content], + show_progress="hidden", )