Computing a Composite Data Quality Score per Reading

A quality score earns its place only if it predicts error. Anything else is a number that looks authoritative, gets embedded in a filter, and silently shapes every published figure without ever having been checked. This guide implements the composite score defined in uncertainty quantification and data quality scoring as a single self-contained function, and — more importantly — the validation that shows it works.

What the Score Is For

The score answers one question: how much should a consumer trust this row relative to the others in the same table? It is deliberately dimensionless and deliberately monotone, because its job is ranking and filtering rather than physical interpretation. The physical claim belongs to the uncertainty column.

That framing settles most design arguments. The score does not need to be calibrated as a probability. It does need to be ordered correctly — a 0.9 reading must be more reliable than a 0.6 one, across the whole fleet and across seasons — and it needs to be explainable, because the first question anyone asks about an excluded station is why.

Four components carry nearly all the signal in practice. Flag state is the strongest and is mostly a veto. Calibration freshness decays predictably and catches the slow failure that nothing else notices. Neighbour agreement catches a sensor that has gone wrong between calibrations. Completeness catches an interval built from too few samples to mean anything.

From four components to one filterable number Flow diagram of the scoring function: four sub-scores are computed independently, a disqualifying flag vetoes the result outright, and the survivors are combined by normalised weights into a single 0-to-1 score stored beside its components. From four components to one filterable number Sub-scores flag, calibration agreement, completeness Veto flag 4 or 9 → 0.0 beats any weighting Weighted mean normalised weights per use case Store both score + components filter and explain Without the veto, a hardware-failure reading with three healthy dimensions scores about 0.6 and passes most filters.
The components are what make an exclusion defensible six months later, which is why they are stored rather than recomputed.

Production-Ready Implementation

# python 3.11 · numpy==1.26.4 · pandas==2.2.2
from __future__ import annotations

from dataclasses import dataclass, field

import numpy as np
import pandas as pd

# Flag vocabulary shared across the pipeline (CF-convention aligned)
FLAG_TRUST = {1: 1.0, 2: 0.6, 3: 0.4, 4: 0.0, 8: 0.3, 9: 0.0}
DISQUALIFYING = {4, 9}


@dataclass(frozen=True)
class ScoreWeights:
    flag: float = 0.40
    calibration: float = 0.25
    agreement: float = 0.20
    completeness: float = 0.15

    def as_dict(self) -> dict[str, float]:
        return {"flag": self.flag, "calibration": self.calibration,
                "agreement": self.agreement, "completeness": self.completeness}


@dataclass
class QualityScorer:
    """Composite 0..1 quality score with explainable components.

    weights sum is normalised internally, so adjusting one weight does not
    require rebalancing the others — a common source of drift between
    environments where the weights are configuration rather than code.
    """

    weights: ScoreWeights = field(default_factory=ScoreWeights)
    calibration_interval_days: float = 180.0

    def components(
        self,
        *,
        qc_flag: int,
        days_since_calibration: float,
        value: float,
        neighbour_values: list[float],
        neighbour_sigma: float,
        n_samples: int,
        expected_samples: int,
    ) -> dict[str, float]:
        return {
            "flag": FLAG_TRUST.get(qc_flag, 0.5),
            "calibration": float(np.clip(
                1.0 - days_since_calibration / (2 * self.calibration_interval_days), 0.0, 1.0)),
            "agreement": self._agreement(value, neighbour_values, neighbour_sigma),
            "completeness": float(np.clip(n_samples / max(expected_samples, 1), 0.0, 1.0)),
        }

    @staticmethod
    def _agreement(value: float, neighbours: list[float], sigma: float) -> float:
        """Gaussian decay of the z-score against the neighbour median.

        With no neighbours the answer is 0.5 — 'no information', which must not
        be confused with 0.0 ('disagrees') or 1.0 ('agrees').
        """
        usable = [v for v in neighbours if v is not None and np.isfinite(v)]
        if not usable or sigma <= 0:
            return 0.5
        z = abs(value - float(np.median(usable))) / sigma
        return float(np.exp(-0.5 * z * z))

    def score(self, components: dict[str, float], qc_flag: int) -> float:
        if qc_flag in DISQUALIFYING:
            return 0.0                       # veto beats any weighting
        w = self.weights.as_dict()
        return float(np.clip(
            sum(w[k] * components[k] for k in w) / sum(w.values()), 0.0, 1.0))

Applied over a frame, with the components preserved for explanation:

def score_frame(df: pd.DataFrame, scorer: QualityScorer) -> pd.DataFrame:
    """Attach quality_score and quality_components to every row.

    Neighbour values are expected as a list column, prepared upstream by a
    spatial join — computing them per row here would be O(n^2).
    """
    rows = []
    for r in df.itertuples(index=False):
        comps = scorer.components(
            qc_flag=r.qc_flag,
            days_since_calibration=r.days_since_calibration,
            value=r.value,
            neighbour_values=list(r.neighbour_values or []),
            neighbour_sigma=r.neighbour_sigma,
            n_samples=r.n_samples,
            expected_samples=r.expected_samples,
        )
        rows.append({"quality_score": scorer.score(comps, r.qc_flag),
                     "quality_components": comps})
    return df.assign(**pd.DataFrame(rows, index=df.index))
