Batching Loss — StreamPETR Head

This page documents the batched loss computation used by StreamPETRHeadBatched. It builds on the same approach as the focal head but extends it to multiple decoder layers, keeping the computation batched over both samples and layers while returning per-layer losses.

The reference head is already partially batched over samples: within each decoder layer, it concatenates the per-sample targets and invokes each classification and bounding-box loss function once. The optimized head does not newly batch those loss-function calls over samples. Instead, it batches the surrounding per-sample target preparation, keeps variable-length ground truth in RaggedBatch objects, computes assignment cost matrices in batches, and computes bounding-box loss only for matched prediction/target pairs. The SciPy Hungarian solver still handles each layer/sample entry separately. The optimized head additionally invokes the classification and bounding-box loss functions across all decoder layers at once, including the DN losses; the reference processes one layer at a time.

You can find the implementation in the repository here:
  • StreamPETRHeadBatched: demos/streampetr/projects/mmdet3d_plugin/models/dense_heads/streampetr_head_batched.py

  • StreamPETRHead (reference): demos/streampetr/projects/mmdet3d_plugin/models/dense_heads/streampetr_head.py

  • Helper classes:

    • HungarianAssigner3DBatched: demos/streampetr/projects/mmdet3d_plugin/core/bbox/assigners/hungarian_assigner_3d_batched.py

    • HungarianAssigner3D (non-batched reference): demos/streampetr/projects/mmdet3d_plugin/core/bbox/assigners/hungarian_assigner_3d.py

Note

The used HungarianAssigner3DBatched follows the same approach as the HungarianAssigner2DBatched, with some changes in the cost matrix computation (but still using the same approach to compute the matrices). For more details, please also see the source code of the HungarianAssigner3DBatched and the page on the matching implementation.

Overview and Goals

The more fully batched loss computation is based on the same approach as used in focal_head_batched.py. Please, see the notes in focal_head_batched.py first for a detailed explanation of the approach.

The original StreamPETR head already batches the final classification and bounding-box loss-function calls over samples within each decoder layer: it concatenates the per-sample targets and flattens the sample and query dimensions before invoking the loss functions. The changes here instead batch the surrounding target preparation and cost-matrix computation over samples and decoder layers, preserve the structured batch dimensions until the loss-function boundary, and compute bounding-box loss only for matched prediction/target pairs. The SciPy Hungarian solver itself still runs separately for each layer/sample entry. The changes also add batching of the loss-function calls across decoder layers, including the DN losses, which is not present in the original implementation.

Here, we extend the approach to the case of multiple decoder layers, where the loss is computed for each decoder layer (i.e. batched, but not reduced over the layers). The explanation here is focused on this extension. The extension is done as follows:

  1. The GT data is duplicated for each decoder layer to match the shape of the predictions. This is similar to the original implementation. However, lists are used there. Here, we use RaggedBatch instances. After converting the GT data to RaggedBatch objects, an additional batch dimension is added, and the GT data is duplicated for each decoder layer, so that the additional batch dimension iterates over the decoder layers.

  2. The method _compute_per_layer_loss() is used as a wrapper around the actual loss functions and allows for reduction of the loss over the samples while retaining the per-decoder-layer separation. Note that this is done to work around the fact that the loss functions can only reduce over all objects in the batch at once, but cannot return per-sample losses, i.e. they cannot reduce over the objects only, keeping the batch dimension(s) intact.

Also note that the loss computation includes the DN loss computation (dn_loss_batched()), which is not present for the focal head. The inputs for the DN loss computation are obtained from prepare_for_loss(), which returns the data with the first dimension being the decoder layers, so that the inputs can be directly used for batched loss computation.

The implementation of the loss computation is done in the loss() function. Please see the notes & doc-strings there and in the related functions called from there for more details.

Below, we discuss the implementation in detail, focusing on the parts of the implementation that differ from or extend the focal head case.

Top-level Loss Computation

Pre-processing

Initial validation and GT preparation (combining samples as ragged) mirrors the focal head.

