350 lines
10 KiB
Python
350 lines
10 KiB
Python
"""
|
|
Score distribution analysis for Golf AI.
|
|
|
|
Generates box plots and statistics to verify AI plays reasonably.
|
|
"""
|
|
|
|
import random
|
|
import sys
|
|
from collections import defaultdict
|
|
|
|
from game import Game, Player, GamePhase, GameOptions
|
|
from ai import GolfAI, CPUProfile, CPU_PROFILES, get_ai_card_value
|
|
|
|
|
|
def run_game_for_scores(num_players: int = 4) -> dict[str, int]:
|
|
"""Run a single game and return final scores by player name."""
|
|
|
|
# Pick random profiles
|
|
profiles = random.sample(CPU_PROFILES, min(num_players, len(CPU_PROFILES)))
|
|
|
|
game = Game()
|
|
player_profiles: dict[str, CPUProfile] = {}
|
|
|
|
for i, profile in enumerate(profiles):
|
|
player = Player(id=f"cpu_{i}", name=profile.name)
|
|
game.add_player(player)
|
|
player_profiles[player.id] = profile
|
|
|
|
options = GameOptions(initial_flips=2, flip_mode="never", use_jokers=False)
|
|
game.start_game(num_decks=1, num_rounds=1, options=options)
|
|
|
|
# Initial flips
|
|
for player in game.players:
|
|
positions = GolfAI.choose_initial_flips(options.initial_flips)
|
|
game.flip_initial_cards(player.id, positions)
|
|
|
|
# Play game
|
|
turn = 0
|
|
max_turns = 200
|
|
|
|
while game.phase in (GamePhase.PLAYING, GamePhase.FINAL_TURN) and turn < max_turns:
|
|
current = game.current_player()
|
|
if not current:
|
|
break
|
|
|
|
profile = player_profiles[current.id]
|
|
|
|
# Draw
|
|
discard_top = game.discard_top()
|
|
take_discard = GolfAI.should_take_discard(discard_top, current, profile, game)
|
|
source = "discard" if take_discard else "deck"
|
|
drawn = game.draw_card(current.id, source)
|
|
|
|
if not drawn:
|
|
break
|
|
|
|
# Swap or discard
|
|
swap_pos = GolfAI.choose_swap_or_discard(drawn, current, profile, game)
|
|
|
|
if swap_pos is None and game.drawn_from_discard:
|
|
face_down = [i for i, c in enumerate(current.cards) if not c.face_up]
|
|
if face_down:
|
|
swap_pos = random.choice(face_down)
|
|
else:
|
|
worst_pos = 0
|
|
worst_val = -999
|
|
for i, c in enumerate(current.cards):
|
|
card_val = get_ai_card_value(c, game.options)
|
|
if card_val > worst_val:
|
|
worst_val = card_val
|
|
worst_pos = i
|
|
swap_pos = worst_pos
|
|
|
|
if swap_pos is not None:
|
|
game.swap_card(current.id, swap_pos)
|
|
else:
|
|
game.discard_drawn(current.id)
|
|
if game.flip_on_discard:
|
|
flip_pos = GolfAI.choose_flip_after_discard(current, profile)
|
|
game.flip_and_end_turn(current.id, flip_pos)
|
|
|
|
turn += 1
|
|
|
|
# Return scores
|
|
return {p.name: p.total_score for p in game.players}
|
|
|
|
|
|
def collect_scores(num_games: int = 100, num_players: int = 4) -> dict[str, list[int]]:
|
|
"""Run multiple games and collect all scores by player."""
|
|
|
|
all_scores: dict[str, list[int]] = defaultdict(list)
|
|
|
|
print(f"Running {num_games} games with {num_players} players each...")
|
|
|
|
for i in range(num_games):
|
|
if (i + 1) % 20 == 0:
|
|
print(f" {i + 1}/{num_games} games completed")
|
|
|
|
scores = run_game_for_scores(num_players)
|
|
for name, score in scores.items():
|
|
all_scores[name].append(score)
|
|
|
|
return dict(all_scores)
|
|
|
|
|
|
def print_statistics(all_scores: dict[str, list[int]]):
|
|
"""Print statistical summary."""
|
|
|
|
print("\n" + "=" * 60)
|
|
print("SCORE STATISTICS BY PLAYER")
|
|
print("=" * 60)
|
|
|
|
# Combine all scores
|
|
combined = []
|
|
for scores in all_scores.values():
|
|
combined.extend(scores)
|
|
|
|
combined.sort()
|
|
|
|
def percentile(data, p):
|
|
k = (len(data) - 1) * p / 100
|
|
f = int(k)
|
|
c = f + 1 if f + 1 < len(data) else f
|
|
return data[f] + (k - f) * (data[c] - data[f])
|
|
|
|
def stats(data):
|
|
data = sorted(data)
|
|
n = len(data)
|
|
mean = sum(data) / n
|
|
q1 = percentile(data, 25)
|
|
median = percentile(data, 50)
|
|
q3 = percentile(data, 75)
|
|
return {
|
|
'n': n,
|
|
'min': min(data),
|
|
'q1': q1,
|
|
'median': median,
|
|
'q3': q3,
|
|
'max': max(data),
|
|
'mean': mean,
|
|
'iqr': q3 - q1
|
|
}
|
|
|
|
print(f"\n{'Player':<12} {'N':>5} {'Min':>6} {'Q1':>6} {'Med':>6} {'Q3':>6} {'Max':>6} {'Mean':>7}")
|
|
print("-" * 60)
|
|
|
|
for name in sorted(all_scores.keys()):
|
|
s = stats(all_scores[name])
|
|
print(f"{name:<12} {s['n']:>5} {s['min']:>6.0f} {s['q1']:>6.1f} {s['median']:>6.1f} {s['q3']:>6.1f} {s['max']:>6.0f} {s['mean']:>7.1f}")
|
|
|
|
print("-" * 60)
|
|
s = stats(combined)
|
|
print(f"{'OVERALL':<12} {s['n']:>5} {s['min']:>6.0f} {s['q1']:>6.1f} {s['median']:>6.1f} {s['q3']:>6.1f} {s['max']:>6.0f} {s['mean']:>7.1f}")
|
|
|
|
print(f"\nInterquartile Range (IQR): {s['iqr']:.1f}")
|
|
print(f"Typical score range: {s['q1']:.0f} to {s['q3']:.0f}")
|
|
|
|
# Score distribution buckets
|
|
print("\n" + "=" * 60)
|
|
print("SCORE DISTRIBUTION")
|
|
print("=" * 60)
|
|
|
|
buckets = defaultdict(int)
|
|
for score in combined:
|
|
if score < -5:
|
|
bucket = "< -5"
|
|
elif score < 0:
|
|
bucket = "-5 to -1"
|
|
elif score < 5:
|
|
bucket = "0 to 4"
|
|
elif score < 10:
|
|
bucket = "5 to 9"
|
|
elif score < 15:
|
|
bucket = "10 to 14"
|
|
elif score < 20:
|
|
bucket = "15 to 19"
|
|
elif score < 25:
|
|
bucket = "20 to 24"
|
|
else:
|
|
bucket = "25+"
|
|
buckets[bucket] += 1
|
|
|
|
bucket_order = ["< -5", "-5 to -1", "0 to 4", "5 to 9", "10 to 14", "15 to 19", "20 to 24", "25+"]
|
|
|
|
total = len(combined)
|
|
for bucket in bucket_order:
|
|
count = buckets.get(bucket, 0)
|
|
pct = count / total * 100
|
|
bar = "#" * int(pct / 2)
|
|
print(f"{bucket:>10}: {count:>4} ({pct:>5.1f}%) {bar}")
|
|
|
|
return stats(combined)
|
|
|
|
|
|
def create_box_plot(all_scores: dict[str, list[int]], output_file: str = "score_distribution.png"):
|
|
"""Create a box plot visualization."""
|
|
|
|
try:
|
|
import matplotlib.pyplot as plt
|
|
import matplotlib
|
|
matplotlib.use('Agg') # Non-interactive backend
|
|
except ImportError:
|
|
print("\nMatplotlib not installed. Install with: pip install matplotlib")
|
|
print("Skipping box plot generation.")
|
|
return False
|
|
|
|
# Prepare data
|
|
names = sorted(all_scores.keys())
|
|
data = [all_scores[name] for name in names]
|
|
|
|
# Also add combined data
|
|
combined = []
|
|
for scores in all_scores.values():
|
|
combined.extend(scores)
|
|
names.append("ALL")
|
|
data.append(combined)
|
|
|
|
# Create figure
|
|
fig, ax = plt.subplots(figsize=(12, 6))
|
|
|
|
# Box plot
|
|
bp = ax.boxplot(data, labels=names, patch_artist=True)
|
|
|
|
# Color boxes
|
|
colors = ['#FF9999', '#99FF99', '#9999FF', '#FFFF99',
|
|
'#FF99FF', '#99FFFF', '#FFB366', '#B366FF', '#CCCCCC']
|
|
for patch, color in zip(bp['boxes'], colors[:len(bp['boxes'])]):
|
|
patch.set_facecolor(color)
|
|
patch.set_alpha(0.7)
|
|
|
|
# Labels
|
|
ax.set_xlabel('Player (AI Personality)', fontsize=12)
|
|
ax.set_ylabel('Round Score (lower is better)', fontsize=12)
|
|
ax.set_title('6-Card Golf AI Score Distribution', fontsize=14)
|
|
|
|
# Add horizontal line at 0
|
|
ax.axhline(y=0, color='green', linestyle='--', alpha=0.5, label='Zero (par)')
|
|
|
|
# Add reference lines
|
|
ax.axhline(y=10, color='orange', linestyle=':', alpha=0.5, label='Good (10)')
|
|
ax.axhline(y=20, color='red', linestyle=':', alpha=0.5, label='Poor (20)')
|
|
|
|
ax.legend(loc='upper right')
|
|
ax.grid(axis='y', alpha=0.3)
|
|
|
|
# Save
|
|
plt.tight_layout()
|
|
plt.savefig(output_file, dpi=150)
|
|
print(f"\nBox plot saved to: {output_file}")
|
|
|
|
return True
|
|
|
|
|
|
def create_ascii_box_plot(all_scores: dict[str, list[int]]):
|
|
"""Create an ASCII box plot for terminal display."""
|
|
|
|
print("\n" + "=" * 70)
|
|
print("ASCII BOX PLOT (Score Distribution)")
|
|
print("=" * 70)
|
|
|
|
def percentile(data, p):
|
|
data = sorted(data)
|
|
k = (len(data) - 1) * p / 100
|
|
f = int(k)
|
|
c = f + 1 if f + 1 < len(data) else f
|
|
return data[f] + (k - f) * (data[c] - data[f])
|
|
|
|
# Find global min/max for scaling
|
|
all_vals = []
|
|
for scores in all_scores.values():
|
|
all_vals.extend(scores)
|
|
|
|
global_min = min(all_vals)
|
|
global_max = max(all_vals)
|
|
|
|
# Scale to 50 characters
|
|
width = 50
|
|
|
|
def scale(val):
|
|
if global_max == global_min:
|
|
return width // 2
|
|
return int((val - global_min) / (global_max - global_min) * (width - 1))
|
|
|
|
# Print scale
|
|
print(f"\n{' ' * 12} {global_min:<6} {'':^{width-12}} {global_max:>6}")
|
|
print(f"{' ' * 12} |{'-' * (width - 2)}|")
|
|
|
|
# Add combined
|
|
combined = list(all_vals)
|
|
scores_to_plot = dict(all_scores)
|
|
scores_to_plot["COMBINED"] = combined
|
|
|
|
for name in sorted(scores_to_plot.keys()):
|
|
scores = scores_to_plot[name]
|
|
|
|
q1 = percentile(scores, 25)
|
|
med = percentile(scores, 50)
|
|
q3 = percentile(scores, 75)
|
|
min_val = min(scores)
|
|
max_val = max(scores)
|
|
|
|
# Build the line
|
|
line = [' '] * width
|
|
|
|
# Whiskers
|
|
min_pos = scale(min_val)
|
|
max_pos = scale(max_val)
|
|
q1_pos = scale(q1)
|
|
q3_pos = scale(q3)
|
|
med_pos = scale(med)
|
|
|
|
# Left whisker
|
|
line[min_pos] = '|'
|
|
for i in range(min_pos + 1, q1_pos):
|
|
line[i] = '-'
|
|
|
|
# Box
|
|
for i in range(q1_pos, q3_pos + 1):
|
|
line[i] = '='
|
|
|
|
# Median
|
|
line[med_pos] = '|'
|
|
|
|
# Right whisker
|
|
for i in range(q3_pos + 1, max_pos):
|
|
line[i] = '-'
|
|
line[max_pos] = '|'
|
|
|
|
print(f"{name:>11} {''.join(line)}")
|
|
|
|
print(f"\n Legend: |---[===|===]---| = min--Q1--median--Q3--max")
|
|
print(f" Lower scores are better (left side of plot)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
num_games = int(sys.argv[1]) if len(sys.argv) > 1 else 100
|
|
num_players = int(sys.argv[2]) if len(sys.argv) > 2 else 4
|
|
|
|
# Collect scores
|
|
all_scores = collect_scores(num_games, num_players)
|
|
|
|
# Print statistics
|
|
print_statistics(all_scores)
|
|
|
|
# ASCII box plot (always works)
|
|
create_ascii_box_plot(all_scores)
|
|
|
|
# Try matplotlib box plot
|
|
create_box_plot(all_scores)
|