diff --git a/brumby-soccer/benches/cri_interval.rs b/brumby-soccer/benches/cri_interval.rs index 224be45..35fb8e4 100644 --- a/brumby-soccer/benches/cri_interval.rs +++ b/brumby-soccer/benches/cri_interval.rs @@ -1,7 +1,7 @@ use criterion::{criterion_group, criterion_main, Criterion}; use brumby_soccer::interval; -use brumby_soccer::interval::{IntervalConfig, PruneThresholds, BivariateProbs, TeamProbs}; +use brumby_soccer::interval::{IntervalConfig, PruneThresholds, BivariateProbs, TeamProbs, UnivariateProbs}; fn criterion_benchmark(c: &mut Criterion) { fn run(intervals: u8, max_total_goals: u16) -> usize { @@ -11,6 +11,7 @@ fn criterion_benchmark(c: &mut Criterion) { team_probs: TeamProbs { h1_goals: BivariateProbs { home: 0.25, away: 0.25, common: 0.25 }, h2_goals: BivariateProbs { home: 0.25, away: 0.25, common: 0.25 }, + assists: UnivariateProbs { home: 1.0, away: 1.0 }, }, player_probs: vec![], prune_thresholds: PruneThresholds { diff --git a/brumby-soccer/benches/cri_isolate.rs b/brumby-soccer/benches/cri_isolate.rs index 3f5b66b..fc13e40 100644 --- a/brumby-soccer/benches/cri_isolate.rs +++ b/brumby-soccer/benches/cri_isolate.rs @@ -1,7 +1,7 @@ use brumby_soccer::domain::{OfferType, OutcomeType, Player, Side}; use criterion::{criterion_group, criterion_main, Criterion}; -use brumby_soccer::interval::{explore, Exploration, IntervalConfig, BivariateProbs, PruneThresholds, PlayerProbs, TeamProbs}; +use brumby_soccer::interval::{explore, Exploration, IntervalConfig, BivariateProbs, PruneThresholds, PlayerProbs, TeamProbs, UnivariateProbs}; use brumby_soccer::interval::query::isolate; fn criterion_benchmark(c: &mut Criterion) { @@ -13,6 +13,7 @@ fn criterion_benchmark(c: &mut Criterion) { team_probs: TeamProbs { h1_goals: BivariateProbs { home: 0.25, away: 0.25, common: 0.25 }, h2_goals: BivariateProbs { home: 0.25, away: 0.25, common: 0.25 }, + assists: UnivariateProbs { home: 1.0, away: 1.0 }, }, player_probs: vec![(player, PlayerProbs { goal: Some(0.25), assist: None })], prune_thresholds: PruneThresholds { diff --git a/brumby-soccer/src/bin/soc_prices.rs b/brumby-soccer/src/bin/soc_prices.rs index 56d5b24..9f637c1 100644 --- a/brumby-soccer/src/bin/soc_prices.rs +++ b/brumby-soccer/src/bin/soc_prices.rs @@ -13,14 +13,12 @@ use brumby::hash_lookup::HashLookup; use brumby::market::{Market, Overround, OverroundMethod, PriceBounds}; use brumby::probs::SliceExt; use brumby_soccer::data::{download_by_id, ContestSummary, SoccerFeedId}; -use brumby_soccer::domain::{ - FittingErrors, Offer, OfferType, OutcomeType, Over, Period, Player, Score, Side, -}; -use brumby_soccer::fit::ErrorType; +use brumby_soccer::domain::{FittingErrors, Offer, OfferType, OutcomeType, Over, Period, Score}; +use brumby_soccer::fit::{away_booksum, home_booksum, ErrorType}; use brumby_soccer::interval::query::isolate; use brumby_soccer::interval::{ explore, BivariateProbs, Expansions, Exploration, IntervalConfig, PlayerProbs, PruneThresholds, - TeamProbs, + TeamProbs, UnivariateProbs, }; use brumby_soccer::{fit, print}; @@ -82,11 +80,11 @@ async fn main() -> Result<(), Box> { info!("offered {offer_type:?}"); } - let ft_correct_score_prices = - contest.offerings[&OfferType::CorrectScore(Period::FullTime)].clone(); let ft_h2h_prices = contest.offerings[&OfferType::HeadToHead(Period::FullTime)].clone(); let ft_goals_ou_prices = contest.offerings[&OfferType::TotalGoals(Period::FullTime, Over(2))].clone(); + let ft_correct_score_prices = + contest.offerings[&OfferType::CorrectScore(Period::FullTime)].clone(); let h1_h2h_prices = contest.offerings[&OfferType::HeadToHead(Period::FirstHalf)].clone(); let h1_goals_ou_prices = contest.offerings[&OfferType::TotalGoals(Period::FirstHalf, Over(2))].clone(); @@ -98,11 +96,13 @@ async fn main() -> Result<(), Box> { let anytime_assist = contest.offerings[&OfferType::AnytimeAssist].clone(); let ft_h2h = fit_offer(OfferType::HeadToHead(Period::FullTime), &ft_h2h_prices, 1.0); + // println!("ft_h2h: {ft_h2h:?}"); let ft_goals_ou = fit_offer( OfferType::TotalGoals(Period::FullTime, Over(2)), &ft_goals_ou_prices, 1.0, ); + // println!("ft_goals_ou: {ft_goals_ou:?}"); let ft_correct_score = fit_offer( OfferType::CorrectScore(Period::FullTime), &ft_correct_score_prices, @@ -137,13 +137,21 @@ async fn main() -> Result<(), Box> { let mut adj_optimal_h1 = [0.0; 3]; let mut adj_optimal_h2 = [0.0; 3]; - for (i, orig_h1) in h1_search_outcome.optimal_values.iter().enumerate() { + // only adjust the home and away scoring probs; common prob is locked to the full-time one + for (i, orig_h1) in h1_search_outcome.optimal_values.iter().take(2).enumerate() { let orig_h2 = h2_search_outcome.optimal_values[i]; let ft = ft_search_outcome.optimal_values[i]; let avg_h1_h2 = (orig_h1 + orig_h2) / 2.0; - adj_optimal_h1[i] = orig_h1 / (avg_h1_h2 / ft); - adj_optimal_h2[i] = orig_h2 / (avg_h1_h2 / ft); + if avg_h1_h2 > 0.0 { + adj_optimal_h1[i] = orig_h1 / (avg_h1_h2 / ft); + adj_optimal_h2[i] = orig_h2 / (avg_h1_h2 / ft); + } else { + adj_optimal_h1[i] = ft; + adj_optimal_h2[i] = ft; + } } + adj_optimal_h1[2] = ft_search_outcome.optimal_values[2]; + adj_optimal_h2[2] = ft_search_outcome.optimal_values[2]; println!("adjusted optimal_h1={adj_optimal_h1:?}, optimal_h2={adj_optimal_h2:?}"); // let adj_optimal_h1 = h1_search_outcome.optimal_values; // let adj_optimal_h2 = h2_search_outcome.optimal_values; @@ -386,19 +394,6 @@ async fn main() -> Result<(), Box> { FIRST_GOALSCORER_BOOKSUM, ); let anytime_gs = fit_offer(OfferType::AnytimeGoalscorer, &anytime_gs, 1.0); - let home_goalscorer_booksum = first_gs - .market - .probs - .iter() - .zip(first_gs.outcomes.items().iter()) - .filter(|(_, outcome_type)| { - matches!( - outcome_type, - OutcomeType::Player(Player::Named(Side::Home, _)) - ) - }) - .map(|(prob, _)| prob) - .sum::(); // println!("scoregrid:\n{}sum: {}", scoregrid.verbose(), scoregrid.flatten().sum()); let draw_prob = isolate( @@ -462,6 +457,10 @@ async fn main() -> Result<(), Box> { team_probs: TeamProbs { h1_goals: BivariateProbs::from(adj_optimal_h1.as_slice()), h2_goals: BivariateProbs::from(adj_optimal_h2.as_slice()), + assists: UnivariateProbs { + home: 1.0, + away: 1.0, + }, }, player_probs: vec![( player.clone(), @@ -497,7 +496,8 @@ async fn main() -> Result<(), Box> { // fitted_first_goalscorer_probs.push(draw_prob); // fitted_first_goalscorer_probs.normalise(FIRST_GOALSCORER_BOOKSUM); - fitted_first_goalscorer_probs.push(FIRST_GOALSCORER_BOOKSUM - fitted_first_goalscorer_probs.sum()); + fitted_first_goalscorer_probs + .push(FIRST_GOALSCORER_BOOKSUM - fitted_first_goalscorer_probs.sum()); let fitted_first_goalscorer = Offer { offer_type: OfferType::FirstGoalscorer, @@ -536,6 +536,10 @@ async fn main() -> Result<(), Box> { team_probs: TeamProbs { h1_goals: BivariateProbs::from(adj_optimal_h1.as_slice()), h2_goals: BivariateProbs::from(adj_optimal_h2.as_slice()), + assists: UnivariateProbs { + home: 1.0, + away: 1.0, + }, }, player_probs: vec![( player.clone(), @@ -605,17 +609,32 @@ async fn main() -> Result<(), Box> { .map(|price| 1.0 / price) .sum::(); - let per_outcome_overround = (anytime_goalscorer_overround.value - 1.0) / anytime_gs.outcomes.len() as f64; + let per_outcome_overround = + (anytime_goalscorer_overround.value - 1.0) / anytime_gs.outcomes.len() as f64; let anytime_assist = fit_offer( OfferType::AnytimeAssist, &anytime_assist, - sample_anytime_assist_booksum / (1.0 + per_outcome_overround * anytime_assist.len() as f64) + sample_anytime_assist_booksum / (1.0 + per_outcome_overround * anytime_assist.len() as f64), ); + let home_goalscorer_booksum = home_booksum(&fitted_anytime_goalscorer); + let away_goalscorer_booksum = away_booksum(&fitted_anytime_goalscorer); + println!("partial goalscorer booksums: home: {home_goalscorer_booksum:.3}, away: {away_goalscorer_booksum:.3}"); + + let home_assister_booksum = home_booksum(&anytime_assist); + let away_assister_booksum = away_booksum(&anytime_assist); + println!("partial assister booksums: home: {home_assister_booksum:.3}, away: {away_assister_booksum:.3}"); + let assist_probs = UnivariateProbs { + home: home_assister_booksum / home_goalscorer_booksum, + away: away_assister_booksum / away_goalscorer_booksum, + }; + println!("assist_probs: {assist_probs:?}"); + let fitted_assist_probs = fit::fit_anytime_assist_all( &BivariateProbs::from(adj_optimal_h1.as_slice()), &BivariateProbs::from(adj_optimal_h2.as_slice()), + &assist_probs, &anytime_assist, draw_prob, anytime_assist.market.fair_booksum(), @@ -631,6 +650,7 @@ async fn main() -> Result<(), Box> { team_probs: TeamProbs { h1_goals: BivariateProbs::from(adj_optimal_h1.as_slice()), h2_goals: BivariateProbs::from(adj_optimal_h2.as_slice()), + assists: assist_probs.clone(), }, player_probs: vec![( player.clone(), @@ -981,7 +1001,14 @@ fn explore_scores(h1_goals: BivariateProbs, h2_goals: BivariateProbs) -> Explora explore( &IntervalConfig { intervals: INTERVALS as u8, - team_probs: TeamProbs { h1_goals, h2_goals }, + team_probs: TeamProbs { + h1_goals, + h2_goals, + assists: UnivariateProbs { + home: 1.0, + away: 1.0, + }, + }, player_probs: vec![], prune_thresholds: PruneThresholds { max_total_goals: MAX_TOTAL_GOALS_FULL, diff --git a/brumby-soccer/src/data.rs b/brumby-soccer/src/data.rs index 8423543..40e0d14 100644 --- a/brumby-soccer/src/data.rs +++ b/brumby-soccer/src/data.rs @@ -69,9 +69,9 @@ impl From for ContestSummary { offerings.insert( OfferType::FirstGoalscorer, HashMap::from_iter(players.into_iter().map(|player| { - if player.side.is_none() { - println!("PLAYER {player:?}"); - } + // if player.side.is_none() { + // println!("PLAYER {player:?}"); + // } let OutcomeOdds(outcome_type, odds) = OutcomeOdds::from(player); (outcome_type, odds) })), diff --git a/brumby-soccer/src/domain.rs b/brumby-soccer/src/domain.rs index c45845b..5ed16f1 100644 --- a/brumby-soccer/src/domain.rs +++ b/brumby-soccer/src/domain.rs @@ -1,3 +1,5 @@ +use std::iter::{Filter, Zip}; +use std::slice::Iter; use brumby::hash_lookup::HashLookup; use brumby::market::Market; @@ -77,6 +79,11 @@ pub struct Offer { pub outcomes: HashLookup, pub market: Market, } +impl Offer { + pub fn filter_outcomes_with_probs(&self, filter: F) -> Filter, Iter>, F> where F: FnMut(&(&OutcomeType, &f64)) -> bool{ + self.outcomes.items().iter().zip(self.market.probs.iter()).filter(filter) + } +} #[derive(Debug)] pub struct FittingErrors { diff --git a/brumby-soccer/src/fit.rs b/brumby-soccer/src/fit.rs index 59c5a36..20a56a4 100644 --- a/brumby-soccer/src/fit.rs +++ b/brumby-soccer/src/fit.rs @@ -13,7 +13,7 @@ use brumby::{factorial, poisson}; use crate::domain::Player::Named; use crate::domain::{Offer, OfferType, OutcomeType, Player, Side}; use crate::interval::query::isolate; -use crate::interval::{explore, Expansions, IntervalConfig, PruneThresholds, BivariateProbs, PlayerProbs, TeamProbs}; +use crate::interval::{explore, BivariateProbs, Expansions, IntervalConfig, PlayerProbs, PruneThresholds, TeamProbs, UnivariateProbs}; use crate::scoregrid; // const OVERROUND_METHOD: OverroundMethod = OverroundMethod::OddsRatio; @@ -42,12 +42,68 @@ impl ErrorType { } } +pub fn home_booksum(offer: &Offer) -> f64 { + offer + .filter_outcomes_with_probs(|(outcome_type, _)| { + matches!( + outcome_type, + OutcomeType::Player(Player::Named(Side::Home, _)) + ) + }) + .map(|(_, prob)| prob) + .sum() +} + +pub fn away_booksum(offer: &Offer) -> f64 { + offer + .filter_outcomes_with_probs(|(outcome_type, _)| { + matches!( + outcome_type, + OutcomeType::Player(Player::Named(Side::Away, _)) + ) + }) + .map(|(_, prob)| prob) + .sum() +} + +// pub fn fit_scoregrid_half(offers: &[&Offer]) -> HypergridSearchOutcome { +// let init_estimates = { +// let start = Instant::now(); +// let search_outcome = fit_bivariate_poisson_scoregrid(offers, MAX_TOTAL_GOALS_HALF); +// let elapsed = start.elapsed(); +// println!("biv-poisson: {elapsed:?} elapsed: search outcome: {search_outcome:?}, expectation: {:.3}", expectation_from_lambdas(&search_outcome.optimal_values)); +// search_outcome +// .optimal_values +// .iter() +// .map(|optimal_value| { +// 1.0 - poisson::univariate( +// 0, +// optimal_value / INTERVALS as f64 * 2.0, +// &factorial::Calculator, +// ) +// }) +// .collect::>() +// }; +// println!("initial estimates: {init_estimates:?}"); +// +// let start = Instant::now(); +// let search_outcome = fit_bivariate_binomial_scoregrid( +// offers, +// &init_estimates, +// (INTERVALS / 2) as u8, +// MAX_TOTAL_GOALS_HALF, +// ); +// let elapsed = start.elapsed(); +// println!("biv-binomial: {elapsed:?} elapsed: search outcome: {search_outcome:?}"); +// search_outcome +// } + pub fn fit_scoregrid_half(offers: &[&Offer]) -> HypergridSearchOutcome { let init_estimates = { let start = Instant::now(); - let search_outcome = fit_bivariate_poisson_scoregrid(offers, MAX_TOTAL_GOALS_HALF); + let search_outcome = fit_univariate_poisson_scoregrid(offers, MAX_TOTAL_GOALS_HALF); let elapsed = start.elapsed(); - println!("biv-poisson: {elapsed:?} elapsed: search outcome: {search_outcome:?}, expectation: {:.3}", expectation_from_lambdas(&search_outcome.optimal_values)); + println!("biv-poisson: {elapsed:?} elapsed: search outcome: {search_outcome:?}, expectation: {:.3}", expectation_from_univariate_poisson(&search_outcome.optimal_values)); search_outcome .optimal_values .iter() @@ -63,7 +119,7 @@ pub fn fit_scoregrid_half(offers: &[&Offer]) -> HypergridSearchOutcome { println!("initial estimates: {init_estimates:?}"); let start = Instant::now(); - let search_outcome = fit_bivariate_binomial_scoregrid( + let search_outcome = fit_univariate_binomial_scoregrid( offers, &init_estimates, (INTERVALS / 2) as u8, @@ -82,7 +138,7 @@ pub fn fit_scoregrid_full(offers: &[&Offer]) -> HypergridSearchOutcome { let elapsed = start.elapsed(); println!( "F/T: {elapsed:?} elapsed: search outcome: {search_outcome:?}, expectation: {:.3}", - expectation_from_lambdas(&search_outcome.optimal_values) + expectation_from_bivariate_poisson(&search_outcome.optimal_values) ); search_outcome .optimal_values @@ -148,8 +204,7 @@ pub fn fit_first_goalscorer_all( // println!("goal for player {player:?}, {player_search_outcome:?}, sample prob. {}, init_estimate: {init_estimate}", first_gs.market.probs[index]); fitted_goalscorer_probs.insert(player.clone(), player_search_outcome.optimal_value); } - OutcomeType::None => { - } + OutcomeType::None => {} _ => unreachable!(), } } @@ -170,8 +225,15 @@ fn fit_first_goalscorer_one( team_probs: TeamProbs { h1_goals: h1_goals.clone(), h2_goals: h2_goals.clone(), + assists: UnivariateProbs { home: 1.0, away: 1.0 }, }, - player_probs: vec![(player.clone(), PlayerProbs { goal: Some(0.0), assist: None })], + player_probs: vec![( + player.clone(), + PlayerProbs { + goal: Some(0.0), + assist: None, + }, + )], prune_thresholds: PruneThresholds { max_total_goals: MAX_TOTAL_GOALS_FULL, min_prob: GOALSCORER_MIN_PROB, @@ -211,6 +273,7 @@ fn fit_first_goalscorer_one( pub fn fit_anytime_assist_all( h1_probs: &BivariateProbs, h2_probs: &BivariateProbs, + assist_probs: &UnivariateProbs, anytime_assist: &Offer, draw_prob: f64, booksum: f64, @@ -219,8 +282,8 @@ pub fn fit_anytime_assist_all( let away_rate = (h1_probs.away + h2_probs.away) / 2.0; let common_rate = (h1_probs.common + h2_probs.common) / 2.0; let rate_sum = home_rate + away_rate + common_rate; - let home_ratio = (home_rate + common_rate / 2.0) / rate_sum * (1.0 - draw_prob); - let away_ratio = (away_rate + common_rate / 2.0) / rate_sum * (1.0 - draw_prob); + let home_ratio = (home_rate + common_rate / 2.0) / rate_sum * (1.0 - draw_prob) * assist_probs.home; + let away_ratio = (away_rate + common_rate / 2.0) / rate_sum * (1.0 - draw_prob) * assist_probs.away; // println!("home_ratio={home_ratio} + away_ratio={away_ratio}"); let mut fitted_assist_probs = BTreeMap::new(); let start = Instant::now(); @@ -238,6 +301,7 @@ pub fn fit_anytime_assist_all( let player_search_outcome = fit_anytime_assist_one( h1_probs, h2_probs, + assist_probs, player, init_estimate, anytime_assist.market.probs[index], @@ -257,6 +321,7 @@ pub fn fit_anytime_assist_all( fn fit_anytime_assist_one( h1_goals: &BivariateProbs, h2_goals: &BivariateProbs, + assist_probs: &UnivariateProbs, player: &Player, init_estimate: f64, expected_prob: f64, @@ -266,8 +331,15 @@ fn fit_anytime_assist_one( team_probs: TeamProbs { h1_goals: h1_goals.clone(), h2_goals: h2_goals.clone(), + assists: assist_probs.clone(), }, - player_probs: vec![(player.clone(), PlayerProbs { goal: None, assist: Some(0.0) })], + player_probs: vec![( + player.clone(), + PlayerProbs { + goal: None, + assist: Some(0.0), + }, + )], prune_thresholds: PruneThresholds { max_total_goals: MAX_TOTAL_GOALS_FULL, min_prob: GOALSCORER_MIN_PROB, @@ -304,7 +376,12 @@ fn fit_anytime_assist_one( ) } -fn expectation_from_lambdas(lambdas: &[f64]) -> f64 { +fn expectation_from_univariate_poisson(lambdas: &[f64]) -> f64 { + assert_eq!(2, lambdas.len()); + lambdas[0] + lambdas[1] +} + +fn expectation_from_bivariate_poisson(lambdas: &[f64]) -> f64 { assert_eq!(3, lambdas.len()); lambdas[0] + lambdas[1] + 2.0 * lambdas[2] } @@ -314,6 +391,27 @@ fn allocate_scoregrid(max_total_goals: u16) -> Matrix { Matrix::allocate(dim, dim) } +fn fit_univariate_poisson_scoregrid( + offers: &[&Offer], + max_total_goals: u16, +) -> HypergridSearchOutcome { + let mut scoregrid = allocate_scoregrid(max_total_goals); + let bounds = [0.2..=3.0, 0.2..=3.0]; + hypergrid_search( + &HypergridSearchConfig { + max_steps: 10, + acceptable_residual: 1e-6, + bounds: Capture::Borrowed(&bounds), + resolution: 10, + }, + |_| true, + |values| { + univariate_poisson_scoregrid(values[0], values[1], &mut scoregrid); + scoregrid_error(offers, &scoregrid) + }, + ) +} + fn fit_bivariate_poisson_scoregrid( offers: &[&Offer], max_total_goals: u16, @@ -335,6 +433,37 @@ fn fit_bivariate_poisson_scoregrid( ) } +fn fit_univariate_binomial_scoregrid( + offers: &[&Offer], + init_estimates: &[f64], + intervals: u8, + max_total_goals: u16, +) -> HypergridSearchOutcome { + let mut scoregrid = allocate_scoregrid(max_total_goals); + let bounds = init_estimates + .iter() + .map(|&estimate| (estimate * 0.67)..=(estimate * 1.5)) + .collect::>(); + hypergrid_search( + &HypergridSearchConfig { + max_steps: 10, + acceptable_residual: 1e-6, + bounds: Capture::Borrowed(&bounds), + resolution: 10, + }, + |values| values.sum() <= 1.0, + |values| { + univariate_binomial_scoregrid( + intervals, + values[0], + values[1], + &mut scoregrid, + ); + scoregrid_error(offers, &scoregrid) + }, + ) +} + fn fit_bivariate_binomial_scoregrid( offers: &[&Offer], init_estimates: &[f64], @@ -367,6 +496,16 @@ fn fit_bivariate_binomial_scoregrid( ) } +/// Univariate Poisson. +fn univariate_poisson_scoregrid( + home_rate: f64, + away_rate: f64, + scoregrid: &mut Matrix, +) { + scoregrid.fill(0.0); + scoregrid::from_univariate_poisson(home_rate, away_rate, scoregrid); +} + /// Bivariate Poisson. fn bivariate_poisson_scoregrid( home_rate: f64, @@ -376,7 +515,22 @@ fn bivariate_poisson_scoregrid( ) { scoregrid.fill(0.0); scoregrid::from_bivariate_poisson(home_rate, away_rate, common, scoregrid); - // scoregrid::inflate_zero(ZERO_INFLATION, &mut scoregrid); +} + +/// Univariate binomial. +fn univariate_binomial_scoregrid( + intervals: u8, + interval_home_prob: f64, + interval_away_prob: f64, + scoregrid: &mut Matrix, +) { + scoregrid.fill(0.0); + scoregrid::from_binomial( + intervals, + interval_home_prob, + interval_away_prob, + scoregrid, + ); } /// Bivariate binomial. diff --git a/brumby-soccer/src/interval.rs b/brumby-soccer/src/interval.rs index cc935c0..6deba34 100644 --- a/brumby-soccer/src/interval.rs +++ b/brumby-soccer/src/interval.rs @@ -92,7 +92,11 @@ impl Expansions { ); } assert!( - self.ft_score || self.ht_score || self.player_goal_stats || self.first_goalscorer || self.max_player_assists > 0, + self.ft_score + || self.ht_score + || self.player_goal_stats + || self.first_goalscorer + || self.max_player_assists > 0, "at least one expansion must be enabled" ) } @@ -128,7 +132,8 @@ impl Default for PruneThresholds { #[derive(Debug)] pub struct TeamProbs { pub h1_goals: BivariateProbs, - pub h2_goals: BivariateProbs + pub h2_goals: BivariateProbs, + pub assists: UnivariateProbs, } #[derive(Debug)] @@ -143,7 +148,7 @@ pub struct IntervalConfig { #[derive(Debug)] pub struct PlayerProbs { pub goal: Option, - pub assist: Option + pub assist: Option, } #[derive(Debug)] @@ -219,8 +224,14 @@ pub fn explore(config: &IntervalConfig, include_intervals: Range) -> Explora } player_lookup.push(Player::Other); let other_player_index = config.player_probs.len(); - home_scorers.push((config.player_probs.len(), 1.0 - combined_home_player_goal_prob)); - away_scorers.push((config.player_probs.len(), 1.0 - combined_away_player_goal_prob)); + home_scorers.push(( + config.player_probs.len(), + 1.0 - combined_home_player_goal_prob, + )); + away_scorers.push(( + config.player_probs.len(), + 1.0 - combined_away_player_goal_prob, + )); home_assisters.push((config.player_probs.len(), f64::NAN)); // the probability for 'other' is derived on the fly away_assisters.push((config.player_probs.len(), f64::NAN)); @@ -281,23 +292,55 @@ pub fn explore(config: &IntervalConfig, include_intervals: Range) -> Explora remaining_player_assist_prob -= player_assist_prob; *player_assist_prob }; - let partial = PartialProspect { - home_scorer: Some(*scorer_index), - away_scorer: None, - home_assister: Some(*assister_index), - away_assister: None, - first_scoring_side: Some(&Side::Home), - prob: params.home * player_score_prob * player_assist_prob, - }; - merge( - &config.expansions, - &half, - ¤t_prospect, - current_prob, - partial, - &mut next_prospects, - ); + + for (assister, assist_prob) in [ + (Some(*assister_index), config.team_probs.assists.home * player_assist_prob), + (None, (1.0 - config.team_probs.assists.home) * player_assist_prob) + ] { + if assist_prob == 0.0 { + continue; + } + merge( + &config.expansions, + &half, + ¤t_prospect, + current_prob, + PartialProspect { + home_scorer: Some(*scorer_index), + away_scorer: None, + home_assister: assister, + away_assister: None, + first_scoring_side: Some(&Side::Home), + prob: params.home + * player_score_prob + * assist_prob, + }, + &mut next_prospects, + ); + } } + + // // without assist + // if config.team_probs.assists.home != 1.0 { + // let partial = PartialProspect { + // home_scorer: Some(*scorer_index), + // away_scorer: None, + // home_assister: None, + // away_assister: None, + // first_scoring_side: Some(&Side::Home), + // prob: params.home + // * player_score_prob + // * (1.0 - config.team_probs.assists.home), + // }; + // merge( + // &config.expansions, + // &half, + // ¤t_prospect, + // current_prob, + // partial, + // &mut next_prospects, + // ); + // } } // only the away team scores @@ -313,23 +356,55 @@ pub fn explore(config: &IntervalConfig, include_intervals: Range) -> Explora remaining_player_assist_prob -= player_assist_prob; *player_assist_prob }; - let partial = PartialProspect { - home_scorer: None, - away_scorer: Some(*scorer_index), - home_assister: None, - away_assister: Some(*assister_index), - first_scoring_side: Some(&Side::Away), - prob: params.away * player_score_prob * player_assist_prob, - }; - merge( - &config.expansions, - &half, - ¤t_prospect, - current_prob, - partial, - &mut next_prospects, - ); + + for (assister, assist_prob) in [ + (Some(*assister_index), config.team_probs.assists.away * player_assist_prob), + (None, (1.0 - config.team_probs.assists.away) * player_assist_prob) + ] { + if assist_prob == 0.0 { + continue; + } + merge( + &config.expansions, + &half, + ¤t_prospect, + current_prob, + PartialProspect { + home_scorer: None, + away_scorer: Some(*scorer_index), + home_assister: None, + away_assister: assister, + first_scoring_side: Some(&Side::Away), + prob: params.away + * player_score_prob + * assist_prob, + }, + &mut next_prospects, + ); + } } + + // // without assist + // if config.team_probs.assists.away != 1.0 { + // let partial = PartialProspect { + // home_scorer: None, + // away_scorer: Some(*scorer_index), + // home_assister: None, + // away_assister: None, + // first_scoring_side: Some(&Side::Away), + // prob: params.away + // * player_score_prob + // * (1.0 - config.team_probs.assists.away), + // }; + // merge( + // &config.expansions, + // &half, + // ¤t_prospect, + // current_prob, + // partial, + // &mut next_prospects, + // ); + // } } } else { pruned += current_prob * (params.home + params.away); @@ -343,51 +418,242 @@ pub fn explore(config: &IntervalConfig, include_intervals: Range) -> Explora for first_scoring_side in [&Side::Home, &Side::Away] { let mut remaining_home_player_assist_prob = 1.0; for (home_assister_index, home_player_assist_prob) in &home_assisters { - if *home_scorer_index != other_player_index && home_assister_index == home_scorer_index { + if *home_scorer_index != other_player_index + && home_assister_index == home_scorer_index + { continue; } - let home_player_assist_prob = if home_assister_index == &other_player_index { + let home_player_assist_prob = if home_assister_index + == &other_player_index + { remaining_home_player_assist_prob } else { remaining_home_player_assist_prob -= home_player_assist_prob; *home_player_assist_prob }; + // with assist let mut remaining_away_player_assist_prob = 1.0; - for (away_assister_index, away_player_assist_prob) in &away_assisters { - if *away_scorer_index != other_player_index && away_assister_index == away_scorer_index { + for (away_assister_index, away_player_assist_prob) in + &away_assisters + { + if *away_scorer_index != other_player_index + && away_assister_index == away_scorer_index + { continue; } - let away_player_assist_prob = if away_assister_index == &other_player_index { - remaining_away_player_assist_prob - } else { - remaining_away_player_assist_prob -= away_player_assist_prob; - *away_player_assist_prob - }; - - let partial = PartialProspect { - home_scorer: Some(*home_scorer_index), - away_scorer: Some(*away_scorer_index), - home_assister: Some(*home_assister_index), - away_assister: Some(*away_assister_index), - first_scoring_side: Some(first_scoring_side), - prob: params.common - * 0.5 - * home_player_score_prob - * away_player_score_prob - * home_player_assist_prob - * away_player_assist_prob, - }; - merge( - &config.expansions, - &half, - ¤t_prospect, - current_prob, - partial, - &mut next_prospects, - ); + let away_player_assist_prob = + if away_assister_index == &other_player_index { + remaining_away_player_assist_prob + } else { + remaining_away_player_assist_prob -= + away_player_assist_prob; + *away_player_assist_prob + }; + + for (home_assister, away_assister, assist_prob) in [ + ( + Some(*home_assister_index), + Some(*away_assister_index), + config.team_probs.assists.home + * home_player_assist_prob + * config.team_probs.assists.away + * away_player_assist_prob, + ), + ( + Some(*home_assister_index), + None, + config.team_probs.assists.home + * home_player_assist_prob + * (1.0 - config.team_probs.assists.away) + * away_player_assist_prob, + ), + ( + None, + Some(*away_assister_index), + (1.0 - config.team_probs.assists.home) + * home_player_assist_prob + * config.team_probs.assists.away + * away_player_assist_prob, + ), + ( + None, + None, + (1.0 - config.team_probs.assists.home) + * home_player_assist_prob + * (1.0 - config.team_probs.assists.away) + * away_player_assist_prob, + ), + ] { + if assist_prob == 0.0 { + continue; + } + + merge( + &config.expansions, + &half, + ¤t_prospect, + current_prob, + PartialProspect { + home_scorer: Some(*home_scorer_index), + away_scorer: Some(*away_scorer_index), + home_assister, + away_assister, + first_scoring_side: Some(first_scoring_side), + prob: params.common + * 0.5 + * home_player_score_prob + * away_player_score_prob + * assist_prob, + }, + &mut next_prospects, + ); + } + + // // with home and away assist + // merge( + // &config.expansions, + // &half, + // ¤t_prospect, + // current_prob, + // PartialProspect { + // home_scorer: Some(*home_scorer_index), + // away_scorer: Some(*away_scorer_index), + // home_assister: Some(*home_assister_index), + // away_assister: Some(*away_assister_index), + // first_scoring_side: Some(first_scoring_side), + // prob: params.common + // * 0.5 + // * home_player_score_prob + // * away_player_score_prob + // * config.team_probs.assists.home + // * home_player_assist_prob + // * config.team_probs.assists.away + // * away_player_assist_prob, + // }, + // &mut next_prospects, + // ); + // + // // with home assist only + // merge( + // &config.expansions, + // &half, + // ¤t_prospect, + // current_prob, + // PartialProspect { + // home_scorer: Some(*home_scorer_index), + // away_scorer: Some(*away_scorer_index), + // home_assister: Some(*home_assister_index), + // away_assister: None, + // first_scoring_side: Some(first_scoring_side), + // prob: params.common + // * 0.5 + // * home_player_score_prob + // * away_player_score_prob + // * config.team_probs.assists.home + // * home_player_assist_prob + // * (1.0 - config.team_probs.assists.away) + // * away_player_assist_prob, + // }, + // &mut next_prospects, + // ); + // + // // with away assist only + // merge( + // &config.expansions, + // &half, + // ¤t_prospect, + // current_prob, + // PartialProspect { + // home_scorer: Some(*home_scorer_index), + // away_scorer: Some(*away_scorer_index), + // home_assister: None, + // away_assister: Some(*away_assister_index), + // first_scoring_side: Some(first_scoring_side), + // prob: params.common + // * 0.5 + // * home_player_score_prob + // * away_player_score_prob + // * (1.0 - config.team_probs.assists.home) + // * home_player_assist_prob + // * config.team_probs.assists.away + // * away_player_assist_prob, + // }, + // &mut next_prospects, + // ); + // + // // without assist + // merge( + // &config.expansions, + // &half, + // ¤t_prospect, + // current_prob, + // PartialProspect { + // home_scorer: Some(*home_scorer_index), + // away_scorer: Some(*away_scorer_index), + // home_assister: None, + // away_assister: None, + // first_scoring_side: Some(first_scoring_side), + // prob: params.common + // * 0.5 + // * home_player_score_prob + // * away_player_score_prob + // * (1.0 - config.team_probs.assists.home) + // * home_player_assist_prob + // * (1.0 - config.team_probs.assists.away) + // * away_player_assist_prob, + // }, + // &mut next_prospects, + // ); } + // + // // without assist + // let partial = PartialProspect { + // home_scorer: Some(*home_scorer_index), + // away_scorer: Some(*away_scorer_index), + // home_assister: Some(*home_assister_index), + // away_assister: None, + // first_scoring_side: Some(first_scoring_side), + // prob: params.common + // * 0.5 + // * home_player_score_prob + // * away_player_score_prob + // * config.team_probs.assists.home + // * home_player_assist_prob + // * (1.0 - config.team_probs.assists.away) + // }; + // merge( + // &config.expansions, + // &half, + // ¤t_prospect, + // current_prob, + // partial, + // &mut next_prospects, + // ); } + + // // without assist + // let partial = PartialProspect { + // home_scorer: Some(*home_scorer_index), + // away_scorer: Some(*away_scorer_index), + // home_assister: None, + // away_assister: None, + // first_scoring_side: Some(first_scoring_side), + // prob: params.common + // * 0.5 + // * home_player_score_prob + // * away_player_score_prob + // * (1.0 - config.team_probs.assists.home) + // * (1.0 - config.team_probs.assists.away) + // }; + // merge( + // &config.expansions, + // &half, + // ¤t_prospect, + // current_prob, + // partial, + // &mut next_prospects, + // ); } } } diff --git a/brumby-soccer/src/interval/query.rs b/brumby-soccer/src/interval/query.rs index 3af4a9d..b9c723e 100644 --- a/brumby-soccer/src/interval/query.rs +++ b/brumby-soccer/src/interval/query.rs @@ -107,7 +107,7 @@ pub fn isolate_set( #[cfg(test)] mod tests { use crate::domain::{Period, Score, Side}; - use crate::interval::{explore, IntervalConfig, BivariateProbs, TeamProbs}; + use crate::interval::{explore, IntervalConfig, BivariateProbs, TeamProbs, UnivariateProbs}; use super::*; @@ -119,6 +119,7 @@ mod tests { team_probs: TeamProbs { h1_goals: BivariateProbs { home: 0.25, away: 0.25, common: 0.25 }, h2_goals: BivariateProbs { home: 0.25, away: 0.25, common: 0.25 }, + assists: UnivariateProbs { home: 1.0, away: 1.0 }, }, player_probs: vec![], prune_thresholds: Default::default(), @@ -160,6 +161,7 @@ mod tests { team_probs: TeamProbs { h1_goals: BivariateProbs { home: 0.25, away: 0.25, common: 0.25 }, h2_goals: BivariateProbs { home: 0.25, away: 0.25, common: 0.25 }, + assists: UnivariateProbs { home: 1.0, away: 1.0 }, }, player_probs: vec![], prune_thresholds: Default::default(), @@ -221,6 +223,7 @@ mod tests { team_probs: TeamProbs { h1_goals: BivariateProbs { home: 0.25, away: 0.25, common: 0.25 }, h2_goals: BivariateProbs { home: 0.25, away: 0.25, common: 0.25 }, + assists: UnivariateProbs { home: 1.0, away: 1.0 }, }, player_probs: vec![], prune_thresholds: Default::default(), diff --git a/brumby-soccer/src/interval/query/anytime_assist.rs b/brumby-soccer/src/interval/query/anytime_assist.rs index 0e25a1b..7da78ee 100644 --- a/brumby-soccer/src/interval/query/anytime_assist.rs +++ b/brumby-soccer/src/interval/query/anytime_assist.rs @@ -40,9 +40,10 @@ pub(crate) fn filter(query: &QuerySpec, prospect: &Prospect) -> bool { #[cfg(test)] mod tests { + use assert_float_eq::*; use super::*; use crate::domain::{Period, Score, Side}; - use crate::interval::{explore, IntervalConfig, PlayerProbs, BivariateProbs, TeamProbs}; + use crate::interval::{explore, IntervalConfig, PlayerProbs, BivariateProbs, TeamProbs, UnivariateProbs}; fn print_prospects(prospects: &Prospects) { for (prospect, prob) in prospects { @@ -68,6 +69,7 @@ mod tests { away: 0.25, common: 0.25, }, + assists: UnivariateProbs { home: 0.5, away: 1.0 }, }, player_probs: vec![ ( @@ -98,6 +100,7 @@ mod tests { 0..1, ); print_prospects(&exploration.prospects); + assert_float_relative_eq!(1.0, exploration.prospects.values().sum::()); let alice_to_bob = isolate_set( &[ @@ -110,7 +113,7 @@ mod tests { &exploration.prospects, &exploration.player_lookup, ); - assert_eq!((0.25 + 0.25) * 0.4 * 0.25, alice_to_bob, "{alice_to_bob}"); + assert_eq!((0.25 + 0.25) * 0.4 * 0.25 * 0.5, alice_to_bob, "{alice_to_bob}"); let bob_to_alice = isolate_set( &[ @@ -123,7 +126,7 @@ mod tests { &exploration.prospects, &exploration.player_lookup, ); - assert_eq!((0.25 + 0.25) * 0.25 * 0.4, bob_to_alice, "{bob_to_alice}"); + assert_eq!((0.25 + 0.25) * 0.25 * 0.4 * 0.5, bob_to_alice, "{bob_to_alice}"); let alice_to_alice = isolate_set( &[ @@ -157,6 +160,7 @@ mod tests { away: 0.25, common: 0.25, }, + assists: UnivariateProbs { home: 0.3, away: 0.4 }, }, player_probs: vec![ ( @@ -187,6 +191,7 @@ mod tests { 0..1, ); print_prospects(&exploration.prospects); + assert_float_relative_eq!(1.0, exploration.prospects.values().sum::()); let alice_to_bob = isolate_set( &[ diff --git a/brumby-soccer/src/interval/tests.rs b/brumby-soccer/src/interval/tests.rs index 6f197d6..8dbd236 100644 --- a/brumby-soccer/src/interval/tests.rs +++ b/brumby-soccer/src/interval/tests.rs @@ -30,6 +30,7 @@ fn explore_2x2() { team_probs: TeamProbs { h1_goals: BivariateProbs { home: 0.25, away: 0.25, common: 0.25 }, h2_goals: BivariateProbs { home: 0.25, away: 0.25, common: 0.25 }, + assists: UnivariateProbs { home: 1.0, away: 1.0 }, }, player_probs: vec![], prune_thresholds: Default::default(), @@ -245,6 +246,7 @@ fn explore_2x2_pruned_2_goals() { team_probs: TeamProbs { h1_goals: BivariateProbs { home: 0.25, away: 0.25, common: 0.25 }, h2_goals: BivariateProbs { home: 0.25, away: 0.25, common: 0.25 }, + assists: UnivariateProbs { home: 1.0, away: 1.0 }, }, player_probs: vec![], prune_thresholds: PruneThresholds { @@ -386,6 +388,7 @@ fn explore_3x3() { team_probs: TeamProbs { h1_goals: BivariateProbs { home: 0.25, away: 0.25, common: 0.25 }, h2_goals: BivariateProbs { home: 0.25, away: 0.25, common: 0.25 }, + assists: UnivariateProbs { home: 1.0, away: 1.0 }, }, player_probs: vec![], prune_thresholds: Default::default(), @@ -407,6 +410,7 @@ fn explore_4x4() { team_probs: TeamProbs { h1_goals: BivariateProbs { home: 0.25, away: 0.25, common: 0.25 }, h2_goals: BivariateProbs { home: 0.25, away: 0.25, common: 0.25 }, + assists: UnivariateProbs { home: 1.0, away: 1.0 }, }, player_probs: vec![], prune_thresholds: Default::default(), @@ -429,6 +433,7 @@ fn explore_1x1_player_goal() { team_probs: TeamProbs { h1_goals: BivariateProbs { home: 0.25, away: 0.25, common: 0.25 }, h2_goals: BivariateProbs { home: 0.25, away: 0.25, common: 0.25 }, + assists: UnivariateProbs { home: 1.0, away: 1.0 }, }, player_probs: vec![(player.clone(), PlayerProbs { goal: Some(0.25), assist: None })], prune_thresholds: Default::default(), @@ -609,6 +614,7 @@ fn explore_2x2_player_goal() { team_probs: TeamProbs { h1_goals: BivariateProbs { home: 0.25, away: 0.25, common: 0.25 }, h2_goals: BivariateProbs { home: 0.25, away: 0.25, common: 0.25 }, + assists: UnivariateProbs { home: 0.5, away: 0.5 }, }, player_probs: vec![(player.clone(), PlayerProbs { goal: Some(0.25), assist: None })], prune_thresholds: Default::default(), @@ -654,6 +660,7 @@ fn explore_2x2_player_goal_asymmetric() { team_probs: TeamProbs { h1_goals: BivariateProbs { home: 0.3, away: 0.2, common: 0.1 }, h2_goals: BivariateProbs { home: 0.3, away: 0.2, common: 0.1 }, + assists: UnivariateProbs { home: 1.0, away: 1.0 }, }, player_probs: vec![(player.clone(), PlayerProbs { goal: Some(0.25), assist: None })], prune_thresholds: Default::default(), diff --git a/brumby-soccer/src/scoregrid.rs b/brumby-soccer/src/scoregrid.rs index d34e884..1f897d1 100644 --- a/brumby-soccer/src/scoregrid.rs +++ b/brumby-soccer/src/scoregrid.rs @@ -6,7 +6,7 @@ use brumby::multinomial::binomial; use brumby::comb::{count_permutations, pick}; use crate::domain::{OutcomeType, Score, Side}; -use crate::interval::{Expansions, explore, IntervalConfig, PruneThresholds, BivariateProbs, TeamProbs}; +use crate::interval::{Expansions, explore, IntervalConfig, PruneThresholds, BivariateProbs, TeamProbs, UnivariateProbs}; use brumby::linear::matrix::Matrix; use brumby::multinomial::bivariate_binomial; use brumby::probs::SliceExt; @@ -164,6 +164,7 @@ pub fn from_interval( team_probs: TeamProbs { h1_goals, h2_goals, + assists: UnivariateProbs { home: 1.0, away: 1.0 }, }, player_probs: vec![], prune_thresholds: PruneThresholds {