Skip to content
mlmentorship

Implement mergeable streaming classification metrics

Build a bounded-memory confusion-matrix accumulator with merge, precision, recall, F1, and edge-case handling.

Published · 5 min read ·Core ·Mixed

ML implementation · active recall

Practice before you read

40 minutes. Clarify the contract, implement a correct baseline, test edge cases, then optimize.

ML implementation · closed-book attempt

Implement mergeable streaming classification metrics

Clarify the contract, implement a correct baseline, test edge cases, then optimize.

40:00recommended time

Closing or reloading clears the scratchpad. Only score, weak rubric dimensions, attempt count, and retry date can be stored locally.

30-second answer map

Visual first · depth when needed

Explain why distributed evaluation must sum each shard's TP, FP, TN, and FN counts before computing F1 rather than averaging shard-level F1 values.

Preparing the visual…

Implement a streaming binary-classification metric accumulator. It receives batches of labels and predictions, uses bounded memory, supports merging across workers, and reports precision, recall, F1, accuracy, and the confusion matrix.

This is distributed model evaluation in miniature: score a stream too large to hold in memory, then merge per-worker accumulators into one global result. The design hinges on storing sufficient statistics (the four confusion-matrix counts) rather than every prediction, because those counts are exactly what makes the metric mergeable across shards.

Contract

class BinaryMetrics:
    def update(self, y_true, y_pred): ...
    def merge(self, other): ...
    def compute(self): ...

Clarify:

  • Are predictions labels or probabilities?
  • If probabilities, where is the threshold configured?
  • How should undefined precision or recall be represented?
  • Are sample weights required?
  • Can batches be empty?
  • Must updates be thread-safe?

Reference implementation sketch

class BinaryMetrics:
    def __init__(self, threshold=0.5):
        self.threshold = threshold
        self.tp = self.fp = self.tn = self.fn = 0

    def update(self, y_true, y_score):
        if len(y_true) != len(y_score):
            raise ValueError("length mismatch")
        for truth, score in zip(y_true, y_score):
            if truth not in (0, 1):
                raise ValueError("labels must be binary")
            pred = int(score >= self.threshold)
            if truth == 1 and pred == 1:
                self.tp += 1
            elif truth == 0 and pred == 1:
                self.fp += 1
            elif truth == 0 and pred == 0:
                self.tn += 1
            else:
                self.fn += 1

    def merge(self, other):
        if self.threshold != other.threshold:
            raise ValueError("threshold mismatch")
        self.tp += other.tp
        self.fp += other.fp
        self.tn += other.tn
        self.fn += other.fn
        return self

    @staticmethod
    def safe_divide(numerator, denominator):
        return numerator / denominator if denominator else None

    def compute(self):
        precision = self.safe_divide(self.tp, self.tp + self.fp)
        recall = self.safe_divide(self.tp, self.tp + self.fn)
        f1 = None if precision is None or recall is None or precision + recall == 0 \
            else 2 * precision * recall / (precision + recall)
        total = self.tp + self.fp + self.tn + self.fn
        return {
            "tp": self.tp, "fp": self.fp, "tn": self.tn, "fn": self.fn,
            "precision": precision, "recall": recall, "f1": f1,
            "accuracy": self.safe_divide(self.tp + self.tn, total),
        }

Why merge works

TP, FP, TN, and FN are additive sufficient statistics for metrics at a fixed threshold. Each worker can accumulate locally, then reducers sum the counts. Storing probabilities would be necessary for metrics across all thresholds such as exact ROC-AUC.

Learning objective

What should workers merge: confusion counts or finished F1 scores?

Confusion-count states add component by component Shard A stores true positives 1, false positives 0, true negatives 9, and false negatives 0. Shard B stores true positives 1, false positives 9, true negatives 1, and false negatives 9. Componentwise addition produces the global state: true positives 2, false positives 9, true negatives 10, and false negatives 9. Only four counts are retained regardless of stream size. 1 · REDUCE THE ADDITIVE STATE SHARD A TP 1 · FP 0 · TN 9 · FN 0 + SHARD B TP 1 · FP 9 · TN 1 · FN 9 = · add matching fields GLOBAL TP 2 · FP 9 · TN 10 · FN 9
Averaging shard F1 differs from F1 computed from global counts The wrong path computes shard A F1 as 1.00 and shard B F1 as 0.10, then averages them to 0.55. The correct path first uses the merged counts and computes F1 as two times 2 divided by two times 2 plus 9 plus 9, which is 4 over 22 or approximately 0.18. F1 is nonlinear, so 0.55 and 0.18 differ. 2 · COMPUTE THE RATIO ONCE WRONG · AVERAGE WORKER F1 (1.00 + 0.10) / 2 = 0.55 CORRECT · F1 FROM GLOBAL COUNTS F1 = 2TP / (2TP + FP + FN) = 4 / (4 + 9 + 9) = 4 / 22 approximately 0.18 · not 0.55 F1 is nonlinear; its inputs are mergeable.
Read it this way: retain the four fixed-threshold counts on every worker, add matching fields, and compute F1 once from the totals. Computing early loses each shard's denominator: here the mean worker F1 is 0.55, but the same records have global F1 4/22 ≈ 0.18. The count state is mergeable; the finished ratio is not. Original example checked against the scikit-learn confusion-matrix and F1 definitions plus TorchMetrics state-reduction guidance.

L4, L5, and L6 signals

  • L4: correct counts and formulas with basic tests.
  • L5: explicit undefined-metric policy, threshold contract, merge validation, and weighted or multiclass extension discussion.
  • L6: distinguishes exact from approximate distributed metrics, addresses delayed labels and slice aggregation, and explains why global F1 cannot be averaged from worker F1 values.

Tests to write

  • Perfect predictions and all-wrong predictions.
  • No predicted positives and no actual positives.
  • Empty accumulator.
  • Batch updates equal one combined update.
  • Merging two accumulators equals processing all examples once.
  • Threshold mismatch on merge.

Common mistakes

  • Averaging batch-level precision or F1.
  • Returning zero for undefined metrics without documenting the policy.
  • Storing every prediction despite the streaming requirement.
  • Merging workers with different thresholds.
  • Confusing macro, micro, and weighted multiclass averages.

Common follow-ups

  • Extend this to multiclass classification.
  • Add sample weights.
  • Compute approximate AUC with bounded memory.
  • Track metrics by slice without unbounded cardinality.
  • Handle labels that arrive days after predictions.

Related: precision, recall, and F1, confusion matrices, and ROC/PR AUC.