Repository: MichaelJWelsh/bot-evolution Branch: master Commit: 6d8e3449fc53 Files: 7 Total size: 24.2 KB Directory structure: gitextract_rb342yw4/ ├── LICENSE ├── README.md └── src/ ├── main.py ├── neural_network.py ├── population.py ├── settings.py └── utility.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2017 Michael J Welsh Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Bot Evolution Bot evolution is an interesting display of evolution through neural networks and a genetic algorithm. Bot's have a field of vision represented by their antenna's. They are told if they "see" food in their field of vision, and are then asked to either move forward, turn counterclockwise, turn clockwise, or do nothing. If a bot does not recieve food after a certain period of time, it will die off. When a bot gets food, it reproduces asexually with a chance of mutation. If a bot goes too far out of the map, it dies and a completely random bot is spawned in the middle of the map. Each bot has its own neural network. You can see species emerge based on their colors. A random group of bots is spawned in the middle of the map upon startup. ![](https://github.com/MichaelJWelsh/bot-evolution/blob/master/example.gif) ## Usage Simply go into the source folder and type this in terminal: ```python python3.5 main.py ``` ## Dependencies - numpy - pygame ================================================ FILE: src/main.py ================================================ """ Bot Evolution v1.0.0 """ import os import sys import pickle import pygame as pg from pygame.locals import * import numpy as np import datetime import settings import population def main(): np.random.seed() pg.init() # Initialize runtime variables. periodically_save = False pop = None if os.path.isfile("save.txt") and input("Save file detected! Use it? (y/n): ").lower() == 'y': settings.FPS, settings.WINDOW_WIDTH, settings.WINDOW_HEIGHT, settings.TIME_MULTIPLIER, pop = pickle.load(open("save.txt", "rb")) else: pop_size = 0 mutation_rate = 0 while True: pop_size = int(input("Population size: ")) if pop_size < 5: print("Population size must be at least 5!") else: break while True: mutation_rate = float(input("Mutation rate: ")) if mutation_rate <= 0 or mutation_rate >= 1: print("Mutation rate must be in the range (0, 1)!") else: break while True: settings.TIME_MULTIPLIER = float(input("Time multiplier: ")) if settings.TIME_MULTIPLIER < 1: print("Time multiplier must be at least 1!") else: break if input("Advance options? (y/n): ").lower() == 'y': while True: settings.FPS = int(input("Frames per second: ")) if settings.FPS < 1: print("FPS must be at least 1!") else: break while True: settings.WINDOW_WIDTH = int(input("Window width: ")) if settings.WINDOW_WIDTH < 50: print("Window width must be at least 50!") else: break while True: settings.WINDOW_HEIGHT = int(input("Window height: ")) if settings.WINDOW_HEIGHT < 50: print("Window height must be at least 50!") else: break pop = population.Population(pop_size, mutation_rate) if input("Periodically save every half hour? (y/n): ").lower() == 'y': periodically_save = True print("\nNote: ") print("\tPress 'r' to reset the population.") print("\tPress 'p' to pause / unpause.") print("\tPress 's' to save population's data (for use next time).") print("\tPress 'up' / 'down' to change the populations mutation rate.") print("\tPress 'left' / 'right' to change the time multiplier.") print("\tClick on the screen to lay down food.") # Core variables. FONT_SIZE = 30 FONT = pg.font.SysFont("Arial", FONT_SIZE) fps_clock = pg.time.Clock() window = pg.display.set_mode((settings.WINDOW_WIDTH, settings.WINDOW_HEIGHT)) pg.display.set_caption("Bot Evolution") # Main loop. dt = 0.0 fps_clock.tick(int(settings.FPS / (settings.TIME_MULTIPLIER / 5.0 + 1))) paused = False while True: key_pressed = {"up": False, "down": False, "left": False, "right": False} for event in pg.event.get(): if event.type == QUIT: pg.quit() sys.exit() elif event.type == pg.KEYDOWN: if event.key == pg.K_r: pop = population.Population(pop.SIZE, pop.mutation_rate) if event.key == pg.K_s: pickle.dump([settings.FPS, settings.WINDOW_WIDTH, settings.WINDOW_HEIGHT, settings.TIME_MULTIPLIER, pop], open("save.txt", "wb")) if event.key == pg.K_p: paused = not paused elif event.type == pg.MOUSEBUTTONUP: pos = pg.mouse.get_pos() pop.food.pop() food = population.Food(pop) food.x = pos[0] food.y = pos[1] pop.food.append(food) if paused: dt = fps_clock.tick(int(settings.FPS / (settings.TIME_MULTIPLIER / 5.0 + 1))) / 1000.0 * int(settings.FPS / (settings.TIME_MULTIPLIER / 5.0 + 1)) continue if periodically_save and datetime.datetime.now().minute % 30 == 0: pickle.dump([settings.FPS, settings.WINDOW_WIDTH, settings.WINDOW_HEIGHT, settings.TIME_MULTIPLIER, pop], open("save.txt", "wb")) keys = pg.key.get_pressed() if keys[pg.K_UP]: key_pressed["up"] = True if keys[pg.K_DOWN]: key_pressed["down"] = True if key_pressed["up"] and key_pressed["down"]: key_pressed["up"] = False key_pressed["down"] = False if keys[pg.K_LEFT]: key_pressed["left"] = True if keys[pg.K_RIGHT]: key_pressed["right"] = True if key_pressed["left"] and key_pressed["right"]: key_pressed["left"] = False key_pressed["right"] = False update(dt, pop, key_pressed) window.fill((0, 0, 0)) render(window, FONT, pop) pg.display.update() dt = fps_clock.tick(int(settings.FPS / (settings.TIME_MULTIPLIER / 5.0 + 1))) / 1000.0 * int(settings.FPS / (settings.TIME_MULTIPLIER / 5.0 + 1)) display_time_remaining = 0.0 def update(dt, pop, key_pressed): global display_time_remaining if key_pressed["up"] or key_pressed["down"] or key_pressed["left"] or key_pressed["right"]: display_time_remaining = 3.0 if key_pressed["up"]: pop.mutation_rate += 0.001 elif key_pressed["down"]: pop.mutation_rate -= 0.001 if pop.mutation_rate <= 0: pop.mutation_rate = 0.001 elif pop.mutation_rate >= 1: pop.mutation_rate = 0.999 if key_pressed["left"]: settings.TIME_MULTIPLIER -= 0.1 elif key_pressed["right"]: settings.TIME_MULTIPLIER += 0.1 if settings.TIME_MULTIPLIER < 1: settings.TIME_MULTIPLIER = 1.0 else: display_time_remaining -= 1.0 / settings.FPS * dt if display_time_remaining < 0: display_time_remaining = 0.0 pop.update(dt) def render(window, FONT, pop): for food in pop.food: pg.draw.circle(window, food.RGB, (int(food.x), int(food.y)), food.HITBOX_RADIUS) for bot in pop.bots: # Draw body. pg.draw.circle(window, bot.RGB, (int(bot.x), int(bot.y)), bot.HITBOX_RADIUS) # Draw field-of-vision lines. LINE_THICKNESS = 1 PROTRUSION = int(bot.HITBOX_RADIUS * 1.5) to_x = int(bot.x + (bot.HITBOX_RADIUS + PROTRUSION) * np.cos(bot.theta - bot.FIELD_OF_VISION_THETA / 2)) to_y = int(bot.y - (bot.HITBOX_RADIUS + PROTRUSION) * np.sin(bot.theta - bot.FIELD_OF_VISION_THETA / 2)) pg.draw.line(window, bot.RGB, (bot.x, bot.y), (to_x, to_y), LINE_THICKNESS) to_x = int(bot.x + (bot.HITBOX_RADIUS + PROTRUSION) * np.cos(bot.theta + bot.FIELD_OF_VISION_THETA / 2)) to_y = int(bot.y - (bot.HITBOX_RADIUS + PROTRUSION) * np.sin(bot.theta + bot.FIELD_OF_VISION_THETA / 2)) pg.draw.line(window, bot.RGB, (bot.x, bot.y), (to_x, to_y), LINE_THICKNESS) if display_time_remaining > 0: resultSurf = FONT.render("Mutation Rate: %.3f Speed: %.1fx" % (pop.mutation_rate, settings.TIME_MULTIPLIER), True, (255, 255, 255)) resultRect = resultSurf.get_rect() resultRect.topleft = (25, 25) window.blit(resultSurf, resultRect) if __name__ == "__main__": main() ================================================ FILE: src/neural_network.py ================================================ """ This module implements the neural network class and all components necessary to modularize the build/use process of the neural network. """ import numpy as np import pickle class NNetwork: """ The representation of a feed forward neural network with a bias in every layer (excluding output layer obviously). """ def __init__(self, layer_sizes, activation_funcs, bias_neuron = False): """ Creates a 'NNetwork'. 'layer_sizes' provides information about the number of neurons in each layer, as well as the total number of layers in the neural network. 'activation_funcs' provides information about the activation functions to use on each respective hidden layers and output layer. This means that the length of 'activation_funcs' is always one less than the length of 'layer_sizes'. """ assert(len(layer_sizes) >= 2) assert(len(layer_sizes) - 1 == len(activation_funcs)) assert(min(layer_sizes) >= 1) self.layers = [] self.connections = [] # Initialize layers. for i in range(len(layer_sizes)): # Input layer. if i == 0: self.layers.append(Layer(layer_sizes[i], None, bias_neuron)) # Hidden layer. elif i < len(layer_sizes) - 1: self.layers.append(Layer(layer_sizes[i], activation_funcs[i - 1], bias_neuron)) # Output layer. else: self.layers.append(Layer(layer_sizes[i], activation_funcs[i - 1])) # Initialize connections. num_connections = len(layer_sizes) - 1 for i in range(num_connections): self.connections.append(Connection(self.layers[i], self.layers[i + 1])) def feed_forward(self, data, one_hot_encoding = True): """ Feeds given data through neural network and stores output in output layer's data field. Output can optionally be one-hot encoded. """ if self.layers[0].HAS_BIAS_NEURON: assert(len(data) == self.layers[0].SIZE - 1) self.layers[0].data = data self.layers[0].data.append(1) else: assert(len(data) == self.layers[0].SIZE) self.layers[0].data = data for i in range(len(self.connections)): self.connections[i].TO.data = np.dot(self.layers[i].data, self.connections[i].weights) self.connections[i].TO.activate() if one_hot_encoding: this_data = self.layers[len(self.layers) - 1].data MAX = max(this_data) for i in range(len(this_data)): if this_data[i] == MAX: this_data[i] = 1 else: this_data[i] = 0 def output(self): """ Retrieves data in output layer. """ return self.layers[len(self.layers) - 1].data class Layer: """ The representation of a layer in a neural network. Used as a medium for passing data through the network in an efficent manner. """ def __init__(self, num_neurons, activation_func, bias_neuron = False): """ Creates a 'Layer' with 'num_neurons' and an additional (optional) bias neuron (which always has a value of '1'). The layer will utilize the 'activation_func' during activation. """ assert(num_neurons > 0) self.ACTIVATION_FUNC = activation_func self.HAS_BIAS_NEURON = bias_neuron if bias_neuron: self.SIZE = num_neurons + 1 self.data = np.array([0] * num_neurons + [1]) else: self.SIZE = num_neurons self.data = np.array([0] * num_neurons) def activate(self): """ Calls activation function on layer's data. """ if self.ACTIVATION_FUNC != None: self.ACTIVATION_FUNC(self.data) class Connection: """ The representation of a connection between layers in a neural network. """ def __init__(self, layer_from, layer_to): """" Creates a 'Connection' between 'layer_from' and 'layer_to' that contains all required weights, which are randomly initialized with random numbers in a guassian distribution of mean '0' and standard deviation '1'. """ self.FROM = layer_from self.TO = layer_to self.weights = np.zeros((layer_from.SIZE, layer_to.SIZE)) for i in range(layer_from.SIZE): for j in range(layer_to.SIZE): self.weights[i][j] = np.random.standard_normal() def sigmoid(data): """ Uses sigmoid transformation on given data. This is an activation function. """ for i in range(len(data)): data[i] = 1 / (1 + np.exp(-data[i])) def softmax(data): """ Uses softmax transformation on given data. This is an activation function. """ sum = 0.0 for i in range(len(data)): sum += np.exp(data[i]) for i in range(len(data)): data[i] = np.exp(data[i]) / sum ================================================ FILE: src/population.py ================================================ """ This modules implements the bulk of Bot Evolution. """ import numpy as np import copy import settings from utility import seq_is_equal, distance_between, angle_is_between, find_angle from neural_network import NNetwork, sigmoid, softmax class Population: """ The environment of bots and food. """ def __init__(self, size, mutation_rate): assert(size >= 5) assert(0 < mutation_rate < 1) self.SIZE = size self.mutation_rate = mutation_rate self.bots = [] self.food = [] self.time_since_last_death = 0.0 # The neural network will have 1 neuron in the input layer, 1 hidden # layer with 2 neurons, and 4 neurons in the output layer. The sigmoid # activation function will be used on the hidden layer, and a softmax # activation function will be used on the output layer. Input consists # of the bot's direction and if there is or isn't food in the bots field # of vision. Output consists of whether or not to move foward, turn # left, turn right, or do nothing. for i in range(size): random_rgb = (np.random.randint(30, 256), np.random.randint(30, 256), np.random.randint(30, 256)) self.bots.append(Bot(NNetwork((1, 2, 4), (sigmoid, softmax)), random_rgb, self)) self.food.append(Food(self)) def eliminate(self, bot, replace = False): self.time_since_last_death = 0.0 self.bots.remove(bot) if replace: random_rgb = (np.random.randint(30, 256), np.random.randint(30, 256), np.random.randint(30, 256)) self.bots.append(Bot(NNetwork((1, 2, 4), (sigmoid, softmax)), random_rgb, self)) def feed(self, bot, food): bot.score = 1.0 self.food.remove(food) self.food.append(Food(self)) num_to_replace = int(self.SIZE / 7 - 1) if num_to_replace < 2: num_to_replace = 2 for i in range(num_to_replace): weakest = self.bots[0] for other in self.bots: if other.score < weakest.score: weakest = other self.eliminate(weakest) for i in range(num_to_replace): if np.random.uniform(0, 1) <= self.mutation_rate: new_rgb = [bot.RGB[0], bot.RGB[1], bot.RGB[2]] new_rgb[np.random.choice((0, 1, 2))] = np.random.uniform(30, 256) new_bot = Bot(bot.nnet, new_rgb, self) new_bot.x = bot.x + Bot.HITBOX_RADIUS * 4 * np.random.uniform(0, 1) * np.random.choice((-1, 1)) new_bot.y = bot.y + Bot.HITBOX_RADIUS * 4 * np.random.uniform(0, 1) * np.random.choice((-1, 1)) nb_c = new_bot.nnet.connections mutated = False while not mutated: for k in range(len(nb_c)): for i in range(nb_c[k].FROM.SIZE): for j in range(nb_c[k].TO.SIZE): if np.random.uniform(0, 1) <= self.mutation_rate: nb_c[k].weights[i][j] = nb_c[k].weights[i][j] * np.random.normal(1, 0.5) + np.random.standard_normal() mutated = True self.bots.append(new_bot) else: new_bot = Bot(bot.nnet, bot.RGB, self) new_bot.x = bot.x + Bot.HITBOX_RADIUS * 4 * np.random.uniform(0, 1) * np.random.choice((-1, 1)) new_bot.y = bot.y + Bot.HITBOX_RADIUS * 4 * np.random.uniform(0, 1) * np.random.choice((-1, 1)) self.bots.append(new_bot) def update(self, dt): """ Updates the population's internals. The bulk of event handling for all bots and food starts here. """ self.time_since_last_death += 1.0 / settings.FPS * dt * settings.TIME_MULTIPLIER for food in self.food[:]: if food not in self.food: continue food.update(dt) for bot in self.bots[:]: if bot not in self.bots: continue sensory_input = [] # This is where the bot's field of vision is put into action. min_theta = bot.theta - Bot.FIELD_OF_VISION_THETA / 2 max_theta = bot.theta + Bot.FIELD_OF_VISION_THETA / 2 food_in_sight = False for food in self.food: if angle_is_between(find_angle(bot.x, bot.y, food.x, food.y), min_theta, max_theta): food_in_sight = True break if food_in_sight: sensory_input.append(1.0) else: sensory_input.append(0.0) # Useful debugging outputs. #print(bot.RGB) #print(sensory_input) bot.update(dt, sensory_input) if self.time_since_last_death >= 5: weakest = self.bots[0] for bot in self.bots: if bot.score < weakest.score: weakest = bot self.eliminate(weakest, replace = True) class Bot: """ The representation of the circle thing with probes. """ # In pixels/pixels per second/revolutions per second/radians. SPAWN_RADIUS = int(settings.WINDOW_WIDTH / 20) if settings.WINDOW_WIDTH <= settings.WINDOW_HEIGHT else int(settings.WINDOW_HEIGHT / 20) HITBOX_RADIUS = 6 SPEED = 350.0 TURN_RATE = 2 * np.pi FIELD_OF_VISION_THETA = 45 * np.pi / 180 # These lists represent the output from the neural network. Note that the # output '[0, 0, 0, 1]' means "do nothing". MOVE_FORWARD = [1, 0, 0, 0] TURN_LEFT = [0, 1, 0, 0] TURN_RIGHT = [0, 0, 1, 0] def __init__(self, nnet, rgb, population): self.nnet = copy.deepcopy(nnet) self.RGB = rgb self.pop = population self.theta = np.random.uniform(0, 1) * 2 * np.pi self.x = settings.WINDOW_WIDTH / 2.0 + Bot.SPAWN_RADIUS * np.random.uniform(0, 1) * np.cos(self.theta) self.y = settings.WINDOW_HEIGHT / 2.0 + Bot.SPAWN_RADIUS * np.random.uniform(0, 1) * np.sin(self.theta) self.score = 0.0 def _move_forward(self, dt): self.x += Bot.SPEED / settings.FPS * dt * np.cos(self.theta) * settings.TIME_MULTIPLIER self.y -= Bot.SPEED / settings.FPS * dt * np.sin(self.theta) * settings.TIME_MULTIPLIER if self.x < -Bot.HITBOX_RADIUS * 6 or self.x > settings.WINDOW_WIDTH + Bot.HITBOX_RADIUS * 6 \ or self.y < -Bot.HITBOX_RADIUS * 6 or self.y > settings.WINDOW_HEIGHT + Bot.HITBOX_RADIUS * 6: self.pop.eliminate(self, replace = True) def _turn_left(self, dt): self.theta += Bot.TURN_RATE / settings.FPS * dt * settings.TIME_MULTIPLIER while self.theta >= 2 * np.pi: self.theta -= 2 * np.pi def _turn_right(self, dt): self.theta -= Bot.TURN_RATE / settings.FPS * dt * settings.TIME_MULTIPLIER while self.theta < 0: self.theta += 2 * np.pi def update(self, dt, sensory_input): """ Updates the bot's internals. "Hunger" can be thought of as a score between '-1' and '1' where a greater value means less hungry. """ self.score -= 1.0 / settings.FPS / 10.0 * dt * settings.TIME_MULTIPLIER if self.score < -1: self.score = -1.0 self.nnet.feed_forward(sensory_input) output = self.nnet.output() if seq_is_equal(output, Bot.MOVE_FORWARD): self._move_forward(dt) elif seq_is_equal(output, Bot.TURN_LEFT): self._turn_left(dt) elif seq_is_equal(output, Bot.TURN_RIGHT): self._turn_right(dt) class Food: """ The representation of the red circles. """ # In pixels. HITBOX_RADIUS = 5 RGB = (255, 0, 0) def __init__(self, population): mid_x = int(settings.WINDOW_WIDTH / 2) mid_y = int(settings.WINDOW_HEIGHT / 2) max_left_x = mid_x - (Bot.SPAWN_RADIUS + Bot.HITBOX_RADIUS + 5) min_right_x = mid_x + (Bot.SPAWN_RADIUS + Bot.HITBOX_RADIUS + 5) max_top_y = mid_y - (Bot.SPAWN_RADIUS + Bot.HITBOX_RADIUS + 5) min_bottom_y = mid_y + (Bot.SPAWN_RADIUS + Bot.HITBOX_RADIUS + 5) self.x = np.random.choice((np.random.uniform(0, max_left_x), np.random.uniform(min_right_x, settings.WINDOW_WIDTH))) self.y = np.random.choice((np.random.uniform(0, max_top_y), np.random.uniform(min_bottom_y, settings.WINDOW_HEIGHT))) self.pop = population def update(self, dt): """ Updates the food's internals and handles bot<->food collision. """ for bot in self.pop.bots: if distance_between(self.x, self.y, bot.x, bot.y) <= Bot.HITBOX_RADIUS + Food.HITBOX_RADIUS: self.pop.feed(bot, self) break ================================================ FILE: src/settings.py ================================================ """ This module contains the general settings used across modules. """ FPS = 60 WINDOW_WIDTH = 1100 WINDOW_HEIGHT = 600 TIME_MULTIPLIER = 1.0 ================================================ FILE: src/utility.py ================================================ """ This module contains general-use functions. """ import numpy as np def seq_is_equal(a, b): """ Helper function that checks if two sequences are equal (assuming they have the same length). """ for i in range(len(a)): if a[i] != b[i]: return False return True def angle_is_between(angle, a, b): """ Takes in 3 angles (in radians) and returns 'a' <= 'angle' <= 'b'. """ to_degrees = lambda rads: int(rads * 180 / np.pi) reduce_degrees = lambda degr: (360 + degr % 360) % 360 angle = to_degrees(angle) a = to_degrees(a) b = to_degrees(b) angle = reduce_degrees(angle) a = reduce_degrees(a) b = reduce_degrees(b) if a < b: return a <= angle <= b return a <= angle or angle <= b def find_angle(x1, y1, x2, y2): """ Finds the angle between two points (in radians). """ angle = (360 + int(np.arctan2(y1 - y2, x2 - x1) * 180 / np.pi) % 360) % 360 return angle * np.pi / 180 def distance_between(x1, y1, x2, y2): """ Calculates the distance between 2 points. """ return np.hypot(x1 - x2, y1 - y2)