Skip to content

Metrics and evaluation

A robot that keeps learning needs to know whether it is learning well. These tools keep a small running score that is updated every time a new reading arrives and its answer can be compared with what really happened.

What this is

A robot that learns while it works needs a way to tell whether it is learning well. You cannot stop it, collect a test set and score it at the end: the data never ends and the world changes under it. The answer is to score it as it goes: every time a new sample arrives, compare what the model predicted with what really happened, and keep a small running summary.

This page shows those running summaries (the metrics), the protocol that feeds them (predict first, then learn), and the metrics made for events on a robot: how fast a fault is caught and how often the alarm rings for nothing.

A stream whose mean steps from 0 to 3 at t = 150 s. The orange band is the labelled event; the red line is the first alarm of a CUSUM drift detector, 0.30 s after the change.

The figure is the case this page builds towards: a signal sampled at 20 Hz changes at t = 150 s, a drift detector raises its first alarm 0.30 s later, and the metrics turn that into numbers: delay, missed events, false alarms per hour.

1. A metric, one pair at a time

A metric reads one pair (y_true, y_pred) with update and answers get() at any moment.

from dense_armor.utility.metrics import Accuracy

m = Accuracy()
for y, p in [(0, 0), (1, 1), (0, 1), (1, 1)]:
    m.update(y, p)
print(m.get(), m.n)
0.75 4

Three of the four predictions are right, so the accuracy is 3/4 = 0.75. The metric keeps two running sums (hits and total), not the list of pairs, so its memory stays the same after a million samples. Every metric on this page has the same members:

  • update(y_true, y_pred, t=None, w=1.0) adds a pair, with an optional timestamp and weight;
  • revert(y_true, y_pred) removes a pair added before (used by the rolling windows of step 6);
  • merge(other) combines two metrics computed on different parts of the stream;
  • n counts the pairs used, n_missing the pairs skipped because one side was None or NaN;
  • works_with(model) tells whether the metric fits the model's role (classifier or regressor).

2. Classification: precision, recall, F1

When the model says "positive", how often is it right (precision)? Of the real positives, how many did it find (recall)? F1 is their harmonic mean.

from dense_armor.utility.metrics import F1, Precision, Recall

y = [1, 0, 1, 1, 0, 1]
p = [1, 1, 1, 0, 0, 1]
ms = [Precision(positive=1), Recall(positive=1), F1(positive=1), F1(average="macro")]
for a, b in zip(y, p):
    for m in ms:
        m.update(a, b)
print([round(m.get(), 3) for m in ms])
[0.75, 0.75, 0.75, 0.625]

Count by hand for class 1: true positives TP = 3 (positions 0, 2, 5), one false positive FP (position 1), one false negative FN (position 3).

$$P = \frac{TP}{TP + FP} = \frac{3}{4}, \qquad R = \frac{TP}{TP + FN} = \frac{3}{4}, \qquad F_1 = \frac{2PR}{P + R} = 0.75.$$

average="macro" computes F1 for every class and takes the plain mean: class 0 has TP = 1, FP = 1, FN = 1, so F1 = 0.5, and the macro F1 is (0.75 + 0.5) / 2 = 0.625. "micro" pools the counts of all classes, "weighted" weights each class by how often it occurs; FBeta(beta=2) weights recall more than precision.

3. Agreement beyond chance and probabilities

Accuracy can look good by chance when one class is common. Cohen's kappa subtracts the agreement expected by chance.

from dense_armor.utility.metrics import CohenKappa

m = CohenKappa()
for a, b in zip([1, 0, 1, 1, 0, 1], [1, 1, 1, 0, 0, 1]):
    m.update(a, b)
print(round(m.get(), 3))
0.25

$$\kappa = \frac{p_o - p_e}{1 - p_e}$$

where $p_o$ is the observed agreement (4 of 6) and $p_e$ the agreement two independent guessers with the same label frequencies would reach: both say 1 four times out of six and 0 twice, so $p_e = (4 \cdot 4 + 2 \cdot 2)/36 = 0.556$ and $\kappa = (0.667 - 0.556)/(1 - 0.556) = 0.25$.

When the model gives a probability instead of a label, score the probability itself.

from dense_armor.utility.metrics import BrierScore, LogLoss

ll, br = LogLoss(), BrierScore()
for y, p in [(1, 0.9), (0, 0.2), (1, 0.6)]:
    ll.update(y, p)
    br.update(y, p)
print(round(ll.get(), 4), round(br.get(), 4))
0.2798 0.07

The log-loss is the mean of $-\ln p$ given to what happened: $-(\ln 0.9 + \ln 0.8 + \ln 0.6)/3 = 0.2798$. The Brier score is the mean squared distance between probability and outcome: $(0.1^2 + 0.2^2 + 0.4^2)/3 = 0.07$. Both are lower for better forecasts.

4. Regression on a joint

A regressor predicts a number, for example the torque a joint needs at a given velocity. Here a recursive least-squares model learns $\tau = 2\,\dot q$ from noisy samples, and three metrics score it before each update.

import numpy as np
from dense_armor.utility.learn.online_dynamics import RecursiveLeastSquares
from dense_armor.utility.metrics import MeanAbsoluteError, R2Score, RootMeanSquaredError

rng = np.random.default_rng(0)
rls, ms = RecursiveLeastSquares(), [MeanAbsoluteError(), RootMeanSquaredError(), R2Score()]
for qd in rng.normal(0, 1, 500):
    tau = 2.0 * qd + rng.normal(0, 0.1)
    for m in ms:
        m.update(tau, rls.predict_one({"qd": qd}))
    rls.learn_one({"qd": qd}, tau)
print([round(m.get(), 3) for m in ms])
[0.077, 0.098, 0.998]

The MAE is the mean of $|\tau - \hat\tau|$, the RMSE the square root of the mean of $(\tau - \hat\tau)^2$, both in the units of the torque. They sit near the noise level: for noise with standard deviation 0.1 the RMSE tends to 0.1 and the MAE to $0.1\sqrt{2/\pi} = 0.080$. $R^2 = 1 - \sum(\tau - \hat\tau)^2 / \sum(\tau - \bar\tau)^2$ compares the model with always predicting the mean: 0.998 means the model explains almost all the variation.

A robot arm has several joints. Pass the vector and the metric reports each joint and the mean.

from dense_armor.utility.metrics import MeanAbsoluteError

m = MeanAbsoluteError()
m.update([1.0, 2.0], [1.5, 2.5])
m.update([2.0, 4.0], [2.5, 3.0])
print(m.get())
{'per_joint': [0.5, 0.75], 'mean': 0.625}

Joint 0 is off by 0.5 both times; joint 1 by 0.5 and then 1.0, mean 0.75.

5. Scoring the uncertainty

A prediction with an interval is useful only if the interval is honest. Two numbers check it: how often the truth falls inside (coverage) and how wide the interval is (width).

import numpy as np
from dense_armor.roles import AdaptiveConformalRegressor
from dense_armor.utility.learn.online_dynamics import RecursiveLeastSquares
from dense_armor.utility.metrics import IntervalCoverage, MeanIntervalWidth

rng = np.random.default_rng(0)
acr, cov, wid = AdaptiveConformalRegressor(RecursiveLeastSquares(), alpha=0.1), IntervalCoverage(), MeanIntervalWidth()
for qd in rng.normal(0, 1, 2000):
    x, tau = {"qd": qd}, 2.0 * qd + rng.normal(0, 0.1)
    cov.update(tau, acr.predict_interval(x))
    wid.update(tau, acr.predict_interval(x))
    acr.learn_one(x, tau)
print(round(cov.get(), 3), round(wid.get(), 3))
0.9 0.34

The interval was asked for 90 % coverage (alpha=0.1) and covers 90 % of the torques. Its mean width 0.34 is close to the narrowest honest 90 % interval for Gaussian noise of standard deviation 0.1, which is $2 \cdot 1.645 \cdot 0.1 = 0.33$.

When the prediction is a mean and a variance, the Gaussian negative log-likelihood scores both at once.

from dense_armor.roles import Estimate
from dense_armor.utility.metrics import GaussianNLL

m = GaussianNLL()
m.update(0.1, Estimate(mean=0.0, var=0.01))
print(round(m.get(), 4))
-0.8836

$$\mathrm{NLL} = \tfrac12 \ln(2\pi v) + \frac{(y - \mu)^2}{2v} = \tfrac12 \ln(0.0628) + \tfrac12 = -0.8836.$$

A variance that is too small makes the second term explode; one that is too large makes the first term grow. The minimum is at the true spread.

6. Windows: only the recent past

A robot changes over time; the accuracy of last week says little about now. Rolling keeps a metric over the last window samples, or the last window_s seconds.

from dense_armor.utility.metrics import Accuracy, Rolling

m = Rolling(Accuracy(), window_s=0.05)
for i in range(20):
    m.update(1 if i < 17 else 0, 1, t=i * 0.01)
print(m.count, m.get())
5 0.4

At 100 Hz a window of 0.05 s holds 5 samples: the window keeps the samples with $t_\text{now} - t < 0.05$. The last three predictions are wrong, so the accuracy over the window is 2/5. The window uses the timestamps, not the sample count, so a dropped sample does not stretch it. Any metric with revert can be rolled.

The ROC-AUC is the probability that a random positive gets a higher score than a random negative. RollingAUC computes it exactly on the last window pairs.

import numpy as np
from dense_armor.utility.metrics import RollingAUC

rng = np.random.default_rng(0)
m = RollingAUC(window=200)
for _ in range(1000):
    y = int(rng.random() < 0.3)
    m.update(y, y + rng.normal(0, 1.0))
print(round(m.get(), 3))
0.712

$$\mathrm{AUC} = \frac{1}{n_+ n_-}\sum_{i \in +}\sum_{j \in -} \begin{cases} 1 & s_i > s_j \ \tfrac12 & s_i = s_j \ 0 & \text{otherwise} \end{cases}$$

Here positives score one unit higher than negatives with unit noise, so the long-run value is $\Phi(1/\sqrt2) = 0.760$; a window of 200 pairs gives 0.712.

7. Predict, then learn

progressive_val_score runs the protocol for you: for each sample the model predicts, the metric scores, then the model learns. Labels often arrive late (a technician confirms a fault at the end of the shift); with delay the model learns each label only when it arrives.

import numpy as np
from dense_armor.utility.evaluate import progressive_val_score
from dense_armor.utility.learn.online_classifiers import OnlineGaussianNB
from dense_armor.utility.metrics import Accuracy, Rolling

rng = np.random.default_rng(0)
qd = rng.normal(0, 1, 2000)
y = np.abs(qd) > np.where(np.arange(2000) < 1000, 0.5, 1.5)
s = [({"qd": a}, int(b)) for a, b in zip(qd, y)]
for d in (None, 300):
    m, tr = progressive_val_score(s, OnlineGaussianNB(), Rolling(Accuracy(), window=200), delay=d, every=200)
    print(d, [round(r, 2) for r in tr])
None [0.91, 0.94, 0.95, 0.95, 0.92, 0.54, 0.66, 0.76, 0.84, 0.84]
300 [0.0, 0.0, 0.0, 0.89, 0.95, 0.92, 0.71, 0.38, 0.59, 0.7]

The task is "is the joint moving fast?", and at sample 1000 the meaning of "fast" changes from $|\dot q| > 0.5$ to $|\dot q| > 1.5$. With labels on time the rolling accuracy drops to 0.54 and recovers to 0.84. With labels 300 samples late the drop comes later and goes deeper (0.38), because the model keeps answering with the old rule until the new labels reach it. For delay=300 the first three values are 0.0 because no label has arrived yet and nothing has been scored. every=200 returns the trace of the metric.

For metrics that need a probability, progressive_val_proba_score uses predict_proba_one.

import numpy as np
from dense_armor.utility.evaluate import progressive_val_proba_score
from dense_armor.utility.learn.online_classifiers import OnlineGaussianNB
from dense_armor.utility.metrics import LogLoss

