Обучение нейронных сетей 2

Обучение нейронных сетей

Содержание: - Генерация некоторых данных - Обучение линейного классификатора Softmax - Инициализируйте параметры - Подсчитайте баллы за класс - Вычислите потери - Вычисление аналитического градиента с обратным распространением - Выполнение обновления параметров - Сведение всего этого воедино: обучение классификатора Softmax - Обучение нейронной сети - Краткая сводка - Дополнительные материалы

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

Генерация некоторых данных

Давайте создадим набор данных для классификации, который нелегко разделить на линейные классы. Наш любимый пример — набор данных «спираль», который можно создать следующим образом:

N = 100 # number of points per class
D = 2 # dimensionality
K = 3 # number of classes
X = np.zeros((N*K,D)) # data matrix (each row = single example)
y = np.zeros(N*K, dtype='uint8') # class labels
for j in range(K):
  ix = range(N*j,N*(j+1))
  r = np.linspace(0.0,1,N) # radius
  t = np.linspace(j*4,(j+1)*4,N) + np.random.randn(N)*0.2 # theta
  X[ix] = np.c_[r*np.sin(t), r*np.cos(t)]
  y[ix] = j
# lets visualize the data:
plt.scatter(X[:, 0], X[:, 1], c=y, s=40, cmap=plt.cm.Spectral)
plt.show()

Данные игрушечной спирали состоят из трёх классов (синий, красный, жёлтый), которые нельзя разделить линейно.


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

Обучение линейного классификатора Softmax

Инициализируйте параметры

Давайте сначала обучим классификатор Softmax на этом наборе данных для классификации. Как мы видели в предыдущих разделах, классификатор Softmax имеет линейную функцию оценки и использует функцию потерь кросс-энтропии. Параметры линейного классификатора состоят из весовой матрицы W и вектора смещения b для каждого класса. Давайте сначала инициализируем эти параметры случайными числами:

# initialize parameters randomly
W = 0.01 * np.random.randn(D,K)
b = np.zeros((1,K))

Напомним, что D = 2 — это размерность, а K = 3 — количество классов.

Подсчитайте баллы за класс

Поскольку это линейный классификатор, мы можем очень просто вычислить оценки для всех классов параллельно с помощью одного умножения матриц:

# compute class scores for a linear classifier
scores = np.dot(X, W) + b

В этом примере у нас есть 300 двумерных точек, поэтому после этого умножения массив scores будет иметь размер [300 x 3], где каждая строка содержит баллы за классы, соответствующие трём классам (синий, красный, жёлтый).

Вычислите потери

Второй ключевой компонент, который нам нужен, — это функция потерь, представляющая собой дифференцируемую целевую функцию, которая количественно оценивает наше недовольство вычисленными оценками классов. Интуитивно понятно, что мы хотим, чтобы правильный класс имел более высокую оценку, чем другие классы. В этом случае потери должны быть низкими, а в противном случае — высокими. Существует множество способов количественно оценить эту интуитивную догадку, но в этом примере мы будем использовать потери перекрёстной энтропии, которые связаны с классификатором Softmax. Напомним, что если f — это массив оценок классов для одного примера (например, массив из трёх чисел), тогда классификатор Softmax вычисляет потерю для этого примера следующим образом:

$$ L_i = -\log\left(\frac{e^{f_{y_i}}}{ \sum_j e^{f_j} }\right) $$

Мы можем видеть, что классификатор Softmax интерпретирует каждый элемент f. В качестве входных данных используются (ненормализованные) логарифмические вероятности трёх классов. Мы возводим их в степень, чтобы получить (ненормализованные) вероятности, а затем нормализуем их, чтобы получить вероятности. Таким образом, выражение внутри логарифма — это нормализованная вероятность правильного класса. Обратите внимание на то, как работает это выражение: эта величина всегда находится в диапазоне от 0 до 1. Когда вероятность правильного класса очень мала (близка к 0), потери будут стремиться к (положительной) бесконечности. И наоборот, когда вероятность правильного класса приближается к 1, потери приближаются к нулю, потому что log(1)=0. Следовательно, выражение для \(L_i\). Вероятность правильного класса низкая, когда она высока, и очень высокая, когда она низка.

Напомним также, что полная потеря классификатора Softmax определяется как средняя потеря кросс-энтропии по обучающим примерам и регуляризация:

