Як побудувати нейронну мережу з нуля

Нейронні мережі - це ніби робочі коні глибокого навчання. Маючи достатньо даних та обчислювальної потужності, їх можна використовувати для вирішення більшості проблем глибокого навчання. За допомогою бібліотеки Python або R дуже просто створити нейронну мережу та навчити її на будь-якому наборі даних і отримати велику точність.

Ми можемо розглядати нейронні мережі як просто чорну скриньку і використовувати їх без особливих труднощів. Але незважаючи на те, що йти таким шляхом дуже легко, набагато цікавіше дізнатися, що криється за цими алгоритмами та як вони працюють.

У цій статті ми розберемо деякі подробиці побудови нейронної мережі. Я збираюся використовувати Python для написання коду для мережі. Я також буду використовувати бібліотеку numpy Python для виконання числових обчислень. Я спробую уникнути деяких складних математичних деталей, але зрештою я звернусь до кількох чудових ресурсів, якщо ви хочете дізнатись більше про це.

Тож давайте почнемо.

Ідея

Перш ніж ми почнемо писати код для своєї нейронної мережі, давайте просто почекаємо і зрозуміємо, що саме таке нейронна мережа.

На зображенні вище ви можете побачити дуже випадкову схему нейронної мережі. У ньому є кілька кольорових кіл, з’єднаних між собою стрілками, що вказують на певний напрямок. Ці кольорові кола іноді називають нейронами .

Ці нейрони є не що інше, як математичні функції, які, коли їм надається певний вхід, генерують результат . Вихід з нейронів залежить від вхідних і параметрів цих нейронів . Ми можемо оновити ці параметри, щоб отримати бажане значення з мережі.

Кожен з цих нейронів визначається за допомогою сигмовидної функції . Сигмовидная функція дає вихід від нуля до однієї для кожного входу він отримує. Ці сигмоподібні одиниці з'єднані між собою, утворюючи нейронну мережу.

Під цим зв’язком ми маємо на увазі, що вихід одного шару сигмовидних одиниць подається як вхід для кожної сигмовидної одиниці наступного шару. Таким чином наша нейронна мережа виробляє вихід для будь-якого даного входу. Процес триває, поки ми не дійдемо до останнього шару. Кінцевий шар генерує свій вихід.

Цей процес нейронної мережі, що генерує вихід для даного входу, є прямим поширенням . Висновок кінцевого шару також називається передбаченням нейронної мережі. Далі в цій статті ми обговоримо, як ми оцінюємо прогнози . Ці оцінки можна використовувати, щоб визначити, чи потребує вдосконалення наша нейронна мережа чи ні.

Одразу після того, як кінцевий рівень генерує свою продукцію, ми обчислюємо функцію витрат . Функція витрат обчислює, наскільки наша нейромережа віддалена від бажаних прогнозів. Значення функції витрат показує різницю між передбачуваним значенням і значенням істини .

Наша мета - мінімізувати значення функції витрат . Процес мінімізації функції витрат вимагає алгоритму, який може оновлювати значення параметрів у мережі таким чином, що функція витрат досягає свого мінімального значення .

Для оновлення параметрів нейронної мережі використовуються такі алгоритми, як градієнтний спуск та стохастичний градієнтний спуск . Ці алгоритми оновлюють значення ваг та упереджень кожного рівня в мережі залежно від того, як це вплине на функцію мінімізації витрат. Вплив на мінімізацію функції витрат щодо кожного з вагових коефіцієнтів та зсувів кожного з вхідних нейронів у мережі обчислюється шляхом зворотного розповсюдження .

Код

Отже, тепер ми знаємо основні ідеї нейронних мереж. Почнемо впроваджувати ці ідеї в код. Ми почнемо з імпорту всіх необхідних бібліотек.

import numpy as np import matplotlib.pyplot as plt

Як я вже згадував, ми не збираємось користуватися жодною з бібліотек глибокого навчання. Отже, ми в основному будемо використовувати numpy для ефективного виконання математичних обчислень.