rng = np.random.default_rng(0)
s = [({"qd": v}, int(abs(v) > 0.5)) for v in rng.normal(0, 1, 2000)]
m, tr = progressive_val_proba_score(s, OnlineGaussianNB(), LogLoss(), every=500)
print([round(v, 3) for v in tr])
[0.312, 0.283, 0.264, 0.253]

The log-loss falls as the model sees more samples.

8. Choosing a model on the stream

best_of runs several models on the same stream with the same protocol and tells which one is ahead.

import numpy as np
from dense_armor.utility.evaluate import best_of
from dense_armor.utility.learn.online_classifiers import OnlineGaussianNB, OnlineSoftmaxRegression
from dense_armor.utility.metrics import Accuracy

rng = np.random.default_rng(0)
s = [({"qd": v}, int(abs(v) > 0.5)) for v in rng.normal(0, 1, 2000)]
sel = best_of([OnlineGaussianNB(), OnlineSoftmaxRegression()], Accuracy, stream=s)
print([round(v, 3) for v in sel.scores()], sel.current_best())
[0.941, 0.621] 0

"Fast in either direction" is not a straight cut on $\dot q$, so a linear softmax model cannot learn it (0.621), while the Gaussian model, which learns a different spread per class, can (0.941). current_best() returns the index 0 and best_model() the model itself. The second argument is the metric class: each model gets its own fresh metric.

9. Events: delay and false alarms

A fault is not one sample: it lasts. EventMetrics takes the labelled event windows, then the alarm times, and answers the two questions a robot operator asks: how late was the alarm, and how often does it ring for nothing?

from dense_armor.utility.metrics import EventMetrics

m = EventMetrics([(10.0, 20.0), (40.0, 45.0)], dt=0.5, normal_time_s=3600.0)
for t in (5.0, 12.0, 12.5, 30.0):
    m.add_alarm(t)
print(m.detection_delays_s(), m.missed_events(), m.false_alarms(), m.false_alarms_per_hour())
[2.0, None] 1 2 2.0

The first event starts at 10 s and the first alarm inside it is at 12 s: delay 2 s. The second event has no alarm: missed. The alarms at 5 s and 30 s are outside every event: two false alarms over one hour of normal operation, 2 per hour. dt is the sample period, used in step 10 to count the points of a window.

10. How much of the event was caught

Tatbul et al. (2018) extend precision and recall to ranges. The recall of one real event mixes two rewards:

$$\mathrm{Recall}(R_i, P) = \alpha \cdot \mathrm{Existence}(R_i, P) + (1 - \alpha) \cdot \mathrm{Overlap}(R_i, P) \qquad \text{(Eq. 4)}$$

The existence reward is 1 if any alarm falls inside the event (Eq. 5). The overlap reward (Eq. 6) adds up a weight $\delta(i)$ for every point $i$ of the event that the alarms cover, divided by the sum of all the weights, and is shrunk by a cardinality factor when the event is split across several alarm ranges (Eq. 7). The weights are the positional bias of their Figure 2b: flat ($\delta = 1$), front ($\delta = L - i + 1$, early points count more), back ($\delta = i$), middle. Precision (Eq. 9) uses the same overlap reward, with flat bias.

from dense_armor.utility.metrics import EventMetrics

for bias in ("front", "back"):
    m = EventMetrics([(0.0, 9.0)], positional_bias=bias, threshold_window=1.0)
    for t in (0.0, 1.0, 2.0):
        m.add_alarm(t)
    print(bias, round(m.range_precision(), 3), round(m.range_recall(), 3), round(m.range_f1(), 3))
front 1.0 0.491 0.659
back 1.0 0.109 0.197

The event has 10 points (0 to 9 s at dt=1.0); the three alarms, merged into one range by threshold_window=1.0, cover the first three. With front bias the weights are 10, 9, …, 1 (sum 55) and the covered ones add up to 10 + 9 + 8 = 27: recall 27/55 = 0.491. With back bias they are 1 + 2 + 3 = 6: recall 6/55 = 0.109. Precision is 1.0 in both cases because every alarm point lies inside the event. For a robot, front bias says "catching the start of a fault is what matters".

11. One score for early detection: NAB

The Numenta Anomaly Benchmark (Lavin and Ahmad 2015) gives each detection a weight that depends on where it falls.

The NAB scaled sigmoid. Inside the anomaly window (orange, y from -1 to 0) a detection earns close to 1 at the start and 0 at the end; after the window the weight turns negative and tends to -1.

$$s(y) = \frac{2}{1 + e^{5y}} - 1$$

where $y$ is the position relative to the window: $-1$ at its start, $0$ at its end, positive after it. The earliest detection in a window earns $A_{TP}\,s(y)$; a missed window costs $A_{FN}$; an alarm outside every window costs $|A_{FP}|\,s(y)$ measured after the preceding window, or $-|A_{FP}|$ if no window precedes it. The total is normalised so that a perfect detector scores 100 and a detector that never fires scores 0 (Eq. 4).

from dense_armor.utility.metrics import EventMetrics

for alarms in ([0.0], [9.0], [9.0, 14.5], []):
    m = EventMetrics([(0.0, 10.0)])
    for t in alarms:
        m.add_alarm(t)
    print(alarms, round(m.nab_score(), 2))
[0.0] 100.0
[9.0] 62.67
[9.0, 14.5] 58.18
[] 0.0

A detection at 9 s in a window from 0 to 10 s has $y = -0.1$ and earns $s = 0.245$; normalised between the null detector ($-1$) and the perfect one ($s(-1) = 0.987$) it gives $100 \cdot 1.245 / 1.987 = 62.67$. The extra alarm at 14.5 s is 0.45 windows after the end: $s = -0.809$, times $|A_{FP}| = 0.11$, which lowers the score to 58.18. The default weights are the paper's standard profile ($A_{TP} = 1$, $A_{FP} = -0.11$, $A_{FN} = -1$); pass a_tp, a_fp, a_fn to change them.

12. A real detector on a stream

evaluate_events runs a detector over (t, x) pairs and returns everything at once. This is the case of the figure at the top.

import numpy as np
from dense_armor.utility.drift.detector import CUSUMDriftDetector
from dense_armor.utility.evaluate import evaluate_events

rng = np.random.default_rng(1)
x = np.concatenate([rng.normal(0, 1, 3000), rng.normal(3, 1, 1000)])
det = CUSUMDriftDetector(reference="fixed", radius=76, ref_mult=10, k=0.5, h=20.0)
s = [(i / 20, v) for i, v in enumerate(x)]
r = evaluate_events(s, det, [(150.0, 199.95)], dt=0.05, normal_time_s=150.0)
print(round(r["detection_delays_s"][0], 2), r["missed"], r["false_alarms_per_hour"])
0.3 0 0.0

The CUSUM detector catches the step 0.30 s after it starts (6 samples at 20 Hz), misses nothing and raises no alarm during the 150 s of normal operation. The returned dict also holds range_precision, range_recall, range_f1 and nab_score. A drift detector is read through update and drift_detected; an anomaly detector through score_one, learn_one and a score_threshold attribute.

13. A warning: point-adjusted F1

Many papers score anomaly detectors with point adjustment: if one point inside a real anomaly is flagged, the whole anomaly counts as found. Kim et al. (2021) show that this rewards noise.

import numpy as np
from dense_armor.utility.metrics import F1, PointAdjustedF1

rng = np.random.default_rng(0)
y = np.zeros(2000, dtype=int)
for s in (200, 700, 1200, 1700):
    y[s:s + 100] = 1
p = (rng.random(2000) < 0.05).astype(int)
pa, f1 = PointAdjustedF1(positive=1, warn=False), F1(positive=1)
for a, b in zip(y.tolist(), p.tolist()):
    pa.update(a, b)
    f1.update(a, b)
print(round(pa.get(), 3), round(f1.get(), 3))
0.895 0.063

The "detector" flags 5 % of the samples at random. The plain F1 is 0.063, as it should be; the point-adjusted F1 is 0.895, because each 100-sample anomaly almost surely contains one random flag. PointAdjustedF1 is here only to compare with published numbers; it warns when read. Report the range-based metrics of step 10 next to it.

API reference

metrics

Online metrics for streams, robots and LLMs.

Metric

Metric()

Base class for online metrics.

Subclasses override _update, _revert, get and merge; the base handles the validity check, the count and the timestamp.

Attributes:

Name Type Description
role_ Any

role class the metric applies to, or None for role-agnostic metrics.

bigger_is_better bool

True for accuracy-like metrics, False for loss-like metrics. Model selection uses it to rank.

Source code in dense_armor/utility/metrics/base.py
def __init__(self) -> None:
    self.n_ = 0
    self.n_missing_ = 0
    self.t_first_: float | None = None
    self.t_last_: float | None = None

update

update(
    y_true: Any,
    y_pred: Any,
    t: float | None = None,
    w: float = 1.0,
) -> Metric

Feed one pair into the metric.

Parameters:

Name Type Description Default
y_true Any

the observed label.

required
y_pred Any

the predicted label.

required
t float | None

timestamp in seconds; not required by every metric.

None
w float

sample weight, default 1.0.

1.0

Returns:

Type Description
Metric

self, for chaining.

Source code in dense_armor/utility/metrics/base.py
def update(
    self,
    y_true: Any,
    y_pred: Any,
    t: float | None = None,
    w: float = 1.0,
) -> "Metric":
    """Feed one pair into the metric.

    Args:
        y_true: the observed label.
        y_pred: the predicted label.
        t: timestamp in seconds; not required by every metric.
        w: sample weight, default 1.0.

    Returns:
        ``self``, for chaining.
    """
    self._time_step(t)
    if _is_missing(y_true) or _is_missing(y_pred):
        self.n_missing_ += 1
        return self
    self._update(y_true, y_pred, float(w))
    self.n_ += 1
    return self

revert

revert(y_true: Any, y_pred: Any, w: float = 1.0) -> Metric

Undo one update. Used by the rolling wrappers.

Parameters:

Name Type Description Default
y_true Any

the label that was passed to update.

required
y_pred Any

the prediction that was passed to update.

required
w float

the same weight used in update.

1.0

Returns:

Type Description
Metric

self, for chaining.

Source code in dense_armor/utility/metrics/base.py
def revert(self, y_true: Any, y_pred: Any, w: float = 1.0) -> "Metric":
    """Undo one ``update``. Used by the rolling wrappers.

    Args:
        y_true: the label that was passed to ``update``.
        y_pred: the prediction that was passed to ``update``.
        w: the same weight used in ``update``.

    Returns:
        ``self``, for chaining.
    """
    if _is_missing(y_true) or _is_missing(y_pred):
        return self
    self._revert(y_true, y_pred, float(w))
    self.n_ -= 1
    return self

get

get() -> Any

Return the current value of the metric.

Most metrics return a float. :class:ConfusionMatrix is the one exception: it returns a nested dict {y_true: {y_pred: count}}, which is why the return type here is Any. Concrete subclasses refine it.

Source code in dense_armor/utility/metrics/base.py
def get(self) -> Any:
    """Return the current value of the metric.

    Most metrics return a ``float``. :class:`ConfusionMatrix` is
    the one exception: it returns a nested dict
    ``{y_true: {y_pred: count}}``, which is why the return type
    here is ``Any``. Concrete subclasses refine it.
    """
    raise NotImplementedError

merge

merge(other: Metric) -> Metric

Combine two partial states into the summary of the union.

Source code in dense_armor/utility/metrics/base.py
def merge(self, other: "Metric") -> "Metric":
    """Combine two partial states into the summary of the union."""
    raise NotImplementedError

works_with

works_with(model: Any) -> bool

Return True if the metric applies to model's role.

Source code in dense_armor/utility/metrics/base.py
def works_with(self, model: Any) -> bool:
    """Return ``True`` if the metric applies to ``model``'s role."""
    if self.role_ is None:
        return True
    return isinstance(model, self.role_)

