Обучение нейронной сети без обратного распространения с использованием стратегии эволюции

В этом посте мы научимся обучать нейронную сеть без обратного распространения, используя стратегии эволюции (ES) в Python с нуля на наборе данных MNIST Handwritten Digit. Эта простая реализация поможет нам лучше понять концепцию и применить ее к другим подходящим параметрам. Давайте начнем!

Оглавление
1. Численная оптимизация
2. Стратегии развития
3. Обычная реализация
4. Реализация Python с нуля
5. Конечная заметка

Численная оптимизация

Практически любой алгоритм машинного обучения можно представить как проблему оптимизации. В алгоритме машинного обучения мы обновляем параметры модели, чтобы минимизировать потери. Например, каждый алгоритм контролируемого обучения может быть записан как θ_estimate = argmin 𝔼 [L (y, f (x, θ))], где x и y представляют функции и цель соответственно, θ представляет параметры модели, f представляет функцию, которую мы пытаемся смоделировать, а L представляет функцию потерь, которая измеряет, насколько хорошо наше соответствие. Алгоритм градиентного спуска, также известный как наискорейший спуск, доказал, что в большинстве случаев хорошо решает такие проблемы. Это итерационный алгоритм первого порядка для поиска локального минимума дифференцируемой функции. Мы предпринимаем шаги, пропорциональные отрицательному градиенту функции потерь в текущей точке, то есть θ_new = θ_old - α * ∇ L (y, f (x, θ_old)). Метод Ньютона - это еще один итерационный метод второго порядка, который сходится за меньшее количество итераций, но требует больших вычислительных ресурсов, поскольку необходимо вычислять обратную производную второго порядка функции потерь (матрицу Гессе), то есть θ_new = θ_old - [∇² L (y , f (x, θ_old))] ^ (- 1) * ∇ L (y, f (x, θ_old)). Мы ищем параметр, используя градиенты, так как считаем, что он приведет нас в том направлении, в котором потери будут уменьшены. Но можно ли искать оптимальные параметры без расчета градиентов? Собственно, есть много способов решить эту проблему! Существует множество различных алгоритмов оптимизации без производных (также известных как оптимизация черного ящика).

Стратегии развития

Градиентный спуск не всегда может решить наши проблемы. Почему? Короче говоря, ответ - местный оптимум. Например, в случае редких сценариев вознаграждения в обучении с подкреплением, когда агент получает вознаграждение в конце эпизода, как в шахматах с конечным вознаграждением в виде +1 или -1 за победу или поражение в игре соответственно. Если мы проиграем игру, мы не узнаем, сыграли ли мы ужасно неправильно или просто допустили небольшую ошибку. Сигнал градиента вознаграждения в значительной степени неинформативен и может заставить нас застрять. Вместо того, чтобы использовать шумные градиенты для обновления наших параметров, мы можем прибегнуть к методам без производных, таким как Evolution Strategies (ES). ES хорошо работает в таких случаях, а также там, где мы не знаем точной аналитической формы целевой функции или не можем вычислить градиенты напрямую.

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

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

В ES нас не особо заботит функция и ее связь с входными данными или параметрами. Несколько миллионов чисел (параметров модели) входят в алгоритм, и он выдает 1 значение (например, потеря в контролируемой настройке; вознаграждение в случае обучения с подкреплением). Мы пытаемся найти лучший набор таких чисел, который дает хорошие значения для нашей задачи оптимизации. Мы оптимизируем функцию J (θ) по параметрам θ, просто оценивая ее без каких-либо предположений о структуре J, отсюда и название «оптимизация черного ящика». Давайте углубимся в детали реализации!

Ванильная реализация

Для начала мы случайным образом генерируем параметры и настраиваем их так, чтобы параметры работали немного лучше. Математически на каждом этапе мы берем вектор параметров θ и генерируем совокупность, скажем, из 100 немного разных векторов параметров θ₁, θ₂… θ₁₀₀ путем дрожания θ с гауссовым шумом. Затем мы оцениваем каждого из 100 кандидатов независимо, запуская модель и на основе выходного значения оцениваем потери или целевую функцию. Затем мы выбираем N самых эффективных элитных параметров, N может быть равно 10, и берем среднее значение этих параметров и называем его нашим лучшим параметром на данный момент. Затем мы повторяем описанный выше процесс, снова генерируя 100 различных параметров, добавляя гауссовский шум к нашему лучшему параметру, полученному на данный момент.

