📑 本页目录(点开跳转)
19 · 隐私、脱敏与留存
⏱ 78 分钟 | ⚠️ 脱敏不等于匿名
🎯 一句话
删掉姓名、手机号、身份证之后,你手里的仍然是一份能认出人的数据。 认出一个人不需要标识符,只需要几个谁看了都不觉得敏感的字段凑在一起。 这一章讲:PII 有几层、七种脱敏各自防什么防不住什么、数据该留多久, 以及那个最难的:用户要求删除时,已经训进模型的那份怎么办。
🪪 一、PII 有三层,只删第一层等于没删
| 层 | 是什么 | 例子 |
|---|---|---|
| ① 直接标识符 | 一个字段就能指到人 | 姓名、手机号、身份证、邮箱、银行卡、设备 ID |
| ② 准标识符 ⭐ | 单独没用,组合起来是指纹,看着完全无害 | 生日、性别、邮编、职业、就诊科室、机型 |
| ③ 敏感属性 | 攻击者最终想要的结果,不是入口 | 诊断、收入、负债、性取向、宗教 |
外部名单(姓名+准标识符) 你的"脱敏"数据集
姓名 / 生日 / 性别 / 邮编 ──join──▶ 生日 / 性别 / 邮编 / 诊断
▼
姓名 ←→ 诊断 ← 攻击完成
⭐ join 用的钥匙从来不是①,是②。
💀 所以你在①上做的一切(删除、哈希、加密),对这条链路【毫无影响】。
实跑(296,000 条就诊记录,612 个邮编,年龄 18–85):
import numpy as np
from collections import Counter
rng, N, nz = np.random.default_rng(7), 296_000, 612
w = 1.0 / np.arange(1, nz + 1) ** 0.75 # 邮编人口是倾斜的,不是均匀的
zip5 = rng.choice(np.arange(200100, 200100 + nz), N, p=w / w.sum())
sex = rng.choice([0, 1], N, p=[0.49, 0.51])
bdays = rng.integers(0, 67 * 365, N) # 出生日期,天粒度
def k_report(cols, name):
cnt = Counter(keys := list(zip(*cols)))
s = np.array([cnt[k] for k in keys]) # ⭐ 每条记录所在等价类的大小 = 它的 k
print(name, f"唯一率={100*(s==1).mean():.1f}% k<5={100*(s<5).mean():.1f}% 最小k={s.min()}")
k_report([bdays, sex, zip5], "生日(天)+性别+邮编5位")
k_report([bdays // 365, sex, zip5], "出生年+性别+邮编5位") # ⭐ 只粗化生日
k_report([bdays // (365*5), sex, zip5 // 100], "5岁分箱+性别+邮编前3位")
实跑结果:
| 保留的准标识符组合 | 唯一率 | k<5 占比 | 最小等价类 |
|---|---|---|---|
| 生日(到天) + 性别 + 邮编5位 | 94.9% 💀 | 100.0% | 1 |
| 出生年 + 性别 + 邮编5位 | 6.6% | 38.0% | 1 |
| 5岁分箱 + 性别 + 邮编前3位 ⭐ | 0.0% | 0.0% | 18 |
| 生日(到天) + 性别(把邮编删了) | 0.2% | 14.7% | 1 |
⭐⭐ 看前两行:只把生日从「到天」粗化成「到年」,唯一率就从 94.9% 掉到 6.6% —— 一个字段的粒度值 88 个百分点。 ⭐ 风险来自组合,不是来自某一列 —— 逐列看每一列都"不敏感", 所以任何一张只按列打勾的隐私检查表都抓不到它。
🔎 二、怎么把 PII 找出来(以及自动找不到的那四类)
自动扫描靠四路信号叠加(列名 + 值正则 + 校验位 + 唯一度),任何单路都会漏:
import re
PAT = {"手机号": r"^1[3-9]\d{9}$", "身份证": r"^\d{17}[\dXx]$",
"邮箱": r"^[\w.+-]+@[\w-]+\.[\w.]+$", "银行卡": r"^\d{16,19}$"}
HINT = re.compile(r"(phone|mobile|idcard|email|addr|name|card|imei|ip)", re.I)
W, C = [7,9,10,5,8,4,2,1,6,3,7,9,10,5,8,4,2], "10X98765432"
id_ok = lambda s: C[sum(int(a)*b for a, b in zip(s[:17], W)) % 11] == s[17].upper()
def scan(col, v): # ⭐ 校验位把"长得像身份证"和"真的是"分开;唯一度高 = 准标识符候选
n = max(len(v), 1)
kind, rate = max(((k, sum(bool(re.match(p, x)) for x in v) / n) for k, p in PAT.items()),
key=lambda kv: kv[1])
ck, uniq = sum(id_ok(x) for x in v if re.match(PAT["身份证"], x)) / n, len(set(v)) / n
return kind, round(rate, 3), round(uniq, 3), (
"直接标识" if rate > .8 or ck > .8 else "疑似直接标识" if HINT.search(col)
else "准标识符候选" if uniq > .5 else "低风险")
⚠️ 自动扫描系统性地漏掉这四类,而它们恰好是事故的主要来源:
| 抓不到的 | 怎么办 |
|---|---|
| 自由文本(工单备注、客服会话、详址) | 单独跑 NER,并默认把整列当高风险 |
伪 ID(user_id)⭐ 不是 PII,却是串起所有表的钥匙 |
对外发布换成每次不同的一次性 ID |
| 行为指纹 💀 轨迹点、打开序列 —— 没有一列是 PII | 当准标识符处理:降采样、粗化时间、截断序列 |
| 图片 / 音频(发票照片、报告截图) | 走单独管线,别混进结构化数据的评审 |
⭐ 第三行最容易被跳过:高维稀疏的行为数据几乎必然是唯一的 —— 一个人最近 30 天的 app 打开时间序列,在千万人的库里也基本只属于他。 这类数据不存在"删掉 PII 就安全"的版本,只有降低分辨率一条路。
🧪 三、七种脱敏方法:各自能防什么,防不住什么
| 方法 | join | ✅ 防住 | ❌ 防不住 |
|---|---|---|---|
| 删除(整列丢掉) | ❌ | 该列的一切攻击 | 其他列的组合攻击;代价最大 |
掩码 138****5678 |
⚠️ | 肉眼浏览、截图外泄 | 💀 剩余空间只有 10⁴,几秒枚举完 |
| 确定性哈希 | ✅ | 肉眼读出 | 💀 暴力枚举 + 频率分析,见下 |
| 加盐哈希(全局盐) | ✅ | 外部彩虹表 | ⚠️ 盐泄漏就退化成上一行;频率分析照样有效 |
| Tokenization / 每行盐 | ❌ | 频率分析、枚举 | 映射表成了新的高价值资产 |
| 泛化 / 分箱 ⭐ | ✅ | 重识别(真正有效) | 敏感属性的同质推断 |
| 加噪 | ✅ | 单条精确读数 | 💀 重复查询取平均能消掉,见第五节 |
| k-匿名 / 差分隐私 | ⚠️ | 见第四、五节 | 同质攻击 / 小分组精度 |
💀 确定性哈希的两个死法(都实跑了)
import hashlib, time
import numpy as np
from collections import Counter
# 死法一:明文空间太小,直接枚举
n, t0 = 120_000, time.perf_counter()
for i in range(n):
hashlib.sha256(f"138{i:08d}".encode()).digest()
rate = n / (time.perf_counter() - t0) # ⭐ 纯 Python 单核实测
phone, idcard, gpu = 200 * 10**8, 3500 * 36500 * 999, 2.1e10 # 号段/身份证空间、显卡吞吐
print(f"{rate/1e6:.2f} M/s;手机号 {phone/rate/3600:.1f} 小时;"
f"GPU 手机号 {phone/gpu:.2f} 秒、身份证 {idcard/gpu:.1f} 秒") # ⭐⭐
# 死法二:分布原样保留 —— 连破解都不需要
city = np.random.default_rng(1).choice(list("ABCDEF"), 50_000, p=[.34,.22,.17,.12,.09,.06])
hsh = [hashlib.sha256(c.encode()).hexdigest()[:10] for c in city]
f = lambda x: [round(v / 50_000, 4) for _, v in Counter(x).most_common()]
print(f(city), f(hsh), f(city) == f(hsh)) # ⭐⭐ 两边都是 [0.3403, 0.2192, 0.17, …],True
实跑:纯 Python 单核 0.7–1.1 M/s(手机号全空间 5–8 小时),一张消费级 GPU:手机号 0.95 秒、身份证 6.1 秒。 频率向量两边逐位相同、排序也相同 —— 拿一份公开人口构成表按频率对齐就能把 top-N 映射回真实城市,一个哈希都没破。
⭐ 哈希不是加密 —— 哈希函数公开、无密钥,把手机号哈希一遍等于用一把全世界都有的钥匙锁门。 只要明文空间可枚举(手机号、身份证、车牌、订单号),确定性哈希提供的保护是零。
⚠️⚠️ 这就是 密码学 07 工作模式 里那只 ECB 企鹅,一模一样的道理: ECB 把每一个明文块都正确加密了 —— 但相同的块产生相同的密文块,块与块的对应关系原封不动, 于是图案还在,你依然看得见企鹅。 ⭐ 记住这句:确定性脱敏保护的是"值",泄漏的是"结构",而重识别攻击要的从来就是结构。
🧂 关于盐:盐是密钥,不是配置项
① 盐不能和数据存在一起(一起被拖走等于没加);② 盐必须能轮换;
③ 全局盐才能跨表 join,每次发布换盐才能防"两次发布交叉";④ 写死 "salt" 等于没加。
⭐ 这四条就是密钥管理的四条,一条不差 —— 详见 密码学 19 密钥管理: 盐要按 KEK/DEK 那一套存(放 KMS、永不导出、支持新旧共存的轮换)。 💀 最常见的写法是把盐写进仓库的配置文件,然后仓库权限比数据库还宽。
🛑 读到这里可以停 —— 前半章讲完了(约 24 分钟)。 后半章还有:k-匿名:这一章唯一能算出数的东西 · 加噪与差分隐私:ε 是什么,"我加了噪声"为什么不算 · 事故复盘:三个字段,87.3% 的人被点名 · 最小化与留存:不采集的数据不会泄漏 · 删除权最硬的那一半:已经训进模型的怎么办 · 一张决策表 回来的时候不用重读,直接从下一节接着看就行。
📏 四、k-匿名:这一章唯一能算出数的东西
k-匿名:把准标识符泛化,直到【每一种取值组合至少对应 k 个人】
等价类 = 准标识符取值完全相同的一组记录;一条记录的 k = 它所在等价类的大小
数据集的 k = 所有等价类里【最小的那个】 ⭐ 是最小值,不是平均值
代价有多大?实跑(12 万条,用年龄 + 收入预测一个二分类目标):
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
rng, N = np.random.default_rng(3), 120_000
age = rng.integers(18, 85, N).astype(float)
inc = rng.lognormal(10, 0.6, N)
y = rng.random(N) < 1 / (1 + np.exp(-(-2.5 + .045*(age-45) + .5*np.log(inc/22000))))
tr = np.arange(N) < 80_000
def auc_of(a):
X = np.c_[a, np.log(inc)]
m = LogisticRegression(max_iter=500).fit(X[tr], y[tr])
return round(roc_auc_score(y[~tr], m.predict_proba(X[~tr])[:, 1]), 4)
print(auc_of(age), [auc_of((age//w)*w + w/2) for w in (5, 10, 20)], # ⭐ 分箱取箱中值
auc_of(np.full(N, age.mean()))) # ⭐ 整列删掉 = 常数
| 处理 | 精确年龄 | 5 岁分箱 ⭐⭐ | 10 岁分箱 | 20 岁分箱 | 整列删掉 💀 |
|---|---|---|---|---|---|
| AUC | 0.7345 | 0.7342 | 0.7328 | 0.7275 | 0.5817 |
| 相对损失 | — | −0.0003 | −0.0017 | −0.0070 | −0.1528 |
⭐⭐ 把这张表和第一节那张表放在一起,就是本章最重要的结论: 生日泛化到 5 岁分箱 + 邮编截到前 3 位,重识别唯一率从 94.9% 归零(最小等价类 18 人), 模型 AUC 只掉 0.0003。 "脱敏一定会毁掉数据价值"这话在合理粒度上是错的。 💀 真正毁掉数据价值的是"不确定就把列删了" —— 掉 0.1528,是分箱代价的 500 倍。 ⭐ 所以脱敏的默认动作应该是「降分辨率」,不是「删列」。
k-匿名的三个漏洞
① 同质攻击 —— k 够大,但那一组人得的是同一种病。 实跑(同一份 296,000 条,敏感属性 = 诊断大类): 满足 5-匿名的记录占 62.0%,其中 0.7% 落在只有一种诊断的等价类(l = 1)、6.6% 落在种类 < 3 的类、3.8% 落在某个诊断占 ≥ 80% 的类。
💀 对最后那 3.8% 的人,攻击者根本不需要定位到具体是谁 —— 只要知道"张三是 1982 年生的男性、住这个邮编",就能以 80% 以上的置信度说出他的诊断。 ⭐ k-匿名保证的是"认不出是哪一个人",不保证"猜不到他的秘密"。 补丁叫 l-多样性(每类至少 l 种敏感值)和 t-接近(类内敏感值分布要接近全局分布)。
② 背景知识攻击:攻击者已知"张三没有心脏病",候选就又少一个。 ③ 多次发布交叉 💀:两份各自满足 5-匿名的数据集交起来的 k 可能是 1 —— k 必须对历史全部版本的并集算,不是对单次发布算。
🎲 五、加噪与差分隐私:ε 是什么,"我加了噪声"为什么不算
Laplace 机制:给查询结果 f(D) 加上 Lap(0, Δf/ε) 的噪声
Δf = 敏感度 = 增删【一条记录】最多能让 f 变多少(计数查询 Δf = 1;
⚠️ 求和/均值要先【截断取值范围】才有有限的 Δf)
ε = 隐私预算,越小越私密、噪声越大。⭐ ε 不是"泄漏概率",
是【一个人在与不在,输出分布最多差 e^ε 倍】
实跑(Δf=1,各 20000 次采样)。左半是不同 ε 的绝对误差,右半是固定 ε=1 时不同分组大小的相对误差:
| ε | 误差中位数 / 95 分位 | | | 分组人数(ε=1) | 相对误差中位数 / 95 分位 |
|---|---|---|---|---|
| 0.1 | 6.86 / 30.03 | | | 3 | 23.0% / 101.2% 💀 |
| 1.0 | 0.70 / 3.02 | | | 12 | 5.8% / 25.2% |
| 2.0 | 0.35 / 1.47 | | | 50 | 1.3% / 5.8% |
| 10.0 | 0.07 / 0.30 | | | 5000 | 0.0% / 0.1% |
⭐ 右半张表说明了 DP 的全部性格:它对大盘几乎免费,对小分组毁灭性。 而"小分组"恰恰是罕见病患者、少数族裔、小城市用户 —— 最需要保护也最需要被看见的那批人。 ⚠️ DP 报表最常见的翻车:某个长尾分组一天正一天负,业务方从此不再相信任何数字。
💀 为什么"我加了随机噪声"不等于差分隐私
差分攻击实跑:①某邮编阳性人数(真值 41)、②同一邮编排除张三后(真值 40),各自独立加 ε=1 噪声, 攻击者重复查 m 次取平均,看差值是否 > 0.5:
import numpy as np
rng = np.random.default_rng(0)
for m in (1, 10, 100, 200):
d = np.array([np.mean(rng.laplace(41, 1., m)) - np.mean(rng.laplace(40, 1., m))
for _ in range(4000)])
print(m, round(float(np.median(d)), 3), f"{100*float((d > .5).mean()):.1f}%") # ⭐ 判对率
| 重复次数 m | 1 | 10 | 100 | 200 |
|---|---|---|---|---|
| 差值中位数 | 1.032 | 1.019 | 0.998 | 1.006 |
| 判对张三是阳性的概率 | 61.6% | 79.4% | 99.7% 💀 | 100.0% |
⭐⭐ 差分隐私不是"加噪声"这个动作,是"预算"这个制度。 两条铁律: ① 同一个查询必须缓存并返回同一个答案(否则重复取平均把噪声抹平); ② 全部查询的 ε 必须累加记账,账花完就拒绝服务。 没有这两条的加噪,只是把攻击成本从 1 次变成 200 次而已。 ⚠️ 和密码学那条「Nonce 绝不能重用」同类:安全性靠使用规程兑现,不靠公式本身。
💀 六、事故复盘:三个字段,87.3% 的人被点名
系统:某区域健康 App 的"科研开放数据集",296,000 名用户的就诊记录
处理:删掉姓名、手机号、身份证、住址详址 —— 通过内部隐私评审,标注为"已匿名"
保留:出生日期(到天)、性别、邮政编码(5位)、就诊科室、诊断大类
| 时间 | 事件 |
|---|---|
| D0 | 发布给 3 家科研合作方,说明书写「已完全匿名,可自由使用」 |
| D+40 | 一名研究者拿一份公开的马拉松报名名单(姓名/生日/性别/居住邮编)做 join |
| D+41 | 数据集 94.9% 的记录在 {生日,性别,邮编} 上唯一;名单覆盖该区域 92% 成年常住人口 |
| D+41 | ⭐ 94.9% × 92% = 87.3% 的人被指名道姓地对上,其中 4,180 人属于精神科 / 传染病科 |
| D+52 | 数据召回,但 2 家合作方已二次分发,无法确认删干净 💀 |
为什么内部评审没拦住:
① 评审清单是一张【PII 字段表】—— 生日、性别、邮编【都不在表上】,
所以逐项打勾全部通过 💀
② 审查方式是【逐列看】:"生日敏感吗?不敏感。邮编敏感吗?不敏感。"
⭐ 从头到尾没有任何一步计算过【组合】
③ 术语混用:"已删除直接标识符" 在纪要里被写成了 "已匿名" ⭐⭐
代价:3 家召回但 2 家已二次分发 → 无法闭环;296,000 人逐一告知 + 罚款;
开放数据计划停摆 14 个月。⭐ 最贵的是重识别结果【删不回来】
| 缺失 | 补上之后 |
|---|---|
| 只有 PII 字段表,没有准标识符登记 💀 | 建立准标识符清单,每次发布前计算 k、l |
| 发布前没有量化门禁 | min k ≥ 10 且 min l ≥ 3 才允许发布,否则自动继续泛化 |
| 生日精确到天、邮编 5 位 | 5 岁分箱 + 邮编前 3 位 —— 唯一率 94.9% → 0,AUC 只掉 0.0003 ⭐ |
| 从没做过重识别演练 | ⭐⭐ 每次发布前用一份真实公开名单跑一次 join,把匹配率写进说明 |
| "脱敏"和"匿名"混用 | 脱敏是过程,匿名是结论;只有算过 k 才有资格说"匿名" |
⭐ 改进后的关键数字:同一份数据按
{5岁分箱, 性别, 邮编前3位}重发, 最小等价类 18 人,用同一份马拉松名单再跑匹配,匹配率 0%, 而下游三个科研项目的模型指标没有一个掉超过 0.005。
📦 七、最小化与留存:不采集的数据不会泄漏
最小化四问,在接入时问而不是事故后问:① 现在有哪个模型/报表在用它?(说不出使用方 → 不采集 ⭐) ② 能不能用更粗的粒度?(存生日不如存年龄段)③ 要留多久?(说不出天数 → 默认 30 天) ④ 泄漏了会怎样?(答案里出现"人身安全" → 走单独的高敏管线)
| 数据类型 | 建议留存 | 为什么 |
|---|---|---|
| 原始请求日志(IP / UA / 设备号) | 7–30 天 | 只为排障,超一个月没人回看 |
| 脱敏后的行为事件 | 12–24 个月 | 建模要覆盖季节性 |
| 训练数据快照 | 模型下线 + 6 个月 | 审计与复现(第 14 章) |
| 标注结果 | 长期,但必须能按人删 ⭐ | 最贵也最难重建 |
| 备份 / 冷归档 | ⚠️ 和主库同策略 | 💀 删除请求漏得最多的就是这里 |
💀 一条数据的七个藏身处 —— 删除做不干净,几乎都是漏了其中之一:
① 主库 ② 数仓历史分区/快照 ③ 特征存储在线表+离线表 ④ 备份与冷归档
⑤ 下游导出(BI / 合作方 / 本地 Excel) ⑥ 含请求体的日志 ⑦ 向量索引
⭐ 每一处都要有【按主体 ID 删除】的接口和验收脚本;删除任务要留审计记录,
而这条记录本身【不能包含被删的内容】
🧹 八、删除权最硬的那一半:已经训进模型的怎么办
用户行使删除权时,删掉数据库那一行是最容易的部分。难的是:它已经变成了模型权重的一部分。
| 做法 | 成本 | 效果 / 什么时候用 |
|---|---|---|
| 只删数据、不动模型 | 0 | ⚠️ 通常不够;只在模型不含个体信息时(纯聚合特征) |
| 全量重训 | 100% | ✅ 彻底。攒批按周期执行 —— 最常见的现实解 |
| 分片重训(SISA) ⭐ | 1/S | ✅ 彻底;删除请求高频时用 |
| 近似遗忘 / 影响函数 | 低 | ⚠️ 没有强保证,别写进合规承诺 |
| DP 训练当事前防线 ⭐⭐ | 训练时的精度 | ✅ 单条记录的影响本身就被限住;新系统最省事 |
SISA:训练集切成 S 个不相交分片,各训一个子模型、预测时集成,删除一条记录只需重训它所在的那一片。实跑(6 万条):
| 方案 | 全量单模型 | SISA 4 分片 ⭐ | SISA 8 分片 | SISA 16 分片 |
|---|---|---|---|---|
| AUC | 0.7168 | 0.7162(−0.0006) | 0.6982(−0.0185) | 0.6987(−0.0181) |
| 单次删除重训成本 | 100% | 25% | 12.5% | 6.2% |
⭐ 看 4 分片那一列:重训成本降到 1/4,精度只掉 0.0006;而从 4 片到 8 片, 成本再减半,精度却掉了 0.0185(30 倍) —— 拐点在"每片仍有足够样本量"的地方。 ⭐⭐ 这一节真正的结论:可遗忘性是一个架构属性,不是一个事后操作。 等删除请求来了才想怎么办,你只剩"全量重训"和"装作没这回事"两个选项。
⚠️ 生成式模型更难:模型会逐字记住语料里高频重复的片段,并能被诱导原样输出。 训练前的去重和 PII 过滤比训练后的任何补救有效一个数量级。
🧭 九、一张决策表
| 你的情况 | 该做什么 |
|---|---|
| 只在内部用,不出团队 | 直接标识符加盐哈希 + 列级权限就够,别过度设计 |
| 要给另一个部门用 | 直接标识符 tokenize,准标识符做泛化,记录用途和留存期 |
| 要对外发布 / 给合作方 ⭐ | 必须算 k 和 l,必须做一次真实的重识别演练,写进发布说明 |
| 要发布统计结果 / 报表 | DP + 预算记账 + 同查询缓存;小分组直接抑制不发 |
| 有高频删除请求 | 上 SISA 分片(4 片起),或直接用 DP 训练当事前防线 |
| 数据里有行为序列 / 轨迹 💀 | 别指望删列,只有降低时空分辨率一条路 |
| 有人跟你说"我们已经匿名了" | ⭐⭐ 问一句:"最小等价类是多少人?" —— 答不上来就是没做 |
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| 密码学 07 工作模式 | ⭐⭐ 那只 ECB 企鹅就是本章第三节的完整版:确定性映射保留结构,图案就还在 —— 和确定性脱敏是同一个错误的两种穿法 |
| 密码学 19 密钥管理 | 加盐哈希的盐本身就是密钥:存哪、怎么轮换、新旧怎么共存 —— KEK/DEK 那套原样适用 |
| 模型上线之后 19 合规审计与模型卡 | 本章产出的「准标识符清单 / k 值 / 留存期 / 删除接口」正是模型卡要填的那几栏 |
| 大模型全景导论 14 合规与伦理 | 生成式模型的记忆化:第八节那个「删不掉」问题在大模型语境下的展开 |
| ⭐ 《AI 全栈》15 · 可观测性 | 本章的原则落到日志上是什么样:⚠️ 别把 prompt 和回复原样打进日志 —— 用户输进去的东西天然含 PII,而日志的留存期、访问权限、导出路径往往比主库松得多。那一章讲该记什么、怎么脱敏 |
| 《AI 全栈》08 · 认证会话与多租户 | 数据隔离的工程实现:共享表加 tenant_id / 分 schema / 分库三档取舍,以及怎么让「忘了加过滤条件」这类 bug 跑不通而不是靠人记得 |
✅ 检查点
- PII 分成哪三层?重识别攻击真正用来 join 的是哪一层?为什么在第一层上做的所有工作对这条链路没影响?
- 实跑里,
{生日(到天), 性别, 邮编5位}的唯一率是多少?只把生日粗化到「年」之后变成多少?这说明了什么? - 自动 PII 扫描系统性抓不到哪四类?为什么行为序列没法靠"删列"解决?
- 确定性哈希的两个死法分别是什么?实跑的枚举时间是多少?为什么说它和 ECB 那只企鹅是同一个道理?
- 加盐哈希里,盐该按什么东西来管?四条规矩是什么?
- 把生日泛化到 5 岁分箱、邮编截到前 3 位,重识别风险和模型精度各变成多少?为什么说"不确定就删列"是最贵的做法?
- k-匿名的三个漏洞是什么?实跑里,满足 5-匿名的记录中有多少落在"某个诊断占 ≥80%"的等价类?
- 为什么说"我加了随机噪声"不等于差分隐私?差分攻击实跑里重复 100 次的判对率是多少?DP 的两条铁律是什么?
- 那个健康数据事故里,87.3% 这个数是怎么算出来的?内部评审为什么三次都没拦住?改进后匹配率变成多少?
- 删除权面对「已经训进模型」时有哪几种做法?SISA 4 分片和 8 分片的成本/精度各是多少?这一节真正的结论是什么?
👀 答案
- ① 直接标识符(姓名/手机号/身份证,一个字段就能指到人)、② 准标识符(生日/性别/邮编/科室/机型,单独无害但组合起来是指纹)、③ 敏感属性(诊断/收入/取向,攻击者最终想要的结果)。join 用的是第二层。因为攻击链是「外部名单(姓名+准标识) → 用准标识 join → 拿到敏感属性」,这条链上根本不经过第一层,所以你对第一层做的删除、哈希、加密全部无效。
- 唯一率 94.9%(k<5 的记录占 100.0%,最小等价类 1)。只把生日粗化到年之后掉到 6.6% —— 一个字段的粒度值 88 个百分点。说明风险来自组合而不是某一列,任何只按列打勾的检查表都抓不到它。
- ①自由文本里的 PII(工单备注、客服会话)②伪 ID / 内部 user_id(本身不是 PII,但它是把所有表串起来的钥匙,对外必须换成一次性 ID)③行为指纹(轨迹、app 打开序列,没有任何一列是 PII)④图片/音频。行为序列没法删列,是因为高维稀疏数据几乎必然唯一 —— 一个人 30 天的打开时间序列在千万人库里也基本只属于他,只有降低分辨率一条路。
- 死法一:空间可枚举。 实跑纯 Python 单核 0.7–1.1 M hash/s,扫完手机号全空间(约 2.0×10¹⁰)5–8 小时;一张消费级 GPU 只要手机号 0.95 秒、身份证 6.1 秒。⭐哈希不是加密,哈希函数无密钥且公开。死法二:分布原样保留 —— 实跑城市列哈希前后的频率向量逐位完全相同(0.3403/0.2192/0.1700/…),攻击者按频率排序就能映射回去,一个哈希都不用破。和 ECB 是同一个道理:ECB 把每块都正确加密了,但相同明文块产生相同密文块,块间对应关系没动,所以图案还在。⭐ 确定性脱敏保护的是"值",泄漏的是"结构",而重识别要的就是结构。
- 盐就是密钥,按密钥管理那一套来。①盐不能和数据存在一起(一起被拖走等于没加)②必须能轮换③全局盐才能跨表 join,每次发布换盐才能防多次发布交叉④长度和随机性要够(写死
"salt"等于没加)。放 KMS、永不导出、支持新旧共存 —— 就是 KEK/DEK 那套。 - 重识别:唯一率 94.9% → 0%,最小等价类 18 人;精度:AUC 0.7345 → 0.7342,只掉 0.0003。而整列删掉掉 0.1528,是分箱代价的 500 倍 —— ⭐ 所以脱敏的默认动作应该是「降分辨率」,不是「删列」。
- ①同质攻击(k 够大但那组人得同一种病,补丁是 l-多样性 / t-接近)②背景知识攻击 ③多次发布交叉(两份各自 5-匿名的数据交起来 k 可能是 1,所以 k 必须对历史全部发布版本的并集计算)。实跑:满足 5-匿名的记录占全部 62.0%,其中 3.8% 落在"某个诊断占 ≥80%"的等价类,0.7% 落在只有一种诊断的类里。⭐ k-匿名保证"认不出是哪个人",不保证"猜不到他的秘密"。
- 因为噪声可以被重复查询取平均消掉。实跑差分攻击(真值 41 vs 排除张三的 40,每次 ε=1):重复 1 次判对 61.6%,10 次 79.4%,100 次 99.7%,200 次 100.0%。⭐⭐ 两条铁律:①同一个查询必须缓存并返回同一个答案 ②全部 ε 累加记账,账花完就拒绝服务。没有这两条,只是把攻击成本从 1 次变成 200 次。另外 ε=1 时相对误差在 n=3 的分组上是 23.0%,n=5000 上是 0.0% —— DP 对大盘几乎免费,对小分组毁灭性。
- 94.9%(数据集在 {生日,性别,邮编} 上的唯一率)× 92%(外部马拉松名单对当地成年常住人口的覆盖率)= 87.3%。评审没拦住的三个原因:①清单是一张 PII 字段表,生日/性别/邮编都不在表上,逐项打勾全通过 ②审查方式是逐列看,从没算过组合 ③会议纪要把"已删除直接标识符"写成了"已匿名"。改进后按
{5岁分箱, 性别, 邮编前3位}重发,最小等价类 18 人,用同一份名单再跑匹配率 0%,而下游三个科研项目的指标没有一个掉超过 0.005。 - 只删数据不动模型(⚠️通常不够)/ 全量重训(100% 成本,攒批周期执行,最常见的现实解)/ SISA 分片重训 / 近似遗忘(⚠️没有强保证,别写进合规承诺)/ DP 训练当事前防线。实跑:4 分片 AUC 0.7162(−0.0006),成本 25%;8 分片 0.6982(−0.0185),成本 12.5% —— 成本再减半但精度掉了 30 倍,分片数不是越多越好。⭐⭐ 真正的结论:可遗忘性是架构属性不是事后操作 —— 等删除请求来了才想办法,就只剩"全量重训"和"装作没这回事"两个选项。
🛑 可以停在这里
⚡ 走神救援
⭐⭐核心:删掉姓名手机号身份证之后,你手里的仍然是一份能认出人的数据 —— 认出一个人不需要标识符,只需要几个谁看了都不觉得敏感的字段凑在一起。 PII 分三层:①直接标识符(一个字段指到人)②准标识符(生日/性别/邮编/科室/机型,单独无害、组合是指纹)③敏感属性(诊断/收入,攻击者最终想要的)。💀重识别攻击永远是「外部名单(姓名+准标识) → 用准标识 join → 拿到敏感属性」,这条链根本不经过第一层 —— 所以你在①上做的删除、哈希、加密对它完全没有影响。实跑 296,000 条:
{生日(到天),性别,邮编5位}唯一率 94.9%、k<5 占 100%、最小等价类 1;⭐只把生日粗化到「年」,唯一率就掉到 6.6% —— 一个字段的粒度值 88 个百分点;泛化成{5岁分箱,性别,邮编前3位}后唯一率 0、最小等价类 18。⭐风险来自组合,不是某一列 —— 任何只按列打勾的隐私检查表都抓不到它。 自动扫描(列名+正则+校验位+唯一度)系统性漏四类:自由文本里的 PII、伪 ID(内部 user_id 是把所有表串起来的钥匙)、行为指纹(轨迹/序列,高维稀疏几乎必然唯一,只有降分辨率一条路)、图片音频。🧪七种脱敏各有各的失效方式,其中确定性哈希有两个死法:💀①空间可枚举 —— 实跑纯 Python 单核 0.7–1.1 M/s、扫完手机号全空间 5–8 小时,一张消费级 GPU 只要 0.95 秒(身份证 6.1 秒),⭐哈希不是加密,它无密钥且公开,等于用一把全世界都有的钥匙锁门;💀②分布原样保留 —— 实跑城市列哈希前后频率向量逐位完全相同,攻击者按频率排序就能映射回去,一个哈希都不用破。⚠️⚠️这就是密码学 07 那只 ECB 企鹅:ECB 把每块都正确加密了,但相同明文块产生相同密文块、块间对应关系没动,所以图案还在。⭐一句话:确定性脱敏保护的是"值",泄漏的是"结构",而重识别要的从来就是结构。 🧂盐就是密钥(见密码学 19):不能和数据存一起、必须能轮换、全局盐才能 join、写死"salt"等于没加。📏k-匿名是这章唯一能算出数的东西(k 取所有等价类的最小值不是平均值),⭐⭐最重要的一组数字:5 岁分箱 + 邮编前 3 位让唯一率 94.9%→0、最小等价类 18,而模型 AUC 只从 0.7345 掉到 0.7342(−0.0003);💀整列删掉却掉 0.1528,是分箱代价的 500 倍 —— ⭐所以脱敏的默认动作是「降分辨率」不是「删列」。k-匿名的三个漏洞:同质攻击(实跑:满足 5-匿名的 62.0% 记录里仍有 3.8% 落在"某诊断占≥80%"的等价类,⭐k 保证"认不出是谁",不保证"猜不到他的秘密",补丁是 l-多样性/t-接近)、背景知识攻击、💀多次发布交叉(两份各自 5-匿名的数据交起来 k 可能是 1,所以 k 要对历史全部版本的并集算)。🎲差分隐私:Lap(0, Δf/ε),ε 越小噪声越大;实跑 ε=1 时误差中位数 0.70,但相对误差在 n=3 的分组上是 23.0%、n=5000 上是 0.0% —— ⭐DP 对大盘几乎免费,对小分组毁灭性,而小分组正是最需要保护的那批人。💀"我加了随机噪声"不等于 DP:实跑差分攻击(真值 41 vs 排除张三的 40),重复 1 次判对 61.6%、100 次 99.7%、200 次 100.0% —— ⭐⭐DP 不是"加噪声"这个动作,是"预算"这个制度:①同一查询必须缓存返回同一答案 ②全部 ε 累加记账,花完就拒服。💀事故:某健康 App 发布 296,000 条"已匿名"就诊记录,删了姓名手机号身份证,留了生日(到天)/性别/邮编5位/诊断。40 天后一名研究者用一份公开的马拉松报名名单做 join:94.9% 唯一率 × 92% 名单覆盖率 = 87.3% 的人被指名道姓对上,其中 4,180 人属于精神科/传染病科;数据召回时已有 2 家合作方二次分发,无法确认删干净,计划停摆 14 个月。三个没拦住的原因:①检查清单是一张 PII 字段表,生日/性别/邮编都不在表上,逐项打勾全通过 ②审查是逐列看的,从没算过组合 ③⭐⭐会议纪要把"已删除直接标识符"写成了"已匿名" —— 这两句话之间隔着一整章的距离。改进后按{5岁分箱,性别,邮编前3位}重发,最小等价类 18、同一份名单匹配率 0%,下游三个项目指标没有一个掉超过 0.005。📦最小化四问(说不出使用方就别采、能粗就别细、说不出天数默认 30 天、泄漏会危及人身就走单独管线);⚠️留存里最常漏的是备份与冷归档;💀一条数据的七个藏身处:主库/数仓快照/特征存储在线+离线/备份归档/下游导出/日志/向量索引。🧹删除权最硬的一半是"已经训进模型的怎么办":全量重训(100%,攒批执行,最现实)、SISA 分片重训(实跑 4 分片 AUC 0.7162 只掉 0.0006、成本 25%;8 分片 0.6982 掉 0.0185、成本 12.5% —— 成本再减半精度却掉 30 倍,分片不是越多越好)、近似遗忘(⚠️没有强保证,别写进合规承诺)、DP 训练当事前防线。⭐⭐这一节真正的结论:可遗忘性是一个架构属性,不是一个事后操作 —— 等删除请求来了才想办法,你只剩"全量重训"和"装作没这回事"两个选项。 最后记住一句可以当场用的话:⭐有人说"我们已经匿名了",你就问一句 ——「最小等价类是多少人?」答不上来就是没做。
下一节 👉 20-实战与挑战项目.md