Статьи

Биологическое компьютерное моделирование эгоистичных генов

TL; DR: я сделал компьютерную симуляцию эволюции поведения обезьян, продолжаю читать, чтобы увидеть, как эта проблема была изначально сформулирована в «Эгоистичном гене» . Первая часть показывает мою реализацию Java , вторая часть отображает результаты и выводы.

Эта проблема

Недавно я прочитал «Эгоистичный ген » Ричарда Докинза , очень откровенную книгу, несмотря на то, что мне 40 лет. Хотя первоначальный текст иногда устарел (« вы могли бы упаковать в череп всего несколько сотен транзисторов — больше, как триллионы в наши дни , закон Мура» ), общие утверждения так же привлекательны, как и раньше.

Одна из глав, которая особенно привлекла мое внимание, как компьютерного инженера, была о животных, выполняющих социальную груминг , как некоторые виды обезьян (см. Рисунок выше). Позвольте мне процитировать соответствующую главу :


Предположим, что вид […] подвергается паразитированию в результате особенно неприятного вида
клеща, который несет опасное заболевание. Очень важно, чтобы эти клещи были удалены как можно скорее. […] Человек может быть не в состоянии достичь своей собственной головы, но нет ничего проще, чем для друга сделать это для него. Позже, когда друг сам паразитирует, доброе дело может быть окуплено. […] Это имеет непосредственный интуитивный смысл. Любой, обладающий сознательным предвидением, может понять, что разумно вступать во взаимные договоренности по поводу возвращения в прошлое […]

Предположим, у B есть паразит на макушке. А тянет его от себя. Позже приходит время, когда у А на голове паразит. Естественно, он ищет В, чтобы В мог вернуть свой добрый поступок. Б просто поворачивает нос и уходит. Б — это обман, человек, который принимает пользу от альтруизма других людей, но который не окупает его или который платит недостаточно. Читы работают лучше, чем беспорядочные альтруисты, потому что они получают выгоды, не оплачивая расходы. Безусловно, стоимость ухода за головой другого человека кажется небольшой по сравнению с выгодой удаления опасного паразита, но она не является незначительной. Немного ценной энергии и времени нужно потратить.

Пусть население состоит из людей, которые принимают одну из двух стратегий. […] Назовите две стратегии Sucker и Cheat. Присоски ухаживают за всеми, кто в этом нуждается, без разбора. Читы принимают альтруизм от лохов, но они никогда не ухаживают за кем-либо еще, даже за кого-то, кто их ранее готовил. […] читы будут лучше, чем лохи. Даже если все население склоняется к вымиранию, никогда не будет времени, когда лохи будут лучше, чем читы. Поэтому, пока мы рассматриваем только эти две стратегии, ничто не может остановить вымирание лохов и, весьма вероятно, вымирание всего населения.

Но теперь, предположим, есть третья стратегия, называемая Grudger. Grudgers ухаживают за незнакомцами и людьми, которые ранее ухаживали за ними. Однако, если кто-то обманывает их, они запоминают инцидент и обижаются: они отказываются ухаживать за этим человеком в будущем. В популяции недоброжелателей и лохов невозможно сказать, что есть что. Оба типа ведут себя альтруистично по отношению ко всем остальным […]. Если по сравнению с читами хруджеры редки, то ген вырубится. Однако, как только злоумышленникам удастся нарастить численность, чтобы они достигли критической пропорции, их шанс встретиться друг с другом становится достаточно великим, чтобы компенсировать их напрасные усилия по уходу за читами. Когда эта критическая пропорция будет достигнута, они начнут получать более высокие выплаты, чем читы,и читы будут двигаться с ускоряющейся скоростью к исчезновению. […]

Цитата: Эгоистичный ген от Ричарда Докинза , ISBN 0-19-857519-X.

Позже автор проводит серию компьютерных симуляций, чтобы наблюдать, как эти три стратегии играют вместе в различных условиях. Очевидно, что исходный код недоступен, и я на самом деле рад этому. Во-первых, потому что у меня была возможность написать код для удовольствия. Во-вторых: книга была опубликована в 1976 году, через 4 года после изобретения C и за много лет до C ++ (чтобы представить вещи в перспективе), и я не совсем в настроении (я полагаю) Fortran .

Реализация

Поэтому я взломал несколько классов, чтобы провести симуляцию мошенников, лохов и недовольных по всему населению. Я хочу увидеть, как различные типы поведения влияют на численность населения. Какое поведение является стабильным и гарантирует выживание. Наконец, как смягчают случайные мутации и смерти. Я решил поэкспериментировать с некоторыми методами, а именно:

  1. Ручная инъекция зависимостей, без контейнера вообще
  2. Полностью однопоточный
  3. Логические часы, которые выдвинуты явно для имитации времени