Рассуждая в терминах естественного отбора, мы случайным образом создаем популяцию параметров (видов) и выбираем наиболее эффективные параметры на основе нашей целевой функции (также известной как функция приспособленности). Затем мы объединяем лучшие качества этих параметров, беря их среднее значение (это грубый способ, но он все еще работает!), И называем его нашим лучшим параметром. Затем мы воссоздаем популяцию, изменяя этот параметр, добавляя случайный шум, и повторяем весь процесс до сходимости.

Псевдокод:

  1. Произвольно инициализировать лучший параметр, используя распределение Гаусса
  2. Цикл до сходимости:
    - Создайте совокупность параметров θ₁, θ₂… θ₁₀₀, добавив гауссовский шум к лучшему параметру
    - Оцените целевую функцию для всех параметров и выберите N самых эффективных параметров (элитные параметры )
    - Лучший параметр = Среднее (N элитных параметров)
    - Снижение шума в конце каждой итерации на некоторый коэффициент (В начале больше шума поможет нам лучше исследовать, но когда мы достигнем точка схождения, мы хотим, чтобы шум был минимальным, чтобы не отклоняться)

Реализация Python с нуля

Давайте рассмотрим простой пример на Python, чтобы лучше понять. Я попытался добавить детали, связанные с числовой стабильностью, по некоторым вопросам. Пожалуйста, прочтите комментарии! Мы начнем с загрузки необходимых библиотек и набора данных рукописных цифр MNIST.

# Importing all the required libraries
import numpy as np
import matplotlib.pyplot as plt
import tqdm
import pickle
import warnings
warnings.filterwarnings(‘ignore’)
from keras.datasets import mnist
# Machine Epsilon (needed to calculate logarithms)
eps = np.finfo(np.float64).eps
# Loading MNIST dataset
(x_train, y_train), (x_test, y_test) = mnist.load_data()
# x contains the images (features to our model)
# y contains the labels 0 to 9
# Normalizing the inputs between 0 and 1
x_train = x_train/255.
x_test = x_test/255.
# Flattening the image as we are using 
# dense neural networks
x_train = x_train.reshape( -1, x_train.shape[1]*x_train.shape[2])
x_test = x_test.reshape( -1, x_test.shape[1]*x_test.shape[2])
# Converting to one-hot representation
identity_matrix = np.eye(10) 
y_train = identity_matrix[y_train]
y_test = identity_matrix[y_test]
# Plotting the images
fig, ax = plt.subplots(2,5)
for i, ax in enumerate(ax.flatten()):
 im_idx = np.argwhere(y_train == i)[0]
 plottable_image = np.reshape(x_train[im_idx], (28, 28))
 ax.set_axis_off()
 ax.imshow(plottable_image, cmap=’gray’)
 
plt.savefig(‘mnist.jpg’)

Вот как выглядят изображения,

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

def soft_max(x):
'''
 Arguments: numpy array
 
 Returns: numpy array after applying 
 softmax function to each
 element
 '''
 
 # Subtracting max of x from each element of x for numerical
 # stability as this results in the largest argument to 
 # exp being 0, ruling out the possibility of overflow
 # Read more about it at :
 # https://www.deeplearningbook.org/contents/numerical.html
 
 e_x = np.exp(x — np.max(x))
 
 return e_x /e_x.sum()
