chessllm / app.py
mlabonne's picture
Update app.py
5091a94 verified
import io
import os
import re
import time
from collections import defaultdict
from datetime import datetime
import cairosvg
import chess
import chess.svg
import gistyc
import numpy as np
import outlines.models as models
from outlines import generate
import pandas as pd
import requests
from tqdm.auto import tqdm
from PIL import Image as PILImage
import gradio as gr
def generate_regex(board):
"""
Generate regular expression for legal moves.
Based on https://gist.github.com/903124/cfbefa24da95e2316e0d5e8ef8ed360d by @903124S.
"""
legal_moves = list(board.legal_moves)
move_strings = [board.san(move) for move in legal_moves]
move_strings = [re.sub(r"[+#]", "", move) for move in move_strings]
regex_pattern = "|".join(re.escape(move) for move in move_strings)
return regex_pattern
def write_pgn(
pgn_moves, model_id_white, model_id_black, result, time_budget, termination
):
# Get current UTC date and time
current_utc_datetime = datetime.utcnow()
utc_date = current_utc_datetime.strftime("%Y.%m.%d")
utc_time = current_utc_datetime.strftime("%H:%M:%S")
# Output the final PGN with CLKS and additional details
final_pgn = f"""[Event 'Chess LLM Arena']
[Site 'https://github.com/mlabonne/chessllm']
[Date '{utc_date}']
[White '{model_id_white}']
[Black '{model_id_black}']
[Result '{result}']
[Time '{utc_time}']
[TimeControl '{time_budget}+0']
[Termination '{termination}']
{pgn_moves}
"""
return final_pgn
def determine_termination(board, time_budget_white, time_budget_black):
if board.is_checkmate():
return "Checkmate"
elif board.is_stalemate():
return "Stalemate"
elif board.is_insufficient_material():
return "Draw due to insufficient material"
elif board.can_claim_threefold_repetition():
return "Draw by threefold repetition"
elif board.can_claim_fifty_moves():
return "Draw by fifty-move rule"
elif time_budget_white <= 0 or time_budget_black <= 0:
return "Timeout"
else:
return "Unknown"
def format_elapsed(seconds):
"""Formats elapsed time dynamically to hh:mm:ss, mm:ss, or ss format."""
hours, remainder = divmod(int(seconds), 3600)
minutes, seconds = divmod(remainder, 60)
if hours:
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
elif minutes:
return f"{minutes:02d}:{seconds:02d}"
else:
return f"{seconds:02d}"
def create_gif(image_list, gif_path, duration):
# Convert numpy arrays back to PIL images
pil_images = [PILImage.fromarray(image) for image in image_list]
# Save the images as a GIF
pil_images[0].save(
gif_path, save_all=True, append_images=pil_images[1:], duration=duration, loop=0
)
def render_init(board):
svg = chess.svg.board(board=board).encode("utf-8")
png = cairosvg.svg2png(bytestring=svg)
image = PILImage.open(io.BytesIO(png))
# Calculate the size of the new image
width, height = image.size
new_width = 3 * width
# Create a new blank image with the desired dimensions
new_image = PILImage.new('RGB', (width, height), 'white')
# Calculate the position to paste the chess board image
left = 0 # One third of the width
upper = 0
# Paste the chess board image into the new image
new_image.paste(image, (left, upper))
return new_image
def render_new(board):
last_move = board.peek()
svg = chess.svg.board(board=board, arrows=[(last_move.from_square, last_move.to_square)]).encode("utf-8")
png = cairosvg.svg2png(bytestring=svg)
image = PILImage.open(io.BytesIO(png))
# Calculate the size of the new image
width, height = image.size
new_width = 3 * width
# Create a new blank image with the desired dimensions
new_image = PILImage.new('RGB', (width, height), 'white')
# Calculate the position to paste the chess board image
left = 0 # One third of the width
upper = 0
# Paste the chess board image into the new image
new_image.paste(image, (left, upper))
return new_image
def save_result_file(
pgn_id, model_id_white, model_id_black, termination, result, auth_token, gist_id
):
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# Data to be written to the file
data_str = f"{pgn_id},{timestamp},{model_id_white},{model_id_black},{termination},{result}\n"
# Append data to a text file
with open("chessllm_results.csv", "a") as file:
file.write(data_str)
# Update the Gist
gist_api = gistyc.GISTyc(auth_token=GITHUB_TOKEN)
response_update_data = gist_api.update_gist(
file_name="chessllm_results.csv", gist_id=gist_id
)
def save_pgn(final_pgn, file_name, auth_token):
# Write final PGN to a file
with open(file_name + ".pgn", "w") as file:
file.write(final_pgn)
gist_api = gistyc.GISTyc(auth_token=GITHUB_TOKEN)
response_data = gist_api.create_gist(file_name=file_name + ".pgn")
print(response_data)
return response_data["id"]
def download_file(base_url, file_name):
"""
Helper function to download a file.
"""
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
url = f"{base_url}?ts={timestamp}"
headers = {
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0",
}
response = requests.get(url, headers=headers)
if response.status_code == 200:
with open(file_name, "wb") as file:
file.write(response.content)
else:
print(f"Failed to download file. HTTP status code: {response.status_code}")
def get_leaderboard():
"""
Wrapper to download the leaderboard and update the Gradio Dataframe.
"""
url = f"https://gist.githubusercontent.com/chessllm/{LEAD_GIST_ID}/raw"
download_file(url, "chessllm_leaderboard.csv")
return pd.read_csv("chessllm_leaderboard.csv")
def calculate_elo(rank1, rank2, result):
"""
Calculate the new ELO rating for a player.
:param rank1: The current ELO rating of player 1
:param rank2: The current ELO rating of player 2
:param result: 1 if player 1 wins, 0 if player 2 wins, 0.5 for a draw
:return: The updated ELO rating of player 1
"""
K = 32
expected_score1 = 1 / (1 + 10 ** ((rank2 - rank1) / 400))
new_rank1 = rank1 + K * (result - expected_score1)
return round(new_rank1)
def update_elo_ratings(chess_data):
"""
Update ELO ratings for each player based on the match results in the dataset.
:param chess_data: DataFrame with chess match results
:return: A dictionary with updated ELO ratings for each player
"""
elo_ratings = defaultdict(lambda: 1000) # Default ELO rating is 1000
for index, row in chess_data.iterrows():
if row["Result"] == "*":
continue # Skip ongoing games
model1 = row["Model1"]
model2 = row["Model2"]
result = row["Result"]
model1_elo = elo_ratings[model1]
model2_elo = elo_ratings[model2]
# Update ELO based on the result
if result == "1-0": # Model1 wins
elo_ratings[model1] = calculate_elo(model1_elo, model2_elo, 1)
elo_ratings[model2] = calculate_elo(model2_elo, model1_elo, 0)
elif result == "0-1": # Model2 wins
elo_ratings[model1] = calculate_elo(model1_elo, model2_elo, 0)
elo_ratings[model2] = calculate_elo(model2_elo, model1_elo, 1)
elif result == "1/2-1/2": # Draw
elo_ratings[model1] = calculate_elo(model1_elo, model2_elo, 0.5)
elo_ratings[model2] = calculate_elo(model2_elo, model1_elo, 0.5)
return elo_ratings
def update(model_id_white, model_id_black):
model_white = models.transformers(model_id_white)
model_black = models.transformers(model_id_black)
TIME_BUDGET = 180
prompt = '1.'
# Initialize the chess board
board = chess.Board()
board_images = []
pgn_moves = ""
move_number = 1
result = None
# Render first image
image = render_init(board)
yield image
# Time budget
time_budget_white = TIME_BUDGET
time_budget_black = TIME_BUDGET
white_bar_format = "{desc} {n:.0f} seconds left | Elapsed: {elapsed}"
black_bar_format = "{desc} {n:.0f} seconds left | Elapsed: {elapsed}"
white_bar = tqdm(total=time_budget_white, desc=f"{model_id_white.split('/')[-1]}:", bar_format=white_bar_format, colour='white')
black_bar = tqdm(total=time_budget_black, desc=f"{model_id_black.split('/')[-1]}:", bar_format=black_bar_format, colour='black')
elo_ratings_df = pd.read_csv("chessllm_leaderboard.csv")
# Game loop
while not board.is_game_over():
# Select model
current_model = model_white if board.turn == chess.WHITE else model_black
# Generate regex pattern
regex_pattern = generate_regex(board)
# Generate move
start_time = time.time()
guided = generate.regex(current_model, regex_pattern)(prompt)
end_time = time.time()
move_duration = end_time - start_time
try:
# Parse move
move_san = guided.strip()
move = board.parse_san(move_san)
if move not in board.legal_moves:
print(f"Illegal move: {move_san}")
break
board.push(move)
# Write move
if board.turn == chess.BLACK:
move_str = f"{move_number}. {move_san} "
move_number += 1
else:
move_str = f"{move_san} "
pgn_moves += move_str
# Render the board to an image
image = render_new(board)
board_images.append(np.array(image))
# Deduct the time taken for the move from the model's time budget
if board.turn == chess.WHITE:
time_budget_black -= move_duration
black_bar.n = time_budget_black
black_bar.set_postfix_str(f"{format_elapsed(black_bar.format_dict['elapsed'])} elapsed")
black_bar.refresh()
if time_budget_black <= 0:
result = "1-0"
break
else:
time_budget_white -= move_duration
white_bar.n = time_budget_white
white_bar.set_postfix_str(f"{format_elapsed(white_bar.format_dict['elapsed'])} elapsed")
white_bar.refresh()
if time_budget_white <= 0:
result = "0-1"
break
# Display board
yield image
except ValueError:
print(f"Invalid move: {guided}")
break
white_bar.close()
black_bar.close()
# Get result
if result is None:
result = board.result()
# Create PGN
termination = determine_termination(board, time_budget_white, time_budget_black)
final_pgn = write_pgn(
pgn_moves, model_id_white, model_id_black, result, TIME_BUDGET, termination
)
file_name = f"{model_id_white.split('/')[-1]}_vs_{model_id_black.split('/')[-1]}"
pgn_id = save_pgn(final_pgn, file_name, GITHUB_TOKEN)
# Save results
save_result_file(
pgn_id, model_id_white, model_id_black, termination, result, GITHUB_TOKEN, RESULT_GIST_ID
)
# Create and display the GIF
create_gif(board_images, file_name + ".gif", duration=400)
# Print ELO ratings
current_elo_white = elo_ratings_df.loc[
elo_ratings_df["Model"] == model_id_white, "ELO Rating"
].get(0, 1000)
current_elo_black = elo_ratings_df.loc[
elo_ratings_df["Model"] == model_id_black, "ELO Rating"
].get(0, 1000)
if result == "1-0":
new_elo_white = calculate_elo(current_elo_white, current_elo_black, 1)
new_elo_black = calculate_elo(current_elo_black, current_elo_white, 0)
update_str = f"""{model_id_white} wins! ({termination})
ELO change:
* {model_id_white}: {current_elo_white} -> {new_elo_white} ({new_elo_white - current_elo_white:+})
* {model_id_black}: {current_elo_black} -> {new_elo_black} ({new_elo_black - current_elo_black:+})
"""
gr.Info(update_str)
print(update_str)
elif result == "0-1":
new_elo_white = calculate_elo(current_elo_white, current_elo_black, 0)
new_elo_black = calculate_elo(current_elo_black, current_elo_white, 1)
update_str = f"""{model_id_black} wins! ({termination})
ELO change:
* {model_id_white}: {current_elo_white} -> {new_elo_white} ({new_elo_white - current_elo_white:+})
* {model_id_black}: {current_elo_black} -> {new_elo_black} ({new_elo_black - current_elo_black:+})
"""
gr.Info(update_str)
print(update_str)
elif result == "1/2-1/2":
new_elo_white = calculate_elo(current_elo_white, current_elo_black, 0.5)
new_elo_black = calculate_elo(current_elo_black, current_elo_white, 0.5)
update_str = f"""Draw! ({termination})
ELO change:
* {model_id_white}: {current_elo_white} -> {new_elo_white} ({new_elo_white - current_elo_white:+})
* {model_id_black}: {current_elo_black} -> {new_elo_black} ({new_elo_black - current_elo_black:+})
"""
gr.Info(update_str)
print(update_str)
elif result == "*":
print(f"Ongoing game! ({termination})")
# Update ELO ratings for each model
chess_data = pd.read_csv('chessllm_results.csv')
elo_ratings = update_elo_ratings(chess_data)
# Convert the dictionary to a DataFrame for better display
elo_ratings_df = pd.DataFrame(elo_ratings.items(), columns=['Model', 'ELO Rating'])
# Round the ELO ratings to the nearest integer
elo_ratings_df['ELO Rating'] = elo_ratings_df['ELO Rating'].round().astype(int)
elo_ratings_df.sort_values(by='ELO Rating', ascending=False, inplace=True)
elo_ratings_df.reset_index(drop=True, inplace=True)
elo_ratings_df.to_csv('chessllm_leaderboard.csv', index=False)
# Upload chessllm_leaderboard.csv to GIST
gist_api = gistyc.GISTyc(auth_token=GITHUB_TOKEN)
response_update_data = gist_api.update_gist(file_name='chessllm_leaderboard.csv', gist_id={LEAD_GIST_ID})
yield file_name + ".gif"
os.environ["TOKENIZERS_PARALLELISM"] = "false"
GITHUB_TOKEN = os.environ["GITHUB_TOKEN"]
RESULT_GIST_ID = "c491299e7b8a45a61ce5403a70cf8656"
LEAD_GIST_ID = "696115fe2df47fb2350fcff2663678c9"
# Download results and leaderboard
url1 = (f"https://gist.githubusercontent.com/chessllm/{RESULT_GIST_ID}/raw")
download_file(url1, "chessllm_results.csv")
elo_ratings_df = get_leaderboard()
# Render chessboard
board = chess.Board()
image = render_init(board)
title = """
<div align="center">
<p style="font-size: 36px;">⚔️ Chess LLM Arena (preview)</p>
<p style="font-size: 20px;">💻 <a href="https://github.com/mlabonne/chessllm">GitHub</a> • 💾 <a href="https://gist.github.com/chessllm/696115fe2df47fb2350fcff2663678c9">Gist Database</a> • 🤖 <a href="https://colab.research.google.com/drive/1e2PszrvaY4Lv5SiRXuBGb5R4GdZsm-H3">Trainer</a> • 📁 <a href="https://colab.research.google.com/drive/11UjbfajCzphe707_V7PD-2e5WIzyintf">Dataset</a></p>
<p><em>Pick two chess LLMs and make them compete in a chess match. When the game is over, it will automatically update the crowd-sourced leaderboard. Build a dataset and train your own small language model to compete in the arena.</em></p>
</div>
"""
footer = """
<p><em>Made by Maxime Labonne, Kostis Gourgoulias, and Ruchi Bahl.</em></p>
"""
with gr.Blocks(theme=gr.themes.Monochrome()) as demo:
gr.Markdown(title)
with gr.Row():
model_id_white = gr.Textbox(label="♘ White Model ID", value="mlabonne/chesspythia-70m")
model_id_black = gr.Textbox(label="♞ Black Model ID", value="EleutherAI/pythia-70m-deduped")
btn = gr.Button("Fight!")
with gr.Row():
gr.HTML("""<div id='chessboard' style='width: 50%; display: block;'></div>""")
out = gr.Image(value=image, show_label=False, show_share_button=False, elem_id="chessboard")
gr.HTML("""<div id='rightblock' style='width: 50%; display: block;'></div>""")
btn.click(fn=update, inputs=[model_id_white, model_id_black], outputs=out)
gr.Markdown('<div align="center"><p style="font-size: 30px;">🏆 Leaderboard</p></div>')
leaderboard = gr.Dataframe(value=get_leaderboard, every=60)
gr.Markdown(footer)
demo.queue(api_open=False).launch(show_api=False)