Часть 1. Использование однослойного сверточного фильтра и одного плотного слоя в задаче классификации изображений Fashion MNIST с двумя классами.

Существует множество мощных инструментов, таких как Keras и Tensorflow, для создания сверточных нейронных сетей (CNN). Однако, пока я не открыл капот и не заглянул внутрь, я на самом деле не удовлетворен тем, что знаю что-то. Если вы похожи на меня, читайте дальше, чтобы узнать, как создавать CNN с нуля, используя Numpy (и Scipy).

Код этого поста доступен в моем репозитории.

Я делаю этот пост состоящим из нескольких частей. На данный момент я запланировал две части. В первом я буду решать только задачу классификации двух классов в Fashion MNIST, ограниченную двумя классами. Изображения монохроматические, и CNN будет иметь только один сверточный фильтр, за которым следует плотный слой. Эта простота позволит нам сосредоточиться на деталях свертки, не беспокоясь о случайной сложности. Во втором посте я расскажу о полном наборе данных Fashion MNIST (10 классов) и нескольких сверточных фильтрах. Я также рассмотрю набор данных CIFAR-10, в котором есть цветные изображения в 10 классах. Я мог бы сделать третий пост, в котором я расскажу о некоторых других аспектах свертки.

Это собирается на техническую должность, и предполагается определенное знакомство с алгеброй и исчислением. Хотя я знаю, что с tensorflow можно многое сделать, не зная продвинутой математики, я не понимаю, как можно заглянуть под капот без того же самого. Алгебра важна при прямом проходе, а алгебра и исчисление полезны для вычисления производной функции потерь по весам сети, которую я вывожу в закрытой форме. Они используются для обновления весов, что обычно называют обратным распространением. Читатели, не желающие проходить через вывод, но способные следовать окончательным выражениям, все равно могут извлечь выгоду, поняв окончательные результаты и реализуя (или следуя моей реализации) в numpy.

свертка

В этом разделе мы обсудим, что именно мы подразумеваем под сверткой в ​​​​обработке изображений и как она связана с реализацией в scipy. Это полезно, поскольку реализация scipy намного быстрее, чем наивная реализация numpy. В конце мы рассмотрим пример, в котором мы вычисляем свертки вручную и используем scipy в качестве проверки работоспособности. Для простоты мы будем работать с одним измерением в этом разделе, но аргументы распространяются просто на любое количество измерений.

Математически свертка функции f функцией g определяется как

Это определение полезно в физике и обработке сигналов. Например, в электромагнетизме g может быть плотностью заряда, а f (в ​​этом случае называемая функцией Грина) поможет нам вычислить потенциал из-за указанного распределения заряда. (Подробности см. в книгах по электромагнетизму, таких как Джексон, или по физике элементарных частиц, таких как Пескин и Шредер).

При обработке изображений это определение немного отстает в том смысле, что для окна свертки g длины 𝐾, мы хотели бы, чтобы свертка в 𝑥 была средневзвешенным значением 𝑓 так, что значение в 𝑥-𝐾/2+𝑦 взвешено по 𝑔 (𝑦). Таким образом, для целей обработки изображений правильным определением является

Обратите внимание, что для этого потребуется значение f(x) для отрицательных значений x, и если это указано как есть, то numpy будет интерпретировать отрицательные числа как индексацию с конца массива. Таким образом, при реализации этого в numpy нам нужно убедиться, что исходный массив встроен в больший массив с нулевым дополнением, а отрицательные индексы понимаются правильно.

В нашей реализации CNN мы будем использовать scipy.convolve, так как это будет быстрее, чем наивная реализация в numpy. Поэтому полезно понять, как scipy.convolve связан с тем, что мы хотим. Свертка Scipy предназначена для обработки сигналов, поэтому она напоминает обычное определение физики, но из-за соглашения numpy о начале местоположения массива с 0 центр окна g находится не в 0, а в K/2. Итак, scipy.convolve использует определение

Теперь, если мы перевернем окно свертки scipy, у нас будет y -> K-y, и это сделает интеграл

Таким образом, мы получим желаемый результат, передав перевернутый массив окна свертки в scipy.convolve.

В качестве примера рассмотрим сигнал и фильтр, приведенные ниже.

Затем мы реализуем свертку вручную и используем scipy в следующей команде.

convolve(sig,win[::-1],'same')

Результаты представлены ниже. Читателю рекомендуется убедиться в том, что результаты одинаковы, либо взглянув на графики, либо самостоятельно применив два метода.

Теперь мы готовы реализовать CNN с помощью numpy.

Двухклассная классификация

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