demos/streampetr/projects/mmdet3d_plugin/models/dense_heads/streampetr_head_batched.py
1084        # ===== Pre-processing (`loss()`) =====
1085        nvtx_wrp.range_push("StreamPETRHeadBatched.loss")
1086        stopwatch.start_meas("StreamPETRHeadBatched.loss")
1087        assert gt_bboxes_ignore is None, \
1088            f'{self.__class__.__name__} only supports ' \
1089            f'for gt_bboxes_ignore setting to None.'
1090        
1091        loss_dict = dict()
1092
1093        all_cls_scores = preds_dicts['all_cls_scores']
1094        all_bbox_preds = preds_dicts['all_bbox_preds']
1095
1096        num_dec_layers = len(all_cls_scores)
1097        device = gt_labels_list[0].device
1098        #@NOTE
1099        # Note that `gt_bboxes_list` is on the CPU, so we need to transfer it to the GPU.
1100        # However, this is done when calling `combine_data()` below. Typically, it is more efficient to
1101        # do the combining of samples onto batched on the CPU, so we do not not transfer the data to the GPU
1102        # before calling `combine_data()`.
1103        gt_bboxes_list = [
1104            torch.cat((gt_bboxes.gravity_center, gt_bboxes.tensor[:, 3:]), dim=1)
1105            for gt_bboxes in gt_bboxes_list
1106        ]
1107        gt_bboxes = bhl.combine_data(gt_bboxes_list, device=device, flatten_batch_dims=True)
1108
1109        #@NOTE
1110        # Using `other_with_same_sample_sizes=gt_bboxes` to share the memory for the 
1111        # `sample_sizes` and `mask` tensors of the two RaggedBatch instances.
1112        gt_labels = bhl.combine_data(gt_labels_list, other_with_same_sample_sizes=gt_bboxes, flatten_batch_dims=True)
1113

Prepare Per-layer GT

A key difference comes next where GT is duplicated across decoder layers to match prediction shapes. Introduce a new batch dimension for decoder layers and repeat GT along it to match prediction layout:

demos/streampetr/projects/mmdet3d_plugin/models/dense_heads/streampetr_head_batched.py
1114        # ===== Prepare per-layer GT =====
1115        #@NOTE
1116        # Duplicating the GT for each layer in order to match the shape of the predictions, which
1117        # are also per-layer (but in contrast to the GT, the predictions differ for each layer). 
1118        # This is done by introducing a new batch dimension (iterating over decoder layers) and repeating the GT data 
1119        # for each decoder layer along that dimension.
1120        gt_bboxes = gt_bboxes.unsqueeze_batch_dim(0)
1121        gt_bboxes = gt_bboxes.repeat_samples(num_dec_layers, batch_dim=0)
1122        gt_labels = gt_labels.unsqueeze_batch_dim(0)
1123        gt_labels = gt_labels.repeat_samples(num_dec_layers, batch_dim=0)
1124        

Compute Batched Loss over Decoder Layers

Delegate to loss_batched to compute per-layer losses in a batched manner:

demos/streampetr/projects/mmdet3d_plugin/models/dense_heads/streampetr_head_batched.py
1125        # ===== Compute batched loss over decoder layers =====
1126        #@NOTE 
1127        # Compute the loss for each decoder layer. Note that in the original implementation, the loss is 
1128        # computed for each decoder layer separately. Here, we batch the computation over all decoder layers. 
1129        # 
1130        # Batching over the individual samples in the batch is done both in the original and our 
1131        # implementations (although the approaches differ).
1132        losses_cls, losses_bbox = self.loss_batched(all_cls_scores, all_bbox_preds, gt_bboxes, gt_labels)
1133
1134        # loss from the last decoder layer

Assemble Results

Collect losses into the output dictionary and finalize timers:

demos/streampetr/projects/mmdet3d_plugin/models/dense_heads/streampetr_head_batched.py
1135        # ===== Assemble results =====
1136        loss_dict['loss_cls'] = losses_cls[-1]
1137        loss_dict['loss_bbox'] = losses_bbox[-1]
1138
1139        # loss from other decoder layers
1140        for ndl, (loss_cls_i, loss_bbox_i) in enumerate(zip(losses_cls[:-1], losses_bbox[:-1])):
1141            loss_dict[f'd{ndl}.loss_cls'] = loss_cls_i
1142            loss_dict[f'd{ndl}.loss_bbox'] = loss_bbox_i
1143

