В этом руководстве вы узнаете, как взламывать deep-learning модели для анализа изображений с помощью состязательных (Adversarial) атак. В заметке показано, как реализовывать атаки с использованием библиотек глубокого обучения Keras и TensorFlow.

Сегодня всё большее распространение находят системы, например, автопилоты и различные помощники при вождении и другие системы, автоматизация которых основана на сложных визуальных датчиках. Если быть точнее, технически датчики не особо сложны, но для обработки изображений используются сложные модели глубокого обучения. Таким образом, принятие решения о торможении, разгоне, повороте или других потенциальных опасных решений основано на данных, предоставленных нейронной сетью.
Например, вы едете в машине, ведете беседу с пассажирами и видите впереди знак «STOP» с какой-то наклейкой, однако, вы не обращаете на это внимание и продолжаете разговор, но автомобиль вместо того, чтобы остановиться перед знаком, увеличивает скорость и в следующее мгновение в вас врезается грузовик, едущий по главной дороге.
Очевидны вопросы: что случилось? Почему ваша беспилотная машина отреагировала именно так? Это была какая-то странная «ошибка» в коде или программном обеспечении вашей машины?
Ответом на этот вопрос будет то, что нейронная сеть, которая принимает непосредственное участие в управлении вашим (когда-то) автомобилем была атакована с помощью той самой наклейки на знак.
К состязательным (adversarial) изображениям относятся такие изображения, которые содержат умышленно измененные пиксели, с целью вызвать некорректную работу нейронной сети, но при этом не имеющие существенных для человека изменений.
Так как же работают такие атаки? Как они осуществляются? И как от них защититься? Рассмотрим базовые приемы с использованием Keras и TensorFlow.
Что такое состязательные изображения и атаки?

