Состязательные (Adversarial) атаки с помощью Keras и TensorFlow

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

фото: ГУ МВД РФ по Волгоградской области, pixabay.com

Сегодня всё большее распространение находят системы, например, автопилоты и различные помощники при вождении и другие системы, автоматизация которых основана на сложных визуальных датчиках. Если быть точнее, технически датчики не особо сложны, но для обработки изображений используются сложные модели глубокого обучения. Таким образом, принятие решения о торможении, разгоне, повороте или других потенциальных опасных решений основано на данных, предоставленных нейронной сетью.

Например, вы едете в машине, ведете беседу с пассажирами и видите впереди знак «STOP» с какой-то наклейкой, однако, вы не обращаете на это внимание и продолжаете разговор, но автомобиль вместо того, чтобы остановиться перед знаком, увеличивает скорость и в следующее мгновение в вас врезается грузовик, едущий по главной дороге.

Очевидны вопросы: что случилось? Почему ваша беспилотная машина отреагировала именно так? Это была какая-то странная «ошибка» в коде или программном обеспечении вашей машины?

Ответом на этот вопрос будет то, что нейронная сеть, которая принимает непосредственное участие в управлении вашим (когда-то) автомобилем была атакована с помощью той самой наклейки на знак.

К состязательным (adversarial) изображениям относятся такие изображения, которые содержат умышленно измененные пиксели, с целью вызвать некорректную работу нейронной сети, но при этом не имеющие существенных для человека изменений.

Так как же работают такие атаки? Как они осуществляются? И как от них защититься? Рассмотрим базовые приемы с использованием Keras и TensorFlow.

Что такое состязательные изображения и атаки?

При выполнении состязательной изображение (слева) подается на вход нейронной сети. Затем с использованием градиентного спуска строится вектор шума (в центре). Этот вектор шума добавляется к входному изображению, что приводит к неправильной классификации (справа). (Источник изображения: Рисунок 1 Explaining and Harnessing Adversarial Examples)

В далеком 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"
	],
...

Здесь вы можете видеть, что файл является словарем. Ключом к словарю является индекс метки целочисленного класса, а значение — это кортеж, состоящий из:

  1. Уникальный идентификатор ImageNet
  2. Название класса

Цель разработки вспомогательного кода — реализовать функцию Python, которая будет анализировать файл JSON следующим образом:

  1. Принимать метку входного класса
  2. Возвращать целочисленный индекс этого класса

По сути, необходимо инвертировать отношения «ключ-значение» в файле описания.

# Импортируем необходимые модули
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

Однако, если вы новичок и не работали с этой библиотекой, то вы можете ознакомиться с базовыми приемами в заметках:

Теперь определим вспомогательную функцию 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 принимает единственный обязательный аргумент — изображение, которое мы хотим предварительно обработать.

Предварительная обработка изображения содержит следующие шаги:

  1. Замена порядока каналов изображения с BGR на RGB
  2. Вызов функции preprocess_input, которая выполняет специфичную для ResNet50 предварительную обработку и масштабирование
  3. Изменение размера изображения до 224 × 224
  4. Добавление размерности 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 они совершенно разные, и вместо кота нейронная сеть видит медведя.

Показаный метод работает, но в нем явно не контролируется окончательный класс на изображении (а потому получаем «соседний» класс, и в этом случае получили медведя вместо кота). Возникает вопрос:

Можно ли контролировать окончательную метку выходного класса входного изображения? Ответ — да, и об этом в следующей статье.

В заключение можно сказать, что легко испугаться состязательных изображений и атак, если вы позволите своему воображению взять верх над вами. Но от них возможно защититься.

Один комментарий к “Состязательные (Adversarial) атаки с помощью Keras и TensorFlow

  1. Уведомление: Направленные состязательные (Targeted adversarial) атаки с использование Keras и TensorFlow | Digiratory

Обсуждение закрыто.