F1

F1(positive: Any = True, average: str = 'binary')

Bases: FBeta

F1 score: the harmonic mean of precision and recall.

A thin subclass of :class:FBeta with beta = 1.0.

Examples:

>>> from dense_armor.utility.metrics import F1
>>> m = F1(positive=1)
>>> for a, b in [(1, 1), (0, 1), (1, 1)]:
...     _ = m.update(a, b)
>>> round(m.get(), 4)
0.8
Source code in dense_armor/utility/metrics/classification.py
def __init__(self, positive: Any = True, average: str = "binary") -> None:
    super().__init__(beta=1.0, positive=positive, average=average)

merge

merge(other: Metric) -> F1

Combine two partial states, keeping the F1 class.

Parameters:

Name Type Description Default
other Metric

another :class:F1.

required

Returns:

Type Description
F1

A new :class:F1 with the combined state.

Raises:

Type Description
TypeError

if other is not an :class:F1.

Source code in dense_armor/utility/metrics/classification.py
def merge(self, other: Metric) -> "F1":
    """Combine two partial states, keeping the F1 class.

    Args:
        other: another :class:`F1`.

    Returns:
        A new :class:`F1` with the combined state.

    Raises:
        TypeError: if ``other`` is not an :class:`F1`.
    """
    if not isinstance(other, F1):
        raise TypeError(
            f"merge expects F1, got {type(other).__name__}"
        )
    m = F1(positive=self.positive, average=self.average)
    self._merge_into(m, other)
    m.n_ = self.n_ + other.n_
    m.n_missing_ = self.n_missing_ + other.n_missing_
    return m

Accuracy

Accuracy()

Bases: Metric

Fraction of samples where y_pred == y_true.

Examples:

>>> from dense_armor.utility.metrics import Accuracy
>>> m = Accuracy()
>>> for a, b in [(0, 0), (1, 1), (0, 1), (1, 1)]:
...     _ = m.update(a, b)
>>> m.get()
0.75
Source code in dense_armor/utility/metrics/classification.py
def __init__(self) -> None:
    super().__init__()
    self.hits_ = 0.0
    self.total_ = 0.0

merge

merge(other: Metric) -> Accuracy

Combine two partial states.

Parameters:

Name Type Description Default
other Metric

another :class:Accuracy on a disjoint set of pairs.

required

Returns:

Type Description
Accuracy

A new :class:Accuracy with the combined state.

Raises:

Type Description
TypeError

if other is not an :class:Accuracy.

Source code in dense_armor/utility/metrics/classification.py
def merge(self, other: Metric) -> "Accuracy":
    """Combine two partial states.

    Args:
        other: another :class:`Accuracy` on a disjoint set of pairs.

    Returns:
        A new :class:`Accuracy` with the combined state.

    Raises:
        TypeError: if ``other`` is not an :class:`Accuracy`.
    """
    if not isinstance(other, Accuracy):
        raise TypeError(
            f"merge expects Accuracy, got {type(other).__name__}"
        )
    m = Accuracy()
    m.hits_ = self.hits_ + other.hits_
    m.total_ = self.total_ + other.total_
    m.n_ = self.n_ + other.n_
    m.n_missing_ = self.n_missing_ + other.n_missing_
    return m

BalancedAccuracy

BalancedAccuracy()

Bases: Metric

Mean of the per-class recall.

For binary classification with classes 0 and 1 this is (TPR + TNR) / 2, where TNR is the recall of the negative class. Balanced accuracy is the natural metric for imbalanced streams, since a majority-class predictor scores 0.5.

Examples:

>>> from dense_armor.utility.metrics import BalancedAccuracy
>>> m = BalancedAccuracy()
>>> for a, b in [(0, 0), (0, 1), (1, 1), (1, 1)]:
...     _ = m.update(a, b)
>>> round(m.get(), 4)
0.75
Source code in dense_armor/utility/metrics/classification.py
def __init__(self) -> None:
    super().__init__()
    self.tp_: dict[Any, float] = {}
    self.pos_: dict[Any, float] = {}

merge

merge(other: Metric) -> BalancedAccuracy

Combine two partial states.

Parameters:

Name Type Description Default
other Metric

another :class:BalancedAccuracy.

required

Returns:

Type Description
BalancedAccuracy

A new :class:BalancedAccuracy with the combined state.

Raises:

Type Description
TypeError

if other is not a :class:BalancedAccuracy.

Source code in dense_armor/utility/metrics/classification.py
def merge(self, other: Metric) -> "BalancedAccuracy":
    """Combine two partial states.

    Args:
        other: another :class:`BalancedAccuracy`.

    Returns:
        A new :class:`BalancedAccuracy` with the combined state.

    Raises:
        TypeError: if ``other`` is not a :class:`BalancedAccuracy`.
    """
    if not isinstance(other, BalancedAccuracy):
        raise TypeError(
            f"merge expects BalancedAccuracy, got {type(other).__name__}"
        )
    m = BalancedAccuracy()
    for k, v in self.tp_.items():
        m.tp_[k] = m.tp_.get(k, 0.0) + v
    for k, v in other.tp_.items():
        m.tp_[k] = m.tp_.get(k, 0.0) + v
    for k, v in self.pos_.items():
        m.pos_[k] = m.pos_.get(k, 0.0) + v
    for k, v in other.pos_.items():
        m.pos_[k] = m.pos_.get(k, 0.0) + v
    m.n_ = self.n_ + other.n_
    m.n_missing_ = self.n_missing_ + other.n_missing_
    return m

BrierScore

BrierScore()

Bases: Metric

Mean squared error of predicted probabilities.

mean((p - y)**2). The lower the better; 0.25 is the score of a constant 0.5 forecast.

Examples:

>>> from dense_armor.utility.metrics import BrierScore
>>> m = BrierScore()
>>> for y, p in [(1, 0.9), (0, 0.1)]:
...     _ = m.update(y, p)
>>> round(m.get(), 4)
0.01
Source code in dense_armor/utility/metrics/classification.py
def __init__(self) -> None:
    super().__init__()
    self.sum_ = 0.0
    self.total_ = 0.0

merge

merge(other: Metric) -> BrierScore

Combine two partial states.

Parameters:

Name Type Description Default
other Metric

another :class:BrierScore.

required

Returns:

Type Description
BrierScore

A new :class:BrierScore with the combined state.

Raises:

Type Description
TypeError

if other is not a :class:BrierScore.

Source code in dense_armor/utility/metrics/classification.py
def merge(self, other: Metric) -> "BrierScore":
    """Combine two partial states.

    Args:
        other: another :class:`BrierScore`.

    Returns:
        A new :class:`BrierScore` with the combined state.

    Raises:
        TypeError: if ``other`` is not a :class:`BrierScore`.
    """
    if not isinstance(other, BrierScore):
        raise TypeError(
            f"merge expects BrierScore, got {type(other).__name__}"
        )
    m = BrierScore()
    m.sum_ = self.sum_ + other.sum_
    m.total_ = self.total_ + other.total_
    m.n_ = self.n_ + other.n_
    m.n_missing_ = self.n_missing_ + other.n_missing_
    return m

CohenKappa

CohenKappa()

Bases: Metric

Cohen's kappa: agreement corrected for chance.

kappa = (p_o - p_e) / (1 - p_e) where p_o is the observed agreement and p_e is the agreement expected by chance under the marginal label frequencies. 1.0 is perfect, 0.0 is chance, negative is worse than chance.

References

Cohen, J. (1960). Educational and Psychological Measurement 20(1), 37-46.

Source code in dense_armor/utility/metrics/classification.py
def __init__(self) -> None:
    super().__init__()
    self.cm_: dict[Any, dict] = {}
    self.total_ = 0.0
    self.row_: dict[Any, float] = {}
    self.col_: dict[Any, float] = {}

merge

merge(other: Metric) -> CohenKappa

Combine two partial states.

Parameters:

Name Type Description Default
other Metric

another :class:CohenKappa.

required

Returns:

Type Description
CohenKappa

A new :class:CohenKappa with the combined state.

Raises:

Type Description
TypeError

if other is not a :class:CohenKappa.

Source code in dense_armor/utility/metrics/classification.py
def merge(self, other: Metric) -> "CohenKappa":
    """Combine two partial states.

    Args:
        other: another :class:`CohenKappa`.

    Returns:
        A new :class:`CohenKappa` with the combined state.

    Raises:
        TypeError: if ``other`` is not a :class:`CohenKappa`.
    """
    if not isinstance(other, CohenKappa):
        raise TypeError(
            f"merge expects CohenKappa, got {type(other).__name__}"
        )
    m = CohenKappa()
    for a, row in self.cm_.items():
        for b, v in row.items():
            m.cm_.setdefault(a, {})
            m.cm_[a][b] = m.cm_[a].get(b, 0.0) + v
    for a, row in other.cm_.items():
        for b, v in row.items():
            m.cm_.setdefault(a, {})
            m.cm_[a][b] = m.cm_[a].get(b, 0.0) + v
    for k, v in self.row_.items():
        m.row_[k] = m.row_.get(k, 0.0) + v
    for k, v in other.row_.items():
        m.row_[k] = m.row_.get(k, 0.0) + v
    for k, v in self.col_.items():
        m.col_[k] = m.col_.get(k, 0.0) + v
    for k, v in other.col_.items():
        m.col_[k] = m.col_.get(k, 0.0) + v
    m.total_ = self.total_ + other.total_
    m.n_ = self.n_ + other.n_
    m.n_missing_ = self.n_missing_ + other.n_missing_
    return m

ConfusionMatrix

ConfusionMatrix()

Bases: Metric

Multiclass confusion matrix.

get() returns a nested dict {y_true: {y_pred: count}}, so this metric is the one exception to the get() -> float convention; works_with still uses the classifier role.

Examples:

>>> from dense_armor.utility.metrics import ConfusionMatrix
>>> m = ConfusionMatrix()
>>> for a, b in [(0, 0), (0, 1), (1, 1)]:
...     _ = m.update(a, b)
>>> m.get()[0][1]
1.0
Source code in dense_armor/utility/metrics/classification.py
def __init__(self) -> None:
    super().__init__()
    self.cm_: dict[Any, dict] = {}

merge

merge(other: Metric) -> ConfusionMatrix

Combine two partial states.

Parameters:

Name Type Description Default
other Metric

another :class:ConfusionMatrix.

required

Returns:

Type Description
ConfusionMatrix

A new :class:ConfusionMatrix with the combined state.

Raises:

Type Description
TypeError

if other is not a :class:ConfusionMatrix.

Source code in dense_armor/utility/metrics/classification.py
def merge(self, other: Metric) -> "ConfusionMatrix":
    """Combine two partial states.

    Args:
        other: another :class:`ConfusionMatrix`.

    Returns:
        A new :class:`ConfusionMatrix` with the combined state.

    Raises:
        TypeError: if ``other`` is not a :class:`ConfusionMatrix`.
    """
    if not isinstance(other, ConfusionMatrix):
        raise TypeError(
            f"merge expects ConfusionMatrix, got {type(other).__name__}"
        )
    m = ConfusionMatrix()
    for a, row in self.cm_.items():
        for b, v in row.items():
            m.cm_.setdefault(a, {})
            m.cm_[a][b] = m.cm_[a].get(b, 0.0) + v
    for a, row in other.cm_.items():
        for b, v in row.items():
            m.cm_.setdefault(a, {})
            m.cm_[a][b] = m.cm_[a].get(b, 0.0) + v
    m.n_ = self.n_ + other.n_
    m.n_missing_ = self.n_missing_ + other.n_missing_
    return m

FBeta

FBeta(
    beta: float = 1.0,
    positive: Any = True,
    average: str = "binary",
)

Bases: _PRFBase

Weighted harmonic mean of precision and recall.

