survival-analysis-tools 0.1.1__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.
- survival_analysis_tools-0.1.1/LICENSE +21 -0
- survival_analysis_tools-0.1.1/PKG-INFO +62 -0
- survival_analysis_tools-0.1.1/README.md +40 -0
- survival_analysis_tools-0.1.1/pyproject.toml +34 -0
- survival_analysis_tools-0.1.1/setup.cfg +4 -0
- survival_analysis_tools-0.1.1/src/survival_analysis_tools/__init__.py +12 -0
- survival_analysis_tools-0.1.1/src/survival_analysis_tools/plot_km.py +379 -0
- survival_analysis_tools-0.1.1/src/survival_analysis_tools.egg-info/PKG-INFO +62 -0
- survival_analysis_tools-0.1.1/src/survival_analysis_tools.egg-info/SOURCES.txt +11 -0
- survival_analysis_tools-0.1.1/src/survival_analysis_tools.egg-info/dependency_links.txt +1 -0
- survival_analysis_tools-0.1.1/src/survival_analysis_tools.egg-info/requires.txt +9 -0
- survival_analysis_tools-0.1.1/src/survival_analysis_tools.egg-info/top_level.txt +1 -0
- survival_analysis_tools-0.1.1/tests/test_hello.py +40 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dina Daitchman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: survival_analysis_tools
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Survival analysis helpers: Kaplan–Meier plots and Cox HR utilities.
|
|
5
|
+
Author-email: Dina Daitchman <ddaitchman@novocure.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: numpy>=1.20
|
|
14
|
+
Requires-Dist: pandas>=1.3
|
|
15
|
+
Requires-Dist: matplotlib>=3.5
|
|
16
|
+
Requires-Dist: lifelines>=0.27
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
19
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
20
|
+
Requires-Dist: twine>=4.0; extra == "dev"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# survival_analysis_tools
|
|
24
|
+
|
|
25
|
+
Kaplan–Meier plotting and Cox HR helpers for survival analysis workflows.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install survival_analysis_tools
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
From TestPyPI:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ survival_analysis_tools
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from survival_analysis_tools import plot_km, cox_over_df
|
|
43
|
+
|
|
44
|
+
summary, hr, pval = plot_km(
|
|
45
|
+
df,
|
|
46
|
+
time_col="survival",
|
|
47
|
+
event_col="event",
|
|
48
|
+
group_col="usage_bin",
|
|
49
|
+
)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Development
|
|
53
|
+
|
|
54
|
+
```powershell
|
|
55
|
+
pip install -e ".[dev]"
|
|
56
|
+
python -m pytest
|
|
57
|
+
python -m build
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Publishing
|
|
61
|
+
|
|
62
|
+
See [PUBLISHING.md](PUBLISHING.md) for TestPyPI and production PyPI upload steps with twine.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# survival_analysis_tools
|
|
2
|
+
|
|
3
|
+
Kaplan–Meier plotting and Cox HR helpers for survival analysis workflows.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install survival_analysis_tools
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
From TestPyPI:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ survival_analysis_tools
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from survival_analysis_tools import plot_km, cox_over_df
|
|
21
|
+
|
|
22
|
+
summary, hr, pval = plot_km(
|
|
23
|
+
df,
|
|
24
|
+
time_col="survival",
|
|
25
|
+
event_col="event",
|
|
26
|
+
group_col="usage_bin",
|
|
27
|
+
)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Development
|
|
31
|
+
|
|
32
|
+
```powershell
|
|
33
|
+
pip install -e ".[dev]"
|
|
34
|
+
python -m pytest
|
|
35
|
+
python -m build
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Publishing
|
|
39
|
+
|
|
40
|
+
See [PUBLISHING.md](PUBLISHING.md) for TestPyPI and production PyPI upload steps with twine.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "survival_analysis_tools"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Survival analysis helpers: Kaplan–Meier plots and Cox HR utilities."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
authors = [{ name = "Dina Daitchman", email = "ddaitchman@novocure.com" }]
|
|
12
|
+
license = { text = "MIT" }
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Topic :: Scientific/Engineering :: Medical Science Apps.",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
dependencies = [
|
|
20
|
+
"numpy>=1.20",
|
|
21
|
+
"pandas>=1.3",
|
|
22
|
+
"matplotlib>=3.5",
|
|
23
|
+
"lifelines>=0.27",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = [
|
|
28
|
+
"pytest>=7.0",
|
|
29
|
+
"build>=1.0",
|
|
30
|
+
"twine>=4.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["src"]
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import matplotlib.pyplot as plt
|
|
4
|
+
import warnings
|
|
5
|
+
|
|
6
|
+
from lifelines import KaplanMeierFitter, CoxPHFitter
|
|
7
|
+
from lifelines.plotting import add_at_risk_counts
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def cox_over_df(
|
|
11
|
+
df,
|
|
12
|
+
time_col='survival',
|
|
13
|
+
event_col='event',
|
|
14
|
+
group_col='usage_bin',
|
|
15
|
+
weights_col=None,
|
|
16
|
+
cluster_col=None,
|
|
17
|
+
robust=False,
|
|
18
|
+
):
|
|
19
|
+
"""
|
|
20
|
+
Computes HR, 95% CI, and p-value from Cox proportional-hazards model, optionally using weights and robust variance clustering.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
df : pd.DataFrame
|
|
25
|
+
time_col : str
|
|
26
|
+
event_col : str
|
|
27
|
+
group_col : str
|
|
28
|
+
weights_col : str or None
|
|
29
|
+
Optional column of weights.
|
|
30
|
+
cluster_col : str or None
|
|
31
|
+
Optional column for robust variance clustering. If provided, passed as cluster_col to lifelines' CoxPHFitter.
|
|
32
|
+
|
|
33
|
+
Returns
|
|
34
|
+
-------
|
|
35
|
+
list: [HR, pval, ci_low, ci_high]
|
|
36
|
+
HR (hazard ratio), p-value, lower 95% CI, upper 95% CI
|
|
37
|
+
"""
|
|
38
|
+
# Keep only needed columns and copy
|
|
39
|
+
needed_cols = [time_col, event_col, group_col]
|
|
40
|
+
if weights_col is not None and weights_col in df.columns:
|
|
41
|
+
needed_cols.append(weights_col)
|
|
42
|
+
if cluster_col is not None and cluster_col in df.columns:
|
|
43
|
+
needed_cols.append(cluster_col)
|
|
44
|
+
X = df[needed_cols].copy()
|
|
45
|
+
|
|
46
|
+
# Coerce time to numeric
|
|
47
|
+
X[time_col] = pd.to_numeric(X[time_col], errors='coerce')
|
|
48
|
+
# Coerce event to numeric 0/1
|
|
49
|
+
X[event_col] = pd.to_numeric(X[event_col], errors='coerce')
|
|
50
|
+
# Drop rows with missing essentials
|
|
51
|
+
missing_cols = [time_col, event_col, group_col]
|
|
52
|
+
if cluster_col is not None:
|
|
53
|
+
missing_cols.append(cluster_col)
|
|
54
|
+
X = X.dropna(subset=missing_cols)
|
|
55
|
+
# Keep only valid event codes 0/1
|
|
56
|
+
X = X[X[event_col].isin([0, 1])]
|
|
57
|
+
# Exactly two groups required
|
|
58
|
+
X[group_col] = X[group_col].astype('category')
|
|
59
|
+
if X[group_col].nunique() != 2 or len(X) < 2:
|
|
60
|
+
return [np.nan, np.nan, np.nan, np.nan]
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
cph = CoxPHFitter()
|
|
64
|
+
fit_kwargs = dict(
|
|
65
|
+
duration_col=time_col,
|
|
66
|
+
event_col=event_col,
|
|
67
|
+
formula=f"C({group_col})",
|
|
68
|
+
robust=robust
|
|
69
|
+
)
|
|
70
|
+
if weights_col is not None and weights_col in X.columns:
|
|
71
|
+
fit_kwargs["weights_col"] = weights_col
|
|
72
|
+
if cluster_col is not None and cluster_col in X.columns:
|
|
73
|
+
fit_kwargs["cluster_col"] = cluster_col
|
|
74
|
+
cph.fit(X, **fit_kwargs)
|
|
75
|
+
# With 2 groups there is a single coefficient row
|
|
76
|
+
row = cph.summary.iloc[0]
|
|
77
|
+
HR = row["exp(coef)"]
|
|
78
|
+
pval = row["p"] # Wald test p-value from the Cox model
|
|
79
|
+
ci_low = row.get("exp(coef) lower 95%", np.nan)
|
|
80
|
+
ci_high = row.get("exp(coef) upper 95%", np.nan)
|
|
81
|
+
return [HR, pval, ci_low, ci_high]
|
|
82
|
+
except Exception:
|
|
83
|
+
return [np.nan, np.nan, np.nan, np.nan]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def median_ci(fitter):
|
|
87
|
+
"""
|
|
88
|
+
Returns (lower, upper) time values such that survival probability 95% CI crosses 0.5.
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
ci = fitter.confidence_interval_
|
|
92
|
+
times = ci.index
|
|
93
|
+
lower, upper = ci.iloc[:, 0], ci.iloc[:, 1]
|
|
94
|
+
l = times[lower < 0.5][0] if any(lower < 0.5) else np.nan
|
|
95
|
+
h = times[upper < 0.5][0] if any(upper < 0.5) else np.nan
|
|
96
|
+
return l, h
|
|
97
|
+
except Exception:
|
|
98
|
+
return np.nan, np.nan
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def fmt(m, ci):
|
|
102
|
+
if not np.isfinite(m): return "NA"
|
|
103
|
+
l, h = ci
|
|
104
|
+
if np.isfinite(l) and np.isfinite(h): return f"{m:.1f} [{l:.1f}-{h:.1f}]"
|
|
105
|
+
if np.isfinite(l): return f"{m:.1f} [{l:.1f}-NA]"
|
|
106
|
+
if np.isfinite(h): return f"{m:.1f} [NA-{h:.1f}]"
|
|
107
|
+
return f"{m:.1f}"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def plot_km(
|
|
111
|
+
df,
|
|
112
|
+
time_col="survival",
|
|
113
|
+
event_col="event",
|
|
114
|
+
group_col="usage_bin",
|
|
115
|
+
group_order=None,
|
|
116
|
+
show_censors=True,
|
|
117
|
+
show_ci=True,
|
|
118
|
+
show_at_risk=True,
|
|
119
|
+
xlabel='Time [months]',
|
|
120
|
+
ylabel="Survival probability",
|
|
121
|
+
title="Survival probability vs. Usage Bin",
|
|
122
|
+
font_scale: float = 1.3,
|
|
123
|
+
plot_height=5,
|
|
124
|
+
xlim=False,
|
|
125
|
+
show_stats=True,
|
|
126
|
+
ax=None,
|
|
127
|
+
show=True,
|
|
128
|
+
weights_col=None,
|
|
129
|
+
cluster_col=None,
|
|
130
|
+
robust=False,
|
|
131
|
+
created_fig=True,
|
|
132
|
+
flip_legend_order=False
|
|
133
|
+
):
|
|
134
|
+
"""
|
|
135
|
+
Plot Kaplan-Meier curves grouped by `group_col`. If group_col is None, plot KM for all data.
|
|
136
|
+
|
|
137
|
+
Now returns a DataFrame with cols: group_name, n, median, median_ci_low, median_ci_high.
|
|
138
|
+
|
|
139
|
+
Parameters
|
|
140
|
+
----------
|
|
141
|
+
df : pd.DataFrame
|
|
142
|
+
Dataframe containing the survival data.
|
|
143
|
+
time_col : str
|
|
144
|
+
Column for durations.
|
|
145
|
+
event_col : str
|
|
146
|
+
Column for event indicators.
|
|
147
|
+
group_col : str or None
|
|
148
|
+
Column for group assignment (if None, all data is treated as one group).
|
|
149
|
+
group_order : list, optional
|
|
150
|
+
List controlling the plotting order for groups.
|
|
151
|
+
show_censors : bool
|
|
152
|
+
Show censors (default True).
|
|
153
|
+
show_ci : bool
|
|
154
|
+
Show confidence intervals.
|
|
155
|
+
show_at_risk : bool
|
|
156
|
+
Add at-risk counts table.
|
|
157
|
+
xlabel, ylabel, title : str
|
|
158
|
+
Plot labels.
|
|
159
|
+
font_scale : float
|
|
160
|
+
Font scaling.
|
|
161
|
+
plot_height : float
|
|
162
|
+
Figure height.
|
|
163
|
+
xlim : float or False
|
|
164
|
+
X-axis upper bound.
|
|
165
|
+
show_stats : bool
|
|
166
|
+
Whether to show HR/p-value on plot.
|
|
167
|
+
ax : matplotlib Axes, optional
|
|
168
|
+
Axes to plot into.
|
|
169
|
+
show : bool
|
|
170
|
+
Whether to call plt.show().
|
|
171
|
+
weights_col : str or None
|
|
172
|
+
Optional column of weights to use in fitting KM curves, Cox model, and logrank test.
|
|
173
|
+
flip_legend_order : bool
|
|
174
|
+
If True, reverse the legend order.
|
|
175
|
+
robust : bool
|
|
176
|
+
Set 'robust' parameter when calling CoxPHFitter. Default False.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
created_fig = False
|
|
180
|
+
if ax is None:
|
|
181
|
+
fig, ax = plt.subplots(figsize=(8, plot_height))
|
|
182
|
+
created_fig = True
|
|
183
|
+
|
|
184
|
+
rows = []
|
|
185
|
+
fitters = []
|
|
186
|
+
|
|
187
|
+
if group_col is None:
|
|
188
|
+
# All one group; just plot one KM curve
|
|
189
|
+
kmf = KaplanMeierFitter(label="All")
|
|
190
|
+
fit_args = dict(
|
|
191
|
+
durations=df[time_col],
|
|
192
|
+
event_observed=df[event_col],
|
|
193
|
+
)
|
|
194
|
+
if weights_col is not None and weights_col in df.columns:
|
|
195
|
+
fit_args["weights"] = df[weights_col]
|
|
196
|
+
# lifelines <=0.27.5 emits warning if any weights==0, so filter those out
|
|
197
|
+
if weights_col is not None and weights_col in df.columns:
|
|
198
|
+
mask = (df[weights_col] > 0)
|
|
199
|
+
# If all weights are zero, skip plotting
|
|
200
|
+
if mask.sum() == 0:
|
|
201
|
+
# No data to plot
|
|
202
|
+
if created_fig and show:
|
|
203
|
+
plt.tight_layout()
|
|
204
|
+
plt.show()
|
|
205
|
+
return pd.DataFrame([])
|
|
206
|
+
for k in ["durations", "event_observed", "weights"]:
|
|
207
|
+
fit_args[k] = fit_args[k][mask]
|
|
208
|
+
with warnings.catch_warnings():
|
|
209
|
+
warnings.simplefilter("ignore")
|
|
210
|
+
kmf.fit(**fit_args)
|
|
211
|
+
kmf.plot(
|
|
212
|
+
ax=ax,
|
|
213
|
+
ci_show=show_ci,
|
|
214
|
+
lw=2,
|
|
215
|
+
show_censors=show_censors,
|
|
216
|
+
censor_styles={'ms': 6, 'marker': '+'}
|
|
217
|
+
)
|
|
218
|
+
med = kmf.median_survival_time_
|
|
219
|
+
median_low, median_high = median_ci(kmf)
|
|
220
|
+
fitters.append(kmf)
|
|
221
|
+
|
|
222
|
+
if weights_col is not None and weights_col in df.columns:
|
|
223
|
+
n = float(df[weights_col].sum())
|
|
224
|
+
else:
|
|
225
|
+
n = int(df.shape[0])
|
|
226
|
+
|
|
227
|
+
rows.append({
|
|
228
|
+
"group_name": "All",
|
|
229
|
+
"n": int(n),
|
|
230
|
+
"median": float(med) if np.isfinite(med) else np.nan,
|
|
231
|
+
"median_ci_low": float(median_low) if np.isfinite(median_low) else np.nan,
|
|
232
|
+
"median_ci_high": float(median_high) if np.isfinite(median_high) else np.nan,
|
|
233
|
+
})
|
|
234
|
+
else:
|
|
235
|
+
if group_order is None:
|
|
236
|
+
group_order = sorted(df[group_col].unique())
|
|
237
|
+
else:
|
|
238
|
+
group_order = [g for g in group_order if g in df[group_col].unique()]
|
|
239
|
+
for grp_name in group_order:
|
|
240
|
+
g = df[df[group_col] == grp_name]
|
|
241
|
+
if g.empty:
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
kmf = KaplanMeierFitter(label=str(grp_name))
|
|
245
|
+
|
|
246
|
+
fit_args = dict(
|
|
247
|
+
durations=g[time_col],
|
|
248
|
+
event_observed=g[event_col],
|
|
249
|
+
)
|
|
250
|
+
if weights_col is not None and weights_col in g.columns:
|
|
251
|
+
fit_args["weights"] = g[weights_col]
|
|
252
|
+
if weights_col is not None and weights_col in g.columns:
|
|
253
|
+
mask = (g[weights_col] > 0)
|
|
254
|
+
if mask.sum() == 0:
|
|
255
|
+
continue # skip group with all zero weights
|
|
256
|
+
for k in ["durations", "event_observed", "weights"]:
|
|
257
|
+
fit_args[k] = fit_args[k][mask]
|
|
258
|
+
|
|
259
|
+
with warnings.catch_warnings():
|
|
260
|
+
warnings.simplefilter("ignore")
|
|
261
|
+
kmf.fit(**fit_args)
|
|
262
|
+
kmf.plot(
|
|
263
|
+
ax=ax,
|
|
264
|
+
ci_show=show_ci,
|
|
265
|
+
lw=2,
|
|
266
|
+
show_censors=show_censors,
|
|
267
|
+
censor_styles={'ms': 6, 'marker': '+'}
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
med = kmf.median_survival_time_
|
|
271
|
+
median_low, median_high = median_ci(kmf)
|
|
272
|
+
fitters.append(kmf)
|
|
273
|
+
|
|
274
|
+
if weights_col is not None and weights_col in g.columns:
|
|
275
|
+
n = float(g[weights_col].sum())
|
|
276
|
+
else:
|
|
277
|
+
n = int(g.shape[0])
|
|
278
|
+
|
|
279
|
+
rows.append({
|
|
280
|
+
"group_name": str(grp_name),
|
|
281
|
+
"n": int(n),
|
|
282
|
+
"median": float(med) if np.isfinite(med) else np.nan,
|
|
283
|
+
"median_ci_low": float(median_low) if np.isfinite(median_low) else np.nan,
|
|
284
|
+
"median_ci_high": float(median_high) if np.isfinite(median_high) else np.nan,
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
# ---- cosmetics ----
|
|
288
|
+
if title is None:
|
|
289
|
+
title = f"Survival Probability" if group_col is None else f"Survival Probability by {group_col}"
|
|
290
|
+
ax.set_title(title, fontsize=16 * font_scale, weight="bold")
|
|
291
|
+
ax.set_xlabel(xlabel, fontsize=14 * font_scale)
|
|
292
|
+
ax.set_ylabel(ylabel, fontsize=14 * font_scale)
|
|
293
|
+
ax.tick_params(axis="both", labelsize=12 * font_scale)
|
|
294
|
+
ax.grid(True, ls="--", alpha=0.35)
|
|
295
|
+
ax.set_ylim(0, 1.0)
|
|
296
|
+
|
|
297
|
+
if xlim:
|
|
298
|
+
ax.set_xlim(0, xlim)
|
|
299
|
+
|
|
300
|
+
# ---- Legend with n & median ----
|
|
301
|
+
handles, labels = ax.get_legend_handles_labels()
|
|
302
|
+
|
|
303
|
+
medians = {r["group_name"]: r["median"] for r in rows}
|
|
304
|
+
cis = {r["group_name"]: (r["median_ci_low"], r["median_ci_high"]) for r in rows}
|
|
305
|
+
|
|
306
|
+
if handles:
|
|
307
|
+
if flip_legend_order:
|
|
308
|
+
handles = handles[::-1]
|
|
309
|
+
labels = labels[::-1]
|
|
310
|
+
ax.legend(
|
|
311
|
+
handles,
|
|
312
|
+
[f"{lab} (median={fmt(medians.get(lab, np.nan), cis.get(lab, (np.nan, np.nan)))})" for lab in labels],
|
|
313
|
+
fontsize=14 * font_scale,
|
|
314
|
+
loc='upper right',
|
|
315
|
+
frameon=False,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
# ---- At-risk counts ----
|
|
319
|
+
if show_at_risk and fitters:
|
|
320
|
+
# lifelines' add_at_risk_counts ignores weights, so this may mismatch with weighted.
|
|
321
|
+
add_at_risk_counts(
|
|
322
|
+
*fitters,
|
|
323
|
+
ax=ax,
|
|
324
|
+
rows_to_show=['At risk'],
|
|
325
|
+
fontsize=10 * font_scale,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
# ---- HR, CI & p-value (respect group_order, only if group_col provided) ----
|
|
329
|
+
if show_stats and group_col is not None and len(fitters) > 1:
|
|
330
|
+
df_cox = df.copy()
|
|
331
|
+
df_cox[group_col] = pd.Categorical(
|
|
332
|
+
df_cox[group_col],
|
|
333
|
+
categories=group_order,
|
|
334
|
+
ordered=True,
|
|
335
|
+
)
|
|
336
|
+
HR, pval, ci_low, ci_high = cox_over_df(
|
|
337
|
+
df_cox,
|
|
338
|
+
time_col=time_col,
|
|
339
|
+
event_col=event_col,
|
|
340
|
+
group_col=group_col,
|
|
341
|
+
weights_col=weights_col,
|
|
342
|
+
cluster_col=cluster_col,
|
|
343
|
+
robust=robust, # <-- propagate robust!
|
|
344
|
+
)
|
|
345
|
+
if pd.notna(HR):
|
|
346
|
+
# p-value formatting: show <0.001 if smaller than 0.001
|
|
347
|
+
if pd.notna(pval) and pval < 0.001:
|
|
348
|
+
pval_str = "<0.001"
|
|
349
|
+
elif pd.notna(pval):
|
|
350
|
+
pval_str = f"{pval:.3g}"
|
|
351
|
+
else:
|
|
352
|
+
pval_str = "NA"
|
|
353
|
+
# CI formatting
|
|
354
|
+
if pd.notna(ci_low) and pd.notna(ci_high):
|
|
355
|
+
ci_str = f"{HR:.2f} [{ci_low:.2f}, {ci_high:.2f}]"
|
|
356
|
+
elif pd.notna(ci_low):
|
|
357
|
+
ci_str = f"{HR:.2f} [{ci_low:.2f}, NA]"
|
|
358
|
+
elif pd.notna(ci_high):
|
|
359
|
+
ci_str = f"{HR:.2f} [NA, {ci_high:.2f}]"
|
|
360
|
+
else:
|
|
361
|
+
ci_str = f"{HR:.2f}"
|
|
362
|
+
|
|
363
|
+
ax.text(
|
|
364
|
+
0.02, 0.2,
|
|
365
|
+
f"HR = {ci_str}\np = {pval_str}",
|
|
366
|
+
transform=ax.transAxes,
|
|
367
|
+
ha='left', va='top',
|
|
368
|
+
fontsize=12 * font_scale,
|
|
369
|
+
bbox=dict(
|
|
370
|
+
boxstyle='round,pad=0.3',
|
|
371
|
+
fc='white', ec='0.5', alpha=0.9,
|
|
372
|
+
),
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
if created_fig and show:
|
|
376
|
+
plt.tight_layout()
|
|
377
|
+
plt.show()
|
|
378
|
+
|
|
379
|
+
return pd.DataFrame(rows), HR, pval
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: survival_analysis_tools
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Survival analysis helpers: Kaplan–Meier plots and Cox HR utilities.
|
|
5
|
+
Author-email: Dina Daitchman <ddaitchman@novocure.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: numpy>=1.20
|
|
14
|
+
Requires-Dist: pandas>=1.3
|
|
15
|
+
Requires-Dist: matplotlib>=3.5
|
|
16
|
+
Requires-Dist: lifelines>=0.27
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
19
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
20
|
+
Requires-Dist: twine>=4.0; extra == "dev"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# survival_analysis_tools
|
|
24
|
+
|
|
25
|
+
Kaplan–Meier plotting and Cox HR helpers for survival analysis workflows.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install survival_analysis_tools
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
From TestPyPI:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ survival_analysis_tools
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from survival_analysis_tools import plot_km, cox_over_df
|
|
43
|
+
|
|
44
|
+
summary, hr, pval = plot_km(
|
|
45
|
+
df,
|
|
46
|
+
time_col="survival",
|
|
47
|
+
event_col="event",
|
|
48
|
+
group_col="usage_bin",
|
|
49
|
+
)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Development
|
|
53
|
+
|
|
54
|
+
```powershell
|
|
55
|
+
pip install -e ".[dev]"
|
|
56
|
+
python -m pytest
|
|
57
|
+
python -m build
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Publishing
|
|
61
|
+
|
|
62
|
+
See [PUBLISHING.md](PUBLISHING.md) for TestPyPI and production PyPI upload steps with twine.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/survival_analysis_tools/__init__.py
|
|
5
|
+
src/survival_analysis_tools/plot_km.py
|
|
6
|
+
src/survival_analysis_tools.egg-info/PKG-INFO
|
|
7
|
+
src/survival_analysis_tools.egg-info/SOURCES.txt
|
|
8
|
+
src/survival_analysis_tools.egg-info/dependency_links.txt
|
|
9
|
+
src/survival_analysis_tools.egg-info/requires.txt
|
|
10
|
+
src/survival_analysis_tools.egg-info/top_level.txt
|
|
11
|
+
tests/test_hello.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
survival_analysis_tools
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
|
|
3
|
+
from survival_analysis_tools import cox_over_df, hello, plot_km
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_hello_default():
|
|
7
|
+
assert hello() == "Hello, world from survival_analysis_tools!"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_plot_km_imports_and_runs():
|
|
11
|
+
df = pd.DataFrame(
|
|
12
|
+
{
|
|
13
|
+
"survival": [3, 5, 8, 10, 12, 15],
|
|
14
|
+
"event": [1, 1, 0, 1, 0, 1],
|
|
15
|
+
"usage_bin": ["low", "low", "low", "high", "high", "high"],
|
|
16
|
+
}
|
|
17
|
+
)
|
|
18
|
+
summary, hr, pval = plot_km(df, show=False, show_at_risk=False)
|
|
19
|
+
assert list(summary.columns) == [
|
|
20
|
+
"group_name",
|
|
21
|
+
"n",
|
|
22
|
+
"median",
|
|
23
|
+
"median_ci_low",
|
|
24
|
+
"median_ci_high",
|
|
25
|
+
]
|
|
26
|
+
assert len(summary) == 2
|
|
27
|
+
assert pd.notna(hr)
|
|
28
|
+
assert pd.notna(pval)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_cox_over_df():
|
|
32
|
+
df = pd.DataFrame(
|
|
33
|
+
{
|
|
34
|
+
"survival": [3, 5, 8, 10, 12, 15],
|
|
35
|
+
"event": [1, 1, 0, 1, 0, 1],
|
|
36
|
+
"usage_bin": ["low", "low", "low", "high", "high", "high"],
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
hr, pval, ci_low, ci_high = cox_over_df(df)
|
|
40
|
+
assert all(isinstance(x, float) for x in (hr, pval, ci_low, ci_high))
|