Running Membership Inference Attacks on Synthetic Geodata

A synthetic point dataset leaks its source when a record used to train the generator is measurably easier to reconstruct than one the generator never saw, and a shadow-model membership inference attack turns that asymmetry into a single auditable number.

Part of Adversarial Privacy Testing for Synthetic Spatial Data: that page frames the adversary catalogue — membership inference, attribute inference, reconstruction — as a suite of red-team checks a release must survive; this page implements the first of them end to end, from shadow-model construction through an attack-AUC threshold you can wire into continuous integration.

Root Cause: Why Synthetic Geodata Reveals Membership

A membership inference attack (MIA) is a binary classifier that, given a candidate record and access to a generator’s synthetic output, predicts whether that candidate was in the generator’s training set. The attack works whenever the generator reproduces its training records more faithfully than fresh ones — the textbook signature of overfitting. Spatial generators are unusually exposed to this because geographic data is heavy-tailed: most points sit in dense clusters, but a heavy-tailed set of isolated locations (a single rural dwelling, a lone offshore platform) carries almost no neighbours to blend into. A model that has seen such an outlier tends to emit a synthetic point almost coincident with it, so the distance from a candidate to its nearest synthetic neighbour is a near-optimal membership signal.

The attack’s power is summarized by the ROC AUC over a balanced set of member and non-member probes. An AUC of 0.5 means the release reveals nothing about membership; an AUC of 1.0 means every member is perfectly identifiable. The membership advantage is

Adv=2(AUC0.5)=TPRFPR.\mathrm{Adv} = 2\,(\mathrm{AUC} - 0.5) = \mathrm{TPR} - \mathrm{FPR}.

This is exactly the quantity a formal privacy guarantee bounds. If the generator is (ε,δ)(\varepsilon, \delta)-differentially private, then for every attack decision threshold the true- and false-positive rates obey TPReεFPR+δ\mathrm{TPR} \le e^{\varepsilon}\,\mathrm{FPR} + \delta, which caps how far the ROC curve can bow above the diagonal and therefore caps AUC. Measuring attack AUC empirically is the black-box counterpart to that promise: it detects leakage whether it came from a missing privacy mechanism, an exhausted budget, or plain overfitting. The mechanisms that push AUC back toward 0.5 are catalogued in Differential Privacy Mechanisms Compared for Spatial Data, and the same proximity signal the attack exploits is what the utility-side realism metrics evaluation rewards — which is precisely the tension a privacy gate must arbitrate.

Shadow-model membership inference attack construction and the attack-AUC gate Left: a vertical pipeline of five stages. Stage one holds real records labelled members (in the target training set) and non-members (held out). Stage two trains shadow generators that replicate the target generator on splits whose membership is known. Stage three computes a per-record attack feature, the minimum distance from the record to the synthetic point cloud measured in metres in an equal-area projection. Stage four is an attack classifier emitting the probability that a record was a member. Stage five scores the target and reduces the result to a single attack AUC. An arrow leads from the pipeline into the right panel. Right: a receiver-operating-characteristic plot with false-positive rate on the horizontal axis and true-positive rate on the vertical axis. A dashed diagonal marks random guessing at AUC 0.5. A solid primary curve bows above the diagonal and is labelled AUC equals 0.79, above the gate. A shaded band along the diagonal marks the passing region where AUC is at most 0.60. Shadow-model attack construction Attack ROC → AUC gate Real records members (in-train) + non-members (held-out) Shadow generators replicate target G on known splits Attack feature min distance to synthetic cloud (metres) Attack classifier P(record was a member) Score target → attack AUC False positive rate True positive rate 0 1 AUC = 0.79 advantage 0.58 → FAIL random (0.5) pass band: AUC ≤ 0.60
The shadow models teach an attack classifier the shape of membership leakage; scoring the target release with it collapses the whole question to one point on an ROC curve and one AUC to gate on.

Minimal Reproducer: Confirm Members Sit Closer to the Synthetic Cloud

Before building the full attack, confirm the signal exists with the crudest possible distinguisher: the nearest-neighbour distance from each probe to the synthetic points. If members are systematically closer than non-members, a threshold-free AUC will already exceed 0.5. Measure distance in metres, never degrees — a degree of longitude is not a fixed distance, and comparing raw EPSG:4326 coordinates biases the attack by latitude.

python
# requirements.txt — pin majors; let patch releases float
numpy==1.26.*
scipy==1.11.*             # cKDTree for nearest-neighbour distance
geopandas==0.14.*         # CRS-aware reprojection to metres
scikit-learn==1.4.*       # attack classifier + roc_auc_score
python
import numpy as np
import geopandas as gpd
from scipy.spatial import cKDTree
from sklearn.metrics import roc_auc_score

