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:

Si=wc(i)(1+αlog(1+τiτ0))(1+γri)S_i = w_{c(i)} \cdot \bigl(1 + \alpha \log(1 + \tfrac{\tau_i}{\tau_0})\bigr) \cdot \bigl(1 + \gamma\, r_i\bigr)

where:

  • wc(i)w_{c(i)} is the category weight of the POI category c(i) the stop maps to;
  • τi\tau_i is the dwell time at the stop and τ0\tau_0 a reference dwell (a short visit); the logarithm keeps very long dwells from dominating;
  • rir_i is the visit rarity, defined from the visit frequency fif_i (share of individuals who ever visit that POI) as ri=log(fi)r_i = -\log(f_i);
  • α,γ0\alpha, \gamma \ge 0 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:

S~i=SiminjSjmaxjSjminjSj\tilde{S}_i = \frac{S_i - \min_j S_j}{\max_j S_j - \min_j S_j}

The suppression rule is then simply: suppress every stop with S~iθ\tilde{S}_i \ge \theta, 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 wcw_c
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 τ0=5\tau_0 = 5 min, α=0.5\alpha = 0.5, γ=0.3\gamma = 0.3:

Stop Category wcw_c Dwell τ\tau Freq ff Rarity r=lnfr=-\ln f Raw SS
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 S~=1.0\tilde{S} = 1.0, 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.

Sensitivity score composition and suppression cutoff Three inputs — category weight, dwell time, and visit rarity — combine into a single sensitivity score, which is normalized and compared against a threshold to produce a ranked suppression list. category weight dwell time visit rarity × normalize to [0,1] rank & suppress score ≥ θ
Three inputs multiply into one score; normalization and a single threshold turn it into an ordered suppression list.

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

Back to Privacy Risk Scoring Frameworks for GIS