A release passes its membership-inference test comfortably and still leaks. An adversary who knows where somebody lives and works can predict a withheld attribute — income band, household composition, a health indicator — far better than chance, using only the synthetic data.
Part of Adversarial Privacy Testing: membership inference asks whether a record was present, and attribute inference asks what a known individual’s hidden value is. They are different attacks with different defences, and passing one says nothing about the other.
The uncomfortable part of this attack is that the thing it exploits is the thing the release was asked to preserve.
A consumer wants synthetic data whose attributes relate to each other the way the real ones do — that is the entire point of attribute correlation modelling. An adversary wants to predict a sensitive attribute from quasi-identifiers they already hold. Both are asking the generator to carry the same information: the conditional distribution of the sensitive attribute given the quasi-identifiers.
That means the defence cannot be “remove the correlation”, because removing it removes the utility. It has to be a bound on how much is carried, chosen deliberately, with the residual measured rather than assumed.
Spatial data makes this sharper than it is elsewhere, for two reasons. Location is an unusually strong quasi-identifier — a home and work pair is close to unique over a metropolitan area — and spatial autocorrelation means neighbouring records carry information about each other, so suppressing an individual’s own record does not suppress the signal about them.
Measured across five generator configurations: the inference advantage tracks the carried mutual information almost exactly, and more coordinate noise barely moves it.
Raw accuracy is meaningless here, because a sensitive attribute with a skewed marginal can be predicted well by always guessing the majority class. The number that matters is the advantage over that baseline.
python
defattribute_advantage(synth, targets, quasi:list[str], sensitive:str)->float:"""How much better than the marginal an adversary does, using the synthetic data.
0.0 means the release adds nothing beyond what the marginal already gives away;
1.0 means the sensitive value is fully determined by the quasi-identifiers.
"""
baseline = majority_rate(synth[sensitive])
model = fit_predictor(synth, features=quasi, target=sensitive)
accuracy = evaluate(model, targets, features=quasi, target=sensitive)returnmax(0.0,(accuracy - baseline)/(1.0- baseline))
Two details in that function decide whether the number means anything. The predictor is fitted on the synthetic data and evaluated on the real targets, because the question is what an adversary learns from the release about real people. And the quasi-identifier set has to be the one an adversary plausibly holds — which for spatial data almost always includes location, and is worth agreeing with whoever signs off the release rather than choosing quietly.
import math
from collections import Counter
defmutual_information(pairs)->float:"""I(Q; S) in bits, over binned quasi-identifiers and the sensitive attribute."""
joint = Counter(pairs)
n =sum(joint.values())
pq = Counter(q for q, _ in pairs)
ps = Counter(s for _, s in pairs)returnsum((c / n)* math.log2((c / n)/((pq[q]/ n)*(ps[s]/ n)))for(q, s), c in joint.items()if c)
Mutual information is the right quantity because it is what the attack consumes, and because it is bounded by the entropy of the sensitive attribute — which gives the cap a natural scale. A release carrying half the sensitive attribute’s entropy is telling an adversary half of what they want to know, regardless of which model they use.
The effective defences all operate on the conditional distribution during generation, and there are three of them.
python
defcoarsen_quasi(df, columns:list[str], cell_m:float, age_band:int=10):"""Generalise the quasi-identifiers so fewer individuals are uniquely described."""
df = df.copy()for col in columns:if col in("home_x","home_y","work_x","work_y"):
df[col]=(df[col]// cell_m)* cell_m
elif col =="age":
df[col]=(df[col]// age_band)* age_band
return df
defsmooth_conditional(counts:dict, floor:float=0.05)->dict:"""Blend each conditional toward the marginal, capping how sharp any cell can be."""
marginal = normalise(aggregate(counts))return{key:{k:(1- floor)* v + floor * marginal[k]for k, v in dist.items()}for key, dist in normalise_each(counts).items()}defsuppress_sparse(counts:dict, k:int=12)->dict:"""Drop conditionals estimated from fewer than k individuals; fall back to the marginal."""
marginal = normalise(aggregate(counts))return{key:(dist ifsum(dist.values())>= k else marginal)for key, dist in counts.items()}
Coarsening reduces how uniquely the quasi-identifiers describe anybody, smoothing bounds how sharp any single conditional can be, and sparse suppression removes the conditionals that are effectively about one person. All three cost utility, and all three cost it in a different place, which is why they are worth applying together rather than pushing any one of them hard.
The instinct on discovering an inference leak is to perturb coordinates further. It does very little, and the reason is worth stating: the attack does not need the individual’s exact position. It needs the neighbourhood’s conditional distribution, and moving a point a hundred metres leaves it in the same neighbourhood. Coordinate noise defends against re-identification and barely touches attribute inference.
Four defences against the same attack: the three that operate on the conditional distribution work, and the one everybody reaches for first does not.
ADVANTAGE_CEILING =0.15deftest_attribute_inference_advantage(release, real_targets, contract):
adv = attribute_advantage(release, real_targets,
quasi=contract["quasi_identifiers"],
sensitive=contract["sensitive_attribute"])assert adv <= ADVANTAGE_CEILING,f"attribute advantage {adv:.2f}"deftest_utility_survived_the_cap(release, reference, contract):"""The defence must not have removed the correlation the release exists to carry."""
got = rank_correlation(release, contract["utility_pair"])
want = rank_correlation(reference, contract["utility_pair"])assertabs(got - want)< contract["correlation_tolerance"],(got, want)deftest_quasi_identifier_set_is_declared(contract):assert contract.get("quasi_identifiers"),("an undeclared quasi-identifier set means the attack was run against a guess")
Running the two assertions together is the point. Either one alone is trivially satisfiable — destroy the correlation and the advantage goes to zero; carry it fully and the utility is perfect — and it is only the pair that describes a release worth shipping.
The sensitive attribute is the one consumers want to model. Then the release is being asked to carry the exact information the attack extracts, and no parameter resolves it. The resolution is structural: publish aggregates of the sensitive attribute rather than per-record values, or restrict the release to an environment where the quasi-identifiers are not available.
Spatial quasi-identifiers combine aggressively: a home cell alone identifies almost nobody, and a home-and-work pair identifies most people.
Neighbours leak about each other. Spatial autocorrelation means an individual’s sensitive value is partly predictable from the neighbours in the release even if their own record is suppressed. Suppression at the individual level is therefore weaker here than in tabular data, and the cell-level defences — coarsening and sparse suppression — do more.
The adversary’s quasi-identifier set grows. What is a quasi-identifier depends on what else exists in the world, and that changes. Re-run the attack when a new public dataset appears that overlaps the release, and treat the declared set as a version rather than a constant.
The advantage is high on a small subgroup. An aggregate advantage inside the ceiling can hide a subgroup where it is near one — a rare category, a sparsely populated area. Report the advantage per stratum as well as overall; the worst stratum is the one an adversary will use.
Attribute inference is the attack whose result is hardest to communicate, because a single number
does not carry it. Three fields do.
The advantage, with the quasi-identifier set beside it. An advantage of 0.11 means nothing
without knowing what the adversary was assumed to hold. The same release scores very differently
against a home cell alone and against a home-and-work pair, and quoting the number without the
assumption is quoting half a result.
The worst stratum, not only the aggregate. An overall advantage inside the ceiling routinely
hides a subgroup — a rare category, a sparsely populated district — where it approaches one. An
adversary will use the worst stratum, so that is the number that describes the release’s actual
exposure, and reporting only the mean understates it in exactly the cases that matter.
The utility that was preserved. Because the defence and the requirement pull on the same
information, an advantage number alone is uninterpretable: it could describe a well-defended
release or one whose correlations were destroyed. Publishing the rank correlation the release
still carries, beside the advantage, is what shows the trade was made deliberately.
Together those three make the result reviewable by somebody who was not in the room — which is
the standard every other privacy artifact in this area is held to, and there is no reason this
one should be exempt.