Skip to content

Training

The training stage fits a fresh causal language model on tokenized timelines. All three trainers read a training.yaml configuration, pull the training and validation splits out of the processed data, and wrap HuggingFace's Trainer so that sequences of event tokens are modeled autoregressively (each token predicts the next). Time-aware position ids for time-based RoPE and a custom loss are wired in when the configuration requests them. On completion the model weights and the exact configuration used are written to output_home.

Trainer

The workhorse. Trainer builds a model from scratch from a HuggingFace architecture preset (its vocabulary, BOS, and EOS come from tokenizer.yaml), packs the training and tuning splits into fixed-length sequences, and runs standard next-token training. Its collate function ties labels to input_ids for causal language modeling and, when time-based RoPE is configured, derives per-token position_ids from elapsed time. Runs are tracked in Weights & Biases, and train() can resume from the latest checkpoint.

Bases: Configurable

the meds format dumps training (train), validation (tuning), and test (held_out) data into the same file; we need to start by fishing out training and validation data

Source code in src/cotorra/trainer.py
class Trainer(Configurable):
    """the meds format dumps training (train), validation (tuning), and test (held_out)
    data into the same file;
    we need to start by fishing out training and validation data"""

    default_file = "training.yaml"

    def __init__(
        self,
        training_cfg: pathlib.Path | str = None,
        processed_data_home: pathlib.Path | str = None,
        output_home: pathlib.Path | str = None,
        **kwargs,
    ):
        super().__init__(training_cfg, **kwargs)

        self.processed_data_home, self.output_home = map(
            lambda p: pathlib.Path(p).expanduser().resolve(),
            [processed_data_home, output_home],
        )

        self.tkzr_cfg = OmegaConf.load(self.processed_data_home / "tokenizer.yaml")
        self.loss = (
            Loss(self.cfg, self.tkzr_cfg).custom_loss if self.cfg.custom_loss else None
        )
        self.run_name = self.cfg.get("run_name", self.cfg.wandb.get("run_name", ""))
        self.loader = Loader(training_cfg, self.processed_data_home)

        self.trainer = TrainerWithCustomLoss(
            model_init=self.model_init,
            data_collator=self.collate_fn,
            compute_loss_func=self.loss,
            train_dataset=self.loader.get_train_data(),
            eval_dataset=self.loader.get_tuning_data(),
            args=TrainingArguments(
                output_dir=str(self.output_home), **self.cfg.training_args
            ),
        )
        self.model = self.trainer.model

        os.environ["WANDB_PROJECT"] = self.cfg.get("wandb", {}).get(
            "project", "cotorra"
        )
        os.environ["WANDB_NAME"] = self.cfg.get("wandb", {}).get("run_name", "cotorra")

    def model_init(self):
        conf_param = dict(
            vocab_size=len(self.tkzr_cfg.lookup),
            bos_token_id=self.tkzr_cfg.lookup.BOS,
            eos_token_id=self.tkzr_cfg.lookup.EOS,
        )
        config = AutoConfig.from_pretrained(
            self.cfg.model.model_name, **conf_param, **self.cfg.model.model_args
        )
        mdl = AutoModelForCausalLM.from_config(config)
        self.logger.info(
            "Loaded model {name} with {num} params ({dtype}).".format(
                name=self.cfg.model.model_name,
                num=sum(p.numel() for p in mdl.parameters()),
                dtype=next(mdl.parameters()).dtype,
            )
        )

        return mdl

    def collate_fn(self, batch):
        input_ids = t.stack([x["input_ids"] for x in batch])
        if "time_based_rope" not in self.cfg:
            return {"input_ids": input_ids, "labels": input_ids}
        else:
            p_ids = (
                t.stack([x["s_elapsed"] for x in batch])
                / self.cfg.time_based_rope.sec_per_pos_id
            )
            p_ids += t.arange(p_ids.shape[-1], device=p_ids.device, dtype=p_ids.dtype)
            return {"input_ids": input_ids, "labels": input_ids, "position_ids": p_ids}

    def train(self, resume_from_checkpoint: bool = False, verbose: bool = False):
        if resume_from_checkpoint:
            try:
                self.trainer.train(resume_from_checkpoint=True)
            except Exception as e:
                self.logger.warning(f"Encountered {e} on resume from checkpoint.")
                self.trainer.train()
        else:
            self.trainer.train()

        self.trainer.model.save_pretrained(self.output_home / f"mdl-{self.run_name}")

        with open(self.output_home / f"mdl-{self.run_name}-training.yaml", "w") as f:
            f.write(OmegaConf.to_yaml(self.cfg))

        if verbose:
            self.logger.summarize_trained_model(
                model=self.trainer.model,
                bos_token_id=self.tkzr_cfg.lookup["BOS"],
                reverse={v: k for k, v in self.tkzr_cfg.lookup.items()},
            )

TrainerDP (differential privacy)

TrainerDP extends Trainer with differentially private training via Opacus. It attaches a PrivacyEngine that replaces the model, optimizer, and data loader with DP-aware versions performing per-sample gradient clipping (max_grad_norm) and Gaussian noise injection (noise_multiplier); after training it reports the achieved (epsilon, delta) privacy guarantee. Because per-sample gradients are incompatible with gradient accumulation, DP training requires gradient_accumulation_steps=1, and the model is unwrapped from its GradSampleModule before being saved.

Bases: Trainer