F_beta = (1 + beta**2) * P * R / (beta**2 * P + R). F1 is the special case beta = 1. The average follows the same conventions as :class:Precision.

Parameters:

Name Type Description Default
beta float

relative weight of recall over precision.

1.0
positive Any

the label treated as positive in "binary" mode.

True
average str

"binary", "macro", "micro" or "weighted".

'binary'

Examples:

>>> from dense_armor.utility.metrics import FBeta
>>> m = FBeta(beta=1.0, positive=1)
>>> for a, b in [(1, 1), (0, 1), (1, 1), (0, 0)]:
...     _ = m.update(a, b)
>>> round(m.get(), 4)
0.8
Source code in dense_armor/utility/metrics/classification.py
def __init__(
    self,
    beta: float = 1.0,
    positive: Any = True,
    average: str = "binary",
) -> None:
    super().__init__(positive=positive, average=average)
    self.beta = beta

merge

merge(other: Metric) -> FBeta

Combine two partial states.

Parameters:

Name Type Description Default
other Metric

another :class:FBeta with the same beta and average.

required

Returns:

Type Description
FBeta

A new :class:FBeta with the combined state.

Raises:

Type Description
TypeError

if other is not an :class:FBeta.

Source code in dense_armor/utility/metrics/classification.py
def merge(self, other: Metric) -> "FBeta":
    """Combine two partial states.

    Args:
        other: another :class:`FBeta` with the same ``beta`` and
            ``average``.

    Returns:
        A new :class:`FBeta` with the combined state.

    Raises:
        TypeError: if ``other`` is not an :class:`FBeta`.
    """
    if not isinstance(other, FBeta):
        raise TypeError(f"merge expects FBeta, got {type(other).__name__}")
    if self.beta != other.beta or self.average != other.average:
        raise ValueError("merge requires the same beta and average")
    m = FBeta(
        beta=self.beta, positive=self.positive, average=self.average
    )
    self._merge_into(m, other)
    m.n_ = self.n_ + other.n_
    m.n_missing_ = self.n_missing_ + other.n_missing_
    return m

LogLoss

LogLoss(eps: float = 1e-15)

Bases: Metric

Binary log-loss (cross-entropy).

-mean(y log p + (1 - y) log(1 - p)) with p the predicted probability of the positive class. The predicted probability is clipped to [eps, 1 - eps] to avoid log(0).

Parameters:

Name Type Description Default
eps float

clipping floor, default 1e-15.

1e-15

Examples:

>>> from dense_armor.utility.metrics import LogLoss
>>> m = LogLoss()
>>> for y, p in [(1, 0.9), (0, 0.1)]:
...     _ = m.update(y, p)
>>> round(m.get(), 4)
0.1054
Source code in dense_armor/utility/metrics/classification.py
def __init__(self, eps: float = 1e-15) -> None:
    super().__init__()
    self.eps = eps
    self.sum_ = 0.0
    self.total_ = 0.0

merge

merge(other: Metric) -> LogLoss

Combine two partial states.

Parameters:

Name Type Description Default
other Metric

another :class:LogLoss with the same eps.

required

Returns:

Type Description
LogLoss

A new :class:LogLoss with the combined state.

Raises:

Type Description
TypeError

if other is not a :class:LogLoss.

Source code in dense_armor/utility/metrics/classification.py
def merge(self, other: Metric) -> "LogLoss":
    """Combine two partial states.

    Args:
        other: another :class:`LogLoss` with the same ``eps``.

    Returns:
        A new :class:`LogLoss` with the combined state.

    Raises:
        TypeError: if ``other`` is not a :class:`LogLoss`.
    """
    if not isinstance(other, LogLoss):
        raise TypeError(
            f"merge expects LogLoss, got {type(other).__name__}"
        )
    m = LogLoss(eps=self.eps)
    m.sum_ = self.sum_ + other.sum_
    m.total_ = self.total_ + other.total_
    m.n_ = self.n_ + other.n_
    m.n_missing_ = self.n_missing_ + other.n_missing_
    return m

Precision

Precision(positive: Any = True, average: str = 'binary')

Bases: _PRFBase

Fraction of predicted positives that are correct.

Supports four averages: "binary" (uses positive), "macro", "micro", "weighted".

Examples:

>>> from dense_armor.utility.metrics import Precision
>>> m = Precision(positive=1, average="binary")
>>> for a, b in [(1, 1), (0, 1), (1, 1), (0, 0)]:
...     _ = m.update(a, b)
>>> m.get()
0.6666666666666666
Source code in dense_armor/utility/metrics/classification.py
def __init__(
    self, positive: Any = True, average: str = "binary"
) -> None:
    super().__init__()
    self.positive = positive
    self.average = average
    self.tp_: dict[Any, float] = {}
    self.fp_: dict[Any, float] = {}
    self.fn_: dict[Any, float] = {}
    self.support_: dict[Any, float] = {}

merge

merge(other: Metric) -> Precision

Combine two partial states.

Parameters:

Name Type Description Default
other Metric

another :class:Precision with the same configuration.

required

Returns:

Type Description
Precision

A new :class:Precision with the combined state.

Raises:

Type Description
TypeError

if other is not a :class:Precision.

Source code in dense_armor/utility/metrics/classification.py
def merge(self, other: Metric) -> "Precision":
    """Combine two partial states.

    Args:
        other: another :class:`Precision` with the same configuration.

    Returns:
        A new :class:`Precision` with the combined state.

    Raises:
        TypeError: if ``other`` is not a :class:`Precision`.
    """
    if not isinstance(other, Precision):
        raise TypeError(
            f"merge expects Precision, got {type(other).__name__}"
        )
    m = Precision(positive=self.positive, average=self.average)
    self._merge_into(m, other)
    m.n_ = self.n_ + other.n_
    m.n_missing_ = self.n_missing_ + other.n_missing_
    return m

Recall

Recall(positive: Any = True, average: str = 'binary')

Bases: _PRFBase

Fraction of true positives that are detected.

Same four averages as :class:Precision.

Examples:

>>> from dense_armor.utility.metrics import Recall
>>> m = Recall(positive=1, average="binary")
>>> for a, b in [(1, 1), (0, 1), (1, 0)]:
...     _ = m.update(a, b)
>>> m.get()
0.5
Source code in dense_armor/utility/metrics/classification.py
def __init__(
    self, positive: Any = True, average: str = "binary"
) -> None:
    super().__init__()
    self.positive = positive
    self.average = average
    self.tp_: dict[Any, float] = {}
    self.fp_: dict[Any, float] = {}
    self.fn_: dict[Any, float] = {}
    self.support_: dict[Any, float] = {}

merge

merge(other: Metric) -> Recall

Combine two partial states.

Parameters:

Name Type Description Default
other Metric

another :class:Recall with the same configuration.

required

Returns:

Type Description
Recall

A new :class:Recall with the combined state.

Raises:

Type Description
TypeError

if other is not a :class:Recall.

Source code in dense_armor/utility/metrics/classification.py
def merge(self, other: Metric) -> "Recall":
    """Combine two partial states.

    Args:
        other: another :class:`Recall` with the same configuration.

    Returns:
        A new :class:`Recall` with the combined state.

    Raises:
        TypeError: if ``other`` is not a :class:`Recall`.
    """
    if not isinstance(other, Recall):
        raise TypeError(f"merge expects Recall, got {type(other).__name__}")
    m = Recall(positive=self.positive, average=self.average)
    self._merge_into(m, other)
    m.n_ = self.n_ + other.n_
    m.n_missing_ = self.n_missing_ + other.n_missing_
    return m

EventMetrics

EventMetrics(
    events: Iterable[EventWindow | tuple[float, float]],
    dt: float = 1.0,
    alpha: float = 0.0,
    positional_bias: str = "front",
    cardinality: bool = True,
    normal_time_s: float | None = None,
    threshold_window: float = 0.0,
    a_tp: float = 1.0,
    a_fp: float = -0.11,
    a_fn: float = -1.0,
)

Collect the event metrics for a stream of alarms.

Feed the labelled windows once, then each alarm as it happens. report() returns all the metrics in one dict.

Parameters:

Name Type Description Default
events Iterable[EventWindow | tuple[float, float]]

the labelled event windows, as :class:EventWindow or as (start, end) pairs.

required
dt float

sample period in seconds, used to count the discrete points of a window. Default 1.0 (one point per second).

1.0
alpha float

existence-reward weight of Tatbul et al. (2018), in [0, 1]. 0 ignores existence and rewards only overlap.

0.0
positional_bias str

one of "flat", "front", "back", "middle". Used for recall; precision always uses flat.

'front'
cardinality bool

whether the cardinality factor of equation 7 penalises a real event split across several predictions.

True
normal_time_s float | None

total normal time in seconds, used as the denominator of false alarms per hour. If None, it is inferred from the first and last alarm timestamps minus the event durations.

None
threshold_window float

two alarms within this many seconds are the same detection. Default 0.0.

0.0
a_tp float

NAB weight of a true positive (Lavin and Ahmad 2015), in [0, 1]; standard profile 1.0.

1.0
a_fp float

NAB weight of a false positive, in [-1, 0]; standard profile -0.11.

-0.11
a_fn float

NAB weight of a missed window, in [-1, 0]; standard profile -1.0.

-1.0

Raises:

Type Description
ValueError

on a bad bias, an empty events, or out-of-range weights.

Source code in dense_armor/utility/metrics/events.py
def __init__(
    self,
    events: Iterable[EventWindow | tuple[float, float]],
    dt: float = 1.0,
    alpha: float = 0.0,
    positional_bias: str = "front",
    cardinality: bool = True,
    normal_time_s: float | None = None,
    threshold_window: float = 0.0,
    a_tp: float = 1.0,
    a_fp: float = -0.11,
    a_fn: float = -1.0,
) -> None:
    if dt <= 0.0:
        raise ValueError(f"dt must be > 0, got {dt}")
    self.events: list[EventWindow] = []
    for e in events:
        if isinstance(e, EventWindow):
            self.events.append(e)
        else:
            self.events.append(EventWindow(float(e[0]), float(e[1])))
    if not self.events:
        raise ValueError("EventMetrics needs at least one event window")
    if not 0.0 <= alpha <= 1.0:
        raise ValueError(f"alpha must be in [0, 1], got {alpha}")
    if not 0.0 <= a_tp <= 1.0:
        raise ValueError(f"a_tp must be in [0, 1], got {a_tp}")
    if not -1.0 <= a_fp <= 0.0:
        raise ValueError(f"a_fp must be in [-1, 0], got {a_fp}")
    if not -1.0 <= a_fn <= 0.0:
        raise ValueError(f"a_fn must be in [-1, 0], got {a_fn}")
    self.dt = dt
    self.alpha = alpha
    self.positional_bias = positional_bias
    self._bias_recall = _PositionalBias.get(positional_bias)
    self._bias_precision = _PositionalBias.get("flat")
    self.cardinality = cardinality
    self.normal_time_s = normal_time_s
    self.threshold_window = threshold_window
    self.a_tp = a_tp
    self.a_fp = a_fp
    self.a_fn = a_fn
    self.alarms_: list[float] = []

add_alarm

add_alarm(t: float) -> EventMetrics

Register an alarm at time t (seconds).

Source code in dense_armor/utility/metrics/events.py
def add_alarm(self, t: float) -> "EventMetrics":
    """Register an alarm at time ``t`` (seconds)."""
    self.alarms_.append(float(t))
    return self

detection_delays_s

detection_delays_s() -> list[float | None]

Delay in seconds for every event, None if it was missed.

Source code in dense_armor/utility/metrics/events.py
def detection_delays_s(self) -> list[float | None]:
    """Delay in seconds for every event, ``None`` if it was missed."""
    return [self._delay_for(e) for e in self.events]

missed_events

missed_events() -> int

Number of event windows with no alarm inside.

