"""Implementations of the Fast Minimum-Norm evasion attack."""
from __future__ import annotations # noqa: I001
import importlib.util
import math
from secmlt.optimization.losses import LogitDifferenceLoss
import torch
from secmlt.adv.backends import Backends
from secmlt.adv.evasion.base_evasion_attack import (
BaseEvasionAttack,
BaseEvasionAttackCreator,
)
from secmlt.adv.evasion.modular_attacks.modular_attack import (
LOGIT_LOSS,
)
from secmlt.adv.evasion.modular_attacks.modular_attack_min_distance import (
ModularEvasionAttackMinDistance,
)
from secmlt.adv.evasion.perturbation_models import LpPerturbationModels
from secmlt.manipulations.manipulation import AdditiveManipulation
from secmlt.optimization.constraints import (
ClipConstraint,
L0Constraint,
L1Constraint,
L2Constraint,
LInfConstraint,
)
from secmlt.optimization.gradient_processing import LinearProjectionGradientProcessing
from secmlt.optimization.initializer import Initializer
from secmlt.optimization.optimizer_factory import OptimizerFactory
from secmlt.optimization.scheduler_factory import LRSchedulerFactory
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from secmlt.trackers.trackers import Tracker
[docs]
class FMN(BaseEvasionAttackCreator):
"""Creator for the Fast Minimum-Norm (FMN) attack."""
def __new__(
cls,
perturbation_model: str,
num_steps: int,
step_size: float,
min_step_size: float | None = None,
gamma: float = 0.05,
y_target: int | None = None,
lb: float = 0.0,
ub: float = 1.0,
backend: str = Backends.NATIVE,
trackers: list[Tracker] | None = None,
**kwargs,
) -> BaseEvasionAttack:
"""
Create the FMN attack.
Parameters
----------
perturbation_model : str
The perturbation model to be used for the attack.
num_steps : int
The number of iterations for the attack.
max_step_size : float
The attack maximum step size.
min_step_size : float, optional
The minimum step size for the attack. If None, it is set to
max_step_size/100.
The default value is None.
gamma: float, optional
Step size for modifying the eps-ball. Will decay with cosine annealing.
y_target : int | None, optional
The target label for the attack. If None, the attack is
untargeted. The default value is None.
lb : float, optional
The lower bound for the perturbation. The default value is 0.0.
ub : float, optional
The upper bound for the perturbation. The default value is 1.0.
backend : str, optional
Backend to use to run the attack, by default Backends.FOOLBOX
trackers : list[Tracker] | None, optional
Trackers to check various attack metrics (see secmlt.trackers),
available only for native implementation, by default None.
Returns
-------
BaseEvasionAttack
FMN attack instance.
"""
cls.check_backend_available(backend)
implementation = cls.get_implementation(backend)
implementation.check_perturbation_model_available(perturbation_model)
return implementation(
perturbation_model=perturbation_model,
num_steps=num_steps,
max_step_size=step_size,
min_step_size=min_step_size,
gamma=gamma,
y_target=y_target,
lb=lb,
ub=ub,
trackers=trackers,
**kwargs,
)
[docs]
@staticmethod
def get_backends() -> list[str]:
"""Get available implementations for the FMN attack."""
return [Backends.FOOLBOX, Backends.ADVLIB, Backends.NATIVE]
@staticmethod
def _get_foolbox_implementation() -> type[FMNFoolbox]: # noqa: F821
if importlib.util.find_spec("foolbox", None) is not None:
from secmlt.adv.evasion.foolbox_attacks.foolbox_fmn import FMNFoolbox
return FMNFoolbox
msg = "foolbox extra not installed"
raise ImportError(msg)
@staticmethod
def _get_advlib_implementation() -> type[FMNAdvLib]: # noqa: F821
if importlib.util.find_spec("adv_lib", None) is not None:
from secmlt.adv.evasion.advlib_attacks import FMNAdvLib
return FMNAdvLib
msg = "adv_lib extra not installed"
raise ImportError(msg)
@staticmethod
def _get_native_implementation() -> type[FMNNative]:
return FMNNative
[docs]
class FMNNative(ModularEvasionAttackMinDistance):
"""Native implementation of the Fast Minimum-Norm attack."""
def __init__(
self,
perturbation_model: str,
num_steps: int,
max_step_size: float,
y_target: int | None = None,
lb: float = 0.0,
ub: float = 1.0,
trackers: list[Tracker] | None = None,
gamma: float = 0.05,
min_step_size: float | None = None,
) -> None:
"""
Create Native FMN attack.
Parameters
----------
perturbation_model : str
The perturbation model to be used for the attack.
num_steps : int
The number of iterations for the attack.
max_step_size : float
The attack maximum step size.
min_step_size : float, optional
The minimum step size for the attack. If None, it is set to
max_step_size/100. The default value is None.
gamma: float, optional
Step size for modifying the eps-ball. Will decay with cosine annealing.
y_target : int | None, optional
The target label for the attack. If None, the attack is
untargeted. The default value is None.
lb : float, optional
The lower bound for the perturbation. The default value is 0.0.
ub : float, optional
The upper bound for the perturbation. The default value is 1.0.
trackers : list[Tracker] | None, optional
Trackers to check various attack metrics (see secmlt.trackers),
available only for native implementation, by default None.
"""
perturbation_models = {
LpPerturbationModels.L0: L0Constraint,
LpPerturbationModels.L1: L1Constraint,
LpPerturbationModels.L2: L2Constraint,
LpPerturbationModels.LINF: LInfConstraint,
}
initializer = Initializer()
gradient_processing = LinearProjectionGradientProcessing(
LpPerturbationModels.L2
)
perturbation_constraints = [
perturbation_models[perturbation_model](radius=torch.inf)
]
domain_constraints = [ClipConstraint(lb=lb, ub=ub)]
manipulation_function = AdditiveManipulation(
domain_constraints=domain_constraints,
perturbation_constraints=perturbation_constraints,
)
self.perturbation_model = LpPerturbationModels.get_p(perturbation_model)
self.perturbation_model_dual = LpPerturbationModels.get_dual(perturbation_model)
super().__init__(
y_target=y_target,
num_steps=num_steps,
step_size=max_step_size,
loss_function=LOGIT_LOSS,
optimizer_cls=OptimizerFactory.create_sgd(lr=max_step_size),
scheduler_cls=LRSchedulerFactory.create_cosine_annealing(),
manipulation_function=manipulation_function,
gradient_processing=gradient_processing,
initializer=initializer,
trackers=trackers,
gamma=gamma,
min_step_size=min_step_size,
)
[docs]
@classmethod
def get_perturbation_models(cls) -> set[str]:
"""
Check if a given perturbation model is implemented.
Returns
-------
set[str]
Set of perturbation models available for this attack.
"""
return {
LpPerturbationModels.L0,
LpPerturbationModels.L1,
LpPerturbationModels.L2,
LpPerturbationModels.LINF,
}
def _init_epsilons(self, samples: torch.Tensor) -> torch.Tensor:
return (
torch.zeros(samples.shape[0]).fill_(torch.inf)
if self.perturbation_model != 0
else torch.ones(samples.shape[0])
)
def _update_epsilons(
self,
is_adv: torch.Tensor,
epsilons: torch.Tensor,
best_distances: torch.Tensor,
gamma: float,
scores: torch.Tensor,
target: torch.Tensor,
delta: torch.Tensor,
adv_found: torch.Tensor,
) -> torch.Tensor:
# update epsilons
if self.perturbation_model == 0:
epsilons = torch.where(
is_adv,
torch.minimum(
epsilons - 1,
torch.minimum(best_distances, torch.floor(epsilons * (1 - gamma))),
),
torch.maximum(torch.floor(epsilons * (1 + gamma)), epsilons + 1),
)
else:
logits_difference_loss = LogitDifferenceLoss()(scores, target)
distance_to_boundary = logits_difference_loss / delta.data.flatten(
start_dim=1
).norm(p=self.perturbation_model_dual, dim=-1)
epsilons = torch.where(
is_adv,
torch.minimum(best_distances, epsilons * (1 - gamma)),
torch.where(
adv_found,
epsilons * (1 + gamma),
best_distances + distance_to_boundary,
),
)
return epsilons
def _update_gamma(self, i: int) -> float:
"""Update gamma with cosine annealing."""
return (
self.min_gamma
+ (self.gamma - self.min_gamma)
* (1 + math.cos(math.pi * i / self.num_steps))
/ 2
)