Add calendar-dated event index utilities
This commit is contained in:
104
event_date_utils.py
Normal file
104
event_date_utils.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Read and query calendar-dated disease-event arrays from prepare_event_dates.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
REQUIRED_FIELDS = {"eid", "event_date", "token"}
|
||||
|
||||
|
||||
def load_event_dates(path: str | Path) -> np.ndarray:
|
||||
"""Load and validate the structured ``.npy`` event array."""
|
||||
events = np.load(path)
|
||||
if events.dtype.names is None or not REQUIRED_FIELDS.issubset(events.dtype.names):
|
||||
raise ValueError(
|
||||
"Expected a structured .npy with eid, event_date, token fields. "
|
||||
"Create it with prepare_event_dates.py."
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
def load_token_labels(labels_file: str | Path) -> dict[int, str]:
|
||||
"""Load token -> human-readable code using the project label convention."""
|
||||
labels = {1: "CHECKUP"}
|
||||
with Path(labels_file).open(encoding="utf-8") as handle:
|
||||
for index, line in enumerate(handle):
|
||||
code = line.strip().split(" ", maxsplit=1)[0]
|
||||
if code:
|
||||
labels[index + 2] = code
|
||||
return labels
|
||||
|
||||
|
||||
@dataclass
|
||||
class EventDateIndex:
|
||||
"""Small in-memory query wrapper for exposure-linkage and cohort scripts."""
|
||||
|
||||
events: np.ndarray
|
||||
token_labels: dict[int, str] | None = None
|
||||
|
||||
@classmethod
|
||||
def from_files(
|
||||
cls,
|
||||
event_file: str | Path,
|
||||
labels_file: str | Path | None = None,
|
||||
) -> "EventDateIndex":
|
||||
labels = load_token_labels(labels_file) if labels_file is not None else None
|
||||
return cls(load_event_dates(event_file), labels)
|
||||
|
||||
def to_frame(self, events: np.ndarray | None = None) -> pd.DataFrame:
|
||||
"""Convert records to a convenient, calendar-dated DataFrame."""
|
||||
data = self.events if events is None else events
|
||||
frame = pd.DataFrame(
|
||||
{
|
||||
"eid": data["eid"].astype("int64"),
|
||||
"event_date": pd.to_datetime(data["event_date"]),
|
||||
"token": data["token"].astype("int32"),
|
||||
}
|
||||
)
|
||||
if self.token_labels is not None:
|
||||
frame["label_code"] = frame["token"].map(self.token_labels).fillna("UNKNOWN")
|
||||
return frame.sort_values(["eid", "event_date", "token"], kind="stable").reset_index(drop=True)
|
||||
|
||||
def for_eid(self, eid: int) -> pd.DataFrame:
|
||||
"""Return every stored disease/death event for one participant."""
|
||||
return self.to_frame(self.events[self.events["eid"] == int(eid)])
|
||||
|
||||
def between(
|
||||
self,
|
||||
start: str | pd.Timestamp,
|
||||
end: str | pd.Timestamp,
|
||||
*,
|
||||
eids: Iterable[int] | None = None,
|
||||
tokens: Iterable[int] | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Query events in an inclusive calendar-date interval."""
|
||||
start_day = np.datetime64(pd.Timestamp(start).date(), "D")
|
||||
end_day = np.datetime64(pd.Timestamp(end).date(), "D")
|
||||
mask = (self.events["event_date"] >= start_day) & (self.events["event_date"] <= end_day)
|
||||
if eids is not None:
|
||||
mask &= np.isin(self.events["eid"], list(eids))
|
||||
if tokens is not None:
|
||||
mask &= np.isin(self.events["token"], list(tokens))
|
||||
return self.to_frame(self.events[mask])
|
||||
|
||||
def anchors_before(self, eid: int, date: str | pd.Timestamp) -> pd.DataFrame:
|
||||
"""Return a participant's event history strictly before an exposure anchor."""
|
||||
day = np.datetime64(pd.Timestamp(date).date(), "D")
|
||||
mask = (self.events["eid"] == int(eid)) & (self.events["event_date"] < day)
|
||||
return self.to_frame(self.events[mask])
|
||||
|
||||
def first_event(self, token: int) -> pd.DataFrame:
|
||||
"""Return each participant's first date for a requested token."""
|
||||
selected = self.events[self.events["token"] == int(token)]
|
||||
# Arrays produced by prepare_event_dates.py are already deduplicated;
|
||||
# sorting makes this safe for externally produced compatible arrays too.
|
||||
order = np.lexsort((selected["event_date"], selected["eid"]))
|
||||
selected = selected[order]
|
||||
_, first = np.unique(selected["eid"], return_index=True)
|
||||
return self.to_frame(selected[first])
|
||||
203
prepare_event_dates.py
Normal file
203
prepare_event_dates.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""Create a compact calendar-dated disease-event index from ``ukb_data.csv``.
|
||||
|
||||
Unlike ``prepare_data.py``, this ETL does not create model-ready relative-time
|
||||
sequences or other-information tokens. It writes one structured ``.npy`` file
|
||||
with exactly three fields:
|
||||
|
||||
eid int64
|
||||
event_date datetime64[D]
|
||||
token int32
|
||||
|
||||
``token`` follows the existing ``labels.csv`` convention used by
|
||||
``prepare_data.py``: padding=0, checkup=1 (not emitted here), and the first
|
||||
label in ``labels.csv`` receives token 2. Each ``(eid, token)`` is deduplicated
|
||||
to the first known event date.
|
||||
|
||||
The output is intended for calendar-indexed temperature and air-pollution
|
||||
queries. It contains no date of birth, sex, covariates, or checkup events.
|
||||
|
||||
Usage
|
||||
-----
|
||||
python prepare_event_dates.py --output ukb_disease_event_dates.npy
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
try:
|
||||
from tqdm import tqdm
|
||||
except ImportError: # Keep the ETL runnable in minimal Python environments.
|
||||
tqdm = None
|
||||
|
||||
|
||||
EVENT_DTYPE = np.dtype(
|
||||
[
|
||||
("eid", "<i8"),
|
||||
("event_date", "datetime64[D]"),
|
||||
("token", "<i4"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def load_label_tokens(labels_file: str | Path) -> dict[str, int]:
|
||||
"""Return the label-code -> token mapping shared with ``prepare_data.py``."""
|
||||
token_map: dict[str, int] = {}
|
||||
with Path(labels_file).open(encoding="utf-8") as handle:
|
||||
for index, line in enumerate(handle):
|
||||
code = line.strip().split(" ", maxsplit=1)[0]
|
||||
if code:
|
||||
token_map[code] = index + 2
|
||||
return token_map
|
||||
|
||||
|
||||
def build_raw_column_map(
|
||||
field_map_file: str | Path,
|
||||
icd_map_file: str | Path,
|
||||
) -> tuple[dict[str, str], list[str]]:
|
||||
"""Build raw-column renames and the calendar-date event columns to inspect."""
|
||||
field_df = pd.read_csv(field_map_file, low_memory=False)
|
||||
required = {"field_instance", "var_name"}
|
||||
missing = required - set(field_df.columns)
|
||||
if missing:
|
||||
raise ValueError(f"{field_map_file} is missing columns: {sorted(missing)}")
|
||||
|
||||
raw_to_name = dict(
|
||||
zip(field_df["field_instance"].astype(str), field_df["var_name"].astype(str))
|
||||
)
|
||||
icd_date_columns: list[str] = []
|
||||
with Path(icd_map_file).open(encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
parts = line.strip().split()
|
||||
if len(parts) >= 6:
|
||||
raw_to_name[parts[0]] = parts[5]
|
||||
icd_date_columns.append(parts[5])
|
||||
|
||||
for slot in range(17):
|
||||
raw_to_name[f"40005-{slot}.0"] = f"cancer_date_{slot}"
|
||||
raw_to_name[f"40006-{slot}.0"] = f"cancer_type_{slot}"
|
||||
return raw_to_name, icd_date_columns
|
||||
|
||||
|
||||
def _records_from_icd_columns(
|
||||
chunk: pd.DataFrame,
|
||||
event_columns: list[str],
|
||||
token_map: dict[str, int],
|
||||
) -> list[pd.DataFrame]:
|
||||
frames: list[pd.DataFrame] = []
|
||||
for column in event_columns:
|
||||
token = token_map.get(column)
|
||||
if token is None or column not in chunk.columns:
|
||||
continue
|
||||
event_date = pd.to_datetime(chunk[column], format="%Y-%m-%d", errors="coerce")
|
||||
valid = event_date.notna()
|
||||
if valid.any():
|
||||
frames.append(
|
||||
pd.DataFrame(
|
||||
{
|
||||
"eid": chunk.index[valid].astype("int64"),
|
||||
"event_date": event_date.loc[valid].to_numpy(),
|
||||
"token": np.full(valid.sum(), token, dtype=np.int32),
|
||||
}
|
||||
)
|
||||
)
|
||||
return frames
|
||||
|
||||
|
||||
def _records_from_cancer_columns(chunk: pd.DataFrame, token_map: dict[str, int]) -> list[pd.DataFrame]:
|
||||
frames: list[pd.DataFrame] = []
|
||||
for slot in range(17):
|
||||
date_column = f"cancer_date_{slot}"
|
||||
type_column = f"cancer_type_{slot}"
|
||||
if date_column not in chunk.columns or type_column not in chunk.columns:
|
||||
continue
|
||||
event_date = pd.to_datetime(chunk[date_column], format="%Y-%m-%d", errors="coerce")
|
||||
code = chunk[type_column].astype("string").str.slice(0, 3)
|
||||
token = code.map(token_map)
|
||||
valid = event_date.notna() & token.notna()
|
||||
if valid.any():
|
||||
frames.append(
|
||||
pd.DataFrame(
|
||||
{
|
||||
"eid": chunk.index[valid].astype("int64"),
|
||||
"event_date": event_date.loc[valid].to_numpy(),
|
||||
"token": token.loc[valid].astype("int32").to_numpy(),
|
||||
}
|
||||
)
|
||||
)
|
||||
return frames
|
||||
|
||||
|
||||
def prepare_event_dates(
|
||||
*,
|
||||
ukb_data_file: str | Path,
|
||||
field_map_file: str | Path,
|
||||
icd_map_file: str | Path,
|
||||
labels_file: str | Path,
|
||||
output_file: str | Path,
|
||||
chunksize: int = 10_000,
|
||||
) -> int:
|
||||
"""Stream the raw UKB export, then write a sorted structured event array."""
|
||||
token_map = load_label_tokens(labels_file)
|
||||
raw_to_name, icd_date_columns = build_raw_column_map(field_map_file, icd_map_file)
|
||||
event_columns = [*icd_date_columns, "Death"]
|
||||
frames: list[pd.DataFrame] = []
|
||||
|
||||
reader = pd.read_csv(
|
||||
ukb_data_file,
|
||||
chunksize=chunksize,
|
||||
index_col=0, # UKB participant ID / eid
|
||||
low_memory=False,
|
||||
)
|
||||
chunk_iterator = tqdm(reader, desc="Extracting calendar-dated disease events") if tqdm else reader
|
||||
for raw_chunk in chunk_iterator:
|
||||
chunk = raw_chunk.rename(columns=raw_to_name)
|
||||
frames.extend(_records_from_icd_columns(chunk, event_columns, token_map))
|
||||
frames.extend(_records_from_cancer_columns(chunk, token_map))
|
||||
|
||||
if not frames:
|
||||
result = np.empty(0, dtype=EVENT_DTYPE)
|
||||
else:
|
||||
events = pd.concat(frames, ignore_index=True)
|
||||
events = events.dropna(subset=["eid", "event_date", "token"])
|
||||
events = events.sort_values(["eid", "token", "event_date"], kind="stable")
|
||||
# Match prepare_data.py: first occurrence of each disease/death token.
|
||||
events = events.drop_duplicates(["eid", "token"], keep="first")
|
||||
events = events.sort_values(["eid", "event_date", "token"], kind="stable")
|
||||
|
||||
result = np.empty(len(events), dtype=EVENT_DTYPE)
|
||||
result["eid"] = events["eid"].to_numpy(dtype=np.int64)
|
||||
result["event_date"] = events["event_date"].to_numpy(dtype="datetime64[D]")
|
||||
result["token"] = events["token"].to_numpy(dtype=np.int32)
|
||||
|
||||
np.save(output_file, result)
|
||||
return len(result)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--ukb-data", default="ukb_data.csv")
|
||||
parser.add_argument("--field-map", default="field_ids_enriched.csv")
|
||||
parser.add_argument("--icd-map", default="icd10_codes_mod.tsv")
|
||||
parser.add_argument("--labels", default="labels.csv")
|
||||
parser.add_argument("--output", default="ukb_disease_event_dates.npy")
|
||||
parser.add_argument("--chunksize", type=int, default=10_000)
|
||||
args = parser.parse_args()
|
||||
if args.chunksize <= 0:
|
||||
raise ValueError("chunksize must be positive")
|
||||
count = prepare_event_dates(
|
||||
ukb_data_file=args.ukb_data,
|
||||
field_map_file=args.field_map,
|
||||
icd_map_file=args.icd_map,
|
||||
labels_file=args.labels,
|
||||
output_file=args.output,
|
||||
chunksize=args.chunksize,
|
||||
)
|
||||
print(f"Wrote {count:,} first disease/death events to {args.output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user