doctorlinux commited on
Commit
8a71b3f
·
verified ·
1 Parent(s): 4c71f30

Upload 5 files

Browse files
Files changed (5) hide show
  1. README.md +34 -0
  2. app.py +357 -0
  3. apt.txt +1 -0
  4. requirements.txt +6 -0
  5. runtime.txt +1 -0
README.md ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DecodeChess‑IA — Doctor Linux
2
+
3
+ Analizador de partidas con **Motor + Machine Learning** (RandomForest) listo para **Hugging Face Spaces**.
4
+
5
+ ## ¿Qué hace?
6
+ - Repara y lee **PGN** (toma la primera partida válida).
7
+ - **Entrena automáticamente** un modelo ML al iniciar (si no existe).
8
+ - Analiza jugada por jugada con **Stockfish** (tiempo configurable).
9
+ - Predice la **categoría ML** *(Best, Good, Inaccuracy, Mistake, Blunder)* y genera explicación.
10
+ - Devuelve **PGN anotado** y **CSV** por jugada.
11
+
12
+ ## Archivos necesarios
13
+ ```
14
+ app.py
15
+ requirements.txt
16
+ apt.txt # instala stockfish
17
+ runtime.txt # python-3.11
18
+ README.md
19
+ ```
20
+
21
+ ## Despliegue en Hugging Face
22
+ 1. Crea un Space tipo **Gradio**.
23
+ 2. Sube todos los archivos a la **raíz** del repo.
24
+ 3. El Space instalará `stockfish` (vía `apt.txt`) y se iniciará el entrenamiento rápido.
25
+ 4. Una vez corra, pega tu PGN y haz clic en **Analizar con IA**.
26
+
27
+ ## Local
28
+ ```bash
29
+ python -m venv .venv && source .venv/bin/activate
30
+ pip install -r requirements.txt
31
+ python app.py
32
+ ```
33
+
34
+ > El entrenamiento rápido genera un modelo **RandomForest** usando posiciones analizadas con Stockfish en poco tiempo (no requiere dataset previo). Puedes reemplazarlo luego con tu propio `model_rf.joblib` si lo deseas.
app.py ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import io
3
+ import re
4
+ import math
5
+ import time
6
+ import random
7
+ import traceback
8
+ from io import StringIO
9
+
10
+ import gradio as gr
11
+ import numpy as np
12
+ import pandas as pd
13
+ from joblib import dump, load
14
+ from sklearn.ensemble import RandomForestClassifier
15
+
16
+ import chess, chess.pgn, chess.engine
17
+
18
+ APP_TITLE = "DecodeChess‑IA — Doctor Linux"
19
+ ENGINE_CANDIDATES = ["stockfish", "/usr/bin/stockfish", "/usr/games/stockfish"]
20
+
21
+ # ==============================
22
+ # Utilidades de Motor
23
+ # ==============================
24
+ def load_engine():
25
+ last = None
26
+ for p in ENGINE_CANDIDATES:
27
+ try:
28
+ eng = chess.engine.SimpleEngine.popen_uci(p)
29
+ return eng
30
+ except Exception as e:
31
+ last = e
32
+ raise RuntimeError(f"No pude iniciar Stockfish. Último error: {last}")
33
+
34
+ def score_cp(score: chess.engine.PovScore) -> float:
35
+ """Devuelve centipawns desde la perspectiva de las blancas (+ = blancas mejor)."""
36
+ if score.is_mate():
37
+ m = score.white().mate()
38
+ if m is None:
39
+ return 0.0
40
+ return 100000.0 if m > 0 else -100000.0
41
+ return float(score.white().score(mate_score=100000))
42
+
43
+ # ==============================
44
+ # Reparador PGN simple
45
+ # ==============================
46
+ def repair_pgn_text(text: str) -> str:
47
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
48
+ # Si no hay headers mínimos, agrégalos
49
+ if "[Event " not in text:
50
+ head = ['[Event "?"]','[Site "?"]','[Date "????.??.??"]','[Round "?"]','[White "?"]','[Black "?"]','[Result "*"]','','']
51
+ text = "\n".join(head) + text
52
+ # Arreglos menores
53
+ text = re.sub(r'\[Ulnite', '[White', text)
54
+ text = text.replace('[Result "I-0"]','[Result "1-0"]').replace('[Result "O-I"]','[Result "0-1"]').replace('[Result "I/2-I/2"]','[Result "1/2-1/2"]')
55
+ return text.strip() + "\n"
56
+
57
+ # ==============================
58
+ # Features de posición
59
+ # ==============================
60
+ PIECE_VALUES = {chess.PAWN:1, chess.KNIGHT:3, chess.BISHOP:3, chess.ROOK:5, chess.QUEEN:9}
61
+ CENTER = [chess.D4, chess.E4, chess.D5, chess.E5]
62
+
63
+ def material_eval(board: chess.Board, color=True):
64
+ s = 0
65
+ for pt, v in PIECE_VALUES.items():
66
+ s += len(board.pieces(pt, chess.WHITE))*v
67
+ s -= len(board.pieces(pt, chess.BLACK))*v
68
+ return s if color else -s
69
+
70
+ def mobility(board: chess.Board):
71
+ return board.legal_moves.count()
72
+
73
+ def king_safety(board: chess.Board, color=chess.WHITE):
74
+ king_sq = board.king(color)
75
+ if king_sq is None:
76
+ return 0
77
+ # casillas alrededor del rey libres del enemigo
78
+ danger = 0
79
+ for sq in chess.SquareSet.ring(king_sq):
80
+ if board.is_attacked_by(not color, sq):
81
+ danger += 1
82
+ return -danger
83
+
84
+ def hanging_pieces(board: chess.Board, color=chess.WHITE):
85
+ # piezas atacadas y no defendidas
86
+ count = 0
87
+ for sq in chess.SquareSet(board.occupied_co[color]):
88
+ if board.is_attacked_by(not color, sq) and not board.is_attacked_by(color, sq):
89
+ count += 1
90
+ return count
91
+
92
+ def basic_features(board: chess.Board):
93
+ turn_white = board.turn
94
+ feats = {
95
+ "turn_white": 1 if turn_white else 0,
96
+ "mat_cp": material_eval(board), # + = blancas
97
+ "mobility": mobility(board),
98
+ "king_safety_w": king_safety(board, chess.WHITE),
99
+ "king_safety_b": king_safety(board, chess.BLACK),
100
+ "hanging_w": hanging_pieces(board, chess.WHITE),
101
+ "hanging_b": hanging_pieces(board, chess.BLACK),
102
+ "center_pawns": int(any(board.piece_at(sq) and board.piece_at(sq).piece_type==chess.PAWN for sq in CENTER)),
103
+ "in_check": 1 if board.is_check() else 0,
104
+ "phase": len(board.move_stack) # ply actual
105
+ }
106
+ return feats
107
+
108
+ FEATURE_ORDER = ["turn_white","mat_cp","mobility","king_safety_w","king_safety_b","hanging_w","hanging_b","center_pawns","in_check","phase","eval_before_cp"]
109
+
110
+ # ==============================
111
+ # Etiquetado de categorías por delta de evaluación
112
+ # ==============================
113
+ def delta_to_label(delta_cp: float) -> str:
114
+ # delta_cp = eval_after(POV side-to-move) - eval_before(POV side-to-move)
115
+ drop = -delta_cp # caída (negativo = peor)
116
+ if drop < 20: return "Best"
117
+ if drop < 60: return "Good"
118
+ if drop < 120: return "Inaccuracy"
119
+ if drop < 300: return "Mistake"
120
+ return "Blunder"
121
+
122
+ # ==============================
123
+ # Generación de dataset (entrenamiento rápido on‑startup)
124
+ # ==============================
125
+ def generate_training(engine, games=20, plies_per_game=50, time_per=0.1):
126
+ rows = []
127
+ for g in range(games):
128
+ board = chess.Board()
129
+ for _ in range(plies_per_game):
130
+ if board.is_game_over():
131
+ break
132
+ # Eval antes
133
+ info_before = engine.analyse(board, chess.engine.Limit(time=time_per))
134
+ eval_before = score_cp(info_before["score"].pov(chess.WHITE))
135
+ # mejor jugada (para usarla como "buena")
136
+ pv = info_before.get("pv")
137
+ if pv:
138
+ best = pv[0]
139
+ else:
140
+ # fallback: escoger la primera legal
141
+ legal = list(board.legal_moves)
142
+ if not legal: break
143
+ best = legal[0]
144
+
145
+ # jugar movimiento aleatorio razonable (50% best, 50% otro random) para variar etiquetas
146
+ legal = list(board.legal_moves)
147
+ if not legal: break
148
+ if random.random() < 0.5:
149
+ move = best
150
+ else:
151
+ move = random.choice(legal)
152
+
153
+ before_board = board.copy()
154
+ before_feats = basic_features(before_board)
155
+ before_feats["eval_before_cp"] = eval_before
156
+
157
+ san_played = board.san(move) if move else None
158
+ board.push(move)
159
+
160
+ info_after = engine.analyse(board, chess.engine.Limit(time=time_per))
161
+ eval_after = score_cp(info_after["score"].pov(chess.WHITE))
162
+
163
+ # delta desde POV del bando que movía
164
+ if before_board.turn: # blancas movían
165
+ delta_cp = eval_after - eval_before
166
+ else: # negras movían
167
+ delta_cp = -(eval_after - eval_before)
168
+
169
+ label = delta_to_label(delta_cp)
170
+
171
+ row = {k: before_feats[k] for k in FEATURE_ORDER if k in before_feats}
172
+ row["eval_before_cp"] = before_feats["eval_before_cp"]
173
+ row["delta_cp"] = delta_cp
174
+ row["label"] = label
175
+ row["played_san"] = san_played
176
+ rows.append(row)
177
+ df = pd.DataFrame(rows)
178
+ return df
179
+
180
+ def train_model_if_needed():
181
+ model_path = "model_rf.joblib"
182
+ if os.path.exists(model_path):
183
+ try:
184
+ clf = load(model_path)
185
+ return clf
186
+ except Exception:
187
+ pass
188
+ # Entrenamiento rápido
189
+ eng = load_engine()
190
+ try:
191
+ df = generate_training(eng, games=24, plies_per_game=40, time_per=0.06)
192
+ finally:
193
+ eng.quit()
194
+ # Balance simple
195
+ X = df[FEATURE_ORDER].astype(float).values
196
+ y = df["label"].values
197
+ clf = RandomForestClassifier(n_estimators=140, max_depth=None, random_state=42, n_jobs=-1, class_weight="balanced")
198
+ clf.fit(X, y)
199
+ dump(clf, model_path)
200
+ return clf
201
+
202
+ # ==============================
203
+ # Explicaciones naturales (plantillas simples)
204
+ # ==============================
205
+ def explain(label: str, delta_cp: float, in_check: int, hanging_w: int, hanging_b: int) -> str:
206
+ tips = []
207
+ if label == "Blunder":
208
+ tips.append("Grave caída de evaluación; probablemente táctica inmediata o pieza colgando.")
209
+ elif label == "Mistake":
210
+ tips.append("Movimiento impreciso que cede ventaja significativa.")
211
+ elif label == "Inaccuracy":
212
+ tips.append("Había alternativas más fuertes según el motor.")
213
+ elif label == "Good":
214
+ tips.append("Jugada razonable; mantiene la evaluación.")
215
+ else:
216
+ tips.append("Excelente jugada; coincide con la mejor línea del motor.")
217
+ if in_check:
218
+ tips.append("El rey estaba en jaque o vulnerable.")
219
+ if hanging_w or hanging_b:
220
+ tips.append("Detecto pieza(s) atacadas sin defensa; revisa táctica.")
221
+ tips.append(f"Δ={round(delta_cp,1)} cp.")
222
+ return " ".join(tips)
223
+
224
+ # ==============================
225
+ # Análisis de una partida PGN
226
+ # ==============================
227
+ def analyze_pgn(pgn_text: str, time_per_move=0.2, depth_limit=0):
228
+ pgn_text = repair_pgn_text(pgn_text)
229
+ f = StringIO(pgn_text)
230
+
231
+ # Tomar la primera partida válida con movimientos
232
+ game = chess.pgn.read_game(f)
233
+ while game is not None and sum(1 for _ in game.mainline_moves()) == 0:
234
+ game = chess.pgn.read_game(f)
235
+ if game is None:
236
+ return None, None, "No se encontró una partida válida."
237
+
238
+ engine = load_engine()
239
+ try:
240
+ # Cargar modelo (entrenar si no existe)
241
+ clf = train_model_if_needed()
242
+ board = game.board()
243
+ rows = []
244
+ # Exporter para comentarios
245
+ exporter = chess.pgn.StringExporter(headers=True, comments=True, variations=False)
246
+
247
+ node = game
248
+ ply = 0
249
+ while node.variations:
250
+ move = node.variation(0).move
251
+ turn_white = board.turn
252
+ # eval antes
253
+ info_b = engine.analyse(board, chess.engine.Limit(time=time_per_move, depth=None if depth_limit<=0 else depth_limit))
254
+ eval_b = score_cp(info_b["score"].pov(chess.WHITE))
255
+
256
+ feats = basic_features(board)
257
+ feats["eval_before_cp"] = eval_b
258
+ X = np.array([[feats.get(k,0.0) for k in FEATURE_ORDER]], dtype=float)
259
+ pred = clf.predict(X)[0]
260
+
261
+ # mejor SAN propuesto (primera PV)
262
+ best_move = info_b.get("pv", [move])[0]
263
+ best_san = board.san(best_move) if best_move else None
264
+
265
+ played_san = board.san(move)
266
+ board.push(move)
267
+
268
+ info_a = engine.analyse(board, chess.engine.Limit(time=time_per_move, depth=None if depth_limit<=0 else depth_limit))
269
+ eval_a = score_cp(info_a["score"].pov(chess.WHITE))
270
+
271
+ if turn_white:
272
+ delta = eval_a - eval_b
273
+ else:
274
+ delta = -(eval_a - eval_b)
275
+
276
+ text = explain(pred, delta, feats["in_check"], feats["hanging_w"], feats["hanging_b"])
277
+ # Añadir comentario al nodo siguiente
278
+ node = node.variation(0)
279
+ node.comment = (node.comment + " " if node.comment else "") + f"[{pred}] Δ={round(delta,1)} | Mejor: {best_san}. {text}"
280
+
281
+ ply += 1
282
+ rows.append({
283
+ "ply": ply,
284
+ "turn": "White" if turn_white else "Black",
285
+ "played": played_san,
286
+ "best": best_san,
287
+ "eval_before_cp": round(eval_b,1),
288
+ "eval_after_cp": round(eval_a,1),
289
+ "delta_cp": round(delta,1),
290
+ "ml_label": pred,
291
+ "explanation": text
292
+ })
293
+
294
+ annotated = game.accept(exporter)
295
+ finally:
296
+ engine.quit()
297
+
298
+ # Resumen rápido
299
+ worst = sorted(rows, key=lambda r: r["delta_cp"])[:10]
300
+ md = ["### Principales caídas de evaluación",
301
+ "| Ply | Turno | Jugada | Mejor | Δcp | ML |",
302
+ "|---:|:---:|:---|:---|---:|:---|"]
303
+ for r in worst:
304
+ md.append(f"| {r['ply']} | {r['turn']} | {r['played']} | {r.get('best','')} | {r['delta_cp']} | {r['ml_label']} |")
305
+ summary_md = "\n".join(md)
306
+
307
+ df = pd.DataFrame(rows)
308
+ return annotated, summary_md, df.to_csv(index=False)
309
+
310
+ # ==============================
311
+ # Interfaz Gradio
312
+ # ==============================
313
+ with gr.Blocks(title=APP_TITLE) as demo:
314
+ gr.Markdown(f"# {APP_TITLE}\n**Motor + Machine Learning**\n1) Entrenamiento rápido en el arranque (RandomForest).\n2) Analiza tu PGN y comenta jugada por jugada (estilo DecodeChess).\n3) Descarga el PGN anotado y el CSV de jugadas.")
315
+
316
+ eg = gr.Examples(
317
+ examples=[
318
+ ["[Event \"Ejemplo\"]\n[Site \"?\"]\n[Date \"2024.??.??\"]\n[Round \"?\"]\n[White \"Alice\"]\n[Black \"Bob\"]\n[Result \"1-0\"]\n\n1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 4. Ba4 Nf6 5. O-O Be7 6. Re1 b5 7. Bb3 O-O 8. c3 d5 9. exd5 Nxd5 10. Nxe5 Nxe5 11. Rxe5 c6 12. d4 Bd6 13. Re1 Qh4 14. g3 Qh3 15. Qf3 Bg4 16. Qg2 Rae8 17. Be3 Qh5 18. Nd2 Bh3 19. Qf3 Bg4 20. Qg2 f5 21. Bxd5+ cxd5 22. Qxd5+ Kh8 23. Qxd6 f4 24. Bxf4 Bf3 25. Rxe8 Rxe8 26. Be5 Qh3 27. Nxf3 Qf5 28. Qc6 Rf8 29. Qb7 Qg6 30. Nh4 Qc2 31. Qxg7# 1-0"],
319
+ inputs=[gr.Textbox(visible=False)] # solo para pre-cargar
320
+ )
321
+
322
+ pgn_in = gr.Textbox(lines=18, label="PGN (pega tu partida aquí)", placeholder="Pega aquí tu PGN...")
323
+
324
+ with gr.Row():
325
+ tpm = gr.Slider(0.05, 0.5, value=0.2, step=0.05, label="Tiempo de análisis por jugada (seg)")
326
+ depth = gr.Slider(0, 30, value=0, step=1, label="Profundidad máxima (0 = solo por tiempo)")
327
+
328
+ run = gr.Button("Analizar con IA")
329
+
330
+ annotated_out = gr.Textbox(lines=12, label="PGN anotado por IA")
331
+ summary_out = gr.Markdown(label="Resumen IA")
332
+ files_out = gr.Files(label="Descargas (PGN + CSV)")
333
+
334
+ def _analyze(pgn_text, t, d):
335
+ try:
336
+ annotated, summary_md, csv_txt = analyze_pgn(pgn_text, time_per_move=t, depth_limit=d)
337
+ files = []
338
+ if annotated:
339
+ open("anotado.pgn","w",encoding="utf-8").write(annotated)
340
+ files.append("anotado.pgn")
341
+ if csv_txt:
342
+ open("jugadas.csv","w",encoding="utf-8").write(csv_txt)
343
+ files.append("jugadas.csv")
344
+ return annotated, summary_md, files
345
+ except Exception as e:
346
+ tb = traceback.format_exc(limit=2)
347
+ return f"Error: {e}\n{tb}", "",""
348
+
349
+ run.click(_analyze, inputs=[pgn_in, tpm, depth], outputs=[annotated_out, summary_out, files_out])
350
+
351
+ if __name__ == "__main__":
352
+ # Entrenar en arranque (si no existe)
353
+ try:
354
+ _ = train_model_if_needed()
355
+ except Exception as e:
356
+ print("⚠️ Entrenamiento en arranque falló:", e)
357
+ demo.launch()
apt.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ stockfish
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ gradio>=4.44.0
2
+ python-chess>=1.999
3
+ numpy>=1.26
4
+ pandas>=2.2
5
+ scikit-learn>=1.3
6
+ joblib>=1.4
runtime.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ python-3.11