Коли приходить зима часом хочеться щоб домашня машина для перетворення електричної енергії в теплову (так директор ФТЛ називає комп’ютери) працювала на всіх парах. Але крім того хочеться аби вона робила щось цікавіше за обчислення хеш функцій якогось блоку криптовалюти. Часом залишаю комп’ютер на ніч попрацювати над вікіпедією, але це задача скоріше зав’язана на швидкість мережі і диска, тому процесор майже не завантажує.
Публікація про те що прості штучні нейронні мережі не такі вже й складні. Алгоритм їх навчання я ще не ґрокнув але навчився складати їх вручну задаючи топологію і всі коефіцієнти, тому така нібито тавтологічна назва. Насправді нейронні мережі не складаються вручну, а самонавчаються різними методами, бо для типових задач штучного інтелекту вони занадто великі аби це було можливо. Але все одно вони залишаються просто купкою матриць, тільки великих.
Проблема з нейронними мережами в тому, що аби з ними почати робити щось цікаве треба дуже багато прикладів по яких її можна навчати. І на цьому місці я згадав що ще в університеті з Андрієм ми пробували навчити штучну нейронну мережу запам’ятовувати зображення, як функцію з (x, y) -> (r, g, b). Де на вході було два нейрони – координати пікселя в зображенні, а на виході три, збуджені від 0 до 1 які позначають компоненти кольору пікселя. Прикладів по яких можна навчатись купа – просто давай мережі координати кожного пікселя, і хай пробує вгадати колір. І коли ми пробували навчити її запам’ятати зображення якогось символа, на виході отримували щось на зразок цього:
На заняттях зі штучного інтелекту нам замість того щоб пояснити що там могло відбуватись сказали вивчити Lisp або Prolog. І напевне не змогли б пояснити, бо аби це зрозуміти треба добре розуміти матаналіз. Але не переживайте, в цій статті я вас навчу будувати нейронні мережі без матаналізу. Вони не зможуть вчитись, але будуть зразу навчені. Для цього треба лише розуміти трохи дискретки і лінійної алгебри.
Клод Шеннон окрім всього іншого в 1938 році зрозумів що за допомогою простих реле можна реалізувати як завгодно складну функцію булевої алгебри. Зараз ми спробуємо побачити що будь-яку функцію булевої алгебри можна реалізувати комбінацією простих нейронів. Є формальне доведення, що будь-яку неперервну функцію багатьох змінних нейронна мережа з одним прихованим шаром апроксимує з довільною точністю
Нейрон – це функція вигляду , де
– передавальна функція (функція активації) (що небудь що для маленьких величин дає приблизно 0 чи -1, а для великих – 1),
– це вага кожного входу, а
– поріг (bias). Тобто це така штука яка має багато входів, сигнал на кожному вході множить на щось (синаптичу силу?), тоді додає їх всіх разом, застосовує функцію активації до суми щоб визначити наскільки збудитись, і передає своє збудження на аксон. До його аксона можуть бути приєднані входи купи інших нейронів.
Що може нейрон? Ну, скажімо так, небагато. Він просто обчислює зважену суму своїх входів і перевіряє чи вона більша за якийсь поріг. Геометрично це означає що він задає гіперплощину яка ділить гіперпростір входів на дві половини. Для випадку нейрона з двома входами – це пряма що ділить простір на дві половини. Наприклад, якщо , то нейрон, якщо йому на вхід дати координати на зображенні, а функція активації – функція Гевісайда (
)активується якщо
. Тобто, якось так:
Поки що нічого цікавого. Але, якщо прийняти за 0 – False, а 1 – True, то цей нейрон обчислює логічну кон’юнкцію (активується якщо активовані обидва входи). Чи є нейрон для диз’юнкції? Так, якщо , то для графіка отримуємо нерівність
і такий нейрон активуватиметься якщо хоча б один на вході активований.
Ось код для matplotlib, який за функцією від двох змінних малює зображення, якщо ви хочете відтворити мої результати:
import matplotlib.pyplot as plt def draw_image(f): """Show image generated by function.""" image = [] y = 1 delta = 0.005 while y > 0: x = 0 row = [] while x < 1: row.append(f(x, y)) x += delta image.append(row) y -= delta plt.imshow(image, extent=[0, 1, 0, 1], cmap='winter') plt.show()
Чи зможемо ми створити нейрон який буде обчислювати наприклад функцію xor? Відповідь – ні, бо нейрон задає гіперплощину (для 2d – пряму), а графік функції XOR виглядає якось так:
Але якщо з’єднати три нейрони? Ми знаємо що виключне або, це коли або а або б, але не а і б зразу, що записується так: . Цій формулі відповідатиме така нейронна мережа:
Сигнали йдуть зліва направо, над зв’язками написані ваги зв’язків, над нейронами – пороги. Можете на листочку перевірити що це дійсно XOR. Нейрон з порогом -0.5 активується коли активні або x або y, а нейрон з порогом +1.5 – коли не обидва зразу. Вихідний нейрон активується коли активуються обидва проміжні.
Ще варто зауважити що наша мережа відповідає популярній топології що має назву “багатошаровий персептрон”. В ній вхідні сигнали передаються на вхід масиву нейронів, тоді активації цього масиву нейронів передаються на вхід наступному шару, і так поки не закінчаться шари. Результат роботи останнього шару – це вихід мережі.
А тепер трохи коду для того щоб полегшити нам побудову складніших мереж:
import math # без паніки, буде використано лише раз, і то не завжди import numpy as np # щоб спростити роботу з векторами і матрицями (звідки вони тут буде пояснено нижче) def sigmoid(x): """Відображає дійсні числа в діапазон 0-1""" return 1 / (1 + math.exp(-x)) def heaviside_step_function(x): """Просто поріг. True в Python == 1, False == 0""" return x > 0 class NeuralNet: """Клас що задає топологію і симулює нейронну мережу""" # Вибираємо функцію активації. # Декоратор np.vectorize - дозволяє застосовувати функцію поелементно до векторів # logistic_function = np.vectorize(sigmoid) logistic_function = np.vectorize(heaviside_step_function) def __init__(self): """Початково мережа порожня""" self.weights = [] self.biases = [] def add_layer(self, *neurons): """Додати новий шар нейронів в мережу. Кожен нейрон це список чисел: neuron[:-1] - ваги входів neuron[-1] - поріг """ weights = [] biases = [] assert neurons, 'В шарі очікується хоч один нейрон' if self.weights: # якщо в нас вже є шари - перевірити чи новий шар стикується current_output = self.weights[-1].shape[0] # поточна кількість виходів мережі assert len(neurons[0]) - 1 == current_output, ( f'поточна топологія мережі має {current_output} виходів' f'але ви намагаєтесь додати шар що має нейрони з {len(neurons[0] - 1)} входів' ) # Взагалі то кожен нейрон шару має мати однакову кількість входів, # але вистачить перевірок, бо код стане довгим. for neuron in neurons: # розбиваємо ваги окремо, пороги окремо weights.append(neuron[:-1]) biases.append(neuron[-1]) self.weights.append(np.array(weights)) self.biases.append(np.array(biases)) def __call__(self, *args): """ Мережу можна викликати як функцію, і вона прожене вхідні дані через себе і дасть вихід першого нейрона з останнього шару. Поки що картинки будуть мати один канал кольору. """ output = np.array(args) # ми по черзі застосуємо до цього вектора кожен шар нейронів for weights, biases in zip(self.weights, self.biases): # попарно беремо ваги і пороги output = self.logistic_function(weights @ output + biases) # ну власне виконуємо обчислення в нейронах return output[0]
Трохи більше ніж 50 рядків коду, і вже нейронна мережа? Тааа, просто вона ще не вміє вчитися. Взагалі то більшість логіки зашита в рядку logistic_function(weights @ output + biases)
– решта просто допоміжна, тому можна напевне було б і в 10 вкластися, за рахунок зручності роботи з нею.
Що це за собачка? Це множення матриці на вектор, що дає вектор скалярних добутків кожного рядка матриці на той вектор. Тобто обчисленя зразу всіх сум нейронів. Далі ми додаємо вектор порогів (що просто сумує всі елементи), тобто додає пороги зразу у всіх нейронах. Оце й уся лінійна алгебра. А потім застосовуємо логістичну функцію, яка в нас була logistic_function
.
Тепер подивимось як її використовувати. Наприклад для того щоб побудувати мережу яка обчислює XOR (топологію якої я намалював вище), і зобразити результат на картинці, ми пишемо таке:
xor_net = NeuralNet() xor_net.add_layer( [1, 1, -0.5], # кожен рядок описує нейрон. Наприклад: [-1, -1, 1.5] -x - y + 1.5 > 0 (якщо функція активації - функція Гевісайда) ) xor_net.add_layer( [1, 1, -1.5] # наш давній знайомий нейрон AND ) draw_image(xor_net)
Ну добре, ми бачили прямі лінії, а чи може ця мережа побудувати якусь фігуру?
Я вирішив спробувати таку:
Як таку картинку побудувати з нейронів? Можемо її розбити на три фігури – квадрат вгорі, квадрат посередині справа, і прямокутник внизу, побудувати мережі які активуються якщо координати пікселя на вході знаходяться в фігурі, і з’єднати їх нейроном що активується, якщо активується хоч одна з них.
Найпростіше нижню, для цього досить перевірити що y < 1/3. Для того що посередині справа треба щоб y 2/3. Для тої що вгорі треба перевірити три краї. Таким чином матимемо мережу з трьома шарами. Перший визначатиме з якого боку якоїсь прямої ми знаходимось, другий визначатиме чи ми в середині опуклої області, третій об’єднуватиме окремі фрагменти в одну картинку:
hacker_net = NeuralNet() hacker_net.add_layer( [ 0.0, -3.0, 1.0], # y < 1/3 [ 0.0, -3.0, 2.0], # y < 2/3 [ 0.0, 3.0, -2.0], # y > 2/3 [ 3.0, 0.0, -2.0], # x > 2/3 [-3.0, 0.0, 2.0], # x < 2/3 [ 3.0, 0.0, -1.0], # x > 1/3 ) hacker_net.add_layer( [0.0, 0.0, 1.0, 0.0, 1.0, 1.0, -2.5], # y>2/3 && x<2/3 && x>1/3 [0.0, 1.0, 0.0, 1.0, 0.0, 0.0, -1.5], # y<2/3 && x>2/3 [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.5], # y<1/3 ) hacker_net.add_layer( [1.0, 1.0, 1.0, -0.5], # хоч щось в попередньому шарі біля 1 ) draw_image(hacker_net)
Класно правда? Єдина відмінність нашої нейронної мережі від інших – що в інших переважно використовують як функцію активації сигмоїду. Тому що порогова функція не піддається оптимізації методом градієнтного спуску, бо не диференційовна в тому місці де змінюється. А сигмоїду дуже просто диференціювати, якщо вміти диференціювати.
Що якщо ми нашу мережу перемкнемо на сигмоїду? Вийде таке:
Хм, наша нейронна мережа тепер має якесь дуже розмите уявлення про зображення яке вона “вивчила”. Трохи нагадує університетські спроби.
Чого так? Давайте глянемо на графік сигмоїди:
Бачимо що для наших сум, значення яких коливаються біля нуля, вона дає значення не біля нуля і одиниці, а десь біля 0.4, 0.6. А за пару шарів таке усереднення накопичується. Щоб це виправити, треба великі значення ваг і порогів, щоб загальна сума що передається в функцію активації була велика. Правда в університеті ми використовували якусь бібліотеку, а вони уникають великих значень ваг, бо вони спричинюють деякі негативні побічні ефекти такі як перенавчання (гарні оцінки на навчальних даних, погані на тестових), і застрягання навчання в місцях де похідна від сигмоїди ~ 1, тому градієнт дуже маленький. Тому напевне таке розмите зображення виходило.
Окрім того, ми можемо додати деякий коефіцієнт в саму сигмоїду:
І тоді зображення вийде значно кращим:
Якщо хочете вчитись далі – є три гарні ресурси:
- 3blue1brown – канал на YouTube з дуууже цікавими мультиками про математику
- http://neuralnetworksanddeeplearning.com/ – книжка яку цей канал рекомендує.
- http://karpathy.github.io/neuralnets/ – така стаття що майже книжка. Автор тепер працює в компанії Ілона Маска Open AI
Я правда перед тим як пробувати осилити два останні, ще трохи намагаюсь гризти граніт матаналізу, бо багато вже з голови вивітрилось, а багато чого там і не було.