Optimize exposure index training IO

This commit is contained in:
2026-07-08 11:10:56 +08:00
parent 1288087959
commit 2388d81678
2 changed files with 222 additions and 40 deletions

View File

@@ -45,7 +45,7 @@ def _monthly_exposure_columns() -> list[str]:
class ExposureCache: class ExposureCache:
"""Random-access view over files produced by prepare_exposure_cache.py.""" """Random-access view over files produced by prepare_exposure_cache.py."""
def __init__(self, cache_dir: str | Path, row_group_cache_size: int = 16): def __init__(self, cache_dir: str | Path, row_group_cache_size: int = 4):
cache_dir = Path(cache_dir) cache_dir = Path(cache_dir)
self.cache_dir = cache_dir self.cache_dir = cache_dir
manifest_path = cache_dir / "exposure_manifest.json" manifest_path = cache_dir / "exposure_manifest.json"
@@ -129,6 +129,21 @@ class ExposureCache:
self._key_to_index: dict[tuple[int, int, int], int] | None = None self._key_to_index: dict[tuple[int, int, int], int] | None = None
def locality_key(self, indices: np.ndarray) -> tuple[int, int]:
"""Return a stable parquet locality key for sampler-side batching."""
indices = np.asarray(indices, dtype=np.int64)
valid = indices[indices >= 0]
if len(valid) == 0:
return (2**31 - 1, 2**31 - 1)
if self.storage != "parquet_index":
return (0, int(valid[0] // 1024))
file_ids = np.asarray(self.daily_file_ids[valid], dtype=np.int64)
row_groups = np.asarray(self.daily_row_groups[valid], dtype=np.int64)
groups = np.stack([file_ids, row_groups], axis=1)
unique_groups, counts = np.unique(groups, axis=0, return_counts=True)
best = unique_groups[int(np.argmax(counts))]
return (int(best[0]), int(best[1]))
def build_age_index(self, birth_date_by_eid: dict[int, np.datetime64]) -> None: def build_age_index(self, birth_date_by_eid: dict[int, np.datetime64]) -> None:
keys: dict[tuple[int, int, int], int] = {} keys: dict[tuple[int, int, int], int] = {}
eids = np.asarray(self.eids, dtype=np.int64) eids = np.asarray(self.eids, dtype=np.int64)
@@ -159,18 +174,67 @@ class ExposureCache:
return out return out
def daily_window(self, index: int) -> np.ndarray: def daily_window(self, index: int) -> np.ndarray:
if index < 0: return self.daily_windows(np.asarray([index], dtype=np.int64))[0]
return np.full(DAILY_EXPOSURE_SHAPE, np.nan, dtype=np.float32)
if self.storage == "dense_npy":
return np.asarray(self.daily[index], dtype=np.float32)
return self._parquet_window("daily", index)
def monthly_window(self, index: int) -> np.ndarray: def monthly_window(self, index: int) -> np.ndarray:
if index < 0: return self.monthly_windows(np.asarray([index], dtype=np.int64))[0]
return np.full(MONTHLY_EXPOSURE_SHAPE, np.nan, dtype=np.float32)
def daily_windows(self, indices: np.ndarray) -> np.ndarray:
return self._windows("daily", indices)
def monthly_windows(self, indices: np.ndarray) -> np.ndarray:
return self._windows("monthly", indices)
def _windows(
self,
kind: Literal["daily", "monthly"],
indices: np.ndarray,
) -> np.ndarray:
indices = np.asarray(indices, dtype=np.int64)
shape = DAILY_EXPOSURE_SHAPE if kind == "daily" else MONTHLY_EXPOSURE_SHAPE
out = np.full((len(indices), shape[0], shape[1]), np.nan, dtype=np.float32)
valid_pos = np.nonzero(indices >= 0)[0]
if len(valid_pos) == 0:
return out
valid_indices = indices[valid_pos]
if self.storage == "dense_npy": if self.storage == "dense_npy":
return np.asarray(self.monthly[index], dtype=np.float32) source = self.daily if kind == "daily" else self.monthly
return self._parquet_window("monthly", index) out[valid_pos] = np.asarray(source[valid_indices], dtype=np.float32)
return out
if kind == "daily":
file_ids = np.asarray(self.daily_file_ids[valid_indices], dtype=np.int64)
row_groups = np.asarray(self.daily_row_groups[valid_indices], dtype=np.int64)
row_in_groups = np.asarray(self.daily_row_in_groups[valid_indices], dtype=np.int64)
columns = _daily_exposure_columns()
else:
file_ids = np.asarray(self.monthly_file_ids[valid_indices], dtype=np.int64)
row_groups = np.asarray(self.monthly_row_groups[valid_indices], dtype=np.int64)
row_in_groups = np.asarray(
self.monthly_row_in_groups[valid_indices],
dtype=np.int64,
)
columns = _monthly_exposure_columns()
group_keys = np.stack([file_ids, row_groups], axis=1)
unique_groups, inverse = np.unique(group_keys, axis=0, return_inverse=True)
for group_idx, (file_id, row_group) in enumerate(unique_groups):
group_pos = np.nonzero(inverse == group_idx)[0]
frame = self._read_parquet_row_group(
kind,
int(file_id),
int(row_group),
columns,
)
row_values = frame.iloc[row_in_groups[group_pos]].reindex(columns=columns)
values = (
row_values.to_numpy(dtype=np.float32, copy=True)
.reshape(len(group_pos), shape[1], shape[0])
.transpose(0, 2, 1)
)
out[valid_pos[group_pos]] = values
return out
def _parquet_window(self, kind: Literal["daily", "monthly"], index: int) -> np.ndarray: def _parquet_window(self, kind: Literal["daily", "monthly"], index: int) -> np.ndarray:
if kind == "daily": if kind == "daily":
@@ -308,13 +372,17 @@ class _ExpoBaseDataset(Dataset):
include_no_event_in_uts_target: bool = False, include_no_event_in_uts_target: bool = False,
exposure_cache_dir: str | Path | None = None, exposure_cache_dir: str | Path | None = None,
mask_onset_exposure: bool = False, mask_onset_exposure: bool = False,
exposure_row_group_cache_size: int = 4,
) -> None: ) -> None:
self.data_prefix = data_prefix self.data_prefix = data_prefix
self.labels_file = labels_file self.labels_file = labels_file
self.no_event_interval_years = float(no_event_interval_years) self.no_event_interval_years = float(no_event_interval_years)
self.include_no_event_in_uts_target = bool(include_no_event_in_uts_target) self.include_no_event_in_uts_target = bool(include_no_event_in_uts_target)
self.exposure_cache = ( self.exposure_cache = (
ExposureCache(exposure_cache_dir) ExposureCache(
exposure_cache_dir,
row_group_cache_size=exposure_row_group_cache_size,
)
if exposure_cache_dir is not None if exposure_cache_dir is not None
else None else None
) )
@@ -450,14 +518,14 @@ class _ExpoBaseDataset(Dataset):
if self.exposure_cache is None: if self.exposure_cache is None:
raise RuntimeError("Exposure cache is not enabled") raise RuntimeError("Exposure cache is not enabled")
daily = np.stack( daily = self.exposure_cache.daily_windows(exposure_index).astype(
[self.exposure_cache.daily_window(int(idx)) for idx in exposure_index], np.float32,
axis=0, copy=False,
).astype(np.float32, copy=True) )
monthly = np.stack( monthly = self.exposure_cache.monthly_windows(exposure_index).astype(
[self.exposure_cache.monthly_window(int(idx)) for idx in exposure_index], np.float32,
axis=0, copy=False,
).astype(np.float32, copy=True) )
if self.mask_onset_exposure: if self.mask_onset_exposure:
daily[:, 0, :] = np.nan daily[:, 0, :] = np.nan
@@ -485,6 +553,7 @@ class NextStepHealthDataset(_ExpoBaseDataset):
include_no_event_in_uts_target: bool = False, include_no_event_in_uts_target: bool = False,
exposure_cache_dir: str | Path | None = None, exposure_cache_dir: str | Path | None = None,
mask_onset_exposure: bool = False, mask_onset_exposure: bool = False,
exposure_row_group_cache_size: int = 4,
) -> None: ) -> None:
super().__init__( super().__init__(
data_prefix=data_prefix, data_prefix=data_prefix,
@@ -493,6 +562,7 @@ class NextStepHealthDataset(_ExpoBaseDataset):
include_no_event_in_uts_target=include_no_event_in_uts_target, include_no_event_in_uts_target=include_no_event_in_uts_target,
exposure_cache_dir=exposure_cache_dir, exposure_cache_dir=exposure_cache_dir,
mask_onset_exposure=mask_onset_exposure, mask_onset_exposure=mask_onset_exposure,
exposure_row_group_cache_size=exposure_row_group_cache_size,
) )
self.samples: List[Dict] = [] self.samples: List[Dict] = []
@@ -581,6 +651,7 @@ class AllFutureHealthDataset(_ExpoBaseDataset):
validation_query_seed: int = 42, validation_query_seed: int = 42,
exposure_cache_dir: str | Path | None = None, exposure_cache_dir: str | Path | None = None,
mask_onset_exposure: bool = False, mask_onset_exposure: bool = False,
exposure_row_group_cache_size: int = 4,
) -> None: ) -> None:
if split not in {"train", "valid", "test"}: if split not in {"train", "valid", "test"}:
raise ValueError(f"split must be train/valid/test, got {split!r}") raise ValueError(f"split must be train/valid/test, got {split!r}")
@@ -592,6 +663,7 @@ class AllFutureHealthDataset(_ExpoBaseDataset):
include_no_event_in_uts_target=include_no_event_in_uts_target, include_no_event_in_uts_target=include_no_event_in_uts_target,
exposure_cache_dir=exposure_cache_dir, exposure_cache_dir=exposure_cache_dir,
mask_onset_exposure=mask_onset_exposure, mask_onset_exposure=mask_onset_exposure,
exposure_row_group_cache_size=exposure_row_group_cache_size,
) )
self.split = split self.split = split

