Skip to content

Risk model: Table 1, each risk group's clinical and genomic makeup - #41

Merged
alisman merged 6 commits into
masterfrom
session/riskmodel-table1
Oct 19, 2026
Merged

alisman merged 6 commits into
masterfrom
session/riskmodel-table1

Conversation

@alisman

@alisman alisman commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

Request #4 from the analyst's review of the riskmodel view: show the clinical and genomic makeup of each risk group — stage in the low-risk group, whether a group is enriched or depleted for KEAP1 or TP53 — with a test across groups. Built on the risk-model work merged in #37.

Preview

Open this on the PR's preview deployment (or any instance with msk_chord_2024): the gallery's NSCLC model. Click table 1 in the card's toolbar; add a gene such as SMARCA2 (not every panel covers it); try Add to document.

/lab?tab=analysis&doc=eyJkc2wiOiIyLjYiLCJzdHVkeSI6Im1za19jaG9yZF8yMDI0IiwiY29ob3J0cyI6eyJOU0NMQyI6eyJhbGwiOlt7ImtpbmQiOiJjbGluaWNhbCIsImF0dHJpYnV0ZSI6IkNBTkNFUl9UWVBFIiwidmFsdWVzIjpbIk5vbi1TbWFsbCBDZWxsIEx1bmcgQ2FuY2VyIl0sInBlciI6InNhbXBsZSJ9XX19LCJ2aWV3cyI6W3sia2luZCI6InJpc2ttb2RlbCIsImNvaG9ydHMiOlsiTlNDTEMiXSwicnVucyI6NX1dfQ