DN Loss (Batched over Layers) & Build Loss Dictionary

When DN is enabled, DN losses are also computed batched over decoder layers and merged into the dictionary. Note that in the original implementation, the dn_loss_single() function is called for each decoder layer separately. Here, we implement dn_loss_batched() to compute the DN loss batched over all decoder layers.

demos/streampetr/projects/mmdet3d_plugin/models/dense_heads/streampetr_head_batched.py
1144        # ===== DN Loss (batched over layers) =====
1145        #@NOTE: Compute the DN loss. 
1146        if preds_dicts['dn_mask_dict'] is not None:
1147            known_labels, known_bboxs, output_known_class, output_known_coord, num_tgt = \
1148                self.prepare_for_loss(preds_dicts['dn_mask_dict'])
1149            #@NOTE
1150            # Duplicating the GT for each decoder layer to match the shape of the predictions, which
1151            # are also per-layer (but differ for each layer in contrast to the GT). In the original 
1152            # implementation, lists are used instead of `repeat()`, as the GT is not batched over the decoder 
1153            # layers and instead, the loss is computed for each layer individually. Here, the computation is 
1154            # batched, so we need the GT data to be repeated for each decoder layer.
1155            #   
1156            # Also, note that here, we use tensors and not `RaggedBatch` instances for the GT data, as the 
1157            # GT is of uniform size.
1158            all_known_bboxs = known_bboxs.unsqueeze(0).repeat((num_dec_layers, 1, 1))
1159            all_known_labels = known_labels.unsqueeze(0).repeat((num_dec_layers, 1))
1160            all_num_tgt = torch.full(
1161                (num_dec_layers,), num_tgt, dtype=torch.float32, device=all_known_labels.device
1162            )
1163
1164            #@NOTE: Computing the DN loss batched over the decoder layers.
1165            dn_losses_cls, dn_losses_bbox = self.dn_loss_batched(
1166                output_known_class, output_known_coord, all_known_bboxs, all_known_labels, all_num_tgt
1167            )
1168
1169            loss_dict['dn_loss_cls'] = dn_losses_cls[-1]
1170            loss_dict['dn_loss_bbox'] = dn_losses_bbox[-1]
1171            #@NOTE
1172            # Here, we split the loss for each decoder layer and store them in the loss dictionary
1173            # This results in a dictionary with the same structure as in the original implementation.
1174            for ndl, (dn_l_cls_i, dn_l_bbox_i) in enumerate(zip(dn_losses_cls[:-1], dn_losses_bbox[:-1])):
1175                loss_dict[f'd{ndl}.dn_loss_cls'] = dn_l_cls_i
1176                loss_dict[f'd{ndl}.dn_loss_bbox'] = dn_l_bbox_i
1177        elif self.with_dn:
1178            #@NOTE
1179            # In this case, the DN loss computation is the same as the loss computation, so we
1180            # re-use the results from the loss computation. Note that this is different from the original 
1181            # implementation, where the loss is re-computed. 
1182            # 
1183            # This branch is not used in the configurations used for the runtime experiments and
1184            # is only added for feature completeness w.r.t. the original implementation.
1185            loss_dict['dn_loss_cls'] = loss_dict['loss_cls'].detach().clone()
1186            loss_dict['dn_loss_bbox'] = loss_dict['loss_bbox'].detach().clone()
1187            for ndl in range(num_dec_layers - 1):
1188                loss_dict[f'd{ndl}.dn_loss_cls'] = loss_dict[f'd{ndl}.loss_cls'].detach().clone()
1189                loss_dict[f'd{ndl}.dn_loss_bbox'] = loss_dict[f'd{ndl}.loss_bbox'].detach().clone()
1190

Finalize

The final step is to return the loss dictionary.

demos/streampetr/projects/mmdet3d_plugin/models/dense_heads/streampetr_head_batched.py
1191        # ===== Finalize (`loss()`) =====
1192        nvtx_wrp.range_pop("StreamPETRHeadBatched.loss")
1193        stopwatch.end_meas("StreamPETRHeadBatched.loss")
1194
1195        return loss_dict

Inside loss_batched()

Pre-processing

Shape extraction and entry-point for the per-layer batched computation:

demos/streampetr/projects/mmdet3d_plugin/models/dense_heads/streampetr_head_batched.py
797        # ===== Pre-processing (`loss_batched()`) =====
798        nvtx_wrp.range_push("StreamPETRHeadBatched.loss_batched")
799        num_dec_layers = cls_scores.size(0)
800        num_samples_in_batch = cls_scores.size(1)
801        num_preds = cls_scores.size(2)
802        

Perform the Matching

Call the 3D batched assigner to obtain matched indices. Note that this is analogous to the focal head case.

demos/streampetr/projects/mmdet3d_plugin/models/dense_heads/streampetr_head_batched.py
803        # ===== Perform the matching =====
804        #@NOTE
805        # The following is the same as the case of `FocalHeadBatched.loss_batched()`, i.e. we
806        # call the assign() method directly and obtain the matched indices.
807        # 
808        # This is the same as in the focal head case. However, the assigner itself if a different class
809        # (i.e. `HungarianAssigner3DBatched` instead of `HungarianAssigner2DBatched`). Also, the data
810        # has multiple batch dimensions (which are handled automatically by the assigner implementation).
811        matched_pred_indices, matched_gt_indices = self.assigner.assign(bbox_preds, cls_scores, gt_bboxes,
812                                                                        gt_labels, None, self.match_costs, 
813                                                                        self.match_with_velo)
814        

Per-decoder-layer Counts and Average Factors

Compute per-layer number of positives/negatives and average factors. In contrast to the focal head case, the number of positives/negatives is computed for each decoder layer separately.

demos/streampetr/projects/mmdet3d_plugin/models/dense_heads/streampetr_head_batched.py
815        # ===== Per-layer counts and avg factors =====
816        #@NOTE: `per_sample_nums_pos` will have the same shape as the batch dimensions.
817        per_sample_nums_pos = matched_pred_indices.sample_sizes
818        #@NOTE
819        # Here, we sum over the samples (the second batch dimension, i.e. `dim==1`) to obtain the total 
820        # number of positive samples for each decoder layer. Note that the first batch dimension, i.e. 
821        # `dim==0`, is over the decoder layers, and we keep that dimension.
822        #   
823        # For comparison: In the original implementation, the number of positive samples is 
824        # computed for each decoder layer separately (in the `get_targets()` function).
825        per_layer_nums_pos = per_sample_nums_pos.sum(dim=1)
826        per_layer_nums_neg = num_preds * num_samples_in_batch - per_layer_nums_pos
827
828        #@NOTE
829        # The following is the batched version of the per-layer normalization factor computation (for the 
830        # classification loss). This is equivalent to the (per-sample) computation in the original 
831        # implementation.
832        per_layer_cls_avg_factors = per_layer_nums_pos * 1.0 + \
833            per_layer_nums_neg * self.bg_cls_weight
834        if self.sync_cls_avg_factor:
835            per_layer_cls_avg_factors = reduce_mean(per_layer_cls_avg_factors)
836        #@NOTE 
837        # Make sure we don't have any division by zero (similar to original implementation).
838        # This is equivalent to the original implementation, but done here for tensor elements
839        # instead of for a scalar value.
840        per_layer_cls_avg_factors[per_layer_cls_avg_factors < 1.0] = 1.0
841

Align Labels and Align Matched Boxes

Map GT labels to predictions (for classification) and align matched GT/prediction boxes (for bounding box regression loss):

