Metrics¶
metrics
¶
Forecast metric panel — pure, and the factory that assembles it.
One entry point, compute_metrics, returns the full panel every run so users never re-run to get
a different metric; the decision metric is then a pure config choice. All values are floats, with
NaN where a metric is undefined (MAPE with zeros, MASE without training history, coverage without
intervals) rather than raising — a metric that can't be computed for one cell must not sink
the batch.
Importing this package registers every metric file by name (each metric module ends with
register(...)); get_metric(name) returns the class and list_metrics() lists the
registered names. Adding a metric is one new file, one line in the import block, one line in
METRIC_NAMES, and the ADD COLUMN IF NOT EXISTS migration registry.ddl.render_migrations
already emits — see docs/metric_template.py.
Public surface: compute_metrics, METRIC_NAMES, METRIC_DIRECTION, loss_of,
get_metric, list_metrics, BaseMetric, MetricContext, MetricDirection,
register.
Definitions (n = horizon, e = yhat - y_true):
- mae = mean(|e|)
- rmse = sqrt(mean(e²)); mse = mean(e²)
- mape = mean(|e| / |y_true|) (NaN if any y_true == 0)
- smape = mean(2|e| / (|y_true| + |yhat|))
- wape = sum(|e|) / sum(|y_true|) (NaN if sum(|y_true|) == 0)
- mase = mae / mae_naive, naive = one-step (m=1) on y_train
- rmsse = rmse / rmse_naive, naive = one-step (m=1) on y_train
- bias = mean(e) (mean error / ME)
- coverage = fraction of y_true within [lower, upper] (needs intervals)
- pinball = mean quantile loss across the interval bounds (needs intervals)
- mase_seasonal = mae / mae_naive, naive = seasonal (m=
seasonal_period) on y_train - maape = mean(arctan(|e| / |y_true|)) — defined at y_true == 0, unlike MAPE
- interval_score = mean Winkler score of [lower, upper] at α = 0.2 (needs intervals)
- interval_width = mean(upper - lower) (needs intervals)
- ope = |sum(yhat) - sum(y_true)| / |sum(y_true)| (NaN if sum(y_true) == 0)
- rmsle = sqrt(mean((log1p(y_true) - log1p(yhat))²)) (NaN if any y_true < 0 or yhat < 0)
- msse = mse / mean(diff(y_train)²) (needs y_train)
- msis = interval_score / mae_naive_seasonal (needs intervals, y_train, seasonal_period)
- r2 = 1 - sum(e²) / sum((y_true - mean(y_true))²) (higher is better; 1.0 is ideal)
- cv = rmse / |mean(y_true)| (NaN if mean(y_true) == 0)
BaseMetric
¶
Bases: ABC
One metric: a name, a direction, what it needs, and how to compute it.
Subclasses set the class-level attributes and implement compute. The needs_* flags are
declarative, not enforcement — every metric is already NaN-safe when its inputs are absent
and that behaviour must not change. Their job is to let config validation say at plan time that
a chosen decision_metric will be NaN for every cell of this run, instead of leaving an
operator to work it out from an empty leaderboard.
compute
abstractmethod
¶
MetricContext
dataclass
¶
MetricContext(
y_true,
yhat,
err,
abs_err,
y_train=None,
lower=None,
upper=None,
seasonal_period=None,
_memo=dict(),
)
One scored window, and everything any metric is allowed to see.
Built once per call to compute_metrics and shared by the whole panel, so the arrays every
metric needs — the residual and its absolute value — are computed once rather than fifteen
times. Construct it with from_arrays, which is where the shape validation lives.
value(name) is the other half of the sharing, and the reason a per-metric contract does not
cost anything in arithmetic. The panel is not fifteen independent numbers: mase divides
mae, rmsse divides rmse, and mase_seasonal divides mae again. Rather than
have those three recompute their numerator, each asks the context for it by name and the
context memoises the answer. The dependency stays visible in the file that has it, the
arithmetic still happens once, and the math for mae stays in mae.py where a reader
looking for it will go.
has_intervals
property
¶
True when both bounds are present — what the interval metrics check before scoring.
bounds
¶
(lower, upper) when both bounds are present, else None.
The same check as has_intervals, shaped so that the one guard an interval metric makes
also hands it the arrays — a boolean property cannot tell a type checker the bounds exist.
Source code in src/scale_forecasting/metrics/base_metric.py
from_arrays
classmethod
¶
Normalise a caller's arrays into a context, or raise on a window that cannot be scored.
The two raises are the only ones in the metric layer, and they are here rather than in any metric because they are not about any one metric: a window whose forecast and actuals have different lengths, or no actuals at all, is a caller bug that should stop, not fifteen NaNs that read like fifteen undefined metrics.
Source code in src/scale_forecasting/metrics/base_metric.py
value
¶
This window's value for the registered metric name, computed at most once.
Used by a metric that builds on another (mase on mae). A metric that names one
that is not registered gets NaN rather than an exception, on the same principle as
everything else here: an undefined input makes an undefined metric, not a failed run.
Source code in src/scale_forecasting/metrics/base_metric.py
compute_metrics
¶
Compute the full metric panel for one forecast window.
Builds one MetricContext and asks it for every name in METRIC_NAMES. Because the context
memoises, a metric that another metric already needed — mae under mase — is computed
once no matter how many times it is asked for or what order the panel is in.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
y_true
|
Sequence[float] | ndarray
|
actuals over the evaluation window. |
required |
yhat
|
Sequence[float] | ndarray
|
point forecasts, aligned to |
required |
y_train
|
Sequence[float] | ndarray | None
|
training-history actuals; required for scale-free MASE/RMSSE (else NaN).
This fold's training window, not the whole series. MASE and RMSSE divide by the
mean step of whatever is handed in, so passing history that overlaps |
None
|
lower
|
Sequence[float] | ndarray | None
|
lower prediction bound; with |
None
|
upper
|
Sequence[float] | ndarray | None
|
upper prediction bound. |
None
|
seasonal_period
|
int | None
|
steps in one seasonal cycle, from |
None
|
Returns:
| Type | Description |
|---|---|
dict[str, float]
|
|
dict[str, float]
|
are NaN. |
Raises:
| Type | Description |
|---|---|
ValueError
|
if |
Source code in src/scale_forecasting/metrics/__init__.py
get_metric
¶
Return the registered metric class for name.
Raises ConfigError with the available names when name is unknown.
Source code in src/scale_forecasting/metrics/__init__.py
list_metrics
¶
loss_of
¶
value restated as a loss: non-negative, zero is perfect, smaller is better (pure).
One direction map, one conversion, for every caller that has to rank models — the optimiser, the inverse-error weighting and the pruner. Each of those wants the same thing, and each used to hard-code its own idea of what "worse" means.
The conversions:
lower→ the value itself.higher→1 - value. Bothcoverageandr2are bounded above by1.0, so the shortfall from perfection is a non-negative loss. Returning-valuewould rank identically but be negative, and a negative loss cannot be inverted into a weight.zero→abs(value). A bias of −0.1 is better than one of +0.4, and both are worse than 0.- A metric this table does not know, or a NaN, →
inf: unrankable, so it can never win.
Two caveats worth stating rather than burying. coverage is treated as higher-is-better
although it is really best at nominal — 98% coverage from a wildly over-wide interval is not
better than 80% from a calibrated one. Judging it against nominal needs the interval's α,
which is not in the panel, and interval_score is the metric that already penalises width
honestly. And interval_width is scored lower-is-better, which in isolation rewards an
interval of zero width; it is a diagnostic to read beside coverage, not a decision_metric
to optimise alone.
Source code in src/scale_forecasting/metrics/__init__.py
register
¶
Register a metric class under its name. Returns the class so it
doubles as a decorator. Raises on a missing, duplicate or unusable name.