Розділ 11. Нейроеволюція
Читати про природу – це добре,
але якщо людина ходить лісом і уважно слухає,
вона може дізнатися набагато більше,
ніж те, що написано у книгах.
— Джордж Вашингтон Карвер

Зірконосий кріт (Condylura cristata), поширений переважно на північному сході Сполучених Штатів і східній Канаді, має унікальний і вузькоспеціалізований носовий орган. Його ніс, який розвивався протягом багатьох поколінь, складається з 22 мацаків, що мають понад 25 000 дрібних сенсорних рецепторів. Попри те, що кроти незрячі, ці мацаки дозволяють їм створюват и детальну просторову карту свого оточення. Вони можуть орієнтуватися у своєму темному підземному середовищі з точністю і прудкістю, що вражають, швидко розпізнаючи та споживаючи їстівні об’єкти за лічені мілісекунди.
Щиро вітаю! Ви дійшли до фінального акту цієї книги. Знайдіть хвилинку, щоб відсвяткувати все, чого ви навчилися.

У цій книзі ви досліджували фундаментальні принципи інтерактивного моделювання фізики за допомогою p5.js, занурювалися у складні поведінки агентів та інших поведінок заснованих на правилах, а також заглиблювалися у захопливу галузь машинного навчання. Ви стали природним!
Однак Розділ 10 лише трохи торкнувся поверхні роботи з даними й машинним навчанням на основі нейронних мереж — величезного краєвиду, для повного охоплення якого знадобилися б незліченні продовження цієї книги. Моя мета полягала не у заглибленні у нейронні мережі, а була простим ознайомленням з основними концепціями під час підготовки до великого фіналу. Фіналу, де я покажу спосіб інтегрування машинного навчання у світ анімованих інтерактивних програм p5.js і об’єднаю для останнього успіху якомога більше наших нових концепцій з природи коду, наскільки це можливо.
Шлях вперед пролягає через нейроеволюцію — стиль машинного навчання, який поєднує ГА-ми із Розділу 9 з нейронними мережами з Розділу 10. Нейроеволюційна система використовує дарвінівські принципи, щоб розвивати ваги (а в деяких випадках і саму структуру) нейронної мережі протягом поколінь навчання методом проб і помилок. У цьому розділі я покажу, як використовувати нейроеволюцію на знайомому прикладі зі світу ігор. На завершення я зміню керувальні поведінки Крейга Рейнольдса з Розді лу 5, щоб вони навчались шляхом нейроеволюції.
Навчання з підкріпленням
Нейроеволюція має багато спільного з іншою методологією машинного навчання, про яку я коротко згадував у Розділі 10 — навчання з підкріпленням, яке включає машинне навчання у симуляції. Агент із підтримкою нейронної мережі навчається, взаємодіючи з навколишнім середовищем і отримуючи зворотний зв’язок про свої рішення у формі винагород або штрафів. Це стратегія, побудована навколо спостереження.
Уявіть маленьку мишку, що біжить лабіринтом. Якщо вона повертає ліворуч, то отримує шматочок сиру, а якщо повертає праворуч — отримує невеликий електричний удар. (Не хвилюйтеся, це лише вигадана миша.) Імовірно, з часом миша навчиться повертати ліворуч. Її біологічна нейронна мережа приймає рішення з результатом (поворот ліворуч або праворуч) і спостерігає за своїм оточенням (ням-ням або ой-ой). Якщо спостереження вбачає негативні результати, мережа може скорегувати свої ваги, щоб наступного разу прийняти інше рішення.
У реальному сві ті навчання з підкріпленням зазвичай використовується не для мучення гризунів, а для розробки роботів. У момент часу t робот виконує завдання і спостерігає за результатами. Чи він врізався у стіну, чи впав зі столу, чи він неушкоджений? З часом робот навчається інтерпретувати сигнали з навколишнього середовища оптимальним чином, щоб виконувати свої завдання й уникати шкоди.
Замість мишки чи робота, подумайте тепер про будь-які об’єкти із прикладів цієї книги (блукачі, агенти, частинки, рухомі створіння). Уявіть, що вбудовуєте нейронну мережу в один із цих об’єктів і використовуєте її для обчислення сили чи іншої дії. Нейронна мережа може отримувати вхідні дані з навколишнього середовища (наприклад, відстань до перешкоди) і виводити певне рішення. Можливо, мережа вибиратиме з набору дискретних параметрів (переміщення вліво або вправо) або набору безперервних значень (величина і напрямок керувальної сили).
Чи це звучить знайомо? Це нічим не відрізняється від того, як нейронна мережа працювала після навчання у прикладах із Розділу 10, отримуючи вхідні дані та прогнозуючи класифікацію чи регресію! Власне навчання одного з цих об’єктів для прийняття правильного рішення є тим місцем, що відрізняє процес навчання з підкріпленням від підходу навчання під наглядом. Щоб краще продемонструвати це, розпочнімо зі, сподіваюся, легкого для розуміння і можливо знайомого сценарію — гри Flappy Bird (див. малюнок 11.1).
Гра оманливо проста. Ви керуєте маленькою пташкою, яка постійно рухається по екрану горизонтально. З кожною дією гравця птах змахує крилами й трохи підіймається вгору. Виклик полягає в тому, щоб пташка здолала на своєму шляху серію вертикальних труб, розташованих одна за одною через нерівні проміжки, які виходять з правої сторони екрана. У трубах на різних рівнях є прогалини й вашою основною метою є безпечне проведення пташки через ці прогалини. Якщо ви зіштовхуєтеся із трубою, гра завершується. У міру просування швидкість гри зростає і чим більше труб ви проходите, тим вищий ваш рахунок.

Припустімо, що ви хочете автоматизувати ігровий процес, і замість людських дій для створення пташиного руху цим буде займатися нейронна мережа, яка сама вирішуватиме, чи потрібно пташці змахувати крильцями. Чи може тут спрацювати машинне навчання? Пропустимо на мить початкові етапи життєвого циклу машинного навчання і подумаємо про те, як вибрати модель. Які у нейронної мережі мають бути входи й виходи?
Це досить цікаве запитання, оскільки тут, принаймні для вхідних даних, немає однозначної відповіді. Якщо ви не дуже знайомі з грою або не бажаєте визначати, які аспекти гри є важливими, доцільніше, щоб вхідними даними були усі пікселі ігрового екрана. Цей підхід намагається передати моделі усе про гру і дозволити її самій визначити, що є важливим.
Однак я маю достатній досвід гри у Flappy Bird і вважаю, що досить добре її розумію. Тому я можу обійтися без передачі всіх пікселів до моделі й звести суть гри лише до кількох частин вхідних даних, необхідних для формування прогнозів. Ці частини даних, які часто називають ознаками (особливостями, характеристиками) машинного навчання, представляють відмінні характеристики даних, які є найбільш важливими для прогнозування. Уявіть, що ви кусаєте загадково соковитий фрукт — його смак (солодкий!), консистенція (хрустка!) і колір (яскраво-червоний!) допомагають ідентифікувати цей фрукт як яблуко. У випадку з Flappy Bird найважливіші особливості перераховані нижче:
- -положення птаха
- -швидкість птаха
- -положення отвору наступної верхньої труби
- -положення отвору наступної нижньої труби
- -відстань до наступної труби
Ці ознаки показано на малюнку 11.2.

Нейронна мережа матиме п’ять входів, по одному для кожної ознаки, а як щодо виходів? Це задача класифікації чи регресії? Це може здатися дивним запитанням у контексті такої гри, як Flappy Bird, але насправді воно дуже важливе і пов’язане зі способом керування грою. Дотики до екрана, натискання кнопок чи використання клавіатури — усе це приклади класифікації. Зрештою, у гравця є лише обмежений набір вибору: доторкатися до екрана чи ні або яку саме з клавіш натиснути W, A, S або D. З іншого боку, використання аналогового контролера такого як джойстик схиляє до регресії. Джойстик можна нахилити на різні кути у будь-якому напрямку, перетворюючи на вихідні значення безперервного діапазону як для горизонтальної, так і для вертикальної осей.
Для Flappy Bird виходи являють собою вибір класифікації лише з двома варіантами:
- Махнути крильми.
- Не махати крильми.
Це означає, що мережа повинна мати два виходи, що вказує на загальну архітектуру мережі, як зображено на малюнку 11.3.

