Skip to content
About
Main content

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 ( 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

Each release is safely anonymous on its own (k above 30). Joined by row id, what share of people fall below k = 5?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    re-identify-a-5-anonymous-table.py90 linesDownload
    View 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()
    
  3. 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
Knobs
Knob · defaultWhat 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

Smallest class per release, 100,000 people
ReleaseClassesk (smallest class)People uniquePeople below k = 5
Raw (birth date, postcode, sex)100K1100K100K
A (birth decade, district, sex)1.6K3600
B (birth year, region, sex)1.6K3800
A joined with B16K118213.1K
Reference runApple M2 · 8 cores · 8 GB
Output · uv run re-identify-a-5-anonymous-table.py --people 100000
--- 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 100000
macOS 26.6.2 · Python 3.13.3 · pandas 3.0.6 · numpy 2.5.4 · run 2026-10-20 · 3 runs

What 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 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.