Source code for ts3.probes.mlp

"""The MLP probe: an Optuna sweep over subject-wise folds, refit on all pretrain units."""

import os
from typing import Any, Literal

import numpy as np
import pandas as pd
import ray
import torch
import torch.nn.functional as F
from ray import tune
from ray.tune.search.optuna.optuna_search import OptunaSearch
from sklearn.metrics import f1_score
from sklearn.preprocessing import StandardScaler
from torch import nn
from tqdm import tqdm

from core.optim import param_groups
from core.utils.logger import get_cli_logger
from core.utils.util import kfold_assignment, seed_everything
from ibl_bwb_eval.tasks import TS3ReadoutSpec, check_ts3_label_order
from ts3.probes.base import Probe

logger = get_cli_logger()

# Deliberately not a config group: the space is part of what the MLP probe is, so two
# submissions are compared on the same search rather than on how wide each one made it.
SEARCH_SPACE = {
    # model
    "m.hidden_layers": tune.randint(1, 4),
    "m.hidden_dim_log2": tune.randint(5, 9),
    "m.dropout": tune.quniform(0, 0.6, 0.2),
    "m.batch_norm": tune.choice([True, False]),
    "m.activation": tune.choice(["relu", "gelu", "tanh"]),
    # training
    "num_epochs": tune.qrandint(50, 200, 50),
    "batch_size_log2": tune.randint(7, 10),
    "lr": tune.loguniform(1e-5, 1e-2),
    "wd": tune.loguniform(1e-5, 1e-1),
}


def _seed_everything(seed: int):
    seed_everything(seed)
    # read at interpreter start, so this is for the ray workers, which are child processes
    os.environ["PYTHONHASHSEED"] = str(seed)
    torch.backends.cudnn.deterministic = True


def build_mlp(
    in_features: int,
    n_classes: int,
    hidden_dim: int,
    hidden_layers: int,
    dropout: float,
    activation: Literal["relu", "gelu", "tanh"],
    batch_norm: bool,
) -> nn.Module:
    assert hidden_layers >= 1

    def norm() -> nn.Module:
        if batch_norm:
            return nn.BatchNorm1d(hidden_dim)
        return nn.Identity()

    _ACT_MAP = {"relu": nn.ReLU, "gelu": nn.GELU, "tanh": nn.Tanh}
    act = _ACT_MAP[activation]

    layers = []
    for i in range(hidden_layers):
        in_dim = in_features if i == 0 else hidden_dim
        layer = nn.Sequential(
            nn.Linear(in_dim, hidden_dim),
            norm(),
            act(),
            nn.Dropout(dropout),
        )
        layers.append(layer)

    layers.append(nn.Linear(hidden_dim, n_classes))
    return nn.Sequential(*layers)


def train_one_mlp(x_train, y_train, hparams, device, n_classes, grad_clip, seed):
    """Fit one MLP, ``seed`` fixing its init, batch order and dropout masks.

    A Ray worker starts with a seed of its own, so this one travels with the work.
    """
    torch.manual_seed(seed)
    train_ds = torch.utils.data.TensorDataset(x_train, y_train)
    train_dl = torch.utils.data.DataLoader(
        train_ds,
        batch_size=2 ** hparams["batch_size_log2"],
        shuffle=True,
        drop_last=True,
    )
    _, counts = y_train.squeeze().unique(sorted=True, return_counts=True)
    assert len(counts) == n_classes, (
        f"fold covers {len(counts)} of {n_classes} classes; the class weights and the macro "
        "F1 both need every class present in the fold"
    )
    class_weights = counts.min() / counts
    class_weights = class_weights.to(device)

    model = build_mlp(
        in_features=x_train.size(1),
        n_classes=n_classes,
        hidden_dim=2 ** hparams["m.hidden_dim_log2"],
        hidden_layers=hparams["m.hidden_layers"],
        dropout=hparams["m.dropout"],
        activation=hparams["m.activation"],
        batch_norm=hparams["m.batch_norm"],
    ).to(device)

    optim = torch.optim.AdamW(
        params=param_groups(model, weight_decay=hparams["wd"]),
        lr=hparams["lr"],
    )

    lr_sched = torch.optim.lr_scheduler.CosineAnnealingLR(
        optim,
        T_max=len(train_dl) * hparams["num_epochs"],
        eta_min=hparams["lr"] * 1e-2,
    )

    pbar = tqdm(range(hparams["num_epochs"]), desc="Train epoch", leave=False)
    for _ in pbar:
        model.train()
        for x, y in train_dl:
            x, y = x.to(device), y.to(device)
            pred = model(x)
            loss = F.cross_entropy(pred, y.squeeze(), weight=class_weights)

            optim.zero_grad()
            loss.backward()
            if grad_clip is not None:
                torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip)
            optim.step()
            lr_sched.step()

    return model


