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

Prelucrări de text PGN şi adnotarea partidelor de şah

AWK | Bash | Crafty | PGN | Python | perl | pyparsing
2014 jul

Joc şah "rapid" (timp de gândire de câte 15 minute) pe InstantChess; uneori, rezultă partide pentru care mă interesează totuşi să lămuresc nuanţele unei anumite poziţii şi să dezvălui greşelile de gândire şi scăpările tactice. InstantChess permite abonaţilor să obţină fişiere conţinând partide jucate într-o anumită perioadă; se creează astfel posibilitatea de a analiza "în linişte" - dar (rămas fără motivaţia specifică şahistului activ) mă mulţumesc cu o analiză automată, folosind Crafty.

Este de regretat cred, anularea obiceiului de a aşeza şi a muta piesele pe o tablă "clasică" de şah. Vizualizez desfăşurarea partidei folosind PGN-browser-ul meu; eventual, public partida adnotată cu Crafty, pe instant_chess (vizăm aici infrastructura aferentă, nu calitatea de "partidă publicabilă").

Fişierul PGN

Pentru experimentele care urmează, am obţinut de pe instantchess.com fişierul GA_07-14-2014.pgn (denumit după data "14 iulie"), cu partidele mele din perioada 14.06 - 14.07.2014. Fiecare partidă este înregistrată după modelul următor (formatul PGN):

[Event "InstantChess"]
[White "vlad.bazon"]    # numele albului
[Black "гладиатор"]     # numele negrului
[Result "1-0"]          # rezultatul jocului
[WhiteIFlag "RO"]  # steagurile naţionale (uneori apare şi [BlackITeam "nume_echipă"])
[BlackIFlag "RU"]  
# urmează lista mutărilor efectuate în partidă
1. d4 e6
2. c4 Nf6
3. Nc3 Be7
# ...
18. Nf7 1-0  # rezultatul încheie înregistrarea partidei în fişier

Vom justifica ulterior că adnotarea partidelor respective folosind Crafty necesită ca în prealabil, să eliminăm tagurile nestandard WhiteIFlag, etc. (altfel se pierd valorile tagurilor White şi Black).

Pentru extragerea şi gruparea diverselor informaţii din fişier vom folosi modulul pyparsing (dar şi alte instrumente). Alegem să constituim programe independente şi în principiu afişăm rezultatele pe ecran, dar vizăm implicit redirectarea ieşirii către fişiere text.

Identificarea partidelor conţinute în fişier

Infrastructura necesară publicării partidelor conţine de regulă o bază de date din care (între altele) să se poată extrage partidele după numele jucătorului (desigur, sunt posibile şi alte criterii). Prin urmare, am avea această primă problemă: să extragem valorile tagurilor White, Black şi Result (eventual şi tagurile de naţionalitate, dacă interesează) din fişierul PGN, combinându-le într-un format textual cât mai simplu:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
from pyparsing import quotedString, removeQuotes
from pyparsing import Combine, Group, Literal, OneOrMore, Suppress, SkipTo 

def val_tag(tag):
    return Suppress("[") + Literal(tag).suppress() + \
           quotedString.setParseAction(removeQuotes) + Suppress("]")

white = val_tag("White")
black = val_tag("Black")
result = val_tag("Result") 

play = Group( SkipTo(white).suppress() + white
              + SkipTo(black).suppress() + black 
              + SkipTo(result).suppress() + result )

plays = OneOrMore(play)

players = plays.parseFile("GA_07-14-2014.pgn")

for act in players:
    print ', '.join(act)

Funcţia val_tag() (liniile 4-6) produce un parser pentru parametrul care îi este transmis: identifică o secvenţă de forma [Tag "valoare"], suprimă "[", "Tag", "]" şi elimină ghilimelele - rezultând valoare; în liniile 8-10 se obţin trei asemenea parsere. Parserul din linia 12 se poate citi astfel: caută ceea ce specifică parserele white, black şi result (în această ordine) - ignorând textele precedente acestora - şi grupează rezultatele într-un răspuns final; fiindcă vrem aceasta nu numai pentru prima partidă din fişier - "iterăm" acest parser (pentru toate partidele), prin linia 16.

