Обман нейронной сети для начинающих
В рамках ежегодного контеста ZeroNights HackQuest 2018 участникам предлагалось попробовать силы в целом ряде нетривиальных заданий и конкурсов. Часть одного из них была связана с генерированием adversarial-примера для нейронной сети. В наших статьях мы уже уделяли внимание методам атаки и защиты алгоритмов машинного обучения. В рамках же этой публикации мы разберем пример того, как можно было решить задание с ZeroNights Hackquest при помощи библиотеки foolbox.
В данном задании атакующий должен был получить доступ к серверу. После того как ему это удавалось, он в домашней директории видел следующую файловую структуру:
| Home
--| KerasModel.h5
--| Task.txt
--| ZeroSource.bmp
В файле Task.txt находилась следующая информация:
Now it is time for a final boss!
http://51.15.100.188:36491/predict
You have a mode and an image.
To get a ticket, you need to change an image so that it is identified as "1".
curl -X POST -F image=@ZeroSource.bmp 'http://51.15.100.188:36491/predict'.
(don't forget about normalization (/255) ^_^)
Для получения заветныго тикета атакующему предлагалось преобразовать ZeroSource.bmp:
таким образом, чтобы после отправки на сервер нейронная сеть интерпретировала это изображение как 1. Если попытаться отправить данное изображение без обработки, то результат работы нейронной сети будет равен 0.
И, конечно же, главная подсказка к этому заданию — файл модели KerasModel.h5 (именно этот файл помогает атакующему перевести атаку в плоскость WhiteBox, так как ему доступна нейронная сеть и все данные, связанные с ней). В названии файла сразу же содержится подсказка — название фреймворка, на котором реализована нейронная сеть.
Именно с такими вводными участник и приступал к решению задания:
- Модель нейронной сети написанная на Keras.
- Возможность отправлять изображение на сервер с помощью curl.
- Исходное изображение, которое необходимо было изменить.
На стороне сервера проверка была максимально простая:
- Изображение должно быть нужного размера — 28×28 пикселей.
- На данном изображении модель должна вернуть 1.
- Разница между исходным изобаржением ZeroSource.bmp и отправленным на сервер должна быть меньше порога k по метрике MSE (среднеквадратичная ошибка).
Итак, начнем.
Сперва участнику было необходимо найти информацию о том, как же обмануть нейронную сеть. После недолгих действий в гугле он получал ключевые слова «Adversarial пример» и «Adversarial атака». Далее ему надо было поискать инструменты, позволяющие применять adversarial-атаки. Если вбить в Google запрос «Adversarial attacks on Keras Neural Net», первая же ссылка будет на GitHub проекта FoolBox — библиотеки на python для генерации adversarial-примеров. Конечно же, есть и другие библиотеки (о некоторых из них мы говорили в предыдущих статьях). Более того, атаки можно было написать, что называется, from scratch. Но мы все таки остановимся на самой популярной библиотеке, которую человек, ранее не сталкивавшийся с темой adversarial-атак, может найти по первой же ссылке в Google.
Теперь необходимо написать Python-cкрипт, который сгенерирует adversarial-пример.
Начнем мы, конечно же, с импорта.
import keras
import numpy as np
from PIL import Image
import foolbox
Что мы здесь видим?
- Keras — фреймворк, на котором написана Нейронная сеть, которую мы будем обманывать.
- NumPy — библиотека, которая позволит нам эффективно работать с векторами.
- PIL — инструмент для работы с изображениями.
- FoolBox — библиотека для генерации adversarial-примеров.
Первое, что необходимо сделать — это, естественно, загрузить модель нейронной сети в память нашей программы и посмотреть информацию о модели.
model = keras.models.load_model("KerasModel.h5") # Загружаем модель
model.summary() # Выводим информацию о модели
model.input # Выводим информацию о том, какие данные должны поступать на вход НС
На выходе мы получим следующее:
Layer (type) Output Shape Param #
=================================================================
conv2d_1 (Conv2D) (None, 26, 26, 32) 320
_________________________________________________________________
conv2d_2 (Conv2D) (None, 26, 26, 64) 18496
_________________________________________________________________
max_pooling2d_1 (MaxPooling2 (None, 13, 13, 64) 0
_________________________________________________________________
dropout_1 (Dropout) (None, 13, 13, 64) 0
_________________________________________________________________
conv2d_3 (Conv2D) (None, 13, 13, 64) 36928
_________________________________________________________________
conv2d_4 (Conv2D) (None, 13, 13, 128) 73856
_________________________________________________________________
max_pooling2d_2 (MaxPooling2 (None, 6, 6, 128) 0
_________________________________________________________________
flatten_1 (Flatten) (None, 4608) 0
_________________________________________________________________
dense_1 (Dense) (None, 256) 1179904
_________________________________________________________________
dense_2 (Dense) (None, 10) 2570
=================================================================
Total params: 1,312,074
Trainable params: 1,312,074
Non-trainable params: 0
_________________________________________________________________
Какую информацию отсюда можно вынести?
- Модель на вход (conv2d_1 слой) принимает объект размерностью ? x28×28x1, где »?» — количество объектов; если изображение одно, тогда размерность будет 1×28x28×1. А изображение представляет с собой трехмерный массив, где одна размерность равна 1. То есть изображение подается в виде таблицы значений от 0 до 255.
- На выходе у модели (dense_2 слой) получается вектор размерностью 10.
Загрузим изображение и не забудем привести его к типу float (далее нейронная сеть будет работать именно с вещественными числами) и нормализовать его (поделим все значения на 255). Здесь стоит уточнить, что нормализация — один из «обязательных» приемов при работе с нейронными сетями, но атакующий ведь мог этого и не знать, поэтому мы специально добавили небольшую подсказку в описании к задаче):
img = Image.open("ZeroSource.bmp") # Загружаем изображение
img = np.array(img) # Приводим его к типу numpy.array
img = img.astype('float32') # приводим значения массива к типу float
img /= 255 # нормализуем
Теперь мы можем отправить изображение в загруженную модель и посмотреть, какой результат она выдает:
model.predict(img.reshape(1,28,28,1)) # вызываем метод predict у найше модели и посылаем туда массив предварительно откорректровав его размерность
На выходе получаем следующую информацию
array([[1.0000000e+00, 4.2309660e-19, 3.1170484e-15, 6.2545637e-18,
1.4199094e-16, 6.3990816e-13, 6.9493417e-10, 2.8936278e-12,
8.9440377e-14, 1.6340098e-12]], dtype=float32)
Здесь стоит объяснить, что из себя представляет данный вектор: на самом деле, это распределение вероятностей, то есть каждое число представляет собой вероятность класса 0,1,2…,9. Сумма всех чисел в векторе равна 1. В данном случае видно, что модель уверена в том, что на входном изображении представлен класс 0 с вероятностью в 100%.
Если изобразить это на гистограмме, мы получим следующее:
Абсолютная уверенность.
Если бы модель не смогла определить класс, вектор вероятности стремился бы к равномерному распределению, что, в свою очередь, означало бы, что модель относит объект ко всем классам одновременно с одинаковой вероятностью. А гистограмма выглядела бы вот так:
Принято считать, что класс модели определяется по индексу максимального числа в данном векторе. То есть модель теоретически могла выбрать класс, у которого вероятность больше 10%. Но эта логика может изменяться в зависимости от логики принятия решения, описанной разработчиками.
Теперь перейдем к самому интересному — adversarial-атакам.
Во-первых, для работы с моделью в библиотеке FoolBox необходимо перевести модель в нотацию Foolbox. Сделать это можно так:
fmodel = foolbox.models.KerasModel(model,bounds=(0,1)) # в bounds показывается, в каком диапазоне входных значений работает модель, так как наши данные были поделены на 255, то они как раз приведены в диапазон 0-1.
После этого можно тестировать разные атаки. Начнем с самой популярной — FGSM:
FGSM
attack = foolbox.attacks.FGSM(fmodel) # Вызываем конструктор класса FGSM и передаем туда модель
adversarial = attack(img.reshape(28,28,1),0) # Вызываем атаку, получаем adversarial пример
probs = model.predict(adversarial.reshape(1,28,28,1)) # Отправляем его в сеть
print(probs) # Вектор распределения вероятностей
print(np.argmax(probs)) # Индекс максимального числа в векторе
Выход нейронной сети будет выглядеть следующим образом
[4.8592144e-01 2.5432981e-14 5.7048566e-13 1.6787202e-14 1.6875961e-11
1.2974949e-07 5.1407838e-01 3.9819957e-12 1.9827724e-09 5.7383300e-12]
6
А полученное изображение:
Итак, теперь с вероятностью более 50% 0 был распознан как 6. Уже хорошо. Однако нам ведь все-таки хочется получить 1, да и уровень шума не сильно впечатляет. Изображение, действительно, выглядит неправдоподобно. Об этом чуть позже. А пока давайте попробуем просто поперебирать атаки. Вдруг мы все таки получим 1.
L-BFGS атака
attack = foolbox.attacks.LBFGSAttack(fmodel)
adversarial = attack(img.reshape(28,28,1),0)
probs = model.predict(adversarial.reshape(1,28,28,1))
print(probs)
print(np.argmax(probs))
Вывод:
[4.7782943e-01, 1.9682934e-10, 1.0285517e-06, 3.2558936e-10,
6.5797998e-05, 4.0495447e-06, 2.5545436e-04, 3.4730587e-02,
5.5223148e-07, 4.8711312e-01]
9
Изображение:
Опять мимо. Теперь у нас 0 распознается как 9 с вероятностью ~49%. Впрочем, шума уже гораздо меньше.
Давайте на этом закончим бить рандомом. Пример был выбран таким образом, что рандомно получить результат будет весьма сложно. Сейчас мы нигде не указывали, что мы хотим получить 1. Соответственно мы проводили non-targeted атаку и верили, что все таки получим класс 1, но этого не случилось. Поэтому стоит перейти к targeted атакам. Давайте воспользуемся документацией к foolbox и найдем там модуль criteria
В данном модуле можно выбрать критерий для атаки, если она их поддерживает. Конкретно нас интересует два критерия:
- TargetClass — делает так, чтобы в векторе распределений вероятности, элемент под номером k имел максимальную вероятность.
- TargetClassProbability — делает так, чтобы в векторе распределений вероятности, элемент под номером k имел вероятность не ниже p.
Давайте попробуем оба:
L-BFGS + TargetClass
Главное в TargetClass критерии — получить вероятность класса k, выше чем вероятность любого другого класса. Тогда сеть, которая принимает решение просто смотря на максимальную вероятность — ошибется.
attack = foolbox.attacks.LBFGSAttack(fmodel,foolbox.criteria.TargetClass(1))# Здесь все так же, как и в предыдущем примере, но добавляется критерий TargetClass, аргументом которого, является индекс класса в векторе распределений, который мы будем делать максимальным
adversarial = attack(img.reshape(28,28,1),0)
probs = model.predict(adversarial.reshape(1,28,28,1))
print(probs)
print(np.argmax(probs))
Вывод:
[3.2620126e-01 3.2813528e-01 8.5446298e-02 8.1292394e-04 1.1273423e-03
2.4886258e-02 3.3904776e-02 1.9947644e-01 8.2347924e-07 8.5878673e-06]
1
Изображение:
Как видно из вывода, теперь наша нейронная сеть утверждает, что это 1 с вероятностью 32,8%, при этом вероятность 0 максимальна близка к этому значению и равна 32,6%. У нас получилось! В принципе, этого уже достаточно, чтобы выполнить задание. Но мы пойдем дальше и попробуем получить вероятность 1 выше 0,5.
L-BFGS + TargetClassProbability
Теперь воспользуемся критерием TargetClassProbability, который позволяет получить вероятность класса в объекте не ниже p. У него есть всего два параметра:
1) Номер класса объекта.
2) Вероятность этого класса в adversarial-примере.
При этом, если достичь такой вероятности невозможно или время для нахождения такого объекта занимает слишком много времени, то объект adversarial будет равен none. Вы можете самостоятельно это проверить, попробовав сделать вероятность, скажем, 0,99. Тогда метод вполне может не сойтись.
attack = foolbox.attacks.LBFGSAttack(fmodel,foolbox.criteria.TargetClassProbability(1,0.5))
adversarial = attack(img.reshape(28,28,1),0)
probs = model.predict(adversarial.reshape(1,28,28,1))
print(probs)
print(np.argmax(probs))
Вывод:
[4.2620126e-01 5.0013528e-01 9.5413298e-02 8.1292394e-04 1.1273423e-03
2.4886258e-02 3.3904776e-02 1.9947644e-01 8.2347924e-07 8.5878673e-06]
Ура! У нас вышло получить adversarial-пример, на котором вероятность 1 для нашей нейронной сети выше 50%! Замечательно! Теперь давайте сделаем денормализацию (вернем изображение в формат 0–255) и сохраним его.
Итоговый скрипт получился следующим:
import keras
from PIL import Image
import numpy as np
import foolbox
from foolbox.criteria import TargetClassProbability
import scipy.misc
model = keras.models.load_model("KerasModel.h5")
img = Image.open("ZeroSource.bmp")
img = np.array(img.getdata())
img = img.astype('float32')
img = img /255.
img = img.reshape(28,28,1)
fmodel = foolbox.models.KerasModel(model,bounds=(0,1))
attack = foolbox.attacks.LBFGSAttack(fmodel,criterion=TargetClassProbability(1 ,p=.5))
adversarial = attack(img[:,:,::-1], 0)
adversarial = adversarial * 255
adversarial = adversarial.astype('int')
scipy.misc.toimage(adversarial.reshape(28,28)).save('AdversarialExampleZero.bmp')
А итоговое изображение выглядит следующим образом:
.
Выводы
Итак, как мы увидели из приведенных выше примеров, обмануть нейронную сеть было достаточно просто. Методов, способных это сделать, также большое количество. Достаточно просто открыть список доступных атак в foolbox и попробовать их применить. Мы предлагаем вам самим попробовать провернуть то же самое, взяв за основу ту же нейронную сеть и то же самое изображение, доступные по ссылке. Ваши вопросы вы можете оставлять в комментариях. Мы обязательно на них ответим!
Всегда помните о том, что, как бы полезны ни были алгоритмы и модели, они могут быть крайне неустойчивы к небольшим сдвигам, которые могут приводить к серьезным ошибкам. Поэтому рекомендуем вам тестировать свои модели, в чем вам могут помочь python и инструменты по типу foolbox.
Спасибо за внимание!