alitrack

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 的事。

● ● ●

参考来源

  1. 01duckdb-luajit-libs
    (本文的库与全部回归套件,MIT)— https://github.com/alitrack/duckdb-luajit-libs
  2. 02MIMIC-IV
    (临床数据去标识化与日期平移的行业惯例)— https://physionet.org/content/mimiciv/2.2/
  3. 03The Algorithmic Foundations of Differential Privacy
    (Dwork & Roth,组合界出处)— https://www.cis.upenn.edu/~aaroth/Papers/privacybook.pdf
  4. 04GB/T 37964-2019
    《信息安全技术 个人信息去标识化指南》、GB/T 42460-2023《信息安全技术 个人信息去标识化效果评估指南》——标准文本可在国家标准全文公开系统查询。