$$ L = \underbrace{ \frac{1}{N} \sum_i L_i }\text{потеря данных} + \underbrace{ \frac{1}{2} \lambda \sum_k\sum_l W \\ $$ }^2 }_\text{потеря регуляризации

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

num_examples = X.shape[0]
# get unnormalized probabilities
exp_scores = np.exp(scores)
# normalize them for each example
probs = exp_scores / np.sum(exp_scores, axis=1, keepdims=True)

Теперь у нас есть массив probs размером [300 x 3], где каждая строка содержит вероятности классов. В частности, поскольку мы их нормализовали, сумма значений в каждой строке равна единице. Теперь мы можем запросить логарифмические вероятности, присвоенные правильным классам в каждом примере:

orrect_logprobs = -np.log(probs[range(num_examples),y])

Массив correct_logprobs — это одномерный массив, содержащий только вероятности, присвоенные правильным классам для каждого примера. Полная потеря — это среднее значение этих логарифмических вероятностей и потери от регуляризации:

# compute the loss: average cross-entropy loss and regularization
data_loss = np.sum(correct_logprobs)/num_examples
reg_loss = 0.5*reg*np.sum(W*W)
loss = data_loss + reg_loss

В этом коде сила регуляризации λ хранится внутри reg. Коэффициент удобства 0.5 умножения регуляризации станет ясен через секунду. Оценка этого вначале (со случайными параметрами) может дать нам loss = 1.1, что и есть -np.log(1.0/3), поскольку при небольших начальных случайных весах все вероятности, присвоенные всем классам, составляют около одной трети. Теперь мы хотим сделать потери как можно более низкими, используя loss = 0 в качестве абсолютной нижней границы. Но чем меньше потери, тем выше вероятности, присвоенные правильным классам для всех примеров.

Вычисление аналитического градиента с обратным распространением

У нас есть способ оценки потерь, и теперь нам нужно их минимизировать. Мы сделаем это с помощью градиентного спуска. То есть мы начнём со случайных параметров (как показано выше) и вычислим градиент функции потерь по отношению к параметрам, чтобы знать, как изменить параметры для уменьшения потерь. Давайте введём промежуточную переменную p, который представляет собой вектор (нормализованных) вероятностей. Потери для одного примера составляют:

$$ p_k = \frac{e^{f_k}}{ \sum_j e^{f_j} } \hspace{1in} L_i =-\log\left(p_{y_i}\right) $$

Теперь мы хотим понять, как вычисляются баллы внутри f следует изменить, чтобы уменьшить потери \(L_i\), что этот пример соответствует общей цели. Другими словами, мы хотим вычислить градиент \( \partial L_i / \partial f_k \). Потеря __\(L_i\)__вычисляется из p, что , в свою очередь , зависит от f. Читателю будет интересно использовать правило дифференцирования сложной функции для вычисления градиента, но в итоге всё оказывается очень простым и понятным, после того как многое сокращается:

$$ \frac{\partial L_i }{ \partial f_k } = p_k - \mathbb{1}(y_i = k) $$

Обратите внимание, насколько элегантно и просто выглядит это выражение. Предположим, что вычисленные нами вероятности были p = [0.2, 0.3, 0.5] и что правильным классом был средний (с вероятностью 0,3). Согласно этому выводу, градиент оценок будет равен df = [0.2, -0.7, 0.5]. Вспомнив, что означает интерпретация градиента, мы видим, что этот результат вполне интуитивен: увеличение первого или последнего элемента вектора оценок f (оценок неверных классов) приводит к увеличению потерь (из-за положительных значений +0,2 и +0,5) — а увеличение потерь плохо, как и ожидалось. Однако увеличение оценки правильного класса отрицательно влияет на потери. Градиент -0,7 говорит нам о том, что увеличение оценки правильного класса приведёт к уменьшению потерь \(L_i\), что имеет смысл.

Всё это сводится к следующему коду. Напомним, что probs хранит вероятности всех классов (в виде строк) для каждого примера. Чтобы получить градиент оценок, который мы называем dscores, мы поступаем следующим образом:

dscores = probs
dscores[range(num_examples),y] -= 1
dscores /= num_examples

Наконец, у нас есть scores = np.dot(X, W) + b и, вооружившись градиентом scores (хранящимся в dscores), мы можем выполнить обратное распространение ошибки в W и b:

dW = np.dot(X.T, dscores)
db = np.sum(dscores, axis=0, keepdims=True)
dW += reg*W # don't forget the regularization gradient

Здесь мы видим, что мы выполнили обратное преобразование с помощью операции умножения матриц, а также добавили вклад от регуляризации. Обратите внимание, что градиент регуляризации имеет очень простую форму reg*W, поскольку мы использовали константу 0.5 для вклада в потери (т. е. \(\frac{d}{dw} ( \frac{1}{2} \lambda w^2) = \lambda w\) ). Это распространенный удобный прием, который упрощает выражение градиента.

Выполнение обновления параметров

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

# perform a parameter update
W += -step_size * dW
b += -step_size * db

Сведение всего этого воедино: обучение классификатора Softmax

Если собрать всё это воедино, получится полный код для обучения классификатора Softmax с помощью градиентного спуска:

#Train a Linear Classifier

# initialize parameters randomly
W = 0.01 * np.random.randn(D,K)
b = np.zeros((1,K))

# some hyperparameters
step_size = 1e-0
reg = 1e-3 # regularization strength

# gradient descent loop
num_examples = X.shape[0]
for i in range(200):

  # evaluate class scores, [N x K]
  scores = np.dot(X, W) + b

  # compute the class probabilities
  exp_scores = np.exp(scores)
  probs = exp_scores / np.sum(exp_scores, axis=1, keepdims=True) # [N x K]

  # compute the loss: average cross-entropy loss and regularization
  correct_logprobs = -np.log(probs[range(num_examples),y])
  data_loss = np.sum(correct_logprobs)/num_examples
  reg_loss = 0.5*reg*np.sum(W*W)
  loss = data_loss + reg_loss
  if i % 10 == 0:
    print "iteration %d: loss %f" % (i, loss)

  # compute the gradient on scores
  dscores = probs
  dscores[range(num_examples),y] -= 1
  dscores /= num_examples

  # backpropate the gradient to the parameters (W,b)
  dW = np.dot(X.T, dscores)
  db = np.sum(dscores, axis=0, keepdims=True)

  dW += reg*W # regularization gradient

  # perform a parameter update
  W += -step_size * dW
  b += -step_size * db
  ```


 При выполнении этой операции выводятся выходные данные:  

 ```
 iteration 0: loss 1.096956
iteration 10: loss 0.917265
iteration 20: loss 0.851503
iteration 30: loss 0.822336
iteration 40: loss 0.807586
iteration 50: loss 0.799448
iteration 60: loss 0.794681
iteration 70: loss 0.791764
iteration 80: loss 0.789920
iteration 90: loss 0.788726
iteration 100: loss 0.787938
iteration 110: loss 0.787409
iteration 120: loss 0.787049
iteration 130: loss 0.786803
iteration 140: loss 0.786633
iteration 150: loss 0.786514
iteration 160: loss 0.786431
iteration 170: loss 0.786373
iteration 180: loss 0.786331
iteration 190: loss 0.786302

Мы видим, что после примерно 190 итераций мы приблизились к чему-то. Мы можем оценить точность обучающего набора данных:

# evaluate training set accuracy
scores = np.dot(X, W) + b
predicted_class = np.argmax(scores, axis=1)
print 'training accuracy: %.2f' % (np.mean(predicted_class == y))

Это выводит 49%. Не очень хорошо, но и неудивительно, учитывая, что набор данных составлен таким образом, что он не является линейно разделимым. Мы также можем построить границы принятых решений:



Линейный классификатор не может изучить набор данных toy spiral.


Обучение нейронной сети

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

# initialize parameters randomly
h = 100 # size of hidden layer
W = 0.01 * np.random.randn(D,h)
b = np.zeros((1,h))
W2 = 0.01 * np.random.randn(h,K)
b2 = np.zeros((1,K))

Прямой проход для подсчета очков теперь меняет форму:

# evaluate class scores with a 2-layer Neural Network
hidden_layer = np.maximum(0, np.dot(X, W) + b) # note, ReLU activation
scores = np.dot(hidden_layer, W2) + b2

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

Всё остальное остаётся прежним. Мы вычисляем потери на основе оценок точно так же, как и раньше, и получаем градиент для оценок dscores точно так же, как и раньше. Однако способ обратного распространения этого градиента на параметры модели, конечно, меняется. Сначала давайте выполним обратное распространение для второго слоя нейронной сети. Это выглядит так же, как и код для классификатора Softmax, за исключением того, что мы заменяем X (исходные данные) на переменную hidden_layer):

