Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

## Unreleased

### Added

* The `AdversarialAttack` problems: black-box adversarial attacks on a bundled MNIST (784 variables) and CIFAR-10
(3072 variables) classifier, as a many-dimensional benchmark. The objective, maximized, is
`-log softmax(model(x + delta))[y] - alpha * ||delta||_2` for a fixed, correctly classified image `x` with label `y`
and a perturbation `delta` in `[-eps, eps]^d`. The 12 problems have ids 5001-5006 (MNIST) and 5011-5016 (CIFAR-10),
and are created by name (`AdversarialMNIST5001`, ...) or id, in `ioh.ProblemClass.ADVERSARIAL`, in the
`AdversarialAttack` suite (suite id 5) and in the factory of all real problems. The `instance` selects the base image
(`(instance - 1) mod 100`) and so changes the whole landscape; `n_variables` is ignored. The network is a dependency
free C++ implementation, whose weights (float16) and 100 images per dataset are new data files: `static/` grows by
about 11 MB. Reference: <https://github.com/wz7475/amhe-attack>.
* Because the ids 5001-5016 are taken, a real problem that is registered at run time (`wrap_problem`, `register_problem`)
gets the next free id after them, 5017, instead of 1121.

### Fixed

* `FlatFile` data rows had no separator between the last meta data column (`run`) and the first logged property, so
Expand Down
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ file(GLOB_RECURSE HEADERS include/ioh *.hpp)
# problems and suites (src/registry), so that only these files instantiate every problem class
add_library(ioh STATIC
src/data.cpp
src/registry/adversarial.cpp
src/registry/bbob.cpp
src/registry/cec.cpp
src/registry/dynamic_bin_val.cpp
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
* BBOB (Single Objective Noiseless) (COCO)
* SBOX-COST (COCO)
* StarDiscrepancy
* AdversarialAttack (black-box adversarial attacks on MNIST and CIFAR-10 classifiers)
* PBO
* Submodular Graph Problems
* CEC 2013 Special Session and Competition on Niching Methods for Multimodal Function Optimization
Expand Down
9 changes: 9 additions & 0 deletions doc/python/source/python/adversarial.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Adversarial Attack
==================
Black-box adversarial attacks on image classifiers, as a many-dimensional benchmark

.. automodapi:: ioh.iohcpp.problem
:inherited-members:
:no-main-docstr:
:no-heading:
:include: AdversarialAttack
1 change: 1 addition & 0 deletions doc/python/source/python/problem.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Problem Types
pbo.rst
submodular.rst
star_discrepancy.rst
adversarial.rst
wmodel.rst
cec2013.rst
cec2022.rst
2 changes: 1 addition & 1 deletion doc/python/source/upgrading.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ existing ids and returns them.

A suite declares `registry_id` and `registry_name` and is registered with `register_suite<T>()`; derive from
`Suite<...>` instead of `RealSuite` or `IntegerSuite` (which registered a suite under its class name with the next free
id). The built-in suites have fixed ids: Real 1, BBOB 2, SBOX 3, RealStarDiscrepancy 4; Integer 1, PBO 2, Submodular 3,
id). The built-in suites have fixed ids: Real 1, BBOB 2, SBOX 3, RealStarDiscrepancy 4, AdversarialAttack 5; Integer 1, PBO 2, Submodular 3,
IntegerStarDiscrepancy 4.

### Removed and renamed
Expand Down
10 changes: 8 additions & 2 deletions include/ioh/common/builtins.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ namespace ioh::problem
struct GraphProblem;
}

namespace adversarial
{
class AdversarialAttack;
}

