doctorlinux commited on
Commit
841e63a
·
verified ·
1 Parent(s): ff323e1

Upload 5 files

Browse files
Files changed (3) hide show
  1. README.md +3 -3
  2. app.py +113 -103
  3. requirements.txt +1 -0
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: DecodeChess-IA by Doctor Linux
3
  emoji: ♟️
4
  colorFrom: blue
5
  colorTo: green
@@ -9,5 +9,5 @@ app_file: app.py
9
  pinned: false
10
  ---
11
 
12
- # DecodeChess-IA — Doctor Linux
13
- Analizador de partidas con **Motor + Machine Learning**. Pega tu PGN y pulsa **Analizar con IA**.
 
1
  ---
2
+ title: DecodeChess-IA with Graphs (Images) — Doctor Linux
3
  emoji: ♟️
4
  colorFrom: blue
5
  colorTo: green
 
9
  pinned: false
10
  ---
11
 
12
+ # DecodeChess-IA (gráficos en imagen)
13
+ Esta versión renderiza los gráficos como **imágenes PNG** para máxima compatibilidad en Hugging Face Spaces.
app.py CHANGED
@@ -14,15 +14,15 @@ from joblib import dump, load
14
  from sklearn.ensemble import RandomForestClassifier
15
  import chess, chess.pgn, chess.engine
16
 
17
- APP_TITLE = "DecodeChess-IA — Doctor Linux"
18
- ENGINE_CANDIDATES = [
19
- "stockfish",
20
- "/usr/bin/stockfish",
21
- "/usr/games/stockfish",
22
- "/bin/stockfish",
23
- "/usr/local/bin/stockfish",
24
- ]
25
 
 
 
 
 
26
  def load_engine():
27
  path = shutil.which("stockfish")
28
  if path:
@@ -50,33 +50,31 @@ def score_cp(score_obj) -> float:
50
  return 100000.0 if m > 0 else -100000.0
51
  return float(pov.score(mate_score=100000))
52
 
 
53
  def repair_pgn_text(text: str) -> str:
54
- text = text.replace("\r\n", "\n").replace("\r", "\n")
55
  if "[Event " not in text:
56
  head = ['[Event "?"]','[Site "?"]','[Date "????.??.??"]','[Round "?"]','[White "?"]','[Black "?"]','[Result "*"]','','']
57
  text = "\n".join(head) + text
58
- text = re.sub(r'\[Ulnite','[White', text)
59
- 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"]')
60
- return text.strip() + "\n"
61
 
62
  PIECE_VALUES = {chess.PAWN:1, chess.KNIGHT:3, chess.BISHOP:3, chess.ROOK:5, chess.QUEEN:9}
63
  CENTER = [chess.D4, chess.E4, chess.D5, chess.E5]
64
 
65
  def material_eval(board: chess.Board):
66
- s = 0
67
- for pt, v in PIECE_VALUES.items():
68
- s += len(board.pieces(pt, chess.WHITE))*v
69
- s -= len(board.pieces(pt, chess.BLACK))*v
70
  return s
71
 
72
- def mobility(board: chess.Board):
73
- return board.legal_moves.count()
74
 
75
  def hanging_pieces(board: chess.Board, color=chess.WHITE):
76
- c = 0
77
  for sq in chess.SquareSet(board.occupied_co[color]):
78
  if board.is_attacked_by(not color, sq) and not board.is_attacked_by(color, sq):
79
- c += 1
80
  return c
81
 
82
  def basic_features(board: chess.Board):
