bpskit 0.2.0__py3-none-any.whl
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/__init__.py +14 -0
- bpskit/cli.py +102 -0
- bpskit/delivery.py +153 -0
- bpskit/icd.py +191 -0
- bpskit/persontime.py +132 -0
- bpskit/py.typed +0 -0
- bpskit/stats.py +76 -0
- bpskit/synthetic.py +179 -0
- bpskit/transfer.py +211 -0
- bpskit-0.2.0.dist-info/METADATA +181 -0
- bpskit-0.2.0.dist-info/RECORD +14 -0
- bpskit-0.2.0.dist-info/WHEEL +4 -0
- bpskit-0.2.0.dist-info/entry_points.txt +2 -0
- bpskit-0.2.0.dist-info/licenses/LICENSE +21 -0
bpskit/__init__.py
ADDED
|
@@ -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"
|
bpskit/cli.py
ADDED
|
@@ -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())
|
bpskit/delivery.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Reading a BPS delivery: pipe-delimited CSVs, every column read as text.
|
|
2
|
+
|
|
3
|
+
Columns stay text and nothing becomes null on its own, because the delivery
|
|
4
|
+
uses sentinel codes ("-1", "-2", "-9", "") for "unknown". Callers decide what
|
|
5
|
+
a sentinel means for each column: a missing date, or an unknown count.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pandas as pd
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"read_delivery",
|
|
16
|
+
"read_header",
|
|
17
|
+
"stream_csv",
|
|
18
|
+
"parse_date",
|
|
19
|
+
"normalise_code",
|
|
20
|
+
"load_config",
|
|
21
|
+
"delivery_root",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
PROFILE = "profiles/default/config.yaml"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def read_delivery(path: str | Path, usecols: list[str]) -> pd.DataFrame:
|
|
28
|
+
"""Read a small delivery file whole, quotes and padding stripped.
|
|
29
|
+
|
|
30
|
+
For the people file and similar. Diagnosis files can be hundreds of
|
|
31
|
+
millions of rows: use `stream_csv` for those.
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
KeyError: a needed column is not in the file. The message lists the
|
|
35
|
+
header that is there, which is usually enough to fix the config.
|
|
36
|
+
"""
|
|
37
|
+
path = Path(path)
|
|
38
|
+
for enc in ("utf-8", "latin-1"):
|
|
39
|
+
try:
|
|
40
|
+
header = pd.read_csv(path, sep="|", nrows=0, encoding=enc).columns
|
|
41
|
+
break
|
|
42
|
+
except UnicodeDecodeError:
|
|
43
|
+
continue
|
|
44
|
+
missing = [c for c in usecols if c not in header]
|
|
45
|
+
if missing:
|
|
46
|
+
raise KeyError(f"{path.name}: column(s) {missing} not in file; have {list(header)}")
|
|
47
|
+
df = pd.read_csv(path, sep="|", usecols=usecols, dtype=str, encoding=enc, keep_default_na=False)
|
|
48
|
+
for c in usecols:
|
|
49
|
+
df[c] = df[c].str.strip().str.strip('"').str.strip()
|
|
50
|
+
print(f"[READ ] {path.name}: {len(df):,} rows")
|
|
51
|
+
return df
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_date(s: pd.Series, fmt: str, sentinels, mid_month_day: int) -> pd.Series:
|
|
55
|
+
"""Parse delivery date strings, treating sentinel codes as missing.
|
|
56
|
+
|
|
57
|
+
Month-precision formats (no `%d`) land on `mid_month_day`, so the average
|
|
58
|
+
misplacement is zero rather than half a month early. Unparseable values
|
|
59
|
+
become NaT, but a column in which EVERY non-missing value fails raises:
|
|
60
|
+
that is a wrong `date_format` in config, not bad data.
|
|
61
|
+
"""
|
|
62
|
+
s = s.where(~s.isin(sentinels))
|
|
63
|
+
# An unquoted numeric column can arrive as "20150101.0".
|
|
64
|
+
s = s.str.replace(r"\.0$", "", regex=True)
|
|
65
|
+
out = pd.to_datetime(s, format=fmt, errors="coerce")
|
|
66
|
+
if "%d" not in fmt:
|
|
67
|
+
# pd.to_timedelta, not pd.Timedelta: NumPy deprecates the latter's
|
|
68
|
+
# unitless conversion when the column holds NaT.
|
|
69
|
+
out = out + pd.to_timedelta(mid_month_day - 1, unit="D")
|
|
70
|
+
if len(out) and out.isna().all() and s.notna().any():
|
|
71
|
+
raise ValueError(f"every value failed to parse as {fmt!r}, e.g. {s.dropna().iloc[0]!r}")
|
|
72
|
+
return out
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def read_header(path: str | Path, encoding: str) -> list[str]:
|
|
76
|
+
"""Column names of a delivery file, without reading any data."""
|
|
77
|
+
with open(path, encoding=encoding) as fh:
|
|
78
|
+
return [h.strip().strip('"') for h in fh.readline().rstrip("\r\n").split("|")]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def stream_csv(
|
|
82
|
+
path: str | Path, usecols: list[str], block_mb: float, encoding: str
|
|
83
|
+
) -> Iterator[pd.DataFrame]:
|
|
84
|
+
"""Yield a delivery file as pandas blocks of text columns.
|
|
85
|
+
|
|
86
|
+
pyarrow parses `block_mb` of the file at a time, so memory stays flat
|
|
87
|
+
whatever the file size. Block boundaries carry no meaning: a person's
|
|
88
|
+
records can be split across blocks, and callers must not assume otherwise.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
KeyError: a needed column is not in the header.
|
|
92
|
+
"""
|
|
93
|
+
import pyarrow as pa
|
|
94
|
+
import pyarrow.csv as pacsv
|
|
95
|
+
|
|
96
|
+
path = Path(path)
|
|
97
|
+
header = read_header(path, encoding)
|
|
98
|
+
missing = [c for c in usecols if c not in header]
|
|
99
|
+
if missing:
|
|
100
|
+
raise KeyError(f"{path.name}: column(s) {missing} not in file; have {header}")
|
|
101
|
+
reader = pacsv.open_csv(
|
|
102
|
+
path,
|
|
103
|
+
read_options=pacsv.ReadOptions(block_size=int(float(block_mb) * 2**20), encoding=encoding),
|
|
104
|
+
parse_options=pacsv.ParseOptions(delimiter="|"),
|
|
105
|
+
convert_options=pacsv.ConvertOptions(
|
|
106
|
+
include_columns=usecols,
|
|
107
|
+
column_types={c: pa.string() for c in usecols},
|
|
108
|
+
strings_can_be_null=False,
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
n = 0
|
|
112
|
+
for batch in reader:
|
|
113
|
+
df = batch.to_pandas()
|
|
114
|
+
for c in usecols:
|
|
115
|
+
df[c] = df[c].str.strip().str.strip('"').str.strip()
|
|
116
|
+
n += len(df)
|
|
117
|
+
yield df
|
|
118
|
+
print(f"[READ ] {path.name}: {n:,} rows streamed")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def normalise_code(s: pd.Series) -> pd.Series:
|
|
122
|
+
"""ICD codes as upper case with dots, spaces and quotes removed ('e11.9' -> 'E119')."""
|
|
123
|
+
return s.astype(str).str.upper().str.replace(r"[.\s\"]", "", regex=True)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# --------------------------------------------------------------------------- #
|
|
127
|
+
# Config, for the command-line tools (rules get theirs from Snakemake)
|
|
128
|
+
# --------------------------------------------------------------------------- #
|
|
129
|
+
def load_config(path: str | Path) -> dict:
|
|
130
|
+
"""A workflow's config file (YAML)."""
|
|
131
|
+
return yaml.safe_load(Path(path).read_text())
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def delivery_root(root: Path, cfg: dict) -> Path:
|
|
135
|
+
"""Where the delivery is, resolved the way Snakemake resolves it.
|
|
136
|
+
|
|
137
|
+
A workflow profile at <root>/profiles/default/config.yaml overrides the
|
|
138
|
+
config's own `delivery_root`. A `--config delivery_root=...` given to one
|
|
139
|
+
run is invisible here, so a tool run after such a run must be given the
|
|
140
|
+
same profile.
|
|
141
|
+
"""
|
|
142
|
+
root = Path(root)
|
|
143
|
+
prof = root / "profiles/default/config.yaml"
|
|
144
|
+
entries = (yaml.safe_load(prof.read_text()) or {}).get("config") if prof.exists() else None
|
|
145
|
+
entries = entries or []
|
|
146
|
+
# A profile may write `config:` as a list of "key=value" or as a mapping.
|
|
147
|
+
if isinstance(entries, dict):
|
|
148
|
+
entries = [f"{k}={v}" for k, v in entries.items()]
|
|
149
|
+
for e in entries:
|
|
150
|
+
k, _, v = str(e).partition("=")
|
|
151
|
+
if k.strip() == "delivery_root":
|
|
152
|
+
return (root / v.strip()).resolve()
|
|
153
|
+
return (root / cfg["delivery_root"]).resolve()
|
bpskit/icd.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""ICD-10 code specifications, chapters, and ICD-9 -> ICD-10 mapping.
|
|
2
|
+
|
|
3
|
+
Codes are handled in normalised form: upper case, no dot ('E119').
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
from bpskit.delivery import normalise_code
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"expand_spec",
|
|
16
|
+
"prefix_mask",
|
|
17
|
+
"match_table",
|
|
18
|
+
"icd10_chapter",
|
|
19
|
+
"CHAPTER_ROMAN",
|
|
20
|
+
"Icd9Mapper",
|
|
21
|
+
"harmonise",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
_CAT = re.compile(r"^[A-Z]\d{2}$")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _cat_index(c: str) -> int:
|
|
28
|
+
"""'A00' -> 0, 'B12' -> 112: 3-character categories as integers, for ranges."""
|
|
29
|
+
if not _CAT.match(c):
|
|
30
|
+
raise ValueError(f"range end {c!r} is not a 3-character ICD-10 category")
|
|
31
|
+
return (ord(c[0]) - 65) * 100 + int(c[1:])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _cat_from_index(i: int) -> str:
|
|
35
|
+
return f"{chr(65 + i // 100)}{i % 100:02d}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def expand_spec(spec: str) -> list[str]:
|
|
39
|
+
"""Expand a code specification into code prefixes.
|
|
40
|
+
|
|
41
|
+
'A15-A19, B18, H35.3' -> ['A15', 'A16', 'A17', 'A18', 'A19', 'B18', 'H353'].
|
|
42
|
+
Range ends must be 3-character categories; a single code may be longer and
|
|
43
|
+
matches as a prefix. Raises ValueError on a malformed or empty range.
|
|
44
|
+
"""
|
|
45
|
+
out: list[str] = []
|
|
46
|
+
for tok in str(spec).split(","):
|
|
47
|
+
tok = tok.strip().upper().replace(".", "")
|
|
48
|
+
if not tok:
|
|
49
|
+
continue
|
|
50
|
+
if "-" in tok:
|
|
51
|
+
a, b = (t.strip() for t in tok.split("-"))
|
|
52
|
+
ia, ib = _cat_index(a), _cat_index(b)
|
|
53
|
+
if ib < ia:
|
|
54
|
+
raise ValueError(f"empty range {tok!r}")
|
|
55
|
+
out += [_cat_from_index(i) for i in range(ia, ib + 1)]
|
|
56
|
+
else:
|
|
57
|
+
out.append(tok)
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def prefix_mask(codes: pd.Series, prefixes: list[str]) -> np.ndarray:
|
|
62
|
+
"""True where a code starts with any of `prefixes`."""
|
|
63
|
+
mask = np.zeros(len(codes), dtype=bool)
|
|
64
|
+
# One isin per prefix length is far faster than one startswith per prefix.
|
|
65
|
+
for length in sorted({len(p) for p in prefixes}):
|
|
66
|
+
want = {p for p in prefixes if len(p) == length}
|
|
67
|
+
mask |= codes.str[:length].isin(want).to_numpy()
|
|
68
|
+
return mask
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def match_table(unique_codes: pd.Series, conditions: pd.DataFrame) -> pd.DataFrame:
|
|
72
|
+
"""(code, condition_id) for every code each condition's `codes` spec matches.
|
|
73
|
+
|
|
74
|
+
A condition row may carry an `exclude` spec, removed from what `codes`
|
|
75
|
+
matched. A code can belong to several conditions (a chapter row and a
|
|
76
|
+
condition inside it), so this is a table, not a mapping.
|
|
77
|
+
"""
|
|
78
|
+
codes = pd.Series(unique_codes, dtype=str).reset_index(drop=True)
|
|
79
|
+
rows = []
|
|
80
|
+
for _, c in conditions.iterrows():
|
|
81
|
+
m = prefix_mask(codes, expand_spec(c["codes"]))
|
|
82
|
+
if c.get("exclude"):
|
|
83
|
+
m &= ~prefix_mask(codes, expand_spec(c["exclude"]))
|
|
84
|
+
rows.append(pd.DataFrame({"code": codes[m].to_numpy(), "condition_id": c["condition_id"]}))
|
|
85
|
+
return pd.concat(rows, ignore_index=True)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# First 3-character category of each WHO ICD-10 chapter, in code order. U
|
|
89
|
+
# (chapter XXII, special purposes) sorts between T and V.
|
|
90
|
+
_CHAPTER_STARTS = [
|
|
91
|
+
("A00", 1),
|
|
92
|
+
("C00", 2),
|
|
93
|
+
("D50", 3),
|
|
94
|
+
("E00", 4),
|
|
95
|
+
("F00", 5),
|
|
96
|
+
("G00", 6),
|
|
97
|
+
("H00", 7),
|
|
98
|
+
("H60", 8),
|
|
99
|
+
("I00", 9),
|
|
100
|
+
("J00", 10),
|
|
101
|
+
("K00", 11),
|
|
102
|
+
("L00", 12),
|
|
103
|
+
("M00", 13),
|
|
104
|
+
("N00", 14),
|
|
105
|
+
("O00", 15),
|
|
106
|
+
("P00", 16),
|
|
107
|
+
("Q00", 17),
|
|
108
|
+
("R00", 18),
|
|
109
|
+
("S00", 19),
|
|
110
|
+
("U00", 22),
|
|
111
|
+
("V00", 20),
|
|
112
|
+
("Z00", 21),
|
|
113
|
+
]
|
|
114
|
+
CHAPTER_ROMAN = {
|
|
115
|
+
i: r
|
|
116
|
+
for i, r in enumerate(
|
|
117
|
+
"- I II III IV V VI VII VIII IX X XI XII XIII XIV XV XVI XVII XVIII XIX XX XXI XXII".split()
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def icd10_chapter(codes: Iterable[str]) -> np.ndarray:
|
|
123
|
+
"""Chapter number (1-22) of 3-character ICD-10 categories; 0 for anything else."""
|
|
124
|
+
codes = pd.Series(codes, dtype=str)
|
|
125
|
+
ok = codes.str.fullmatch(r"[A-Z]\d{2}").to_numpy()
|
|
126
|
+
idx = np.full(len(codes), -1)
|
|
127
|
+
idx[ok] = [(ord(c[0]) - 65) * 100 + int(c[1:]) for c in codes[ok]]
|
|
128
|
+
starts = np.array([_cat_index(s) for s, _ in _CHAPTER_STARTS])
|
|
129
|
+
nums = np.array([n for _, n in _CHAPTER_STARTS])
|
|
130
|
+
pos = np.searchsorted(starts, idx, side="right") - 1
|
|
131
|
+
return np.where(ok & (pos >= 0), nums[np.clip(pos, 0, None)], 0)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# --------------------------------------------------------------------------- #
|
|
135
|
+
# ICD-9 -> ICD-10
|
|
136
|
+
# --------------------------------------------------------------------------- #
|
|
137
|
+
class Icd9Mapper:
|
|
138
|
+
"""Map ICD-9-CM codes to ICD-10-CM with the CMS GEM bundled in icd-mappings.
|
|
139
|
+
|
|
140
|
+
Results are cached per code, because a delivery repeats a few thousand
|
|
141
|
+
codes hundreds of millions of times. With `enabled=False`, or when
|
|
142
|
+
icd-mappings is not installed, every code maps to None and is dropped as
|
|
143
|
+
unmapped rather than passed through as if it were ICD-10.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def __init__(self, enabled: bool = True):
|
|
147
|
+
self._mapper = None
|
|
148
|
+
if enabled:
|
|
149
|
+
try:
|
|
150
|
+
from icdmappings import Mapper
|
|
151
|
+
|
|
152
|
+
self._mapper = Mapper()
|
|
153
|
+
except ImportError:
|
|
154
|
+
print("[WARN ] icd-mappings not installed: ICD-9 records are dropped, not mapped")
|
|
155
|
+
self._cache: dict[str, str | None] = {}
|
|
156
|
+
|
|
157
|
+
def map(self, codes: pd.Series) -> pd.Series:
|
|
158
|
+
new = sorted(set(codes.unique()) - self._cache.keys())
|
|
159
|
+
if new:
|
|
160
|
+
got = (
|
|
161
|
+
self._mapper.map(new, source="icd9", target="icd10")
|
|
162
|
+
if self._mapper
|
|
163
|
+
else [None] * len(new)
|
|
164
|
+
)
|
|
165
|
+
self._cache.update(zip(new, got))
|
|
166
|
+
return codes.map(self._cache)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def harmonise(df: pd.DataFrame, changeover_day: float | None, mapper: Icd9Mapper) -> pd.Series:
|
|
170
|
+
"""Map ICD-9 codes in `df.code` to ICD-10 in place; return each row's system.
|
|
171
|
+
|
|
172
|
+
A code is ICD-9 when it is all digits. Before `changeover_day` (None: never)
|
|
173
|
+
the letter-led supplementary (V01-V91) and external-cause (E800-E999) codes
|
|
174
|
+
count as ICD-9 too. Those shapes are also valid ICD-10 (E11 is diabetes),
|
|
175
|
+
so only a date can place them, and only where the changeover was measured
|
|
176
|
+
on that file.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
"icd10", "icd9_mapped" or "icd9_unmapped" per row. Unmapped rows keep
|
|
180
|
+
their ICD-9 code; the caller drops them.
|
|
181
|
+
"""
|
|
182
|
+
is9 = df.code.str.fullmatch(r"\d+")
|
|
183
|
+
if changeover_day is not None:
|
|
184
|
+
is9 |= (df.day < changeover_day) & df.code.str.match(r"^(V\d{2}|E[89]\d{2})")
|
|
185
|
+
system = pd.Series(np.where(is9, "icd9", "icd10"), index=df.index)
|
|
186
|
+
if is9.any():
|
|
187
|
+
m = mapper.map(df.code[is9])
|
|
188
|
+
hit = m.notna()
|
|
189
|
+
system[is9] = np.where(hit, "icd9_mapped", "icd9_unmapped")
|
|
190
|
+
df.loc[hit[hit].index, "code"] = normalise_code(m[hit])
|
|
191
|
+
return system
|
bpskit/persontime.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Person-time: dates as numbers, age bands, Lexis splitting, streaming persons.
|
|
2
|
+
|
|
3
|
+
Every per-person array holds dates as float days since 1970-01-01, so
|
|
4
|
+
person-time is a subtraction and NaN means "no date" (alive, never recorded).
|
|
5
|
+
Ages are in years of DAYS_PER_YEAR days.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import pandas as pd
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"DAYS_PER_YEAR",
|
|
16
|
+
"to_days",
|
|
17
|
+
"day_of",
|
|
18
|
+
"follow_up",
|
|
19
|
+
"age_bands",
|
|
20
|
+
"lexis",
|
|
21
|
+
"band_index",
|
|
22
|
+
"accumulate",
|
|
23
|
+
"person_chunks",
|
|
24
|
+
"read_universe",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
DAYS_PER_YEAR = 365.25
|
|
28
|
+
EPOCH = np.datetime64("1970-01-01", "D")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def to_days(s: pd.Series) -> np.ndarray:
|
|
32
|
+
"""Datetimes to float days since 1970-01-01; NaT becomes NaN."""
|
|
33
|
+
v = s.to_numpy("datetime64[D]")
|
|
34
|
+
out = ((v - EPOCH) / np.timedelta64(1, "D")).astype(float)
|
|
35
|
+
out[np.isnat(v)] = np.nan
|
|
36
|
+
return out
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def day_of(date: str | pd.Timestamp) -> float:
|
|
40
|
+
"""One date (string or Timestamp) as days since 1970-01-01."""
|
|
41
|
+
return to_days(pd.Series([pd.Timestamp(date)]))[0]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def follow_up(
|
|
45
|
+
birth_d: np.ndarray, death_d: np.ndarray, start_d: float, end_d: float
|
|
46
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
47
|
+
"""Entry day, exit day and whether any follow-up falls inside [start, end].
|
|
48
|
+
|
|
49
|
+
A person enters at the later of the period start and birth, and leaves at
|
|
50
|
+
the earlier of death and the period end; NaN death means alive.
|
|
51
|
+
"""
|
|
52
|
+
entry = np.maximum(start_d, birth_d)
|
|
53
|
+
exit_ = np.minimum(end_d, np.where(np.isnan(death_d), np.inf, death_d))
|
|
54
|
+
return entry, exit_, exit_ > entry
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def age_bands(width: int, open_from: int) -> pd.DataFrame:
|
|
58
|
+
"""Age bands of `width` years from 0, with a last open band from `open_from`.
|
|
59
|
+
|
|
60
|
+
Columns: age_band (label), age_lo, age_hi (200 for the open band), age_mid.
|
|
61
|
+
"""
|
|
62
|
+
lo = np.arange(0, open_from + 1, width)
|
|
63
|
+
hi = np.append(lo[1:], 200.0).astype(float)
|
|
64
|
+
label = [f"{a}-{a + width - 1}" for a in lo[:-1]] + [f"{lo[-1]}+"]
|
|
65
|
+
mid = np.append(lo[:-1] + width / 2, lo[-1] + width / 2)
|
|
66
|
+
return pd.DataFrame({"age_band": label, "age_lo": lo, "age_hi": hi, "age_mid": mid})
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def lexis(age_start: np.ndarray, age_end: np.ndarray, bands: pd.DataFrame) -> np.ndarray:
|
|
70
|
+
"""Person-years each interval [age_start, age_end) spends in each band (N x B).
|
|
71
|
+
|
|
72
|
+
An empty or reversed interval contributes zero everywhere.
|
|
73
|
+
"""
|
|
74
|
+
lo = bands["age_lo"].to_numpy(float)[None, :]
|
|
75
|
+
hi = bands["age_hi"].to_numpy(float)[None, :]
|
|
76
|
+
a = np.asarray(age_start, float)[:, None]
|
|
77
|
+
b = np.asarray(age_end, float)[:, None]
|
|
78
|
+
return np.clip(np.minimum(b, hi) - np.maximum(a, lo), 0.0, None)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def band_index(age: np.ndarray, bands: pd.DataFrame) -> np.ndarray:
|
|
82
|
+
"""Band of each age; ages outside the bands are clipped to the first or last."""
|
|
83
|
+
lo = bands["age_lo"].to_numpy(float)
|
|
84
|
+
return np.clip(np.searchsorted(lo, np.asarray(age, float), side="right") - 1, 0, len(lo) - 1)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def accumulate(acc: np.ndarray, key: np.ndarray, values: np.ndarray) -> None:
|
|
88
|
+
"""acc[key[i], b] += values[i, b] for every row i and column b, in place."""
|
|
89
|
+
for b in range(values.shape[1]):
|
|
90
|
+
acc[:, b] += np.bincount(key, weights=values[:, b], minlength=acc.shape[0])
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def person_chunks(
|
|
94
|
+
path: str | Path, batch_rows: int, columns: list[str] | None = None
|
|
95
|
+
) -> Iterator[pd.DataFrame]:
|
|
96
|
+
"""Stream a parquet sorted by `idx` as DataFrames that never split a person.
|
|
97
|
+
|
|
98
|
+
Each yielded frame holds whole persons; the last person of a batch is held
|
|
99
|
+
back and joined to the next one. The file MUST be sorted by idx, or a
|
|
100
|
+
person can come out in two chunks.
|
|
101
|
+
"""
|
|
102
|
+
import pyarrow.parquet as pq
|
|
103
|
+
|
|
104
|
+
left = None
|
|
105
|
+
for batch in pq.ParquetFile(path).iter_batches(batch_size=int(batch_rows), columns=columns):
|
|
106
|
+
df = batch.to_pandas()
|
|
107
|
+
if left is not None:
|
|
108
|
+
df = pd.concat([left, df], ignore_index=True)
|
|
109
|
+
cut = int(np.searchsorted(df.idx.to_numpy(), df.idx.iat[-1], side="left"))
|
|
110
|
+
if cut:
|
|
111
|
+
yield df.iloc[:cut].reset_index(drop=True)
|
|
112
|
+
left = df.iloc[cut:]
|
|
113
|
+
if left is not None and len(left):
|
|
114
|
+
yield left.reset_index(drop=True)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def read_universe(path: str | Path, columns: list[str]) -> pd.DataFrame:
|
|
118
|
+
"""Read universe columns, refusing a file written by an older workflow version.
|
|
119
|
+
|
|
120
|
+
Snakemake does not rebuild an intermediate whose rule did not change, so a
|
|
121
|
+
universe.parquet from an older layout can outlive the code that wrote it.
|
|
122
|
+
"""
|
|
123
|
+
import pyarrow.parquet as pq
|
|
124
|
+
|
|
125
|
+
have = set(pq.read_schema(path).names)
|
|
126
|
+
missing = [c for c in columns if c not in have]
|
|
127
|
+
if missing:
|
|
128
|
+
raise RuntimeError(
|
|
129
|
+
f"{path} has no column(s) {missing}: it was written by an older version of "
|
|
130
|
+
"this workflow. Rebuild everything downstream of it: snakemake -c 20 --forceall"
|
|
131
|
+
)
|
|
132
|
+
return pd.read_parquet(path, columns=columns)
|
bpskit/py.typed
ADDED
|
File without changes
|
bpskit/stats.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Exact tests and multiplicity adjustment, vectorised over many tests at once.
|
|
2
|
+
|
|
3
|
+
Missing inputs give NaN p-values, and NaN p-values stay out of the adjustment
|
|
4
|
+
family rather than counting as tests.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
from numpy.typing import ArrayLike
|
|
10
|
+
from scipy import stats
|
|
11
|
+
|
|
12
|
+
__all__ = ["bh", "adjust", "poisson_rr", "binom_half_p", "binom_p"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def bh(p: pd.Series) -> pd.Series:
|
|
16
|
+
"""Benjamini-Hochberg q-values over the non-missing p-values."""
|
|
17
|
+
p = pd.Series(p, dtype=float)
|
|
18
|
+
ok = p.notna()
|
|
19
|
+
q = pd.Series(np.nan, index=p.index)
|
|
20
|
+
if ok.sum():
|
|
21
|
+
v = p[ok].to_numpy()
|
|
22
|
+
order = np.argsort(v)
|
|
23
|
+
m = len(v)
|
|
24
|
+
ranked = v[order] * m / np.arange(1, m + 1)
|
|
25
|
+
ranked = np.minimum.accumulate(ranked[::-1])[::-1]
|
|
26
|
+
out = np.empty(m)
|
|
27
|
+
out[order] = np.minimum(ranked, 1.0)
|
|
28
|
+
q[ok] = out
|
|
29
|
+
return q
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def adjust(p: pd.Series, method: str) -> pd.Series:
|
|
33
|
+
"""BH q-values, or Bonferroni-adjusted p-values, over the non-missing entries."""
|
|
34
|
+
if method == "bh":
|
|
35
|
+
return bh(p)
|
|
36
|
+
if method == "bonferroni":
|
|
37
|
+
p = pd.Series(p, dtype=float)
|
|
38
|
+
return (p * p.notna().sum()).clip(upper=1.0)
|
|
39
|
+
raise ValueError(f"multiplicity must be 'bh' or 'bonferroni', not {method!r}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def poisson_rr(
|
|
43
|
+
obs: ArrayLike, exp: ArrayLike, alpha: float
|
|
44
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|
45
|
+
"""Observed/expected ratio, exact (Garwood) interval and two-sided exact p.
|
|
46
|
+
|
|
47
|
+
`exp` is treated as known. exp == 0 gives an infinite or NaN ratio.
|
|
48
|
+
"""
|
|
49
|
+
obs, exp = np.asarray(obs, float), np.asarray(exp, float)
|
|
50
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
51
|
+
rr = obs / exp
|
|
52
|
+
lo = np.where(obs > 0, stats.chi2.ppf(alpha / 2, 2 * obs) / 2, 0.0) / exp
|
|
53
|
+
hi = stats.chi2.ppf(1 - alpha / 2, 2 * obs + 2) / 2 / exp
|
|
54
|
+
p = np.minimum(
|
|
55
|
+
1.0, 2 * np.minimum(stats.poisson.sf(obs - 1, exp), stats.poisson.cdf(obs, exp))
|
|
56
|
+
)
|
|
57
|
+
return rr, lo, hi, p
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def binom_half_p(k: ArrayLike, n: ArrayLike) -> np.ndarray:
|
|
61
|
+
"""Two-sided exact binomial test of k successes in n against p = 0.5; NaN when n = 0."""
|
|
62
|
+
k, n = np.asarray(k, float), np.asarray(n, float)
|
|
63
|
+
with np.errstate(invalid="ignore"):
|
|
64
|
+
p = np.minimum(1.0, 2 * stats.binom.cdf(np.minimum(k, n - k), n, 0.5))
|
|
65
|
+
return np.where(n > 0, p, np.nan)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def binom_p(k: ArrayLike, n: ArrayLike, p0: ArrayLike) -> np.ndarray:
|
|
69
|
+
"""Two-sided exact binomial test of k in n against p0 (doubled smaller tail).
|
|
70
|
+
|
|
71
|
+
NaN when n = 0 or p0 is 0 or 1, where the test is undefined.
|
|
72
|
+
"""
|
|
73
|
+
k, n, p0 = np.asarray(k, float), np.asarray(n, float), np.asarray(p0, float)
|
|
74
|
+
with np.errstate(invalid="ignore"):
|
|
75
|
+
p = np.minimum(1.0, 2 * np.minimum(stats.binom.cdf(k, n, p0), stats.binom.sf(k - 1, n, p0)))
|
|
76
|
+
return np.where((n > 0) & (p0 > 0) & (p0 < 1), p, np.nan)
|
bpskit/synthetic.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""A synthetic BPS delivery with known structure, for tests and examples.
|
|
2
|
+
|
|
3
|
+
The files have the BPS column names and formats (pipe-delimited, `AAAAMM` or
|
|
4
|
+
`AAAAMMDD` dates, ICD-10 codes with and without dots), so a workflow written
|
|
5
|
+
for the real delivery runs on it unchanged. What is planted, so a correct
|
|
6
|
+
analysis can be checked against it:
|
|
7
|
+
|
|
8
|
+
E11 -> N18 -> I50 -> death directional: hazard x4 after the predecessor
|
|
9
|
+
(x5 for death after I50)
|
|
10
|
+
J44 ~ M81 associated through a shared frailty, no direction
|
|
11
|
+
150 codes (A/B/C/G/K/L00-24) independent of everything, with rates scaled by a
|
|
12
|
+
person-level healthcare-use intensity (lognormal)
|
|
13
|
+
R53 routine visits, Poisson in that intensity
|
|
14
|
+
U07 from 2020 only, independent of everything
|
|
15
|
+
|
|
16
|
+
Directional tests need power: at 50,000 persons the planted pairs are found;
|
|
17
|
+
at a few thousand the files are only good for exercising code.
|
|
18
|
+
|
|
19
|
+
from bpskit.synthetic import make_delivery
|
|
20
|
+
cfg = make_delivery("data/synthetic", n_persons=2_000) # writes the files
|
|
21
|
+
cfg["universe"], cfg["sources"] # how to read them
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
import pandas as pd
|
|
28
|
+
|
|
29
|
+
__all__ = ["FILES", "config", "make_delivery"]
|
|
30
|
+
|
|
31
|
+
# One people file and three diagnosis sources, named by what they hold.
|
|
32
|
+
FILES = {
|
|
33
|
+
"universe": "sociodemograficos.csv",
|
|
34
|
+
"ap": "atencion_primaria.csv",
|
|
35
|
+
"cmbd": "hospitalizaciones.csv",
|
|
36
|
+
"bps": "patologias_bps.csv",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def config(delivery_root: str | Path = ".") -> dict:
|
|
41
|
+
"""The `delivery_root`, `universe` and `sources` config of a synthetic delivery.
|
|
42
|
+
|
|
43
|
+
The same shape a BPS workflow config uses, so it can be merged into one:
|
|
44
|
+
`universe` names the people file's columns and sex labels, and each source
|
|
45
|
+
its id, date and ICD-code columns.
|
|
46
|
+
"""
|
|
47
|
+
return {
|
|
48
|
+
"delivery_root": str(delivery_root),
|
|
49
|
+
"universe": {
|
|
50
|
+
"file": FILES["universe"],
|
|
51
|
+
"id": "COD_ID",
|
|
52
|
+
"sex": "DESC_SEXO",
|
|
53
|
+
"sex_values": {"Mujer": "F", "Hombre": "M"},
|
|
54
|
+
"birth": "ANYO_NAC",
|
|
55
|
+
"death": "ANYO_FAC",
|
|
56
|
+
"date_format": "%Y%m",
|
|
57
|
+
},
|
|
58
|
+
"sources": {
|
|
59
|
+
"ap": {
|
|
60
|
+
"file": FILES["ap"],
|
|
61
|
+
"id": "COD_ID",
|
|
62
|
+
"date": "FECHA_INICIO_DIAGNOSTICO",
|
|
63
|
+
"date_format": "%Y%m%d",
|
|
64
|
+
"code": "COD_CIE",
|
|
65
|
+
},
|
|
66
|
+
"cmbd": {
|
|
67
|
+
"file": FILES["cmbd"],
|
|
68
|
+
"id": "COD_ID",
|
|
69
|
+
"date": "COD_FEC_INGRESO",
|
|
70
|
+
"date_format": "%Y%m%d",
|
|
71
|
+
"code": "COD_CIE",
|
|
72
|
+
"position": "COD_POSICION",
|
|
73
|
+
},
|
|
74
|
+
"bps": {
|
|
75
|
+
"file": FILES["bps"],
|
|
76
|
+
"id": "COD_ID",
|
|
77
|
+
"date": "FEC_INI_PATOLOGIA",
|
|
78
|
+
"date_format": "%Y%m",
|
|
79
|
+
"code": "COD_CIE",
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def make_delivery(out_dir: str | Path, n_persons: int = 50_000, seed: int = 1) -> dict:
|
|
86
|
+
"""Write a synthetic delivery of `n_persons` into `out_dir`; return its config.
|
|
87
|
+
|
|
88
|
+
The same `n_persons` and `seed` always write the same bytes. Persons are
|
|
89
|
+
followed 2015-2025; identifiers look like `P00000042`.
|
|
90
|
+
"""
|
|
91
|
+
n = n_persons
|
|
92
|
+
N = int(n)
|
|
93
|
+
rng = np.random.default_rng(int(seed))
|
|
94
|
+
out = Path(out_dir)
|
|
95
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
T0, T1 = 2015.0, 2026.0 # decimal years
|
|
97
|
+
|
|
98
|
+
birth = rng.uniform(1930, 1975, N)
|
|
99
|
+
sex = rng.choice(["Mujer", "Hombre"], N)
|
|
100
|
+
frail = rng.random(N) < 0.1
|
|
101
|
+
use = rng.lognormal(0, 0.8, N)
|
|
102
|
+
|
|
103
|
+
def first_time(rate):
|
|
104
|
+
t = T0 + rng.exponential(1 / rate)
|
|
105
|
+
return np.where(t < T1, t, np.nan)
|
|
106
|
+
|
|
107
|
+
def after(base, mult, trigger):
|
|
108
|
+
"""Hazard `base`, multiplied by `mult` from the trigger's time on."""
|
|
109
|
+
t0 = first_time(np.full(N, base))
|
|
110
|
+
t1 = trigger + rng.exponential(1 / (base * mult), N)
|
|
111
|
+
t = np.where(np.isnan(trigger) | (t0 < trigger), t0, t1)
|
|
112
|
+
return np.where(t < T1, t, np.nan)
|
|
113
|
+
|
|
114
|
+
codes = {"E11": first_time(0.01 * np.exp((2020 - birth - 65) / 15))}
|
|
115
|
+
codes["N18"] = after(0.004, 4, codes["E11"])
|
|
116
|
+
codes["I50"] = after(0.004, 4, codes["N18"])
|
|
117
|
+
codes["J44"] = first_time(np.where(frail, 0.03, 0.004))
|
|
118
|
+
codes["M81"] = first_time(np.where(frail, 0.03, 0.004))
|
|
119
|
+
for c in [f"{letter}{n:02d}" for letter in "ABCGKL" for n in range(25)]:
|
|
120
|
+
codes[c] = first_time(rng.uniform(0.002, 0.02) * use)
|
|
121
|
+
death = after(0.01, 5, codes["I50"])
|
|
122
|
+
|
|
123
|
+
rows = []
|
|
124
|
+
for c, t in codes.items():
|
|
125
|
+
ids = np.flatnonzero(~np.isnan(t))
|
|
126
|
+
for rep in range(2): # first record + one later repeat
|
|
127
|
+
tt = t[ids] + rep * rng.uniform(0, 2, len(ids))
|
|
128
|
+
ok = (tt < T1) & (np.isnan(death[ids]) | (tt <= death[ids]))
|
|
129
|
+
rows.append(pd.DataFrame({"i": ids[ok], "t": tt[ok], "code": c}))
|
|
130
|
+
k = rng.poisson(3 * use * 11)
|
|
131
|
+
vi = np.repeat(np.arange(N), k)
|
|
132
|
+
vt = rng.uniform(T0, T1, len(vi))
|
|
133
|
+
ok = np.isnan(death[vi]) | (vt <= death[vi])
|
|
134
|
+
rows.append(pd.DataFrame({"i": vi[ok], "t": vt[ok], "code": "R53"}))
|
|
135
|
+
m = rng.random(N) < 0.15
|
|
136
|
+
rows.append(
|
|
137
|
+
pd.DataFrame({"i": np.flatnonzero(m), "t": rng.uniform(2020, T1, m.sum()), "code": "U07"})
|
|
138
|
+
)
|
|
139
|
+
ev = pd.concat(rows, ignore_index=True)
|
|
140
|
+
|
|
141
|
+
def fmt(t, f):
|
|
142
|
+
d = pd.to_datetime("2015-01-01") + pd.to_timedelta((pd.Series(t) - T0) * 365.25, unit="D")
|
|
143
|
+
return d.dt.strftime(f).to_numpy()
|
|
144
|
+
|
|
145
|
+
pid = np.array([f"P{i:08d}" for i in range(N)])
|
|
146
|
+
pd.DataFrame(
|
|
147
|
+
{
|
|
148
|
+
"COD_ID": pid,
|
|
149
|
+
"DESC_SEXO": sex,
|
|
150
|
+
"ANYO_NAC": fmt(birth, "%Y%m"),
|
|
151
|
+
"ANYO_FAC": np.where(np.isnan(death), "", fmt(np.nan_to_num(death, nan=T0), "%Y%m")),
|
|
152
|
+
}
|
|
153
|
+
).to_csv(out / FILES["universe"], sep="|", index=False)
|
|
154
|
+
# routine visits and U07 are primary care; the rest is split across the sources
|
|
155
|
+
src = np.where(
|
|
156
|
+
ev.code.isin(["R53", "U07"]),
|
|
157
|
+
"ap",
|
|
158
|
+
rng.choice(["ap", "cmbd", "bps"], len(ev), p=[0.6, 0.25, 0.15]),
|
|
159
|
+
)
|
|
160
|
+
a, c, b = (ev[src == s] for s in ("ap", "cmbd", "bps"))
|
|
161
|
+
pd.DataFrame(
|
|
162
|
+
{
|
|
163
|
+
"COD_ID": pid[a.i],
|
|
164
|
+
"FECHA_INICIO_DIAGNOSTICO": fmt(a.t, "%Y%m%d"),
|
|
165
|
+
"COD_CIE": a.code + ".9",
|
|
166
|
+
}
|
|
167
|
+
).to_csv(out / FILES["ap"], sep="|", index=False)
|
|
168
|
+
pd.DataFrame(
|
|
169
|
+
{
|
|
170
|
+
"COD_ID": pid[c.i],
|
|
171
|
+
"COD_FEC_INGRESO": fmt(c.t, "%Y%m%d"),
|
|
172
|
+
"COD_CIE": c.code,
|
|
173
|
+
"COD_POSICION": rng.integers(1, 4, len(c)),
|
|
174
|
+
}
|
|
175
|
+
).to_csv(out / FILES["cmbd"], sep="|", index=False)
|
|
176
|
+
pd.DataFrame(
|
|
177
|
+
{"COD_ID": pid[b.i], "FEC_INI_PATOLOGIA": fmt(b.t, "%Y%m"), "COD_CIE": b.code}
|
|
178
|
+
).to_csv(out / FILES["bps"], sep="|", index=False)
|
|
179
|
+
return config(out)
|
bpskit/transfer.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Does a file name a patient? The check behind every transfer off the data machine.
|
|
2
|
+
|
|
3
|
+
The rule: no row carrying a patient identifier leaves the machine that holds
|
|
4
|
+
the delivery. It is tested on the bytes, against the identifiers the workflow
|
|
5
|
+
actually READ, not against a guessed id format:
|
|
6
|
+
|
|
7
|
+
results/work/ids/universe.parquet every id in the people file, written
|
|
8
|
+
before the universe drops anyone
|
|
9
|
+
results/work/ids/<source>.parquet ids in a diagnosis file that are not in
|
|
10
|
+
the universe (ingest drops their rows)
|
|
11
|
+
|
|
12
|
+
An id the workflow never read cannot be in anything it wrote, so this set is
|
|
13
|
+
complete. The check fails closed: an unreadable file, an unknown suffix or an
|
|
14
|
+
empty id set refuses, because a guard that waves through what it could not
|
|
15
|
+
parse is trusted and wrong.
|
|
16
|
+
|
|
17
|
+
A small aggregate cell is not a row and names nobody, so cell counts are not
|
|
18
|
+
checked here; a publication floor is a separate, later decision.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
from collections.abc import Iterable
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
import pandas as pd
|
|
26
|
+
|
|
27
|
+
from bpskit.delivery import delivery_root, stream_csv
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"IDS_DIR",
|
|
31
|
+
"MIN_ID_LEN",
|
|
32
|
+
"write_ids",
|
|
33
|
+
"load_ids",
|
|
34
|
+
"ids_for_root",
|
|
35
|
+
"forbidden_columns",
|
|
36
|
+
"check_file",
|
|
37
|
+
"check",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
IDS_DIR = "results/work/ids"
|
|
41
|
+
# Shorter ids would match ordinary counts in every table. How many are skipped
|
|
42
|
+
# is printed, so a delivery with short ids cannot weaken the check unnoticed.
|
|
43
|
+
MIN_ID_LEN = 4
|
|
44
|
+
# results/out/ is copied wholesale, so only formats this check can read (and
|
|
45
|
+
# figures, which are drawn from tables that must pass on their own).
|
|
46
|
+
OUT_SUFFIXES = {".csv", ".txt", ".png"}
|
|
47
|
+
TEXT_SUFFIXES = {".csv", ".tsv", ".txt", ".log", ".md", ".json"}
|
|
48
|
+
OPAQUE_SUFFIXES = {".png"}
|
|
49
|
+
# A CSV with one of these columns is a per-person table whatever else it holds.
|
|
50
|
+
INTERNAL_KEYS = {"pid", "idx", "bucket", "birth", "death", "birth_d", "death_d", "day"}
|
|
51
|
+
ID_LIKE = re.compile(r"(^|_)(pid|patient|cod_id|id_paciente|nuhsa)($|_)", re.I)
|
|
52
|
+
# Deliberately wide: an id inside a delimited row, a quoted cell, `key=value`,
|
|
53
|
+
# a traceback repr or a sentence must come out as a token of its own.
|
|
54
|
+
TOKENISERS = (
|
|
55
|
+
re.compile(r"[^\s,;|\"'\[\](){}:=<>/\\]+"),
|
|
56
|
+
re.compile(r"[A-Za-z0-9_-]+"),
|
|
57
|
+
re.compile(r"[A-Za-z0-9]+"),
|
|
58
|
+
re.compile(r"\d+"),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# --------------------------------------------------------------------------- #
|
|
63
|
+
# Identifiers
|
|
64
|
+
# --------------------------------------------------------------------------- #
|
|
65
|
+
def _clean(values) -> set[str]:
|
|
66
|
+
s = pd.Series(list(values), dtype="string").dropna().str.strip().str.strip('"').str.strip()
|
|
67
|
+
return set(s[s != ""])
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _finish(ids: set[str], where: str) -> set[str]:
|
|
71
|
+
short = sum(len(i) < MIN_ID_LEN for i in ids)
|
|
72
|
+
ids = {i for i in ids if len(i) >= MIN_ID_LEN}
|
|
73
|
+
if not ids:
|
|
74
|
+
raise SystemExit(f"[REFUSE] no patient identifiers loaded from {where}; nothing is cleared")
|
|
75
|
+
warn = f" [WARN ] {short:,} shorter than {MIN_ID_LEN} chars are NOT checked" if short else ""
|
|
76
|
+
print(f"[IDS ] {len(ids):,} identifiers from {where}{warn}")
|
|
77
|
+
return ids
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def write_ids(ids: Iterable[str], path: str | Path) -> None:
|
|
81
|
+
"""Write an id list for the transfer check (one `pid` column)."""
|
|
82
|
+
pd.DataFrame({"pid": pd.Series(list(ids), dtype="string")}).to_parquet(path, index=False)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def load_ids(paths: Iterable[str | Path]) -> set[str]:
|
|
86
|
+
"""The identifier set from id files, as declared inputs of a rule."""
|
|
87
|
+
paths = list(paths)
|
|
88
|
+
ids: set[str] = set()
|
|
89
|
+
for p in paths:
|
|
90
|
+
ids |= _clean(pd.read_parquet(p, columns=["pid"]).pid)
|
|
91
|
+
return _finish(ids, f"{len(paths)} id file(s)")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def ids_for_root(root: Path, cfg: dict) -> set[str]:
|
|
95
|
+
"""Every identifier the workflow at `root` has read, for the hand-run checks.
|
|
96
|
+
|
|
97
|
+
Uses the id files where they exist. Where one is missing (a run that died
|
|
98
|
+
before `universe` or inside an `ingest_source`), it reads that file's id
|
|
99
|
+
column from the delivery itself, because the job may have printed a row
|
|
100
|
+
before dying.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
SystemExit: an id file is missing and the delivery file cannot be read.
|
|
104
|
+
"""
|
|
105
|
+
root = Path(root)
|
|
106
|
+
ids_dir = root / IDS_DIR
|
|
107
|
+
wanted = {"universe": cfg["universe"]} | dict(cfg["sources"])
|
|
108
|
+
ids: set[str] = set()
|
|
109
|
+
fallback = []
|
|
110
|
+
for name, spec in wanted.items():
|
|
111
|
+
f = ids_dir / f"{name}.parquet"
|
|
112
|
+
if f.exists():
|
|
113
|
+
ids |= _clean(pd.read_parquet(f, columns=["pid"]).pid)
|
|
114
|
+
continue
|
|
115
|
+
path = delivery_root(root, cfg) / spec["file"]
|
|
116
|
+
sc = cfg["scale"]
|
|
117
|
+
try:
|
|
118
|
+
for block in stream_csv(path, [spec["id"]], sc["csv_block_mb"], sc["encoding"]):
|
|
119
|
+
ids |= _clean(block[spec["id"]].unique())
|
|
120
|
+
except Exception as exc: # noqa: BLE001 -- fail closed: no ids, nothing cleared
|
|
121
|
+
raise SystemExit(
|
|
122
|
+
f"[REFUSE] no id file for {name!r} and its delivery file could not be read "
|
|
123
|
+
f"({exc}); nothing is cleared"
|
|
124
|
+
) from exc
|
|
125
|
+
fallback.append(name)
|
|
126
|
+
where = IDS_DIR + (
|
|
127
|
+
f" + delivery files for {', '.join(fallback)} (no id file yet)" if fallback else ""
|
|
128
|
+
)
|
|
129
|
+
return _finish(ids, where)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def forbidden_columns(cfg: dict) -> set[str]:
|
|
133
|
+
"""Lower-cased column names that make a CSV per-person.
|
|
134
|
+
|
|
135
|
+
The internal keys plus the delivery's own id and date columns, taken from
|
|
136
|
+
config/config.yaml so a new source is covered without editing this.
|
|
137
|
+
"""
|
|
138
|
+
cols = set(INTERNAL_KEYS)
|
|
139
|
+
u = cfg["universe"]
|
|
140
|
+
cols |= {u["id"], u["birth"], u["death"]}
|
|
141
|
+
for s in cfg["sources"].values():
|
|
142
|
+
cols |= {s["id"], s["date"]}
|
|
143
|
+
return {c.lower() for c in cols}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# --------------------------------------------------------------------------- #
|
|
147
|
+
# Files
|
|
148
|
+
# --------------------------------------------------------------------------- #
|
|
149
|
+
def _tokens(text: str) -> set[str]:
|
|
150
|
+
out: set[str] = set()
|
|
151
|
+
for t in TOKENISERS:
|
|
152
|
+
out.update(t.findall(text))
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def check_file(path: Path, ids: set[str], strict: bool, forbidden: set[str]) -> list[str]:
|
|
157
|
+
"""Reasons this file may not leave; empty if it may.
|
|
158
|
+
|
|
159
|
+
`strict` adds the rules for results/out/: an allowed suffix, and no CSV
|
|
160
|
+
header column in `forbidden` or shaped like an identifier. Without it
|
|
161
|
+
(logs), only the identifier scan applies.
|
|
162
|
+
"""
|
|
163
|
+
path = Path(path)
|
|
164
|
+
suffix = path.suffix.lower()
|
|
165
|
+
if strict and suffix not in OUT_SUFFIXES:
|
|
166
|
+
return [f"suffix {suffix or '(none)'} is not one of {sorted(OUT_SUFFIXES)}"]
|
|
167
|
+
if suffix in OPAQUE_SUFFIXES:
|
|
168
|
+
return []
|
|
169
|
+
if suffix not in TEXT_SUFFIXES:
|
|
170
|
+
return [f"suffix {suffix or '(none)'} cannot be checked"]
|
|
171
|
+
bad = []
|
|
172
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
173
|
+
if hits := ids & _tokens(text):
|
|
174
|
+
bad.append(f"contains {len(hits)} patient identifier(s)")
|
|
175
|
+
if strict and suffix == ".csv" and text:
|
|
176
|
+
header = [c.strip().strip('"').strip() for c in text.split("\n", 1)[0].split(",")]
|
|
177
|
+
if cols := [c for c in header if c.lower() in forbidden or ID_LIKE.search(c)]:
|
|
178
|
+
bad.append(f"per-person or identifier column(s) {cols}")
|
|
179
|
+
return bad
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def check(
|
|
183
|
+
paths, ids: set[str], strict: bool, forbidden: set[str], skip=()
|
|
184
|
+
) -> tuple[int, list[str]]:
|
|
185
|
+
"""Check files and directories (recursively).
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
(number of files checked, one "path: reason" line per problem). A
|
|
189
|
+
listed path that does not exist is skipped: not built yet is not a leak.
|
|
190
|
+
"""
|
|
191
|
+
skip = {Path(p).resolve() for p in skip}
|
|
192
|
+
files = []
|
|
193
|
+
for p in map(Path, paths):
|
|
194
|
+
if p.is_dir():
|
|
195
|
+
files += sorted(f for f in p.rglob("*") if f.is_file())
|
|
196
|
+
elif p.exists():
|
|
197
|
+
files.append(p)
|
|
198
|
+
problems, n = [], 0
|
|
199
|
+
for f in files:
|
|
200
|
+
if f.resolve() in skip:
|
|
201
|
+
continue
|
|
202
|
+
# Snakemake's empty marker inside a directory output; sync.sh never copies it.
|
|
203
|
+
if f.name == ".snakemake_timestamp" and f.stat().st_size == 0:
|
|
204
|
+
continue
|
|
205
|
+
n += 1
|
|
206
|
+
try:
|
|
207
|
+
why = check_file(f, ids, strict, forbidden)
|
|
208
|
+
except Exception as exc: # noqa: BLE001 -- fail closed: ANY error refuses
|
|
209
|
+
why = [f"could not be checked: {exc}"]
|
|
210
|
+
problems += [f"{f}: {w}" for w in why]
|
|
211
|
+
return n, problems
|
|
@@ -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.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
bpskit/__init__.py,sha256=nc8t3V09DcRdGnjvrRohAqCG-SW0FXtF7mZKVoX5uDA,714
|
|
2
|
+
bpskit/cli.py,sha256=oez_8vrj1Ae_QW_gX9r7KDi6pFxInQM1jbFA5OmmsKA,3938
|
|
3
|
+
bpskit/delivery.py,sha256=LWddYwITPR7AqMenxjzvrgltf8rpblMPjsPhZdhvaYM,5940
|
|
4
|
+
bpskit/icd.py,sha256=yWnDUo--9i6KWTC-qfsf_69kQMsKMdLbJ0_rl__do3I,6627
|
|
5
|
+
bpskit/persontime.py,sha256=Rufi_MbyM5geyQmReo4aOwReB0TVIAIWAf7w57aWMEw,4892
|
|
6
|
+
bpskit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
bpskit/stats.py,sha256=L4BpDEyRT8kEGJYNisY_4Y9zKTpDb4HTYLnUF90npdk,2876
|
|
8
|
+
bpskit/synthetic.py,sha256=4OPO2Sn6n31onFnQcosrGxFbGp3AclVLMcZ6vprQeZg,6867
|
|
9
|
+
bpskit/transfer.py,sha256=_s44aKadLXmb1ieEsapH4y3udxD5ECrm-2i__gwjOQg,8321
|
|
10
|
+
bpskit-0.2.0.dist-info/METADATA,sha256=ibxTjKKwjbGK--0YL9v4MaJpTpgJgEFZ-PYnYnD_--U,6954
|
|
11
|
+
bpskit-0.2.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
12
|
+
bpskit-0.2.0.dist-info/entry_points.txt,sha256=NJKaOyKGXbc9J5x_ycMW0AKPrJ5fXoaWBRQSIpGCTKw,43
|
|
13
|
+
bpskit-0.2.0.dist-info/licenses/LICENSE,sha256=O9v5wiTVjzzmwlbvn3s1hN9a0J3R8WXv9Ue8zx_R_Gg,1071
|
|
14
|
+
bpskit-0.2.0.dist-info/RECORD,,
|
|
@@ -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.
|