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.
@@ -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
@@ -8,7 +8,7 @@ authors:
8
8
  email: clou@us.es
9
9
  license: MIT
10
10
  repository-code: "https://github.com/loucerac/bpskit"
11
- version: 0.2.0
11
+ version: 0.3.0
12
12
  keywords:
13
13
  - epidemiology
14
14
  - electronic health records
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bpskit
3
- Version: 0.2.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 "bpskit @ git+https://github.com/loucerac/bpskit.git@v0.2.0"
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 = { git = "https://github.com/loucerac/bpskit.git", tag = "v0.2.0" }
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
- - git+https://github.com/loucerac/bpskit.git@v0.2.0
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 "bpskit @ git+https://github.com/loucerac/bpskit.git@v0.2.0"
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 = { git = "https://github.com/loucerac/bpskit.git", tag = "v0.2.0" }
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
- - git+https://github.com/loucerac/bpskit.git@v0.2.0
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, BH / Bonferroni
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.2.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__ = ["bh", "adjust", "poisson_rr", "binom_half_p", "binom_p"]
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