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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ bpskit = bpskit.cli:main
@@ -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.