demos/streampetr/projects/mmdet3d_plugin/models/dense_heads/streampetr_head_batched.py
842        # ===== Align labels and align matched boxes =====
843        nvtx_wrp.range_push("Indexing [StreamPETRHeadBatched.loss_batched]")
844        #@NOTE:
845        # Align/map back the GT labels to the predicted labels as is done for losses which 
846        # are computed for all predictions, not just the matched ones. This is analogous to the focal head 
847        # classification loss. Please have a look at the notes in `focal_head_batched.py` for a more detailed 
848        # description. The main difference is that here, we have multiple batch dimensions. However, this is 
849        # handled automatically by `bhl.batched_index_mapping()`.
850        gt_labels_mapped_to_pred = torch.full(
851            (num_dec_layers, num_samples_in_batch, num_preds), 
852            fill_value=self.cls_out_channels, 
853            dtype=gt_labels.dtype, device=gt_labels.device
854        )
855        gt_labels_mapped_to_pred = bhl.batched_index_mapping(
856            gt_labels, matched_gt_indices, matched_pred_indices, gt_labels_mapped_to_pred
857        )
858
859        #@NOTE:
860        # Get the aligned (i.e. indexed using indices obtained from the matching) versions of the 
861        # bounding boxes. Again, this is analogous to the focal head case (except for the additional batch 
862        # dimensions). Please have a look at the notes in `focal_head_batched.py` for a more detailed 
863        # description.
864        gt_bboxes_matched = bhl.batched_indexing_access(gt_bboxes, matched_gt_indices)
865        pred_bboxes_matched = bhl.batched_indexing_access(bbox_preds, matched_pred_indices)
866        nvtx_wrp.range_pop()
867
868        label_weights = torch.ones(
869            (num_dec_layers, num_samples_in_batch, num_preds), 
870            dtype=torch.float32, device = gt_labels.device
871        )
872

Losses: Classification

Compute classification loss per layer using the wrapper to reduce over samples while keeping layers separate.

demos/streampetr/projects/mmdet3d_plugin/models/dense_heads/streampetr_head_batched.py
873        # ===== Losses: Classification (per-layer wrapper) =====
874        nvtx_wrp.range_push("loss_cls computation[StreamPETRHeadBatched.loss_batched]")
875        #@NOTE
876        # The method `_compute_per_layer_loss()` is a wrapper to the actual loss computation and enables
877        # batching over both samples and decoder layers, with the possibility to compute the loss for each
878        # layer separately, while combining (i.e. summing or computing the average) the loss over the samples.
879        # This is necessary as the loss function does not differentiate between the entries in a batch. 
880        # Using the loss function directly, it would be not possible to compute the per-layer loss separately.
881        # Please refer to the documentation of `_compute_per_layer_loss()` and the comments
882        # in that function for more details.
883        # 
884        # Furthermore, note that the classification loss is computed for all predictions, not just the matched 
885        # ones (also see the discussion of the classification loss in `focal_head_batched.py`).
886        loss_cls = self._compute_per_layer_loss(
887            self.loss_cls, gt_labels_mapped_to_pred, cls_scores, label_weights, per_layer_cls_avg_factors, 
888            num_batch_dims=2
889        )
890
891        nvtx_wrp.range_pop()
892        loss_cls = torch.nan_to_num(loss_cls)
893

Synchronize Positives

Synchronize positive counts across devices:

demos/streampetr/projects/mmdet3d_plugin/models/dense_heads/streampetr_head_batched.py
894        # ===== Synchronize positives =====
895        #@NOTE: reduce_mean is used to synchronize over all devices, as in the original implementation.
896        per_layer_nums_pos = per_layer_nums_pos.type(loss_cls.dtype)
897        per_layer_nums_pos = reduce_mean(per_layer_nums_pos)
898        per_layer_nums_pos[per_layer_nums_pos < 1.0] = 1.0
899

Prepare Bounding Box Targets & Masks

Next, we prepare the bounding box targets and masks:

