Source code for latents.callbacks

"""Callbacks for model fitting.

Callbacks allow custom actions at specific points during model fitting.
Implement any subset of the callback methods you need (duck typing).

**Callback Methods**:

on_fit_start(ctx)
    Called when fit() begins, after initialization.

on_fit_end(ctx, reason)
    Called when fit() completes. reason is one of:
    "converged", "max_iter", "no_latents".

on_iteration_end(ctx, iteration, lb, lb_prev)
    Called at the end of each EM iteration.

on_flag_changed(ctx, flag, value, iteration)
    Called when a boolean flag changes (converged, decreasing_lb,
    private_var_floor).

on_x_dim_pruned(ctx, n_removed, x_dim_remaining, iteration)
    Called when latent dimensions are pruned.

**Iteration Numbering**:

Callbacks receive 0-indexed iteration values from the
fit loop (for array indexing). User-facing output (logs, filenames) uses
1-indexed iterations for clarity.

**Examples**:

>>> from latents.callbacks import ProgressCallback, CheckpointCallback
>>>
>>> model.fit(Y, callbacks=[
...     ProgressCallback(),
...     CheckpointCallback(save_dir="./checkpoints"),
... ])
"""

from __future__ import annotations

import logging
import multiprocessing
import signal
import sys
import warnings
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

from tqdm.auto import tqdm

from latents._internal.logging import FitEvent, log_event

# -----------------------------------------------------------------------------
# Callback Invocation Helper
# -----------------------------------------------------------------------------