В далеком 2014 году Goodfellow и др. опубликовали статью, озаглавленную «Объяснение и использование состязательных примеров» (Explaining and Harnessing Adversarial Examples), в которой была показана интересная особенность глубоких нейронных сетей — возможность намеренного искажения входного изображения так, что нейронная сеть неверно классифицировала его. Этот тип искажения называется состязательной атакой.
Классический пример состязательной атаки представлен на рисунке выше. Слева у нас есть входное изображение, которое наша нейронная сеть правильно классифицирует как «панда» с достоверностью 57,7%. В середине вектор шума, который человеческому глазу кажется случайным. Однако, это далеко не так: пиксели в векторе шума «равны знаку элементов градиента функции стоимости по отношению к входному изображению» (Goodfellow и др.).
Затем этот вектор шума добавляется к входному изображению, что дает результат (справа). Человеку это изображение кажется идентичным входному; однако нейронная сеть теперь классифицирует изображение как «гиббон» (маленькая обезьяна) с достоверностью 99,7%.
Почему состязательные атаки являются проблемой?
На примере с автомобилем и знаком в начале статьи показано, как состязательные атаки могут нанести огромный ущерб здоровью, жизни и собственности.
В качестве примеров с менее серьезными последствиями могут быть приведены таргетированные атаки на различные системы автоматической защиты, например, от спама или порнографии.
Предел последствий от таких атак ограничен только воображением атакующего, знанием используемой в объекте атаки модели и тем, насколько у него есть доступ к самой модели.
Можно ли защититься от таких атак?
Хорошая новость, что снижение вероятности состязательных атак возможно. Плохая — не в рамках этого руководства и не гарантированно.
Настройка вашей среды разработки
Для этой статьи специально подготовлен блокнот в экосистеме Google Colaboratory, так что Вам не придётся что-то специально настраивать.
Состязательные (Adversarial) атаки с помощью Keras и TensorFlow (Google Colab)
Вспомогательный код для работы с ImageNet
Прежде чем начать работать с классификатором изображений, необходимо создать вспомогательную функцию Python, используемую для загрузки и анализа меток классов набора данных ImageNet.
Для работы с классами имеется файл JSON (imagenet_class_index.json), который содержит индексы меток класса ImageNet, идентификаторы и названия классов.
Несколько первых строк этого файла JSON:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | { "0": [ "n01440764", "tench" ], "1": [ "n01443537", "goldfish" ], "2": [ "n01484850", "great_white_shark" ], "3": [ "n01491361", "tiger_shark" ], "4": [ "n01494475", "hammerhead" ], ... |
Здесь вы можете видеть, что файл является словарем. Ключом к словарю является индекс метки целочисленного класса, а значение — это кортеж, состоящий из:
- Уникальный идентификатор ImageNet
- Название класса
Цель разработки вспомогательного кода — реализовать функцию Python, которая будет анализировать файл JSON следующим образом:
- Принимать метку входного класса
- Возвращать целочисленный индекс этого класса
По сути, необходимо инвертировать отношения «ключ-значение» в файле описания.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | # Импортируем необходимые модули import json import os def get_class_idx(label): # Путь к файлу с описанием labelPath = "imagenet_class_index.json" # Открываем ImageNet файл описания классов и загружаем его как # словарь с названием класса, как ключом и индексом класса, как # значением with open (labelPath) as f: imageNetClasses = {labels[ 1 ]: int (idx) for (idx, labels) in json.load(f).items()} # возвращаем индекс класса или None, если класса не существует в файле return imageNetClasses.get(label, None ) |
Строки 2 и 3 импортируют необходимые Python пакеты. В коде будет использоваться модуль json для загрузки файла JSON, а пакет os может использоваться для создания путей к файлам, независимо от того, какую операционную систему вы используете.
Затем определяется вспомогательная функция get_class_idx. Цель этой функции — принять название класса, а затем получить целочисленный индекс класса.
Строка 7 — создается путь к файлу imagenet_class_index.json
Строки 13-15 — открывается файл labelPath и инвертируется отношение ключ/значение, так что ключ будет представлять название класса, а значение представляет собой целочисленный индекс, соответствующий этому классу.
Чтобы получить целочисленный индекс для названия класса, вызывается метод .get словаря imageNetClasses (строка 20) — этот вызов вернет либо:
- Целочисленный индекс класса (если он есть в словаре)
- И если класс (ключ) не существует в imageNetClasses, она вернет None
Затем это значение возвращается вызывающей функции.
Обычная классификация изображений без атак с использованием Keras и TensorFlow
С помощью нашей вспомогательной функции сначала создадим сценарий классификации изображений, который выполняет простую классификацию изображений без атак.
Этот сценарий продемонстрирует, что модель ResNet работает так, как и ожидается (то есть делает правильные прогнозы).
Сначала импортируем необходимые пакеты в строках 2–9. Все они будут казаться стандартными, если вы когда-либо раньше работали с Keras, TensorFlow и OpenCV.
1 2 3 4 5 6 7 8 9 | # Импортируем необходимые модули from tensorflow.keras.applications import ResNet50 from tensorflow.keras.applications.resnet50 import decode_predictions from tensorflow.keras.applications.resnet50 import preprocess_input import numpy as np import argparse import imutils import cv2 from google.colab.patches import cv2_imshow |
Однако, если вы новичок и не работали с этой библиотекой, то вы можете ознакомиться с базовыми приемами в заметках:
- Компьютерное зрение. Введение
- Компьютерное зрение. Машиное обучение с использовнием нейронных сетей (Keras)
- Сверточные нейронные сети для компьютерного зрения [0.1] Введение и установка и следующие в серии.
Теперь определим вспомогательную функцию preprocess_image:
1 2 3 4 5 6 7 8 9 | def preprocess_image(image): # swap color channels, preprocess the image, and add in a batch # dimension image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) image = preprocess_input(image) image = cv2.resize(image, ( 224 , 224 )) image = np.expand_dims(image, axis = 0 ) # return the preprocessed image return image |
Метод preprocess_image принимает единственный обязательный аргумент — изображение, которое мы хотим предварительно обработать.
Предварительная обработка изображения содержит следующие шаги:
- Замена порядока каналов изображения с BGR на RGB
- Вызов функции preprocess_input, которая выполняет специфичную для ResNet50 предварительную обработку и масштабирование
- Изменение размера изображения до 224 × 224
- Добавление размерности batch’а
Затем предварительно обработанное изображение возвращается в вызывающую функцию.
Теперь загрузим наше входное изображение и обработаем его, в качестве изображения будем использовать сородича кота из статьи Сверточные нейронные сети для компьютерного зрения [1.1] Классификация изображений…:

1 2 3 4 5 6 7 | # load image from disk and make a clone for annotation print ( "[INFO] loading image..." ) image = cv2.imread( "cat_2.jpg" ) output = image.copy() # preprocess the input image output = imutils.resize(output, width = 400 ) preprocessedImage = preprocess_image(image) |
Вызов cv2.imread загружает входное изображение с диска. В строке 4 оно копируется, чтобы позже можно было отрисовать (аннотировать) его с названием предсказананного класса.
Далее размер выходного изображения изменяется, чтобы иметь ширину 400 пикселей, чтобы оно поместилось на нашем экране. А также вызывается ранее написанная функция preprocess_image для входного изображения, чтобы подготовить его к классификации в ResNet.
После предварительной обработки изображения можно загрузить ResNet и классифицировать изображение:
1 2 3 4 5 6 7 | # load the pre-trained ResNet50 model print ( "[INFO] loading pre-trained ResNet50 model..." ) model = ResNet50(weights = "imagenet" ) # make predictions on the input image and parse the top-3 predictions print ( "[INFO] making predictions..." ) predictions = model.predict(preprocessedImage) predictions = decode_predictions(predictions, top = 3 )[ 0 ] |
В строке 3 загружается ResNet с весами, предварительно обученными в наборе данных ImageNet.
В строках 6 и 7 делается прогноз на предварительно обработанном изображении, который затем декодируется с помощью вспомогательной функции decode_predictions в Keras/TensorFlow.
Теперь переберем 3 наиболее вероятных прогноза сети и выведем ярлыки классов:
1 2 3 4 5 6 7 8 9 | # loop over the top three predictions for (i, (imagenetID, label, prob)) in enumerate (predictions): # print the ImageNet class label ID of the top prediction to our # terminal (we'll need this label for our next script which will # perform the actual adversarial attack) if i = = 0 : print ( "[INFO] {} => {}" . format (label, get_class_idx(label))) # display the prediction to our screen print ( "[INFO] {}. {}: {:.2f}%" . format (i + 1 , label, prob * 100 )) |
В строке 2 начинается цикл для трех самых вероятных прогнозов.
Если это первый прогноз (то есть наиболее вероятный), то отображаются название класса в терминале, а затем ищется целочисленный индекс ImageNet класса с помощью функции get_class_idx.
Также отображаются топ-3 классов и соответствующие им вероятности.
Последний шаг — нарисовать наиболее вероятный прогноз на выходном изображении:
1 2 3 4 5 6 7 8 | # draw the top-most predicted label on the image along with the # confidence score text = "{}: {:.2f}%" . format (predictions[ 0 ][ 1 ], predictions[ 0 ][ 2 ] * 100 ) cv2.putText(output, text, ( 3 , 20 ), cv2.FONT_HERSHEY_SIMPLEX, 0.8 , ( 0 , 255 , 0 ), 2 ) # show the output image cv2_imshow(output) |

Кроме того, обратите внимание на идентификатор метки ImageNet «Egyptian_cat» (285) — он будет использоваться в следующем разделе, где будет проведена состязательная атака на входное изображение.
Реализация состязательных изображений и атак с помощью Keras и TensorFlow
Теперь рассмотрим, как реализовать состязательные атаки с помощью Keras и TensorFlow.
Начнем, как обычно, с импорта зависимостей:
1 2 3 4 5 6 7 8 9 10 | # import necessary packages from tensorflow.keras.optimizers import Adam from tensorflow.keras.applications import ResNet50 from tensorflow.keras.losses import SparseCategoricalCrossentropy from tensorflow.keras.applications.resnet50 import decode_predictions from tensorflow.keras.applications.resnet50 import preprocess_input import tensorflow as tf import numpy as np import argparse import cv2 |
Вы можете заметить, что снова используется архитектуру ResNet50 с соответствующей функцией preprocess_input (для предварительной обработки и масштабирования входных изображений) и утилитой decode_predictions для декодирования выходных прогнозов и отображения классов ImageNet.
SparseCategoricalCrossentropy вычисляет функцию потери кросс-энтропии между реальными классами и их прогнозами.
Так же, как и в прошлый раз нужно создать функцию для предобработки изображения, однако она будет несколько отличаться:
1 2 3 4 5 6 7 8 | def preprocess_image_attack(image): # swap color channels, resize the input image, and add a batch # dimension image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) image = cv2.resize(image, ( 224 , 224 )) image = np.expand_dims(image, axis = 0 ) # return the preprocessed image return image |
Эта реализация идентична написанной ранее, за исключением того, что не используется вызов функции preprocess_input — чуть позже станет понятно, почему эта функция не вызывается.
Далее, напишем простую вспомогательную функцию clip_eps:
1 2 3 4 | def clip_eps(tensor, eps): # clip the values of the tensor to a given range and return it return tf.clip_by_value(tensor, clip_value_min = - eps, clip_value_max = eps) |
Задача этой функции — принять тензор, а затем вырезать любые значения в диапазоне [-eps, eps].
Обрезанный тензор затем возвращается вызывающей функции.
Теперь рассмотрим функцию generate_adversaries, которая является основой состязательной атаки:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | def generate_adversaries(model, baseImage, delta, classIdx, steps = 50 ): # Цикл по количеству шагов for step in range ( 0 , steps): # Сохраняем градиент with tf.GradientTape() as tape: # явно указать, что наш вектор искажения должен # отслеживаться для обновления градиента tape.watch(delta) # добавляем наш вектор искажения к базовому изображению # и предварительно обрабатываем полученное изображение adversary = preprocess_input(baseImage + delta) # пропускаем этот недавно построенный тензор изображения # через нашу модель и вычисляем потери относительно # *исходного* индекса класса predictions = model(adversary, training = False ) loss = - sccLoss(tf.convert_to_tensor([classIdx]), predictions) # проверьте, регистрируем ли мы значение потерь, и если да, # отображаем его на терминале if step % 5 = = 0 : print ( "step: {}, loss: {}..." . format (step, loss.numpy())) # вычисляем градиенты потерь # относительно вектора искажения gradients = tape.gradient(loss, delta) # обновить веса, отсечь вектор искажения и # обновить его значение optimizer.apply_gradients([(gradients, delta)]) delta.assign_add(clip_eps(delta, eps = EPS)) # вернуть вектор искажения return delta |
Метод generate_adversaries — это основная часть атакующего сценария. Эта функция принимает четыре обязательных параметра и необязательный пятый:
- model: модель ResNet50 (ее можно заменить на другую предварительно обученную модель, такую как VGG16, MobileNet и т. д., если хотите попробовать атаку на другую модель).
- baseImage: исходное входное изображение, для которого нужно создать состязательную атаку, в результате которой модель должна будет неверно классифицировать.
- delta: вектор шума, который будет добавлен к baseImage, что в конечном итоге приведет к ошибочной классификации. Этот дельта-вектор обновится с помощью градиентного спуска.
- classIdx: Целочисленный индекс метки класса, который был получен, при нормальной работе сети.
- steps: количество выполняемых шагов градиентного спуска (по умолчанию 50 шагов).
В строке 3 запускается цикл по заданному количеству шагов.
Затем используется GradientTape для записи градиентов. Вызов метода .watch объекта tape явно указывает на то, что вектора искажения должен отслеживаться на предмет обновлений.
В строке 11 создается атакующее изображение путем добавления вектор дельта-искажения к baseImage. Результат этого добавления передается через функцию preprocess_input ResNet50 для масштабирования и нормализации результирующего состязательного изображения.
Далее происходит следующее:
- В строке 15 с помощью модели делается прогноз для созданного атакующего изображения.
- В строках 16 и 17 вычисляются потери по отношению к исходному classIdx (то есть целочисленному индексу метки класса Top-1 ImageNet, который был получен при распознавании исходного изображения).
- Строки 20-22 показывают итоговые значения функции потерь каждые пять шагов.
Вне оператора with, вычисляется градиент функции потерь относительно вектора искажения (строка 25).
Затем обновляется дельта-вектор, и обрезаются значения, выходящие за пределы диапазона [-EPS, EPS].
Наконец, возвращается полученный вектор искажения вызывающей функции — окончательное значение дельты позволит построить состязательную атаку, используемую для обмана модели.
Теперь мы можем выполнить пару инициализаций, загрузить и предварительно обработать изображение нашего кота:
1 2 3 4 5 6 7 8 9 | # определить эпсилон и константы скорости обучения EPS = 2 / 255.0 LR = 0.1 # загрузить входное изображение с диска и предварительно обработать его print ( "[INFO] loading image..." ) image = cv2.imread( "cat_2.jpg" ) image = preprocess_image_attack(image) class_idx = 285 |
В строке 2 определяется значение epsilon (EPS), используемое для отсечения тензоров при построении состязательного изображения. Значение EPS, равное 2/255,0, является стандартным значением, используемым в публикациях и учебных пособиях, связанных с атаками.
Затем определяется скорость обучения в строке 3. Значение LR = 0.1 было получено путем эмпирической настройки — вам может потребоваться обновить это значение при построении ваших собственных состязательных изображений.
Строки 6 и 7 содержат команды загрузки входного изображение с диска и предварительной обрабоки его с помощью вспомогательной функции preprocess_image.
Затем загружаем модель ResNet:
1 2 3 4 5 6 | # загрузить предварительно обученную модель ResNet50 для выполнения вывода print("[INFO] loading pre-trained ResNet50 model...") model = ResNet50(weights="imagenet") # инициализировать оптимизатор и функцию потерь optimizer = Adam(learning_rate=LR) sccLoss = SparseCategoricalCrossentropy() |
В строке 3 загружается модель ResNet50, предварительно обученная на наборе данных ImageNet.
Оптимизатор Adam будет использоваться вместе с реализацией разреженных категориальных потерь при обновлении вектора искажения.
Теперь создадим атакующее изображение:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | # вектор искажения (этот вектор будет обновляться в процессе обучения) baseImage = tf.constant(image, dtype=tf.float32) delta = tf.Variable(tf.zeros_like(baseImage), trainable=True) # сгенерировать вектор искажения для создания состязательного примера print("[INFO] generating perturbation...") deltaUpdated = generate_adversaries(model, baseImage, delta, class_idx) # создать состязательный пример, поменять местами цветовые каналы # и сохранить выходное изображение на диск print("[INFO] creating adversarial example...") adverImage = (baseImage + deltaUpdated).numpy().squeeze() adverImage = np.clip(adverImage, 0, 255).astype("uint8") adverImage = cv2.cvtColor(adverImage, cv2.COLOR_RGB2BGR) cv2.imwrite("cat_but_no_cat.jpeg", adverImage) |
В строке 3 создается тензор из входного изображения, а в строке 4 инициализируется вектор искажения.
Чтобы фактически создать и обновить дельта-вектор, вызывается функция generate_adversaries, в которую передается модель ResNet50, входное изображение, вектор искажения и индекс исходного класса.
Функция generate_adversaries запускается, попутно обновляя вектор дельта-искажения, в результате чего получается deltaUpdated, окончательный вектор шума.
Создается окончательное состязательное изображение (adverImage) в строке 12, путем добавления вектора deltaUpdated к baseImage.
После этого выполняется постобработке полученного состязательного изображения:
- Отсечение любых значений, выходящих за пределы диапазона [0, 255]
- Преобразование изображения в 8-битное целое число без знака (чтобы OpenCV теперь мог работать с изображением)
- Изменение порядка цветовых каналов с RGB на BGR
После вышеуказанных шагов постобработки выходное изображение записывается на диск.
Сможет ли созданное изображение обмануть модель ResNet?
Следующий блок кода ответит на этот вопрос:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | # выполнить вывод с этим состязательным примером, проанализировать результаты, # и отобразить первый прогнозируемый результат print("[INFO] running inference on the adversarial example...") preprocessedImage = preprocess_input(baseImage + deltaUpdated) predictions = model.predict(preprocessedImage) predictions = decode_predictions(predictions, top=3)[0] label = predictions[0][1] confidence = predictions[0][2] * 100 print("[INFO] label: {} confidence: {:.2f}%".format(label, confidence)) # нарисуйте метку с наиболее вероятным прогнозом на атакующем изображении # вместе с показателем достоверности text = "{}: {:.2f}%".format(label, confidence) cv2.putText(adverImage, text, (3, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 2) # показать выходное изображение cv2_imshow(adverImage) |
Снова создается состязательное изображение в строке 4, путем добавления вектора дельта-шума к исходному изображению, но на этот раз используется утилита preprocess_input ResNet.
Полученное предварительно обработанное изображение пропускается через ResNet, после чего получается и декодируются 3 лучших прогноза (строки 5 и 6).
Затем берется название наиболее вероятного класса и соответствующая ему вероятность (достоверность) прогноза и выводится в терминал (строки 7-10).
Последний шаг — нарисовать прогноз на выходном состязательном изображении и отобразить его на экране.

Как видите, между изображениями нет заметной разницы —человеческие глаза не могут видеть разницу между этими двумя изображениями, но для ResNet они совершенно разные, и вместо кота нейронная сеть видит медведя.
Показаный метод работает, но в нем явно не контролируется окончательный класс на изображении (а потому получаем «соседний» класс, и в этом случае получили медведя вместо кота). Возникает вопрос:
Можно ли контролировать окончательную метку выходного класса входного изображения? Ответ — да, и об этом в следующей статье.
В заключение можно сказать, что легко испугаться состязательных изображений и атак, если вы позволите своему воображению взять верх над вами. Но от них возможно защититься.
Уведомление: Направленные состязательные (Targeted adversarial) атаки с использование Keras и TensorFlow | Digiratory