Давайте рассмотрим наипростейшую модель естественного отбора. В сети встречал модель с двумя параметрами-генами, а у нас будет всего один, при сохранении наглядности. Модель настолько элементарна, что её можно обсудить даже со своим ребёнком (проверил со своей шестилетней дочкой).

NB: Весь код в статье интерактивный, кликайте, чтобы открыть, запустить, попробовать свои идеи сразу на ходу. Используется Python + p5py, который разрабатывался для книги для детей, преподавания в Универе, детских кружках и школе.

Внимание: 21 гифка, 29 фрагментов кода и 12 ссылок на запускаемый код.

Прост, как localhost

f1bd0c15cbe74b7c18db4b4fdee8bf91

У нас будет гордый птыц со всего одним геном, который хранит направление полёта из гнезда к еде. Альфа-ген. Птыц летит куда-то в соответствии со своим геном направления и каждый ход тратит на это энергию.

Если птыц добудет еду, он:

  • восстановит свою энергию;

  • вернётся в гнездо;

  • и там даст два потомка, передав свой ген с мутациями.

Если не добудет, то просто отразится от стенок и полетит назад. Когда энергия закончится, он умрёт. Гнездо — в центре экрана. Еда спавнится где повезёт.

Часть первая. Подготовка

Итак, нужна птица, направление и факт полёта. Для начала сама птица:

d2102a944f6ee096fcd516924edb6cc0
bird_position = Vector(width / 2, height / 2)

И её отображение:

def display_bird():
    text_size(140)
    text_align(CENTER, CENTER)
    text("?", bird_position.x, bird_position.y)

Всё вместе (чтобы можно было запустить):

from p5py import *
import random

run()

# Устанавливаем размеры окна
size(400, 400)

# Инициализация позиции птицы
bird_position = Vector(width / 2, height / 2)  

# Функция для отображения птицы
def display_bird():
    text_size(140)
    text_align(CENTER, CENTER)
    text("?", bird_position.x, bird_position.y)

# Вечный игровой "цикл"
def draw():
    background(30, 30, 40)  # Устанавливаем цвет фона
    display_bird()  # Отображаем птицу

Код — нажмите, чтобы запустить сразу в браузере.

Поменяйте птицу на что-нибудь забавное. Можно исследовать не только в браузере. Модуль также доступен как p5 для VS Code, но в нём не реализовано отображение эмоджи, поэтому придётся поменять на ellipse(x, y, radius).

Теперь направление. Единственный ген птицы — это угол полёта. Он будет наследоваться и иногда мутировать.

bird_angle = random.uniform(-PI, PI)

И скорость:

bird_speed = 4

Клюв поверни, и полетели...

def move_bird():
    # Обновляем координаты птицы в зависимости от её скорости и угла
    bird_position.x += bird_speed * cos(bird_angle)  
    bird_position.y += bird_speed * sin(bird_angle)

Всё вместе:

55ec39db0470edf07762a76fd2265a03
from p5py import *
import random

run()

size(400, 400)

bird_position = Vector(width / 2, height / 2)
bird_angle = random.uniform(-PI, PI)
bird_speed = 4

def move_bird():
    bird_position.x += bird_speed * cos(bird_angle)
    bird_position.y += bird_speed * sin(bird_angle)

def display_bird():
    text_size(140)
    text_align(CENTER, CENTER)
    text("?", bird_position.x, bird_position.y)

Запускаем

bear
Не-не-не, я, пожалуй, пасс..

Запустить

Где еда, Лебовски?!

Чтобы еда не спавнилась на курице, используем полярные координаты.

food_angle = rand(-PI, PI)
food_distance = 3 * width / 5
food_position = Vector(width / 2 + food_distance * cos(food_angle), height / 2 + food_distance * sin(food_angle))
food_size = 140

def display_food():
    text_size(food_size)
    text_align(CENTER, CENTER)
    text("?", food_position.x, food_position.y)