Для данных мы будем использовать Fashion MNIST. Мы получаем это от Keras, так как он легко доступен, но обратите внимание, что это единственное использование Keras в этом посте.

(X_train_full, y_train_full), (X_test, y_test) = keras.datasets.fashion_mnist.load_data()

Затем мы ограничиваем наш набор данных только категорией 1 и 3.

cond=np.any([y_train_full==1,y_train_full==3],0)
X_train_full=X_train_full[cond]
y_train_full=y_train_full[cond]
X_test=X_test[np.any([y_test==1,y_test==3],0)]
y_test=y_test[np.any([y_test==1,y_test==3],0)]
y_train_full=(y_train_full==3).astype(int)
y_test=(y_test==3).astype(int)

Затем мы разделяем наш обучающий набор на обучающий и проверочный наборы.

X_train, X_valid = X_train_full[:-1000], X_train_full[-1000:]
y_train, y_valid = y_train_full[:-1000], y_train_full[-1000:]

И, наконец, мы стандартизируем данные, поскольку нейронные сети лучше всего работают с данными с нулевым средним значением и единичным стандартным отклонением.

X_mean = X_train.mean(axis=0, keepdims=True)
X_std = X_train.std(axis=0, keepdims=True) + 1e-7
X_train = (X_train - X_mean) / X_std
X_valid = (X_valid - X_mean) / X_std
X_test = (X_test - X_mean) / X_std

Наши данные представляют собой монохромные изображения, представленные в виде матриц размером L x L (здесь L=28). Для простоты у нас есть только один слой свертки (мы ослабим это ограничение в следующих постах), который представляет собой матрицу размера K x K (в наших примерах мы возьмем K = 3), веса которой можно узнать. Кроме того, у нас есть плотный слой, который представляет собой матрицу размера (L * L) x 1. Обратите внимание, что мы не сохранили условия смещения, и читатель может включить их в качестве упражнения, как только он поймет этот пример.

Теперь мы подробно опишем прямой проход, ошибку и обратное распространение.

Проход вперед

  • Получаем изображения и относимся к ним как к 0-му слою 𝑙0
  • Мы встраиваем изображение по центру одного размера (𝐿+𝐾,𝐿+𝐾) с нулевым отступом

  • Затем мы пропускаем его через сверточный слой и функцию активации f1 (которую мы примем за Relu). Это первый слой l1.

  • Наконец, мы создаем плотный слой, обернутый функцией f2 (которую в данном случае мы принимаем за сигмовидную функцию). Это второй слой l2.

Обратите внимание, что несмотря на то, что веса W2 относятся к плотному слою, мы записали здесь индексы без выравнивания, поскольку это имеет интуитивно понятную интерпретацию, поскольку он берет каждый пиксель свернутого изображения и составляет взвешенную сумму. Тем не менее, сглаживание обоих и создание пустой точки более эффективно, и это то, что мы делаем в коде ниже.

Функция потерь

Для функции потерь мы берем обычный логарифм потерь

где у - истинный результат.

Обратное распространение

Обратное распространение — это просто причудливое слово для того, чтобы сказать, что все обучаемые веса корректируются градиентом функции потерь по отношению к весам, которые изучаются. Несложно дифференцировать функцию потерь по отношению к W1 и W2, используя цепное правило.

  • Производная функции потерь по слою 2 равна

  • Производная функции потерь по весам плотных слоев равна

где мы использовали тот факт, что l2 является результатом сигмовидной функции s(x) и что s'(x)=s(x)(1-s(x)).

  • Точно так же производная функции потерь по отношению к слою 1 равна

  • Производная функции потерь по сверточному фильтру равна

Теперь у нас есть выражения в закрытой форме для производной функции потерь по весам фильтра свертки, а также итоговая плотная матрица, поэтому мы можем обновить веса (с гиперпараметром скорости обучения) как

Вот и все.

Это все, что требуется для написания реализации CNN. Прежде чем сделать это, мы сначала отмечаем функцию потерь и точность для начальных случайных весов, чтобы иметь ориентир.

Потери и точность перед тренировкой

До начала обучения потеря в среднем может быть получена аналитически из выражения для потери и равна 1. Точность 0,5. Мы запустим наш код в течение пяти эпох и увидим потери и точность на тестовом и проверочном наборе.

Код

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

def relu(x):
    return np.where(x>0,x,0)
    
def relu_prime(x):
    return np.where(x>0,1,0)
def sigmoid(x):
    return 1./(1.+np.exp(-x))
