Single game analysis

import chess
import chess.pgn
import matplotlib.pyplot as plt
import pickle
import pandas as pd
import numpy as np
import io

In this notebook we take the PGN string of a game and we unpack all the information that we can extract from it, then put the data in a dataframe.

Load data

Load data of a user

# the name of the user we're looking at
username = 'rugitodoleao_returns'
# or load results
with open('../data/player_data/{}.pkl'.format(username), 'rb') as f:
    data_raw = pickle.load(f)

Load openings database

openings = pd.DataFrame()
for letter in ['a', 'b', 'c', 'd', 'e']:
    tmp = pd.read_csv('../data/chess-openings/dist/{}.tsv'.format(letter), sep='\t')
    openings = pd.concat((openings, tmp))

Basic postprocessing: extract a single PGN file

# look inside the list of results and see if there are no games or there was an error code
data = []
for d in data_raw:
    try:
        if len(d['games']) > 0:
            data.append(d['games'])
    except KeyError:
        # this will typically happen if you asked for something weird such as the list of
        # games in the future
        continue

# create master dataframe
df = pd.concat([pd.DataFrame(d).set_index('url') for d in data])
# data from the API
game_api = df.iloc[219]
pgn = game_api.pgn
print(pgn)
[Event "Live Chess"]
[Site "Chess.com"]
[Date "2022.08.19"]
[Round "-"]
[White "rugitodoleao_returns"]
[Black "capedia"]
[Result "0-1"]
[CurrentPosition "8/8/8/5pk1/8/P1R2P2/6PP/6K1 w - -"]
[Timezone "UTC"]
[ECO "C60"]
[ECOUrl "https://www.chess.com/openings/Ruy-Lopez-Opening-Nurnberg-Variation"]
[UTCDate "2022.08.19"]
[UTCTime "14:36:57"]
[WhiteElo "789"]
[BlackElo "801"]
[TimeControl "60"]
[Termination "capedia won on time"]
[StartTime "14:36:57"]
[EndDate "2022.08.19"]
[EndTime "14:39:14"]
[Link "https://www.chess.com/game/live/54658652899"]

