A generalization that looks like enough

A common first step toward anonymizing customer data: replace an exact, highly identifying value with a coarser range. Exact birth date becomes a ten-year age bucket:

def age_bucket(birth_year):
    age = 2026 - birth_year
    lower = (age // 10) * 10
    return f"{lower}-{lower + 9}"

df["age_bucket"] = df["birth_year"].apply(age_bucket)

Combined with a country column already in the data, the reasoning goes: nobody can be individually identified from "30-39, Germany" the way they could from an exact birth date. That reasoning is worth checking directly rather than trusting.

Checking whether the generalized groups are actually safe

k-anonymity is the real, standard way to check this: does every combination of the quasi-identifying columns, here age_bucket and country together, cover at least k real people. A group of one is exactly as identifying as the original exact value; the generalization only genuinely protects anyone if the groups it produces are large enough to hide within.

group_sizes = df.groupby(["age_bucket", "country"]).size()
K = 5
unsafe = group_sizes[group_sizes < K]
print(f"total combinations: {len(group_sizes)}")
print(f"combinations with fewer than k={K} members: {len(unsafe)}")
print(f"smallest group size: {group_sizes.min()}")
total combinations: 54
combinations with fewer than k=5 members: 32
smallest group size: 1

32 of 54 real combinations, on a real 300-row sample, have fewer than five people in them. Several have exactly one. A group of exactly one person, labeled "10-19, Brazil," is just as individually identifying as the original exact birth date and country would have been, the generalization changed the label without actually changing whether a specific person can be singled out.

A coarser attempt, checked the same way

# 20-year buckets instead of 10, and country collapsed into region
df["age_bucket_wide"] = df["birth_year"].apply(wide_age_bucket)
df["region"] = df["country"].map(region_map)

group_sizes = df.groupby(["age_bucket_wide", "region"]).size()
total combinations: 20
combinations with fewer than k=5: 6
smallest group size: 1

Real, meaningful improvement, 32 unsafe groups down to 6, but not zero. Even after generalizing both columns substantially, a handful of combinations, smaller regions crossed with less common age ranges, still come out below the safety threshold. This is a genuinely honest result, not a failure of the technique: broad generalization helps a lot, and checking whether it helped ENOUGH is a separate step that doesn't happen automatically just because the values got coarser.

What the remaining gap actually needs

The six remaining unsafe groups are a real, specific, addressable problem, not a reason to give up on the approach. Options that actually close a gap like this: suppress just those specific rows (drop or redact them from the anonymized dataset, rather than the whole dataset), merge the smallest region categories further, or use a different generalization entirely for the columns still causing the problem. Which one is right depends on how much analytical value those particular rows carry, but the decision can only be made deliberately once the gap is actually visible, which requires running the check, not assuming generalization alone was sufficient.

The takeaway

"We generalized the data" and "we anonymized the data" are different claims, and the gap between them is exactly what a k-anonymity check measures. Coarser generalization narrows that gap, confirmed directly above, but doesn't automatically close it, and the only way to know whether it actually did is to check the resulting group sizes directly, the same way it's checked here, rather than trusting that a bucketed value is inherently safe because it looks less precise than the original.