Source code in dense_armor/utility/metrics/events.py
def missed_events(self) -> int:
    """Number of event windows with no alarm inside."""
    return sum(1 for d in self.detection_delays_s() if d is None)

false_alarms

false_alarms() -> int

Number of alarms outside every event window.

Source code in dense_armor/utility/metrics/events.py
def false_alarms(self) -> int:
    """Number of alarms outside every event window."""
    return sum(1 for t in self.alarms_ if self._is_false_alarm(t))

false_alarms_per_hour

false_alarms_per_hour() -> float

False alarms divided by the observed normal time in hours.

Source code in dense_armor/utility/metrics/events.py
def false_alarms_per_hour(self) -> float:
    """False alarms divided by the observed normal time in hours."""
    fa = self.false_alarms()
    if self.normal_time_s is not None:
        hours = self.normal_time_s / 3600.0
    elif not self.alarms_:
        return 0.0
    else:
        t_lo = min(self.alarms_)
        t_hi = max(self.alarms_)
        span = t_hi - t_lo
        in_events = sum(
            max(0.0, min(e.end, t_hi) - max(e.start, t_lo))
            for e in self.events
        )
        hours = max(0.0, span - in_events) / 3600.0
    if hours <= 0.0:
        return 0.0
    return fa / hours

range_recall

range_recall() -> float

Range-based recall of Tatbul et al. (2018), equation 3.

Source code in dense_armor/utility/metrics/events.py
def range_recall(self) -> float:
    """Range-based recall of Tatbul et al. (2018), equation 3."""
    alarms = self._merged_alarms()
    if not self.events:
        return 0.0
    total = 0.0
    for e in self.events:
        ex = self._existence_reward(e)
        ov = self._overlap_reward(e, alarms, self._bias_recall)
        total += self.alpha * ex + (1.0 - self.alpha) * (
            self._cardinality_factor(e, alarms) * ov
        )
    return total / len(self.events)

range_precision

range_precision() -> float

Range-based precision of Tatbul et al. (2018), equation 8.

Precision always uses the flat positional bias (Section 5).

Source code in dense_armor/utility/metrics/events.py
def range_precision(self) -> float:
    """Range-based precision of Tatbul et al. (2018), equation 8.

    Precision always uses the flat positional bias (Section 5).
    """
    alarms = self._merged_alarms()
    if not alarms:
        return 0.0
    total = 0.0
    for a in alarms:
        ov = 0.0
        for e in self.events:
            if _has_overlap(a, e, self.dt):
                ov += self._overlap_reward(a, [e], self._bias_precision)
        card = 1.0
        if self.cardinality:
            n_overlap = sum(
                1 for e in self.events if _has_overlap(a, e, self.dt)
            )
            if n_overlap > 1:
                card = 1.0 / n_overlap
        total += card * ov
    return total / len(alarms)

range_f1

range_f1() -> float

Harmonic mean of :meth:range_precision and :meth:range_recall.

Source code in dense_armor/utility/metrics/events.py
def range_f1(self) -> float:
    """Harmonic mean of :meth:`range_precision` and :meth:`range_recall`."""
    p = self.range_precision()
    r = self.range_recall()
    return 2.0 * p * r / (p + r) if (p + r) > 0 else 0.0

nab_score

nab_score() -> float

Normalised NAB score of Lavin and Ahmad (2015), equation 4.

The score uses the scaled sigmoid of Figure 3, s(y) = 2 / (1 + exp(5 y)) - 1, with y = -1 at the window start and y = 0 at the window end. Each window contributes a_tp * s(y) for its earliest detection, or a_fn if missed; each false alarm contributes |a_fp| * s(y) with y measured after the preceding window, and -|a_fp| when no window precedes it (Figure 3).

Returns:

Type Description
float

The score; 100 for the perfect detector, 0 for the null one.

Source code in dense_armor/utility/metrics/events.py
def nab_score(self) -> float:
    """Normalised NAB score of Lavin and Ahmad (2015), equation 4.

    The score uses the scaled sigmoid of Figure 3,
    ``s(y) = 2 / (1 + exp(5 y)) - 1``, with ``y = -1`` at the
    window start and ``y = 0`` at the window end. Each window
    contributes ``a_tp * s(y)`` for its earliest detection, or
    ``a_fn`` if missed; each false alarm contributes
    ``|a_fp| * s(y)`` with ``y`` measured after the preceding
    window, and ``-|a_fp|`` when no window precedes it (Figure 3).

    Returns:
        The score; 100 for the perfect detector, 0 for the null one.
    """
    raw = 0.0
    for e in self.events:
        hits = [t for t in self.alarms_ if e.contains(t)]
        if hits:
            first = min(hits)
            raw += self.a_tp * _nab_sigmoid(self._nab_y_tp(e, first))
        else:
            raw += self.a_fn
    for t in self.alarms_:
        if self._is_false_alarm(t):
            y = self._nab_y_fp(t)
            s_fp = -1.0 if y is None else _nab_sigmoid(y)
            raw += abs(self.a_fp) * s_fp
    s_null = self.a_fn * len(self.events)
    s_perfect = self.a_tp * _nab_sigmoid(-1.0) * len(self.events)
    denom = s_perfect - s_null
    if denom == 0.0:
        return 0.0
    return 100.0 * (raw - s_null) / denom

report

report() -> dict

Return all event metrics in one dict.

Returns:

Type Description
dict

A dict with detection_delays_s (list, one per event,

dict

None for missed), mean_delay_s, missed,

dict

false_alarms, false_alarms_per_hour,

dict

range_precision, range_recall, range_f1, and

dict

nab_score.

Source code in dense_armor/utility/metrics/events.py
def report(self) -> dict:
    """Return all event metrics in one dict.

    Returns:
        A dict with ``detection_delays_s`` (list, one per event,
        ``None`` for missed), ``mean_delay_s``, ``missed``,
        ``false_alarms``, ``false_alarms_per_hour``,
        ``range_precision``, ``range_recall``, ``range_f1``, and
        ``nab_score``.
    """
    delays = self.detection_delays_s()
    seen = [d for d in delays if d is not None]
    return {
        "detection_delays_s": delays,
        "mean_delay_s": sum(seen) / len(seen) if seen else None,
        "missed": self.missed_events(),
        "false_alarms": self.false_alarms(),
        "false_alarms_per_hour": self.false_alarms_per_hour(),
        "range_precision": self.range_precision(),
        "range_recall": self.range_recall(),
        "range_f1": self.range_f1(),
        "nab_score": self.nab_score(),
    }

EventWindow dataclass

EventWindow(start: float, end: float)

A labelled event interval.

Attributes:

Name Type Description
start float

start time of the event, in seconds.

end float

end time of the event, in seconds; end >= start.

Raises:

Type Description
ValueError

if end < start.

n_points

n_points(dt: float) -> int

Number of discrete points in the window at period dt.

A window [start, end] sampled at period dt covers the points start, start+dt, ..., end; the count is round((end - start) / dt) + 1.

Source code in dense_armor/utility/metrics/events.py
def n_points(self, dt: float) -> int:
    """Number of discrete points in the window at period ``dt``.

    A window ``[start, end]`` sampled at period ``dt`` covers the
    points ``start, start+dt, ..., end``; the count is
    ``round((end - start) / dt) + 1``.
    """
    if dt <= 0.0:
        raise ValueError(f"dt must be > 0, got {dt}")
    return max(1, round((self.end - self.start) / dt) + 1)

PointAdjustedF1

PointAdjustedF1(positive: Any = 1, warn: bool = True)

Bases: Metric

Point-adjusted F1 (Xu et al. 2018).

The protocol marks the whole real anomaly segment as correctly detected as soon as one sample inside it is detected. It was proposed on the basis that a single alert inside an anomaly is enough to act; Kim et al. (2021) showed that a random anomaly score reaches a high point-adjusted F1, so the metric cannot distinguish a real detector from noise.

Use this metric only as a comparison, next to the range-based precision and recall of Tatbul et al. (2018).

Parameters:

Name Type Description Default
positive Any

the label treated as the positive class.

1
warn bool

whether to emit a UserWarning the first time the metric is scored. Default True.

True
References

Xu, H. et al. (2018). In WWW. Kim, S. et al. (2021). arXiv:2109.05257.

Source code in dense_armor/utility/metrics/events.py
def __init__(self, positive: Any = 1, warn: bool = True) -> None:
    super().__init__()
    self.positive = positive
    self.warn = warn
    self._warned = False
    self.y_true_: list[int] = []
    self.y_pred_: list[int] = []

GaussianNLL

GaussianNLL(eps: float = 1e-12)

Bases: Metric

Gaussian negative log-likelihood.

Examples:

>>> from dense_armor.utility.metrics import GaussianNLL
>>> m = GaussianNLL()
>>> for y, mu, v in [(0.0, 0.0, 1.0), (0.0, 0.0, 1.0)]:
...     _ = m.update(y, (mu, v))
>>> round(m.get(), 4)
0.9189
Source code in dense_armor/utility/metrics/regression.py
def __init__(self, eps: float = 1e-12) -> None:
    super().__init__()
    self.eps = eps
    self.sum_ = 0.0
    self.total_ = 0.0

merge

merge(other: Metric) -> GaussianNLL

Combine two partial states.

Parameters:

Name Type Description Default
other Metric

another :class:GaussianNLL with the same eps.

required

Returns:

Type Description
GaussianNLL

A new :class:GaussianNLL with the combined state.

Raises:

Type Description
TypeError

if other is not a :class:GaussianNLL.

Source code in dense_armor/utility/metrics/regression.py
def merge(self, other: Metric) -> "GaussianNLL":
    """Combine two partial states.

    Args:
        other: another :class:`GaussianNLL` with the same ``eps``.

    Returns:
        A new :class:`GaussianNLL` with the combined state.

    Raises:
        TypeError: if ``other`` is not a :class:`GaussianNLL`.
    """
    if not isinstance(other, GaussianNLL):
        raise TypeError(
            f"merge expects GaussianNLL, got {type(other).__name__}"
        )
    m = GaussianNLL(eps=self.eps)
    m.sum_ = self.sum_ + other.sum_
    m.total_ = self.total_ + other.total_
    m.n_ = self.n_ + other.n_
    m.n_missing_ = self.n_missing_ + other.n_missing_
    return m

IntervalCoverage

IntervalCoverage()

Bases: Metric

Fraction of true labels inside a prediction interval.

Examples:

>>> from dense_armor.utility.metrics import IntervalCoverage
>>> m = IntervalCoverage()
>>> for y, lo, hi in [(0.5, 0.0, 1.0), (2.0, 0.0, 1.0)]:
...     _ = m.update(y, (lo, hi))
>>> m.get()
0.5
Source code in dense_armor/utility/metrics/regression.py
def __init__(self) -> None:
    super().__init__()
    self.hits_ = 0.0
    self.total_ = 0.0

merge

merge(other: Metric) -> IntervalCoverage

Combine two partial states.

Parameters:

Name Type Description Default
other Metric

another :class:IntervalCoverage.

required

Returns:

Type Description
IntervalCoverage

A new :class:IntervalCoverage with the combined state.

Raises:

Type Description
TypeError

if other is not an :class:IntervalCoverage.

Source code in dense_armor/utility/metrics/regression.py
def merge(self, other: Metric) -> "IntervalCoverage":
    """Combine two partial states.

    Args:
        other: another :class:`IntervalCoverage`.

    Returns:
        A new :class:`IntervalCoverage` with the combined state.

    Raises:
        TypeError: if ``other`` is not an :class:`IntervalCoverage`.
    """
    if not isinstance(other, IntervalCoverage):
        raise TypeError(
            f"merge expects IntervalCoverage, got {type(other).__name__}"
        )
    m = IntervalCoverage()
    m.hits_ = self.hits_ + other.hits_
    m.total_ = self.total_ + other.total_
    m.n_ = self.n_ + other.n_
    m.n_missing_ = self.n_missing_ + other.n_missing_
    return m

MeanAbsoluteError

