Fix elo_analysis: use correct timestamp column, show real ratings
This commit is contained in:
parent
03a3d44149
commit
16e21346c2
@ -1,109 +1,87 @@
|
||||
{
|
||||
"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_players": 6
|
||||
},
|
||||
"players": [
|
||||
{
|
||||
"doubles": {
|
||||
"difference": 290.9,
|
||||
"elo": 1500.0,
|
||||
"glicko2": 1209.1
|
||||
},
|
||||
"id": 1,
|
||||
"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
|
||||
"difference": -112.8,
|
||||
"elo_unified": 1537.7,
|
||||
"glicko2": {
|
||||
"average": 1650.5,
|
||||
"doubles": 1718.2,
|
||||
"singles": 1582.9
|
||||
},
|
||||
"id": 2,
|
||||
"matches_played": 0,
|
||||
"name": "Andrew Stricklin",
|
||||
"singles": {
|
||||
"difference": -82.9,
|
||||
"elo": 1500.0,
|
||||
"glicko2": 1582.9
|
||||
}
|
||||
"matches_played": 19,
|
||||
"name": "Andrew Stricklin"
|
||||
},
|
||||
{
|
||||
"doubles": {
|
||||
"difference": 173.1,
|
||||
"elo": 1500.0,
|
||||
"glicko2": 1326.9
|
||||
},
|
||||
"id": 4,
|
||||
"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
|
||||
"difference": -40.2,
|
||||
"elo_unified": 1521.6,
|
||||
"glicko2": {
|
||||
"average": 1561.8,
|
||||
"doubles": 1661.5,
|
||||
"singles": 1462.2
|
||||
},
|
||||
"id": 3,
|
||||
"matches_played": 0,
|
||||
"name": "David Pabst",
|
||||
"singles": {
|
||||
"difference": 37.8,
|
||||
"elo": 1500.0,
|
||||
"glicko2": 1462.2
|
||||
}
|
||||
"matches_played": 11,
|
||||
"name": "David Pabst"
|
||||
},
|
||||
{
|
||||
"doubles": {
|
||||
"difference": -114.9,
|
||||
"elo": 1500.0,
|
||||
"glicko2": 1614.9
|
||||
"difference": -43.0,
|
||||
"elo_unified": 1514.5,
|
||||
"glicko2": {
|
||||
"average": 1557.5,
|
||||
"doubles": 1614.9,
|
||||
"singles": 1500.0
|
||||
},
|
||||
"id": 6,
|
||||
"matches_played": 0,
|
||||
"name": "Jacklyn Wyszynski",
|
||||
"singles": {
|
||||
"difference": 0.0,
|
||||
"elo": 1500.0,
|
||||
"glicko2": 1500.0
|
||||
}
|
||||
"matches_played": 9,
|
||||
"name": "Jacklyn Wyszynski"
|
||||
},
|
||||
{
|
||||
"doubles": {
|
||||
"difference": -5.8,
|
||||
"elo": 1500.0,
|
||||
"glicko2": 1505.8
|
||||
"difference": 11.3,
|
||||
"elo_unified": 1496.8,
|
||||
"glicko2": {
|
||||
"average": 1485.5,
|
||||
"doubles": 1505.8,
|
||||
"singles": 1465.1
|
||||
},
|
||||
"id": 5,
|
||||
"matches_played": 0,
|
||||
"name": "Eliana Crew",
|
||||
"singles": {
|
||||
"difference": 34.9,
|
||||
"elo": 1500.0,
|
||||
"glicko2": 1465.1
|
||||
}
|
||||
"matches_played": 13,
|
||||
"name": "Eliana Crew"
|
||||
},
|
||||
{
|
||||
"difference": 2.7,
|
||||
"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": {
|
||||
"doubles": {
|
||||
"avg_elo": 1500.0,
|
||||
"avg_glicko2": 1506.060606060606
|
||||
},
|
||||
"singles": {
|
||||
"avg_elo": 1500.0,
|
||||
"avg_glicko2": 1500.0
|
||||
}
|
||||
"system_description": {
|
||||
"new": "Pure ELO with unified rating, per-point scoring, effective opponent formula",
|
||||
"old": "Glicko-2 with separate singles/doubles ratings, RD, volatility"
|
||||
}
|
||||
}
|
||||
@ -1,38 +1,54 @@
|
||||
# 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
|
||||
|
||||
- **Total Players:** 6
|
||||
- **Total Matches:** 29
|
||||
- **Analysis Date:** 2026-02-26 11:33:29
|
||||
- **Total Matches Replayed:** 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 |
|
||||
|--------|------|------|------|------|------|------|--------|
|
||||
| Dane Sabo | 1371 | 1500 | +129 | 1209 | 1500 | +291 | 0 |
|
||||
| Andrew Stricklin | 1583 | 1500 | -83 | 1718 | 1500 | -218 | 0 |
|
||||
| Krzysztof Radziszeski | 1619 | 1500 | -119 | 1327 | 1500 | +173 | 0 |
|
||||
| David Pabst | 1462 | 1500 | +38 | 1661 | 1500 | -161 | 0 |
|
||||
| Jacklyn Wyszynski | 1500 | 1500 | +0 | 1615 | 1500 | -115 | 0 |
|
||||
| Eliana Crew | 1465 | 1500 | +35 | 1506 | 1500 | -6 | 0 |
|
||||
| Rank | Player | Glicko-2 Avg | New ELO | Diff | Matches |
|
||||
|:----:|--------|-------------:|--------:|-----:|--------:|
|
||||
| 1 | Andrew Stricklin | 1651 | 1538 | -113 | 19 |
|
||||
| 2 | David Pabst | 1562 | 1522 | -40 | 11 |
|
||||
| 3 | Jacklyn Wyszynski | 1557 | 1514 | -43 | 9 |
|
||||
| 4 | Eliana Crew | 1485 | 1497 | +11 | 13 |
|
||||
| 5 | Krzysztof Radziszeski | 1473 | 1476 | +3 | 25 |
|
||||
| 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
|
||||
|
||||
@ -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 std::collections::HashMap;
|
||||
@ -12,14 +13,13 @@ struct PlayerRatings {
|
||||
name: String,
|
||||
glicko2_singles: f64,
|
||||
glicko2_doubles: f64,
|
||||
elo_singles: f64,
|
||||
elo_doubles: f64,
|
||||
singles_diff: f64,
|
||||
doubles_diff: f64,
|
||||
elo_unified: f64,
|
||||
glicko2_avg: f64,
|
||||
elo_diff: f64,
|
||||
matches_played: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone)]
|
||||
struct MatchData {
|
||||
id: i64,
|
||||
match_type: String,
|
||||
@ -27,12 +27,41 @@ struct MatchData {
|
||||
team2_score: i32,
|
||||
team1_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]
|
||||
async fn main() {
|
||||
println!("🔄 ELO System Analysis Tool");
|
||||
println!("===========================\n");
|
||||
println!("🔄 ELO System Analysis Tool (v2 - Unified Rating)");
|
||||
println!("==================================================\n");
|
||||
|
||||
// Connect to database
|
||||
let db_path = "/Users/split/Projects/pickleball-elo/pickleball.db";
|
||||
@ -50,7 +79,7 @@ async fn main() {
|
||||
|
||||
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(
|
||||
"SELECT id, name, singles_rating, doubles_rating FROM players ORDER BY name"
|
||||
)
|
||||
@ -60,9 +89,10 @@ async fn main() {
|
||||
|
||||
println!("✅ Found {} players", players.len());
|
||||
|
||||
// Fetch all matches
|
||||
let matches: Vec<(i64, String, i32, i32)> = sqlx::query_as(
|
||||
"SELECT id, match_type, team1_score, team2_score FROM matches ORDER BY id"
|
||||
// Fetch all matches ordered by time
|
||||
let matches: Vec<(i64, String, i32, i32, String)> = sqlx::query_as(
|
||||
"SELECT id, match_type, team1_score, team2_score, COALESCE(timestamp, datetime('now')) as played_at
|
||||
FROM matches ORDER BY timestamp, id"
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
@ -70,13 +100,15 @@ async fn main() {
|
||||
|
||||
println!("✅ Found {} matches", matches.len());
|
||||
|
||||
// For each match, get participants
|
||||
let mut match_data_map: HashMap<i64, MatchData> = HashMap::new();
|
||||
// Build match data with participants
|
||||
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(
|
||||
"SELECT player_id FROM match_participants WHERE match_id = ? AND team = 1"
|
||||
)
|
||||
.bind(match_id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
@ -84,141 +116,124 @@ async fn main() {
|
||||
let team2_players: Vec<i64> = sqlx::query_scalar(
|
||||
"SELECT player_id FROM match_participants WHERE match_id = ? AND team = 2"
|
||||
)
|
||||
.bind(match_id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.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,
|
||||
match_type: match_type.clone(),
|
||||
team1_score: *team1_score,
|
||||
team2_score: *team2_score,
|
||||
team1_players,
|
||||
team2_players,
|
||||
played_at: played_at.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize ELO ratings (everyone starts at 1500)
|
||||
let mut elo_ratings: HashMap<i64, (f64, f64)> = HashMap::new(); // (singles, doubles)
|
||||
// Initialize UNIFIED ELO ratings (everyone starts at 1500)
|
||||
let mut elo_ratings: HashMap<i64, f64> = HashMap::new();
|
||||
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
|
||||
for (match_id, match_type, _team1_score, _team2_score) in &matches {
|
||||
if let Some(match_info) = match_data_map.get(match_id) {
|
||||
let is_doubles = match_type == "doubles";
|
||||
let team1_won = match_info.team1_score > match_info.team2_score;
|
||||
// Replay all matches chronologically
|
||||
for (i, match_data) in all_matches.iter().enumerate() {
|
||||
let is_doubles = match_data.match_type == "doubles" || match_data.team1_players.len() > 1;
|
||||
let team1_won = match_data.team1_score > match_data.team2_score;
|
||||
|
||||
// For each player, calculate rating change
|
||||
for player_id in &match_info.team1_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 per-point performance
|
||||
let team1_performance = per_point_performance(match_data.team1_score, match_data.team2_score);
|
||||
let team2_performance = per_point_performance(match_data.team2_score, match_data.team1_score);
|
||||
|
||||
// Calculate effective opponent rating
|
||||
let opponent_rating = if is_doubles {
|
||||
let opponent_ratings: Vec<f64> = match_info.team2_players.iter()
|
||||
.map(|pid| elo_ratings.get(pid).copied().unwrap_or((1500.0, 1500.0)).1)
|
||||
// Collect current ratings BEFORE updates
|
||||
let team1_ratings: Vec<(i64, f64)> = match_data.team1_players.iter()
|
||||
.map(|pid| (*pid, elo_ratings.get(pid).copied().unwrap_or(START_RATING)))
|
||||
.collect();
|
||||
let avg_opp_rating = opponent_ratings.iter().sum::<f64>() / opponent_ratings.len() as f64;
|
||||
|
||||
// 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)
|
||||
let team2_ratings: Vec<(i64, f64)> = match_data.team2_players.iter()
|
||||
.map(|pid| (*pid, elo_ratings.get(pid).copied().unwrap_or(START_RATING)))
|
||||
.collect();
|
||||
let avg_opp_rating = opponent_ratings.iter().sum::<f64>() / opponent_ratings.len() as f64;
|
||||
|
||||
// Teammate rating for effective opponent
|
||||
let teammate_rating = if match_info.team2_players.len() > 1 {
|
||||
let teammate_id = match_info.team2_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
|
||||
// Calculate and apply rating changes for Team 1
|
||||
for (player_id, player_rating) in &team1_ratings {
|
||||
let effective_opp = if is_doubles && team2_ratings.len() >= 2 && team1_ratings.len() >= 2 {
|
||||
// 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 {
|
||||
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 {
|
||||
match_info.team1_players.iter()
|
||||
.map(|pid| elo_ratings.get(pid).copied().unwrap_or((1500.0, 1500.0)).0)
|
||||
.next()
|
||||
.unwrap_or(1500.0)
|
||||
team1_ratings.get(0).map(|(_, r)| *r).unwrap_or(START_RATING)
|
||||
};
|
||||
|
||||
// Calculate performance
|
||||
let performance = if !team1_won { 1.0 } else { 0.0 };
|
||||
let expected = expected_score(*player_rating, effective_opp);
|
||||
let rating_change = K_FACTOR * (team2_performance - expected);
|
||||
let new_rating = player_rating + rating_change;
|
||||
|
||||
// ELO rating change
|
||||
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;
|
||||
elo_ratings.insert(*player_id, new_rating);
|
||||
}
|
||||
|
||||
if is_doubles {
|
||||
elo_ratings.insert(*player_id, (current_singles, new_rating));
|
||||
} else {
|
||||
elo_ratings.insert(*player_id, (new_rating, current_doubles));
|
||||
}
|
||||
}
|
||||
// Progress indicator every 5 matches
|
||||
if (i + 1) % 5 == 0 || i == all_matches.len() - 1 {
|
||||
println!(" Processed {}/{} matches", i + 1, all_matches.len());
|
||||
}
|
||||
}
|
||||
|
||||
println!("✅ 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;
|
||||
}
|
||||
}
|
||||
println!("\n✅ ELO ratings calculated\n");
|
||||
|
||||
// Build comparison data
|
||||
let mut comparisons = vec![];
|
||||
let mut comparisons: Vec<PlayerRatings> = vec![];
|
||||
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);
|
||||
|
||||
comparisons.push(PlayerRatings {
|
||||
@ -226,93 +241,126 @@ async fn main() {
|
||||
name: name.clone(),
|
||||
glicko2_singles: *glicko2_singles,
|
||||
glicko2_doubles: *glicko2_doubles,
|
||||
elo_singles,
|
||||
elo_doubles,
|
||||
singles_diff: elo_singles - glicko2_singles,
|
||||
doubles_diff: elo_doubles - glicko2_doubles,
|
||||
elo_unified,
|
||||
glicko2_avg,
|
||||
elo_diff: elo_unified - glicko2_avg,
|
||||
matches_played: matches,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by biggest differences
|
||||
comparisons.sort_by(|a, b| {
|
||||
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;
|
||||
b_total_diff.partial_cmp(&a_total_diff).unwrap()
|
||||
});
|
||||
// Sort by ELO rating (highest first)
|
||||
comparisons.sort_by(|a, b| b.elo_unified.partial_cmp(&a.elo_unified).unwrap());
|
||||
|
||||
// Print summary
|
||||
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
|
||||
let json_output = json!({
|
||||
"metadata": {
|
||||
"timestamp": chrono::Local::now().to_rfc3339(),
|
||||
"total_players": players.len(),
|
||||
"total_matches": matches.len(),
|
||||
"total_matches": all_matches.len(),
|
||||
"k_factor": K_FACTOR,
|
||||
"start_rating": START_RATING,
|
||||
},
|
||||
"summary": {
|
||||
"singles": {
|
||||
"avg_glicko2": players.iter().map(|(_, _, sr, _)| sr).sum::<f64>() / players.len() as f64,
|
||||
"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,
|
||||
}
|
||||
"system_description": {
|
||||
"old": "Glicko-2 with separate singles/doubles ratings, RD, volatility",
|
||||
"new": "Pure ELO with unified rating, per-point scoring, effective opponent formula",
|
||||
},
|
||||
"players": comparisons.iter().map(|p| json!({
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"singles": {
|
||||
"glicko2": (p.glicko2_singles * 10.0).round() / 10.0,
|
||||
"elo": (p.elo_singles * 10.0).round() / 10.0,
|
||||
"difference": (p.singles_diff * 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,
|
||||
"glicko2": {
|
||||
"singles": (p.glicko2_singles * 10.0).round() / 10.0,
|
||||
"doubles": (p.glicko2_doubles * 10.0).round() / 10.0,
|
||||
"average": (p.glicko2_avg * 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,
|
||||
})).collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
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();
|
||||
println!("💾 JSON report saved to: {}", json_path);
|
||||
println!("\n💾 JSON report saved to: {}", json_path);
|
||||
|
||||
// Write Markdown report
|
||||
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(&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("## Ratings Comparison\n\n");
|
||||
md.push_str("| Player | Singles (G2) | Singles (ELO) | Diff | Doubles (G2) | Doubles (ELO) | Diff | Matches |\n");
|
||||
md.push_str("|--------|------|------|------|------|------|------|--------|\n");
|
||||
md.push_str("## Ratings Comparison (Sorted by New ELO)\n\n");
|
||||
md.push_str("| Rank | Player | Glicko-2 Avg | New ELO | Diff | Matches |\n");
|
||||
md.push_str("|:----:|--------|-------------:|--------:|-----:|--------:|\n");
|
||||
|
||||
for player in &comparisons {
|
||||
let s_diff_str = format!("{:+.0}", player.singles_diff);
|
||||
let d_diff_str = format!("{:+.0}", player.doubles_diff);
|
||||
for (i, player) in comparisons.iter().enumerate() {
|
||||
let diff_str = format!("{:+.0}", player.elo_diff);
|
||||
md.push_str(&format!(
|
||||
"| {} | {:.0} | {:.0} | {} | {:.0} | {:.0} | {} | {} |\n",
|
||||
"| {} | {} | {:.0} | {:.0} | {} | {} |\n",
|
||||
i + 1,
|
||||
player.name,
|
||||
player.glicko2_singles, player.elo_singles, s_diff_str,
|
||||
player.glicko2_doubles, player.elo_doubles, d_diff_str,
|
||||
player.glicko2_avg,
|
||||
player.elo_unified,
|
||||
diff_str,
|
||||
player.matches_played
|
||||
));
|
||||
}
|
||||
|
||||
md.push_str("\n## Biggest Rating Changes\n\n");
|
||||
md.push_str("Players whose ratings changed the most in the conversion:\n\n");
|
||||
md.push_str("\n## Key Insights\n\n");
|
||||
|
||||
for (i, player) in comparisons.iter().take(5).enumerate() {
|
||||
let avg_diff = (player.singles_diff.abs() + player.doubles_diff.abs()) / 2.0;
|
||||
// Find biggest movers
|
||||
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!(
|
||||
"{}. **{}** - Average change: {:.0} points\n - Singles: {:+.0}\n - Doubles: {:+.0}\n",
|
||||
i+1, player.name, avg_diff, player.singles_diff, player.doubles_diff
|
||||
"- **{}**: {:+.0} points (Glicko avg {:.0} → ELO {:.0})\n",
|
||||
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";
|
||||
fs::write(md_path, md).ok();
|
||||
println!("💾 Markdown report saved to: {}", md_path);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user