METRIC_CRS = "EPSG:6933"   # global equal-area; distances come out in metres

def nn_distance(probes: gpd.GeoDataFrame, synthetic: gpd.GeoDataFrame) -> np.ndarray:
    p = probes.to_crs(METRIC_CRS)
    s = synthetic.to_crs(METRIC_CRS)
    tree = cKDTree(np.c_[s.geometry.x, s.geometry.y])
    dist, _ = tree.query(np.c_[p.geometry.x, p.geometry.y], k=1)
    return dist                                   # metres to nearest synthetic point

# Small = closer = more likely a member, so score with the negated distance.
score = np.concatenate([-nn_distance(members, synth), -nn_distance(nonmembers, synth)])
label = np.concatenate([np.ones(len(members)), np.zeros(len(nonmembers))])
print(f"crude NN-distance AUC = {roc_auc_score(label, score):.3f}")
# crude NN-distance AUC = 0.71   # already leaking before any shadow modelling

Any AUC meaningfully above 0.5 here means the release is distinguishable. The shadow-model attack below only sharpens that estimate and makes it robust to the attacker not knowing the target’s own splits.

Fix: A Shadow-Model Attack with a Calibrated AUC Gate

The reproducer cheats: it assumes you already know which target records were members. A realistic auditor does not. Shadow models remove that assumption — you train replicas of the target generator on reference data whose membership you control, let the attack classifier learn the mapping from proximity features to in/out labels on those replicas, then apply the frozen classifier to the target. Provide the generator as a callable so the audit stays generator-agnostic; the same harness scores a Poisson sampler, a copula model, or a DP-SGD-trained coordinate GAN.

Step 1 — Per-record attack features. Reduce each probe to proximity features against the synthetic cloud. Minimum distance captures memorization; mean-of-k captures local density so the classifier can calibrate distance by how crowded the neighbourhood is.

python
def membership_features(probes: gpd.GeoDataFrame, synthetic: gpd.GeoDataFrame,
                        k: int = 5) -> np.ndarray:
    p = probes.to_crs(METRIC_CRS)
    s = synthetic.to_crs(METRIC_CRS)
    tree = cKDTree(np.c_[s.geometry.x, s.geometry.y])
    dist, _ = tree.query(np.c_[p.geometry.x, p.geometry.y], k=k)   # metres, k nearest
    return np.column_stack([dist.min(axis=1), dist.mean(axis=1)])  # memorization + density

Step 2 — Train shadow generators on known splits. Each shadow run shuffles the reference set into an in-half and an out-half, fits a fresh generator on the in-half only, then labels features so the classifier sees both sides of the boundary. Seed every shuffle and every generator from an explicit integer so the audit is reproducible.

python
import numpy as np, geopandas as gpd
from sklearn.ensemble import GradientBoostingClassifier

def train_attack(reference: gpd.GeoDataFrame, fit_generator, *,
                 n_shadows: int = 16, seed: int = 20260603) -> GradientBoostingClassifier:
    rng = np.random.default_rng(seed)
    idx = np.arange(len(reference))
    X, y = [], []
    for _ in range(n_shadows):
        rng.shuffle(idx)
        half = len(idx) // 2
        members = reference.iloc[idx[:half]]       # this shadow's training set ("in")
        nonmembers = reference.iloc[idx[half:]]    # held out from this shadow ("out")
        synth = fit_generator(members, seed=int(rng.integers(1 << 32)))
        X.append(membership_features(members, synth));    y.append(np.ones(half))
        X.append(membership_features(nonmembers, synth)); y.append(np.zeros(len(idx) - half))
    clf = GradientBoostingClassifier(random_state=seed)
    clf.fit(np.vstack(X), np.concatenate(y))
    return clf                                     # frozen attack model

Step 3 — Score the target release. Apply the frozen classifier to the target’s true members and a disjoint held-out set, then take the ROC AUC of the member-probability output.

python
from sklearn.metrics import roc_auc_score

def attack_auc(clf, target_members, target_nonmembers, target_synth) -> float:
    Xm = membership_features(target_members, target_synth)
    Xn = membership_features(target_nonmembers, target_synth)
    X = np.vstack([Xm, Xn])
    y = np.concatenate([np.ones(len(Xm)), np.zeros(len(Xn))])
    scores = clf.predict_proba(X)[:, 1]            # P(member)
    return float(roc_auc_score(y, scores))

Step 4 — Turn AUC into a pass/fail threshold. A single AUC is noisy on small probe sets, so bootstrap a confidence interval and gate on the upper bound — the release must be safe even on an unlucky draw. The mechanisms that pull that bound down live in the privacy-preserving generation frameworks stage; this gate is what proves they worked.

