AutoAttackの論文解説・実装

AutoAttackとは

AutoAttackとは,Reliable Evaluation of Adversarial Robustness with an Ensemble of Diverse Parameter-free Attacks [Croce+ ICML2020]において提案されたadversarial attackの手法の一つで,現在(2022/07/15)のstate of the artです.本記事ではこのAutoAttackについての解説・実装を行います.(ちなみに公式実装があります.)

この論文の貢献

この論文の貢献としては以下の三つが挙げられます.

  • ハイパーパラメータが不要なadversarial attackの手法を提案したこと.
  • Adversarial examplesにロバストな既存のほとんどのモデルに対し,論文内で報告された値より低いrobust accuracy(adversarial examplesに対する精度)を達成したこと.
  • それにより,adversarial attackに対するモデルの頑健性を評価する基本的な指標となったこと.

従来のadversarial attack(PGDやFGSMなど)はモデルや画像によって異なるハイパーパラメータを設定する必要があり,その設定によってrobust accuracyも変わってくるためモデルの頑健性に対しての公平な比較ができていませんでした.AutoAttackはそのハイパーパラメータが不要であるため,モデル間で公平な頑健性の比較ができ,さらに既存の攻撃手法よりも攻撃成功率が高いという利点があります.

以下,一つ目のハイパーパラメータ不要なAttackの手法について解説し,二つ目のrobust accuracyについて実際にコードを実装して比較実験してみます.

Auto-PGD

AutoAttackは四種類のadversarial attackのアンサンブル攻撃となっています.四つのAdversarial Attackの手法のうち,二つは既存の攻撃手法,二つはAuto-PGDというハイパーパラメータが不要なPGDを用います.ここではPGDとAuto-PGDについて解説していきます.

PGDは画像 xに少しずつノイズを加えて,制約( L_{p} > \Vert \epsilon \Vert_p)に収まるように画像を更新していくadversarial examplesの生成手法です. k回目の画像 x_k から次の画像 x_{(k+1)}を生成するPGDの更新式は以下のようになります.

 \displaystyle
x^{(k+1)} = P_{\mathcal{S}} \left( x^{(k)} + \alpha \nabla f(x^{(k)}) \right)

ここで P_{\mathcal{S}} は領域 \mathcal{S}内に投影する関数, \alphaはステップサイズです. x_{(k)}に勾配を足して,それが元画像から離れすぎないように P_{\mathcal{S}}によって戻す,といったイメージです.ステップサイズは攻撃対象の画像,モデルによって調整する必要があるハイパーパラメータです.

Auto-PGDはこれにmomentumを足したような感じです.

 \displaystyle