def forward_pass(W1,W2,X,y):
    l0=X
    l0_conv=convolve(l0,W1[::-1,::-1],'same','direct')
    l1=relu(l0_conv)
    l2=sigmoid(np.dot(l1.reshape(-1,),W2))
    l2=l2.clip(10**-16,1-10**-16)
    loss=-(y*np.log(l2)+(1-y)*np.log(1-l2))
    accuracy=int(y==np.where(l2>0.5,1,0))
    return accuracy,loss

Теперь пишем основную часть кода

# learning rate
eta=.001
for epoch in range(5):
    # custom code to keep track of quantities to 
    # keep a running average. it is not shown for clarity. 
    # the reader can implement her own or ask me in the comments.
    train_loss, train accuracy=averager(), averager()
    
    for i in range(len(y_train)):
        
        # Take a random sample from train set
        k=np.random.randint(len(y_train))
        X=X_train[k]
        y=y_train[k]

        ##### FORWARD PASS ######
        # First layer is just the input
        l0=X
        
        # Embed the image in a bigger image. 
        # It would be useful in computing corrections 
        # to the convolution filter
        lt0=np.zeros((l0.shape[0]+K-1,l0.shape[1]+K-1))
        lt0[K//2:-K//2+1,K//2:-K//2+1]=l0
        
        # convolve with the filter
        # Layer one is Relu applied on the convolution        
        l0_conv=convolve(l0,W1[::-1,::-1],'same','direct')
        l1=relu(l0_conv)
        # Compute layer 2
        l2=sigmoid(np.dot(l1.reshape(-1,),W2))
        l2=l2.clip(10**-16,1-10**-16)
        
        ####### LOSS AND ACCURACY #######
        loss=-(y*np.log(l2)+(1-y)*np.log(1-l2))
        accuracy=int(y==np.where(l2>0.5,1,0))
        
        # Save the loss and accuracy to a running averager
        train_loss.send(loss)
        train_accuracy.send(accuracy)
        ##### BACKPROPAGATION #######
        
        # Derivative of loss wrt the dense layer
        dW2=(((1-y)*l2-y*(1-l2))*l1).reshape(-1,)
        
        # Derivative of loss wrt the output of the first layer
        dl1=(((1-y)*l2-y*(1-l2))*W2).reshape(28,28)
        
        # Derivative of the loss wrt the convolution filter
        f1p=relu_prime(l0_conv)
        dl1_f1p=dl1*f1p
        dW1=np.array([[
           (lt0[alpha:+alpha+image_size,beta:beta+image_size]\
           *dl1_f1p).sum() for beta in range(K)
        ]for alpha in range(K)])
        W2+=-eta*dW2
        W1+=-eta*dW1
    loss_averager_valid=averager()
    accuracy_averager_valid=averager()   
    
    for X,y in zip(X_valid,y_valid):
        accuracy,loss=forward_pass(W1,W2,X,y)
        loss_averager_valid.send(loss)
        accuracy_averager_valid.send(accuracy)
    
    train_loss,train_accuracy,valid_loss,valid_accuracy\
            =map(extract_averager_value,[train_loss,train_accuracy,
                 loss_averager_valid,accuracy_averager_valid])
    
    # code to print losses and accuracies suppressed for clarity

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

Мы видим, что даже эта простая модель снижает потери примерно с 1 до 0,1 и повышает точность с 0,5 до примерно 0,96 (на проверочном наборе).

Мы можем визуализировать свертку, построив несколько (стандартизированных) изображений и их свертку с исходным случайным фильтром и окончательным обученным фильтром.

Мы видим, что сверточный фильтр, похоже, научился в некоторой степени идентифицировать правые ребра. Должно быть лучше, когда мы обучаем несколько фильтров, но мы сохранили простоту, имея только один фильтр. (Сам этот выбор является гиперпараметром). Мы можем сделать еще одну вещь, прежде чем закончить часть 1 этого поста. Мы можем сделать еще две модели: 1) заморозить веса сверточных слоев и 2) заморозить веса плотных слоев. Это помогло бы нам понять, вносят ли эти слои вклад в процесс. Это очень легко. Для первого комментируем строку обновления для W1.

W2+=-eta*dW2
# W1+=-eta*dW1

а для последнего закомментируем строку обновления для W2

# W2+=-eta*dW2
W1+=-eta*dW1

В результате потери и точность

и

соответственно. Понятно, что большую часть работы выполняет плотный слой, но мы все же можем получить потери 0,5 и точность 0,8 только для слоя свертки. Предположительно, эта производительность будет увеличиваться с увеличением количества фильтров. Мы рассмотрим это в следующих постах.