1. e4 {[%clk 0:01:00]} 1... e5 {[%clk 0:01:00]} 2. Nf3 {[%clk 0:00:58.6]} 2... Nc6 {[%clk 0:00:59.2]} 3. Bb5 {[%clk 0:00:56.9]} 3... f6 {[%clk 0:00:58.7]} 4. Nc3 {[%clk 0:00:55.2]} 4... Bc5 {[%clk 0:00:56.7]} 5. d4 {[%clk 0:00:53.9]} 5... exd4 {[%clk 0:00:55.5]} 6. Bxc6 {[%clk 0:00:51.5]} 6... bxc6 {[%clk 0:00:49.4]} 7. Nxd4 {[%clk 0:00:50.5]} 7... Bxd4 {[%clk 0:00:48.2]} 8. Qxd4 {[%clk 0:00:49.8]} 8... Ne7 {[%clk 0:00:46.7]} 9. O-O {[%clk 0:00:48.3]} 9... d5 {[%clk 0:00:44.5]} 10. exd5 {[%clk 0:00:45.9]} 10... cxd5 {[%clk 0:00:44.4]} 11. Re1 {[%clk 0:00:43.7]} 11... c6 {[%clk 0:00:40.6]} 12. Bf4 {[%clk 0:00:40.7]} 12... O-O {[%clk 0:00:38.2]} 13. a3 {[%clk 0:00:36.4]} 13... Bf5 {[%clk 0:00:33.4]} 14. Rac1 {[%clk 0:00:32.5]} 14... a5 {[%clk 0:00:27.4]} 15. Qc5 {[%clk 0:00:31.4]} 15... Re8 {[%clk 0:00:23.8]} 16. Bd6 {[%clk 0:00:29.4]} 16... Ng6 {[%clk 0:00:14.1]} 17. Rxe8+ {[%clk 0:00:23.5]} 17... Qxe8 {[%clk 0:00:12.8]} 18. Qxc6 {[%clk 0:00:18.2]} 18... Ne5 {[%clk 0:00:11.7]} 19. Qxe8+ {[%clk 0:00:17.1]} 19... Rxe8 {[%clk 0:00:10]} 20. Nxd5 {[%clk 0:00:14.8]} 20... Be4 {[%clk 0:00:08.3]} 21. Ne7+ {[%clk 0:00:12.4]} 21... Rxe7 {[%clk 0:00:07.4]} 22. Bxe7 {[%clk 0:00:11.4]} 22... Kf7 {[%clk 0:00:06.5]} 23. Bb4 {[%clk 0:00:09.9]} 23... Bxc2 {[%clk 0:00:05.2]} 24. Rxc2 {[%clk 0:00:08.5]} 24... Nd3 {[%clk 0:00:05.1]} 25. Rc7+ {[%clk 0:00:07]} 25... Kg6 {[%clk 0:00:03.4]} 26. Bxa5 {[%clk 0:00:06.9]} 26... Nxb2 {[%clk 0:00:03]} 27. Bc3 {[%clk 0:00:05.9]} 27... h6 {[%clk 0:00:02.8]} 28. Bxb2 {[%clk 0:00:05.1]} 28... f5 {[%clk 0:00:02.4]} 29. Bxg7 {[%clk 0:00:03.9]} 29... Kg5 {[%clk 0:00:02.3]} 30. Bxh6+ {[%clk 0:00:03.1]} 30... Kxh6 {[%clk 0:00:01.5]} 31. Rc6+ {[%clk 0:00:01.9]} 31... Kg5 {[%clk 0:00:01.4]} 32. Rc5 {[%clk 0:00:01.3]} 32... Kg4 {[%clk 0:00:01.2]} 33. Rc4+ {[%clk 0:00:00.8]} 33... Kg5 {[%clk 0:00:01]} 34. Rc3 {[%clk 0:00:00.5]} 34... Kg4 {[%clk 0:00:00.6]} 35. f3+ {[%clk 0:00:00.4]} 35... Kg5 {[%clk 0:00:00.5]} 0-1

Game preprocessing

def get_usercolor(game, username):
    
    # figure out the color of the user
    for color in ['white', 'black']:
        
        if game.headers.get(color.capitalize()) == username:
            return color
# we can use this to extract all the information we need
game = chess.pgn.read_game(io.StringIO(pgn))

# get user color
usercolor = get_usercolor(game, username)

# init game board
board = game.board()

# iterate through the moves
game_data = []
for i, move in enumerate(game.mainline_moves()):
    
    # get move number
    move_number = i//2 + 1
    
    # black or white
    if i%2 == 0:
        color =  'white'
    else:
        color = 'black'
    
    # get start square and end square and determine the piece that was moved
    start_square, end_square = move.uci()[:2], move.uci()[2:]
    piece = board.piece_at(chess.parse_square(start_square)).symbol().upper()
    
    # get the move in SAN notation
    san = board.san(move)
    
    # get move in UCI notation
    uci = board.uci(move)
    
    # push the move to the board
    board.push(move)
    
    # put together all the data that we have
    this_move_data = (i, move_number, color, piece, uci, san, board.epd(), board.fen())
    
    # append the data to this move
    game_data.append(this_move_data)
    
game_data = pd.DataFrame(game_data, columns=['i', 'move_number', 'color', 'piece', 'uci', 'san', 'epd', 'fen'])

# init the classification column
game_data['classification'] = 'unknown'

Move classification

Book moves

# the end of the book openings is defined as the last board position where we see that there
# is a correspondance to a board position from the database
last_book_move = game_data.merge(openings, on='epd').tail(1).iloc[0]
# set the book move classification info
game_data.loc[game_data.i <= last_book_move.i, 'classification'] = 'book'

Engine evaluation of the moves

To proceed in the classification of the moves, the first thing we do is we ask the engine to evaluate the positions.

# init the engine
engine = chess.engine.SimpleEngine.popen_uci("/home/rcortini/soft/stockfish_15.1_linux_x64/src/stockfish")
def get_info(
    fen,
    engine,
    depth = 10):
    
    # set a chess.Board with the supplied FEN string
    b = chess.Board(fen=fen)
    
    # return the game analysis info
    info = engine.analyse(b, chess.engine.Limit(depth=depth))
    return info