Тепер у мене є вся інформація, необхідна для налаштування моделі, щоб створити її за допомогою ml5.js:
let options = {
inputs: 5,
outputs: ["flap", "no flap"],
task: "classification"
};
let birdBrain = ml5.neuralNetwork(options);
Що далі? Якщо виконувати кроки, описані в Розділі 10, мені довелося б повернутися до кроків 1 і 2 процесу машинного навчання: збору і підготовки даних. Як саме це буде працювати тут? Одна з ідей може полягати у пошуку найкращого гравця у Flappy Bird усіх часів і записати його гру впродовж багатьох годин. Я міг би зареєструвати особливості вхідних даних для кожного моменту ігрового процесу разом з тим коли гравець робив змахи для пташки, а коли ні. Введіть усі ці дані у модель, натренуйте її, і я вже бачу заголовки: “Бот зі штучним інтелектом перемагає у Flappy Bird”.
Але зачекайте, чи справді комп’ютеризований агент навчився грати у Flappy Bird самостійно, чи він просто навчився віддзеркалювати ігровий процес людини? Що, якщо людина під час гри пропустила ключовий аспект стратегії Flappy Bird? Автоматизований гравець цього ніколи не виявить. Не кажучи вже про те, що збирати всі ці дані було б неймовірно виснажливо.
Проблема полягає в тому, що я повернувся до сценарію навчання під наглядом, подібного до тих, що були у Розділі 10, але це має бути розділ про навчання з підкріпленням. На відміну від навчання під наглядом, у якому правильні відповіді надходять з навчальним набором даних, агент у навчанні з підкріпленням навчається на відповідях — оптимальних рішеннях — методом проб і помилок, взаємодіючи з середовищем і отримуючи зворотний зв’язок. У випадку з Flappy Bird, агент може отримувати позитивну відзнаку (своєрідна винагорода) кожного разу, коли він успішно проходить трубу й н егативну відзнаку (своєрідне покарання), якщо він влучає у трубу або землю. Мета агента — визначити, які дії з часом призводять до найбільшої сукупної винагороди.
На початку агент Flappy Bird не знає, коли найкраще махати крилами, що призведе до багатьох зіткнень. У міру того, як він отримує все більше і більше відгуків від незліченних спроб проходження тунелю, він починає вдосконалювати свої дії та розробляти оптимальну стратегію для проходження труб без збоїв, максимізуючи свою загальну винагороду. Цей процес навчання через практику й оптимізації на основі зворотного зв’язку є суттю навчання з підкріпленням.
У цьому розділі я розгляну принципи, які тут викладаю, але з нововведенням. Традиційні методи навчання з підкріпленням включають визначення стратегії (званої політикою) і відповідної функції винагороди, щоб забезпечити зворотний зв’язок для коригування політики. Однак замість того, щоб піти цим шляхом, я звернуся до зірки цього розділу — нейроеволюції.
Еволюція нейронних мереж
Замість традиційн ого зворотного поширення помилки, політики й функції винагороди, нейроеволюція застосовує принципи ГА та природного відбору для тренування ваг у нейронній мережі. Ця техніка запускає відразу багато нейронних мереж для розв'язання проблеми. Періодично “відбираються” найкращі нейронні мережі і їхні “гени” (ваги мережевих з’єднань) комбінуються та змінюються для створення наступного покоління мереж. Нейроеволюція особливо ефективна в середовищах, де правила навчання не визначені точно або задачі є складними й мають численні потенційні рішення.
Один із перших прикладів нейроеволюції можна знайти у статті Едмунда Рональда і Марка Шенауера 1994 року “Genetic Lander: An Experiment in Accurate Neuro-genetic Control”. У 1990-х роках традиційні методи навчання нейронних мереж ще тільки зароджувалися і ця робота досліджувала альтернативний підхід. У документі описано, як імітований космічний корабель — у грі з влучною назвою Lunar Lander — може навчитися безпечно спускатися і приземлятися на поверхню. Замість того, щоб використовувати створені вручну правила чи промарковані набори даних, дослідники вирішили використовувати ГА для розвитку і навчання нейронних мереж протягом кількох поколінь. І це спрацювало!
У 2002 році Кеннет О. Стенлі та Рісто Мійккулайнен розширили попередні нейроеволюційні підходи у своїй статті “Evolving Neural Networks Through Augmenting Topologies”. На відміну від методу посадки на Місяць, який зосереджувався на еволюції ваг нейронної мережі, Стенлі та Мійккулайнен представили метод, який також розвивав структуру самої мережі! Їхній алгоритм NEAT — NeuroEvolution of Augmenting Topologies — починається з простих мереж і поступово вдосконалює їхню топологію шляхом еволюції. У результаті NEAT може відкрити мережеву архітектуру, адаптовану до конкретних завдань, часто даючи більш оптимізовані й ефективні рішення.
Комплексна реалізація NEAT потребує глибшого вивчення архітектури нейронних мереж і безпосередньої роботи з TensorFlow.js. Натомість моя мета — імітувати оригінальні дослідження Рональда та Шенауера в сучасному контексті веббраузера за допомогою ml5.js. Замість того, щоб використовувати гру Lunar Lander, я спробую зробити це з Flappy Bird. І для цього мені спочатку потрібно запрограмувати версію Flappy Bird у якій зможе працювати моя нейроеволюційна мережа.
Програмування Flappy Bird
Flappy Bird створив в’єтнамський розробник ігор Донг Нгуєн у 2013 році. У січні 2014 року він став додатком з найбільшою кількістю завантажень в Apple App Store. Однак 8 лютого того ж року Нгуєн оголосив, що видаляє гру у зв’язку зі звиканням до неї. З того часу вона стала однією з найбільш клонованих ігор в історії.
Flappy Bird є чудовим прикладом закону Бушнелла, афоризму, який приписують засновнику Atari й творцю гри Pong Нолану Бушнеллу: “В усі найкращі ігри легко навчитися грати, але важко опанувати досконало”. Це також чудова гра для програмістів-початківців, яку можна відтворити у навчальних цілях і вона ідеально відповідає концепціям цієї книги.
Щоб запрограмувати гру за допомогою p5.js, я почну з визначення класу Bird
. Це може вас шокувати, але у цій демонстрації я збираюся пропустити використання класу p5.Vector
, а натомість для положення птаха буду використовувати роздільні -змінні. Оскільки птах у грі рухається тільки по вертикальній осі, значення властивості залишається постійною! Таким чином velocity
(і всі відповідні сили) можуть бути єдиним скалярним значенням лише для осі .
Для ще більшого спрощення коду, я додам сили безпосередньо до швидкості птаха замість накопичення їх у змінній acceleration
. На додаток до звичайного методу update()
, я включу метод flap()
для того, щоб птах робив змах вгору. Метод show()
тут не описано, оскільки він лише малює круг. Ось код:
class Bird {
constructor() {
this.x = 50
this.y = 120;
Положення птаха (значення x буде константним).
this.velocity = 0;
this.gravity = 0.5;
this.flapForce = -10;
Швидкість і сили є скалярними, оскільки птах рухається лише вздовж осі y.
}
flap() {
this.velocity += this.flapForce;
}
Метод для змаху крилами.
update() {
this.velocity += this.gravity;
this.y += this.velocity;
Додавання сили тяжіння.
this.velocity *= 0.95;
Послаблення швидкості.
if (this.y > height) {
this.y = height;
this.velocity = 0;
}
Обробка зіткнення з підлогою.
}
}
Іншими основними елементами гри є труби через які птах має пролітати. Я створю клас Pipe
де опишу пару прямокутників: один виходитиме із верхньої частини полотна, а інший — з нижньої. Подібно до того, як птах рухається лише у вертикальному напрямку, труби ковзають лише вздовж горизонтальної осі, тому їх властивості також можуть бути скалярними значеннями, а не векторами. Труби рухаються з постійною швидкістю і не зазнають ніяких інших сил.
class Pipe {
constructor() {
this.spacing = 100;
Розмір отвору між двома частинами труби.
this.top = random(height - this.spacing);
Довільна висота верхньої частини труби.
this.bottom = this.top + this.spacing;
Початкове положення нижньої труби (на основі верхньої)
this.x = width;
Розташування труби починається з правого краю полотна.
this.w = 20;
Ширина труби.
this.velocity = 2;
Горизонтальна швидкість труби.
}
show() {
fill(0);
noStroke();
rect(this.x, 0, this.w, this.top);
rect(this.x, this.bottom, this.w, height - this.bottom);
}
Малювання верхньої і нижньої труб.
update() {
this.x -= this.velocity;
}
Оновлення горизонтального положення.
}
Для ясності, гра зображує птаха, який летить через труби — птах рухається вздовж двомірного простору, а труби залишаються нерухомими. Однак простіше запрограмувати гру таким чином, ніби птах нерухомий у своєму горизонтальному положенні, а труби рухаються.
З написаними класами Bird
і Pipe
, я практично готовий до запуску гри. Однак не вистачає ключової деталі — зіткнень. Вся гра полягає в тому, що птах намагається уникнути зіткнення з трубами! На щастя, в цьому немає нічого нового. У цій книзі ви вже бачили кілька прикладів з об’єктами, які звіряли своє положення з іншими елементами оточення. Метод для перевірки зіткнень можна розмістити або в класі Bird
(щоб перевірити, чи птах вдаряється об трубу), або у класі Pipe
(щоб перевірити, чи труба вдаряє птаха). Будь-який варіант може бути логічно виправданим, залежно від вашої точки зору.
Я розміщу метод у класі Pipe
і назву його collides()
. Сам код дещо складніший, ніж можна подумати, на перший погляд, оскільки метод потребує перевірки як верхнього, т ак і нижнього прямокутників труби із положенням птаха. Я міг би підійти до цього різними шляхами. Один зі способів — спочатку перевірити, чи знаходиться птах вертикально в межах будь-якого прямокутника (над нижньою частиною верхньої труби чи під верхньою частиною нижньої). Але птах стикається з трубою тільки в тому випадку, якщо він також знаходиться горизонтально в межах ширини труби. Елегантний спосіб написати це — поєднати кожну з цих перевірок з логічним і:
collides(bird) {
let verticalCollision = bird.y < this.top || bird.y > this.bottom;
Чи знаходиться птах у вертикальному діапазоні верхньої чи нижньої труби?
let horizontalCollision = bird.x > this.x && bird.x < this.x + this.w;
Чи знаходиться птах у горизонтальному діапазоні якоїсь із труб?
return verticalCollision && horizontalCollision;
Якщо є і вертикальний і горизонтальний перетин — це зіткнення!
}
Наразі алгоритм розглядає птаха як одну точку і не враховує його розміри. Для більш реалістичної гри цей момент варто вдосконалити.
Залишилося лише написати функції setup()
і draw()
. Мені потрібна одна змінна для птаха і масив для списку набору труб. Взаємодія із користувачем полягає лише у клацанні комп’ютерної миші, що запускає метод птаха flap()
. Замість того, щоб створювати повнофункціональну гру з рахунком, фінальними показниками та іншими звичними елементами, я просто переконаюся, що працює сама ігрова механіка, намалювавши текст “OOPS!” поблизу будь-якої труби, коли відбувається зіткнення. У повному коді клас Pipe
має додатковий метод offscreen()
, який перевіряє чи труба вже перемістилася за ліву межу полотна — це дозволяє видалити її з масиву, коли вона вже не потрібна.
let bird;
let pipes = [];
function setup() {
createCanvas(640, 240);
bird = new Bird();
pipes.push(new Pipe());
Створення пташки і першої труби.
}
function mousePressed() {
bird.flap();
}
Птах махає крилами, коли відбувається клацання мишкою.
function draw() {
background(255);
for (let i = pipes.length - 1; i >= 0; i--) {
pipes[i].show();
pipes[i].update();
if (pipes[i].collides(bird)) {
text("OOPS!", pipes[i].x, pipes[i].top + 20);
}
if (pipes[i].offscreen()) {
pipes.splice(i, 1);
}
}
Перевірка і обробка усіх труб.
bird.update();
bird.show();
Оновлення і малювання птаха.
if (frameCount % 100 === 0) {
pipes.push(new Pipe());
}
Додавання нової труби кожні 100 кадрів.
}
Найскладніший аспект цього коду полягає у створенні труб через регулярні проміжки часу за допомогою змінної frameCount
і операції ділення по модулю. У p5.js frameCount
є системною змінною, що збільшується із кожним кадром і зберігає значення кількості кадрів пройдених від початку запуску програми. Оператор модуля, позначений як %
, повертає залишок від операції ділення. Наприклад 7 % 3
повертає у результаті 1
, тому що при діленні 7 на 3 результат дорівнює 2 із залишком 1. Отже, логічний вираз frameCount % 100 === 0
перевіряє чи поточне значення змінної frameCount
націло ділиться на 100, що в результаті ділення по модулю має повертати нуль. Ця умова відповідає істині кожні 100 кадрів і тоді створюється нова парна труба та додається до масиву pipes
.
Вправа 11.1
Імплементуйте підрахунок очок, які нараховуються за успішне проходження кожної труби. Не соромтеся також додавати власні візуальні елементи оформлення для птаха, труб і середовища!
Нейроеволюційний Flappy Bird
Мій клон Flappy Bird, як він є наразі, керується за допомогою клацання миші. Тепер я хочу передати контроль над грою комп’ютеру і, використавши нейроеволюцію, навчити його грати. На щастя, процес нейроеволюції вже вбудований у ml5.js, тому зробити цей перехід буде порівняно просто. Перший крок — надати пташці мозок, щоб вона могла самостійно вирішувати, коли махати крилами.
Пташиний мозок
Коли я розповідав про навчання з підкріпленням, то склав спис ок вхідних характеристик, які повинні складати процес прийняття рішень птахом. Я збираюся використати той самий список, але з одним спрощенням. Оскільки розмір отвору між трубами постійний, немає потреби включати -положення верхньої й нижньої сторони — вистачить будь-якого з них. Отже, вхідні дані будуть наступними:
- -положення птаха
- -швидкість птаха
- -положення отвору наступної верхньої (або нижньої!) труби
- -відстань до наступної труби
Два виходи представляють два варіанти для птаха: махати крилами чи не махати. З налаштованими входами та виходами, для зберігання нейронної мережі ml5.js із відповідною конфігурацією, я можу додати до конструктора пташки властивість brain
. Щоб продемонструвати тут інший стиль кодування, я пропущу створення окремої змінної options
і передам потрібні опції у вигляді літерала об’єкта безпосередньо у функцію ml5.neuralNetwork()
. Зверніть увагу на додану властивість neuroEvolution
зі значенням true
. Це необхідно, щоб увімкнути деяку функціональність, яку я буду використовувати у коді пізніше:
constructor() {
this.brain = ml5.neuralNetwork({
inputs: 4,
outputs: ["flap", "no flap"],
task: "classification",
Мозок птаха отримує чотири вхідні дані і класифікує їх в одну з двох міток.
neuroEvolution: true.
Нова властивість, необхідна для забезпечення функціональності нейроеволюції.
});
}
Далі до класу Bird
я додам новий метод think()
для обчислення всіх необхідних вхідних даних птаха в кожен момент часу. Перші два входи прості — це просто властивості птаха y
і velocity
. Однак для входів 3 і 4 мені потрібно визначити, яка труба є наступною.
На перший погляд, може здатися, що наступна труба завжди буде у масиві першою, оскільки труби додаються по одній у кінець масиву. Однак після того, як труба проходить повз птаха, вона вже не актуальна, але все ще залишається деякий проміжок часу допоки повністю не вийде за межі полотна і не буде видалена з початку масиву. Тому мені потрібно знайти у масиві першу трубу в якої положення правого краю (її -позиція плюс ширина) більше за -позицію пташки:
think(pipes) {
let nextPipe = null;
for (let pipe of pipes) {
if (pipe.x + pipe.w > this.x) {
nextPipe = pipe;
break;
}
Наступна труба — це та, яка ще не пройшла повз птаха.
}
Отримавши наступну перешкоду, я можу створити чотири входи:
let inputs = [
this.y,
y-положення птаха.
this.velocity,
y-швидкість птаха.
nextPipe.top,
Верхня частина отвору наступної труби.
nextPipe.x - this.x,
Відстань до наступної труби.
];
Вже близько, але я забув критичний крок. Діапазон усіх вхідних значень визначається розмірами полотна, але нейронна мережа очікує значення у стандартизованому діапазоні, подібному до діапазону від 0 до 1. Одним із методів нормалізації цих значень є розділення значень вхідних даних, пов’язаних із вертикальними властивостями, на значення height
, а тих, що пов'язані з горизонтальними — на width
:
let inputs = [
this.y / height,
this.velocity / height,
nextPipe.top / height,
(nextPipe.x - this.x) / width,
Усі вхідні дані тепер нормалізовано по ширині і висоті.
];
Маючи в руках вхідні дані, я готовий передати їх методу нейронної мережі classify()
. Однак у мене є ще одна невелика проблема: метод classify()
асинхронний, тобто для обробки рішення моделі мені доведеться реалізувати механізм зворотного виклику усередині класу Bird
. Це додало б значного ускладнення коду, але, на щастя, у цьому випадку це зовсім непотрібно. Асинхронні зворотні виклики для функцій машинного навчання ml5.js зазвичай потрібні через час, необхідний для обробки моделлю великої кількості даних. Без функції зворотного виклику програмі для отримання результату від моделі може знадобитися певний час і якщо модель працює як частина програми p5.js, ця затримка може серйозно вплинути на плавність анімації. Проте нейронна мережа тут має лише чотири числові входи й дві вихідні мітки! Ці дані достатньо маленькі й можуть працювати досить швидко, тому в такому випадку немає причин використовувати асинхронний код.
Для повноти на вебсайті книги я додав версію прикладу, яка реалізує нейроеволюцію з асинхронними зворотними викликами. Однак для цього обговорення я збираюся використовувати функцію ml5.js, яка дозволяє мені використовувати синхронний підхід. Метод classifySync()
ідентичний методу classify()
, але він працює синхронно, тобто код зупиняється та чекає на результати, перш ніж рушити далі. Ви повинні бути дуже обережними, використовуючи цю версію методу, оскільки вона може спричинити проблеми в інших контекстах, але для цього простого сценарію вона працюватиме добре. Ось кінцева частина методу think()
з використанням classifySync()
:
let results = this.brain.classifySync(inputs);
if (results[0].label === "flap") {
this.flap();
}
}
Прогноз нейронної мережі має той самий формат, що й класифікатор рухів з Розділу 10 і потрібне рішення можна прийняти, перевіривши перший елемент масиву results
. Якщо вихідна мітка це "flap"
, тоді викликаємо метод flap()
.
Тепер, коли я закінчив метод think()
, можна перейти до справжнього виклику: навчити птаха вигравати у грі, постійно змахуючи крилами в потрібний момент. Ось де у сюжет повертається ГА. Пригадаємо з Розділу 9 три ключові принципи, які лежать в основі дарвінівської еволюції: мінливість або варіативність, відбір і спадковість. Я по черзі перегляну кожен із цих принципів, реалізуючи кроки ГА у цьому новому контексті нейронних мереж.
Варіативність Flappy Birds: Зграя
Один птах з ініціалізованою випадковою нейронною мережею навряд чи матиме успіх. Цей самотній птах, швидше за все, безперервно махатиме крильми й летітиме все вище за межі екрана, або сидітиме внизу полотна, очікуючи на зіткнення за зіткненням із трубами. Ця хаотична та безглузда поведінка є нагадуванням: випадково ініціалізована нейронна мережа не має жодних знань чи досвіду. Птах, по суті, робить щодо своїх дій випадкові припущення хаотичного типу, тому успіх буде рідкісним.
Ось тут і з’являється перший ключовий принцип ГА — варіативність. Розрахунок на те, що запровадивши якомога більше різних конфігурацій нейронної мережі, деякі з них можуть проявити себе трохи краще, ніж решта. Першим кроком до варіації є створення масиву з багатьма птахами (малюнок 11.4).

