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

Attacker score distributions for members and non-members, with a 1% false-positive operating point Two histograms are drawn over the same horizontal score axis. The non-member distribution is centred at zero; the member distribution is shifted to the right by about six-tenths of a standard deviation, because the generator memorised its training records to a small but real degree. The two overlap heavily — most of the mass is common to both, which is why overall classification accuracy is close to chance and is the wrong number to report. A vertical line marks the threshold that holds the attacker's false-positive rate at one per cent, far out in the right tail. At that threshold, a meaningful fraction of members is still identified correctly, and that fraction is printed on the chart. The shaded region to the right of the threshold is the part of the member distribution the attacker recovers with high confidence. The point drawn underneath is that a privacy review should read the low-false-positive corner of the curve, not the average: an attacker does not need to classify everybody, only to be confident about a few people, and a distribution shift far too small to move accuracy is more than enough for that. Accuracy is near chance; the tail is where the leak lives -3 -2 -1 0 1 2 3 4 0 50 100 150 200 attacker membership score (local synthetic density, standardised) records threshold at 1% FPR 4.1% of members recovered here non-members members (in the training set) 2,500 members and 2,500 non-members, fixed seed. Overall accuracy is close to chance and tells a reviewer nothing. Read the low-false-positive corner: an attacker does not need to classify everybody, only to be confident about a few people, and this shift is more than enough for that.
Overall accuracy is near chance and tells a reviewer nothing; the leak lives in the low-false-positive corner.

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.

Membership-inference harness, and the three mistakes that invalidate its result Five stages run left to right. Stage one splits the real dataset into a member set and a held-out non-member set drawn from the same population, with the split seed recorded. Stage two trains the generator on the member set only. Stage three produces the synthetic release exactly as production would, with the same post-processing. Stage four scores every candidate — members and non-members alike — against the release using the attacker's chosen statistic. Stage five reports the AUC together with the true-positive rate at a fixed low false-positive rate. Three warnings hang below the chain. The first: if the non-members are drawn from a different population than the members, the attack measures distribution shift rather than memorisation, and the AUC is meaningless in the flattering direction. The second: if the release is post-processed differently from production — a smoothing step skipped, a clamp not applied — the harness tests an artifact that will never ship. The third: if the attacker's statistic is tuned on the same candidates it is then evaluated on, the AUC is optimistically biased, and the fix is a separate tuning split. Three ways a membership-inference harness quietly flatters you 1 · split members / held-out non-members split seed recorded 2 · train generator sees the member set only 3 · generate the release, exactly as production makes it 4 · score every candidate, members and not 5 · report AUC + TPR at a fixed low FPR non-members from a different population the attack measures distribution shift, not memorisation — and it flatters you release post-processed differently from production the harness tests an artifact that will never ship attacker statistic tuned on the evaluation candidates optimistically biased AUC; use a separate tuning split All three produce a lower AUC than the truth, which is why a harness nobody has attacked is not evidence of anything.
All three mistakes bias the AUC downward, which is why a harness nobody has attacked is not evidence of anything.

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.