MeanAbsoluteError(feature: str | None = None)

Bases: Metric

Mean absolute error, one sample at a time.

MAE = mean(|y - y_hat|). Robust to outliers compared to MSE and the natural error for a robot torque loop, where all deviations are equally bad.

Parameters:

Name Type Description Default
feature str | None

key of the channel to read from dict inputs. None accepts a scalar or a joint vector.

None

Examples:

>>> from dense_armor.utility.metrics import MeanAbsoluteError
>>> m = MeanAbsoluteError()
>>> for a, b in [(1.0, 1.5), (2.0, 2.5), (3.0, 2.5)]:
...     _ = m.update(a, b)
>>> round(m.get(), 4)
0.5
Source code in dense_armor/utility/metrics/regression.py
def __init__(self, feature: str | None = None) -> None:
    super().__init__()
    self.feature = feature
    self.sum_ = 0.0
    self.total_ = 0.0
    self.per_joint_: _PerJoint | None = None

merge

merge(other: Metric) -> MeanAbsoluteError

Combine two partial states.

Parameters:

Name Type Description Default
other Metric

another :class:MeanAbsoluteError.

required

Returns:

Type Description
MeanAbsoluteError

A new :class:MeanAbsoluteError with the combined state.

Raises:

Type Description
TypeError

if other is not a :class:MeanAbsoluteError.

Source code in dense_armor/utility/metrics/regression.py
def merge(self, other: Metric) -> "MeanAbsoluteError":
    """Combine two partial states.

    Args:
        other: another :class:`MeanAbsoluteError`.

    Returns:
        A new :class:`MeanAbsoluteError` with the combined state.

    Raises:
        TypeError: if ``other`` is not a :class:`MeanAbsoluteError`.
    """
    if not isinstance(other, MeanAbsoluteError):
        raise TypeError(
            f"merge expects MeanAbsoluteError, got {type(other).__name__}"
        )
    m = MeanAbsoluteError(feature=self.feature)
    m.sum_ = self.sum_ + other.sum_
    m.total_ = self.total_ + other.total_
    if (
        self.per_joint_ is not None
        and other.per_joint_ is not None
        and self.per_joint_.n == other.per_joint_.n
    ):
        m.per_joint_ = self.per_joint_.merged(other.per_joint_)
    m.n_ = self.n_ + other.n_
    m.n_missing_ = self.n_missing_ + other.n_missing_
    return m

MeanIntervalWidth

MeanIntervalWidth()

Bases: Metric

Mean width of a prediction interval.

Examples:

>>> from dense_armor.utility.metrics import MeanIntervalWidth
>>> m = MeanIntervalWidth()
>>> for y, lo, hi in [(0.5, 0.0, 1.0), (1.0, 0.0, 2.0)]:
...     _ = m.update(y, (lo, hi))
>>> m.get()
1.5
Source code in dense_armor/utility/metrics/regression.py
def __init__(self) -> None:
    super().__init__()
    self.sum_ = 0.0
    self.total_ = 0.0

merge

merge(other: Metric) -> MeanIntervalWidth

Combine two partial states.

Parameters:

Name Type Description Default
other Metric

another :class:MeanIntervalWidth.

required

Returns:

Type Description
MeanIntervalWidth

A new :class:MeanIntervalWidth with the combined state.

Raises:

Type Description
TypeError

if other is not a :class:MeanIntervalWidth.

Source code in dense_armor/utility/metrics/regression.py
def merge(self, other: Metric) -> "MeanIntervalWidth":
    """Combine two partial states.

    Args:
        other: another :class:`MeanIntervalWidth`.

    Returns:
        A new :class:`MeanIntervalWidth` with the combined state.

    Raises:
        TypeError: if ``other`` is not a :class:`MeanIntervalWidth`.
    """
    if not isinstance(other, MeanIntervalWidth):
        raise TypeError(
            f"merge expects MeanIntervalWidth, got {type(other).__name__}"
        )
    m = MeanIntervalWidth()
    m.sum_ = self.sum_ + other.sum_
    m.total_ = self.total_ + other.total_
    m.n_ = self.n_ + other.n_
    m.n_missing_ = self.n_missing_ + other.n_missing_
    return m

MeanSquaredError

MeanSquaredError(feature: str | None = None)

Bases: Metric

Mean squared error, one sample at a time.

Examples:

>>> from dense_armor.utility.metrics import MeanSquaredError
>>> m = MeanSquaredError()
>>> for a, b in [(1.0, 1.5), (2.0, 2.5), (3.0, 2.5)]:
...     _ = m.update(a, b)
>>> round(m.get(), 4)
0.25
Source code in dense_armor/utility/metrics/regression.py
def __init__(self, feature: str | None = None) -> None:
    super().__init__()
    self.feature = feature
    self.sum_ = 0.0
    self.total_ = 0.0
    self.per_joint_: _PerJoint | None = None

merge

merge(other: Metric) -> MeanSquaredError

Combine two partial states.

Parameters:

Name Type Description Default
other Metric

another :class:MeanSquaredError.

required

Returns:

Type Description
MeanSquaredError

A new :class:MeanSquaredError with the combined state.

Raises:

Type Description
TypeError

if other is not a :class:MeanSquaredError.

Source code in dense_armor/utility/metrics/regression.py
def merge(self, other: Metric) -> "MeanSquaredError":
    """Combine two partial states.

    Args:
        other: another :class:`MeanSquaredError`.

    Returns:
        A new :class:`MeanSquaredError` with the combined state.

    Raises:
        TypeError: if ``other`` is not a :class:`MeanSquaredError`.
    """
    if not isinstance(other, MeanSquaredError):
        raise TypeError(
            f"merge expects MeanSquaredError, got {type(other).__name__}"
        )
    m = MeanSquaredError(feature=self.feature)
    m.sum_ = self.sum_ + other.sum_
    m.total_ = self.total_ + other.total_
    if (
        self.per_joint_ is not None
        and other.per_joint_ is not None
        and self.per_joint_.n == other.per_joint_.n
    ):
        m.per_joint_ = self.per_joint_.merged(other.per_joint_)
    m.n_ = self.n_ + other.n_
    m.n_missing_ = self.n_missing_ + other.n_missing_
    return m

R2Score

R2Score(feature: str | None = None)

Bases: Metric

Coefficient of determination, Welford running mean.

R^2 = 1 - SS_res / SS_tot with SS_tot from Welford's M2 accumulator (Welford 1962; Chan et al. 1979). 1 is a perfect fit, 0 is the mean-only predictor, negative is worse than the mean.

Examples:

>>> from dense_armor.utility.metrics import R2Score
>>> m = R2Score()
>>> for a, b in [(1.0, 1.0), (2.0, 2.0), (3.0, 3.0)]:
...     _ = m.update(a, b)
>>> m.get()
1.0
Source code in dense_armor/utility/metrics/regression.py
def __init__(self, feature: str | None = None) -> None:
    super().__init__()
    self.feature = feature
    self.ss_res_ = 0.0
    self.mean_ = 0.0
    self.m2_ = 0.0
    self.total_ = 0.0

merge

merge(other: Metric) -> R2Score

Combine two partial states.

Uses the parallel formula for Welford's M2 (Chan et al. 1979).

Parameters:

Name Type Description Default
other Metric

another :class:R2Score.

required

Returns:

Type Description
R2Score

A new :class:R2Score with the combined state.

Raises:

Type Description
TypeError

if other is not a :class:R2Score.

Source code in dense_armor/utility/metrics/regression.py
def merge(self, other: Metric) -> "R2Score":
    """Combine two partial states.

    Uses the parallel formula for Welford's ``M2`` (Chan et al.
    1979).

    Args:
        other: another :class:`R2Score`.

    Returns:
        A new :class:`R2Score` with the combined state.

    Raises:
        TypeError: if ``other`` is not a :class:`R2Score`.
    """
    if not isinstance(other, R2Score):
        raise TypeError(
            f"merge expects R2Score, got {type(other).__name__}"
        )
    m = R2Score(feature=self.feature)
    na, nb = self.total_, other.total_
    m.ss_res_ = self.ss_res_ + other.ss_res_
    m.total_ = na + nb
    if na == 0:
        m.mean_, m.m2_ = other.mean_, other.m2_
    elif nb == 0:
        m.mean_, m.m2_ = self.mean_, self.m2_
    else:
        d = other.mean_ - self.mean_
        m.mean_ = self.mean_ + d * nb / m.total_
        m.m2_ = self.m2_ + other.m2_ + d * d * na * nb / m.total_
    m.n_ = self.n_ + other.n_
    m.n_missing_ = self.n_missing_ + other.n_missing_
    return m

RootMeanSquaredError

RootMeanSquaredError(feature: str | None = None)

Bases: MeanSquaredError

Square root of the mean squared error.

Examples:

>>> from dense_armor.utility.metrics import RootMeanSquaredError
>>> m = RootMeanSquaredError()
>>> for a, b in [(1.0, 1.5), (2.0, 2.5), (3.0, 2.5)]:
...     _ = m.update(a, b)
>>> round(m.get(), 4)
0.5
Source code in dense_armor/utility/metrics/regression.py
def __init__(self, feature: str | None = None) -> None:
    super().__init__()
    self.feature = feature
    self.sum_ = 0.0
    self.total_ = 0.0
    self.per_joint_: _PerJoint | None = None

merge

merge(other: Metric) -> RootMeanSquaredError

Combine two partial states.

Parameters:

Name Type Description Default
other Metric

another :class:RootMeanSquaredError.

required

Returns:

Type Description
RootMeanSquaredError

A new :class:RootMeanSquaredError with the combined state.

Raises:

Type Description
TypeError

if other is not a :class:RootMeanSquaredError.

Source code in dense_armor/utility/metrics/regression.py
def merge(self, other: Metric) -> "RootMeanSquaredError":
    """Combine two partial states.

    Args:
        other: another :class:`RootMeanSquaredError`.

    Returns:
        A new :class:`RootMeanSquaredError` with the combined state.

    Raises:
        TypeError: if ``other`` is not a
            :class:`RootMeanSquaredError`.
    """
    if not isinstance(other, RootMeanSquaredError):
        raise TypeError(
            f"merge expects RootMeanSquaredError, "
            f"got {type(other).__name__}"
        )
    m = RootMeanSquaredError(feature=self.feature)
    m.sum_ = self.sum_ + other.sum_
    m.total_ = self.total_ + other.total_
    if (
        self.per_joint_ is not None
        and other.per_joint_ is not None
        and self.per_joint_.n == other.per_joint_.n
    ):
        m.per_joint_ = self.per_joint_.merged(other.per_joint_)
    m.n_ = self.n_ + other.n_
    m.n_missing_ = self.n_missing_ + other.n_missing_
    return m

RollingAUC

RollingAUC(window: int = 200)

Bases: Metric

Rolling ROC-AUC, exact on the last window samples.

Parameters:

Name Type Description Default
window int

window size in samples (default 200).

200

Examples:

>>> from dense_armor.utility.metrics import RollingAUC
>>> m = RollingAUC(window=4)
>>> for y, s in [(0, 0.1), (1, 0.9), (0, 0.2), (1, 0.8)]:
...     _ = m.update(y, s)
>>> m.get()
1.0
Source code in dense_armor/utility/metrics/roc_auc.py
def __init__(self, window: int = 200) -> None:
    super().__init__()
    if window < 1:
        raise ValueError(f"window must be >= 1, got {window}")
    self.window = window
    self.buf_: deque[tuple[int, float]] = deque(maxlen=window)

merge

merge(other: Metric) -> RollingAUC

Combine two rolling windows by concatenation.

Concatenation is not the state of the union in general; the result keeps the most recent window pairs from the union.

Parameters:

Name Type Description Default
other Metric

another :class:RollingAUC with the same window size.

required

Returns:

Type Description
RollingAUC

A new :class:RollingAUC with the combined window.

Raises:

