Skip to content
DHIS2.org Community GitHub

Creating Custom Metrics

This guide explains how to create custom evaluation metrics for Chap backtest results.

Overview

Metrics in Chap measure how well a model's forecasts match observed values. The metrics system provides:

  • Single definition: Each metric is defined once and supports multiple aggregation levels
  • Multi-level aggregation: Get global values, per-location, per-horizon, or detailed breakdowns
  • Automatic registration: Metrics are discovered and available throughout Chap
  • Two metric types: Deterministic (point forecasts) and Probabilistic (all samples)

Quick Start

Here's a minimal deterministic metric:

from chap_core.assessment.metrics.base import (
    AggregationOp,
    DeterministicMetric,
    MetricSpec,
    OptimizationDirection,
)
from chap_core.assessment.metrics import metric


@metric()
class MyAbsoluteErrorMetric(DeterministicMetric):
    """Computes absolute error between forecast and observation."""

    spec = MetricSpec(
        metric_id="my_absolute_error",
        metric_name="My Absolute Error",
        aggregation_op=AggregationOp.MEAN,
        description="Absolute difference between forecast and observation",
        optimization_direction=OptimizationDirection.MINIMIZE,
    )

    def compute_point_metric(self, forecast: float, observed: float) -> float:
        return abs(forecast - observed)

And a minimal probabilistic metric:

import numpy as np
from chap_core.assessment.metrics.base import (
    AggregationOp,
    ProbabilisticMetric,
    MetricSpec,
    OptimizationDirection,
)
from chap_core.assessment.metrics import metric


@metric()
class MySpreadMetric(ProbabilisticMetric):
    """Computes the spread (std dev) of forecast samples."""

    spec = MetricSpec(
        metric_id="my_spread",
        metric_name="My Spread",
        aggregation_op=AggregationOp.MEAN,
        description="Standard deviation of forecast samples",
        optimization_direction=OptimizationDirection.MINIMIZE,
    )

    def compute_sample_metric(self, samples: np.ndarray, observed: float) -> float:
        return float(np.std(samples))

Data Formats

Your metric receives data in standardized DataFrame formats:

Observations DataFrame (FlatObserved)

Column Type Description
location str Location identifier
time_period str Time period (e.g., "2024-01" or "2024W01")
disease_cases float Observed disease cases

Forecasts DataFrame (FlatForecasts)

Column Type Description
location str Location identifier
time_period str Time period being forecasted
horizon_distance int How many periods ahead this forecast is
sample int Sample index (for probabilistic forecasts)
forecast float Forecasted value

Output Format

Metrics return a DataFrame with dimension columns plus a metric column.

Base Classes

DeterministicMetric

For metrics comparing point forecasts (median of samples) to observations:

from chap_core.assessment.metrics.base import DeterministicMetric

# DeterministicMetric requires implementing:
# def compute_point_metric(self, forecast: float, observed: float) -> float

ProbabilisticMetric

For metrics that need all forecast samples:

from chap_core.assessment.metrics.base import ProbabilisticMetric

# ProbabilisticMetric requires implementing:
# def compute_sample_metric(self, samples: np.ndarray, observed: float) -> float

MetricSpec Configuration

from chap_core.assessment.metrics.base import AggregationOp, MetricSpec, TargetBehavior

spec = MetricSpec(
    metric_id="unique_id",              # Used in APIs and registry
    metric_name="Display Name",          # Human-readable name
    aggregation_op=AggregationOp.MEAN,   # MEAN, SUM, or ROOT_MEAN_SQUARE
    description="What this metric measures",
    optimization_direction=None,        # MINIMIZE, MAXIMIZE or None
    proper_scoring_rule=False,          # True only for proper scoring rules, which HPO may optimize
    unit=None,                          # Display suffix for the raw score, e.g. "%"
    target=None,                        # Ideal raw value when neither direction is better, e.g. 0.8
    target_behavior=TargetBehavior.CLOSEST,  # CLOSEST or AT_LEAST, only used with a target
)

The metric catalogue API returns unit, target and target_behavior alongside the optimization direction. A metric with optimization_direction=None should set a target, and target_behavior tells clients how to judge a score against it: CLOSEST means deviating in either direction is worse (ratio above truth, peak difference), while AT_LEAST means higher is better up to the target and flat above it, so only scores below the target should be flagged as bad (coverage metrics). Units do not rescale scores: MAPE is already a percentage, while coverage targets use fractions such as 0.8.

