%load_ext autoreload
%autoreload 2
The python-chess example shows us how to get stockfish to score a given position.
engine = cst.makeEngine()()
board = chess.Board("r1bqkbnr/p1pp1ppp/1pn5/4p3/2B1P3/5Q2/PPPP1PPP/RNB1K1NR w KQkq - 2 4")
info = engine.analyse(board, chess.engine.Limit(depth=20))
engine.quit()
print('engine score:', info['score'])
display(board)
Let's process the first game from the Candidates tournament 1953.
games = list(cda.games(cda.fetch()['Candidates_1953.pgn']))
games[0].headers, games[0].moves
I still need to figure out how to safely do RAII with Python, hence the clumsy engine_maker
stuff. Python's with ... as ...:
looks promising.
proc = Processor(cst.makeEngine(), games[0])
data = proc.run(cda.diffReduce, cco.attack, 4)
We now have a dataframe of positions, each analyzed by stockfish. It's probably pointless to try to teach Pandas to use proper types for its columns. Just keep in mind that the string representation as shown by pd.DataFrame.head()
lies to you and that additional marshalling might be needed to work with the data.
data.head(10)
HalfMoveConfiguration tells us which half move was evaluated. Since we are interested in views for each side, the half moves are adjusted for each side, too. The triple (3, 2, 3)
tells us that the row we look at is the result after half move 3 got executed. Since we are zero-started and white was to move first, this is a black move (see SideThatMoved
). We see that the last element of the triple is 3, the same as our half move. The second element is still at 2, however, showing that the positional view for white in this row was derived from half move 2. This is also what the Last{White,Black}Move
columns try to tell us. We can reconstruct the board using the FEN column like so:
chess.Board(data.loc[3, 'FEN'])
It's important to read PovScore
(point-of-view score) in combination with SideToMove
– that's just how chess engines work. The ability to move in a given position is highly valuable to a chess engine unless, of course, you are under Zugzwang.
data.loc[3, ['SideToMove', 'PovScore']]
Notice that as a result of our HalfMoveConfiguration
semantics, the ViewFor{White,Black}
columns only change with every other move. This is intentional. Our move echo filters positions for each side so that a move echo of 4 considers the last 4 postions after a side moved, going back a total of 8 half moves in the move sequence. Without position filtering, move echo becomes a "noisy" metric. That doesn't necessarily mean it's worse but the extra "noise" can be confusing to a human. Comparing the two versions (and deciding which is easier to "understand" for a machine) is future work.
cco.show(data.loc[3, 'ViewForBlack']), cco.show(data.loc[4, 'ViewForBlack']);
Obviously, them being Numpy arrays, we can trivially combine those arrays.
cco.show(data.loc[3, 'ViewForWhite'] - data.loc[3, 'ViewForBlack']);
We can filter the dataframe by side.
data[data['SideThatMoved'] == 'White'].head(5)
The Zeros
column indicates how well the board is covered by pieces. If the attack filter is used then higher values roughly mean that the pieces on the board control fewer squares. The maximum of Zeros
would equal all squares on an empty chess board, 64. Our move echo – in combination with the reduce filter – can massively influence that metric (ZerosWithEcho
).
data['Zeros'].plot(style='.-', figsize=(8, 5))
data['ZerosWithEcho'].plot(style='.-');
data['Zeros'].rolling(4).median().plot(figsize=(8, 5))
data['ZerosWithEcho'].rolling(4).median().plot();
Let's plot the centipawn score. Here we plot it separately for each side.
data[data['SideToMove'] == 'White']['Score'].plot(style='.-', figsize=(8, 5))
data[data['SideToMove'] == 'Black']['Score'].plot(style='.-');
We can also see how strongly the score changes between positions.
pd.Series(data[data['SideToMove'] == 'White']['Score'].to_numpy()
- data[data['SideToMove'] == 'Black']['Score'].to_numpy()).plot(style='.-', figsize=(8, 5));
As visual comparison, the rolling/expanding window version:
data['PovScore'].expanding(2).agg(lambda x: x[x.index[-1]] + x[x.index[-2]]).plot(style='.-', figsize=(8, 5));
The main difference to the previous plot is that our xticks are half moves instead of full moves.
Here is an example of how you can read data from a row that you find interesting. The row index is the half move of the analyzed position.
row = data.loc[61]
display(chess.Board(row['FEN']), row['PovScore'], row['SideToMove'], cco.show(row['ViewForWhite']), cco.show(row['ViewForBlack']))