Source code in src/cotorra/trainer_dp.py
class TrainerDP(Trainer):
    def __init__(
        self,
        training_cfg: pathlib.Path | str = None,
        processed_data_home: pathlib.Path | str = None,
        output_home: pathlib.Path | str = None,
        noise_multiplier: float | None = None,
        max_grad_norm: float | None = None,
        **kwargs,
    ):
        super().__init__(
            training_cfg=training_cfg,
            processed_data_home=processed_data_home,
            output_home=output_home,
            privacy_parameters={
                k: v
                for k, v in {
                    "noise_multiplier": noise_multiplier,
                    "max_grad_norm": max_grad_norm,
                }.items()
                if v is not None
            },
            **kwargs,
        )

        self.trainer = TrainerWithCustomLossDP(
            model=self.model,
            data_collator=self.collate_fn,
            compute_loss_func=self.loss,
            train_dataset=self.loader.get_train_data(),
            eval_dataset=self.loader.get_tuning_data(),
            args=TrainingArguments(
                output_dir=str(self.output_home), **self.cfg.training_args
            ),
        )

        self.trainer.create_optimizer()

        # HuggingFace Trainer calls get_train_dataloader() internally, so we must
        # ensure get_train_dataloader() returns the Opacus-wrapped DataLoader.
        privacy_engine = opacus.PrivacyEngine()
        noise_multiplier = self.cfg.get("privacy_parameters", {}).get(
            "noise_multiplier", 1.0
        )
        max_grad_norm = self.cfg.get("privacy_parameters", {}).get("max_grad_norm", 1.0)
        self.logger.info(
            f"Making private with {noise_multiplier=} and {max_grad_norm=}"
        )
        dp_model, dp_optimizer, dp_dataloader = privacy_engine.make_private(
            module=self.trainer.model,
            optimizer=self.trainer.optimizer,
            data_loader=self.trainer.get_train_dataloader(),
            noise_multiplier=noise_multiplier,
            max_grad_norm=max_grad_norm,
            poisson_sampling=False,
        )
        self.trainer.model = dp_model
        self.trainer.optimizer = dp_optimizer
        self.trainer.set_dp_train_dataloader(dp_dataloader)
        self.model = self.trainer.model
        self.privacy_engine = privacy_engine

    def train(self, verbose=False):
        """Override train to properly handle saving the DP-wrapped model."""
        self.trainer.train()

        self.logger.info("For (epsilon, delta)-differential privacy:")
        for delta in [1e-5, 1e-4, 1e-3]:
            self.logger.info(
                "delta={delta} gives epsilon={epsilon:.3e}".format(
                    delta=delta,
                    epsilon=self.privacy_engine.accountant.get_epsilon(delta=delta),
                )
            )

        # Unwrap the model from GradSampleModule before saving
        unwrapped_model = self.trainer.model._module
        unwrapped_model.save_pretrained(self.output_home / f"mdl-{self.run_name}")

        with open(self.output_home / f"mdl-{self.run_name}-training.yaml", "w") as f:
            f.write(OmegaConf.to_yaml(self.cfg))

        if verbose:
            self.logger.summarize_trained_model(
                model=unwrapped_model,
                bos_token_id=self.tkzr_cfg.lookup["BOS"],
                reverse={v: k for k, v in self.tkzr_cfg.lookup.items()},
            )

train(verbose=False)

Override train to properly handle saving the DP-wrapped model.

Source code in src/cotorra/trainer_dp.py
def train(self, verbose=False):
    """Override train to properly handle saving the DP-wrapped model."""
    self.trainer.train()

    self.logger.info("For (epsilon, delta)-differential privacy:")
    for delta in [1e-5, 1e-4, 1e-3]:
        self.logger.info(
            "delta={delta} gives epsilon={epsilon:.3e}".format(
                delta=delta,
                epsilon=self.privacy_engine.accountant.get_epsilon(delta=delta),
            )
        )

    # Unwrap the model from GradSampleModule before saving
    unwrapped_model = self.trainer.model._module
    unwrapped_model.save_pretrained(self.output_home / f"mdl-{self.run_name}")

    with open(self.output_home / f"mdl-{self.run_name}-training.yaml", "w") as f:
        f.write(OmegaConf.to_yaml(self.cfg))

    if verbose:
        self.logger.summarize_trained_model(
            model=unwrapped_model,
            bos_token_id=self.tkzr_cfg.lookup["BOS"],
            reverse={v: k for k, v in self.tkzr_cfg.lookup.items()},
        )

Tuner

Tuner extends Trainer to run an Optuna hyperparameter search (over learning rate and gradient-accumulation steps) before the final training run. The best trial's hyperparameters are copied back onto the trainer, the model is retrained with them, and the weights and configuration are saved as usual.

Bases: Trainer

Source code in src/cotorra/tuner.py
class Tuner(Trainer):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)

    @staticmethod
    def optuna_hp_space(trial):
        return {
            "learning_rate": trial.suggest_float("learning_rate", 1e-4, 5e-4, log=True),
            "gradient_accumulation_steps": trial.suggest_int(
                "gradient_accumulation_steps", 1, 3
            ),
        }

    def train(self, verbose=False):
        best_trial = self.trainer.hyperparameter_search(
            hp_space=self.optuna_hp_space, **self.cfg.tuning_args
        )
        for n, v in best_trial.hyperparameters.items():
            setattr(self.trainer.args, n, v)
        self.trainer.train()
        self.trainer.model.save_pretrained(self.output_home / f"mdl-{self.run_name}")

        with open(self.output_home / f"mdl-{self.run_name}-tuning.yaml", "w") as f:
            f.write(OmegaConf.to_yaml(self.cfg))

        if verbose:
            self.logger.summarize_trained_model(
                model=self.trainer.model,
                bos_token_id=self.tkzr_cfg.lookup["BOS"],
                reverse={v: k for k, v in self.tkzr_cfg.lookup.items()},
            )