Fix elo_analysis: use correct timestamp column, show real ratings

This commit is contained in:
Split 2026-02-26 11:55:40 -05:00
parent 03a3d44149
commit 16e21346c2
3 changed files with 324 additions and 282 deletions

View File

@ -1,109 +1,87 @@
{ {
"metadata": { "metadata": {
"timestamp": "2026-02-26T11:33:29.969922-05:00", "k_factor": 32.0,
"start_rating": 1500.0,
"timestamp": "2026-02-26T11:55:18.544210-05:00",
"total_matches": 29, "total_matches": 29,
"total_players": 6 "total_players": 6
}, },
"players": [ "players": [
{ {
"doubles": { "difference": -112.8,
"difference": 290.9, "elo_unified": 1537.7,
"elo": 1500.0, "glicko2": {
"glicko2": 1209.1 "average": 1650.5,
}, "doubles": 1718.2,
"id": 1, "singles": 1582.9
"matches_played": 0,
"name": "Dane Sabo",
"singles": {
"difference": 129.5,
"elo": 1500.0,
"glicko2": 1370.5
}
},
{
"doubles": {
"difference": -218.2,
"elo": 1500.0,
"glicko2": 1718.2
}, },
"id": 2, "id": 2,
"matches_played": 0, "matches_played": 19,
"name": "Andrew Stricklin", "name": "Andrew Stricklin"
"singles": {
"difference": -82.9,
"elo": 1500.0,
"glicko2": 1582.9
}
}, },
{ {
"doubles": { "difference": -40.2,
"difference": 173.1, "elo_unified": 1521.6,
"elo": 1500.0, "glicko2": {
"glicko2": 1326.9 "average": 1561.8,
}, "doubles": 1661.5,
"id": 4, "singles": 1462.2
"matches_played": 0,
"name": "Krzysztof Radziszeski ",
"singles": {
"difference": -119.3,
"elo": 1500.0,
"glicko2": 1619.3
}
},
{
"doubles": {
"difference": -161.5,
"elo": 1500.0,
"glicko2": 1661.5
}, },
"id": 3, "id": 3,
"matches_played": 0, "matches_played": 11,
"name": "David Pabst", "name": "David Pabst"
"singles": {
"difference": 37.8,
"elo": 1500.0,
"glicko2": 1462.2
}
}, },
{ {
"doubles": { "difference": -43.0,
"difference": -114.9, "elo_unified": 1514.5,
"elo": 1500.0, "glicko2": {
"glicko2": 1614.9 "average": 1557.5,
"doubles": 1614.9,
"singles": 1500.0
}, },
"id": 6, "id": 6,
"matches_played": 0, "matches_played": 9,
"name": "Jacklyn Wyszynski", "name": "Jacklyn Wyszynski"
"singles": {
"difference": 0.0,
"elo": 1500.0,
"glicko2": 1500.0
}
}, },
{ {
"doubles": { "difference": 11.3,
"difference": -5.8, "elo_unified": 1496.8,
"elo": 1500.0, "glicko2": {
"glicko2": 1505.8 "average": 1485.5,
"doubles": 1505.8,
"singles": 1465.1
}, },
"id": 5, "id": 5,
"matches_played": 0, "matches_played": 13,
"name": "Eliana Crew", "name": "Eliana Crew"
"singles": { },
"difference": 34.9, {
"elo": 1500.0, "difference": 2.7,
"glicko2": 1465.1 "elo_unified": 1475.8,
} "glicko2": {
"average": 1473.1,
"doubles": 1326.9,
"singles": 1619.3
},
"id": 4,
"matches_played": 25,
"name": "Krzysztof Radziszeski "
},
{
"difference": 159.0,
"elo_unified": 1448.8,
"glicko2": {
"average": 1289.8,
"doubles": 1209.1,
"singles": 1370.5
},
"id": 1,
"matches_played": 25,
"name": "Dane Sabo"
} }
], ],
"summary": { "system_description": {
"doubles": { "new": "Pure ELO with unified rating, per-point scoring, effective opponent formula",
"avg_elo": 1500.0, "old": "Glicko-2 with separate singles/doubles ratings, RD, volatility"
"avg_glicko2": 1506.060606060606
},
"singles": {
"avg_elo": 1500.0,
"avg_glicko2": 1500.0
}
} }
} }

View File

@ -1,38 +1,54 @@
# Rating System Comparison: Glicko-2 vs Pure ELO # Rating System Comparison: Glicko-2 vs Pure ELO
## Overview
This analysis replays all historical matches through the new ELO system to compare ratings.
**Key differences:**
- **Old system:** Glicko-2 with separate singles/doubles ratings, RD, volatility
- **New system:** Pure ELO with unified rating, per-point scoring, effective opponent formula
## Summary ## Summary
- **Total Players:** 6 - **Total Players:** 6
- **Total Matches:** 29 - **Total Matches Replayed:** 29
- **Analysis Date:** 2026-02-26 11:33:29 - **K-Factor:** 32
- **Analysis Date:** 2026-02-26 11:55:18
## Ratings Comparison ## Ratings Comparison (Sorted by New ELO)
| Player | Singles (G2) | Singles (ELO) | Diff | Doubles (G2) | Doubles (ELO) | Diff | Matches | | Rank | Player | Glicko-2 Avg | New ELO | Diff | Matches |
|--------|------|------|------|------|------|------|--------| |:----:|--------|-------------:|--------:|-----:|--------:|
| Dane Sabo | 1371 | 1500 | +129 | 1209 | 1500 | +291 | 0 | | 1 | Andrew Stricklin | 1651 | 1538 | -113 | 19 |
| Andrew Stricklin | 1583 | 1500 | -83 | 1718 | 1500 | -218 | 0 | | 2 | David Pabst | 1562 | 1522 | -40 | 11 |
| Krzysztof Radziszeski | 1619 | 1500 | -119 | 1327 | 1500 | +173 | 0 | | 3 | Jacklyn Wyszynski | 1557 | 1514 | -43 | 9 |
| David Pabst | 1462 | 1500 | +38 | 1661 | 1500 | -161 | 0 | | 4 | Eliana Crew | 1485 | 1497 | +11 | 13 |
| Jacklyn Wyszynski | 1500 | 1500 | +0 | 1615 | 1500 | -115 | 0 | | 5 | Krzysztof Radziszeski | 1473 | 1476 | +3 | 25 |
| Eliana Crew | 1465 | 1500 | +35 | 1506 | 1500 | -6 | 0 | | 6 | Dane Sabo | 1290 | 1449 | +159 | 25 |
## Biggest Rating Changes ## Key Insights
Players whose ratings changed the most in the conversion: ### Biggest Winners (rating increased)
- **Dane Sabo**: +159 points (Glicko avg 1290 → ELO 1449)
- **Eliana Crew**: +11 points (Glicko avg 1485 → ELO 1497)
- **Krzysztof Radziszeski **: +3 points (Glicko avg 1473 → ELO 1476)
### Biggest Losers (rating decreased)
- **Andrew Stricklin**: -113 points (Glicko avg 1651 → ELO 1538)
- **Jacklyn Wyszynski**: -43 points (Glicko avg 1557 → ELO 1514)
- **David Pabst**: -40 points (Glicko avg 1562 → ELO 1522)
## Why Ratings Changed
The new system differs in several ways:
1. **Per-point scoring**: Instead of just win/loss, we use `points_won / total_points`. Winning 11-9 gives less credit than winning 11-2.
2. **Effective opponent formula**: In doubles, your effective opponent is calculated as `Opp1 + Opp2 - Teammate`. This means:
- Strong teammate → lower effective opponent → less credit for winning
- Weak teammate → higher effective opponent → more credit for winning
3. **Unified rating**: Singles and doubles contribute to one rating instead of two.
1. **Dane Sabo** - Average change: 210 points
- Singles: +129
- Doubles: +291
2. **Andrew Stricklin** - Average change: 151 points
- Singles: -83
- Doubles: -218
3. **Krzysztof Radziszeski ** - Average change: 146 points
- Singles: -119
- Doubles: +173
4. **David Pabst** - Average change: 100 points
- Singles: +38
- Doubles: -161
5. **Jacklyn Wyszynski** - Average change: 57 points
- Singles: +0
- Doubles: -115