```# backpropate the gradient to the parameters

first backprop into parameters W2 and b2

dW2 = np.dot(hidden_layer.T, dscores) db2 = np.sum(dscores, axis=0, keepdims=True)

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

dhidden = np.dot(dscores, W2.T)

Теперь у нас есть градиент на выходе скрытого слоя. Далее нам нужно выполнить обратное распространение ошибки для нелинейности **ReLU**. Это оказывается простым, потому что **ReLU** при обратном распространении ошибки фактически является переключателем. Поскольку **r=max(0,x)**, у нас есть это **dr/dx=1(x>0)**. В сочетании с правилом дифференцирования по частям мы видим, что блок **ReLU** пропускает градиент без изменений, если его входные данные больше 0, но _отменяет_ его, если входные данные меньше нуля во время прямого прохода. Следовательно, мы можем выполнить обратное распространение ошибки для **ReLU** следующим образом:  

backprop the ReLU non-linearity

dhidden[hidden_layer <= 0] = 0

И теперь мы, наконец, переходим к первому слою весов и смещений:  

finally into W,b

dW = np.dot(X.T, dhidden) db = np.sum(dhidden, axis=0, keepdims=True)

**Готово!** У нас есть градиенты `dW,db,dW2,db2` и мы можем выполнить обновление параметров. Всё остальное остаётся без изменений. Полный код выглядит очень похоже:  

initialize parameters randomly

h = 100 # size of hidden layer W = 0.01 * np.random.randn(D,h) b = np.zeros((1,h)) W2 = 0.01 * np.random.randn(h,K) b2 = np.zeros((1,K))

some hyperparameters

step_size = 1e-0 reg = 1e-3 # regularization strength

gradient descent loop

num_examples = X.shape[0] for i in range(10000):

# evaluate class scores, [N x K] hidden_layer = np.maximum(0, np.dot(X, W) + b) # note, ReLU activation scores = np.dot(hidden_layer, W2) + b2

# compute the class probabilities exp_scores = np.exp(scores) probs = exp_scores / np.sum(exp_scores, axis=1, keepdims=True) # [N x K]

# compute the loss: average cross-entropy loss and regularization correct_logprobs = -np.log(probs[range(num_examples),y]) data_loss = np.sum(correct_logprobs)/num_examples reg_loss = 0.5regnp.sum(WW) + 0.5regnp.sum(W2W2) loss = data_loss + reg_loss if i % 1000 == 0: print "iteration %d: loss %f" % (i, loss)

# compute the gradient on scores dscores = probs dscores[range(num_examples),y] -= 1 dscores /= num_examples

# backpropate the gradient to the parameters # first backprop into parameters W2 and b2 dW2 = np.dot(hidden_layer.T, dscores) db2 = np.sum(dscores, axis=0, keepdims=True) # next backprop into hidden layer dhidden = np.dot(dscores, W2.T) # backprop the ReLU non-linearity dhidden[hidden_layer <= 0] = 0 # finally into W,b dW = np.dot(X.T, dhidden) db = np.sum(dhidden, axis=0, keepdims=True)

# add regularization gradient contribution dW2 += reg * W2 dW += reg * W

# perform a parameter update W += -step_size * dW b += -step_size * db W2 += -step_size * dW2 b2 += -step_size * db2 ```

