"""Gradient, dtype, entropy, prototype, and parameter diagnostics."""
import os
import re
import torch
import torch.nn as nn
from .default import HookBase
from .builder import HOOKS
[docs]
@HOOKS.register_module()
class SimulateCrash(HookBase):
"""Hard-exit the process after a fixed number of steps to simulate a crash.
Fires only when ``PIMM_SIMULATE_CRASH_STEP`` is set, so a run can die
deterministically after writing a checkpoint while a later resume of the
same config runs to completion. Used to test crash recovery.
"""
[docs]
def after_step(self):
target = os.environ.get("PIMM_SIMULATE_CRASH_STEP")
if target is None:
return
self.step_count = getattr(self, "step_count", 0) + 1
if self.step_count >= int(target):
os._exit(137)
[docs]
@HOOKS.register_module()
class GradientNormLogger(HookBase):
"""Log model gradient norms to the writer after each training step.
Runs in ``after_step`` every ``log_frequency`` steps: computes the
aggregate gradient norm over all model parameters (for the configured
``norm_type``) and writes it under ``{prefix}/total``. With
``log_per_layer=True`` it additionally writes each named parameter's
gradient norm under ``{prefix}/layers/<name>``. Registered as
``GradientNormLogger``.
Args:
norm_type (float): Order of the norm to compute, e.g. ``2.0`` for L2 or
``float("inf")`` for max-abs. Defaults to ``2.0``.
log_per_layer (bool): If ``True``, also log per-parameter gradient
norms (verbose). Defaults to ``False``.
log_frequency (int): Compute and log every this many steps. Defaults to
``1``.
prefix (str): Namespace prefix for the writer keys. Defaults to
``"grad_norm"``.
Note:
No-ops when ``trainer.writer`` is absent/``None`` or does not expose
``add_scalar``. Reads gradients after backward, so it reflects the
un-clipped gradients of that step.
Example:
Add to ``cfg.hooks``; after every ``log_frequency`` steps it writes the
total gradient norm to the experiment writer (W&B/TensorBoard):
.. code-block:: python
hooks = [dict(type="GradientNormLogger", log_frequency=50)]
# → logs scalar "grad_norm/total" every 50 optimizer steps
# (with log_per_layer=True, also "grad_norm/layers/<name>" per param)
The norm helper is pure and can be exercised standalone:
.. code-block:: python
>>> import torch
>>> from pimm.engines.hooks.diagnostics import GradientNormLogger
>>> p = torch.nn.Parameter(torch.tensor([3.0, 4.0]))
>>> (0.5 * (p ** 2).sum()).backward() # grad = [3., 4.]
>>> float(GradientNormLogger()._compute_grad_norm([p]))
5.0
"""
def __init__(self, norm_type=2.0, log_per_layer=False, log_frequency=1, prefix="grad_norm"):
"""Configure gradient norm type, cadence, and writer namespace."""
self.norm_type = norm_type
self.log_per_layer = log_per_layer
self.log_frequency = log_frequency
self.prefix = prefix
self.step_count = 0
def _compute_grad_norm(self, parameters, norm_type=2.0):
"""Compute gradient norm for given parameters."""
if isinstance(parameters, torch.Tensor):
parameters = [parameters]
grads = [p.grad for p in parameters if p.grad is not None]
if len(grads) == 0:
return torch.tensor(0.0)
if norm_type == float('inf'):
total_norm = max(g.abs().max() for g in grads)
else:
total_norm = torch.norm(
torch.stack([torch.norm(g, norm_type) for g in grads]),
norm_type
)
return total_norm
[docs]
def after_step(self):
"""Log gradient norms after each training step."""
self.step_count += 1
# Only log at specified frequency
if self.step_count % self.log_frequency != 0:
return
# Only log if wandb writer is available
if not hasattr(self.trainer, 'writer') or self.trainer.writer is None:
return
# Check if we're using wandb (not tensorboard)
if not hasattr(self.trainer.writer, 'add_scalar'):
return
# Get current iteration for logging
current_iter = self.trainer.comm_info.get("iter", 0) + 1
current_epoch = self.trainer.epoch + 1
global_step = (current_epoch - 1) * len(self.trainer.train_loader) + current_iter
# Compute total gradient norm
total_grad_norm = self._compute_grad_norm(
self.trainer.model.parameters(),
self.norm_type
)
# Log total gradient norm
self.trainer.writer.add_scalar(
f"{self.prefix}/total",
total_grad_norm.item(),
global_step
)
# Optionally log per-layer gradient norms
if self.log_per_layer:
self._log_per_layer_grad_norms(global_step)
def _log_per_layer_grad_norms(self, global_step):
"""Log gradient norms for individual layers."""
# Get model without DDP wrapper if present
model = self.trainer.model
if hasattr(model, 'module'):
model = model.module
for name, param in model.named_parameters():
if param.grad is not None:
grad_norm = torch.norm(param.grad, self.norm_type)
# Clean up layer name for wandb logging
clean_name = name.replace('.', '_')
self.trainer.writer.add_scalar(
f"{self.prefix}/layers/{clean_name}",
grad_norm.item(),
global_step
)
def __repr__(self):
"""Return a compact configuration summary for logs."""
return (f"{self.__class__.__name__}(norm_type={self.norm_type}, "
f"log_per_layer={self.log_per_layer}, "
f"log_frequency={self.log_frequency}, "
f"prefix='{self.prefix}')")
DTYPE_TO_TORCH_DTYPE = {
"float16": torch.float16,
"float32": torch.float32,
"float64": torch.float64,
"bfloat16": torch.bfloat16,
"int8": torch.int8,
"int16": torch.int16,
"int32": torch.int32,
}
[docs]
@HOOKS.register_module()
class DtypeOverrider(HookBase):
"""Force matched layers to compute (and optionally store params) in a dtype.
Runs once in ``before_train``: walks the model and, for every submodule
whose dotted name matches ``patterns`` or whose class name matches
``class_patterns``, wraps each method in ``methods_to_override`` so its
tensor input is cast to ``dtype`` and its output cast back to the original
dtype. With ``override_parameters=True`` the matched module's own
parameters are also cast to ``dtype`` in place. Useful for keeping
precision-sensitive layers (e.g. LayerNorm) in fp32 under mixed-precision
training. Registered as ``DtypeOverrider``.
Args:
patterns (list, optional): Regex patterns matched against dotted module
names. Defaults to ``None`` (treated as ``[]``).
class_patterns (list, optional): Regex patterns matched against module
class names. Defaults to ``None`` (treated as ``[]``).
dtype (str): Target dtype name (key of the module's dtype table, e.g.
``"float32"``, ``"bfloat16"``), resolved to a ``torch.dtype``.
Defaults to ``"float32"``.
methods_to_override (list, optional): Method names on matched modules to
wrap with the cast. Defaults to ``None`` (treated as
``["forward"]``).
override_parameters (bool): If ``True``, also cast matched modules'
parameters to ``dtype`` in place. Defaults to ``False``.
verbose (bool): If ``True``, log each wrapped method/parameter and a
summary. Defaults to ``False``.
check_interval (int): Reserved cadence for periodic parameter-dtype
re-checks. Defaults to ``10``.
Note:
Only the first positional tensor argument of a wrapped method is cast;
methods whose first argument is not a tensor are called unchanged. The
DDP wrapper is handled by walking the underlying model.
Example:
Add to ``cfg.hooks``; once in ``before_train`` it wraps matched modules
so they compute in ``dtype`` regardless of AMP autocast:
.. code-block:: python
hooks = [
dict(type="DtypeOverrider",
patterns=["layer_norm", "LayerNorm", "norm"],
dtype="float32",
override_parameters=True,
verbose=True),
]
# → each matched module's forward() now casts its input to float32 and
# casts the output back to the original dtype; with
# override_parameters=True the module's own params are converted to
# float32 in place (each wrap/conversion logged via verbose)
"""
def __init__(
self,
patterns=None,
class_patterns=None,
dtype="float32",
methods_to_override=None,
override_parameters=False,
verbose=False,
check_interval=10
):
"""Configure name/class matching and target compute dtype."""
self.patterns = patterns or []
self.class_patterns = class_patterns or []
self.dtype = DTYPE_TO_TORCH_DTYPE[dtype]
self.methods_to_override = methods_to_override or ["forward"]
self.override_parameters = override_parameters
self.verbose = verbose
self.check_interval = check_interval
self.overridden_layers = []
self.overridden_params = []
self.step_counter = 0
self.param_original_dtypes = {}
[docs]
def before_train(self):
"""Apply dtype overriding before training starts."""
self._override_layers(self.trainer.model)
if self.verbose:
self.trainer.logger.info(f"Overridden {len(self.overridden_layers)} layers to use {self.dtype}:")
for name in self.overridden_layers:
self.trainer.logger.info(f" - {name}")
if self.override_parameters:
self.trainer.logger.info(f"Overridden {len(self.overridden_params)} parameters to use {self.dtype}")
def _should_override(self, name, module):
"""Check if this module should be overridden based on name or class."""
# Check if module name matches any pattern
name_match = any(re.search(pattern, name) for pattern in self.patterns)
# Check if class name matches any pattern
class_match = any(re.search(pattern, module.__class__.__name__) for pattern in self.class_patterns)
return name_match or class_match
def _override_layers(self, module, prefix=''):
"""Recursively override layer methods and parameters to force dtype."""
for name, child in module.named_children():
full_name = f"{prefix}.{name}" if prefix else name
# Check if this layer should be overridden
if self._should_override(full_name, child):
# Override specified methods
for method_name in self.methods_to_override:
if hasattr(child, method_name):
if self.verbose:
self.trainer.logger.info(f"Wrapping {full_name}.{method_name} to force {self.dtype}")
original_method = getattr(child, method_name)
# Create a wrapped method that forces dtype
wrapped_method = self._make_dtype_wrapper(original_method, self.dtype)
# Replace the original method
setattr(child, method_name, wrapped_method)
self.overridden_layers.append(f"{full_name}.{method_name}")
# Override parameters if requested
if self.override_parameters:
for param_name, param in child.named_parameters(recurse=False):
param_full_name = f"{full_name}.{param_name}"
if param.dtype != self.dtype:
if self.verbose:
self.trainer.logger.info(f"Overriding {param_full_name} to {self.dtype}")
self.param_original_dtypes[param_full_name] = param.data.dtype
param.data = param.data.to(self.dtype)
self.overridden_params.append(param_full_name)
# Recursively apply to child modules
self._override_layers(child, full_name)
def _make_dtype_wrapper(self, original_method, dtype):
"""Create a wrapper function that forces computation in specified dtype."""
def wrapped_method(*args, **kwargs):
# Handle different argument patterns for different methods
if args and isinstance(args[0], torch.Tensor):
# Most common case: first arg is input tensor
orig_dtype = args[0].dtype
args_cast = [args[0].to(dtype)] + list(args[1:])
output = original_method(*args_cast, **kwargs)
# Convert output back to original dtype
if isinstance(output, torch.Tensor):
output = output.to(orig_dtype)
elif isinstance(output, tuple):
output = tuple(x.to(orig_dtype) if isinstance(x, torch.Tensor) else x for x in output)
elif isinstance(output, list):
output = [x.to(orig_dtype) if isinstance(x, torch.Tensor) else x for x in output]
return output
else:
# If no tensor input, just call original method
return original_method(*args, **kwargs)
return wrapped_method
def __repr__(self):
"""Return a compact configuration summary for logs."""
patterns_str = ', '.join(self.patterns)
return (f"{self.__class__.__name__}(patterns=[{patterns_str}], "
f"dtype={self.dtype})")
[docs]
@HOOKS.register_module()
class LogitEntropyLogger(HookBase):
"""Log the entropy of teacher logits using Sonata's temperature schedule.
Monitors prediction confidence by computing the Shannon entropy of the
temperature-scaled softmax over ``logits_key`` in the model output dict. In
``before_train`` it locates the Sonata submodule (unwrapping DDP) to read
its current ``teacher_temp``; if none is found it falls back to
``default_temperature``. Runs in ``after_step`` every ``log_frequency``
steps: logs the entropy (and the temperature) to the writer under
``train_batch/{prefix}`` and to ``trainer.storage``/``iter_info``, and
stashes the temperature back into the model output dict for other consumers.
``after_epoch`` logs the epoch-average entropy under ``train/{prefix}``.
Registered as ``LogitEntropyLogger``.
Args:
logits_key (str): Key in the model output dict holding the logits.
Defaults to ``"teacher_logits"``.
log_frequency (int): Compute and log every this many steps. Defaults to
``1``.
prefix (str): Namespace prefix for the logged keys. Defaults to
``"entropy"``.
reduction (str): How to reduce the per-token entropy: ``"mean"``,
``"sum"``, or ``"none"``. Defaults to ``"mean"``.
log_per_class (bool): If ``True`` (and ``reduction="none"``), also log
per-class mean entropy. Defaults to ``False``.
default_temperature (float): Temperature used when no Sonata model is
found. Defaults to ``0.07``.
Note:
Higher entropy indicates more uncertain predictions; lower entropy more
confident ones. No-ops on a given step if the logits key is absent from
the model output dict.
Example:
Add to ``cfg.hooks`` for Sonata-style SSL; every ``log_frequency`` steps
it logs the entropy of the teacher logits:
.. code-block:: python
hooks = [dict(type="LogitEntropyLogger", log_frequency=50)]
# → writes "train_batch/entropy" and "entropy/temperature" to the writer
# (and the epoch average "train/entropy"), puts "entropy" in
# trainer.storage, and appends "entropy: <val>" to the iter log line
"""
def __init__(
self,
logits_key="teacher_logits",
log_frequency=1,
prefix="entropy",
reduction='mean',
log_per_class=False,
default_temperature=0.07
):
"""Configure logits source, logging cadence, and entropy reduction."""
self.logits_key = logits_key
self.log_frequency = log_frequency
self.prefix = prefix
self.reduction = reduction
self.log_per_class = log_per_class
self.default_temperature = default_temperature
self.step_count = 0
self.sonata_model = None
[docs]
def before_train(self):
"""Initialize entropy tracking and find Sonata model."""
self.trainer.logger.info(f"Logging entropy of '{self.logits_key}' with prefix '{self.prefix}'")
# Find Sonata module in the model
if hasattr(self.trainer.model, 'module'):
model = self.trainer.model.module # Unwrap DDP
else:
model = self.trainer.model
# Look for Sonata module
if hasattr(model, 'sonata'):
self.sonata_model = model.sonata
self.trainer.logger.info("Found Sonata model for temperature scheduling")
elif isinstance(model, nn.ModuleDict) and 'sonata' in model:
self.sonata_model = model['sonata']
self.trainer.logger.info("Found Sonata model in ModuleDict for temperature scheduling")
elif hasattr(model, '__class__') and 'Sonata' in model.__class__.__name__:
self.sonata_model = model
self.trainer.logger.info("Model itself is a Sonata model")
else:
self.trainer.logger.warning(f"Couldn't find Sonata model, using default temperature: {self.default_temperature}")
def _get_sonata_temperature(self):
"""Get the current temperature from Sonata model."""
if self.sonata_model is not None and hasattr(self.sonata_model, 'teacher_temp'):
return self.sonata_model.teacher_temp
return self.default_temperature
def _calculate_entropy(self, logits):
"""Calculate entropy of probability distribution from logits."""
# Get temperature from Sonata model
temperature = self._get_sonata_temperature()
# Log temperature if writer is available
if hasattr(self.trainer, 'writer') and self.trainer.writer is not None:
current_iter = self.trainer.comm_info.get("iter", 0) + 1
current_epoch = self.trainer.epoch + 1
global_step = (current_epoch - 1) * len(self.trainer.train_loader) + current_iter
self.trainer.writer.add_scalar(f"{self.prefix}/temperature", temperature, global_step)
# Apply temperature scaling and softmax to get probabilities
probs = torch.nn.functional.softmax(logits / temperature, dim=-1)
# Calculate entropy: -sum(p * log(p))
# Add small epsilon to avoid log(0)
entropy = -torch.sum(probs * torch.log(probs + 1e-10), dim=-1)
if self.reduction == 'mean':
return entropy.mean()
elif self.reduction == 'sum':
return entropy.sum()
else:
return entropy
[docs]
def after_step(self):
"""Calculate and log entropy after each step."""
self.step_count += 1
if self.step_count % self.log_frequency != 0:
return
# Check if model output contains the logits key
if (not hasattr(self.trainer, 'comm_info') or
"model_output_dict" not in self.trainer.comm_info or
self.logits_key not in self.trainer.comm_info["model_output_dict"]):
return
# Get logits from model output
logits = self.trainer.comm_info["model_output_dict"][self.logits_key]
# Calculate entropy
entropy = self._calculate_entropy(logits)
# Add temperature to model output for other hooks/modules
self.trainer.comm_info["model_output_dict"]["temperature"] = self._get_sonata_temperature()
# Log to storage for console output
if isinstance(entropy, torch.Tensor) and entropy.numel() == 1:
entropy_val = entropy.item()
self.trainer.storage.put_scalar(f"{self.prefix}", entropy_val)
# Add to iteration info
if "iter_info" in self.trainer.comm_info:
self.trainer.comm_info["iter_info"] += f"{self.prefix}: {entropy_val:.4f} "
# Log to tensorboard/wandb if available
if hasattr(self.trainer, 'writer') and self.trainer.writer is not None:
current_iter = self.trainer.comm_info.get("iter", 0) + 1
current_epoch = self.trainer.epoch + 1
global_step = (current_epoch - 1) * len(self.trainer.train_loader) + current_iter
self.trainer.writer.add_scalar(
f"train_batch/{self.prefix}",
entropy.item() if entropy.numel() == 1 else entropy.mean().item(),
global_step
)
# Log per-class entropy if requested and entropy is not already reduced
if self.log_per_class and self.reduction == 'none' and entropy.dim() > 0:
for i, ent in enumerate(entropy.mean(dim=0)):
self.trainer.writer.add_scalar(
f"train_batch/{self.prefix}_class_{i}",
ent.item(),
global_step
)
[docs]
def after_epoch(self):
"""Log epoch average entropy."""
if hasattr(self.trainer, 'writer') and self.trainer.writer is not None:
if hasattr(self.trainer.storage, 'history') and self.prefix in self.trainer.storage.history():
avg_entropy = self.trainer.storage.history(self.prefix).avg
self.trainer.writer.add_scalar(
f"train/{self.prefix}",
avg_entropy,
self.trainer.epoch + 1
)
def __repr__(self):
"""Return a compact configuration summary for logs."""
return (f"{self.__class__.__name__}(logits_key='{self.logits_key}', "
f"prefix='{self.prefix}')")
[docs]
@HOOKS.register_module()
class PrototypeUsageLogger(HookBase):
"""Monitor prototype utilization in Sonata-style clustering heads.
In ``before_train`` (after unwrapping DDP) it finds the Sonata submodule and
registers a forward hook on each teacher/student head whose name contains
``"head"``. Each forward hook fires every ``log_frequency`` calls (training
mode only) and, from the head's output logits, computes prototype-usage
statistics via argmax assignment counts (synchronized across ranks):
number of used prototypes, percent unused, average tokens per active
prototype, and the assignment-distribution entropy. These are written to the
writer under ``{prefix}/<head>/...``. ``after_train`` removes all registered
hooks. Registered as ``PrototypeUsageLogger``.
When ``log_pooling_levels`` is set it ALSO hooks each teacher/student
``backbone`` and, by walking the ``pooling_parent`` chain of the returned
``Point`` (read-only, so the model's later up-cast is untouched), logs the
cross-rank average number of points per event at each hierarchy stage under
``{prefix}/<backbone>/points_per_event/level{k}`` (``level0`` = finest input
resolution, increasing with pooling depth). This surfaces redundant pooling
stages -- e.g. a deepest level that collapses to ~1 point per event after
many pooling operations.
Args:
log_frequency (int): Log statistics every this many head/backbone forward
passes. Defaults to ``10``.
prefix (str): Namespace prefix for the logged keys. Defaults to
``"prototypes"``.
log_pooling_levels (bool): Also log average points-per-event at each
backbone pooling stage. Defaults to ``True``.
Note:
Specific to models exposing a Sonata-like ``teacher``/``student``
``ModuleDict`` of heads; warns and does nothing if none is found.
Counts are all-reduced so the reported usage is global across GPUs.
Example:
Add to ``cfg.hooks`` for Sonata-style SSL; it watches each clustering
head's prototype assignments:
.. code-block:: python
hooks = [dict(type="PrototypeUsageLogger", log_frequency=20)]
# → every 20 head forward passes writes, per head, the cross-rank
# "prototypes/<teacher|student>/<head>/{used_count,unused_percent,
# tokens_per_prototype,assignment_entropy}" to the writer, plus
# "prototypes/<teacher|student>/backbone/points_per_event/level{k}"
# for each pooling stage
"""
def __init__(
self,
log_frequency=10,
prefix="prototypes",
log_pooling_levels=True,
):
"""Configure prototype usage logging cadence and namespace."""
self.log_frequency = log_frequency
self.prefix = prefix
self.log_pooling_levels = log_pooling_levels
self.hook_handles = []
self._step_counters = {}
[docs]
def before_train(self):
"""Register hooks on Sonata heads to capture prototype usage."""
self.trainer.logger.info(f"Monitoring prototype usage with prefix '{self.prefix}'")
# Access the model (unwrap DDP if needed)
if hasattr(self.trainer.model, 'module'):
model = self.trainer.model.module
else:
model = self.trainer.model
# Register hooks on the model
self._register_hooks(model)
def _register_hooks(self, model):
"""Register hooks on Sonata heads to capture prototype usage."""
# Clear previous hooks
for handle in self.hook_handles:
handle.remove()
self.hook_handles = []
# Find Sonata module
sonata_module = None
if hasattr(model, 'sonata'):
sonata_module = model.sonata
elif isinstance(model, torch.nn.ModuleDict) and 'sonata' in model:
sonata_module = model['sonata']
elif hasattr(model, '__class__') and 'Sonata' in model.__class__.__name__:
sonata_module = model
if not sonata_module:
self.trainer.logger.warning("Could not find Sonata module for prototype monitoring")
return
# Register hooks on teacher heads
if hasattr(sonata_module, 'teacher') and isinstance(sonata_module.teacher, torch.nn.ModuleDict):
for head_name, head in sonata_module.teacher.items():
if 'head' in head_name.lower():
self.trainer.logger.info(f"Registering prototype monitor on {head_name}")
hook = head.register_forward_hook(self._prototype_stats_hook(f"teacher/{head_name}"))
self.hook_handles.append(hook)
# Register hooks on student heads
if hasattr(sonata_module, 'student') and isinstance(sonata_module.student, torch.nn.ModuleDict):
for head_name, head in sonata_module.student.items():
if 'head' in head_name.lower():
self.trainer.logger.info(f"Registering prototype monitor on {head_name}")
hook = head.register_forward_hook(self._prototype_stats_hook(f"student/{head_name}"))
self.hook_handles.append(hook)
# Register hooks on the backbones to log per-stage point counts.
if self.log_pooling_levels:
for role in ("teacher", "student"):
sub = getattr(sonata_module, role, None)
if isinstance(sub, torch.nn.ModuleDict) and "backbone" in sub:
self.trainer.logger.info(
f"Registering pooling-level monitor on {role}/backbone"
)
hook = sub["backbone"].register_forward_hook(
self._pooling_levels_hook(f"{role}/backbone")
)
self.hook_handles.append(hook)
def _prototype_stats_hook(self, name):
"""Create a forward hook that calculates prototype statistics."""
def hook_fn(module, input, output):
if not module.training:
return
if output is None:
return
if name not in self._step_counters:
self._step_counters[name] = 0
self._step_counters[name] += 1
if self._step_counters[name] % self.log_frequency != 0:
return
if isinstance(output, tuple):
stats = {}
for i, o in enumerate(output):
stats[f"output_{i}"] = self._get_stats(o)
else:
stats = self._get_stats(output)
# Log to tensorboard/wandb if available
if hasattr(self.trainer, 'writer') and self.trainer.writer is not None:
current_iter = self.trainer.comm_info.get("iter", 0) + 1
current_epoch = self.trainer.epoch + 1
global_step = (current_epoch - 1) * len(self.trainer.train_loader) + current_iter
# Log metrics
for stat_name, stat_value in stats.items():
if isinstance(stat_value, dict):
for k, v in stat_value.items():
self.trainer.writer.add_scalar(
f"{self.prefix}/{name}/{stat_name}/{k}",
v,
global_step
)
else:
self.trainer.writer.add_scalar(
f"{self.prefix}/{name}/{stat_name}",
stat_value,
global_step
)
return hook_fn
def _get_stats(self, output):
"""Calculate statistics from output with proper distributed synchronization."""
import torch.distributed as dist
from pimm.utils.comm import get_world_size
with torch.no_grad():
# Get assignments by taking argmax of logits
assignments = output.argmax(dim=-1) # (tokens,)
# Total number of prototypes
total_prototypes = output.shape[-1]
# Count tokens per prototype locally
local_counts = torch.bincount(assignments, minlength=total_prototypes).float()
# Synchronize counts across all GPUs
if get_world_size() > 1:
dist.all_reduce(local_counts, op=dist.ReduceOp.SUM)
global_counts = local_counts
total_tokens = global_counts.sum().item()
# Calculate global usage metrics
used_mask = global_counts > 0
used_count = used_mask.sum().item()
unused_count = total_prototypes - used_count
unused_percent = (unused_count / total_prototypes) * 100
# Calculate tokens per prototype (global average)
tokens_per_prototype = total_tokens / used_count if used_count > 0 else 0
# Calculate entropy of assignment distribution (global)
probs = global_counts / total_tokens if total_tokens > 0 else global_counts
entropy = -torch.sum(probs * torch.log(probs + 1e-10))
# Create stats dictionary
stats = {
"used_count": used_count,
"unused_percent": unused_percent,
"tokens_per_prototype": tokens_per_prototype,
"assignment_entropy": entropy.item()
}
return stats
def _pooling_levels_hook(self, name):
"""Create a forward hook that logs avg points/event at each pooling stage."""
def hook_fn(module, input, output):
# Only log during training steps; eval/probe passes also call the
# backbone but are not what we want to characterize here.
if not getattr(self.trainer.model, "training", False):
return
point = output[0] if isinstance(output, tuple) else output
if not hasattr(point, "keys") or "feat" not in point:
return
key = f"pooling::{name}"
self._step_counters[key] = self._step_counters.get(key, 0) + 1
if self._step_counters[key] % self.log_frequency != 0:
return
stats = self._get_pooling_stats(point)
if not stats:
return
if hasattr(self.trainer, "writer") and self.trainer.writer is not None:
current_iter = self.trainer.comm_info.get("iter", 0) + 1
current_epoch = self.trainer.epoch + 1
global_step = (current_epoch - 1) * len(self.trainer.train_loader) + current_iter
for level, value in stats.items():
self.trainer.writer.add_scalar(
f"{self.prefix}/{name}/points_per_event/level{level}",
value,
global_step,
)
return hook_fn
def _get_pooling_stats(self, point):
"""Average points per event at each pooling stage (cross-rank).
Walks the ``pooling_parent`` chain WITHOUT consuming it (the model still
needs it for up-casting). ``level0`` is the finest input resolution and
the level index grows with pooling depth, so the largest level index is
the most aggressively pooled stage.
"""
import torch.distributed as dist
from pimm.utils.comm import get_world_size
with torch.no_grad():
# Walk from the returned (coarsest) point to the finest parent.
chain = [point]
cur = point
while "pooling_parent" in cur.keys():
cur = cur["pooling_parent"]
chain.append(cur)
levels = list(reversed(chain)) # finest first
world_size = get_world_size()
stats = {}
for k, lvl in enumerate(levels):
n_points = float(lvl["feat"].shape[0])
if "batch" in lvl.keys():
n_events = float(int(lvl["batch"].max().item()) + 1)
elif "offset" in lvl.keys():
n_events = float(lvl["offset"].shape[0])
else:
n_events = 1.0
totals = torch.tensor(
[n_points, n_events], device=lvl["feat"].device, dtype=torch.float64
)
if world_size > 1:
dist.all_reduce(totals, op=dist.ReduceOp.SUM)
total_points, total_events = totals[0].item(), totals[1].item()
stats[k] = total_points / total_events if total_events > 0 else 0.0
return stats
[docs]
def after_train(self):
"""Clean up hooks when training is done."""
for handle in self.hook_handles:
handle.remove()
def __repr__(self):
"""Return a compact configuration summary for logs."""
return (
f"{self.__class__.__name__}(log_frequency={self.log_frequency}, "
f"prefix='{self.prefix}', log_pooling_levels={self.log_pooling_levels})"
)
[docs]
@HOOKS.register_module()
class FeatureStdMonitor(HookBase):
"""Monitor feature standard deviation to detect representation collapse.
In ``before_train`` (after unwrapping DDP) it locates the student/teacher
module (Sonata, JEPA, etc.) and registers forward hooks on the selected
backbones (and their ``representation_fusion`` if present). Each forward
hook fires every ``log_frequency`` calls (training mode only) and computes,
with cross-rank synchronization, the global feature std, per-sample batch
std, and per-channel std summary (mean/min/max), logging them to the writer
under ``{prefix}/<module>/...``; with ``track_channels=True`` it also logs
each channel's std. ``after_train`` removes all hooks. Useful for catching
feature collapse (std trending to zero). Registered as ``FeatureStdMonitor``.
Args:
log_frequency (int): Log statistics every this many forward passes.
Defaults to ``10``.
prefix (str): Namespace prefix for the logged keys. Defaults to
``"feature_std"``.
monitor_student (bool): Register hooks on the student branch. Defaults
to ``True``.
monitor_teacher (bool): Register hooks on the teacher branch. Defaults
to ``True``.
track_channels (bool): If ``True``, additionally log per-channel std.
Defaults to ``False``.
Note:
Statistics are computed inside the forward pass to avoid retaining large
feature tensors. Warns and does nothing if no student/teacher module is
found.
Example:
Add to ``cfg.hooks`` to catch representation collapse during SSL; it
watches the std of teacher/student backbone features:
.. code-block:: python
hooks = [dict(type="FeatureStdMonitor", log_frequency=20)]
# → every 20 forward passes writes "feature_std/<student|teacher>/
# {global_std,batch_std,channel_mean_std,channel_min_std,
# channel_max_std}" to the writer (std trending to 0 signals collapse)
"""
def __init__(
self,
log_frequency=10,
prefix="feature_std",
monitor_student=True,
monitor_teacher=True,
track_channels=False
):
"""Configure which teacher/student features are monitored."""
self.log_frequency = log_frequency
self.prefix = prefix
self.monitor_student = monitor_student
self.monitor_teacher = monitor_teacher
self.track_channels = track_channels
self.step_count = 0
self.hook_handles = []
[docs]
def before_train(self):
"""Register forward hooks to capture feature statistics."""
self.trainer.logger.info(f"Monitoring feature statistics with prefix '{self.prefix}'")
# Access the model (unwrap DDP if needed)
if hasattr(self.trainer.model, 'module'):
model = self.trainer.model.module
else:
model = self.trainer.model
# Find Sonata modules to monitor
self._register_sonata_hooks(model)
def _register_sonata_hooks(self, model):
"""Register hooks on student and teacher modules to capture feature stats."""
# Clear previous hooks
for handle in self.hook_handles:
handle.remove()
self.hook_handles = []
# Check if model has student/teacher structure (Sonata, JEPA, etc.)
sonata_module = None
if hasattr(model, 'sonata'):
sonata_module = model.sonata
elif isinstance(model, torch.nn.ModuleDict) and 'sonata' in model:
sonata_module = model['sonata']
elif hasattr(model, 'student') and hasattr(model, 'teacher'):
sonata_module = model
if not sonata_module:
self.trainer.logger.warning("Could not find student/teacher module for feature monitoring")
return
# Register hooks on teacher backbone
if self.monitor_teacher and hasattr(sonata_module, 'teacher') and 'backbone' in sonata_module.teacher:
self.trainer.logger.info("Registering feature monitor on teacher backbone")
hook = sonata_module.teacher['backbone'].register_forward_hook(
self._feature_stats_hook('teacher')
)
self.hook_handles.append(hook)
if 'representation_fusion' in sonata_module.teacher:
self.trainer.logger.info("Registering feature monitor on teacher representation_fusion")
hook = sonata_module.teacher['representation_fusion'].register_forward_hook(
self._feature_stats_hook('teacher/representation_fusion')
)
self.hook_handles.append(hook)
# Register hooks on student backbone
if self.monitor_student and hasattr(sonata_module, 'student') and 'backbone' in sonata_module.student:
self.trainer.logger.info("Registering feature monitor on student backbone")
hook = sonata_module.student['backbone'].register_forward_hook(
self._feature_stats_hook('student')
)
self.hook_handles.append(hook)
if 'representation_fusion' in sonata_module.student:
self.trainer.logger.info("Registering feature monitor on student representation_fusion")
hook = sonata_module.student['representation_fusion'].register_forward_hook(
self._feature_stats_hook('student/representation_fusion')
)
self.hook_handles.append(hook)
def _feature_stats_hook(self, module_name):
"""Create a forward hook function that captures feature statistics."""
def hook_fn(module, input, output):
if not module.training:
return
if not hasattr(self, '_step_counter'):
self._step_counter = {}
if module_name not in self._step_counter:
self._step_counter[module_name] = 0
self._step_counter[module_name] += 1
if self._step_counter[module_name] % self.log_frequency != 0:
return
if hasattr(output, 'feat'):
features = output.feat
elif isinstance(output, torch.Tensor):
features = output
else:
return
with torch.no_grad():
import torch.distributed as dist
from pimm.utils.comm import get_world_size
features_flat = features.float()
local_n = torch.tensor([features_flat.numel()], device=features.device, dtype=torch.float64)
local_sum = features_flat.sum().to(torch.float64)
local_sum_sq = (features_flat ** 2).sum().to(torch.float64)
if get_world_size() > 1:
dist.all_reduce(local_n)
dist.all_reduce(local_sum)
dist.all_reduce(local_sum_sq)
global_mean = local_sum / local_n
global_var = (local_sum_sq / local_n) - (global_mean ** 2)
global_std = torch.sqrt(global_var.clamp(min=0)).item()
batch_std = torch.std(features, dim=1).mean().item()
local_channel_n = torch.tensor([features.shape[0]], device=features.device, dtype=torch.float64)
local_channel_sum = features.sum(dim=0).to(torch.float64)
local_channel_sum_sq = (features ** 2).sum(dim=0).to(torch.float64)
if get_world_size() > 1:
dist.all_reduce(local_channel_n)
dist.all_reduce(local_channel_sum)
dist.all_reduce(local_channel_sum_sq)
channel_mean = local_channel_sum / local_channel_n
channel_var = (local_channel_sum_sq / local_channel_n) - (channel_mean ** 2)
channel_std = torch.sqrt(channel_var.clamp(min=0))
channel_mean_std = channel_std.mean().item()
channel_min_std = channel_std.min().item()
channel_max_std = channel_std.max().item()
stats = {
"global_std": global_std,
"batch_std": batch_std,
"channel_mean_std": channel_mean_std,
"channel_min_std": channel_min_std,
"channel_max_std": channel_max_std
}
# Log to tensorboard/wandb if available
if hasattr(self.trainer, 'writer') and self.trainer.writer is not None:
current_iter = self.trainer.comm_info.get("iter", 0) + 1
current_epoch = self.trainer.epoch + 1
global_step = (current_epoch - 1) * len(self.trainer.train_loader) + current_iter
# Log metrics
for stat_name, stat_value in stats.items():
self.trainer.writer.add_scalar(
f"{self.prefix}/{module_name}/{stat_name}",
stat_value,
global_step
)
# Log per-channel std if requested
if self.track_channels:
for i, std_val in enumerate(channel_std):
self.trainer.writer.add_scalar(
f"{self.prefix}/{module_name}/channel_{i}_std",
std_val.item(),
global_step
)
return hook_fn
[docs]
def after_train(self):
"""Clean up hooks."""
for handle in self.hook_handles:
handle.remove()
def __repr__(self):
"""Return a compact configuration summary for logs."""
return (f"{self.__class__.__name__}("
f"log_frequency={self.log_frequency}, "
f"prefix='{self.prefix}')")
[docs]
@HOOKS.register_module()
class ParameterCounter(HookBase):
"""Log a parameter-count breakdown of the model at the start of training.
Runs once in ``before_train``: logs total / trainable / non-trainable
parameter counts and an estimated (fp32) memory footprint. With
``show_details=True`` it logs a per-module breakdown (optionally sorted by
size, filtered by ``min_params``) and the ten largest modules; with
``show_gradients=True`` it lists how many parameters require gradients and
which modules contain frozen parameters. Output goes to the trainer logger
only. Registered as ``ParameterCounter``.
Args:
show_details (bool): Show the per-module parameter breakdown. Defaults
to ``True``.
show_gradients (bool): Show gradient-requirement and frozen-module info.
Defaults to ``True``.
sort_by_params (bool): Sort the module breakdown by parameter count
(descending). Defaults to ``True``.
min_params (int): Omit modules with fewer than this many parameters from
the breakdown. Defaults to ``0``.
Note:
Purely diagnostic — logs to the console/logger, not the writer. The DDP
wrapper is unwrapped before counting.
Example:
Add to ``cfg.hooks``; once in ``before_train`` it logs a parameter
breakdown to the trainer logger (not the writer):
.. code-block:: python
hooks = [dict(type="ParameterCounter", min_params=1000)]
# → logs total/trainable/non-trainable param counts, an fp32 memory
# estimate, a per-module table (modules with >=1000 params), the top
# 10 largest modules, and which modules hold frozen params
"""
def __init__(self, show_details=True, show_gradients=True, sort_by_params=True, min_params=0):
"""Configure parameter detail level and filtering for startup logs."""
self.show_details = show_details
self.show_gradients = show_gradients
self.sort_by_params = sort_by_params
self.min_params = min_params
[docs]
def before_train(self):
"""Count and log parameter information before training starts."""
self.trainer.logger.info("=" * 80)
self.trainer.logger.info("MODEL PARAMETER ANALYSIS")
self.trainer.logger.info("=" * 80)
# Get the model (unwrap DDP if present)
model = self.trainer.model
if hasattr(model, 'module'):
model = model.module
# Count total parameters
total_params = sum(p.numel() for p in model.parameters())
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
non_trainable_params = total_params - trainable_params
# Estimate memory footprint (assuming float32)
memory_mb = (total_params * 4) / (1024 * 1024)
# Log overall statistics
self.trainer.logger.info(f"Total Parameters: {total_params:,}")
self.trainer.logger.info(f"Trainable Parameters: {trainable_params:,}")
self.trainer.logger.info(f"Non-trainable Parameters: {non_trainable_params:,}")
self.trainer.logger.info(f"Estimated Memory (params only): {memory_mb:.2f} MB")
if self.show_details:
self.trainer.logger.info("-" * 80)
self.trainer.logger.info("PARAMETER BREAKDOWN BY MODULE")
self.trainer.logger.info("-" * 80)
module_stats = []
# Collect statistics for each named module
for name, module in model.named_modules():
if name == "": # Skip root module
continue
# Count parameters in this specific module (not children)
module_params = sum(p.numel() for p in module.parameters(recurse=False))
module_trainable = sum(p.numel() for p in module.parameters(recurse=False) if p.requires_grad)
if module_params >= self.min_params:
module_stats.append({
'name': name,
'type': module.__class__.__name__,
'total_params': module_params,
'trainable_params': module_trainable,
'non_trainable_params': module_params - module_trainable
})
# Sort by parameter count if requested
if self.sort_by_params:
module_stats.sort(key=lambda x: x['total_params'], reverse=True)
# Display module breakdown
self.trainer.logger.info(f"{'Module Name':<50} {'Type':<20} {'Params':<12} {'Trainable':<12}")
self.trainer.logger.info("-" * 94)
for stats in module_stats:
if stats['total_params'] > 0:
self.trainer.logger.info(
f"{stats['name']:<50} {stats['type']:<20} "
f"{stats['total_params']:<12,} {stats['trainable_params']:<12,}"
)
# Show largest modules summary
if module_stats:
self.trainer.logger.info("-" * 80)
self.trainer.logger.info("TOP 10 LARGEST MODULES")
self.trainer.logger.info("-" * 80)
top_modules = sorted(module_stats, key=lambda x: x['total_params'], reverse=True)[:10]
for i, stats in enumerate(top_modules, 1):
if stats['total_params'] > 0:
percent = (stats['total_params'] / total_params) * 100
self.trainer.logger.info(
f"{i:2d}. {stats['name']:<40} {stats['total_params']:>10,} params ({percent:5.1f}%)"
)
if self.show_gradients:
self.trainer.logger.info("-" * 80)
self.trainer.logger.info("GRADIENT INFORMATION")
self.trainer.logger.info("-" * 80)
grad_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
no_grad_params = sum(p.numel() for p in model.parameters() if not p.requires_grad)
if grad_params > 0:
self.trainer.logger.info(f"Parameters requiring gradients: {grad_params:,}")
if no_grad_params > 0:
self.trainer.logger.info(f"Parameters NOT requiring gradients: {no_grad_params:,}")
# Show which modules have frozen parameters
frozen_modules = []
for name, module in model.named_modules():
frozen_in_module = sum(p.numel() for p in module.parameters(recurse=False) if not p.requires_grad)
if frozen_in_module > 0:
frozen_modules.append((name, frozen_in_module))
if frozen_modules:
self.trainer.logger.info("Modules with frozen parameters:")
for name, count in frozen_modules:
self.trainer.logger.info(f" {name}: {count:,} frozen params")
self.trainer.logger.info("=" * 80)
def __repr__(self):
"""Return a compact configuration summary for logs."""
return (f"{self.__class__.__name__}(show_details={self.show_details}, "
f"show_gradients={self.show_gradients})")
[docs]
@HOOKS.register_module()
class AttentionMaskAnnealingHook(HookBase):
"""Drive and log attention-mask annealing for the Panda detector decoder.
For models that gradually anneal their decoder attention masks (e.g. a
Mask3Former-style decoder). In ``before_train`` it seeds the step counter
from resumed progress, checks whether the model exposes
``update_anneal_step`` (disabling itself otherwise), and logs the annealing
schedule. In ``after_step`` it advances the model's annealing step every
step and, every ``log_frequency`` steps, reads each decoder block's anneal
factor to log the average (and per-block factors when
``log_per_layer=True``) to ``trainer.storage`` and the writer, announcing
once when annealing completes. Registered as ``AttentionMaskAnnealingHook``.
Args:
log_frequency (int): Log annealing factors every this many steps.
Defaults to ``100``.
log_per_layer (bool): If ``True``, also log each decoder block's anneal
factor. Defaults to ``False``.
prefix (str): Namespace prefix for the logged keys. Defaults to
``"anneal"``.
Note:
This hook *updates* the model's annealing state, so it is functionally
required (not merely diagnostic) for models that anneal. It silently
disables itself if the model lacks ``update_anneal_step`` or has
annealing turned off.
Example:
Add to ``cfg.hooks`` for a Mask3Former-style Panda decoder; it both
drives and logs attention-mask annealing:
.. code-block:: python
hooks = [
dict(type="AttentionMaskAnnealingHook",
log_frequency=100, log_per_layer=True),
]
# → calls model.update_anneal_step(step) every step; every 100 steps
# puts "anneal_factor" in trainer.storage and writes "anneal/average"
# (and "anneal/layer_<i>" per block) to the writer; logs once when the
# factor drops below 0.01 (annealing complete)
"""
def __init__(self, log_frequency=100, log_per_layer=False, prefix="anneal"):
"""Configure annealing update logging cadence and namespace."""
self.log_frequency = log_frequency
self.log_per_layer = log_per_layer
self.prefix = prefix
self.step_count = 0
self.annealing_complete = False
self.has_annealing = False
[docs]
def before_train(self):
"""Check if model supports annealing and log initial state."""
self.step_count = int(getattr(self.trainer, "global_step", 0) or (
self.trainer.start_epoch * len(self.trainer.train_loader)
+ self.trainer.start_iter
))
# Get model without DDP wrapper if present
model = self.trainer.model
if hasattr(model, 'module'):
model = model.module
# Check if model has update_anneal_step method
if hasattr(model, 'update_anneal_step'):
self.has_annealing = True
self.trainer.logger.info("Attention mask annealing enabled")
model.update_anneal_step(self.step_count)
# Log annealing configuration if available
if hasattr(model, 'decoder') and hasattr(model.decoder, 'attn_mask_anneal'):
if model.decoder.attn_mask_anneal:
steps = model.decoder.attn_mask_anneal_steps
warmup = model.decoder.attn_mask_warmup_steps
progressive = model.decoder.attn_mask_progressive
delay = model.decoder.attn_mask_progressive_delay
self.trainer.logger.info(
f"Annealing schedule: {steps} steps using cosine decay"
)
if warmup > 0:
self.trainer.logger.info(f" Warmup: {warmup} steps before annealing starts")
if progressive and delay > 0:
self.trainer.logger.info(
f" Progressive: {delay} steps delay between blocks "
f"(layer 0 starts at step {warmup}, layer N at step {warmup + delay * (len(model.decoder.blocks) - 1)})"
)
# Log per-block warmup if available
if hasattr(model.decoder, 'blocks') and len(model.decoder.blocks) > 0:
warmup_steps = [b.attn_mask_warmup_steps for b in model.decoder.blocks]
if len(set(warmup_steps)) > 1:
self.trainer.logger.info(
f" Per-block warmup: {warmup_steps}"
)
else:
self.trainer.logger.info("Attention masks enabled (no annealing)")
self.has_annealing = False
else:
self.trainer.logger.info("Model does not support attention mask annealing")
self.has_annealing = False
[docs]
def after_step(self):
"""Update annealing progress and optionally log statistics."""
if not self.has_annealing:
return
self.step_count += 1
# Get model
model = self.trainer.model
if hasattr(model, 'module'):
model = model.module
# Update annealing step
if hasattr(model, 'update_anneal_step'):
model.update_anneal_step(self.step_count)
# Log annealing progress
if self.step_count % self.log_frequency == 0:
self._log_annealing_stats(model)
def _log_annealing_stats(self, model):
"""Log annealing statistics to tensorboard/wandb."""
# Try to access decoder blocks
if not hasattr(model, 'decoder') or not hasattr(model.decoder, 'blocks'):
return
blocks = model.decoder.blocks
if len(blocks) == 0:
return
# Get annealing factors from blocks
anneal_factors = []
for i, block in enumerate(blocks):
if hasattr(block, 'get_anneal_factor'):
factor = block.get_anneal_factor()
anneal_factors.append(factor)
# Log per-layer if requested
if self.log_per_layer and self.trainer.writer is not None:
self.trainer.writer.add_scalar(
f"{self.prefix}/layer_{i}",
factor,
self.step_count
)
if len(anneal_factors) == 0:
return
# Compute average annealing factor
avg_factor = sum(anneal_factors) / len(anneal_factors)
# Log average factor
self.trainer.storage.put_scalar(f"{self.prefix}_factor", avg_factor)
if self.trainer.writer is not None:
self.trainer.writer.add_scalar(
f"{self.prefix}/average",
avg_factor,
self.step_count
)
# Report when annealing completes (factor drops below threshold)
if not self.annealing_complete and avg_factor < 0.01:
self.annealing_complete = True
self.trainer.logger.info(
f"Attention mask annealing completed at step {self.step_count} "
f"(factor={avg_factor:.4f})"
)
# Log info message periodically
if self.step_count % (self.log_frequency * 10) == 0:
self.trainer.logger.info(
f"Attention mask annealing factor: {avg_factor:.4f} "
f"(step {self.step_count})"
)
def __repr__(self):
"""Return a compact configuration summary for logs."""
return (
f"{self.__class__.__name__}("
f"log_frequency={self.log_frequency}, "
f"log_per_layer={self.log_per_layer}, "
f"prefix='{self.prefix}')"
)