Cómo programé una partida de ajedrez contra un hermano





Esta es la historia de cómo traté de ganar una partida de ajedrez con mi hermano. Solo un puto juego. ¿Qué tiene de especial? ¿Soy bueno en el ajedrez? Para nada. ¿Aprendí algo mientras jugaba? También no. ¿Quizás esta es una historia sobre un viaje por el bien de un viaje, no una meta? Realmente no. ¿Lo disfruté siquiera? No estoy seguro.



Esta es una historia sobre mi intento de ser original en uno de los juegos más estudiados del mundo, utilizando la experiencia de desarrollo de software donde puede que no sea necesario.



A pesar de mi absoluta estupidez en el ajedrez y de la total inutilidad de este artículo para quienes buscan mejorar su juego, sigo pensando que valió la pena compartir una forma original de aplicar los principios de la ingeniería a un problema. ¿Tengo éxito? Aprenderás sobre esto al final.



Por qué incluso me involucré con el ajedrez



Durante la pandemia de 2020, mi hermano, como muchas otras personas, se volvió adicto al ajedrez en línea. Después de jugar durante un par de meses, comenzó a hablar con mucha inspiración sobre este juego y a desafiar a otros miembros de la familia. Nuestro padre respondió a la llamada (aunque sufrió un fiasco digital), pero yo no cedí. Uno de los factores limitantes fue mi renuencia a sumergirme en un pasatiempo que potencialmente consumía mucho tiempo. Sabía lo suficiente sobre este juego para darme cuenta de que convertirse incluso en un jugador intermedio requiere pasar cientos, si no miles, de horas jugando. Aunque admito que al mismo tiempo tampoco me inspiró la idea de perder ante mi hermano, que en ese momento ya había jugado más de cien partidas. Yo no soy uno.



Sin embargo, un día sucumbí a su desafío. No hace falta decir que la pérdida fue devastadora. Conocía las reglas y los fundamentos del juego, desde que jugaba un poco cuando era niño, pero esto de ninguna manera se podía comparar con las habilidades de mi hermano. Más tarde, mirando el análisis de la partida en chess.com , vi que mi retraso táctico solo crecía, jugada a jugada, hasta alcanzar la marca de +9 (que es igual a perder una torre, alfil y peón ante la ausencia de pérdidas enemigas). En ese momento, habiendo perdido toda esperanza, me di por vencido. Una situación similar se repitió durante un par de juegos más, cuando me di cuenta de que era necesario hacer algo al respecto.



Mi primera decisión fue profundizar en el juego.



Intento uno: estudiar



Mi primer intento de mejorar la calidad del juego fue lo obvio: vaya a Reddit y YouTube para obtener recomendaciones de otros estudiantes. Entre las lecciones del GM Naroditsky , leyendo y resolviendo problemas en Lichess, también jugué algunos juegos con oponentes aleatorios en Internet. A pesar de todo esto, mi calificación se mantuvo baja (1300-1400 Rapid on Lichess).







Después de un par de juegos más contra mi hermano, me di cuenta de que no tenía ninguna posibilidad de ganar. Continué siguiendo los mismos métodos de desarrollo (jugar, estudiar técnicas y ver videos), pero dediqué mucho menos tiempo a esto que mi hermano. En ese momento, él ya jugaba cientos de juegos al mes, mientras que yo no tenía más de 10. A este ritmo, mi brecha crecía cada vez más.



Fue entonces cuando me di cuenta de un matiz muy importante: no estaba particularmente interesado en el juego en sí y, de hecho, no quería mejorar en él. El objetivo principal para mí era derrotar a una persona: mi hermano.



Intento dos: estudiar al enemigo



Un juego de ajedrez generalmente se divide en tres fases: apertura , medio juego y final . Después de aprender algunos patrones básicos de apareamiento, generalmente es "fácil" pasar de una gran ventaja a una victoria en la etapa final, por lo que obtener esa ventaja fue la primera pregunta para mí.



En la etapa del medio juego, la ventaja generalmente se logra mediante el despliegue de una estrategia a largo plazo y la aplicación de tácticas . La estrategia se puede mejorar leyendo y estudiando los principios del juego (eso me gusta), y las tácticas se desarrollan solo a través de la resolución de problemas.(que odio particularmente). Por lo tanto, entendí que en habilidades tácticas definitivamente me quedaría atrás, dado que mi hermano resolvía alrededor de 20 problemas de este tipo en chess.com todos los días. Para mí, este fue un límite inalcanzable. Por lo tanto, solo quedaba una oportunidad: ganar ventaja en la etapa inicial.



