对抗训练:FGM与PGD方法介绍
为什么会有对抗训练
当我们对一个事物进行建模或描述时,即使这种描述可能存在偏差,它仍然是对该事物本质的一种映射或表达。对于一张猫的图片来说,我们知道它是猫,但当我们对图片进行裁剪、缩放、遮掩和模糊后(对于模型而言,常见的数据增强(如裁剪、模糊等)是有意识地改变输入,但它们通常不会欺骗模型。而对抗样本 则是通过在输入中加入微小且人类难以察觉的扰动 ,以最大化模型的预测错误,从而检验模型的鲁棒性。这里只是为了方便理解故使用这些),我们可能不太能认出这是猫,但他实际上仍是猫。为了让模型能在这种情况下认出他是猫,所以我们认为构建这种人类难以察觉但会导致模型错误预测的微小扰动样本加入到模型的训练中,能使模型具备辨别的能力,而这种生成困难表示的方式就是对样本进行数据扰动,而在深度学习中,由于深度模型具有极高的拟合能力,它们很容易在训练数据上表现良好,但在面对稍有偏差的输入时却可能表现极差。而训练模型的数据往往本身就是有噪声且不充分的,所以我们需要引入对抗训练,让模型能优化到那些他原本可能薄弱的实例。
数据扰动的几种方式
1. FGM
在FGM中,数据扰动让输入或嵌入在最大化损失的方向上沿梯度单位方向扰动一次(通常要对梯度进行归一化以控制扰动幅度,常见的是使用L2归一,即将梯度除以它的L2范数),使得模型损失最大程度增长,让模型更能学到知识,至于这里的梯度是如何来的,如果你看过使用FGM的代码,你会发现在一个batch中调用了两次model和loss,第一次正是用来计算梯度帮助第二次生成对抗样本的
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> x a d v = x + ε ⋅ ∇ x L ( x , y ) ∥ ∇ x L ( x , y ) ∥ x_{adv} = x + ε \cdot \frac{\nabla_x L(x, y)}{\|\nabla_x L(x, y)\|} </math>xadv=x+ε⋅∥∇xL(x,y)∥∇xL(x,y)
- ε 扰动强度
- ∇_x L(x, y) 损失函数L的梯度
ini
class FGM:
def __init__(self, model):
self.model = model
self.backup = {}
def attack(self, epsilon=1.0, emb_name='embed_tokens'): #例子是nlp文本生成的,其中embedding这里会给出每个token的向量,所以对他的参数进行扰动就能实现对样例的扰动
for name, param in self.model.named_parameters():
if param.requires_grad and emb_name in name:
self.backup[name] = param.data.clone()
norm = torch.norm(param.grad)
if norm != 0:
r_at = epsilon * param.grad / norm
param.data.add_(r_at)
def restore(self, emb_name='embed_tokens'):
for name, param in self.model.named_parameters():
if param.requires_grad and emb_name in name and name in self.backup:
param.data = self.backup[name]
self.backup = {}
'''
在batch中
'''
output=model(**batch)
loss=output.loss
loss.backward()
fgm.attack() #进行数据扰动
output2=model(**batch)
loss2=output2.loss
loss2.backward()
fgm.restore() #恢复扰动数据,因为有些时候的扰动实际上是通过作作用于模型参数做的,如nlp,因为他的原始输入是文字等,无法扰动。所以攻击完后要复原,趁着参数还没更新
2. PGD
与 FGM 只执行一次线性扰动不同,PGD 会在一个局部邻域内多步迭代地进行扰动,每一步都在当前对抗样本基础上进一步最大化损失,并通过投影确保扰动不会超过指定的上限,从而更有效地逼近最坏情况。
第一个样本
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> x 0 a d v = x + δ 0 , 其中 δ 0 ∼ U ( − ϵ , ϵ ) x_{0}^{adv} = x + δ_0,\quad \text{其中 } δ_0 \sim \mathcal{U}(-\epsilon, \epsilon) </math>x0adv=x+δ0,其中 δ0∼U(−ϵ,ϵ)
后续样本
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> x t + 1 = x t + α ∗ s i g n ( ∇ x L ( x t , y ) ) x t + 1 = c l i p ( x t + 1 , x − ε , x + ε ) x_{t+1} = x_t + α * sign(∇x L(x_t, y))\\ x{t+1} = clip(x_{t+1}, x-ε, x+ε) </math>xt+1=xt+α∗sign(∇xL(xt,y))xt+1=clip(xt+1,x−ε,x+ε)
clip 对扰动进行裁剪防止过大或过小
python
class PGD:
def __init__(self, model, emb_name='model.encoder.embed_tokens', epsilon=1.0, alpha=0.3, K=3):
self.model = model
self.emb_name = emb_name # 目标embedding名称,因为例子是nlp文本生成的,其中embedding这里会给出每个token的向量,所以对他的参数进行扰动就能实现对样例的扰动
self.epsilon = epsilon # 扰动半径
self.alpha = alpha # 每步步长
self.K = K # 攻击步数
self.emb_backup = {} # 原始embedding备份
self.grad_backup = {}
def attack(self, is_first_attack=False):
for name, param in self.model.named_parameters():
if param.requires_grad and self.emb_name in name:
if is_first_attack:
self.emb_backup[name] = param.data.clone()
norm = torch.norm(param.grad)
if norm != 0 and not torch.isnan(norm):
r_at = self.alpha * param.grad / norm
param.data.add_(r_at)
param.data = self.project(name, param.data)
def restore(self):
for name, param in self.model.named_parameters():
if param.requires_grad and self.emb_name in name:
assert name in self.emb_backup
param.data = self.emb_backup[name]
self.emb_backup = {}
def project(self, param_name, param_data):
r = param_data - self.emb_backup[param_name]
if torch.norm(r) > self.epsilon:
r = self.epsilon * r / torch.norm(r)
return self.emb_backup[param_name] + r
def backup_grad(self):
for name, param in self.model.named_parameters():
if param.requires_grad and param.grad is not None:
self.grad_backup[name] = param.grad.clone()
def restore_grad(self):
for name, param in self.model.named_parameters():
if param.requires_grad and param.grad is not None:
param.grad = self.grad_backup[name]
'''
在batch中
'''
outputs = model(input_ids=batch['input_ids'], attention_mask=batch['attention_mask'], labels=batch['labels'])
loss = outputs.loss
epoch_loss += loss.item()
loss.backward()
pgd.backup_grad() #保留原梯度,用来后续还原
for t in range(pgd.K):
pgd.attack(is_first_attack=(t==0)) #数据扰动
if t != pgd.K - 1:
model.zero_grad() #清除中间梯度防止污染累计梯度
else:
pgd.restore_grad() #最后一次攻击后恢复梯度
outputs_adv = model(input_ids=batch['input_ids'], attention_mask=batch['attention_mask'], labels=batch['labels'])
adv_loss = outputs_adv.loss
adv_loss.backward() #求梯度用于优化
pgd.restore() #恢复扰动数据
PGD多次生成对抗样本并优化的优势
- 攻击能力更强
- 搜索更充分
- 克服局部最优解
缺点显而易见,太耗时间了,所以对于PGD而言,对抗次数是一个重要的超参数,需要平衡性能与效果
为什么对抗训练有效
- 对抗训练不仅仅是鲁棒性提升,它本质上是一种正则化方法,可以减少过拟合;
- 它也迫使模型在局部邻域保持一致预测(类似一致性正则项);
- 可以提高模型对分布外样本(out-of-distribution)更稳定。
因此,对抗训练不仅是一种提升鲁棒性的技术手段,更是一种提升泛化能力、缓解过拟合的正则化策略,尤其在面对现实世界中非理想输入时,它表现出明显优势。