What changes for a reader

  • Table 1 — every risk group side by side, on the patients the curves draw (with validate, the validation half):
    • a categorical attribute's patients per value (the patient's value, or their first sample's; case and whitespace folded) with a χ² test;
    • a numeric one's median [IQR] with Kruskal–Wallis;
    • a gene's mutated patients over those whose mutation panels cover it — a patient whose panels do not is "not profiled", never unmutated, and the count is shown — with a χ² test;
    • Benjamini–Hochberg q across rows.
  • Rows that differ by construction are marked. The model's genes (kept in any run) and covariates are "in the model", the endpoint's own columns "outcome": they keep a p-value but get no q-value and stay out of the correction, where their guaranteed tiny p-values would shrink every other row's q. The model's stratum is marked "stratum" and stays in — the score has no stratum term.
  • Defaults: the model's stratum and covariates, then the study's attributes in priority order, leaving out survival columns and any that have no value, one value, or more than 12 categories among these patients (each left out is listed with why); the first 8. Genes: the 15 the model kept most often. The dialog adds and drops attributes and genes (≤ 20 each).
  • describe: {attributes?, genes?} — new view option. "Add to document" writes the table's resolved lists into the view, so a link reproduces it and the figure's caption (MCP, exports) states Table 1 in words. Without it the table is the dialog's alone.
  • Toolbar: three sections on one line — data (genes, stratify by, covariates, clock from), model (settings · groups · table 1), view. Penalty, runs and "validate on held-out half" move into a settings menu (the sequence and course views' idiom); the groups button reads "groups", its current cut in its title.

Commit by commit

  1. Risk model: share the modelled-patient filter, and keep each group's members — refactor, no behaviour change: keepRiskRows (who is modelled, every exclusion counted) out of fitRiskmodel; groupRiskmodel also returns each group's members as positions. The riskmodel result hashes identically before and after.
  2. Risk model: Table 1, each risk group's clinical and genomic makeup — the engine. Table 1 re-reads the fit's own rows query with its attributes added, keeps them through keepRiskRows, and refuses unless they match the fit's patients one by one (time, entry, event), so each column is the patients its curve drew. Counting is pure (lib/views/riskmodel-table1.ts); tests reuse lib/stats.ts (chiSquareTest, kruskalWallis, quantilesFromHistogram, benjaminiHochberg). describe joins RISK_PRESENTATION_KEYS, so a Table 1 change never refits. Method record (steps + a script recomputing every p, q and median from the counts), verification wording (no new statistic), DSL reference + docs/dsl.md, the gallery example, two planted bugs in check-mutants.
  3. Risk model: the figure's caption states Table 1 — and a stress document (20 attributes, 20 genes, five groups) for check:figures.
  4. Risk model: a Table 1 dialog, with rows to add and "Add to document"
  5. Risk model: "groups" and "table 1" side by side in the model zone
  6. Risk model: the fitting settings in a settings menu, three toolbar sections

How it was verified

  • Gate (scripts/gate.sh): build, lint, unit tests, Python suite, check:methods, docs, figures, e2e — green on this branch rebased onto master.
  • Denominator against ClickHouse, independently: msk_chord_2024 NSCLC, validate, runs 3 — a hand-written query over the validation half gives 3,867 modelled patients, SMARCA2 22/1,093, ZFHX3 185/3,710, KEAP1 535/3,867; Table 1's per-group counts sum to exactly these.
  • Statistics: the method script (scipy chi2_contingency(correction=False), kruskal, numpy percentiles, statsmodels fdr_bh over the same family) reproduces every number on the gallery example (21) and on ad-hoc runs with defaults, named lists, strata/covariates and validation (29–32 each). It caught a duplicate-gene bug before commit.
  • Unit tests (test/riskmodel-table1.test.ts): the denominator (panel lacking the gene, WES, a group nobody covers), roles and the BH family, folding, numbers, defaults, set-aside rows, and the refusal when rows do not match the fit. Planting either mutant by hand fails them.
  • e2e: one button, aligned; groups side by side; model genes marked with no q; adding a gene with the request held covers the table and leaves the controls usable, then is one request; "Add to document" puts describe in the URL and Table 1 in the caption; reopening is served from the view's result with no request; the settings menu shows the document's runs.

Review notes

  • Defaults can include near-constant attributes. On an NSCLC cohort, CANCER_TYPE and DIAGNOSIS_DESCRIPTION are kept (~99% one value) because a sample-level attribute takes each patient's first sample, which for 36 patients is not the lung sample. A dominance cut-off would drop them; left as is pending a decision.
  • No DOC_VERSION bump: describe is an optional view setting, as cuts and labelAbove were in Risk model: OncoCast's 0–10 score, volcano, custom cuts, and a cached fit #37; an older engine shows no Table 1 rather than a wrong number.
  • Not this PR's, noticed on the way: the volcano's gene list runs names into their percentages ("DNMT3A80%") and the figure lint misses it; the gallery lists the risk-model example twice (already so on master — this PR adds Table 1 to the first).

🤖 Generated with Claude Code

https://claude.ai/code/session_01UCpBeF9VMrjXyddFKUfiSX

alisman and others added 6 commits October 19, 2026 01:07
…members

Two internal changes Table 1 will build on, with no change to any result:

- keepRiskRows(rows, spec) is the fit's own loop that decides who is
  modelled and counts who is left out and why, moved out of fitRiskmodel.
  A second reader of the same rows (Table 1) gets the same patients in the
  same order through it rather than through a copy.
- groupRiskmodel also returns `members`: each group's patients as positions
  in the fit's subjects (no identifiers). It is not added to the view's
  result.

Verified: test/riskmodel-groups.test.ts passes; the riskmodel result for
msk_chord_2024 NSCLC (runs 2, covariate GENDER, stratified by
SMOKING_PREDICTIONS_3_CLASSES; 7,809 patients) hashes identically
(sha1 44e5f901…) before and after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UCpBeF9VMrjXyddFKUfiSX
A riskmodel view with `describe: {attributes?, genes?}` adds `table1` to its
result: per risk group, a categorical attribute's patients per value, a
numeric one's median and IQR, a gene's mutated patients over those whose
panels cover it — each tested across groups, with Benjamini–Hochberg q.

Where and how it is counted
- In the engine, from the fit's own rows query (riskmodelRowsSql) with the
  attributes added (folded value, its spelling, and how many values the
  patient's samples hold), kept by keepRiskRows exactly as the fit kept
  them, and refused unless they match the fit's patients one by one (time,
  entry, event). Group membership is groupRiskmodel's `members`. Only counts
  leave; no identifier is projected (boundary suite passes).
- Gene rows are panel-aware: profiled = the patient's mutation panels cover
  the gene (WES covers all); a patient whose panels do not is "not
  profiled", never unmutated. Mutated follows the model's `measure`.
- Tests reuse lib/stats.ts: chiSquareTest (categories; genes as mutated vs
  not among the profiled), kruskalWallis + quantilesFromHistogram (numbers),
  benjaminiHochberg.
- Rows the groups differ in by construction are marked: genes the model
  kept in any run and covariates "in the model", the endpoint's columns
  "outcome". They keep p but get no q and stay out of the BH family. The
  stratum is marked "stratum" and stays in (the score has no stratum term).
- Defaults: attributes = stratum, covariates, then the study's attributes
  by priority without survival columns; of the first 20, those with no
  value, one value, or >12 categories are left out (listed in `omitted`
  with why), first 8 kept. Genes = the 15 kept in the most runs. Named
  lists: ≤20 each (refused past that); an unknown attribute, a category
  with >100 values, or a gene on no panel and mutated in nobody is set aside
  by name. Case variants of one gene are one row.
- Not in the fit's cache key (test/riskmodel-groups.test.ts): changing
  Table 1 refits nothing.

Invariants: riskmodelMethod gains Table 1's steps and a script recomputing
every p, q and median from the counts (scipy chi2_contingency without
correction, kruskal, numpy percentile, statsmodels fdr_bh over the same
family); lib/verification.ts names Table 1 under χ² and Kruskal–Wallis (no
new statistic); DSL reference + docs/dsl.md regenerated; the gallery's
riskmodel example names a Table 1; two planted bugs in check-mutants
(denominator ignores the panel; model rows in the FDR family).

Verified
- npm test: 1258 pass. test/riskmodel-table1.test.ts covers the
  denominator (panel lacking the gene, WES, a group with nobody covered),
  roles and the BH family, folding, numbers, defaults, set-aside rows, and
  the refusal when rows do not match the fit. Planting either mutant by hand
  fails it.
- Against ClickHouse, independently: msk_chord_2024 NSCLC, validate, runs 3
  — a hand-written query over the validation half gives 3867 modelled
  patients, SMARCA2 22/1093, ZFHX3 185/3710, KEAP1 535/3867; Table 1's
  per-group counts sum to exactly these.
- check:methods on the riskmodel example: the script reproduces all 21
  numbers. Ad-hoc runs with defaults, named lists, strata/covariates and
  validation all reproduced (29–32 numbers each); the check caught a
  duplicate-gene bug, fixed above.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UCpBeF9VMrjXyddFKUfiSX
The figure does not draw Table 1 (the page shows it in a dialog), so the
caption the server SVG carries — MCP, exports — says it in words when the
view has `describe`: the groups and their sizes, how many rows and how
many are in the q-value family, then each row's headline per group (a
category's commonest value as a share of patients with a value, a number's
median, a gene's share mutated among patients whose panels cover it) with
its q, in three parts: differing at q < 0.05, not, and different by
construction (in the model / outcome, with p and no q). Defaults passed
over and set-aside rows are named with why.

A stress document (riskmodel-table1-full: 20 attributes, 20 genes, five
groups, a stratum and a covariate, including outcome columns and an
unknown gene) joins scripts/figure-stress.json.

Verified: FIGURE_CHECK_ONLY=risk check:figures — the gallery example and
the stress document lint clean at 560, 936 and 1352px (6 figures, 0
issues); the 936px caption was rendered and read; npm test passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UCpBeF9VMrjXyddFKUfiSX
A "Table 1" button in the riskmodel card's toolbar (its own zone, HTML
beside the figure) opens every risk group's makeup side by side: a
category's patients per value (top 8, the rest pooled as a disclosed
"other"; not recorded shown), a number's median [IQR], a gene's mutated
over profiled with the not-profiled count; p, q, and the row's role
("in the model", "outcome", "stratum", each titled with what it means)
and a "sparse" flag below an expected count of 5. Clicking a group's
header marks its column.