Першим кроком у побудові нашої нейронної мережі буде ініціалізація параметрів. Нам потрібно ініціалізувати два параметри для кожного з нейронів у кожному шарі: 1) вага та 2) зміщення .

Ці ваги та упередження заявляються у векторизованій формі. Це означає, що замість ініціалізації ваг та упереджень для кожного окремого нейрона в кожному окремому шарі ми створимо вектор (або матрицю) для ваг та ще один для упереджень для кожного шару.

Ці ваги та вектори зміщення будуть поєднані із входом у шар. Тоді ми застосуємо сигмоподібну функцію до цієї комбінації та надішлемо її як вхідні дані для наступного шару.

шар_димівмістить розміри кожного шару. Ми передамо ці розміри шарів у файл init_parmsфункція, яка використовуватиме їх для ініціалізації параметрів. Ці параметри зберігатимуться у словнику, який називається params . Тож у словнику params params ['W1']буде представляти матрицю ваги для шару 1.

def init_params(layer_dims): np.random.seed(3) params = {} L = len(layer_dims) for l in range(1, L): params['W'+str(l)] = np.random.randn(layer_dims[l], layer_dims[l-1])*0.01 params['b'+str(l)] = np.zeros((layer_dims[l], 1)) return params

Чудово! Ми ініціалізували ваги та упередження, а тепер визначимо сигмоподібну функцію . Він обчислить значення сигмовидної функції для будь-якого заданого значення Z, а також збереже це значення як кеш. Ми будемо зберігати значення кешу, оскільки вони потрібні для здійснення зворотного розповсюдження. Z тут є лінійної гіпотезою .

Зауважимо, що сигмоїдна функція підпадає під клас функцій активації в термінології нейронної мережі. Завдання функції активації полягає у формуванні виходу нейрона.

Наприклад, сигмоїдна функція приймає вхідні дані з дискретними значеннями і дає значення, яке лежить між нулем та одиницею. Його метою є перетворення лінійних виходів на нелінійні виходи. Існують різні типи функцій активації, які можна використовувати для кращої продуктивності, але для простоти ми дотримуватимемось сигмоїдів.

# Z (linear hypothesis) - Z = W*X + b , # W - weight matrix, b- bias vector, X- Input def sigmoid(Z): A = 1/(1+np.exp(np.dot(-1, Z))) cache = (Z) return A, cache

Тепер почнемо писати код для прямого розповсюдження. Ми вже обговорювали, що пряме розповсюдження прийме значення попереднього шару і надасть його як вхід для наступного шару. Функція, наведена нижче, буде приймати навчальні дані та параметри як вхідні дані та генеруватиме вихідні дані для одного рівня, а потім передаватиме ці результати до наступного рівня тощо.

def forward_prop(X, params): A = X # input to first layer i.e. training data caches = [] L = len(params)//2 for l in range(1, L+1): A_prev = A # Linear Hypothesis Z = np.dot(params['W'+str(l)], A_prev) + params['b'+str(l)] # Storing the linear cache linear_cache = (A_prev, params['W'+str(l)], params['b'+str(l)]) # Applying sigmoid on linear hypothesis A, activation_cache = sigmoid(Z) # storing the both linear and activation cache cache = (linear_cache, activation_cache) caches.append(cache) return A, caches

A_prev i s введення на перший шар. Ми пройдемося по всіх шарах мережі та обчислимо лінійну гіпотезу. Після цього він прийме значення Z (лінійна гіпотеза) і передасть його функції сигмоїдної активації. Значення кешу зберігаються по дорозі та накопичуються в кешах . Нарешті, функція поверне генероване значення та збережений кеш.

Давайте тепер визначимо нашу функцію витрат.

def cost_function(A, Y): m = Y.shape[1] cost = (-1/m)*(np.dot(np.log(A), Y.T) + np.dot(log(1-A), 1-Y.T)) return cost

Оскільки значення функції витрат зменшується, продуктивність нашої моделі стає кращою. Значення функції витрат можна мінімізувати, оновивши значення параметрів кожного з шарів нейронної мережі. Такі алгоритми, як Gradient Descent , використовуються для оновлення цих значень таким чином, що функція витрат зводиться до мінімуму.

