diff --git a/src/site/snake/canvas.ts b/src/site/snake/canvas.ts index e08e37e..3433ea4 100644 --- a/src/site/snake/canvas.ts +++ b/src/site/snake/canvas.ts @@ -3,6 +3,7 @@ import { Engine, Keys, randint, UI, Vec2, vec2 } from './game-engine'; import { SGSHashSet, Snake, SnakeGameState, SnakeGameStateWithHistory } from './types'; import { PipeRef } from '.'; import { + alignGenomes, assignSpecies, CompatibilityDistanceConfig, CompatibilityDistanceThreshold, @@ -15,9 +16,10 @@ import { MutateConfig, NextGenerationConfig, resetGlobalIDs, + SpeciesID, } from './neat'; import { BASE_GENOME_SNAKE_BRAIN_NEAT, NEATSnakeBrain } from './neat-snake-brain'; -import { mapInvert, mapMap } from './util'; +import { histogram, mapInvert, mapMap } from './util'; const BOARD_SIZE = 600; // px const SQUARE_SIZE = 30; // px @@ -132,37 +134,37 @@ export default function runCanvas(canvas: HTMLCanvasElement, pipeRef: MutableRef // game logic -------------------------------------------------------------- - const SNAKES = 150; + const SNAKES = 1000; const STARVE_STEPS = BOARD_SQUARES * 6; // neat configuration ------------------------------------------------------ // TODO: this configuration is not learning // figure out why, maybe scoring is not proper -- or speciation is too forced const FC: FertilityConfig = { - fertile_threshold: 0.6, + fertile_threshold: 0.8, champion_min_species_size: 3, }; const MCC: MateChoiceConfig = { - asexual_rate: 0.5, - interspecies_rate: 0.2, + asexual_rate: 1.0, + interspecies_rate: 0.0, }; const CC: CrossConfig = { reenable_rate: 0.25, }; const MC: MutateConfig = { - mutate_rate: 0.25, - assign_rate: 0.1, + mutate_rate: 0.1, + assign_rate: 0.9, assign_mag: 1.0, - perturb_mag: 0.1, - new_node_rate: 0.2, - new_connection_rate: 0.1, + perturb_mag: 0.2, + new_node_rate: 0.1, + new_connection_rate: 0.35, }; const CDC: CompatibilityDistanceConfig = { - c1: 1, - c2: 1, - c3: 1, + c1: 1.0, + c2: 1.0, + c3: 0.4, }; - const CDT: CompatibilityDistanceThreshold = 1.0; + const CDT: CompatibilityDistanceThreshold = 100.0; const NGC: NextGenerationConfig = { fc: FC, mcc: MCC, @@ -172,6 +174,15 @@ export default function runCanvas(canvas: HTMLCanvasElement, pipeRef: MutableRef cdt: CDT, }; + const MC_INITIAL_POPULATION: MutateConfig = { + mutate_rate: 1.0, + assign_rate: 1.0, + assign_mag: 1.0, + perturb_mag: 0.0, + new_node_rate: 0.0, + new_connection_rate: 0.0, + }; + // general simulation ------------------------------------------------------ let generation = 1; @@ -179,11 +190,13 @@ export default function runCanvas(canvas: HTMLCanvasElement, pipeRef: MutableRef resetGlobalIDs({ node_id: 1, innovation_number: 1, species_id: 1 }); // eslint-disable-next-line prefer-spread - const initialGenomes = Array.apply(null, Array(SNAKES)).map(() => mutate(BASE_GENOME_SNAKE_BRAIN_NEAT, MC)); + const initialGenomes = Array.apply(null, Array(SNAKES)).map(() => + mutate(BASE_GENOME_SNAKE_BRAIN_NEAT, MC_INITIAL_POPULATION), + ); // assign initial species - let population = new Map(); - let reps = new Map(); + let population = new Map(); + let reps = new Map(); for (const genome of initialGenomes) { const sid = assignSpecies(genome, reps, CDC, CDT); population.set(genome, sid); @@ -208,20 +221,54 @@ export default function runCanvas(canvas: HTMLCanvasElement, pipeRef: MutableRef const allDead = trainer.labs.findIndex(l => !l.state.dead) === -1; if (allDead) { - console.log('computing next gen'); - // compute next generation + // get fitness into the correct structure const fitness = new Map(); for (const lab of trainer.labs) { fitness.set(lab.brain.brain.genome, lab.state.snake.length); } + + // log some stats + const avgFitness = Array.from(fitness.values()).reduce((a, b) => a + b) / fitness.size; + const speciesSizes = mapMap(mapInvert(population), (k, v) => [k, v.size]); + let speciesSizesStr = ''; + for (const [sid, size] of speciesSizes.entries()) { + speciesSizesStr += ` ${sid}: ${size}\n`; + } + const top10Fitness = Array.from(fitness.values()) + .sort((a, b) => a - b) + .slice(-10); + + console.log( + `g${generation}\naverage fitness: ${avgFitness}\nspecies sizes:\n${speciesSizesStr}top 10 fitness: ${top10Fitness.join(', ')}`, + ); + + // compute next generation const { nextPopulation, nextReps } = computeNextGeneration(population, fitness, NGC); population = nextPopulation; reps = nextReps; - console.log({ nextPopulationSizes: mapMap(mapInvert(nextPopulation), (k, v) => [k, v.size]) }); + let repsStr = 'reps:\n'; + for (const [sid, rep] of reps) { + const { excess } = alignGenomes(BASE_GENOME_SNAKE_BRAIN_NEAT, rep); + const excessStr = excess.map(n => `${n.dad?.src_id} -> ${n.dad?.dst_id}`).join(', '); + repsStr += `${sid}: [${excessStr}]\n`; + } + console.log(repsStr); trainer = { labs: Array.from(population.keys()).map(g => makeLab(nextLabId++, g)), }; generation++; + + // pause after given generation + if (generation === 20) { + const weights = Array.from(population.keys()).flatMap(g => g.map(n => n.data.weight)); + const weightsHistogram = histogram(weights, { bucketCount: 100 }); + let weightsHistoStr = ''; + for (const [range, count] of weightsHistogram.entries()) { + weightsHistoStr += `${range}: ${count}\n`; + } + console.log(weightsHistoStr); + debugger; + } } for (const lab of trainer.labs) { @@ -292,6 +339,8 @@ export default function runCanvas(canvas: HTMLCanvasElement, pipeRef: MutableRef ui.setFillStyle('#333333'); ui.clearCanvas(); + return; + const labsAlive = trainer.labs.filter(l => !l.state.dead); const labsRanked = labsAlive.slice().sort((a, b) => a.state.snake.length - b.state.snake.length); diff --git a/src/site/snake/neat.ts b/src/site/snake/neat.ts index a8aa9de..27e93e2 100644 --- a/src/site/snake/neat.ts +++ b/src/site/snake/neat.ts @@ -239,7 +239,7 @@ interface GenomeAlignment { genomes: { mom: Genome; dad: Genome }; } -type SpeciesID = number; +export type SpeciesID = number; export type Population = Map; let g_node_id = 1; @@ -365,13 +365,14 @@ export function tournamentSelectionWithChampions( const species = mapInvert(population); // compute adjusted fitness by scaling fitness by species size - const adjFitness = mapMap(fitness, (k, v) => { - const sid = population.get(k)!; - const spec = species.get(sid)!; - if (!spec) debugger; - const speciesSize = species.get(sid)!.size; - return [k, v / speciesSize]; - }); + const adjFitness = fitness; + // const adjFitness = mapMap(fitness, (k, v) => { + // const sid = population.get(k)!; + // const spec = species.get(sid)!; + // if (!spec) debugger; + // const speciesSize = species.get(sid)!.size; + // return [k, v / speciesSize]; + // }); const winners = new Set(); @@ -407,6 +408,7 @@ export function chooseMate(genome: Genome, population: Population, config: MateC const genomeSID = population.get(genome)!; if (Math.random() < interspecies_rate) { + console.log('interspecies'); // mate with an organism from a different species if possible const otherSpeciesOrganisms = []; for (const [sid, organisms] of species.entries()) { @@ -624,13 +626,18 @@ export function computeNextGeneration( const winners = Array.from(winners_); // convert to an array once for randchoice() const winnersPopulation = new Map(winners.map(w => [w, population.get(w)!])); + const populationFitness = Array.from(population.keys()).reduce((p, w) => p + fitness.get(w)!, 0); + const winnersFitness = winners.reduce((p, w) => p + fitness.get(w)!, 0); + + console.log({ populationFitness, winnersFitnessScaled: (winnersFitness * population.size) / winners.length }); + // copy over champions to the next generation and use them as representatives for their species const nextPopulation = mapMap(champions, (sid, c) => [c, sid]); const nextReps = new Map(champions); while (nextPopulation.size < population.size) { // mate - const mom = randchoice(winners); + const mom = randchoice(winners.flatMap(w => [...Array(5)].map(_ => w))); const dad = chooseMate(mom, winnersPopulation, mcc); const crossed = crossGenomes(mom, dad, fitness, cc); const baby = mutate(crossed, mc); diff --git a/src/site/snake/util.ts b/src/site/snake/util.ts index 506dd2c..e8a9e8f 100644 --- a/src/site/snake/util.ts +++ b/src/site/snake/util.ts @@ -1,3 +1,5 @@ +const EPSILON = 0.0001; + // [-1, 1) export function randomNegPos() { return 2 * (Math.random() - 0.5); @@ -72,3 +74,35 @@ export function setDifference(a: Set, b: Set) { for (const e of b) diff.delete(e); return diff; } + +export function histogram(arr: number[], config: { bucketCount: number }) { + const { bucketCount } = config; + + const min = Math.min(...arr); + const max = Math.max(...arr); + const range = max - min; + + console.log('min:', min, 'max:', max); + + const buckets = new Map(); + + for (const n of arr) { + const bucket = Math.floor(((n - min) / range) * bucketCount); + + const prev = buckets.get(bucket) || 1; + buckets.set(bucket, prev + 1); + } + + const bucketsOrdered = new Map(); + for (const bucket of Array.from(buckets.keys()).sort((a, b) => a - b)) { + bucketsOrdered.set(bucket, buckets.get(bucket)!); + } + + const bucketsBmin = mapMap(bucketsOrdered, (k, v) => { + const bmin = ((k + 0) * range) / bucketCount + min; + const bmax = ((k + 1) * range) / bucketCount + min; + return [`${bmin}->${bmax}`, v]; + }); + + return bucketsBmin; +}