The dialog owns its draft `describe` (attributes from the study's catalog
in a PickMenu, genes typed in and removed as chips, "defaults" to go
back): a change re-runs only this request — the view's one-view document
with the draft, through /api/analysis, so the model is the cached fit —
and the page re-renders nothing. While it runs the table is covered
("updating Table 1…") and the controls stay usable; a failure says why
with retry. When the draft is what the document holds, the view's own
result is shown with no request. "Add to document" writes the resolved
lists into the view (patchView), so a link and the caption carry it;
"Remove from document" takes them out.

e2e (riskmodel Table 1 block): one button, aligned (misalignedControls on
the toolbar and the dialog); groups side by side, a header click marks a
column; the model's genes marked with no q; adding SMARCA2 with the
request held covers the table and leaves the controls usable, then is one
request and shows not-profiled counts; "Add to document" puts describe in
the URL and "Table 1 (not drawn; in the result)" in the caption (caption
lines joined as read, since it wraps); reopening is served from the
view's result with no request; no JS errors.

Verified: the block passes against this folder's dev server on cold seeds
(13; and 11, which first exposed the caption-wrap flaw in the check);
screenshots of the toolbar and dialog at 2x read and compared with the
risk-groups dialog; tsc and eslint clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UCpBeF9VMrjXyddFKUfiSX
The groups button reads just "groups"; what the groups are now is drawn
in the figure, and said in the button's title ("tertiles: equal shares of
patients by risk score, tied scores kept together — click to change", or
"cut at the 80th percentile of the risk score"). "equal groups" said
nothing about what was equal. "table 1" moves beside it, lowercase like
the toolbar's other controls, and its own "groups" zone (a second
"groups" beside the first) is gone.