Мы собираемся смоделировать популяцию обезьян (от нескольких до миллионов), каждая с независимым поведением, случайным временем жизни и так далее. Кажется очевидным использование мультиагентных решений, таких как актеры или хотя бы потоки. Однако курс « Принципы реактивного программирования» научил меня, что это часто слишком сложно. По сути, такая симуляция представляет собой последовательность событий, которые должны произойти в будущем: обезьяна должна родиться через 2 года, размножиться через 5 лет и умереть через 10. Конечно, есть еще много таких событий и больше обезьян. Однако достаточно выбросить все эти события в одну очередь с приоритетами, где ближайшие события будут первыми. Вот как Plannerэто по сути реализовано:

public abstract class Action implements Comparable<Action> {

    private final Instant schedule;

    public Action(Clock simulationTime, Duration delay) {
        this.schedule = simulationTime.instant().plus(delay);
    }

    @Override
    public int compareTo(Action other) {
        return this.schedule.compareTo(other.schedule);
    }

    public abstract void run();

}

//...

public class Planner implements Runnable {

    private final Queue<Action> pending = new PriorityQueue<>();
    private final SimulationClock simulationClock;

    public void schedule(Action action) {
        pending.add(action);
    }

    @Override
    public void run() {
        while (!pending.isEmpty()) {
            Action nearestAction = pending.poll();
            simulationClock.advanceTo(nearestAction.getSchedule());
            nearestAction.run();
        }
    }
}

У нас есть Actionс предопределенным

schedule

(when it should be executed) and a

pending

queue of future actions. There is no need to wait, we just pick nearest action from the future and advance simulation time:

import java.time.Clock;

public class SimulationClock extends Clock {

    private Instant simulationNow = Instant.now();

    @Override
    public Instant instant() {
        return simulationNow;
    }

    public void advanceTo(Instant instant) {
        simulationNow = instant;
    }

}

So we essentially implemented an event loop where events can be added anywhere within the queue (not necessarily at the end). Now that we have a basic framework, let’s implement the monkeys’ behaviors:

public class Sucker extends Monkey {
    //...

    @Override
    public boolean acceptsToGroom(Monkey monkey) {
        return true;
    }

}

public class Cheater extends Monkey {

    private final double acceptProbability;

    //...

    @Override
    public boolean acceptsToGroom(Monkey monkey) {
        return Math.random() < acceptProbability;
    }
}

public class Grudger extends Monkey {

    private final Set<Monkey> cheaters = new HashSet<>();

    @Override
    public boolean acceptsToGroom(Monkey monkey) {
        return !cheaters.contains(monkey);
    }

    @Override
    public void monkeyRejectedToGroomMe(Monkey monkey) {
        cheaters.add(monkey);
    }

}

As you can see these three classes capture three different behaviors.

Sucker

s always accept grooming requests,

Cheater

s only sometimes (in original simulation — never, but I made this configurable),

Grudger

s remember who rejected their request previously. Monkeys are aggregated within a class

Population

, here is a small snippet:

public class Population {

    private final Set<Monkey> monkeys = new HashSet<>();
    private final MonkeyFactory monkeyFactory;

    private Population addMonkey(Monkey child) {
        if (!full()) {
            newMonkey(child);
        }
        return this;
    }

    private boolean full() {
        return monkeys.size() >= environment.getMaxPopulationSize();
    }

    private void newMonkey(Monkey child) {
        monkeys.add(child);
        planner.scheduleMonkeyLifecycle(child, this);
        log.debug("New monkey in population {}total {}", child, monkeys.size());
    }

//...
}

For each new monkey we schedule its so-called lifecycle, i.e. events related to breeding, grooming and death (in

Planner

):

void scheduleMonkeyLifecycle(Monkey child, Population population) {
    askForGrooming(child, environment.getParasiteInfection().make(), population);
    scheduleBreedings(child, population);
    kill(child, environment.getLifetime().make(), population);
}

void askForGrooming(Monkey child, Duration parasiteInfection, Population population) {
    schedule(new AskForGrooming(child, parasiteInfection, population));
}

private void scheduleBreedings(Monkey child, Population population) {
    final int childrenCount = RANDOM.nextInt(environment.getMaxChildren() + 1);
    IntStream.
            rangeClosed(1, childrenCount)
            .forEach(x -> breed(child, environment.getBreeding().make(), population));
}

void kill(Monkey child, Duration lifetime, Population population) {
    schedule(new Kill(child, lifetime, population));
}

private void breed(Monkey child, Duration breeding, Population population) {
    schedule(new Breed(child, breeding, population));
}

AskForGrooming, Kill, Breed, etc. are instances of already mentioned

Action

class, e.g.

Kill

:

public class Kill extends MonkeyAction {
    private final Population population;

    public Kill(Monkey monkey, Duration lifetime, Population population) {
        super(monkey, lifetime);
        this.population = population;
    }

    @Override
    public void run(Monkey monkey) {
        population.kill(monkey);
    }
}