@@ -95,74 +93,81 @@ FEATURE_ORDER = ["turn_white","mat_cp","mobility","hanging_w","hanging_b","cente
95
 
96
  def delta_to_label(delta_cp: float) -> str:
97
  drop = -delta_cp
98
- if drop < 20: return "Best"
99
- if drop < 60: return "Good"
100
- if drop < 120: return "Inaccuracy"
101
- if drop < 300: return "Mistake"
102
  return "Blunder"
103
 
104
- def generate_training(engine, games=16, plies_per_game=30, time_per=0.05):
105
- rows = []
 
106
  for _ in range(games):
107
  board = chess.Board()
108
  for _ in range(plies_per_game):
109
- if board.is_game_over():
110
- break
111
  info_b = engine.analyse(board, chess.engine.Limit(time=time_per))
112
  eval_b = score_cp(info_b["score"])
113
  legal = list(board.legal_moves)
114
- if not legal:
115
- break
116
  move = random.choice(legal)
117
- feats = basic_features(board)
118
- feats["eval_before_cp"] = eval_b
119
  board.push(move)
120
  info_a = engine.analyse(board, chess.engine.Limit(time=time_per))
121
  eval_a = score_cp(info_a["score"])
122
  delta = eval_a - eval_b if feats["turn_white"] else -(eval_a - eval_b)
123
- row = {k: feats.get(k, 0.0) for k in FEATURE_ORDER}
124
- row["delta_cp"] = delta
125
- row["label"] = delta_to_label(delta)
126
  rows.append(row)
127
  return pd.DataFrame(rows)
128
 
129
  def train_model_if_needed():
130
  if os.path.exists("model_rf.joblib"):
131
- try:
132
- return load("model_rf.joblib")
133
- except Exception:
134
- pass
135
  eng = load_engine()
136
- try:
137
- df = generate_training(eng, games=16, plies_per_game=28, time_per=0.05)
138
- finally:
139
- eng.quit()
140
- X = df[FEATURE_ORDER].astype(float)
141
- y = df["label"]
142
  clf = RandomForestClassifier(n_estimators=120, random_state=42, n_jobs=-1)
143
- clf.fit(X, y)
144
- dump(clf, "model_rf.joblib")
145
- return clf
146
 
 
147
  def explain(label, delta_cp, in_check, hanging_w, hanging_b):
148
- tips = []
149
- if label == "Blunder":
150
- tips.append("Error grave; posible táctica inmediata o pieza colgando.")
151
- elif label == "Mistake":
152
- tips.append("Movimiento impreciso que cede ventaja notable.")
153
- elif label == "Inaccuracy":
154
- tips.append("Había alternativas más fuertes según el motor.")
155
- elif label == "Good":
156
- tips.append("Jugada sólida; mantiene la evaluación.")
157
- else:
158
- tips.append("Excelente jugada; muy precisa.")
159
- if in_check:
160
- tips.append("El rey estaba en jaque o vulnerable.")
161
- if hanging_w or hanging_b:
162
- tips.append("Detecto pieza(s) atacadas sin defensa.")
163
  tips.append(f"Δ={round(delta_cp,1)} cp.")
164
  return " ".join(tips)
165
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  def analyze_pgn(pgn_text: str, time_per_move=0.2):
167
  pgn_text = repair_pgn_text(pgn_text)
168
  f = StringIO(pgn_text)
@@ -170,77 +175,82 @@ def analyze_pgn(pgn_text: str, time_per_move=0.2):
170
  while game is not None and sum(1 for _ in game.mainline_moves()) == 0:
171
  game = chess.pgn.read_game(f)
172
  if game is None:
173
- return None, None, "PGN vacío o inválido."
 
174
  engine = load_engine()
175
  model = train_model_if_needed()
176
  board = game.board()
177
- rows = []
 
178
  exporter = chess.pgn.StringExporter(headers=True, comments=True, variations=False)
179
- node = game
180
- ply = 0
181
  while node.variations:
182
  move = node.variation(0).move
183
  turn_white = board.turn
184
- info_b = engine.analyse(board, chess.engine.Limit(time=time_per_move))
185
- eval_b = score_cp(info_b["score"])
186
- feats = basic_features(board)
187
- feats["eval_before_cp"] = eval_b
188
- X = np.array([[feats.get(k, 0.0) for k in FEATURE_ORDER]], dtype=float)
189
  label = model.predict(X)[0]
190
- best_move = info_b.get("pv", [move])[0]
191
- best_san = board.san(best_move) if best_move else ""
192
- played_san = board.san(move)
193
- board.push(move)
194
- info_a = engine.analyse(board, chess.engine.Limit(time=time_per_move))
195
- eval_a = score_cp(info_a["score"])
196
  delta = eval_a - eval_b if turn_white else -(eval_a - eval_b)
 
 
197
  text = explain(label, delta, feats["in_check"], feats["hanging_w"], feats["hanging_b"])
198
- node = node.variation(0)
199
- node.comment = f"[{label}] Δ={round(delta,1)} | Mejor: {best_san}. {text}"
200
- ply += 1
201
- rows.append({
202
- "ply": ply,
203
- "turn": "White" if turn_white else "Black",
204
- "played": played_san,
205
- "best": best_san,
206
- "eval_before_cp": round(eval_b,1),
207
- "eval_after_cp": round(eval_a,1),
208
- "delta_cp": round(delta,1),
209
- "ml_label": label,
210
- "explanation": text,
211
- })
212
  engine.quit()
 
213
  annotated = game.accept(exporter)
214
- import pandas as pd
215
  df = pd.DataFrame(rows)
216
- md = ["### Principales errores", "| Ply | Turno | Jugada | Δcp | ML |", "|---:|:---:|:---|---:|:---|"]
217
  for r in sorted(rows, key=lambda r: r["delta_cp"])[:10]:
218
  md.append(f"| {r['ply']} | {r['turn']} | {r['played']} | {r['delta_cp']} | {r['ml_label']} |")
219
- return annotated, "\n".join(md), df.to_csv(index=False)
220
 
 
 
 
 
 
 
221
  with gr.Blocks(title=APP_TITLE) as demo:
222
- gr.Markdown(f"# {APP_TITLE}\nMotor + Machine Learning en acción 🧠♟️\nSube o pega tu partida PGN para analizarla.")
 
223
  eg = gr.Examples(
224
- examples=[[
225
- "[Event \"Demo\"]\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"
226
- ]],
227
  inputs=[gr.Textbox(visible=False)],
228
  )
 
229
  pgn_in = gr.Textbox(lines=18, label="PGN (pega tu partida aquí)")
230
  run = gr.Button("Analizar con IA")
 
 
 
 
 
231
  annotated_out = gr.Textbox(lines=10, label="PGN anotado")
232
  summary_out = gr.Markdown(label="Resumen IA")
233
  files_out = gr.Files(label="Descargas (PGN + CSV)")
 
234
  def _analyze(pgn_text):
235
  try:
236
- annotated, md, csv_text = analyze_pgn(pgn_text)
237
  open("anotado.pgn","w",encoding="utf-8").write(annotated if annotated else "")
238
  open("jugadas.csv","w",encoding="utf-8").write(csv_text if csv_text else "")
239
- return annotated, md, ["anotado.pgn","jugadas.csv"]
240
  except Exception as e:
241
  tb = traceback.format_exc(limit=2)
242
- return f"Error: {e}\n{tb}", "", []
243
- run.click(_analyze, inputs=[pgn_in], outputs=[annotated_out, summary_out, files_out])
 
244
 
245
  if __name__ == "__main__":
246
  try:
 
14
  from sklearn.ensemble import RandomForestClassifier
15
  import chess, chess.pgn, chess.engine
16
 
17
+ # Graficos como imagen
18
+ import matplotlib
19
+ matplotlib.use("Agg")
20
+ import matplotlib.pyplot as plt
 
 
 
 
21
 
22
+ APP_TITLE = "DecodeChess-IA — Doctor Linux (gráficos en imagen)"
23
+ ENGINE_CANDIDATES = ["stockfish","/usr/bin/stockfish","/usr/games/stockfish","/bin/stockfish","/usr/local/bin/stockfish"]
24
+
25
+ # ---------------- Motor ----------------
26
  def load_engine():
27
  path = shutil.which("stockfish")
28
  if path:
 
50
  return 100000.0 if m > 0 else -100000.0
51
  return float(pov.score(mate_score=100000))
52
 
53
+ # ---------------- PGN & features ----------------
54
  def repair_pgn_text(text: str) -> str:
55
+ text = text.replace("\r\n","\n").replace("\r","\n")
56
  if "[Event " not in text:
57
  head = ['[Event "?"]','[Site "?"]','[Date "????.??.??"]','[Round "?"]','[White "?"]','[Black "?"]','[Result "*"]','','']
58
  text = "\n".join(head) + text
59
+ return text.strip()+"\n"
 
 
60
 
61
  PIECE_VALUES = {chess.PAWN:1, chess.KNIGHT:3, chess.BISHOP:3, chess.ROOK:5, chess.QUEEN:9}
62
  CENTER = [chess.D4, chess.E4, chess.D5, chess.E5]
63
 
64
  def material_eval(board: chess.Board):
65
+ s=0
66
+ for pt,v in PIECE_VALUES.items():
67
+ s+=len(board.pieces(pt,chess.WHITE))*v
68
+ s-=len(board.pieces(pt,chess.BLACK))*v
69
  return s
70
 
71
+ def mobility(board: chess.Board): return board.legal_moves.count()
 
72
 
73
  def hanging_pieces(board: chess.Board, color=chess.WHITE):
74
+ c=0
75
  for sq in chess.SquareSet(board.occupied_co[color]):
76
  if board.is_attacked_by(not color, sq) and not board.is_attacked_by(color, sq):
77
+ c+=1
78
  return c
79
 
80
  def basic_features(board: chess.Board):
 
93
 
94
  def delta_to_label(delta_cp: float) -> str:
95
  drop = -delta_cp
96
+ if drop < 20: return "Best"
97
+ if drop < 60: return "Good"
98
+ if drop < 120: return "Inaccuracy"
99
+ if drop < 300: return "Mistake"
100
  return "Blunder"
101
 
102
+ # ---------------- Entrenamiento rápido ----------------
103
+ def generate_training(engine, games=14, plies_per_game=24, time_per=0.05):
104
+ rows=[]
105
  for _ in range(games):
106
  board = chess.Board()
107
  for _ in range(plies_per_game):
108
+ if board.is_game_over(): break
 
109
  info_b = engine.analyse(board, chess.engine.Limit(time=time_per))
110
  eval_b = score_cp(info_b["score"])
111
  legal = list(board.legal_moves)
112
+ if not legal: break
 
113
  move = random.choice(legal)
114
+ feats = basic_features(board); feats["eval_before_cp"]=eval_b
 
115
  board.push(move)
116
  info_a = engine.analyse(board, chess.engine.Limit(time=time_per))
117
  eval_a = score_cp(info_a["score"])
118
  delta = eval_a - eval_b if feats["turn_white"] else -(eval_a - eval_b)
119
+ row = {k: feats.get(k,0.0) for k in FEATURE_ORDER}
120
+ row["delta_cp"]=delta; row["label"]=delta_to_label(delta)
 
121
  rows.append(row)
122
  return pd.DataFrame(rows)
123
 
124
  def train_model_if_needed():
125
  if os.path.exists("model_rf.joblib"):
126
+ try: return load("model_rf.joblib")
127
+ except Exception: pass
 
 
128
  eng = load_engine()
129
+ try: df = generate_training(eng)
130
+ finally: eng.quit()
131
+ X = df[FEATURE_ORDER].astype(float); y=df["label"]
 
 
 
132
  clf = RandomForestClassifier(n_estimators=120, random_state=42, n_jobs=-1)
133
+ clf.fit(X,y); dump(clf,"model_rf.joblib"); return clf
 
 
134
 
135
+ # ---------------- Explicaciones ----------------
136
  def explain(label, delta_cp, in_check, hanging_w, hanging_b):
137
+ tips=[]
138
+ if label=="Blunder": tips.append("Error grave; posible táctica o pieza colgando.")
139
+ elif label=="Mistake": tips.append("Cede ventaja significativa.")
140
+ elif label=="Inaccuracy": tips.append("Había opciones mejores.")
141
+ elif label=="Good": tips.append("Jugada sólida.")
142
+ else: tips.append("Excelente jugada.")
143
+ if in_check: tips.append("Rey bajo ataque.")
144
+ if hanging_w or hanging_b: tips.append("Piezas atacadas sin defensa.")
 
 
 
 
 
 
 
145
  tips.append(f"Δ={round(delta_cp,1)} cp.")
146
  return " ".join(tips)
147
 
148
+ # ---------------- Gráficos (guardar como imagen) ----------------
149
+ def save_eval_plot(eval_series, title):
150
+ fig, ax = plt.subplots(figsize=(10,4))
151
+ if not eval_series:
152
+ ax.text(0.5,0.5,"Sin datos",ha="center",va="center",transform=ax.transAxes); ax.axis("off")
153
+ else:
154
+ xs = list(range(1,len(eval_series)+1))
155
+ ax.plot(xs, eval_series, linewidth=2)
156
+ ax.axhline(0, linestyle="--", linewidth=1)
157
+ ax.set_xlabel("Ply"); ax.set_ylabel("Centipawns"); ax.set_title(title); ax.grid(True, alpha=0.3)
158
+ fig.tight_layout()
159
+ out = "eval.png"; fig.savefig(out, dpi=120); plt.close(fig); return out
160
+
161
+ def save_errbars(labels):
162
+ fig, ax = plt.subplots(figsize=(6,4))
163
+ cats = ["Best","Good","Inaccuracy","Mistake","Blunder"]
164
+ counts = [sum(1 for l in labels if l==c) for c in cats]
165
+ ax.bar(cats, counts); ax.set_ylabel("Cantidad"); ax.set_title("Distribución por categoría ML")
166
+ for i,v in enumerate(counts): ax.text(i, v + (max(counts)*0.04 if counts else 0.2), str(v), ha="center", va="bottom")
167
+ fig.tight_layout()
168
+ out = "errors.png"; fig.savefig(out, dpi=120); plt.close(fig); return out
169
+
170
+ # ---------------- Análisis PGN ----------------
171
  def analyze_pgn(pgn_text: str, time_per_move=0.2):
172
  pgn_text = repair_pgn_text(pgn_text)
173
  f = StringIO(pgn_text)
 
175
  while game is not None and sum(1 for _ in game.mainline_moves()) == 0:
176
  game = chess.pgn.read_game(f)
177
  if game is None:
178
+ return None, None, None, None, "PGN vacío o inválido."
179
+
180
  engine = load_engine()
181
  model = train_model_if_needed()
182
  board = game.board()
183
+ rows=[]; eval_series=[]; labels=[]
184
+
185
  exporter = chess.pgn.StringExporter(headers=True, comments=True, variations=False)
186
+ node = game; ply=0
 
187
  while node.variations:
188
  move = node.variation(0).move
189
  turn_white = board.turn
190
+ info_b = engine.analyse(board, chess.engine.Limit(time=time_per_move)); eval_b = score_cp(info_b["score"])
191
+ feats = basic_features(board); feats["eval_before_cp"]=eval_b
192
+ import numpy as np
193
+ X = np.array([[feats.get(k,0.0) for k in FEATURE_ORDER]], dtype=float)
 
194
  label = model.predict(X)[0]
195
+
196
+ best_move = info_b.get("pv",[move])[0]; best_san = board.san(best_move) if best_move else ""
197
+ played_san = board.san(move); board.push(move)
198
+
199
+ info_a = engine.analyse(board, chess.engine.Limit(time=time_per_move)); eval_a = score_cp(info_a["score"])
 
200
  delta = eval_a - eval_b if turn_white else -(eval_a - eval_b)
201
+
202
+ labels.append(label); eval_series.append(eval_a)
203
  text = explain(label, delta, feats["in_check"], feats["hanging_w"], feats["hanging_b"])
204
+ node = node.variation(0); node.comment = f"[{label}] Δ={round(delta,1)} | Mejor: {best_san}. {text}"
205
+ ply+=1
206
+ rows.append({"ply":ply,"turn":"White" if turn_white else "Black","played":played_san,"best":best_san,
207
+ "eval_before_cp":round(eval_b,1),"eval_after_cp":round(eval_a,1),"delta_cp":round(delta,1),
208
+ "ml_label":label,"explanation":text})
 
 
 
 
 
 
 
 
 
209
  engine.quit()
210
+
211
  annotated = game.accept(exporter)
 
212
  df = pd.DataFrame(rows)
213
+ md = ["### Principales errores","| Ply | Turno | Jugada | Δcp | ML |","|---:|:---:|:---|---:|:---|"]
214
  for r in sorted(rows, key=lambda r: r["delta_cp"])[:10]:
215
  md.append(f"| {r['ply']} | {r['turn']} | {r['played']} | {r['delta_cp']} | {r['ml_label']} |")
216
+ summary_md = "\n".join(md)
217
 
218
+ eval_img = save_eval_plot(eval_series, f"Evaluación — {game.headers.get('White','?')} vs {game.headers.get('Black','?')}")
219
+ errs_img = save_errbars(labels)
220
+
221
+ return annotated, summary_md, df.to_csv(index=False), eval_img, errs_img
222
+
223
+ # ---------------- UI ----------------
224
  with gr.Blocks(title=APP_TITLE) as demo:
225
+ gr.Markdown(f"# {APP_TITLE}\n**Motor + ML con visualizaciones** (renderizadas como imágenes)\n- Gráfico de evaluación\n- Gráfico de categorías\n- PGN anotado y CSV")
226
+
227
  eg = gr.Examples(
228
+ examples=[["[Event \"Demo\"]\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"]],
 
 
229
  inputs=[gr.Textbox(visible=False)],
230
  )
231
+
232
  pgn_in = gr.Textbox(lines=18, label="PGN (pega tu partida aquí)")
233
  run = gr.Button("Analizar con IA")
234
+
235
+ with gr.Row():
236
+ eval_img = gr.Image(label="Evaluación (imagen)", interactive=False)
237
+ errs_img = gr.Image(label="Errores (imagen)", interactive=False)
238
+
239
  annotated_out = gr.Textbox(lines=10, label="PGN anotado")
240
  summary_out = gr.Markdown(label="Resumen IA")
241
  files_out = gr.Files(label="Descargas (PGN + CSV)")
242
+
243
  def _analyze(pgn_text):
244
  try:
245
+ annotated, md, csv_text, eval_png, errs_png = analyze_pgn(pgn_text)
246
  open("anotado.pgn","w",encoding="utf-8").write(annotated if annotated else "")
247
  open("jugadas.csv","w",encoding="utf-8").write(csv_text if csv_text else "")
248
+ return eval_png, errs_png, annotated, md, ["anotado.pgn","jugadas.csv"]
249
  except Exception as e:
250
  tb = traceback.format_exc(limit=2)
251
+ return None, None, f"Error: {e}\n{tb}", "", []
252
+
253
+ run.click(_analyze, inputs=[pgn_in], outputs=[eval_img, errs_img, annotated_out, summary_out, files_out])
254
 
255
  if __name__ == "__main__":
256
  try:
requirements.txt CHANGED
@@ -4,3 +4,4 @@ numpy>=1.26
4
  pandas>=2.2
5
  scikit-learn>=1.3
6
  joblib>=1.4
 
 
4
  pandas>=2.2
5
  scikit-learn>=1.3
6
  joblib>=1.4
7
+ matplotlib>=3.8