%%time
# depth of the engine
depth = 10

# calculate score: remember, this means that we are 
# evaluating the score of the board AFTER the move.
# For the classification of the move, we need to therefore
# compare the score BEFORE the current move. For the
# first move there is no need to compute the score at
# the beginning, because all the first moves are book moves.
game_data['info'] = game_data.fen.apply(lambda fen : get_info(fen, engine, depth=depth))
CPU times: user 197 ms, sys: 13.7 ms, total: 210 ms
Wall time: 1.47 s

Best moves

We can now determine whether the moves of the user were the best moves or not. The info column of the dataframe contain the information on what was the best move in the position. To explain how we’re going to do that, let’s look first at the game_data dataframe:

game_data['info'].iloc[0]
{'string': 'NNUE evaluation using nn-ad9b42354671.nnue enabled',
 'depth': 10,
 'seldepth': 13,
 'multipv': 1,
 'score': PovScore(Cp(-35), BLACK),
 'nodes': 39060,
 'nps': 406875,
 'hashfull': 12,
 'tbhits': 0,
 'time': 0.096,
 'pv': [Move.from_uci('c7c5'),
  Move.from_uci('c2c3'),
  Move.from_uci('g8f6'),
  Move.from_uci('e4e5'),
  Move.from_uci('f6d5'),
  Move.from_uci('g1f3'),
  Move.from_uci('e7e6')]}