La teoría detrás de la fase de apertura es enorme. Al mismo tiempo, requiere memorizar largas secuencias y variaciones de movimientos, así como posibles respuestas del oponente. Los principiantes no necesitan memorizar mucho, pero un poco de familiaridad con las aperturas más típicas puede ser de gran beneficio (o eso me han dicho).



Entonces decidí mirar algunos de los juegos aleatorios de mi hermano y tratar de entender las aperturas que usaba. También estudié en los debuts de Lichess "Partido Italiano" y la Defensa Siciliana , tratando de recordar sus principios básicos. Aparte de eso, vi un montón de videos de YouTube.



Obviamente, mi hermano ha hecho todo esto antes que yo (y mejor), así que, naturalmente, volví a perder. Sin mencionar el hecho de que memorizar movimientos de apertura sin sentido (al menos para mí) es solo aburrido y agotador. Todo esto no me dio ningún placer. Otro problema fue que cuando mi oponente comenzó a desviarse de los movimientos prescritos en el libro, absolutamente no sabía cómo reaccionar, porque simplemente no entendía las posiciones emergentes.



Es hora de dar un paso atrás y pensar de nuevo. Entonces me di cuenta de que en realidad estaba tratando de no vencer a mi hermano, sino de mejorar mi juego contra oponentes que jugaban las mismas aperturas a la perfección. ¿Podría haber actuado de manera más direccional? En cambio, ¿podría haberse preparado específicamente contra las debilidades de su hermano? Obviamente, este enfoque solo funcionaría en su contra, pero era bastante consistente con mi objetivo.



Intento tres: programación



Ahora mi tarea ha tomado una forma diferente: encontrar posiciones a la salida de la apertura, que es más probable que alcance mi hermano (en adelante PlayerX), estando en desventaja. Tenga en cuenta que ninguno de nosotros somos expertos en el juego y que los jugadores de nuestro nivel no juegan con mucho cuidado.



La única forma de contrarrestar a un buen jugador es seguir los movimientos en el libro exactamente, porque entonces al menos sabrás que tu oponente no hará ningún movimiento, obteniendo una ventaja. Sin embargo, la situación cambia cuando juegas contra un jugador de nivel de club. Puede tomar riesgos (es decir, encontrarse temporalmente en desventaja) si sabe que es poco probable que el enemigo pueda reaccionar correctamente a esto y, por lo tanto, se encontrará en una posición difícil.



También tenía una lista de más de 500 juegos que mi hermano jugó en chess.com. Y como soy programador, se convirtió en un enfoque natural para mí resolver este problema de manera ingenieril.



Comencé a descargar las partidas a las que jugaba usando la API de chess.com y las dividí en partidas blancas y negras. Luego me concentré en los juegos en los que mi hermano jugaba con negras, porque sentí que tenía más posibilidades de dirigir el juego en la dirección que quería cuando jugaba con blancas.



import json
import requests

def get_month_games(player, yyyy_mm):
    url = 'https://api.chess.com/pub/player/{}/games/{}'
    r = requests.get(url.format(player, yyyy_mm))
    if not r.ok:
        raise Exception('get_month_games failed')
    games = json.loads(r.content)
    # Format: {games: [{url, pgn}, ...]}
    return games['games']

# ...
      
      





import chess.pgn
import io
import json

with open('games.json') as f:
    data = json.load(f)

games = []
for game in data:
    pgn = io.StringIO(game)
    games.append(chess.pgn.read_game(pgn))

black_games = [g for g in games if g.headers["Black"] == "playerx"]
      
      





Luego formulé la tarea de la siguiente manera: “Considerando todas las posiciones que PlayerX ha visto, ¿cuál de ellas probablemente será la menos rentable para él al final de la apertura?



Esta vez, la tarea estaba claramente definida y el trabajo comenzó en un campo que me era familiar. Decidí hacer el análisis en Python, es decir, en el cuaderno de Jupyter , porque no tenía el objetivo de crear una herramienta reutilizable y solo necesitaba estudiar los datos disponibles para encontrar una solución.



Resultó que Python ya tiene excelentes bibliotecas para trabajar con ajedrez: python-chess (generación, evaluación y visualización de movimientos) y python stockfish(enlaces para evaluar una posición de ajedrez utilizando el conocido motor de ajedrez Stockfish).



