#!/usr/bin/env python3 """Shift every date in a patient's JSONL files by one offset (a whole number of years, a multiple of 4, plus optional days); intervals between dates are preserved.""" import argparse import datetime as dt import json import random import re from collections import Counter from pathlib import Path MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] MON = r"(?PJan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|June?|July?|Aug(?:ust)?|Sep(?:t(?:ember)?)?" \ r"|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)" Y = r"(?:19|20)\d\d" UNIT = (r"(?!\s?(?!U/A\b|L-[a-z])" r"(?:mg|mcg|g|kg|ml|mL|cc|L|IU|U|units?|kcal|cal|mmHg|mm|cm|%|tabs?|tablets?|caps?|times|x)\b)") END = r"(?!\d|[/.]\d)" RX = re.compile("|".join([ rf"(?P(?{Y})(?P[-/.])(?P\d{{1,2}})(?P=s1)(?P\d{{1,2}}){END})", rf"(?P(?0?[1-9]|1[0-2])/(?P0?[1-9]|[12]\d|3[01])/(?P{Y})(?!\d|[/.]\d))", rf"(?P(?0?[1-9]|1[0-2])/(?P{Y})(?!\d|[/.]\d))", rf"(?P(?{Y})(?P0[1-9]|1[0-2])(?P0[1-9]|[12]\d|3[01])(?!\d))", rf"(?P{MON.format(n=1)}\.? (?P\d{{1,2}})(?P,? )(?P{Y})(?!\d))", rf"(?P(?\d{{1,2}}) {MON.format(n=2)}(?P\.?,? )(?P{Y})(?!\d))", rf"(?P(?{Y})(?P-|/|\u2013)(?P{Y})(?![A-Za-z\d]|[/.]\d){UNIT})", rf"(?P(?{Y})(?P[-/.])(?P0?[1-9]|1[0-2]){END}(?!-\d{{1,2}}(?!\d)))", rf"(?P{MON.format(n=3)}(?P\.?,? )(?P{Y})(?!\d))", rf"(?P{MON.format(n=4)}\.? (?P\d{{1,2}})(?:st|nd|rd|th)?(?![\d,]))", rf"(?P(?{Y})(?=[-/.]\d))", rf"(?P(?19[4-9]\d|20[0-2]\d)" rf"(?!(?!year)[A-Za-z]|\d|[/.]\d|-\d{{1,2}}(?!\d)){UNIT})", ])) KINDS = ("ymd", "mdyn", "my2", "c8", "mdy", "dmy", "yr", "ym", "my", "md", "yx", "y") MD_NUM = re.compile(r"(? hi or lo % 4 or hi % 4: raise argparse.ArgumentTypeError(f"{s}: LO and HI must be multiples of 4 with LO <= HI") return list(range(lo, hi + 1, 4)) def main(): ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--src", required=True, type=Path) ap.add_argument("--out", required=True, type=Path) ap.add_argument("--offsets", required=True, type=Path, help="TSV file that receives the offset of each patient") ap.add_argument("--years", type=year_offsets, required=True, help="--years=LO,HI: each patient's year offset is a multiple of 4 from LO to HI, so leap days " "and day intervals are kept; LO and HI must be multiples of 4") ap.add_argument("--max-days", type=int, default=0, help="N (default 0): also add one day offset from -N to N to every full date of a patient; " "year-month, year-only and month/day dates without a year are not shifted by days") ap.add_argument("--seed", type=int, required=True) ap.add_argument("--patients", nargs="*") a = ap.parse_args() if a.out.resolve() == a.src.resolve(): raise SystemExit("--out must differ from --src") pids = a.patients or sorted(p.name for p in a.src.iterdir() if p.is_dir()) total, rows = Counter(), [] for pid in pids: rng = random.Random(f"{a.seed}:{pid}") sh = Shift(rng.choice(a.years), rng.randint(-a.max_days, a.max_days)) (a.out / pid).mkdir(parents=True, exist_ok=True) no_year = 0 for src in sorted((a.src / pid).glob("*.jsonl")): with open(src, encoding="utf-8") as fi, open(a.out / pid / src.name, "w", encoding="utf-8") as fo: for line in fi: r = json.loads(line) if sh.days: no_year += sum(len(MD_NUM.findall(s)) for s in leaves(r)) fo.write(json.dumps(walk(r, sh), ensure_ascii=False) + "\n") rows.append((pid, sh.years, sh.days)) total.update(sh.n) print(pid, dict(sh.n), f"numeric dates without a year: {no_year}" if sh.days else "") a.offsets.parent.mkdir(parents=True, exist_ok=True) a.offsets.write_text("patient\tyears\tdays\n" + "".join(f"{p}\t{y}\t{d}\n" for p, y, d in rows)) print("total", dict(total)) def leaves(o): if isinstance(o, dict): for v in o.values(): yield from leaves(v) elif isinstance(o, list): for v in o: yield from leaves(v) elif isinstance(o, str): yield o if __name__ == "__main__": main()