View File

@@ -12,13 +12,13 @@ import json
import logging import logging
import math import math
import time import time
from typing import Any, Dict from typing import Any, Dict, Iterator, List
import numpy as np import numpy as np
import torch import torch
from torch.nn.utils import clip_grad_norm_ from torch.nn.utils import clip_grad_norm_
from torch.optim import AdamW from torch.optim import AdamW
from torch.utils.data import DataLoader, RandomSampler from torch.utils.data import DataLoader, RandomSampler, Sampler
from tqdm.auto import tqdm from tqdm.auto import tqdm
from dataset import HealthDataset, collate_fn from dataset import HealthDataset, collate_fn
@@ -53,6 +53,59 @@ EXPOSURE_INPUT_KEYS = (
) )
class ExposureLocalityBatchSampler(Sampler[List[int]]):
"""Randomized batches with within-buffer sorting by exposure parquet locality."""
def __init__(
self,
data_source,
batch_size: int,
buffer_size: int,
seed: int,
drop_last: bool = False,
) -> None:
self.data_source = data_source
self.batch_size = int(batch_size)
self.buffer_size = max(int(buffer_size), self.batch_size)
self.seed = int(seed)
self.drop_last = bool(drop_last)
self.epoch = 0
def __iter__(self) -> Iterator[List[int]]:
n = len(self.data_source)
generator = torch.Generator().manual_seed(self.seed + self.epoch)
self.epoch += 1
shuffled = torch.randperm(n, generator=generator).tolist()
for start in range(0, n, self.buffer_size):
buffer = shuffled[start:start + self.buffer_size]
buffer.sort(key=self._locality_key)
for batch_start in range(0, len(buffer), self.batch_size):
batch = buffer[batch_start:batch_start + self.batch_size]
if len(batch) < self.batch_size and self.drop_last:
continue
yield batch
def __len__(self) -> int:
n = len(self.data_source)
if self.drop_last:
return n // self.batch_size
return (n + self.batch_size - 1) // self.batch_size
def _locality_key(self, local_idx: int) -> tuple[int, int, int]:
dataset = self.data_source
raw_idx = int(local_idx)
if hasattr(dataset, "dataset") and hasattr(dataset, "indices"):
raw_idx = int(dataset.indices[local_idx])
dataset = dataset.dataset
sample = getattr(dataset, "samples", [])[raw_idx]
exposure_index = sample.get("exposure_index")
exposure_cache = getattr(dataset, "exposure_cache", None)
if exposure_index is None or exposure_cache is None:
return (2**31 - 1, 2**31 - 1, raw_idx)
file_id, row_group = exposure_cache.locality_key(exposure_index)
return (file_id, row_group, raw_idx)
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Train DeepHealth with next-token/point supervision") description="Train DeepHealth with next-token/point supervision")
@@ -83,6 +136,15 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--exposure_conv_kernel_size", type=int, default=7) parser.add_argument("--exposure_conv_kernel_size", type=int, default=7)
parser.add_argument("--exposure_mlp_ratio", type=float, default=4.0) parser.add_argument("--exposure_mlp_ratio", type=float, default=4.0)
parser.add_argument("--no_exposure_gate", action="store_true") parser.add_argument("--no_exposure_gate", action="store_true")
parser.add_argument(
"--exposure_row_group_cache_size",
type=int,
default=4,
help=(
"Number of parquet exposure row groups cached per DataLoader worker "
"when using indexed exposure storage."
),
)
parser.add_argument("--target_mode", type=str, default="uts", parser.add_argument("--target_mode", type=str, default="uts",
choices=["delphi2m", "uts"]) choices=["delphi2m", "uts"])
@@ -106,6 +168,21 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--patience", type=int, default=15) parser.add_argument("--patience", type=int, default=15)
parser.add_argument("--min_lr_ratio", type=float, default=0.1) parser.add_argument("--min_lr_ratio", type=float, default=0.1)
parser.add_argument("--num_workers", type=int, default=4) parser.add_argument("--num_workers", type=int, default=4)
parser.add_argument(
"--prefetch_factor",
type=int,
default=4,
help="DataLoader batches prefetched per worker when num_workers > 0.",
)
parser.add_argument(
"--exposure_locality_buffer_size",
type=int,
default=4096,
help=(
"Training-only shuffle buffer sorted by exposure parquet locality. "
"Set 0 to use the standard RandomSampler."
),
)
parser.add_argument("--device", type=str, default="cuda") parser.add_argument("--device", type=str, default="cuda")
parser.add_argument("--progress_interval", type=int, default=20) parser.add_argument("--progress_interval", type=int, default=20)
@@ -116,6 +193,12 @@ def parse_args() -> argparse.Namespace:
) )
if not use_eid_split and not np.isclose(args.train_ratio + args.val_ratio + args.test_ratio, 1.0): if not use_eid_split and not np.isclose(args.train_ratio + args.val_ratio + args.test_ratio, 1.0):
raise ValueError("train_ratio + val_ratio + test_ratio must equal 1.0") raise ValueError("train_ratio + val_ratio + test_ratio must equal 1.0")
if args.num_workers > 0 and args.prefetch_factor <= 0:
raise ValueError("prefetch_factor must be positive when num_workers > 0")
if args.exposure_row_group_cache_size < 0:
raise ValueError("exposure_row_group_cache_size must be non-negative")
if args.exposure_locality_buffer_size < 0:
raise ValueError("exposure_locality_buffer_size must be non-negative")
if args.target_mode == "uts": if args.target_mode == "uts":
args.readout_name = args.readout_name or "same_time_group_end" args.readout_name = args.readout_name or "same_time_group_end"
args.include_no_event_in_uts_target = True args.include_no_event_in_uts_target = True
@@ -364,6 +447,10 @@ def build_metadata(
"exposure_conv_kernel_size": int(args.exposure_conv_kernel_size), "exposure_conv_kernel_size": int(args.exposure_conv_kernel_size),
"exposure_mlp_ratio": float(args.exposure_mlp_ratio), "exposure_mlp_ratio": float(args.exposure_mlp_ratio),
"exposure_use_gate": not bool(args.no_exposure_gate), "exposure_use_gate": not bool(args.no_exposure_gate),
"exposure_row_group_cache_size": int(args.exposure_row_group_cache_size),
"num_workers": int(args.num_workers),
"prefetch_factor": int(args.prefetch_factor),
"exposure_locality_buffer_size": int(args.exposure_locality_buffer_size),
"split_sizes": { "split_sizes": {
"train": int(len(train_subset)), "train": int(len(train_subset)),
"val": int(len(val_subset)), "val": int(len(val_subset)),
@@ -394,6 +481,13 @@ def main() -> None:
logger.info(f"Device: {device}") logger.info(f"Device: {device}")
logger.info(f"readout={args.readout_name}, target_mode={args.target_mode}") logger.info(f"readout={args.readout_name}, target_mode={args.target_mode}")
logger.info(f"exposure_cache_dir={args.exposure_cache_dir}") logger.info(f"exposure_cache_dir={args.exposure_cache_dir}")
logger.info(
"DataLoader IO: "
f"num_workers={args.num_workers}, "
f"prefetch_factor={args.prefetch_factor if args.num_workers > 0 else None}, "
f"exposure_row_group_cache_size={args.exposure_row_group_cache_size}, "
f"exposure_locality_buffer_size={args.exposure_locality_buffer_size}"
)
dataset = HealthDataset( dataset = HealthDataset(
data_prefix=args.data_prefix, data_prefix=args.data_prefix,
@@ -402,6 +496,7 @@ def main() -> None:
include_no_event_in_uts_target=args.include_no_event_in_uts_target, include_no_event_in_uts_target=args.include_no_event_in_uts_target,
exposure_cache_dir=args.exposure_cache_dir, exposure_cache_dir=args.exposure_cache_dir,
mask_onset_exposure=args.mask_onset_exposure, mask_onset_exposure=args.mask_onset_exposure,
exposure_row_group_cache_size=args.exposure_row_group_cache_size,
) )
if args.train_eid_file and args.val_eid_file and args.test_eid_file: if args.train_eid_file and args.val_eid_file and args.test_eid_file:
train_subset, val_subset, test_subset = split_dataset_by_eid_files( train_subset, val_subset, test_subset = split_dataset_by_eid_files(
@@ -430,35 +525,50 @@ def main() -> None:
f"Samples: train={len(train_subset)}, val={len(val_subset)}, test={len(test_subset)}" f"Samples: train={len(train_subset)}, val={len(val_subset)}, test={len(test_subset)}"
) )
train_loader = DataLoader( dataloader_kwargs = {
train_subset, "collate_fn": collate_fn,
batch_size=args.batch_size, "num_workers": args.num_workers,
sampler=RandomSampler(train_subset, generator=torch.Generator().manual_seed(args.seed)), "pin_memory": device.type == "cuda",
collate_fn=collate_fn, "persistent_workers": args.num_workers > 0,
num_workers=args.num_workers, "prefetch_factor": args.prefetch_factor if args.num_workers > 0 else None,
pin_memory=device.type == "cuda", }
persistent_workers=args.num_workers > 0, use_locality_sampler = (
prefetch_factor=2 if args.num_workers > 0 else None, args.exposure_cache_dir is not None
and args.exposure_locality_buffer_size > 0
) )
if use_locality_sampler:
logger.info("Using exposure-locality batch sampler for training")
train_loader = DataLoader(
train_subset,
batch_sampler=ExposureLocalityBatchSampler(
train_subset,
batch_size=args.batch_size,
buffer_size=args.exposure_locality_buffer_size,
seed=args.seed,
),
**dataloader_kwargs,
)
else:
train_loader = DataLoader(
train_subset,
batch_size=args.batch_size,
sampler=RandomSampler(
train_subset,
generator=torch.Generator().manual_seed(args.seed),
),
**dataloader_kwargs,
)
val_loader = DataLoader( val_loader = DataLoader(
val_subset, val_subset,
batch_size=args.batch_size, batch_size=args.batch_size,
shuffle=False, shuffle=False,
collate_fn=collate_fn, **dataloader_kwargs,
num_workers=args.num_workers,
pin_memory=device.type == "cuda",
persistent_workers=args.num_workers > 0,
prefetch_factor=2 if args.num_workers > 0 else None,
) )
test_loader = DataLoader( test_loader = DataLoader(
test_subset, test_subset,
batch_size=args.batch_size, batch_size=args.batch_size,
shuffle=False, shuffle=False,
collate_fn=collate_fn, **dataloader_kwargs,
num_workers=args.num_workers,
pin_memory=device.type == "cuda",
persistent_workers=args.num_workers > 0,
prefetch_factor=2 if args.num_workers > 0 else None,
) )
model = build_model(args, dataset).to(device) model = build_model(args, dataset).to(device)