aidevelopementtoolkit.data_utils.metrics

compute_classification_metrics(confusion_matrix: np.ndarray) -> Tuple[Dict[str, float], np.ndarray]

Compute classification metrics from a confusion matrix.

This function computes classification metrics directly from an accumulated confusion matrix. It is useful when predictions and labels are aggregated over multiple batches or distributed processes, avoiding the need to store all predictions and labels.

Notes

The following metrics are computed: - Accuracy - Precision - Recall - F1 score

The function automatically detects the classification problem type: - Binary classification if the confusion matrix has shape (2, 2). - Multiclass classification otherwise.

For multiclass problems, Precision, Recall, and F1 Score are computed using macro averaging.

The confusion matrix is expected to follow the convention:

rows -> ground-truth classes
columns -> predicted classes
Parameters:
  • confusion_matrix (ndarray) –

    Confusion matrix with shape (C, C) where: - C: Number of classes

    Element (i, j) represents the number of samples belonging to ground-truth class i that were predicted as class j.

Returns:
  • Tuple[Dict[str, float], ndarray]

    Respectively: - Dictionary containing the computed classification metrics. - Row-normalized confusion matrix.

Examples:

>>> cm = np.array(
...     [
...         [8, 2],
...         [1, 9],
...     ]
... )
>>> metrics, normalized_cm = compute_classification_metrics_from_cm(cm)
Source code in aidevelopementtoolkit/data_utils/metrics.py
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
def compute_classification_metrics(confusion_matrix: np.ndarray) -> Tuple[Dict[str, float], np.ndarray]:
    """Compute classification metrics from a confusion matrix.

    This function computes classification metrics directly from an accumulated
    confusion matrix. It is useful when predictions and labels are aggregated
    over multiple batches or distributed processes, avoiding the need to store
    all predictions and labels.

    Notes
    -----
    The following metrics are computed:
    - Accuracy
    - Precision
    - Recall
    - F1 score

    The function automatically detects the classification problem type:
    - Binary classification if the confusion matrix has shape `(2, 2)`.
    - Multiclass classification otherwise.

    For multiclass problems, Precision, Recall, and F1 Score are computed
    using macro averaging.

    The confusion matrix is expected to follow the convention:

        rows -> ground-truth classes
        columns -> predicted classes

    Parameters
    ----------
    confusion_matrix : np.ndarray
        Confusion matrix with shape `(C, C)` where:
        - `C`: Number of classes

        Element `(i, j)` represents the number of samples belonging to
        ground-truth class `i` that were predicted as class `j`.

    Returns
    -------
    Tuple[Dict[str, float], np.ndarray]
        Respectively:
            - Dictionary containing the computed classification metrics.
            - Row-normalized confusion matrix.

    Examples
    --------
    >>> cm = np.array(
    ...     [
    ...         [8, 2],
    ...         [1, 9],
    ...     ]
    ... )
    >>> metrics, normalized_cm = compute_classification_metrics_from_cm(cm)
    """

    confusion_matrix = np.asarray(confusion_matrix, dtype=np.float64,)

    check_shape(confusion_matrix, (confusion_matrix.shape[0], confusion_matrix.shape[1]))

    num_classes = confusion_matrix.shape[0]

    # Compute correctly classified samples
    true_positives = np.diag(confusion_matrix)

    # Compute accuracy
    total_samples = confusion_matrix.sum()
    accuracy = true_positives.sum() / total_samples if total_samples > 0 else 0.0

    if num_classes == 2:

        fp = confusion_matrix[0, 1]
        fn = confusion_matrix[1, 0]
        tp = confusion_matrix[1, 1]

        precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
        recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
        f1_score = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0

    else:

        # Multiclass classification:
        precisions = np.zeros(num_classes, dtype=np.float64)
        recalls = np.zeros(num_classes, dtype=np.float64)

        for class_idx in range(num_classes):

            tp = true_positives[class_idx]

            predicted_as_class = confusion_matrix[:, class_idx].sum()
            actual_class = confusion_matrix[class_idx, :].sum()

            if predicted_as_class > 0:
                precisions[class_idx] = tp / predicted_as_class

            if actual_class > 0:
                recalls[class_idx] = tp / actual_class


        # Macro averaging: every class has equal importance
        precision = np.mean(precisions)
        recall = np.mean(recalls)
        f1_score = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0


    metrics = {
        "Accuracy": float(accuracy),
        "Precision": float(precision),
        "Recall": float(recall),
        "F1 Score": float(f1_score),
    }


    # Normalize the confusion matrix by row so each true-class sum is 1
    normalized_confusion_matrix = confusion_matrix.copy()

    row_sums = normalized_confusion_matrix.sum(axis=1, keepdims=True)
    row_sums[row_sums == 0] = 1.0
    normalized_confusion_matrix /= row_sums

    return metrics, normalized_confusion_matrix

