momente şi schiţe de informatică şi matematică
To attain knowledge, write. To attain wisdom, rewrite.

Transformarea fișierului PGN în obiect de date (I)

PGN | limbajul R
2023 apr

[1] Un proiect PGN-games cu R, Django şi PostgreSQL

[2] V. Bazon - Modelarea în browser a partidei de șah (Lulu.com, 2020)

Se fac 30 de ani de când Steven J. Edwards a introdus formatul PGN (Portable Game Notation), pentru reprezentarea datelor din partidele de șah; PGN utilizează fișiere-text ASCII – încât poate fi citit / scris / analizat / transmis cu ușurință (spre deosebire de formatele criptate specifice aplicațiilor comerciale), indiferent de limba-nativă / limbaj / arhitectură.
De fapt, cam acum 30 de ani au demarat și marile proiecte "open-source" (Linux, TeX, R, MySQL și altele), eliberând de mercantilism și restricții accesul la cunoaștere și însăși constituirea coerentă și dezvoltarea ca știință, a informaticii (dar bineînțeles că nișa comercială – rezumarea informaticii la produsele firmei și reclamă animată, plus discounturi | comisioane – a găsit strategiile practice de clientelizare, începând de la guverne | ministere | funcționari și până la mai simplii utilizatori).

Vom formula treptat, o funcție care să transforme un fișier PGN într-un obiect de memorie data.frame – permițând astfel explorarea și când este cazul, analiza statistică a datelor respective; am mai abordat chestiunea, în [1] (ba și în [2]) – dar acum vreo 5 ani…

Ajustări prealabile, folosind expresii regulate

Pe unele fișiere PGN, liniile sunt separate prin '\r\n' (cazul sistemelor DOS/Windows, care folosesc drept „sfârșit de linie” două coduri ASCII: 0D și 0A).
Pe unele fișiere PGN, secțiunea mutărilor din partidă este redată pe linii consecutive – câte o mutare pe linie, sau mai multe pe câte o linie de cel mult 80 (sau 255) de caractere.

Este simplu de înlocuit în fișierul respectiv, '\r\n' prin '\n'; însă avem de construit o expresie regulată pe baza căreia să rescriem pe o singură linie, secțiunea mutărilor…
Derulăm aici un mic experiment, pentru a evidenția anumite aspecte ale lucrului cu fișiere-text și cu expresii regulate (în R plus tidyverse).

Mai demult, am procurat de pe undeva un fișier PGN conținând o colecție vastă (pe 6.5 MiB) de partide cu aceleași câteva mutări inițiale (o variantă din Apărarea Grünfeld); deschizându-l în gedit, constatăm imediat nepotrivirea dintre codificarea caracterelor pe sistemul propriu (Unicode UTF-8) și cea din fișierul încărcat:

Pentru experiment ne sunt suficiente liniile corespunzătoare primelor două partide; derulând textul arătat de gedit pe ecran, constatăm că primele două partide ocupă liniile 1–39. Dar să nu decupăm aceste linii prin gedit, fiindcă operațiile selectare&Copy&Paste de regulă „repară” automat nepotrivirea de caractere (cel puțin, rezumă '\r\n' la '\n'); preferăm să decupăm prin pachetele readr și stringr – incluse ambele, în „dialectul” tidyverse:

library(tidyverse)
read_file("GrunfeldExchange_Desk.pgn") %>%
    str_extract(., "(.*\r\n){39}") %>%  # extrage primele 39 de linii (2 partide)
        write_file(., "test.pgn")