Gradient Descent оновлює значення за допомогою деяких термінів оновлення. Ці умови оновлення, звані градієнтами , обчислюються за допомогою зворотного розповсюдження. Градієнтні значення обчислюються для кожного нейрона в мережі, і це представляє зміну кінцевого виходу щодо зміни параметрів цього конкретного нейрона.

def one_layer_backward(dA, cache): linear_cache, activation_cache = cache Z = activation_cache dZ = dA*sigmoid(Z)*(1-sigmoid(Z)) # The derivative of the sigmoid function A_prev, W, b = linear_cache m = A_prev.shape[1] dW = (1/m)*np.dot(dZ, A_prev.T) db = (1/m)*np.sum(dZ, axis=1, keepdims=True) dA_prev = np.dot(W.T, dZ) return dA_prev, dW, db

The code above runs the backpropagation step for one single layer. It calculates the gradient values for sigmoid units of one layer using the cache values we stored previously. In the activation cache we have stored the value of Z for that layer. Using this value we will calculate the dZ, which is the derivative of the cost function with respect to the linear output of the given neuron.

Once we have calculated all of that, we can calculate dW, db and dA_prev, which are the derivatives of cost function with respect the weights, biases and previous activation respectively. I have directly used the formulae in the code. If you are not familiar with calculus then it might seem too complicated at first. But for now think about it as any other math formula.

After that we will use this code to implement backpropagation for the entire neural network. The function backprop implements the code for that. Here, we have created a dictionary for mapping gradients to each layer. We will loop through the model in a backwards direction and compute the gradient.

def backprop(AL, Y, caches): grads = {} L = len(caches) m = AL.shape[1] Y = Y.reshape(AL.shape) dAL = -(np.divide(Y, AL) - np.divide(1-Y, 1-AL)) current_cache = caches[L-1] grads['dA'+str(L-1)], grads['dW'+str(L-1)], grads['db'+str(L-1)] = one_layer_backward(dAL, current_cache) for l in reversed(range(L-1)): current_cache = caches[l] dA_prev_temp, dW_temp, db_temp = one_layer_backward(grads["dA" + str(l+1)], current_cache) grads["dA" + str(l)] = dA_prev_temp grads["dW" + str(l + 1)] = dW_temp grads["db" + str(l + 1)] = db_temp return grads

Once, we have looped through all the layers and computed the gradients, we will store those values in the grads dictionary and return it.

Finally, using these gradient values we will update the parameters for each layer. The function update_parameters goes through all the layers and updates the parameters and returns them.

def update_parameters(parameters, grads, learning_rate): L = len(parameters) // 2 for l in range(L): parameters['W'+str(l+1)] = parameters['W'+str(l+1)] -learning_rate*grads['W'+str(l+1)] parameters['b'+str(l+1)] = parameters['b'+str(l+1)] - learning_rate*grads['b'+str(l+1)] return parameters

Finally, it's time to put it all together. We will create a function called train for training our neural network.

def train(X, Y, layer_dims, epochs, lr): params = init_params(layer_dims) cost_history = [] for i in range(epochs): Y_hat, caches = forward_prop(X, params) cost = cost_function(Y_hat, Y) cost_history.append(cost) grads = backprop(Y_hat, Y, caches) params = update_parameters(params, grads, lr) return params, cost_history

This function will go through all the functions step by step for a given number of epochs. After finishing that, it will return the final updated parameters and the cost history. Cost history can be used to evaluate the performance of your network architecture.

Conclusion

If you are still reading this, Thanks! This article was a little complicated, so what I suggest you to do is to try playing around with the code. You might get some more insights out of it and maybe you might find some errors in the code too. If that is the case or if you have some questions or both, feel free to hit me up on twitter. I will do my best to help you.

Resources

  • Neural Networks Playlist - by 3Blue1Brown
  • Neural Networks and Deep Learning  - by Michael A. Nielsen
  • Gradient Descent and Stochastic Gradient Descent