bpskit 0.2.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/.gitignore +7 -0
- bpskit-0.2.0/CHANGELOG.md +29 -0
- bpskit-0.2.0/CITATION.cff +16 -0
- bpskit-0.2.0/LICENSE +21 -0
- bpskit-0.2.0/PKG-INFO +181 -0
- bpskit-0.2.0/README.md +153 -0
- bpskit-0.2.0/examples/quickstart.py +91 -0
- bpskit-0.2.0/pyproject.toml +56 -0
- bpskit-0.2.0/src/bpskit/__init__.py +14 -0
- bpskit-0.2.0/src/bpskit/cli.py +102 -0
- bpskit-0.2.0/src/bpskit/delivery.py +153 -0
- bpskit-0.2.0/src/bpskit/icd.py +191 -0
- bpskit-0.2.0/src/bpskit/persontime.py +132 -0
- bpskit-0.2.0/src/bpskit/py.typed +0 -0
- bpskit-0.2.0/src/bpskit/stats.py +76 -0
- bpskit-0.2.0/src/bpskit/synthetic.py +179 -0
- bpskit-0.2.0/src/bpskit/transfer.py +211 -0
- bpskit-0.2.0/tests/test_icd.py +50 -0
- bpskit-0.2.0/tests/test_persontime.py +23 -0
- bpskit-0.2.0/tests/test_stats.py +27 -0
- bpskit-0.2.0/tests/test_synthetic.py +26 -0
- bpskit-0.2.0/tests/test_transfer.py +62 -0
bpskit-0.2.0/.gitignore
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to bpskit. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the versions
|
|
5
|
+
[Semantic Versioning](https://semver.org/): until 1.0, a minor release may
|
|
6
|
+
change the public API (the names in each module's `__all__`).
|
|
7
|
+
|
|
8
|
+
## [0.2.0]
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `bpskit.synthetic`: a synthetic BPS delivery with planted structure
|
|
12
|
+
(`make_delivery`, `config`, `FILES`), and `bpskit synthetic` on the command line.
|
|
13
|
+
- `examples/quickstart.py`, a worked example run in CI.
|
|
14
|
+
- A declared public API (`__all__` in every module), type hints and `py.typed`.
|
|
15
|
+
- Packaging metadata, a release workflow publishing to PyPI by trusted
|
|
16
|
+
publishing, `CITATION.cff`.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- The version is read from `bpskit/__init__.py` (one place to bump).
|
|
20
|
+
|
|
21
|
+
## [0.1.0]
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
- `delivery`, `icd`, `persontime`, `stats` and `transfer`, extracted from the
|
|
25
|
+
BPS sex-differences workflow, and the `bpskit pullable` / `bpskit check-logs`
|
|
26
|
+
command line.
|
|
27
|
+
|
|
28
|
+
[0.2.0]: https://github.com/loucerac/bpskit/compare/v0.1.0...v0.2.0
|
|
29
|
+
[0.1.0]: https://github.com/loucerac/bpskit/releases/tag/v0.1.0
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
cff-version: 1.2.0
|
|
2
|
+
message: "If you use bpskit, please cite it as below."
|
|
3
|
+
title: "bpskit: tools for studies on a BPS health-records delivery"
|
|
4
|
+
type: software
|
|
5
|
+
authors:
|
|
6
|
+
- family-names: Loucera
|
|
7
|
+
given-names: Carlos
|
|
8
|
+
email: clou@us.es
|
|
9
|
+
license: MIT
|
|
10
|
+
repository-code: "https://github.com/loucerac/bpskit"
|
|
11
|
+
version: 0.2.0
|
|
12
|
+
keywords:
|
|
13
|
+
- epidemiology
|
|
14
|
+
- electronic health records
|
|
15
|
+
- ICD-10
|
|
16
|
+
- disclosure control
|
bpskit-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Carlos Loucera
|
|
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.
|
bpskit-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: bpskit
|
|
3
|
+
Version: 0.2.0
|
|
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
|
+
Project-URL: Homepage, https://github.com/loucerac/bpskit
|
|
6
|
+
Project-URL: Changelog, https://github.com/loucerac/bpskit/blob/main/CHANGELOG.md
|
|
7
|
+
Project-URL: Issues, https://github.com/loucerac/bpskit/issues
|
|
8
|
+
Author-email: Carlos Loucera <clou@us.es>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ICD-10,ICD-9,disclosure control,electronic health records,epidemiology,person-time,snakemake,trusted research environment
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: icd-mappings<0.7,>=0.6.2
|
|
22
|
+
Requires-Dist: numpy>=2
|
|
23
|
+
Requires-Dist: pandas>=2.2
|
|
24
|
+
Requires-Dist: pyarrow>=15
|
|
25
|
+
Requires-Dist: pyyaml>=6
|
|
26
|
+
Requires-Dist: scipy>=1.11
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# bpskit
|
|
30
|
+
|
|
31
|
+
Tools for studies on a BPS (Base Poblacional de Salud, Servicio Andaluz de
|
|
32
|
+
Salud) health-records delivery, and for working with pseudonymised
|
|
33
|
+
health-record extracts in a secure data environment generally.
|
|
34
|
+
|
|
35
|
+
- **Read the delivery.** Pipe-delimited files read as text, sentinel codes for
|
|
36
|
+
"unknown", month- and day-precision dates, streaming in fixed-size blocks so a
|
|
37
|
+
400-million-row file never sits in memory.
|
|
38
|
+
- **ICD codes.** Code specifications like `A15-A19, H35.3`, ICD-10 chapters,
|
|
39
|
+
and ICD-9 → ICD-10 mapping with the CMS GEM. That includes the V/E
|
|
40
|
+
supplementary codes, which are also valid ICD-10 and can only be placed by
|
|
41
|
+
the record's date relative to a changeover.
|
|
42
|
+
- **Person-time.** Dates as days since 1970, age bands, Lexis splitting,
|
|
43
|
+
person-sorted streaming.
|
|
44
|
+
- **Exact tests.** Poisson rate ratios (Garwood intervals), binomial tests,
|
|
45
|
+
BH and Bonferroni.
|
|
46
|
+
- **An output check for leaving the secure environment.** `bpskit.transfer`
|
|
47
|
+
refuses any file that contains an identifier the workflow read, or a
|
|
48
|
+
per-person column, and fails closed when it cannot read something.
|
|
49
|
+
- **A synthetic delivery** with BPS column names and planted structure, for
|
|
50
|
+
tests and examples.
|
|
51
|
+
|
|
52
|
+
## Installing
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install "bpskit @ git+https://github.com/loucerac/bpskit.git@v0.2.0"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
In a pixi project (`pixi.toml`):
|
|
59
|
+
|
|
60
|
+
```toml
|
|
61
|
+
[pypi-dependencies]
|
|
62
|
+
bpskit = { git = "https://github.com/loucerac/bpskit.git", tag = "v0.2.0" }
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
In a Snakemake rule environment (`workflow/envs/*.yaml`):
|
|
66
|
+
|
|
67
|
+
```yaml
|
|
68
|
+
dependencies:
|
|
69
|
+
- pip
|
|
70
|
+
- pip:
|
|
71
|
+
- git+https://github.com/loucerac/bpskit.git@v0.2.0
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Pin a release. Bumping the tag changes the environment file, and Snakemake's
|
|
75
|
+
`software-env` rerun trigger reruns the rules that use it. A module imported
|
|
76
|
+
from a sibling folder gets no such tracking.
|
|
77
|
+
|
|
78
|
+
## A worked example
|
|
79
|
+
|
|
80
|
+
[`examples/quickstart.py`](examples/quickstart.py) runs in a few seconds with no
|
|
81
|
+
real data:
|
|
82
|
+
|
|
83
|
+
1. it writes a 5,000-person synthetic delivery;
|
|
84
|
+
2. it measures the incidence of type 2 diabetes (E11) in primary care in
|
|
85
|
+
2018-2025;
|
|
86
|
+
3. it compares women with the whole population;
|
|
87
|
+
4. it runs the transfer check on the result.
|
|
88
|
+
|
|
89
|
+
The core of it:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from bpskit import transfer
|
|
93
|
+
from bpskit.delivery import normalise_code, parse_date, read_delivery, stream_csv
|
|
94
|
+
from bpskit.icd import expand_spec, prefix_mask
|
|
95
|
+
from bpskit.persontime import day_of, follow_up, to_days
|
|
96
|
+
from bpskit.stats import poisson_rr
|
|
97
|
+
from bpskit.synthetic import make_delivery
|
|
98
|
+
|
|
99
|
+
cfg = make_delivery("work/delivery", n_persons=5_000) # files + how to read them
|
|
100
|
+
|
|
101
|
+
# people: birth and death as days since 1970 (NaN = alive)
|
|
102
|
+
u = cfg["universe"]
|
|
103
|
+
people = read_delivery(f"work/delivery/{u['file']}", [u["id"], u["sex"], u["birth"], u["death"]])
|
|
104
|
+
birth = to_days(parse_date(people[u["birth"]], u["date_format"], ["", "-1"], mid_month_day=15))
|
|
105
|
+
|
|
106
|
+
# first E11 record per person, streamed block by block
|
|
107
|
+
ap = cfg["sources"]["ap"]
|
|
108
|
+
for block in stream_csv(
|
|
109
|
+
f"work/delivery/{ap['file']}", [ap["id"], ap["date"], ap["code"]], 16, "utf-8"
|
|
110
|
+
):
|
|
111
|
+
hits = block[prefix_mask(normalise_code(block[ap["code"]]), expand_spec("E11"))]
|
|
112
|
+
...
|
|
113
|
+
|
|
114
|
+
# follow-up, then observed vs expected with an exact interval
|
|
115
|
+
entry, exit_, in_period = follow_up(birth, death, day_of("2018-01-01"), day_of("2025-12-31"))
|
|
116
|
+
ratio, lo, hi, p = poisson_rr(observed, expected, alpha=0.05)
|
|
117
|
+
|
|
118
|
+
# nothing leaves the machine that names a patient
|
|
119
|
+
n, problems = transfer.check(
|
|
120
|
+
["work/results/out"],
|
|
121
|
+
ids=set(people[u["id"]]),
|
|
122
|
+
strict=True,
|
|
123
|
+
forbidden=transfer.forbidden_columns(cfg),
|
|
124
|
+
)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
E11 incidence 1,258 per 100,000 person-years; women vs all: 1.12 (0.98-1.27)
|
|
129
|
+
aggregate table: 1 file checked, 0 problem(s)
|
|
130
|
+
with a leaked file:
|
|
131
|
+
.../first_cases.csv: contains 3 patient identifier(s)
|
|
132
|
+
.../first_cases.csv: per-person or identifier column(s) ['patient']
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## The transfer check
|
|
136
|
+
|
|
137
|
+
The rule it enforces: no row carrying a patient identifier leaves the machine
|
|
138
|
+
that holds the delivery. It checks the bytes of every file against the
|
|
139
|
+
identifiers the workflow actually read, not against a guessed id format. A
|
|
140
|
+
workflow writes those identifiers with `transfer.write_ids`:
|
|
141
|
+
|
|
142
|
+
- `results/work/ids/universe.parquet`: every id in the people file, written
|
|
143
|
+
before anyone is dropped;
|
|
144
|
+
- `results/work/ids/<source>.parquet`: ids in a diagnosis file but not in the
|
|
145
|
+
people file.
|
|
146
|
+
|
|
147
|
+
Where an id file is missing (a run that died early), the check reads the
|
|
148
|
+
delivery file instead. An unreadable file, an unknown suffix or an empty id set
|
|
149
|
+
all refuse. With `strict=True` (for a results folder copied off wholesale),
|
|
150
|
+
only csv/txt/png pass. CSVs whose header holds a per-person key, one of the
|
|
151
|
+
delivery's id or date columns, or an identifier-like name are refused too.
|
|
152
|
+
|
|
153
|
+
From a workflow directory on the data machine:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
bpskit pullable --strict results/out # exit 0 only if results/out/ may leave
|
|
157
|
+
bpskit check-logs . # list the logs that may leave
|
|
158
|
+
bpskit synthetic data/synthetic --persons 2000
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`pullable` and `check-logs` read the workflow's `config/config.yaml`
|
|
162
|
+
(`--config` to change it). Its `universe` and `sources` sections, in the shape
|
|
163
|
+
`bpskit.synthetic.config()` returns, name the id columns.
|
|
164
|
+
|
|
165
|
+
## Developing
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
pixi run test # pytest
|
|
169
|
+
pixi run lint # ruff check + format check
|
|
170
|
+
pixi run example # the worked example
|
|
171
|
+
pixi run build # sdist + wheel, twine check
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Releasing: bump `__version__` in `src/bpskit/__init__.py`, add the release to
|
|
175
|
+
`CHANGELOG.md`, commit, then tag `vX.Y.Z` and push the tag.
|
|
176
|
+
`.github/workflows/release.yml` checks that the tag matches the version,
|
|
177
|
+
builds, and publishes to PyPI by trusted publishing.
|
|
178
|
+
|
|
179
|
+
## Citing
|
|
180
|
+
|
|
181
|
+
See [`CITATION.cff`](CITATION.cff). MIT licensed.
|
bpskit-0.2.0/README.md
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# bpskit
|
|
2
|
+
|
|
3
|
+
Tools for studies on a BPS (Base Poblacional de Salud, Servicio Andaluz de
|
|
4
|
+
Salud) health-records delivery, and for working with pseudonymised
|
|
5
|
+
health-record extracts in a secure data environment generally.
|
|
6
|
+
|
|
7
|
+
- **Read the delivery.** Pipe-delimited files read as text, sentinel codes for
|
|
8
|
+
"unknown", month- and day-precision dates, streaming in fixed-size blocks so a
|
|
9
|
+
400-million-row file never sits in memory.
|
|
10
|
+
- **ICD codes.** Code specifications like `A15-A19, H35.3`, ICD-10 chapters,
|
|
11
|
+
and ICD-9 → ICD-10 mapping with the CMS GEM. That includes the V/E
|
|
12
|
+
supplementary codes, which are also valid ICD-10 and can only be placed by
|
|
13
|
+
the record's date relative to a changeover.
|
|
14
|
+
- **Person-time.** Dates as days since 1970, age bands, Lexis splitting,
|
|
15
|
+
person-sorted streaming.
|
|
16
|
+
- **Exact tests.** Poisson rate ratios (Garwood intervals), binomial tests,
|
|
17
|
+
BH and Bonferroni.
|
|
18
|
+
- **An output check for leaving the secure environment.** `bpskit.transfer`
|
|
19
|
+
refuses any file that contains an identifier the workflow read, or a
|
|
20
|
+
per-person column, and fails closed when it cannot read something.
|
|
21
|
+
- **A synthetic delivery** with BPS column names and planted structure, for
|
|
22
|
+
tests and examples.
|
|
23
|
+
|
|
24
|
+
## Installing
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install "bpskit @ git+https://github.com/loucerac/bpskit.git@v0.2.0"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
In a pixi project (`pixi.toml`):
|
|
31
|
+
|
|
32
|
+
```toml
|
|
33
|
+
[pypi-dependencies]
|
|
34
|
+
bpskit = { git = "https://github.com/loucerac/bpskit.git", tag = "v0.2.0" }
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
In a Snakemake rule environment (`workflow/envs/*.yaml`):
|
|
38
|
+
|
|
39
|
+
```yaml
|
|
40
|
+
dependencies:
|
|
41
|
+
- pip
|
|
42
|
+
- pip:
|
|
43
|
+
- git+https://github.com/loucerac/bpskit.git@v0.2.0
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Pin a release. Bumping the tag changes the environment file, and Snakemake's
|
|
47
|
+
`software-env` rerun trigger reruns the rules that use it. A module imported
|
|
48
|
+
from a sibling folder gets no such tracking.
|
|
49
|
+
|
|
50
|
+
## A worked example
|
|
51
|
+
|
|
52
|
+
[`examples/quickstart.py`](examples/quickstart.py) runs in a few seconds with no
|
|
53
|
+
real data:
|
|
54
|
+
|
|
55
|
+
1. it writes a 5,000-person synthetic delivery;
|
|
56
|
+
2. it measures the incidence of type 2 diabetes (E11) in primary care in
|
|
57
|
+
2018-2025;
|
|
58
|
+
3. it compares women with the whole population;
|
|
59
|
+
4. it runs the transfer check on the result.
|
|
60
|
+
|
|
61
|
+
The core of it:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from bpskit import transfer
|
|
65
|
+
from bpskit.delivery import normalise_code, parse_date, read_delivery, stream_csv
|
|
66
|
+
from bpskit.icd import expand_spec, prefix_mask
|
|
67
|
+
from bpskit.persontime import day_of, follow_up, to_days
|
|
68
|
+
from bpskit.stats import poisson_rr
|
|
69
|
+
from bpskit.synthetic import make_delivery
|
|
70
|
+
|
|
71
|
+
cfg = make_delivery("work/delivery", n_persons=5_000) # files + how to read them
|
|
72
|
+
|
|
73
|
+
# people: birth and death as days since 1970 (NaN = alive)
|
|
74
|
+
u = cfg["universe"]
|
|
75
|
+
people = read_delivery(f"work/delivery/{u['file']}", [u["id"], u["sex"], u["birth"], u["death"]])
|
|
76
|
+
birth = to_days(parse_date(people[u["birth"]], u["date_format"], ["", "-1"], mid_month_day=15))
|
|
77
|
+
|
|
78
|
+
# first E11 record per person, streamed block by block
|
|
79
|
+
ap = cfg["sources"]["ap"]
|
|
80
|
+
for block in stream_csv(
|
|
81
|
+
f"work/delivery/{ap['file']}", [ap["id"], ap["date"], ap["code"]], 16, "utf-8"
|
|
82
|
+
):
|
|
83
|
+
hits = block[prefix_mask(normalise_code(block[ap["code"]]), expand_spec("E11"))]
|
|
84
|
+
...
|
|
85
|
+
|
|
86
|
+
# follow-up, then observed vs expected with an exact interval
|
|
87
|
+
entry, exit_, in_period = follow_up(birth, death, day_of("2018-01-01"), day_of("2025-12-31"))
|
|
88
|
+
ratio, lo, hi, p = poisson_rr(observed, expected, alpha=0.05)
|
|
89
|
+
|
|
90
|
+
# nothing leaves the machine that names a patient
|
|
91
|
+
n, problems = transfer.check(
|
|
92
|
+
["work/results/out"],
|
|
93
|
+
ids=set(people[u["id"]]),
|
|
94
|
+
strict=True,
|
|
95
|
+
forbidden=transfer.forbidden_columns(cfg),
|
|
96
|
+
)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
E11 incidence 1,258 per 100,000 person-years; women vs all: 1.12 (0.98-1.27)
|
|
101
|
+
aggregate table: 1 file checked, 0 problem(s)
|
|
102
|
+
with a leaked file:
|
|
103
|
+
.../first_cases.csv: contains 3 patient identifier(s)
|
|
104
|
+
.../first_cases.csv: per-person or identifier column(s) ['patient']
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## The transfer check
|
|
108
|
+
|
|
109
|
+
The rule it enforces: no row carrying a patient identifier leaves the machine
|
|
110
|
+
that holds the delivery. It checks the bytes of every file against the
|
|
111
|
+
identifiers the workflow actually read, not against a guessed id format. A
|
|
112
|
+
workflow writes those identifiers with `transfer.write_ids`:
|
|
113
|
+
|
|
114
|
+
- `results/work/ids/universe.parquet`: every id in the people file, written
|
|
115
|
+
before anyone is dropped;
|
|
116
|
+
- `results/work/ids/<source>.parquet`: ids in a diagnosis file but not in the
|
|
117
|
+
people file.
|
|
118
|
+
|
|
119
|
+
Where an id file is missing (a run that died early), the check reads the
|
|
120
|
+
delivery file instead. An unreadable file, an unknown suffix or an empty id set
|
|
121
|
+
all refuse. With `strict=True` (for a results folder copied off wholesale),
|
|
122
|
+
only csv/txt/png pass. CSVs whose header holds a per-person key, one of the
|
|
123
|
+
delivery's id or date columns, or an identifier-like name are refused too.
|
|
124
|
+
|
|
125
|
+
From a workflow directory on the data machine:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
bpskit pullable --strict results/out # exit 0 only if results/out/ may leave
|
|
129
|
+
bpskit check-logs . # list the logs that may leave
|
|
130
|
+
bpskit synthetic data/synthetic --persons 2000
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`pullable` and `check-logs` read the workflow's `config/config.yaml`
|
|
134
|
+
(`--config` to change it). Its `universe` and `sources` sections, in the shape
|
|
135
|
+
`bpskit.synthetic.config()` returns, name the id columns.
|
|
136
|
+
|
|
137
|
+
## Developing
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
pixi run test # pytest
|
|
141
|
+
pixi run lint # ruff check + format check
|
|
142
|
+
pixi run example # the worked example
|
|
143
|
+
pixi run build # sdist + wheel, twine check
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Releasing: bump `__version__` in `src/bpskit/__init__.py`, add the release to
|
|
147
|
+
`CHANGELOG.md`, commit, then tag `vX.Y.Z` and push the tag.
|
|
148
|
+
`.github/workflows/release.yml` checks that the tag matches the version,
|
|
149
|
+
builds, and publishes to PyPI by trusted publishing.
|
|
150
|
+
|
|
151
|
+
## Citing
|
|
152
|
+
|
|
153
|
+
See [`CITATION.cff`](CITATION.cff). MIT licensed.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""bpskit end to end on a synthetic delivery, in a few seconds.
|
|
2
|
+
|
|
3
|
+
Writes a 5,000-person synthetic BPS delivery, measures the incidence of type 2
|
|
4
|
+
diabetes (ICD-10 E11) in primary care in 2018-2025, compares women with the
|
|
5
|
+
whole population, and runs the transfer check on the result table: once on the
|
|
6
|
+
aggregate table (it passes), once on a file that names a patient (it is refused).
|
|
7
|
+
|
|
8
|
+
python examples/quickstart.py [work_dir]
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import pandas as pd
|
|
17
|
+
|
|
18
|
+
from bpskit import transfer
|
|
19
|
+
from bpskit.delivery import normalise_code, parse_date, read_delivery, stream_csv
|
|
20
|
+
from bpskit.icd import expand_spec, prefix_mask
|
|
21
|
+
from bpskit.persontime import DAYS_PER_YEAR, day_of, follow_up, to_days
|
|
22
|
+
from bpskit.stats import poisson_rr
|
|
23
|
+
from bpskit.synthetic import make_delivery
|
|
24
|
+
|
|
25
|
+
work = Path(sys.argv[1] if len(sys.argv) > 1 else tempfile.mkdtemp())
|
|
26
|
+
cfg = make_delivery(work / "delivery", n_persons=5_000)
|
|
27
|
+
root = Path(cfg["delivery_root"])
|
|
28
|
+
sentinels = ["", "-1", "-2", "-9"]
|
|
29
|
+
|
|
30
|
+
# 1. People: sex, and birth and death as days since 1970 (NaN = alive).
|
|
31
|
+
u = cfg["universe"]
|
|
32
|
+
people = read_delivery(root / u["file"], [u["id"], u["sex"], u["birth"], u["death"]])
|
|
33
|
+
is_f = people[u["sex"]].map(u["sex_values"]).eq("F").to_numpy()
|
|
34
|
+
birth = to_days(parse_date(people[u["birth"]], u["date_format"], sentinels, 15))
|
|
35
|
+
death = to_days(parse_date(people[u["death"]], u["date_format"], sentinels, 15))
|
|
36
|
+
|
|
37
|
+
# 2. Each person's first primary-care record of E11, streamed block by block.
|
|
38
|
+
ap = cfg["sources"]["ap"]
|
|
39
|
+
prefixes = expand_spec("E11")
|
|
40
|
+
first: dict[str, float] = {}
|
|
41
|
+
for block in stream_csv(root / ap["file"], [ap["id"], ap["date"], ap["code"]], 16, "utf-8"):
|
|
42
|
+
hit = block[prefix_mask(normalise_code(block[ap["code"]]), prefixes)]
|
|
43
|
+
days = to_days(parse_date(hit[ap["date"]], ap["date_format"], sentinels, 15))
|
|
44
|
+
for pid, d in zip(hit[ap["id"]], days):
|
|
45
|
+
first[pid] = min(d, first.get(pid, np.inf))
|
|
46
|
+
event = people[u["id"]].map(first).to_numpy(float)
|
|
47
|
+
|
|
48
|
+
# 3. Follow-up 2018-2025. A record before the start makes a person prevalent and
|
|
49
|
+
# removes them from the risk set; an incident record ends their follow-up.
|
|
50
|
+
entry, exit_, in_period = follow_up(birth, death, day_of("2018-01-01"), day_of("2025-12-31"))
|
|
51
|
+
at_risk = in_period & ~(event < entry)
|
|
52
|
+
incident = at_risk & (event <= exit_)
|
|
53
|
+
exit_ = np.where(incident, event, exit_)
|
|
54
|
+
years = np.where(at_risk, (exit_ - entry) / DAYS_PER_YEAR, 0.0)
|
|
55
|
+
|
|
56
|
+
# 4. Women against the whole population: observed cases over those expected at
|
|
57
|
+
# the overall rate, with an exact Poisson interval.
|
|
58
|
+
rate = incident.sum() / years.sum()
|
|
59
|
+
obs, exp = incident[is_f].sum(), years[is_f].sum() * rate
|
|
60
|
+
ratio, lo, hi, p = poisson_rr(obs, exp, alpha=0.05)
|
|
61
|
+
table = pd.DataFrame(
|
|
62
|
+
{
|
|
63
|
+
"group": ["women"],
|
|
64
|
+
"cases": [obs],
|
|
65
|
+
"expected": [exp],
|
|
66
|
+
"ratio": [ratio],
|
|
67
|
+
"ratio_lo": [lo],
|
|
68
|
+
"ratio_hi": [hi],
|
|
69
|
+
"p": [p],
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
print(
|
|
73
|
+
f"E11 incidence {rate * 1e5:,.0f} per 100,000 person-years; women vs all: "
|
|
74
|
+
f"{float(ratio):.2f} ({float(lo):.2f}-{float(hi):.2f})"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# 5. The transfer check: no file leaving the machine may name a patient.
|
|
78
|
+
out = work / "results/out"
|
|
79
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
table.to_csv(out / "e11_incidence.csv", index=False)
|
|
81
|
+
ids = set(people[u["id"]])
|
|
82
|
+
forbidden = transfer.forbidden_columns(cfg)
|
|
83
|
+
n, problems = transfer.check([out], ids, strict=True, forbidden=forbidden)
|
|
84
|
+
print(f"aggregate table: {n} file checked, {len(problems)} problem(s)")
|
|
85
|
+
assert not problems
|
|
86
|
+
|
|
87
|
+
leak = out / "first_cases.csv"
|
|
88
|
+
pd.DataFrame({"patient": list(first)[:3]}).to_csv(leak, index=False)
|
|
89
|
+
n, problems = transfer.check([out], ids, strict=True, forbidden=forbidden)
|
|
90
|
+
print("with a leaked file:", *problems, sep="\n ")
|
|
91
|
+
assert problems and all(str(leak) in p for p in problems)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "bpskit"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "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
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "Carlos Loucera", email = "clou@us.es" }]
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
keywords = ["epidemiology", "electronic health records", "ICD-10", "ICD-9", "person-time",
|
|
11
|
+
"trusted research environment", "disclosure control", "snakemake"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Intended Audience :: Science/Research",
|
|
15
|
+
"Operating System :: POSIX :: Linux",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Topic :: Scientific/Engineering :: Medical Science Apps.",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"numpy>=2",
|
|
24
|
+
"pandas>=2.2",
|
|
25
|
+
"scipy>=1.11",
|
|
26
|
+
"pyarrow>=15",
|
|
27
|
+
"pyyaml>=6",
|
|
28
|
+
"icd-mappings>=0.6.2,<0.7",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/loucerac/bpskit"
|
|
33
|
+
Changelog = "https://github.com/loucerac/bpskit/blob/main/CHANGELOG.md"
|
|
34
|
+
Issues = "https://github.com/loucerac/bpskit/issues"
|
|
35
|
+
|
|
36
|
+
[project.scripts]
|
|
37
|
+
bpskit = "bpskit.cli:main"
|
|
38
|
+
|
|
39
|
+
[build-system]
|
|
40
|
+
requires = ["hatchling>=1.26"]
|
|
41
|
+
build-backend = "hatchling.build"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.version]
|
|
44
|
+
path = "src/bpskit/__init__.py"
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
testpaths = ["tests"]
|
|
48
|
+
|
|
49
|
+
[tool.ruff]
|
|
50
|
+
line-length = 100
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint]
|
|
53
|
+
select = ["E9", "E501", "F", "I"]
|
|
54
|
+
|
|
55
|
+
[tool.hatch.build.targets.sdist]
|
|
56
|
+
include = ["src", "tests", "examples", "README.md", "CHANGELOG.md", "CITATION.cff", "LICENSE"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Shared tools for studies on a BPS (Base Poblacional de Salud, SAS) delivery.
|
|
2
|
+
|
|
3
|
+
bpskit.delivery read the pipe-delimited delivery: sentinels, dates, streaming
|
|
4
|
+
bpskit.icd ICD-10 code specs and chapters, ICD-9 -> ICD-10 mapping (CMS GEM)
|
|
5
|
+
bpskit.persontime dates as numbers, age bands, Lexis splitting, person streaming
|
|
6
|
+
bpskit.stats exact Poisson and binomial tests, BH / Bonferroni
|
|
7
|
+
bpskit.transfer the check that no file leaving the data machine names a patient
|
|
8
|
+
bpskit.synthetic a synthetic BPS delivery with planted structure, for tests
|
|
9
|
+
|
|
10
|
+
The command line (`bpskit pullable`, `bpskit check-logs`) runs the transfer
|
|
11
|
+
check on the data machine.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""The transfer checks, run on the data machine before anything is copied off it.
|
|
2
|
+
|
|
3
|
+
bpskit pullable [--strict] <path> ... exit 0 only if no file names a patient
|
|
4
|
+
bpskit check-logs [workflow_dir] list the logs that may leave
|
|
5
|
+
bpskit synthetic <out_dir> write a synthetic delivery (bpskit.synthetic)
|
|
6
|
+
|
|
7
|
+
pullable and check-logs run from the workflow directory and read its config
|
|
8
|
+
(default config/config.yaml) for the delivery's id columns and the id files
|
|
9
|
+
under results/work/ids/.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from bpskit import transfer
|
|
17
|
+
from bpskit.delivery import load_config
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def pullable(args: argparse.Namespace) -> int:
|
|
21
|
+
root = Path.cwd()
|
|
22
|
+
cfg = load_config(root / args.config)
|
|
23
|
+
ids = transfer.ids_for_root(root, cfg)
|
|
24
|
+
n, problems = transfer.check(args.paths, ids, args.strict, transfer.forbidden_columns(cfg))
|
|
25
|
+
print(f"[CHECK] {n} file(s), {len(problems)} problem(s)")
|
|
26
|
+
for p in problems:
|
|
27
|
+
print(f"[REFUSE] {p}")
|
|
28
|
+
if problems:
|
|
29
|
+
print("No transfer: these files stay on this machine.")
|
|
30
|
+
return 1
|
|
31
|
+
print("[PASS ] no file names a patient")
|
|
32
|
+
return 0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def check_logs(args: argparse.Namespace) -> int:
|
|
36
|
+
"""Write the clean logs to results/work/logs_pullable.txt (which sync.sh copies).
|
|
37
|
+
|
|
38
|
+
A traceback can print a data row, so a log containing an id is WITHHELD,
|
|
39
|
+
not redacted. The list is emptied first, so any failure leaves nothing listed.
|
|
40
|
+
"""
|
|
41
|
+
root = Path(args.workflow_dir).resolve()
|
|
42
|
+
out = root / "results/work/logs_pullable.txt"
|
|
43
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
out.write_text("")
|
|
45
|
+
ids = transfer.ids_for_root(root, load_config(root / args.config))
|
|
46
|
+
candidates = sorted(
|
|
47
|
+
list((root / "results/work/logs").rglob("*.log"))
|
|
48
|
+
+ list((root / ".snakemake/log").glob("*.snakemake.log"))
|
|
49
|
+
+ [p for p in [root / "results_run.txt"] if p.exists()]
|
|
50
|
+
)
|
|
51
|
+
clean, withheld = [], []
|
|
52
|
+
for f in candidates:
|
|
53
|
+
try:
|
|
54
|
+
why = transfer.check_file(f, ids, strict=False, forbidden=set())
|
|
55
|
+
except Exception as exc: # noqa: BLE001 -- fail closed: any error withholds
|
|
56
|
+
why = [f"could not be checked: {exc}"]
|
|
57
|
+
(withheld if why else clean).append((f, why))
|
|
58
|
+
out.write_text("".join(f"{f.relative_to(root).as_posix()}\n" for f, _ in clean))
|
|
59
|
+
for f, why in withheld:
|
|
60
|
+
print(f"WITHHELD ({'; '.join(why)}): {f.relative_to(root)}")
|
|
61
|
+
print(f"{len(clean)} of {len(candidates)} logs cleared")
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def synthetic(args: argparse.Namespace) -> int:
|
|
66
|
+
from bpskit.synthetic import make_delivery
|
|
67
|
+
|
|
68
|
+
make_delivery(args.out_dir, args.persons, args.seed)
|
|
69
|
+
print(f"{args.persons:,} synthetic persons -> {args.out_dir}")
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def main(argv: list[str] | None = None) -> int:
|
|
74
|
+
parser = argparse.ArgumentParser(prog="bpskit", description=__doc__.split("\n")[0])
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
"--config",
|
|
77
|
+
default="config/config.yaml",
|
|
78
|
+
help="workflow config, relative to the workflow directory",
|
|
79
|
+
)
|
|
80
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
81
|
+
p = sub.add_parser("pullable", help="refuse files that name a patient")
|
|
82
|
+
p.add_argument(
|
|
83
|
+
"--strict",
|
|
84
|
+
action="store_true",
|
|
85
|
+
help="results/out/ rules: csv/txt/png only, no per-person columns",
|
|
86
|
+
)
|
|
87
|
+
p.add_argument("paths", nargs="+")
|
|
88
|
+
p.set_defaults(func=pullable)
|
|
89
|
+
c = sub.add_parser("check-logs", help="list the logs free of patient ids")
|
|
90
|
+
c.add_argument("workflow_dir", nargs="?", default=".")
|
|
91
|
+
c.set_defaults(func=check_logs)
|
|
92
|
+
s = sub.add_parser("synthetic", help="write a synthetic BPS delivery")
|
|
93
|
+
s.add_argument("out_dir")
|
|
94
|
+
s.add_argument("--persons", type=int, default=50_000)
|
|
95
|
+
s.add_argument("--seed", type=int, default=1)
|
|
96
|
+
s.set_defaults(func=synthetic)
|
|
97
|
+
args = parser.parse_args(argv)
|
|
98
|
+
return args.func(args)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
sys.exit(main())
|