import numpy as np
import wandb
import torch
import torch.distributed as dist
import pointops
from uuid import uuid4
try:
from torch_cluster import knn_graph
except ImportError:
knn_graph = None
import pimm.utils.comm as comm
from pimm.distributed import unwrap_model
from pimm.utils.misc import intersection_and_union_gpu
from pimm.engines.metrics import (
aggregate_instance_results,
compute_semseg_metrics,
eval_instances,
)
from pimm.engines.hooks.default import HookBase
from pimm.engines.hooks.builder import HOOKS
from pimm.observability import structured_logger as sl
from pimm.models.utils.structure import Point
from pimm.models.utils.misc import offset2bincount
import torch.nn.functional as F
from sklearn.metrics import adjusted_rand_score
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 InstanceSegmentationEvaluator(HookBase):
"""Instance-level segmentation metrics including ARI and detection/class stats.
Runs the model with ``return_point=True`` on ``trainer.val_loader`` (batch
size MUST be 1), postprocesses predicted instance masks via
``model.postprocess``, and greedily matches them to ground-truth instances
by mask IoU. For each configured label it reports detection
precision/recall/F1 and mean matched IoU, FP/FN counts, macro
classification precision/recall/F1 on matched instances, mean Adjusted Rand
Index (ARI), and, when momentum predictions and targets are present,
per-class momentum regression MAE/RMSE. Supports a single default label
(``labels=(None,)``) or multiple auxiliary labels via
``outputs_by_label``. Results are gathered to rank 0 on multi-GPU. The
primary label's detection F1 is published as the checkpoint-selection metric
(``current_metric_value`` = ``det_f1``, ``current_metric_name`` =
``<prefix>/ins_det_f1``). 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
``InstanceSegmentationEvaluator`` (use as ``type`` in a ``hooks=[...]``
entry).
Args:
every_n_steps (int): Step cadence; ``0`` evaluates once per epoch.
Defaults to ``0``.
stuff_threshold (float): Threshold passed to ``model.postprocess`` for
stuff/semantic regions. Defaults to ``0.5``.
mask_threshold (float): Per-point probability threshold for binarizing
predicted masks in postprocessing. Defaults to ``0.5``.
class_names (list | dict | None): Class names for logging; a list
(shared) or a per-label dict. Falls back to data-config/model
metadata. Defaults to ``None``.
stuff_classes (list | None): Class ids treated as stuff. Defaults to
``None``.
iou_thresh (float): Minimum mask IoU for a prediction to count as a
true-positive match. Defaults to ``0.5``.
require_class_for_match (bool): Require predicted and GT classes to
agree for a match to count. Defaults to ``False``.
labels (str | Sequence | None): Label(s) to evaluate; ``None`` evaluates
the default instance/segment label. Defaults to ``None`` (treated as
``(None,)``).
prefix (str | None): Writer metric prefix; defaults to ``val`` (or
``val/<label>`` for named labels) when ``None``. Defaults to
``None``.
primary_label (Any): Label whose ``det_f1`` becomes the selection
metric; defaults to the first entry of ``labels``. Defaults to
``None``.
set_current_metric (bool): Whether to publish a checkpoint-selection
metric at all. Defaults to ``True``.
instance_key (str | dict | None): Override input key for GT instance ids
(per label). Defaults to ``None``.
segment_key (str | dict | None): Override input key for GT semantic
labels (per label). Defaults to ``None``.
segment_fallback_key (str | dict | None): Fallback semantic key used
when ``segment_key`` is absent. Defaults to ``None``.
log_ari (bool): Compute and log mean ARI over events. Defaults to
``True``.
Note:
Requires validation batch size 1 (asserted). The selection metric is
instance detection F1 of the primary label (higher is better).
Momentum regression metrics are only computed when the model exposes a
regression criterion and the batch carries a ``momentum`` target.
Example:
Add to ``cfg.hooks`` (validation batch size MUST be 1); it scores
instance masks and sets the selection metric:
.. code-block:: python
hooks = [
dict(type="InstanceSegmentationEvaluator", every_n_steps=2000,
iou_thresh=0.5, labels="particle"),
]
# → every 2000 steps logs val/particle/ins_det_{precision,recall,f1,
# mean_iou,fp,fn,fp_per_gt,fn_per_gt}, val/particle/ins_cls_macro_
# {precision,recall,f1}, val/particle/ins_ari and sets the
# checkpoint-selection metric to val/particle/ins_det_f1
"""
def __init__(
self,
every_n_steps=0,
stuff_threshold=0.5,
mask_threshold=0.5,
class_names=None,
stuff_classes=None,
iou_thresh=0.5,
require_class_for_match=False,
labels=None,
prefix=None,
primary_label=None,
set_current_metric=True,
instance_key=None,
segment_key=None,
segment_fallback_key=None,
log_ari=True,
):
"""Configure label-specific instance metrics and logging behavior."""
self.every_n_steps = int(every_n_steps)
self.stuff_threshold = float(stuff_threshold)
self.mask_threshold = float(mask_threshold)
self.iou_thresh = float(iou_thresh)
self.require_class_for_match = bool(require_class_for_match)
if labels is None:
labels = (None,)
elif isinstance(labels, str):
labels = (labels,)
self.labels = tuple(labels)
self.prefix = prefix
self.primary_label = primary_label
self.set_current_metric = bool(set_current_metric)
self.instance_key = instance_key
self.segment_key = segment_key
self.segment_fallback_key = segment_fallback_key
self.log_ari = bool(log_ari)
self.class_names = class_names
self.stuff_classes = stuff_classes
@staticmethod
def _select_for_label(value, label, default=None):
"""Resolve scalar or label-keyed config values for one label."""
if value is None:
return default
if isinstance(value, dict):
if label in value:
return value[label]
label_key = str(label)
if label_key in value:
return value[label_key]
return value.get("default", default)
return value
def _metric_prefix(self, label):
"""Return the writer prefix used for a label's metrics."""
prefix = self.prefix
if prefix is None:
prefix = "val" if label is None else f"val/{label}"
return str(prefix).strip("/")
def _metric_key(self, label, name):
"""Return the full writer metric key for a label and metric name."""
return f"{self._metric_prefix(label)}/{name}"
@staticmethod
def _label_name(label):
"""Return a readable name for default or explicit labels."""
return "default" if label is None else str(label)
def _label_specs(self, model):
"""Return optional model-provided label metadata."""
return getattr(model, "label_specs", {}) or {}
@staticmethod
def _spec_value(spec, name, default=None):
"""Read a field from a dictionary or dataclass label config."""
return (
spec.get(name, default)
if isinstance(spec, dict)
else getattr(spec, name, default)
)
def _resolve_class_names(self, label, model):
"""Resolve class names from hook config, data config, or model specs."""
class_names = self._select_for_label(self.class_names, label, None)
if class_names:
return tuple(class_names)
data_cfg = self.trainer.cfg.data
if label is not None:
label_names_key = f"{label}_names"
if hasattr(data_cfg, label_names_key):
return tuple(getattr(data_cfg, label_names_key))
if label == "interaction" and hasattr(data_cfg, "interaction_names"):
return tuple(data_cfg.interaction_names)
if label == "particle" and hasattr(data_cfg, "names"):
return tuple(data_cfg.names)
spec = self._label_specs(model).get(label, {})
num_classes = self._spec_value(spec, "num_classes")
if num_classes is not None:
return tuple(range(int(num_classes)))
if hasattr(data_cfg, "names"):
return tuple(data_cfg.names)
return tuple(range(data_cfg.num_classes))
def _resolve_gt_labels(self, input_dict, point, model, label):
"""Return instance and segment ground truth tensors for one label."""
if label is None:
return (
getattr(point, "instance", None),
getattr(point, "segment", None),
)
spec = self._label_specs(model).get(label, {})
instance_key = (
self._select_for_label(self.instance_key, label, None)
or self._spec_value(spec, "instance_key")
or f"instance_{label}"
)
segment_key = (
self._select_for_label(self.segment_key, label, None)
or self._spec_value(spec, "segment_key")
or f"segment_{label}"
)
fallback_key = self._select_for_label(
self.segment_fallback_key, label, None
) or self._spec_value(spec, "segment_fallback_key")
if segment_key not in input_dict and fallback_key in input_dict:
segment_key = fallback_key
return input_dict.get(instance_key), input_dict.get(segment_key)
def _get_label_predictions(self, output_dict, label):
"""Return model predictions for an auxiliary label when available."""
if label is not None and "outputs_by_label" in output_dict:
outputs_by_label = output_dict["outputs_by_label"]
if label in outputs_by_label:
return outputs_by_label[label]
return None
def _should_set_current_metric(self, label):
"""Return whether this label should update checkpoint selection metrics."""
if not self.set_current_metric:
return False
primary = self.primary_label
if primary is None:
primary = self.labels[0] if self.labels else None
return label == primary
[docs]
def after_step(self):
"""Run instance 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 instance evaluation after epochs when step cadence is disabled."""
if self.trainer.cfg.evaluate and self.every_n_steps == 0:
self.eval()
[docs]
@sl.log_trace_span("evaluation.instance_segmentation")
def eval(self):
"""Evaluate instance masks, class labels, ARI, and momentum metrics."""
sl.add_step_tag("evaluation")
label_msg = ", ".join(self._label_name(label) for label in self.labels)
self.trainer.logger.info(
f">>>>>>>>>>>>>> Start Instance Segmentation Evaluation [{label_msg}] >>>>>>>>>>>>>>>>"
)
self.trainer.model.eval()
model = unwrap_model(self.trainer.model)
all_stats_by_label = {label: [] for label in self.labels}
ari_scores_by_label = {label: [] for label in self.labels}
momentum_stats_by_label = {label: [] for label in self.labels}
for input_dict in self.trainer.val_loader:
assert (
len(input_dict["offset"]) == 1
), "InstanceSegmentationEvaluator requires bs=1"
for key, value in input_dict.items():
if isinstance(value, torch.Tensor):
input_dict[key] = value.cuda(non_blocking=True)
with torch.no_grad():
output_dict = self.trainer.model(input_dict, return_point=True)
point = output_dict.get("point")
if point is None:
self.trainer.logger.warning(
"InstanceSegmentationEvaluator: missing point data"
)
continue
point_counts = output_dict.get("point_counts")
if point_counts is None:
point_counts = offset2bincount(point.offset)
for label in self.labels:
gt_instance, gt_segment = self._resolve_gt_labels(
input_dict, point, model, label
)
label_name = self._label_name(label)
if gt_instance is None:
self.trainer.logger.warning(
f"InstanceSegmentationEvaluator[{label_name}]: missing instance labels"
)
continue
if gt_segment is None:
self.trainer.logger.warning(
f"InstanceSegmentationEvaluator[{label_name}]: missing semantic labels"
)
continue
task_output = self._get_label_predictions(output_dict, label)
if task_output is not None:
pred_masks_list = task_output.get("pred_masks")
pred_logits_list = task_output.get("pred_logits")
pred_momentum_list = task_output.get("pred_momentum")
post_input = {
"outputs_by_label": output_dict["outputs_by_label"],
"point_counts": point_counts,
}
else:
pred_masks_list = output_dict.get("pred_masks")
pred_logits_list = output_dict.get("pred_logits")
pred_momentum_list = output_dict.get("pred_momentum")
stuff_probs = (
point.outputs.get("stuff_probs")
if hasattr(point, "outputs")
else None
)
post_input = {
"pred_masks": pred_masks_list,
"pred_logits": pred_logits_list,
"stuff_probs": stuff_probs,
"point_counts": point_counts,
"pred_momentum": pred_momentum_list,
}
if not pred_masks_list or pred_logits_list is None:
self.trainer.logger.warning(
f"InstanceSegmentationEvaluator[{label_name}]: missing predictions"
)
continue
post_kwargs = dict(
stuff_threshold=self.stuff_threshold,
mask_threshold=self.mask_threshold,
)
if label is not None:
post_kwargs["label"] = label
results = model.postprocess(post_input, **post_kwargs)
pred_instance_labels = results["instance_labels"].cpu()
pred_pid_labels = results["class_labels"].cpu()
pred_instance_momentum = None
if "pred_momentum" in results:
pred_instance_momentum = results["pred_momentum"].cpu()
gt_inst = gt_instance.detach().reshape(-1).cpu().numpy().astype(np.int64)
gt_pid = gt_segment.detach().reshape(-1).cpu().numpy().astype(np.int64)
pr_inst = pred_instance_labels.numpy().astype(np.int64)
pr_pid = pred_pid_labels.numpy().astype(np.int64)
class_names = self._resolve_class_names(label, model)
stats = eval_instances(
gt_inst,
pr_inst,
gt_pid,
pr_pid,
class_names=class_names,
iou_thresh=self.iou_thresh,
require_class_for_match=self.require_class_for_match,
)
all_stats_by_label[label].append(stats)
# evaluate momentum regression if available
momentum_gt = input_dict.get("momentum")
if pred_instance_momentum is not None and momentum_gt is not None:
counts = offset2bincount(point.offset)
counts_list = counts.cpu().tolist()
criterion = None
criteria = getattr(model, "criteria", None)
if criteria is not None and getattr(criteria, "criteria", None):
criterion = getattr(criteria.criteria[0], "criterion", None)
if criterion is not None:
mom_stats = self._eval_momentum(
stats["matches"],
pred_instance_momentum,
momentum_gt,
gt_instance,
counts_list,
criterion,
num_classes=len(class_names),
pred_instance_labels=pr_inst,
)
if mom_stats is not None:
momentum_stats_by_label[label].append(mom_stats)
if self.log_ari:
valid_ari_mask = pr_inst != -1
if valid_ari_mask.any():
ari_scores_by_label[label].append(
adjusted_rand_score(
gt_inst[valid_ari_mask], pr_inst[valid_ari_mask]
)
)
else:
ari_scores_by_label[label].append(float("nan"))
if comm.get_world_size() > 1:
gathered = comm.gather(
(all_stats_by_label, ari_scores_by_label, momentum_stats_by_label),
dst=0,
)
if comm.get_rank() == 0:
merged_stats = {label: [] for label in self.labels}
merged_ari = {label: [] for label in self.labels}
merged_momentum = {label: [] for label in self.labels}
for stats_chunk, ari_chunk, momentum_chunk in gathered:
for label in self.labels:
merged_stats[label].extend(stats_chunk.get(label, []))
merged_ari[label].extend(ari_chunk.get(label, []))
merged_momentum[label].extend(momentum_chunk.get(label, []))
all_stats_by_label = merged_stats
ari_scores_by_label = merged_ari
momentum_stats_by_label = merged_momentum
else:
all_stats_by_label = {label: [] for label in self.labels}
ari_scores_by_label = {label: [] for label in self.labels}
momentum_stats_by_label = {label: [] for label in self.labels}
if comm.get_world_size() > 1 and comm.get_rank() != 0:
self.trainer.model.train()
return
logged_any = False
current_metric_set = False
for label in self.labels:
all_stats = all_stats_by_label[label]
label_name = self._label_name(label)
if not all_stats:
self.trainer.logger.warning(
f"InstanceSegmentationEvaluator[{label_name}]: no stats computed"
)
continue
aggregated = aggregate_instance_results(
all_stats, require_class_for_match=self.require_class_for_match
)
class_names = self._resolve_class_names(label, model)
det = aggregated["detection"]
cls = aggregated["classification_on_matched"]
det_prec = det["precision"]
det_rec = det["recall"]
det_f1 = det["f1"]
det_iou = det["mean_matched_iou"]
total_gt = int(det.get("num_gt", 0))
total_pred = int(det.get("num_pred", 0))
total_matched = int(det.get("num_matched", 0))
fp_det = max(total_pred - total_matched, 0)
fn_det = max(total_gt - total_matched, 0)
fp_per_gt = (fp_det / total_gt) if total_gt > 0 else 0.0
fn_per_gt = (fn_det / total_gt) if total_gt > 0 else 0.0
support = cls["support"]
if np.any(support > 0):
precision_macro = float(np.mean(cls["precision"][support > 0]))
recall_macro = float(np.mean(cls["recall"][support > 0]))
f1_macro = float(np.mean(cls["f1"][support > 0]))
else:
precision_macro = recall_macro = f1_macro = 0.0
ari_clean = np.asarray(ari_scores_by_label[label], dtype=float)
ari_clean = ari_clean[~np.isnan(ari_clean)]
ari_mean = float(np.mean(ari_clean)) if ari_clean.size else float("nan")
self.trainer.logger.info(
"[{}] Detection P={:.3f} R={:.3f} F1={:.3f} IoU={:.3f}".format(
label_name, det_prec, det_rec, det_f1, det_iou
)
)
self.trainer.logger.info(
"[{}] Counts GT={} Pred={} TP={} FP={} FN={} (FP/GT={:.3f} FN/GT={:.3f})".format(
label_name,
total_gt,
total_pred,
total_matched,
fp_det,
fn_det,
fp_per_gt,
fn_per_gt,
)
)
self.trainer.logger.info(
"[{}] Classification macro P={:.3f} R={:.3f} F1={:.3f}".format(
label_name, precision_macro, recall_macro, f1_macro
)
)
if self.log_ari:
if not np.isnan(ari_mean):
self.trainer.logger.info(
"[{}] ARI mean={:.3f}".format(label_name, ari_mean)
)
else:
self.trainer.logger.info("[{}] ARI mean=n/a".format(label_name))
momentum_stats = momentum_stats_by_label[label]
if momentum_stats:
momentum_aggregated = self._aggregate_momentum_results(
momentum_stats, class_names
)
self._log_momentum_metrics(momentum_aggregated, class_names)
if self.trainer.writer is not None:
step = _get_writer_step(self.trainer)
self.trainer.writer.add_scalar(
self._metric_key(label, "ins_det_precision"), det_prec, step
)
self.trainer.writer.add_scalar(
self._metric_key(label, "ins_det_recall"), det_rec, step
)
self.trainer.writer.add_scalar(
self._metric_key(label, "ins_det_f1"), det_f1, step
)
self.trainer.writer.add_scalar(
self._metric_key(label, "ins_det_mean_iou"), det_iou, step
)
self.trainer.writer.add_scalar(
self._metric_key(label, "ins_det_fp"), fp_det, step
)
self.trainer.writer.add_scalar(
self._metric_key(label, "ins_det_fn"), fn_det, step
)
self.trainer.writer.add_scalar(
self._metric_key(label, "ins_det_fp_per_gt"), fp_per_gt, step
)
self.trainer.writer.add_scalar(
self._metric_key(label, "ins_det_fn_per_gt"), fn_per_gt, step
)
self.trainer.writer.add_scalar(
self._metric_key(label, "ins_cls_macro_precision"),
precision_macro,
step,
)
self.trainer.writer.add_scalar(
self._metric_key(label, "ins_cls_macro_recall"),
recall_macro,
step,
)
self.trainer.writer.add_scalar(
self._metric_key(label, "ins_cls_macro_f1"), f1_macro, step
)
if self.log_ari and not np.isnan(ari_mean):
self.trainer.writer.add_scalar(
self._metric_key(label, "ins_ari"), ari_mean, step
)
if self._should_set_current_metric(label):
self.trainer.comm_info["current_metric_value"] = det_f1
self.trainer.comm_info["current_metric_name"] = self._metric_key(
label, "ins_det_f1"
)
current_metric_set = True
elif self.set_current_metric and not current_metric_set:
self.trainer.comm_info.setdefault("current_metric_value", det_f1)
self.trainer.comm_info.setdefault(
"current_metric_name", self._metric_key(label, "ins_det_f1")
)
logged_any = True
if not logged_any:
self.trainer.model.train()
return
self.trainer.logger.info(
"<<<<<<<<<<<<<<<<< End Instance Segmentation Evaluation <<<<<<<<<<<<<<<<<"
)
self.trainer.model.train()
def _eval_momentum(
self,
matches,
pred_instance_momentum,
momentum_gt,
gt_instance,
counts,
criterion,
num_classes,
pred_instance_labels,
):
"""Evaluate momentum regression per class for matched instances."""
try:
b = 0
# get batch momentum ground truth
mom_gt_b = criterion._get_batch_tensor(
momentum_gt, b, counts, torch.device("cpu"), None
)
# get inverse mapping from points to instances
inst_b = gt_instance.squeeze(-1)
if inst_b.dim() == 0:
inst_b = inst_b.unsqueeze(0)
if inst_b.dim() == 2 and inst_b.shape[1] == 1:
inst_b = inst_b.squeeze(1)
inst_b = inst_b.cpu()
# Map GT ID -> Momentum
unique_gt_ids = torch.unique(inst_b)
unique_gt_ids = unique_gt_ids[unique_gt_ids >= 0]
gt_mom_map = {}
for gt_id in unique_gt_ids:
mask = (inst_b == gt_id)
if mask.any():
gt_mom_map[gt_id.item()] = mom_gt_b[mask].float().mean().item()
# Map Pred ID -> Momentum
if isinstance(pred_instance_labels, np.ndarray):
pred_inst_t = torch.from_numpy(pred_instance_labels)
else:
pred_inst_t = pred_instance_labels
# Sort points by instance ID
sorted_ids, sort_idx = torch.sort(pred_inst_t)
# Get unique IDs and their first occurrence index in the sorted array
unique_ids_sorted, counts_sorted = torch.unique_consecutive(sorted_ids, return_counts=True)
# Compute start indices
cumsum_counts = torch.cat([torch.tensor([0]), counts_sorted.cumsum(0)[:-1]])
# Extract values at first indices
first_indices = sort_idx[cumsum_counts]
mom_values = pred_instance_momentum[first_indices]
# Build map
pr_mom_map = {uid.item(): val.item() for uid, val in zip(unique_ids_sorted, mom_values) if uid.item() >= 0}
matched_preds = []
# Iterate matches
for m in matches:
pid = m["pred_id"]
gid = m["gt_id"]
pred_cls = m["pred_cls"] # used for per-class stats
if pid in pr_mom_map and gid in gt_mom_map:
matched_preds.append({
"pred": pr_mom_map[pid],
"gt": gt_mom_map[gid],
"cls": pred_cls
})
if not matched_preds:
return None
# Compute stats
all_p = np.array([x["pred"] for x in matched_preds])
all_g = np.array([x["gt"] for x in matched_preds])
all_c = np.array([x["cls"] for x in matched_preds])
all_e = all_p - all_g
# Collect stats per class
class_stats = {}
for cls_idx in range(num_classes):
cls_mask = (all_c == cls_idx)
if not np.any(cls_mask):
continue
cls_errors = all_e[cls_mask]
cls_mae = float(np.mean(np.abs(cls_errors)))
cls_rmse = float(np.sqrt(np.mean(cls_errors ** 2)))
cls_count = int(np.sum(cls_mask))
class_stats[cls_idx] = {
"mae": cls_mae,
"rmse": cls_rmse,
"count": cls_count,
}
# Overall
overall_mae = float(np.mean(np.abs(all_e)))
overall_rmse = float(np.sqrt(np.mean(all_e ** 2)))
return {
"per_class": class_stats,
"overall": {
"mae": overall_mae,
"rmse": overall_rmse,
"count": len(all_e),
},
}
except Exception as e:
self.trainer.logger.warning(f"Error evaluating momentum: {e}")
import traceback
traceback.print_exc()
return None
def _aggregate_momentum_results(self, momentum_stats_list, class_names):
"""Aggregate momentum evaluation results across batches."""
num_classes = len(class_names)
# initialize per-class accumulators
class_mae = {i: [] for i in range(num_classes)}
class_rmse = {i: [] for i in range(num_classes)}
class_count = {i: 0 for i in range(num_classes)}
# overall accumulators
overall_mae = []
overall_rmse = []
overall_count = 0
for stats in momentum_stats_list:
if stats is None:
continue
# accumulate per-class stats
for cls_idx, cls_stats in stats["per_class"].items():
if cls_idx < num_classes:
class_mae[cls_idx].append(cls_stats["mae"])
class_rmse[cls_idx].append(cls_stats["rmse"])
class_count[cls_idx] += cls_stats["count"]
# accumulate overall stats
overall_mae.append(stats["overall"]["mae"])
overall_rmse.append(stats["overall"]["rmse"])
overall_count += stats["overall"]["count"]
# compute aggregated per-class metrics
aggregated_per_class = {}
for cls_idx in range(num_classes):
if len(class_mae[cls_idx]) > 0:
aggregated_per_class[cls_idx] = {
"mae": float(np.mean(class_mae[cls_idx])),
"rmse": float(np.mean(class_rmse[cls_idx])),
"count": class_count[cls_idx],
}
# compute aggregated overall metrics
aggregated_overall = {
"mae": float(np.mean(overall_mae)) if overall_mae else 0.0,
"rmse": float(np.mean(overall_rmse)) if overall_rmse else 0.0,
"count": overall_count,
}
return {
"per_class": aggregated_per_class,
"overall": aggregated_overall,
}
def _log_momentum_metrics(self, momentum_aggregated, class_names):
"""Log momentum regression metrics."""
self.trainer.logger.info("Momentum Regression Metrics:")
# overall metrics
overall = momentum_aggregated["overall"]
self.trainer.logger.info(
"Overall: MAE={:.4f} RMSE={:.4f} Count={}".format(
overall["mae"], overall["rmse"], overall["count"]
)
)
# per-class metrics
per_class = momentum_aggregated["per_class"]
if per_class:
self.trainer.logger.info("Per-class metrics:")
for cls_idx in sorted(per_class.keys()):
cls_name = class_names[cls_idx] if cls_idx < len(class_names) else f"class_{cls_idx}"
cls_stats = per_class[cls_idx]
self.trainer.logger.info(
" {}: MAE={:.4f} RMSE={:.4f} Count={}".format(
cls_name, cls_stats["mae"], cls_stats["rmse"], cls_stats["count"]
)
)
# log to tensorboard if available
if self.trainer.writer is not None:
step = _get_writer_step(self.trainer)
overall = momentum_aggregated["overall"]
self.trainer.writer.add_scalar("val/momentum_overall_mae", overall["mae"], step)
self.trainer.writer.add_scalar("val/momentum_overall_rmse", overall["rmse"], step)
per_class = momentum_aggregated["per_class"]
for cls_idx in sorted(per_class.keys()):
cls_name = class_names[cls_idx] if cls_idx < len(class_names) else f"class_{cls_idx}"
cls_stats = per_class[cls_idx]
self.trainer.writer.add_scalar(
f"val/momentum_{cls_name}_mae", cls_stats["mae"], step
)
self.trainer.writer.add_scalar(
f"val/momentum_{cls_name}_rmse", cls_stats["rmse"], step
)