[docs] def invoke_callbacks(callbacks: list, method: str, **kwargs: Any) -> None: """Call a method on all callbacks that implement it. Parameters ---------- callbacks : list List of callback objects. method : str Method name to call. **kwargs : Any Arguments passed to the method. """ for cb in callbacks: if hasattr(cb, method): getattr(cb, method)(**kwargs)
# ----------------------------------------------------------------------------- # Logging Callback # -----------------------------------------------------------------------------
[docs] @dataclass class LoggingCallback: """Emit log events to the 'latents' logger during fitting. Configure Python logging to see output:: import logging logging.basicConfig(level=logging.INFO) Or to log to a file:: import logging logging.basicConfig(level=logging.INFO, filename="fit.log") Examples -------- >>> import logging >>> logging.basicConfig(level=logging.INFO) >>> model.fit(Y, callbacks=[LoggingCallback()]) """ def on_fit_start(self, ctx: Any) -> None: """Log fit start event with data dimensions. Parameters ---------- ctx : Any Fitting context providing obs_posterior, latents_posterior, tracker. """ # Log data shape info: n_samples from latents, y_dims per group, x_dim n_samples = ctx.latents_posterior.mean.shape[1] y_dims = ctx.obs_posterior.y_dims x_dim = ctx.obs_posterior.x_dim log_event(FitEvent.STARTED, n_samples=n_samples, y_dims=y_dims, x_dim=x_dim) def on_fit_end(self, ctx: Any, reason: str) -> None: """Log fit end event with termination reason. Parameters ---------- ctx : Any Fitting context providing tracker. reason : str Termination reason ("converged", "max_iter", or "no_latents"). """ event_map = { "converged": FitEvent.CONVERGED, "max_iter": FitEvent.MAX_ITER, "no_latents": FitEvent.NO_LATENTS, } event = event_map.get(reason, FitEvent.CONVERGED) # no_latents suggests data/config issue level = logging.WARNING if reason == "no_latents" else logging.INFO iteration = len(ctx.tracker.lb) if ctx.tracker.lb is not None else 0 log_event(event, level=level, iteration=iteration) def on_flag_changed( self, ctx: Any, flag: str, value: Any, iteration: int | None ) -> None: """Log flag change events (warnings for decreasing_lb, private_var_floor). Parameters ---------- ctx : Any Fitting context (unused by this callback). flag : str Name of the flag that changed. value : Any New value of the flag. iteration : int or None Current iteration number (0-indexed), or None for post-fit checks. """ # converged is redundant with on_fit_end if flag == "converged": return # decreasing_lb and private_var_floor are warnings # Use 1-indexed iteration for user-facing output log_event( FitEvent.FLAG_CHANGED, level=logging.WARNING, flag=flag, value=value, iteration=iteration + 1 if iteration is not None else None, ) def on_x_dim_pruned( self, ctx: Any, n_removed: int, x_dim_remaining: int, iteration: int ) -> None: """Log latent dimension pruning event. Parameters ---------- ctx : Any Fitting context (unused by this callback). n_removed : int Number of dimensions removed. x_dim_remaining : int Number of dimensions remaining. iteration : int Current iteration number. """ # Use 1-indexed iteration for user-facing output log_event( FitEvent.X_DIM_PRUNED, n_removed=n_removed, x_dim_remaining=x_dim_remaining, iteration=iteration + 1, )
# ----------------------------------------------------------------------------- # Progress Callback # -----------------------------------------------------------------------------
[docs] @dataclass class ProgressCallback: """Display tqdm progress bar during fitting. Parameters ---------- desc : str, default "Fitting" Description shown next to the progress bar. Examples -------- >>> model.fit(Y, callbacks=[ProgressCallback()]) **Custom description** >>> model.fit(Y, callbacks=[ProgressCallback(desc="Training GFA")]) """ desc: str = "Fitting" # Internal state (not set by user) _pbar: tqdm | None = field(default=None, init=False, repr=False) _x_dim: int = field(default=0, init=False, repr=False) _lb_base: float | None = field(default=None, init=False, repr=False) def on_fit_start(self, ctx: Any) -> None: """Initialize progress bar. Parameters ---------- ctx : Any Fitting context providing config, obs_posterior, tracker. """ max_iter = ctx.config.max_iter self._pbar = tqdm(total=max_iter, desc=self.desc) self._x_dim = ctx.obs_posterior.x_dim # Initialize from tracker (handles resume case) self._lb_base = ctx.tracker.lb_base def on_iteration_end( self, ctx: Any, iteration: int, lb: float, lb_prev: float ) -> None: """Update progress bar with current lower bound and relative change. Parameters ---------- ctx : Any Fitting context providing config, tracker. iteration : int Current iteration number. lb : float Current lower bound value. lb_prev : float Previous iteration's lower bound value. """ if self._pbar is None: return self._pbar.update(1) # Build postfix matching original implementation postfix: dict[str, Any] = {"lb": f"{lb:.2e}"} # Update lb_base during burn-in (matches main fit loop: overwrite on 0 and 1) # Only do this for fresh fits (when _lb_base was None at start) if iteration <= 1 and ctx.tracker.lb_base is None: # Fresh fit, still in burn-in - tracker hasn't set lb_base yet pass elif iteration <= 1: # Fresh fit, tracker just set lb_base - sync our copy self._lb_base = ctx.tracker.lb_base # Relative change (after burn-in, when denominator is non-zero) if self._lb_base is not None and iteration > 1: denom = lb_prev - self._lb_base if denom != 0.0: rel_change = (lb - lb_prev) / denom postfix["Δ"] = f"{rel_change:.1e}" if ctx.config.prune_x: postfix["x_dim"] = self._x_dim self._pbar.set_postfix(postfix) def on_x_dim_pruned( self, ctx: Any, n_removed: int, x_dim_remaining: int, iteration: int ) -> None: """Update tracked x_dim for progress bar display. Parameters ---------- ctx : Any Fitting context (unused by this callback). n_removed : int Number of dimensions removed. x_dim_remaining : int Number of dimensions remaining. iteration : int Current iteration number. """ self._x_dim = x_dim_remaining def on_fit_end(self, ctx: Any, reason: str) -> None: """Close progress bar. Parameters ---------- ctx : Any Fitting context (unused by this callback). reason : str Termination reason. """ if self._pbar is not None: self._pbar.close() self._pbar = None
# ----------------------------------------------------------------------------- # Checkpoint Callback # -----------------------------------------------------------------------------
[docs] @dataclass class CheckpointCallback: """Save model checkpoints during fitting. Checkpoints are saved in safetensors format and can be loaded via :meth:`GFAModel.load`. Parameters ---------- save_dir : str or Path Directory for checkpoint files. Created if it doesn't exist. every_n_iter : int, default 5000 Save every N iterations. Set to 0 to disable periodic saves. save_initial : bool, default True If True, save immediately after initialization (before iteration 0). save_final : bool, default True If True, save after fit completes. save_on_interrupt : bool, default True If True, save checkpoint when Ctrl+C is pressed. Only works in the main process; in parallel workers, rely on periodic checkpoints. max_checkpoints : int, default 3 Maximum periodic checkpoints to keep. Older ones are deleted. Set to 0 to keep all. Does not affect initial, final, or interrupt checkpoints. prefix : str, default "" Optional prefix for checkpoint filenames. Examples -------- >>> callback = CheckpointCallback( ... save_dir="./checkpoints", ... every_n_iter=5000, ... prefix="experiment1", ... ) >>> model.fit(Y, callbacks=[callback]) """ save_dir: str | Path every_n_iter: int = 5000 save_initial: bool = True save_final: bool = True save_on_interrupt: bool = True max_checkpoints: int = 3 prefix: str = "" # Internal state _ctx: Any = field(default=None, init=False, repr=False) _iteration: int = field(default=0, init=False, repr=False) _periodic_paths: list[Path] = field(default_factory=list, init=False, repr=False) _original_sigint: Any = field(default=None, init=False, repr=False) def __post_init__(self) -> None: self.save_dir = Path(self.save_dir) self.save_dir.mkdir(parents=True, exist_ok=True) def _is_main_process(self) -> bool: """Check if running in the main process.""" return multiprocessing.current_process().name == "MainProcess" def on_fit_start(self, ctx: Any) -> None: """Initialize checkpointing, register interrupt handler, save initial. Parameters ---------- ctx : Any Fitting context providing save() method. """ self._ctx = ctx self._iteration = 0 self._periodic_paths = [] # Register interrupt handler (main process only) if self.save_on_interrupt: if self._is_main_process(): self._original_sigint = signal.getsignal(signal.SIGINT) signal.signal(signal.SIGINT, self._handle_interrupt) else: # Warn user that interrupt handling won't work in workers warnings.warn( "save_on_interrupt has no effect in worker processes; " "rely on periodic checkpoints (every_n_iter) for parallel fits", UserWarning, stacklevel=2, ) if self.save_initial: self._save("init") def on_iteration_end( self, ctx: Any, iteration: int, lb: float, lb_prev: float ) -> None: """Save periodic checkpoint if iteration matches every_n_iter. Parameters ---------- ctx : Any Fitting context (unused, uses stored context). iteration : int Current iteration number. lb : float Current lower bound value (unused). lb_prev : float Previous lower bound value (unused). """ self._iteration = iteration # Periodic checkpoint (iteration is 0-indexed, so add 1 for display) iter_num = iteration + 1 if self.every_n_iter > 0 and iter_num % self.every_n_iter == 0: path = self._save(f"iter_{iter_num:06d}") self._periodic_paths.append(path) self._prune_old_checkpoints() def on_fit_end(self, ctx: Any, reason: str) -> None: """Save final checkpoint and restore signal handler. Parameters ---------- ctx : Any Fitting context (unused, uses stored context). reason : str Termination reason (unused). """ if self.save_final: self._save("final") self._restore_signal_handler() def _save(self, suffix: str) -> Path: """Save checkpoint and log the event.""" if self.prefix: filename = f"{self.prefix}_{suffix}.safetensors" else: filename = f"checkpoint_{suffix}.safetensors" path = self.save_dir / filename self._ctx.save(path) # Use 1-indexed iteration for user-facing output (matches filename) log_event( FitEvent.CHECKPOINT_SAVED, path=str(path), iteration=self._iteration + 1, ) return path def _prune_old_checkpoints(self) -> None: """Delete oldest periodic checkpoints if we exceed max_checkpoints.""" if self.max_checkpoints <= 0: return while len(self._periodic_paths) > self.max_checkpoints: oldest = self._periodic_paths.pop(0) if oldest.exists(): oldest.unlink() def _handle_interrupt(self, signum: int, frame: Any) -> None: """Handle Ctrl+C: save checkpoint then exit.""" # Use 1-indexed iteration for user-facing output (matches filename) log_event( FitEvent.INTERRUPTED, level=logging.WARNING, iteration=self._iteration + 1, ) self._save(f"interrupted_{self._iteration + 1:06d}") self._restore_signal_handler() sys.exit(130) # Standard exit code for SIGINT def _restore_signal_handler(self) -> None: """Restore the original SIGINT handler.""" if self._original_sigint is not None: signal.signal(signal.SIGINT, self._original_sigint) self._original_sigint = None