Type Description
TypeError

if other is not a :class:RollingAUC.

ValueError

if window differs.

Source code in dense_armor/utility/metrics/roc_auc.py
def merge(self, other: Metric) -> "RollingAUC":
    """Combine two rolling windows by concatenation.

    Concatenation is not the state of the union in general; the
    result keeps the most recent ``window`` pairs from the union.

    Args:
        other: another :class:`RollingAUC` with the same window size.

    Returns:
        A new :class:`RollingAUC` with the combined window.

    Raises:
        TypeError: if ``other`` is not a :class:`RollingAUC`.
        ValueError: if ``window`` differs.
    """
    if not isinstance(other, RollingAUC):
        raise TypeError(
            f"merge expects RollingAUC, got {type(other).__name__}"
        )
    if self.window != other.window:
        raise ValueError("merge requires the same window")
    m = RollingAUC(window=self.window)
    for item in list(self.buf_) + list(other.buf_):
        m.buf_.append(item)
    m.n_ = self.n_ + other.n_
    m.n_missing_ = self.n_missing_ + other.n_missing_
    return m

Rolling

Rolling(
    metric: Metric,
    window: int | None = None,
    window_s: float | None = None,
)

Bases: Metric

Rolling window over any metric with revert.

Parameters:

Name Type Description Default
metric Metric

the underlying metric. It must implement revert.

required
window int | None

window length in samples. Exactly one of window and window_s must be given.

None
window_s float | None

window length in seconds. The window slides on the timestamps as they come in.

None

Raises:

Type Description
ValueError

if neither or both of window and window_s are given, or if window < 1, or if window_s <= 0.

Examples:

>>> from dense_armor.utility.metrics import Accuracy
>>> from dense_armor.utility.metrics.rolling import Rolling
>>> m = Rolling(Accuracy(), window=5)
>>> for a, b in [(1, 1), (1, 1), (1, 1), (1, 1), (1, 1), (0, 1)]:
...     _ = m.update(a, b)
>>> m.get()
0.8
Source code in dense_armor/utility/metrics/rolling.py
def __init__(
    self,
    metric: Metric,
    window: int | None = None,
    window_s: float | None = None,
) -> None:
    super().__init__()
    if (window is None) == (window_s is None):
        raise ValueError(
            "exactly one of 'window' and 'window_s' must be given"
        )
    if window is not None and window < 1:
        raise ValueError(f"window must be >= 1, got {window}")
    if window_s is not None and window_s <= 0:
        raise ValueError(f"window_s must be > 0, got {window_s}")
    self.metric = metric
    self.window = window
    self.window_s = window_s
    self.role_ = metric.role_
    self.bigger_is_better = metric.bigger_is_better
    self.entries_: deque[tuple[float | None, Any, Any, float]] = deque()
    self._last_t_: float | None = None

update

update(
    y_true: Any,
    y_pred: Any,
    t: float | None = None,
    w: float = 1.0,
) -> Rolling

Add a sample and evict the entries that left the window.

Parameters:

Name Type Description Default
y_true Any

the observed label.

required
y_pred Any

the predicted label.

required
t float | None

timestamp in seconds; required for the window_s mode.

None
w float

sample weight.

1.0

Returns:

Type Description
Rolling

self.

Raises:

Type Description
ValueError

if t is not increasing.

Source code in dense_armor/utility/metrics/rolling.py
def update(
    self,
    y_true: Any,
    y_pred: Any,
    t: float | None = None,
    w: float = 1.0,
) -> "Rolling":
    """Add a sample and evict the entries that left the window.

    Args:
        y_true: the observed label.
        y_pred: the predicted label.
        t: timestamp in seconds; required for the ``window_s`` mode.
        w: sample weight.

    Returns:
        ``self``.

    Raises:
        ValueError: if ``t`` is not increasing.
    """
    if t is not None:
        t = float(t)
        if self._last_t_ is not None and t <= self._last_t_:
            raise ValueError(
                f"non-increasing t: previous {self._last_t_}, got {t}"
            )
        self._last_t_ = t
    self.entries_.append((t, y_true, y_pred, w))
    self.metric.update(y_true, y_pred, t=t, w=w)
    self.n_ += 1
    if self.window is not None:
        self._evict_samples()
    elif self.window_s is not None and t is not None:
        self._evict_seconds(t)
    return self

revert

revert(y_true: Any, y_pred: Any, w: float = 1.0) -> Rolling

Undo one update, dropping the newest entry.

Parameters:

Name Type Description Default
y_true Any

the label that was passed to update.

required
y_pred Any

the prediction that was passed to update.

required
w float

the same weight used in update.

1.0

Returns:

Type Description
Rolling

self.

Source code in dense_armor/utility/metrics/rolling.py
def revert(self, y_true: Any, y_pred: Any, w: float = 1.0) -> "Rolling":
    """Undo one update, dropping the newest entry.

    Args:
        y_true: the label that was passed to ``update``.
        y_pred: the prediction that was passed to ``update``.
        w: the same weight used in ``update``.

    Returns:
        ``self``.
    """
    if self.entries_:
        self.entries_.pop()
    self.metric.revert(y_true, y_pred, w=w)
    self.n_ -= 1
    return self

merge

merge(other: Metric) -> Rolling

Combine two rolling windows.

Only the last window entries of the union are kept; the intermediate contributions to the underlying metric are not recovered, so this is a pragmatic merge for reporting, not the exact state of the union.

Parameters:

Name Type Description Default
other Metric

another :class:Rolling with the same configuration.

required

Returns:

Type Description
Rolling

A new :class:Rolling with the combined window.

Raises:

Type Description
TypeError

if other is not a :class:Rolling.

ValueError

if the configuration does not match.

Source code in dense_armor/utility/metrics/rolling.py
def merge(self, other: Metric) -> "Rolling":
    """Combine two rolling windows.

    Only the last ``window`` entries of the union are kept; the
    intermediate contributions to the underlying metric are not
    recovered, so this is a pragmatic merge for reporting, not the
    exact state of the union.

    Args:
        other: another :class:`Rolling` with the same configuration.

    Returns:
        A new :class:`Rolling` with the combined window.

    Raises:
        TypeError: if ``other`` is not a :class:`Rolling`.
        ValueError: if the configuration does not match.
    """
    if not isinstance(other, Rolling):
        raise TypeError(
            f"merge expects Rolling, got {type(other).__name__}"
        )
    if (self.window, self.window_s) != (other.window, other.window_s):
        raise ValueError("merge requires the same window configuration")
    m = Rolling(
        self.metric.merge(other.metric),
        window=self.window,
        window_s=self.window_s,
    )
    union = list(self.entries_) + list(other.entries_)
    if self.window is not None:
        union = union[-self.window:]
    m.entries_ = deque(union)
    m.n_ = self.n_ + other.n_
    m.n_missing_ = self.n_missing_ + other.n_missing_
    return m

evaluate

Online evaluation helpers.

Two protocols:

  • :func:progressive_val_score / :func:progressive_val_proba_score for a supervised model on a labelled stream.
  • :func:evaluate_events for a drift or anomaly detector against labelled event windows.

Plus :class:OnlineModelSelection / :func:best_of for model selection on a stream.

OnlineModelSelection

OnlineModelSelection(
    models: Sequence[Any],
    metric_factory: Callable[[], Metric],
    delay: float | None = None,
    every: int | None = None,
)

Track several models on a stream and expose the current best.

Parameters:

Name Type Description Default
models Sequence[Any]

the estimators.

required
metric_factory Callable[[], Metric]

a callable returning a fresh metric. The selection uses one metric per model.

required
delay float | None

label delay forwarded to :func:progressive_val_score.

None
every int | None

if given, records the trace every every samples.

None

Raises:

Type Description
ValueError

if models is empty.

Source code in dense_armor/utility/evaluate/model_selection.py
def __init__(
    self,
    models: Sequence[Any],
    metric_factory: Callable[[], Metric],
    delay: float | None = None,
    every: int | None = None,
) -> None:
    if not models:
        raise ValueError("models must be non-empty")
    self.models = list(models)
    self.metric_factory = metric_factory
    self.metrics: list[Metric] = [metric_factory() for _ in models]
    self.delay = delay
    self.every = every

run

run(stream: Iterable[Any]) -> list[Metric]

Run every model on the same stream.

Parameters:

Name Type Description Default
stream Iterable[Any]

the iterable of (x, y) pairs. It is materialised once so every model sees the same samples in the same order.

required

Returns:

Type Description
list[Metric]

The list of metrics, one per model.

Source code in dense_armor/utility/evaluate/model_selection.py
def run(
    self, stream: Iterable[Any]
) -> list[Metric]:
    """Run every model on the same stream.

    Args:
        stream: the iterable of ``(x, y)`` pairs. It is materialised
            once so every model sees the same samples in the same
            order.

    Returns:
        The list of metrics, one per model.
    """
    from dense_armor.utility.evaluate.prequential import (
        progressive_val_score,
    )

    samples = list(stream)
    for model, metric in zip(self.models, self.metrics):
        progressive_val_score(
            samples, model, metric, delay=self.delay
        )
    return self.metrics

scores

scores() -> list[float]

Current metric value per model.

Source code in dense_armor/utility/evaluate/model_selection.py
def scores(self) -> list[float]:
    """Current metric value per model."""
    return [m.get() for m in self.metrics]

current_best

current_best() -> int

Index of the model with the best metric value.

bigger_is_better of the metric is respected.

Raises:

Type Description
ValueError

if the models have not been run yet.

Source code in dense_armor/utility/evaluate/model_selection.py
def current_best(self) -> int:
    """Index of the model with the best metric value.

    ``bigger_is_better`` of the metric is respected.

    Raises:
        ValueError: if the models have not been run yet.
    """
    if not self.metrics:
        raise ValueError("no metrics")
    sign = 1.0 if self.metrics[0].bigger_is_better else -1.0
    scores = [sign * m.get() for m in self.metrics]
    return max(range(len(scores)), key=scores.__getitem__)

best_model

best_model() -> Any

The model with the best metric value.

Source code in dense_armor/utility/evaluate/model_selection.py
def best_model(self) -> Any:
    """The model with the best metric value."""
    return self.models[self.current_best()]

evaluate_events

evaluate_events(
    stream: Iterable[tuple[float, Any]],
    detector: Any,
    events: Iterable[EventWindow | tuple[float, float]],
    **kwargs: Any,
) -> dict

Run detector on stream and report the event metrics.

Parameters:

Name Type Description Default
stream Iterable[tuple[float, Any]]

(t, x) pairs, t in seconds, in time order. x is a scalar, or a dict passed to score_one; a dict with one key is unwrapped for a drift detector's update.

required
detector Any

a drift detector (update + drift_detected) or an anomaly detector (score_one + learn_one + score_threshold; the score is taken before learning).

required
events Iterable[EventWindow | tuple[float, float]]

the labelled event windows.

required
**kwargs Any

forwarded to :class:EventMetrics (dt, alpha, positional_bias, cardinality, normal_time_s, threshold_window, a_tp, a_fp, a_fn).

{}

Returns:

Type Description
dict

The dict of :meth:EventMetrics.report.

Raises:

Type Description
TypeError

if the detector exposes neither interface.

ValueError

if an anomaly detector has no score_threshold.

Example

from dense_armor.utility.evaluate import evaluate_events class TriggerAt: ... def init(self, at): ... self.at, self.drift_detected = at, False ... def update(self, v, t=None): ... self.drift_detected = t == self.at ... return self stream = [(float(i), float(i)) for i in range(100)] r = evaluate_events(stream, TriggerAt(15.0), [(10.0, 20.0)]) r["mean_delay_s"], r["missed"], r["false_alarms"] (5.0, 0, 0)