demos/streampetr/projects/mmdet3d_plugin/models/dense_heads/streampetr_head_batched.py
900        # ===== Prepare bbox targets & masks =====
901        gt_bboxes_matched_normalized = gt_bboxes_matched.apply(
902            lambda bboxs: normalize_bbox(bboxs, self.pc_range)
903        )
904
905        isnotnan = torch.isfinite(gt_bboxes_matched_normalized.tensor).all(dim=-1, keepdim=True)
906        
907        #@NOTE
908        # Get the existence weights. In the original implementation, the weights are returned 
909        # by the `get_targets()` function (also see notes in `FocalHeadBatched.loss_batched()`). Note that
910        # the weights here do not exactly correspond to the weights in the original implementation, as the 
911        # existence weights here are computed for the data tensor (i.e. `tensor`) of the `RaggedBatch` 
912        # instance (containing filler values to ensure uniform shape), while in the original implementation, 
913        # the weights are computed for a tensor with the same shape as the predictions.
914        bbox_existence_weights_matched = gt_bboxes_matched_normalized.get_existence_weights()
915
916        #@NOTE 
917        # The code weights are per-element weights for the individual elements which describe a single
918        # bounding box. Therefore, they can be directly multiplied with the existence weights without any
919        # adaptations regarding the batch shape, as long as the size of the inner-most dimension of the 
920        # existence weights corresponds to the bounding box descriptors.
921        bbox_weights_matched = bbox_existence_weights_matched * self.code_weights
922
923        #@NOTE
924        # The handling of non-finite values differs from the original implementation. In the original code,
925        # boolean indexing with `isnotnan` as a mask is used to filter out non-finite values. However, 
926        # this would lead to flattening of the batch dimensions and the dimension iterating over individual 
927        # objects inside a sample. Here, we use `apply_mask_to_tensor()` to zero out non-finite values while 
928        # preserving the current shape of the data.
929        # This approach:
930        #   1. Maintains the batch dimensions for efficient batched processing
931        #   2. Properly handles `NaN` values (unlike simple multiplication, where `NaN * 0 = NaN`)
932        # 
933        # Note that our approach changes elements containing `NaN` values to filler (0) values, while the 
934        # original implementation removes the `NaN` elements instead (using boolean indexing).
935        gt_bboxes_matched_normalized = gt_bboxes_matched_normalized.apply(
936            lambda tensor: bhl.apply_mask_to_tensor(tensor, isnotnan)
937        )
938
939        #@NOTE 
940        # Here, we can multiply instead of using `apply_mask_to_tensor()` as the predictions and weights do 
941        # not contain `NaN` values.
942        pred_bboxes_matched = pred_bboxes_matched.apply(lambda bboxs: bboxs * isnotnan)
943        bbox_weights_matched = bbox_weights_matched * isnotnan
944

Losses: Bounding Box Overlap

Compute bbox regression loss per layer using the wrapper, operating only on matched predictions:

demos/streampetr/projects/mmdet3d_plugin/models/dense_heads/streampetr_head_batched.py
945        # ===== Losses: BBox (per-layer wrapper) =====
946        nvtx_wrp.range_push("loss_bbox computation[StreamPETRHeadBatched.loss_batched]")
947        #@NOTE
948        # The wrapper method `_compute_per_layer_loss()` is used again here.
949        # 
950        # In contrast to the classification loss, the bounding box loss is computed only for matched 
951        # predictions. See notes in `focal_head_batched.py` for a discussion of how the input data differs 
952        # between the two cases.
953        loss_bbox = self._compute_per_layer_loss(
954            self.loss_bbox, gt_bboxes_matched_normalized.tensor, pred_bboxes_matched.tensor, 
955            bbox_weights_matched, per_layer_nums_pos, num_batch_dims=2
956        )
957
958        loss_bbox = torch.nan_to_num(loss_bbox)
959        nvtx_wrp.range_pop()
960

Finalize

End of per-layer loss computation:

demos/streampetr/projects/mmdet3d_plugin/models/dense_heads/streampetr_head_batched.py
961        # ===== Finalize (`loss_batched()`) =====
962        nvtx_wrp.range_pop("StreamPETRHeadBatched.loss_batched")
963        return loss_cls, loss_bbox

Other Notable Implementations Changes

In this section, we discussed the changes needed to implement the batched loss computation for the StreamPETR head.

In addition to the changes discussed above, there are some other notable changes to the implementation. Please refer to the source code (streampetr_head_batched.py, see beginning of this page) for more details. Changes not discussed above in detail are:

  • The dn_loss_batched() method is used to compute the DN loss batched over all decoder layers. As the GT data is of uniform size, there is not need to use the RaggedBatch data format. However, the implementation is similar to loss_batched() in that the GT data is repeated for the individual decoder layers, and the loss is computed in a batched manner.

  • The _compute_per_layer_loss() method is used to compute the loss for the individual decoder layers. This is achieved by disabling the reduction in the called loss function, and performing the reduction after the call in _compute_per_layer_loss() instead. In this way, we can decide over which dimensions to reduce, allowing us to keep the batch dimension corresponding to the decoder layers intact, while reducing over the individual samples. Note that the type of reduction can still be configured for the loss function, and the configuration will be read and applied accordingly in the _compute_per_layer_loss() method.