Building a Spatial Data Quality Scorecard
A scorecard is a summary that has to survive being argued with. Somebody will ask why the parcel layer scored 82 when the roads layer scored 91, whether last month’s 76 is comparable, and what would need to change to reach 90 — and if the answers are not immediately derivable from the published numbers, the scorecard stops being used. This guide builds one that answers those questions: rules mapped to quality dimensions, each dimension normalised against its own history, weights that reflect what the layer is for, a grade capped by blocking defects, and a drill-down that names the rules doing the damage. It implements the metric design set out in Spatial Data Quality Metrics and Reporting.
Prerequisites
- A findings table with run history — at least thirty runs per layer, or the normalisation has no distribution to work against.
- A per-run denominator: features validated, per layer. Without it the sub-scores are counts wearing a percentage sign.
- A rule catalogue where every rule has exactly one quality dimension and one severity. Rules mapped to two dimensions make the sub-scores overlap and the weights meaningless.
- A declared purpose per layer, which is where the weights come from.
- pandas 2.x and somewhere to persist the scorecard table — the same warehouse as the findings is easiest.
Step-by-Step Procedure
Step 1 — Map every rule to exactly one dimension
# scorecard/step1_catalogue.py
RULE_DIMENSION = {
"ATTR_NULL_001": "completeness",
"ATTR_DOMAIN_001": "thematic_accuracy",
"ATTR_RANGE_001": "thematic_accuracy",
"GEOM_VALID_001": "logical_consistency",
"GEOM_TYPE_001": "logical_consistency",
"TOPO_OVERLAP_001": "logical_consistency",
"TOPO_GAP_001": "completeness",
"CRS_001": "positional_accuracy",
"PREC_DRIFT_001": "positional_accuracy",
"TEMPORAL_STALE_001": "temporal_quality",
}
DIMENSIONS = ["completeness", "logical_consistency", "positional_accuracy",
"temporal_quality", "thematic_accuracy"]
def unmapped_rules(findings) -> set:
"""Any rule without a dimension silently disappears from the score — find those first."""
return set(findings["rule_id"].unique()) - set(RULE_DIMENSION)
Verification: run unmapped_rules before every scorecard build and fail the job if it returns anything. An unmapped rule is worse than a missing rule: the defect is detected, reported and then excluded from the summary that people read.
Step 2 — Normalise each dimension against its own history
# scorecard/step2_normalise.py
import numpy as np
import pandas as pd
def dimension_rates(findings: pd.DataFrame, runs: pd.DataFrame, mapping: dict) -> pd.DataFrame:
"""Defects per 1,000 features, per dimension, per run."""
f = findings.assign(dimension=findings["rule_id"].map(mapping))
counts = (f.groupby(["run_id", "layer_id", "dimension"])
.size().rename("defects").reset_index())
merged = counts.merge(runs[["run_id", "layer_id", "features_validated"]],
on=["run_id", "layer_id"], how="right")
merged["defects"] = merged["defects"].fillna(0)
merged["rate_per_1k"] = 1000 * merged["defects"] / merged["features_validated"].clip(lower=1)
return merged
def sub_scores(rates: pd.DataFrame, history_runs: int = 30) -> pd.DataFrame:
"""0-100 per dimension: 100 at the historical best decile, 0 at three times the median."""
out = []
for (layer, dim), g in rates.groupby(["layer_id", "dimension"]):
g = g.sort_values("run_id")
hist = g["rate_per_1k"].tail(history_runs)
best = float(np.percentile(hist, 10))
worst = max(float(hist.median()) * 3, best + 1e-6)
score = 100 * (worst - g["rate_per_1k"]) / (worst - best)
out.append(g.assign(sub_score=score.clip(0, 100).round(1),
scale_best=round(best, 3), scale_worst=round(worst, 3)))
return pd.concat(out, ignore_index=True)
Verification: a run at the historical median should score around 67, a run at the best decile 100, and a run three times worse than the median 0. Sanity-check those three points on real data before publishing anything — a normalisation that puts every run at 99 is measuring nothing.
Step 3 — Weight by what the layer is for
# scorecard/step3_weights.py
LAYER_WEIGHTS = {
"parcels": { # cadastral: geometry and completeness dominate
"completeness": 0.30, "logical_consistency": 0.35,
"positional_accuracy": 0.20, "temporal_quality": 0.05,
"thematic_accuracy": 0.10,
},
"road_network": { # routing: position and consistency matter most
"completeness": 0.25, "logical_consistency": 0.30,
"positional_accuracy": 0.30, "temporal_quality": 0.10,
"thematic_accuracy": 0.05,
},
"land_use": { # classification: thematic accuracy dominates
"completeness": 0.20, "logical_consistency": 0.15,
"positional_accuracy": 0.10, "temporal_quality": 0.15,
"thematic_accuracy": 0.40,
},
}
def composite(sub: "pd.DataFrame") -> "pd.DataFrame":
rows = []
for (run_id, layer), g in sub.groupby(["run_id", "layer_id"]):
weights = LAYER_WEIGHTS.get(layer)
if weights is None:
continue # an unweighted layer is not scored, not scored as zero
applicable = {d: w for d, w in weights.items() if d in set(g["dimension"])}
total_w = sum(applicable.values()) or 1
score = sum(
float(g.loc[g["dimension"] == d, "sub_score"].iloc[0]) * w
for d, w in applicable.items()) / total_w
rows.append({"run_id": run_id, "layer_id": layer,
"composite": round(score, 1),
"dimensions_scored": sorted(applicable),
"weight_coverage": round(total_w, 2)})
import pandas as pd
return pd.DataFrame(rows)
Verification: weight_coverage below 1.0 means some dimensions had no applicable rules and the weights were renormalised. Publish that figure — a layer scored on 60% of its intended weight is a partially measured layer, and the scorecard should say so rather than implying full coverage.
Step 4 — Grade, and cap on blockers
# scorecard/step4_grade.py
GRADE_BANDS = [(90, "A"), (80, "B"), (70, "C"), (60, "D"), (0, "F")]
BLOCKER_CAP = "C"
def grade(composite_score: float, blocker_count: int) -> dict:
letter = next(g for threshold, g in GRADE_BANDS if composite_score >= threshold)
capped = False
if blocker_count > 0 and letter in ("A", "B"):
letter, capped = BLOCKER_CAP, True
return {
"composite": round(composite_score, 1),
"grade": letter,
"capped_by_blockers": capped,
"blocker_count": int(blocker_count),
"explanation": (
f"capped at {BLOCKER_CAP}: {blocker_count} blocker-severity finding(s)"
if capped else "grade from the weighted composite"),
}
Verification: construct a case with an excellent composite and one blocker, and confirm it grades C with the explanation attached. This single rule prevents the most damaging scorecard failure — a green light on a layer that a downstream system will reject.
Step 5 — Publish with the drill-down attached
# scorecard/step5_publish.py
import pandas as pd
def scorecard_row(run_id: str, layer: str, graded: dict, sub: pd.DataFrame,
findings: pd.DataFrame, rule_set_version: str, features: int) -> dict:
top_rules = (findings[(findings["run_id"] == run_id) & (findings["layer_id"] == layer)]
.groupby("rule_id").size().sort_values(ascending=False).head(3))
return {
"run_id": run_id,
"layer_id": layer,
"rule_set_version": rule_set_version,
"features_validated": features,
**graded,
"sub_scores": {
r.dimension: {"score": r.sub_score, "rate_per_1k": round(r.rate_per_1k, 3)}
for r in sub.itertuples()
},
"top_contributing_rules": [
{"rule_id": rule, "findings": int(n)} for rule, n in top_rules.items()
],
}
Verification: the published row should let a reader reconstruct the composite by hand from the sub-scores and the weights. If they cannot, the scorecard has become a black box, and black-box scores get ignored the first time somebody disagrees with one.
Interpreting Results
| Pattern | What it means | Response |
|---|---|---|
| Composite falls, one sub-score falls | A specific dimension regressed | Read top_contributing_rules; usually one rule |
| Composite falls, all sub-scores fall | The denominator shrank, or a scope change | Check features_validated before investigating data |
| Grade capped repeatedly at C | Persistent blockers nobody is clearing | A backlog problem, not a measurement problem |
| Score improves after a rule change | Rules were relaxed, not data improved | Compare rule_set_version; annotate the chart |
weight_coverage below 1 |
Some dimensions have no applicable rules | Either add rules or reduce the claimed coverage |
| Score stable at 99 for months | Normalisation scale is too generous | Re-derive best and worst from recent history |
The most common misreading is treating a composite move of two or three points as meaningful. Normalised scores are noisy at that scale; look at the sub-scores and the underlying rate before acting. A useful convention is to publish the composite with a band — “82 ± 3 over the last five runs” — which stops people reacting to noise while still surfacing genuine movement.
Gotchas & Edge Cases
A dimension with no rules is not a perfect dimension. If a point layer has no topology rules, logical_consistency should be excluded from the weighting, not scored 100. The weight_coverage figure exists precisely to make that exclusion visible.
Historical normalisation embeds historical mediocrity. Scoring against your own past means a layer that has always been poor can score well while remaining poor in absolute terms. Publish the raw rate alongside the sub-score so absolute quality stays visible, and re-anchor the scale when a step change in quality is achieved.
Rule additions look like regressions. A new rule finds defects that were always there, and the score drops. This is correct behaviour and must be annotated, or the team learns that adding rules makes them look worse — which is how rule sets stop growing.
Weights must sum to something you can explain. Weights invented in a meeting and never revisited are the norm. Tie them to the layer’s stated purpose and re-review when that purpose changes, as part of the stewardship cycle in Data Stewardship Roles and Responsibilities.
Small layers produce wild scores. A layer with 200 features moves several points on a single finding. Suppress the score below a minimum denominator and show the raw counts instead.
One score per layer, not per file. Scoring individual files or partitions produces a distribution nobody can act on. Aggregate to the layer, and use the drill-down for locality.
When to Escalate
- A layer capped at C for several consecutive runs is a backlog escalation, not a measurement question — the blockers have an owner and are not being cleared.
- A composite that cannot be reconstructed from published components means the pipeline and the dashboard have diverged. Fix that before anyone makes a decision on the number.
- Weights that nobody will own indicate the layer has no clear declared purpose. That is a governance gap worth closing before the scorecard is published at all.
- Persistent disagreement about a dimension’s normalisation usually means two teams are measuring different denominators. Settle the denominator first; the scale argument disappears with it.
Frequently Asked Questions
How do I choose the dimension weights?
From what the layer is used for. An emergency routing network weights positional accuracy and logical consistency heavily and thematic accuracy lightly; a land-use layer does the reverse. Write the weights next to the layer's declared purpose, review them when the purpose changes, and version them — a scorecard whose weights drift silently cannot be compared over time.
Why cap the grade when a blocker fires?
Because averages hide blockers. A layer with one overlapping parcel and otherwise perfect metrics scores 98 and looks publishable, when in fact it has a defect that stops a downstream process. Capping the grade whenever any blocker is present keeps the composite honest without abandoning the average.
Should the scorecard compare layers against each other?
Cautiously. Layers differ in what is measurable — a point layer has no topology dimension — so a league table rewards layers with fewer applicable rules. Compare a layer against its own history first, and against peers only within the same layer type and rule coverage.
How do I stop the score becoming a target people game?
Publish the components and the raw rates beside the score, and review rule coverage as part of the scorecard. The easiest way to raise a score is to remove a rule, so any drop in the number of applicable rules should be as visible as a change in the score itself.
Related
- Spatial Data Quality Metrics and Reporting — denominators, rates and the metric set behind the scorecard
- Publishing Validation Dashboards with Grafana — rendering the scorecard and its drill-down
- Aligning Spatial Metadata with ISO 19157 — the dimension vocabulary the sub-scores use