模型隐私与模型窃取风险:成员推断、模型抽取和输出接口防护
模型隐私与模型窃取风险:成员推断、模型抽取和输出接口防护
站内搜索
直接问 AI

模型隐私与模型窃取风险:成员推断、模型抽取和输出接口防护

一个训练好的模型会记住训练数据——不是比喻,是可以被测量出来的。如果某条记录参与了训练,模型对它的预测通常比对没见过的记录更有把握,而这个置信度差异足以让攻击者判断「这条记录在不在训练集里」。对于用病历、聊天记录或私有代码训练的模型,这本身就是泄漏。

一、差分隐私(DP)给出的是什么保证

为了严格防御精确记忆和成员推断,生产系统依赖于差分隐私,特别是 DP-SGD(差分隐私随机梯度下降)。DP 提供了一种数学保证:包含或排除单个训练样本不会显著改变最终的模型权重。

如果对于相差最多一条记录的所有数据集 $D$ 和 $D’$,以及输出集的所有子集 $S \subseteq \text{Range}(\mathcal{M})$,一个随机算法 $\mathcal{M}$ 满足 $(\epsilon, \delta)$-差分隐私,那么:

$$ P[\mathcal{M}(D) \in S] \le e^\epsilon P[\mathcal{M}(D’) \in S] + \delta $$

  • $\epsilon$ (隐私损失边界 / Privacy Loss Bound): 控制特定模型输出的概率变化程度。$\epsilon$ 越低,隐私保护越强。
  • $\delta$ (失败概率): 严格违反 $\epsilon$ 边界的密码学概率,通常设置为 $< 1/|D|$。

二、真实世界的成员推断攻击向量

高级的成员推断攻击超越了简单的置信度阈值判断。最先进的攻击,如 LiRA (Likelihood Ratio Attack),会训练局部影子模型(Shadow Models)。对于目标样本 $(x, y)$ 和模型 $\theta$,攻击者计算似然比:

$$ \Lambda(x, y) = \frac{P(f_\theta(x)=y | (x, y) \in D_{train})}{P(f_\theta(x)=y | (x, y) \notin D_{train})} $$

如果与影子模型预测的高斯分布相比,对数似然异常高,则该样本被标记为成员。这种攻击向量针对在专有代码库或私人 PII 上训练的 LLM 极其有效。

三、PyTorch 实现:DP-SGD 梯度裁剪与噪声注入

为了在训练期间强制执行 DP 边界,在添加高斯噪声之前,我们必须限制梯度的敏感度(Sensitivity)。下面是 DP-SGD 逐样本梯度裁剪与噪声注入的实现。

import torch
import torch.nn as nn

def dp_sgd_step(model: nn.Module, optimizer: torch.optim.Optimizer, 
                loss_fn, x: torch.Tensor, y: torch.Tensor, 
                max_grad_norm: float = 1.0, noise_multiplier: float = 0.5):
    optimizer.zero_grad()
    
    # 前向传播
    logits = model(x)
    # 计算每个样本的损失 (reduction='none' 是必需的)
    losses = loss_fn(logits, y)
    
    saved_grads = {name: torch.zeros_like(param) for name, param in model.named_parameters()}
    
    # 1. 逐样本梯度计算与裁剪 (Per-sample Gradient Clipping)
    for i in range(x.size(0)):
        losses[i].backward(retain_graph=True)
        torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=max_grad_norm)
        
        for name, param in model.named_parameters():
            if param.grad is not None:
                saved_grads[name] += param.grad.data
            param.grad = None # 清除梯度,准备下一个样本
            
    # 2. 注入受敏感度 (max_grad_norm) 缩放的高斯噪声
    for name, param in model.named_parameters():
        if param.requires_grad:
            noise = torch.normal(
                mean=0.0, 
                std=noise_multiplier * max_grad_norm, 
                size=param.size(), 
                device=param.device
            )
            # 在 Batch 上平均带噪梯度
            param.grad = (saved_grads[name] + noise) / x.size(0)
            
    optimizer.step()

四、企业级推理架构边界防护

为了防止模型抽取(通过 API 滥用的 Surrogate Training),生产级 API 必须部署多层可观测性和信息熵限制:


graph LR
    A[客户端请求] --> B[API 网关 / WAF]
    B --> C{查询信息熵分析}
    C -->|高方差异常| D[速率限制 / Tarpit 降速]
    C -->|正常| E[推理引擎 Inference Engine]
    E --> F[输出扰动层 Output Perturbation]
    F -->|仅返回 Top-K Logits| A
    F -->|低精度浮点数截断| A

核心防御策略:

  • 输出扰动 (Output Perturbation): 永远不要返回原始的概率分布或完整的 Logits 向量。仅返回 Top-K 类别,并使用低精度浮点数截断(例如,舍入到小数点后两位)。
  • 查询降维检测: 识别主动学习(Active Learning)启发式算法。如果一个 IP 段系统地在几何决策边界附近查询模型(对抗性探索),立即触发 API Tarpitting(故意延迟响应以消耗攻击者资源)。

五、隐私防护的工程取舍

模型隐私防护不是单一开关。差分隐私、输出裁剪、速率限制和审计日志解决的是不同层面的风险,而且都会影响可用性、准确率或开发体验。实际部署时应把这些控制拆开评估。

