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

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

Ниже приведен неполный фрагмент кода (в assignment.py), который мы будем использовать в качестве базового кода, и мы подробно рассмотрим реализацию каждой функции.



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





В центре внимания этого блога скорее будет пакетный ввод без использования циклов for (то есть векторизованных вычислений). Пойдем…

Свести слой

class FlattenLayer:
    '''
    This class converts a multi-dimensional into 1-d vector
    '''
    def __init__(self, input_shape):
        self.input_shape = input_shape

    def forward(self, input):
        ## TODO
        return input
        ## END TODO
        
    
    def backward(self, output_error):
        ## TODO
        return output_error
        ## END TODO

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

def forward(self, input):
    input = input.reshape(self.input_shape[0], -1) # -1 would flatten the remaining dimensions
    return input

Для назад ошибку нужно просто изменить до исходной формы (input_shape). Следовательно,

def backward(self, output_error):
    ## TODO
    return output_error.reshape(self.input_shape)
    ## END TODO

Ладно, переходим в следующий класс…

class FCLayer:
    '''
    Implements a fully connected layer  
    '''
    def __init__(self, input_size, output_size):
        self.input_size = input_size
        self.output_size = output_size
        self.weights = np.random.randn(input_size, output_size)
        self.bias = np.random.randn(1, output_size)

    def forward(self, input):
        ## TODO
        return None
        ## END TODO

    def backward(self, output_error, learning_rate):
        ## TODO
        return None
        ## END TODO

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

def forward(self, input):
    self.X = input.copy()
    return np.matmul(input, self.weights) + self.bias

назад отличается от FlattenLayer. Здесь нам нужно обновить веса, а затем распространить (вернуть) градиенты обратно.

    def backward(self, output_error, learning_rate):
        ret_error = [email protected]
        self.weights = self.weights - learning_rate*(self.X.T@output_error)
        self.bias = self.bias - learning_rate*output_error
        return ret_error

Чтобы понять это, нам нужно знать некоторые матричные вычисления. Но есть некоторые приемы, которые можно использовать для легкого получения производных матричных произведений. Пусть Y = X*W. Формы трех матриц: Y : b*c ; Х: б*а; В : а*к

Рассмотрим эти 2 важных результата (или правила большого пальца):

  1. Производная скаляра по w.r.t. матрица M является матрицей той же размерности, что и M.
  2. Производная в случае линейных преобразований, таких как умножение матриц в приведенном выше случае, может быть найдена путем сопоставления форм матриц (см. пример ниже, чтобы понять это).

Используя эти два результата, мы знаем, что output_error имеет вид b*c. Нам нужна производная w.r.t. W для обновления W. Пытаясь сопоставить формы, нам нужно a*c с использованием X и output_error. Это можно получить путем транспонирования X и последующего умножения на output_error. Далее нам нужно найти производную относительно X и распространите его назад (здесь просто верните его). Опять же, используя анализ, аналогичный приведенному выше, мы получаем ret_error как output_error, пост-умноженное на транспонирование W.

С этого момента я пропускаю коды #TODO и сразу перехожу к полным фрагментам кода.

Слой активации

class ActivationLayer:
    '''
    Implements a Activation layer which applies activation function on the inputs. 
    '''
    def __init__(self, activation, activation_prime):
        self.activation = activation
        self.activation_prime = activation_prime
    
    def forward(self, input):
        self.X = input.copy()
        return self.activation(input)

    def backward(self, output_error, learning_rate):
        
        return output_error*self.activation_prime(self.X)

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

Сигмоид

def sigmoid(x):
    x = np.clip(x, a_min=-100, a_max=100) # Clipping for numerical stability
    return 1/(1+np.exp(-x))

def sigmoid_prime(x):
    x = sigmoid(x)*(1-sigmoid(x)))
    return x

Тан

def tanh(x):
    return np.tanh(x)

def tanh_prime(x):
    x = 1-np.tanh(x)**2
    return x

РеЛУ

def relu(x):
    return 0.5*(np.abs(x) + x)

def relu_prime(x):
    return x > 0

Обратите внимание, что оператор «*» предназначен для поэлементного умножения между матрицами numpy. Класс ActivationLayer использует поэлементное умножение (также называемое произведением Адамара) в обратном, а не матричное умножение.