View File

@ -1,4 +1,5 @@
// Analysis tool: Compare Glicko-2 vs ELO ratings using historical match data // Analysis tool: Compare Glicko-2 vs Pure ELO ratings using historical match data
// Fixed version: unified rating, per-point scoring, correct match counts
use sqlx::SqlitePool; use sqlx::SqlitePool;
use std::collections::HashMap; use std::collections::HashMap;
@ -12,14 +13,13 @@ struct PlayerRatings {
name: String, name: String,
glicko2_singles: f64, glicko2_singles: f64,
glicko2_doubles: f64, glicko2_doubles: f64,
elo_singles: f64, elo_unified: f64,
elo_doubles: f64, glicko2_avg: f64,
singles_diff: f64, elo_diff: f64,
doubles_diff: f64,
matches_played: i32, matches_played: i32,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone)]
struct MatchData { struct MatchData {
id: i64, id: i64,
match_type: String, match_type: String,
@ -27,12 +27,41 @@ struct MatchData {
team2_score: i32, team2_score: i32,
team1_players: Vec<i64>, team1_players: Vec<i64>,
team2_players: Vec<i64>, team2_players: Vec<i64>,
played_at: String,
}
const K_FACTOR: f64 = 32.0;
const START_RATING: f64 = 1500.0;
/// Calculate expected score using ELO formula
fn expected_score(player_rating: f64, opponent_rating: f64) -> f64 {
1.0 / (1.0 + 10.0_f64.powf((opponent_rating - player_rating) / 400.0))
}
/// Calculate per-point performance (points_won / total_points)
fn per_point_performance(points_won: i32, points_lost: i32) -> f64 {
let total = points_won + points_lost;
if total == 0 {
0.5
} else {
points_won as f64 / total as f64
}
}
/// Calculate effective opponent rating for doubles
/// Formula: Opp1 + Opp2 - Teammate
fn effective_opponent_rating(
opp1_rating: f64,
opp2_rating: f64,
teammate_rating: f64,
) -> f64 {
opp1_rating + opp2_rating - teammate_rating
} }
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
println!("🔄 ELO System Analysis Tool"); println!("🔄 ELO System Analysis Tool (v2 - Unified Rating)");
println!("===========================\n"); println!("==================================================\n");
// Connect to database // Connect to database
let db_path = "/Users/split/Projects/pickleball-elo/pickleball.db"; let db_path = "/Users/split/Projects/pickleball-elo/pickleball.db";
@ -50,7 +79,7 @@ async fn main() {
println!("📊 Reading match history..."); println!("📊 Reading match history...");
// Fetch all players with current ratings // Fetch all players with current Glicko-2 ratings
let players: Vec<(i64, String, f64, f64)> = sqlx::query_as( let players: Vec<(i64, String, f64, f64)> = sqlx::query_as(
"SELECT id, name, singles_rating, doubles_rating FROM players ORDER BY name" "SELECT id, name, singles_rating, doubles_rating FROM players ORDER BY name"
) )
@ -60,9 +89,10 @@ async fn main() {
println!("✅ Found {} players", players.len()); println!("✅ Found {} players", players.len());
// Fetch all matches // Fetch all matches ordered by time
let matches: Vec<(i64, String, i32, i32)> = sqlx::query_as( let matches: Vec<(i64, String, i32, i32, String)> = sqlx::query_as(
"SELECT id, match_type, team1_score, team2_score FROM matches ORDER BY id" "SELECT id, match_type, team1_score, team2_score, COALESCE(timestamp, datetime('now')) as played_at
FROM matches ORDER BY timestamp, id"
) )
.fetch_all(&pool) .fetch_all(&pool)
.await .await
@ -70,13 +100,15 @@ async fn main() {
println!("✅ Found {} matches", matches.len()); println!("✅ Found {} matches", matches.len());
// For each match, get participants // Build match data with participants
let mut match_data_map: HashMap<i64, MatchData> = HashMap::new(); let mut all_matches: Vec<MatchData> = vec![];
let mut match_counts: HashMap<i64, i32> = HashMap::new();
for (match_id, match_type, team1_score, team2_score) in &matches { for (match_id, match_type, team1_score, team2_score, played_at) in &matches {
let team1_players: Vec<i64> = sqlx::query_scalar( let team1_players: Vec<i64> = sqlx::query_scalar(
"SELECT player_id FROM match_participants WHERE match_id = ? AND team = 1" "SELECT player_id FROM match_participants WHERE match_id = ? AND team = 1"
) )
.bind(match_id)
.fetch_all(&pool) .fetch_all(&pool)
.await .await
.unwrap_or_default(); .unwrap_or_default();
@ -84,141 +116,124 @@ async fn main() {
let team2_players: Vec<i64> = sqlx::query_scalar( let team2_players: Vec<i64> = sqlx::query_scalar(
"SELECT player_id FROM match_participants WHERE match_id = ? AND team = 2" "SELECT player_id FROM match_participants WHERE match_id = ? AND team = 2"
) )
.bind(match_id)
.fetch_all(&pool) .fetch_all(&pool)
.await .await
.unwrap_or_default(); .unwrap_or_default();
match_data_map.insert(*match_id, MatchData { // Count matches per player
for pid in &team1_players {
*match_counts.entry(*pid).or_insert(0) += 1;
}
for pid in &team2_players {
*match_counts.entry(*pid).or_insert(0) += 1;
}
all_matches.push(MatchData {
id: *match_id, id: *match_id,
match_type: match_type.clone(), match_type: match_type.clone(),
team1_score: *team1_score, team1_score: *team1_score,
team2_score: *team2_score, team2_score: *team2_score,
team1_players, team1_players,
team2_players, team2_players,
played_at: played_at.clone(),
}); });
} }
// Initialize ELO ratings (everyone starts at 1500) // Initialize UNIFIED ELO ratings (everyone starts at 1500)
let mut elo_ratings: HashMap<i64, (f64, f64)> = HashMap::new(); // (singles, doubles) let mut elo_ratings: HashMap<i64, f64> = HashMap::new();
for (player_id, _, _, _) in &players { for (player_id, _, _, _) in &players {
elo_ratings.insert(*player_id, (1500.0, 1500.0)); elo_ratings.insert(*player_id, START_RATING);
} }
println!("\n🔢 Recalculating ELO ratings from match history..."); println!("\n🔢 Replaying {} matches through new ELO system...\n", all_matches.len());
// Simulate all matches to calculate ELO // Replay all matches chronologically
for (match_id, match_type, _team1_score, _team2_score) in &matches { for (i, match_data) in all_matches.iter().enumerate() {
if let Some(match_info) = match_data_map.get(match_id) { let is_doubles = match_data.match_type == "doubles" || match_data.team1_players.len() > 1;
let is_doubles = match_type == "doubles"; let team1_won = match_data.team1_score > match_data.team2_score;
let team1_won = match_info.team1_score > match_info.team2_score;
// For each player, calculate rating change // Calculate per-point performance
for player_id in &match_info.team1_players { let team1_performance = per_point_performance(match_data.team1_score, match_data.team2_score);
let (current_singles, current_doubles) = elo_ratings.get(player_id).copied().unwrap_or((1500.0, 1500.0)); let team2_performance = per_point_performance(match_data.team2_score, match_data.team1_score);
let current = if is_doubles { current_doubles } else { current_singles };
// Calculate effective opponent rating // Collect current ratings BEFORE updates
let opponent_rating = if is_doubles { let team1_ratings: Vec<(i64, f64)> = match_data.team1_players.iter()
let opponent_ratings: Vec<f64> = match_info.team2_players.iter() .map(|pid| (*pid, elo_ratings.get(pid).copied().unwrap_or(START_RATING)))
.map(|pid| elo_ratings.get(pid).copied().unwrap_or((1500.0, 1500.0)).1)
.collect(); .collect();
let avg_opp_rating = opponent_ratings.iter().sum::<f64>() / opponent_ratings.len() as f64; let team2_ratings: Vec<(i64, f64)> = match_data.team2_players.iter()
.map(|pid| (*pid, elo_ratings.get(pid).copied().unwrap_or(START_RATING)))
// Teammate rating for effective opponent
let teammate_rating = if match_info.team1_players.len() > 1 {
let teammate_id = match_info.team1_players.iter().find(|pid| *pid != player_id).copied().unwrap_or(*player_id);
elo_ratings.get(&teammate_id).copied().unwrap_or((1500.0, 1500.0)).1
} else {
1500.0
};
avg_opp_rating * 2.0 - teammate_rating
} else {
match_info.team2_players.iter()
.map(|pid| elo_ratings.get(pid).copied().unwrap_or((1500.0, 1500.0)).0)
.next()
.unwrap_or(1500.0)
};
// Calculate performance (0.0 or 1.0 for now, simple version)
let performance = if team1_won { 1.0 } else { 0.0 };
// ELO rating change: K=32
let expected = 1.0 / (1.0 + 10.0_f64.powf((opponent_rating - current) / 400.0));
let k_factor = 32.0;
let rating_change = k_factor * (performance - expected);
let new_rating = current + rating_change;
if is_doubles {
elo_ratings.insert(*player_id, (current_singles, new_rating));
} else {
elo_ratings.insert(*player_id, (new_rating, current_doubles));
}
}
// Update team 2
for player_id in &match_info.team2_players {
let (current_singles, current_doubles) = elo_ratings.get(player_id).copied().unwrap_or((1500.0, 1500.0));
let current = if is_doubles { current_doubles } else { current_singles };
// Calculate effective opponent rating
let opponent_rating = if is_doubles {
let opponent_ratings: Vec<f64> = match_info.team1_players.iter()
.map(|pid| elo_ratings.get(pid).copied().unwrap_or((1500.0, 1500.0)).1)
.collect(); .collect();
let avg_opp_rating = opponent_ratings.iter().sum::<f64>() / opponent_ratings.len() as f64;
// Teammate rating for effective opponent // Calculate and apply rating changes for Team 1
let teammate_rating = if match_info.team2_players.len() > 1 { for (player_id, player_rating) in &team1_ratings {
let teammate_id = match_info.team2_players.iter().find(|pid| *pid != player_id).copied().unwrap_or(*player_id); let effective_opp = if is_doubles && team2_ratings.len() >= 2 && team1_ratings.len() >= 2 {
elo_ratings.get(&teammate_id).copied().unwrap_or((1500.0, 1500.0)).1 // Find teammate
let teammate_rating = team1_ratings.iter()
.find(|(pid, _)| pid != player_id)
.map(|(_, r)| *r)
.unwrap_or(START_RATING);
effective_opponent_rating(
team2_ratings[0].1,
team2_ratings[1].1,
teammate_rating,
)
} else if is_doubles && team2_ratings.len() >= 2 {
// Singles vs doubles scenario or missing teammate
(team2_ratings[0].1 + team2_ratings[1].1) / 2.0
} else { } else {
1500.0 // Singles
team2_ratings.get(0).map(|(_, r)| *r).unwrap_or(START_RATING)
}; };
avg_opp_rating * 2.0 - teammate_rating let expected = expected_score(*player_rating, effective_opp);
let rating_change = K_FACTOR * (team1_performance - expected);
let new_rating = player_rating + rating_change;
elo_ratings.insert(*player_id, new_rating);
}
// Calculate and apply rating changes for Team 2
for (player_id, player_rating) in &team2_ratings {
let effective_opp = if is_doubles && team1_ratings.len() >= 2 && team2_ratings.len() >= 2 {
// Find teammate
let teammate_rating = team2_ratings.iter()
.find(|(pid, _)| pid != player_id)
.map(|(_, r)| *r)
.unwrap_or(START_RATING);
effective_opponent_rating(
team1_ratings[0].1,
team1_ratings[1].1,
teammate_rating,
)
} else if is_doubles && team1_ratings.len() >= 2 {
(team1_ratings[0].1 + team1_ratings[1].1) / 2.0
} else { } else {
match_info.team1_players.iter() team1_ratings.get(0).map(|(_, r)| *r).unwrap_or(START_RATING)
.map(|pid| elo_ratings.get(pid).copied().unwrap_or((1500.0, 1500.0)).0)
.next()
.unwrap_or(1500.0)
}; };
// Calculate performance let expected = expected_score(*player_rating, effective_opp);
let performance = if !team1_won { 1.0 } else { 0.0 }; let rating_change = K_FACTOR * (team2_performance - expected);
let new_rating = player_rating + rating_change;
// ELO rating change elo_ratings.insert(*player_id, new_rating);
let expected = 1.0 / (1.0 + 10.0_f64.powf((opponent_rating - current) / 400.0)); }
let k_factor = 32.0;
let rating_change = k_factor * (performance - expected);
let new_rating = current + rating_change;
if is_doubles { // Progress indicator every 5 matches
elo_ratings.insert(*player_id, (current_singles, new_rating)); if (i + 1) % 5 == 0 || i == all_matches.len() - 1 {
} else { println!(" Processed {}/{} matches", i + 1, all_matches.len());
elo_ratings.insert(*player_id, (new_rating, current_doubles));
}
}
} }
} }
println!("✅ ELO ratings calculated\n"); println!("\n✅ ELO ratings calculated\n");
// Count matches per player
let mut match_counts: HashMap<i64, i32> = HashMap::new();
for match_info in match_data_map.values() {
for pid in &match_info.team1_players {
*match_counts.entry(*pid).or_insert(0) += 1;
}
for pid in &match_info.team2_players {
*match_counts.entry(*pid).or_insert(0) += 1;
}
}
// Build comparison data // Build comparison data
let mut comparisons = vec![]; let mut comparisons: Vec<PlayerRatings> = vec![];
for (player_id, name, glicko2_singles, glicko2_doubles) in &players { for (player_id, name, glicko2_singles, glicko2_doubles) in &players {
let (elo_singles, elo_doubles) = elo_ratings.get(player_id).copied().unwrap_or((1500.0, 1500.0)); let elo_unified = elo_ratings.get(player_id).copied().unwrap_or(START_RATING);
let glicko2_avg = (glicko2_singles + glicko2_doubles) / 2.0;
let matches = match_counts.get(player_id).copied().unwrap_or(0); let matches = match_counts.get(player_id).copied().unwrap_or(0);
comparisons.push(PlayerRatings { comparisons.push(PlayerRatings {
@ -226,93 +241,126 @@ async fn main() {
name: name.clone(), name: name.clone(),
glicko2_singles: *glicko2_singles, glicko2_singles: *glicko2_singles,
glicko2_doubles: *glicko2_doubles, glicko2_doubles: *glicko2_doubles,
elo_singles, elo_unified,
elo_doubles, glicko2_avg,
singles_diff: elo_singles - glicko2_singles, elo_diff: elo_unified - glicko2_avg,
doubles_diff: elo_doubles - glicko2_doubles,
matches_played: matches, matches_played: matches,
}); });
} }
// Sort by biggest differences // Sort by ELO rating (highest first)
comparisons.sort_by(|a, b| { comparisons.sort_by(|a, b| b.elo_unified.partial_cmp(&a.elo_unified).unwrap());
let a_total_diff = (a.singles_diff.abs() + a.doubles_diff.abs()) / 2.0;
let b_total_diff = (b.singles_diff.abs() + b.doubles_diff.abs()) / 2.0; // Print summary
b_total_diff.partial_cmp(&a_total_diff).unwrap() println!("📊 Final Ratings Comparison:");
}); println!("┌────────────────────────────┬────────────┬────────────┬────────────┬─────────┐");
println!("│ Player │ Glicko Avg │ ELO Unified│ Difference │ Matches │");
println!("├────────────────────────────┼────────────┼────────────┼────────────┼─────────┤");
for p in &comparisons {
let diff_str = if p.elo_diff >= 0.0 {
format!("+{:.0}", p.elo_diff)
} else {
format!("{:.0}", p.elo_diff)
};
println!("{:26}{:>10.0}{:>10.0}{:>10}{:>7}",
p.name, p.glicko2_avg, p.elo_unified, diff_str, p.matches_played);
}
println!("└────────────────────────────┴────────────┴────────────┴────────────┴─────────┘");
// Write JSON report // Write JSON report
let json_output = json!({ let json_output = json!({
"metadata": { "metadata": {
"timestamp": chrono::Local::now().to_rfc3339(), "timestamp": chrono::Local::now().to_rfc3339(),
"total_players": players.len(), "total_players": players.len(),
"total_matches": matches.len(), "total_matches": all_matches.len(),
"k_factor": K_FACTOR,
"start_rating": START_RATING,
}, },
"summary": { "system_description": {
"singles": { "old": "Glicko-2 with separate singles/doubles ratings, RD, volatility",
"avg_glicko2": players.iter().map(|(_, _, sr, _)| sr).sum::<f64>() / players.len() as f64, "new": "Pure ELO with unified rating, per-point scoring, effective opponent formula",
"avg_elo": elo_ratings.values().map(|(sr, _)| sr).sum::<f64>() / players.len() as f64,
},
"doubles": {
"avg_glicko2": players.iter().map(|(_, _, _, dr)| dr).sum::<f64>() / players.len() as f64,
"avg_elo": elo_ratings.values().map(|(_, dr)| dr).sum::<f64>() / players.len() as f64,
}
}, },
"players": comparisons.iter().map(|p| json!({ "players": comparisons.iter().map(|p| json!({
"id": p.id, "id": p.id,
"name": p.name, "name": p.name,
"singles": { "glicko2": {
"glicko2": (p.glicko2_singles * 10.0).round() / 10.0, "singles": (p.glicko2_singles * 10.0).round() / 10.0,
"elo": (p.elo_singles * 10.0).round() / 10.0, "doubles": (p.glicko2_doubles * 10.0).round() / 10.0,
"difference": (p.singles_diff * 10.0).round() / 10.0, "average": (p.glicko2_avg * 10.0).round() / 10.0,
},
"doubles": {
"glicko2": (p.glicko2_doubles * 10.0).round() / 10.0,
"elo": (p.elo_doubles * 10.0).round() / 10.0,
"difference": (p.doubles_diff * 10.0).round() / 10.0,
}, },
"elo_unified": (p.elo_unified * 10.0).round() / 10.0,
"difference": (p.elo_diff * 10.0).round() / 10.0,
"matches_played": p.matches_played, "matches_played": p.matches_played,
})).collect::<Vec<_>>() })).collect::<Vec<_>>()
}); });
let json_path = "/Users/split/Projects/pickleball-elo/docs/rating-comparison.json"; let json_path = "/Users/split/Projects/pickleball-elo/docs/rating-comparison.json";
fs::write(json_path, serde_json::to_string_pretty(&json_output).unwrap()).ok(); fs::write(json_path, serde_json::to_string_pretty(&json_output).unwrap()).ok();
println!("💾 JSON report saved to: {}", json_path); println!("\n💾 JSON report saved to: {}", json_path);
// Write Markdown report // Write Markdown report
let mut md = String::from("# Rating System Comparison: Glicko-2 vs Pure ELO\n\n"); let mut md = String::from("# Rating System Comparison: Glicko-2 vs Pure ELO\n\n");
md.push_str("## Overview\n\n");
md.push_str("This analysis replays all historical matches through the new ELO system to compare ratings.\n\n");
md.push_str("**Key differences:**\n");
md.push_str("- **Old system:** Glicko-2 with separate singles/doubles ratings, RD, volatility\n");
md.push_str("- **New system:** Pure ELO with unified rating, per-point scoring, effective opponent formula\n\n");
md.push_str("## Summary\n\n"); md.push_str("## Summary\n\n");
md.push_str(&format!("- **Total Players:** {}\n", players.len())); md.push_str(&format!("- **Total Players:** {}\n", players.len()));
md.push_str(&format!("- **Total Matches:** {}\n", matches.len())); md.push_str(&format!("- **Total Matches Replayed:** {}\n", all_matches.len()));
md.push_str(&format!("- **K-Factor:** {}\n", K_FACTOR));
md.push_str(&format!("- **Analysis Date:** {}\n\n", chrono::Local::now().format("%Y-%m-%d %H:%M:%S"))); md.push_str(&format!("- **Analysis Date:** {}\n\n", chrono::Local::now().format("%Y-%m-%d %H:%M:%S")));
md.push_str("## Ratings Comparison\n\n"); md.push_str("## Ratings Comparison (Sorted by New ELO)\n\n");
md.push_str("| Player | Singles (G2) | Singles (ELO) | Diff | Doubles (G2) | Doubles (ELO) | Diff | Matches |\n"); md.push_str("| Rank | Player | Glicko-2 Avg | New ELO | Diff | Matches |\n");
md.push_str("|--------|------|------|------|------|------|------|--------|\n"); md.push_str("|:----:|--------|-------------:|--------:|-----:|--------:|\n");
for player in &comparisons { for (i, player) in comparisons.iter().enumerate() {
let s_diff_str = format!("{:+.0}", player.singles_diff); let diff_str = format!("{:+.0}", player.elo_diff);
let d_diff_str = format!("{:+.0}", player.doubles_diff);
md.push_str(&format!( md.push_str(&format!(
"| {} | {:.0} | {:.0} | {} | {:.0} | {:.0} | {} | {} |\n", "| {} | {} | {:.0} | {:.0} | {} | {} |\n",
i + 1,
player.name, player.name,
player.glicko2_singles, player.elo_singles, s_diff_str, player.glicko2_avg,
player.glicko2_doubles, player.elo_doubles, d_diff_str, player.elo_unified,
diff_str,
player.matches_played player.matches_played
)); ));
} }
md.push_str("\n## Biggest Rating Changes\n\n"); md.push_str("\n## Key Insights\n\n");
md.push_str("Players whose ratings changed the most in the conversion:\n\n");
for (i, player) in comparisons.iter().take(5).enumerate() { // Find biggest movers
let avg_diff = (player.singles_diff.abs() + player.doubles_diff.abs()) / 2.0; let mut by_diff: Vec<_> = comparisons.iter().collect();
by_diff.sort_by(|a, b| b.elo_diff.partial_cmp(&a.elo_diff).unwrap());
md.push_str("### Biggest Winners (rating increased)\n\n");
for player in by_diff.iter().take(3).filter(|p| p.elo_diff > 0.0) {
md.push_str(&format!( md.push_str(&format!(
"{}. **{}** - Average change: {:.0} points\n - Singles: {:+.0}\n - Doubles: {:+.0}\n", "- **{}**: {:+.0} points (Glicko avg {:.0} → ELO {:.0})\n",
i+1, player.name, avg_diff, player.singles_diff, player.doubles_diff player.name, player.elo_diff, player.glicko2_avg, player.elo_unified
)); ));
} }
md.push_str("\n### Biggest Losers (rating decreased)\n\n");
for player in by_diff.iter().rev().take(3).filter(|p| p.elo_diff < 0.0) {
md.push_str(&format!(
"- **{}**: {:+.0} points (Glicko avg {:.0} → ELO {:.0})\n",
player.name, player.elo_diff, player.glicko2_avg, player.elo_unified
));
}
md.push_str("\n## Why Ratings Changed\n\n");
md.push_str("The new system differs in several ways:\n\n");
md.push_str("1. **Per-point scoring**: Instead of just win/loss, we use `points_won / total_points`. ");
md.push_str("Winning 11-9 gives less credit than winning 11-2.\n\n");
md.push_str("2. **Effective opponent formula**: In doubles, your effective opponent is calculated as ");
md.push_str("`Opp1 + Opp2 - Teammate`. This means:\n");
md.push_str(" - Strong teammate → lower effective opponent → less credit for winning\n");
md.push_str(" - Weak teammate → higher effective opponent → more credit for winning\n\n");
md.push_str("3. **Unified rating**: Singles and doubles contribute to one rating instead of two.\n\n");
let md_path = "/Users/split/Projects/pickleball-elo/docs/rating-comparison.md"; let md_path = "/Users/split/Projects/pickleball-elo/docs/rating-comparison.md";
fs::write(md_path, md).ok(); fs::write(md_path, md).ok();
println!("💾 Markdown report saved to: {}", md_path); println!("💾 Markdown report saved to: {}", md_path);