99e576f3e08a1feef8717bb8f082f4cd

Запустить

Возвращайся, сделав круг

Пусть птица возвращается, если долетела до края экрана или до еды.

b211736a13caa5dcee6a5ec4168eabdc

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

Сначала просто возвращение:

def move_bird():
    global bird_angle 
    bird_position.x += bird_speed * cos(bird_angle)
    bird_position.y += bird_speed * sin(bird_angle)

    # Проверка на достижение края экрана
    if (bird_position.x < 0 or bird_position.x > width or 
        bird_position.y < 0 or bird_position.y > height):
        # Пусть летит в центр, инвертируем угол
        invert_angle()

    # Проверка на достижение пищи
    if (dist(bird_position.x, bird_position.y, food_position.x, food_position.y) < food_size / 2):
        invert_angle()

Запустить

Классы

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

class Bird:
    def __init__(self, x, y, speed):
        self.position = Vector(x, y)
        self.angle = rand(-PI, PI)
        self.speed = speed
    
    def move(self):
        self.position.x += self.speed * cos(self.angle)
        self.position.y += self.speed * sin(self.angle)

        if (self.position.x < 0 or self.position.x > width or 
            self.position.y < 0 or self.position.y > height):
            self.invert_angle()

        if (dist(self.position.x, self.position.y, food_position.x, food_position.y) < food_size / 2):
            self.invert_angle()                

    def invert_angle(self):    
        self.angle = self.angle + PI

    def display(self):
        text_size(140)
        text_align(CENTER, CENTER)
        text("?", self.position.x, self.position.y)

bird = Bird(width / 2, height / 2, 4)

Код

Энергия

Пусть у птицы будет энергия, которая уменьшается ход за ходом. Если птица нашла еду, то энергия восстанавливается.

INITIAL_ENERGY = 700  # Начальное количество энергии у птиц

А при движении энергия уменьшается.

def move(self):
    if not self.alive:
        return
            
    # Тратим энергию
    self.energy -= self.speed
        
    # Проверка, осталась ли энергия
    if self.energy <= 0:
        self.alive = False
        return

Заодно добавили атрибут alive — жива ещё старушка или уже нет. Прозрачностью покажем, что жизнь на исходе и пора сожалеть об упущенных возможностях.

def display(self):
    text_size(140)
    text_align(CENTER, CENTER)
    fill(255, lerp(self.energy, Bird.INITIAL_ENERGY, 0, 255, 0))
    text("?", self.position.x, self.position.y)

Но применение прозрачности к эмоджи — ненадёжное дело: где-то работает, а где-то нет. Если что, можно заменить text() на простой ellipse(x, y, r). lerp() из p5.js (и p5py) соотносит один интервал с другим — масштабирует. Энергия self.energy в интервале от 0 до максимальной будет масштабирована так, чтобы уместиться в диапазон от 0 до 255.

А если пернатое коснулось еды, то энергия восстановилась, птица ягодка опять.

if (dist(self.position.x, self.position.y, food_position.x, food_position.y) < food_size / 2):
    self.invert_angle()           
    self.energy = Bird.INITIAL_ENERGY
54344b4e7136be12c8e7e6074a378316

Запускаем

Размножение

Прежде чем перейти к размножению, наплодим хотя бы сотню птиц.

f9564623180a033e4eb01b9dc9359a73

Видно, как быстро вымирают те, кому «повезло» родиться с геном угла, не совпадающим с направлением на еду:

# Параметры симуляции
NUM_BIRDS = 100

# Создаем массив из птиц
birds = [Bird(Vector(width / 2, height / 2), rand(-PI, PI)) for _ in range(NUM_BIRDS)]

def draw():
    background(30, 30, 40)
    display_food()
    
    for bird in birds:
        bird.move()
        bird.display()

Запустить код

Введём признак того, что птица накушалась и летит в гнездо размножаться:

self.is_returning = False