python
def bootstrap_auc(clf, tm, tn, tsyn, *, n_boot: int = 500, seed: int = 7) -> tuple[float, float]:
    rng = np.random.default_rng(seed)
    Xm, Xn = membership_features(tm, tsyn), membership_features(tn, tsyn)
    X = np.vstack([Xm, Xn]); y = np.concatenate([np.ones(len(Xm)), np.zeros(len(Xn))])
    s = clf.predict_proba(X)[:, 1]
    aucs = [roc_auc_score(y[i], s[i]) for i in
            (rng.integers(0, len(y), len(y)) for _ in range(n_boot))]
    return float(np.mean(aucs)), float(np.quantile(aucs, 0.95))   # mean, 95% upper bound

Verification Step: Gate the Release in CI

Wire the upper confidence bound as a pytest assertion so an overfit generator cannot reach a published dataset. Pick the threshold to match the promised guarantee: 0.60 (advantage 0.20) is a conventional starting gate, tightened toward 0.55 for regulated releases whose (ε,δ)(\varepsilon, \delta) target implies a smaller admissible advantage.

python
ATTACK_AUC_MAX = 0.60      # advantage <= 0.20; tighten for a stricter epsilon target

def test_membership_leakage_within_budget(attack_clf, target_members,
                                           target_nonmembers, target_synth):
    mean_auc, upper = bootstrap_auc(attack_clf, target_members,
                                    target_nonmembers, target_synth)
    advantage = 2 * (mean_auc - 0.5)
    assert upper <= ATTACK_AUC_MAX, (
        f"MIA AUC 95% upper bound {upper:.3f} (mean {mean_auc:.3f}, "
        f"advantage {advantage:.3f}) exceeds gate {ATTACK_AUC_MAX}"
    )

Gate on the bootstrap upper bound, not the point estimate: a mean AUC of 0.58 with a 0.95 upper bound of 0.63 fails, because on a realistic adversary’s sample the release could well be distinguishable. The point estimate alone routinely passes a leaking release when probe counts are low.

Edge Cases & Gotchas

Null-island and sentinel duplicates. Records geocoded to (0, 0) — the classic failed-geocode sentinel — collapse to a single point that both members and non-members sit on top of, giving a nearest-neighbour distance of zero for everyone and a meaningless AUC near 0.5 that masks real leakage elsewhere. Strip sentinel coordinates and exact duplicates before building features, and log how many you dropped so a flood of (0, 0) rows cannot silently launder a leaking release.

Antimeridian wrap in the metric projection. EPSG:6933 wraps at ±180° longitude, so two physically adjacent points straddling the seam land at opposite ends of the projected plane and the KDTree reports a nearest-neighbour distance of tens of thousands of kilometres. For datasets crossing the Pacific, reproject into a region-local CRS whose central meridian sits inside the extent, or run the attack per-tile so no tile spans the seam.

Worst-case versus average leakage. AUC is an average over probes; a release can post an AUC of 0.51 yet still reproduce one isolated dwelling almost exactly. If your threat model cares about the single most-exposed record — it usually should for spatial outliers — supplement the AUC gate with a per-record check on the smallest member nearest-neighbour distance, not just the aggregate curve.

Frequently Asked Questions

What attack AUC threshold should I use for the gate?
Start at 0.60, which corresponds to a membership advantage of 0.20, and tighten toward 0.55 for releases governed by a strict epsilon target. Derive the ceiling from the promised guarantee rather than convention: an (epsilon, delta)-DP mechanism bounds the true-positive rate by e-to-the-epsilon times the false-positive rate plus delta, which caps the admissible AUC. Always gate on the bootstrap upper bound, not the point estimate.
Do I need access to the target generator's weights to run this attack?
No. This is a black-box attack: it needs only the synthetic output and the ability to train shadow replicas of the same generator on reference data you control. That is deliberately the weakest realistic adversary, so a release that survives it survives most stronger ones. If you do have white-box access, add gradient- or likelihood-based features to the same harness for a tighter estimate.
My AUC is near 0.5 but one specific record was reproduced almost exactly. Is the release safe?
Not necessarily. AUC averages over all probes, so a single memorized outlier barely moves it while still constituting a real disclosure. If your threat model cares about the most-exposed individual, add a worst-case check on the minimum member nearest-neighbour distance and gate on that separately. Spatial outliers are exactly the records this failure hits hardest.
How is membership inference different from attribute inference?
Membership inference asks whether a specific record was in the training set; attribute inference asks whether a sensitive attribute of a known record can be recovered from the release. They share features and shadow-model machinery but answer different questions and warrant separate gates. Passing membership inference does not guarantee attribute privacy, which is why the adversarial testing suite runs both.