Source code in dense_armor/utility/evaluate/events.py
def evaluate_events(
    stream: Iterable[tuple[float, Any]],
    detector: Any,
    events: Iterable[EventWindow | tuple[float, float]],
    **kwargs: Any,
) -> dict:
    """Run ``detector`` on ``stream`` and report the event metrics.

    Args:
        stream: ``(t, x)`` pairs, ``t`` in seconds, in time order. ``x``
            is a scalar, or a dict passed to ``score_one``; a dict with
            one key is unwrapped for a drift detector's ``update``.
        detector: a drift detector (``update`` + ``drift_detected``) or
            an anomaly detector (``score_one`` + ``learn_one`` +
            ``score_threshold``; the score is taken before learning).
        events: the labelled event windows.
        **kwargs: forwarded to :class:`EventMetrics` (``dt``, ``alpha``,
            ``positional_bias``, ``cardinality``, ``normal_time_s``,
            ``threshold_window``, ``a_tp``, ``a_fp``, ``a_fn``).

    Returns:
        The dict of :meth:`EventMetrics.report`.

    Raises:
        TypeError: if the detector exposes neither interface.
        ValueError: if an anomaly detector has no ``score_threshold``.

    Example:
        >>> from dense_armor.utility.evaluate import evaluate_events
        >>> class TriggerAt:
        ...     def __init__(self, at):
        ...         self.at, self.drift_detected = at, False
        ...     def update(self, v, t=None):
        ...         self.drift_detected = t == self.at
        ...         return self
        >>> stream = [(float(i), float(i)) for i in range(100)]
        >>> r = evaluate_events(stream, TriggerAt(15.0), [(10.0, 20.0)])
        >>> r["mean_delay_s"], r["missed"], r["false_alarms"]
        (5.0, 0, 0)
    """
    em = EventMetrics(events, **kwargs)
    for t, x in stream:
        if _alarm(detector, x, float(t)):
            em.add_alarm(float(t))
    return em.report()

best_of

best_of(
    models: Sequence[Any],
    metric_factory: Callable[[], Metric],
    stream: Iterable[Any] | None = None,
    delay: float | None = None,
) -> OnlineModelSelection

Return an :class:OnlineModelSelection for models.

If stream is given, the selection is run immediately on it.

Parameters:

Name Type Description Default
models Sequence[Any]

the estimators.

required
metric_factory Callable[[], Metric]

a callable returning a fresh metric.

required
stream Iterable[Any] | None

optional stream to run the selection on.

None
delay float | None

label delay for the stream.

None

Returns:

Name Type Description
The OnlineModelSelection

class:OnlineModelSelection.

Source code in dense_armor/utility/evaluate/model_selection.py
def best_of(
    models: Sequence[Any],
    metric_factory: Callable[[], Metric],
    stream: Iterable[Any] | None = None,
    delay: float | None = None,
) -> OnlineModelSelection:
    """Return an :class:`OnlineModelSelection` for ``models``.

    If ``stream`` is given, the selection is run immediately on it.

    Args:
        models: the estimators.
        metric_factory: a callable returning a fresh metric.
        stream: optional stream to run the selection on.
        delay: label delay for the stream.

    Returns:
        The :class:`OnlineModelSelection`.
    """
    sel = OnlineModelSelection(models, metric_factory, delay=delay)
    if stream is not None:
        sel.run(stream)
    return sel

progressive_val_proba_score

progressive_val_proba_score(
    stream: Iterable[Any],
    classifier: Classifier,
    metric: Metric,
    delay: float | None = None,
    every: int | None = None,
) -> Metric | tuple[Metric, list[float]]

Same as :func:progressive_val_score, but scores a probability.

Use when the metric needs a probability (LogLoss, BrierScore) and the classifier exposes predict_proba_one. The class positive=1 is used as the probability of the positive class; if the classifier is not binary, the metric must select the class itself.

Parameters:

Name Type Description Default
stream Iterable[Any]

an iterable of (x, y) pairs.

required
classifier Classifier

a classifier exposing predict_proba_one.

required
metric Metric

an online metric.

required
delay float | None

label delay as in :func:progressive_val_score.

None
every int | None

if given, records the metric every every samples.

None

Returns:

Type Description
Metric | tuple[Metric, list[float]]

The metric, or (metric, trace).

Source code in dense_armor/utility/evaluate/prequential.py
def progressive_val_proba_score(
    stream: Iterable[Any],
    classifier: Classifier,
    metric: Metric,
    delay: float | None = None,
    every: int | None = None,
) -> Metric | tuple[Metric, list[float]]:
    """Same as :func:`progressive_val_score`, but scores a probability.

    Use when the metric needs a probability (``LogLoss``,
    ``BrierScore``) and the classifier exposes ``predict_proba_one``.
    The class ``positive=1`` is used as the probability of the positive
    class; if the classifier is not binary, the metric must select the
    class itself.

    Args:
        stream: an iterable of ``(x, y)`` pairs.
        classifier: a classifier exposing ``predict_proba_one``.
        metric: an online metric.
        delay: label delay as in :func:`progressive_val_score`.
        every: if given, records the metric every ``every`` samples.

    Returns:
        The metric, or ``(metric, trace)``.
    """
    if every is not None and every < 1:
        raise ValueError(f"every must be >= 1, got {every}")
    buffer: list[_PENDING] = []
    trace: list[float] = []
    for i, (x, y) in enumerate(stream, 1):
        t = _timestamp_of(x)
        proba = call_with_t(classifier.predict_proba_one, x, t=t)
        p = proba.get(1, proba.get(True, 0.5)) if proba else 0.5
        buffer.append((x, y, p, t))
        ready, buffer = _split_ready(buffer, delay, t)
        for rx, ry, rp, rt in ready:
            metric.update(ry, rp, t=rt)
            call_with_t(classifier.learn_one, rx, ry, t=rt)
        if every is not None and i % every == 0:
            trace.append(metric.get())
    for rx, ry, rp, rt in buffer:
        metric.update(ry, rp, t=rt)
        call_with_t(classifier.learn_one, rx, ry, t=rt)
    if every is None:
        return metric
    return metric, trace

progressive_val_score

progressive_val_score(
    stream: Iterable[Any],
    model: Any,
    metric: Metric,
    delay: float | None = None,
    every: int | None = None,
) -> Metric | tuple[Metric, list[float]]

Score a model on a stream, test first, then train.

Each element of stream is a pair (x, y). For every sample, in order:

  1. the model predicts (if it exposes predict_one);
  2. the sample is buffered with its prediction;
  3. the samples whose delay has expired are scored against their (now available) label and learned from.

With delay=None every sample is scored and learned immediately. With an integer delay the last delay samples wait in the buffer; with a float the delay is measured in seconds on the timestamps.

Parameters:

Name Type Description Default
stream Iterable[Any]

an iterable of (x, y) pairs.

required
model Any

the estimator.

required
metric Metric

the online metric to update.

required
delay float | None

label delay in samples (int) or seconds (float).

None
every int | None

if given, records the metric every every samples and returns the trace.

None

Returns:

Type Description
Metric | tuple[Metric, list[float]]

The metric, or (metric, trace) when every is given.

Raises:

Type Description
ValueError

if every is given and is not positive.

Example

from dense_armor.utility.evaluate import progressive_val_score from dense_armor.utility.metrics import Accuracy class LastValue: ... def init(self): self._last = 0 ... def learn_one(self, x, y, t=None): ... self._last = y; return self ... def predict_one(self, x, t=None): return self._last stream = [({}, 1)] * 20 m = progressive_val_score(stream, LastValue(), Accuracy()) round(m.get(), 3) 0.95

Source code in dense_armor/utility/evaluate/prequential.py
def progressive_val_score(
    stream: Iterable[Any],
    model: Any,
    metric: Metric,
    delay: float | None = None,
    every: int | None = None,
) -> Metric | tuple[Metric, list[float]]:
    """Score a model on a stream, test first, then train.

    Each element of ``stream`` is a pair ``(x, y)``. For every sample,
    in order:

    1. the model predicts (if it exposes ``predict_one``);
    2. the sample is buffered with its prediction;
    3. the samples whose delay has expired are scored against their
       (now available) label and learned from.

    With ``delay=None`` every sample is scored and learned immediately.
    With an integer ``delay`` the last ``delay`` samples wait in the
    buffer; with a float the delay is measured in seconds on the
    timestamps.

    Args:
        stream: an iterable of ``(x, y)`` pairs.
        model: the estimator.
        metric: the online metric to update.
        delay: label delay in samples (int) or seconds (float).
        every: if given, records the metric every ``every`` samples
            and returns the trace.

    Returns:
        The metric, or ``(metric, trace)`` when ``every`` is given.

    Raises:
        ValueError: if ``every`` is given and is not positive.

    Example:
        >>> from dense_armor.utility.evaluate import progressive_val_score
        >>> from dense_armor.utility.metrics import Accuracy
        >>> class LastValue:
        ...     def __init__(self): self._last = 0
        ...     def learn_one(self, x, y, t=None):
        ...         self._last = y; return self
        ...     def predict_one(self, x, t=None): return self._last
        >>> stream = [({}, 1)] * 20
        >>> m = progressive_val_score(stream, LastValue(), Accuracy())
        >>> round(m.get(), 3)
        0.95
    """
    if every is not None and every < 1:
        raise ValueError(f"every must be >= 1, got {every}")
    buffer: list[_PENDING] = []
    trace: list[float] = []
    for i, (x, y) in enumerate(stream, 1):
        t = _timestamp_of(x)
        pred = (
            call_with_t(model.predict_one, x, t=t)
            if hasattr(model, "predict_one")
            else None
        )
        buffer.append((x, y, pred, t))
        ready, buffer = _split_ready(buffer, delay, t)
        for rx, ry, rpred, rt in ready:
            _learn_or_score(model, metric, rx, ry, rpred, rt)
        if every is not None and i % every == 0:
            trace.append(metric.get())
    for rx, ry, rpred, rt in buffer:
        _learn_or_score(model, metric, rx, ry, rpred, rt)
    if every is None:
        return metric
    return metric, trace

Details

  • NAB, Eq. 1 against Figure 3. The paper prints the scaled sigmoid as $\sigma^A(y) = (A_{TP} - A_{FP})\,\frac{1}{1 + e^{5y}} - 1$ and states that the right end of the window gives $\sigma(0) = 0$; that holds only when $A_{TP} - A_{FP} = 2$. Figure 3 instead multiplies the scaled sigmoid by the profile weight (its total, $-1.0 \cdot 0.11 + 0.9999 \cdot 1 - 0.8093 \cdot 0.11 - 1.0 \cdot 0.11 = 0.6909$, checks out). The library follows Figure 3 and the text. The paper names two more profiles (reward low FP, reward low FN) without giving their weights, so only the standard weights are defaults.
  • Window boundary. Rolling(window_s=w) drops a sample when $t_\text{now} - t \ge w$, with a tolerance of $10^{-9}$ s so that timestamps such as i * 0.01 do not lose or gain a sample to rounding.
  • Same numbers as the robot benchmark. On the CUSUM stream of step 12, evaluate_events returns the same delay and false alarms per hour as run_cusum in benchmarks/casper_benchmark.py for thresholds 2, 5 and 20 (tested).
  • Sources. Tatbul, N. et al. (2018), "Precision and recall for time series", NeurIPS, arXiv:1803.03639. Lavin, A., Ahmad, S. (2015), "Evaluating real-time anomaly detection algorithms: the Numenta Anomaly Benchmark", IEEE ICMLA, arXiv:1510.03336. Kim, S. et al. (2021), "Towards a rigorous evaluation of time-series anomaly detection", arXiv:2109.05257. Ksieniewicz, P., Zyblewski, P. (2020), "stream-learn", arXiv:2001.11077 (test-then-train and prequential protocols). Amekoe, K. M. et al. (2024), arXiv:2409.10111 (delayed labels). Xie, L., Zou, S., Xie, Y., Veeravalli, V. V. (2021), "Sequential (quickest) change detection", arXiv:2104.04186 (detection delay and false alarm rate).