game_data.head()
i move_number color piece uci san epd fen classification info
0 0 1 white P e2e4 e4 rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR ... rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR ... unknown {'string': 'NNUE evaluation using nn-ad9b42354...
1 1 1 black P e7e5 e5 rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBN... rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBN... unknown {'string': 'NNUE evaluation using nn-ad9b42354...
2 2 2 white N g1f3 Nf3 rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQK... rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQK... unknown {'string': 'NNUE evaluation using nn-ad9b42354...
3 3 2 black N b8c6 Nc6 r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNB... r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNB... unknown {'string': 'NNUE evaluation using nn-ad9b42354...
4 4 3 white B f1b5 Bb5 r1bqkbnr/pppp1ppp/2n5/1B2p3/4P3/5N2/PPPP1PPP/R... r1bqkbnr/pppp1ppp/2n5/1B2p3/4P3/5N2/PPPP1PPP/R... unknown {'string': 'NNUE evaluation using nn-ad9b42354...

The columns of the game_data dataframe are the following represent:

  • i: the computer-friendly move number (i.e. the number of half-moves minus one - because it starts at zero.)
  • move_number: the move number in chess notation
  • piece: the symbol of the piece that was moved
  • color: the color of the user that made the move
  • san: the move made, in SAN notation
  • epd and fen: the EPD and FEN strings of the position after the move
  • info: the evaluation of the board after the move

If we want to evaluate the move in question, we must look at the line before the current one. In fact, the info of the line before contains the best moves of the next turn, which is the turn of the current line.

game_data
i move_number color piece uci san epd fen classification
0 0 1 white P e2e4 e4 rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR ... rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR ... unknown
1 1 1 black P e7e5 e5 rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBN... rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBN... unknown
2 2 2 white N g1f3 Nf3 rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQK... rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQK... unknown
3 3 2 black N b8c6 Nc6 r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNB... r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNB... unknown
4 4 3 white B f1b5 Bb5 r1bqkbnr/pppp1ppp/2n5/1B2p3/4P3/5N2/PPPP1PPP/R... r1bqkbnr/pppp1ppp/2n5/1B2p3/4P3/5N2/PPPP1PPP/R... unknown
... ... ... ... ... ... ... ... ... ...
65 65 33 black K g4g5 Kg5 8/8/8/5pk1/2R5/P7/5PPP/6K1 w - - 8/8/8/5pk1/2R5/P7/5PPP/6K1 w - - 6 34 unknown
66 66 34 white R c4c3 Rc3 8/8/8/5pk1/8/P1R5/5PPP/6K1 b - - 8/8/8/5pk1/8/P1R5/5PPP/6K1 b - - 7 34 unknown
67 67 34 black K g5g4 Kg4 8/8/8/5p2/6k1/P1R5/5PPP/6K1 w - - 8/8/8/5p2/6k1/P1R5/5PPP/6K1 w - - 8 35 unknown
68 68 35 white P f2f3 f3+ 8/8/8/5p2/6k1/P1R2P2/6PP/6K1 b - - 8/8/8/5p2/6k1/P1R2P2/6PP/6K1 b - - 0 35 unknown
69 69 35 black K g4g5 Kg5 8/8/8/5pk1/8/P1R2P2/6PP/6K1 w - - 8/8/8/5pk1/8/P1R2P2/6PP/6K1 w - - 1 36 unknown

70 rows × 9 columns

for row, move in game_data.iterrows():
    
    # first, we must skip the book moves
    if move.classification == 'book':
        continue
        
    # Compare the UCI of the move to the best move of the 
    # position. That info is stored in the `ìnfo` in the `pv`
    # list. The first element of that list is the best move.
    # The data structure returned is a chess.Move object, of
    # which we can access the `uci` function to get the move in
    # UCI notation
    if move.uci == game_data.iloc[row-1]['info']['pv'][0].uci():
        game_data.loc[game_data.i == move.i, 'classification'] = 'best'
        
game_data.classification.value_counts()
unknown    43
best       21
book        6
Name: classification, dtype: int64

To classify the other moves, we can use a combination of resources.

First, according to the official website, there is a move classification table, as follows:

classification_matrix_str = \
"""Classification   Lower Limit Upper Limit
Best    0.00    0.00
Excellent   0.00    0.02
Good    0.02    0.05
Inaccuracy  0.05    0.10
Mistake 0.10    0.20
Blunder 0.20    1.00"""
tmp = [r.split('\t') for r in classification_matrix_str.split('\n')]
classification_matrix = pd.DataFrame(tmp[1:], columns=tmp[0]).set_index('Classification').astype(float)*100
classification_matrix
Lower Limit Upper Limit
Classification
Best 0.0 0.0
Excellent 0.0 2.0
Good 2.0 5.0
Inaccuracy 5.0 10.0
Mistake 10.0 20.0
Blunder 20.0 100.0

This classification does not take into consideration special moves, classified as “brilliant”, “great move”, or “miss”. We won’t take those into consideration in a first round of analysis.

We therefore first need to calculate the win probability and accuracy of each move.

Win probability and accuracy

We use the idea from Lichess’s page on the accuracy metric.

First, we calculate the win_pct of the move: it represents the probability of winning the game, based on the value of the centipawns. This is represented by the following function:

\[ w_{\%} = 50 + 50\left(\frac{2}{1 + \exp{-\lambda C_p}} - 1\right) \] where \(\lambda = 0.00368208\).

The function looks like this:

def win_pct_f(centipawns):
    return 50 + 50 * (2 / (1 + np.exp(-0.00368208*centipawns)) - 1)
centipawns = np.arange(-2000, 2000)
win_pct = win_pct_f(centipawns)
plt.plot(centipawns, win_pct)
plt.xlabel("Centipawns", fontsize=18)
plt.ylabel("Win %", fontsize=18)
plt.axhline(y=50, linestyle='--', color='k', linewidth=0.75)
plt.show()

As you can see, the curve represents the probability of winning the game based on the value of the centipawns. In this sense one can notice that there is a large difference of losing 500 centipawns when you’re close to zero, than when you’re very much winning or losing.

def calculate_cp(
    score : chess.engine.PovScore,
):
    return score.relative.score(mate_score=5000)    

# first, we calculate the centipawn score for each of the moves.
# Remember: the info represents the board AFTER the move, so the centipawn
# value in this case will be relative to the color of the next turn
game_data['cp'] = game_data['info'].apply(lambda info : calculate_cp(info['score']))

Then we can evaluate the accuracy by calculating the following function:

\[ a = K_1 \exp{-\gamma (w_{b} - w_{a})} - K_2 \] where \(K_1 = 103.1668\), \(K_2 = 3.1669\), and \(\gamma = 0.04354\). The symbols represent:

  • \(a\) is the accuracy
  • \(w_a\) is the win percentage after the move
  • \(w_b\) is the win percentage before the move.
def accuracy_f(
    winPercentBefore,
    winPercentAfter
):  
    # calculate the accuracy function
    accuracy = 103.1668 * np.exp(-0.04354 * (winPercentBefore - winPercentAfter)) - 3.1669
    
    # first, check that the values make sense
    if accuracy > 100:
        return 100.
    if accuracy < 0.:
        return 0.
    
    # then return the value now bounded between [0, 100]
    return accuracy

The accuracy of the move then is calculated for each of the moves. We must pay a lot of attention with turns and signs: at every move, the info of the move represents the score after the move. Then, the score is represented from the point of view of the next player! Therefore, to calculate the win% after the move, we take that value and change sign to the centipawn evaluation. The win% before the move is represented by the win% value of the score corresponding to the line before the move.

Since we now calculate the win% before and after the move, we can also classify the moves.

accuracy = pd.Series(index=game_data.index, dtype=float, name='accuracy')
move_classification = pd.Series(index=game_data.index, dtype=str, name='classification_chess_lichess')
cp_loss = pd.Series(index=game_data.index, dtype=float, name='cp_loss')
for i in range(len(game_data)):
    
    # if it's the first move, assign a zero value to the centipawns
    if i == 0:
        cpBefore = 0.
    else:
        cpBefore = game_data.iloc[i-1].cp
        
    # change sign to the value corresponding to this move
    cpAfter = -game_data.iloc[i].cp
        
    # calculate accuracy: NOTE here that we don't just
    # blindly calculate the win% before and after the move, because
    # otherwise this would mean that the moves done when the game is
    # practically won would almost always count as best or excellent moves,
    # with close to 100% accuracy
    a = accuracy_f(50., win_pct_f(cpAfter-cpBefore))
    
    # calculate the delta
    d = win_pct_f(cpAfter)-win_pct_f(cpBefore)
    
    # establish the move classes
    if d >= 0.:
        mc = 'best'
    elif d < 0. and d > -2.0:
        mc = 'excellent'
    elif d < -2.0 and d > -5.0:
        mc = 'good'
    elif d < -5.0 and d > -10.0:
        mc = 'inaccuracy'
    elif d < -10.0 and d > -20.0:
        mc = 'mistake'
    else:
        mc = 'blunder'
        
    print("Move {}: cpBefore = {:.1f} cpAfter = {:.1f},  d = {:.2f}, a = {:.2f}, {}".format(\
          i, cpBefore, cpAfter, d, a, mc))
    
    accuracy[i] = a
    move_classification[i] = mc
    cp_loss[i] = cpAfter-cpBefore

# put in the info on the move classification
mask = (game_data.classification != 'book') & (game_data.classification != 'best')
game_data.loc[mask, 'classification'] = move_classification[mask]

# put the info on the accuracy
game_data['accuracy'] = accuracy
game_data['cp_loss'] = cp_loss
Move 0: cpBefore = 0.0 cpAfter = 40.0,  d = 3.68, a = 100.00, best
Move 1: cpBefore = -40.0 cpAfter = -30.0,  d = 0.92, a = 100.00, best
Move 2: cpBefore = 30.0 cpAfter = 24.0,  d = -0.55, a = 97.55, excellent
Move 3: cpBefore = -24.0 cpAfter = -25.0,  d = -0.09, a = 99.59, excellent
Move 4: cpBefore = 25.0 cpAfter = 33.0,  d = 0.73, a = 100.00, best
Move 5: cpBefore = -33.0 cpAfter = -84.0,  d = -4.64, a = 80.98, good
Move 6: cpBefore = 84.0 cpAfter = 67.0,  d = -1.53, a = 93.21, excellent
Move 7: cpBefore = -67.0 cpAfter = -60.0,  d = 0.64, a = 100.00, best
Move 8: cpBefore = 60.0 cpAfter = -40.0,  d = -9.18, a = 66.24, inaccuracy
Move 9: cpBefore = 40.0 cpAfter = -36.0,  d = -6.98, a = 73.06, inaccuracy
Move 10: cpBefore = 36.0 cpAfter = -86.0,  d = -11.16, a = 60.61, mistake
Move 11: cpBefore = 86.0 cpAfter = -64.0,  d = -13.72, a = 54.23, mistake
Move 12: cpBefore = 64.0 cpAfter = 60.0,  d = -0.36, a = 98.36, excellent
Move 13: cpBefore = -60.0 cpAfter = -123.0,  d = -5.63, a = 77.07, inaccuracy
Move 14: cpBefore = 123.0 cpAfter = 104.0,  d = -1.67, a = 92.44, excellent
Move 15: cpBefore = -104.0 cpAfter = -101.0,  d = 0.27, a = 100.00, best
Move 16: cpBefore = 101.0 cpAfter = 47.0,  d = -4.88, a = 79.98, good
Move 17: cpBefore = -47.0 cpAfter = -148.0,  d = -8.98, a = 65.97, inaccuracy
Move 18: cpBefore = 148.0 cpAfter = 90.0,  d = -5.09, a = 78.67, inaccuracy
Move 19: cpBefore = -90.0 cpAfter = -90.0,  d = 0.00, a = 100.00, best
Move 20: cpBefore = 90.0 cpAfter = 89.0,  d = -0.09, a = 99.59, excellent
Move 21: cpBefore = -89.0 cpAfter = -102.0,  d = -1.16, a = 94.76, excellent
Move 22: cpBefore = 102.0 cpAfter = 73.0,  d = -2.60, a = 88.69, good
Move 23: cpBefore = -73.0 cpAfter = -77.0,  d = -0.36, a = 98.36, excellent
Move 24: cpBefore = 77.0 cpAfter = 43.0,  d = -3.09, a = 86.87, good
Move 25: cpBefore = -43.0 cpAfter = -60.0,  d = -1.55, a = 93.21, excellent
Move 26: cpBefore = 60.0 cpAfter = 30.0,  d = -2.74, a = 88.32, good
Move 27: cpBefore = -30.0 cpAfter = -106.0,  d = -6.88, a = 73.06, inaccuracy
Move 28: cpBefore = 106.0 cpAfter = 120.0,  d = 1.23, a = 100.00, best
Move 29: cpBefore = -120.0 cpAfter = -112.0,  d = 0.70, a = 100.00, best
Move 30: cpBefore = 112.0 cpAfter = 115.0,  d = 0.26, a = 100.00, best
Move 31: cpBefore = -115.0 cpAfter = -261.0,  d = -11.90, a = 55.09, mistake
Move 32: cpBefore = 261.0 cpAfter = 19.0,  d = -20.58, a = 38.34, blunder
Move 33: cpBefore = -19.0 cpAfter = -10.0,  d = 0.83, a = 100.00, best
Move 34: cpBefore = 10.0 cpAfter = -700.0,  d = -43.86, a = 12.58, blunder
Move 35: cpBefore = 700.0 cpAfter = -293.0,  d = -67.57, a = 9.89, blunder
Move 36: cpBefore = 293.0 cpAfter = 291.0,  d = -0.14, a = 99.18, excellent
Move 37: cpBefore = -291.0 cpAfter = -260.0,  d = 2.23, a = 100.00, best
Move 38: cpBefore = 260.0 cpAfter = 268.0,  d = 0.59, a = 100.00, best
Move 39: cpBefore = -268.0 cpAfter = -288.0,  d = -1.43, a = 92.06, excellent
Move 40: cpBefore = 288.0 cpAfter = 205.0,  d = -6.25, a = 71.00, inaccuracy
Move 41: cpBefore = -205.0 cpAfter = -570.0,  d = -21.06, a = 25.62, blunder
Move 42: cpBefore = 570.0 cpAfter = 563.0,  d = -0.25, a = 97.15, excellent
Move 43: cpBefore = -563.0 cpAfter = -575.0,  d = -0.43, a = 95.16, excellent
Move 44: cpBefore = 575.0 cpAfter = 328.0,  d = -12.27, a = 37.66, mistake
Move 45: cpBefore = -328.0 cpAfter = -626.0,  d = -13.94, a = 31.61, mistake
Move 46: cpBefore = 626.0 cpAfter = 630.0,  d = 0.12, a = 100.00, best
Move 47: cpBefore = -630.0 cpAfter = -753.0,  d = -3.07, a = 60.37, good
Move 48: cpBefore = 753.0 cpAfter = 642.0,  d = -2.71, a = 63.36, good
Move 49: cpBefore = -642.0 cpAfter = -652.0,  d = -0.28, a = 95.95, excellent
Move 50: cpBefore = 652.0 cpAfter = 685.0,  d = 0.88, a = 100.00, best
Move 51: cpBefore = -685.0 cpAfter = -711.0,  d = -0.63, a = 89.80, excellent
Move 52: cpBefore = 711.0 cpAfter = 710.0,  d = -0.02, a = 99.59, excellent
Move 53: cpBefore = -710.0 cpAfter = -1109.0,  d = -5.17, a = 23.25, inaccuracy
Move 54: cpBefore = 1109.0 cpAfter = 3283.0,  d = 1.66, a = 100.00, best
Move 55: cpBefore = -3283.0 cpAfter = -4996.0,  d = -0.00, a = 8.62, excellent
Move 56: cpBefore = 4996.0 cpAfter = 3274.0,  d = -0.00, a = 8.62, excellent
Move 57: cpBefore = -3274.0 cpAfter = -3313.0,  d = -0.00, a = 85.09, excellent
Move 58: cpBefore = 3313.0 cpAfter = 2920.0,  d = -0.00, a = 23.64, excellent
Move 59: cpBefore = -2920.0 cpAfter = -4964.0,  d = -0.00, a = 8.56, excellent
Move 60: cpBefore = 4964.0 cpAfter = 3206.0,  d = -0.00, a = 8.61, excellent
Move 61: cpBefore = -3206.0 cpAfter = -3206.0,  d = 0.00, a = 100.00, best
Move 62: cpBefore = 3206.0 cpAfter = 3206.0,  d = 0.00, a = 100.00, best
Move 63: cpBefore = -3206.0 cpAfter = -4960.0,  d = -0.00, a = 8.61, excellent
Move 64: cpBefore = 4960.0 cpAfter = 4957.0,  d = -0.00, a = 98.77, excellent
Move 65: cpBefore = -4957.0 cpAfter = -4957.0,  d = 0.00, a = 100.00, best
Move 66: cpBefore = 4957.0 cpAfter = 4956.0,  d = -0.00, a = 99.59, excellent
Move 67: cpBefore = -4956.0 cpAfter = -4956.0,  d = 0.00, a = 100.00, best
Move 68: cpBefore = 4956.0 cpAfter = 1725.0,  d = -0.17, a = 8.53, excellent
Move 69: cpBefore = -1725.0 cpAfter = -1725.0,  d = 0.00, a = 100.00, best

User report

# filter out the user data
user_game_data = game_data.query('color == @usercolor')
user_game_data[['move_number', 'piece', 'san', 'cp', 'accuracy', 'classification']]
move_number piece san cp accuracy classification
0 1 P e4 -40 100.000000 book
2 2 N Nf3 -24 97.548670 book
4 3 B Bb5 -33 100.000000 book
6 4 N Nc3 -67 93.206891 excellent
8 5 P d4 40 66.242364 inaccuracy
10 6 B Bxc6 86 60.612985 mistake
12 7 N Nxd4 -60 98.359170 best
14 8 Q Qxd4 -104 92.438299 best
16 9 K O-O -47 79.981614 good
18 10 P exd5 -90 78.673077 inaccuracy
20 11 R Re1 -89 99.587241 best
22 12 B Bf4 -73 88.689550 good
24 13 P a3 -43 86.873161 good
26 14 R Rac1 -30 88.323211 good
28 15 Q Qc5 -120 100.000000 best
30 16 B Bd6 -115 100.000000 best
32 17 R Rxe8+ -19 38.341012 blunder
34 18 Q Qxc6 700 12.576161 blunder
36 19 Q Qxe8+ -291 99.176236 best
38 20 N Nxd5 -268 100.000000 best
40 21 N Ne7+ -205 70.995179 inaccuracy
42 22 B Bxe7 -563 97.145874 best
44 23 B Bb4 -328 37.662945 mistake
46 24 R Rxc2 -630 100.000000 best
48 25 R Rc7+ -642 63.356682 good
50 26 B Bxa5 -685 100.000000 best
52 27 B Bc3 -710 99.587241 best
54 28 B Bxb2 -3283 100.000000 best
56 29 B Bxg7 -3274 8.620265 excellent
58 30 B Bxh6+ -2920 23.638074 excellent
60 31 R Rc6+ -3206 8.609072 excellent
62 32 R Rc5 -3206 99.999900 best
64 33 R Rc4+ -4957 98.766880 best
66 34 R Rc3 -4956 99.587241 best
68 35 P f3+ -1725 8.530619 excellent
user_game_data.classification.value_counts()
best          15
excellent      5
good           5
book           3
inaccuracy     3
mistake        2
blunder        2
Name: classification, dtype: int64
user_game_data.query('classification != "book"').accuracy.mean()
74.98690444022928
user_game_data.query('classification != "book"').groupby('piece').accuracy.mean()
piece
B    71.586694
K    79.981614
N    90.640310
P    60.079718
Q    76.047674
R    77.388049
Name: accuracy, dtype: float64
user_game_data.query('classification != "book" & piece == "B"')[['i', 'move_number', 'san', 'classification', 'cp', 'cp_loss', 'accuracy', 'info']].iloc[-1]['info']
{'string': 'NNUE evaluation using nn-ad9b42354671.nnue enabled',
 'depth': 20,
 'seldepth': 20,
 'multipv': 1,
 'score': PovScore(Cp(-2920), BLACK),
 'nodes': 2130935,
 'nps': 1477763,
 'hashfull': 492,
 'tbhits': 0,
 'time': 1.442,
 'pv': [Move.from_uci('g5h6'),
  Move.from_uci('a3a4'),
  Move.from_uci('h6g5'),
  Move.from_uci('a4a5'),
  Move.from_uci('g5f4'),
  Move.from_uci('a5a6'),
  Move.from_uci('f4e4'),
  Move.from_uci('h2h4'),
  Move.from_uci('e4d3'),
  Move.from_uci('a6a7'),
  Move.from_uci('f5f4'),
  Move.from_uci('h4h5'),
  Move.from_uci('d3d2'),
  Move.from_uci('h5h6'),
  Move.from_uci('f4f3'),
  Move.from_uci('g2g4'),
  Move.from_uci('d2e2'),
  Move.from_uci('h6h7'),
  Move.from_uci('e2d2'),
  Move.from_uci('g4g5')]}
game_data.iloc[57]['info']
{'string': 'NNUE evaluation using nn-ad9b42354671.nnue enabled',
 'depth': 20,
 'seldepth': 25,
 'multipv': 1,
 'score': PovScore(Cp(+3313), WHITE),
 'nodes': 97594,
 'nps': 1682655,
 'hashfull': 38,
 'tbhits': 0,
 'time': 0.058,
 'pv': [Move.from_uci('a3a4'),
  Move.from_uci('g5g4'),
  Move.from_uci('g7h6'),
  Move.from_uci('g4h5'),
  Move.from_uci('h6d2'),
  Move.from_uci('h5g6'),
  Move.from_uci('a4a5'),
  Move.from_uci('g6f6'),
  Move.from_uci('a5a6'),
  Move.from_uci('f6e6'),
  Move.from_uci('a6a7'),
  Move.from_uci('e6e5'),
  Move.from_uci('c7c5'),
  Move.from_uci('e5e6'),
  Move.from_uci('d2c3'),
  Move.from_uci('f5f4'),
  Move.from_uci('f2f3'),
  Move.from_uci('e6e7'),
  Move.from_uci('c3e5')]}