DSZI_Survival/GeneticAlgorithm.md

6.7 KiB

Algorythm Genetyczny w projekcie DSZI_Survival

Autor: Marcin Kostrzewski


Cel

Celem algorytmu jest znalezienie czterech optymalnych wartości, według których agent podejmuje decyzję, co zrobić dalej. Te cztery cechy to:

  • Priorytet (chęć) zaspokajania głodu,
  • Zaspokajanie pragnienia,
  • Odpoczynek,
  • Jak odległość od obiektu wpływa na podjętą decyzję.

Zestaw tych cech reprezentuje klasa-struktura Affinities:

class Affinities:
    def __init__(self, food, water, rest, walking):
        """
        Create a container of affinities. Affinities describe, what type of entities a player prioritizes.
        :param food: Food affinity
        :param water: Freshwater affinity
        :param rest: Firepit affinity
        :param walking: How distances determine choices
        """
        self.food = food
        self.water = water
        self.rest = rest
        self.walking = walking

Oczywiście agent (gracz) posiada w swojej klasie pole self.affinities.

Podejmowanie decyzji

Gracz podejmuje decyzję o wyborze celu według następującej formuły:

typeWeight / (distance / walkingAffinity) * affectedStat * multiplier

gdzie:

  • typeWeight - wartość cechy odpowiadającej typowi celu,
  • distance - odległość od celu,
  • walkingAffinity - waga odległości,
  • affectedStat - aktualna wartość odpowiadającej statystyki agenta,
  • multiplier - mnożnik redukujący wpływ obecnych statystyk na wybór.

Implementacja w GA.py/pickEntity() (przykładowo dla jedzenia):

watersWeights = []
thirst = player.statistics.thirst
for water in waters:
    typeWeight = weights[1]
    distance = abs(player.x - water.x) + abs(player.x - water.y)
    watersWeights.append(typeWeight / (distance * walkingAffinity) * thirst * 0.01)

Dla każdego obiektu, z którym agent może podjąć interakcję wyliczana jest ta wartość i wybierany jest obiekt, dla którego jest największa.

Implementacja algorytmu genetycznego

Za realizację algorytmu odpowiada funkcja geneticAlgorithm() w GA.py (Skrócona wersja):

def geneticAlgorithm(map, iter, solutions, mutationAmount=0.05):
    # Based on 4 weights, that are affinities tied to the player
    weightsCount = 4

    # Initialize the first population with random values
    initialPopulation = numpy.random.uniform(low=0.0, high=1.0, size=(solutions, weightsCount))
    population = initialPopulation

    for i in range(iter):
        fitness = []
        for player in population:
            fitness.append(doSimulation(player, map))

        parents = selectMatingPool(population, fitness, int(solutions / 2))

        offspring = mating(parents, solutions, mutationAmount)

        population = offspring

Omówienie:

Pierwsza populacja

Pierwsza populacja inicjalizowana jest losowymi wartościami. Szukamy czterech najlepszych wag; każdy osobnik z gatunku jest reprezentowany przez listę 4-elementową wag.

initialPopulation = numpy.random.uniform(low=0.0, high=1.0, size=(solutions, weightsCount))

Rozpoczyna się pętla, która stworzy tyle generacji, ile sprecyzujemy w parametrze.

Symulacja i fitness

Dla każdego osobnika z populacji uruchamiana jest symulacja. Symulacja dzieje się w tle, żeby zminimializować czas potrzebny do wykonania pełnej symulacji. Jej koniec następuje w momencie, gdy agent umrze.

fitness.append(doSimulation(player, map))

Wartością zwracaną przez funkcję symulacji jest tzw. fitness. W tym wypadku, wartością tą jest ilość kroków, jakie pokonał agent przez cykl życia.

Wybór rodziców

Rodzice dla dzieci przyszłego pokolenia wybierani są na podstawie wartości fitness. W tym wypadku wybirana jest połowa populacji z najwyższymi wartościami przeżywalności.

parents = selectMatingPool(population, fitness, int(solutions / 2))
Potomstwo, czyli rozmnażanie i mutacje

Za wyliczanie wartości dla nowego pokolenia odpowiada funkcja mating. Przekazujemy do niej rodziców, ilość potomstwa i siłę mutacji. Z GA.py/mating():

for i in range(offspringCount):
    parent1 = i % len(parents)
    parent2 = (i + 1) % len(parents)
    offspring.append(crossover(parents[parent1], parents[parent2]))

Do stworzenia potomstwa używana jest funkcja crossover, która wylicza wartości, jakie przyjmie nowe potomstwo. Wartośc ta to mediana wartości obu rodziców. Z GA.py/crossover():

for gene1, gene2 in zip(genes1, genes2):
    result.append((gene1 + gene2) / 2)

Po zastosowaniu krzyżówki, jeden losowo wybrany gen jest alterowany o niewielką wartość (mutacja). Z GA.py/mutation():

for player in offspring:
    randomGeneIdx = random.randrange(0, len(player))
    player[randomGeneIdx] = player[randomGeneIdx] + random.uniform(-1.0, 1.0) * mutationAmount

Nowe potomstwo zastępuje obecną populacje i algorytm wchodzi w kolejną pętle:

population = offspring

Skuteczność algorytmu

Zastosowanie algorytmu przynosi niezbyt spektakularne, lecz oczekiwane wyniki. Po uruchomieniu symulacji dla 1000 generacji:

  • Wykres wartości fitness od generacji: fig

  • Najlepsze / najgorsze fitness:

Best Fitness: 188
Worst Fitness: 66
  • Zestaw najlepszych / najgorszych wartości
Best:
Affinities: food=0.6246050649543714, water=0.7464600395102128, rest=0.09747026036072276, walking=0.8427814969457226
Worst:
Affinities: food=0.5010343683602108, water=0.5248573924802746, rest=0.2191880576417362, walking=0.7195556582421176

Zastosowanie w całości projektu

Dzięki wyliczonym przez algorytm wagom, gracz poruszający się w środowisku będzie znał swoje priorytety i będzie w stanie przeżyć jak najdłużej. Obecnie, wybór obiektu jest dość statyczny i niezbyt "mądry", został napisany jedynie na potrzeby tego projektu. W przyszłości algorytm może być trenowany według inteligentnych wyborów obiektów np. poprzez zastosowanie drzewa decyzyjnego. Każdy obiekt ma zdefiniowany swój skutek, czyli gracz z góry wie, czym jest dany obiekt. W przyszłości gracz może nie znać informacji o obiektach, może być do tego używany jakiś inny algorytm, który oceni, czym jest dany obiekt.