z^{(k+1)} = P_{\mathcal{S}} \left( x^{(k)} + \eta^{(k)}\nabla f(x^{(k))} \right),\\
x^{(k+1)} = P_{\mathcal{S}} \left( x^{(k)} + \alpha \cdot (z^{(k+1)} - x^{(k)}) + (1-\alpha ) \cdot (x^{(k)} - x^{(k-1)} \right),

ここで \alpha = 0.75, \eta^{(0)} = 2\epsilon です. (x_{(k)} - x_{(k-1)})は画像の更新の差分なので,この部分がmomentumの役割となっています.

momentum以外にPGDと違うところはステップサイズ \etaの自動変更と使用する損失関数です.

ステップサイズ

さて,PGDとは違うステップサイズの自動変更ですが,チェックポイント(次で解説します)のiterationにおいて次の二つの条件のうち,どちらかを満たした時に更新されます.

 \displaystyle
1. \ \sum_{i=w_{j-1}}^{w_j - 1} 1_{f(x^{(i+1)}) > f(x^{(i)})} < \rho \cdot (w_j - w_{j-1}),\\
2. \quad \eta^{(w_{j-1})} \equiv \eta^{(w_j)} \quad \mathrm{and} \quad f_{\mathrm{max}}^{(w_{j-1})} \equiv f_{\mathrm{max}}^{(w_j)},

ここで \eta^{(0)} = 2\epsilon, \rho = 0.75 で固定します. この条件の意味ですが,一つ目は前回のチェックポイントからどのくらい x の更新が成功したかということです.目的関数の値が良くなっている割合が多いということは,ステップサイズは適切であるということを意味します.よって,その場合はステップサイズを更新しなくても良いということになります.

二つ目は前回のチェックポイントと今回で \etaの値が変わらず,さらに目的関数の一番高かった値が変わらないとき,という条件です.つまり \etaの値が更新されていないのにも関わらず目的関数の最大値が変わらないという,泥沼の状況(同じところをぐるぐる回っているなど)にハマるのを防いでいるということです.

Python(PyTorch)での実装例

# j回目の更新エポックにおいて,チェックポイントのリストw, 各エポックの目的関数fの値とその時のfの最大値からetaの値を更新するか判定する関数
def condition1(j):
    count = 0
    for i in range(w[j-1], w[j]):
        if f_list[i+1] > f_list[i]:
            count  += 1
    if count < rho*(w[j] - w[j-1]):
        return True
    return False

def condition2(j):
    if etas[w[j-1]] == etas[w[j]] and f_maxlist[w[j-1]] == f_maxlist[w[j]]:
        return True
    return False

チェックポイント

Auto-PGDではadversarial examples生成のiterationにおいて,チェックポイントのエポックをいくつか設け,そのチェックポイントでステップサイズを小さくするかどうか決定します.チェックポイントは w_j = \lceil p_j N_{iter} \rceil で計算します.ここで, p_j \in [0,1], p_0 = 0, p_1 = 0.22として更新式は以下を使います.

 \displaystyle
p_{j+1} = p_j + \mathrm{max}\{p_j - p_{j-1} - 0.03, 0.06\}

Python(PyTorch)での実装例

# ステップサイズを更新するエポックを計算する関数
def checkpoints(N_iter=100):
    w = []
    p = [0, 0.22]
    while True:
        next_p = p[-1] + max(p[-1] - p[-2] - 0.03, 0.06)
        if next_p > 1:
            break
        p.append(next_p)
    for pj in p:
        w.append(math.ceil(pj * N_iter))
    return w

チェックポイントで更新されると...

チェックポイントでステップサイズが更新された場合,そこからの探索は x_{max}を用います.

 \displaystyle
x^{(w_j + 1)} := x_{max}

これは,今までに一番よかった(目的関数の値が大きかった)画像をもとに,そこからステップサイズを小さくして探索する,ということです.

PGDの損失関数の欠点

よく使われる損失関数のCross-entropyは,入力 xに対しラベルが y のとき,

 \displaystyle
\mathrm{CE}(x,y) = -\log p_y = -z_y + \log \left(\sum_{j=1}^{K}e^{z_j} \right),

と表せます.これを微分すると以下のようになります.

 \displaystyle
\nabla_x \mathrm{CE}(x,y) = (-1 + p_y)\nabla_x z_y + \sum_{i \neq y}p_i \nabla_x z_i.

もし p_y \approx 1かつ全ての i \neq y について p_i \approx 0ならば \nabla_x \mathrm{CE}(x,y) \approx \boldsymbol{0}となり,勾配消失してしまいます.さらに, h=\alpha gとして損失関数 g  \alpha 倍したものを使用すると, \alpha の増加に伴い容易に勾配消失が起こり,CEを使うのは攻撃として適切でないことがわかります.

使用する損失関数

CRに変わる損失関数として,Auto-PGDではDifference of Logits Ratio(DLR) lossという損失関数を使います.

 \displaystyle
\mathrm{DLR}(x,y) = - \frac{z_y - \underset{i \neq y}{\mathrm{max}} \: z_i}{z_{\pi_1} - z_{\pi_3}}

ここで z_{\pi_i} は出力のロジットのうち, i 番目に高いものを指します.つまり \mathrm{max}z_i =z_{\pi_1} です.この損失関数を最大化することは, xyに分類しないようにすることと同義です.さらに y に分類されなくなると, y のロジットを最小化するようにされています.分母にもロジットの差を入れることで,CrossEntropyで問題となる勾配消失を解決しています.

これをターゲットクラス t に誘導するときは

 \displaystyle
\mathrm{Targeted}\text{-}\mathrm{DLR}(x,y) = - \frac{z_y - z_t}{z_{\pi_1} - (z_{\pi_3} + z_{\pi_4})/2}

という損失関数を使います.DLRと分母が違うのは, z_t = z_{\pi_3}の時に分子と分母で打ち消して損失が定数になるのを防ぐためです.

Python(PyTorch)での実装例

# モデルの出力(logits)と正解ラベル(label)を受け取ってDLR Lossを返す関数
def DLRLoss(logits, label):
    length = logits.shape[0]
    z_y = logits[range(length), label]
    z_max = torch.zeros(length)
    z_pi1 = logits.data.max(1)[0]
    z_pi3 = torch.topk(logits, 3, dim=1)[0][:,2]
  
    for i in range(length):
        if logits[i].data.max(0)[1] == label[i]:
            z_max[i] = torch.topk(logits[i], 2)[0][1]
        else:
            z_max[i] = torch.max(logits[i])
    
    loss = -(z_y - z_max)/(z_pi1 - z_pi3)
    
    return torch.sum(loss)/loss.shape[0]

# モデルの出力(logits)と正解ラベル(label),ターゲットのクラス(targets)を受け取ってTargeted DLR Lossを返す関数
def TargetedDLRLoss(logits, label, targets):
    eps = 1e-12
    length = logits.shape[0]
    
    z_y = logits[range(length), label]
    z_t = logits[range(length), targets]
    topk_z = torch.topk(logits, 4, dim=1)[0]
    z_pi1 = topk_z[:,0]
    z_pi3 = topk_z[:,2]
    z_pi4 = topk_z[:,3]
    
    loss = -(z_y - z_t)/(z_pi1 - (z_pi3 + z_pi4)/2 + eps)
    
    return torch.sum(loss)/loss.shape[0]

AutoAttack

これでPGDのパラメータが不要なAuto-PGD(APGD)と,DLR lossという損失関数が新たに使えるようになりました.AutoAttackでは,このAPGDと(Targeted)DLR loss,さらに既存のFABSquare attackのアンサンブルで攻撃します.具体的には以下の四つの攻撃を使います.

  •  \mathrm{APGD_{CE}}:CrossEntropyを使ったrandom restartのないAPGD
  •  \mathrm{APGD_{DLR}^T} :Targeted DLR lossを使ったAPGD
  •  \mathrm{FAB^{T}} :targetを絞ったFAB
  •  \mathrm{Square} :5000回のクエリのSquare Attack

white-boxの攻撃は100回のiterationを用います.各画像について,これらの攻撃の内最も攻撃が成功したものを採用します.論文によると,このAutoAttackのkey pointは攻撃の多様性にあるようです.APGDが最大の損失を目指す攻撃である一方,FABは加える摂動が最小になるようにしたり,勾配を利用しないblack-boxのSquare Attackがあったりとそれぞれ攻撃の特性が異なっています.

Python(PyTorch)での実装例

以上をまとめると,AutoAttackに使われるAPGD_CEのコードは以下のようになります.

class APGD_CE():
    def __init__(self, model, eps=0.03, rho=0.75, N_iter=100, alpha=0.75, device="cuda"):
        self.model = model
        self.eps = eps
        self.rho = rho
        self.N_iter = N_iter
        self.alpha = alpha
        self.loss = nn.CrossEntropyLoss()
        self.device = device
        self.w = []
        # iterationごとに目的関数の値のリスト
        self.f_list = []
        # iterationごとのetaのリスト
        self.etas = []
        # iterationごとに目的関数の最大値のリスト
        self.f_maxlist = []
        self.p = [0, 0.22]
        self.checkpoints()
        
    def condition1(self, j):
        count = 0
        for i in range(self.w[j-1],self.w[j]):
            if self.f_list[i+1] > self.f_list[i]:
                count  += 1
        if count < self.rho*(self.w[j] - self.w[j-1]):
            return True
        return False
    
    def condition2(self, j):
        if self.etas[self.w[j-1]] == self.etas[self.w[j]] and self.f_maxlist[self.w[j-1]] == self.f_maxlist[self.w[j]]:
            return True
        return False
    
    def checkpoints(self):
        w = []
        while True:
            next_p = self.p[-1] + max(self.p[-1] - self.p[-2] - 0.03, 0.06)
            if next_p > 1:
                break
            self.p.append(next_p)
        for pj in self.p:
            w.append(math.ceil(pj * self.N_iter))
        self.w = w
    
    def perturb(self, x, y):
        x_orig = x.clone().detach()
        x_0 = x.clone().detach()
        x_0.requires_grad = True

        # 目的関数fの初期値
        self.model.zero_grad()
        logit_0 = self.model(x_0)
        f_0 = self.loss(logit_0, y)
        f_0.backward()

        # 目的関数fの次の値
        eta = 2*self.eps
        x_1pre = x_0 + eta*x_0.grad.sign()
        noize_x1 = torch.clamp(x_1pre - x_orig, min=-self.eps, max=self.eps)
        x_1 = torch.clamp(x_orig + noize_x1, min=0.0, max=1.0).clone().detach()
        x_1.requires_grad = True
        self.model.zero_grad()
        logit_1 = self.model(x_1)
        f_1 = self.loss(logit_1, y)
        f_1.backward()

        f_max = max(f_0, f_1)
        x_max = (x_orig if f_max == f_0 else x_1)
        eta_0 = 2*self.eps

        self.f_list = [f_0, f_1]
        self.f_maxlist = [f_0, f_max]
        self.etas = [eta_0, eta_0]

        f_xk = f_1
        x_k = x_1
        x_km = x_orig

        wj = 1
        for k in range(1, self.N_iter):
            # calculate z
            z_pre = x_k + self.etas[-1]*x_k.grad.sign()
            noise_z = torch.clamp(z_pre - x_orig, min=-self.eps, max=self.eps)
            z = torch.clamp(x_orig + noise_z, min=0.0, max=1.0)

            # calculate x_kp := x^(k+1)
            x_pre = x_k + self.alpha*(z - x_k) + (1 - self.alpha)*(x_k - x_km)
            noize_x = torch.clamp(x_pre - x_orig, min=-self.eps, max=self.eps)
            x_kp = torch.clamp(x_orig + noize_x, min=0.0, max=1.0).clone().detach()
            x_kp.requires_grad = True

            # update x,f max
            logit = self.model(x_kp)
            f_xkp = self.loss(logit, y)
            f_xkp.backward()
            if f_xkp > f_max:
                x_max = x_kp
                f_max = f_xkp

            # update eta
            if k in self.w:
                if self.condition1(wj) or self.condition2(wj):
                    eta = eta/2
                    x_kp = x_max
                wj += 1

            # update x, x_km
            x_km = x_k
            x_k = x_kp

            # append eta, f, f_max
            self.etas.append(eta)
            self.f_list.append(f_xkp)
            self.f_maxlist.append(f_max)

        return x_max
    

実験

以上のAutoAttackを実装して実験し,既存研究のモデルを評価してみます.

環境

  • Ubuntu 18.04
  • Python 3.8.5
  • PyTorch 1.10.0+cu113
  • advertorch 0.2.3

結果

データセットはCIFAR10とMNISTを用い,手元で実験した値とAutoAttackの論文内で報告されていた値(括弧内の値)を示します.論文内ではadversarial trainingなどをしてロバストになっている先行研究のモデルを使用しているので,それに倣って同じモデルを使用しています.大体報告通りの結果になっていますね.論文の値との微妙な違いはseed値やバッチサイズによるものだと考えられます.

CIFAR10

論文 Clean APGD APGD_T AA
Carmon 89.69 (89.69) 61.84 (61.74) 59.62 (59.54) 59.60 (59.53)
Ding 84.36 (84.36) 50.25 (50.12) 41.79 (41.74) 41.57 (41.44)

MNIST

論文 Clean APGD APGD_T AA
Ding 98.95 (98.95) 95.31 (94.58) 95.47 (94.62) 91.39 (91.40)

まとめ

この論文をまとめると,従来の

  • モデルごとにハイパーパラメータの調整が必要なAdversarial Attack
  • 勾配消失などの問題があるCross-Entropy Loss
  • モデルの頑健性の一般的な指標がない

という問題を解決し,これらは

  • Auto-PGDというmomentum付きPGDというハイパーパラメータ不要な手法
  • 特別に設計したDLR Lossという損失関数

によって実現している.

参考文献