S <- read_file("test.pgn")
print(S)  # Aici, omitem prin '...' unele porțiuni și boldăm secvențele \r\n
[1] "[Event \"Teplitz-Schoenau\"]\r\n[Site \"Teplitz-Schoenau\"]\r\n[Date \"1922.??.??\"]\r\n[Round \"11\"]\r\n[White \"Kostic, Boris\"]\r\n[Black \"Gruenfeld, Ernst\"]\r\n[Result \"0-1\"]\r\n[WhiteElo \"\"]\r\n[BlackElo \"\"]\r\n[ECO \"D85\"]\r\n\r\n1.d4 Nf6 2.c4 g6 3.Nc3 d5 ... 8.Bb5+ Bd7\r\n9.Bxd7+ Qxd7 ... 15.Bd4 Bxd4\r\n16.Qxd4 b6 ... 22.f3 h5\r\n23.g3 Qc7 ... 29.e6 Qc5\r\n30.Qxc5 Rxc5 ... 36.fxg5+ Kxg5\r\n37.Kg2 Kf5 ... 43.Rxh5 Rb2\r\n44.Rh8 a5 ... 50.Ke4 a3\r\n51.Ra6+ Kg7 52.Kf5 b4 53.Ra7 Rf3+ 54.Ke4 Rf2 55.Ke3 Rb2  0-1\r\n\r\n[Event \"Trieste\"]\r\n[Site \"Trieste\"]\r\n[Date \"1923.??.??\"]\r\n[Round \"3\"]\r\n[White \"Seitz, Jakob Adolf\"]\r\n[Black \"Johner, Paul F\"]\r\n[Result \"0-1\"]\r\n[WhiteElo \"\"]\r\n[BlackElo \"\"]\r\n[ECO \"D85\"]\r\n\r\n1.d4 Nf6 2.c4 g6 3.Nc3 d5 ... 8.Be3 cxd4\r\n9.cxd4 Nc6 ... 15.Qg1 Rac8\r\n16.Bb3 Rfd8 ... 22.Qe2 Qb6\r\n23.Bb2 Nf5 ... 29.Rb1 b6\r\n30.a3 Nf5 ... 36.fxe5 Bxd2\r\n37.b5 Bf4 ... 43.Ke3 Kc5\r\n44.Be2 Bb8 45.Bd3 e5 46.Be2 a5 47.bxa6 b5 48.Bd1 Kc4 49.Be2+ Kb4  0-1\r\n\r\n"

Precizăm că în cadrul unei expresii regulate, '.' desemnează nu „punct”-ul obișnuit, ci un caracter oarecare, diferit de '\n'; iar '.*' desemnează o secvență oarecare—eventual, de lungime zero—de caractere diferite de '\n'.
Putem sesiza deja că avem de judecat bine, când angajăm expresii regulate… Să ne imaginăm că S începea cu '\r\n' (putem întâlni fișiere care încep cu o linie albă); atunci '.*\r\n' repera și această primă linie (cu zero caractere, înainte de \r\n), pe când '.+' (care diferă de '.*' numai prin pretenția ca secvența respectivă să aibă lungimea cel puțin unu) ar fi lăsat linia respectivă intactă.

Precizăm că parantezele rotunde grupează secvența conținută în interior (permițând referirea ulterioară a ei), iar între acolade se poate specifica mai departe, un număr de repetiții ale grupării respective; astfel, expresia "(.*\r\n){39}" indicată mai sus în str_extract(), reperează primele 39 de „linii” din șirul de caractere S.
Dar trebuie subliniat un lucru: funcțiile din pachetul stringr simplifică pentru comoditate, utilizarea expresiilor regulate; în principiu, în loc de str_extract() puteam folosi funcția de bază gsub() – dar atunci trebuia să indicăm nu '\r\n', ci '\\r\\n' și mai mult, trebuia să și explicităm cumva, conversia codificării caracterelor.
'\' a fost ales pentru a schimba semnificația caracterului care îi urmează; 'n' este o literă obișnuită, dar în cadrul unui șir de caractere, '\n' devine "Newline Character"; o expresie regulată este definită și ea, printr-un șir de caractere – încât pentru a distinge un caz de celălalt, trebuie dublat '\', indicând '\\n'. La fel, pentru alte caractere; de exemplu, o expresie regulată care să repereze caracterul obișnuit „punct” trebuie să folosească nu '.' (care are semnificația prestabilită de „un caracter oarecare”), nici '\.', ci neapărat '\\.'.

Este ușor să eliminăm cele 39 de caractere '\r' (și verificând, prin nchar()):

> nchar(S)  ## [1] 1569
> S <- str_replace_all(S, "\r", "")
> nchar(S)  ## [1] 1530  (69-30 = 39 de caractere eliminate)

Acum să formulăm o expresie regulată care să repereze caracterele '\n' rămase în interiorul secvenței mutărilor.

O „mutare” completă are la început una sau două cifre, după care urmează un '.' (și apoi, notația mutării albului și respectiv, a negrului). Deci expresia regulată care să repereze o mutare ar fi "\\d{1,2} [\\.] .+" (scriind aici cu spațiu, între „cifre”, „punct”, ”caractere nenule oarecare”); numai că acest șablon acoperă și alte porțiuni din textul PGN, de exemplu în "[Date \"1922.??.??\"]", din secțiunea de tag-uri (care precede secțiunea mutărilor, furnizând informații despre partidă).

Să observăm o diferență între cele două cazuri: în tag-uri întâlnim '\"', dar nu și la mutări; prin urmare, să excludem '\"' din căutare: "\\d{1,2} [\\.] [^\"]+" (precizăm că '[ ]' include, iar '[^ ]' exclude caracterele indicate înăuntru).
Prin stringr::str_view() putem verifica vizual, potrivirile depistate de o expresie regulată, pe un șir dat:

> str_view_all(S, "\\d{1,2}[\\.][^\"]+")

Se vede mai întâi, că n-a fost suficient să excludem '\"': în tag-ul "Date" s-a marcat ca potrivire cu șablonul indicat secvența 22.??.?? (care într-adevăr, nu conține '\"'); prin urmare este necesară încă o deosebire: după secvența marcată în Date urmează '\"]' (și apoi '\n'), pe când la mutări urmează '\n' (și nu apare ']').
Deci secvențele pe care le căutăm trebuie să se încheie cu '\n':

> str_view_all(S, "\\d{1,2}[\\.][^\"]+\n")

Dar tot nu ajunge: înainte de mutarea 9 de exemplu, în șirul S avem '\n' și nu vedem marcajul de potrivire corespunzător… Ce ne scapă?
Am precizat mai sus, că '.+' reperează orice secvență de caractere „nenule” (adică diferite de '\n'), de lungime măcar unu; dar aceasta nu înseamnă decât că acele caractere '\n' care sunt interioare secvenței, sunt ignorate („sărite”).
Deci, pentru a putea repera '\n' în interiorul secvenței mutărilor, trebuie să-l precizăm drept „stop” al căutării (cum avem deja pentru '\"'):

> str_view_all(S, "\\d{1,2}[\\.][^\"|\n]+\n")

Slash-ul vertical care se observă acum înainte de mutarea 9 (sau 23, sau 30) de exemplu, indică faptul că în acel loc s-a depistat un caracter '\n' – marcând sfârșitul secvenței de mutări 1..8, corespunzătoare primei potriviri cu șablonul indicat.

Ar fi de precizat că str_view() produce un fișier temporar "index.html" și îl transmite browser-ului (de unde am decupat rezultatele redate mai sus). Dar pe sistemul propriu (Ubuntu 22.04), Firefox este instalat și updatat automat prin snap, încât nu are acces la fișierele din /tmp ("Firefox can’t find the file at /tmp/..."); nevrând să instalăm o versiune non-Snap, am procedat „băbește”: am pasat link-ul indicat altui browser (Google Chrome).

Concluzionând cele de mai sus, putem formula această funcție:

smoothing <- function(file_pgn) {
    read_file(file_pgn) %>%
    str_replace_all(., "\r", "") %>%
    str_replace_all(., "(\\d{1,2}[\\.][^\n|\"]+)\n[^\n]", "\\1 ") %>%
    write_file(., file_pgn)
}

Am închis în paranteze rotunde șablonul corespunzător unei secvențe de mutări încheiate cu '\n'; referind secvența respectivă prin '\\1 ', se asigură înlocuirea lui '\n' din finalul grupului, cu ' ' (spațiu). Am prevăzut '[^\n]' la capătul șablonului, pentru a păstra o linie albă după fiecare partidă (separând-o de partida care îi urmează în fișier).

Dar trebuie să observăm că funcția la care am ajuns este limitată: probabil că nu operează corect în cazul când secvența mutărilor conține și adnotări (comentarii asupra unor mutări, variante de joc, aprecieri). Și probabil, puteam proceda mai simplu: izolăm (sau extragem) câte o secvență de mutări (ținând seama că de obicei fiecare este separată de secvența tag-urilor prin una sau două linii albe și este separată de partida următoare prin două linii albe); eliminăm '\n' din secvența extrasă și plasăm înapoi rezultatul.

Factori și „mulțimi-cât”

Considerăm un fișier PGN oarecare (poate conține și adnotări) și ne asigurăm întâi că în fiecare partidă, secțiunea mutărilor este scrisă pe o singură linie de text (nu conține caractere '\n'; altfel, putem „netezi” printr-o funcție precum smoothing() de mai sus).

Prin readLines() obținem un vector Lns, având drept elemente (de tip "character", sau chr) liniile de text ale fișierului (inclusiv, liniile albe "").

Plecând de la vectorul Lns, putem construi o listă SL în care pe rangurile impare să avem secvența din Lns corespunzătoare tag-urilor din câte o aceeași partidă, iar pe rangurile pare să avem respectiv, elementele din Lns corespunzătoare secțiunilor de mutări.
De exemplu, SL[[1]] ar conține (unul după altul) tag-urile din prima partidă, iar SL[[2]] ar conține secțiunea mutărilor din prima partidă; la fel SL[[3]] și SL[[4]] pentru a doua partidă, apoi SL[[5]] și SL[[6]] pentru a treia, ș.a.m.d.