optimization_direction says which way is better when reading a score, not that the metric is a sound thing to optimize. Set proper_scoring_rule=True only when the metric is a proper scoring rule (or a consistent scoring function for point metrics, such as MAE for the median and RMSE for the mean), meaning a forecaster cannot improve its expected score by reporting anything other than its honest forecast. HPO only accepts proper scoring rules with an explicit direction. The outbreak metrics keep MAXIMIZE so clients colour high scores as good, but HPO rejects them, since sensitivity is maximised by always alerting and specificity by never alerting. MAPE is rejected for the same reason: it is minimised by systematically under-forecasting.

Complete Examples

Example: RMSE-style Metric

from chap_core.assessment.metrics.base import (
    AggregationOp,
    DeterministicMetric,
    MetricSpec,
    OptimizationDirection,
)
from chap_core.assessment.metrics import metric


@metric()
class SquaredErrorMetric(DeterministicMetric):
    """
    Squared error metric.

    With ROOT_MEAN_SQUARE aggregation, this produces RMSE.
    """

    spec = MetricSpec(
        metric_id="squared_error",
        metric_name="Squared Error",
        aggregation_op=AggregationOp.ROOT_MEAN_SQUARE,
        description="Squared error with RMSE aggregation",
        optimization_direction=OptimizationDirection.MINIMIZE,
    )

    def compute_point_metric(self, forecast: float, observed: float) -> float:
        return abs(forecast - observed)  # Base class squares for ROOT_MEAN_SQUARE

Example: Bias Detection Metric

import numpy as np
from chap_core.assessment.metrics.base import (
    AggregationOp,
    ProbabilisticMetric,
    MetricSpec,
)
from chap_core.assessment.metrics import metric


@metric()
class ForecastBiasMetric(ProbabilisticMetric):
    """
    Measures forecast bias as proportion of samples above truth.

    Returns 0.5 for unbiased forecasts, >0.5 for over-prediction,
    <0.5 for under-prediction.
    """

    spec = MetricSpec(
        metric_id="forecast_bias",
        metric_name="Forecast Bias",
        aggregation_op=AggregationOp.MEAN,
        description="Proportion of samples above observed (0.5 = unbiased)",
        optimization_direction=None,
        target=0.5,
    )

    def compute_sample_metric(self, samples: np.ndarray, observed: float) -> float:
        return float(np.mean(samples > observed))

Example: Parameterized Metric with Subclasses

import numpy as np
from chap_core.assessment.metrics.base import (
    AggregationOp,
    ProbabilisticMetric,
    MetricSpec,
    TargetBehavior,
)
from chap_core.assessment.metrics import metric


class IntervalCoverageMetric(ProbabilisticMetric):
    """Base class for interval coverage metrics (not registered directly)."""

    low_pct: int
    high_pct: int

    def compute_sample_metric(self, samples: np.ndarray, observed: float) -> float:
        low, high = np.percentile(samples, [self.low_pct, self.high_pct])
        return 1.0 if (low <= observed <= high) else 0.0


@metric()
class Coverage80Metric(IntervalCoverageMetric):
    """80% prediction interval coverage."""

    spec = MetricSpec(
        metric_id="coverage_80",
        metric_name="80% Coverage",
        aggregation_op=AggregationOp.MEAN,
        description="Proportion within 10th-90th percentile",
        optimization_direction=None,
        target=0.8,
        target_behavior=TargetBehavior.AT_LEAST,
    )
    low_pct = 10
    high_pct = 90

Using Metrics

Creating Example Data

First, let's create sample data to demonstrate metric computation:

import pandas as pd
import numpy as np
from chap_core.assessment.flat_representations import FlatObserved, FlatForecasts

# Create sample observations: 2 locations, 3 time periods
observations_df = pd.DataFrame({
    "location": ["loc_A", "loc_A", "loc_A", "loc_B", "loc_B", "loc_B"],
    "time_period": ["2024-01", "2024-02", "2024-03", "2024-01", "2024-02", "2024-03"],
    "disease_cases": [100.0, 120.0, 90.0, 200.0, 180.0, 220.0],
})
observations = FlatObserved(observations_df)

# Create sample forecasts: 10 samples per observation, horizon 1 and 2
forecast_rows = []
np.random.seed(42)
for loc in ["loc_A", "loc_B"]:
    base = 100 if loc == "loc_A" else 200
    for period in ["2024-01", "2024-02", "2024-03"]:
        for horizon in [1, 2]:
            for sample_id in range(10):
                forecast_rows.append({
                    "location": loc,
                    "time_period": period,
                    "horizon_distance": horizon,
                    "sample": sample_id,
                    "forecast": base + np.random.normal(0, 15),
                })
