Re-identify a "5-anonymous" table and measure the uniqueness of quasi-identifiers
Break it on purposeNeeds Python with pandas
Problem
Removing names does not remove identity: a combination of ordinary columns (quasi-identifiers such as birth date, postcode and sex) is unique for most people once the number of possible combinations far exceeds the number of people. K-anonymity generalizes those columns, and suppresses rare combinations, until every combination is shared by at least k people. But two releases generalized differently can be joined, and their intersection is finer than either.
Generate 100,000 fake people with birth date (about 29,200 values), postcode (10,000 values) and sex. Count people unique on the raw columns. Publish release A as (birth decade, postcode district, sex) and release B as (birth year, postcode region, sex), each suppressing classes smaller than 5, then join A and B by row id and measure the class sizes of the intersection.
The question. How many people are unique on the raw columns, and what k does the join of two 5-anonymous releases reach?
Predict
Most people are unique on the raw columns (the space has about 584 million cells for 100,000 rows). Each release may be 3-anonymous or better, while the intersection has k = 1 for many people.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
re-identify-a-5-anonymous-table.py # /// script # requires-python = ">=3.10" # dependencies = ["pandas", "numpy"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Re-identify a 5-anonymous table and measure the uniqueness of quasi-identifiers. See that removing names does not remove identity, and that composition breaks k-anonymity. Two releases are generalized differently so that each is k-anonymous on its own (classes smaller than k are suppressed); joining them by row id gives classes far smaller than k.""" import argparse import pandas as pd import numpy as np import json def k_of(df, cols): """Size of each person's equivalence class on these columns.""" return df.groupby(cols)[cols[0]].transform("size") def main(): parser = argparse.ArgumentParser(description=__doc__.split('\n')[0]) parser.add_argument("--people", type=int, default=100_000, help="Number of fake people to generate.") parser.add_argument("--k", type=int, default=5, help="Each release suppresses classes smaller than k.") parser.add_argument("--seed", type=int, default=1, help="Random seed for reproducibility.") parser.add_argument("--json", type=str, help="Also write the results to this JSON file.") args = parser.parse_args() rng = np.random.default_rng(args.seed) n = args.people print(f"--- 1. Generating Fake Population ({n} people) ---") df = pd.DataFrame({ "dob": rng.integers(0, 29_200, n), "pc": rng.integers(0, 10_000, n), "sex": rng.integers(0, 2, n), }) df["year"] = df.dob // 365 # 80 birth years df["decade"] = df.year // 10 # 8 birth decades df["pc2"] = df.pc // 100 # 100 postcode districts df["pc1"] = df.pc // 1000 # 10 postcode regions print("\n--- 2. Uniqueness on Raw Quasi-Identifiers ---") unique_raw = int((k_of(df, ["dob", "pc", "sex"]) == 1).sum()) print(f"unique on (birth date, postcode, sex): {unique_raw} of {n}") print(f"\n--- 3. Two Releases, each {args.k}-anonymous ---") # Release A is coarse on age and fine on place; release B the other way round. releases = {"A (birth decade, district, sex)": ["decade", "pc2", "sex"], "B (birth year, region, sex)": ["year", "pc1", "sex"]} rows = [{"release": "Raw (birth date, postcode, sex)", "classes": int(df.groupby(["dob", "pc", "sex"]).ngroups), "suppressed": 0, "k": int(k_of(df, ["dob", "pc", "sex"]).min()), "unique_count": unique_raw, "below_k": int((k_of(df, ["dob", "pc", "sex"]) < args.k).sum())}] keep = pd.Series(True, index=df.index) for name, cols in releases.items(): size = k_of(df, cols) kept = size >= args.k # suppress classes smaller than k keep &= kept k_rel = int(size[kept].min()) rows.append({"release": name, "classes": int(df.groupby(cols).ngroups), "suppressed": int((~kept).sum()), "k": k_rel, "unique_count": int((size[kept] == 1).sum()), "below_k": int((size[kept] < args.k).sum())}) print(f"k({name}) = {k_rel}, suppressed {int((~kept).sum())} people") print("\n--- 4. Composition Attack (Joining Releases by row id) ---") both = df[keep] size_c = k_of(both, ["decade", "pc2", "year", "pc1", "sex"]) k_c = int(size_c.min()) unique_c = int((size_c == 1).sum()) below_c = int((size_c < args.k).sum()) rows.append({"release": "A joined with B", "classes": int(both.groupby(["year", "pc2", "sex"]).ngroups), "suppressed": int(n - len(both)), "k": k_c, "unique_count": unique_c, "below_k": below_c}) print(f"k(A and B) = {k_c}; unique: {unique_c}; in classes smaller than {args.k}: {below_c} of {len(both)}") results = { "summary": { "people": n, "k_target": args.k, "unique_raw": unique_raw, "k_A": rows[1]["k"], "k_B": rows[2]["k"], "k_intersection": k_c, "unique_intersection": unique_c, "below_k_intersection": below_c, "below_k_intersection_pct": 100 * below_c / len(both), "people_in_both": int(len(both)), }, "tables": {"releases": rows}, } if args.json: with open(args.json, "w") as f: json.dump(results, f, indent=2) if __name__ == "__main__": main()3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run re-identify-a-5-anonymous-table.py uv run re-identify-a-5-anonymous-table.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --people100,000 | Number of fake people to generate. |
| --k5 | Each release suppresses classes smaller than k. |
| --seed1 | Random seed for reproducibility. |
Our run
Smallest class per release, 100,000 people
| Release | Classes | k (smallest class) | People unique | People below k = 5 |
|---|---|---|---|---|
| Raw (birth date, postcode, sex) | 100K | 1 | 100K | 100K |
| A (birth decade, district, sex) | 1.6K | 36 | 0 | 0 |
| B (birth year, region, sex) | 1.6K | 38 | 0 | 0 |
| A joined with B | 16K | 1 | 182 | 13.1K |
--- 1. Generating Fake Population (100000 people) ---
--- 2. Uniqueness on Raw Quasi-Identifiers ---
unique on (birth date, postcode, sex): 99970 of 100000
--- 3. Two Releases, each 5-anonymous ---
k(A (birth decade, district, sex)) = 36, suppressed 0 people
k(B (birth year, region, sex)) = 38, suppressed 0 people
--- 4. Composition Attack (Joining Releases by row id) ---
k(A and B) = 1; unique: 182; in classes smaller than 5: 13124 of 100000What we found
99970 of 100,000 people were unique on (birth date, postcode, sex), as the chapter expects. Each release was safely anonymous on its own: k was 36 for A and 38 for B, with nobody suppressed. Joined by row id, the two releases have 16,000 possible classes instead of 1,600: k fell to 1, 182 people were unique again, and 13.1K (13.1%) sat in classes smaller than 5.
The effect depends on how many people share the space. At 10,000 people each release had to suppress over a thousand people to reach k = 5, and the join still left 3.59K unique. At 1,000,000 people the join's smallest class was 32: the same generalizations were safe even combined.
Read it
Uniqueness is a property of the combination of columns, not of any single one, and the number of combinations grows as the product of their values. A release is only as anonymous as its join with every other release of the same people: generalizing each release on its own says nothing about what their intersection reveals, which is why k-anonymity does not compose and why release decisions must consider what has been published before.