И главный фактор для нас, вероятность мутации гена:

MUTATION_RATE = 0.2  # Вероятность мутации направления у потомков

Добавим метод репродукции:

def invert_angle(self):    
        self.angle = self.angle + PI

    def update(self):
        self.move()
        self.display()

        # Проверяем, находится ли птица в круге и возвращается ли она
        center_position = Vector(width / 2, height / 2)  # Центр экрана
        if self.is_returning and self.position.dist(center_position) < 5:  # - радиус круга размножения
            self.is_returning = False
            return self.reproduce()
        return []

    def reproduce(self):
        new_birds = []
        self.invert_angle()
        if rand(0, 1) < MUTATION_RATE: 
            angle = rand(-PI, PI)  # Большая вариация угла
        else:
            angle = self.angle + rand(-0.1, 0.1) # Небольшая вариация угла
        new_birds.append(Bird(Vector(200, 200), angle))
        new_birds.append(Bird(Vector(200, 200), angle))
        return new_birds

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

Сначала все птицы гордо разлетаются из гнезда в разные стороны:

708d3812d59e3c4e632d8649e5391324

Но потом вымирают те, кому не повезло с геном и которые не нашли еды:

c96378e8ce9a565455a685520eb86cea

А кто еду нашёл, тот молодец. Притащил её в гнездо и произвёл потомков, у которых скопировался родительский ген.

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

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

Запустить код

Изменчивый мир. Стоит ли прогибаться?

Наступает самое интересное! Окружающая среда меняется: через какой-то интервал еда появится в новом месте (старую съели).

Пространство для экспериментов. Сейчас не реализовано, но можно уменьшать размер еды, когда птицы её растаскивают. И посмотреть, как такое изменение окружающей среды скажется на популяции.

596efdd605a950e29ab2fc9dfb1d29ed
FOOD_CHANGE_INTERVAL = 250  # Интервал изменения позиции еды

Для удобства перенесём работу с едой в класс:

class Food:
    def __init__(self):
        self.size = 140
        self.get_random_position()

    def get_random_position(self):
        food_angle = rand(-PI, PI)
        food_distance = 3 * width / 5
        self.position = Vector(width / 2 + food_distance * cos(food_angle), height / 2 + food_distance * sin(food_angle))

    def display(self):
        text_size(self.size)
        text_align(CENTER, CENTER)
        fill(255)
        text("?", self.position.x, self.position.y)

Запускайте наш главный код здесь и экспериментируйте.

Первым делом — баголёты

Сейчас есть смешной баг: NPC застревают в текстурах. Если еда появляется там, где уже есть птицы, то они начинают дёргаться почти на одном месте из-за того, что при смене угла (чтобы вернуться в гнездо) они не успевают улететь за пределы еды.

1838d0bedc3089427071fb0ed68030e1

Как поправить? Будем менять направление только если птица не возвращается.

Часть вторая. Эксперименты

Главный код

Как действует естественный отбор. Вариант первый: мутаций нет

# Параметры симуляции
NUM_BIRDS = 100
MUTATION_RATE = 0.0  # Вероятность мутации направления у потомков
FOOD_CHANGE_INTERVAL = 250  # Интервал изменения позиции еды
BIRD_SPEED = 5
  1. Новые поколения находят еду, адаптируются, успешно размножаются.

  2. Но потомки почти полностью копируют родителей, без отклонений. Они не экспериментируют и не ищут.

  3. Окружающая среда меняется. Еда теперь в другом месте. Потомки не могут её найти.

  4. Все умирают.

f7e7fcea8902e1027180923c62d9cf1b
b4a8026be28879b068a493cdc39ba352

Вариант второй: мутаций слишком много

