bpskit 0.2.0__tar.gz → 0.3.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.
- {bpskit-0.2.0 → bpskit-0.3.0}/CHANGELOG.md +13 -0
- {bpskit-0.2.0 → bpskit-0.3.0}/CITATION.cff +1 -1
- {bpskit-0.2.0 → bpskit-0.3.0}/PKG-INFO +7 -5
- {bpskit-0.2.0 → bpskit-0.3.0}/README.md +6 -4
- {bpskit-0.2.0 → bpskit-0.3.0}/src/bpskit/__init__.py +4 -2
- {bpskit-0.2.0 → bpskit-0.3.0}/src/bpskit/icd.py +25 -0
- {bpskit-0.2.0 → bpskit-0.3.0}/src/bpskit/stats.py +65 -1
- bpskit-0.3.0/src/bpskit/strata.py +198 -0
- bpskit-0.3.0/tests/test_contrast.py +25 -0
- bpskit-0.3.0/tests/test_strata.py +35 -0
- {bpskit-0.2.0 → bpskit-0.3.0}/.gitignore +0 -0
- {bpskit-0.2.0 → bpskit-0.3.0}/LICENSE +0 -0
- {bpskit-0.2.0 → bpskit-0.3.0}/examples/quickstart.py +0 -0
- {bpskit-0.2.0 → bpskit-0.3.0}/pyproject.toml +0 -0
- {bpskit-0.2.0 → bpskit-0.3.0}/src/bpskit/cli.py +0 -0
- {bpskit-0.2.0 → bpskit-0.3.0}/src/bpskit/delivery.py +0 -0
- {bpskit-0.2.0 → bpskit-0.3.0}/src/bpskit/persontime.py +0 -0
- {bpskit-0.2.0 → bpskit-0.3.0}/src/bpskit/py.typed +0 -0
- {bpskit-0.2.0 → bpskit-0.3.0}/src/bpskit/synthetic.py +0 -0
- {bpskit-0.2.0 → bpskit-0.3.0}/src/bpskit/transfer.py +0 -0
- {bpskit-0.2.0 → bpskit-0.3.0}/tests/test_icd.py +0 -0
- {bpskit-0.2.0 → bpskit-0.3.0}/tests/test_persontime.py +0 -0
- {bpskit-0.2.0 → bpskit-0.3.0}/tests/test_stats.py +0 -0
- {bpskit-0.2.0 → bpskit-0.3.0}/tests/test_synthetic.py +0 -0
- {bpskit-0.2.0 → bpskit-0.3.0}/tests/test_transfer.py +0 -0
|
@@ -5,6 +5,18 @@ All notable changes to bpskit. The format follows
|
|
|
5
5
|
[Semantic Versioning](https://semver.org/): until 1.0, a minor release may
|
|
6
6
|
change the public API (the names in each module's `__all__`).
|
|
7
7
|
|
|
8
|
+
## [0.3.0]
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `bpskit.strata`: `Strata` (person-time and expected counts by sex, age band,
|
|
12
|
+
calendar year and prior-year contact intensity) and `person_pairs`.
|
|
13
|
+
- `bpskit.stats.exact_contrast` and `idtn_from_ratio`: rate ratio, IDTN and rate
|
|
14
|
+
difference of two groups with exact (Clopper-Pearson) intervals.
|
|
15
|
+
- `bpskit.icd.read_conditions`: read and validate a condition table.
|
|
16
|
+
|
|
17
|
+
These moved from the BPS sex-differences workflow, so a workflow needs no
|
|
18
|
+
helper modules of its own.
|
|
19
|
+
|
|
8
20
|
## [0.2.0]
|
|
9
21
|
|
|
10
22
|
### Added
|
|
@@ -25,5 +37,6 @@ change the public API (the names in each module's `__all__`).
|
|
|
25
37
|
BPS sex-differences workflow, and the `bpskit pullable` / `bpskit check-logs`
|
|
26
38
|
command line.
|
|
27
39
|
|
|
40
|
+
[0.3.0]: https://github.com/loucerac/bpskit/compare/v0.2.0...v0.3.0
|
|
28
41
|
[0.2.0]: https://github.com/loucerac/bpskit/compare/v0.1.0...v0.2.0
|
|
29
42
|
[0.1.0]: https://github.com/loucerac/bpskit/releases/tag/v0.1.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: bpskit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Tools for studies on a BPS (Base Poblacional de Salud) health-records delivery, including an identifier check for outputs leaving a secure data environment
|
|
5
5
|
Project-URL: Homepage, https://github.com/loucerac/bpskit
|
|
6
6
|
Project-URL: Changelog, https://github.com/loucerac/bpskit/blob/main/CHANGELOG.md
|
|
@@ -42,7 +42,9 @@ health-record extracts in a secure data environment generally.
|
|
|
42
42
|
- **Person-time.** Dates as days since 1970, age bands, Lexis splitting,
|
|
43
43
|
person-sorted streaming.
|
|
44
44
|
- **Exact tests.** Poisson rate ratios (Garwood intervals), binomial tests,
|
|
45
|
-
BH and Bonferroni.
|
|
45
|
+
two-group rate contrasts with exact intervals, BH and Bonferroni.
|
|
46
|
+
- **Indirect standardisation.** Person-time and expected counts by sex, age,
|
|
47
|
+
calendar year and prior-year contact intensity; within-person record pairs.
|
|
46
48
|
- **An output check for leaving the secure environment.** `bpskit.transfer`
|
|
47
49
|
refuses any file that contains an identifier the workflow read, or a
|
|
48
50
|
per-person column, and fails closed when it cannot read something.
|
|
@@ -52,14 +54,14 @@ health-record extracts in a secure data environment generally.
|
|
|
52
54
|
## Installing
|
|
53
55
|
|
|
54
56
|
```bash
|
|
55
|
-
pip install
|
|
57
|
+
pip install bpskit
|
|
56
58
|
```
|
|
57
59
|
|
|
58
60
|
In a pixi project (`pixi.toml`):
|
|
59
61
|
|
|
60
62
|
```toml
|
|
61
63
|
[pypi-dependencies]
|
|
62
|
-
bpskit =
|
|
64
|
+
bpskit = "==0.3.0"
|
|
63
65
|
```
|
|
64
66
|
|
|
65
67
|
In a Snakemake rule environment (`workflow/envs/*.yaml`):
|
|
@@ -68,7 +70,7 @@ In a Snakemake rule environment (`workflow/envs/*.yaml`):
|
|
|
68
70
|
dependencies:
|
|
69
71
|
- pip
|
|
70
72
|
- pip:
|
|
71
|
-
-
|
|
73
|
+
- bpskit==0.3.0
|
|
72
74
|
```
|
|
73
75
|
|
|
74
76
|
Pin a release. Bumping the tag changes the environment file, and Snakemake's
|
|
@@ -14,7 +14,9 @@ health-record extracts in a secure data environment generally.
|
|
|
14
14
|
- **Person-time.** Dates as days since 1970, age bands, Lexis splitting,
|
|
15
15
|
person-sorted streaming.
|
|
16
16
|
- **Exact tests.** Poisson rate ratios (Garwood intervals), binomial tests,
|
|
17
|
-
BH and Bonferroni.
|
|
17
|
+
two-group rate contrasts with exact intervals, BH and Bonferroni.
|
|
18
|
+
- **Indirect standardisation.** Person-time and expected counts by sex, age,
|
|
19
|
+
calendar year and prior-year contact intensity; within-person record pairs.
|
|
18
20
|
- **An output check for leaving the secure environment.** `bpskit.transfer`
|
|
19
21
|
refuses any file that contains an identifier the workflow read, or a
|
|
20
22
|
per-person column, and fails closed when it cannot read something.
|
|
@@ -24,14 +26,14 @@ health-record extracts in a secure data environment generally.
|
|
|
24
26
|
## Installing
|
|
25
27
|
|
|
26
28
|
```bash
|
|
27
|
-
pip install
|
|
29
|
+
pip install bpskit
|
|
28
30
|
```
|
|
29
31
|
|
|
30
32
|
In a pixi project (`pixi.toml`):
|
|
31
33
|
|
|
32
34
|
```toml
|
|
33
35
|
[pypi-dependencies]
|
|
34
|
-
bpskit =
|
|
36
|
+
bpskit = "==0.3.0"
|
|
35
37
|
```
|
|
36
38
|
|
|
37
39
|
In a Snakemake rule environment (`workflow/envs/*.yaml`):
|
|
@@ -40,7 +42,7 @@ In a Snakemake rule environment (`workflow/envs/*.yaml`):
|
|
|
40
42
|
dependencies:
|
|
41
43
|
- pip
|
|
42
44
|
- pip:
|
|
43
|
-
-
|
|
45
|
+
- bpskit==0.3.0
|
|
44
46
|
```
|
|
45
47
|
|
|
46
48
|
Pin a release. Bumping the tag changes the environment file, and Snakemake's
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
bpskit.delivery read the pipe-delimited delivery: sentinels, dates, streaming
|
|
4
4
|
bpskit.icd ICD-10 code specs and chapters, ICD-9 -> ICD-10 mapping (CMS GEM)
|
|
5
5
|
bpskit.persontime dates as numbers, age bands, Lexis splitting, person streaming
|
|
6
|
-
bpskit.stats exact Poisson and binomial tests,
|
|
6
|
+
bpskit.stats exact Poisson and binomial tests, exact two-group rate contrasts,
|
|
7
|
+
BH / Bonferroni
|
|
8
|
+
bpskit.strata strata for indirect standardisation, within-person record pairs
|
|
7
9
|
bpskit.transfer the check that no file leaving the data machine names a patient
|
|
8
10
|
bpskit.synthetic a synthetic BPS delivery with planted structure, for tests
|
|
9
11
|
|
|
@@ -11,4 +13,4 @@ The command line (`bpskit pullable`, `bpskit check-logs`) runs the transfer
|
|
|
11
13
|
check on the data machine.
|
|
12
14
|
"""
|
|
13
15
|
|
|
14
|
-
__version__ = "0.
|
|
16
|
+
__version__ = "0.3.0"
|
|
@@ -5,6 +5,7 @@ Codes are handled in normalised form: upper case, no dot ('E119').
|
|
|
5
5
|
|
|
6
6
|
import re
|
|
7
7
|
from collections.abc import Iterable
|
|
8
|
+
from pathlib import Path
|
|
8
9
|
|
|
9
10
|
import numpy as np
|
|
10
11
|
import pandas as pd
|
|
@@ -189,3 +190,27 @@ def harmonise(df: pd.DataFrame, changeover_day: float | None, mapper: Icd9Mapper
|
|
|
189
190
|
system[is9] = np.where(hit, "icd9_mapped", "icd9_unmapped")
|
|
190
191
|
df.loc[hit[hit].index, "code"] = normalise_code(m[hit])
|
|
191
192
|
return system
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def read_conditions(path: str | Path) -> pd.DataFrame:
|
|
196
|
+
"""A condition table (TSV): one row per condition, its ICD-10 code spec.
|
|
197
|
+
|
|
198
|
+
Columns: condition_id ([a-z0-9_]+, so it can name a file or a Snakemake
|
|
199
|
+
wildcard), chapter_num, codes (a spec for `expand_spec`), and optionally
|
|
200
|
+
exclude; any other column is carried along. Lines starting with # are
|
|
201
|
+
comments.
|
|
202
|
+
|
|
203
|
+
Raises:
|
|
204
|
+
ValueError: a condition_id that cannot be a Snakemake wildcard value, a
|
|
205
|
+
duplicated id, or a code spec that does not parse.
|
|
206
|
+
"""
|
|
207
|
+
c = pd.read_csv(path, sep="\t", comment="#", dtype=str, keep_default_na=False)
|
|
208
|
+
c["chapter_num"] = c["chapter_num"].astype(int)
|
|
209
|
+
bad = c.loc[~c.condition_id.str.fullmatch(r"[a-z0-9_]+"), "condition_id"]
|
|
210
|
+
if len(bad):
|
|
211
|
+
raise ValueError(f"condition_id not wildcard-safe: {list(bad)}")
|
|
212
|
+
if c.condition_id.duplicated().any():
|
|
213
|
+
raise ValueError("duplicated condition_id")
|
|
214
|
+
for spec in c["codes"]:
|
|
215
|
+
expand_spec(spec)
|
|
216
|
+
return c
|
|
@@ -9,7 +9,15 @@ import pandas as pd
|
|
|
9
9
|
from numpy.typing import ArrayLike
|
|
10
10
|
from scipy import stats
|
|
11
11
|
|
|
12
|
-
__all__ = [
|
|
12
|
+
__all__ = [
|
|
13
|
+
"bh",
|
|
14
|
+
"adjust",
|
|
15
|
+
"poisson_rr",
|
|
16
|
+
"binom_half_p",
|
|
17
|
+
"binom_p",
|
|
18
|
+
"idtn_from_ratio",
|
|
19
|
+
"exact_contrast",
|
|
20
|
+
]
|
|
13
21
|
|
|
14
22
|
|
|
15
23
|
def bh(p: pd.Series) -> pd.Series:
|
|
@@ -74,3 +82,59 @@ def binom_p(k: ArrayLike, n: ArrayLike, p0: ArrayLike) -> np.ndarray:
|
|
|
74
82
|
with np.errstate(invalid="ignore"):
|
|
75
83
|
p = np.minimum(1.0, 2 * np.minimum(stats.binom.cdf(k, n, p0), stats.binom.sf(k - 1, n, p0)))
|
|
76
84
|
return np.where((n > 0) & (p0 > 0) & (p0 < 1), p, np.nan)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def idtn_from_ratio(r: ArrayLike) -> np.ndarray:
|
|
88
|
+
"""IDTN from a rate ratio: (r - 1) / (r + 1), with an infinite ratio giving 1."""
|
|
89
|
+
r = np.asarray(r, float)
|
|
90
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
91
|
+
out = (r - 1.0) / (r + 1.0)
|
|
92
|
+
return np.where(np.isinf(r), 1.0, out)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def exact_contrast(
|
|
96
|
+
cf: ArrayLike, pyf: ArrayLike, cm: ArrayLike, pym: ArrayLike, alpha: float
|
|
97
|
+
) -> pd.DataFrame:
|
|
98
|
+
"""Rate ratio, IDTN and rate difference of two groups, with exact intervals.
|
|
99
|
+
|
|
100
|
+
Written for female (f) against male (m), the names the output columns carry,
|
|
101
|
+
but any two groups work.
|
|
102
|
+
|
|
103
|
+
Conditional on the total count, female cases are binomial with
|
|
104
|
+
p = IRR*pyf / (IRR*pyf + pym). A Clopper-Pearson interval on p therefore
|
|
105
|
+
maps to an exact interval on the IRR, and through the monotone
|
|
106
|
+
(r-1)/(r+1) onto IDTN. Exact matters here: age bands at the extremes hold
|
|
107
|
+
a handful of cases, where a Wald interval on log IRR is badly wrong.
|
|
108
|
+
|
|
109
|
+
Rates are per 100,000 person-years. Cells with no cases or no person-time
|
|
110
|
+
in either sex come back NaN rather than as 0 or +-inf.
|
|
111
|
+
"""
|
|
112
|
+
cf, cm = np.asarray(cf, float), np.asarray(cm, float)
|
|
113
|
+
pyf, pym = np.asarray(pyf, float), np.asarray(pym, float)
|
|
114
|
+
n = cf + cm
|
|
115
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
116
|
+
rf, rm = cf / pyf, cm / pym
|
|
117
|
+
irr = rf / rm
|
|
118
|
+
idtn = (rf - rm) / (rf + rm)
|
|
119
|
+
p_lo = np.where(cf > 0, stats.beta.ppf(alpha / 2, cf, n - cf + 1), 0.0)
|
|
120
|
+
p_hi = np.where(cf < n, stats.beta.ppf(1 - alpha / 2, cf + 1, n - cf), 1.0)
|
|
121
|
+
k = pym / pyf
|
|
122
|
+
irr_lo = p_lo / (1 - p_lo) * k
|
|
123
|
+
irr_hi = np.where(p_hi >= 1, np.inf, p_hi / (1 - p_hi) * k)
|
|
124
|
+
bad = (n == 0) | (pyf <= 0) | (pym <= 0)
|
|
125
|
+
out = pd.DataFrame(
|
|
126
|
+
{
|
|
127
|
+
"rate_f": rf * 1e5,
|
|
128
|
+
"rate_m": rm * 1e5,
|
|
129
|
+
"rd_per_100k": (rf - rm) * 1e5,
|
|
130
|
+
"irr": irr,
|
|
131
|
+
"irr_lo": irr_lo,
|
|
132
|
+
"irr_hi": irr_hi,
|
|
133
|
+
"idtn": idtn,
|
|
134
|
+
"idtn_lo": idtn_from_ratio(irr_lo),
|
|
135
|
+
"idtn_hi": idtn_from_ratio(irr_hi),
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
out.loc[bad, ["irr", "irr_lo", "irr_hi", "idtn", "idtn_lo", "idtn_hi"]] = np.nan
|
|
139
|
+
out.loc[(pyf <= 0) | (pym <= 0), ["rate_f", "rate_m", "rd_per_100k"]] = np.nan
|
|
140
|
+
return out
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Strata for indirect standardisation, and every within-person pair of records.
|
|
2
|
+
|
|
3
|
+
`Strata` splits person-time by sex, age band, calendar year and, optionally,
|
|
4
|
+
prior-year healthcare-contact intensity; expected counts are person-time
|
|
5
|
+
times a reference rate per stratum. `person_pairs` enumerates ordered pairs of
|
|
6
|
+
a person's records in bounded blocks. Both were written for the disease-
|
|
7
|
+
trajectory method (Jensen et al. 2014) and work for any indirect standardisation.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
from bpskit.persontime import DAYS_PER_YEAR, age_bands, band_index, day_of, lexis, to_days
|
|
16
|
+
|
|
17
|
+
__all__ = ["Strata", "person_pairs"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Strata:
|
|
21
|
+
"""Sex x age band x calendar year x (optionally) prior-year contact intensity.
|
|
22
|
+
|
|
23
|
+
Stratum index s = ((sex * B + band) * Y + year) * C + level, sex 0 = F,
|
|
24
|
+
1 = M, so the F strata come first ("sex-major").
|
|
25
|
+
|
|
26
|
+
`contact_days` is a dense (persons x years) array of distinct record days in
|
|
27
|
+
the setting, first column = year `contact_year0`, cut at `contact_bins`
|
|
28
|
+
into C = len(bins) + 1 levels.
|
|
29
|
+
|
|
30
|
+
Why intensity, and why the previous year: people who visit more accumulate
|
|
31
|
+
more first records, so without it every pair looks associated. A yes/no
|
|
32
|
+
flag stratifies nothing when nearly everyone has a record every year. A
|
|
33
|
+
same-year count is partly made by the outcome, since D2's own first record
|
|
34
|
+
is a contact.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
BLOCK = 1_000_000 # rows per Lexis split: the (rows x bands) arrays stay under ~200 MB
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
bands: pd.DataFrame,
|
|
42
|
+
year_starts: np.ndarray,
|
|
43
|
+
contact_days=None,
|
|
44
|
+
contact_year0: int = 0,
|
|
45
|
+
contact_bins=(1,),
|
|
46
|
+
):
|
|
47
|
+
self.bands = bands
|
|
48
|
+
self.B = len(bands)
|
|
49
|
+
self.ys = np.asarray(year_starts, float) # Y + 1 boundaries, in days
|
|
50
|
+
self.Y = len(self.ys) - 1
|
|
51
|
+
self.year0 = int(pd.to_datetime(self.ys[0], unit="D").year)
|
|
52
|
+
self.coff = self.year0 - contact_year0 - 1 # column of the previous year
|
|
53
|
+
if contact_days is None:
|
|
54
|
+
self.levels, self.C = None, 1
|
|
55
|
+
else:
|
|
56
|
+
if self.coff < 0:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
"contact strata need a calendar year of records before the "
|
|
59
|
+
"first stratum year: set index_from a year after washout_from"
|
|
60
|
+
)
|
|
61
|
+
bins = np.asarray(sorted(contact_bins))
|
|
62
|
+
self.levels = np.searchsorted(bins, contact_days, side="right").astype(np.uint8)
|
|
63
|
+
self.C = len(bins) + 1
|
|
64
|
+
self.S = 2 * self.B * self.Y * self.C
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def for_analysis(
|
|
68
|
+
cls,
|
|
69
|
+
traj: dict,
|
|
70
|
+
ages: dict,
|
|
71
|
+
washout_from,
|
|
72
|
+
index_from,
|
|
73
|
+
last_date,
|
|
74
|
+
contacts=None,
|
|
75
|
+
year_starts=None,
|
|
76
|
+
):
|
|
77
|
+
"""The strata a trajectory analysis uses, built the same way by every rule.
|
|
78
|
+
|
|
79
|
+
`year_starts` defaults to 1 January of each year from `index_from`, with
|
|
80
|
+
the last boundary at the day after `last_date`.
|
|
81
|
+
"""
|
|
82
|
+
if year_starts is None:
|
|
83
|
+
end = day_of(last_date) + 1
|
|
84
|
+
y0, y1 = pd.Timestamp(index_from).year, pd.Timestamp(last_date).year
|
|
85
|
+
year_starts = to_days(
|
|
86
|
+
pd.Series(pd.to_datetime([f"{y}-01-01" for y in range(y0, y1 + 2)]))
|
|
87
|
+
)
|
|
88
|
+
year_starts[-1] = end
|
|
89
|
+
return cls(
|
|
90
|
+
age_bands(ages["band_width"], ages["open_band_from"]),
|
|
91
|
+
year_starts,
|
|
92
|
+
contacts,
|
|
93
|
+
contact_year0=pd.Timestamp(washout_from).year,
|
|
94
|
+
contact_bins=traj["contact_bins"],
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def level_shares(self) -> np.ndarray:
|
|
98
|
+
"""Share of persons at each intensity level, per stratum year (Y x C)."""
|
|
99
|
+
if self.levels is None:
|
|
100
|
+
return np.ones((self.Y, 1))
|
|
101
|
+
cols = self.levels[:, self.coff : self.coff + self.Y]
|
|
102
|
+
return np.stack([(cols == c).mean(axis=0) for c in range(self.C)], axis=1)
|
|
103
|
+
|
|
104
|
+
def _contact(self, person, y):
|
|
105
|
+
if self.levels is None:
|
|
106
|
+
return 0
|
|
107
|
+
return self.levels[person, y + self.coff].astype(np.int64)
|
|
108
|
+
|
|
109
|
+
def at(self, day, sex, birth, person) -> np.ndarray:
|
|
110
|
+
"""Stratum of single points in time (days outside the years are clipped)."""
|
|
111
|
+
y = np.clip(np.searchsorted(self.ys, day, side="right") - 1, 0, self.Y - 1)
|
|
112
|
+
b = band_index((day - birth) / DAYS_PER_YEAR, self.bands)
|
|
113
|
+
return ((sex * self.B + b) * self.Y + y) * self.C + self._contact(person, y)
|
|
114
|
+
|
|
115
|
+
def accumulate(self, acc: np.ndarray, key, sex, birth, entry, exit_, person) -> None:
|
|
116
|
+
"""acc[key, s] += person-years of [entry, exit) falling in stratum s, in place."""
|
|
117
|
+
for lo in range(0, len(entry), self.BLOCK):
|
|
118
|
+
sl = slice(lo, lo + self.BLOCK)
|
|
119
|
+
self._accumulate(acc, key[sl], sex[sl], birth[sl], entry[sl], exit_[sl], person[sl])
|
|
120
|
+
|
|
121
|
+
def _accumulate(self, acc, key, sex, birth, entry, exit_, person) -> None:
|
|
122
|
+
flat = acc.reshape(-1)
|
|
123
|
+
for y in range(self.Y):
|
|
124
|
+
lo = np.maximum(entry, self.ys[y])
|
|
125
|
+
hi = np.minimum(exit_, self.ys[y + 1])
|
|
126
|
+
m = hi > lo
|
|
127
|
+
if not m.any():
|
|
128
|
+
continue
|
|
129
|
+
bm = birth[m]
|
|
130
|
+
pt = lexis((lo[m] - bm) / DAYS_PER_YEAR, (hi[m] - bm) / DAYS_PER_YEAR, self.bands)
|
|
131
|
+
r, b = np.nonzero(pt)
|
|
132
|
+
s = ((sex[m][r] * self.B + b) * self.Y + y) * self.C + self._contact(person[m][r], y)
|
|
133
|
+
flat += np.bincount(key[m][r] * self.S + s, weights=pt[r, b], minlength=flat.size)
|
|
134
|
+
|
|
135
|
+
def integrate(self, rate: np.ndarray, col, sex, birth, entry, exit_, person) -> np.ndarray:
|
|
136
|
+
"""Expected count per row: sum over strata of person-years of [entry, exit) in s
|
|
137
|
+
times rate[s, col[row]].
|
|
138
|
+
|
|
139
|
+
Built exactly the way `accumulate` builds the bulk term, so a per-pair
|
|
140
|
+
correction computed with it can never exceed the bulk and drive an
|
|
141
|
+
expected count negative.
|
|
142
|
+
"""
|
|
143
|
+
if len(entry) > self.BLOCK:
|
|
144
|
+
return np.concatenate(
|
|
145
|
+
[
|
|
146
|
+
self.integrate(
|
|
147
|
+
rate,
|
|
148
|
+
col[i : i + self.BLOCK],
|
|
149
|
+
sex[i : i + self.BLOCK],
|
|
150
|
+
birth[i : i + self.BLOCK],
|
|
151
|
+
entry[i : i + self.BLOCK],
|
|
152
|
+
exit_[i : i + self.BLOCK],
|
|
153
|
+
person[i : i + self.BLOCK],
|
|
154
|
+
)
|
|
155
|
+
for i in range(0, len(entry), self.BLOCK)
|
|
156
|
+
]
|
|
157
|
+
)
|
|
158
|
+
out = np.zeros(len(entry))
|
|
159
|
+
for y in range(self.Y):
|
|
160
|
+
lo = np.maximum(entry, self.ys[y])
|
|
161
|
+
hi = np.minimum(exit_, self.ys[y + 1])
|
|
162
|
+
m = np.flatnonzero(hi > lo)
|
|
163
|
+
if not len(m):
|
|
164
|
+
continue
|
|
165
|
+
bm = birth[m]
|
|
166
|
+
pt = lexis((lo[m] - bm) / DAYS_PER_YEAR, (hi[m] - bm) / DAYS_PER_YEAR, self.bands)
|
|
167
|
+
r, b = np.nonzero(pt)
|
|
168
|
+
rows = m[r]
|
|
169
|
+
s = ((sex[rows] * self.B + b) * self.Y + y) * self.C + self._contact(person[rows], y)
|
|
170
|
+
out += np.bincount(rows, weights=pt[r, b] * rate[s, col[rows]], minlength=len(out))
|
|
171
|
+
return out
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def person_pairs(person: np.ndarray, max_pairs: int) -> Iterator[tuple[np.ndarray, np.ndarray]]:
|
|
175
|
+
"""Every ordered pair of distinct rows of the same person.
|
|
176
|
+
|
|
177
|
+
`person` must be sorted. Yields (row_a, row_b) index arrays in blocks of
|
|
178
|
+
about `max_pairs` pairs; one person's pairs are never split across blocks,
|
|
179
|
+
so a single person with more than sqrt(max_pairs) records makes a block
|
|
180
|
+
bigger than `max_pairs`.
|
|
181
|
+
"""
|
|
182
|
+
if not len(person):
|
|
183
|
+
return
|
|
184
|
+
starts = np.flatnonzero(np.r_[True, person[1:] != person[:-1]])
|
|
185
|
+
sizes = np.diff(np.r_[starts, len(person)]).astype(np.int64)
|
|
186
|
+
cum = np.cumsum(sizes * sizes)
|
|
187
|
+
g0 = 0
|
|
188
|
+
while g0 < len(starts):
|
|
189
|
+
base = cum[g0 - 1] if g0 else 0
|
|
190
|
+
g1 = max(int(np.searchsorted(cum, base + max_pairs, side="right")), g0 + 1)
|
|
191
|
+
st, sz = starts[g0:g1], sizes[g0:g1]
|
|
192
|
+
rows = np.arange(st[0], st[-1] + sz[-1])
|
|
193
|
+
rs, rz = np.repeat(st, sz), np.repeat(sz, sz)
|
|
194
|
+
a = np.repeat(rows, rz)
|
|
195
|
+
b = np.repeat(rs, rz) + np.arange(len(a)) - np.repeat(np.cumsum(rz) - rz, rz)
|
|
196
|
+
keep = a != b
|
|
197
|
+
yield a[keep], b[keep]
|
|
198
|
+
g0 = g1
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from scipy import stats
|
|
3
|
+
|
|
4
|
+
from bpskit.stats import exact_contrast, idtn_from_ratio
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_irr_interval_is_exact_clopper_pearson():
|
|
8
|
+
cf, cm, pyf, pym = 7.0, 3.0, 1000.0, 2000.0
|
|
9
|
+
out = exact_contrast([cf], [pyf], [cm], [pym], alpha=0.05).iloc[0]
|
|
10
|
+
ci = stats.binomtest(int(cf), int(cf + cm)).proportion_ci(0.95, method="exact")
|
|
11
|
+
to_irr = lambda p: p / (1 - p) * pym / pyf # noqa: E731
|
|
12
|
+
np.testing.assert_allclose([out.irr_lo, out.irr_hi], [to_irr(ci.low), to_irr(ci.high)])
|
|
13
|
+
np.testing.assert_allclose(out.irr, (cf / pyf) / (cm / pym))
|
|
14
|
+
np.testing.assert_allclose(out.idtn, idtn_from_ratio(out.irr))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_empty_cells_are_nan_not_infinite():
|
|
18
|
+
out = exact_contrast([0, 5], [100, 0], [0, 1], [100, 100], alpha=0.05)
|
|
19
|
+
assert out[["irr", "idtn"]].isna().all().all()
|
|
20
|
+
assert np.isnan(out.rate_f.iloc[1])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_idtn_of_infinite_ratio_is_one():
|
|
24
|
+
assert idtn_from_ratio(np.inf) == 1.0
|
|
25
|
+
assert idtn_from_ratio(1.0) == 0.0
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import itertools
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from bpskit.persontime import age_bands
|
|
6
|
+
from bpskit.strata import Strata, person_pairs
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_person_pairs_is_every_ordered_pair_within_a_person():
|
|
10
|
+
person = np.array([0, 0, 0, 1, 2, 2, 5, 5, 5, 5])
|
|
11
|
+
got = set()
|
|
12
|
+
for a, b in person_pairs(person, max_pairs=5):
|
|
13
|
+
got |= set(zip(a.tolist(), b.tolist()))
|
|
14
|
+
want = {
|
|
15
|
+
(i, j) for i, j in itertools.permutations(range(len(person)), 2) if person[i] == person[j]
|
|
16
|
+
}
|
|
17
|
+
assert got == want
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_strata_integrate_matches_accumulate():
|
|
21
|
+
bands = age_bands(5, 95)
|
|
22
|
+
ys = np.array([0.0, 365.0, 730.0, 1095.0])
|
|
23
|
+
st = Strata(bands, ys)
|
|
24
|
+
rng = np.random.default_rng(0)
|
|
25
|
+
n = 40
|
|
26
|
+
birth = rng.uniform(-30000, -10000, n)
|
|
27
|
+
entry = rng.uniform(0, 600, n)
|
|
28
|
+
exit_ = entry + rng.uniform(0, 600, n)
|
|
29
|
+
sex = rng.integers(0, 2, n)
|
|
30
|
+
person = np.arange(n)
|
|
31
|
+
acc = np.zeros((1, st.S))
|
|
32
|
+
st.accumulate(acc, np.zeros(n, np.int64), sex, birth, entry, exit_, person)
|
|
33
|
+
rate = rng.uniform(0, 1, (st.S, 1))
|
|
34
|
+
got = st.integrate(rate, np.zeros(n, np.int64), sex, birth, entry, exit_, person).sum()
|
|
35
|
+
np.testing.assert_allclose(got, acc[0] @ rate[:, 0])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|