From 50a97c6c9fd3fc28b7ecde4792c09517279314f8 Mon Sep 17 00:00:00 2001 From: Matas Minelga Date: Sat, 26 Apr 2025 00:23:01 +0300 Subject: [PATCH 1/2] Sexual reproduction and logging and performance --- jes.py | 26 +++- jes_creature.py | 54 ++++++++- jes_dataviz.py | 130 ++++++++++++++++---- jes_sim.py | 288 +++++++++++++++++++++++++++++++++++++++----- jes_species_info.py | 8 +- jes_ui.py | 8 +- 6 files changed, 446 insertions(+), 68 deletions(-) diff --git a/jes.py b/jes.py index 879df22..107a28c 100644 --- a/jes.py +++ b/jes.py @@ -1,5 +1,27 @@ from jes_sim import Sim from jes_ui import UI +import logging +import sys + +def setup_logging(level=logging.INFO, log_file='app.log'): + # Create a formatter that includes timestamp, level, and module + formatter = logging.Formatter('%(asctime)s - [%(filename)s:%(lineno)d] - [%(name)s:%(funcName)s] - %(levelname)s - %(message)s') + + # Configure console handler + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setFormatter(formatter) + + # Configure root logger + root_logger = logging.getLogger() + root_logger.setLevel(level) + root_logger.addHandler(console_handler) + + # Optional: Add file handler for persistent logs + file_handler = logging.FileHandler(log_file) + file_handler.setFormatter(formatter) + root_logger.addHandler(file_handler) + + return root_logger c_input = input("How many creatures do you want?\n100: Lightweight\n250: Standard (if you don't type anything, I'll go with this)\n500: Strenuous (this is what my carykh video used)\n") if c_input == "": @@ -7,7 +29,7 @@ # Simulation # population size is 250 here, because that runs faster. You can increase it to 500 to replicate what was in my video, but do that at your own risk! - +logger = setup_logging() sim = Sim(_c_count=int(c_input), _stabilization_time=200, _trial_time=300, _beat_time=20, _beat_fade_time=5, _c_dim=[4,4], _beats_per_cycle=3, _node_coor_count=4, # x_position, y_position, x_velocity, y_velocity @@ -17,7 +39,7 @@ _traits_per_box=3, # desired width, desired height, rigidity _traits_extra=1, # heartbeat (time) _mutation_rate=0.07, _big_mutation_rate=0.025, -_UNITS_PER_METER=0.05) +_UNITS_PER_METER=0.05, logger=logger) # Cosmetic UI variables ui = UI(_W_W=1920, _W_H=1078, _MOVIE_SINGLE_DIM=(650,650), diff --git a/jes_creature.py b/jes_creature.py index aa72e5e..774f373 100644 --- a/jes_creature.py +++ b/jes_creature.py @@ -4,10 +4,13 @@ import numpy as np import math from jes_species_info import SpeciesInfo +# from jes_sim import Sim import random +import logging class Creature: - def __init__(self,d,pIDNumber,parent_species,_sim,_ui): + def __init__(self,d,pIDNumber,parent_species,_sim,_ui, max_offspring=4, logger=None, mutate_rate=0.2): + self.logger = logger or logging.getLogger(__name__) self.dna = d self.calmState = None self.icons = [None]*2 @@ -20,6 +23,13 @@ def __init__(self,d,pIDNumber,parent_species,_sim,_ui): self.sim = _sim self.ui = _ui self.codonWithChange = None + self.max_offspring = max_offspring + self.generation_offspring = 0 + self.species_threshold = 0.95 + self.mutate_rate = mutate_rate + + def __str__(self): + return f"ID: {self.IDNumber:<5} S: {str(self.sim.species_info[self.species]):<10} OG: {self.generation_offspring:<4}" def getSpecies(self, parent_species): if parent_species == -1: @@ -95,13 +105,32 @@ def drawIcon(self, ICON_DIM, BG_COLOR, BEAT_FADE_TIME): def saveCalmState(self, arr): self.calmState = arr + + def check_if_new_species(self, other_dna): + sp_info = self.sim.species_info[self.species] + species_rep_dna = self.sim.getCreatureWithID(sp_info.reps[1]).dna + + new_creature_similarity = self.calculate_raw_dna_similarity(species_rep_dna, other_dna) + if new_creature_similarity < self.species_threshold: + self.logger.info(f"Creating new species, as offspring similarity is below threshold: {new_creature_similarity}") + return True + return False + def getMutatedDNA(self, sim): - mutation = np.clip(np.random.normal(0.0, 1.0, self.dna.shape[0]),-99,99) - result = self.dna + sim.mutation_rate*mutation + mutation = np.clip(np.random.normal(-1.0, 1.0, self.dna.shape[0]),-99,99) + mutation_mask = np.random.random(self.dna.shape) < self.mutate_rate + result = self.dna + sim.mutation_rate*mutation * mutation_mask newSpecies = self.species big_mut_loc = 0 + + if self.check_if_new_species(result): + newSpecies = sim.species_count + sim.species_count += 1 + # biggest_dna_change = mutation.argmax() + return result, newSpecies, big_mut_loc + if random.uniform(0,1) < self.sim.big_mutation_rate: # do a big mutation newSpecies = sim.species_count sim.species_count += 1 @@ -121,6 +150,25 @@ def getMutatedDNA(self, sim): result[big_mut_loc+i] = 0.5 return result, newSpecies, big_mut_loc + def calculate_dna_similarity(self, other_creature): + """ + Calculate the genetic similarity between this creature and another creature. + Returns a value between 0 and 1, where 1 means identical DNA. + """ + # Calculate Euclidean distance between DNA arrays, normalized + distance = np.sqrt(np.sum(np.square(self.dna - other_creature.dna))) + # Convert to similarity (closer to 1 means more similar) + max_possible_distance = np.sqrt(len(self.dna) * 36) # Maximum possible distance assuming all values differ by 6 (-3 to 3) + similarity = 1 - (distance / max_possible_distance) + return similarity + + def calculate_raw_dna_similarity(self, dna_a, dna_b): + distance = np.sqrt(np.sum(np.square(dna_a - dna_b))) + # Convert to similarity (closer to 1 means more similar) + max_possible_distance = np.sqrt(len(dna_a) * 36) # Maximum possible distance assuming all values differ by 6 (-3 to 3) + similarity = 1 - (distance / max_possible_distance) + return similarity + def traitsToColor(self, dna, x, y, frame): beat = self.sim.frameToBeat(frame) diff --git a/jes_dataviz.py b/jes_dataviz.py index 95828de..62a679d 100644 --- a/jes_dataviz.py +++ b/jes_dataviz.py @@ -6,6 +6,12 @@ import random import bisect +# Cache for sorted species keys +_species_keys_cache = {} +# Cache for species colors +_species_color_cache = {} + + def drawAllGraphs(sim, ui): drawLineGraph(sim.percentiles, ui.graph, [70,0,30,30], sim.UNITS_PER_METER, ui.smallFont) drawSAC(sim.species_pops, ui.sac, [70,0], ui) @@ -56,10 +62,106 @@ def drawLineGraph(data,graph,margins,u,font): thickness = 3 pygame.draw.line(graph, color, (x1, y1), (x2, y2), width=thickness) -def drawSAC(data,sac,margins,ui): - sac.fill((0,0,0)) + +def drawSAC(data, sac, margins, ui): + """ + Optimized Species Area Chart drawing function. + """ + sac.fill((0, 0, 0)) + + # Pre-calculate constants + W = sac.get_width() - margins[0] - margins[1] + H = sac.get_height() + LEN = len(data) + LEFT = margins[0] + + # Cache the species keys for each generation to avoid repeated sorting + for g in range(len(data)): + _species_keys_cache[g] = sorted(list(data[g].keys())) + + # Draw all generations for g in range(len(data)): - scanDownTrapezoids(data, g, sac, margins, ui) + x1 = LEFT + (g / LEN) * W + x2 = LEFT + ((g + 1) / LEN) * W + + if g == 0: + # Optimization for first generation + keys = _species_keys_cache[g] + c_count = data[g][keys[-1]][2] # ending index of the last entry + FAC = H / c_count + + for sp in keys: + pop = data[g][sp] + points = [[x1, H/2], [x1, H/2], [x2, H-pop[1]*FAC], [x2, H-pop[2]*FAC]] + color = get_cached_species_color(sp, ui) + pygame.draw.polygon(sac, color, points) + else: + keys = _species_keys_cache[g] + c_count = data[g][keys[-1]][2] # ending index of the last entry + FAC = H / c_count + trapezoidHelper(sac, data, g, g-1, 0, c_count, x1, x2, FAC, 0, ui) + +def get_cached_species_color(species_id, ui): + """ + Retrieve cached species color or compute and cache it if not available. + """ + if species_id not in _species_color_cache: + _species_color_cache[species_id] = speciesToColor(species_id, ui) + return _species_color_cache[species_id] + +def getRangeEvenIfNone(dicty, key, generation=None): + """ + Optimized function to get the range for a species, even if the species + doesn't exist in the given dictionary. + """ + # Use cached sorted keys if available + if generation is not None and generation in _species_keys_cache: + keys = _species_keys_cache[generation] + else: + # Fall back to sorting if cache not available + keys = sorted(list(dicty.keys())) + + if key in keys: + return dicty[key] + else: + n = bisect.bisect_right(keys, key) + if n >= len(keys): + val = dicty[keys[n-1]][2] + else: + val = dicty[keys[n]][1] + return [0, val, val] + +def trapezoidHelper(sac, data, g1, g2, i_start, i_end, x1, x2, FAC, level, ui): + """ + Optimized trapezoid drawing helper that uses caching for performance. + """ + # Get the keys from the cache + keys1 = _species_keys_cache.get(g1, sorted(list(data[g1].keys()))) + + H = sac.get_height() + pop2 = [0, 0, 0] + + # Pre-compute all colors needed to avoid calling speciesToColor in the loop + colors = {} + for sp in keys1: + colors[sp] = get_cached_species_color(sp, ui) + + # Loop through species using the cached sorted keys + for sp in keys1: + pop1 = data[g1][sp] + if level == 0 and pop1[1] != pop2[2]: # there was a gap + trapezoidHelper(sac, data, g2, g1, pop2[2], pop1[1], x2, x1, FAC, 1, ui) + + # Use optimized function with generation hint + pop2 = getRangeEvenIfNone(data[g2], sp, g2) + + # Draw the polygon + points = [[x1, H-pop2[1]*FAC], [x1, H-pop2[2]*FAC], + [x2, H-pop1[2]*FAC], [x2, H-pop1[1]*FAC]] + pygame.draw.polygon(sac, colors[sp], points) + + # Update for next iteration + pop2 = pop1 def scanDownTrapezoids(data, g, sac, margins, ui): W = sac.get_width()-margins[0]-margins[1] @@ -81,28 +183,6 @@ def scanDownTrapezoids(data, g, sac, margins, ui): else: trapezoidHelper(sac, data, g, g-1, 0, c_count, x1, x2, FAC, 0, ui) -def getRangeEvenIfNone(dicty, key): - keys = sorted(list(dicty.keys())) - if key in keys: - return dicty[key] - else: - n = bisect.bisect(keys, key+0.5) - if n >= len(keys): - val = dicty[keys[n-1]][2] - else: - val = dicty[keys[n]][1] - return [0,val,val] - -def trapezoidHelper(sac, data, g1, g2, i_start, i_end, x1, x2, FAC, level, ui): - pop2 = [0,0,0] - H = sac.get_height() - for sp in data[g1].keys(): - pop1 = data[g1][sp] - if level == 0 and pop1[1] != pop2[2]: #there was a gap - trapezoidHelper(sac, data, g2, g1, pop2[2], pop1[1], x2, x1, FAC, 1, ui) - pop2 = getRangeEvenIfNone(data[g2],sp) - points = [[x1,H-pop2[1]*FAC],[x1,H-pop2[2]*FAC],[x2,H-pop1[2]*FAC],[x2,H-pop1[1]*FAC]] - pygame.draw.polygon(sac,speciesToColor(sp, ui),points) def drawGeneGraph(species_info, ps, gg, sim, ui, font): # ps = prominent_species R = ui.GENEALOGY_COOR[4] diff --git a/jes_sim.py b/jes_sim.py index e396e72..6eb8499 100644 --- a/jes_sim.py +++ b/jes_sim.py @@ -5,13 +5,16 @@ from jes_dataviz import drawAllGraphs import time import random +import logging class Sim: def __init__(self, _c_count, _stabilization_time, _trial_time, _beat_time, _beat_fade_time, _c_dim, _beats_per_cycle, _node_coor_count, _y_clips, _ground_friction_coef, _gravity_acceleration_coef, _calming_friction_coef, _typical_friction_coef, _muscle_coef, - _traits_per_box, _traits_extra, _mutation_rate, _big_mutation_rate, _UNITS_PER_METER): + _traits_per_box, _traits_extra, _mutation_rate, _big_mutation_rate, _UNITS_PER_METER, logger=None): + self.logger = logger or logging.getLogger(__name__) + self.c_count = _c_count #creature count self.species_count = _c_count #species count self.stabilization_time = _stabilization_time @@ -48,12 +51,13 @@ def __init__(self, _c_count, _stabilization_time, _trial_time, _beat_time, self.prominent_species = [] self.ui = None self.last_gen_run_time = -1 + self.creature_generations = {} def initializeUniverse(self): self.creatures = [[None]*self.c_count] for c in range(self.c_count): self.creatures[0][c] = self.createNewCreature(c) - self.species_info.append(SpeciesInfo(self,self.creatures[0][c], None)) + self.species_info.append(SpeciesInfo(self,self.creatures[0][c], None, generation=0)) # We want to make sure that all creatures, even in their # initial state, are in calm equilibrium. They shouldn't @@ -65,9 +69,11 @@ def initializeUniverse(self): self.creatures[0][c].icons[i] = self.creatures[0][c].drawIcon(self.ui.ICON_DIM[i], self.ui.MOSAIC_COLOR, self.beat_fade_time) self.ui.drawCreatureMosaic(0) + def createNewCreature(self, idNumber): dna = np.clip(np.random.normal(0.0, 1.0, self.trait_count),-3,3) + self.creature_generations[idNumber] = 0 return Creature(dna, idNumber, -1, self, self.ui) def getCalmStates(self, gen, startIndex, endIndex, frameCount, calmingRun): @@ -144,6 +150,150 @@ def simulateRun(self, param, frameCount, calmingRun): if calmingRun: # If it's a calming run, then take the average location of all nodes to center it at the origin. nodeCoor[:,:,:,0] -= np.mean(nodeCoor[:,:,:,0], axis=(1,2), keepdims=True) return nodeCoor, muscles, startCurrentFrame+frameCount + + # Add this method to Sim class + def sample_biased_id(max_id): + """ + Sample an ID from 0 to max_id where 0 has the highest probability + and max_id has the lowest. + + Args: + max_id (int): The maximum ID value (inclusive) + + Returns: + int: A sampled ID with bias toward lower values + """ + # Generate random value with exponential distribution (more bias toward 0) + x = np.random.exponential(scale=max_id/10) + + # Clip the value to our range and convert to integer + selected_id = int(np.clip(x, 0, max_id)) + + return selected_id + + def sample_weighted_creature_index(self, rankings, exclude_indices=None, weights = None): + """ + Sample a creature index with probability weighted by its ranking. + Higher ranked creatures (lower rank number) have higher probability of being selected. + + Parameters: + - rankings: The array of creature indices sorted by fitness (highest first) + - exclude_indices: Indices to exclude from selection (e.g., to avoid self-mating) + + Returns: + - The selected creature index + """ + if exclude_indices is None: + exclude_indices = [] + + # Create weights inversely proportional to rank + if weights is None: + weights = np.array([max(0.03, 1.0 - (r / len(rankings))) for r in range(len(rankings))]) + + # Zero out excluded indices + for idx in exclude_indices: + if 0 <= idx < len(weights): + weights[idx] = 0 + + # Normalize weights + if np.sum(weights) > 0: + weights = weights / np.sum(weights) + else: + # If all weights are zero, use uniform distribution among non-excluded indices + weights = np.ones(len(rankings)) + for idx in exclude_indices: + if 0 <= idx < len(weights): + weights[idx] = 0 + weights = weights / np.sum(weights) + + # Sample from the distribution + selected_rank = np.random.choice(len(rankings), p=weights) + return rankings[selected_rank] + + def are_creatures_compatible(self, creature1: Creature, creature2: Creature): + """ + Determine if two creatures are compatible for sexual reproduction. + They should be genetically similar but not identical, and preferably of the same species. + """ + if creature1.species != creature2.species: + return False + + if creature1.max_offspring <= creature1.generation_offspring or creature2.max_offspring <= creature2.generation_offspring: + return False + + similarity = creature1.calculate_dna_similarity(creature2) + + # Creatures must be somewhat similar but not identical + if similarity < 0.7 or similarity == 1.0: + return False + + # Creatures of the same species are always compatible if they meet similarity criteria + + + # Different species might still be compatible if they're genetically similar enough + # This allows for occasional cross-species breeding + # if similarity > 0.85: + # return True + + return True + + def sexual_reproduce(self, parent1: Creature, parent2: Creature, child_id): + """ + Perform sexual reproduction between two creatures, creating a child with mixed DNA. + """ + self.logger.info(f"Performing sexual reproduction. P1: {parent1} P2: {parent2}") + # Get DNA from both parents + dna1 = parent1.dna + dna2 = parent2.dna + + # Create child DNA through recombination + # We'll use a simple crossover method with some random variation + child_dna = np.zeros_like(dna1) + choice_array = np.random.random(dna1.shape) + # Create masks for each inheritance type + parent1_mask = (choice_array < 0.4) + parent2_mask = (choice_array >= 0.4) & (choice_array < 0.8) + blend_mask = (choice_array >= 0.8) + + child_dna[parent1_mask] = dna1[parent1_mask] + child_dna[parent2_mask] = dna2[parent2_mask] + child_dna[blend_mask] = (dna1[blend_mask] + dna2[blend_mask]) / 2.0 + + # Add small random mutations + mutation = np.clip(np.random.normal(0.0, 0.5, child_dna.shape[0]), -1, 1) # Smaller mutations than regular mutation + child_dna += self.mutation_rate * 0.5 * mutation # Half the normal mutation rate + + # Determine child's species + # Usually, child inherits species from the more fit parent + # But occasionally, a new species emerges + # if random.random() < 0.05: # DOES NOTHING + # # new_species = self.species_count + # # self.species_count += 1 + # # # Create new creature with new species + # # self.logger.info(f"Creating new species. Reason: Why not. ID: {new_species}") + # # child = Creature(child_dna, child_id, new_species, self, self.ui) + # # # Create new species info record + # # more_fit_parent = parent1 if parent1.fitness > parent2.fitness else parent2 + # # self.species_info.append(SpeciesInfo(self, child, more_fit_parent)) + # parent_species = parent1.species if parent1.fitness > parent2.fitness else parent2.species + # child = Creature(child_dna, child_id, parent_species, self, self.ui) + # else: + # # Inherit species from more fit parent + species = parent1.species if parent1.fitness > parent2.fitness else parent2.species + + generation = len(self.creatures) - 1 + self.creature_generations[child_id] = generation + parent1.generation_offspring += 1 + parent2.generation_offspring += 1 + if parent1.check_if_new_species(child_dna): + species = self.species_count + self.species_count += 1 + child = Creature(child_dna, child_id, species, self, self.ui) + self.species_info.append(SpeciesInfo(self, child, parent1, generation=generation)) + else: + child = Creature(child_dna, child_id, species, self, self.ui) + + return child def doSpeciesInfo(self,nsp,best_of_each_species): nsp = dict(sorted(nsp.items())) @@ -165,7 +315,6 @@ def doSpeciesInfo(self,nsp,best_of_each_species): def checkALAP(self): if self.ui.ALAPButton.setting == 1: # We're already ALAP-ing! self.doGeneration(self.ui.doGenButton) - def doGeneration(self, button): generation_start_time = time.time() #calculates how long each generation takes to run @@ -175,10 +324,12 @@ def doGeneration(self, button): finalScores = nodeCoor[:,:,:,0].mean(axis=(1, 2)) # find each creature's average X-coordinate # Tallying up all the data - currRankings = np.flip(np.argsort(finalScores),axis=0) + currRankings = np.flip(np.argsort(finalScores), axis=0) newPercentiles = np.zeros((self.HUNDRED+1)) newSpeciesPops = {} best_of_each_species = {} + + # Set fitness and rank for each creature for rank in range(self.c_count): c = currRankings[rank] self.creatures[gen][c].fitness = finalScores[c] @@ -188,62 +339,135 @@ def doGeneration(self, button): if species in newSpeciesPops: newSpeciesPops[species][0] += 1 else: - newSpeciesPops[species] = [1,None,None] + newSpeciesPops[species] = [1, None, None] if species not in best_of_each_species: best_of_each_species[species] = self.creatures[gen][c].IDNumber - self.doSpeciesInfo(newSpeciesPops,best_of_each_species) + + self.doSpeciesInfo(newSpeciesPops, best_of_each_species) + # Calculate percentiles for p in range(self.HUNDRED+1): - rank = min(int(self.c_count*p/self.HUNDRED),self.c_count-1) + rank = min(int(self.c_count*p/self.HUNDRED), self.c_count-1) c = currRankings[rank] newPercentiles[p] = self.creatures[gen][c].fitness + # Prepare for reproduction currCreatures = self.creatures[-1] - nextCreatures = [None]*self.c_count - for rank in range(self.c_count//2): - winner = currRankings[rank] - loser = currRankings[(self.c_count-1)-rank] - if random.uniform(0,1) < rank/self.c_count: - ph = loser - loser = winner - winner = ph - nextCreatures[winner] = None - if random.uniform(0,1) < rank/self.c_count*2.0: # A 1st place finisher is guaranteed to make a clone, but as we get closer to the middle the odds get more likely we just get 2 mutants. - nextCreatures[winner] = self.mutate(self.creatures[gen][winner],(gen+1)*self.c_count+winner) - else: - nextCreatures[winner] = self.clone(self.creatures[gen][winner],(gen+1)*self.c_count+winner) - nextCreatures[loser] = self.mutate(self.creatures[gen][winner],(gen+1)*self.c_count+loser) - self.creatures[gen][loser].living = False + nextCreatures = [None] * self.c_count + # Keep track of parents that have already reproduced + reproduction_count = np.zeros(self.c_count, dtype=int) + + for c in range(self.c_count): + self.creatures[gen][c].living = False + + weights = np.array([max(0.03, 1.0 - (r / len(currCreatures))) for r in range(len(currCreatures))]) + # Fill the new generation with offspring + for new_idx in range(self.c_count): + # Choose a parent with weighted probability based on rank + parent_creature_idx = self.sample_weighted_creature_index(currRankings, weights=weights) + parent_creature = self.creatures[gen][parent_creature_idx] + reproduced = False + species_individuals = newSpeciesPops[parent_creature.species][0] + # Choose reproduction method - 80% chance for sexual, 20% for asexual + # This is independent of creature's rank + if random.random() < 0.8: # Try sexual reproduction + # Find compatible mates + potential_mates = [] + # Try to find up to 5 compatible mates + for _ in range(self.c_count // 2): # Try out population//10 times to find compatible mates + if species_individuals < 2: + # Only a single individual, nothing to check + break + # Sample potential mate with preference for higher fitness + mate_idx = self.sample_weighted_creature_index(currRankings, [parent_creature_idx], weights=weights) + mate_creature = self.creatures[gen][mate_idx] + + if self.are_creatures_compatible(parent_creature, mate_creature): + potential_mates.append(mate_idx) + if len(potential_mates) >= 1: + break + + if potential_mates: + # Choose one of the compatible mates randomly + mate_idx = random.choice(potential_mates) + mate_creature = self.creatures[gen][mate_idx] + + # Create offspring through sexual reproduction + nextCreatures[new_idx] = self.sexual_reproduce( + parent_creature, + mate_creature, + (gen+1) * self.c_count + new_idx + ) + + # Increment reproduction counters + reproduction_count[parent_creature_idx] += 1 + reproduction_count[mate_idx] += 1 + reproduced = True + mate_creature.living = True + continue + + # If sexual reproduction wasn't chosen or failed, do asexual reproduction + # 30% chance for cloning, 70% for mutation unless less than 4 individuals alive, then favor cloning + if not reproduced: + if (random.random() * min(species_individuals, 4) / 4.0) < 0.3: + nextCreatures[new_idx] = self.clone( + parent_creature, + (gen+1) * self.c_count + new_idx + ) + parent_creature + else: + nextCreatures[new_idx] = self.mutate( + parent_creature, + (gen+1) * self.c_count + new_idx + ) + + # Increment reproduction counter + reproduction_count[parent_creature_idx] += 1 + parent_creature.living = True + + # Add the new generation to the simulation self.creatures.append(nextCreatures) - self.rankings = np.append(self.rankings,currRankings.reshape((1,self.c_count)),axis=0) - self.percentiles = np.append(self.percentiles,newPercentiles.reshape((1,self.HUNDRED+1)),axis=0) + self.rankings = np.append(self.rankings, currRankings.reshape((1, self.c_count)), axis=0) + self.percentiles = np.append(self.percentiles, newPercentiles.reshape((1, self.HUNDRED+1)), axis=0) self.species_pops.append(newSpeciesPops) - + graph_start = time.time() drawAllGraphs(self, self.ui) + graph_end = time.time() - self.getCalmStates(gen+1,0,self.c_count,self.stabilization_time,True) - #Calm the creatures down so no potential energy is stored + # Calm the creatures down so no potential energy is stored + self.getCalmStates(gen+1, 0, self.c_count, self.stabilization_time, True) for c in range(self.c_count): for i in range(2): - self.creatures[gen+1][c].icons[i] = self.creatures[gen+1][c].drawIcon(self.ui.ICON_DIM[i], self.ui.MOSAIC_COLOR, self.beat_fade_time) - + self.creatures[gen+1][c].icons[i] = self.creatures[gen+1][c].drawIcon( + self.ui.ICON_DIM[i], + self.ui.MOSAIC_COLOR, + self.beat_fade_time + ) + + # Update UI self.ui.genSlider.val_max = gen+1 self.ui.genSlider.manualUpdate(gen) - self.last_gen_run_time = time.time()-generation_start_time + self.last_gen_run_time = time.time() - generation_start_time self.ui.detectMouseMotion() - + self.checkALAP() + self.logger.info(f"Graph draw time: {graph_end - graph_start}s") + def getCreatureWithID(self, ID): return self.creatures[ID//self.c_count][ID%self.c_count] def clone(self, parent, newID): + generation = len(self.creatures) - 1 + self.creature_generations[newID] = generation return Creature(parent.dna, newID, parent.species, self, self.ui) def mutate(self, parent, newID): newDNA, newSpecies, cwc = parent.getMutatedDNA(self) newCreature = Creature(newDNA, newID, newSpecies, self, self.ui) + generation = len(self.creatures) - 1 + self.creature_generations[newID] = generation if newCreature.species != parent.species: - self.species_info.append(SpeciesInfo(self,newCreature,parent)) + self.species_info.append(SpeciesInfo(self, newCreature, parent, generation=generation)) newCreature.codonWithChange = cwc return newCreature \ No newline at end of file diff --git a/jes_species_info.py b/jes_species_info.py index 7d79462..bafaeb2 100644 --- a/jes_species_info.py +++ b/jes_species_info.py @@ -1,19 +1,20 @@ import numpy as np from hashlib import sha256 import math +from utils import species_to_name class SpeciesInfo: - def __init__(self, _sim, me, ancestor): + def __init__(self, _sim, me, ancestor, generation): self.sim = _sim self.speciesID = me.species self.ancestorID = None + self.generation = generation self.level = 0 if ancestor is not None: self.ancestorID = ancestor.species self.level = self.sim.species_info[ancestor.species].level+1 self.apex_pop = 0 - self.reign = [] self.reps = np.zeros((4), dtype=int) # Representative ancestor, first, apex, and last creatures of this species. self.prominent = False @@ -21,6 +22,9 @@ def __init__(self, _sim, me, ancestor): self.reps[0] = ancestor.IDNumber self.reps[1] = me.IDNumber self.coor = None + + def __str__(self): + return f"{species_to_name(self.speciesID, self.sim.ui)}" def becomeProminent(self): # if you are prominent, all your ancestors become prominent. self.prominent = True diff --git a/jes_ui.py b/jes_ui.py index 02c536b..3ca4da4 100644 --- a/jes_ui.py +++ b/jes_ui.py @@ -15,9 +15,9 @@ def __init__(self, _W_W, _W_H, _MOVIE_SINGLE_DIM, _GRAPH_COOR, _SAC_COOR, _GENEA self.sliderList = [] self.buttonList = [] pygame.font.init() - self.bigFont = pygame.font.Font('C:/Users/caryk/AppData/Local/Microsoft/Windows/Fonts/Jygquip 1.ttf', 60) - self.smallFont = pygame.font.Font('C:/Users/caryk/AppData/Local/Microsoft/Windows/Fonts/Jygquip 1.ttf', 30) - self.tinyFont = pygame.font.Font('C:/Users/caryk/AppData/Local/Microsoft/Windows/Fonts/Jygquip 1.ttf', 21) + self.bigFont = pygame.font.SysFont('arial', 60) + self.smallFont = pygame.font.SysFont('arial', 30) + self.tinyFont = pygame.font.SysFont('arial', 21) self.BACKGROUND_PIC = pygame.image.load("visuals/background.png") self.W_W = _W_W self.W_H = _W_H @@ -331,7 +331,7 @@ def doMovies(self): if self.sample_frames >= self.sim.trial_time+self.SAMPLE_FREEZE_TIME: self.startSampleHelper() for i in range(L): - if self.visualSimMemory[i][2] < self.sim.trial_time: + if self.visualSimMemory[i][2] < self.sim.trial_time: self.visualSimMemory[i] = self.sim.simulateRun(self.visualSimMemory[i], 1, False) DIM = arrayIntMultiply(self.MOVIE_SINGLE_DIM, MSCALE[self.CLH[0]]) self.movieScreens[i] = pygame.Surface(DIM, pygame.SRCALPHA, 32) From fa80b39566a5eb48df21906f9e97c764f1f35089 Mon Sep 17 00:00:00 2001 From: Matas Minelga Date: Sat, 26 Apr 2025 15:59:07 +0300 Subject: [PATCH 2/2] Functional sexual reproduction --- jes.py | 4 +- jes_creature.py | 21 +++---- jes_sim.py | 152 +++++++++++++++++++++++++++++++++++++----------- 3 files changed, 130 insertions(+), 47 deletions(-) diff --git a/jes.py b/jes.py index 107a28c..9d60be5 100644 --- a/jes.py +++ b/jes.py @@ -30,7 +30,7 @@ def setup_logging(level=logging.INFO, log_file='app.log'): # Simulation # population size is 250 here, because that runs faster. You can increase it to 500 to replicate what was in my video, but do that at your own risk! logger = setup_logging() -sim = Sim(_c_count=int(c_input), _stabilization_time=200, _trial_time=300, +sim = Sim(_c_count=int(c_input), _stabilization_time=200, _trial_time=600, _beat_time=20, _beat_fade_time=5, _c_dim=[4,4], _beats_per_cycle=3, _node_coor_count=4, # x_position, y_position, x_velocity, y_velocity _y_clips=[-10000000,0], _ground_friction_coef=25, @@ -38,7 +38,7 @@ def setup_logging(level=logging.INFO, log_file='app.log'): _typical_friction_coef=0.8, _muscle_coef=0.08, _traits_per_box=3, # desired width, desired height, rigidity _traits_extra=1, # heartbeat (time) -_mutation_rate=0.07, _big_mutation_rate=0.025, +mutation_size=0.05, big_mutation_size=0.1, mutation_rate=0.05, big_mutation_rate=0.1, _UNITS_PER_METER=0.05, logger=logger) # Cosmetic UI variables diff --git a/jes_creature.py b/jes_creature.py index 774f373..7242f6f 100644 --- a/jes_creature.py +++ b/jes_creature.py @@ -9,7 +9,7 @@ import logging class Creature: - def __init__(self,d,pIDNumber,parent_species,_sim,_ui, max_offspring=4, logger=None, mutate_rate=0.2): + def __init__(self,d,pIDNumber,parent_species,_sim,_ui, max_offspring=4, logger=None, mutate_rate=0.05): self.logger = logger or logging.getLogger(__name__) self.dna = d self.calmState = None @@ -25,8 +25,6 @@ def __init__(self,d,pIDNumber,parent_species,_sim,_ui, max_offspring=4, logger=N self.codonWithChange = None self.max_offspring = max_offspring self.generation_offspring = 0 - self.species_threshold = 0.95 - self.mutate_rate = mutate_rate def __str__(self): return f"ID: {self.IDNumber:<5} S: {str(self.sim.species_info[self.species]):<10} OG: {self.generation_offspring:<4}" @@ -111,7 +109,7 @@ def check_if_new_species(self, other_dna): species_rep_dna = self.sim.getCreatureWithID(sp_info.reps[1]).dna new_creature_similarity = self.calculate_raw_dna_similarity(species_rep_dna, other_dna) - if new_creature_similarity < self.species_threshold: + if new_creature_similarity < self.sim.species_threshold: self.logger.info(f"Creating new species, as offspring similarity is below threshold: {new_creature_similarity}") return True return False @@ -119,8 +117,8 @@ def check_if_new_species(self, other_dna): def getMutatedDNA(self, sim): mutation = np.clip(np.random.normal(-1.0, 1.0, self.dna.shape[0]),-99,99) - mutation_mask = np.random.random(self.dna.shape) < self.mutate_rate - result = self.dna + sim.mutation_rate*mutation * mutation_mask + mutation_mask = np.random.random(self.dna.shape) < sim.mutation_rate + result = self.dna + sim.mutation_size * mutation * mutation_mask newSpecies = self.species big_mut_loc = 0 @@ -132,8 +130,7 @@ def getMutatedDNA(self, sim): return result, newSpecies, big_mut_loc if random.uniform(0,1) < self.sim.big_mutation_rate: # do a big mutation - newSpecies = sim.species_count - sim.species_count += 1 + cell_x = random.randint(0,self.sim.CW-1) cell_y = random.randint(0,self.sim.CH-1) cell_beat = random.randint(0,self.sim.beats_per_cycle-1) @@ -142,13 +139,17 @@ def getMutatedDNA(self, sim): for i in range(self.sim.traits_per_box): delta = 0 while abs(delta) < 0.5: - delta = np.random.normal(0.0, 1.0, 1) + delta = np.random.normal(-1.0, 1.0, 1) result[big_mut_loc+i] += delta #Cells that endure a big mutation are also required to be at least somewhat rigid, because if a cell goes from super-short to super-tall but has low rigidity the whole time, then it doesn't really matter. if i == 2 and result[big_mut_loc+i] < 0.5: result[big_mut_loc+i] = 0.5 - + + if self.check_if_new_species(result): + newSpecies = sim.species_count + sim.species_count += 1 + return result, newSpecies, big_mut_loc def calculate_dna_similarity(self, other_creature): """ diff --git a/jes_sim.py b/jes_sim.py index 6eb8499..8e719d0 100644 --- a/jes_sim.py +++ b/jes_sim.py @@ -12,7 +12,7 @@ def __init__(self, _c_count, _stabilization_time, _trial_time, _beat_time, _beat_fade_time, _c_dim, _beats_per_cycle, _node_coor_count, _y_clips, _ground_friction_coef, _gravity_acceleration_coef, _calming_friction_coef, _typical_friction_coef, _muscle_coef, - _traits_per_box, _traits_extra, _mutation_rate, _big_mutation_rate, _UNITS_PER_METER, logger=None): + _traits_per_box, _traits_extra, mutation_size, big_mutation_size, _UNITS_PER_METER, logger=None, mutation_rate=0.05, big_mutation_rate=0.1, species_threshold=0.95, sexual_reproduction_chance=0.75): self.logger = logger or logging.getLogger(__name__) self.c_count = _c_count #creature count @@ -36,8 +36,16 @@ def __init__(self, _c_count, _stabilization_time, _trial_time, _beat_time, self.traits_extra = _traits_extra self.trait_count = self.CW*self.CH*self.beats_per_cycle*self.traits_per_box+self.traits_extra - self.mutation_rate = _mutation_rate - self.big_mutation_rate = _big_mutation_rate + self.mutation_size = mutation_size + self.big_mutation_size = big_mutation_size + + self.mutation_rate = mutation_rate + self.big_mutation_rate = big_mutation_rate + + self.species_threshold = species_threshold + + self.sexual_reproduction_chance = sexual_reproduction_chance + self.average_reproductions_per_creature = 1.1 self.S_VISIBLE = 0.05 #what proportion of the population does a species need to appear on the SAC graph? self.S_NOTABLE = 0.10 #what proportion of the population does a species need to appear in the genealogy? @@ -218,13 +226,13 @@ def are_creatures_compatible(self, creature1: Creature, creature2: Creature): if creature1.species != creature2.species: return False - if creature1.max_offspring <= creature1.generation_offspring or creature2.max_offspring <= creature2.generation_offspring: - return False + # if creature1.max_offspring <= creature1.generation_offspring or creature2.max_offspring <= creature2.generation_offspring: + # return False similarity = creature1.calculate_dna_similarity(creature2) - # Creatures must be somewhat similar but not identical - if similarity < 0.7 or similarity == 1.0: + # Creatures must be somewhat similar + if similarity < 0.85: return False # Creatures of the same species are always compatible if they meet similarity criteria @@ -258,10 +266,12 @@ def sexual_reproduce(self, parent1: Creature, parent2: Creature, child_id): child_dna[parent1_mask] = dna1[parent1_mask] child_dna[parent2_mask] = dna2[parent2_mask] child_dna[blend_mask] = (dna1[blend_mask] + dna2[blend_mask]) / 2.0 + + mutate_mask = np.random.random(dna1.shape) < self.mutation_rate # Add small random mutations - mutation = np.clip(np.random.normal(0.0, 0.5, child_dna.shape[0]), -1, 1) # Smaller mutations than regular mutation - child_dna += self.mutation_rate * 0.5 * mutation # Half the normal mutation rate + mutation = np.clip(np.random.normal(-1.0, 1.0, child_dna.shape[0]), -99, 99) + child_dna += self.mutation_size * 0.5 * mutation * mutate_mask # Half the normal mutation size # Determine child's species # Usually, child inherits species from the more fit parent @@ -315,6 +325,59 @@ def doSpeciesInfo(self,nsp,best_of_each_species): def checkALAP(self): if self.ui.ALAPButton.setting == 1: # We're already ALAP-ing! self.doGeneration(self.ui.doGenButton) + + + def sample_weighted_by_species(self, creature_matrix, species_id, max_reproductions=2, excluded_indices=None, sample_size=10): + """ + Sample indices weighted by fitness from creatures of a specific species. + + Args: + creature_matrix: NumPy array with shape (n, 3) where: + - column 0 is species ID + - column 1 is fitness + - column 2 is reproduction count + species_id: The species ID to sample from + max_reproductions: Maximum number of times a creature can reproduce + excluded_indices: Optional list/array of indices to exclude from sampling + + Returns: + Index of the sampled creature, or None if no valid creatures found + """ + # Filter by species and reproduction count + valid_mask = (creature_matrix[:, 0] == species_id) & (creature_matrix[:, 2] < max_reproductions) + + # Also filter out excluded indices if provided + if excluded_indices is not None: + # Create a mask of indices to exclude (True for positions to exclude) + exclude_mask = np.zeros(len(creature_matrix), dtype=bool) + exclude_mask[excluded_indices] = True + # Update valid_mask to exclude these indices + valid_mask = valid_mask & ~exclude_mask + + valid_indices = np.where(valid_mask)[0] + + # Return None if no valid creatures + if len(valid_indices) == 0: + return None + + # Determine actual sample size (can't sample more than available) + actual_sample_size = min(sample_size, len(valid_indices)) + + # Get fitness values for valid creatures + fitness_values = creature_matrix[valid_indices, 1] + + # Apply softmax-like normalization to exaggerate differences + temperature = 1.0 + weights = np.exp(fitness_values / temperature) + + # Normalize to get probabilities + probabilities = weights / np.sum(weights) + + # Sample based on probabilities + sampled_indices = np.random.choice(valid_indices, size=actual_sample_size, p=probabilities, replace=False) + + return sampled_indices + def doGeneration(self, button): generation_start_time = time.time() #calculates how long each generation takes to run @@ -355,42 +418,58 @@ def doGeneration(self, button): currCreatures = self.creatures[-1] nextCreatures = [None] * self.c_count - # Keep track of parents that have already reproduced - reproduction_count = np.zeros(self.c_count, dtype=int) for c in range(self.c_count): self.creatures[gen][c].living = False weights = np.array([max(0.03, 1.0 - (r / len(currCreatures))) for r in range(len(currCreatures))]) # Fill the new generation with offspring + creature_species_matrix = np.zeros((self.c_count, 3), dtype=int) + for c in range(self.c_count): + creature = self.creatures[gen][c] + creature_species_matrix[c, 0] = creature.species + creature_species_matrix[c, 1] = creature.fitness + creature_species_matrix[c, 2] = 0 # Times reproduced already + for new_idx in range(self.c_count): # Choose a parent with weighted probability based on rank parent_creature_idx = self.sample_weighted_creature_index(currRankings, weights=weights) + # make sure we don't reproduce too much + while creature_species_matrix[parent_creature_idx, 2] + 1 >= self.average_reproductions_per_creature: + if creature_species_matrix[parent_creature_idx, 2] - 1 < self.average_reproductions_per_creature: + if random.random() < 0.2: + break + parent_creature_idx = self.sample_weighted_creature_index(currRankings, weights=weights) + parent_creature = self.creatures[gen][parent_creature_idx] reproduced = False - species_individuals = newSpeciesPops[parent_creature.species][0] - # Choose reproduction method - 80% chance for sexual, 20% for asexual - # This is independent of creature's rank - if random.random() < 0.8: # Try sexual reproduction + # Choose reproduction method + if random.random() < self.sexual_reproduction_chance: # Try sexual reproduction # Find compatible mates - potential_mates = [] - # Try to find up to 5 compatible mates - for _ in range(self.c_count // 2): # Try out population//10 times to find compatible mates - if species_individuals < 2: - # Only a single individual, nothing to check - break - # Sample potential mate with preference for higher fitness - mate_idx = self.sample_weighted_creature_index(currRankings, [parent_creature_idx], weights=weights) - mate_creature = self.creatures[gen][mate_idx] - - if self.are_creatures_compatible(parent_creature, mate_creature): - potential_mates.append(mate_idx) - if len(potential_mates) >= 1: - break + # Sample potential mates with preference for higher fitness + potential_mates_sample = self.sample_weighted_by_species( + creature_matrix=creature_species_matrix, + species_id=creature_species_matrix[parent_creature_idx][0], + max_reproductions=int(self.average_reproductions_per_creature*10), + excluded_indices=parent_creature_idx, + sample_size=1 + ) + # if potential_mates_sample is None: + # potential_mates_sample = [] + # No need to check compatibility, species must be compatible between itself + # for mate_idx in potential_mates_sample: + # mate_creature = self.creatures[gen][mate_idx] + # self.logger.info(f"Checking creature compatibility: {parent_creature} | {mate_creature}") + # self.logger.info(f"Checking creature compatibility: {parent_creature.fitness} | {mate_creature.fitness}") + + # if self.are_creatures_compatible(parent_creature, mate_creature): + # potential_mates.append(mate_idx) + # if len(potential_mates) >= 1: + # break - if potential_mates: + if potential_mates_sample is not None: # Choose one of the compatible mates randomly - mate_idx = random.choice(potential_mates) + mate_idx = random.choice(potential_mates_sample) mate_creature = self.creatures[gen][mate_idx] # Create offspring through sexual reproduction @@ -401,16 +480,19 @@ def doGeneration(self, button): ) # Increment reproduction counters - reproduction_count[parent_creature_idx] += 1 - reproduction_count[mate_idx] += 1 + creature_species_matrix[parent_creature_idx, 2] += 1 + # We're the ones giving birth, mate just added genetic material + # creature_species_matrix[mate_idx, 2] += 1 reproduced = True mate_creature.living = True + parent_creature.living = True continue # If sexual reproduction wasn't chosen or failed, do asexual reproduction # 30% chance for cloning, 70% for mutation unless less than 4 individuals alive, then favor cloning if not reproduced: - if (random.random() * min(species_individuals, 4) / 4.0) < 0.3: + species_individuals_num = (creature_species_matrix[:, 0] == parent_creature.species).sum() + if (random.random() * min(species_individuals_num, 4) / 4.0) < 0.3: nextCreatures[new_idx] = self.clone( parent_creature, (gen+1) * self.c_count + new_idx @@ -423,7 +505,7 @@ def doGeneration(self, button): ) # Increment reproduction counter - reproduction_count[parent_creature_idx] += 1 + creature_species_matrix[parent_creature_idx, 2] += 1 parent_creature.living = True # Add the new generation to the simulation