I encapsulate all simulation parameters in a simple value class Environment, many parameters like

parasiteInfection

,

lifetime

or

breeding

aren’t constants but instances of RandomPeriod class:

@Value
public class RandomPeriod {

    private static final Random RANDOM = new Random();

    Period expected;
    Period stdDev;

    public Duration make() {
        final long shift = Periods.toDuration(expected).toMillis();
        final long stdDev = Periods.toDuration(this.stdDev).toMillis();
        final double gaussian = RANDOM.nextGaussian() * stdDev;
        double randomMillis = shift + gaussian;
        return Duration.ofMillis((long) randomMillis);
    }

}

This allowed me to capture the concept of a random period of time with expected value, standard deviation and normal distribution.

make()

method simply generates one such random period. I am not going to explore full source code of this simulation, it’s available on GitHub. Now it’s finally time to run it a few times and observe how populations grow (or go extinct). By the way I use the same planner and action mechanism to peek at what happens: I simply inject the Probe action once every year (logical time!) and output the current population size.

Just as with many event loops, there must be just one thread accessing events. We followed this practice, the simulation is single-threaded, thus there is no need to perform any synchronization or locking at all, we can also use standard, unsafe but faster collections. Less context switches and improved cache locality help as well. Also we can easily dump simulation state to disk, e.g. to restore it later. Of course there are drawbacks. With thousands of monkeys the simulation slows down and there is not much we can do about it, except careful optimization and buying a faster CPU (not even more CPUs!)

The Experiment

As a control group we start with a tiny (10 specimens) population consisting solely of suckers and a mixture of suckers and grudgers. In the absence of cheaters these two behaviors are indistinguishable. We turn off mutation (a possibility that a child of sucker and grudger will become a cheater, rather then sucker or grudger as well) and see how the population grows (X axis represents time, Y axis is the population size):

Notice that the proportion of suckers and grudgers fluctuates around 50% since these two behaviors are doing exactly the same thing. There is no point in running a simulation with a few cheaters only. Since they generally don’t groom each other, they quickly die, erasing the «cheating gene«. On the other hand suckers only (without mutation) are growing exponentially (you can clearly see new generations being born after plateaus):

However what would happen if we simulate a population with 100 suckers and just 5 cheaters? Mutations are again turned off to keep the simulation clean:

There are two possible scenarios: either the cheater gene disappears or it spreads and causes the population to extinct. It’s kind of a paradox — if this particular gene wins, the whole population (including that gene!) is doomed. Now let’s simulate something more interesting. We turn on 5% mutation probability and ask cheaters to groom in 9 out of 10 cases (so they behave somewhat like suckers). We start with a healthy population of 5 suckers and 5 grudgers:

Do you see how grudgers quickly expand and almost always outnumber suckers? That’s because suckers are much more vulnerable in an environment where cheaters sometimes appear due to a mutation. Did you notice how the population quickly shrinks every time even a small number of suckers appear? Grudgers are vulnerable as well: they groom newborn cheaters without knowing yet who they are. However they don’t repeat this mistake like suckers do. That’s why suckers always loose, but they don’t go entirely extinct since they are somewhat protected by grudgers. Not directly, but grudgers kill cheaters by not grooming them, reducing the threat. That’s how different behaviors cooperate. So why did the population go extinct after all? Look carefully at the end of the chart, at some point for a random reason the suckers outnumbered grudgers — this was especially possible in the absence of cheaters at that time. So what happened? A few cheaters appeared suddenly due to a mutation and this society of monkeys was doomed.

Now let’s study another example: a mature society of 100 suckers living already, no other behaviors observed. Of course due to a mutation grudgers and cheaters quickly emerge. Most of the times this simulation ends very quickly, just after a few generations. The probability of a cheater being born is the same as the probability of a grudger, but we need way more grudgers to protect suckers. Thus the population dies. But I managed to run a few simulations where such society actually survived for a while:

Interestingly suckers dominated in the population for quite some time, but yet another epidemic attack of cheaters killed most of suckers. Unfortunately the last growth of cheaters reduced the number of grudgers to the point when they could no longer protect suckers and suddenly everyone dies.

Summary

From an implementation perspective using single-threaded model and an action queue worked really well, despite being hard to scale. However the biological conclusions are much more interesting:

  • An altruistic population (suckers only) can live happily forever, as long as there are no cheaters, trying to exploit the system
  • The presence of a single cheater in an altruistic population can cause such population to collapse
  • Population needs guards (grudgers) that protect from cheaters
  • Even in the presence of guards there is still place for small amount of cheaters that remain unnoticed
  • If the number of cheaters exceed some critical ratio, the population can no longer protect itself and gives up. Every specimen dies, including cheaters
  • Genes that are generally bad for a population go extinct, even if this drags down the whole population

You are free to extend conclusions to human society. Full source code is available on GitHub, feel free to experiment, pull requests are welcome as well!