compute_confusion_matrix(predictions: np.ndarray, labels: np.ndarray, num_classes: int, padding_mask: Optional[np.ndarray] = None) -> np.ndarray

Compute the confusion matrix from predictions and labels.

This function computes a confusion matrix that can be accumulated across multiple batches or distributed processes before computing classification metrics.

Notes

The confusion matrix follows the convention:

rows -> ground-truth classes
columns -> predicted classes

Element (i, j) represents the number of samples belonging to ground-truth class i that were predicted as class j.

Padding positions are ignored when a padding mask is provided.

Parameters:
  • predictions (ndarray) –

    Array containing the predicted classes. Accepted shapes: (N,) or any shape that can be flattened consistently with labels.

  • labels (ndarray) –

    Array containing the ground-truth classes. Same shape as predictions.

  • num_classes (int) –

    Number of classes in the classification problem.

  • padding_mask (ndarray, default: None ) –

    Boolean array where True indicates padding positions that must be ignored. Same shape as predictions.

Returns:
  • ndarray

    Confusion matrix with shape (num_classes, num_classes).

Examples:

>>> predictions = np.array([0, 1, 1, 0])
>>> labels = np.array([0, 1, 0, 0])
>>> cm = compute_confusion_matrix(
...     predictions=predictions,
...     labels=labels,
...     num_classes=2,
... )
Source code in aidevelopementtoolkit/data_utils/metrics.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
def compute_confusion_matrix(
        predictions: np.ndarray,
        labels: np.ndarray,
        num_classes: int,
        padding_mask: Optional[np.ndarray] = None,
    ) -> np.ndarray:
    """Compute the confusion matrix from predictions and labels.

    This function computes a confusion matrix that can be accumulated across
    multiple batches or distributed processes before computing classification
    metrics.

    Notes
    -----
    The confusion matrix follows the convention:

        rows -> ground-truth classes
        columns -> predicted classes

    Element `(i, j)` represents the number of samples belonging to
    ground-truth class `i` that were predicted as class `j`.

    Padding positions are ignored when a padding mask is provided.

    Parameters
    ----------
    predictions : np.ndarray
        Array containing the predicted classes. Accepted shapes:
        `(N,)` or any shape that can be flattened consistently with labels.

    labels : np.ndarray
        Array containing the ground-truth classes.
        Same shape as `predictions`.

    num_classes : int
        Number of classes in the classification problem.

    padding_mask : np.ndarray, default=None
        Boolean array where `True` indicates padding positions that must be
        ignored. Same shape as `predictions`.

    Returns
    -------
    np.ndarray
        Confusion matrix with shape `(num_classes, num_classes)`.

    Examples
    --------
    >>> predictions = np.array([0, 1, 1, 0])
    >>> labels = np.array([0, 1, 0, 0])
    >>> cm = compute_confusion_matrix(
    ...     predictions=predictions,
    ...     labels=labels,
    ...     num_classes=2,
    ... )
    """

    predictions = np.asarray(predictions, dtype=np.int64)
    labels = np.asarray(labels, dtype=np.int64)

    check_shape(labels, predictions.shape)

    if padding_mask is not None:

        padding_mask = np.asarray(padding_mask, dtype=bool)

        if predictions.shape != padding_mask.shape:
            logger.error(
                "The given `predictions` and `padding_mask` have different "
                "shapes: "
                f"{predictions.shape} vs {padding_mask.shape}"
            )
            raise ValueError()

        valid = ~padding_mask

        predictions = predictions[valid]
        labels = labels[valid]

    # Flatten all dimensions
    predictions = predictions.reshape(-1)
    labels = labels.reshape(-1)

    if np.any(predictions < 0) or np.any(predictions >= num_classes):
        logger.error(
            "Predictions contain class indices outside the valid range "
            f"[0, {num_classes - 1}]."
        )
        raise ValueError()

    if np.any(labels < 0) or np.any(labels >= num_classes):
        logger.error(
            "Labels contain class indices outside the valid range "
            f"[0, {num_classes - 1}]."
        )
        raise ValueError()

    return confusion_matrix(
        labels,
        predictions,
        labels=np.arange(num_classes),
    )