namespace star_discrepancy::real
{
class StarDiscrepancy;
Expand Down Expand Up @@ -58,7 +63,7 @@ namespace ioh::common
//! @name The factories with built-in entries
///@{

//! All real single-objective problems: BBOB, CEC2013, CEC2022, real star discrepancy
//! All real single-objective problems: BBOB, CEC2013, CEC2022, real star discrepancy, adversarial attack
void add_builtins(Factory<problem::SingleObjectiveProblem<double>, int, int> &);

//! All integer single-objective problems: PBO, graph, integer star discrepancy, DynamicBinVal
Expand All @@ -71,10 +76,11 @@ namespace ioh::common
void add_builtins(Factory<problem::CEC2022, int, int> &);
void add_builtins(Factory<problem::DynamicBinVal, int, int> &);
void add_builtins(Factory<problem::submodular::GraphProblem, int, int> &);
void add_builtins(Factory<problem::adversarial::AdversarialAttack, int, int> &);
void add_builtins(Factory<problem::star_discrepancy::real::StarDiscrepancy, int, int> &);
void add_builtins(Factory<problem::star_discrepancy::integer::StarDiscrepancy, int, int> &);

//! The real suites: Real, BBOB, SBOX, RealStarDiscrepancy
//! The real suites: Real, BBOB, SBOX, RealStarDiscrepancy, AdversarialAttack
void add_builtins(Factory<suite::Suite<problem::SingleObjectiveProblem<double>>, std::vector<int>, std::vector<int>,
std::vector<int>> &);

Expand Down
2 changes: 2 additions & 0 deletions include/ioh/problem.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,5 @@
#include "problem/star_discrepancy/real.hpp"
#include "problem/star_discrepancy/integer.hpp"

#include "problem/adversarial/adversarial_attack.hpp"

191 changes: 191 additions & 0 deletions include/ioh/problem/adversarial/adversarial_attack.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
#pragma once

#include <algorithm>
#include <cmath>
#include <limits>
#include <memory>
#include <string>
#include <vector>

#include "ioh/common/format.hpp"
#include "ioh/problem/single.hpp"
#include "network.hpp"

namespace ioh::problem
{
namespace adversarial
{
/**
* @brief Numerically stable log(softmax(logits)[label]) = logits[label] - logsumexp(logits).
*/
inline double log_softmax_at(const std::vector<float> &logits, const int label)
{
const float max_logit = *std::max_element(logits.begin(), logits.end());
double sum_exp = 0.0;
for (const auto v : logits)
sum_exp += std::exp(static_cast<double>(v) - static_cast<double>(max_logit));
const double log_sum_exp = static_cast<double>(max_logit) + std::log(sum_exp);
return static_cast<double>(logits[static_cast<size_t>(label)]) - log_sum_exp;
}

/**
* @brief Black-box adversarial-attack objective on a fixed image classifier,
* proposed as a many-dimensional global optimization benchmark (amhe-attack,
* https://github.com/wz7475/amhe-attack). For a fixed base image `x` (from a
* bundled 100-image bank per dataset) with true label `y`, and a search vector
* `delta` (the perturbation) bounded in [-eps, eps]^d:
*
* f(delta) = -log softmax(model(x + delta))[y] - alpha * ||delta||_2
*
* maximized. `x + delta` is NOT clamped to [0,1] by default (matching the
* reference implementation's main experimental path in amheattack/attack.py);
* pass `clamp_input=true` for the amheattack/stochastic_attack.py semantics.
*
* Unlike most IOH problems, `instance` selects which of the 100 bundled base
* images is attacked -- it changes the entire landscape, not just an affine
* transformation of one fixed function. There is no known optimum, so
* `optimum()` is left at its default (NaN).
*/
class AdversarialAttack : public RealSingleObjective
{
public:
//! The registry entries (see registry_entries_of): 12 (dataset, eps, alpha) presets with ids 5001-5006
//! (MNIST) and 5011-5016 (CIFAR-10). Listing them loads no data: the network and the image bank are
//! loaded when the first problem of a dataset is created.
static std::vector<RegistryEntry<AdversarialAttack>> registry_entries();

private:
std::shared_ptr<const Network> net_;
std::shared_ptr<const ImageBank> bank_;
std::string dataset_;
double eps_;
double alpha_;
int image_index_;
int label_;
bool clamp_input_;

// Per-object scratch buffers: NOT shared between instances, so one
// AdversarialAttack object must not be evaluated concurrently from
// multiple threads (see tests/cpp/problem/test_threaded.cpp).
mutable std::vector<float> input_;
mutable std::vector<float> logits_;
mutable std::vector<float> scratch_a_;
mutable std::vector<float> scratch_b_;

[[nodiscard]] static int dimension_for(const std::string &dataset)
{
return static_cast<int>(get_network(dataset)->input_size());
}

protected:
double evaluate(const std::vector<double> &x) override
{
const auto &base = bank_->images[static_cast<size_t>(image_index_)];
double sq = 0.0;
for (size_t i = 0; i < x.size(); ++i)
{
const float v = base[i] + static_cast<float>(x[i]);
input_[i] = clamp_input_ ? std::min(1.0f, std::max(0.0f, v)) : v;
sq += x[i] * x[i];
}

net_->forward(input_, logits_, scratch_a_, scratch_b_);
const double log_p_true = log_softmax_at(logits_, label_);
return -log_p_true - alpha_ * std::sqrt(sq);
}

public:
/**
* @brief Construct a new AdversarialAttack object.
*
* @param problem_id the id of the problem (one per (dataset, eps, alpha) preset)
* @param instance the instance id: selects the base image, 1-indexed into the
* 100-image bank as `(instance - 1) mod 100`
* @param n_variables ignored: dimension is fixed by `dataset` (784 for mnist,
* 3072 for cifar10) and derived from the loaded network
* @param name the name of the problem
* @param dataset "mnist" or "cifar10"
* @param eps the perturbation bound: search space is [-eps, eps]^d
* @param alpha weight of the L2 perturbation-size penalty
* @param clamp_input whether to clamp x + delta to [0, 1] before classification
*/
AdversarialAttack(const int problem_id, const int instance, const int n_variables,
const std::string &name, const std::string &dataset, const double eps,
const double alpha, const bool clamp_input = false) :
RealSingleObjective(MetaData(problem_id, instance, name, dimension_for(dataset),
common::OptimizationType::MAX),
Bounds<double>(dimension_for(dataset), -eps, eps)),
net_(get_network(dataset)), bank_(get_images(dataset)), dataset_(dataset), eps_(eps), alpha_(alpha),
image_index_(static_cast<int>((static_cast<size_t>(instance - 1)) % bank_->images.size())),
label_(bank_->labels[static_cast<size_t>(image_index_)]), clamp_input_(clamp_input),
input_(net_->input_size()), scratch_a_(net_->scratch_size()), scratch_b_(net_->scratch_size())
{
static_cast<void>(n_variables); // dimension is derived from `dataset`, not user-supplied
// Negative weight, matching star_discrepancy: this is a MAX problem, so a hard
// bounds violation must push y towards -infinity, not +infinity.
enforce_bounds(-std::numeric_limits<double>::infinity(), constraint::Enforced::HARD);
}

[[nodiscard]] const std::string &dataset() const { return dataset_; }
[[nodiscard]] double eps() const { return eps_; }
[[nodiscard]] double alpha() const { return alpha_; }
[[nodiscard]] int image_index() const { return image_index_; }
[[nodiscard]] int true_label() const { return label_; }
[[nodiscard]] bool clamp_input() const { return clamp_input_; }

//! Softmax probabilities from the most recent `evaluate` call.
[[nodiscard]] std::vector<double> probabilities() const
{
const float max_logit = *std::max_element(logits_.begin(), logits_.end());
std::vector<double> probs(logits_.size());
double sum = 0.0;
for (size_t i = 0; i < logits_.size(); ++i)
{
probs[i] = std::exp(static_cast<double>(logits_[i] - max_logit));
sum += probs[i];
}
for (auto &p : probs)
p /= sum;
return probs;
}

//! argmax of the most recent `evaluate` call's logits; != true_label() means the attack succeeded.
[[nodiscard]] int predicted_label() const
{
return static_cast<int>(std::max_element(logits_.begin(), logits_.end()) - logits_.begin());
}
};
} // namespace adversarial

inline std::vector<RegistryEntry<adversarial::AdversarialAttack>>
adversarial::AdversarialAttack::registry_entries()
{
struct Preset
{
int id;
const char *dataset;
double eps;
double alpha;
};

const std::vector<Preset> presets = {
{5001, "mnist", 0.1, 0.0}, {5002, "mnist", 0.3, 0.0}, {5003, "mnist", 1.0, 0.0},
{5004, "mnist", 0.1, 0.05}, {5005, "mnist", 0.3, 0.05}, {5006, "mnist", 1.0, 0.05},
{5011, "cifar10", 0.1, 0.0}, {5012, "cifar10", 0.3, 0.0}, {5013, "cifar10", 1.0, 0.0},
{5014, "cifar10", 0.1, 0.05}, {5015, "cifar10", 0.3, 0.05}, {5016, "cifar10", 1.0, 0.05},
};

std::vector<RegistryEntry<AdversarialAttack>> entries;
for (const auto &preset : presets)
{
const std::string dataset = preset.dataset;
const auto name = fmt::format("Adversarial{}{}", dataset == "mnist" ? "MNIST" : "CIFAR10", preset.id);
entries.push_back({preset.id, name,
[preset, dataset, name](const int instance, const int n_variables) {
return std::make_shared<AdversarialAttack>(preset.id, instance, n_variables, name,
dataset, preset.eps, preset.alpha);
}});
}
return entries;
}
} // namespace ioh::problem
Loading
Loading