The only validation that matters: does the score predict error? Bar chart of mean absolute error against a co-located reference by quality-score decile, falling monotonically from the lowest decile to the highest. The only validation that matters: does the score predict error? 0 5 10 11.4 0–1 8.1 2–3 5.6 4–5 3.9 6–7 2.7 8–9 MAE vs reference (µg/m³)
If this relationship is flat, the weights express an opinion rather than measuring anything. Re-run it quarterly against held-out co-location data.

Parameter Tuning Guide

Component Typical value, healthy sensor Typical value, failing sensor What a low value means
flag 1.0 0.0–0.4 QC already caught something
calibration 0.75–1.0 0.0–0.3 overdue for service
agreement 0.7–1.0 0.0–0.3 diverging from neighbours
completeness 0.95–1.0 0.2–0.7 connectivity or power
Sensor class Calibration interval Neighbour sigma Expected samples/hour
Low-cost PM2.5 180 days 6.0 µg/m³ 60
Reference PM2.5 365 days 2.0 µg/m³ 4
Temperature 730 days 0.8 °C 12
Humidity 365 days 4.0 % 12
Dissolved oxygen 90 days 0.4 mg/L 4

neighbour_sigma is the parameter people set carelessly. It should be the expected spatial variability between comparable sensors, measured from your own network, not the instrument’s uncertainty — the two differ by a large factor for PM2.5, where genuine street-level variation dwarfs sensor noise.

Weights by what the data is for Matrix of five use cases against the weight each assigns to the four components and the filter threshold that typically accompanies it. Weights by what the data is for Flag Calibration Agreement Threshold Regulatory submission 0.45 0.30 0.15 ≥ 0.85 Public dashboard 0.40 0.25 0.20 ≥ 0.60 Interpolation input 0.35 0.20 0.30 ≥ 0.40, weighted Detector training set 0.50 0.25 0.10 ≥ 0.90 Operational alerting 0.60 0.10 0.20 ≥ 0.50
Alerting weights calibration lightly on purpose: a mis-calibrated sensor still detects a tenfold spike, and delaying an alert for that is the wrong trade.

Verification and Testing

The validation that matters is the monotonicity check against a reference.

# python 3.11 · pandas==2.2.2 · pytest==8.2.0
import pandas as pd


def score_predicts_error(scored: pd.DataFrame, reference_col: str = "reference_value") -> pd.DataFrame:
    """Mean absolute error by score decile — must fall as the score rises."""
    df = scored.dropna(subset=[reference_col]).copy()
    df["abs_error"] = (df["value"] - df[reference_col]).abs()
    df["decile"] = pd.qcut(df["quality_score"], 10, labels=False, duplicates="drop")
    return df.groupby("decile", as_index=False)["abs_error"].mean()


def test_error_falls_as_score_rises(colocation_frame):
    table = score_predicts_error(colocation_frame)
    top = table.loc[table["decile"] >= 7, "abs_error"].mean()
    bottom = table.loc[table["decile"] <= 2, "abs_error"].mean()
    assert top < 0.6 * bottom, f"score does not predict error: {table}"


def test_disqualifying_flag_vetoes_a_healthy_looking_reading():
    scorer = QualityScorer()
    comps = scorer.components(
        qc_flag=9, days_since_calibration=1, value=20.0,
        neighbour_values=[20.1, 19.8], neighbour_sigma=6.0,
        n_samples=60, expected_samples=60,
    )
    assert comps["calibration"] > 0.99          # every other dimension is perfect
    assert scorer.score(comps, qc_flag=9) == 0.0


def test_no_neighbours_scores_as_no_information():
    scorer = QualityScorer()
    comps = scorer.components(
        qc_flag=1, days_since_calibration=10, value=20.0,
        neighbour_values=[], neighbour_sigma=6.0,
        n_samples=60, expected_samples=60,
    )
    assert comps["agreement"] == 0.5            # not 0.0 and not 1.0

Run score_predicts_error quarterly against your co-location data and keep the table. It is the only evidence that the score means anything, and it is the first thing to check when someone proposes changing a weight.

Gotchas

Scoring agreement against a single neighbour. With one comparison sensor, a disagreement is equally likely to mean the neighbour is broken. Require at least three usable neighbours before the agreement component carries its full weight, or fall back to 0.5.

Weights that do not sum to one, applied without normalising. Someone raises the flag weight to 0.6 to “make flags matter more” and every score in the table shifts upward, silently moving every filter threshold. Normalise by the weight sum, as above.

Using the score as an input to anomaly detection. The score already contains the anomaly flag, so feeding it back creates a loop where a flagged reading lowers the score which strengthens the flag. Keep the direction of dependency one-way.

Recomputing scores in place on every batch. The components depend on registry state that changes; overwriting yesterday’s score with today’s registry makes historical scores unreproducible. Store the registry version with the score and treat a recompute as a new derivation.


FAQ

How do I know the weights are right?

Bin readings by score against a co-located reference and check that mean absolute error falls monotonically as the score rises. If it does not, the weights are expressing an opinion rather than measuring anything. Tune them against a held-out co-location period, never against the data you will publish.

Why a veto instead of just a large weight on flags?

Because a weight can be outvoted. With flags at 0.4 and everything else healthy, a hardware-failure reading still scores about 0.6 and passes a typical filter. A veto makes disqualifying flags disqualifying, which is what the flag already means.

Should the score be recomputed when the registry changes?

Yes — it is derived data. A corrected calibration date changes the freshness component for every reading in that interval. Store the registry version alongside the score so a recompute can target exactly the affected rows rather than the whole table.