let populationSize = 200;
Чисельність популяції.
let birds = [];
Масив птахів.
function setup() {
for (let i = 0; i < populationSize; i++) {
birds[i] = new Bird();
}
Створення популяції птахів.
ml5.setBackend("cpu");
Запуск обчислення на CPU для кращої продуктивності.
}
function draw() {
for (let bird of birds) {
bird.think(pipes);
Це новий спосіб за допомогою якого птах приймає рішення махати крилами чи ні.
bird.update();
bird.show();
}
}
Ви можете помітити особливий рядок коду, який прокрався у функцію setup()
: ml5.setBackend("cpu")
. Під час роботи нейронних мереж багато важких обчислювальних операцій часто перекладають на GPU. Це поведінка за замовчуванням і це особливо важливо для більших попередньо натренованих моделей, які входять до ml5.js.
GPU проти CPU
- Графічний процесор (GPU — graphics processing unit): початково розроблений для візуалізації графіки, вправно обробляє величезну кількість операцій паралельним чином. Це робить його чудовим для математичних операцій і обчислень, які часто виконують моделі машинного навчання.
- Центральний процесор (CPU — central processing unit): часто вважається мозком або серцем комп’ютера універсального значення, обробляє ширшу різноманітність завдань, ніж спеціалізований графічний процесор, але він не створений для одночасного виконання такої ж кількості завдань.
Але тут є заковика! Передача даних до графічного процесора і назад створює додаткові витрати. У більшості випадків переваги від паралельної обробки графічним процесором з лишком компенсують ці накладні витрати, але для крихітної моделі, як тут, копіювання даних у графічний процесор і назад фактично сповільнює роботу нейронної мережі. Виклик ml5.setBackend("cpu")
повідомляє ml5.js виконувати обчислення нейронної мережі на CPU замість GPU. Принаймні в цьому простому випадку з невеликими пташиними мізками це ефективніший вибір.
Відбір Flappy Bird: Функція оцінювання придатності
Коли я маю різноманітну популяцію птахів, кожна з яких має власну нейронну мережу, наступним кроком у ГА стане відбір. Які птахи повинні передати свої гени (у нашому випадку, вагу нейронної мережі) наступному поколінню? У світі Flappy Bird мірилом успіху є здатність залишатися в живих якнайдовше, уникаючи труб. Це пристосованість птаха. Птах, який ухиляється від багатьох труб, вважається більш придатним, ніж той, який врізається у першу зустрічну перешкоду.
Щоб відстежувати оцінку пристосованості кожного птаха, я додам до класу Bird
дві властивості — fitness
і alive
:
constructor() {
this.fitness = 0;
Оцінка пристосованості птаха.
this.alive = true;
Змінна, що повідомляє чи жива наразі птаха.
}
Я призначаю змінній пристосованості числове значення, яке збільшується на одиницю кожного кадру анімації, доки птах залишається живим. Птахи, які виживають довше, повинні мати вищу пристосованість. Цей механізм відображає техніку навчання з підкріпленням винагородою за хороші рішення. Однак під час навчання з підкріпленням агент отримує негайний зворотний зв’язок за кожне прийняте ним рішення, що дозволяє йому коригувати відповідним чином свою політику. Тут пристосованість птаха є кумулятивним показником його загального успіху і застосовуватиметься лише на етапі відбору ГА:
update() {
this.fitness++;
Збільшення значення пристосованості у методі update().
}
Властивість alive
має булевий тип, значення якого напочатку встановлено у true
. Коли пташка зіштовхується з трубою, для цієї властивості встановлюється значення false
. Оновлюються і малюються на полотні лише ті птахи, що залишаються живими:
function draw() {
for (let bird of birds) {
На початку є масив пташок.
if (bird.alive) {
Малювати і оновлювати потрібно лише живих птахів.
bird.think(pipes);
Рішення приймаються на основі труб.
bird.update();
bird.show();
Оновлення і зображення птаха.
for (let pipe of pipes) {
if (pipe.collides(bird)) {
bird.alive = false;
}
Якщо птах зачепив трубу, він вважається неживим.
}
}
}
}
У Розділі 9 я продемонстрував два способи проведення еволюційного моделювання. У прикладі розумних ракет популяція існувала фіксовану кількість часу для кожного покоління. Той самий підхід, ймовірно, може спрацювати й тут, але я хочу дозволити птахам накопичувати найбільше можливе значення пристосованості, а не довільно зупиняти їх через певний ліміт часу. Другий спосіб продемонстрований на прикладі блупів, повністю виключає оцінку придатності й встановлює випадкову ймовірність клонування будь-якої живої істоти. Для Flappy Bird такий підхід може стати безладним і загрожує перенаселенням або повним вимиранням усіх птахів.
Я пропоную поєднати особливості обох підходів. Я дозволю поколінню існувати доки живою буде хоча б одна пташка. Коли всі птахи загинуть я виберу батьків для етапу відтворення і почну заново. Я почну з написання функ ції, що перевірятиме, чи всі птахи загинули:
function allBirdsDead() {
for (let bird of birds) {
if (bird.alive) {
return false;
}
Якщо жива хоча б одна пташка, значить не всі вони мертві!
}
return true;
Якщо цикл закінчується без виявлення жодної живої птахи, значить усі вони мертві.
}
Коли всі птахи загинули, настав час відбору! У попередніх прикладах ГА я показав техніку естафети для надання справедливого шансу усім членам популяції, водночас збільшуючи шанси відбору для тих, хто має вищий показник придатності. Я використаю тут ту саму функцію weightedSelection()
:
function weightedSelection() {
Для детального пояснення цього алгоритму перегляньте Розділ 9.
let index = 0;
let start = random(1);
while (start > 0) {
start = start - birds[index].fitness;
index++;
}
index--;
return birds[index].brain;
Замість повернення всього об’єкта, Bird повертається лише його мозок.
}
Щоб цей алгоритм працював належним чином, мені потрібно спершу нормалізувати значення придатності птахів, щоб їх сума разом складала 1-цю:
function normalizeFitness() {
let sum = 0;
for (let bird of birds) {
sum += bird.fitness;
}
Підрахунок суми загального значення придатності усіх птахів.
for (let bird of birds) {
bird.fitness = bird.fitness / sum;
}
Поділ значення придатності кожного птаха на загальну суму.
}
Після нормалізації придатність кожного птаха дорівнює його ймовірності для відбору.
Спадковість: Пташенята
У ГА залишився лише один крок — відтворення. У Розділі 9 я дуже детально пояснював про двоетапний процес генерації дочірнього елемента: схрещування і мутацію. Схрещування — це той крок де відбувається третій ключовий принцип спадковості: ДНК двох відібраних батьківських об’єктів об’єднується для формування дочірньої ДНК.
На перший погляд, ідея винайдення алгоритму для схрещування двох нейронних мереж може налякати, але насправді вона досить проста. Міркуйте про окремі “гени” мозку птахів як про ваги у нейронній мережі. Змішування двох таких мізків зводиться до створення нової нейронної мережі де кожна вага обрана через віртуальне підкидання монети — вага береться від першого або другого батьківського об’єкта:
let parentA = weightedSelection();
let parentB = weightedSelection();
let child = parentA.crossover(parentB);
Вибір двох батьків і створення дочірнього об’єкту за допомогою схрещування.
О так, сьогодні мій щасливий день! Виявляється ml5.js вже має метод crossover()
, який керує алгоритмом змішування двох нейронних мереж. Тож я з радістю можу перейти до кроку з мутацією:
child.mutate(0.01);
Застосування мутації.
Моя удача продовжується! Бібліотека ml5.js також надає і метод mutate()
, який приймає першим аргументом значення для швидкості мутації. Швидкість визначає, як часто буде змінюватися вага. Наприклад, коефіцієнт 0.01 вказує на 1 відсоток ймовірності того, що будь-яка задана вага зміниться. Під час мутації ml5.js трохи коригує вагу, додаючи до неї невелике випадкове число, а не вибираючи зовсім нове випадкове значення. Така поведінка імітує реальні генетичні мутації, які зазвичай вносять незначні зміни, а не абсолютно нові ознаки. Хоча цей підхід за замовчуванням пра цює у багатьох випадках, ml5.js пропонує більше контролю над процесом, дозволяючи використовувати спеціальну функцію мутації як необов’язковий другий аргумент у методі mutate()
.
Етапи схрещування і мутації потрібно повторювати для отримання такого ж розміру нового покоління пташок, що й у поточній популяції. Це досягається шляхом заповнення новими птахами порожнього локального масиву nextBirds
до потрібного розміру. Після заповнення популяції нового покоління глобальний масив birds
оновлює своє посилання на цей новий масив:
function reproduction() {
let nextBirds = [];
Початок з новим порожнім масивом.
for (let i = 0; i < populationSize; i++) {
let parentA = weightedSelection();
let parentB = weightedSelection();
Вибір двох батьків.
let child = parentA.crossover(parentB);
Створення дочірнього елементу за рахунок схрещування.
child.mutate(0.01);
Застосування мутації.
nextBirds[i] = new Bird(child);
Створення нового птаха.
}
birds = nextBirds;
Наступне покоління стає поточним!
}
Якщо ви уважно придивитесь до функції reproduction()
, то можете помітити, що у конструктор класу Bird
я передаю аргумент. Коли я вперше представив ідею пташиного мозку, кожен новий об’єкт Bird
створювався з абсолютно новим мозком — свіжоствореною нейронною мережею, наданою ml5.js. Однак тепер я хочу, щоб нові птахи успадкували дочірній мозок, який утворився в процесі схрещування і мутації. Щоб зробити це можливим, я трохи зміню конструктор класу Bird
, щоб використовувати необов’язковий аргумент під назвою brain
:
constructor(brain) {
if (brain) {
Перевірка, чи мозок був переданий.
this.brain = brain;
} else {
Якщо мозок не переданий буде створено новий.
this.brain = ml5.neuralNetwork({
inputs: 4,
outputs: ["flap", "no flap"],
task: "classification",
neuroEvolution: true,
});
}
}
Якщо під час створення нового птаха brain
не передано, тоді цей аргумент матиме значення undefined
. У JavaScript значення undefined
розглядається як false
. Таким чином перевірка if (brain)
буде негативною, тому код перейде до оператора else
і викличе ml5.neuralNetwork()
. З іншого боку, якщо через аргумент передається нейронна мережа, що існує, тоді перевірка на brain
пройде ствердно наче true
і значення буде безпосередньо присвоєне до властивості this.brain
. Цей елегантний підхід дозволяє одному конструктору обробляти кілька сценаріїв.
На цьому приклад завершено. Все, що залишилося зробити, це викликати normalizeFitness()
і reproduction()
усередині функції draw()
у кінці кожного покоління, коли всі птахи загинули.
function draw() {
/* Уся інша частина функції */
if (allBirdsDead()) {
normalizeFitness();
reproduction();
resetPipes();
Створення наступного покоління, коли всі птахи загинули.
}
}
function resetPipes() {
pipes.splice(0, pipes.length - 1);
Видалення всіх труб, окрім останньої.
}
Зверніть увагу на додану нову функцію resetPipes()
. Якщо я не видалю труби перед тим, як почати нове покоління, птахи можуть моментально перезапуститися у позиціях, що одразу перетинаються з трубою, яка на той момент була на початку полотна, і в цьому випадку навіть найкращий птах не матиме шансу для польоту! Повний онлайн-код для прикладу 11.2 також обробляє поведінку птахів таким чином, що коли вони вилітають за верхню або нижню межі полотна, то гинуть.
Вправа 11.2
Приклад 11.2 потребує багато часу, щоб отримати якісь результати. Чи могли б ви “прискорити час”, пропускаючи малювання кожного кадру гри, щоб досягти оптимальних характеристик птаха швидше? (Рішення буде представлено у частині цього розділу під однойменною назвою “Прискорення часу”.) Крім того, чи можете ви відобразити додаткову інформацію про статус симуляції, наприклад, кількість активних птахів, які все ще беруть участь у грі, поточне покоління і тривалість життя найкращих пташок?
Вправа 11.3
Щоб не запускати процес нейроеволюції кожного разу з нуля, спробуйте використовувати методи нейронної мережі від ml5.js та її методи save()
і load()
. Як можна додати функціональність, яка зберігає найкращу модель птаха, а також опцію завантаження попередньо збереженої моделі?
Керування нейроеволюцією
Дослідивши нейроеволюцію за допомогою Flappy Bird, я хотів би перенести фокус назад на область моделювання, зокрема до керувальних агентів, представлених у Розділі 5. Що, якби замість того, щоб я диктував правила для алгоритму розрахунку керівної сили, імітоване створіння могло б розвинути власну стратегію? Черпаючи натхнення від Рейнольдса щодо реалістичної й імпровізаційної поведінки, моя мета полягає не в тому, щоб використовувати нейроеволюцію для створення ідеальної істоти, яка може бездоганно виконувати завдання. Натомість я сподіваюся створити захопливий світ симуляції життя, де на полотні розгортаються різні дивацтва, нюанси й щасливі випадковості еволюції.
Я почну з адаптації прикладу розумних ракет з Розділу 9. У цьому прикладі гени для кожної ракети являли собою масив векторів:
this.genes = [];
for (let i = 0; i < lifeSpan; i++) {
this.genes[i] = p5.Vector.random2D();
this.genes[i].mult(random(0, this.maxforce));
Кожен ген є вектором із випадковим напрямком і величиною.
}
Я пропоную адаптувати цей код, щоб натомість використовувати нейронну мережу для прогнозування вектора або сили керування, перетворивши genes
на brain
. Вектори можуть мати безперервний діапазон значень, тому це регресійна задача:
this.brain = ml5.neuralNetwork({
inputs: 2,
outputs: 2,
task: "regression",
neuroEvolution: true,
});
В оригінальному прикладі вектори з масиву genes
застосовувалися послідовно, зчитуючи значення масиву за допомогою змінної counter
:
this.applyForce(this.genes[this.counter]);
Тепер, замість пошуку потрібного значення у масиві, я хочу, щоб нейронна мережа повертала новий вектор для кожного кадру анімації. Для задач регресії з ml5.js вихід нейронної мережі отримується з методу predict()
. І тут я використаю варіант синхронного методу predictSync()
що збереже код простим і дозволить синхронне отримання вихідних даних із моделі у методі ракети run()
:
run() {
let outputs = this.brain.predictSync(inputs);
Отримання вихідних даних нейронної мережі.
let angle = outputs[0].value * TWO_PI;
Використання одного вихідного значення для кута.
let magnitude = outputs[1].value * this.maxforce;
Використання іншого вихідного значення для магнітуди.
let force = p5.Vector.fromAngle(angle)
force.setMag(magnitude);
this.applyForce(force);
Створення і застосовування сили.
}
Мозок нейронної мережі виводить два значення: одне для кута вектор а й одне для його магнітуди. Ви можете подумати, щоб натомість використовувати ці результати для -компонентів вектора. Проте стандартний вихідний діапазон у нейронної мережі ml5.js має значення від 0 до 1, а я хочу, щоб сили були здатні спрямовуватись у будь-якому напрямку, а не лише у додатному. Стандартний вихідний діапазон можна перевести в інший, наприклад можна помножити перший вихід, що використовується для кута на TWO_PI
, що забезпечить повний діапазон кутів.
Визначення вхідних даних для нейронної мережі — це те місце, де ви, як розробник системи, можете бути найбільш креативними. Ви повинні врахувати природу навколишнього середовища і змодельовану біологію та можливості ваших створінь і потім вирішити, які характеристики є найважливішими.
Для першої спроби я призначу для входів щось просте і перевірю, чи це спрацює. Оскільки середовище розумних ракет є статичним, з фіксованими перешкодами й цілями, можливо мозок міг би вивчити та вирахувати поле потоків для навігації до своєї мети? Як я показав у Розділі 5, поле потоків отримує позицію і повертає вектор, тому нейромережа може віддзеркалювати цю функціональність та використовувати як вхідні дані поточні -координати ракети. Мені просто потрібно нормалізувати значення відповідно до розмірів полотна:
let inputs = [this.position.x / width, this.position.y / height];
Це воно! Практично все інше з оригін ального прикладу може залишитися незмінним: популяція, функція оцінювання придатності й процес відбору.
reproduction() {
let nextPopulation = [];
for (let i = 0; i < this.population.length; i++) {
Створення наступної популяції.
let parentA = this.weightedSelection();
let parentB = this.weightedSelection();
let child = parentA.crossover(parentB);
Підхід з колесом фортуни, щоб вибрати двох батьків.
child.mutate(this.mutationRate);
Застосування мутації.
nextPopulation[i] = new Rocket(320, 220, child);
}
this.population = nextPopulation;
Заміна старої популяції.
this.generations++;
}
Зауважте, що тепер, коли я використовую ml5.js, мені більше не потрібен окремий клас DNA
із реалізаціями методів crossover()
і mutate()
. Натомість можна безпосередньо викликати відповідні методи, що вбудовані у ml5.neuralNetwork
.
Вправа 11.4
Керувальна сила, як визначено Рейнольдсом — це різниця між бажаною швидкістю агента і його поточною швидкістю. Як ця еволюційна система може відображати цю методологію? Що, якщо замість того, щоб використовувати лише положення як вхідні дані для нейронної мережі, ви введете поточну швидкість ракети? Ви можете спробувати використати -компоненти або напрямок і магнітуду вектора. Не забудьте нормалізувати ці значення!
Реагування на зміни
У попередньому прикладі середовище було статичним із нерухомою ціллю і перешкодою. Це зробило задачу ракети по знаходженню цілі легкою, достатньо було використати лише положення як вихідні дані. Але що, якби ціль і перешкоди на шляху ракети рухалися? Щоб працювати зі складнішим і мінливішим середовищем, мені потрібно розширити вхідні дані нейронної мережі та розглянути додаткові особливості середовища. Це схоже на те, що я зробив із Flappy Bird, коли визначив ключові дані середовища для керування процесом прийняття рішень птахом.
Я почну з найпростішої версії цього сценарію, майже ідентичної оригінальному прикладу розумних ракет, але без перешкод, і заміною статичну ціль на рухому, що випадково блукає за допомогою шуму Перліна. У цьому прикладі я перейменую клас Rocket
на Creature
і створю клас Glow
подібно до блукача, який представлятиме кулю, що дрейфуватиме на полотні. Уявіть, що ціль створіння — дістатися до джерела світла і танцювати в його сяйливих обіймах так довго, наскільки це буде можливо:
class Glow {
constructor() {
this.xoff = 0;
this.yoff = 1000;
Початкові зміщення для шуму Перліна.
this.position = createVector();
this.r = 24;
}
update() {
this.position.x = noise(this.xoff) * width;
this.position.y = noise(this.yoff) * height;
Призначення позиції відповідно до шуму Перліна.
this.xoff += 0.01;
this.yoff += 0.01;
Рух відбувається залежно від простору шуму Перліна.
}
show() {
stroke(0);
strokeWeight(2);
fill(200);
circle(this.position.x, this.position.y, this.r * 2);
}
}
У процесі прийняття своїх рішень створіння повинно враховувати положення рухомого сяйва як вхідні дані для свого мозку. Однак недостатньо знати лише положення сяйва, ключовою є відносна позиція до самої істоти. Хороший спосіб синтезувати цю інформацію як вхідну характеристику — це обчислити вектор, який вказує від істоти до сяйва. По суті, я переосмислюю метод seek()
з Розділу 5, використовуючи нейронну мережу для оцінки керувальної сили:
seek(target) {
let v = p5.Vector.sub(target.position, this.position);
Обчислення вектора від позиції до цілі.
Це хороший початок, але значення компонентів вектора не підходять до нормалізованого вхідного діапазону. Я міг би поділити v.x
на width
і v.y
на height
, але оскільки моє полотно не ідеально квадратне, це може спотворити дані. Іншим рішенням є нормалізація вектора, але хоча це збереже інформацію про напрямок від створіння до сяйва, це позбавить будь-якої можливості вимірювати відстані. Це також не підійде — якщо створіння знаходиться у центрі сяйва, то має керуватися іншим чином, ніж якби воно було дуже далеко. Як рішення, перед нормалізацією вектора я збережу значення відстані в окремій змінній. Але щоб використати відстань для входу мені все одно потрібно нормалізувати її діапазон. Хоча це не ідеальна нормалізація від 0 до 1, я розділю її на ширину полотна, що забезпечить практичну нормалізацію, яка зберігає відносну магнітуду:
seek(target) {
let v = p5.Vector.sub(target.position, this.position);
let distance = v.mag() / width;
Збереження відстані у змінній і нормалізація значення відповідно до ширини (один вхід).
v.normalize();
Нормалізація вектора, що вказує від позиції до цілі (два входи).
Як ви можете пам’ятати, ключовим елементом керувальної формули Рейнольдса було порівняння бажаної швидкості з поточною. Те, як рухається об’єкт, відіграє важливу роль у тому, як він має керуватись! Щоб створіння розглядало власну швидкість як частину свого рішення, у вхідні дані для нейронної мережі я також можу включити вектор швидкості. Для нормалізації цих значень чудово спрацює розділення значень компонентів вектора на властивість maxspeed
. Це збереже як напрямок, так і відносну величину вектора. Решта методу seek()
дотримується тієї ж логіки, що й у попередньому прикладі, з виходами нейронної мережі, синтезованими в силу, яку потрібно застосувати до створіння:
seek(target) {
let v = p5.Vector.sub(target.position, this.position);
let distance = v.mag() / width;
v.normalize();
let inputs = [
v.x,
v.y,
distance,
this.velocity.x / this.maxspeed,
this.velocity.y / this.maxspeed,
];
Збір важливих характеристик у масив вхідних даних.
let outputs = this.brain.predictSync(inputs);
let angle = outputs[0].value * TWO_PI;
let force = p5.Vector.fromAngle(angle);
let magnitude = outputs[1].value;
force.setMag(magnitude);
Прогнозування сили, яку необхідно застосувати.
this.applyForce(force);
}
Під час переходу від ракет до створінь відбулося багато змін, тому варто також пе реглянути функцію оцінки придатності. Раніше придатність обчислювалася в кінці кожного покоління на основі найкоротшої відстані від ракети до цілі. Оскільки зараз ціль рухається, я вважаю, що кращою оцінкою буде підрахунок кількості часу, протягом якого створіння здатне вловлювати об’єкт сяйва. Це можна зробити, перевіряючи відстань між створінням і сяйвом у методі update()
та збільшуючи значення fitness
, коли вони перетинаються:
update(target) {
/* Звичайне оновлення положення, швидкості, прискорення */
let d = p5.Vector.dist(this.position, target.position);
if (d < this.r + target.r) {
this.fitness++;
}
Збільшення значення придатності кожного разу, коли створіння достатньо близько біля сяйва.
}
Обидва класи Glow
і Creature
мають властивість для радіуса — r
, яку я використовую для визначення перетину.
Прискорення часу
Одна річ, яку ви могли помітити стосовно еволюційних обчислень, полягає в тому, що тестування коду є чудовою вправою на терпіння. Ви повинні спостерігати, як симуляція повільно розвивається покоління за поколінням. Це частина суті — я хочу спостерігати за процесом! Це також гарний привід для перерви, яку іноді слід робити. Вийдіть на вулицю і насолодіться деякий час неімітованою природою або, можливо, випийте заспокійливу чашку чаю. Потім перевірте свої створіння і подивіться на їх прогрес. Заспокойте себе тим, що вам доведеться чекати лише мільйони мілісекунд, а не мільйони років, необхідних для справжньої біологічної еволюції.
Проте для еволюціонування симуляції немає обов’язкової вимоги, щоб ви малювали й анімували розвиток кожного покоління. Сотні поколінь можна було б завершити миттєво, якби ви могли пропустити весь час, витрачений на рендеринг сцени. Або натомість ви можете відтворювати його набагато рідше. Це позбавить вас від зайвої дратівливості щоразу, коли ви змінюєте невеликий параметр, і чекаєте, здається, години, щоб побачити, чи вплинула ця зміна на еволюцію системи.
Тут я можу використати одну з моїх улюблених функцій p5.js: можливість швидко створювати стандартні елементи інтерфейсу. Ви бачили це раніше у прикладі 9.4 з функцією createButton()
. Цього разу я створю повзунок для контролю кількості ітерацій циклуfor
, який виконується всередині функції draw()
. Цикл for
міститиме код для оновлення симуляції (але не малювання). Чим більше разів повторюється цикл, тим швидшою буде анімація.
Ось код для доданого повзунка часу, виключаючи всі інші глобальні змінні та їх ініціалізацію у функції setup()
. Зверніть увагу, що код для візуальних елементів відокремлений від коду симуляції фізики, щоб гарантувати, що сама візуалізація в ідбувається лише один раз на кадр draw()
:
let timeSlider;
Змінна для повзунка.
function setup() {
timeSlider = createSlider(1, 20, 1);
Створення повзунка із мінімальним і максимальним діапазоном та початковим значенням.
}
function draw() {
background(255);
glow.show();
for (let creature of creatures) {
creature.show();
}
Код для малювання відбувається лише один раз на кадр!
for (let i = 0; i < timeSlider.value(); i++) {
for (let creature of creatures) {
creature.seek(glow);
creature.update(glow);
}
glow.update();
lifeCounter++;
}
Код симуляції виконується кілька разів на кадр відповідно до значення повзунка.
}
У p5.js повзунок визначається трьома аргументами: мінімальне значення (коли повзунок знаходиться повністю зліва), максимальне значення (коли повзунок знаходиться справа) і початкове значення (де буде повзунок на початок запуску програми). У цьому випадку повзунок дозволяє запустити симуляцію з 20-кратною швидкістю, щоб швидше досягти результатів еволюції, а потім уповільнити її до 1-кратної швидкості, щоб насолодитися славою розвиненої поведінки на екрані.
О сь остаточна версія прикладу з новим конструктором класу Creature
для створення нейронної мережі. Усе інше, пов’язане із застосуванням кроків ГА, залишилося таким самим як у прикладі коду Flappy Bird.
class Creature {
constructor(x, y, brain) {
this.position = createVector(x, y);
this.velocity = createVector(0, 0);
this.acceleration = createVector(0, 0);
this.r = 4;
this.maxspeed = 4;
this.fitness = 0;
if (brain) {
this.brain = brain;
} else {
this.brain = ml5.neuralNetwork({
inputs: 5,
outputs: 2,
task: "regression",
neuroEvolution: true,
});
}
}
/* Метод seek() прогнозує керувальну силу так само як і раніше. *
/* Метод update() збільшує оцінку придатності так само як і раніше. */
}
Важко повірити, але ця книга була подорожжю, яка тривала понад 10 років. Дякую тобі, любий читачу, що долучився до неї. Я обіцяю, що це не безкінечний цикл. Яким би звивистим не здавався цей шлях, наче прогулянка з випадковим блуканням, я нарешті використаю керувальну поведінку наближення, щоб дістатися до останнього фрагмента головоломки — спроби об’єднати всі мої попередні дослідження у власній версії “Проєкту екосистеми”.
Нейроеволюційна екосистема
Кілька моментів із прикладів цього розділу не зовсім узгоджуються з моєю фантазією про моделювання природної екосистеми. Перший з них стосується проблеми, яку я підняв у Розділі 9, коли розбирався з блупами. Ми розглядали систему створінь, які одночасно живуть і так само одночасно вмирають, повністю відроджуючись у кожному наступному поколінні, але реальний біологічний світ влаштований не так! Я хотів би повернутися до цієї дилеми в контексті нейроеволюції цього розділу.
Другий, і мабуть, важливіший серйозний недолік у тому, як я вибираю необхідні характеристики для навчання моделі. Створіння у прикладі 11.4 є всезнайками. Звісно, розумно зробити припущення, що істота знає свою власну поточну швидкість, але я також дозволив кожному створінню знати й точне місце розташування сяйва, незалежно від того, наскільки далеко воно знаходиться чи у який бік спрямовано саме створіння та незалежно від того, що його зір або інші сенсори можуть бути частково заблоковані. Це суперечить одному з основних принципів автономних агентів, які я представив у Розділі 5, що агент повинен мати обмежену здатність сприйняття свого оточення.
Сприйняття навколишнього середовища
Загальний підхід до моделювання реальної істоти (або робота), що матиме обмежену обізнаність свого оточення, полягає в тому, щоб прикріпити до такого агента датчики. Згадайте мишу у лабіринті з початку розділу, а тепер уявіть, що їй доводиться орієнтуватися у подібному лабіринті в темряві. Її вуса можуть працювати як датчики наближення, щоб виявляти стіни й повороти. Мишачі вуса не бачать весь л абіринт, а відчувають лише найближче оточення. Ще одним прикладом з сенсорами є кажан, який використовує ехолокацію для навігації, або автомобіль на звивистій дорозі, де водій бачить лише те, що освітлюється перед фарами машини.
Я хотів би зупинитися на ідеї про вуса (або більш формально вібриси), які є у мишей, котів та інших ссавців. У реальному світі тварини використовують свої вібриси, щоб орієнтуватися і виявляти об’єкти поблизу, особливо в темряві або затемненому середовищі (див. малюнок 11.5). Як я можу прикріпити датчики, схожі на вуса, до моїх нейроеволюційних створінь, що займаються пошуками?

Я збережу загальну назву класу Creature
, але буду думати про нього, як про амебоподібного блупа з Розділу 9, доповненого датчиками схожими на вуса, які виходять із центру тіла в усіх напрямках:
class Creature {
constructor(x, y) {
this.position = createVector(x, y);
this.r = 16;
Створіння має позицію і радіус.
this.sensors = [];
Створіння матиме набір датчиків.
let totalSensors = 8;
for (let i = 0; i < totalSensors; i++) {
Кількість датчиків.
let angle = map(i, 0, totalSensors, 0, TWO_PI);
Спочатку розраховується напрямок для датчика.
this.sensors[i] = p5.Vector.fromAngle(angle);
this.sensors[i].setMag(this.r * 1.5);
Створення вектора для датчика. трохи довшого за радіус створіння.
}
}
}
Код створює серію векторів, кожен з яких описує напрямок й довжину для окремих датчиків вусів, прикріплених д о істоти. Однак просто вектора недостатньо. Я хочу, щоб датчик містив значення value
— числове представлення того, що він відчуває. Це value
можна розглядати як аналог сили дотику. Подібно до того, як вуса кота Клавдіуса можуть відчути слабкий дотик від віддаленого об’єкта або сильніший тиск від ближчого, значення віртуального датчика може змінюватися для оцінки дальності об’єкта.
Перш ніж піти далі, мені потрібно дати створінням щось відчути. Як щодо класу Food
, що описуватиме смаколик у вигляді круга, яке створіння бажає знайти? Кожен об’єкт Food
матиме позицію і радіус:
class Food {
constructor() {
this.position = createVector(random(width), random(height));
this.r = 50;
}
Шматок їжі має випадкове пол оження і фіксований радіус.
show() {
noStroke();
fill(0, 100);
circle(this.position.x, this.position.y, this.r * 2);
}
}
Як я можу визначити, що сенсор істоти торкається їжі? Одним із підходів може бути використання підходу рейкастингу (кидання променів). Цей підхід зазвичай використовується у комп’ютерній графіці для проєктування п рямих ліній (часто представляють промені світла) від певної точки сцени, щоб визначити з якими об’єктами ці промені перетинаються. Рейкастинг корисний для перевірки області видимості та зіткнень, саме те, що мені потрібно!
Хоча рейкастинг забезпечить надійне рішення, він потребує більше математики, ніж я хотів би зараз використати. Для тих, хто зацікавлений, відповідні пояснення і реалізацію можна переглянути у відео Coding Challenge #145 на вебсайті Coding Train. Для цього прикладу я виберу простіший підхід і перевірю, чи кінцева точка датчика лежить усередині об’єкта їжі (див. малюнок 11.6)

Оскільки я хочу, щоб датчик зберігав значення для свого вимірювання разом з алгоритмом вимірювання, є сенс інкапсулювати ці елементи у класі Sensor
:
class Sensor {
constructor(v) {
this.v = v.copy();
this.value = 0;
Датчик зберігає значення близькості до того об’єкта, який він відчуває.
}
sense(position, food) {
let end = p5.Vector.add(position, this.v);
Знаходження зовнішнього кінчика датчика.
let d = end.dist(food.position);
Яка відстань до центру їжі?
if (d < food.r) {
Якщо датчик знаходиться у радіусі їжі, він вмикається.
this.value = map(d, 0, food.r, 1, 0);
Чим ближче до центру їжі, тим сильніше активується датчик.
} else {
this.value = 0;
}
}
}
Зауважте, що сенсорний механізм вимірює глибину свого кінчика всередині їжі за допомогою функції map()
. Якщо датчик знаходиться поза межами їжі або торкається тільки її зовнішнього краю, то значення value
дорівнює 0. Коли кінчик датчика знаходиться в межах їжі й наближається до її центру, то value
поступово збільшується від 0 на краях до максимального значення 1 у центрі. Цей градієнт значень відображатиме інтенсивність дотику чи тиску з реального світу.
Перевірмо цей сенсорний механізм на простому прикладі з одним блупом (керованим курсором) і одним шматком їжі (розміщеним у центрі полотна). Коли датчики торкаються їжі, вони світяться і стають яскравішими, коли наближаються до її центру.
let bloop, food;
function setup() {
createCanvas(640, 240);
bloop = new Creature();
food = new Food();
Один блуп і один шматок їжі.
}
function draw() {
background(255);
bloop.position.x = mouseX;
bloop.position.y = mouseY;
Положення блупа керується за допомогою курсора.
food.show();
bloop.show();
Малювання їжі та блупа.
bloop.sense(food);
Блуп намагається відчути їжу.
}
class Creature {
constructor(x, y) {
this.position = createVector(x, y);
this.r = 16;
this.sensors = [];
Створення масиву для сенсорів створіння.
let totalSensors = 15;
for (let i = 0; i < totalSensors; i++) {
let a = map(i, 0, totalSensors, 0, TWO_PI);
let v = p5.Vector.fromAngle(a);
v.mult(this.r * 2);
this.sensors[i] = new Sensor(v);
}
Кількість датчиків. Як щодо 15?
}
sense(food) {
for (let sensor of this.sensors) {
sensor.sense(this.position, food);
}
Виклик методу sense() для кожного датчика.
}
show() {
push();
translate(this.position.x, this.position.y);
for (let sensor of this.sensors) {
stroke(0);
line(0, 0, sensor.v.x, sensor.v.y);
if (sensor.value > 0) {
fill(255, sensor.value * 255);
stroke(0, 100)
circle(sensor.v.x, sensor.v.y, 8);
}
}
noStroke();
fill(0);
circle(0, 0, this.r * 2);
pop();
}
Малювання створіння і всіх його датчиків.
}
У прикладі датчики істоти намальовані лініями від центру блупа. Коли датчик щось виявляє (коли value
більше 0), у нього на кінці з’являється кружечок. Щоб візуалізувати силу показань датчика, я використовую значення value
для задання прозорості цього кружечка.
Навчання від сенсорів
Ви думаєте про те ж саме, що і я? Що, якщо значення сенсорів істоти будуть вхідними даними для нейронної мережі? Якщо припустити, що я знову надаю створінням контроль над їхніми власними рухами, то я міг би написати новий метод think()
, який обробляє значення датчиків у мозку нейронної мережі та видає керувальну силу, як у двох останніх прикладах з керуванням:
think() {
let inputs = [];
for (let i = 0; i < this.sensors.length; i++) {
inputs[i] = this.sensors[i].value;
}
Підготовка вхідного масиву зі значеннями сенсорів.
let outputs = this.brain.predictSync(inputs);
let angle = outputs[0].value * TWO_PI;
let magnitude = outputs[1].value;
let force = p5.Vector.fromAngle(angle)
force.setMag(magnitude);
this.applyForce(force);
Прогнозування керувальної сили на основі показників сенсорів.
}
Наступним логічним кроком могло бути об’єднання всіх звичайних частин ГА, написання оцінювальної функції придатності (скільки їжі з’їдала кожна істота?) і виконання відбору після фіксованого періоду часу для покоління. Але це чудова можливість переглянути принципи безперервної екосистеми й націлитись до формування складнішого середовища та набору потенційних способів поведінки для самих істот. Замість фіксованої тривалості життя для кожного покоління, я використаю для кожного створіння властивість health
з Розділу 9. З кожним кадром анімації, при виконанні функції draw()
, рівень здоров’я істоти дещо зменшуватиметься:
class Creature {
constructor() {
/* Усі інші властивості створіння */
this.health = 100;
Рівень здоров’я починається зі значення 100.
}
update() {
/* Звичайне оновлення положення, швидкості, прискорення */
this.health -= 0.25;
Часткове зменшення здоров’я!
}
Якщо під час виконання функції draw()
здоров’я будь-якого блупу падає нижче 0, то він помирає і видаляється з масиву bloops
. А для розмноження, замість того, щоб виконувати звичайне схрещування і мутацію відразу, кожен блуп (зі здоров’ям більшим за 0) матиме шанс на відтворення рівний 0.1 відсотка:
function draw() {
for (let i = bloops.length - 1; i >= 0; i--) {
if (bloops[i].health < 0) {
bloops.splice(i, 1);
} else if (random(1) < 0.001) {
let child = bloops[i].reproduce();
bloops.push(child);
}
}
}
У методі reproduce()
я буду використовувати метод copy()
(клонування) замість методу crossover()
(спарювання), з вищою, ніж зазвичай, частотою мутацій, щоб покращити рівень варіативності. Я раджу вам також розглянути варіант підходу зі схрещуванням. Ось код:
reproduce() {
let brain = this.brain.copy();
brain.mutate(0.1);
Копіювання і мутація, замість схрещування і мутації.
return new Creature(this.position.x, this.position.y, brain);
}
Щоб це спрацювало, деякі блупи повинні жити довше, ніж інші. Споживаючи їжу, їхнє здоров’я покращується, даючи їм додатковий час для розмноження. Я керуватиму цим процесом у класі Creature
за допомогою методу eat()
:
eat(food) {
let d = p5.Vector.dist(this.position, food.position);
if (d < this.r + food.r) {
this.health += 0.5;
}
Якщо блуп знаходиться поруч з їжею, його здоров’я збільшується.
}
Чи достатньо цього, щоб система розвивалася і знаходилась у рівновазі? Я міг би зануритися у цю тему ще глибше, налаштовуючи параметри й поведінку в пошуках остаточної еволюційної системи. Я досі не можу так просто позбутися чарівності цієї нескінченної кролячої нори, але досліджу її вже у свій вільний час. Для цілей цієї книги я пропоную вам запустити отриманий приклад, поекспериментувати та зробити власні висновки.
let bloops = [];
let food = [];
function setup() {
createCanvas(640, 240);
for (let i = 0; i < 20; i++) {
bloops[i] = new Creature(random(width), random(height));
}
for (let i = 0; i < 8; i++) {
food[i] = new Food();
}
}
function draw() {
background(255);
for (let i = bloops.length - 1; i >= 0; i--) {
bloops[i].think();
bloops[i].eat();
bloops[i].update();
bloops[i].borders();
if (bloops[i].health < 0) {
bloops.splice(i, 1);
} else if (random(1) < 0.001) {
let child = bloops[i].reproduce();
bloops.push(child);
}
}
for (let treat of food) {
treat.show();
}
for (let bloop of bloops) {
bloop.show();
}
}
Останній приклад також містить кілька додаткових особливостей, які ви знайдете у супровідному онлайн-коді, як-от масив їжі, яка зменшує свій розмір, коли її поступово з’їдають (відновлюється після вичерпання). Крім того, коли у блупів погіршується здоров’я вони стають прозорішими, аж поки не зникнуть, якщо не встигнуть вчасно підкріпитись.
Проєкт “Екосистема”
Спробуйте включити у створіння вашого світу концепцію мозку!
- Чи можуть різні створіння мати різні цілі й стимули? Щоб одні шукали їжу, а другі якісь інші ресурси? А як щодо істот, які уникають небезпек, таких як хижаки чи отруйні об’єкти?
- Які входи та виходи мають бути для кожного створіння?
- Яка у створінь сенсорика? Чи вони можуть бачити усе, чи мають обмеження на основі своїх сенсорів?
- Які стратегії ви можете використовувати для встановлення і підтримки балансу у вашій екосистемі?

Кінець
Якщо ви ще читаєте, мої вітання! Ви дійшли до кінця книги. Але попри кількість матеріалу, яку містить ця книжка, я лише заледве торкнувся поверхні фізичного світу, в якому ми живемо, і методів його моделювання. Я маю намір, щоб ця книга існувала як постійний проєкт і я сподіваюся продовжувати додавати нові уроки й приклади на вебсайті книги, а також розширювати та оновлювати супровідні відеоуроки на вебсайті Coding Train.
Ваші відгуки є дуже цінними, тому можете надіслати їх за електронною поштою daniel@shiffman.net або зробити свій внесок у GitHub-репозиторій проєкту, відповідно до духу проєктів з відкритим кодом. Діліться своїми роботами. Будьмо на зв'язку і будьмо з природою.
