duckdb-luajit:病历出域前三刀脱敏,全在 SQL 里
一份病历要出域,通常要过三拨人:先有人把身份证、手机号抹掉,再有人把入院日期改成假的,最后有人盯着病历正文一行行删名字。三拨人做的事,其实都是同一件事——让这份数据还能用来研究,但认不出是谁。
前两刀好办,规则是死的。第三刀最费人:病历正文是自由文本,名字混在句子里,正则写不出来。
这篇文章讲的是把这三刀都收进 SQL:同一个 DuckDB 扩展 duckdb-luajit 里的 privacy 库,现在有 mask_cn、dateshift、redact_text 三个 op,正好对应三刀,而且第二刀和第三刀共用同一个时间轴。
● ● ●
出域这道题,难在三处
- 静态标识符
:身份证、手机号、银行卡、姓名、邮箱——国家有明确的去标识化规矩(GB/T 37964),露前几位露后几位不能拍脑袋。 - 时间
:直接删掉最省事,但住院时长、两次检查的间隔、术后第几天复发,全建立在日期上。删了日期,数据就没法做研究了。 - 自由文本
:病历正文里的人名、电话、日期。这是三处里唯一不能靠"格式"识别的—— 张三和腹痛在正则眼里都是两个汉字。
● ● ●
库本身是 Lua 写的,但调用面是 SQL。为了让正文像 SQL 而不是一堆结构体,先包一层宏:
CREATE MACRO mask_id(v) AS luajit_s('privacy', {'op':'mask_cn','kind':'idcard','v':v});
CREATE MACRO mask_mobile(v) AS luajit_s('privacy', {'op':'mask_cn','kind':'mobile','v':v});
CREATE MACRO mask_name(v) AS luajit_s('privacy', {'op':'mask_cn','kind':'name','v':v});
CREATE MACRO shift_day(v, k) AS luajit_s('privacy', {'op':'dateshift','v':v,'key':k,'days':180});
CREATE MACRO redact(v, d, k) AS json_extract_string(
luajit_s('privacy', {'op':'redact_text','v':v,'dict':d,'key':k,'days':180}), '$.text');
包完之后,三刀各自就是一次函数调用。
三刀脱敏流程:静态标识符 → 时间轴 → 自由文本,最后做脱敏效果自证
● ● ●
第一刀:静态标识符,露多少有规矩
mask_cn 是规则表驱动的:身份证留前 6 位(地区)和后 4 位,手机号留前 3 后 4,银行卡留前 6(BIN,能看出是哪家银行)后 4,姓名保留姓。
SELECT subject_id,
mask_id(idcard) AS idcard_masked,
mask_mobile(mobile) AS mobile_masked,
mask_name(name) AS name_masked
FROM raw_events;
输出:
310101198512150002 → 310101********0002
44030119900101123X → 440301********123X
13800138000 → 138****8000
欧阳锋 → 欧阳*
三个细节值得一说:复姓认得出(欧阳锋 保留 欧阳 而不是 欧);格式保持,脱敏列还是原来那么长,下游的定长列、校验规则不用改;识别失败的会 fail-closed 退成通用星号,不会原样漏出去。
如果要拿脱敏后的姓名做连接键(比如同一患者跨表关联),把 mask_cn 的模式参数换成 hash——同样的输入永远得到同样的指纹,可连接,但反推不回原文。
● ● ●
第二刀:日期不能删,只能平移
dateshift 的做法是给每个患者算一个只依赖患者 ID 的偏移量:同一个患者所有日期加同一个天数,不同患者加不同天数。
SELECT subject_id, event_date, shift_day(event_date, subject_id) AS event_date_shifted
FROM raw_events;
P001 2026-03-04 → 2026-05-14
P001 2026-03-09 → 2026-05-19
P002 2026-03-05 → 2026-07-25
这个设计的价值全在"只依赖 ID"这五个字上:同一个人所有日期同向平移相同的天数,于是——
住院时长不变。P001 两次事件间隔 5 天,平移后还是 5 天。 事件相对顺序不变。谁先谁后、隔了几天,全保留。 但绝对日期被彻底打乱。P001 的记录从 3 月跳到了 5 月,P002 从 3 月跳到了 7 月,两份记录没法跟公开日历对上。
日期算术用的是纯整数的 civil-days 实现,闰年精确——这类代码错一天很难被发现,所以库里的回归套件拿 1900 到 2200 年、含全部闰日的 2,055 组日期跟 Python 的日期库逐例对过。
● ● ●
第三刀:自由文本里的人名,正则认不出来
病历正文得靠 redact_text。它不假装能"认出"中文人名——人名由你传字典,脚本负责把字典里出现的词、以及能靠格式认出来的东西(手机号、身份证、邮箱、URL、IP、日期)替换成占位符。
SELECT subject_id, redact(note, [name], subject_id) AS note_redacted
FROM raw_events;
原文 患者欧阳锋,电话13800138000,身份证310101198512150002,2026-03-04 入院。
脱敏 患者[**Name1**],电话[**PHONE**],身份证[**ID**],2026-05-14 入院。
输出是占位符(MIMIC 数据集就是这个风格),不是直接删掉。三个原因:
读得懂。[Name1]诉头痛 一眼就知道是"某位患者主诉头痛";删成空白,这段就废了。
能对齐。同一个字典跑一整批文本时,编号是按字典顺序给的——同一份字典下,同一个患者永远拿到同一个编号,跨行、跨表都能对上。反过来,如果按"每行第一个出现的名字"编号,同一份字典跑两行就会把两个人编成同一个 Name1,对齐直接错。
幂等。已经脱敏过的文本再跑一遍,结果不变——占位符里没有能被二次命中的模式。
最后它还会带一句声明:规则只覆盖规则表里列出的模式,没匹配上的自由文本不保证没有隐私信息。这句话不是免责,是诚实——规则驱动的文本脱敏本来就给不了全覆盖保证,写清楚比假装全面更负责。
● ● ●
第二刀和第三刀,共用一条时间轴
这是三刀里最容易出岔子的地方:结构化列里的日期平移了,正文里同一件事的日期却没动,两条记录自相矛盾——矛盾本身就是泄漏点,能反推出偏移量。
所以 redact_text 接了 key 之后,对文本里的日期用的是跟 dateshift完全相同的偏移(默认盐参数也一致)。看 P002 那行:病历里的日期是 2026-03-05,正文里写的是 2026-03-06,平移后变成 2026-07-25 和 2026-07-26——两天都加了同样的 142 天,间隔一天的关系原样保留,跟结构化列也对得上。
一条 SQL 出来的表,日期和文本里的日期在同一个时间轴上,这份数据才能放心出域。
● ● ●
脱够了没有,让报告说话
三刀脱完,最后一个问题是:够了没有? 说"够了"得有依据,所以库里还有 kanon_report:它把表按准标识符分组,逐组给出 k 值、l 值(distinct-l 与 entropy-l 双判据)、t 值(t-closeness),加上抑制率和泛化损失,直接给一个裁定:
{"verdict":"fail","k_ok":true,"l_ok":false,"t_ok":false,
"violations":["l","t"],"suppression_rate":0,"min_distinct_l":1,"max_t":0.25}
上面这组测试数据,k 达标但 l 和 t 没达标,报告就直说 fail、把违规项列出来——不够就是不够,报告不替你遮掩。(t-closeness 会标注用的是哪个度量:分类属性用总变差,有序数值用归一化的一维 EMD,两者结论可能不同。)
如果脱敏后的数据还要出统计结果,dp_compose、dp_alloc、dp_budget 三个 op 管 ε 预算台账:100 次 ε=0.01 的查询,线性组合算是 1.0,用强组合界只算 0.4899——预算花在哪、还剩多少,都留痕。
● ● ●
这些数字都验过
库里每个 op 的回归测试都在仓库里,不是文章里的说法:
privacy库的 Lua 单元断言 147 条,DuckDB 端到端断言 24 条,全过。 文本脱敏的规则另写了一份 Python 实现做交叉验证——1,219 组输入,输出逐字比对,0 处不一致;并且回查了这 1,219 组里每条规则各命中多少次,确认没有"因为没测到所以没失配"。 日期平移的 2,055 组日期(1900–2200,含全部闰日)与 Python 日期库逐例一致。
三刀合起来,一张病历表从原始状态到能出域,是一条 SQL 的事。
● ● ●
参考来源
- 01duckdb-luajit-libs
(本文的库与全部回归套件,MIT)— https://github.com/alitrack/duckdb-luajit-libs - 02MIMIC-IV
(临床数据去标识化与日期平移的行业惯例)— https://physionet.org/content/mimiciv/2.2/ - 03The Algorithmic Foundations of Differential Privacy
(Dwork & Roth,组合界出处)— https://www.cis.upenn.edu/~aaroth/Papers/privacybook.pdf - 04GB/T 37964-2019
《信息安全技术 个人信息去标识化指南》、GB/T 42460-2023《信息安全技术 个人信息去标识化效果评估指南》——标准文本可在国家标准全文公开系统查询。