Convertí el problema en un gráfico de esta manera: un nodo es una posición de ajedrez particular (descrita en la notación FEN ). Un borde conecta dos nodos, mientras que la posición de destino es accesible desde la inicial mediante un movimiento admisible. Todos los juegos tienen un mismo nodo inicial: la posición inicial.



Luego construí un gráfico de todas las partidas jugadas por PlayerX como negras, marcando además cada borde con el número de veces que se realizó el movimiento correspondiente.



class GamesGraph():
    def __init__(self):
        self.graph = igraph.Graph(directed=True)

    def add_move(self, start_fen, end_fen, uci):
        vs = self._ensure_vertex(start_fen)
        vt = self._ensure_vertex(end_fen)
        try:
            e = self.graph.es.find(_source=vs.index, _target=vt.index)
            e["count"] += 1
        except:
            e = self.graph.add_edge(vs, vt)
            e["uci"] = uci
            e["count"] = 1

    @property
    def start_node(self):
        return self.graph.vs.find(chess.STARTING_FEN)

    def _ensure_vertex(self, fen):
        try:
            return self.graph.vs.find(fen)
        except:
            v = self.graph.add_vertex(name=fen)
            v["fen"] = fen
            v["turn"] = chess.Board(fen).turn
            return v
      
      





Como resultado, obtuvimos un gráfico dirigido ponderado (no un árbol, porque una posición se puede obtener mediante una secuencia diferente de movimientos) como este (sintético, porque uno real simplemente no encajaría aquí):







Aquí la posición de inicio está indicada por un nudo cuadrado, el color indica si es blanco o negro para moverse en esta posición.



También quería obtener una evaluación de cada posición en términos de ventaja blanca, para la que usé Stockfish. Dado que el proceso de evaluar miles de posiciones lleva tiempo, decidí ejecutarlo por separado y creé un objeto JSON que asignaba cada posición FEN única a su estimación de Stockfish.



from stockfish import Stockfish

stock = Stockfish(parameters={"Threads": 8})
stock.set_depth(20)
stock.set_skill_level(20)

def eval_pos(fen):
    stock.set_fen_position(fen)
    return stock.get_evaluation()

# fens -     FEN   .
for fen, node in graph.fens.items():
    node.eva = eval_pos(fen)
      
      





La evaluación de la ventaja se devolvió en centipoints o como "jaque mate en X jugadas", donde un número positivo significa una ventaja para las blancas y una ventaja negativa para las negras:



{"type":"cp", "value":12}    #    12 .
{"type":"mate", "value":-3}  #      .
      
      





100 centipoints significa una ventaja sobre el oponente de un peón, y 300 significa una pieza menor como un alfil. Sin embargo, cabe destacar que Stockfish asigna un valor a las piezas en función de su posición, lo que significa que es bastante posible tener una ventaja de 1000 centipoints incluso con igual número de piezas en el tablero.



Necesitaba mapear este puntaje en algo más conveniente para procesar, por ejemplo, números entre 0 y 1. Para esto, decidí de improviso que una ventaja de 300+ se mostraría en 1.0 y un retraso de 300+ en 0. Además, cualquier mate en X movimientos (incluso si X es 20) será 1 o 0.



#  [-1;1]
def rating(ev, fen):
    val = ev["value"]
    if ev["type"] == "cp":
        #  -300, +300.   .
        val = max(-300, min(300, val))
        return val / 300.0
    #   X :  max .
    if val > 0: return 1.0
    if val < 0: return -1.0
    #   ,     ?
    b = chess.Board(fen)
    return 1.0 if b.turn == chess.WHITE else -1.0

#  [0;1],  0 -  min,  1 -  max   .
def rating_black(ev, fen):
    return -rating(ev, fen) * 0.5 + 0.5
      
      





Ahora toda la información estaba fuera de lugar y tenía que encontrar los nodos del gráfico (es decir, las posiciones) en los que Black estaba en una posición perdedora, así como la secuencia de movimientos más adecuada para alcanzarlos. Era necesario pesar las costillas de tal manera que fuera posible calcular fácilmente la probabilidad de alcanzar una posición particular. Razoné así:



  • En cada posición, puede estimar la probabilidad de realizar un movimiento en particular dividiendo el número de pases a lo largo del borde correspondiente por el número total de movimientos realizados desde esa posición.
  • Cada borde ahora tendrá un peso entre 0 y 1, donde un valor más alto refleja una mayor probabilidad de atravesarlo desde esa posición.
  • Entonces, la probabilidad de pasar por un camino en particular será el producto de las probabilidades de todos los bordes atravesados.


