Approach: compute per-class true positives (TP), false positives (FP), false negatives (FN) from integer label arrays, then produce precision/recall/F1 per class and aggregate by micro/macro/weighted. Handle zero-division by defining precision/recall as 0.0 when denominator is 0 (common practice) and allow an optional parameter to set that fallback.python
import numpy as np
from typing import Tuple, Iterable, Dict
def precision_recall_f1(
y_true: Iterable[int],
y_pred: Iterable[int],
average: str = "macro",
zero_division: float = 0.0
) -> Tuple[float, float, float]:
"""
Compute precision, recall, f1 for multi-class labels.
Inputs:
- y_true, y_pred: lists or numpy arrays of integer labels (same length)
- average: "micro", "macro", or "weighted"
- zero_division: value to use when a division by zero occurs (e.g., 0.0 or 1.0)
Returns: (precision, recall, f1)
"""
y_true = np.asarray(y_true)
y_pred = np.asarray(y_pred)
if y_true.shape != y_pred.shape:
raise ValueError("y_true and y_pred must have same shape")
labels = np.union1d(y_true, y_pred) # include classes seen only in preds or truths
tp = {l: 0 for l in labels}
fp = {l: 0 for l in labels}
fn = {l: 0 for l in labels}
for t, p in zip(y_true, y_pred):
if t == p:
tp[t] += 1
else:
fp[p] += 1
fn[t] += 1
# per-class metrics
prec = {}
rec = {}
support = {} # number of true instances per class
for l in labels:
support[l] = tp[l] + fn[l]
denom_p = tp[l] + fp[l]
denom_r = tp[l] + fn[l]
prec[l] = (tp[l] / denom_p) if denom_p != 0 else zero_division
rec[l] = (tp[l] / denom_r) if denom_r != 0 else zero_division
if average == "micro":
total_tp = sum(tp.values())
total_fp = sum(fp.values())
total_fn = sum(fn.values())
precision = total_tp / (total_tp + total_fp) if (total_tp + total_fp) != 0 else zero_division
recall = total_tp / (total_tp + total_fn) if (total_tp + total_fn) != 0 else zero_division
f1 = (2 * precision * recall / (precision + recall)) if (precision + recall) != 0 else zero_division
return precision, recall, f1
# macro or weighted
if average == "macro":
precision = float(np.mean(list(prec.values())))
recall = float(np.mean(list(rec.values())))
elif average == "weighted":
weights = np.array([support[l] for l in labels], dtype=float)
total = weights.sum()
if total == 0:
precision = recall = zero_division
else:
precision = float(np.sum([prec[l] * support[l] for l in labels]) / total)
recall = float(np.sum([rec[l] * support[l] for l in labels]) / total)
else:
raise ValueError("average must be one of {'micro','macro','weighted'}")
f1 = (2 * precision * recall / (precision + recall)) if (precision + recall) != 0 else zero_division
return precision, recall, f1
Key concepts:- Micro averages aggregate TP/FP/FN across classes (suitable when class imbalance and you care about overall instance-level performance).- Macro averages compute unweighted mean of per-class metrics (treats all classes equally).- Weighted uses class support to weight per-class metrics.Complexity:- Time: O(n + k) where n = number of samples, k = number of distinct labels (dominant term O(n)).- Space: O(k) for per-class counters.Zero-division handling:- If a class has no predicted samples, denom for precision is zero — we return zero_division (default 0.0). This mirrors scikit-learn behavior and avoids exceptions; optionally set zero_division=1.0 if you prefer a different convention.