Align autoencoder splits and add multi-GPU training
This commit is contained in:
@@ -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`.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user