Софтмакс

class SoftmaxLayer:
    '''
      Implements a Softmax layer which applies softmax function on the inputs. 
    '''
    def __init__(self, input_size):
        self.input_size = input_size
    
    def forward(self, input):
        input = np.clip(input, a_min=-100, a_max=100)
        self.softm = np.divide(np.exp(input), np.sum(np.exp(input),axis=1)[:, None])
        return self.softm.copy()

    def backward(self, output_error):
        # diagflat for batched input (see references)
        tmp = self.softm.copy()
        N = tmp.shape[1]
        tmp= np.expand_dims(tmp, axis=1)
        tmp_diag = tmp*np.eye(N)  # dims = (batch, N, N)
        
        # 2nd term that needs to se subtracted from first
        tmp = self.softm.copy()
        term2 = np.matmul(tmp.expand_dims(axis=2), tmp.expand_dims(axis=1)) # (b, N, 1)X(b, 1, N) = (b, N, N)
        
        grad = tmp_diag - term2 # (b, N, N)
        return np.matmul(grad, output_error.expand_dims(axis=2)).squeeze()

Здесь функция forward реализует операцию softmax для каждой строки (помните, что у нас есть пакетные входные данные).

назад требует хорошего объяснения. Производную от softmax можно найти здесь.

Теперь мы хотим реализовать это для пакетного ввода без использования циклов for. Если бы мы хотели сделать это для одной точки данных (непакетной), сработало бы следующее:

def backward(self, output_error, learning_rate):
        tmp = self.softm.copy()
        grad = np.diagflat(tmp) - tmp.T@tmp
        return (output_error@grad)

градация выше предназначена для получения следующей матрицы (без учета чисел):

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

Кросс-энтропийная потеря

def cross_entropy(y_true, y_pred):
    # y_true is a 1D array containing the gt classes
    # y_pred is of shape batchXnum_classes, each row containing probability of respective classes
    preds = y_pred[range(len(y_pred)), y_true] # to fetch the predicted probability of gt class from each row
    return -np.mean(np.log(preds)) # NLL followed by average

def cross_entropy_prime(y_true, y_pred):
    tmp = np.zeros_like(y_pred)
    tmp[:, y_true] = -1/y_pred[:, y_true] # see references to find derivative of cross-entropy loss
    return tmp

На этом мы завершили реализацию всех функций и классов. Еще остаются две важные вещи: создание архитектуры с использованием этих функций и прямое и обратное распространение с использованием определенной модели. Оба они могут быть найдены в функции fit в скрипте.

Определение архитектуры

# defining the architecture of model
network = [
    FlattenLayer(input_shape=(1, 28, 28)), #flattening the input image
    FCLayer(28 * 28, 12),
    ActivationLayer(sigmoid, sigmoid_prime),
    FCLayer(12, 10),
    SoftmaxLayer(10)
] # This creates feed forward

Здесь размер пакета выбран равным 1 (может быть изменен после группирования входных точек данных).

Прямое распространение

# forward propagation
output = x # x is input data   
for layer in network:
      output = layer.forward(output)
error += cross_entropy(y_true, output) # not used for training(only for getting the loss)

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

output_error = cross_entropy_prime(y_true, output)
for layer in reversed(network):
    output_error = layer.backward(output_error, learning_rate)

На этом мы завершили все части скрипта. К сожалению, в настоящее время скрипт не выполняет пакетную обработку входных данных, поэтому наши реализованные функции не будут работать и могут вызывать ошибки. После объединения входных данных можно запустить и обучить модель классификации для набора данных «mnist» и «flower».

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

Рекомендации

  1. Пакетная диаграмма в numpy: https://stackoverflow.com/questions/53741481/vectorized-creation-of-an-array-of-diagonal-square-arrays-from-a-liner-array-in
  2. Производная Softmax: https://eli.thegreenplace.net/2016/the-softmax-function-and-its-derivative/
  3. Кросс-энтропия и производная softmax: https://towardsdatascience.com/derivative-of-the-softmax-function-and-the-categorical-cross-entropy-loss-ffceefc081d1
  4. Стэнфордские слайды для обратного распространения ошибки и градиентов: http://cs231n.stanford.edu/slides/2018/cs231n_2018_ds02.pdf