Las damas, también conocidas como damas, son un juego antiguo que ha sido popular durante mucho tiempo. Al igual que otros juegos de mesa clásicos, ha despertado el interés de informáticos e investigadores de IA como plataforma para sus experimentos. Un método destacado que utilizan para entrenar a las computadoras para jugar a las damas es un algoritmo genético, que se basa en el concepto de supervivencia del más fuerte que impulsa la evolución.
En este artículo, exploraremos la intersección de las damas y los algoritmos genéticos, demostrando cómo se pueden emplear técnicas computacionales evolutivas para desarrollar agentes competentes en el juego de damas.
En mi último artículo , creo que ya cubrí lo suficiente el tema de las damas, pero aún así, recordemos las reglas básicas del juego: el juego de damas se juega en un tablero de 10x10, y cada jugador comienza con 20 piezas.
El objetivo es simple: capturar todas las piezas de tu oponente o atraparlas para que no puedan moverse. Las estrategias principales giran en torno al posicionamiento, la defensa, el ataque y la previsión de los movimientos del oponente.
Los AG son algoritmos de optimización basados en el proceso de selección natural. Estos algoritmos reflejan el procedimiento de evolución observado en la naturaleza, que comprende selección, cruce (recombinación) y mutación.
Este artículo utilizará un modelo simple con capas completamente conectadas. Una capa es un conjunto de neuronas que procesan entradas y producen salidas. Estas salidas luego se utilizan como predicción final en el caso de la última capa (capa de salida) o se pasan como entrada a la siguiente capa de la red.
Cada capa contiene:
Primero, tenemos que descubrir cómo representar una solución potencial, como una estrategia de damas, de manera que funcione con un algoritmo genético. En nuestro caso, tomaremos todos los movimientos posibles para el jugador actual, presionaremos GA sobre cada uno y luego elegiremos el movimiento con mayor peso.
Además, necesitamos calcular la puntuación del juego para elegir los mejores elementos de la población. En nuestro caso será:
Para cada población, realizaremos un torneo (una versión simplificada del torneo del sistema suizo). Después de cada torneo, seleccionamos 1/4 de la población para una futura selección.
Si algún agente es el mejor jugador (seleccionado para la próxima generación) en más de 1 torneo, lo elegimos para que sea el mejor jugador, y una vez terminadas todas las épocas, hacemos un torneo entre los mejores jugadores y elegimos al mejor. que será salvo.
El quid del algoritmo radica en la evolución de la población de estrategias.
Existe el riesgo de que el algoritmo se quede atascado en un óptimo local, donde cree que ha encontrado la mejor estrategia pero sólo es óptimo en una pequeña región del espacio de solución.
Si bien los GA pueden ser rápidos, la simulación de miles o millones de juegos requiere importantes recursos computacionales.
Existe el riesgo de que la estrategia evolucionada se adapte demasiado a los escenarios de entrenamiento y funcione mal frente a estrategias o tácticas invisibles.
Voy a usar node.js.
¿Por qué no Python?
Sé que Python será más efectivo, pero con node.js, con el que estoy más familiarizado, se puede usar fácilmente en el lado del cliente y del servidor.
¿Por qué no tensorflow.js (o cualquier otra biblioteca tan cercana a usted) ?
Inicialmente, planeé usar esta biblioteca, pero luego decidí que, dado que este proyecto es principalmente educativo, puedo crear yo mismo una red de neuronas mínimamente viable y también dejar espacio para futuras mejoras (por ejemplo, usar wasm para los cálculos).
Casi cualquier red neuronal es un conjunto de capas donde se reciben algunos datos como entrada, se multiplican por un conjunto de índices y se devuelven algunos datos.
Para mayor claridad, imagina que estás buscando la nueva casa de tus sueños, y ahora la has encontrado, y ahora les preguntas a tus amigos qué piensan sobre esta casa y ellos te dan su respuesta en una escala de 10 puntos, por ejemplo. .
Confías hasta cierto punto en la opinión de cada uno de tus amigos, pero tienes que preguntar a todos, incluso a aquellos en los que no confías especialmente, para no ofender. Entonces te sientas con tus amigos en un grupo con tus bebidas favoritas y miras diferentes casas, y todos dicen cuánto les gustó.
Gracias a ello, sabes lo que tus amigos piensan de cada casa y, en función del resultado final, decides.
La red neuronal se compone precisamente de esos "amigos", pero el número de grupos de amigos puede ser bastante grande y todos confían en la opinión del grupo anterior.
Presentación esquemática de la red neuronal:
import {createEmpty, createNew} from "./genetic"; export enum Activation { Sigmoid, Relu, } class Layer { weights: Float32Array; biases: Float32Array; inputCount: number; outputCount: number; size: number; activation: Activation; /** * Create a new layer * @param inputCount * @param outputCount * @param activation */ constructor( inputCount: number, outputCount: number, activation: Activation = Activation.Relu ) { this.inputCount = inputCount; this.outputCount = outputCount; this.weights = createNew(inputCount * outputCount, 2); this.biases = createNew(outputCount, 2); this.activation = activation; this.size = inputCount * outputCount + outputCount; switch (activation) { case Activation.Sigmoid: this.activate = this.activateSigmoid.bind(this); break; case Activation.Relu: this.activate = this.activationRelu.bind(this); break; } } /** * Activation function (identity) * @param val */ activate(val: Float32Array) { return val; } /** * Activation function (sigmoid) * @param val */ activateSigmoid(val: Float32Array) { return val.map((v) => 1 / (1 + Math.exp(-v))); } /** * Activation function (relu) * @param val */ activationRelu(val: Float32Array) { return val.map((v) => Math.max(0, v)); } /** * Predict an output * @param inputs */ predict(inputs: Float32Array) { let result = createEmpty(this.outputCount); for (let i = 0; i < this.outputCount; i++) { for (let j = 0; j < this.inputCount; j++) { result[i] += inputs[j] * this.weights[i * this.inputCount + j]; } result[i] += this.biases[i]; } return this.activate(result); } /** * Get the weights of the layer */ getWeights() { return Float32Array.from([...this.weights, ...this.biases]); } /** * Gst current layer topology */ getTopology() { return new Float32Array([ this.inputCount, this.activation, this.outputCount, ]); } /** * Set the weights of the layer * @param weights */ setWeights(weights: Float32Array) { this.weights = weights.slice(0, this.weights.length); this.biases = weights.slice(this.weights.length); } }
export class Network { network: Layer[] = []; inputs: any; outputs: any; /** * Create a new network * @param inputs * @param outputs * @param layer */ constructor(inputs: number, outputs: number, layer: (number[] | Layer)[]) { this.inputs = inputs; this.outputs = outputs; for (const layerSize of layer) { const l = layerSize instanceof Layer ? layerSize : new Layer(layerSize[0], layerSize[2], layerSize[1]); this.network.push(l); } } /** * Predict an output * @param input */ predict(input: Float32Array) { return this.network.reduce((acc, layer) => layer.predict(acc), input); } /** * Get topology for whole network */ getTopology() { return new Float32Array( [ this.inputs, this.outputs, this.network.length, ...this.network.map((layer) => [...layer.getTopology()]), ].flat() ); } /** * Get the weights of the network */ getWeights() { return this.network.reduce((acc, layer) => { return new Float32Array([...acc, ...layer.getWeights()]); }, new Float32Array([])); } /** * Set the weights of the network * @param weights */ setWeights(weights: Float32Array) { let offset = 0; for (const layer of this.network) { layer.setWeights(weights.slice(offset, offset + layer.size)); offset += layer.size; } } /** * Get the size of the network */ size() { return this.network.reduce((acc, layer) => acc + layer.size, 0); } /** * Serialize the network */ toBinary() { const topology = this.getTopology(); const weights = new Float32Array(topology.length + this.size()); weights.set(this.getTopology()); weights.set(this.getWeights(), topology.length); return Buffer.from(weights.buffer); } /** * Create a network from a binary * @param json * @param weights */ static fromBinary(buffer: Float32Array) { const inputs = buffer[0]; const outputs = buffer[1]; const length = buffer[2]; const layers = Array.from({ length }).map((_, i) => { const start = 3 + i * 3; const end = start + 3; const topology = buffer.subarray(start, end); return new Layer(topology[0], topology[2], topology[1]); }); const network = new Network(inputs, outputs, layers); network.setWeights(buffer.subarray(3 + length * 3)); return network; } }
Un agent
es una entidad que decide qué hacer a continuación y recibe recompensas o multas por sus actividades. En otras palabras, un agente es una persona común y corriente que toma decisiones en el trabajo y recibe una recompensa por ello.
Como parte de nuestra tarea, el agente contiene una red neuronal y selecciona la mejor solución desde el punto de vista de la red neuronal.
Además, nuestro agente, como todo buen empleado, recuerda los resultados de su trabajo e informa a sus superiores (algoritmo genético) sobre el valor promedio, en base al cual se toma la decisión final sobre este empleado.
import { Keccak } from "sha3"; import { Network, Agent, createEmpty, getMoves, FEN } from "shared"; export class AgentTrainer implements Agent { historySize: number; history: Float32Array; id: string; private _games: Set<string> = new Set(); games: number = 0; wins: number = 0; score: number = 0; minScore = +Infinity; maxScore = -Infinity; age: number = 0; network: Network; taken: boolean = false; _player: "B" | "W" = "W"; /** * Create a new agent * @param historySize * @param modelPath * @param weights */ constructor(historySize: number, buf: Float32Array) { this.historySize = historySize; this.network = Network.fromBinary(buf); this.id = new Keccak(256).update(Buffer.from(buf.buffer)).digest("hex"); this.history = createEmpty(this.historySize); } /** * Create a new epoch */ onNewEpoch() { this.age += 1; this.score = 0; this.games = 0; this._games = new Set(); this.maxScore = -Infinity; this.minScore = +Infinity; this.wins = 0; this.reset(); } /** * Check if the player has played against the opponent * @param player */ hasPlayedBefore(player: AgentTrainer) { if (this.id === player.id) { return false; } return this._games.has(player.id); } /** * Set the result of a match * @param score * @param opponent */ setResult(score: number, opponent: AgentTrainer) { this._games.add(opponent.id); this.games += 1; this.score += score; this.minScore = Math.min(this.minScore, score); this.maxScore = Math.max(this.maxScore, score); if (score > 0) { this.wins += 1; } } /** * Calculate the average score * @returns number */ getAverageScore() { return this.score / this.games; } /** * Get the weights of the network */ getWeights() { return this.network.getWeights(); } getTopology() { return this.network.getTopology(); } /** * Serialize the weights of the network */ serialize() { return this.network.toBinary(); } /** * Reset history */ reset() { this.history = new Float32Array(this.historySize); this.taken = false; } toString() { return `${this.id} with ${String(this.score).padStart( 6, " " )} points min: ${String(this.minScore).padStart(6, " ")} max: ${String( this.maxScore ).padStart(6, " ")} avg: ${String( this.getAverageScore().toFixed(2) ).padStart(9, " ")} ${((this.wins / this.games) * 100) .toFixed(2) .padStart(6, " ")}%`; } setPlayer(player: "B" | "W") { this._player = player; } /** * Calculate moves and return the best one * @param gameState * @returns */ getMove(gameState: FEN): string { const board = new Float32Array(50); const wMul = this._player === "W" ? 1 : -1; for (let i = 0; i < gameState.white.length; i++) { let isKing = gameState.white[i].startsWith("K"); let pos = isKing ? parseInt(gameState.white[i].slice(1), 10) : parseInt(gameState.white[i], 10); board[pos] = wMul * (isKing ? 2 : 1); } for (let i = 0; i < gameState.black.length; i++) { let isKing = gameState.black[i].startsWith("K"); let pos = isKing ? parseInt(gameState.black[i].slice(1), 10) : parseInt(gameState.black[i], 10); board[pos] = -1 * wMul * (isKing ? 2 : 1); } this.history = new Float32Array([...board, ...this.history.slice(50)]); const value = new Float32Array(this.network.inputs); value.set(new Float32Array(50)); value.set(this.history, 50); let pos = 0; let posVal = -Infinity; const moves = getMoves(gameState); for (let i = 0; i < moves.length; i += 1) { /** * Create a new value for move */ const move = moves[i]; const val = value.slice(); val[move.from - 1] = -1; val[move.to - 1] = 1; const result = this.network.predict(val); /** * If the result is better than the previous one, save it */ if (result[0] > posVal) { pos = moves.indexOf(move); posVal = result[0]; } } /** * Return the best move in the format from-to */ return `${moves[pos].from}-${moves[pos].to}`; } }
Para obtener el resultado del trabajo, necesitamos comparar dos agentes para que estén en iguales condiciones; cada uno de ellos jugará por cada uno de los colores, y se resume el resultado final.
Para no sobrecargar la red con información innecesaria, cada agente ve el tablero como si estuviera jugando a las damas blancas.
import { Draughts } from "@jortvl/draughts"; import { Player, Position, getFen } from "shared"; import { AgentTrainer } from "./agent"; export function playMatch(white: AgentTrainer, black: AgentTrainer) { const draughts = new Draughts(); white.setPlayer(Player.White); black.setPlayer(Player.Black); while (!draughts.gameOver()) { /** * Get current player */ const player = draughts.turn() === Player.White ? white : black; /** * Get the move from the player */ const move = player.getMove(getFen(draughts.fen())); draughts.move(move); } /** * Calculate the score */ const [winner, ...left] = draughts.position().split(""); const score = 250 + left.reduce((acc: number, val: string) => { switch (val) { case Position.Black: case Position.White: return acc + 3; case Position.BlackKing: case Position.WhiteKing: return acc + 7; default: return acc; } }, 0) - draughts.history().length; /** * Set the result, if white won, the score is positive; if black won, the score is negative */ return winner === Player.White ? score : score * -1; }
En cada era de entrenamiento (prueba del trabajo) de agentes, tenemos un conjunto de agentes experimentales que enfrentaremos entre sí. Pero como comprobar a cada agente entre sí será muy largo e ineficaz, utilizaremos un algoritmo de torneo de ajedrez simplificado.
En cada etapa tenemos una lista de jugadores ordenados por puntos y los distribuimos de la siguiente manera: el primero juega con el primer oponente del centro de la hoja. Si ya ha jugado con él, seleccionamos el siguiente.
import {Agent} from "./agent"; import {playMatch} from "./play-match"; export function playTournament(playerList: Agent[]) { let d = Math.floor(playerList.length / 2); /** * Count rounds */ let rounds = Math.ceil(Math.log2(playerList.length)) + 2; for (let i = 0; i < rounds; i += 1) { for (let j = 0; j < d; j += 1) { let dj = d; /** * Find the next opponent */ let found = false; while (dj < playerList.length && !found) { if (playerList[dj].hasPlayedBefore(playerList[j]) || playerList[dj].games > i) { dj += 1; } else { found = true; } } if (found) { let score = 0; /** * Play the match */ score += playMatch(playerList[j], playerList[dj]); /** * Play the reverse match */ score += playMatch(playerList[dj], playerList[j]) * -1; playerList[j].setResult(score, playerList[dj]); playerList[dj].setResult(-1 * score, playerList[j]); } } playerList.sort((player1, player2) => player2.score - player1.score); console.log('round', i, playerList[0].id, playerList[0].score.toFixed(1).padStart(6, ' ')); } return playerList; }
Aquí es donde ocurre toda la magia del algoritmo genético. Se cargan los modelos existentes y se crean otros nuevos.
A partir de estos modelos obtenemos un genoma con agentes que serán utilizados en nuestro juego. Este genoma irá cambiando a medida que avance el entrenamiento, los agentes con la puntuación más baja serán descartados y los agentes con la puntuación más alta transmitirán sus genes a las nuevas generaciones y serán comparados con ellas.
Crea un nuevo genoma:
Se crea un nuevo conjunto de genes en un intervalo numérico determinado, sin problemas hereditarios y cosas por el estilo, y ahí es donde comenzará todo.
export function createNew(size: number, delta = 4): Float32Array { return new Float32Array(size).map(() => Math.random() * delta - (delta / 2)); }
Transversal:
El mecanismo es bastante sencillo; tomamos dos conjuntos de genes (pesos) de los agentes y, dependiendo de la probabilidad, tomamos un gen del primer agente o del segundo, así, los genes se mezclan y se obtiene una nueva red con algún conocimiento del primero y del segundo. agente.
Así es exactamente como funciona todo en la naturaleza real; cada uno de nosotros tiene genes de cada uno de nuestros padres.
export function crossover(first: Float32Array, second: Float32Array, prob = 0.25): Float32Array { return new Float32Array(first.map((w, i) => Math.random() < prob ? second[i] : w)) }
Mudar:
La función de mutación funciona de la siguiente manera: tomamos un conjunto de genes y, con cierto grado de probabilidad, le agregamos un poco de caos. Nuevamente, en la vida real todo funciona exactamente igual, y cada uno de nosotros tiene genes que no están en nuestros padres, de lo contrario no habría enfermedades ni otros procesos incomprensibles en nuestro cuerpo.
export function mutate(master: Float32Array, prob = 0.25, delta = 0.5): Float32Array { return new Float32Array(master.map(w => Math.random() < prob ? w + (Math.random() * delta - (delta / 2)) : w)) }
Como resultado de cada torneo, tenemos un cierto número de agentes que tienen un poco más de suerte que otros (en los algoritmos genéticos, todo se basa en nuestras vidas, y algunos tienen más suerte, otros menos).
Y al final, tomamos la lista de estos muy afortunados y los comparamos entre sí para encontrar el mejor genoma para nuestro juego, y para que los datos no desaparezcan, debemos recordar guardar este genoma.
Cuando tengamos una cantidad suficiente de esos genomas, podremos construir poblaciones de agentes basados en ellos. Aún así, cada uno de los genomas ya tiene cierta comprensión de lo que debe hacer.
import * as fs from "fs"; import { playTournament } from "./play-tournament"; import { Network, createNew, crossover, mutate } from "shared"; import { playBattle } from "./play-battle"; import { AgentTrainer } from "./agent"; export async function tournaments( historySize: number, layers: number[] = [], epoch = 64, population = 32, best = false ) { const modelName = `${(historySize + 1) * 50}_${layers.join("_")}`; const modelPath = `../models/${modelName}`; /** * Create the model if it does not exist */ if (!fs.existsSync(modelPath)) { fs.mkdirSync(modelPath, { recursive: true }); } let topPlayerList = []; const topPlayerIds = new Set(); const bestModels = new Set(); let playerList: AgentTrainer[] = []; const baseLayers = []; let inp = historySize * 50 + 50; for (let i = 0; i < layers.length; i++) { baseLayers.push([inp, 1, layers[i]]); inp = layers[i]; } const baseNet = new Network(historySize * 50 + 50, 1, baseLayers); const topologySize = baseNet.getTopology().length; const size = baseNet.size() + topologySize; /** * Load the best models */ if (best) { const weights = fs .readdirSync(modelPath) .filter((file) => file.endsWith(".bin")); for (const weight of weights) { const buf = fs.readFileSync(`${modelPath}/${weight}`); const weights = new Float32Array(buf.buffer); const agent = new AgentTrainer(historySize * 50, weights); agent.age = 1; bestModels.add(agent.id); playerList.push(agent); topPlayerList.push(agent); topPlayerIds.add(agent.id); } const d = playerList.length; let ind = 0; /** * Create new players by crossover and mutation from the best models. * For the zero population, we need to ensure the greatest genetic diversity, than next populations. * This way we will get a larger number of potentially viable models, from which subsequent generations will be built in the future */ if (d > 1) { while (playerList.length < Math.max(population, d * 2)) { const playerA = playerList[ind]; const playerB = playerList[Math.floor(Math.random() * d)]; if (playerA && playerB && playerA.id !== playerB.id) { const newWeights = mutate( crossover(playerA.getWeights(), playerB.getWeights()) ); const weights = new Float32Array(size); weights.set(baseNet.getTopology()); weights.set(newWeights, topologySize); const agent = new AgentTrainer(historySize * 50, weights); playerList.push(agent); ind += 1; ind = ind % d; } } } } /** * Create the initial population */ while (playerList.length < population) { const w = createNew(baseNet.size(), 2); const weights = new Float32Array(size); weights.set(baseNet.getTopology()); weights.set(w, topologySize); const agent = new AgentTrainer(historySize * 50, weights); playerList.push(agent); } /** * Run the initial championship */ playerList = await playTournament(playerList); console.log( `0 ${playerList[0].id} (${playerList[0].age}) with ${playerList[0].score} points` ); let currentEpoch = 0; while (currentEpoch <= epoch) { /** * Keep the best 25% of the population */ playerList = playerList.slice(0, Math.floor(population / 4)); for (const player of playerList) { player.onNewEpoch(); /** * if the player is in the top 25% and has played at least one tournament, add it to the top players */ if (player.age > 1 && !topPlayerIds.has(player.id)) { topPlayerIds.add(player.id); topPlayerList.push(player); console.log("add top player", player.id, topPlayerList.length); } } const d = playerList.length; /** * Create new players by crossover and mutation */ let ind = 0; while (playerList.length < population) { const playerA = playerList[ind]; const playerB = playerList[Math.floor(Math.random() * d)]; if (playerA && playerB && playerA.id !== playerB.id) { const newWeights = mutate( crossover(playerA.getWeights(), playerB.getWeights()) ); const weights = new Float32Array(size); weights.set(baseNet.getTopology()); weights.set(newWeights, topologySize); const agent = new AgentTrainer(historySize * 50, weights); playerList.push(agent); ind += 1; ind = ind % d; } } /** * Run the championship */ playerList = await playTournament(playerList); currentEpoch += 1; console.log( `${currentEpoch} ${playerList[0].id} (${playerList[0].age}) with ${playerList[0].score} points` ); } /** * Add the top players to the list from championship */ for (const player of playerList) { if (player.age > 1 && !topPlayerIds.has(player.id)) { topPlayerIds.add(player.id); topPlayerList.push(player); console.log("add top player", player.id, topPlayerList.length); } } console.log("-----"); console.log(topPlayerList.length); console.log("-----"); /** * Reset agents */ for (const player of topPlayerList) { player.onNewEpoch(); } /** * Run the final championship */ topPlayerList = await playBattle(topPlayerList); let index = 1; for (const player of topPlayerList) { const code = bestModels.has(player.id) ? "\x1b[32m" : "\x1b[36m"; const reset = "\x1b[m"; console.log( `${code}${String(index).padStart(4, " ")} ${player.toString()}${reset}` ); index += 1; } /** * Save the best player */ while (topPlayerList[0] && bestModels.has(topPlayerList[0].id)) { /** * Remove the best player if it is already in the best models */ console.log("remove", topPlayerList[0].id); topPlayerList.shift(); } if (topPlayerList[0]) { let player = topPlayerList[0]; console.log(`${player.score} ${player.id}`); const weights = player.serialize(); console.log(weights.length, weights.length / 4); fs.writeFileSync(`${modelPath}/${player.id}.bin`, weights); } }
Para los resultados utilicé los modelos más actuales y esto es lo que salió:
Al comparar dos modelos de redes neuronales entre sí, muestran resultados bastante buenos, aunque realizan acciones ilógicas.
Cuando se juega entre una red neuronal y una búsqueda alfa beta con una profundidad de búsqueda de 1 paso, la red neuronal tiene bastantes posibilidades de ganar.
En un juego entre redes neuronales y búsquedas alfa beta con una profundidad de búsqueda de 2 pasos, la red neuronal no tuvo ninguna posibilidad y perdió todos los juegos.
No he mirado más profundamente todavía porque no tiene sentido. Quizás después de más juegos podrá producir resultados más aceptables, o si le enseñas a la red neuronal a jugar no con las mismas redes, sino con un agente de búsqueda alfa-beta.
La aplicación de algoritmos genéticos al juego de damas ejemplifica el potencial transformador de la computación de inspiración biológica. Si bien los algoritmos de juego tradicionales como Minimax y sus variantes han demostrado ser efectivos, la naturaleza evolutiva y adaptativa de los AG ofrece una nueva perspectiva.
Al igual que ocurre con la evolución biológica, es posible que las estrategias desarrolladas mediante este método no siempre sean las más adecuadas de inmediato. Aun así, con el tiempo suficiente y las condiciones adecuadas, pueden evolucionar hasta convertirse en oponentes formidables, demostrando el poder de la computación inspirada en la naturaleza.
Si eres un entusiasta de las damas, un investigador de inteligencia artificial o simplemente alguien fascinado por la convergencia de lo antiguo y lo nuevo, no se puede negar que la combinación de este antiguo juego con algoritmos de vanguardia es una frontera apasionante en la inteligencia artificial. .
Como siempre, todo el código proporcionado en GitHub.