def tune_func(hparams: dict[str, Any], X, Y, kfold_idx, n_classes, num_folds, grad_clip, seed):
    dev = torch.device("cuda")

    metric_list = []
    for fold_idx in range(num_folds):
        # On purpose fit on the one fold, score on the other four.
        # the winning hparams are refit on all pretrain units in fit_predict()
        train_mask = kfold_idx == fold_idx

        # Train
        x_train, y_train = X[train_mask].clone(), Y[train_mask].clone()
        # one seed per fold, so two configs are compared on the same draw
        model = train_one_mlp(x_train, y_train, hparams, dev, n_classes, grad_clip, seed + fold_idx)

        # Eval
        x_val, y_val = X[~train_mask].clone(), Y[~train_mask].clone()
        model.eval()
        with torch.inference_mode():
            pred = model(x_val.to(dev)).cpu().numpy()

        pred_label = np.argmax(pred, axis=1)
        f1 = f1_score(y_val, pred_label, average="macro")
        metric_list.append(f1)

    mean_f1 = np.mean(metric_list)
    tune.report({"val_f1": mean_f1})


[docs] class MLPProbe(Probe): r"""An MLP whose hyperparameters are swept with Optuna before the final fit. Args: num_trials: sweep budget. num_folds: subject-wise folds; each trial fits on one and scores on the rest. num_concurrent: trials in flight at once. seed: the search, every trial and the final fit. Not the pretraining seed a submission is filed under, and not trial completion order, which is unseeded. gpu_per_trial: fractional GPU per trial. cpu_per_trial: CPUs per trial. grad_clip: max grad norm, ``null`` to disable. Fixed across trials, not swept. """ def __init__( self, num_trials: int = 100, num_folds: int = 5, num_concurrent: int = 4, seed: int = 42, gpu_per_trial: float = 0.25, cpu_per_trial: float = 1, grad_clip: float | None = 1.0, ): self.num_trials = num_trials self.num_folds = num_folds self.num_concurrent = num_concurrent self.seed = seed self.gpu_per_trial = gpu_per_trial self.cpu_per_trial = cpu_per_trial self.grad_clip = grad_clip
[docs] def fit_predict( self, train_embs: np.ndarray, train_md: pd.DataFrame, eval_embs: np.ndarray, spec: TS3ReadoutSpec, ) -> tuple[np.ndarray, dict[str, Any]]: assert torch.cuda.is_available(), ( "MLPProbe trains on a GPU; probe=linear is the CPU-only probe" ) _seed_everything(self.seed) scaler = StandardScaler() train_embs = scaler.fit_transform(train_embs) eval_embs = scaler.transform(eval_embs) check_ts3_label_order(sorted(train_md.brain_region.unique()), spec.id) label_to_idx = {label: i for i, label in enumerate(spec.label_names)} train_X = torch.tensor(train_embs).to(torch.float32) train_Y = torch.tensor([label_to_idx[x] for x in train_md.brain_region.to_numpy()])[:, None] kfold_idx = torch.tensor(kfold_assignment(train_md.subject_id.values, self.num_folds)) ray.init( address="local", log_to_driver=False, ignore_reinit_error=True, num_cpus=len(os.sched_getaffinity(0)), num_gpus=torch.cuda.device_count(), ) try: tuner = tune.Tuner( tune.with_resources( tune.with_parameters( tune_func, X=train_X, Y=train_Y, kfold_idx=kfold_idx, n_classes=spec.dim, num_folds=self.num_folds, grad_clip=self.grad_clip, seed=self.seed, ), resources={"gpu": self.gpu_per_trial, "cpu": self.cpu_per_trial}, ), tune_config=tune.TuneConfig( num_samples=self.num_trials, search_alg=OptunaSearch(seed=self.seed), metric="val_f1", mode="max", max_concurrent_trials=self.num_concurrent, ), param_space=SEARCH_SPACE, ) best = tuner.fit().get_best_result() finally: ray.shutdown() best_hparams = best.config fit_metrics = {"val_f1": best.metrics["val_f1"]} fit_metrics.update({f"hparams/{k}": v for k, v in best_hparams.items()}) logger.info(f"Best val_f1: {best.metrics['val_f1']}") logger.info(f"Best hparams: {best_hparams}") dev = torch.device("cuda") model = train_one_mlp( train_X, train_Y, best_hparams, dev, spec.dim, self.grad_clip, self.seed ) model.eval() with torch.inference_mode(): logits = model(torch.tensor(eval_embs).to(torch.float32).to(dev)) return F.softmax(logits, dim=-1).cpu().numpy(), fit_metrics