Para resolver el problema utilizando algoritmos de gráficos estándar, fue necesario transformar los pesos de los bordes para que:



  • Representaban la distancia, no la probabilidad (es decir, cuanto mayor es la distancia, menor es la probabilidad de elegir un camino).
  • La distancia entre dos nodos fue la suma de los pesos de los bordes atravesados ​​(en contraposición al producto de las probabilidades).


De hecho, esto es mucho más fácil de hacer que de explicar. La fórmula es muy sencilla:



distance(e) = -log(prob(e))
      
      





En Python, se vería así:



def compute_edges_weight(vertex):
    all_count = sum(map(lambda x: x["count"], vertex.out_edges()))
    for edge in vertex.out_edges():
        prob = edge["count"] / all_count
        edge["prob"] = prob
        edge["weight"] = -math.log(prob)
      
      





Tomar el logaritmo de la probabilidad de elegir una arista dará un número negativo, porque la probabilidad está entre 0 y 1. No hay necesidad de preocuparse por el caso en el que la probabilidad es cero (como resultado de lo cual el logaritmo daría menos infinito), ya que cada borde del gráfico se ha atravesado al menos una vez ... Cuanto menor sea la probabilidad, más negativo resultará el logaritmo, lo que significa que invertir su signo dará lo que necesitamos, ya que:



  • La suma de logaritmos es el logaritmo del producto de sus argumentos log(a) + log(b) = log(a*b)



    .
  • Cuanto mayor sea el resultado, menor será la probabilidad que lo determine.






Armado con esta idea, puede calcular la ruta más corta entre el nodo de inicio y todos los demás nodos utilizando el algoritmo de Dijkstra . El resultado es un mapeo entre cada nodo y la ruta más corta a la posición inicial, que representa la secuencia más probable de movimientos que conducen a ella.



En este punto, elegí arbitrariamente el valor mínimo de la ventaja y ordené los caminos por probabilidad. Los primeros caminos representaron la mayor oportunidad para mí de salir del debut con una ventaja sobre PlayerX.



Mejoras



¿Qué he averiguado? Entre las posiciones dadas por este algoritmo se encontraba la siguiente (movimiento de las blancas):







Como puede ver, las negras están en una posición muy incómoda (+8,9 según Stockfish) porque g6, la última jugada de las negras, fue un error. Las blancas continúan, tomando el peón de e5 y la torre. En este punto, el juego para las negras está prácticamente terminado, ya que tendrá que salvar el caballo, el peón en h7 y el alfil. Otro resultado del algoritmo fue este (movimiento de las blancas):







Aquí vemos un jaque mate en un movimiento ( jaque mate de los niños ).



El problema es que PlayerX cometió estos errores solo unas pocas veces en sus primeros juegos y no los volvió a repetir. Los primeros ataques de reina generalmente solo los realizan jugadores sin experiencia y solo son efectivos contra jugadores del mismo nivel. Habiendo dejado la categoría de principiantes, PlayerX no ha cometido estos errores durante mucho tiempo, porque los oponentes más competentes no van por ese camino. Sabía que tal apertura no funcionaría, porque PlayerX sabía cómo defenderse.



Otro problema estaba relacionado con las secuencias de movimientos, que ocurrieron solo una vez, pero desde posiciones típicas. La probabilidad de su posición final resultó ser la misma que la probabilidad de la última posición típica, porque cada ventaja tenía una probabilidad de 1.0 (dado que no se jugaron otras posibilidades). En el siguiente ejemplo, puede seguir los bordes 7 y 6 (la posición más común en el segundo movimiento) y luego uno de los bordes con 1. Además, todos los movimientos posteriores se jugarán solo una vez (porque esta posición se formó solo en una partida), como resultado de lo cual cada movimiento tendrá una probabilidad de 1.0.







Y aquí están las probabilidades:







No se puede confiar en tal esquema, porque es poco probable que la misma secuencia de movimientos se juegue sin ambigüedades. Para tal conclusión, no tenemos suficientes juegos en los que el juego se desarrolle desde estas posiciones.



La famosa cita (¿ B. Brewster ?): "En teoría no hay diferencia entre teoría y práctica, pero en la práctica sí" resultó ser correcta en este caso, por lo que necesitaba algunos refinamientos e investigación independiente para encontrar hipotéticos más exitosos. posiciones.



