UNPKG

justneat

Version:
583 lines (542 loc) 23 kB
const Node = require('./models/neatNode') const Connection = require('./models/neatConnection') const Genome = require('./models/neatGenome') const Client = require('./models/client') const NodeType = require('./models/nodeType') const { randomRange } = require('./helpers/mathHelper') const { rouletteSelectClientArray } = require('./helpers/arrHelper') const { aNm } = require('./helpers/activationHelper') const { lFn, lNm } = require('./helpers/lossHelper') const { opts: defaultOpts, probs: defaultProbs, hyper: defaultHyper } = require('./models/config') class Neat { constructor(inputs, outputs, opts, fromJSON = false) { opts = {...defaultOpts, ...opts } this.inputs = inputs this.outputs = outputs this.outputActivation = opts.outputActivation this.hiddenActivation = opts.hiddenActivation this.allowedActivations = opts.allowedActivations this.lossFn = opts.lossFn this.maxPop = opts.maxPop this.pop = [] this.connectionPool = {} this.connections = [] this.replacePool = {} this.currentConnections = 0 this.nodePool = [] this.mandatoryNodes = [] this.hyper = {...defaultHyper, ...opts.hyper } this.probs = {...defaultProbs, ...opts.probs } if (!opts.recurrent) this.probs.addRecurrentChance = 0 this.prevSpecScores = {} this.dropoffTracker = {} this.nextPruneComplexity = 0 this.pruning = false this.lastMCP = 0 this.mcpFloorCount = 0 this.lastPopFitness = 0 this.currentPopFitness = 0 this.fitnessPlatauCount = 0 if (!fromJSON) this.reset() } static FromJson(json) { const data = JSON.parse(json) const opts = { maxPop: data.maxPop, recurrent: data.probs.addRecurrentChance > 0, outputActivation: data.outputActivation, hiddenActivation: data.hiddenActivation, allowedActivations: data.allowedActivations, lossFn: data.lossFn, hyper: data.hyper, probs: data.probs, } const neat = new Neat(data.inputs, data.outputs, opts, true) const replacePool = {} for (let i = 0; i < data.replacePool.length; i++) { replacePool[data.replacePool[i][0]] = Node.FromJson(data.replacePool[i][1]) } neat.replacePool = replacePool neat.pop = data.pop.map(json => new Client(Genome.FromJson(json))) neat.nodePool = data.nodePool.map(json => Node.FromJson(json)) neat.mandatoryNodes = data.mandatoryNodes.map(json => Node.FromJson(json)) neat.connections = data.connections.map(json => Connection.FromJson(json)) neat.connectionPool = data.connectionPool neat.nextPruneComplexity = data.nextPruneComplexity neat.pruning = data.pruning neat.lastMCP = data.lastMCP neat.mcpFloorCount = data.mcpFloorCount neat.lastPopFitness = data.lastPopFitness neat.currentPopFitness = data.currentPopFitness neat.fitnessPlatauCount = data.fitnessPlatauCount neat.prevSpecScores = data.prevSpecScores neat.dropoffTracker = data.dropoffTracker neat.currentConnections = data.currentConnections return neat } toJson() { return JSON.stringify({ nextPruneComplexity: this.nextPruneComplexity, pruning: this.pruning, lastMCP: this.lastMCP, mcpFloorCount: this.mcpFloorCount, lastPopFitness: this.lastPopFitness, currentPopFitness: this.currentPopFitness, fitnessPlatauCount: this.fitnessPlatauCount, hyper: this.hyper, probs: this.probs, connectionPool: this.connectionPool, connections: this.connections.map(c => c.toJson()), nodePool: this.nodePool.map(n => n.toJson()), replacePool: Object.entries(this.replacePool).map(r => [r[0], r[1].toJson()]), pop: this.pop.map(c => c.genome.toJson()), prevSpecScores: this.prevSpecScores, dropoffTracker: this.dropoffTracker, mandatoryNodes: this.mandatoryNodes.map(n => n.toJson()), currentConnections: this.currentConnections, inputs: this.inputs, outputs: this.outputs, outputActivation: this.outputActivation, hiddenActivation: this.hiddenActivation, allowedActivations: this.allowedActivations, lossFn: this.lossFn, maxPop: this.maxPop, }) } reset() { const outputNodes = [] for (let i = 0; i < this.outputs; i++) { const node = new Node(i + this.inputs, NodeType.output, randomRange(this.hyper.minBias, this.hyper.maxBias)) node.activation = aNm[this.outputActivation] outputNodes.push(node) } const inputNodes = [] this.connections = [] this.connectionPool = {} this.currentConnections = 0 this.replacePool = {} this.prevSpecScores = {} this.dropoffTracker = {} for (let i = 0; i < this.inputs; i++) { const node = new Node(i, NodeType.input, randomRange(this.hyper.minBias, this.hyper.maxBias)) inputNodes.push(node) for (let j = 0; j < outputNodes.length; j++) { this.connectionPool[`${node.id},${outputNodes[j].id}`] = this.currentConnections this.connections.push(new Connection(node.id, outputNodes[j].id, randomRange(this.hyper.minBias, this.hyper.maxBias), this.currentConnections)) this.currentConnections++ } } this.nodePool = [...inputNodes, ...outputNodes] this.mandatoryNodes = this.nodePool.slice(0) this.pop = [] for (let i = 0; i < this.maxPop; i++) { const g = this.blankGenome() for (let j = 0; j < this.hyper.initialMutation; j++) { g.augment(this) } this.pop.push(new Client(g)) } const currentMCP = this.mcp() this.nextPruneComplexity = currentMCP + this.hyper.complexityThreshold this.pruning = false this.lastMCP = 0 this.mcpFloorCount = 0 this.lastPopFitness = 0 this.currentPopFitness = 0 this.fitnessPlatauCount = 0 } getInnovationId(inNodeId, outNodeId) { const id = `${inNodeId},${outNodeId}` if (this.connectionPool[id]) return this.connectionPool[id] this.connectionPool[id] = this.currentConnections this.currentConnections++; return this.currentConnections - 1 } newNode(type) { const node = new Node(this.nodePool.length, type, randomRange(this.hyper.minBias, this.hyper.maxBias)) for (let i = 0; i < this.nodePool.length; i++) { const current = this.nodePool[i] if (current.type < node.type) continue if (current.type == node.type) { if (current.id < node.id) continue this.nodePool.splice(i, 0, node) break } this.nodePool.splice(i, 0, node) break } return node } blankGenome() { const nodes = this.mandatoryNodes.slice(0) for (let i = 0; i < nodes.length; i++) { nodes[i] = nodes[i].copy() nodes[i].bias = randomRange(this.hyper.minBias, this.hyper.maxBias) } const connections = this.connections.slice(0) for (let i = 0; i < connections.length; i++) { connections[i] = connections[i].copy() connections[i].weight = randomRange(this.hyper.minWeight, this.hyper.maxWeight) } return new Genome(nodes, connections) } addConnection(genome, inNode, outNode, weight = undefined) { if (inNode.layer == outNode.layer) return false if (inNode.layer > outNode.layer && Math.random() >= this.probs.addRecurrentChance) return false const id = `${inNode.id},${outNode.id}` if (genome.connectionMap[id] != undefined) { if (Math.random() >= this.hyper.reEnableConnectionChance) return false genome.connections[genome.connectionMap[id]].enabled = true return true } const w = weight == undefined ? randomRange(this.hyper.minWeight, this.hyper.maxWeight) : weight const innov = this.getInnovationId(inNode.id, outNode.id) const connection = new Connection(inNode.id, outNode.id, w, innov, inNode.layer > outNode.layer) genome.connectionMap[connection.id] = genome.connections.length genome.connections.push(connection) return true } interposeConnection(genome, connection) { let node; if (this.replacePool[connection.id] != undefined) { node = this.replacePool[connection.id].copy() } else { node = this.newNode(NodeType.hidden) node.activation = aNm[this.hiddenActivation] this.replacePool[connection.id] = node } const innovA = this.getInnovationId(connection.inNode, node.id) const innovB = this.getInnovationId(node.id, connection.outNode) const connectionA = new Connection(connection.inNode, node.id, 1, innovA) const connectionB = new Connection(node.id, connection.outNode, connection.weight, innovB) const i = genome.connectionMap[connection.id] genome.connections[i].enabled = false genome.connectionMap[connectionA.id] = genome.connections.length genome.connections.push(connectionA) genome.connectionMap[connectionB.id] = genome.connections.length genome.connections.push(connectionB) genome.nodeMap[node.id] = genome.nodes.length node.layer = genome.nodes[genome.nodeMap[connection.inNode]] + 1 genome.nodes.push(node) } mcp() { return this.pop.reduce((acc, c) => acc + (c.genome.nodes.length + c.genome.connections.length), 0) / this.pop.length } dist(g1, g2) { let i1 = 0 let i2 = 0 const h1 = g1.connections.length > 0 ? g1.connections.slice(-1)[0].innov : 0 const h2 = g2.connections.length > 0 ? g2.connections.slice(-1)[0].innov : 0 if (h1 < h2) { const temp = g1 g1 = g2 g2 = temp } let disjoint = 0 let similar = 0 let weightDiff = 0 while (i1 < g1.connections.length && i2 < g2.connections.length) { const a = g1.connections[i1] const b = g2.connections[i2] if (a.innov == b.innov) { similar++ weightDiff += Math.abs(a.weight - b.weight) i1++ i2++ continue } else if (a.innov > b.innov) { disjoint++ i2++ } else { disjoint++ i1++ } } if (similar > 0) weightDiff /= similar let excess = g1.connections.length - i1 let N = Math.max(g1.connections.length, g2.connections.length) if (N < 20) N = 1 return ((this.hyper.c1 * disjoint) / N) + ((this.hyper.c2 * excess) / N) + (this.hyper.c3 * weightDiff) } crossover(g1, g2) { let i1 = 0 let i2 = 0 const newConnections = [] const requiredNodes = Object.fromEntries(this.mandatoryNodes.map(n => [n.id, true])) while (i1 < g1.connections.length && i2 < g2.connections.length) { const a = g1.connections[i1] const b = g2.connections[i2] if (a.innov == b.innov) { if (Math.random() < 0.5) { newConnections.push(a.copy()) requiredNodes[a.inNode] = true requiredNodes[a.outNode] = true } else { newConnections.push(b.copy()) requiredNodes[b.inNode] = true requiredNodes[b.outNode] = true } i1++ i2++ continue } else if (a.innov < b.innov) { newConnections.push(a) requiredNodes[a.inNode] = true requiredNodes[a.outNode] = true i1++ } else { i2++ } } while (i1 < g1.connections.length) { const a = g1.connections[i1] newConnections.push(a.copy()) requiredNodes[a.inNode] = true requiredNodes[a.outNode] = true i1++ } const newNodes = [] const requiredNodeIds = Object.keys(requiredNodes) for (let i = 0; i < requiredNodeIds.length; i++) { const id = requiredNodeIds[i] const ni1 = g1.nodeMap[id] const ni2 = g2.nodeMap[id] if (ni1 != undefined) { if (ni2 != undefined) { if (Math.random() < 0.5) newNodes.push(g1.nodes[ni1].copy()) else newNodes.push(g2.nodes[ni2].copy()) continue } newNodes.push(g1.nodes[ni1].copy()) continue } newNodes.push(g2.nodes[ni2].copy()) } return new Genome(newNodes, newConnections, g1.species) } speciate() { let species = {} let maxCurrentSpecies = 0 for (let i = 0; i < this.pop.length; i++) { const current = this.pop[i] if (current.genome.species == null) continue if (species[current.genome.species] != undefined) continue if (maxCurrentSpecies < current.genome.species) maxCurrentSpecies = current.genome.species species[current.genome.species] = [current] } popLoop: for (let i = 0; i < this.pop.length; i++) { const currentKnownSpecs = Object.keys(species) for (let j = 0; j < currentKnownSpecs.length; j++) { const spec = currentKnownSpecs[j] if (this.pop[i].genome == species[spec][0].genome) continue const d = this.dist(this.pop[i].genome, species[spec][0].genome) if (d > this.hyper.threshold) continue this.pop[i].genome.species = spec species[spec].push(this.pop[i]) continue popLoop } maxCurrentSpecies++ this.pop[i].genome.species = maxCurrentSpecies species[maxCurrentSpecies] = [this.pop[i]] } species = Object.values(species) for (let i = 0; i < species.length; i++) { species[i].sort((a, b) => (b.score - b.genomeCost) - (a.score - a.genomeCost)) for (let j = 0; j < species[i].length; j++) { species[i][j].genome.species = i } } return species } cull(species) { for (let i = 0; i < species.length; i++) { const max = Math.max(Math.floor((1 - this.hyper.cullRate) * species[i].length), 1) species[i] = species[i].slice(0, max) } species = species.filter(s => s.length > 0) return species } breed(species) { const children = [] const adjustedFitness = species.map(s => s.map(c => { return c.score / s.length })) const sFit = adjustedFitness.map(s => s.reduce((acc, c) => acc + c, 0) / s.length) if (Math.random() < this.hyper.dropRate) { const dropIndex = sFit.indexOf(Math.min(...sFit)) sFit[dropIndex] = 0 if (this.dropoffTracker[dropIndex] != undefined) delete this.dropoffTracker[dropIndex] if (this.prevSpecScores[dropIndex] != undefined) delete this.prevSpecScores[dropIndex] } const gFit = adjustedFitness.reduce((acc, s) => acc + s.reduce((acc, c) => acc + c, 0), 0) / adjustedFitness.reduce((acc, s) => acc + s.length, 0) this.currentPopFitness = gFit for (let i = 0; i < sFit.length; i++) { if (this.prevSpecScores[i] == undefined) { this.prevSpecScores[i] = sFit[i] continue } if (sFit[i] <= this.prevSpecScores[i]) { if (!this.dropoffTracker[i]) this.dropoffTracker[i] = 0 this.dropoffTracker[i]++; if (this.dropoffTracker[i] >= this.hyper.dropoff) { sFit[i] = 0 delete this.dropoffTracker[i] delete this.prevSpecScores[i] } } else if (this.dropoffTracker[i] != undefined) { delete this.dropoffTracker[i] delete this.prevSpecScores[i] continue } this.prevSpecScores[i] = sFit[i] } for (let i = 0; i < species.length; i++) { let n = Math.round((sFit[i] / gFit) * species[i].length) while (n > 0) { if (Math.random() < this.hyper.cloneRate) { const c1 = rouletteSelectClientArray(species[i]) children.push(new Client(c1.genome.copy())) } else { const c1 = rouletteSelectClientArray(species[i]) const c2 = rouletteSelectClientArray(species[i]) const parents = [c1, c2] parents.sort((a, b) => b.score - a.score) const childGenome = this.crossover(parents[0].genome, parents[1].genome) children.push(new Client(childGenome)) } n-- } } return children } mutate() { if (!this.pruning) { for (let i = 0; i < this.pop.length; i++) { this.pop[i].genome.augment(this) } } else { for (let i = 0; i < this.pop.length; i++) { this.pop[i].genome.simplify(this) } } } populateGenomeCosts() { if (this.hyper.connectionCost == 0 && this.hyper.nodeCost == 0) return for (let i = 0; i < this.pop.length; i++) { const client = this.pop[i] client.genomeCost = (client.genome.connections.length * this.hyper.connectionCost) + (client.genome.nodes.length * this.hyper.nodeCost) } } evolve() { this.pop.sort((a, b) => (b.score - b.genomeCost) - (a.score - a.genomeCost)) this.populateGenomeCosts() let species = this.speciate() const elite = this.pop.slice(0, Math.ceil(this.pop.length * this.hyper.elitism)).map(c => new Client(c.genome.copy())) if (species.length > this.hyper.speciesTarget) this.hyper.threshold++; if (species.length < this.hyper.speciesTarget) this.hyper.threshold--; species = this.cull(species) this.pop = this.breed(species) if (this.pop.length + elite.length < this.maxPop) { const blankCount = this.maxPop - (this.pop.length + elite.length) for (let i = 0; i < blankCount; i++) { const g = this.blankGenome() this.pop.push(new Client(g)) } } this.mutate() this.pop = [...elite, ...this.pop] if (this.currentPopFitness > this.lastPopFitness) this.fitnessPlatauCount = 0 else this.fitnessPlatauCount++; this.lastPopFitness = this.currentPopFitness const currentMCP = this.mcp() if (!this.pruning) { if (this.fitnessPlatauCount >= this.hyper.fitnessPlatauThreshold) { if (currentMCP >= this.nextPruneComplexity) { this.pruning = true } } } else { if (currentMCP >= this.lastMCP) this.mcpFloorCount++; if (this.mcpFloorCount >= this.hyper.complexityFloorDelay) { this.pruning = false this.nextPruneComplexity = currentMCP + this.hyper.complexityThreshold this.fitnessPlatauCount = 0 } this.lastMCP = currentMCP } } trainFnStep(getScore, best = -Infinity) { for (let i = 0; i < this.pop.length; i++) { const score = getScore(this.pop[i]) this.pop[i].score = score if (score > best) { best = score } } return best } trainFn(getScore, goal, targetLoss = 0.01, log = false, onBest = undefined) { let best = -Infinity let gen = 0 while (true) { const score = this.trainFnStep(getScore, best) if (score > best) { best = score if (onBest != undefined) onBest(this, best, gen) if (log) { const table = {} table[gen] = { "Best score": best } console.table(table) } if (goal - best <= targetLoss) break } this.evolve() gen++ } this.pop.sort((a, b) => b.score - a.score) const client = this.pop[0] return { client, gen } } trainDataStep(trainingData, getLoss) { let minLoss = Infinity for (let i = 0; i < this.pop.length; i++) { const results = [] for (let j = 0; j < trainingData.length; j++) { const [input, expected] = trainingData[j] const output = this.pop[i].predict(input) if (output.length != expected.length) throw (`Training data output has wrong length: ${expected.length}, expected ${output.length}`) results.push([expected, output]) } const loss = getLoss(results) this.pop[i].score = -1 * loss if (loss < minLoss) minLoss = loss } return minLoss } trainData(trainingData, targetLoss = 0.01, log = false, onBest = undefined, lossFnOverride = undefined) { if (lossFnOverride == undefined) lossFnOverride = lFn[lNm[this.lossFn]] let best = Infinity; let gen = 0; while (true) { const loss = this.trainDataStep(trainingData, lossFnOverride) if (loss < best) { best = loss if (onBest != undefined) onBest(this, best, gen) if (log) { const table = {} table[gen] = { "Loss": best } console.table(table) } if (best <= targetLoss) break } this.evolve() gen++ } this.pop.sort((a, b) => b.score - a.score) const client = this.pop[0] return { client, gen } } } module.exports = Neat