Scoring POI Sensitivity for Location Datasets
To let a pipeline decide which stops to hide first, assign each point-of-interest a sensitivity score that combines how revealing its category is, how long the person stayed, and how rare visits there are — then suppress in descending score order until the release meets its risk budget.
Core Calculation permalink
Not every stop carries equal disclosure risk. A visit to a grocery store reveals almost nothing; a visit to a dialysis clinic, a domestic-violence shelter, or a place of worship can expose health status, victimization, or belief. A sensitivity score turns that intuition into a ranking a machine can act on, feeding directly into a privacy risk scoring framework for GIS.
Define the raw sensitivity of a stop i as a weighted product of three factors:
where:
- is the category weight of the POI category
c(i)the stop maps to; - is the dwell time at the stop and a reference dwell (a short visit); the logarithm keeps very long dwells from dominating;
- is the visit rarity, defined from the visit frequency (share of individuals who ever visit that POI) as ;
- tune how much dwell and rarity matter relative to category.
Normalize to a comparable zero-to-one scale with a min-max transform across the dataset:
The suppression rule is then simply: suppress every stop with , where the threshold θ is set by how much of the dataset your risk budget allows you to remove.
Category weights (illustrative tiers) permalink
| Tier | Example categories | Weight |
|---|---|---|
| Ordinary | supermarket, retail, park | 1.0 |
| Elevated | bar, political office, gym | 2.5 |
| Sensitive | clinic, pharmacy, place of worship | 6.0 |
| Highly sensitive | shelter, addiction service, home | 9.0 |
Worked example permalink
Take four stops, with min, , :
| Stop | Category | Dwell | Freq | Rarity | Raw | |
|---|---|---|---|---|---|---|
| A | supermarket | 1.0 | 20 min | 0.40 | 0.92 | 1.0·(1+0.5·ln(5))·(1+0.3·0.92) ≈ 2.14 |
| B | clinic | 6.0 | 45 min | 0.05 | 3.00 | 6.0·(1+0.5·ln(10))·(1+0.3·3.00) ≈ 17.8 |
| C | place of worship | 6.0 | 60 min | 0.08 | 2.53 | 6.0·(1+0.5·ln(13))·(1+0.3·2.53) ≈ 18.0 |
| D | shelter | 9.0 | 90 min | 0.01 | 4.61 | 9.0·(1+0.5·ln(19))·(1+0.3·4.61) ≈ 43.6 |
After min-max normalization the shelter stop D lands at , the clinic and worship stops near 0.37–0.38, and the supermarket at 0.0. With a budget to suppress the top 25% by score, only D is removed; tighten θ and B and C follow, while the innocuous supermarket visit A is never suppressed. The score has correctly spent scarce suppression on the most damaging disclosures.
Python Implementation permalink
The routine joins detected stops to a POI layer, computes the score, and returns a ranked suppression list. The stops are assumed to come from a stay-point detector; suppressing them is the enforcement step described in stop-location and POI suppression.
import numpy as np
import geopandas as gpd
import pandas as pd
# CRS: reproject both layers to a projected metric CRS (UTM zone 33N,
# EPSG:32633) so the nearest-POI join uses true metre distances.
CRS_METRIC = "EPSG:32633"
CATEGORY_WEIGHTS = {
"supermarket": 1.0, "retail": 1.0, "park": 1.0,
"bar": 2.5, "gym": 2.5, "political_office": 2.5,
"clinic": 6.0, "pharmacy": 6.0, "worship": 6.0,
"shelter": 9.0, "addiction_service": 9.0, "home": 9.0,
}
UNKNOWN_WEIGHT = 6.0 # conservative: absence of evidence != evidence of safety
def score_poi_sensitivity(
stops: gpd.GeoDataFrame,
pois: gpd.GeoDataFrame,
max_join_m: float = 40.0,
tau0_min: float = 5.0,
alpha: float = 0.5,
gamma: float = 0.3,
threshold: float = 0.75,
) -> gpd.GeoDataFrame:
"""
Score each stop's disclosure sensitivity and flag suppression candidates.
Args:
stops: GeoDataFrame with 'geometry' (points) and 'dwell_min'.
pois: GeoDataFrame with 'geometry' and 'category'.
max_join_m: max metres to accept a nearest-POI match; beyond this the
stop is treated as unknown-category (scored conservatively).
tau0_min: reference dwell for the dwell term.
alpha, gamma: dwell and rarity weightings.
threshold: normalized-score cutoff at/above which to suppress.
Returns:
stops with 'category', 'sensitivity', 'suppress' columns, sorted by
descending sensitivity (the ranked suppression list).
"""
assert stops.crs == pois.crs == CRS_METRIC, "Reproject both layers to EPSG:32633"
# 1. Nearest-POI spatial join to attach a category to each stop.
joined = gpd.sjoin_nearest(
stops, pois[["category", "geometry"]],
how="left", max_distance=max_join_m, distance_col="poi_dist_m",
)
# Deduplicate ties from sjoin_nearest (keep the closest single match).
joined = joined.sort_values("poi_dist_m").loc[~joined.index.duplicated(keep="first")]
joined["category"] = joined["category"].fillna("unknown")
# 2. Category weight (unknown -> conservative high default).
w_c = joined["category"].map(CATEGORY_WEIGHTS).fillna(UNKNOWN_WEIGHT)
# 3. Visit rarity: rarer POIs -> smaller equivalence class -> more identifying.
freq = joined["category"].map(joined["category"].value_counts(normalize=True))
rarity = -np.log(freq.clip(lower=1e-6))
# 4. Dwell term (log-damped so long stays don't swamp the score).
dwell_term = 1.0 + alpha * np.log1p(joined["dwell_min"] / tau0_min)
# 5. Combine, then min-max normalize to [0, 1].
raw = w_c * dwell_term * (1.0 + gamma * rarity)
span = raw.max() - raw.min()
joined["sensitivity"] = (raw - raw.min()) / span if span > 0 else 0.0
# 6. Rank + flag. Suppression here means "hide before release".
joined["suppress"] = joined["sensitivity"] >= threshold
return joined.sort_values("sensitivity", ascending=False)
Verification permalink
Confirm the ranking behaves and the suppression budget is respected before wiring the output into a release pipeline.
ranked = score_poi_sensitivity(stops, pois, threshold=0.75)
# Verification checklist:
# [ ] every sensitivity is a normalized value in [0, 1]
assert ranked["sensitivity"].between(0, 1).all()
# [ ] the most sensitive category present outranks the least sensitive
top_cat = ranked.iloc[0]["category"]
assert CATEGORY_WEIGHTS.get(top_cat, UNKNOWN_WEIGHT) >= 6.0, "High-tier stop should rank first"
# [ ] suppression set size stays within the risk budget (e.g. <= 30% of stops)
assert ranked["suppress"].mean() <= 0.30, "Suppression budget exceeded; raise threshold"
# [ ] no unknown-category stop was silently scored as safe
assert (ranked.loc[ranked["category"] == "unknown", "sensitivity"] > 0).all()
print(ranked[["category", "dwell_min", "sensitivity", "suppress"]].head())
The four checks catch the failure modes that matter: an un-normalized score, an inverted ranking, a runaway suppression set that destroys utility, and — most importantly — an unknown-category stop leaking through as if it were safe.
Edge Cases & Adjustments permalink
- Mixed-use buildings. A single address may host a pharmacy above a café. A nearest-POI join picks one category and understates risk. Where the join returns multiple POIs within
max_join_m, take the maximum category weight, not the closest, so the sensitive tenant sets the floor. - Unknown category. Default unknown stops toward the sensitive tier and flag them for review; treating them as ordinary is the most common silent leak. Better still, infer a provisional weight from the categories of neighboring POIs before defaulting.
- Inference from neighbors. Even a suppressed stop can be reconstructed if the surrounding stops pin it geometrically. When a high-score stop is removed, check whether its position is implied by adjacent retained points, and generalize those neighbors if so.
- Population-relative rarity. Visit frequency must be computed over the release population, not a sample; a place that looks rare in a 1% extract may be common overall, and vice versa. Recompute rarity against the exact cohort you publish.
FAQ permalink
Why weight by category rather than treating all stops equally?
Because disclosure harm depends on what the place reveals. A supermarket visit exposes little; a visit to an oncology clinic, a shelter, or a place of worship can disclose health, victimization, or belief. Category weighting directs a limited suppression budget to the disclosures that hurt most.
Why include visit rarity in the score?
Rarity is an identifiability signal. A POI visited by few people yields a small equivalence class, so one visit there is far more re-identifying than a visit somewhere everyone goes. Weighting by inverse visit frequency raises the score of exactly the stops that narrow an attacker’s candidate set, complementing a formal privacy risk matrix for municipal GIS.
How should unknown-category stops be scored?
High, not low. An unknown category is an absence of evidence of safety, not evidence of absence of harm. Assign a weight near the sensitive tier or infer one from nearby POIs, and flag the stop for human review before release.
Does a high score mean the stop must be deleted?
No. Suppression is one response; spatial generalization, timestamp cloaking, and region merging are others. The score sets priority order — what you do at each level depends on the utility you must keep and the applicable k-anonymity or regulatory threshold.
Related
- Privacy Risk Scoring Frameworks for GIS — the parent framework this score plugs into
- Building a Privacy Risk Matrix for Municipal GIS — placing POI scores on a likelihood-by-impact grid
- Stop-Location & POI Suppression — enforcing the ranked suppression list on trajectories
- Spatial Privacy Fundamentals & Threat Modeling — where POI sensitivity fits the overall threat model