Desigur că putem construi în fel și chip, o asemenea listă SL (cel mai banal – parcurgând Lns, prin for() și if(), else()); dar maniera specifică limbajului R este cea obișnuită în matematică, „factorizarea față de o relație de echivalență”. De exemplu, să ne amintim că plecând de la mulțimea întregilor $\mathbb{Z}$ și folosind relația $x\sim y\Leftrightarrow x\bmod n=y\bmod n$ (cu $n$ dat) putem obține „mulțimea-cât” (sau „factor”) $\mathbb{Z}_{/\sim}$ – ajungând la „inelul claselor de resturi modulo n”, $\mathbb{Z}_n$.

Să considerăm „echivalente”, două elemente din vectorul Lns între care nu există vreun element ""; mai precis dacă e cazul, pentru orice ranguri i și j de elemente ale vectorului Lns, cu i≤j, considerăm Lns[i]$\sim$Lns[j] dacă pentru orice rang k pentru care i<k<j, avem Lns[k]≠"". Altfel spus (fiindcă „element” din Lns înseamnă în fond „linie” din fișier), două linii sunt echivalente dacă între ele nu există o linie albă.

Asociem vectorului Lns un vector fct de aceeași lungime, în care înscriem pe locul i, numărul de linii albe aflate deasupra liniei de rang i; atunci Lns[i]$\sim$Lns[j] revine la egalitatea fct[i]=fct[j]. Liniile cu aceeași valoare fct constituie o „clasă de echivalență” față de relația $\sim$, iar „mulțimea-cât” Lns/fct ne va furniza lista SL.

Să experimentăm pe fișierul PGN ilustrat mai sus:

> Lns <- readLines("test.pgn")
> fct <- cumsum(Lns == "")  # numărul de linii albe, aflate deasupra
 [1] 0 0 0 0 0 0 0 0 0 0 1 1 2 2 2 2 2 2 2 2 2 2 2 3 3 4

Primele 10 linii din vectorul Lns – care prezintă câte un tag din secțiunea tag-urilor primei partide – sunt echivalente, fiind toate pe un același nivel (=0) în fct:

> Lns[1:10]  # secțiunea tagurilor (10 linii) din prima partidă
 [1] "[Event \"Teplitz-Schoenau\"]" "[Site \"Teplitz-Schoenau\"]" 
 [3] "[Date \"1922.??.??\"]"        "[Round \"11\"]"              
 [5] "[White \"Kostic, Boris\"]"    "[Black \"Gruenfeld, Ernst\"]"
 [7] "[Result \"0-1\"]"             "[WhiteElo \"\"]"             
 [9] "[BlackElo \"\"]"              "[ECO \"D85\"]"               
> Lns[11] 
[1] ""  # Linie albă (fct[11] devine 1)

Pe nivelul 1 din fct avem două linii: Lns[11] (linie albă) și Lns[12] care prezintă secțiunea mutărilor din prima partidă. Apoi, fct[13] urcă la 2 – însemnând că Lns[13] este o linie albă – și rămâne la 2 pentru următoarele 10 linii din Lns (acestea constituind secțiunea de tag-uri din a doua partidă). Nivelul 3 din fct acoperă două linii (prima este albă, a doua este secțiunea mutărilor din a doua partidă).

Bineînțeles că în lista SL vom avea de reținut pe fiecare nivel indicat de fct, numai liniile nenule (de exemplu, fără linia Lns[13], pentru secțiunea tag-urilor din a doua partidă).

Transformarea în data.frame

În următoarea funcție obținem lista SL cum am arătat mai sus (folosind split() cu factorul dat de cumsum(), pe vectorul returnat de readLines()), eliminăm dintre elementele lui SL liniile albe și folosind map_dfr(), transformăm lista SL în data.frame:

pgn_dfr <- function(file_pgn) {
    con <- file(file_pgn, "r")
    Lns <- readLines(con) 
    close(con)

    fct <- cumsum(Lns == "")  # fct[i] = numărul de linii albe, deasupra liniei i
    SL <- split(Lns, fct)  # "mulțimea-cât" față de factorul fct
    SL[SL == ""] <- NULL  # elimină "clasele de echivalență" vide
    names(SL) <- NULL
    
    # ignoră liniile albe din secțiunile aduse în SL
    for(i in seq(2, length(SL), by=2))
        SL[[i]] <- SL[[i]][2]
    for(i in seq(3, length(SL), by=2)) 
        SL[[i]] <- SL[[i]][ SL[[i]] != "" ]
    # o partidă are tag-urile în SL[[i]] și mutările în SL[[i+1]] (cu i impar)
 
    # transformă lista SL în data.frame și returnează rezultatul
    map_dfr(seq(1, length(SL), by = 2), function(i) {
        line <- gsub("\"|\\[|\\]", "", SL[[i]])  # culege conținuturile (fără '[', ']')
        tags <- str_split_fixed(line, " ", 2)  # desparte față de primul spațiu
        DF <- data.frame(matrix(ncol = nrow(tags)+1, nrow = 0))
        colnames(DF) <- c(tags[, 1], "moves")
        DF[1, ] <- c(tags[, 2], SL[[i+1]])  # valorile tag-urilor, secțiunea mutărilor
        DF
    })
}

La intrarea în map_dfr(), fiecărei partide îi corespund două elemente consecutive ale listei SL (secțiunea tag-urilor pe un rang impar, apoi secțiunea mutărilor). Am asociat fiecărei partide un data.frame DF (pe care map_dfr() îl va „alipi” cu cel aferent partidei precedente); dar numărul de taguri și componența acestora poate să difere, de la o partidă la alta – de aceea, am definit DF nu direct, ci plecând de la matrix() (și am setat apoi, numele de coloană – corespunzător tagurilor prezente în partida curentă). DF are o singură linie, conținând valorile tagurilor și (în coloana denumită "moves") secțiunea de mutări.

Copiem sub alt nume, fișierul PGN evocat la început și îi aplicăm smoothing(); apoi obținem data.frame corespunzător (și deocamdată observăm structura lui, prin str()):

D <- pgn_dfr("GrunfeldExchange.pgn")
> str(D) 
'data.frame':	10792 obs. of  11 variables:
 $ Event   : chr  "Teplitz-Schoenau" "Trieste" "Gyor" "URS-ch03" ...
 $ Site    : chr  "Teplitz-Schoenau" "Trieste" "Gyor" "Moscow" ...
 $ Date    : chr  "1922.??.??" "1923.??.??" "1924.??.??" "1924.??.??" ...
 $ Round   : chr  "11" "3" "?" "5" ...
 $ White   : chr  "Kostic, Boris" "Seitz, Jakob Adolf" "Seitz, Jakob Adolf" "Levenfish, Grigory" ...
 $ Black   : chr  "Gruenfeld, Ernst" "Johner, Paul F" "Steiner, Lajos" "Rozental, Solomon" ...
 $ Result  : chr  "0-1" "0-1" "1-0" "1-0" ...
 $ WhiteElo: chr  "" "" "" "" ...
 $ BlackElo: chr  "" "" "" "" ...
 $ ECO     : chr  "D85" "D85" "D85" "D85" ...
 $ moves   : chr  "1.d4 Nf6 2.c4 g6 3.Nc3 d5 4.cxd5 Nxd5 5.e4 Nxc3 6.bxc3 Bg7 7.Nf3 c5 8.Bb5+ Bd7 .Bxd7+ Qxd7 10.O-O cxd4 11.cxd4 "| __truncated__ "1.d4 Nf6 2.c4 g6 3.Nc3 d5 4.cxd5 Nxd5 5.e4 Nxc3 6.bxc3 Bg7 7.f4 c5 8.Be3 cxd4 .cxd4 Nc6 10.Nf3 Bg4 11.e5 Bxf3 1"| __truncated__ ...

Avem, pe câte o linie din D, 10792 de partide; primele 10 coloane conțin valorile tagurilor, iar ultima conține mutările efectuate în fiecare partidă; toate coloanele sunt de tip chr.

Acum dacă vrem, putem obține ușor fel de fel de informații: câte partide a câștigat albul, câte negrul, câte au ieșit remiză; cine sunt jucătorii (unici) și ce rezultate are fiecare; care este numărul mediu de mutări pe partidă; cum arată repartiția pe ani a partidelor; etc.

Am putea aprecia și valoarea intrinsecă, sau calitatea partidelor respective, luându-ne după coeficienții ELO ai jucătorilor (unde există):

> mean(as.integer(c(D$WhiteElo, D$BlackElo)), na.rm=TRUE)  # [1] 2375.6

Desigur că pentru a calcula media coeficienților, a trebuit să convertim valorile chr, prin as.integer(). Media ELO de peste 2300 corespunde categoriei „maestru” – așa că putem spune că partidele respective merită atenția unuia care studiază apărarea Grünfeld (se știe, în domeniul șahului „teoria este practica maeștrilor”).

vezi Cărţile mele (de programare)

docerpro | Prev | Next