Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| """ | |
| Evaluation script for chess puzzle solving with Position2Move model. | |
| This script evaluates how well a trained model can solve chess puzzles by: | |
| 1. Loading a trained model checkpoint | |
| 2. Testing it on puzzles from the Lichess puzzle database | |
| 3. Measuring accuracy for first move, full solution, and by rating/theme | |
| 4. Generating detailed reports | |
| Usage: | |
| python src/chesstransformer/utils/evaluate_puzzles.py --model data/models/puzzle_training/run_001/best_model.pth | |
| Options: | |
| --model: Path to model checkpoint (required) | |
| --puzzle-data: Path to puzzle .csv.zst file (default: data/lichess_db_puzzle.csv.zst) | |
| --num-puzzles: Number of puzzles to evaluate (default: 1000) | |
| --min-rating: Minimum puzzle rating (default: None) | |
| --max-rating: Maximum puzzle rating (default: None) | |
| --themes: Comma-separated puzzle themes to filter by (default: None) | |
| --output: Output JSON file for results (default: results/puzzle_eval_{timestamp}.json) | |
| """ | |
| from pathlib import Path | |
| from datetime import datetime | |
| import argparse | |
| import json | |
| import torch | |
| import chess | |
| from tqdm.auto import tqdm | |
| from chesstransformer.datasets.puzzle_dataset import LichessPuzzleFullSolutionDataset | |
| from chesstransformer.models.transformer.position2move import Position2MoveModel | |
| from chesstransformer.models.tokenizer.position_tokenizer import PostionTokenizer | |
| from chesstransformer.models.tokenizer.move_tokenizer import MoveTokenizer | |
| class PuzzleEvaluator: | |
| """Evaluates a Position2Move model on chess puzzles.""" | |
| def __init__(self, model, device="cpu"): | |
| self.model = model | |
| self.device = device | |
| self.model.to(device) | |
| self.model.eval() | |
| self.position_tokenizer = PostionTokenizer() | |
| self.move_tokenizer = MoveTokenizer() | |
| def predict_move(self, board: chess.Board, top_k=5): | |
| """ | |
| Predict the best move for a given board position. | |
| Args: | |
| board: chess.Board object | |
| top_k: Return top-k predictions | |
| Returns: | |
| List of (move_uci, probability, is_legal) tuples | |
| """ | |
| # Encode position | |
| position_tokens = self.position_tokenizer.encode(board) | |
| position_tensor = torch.tensor(position_tokens, dtype=torch.long).unsqueeze(0).to(self.device) | |
| is_white = torch.tensor([board.turn == chess.WHITE], dtype=torch.long).to(self.device) | |
| # Get predictions | |
| with torch.no_grad(): | |
| logits = self.model(position_tensor, is_white) | |
| # Create legal moves mask | |
| legal_moves = [m.uci() for m in board.legal_moves] | |
| legal_move_indices = [] | |
| for move_uci in legal_moves: | |
| try: | |
| idx = self.move_tokenizer.encode(move_uci) | |
| legal_move_indices.append(idx) | |
| except ValueError: | |
| continue | |
| # Apply mask to logits (set illegal moves to -inf before softmax) | |
| mask = torch.full_like(logits, float("-inf")) | |
| if legal_move_indices: | |
| mask[0, legal_move_indices] = 0 | |
| masked_logits = logits + mask | |
| probs = torch.softmax(masked_logits, dim=-1) | |
| # Get top-k predictions | |
| top_probs, top_indices = torch.topk(probs[0], k=top_k) | |
| predictions = [] | |
| for prob, idx in zip(top_probs.cpu().numpy(), top_indices.cpu().numpy()): | |
| try: | |
| move_uci = self.move_tokenizer.decode(int(idx)) | |
| is_legal = move_uci in legal_moves | |
| predictions.append((move_uci, float(prob), is_legal)) | |
| except ValueError: | |
| continue | |
| return predictions | |
| def evaluate_puzzle(self, puzzle_data, max_moves=10): | |
| """ | |
| Evaluate a single puzzle. | |
| Args: | |
| puzzle_data: Dictionary with puzzle information | |
| max_moves: Maximum number of moves to try in the solution | |
| Returns: | |
| Dictionary with evaluation results | |
| """ | |
| board = chess.Board(puzzle_data["fen"]) | |
| solution_moves = puzzle_data["moves_uci"] | |
| results = { | |
| "puzzle_id": puzzle_data["puzzle_id"], | |
| "rating": puzzle_data["rating"], | |
| "themes": puzzle_data["themes"], | |
| "solution_length": len(solution_moves), | |
| "moves_tried": [], | |
| "first_move_correct": False, | |
| "fully_solved": False, | |
| "moves_correct": 0, | |
| "predictions": [], | |
| } | |
| # Try to solve the puzzle move by move | |
| for move_idx in range(0, len(solution_moves), 2): # Only predict our moves (every other move) | |
| if move_idx >= max_moves: | |
| break | |
| # Apply opponent's move first (if not the first move) | |
| if move_idx > 0: | |
| opponent_move = chess.Move.from_uci(solution_moves[move_idx - 1]) | |
| if opponent_move in board.legal_moves: | |
| board.push(opponent_move) | |
| else: | |
| # Opponent move is illegal - puzzle data might be corrupted | |
| results["error"] = "Illegal opponent move in solution" | |
| break | |
| # Predict our move | |
| expected_move = solution_moves[move_idx] | |
| predictions = self.predict_move(board, top_k=5) | |
| if not predictions: | |
| results["error"] = "Model produced no valid predictions" | |
| break | |
| predicted_move = predictions[0][0] | |
| predicted_prob = predictions[0][1] | |
| is_correct = predicted_move == expected_move | |
| results["moves_tried"].append( | |
| { | |
| "move_number": move_idx // 2 + 1, | |
| "expected": expected_move, | |
| "predicted": predicted_move, | |
| "probability": predicted_prob, | |
| "correct": is_correct, | |
| "top_5_predictions": [{"move": m, "prob": p, "legal": l} for m, p, l in predictions], | |
| } | |
| ) | |
| if is_correct: | |
| results["moves_correct"] += 1 | |
| if move_idx == 0: | |
| results["first_move_correct"] = True | |
| # Apply our correct move | |
| our_move = chess.Move.from_uci(predicted_move) | |
| board.push(our_move) | |
| else: | |
| # Wrong move - puzzle failed | |
| break | |
| # Check if fully solved | |
| if results["moves_correct"] == (len(solution_moves) + 1) // 2: | |
| results["fully_solved"] = True | |
| return results | |
| def evaluate_dataset(self, dataset, num_puzzles=None): | |
| """ | |
| Evaluate the model on a puzzle dataset. | |
| Args: | |
| dataset: LichessPuzzleFullSolutionDataset | |
| num_puzzles: Number of puzzles to evaluate (None = all) | |
| Returns: | |
| Dictionary with aggregate results | |
| """ | |
| num_puzzles = min(num_puzzles or len(dataset), len(dataset)) | |
| print(f"Evaluating {num_puzzles} puzzles...") | |
| results = { | |
| "num_puzzles": num_puzzles, | |
| "first_move_accuracy": 0, | |
| "full_solution_accuracy": 0, | |
| "average_moves_correct": 0, | |
| "by_rating": {}, | |
| "by_theme": {}, | |
| "puzzle_results": [], | |
| } | |
| first_move_correct = 0 | |
| fully_solved = 0 | |
| total_moves_correct = 0 | |
| total_moves = 0 | |
| # Track by rating buckets | |
| rating_buckets = { | |
| "0-1000": {"first": 0, "full": 0, "total": 0}, | |
| "1000-1500": {"first": 0, "full": 0, "total": 0}, | |
| "1500-2000": {"first": 0, "full": 0, "total": 0}, | |
| "2000-2500": {"first": 0, "full": 0, "total": 0}, | |
| "2500+": {"first": 0, "full": 0, "total": 0}, | |
| } | |
| # Track by theme | |
| theme_stats = {} | |
| for i in tqdm(range(num_puzzles), desc="Evaluating puzzles"): | |
| puzzle_data = dataset[i] | |
| puzzle_result = self.evaluate_puzzle(puzzle_data) | |
| # Aggregate statistics | |
| if puzzle_result["first_move_correct"]: | |
| first_move_correct += 1 | |
| if puzzle_result["fully_solved"]: | |
| fully_solved += 1 | |
| total_moves_correct += puzzle_result["moves_correct"] | |
| total_moves += puzzle_result["solution_length"] // 2 + 1 | |
| # Rating bucket | |
| rating = puzzle_result["rating"] | |
| if rating < 1000: | |
| bucket = "0-1000" | |
| elif rating < 1500: | |
| bucket = "1000-1500" | |
| elif rating < 2000: | |
| bucket = "1500-2000" | |
| elif rating < 2500: | |
| bucket = "2000-2500" | |
| else: | |
| bucket = "2500+" | |
| rating_buckets[bucket]["total"] += 1 | |
| if puzzle_result["first_move_correct"]: | |
| rating_buckets[bucket]["first"] += 1 | |
| if puzzle_result["fully_solved"]: | |
| rating_buckets[bucket]["full"] += 1 | |
| # Theme statistics | |
| for theme in puzzle_result["themes"]: | |
| if theme not in theme_stats: | |
| theme_stats[theme] = {"first": 0, "full": 0, "total": 0} | |
| theme_stats[theme]["total"] += 1 | |
| if puzzle_result["first_move_correct"]: | |
| theme_stats[theme]["first"] += 1 | |
| if puzzle_result["fully_solved"]: | |
| theme_stats[theme]["full"] += 1 | |
| results["puzzle_results"].append(puzzle_result) | |
| # Calculate aggregate metrics | |
| results["first_move_accuracy"] = 100.0 * first_move_correct / num_puzzles | |
| results["full_solution_accuracy"] = 100.0 * fully_solved / num_puzzles | |
| results["average_moves_correct"] = total_moves_correct / num_puzzles | |
| results["move_accuracy"] = 100.0 * total_moves_correct / total_moves if total_moves > 0 else 0 | |
| # Rating bucket statistics | |
| for bucket, stats in rating_buckets.items(): | |
| if stats["total"] > 0: | |
| results["by_rating"][bucket] = { | |
| "count": stats["total"], | |
| "first_move_accuracy": 100.0 * stats["first"] / stats["total"], | |
| "full_solution_accuracy": 100.0 * stats["full"] / stats["total"], | |
| } | |
| # Theme statistics (top 20 themes by frequency) | |
| sorted_themes = sorted(theme_stats.items(), key=lambda x: x[1]["total"], reverse=True)[:20] | |
| for theme, stats in sorted_themes: | |
| results["by_theme"][theme] = { | |
| "count": stats["total"], | |
| "first_move_accuracy": 100.0 * stats["first"] / stats["total"], | |
| "full_solution_accuracy": 100.0 * stats["full"] / stats["total"], | |
| } | |
| return results | |
| def main(): | |
| parser = argparse.ArgumentParser(description="Evaluate Position2Move model on chess puzzles") | |
| parser.add_argument("--model", type=str, required=True, help="Path to model checkpoint") | |
| parser.add_argument( | |
| "--puzzle-data", type=str, default="data/lichess_db_puzzle.csv.zst", help="Path to puzzle .csv.zst file" | |
| ) | |
| parser.add_argument("--num-puzzles", type=int, default=1000, help="Number of puzzles to evaluate") | |
| parser.add_argument("--min-rating", type=int, default=None, help="Minimum puzzle rating") | |
| parser.add_argument("--max-rating", type=int, default=None, help="Maximum puzzle rating") | |
| parser.add_argument("--themes", type=str, default=None, help="Comma-separated puzzle themes to filter by") | |
| parser.add_argument("--output", type=str, default=None, help="Output JSON file for results") | |
| args = parser.parse_args() | |
| # Parse themes | |
| themes = args.themes.split(",") if args.themes else None | |
| # Setup device | |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| print(f"Using device: {device}") | |
| # Load model | |
| print(f"\nLoading model from {args.model}...") | |
| model_path = Path(args.model) | |
| # Support both .pth and .safetensors formats | |
| if model_path.suffix == ".safetensors": | |
| # Load config from same directory | |
| from safetensors import safe_open | |
| import json | |
| config_path = model_path.parent / "config.json" | |
| with open(config_path, "r") as f: | |
| config = json.load(f) | |
| model = Position2MoveModel(**config) | |
| # Load weights from safetensors | |
| with safe_open(str(model_path), framework="pt", device=str(device)) as f: | |
| state_dict = {k: f.get_tensor(k) for k in f.keys()} | |
| # Handle compiled model prefix (_orig_mod.) | |
| if any(k.startswith("_orig_mod.") for k in state_dict.keys()): | |
| state_dict = {k.replace("_orig_mod.", ""): v for k, v in state_dict.items()} | |
| model.load_state_dict(state_dict) | |
| print(f"Model loaded from safetensors") | |
| else: | |
| # Load from .pth checkpoint | |
| checkpoint = torch.load(args.model, map_location=device, weights_only=False) | |
| config = checkpoint["config"] | |
| model = Position2MoveModel(**config) | |
| model.load_state_dict(checkpoint["model_state_dict"]) | |
| print(f"Model loaded (epoch {checkpoint.get('epoch', 'unknown')})") | |
| # Load puzzle dataset | |
| print(f"\nLoading puzzle dataset...") | |
| dataset = LichessPuzzleFullSolutionDataset( | |
| puzzle_path=args.puzzle_data, | |
| min_rating=args.min_rating, | |
| max_rating=args.max_rating, | |
| themes=themes, | |
| max_puzzles=args.num_puzzles, | |
| ) | |
| # Evaluate | |
| print("\n" + "=" * 70) | |
| print("Starting evaluation") | |
| print("=" * 70) | |
| evaluator = PuzzleEvaluator(model, device=device) | |
| results = evaluator.evaluate_dataset(dataset, num_puzzles=args.num_puzzles) | |
| # Print summary | |
| print("\n" + "=" * 70) | |
| print("EVALUATION RESULTS") | |
| print("=" * 70) | |
| print(f"Total puzzles: {results['num_puzzles']}") | |
| print(f"First move accuracy: {results['first_move_accuracy']:.2f}%") | |
| print(f"Full solution accuracy: {results['full_solution_accuracy']:.2f}%") | |
| print(f"Move accuracy: {results['move_accuracy']:.2f}%") | |
| print(f"Average moves correct: {results['average_moves_correct']:.2f}") | |
| print("\n" + "-" * 70) | |
| print("By Rating:") | |
| print("-" * 70) | |
| for rating, stats in sorted(results["by_rating"].items()): | |
| print( | |
| f" {rating:>12}: {stats['count']:4} puzzles | " | |
| f"First: {stats['first_move_accuracy']:5.2f}% | " | |
| f"Full: {stats['full_solution_accuracy']:5.2f}%" | |
| ) | |
| print("\n" + "-" * 70) | |
| print("Top Themes:") | |
| print("-" * 70) | |
| for theme, stats in list(results["by_theme"].items())[:10]: | |
| print( | |
| f" {theme:>20}: {stats['count']:4} puzzles | " | |
| f"First: {stats['first_move_accuracy']:5.2f}% | " | |
| f"Full: {stats['full_solution_accuracy']:5.2f}%" | |
| ) | |
| # Save results | |
| if args.output: | |
| output_path = args.output | |
| else: | |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") | |
| output_path = f"results/puzzle_eval_{timestamp}.json" | |
| output_path = Path(output_path) | |
| output_path.parent.mkdir(parents=True, exist_ok=True) | |
| # Don't save individual puzzle results to keep file size manageable | |
| results_summary = {k: v for k, v in results.items() if k != "puzzle_results"} | |
| results_summary["num_puzzles_detailed"] = len(results["puzzle_results"]) | |
| with open(output_path, "w") as f: | |
| json.dump(results_summary, f, indent=2) | |
| print(f"\nResults saved to {output_path}") | |
| print("=" * 70) | |
| if __name__ == "__main__": | |
| main() | |