În linia 18 se aplică parserul final fişierului nostru şi apoi (liniile 20, 21) se afişează rezultatele:

vb@vb:~/instantChess/PGN$ python info_pgn.py 
# ...
vlad.bazon, гладиатор, 1-0
# ...
Super GM Jose Raul Capabl, vlad.bazon, 0-1
# ...
vlad.bazon, جوزيف618, 1-0
# ...

Am redat mai sus câteva mostre, având totuşi de evidenţiat un aspect "neplăcut". Scrierea arabă decurge de la dreapta spre stânga; poziţia caracterului "," sugerează că ultimul dintre rezultatele redate ar trebui citit "pe dos" (جوزيف618 a jucat cu albul, nu cu negrul) - ne putem convinge, căutând partida respectivă în fişierul PGN original (trebuie căutată partida şi nu doar numele, fiindcă pot exista mai multe partide între aceiaşi parteneri).

Extragerea partidelor

Baza de date specifică de regulă câte o înregistrare pentru numele celor doi parteneri şi una pentru textul complet al partidei respective - încât să se poată furniza atât informaţii generice asupra unui jucător, cât şi una sau alta dintre partidele înregistrate ale acestuia. Pentru facilitarea procedurii de înscriere în baza de date, am avea de obţinut un fişier text în care partidele să fie separate în mod explicit - de exemplu, ca elemente ale unei liste (Python):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from pyparsing import Literal, quotedString, OneOrMore, Group, SkipTo  
from pyparsing import originalTextFor

def tag_orig(tag):
    return "[" + Literal(tag) + quotedString + "]"

begin = tag_orig("Event")
games = OneOrMore(
            Group(originalTextFor(begin) + SkipTo(begin))
        )

f = open("GA_07-14-2014.pgn")
pgn_text = f.read()
f.close()

results = games.parseString(pgn_text)
print results[9]

Putem vedea fiecare partidă ca fiind delimitată de două taguri [Event ...] consecutive - cel propriu ei şi cel propriu partidei care urmează după ea - ajungând la definiţia de parser din linia 9; "iterând" prin OneOrMore() - obţinem parserul din linia 8, cu ajutorul căruia vom putea scana toate partidele. Avem într-adevăr o excepţie la ultima partidă din fişier - dar o rezolvăm cel mai simplu adăugând un asemenea delimitator la sfârşitul fişierului:

