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(
- """
-
- """
- )
+ 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(
- """
-
- """
+ 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",
)