練實(shí)戰(zhàn))
簡介面向深度學(xué)習(xí)安全入門與對抗攻防復(fù)現(xiàn)需求這套材料圍繞Fashion_MNIST圖像分類任務(wù)提供從對抗樣本生成到對抗訓(xùn)練防御的完整實(shí)驗(yàn)內(nèi)容適合已掌握基礎(chǔ)神經(jīng)網(wǎng)絡(luò)、希望理解模型魯棒性評估的學(xué)生與算法愛好者也可用于課程設(shè)計(jì)或安全方向的前期驗(yàn)證。壓縮包共121個(gè)文件約15.12MB其中9個(gè)Python腳本承載模型訓(xùn)練、攻擊實(shí)現(xiàn)與評估流程8個(gè)pkl與2個(gè)h5文件保存訓(xùn)練中間結(jié)果及模型權(quán)重100張jpg直觀呈現(xiàn)對抗樣本與原始圖像的對照另附md說明與gitignore便于環(huán)境整理。已有117人學(xué)習(xí)下載。讀者可據(jù)此復(fù)現(xiàn)FGSM、PGD等攻擊方法觀察細(xì)微擾動(dòng)對分類結(jié)果的影響并對比對抗訓(xùn)練前后模型在干凈樣本與對抗樣本上的準(zhǔn)確率變化進(jìn)而理解魯棒性、泛化能力與安全部署之間的權(quán)衡形成從攻擊生成、防御訓(xùn)練到指標(biāo)評估的可運(yùn)行閉環(huán)。1. 一個(gè) 93% 掉到 12% 的分類器對抗攻擊與對抗訓(xùn)練解決的是什么問題一個(gè)做課程設(shè)計(jì)的同學(xué)給我看他的模型Fashion-MNIST 測試集 93.1%我讓他把輸入疊一層 eps0.1 的擾動(dòng)再喂進(jìn)去準(zhǔn)確率掉到 11%比十類瞎猜還低。這就是對抗攻擊最反直覺的地方——擾動(dòng)小到人眼看不出模型卻徹底失明。這份 Attack_Fashion_MNIST 代碼包做的事就是把整條線完整展開先干凈訓(xùn)練一個(gè)卷積基線再用 FGSM、I-FGSM、PGD 三種方式把它打穿然后把對抗樣本塞回訓(xùn)練循環(huán)做對抗訓(xùn)練最后拿干凈準(zhǔn)確率和魯棒準(zhǔn)確率兩條曲線對照。它適合兩類人剛接觸對抗樣本、想要一個(gè)能完整跑通的最小閉環(huán)的以及要拿它當(dāng)實(shí)驗(yàn)底座換成自己的數(shù)據(jù)集或攻擊算法的。2. 目錄結(jié)構(gòu)與數(shù)據(jù)管線Fashion-MNIST 怎么讀、怎么切、怎么喂進(jìn)去拿到壓縮包先別急著執(zhí)行 train把目錄看一遍能省掉后面一半的調(diào)試時(shí)間。這個(gè)項(xiàng)目的模塊邊界切得比較克制沒有一上來就上 Lightning 或者 Hydra純 PyTorch 加一個(gè) yaml好處是可以把每一行發(fā)生的事都看清楚。2.1 從目錄樹看模塊職責(zé)的切分Attack_Fashion_MNIST/ ├── configs/base.yaml # eps / alpha / steps / lr 等全部外置 ├── data/ # 首次運(yùn)行自動(dòng)下載的 idx 文件落在這里 ├── models/ │ └── small_cnn.py # 4 層卷積 BN 全連接的分類器 ├── attacks/ │ ├── fgsm.py # 單步攻擊 │ ├── ifgsm.py # 迭代攻擊固定步長 │ └── pgd.py # 迭代攻擊 隨機(jī)起點(diǎn) 投影 ├── train_clean.py # 干凈基線訓(xùn)練 ├── train_adv.py # 對抗訓(xùn)練 ├── evaluate.py # eps 掃描輸出 csv 和曲線 ├── visualize.py # 對抗樣本網(wǎng)格圖 擾動(dòng)放大圖 ├── utils.py # 種子、ckpt 保存、指標(biāo)統(tǒng)計(jì) └── requirements.txt關(guān)鍵點(diǎn)是attacks/單獨(dú)成包。攻擊函數(shù)既被evaluate.py調(diào)用又被train_adv.py在訓(xùn)練循環(huán)里調(diào)用如果寫在評估腳本里訓(xùn)練腳本就得反向 import很容易寫出循環(huán)依賴。把它們抽出來之后所有攻擊必須滿足同一個(gè)簽名def attack_fn(model, x, y, eps, **kwargs) - torch.Tensor: 輸入干凈樣本與標(biāo)簽返回對抗樣本形狀與 x 一致取值仍在 [0,1]簽名的第 4 個(gè)位置參數(shù)留給 eps 是有意為之——evaluate.py要在一組 eps 上掃出魯棒性曲線把 eps 當(dāng)可變參數(shù)傳掃描循環(huán)才寫得干凈。2.2 DataLoader這份代碼沒做 Normalize數(shù)據(jù)這塊是第一個(gè)容易分歧的地方。絕大多數(shù)圖像分類工程會(huì)在transforms里加Normalize(mean, std)但對抗攻擊項(xiàng)目里我一般不加原因后面代碼里會(huì)顯出來。import torch from torch.utils.data import DataLoader, random_split from torchvision import datasets, transforms DATA_ROOT ./data # 只做 ToTensor像素被縮放到 [0, 1]不做 Normalize base_tf transforms.Compose([transforms.ToTensor()]) def build_loaders(batch_size128, val_ratio0.1, seed42, num_workers2): full_train datasets.FashionMNIST(DATA_ROOT, trainTrue, downloadTrue, transformbase_tf) test_set datasets.FashionMNIST(DATA_ROOT, trainFalse, downloadTrue, transformbase_tf) n_val int(len(full_train) * val_ratio) # 60000 * 0.1 6000 train_set, val_set random_split( full_train, [len(full_train) - n_val, n_val], generatortorch.Generator().manual_seed(seed), # 切分可復(fù)現(xiàn) ) train_loader DataLoader(train_set, batch_sizebatch_size, shuffleTrue, num_workersnum_workers, pin_memoryTrue, drop_lastTrue) val_loader DataLoader(val_set, batch_size512, shuffleFalse, num_workersnum_workers) test_loader DataLoader(test_set, batch_size512, shuffleFalse, num_workersnum_workers) return train_loader, val_loader, test_loader參數(shù)上batch_size在干凈訓(xùn)練階段用 128 就夠了對抗訓(xùn)練階段同樣 128但顯存占用大約是干凈訓(xùn)練的兩到三倍因?yàn)楣裟菐撞竭€要保留計(jì)算圖val_ratio0.1是給「選超參」留的位置測試集從頭到尾只看一次否則 eps 和步數(shù)會(huì)在測試集上過擬合報(bào)出來的魯棒準(zhǔn)確率就是假的drop_lastTrue是為了讓 BN 在訓(xùn)練時(shí)每個(gè) batch 都有穩(wěn)定的統(tǒng)計(jì)量最后不滿 128 的那個(gè) batch 直接丟掉。不做 Normalize 的理由很實(shí)在攻擊函數(shù)要把擾動(dòng)投影回「合法像素空間」如果輸入已經(jīng)被歸一化到[-1, 1]附近那clamp(0, 1)就是個(gè)錯(cuò)誤的約束投影會(huì)把樣本拉到一個(gè)根本不存在的像素分布上攻擊強(qiáng)度會(huì)被莫名削弱。常見做法有兩種——要么把每張圖的合法區(qū)間存下來在歸一化空間里算 clamp 邊界要么干脆不歸一化讓模型第一層的 BatchNorm 去吸收尺度差異。我選后者代碼短出錯(cuò)概率低。2.3 種子與確定性別讓兩次實(shí)驗(yàn)對不上對抗實(shí)驗(yàn)的結(jié)果波動(dòng)比普通分類大得多同一個(gè) eps 跑兩遍差三四個(gè)點(diǎn)很常見所以種子的設(shè)置必須比平時(shí)更狠import os, random import numpy as np import torch def set_seed(seed42, deterministicTrue): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) os.environ[PYTHONHASHSEED] str(seed) if deterministic: torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark Falsecudnn.benchmarkFalse會(huì)犧牲一點(diǎn)吞吐來換取卷積算法固定在對比兩種訓(xùn)練策略時(shí)這點(diǎn)吞吐值得讓出去。PYTHONHASHSEED主要是防 DataLoader 在多 worker 下的順序抖動(dòng)。另外 PGD 里的隨機(jī)起點(diǎn)也是用全局種子驅(qū)動(dòng)的如果只設(shè)了 torch 的種子沒設(shè) numpy 的np.random.uniform那條分支就會(huì)每輪都不一樣實(shí)驗(yàn)日志根本對不齊。提示W(wǎng)indows 下num_workers 0必須把入口代碼放進(jìn)if __name__ __main__:否則 DataLoader 會(huì)反復(fù)重啟進(jìn)程表現(xiàn)是顯存一點(diǎn)點(diǎn)漲上去然后卡死。3. 干凈的分類基線SmallCNN 的結(jié)構(gòu)和訓(xùn)練循環(huán)對抗實(shí)驗(yàn)有個(gè)前提干凈樣本上得先訓(xùn)出個(gè)像樣的模型?;€太弱攻擊再強(qiáng)也沒意義基線太強(qiáng)又會(huì)把大部分時(shí)間耗在調(diào)參上。這個(gè)項(xiàng)目用的結(jié)構(gòu)是折中方案在單卡上十幾分鐘能訓(xùn)完一輪。3.1 模型結(jié)構(gòu)四層卷積加一個(gè)全連接頭import torch import torch.nn as nn class SmallCNN(nn.Module): def __init__(self, num_classes10): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, 3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.Conv2d(32, 32, 3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 28 - 14 nn.Conv2d(32, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.Conv2d(64, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 14 - 7 ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(64 * 7 * 7, 128), nn.ReLU(inplaceTrue), nn.Dropout(0.3), nn.Linear(128, num_classes), ) def forward(self, x): return self.classifier(self.features(x))結(jié)構(gòu)沒什么稀奇但每個(gè)選擇都對應(yīng)一個(gè)理由。輸入是單通道 28×28所以第一層in_channels1換成 CIFAR-10 就是 3這是二次開發(fā)時(shí)第一個(gè)要?jiǎng)拥牡胤健纱蜯axPool2d把 28 降到 7全連接輸入是64*7*73136整個(gè)模型參數(shù)量在五十萬上下不算大正好適合在單張消費(fèi)級卡上反復(fù)跑對抗訓(xùn)練。BatchNorm 放在每個(gè)卷積后面是必須的——沒有它對抗訓(xùn)練階段 loss 會(huì)抖得非常厲害幾乎沒法收斂。Dropout 只放在全連接頭卷積部分不加加了會(huì)讓攻擊的梯度變得稀疏FGSM 打不動(dòng)。3.2 訓(xùn)練循環(huán)里幾個(gè)必須寫對的位置import torch import torch.nn as nn from models.small_cnn import SmallCNN def train_clean(model, train_loader, val_loader, epochs15, lr1e-3, devicecuda): criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lrlr, weight_decay5e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_maxepochs) best_acc 0.0 for epoch in range(epochs): model.train() for x, y in train_loader: x, y x.to(device, non_blockingTrue), y.to(device, non_blockingTrue) optimizer.zero_grad(set_to_noneTrue) loss criterion(model(x), y) loss.backward() optimizer.step() scheduler.step() acc evaluate_acc(model, val_loader, device) # 驗(yàn)證集上只看干凈準(zhǔn)確率 if acc best_acc: best_acc acc torch.save(model.state_dict(), ckpt/clean_best.pt) return best_acc幾個(gè)位置值得單獨(dú)說。zero_grad(set_to_noneTrue)比默認(rèn)的zero_grad()省一點(diǎn)顯存也快一點(diǎn)在大規(guī)模對抗訓(xùn)練里能積少成多。調(diào)度器用余弦退火最后幾個(gè) epoch 學(xué)習(xí)率壓得很低驗(yàn)證準(zhǔn)確率能再穩(wěn)零點(diǎn)幾個(gè)點(diǎn)。evaluate_acc內(nèi)部必須切model.eval()和torch.no_grad()這個(gè)函數(shù)后面會(huì)被反復(fù)復(fù)用訓(xùn)練循環(huán)里一旦漏掉 evalBN 的 running stats 會(huì)被驗(yàn)證集數(shù)據(jù)污染下一輪訓(xùn)練的起點(diǎn)就不對了。3.3 基線結(jié)果怎么讀先看混淆矩陣再看總準(zhǔn)確率干凈訓(xùn)練跑完驗(yàn)證集準(zhǔn)確率落在 92% 到 93% 之間是這份代碼的正常水平。如果只有 88% 上下八成是學(xué)習(xí)率或者 epoch 數(shù)不對如果到了 94% 以上先懷疑是不是驗(yàn)證集和訓(xùn)練集有重疊——random_split用了獨(dú)立的 generator正常情況下不會(huì)重疊但如果手動(dòng)切分時(shí)索引寫錯(cuò)結(jié)果會(huì)好得很可疑。Fashion-MNIST 有個(gè)很典型的現(xiàn)象總的準(zhǔn)確率看著不錯(cuò)但 T-shirt/top、Shirt、Pullover 這三類的混淆率一直偏高原因是它們在 28×28 灰度圖里本來就長得很像。這個(gè)細(xì)節(jié)在對抗實(shí)驗(yàn)里會(huì)放大——攻擊最容易把 Shirt 推到 Pullover 或者反過來做可視化的時(shí)候你看到的第一批「成功攻擊樣本」大概率就是這三類互相串。心里有數(shù)就不會(huì)誤以為是代碼寫錯(cuò)了。4. 攻擊與對抗訓(xùn)練的核心實(shí)現(xiàn)FGSM、I-FGSM、PGD 三段代碼這一章是整個(gè)項(xiàng)目的重心。三種攻擊的代碼量都不大但每一行都有講究寫錯(cuò)一處結(jié)果就差一個(gè)數(shù)量級。4.1 FGSM單步梯度上升十行代碼但有三處會(huì)寫錯(cuò)import torch def fgsm_attack(model, x, y, eps, criterionNone, clamp(0.0, 1.0)): Fast Gradient Sign Method沿?fù)p失對輸入的梯度符號方向走一步 if criterion is None: criterion torch.nn.CrossEntropyLoss() model.eval() # 1. 凍結(jié) BN x_adv x.detach().clone().requires_grad_(True) # 2. 只有輸入需要梯度 loss criterion(model(x_adv), y) grad, torch.autograd.grad(loss, x_adv) # 3. 只求 dL/dx不求參數(shù)梯度 x_adv x_adv.detach() eps * grad.sign() # 沿符號方向走 eps return x_adv.clamp(*clamp).detach() # 投影回合法像素范圍三個(gè)容易翻車的位置都用注釋標(biāo)出來了。第一model.eval()必須在前面哪怕調(diào)用方已經(jīng)切過——攻擊函數(shù)被訓(xùn)練循環(huán)調(diào)用時(shí)上一行往往剛把模型切回 train。第二用x.detach().clone().requires_grad_(True)而不是直接對x開梯度原因是 DataLoader 吐出來的張量可能是只讀視圖原地改會(huì)污染原始數(shù)據(jù)。第三用torch.autograd.grad而不是loss.backward()這樣不會(huì)在模型參數(shù)的.grad上堆積梯度攻擊完模型還是干凈的。如果用 backward下一個(gè) batch 的優(yōu)化器 step 會(huì)帶著攻擊殘留的梯度一起更新訓(xùn)練直接崩。eps的量級是另一個(gè)坑。Fashion-MNIST 像素在[0,1]常見的有效區(qū)間是 0.05 到 0.15對應(yīng) 12/255 到 38/255。如果從 ImageNet 的代碼里抄了8/255在 Fashion-MNIST 上偏弱攻擊后準(zhǔn)確率可能只掉十來個(gè)點(diǎn)容易誤判成「模型很魯棒」。4.2 I-FGSM 與 PGD差別只在一行隨機(jī)起點(diǎn)I-FGSM 就是小步多走的 FGSMPGD 在它基礎(chǔ)上加了隨機(jī)起點(diǎn)。兩者共用一套骨架import torch def pgd_attack(model, x, y, eps0.1, alpha0.01, steps10, criterionNone, random_startTrue, clamp(0.0, 1.0)): if criterion is None: criterion torch.nn.CrossEntropyLoss() model.eval() x_adv x.detach().clone() if random_start: # PGD 與 I-FGSM 的唯一差別 x_adv x_adv torch.empty_like(x_adv).uniform_(-eps, eps) x_adv x_adv.clamp(*clamp) for _ in range(steps): x_adv.requires_grad_(True) loss criterion(model(x_adv), y) grad, torch.autograd.grad(loss, x_adv) x_adv x_adv.detach() alpha * grad.sign() # 單步步長 alpha delta (x_adv - x).clamp(-eps, eps) # 投影回 eps 球 x_adv (x delta).clamp(*clamp) # 再投影回像素范圍 return x_adv.detach()參數(shù)關(guān)系上alpha一般取eps/10到eps/4之間steps * alpha略大于eps是合理的因?yàn)橥队皶?huì)把超出部分拉回來。steps10是評估的常規(guī)配置steps7常用于訓(xùn)練內(nèi)循環(huán)以省時(shí)間。random_startTrue是 PGD 強(qiáng)于 I-FGSM 的關(guān)鍵——每次從 eps 球內(nèi)的隨機(jī)點(diǎn)出發(fā)多次重啟可以摸到不同的局部最優(yōu)評估時(shí)把restarts設(shè)成 5 到 10 再取最差準(zhǔn)確率報(bào)出來的數(shù)才經(jīng)得起追問。requires_grad_(True)放在循環(huán)里而不是循環(huán)外是因?yàn)槊看蝑etach()之后圖就斷了必須重新掛上。4.3 對抗訓(xùn)練把攻擊塞進(jìn)訓(xùn)練循環(huán)的順序問題對抗訓(xùn)練PGD-AT的核心思路很樸素每個(gè) batch 先用當(dāng)前模型生成對抗樣本再用這些樣本來算 loss 和反傳。import torch import torch.nn as nn from attacks.pgd import pgd_attack def train_adv(model, train_loader, val_loader, epochs25, eps0.1, alpha0.01, steps7, lr0.01, devicecuda): criterion nn.CrossEntropyLoss() optimizer torch.optim.SGD(model.parameters(), lrlr, momentum0.9, weight_decay5e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_maxepochs) for epoch in range(epochs): model.train() for x, y in train_loader: x, y x.to(device), y.to(device) # 1) 生成對抗樣本內(nèi)部會(huì)切 eval結(jié)束后手動(dòng)切回 train x_adv pgd_attack(model, x, y, epseps, alphaalpha, stepssteps, criterioncriterion) model.train() # 2) 用對抗樣本前向、反傳、更新 optimizer.zero_grad(set_to_noneTrue) loss criterion(model(x_adv), y) loss.backward() optimizer.step() scheduler.step() print(fepoch {epoch}: val_acc{evaluate_acc(model, val_loader, device):.4f})順序上有兩個(gè)點(diǎn)必須對。第一x_adv必須在optimizer.step()之前生成用舊參數(shù)生成攻擊、用新參數(shù)學(xué)習(xí)這是標(biāo)準(zhǔn)做法如果反過來先更新參數(shù)再生成就變成了對舊模型的攻擊魯棒性提升會(huì)打折扣。第二攻擊生成階段切eval()、反傳階段切回train()目的是不讓對抗樣本去更新 BN 的 running stats——對抗樣本的分布和干凈樣本有偏移混進(jìn)統(tǒng)計(jì)量之后干凈準(zhǔn)確率會(huì)莫名其妙掉兩三個(gè)點(diǎn)而且很難查出來。評估這步也別省。掃一遍 eps 畫出曲線是把「魯棒」這個(gè)詞說清楚的最低要求def eval_under_attack(model, loader, eps_list, devicecuda): model.eval() rows [] for eps in eps_list: correct total 0 for x, y in loader: x, y x.to(device), y.to(device) x_adv pgd_attack(model, x, y, epseps, alphaeps/8, steps20, random_startTrue) # 評估用強(qiáng)攻擊 correct (model(x_adv).argmax(1) y).sum().item() total y.size(0) rows.append({eps: eps, acc: correct / total}) return rows評估時(shí)steps20起步訓(xùn)練時(shí) 7 步就夠這個(gè)不對稱是有意的——訓(xùn)練內(nèi)循環(huán)追求速度評估必須追求可信。用弱攻擊評估強(qiáng)模型很容易得出「魯棒性很好」的結(jié)論實(shí)際上只是梯度被掩蔽了。我這份代碼跑下來的數(shù)量級參考干凈模型在eps0.1、PGD-20 下準(zhǔn)確率掉到個(gè)位數(shù)做完 PGD-AT 之后干凈準(zhǔn)確率大概 87% 到 88%同一個(gè)攻擊下能守在 75% 上下。這個(gè)交換比就是對抗訓(xùn)練的本質(zhì)——用幾個(gè)點(diǎn)的干凈精度換幾十個(gè)點(diǎn)的魯棒精度。5. 避坑與排查五個(gè)讓實(shí)驗(yàn)結(jié)果不可信的現(xiàn)場對抗實(shí)驗(yàn)最麻煩的地方不是跑不起來是跑起來了但結(jié)果是錯(cuò)的而錯(cuò)的結(jié)果看上去還挺合理。下面五條是我在這類項(xiàng)目里反復(fù)遇到的。5.1 攻擊側(cè)的三個(gè)坑坑一攻擊之后準(zhǔn)確率還在 80% 以上以為模型很魯棒?,F(xiàn)象是 FGSM 打完幾乎沒掉點(diǎn)甚至個(gè)別輪次還漲了。原因通常是 eps 量級用錯(cuò)從別的項(xiàng)目抄了8/255≈0.031在 Fashion-MNIST 上太弱也可能是像素被 Normalize 過而攻擊的clamp(0,1)把擾動(dòng)削掉了一大半。解決方式是先打印(x_adv - x).abs().max()正常應(yīng)該接近 eps如果遠(yuǎn)小于 eps說明投影把它壓掉了??佣诙€(gè) batch 報(bào) Trying to backward through the graph a second time?,F(xiàn)象是第一個(gè) batch 正常第二個(gè)直接拋異常。原因是攻擊函數(shù)里用了loss.backward()而不是torch.autograd.grad或者在循環(huán)外requires_grad_(True)之后沒有detach()就進(jìn)入下一輪。計(jì)算圖被復(fù)用了。解決方式是統(tǒng)一改成torch.autograd.grad(loss, x_adv)并且在每輪迭代末尾顯式detach()??尤瑯拥?eps攻擊結(jié)果每次跑都不一樣波動(dòng)能到十個(gè)點(diǎn)?,F(xiàn)象是重跑三次拿到三個(gè)差距很大的數(shù)。原因之一是攻擊生成時(shí)模型還處在 train 模式BN 用的是當(dāng)前 batch 的統(tǒng)計(jì)量batch 組成一變梯度方向就變攻擊強(qiáng)度跟著漂。解決方式是在攻擊函數(shù)第一行強(qiáng)制model.eval()不要指望調(diào)用方記得切。另一個(gè)原因是 PGD 的random_start沒設(shè)種子多 worker 下每個(gè)進(jìn)程的隨機(jī)流不同這個(gè)只能靠固定種子加單 worker 調(diào)試來定位。5.2 訓(xùn)練與評估側(cè)的兩個(gè)坑坑四對抗訓(xùn)練 loss 不降干凈準(zhǔn)確率從 92% 掉到 70% 以下?,F(xiàn)象是前幾個(gè) epoch loss 一直在 2.0 附近橫盤。原因往往是超參直接沿用了干凈訓(xùn)練的配置——Adam 加lr1e-3再加 20 步攻擊等價(jià)于每一步都在最難的樣本上學(xué)模型根本跟不上。解決方式是對抗訓(xùn)練換 SGDlr0.01配余弦退火訓(xùn)練內(nèi)循環(huán)的steps降到 7前兩個(gè) epoch 先用干凈樣本預(yù)熱再切到對抗樣本。這幾個(gè)改動(dòng)做完曲線通常會(huì)正常下來??游逵?5 步 PGD 評估就宣布魯棒換個(gè)攻擊就現(xiàn)原形?,F(xiàn)象是自己報(bào)的魯棒準(zhǔn)確率 80%換成 20 步加 5 次重啟之后掉到 45%。這是典型的梯度掩蔽——模型學(xué)到的不是真魯棒而是讓梯度變得不可用。解決方式是評估統(tǒng)一用steps20、random_startTrue、多次重啟取最差值另外至少再交叉驗(yàn)證一種攻擊比如 FGSM 和 PGD 都跑兩種攻擊的排名如果反過來說明魯棒性是假的??恿梢暬鰜淼膱D全黑或者全白?,F(xiàn)象是visualize.py存出來的對抗樣本看不出原圖。原因是前面某處做了 Normalize畫圖時(shí)直接imshow就會(huì)越界。解決方式是畫圖前統(tǒng)一反歸一化或者像這份代碼一樣全程不 Normalize只在[0,1]區(qū)間里做畫圖直接乘 255 就行。注意這五條里有三條的共同根源是「訓(xùn)練/評估模式?jīng)]切對」。寫完攻擊函數(shù)之后養(yǎng)成立刻在里面補(bǔ)一行model.eval()的習(xí)慣能省掉大量排查時(shí)間。6. 二次開發(fā)換數(shù)據(jù)集、換攻擊組合把 demo 變成實(shí)驗(yàn)底座這份代碼要往上長最先動(dòng)的是數(shù)據(jù)入口。把build_loaders里的FashionMNIST換成CIFAR10同時(shí)改三處transforms加RandomCrop(32, padding4)和RandomHorizontalFlip模型第一層in_channels從 1 改成 3全連接的輸入維度從64*7*7改成64*8*832 輸入兩次池化后是 8。eps 也要跟著換CIFAR-10 上常用8/255正好是 Fashion-MNIST 那套數(shù)值的三分之一直接照搬會(huì)攻擊過強(qiáng)。第二個(gè)改造點(diǎn)是把攻擊做成可注冊的字典這樣evaluate.py掃描的時(shí)候可以一次跑多種攻擊不用改代碼# attacks/__init__.py from .fgsm import fgsm_attack from .ifgsm import ifgsm_attack from .pgd import pgd_attack ATTACKS { fgsm: fgsm_attack, ifgsm: ifgsm_attack, pgd: pgd_attack, } def build_attack(name, **kwargs): if name not in ATTACKS: raise KeyError(funknown attack: {name}, available: {list(ATTACKS)}) return lambda model, x, y, eps: ATTACKS[name](model, x, y, epseps, **kwargs)新加一個(gè)攻擊只需要在目錄里放一個(gè)同簽名的文件注冊進(jìn)去即可訓(xùn)練腳本和評估腳本都不用碰。這是我覺得這份代碼結(jié)構(gòu)上最值得保留的一點(diǎn)。幾個(gè)常見的改造方向可以直接對著改改造目標(biāo)改動(dòng)文件關(guān)鍵動(dòng)作換數(shù)據(jù)集models/small_cnn.py、utils.py改輸入通道、全連接維度、eps 量級加新攻擊attacks/新增文件保持(model, x, y, eps)簽名并注冊加集成攻擊attacks/ensemble.py多個(gè)模型梯度取平均或取最大評估更可信參數(shù)外置configs/base.yamleps / alpha / steps / lr 全提出來方便網(wǎng)格搜索結(jié)果歸檔evaluate.py每個(gè) eps 打一行 csv附上 commit 和種子驗(yàn)證改造有沒有改對方法很土但有效先拿干凈模型跑一遍新數(shù)據(jù)集的干凈準(zhǔn)確率確認(rèn)落在合理區(qū)間再跑eps0的攻擊結(jié)果必須和干凈準(zhǔn)確率完全一致如果對不上說明攻擊函數(shù)在 eps 為 0 時(shí)仍然改了輸入投影或者隨機(jī)起點(diǎn)那步有 bug。這個(gè)自檢我每次都做比看曲線管用。從那以后我每次動(dòng) eps 或者換數(shù)據(jù)集都強(qiáng)制先把「eps0 的攻擊 干凈準(zhǔn)確率」這條斷言跑一遍。對抗實(shí)驗(yàn)的坑大多不在算法本身而在那些看起來無害的默認(rèn)值上。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取