遗传算法/愚弄神经网络

本文灵感来自于上一篇生物进化模拟. 神经网络当前已经能识别各种图像, 网络会给出一个范围为 0 至 1 的置信度(confidence) 表明它有多少把握认为这张图片属于某一分类. 通过将神经网络的置信度作为遗传算法的适应度, 可以很容易生成人眼无法辨认, 而神经网络却有 99.99% 的把握认为是某一分类的图像(例如, 将一张充满无意义噪点的图像以 99.99% 的置信度分类为狮子).

在 2014 年已经有研究者研究该方面的知识, 论文地址是: Deep Neural Networks are Easily Fooled:High Confidence Predictions for Unrecognizable Images. 除了使用随机噪点愚弄神经网络之外, 文章中还研究了如何通过微调像素点, 得到一张标签为图书馆的狮子. 其实类似的研究还有很多, 比如 All it takes to steal your face is a special pair of glasses 就实现了通过佩戴一副特殊眼镜, 让人脸识别系统将你误认为是他人.

本文目的是愚弄一个手写数字识别网络.

训练神经网络

这里使用 keras 来训练我们的手写数字识别模型. 直接用官方 examples 里的训练代码: https://github.com/keras-team/keras/blob/master/examples/mnist_mlp.py, 记得在原始代码最后加上 model.save_weights('mnist_mlp.h5') 来保存模型到本地. keras 在该模型上给出的测试精度是 98.40%.

在完成训练后, 随机生成一个 28 * 28 的图片测试一下该模型:

import keras.losses
import keras.models
import keras.optimizers
import numpy as np

model = keras.models.Sequential()
model.add(keras.layers.core.Dense(512, activation='relu', input_shape=(784, )))
model.add(keras.layers.core.Dropout(0.2))
model.add(keras.layers.core.Dense(512, activation='relu'))
model.add(keras.layers.core.Dropout(0.2))
model.add(keras.layers.core.Dense(10, activation='softmax'))
model.compile(
    loss=keras.losses.categorical_crossentropy,
    optimizer=keras.optimizers.RMSprop(),
    metrics=['accuracy']
)
model.load_weights('mnist_mlp.h5')


def predict(x):
    assert x.shape == (784, )
    y = model.predict(np.array([x]), verbose=0)
    return y[0]


x = np.random.randint(0, 2, size=784, dtype=np.bool)
r = predict(x)
print(r)

输出如下:

[  7.09424297e-09   0.00000000e+00   7.83010735e-04   0.00000000e+00
   0.00000000e+00   3.43550600e-14   9.99216914e-01   2.81605187e-19
   2.40218861e-36   2.99693766e-28]

开始调戏

代码和前几章基本一样, 唯一不同是使用神经网络作为遗传算法的适应度计算函数. 下示算法会初始化 80 张 28*28 的图片, 并将数据传入神经网络计算每张图片在某个数字上的得分, 如果在某一轮, 群体中最优秀的个体得分超过 0.99, 则结束进化, 并保存该最优个体.

import os
import os.path

import keras.losses
import keras.models
import keras.optimizers
import numpy as np
import skimage.draw
import skimage.io
import skimage.transform

model = keras.models.Sequential()
model.add(keras.layers.core.Dense(512, activation='relu', input_shape=(784, )))
model.add(keras.layers.core.Dropout(0.2))
model.add(keras.layers.core.Dense(512, activation='relu'))
model.add(keras.layers.core.Dropout(0.2))
model.add(keras.layers.core.Dense(10, activation='softmax'))
model.compile(
    loss=keras.losses.categorical_crossentropy,
    optimizer=keras.optimizers.RMSprop(),
    metrics=['accuracy']
)
model.load_weights('mnist_mlp.h5')


class GA:
    def __init__(self, aim):
        self.aim = aim
        self.pop_size = 80
        self.dna_size = 28 * 28
        self.max_iter = 500
        self.pc = 0.6
        self.pm = 0.008

    def perfit(self, per):
        y = model.predict(np.array([per]), verbose=0)
        return y[0][self.aim]

    def getfit(self, pop):
        fit = np.zeros(self.pop_size)
        for i, per in enumerate(pop):
            fit[i] = self.perfit(per)
        return fit

    def genpop(self):
        return np.random.choice(np.array([0, 1]), (self.pop_size, self.dna_size)).astype(np.bool)

    def select(self, pop, fit):
        fit = fit - np.min(fit)
        fit = fit + np.max(fit) / 2 + 0.01
        idx = np.random.choice(np.arange(self.pop_size), size=self.pop_size, replace=True, p=fit / fit.sum())
        return pop[idx]

    def optret(self, f):
        def mt(*args, **kwargs):
            opt = None
            opf = None
            for pop, fit in f(*args, **kwargs):
                max_idx = np.argmax(fit)
                min_idx = np.argmax(fit)
                if opf is None or fit[max_idx] >= opf:
                    opt = pop[max_idx]
                    opf = fit[max_idx]
                else:
                    pop[min_idx] = opt
                    fit[min_idx] = opf
                yield pop, fit
        return mt

    def crosso(self, pop):
        for i in range(0, self.pop_size, 2):
            if np.random.random() < self.pc:
                a = pop[i]
                b = pop[i + 1]
                p = np.random.randint(1, self.dna_size)
                a[p:], b[p:] = b[p:], a[p:]
                pop[i] = a
                pop[i + 1] = b
        return pop

    def mutate(self, pop):
        mut = np.random.choice(np.array([0, 1]), pop.shape, p=[1 - self.pm, self.pm])
        pop = np.where(mut == 1, 1 - pop, pop)
        return pop

    def evolve(self):
        pop = self.genpop()
        pop_fit = self.getfit(pop)
        for _ in range(self.max_iter):
            chd = self.select(pop, pop_fit)
            chd = self.crosso(chd)
            chd = self.mutate(chd)
            chd_fit = self.getfit(chd)
            yield chd, chd_fit
            pop = chd
            pop_fit = chd_fit


save_dir = 'mnist_ga_fooled'

for n in range(10):
    ga = GA(n)
    for i, (pop, fit) in enumerate(ga.optret(ga.evolve)()):
        j = np.argmax(fit)
        per = pop[j]
        per_fit = fit[j]
        print(f'{n} {per_fit}')
        if per_fit > 0.99:
            skimage.io.imsave(os.path.join(save_dir, f'{n}.bmp'), per.reshape((28, 28)) * 255)
            break

在目录 mnist_ga_fooled 下保存了最终生成的数字 0-9 的图片, 每张图片在对应分类器下都有 99% 以上的概率. 观察这些图片, 会发现它们只是一些无意义的噪点.