class Model():
'''
 Single layer Neural Network
 
'''
 
 def __init__(self, input_shape, n_classes):
 
 # Number of output classes
 self.n_classes = n_classes
 
 # Parameters/Weights of our network which we will be updating
 self.weights = np.random.randn(input_shape, n_classes)
 
 def forward(self,x):
 
 '''
 Arguments: numpy array containing the features,
 expected shape of input array is
 (batch size, number of features)
 
 
 Returns: numpy array containing the probability,
 expected shape of output array is
 (batch size, number of classes)
 
 '''
 
 # Multiplying weights with inputs
 x = np.dot(x,self.weights)
 
 # Applying softmax function on each row
 x = np.apply_along_axis(soft_max, 1, x)
 
 return x
 
 
 def __call__(self,x):
 
 '''
 This dunder function
 enables your model to be callable
 
 
 When the model is called using model(x),
 forward method of the model is called internally
 
 '''
 
 return self.forward(x)
 
 
 def evaluate(self, x, y, weights = None):
 
 '''
Arguments : x — numpy array of shape (batch size,number of features),
 y — numpy array of shape (batch size,number of classes),
 weights — numpy array containing the parameters of the model
 
 
 Returns : Scalar containing the mean of the categorical cross-entropy loss
 of the batch
'''
 
 if weights is not None:
 
 self.weights = weights
 
 
 # Calculating the negative of cross-entropy loss (since
 # we are maximizing this score)
 # Adding a small value called epsilon 
 # to prevent -inf in the output
 
 log_predicted_y = np.log(self.forward(x) + eps)
 
 return (log_predicted_y*y).mean()

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

def optimize(model,x,y,
 top_n = 5, n_pop = 20, n_iter = 10,
 sigma_error = 1, error_weight = 1, decay_rate = 0.95,
 min_error_weight = 0.01 ):
 
 '''
 Arguments : model — Model object(single layer neural network here),
 x — numpy array of shape (batch size, number of features),
 y — numpy array of shape (batch size, number of classes),
 top_n — Number of elite parameters to consider for calculating the
 best parameter by taking mean
 n_pop — Population size of the parameters
 n_iter — Number of iteration 
 sigma_error — The standard deviation of errors while creating 
 population from best parameter
 error_weight — Contribution of error for considering new population
 decay_rate — Rate at which the weight of the error will reduce after 
 each iteration, so that we don’t deviate away at the 
 point of convergence. It controls the balance between 
 exploration and exploitation
 
 
 
 Returns : Model object with updated parameters/weights
 
 '''
 
 # Model weights have been randomly initialized at first
 best_weights = model.weights
 
 for i in range(n_iter):
 
 # Generating the population of parameters
 pop_weights = [best_weights + error_weight*sigma_error* \
 np.random.randn(*model.weights.shape)
 
 for i in range(n_pop)]
 
 
 # Evaluating the population of parameters
 evaluation_values = [model.evaluate(x,y,weight) for weight in pop_weights]
 
 # Sorting based on evaluation score
 weight_eval_list = zip(evaluation_values, pop_weights)
 
 weight_eval_list = sorted(weight_eval_list, key = lambda x: x[0], reverse = True)
 
 evaluation_values, pop_weights = zip(*weight_eval_list)
 
 # Taking the mean of the elite parameters
 best_weights = np.stack(pop_weights[:top_n], axis=0).mean(axis=0)
 
 #Decaying the weight
 error_weight = max(error_weight*decay_rate, min_error_weight)
 
 model.weights = best_weights
 
 return model
# Instantiating our model object
model = Model(input_shape= x_train.shape[-1], n_classes= 10)
print(“Evaluation on training data”, model.evaluate(x_train, y_train))
# Running it for 200 steps
for i in tqdm.tqdm(range(200)):
 
 model = optimize(model, 
 x_train,
 y_train, 
 top_n = 10, 
 n_pop = 100,
 n_iter = 1)
 
 print(“Test data cross-entropy loss: “, -1*model.evaluate(x_test, y_test))
 print(“Test Accuracy: “,(np.argmax(model(x_test),axis=1) == y_test).mean())
 
# Saving the model for later use
with open(‘model.pickle’,’wb’) as f:
 pickle.dump(model,f)

Результаты: после обучения для 200 итераций точность теста составила ~ 85%, а потеря кросс-энтропии составила ~ 0,28. Это сравнимо с однослойной нейронной сетью, обученной обратным распространением. Обратите внимание, что здесь мы даже не использовали decay, так как n_iter был установлен в 1.

Конечная заметка

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

Надеюсь, вам понравилось читать этот пост!

Также для получения дополнительных технических блогов посетите мой сайт: Dig Deep ML

Ссылки и дополнительная литература:

Сообщение в блоге OpenAI

Блог Оторо

Блог Лилиан