vb@vb:~/instantChess/PGN$ echo [Event \"InstantChess\"] >> GA_07-14-2014.pgn

În linia 16 se aplică parserul din linia 8, textului citit în linia 13 din fişierul nostru; apoi (linia 17) afişăm pentru exemplificare, al 9-lea element al listei astfel obţinute:

vb@vb:~/instantChess/PGN$ python pgn_extract.py 
['[Event "InstantChess"]', '[White "vlad.bazon"]\r\n[Black "\xd0\xb3\xd0\xbb\xd0\xb0\xd0\xb4\xd0\xb8\xd0\xb0\xd1\x82\xd0\xbe\xd1\x80"]\r\n[Result "1-0"]\r\n[WhiteIFlag "RO"]\r\n[BlackIFlag "RU"]\r\n\r\n1. d4 e6\r\n2. c4 Nf6\r\n3. Nc3 Be7\r\n4. e4 b6\r\n5. e5 Ng8\r\n6. Nf3 Bb7\r\n7. Bd3 Bb4\r\n8. O-O Bxc3\r\n9. bxc3 Ne7\r\n10. Ng5 h6\r\n11. Qh5 g6\r\n12. Qh3 Nf5\r\n13. g4 Ng7\r\n14. f4 d5\r\n15. f5 exf5\r\n16. gxf5 Nd7\r\n17. fxg6 f6\r\n18. Nf7 1-0\r\n\r\n\r\n']

Avem aici reprezentarea unei liste conţinând două şiruri (secvenţele încadrate de apostrofuri): primul a fost furnizat de originalTextFor(begin), iar al doilea de SkipTo(begin) - cele două parsere grupate în linia 8; prin print results[9][1] putem "explicita" al doilea şir - print şir va interpreta caracterele de control, precum şi secvenţele de coduri UTF-8: [Black "гладиатор"].

Să observăm: caracterele de control (preluate din fişierul original) sunt cele specifice DOS, "\r\n"; pe Linux, putem înjumătăţi numărul acestora (eliminând "\r") prin:

vb@vb:~/instantChess/PGN$ dos2unix GA_07-14-2014.pgn
dos2unix: converting file GA_07-14-2014.pgn to Unix format ...

Putem viza acum şi interogări mai complicate, pregătind eventual rezolvarea ulterioară a diverse cereri; de exemplu, să furnizăm partidele în care جوزيف618 a jucat cu piesele albe:

his_games = [g for g in results if "[White \"جوزيف618\"]" in g[1]]
for g in his_games:
    print '\n'.join(g)

Dar desigur că asemenea interogări se vor rezolva cel mai bine direct la nivelul bazei de date, după înregistrarea în tabelele respective a partidelor din lista Python results.

Adnotarea automată a partidelor

De analiza unei partide de şah folosind Crafty ne-am mai ocupat în Şah cu Fruit şi Crafty. În cazul de faţă, lansând comanda crafty "annotate GA_07-14-2014.pgn wb 1-999 0.5 10", am obţine fişierul GA_07-14-2014.pgn.can, conţinând partidele adnotate corespunzător de către Crafty; execuţia va dura mult, fiind multe partide (dar între timp, putem şi juca şah pe instantchess.com).

Însă odată lansată comanda menţionată, Crafty va afişa continuu fiecare secvenţă de analiză, pentru fiecare mutare şi la sfârşit (dacă răbdăm astfel) va intra într-o stare de aşteptare a unei noi comenzi, trebuind tastat "end" (şi "Enter") pentru a încheia corect execuţia. Rezolvăm aceasta (sporind într-o anumită măsură şi viteza de execuţie, fiindcă se exclude derularea pe ecran a analizei) adăugând ca parametru comanda end şi redirectând "ieşirea pe ecran" către /dev/null:

crafty "annotate $pgn wb 1-999 0.5 10"  end  >  /dev/null

Un al doilea "necaz" care trebuie rezolvat decurge din modul în care Crafty prelucrează şi înscrie tagurile PGN în fişierul ".can" (extensia însemnând desigur, "Crafty annotated") final.

Funcţia Annotate() - din fişierul "annotate.c" al pachetului sursă Crafty - apelează repetat ReadPGN( FILE* input, int option) (din fişierul "utility.c"), obţinând întâi tagurile PGN (pe care le înscrie direct în fişierul ".can") şi apoi câte o mutare - pe care începe să o analizeze (apelând funcţiile de generare a mutărilor şi de evaluare a poziţiei), adăugând eventual o adnotare pentru acea mutare în fişierul ".can" (şi încheind - reapelează ReadPGN(input) pentru a obţine următoarea mutare de analizat).

În ReadPGN(), dacă buffer-ul în care s-a citit linia curentă din fişierul input începe cu "[", atunci el conţine unul dintre tagurile PGN - de exemplu, [WhiteIFlag "RO"]; se copiază valoarea tagului (şirul cuprins între ghilimele) în variabila char value[] şi se prevede pentru fiecare dintre tagurile PGN standard câte o secvenţă precum următoarea:

          else if (strstr(input_buffer, "White"))
            strcpy(pgn_white, value);

Cu alte cuvinte - dacă tagul respectiv începe cu "White", atunci valoarea lui este transferată într-o variabilă globală (introdusă prin fişierul "data.c") precum pgn_white. Dar ReadPGN() nu-şi încheie execuţia curentă imediat ce a descoperit un tag: Annotate() reprimeşte controlul numai după ce ReadPGN() descoperă toate tagurile PGN din fişierul input (ceea ce este foarte bine - mutările sunt de analizat, nu altceva). Ca urmare, Annotate() va găsi în variabila pgn_white valoarea (incorectă) "RO": valoarea corectă, înscrisă în pgn_white la întâlnirea primului tag "White", a fost suprascrisă când s-a întâlnit ulterior "WhiteIFlag".

Pentru a avea numele corecte în fişierul ".can", soluţia cea mai simplă constă în eliminarea din fişierul original a tagurilor nestandard (precum "WhiteIFlag"):

vb@vb:~/instantChess/PGN$ \
> perl -pi -e 's/\[(?:White|Black)I.*\]\s*//g' GA_07-14-2014.pgn

În sfârşit, ne-am mai pune o problemă de practică: este mult mai uşor de controlat desfăşurarea lucrurilor, dacă am adnota câte o partidă şi nu "în masă". Separăm partidele din fişierul original în câte un fişier independent şi constituim un script care să invoce Crafty pentru acelea dintre fişierele PGN rezultate, pentru care încă nu există un fişier ".can".

Putem folosi desigur, lista Python results (vezi linia 16 din programul precedent): parcurgem lista şi pentru fiecare element al ei - creem un nou fişier, înscriem în acest fişier partida reprezentată pe acel element şi închidem fişierul. Dar de fapt, problema splitării unui fişier este mai generală şi ea poate fi rezolvată cel mai eficient folosind AWK:

vb@vb:~/instantChess/PGN/SPLIT$ \
> awk '/\[Event \"InstantChess\"\]/{x="G_"++i".pgn";}{print > x;}' GA_07-14-2014.pgn

La prima întâlnire în fişierul indicat a şablonului [Event "InstantChess"], se execută acţiunea din prima pereche de acolade: i devine 1 şi se creează variabila x="G_1.pgn"; apoi, se execută acţiunea din cea de-a doua pereche de acolade: se creează fişierul cu numele indicat de variabila x, înscriind în acest fişier fiecare linie din fişierul iniţial, cât timp aceasta nu se potriveşte şablonului indicat. La reîntâlnirea acestui şablon (deci pentru următoarea partidă din fişier), se repetă cele două acţiuni.

Explicaţia de mai sus este extrem de lungă, în raport cu fracţiunea de secundă în care au rezultat cele 314 fişiere corespunzătoare partidelor din fişierul original… Adăugăm în subdirectorul /SPLIT/ al acestor fişiere scriptul Bash următor (şi-l facem "executabil", prin chmod +x annotate.sh):

#!/bin/bash
for pgn in `ls -v G_*.pgn`
do
    crafty "annotate $pgn wb 1-999 0.5 10" end 1>/dev/null
    rm $pgn
done

ls -v listează numele fişierelor în ordinea naturală; se parcurge această listă şi pentru fişierul curent "G_i.pgn" (i=1..314) se invocă Crafty, iar după ce acesta produce fişierul "G_i.pgn.can" şi (receptând comanda "end") îşi încheie execuţia - fişierul "G_i.pgn" este şters (apoi, se trece la fişierul i+1).

Putem lansa vb@vb:~/instantChess/PGN/SPLIT$ ./annotate.sh şi să ne vedem de treabă. Când este cazul, revenim în terminalul în care am lansat "annotate.sh" şi îl închidem (tastând "CTRL+C"), iar cu alt prilej vom relansa "annotate.sh"; o partidă deja analizată nu va fi astfel, re-analizată - fiindcă fişierele PGN pentru care s-a obţinut ".can" au fost şterse.

Este însă drept că pentru 300 de partide, va trebui să procedăm astfel câte vreo trei ore pe zi, vreme de câteva zile… În orice caz, partea tehnică a lucrurilor este pusă la punct şi rămâne de folosit un PGN-browser, pentru a vedea analizele obţinute şi a decide care partide ar fi eventual, publicabile.

vezi Cărţile mele (de programare)

docerpro | Prev | Next