суббота, 5 октября 2024 г.

Послевкусие Си: распространение сигнала в нейронной сети

 

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

Предыдущий пост "Послевкусие Си: решетка Кардано".

Глава 2. На початку була матриця

2.1. Множення матриць

Матрицею розміру n×m називається прямокутна таблиця спеціального вигляду, що складається з n рядків та m стовпців, заповнених числами. Елементи матриці A позначають aij, де i – номер рядка, в якому знаходяться елементи матриці, j – номер стовпця. Кількість рядків та стовпців задають розміри матриці. Зазвичай матрицю програмісти уявляють у вигляді двовимірного масиву.

Як знайти добуток матриць? Для обрахунку добутку матриць, зробимо детальний розв'язок прикладу, який дозволить зрозуміти алгоритм розв'язання таких задач.

Результатом множення матриці Am×n на матрицю Bn×k буде матриця Cm×k така, що елемент матриці C, який знаходиться в i-тому рядку та j-тому стовпчику (cij), дорівнює сумі добутків елементів i-того рядку матриці A на відповідні елементи j-того стовпчика матриці B. Дві матриці можна перемножити якщо кількість стовпчиків першої матриці дорівнює кількості рядків другої матриці.

Трохи спростим приклад і у якості матриці B візьмемо матрицю, яка містить тільки один стовпчик. Таку матрицю називають матрицею-стовпцем, або вектором. Наприклад:


Компоненти матриці C обраховуються наступним чином:

Програмна реалізація алгоритму обрахунку добутку матриці і вектора:

/* File mxv.c */
#include <stdio.h>

const int ROWS = 4;
const int COLS = 3;

void mvp(int rows, int cols, double *matrix, double *vector, double *resvec) {
  /* Matrix-vector product */
  for (int i = 0; i < cols; i++) {
    for (int j = 0; j < rows; j++) {
      resvec[j] += *((matrix + j * cols) + i) * vector[i];
    }
  }
}

void printv(double *vec, int len) {
  for (int i = 0; i < len; i++) {
    printf("%g\t", vec[i]);
  }
  puts("");
}

int main() {
  double matrix[ROWS][COLS] = {
      {0.9, 0.3, 0.4},
      {0.2, 0.8, 0.2},
      {0.1, 0.5, 0.6},
      {0.9, 0.7, 0.9},
  };
  double vector[COLS] = {2, 1, 2};
  double result_vector[ROWS] = {0};
  mvp(ROWS, COLS, (double *)matrix, vector, result_vector);
  printv(result_vector, ROWS);
  return 0;
}

У разі, якщо оновлення результуючого вектора за допомогою арифметики вказівників у тілі функції mvp вам не до смаку, то можна реалізувати цю функцію інакше:


void mvp(int rows, int cols, double matrix[rows][cols], double vector[cols],
          double result_vector[rows]) {
  for (int i = 0; i < cols; i++) {
    for (int j = 0; j < rows; j++) {
      result_vector[j] += matrix[j][i] * vector[i];
    }
  }
}

і викликати її так

mvp(ROWS, COLS, matrix, vector, result_vector);

Функція printv роздруковує вміст вектора, довжина якого передається через її другий параметр.

Компіляція файлу з вихідним кодом і запуск на виконання дадуть нам очікуваний результат:


$ clang -Wall mxv.c
$ ./a.out          
2.9     1.6     1.9     4.3

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

Примітка: для компіляції коду прикладів перших трьох глав цієї книги використовувався компілятор:

$ clang --version
Apple clang version 15.0.0 (clang-1500.3.9.4)
Target: x86_64-apple-darwin23.5.0 

Є один нюанс, на який слід звернути увагу.  Якщо замість компілятора clang ви використаєте gcc, то йому (в залежності від його версії) дещо в цьому коді може не сподобатися. Ви можете отримати від компілятора gcc, наприклад, версії 7.4.0 приблизно таке повідомлення:


