Align autoencoder splits and add multi-GPU training

This commit is contained in:
2026-07-09 13:31:24 +08:00
parent 8a083ed538
commit 54fedc620b
2 changed files with 83 additions and 30 deletions

View File

@@ -64,9 +64,12 @@ Pretrain the exposure encoder as a denoising autoencoder using training-set EIDs
```bash ```bash
python train_exposure_autoencoder.py \ python train_exposure_autoencoder.py \
--exposure_cache_dir ukb_exposure_cache \ --exposure_cache_dir ukb_exposure_cache \
--train_eid_file ukb_train_eid.csv --train_eid_file ukb_train_eid.csv \
--val_eid_file ukb_val_eid.csv
``` ```
The best checkpoint contains both `model_state_dict`, an `encoder_state_dict` The best checkpoint contains both `model_state_dict`, an `encoder_state_dict`
compatible with the default gated `TimesNetExposureEncoder`, and the channel compatible with the default gated `TimesNetExposureEncoder`, and the channel
normalization statistics needed when the encoder is attached to DeepHealth. normalization statistics needed when the encoder is attached to DeepHealth.
Multi-GPU pretraining follows the main trainer interface: add
`--data_parallel --gpu_ids 0,1,2,3`.

View File

@@ -50,9 +50,9 @@ def parse_args() -> argparse.Namespace:
) )
parser.add_argument("--exposure_cache_dir", required=True) parser.add_argument("--exposure_cache_dir", required=True)
parser.add_argument("--train_eid_file", default="ukb_train_eid.csv") parser.add_argument("--train_eid_file", default="ukb_train_eid.csv")
parser.add_argument("--val_eid_file", default="ukb_val_eid.csv")
parser.add_argument("--runs_root", default="runs") parser.add_argument("--runs_root", default="runs")
parser.add_argument("--seed", type=int, default=42) parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--val_fraction", type=float, default=0.05)
parser.add_argument("--n_embd", type=int, default=120) parser.add_argument("--n_embd", type=int, default=120)
parser.add_argument("--d_model", type=int, default=None) parser.add_argument("--d_model", type=int, default=None)
parser.add_argument("--n_layers", type=int, default=2) parser.add_argument("--n_layers", type=int, default=2)
@@ -72,35 +72,80 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--num_workers", type=int, default=4) parser.add_argument("--num_workers", type=int, default=4)
parser.add_argument("--device", default="cuda") parser.add_argument("--device", default="cuda")
parser.add_argument("--amp", action=argparse.BooleanOptionalAction, default=True) parser.add_argument("--amp", action=argparse.BooleanOptionalAction, default=True)
parser.add_argument(
"--data_parallel",
action="store_true",
help="Use torch.nn.DataParallel across multiple CUDA devices.",
)
parser.add_argument(
"--gpu_ids",
default=None,
help="Comma-separated CUDA device ids for --data_parallel, e.g. 0,1,2,3.",
)
args = parser.parse_args() args = parser.parse_args()
if not 0.0 < args.val_fraction < 1.0:
parser.error("--val_fraction must be between 0 and 1")
if not 0.0 <= args.mask_ratio < 1.0: if not 0.0 <= args.mask_ratio < 1.0:
parser.error("--mask_ratio must be in [0, 1)") parser.error("--mask_ratio must be in [0, 1)")
if args.gpu_ids:
try:
args.gpu_ids = [
int(part.strip())
for part in args.gpu_ids.split(",")
if part.strip()
]
except ValueError as exc:
parser.error("--gpu_ids must be a comma-separated list of integers")
if not args.gpu_ids:
parser.error("--gpu_ids did not contain any valid CUDA device ids")
args.data_parallel = True
return args return args
def select_rows( def select_rows(cache: ExposureCache, eids: set[int], split: str) -> np.ndarray:
cache: ExposureCache, train_eids: set[int], val_fraction: float, seed: int
) -> tuple[np.ndarray, np.ndarray]:
candidate_eids = np.asarray(
sorted(set(map(int, cache.eids)) & train_eids), dtype=np.int64
)
if len(candidate_eids) < 2:
raise ValueError("Need at least two training EIDs with cached exposure")
rng = np.random.default_rng(seed)
rng.shuffle(candidate_eids)
n_val = max(1, int(round(len(candidate_eids) * val_fraction)))
val_eids = candidate_eids[:n_val]
fit_eids = candidate_eids[n_val:]
valid_row = np.asarray(cache.row_index, dtype=np.int64) >= 0 valid_row = np.asarray(cache.row_index, dtype=np.int64) >= 0
fit_event_rows = valid_row & np.isin(cache.eids, fit_eids) selected_events = valid_row & np.isin(cache.eids, np.fromiter(eids, np.int64))
val_event_rows = valid_row & np.isin(cache.eids, val_eids) rows = np.unique(
fit_rows = np.unique(np.asarray(cache.row_index[fit_event_rows], dtype=np.int64)) np.asarray(cache.row_index[selected_events], dtype=np.int64)
val_rows = np.unique(np.asarray(cache.row_index[val_event_rows], dtype=np.int64)) )
if len(fit_rows) == 0 or len(val_rows) == 0: if len(rows) == 0:
raise ValueError("Training/validation exposure rows are empty after filtering") raise ValueError(f"{split} exposure rows are empty after EID filtering")
return fit_rows, val_rows return rows
def maybe_wrap_data_parallel(
model: TimesNetExposureAutoencoder,
args: argparse.Namespace,
device: torch.device,
logger,
):
if not args.data_parallel:
return model
if device.type != "cuda":
raise ValueError("--data_parallel requires --device cuda or cuda:<id>")
if not torch.cuda.is_available() or torch.cuda.device_count() < 2:
raise ValueError("--data_parallel requires at least two CUDA devices")
primary = (
int(device.index)
if device.index is not None
else int(torch.cuda.current_device())
)
device_ids = (
args.gpu_ids
if args.gpu_ids
else list(range(torch.cuda.device_count()))
)
device_ids = [primary, *[idx for idx in device_ids if idx != primary]]
if len(device_ids) < 2:
raise ValueError("--data_parallel needs at least two device ids")
if any(idx < 0 or idx >= torch.cuda.device_count() for idx in device_ids):
raise ValueError(f"CUDA device id is out of range: {device_ids}")
logger.info(f"Using DataParallel on CUDA devices: {device_ids}")
return torch.nn.DataParallel(
model, device_ids=device_ids, output_device=primary
)
def unwrap_model(model) -> TimesNetExposureAutoencoder:
return model.module if isinstance(model, torch.nn.DataParallel) else model
def channel_stats( def channel_stats(
@@ -133,7 +178,7 @@ def masked_mse(
def run_epoch( def run_epoch(
model: TimesNetExposureAutoencoder, model,
loader: DataLoader, loader: DataLoader,
device: torch.device, device: torch.device,
stats: tuple[torch.Tensor, ...], stats: tuple[torch.Tensor, ...],
@@ -218,9 +263,12 @@ def main() -> None:
) )
logger = setup_logging(run_dir) logger = setup_logging(run_dir)
cache = ExposureCache(args.exposure_cache_dir) cache = ExposureCache(args.exposure_cache_dir)
train_rows, val_rows = select_rows( train_eids = load_eid_file(args.train_eid_file)
cache, load_eid_file(args.train_eid_file), args.val_fraction, args.seed val_eids = load_eid_file(args.val_eid_file)
) if train_eids & val_eids:
raise ValueError("train and validation EID files must be disjoint")
train_rows = select_rows(cache, train_eids, "Training")
val_rows = select_rows(cache, val_eids, "Validation")
raw_stats = channel_stats(cache, train_rows) raw_stats = channel_stats(cache, train_rows)
stats = tuple( stats = tuple(
torch.as_tensor(value, device=device).view(1, 1, -1) torch.as_tensor(value, device=device).view(1, 1, -1)
@@ -244,6 +292,7 @@ def main() -> None:
conv_kernel_size=args.conv_kernel_size, mlp_ratio=args.mlp_ratio, conv_kernel_size=args.conv_kernel_size, mlp_ratio=args.mlp_ratio,
dropout=args.dropout, dropout=args.dropout,
).to(device) ).to(device)
model = maybe_wrap_data_parallel(model, args, device, logger)
optimizer = AdamW( optimizer = AdamW(
model.parameters(), lr=args.base_lr, model.parameters(), lr=args.base_lr,
weight_decay=args.weight_decay, betas=(0.9, 0.95), weight_decay=args.weight_decay, betas=(0.9, 0.95),
@@ -292,10 +341,11 @@ def main() -> None:
if val_loss < best_loss: if val_loss < best_loss:
best_loss = val_loss best_loss = val_loss
stale_epochs = 0 stale_epochs = 0
checkpoint_model = unwrap_model(model)
torch.save( torch.save(
{ {
"model_state_dict": model.state_dict(), "model_state_dict": checkpoint_model.state_dict(),
"encoder_state_dict": model.encoder.state_dict(), "encoder_state_dict": checkpoint_model.encoder.state_dict(),
"model_config": { "model_config": {
key: config[key] for key in ( key: config[key] for key in (
"n_embd", "d_model", "n_layers", "top_k", "n_embd", "d_model", "n_layers", "top_k",