├── .gitignore ├── README.md ├── api.py ├── chessnn ├── __init__.py ├── nn.py └── player.py ├── demo.gif ├── diagram.png ├── model.png ├── requirements.txt ├── training.py ├── uci.py ├── versus.html └── view.html /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /*.iml 3 | /viewer/ 4 | *.pgn 5 | *.pyc 6 | *.hdf5 7 | *.pkl 8 | /chessboard/ 9 | *.bak 10 | /syzygy/ 11 | /models/ 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chess Engine with Neural Network 2 | 3 | ![](demo.gif) 4 | 5 | ## Motivation 6 | 7 | It is inspired by TCEC Season 14 - Superfinal, where Leela was trying to fry Stockfish. [Stockfish](https://stockfishchess.org/) is chess engine that dominates among publicly available engines since 2014. Stockfish uses classic approach of chess engines, where everything is determined by algorithms built into engine. [Lc0 aka Leela Chess Zero](http://lczero.org/) is different, since it uses some sort of trainable neural network inside, as well as appealing human-like nickname. Mid-series, Leela managed to take the lead by 1-2 points, and there is a chance NN-powered chess engine will finish heuristics-based dominance (78/100 games played so far). 8 | 9 | I wanted to demonstrate my 7yr-old daughter that making these computer chess players are not as hard. We drew a diagram of playing and learning with NN and I started implementing it. The idea is to have practice and fun, make own mistakes, have discussions with daughter about planning and decision making. 10 | 11 | I understand there is ton of research done for centuries on all of the things I say below, I deliberately choose to make my own research for the fun of it. 12 | 13 | ## Diagram 14 | ![](diagram.png) 15 | 16 | ## Neural Network Structure: 17 | ![](model.png) 18 | 19 | 20 | ## Journal 21 | 22 | ### Feb 15, 2019 23 | First commits of the code. 24 | Using own representation of chess board as 8 * 8 * 12 array of 1/0 values. 1/0 are used as most distinctive input for NN about piece presence at certain square. NN uses two hidden layers 64 nodes each. Output of NN is 8 * 8 array for "from cell" and 8 * 8 array for "to cell" scores. A piece of code is used to choose best move that a) is by chess rules; b) has best score of "from" multiplied by "to" output cells. Finally, board state is checked for game end conditions of checkmate or 50-move rule. 25 | 26 | Two copies of NN are used, one plays as White and one as Black, playing versus each other. Game is recorded as PGN file, to be able to review it by human. 27 | 28 | ### Feb 16, 2019 29 | Daughter decided that both NN copies urgently need girl-ish names. White is Lisa, black is Karen. 30 | I decided that writing chess rule checking and move validation is not the goal of this project. Threw away my code for it, in favor of [python-chess](https://python-chess.readthedocs.io/en/latest/) library. This shortened code x3 times. 31 | 32 | Engines are playing with each other, after each game they take move log and NN learns from it, assuming *all moves of lost game were bad, all moves of won game are good*, moves from draw are slightly bad (to motivate them search for better than draw). 33 | 34 | A web UI is added along with small API server to be able to play with White versus Karen, to try her. Lisa vs Karen are left to play with each other and learn overnight. 35 | 36 | ### Feb 17, 2019 37 | They played 12700 games overnight. Watching their game recordings shows they are doing a lot of dumb moves with same piece back and forth, making no progress unless they approach 3-fold or 50-move, when program forces to discard drawish move. 38 | 39 | It seemed that it's deeply wrong to teach NN that any move from victorious game is good. I wanted to avoid this originally, but to make progress, we need to introduce some sort of *position evaluation*, so engine will learn to tell good moves from bad moves. 40 | 41 | With daughter, we outlined some rules of evaluating position: 42 | 1. Pieces have different value 43 | 2. Capturing enemy piece is very good 44 | 3. Attacking enemy piece is good 45 | 4. Putting yourself under attack or removing defence from your piece is bad 46 | 5. Increasing squares under our control is good 47 | 6. Moving pawn forward is slightly good as it is the way to promote 48 | 49 | We started with material balance, taking possible square control as value of the piece: 50 | - Pawn: 1 51 | - Knight: 8 52 | - Bishop: 13 53 | - Rook: 14 54 | - Queen: 27 55 | - King: 64 - special value to reflect it's precious 56 | 57 | Since we analyze game after all moves are done, we judge move by what was the consequence after opponent's move. We calculate sum of all our piece values, subtracting all enemy piece values, so we get *material balance*. Learning for ~3000 games shown that NNs definitely learn to capture pieces a lot, so the principle of evaluation works and reflects into NNs move score predictions. Material balance addresses #1, #2 and #3 of our evaluation rules. 58 | 59 | Next is to take *mobility* into account, to cover rules #3, #4, #5, #6 from above list. It is quite easy to implement, again we take state after opponent's move and we look now many moves can we make versus how many moves are there for opponent. Attacks are counted in a similar way, we assume that if piece is under attack, it adds *half* of its value to mobility score. This gives us "mobility balance". 60 | 61 | Now it gets tricky, since we need to combine material balance and mobility balance to get overall score for position. Current solution is to multiply material balance by 10 and sum with mobility balance. Gut feeling tells me it's not right, but let's see what NN will learn. 62 | 63 | Bots are left to play overnight again. 64 | 65 | ### 18 Feb, 2019 66 | 67 | They've played ~32000 games. Quick game versus Karen shows that still NN miss obvious attacks and loses after human quickly. But this time it does a lot more captures, moves pieces more consistenly forward, does less "dumb cyclic moves". 68 | 69 | Watching games of Lisa vs Karen shows consistent pawn promotions and a lot of captures from both sides. Still most of obvious attacks, exchanges are missed. End game is completely dumb, falling into cyclic moves still. 70 | 71 | I did series of experiments with NN structure, looking if making it deeper or wider would help. But ~10000 games shows that neither 3-layer, nor 128-node NN does better. 72 | 73 | It's time to summarize what was done over past days: 74 | - building this kind of NN-driven test engine is super-easy 75 | - the rules we put as criteria for learning do affect engine move making 76 | - we should research/experiment with evaluation approach more 77 | - we should experiment with NN structure more, maybe use LSTM instead of feed-forward 78 | - re-learning from last N games might be better than incremental learning from very last game 79 | - turning game representation to "always one side" and using exactly same NN for game might make it more robust and universal 80 | 81 | ### 22 Feb, 2019 82 | 83 | Decided to make sing NN that will be able to decide for both sides, using board and move flip for black. 84 | 85 | NN structure changed to 6-layers decreasing from 768 to 128 nodes. This increased number of trainable parameters 100x times. It went back to learn games with a lot of dumb moves. 86 | 87 | ### 23 Feb, 2019 88 | 89 | I tend to change my mind back. Actually, my program is just "what is the best move from current position" solver. It has no tree search part (intentional!), and it's wrong for NN to learn moves as good just because they are part of winning game. The score of move is mere measure of if it has changed position in our favor. At first, I want to get NN that will not do stupid mistakes. 90 | 91 | ### 24 Feb, 2019 92 | 93 | Life of developer: was debugging code to understand why my accuracy metric of NN move quickly reaches 1.0 and stayes there. Found that I used wrong data type for numpy array, it was rounding my floats into ints. :facepalm: 94 | 95 | ### 25 Feb, 2019 96 | 97 | Still nothing leads to learning good moves. Experimenting with various scores for moves. Cyclic moves demonstrate lack of depth. I revised the criteria for good move, made NN deeper and made it to learn by batches of 10 games. Let's see... 98 | 99 | ### 4 Mar, 2019 100 | 101 | Thinking of crystallizing "just next move predictor NN" more. For that, I'll save dataset of all moves generated through learning, and will always learn from this big set. Previous results from multilayer NN were worse than 2-layer, will get back to 2-layer for now. 102 | 103 | I feel the trick to teach this variant of NN is to find correct board state representation, so NN will naturally learn. 104 | 105 | ### 13 Mar, 2019 106 | 107 | I made many experiments yesterday, trying to figure out why NN still suggests dumb moves. One good thing I found is that feeding only good moves into learning process improves the NN accuracy in training, which actually makes sense. Still, the accuracy of "move from" output of NN is much better than "move to". In fact, we need "move to" as most valuable output. 108 | 109 | ### 16 Mar, 2019 110 | 111 | Found that moves in training dataset were not unique. God knows how that was affecting learning process for NN... Also found out that material balance score were inverted for black, also affecting NN training scores. Good to find this, it gives me hope that it is possible to build NN like I want. 112 | 113 | Big problem is dataset balancing. Moves tend to use kings more, which leads NN to learn to move kings more. Trying to re-balance learning sample weights did not help. 114 | 115 | No matter what I did, the best accuracy I get from NN is 0.5, which is equal to random. I will try to go back to principle of "all moves from won game are good" to research it again. 116 | 117 | ### 30 Mar, 2019 118 | 119 | I see that "noisy moves" from draws affect NN learning in a bad way. Same for pointless moves along games with victories. Those moves don't lead to victory as much. So maybe if learning moves for NN would be chosen more selectively, it would help. Will try it in the morning (00:27 now)... 120 | 121 | Well, I experimented whole day and the result didn't change. Still stupid moves. 122 | 123 | ### 14 Apr 2019 124 | 125 | OpenAI has beaten humans in Dota2. This world is doomed. Let's not postpone the unavoidable, I need to find my way to make this chess engine at least a bit smart. 126 | Current idea is to introduce "intermediary goals" of "possible moves", "attacks", "threats" as auxillary output layers. Maybe even "defended pieces". Let's see if NN can learn these simple position analysis concepts. 127 | 128 | ... so, after day of coding, I've implemeted this approach. It is not clear if it is viable or not yet. The good outcome is that I've found a number of severe bugs and fixed them. Those bugs happened because of "single NN for both White and Black", a lot of confusion is there in the code to juggle sides. Cool thing is now I have visualization for model outputs. It will train overnight, though I don't expect outstanding results. 129 | 130 | ### 15 Apr 2019 131 | 132 | After night of training, it has reached slightly above 50% accuracy for choosing moves, but that's not much, since there are lots of repetitions and overall quality of moves is low. Still, it is better than what we had in the past. 133 | 134 | ### 16 Apr 2019 135 | 136 | I'll experiment today's evening with two things: a) simplify the NN back to just inputs and outputs, with no aux outs; b) train with only victories and with score increasing from 0 to 1 the closer move to the end of game. 137 | 138 | ... I need to understand how to avoid repetitions. The problem is that naturally NN falls into local optimums and suggest moves back and forth. One can't expect from this kind of non-temporal NN to work differently. So there should be a mechanism to avoid repetitions. Search tree is not an option, again. Just because whole idea of this engine is to avoid search tree. 139 | 140 | ### 18 Apr 2019 141 | 142 | Doing several experiments: 143 | 144 | - Removing 3-fold repetition moves from learning data. Those moves are still present in the games, but learning input is nog garbaged by them. 145 | - Added "eval" output of NN, teaching it to evaluate win chance of the game. Game start with each player having 0.5 and then loser gets to 0.0 while winner gets to 1.0. 146 | - Playing back and forth with aux outputs of NN. They're still in question. 147 | 148 | Also figured out my residual unit were increasing of size with each level. Reworked that to constant size of 8 * 8 * 12 (initial inputs) 149 | 150 | ### 27 Jul 2019 151 | 152 | Let me take some rest from working for new startup. Starting to change the code with following thoughts: 153 | 154 | - Having 2 outputs 8x8 is somehow hard to train. Let's train 64*64=4096 possible moves output 155 | - Let's switch to [Chess960 aka Fischerandom](https://en.wikipedia.org/wiki/Chess960) for better training variety, since it is generalization over classical Chess 156 | - Still no good solution for 3-fold and 50-move avoiding 157 | - for now, model's structure will get back to 2-layer dense, to speed up experiments 158 | - reading [article](https://towardsdatascience.com/how-to-teach-an-ai-to-play-games-deep-reinforcement-learning-28f9b920440a) about Q-Learning, maybe the learning strategy should change... Also on same topic: https://www.askforgametask.com/tutorial/machine-learning-algorithm-flappy-bird/ 159 | 160 | Found out that training data were using positions _after_ move made as source of training. Oh, my... 161 | Fixing that seems helped learning a ton. Shame on me for overlooking this since the very beginning. 162 | 163 | I also made integration with UCI and now my NN plays versus Stockfish. Loses 100% of games, of course. Let it play with SF overnight... 164 | 165 | ... it played 5000 games and dozens of re-trainings, but did not improve. I'll need to experiment more, still I see one important bug fixed. 166 | 167 | ### 17 Aug 2019 168 | 169 | I have interesting thought. Chess board position is effectively an image. 8x8 pixels with 12 channels. So can we apply convolutional NN to analyze this image? 170 | 171 | I've build a NN structure that uses convolutional layers with additional route of data fed into dense layers, and now experimenting with hyperparams and training. 172 | 173 | ... it played tens of thousands of games vs SF in "retrain with last game" mode, reached only 0.47 accuracy. But I already know there is one more problem with training data, black moves were not flipped properly. Figured it out in my head just before going to sleep. 174 | 175 | ### 18 Aug 2019 176 | 177 | I fixed that issue with move flipping, will see how it affects the model. Looks like learning rate has improved a bit. 178 | 179 | After some training, I see that NN does not give good moves vs SF. Will train it more. 180 | 181 | After some more training, it has approached a bit over 0.5 accuracy, probably for the first time from all my attempts. It's all less about NN structure, it's mostly about stupid bugs in complicated code. 182 | 183 | Still after massive training done, accuracy is barely above 0.5. Will need to experiment with NN structure now, I guess. 184 | 185 | ### 20 Aug 2019 186 | 187 | I decided to do one step back and start with simple 2-layer NN. And I see the accuracy close to 0.5, which means that we get better training without bugs in input data. 188 | 189 | After good amount of training I see that accuracy converges to 0.5, which does not mean any good state. 190 | 191 | OMG, _it makes meaningful captures for the first time_! That happened after I changed learning process to "learn only from moves of winning side", which I decided to do after reading some articles about how to deal with negative samples, softmax, and milticlass outputs. Though training accuracy is now around ~0.25, I like the impact on moves that I got. 192 | 193 | As usual, I will leave it training versus SF for a night, maybe even for 48 hours. Let's see how it evolves. ... And after night of training it still converges into ~0.4 accuracy. 194 | 195 | ### 3 Sep 2019 196 | 197 | Having short break from primary job, I implemented more efficient way to represent possible moves output from NN: it includes only moves that are theoretically possible on board. This limits output size from 4096 into 1792 combinations, which serves well on dense type of layers. 198 | 199 | Also, I changed structure of NN: 200 | - 2D-convolutional branches are made for kernel size 3, 4, 5, 6, 7, 8 201 | - concatenation of all conv branches comes into 2 aux outputs 8x8: attacked squares and defended squares 202 | - output from attacked and defended is concatenated with original position and fed into simple 2-layer Dense part. The assumption is that knowing piece positions, attacked squares and defended squares is sufficient to make "not too dumb" moves. 203 | - final move is decided on 1792 node output layer 204 | 205 | This net trains quite good now, I figured out loss, metrics and activations for aux and main outputs. Categorical accuracy of "moves" output is still below 0.5, but I see sample games with some attacks and captures, which is a good sign. 206 | 207 | Meanwhile, S16 - Division P is live on https://tcec.chessdom.com/, Leela plays versus Stockfish again... 208 | 209 | ### 10 Sep 2019 210 | 211 | Interesting thought: I should not use softmax on moves output layer. Just because possible moves are independent and score for them should be just independent sigmoid. Which also means that lost games can again be included in training set. There are some questions on training moves like this, since we only can pass single good/bad move as input. Ideal solution would be having own loss function. 212 | 213 | ### 18 Apr 2020 214 | 215 | This time, superfinal between Lc0 and Stockfish happens again, season #17. We're in self-isolation because of COVID19, it's a good time to revise my chess AI project. 216 | 217 | I was thinking a lot about it, and many experiments were not merged into main branch, because they're all have failed. 218 | I'm ready to give up on my initial idea: build a NN that would "say" correct move. I realize that much more advanced 219 | reinforcement learning technique would be needed to achieve that. 220 | 221 | Instead, I will try to do a "tree search with just 1 level", to pick the right move. It assumes that central part is NN 222 | position evaluator that is trusted. It's a sort of "optimist" approach where we believe that current eval is very smart 223 | and no deep tree search is needed. 224 | 225 | ... 226 | 227 | After some training against SF, I see a problem of 1-ply depth: if a move actually leads to bad position (taking 228 | opponents' move into account), then all we have is choice from very bad moves. 229 | 230 | ### 1 Dec 2021 231 | 232 | Nepo plays with Carlsen for the Chess 233 | Crown (https://lichess.org/broadcast/world-chess-championship-2021/game-5/H8H4enOL). I want to try exercise on this 234 | project again, using my 3060Ti and the approach of 1-level deep eval. The idea: for each position, we calculate score 235 | for each possible next position. The top score is the move. I'm getting more modest, just want to get a model that makes not totally random moves, for example, does not miss capturing the queen for free. 236 | 237 | I put the current version to run overnight. In the morning it got to game #960, to try all possible starting positions. 238 | Ran for 15 hours. It spends a lot of time making long endgames, so I'll add SyzygyDB checks for 3/4/5-piece endings. 239 | 240 | It seems that with the approach of eval it is a _regression_ task. 241 | 242 | At the end of this exercise, I see that quality of games does not grow much, even training against SF. Maybe the NN structure could be somehow improved to provide better position analysis capacity. I still miss good point on measuring the quality of games, what's the KPI. It's not NN loss, as far as I see. 243 | 244 | ### 11 Nov 2022 245 | 246 | I wrote somewhere above 'bout COVID times... Well, I knew not that it can be that worse. Now I'm a voluntary exile and life won't be the same. Still, I watch TCEC season 23 superfinal and Leela loses it to SF miserably. 247 | 248 | My mood is slightly better, and somehow I feel playing again with my chess program, to vent out my itchy engineering gland. 249 | 250 | I understand I would repeat something from above, but my thoughts are coming again to some statements: 251 | 252 | - I don't want the program to be the _search_ program that gives score to a position. Instead, I want it to be a prediction program, that would suggest the next move and _all_ logic should emerge inside NN, through its training. 253 | - The idea of input position => NN body => 1792 possible moves output 254 | - output layer should be `softmax`-activated 255 | - `categorical_crossentropy` should be a loss function 256 | 257 | I still believe that NN should learn at least basic valid moves rules. That will be the goal of the excercise. 258 | 259 | ... 260 | 261 | Checked again all my code for some huge stupid misuse of NN - it all looks correct according to my expectations. 262 | 263 | Observations: 264 | - for body activation relu is good and linear is even better 265 | - rmsprop optimizer does not work for dense net 266 | - judging by decrease in number of invalid moves offered by NN, training of convolutional variant helps 267 | - `sigmoid` output layer seem to produce better probability of legal moves -------------------------------------------------------------------------------- /api.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import random 3 | from http.server import HTTPServer, SimpleHTTPRequestHandler 4 | from queue import Queue 5 | from threading import Thread 6 | 7 | from chess import WHITE, BLACK 8 | 9 | from chessnn.nn import NN, NNChess 10 | from chessnn.player import NNPLayer 11 | from training import play_one_game 12 | 13 | 14 | class PlayerCLI(NNPLayer): 15 | 16 | def _choose_best_move(self): 17 | print("Opponent's move: %s" % self.board.move_stack[-1]) 18 | while True: 19 | move_str = input("Enter next move: ") 20 | try: 21 | move = self.board.parse_san(move_str) 22 | break 23 | except ValueError as exc: 24 | logging.error("Wrong move, try again: %s", exc) 25 | 26 | return move 27 | 28 | 29 | class ChessAPIHandler(SimpleHTTPRequestHandler): 30 | 31 | def do_GET(self): 32 | if self.path != '/move': 33 | return super().do_GET() 34 | 35 | logging.debug("Getting move to send...") 36 | item = self.server.oqueue.get(True) 37 | logging.debug("Sending move: %s", item) 38 | self.send_response(200) 39 | self.send_header("Content-type", "text/plain") 40 | self.end_headers() 41 | self.wfile.write(bytes(str(item), 'ascii')) 42 | 43 | def do_POST(self): 44 | content_len = int(self.headers.get('Content-Length')) 45 | item = self.rfile.read(content_len) 46 | item = item.decode('ascii') 47 | logging.debug("Received move: %s", item) 48 | self.server.iqueue.put(item) 49 | self.send_response(202) 50 | self.end_headers() 51 | self.wfile.write(bytes(str(item), 'ascii')) 52 | 53 | 54 | class PlayerAPI(NNPLayer): 55 | 56 | def __init__(self, color) -> None: 57 | super().__init__("API", color, None) 58 | server_address = ('', 8090) 59 | self.httpd = HTTPServer(server_address, ChessAPIHandler) 60 | self.iqueue = Queue() 61 | self.oqueue = Queue() 62 | self.httpd.iqueue = self.iqueue 63 | self.httpd.oqueue = self.oqueue 64 | 65 | self.thr = Thread(target=self.run) 66 | self.thr.setDaemon(True) 67 | self.thr.start() 68 | 69 | def run(self): 70 | self.httpd.serve_forever() 71 | 72 | def _choose_best_move(self): 73 | if self.board.move_stack: 74 | self.oqueue.put(self.board.move_stack[-1]) 75 | logging.debug("Getting next move from user...") 76 | move_str = self.iqueue.get(True) 77 | return self.board.parse_san(move_str), 0.5 78 | 79 | 80 | if __name__ == "__main__": 81 | logging.basicConfig(level=logging.DEBUG) 82 | 83 | white = PlayerAPI(WHITE) 84 | black = NNPLayer("Karen", BLACK, NNChess("nn.hdf5")) 85 | 86 | cnt = 1 87 | while True: 88 | rnd = random.randint(0, 960) 89 | play_one_game(white, black, 0) 90 | cnt += 1 91 | -------------------------------------------------------------------------------- /chessnn/__init__.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import copy 3 | import json 4 | import logging 5 | import os.path 6 | import sys 7 | from collections import Counter 8 | from typing import List, Optional 9 | 10 | import chess 11 | import numpy as np 12 | from chess import pgn, SquareSet, SQUARES, Outcome 13 | from chess.syzygy import open_tablebase, Tablebase 14 | from matplotlib import pyplot 15 | 16 | mpl_logger = logging.getLogger('matplotlib') 17 | mpl_logger.setLevel(logging.WARNING) 18 | 19 | PIECE_VALUES = { 20 | chess.PAWN: 1, 21 | chess.KNIGHT: 3, 22 | chess.BISHOP: 4, 23 | chess.ROOK: 6, 24 | chess.QUEEN: 10, 25 | chess.KING: 100, 26 | } 27 | 28 | 29 | class MyStringExporter(pgn.StringExporter): 30 | comm_stack: list 31 | 32 | def __init__(self, comments: list): 33 | super().__init__(headers=True, variations=True, comments=True) 34 | self.comm_stack = copy.copy(comments) 35 | 36 | def visit_move(self, board, move): 37 | if self.variations or not self.variation_depth: 38 | # Write the move number. 39 | if board.turn == chess.WHITE: 40 | self.write_token(str(board.fullmove_number) + ". ") 41 | elif self.force_movenumber: 42 | self.write_token(str(board.fullmove_number) + "... ") 43 | 44 | # Write the SAN. 45 | if self.comm_stack: 46 | log_rec = self.comm_stack.pop(0) 47 | if log_rec.ignore: 48 | comm = "ign" 49 | else: 50 | pass 51 | comm = "%.2f" % (log_rec.get_eval()) 52 | 53 | self.write_token(board.san(move) + " {%s} " % comm) 54 | else: 55 | self.write_token(board.san(move)) 56 | 57 | self.force_movenumber = False 58 | 59 | 60 | class BoardOptim(chess.Board): 61 | move_stack: List[chess.Move] 62 | 63 | def __init__(self, fen=chess.STARTING_FEN, *, chess960=False): 64 | super().__init__(fen, chess960=chess960) 65 | self.forced_result = None 66 | self.illegal_moves = [] 67 | self._fens = [] 68 | self.comment_stack = [] 69 | self.initial_fen = chess.STARTING_FEN 70 | 71 | def outcome(self, *, claim_draw: bool = False) -> Optional[Outcome]: 72 | if self.forced_result: 73 | return self.forced_result 74 | return super().outcome(claim_draw=claim_draw) 75 | 76 | def set_chess960_pos(self, sharnagl): 77 | super().set_chess960_pos(sharnagl) 78 | self.initial_fen = self.fen() 79 | 80 | def write_pgn(self, wp, bp, fname, roundd): 81 | journal = pgn.Game.from_board(self) 82 | journal.headers.clear() 83 | if self.chess960: 84 | journal.headers["Variant"] = "Chess960" 85 | journal.headers["FEN"] = self.initial_fen 86 | journal.headers["White"] = wp.name 87 | journal.headers["Black"] = bp.name 88 | journal.headers["Round"] = roundd 89 | journal.headers["Result"] = self.result(claim_draw=True) 90 | journal.headers["Site"] = self.explain() 91 | exporter = MyStringExporter(self.comment_stack) 92 | pgns = journal.accept(exporter) 93 | with open(fname, "w") as out: 94 | out.write(pgns) 95 | 96 | def explain(self): 97 | if self.forced_result: 98 | comm = "SyzygyDB" 99 | elif self.is_checkmate(): 100 | comm = "checkmate" 101 | elif self.can_claim_fifty_moves(): 102 | comm = "50 moves" 103 | elif self.can_claim_threefold_repetition(): 104 | comm = "threefold" 105 | elif self.is_insufficient_material(): 106 | comm = "material" 107 | elif not any(self.generate_legal_moves()): 108 | comm = "stalemate" 109 | else: 110 | comm = "by other reason" 111 | return comm 112 | 113 | def can_claim_threefold_repetition1(self): 114 | # repetition = super().can_claim_threefold_repetition() 115 | # if repetition: 116 | cnt = Counter(self._fens) 117 | return cnt[self._fens[-1]] >= 3 118 | 119 | def can_claim_threefold_repetition2(self): 120 | """ 121 | Draw by threefold repetition can be claimed if the position on the 122 | board occured for the third time or if such a repetition is reached 123 | with one of the possible legal moves. 124 | 125 | Note that checking this can be slow: In the worst case 126 | scenario every legal move has to be tested and the entire game has to 127 | be replayed because there is no incremental transposition table. 128 | """ 129 | transposition_key = self._transposition_key() 130 | transpositions = collections.Counter() 131 | transpositions.update((transposition_key,)) 132 | 133 | # Count positions. 134 | switchyard = [] 135 | # noinspection PyUnresolvedReferences 136 | while self.move_stack: 137 | move = self.pop() 138 | switchyard.append(move) 139 | 140 | if self.is_irreversible(move): 141 | break 142 | 143 | transpositions.update((self._transposition_key(),)) 144 | 145 | while switchyard: 146 | self.push(switchyard.pop()) 147 | 148 | # Threefold repetition occured. 149 | if transpositions[transposition_key] >= 3: 150 | return True 151 | 152 | return False 153 | 154 | def is_fivefold_repetition1(self): 155 | cnt = Counter(self._fens) 156 | return cnt[self._fens[-1]] >= 5 157 | 158 | def can_claim_draw1(self): 159 | return super().can_claim_draw() or self.fullmove_number > 100 160 | 161 | def push(self, move): 162 | super().push(move) 163 | self._fens.append(self.epd().replace(" w ", " . ").replace(" b ", " . ")) 164 | 165 | def pop(self): 166 | self._fens.pop(-1) 167 | return super().pop() 168 | 169 | def get_position(self): 170 | pos = np.full((8, 8, len(chess.PIECE_TYPES) * 2), 0) 171 | for square in chess.SQUARES: 172 | piece = self.piece_at(square) 173 | 174 | if not piece: 175 | continue 176 | 177 | int(piece.color) 178 | channel = piece.piece_type - 1 179 | if piece.color: 180 | channel += len(PIECE_VALUES) 181 | pos[chess.square_file(square)][chess.square_rank(square)][channel] = 1 182 | 183 | pos.flags.writeable = False 184 | 185 | return pos 186 | 187 | def get_attacked_defended(self): 188 | attacked = np.full(64, 0.0) 189 | defended = np.full(64, 0.0) 190 | 191 | our = self.occupied_co[self.turn] 192 | their = self.occupied_co[not self.turn] 193 | 194 | for square in SquareSet(our): 195 | for our_defended in SquareSet(self.attacks_mask(square)): 196 | defended[our_defended] = 1.0 197 | 198 | for square in SquareSet(their): 199 | for our_attacked in SquareSet(self.attacks_mask(square)): 200 | attacked[our_attacked] = 1.0 201 | 202 | return attacked, defended 203 | 204 | def _plot(self, matrix, position, fig, caption): 205 | """ 206 | :type matrix: numpy.array 207 | :type position: numpy.array 208 | :type fig: matplotlib.axes.Axes 209 | :type caption: str 210 | :return: 211 | """ 212 | fig.axis('off') 213 | 214 | img = fig.matshow(matrix) 215 | 216 | for square in chess.SQUARES: 217 | f = chess.square_file(square) 218 | r = chess.square_rank(square) 219 | 220 | cell = position[f][r] 221 | if any(cell[:6]): 222 | color = chess.BLACK 223 | piece_type = np.argmax(cell[:6]) 224 | elif any(cell[6:]): 225 | color = chess.WHITE 226 | piece_type = np.argmax(cell[6:]) 227 | else: 228 | continue 229 | 230 | piece_symbol = chess.PIECE_SYMBOLS[piece_type + 1] 231 | 232 | fig.text(f, r, chess.UNICODE_PIECE_SYMBOLS[piece_symbol], 233 | color="white" if color == chess.WHITE else "brown", 234 | alpha=0.8, ha="center", va="center") 235 | 236 | fig.set_title(caption) 237 | 238 | def multiplot(self, memo, pmap, predicted, actual): 239 | if not is_debug() or self.fullmove_number < 1: 240 | return 241 | pos = self.get_position() if self.turn else self.mirror().get_position() 242 | 243 | pyplot.close("all") 244 | fig, axes = pyplot.subplots(len(pmap), 2, figsize=(5, 10), gridspec_kw={'wspace': 0.01, 'hspace': 0.3}) 245 | 246 | for idx, param in enumerate(pmap): 247 | self._plot(np.reshape(predicted[idx], (8, 8)), pos, axes[idx][0], "pre " + param) 248 | self._plot(np.reshape(actual[idx], (8, 8)), pos, axes[idx][1], "act " + param) 249 | 250 | # axes[3][1].axis("off") 251 | # axes[3][1].set_title(memo + " - " + chess.COLOR_NAMES[self.turn] + "#%d" % self.fullmove_number) 252 | # pyplot.tight_layout() 253 | pyplot.show() 254 | logging.debug("drawn") 255 | 256 | def get_possible_moves(self): 257 | res = np.full(len(MOVES_MAP), 0.0) 258 | for move in self.generate_legal_moves(): 259 | res[MOVES_MAP.index((move.from_square, move.to_square))] = 1.0 260 | return res 261 | 262 | 263 | class MoveRecord(object): 264 | piece: chess.Piece 265 | 266 | def __init__(self, position, move: chess.Move, piece, move_number, fifty_progress) -> None: 267 | super().__init__() 268 | self.hash = None 269 | # TODO: add en passant square info 270 | # TODO: add castling rights info 271 | self.possible = None 272 | self.full_move = move_number 273 | self.fifty_progress = fifty_progress 274 | self.eval = None 275 | self.ignore = False 276 | 277 | self.position = position 278 | self.piece = piece 279 | 280 | self.from_round = 0 281 | 282 | self.to_square = move.to_square 283 | self.from_square = move.from_square 284 | 285 | self.attacked = None 286 | self.defended = None 287 | 288 | def __hash__(self) -> int: 289 | if self.hash is None: 290 | self.hash = hash(as_tuple(self.position.tolist())) 291 | return self.hash 292 | 293 | def __str__(self) -> str: 294 | return json.dumps({x: y for x, y in self.__dict__.items() if x not in ('forced_eval', 'kpis')}) 295 | 296 | def get_eval(self): 297 | if self.eval is not None: 298 | return self.eval 299 | 300 | return 0.0 301 | 302 | def get_move_num(self): 303 | if self.from_square == self.to_square: 304 | return -1 # null move 305 | 306 | return MOVES_MAP.index((self.from_square, self.to_square)) 307 | 308 | def get_move(self): 309 | return chess.Move(self.from_square, self.to_square) 310 | 311 | 312 | def as_tuple(x): 313 | if isinstance(x, list): 314 | return tuple(as_tuple(y) for y in x) 315 | else: 316 | return x 317 | 318 | 319 | def is_debug(): 320 | return 'pydevd' in sys.modules or os.getenv("DEBUG") 321 | 322 | 323 | def _possible_moves(): 324 | res = set() 325 | for f in SQUARES: 326 | for t in chess.SquareSet(chess.BB_RANK_ATTACKS[f][0]): 327 | res.add((f, t)) 328 | 329 | for t in chess.SquareSet(chess.BB_FILE_ATTACKS[f][0]): 330 | res.add((f, t)) 331 | 332 | for t in chess.SquareSet(chess.BB_DIAG_ATTACKS[f][0]): 333 | res.add((f, t)) 334 | 335 | for t in chess.SquareSet(chess.BB_KNIGHT_ATTACKS[f]): 336 | res.add((f, t)) 337 | 338 | assert (10, 26) in res 339 | 340 | return list(sorted(res)) 341 | 342 | 343 | MOVES_MAP = _possible_moves() 344 | 345 | try: 346 | SYZYGY = open_tablebase(os.path.join(os.path.dirname(__file__), "..", "syzygy", "3-4-5"), load_dtz=False) 347 | except BaseException: 348 | SYZYGY = Tablebase() 349 | -------------------------------------------------------------------------------- /chessnn/nn.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import logging 3 | import os 4 | import tempfile 5 | import time 6 | from abc import abstractmethod 7 | from operator import itemgetter 8 | 9 | import chess 10 | import numpy as np 11 | import tensorflow 12 | from chess import PIECE_TYPES 13 | from keras import models, callbacks, layers, regularizers 14 | from keras.utils.vis_utils import plot_model 15 | from tensorflow import Tensor 16 | 17 | from chessnn import MoveRecord, MOVES_MAP 18 | 19 | tensorflow.compat.v1.disable_eager_execution() 20 | assert regularizers 21 | 22 | 23 | # os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3' 24 | 25 | 26 | class NN(object): 27 | _model: models.Model 28 | 29 | def __init__(self, path=tempfile.gettempdir()) -> None: 30 | super().__init__() 31 | self._train_acc_threshold = 0.9 32 | self._validate_acc_threshold = 0.9 33 | 34 | self._model = self._get_nn() 35 | js = self._model.to_json(indent=True) 36 | cs = hashlib.md5((js + self._model.loss).encode()).hexdigest() 37 | self._store_prefix = os.path.join(path, str(cs)) 38 | 39 | fname = self._store_prefix + ".hdf5" 40 | if os.path.exists(fname): 41 | logging.info("Loading model from: %s", fname) 42 | self._model = models.load_model(fname) 43 | else: 44 | logging.info("Starting with clean model: %s", fname) 45 | with open(self._store_prefix + ".json", 'w') as fp: 46 | fp.write(js) 47 | 48 | with open(self._store_prefix + ".txt", 'w') as fp: 49 | self._model.summary(print_fn=lambda x: fp.write(x + "\n")) 50 | 51 | plot_model(self._model, to_file=self._store_prefix + ".png", show_shapes=True) 52 | 53 | def save(self): 54 | filename = self._store_prefix + ".hdf5" 55 | logging.info("Saving model to: %s", filename) 56 | self._model.save(filename, overwrite=True) 57 | 58 | def inference(self, data): 59 | inputs, outputs = self._data_to_training_set(data, True) 60 | res = self._model.predict_on_batch(inputs) 61 | out = [x for x in res[0]] 62 | return out 63 | 64 | def train(self, data, epochs, validation_data=None): 65 | logging.info("Preparing training set of %s...", len(data)) 66 | inputs, outputs = self._data_to_training_set(data, False) 67 | 68 | logging.info("Starting to learn...") 69 | cbpath = '/tmp/tensorboard/%d' % (time.time() if epochs > 1 else 0) 70 | cbs = [callbacks.TensorBoard(cbpath, write_graph=False, profile_batch=0)] 71 | res = self._model.fit(inputs, outputs, # sample_weight=np.array(sample_weights), 72 | validation_split=0.1 if (validation_data is None and epochs > 1) else 0.0, shuffle=True, 73 | callbacks=cbs, verbose=2 if epochs > 1 else 0, 74 | epochs=epochs) 75 | logging.info("Trained: %s", {x: y[-1] for x, y in res.history.items()}) 76 | 77 | if validation_data is not None: 78 | self.validate(validation_data) 79 | 80 | def validate(self, data): 81 | logging.info("Preparing validation set...") 82 | inputs, outputs = self._data_to_training_set(data, False) 83 | 84 | logging.info("Starting to validate...") 85 | res = self._model.evaluate(inputs, outputs) 86 | logging.info("Validation loss and KPIs: %s", res) 87 | msg = "Validation accuracy is too low: %.3f < %s" % (res[1], self._validate_acc_threshold) 88 | assert res[1] >= self._validate_acc_threshold, msg 89 | 90 | @abstractmethod 91 | def _get_nn(self): 92 | pass 93 | 94 | @abstractmethod 95 | def _data_to_training_set(self, data, is_inference=False): 96 | pass 97 | 98 | 99 | reg = regularizers.l2(0.01) 100 | optimizer = "adam" # sgd rmsprop adagrad adadelta adamax adam nadam 101 | 102 | 103 | class NNChess(NN): 104 | def _get_nn(self): 105 | pos_shape = (8, 8, len(PIECE_TYPES) * 2) 106 | position = layers.Input(shape=pos_shape, name="position") 107 | pos_analyzed = position 108 | # pos_analyzed = self.__nn_conv(pos_analyzed) 109 | pos_analyzed = self.__nn_residual(pos_analyzed) 110 | # pos_analyzed = self.__nn_simple(pos_analyzed) 111 | 112 | # pos_analyzed = layers.concatenate([pos_analyzed2, pos_analyzed3]) 113 | # pos_analyzed = layers.Dense(64, activation=activ_hidden, kernel_regularizer=reg)(pos_analyzed) 114 | 115 | out_moves = layers.Dense(len(MOVES_MAP), activation="sigmoid", name="eval")(pos_analyzed) 116 | 117 | model = models.Model(inputs=[position], outputs=[out_moves]) 118 | model.compile(optimizer=optimizer, 119 | loss="mse", 120 | metrics=["accuracy"]) 121 | return model 122 | 123 | def __nn_simple(self, layer): 124 | activ = "relu" # linear relu elu sigmoid tanh softmax 125 | layer = layers.Flatten()(layer) 126 | layer = layers.Dense(len(MOVES_MAP) * 2, activation=activ, kernel_regularizer=reg)(layer) 127 | layer = layers.Dense(len(MOVES_MAP), activation=activ, kernel_regularizer=reg)(layer) 128 | return layer 129 | 130 | def __nn_residual(self, position): 131 | def relu_bn(inputs: Tensor) -> Tensor: 132 | bn = layers.BatchNormalization()(inputs) 133 | relu = layers.ReLU()(bn) 134 | return relu 135 | 136 | activ = "relu" # linear relu elu sigmoid tanh softmax 137 | 138 | def residual_block(x: Tensor, filters_out: int, filters: int, kernel_size: int) -> Tensor: 139 | # x = layers.Flatten()(x) 140 | 141 | y = x 142 | y = layers.Conv2D(kernel_size=(kernel_size, kernel_size), filters=filters, padding="same")(y) 143 | # y = relu_bn(y) 144 | # y = layers.Conv2D(kernel_size=(kernel_size, kernel_size), filters=filters, activation=activ)(y) 145 | 146 | # if downsample: 147 | # x = layers.Conv2D(kernel_size=kernel_size, filters=filters, activation=activ)(x) 148 | 149 | # y = layers.Dense(x.shape[1], activation=activ, kernel_regularizer=reg)(y) 150 | # y = relu_bn(y) 151 | # y = layers.Dense(x.shape[1], activation=activ, kernel_regularizer=reg)(y) 152 | 153 | y = layers.Add()([y, x]) 154 | y = relu_bn(y) 155 | 156 | y = layers.Conv2D(kernel_size=(kernel_size, kernel_size), filters=filters_out, padding="same")(y) 157 | y = relu_bn(y) 158 | 159 | return y 160 | 161 | t = position 162 | params = [ 163 | (12, 3, 16), 164 | (16, 4, 20), 165 | (20, 5, 24), 166 | (24, 6, 28), 167 | (28, 7, 32), 168 | # (32, 8, 36), 169 | ] 170 | for param in params: 171 | num_filters, ksize, downsample, = param 172 | t = residual_block(t, filters_out=downsample, filters=num_filters, kernel_size=ksize) 173 | 174 | # t = layers.AveragePooling2D(4)(t) 175 | t = layers.Flatten()(t) 176 | 177 | return t 178 | 179 | def __nn_conv(self, position): 180 | activ = "relu" 181 | conv31 = layers.Conv2D(8, kernel_size=(3, 3), activation=activ, kernel_regularizer=reg)(position) 182 | conv32 = layers.Conv2D(16, kernel_size=(3, 3), activation=activ, kernel_regularizer=reg)(conv31) 183 | conv33 = layers.Conv2D(32, kernel_size=(3, 3), activation=activ, kernel_regularizer=reg)(conv32) 184 | flat3 = layers.Flatten()(conv33) 185 | 186 | conv41 = layers.Conv2D(8, kernel_size=(4, 4), activation=activ, kernel_regularizer=reg)(position) 187 | conv42 = layers.Conv2D(16, kernel_size=(4, 4), activation=activ, kernel_regularizer=reg)(conv41) 188 | flat4 = layers.Flatten()(conv42) 189 | 190 | conv51 = layers.Conv2D(8, kernel_size=(5, 5), activation=activ, kernel_regularizer=reg)(position) 191 | conv52 = layers.Conv2D(16, kernel_size=(3, 3), activation=activ, kernel_regularizer=reg)(conv51) 192 | flat5 = layers.Flatten()(conv52) 193 | 194 | conv61 = layers.Conv2D(8, kernel_size=(6, 6), activation=activ, kernel_regularizer=reg)(position) 195 | # conv62 = layers.Conv2D(16, kernel_size=(3, 3), activation=activ, kernel_regularizer=reg)(conv61) 196 | flat6 = layers.Flatten()(conv61) 197 | 198 | conv71 = layers.Conv2D(8, kernel_size=(7, 7), activation=activ, kernel_regularizer=reg)(position) 199 | # conv72 = layers.Conv2D(16, kernel_size=(3, 3), activation=activ, kernel_regularizer=reg)(conv71) 200 | flat7 = layers.Flatten()(conv71) 201 | 202 | conv81 = layers.Conv2D(8, kernel_size=(8, 8), activation=activ, kernel_regularizer=reg)(position) 203 | # conv72 = layers.Conv2D(16, kernel_size=(3, 3), activation=activ, kernel_regularizer=reg)(conv71) 204 | flat8 = layers.Flatten()(conv81) 205 | 206 | conc = layers.concatenate([flat3, flat4, flat5, flat6, flat7, flat8]) 207 | return conc 208 | 209 | def _data_to_training_set(self, data, is_inference=False): 210 | batch_len = len(data) 211 | 212 | inputs_pos = np.full((batch_len, 8, 8, len(PIECE_TYPES) * 2), 0.0) 213 | out_evals = np.full((batch_len, len(MOVES_MAP)), 0.0) 214 | 215 | batch_n = 0 216 | for moverec in data: 217 | assert isinstance(moverec, MoveRecord) 218 | 219 | inputs_pos[batch_n] = moverec.position 220 | 221 | move = (moverec.from_square, moverec.to_square) 222 | if move != (0, 0): 223 | out_evals[batch_n][MOVES_MAP.index(move)] = moverec.eval 224 | 225 | batch_n += 1 226 | 227 | return [inputs_pos], [out_evals] 228 | 229 | def _moves_iter(self, scores): 230 | for idx, score in sorted(np.ndenumerate(scores), key=itemgetter(1), reverse=True): 231 | idx = idx[0] 232 | move = chess.Move(MOVES_MAP[idx][0], MOVES_MAP[idx][1]) 233 | yield move 234 | -------------------------------------------------------------------------------- /chessnn/player.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import random 3 | from abc import abstractmethod 4 | from typing import List 5 | 6 | import chess 7 | import numpy 8 | import numpy as np 9 | from chess.engine import SimpleEngine, INFO_SCORE 10 | 11 | from chessnn import MoveRecord, BoardOptim, nn, is_debug, MOVES_MAP, SYZYGY 12 | 13 | 14 | class PlayerBase(object): 15 | moves_log: List[MoveRecord] 16 | board: BoardOptim 17 | 18 | def __init__(self, name, color) -> None: 19 | super().__init__() 20 | self.name = name 21 | self.color = color 22 | # noinspection PyTypeChecker 23 | self.board = None 24 | self.moves_log = [] 25 | 26 | def get_moves(self, in_round): 27 | """ 28 | :param in_round: Round to record 29 | :return: log of data records, all flipped for white side 30 | """ 31 | res = [] 32 | for x in self.moves_log: 33 | x.in_round = in_round 34 | res.append(x) 35 | self.moves_log.clear() 36 | 37 | return res 38 | 39 | def makes_move(self): 40 | move, geval = self._choose_best_move() 41 | moverec = self._get_moverec(move, geval) 42 | self._log_move(moverec) 43 | self.board.push(move) 44 | if is_debug(): 45 | logging.debug("%d. %r %.2f\n%s", self.board.fullmove_number, move.uci(), geval, self.board.unicode()) 46 | not_over = move != chess.Move.null() and not self.board.is_game_over(claim_draw=False) 47 | 48 | if len(self.board.piece_map()) <= 5: 49 | known = SYZYGY.get_wdl(self.board) 50 | not_over = False 51 | if known is not None: 52 | logging.debug("SyzygyDB: %s", known) 53 | if known > 0: 54 | self.board.forced_result = chess.Outcome(chess.Termination.VARIANT_WIN, self.board.turn) 55 | elif known < 0: 56 | self.board.forced_result = chess.Outcome(chess.Termination.VARIANT_LOSS, self.board.turn) 57 | else: 58 | self.board.forced_result = chess.Outcome(chess.Termination.VARIANT_DRAW, self.board.turn) 59 | 60 | return not_over 61 | 62 | def _maps_for_plot(self, maps_predicted, moverec): 63 | maps_predicted = (maps_predicted[1], maps_predicted[2]) \ 64 | + self._decode_possible(maps_predicted[0]) + self._decode_possible(maps_predicted[3]) 65 | mm = np.full(len(MOVES_MAP), 0.0) 66 | mm[moverec.get_move_num()] = 1.0 67 | maps_actual = (moverec.attacked, moverec.defended) \ 68 | + self._decode_possible(moverec.possible) + self._decode_possible(mm) 69 | maps_actual += self._decode_possible(maps_predicted[3]) 70 | return maps_actual, maps_predicted 71 | 72 | def _get_moverec(self, move, geval): 73 | bflip: BoardOptim = self.board if self.color == chess.WHITE else self.board.mirror() 74 | pos = bflip.get_position() 75 | moveflip = move if self.color == chess.WHITE else self._mirror_move(move) 76 | piece = self.board.piece_at(move.from_square) 77 | piece_type = piece.piece_type if piece else None 78 | moverec = MoveRecord(pos, moveflip, piece_type, self.board.fullmove_number, self.board.halfmove_clock) 79 | moverec.eval = geval 80 | 81 | # moverec.attacked, moverec.defended = bflip.get_attacked_defended() 82 | # moverec.possible = bflip.get_possible_moves() 83 | 84 | return moverec 85 | 86 | def _flip64(self, array): 87 | a64 = np.reshape(array, (8, 8)) 88 | a64flip = np.fliplr(a64) 89 | res = np.reshape(a64flip, (64,)) 90 | return res 91 | 92 | def _log_move(self, moverec): 93 | if moverec.from_square != moverec.to_square: 94 | self.moves_log.append(moverec) 95 | self.board.comment_stack.append(moverec) 96 | else: 97 | logging.debug("Strange move: %s", moverec.get_move()) 98 | 99 | def _mirror_move(self, move): 100 | """ 101 | :type move: chess.Move 102 | """ 103 | 104 | def flip(pos): 105 | arr = np.full((64,), False) 106 | arr[pos] = True 107 | arr = np.reshape(arr, (-1, 8)) 108 | arr = np.flipud(arr) 109 | arr = arr.flatten() 110 | res = arr.argmax() 111 | return int(res) 112 | 113 | new_move = chess.Move(flip(move.from_square), flip(move.to_square), move.promotion, move.drop) 114 | return new_move 115 | 116 | @abstractmethod 117 | def _choose_best_move(self): 118 | pass 119 | 120 | def _decode_possible(self, possible): 121 | ffrom = np.full(64, 0.0) 122 | tto = np.full(64, 0.0) 123 | for idx, score in np.ndenumerate(possible): 124 | f, t = MOVES_MAP[idx[0]] 125 | ffrom[f] = max(ffrom[f], score) 126 | tto[t] = max(tto[t], score) 127 | 128 | return ffrom, tto 129 | 130 | 131 | class NNPLayer(PlayerBase): 132 | nn: nn.NN 133 | 134 | def __init__(self, name, color, net) -> None: 135 | super().__init__(name, color) 136 | self.nn = net 137 | self.legal_cnt = 0 138 | 139 | def _choose_best_move(self): 140 | if self.color == chess.WHITE: 141 | board = self.board 142 | else: 143 | board = self.board.mirror() 144 | 145 | pos = board.get_position() 146 | 147 | moverec = MoveRecord(pos, chess.Move.null(), None, board.fullmove_number, board.halfmove_clock) 148 | mmap = self.nn.inference([moverec]) 149 | 150 | first_legal = 1 151 | while numpy.sum(mmap) > 0: 152 | maxval = numpy.argmax(mmap) 153 | moverec.from_square, moverec.to_square = MOVES_MAP[maxval] 154 | moverec.eval = mmap[maxval] 155 | if board.is_legal(moverec.get_move()): 156 | break 157 | 158 | first_legal = 0 159 | mmap[maxval] = 0 160 | else: 161 | logging.warning("Did not find good move") 162 | legal = list(board.generate_legal_moves()) 163 | move = random.choice(legal) 164 | moverec.from_square, moverec.to_square = move.from_square, move.to_square 165 | moverec.eval = 0.5 166 | 167 | self.legal_cnt += first_legal 168 | 169 | if moverec.eval == 0: 170 | logging.warning("Zero eval move chosen: %s", moverec.get_move()) 171 | 172 | move = moverec.get_move() 173 | if self.color == chess.BLACK: 174 | move = self._mirror_move(move) 175 | 176 | return move, moverec.eval 177 | 178 | 179 | class Stockfish(PlayerBase): 180 | def __init__(self, color) -> None: 181 | super().__init__("Stockfish", color) 182 | self.engine = SimpleEngine.popen_uci("stockfish") 183 | 184 | def _choose_best_move(self): 185 | result = self.engine.play(self.board, chess.engine.Limit(time=0.0100), info=INFO_SCORE) 186 | logging.debug("SF move: %s, %s, %s", result.move, result.draw_offered, result.info) 187 | 188 | if result.info['score'].is_mate(): 189 | forced_eval = 1 190 | elif not result.info['score'].relative.cp: 191 | forced_eval = 0 192 | else: 193 | forced_eval = -1 / abs(result.info['score'].relative.cp) + 1 194 | 195 | return result.move, forced_eval 196 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/undera/chess-engine-nn/8e85e9def44a0b5e3b8e6e82772af2826c961c4d/demo.gif -------------------------------------------------------------------------------- /diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/undera/chess-engine-nn/8e85e9def44a0b5e3b8e6e82772af2826c961c4d/diagram.png -------------------------------------------------------------------------------- /model.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/undera/chess-engine-nn/8e85e9def44a0b5e3b8e6e82772af2826c961c4d/model.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PILLOW 2 | genson 3 | jsonschema 4 | requests 5 | tensorflow 6 | chess 7 | matplotlib 8 | numpy 9 | chess 10 | -------------------------------------------------------------------------------- /training.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import pickle 4 | import random 5 | import sys 6 | from typing import List 7 | 8 | import tensorflow 9 | from chess import WHITE, BLACK, Move 10 | 11 | from chessnn import BoardOptim, is_debug, MoveRecord 12 | from chessnn.nn import NNChess 13 | from chessnn.player import NNPLayer, Stockfish 14 | 15 | 16 | def play_one_game(pwhite, pblack, rnd): 17 | """ 18 | 19 | :type pwhite: NNPLayer 20 | :type pblack: NNPLayer 21 | :type rnd: int 22 | """ 23 | board: BoardOptim = BoardOptim.from_chess960_pos(random.randint(0, 959)) 24 | pwhite.board = board 25 | pblack.board = board 26 | 27 | try: 28 | while True: # and board.fullmove_number < 150 29 | if not pwhite.makes_move(): 30 | break 31 | 32 | if not pblack.makes_move(): 33 | break 34 | 35 | if is_debug(): 36 | board.write_pgn(pwhite, pblack, os.path.join(os.path.dirname(__file__), "last.pgn"), rnd) 37 | except BaseException: 38 | last = board.move_stack[-1] if board.move_stack else Move.null() 39 | logging.warning("Final move: %s %s %s", last, last.from_square, last.to_square) 40 | logging.warning("Final position:\n%s", board.unicode()) 41 | raise 42 | finally: 43 | if board.move_stack: 44 | board.write_pgn(pwhite, pblack, os.path.join(os.path.dirname(__file__), "last.pgn"), rnd) 45 | 46 | result = board.result(claim_draw=True) 47 | 48 | avg_invalid = 0 49 | if isinstance(pwhite, NNPLayer): 50 | avg_invalid = pwhite.legal_cnt / board.fullmove_number 51 | pwhite.legal_cnt = 0 52 | 53 | logging.info("Game #%d/%d:\t%s by %s,\t%d moves, legal: %.2f", rnd, rnd % 960, result, board.explain(), 54 | board.fullmove_number, avg_invalid) 55 | 56 | return result 57 | 58 | 59 | class DataSet(object): 60 | dataset: List[MoveRecord] 61 | 62 | def __init__(self, fname) -> None: 63 | super().__init__() 64 | self.fname = fname 65 | self.dataset = [] 66 | 67 | def dump_moves(self): 68 | if os.path.exists(self.fname): 69 | os.rename(self.fname, self.fname + ".bak") 70 | try: 71 | logging.info("Saving dataset: %s", self.fname) 72 | with open(self.fname, "wb") as fhd: 73 | pickle.dump(self.dataset, fhd) 74 | except: 75 | os.rename(self.fname + ".bak", self.fname) 76 | 77 | def load_moves(self): 78 | if os.path.exists(self.fname): 79 | with open(self.fname, 'rb') as fhd: 80 | loaded = pickle.load(fhd) 81 | self.dataset.extend(loaded) 82 | 83 | logging.info("Loaded from %s: %s", self.fname, len(self.dataset)) 84 | 85 | def update(self, moves): 86 | lprev = len(self.dataset) 87 | for move in moves: 88 | if move.ignore: 89 | move.forced_eval = 0 90 | 91 | self.dataset.extend(moves) 92 | if len(self.dataset) - lprev < len(moves): 93 | logging.debug("partial increase") 94 | elif len(self.dataset) - lprev == len(moves): 95 | logging.debug("full increase") 96 | else: 97 | logging.debug("no increase") 98 | 99 | while len(self.dataset) > 100000: 100 | mmin = min(x.from_round for x in self.dataset) 101 | logging.info("Removing things older than %s", mmin) 102 | self.dataset = [x for x in self.dataset if x.from_round > mmin] 103 | 104 | 105 | def set_to_file(draw, param): 106 | lines = ["%s\n" % item for item in draw] 107 | lines.sort() 108 | with open(param, "w") as fhd: 109 | fhd.writelines(lines) 110 | 111 | 112 | def play_with_score(pwhite, pblack): 113 | results = DataSet("results.pkl") 114 | results.load_moves() 115 | 116 | if results.dataset: 117 | pass 118 | # nn.train(results.dataset, 10) 119 | # nn.save() 120 | # return 121 | 122 | rnd = max([x.from_round for x in results.dataset]) if results.dataset else 0 123 | try: 124 | while True: 125 | if not ((rnd + 1) % 96) and len(results.dataset): 126 | # results.dump_moves() 127 | nn.train(results.dataset, 1) 128 | nn.save() 129 | pass 130 | 131 | if _iteration(pblack, pwhite, results, rnd) != 0: 132 | # results.dump_moves() 133 | # nn.save() 134 | pass 135 | 136 | rnd += 1 137 | finally: 138 | results.dump_moves() 139 | nn.save() 140 | 141 | 142 | def _iteration(pblack, pwhite, results, rnd) -> int: 143 | result = play_one_game(pwhite, pblack, rnd) 144 | wmoves = pwhite.get_moves(rnd) 145 | bmoves = pblack.get_moves(rnd) 146 | 147 | if result == '1-0': 148 | for x, move in enumerate(wmoves): 149 | move.eval = 1 # 0.5 + 0.5 * x / len(wmoves) 150 | move.from_round = rnd 151 | for x, move in enumerate(bmoves): 152 | move.eval = 0 # 0.5 - 0.5 * x / len(bmoves) 153 | move.from_round = rnd 154 | 155 | results.update(wmoves) 156 | nn.train(wmoves+bmoves, 1) 157 | # results.update(bmoves) 158 | 159 | return 1 160 | elif result == '0-1': 161 | for x, move in enumerate(wmoves): 162 | move.eval = 0.5 - 0.5 * x / len(wmoves) 163 | move.from_round = rnd 164 | for x, move in enumerate(bmoves): 165 | move.eval = 1 # 0.5 + 0.5 * x / len(bmoves) 166 | move.from_round = rnd 167 | 168 | # results.update(wmoves) 169 | results.update(bmoves) 170 | nn.train(wmoves+bmoves, 1) 171 | return -1 172 | else: 173 | for x, move in enumerate(wmoves): 174 | move.eval = 0.25 + 0.25 * x / len(wmoves) 175 | move.from_round = rnd 176 | for x, move in enumerate(bmoves): 177 | move.eval = 0.25 + 0.25 * x / len(bmoves) 178 | move.from_round = rnd 179 | 180 | #nn.train(wmoves + bmoves, 1) # shake it a bit 181 | return 0 182 | 183 | 184 | if __name__ == "__main__": 185 | # sys.setrecursionlimit(10000) 186 | _LOG_FORMAT = '[%(relativeCreated)d %(name)s %(levelname)s] %(message)s' 187 | logging.basicConfig(level=logging.DEBUG if is_debug() else logging.INFO, format=_LOG_FORMAT) 188 | devices = tensorflow.config.list_physical_devices('GPU') 189 | logging.info("GPU: %s", devices) 190 | # assert devices 191 | 192 | nn = NNChess(os.path.join(os.path.dirname(__file__), "models")) 193 | white = NNPLayer("Lisa", WHITE, nn) 194 | # white = Stockfish(BLACK) 195 | black = NNPLayer("Karen", BLACK, nn) 196 | black = Stockfish(BLACK) 197 | 198 | try: 199 | play_with_score(white, black) 200 | finally: 201 | if isinstance(black, Stockfish): 202 | black.engine.quit() 203 | -------------------------------------------------------------------------------- /uci.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import random 3 | 4 | import chess 5 | from chess import WHITE 6 | from chess.engine import SimpleEngine 7 | 8 | from chessnn import BoardOptim, is_debug 9 | from chessnn.nn import NNChess 10 | from chessnn.player import NNPLayer 11 | 12 | if __name__ == "__main__": 13 | logging.basicConfig(level=logging.DEBUG if is_debug() else logging.INFO) 14 | 15 | engine = SimpleEngine.popen_uci("stockfish") 16 | 17 | try: 18 | board = BoardOptim.from_chess960_pos(random.randint(0, 959)) 19 | nn = NNChess("nn.hdf5") 20 | white = NNPLayer("Lisa", WHITE, nn) 21 | white.board = board 22 | 23 | while not board.is_game_over(): 24 | if not white.makes_move(): 25 | break 26 | 27 | result = engine.play(board, chess.engine.Limit(time=0.100)) 28 | board.push(result.move) 29 | 30 | logging.info("Result: %s", board.result()) 31 | finally: 32 | engine.quit() 33 | -------------------------------------------------------------------------------- /versus.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Versus Chess NN 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 28 | 29 | 30 |
31 | 124 | 125 | -------------------------------------------------------------------------------- /view.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | View Chess NN 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 36 | 37 | --------------------------------------------------------------------------------