forecasts_df = pd.DataFrame(forecast_rows)
forecasts = FlatForecasts(forecasts_df)

print(f"Observations shape: {observations_df.shape}")
print(f"Forecasts shape: {forecasts_df.shape}")

Computing Metrics at Different Aggregation Levels

from chap_core.assessment.metrics import get_metric
from chap_core.assessment.flat_representations import DataDimension

# Get the MAE metric
mae = get_metric("mae")()

# Global aggregate: single value across all data
global_result = mae.get_global_metric(observations, forecasts)
print("Global MAE:")
print(global_result)
print()

# Detailed: one value per (location, time_period, horizon_distance)
detailed_result = mae.get_detailed_metric(observations, forecasts)
print("Detailed MAE (first 6 rows):")
print(detailed_result.head(6))
print()

# Per location only
per_location = mae.get_metric(observations, forecasts, dimensions=(DataDimension.location,))
print("MAE per location:")
print(per_location)
print()

# Per horizon only
per_horizon = mae.get_metric(observations, forecasts, dimensions=(DataDimension.horizon_distance,))
print("MAE per horizon:")
print(per_horizon)

Getting Metrics from the Registry

from chap_core.assessment.metrics import get_metric, list_metrics

# Get a specific metric by ID
MAEClass = get_metric("mae")
mae_metric = MAEClass()
print(f"Metric: {mae_metric.get_name()} ({mae_metric.get_id()})")
print(f"Description: {mae_metric.get_description()}")
print()

# List all available metrics
print("Available metrics:")
for info in list_metrics():
    print(f"  {info['id']}: {info['name']}")

Registration and Discovery

The @metric() Decorator

The decorator registers your metric class when the module is imported:

from chap_core.assessment.metrics import metric
from chap_core.assessment.metrics.base import DeterministicMetric, MetricSpec, AggregationOp, OptimizationDirection


@metric()  # This registers the class in the global registry
class RegisteredMetric(DeterministicMetric):
    spec = MetricSpec(
        metric_id="registered_example",
        metric_name="Registered Example",
        aggregation_op=AggregationOp.MEAN,
        description="Example of a registered metric",
        optimization_direction=OptimizationDirection.MINIMIZE,
    )

    def compute_point_metric(self, forecast: float, observed: float) -> float:
        return abs(forecast - observed)

File Location

Place your metric file in chap_core/assessment/metrics/ and add an import to _discover_metrics() in chap_core/assessment/metrics/__init__.py.

Understanding Aggregation

AggregationOp Options

Operation Description Use Case
MEAN Average of values MAE, coverage metrics
SUM Sum of values Count-based metrics
ROOT_MEAN_SQUARE sqrt(mean(x^2)) RMSE

DataDimension Options

Dimension Description
location Geographic location
time_period Time period of the forecast
horizon_distance How far ahead the forecast is

Testing Your Metric

Use existing metrics as a pattern for testing:

from chap_core.assessment.metrics import get_metric

# Verify your metric is registered
metric_cls = get_metric("mae")
assert metric_cls is not None

# Instantiate and check properties
metric = metric_cls()
assert metric.get_id() == "mae"
assert metric.get_name() == "MAE"

Reference

Existing Implementations

Study these files in chap_core/assessment/metrics/:

File Type Description
mae.py Deterministic Simple absolute error
rmse.py Deterministic Uses ROOT_MEAN_SQUARE aggregation
crps.py Probabilistic Uses all samples
percentile_coverage.py Probabilistic Parameterized with subclasses
above_truth.py Probabilistic Bias detection

API Summary

from chap_core.assessment.metrics import (
    metric,              # Decorator to register metrics
    get_metric,          # Get metric class by ID
    get_metrics_registry,  # Get all registered metrics
    list_metrics,        # List metrics with metadata
)
from chap_core.assessment.metrics.base import (
    Metric,              # Base class (abstract)
    DeterministicMetric, # For point forecast comparison
    ProbabilisticMetric, # For sample-based metrics
    MetricSpec,          # Configuration dataclass
    AggregationOp,       # MEAN, SUM, ROOT_MEAN_SQUARE
    OptimizationDirection,  # For hpo objective
)
from chap_core.assessment.flat_representations import DataDimension