Spaces:
Sleeping
Sleeping
Download app/viewer.py from govindbalki/oncodsl: direct link, hf CLI and curl.
- Browser
- Download file 62.2 kB
-
https://huggingface.co/spaces/govindbalki/oncodsl/resolve/main/app/viewer.py
- Command line
-
hf download hf://spaces/govindbalki/oncodsl/app/viewer.py
-
curl -L -o viewer.py https://huggingface.co/spaces/govindbalki/oncodsl/resolve/main/app/viewer.py
62.2 kB
"""OncoDSL sanity viewer.
Run with:
streamlit run app/viewer.py
Two top-level tabs (Glossary expander sits above both, available everywhere):
- **Dataset** β the five sanity panels: cohort overview, MSI counts, TMB-vs-MSI
histogram (the load-bearing sanity check), stage/age/missing fields, and a
small expression-matrix peek.
- **Hypothesis 1** β verification of the H1 program on the named matrix: a
short intro, the DSL reference (nouns + verbs), the composition diagram,
and the H1 outputs (conclusion β score distributions β Effect β Fit).
Presentation (palette, fonts, chart theme) lives in app/theme.py and
.streamlit/config.toml. Importing `theme` registers and enables the Altair theme
used by every chart in this file.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
import altair as alt
import numpy as np
import pandas as pd
import requests
import streamlit as st
# Allow `streamlit run app/viewer.py` from the repo root without installing the
# package: prepend the repo root to sys.path so `import data_pipeline` works.
REPO_ROOT = Path(__file__).resolve().parent.parent
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from data_pipeline import schema # noqa: E402
from app import theme # noqa: E402 β registers the "oncodsl" Altair theme on import.
from airgap import anonymise, reveal # noqa: E402
from validate.h1 import IMMUNE_GENES, MMR_GENES, run_h1, usable_msi_cohort # noqa: E402
from dsl import Apply, Load # noqa: E402
CHART_HEIGHT = 300
st.set_page_config(page_title="OncoDSL β TCGA CRC sanity viewer", layout="wide")
# --- data loading -----------------------------------------------------------
@st.cache_data(show_spinner=False)
def load_clinical() -> pd.DataFrame | None:
p = schema.PROCESSED_DIR / "clinical.parquet"
if not p.exists():
return None
return pd.read_parquet(p)
@st.cache_data(show_spinner=False)
def load_expression_shape_and_sample() -> tuple[tuple[int, int], pd.DataFrame] | None:
p = schema.PROCESSED_DIR / "expression.parquet"
if not p.exists():
return None
df = pd.read_parquet(p)
sample = df.iloc[:20, :10]
return df.shape, sample
@st.cache_resource(show_spinner=False)
def _h1_result():
"""Run the full H1 program against the processed cohort."""
return run_h1()
@st.cache_data(show_spinner=False)
def _airgap_demo() -> tuple[pd.DataFrame, list[str], list[str]] | None:
"""Show the anonymised matrix head + reveal() round-trip."""
p = schema.PROCESSED_DIR / "expression.parquet"
if not p.exists():
return None
cohort = Load("processed")
anonymised = anonymise(cohort.expression)
head = anonymised.iloc[:5, :8]
revealed = reveal(list(head.columns))
return head, list(head.columns), revealed
def usable_mask(clinical: pd.DataFrame) -> pd.Series:
return (
clinical["msi_status"].isin(["MSI-H", "MSS"])
& clinical["has_expression"]
& (clinical["stage"] != "NA")
& clinical["age"].notna()
)
# --- glossary ---------------------------------------------------------------
def _glossary_md() -> str:
hi = schema.MSI_SENSOR_HIGH
lo = schema.MSI_SENSOR_LOW
return (
"- **TCGA** β The Cancer Genome Atlas: a large public dataset of tumour genetics.\n"
"- **Adenocarcinoma** β cancer of gland-forming cells (the usual colorectal type).\n"
"- **COAD** β Colon Adenocarcinoma (cancer of the colon).\n"
"- **READ** β Rectum Adenocarcinoma (cancer of the rectum).\n"
"- **MACR** β Mucinous Adenocarcinoma of the Colon and Rectum: a mucinous tissue "
"SUBTYPE that can be colon or rectum (so this field is tumour TYPE, not pure "
"anatomical site).\n"
"- **MSI** β Microsatellite Instability: instability in short repeating DNA "
"stretches, caused by a failed DNA \"spell-checker\" (mismatch repair).\n"
"- **MSI-H (MSI-High)** β many unstable microsatellites; broken repair; high "
"mutation load.\n"
"- **MSS (Microsatellite Stable)** β repair working; a clean, stable genome.\n"
"- **MSI-Indeterminate** β between the thresholds; not clearly classifiable.\n"
"- **NA** β no MSI value available.\n"
f"- **MSIsensor score** β a computed measure of how many microsatellites differ "
f"between the tumour and the patient's own normal DNA; higher = more unstable. "
f"This viewer's labels use: **β₯ {hi} β MSI-H**, **< {lo} β MSS**, "
f"**between {lo} and {hi} β Indeterminate** (thresholds read from the data layer).\n"
"- **TMB** β Tumour Mutational Burden: roughly, the number of mutations in the "
"tumour. MSI-H tumours have high TMB (the key sanity check).\n"
"- **Stage** β how far the cancer has spread (IβIV).\n"
"- **RSEM** β the method used to turn raw RNA-sequencing reads into the "
"per-gene expression numbers shown here. The values are relative (good "
"for comparing across patients), not absolute counts.\n"
"\n"
"**Biology you'll meet in later steps**\n"
"- **MMR (mismatch repair)** β the DNA spell-checker; genes MLH1, MSH2, MSH6, "
"PMS2. dMMR = broken, pMMR = working.\n"
"- **CD8A, GZMA, PRF1** β markers that \"killer\" T-cells are attacking the "
"tumour.\n"
"- **PD-L1 / CD274** β a \"shield\" protein tumours use to switch off the "
"immune attack.\n"
"- **Checkpoint inhibitor (anti-PD-1)** β a drug that removes the shield so "
"the immune system can attack.\n"
)
# --- main -------------------------------------------------------------------
_EXPANDER_CSS = """
<style>
/* Streamlit 1.58 expander testids verified against compiled bundle:
stExpander (container) / stExpanderDetails (body) / stExpanderIcon (chevron).
We do NOT style stExpanderIcon β keep the native chevron visible. */
[data-testid="stExpander"] {
margin-top: 0.4rem;
}
[data-testid="stExpander"] details {
border: 1px solid #ECEAE4;
border-radius: 8px;
background-color: #FCFBF8;
}
[data-testid="stExpander"] summary {
color: #5A6670;
font-size: 0.9rem;
font-weight: 500;
}
</style>
"""
def main() -> None:
st.markdown(_EXPANDER_CSS, unsafe_allow_html=True)
st.title("OncoDSL β TCGA CRC sanity viewer")
st.caption(
f"Source: cBioPortal study `{schema.STUDY}` "
f"(TCGA Colorectal Adenocarcinoma, PanCancer Atlas)."
)
# Glossary sits ABOVE the tabs so it's available everywhere.
with st.expander("Glossary β plain-language key", expanded=False):
st.markdown(_glossary_md())
clinical = load_clinical()
expr_loaded = load_expression_shape_and_sample()
if clinical is None or expr_loaded is None:
st.error(
"Processed data not found. Run from the repo root:\n\n"
"```\n"
"python -m data_pipeline.download\n"
"python -m data_pipeline.build\n"
"```"
)
return
tab_dataset, tab_h1, tab_h2 = st.tabs(
["Dataset", "Hypothesis 1", "Hypothesis 2"]
)
with tab_dataset:
_section_dataset(clinical, expr_loaded)
with tab_h1:
_section_h1_intro()
st.divider()
_section_dsl()
st.divider()
_section_h1_walkthrough()
st.divider()
_section_h1()
st.divider()
_section_airgap_demo()
with tab_h2:
_section_h2()
def _section_dataset(clinical: pd.DataFrame, expr_loaded) -> None:
(n_genes, n_expr_samples), expr_sample = expr_loaded
# ----- Panel 1: dataset overview -----
st.header("1. Dataset overview")
st.caption(
"How big the cohort is, and how many samples are usable for downstream analysis."
)
n_total = len(clinical)
n_coad = int((clinical["site"] == "COAD").sum())
n_read = int((clinical["site"] == "READ").sum())
n_macr = int((clinical["site"] == "MACR").sum())
usable = usable_mask(clinical)
n_usable = int(usable.sum())
c1, c2, c3, c4, c5 = st.columns(5)
c1.metric("Total samples", n_total)
c2.metric(
"COAD", n_coad,
help="Colon Adenocarcinoma β cancer of the colon.",
)
c3.metric(
"READ", n_read,
help="Rectum Adenocarcinoma β cancer of the rectum.",
)
c4.metric(
"MACR", n_macr,
help="Mucinous Adenocarcinoma of the Colon and Rectum β a mucinous tissue "
"subtype that can be colon or rectum.",
)
c5.metric(
"Usable cohort", n_usable,
help="Samples with an MSI label in {MSI-H, MSS} AND expression AND non-missing "
"stage AND non-missing age β the rows downstream analysis can use.",
)
st.caption(
f"Genes in expression matrix: **{n_genes}** Β· expression samples: "
f"**{n_expr_samples}** Β· MSI label derived from MSIsensor "
f"(β₯{schema.MSI_SENSOR_HIGH} β MSI-H, <{schema.MSI_SENSOR_LOW} β MSS, "
"between β Indeterminate β cBioPortal does not ship a clean MSI-H/MSS column)."
)
with st.expander("How to read this", expanded=False):
st.markdown(
"- This is the whole colorectal cohort (colon + rectum + a mucinous "
"subtype), pooled on purpose β more data helps when samples are scarce.\n"
"- READ (155) is the rectal slice; we hold rectum back for validation later.\n"
"- MACR are mucinous tumours that can be colon or rectum, so they're "
"counted separately."
)
st.divider()
# ----- Panel 2: MSI counts -----
st.header("2. MSI status counts")
st.caption(
"How many have a broken DNA spell-checker (MSI-H) vs a working one (MSS)."
)
overall = (
clinical["msi_status"]
.value_counts()
.reindex(theme.MSI_ORDER, fill_value=0)
.rename_axis("msi_status")
.reset_index(name="count")
)
by_site = (
clinical.groupby(["site", "msi_status"]).size().reset_index(name="count")
)
left, right = st.columns(2)
with left:
st.subheader("Overall")
st.altair_chart(
alt.Chart(overall)
.mark_bar()
.encode(
x=alt.X("msi_status:N", sort=theme.MSI_ORDER, title="MSI status"),
y=alt.Y("count:Q", title="samples"),
color=alt.Color(
"msi_status:N",
sort=theme.MSI_ORDER,
scale=theme.msi_color_scale(),
legend=None,
),
tooltip=["msi_status", "count"],
)
.properties(height=CHART_HEIGHT),
width="stretch",
)
with right:
st.subheader("By tumour type")
st.caption(
"COAD = colon, READ = rectum, MACR = mucinous subtype (colon or rectum)."
)
st.altair_chart(
alt.Chart(by_site)
.mark_bar()
.encode(
x=alt.X("site:N", title="tumour type"),
y=alt.Y("count:Q", stack="zero", title="samples"),
color=alt.Color(
"msi_status:N",
sort=theme.MSI_ORDER,
scale=theme.msi_color_scale(),
legend=alt.Legend(title="MSI status", orient="right"),
),
order=alt.Order(
"msi_status_order:Q", sort="ascending",
),
tooltip=["site", "msi_status", "count"],
)
.transform_calculate(
msi_status_order=(
"indexof(['" + "','".join(theme.MSI_ORDER) + "'], datum.msi_status)"
)
)
.properties(height=CHART_HEIGHT),
width="stretch",
)
with st.expander("How to read this", expanded=False):
thresholds = (
f"β₯ {schema.MSI_SENSOR_HIGH} β MSI-H, < {schema.MSI_SENSOR_LOW} β MSS, "
f"between β Indeterminate"
)
st.markdown(
"- All these are colorectal cancers. MSI-H vs MSS is a subtype, not "
"cancer-vs-no-cancer.\n"
"- MSS (the majority, ~85%) = the MMR \"spell-checker\" is working: "
"stable repeats, relatively few mutations β still cancer, driven by "
"other routes.\n"
"- MSI-H = spell-checker broken: unstable repeats, many mutations.\n"
"- These labels come from the MSIsensor score (instability measured "
"directly in the DNA repeats), NOT from the MMR genes' expression β "
"that independence is what makes a later rediscovery meaningful.\n"
f"- The labels are our interpretation of that score: **{thresholds}** "
"(thresholds read from the data layer)."
)
st.divider()
# ----- Panel 2Β½: the biology in one picture -----
_section_biology_picture()
st.divider()
# ----- Panel 3: TMB vs MSI β the load-bearing sanity check -----
st.header("3. TMB vs MSI (sanity check)")
st.caption(
"Broken-repair (MSI-H) tumours should carry far more mutations β they should "
"sit to the right."
)
tmb_df = clinical.loc[
clinical["msi_status"].isin(["MSI-H", "MSS"]) & clinical["tmb"].notna(),
["msi_status", "tmb"],
].copy()
# TMB is heavy-tailed; log1p the x axis so the MSI-H tail is readable.
tmb_df["tmb_log1p"] = np.log1p(tmb_df["tmb"])
hist = (
alt.Chart(tmb_df)
.mark_bar(opacity=0.85)
.encode(
x=alt.X(
"tmb_log1p:Q",
bin=alt.Bin(maxbins=40),
title="log1p(TMB nonsynonymous)",
),
y=alt.Y("count():Q", stack=None, title="samples"),
color=alt.Color(
"msi_status:N",
sort=theme.MSI_ORDER,
scale=theme.msi_color_scale(include_na=False),
legend=alt.Legend(title="MSI status", orient="right"),
),
tooltip=["msi_status", "count()"],
)
.properties(height=CHART_HEIGHT)
)
st.altair_chart(hist, width="stretch")
medians = tmb_df.groupby("msi_status")["tmb"].median().to_dict()
st.caption(
f"Median TMB (nonsynonymous) β MSI-H: **{medians.get('MSI-H', float('nan')):.2f}**, "
f"MSS: **{medians.get('MSS', float('nan')):.2f}**. "
"If MSI-H median isn't markedly higher than MSS, something is wrong upstream."
)
with st.expander("How to read this", expanded=False):
st.markdown(
"- The x-axis is a log mutation count; to translate back, "
"mutations β e^x β 1.\n"
"- MSI-H tumours sit to the right (hypermutated); MSS to the left "
"(quiet) β that clean separation is the sanity check that data, "
"labels and counts are wired up correctly.\n"
"- Minor exception: a few MSS tumours with a broken POLE polymerase "
"are also hypermutated."
)
st.divider()
# ----- Panel 4: stage / age / missing -----
st.header("4. Stage, age, and missing-field counts")
st.caption(
"Stage = how far the cancer has spread (IβIV); age at diagnosis; and where "
"fields are missing across the cohort. Bars are split by MSI status so we "
"can eyeball confounding."
)
# Restrict to MSI-H vs MSS only for the confounder views.
h_vs_s = clinical[clinical["msi_status"].isin(["MSI-H", "MSS"])].copy()
left, mid, right = st.columns([1, 1, 1])
with left:
st.subheader("Stage β proportion MSI-H")
stage_order = ["I", "II", "III", "IV", "NA"]
grouped = h_vs_s.groupby("stage")["msi_status"]
stage_df = pd.DataFrame({
"stage": list(grouped.groups.keys()),
"n": grouped.size().values,
"n_msi_h": grouped.apply(lambda s: int((s == "MSI-H").sum())).values,
})
stage_df["pct_msi_h"] = stage_df["n_msi_h"] / stage_df["n"]
stage_df = (
stage_df.set_index("stage")
.reindex(stage_order)
.reset_index()
.fillna({"n": 0, "n_msi_h": 0, "pct_msi_h": 0})
)
st.altair_chart(
alt.Chart(stage_df)
.mark_bar(color=theme.MSI_COLORS["MSI-H"])
.encode(
x=alt.X("stage:N", sort=stage_order, title="stage"),
y=alt.Y(
"pct_msi_h:Q",
title="% MSI-H",
axis=alt.Axis(format="%"),
scale=alt.Scale(domain=[0, 1]),
),
tooltip=[
alt.Tooltip("stage:N"),
alt.Tooltip("n:Q", title="n (MSI-H + MSS)"),
alt.Tooltip("n_msi_h:Q", title="n MSI-H"),
alt.Tooltip("pct_msi_h:Q", title="% MSI-H", format=".1%"),
],
)
.properties(height=CHART_HEIGHT),
width="stretch",
)
with mid:
st.subheader("Age β MSI-H vs MSS (density)")
age_df = h_vs_s.loc[h_vs_s["age"].notna(), ["age", "msi_status"]]
age_min = float(age_df["age"].min())
age_max = float(age_df["age"].max())
st.altair_chart(
alt.Chart(age_df)
.transform_density(
"age",
as_=["age", "density"],
groupby=["msi_status"],
extent=[age_min, age_max],
steps=80,
)
.mark_area(opacity=0.8)
.encode(
x=alt.X("age:Q", title="age at diagnosis"),
y=alt.Y("density:Q", title="density", stack=None),
color=alt.Color(
"msi_status:N",
sort=theme.MSI_ORDER,
scale=theme.msi_color_scale(include_na=False),
legend=alt.Legend(title="MSI status", orient="right"),
),
tooltip=[
alt.Tooltip("msi_status:N"),
alt.Tooltip("age:Q", format=".1f"),
alt.Tooltip("density:Q", format=".3f"),
],
)
.properties(height=CHART_HEIGHT),
width="stretch",
)
with right:
st.subheader("Missing per field")
st.caption(
"For each field, how many samples lack a value β this is what trims the "
"full cohort down to the usable set. Small numbers here are normal."
)
missing = pd.DataFrame(
{
"field": [
"msi_status (NA)",
"msi_status (Indeterm.)",
"tmb",
"age",
"stage (NA)",
"sex (blank)",
"os_event",
"os_months",
"no expression",
],
"missing": [
int((clinical["msi_status"] == "NA").sum()),
int((clinical["msi_status"] == "MSI-Indeterminate").sum()),
int(clinical["tmb"].isna().sum()),
int(clinical["age"].isna().sum()),
int((clinical["stage"] == "NA").sum()),
int(clinical["sex"].isna().sum()
+ (clinical["sex"].astype(str) == "").sum()),
int(clinical["os_event"].isna().sum()),
int(clinical["os_months"].isna().sum()),
int((~clinical["has_expression"]).sum()),
],
}
)
st.dataframe(missing, hide_index=True, width="stretch")
with st.expander("How to read this", expanded=False):
st.markdown(
"**Stage and age**\n"
"- Stage and age are the background factors (\"confounders\") the "
"Effect operator will hold constant.\n"
"- If MSI-H differs from MSS here (e.g. older or earlier-stage), "
"that's real confounding β which is exactly why we adjust for it "
"rather than trust raw correlations.\n"
"\n"
"**Missing per field**\n"
"- This is a data-quality check, not a problem β these are small "
"counts against the full cohort.\n"
"- **msi_status (NA)** = no MSIsensor score, so no MSI label; "
"**(Indeterm.)** = score fell between the thresholds, so it can't "
"be called MSI-H or MSS.\n"
"- **tmb** = missing mutation-burden value. This only affects the "
"TMB chart; the MSI label does not depend on TMB.\n"
"- **age / stage (NA) / sex (blank)** = missing clinical fields.\n"
"- **os_event / os_months** = survival fields (os = overall "
"survival; event = whether the patient died; months = length of "
"follow-up). Used in later steps, not now.\n"
"- **no expression** = the sample has no RNA-expression data.\n"
"- The \"usable cohort\" keeps only samples that have everything "
"the core task needs: a clear MSI label (MSI-H or MSS), expression "
"data, and the confounders (stage and age). The missing values "
"above are mostly what got dropped to reach that usable count."
)
st.divider()
# ----- Panel 5: expression sanity (small table, NOT a heatmap) -----
st.header("5. Expression matrix sanity")
st.caption(
"A small slice of the gene-by-sample expression matrix β enough to confirm "
"the numbers look reasonable, not a full heatmap."
)
st.write(f"Shape: **{n_genes} genes Γ {n_expr_samples} samples** (RSEM).")
st.write(
"First 20 genes Γ 10 samples (raw values), and per-gene summary stats "
"across those 10 samples:"
)
st.dataframe(expr_sample, width="stretch")
st.dataframe(
expr_sample.T.describe().T[["count", "mean", "std", "min", "50%", "max"]],
width="stretch",
)
with st.expander("How to read this", expanded=False):
st.markdown(
"**Raw values table**\n"
"- This is a tiny corner of the full gene-expression matrix the engine "
"will later search β shown only to confirm it loaded.\n"
"- Rows = genes (Hugo_Symbol). Columns = patients (TCGA-β¦ barcodes, one "
"tumour each). Each cell = how active that gene is in that tumour (a "
"relative RNA-expression value): higher = more active, 0 = effectively off.\n"
"- Many zeros are normal β in any tumour a large fraction of genes are "
"simply switched off (pseudogenes and tissue-specific genes especially). "
"The well-known genes (MLH1, CD8Aβ¦) sit elsewhere in the matrix.\n"
"- The number isn't meaningful on its own; what matters is how it "
"differs across patients.\n"
"\n"
"**Per-gene summary stats table**\n"
"- Summarises each gene across the sample columns shown.\n"
"- count = samples with a value; mean = average; std = how much it "
"varies; min / max = range; 50% = median.\n"
"- A gene that barely varies (std β 0) can't help tell patients apart; "
"the signal lives in genes that differ across patients.\n"
"- These stats cover only the small peek shown here β illustrative, not "
"the full-cohort numbers."
)
# --- Dataset tab: biology in one picture -----------------------------------
# Two parallel stories side-by-side. MSI-H (warm) sits on top: broken
# spell-checker -> MMR genes off, mutations pile up, immune system attracted.
# MSS (cool) below: working spell-checker, few mutations, immune-cold; with
# a "note"-shaped reminder that MSS is still cancer, just driven by other
# mechanisms.
_BIOLOGY_DOT = """\
digraph biology {
rankdir=LR;
bgcolor="transparent";
nodesep=0.45;
ranksep=0.9;
splines=spline;
fontname="Helvetica";
node [shape=box, style="rounded,filled", penwidth=1.1,
fontname="Helvetica", fontcolor="#23303A", fontsize=12,
margin="0.22,0.14", color="#3A6B7E", fillcolor="#F4F2EE"];
edge [color="#C2CACF", penwidth=1.3, arrowsize=0.7,
fontname="Helvetica", fontsize=11, fontcolor="#5A6670"];
msih [label="MSI-H\\nbroken spell-checker (dMMR)",
fillcolor="#FBEFE2", color="#BC6B2E"];
msih_mmr [label="MMR genes LOW\\nMLH1 Β· MSH2 Β· MSH6 Β· PMS2"];
msih_mut [label="mutations pile up\\nβ neoantigens"];
msih_imm [label="immune markers HIGH\\nCD8A Β· GZMA Β· PRF1"];
msih -> msih_mmr [label="switched off"];
msih -> msih_mut;
msih_mut -> msih_imm [label="immune-hot"];
mss [label="MSS\\nworking spell-checker",
fillcolor="#EAF0F2", color="#3A6B7E"];
mss_mmr [label="MMR genes normal / HIGH"];
mss_mut [label="few mutations\\n(stable genome)"];
mss_imm [label="immune markers LOW"];
mss_other [label="cancer driven by OTHER\\nmechanisms (not broken MMR)",
shape=note, fillcolor="#EFEFEA", color="#9AA0A6"];
mss -> mss_mmr [label="active"];
mss -> mss_mut;
mss_mut -> mss_imm [label="immune-cold"];
mss -> mss_other;
}
"""
def _section_biology_picture() -> None:
st.header("The biology in one picture")
st.caption(
"The whole story: the spell-checker's state drives both the MMR-gene "
"activity we measure and (via mutations) the immune response."
)
st.graphviz_chart(_BIOLOGY_DOT, width="stretch")
st.caption(
"MMR-gene activity is our readout of the spell-checker β low activity "
"β broken."
)
# --- Hypothesis 1 tab -------------------------------------------------------
_H1_INTRO_MD = (
"**Hypothesis 1 β Verification.** Before trusting the system to discover "
"anything, we check the tooling on a known answer. We hand it the established "
"MSI genes (we don't make it find them) and confirm the operators reproduce "
"the textbook biology: MSI-H tumours have low MMR-gene activity and high "
"immune activity, the MMRβimmune link survives adjusting for stage and age, "
"and the two scores tell MSI-H from MSS apart. A strong result here isn't "
"a discovery β it's calibration. The blind discovery (Hypothesis 2) comes "
"next."
)
def _section_h1_intro() -> None:
st.markdown(_H1_INTRO_MD)
# --- DSL panel --------------------------------------------------------------
_DSL_NOUNS = [
("Cohort", "a set of patients with their data"),
("Matrix", "patients Γ genes"),
("Vector", "one number per patient (a score)"),
("FeatureSet", "a chosen set of genes"),
("Scalar", "a single number"),
("Outcome", "the thing predicted (e.g. MSI status)"),
]
_DSL_VERBS = [
("Load", "read the cohort (expression + clinical + labels) into one shape"),
("Select", "pick a subset of the matrix's columns by their labels"),
("Reduce", "collapse the chosen columns to one score per patient (mean)"),
("Split", "partition the cohort by a per-patient predicate"),
("Associate", "observed correlation between two per-patient series"),
("Effect", "partial correlation after holding the listed confounders constant"),
("Search", "rank features by an objective and return the top-k (placeholder)"),
("Fit / Apply", "logistic regression to MSI-H vs MSS; Apply gives probabilities"),
]
_H1_PROGRAM_CODE = """\
mmr_score = Reduce(Select(M, MMR), "mean")
immune_score = Reduce(Select(M, IMMUNE), "mean")
effect = Effect(mmr_score, immune_score, adjust={stage, age})
fit = Fit((mmr_score, immune_score), msi_h_label)
"""
# Composition diagram. Operator nodes are stone-filled; the two SCORE nodes
# (MMR score, immune score) are cream "meta-concepts"; the two OUTPUT nodes
# (Effect, Fit) are cool-tinted to mark them as the program's results; the
# two "Given:" note-shaped nodes make explicit that we supply the gene panels
# in H1 (we don't make the system discover them).
_H1_DOT = """\
digraph H1 {
rankdir=LR;
bgcolor="transparent";
nodesep=0.5;
ranksep=1.0;
splines=spline;
fontname="Helvetica";
node [shape=box, style="rounded,filled", penwidth=1.2,
fontname="Helvetica", fontcolor="#23303A", fontsize=12,
margin="0.22,0.14", color="#3A6B7E", fillcolor="#F4F2EE"];
edge [color="#C2CACF", penwidth=1.3, arrowsize=0.7];
GivenMMR [label="Given: MLH1, MSH2, MSH6, PMS2",
shape=note, fillcolor="#EFEFEA", color="#9AA0A6"];
GivenIMM [label="Given: CD8A, GZMA, PRF1",
shape=note, fillcolor="#EFEFEA", color="#9AA0A6"];
M [label="Expression matrix"];
SelMMR [label="Select(MMR genes)"];
RedMMR [label="Reduce(mean)"];
MMR [label="MMR score", fillcolor="#FBEFE2", color="#BC6B2E"];
SelI [label="Select(immune genes)"];
RedI [label="Reduce(mean)"];
IMM [label="immune score", fillcolor="#FBEFE2", color="#BC6B2E"];
Eff [label="Effect(adjust: stage, age)",
fillcolor="#EAF0F2", color="#3A6B7E"];
Ft [label="Fit β MSI-H probability",
fillcolor="#EAF0F2", color="#3A6B7E"];
GivenMMR -> SelMMR;
GivenIMM -> SelI;
M -> SelMMR -> RedMMR -> MMR;
M -> SelI -> RedI -> IMM;
MMR -> Eff;
IMM -> Eff;
MMR -> Ft;
IMM -> Ft;
}
"""
def _section_dsl() -> None:
st.header("The DSL")
st.caption(
"Two short labelled lists: the kinds of thing the engine works with, "
"and the operations it composes."
)
left, right = st.columns(2)
with left:
st.markdown(
"**Nouns β the kinds of thing**\n\n"
+ "\n".join(f"- **{n}** β {d}" for n, d in _DSL_NOUNS)
)
with right:
st.markdown(
"**Verbs β the operations**\n\n"
+ "\n".join(f"- **{v}** β {d}" for v, d in _DSL_VERBS)
)
st.markdown(" ") # gentle vertical breathing room
st.markdown(
"**The H1 program** β we supply the known genes and check the "
"operators reproduce the biology."
)
st.graphviz_chart(_H1_DOT, width="stretch")
st.markdown("**The H1 program β as a one-line composition**")
st.code(_H1_PROGRAM_CODE, language="python")
# --- H1 walkthrough: the operators on 6 real, held-out patients ------------
_WALKTHROUGH_N_PER_GROUP = 3
@st.cache_resource(show_spinner=False)
def _usable_cohort_for_walkthrough():
"""The same usable MSI-H-vs-MSS cohort the H1 program runs on (cached)."""
return usable_msi_cohort(Load("processed"))
@st.cache_data(show_spinner=False)
def _walkthrough_tables():
"""Build the three step tables for 3 MSI-H + 3 MSS held-out patients.
The patients are pulled from `res.fit.test_index` so their predicted
probabilities are genuinely out-of-sample (they were never seen during
fitting). Picking deterministically (the first 3 of each class in the
test set) keeps the panel stable across reloads.
"""
res = _h1_result()
cohort = _usable_cohort_for_walkthrough()
M = cohort.expression
status = res.msi_status
test_ids = list(res.fit.test_index)
msi_h = [s for s in test_ids if status.loc[s] == "MSI-H"][:_WALKTHROUGH_N_PER_GROUP]
mss = [s for s in test_ids if status.loc[s] == "MSS"][:_WALKTHROUGH_N_PER_GROUP]
chosen = msi_h + mss
mmr_vals = M.loc[chosen, MMR_GENES].round(1)
immune_vals = M.loc[chosen, IMMUNE_GENES].round(1)
mmr_score = res.mmr_score.loc[chosen].round(1)
immune_score = res.immune_score.loc[chosen].round(1)
step1 = pd.DataFrame(index=chosen)
step1.insert(0, "MSI", status.loc[chosen].values)
for g in MMR_GENES:
step1[g] = mmr_vals[g].values
step1["MMR score"] = mmr_score.values
for g in IMMUNE_GENES:
step1[g] = immune_vals[g].values
step1["Immune score"] = immune_score.values
step1.index.name = "patient"
step2 = pd.DataFrame(
{
"MSI": status.loc[chosen].values,
"MMR score": mmr_score.values,
"Immune score": immune_score.values,
},
index=chosen,
)
step2.index.name = "patient"
state6 = pd.DataFrame(
{"mmr_score": res.mmr_score.loc[chosen],
"immune_score": res.immune_score.loc[chosen]}
)
probs = Apply(res.fit, state6)
step3 = pd.DataFrame(
{
"MMR score": mmr_score.values,
"Immune score": immune_score.values,
"predicted MSI-H probability": probs.values.round(3),
"actual MSI status": status.loc[chosen].values,
},
index=chosen,
)
step3.index.name = "patient"
return step1, step2, step3
def _section_h1_walkthrough() -> None:
st.header("Working through the program on real patients")
st.markdown(
f"Here are {_WALKTHROUGH_N_PER_GROUP} MSI-H and "
f"{_WALKTHROUGH_N_PER_GROUP} MSS patients **from the held-out test "
"set** β the model never saw them during fitting. We follow the same "
"operators through, one step at a time."
)
res = _h1_result()
step1, step2, step3 = _walkthrough_tables()
st.subheader("Step 1 β Select + Reduce")
st.dataframe(step1, width="stretch")
st.markdown(
"**Select** pulls out these genes' values; **Reduce** averages them "
"into one score per patient. Notice the MSI-H patients tend to have a "
"lower MMR score and a higher immune score."
)
st.subheader("Step 2 β Effect")
st.dataframe(step2, width="stretch")
st.markdown(
f"**Effect** asks whether a lower MMR score actually drives a higher "
f"immune score β measured across ALL patients, holding stage and age "
f"constant. Here that adjusted correlation is "
f"**{res.effect.partial_corr:+.3f}**: close to zero, so the direct "
f"link between the two scores is weak."
)
st.subheader("Step 3 β Fit")
st.dataframe(step3, width="stretch")
st.markdown(
f"**Fit** learns to turn the two scores into a probability that a "
f"patient is MSI-H. Here are its predictions vs the truth for these "
f"held-out patients. Across all held-out patients it scores "
f"AUROC **{res.fit.auroc:.3f}**."
)
# --- H1 verification panel --------------------------------------------------
_AUROC_THRESHOLD = 0.75
_HOW_TO_READ_H1 = (
"- **Score distributions:** each curve is how a score is spread across "
"one group. We want MSI-H (amber) shifted LOW on MMR score and HIGH on "
"immune score vs MSS β broken repair plus an immune-hot tumour. Medians "
"are noted above each chart.\n"
" - The y-axis (density) is a smoothed, normalised histogram β how "
"common a score is within each group. Each curve's area sums to 1, so "
"the two groups can be compared fairly despite very different sizes "
"(MSS has far more patients than MSI-H). The number itself isn't "
"meaningful; what matters is where each curve peaks and how the shapes "
"differ.\n"
"- **Effect:** does a low MMR score drive a high immune score? "
"\"Unadjusted\" is the raw correlation; \"adjusted\" holds stage and age "
"constant. Both run β1 to +1; here both are small (near zero), so the "
"direct linear link between the two scores is weak β the subtype "
"separation comes through more than this single correlation.\n"
" - **Why β1 to +1:** Effect is a correlation, always between β1 and "
"+1. +1 = the two scores rise and fall together in perfect lockstep; "
"β1 = they move in perfect opposition (one up while the other goes "
"down); 0 = no linear relationship. Sign = direction, size = strength. "
"For this biology we'd expect a negative value (low MMR β high immune); "
"near-zero here means that direct link is weak.\n"
"- **Fit:** we train on the two scores to predict MSI-H vs MSS, then test "
"on held-out patients. AUROC: 0.5 = chance, 1.0 = perfect. Raw accuracy "
"is hidden because always guessing \"MSS\" would already score ~86% on "
"this imbalanced cohort.\n"
" - **Why two metrics:** AUROC measures how well the scores SEPARATE "
"the two groups when ranked, at any threshold, robust to the size "
"imbalance β \"can the scores tell MSI-H from MSS at all?\" Balanced "
"accuracy is the average of how many MSI-H we correctly catch "
"(sensitivity) and how many MSS we correctly catch (specificity) at a "
"single yes/no cutoff β \"if forced to decide, how well on BOTH groups, "
"not just the majority?\" A high AUROC with a lower balanced accuracy "
"(as here) means the ranking separates well but the default cutoff "
"isn't tuned to call both classes evenly."
)
def _section_h1() -> None:
st.header("H1 verification")
res = _h1_result()
# --- (a) Conclusion block --------------------------------------------
a_pass = res.mmr_separates_correct_direction # MMR median lower in MSI-H
b_pass = res.immune_separates_correct_direction # immune median higher in MSI-H
c_pass = res.fit.auroc >= _AUROC_THRESHOLD
if a_pass and b_pass and c_pass:
st.success(
f"**Conclusion:** Hypothesis 1 is verified. The operators reproduce "
f"the known MSI biology β MSI-H tumours separate from MSS with "
f"AUROC **{res.fit.auroc:.3f}**, and the scores point the expected "
f"way (MMR lower, immune higher in MSI-H). The instrument is "
f"calibrated, so we can trust it for the blind discovery in "
f"Hypothesis 2. This is a tooling check, not a discovery β we were "
f"given the genes. Caveat: the direct MMR-scoreβimmune-score "
f"correlation is weak (adjusted **{res.effect.partial_corr:+.3f}**), "
f"so we rely on the overall subtype separation rather than that "
f"single link."
)
else:
lines = ["**Conclusion:** Hypothesis 1 is not fully verified β"]
if not a_pass:
lines.append(
f"- Check A failed: MMR median MSI-H "
f"**{res.mmr_median_msi_h:.1f}** vs MSS "
f"**{res.mmr_median_mss:.1f}** (expected MSI-H lower)."
)
if not b_pass:
lines.append(
f"- Check B failed: immune median MSI-H "
f"**{res.immune_median_msi_h:.1f}** vs MSS "
f"**{res.immune_median_mss:.1f}** (expected MSI-H higher)."
)
if not c_pass:
lines.append(
f"- Check C failed: held-out AUROC **{res.fit.auroc:.3f}** "
f"below the **{_AUROC_THRESHOLD:.2f}** threshold."
)
st.warning("\n".join(lines))
# --- (b) Score distributions -----------------------------------------
st.subheader("Score distributions, split by MSI status")
score_df = pd.concat([
pd.DataFrame({"score_name": "MMR score",
"value": res.mmr_score.values,
"msi_status": res.msi_status.values}),
pd.DataFrame({"score_name": "Immune score",
"value": res.immune_score.values,
"msi_status": res.msi_status.values}),
], ignore_index=True)
left, right = st.columns(2)
for col, score_name, summary in [
(left, "MMR score",
f"median MSI-H **{res.mmr_median_msi_h:.1f}** vs MSS **{res.mmr_median_mss:.1f}** "
f"({'lower in MSI-H β' if res.mmr_separates_correct_direction else 'wrong direction'})"),
(right, "Immune score",
f"median MSI-H **{res.immune_median_msi_h:.1f}** vs MSS **{res.immune_median_mss:.1f}** "
f"({'higher in MSI-H β' if res.immune_separates_correct_direction else 'wrong direction'})"),
]:
with col:
st.markdown(f"**{score_name}** β {summary}")
sub = score_df[score_df["score_name"] == score_name]
v_min = float(sub["value"].min())
v_max = float(sub["value"].max())
chart = (
alt.Chart(sub)
.transform_density(
"value",
as_=["value", "density"],
groupby=["msi_status"],
extent=[v_min, v_max],
steps=80,
)
.mark_area(opacity=0.8)
.encode(
x=alt.X("value:Q", title=score_name),
y=alt.Y("density:Q", title="density", stack=None),
color=alt.Color(
"msi_status:N",
sort=theme.MSI_ORDER,
scale=theme.msi_color_scale(include_na=False),
legend=alt.Legend(title="MSI status", orient="right"),
),
)
.properties(height=CHART_HEIGHT)
)
st.altair_chart(chart, width="stretch")
st.markdown(
"**Why this matters:** MSI-H tumours have a broken spell-checker, so "
"the MMR genes are less active (MMR score LOW) and the resulting "
"pile-up of mutations makes the tumour immune-hot (immune score "
"HIGH). MSS is the reverse β working repair, few mutations, "
"immune-cold. Seeing exactly that split here confirms our scores "
"capture the real biology; if they didn't, the tooling would be wrong."
)
# --- (c) Effect numbers ----------------------------------------------
st.markdown(
"*Step: does the MMR score causally relate to the immune score? "
"(a check on the mechanism)*"
)
st.subheader("Effect: MMR score β immune score, adjusting for stage and age")
e = res.effect
e1, e2, e3 = st.columns(3)
e1.metric(
"Unadjusted pearson", f"{e.unadjusted:+.3f}",
help=(
"Plain correlation between the two scores across patients, from "
"β1 to +1. Near 0 = little linear association."
),
)
e2.metric(
"Adjusted partial corr.", f"{e.partial_corr:+.3f}",
help=(
"The same correlation after statistically holding stage and age "
"constant β what's left once those background factors are removed."
),
)
e3.metric(
"n used", e.n_used,
help="Patients with all required values (both scores + stage + age).",
)
st.caption(e.note)
# --- (d) Fit numbers -------------------------------------------------
st.markdown(
"*Step: can the two scores together predict MSI-H vs MSS? "
"(a check that the signal is usable)*"
)
st.subheader("Fit (MMR + immune score β MSI-H) held-out performance")
f1, f2, f3, f4 = st.columns(4)
f1.metric(
"AUROC", f"{res.fit.auroc:.3f}",
help=(
"Area Under the ROC Curve: how well the model ranks an MSI-H "
"patient above an MSS one, from 0.5 (coin-flip) to 1.0 (perfect). "
"Robust to the MSI-H/MSS imbalance."
),
)
f2.metric(
"Balanced accuracy", f"{res.fit.balanced_acc:.3f}",
help=(
"Average of sensitivity (MSI-H correctly flagged) and specificity "
"(MSS correctly flagged); 0.5 = chance."
),
)
f3.metric(
"Train n", res.fit.n_train,
help="Patients used to fit the model.",
)
f4.metric(
"Test n", res.fit.n_test,
help="Held-out patients used to score it β never seen in training.",
)
_auroc_pct = round(res.fit.auroc * 100)
st.markdown(
f"**AUROC 0.5 = guessing, 1.0 = perfect.** "
f"**{res.fit.auroc:.3f}** means: take a random MSI-H patient and a "
f"random MSS patient β about **{_auroc_pct}%** of the time the model "
f"gives the MSI-H one the higher score."
)
# --- (e) How to read this -------------------------------------------
with st.expander("How to read this", expanded=False):
st.markdown(_HOW_TO_READ_H1)
# --- H2 preview: the airgap. Outside the H1 verification flow; sits at the
# bottom of the Hypothesis 1 tab as a setup for the next hypothesis. -----------
def _section_airgap_demo() -> None:
st.header("Preview β the airgap (setup for Hypothesis 2)")
st.markdown(
"This isn't part of Hypothesis 1 β here we used the known gene names "
"openly. It's a preview of the machinery that will make Hypothesis 2 "
"honest: the discovery engine is handed a matrix with gene names "
"hidden (opaque IDs), and only after it picks genes do we \"reveal\" "
"what they were β proving the next step rediscovered the biology "
"rather than recalling it."
)
demo = _airgap_demo()
if demo is None:
st.info("Processed expression matrix not found β skipping airgap demo.")
return
head, opaque_ids, revealed = demo
st.markdown("**Anonymised matrix head** (samples Γ opaque feature IDs):")
st.dataframe(head, width="stretch")
st.markdown("**`reveal()` round-trip** β opaque ID β real gene symbol:")
st.dataframe(
pd.DataFrame({"opaque_id": opaque_ids, "real_symbol": revealed}),
hide_index=True, width="stretch",
)
# --- Hypothesis 2 tab (reads from the FastAPI) -----------------------------
H2_API_BASE = os.getenv("H2_API_BASE", "http://localhost:8000")
_H2_AUROC_THRESHOLD = 0.75
_H2_P_THRESHOLD = 0.05
_H2_HYPOTHESIS_MD = (
"**Hypothesis 2 β Blind discovery.** Claim: if we hide the gene names "
"and show the engine only the numbers, it can rediscover the genes that "
"define the MSI subtype on its own β recovering the MMR spell-checker "
"genes (MLH1, MSH2, MSH6, PMS2) out of ~20,000, without being told them. "
"Confirmed if: (1) the program it finds separates MSI-H from MSS on "
"patients it never trained on (high AUROC), (2) that beats a chance "
"baseline (permutation test), and (3) when we reveal the genes, they "
"overlap the known MMR set. This is the real test β H1 only checked the "
"tooling on genes we supplied; here the engine finds them itself."
)
@st.cache_data(show_spinner=False, ttl=60)
def _h2_api_get(path: str):
r = requests.get(f"{H2_API_BASE}{path}", timeout=10)
r.raise_for_status()
return r.json()
def _h2_api_post_reveal(gene_ids: list[str]) -> list[str]:
r = requests.post(
f"{H2_API_BASE}/reveal",
json={"gene_ids": gene_ids},
timeout=10,
)
r.raise_for_status()
return r.json()["symbols"]
def _h2_card(num: int, title: str) -> "st.delta_generator.DeltaGenerator":
card = st.container(border=True)
card.caption(f"Step {num} of 5")
card.subheader(title)
return card
def _h2_program_dot(feature_sets: list[list[str]]) -> str:
"""Render a program (1 or 2 feature sets) as a graphviz DOT string."""
lines = [
"digraph p {",
' rankdir=LR;',
' bgcolor="transparent";',
' nodesep=0.5; ranksep=0.9; splines=spline;',
' fontname="Helvetica";',
' node [shape=box, style="rounded,filled", penwidth=1.2,',
' fontname="Helvetica", fontcolor="#23303A", fontsize=11,',
' margin="0.22,0.14", color="#3A6B7E", fillcolor="#F4F2EE"];',
' edge [color="#C2CACF", penwidth=1.3, arrowsize=0.7];',
' M [label="Expression matrix"];',
]
score_nodes: list[str] = []
for i, fs in enumerate(feature_sets, start=1):
head = ", ".join(fs[:3])
suffix = ", β¦" if len(fs) > 3 else ""
sel_label = f"Select({len(fs)} genes:\\n{head}{suffix})"
score_label = f"score {i}" if len(feature_sets) > 1 else "score"
lines += [
f' Sel{i} [label="{sel_label}"];',
f' Red{i} [label="Reduce(mean)"];',
f' Score{i} [label="{score_label}", fillcolor="#FBEFE2", color="#BC6B2E"];',
f" M -> Sel{i} -> Red{i} -> Score{i};",
]
score_nodes.append(f"Score{i}")
lines.append(
' Fit [label="Fit β MSI-H probability", fillcolor="#EAF0F2", color="#3A6B7E"];'
)
for s in score_nodes:
lines.append(f" {s} -> Fit;")
lines.append("}")
return "\n".join(lines)
def _h2_show_api_error(exc: Exception) -> None:
st.error(
f"Cannot reach the H2 API at `{H2_API_BASE}`. To bring it up:\n\n"
"```bash\n"
"python -m scripts.run_h2 # run the engine once (~1-2 min)\n"
"uvicorn api.app:app --reload # then start the API\n"
"```\n\n"
f"Error: `{type(exc).__name__}: {exc}`"
)
def _section_h2() -> None:
try:
run_log = _h2_api_get("/run")
result = _h2_api_get("/result")
except Exception as exc:
_h2_show_api_error(exc)
return
_h2_step_hypothesis()
_h2_step_setup(run_log)
_h2_step_evolve(run_log)
_h2_step_result(result)
_h2_step_reveal_and_conclusion(result)
def _h2_step_hypothesis() -> None:
card = _h2_card(0, "The hypothesis")
with card:
_, mid, _ = st.columns([1, 6, 1])
with mid:
st.markdown(_H2_HYPOTHESIS_MD)
def _h2_step_setup(run_log: dict) -> None:
card = _h2_card(1, "The setup β why blind")
with card:
_, mid, _ = st.columns([1, 6, 1])
with mid:
run = run_log["run"]
st.markdown(
f"The cohort: **{run['n_train']} train + {run['n_test']} held-out "
f"test** patients across **{run['n_genes']:,}** anonymised "
f"feature IDs (dropped from {run.get('n_genes_input', run['n_genes']):,} "
f"to {run['n_genes']:,} after removing features with any missing "
f"value). The prefilter shortlists "
f"**{run['prefilter_N']:,}** features by |AUROC β 0.5| computed "
"on the train split only; the GP composes within that shortlist."
)
demo = _airgap_demo()
if demo is not None:
head, _ids, _syms = demo
st.markdown("**Anonymised matrix head** (samples Γ opaque feature IDs):")
st.dataframe(head, width="stretch")
with mid:
st.markdown(
"The genes are hidden behind meaningless IDs, and the objective "
"is computed only from MSI status β never from a gene name. So "
"whatever the engine finds, it found in the data, not by "
"recalling a name."
)
def _h2_step_evolve(run_log: dict) -> None:
card = _h2_card(2, "Watch it evolve")
with card:
_, mid, _ = st.columns([1, 6, 1])
with mid:
st.markdown(
"The engine starts with random small programs, scores each on "
"how well it separates MSI-H from MSS (on held-out folds), "
"keeps the best, then breeds and mutates them over generations, "
"discarding the weak β like selective breeding for programs."
)
generations = run_log["generations"]
max_gen = generations[-1]["generation"]
elitism = int(generations[0].get("elitism", 5))
gen = st.slider(
"Generation",
min_value=0,
max_value=max_gen,
value=max_gen,
step=1,
key="h2_evolve_gen",
)
# Fitness curve.
gens_df = pd.DataFrame({
"generation": [g["generation"] for g in generations],
"best": [g["best_fitness"] for g in generations],
"median": [g["median_fitness"] for g in generations],
})
long_df = gens_df.melt(
"generation", var_name="series", value_name="fitness",
)
fit_curve = (
alt.Chart(long_df)
.mark_line(point=True)
.encode(
x=alt.X("generation:Q", title="generation"),
y=alt.Y("fitness:Q", title="fitness (CV AUROC β Ξ» Γ n_genes)"),
color=alt.Color(
"series:N",
scale=alt.Scale(
domain=["best", "median"],
range=["#BC6B2E", "#6E7F8C"],
),
legend=alt.Legend(title=None, orient="top-right"),
),
tooltip=[
alt.Tooltip("generation:Q"),
alt.Tooltip("series:N"),
alt.Tooltip("fitness:Q", format=".3f"),
],
)
.properties(height=240)
)
rule = (
alt.Chart(pd.DataFrame({"generation": [gen]}))
.mark_rule(color="#BC6B2E", strokeDash=[4, 4])
.encode(x="generation:Q")
)
st.altair_chart(fit_curve + rule, width="stretch")
# Top-K candidates at the selected generation.
cand = generations[gen]["candidates"]
pop_df = pd.DataFrame([
{
"rank": i + 1,
"id": c["id"],
"n_genes": c["n_genes"],
"fitness": c["fitness"],
"first_ids": ", ".join(c["gene_ids"][:3])
+ ("β¦" if len(c["gene_ids"]) > 3 else ""),
}
for i, c in enumerate(cand)
])
pop_df["status"] = pop_df["rank"].apply(
lambda r: "survivor (elite)" if r <= elitism else "discarded"
)
pop_chart = (
alt.Chart(pop_df)
.mark_bar()
.encode(
y=alt.Y(
"id:N",
sort=alt.SortField("rank", order="ascending"),
title="candidate",
),
x=alt.X("fitness:Q", title="fitness"),
color=alt.Color(
"status:N",
scale=alt.Scale(
domain=["survivor (elite)", "discarded"],
range=["#3A6B7E", "#9AA0A6"],
),
legend=alt.Legend(title=None, orient="top"),
),
opacity=alt.condition(
f"datum.rank <= {elitism}",
alt.value(1.0),
alt.value(0.35),
),
tooltip=[
alt.Tooltip("rank:Q"),
alt.Tooltip("id:N"),
alt.Tooltip("n_genes:Q"),
alt.Tooltip("fitness:Q", format=".4f"),
alt.Tooltip("first_ids:N", title="first IDs"),
],
)
.properties(height=220)
)
st.markdown(
f"**Top {len(cand)} candidates at generation {gen}.** "
f"Top {elitism} (solid) survive as elites into the next generation; "
"the rest fade."
)
st.altair_chart(pop_chart, width="stretch")
best = cand[0]
st.markdown(
f"**Best of generation {gen}:** `{best['id']}` Β· "
f"fitness **{best['fitness']:.4f}** Β· {best['n_genes']} genes"
)
st.graphviz_chart(_h2_program_dot(best["feature_sets"]), width="stretch")
def _h2_step_result(result: dict) -> None:
card = _h2_card(3, "The result")
with card:
_, mid, _ = st.columns([1, 6, 1])
win = result["winning"]
base = result["baseline"]
with mid:
st.markdown(
f"The engine's pick after evolving: **{win['program_repr']}** β "
f"a {len(win['gene_ids'])}-gene program."
)
st.graphviz_chart(_h2_program_dot(win["feature_sets"]), width="stretch")
m1, m2, m3, m4 = st.columns(4)
m1.metric(
"Held-out AUROC", f"{win['holdout_auroc']:.3f}",
help=(
"Area Under the ROC Curve on the held-out test patients the "
"engine never trained on. 0.5 = chance, 1.0 = perfect."
),
)
m2.metric(
"Permutation p", f"{win['permutation_p']:.4f}",
help=(
"Fraction of label-shuffled null runs whose AUROC matched or "
"beat the real winner. Small = unlikely to be a fluke."
),
)
m3.metric(
"Baseline AUROC", f"{base['holdout_auroc']:.3f}",
help=(
"Deterministic top-k by univariate prefilter, then "
"Reduce(mean) + Fit. Internal sanity check the GP should match."
),
)
m4.metric(
"n_genes used", len(win["gene_ids"]),
help="Number of opaque feature IDs the winning program touches.",
)
with mid:
auroc_pct = round(win["holdout_auroc"] * 100)
st.markdown(
f"**AUROC 0.5 = guessing, 1.0 = perfect.** "
f"**{win['holdout_auroc']:.3f}** means it ranks a random MSI-H "
f"above a random MSS about **{auroc_pct}%** of the time."
)
st.markdown(
f"**Permutation p {win['permutation_p']:.4f}** β how often pure "
"chance matched this; small = unlikely a fluke."
)
_H2_REVEAL_KEY = "h2_revealed_symbols"
_H2_REVEAL_FOR_IDS_KEY = "h2_revealed_for_ids"
def _h2_step_reveal_and_conclusion(result: dict) -> None:
win = result["winning"]
winning_ids = list(win["gene_ids"])
card = _h2_card(4, "The reveal")
with card:
_, mid, _ = st.columns([1, 6, 1])
with mid:
st.markdown(
"We have not looked at any gene name yet. The button below "
"calls the only endpoint allowed to translate the opaque IDs "
"back β pressing it is the moment the airgap opens."
)
clicked = st.button("Reveal the genes", type="primary")
if clicked:
try:
symbols = _h2_api_post_reveal(winning_ids)
except Exception as exc:
_h2_show_api_error(exc)
return
st.session_state[_H2_REVEAL_KEY] = symbols
st.session_state[_H2_REVEAL_FOR_IDS_KEY] = winning_ids
revealed = st.session_state.get(_H2_REVEAL_KEY)
revealed_for = st.session_state.get(_H2_REVEAL_FOR_IDS_KEY)
if not revealed or revealed_for != winning_ids:
with mid:
st.info("Click **Reveal the genes** to open the airgap.")
return
mmr_set = set(MMR_GENES)
reveal_df = pd.DataFrame({
"opaque ID": winning_ids,
"real symbol": revealed,
"MMR": [s in mmr_set for s in revealed],
})
styled = reveal_df.style.apply(
lambda row: [
"background-color: #FBEFE2; color: #BC6B2E; font-weight: 600"
if row["MMR"] else "" for _ in row
],
axis=1,
).hide(axis="index")
st.dataframe(styled, width="stretch")
with mid:
st.markdown(
"Only now do we look at the names. These are the genes the "
"engine chose β blind. Any row highlighted in amber matches "
"the known MMR set."
)
recovered = [s for s in revealed if s in set(MMR_GENES)]
_h2_step_conclusion(result, recovered)
def _h2_step_conclusion(result: dict, recovered: list[str]) -> None:
card = _h2_card(5, "Conclusion")
with card:
_, mid, _ = st.columns([1, 6, 1])
with mid:
win = result["winning"]
auroc = float(win["holdout_auroc"])
p_val = float(win["permutation_p"])
k = len(recovered)
a_pass = auroc >= _H2_AUROC_THRESHOLD
b_pass = p_val < _H2_P_THRESHOLD
c_pass = k >= 1
if a_pass and b_pass and c_pass:
recovered_str = ", ".join(recovered) if recovered else "β"
st.success(
f"**Conclusion:** Hypothesis 2 is verified. Blind β with "
f"gene names hidden β the engine found a program "
f"separating MSI-H from MSS (held-out AUROC "
f"**{auroc:.3f}**, permutation p **{p_val:.4f}**), and on "
f"reveal it recovered **{k}** of the 4 MMR genes "
f"(**{recovered_str}**). It rediscovered the biology "
f"rather than recalling it. (Like H1, this is the "
f"mechanism, not drug response.)"
)
else:
lines = ["**Conclusion:** Hypothesis 2 is not fully verified β"]
if not a_pass:
lines.append(
f"- Check A failed: held-out AUROC **{auroc:.3f}** "
f"below the **{_H2_AUROC_THRESHOLD:.2f}** threshold."
)
if not b_pass:
lines.append(
f"- Check B failed: permutation p **{p_val:.4f}** "
f"not below **{_H2_P_THRESHOLD:.2f}**."
)
if not c_pass:
lines.append(
f"- Check C failed: **{k}** of the 4 MMR genes "
f"recovered (need at least 1)."
)
st.warning("\n".join(lines))
if __name__ == "__main__":
main()
"""OncoDSL sanity viewer.
Run with:
streamlit run app/viewer.py
Two top-level tabs (Glossary expander sits above both, available everywhere):
- **Dataset** β the five sanity panels: cohort overview, MSI counts, TMB-vs-MSI
histogram (the load-bearing sanity check), stage/age/missing fields, and a
small expression-matrix peek.
- **Hypothesis 1** β verification of the H1 program on the named matrix: a
short intro, the DSL reference (nouns + verbs), the composition diagram,
and the H1 outputs (conclusion β score distributions β Effect β Fit).
Presentation (palette, fonts, chart theme) lives in app/theme.py and
.streamlit/config.toml. Importing `theme` registers and enables the Altair theme
used by every chart in this file.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
import altair as alt
import numpy as np
import pandas as pd
import requests
import streamlit as st
# Allow `streamlit run app/viewer.py` from the repo root without installing the
# package: prepend the repo root to sys.path so `import data_pipeline` works.
REPO_ROOT = Path(__file__).resolve().parent.parent
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from data_pipeline import schema # noqa: E402
from app import theme # noqa: E402 β registers the "oncodsl" Altair theme on import.
from airgap import anonymise, reveal # noqa: E402
from validate.h1 import IMMUNE_GENES, MMR_GENES, run_h1, usable_msi_cohort # noqa: E402
from dsl import Apply, Load # noqa: E402
CHART_HEIGHT = 300
st.set_page_config(page_title="OncoDSL β TCGA CRC sanity viewer", layout="wide")
# --- data loading -----------------------------------------------------------
def load_clinical() -> pd.DataFrame | None:
p = schema.PROCESSED_DIR / "clinical.parquet"
if not p.exists():
return None
return pd.read_parquet(p)
def load_expression_shape_and_sample() -> tuple[tuple[int, int], pd.DataFrame] | None:
p = schema.PROCESSED_DIR / "expression.parquet"
if not p.exists():
return None
df = pd.read_parquet(p)
sample = df.iloc[:20, :10]
return df.shape, sample
def _h1_result():
"""Run the full H1 program against the processed cohort."""
return run_h1()
def _airgap_demo() -> tuple[pd.DataFrame, list[str], list[str]] | None:
"""Show the anonymised matrix head + reveal() round-trip."""
p = schema.PROCESSED_DIR / "expression.parquet"
if not p.exists():
return None
cohort = Load("processed")
anonymised = anonymise(cohort.expression)
head = anonymised.iloc[:5, :8]
revealed = reveal(list(head.columns))
return head, list(head.columns), revealed
def usable_mask(clinical: pd.DataFrame) -> pd.Series:
return (
clinical["msi_status"].isin(["MSI-H", "MSS"])
& clinical["has_expression"]
& (clinical["stage"] != "NA")
& clinical["age"].notna()
)
# --- glossary ---------------------------------------------------------------
def _glossary_md() -> str:
hi = schema.MSI_SENSOR_HIGH