cluster_distance_stats(embeddings: np.ndarray, cluster_ids: np.ndarray, metric: str = 'euclidean') -> Tuple[float, float]

Compute intra- and inter-cluster distance statistics.

Notes

Intra-cluster distance is the mean pairwise distance between every pair of points that belong to the same cluster, averaged across all clusters.

Inter-cluster distance is the mean pairwise distance between every pair of points that belong to different clusters.

Any distance metric accepted by scipy.spatial.distance.cdist can be used (e.g. "euclidean", "cosine", "cityblock").

Parameters:
  • embeddings (ndarray) –

    2-D array of point coordinates with shape (N, D) where: - N: Number of points - D: Embedding dimensionality

  • cluster_ids (ndarray) –

    1-D integer array of cluster assignments with shape (N,). Each element is the cluster id of the corresponding embedding.

  • metric (str, default: "euclidean" ) –

    Distance metric forwarded to scipy.spatial.distance.cdist.

Returns:
  • Tuple[float, float]

    Respectively: - Mean intra-cluster distance (nan when every cluster has fewer than two points). - Mean inter-cluster distance (nan when all points belong to the same cluster).

Examples:

>>> embeddings = np.array(
...     [
...         [0.0, 0.0],
...         [1.0, 0.0],
...         [5.0, 0.0],
...         [6.0, 0.0],
...     ]
... )
>>> cluster_ids = np.array([0, 0, 1, 1])
>>> intra_d, inter_d = cluster_distance_stats(
...     embeddings=embeddings,
...     cluster_ids=cluster_ids,
...     metric="euclidean",
... )
Source code in aidevelopementtoolkit/data_utils/metrics.py
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
def cluster_distance_stats(
        embeddings: np.ndarray,
        cluster_ids: np.ndarray,
        metric: str = "euclidean",
    ) -> Tuple[float, float]:
    """Compute intra- and inter-cluster distance statistics.

    Notes
    -----
    Intra-cluster distance is the mean pairwise distance between every pair of
    points that belong to the **same** cluster, averaged across all clusters.

    Inter-cluster distance is the mean pairwise distance between every pair of
    points that belong to **different** clusters.

    Any distance metric accepted by `scipy.spatial.distance.cdist` can be
    used (e.g. `"euclidean"`, `"cosine"`, `"cityblock"`).

    Parameters
    ----------
    embeddings : np.ndarray
        2-D array of point coordinates with shape `(N, D)` where:
        - `N`: Number of points
        - `D`: Embedding dimensionality

    cluster_ids : np.ndarray
        1-D integer array of cluster assignments with shape `(N,)`.
        Each element is the cluster id of the corresponding embedding.

    metric : str, default="euclidean"
        Distance metric forwarded to `scipy.spatial.distance.cdist`.

    Returns
    -------
    Tuple[float, float]
        Respectively:
            - Mean intra-cluster distance (`nan` when every cluster has
              fewer than two points).
            - Mean inter-cluster distance (`nan` when all points belong to
              the same cluster).

    Examples
    --------
    >>> embeddings = np.array(
    ...     [
    ...         [0.0, 0.0],
    ...         [1.0, 0.0],
    ...         [5.0, 0.0],
    ...         [6.0, 0.0],
    ...     ]
    ... )
    >>> cluster_ids = np.array([0, 0, 1, 1])
    >>> intra_d, inter_d = cluster_distance_stats(
    ...     embeddings=embeddings,
    ...     cluster_ids=cluster_ids,
    ...     metric="euclidean",
    ... )
    """

    embeddings = np.asarray(embeddings, dtype=np.float64)
    cluster_ids = np.asarray(cluster_ids, dtype=np.int64)

    check_shape(embeddings, (-1, -1))
    N, E = embeddings.shape
    check_shape(cluster_ids, (N,))

    unique_ids = np.unique(cluster_ids)

    # Intra cluster distances
    intra_means = []
    for cid in unique_ids:
        mask = cluster_ids == cid
        pts = embeddings[mask]
        if pts.shape[0] < 2:
            continue
        dists = cdist(pts, pts, metric=metric)
        # upper triangle only (exclude diagonal zeros)
        upper = dists[np.triu_indices(pts.shape[0], k=1)]
        intra_means.append(upper.mean())

    intra_d = float(np.mean(intra_means)) if intra_means else float(np.nan)

    # Inter cluster distances
    inter_distances = []
    for i, cid_a in enumerate(unique_ids):
        for cid_b in unique_ids[i + 1:]:
            pts_a = embeddings[cluster_ids == cid_a]
            pts_b = embeddings[cluster_ids == cid_b]
            dists = cdist(pts_a, pts_b, metric=metric)
            inter_distances.append(dists.mean())

    inter_d = float(np.mean(inter_distances)) if inter_distances else float(np.nan)

    return intra_d, inter_d

