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.
- crc_mets_vis-0.1.0/PKG-INFO +100 -0
- crc_mets_vis-0.1.0/README.md +88 -0
- crc_mets_vis-0.1.0/pyproject.toml +25 -0
- crc_mets_vis-0.1.0/setup.cfg +4 -0
- crc_mets_vis-0.1.0/src/crc_mets_vis/__init__.py +2 -0
- crc_mets_vis-0.1.0/src/crc_mets_vis/__main__.py +3 -0
- crc_mets_vis-0.1.0/src/crc_mets_vis/analysis.py +244 -0
- crc_mets_vis-0.1.0/src/crc_mets_vis/app.py +152 -0
- crc_mets_vis-0.1.0/src/crc_mets_vis/attributes.py +126 -0
- crc_mets_vis-0.1.0/src/crc_mets_vis/cli.py +137 -0
- crc_mets_vis-0.1.0/src/crc_mets_vis/component/__init__.py +28 -0
- crc_mets_vis-0.1.0/src/crc_mets_vis/component/frontend/d3.v7.min.js +2 -0
- crc_mets_vis-0.1.0/src/crc_mets_vis/component/frontend/index.html +14 -0
- crc_mets_vis-0.1.0/src/crc_mets_vis/component/frontend/main.js +421 -0
- crc_mets_vis-0.1.0/src/crc_mets_vis/component/frontend/style.css +95 -0
- crc_mets_vis-0.1.0/src/crc_mets_vis/synth.py +223 -0
- crc_mets_vis-0.1.0/src/crc_mets_vis.egg-info/PKG-INFO +100 -0
- crc_mets_vis-0.1.0/src/crc_mets_vis.egg-info/SOURCES.txt +20 -0
- crc_mets_vis-0.1.0/src/crc_mets_vis.egg-info/dependency_links.txt +1 -0
- crc_mets_vis-0.1.0/src/crc_mets_vis.egg-info/entry_points.txt +2 -0
- crc_mets_vis-0.1.0/src/crc_mets_vis.egg-info/requires.txt +3 -0
- crc_mets_vis-0.1.0/src/crc_mets_vis.egg-info/top_level.txt +1 -0
|
@@ -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,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()
|