# Параметры симуляции
NUM_BIRDS = 100
MUTATION_RATE = 1.0  # Вероятность мутации направления у потомков
FOOD_CHANGE_INTERVAL = 250  # Интервал изменения позиции еды
BIRD_SPEED = 5
  1. Дети всё время не похожи на родителей.

  2. В итоге найденная родителями приспособленность к окружающим условиям игнорируется всеми детьми. Они ищут только своё. Часто не находят.

  3. Потомки не успевают достаточно размножиться, ведь не используют «знания» родителей.

  4. Все умерли.

67d9df7e99baf4e27329fee57f2e463f
f5eca20ffef4b5a57e1265c329a60f1d

Вариант третий: нужное для адаптации количество мутаций

# Параметры симуляции
NUM_BIRDS = 100
MUTATION_RATE = 0.3  # Вероятность мутации направления у потомков
FOOD_CHANGE_INTERVAL = 250  # Интервал изменения позиции еды
BIRD_SPEED = 5
  1. Часть потомков пользуется находкой родителей и летят за уже разведанной едой, отчего быстро размножаются.

  2. Другая часть потомков (30 %) занята исследованием нового пространства. В случае смены среды им удаётся найти еду и продолжить род.

  3. Количество птиц резко увеличивается.

  4. Популяция выживает в данных условиях.

b911eeef74494591b9ae8b4c74ef6742
27e5a31aa629c3593dde8042c49439d9

Выводы

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

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

Недостатки симуляции

  1. Случайно еда может повторно оказаться на том же месте, что драматически увеличивает популяцию. Лучше бы сделать более-менее предсказуемое изменение среды. Например, вращение еды по кругу с прогнозируемыми промежутками. Сейчас проверим.

  2. Скорость p5py при большом количестве птиц неприемлемо низкая. Нужно или переписать на JS, или ускорить сам модуль. Вы можете использовать черновую версию похожего кода на Processing. Она на порядок быстрее.

  3. Можно добавить ограничение, чтобы больше XXX птиц не появлялось.

Часть третья. Более наглядные эксперименты

Если вы будете играть с предыдущим кодом, то поймёте, что из-за слишком больших случайностей в появлении еды общую закономерность иногда сложно заметить, и вероятность мутаций в 100 % часто прекрасным образом себя чувствует. Поэтому давайте упростим и сделаем более наглядный вариант. Вот готовый код. Его будем использовать в экспериментах дальше.

Еда теперь бегает по кругу:

class Food:
    def __init__(self):
        self.size = FOOD_SIZE
        self.angle = 0
        self.radius = width / 2  # Фиксированное расстояние
        self.speed = 0.002 # Скорость вращения

    def move(self):
        self.angle += self.speed
        self.position = Vector(width / 2 + self.radius * cos(self.angle), 
                               height / 2 + self.radius * sin(self.angle))
    
    def display(self):
        text_size(self.size)
        text_align(CENTER, CENTER)
        fill(255)
        text("?", self.position.x, self.position.y)

И репродукция упростилась: вместо охвата всех 360 градусов мы будем просто задавать диапазон мутации:

ANGLE_MUTATION_RANGE = 0.8  # Диапазон изменения угла

Вот так:

def reproduce(self):
        if len(birds) >= MAX_BIRDS:
            return []  # Если количество птиц достигло максимума, не создаваем новых
        
        new_birds = []
        self.invert_angle()
        angle = self.angle + rand(-ANGLE_MUTATION_RANGE, ANGLE_MUTATION_RANGE) 
        new_birds.append(Bird(Vector(200, 200), angle))
        new_birds.append(Bird(Vector(200, 200), angle))
        return new_birds

Теперь крайние варианты и средний оптимальный видны очень отчетливо.

Первый вариант — мутаций нет:

ANGLE_MUTATION_RANGE = 0.0  # Диапазон изменения угла
7a45ada86ca49420a5bae40423228f6c

Ха-ха, не успели. Пытаются найти еду, где её уже нет.

Второй вариант — сильные мутации:

ANGLE_MUTATION_RANGE = 1.0  # Диапазон изменения угла
355d08f7c811f5f85fe7f2fa5cca0b0c