Arreglé el segundo problema estableciendo un límite superior en la probabilidad de una ventaja para que las secuencias largas de movimientos jugadas solo una vez perdieran gradualmente su probabilidad.



def compute_edges_weight(vertex, prob_ceiling=0.9):
    all_count = sum(map(lambda x: x["count"], vertex.out_edges()))
    for edge in vertex.out_edges():
        #  ...    (default 90%).
        prob = min(edge["count"] / all_count, prob_ceiling)
        edge["prob"] = prob
        edge["weight"] = -math.log(prob)
      
      





Para el primer problema, simplemente filté manualmente las malas suposiciones. Como resultado, solo me quedaban un par de puestos en los que trabajar.



La razón de otra modificación fue que no quería que las probabilidades de los movimientos de las blancas influyeran en la probabilidad de elegir un camino, porque jugaba para las blancas y podía decidir por mí mismo qué camino tomar. Por esta razón, establecí todas las probabilidades de los movimientos de las blancas en 1.0 (peso cero), lo que resultó en un gráfico como este:







Preparación



En el proceso de estudiar, elegí el siguiente puesto:







Según Lichess, esta es la Defensa Alekhine (ataque de dos peones). En esta posición, las negras solo tienen una jugada exitosa (Cb6), después de la cual aún permanecen en una posición menos ventajosa (+0.6 según Stockfish). Sin embargo, desde esta posición, PlayerX suele jugar Cf4, lo que es muy desafortunado para él (+2,3). Monté un estudio en Lichess y comencé a buscar algunas variaciones (buenos movimientos y movimientos jugados por PlayerX).



Al final, obtuve un árbol de posibilidades, que traté de recordar y comprender. Por ejemplo, necesitaba averiguar qué amenazaba un movimiento como d5, por qué Cf4 no tuvo éxito y preparar respuestas óptimas para todos.



No hice esto por mucho tiempo, porque me aburrí rápidamente y, de hecho, solo estaba parcialmente preparado.



Juego decisivo



Todo sucedió como si estuviera mirando hacia el futuro: PlayerX y yo estábamos en la posición de la defensa de Alekhine. Al encontrarse en una situación incómoda, falló su caballo en el quinto movimiento. Resulta que incluso los jugadores mucho más experimentados que tú comienzan a cometer errores uno tras otro cuando se encuentran en condiciones perdedoras. Es fácil jugar con claridad cuando ganas, pero ¿puedes mantener la calma en la situación contraria? En el movimiento 10, ya estaba liderando con una ventaja de +7.1, lo que hace que sea difícil perder, pero ese también fue el final del esquema que elaboré. Eche un vistazo a lo apretadas que están las negras ahora, y cómo mis piezas apuntan a atacar al rey:







A partir de ese momento comencé a cometer errores aquí y allá, pero al mismo tiempo logré mantener cierta ventaja hasta la jugada 27:







Desafortunadamente, tenía un tiempo muy limitado (jugamos un juego acelerado de 10 minutos), así que tuve que caminar rápido. Al final, cometí errores fatales en 32 y 33 movimientos, y después de uno más recibí un jaque mate de mi oponente invicto: /







Aquí está la coincidencia completa (con errores graves, etc.):





Vista previa por lotes en vivo: lichess.org/2qKKl2MI



conclusiones



¿Qué he aprendido de todo esto? Algunas cosas, la mayoría de las cuales parecen obvias en retrospectiva:



  1. Prepararte para un oponente específico puede darte una ventaja de apertura significativa.
  2. Los jugadores novatos a menudo pierden la oportunidad de aprovechar los movimientos cuestionables del oponente. En este sentido, es fácil obtener una ventaja conduciendo al enemigo a una posición desde la que solo hay un golpe.
  3. . , . .
  4. , , . .
  5. , , .


El código que utilicé está en el repositorio. Tenga en cuenta que no he incluido los datos allí, y el código en sí es bastante descuidado. Sin embargo, espero que sea de utilidad, especialmente para aquellos que todavía están pensando en dominar la informática. Mire, es muy posible con su ayuda resolver problemas en la vida real, por lo que no se trata solo de mover bits de un lado a otro.



Eso es todo, amigos. Espero que algún día pueda derrotar a mi hermano, pero por ahora intentaré lograrlo ... por mis propios medios.








All Articles