Custom Loop Patterns¶
The default EmberModel loop covers ordinary supervised training. For unusual
objectives, keep fit() and override the step methods that differ.
What Still Comes From EmberModel¶
Even in a custom subclass, Ember's lightweight training library still owns the
outer loop through EmberModel:
- Fabric-aware dataloader setup inside
fit(),evaluate(), andpredict(); - callback execution, progress bars, and train/validation phase switching;
- epoch-level tracker synchronization for keys registered with type
"train"and"val"; - scheduler stepping through
scheduler_step()at the end of each epoch; - optional learning-rate tracking when scheduler logging is enabled;
- checkpoint save/load for the model, optimizers, schedulers, tracker, and step count.
That means most custom work should stay inside train_step(), val_step(),
eval_step(), predict_step(), setup_tracker(), calculate_metric(), and
scheduler_step() rather than replacing fit() itself.
Construction Points To Know¶
Custom loops still start from the same constructor surface:
optimizersare always supplied explicitly as a list and are available asself.optimizersinside custom steps;schedulersare optional, but if you pass them they are stepped by the defaultscheduler_step()unless you override that method;metricsare optional TorchMetrics instances. If a step returns(loss, y_pred), the outer loop updates them automatically throughcalculate_metric();setup_tracker()is called during construction, so tracker registration belongs there rather than inside the step methods. If you overridesetup_tracker()and still want the defaulttrain_loss,val_loss, or metric keys, callsuper().setup_tracker()first or register those keys yourself;EmberTracker.update()ignores unregistered keys, so a misspelled or missing custom key will not appear in history;fabric_setup()runs during construction by default and callsfabric.setup(self.model, *self.optimizers).
If your model wrapper or optimizer setup needs different Fabric initialization,
override fabric_setup() or pass setup_fabric=False and handle setup
yourself.
VAE-Style Step¶
A VAE usually needs a custom loss signature, but it can still return
(loss, y_pred) when the default loop should track loss or TorchMetrics.
from ember import EmberModel
class EmberVAE(EmberModel):
def train_step(self, batch, batch_idx: int, epoch: int):
x = batch[0]
x = self.aug_fn(x)
is_accumulating = (batch_idx + 1) % self.accumulate_grad_batches != 0
with self.fabric.no_backward_sync(self.model, enabled=is_accumulating):
x_out, z_mean, z_log_var = self.model(x)
loss = self.loss_fn(x_out, x, z_mean, z_log_var, epoch)
self.fabric.backward(loss / self.accumulate_grad_batches)
if not is_accumulating:
self.optimizers[0].step()
self.optimizers[0].zero_grad()
return loss, x_out
Returning (loss, x_out) lets the outer loop update registered loss keys and
any compatible metrics. If a metric does not make sense for reconstructed
outputs, omit constructor metrics or update your own keys manually.
This pattern is useful when the loop structure is still standard, but the model
forward pass or loss signature differs from simple supervised classification.
The subclass owns the custom forward/loss path, while EmberModel still owns
epoch bookkeeping, tracker synchronization, callbacks, and scheduler stepping.
GAN-Style Step¶
GANs are a better example of manual tracking. There is no single supervised
(loss, y_pred) pair, so the subclass registers custom keys and returns None.
from ember import EmberModel
class EmberGAN(EmberModel):
def setup_tracker(self) -> None:
self.tracker.register(["g_loss", "d_loss"], key_type="train")
def train_step(self, batch, batch_idx: int, epoch: int) -> None:
g_loss, d_loss = train_generator_and_discriminator(batch, self.optimizers)
batch_size = batch[0].shape[0]
self.tracker.update("d_loss", d_loss.detach(), batch_size=batch_size)
self.tracker.update("g_loss", g_loss.detach(), batch_size=batch_size)
return None
fit() still handles callbacks, epoch boundaries, tracker synchronization,
progress bars, and scheduler stepping. The subclass owns optimizer stepping,
gradient accumulation, clipping, and metric updates for the custom step.
If you also need validation-side custom keys, register them with key type
"val" in setup_tracker() and update them inside val_step(). They will be
aggregated automatically at the end of each validation epoch.
Metrics, Schedulers, And Tracker Hooks¶
For custom loops, the key question is whether you still want Ember's automatic metric path.
- Return
(loss, y_pred)when your outputs are compatible with constructor metrics andcalculate_metric(metric, batch, y_pred). - Return
Nonewhen the subclass should own all logging and aggregation. - Override
calculate_metric()when metrics need something other than(y_pred, batch[-1]). - Override
scheduler_step()when scheduler behavior depends on custom state or on a metric other than the default tracked history.
The default scheduler path already handles plain schedulers and
ReduceLROnPlateau. For plateau schedulers, Ember reads the history key given
by reduce_lr_on_plateau_monitor, which defaults to val_loss.
For manual metric aggregation, true epoch metrics, and standalone tracker use, see Tracking.
Standalone EmberTracker¶
EmberTracker can also be used outside EmberModel in fully custom loops. The
core API is the same as the custom-step examples above: register keys, update
them during the loop, then synchronize at an epoch boundary.
from ember import EmberTracker
tracker = EmberTracker(fabric=fabric, fabric_logging=False)
tracker.register(["loss", "aux_metric"], key_type="train")
for step, batch in enumerate(train_loader, start=1):
loss, aux_metric = run_step(batch)
batch_size = batch[0].shape[0]
tracker.update("loss", loss.detach(), batch_size=batch_size, step=step)
tracker.update("aux_metric", aux_metric.detach(), batch_size=batch_size)
tracker.sync_epoch(key_type="train", step=step)
history = tracker.get_history_items()
See Tracking for the full tracker API, synchronization behavior, and checkpointing details.
Single Model Wrapper¶
EmberModel expects one main nn.Module for Fabric setup and checkpointing. For
multi-module systems, wrap the parts in one module:
import torch.nn as nn
class GANWrapper(nn.Module):
def __init__(self, generator: nn.Module, discriminator: nn.Module) -> None:
super().__init__()
self.generator = generator
self.discriminator = discriminator
This keeps checkpointing simple while still allowing custom code to address each submodule directly.