Source code for pretrain.models.ndt2.ndt2_pretrain

import multiprocessing as mp

import hydra
import numpy as np
import torch
import torch.nn as nn
from rich.console import Console
from rich.table import Table
from torch.nn import PoissonNLLLoss
from torch.utils.data import DataLoader
from torch_brain.batching import collate
from torch_brain.samplers import RandomFixedWindowSampler, SequentialFixedWindowSampler
from torch_brain.transforms import Compose

from core.samplers import DistributedSamplerWrapper
from core.trainer import BaseTrainer
from core.utils.util import log_param_breakdown, move_to_device
from pretrain.datasets import IBLBrainWideBenchMaskModelingSpikes

from .masker import NDT2Masker
from .ndt2 import NDT2


[docs] class NDT2Pretrain(BaseTrainer): """Masked spike modelling for NDT2, which masks context tokens and accumulates gradients."""
[docs] def setup(self, ckpt: dict | None): self.logger.info(f"Trainer: {self.__class__}") self.reset_best_tracking(minimize=True) # task self.logger.info("Task: Mask Modeling (BERT style)") self.loss_fn = PoissonNLLLoss(log_input=True, full=True, eps=1e-9, reduction="none") # data loaders self.setup_train_loader() self.setup_val_loader() # model self.setup_model(ckpt) self.link_model(self.model, ckpt) # move model to device after linking, in case new parameters were created self.model.to(self.device) self.model = self.make_ddp(self.model) # optimizer and scheduler self.setup_optimizers(ckpt)
def setup_train_loader(self): self.train_dataset = IBLBrainWideBenchMaskModelingSpikes( root=self.cfg.data_root, recording_ids=self.cfg.recording_ids, split="train", ) self.train_sampler = DistributedSamplerWrapper( RandomFixedWindowSampler( sampling_intervals=self.train_dataset.get_trial_intervals(), window_length=self.train_dataset.context_window, generator=torch.Generator().manual_seed(self.cfg.seed), drop_short=True, ) ) has_workers = self.cfg.num_workers > 0 # cfg.batch_size is the global batch, so each rank draws its own share. self.train_loader = DataLoader( self.train_dataset, sampler=self.train_sampler, batch_size=self.cfg.batch_size // self.world_size, collate_fn=collate, num_workers=self.cfg.num_workers, pin_memory=self.cfg.pin_memory, prefetch_factor=self.cfg.prefetch_factor if has_workers else None, persistent_workers=self.cfg.persistent_workers if has_workers else False, multiprocessing_context=mp.get_context("fork") if has_workers else None, ) self.logger.info( f"Train dataset: {len(self.train_dataset.get_session_ids())} sessions, {len(self.train_dataset.get_unit_ids())} units" ) self.logger.info( f"Training on {len(self.train_loader)} batches, total {len(self.train_sampler)} samples" ) def setup_val_loader(self): self.val_dataset = IBLBrainWideBenchMaskModelingSpikes( root=self.cfg.data_root, recording_ids=self.cfg.recording_ids, split="val", ) self.val_sampler = DistributedSamplerWrapper( SequentialFixedWindowSampler( sampling_intervals=self.val_dataset.get_trial_intervals(), window_length=self.val_dataset.context_window, drop_short=True, ), ) has_workers = self.cfg.num_workers > 0 self.val_loader = DataLoader( self.val_dataset, sampler=self.val_sampler, batch_size=self.cfg.batch_size // self.world_size, collate_fn=collate, num_workers=self.cfg.num_workers, pin_memory=self.cfg.pin_memory, prefetch_factor=self.cfg.prefetch_factor if has_workers else None, persistent_workers=self.cfg.persistent_workers if has_workers else False, multiprocessing_context=mp.get_context("fork") if has_workers else None, ) self.logger.info( f"Val dataset: {len(self.val_dataset.get_session_ids())} sessions, {len(self.val_dataset.get_unit_ids())} units" ) self.logger.info( f"Validating on {len(self.val_loader)} batches, total {len(self.val_sampler)} samples" ) def setup_model(self, ckpt: dict | None = None): self.model = hydra.utils.instantiate(self.cfg.model) if not isinstance(self.model, NDT2): raise TypeError( f"{type(self).__name__} requires a NDT2 model, got {type(self.model).__name__}" ) self.masker = hydra.utils.instantiate(self.cfg.masker) if not isinstance(self.masker, NDT2Masker): raise TypeError( f"{type(self).__name__} requires a NDT2Masker masker, got {type(self.masker).__name__}" ) self.add_checkpoint_items(model=self.model, masker=self.masker) self.logger.info(f"Precision: {self.precision}") self.logger.info(f"Model: {self.model.__class__}") self.logger.info(f"Masker: {self.masker.__class__}") self.model.train() def link_model(self, model: nn.Module, ckpt: dict | None = None): # attach model input_fn to end of dataset transform pipelines for split in ["train", "val"]: model_transforms = hydra.utils.instantiate(self.cfg.get(f"{split}_transforms", [])) self.logger.info(f"Model transforms ({split}): {model_transforms}") dataset = getattr(self, f"{split}_loader").dataset if dataset.transform is None: dataset.transform = Compose([*model_transforms, model.input_fn]) else: dataset.transform = Compose([dataset.transform, *model_transforms, model.input_fn]) # link datasets to model model.link_datasets(self.train_loader.dataset, self.val_loader.dataset) # ckpt is set by ckpt.load_from, whether or not the run resumes epoch and step if ckpt is not None: model.load_ckpt(ckpt) log_param_breakdown(self.logger, self.model, ("session_emb", "subject_emb", "task_emb")) def setup_optimizers(self, ckpt: dict | None = None): if self.model is None: raise ValueError("Trying to configure optimizers before the model is setup") if self.train_loader is None: raise ValueError("Trying to configure optimizers before the train data loader is setup") grouped_parameters = self.get_param_groups() max_lr = self.cfg.base_lr * np.sqrt(self.cfg.batch_size * self.cfg.grad_accum_steps) self.logger.info(f"Max LR: {max_lr}") self.optimizer = torch.optim.AdamW( grouped_parameters, lr=max_lr, ) self.scheduler = torch.optim.lr_scheduler.OneCycleLR( self.optimizer, max_lr=max_lr, epochs=self.cfg.num_epochs, steps_per_epoch=int(np.ceil(len(self.train_loader) / self.cfg.grad_accum_steps)), pct_start=self.cfg.pct_start, anneal_strategy="cos", div_factor=self.cfg.div_factor, final_div_factor=1e4, ) self.add_checkpoint_items(optimizer=self.optimizer, scheduler=self.scheduler) return self.optimizer, self.scheduler
[docs] def train_epoch(self): self.model.train() loader = self.train_loader loader_len = len(loader) if self.cfg.log_train_step: loader = self.logger.get_pbar(loader, prefix="train") for i, X in enumerate(loader): X = move_to_device(X, device=self.device) with torch.autocast(device_type="cuda", dtype=self.precision.dtype): patches = X["model_inputs"]["in_patches"] ssl_mask = self.masker(patches) pred_rates = self.predict(X, ssl_mask) # `ssl_mask` selects which patches were hidden and are therefore queried; # `loss_mask` says which of those queried bins are scored. B, L_q, P = pred_rates.shape target = X["ssl"]["target"] target_spikes = target[ssl_mask].reshape(B, L_q, P).float() in_not_pad = X["model_inputs"]["in_not_pad"] query_not_pad = in_not_pad[ssl_mask].reshape(B, L_q).unsqueeze(-1) query_is_valid = X["ssl"]["is_valid"][ssl_mask].reshape(B, L_q, P) loss_mask = query_not_pad & query_is_valid unscaled_loss = self.loss(pred_rates, target_spikes, loss_mask) should_step = ( (i + 1) % self.cfg.grad_accum_steps == 0 # is accum step ) or ( (i + 1) == loader_len # is last batch ) loss = unscaled_loss / self.cfg.grad_accum_steps # DDP all-reduces on every backward, so the accumulation steps in between # pay for gradients no one reads until the step that syncs them. if self.is_distributed and not should_step: with self.model.no_sync(): loss.backward() else: loss.backward() if should_step: self.clip_and_log_grad_norm() self.optimizer.step() self.scheduler.step() self.optimizer.zero_grad() self.train_step += 1 if self.cfg.log_train_step: # log loss every step self.logger.log("train/loss", unscaled_loss.item(), pbar=True) self.logger.log("train/lr", self.scheduler.get_last_lr()[0], pbar=False) self.push_logs()
def predict(self, X, ssl_mask): rates = self.model(**X["model_inputs"], ssl_mask=ssl_mask) return rates def loss(self, pred_rates, target_spikes, loss_mask): loss = self.loss_fn(pred_rates, target_spikes) return loss[loss_mask].mean()
[docs] @torch.inference_mode() def val_epoch(self): self.model.eval() loss_sum, loss_count = 0.0, 0 for X in self.logger.get_pbar(self.val_loader, prefix="val"): X = move_to_device(X, device=self.device) with torch.autocast(device_type="cuda", dtype=self.precision.dtype): patches = X["model_inputs"]["in_patches"] ssl_mask = self.masker(patches) pred_rates = self.predict(X, ssl_mask) B, L_q, P = pred_rates.shape target = X["ssl"]["target"] target_spikes = target[ssl_mask].reshape(B, L_q, P).float() in_not_pad = X["model_inputs"]["in_not_pad"] query_not_pad = in_not_pad[ssl_mask].reshape(B, L_q).unsqueeze(-1) query_is_valid = X["ssl"]["is_valid"][ssl_mask].reshape(B, L_q, P) loss_mask = query_not_pad & query_is_valid loss = self.loss(pred_rates, target_spikes, loss_mask) n_scored = int(loss_mask.sum().item()) loss_sum += float(loss.item()) * n_scored loss_count += n_scored val_loss = self.reduce_mean(loss_sum, loss_count) metrics = { "avg": val_loss, "loss": val_loss, "epoch": self.epoch, } self.save_if_best(val_loss, metrics=metrics) # store and log metrics val_metrics = {f"val/{k}": v for k, v in metrics.items()} self.logger.log_dict(val_metrics) self.push_logs() self.logger.update_epoch_postfix(self.get_best_pbar_metrics()) self.return_value = val_metrics
[docs] def get_best_pbar_metrics(self) -> dict: if not self.best_metrics: return {} out = {} for k, v in self.best_metrics.items(): if "epoch" in k: continue bare = k.replace("best/val/", "") # drop per-session entries (e.g. "bps/<session_uuid>") to keep the # postfix readable; only show top-level keys and "*/avg" aggregates. if "/" in bare and not bare.endswith("/avg"): continue out[bare] = v return out
[docs] def log_training_results(self): table = Table(title="Final Best Metrics", show_header=True) table.add_column("Metric", style="cyan") table.add_column("Value", style="green") for k, v in sorted(self.best_metrics.items(), key=lambda item: item[0]): table.add_row(k, f"{v:.4f}" if isinstance(v, float) else str(v)) Console().print(table)
def _setup_ssl_task(self): self.logger.info("Task: Mask Modeling SSL (MAE style)") self.loss_fn = PoissonNLLLoss(log_input=True, full=True, eps=1e-9, reduction="none")