import torch
import pimm.utils.comm as comm
from pimm.distributed import unwrap_model
from pimm.engines.hooks.default import HookBase
from pimm.engines.hooks.builder import HOOKS
from pimm.observability import structured_logger as sl
def _get_writer_step(trainer):
"""Local train step for logging; writer applies any configured offset."""
ci = getattr(trainer, "comm_info", {})
if "epoch" in ci and "iter" in ci:
return ci.get("epoch", 0) * ci.get("iter_per_epoch", 0) + ci.get("iter", 0) + 1
return getattr(trainer, "epoch", 0)
[docs]
@HOOKS.register_module()
class PretrainEvaluator(HookBase):
"""Evaluate frozen pretraining features with downstream linear probes.
On rank 0, extracts per-point backbone features from a budget of validation
events (the first ``max_train_events`` for probe training, the next
``max_test_events`` for probe testing), then fits a grid of linear
classifiers (via ``LinearProbingTrainer``) for each configured label and
reports per-class and macro IoU / precision / recall / F1 plus a confusion
matrix. Handles Sonata (teacher backbone), standard backbones (PTv3),
fusion-export models, and models exposing ``encode()`` (Point-M2AE),
unrolling any ``pooling_parent`` chain to recover point-resolution features.
It publishes ``mF1`` of the (last-evaluated) label as the
checkpoint-selection metric (``current_metric_value`` = ``m_f1``,
``current_metric_name`` = ``mF1``). Runs after every step when
``every_n_steps > 0`` (when ``(global_iter + 1) % every_n_steps == 0``),
otherwise after each epoch; only when ``cfg.evaluate`` is true. Registered as
``PretrainEvaluator`` (use as ``type`` in a ``hooks=[...]`` entry).
Args:
label (str | Sequence[str]): Label key(s) to probe; a string probes one
label, a sequence probes several. Defaults to ``"segment"``.
write_cls_iou (bool): Also log per-class IoU/F1/precision/recall to the
writer. Defaults to ``True``.
every_n_steps (int): Step cadence; ``0`` evaluates once per epoch.
Defaults to ``1``.
max_train_events (int): Number of events used to fit the linear probe.
Defaults to ``250``.
max_test_events (int): Number of events used to evaluate the probe.
Defaults to ``250``.
class_names (list | dict | None): Class names; a list (shared across
labels) or a per-label dict. Falls back to ``cfg.data.names``.
Defaults to ``None``.
prefix (str): Writer/metric prefix; the ``segment`` label uses the root
namespace. Defaults to ``""``.
train_config (dict | None): Config forwarded to ``LinearProbingTrainer``
(probe LR grid, epochs, etc.). Defaults to ``None``.
Note:
The selection metric is the linear-probe ``mF1`` (higher is better);
``current_metric_name`` is only set on the first eval, while
``current_metric_value`` is overwritten each eval. Evaluation runs on
rank 0 only; other ranks synchronize.
Example:
Add to ``cfg.hooks`` for SSL pretraining; it fits linear probes on frozen
backbone features and sets the selection metric:
.. code-block:: python
hooks = [
dict(type="PretrainEvaluator", label="segment",
every_n_steps=1000, max_train_events=250,
max_test_events=250),
]
# → every 1000 steps (rank 0) trains a linear probe on 250 events / tests
# on the next 250 and logs val/mIoU, val/mPrecision, val/mRecall,
# val/mF1 (+ per-class IoU/F1 with write_cls_iou), setting the
# checkpoint-selection metric to mF1
"""
def __init__(
self,
label="segment",
write_cls_iou=True,
every_n_steps=1,
max_train_events=250,
max_test_events=250,
class_names=None,
prefix="",
train_config=None,
):
"""Configure label heads, feature budget, cadence, and probe trainer."""
self.write_cls_iou = write_cls_iou
self.every_n_steps = every_n_steps
self.max_train_events = max_train_events
self.max_test_events = max_test_events
self.prefix = prefix
# support both single label and multiple labels
self.labels = [label] if isinstance(label, str) else list(label)
self.train_config = train_config
# support per-label class_names
# class_names can be:
# - None: use default names from cfg.data.names
# - list: same names for all labels
# - dict: {label_name: names_list} for per-label names
if class_names is None or not isinstance(class_names, dict):
# single set of names or None - apply to all labels
self.class_names_dict = {label_name: class_names for label_name in self.labels}
else:
# dict of per-label names
self.class_names_dict = class_names
[docs]
def after_step(self):
"""Run probing evaluation on the configured step cadence."""
if self.trainer.cfg.evaluate and self.every_n_steps > 0:
global_iter = self.trainer.comm_info['iter'] + self.trainer.comm_info['iter_per_epoch'] * self.trainer.comm_info['epoch']
if (global_iter + 1) % self.every_n_steps == 0:
self.eval()
[docs]
def after_epoch(self):
"""Run probing evaluation after epochs when step cadence is disabled."""
if self.trainer.cfg.evaluate and self.every_n_steps == 0:
self.eval()
[docs]
def get_backbone(self):
"""Return the backbone module used to produce probe features."""
model = unwrap_model(self.trainer.model)
if hasattr(model, "teacher"): # sonata
return model.teacher["backbone"]
elif hasattr(model, "backbone"): # else
return model.backbone
else:
raise ValueError(f"Model {model} has no backbone")
def _has_encode(self):
"""Check if the model has a direct encode() method (e.g. Point-M2AE)."""
model = unwrap_model(self.trainer.model)
return hasattr(model, "encode") and callable(model.encode)
def _uses_exported_point(self):
"""Fusion-enabled Sonata exports features outside the backbone."""
model = unwrap_model(self.trainer.model)
return bool(getattr(model, "representation_fusion_enabled", False))
def _process_batch_with_offsets(self, input_dict):
"""Process a batch and extract features properly using offsets to handle multiple events"""
for key in input_dict.keys():
if isinstance(input_dict[key], torch.Tensor):
input_dict[key] = input_dict[key].cuda(non_blocking=True)
with torch.inference_mode():
if self._has_encode():
# Models with encode() (e.g. Point-M2AE): returns packed (N, C) tensor
model = unwrap_model(self.trainer.model)
if getattr(self.trainer.cfg, "enable_amp", False):
amp_dtype = getattr(self.trainer.cfg, "amp_dtype", "bfloat16")
dtype = torch.bfloat16 if amp_dtype == "bfloat16" else torch.float16
with torch.amp.autocast(device_type="cuda", dtype=dtype):
features = model.encode(input_dict).float()
else:
features = model.encode(input_dict).float()
offsets = [0] + input_dict["offset"].cpu().tolist()
all_labels = {}
for label_name in self.labels:
all_labels[label_name] = input_dict[label_name].squeeze(-1)
batch_features = []
batch_labels_dict = {label_name: [] for label_name in self.labels}
for i in range(len(offsets) - 1):
start_idx = offsets[i]
end_idx = offsets[i + 1]
batch_features.append(features[start_idx:end_idx].cpu())
for label_name in self.labels:
batch_labels_dict[label_name].append(
all_labels[label_name][start_idx:end_idx].cpu()
)
return batch_features, batch_labels_dict
# Standard backbone path (Sonata, PTv3, etc.)
if getattr(self.trainer.cfg, "enable_amp", False):
amp_dtype = getattr(self.trainer.cfg, "amp_dtype", "bfloat16")
dtype = torch.bfloat16 if amp_dtype == "bfloat16" else torch.float16
with torch.amp.autocast(device_type="cuda", dtype=dtype):
if self._uses_exported_point():
point = unwrap_model(self.trainer.model)(
input_dict, return_point=True
)["point"]
else:
point = self.get_backbone()(input_dict)
else:
if self._uses_exported_point():
point = unwrap_model(self.trainer.model)(
input_dict, return_point=True
)["point"]
else:
point = self.get_backbone()(input_dict)
while "pooling_parent" in point.keys():
parent = point.pop("pooling_parent")
inverse = point.pop("pooling_inverse")
parent.feat = torch.cat([parent.feat, point.feat[inverse]], dim=-1)
point = parent
# Get features and offset information
features = point.feat.float() # [N, C]
point_offset = point["offset"] if "offset" in point.keys() else input_dict["offset"]
offsets = [0] + point_offset.cpu().tolist() # Batch offsets
# Extract all label types
all_labels = {}
for label_name in self.labels:
if label_name in point.keys():
all_labels[label_name] = point[label_name].squeeze(-1) # [N]
else:
all_labels[label_name] = input_dict[label_name].squeeze(-1) # [N]
# Process features by batch using offsets
batch_features = []
batch_labels_dict = {label_name: [] for label_name in self.labels}
# Use offsets to separate points from different events in the batch
for i in range(len(offsets) - 1):
start_idx = offsets[i]
end_idx = offsets[i + 1]
# Extract features for this event
event_features = features[start_idx:end_idx]
batch_features.append(event_features.cpu())
# Extract labels for each label type
for label_name in self.labels:
event_labels = all_labels[label_name][start_idx:end_idx]
batch_labels_dict[label_name].append(event_labels.cpu())
return batch_features, batch_labels_dict
[docs]
@sl.log_trace_span("evaluation.pretrain_semantic_segmentation")
def eval(self):
"""Collect event features, train probes, and log downstream metrics."""
sl.add_step_tag("evaluation")
# only run on rank 0
rank = comm.get_rank()
if rank != 0:
# wait for rank 0 to finish evaluation
if comm.get_world_size() > 1:
comm.synchronize()
return
self.trainer.model.eval()
self.trainer.logger.info(">>>>>>>>>>>>>>>> Start Evaluation >>>>>>>>>>>>>>>>")
# collect features and labels from events
train_features = []
train_labels_dict = {label_name: [] for label_name in self.labels}
test_features = []
test_labels_dict = {label_name: [] for label_name in self.labels}
event_count = 0
total_events_needed = self.max_train_events + self.max_test_events
for i, input_dict in enumerate(self.trainer.val_loader):
self.trainer.logger.info(f"Processing batch {i}")
batch_features, batch_labels_dict = self._process_batch_with_offsets(
input_dict
)
for event_idx, event_features in enumerate(batch_features):
if event_count < self.max_train_events:
train_features.append(event_features)
for label_name in self.labels:
train_labels_dict[label_name].append(
batch_labels_dict[label_name][event_idx]
)
elif event_count < total_events_needed:
test_features.append(event_features)
for label_name in self.labels:
test_labels_dict[label_name].append(
batch_labels_dict[label_name][event_idx]
)
else:
break
event_count += 1
if event_count >= total_events_needed:
break
# truncate to exact counts
train_features = train_features[: self.max_train_events]
test_features = test_features[: self.max_test_events]
for label_name in self.labels:
train_labels_dict[label_name] = train_labels_dict[label_name][
: self.max_train_events
]
test_labels_dict[label_name] = test_labels_dict[label_name][
: self.max_test_events
]
X_train = torch.cat(train_features, dim=0)
X_test = torch.cat(test_features, dim=0)
self.trainer.logger.info(
f"Train events: {len(train_features)}, Test events: {len(test_features)}"
)
self.trainer.logger.info(
f"Train features: {X_train.shape}, Test features: {X_test.shape}"
)
for label_name in self.labels:
self.trainer.logger.info(
f"\n{'=' * 60}\nEvaluating label: {label_name}\n{'=' * 60}"
)
y_train = torch.cat(train_labels_dict[label_name], dim=0)
y_test = torch.cat(test_labels_dict[label_name], dim=0)
if len(self.labels) > 1:
eval_prefix = (
label_name if not self.prefix else f"{self.prefix}_{label_name}"
)
else:
eval_prefix = self.prefix if self.prefix else label_name
label_class_names = self.class_names_dict.get(label_name, None)
self._evaluate_single_label(
X_train,
y_train,
X_test,
y_test,
eval_prefix,
label_class_names,
)
self.trainer.model.train()
# signal other ranks that evaluation is complete
if comm.get_world_size() > 1:
comm.synchronize()
def _evaluate_single_label(
self,
X_train,
y_train,
X_test,
y_test,
eval_prefix,
label_class_names,
):
"""Train and evaluate a grid of linear classifiers for a single label type."""
from pimm.engines.hooks.eval.pretrain.linear import (
LinearProbingTrainer,
)
# Use provided class names or fall back to default
if label_class_names is None:
label_class_names = self.trainer.cfg.data.names
num_classes = int(y_train.max().item()) + 1
trainer = LinearProbingTrainer(
X_train=X_train,
y_train=y_train,
X_test=X_test,
y_test=y_test,
num_classes=num_classes,
logger=self.trainer.logger,
config=self.train_config,
)
metrics = trainer.train_and_evaluate()
m_iou = metrics["m_iou"]
m_precision = metrics["m_precision"]
m_recall = metrics["m_recall"]
m_f1 = metrics["m_f1"]
iou_class = metrics["iou_class"]
precision_class = metrics["precision_class"]
recall_class = metrics["recall_class"]
f1_class = metrics["f1_class"]
cm = metrics["confusion_matrix"]
class_support = metrics["class_support"]
self.trainer.storage.put_scalar(f"{eval_prefix}_val_intersection", iou_class * (class_support + 1e-10))
self.trainer.storage.put_scalar(f"{eval_prefix}_val_union", (class_support + 1e-10))
self.trainer.storage.put_scalar(f"{eval_prefix}_val_target", class_support)
self.trainer.logger.info(
"Val result: mIoU/mPrec/mRec/mF1 {:.4f}/{:.4f}/{:.4f}/{:.4f}.".format(
m_iou, m_precision, m_recall, m_f1
)
)
from rich.table import Table
from rich.console import Console
table = Table(show_header=True, header_style="bold magenta")
table.add_column("ClassIdx", justify="right")
table.add_column("Name")
table.add_column("IoU", justify="right")
table.add_column("Precision", justify="right")
table.add_column("Recall", justify="right")
table.add_column("F1", justify="right")
table.add_column("Support", justify="right")
for i in range(num_classes):
table.add_row(
str(i),
str(label_class_names[i]),
f"{iou_class[i]:.4f}",
f"{precision_class[i]:.4f}",
f"{recall_class[i]:.4f}",
f"{f1_class[i]:.4f}",
str(class_support[i]),
)
console = Console(file=None, width=100, record=True)
console.print(table)
table_str = console.export_text() # noqa: F841
import pandas as pd
label_names = [str(n) for n in label_class_names[:num_classes]]
cm_df = pd.DataFrame(cm, index=label_names, columns=label_names)
self.trainer.logger.info("Confusion Matrix (rows=true, cols=pred):\n" + cm_df.to_string())
_prefix = eval_prefix
eval_prefix = eval_prefix + "/"
if eval_prefix == "segment/":
eval_prefix = ""
if self.trainer.writer is not None:
step = _get_writer_step(self.trainer)
self.trainer.writer.add_scalar(f"{eval_prefix}val/mIoU", m_iou, step)
self.trainer.writer.add_scalar(f"{eval_prefix}val/mPrecision", m_precision, step)
self.trainer.writer.add_scalar(f"{eval_prefix}val/mRecall", m_recall, step)
self.trainer.writer.add_scalar(f"{eval_prefix}val/mF1", m_f1, step)
if self.write_cls_iou:
for i in range(num_classes):
self.trainer.writer.add_scalar(
f"{eval_prefix}val/cls_{i}-{label_class_names[i]} IoU",
iou_class[i],
step,
)
self.trainer.writer.add_scalar(
f"{eval_prefix}val/cls_{i}-{label_class_names[i]} F1",
f1_class[i],
step,
)
self.trainer.writer.add_scalar(
f"{eval_prefix}val/cls_{i}-{label_class_names[i]} Precision",
precision_class[i],
step,
)
self.trainer.writer.add_scalar(
f"{eval_prefix}val/cls_{i}-{label_class_names[i]} Recall",
recall_class[i],
step,
)
self.trainer.logger.info("<<<<<<<<<<<<<<<<< End Evaluation <<<<<<<<<<<<<<<<<")
if "current_metric_value" not in self.trainer.comm_info.keys():
self.trainer.comm_info["current_metric_name"] = "mF1"
self.trainer.comm_info["current_metric_value"] = m_f1
self.trainer.comm_info[f"{_prefix}_current_metric_value"] = m_f1
# Stash for cross-hook readout (e.g. AutoresearchSummary).
if not hasattr(self, "last_metrics_by_label"):
self.last_metrics_by_label = {}
self.last_metrics_by_label[_prefix or "segment"] = {
"mF1": float(m_f1),
"mIoU": float(m_iou),
"mPrecision": float(m_precision),
"mRecall": float(m_recall),
}