Choosing K for Rare Disease Point Releases
This guide, part of Spatial K-Anonymity for Health Microdata, is about the single parameter that decides both how private and how useful a masked point release is.
Problem Context & Constraints
Spatial k-anonymity requires that each released case location could plausibly belong to at least k households or people. With geomasking, that means each case is displaced far enough that at least k residential addresses lie within the area it could have come from. A higher k gives stronger protection and larger displacement; a lower k keeps points closer to where they belong and preserves more spatial detail for analysis.
Common defaults — k of 5, 10 or 20 — come from tabular disclosure practice and from studies of common conditions. They transfer poorly to rare diseases, for two opposite reasons. Rarity raises risk. For a disease with a dozen cases a year statewide, knowing that a masked point lies in a neighbourhood already narrows the possibilities sharply; a neighbour who knows someone was hospitalised with the condition needs little more. Rarity also raises cost. The analyses rare-disease programs run — cluster investigation around a suspected environmental source, for instance — depend on fine spatial detail, and a large k can displace the few cases so far that a real cluster disappears.
Choosing k is therefore a trade-off to be made explicitly for each release, informed by three things: the number of cases and their spatial spread, the attacker’s likely knowledge, and the analysis the release must support, measured by how much the masked data distort the result that matters.
Prerequisites
python3.11numpy1.26.4,pandas2.2.2,geopandas0.14.4,scipy1.13.1- Geocoded case points and a residential address point layer (or block-level household counts) in a projected metric coordinate reference system
- A geomasking routine that accepts a target k, such as the donut method in Donut Geomasking for Patient Address Points
- The analysis the release must support, stated in advance: a cluster test, a distance-to-source analysis or a density surface
Step-by-Step Solution
For a grid of k values, mask the data many times, measure protection as the chance a local attacker can single out a case, measure utility as the chance the target analysis still gives the right answer, and choose the smallest k that meets the protection requirement while keeping utility above a floor.
# Choose k for a rare-disease point release by simulating protection and utility.
# Pinned: numpy==1.26.4, pandas==2.2.2, geopandas==0.14.4, scipy==1.13.1
import logging
import numpy as np
import pandas as pd
from scipy.spatial import cKDTree
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("choose_k")
SEED = 20260918
def mask_to_k(cases_xy: np.ndarray, homes_xy: np.ndarray, k: int, rng) -> np.ndarray:
"""Donut mask: displace each case to a random point whose neighbourhood,
out to the kth-nearest home, contains at least k homes."""
tree = cKDTree(homes_xy)
r_k = tree.query(cases_xy, k=k)[0][:, -1] # distance to kth home
r_min = 0.5 * r_k
theta = rng.uniform(0, 2 * np.pi, len(cases_xy))
dist = np.sqrt(rng.uniform(r_min ** 2, r_k ** 2))
return cases_xy + np.column_stack([dist * np.cos(theta), dist * np.sin(theta)])
def local_attacker_success(true_xy, masked_xy, known_share: float, rng) -> float:
"""Attacker knows a random share of case households (e.g. neighbours' gossip)
and guesses the nearest known case household to each masked point."""
n = len(true_xy)
known = rng.random(n) < known_share
if not known.any():
return 0.0
tree = cKDTree(true_xy[known])
_, idx = tree.query(masked_xy[known])
return float(np.mean(idx == np.arange(known.sum())))
def cluster_detected(masked_xy, source_xy, radius_m: float, expected: float) -> bool:
"""Utility: is the excess of cases within radius of a suspected source still visible?"""
obs = int((np.hypot(*(masked_xy - source_xy).T) <= radius_m).sum())
return obs >= expected * 2.0 and obs >= 3
def sweep(true_xy, homes_xy, source_xy, radius_m, expected, ks=(5, 10, 15, 20, 30, 50),
reps: int = 200, known_share: float = 0.3) -> pd.DataFrame:
rng = np.random.default_rng(SEED)
rows = []
for k in ks:
succ, det, disp = [], [], []
for _ in range(reps):
m = mask_to_k(true_xy, homes_xy, k, rng)
succ.append(local_attacker_success(true_xy, m, known_share, rng))
det.append(cluster_detected(m, source_xy, radius_m, expected))
disp.append(np.median(np.hypot(*(m - true_xy).T)))
rows.append({"k": k, "attacker_success": np.mean(succ),
"cluster_detected": np.mean(det), "median_disp_m": np.mean(disp)})
df = pd.DataFrame(rows)
log.info("sweep n_cases=%d:\n%s", len(true_xy), df.round(3).to_string(index=False))
return df
def choose(df: pd.DataFrame, max_attack: float = 0.05, min_utility: float = 0.8) -> int | None:
ok = df[(df.attacker_success <= max_attack) & (df.cluster_detected >= min_utility)]
if ok.empty:
log.warning("no k meets both targets; release aggregated counts instead")
return None
return int(ok["k"].min())
The attacker model is deliberately concrete. It assumes the attacker already knows which households have a case for some share of cases — the realistic threat for rare diseases, where neighbours and relatives often know — and asks whether the masked points let them match those known cases to released records, which would reveal the other attributes in the release. A 30 percent known share is a reasonable starting assumption for small communities; use a higher value where the disease carries visible signs or requires frequent treatment.
The utility test should mirror what the release is for. A release meant for cluster investigation around a suspected source measures whether the excess near the source survives masking. A release meant for kernel density maps would measure how far the masked density surface departs from the true one.
Validation & Edge Cases
No k satisfies both targets. With very few cases, any k large enough to protect them may destroy the analysis. That is a finding, not a failure: release aggregated counts at a geography that meets tabular rules, or provide the point data to a named researcher under a data use agreement in a secure enclave rather than as a masked file.
Rural sparsity. In rural areas the kth-nearest household can be kilometres away, so the same k produces much larger displacement than in towns. That is correct — the protection is the same — but the utility loss concentrates in rural areas. Report median displacement by urbanicity so users know where the data are least precise.
Institutional addresses. Cases in nursing homes, prisons or group quarters share one address with many residents. Counting households there by address points undercounts people, so k is overstated. Use population counts for group quarters rather than address counts.
Repeated releases. Masking the same cases independently in two releases lets an attacker average the two masked locations and approach the true one. Mask once and reuse the masked locations, or release each case only once.
Attributes that re-identify. Point location is only one quasi-identifier. Age, sex, onset date and race, released alongside a masked point, can identify a case even when k is large. Choose k together with the attribute generalisation, and test the attacker model with the attributes that will be released.
Choosing the targets. The 5 percent attacker and 80 percent utility thresholds are policy choices. Set them with the review board before running the sweep, so the chosen k follows from agreed targets rather than the targets being adjusted to fit a preferred k.
Changing case counts. A k chosen for a year with 14 cases may not hold for a year with 40, or with 6. Rerun the sweep for each release rather than carrying a k forward, since both the protection and the utility curves move with the number and spread of cases.
INFO choose_k sweep n_cases=14:
k attacker_success cluster_detected median_disp_m
5 0.181 0.960 91.2
10 0.071 0.930 139.8
20 0.029 0.845 238.4
50 0.009 0.510 507.1
INFO choose_k chosen k=20
Compliance Notes
Record the case count, the household source, the masking method, the attacker model and its known share, the utility test and its parameters, the targets and who set them, the full sweep results and the chosen k, together with median displacement by urbanicity.
Rare-disease point data are among the highest-risk products a program can release. The chosen k, the sweep and the attacker assumptions belong in the residual risk statement for the release, and the review board should see the sweep, not only the chosen value, so it can judge the margin between the chosen k and the point where protection or utility fails.
Related Topics
- Spatial K-Anonymity for Health Microdata — the parent guide.
- Enforcing Spatial K-Anonymity on Case Point Releases — checking the chosen k is actually achieved.
- Measuring Utility Loss After Geomasking — broader utility metrics for the sweep.