variable-sized object may not be initialized
   double matrix[ROWS][COLS] = {
   ^~~~~~

Спробуйте самостійно скомпілювати цей файл за допомогою компілятора gcc і проаналізуйте всі отримані від нього повідомлення.

Щоб впоратися з цією особливістю компіляторів, слід замінити в коді константи :

const int ROWS = 3;
const int COLS = 4;

на макроси: 

#define ROWS 3
#define COLS 4

і цей код стане “їстівним” для обох компіляторів.

2.2. Поширення сигналів в нейронній мережі

Використовуючи розпізнавання голосу у смартфоні або у Google Translate, ви неодмінно маєте справу з нейронною мережею, натренованою глибоким навчанням. За останні кілька років глибоке навчання забезпечило компанії Google прибуток, достатній для того, щоб покрити витрати на всі футуристичні проекти Google X, включаючи безпілотні автомобілі, окуляри Google Glass та науково-дослідний проект Google Brain. Google однією з перших почала застосовувати глибоке навчання. Ще у 2013 році Google найняла Джеффрі Хінтона, батька-засновника глибокого навчання, і зараз інші компанії намагаються її наздогнати. Ну і нам теж не до смаку пасти задніх, і якщо ви опанували обрахунок добутку матриць і розібралися з програмним кодом, що втілює цей алгоритм, то нам час перейти до вирішення нового завдання. Спробуємо використати ці знання для отримання відгуку нейронної мережі на заданий вхідний сигнал. 

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

Рисунок 2.1 – Приклад нейронної мережі

Як ми можемо змоделювати штучний нейрон? Перші штучні нейронні мережі були названі перцептронами. Кожен нейрон має кілька входів (i). Нейрон, підсумовуючи відповідні вхідні значення, обчислює результуючу суму, яка стане аргументом функції сигмоїди (σ), що управляє вихідним значенням нейрона. Така схема відбиває принцип роботи нейронної мережі. Нижче наведена діаграма (Рис. 2.2) ілюструє ідею комбінування вхідних значень і обробку результуючої суми функцією сигмоїди.  Якщо комбінований сигнал недостатньо сильний, сигмоїда пригнічує вихідний сигнал, але  якщо сума вхідних сигналів досить велика, то функція σ збуджує нейрон.

Рисунок 2.2 – Схема нейрона

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

Рисунок 2.3 – Сигмоїдальна функція

Буквою e математиці прийнято позначати константу, рівну 2.71828. Це так зване трансцендентне число. Якщо ви раптом не знаєте, що це таке, можете знайти цей термін в інтернеті; його пояснення виходить за межі теми цієї книги.

Дослідниками в галузі штучного інтелекту використовуються також інші функції активації аналогічного виду, але сигмоїда проста і дуже популярна, тому вона буде в нашому випадку підходящим вибором. У перцептроні вхідні дані (i) множаться на вагові коефіцієнти (w) і підсумовуються. Крім того, існує сутність під назвою зсув (bias, b) або параметр активації, який також додається до суми (Σ) вхідних даних, помножених на ваги. Параметр активації дає додаткову гнучкість навчальним можливостям персептрона, хоча перцептрон може працювати і без нього. Сума Σ проходить через сигмоїдальну функцію σ і перетворює Σ у вихідний сигнал нейрона.

Ми далі не заглиблюватимемося в теорію побудови нейронних мереж. На цю тему написано багато хороших книг, назву однієї з них ви знайдете в списку літератури  [1].

Повернемося до наведеного вище зображення нейронної мережі  (Рис. 2.1). На цій ілюстрації представлені три шари, перший і останній з яких включають по три штучні нейрони, або вузли, а середній шар – чотири вузли. Легко помітити, тут кожен вузол з'єднаний з кожним із вузлів попереднього та наступного шарів. З кожним з'єднанням асоціюється певна вага w. Низький ваговий коефіцієнт послаблює сигнал, високий – посилює його. Перший шар вузлів – вхідний. Його єдине призначення – представляти вхідні сигнали. У вхідних вузлах функція активації до вхідних сигналів не застосовується. Зараз немає необхідності висувати щодо цього якісь розумні доводи. Просто сприймаємо як даність: перший шар нейронних мереж є лише вхідним шаром, що представляє вхідні сигнали.

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

Для початку нам знадобиться кілька матриць. Перша (вектор) містить сигнали вхідного шару. Друга містить вагові коефіцієнти для зв'язків між вузлами двох шарів – вхідного та другого (так званого прихованого шару). Результатом множення цих двох матриць є об’єднаний згладжений сигнал, що надходить на вузли другого (вихідного) шару.
Обчислення вхідних сигналів, що надходять на кожен із вузлів другого шару, може бути виконано з використанням матричного множення. Немає потреби в тому, щоб якось особливо дбати про те, скільки вузлів входить у кожен шар. Збільшення кількості шарів призводить до збільшення розміру матриць. Щоб отримати вихідний сигнал другого шару, потрібно застосувати сигмоїду до кожного окремого елемента вектора, отриманого в результаті множення матриці вагових коефіцієнтів на вектор вхідних сигналів. Такий підхід застосовується для обчислення сигналів, що проходять від одного шару до наступного шару. Наприклад, за наявності трьох шарів ми просто знову виконаємо операцію множення матриць, використовуючи вихідні сигнали другого шару як вхідні для третього, але, зрозуміло, попередньо скомбінувавши їх і згладивши за допомогою додаткових вагових коефіцієнтів.

Задумайтесь на хвилину про значущість зв'язків між нейронами.  У вашому мозку сотня мільярдів нейронів, але справжнє джерело вашої індивідуальності – зв'язок між цими нейронами. Кожен нейрон створює щонайменше тисячу зв'язків із іншими нейронами. Тобто у вашому мозку близько сотні трильйонів зв'язків. Наскільки велике це число? У Чумацькому Шляху приблизно 400 мільярдів зірок. У трильйоні – 1000 мільярдів. Виходить, що у вашому мозку більше нейронних зв'язків, ніж зірок у 5 тисячах Чумацьких Шляхів. Ось реальний масштаб індивідуальності – розмір вашої сутності. У світі не було, немає і мабуть ніколи не буде людини з ідентичною сотнею трильйонів зв'язків. Те, що ви запам'ятали, забули, що змушує вас сміятися, нервувати, радіти, що лякає, заспокоює чи виснажує – все це становить унікальну структуру, яка властива лише вам. Світ, який ви бачите протягом життя, видно тільки вам. Ваші реакції на цей світ – тільки ваші. Те, що ви любите – дії, морозиво в літню спеку, сміх коханої людини, рядок комп'ютерного коду, ідеальна відповідність візерунків двох потертих плиток на підлозі вашої кухні – все це доступно тільки вам. Ви все це пам'ятаєте. Усередині вас цілі галактики: вони світитимуть яскраво лише до тих пір, поки ви живі. Варто їм згаснути після вашої смерті, і ніщо і ніхто ніколи не сяятиме так само, як вони…

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

2.3. Використання матричного множення у мережі з трьома шарами

Відомо, перший шар – вхідний, проміжний шар називається прихованим шаром, а третій – вихідний.  Тепер можна розпочати створення власної нейронної мережі за допомогою мови С.
Матриця ваг Wih (input-hidden) містить вагові коефіцієнти для зв'язків між вхідним та прихованим шарами. Коефіцієнти для зв'язків між прихованим та вихідним шарами будуть зберігатися в іншій матриці, яку позначено Who (hidden-output) прихований-вихідний.
Нижче представлені вхідний вектор і всі вагові коефіцієнти (кожен з них вибирався як випадкове число). Ніякі особливі міркування поки що за їх вибором не стояли.

const int INODES = 3;
const int HNODES = 4;
const int ONODES = 3;
double Wih[HNODES][INODES] = {
    {0.9, 0.3, 0.4},
    {0.2, 0.8, 0.2},
    {0.1, 0.5, 0.6},
    {0.9, 0.7, 0.9},
};
double Who[ONODES][HNODES] = {
    {0.3, 0.7, 0.5, 0.6},
    {0.6, 0.5, 0.2, 0.3},
    {0.8, 0.1, 0.9, 0.4},
};
double inputs[INODES] = {0.9, 0.1, 0.8};

Ваговий коефіцієнт для зв'язку між першим вхідним вузлом та першим вузлом проміжного прихованого шару w11 = 0.9. Так само ваговий коефіцієнт для зв'язку між другим вхідним вузлом і другим вузлом прихованого шару w22 = 0.8. Між першим і другим w12 = 0.2.

/* File simple_query.c */
#include <math.h>
#include <stdio.h>

/* clang -Wall simple_query.c -lm */

const double EULER_NUMBER = 2.71828;
const int INODES = 3;
const int HNODES = 4;
const int ONODES = 3;
double Wih[HNODES][INODES] = {
    {0.9, 0.3, 0.4},
    {0.2, 0.8, 0.2},
    {0.1, 0.5, 0.6},
    {0.9, 0.7, 0.9},
};

double Who[ONODES][HNODES] = {
    {0.3, 0.7, 0.5, 0.6},
    {0.6, 0.5, 0.2, 0.3},
    {0.8, 0.1, 0.9, 0.4},
};

void mvp(int rows, int cols, double *matrix, double *vector, double *resvec) {
  /* Matrix-vector product */
  for (int i = 0; i < cols; i++) {
    for (int j = 0; j < rows; j++) {
      resvec[j] += *((matrix + j * cols) + i) * vector[i];
    }
  }
}

void printv(double *vec, int length) {
  for (int i = 0; i < length; i++) {
    printf("%.3f\t", vec[i]);
  }
  putchar('\n');
}

double sigmoid(double n) { return (1 / (1 + pow(EULER_NUMBER, -n))); }
void map(double (*func)(double), int length, double *vector, double *result) {
  for (int i = 0; i < length; i++) {
    result[i] = (*func)(vector[i]);
  }
}

/* query the neural network */
void query(double *inputs) {
  /* calculate signals into hidden layer */
  double hidden_inputs[HNODES] = {0};
  mvp(HNODES, INODES, (double *)Wih, inputs, hidden_inputs);
  /* calculate the signals emerging from hidden layer */
  double hidden_outputs[HNODES] = {0};
  map(sigmoid, HNODES, hidden_inputs, hidden_outputs);
  /* calculate signals into final output layer */
  double final_inputs[ONODES] = {0};
  mvp(ONODES, HNODES, (double *)Who, hidden_outputs, final_inputs);
  /* calculate the signals emerging from final output layer */
  double final_outputs[ONODES] = {0};
  map(sigmoid, ONODES, final_inputs, final_outputs);
  printf("final_outputs:\n");
  printv(final_outputs, ONODES);
}

int main() {
  double inputs[INODES] = {0.9, 0.1, 0.8};
  query(inputs);
  return 0;
}

Компіляція файлу з вихідним кодом і запуск на виконання:

$ clang -Wall simple_query.c -lm
$ ./a.out                        
final_outputs:
0.814   0.757   0.830

Для того, щоб сформувати відгук шару на вхідний сигнал, необхідно застосувати до суми (Σ) вхідних даних, помножених на вагові коефіцієнти, функцію активації σ, яка перетворює Σ у вихідний сигнал нейрона. Саме це завдання виконує функція map(), якій в якості першого параметра передається вказівник на функцію sigmoid().
Функція query() приймає вхідні дані нейронної мережі через параметр, виконує необхідні розрахунки і друкує результат.
Давайте тепер підсумуємо все те, що на цей час вже зроблено: розраховано проходження сигналу через вихідний та прихований шари, тобто визначено значення сигналів на виходах цих шарів. Для повної ясності уточнимо, що ці значення були отримані шляхом застосування функції активації до комбінованих вхідних сигналів відповідного шару.
Варто запам'ятати таке: незалежно від кількості шарів у нейронній мережі, обчислювальна процедура для кожного з них однакова – комбінування вхідних сигналів, згладжування сигналів для кожного зв'язку між вузлами за допомогою вагових коефіцієнтів та отримання вихідного сигналу за допомогою функції активації. Для нас несуттєво те, скільки шарів утворюють нейронну мережу – 3 або, наприклад,  203, адже до кожного з них застосовується один і той самий підхід.

Повністю зв’язані нейронні мережі (fully connected feedforward artificial neural network), також відомі як багатошарові перцептрони (multilayer perceptron MLP), складаються що щонайменше з трьох шарів нейронів (вхідний, вихідний і принаймні один прихований шар), які перетворюють вхідні дані у вихідні через послідовність зважених сум та нелінійних функцій активації. У повністю зв’язаних шарах кожен нейрон має зв'язок з усіма нейронами попереднього шару, що дозволяє ефективно навчати моделі для різних завдань, включаючи класифікацію зображень. 

Комментариев нет:

Отправить комментарий