Measuring Utility Loss After Geomasking
A geomask is chosen for its privacy guarantee and lived with for its analytic cost, and the second is usually estimated by intuition. This guide, part of Geographic Masking Techniques, sets out a measurement procedure that produces a number for each analysis a released file is meant to support.
Problem Context & Constraints
Utility loss is not a single quantity. The same mask that leaves a county rate map essentially untouched can destroy a short-distance point-pattern estimate, and reporting “median displacement 500 m” tells a downstream analyst nothing about whether their specific question survives.
The measurement is straightforward in principle: run the intended analysis on the true data and on the masked data, and compare. Two constraints complicate it.
The comparison must happen inside the trusted environment, because it requires the true locations. That means utility measurement is a data-holder activity, and its output — the utility report — is what travels with the released file rather than the comparison itself.
And the mask is stochastic, so a single realisation is not the answer. Every mask draw produces a different displaced file and a different utility outcome, so the measurement is a distribution over draws, and reporting a single run understates the variability the analyst will experience.
Prerequisites
- The true point locations, inside the trusted environment
- A masking implementation with a settable radius and a seed
- A written list of the analyses the released file is meant to support
python3.11,geopandas1.0.1,numpy1.26.4,pandas2.2.2,esda2.6.0
Utility measurement is a data-holder activity, and being explicit about what crosses the boundary is part of the design:
Step-by-Step Solution
# Measure utility retention across mask draws, per analysis.
# Pinned: geopandas==1.0.1, numpy==1.26.4, pandas==2.2.2, scipy==1.13.1
import logging
import numpy as np
import pandas as pd
import geopandas as gpd
from scipy.stats import spearmanr
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("mask.utility")
def areal_utility(truth: gpd.GeoDataFrame, masked: gpd.GeoDataFrame,
areas: gpd.GeoDataFrame, id_col: str = "geoid") -> float:
"""Spearman correlation of per-area counts. 1.0 = the map is unchanged."""
def counts(pts):
j = gpd.sjoin(pts, areas[[id_col, "geometry"]], how="inner", predicate="within")
return j.groupby(id_col).size()
a, b = counts(truth), counts(masked)
idx = a.index.union(b.index)
rho, _ = spearmanr(a.reindex(idx, fill_value=0), b.reindex(idx, fill_value=0))
return float(rho)
def assignment_utility(truth: gpd.GeoDataFrame, masked: gpd.GeoDataFrame,
facilities: gpd.GeoDataFrame) -> float:
"""Share of cases assigned to the same nearest facility before and after."""
t = gpd.sjoin_nearest(truth, facilities[["fac_id", "geometry"]], how="left")["fac_id"]
m = gpd.sjoin_nearest(masked, facilities[["fac_id", "geometry"]], how="left")["fac_id"]
return float((t.to_numpy() == m.to_numpy()).mean())
def utility_over_draws(truth: gpd.GeoDataFrame, mask_fn, analyses: dict,
radii, n_draws: int = 20, seed: int = 42) -> pd.DataFrame:
"""Utility per analysis per radius, across independent mask realisations.
Reporting the mean across draws AND the spread matters: an analyst receives
one realisation, not the mean, so the lower quantile is what they should
plan against."""
rng = np.random.default_rng(seed)
rows = []
for r in radii:
for d in range(n_draws):
masked = mask_fn(truth, radius=r, seed=int(rng.integers(1 << 31)))
for name, fn in analyses.items():
rows.append({"radius": r, "draw": d, "analysis": name,
"utility": float(fn(truth, masked))})
df = pd.DataFrame(rows)
summ = (df.groupby(["analysis", "radius"])["utility"]
.agg(mean="mean", p10=lambda s: float(np.percentile(s, 10))).reset_index())
log.info("utility summary:\n%s", summ.to_string(index=False))
return summ
def choose_radius(summary: pd.DataFrame, requirements: dict) -> dict:
"""Largest radius meeting every stated utility requirement.
Larger radius means stronger privacy, so the correct choice is the largest
that still satisfies the analyses the file must support."""
ok = None
for r in sorted(summary["radius"].unique()):
at_r = summary.loc[summary["radius"] == r].set_index("analysis")["p10"]
if all(at_r.get(a, 0.0) >= need for a, need in requirements.items()):
ok = r
if ok is None:
raise ValueError("no radius in the sweep meets every requirement; relax a "
"requirement or release an areal product instead")
log.info("selected radius %g m", ok)
return {"radius": ok}
Choosing the largest radius that still meets the requirements inverts the usual habit. Displacement is the privacy protection, so the goal is to give away as much location precision as the analyses can tolerate, not as little as the analyst will accept.
Validation & Edge Cases
1. Use the tenth percentile across draws, not the mean. The analyst receives one realisation. Planning against the mean means half of released files perform worse than the stated utility:
INFO utility summary:
analysis radius mean p10
county_rate 250 0.998 0.996
county_rate 1000 0.991 0.986
tract_rate 250 0.981 0.972
tract_rate 1000 0.884 0.851
nearest_facility 1000 0.612 0.579
INFO selected radius 500 m
2. Include an analysis you expect to fail. A utility table where everything passes is not informative about where the limit is. Including a short-distance point-pattern estimate shows the boundary and pre-empts a user attempting it.
3. Re-measure per release, not once. Utility depends on the point density and the geography, so a radius validated on an urban county is not validated for a rural one, and the same programme may need different radii in different areas — which is itself an argument for the density-adaptive masking described in the parent guide.
4. Do not let the analyst choose the radius. The utility table is theirs to read; the radius is a disclosure-control decision belonging to the data holder, informed by the spatial k-anonymity validation rather than by analytic convenience.
5. Publish the table even when it is unflattering. A file that supports county rates and nothing else is useful if that is stated, and actively harmful if the recipient assumes otherwise.
6. Measure utility on the population that matters, not the average. A mask that preserves 90% of utility overall can preserve 98% in dense areas and 40% in sparse ones, and the sparse areas are usually the ones an equity analysis is about. Break the utility scores down by the same strata used for the privacy validation, so the two halves of the trade-off are reported on the same partition of the map.
7. Include the analyses the recipient will actually run, not the ones you would run. Ask. A research partner planning a case-control study with distance-to-source exposure has a very different utility requirement from a programme office producing a county rate table, and a utility report built around the wrong analyses is reassuring about the wrong thing.
8. Re-measure when the facility layer changes. Nearest-facility utility depends on where the facilities are, so a clinic opening or closing changes the assignment agreement without any change to the mask. Where a released file supports assignment analyses, its utility statement is tied to the facility layer that was current when it was measured, and that layer’s vintage belongs in the report.
9. Treat the utility table as part of the release, not as documentation. It has a version, it is tied to a specific mask seed and facility layer, and it becomes wrong when either changes. Giving it the same version identifier as the data file, and updating both together, keeps the pair coherent in a way that a separate methods note reliably does not.
Reporting the mean across mask draws flatters the file the recipient actually gets, and the gap is worth showing:
Compliance Notes
- The utility measurement runs on true data and its intermediate outputs are as sensitive as the source file. Only the summary table leaves the trusted environment.
- Record the mask seed with the release, since a re-run must reproduce the released file exactly for any later audit.
- Publish the utility table alongside the file, naming the analyses tested and the retention achieved, so the recipient does not have to guess.
- Re-run the spatial k-anonymity validation whenever the radius changes, because utility and privacy move in opposite directions and only one of them is being optimised here.
Related Topics
- Geographic Masking Techniques — the parent guide, covering the masking methods this measures.
- Validating Geomasks With Spatial K-Anonymity — the privacy half of the same trade-off.
- Donut Geomasking for Patient Address Points — the implementation whose radius this procedure selects.