Ищут не там и промахиваются. Слишком большой разброс.

Вариант третий — мутации ближе к оптимальным:

ANGLE_MUTATION_RANGE = 0.3  # Диапазон изменения угла
ca2e98634144a997d208b2fc24c18f7a

И через некоторое время можно, открыв птицефабрику, написать в «Упал, поднялся»:

7a0fbb120e4e0c1e6fbe36021916da79

Если хотите экспериментировать

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

Птичку жалко. Добавим интерактивность

Бездушный чёрствый ломоть хлеба сейчас перемещается сам, но мы можем, поменяв пару строк, управлять им мышкой. Проведем такую замену в классе Food:

def move(self):
    self.position = Vector(mouse_x, mouse_y)
    # self.angle += self.speed
    # self.position = Vector(width / 2 + self.radius * cos(self.angle), 
    #                        height / 2 + self.radius * sin(self.angle))

Интерактивный код

Занятия с детьми

Я стараюсь дочке рассказывать и объяснять, чем занимаюсь и как что работает. Так и эта статья не прошла мимо. Вкратце, в этой модели получилась интересная педагогическая составляющая: если дети слишком сильно похожи на родителей (как некоторые родители требуют от детей: «прекрати рисовать, будь бухгалтером, как я»), то такая популяция слабо адаптивна и вымирает. Если же у нас другая крайность, когда дети уходят в полный отрыв от родителей так, что вообще ничему у них не учатся (крайняя степень конфликта «отцы и дети»), то и такая популяция вымирает, так как дети не используют находки и адаптивную приспособленность родителей. Для популяции идеален поиск среднего (aurea mediocritas), где дети учатся у родителей, но идут по жизни своим независимым путем.

Итак

Мы с вами написали самую простую визуализацию эволюции и естественного отбора всего с одним геном.

О важности девиации. Птицы с геном направления (угла) летят к еде и возвращаются, чтобы дать потомство. Если нет разнообразия, то при смещении еды все погибают, так как улетают в поисках и не могут вернуться. А если есть разнообразие, то отдельные девианты обязательно найдут еду и вернутся, оставив потомство с новым углом поиска, и из девиантов станут основным новым «видом» потомства.

Если вам понравилось быстро тестировать гипотезы в браузере, вот ещё статьи про p5py:

Модуль p5py в бета-версии, узнать новости, обсудить ошибки, идеи и свои работы можно в общей группе в Telegram: @p5py_ru

Комментарии (4)


  1. logofios
    03.12.2024 15:03

    Очень наглядно, любопытно. Интересно, что когда еда перемещается, то от переданного гена словно и нет никакой пользы, т.к. пользы от него нет для потомства


    1. PashaWNN
      03.12.2024 15:03

      Если еда перемещается постепенно, то польза таки есть. Так и в природе работает: внешняя среда редко меняется внезапно. Например, климатические изменения в целом очень неспешные, а живые существа адаптируются к ним за счёт описанных в статье принципов.


  1. Sly_tom_cat
    03.12.2024 15:03

    Тут уже была статья про защиту дамы с косой (мол смертность от возраста дает преимущества) так там тоже питон и мизерныые популяции на довольно коротком интервале времени + мутный подбор параметров привели автора к совершенно неправильному выводу.

    Переписала на golang, прогнал на много тысячных популяциях и десятках тысяч циклов/лет и с дополнительными хромосомами и с вероятностью размножения и прочими разными бантиками - только на крайне низкой популяции смертные начинают спорить с бессмертными, а чуть побольше - ни ни какими параметрами не добиться хотя бы паритета - бессмертные рвут смертных как тузик грелку.

    Так что переписать с питона поделку на коленке на чем-то более серьезном - всегда бывает полезно.


  1. danielsedoff
    03.12.2024 15:03

    Вот статья так статья, есть с чем поиграть/воспроизвести и на что поглазеть.