crc-mets-vis 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.4
2
+ Name: crc-mets-vis
3
+ Version: 0.1.0
4
+ Summary: CRC metastasis explorer: Streamlit app with a custom D3 component (overview matrix, organ combinations with survival, swimmer plot).
5
+ Author: Visual Intelligence Lab, University of Minnesota
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: streamlit>=1.40
10
+ Requires-Dist: pandas>=2.0
11
+ Requires-Dist: numpy>=1.24
12
+
13
+ # crc-mets-vis: CRC metastasis explorer
14
+
15
+ A Streamlit app with a custom D3 component, organized as overview → explore → details:
16
+
17
+ 1. **Who has metastases?** A matrix of any two attributes (default: primary site group × race/ethnicity).
18
+ Each bar shows the share of the cell's patients with metastasis, split into *at diagnosis*
19
+ (first metastasis within N days, set in the sidebar) and *found later*. The whisker is the
20
+ 95% CI and the dashed tick is the rate for all patients.
21
+ 2. **Which organs, and how do patients fare?** A transposed UpSet plot for the selected cell.
22
+ Each row is one organ combination with its patient count (at diagnosis / later) and a
23
+ survival curve measured from the first metastasis, drawn against all metastatic patients.
24
+ The bars above the organ columns show 1-year survival by organ involved.
25
+ 3. **Patients.** A swimmer plot for the clicked combination: one lane per patient, with
26
+ organ markers at the time of each metastasis and follow-up ending in death (×) or alive (›).
27
+
28
+ Groups smaller than **Hide counts below** (sidebar; default 11, the SEER-Medicare rule) are hidden in
29
+ every view. Lower it only for exploring; anything shared must use at least 11.
30
+
31
+ ## Which data file is used
32
+
33
+ 1. `--data <folder>` / `--file <csv>` given to `crc-mets-vis run` (the launcher does this)
34
+ 2. otherwise `DATA_FILE` at the top of `src/crc_mets_vis/app.py` (default `None`)
35
+
36
+ There is no fallback: without a defined file the app shows nothing. The sidebar always
37
+ shows which file is loaded.
38
+
39
+ ## Layout
40
+
41
+ ```
42
+ crc-mets-vis/ (same layout as the project folder in the secure environment)
43
+ ├── final_CRC_mets_full.csv (the app reads this file)
44
+ └── vis-app/
45
+ ├── app.py dev entry: streamlit run app.py
46
+ ├── make_synthetic_data.py writes fake CSVs to ../
47
+ ├── pyproject.toml package metadata (name on PyPI: crc-mets-vis)
48
+ └── src/crc_mets_vis/
49
+ ├── app.py the Streamlit page
50
+ ├── analysis.py loading, summaries, small-cell rule, Kaplan-Meier
51
+ ├── attributes.py attribute bins / labels / order <- edit here
52
+ ├── cli.py crc-mets-vis run | make-launcher | demo-data
53
+ ├── synth.py synthetic data generator
54
+ └── component/frontend/ index.html, main.js, style.css, d3.v7.min.js
55
+ ```
56
+
57
+ ## Develop locally
58
+
59
+ ```bash
60
+ cd vis-app
61
+ conda create -y -n crcvis python=3.12 && conda activate crcvis
62
+ python -m pip install -e .
63
+ python make_synthetic_data.py # fake data in ../ (no real patients)
64
+ python -m streamlit run app.py
65
+ ```
66
+
67
+ After editing `analysis.py` or `attributes.py`, press **C** in the app (clear cache) or restart.
68
+
69
+ ## Publish to PyPI (no file transfer needed)
70
+
71
+ ```bash
72
+ python -m pip install build twine
73
+ # bump __version__ in src/crc_mets_vis/__init__.py first
74
+ python -m build # -> dist/*.whl, dist/*.tar.gz
75
+ python -m twine upload dist/* # username: __token__, password: your PyPI API token
76
+ ```
77
+
78
+ The package contains code only, never data. It's public once uploaded.
79
+
80
+ ## Install in a secure environment (once)
81
+
82
+ In **Anaconda Prompt** inside the secure environment (set `DATA` to the folder that holds
83
+ `final_CRC_mets_full.csv`):
84
+
85
+ ```bat
86
+ set DATA=<project folder>
87
+ C:\ProgramData\anaconda3\python.exe -m venv "%DATA%\crcvis-env"
88
+ "%DATA%\crcvis-env\Scripts\python.exe" -m pip install crc-mets-vis
89
+ "%DATA%\crcvis-env\Scripts\python.exe" -m crc_mets_vis make-launcher --data "%DATA%"
90
+ ```
91
+
92
+ This writes two files next to the data:
93
+
94
+ - **Open CRC metastasis explorer.bat**: double-click to start the app. A browser tab opens, and
95
+ closing the black window stops the app.
96
+ - **Update CRC metastasis explorer.bat**: double-click to install the newest version from PyPI.
97
+
98
+ The launcher calls the environment's Python by its full path, so users never activate anything.
99
+ Each user gets their own free port, so several people can use it at once on the shared desktop.
100
+ If pip can't reach PyPI, set your environment's proxy first (`set HTTPS_PROXY=...` and `HTTP_PROXY`).
@@ -0,0 +1,88 @@
1
+ # crc-mets-vis: CRC metastasis explorer
2
+
3
+ A Streamlit app with a custom D3 component, organized as overview → explore → details:
4
+
5
+ 1. **Who has metastases?** A matrix of any two attributes (default: primary site group × race/ethnicity).
6
+ Each bar shows the share of the cell's patients with metastasis, split into *at diagnosis*
7
+ (first metastasis within N days, set in the sidebar) and *found later*. The whisker is the
8
+ 95% CI and the dashed tick is the rate for all patients.
9
+ 2. **Which organs, and how do patients fare?** A transposed UpSet plot for the selected cell.
10
+ Each row is one organ combination with its patient count (at diagnosis / later) and a
11
+ survival curve measured from the first metastasis, drawn against all metastatic patients.
12
+ The bars above the organ columns show 1-year survival by organ involved.
13
+ 3. **Patients.** A swimmer plot for the clicked combination: one lane per patient, with
14
+ organ markers at the time of each metastasis and follow-up ending in death (×) or alive (›).
15
+
16
+ Groups smaller than **Hide counts below** (sidebar; default 11, the SEER-Medicare rule) are hidden in
17
+ every view. Lower it only for exploring; anything shared must use at least 11.
18
+
19
+ ## Which data file is used
20
+
21
+ 1. `--data <folder>` / `--file <csv>` given to `crc-mets-vis run` (the launcher does this)
22
+ 2. otherwise `DATA_FILE` at the top of `src/crc_mets_vis/app.py` (default `None`)
23
+
24
+ There is no fallback: without a defined file the app shows nothing. The sidebar always
25
+ shows which file is loaded.
26
+
27
+ ## Layout
28
+
29
+ ```
30
+ crc-mets-vis/ (same layout as the project folder in the secure environment)
31
+ ├── final_CRC_mets_full.csv (the app reads this file)
32
+ └── vis-app/
33
+ ├── app.py dev entry: streamlit run app.py
34
+ ├── make_synthetic_data.py writes fake CSVs to ../
35
+ ├── pyproject.toml package metadata (name on PyPI: crc-mets-vis)
36
+ └── src/crc_mets_vis/
37
+ ├── app.py the Streamlit page
38
+ ├── analysis.py loading, summaries, small-cell rule, Kaplan-Meier
39
+ ├── attributes.py attribute bins / labels / order <- edit here
40
+ ├── cli.py crc-mets-vis run | make-launcher | demo-data
41
+ ├── synth.py synthetic data generator
42
+ └── component/frontend/ index.html, main.js, style.css, d3.v7.min.js
43
+ ```
44
+
45
+ ## Develop locally
46
+
47
+ ```bash
48
+ cd vis-app
49
+ conda create -y -n crcvis python=3.12 && conda activate crcvis
50
+ python -m pip install -e .
51
+ python make_synthetic_data.py # fake data in ../ (no real patients)
52
+ python -m streamlit run app.py
53
+ ```
54
+
55
+ After editing `analysis.py` or `attributes.py`, press **C** in the app (clear cache) or restart.
56
+
57
+ ## Publish to PyPI (no file transfer needed)
58
+
59
+ ```bash
60
+ python -m pip install build twine
61
+ # bump __version__ in src/crc_mets_vis/__init__.py first
62
+ python -m build # -> dist/*.whl, dist/*.tar.gz
63
+ python -m twine upload dist/* # username: __token__, password: your PyPI API token
64
+ ```
65
+
66
+ The package contains code only, never data. It's public once uploaded.
67
+
68
+ ## Install in a secure environment (once)
69
+
70
+ In **Anaconda Prompt** inside the secure environment (set `DATA` to the folder that holds
71
+ `final_CRC_mets_full.csv`):
72
+
73
+ ```bat
74
+ set DATA=<project folder>
75
+ C:\ProgramData\anaconda3\python.exe -m venv "%DATA%\crcvis-env"
76
+ "%DATA%\crcvis-env\Scripts\python.exe" -m pip install crc-mets-vis
77
+ "%DATA%\crcvis-env\Scripts\python.exe" -m crc_mets_vis make-launcher --data "%DATA%"
78
+ ```
79
+
80
+ This writes two files next to the data:
81
+
82
+ - **Open CRC metastasis explorer.bat**: double-click to start the app. A browser tab opens, and
83
+ closing the black window stops the app.
84
+ - **Update CRC metastasis explorer.bat**: double-click to install the newest version from PyPI.
85
+
86
+ The launcher calls the environment's Python by its full path, so users never activate anything.
87
+ Each user gets their own free port, so several people can use it at once on the shared desktop.
88
+ If pip can't reach PyPI, set your environment's proxy first (`set HTTPS_PROXY=...` and `HTTP_PROXY`).
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "crc-mets-vis"
7
+ dynamic = ["version"]
8
+ description = "CRC metastasis explorer: Streamlit app with a custom D3 component (overview matrix, organ combinations with survival, swimmer plot)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{ name = "Visual Intelligence Lab, University of Minnesota" }]
13
+ dependencies = ["streamlit>=1.40", "pandas>=2.0", "numpy>=1.24"]
14
+
15
+ [project.scripts]
16
+ crc-mets-vis = "crc_mets_vis.cli:main"
17
+
18
+ [tool.setuptools.dynamic]
19
+ version = { attr = "crc_mets_vis.__version__" }
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["src"]
23
+
24
+ [tool.setuptools.package-data]
25
+ crc_mets_vis = ["component/frontend/*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ """CRC metastasis explorer: a Streamlit app with a custom D3 component."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from crc_mets_vis.cli import main
2
+
3
+ main()
@@ -0,0 +1,244 @@
1
+ """Data loading and the summaries each view needs (all computed in Python).
2
+
3
+ Only summaries leave this module for the matrix and UpSet views; counts of
4
+ fewer than ``min_n`` patients are hidden (SEER-Medicare: min_n = 11; 0 shows everything).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+ import pandas as pd
15
+
16
+ from crc_mets_vis.attributes import SITES, SITE_LABELS, add_attribute_columns
17
+
18
+ FULL_FILE = "final_CRC_mets_full.csv"
19
+ DATE_COLS = ["CRC_DiagDate"] + [f"{s}_Mets_Date" for s in SITES]
20
+ TEXT_COLS = ["PATIENT_ID", "PRIMARY_SITE", "Stage", "TStage", "NStage", "MStage"]
21
+ DAY_COLS = [f"{s}_Days" for s in SITES]
22
+
23
+ SMALL_CELL = 11 # default: never display counts 1-10 (SEER-Medicare)
24
+ SURV_GRID = np.arange(0, 731, 15) # survival curves: 0-2 years, every 15 days
25
+
26
+
27
+ def data_path(configured: str | None = None) -> Path | None:
28
+ """The data file the user defined, or None. First match wins:
29
+
30
+ 1. --data / --file given to `crc-mets-vis run` (or the launcher) -> $CRC_DATA_FILE
31
+ 2. `configured` (DATA_FILE at the top of app.py)
32
+ There is no fallback: without a defined file the app shows nothing.
33
+ """
34
+ for candidate in (os.environ.get("CRC_DATA_FILE"), configured):
35
+ if candidate:
36
+ return Path(candidate)
37
+ if os.environ.get("CRC_DATA_DIR"):
38
+ return Path(os.environ["CRC_DATA_DIR"]) / FULL_FILE
39
+ return None
40
+
41
+
42
+ def load_data(path: Path) -> pd.DataFrame:
43
+ df = pd.read_csv(path, dtype={c: str for c in TEXT_COLS}, low_memory=False)
44
+ for c in DATE_COLS:
45
+ if c in df:
46
+ df[c] = pd.to_datetime(df[c], format="%m/%d/%Y", errors="coerce")
47
+ for c in DAY_COLS + ["OS_Days", "CSS_Days", "Death_Event", "Cancer_Specific_Death_Event",
48
+ "Any_Mets", "Number_Met_Sites"]:
49
+ df[c] = pd.to_numeric(df[c], errors="coerce")
50
+ days = df[DAY_COLS]
51
+ df["first_met_day"] = days.min(axis=1)
52
+ # survival measured from the first metastasis (avoids immortal-time bias)
53
+ df["OS_from_met"] = df["OS_Days"] - df["first_met_day"]
54
+ involved = days.notna().to_numpy()
55
+ df["combo"] = ["|".join(s for s, on in zip(SITES, row) if on) for row in involved]
56
+ return add_attribute_columns(df)
57
+
58
+
59
+ # ---------------------------------------------------------------- helpers
60
+ def apply_filters(df: pd.DataFrame, filters: dict[str, list[str]]) -> pd.DataFrame:
61
+ mask = np.ones(len(df), bool)
62
+ for key, values in filters.items():
63
+ if values:
64
+ mask &= df[f"a:{key}"].isin(values).to_numpy()
65
+ return df[mask]
66
+
67
+
68
+ def cell_mask(df, x, xv, y, yv) -> np.ndarray:
69
+ m = np.ones(len(df), bool)
70
+ if x is not None and xv is not None:
71
+ m &= (df[f"a:{x}"] == xv).to_numpy()
72
+ if y is not None and yv is not None:
73
+ m &= (df[f"a:{y}"] == yv).to_numpy()
74
+ return m
75
+
76
+
77
+ def suppress(n, min_n: int):
78
+ """None (hidden) for counts 1..min_n-1; 0 and counts >= min_n are shown."""
79
+ n = int(n)
80
+ return None if 0 < n < min_n else n
81
+
82
+
83
+ def timing(df: pd.DataFrame, cutoff: int) -> np.ndarray:
84
+ """0 = no metastasis, 1 = at diagnosis (first met <= cutoff days), 2 = later."""
85
+ mets = (df["Any_Mets"] == 1).to_numpy()
86
+ early = (df["first_met_day"] <= cutoff).to_numpy()
87
+ return np.where(~mets, 0, np.where(early, 1, 2))
88
+
89
+
90
+ def wilson(k: int, n: int, z: float = 1.96):
91
+ """95% Wilson score interval for a proportion k/n (well-behaved for small n and rates near 0/1)."""
92
+ if n == 0:
93
+ return None, None
94
+ p = k / n
95
+ d = 1 + z * z / n
96
+ c = (p + z * z / (2 * n)) / d
97
+ h = z * np.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / d
98
+ return float(max(0, c - h)), float(min(1, c + h))
99
+
100
+
101
+ def split_counts(ndx: int, nlate: int, min_n: int):
102
+ """Hide both parts if either is small (one part + the total would reveal the other)."""
103
+ sdx, slate = suppress(ndx, min_n), suppress(nlate, min_n)
104
+ if sdx is None or slate is None:
105
+ return None, None
106
+ return sdx, slate
107
+
108
+
109
+ # ---------------------------------------------------------------- 1. matrix
110
+ def matrix_payload(df, x, y, min_n: int, cutoff: int) -> dict:
111
+ xcol = f"a:{x}" if x else None
112
+ ycol = f"a:{y}" if y else None
113
+ xcats = list(df[xcol].cat.categories) if xcol else ["All patients"]
114
+ ycats = list(df[ycol].cat.categories) if ycol else ["All patients"]
115
+
116
+ t = timing(df, cutoff)
117
+ key_x = df[xcol].astype(str).to_numpy() if xcol else np.full(len(df), "All patients")
118
+ key_y = df[ycol].astype(str).to_numpy() if ycol else np.full(len(df), "All patients")
119
+ counts = pd.DataFrame({"x": key_x, "y": key_y, "t": t}).groupby(["x", "y", "t"]).size()
120
+
121
+ def get(xv, yv, tv):
122
+ return int(counts.get((str(xv), str(yv), tv), 0))
123
+
124
+ cells = []
125
+ for yi, yv in enumerate(ycats):
126
+ for xi, xv in enumerate(xcats):
127
+ n0, ndx, nlate = get(xv, yv, 0), get(xv, yv, 1), get(xv, yv, 2)
128
+ n1, n = ndx + nlate, n0 + ndx + nlate
129
+ s0, s1 = suppress(n0, min_n), suppress(n1, min_n)
130
+ sdx, slate = split_counts(ndx, nlate, min_n)
131
+ sn = None if (s0 is None or s1 is None) else n
132
+ lo, hi = wilson(n1, n) if sn else (None, None)
133
+ cells.append(dict(x=xv, y=yv, xi=xi, yi=yi, n0=s0, n1=s1, ndx=sdx, nlate=slate, n=sn,
134
+ rate=(n1 / n if sn else None), lo=lo, hi=hi))
135
+ n_all = len(df)
136
+ return dict(xCats=xcats, yCats=ycats, cells=cells, total=n_all, minN=min_n,
137
+ cohortRate=float(df["Any_Mets"].mean()) if n_all else 0.0, cutoff=cutoff)
138
+
139
+
140
+ # ---------------------------------------------------------------- survival
141
+ def km_on_grid(t: np.ndarray, e: np.ndarray, grid=SURV_GRID, min_n=SMALL_CELL):
142
+ """Kaplan-Meier survival evaluated on a grid; None once fewer than min_n remain at risk."""
143
+ ok = ~(np.isnan(t) | np.isnan(e))
144
+ t, e = t[ok], e[ok].astype(int)
145
+ if len(t) == 0:
146
+ return [None] * len(grid), None, None
147
+ ts = np.sort(t)
148
+ times, deaths = np.unique(t[e == 1], return_counts=True)
149
+ at_risk = len(ts) - np.searchsorted(ts, times, side="left")
150
+ surv = np.cumprod(1 - deaths / at_risk) if len(times) else np.array([])
151
+
152
+ def s_at(g):
153
+ i = np.searchsorted(times, g, side="right") - 1
154
+ return 1.0 if i < 0 else float(surv[i])
155
+
156
+ risks = len(ts) - np.searchsorted(ts, np.asarray(grid), side="left")
157
+ out = [None if r < max(min_n, 1) else round(s_at(g), 4)
158
+ for g, r in zip(grid, risks)]
159
+ risk365 = int(len(ts) - np.searchsorted(ts, 365, side="left"))
160
+ s1 = round(s_at(365), 4) if risk365 >= max(min_n, 1) else None
161
+ med = None
162
+ if len(surv) and (surv <= 0.5).any():
163
+ med = float(times[np.argmax(surv <= 0.5)])
164
+ return out, s1, med
165
+
166
+
167
+ # ---------------------------------------------------------------- 2. UpSet + survival
168
+ def upset_payload(group: pd.DataFrame, min_n: int, cutoff: int,
169
+ max_rows: int = 15, sort: str = "count") -> dict:
170
+ m = group[group["Any_Mets"] == 1]
171
+ t_met = m["OS_from_met"].to_numpy(float)
172
+ e_met = m["Death_Event"].to_numpy(float)
173
+ early = (m["first_met_day"] <= cutoff).to_numpy()
174
+ combos = m["combo"].to_numpy()
175
+
176
+ counts = pd.Series(combos).value_counts()
177
+ keep = [c for c, n in counts.items() if c and n >= min_n][:max_rows]
178
+ other_mask = ~np.isin(combos, keep)
179
+
180
+ def row(key, mask, label=None):
181
+ n = int(mask.sum())
182
+ ndx, nlate = int((mask & early).sum()), int((mask & ~early).sum())
183
+ sdx, slate = split_counts(ndx, nlate, min_n)
184
+ curve, s1, med = km_on_grid(t_met[mask], e_met[mask], min_n=min_n)
185
+ organs = key.split("|") if key and key != "__other" else []
186
+ return dict(key=key, organs=organs,
187
+ label=label or " + ".join(SITE_LABELS[o] for o in organs),
188
+ n=suppress(n, min_n), ndx=sdx, nlate=slate, surv=curve, s1=s1, med=med)
189
+
190
+ rows = [row(k, combos == k) for k in keep]
191
+ if sort == "surv":
192
+ rows.sort(key=lambda r: (r["s1"] is None, -(r["s1"] or 0)))
193
+ n_other = int(other_mask.sum())
194
+ if n_other and n_other >= min_n:
195
+ rows.append(row("__other", other_mask, label="Other combinations"))
196
+
197
+ organ_bars = []
198
+ for s in SITES:
199
+ mask = m[f"{s}_Days"].notna().to_numpy()
200
+ _, s1, _ = km_on_grid(t_met[mask], e_met[mask], min_n=min_n)
201
+ organ_bars.append(dict(site=s, label=SITE_LABELS[s], n=suppress(int(mask.sum()), min_n), s1=s1))
202
+
203
+ ref, ref_s1, _ = km_on_grid(t_met, e_met, min_n=min_n)
204
+ return dict(sites=SITES, siteLabels=SITE_LABELS, rows=rows, organBars=organ_bars,
205
+ grid=SURV_GRID.tolist(), ref=ref, refS1=ref_s1,
206
+ nMets=suppress(len(m), min_n), minN=min_n,
207
+ hidden=int(sum(1 for c, n in counts.items() if 0 < n < min_n)), cutoff=cutoff)
208
+
209
+
210
+ def combo_mask(group: pd.DataFrame, key: str, shown_keys: list[str]) -> np.ndarray:
211
+ mets = (group["Any_Mets"] == 1).to_numpy()
212
+ combos = group["combo"].to_numpy()
213
+ if key == "__other":
214
+ return mets & ~np.isin(combos, [k for k in shown_keys if k != "__other"])
215
+ return mets & (combos == key)
216
+
217
+
218
+ # ---------------------------------------------------------------- 3. swimmer
219
+ def swimmer_payload(pats: pd.DataFrame, max_lanes: int, cutoff: int, sort: str = "first",
220
+ seed: int = 0) -> dict:
221
+ n = len(pats)
222
+ if n > max_lanes:
223
+ pats = pats.iloc[np.sort(np.random.default_rng(seed).choice(n, max_lanes, replace=False))]
224
+ key = {"first": "first_met_day", "os": "OS_Days"}.get(sort, "first_met_day")
225
+ pats = pats.sort_values([key, "OS_Days"], na_position="last")
226
+
227
+ def num(v):
228
+ return None if pd.isna(v) else int(v)
229
+
230
+ lanes = []
231
+ for _, r in pats.iterrows():
232
+ lanes.append(dict(
233
+ days={s: num(r[f"{s}_Days"]) for s in SITES if pd.notna(r[f"{s}_Days"])},
234
+ os=num(r["OS_Days"]), died=num(r["Death_Event"]),
235
+ info=dict(age=num(r["Age"]), sex=str(r["a:sex"]), stage=str(r["a:stage"]),
236
+ primary=str(r["a:site_group"])),
237
+ ))
238
+ max_day = max([l["os"] or 0 for l in lanes] + [max(l["days"].values(), default=0) for l in lanes] + [365])
239
+ return dict(lanes=lanes, n=n, nShown=len(lanes), maxDay=int(max_day), cutoff=cutoff,
240
+ sites=SITES, siteLabels=SITE_LABELS)
241
+
242
+
243
+ def signature(*parts) -> str:
244
+ return hashlib.md5(json.dumps(parts, sort_keys=True, default=str).encode()).hexdigest()[:12]
@@ -0,0 +1,152 @@
1
+ """CRC metastasis explorer: overview (matrix) -> explore (organ combinations) -> details (patients).
2
+
3
+ Started by `crc-mets-vis run` (or `streamlit run` on this file). The data file is
4
+ DATA_FILE below, unless `--data`/`--file` is given when starting the app.
5
+ """
6
+ import streamlit as st
7
+
8
+ from crc_mets_vis import __version__, component
9
+ from crc_mets_vis.analysis import (SMALL_CELL, apply_filters, cell_mask, combo_mask, data_path,
10
+ load_data, matrix_payload, signature, swimmer_payload,
11
+ upset_payload)
12
+ from crc_mets_vis.attributes import ATTRIBUTES, FILTER_GROUPS, label
13
+
14
+ # ==============================================================================
15
+ # DATA FILE LOCATION
16
+ # The CSV given when starting the app (`crc-mets-vis run --data <folder>` or the
17
+ # launcher) is used first. Otherwise set the file here, e.g.
18
+ # DATA_FILE = r"C:\path\to\final_CRC_mets_full.csv"
19
+ # With neither, the app shows nothing but these instructions.
20
+ DATA_FILE = None
21
+ # ==============================================================================
22
+
23
+
24
+ @st.cache_data(show_spinner="Loading data…")
25
+ def _load(path_str: str, mtime: float): # mtime: reload automatically when the CSV changes
26
+ from pathlib import Path
27
+ return load_data(Path(path_str))
28
+
29
+
30
+ def main():
31
+ st.set_page_config(page_title="CRC metastasis explorer", layout="wide")
32
+ path = data_path(DATA_FILE)
33
+ if path is None:
34
+ st.title("CRC metastasis explorer")
35
+ st.warning("No data file is defined, so nothing is shown.\n\n"
36
+ "Start the app with `crc-mets-vis run --data <folder with final_CRC_mets_full.csv>` "
37
+ "(or `--file <csv>`), use the launcher made by `crc-mets-vis make-launcher`, "
38
+ "or set `DATA_FILE` at the top of `app.py`.")
39
+ st.stop()
40
+ if not path.exists():
41
+ st.error(f"Data file not found: `{path}`\n\nEdit `DATA_FILE` at the top of `app.py`, or start the "
42
+ "app with `crc-mets-vis run --data <folder with final_CRC_mets_full.csv>`.")
43
+ st.stop()
44
+ df_all = _load(str(path), path.stat().st_mtime)
45
+
46
+ ss = st.session_state
47
+ ss.setdefault("matrix_nonce", 0)
48
+ ss.setdefault("filter_nonce", 0)
49
+
50
+ # ---------------------------------------------------------------- sidebar
51
+ with st.sidebar:
52
+ st.header("Settings")
53
+ st.caption(f"Data: `{path}` \n{len(df_all):,} patients · v{__version__}")
54
+ min_n = int(st.number_input("Hide counts below", min_value=0, max_value=1000, value=SMALL_CELL, step=1,
55
+ help="Groups with fewer patients than this are hidden everywhere "
56
+ "(counts, percentages, survival). 0 shows everything. "
57
+ f"SEER-Medicare requires at least {SMALL_CELL} for anything shared."))
58
+ if min_n < SMALL_CELL:
59
+ st.caption(f":orange[Below {SMALL_CELL}: fine for exploring, but don't export or share these views.]")
60
+ cutoff = st.select_slider("“At diagnosis” = first metastasis within", [0, 30, 60, 90, 180],
61
+ value=90, format_func=lambda d: f"{d} days",
62
+ help="Metastases found within this window after the CRC diagnosis "
63
+ "count as present at diagnosis (synchronous); later ones as "
64
+ "found later (metachronous).")
65
+ st.header("Filters")
66
+ if st.button("Reset all filters", use_container_width=True):
67
+ ss.filter_nonce += 1
68
+ filters = {}
69
+ for grp in FILTER_GROUPS:
70
+ with st.expander(grp, expanded=(grp == "Demographics")):
71
+ for key, spec in ATTRIBUTES.items():
72
+ if spec["group"] == grp:
73
+ filters[key] = st.multiselect(spec["label"], list(df_all[f"a:{key}"].cat.categories),
74
+ default=[], placeholder="All",
75
+ key=f"f-{key}-{ss.filter_nonce}")
76
+ df = apply_filters(df_all, filters)
77
+ active = {k: v for k, v in filters.items() if v}
78
+
79
+ # ---------------------------------------------------------------- 1. overview
80
+ st.title("CRC metastasis explorer")
81
+ if len(df) == 0:
82
+ st.warning("No patients match the filters.")
83
+ st.stop()
84
+ st.caption(f"{len(df):,} patients after filters · {int(df['Any_Mets'].sum()):,} with metastasis "
85
+ f"({df['Any_Mets'].mean():.0%})")
86
+ st.subheader("1 · Who has metastases?")
87
+ keys = list(ATTRIBUTES)
88
+ c1, c2, c3, c4 = st.columns([2, 2, 2, 1])
89
+ x_attr = c1.selectbox("Columns", keys, index=keys.index("site_group"), format_func=label)
90
+ y_opts = [None] + keys
91
+ y_attr = c2.selectbox("Rows", y_opts, index=y_opts.index("race"), format_func=label)
92
+ measure = c3.radio("Show", ["rate", "count"], horizontal=True,
93
+ format_func={"rate": "% with metastasis", "count": "Patient counts"}.get)
94
+ show_ci = c3.checkbox("95% confidence intervals", value=True, disabled=measure != "rate",
95
+ help="Uncertainty of each cell's % with metastasis (Wilson score interval). "
96
+ "Small cells have wide intervals, so differences between them may be chance.")
97
+ if c4.button("Clear selection", use_container_width=True):
98
+ ss.matrix_nonce += 1
99
+ mat = matrix_payload(df, x_attr, y_attr, min_n, cutoff)
100
+ clicked = component.matrix(mat, label(x_attr), label(y_attr), measure, show_ci,
101
+ key=f"matrix-{x_attr}-{y_attr}-{ss.matrix_nonce}")
102
+ sel = clicked if clicked and clicked.get("x") in mat["xCats"] and clicked.get("y") in mat["yCats"] else None
103
+
104
+ # ---------------------------------------------------------------- 2. explore
105
+ if sel:
106
+ xv = sel["x"] if x_attr else None
107
+ yv = sel["y"] if y_attr else None
108
+ group = df[cell_mask(df, x_attr, xv, y_attr, yv)]
109
+ desc = " · ".join(f"{label(a)} = {v}" for a, v in ((x_attr, xv), (y_attr, yv)) if a and v is not None)
110
+ else:
111
+ group, xv, yv, desc = df, None, None, "All filtered patients"
112
+ n_mets = int(group["Any_Mets"].sum())
113
+
114
+ st.divider()
115
+ st.subheader("2 · Which organs, and how do patients fare?")
116
+ st.markdown(f"**{desc}** — {len(group):,} patients, "
117
+ + (f"{n_mets:,} with metastasis" if n_mets >= min_n
118
+ else f"fewer than {min_n} with metastasis")
119
+ + ("" if sel else " · click a matrix cell to focus on one group"))
120
+ if n_mets == 0 or n_mets < min_n:
121
+ st.stop()
122
+ s1, s2 = st.columns([1, 3])
123
+ sort = s1.radio("Sort rows by", ["count", "surv"], horizontal=True,
124
+ format_func={"count": "Patients", "surv": "1-year survival"}.get)
125
+ up = upset_payload(group, min_n, cutoff, sort=sort)
126
+ up["sig"] = signature(x_attr, y_attr, xv, yv, active, cutoff, min_n)
127
+ row = component.upset(up, key=f"upset-{up['sig']}")
128
+ shown = [r["key"] for r in up["rows"]]
129
+ combo = row.get("key") if row and row.get("sig") == up["sig"] and row.get("key") in shown else None
130
+
131
+ # ---------------------------------------------------------------- 3. details
132
+ st.divider()
133
+ st.subheader("3 · Patients")
134
+ if combo is None:
135
+ st.info("Click a row in the organ-combination chart to see those patients.")
136
+ return
137
+ pats = group[combo_mask(group, combo, shown)]
138
+ combo_label = next(r["label"] for r in up["rows"] if r["key"] == combo)
139
+ if len(pats) < min_n:
140
+ st.caption(f"Fewer than {min_n} patients; not shown.")
141
+ return
142
+ d1, d2, _ = st.columns([1.4, 1.4, 3])
143
+ lane_sort = d1.radio("Order lanes by", ["first", "os"], horizontal=True,
144
+ format_func={"first": "First metastasis", "os": "Follow-up"}.get)
145
+ max_lanes = d2.select_slider("Max patients", [50, 100, 200, 500], value=100)
146
+ st.markdown(f"**{combo_label}** · {desc}")
147
+ sw = swimmer_payload(pats, max_lanes, cutoff, lane_sort)
148
+ component.swimmer(sw, key=f"swimmer-{up['sig']}-{combo}-{max_lanes}-{lane_sort}")
149
+
150
+
151
+ if __name__ == "__main__":
152
+ main()