В этом руководстве вы узнаете, как взламывать 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:
{
"0": [
"n01440764",
"tench"
],
"1": [
"n01443537",
"goldfish"
],
"2": [
"n01484850",
"great_white_shark"
],
"3": [
"n01491361",
"tiger_shark"
],
"4": [
"n01494475",
"hammerhead"
],
...
Здесь вы можете видеть, что файл является словарем. Ключом к словарю является индекс метки целочисленного класса, а значение — это кортеж, состоящий из:
- Уникальный идентификатор ImageNet
- Название класса
Цель разработки вспомогательного кода — реализовать функцию Python, которая будет анализировать файл JSON следующим образом:
- Принимать метку входного класса
- Возвращать целочисленный индекс этого класса
По сути, необходимо инвертировать отношения «ключ-значение» в файле описания.
# Импортируем необходимые модули
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.
# Импортируем необходимые модули
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:
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] Классификация изображений…:
# 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 и классифицировать изображение:
# 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 наиболее вероятных прогноза сети и выведем ярлыки классов:
# 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 классов и соответствующие им вероятности.
Последний шаг — нарисовать наиболее вероятный прогноз на выходном изображении:
# 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.
Начнем, как обычно, с импорта зависимостей:
# 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 вычисляет функцию потери кросс-энтропии между реальными классами и их прогнозами.
Так же, как и в прошлый раз нужно создать функцию для предобработки изображения, однако она будет несколько отличаться:
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:
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, которая является основой состязательной атаки:
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].
Наконец, возвращается полученный вектор искажения вызывающей функции — окончательное значение дельты позволит построить состязательную атаку, используемую для обмана модели.
Теперь мы можем выполнить пару инициализаций, загрузить и предварительно обработать изображение нашего кота:
# определить эпсилон и константы скорости обучения
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:
# загрузить предварительно обученную модель ResNet50 для выполнения вывода
print("[INFO] loading pre-trained ResNet50 model...")
model = ResNet50(weights="imagenet")
# инициализировать оптимизатор и функцию потерь
optimizer = Adam(learning_rate=LR)
sccLoss = SparseCategoricalCrossentropy()
В строке 3 загружается модель ResNet50, предварительно обученная на наборе данных ImageNet.
Оптимизатор Adam будет использоваться вместе с реализацией разреженных категориальных потерь при обновлении вектора искажения.
Теперь создадим атакующее изображение:
# вектор искажения (этот вектор будет обновляться в процессе обучения)
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?
Следующий блок кода ответит на этот вопрос:
# выполнить вывод с этим состязательным примером, проанализировать результаты,
# и отобразить первый прогнозируемый результат
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