From b33ffc3baacab02f12bb6ec9b30f831516d19a83 Mon Sep 17 00:00:00 2001 From: Michael Peters Date: Wed, 4 Sep 2024 21:00:07 -0700 Subject: [PATCH] fix crossGenomes to prevent cycles --- src/site/snake/canvas.ts | 4 +-- src/site/snake/neat.ts | 61 ++++++++++----------------------------- src/site/snake/network.ts | 3 +- src/test/test-neat.ts | 39 ++++++++++--------------- 4 files changed, 34 insertions(+), 73 deletions(-) diff --git a/src/site/snake/canvas.ts b/src/site/snake/canvas.ts index 4726df9..e3ef837 100644 --- a/src/site/snake/canvas.ts +++ b/src/site/snake/canvas.ts @@ -58,7 +58,7 @@ export interface TrainerSnapshot { // labs and mutation ----------------------------------------------------------- -// TODO: random colors for each species +// TODO: colors based on species number function makeLabColors({ hue, sat, lig }: { hue: number; sat: number; lig: number }): LabColors { const head = `hsl(${hue},${sat}%,${lig}%)`; @@ -343,5 +343,5 @@ export default function runCanvas(canvas: HTMLCanvasElement, pipeRef: MutableRef } keys.bindKeys(); - // engine.run(update, render); + engine.run(update, render); } diff --git a/src/site/snake/neat.ts b/src/site/snake/neat.ts index 70391b2..8db6e49 100644 --- a/src/site/snake/neat.ts +++ b/src/site/snake/neat.ts @@ -141,7 +141,7 @@ * - Mating: * - for each survivor, repeat fertility[i] times: * - select a mate (dad): - * - r_asex chance it is the same organism (TODO: at least one mutation is required?) + * - r_asex chance it is the same organism * - else r_int_sp chance it is from a random other organism from any other species * - else it is from a random other organism from the same species * - compute new genome (baby) for mom x dad @@ -214,18 +214,6 @@ * | 20% | Survival Threshold (your adjusted fitness must be in the top x% to survive to the next generation) * +-------+------- * - * --- TODO -------------------------------------------------------------------- - * - * - Determine reproduction algo - * - Better understand Explicit Fitness Sharing - * - Better understand weights mutation parameter uses - * - * - Implement Paper! - * - * --- Implementation Complexities --------------------------------------------- - * - * - Convert from genome -> neural network topology - * - Effectively pass data from inputs, through hidden nodes, to outputs */ import { edgesToNodes, NodeID, traceParents, RawEdge, traceChildren } from './network'; @@ -453,7 +441,7 @@ export function crossGenomes(mom: Genome, dad: Genome, fitness: Map dadFitness ? 'mom' : 'dad'; - for (const { mom: momGene, dad: dadGene } of alignment.disjoint) { - if (momGene === null) crossed.push(dadGene as Gene); - else if (dadGene === null) crossed.push(momGene); - else if (mostFit === 'mom') crossed.push(momGene); - else if (mostFit === 'dad') crossed.push(dadGene); - // both are equally fit - select at random - else if (Math.random() < 0.5) crossed.push(momGene); - else crossed.push(dadGene); - } - - // excess - for (const { mom: momGene, dad: dadGene } of alignment.excess) { - if (momGene === null) crossed.push(dadGene as Gene); - else if (dadGene === null) crossed.push(momGene as Gene); - else throw Error(`invalid excess alignment: alignment=${JSON.stringify(alignment)}`); + if (momFitness > dadFitness) { + crossed.push(...alignment.disjoint.flatMap(({ mom: momGene }) => (momGene === null ? [] : momGene))); + crossed.push(...alignment.excess.flatMap(({ mom: momGene }) => (momGene === null ? [] : momGene))); + } else { + crossed.push(...alignment.disjoint.flatMap(({ dad: dadGene }) => (dadGene === null ? [] : dadGene))); + crossed.push(...alignment.excess.flatMap(({ dad: dadGene }) => (dadGene === null ? [] : dadGene))); } return crossed; @@ -535,7 +513,6 @@ export function mutateNewConn(newConn: { src_id: NodeID; dst_id: NodeID }, newWe }; } -// TODO: improve name since also excluding source nodes export function findAcyclicInternalNewConns(rawEdges: RawEdge[]): { src_id: NodeID; dst_id: NodeID }[] { // finds potential new connections that are acyclic and are not already connected @@ -548,16 +525,19 @@ export function findAcyclicInternalNewConns(rawEdges: RawEdge[]): const parents = traceParents(nodes); const children = traceChildren(nodes); - // exclude *both* sources and sinks - // - sources are defined during think, connections will be ignored - // - sinks should not be connected? <-- they probably could be, but it would be unclean imo + // exclude sources from being dst nodes *and* exclude sinks from being src nodes const sources = new Set(Array.from(parents.entries()).flatMap(([k, v]) => (v.size === 0 ? [k] : []))); const sinks = new Set(Array.from(children.entries()).flatMap(([k, v]) => (v.size === 0 ? [k] : []))); const acyclic = new Map>(); for (const [nodeID, nodeParents] of parents.entries()) { + if (sinks.has(nodeID)) { + // exclude sinks from being src nodes + acyclic.set(nodeID, new Set()); + continue; + } const nodeParentIDs = setMap(nodeParents, n => n.id); - const exclude = setUnion(nodeParentIDs, sources, sinks, new Set([nodeID])); + const exclude = setUnion(nodeParentIDs, sources, new Set([nodeID])); const nodeIDsAcyclic = setDifference(allNodeIDs, exclude); acyclic.set(nodeID, nodeIDsAcyclic); } @@ -588,7 +568,6 @@ export interface MutateConfig { export function mutate(genome: Genome, config: MutateConfig): Genome { const { mutate_rate, assign_rate, assign_mag, perturb_mag, new_node_rate, new_connection_rate } = config; - console.log('mutating: ', hashGenome(genome), genome); findAcyclicInternalNewConns(genome); const newGenome = genome.map(gene => { @@ -613,8 +592,6 @@ export function mutate(genome: Genome, config: MutateConfig): Genome { } if (Math.random() < new_connection_rate) { - // TODO: figure out why we're not hitting a - // TODO: disallow "source" nodes from being new connection destinations // create a new connection between two *previously unconnected* nodes const options = findAcyclicInternalNewConns(newGenome); if (options.length === 0) { @@ -624,17 +601,12 @@ export function mutate(genome: Genome, config: MutateConfig): Genome { } else { // choose a random connection const newConn = randchoice(options); - console.log('adding new connection...', hashGenome(genome), genome, newConn); - if (new Set(['U', 'D', 'L', 'R']).has(newConn.dst_id)) { - debugger; - } const newGene = mutateNewConn(newConn, assign_mag * randomNegPos()); newGenome.push(newGene); } findAcyclicInternalNewConns(newGenome); } - console.log('created: ', hashGenome(newGenome), newGenome); return newGenome; } @@ -667,7 +639,6 @@ export function computeNextGeneration( const mom = randchoice(winners); const dad = chooseMate(mom, winnersPopulation, mcc); const crossed = crossGenomes(mom, dad, fitness, cc); - // TODO: crossed has cycles! const baby = mutate(crossed, mc); // assign to a species + add to next generation diff --git a/src/site/snake/network.ts b/src/site/snake/network.ts index 981851e..778eb59 100644 --- a/src/site/snake/network.ts +++ b/src/site/snake/network.ts @@ -78,9 +78,8 @@ export function traceParents(nodes: Network>) { const parents = new Map>>(); function traceNodeParents(node: Node, depth: number): Set> { - console.log('tracing: ', node.id, depth); - // TODO: there's a particularly nasty issue where maybe nodes are not just from their own genome? if (depth > 20) { + // TODO: remove this check, it mostly just finds cycles debugger; } if (parents.has(node.id)) return parents.get(node.id)!; diff --git a/src/test/test-neat.ts b/src/test/test-neat.ts index bedc89f..118bb9d 100644 --- a/src/test/test-neat.ts +++ b/src/test/test-neat.ts @@ -267,14 +267,14 @@ function testCrossGenomes() { ]; const fitness = new Map(); - fitness.set(genomeA, 2); - fitness.set(genomeB, 1); + fitness.set(genomeA, 1); + fitness.set(genomeB, 2); const cc = { reenable_rate: 1 }; const crossed = crossGenomes(genomeA, genomeB, fitness, cc); - assert(crossed.length === 10); + assert(crossed.length === 9); // matching assert(crossed[0]!.src_id === 'A'); @@ -297,22 +297,13 @@ function testCrossGenomes() { assert(crossed[4]!.data.weight === 5 || crossed[4]!.data.weight === 3); assert(crossed[4]!.data.enabled === true); // always re-enabled since reenable_rate = 1 - // disjoint + // disjoint (all from genomeB) assert(crossed[5]!.data.innovation === 6); - assert(crossed[5]!.data.weight === 5); - assert(crossed[6]!.data.innovation === 7); - assert(crossed[6]!.data.weight === 5); - assert(crossed[7]!.data.innovation === 8); - assert(crossed[7]!.data.weight === 7); - - // excess - assert(crossed[8]!.data.innovation === 9); - assert(crossed[8]!.data.weight === 5); - - assert(crossed[9]!.data.innovation === 10); - assert(crossed[9]!.data.weight === 5); + // excess (all from genomeB) + assert(crossed[7]!.data.innovation === 9); + assert(crossed[8]!.data.innovation === 10); } addTest(testCrossGenomes); @@ -380,21 +371,21 @@ function testFindAcyclicInternalNewConns() { const options = findAcyclicInternalNewConns(edges); const expected = [ // not A -> B - B is a source - // not A -> F - F is a sink + { src_id: 'A', dst_id: 'F' }, // not B -> A - A is a source { src_id: 'B', dst_id: 'C' }, - // not B -> E - E is a sink - // not B -> F - F is a sink + { src_id: 'B', dst_id: 'E' }, + { src_id: 'B', dst_id: 'F' }, // not C -> B - B is a source { src_id: 'C', dst_id: 'D' }, - // not C -> F - F is a sink + { src_id: 'C', dst_id: 'F' }, { src_id: 'D', dst_id: 'C' }, // not E -> B - B is a parent of E // not E -> F - F is a sink - // not F -> A - A is a parent of F - // not F -> B - B is a parent of F - { src_id: 'F', dst_id: 'C' }, - // not F -> E - E is a sink + // not F -> A - F is a sink + // not F -> B - F is a sink + // not F -> C - F is a sink + // not F -> E - F is a sink ]; function strcmp(a: string, b: string) {