summaryrefslogtreecommitdiff
path: root/annotate_pgns/dedup_pgn.py
blob: 49654c40a341b969afd93f77ef83f4aac8621a03 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
#!/usr/bin/env python3
"""Drops duplicate games from one or more self-play PGN files, keyed on
(starting FEN, move text) -- not on PGN headers (Date/Event/Result
alone don't prove two games are the same game) and not on FEN alone
(two games can legitimately share a random starting position and then
diverge, since self-play move choice is not deterministic across
engine generations/threads -- those are NOT duplicates and must be
kept).

Motivation: self-play starts from randomized opening positions (see
docs/NNUE.md); with enough games it becomes likely the same random
starting FEN gets drawn more than once. If the *same* position then
also produced the *same* game (e.g. a re-run over an unchanged engine
binary, or two shards drawing the same seed), that's a real duplicate
and should not be trained on twice -- it would silently inflate that
game's influence on both the WDL-K fit (fit_k_and_label.py) and the
training loss itself.

Dedup key is computed directly from each game's raw PGN text (the
[FEN "..."] header plus a comment-and-whitespace-stripped movetext
string), not by parsing through python-chess -- this project's own
split_pgn() (run_nnue_pipeline.py) found full-PGN python-chess parsing
too slow to use as a bulk pre-pass over large corpora; a raw-text key
is exact for this purpose (identical moves from an identical FEN is
exactly the definition of "the same game") and avoids that cost
entirely.

Usage:
    python3 dedup_pgn.py ../pgn/stockfish*.pgn --out-dir runs/dedup
Writes <out-dir>/<stem>.dedup.pgn for each input, in the same relative
order games appeared in their source file (cross-file: first file on
the command line wins ties). Prints a per-file and total duplicate
count; does not modify the inputs.
"""
import argparse
import hashlib
import re
import sys
from pathlib import Path

COMMENT_RE = re.compile(rb"\{[^}]*\}")
WHITESPACE_RE = re.compile(rb"\s+")
FEN_RE = re.compile(rb'\[FEN\s+"([^"]*)"\]')


def iter_raw_games(path):
    """Yields raw byte blocks for each game in a PGN file, split on
    lines starting "[Event " -- same boundary convention as
    run_nnue_pipeline.py's split_pgn()."""
    with open(path, "rb") as f:
        data = f.read()
    offsets = [m.start() for m in re.finditer(rb"^\[Event ", data, re.MULTILINE)]
    offsets.append(len(data))
    for i in range(len(offsets) - 1):
        block = data[offsets[i]:offsets[i + 1]]
        if block.strip():
            yield block


def game_key(raw_game):
    """(fen, movetext) dedup key. Movetext is everything after the
    header block (first blank line), with PGN brace-comments and all
    whitespace stripped -- comments carry score/depth/PV annotations
    that are irrelevant to "is this the same game" and would otherwise
    make two runs that scored an identical game differently (e.g. a
    deeper search depth) look like different games, which is wrong:
    same moves played is the actual definition of "the same game"
    here, regardless of what score commentary got attached to it."""
    fen_match = FEN_RE.search(raw_game)
    fen = fen_match.group(1) if fen_match else b""
    blank = raw_game.find(b"\n\n")
    movetext = raw_game[blank:] if blank != -1 else b""
    movetext = COMMENT_RE.sub(b"", movetext)
    movetext = WHITESPACE_RE.sub(b"", movetext)
    return hashlib.sha256(fen + b"|" + movetext).digest()


def main():
    ap = argparse.ArgumentParser(description=__doc__,
        formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("pgns", nargs="+", help="input PGN file(s)")
    ap.add_argument("--out-dir", required=True,
                     help="directory to write <stem>.dedup.pgn into")
    args = ap.parse_args()

    out_dir = Path(args.out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)

    seen = set()
    total_in = 0
    total_dupes = 0
    for pgn_path in args.pgns:
        pgn_path = Path(pgn_path)
        n_in = 0
        n_dupes = 0
        out_path = out_dir / f"{pgn_path.stem}.dedup.pgn"
        with open(out_path, "wb") as out:
            for raw_game in iter_raw_games(pgn_path):
                n_in += 1
                key = game_key(raw_game)
                if key in seen:
                    n_dupes += 1
                    continue
                seen.add(key)
                out.write(raw_game)
                if not raw_game.endswith(b"\n\n"):
                    out.write(b"\n")
        print(f"{pgn_path}: {n_in} games, {n_dupes} duplicates dropped "
              f"-> {out_path} ({n_in - n_dupes} kept)")
        total_in += n_in
        total_dupes += n_dupes

    print(f"\nTotal: {total_in} games in, {total_dupes} duplicates "
          f"dropped, {total_in - total_dupes} kept across "
          f"{len(args.pgns)} file(s).")
    if total_dupes == 0:
        print("(no duplicates found)")


if __name__ == "__main__":
    sys.exit(main())