Это печатает:

iteration 0: loss 1.098744
iteration 1000: loss 0.294946
iteration 2000: loss 0.259301
iteration 3000: loss 0.248310
iteration 4000: loss 0.246170
iteration 5000: loss 0.245649
iteration 6000: loss 0.245491
iteration 7000: loss 0.245400
iteration 8000: loss 0.245335
iteration 9000: loss 0.245292

Точность обучения теперь равна:

# evaluate training set accuracy
hidden_layer = np.maximum(0, np.dot(X, W) + b)
scores = np.dot(hidden_layer, W2) + b2
predicted_class = np.argmax(scores, axis=1)
print 'training accuracy: %.2f' % (np.mean(predicted_class == y))

Что выводит 98%!. Мы также можем визуализировать границы решений:



Классификатор нейронной сети сжимает набор данных spiral.


Краткие сведения

Мы работали с игрушечным 2D-набором данных и обучали как линейную сеть, так и двухслойную нейронную сеть. Мы увидели, что переход от линейного классификатора к нейронной сети требует очень мало изменений в коде. Функция оценки меняет свою форму (разница в 1 строке кода), а обратное распространение ошибки меняет свою форму (нам нужно выполнить ещё один цикл обратного распространения ошибки через скрытый слой к первому слою сети).

Дополнительные материалы