控制措施 主要防御对象 代价 适合场景
DP-SGD 成员推断、训练样本记忆 训练成本上升,准确率可能下降 敏感个人数据训练
Top-k 输出 模型抽取、边界探测 可解释性和调试信息减少 公开推理 API
概率四舍五入 高精度置信度滥用 下游排序精度可能降低 不需要完整 logits 的分类服务
查询模式检测 主动学习式抽取 需要存储和分析请求轨迹 高价值专有模型接口

六、上线前的隐私审计清单

发布模型 API 前,至少应回答下面几个问题:训练数据是否包含个人或私有记录;是否有重复样本或极少数类样本;接口是否返回完整概率向量;是否能按用户、IP、token 追踪异常查询;是否有针对成员推断的影子模型测试结果。

训练数据敏感性: high / medium / low
输出粒度: label-only / top-k / full logits
单用户速率限制: requests per minute
MIA 测试: AUC, precision@risk-threshold
抽取测试: surrogate accuracy vs query budget
保留日志: request hash, output class, confidence bucket, policy decision

如果模型必须服务外部用户,默认不应暴露完整 logits。即使业务需要概率,也可以返回分桶后的置信度,例如 high、medium、low,或者只保留两位小数。隐私风险往往来自大量看似无害的高精度响应被长期收集。

DP-SGD 最容易写错的一步:裁剪必须逐样本

差分隐私的保证建立在一个前提上:任何单条样本对最终模型的影响,都被限制在一个已知的上界内。梯度裁剪就是在制造这个上界——所以裁剪的对象必须是单个样本的梯度,不是一个批次的平均梯度。

而 PyTorch 的默认行为恰恰是后者。loss.backward() 累加的是整批的梯度,clip_grad_norm_ 裁的也是这个累加结果:

# 错:裁的是批次梯度,DP 保证不成立
loss = criterion(model(x_batch), y_batch)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), C)   # 批次级

这样写不会报错,训练照常收敛,加的噪声也照常加——但那个 ε 是算不得数的。因为一条离群样本可以让整批梯度变得很大,裁剪之后它在批内所占的比例仍然很高,它对模型的影响并没有被单独限制住。

正确做法是先算出每个样本各自的梯度、各自裁剪,再求和加噪:

for x_i, y_i in zip(x_batch, y_batch):              # 概念上逐样本
    g_i = grad_of(loss(model(x_i), y_i))
    g_i = g_i * min(1.0, C / (g_i.norm() + 1e-6))   # 逐样本裁剪
    accum += g_i
accum += torch.normal(0, sigma * C, accum.shape)    # 噪声尺度由 C 决定

第三节给出的 dp_sgd_step 就是按这个原则写的——它对每个样本单独调 backward、单独裁剪、再累加,正是为了让每条样本的贡献各自受限。那段代码里 reduction='none' 和循环末尾的 param.grad = None 都不是可以省略的细节:前者保证拿到的是逐样本损失,后者保证下一个样本不会把上一个的梯度算进去。

朴素地写成 Python 循环会慢得无法接受,所以实践中要用支持逐样本梯度的库(Opacus 之类),它们通过 hook 在反向传播时截取每个样本的贡献。用不用这类库,是「有没有 DP」的区别,不是「快不快」的区别。

还有一个配套细节:噪声的标准差正比于裁剪阈值 C。所以 C 调大不只是「裁得松一点」,它会同时把噪声放大。把 C 设得很大以求不损失精度,实际得到的是「几乎没裁剪 + 很大的噪声」,两头不讨好。

ε 是什么,以及它不保证什么

隐私预算 ε 经常被当作一个越小越好的分数报出来,但很少说清它的实际含义。它的定义是:数据集里增删任意一条记录,模型输出任何特定结果的概率,最多变化 e^ε 倍。

代入几个数看一下量级:

ε = 0.1  ->  e^0.1 ≈ 1.11      概率最多变化约 10%
ε = 1    ->  e^1   ≈ 2.72      概率最多变化约 172%
ε = 3    ->  e^3   ≈ 20.1      概率最多变化约 20 倍
ε = 10   ->  e^10  ≈ 22026     概率最多变化约 2.2 万倍

所以 ε = 10 在数学上几乎不构成任何限制——而它在实际论文和产品里并不罕见。报告 ε 时必须同时说清楚它对应的实际保护强度,只给一个数字容易造成误解。

更重要的是它保证什么:

  • 不保护群体层面的信息。DP 限制的是单条记录的影响。如果整个数据集来自某个特定群体,模型学到的群体特征照样会泄漏,这不在 DP 的保护范围内。
  • 不阻止模型被提取。DP 针对的是训练数据泄漏,不是模型权重本身被窃取或被查询复现。那属于另一类威胁,要靠速率限制和输出扰动。
  • 预算会累加。同一份数据训练多次、或者一个模型被反复查询,ε 是累计的。只在单次训练上算 ε 而忽略后续查询,得到的数字是偏乐观的。

最后一条实践建议:把 ε 和它对应的具体威胁写在一起。「ε = 3,对应的是攻击者即便掌握其余全部记录,也无法以超过某个置信度判断某条特定记录是否参与了训练」——这样的表述能被验证、能被讨论;单独一个「ε = 3」不能。

七、参考文献

发表回复

向下探索