compute_clustering_metrics(predictions: np.ndarray, labels: np.ndarray, padding_mask: Optional[np.ndarray] = None, embeddings: Optional[np.ndarray] = None, distance_metrics: Optional[List[Literal['euclidean', 'cosine', 'manhattan']]] = None, n_jobs: int = -1) -> Tuple[Dict[str, np.ndarray], Dict[str, float]]

Compute clustering metrics for one or more sequences.

Notes

The following metrics are computed for every sequence: - Completeness - Homogeneity - V-Measure Score - Fowlkes-Mallows Score - Elements Like Me (ELM) Score - For each metric m in distance_metrics (requires embeddings): - GT Intra-cluster m distance - GT Inter-cluster m distance - Predicted Intra-cluster m distance - Predicted Inter-cluster m distance

The first four metrics are computed through sklearn.metrics (https://scikit-learn.org/stable/api/sklearn.metrics.html). The ELM score is computed by following the paper: https://link.springer.com/article/10.1007/s10791-024-09436-7. Intra/Inter-Cluster distances are computed via :func:cluster_distance_stats.

Degenerate clusterings (only one predicted or one ground-truth cluster) are excluded from the averaged Homogeneity, Completeness and V-Measure statistics for this evaluation protocol.

Parameters:
  • predictions (ndarray) –

    Predicted cluster labels. Accepted shapes (T,) or (B, T): - B: Batch dimension - T: Sequence length

  • labels (ndarray) –

    Ground-truth cluster labels. Same shape as predictions.

  • padding_mask (Optional[ndarray], default: None ) –

    Boolean array where True indicates padding. Same shape as predictions. When set to None, no padding is applied and all elements are considered valid.

  • embeddings (ndarray, default: None ) –

    Point embeddings used to compute distance-based statistics via :func:cluster_distance_stats. Accepted shapes (T, D) or (B, T, D) where D is the embedding dimensionality. When None the distance metrics are omitted from the output.

  • distance_metrics (Optional[List[Literal['euclidean', 'cosine', 'manhattan']]], default: None ) –

    Distance metrics to be used for intra/inter cluster distance computation. For each metric m, the keys "GT Intra-cluster m distance", "GT Inter-cluster m distance", "Predicted Intra-cluster m distance", and "Predicted Inter-cluster m distance" are added to the output. If None, no distance metrics are computed. Requires embeddings.

  • n_jobs (int, default: -1 ) –

    Number of parallel workers for batch processing. -1 uses all available CPU cores. Forwarded to :class:joblib.Parallel.

Returns:
  • Tuple[Dict[str, ndarray], Dict[str, float]]
    • non_averaged: Dictionary containing one metric per sequence.

    • averaged: Dictionary containing the average metric across sequences.

Examples:

>>> predictions = np.array([0, 0, 1, 1])
>>> labels = np.array([0, 0, 1, 2])
>>> padding_mask = np.zeros_like(labels, dtype=bool)
>>> non_averaged, averaged = compute_clustering_metrics(
...     predictions=predictions,
...     labels=labels,
...     padding_mask=padding_mask,
... )
Source code in aidevelopementtoolkit/data_utils/metrics.py
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
def compute_clustering_metrics(
        predictions: np.ndarray,
        labels: np.ndarray,
        padding_mask: Optional[np.ndarray] = None,
        embeddings: Optional[np.ndarray] = None,
        distance_metrics: Optional[List[Literal["euclidean", "cosine", "manhattan"]]] = None,
        n_jobs: int = -1,
    ) -> Tuple[Dict[str, np.ndarray], Dict[str, float]]:
    """Compute clustering metrics for one or more sequences.

    Notes
    -----
    The following metrics are computed for every sequence:
    - Completeness
    - Homogeneity
    - V-Measure Score
    - Fowlkes-Mallows Score
    - Elements Like Me (ELM) Score
    - For each metric `m` in `distance_metrics` (requires `embeddings`):
      - GT Intra-cluster `m` distance
      - GT Inter-cluster `m` distance
      - Predicted Intra-cluster `m` distance
      - Predicted Inter-cluster `m` distance

    The first four metrics are computed through `sklearn.metrics` (https://scikit-learn.org/stable/api/sklearn.metrics.html).
    The ELM score is computed by following the paper: https://link.springer.com/article/10.1007/s10791-024-09436-7.
    Intra/Inter-Cluster distances are computed via :func:`cluster_distance_stats`.

    Degenerate clusterings (only one predicted or one ground-truth cluster) are excluded from the averaged 
    Homogeneity, Completeness and V-Measure statistics for this evaluation protocol.

    Parameters
    ----------
    predictions : np.ndarray
        Predicted cluster labels. Accepted shapes `(T,)` or `(B, T)`: 
        - `B`: Batch dimension
        - `T`: Sequence length

    labels : np.ndarray
        Ground-truth cluster labels. Same shape as `predictions`.

    padding_mask : Optional[np.ndarray], default=None
        Boolean array where `True` indicates padding. Same shape as `predictions`.
        When set to `None`, no padding is applied and all elements are considered valid.

    embeddings : np.ndarray, default=None
        Point embeddings used to compute distance-based statistics via
        :func:`cluster_distance_stats`. Accepted shapes `(T, D)` or
        `(B, T, D)` where `D` is the embedding dimensionality. When
        `None` the distance metrics are omitted from the output.

    distance_metrics : Optional[List[Literal["euclidean", "cosine", "manhattan"]]], default=None
        Distance metrics to be used for intra/inter cluster distance
        computation. For each metric `m`, the keys
        `"GT Intra-cluster m distance"`, `"GT Inter-cluster m distance"`,
        `"Predicted Intra-cluster m distance"`, and
        `"Predicted Inter-cluster m distance"` are added to the output.
        If `None`, no distance metrics are computed. Requires `embeddings`.

    n_jobs : int, default=-1
        Number of parallel workers for batch processing. `-1` uses all
        available CPU cores. Forwarded to :class:`joblib.Parallel`.

    Returns
    -------
    Tuple[Dict[str, np.ndarray], Dict[str, float]]

        - non_averaged:
            Dictionary containing one metric per sequence.

        - averaged:
            Dictionary containing the average metric across sequences.

    Examples
    --------
    >>> predictions = np.array([0, 0, 1, 1])
    >>> labels = np.array([0, 0, 1, 2])
    >>> padding_mask = np.zeros_like(labels, dtype=bool)
    >>> non_averaged, averaged = compute_clustering_metrics(
    ...     predictions=predictions,
    ...     labels=labels,
    ...     padding_mask=padding_mask,
    ... )
    """

    predictions = np.asarray(predictions, dtype=np.int64)
    labels = np.asarray(labels, dtype=np.int64)
    if padding_mask is None:
        padding_mask = np.zeros_like(labels, dtype=bool)

    padding_mask = np.asarray(padding_mask, dtype=bool)

    check_shape(predictions, [(-1,), (-1, -1)])
    check_shape(labels, predictions.shape)
    check_shape(labels, padding_mask.shape)

    # Add batch axis
    if predictions.ndim == 1:
            predictions = predictions[None, :]
            labels = labels[None, :]
            padding_mask = padding_mask[None, :]

    B, T = predictions.shape

    use_embeddings = embeddings is not None
    if use_embeddings:
        embeddings = np.asarray(embeddings, dtype=np.float64)

    use_distances = use_embeddings and bool(distance_metrics)

    # Add batch axis
    if use_embeddings and embeddings.ndim == 2:
        embeddings = embeddings[None, :]
        check_shape(embeddings, (B, T, -1))

    def _process_batch(batch_idx: int) -> Dict[str, float]:
        result: Dict[str, float] = {}

        valid = ~padding_mask[batch_idx]
        if not np.any(valid):
            return result

        pred = predictions[batch_idx][valid]
        gt = labels[batch_idx][valid]

        if pred.size < 2:
            return result

        result["Fowlkes-Mallows Score"] = fowlkes_mallows_score(gt, pred)
        result["ELM Score"] = _elm_score(pred, gt)

        if use_distances:
            emb = embeddings[batch_idx][valid]
            for dist_metric in distance_metrics:
                pred_intra_d, pred_inter_d = cluster_distance_stats(
                    embeddings=emb,
                    cluster_ids=pred,
                    metric=dist_metric,
                )
                gt_intra_d, gt_inter_d = cluster_distance_stats(
                    embeddings=emb,
                    cluster_ids=gt,
                    metric=dist_metric,
                )
                result[f"GT Intra-cluster {dist_metric} distance"] = gt_intra_d
                result[f"GT Inter-cluster {dist_metric} distance"] = gt_inter_d
                result[f"Predicted Intra-cluster {dist_metric} distance"] = pred_intra_d
                result[f"Predicted Inter-cluster {dist_metric} distance"] = pred_inter_d

        n_pred_clusters = np.unique(pred).size
        n_gt_clusters = np.unique(gt).size

        if n_pred_clusters > 1 and n_gt_clusters > 1:
            result["Completeness"] = completeness_score(gt, pred)
            result["Homogeneity"] = homogeneity_score(gt, pred)
            result["V-Measure Score"] = v_measure_score(gt, pred)

        return result

    batch_results = Parallel(n_jobs=n_jobs)(
        delayed(_process_batch)(i)
        for i in tqdm(
            range(B),
            leave=False,
            colour="cyan",
            desc="Computing clustering metrics 📊",
        )
    )

    metric_keys = [
        "Completeness",
        "Homogeneity",
        "V-Measure Score",
        "Fowlkes-Mallows Score",
        "ELM Score",
    ]
    if use_distances:
        for dist_metric in distance_metrics:
            metric_keys += [
                f"GT Intra-cluster {dist_metric} distance",
                f"GT Inter-cluster {dist_metric} distance",
                f"Predicted Intra-cluster {dist_metric} distance",
                f"Predicted Inter-cluster {dist_metric} distance",
            ]

    non_averaged = {k: np.full(B, np.nan) for k in metric_keys}
    for batch_idx, result in enumerate(batch_results):
        for k, v in result.items():
            non_averaged[k][batch_idx] = v

    averaged = {
        key: float(np.nanmean(values))
        for key, values in non_averaged.items()
    }

    return non_averaged, averaged