e2e: the Apply check reads the cut from the button's title instead of its
text. Verified: both risk-model e2e blocks (18 checks, alignment
included) pass against this folder's dev server; the toolbar screenshot
read at 2x.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UCpBeF9VMrjXyddFKUfiSX
…ctions

The card's toolbar had four sections (data, adjust, model, view) and
about ten controls, wrapping onto two lines with the sections hard to
tell apart. It is now three, on one line at 1400px:

  data   genes · stratify by · covariates · clock from
  model  settings · groups · table 1
  view   label genes kept in ≥

"settings" is the view settings menu the sequence and course views use
(GearMenu, Settings2 icon, setrow label + Choice, a chk checkbox), with
the padding the oncoprint's display menu gives form rows. It holds what
is set once and rarely changed — penalty, runs, validate on held-out
half — each explained in its title. A runs value the menu does not offer
(the e2e document's 2) now shows instead of a blank select. Covariates
and the clock join "data"; the "adjust" section is gone.

e2e: a check that the menu holds the three settings and shows the
document's runs. Verified: both risk-model e2e blocks (19 checks,
alignment included) pass against this folder's dev server; a run changed
through the menu reaches the document; toolbar and open menu screenshots
read at 2x.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UCpBeF9VMrjXyddFKUfiSX
@vercel

vercel Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
cohort-lab Ready Ready Preview Oct 19, 2026 1:28am UTC

Request Review

@alisman
alisman merged commit 0654b91 into master Oct 19, 2026
4 of 5 checks passed
@alisman
alisman deleted the session/riskmodel-table1 branch October 19, 2026 01:27

This branch was successfully deployed

1 active deployment
Preview — 3a2ef7d5 Deployed Oct 19, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant