diff --git a/Cargo.toml b/Cargo.toml index 805500f..b3a169e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ exclude = ["/images", "/bin", "/.idea", "/.github", "/coverage", "/doc", "/examp anyhow = "1.0.75" chrono = "0.4.31" clap = { version = "4.4.6", features = ["derive"] } -racing_scraper = "0.0.15" +racing_scraper = "0.0.18" serde_json = "1.0.107" stanza = "0.3.0" tinyrand = "0.5.0" diff --git a/brumby-soccer/benches/cri_interval.rs b/brumby-soccer/benches/cri_interval.rs index e5bc2b1..b751cff 100644 --- a/brumby-soccer/benches/cri_interval.rs +++ b/brumby-soccer/benches/cri_interval.rs @@ -10,7 +10,7 @@ fn criterion_benchmark(c: &mut Criterion) { intervals, h1_probs: ScoringProbs { home_prob: 0.25, away_prob: 0.25, common_prob: 0.25 }, h2_probs: ScoringProbs { home_prob: 0.25, away_prob: 0.25, common_prob: 0.25 }, - players: vec![], + player_probs: vec![], prune_thresholds: PruneThresholds { max_total_goals, min_prob: 1e-6, diff --git a/brumby-soccer/benches/cri_isolate.rs b/brumby-soccer/benches/cri_isolate.rs index 272eab9..cbc69ce 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, ScoringProbs, PruneThresholds}; +use brumby_soccer::interval::{explore, Exploration, IntervalConfig, ScoringProbs, PruneThresholds, PlayerProbs}; use brumby_soccer::interval::query::isolate; fn criterion_benchmark(c: &mut Criterion) { @@ -12,7 +12,7 @@ fn criterion_benchmark(c: &mut Criterion) { intervals, h1_probs: ScoringProbs { home_prob: 0.25, away_prob: 0.25, common_prob: 0.25 }, h2_probs: ScoringProbs { home_prob: 0.25, away_prob: 0.25, common_prob: 0.25 }, - players: vec![(player, 0.25)], + player_probs: vec![(player, PlayerProbs { goal: Some(0.25), assist: None })], prune_thresholds: PruneThresholds { max_total_goals, min_prob: 1e-6, diff --git a/brumby-soccer/src/bin/soc_prices.rs b/brumby-soccer/src/bin/soc_prices.rs index a8cd9d4..748984d 100644 --- a/brumby-soccer/src/bin/soc_prices.rs +++ b/brumby-soccer/src/bin/soc_prices.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; use anyhow::bail; use clap::Parser; -use racing_scraper::sports::PROVIDER; +use racing_scraper::sports::Provider; use stanza::renderer::console::Console; use stanza::renderer::Renderer; use tracing::{debug, info}; @@ -15,19 +15,17 @@ use brumby::hash_lookup::HashLookup; use brumby::market::{Market, Overround, OverroundMethod, PriceBounds}; use brumby::probs::SliceExt; use brumby_soccer::{fit, print}; -use brumby_soccer::data::{ContestSummary, DataProvider, download_by_id}; +use brumby_soccer::data::{ContestSummary, DataProvider, download_by_id, SoccerFeedId}; use brumby_soccer::domain::{ FittingErrors, Offer, OfferType, OutcomeType, Over, Period, Score, }; use brumby_soccer::fit::ErrorType; -use brumby_soccer::interval::{ - Expansions, Exploration, explore, IntervalConfig, PruneThresholds, ScoringProbs, -}; +use brumby_soccer::interval::{Expansions, Exploration, explore, IntervalConfig, PlayerProbs, PruneThresholds, ScoringProbs}; use brumby_soccer::interval::query::isolate; const OVERROUND_METHOD: OverroundMethod = OverroundMethod::OddsRatio; const SINGLE_PRICE_BOUNDS: PriceBounds = 1.01..=301.0; -const FIRST_GOALSCORER_BOOKSUM: f64 = 1.0; +const FIRST_GOALSCORER_BOOKSUM: f64 = 1.9; const INTERVALS: usize = 18; // const MAX_TOTAL_GOALS_HALF: u16 = 4; const MAX_TOTAL_GOALS_FULL: u16 = 8; @@ -42,12 +40,16 @@ struct Args { /// download contest data by ID #[clap(short = 'd', long)] - download: Option, - // download: Option, + // download: Option, + download: Option, - /// print player markets + /// print player goal markets #[clap(long = "player-goals")] player_goals: bool, + + /// print player assists markets + #[clap(long = "player-assists")] + player_assists: bool, } impl Args { fn validate(&self) -> anyhow::Result<()> { @@ -92,6 +94,7 @@ async fn main() -> Result<(), Box> { contest.offerings[&OfferType::TotalGoals(Period::SecondHalf, Over(2))].clone(); let first_gs = contest.offerings[&OfferType::FirstGoalscorer].clone(); let anytime_gs = contest.offerings[&OfferType::AnytimeGoalscorer].clone(); + let anytime_assist = contest.offerings[&OfferType::AnytimeAssist].clone(); let ft_h2h = fit_offer(OfferType::HeadToHead(Period::FullTime), &ft_h2h_prices, 1.0); let ft_goals_ou = fit_offer( @@ -446,7 +449,7 @@ async fn main() -> Result<(), Box> { // h2_probs: ModelParams { home_prob: ft_search_outcome.optimal_values[0], away_prob: ft_search_outcome.optimal_values[1], common_prob: ft_search_outcome.optimal_values[2] }, h1_probs: ScoringProbs::from(adj_optimal_h1.as_slice()), h2_probs: ScoringProbs::from(adj_optimal_h2.as_slice()), - players: vec![(player.clone(), *prob)], + player_probs: vec![(player.clone(), PlayerProbs { goal: Some(*prob), assist: None })], prune_thresholds: PruneThresholds { max_total_goals: MAX_TOTAL_GOALS_FULL, min_prob: GOALSCORER_MIN_PROB, @@ -454,8 +457,9 @@ async fn main() -> Result<(), Box> { expansions: Expansions { ht_score: false, ft_score: false, - player_stats: false, - player_split_stats: false, + player_goal_stats: false, + player_split_goal_stats: false, + max_player_assists: 0, first_goalscorer: true, }, }, @@ -508,11 +512,9 @@ async fn main() -> Result<(), Box> { let exploration = explore( &IntervalConfig { intervals: INTERVALS as u8, - // h1_probs: ModelParams { home_prob: ft_search_outcome.optimal_values[0], away_prob: ft_search_outcome.optimal_values[1], common_prob: ft_search_outcome.optimal_values[2] }, - // h2_probs: ModelParams { home_prob: ft_search_outcome.optimal_values[0], away_prob: ft_search_outcome.optimal_values[1], common_prob: ft_search_outcome.optimal_values[2] }, h1_probs: ScoringProbs::from(adj_optimal_h1.as_slice()), h2_probs: ScoringProbs::from(adj_optimal_h2.as_slice()), - players: vec![(player.clone(), *prob)], + player_probs: vec![(player.clone(), PlayerProbs { goal: Some(*prob), assist: None })], prune_thresholds: PruneThresholds { max_total_goals: MAX_TOTAL_GOALS_FULL, min_prob: GOALSCORER_MIN_PROB, @@ -520,8 +522,9 @@ async fn main() -> Result<(), Box> { expansions: Expansions { ht_score: false, ft_score: false, - player_stats: true, - player_split_stats: false, + player_goal_stats: true, + player_split_goal_stats: false, + max_player_assists: 0, first_goalscorer: false, }, }, @@ -534,18 +537,10 @@ async fn main() -> Result<(), Box> { &exploration.player_lookup, ); fitted_anytime_goalscorer_probs.push(isolated_prob); - // println!("anytime scorer {player:?}, prob: {isolated_prob:.3}"); } fitted_anytime_goalscorer_outcomes.push(OutcomeType::None); - // fitted_anytime_goalscorer_probs.normalise(home_away_expectations.0 + home_away_expectations.1); fitted_anytime_goalscorer_probs.push(draw_prob); - // let anytime_goalscorer_booksum = fitted_anytime_goalscorer_probs.sum(); - // let anytime_goalscorer_overround = Market::fit( - // &OVERROUND_METHOD, - // anytime_gs.market.prices.clone(), - // anytime_goalscorer_booksum, - // ).overround; let anytime_goalscorer_overround = Overround { method: OVERROUND_METHOD, value: anytime_gs.market.offered_booksum() / fitted_anytime_goalscorer_probs.sum() @@ -571,11 +566,94 @@ async fn main() -> Result<(), Box> { fitted_anytime_goalscorer.offer_type, fitted_anytime_goalscorer.market.probs.sum(), implied_booksum(&fitted_anytime_goalscorer.market.prices), - fitted_first_goalscorer.market.probs.len(), + fitted_anytime_goalscorer.market.probs.len(), Console::default().render(&table_anytime_goalscorer) ); } + let sample_anytime_assist_booksum = anytime_assist.values().map(|price| 1.0 / price).sum::(); + + let anytime_assist = fit_offer( + OfferType::AnytimeAssist, + &anytime_assist, + sample_anytime_assist_booksum / anytime_goalscorer_overround.value, + ); + + let fitted_assist_probs = fit::fit_anytime_assist_all( + &ScoringProbs::from(adj_optimal_h1.as_slice()), + &ScoringProbs::from(adj_optimal_h2.as_slice()), + &anytime_assist, + draw_prob, + anytime_assist.market.fair_booksum() + ); + + let mut fitted_anytime_assist_outcomes = + HashLookup::with_capacity(fitted_assist_probs.len()); + let mut fitted_anytime_assist_probs = Vec::with_capacity(fitted_assist_probs.len()); + for (player, prob) in &fitted_assist_probs { + fitted_anytime_assist_outcomes.push(OutcomeType::Player(player.clone())); + let exploration = explore( + &IntervalConfig { + intervals: INTERVALS as u8, + h1_probs: ScoringProbs::from(adj_optimal_h1.as_slice()), + h2_probs: ScoringProbs::from(adj_optimal_h2.as_slice()), + player_probs: vec![(player.clone(), PlayerProbs { goal: None, assist: Some(*prob) })], + prune_thresholds: PruneThresholds { + max_total_goals: MAX_TOTAL_GOALS_FULL, + min_prob: GOALSCORER_MIN_PROB, + }, + expansions: Expansions { + ht_score: false, + ft_score: false, + player_goal_stats: false, + player_split_goal_stats: false, + max_player_assists: 1, + first_goalscorer: false, + }, + }, + 0..INTERVALS as u8, + ); + let isolated_prob = isolate( + &OfferType::AnytimeAssist, + &OutcomeType::Player(player.clone()), + &exploration.prospects, + &exploration.player_lookup, + ); + fitted_anytime_assist_probs.push(isolated_prob); + } + fitted_anytime_assist_outcomes.push(OutcomeType::None); + fitted_anytime_assist_probs.push(draw_prob); + + let anytime_assist_overround = Overround { + method: OVERROUND_METHOD, + value: anytime_assist.market.offered_booksum() / fitted_anytime_assist_probs.sum() + }; + let fitted_anytime_assist = Offer { + offer_type: OfferType::AnytimeAssist, + outcomes: fitted_anytime_assist_outcomes, + market: Market::frame( + &anytime_assist_overround, + fitted_anytime_assist_probs, + &SINGLE_PRICE_BOUNDS, + ), + }; + + if args.player_assists { + println!( + "sample anytime assists σ={:.3}", + implied_booksum(&anytime_assist.market.prices) + ); + let table_anytime_assist = print::tabulate_offer(&fitted_anytime_assist); + println!( + "{:?}: [Σ={:.3}, σ={:.3}, n={}]\n{}", + fitted_anytime_assist.offer_type, + fitted_anytime_assist.market.probs.sum(), + implied_booksum(&fitted_anytime_assist.market.prices), + fitted_anytime_assist.market.probs.len(), + Console::default().render(&table_anytime_assist) + ); + } + let market_errors = [ (&h1_h2h, &fitted_h1_h2h), (&h1_goals_ou, &fitted_h1_goals_ou), @@ -586,6 +664,7 @@ async fn main() -> Result<(), Box> { (&ft_correct_score, &fitted_ft_correct_score), (&first_gs, &fitted_first_goalscorer), (&anytime_gs, &fitted_anytime_goalscorer), + (&anytime_assist, &fitted_anytime_assist), ] .iter() .map(|(sample, fitted)| { @@ -622,6 +701,7 @@ async fn main() -> Result<(), Box> { fitted_ft_correct_score, fitted_first_goalscorer, fitted_anytime_goalscorer, + fitted_anytime_assist, ]; let table_overrounds = print::tabulate_overrounds(&fitted_markets); @@ -861,7 +941,7 @@ fn explore_scores(h1_probs: ScoringProbs, h2_probs: ScoringProbs) -> Exploration intervals: INTERVALS as u8, h1_probs, h2_probs, - players: vec![], + player_probs: vec![], prune_thresholds: PruneThresholds { max_total_goals: MAX_TOTAL_GOALS_FULL, min_prob: 0.0, @@ -869,8 +949,9 @@ fn explore_scores(h1_probs: ScoringProbs, h2_probs: ScoringProbs) -> Exploration expansions: Expansions { ht_score: true, ft_score: true, - player_stats: false, - player_split_stats: false, + player_goal_stats: false, + player_split_goal_stats: false, + max_player_assists: 0, first_goalscorer: false, }, }, @@ -989,7 +1070,7 @@ async fn read_contest_data(args: &Args) -> anyhow::Result { //ContestModel::read_json_file(path)? unimplemented!() } else if let Some(id) = args.download.as_ref() { - download_by_id(FeedId::new(DataProvider(PROVIDER::PointsBet), id.clone())).await? + download_by_id(id.clone()).await? } else { unreachable!() } diff --git a/brumby-soccer/src/data.rs b/brumby-soccer/src/data.rs index fefdbe3..8423543 100644 --- a/brumby-soccer/src/data.rs +++ b/brumby-soccer/src/data.rs @@ -4,7 +4,7 @@ use racing_scraper::sports::soccer::market_model::{HomeAway, Player as ScraperPl use std::collections::HashMap; use std::fmt::{Display, Formatter}; use std::str::FromStr; -use racing_scraper::sports::{get_sports_contest, PROVIDER}; +use racing_scraper::sports::{get_sports_contest, Provider}; use thiserror::Error; use brumby::feed_id::FeedId; @@ -56,20 +56,23 @@ impl From for ContestSummary { ]), ); } - SoccerMarket::AnyTimeGoalScorer(scorers) => { + SoccerMarket::AnytimeGoalScorer(players) => { offerings.insert( OfferType::AnytimeGoalscorer, - HashMap::from_iter(scorers.into_iter().map(|scorer| { - let OutcomeOdds(outcome_type, odds) = OutcomeOdds::from(scorer); + HashMap::from_iter(players.into_iter().map(|player| { + let OutcomeOdds(outcome_type, odds) = OutcomeOdds::from(player); (outcome_type, odds) })), ); } - SoccerMarket::FirstGoalScorer(scorers) => { + SoccerMarket::FirstGoalScorer(players) => { offerings.insert( OfferType::FirstGoalscorer, - HashMap::from_iter(scorers.into_iter().map(|scorer| { - let OutcomeOdds(outcome_type, odds) = OutcomeOdds::from(scorer); + HashMap::from_iter(players.into_iter().map(|player| { + if player.side.is_none() { + println!("PLAYER {player:?}"); + } + let OutcomeOdds(outcome_type, odds) = OutcomeOdds::from(player); (outcome_type, odds) })), ); @@ -102,15 +105,15 @@ impl From for ContestSummary { })), ); } - SoccerMarket::TotalGoalsOddEven(_) => { - //TODO - } - SoccerMarket::FirstHalfGoalsOddEven(_) => { - //TODO - } - SoccerMarket::SecondHalfGoalOddEven(_) => { - //TODO - } + // SoccerMarket::TotalGoalsOddEven(_) => { + // //TODO + // } + // SoccerMarket::FirstHalfGoalsOddEven(_) => { + // //TODO + // } + // SoccerMarket::SecondHalfGoalOddEven(_) => { + // //TODO + // } SoccerMarket::Score2GoalsOrMore(_) => { //TODO } @@ -154,15 +157,28 @@ impl From for ContestSummary { ]), ); } - SoccerMarket::PlayerAssist(_) => {} + SoccerMarket::PlayerAssist(players, at_least) => { + if at_least == 1 { + offerings.insert( + OfferType::AnytimeAssist, + HashMap::from_iter(players.into_iter().map(|player| { + let OutcomeOdds(outcome_type, odds) = OutcomeOdds::from(player); + (outcome_type, odds) + })), + ); + } + } SoccerMarket::TotalCardsOverUnder(_, _) => {} SoccerMarket::FirstHalfCardsOverUnder(_, _) => {} SoccerMarket::SecondHalfCardsOverUnder(_, _) => {} - SoccerMarket::PlayerCard(_) => {} SoccerMarket::PlayerShotsWoodwork(_, _) => {} SoccerMarket::PlayerTotalShots(_, _) => {} SoccerMarket::PlayerShotsOnTarget(_, _) => {} SoccerMarket::TotalCornersOverUnder(_, _) => {} + SoccerMarket::PlayerShownCard(_) => {} + SoccerMarket::PlayerShotsOutsideBox(_, _) => {} + SoccerMarket::FirstHalfCornersOverUnder(_, _) => {} + SoccerMarket::SecondHalfCornersOverUnder(_, _) => {} } } Self { @@ -194,10 +210,10 @@ impl From for OutcomeOdds { } } -// #[derive(Debug, Clone)] -pub struct DataProvider(pub PROVIDER); +#[derive(Debug, Clone)] +pub struct DataProvider(pub Provider); -impl From for PROVIDER { +impl From for Provider { fn from(value: DataProvider) -> Self { value.0 } @@ -208,8 +224,8 @@ impl FromStr for DataProvider { fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { - "ladbrokes" => Ok(DataProvider(PROVIDER::Ladbrokes)), - "pointsbet" => Ok(DataProvider(PROVIDER::PointsBet)), + "ladbrokes" => Ok(DataProvider(Provider::Ladbrokes)), + "pointsbet" => Ok(DataProvider(Provider::PointsBet)), _ => Err(ProviderParseError(format!("unsupported provider {s}"))) } } diff --git a/brumby-soccer/src/fit.rs b/brumby-soccer/src/fit.rs index 3f12e71..c7293f2 100644 --- a/brumby-soccer/src/fit.rs +++ b/brumby-soccer/src/fit.rs @@ -13,12 +13,12 @@ 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, ScoringProbs}; +use crate::interval::{explore, Expansions, IntervalConfig, PruneThresholds, ScoringProbs, PlayerProbs}; use crate::scoregrid; // const OVERROUND_METHOD: OverroundMethod = OverroundMethod::OddsRatio; // const SINGLE_PRICE_BOUNDS: PriceBounds = 1.01..=301.0; -const FIRST_GOALSCORER_BOOKSUM: f64 = 1.0; +// const FIRST_GOALSCORER_BOOKSUM: f64 = 1.0; const INTERVALS: usize = 18; const MAX_TOTAL_GOALS_HALF: u16 = 4; const MAX_TOTAL_GOALS_FULL: u16 = 8; @@ -145,15 +145,16 @@ pub fn fit_first_goalscorer_all( init_estimate, first_gs.market.probs[index], ); - // println!("for player {player:?}, {player_search_outcome:?}, sample prob. {}, init_estimate: {init_estimate}", first_gs.market.probs[index]); + // 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!(), } } let elapsed = start.elapsed(); - println!("player fitting took {elapsed:?}"); + println!("first goalscorer fitting took {elapsed:?}"); fitted_goalscorer_probs } @@ -168,7 +169,7 @@ fn fit_first_goalscorer_one( intervals: INTERVALS as u8, h1_probs: h1_probs.clone(), h2_probs: h2_probs.clone(), - players: vec![(player.clone(), 0.0)], + 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, @@ -176,8 +177,9 @@ fn fit_first_goalscorer_one( expansions: Expansions { ht_score: false, ft_score: false, - player_stats: false, - player_split_stats: false, + player_goal_stats: false, + player_split_goal_stats: false, + max_player_assists: 0, first_goalscorer: true, }, }; @@ -191,7 +193,7 @@ fn fit_first_goalscorer_one( acceptable_residual: 1e-9, }, |value| { - config.players[0].1 = value; + config.player_probs[0].1.goal = Some(value); let exploration = explore(&config, 0..INTERVALS as u8); let isolated_prob = isolate( &OfferType::FirstGoalscorer, @@ -204,6 +206,100 @@ fn fit_first_goalscorer_one( ) } +pub fn fit_anytime_assist_all( + h1_probs: &ScoringProbs, + h2_probs: &ScoringProbs, + anytime_assist: &Offer, + draw_prob: f64, + booksum: f64, +) -> BTreeMap { + let home_rate = (h1_probs.home_prob + h2_probs.home_prob) / 2.0; + let away_rate = (h1_probs.away_prob + h2_probs.away_prob) / 2.0; + let common_rate = (h1_probs.common_prob + h2_probs.common_prob) / 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); + // println!("home_ratio={home_ratio} + away_ratio={away_ratio}"); + let mut fitted_assist_probs = BTreeMap::new(); + let start = Instant::now(); + for (index, outcome) in anytime_assist.outcomes.items().iter().enumerate() { + match outcome { + OutcomeType::Player(player) => { + let side_ratio = match player { + Named(side, _) => match side { + Side::Home => home_ratio, + Side::Away => away_ratio, + }, + Player::Other => unreachable!(), + }; + let init_estimate = anytime_assist.market.probs[index] / booksum / side_ratio; + let player_search_outcome = fit_anytime_assist_one( + h1_probs, + h2_probs, + player, + init_estimate, + anytime_assist.market.probs[index], + ); + // println!("assist for player {player:?}, {player_search_outcome:?}, sample prob. {}, init_estimate: {init_estimate}", anytime_assist.market.probs[index]); + fitted_assist_probs.insert(player.clone(), player_search_outcome.optimal_value); + } + OutcomeType::None => {} + _ => unreachable!(), + } + } + let elapsed = start.elapsed(); + println!("anytime assist fitting took {elapsed:?}"); + fitted_assist_probs +} + +fn fit_anytime_assist_one( + h1_probs: &ScoringProbs, + h2_probs: &ScoringProbs, + player: &Player, + init_estimate: f64, + expected_prob: f64, +) -> UnivariateDescentOutcome { + let mut config = IntervalConfig { + intervals: INTERVALS as u8, + h1_probs: h1_probs.clone(), + h2_probs: h2_probs.clone(), + 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, + }, + expansions: Expansions { + ht_score: false, + ft_score: false, + player_goal_stats: false, + player_split_goal_stats: false, + max_player_assists: 1, + first_goalscorer: false, + }, + }; + let outcome_type = OutcomeType::Player(player.clone()); + univariate_descent( + &UnivariateDescentConfig { + init_value: init_estimate, + init_step: init_estimate * 0.1, + min_step: init_estimate * 0.001, + max_steps: 100, + acceptable_residual: 1e-9, + }, + |value| { + config.player_probs[0].1.assist = Some(value); + let exploration = explore(&config, 0..INTERVALS as u8); + let isolated_prob = isolate( + &OfferType::AnytimeAssist, + &outcome_type, + &exploration.prospects, + &exploration.player_lookup, + ); + ERROR_TYPE.calculate(expected_prob, isolated_prob) + }, + ) +} + fn expectation_from_lambdas(lambdas: &[f64]) -> f64 { assert_eq!(3, lambdas.len()); lambdas[0] + lambdas[1] + 2.0 * lambdas[2] diff --git a/brumby-soccer/src/interval.rs b/brumby-soccer/src/interval.rs index 05a3605..d07f7c6 100644 --- a/brumby-soccer/src/interval.rs +++ b/brumby-soccer/src/interval.rs @@ -62,20 +62,21 @@ impl<'a> From<&'a [f64]> for ScoringProbs { pub struct Expansions { pub ht_score: bool, pub ft_score: bool, - pub player_stats: bool, - pub player_split_stats: bool, + pub player_goal_stats: bool, + pub player_split_goal_stats: bool, + pub max_player_assists: u8, pub first_goalscorer: bool, } impl Expansions { fn validate(&self) { - if self.player_split_stats { + if self.player_split_goal_stats { assert!( - self.player_stats, - "cannot expand player split stats without player stats" + self.player_goal_stats, + "cannot expand player split goal stats without player goal stats" ); } assert!( - self.ft_score || self.ht_score || self.player_stats || self.first_goalscorer, + 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" ) } @@ -86,8 +87,9 @@ impl Default for Expansions { Self { ft_score: true, ht_score: true, - player_stats: true, - player_split_stats: true, + player_goal_stats: true, + player_split_goal_stats: true, + max_player_assists: u8::MAX, first_goalscorer: true, } } @@ -98,17 +100,31 @@ pub struct PruneThresholds { pub max_total_goals: u16, pub min_prob: f64, } +impl Default for PruneThresholds { + fn default() -> Self { + Self { + max_total_goals: u16::MAX, + min_prob: 0.0, + } + } +} #[derive(Debug)] pub struct IntervalConfig { pub intervals: u8, pub h1_probs: ScoringProbs, pub h2_probs: ScoringProbs, - pub players: Vec<(Player, f64)>, + pub player_probs: Vec<(Player, PlayerProbs)>, pub prune_thresholds: PruneThresholds, pub expansions: Expansions, } +#[derive(Debug)] +pub struct PlayerProbs { + pub goal: Option, + pub assist: Option +} + #[derive(Debug)] pub struct Exploration { pub player_lookup: HashLookup, @@ -120,19 +136,22 @@ pub struct Exploration { struct PartialProspect<'a> { home_scorer: Option, away_scorer: Option, + home_assister: Option, + away_assister: Option, first_scoring_side: Option<&'a Side>, prob: f64, } #[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Default)] -pub struct SplitStats { +pub struct PeriodStats { pub goals: u8, } #[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Default)] pub struct PlayerStats { - pub h1: SplitStats, - pub h2: SplitStats, + pub h1: PeriodStats, + pub h2: PeriodStats, + pub assists: u8, } #[derive(Debug, Eq, PartialEq)] @@ -144,30 +163,45 @@ enum Half { pub fn explore(config: &IntervalConfig, include_intervals: Range) -> Exploration { config.expansions.validate(); - let mut player_lookup = HashLookup::with_capacity(config.players.len() + 1); - let mut home_scorers = Vec::with_capacity(config.players.len() + 1); - let mut away_scorers = Vec::with_capacity(config.players.len() + 1); - let mut combined_home_scorer_prob = 0.0; - let mut combined_away_scorer_prob = 0.0; - for (player_index, (player, goal_prob)) in config.players.iter().enumerate() { + let mut player_lookup = HashLookup::with_capacity(config.player_probs.len() + 1); + let mut home_scorers = Vec::with_capacity(config.player_probs.len() + 1); + let mut away_scorers = Vec::with_capacity(config.player_probs.len() + 1); + let mut home_assisters = Vec::with_capacity(config.player_probs.len() + 1); + let mut away_assisters = Vec::with_capacity(config.player_probs.len() + 1); + let mut combined_home_player_goal_prob = 0.0; + let mut combined_away_player_goal_prob = 0.0; + for (player_index, (player, player_probs)) in config.player_probs.iter().enumerate() { player_lookup.push(player.clone()); match player { Player::Named(side, _) => match side { Side::Home => { - combined_home_scorer_prob += goal_prob; - home_scorers.push((player_index, *goal_prob)); + if let Some(prob) = player_probs.goal { + combined_home_player_goal_prob += prob; + home_scorers.push((player_index, prob)); + } + if let Some(prob) = player_probs.assist { + home_assisters.push((player_index, prob)); + } } Side::Away => { - combined_away_scorer_prob += goal_prob; - away_scorers.push((player_index, *goal_prob)); + if let Some(prob) = player_probs.goal { + combined_away_player_goal_prob += prob; + away_scorers.push((player_index, prob)); + } + if let Some(prob) = player_probs.assist { + away_assisters.push((player_index, prob)); + } } }, Player::Other => panic!("unsupported scorer {:?}", Player::Other), } } player_lookup.push(Player::Other); - home_scorers.push((config.players.len(), 1.0 - combined_home_scorer_prob)); - away_scorers.push((config.players.len(), 1.0 - combined_away_scorer_prob)); + 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_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)); let mut current_prospects = init_prospects(1); current_prospects.insert(Prospect::init(player_lookup.len()), 1.0); @@ -185,7 +219,6 @@ pub fn explore(config: &IntervalConfig, include_intervals: Range) -> Explora }; let neither_prob = 1.0 - params.home_prob - params.away_prob - params.common_prob; - let mut next_prospects = init_prospects((current_prospects.len() as f64 * 1.1) as usize); for (current_prospect, current_prob) in current_prospects { @@ -198,6 +231,8 @@ pub fn explore(config: &IntervalConfig, include_intervals: Range) -> Explora let partial = PartialProspect { home_scorer: None, away_scorer: None, + home_assister: None, + away_assister: None, first_scoring_side: None, prob: neither_prob, }; @@ -213,39 +248,67 @@ pub fn explore(config: &IntervalConfig, include_intervals: Range) -> Explora // at least one more goal allowed before pruning if current_prospect.ft_score.total() < config.prune_thresholds.max_total_goals { // only the home team scores - for (player_index, player_prob) in &home_scorers { - let partial = PartialProspect { - home_scorer: Some(*player_index), - away_scorer: None, - first_scoring_side: Some(&Side::Home), - prob: params.home_prob * player_prob, - }; - merge( - &config.expansions, - &half, - ¤t_prospect, - current_prob, - partial, - &mut next_prospects, - ); + for (scorer_index, player_score_prob) in &home_scorers { + let mut remaining_player_assist_prob = 1.0; + for (assister_index, player_assist_prob) in &home_assisters { + if *scorer_index != other_player_index && assister_index == scorer_index { + continue; + } + let player_assist_prob = if assister_index == &other_player_index { + remaining_player_assist_prob + } else { + 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_prob * player_score_prob * player_assist_prob, + }; + merge( + &config.expansions, + &half, + ¤t_prospect, + current_prob, + partial, + &mut next_prospects, + ); + } } // only the away team scores - for (player_index, player_prob) in &away_scorers { - let partial = PartialProspect { - home_scorer: None, - away_scorer: Some(*player_index), - first_scoring_side: Some(&Side::Away), - prob: params.away_prob * player_prob, - }; - merge( - &config.expansions, - &half, - ¤t_prospect, - current_prob, - partial, - &mut next_prospects, - ); + for (scorer_index, player_score_prob) in &away_scorers { + let mut remaining_player_assist_prob = 1.0; + for (assister_index, player_assist_prob) in &away_assisters { + if *scorer_index != other_player_index && assister_index == scorer_index { + continue; + } + let player_assist_prob = if assister_index == &other_player_index { + remaining_player_assist_prob + } else { + 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_prob * player_score_prob * player_assist_prob, + }; + merge( + &config.expansions, + &half, + ¤t_prospect, + current_prob, + partial, + &mut next_prospects, + ); + } } } else { pruned += current_prob * (params.home_prob + params.away_prob); @@ -254,26 +317,56 @@ pub fn explore(config: &IntervalConfig, include_intervals: Range) -> Explora // at least two more goals allowed before pruning if current_prospect.ft_score.total() + 1 < config.prune_thresholds.max_total_goals { // both teams score - for (home_player_index, home_player_prob) in &home_scorers { - for (away_player_index, away_player_prob) in &away_scorers { + for (home_scorer_index, home_player_score_prob) in &home_scorers { + for (away_scorer_index, away_player_score_prob) in &away_scorers { for first_scoring_side in [&Side::Home, &Side::Away] { - let partial = PartialProspect { - home_scorer: Some(*home_player_index), - away_scorer: Some(*away_player_index), - first_scoring_side: Some(first_scoring_side), - prob: params.common_prob - * home_player_prob - * away_player_prob - * 0.5, - }; - merge( - &config.expansions, - &half, - ¤t_prospect, - current_prob, - partial, - &mut next_prospects, - ); + 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 { + continue; + } + 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 + }; + + 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 { + 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_prob + * 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, + ); + } + } } } } @@ -303,15 +396,15 @@ fn merge( ) { let merged_prob = current_prob * partial.prob; let mut merged = current_prospect.clone(); - if let Some(scorer) = partial.home_scorer { - if expansions.player_split_stats { + if let Some(player) = partial.home_scorer { + if expansions.player_split_goal_stats { let split_stats = match half { - Half::First => &mut merged.stats[scorer].h1, - Half::Second => &mut merged.stats[scorer].h2, + Half::First => &mut merged.stats[player].h1, + Half::Second => &mut merged.stats[player].h2, }; split_stats.goals += 1; - } else if expansions.player_stats { - merged.stats[scorer].h2.goals += 1; + } else if expansions.player_goal_stats { + merged.stats[player].h2.goals += 1; } if expansions.ft_score { @@ -325,18 +418,18 @@ fn merge( && merged.first_scorer.is_none() && partial.first_scoring_side.unwrap() == &Side::Home { - merged.first_scorer = Some(scorer); + merged.first_scorer = Some(player); } } - if let Some(scorer) = partial.away_scorer { - if expansions.player_split_stats { + if let Some(player) = partial.away_scorer { + if expansions.player_split_goal_stats { let split_stats = match half { - Half::First => &mut merged.stats[scorer].h1, - Half::Second => &mut merged.stats[scorer].h2, + Half::First => &mut merged.stats[player].h1, + Half::Second => &mut merged.stats[player].h2, }; split_stats.goals += 1; - } else if expansions.player_stats { - merged.stats[scorer].h2.goals += 1; + } else if expansions.player_goal_stats { + merged.stats[player].h2.goals += 1; } if expansions.ft_score { @@ -350,9 +443,21 @@ fn merge( && merged.first_scorer.is_none() && partial.first_scoring_side.unwrap() == &Side::Away { - merged.first_scorer = Some(scorer); + merged.first_scorer = Some(player); } } + + if let Some(player) = partial.home_assister { + if merged.stats[player].assists < expansions.max_player_assists { + merged.stats[player].assists += 1; + } + } + if let Some(player) = partial.away_assister { + if merged.stats[player].assists < expansions.max_player_assists { + merged.stats[player].assists += 1; + } + } + next_prospects .entry(merged) .and_modify(|prob| *prob += merged_prob) diff --git a/brumby-soccer/src/interval/query.rs b/brumby-soccer/src/interval/query.rs index 9dc18e8..155af20 100644 --- a/brumby-soccer/src/interval/query.rs +++ b/brumby-soccer/src/interval/query.rs @@ -1,12 +1,14 @@ +use brumby::hash_lookup::HashLookup; + use crate::domain::{OfferType, OutcomeType, Player}; use crate::interval::{Expansions, Prospect, Prospects}; -use brumby::hash_lookup::HashLookup; +mod anytime_assist; +mod anytime_goalscorer; mod correct_score; +mod first_goalscorer; mod head_to_head; mod total_goals; -mod first_goalscorer; -mod anytime_goalscorer; #[derive(Debug)] pub enum QuerySpec { @@ -15,6 +17,7 @@ pub enum QuerySpec { PlayerLookup(usize), NoFirstGoalscorer, NoAnytimeGoalscorer, + NoAnytimeAssist, } #[must_use] @@ -27,7 +30,7 @@ pub fn requirements(offer_type: &OfferType) -> Expansions { OfferType::FirstGoalscorer => first_goalscorer::requirements(), OfferType::AnytimeGoalscorer => anytime_goalscorer::requirements(), OfferType::PlayerShotsOnTarget(_) => unimplemented!(), - OfferType::AnytimeAssist => unimplemented!(), + OfferType::AnytimeAssist => anytime_assist::requirements(), } } @@ -45,7 +48,7 @@ pub fn prepare( OfferType::FirstGoalscorer => first_goalscorer::prepare(outcome_type, player_lookup), OfferType::AnytimeGoalscorer => anytime_goalscorer::prepare(outcome_type, player_lookup), OfferType::PlayerShotsOnTarget(_) => unimplemented!(), - OfferType::AnytimeAssist => unimplemented!(), + OfferType::AnytimeAssist => anytime_assist::prepare(outcome_type, player_lookup), } } @@ -59,7 +62,7 @@ pub fn filter(offer_type: &OfferType, query: &QuerySpec, prospect: &Prospect) -> OfferType::AnytimeGoalscorer => anytime_goalscorer::filter(query, prospect), OfferType::FirstGoalscorer => first_goalscorer::filter(query, prospect), OfferType::PlayerShotsOnTarget(_) => unimplemented!(), - OfferType::AnytimeAssist => unimplemented!(), + OfferType::AnytimeAssist => anytime_assist::filter(query, prospect), } } @@ -79,7 +82,7 @@ pub fn isolate( } #[must_use] -pub fn isolate_batch( +pub fn isolate_set( selections: &[(OfferType, OutcomeType)], prospects: &Prospects, player_lookup: &HashLookup, @@ -93,10 +96,198 @@ pub fn isolate_batch( prospects .iter() .filter(|(prospect, _)| { - queries + !queries .iter() .any(|(offer_type, query)| !filter(offer_type, query, prospect)) }) .map(|(_, prospect_prob)| prospect_prob) .sum() } + +#[cfg(test)] +mod tests { + use crate::domain::{Period, Score, Side}; + use crate::interval::{explore, IntervalConfig, ScoringProbs}; + + use super::*; + + #[test] + fn isolate_degenerate_case_of_isolate_set() { + let exploration = explore( + &IntervalConfig { + intervals: 4, + h1_probs: ScoringProbs { + home_prob: 0.25, + away_prob: 0.25, + common_prob: 0.25, + }, + h2_probs: ScoringProbs { + home_prob: 0.25, + away_prob: 0.25, + common_prob: 0.25, + }, + player_probs: vec![], + prune_thresholds: Default::default(), + expansions: Expansions { + ht_score: false, + ft_score: true, + player_goal_stats: true, + player_split_goal_stats: false, + max_player_assists: 0, + first_goalscorer: false, + }, + }, + 0..4, + ); + let home_win = isolate( + &OfferType::HeadToHead(Period::FullTime), + &OutcomeType::Win(Side::Home), + &exploration.prospects, + &exploration.player_lookup, + ); + assert!(home_win > 0.0, "{home_win}"); + + let home_win_set = isolate_set( + &[( + OfferType::HeadToHead(Period::FullTime), + OutcomeType::Win(Side::Home), + )], + &exploration.prospects, + &exploration.player_lookup, + ); + assert_eq!(home_win, home_win_set); + } + + #[test] + fn logical_implication_is_a_subset() { + let exploration = explore( + &IntervalConfig { + intervals: 4, + h1_probs: ScoringProbs { + home_prob: 0.25, + away_prob: 0.25, + common_prob: 0.25, + }, + h2_probs: ScoringProbs { + home_prob: 0.25, + away_prob: 0.25, + common_prob: 0.25, + }, + player_probs: vec![], + prune_thresholds: Default::default(), + expansions: Expansions { + ht_score: false, + ft_score: true, + player_goal_stats: true, + player_split_goal_stats: false, + max_player_assists: 0, + first_goalscorer: false, + }, + }, + 0..4, + ); + + let home_win = isolate_set( + &[( + OfferType::HeadToHead(Period::FullTime), + OutcomeType::Win(Side::Home), + )], + &exploration.prospects, + &exploration.player_lookup, + ); + assert!(home_win > 0.0, "{home_win}"); + + let one_nil = isolate_set( + &[( + OfferType::CorrectScore(Period::FullTime), + OutcomeType::Score(Score { home: 1, away: 0 }), + )], + &exploration.prospects, + &exploration.player_lookup, + ); + assert!(one_nil > 0.0, "{one_nil}"); + assert!(home_win > one_nil); + + let one_nil_and_home_win = isolate_set( + &[ + ( + OfferType::CorrectScore(Period::FullTime), + OutcomeType::Score(Score { home: 1, away: 0 }), + ), + ( + OfferType::HeadToHead(Period::FullTime), + OutcomeType::Win(Side::Home), + ), + ], + &exploration.prospects, + &exploration.player_lookup, + ); + assert_eq!(one_nil, one_nil_and_home_win); + } + + #[test] + fn impossibility_of_conflicting_outcomes() { + let exploration = explore( + &IntervalConfig { + intervals: 4, + h1_probs: ScoringProbs { + home_prob: 0.25, + away_prob: 0.25, + common_prob: 0.25, + }, + h2_probs: ScoringProbs { + home_prob: 0.25, + away_prob: 0.25, + common_prob: 0.25, + }, + player_probs: vec![], + prune_thresholds: Default::default(), + expansions: Expansions { + ht_score: false, + ft_score: true, + player_goal_stats: true, + player_split_goal_stats: false, + max_player_assists: 0, + first_goalscorer: false, + }, + }, + 0..4, + ); + + let home_win = isolate_set( + &[( + OfferType::HeadToHead(Period::FullTime), + OutcomeType::Win(Side::Home), + )], + &exploration.prospects, + &exploration.player_lookup, + ); + assert!(home_win > 0.0, "{home_win}"); + + let nil_one = isolate_set( + &[( + OfferType::CorrectScore(Period::FullTime), + OutcomeType::Score(Score { home: 0, away: 1 }), + )], + &exploration.prospects, + &exploration.player_lookup, + ); + assert!(nil_one > 0.0, "{nil_one}"); + + let nil_one_home_win = isolate_set( + &[ + ( + OfferType::CorrectScore(Period::FullTime), + OutcomeType::Score(Score { home: 0, away: 1 }), + ), + ( + OfferType::HeadToHead(Period::FullTime), + OutcomeType::Win(Side::Home), + ), + ], + &exploration.prospects, + &exploration.player_lookup, + ); + assert_eq!(0.0, nil_one_home_win); + } +} diff --git a/brumby-soccer/src/interval/query/anytime_assist.rs b/brumby-soccer/src/interval/query/anytime_assist.rs new file mode 100644 index 0000000..f973d4b --- /dev/null +++ b/brumby-soccer/src/interval/query/anytime_assist.rs @@ -0,0 +1,202 @@ +use super::*; + +#[inline] +#[must_use] +pub(crate) fn requirements() -> Expansions { + Expansions { + ht_score: false, + ft_score: false, + player_goal_stats: false, + player_split_goal_stats: false, + max_player_assists: 1, + first_goalscorer: false, + } +} + +#[inline] +#[must_use] +pub(crate) fn prepare(outcome_type: &OutcomeType, player_lookup: &HashLookup) -> QuerySpec { + match outcome_type { + OutcomeType::Player(player) => { + QuerySpec::PlayerLookup(player_lookup.index_of(player).unwrap()) + } + OutcomeType::None => QuerySpec::NoAnytimeAssist, + _ => panic!("{outcome_type:?} unsupported"), + } +} + +#[inline] +#[must_use] +pub(crate) fn filter(query: &QuerySpec, prospect: &Prospect) -> bool { + match query { + QuerySpec::PlayerLookup(target_player) => { + let stats = &prospect.stats[*target_player]; + stats.assists > 0 + } + QuerySpec::NoAnytimeAssist => !prospect.stats.iter().any(|stats| stats.assists > 0), + _ => panic!("{query:?} unsupported"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::{Period, Score, Side}; + use crate::interval::{explore, IntervalConfig, PlayerProbs, ScoringProbs}; + + fn print_prospects(prospects: &Prospects) { + for (prospect, prob) in prospects { + println!("prospect: {prospect:?} @ {prob}"); + } + } + + #[test] + fn cannot_assist_to_self() { + let alice = Player::Named(Side::Home, "Alice".into()); + let bob = Player::Named(Side::Home, "Bob".into()); + let exploration = explore( + &IntervalConfig { + intervals: 1, + h1_probs: ScoringProbs { + home_prob: 0.25, + away_prob: 0.25, + common_prob: 0.25, + }, + h2_probs: ScoringProbs { + home_prob: 0.25, + away_prob: 0.25, + common_prob: 0.25, + }, + player_probs: vec![ + ( + alice.clone(), + PlayerProbs { + goal: Some(0.25), + assist: Some(0.25), + }, + ), + ( + bob.clone(), + PlayerProbs { + goal: Some(0.4), + assist: Some(0.4), + }, + ), + ], + prune_thresholds: Default::default(), + expansions: Expansions { + ht_score: false, + ft_score: false, + player_goal_stats: true, + player_split_goal_stats: false, + max_player_assists: 1, + first_goalscorer: false, + }, + }, + 0..1, + ); + print_prospects(&exploration.prospects); + + let alice_to_bob = isolate_set( + &[ + (OfferType::AnytimeAssist, OutcomeType::Player(alice.clone())), + ( + OfferType::AnytimeGoalscorer, + OutcomeType::Player(bob.clone()), + ), + ], + &exploration.prospects, + &exploration.player_lookup, + ); + assert_eq!((0.25 + 0.25) * 0.4 * 0.25, alice_to_bob, "{alice_to_bob}"); + + let bob_to_alice = isolate_set( + &[ + (OfferType::AnytimeAssist, OutcomeType::Player(bob.clone())), + ( + OfferType::AnytimeGoalscorer, + OutcomeType::Player(alice.clone()), + ), + ], + &exploration.prospects, + &exploration.player_lookup, + ); + assert_eq!((0.25 + 0.25) * 0.25 * 0.4, bob_to_alice, "{bob_to_alice}"); + + let alice_to_alice = isolate_set( + &[ + (OfferType::AnytimeAssist, OutcomeType::Player(alice.clone())), + ( + OfferType::AnytimeGoalscorer, + OutcomeType::Player(alice.clone()), + ), + ], + &exploration.prospects, + &exploration.player_lookup, + ); + assert_eq!(0.0, alice_to_alice); + } + + #[test] + fn cannot_assist_across_sides() { + let alice = Player::Named(Side::Home, "Alice".into()); + let bob = Player::Named(Side::Away, "Bob".into()); + let exploration = explore( + &IntervalConfig { + intervals: 1, + h1_probs: ScoringProbs { + home_prob: 0.25, + away_prob: 0.25, + common_prob: 0.25, + }, + h2_probs: ScoringProbs { + home_prob: 0.25, + away_prob: 0.25, + common_prob: 0.25, + }, + player_probs: vec![ + ( + alice.clone(), + PlayerProbs { + goal: Some(0.25), + assist: Some(0.25), + }, + ), + ( + bob.clone(), + PlayerProbs { + goal: Some(0.4), + assist: Some(0.4), + }, + ), + ], + prune_thresholds: Default::default(), + expansions: Expansions { + ht_score: false, + ft_score: true, + player_goal_stats: true, + player_split_goal_stats: false, + max_player_assists: 1, + first_goalscorer: false, + }, + }, + 0..1, + ); + print_prospects(&exploration.prospects); + + let alice_to_bob = isolate_set( + &[ + (OfferType::AnytimeAssist, OutcomeType::Player(alice.clone())), + ( + OfferType::AnytimeGoalscorer, + OutcomeType::Player(bob.clone()), + ), + // the third condition is necessary because if the score is 1:1, Alice could have assisted to Other while Bob also scored + (OfferType::CorrectScore(Period::FullTime), OutcomeType::Score(Score { home: 1, away: 0 })), + ], + &exploration.prospects, + &exploration.player_lookup, + ); + assert_eq!(0.0, alice_to_bob, "{alice_to_bob}"); + } +} diff --git a/brumby-soccer/src/interval/query/anytime_goalscorer.rs b/brumby-soccer/src/interval/query/anytime_goalscorer.rs index 4d91502..90d9052 100644 --- a/brumby-soccer/src/interval/query/anytime_goalscorer.rs +++ b/brumby-soccer/src/interval/query/anytime_goalscorer.rs @@ -6,8 +6,9 @@ pub(crate) fn requirements() -> Expansions { Expansions { ht_score: false, ft_score: false, - player_stats: true, - player_split_stats: false, + player_goal_stats: true, + player_split_goal_stats: false, + max_player_assists: 0, first_goalscorer: false, } } diff --git a/brumby-soccer/src/interval/query/correct_score.rs b/brumby-soccer/src/interval/query/correct_score.rs index 0505fbf..3947c69 100644 --- a/brumby-soccer/src/interval/query/correct_score.rs +++ b/brumby-soccer/src/interval/query/correct_score.rs @@ -8,22 +8,25 @@ pub(crate) fn requirements(period: &Period) -> Expansions { Period::FirstHalf => Expansions { ht_score: true, ft_score: false, - player_stats: true, - player_split_stats: false, + player_goal_stats: true, + player_split_goal_stats: false, + max_player_assists: 0, first_goalscorer: false, }, Period::SecondHalf => Expansions { ht_score: true, ft_score: true, - player_stats: true, - player_split_stats: false, + player_goal_stats: true, + player_split_goal_stats: false, + max_player_assists: 0, first_goalscorer: false, }, Period::FullTime => Expansions { ht_score: false, ft_score: true, - player_stats: false, - player_split_stats: false, + player_goal_stats: false, + player_split_goal_stats: false, + max_player_assists: 0, first_goalscorer: false, }, } diff --git a/brumby-soccer/src/interval/query/first_goalscorer.rs b/brumby-soccer/src/interval/query/first_goalscorer.rs index 0b57de3..12cee50 100644 --- a/brumby-soccer/src/interval/query/first_goalscorer.rs +++ b/brumby-soccer/src/interval/query/first_goalscorer.rs @@ -6,8 +6,9 @@ pub(crate) fn requirements() -> Expansions { Expansions { ht_score: false, ft_score: false, - player_stats: false, - player_split_stats: false, + player_goal_stats: false, + player_split_goal_stats: false, + max_player_assists: 0, first_goalscorer: true, } } diff --git a/brumby-soccer/src/interval/query/head_to_head.rs b/brumby-soccer/src/interval/query/head_to_head.rs index 5cd48db..1904bb1 100644 --- a/brumby-soccer/src/interval/query/head_to_head.rs +++ b/brumby-soccer/src/interval/query/head_to_head.rs @@ -8,22 +8,25 @@ pub(crate) fn requirements(period: &Period) -> Expansions { Period::FirstHalf => Expansions { ht_score: true, ft_score: false, - player_stats: true, - player_split_stats: false, + player_goal_stats: true, + player_split_goal_stats: false, + max_player_assists: 0, first_goalscorer: false, }, Period::SecondHalf => Expansions { ht_score: true, ft_score: true, - player_stats: true, - player_split_stats: false, + player_goal_stats: true, + player_split_goal_stats: false, + max_player_assists: 0, first_goalscorer: false, }, Period::FullTime => Expansions { ht_score: false, ft_score: true, - player_stats: false, - player_split_stats: false, + player_goal_stats: false, + player_split_goal_stats: false, + max_player_assists: 0, first_goalscorer: false, }, } diff --git a/brumby-soccer/src/interval/query/total_goals.rs b/brumby-soccer/src/interval/query/total_goals.rs index 1e763d7..462eb10 100644 --- a/brumby-soccer/src/interval/query/total_goals.rs +++ b/brumby-soccer/src/interval/query/total_goals.rs @@ -8,22 +8,25 @@ pub(crate) fn requirements(period: &Period) -> Expansions { Period::FirstHalf => Expansions { ht_score: true, ft_score: false, - player_stats: true, - player_split_stats: false, + player_goal_stats: true, + player_split_goal_stats: false, + max_player_assists: 0, first_goalscorer: false, }, Period::SecondHalf => Expansions { ht_score: true, ft_score: true, - player_stats: true, - player_split_stats: false, + player_goal_stats: true, + player_split_goal_stats: false, + max_player_assists: 0, first_goalscorer: false, }, Period::FullTime => Expansions { ht_score: false, ft_score: true, - player_stats: false, - player_split_stats: false, + player_goal_stats: false, + player_split_goal_stats: false, + max_player_assists: 0, first_goalscorer: false, }, } diff --git a/brumby-soccer/src/interval/tests.rs b/brumby-soccer/src/interval/tests.rs index 4a35c8e..5278950 100644 --- a/brumby-soccer/src/interval/tests.rs +++ b/brumby-soccer/src/interval/tests.rs @@ -29,11 +29,8 @@ fn explore_2x2() { intervals: 2, h1_probs: ScoringProbs { home_prob: 0.25, away_prob: 0.25, common_prob: 0.25 }, h2_probs: ScoringProbs { home_prob: 0.25, away_prob: 0.25, common_prob: 0.25 }, - players: vec![], - prune_thresholds: PruneThresholds { - max_total_goals: u16::MAX, - min_prob: 0.0, - }, + player_probs: vec![], + prune_thresholds: Default::default(), expansions: Default::default(), }, 0..2, @@ -45,7 +42,7 @@ fn explore_2x2() { Prospect { ht_score: Score { home: 0, away: 0 }, ft_score: Score { home: 0, away: 0 }, - stats: vec![PlayerStats { h1: SplitStats { goals: 0 }, h2: SplitStats { goals: 0 }}], + stats: vec![PlayerStats { h1: PeriodStats { goals: 0 }, h2: PeriodStats { goals: 0 }, assists: 0 }], first_scorer: None, }, 0.0625f64, @@ -54,7 +51,7 @@ fn explore_2x2() { Prospect { ht_score: Score { home: 1, away: 0 }, ft_score: Score { home: 2, away: 0 }, - stats: vec![PlayerStats { h1: SplitStats { goals: 1 }, h2: SplitStats { goals: 1 }}], + stats: vec![PlayerStats { h1: PeriodStats { goals: 1 }, h2: PeriodStats { goals: 1 }, assists: 2 }], first_scorer: Some(0), }, 0.0625, @@ -63,7 +60,7 @@ fn explore_2x2() { Prospect { ht_score: Score { home: 1, away: 0 }, ft_score: Score { home: 1, away: 1 }, - stats: vec![PlayerStats { h1: SplitStats { goals: 1 }, h2: SplitStats { goals: 1 }}], + stats: vec![PlayerStats { h1: PeriodStats { goals: 1 }, h2: PeriodStats { goals: 1 }, assists: 2 }], first_scorer: Some(0), }, 0.0625, @@ -72,7 +69,7 @@ fn explore_2x2() { Prospect { ht_score: Score { home: 1, away: 0 }, ft_score: Score { home: 1, away: 1 }, - stats: vec![PlayerStats { h1: SplitStats { goals: 1 }, h2: SplitStats { goals: 1 }}], + stats: vec![PlayerStats { h1: PeriodStats { goals: 1 }, h2: PeriodStats { goals: 1 }, assists: 2 }], first_scorer: Some(0), }, 0.0625, @@ -81,7 +78,7 @@ fn explore_2x2() { Prospect { ht_score: Score { home: 1, away: 1 }, ft_score: Score { home: 1, away: 1 }, - stats: vec![PlayerStats { h1: SplitStats { goals: 2 }, h2: SplitStats { goals: 0 }}], + stats: vec![PlayerStats { h1: PeriodStats { goals: 2 }, h2: PeriodStats { goals: 0 }, assists: 2 }], first_scorer: Some(0), }, 0.0625, @@ -90,7 +87,7 @@ fn explore_2x2() { Prospect { ht_score: Score { home: 0, away: 0 }, ft_score: Score { home: 1, away: 1 }, - stats: vec![PlayerStats { h1: SplitStats { goals: 0}, h2: SplitStats { goals: 2 }}], + stats: vec![PlayerStats { h1: PeriodStats { goals: 0}, h2: PeriodStats { goals: 2 }, assists: 2 }], first_scorer: Some(0), }, 0.0625, @@ -99,7 +96,7 @@ fn explore_2x2() { Prospect { ht_score: Score { home: 1, away: 1 }, ft_score: Score { home: 1, away: 2 }, - stats: vec![PlayerStats { h1: SplitStats { goals: 2 }, h2: SplitStats { goals: 1 }}], + stats: vec![PlayerStats { h1: PeriodStats { goals: 2 }, h2: PeriodStats { goals: 1 }, assists: 3 }], first_scorer: Some(0), }, 0.0625, @@ -108,7 +105,7 @@ fn explore_2x2() { Prospect { ht_score: Score { home: 0, away: 1 }, ft_score: Score { home: 1, away: 2 }, - stats: vec![PlayerStats { h1: SplitStats { goals: 1 }, h2: SplitStats { goals: 2 }}], + stats: vec![PlayerStats { h1: PeriodStats { goals: 1 }, h2: PeriodStats { goals: 2 }, assists: 3 }], first_scorer: Some(0), }, 0.0625, @@ -117,7 +114,7 @@ fn explore_2x2() { Prospect { ht_score: Score { home: 0, away: 1 }, ft_score: Score { home: 0, away: 2 }, - stats: vec![PlayerStats { h1: SplitStats { goals: 1 }, h2: SplitStats { goals: 1 }}], + stats: vec![PlayerStats { h1: PeriodStats { goals: 1 }, h2: PeriodStats { goals: 1 }, assists: 2 }], first_scorer: Some(0), }, 0.0625, @@ -126,7 +123,7 @@ fn explore_2x2() { Prospect { ht_score: Score { home: 1, away: 1 }, ft_score: Score { home: 2, away: 2 }, - stats: vec![PlayerStats { h1: SplitStats { goals: 2 }, h2: SplitStats { goals: 2 }}], + stats: vec![PlayerStats { h1: PeriodStats { goals: 2 }, h2: PeriodStats { goals: 2 }, assists: 4 }], first_scorer: Some(0), }, 0.0625, @@ -135,7 +132,7 @@ fn explore_2x2() { Prospect { ht_score: Score { home: 1, away: 0 }, ft_score: Score { home: 1, away: 0 }, - stats: vec![PlayerStats { h1: SplitStats { goals: 1 }, h2: SplitStats { goals: 0 }}], + stats: vec![PlayerStats { h1: PeriodStats { goals: 1 }, h2: PeriodStats { goals: 0 }, assists: 1 }], first_scorer: Some(0), }, 0.0625, @@ -144,7 +141,7 @@ fn explore_2x2() { Prospect { ht_score: Score { home: 0, away: 0 }, ft_score: Score { home: 1, away: 0 }, - stats: vec![PlayerStats { h1: SplitStats { goals: 0 }, h2: SplitStats { goals: 1 }}], + stats: vec![PlayerStats { h1: PeriodStats { goals: 0 }, h2: PeriodStats { goals: 1 }, assists: 1 }], first_scorer: Some(0), }, 0.0625, @@ -153,7 +150,7 @@ fn explore_2x2() { Prospect { ht_score: Score { home: 0, away: 1 }, ft_score: Score { home: 0, away: 1 }, - stats: vec![PlayerStats { h1: SplitStats { goals: 1 }, h2: SplitStats { goals: 0 }}], + stats: vec![PlayerStats { h1: PeriodStats { goals: 1 }, h2: PeriodStats { goals: 0 }, assists: 1 }], first_scorer: Some(0), }, 0.0625, @@ -162,7 +159,7 @@ fn explore_2x2() { Prospect { ht_score: Score { home: 0, away: 0 }, ft_score: Score { home: 0, away: 1 }, - stats: vec![PlayerStats { h1: SplitStats { goals: 0 }, h2: SplitStats { goals: 1 }}], + stats: vec![PlayerStats { h1: PeriodStats { goals: 0 }, h2: PeriodStats { goals: 1 }, assists: 1 }], first_scorer: Some(0), }, 0.0625, @@ -171,7 +168,7 @@ fn explore_2x2() { Prospect { ht_score: Score { home: 1, away: 1 }, ft_score: Score { home: 2, away: 1 }, - stats: vec![PlayerStats { h1: SplitStats { goals: 2 }, h2: SplitStats { goals: 1 }}], + stats: vec![PlayerStats { h1: PeriodStats { goals: 2 }, h2: PeriodStats { goals: 1 }, assists: 3 }], first_scorer: Some(0), }, 0.0625, @@ -180,7 +177,7 @@ fn explore_2x2() { Prospect { ht_score: Score { home: 1, away: 0 }, ft_score: Score { home: 2, away: 1 }, - stats: vec![PlayerStats { h1: SplitStats { goals: 1 }, h2: SplitStats { goals: 2 }}], + stats: vec![PlayerStats { h1: PeriodStats { goals: 1 }, h2: PeriodStats { goals: 2 }, assists: 3 }], first_scorer: Some(0), }, 0.0625, @@ -189,37 +186,53 @@ fn explore_2x2() { assert_eq!(0.0, exploration.pruned); assert_expected_prospects(&expected, &exploration.prospects); - let isolated_1gs_none = isolate( + let first_goalscorer_none = isolate( &OfferType::FirstGoalscorer, &OutcomeType::None, &exploration.prospects, &exploration.player_lookup, ); - assert_eq!(0.0625, isolated_1gs_none); + assert_eq!(0.0625, first_goalscorer_none); - let isolated_1gs_other = isolate( + let first_goalscorer_other = isolate( &OfferType::FirstGoalscorer, &OutcomeType::Player(Player::Other), &exploration.prospects, &exploration.player_lookup, ); - assert_eq!(1.0 - 0.0625, isolated_1gs_other); + assert_eq!(1.0 - 0.0625, first_goalscorer_other); - let isolated_anytime_none = isolate( + let anytime_goalscorer_none = isolate( &OfferType::AnytimeGoalscorer, &OutcomeType::None, &exploration.prospects, &exploration.player_lookup, ); - assert_eq!(0.0625, isolated_anytime_none); + assert_eq!(0.0625, anytime_goalscorer_none); - let isolated_anytime_other = isolate( + let anytime_goalscorer_other = isolate( &OfferType::AnytimeGoalscorer, &OutcomeType::Player(Player::Other), &exploration.prospects, &exploration.player_lookup, ); - assert_eq!(1.0 - 0.0625, isolated_anytime_other); + assert_eq!(1.0 - 0.0625, anytime_goalscorer_other); + + let anytime_assist_none = isolate( + &OfferType::AnytimeAssist, + &OutcomeType::None, + &exploration.prospects, + &exploration.player_lookup, + ); + assert_eq!(0.0625, anytime_assist_none); + + let anytime_assist_other = isolate( + &OfferType::AnytimeAssist, + &OutcomeType::Player(Player::Other), + &exploration.prospects, + &exploration.player_lookup, + ); + assert_eq!(1.0 - 0.0625, anytime_assist_other); } #[test] @@ -229,7 +242,7 @@ fn explore_2x2_pruned_2_goals() { intervals: 2, h1_probs: ScoringProbs { home_prob: 0.25, away_prob: 0.25, common_prob: 0.25 }, h2_probs: ScoringProbs { home_prob: 0.25, away_prob: 0.25, common_prob: 0.25 }, - players: vec![], + player_probs: vec![], prune_thresholds: PruneThresholds { max_total_goals: 2, min_prob: 0.0, @@ -244,7 +257,7 @@ fn explore_2x2_pruned_2_goals() { Prospect { ht_score: Score { home: 0, away: 0 }, ft_score: Score { home: 0, away: 0 }, - stats: vec![PlayerStats { h1: SplitStats { goals: 0 }, h2: SplitStats { goals: 0 }}], + stats: vec![PlayerStats { h1: PeriodStats { goals: 0 }, h2: PeriodStats { goals: 0 }, assists: 0 }], first_scorer: None, }, 0.0625f64, @@ -253,7 +266,7 @@ fn explore_2x2_pruned_2_goals() { Prospect { ht_score: Score { home: 1, away: 0 }, ft_score: Score { home: 2, away: 0 }, - stats: vec![PlayerStats { h1: SplitStats { goals: 1 }, h2: SplitStats { goals: 1 }}], + stats: vec![PlayerStats { h1: PeriodStats { goals: 1 }, h2: PeriodStats { goals: 1 }, assists: 2 }], first_scorer: Some(0), }, 0.0625, @@ -262,7 +275,7 @@ fn explore_2x2_pruned_2_goals() { Prospect { ht_score: Score { home: 1, away: 0 }, ft_score: Score { home: 1, away: 1 }, - stats: vec![PlayerStats { h1: SplitStats { goals: 1 }, h2: SplitStats { goals: 1 }}], + stats: vec![PlayerStats { h1: PeriodStats { goals: 1 }, h2: PeriodStats { goals: 1 }, assists: 2 }], first_scorer: Some(0), }, 0.0625, @@ -271,7 +284,7 @@ fn explore_2x2_pruned_2_goals() { Prospect { ht_score: Score { home: 1, away: 0 }, ft_score: Score { home: 1, away: 1 }, - stats: vec![PlayerStats { h1: SplitStats { goals: 1 }, h2: SplitStats { goals: 1 }}], + stats: vec![PlayerStats { h1: PeriodStats { goals: 1 }, h2: PeriodStats { goals: 1 }, assists: 2 }], first_scorer: Some(0), }, 0.0625, @@ -280,7 +293,7 @@ fn explore_2x2_pruned_2_goals() { Prospect { ht_score: Score { home: 1, away: 1 }, ft_score: Score { home: 1, away: 1 }, - stats: vec![PlayerStats { h1: SplitStats { goals: 2 }, h2: SplitStats { goals: 0 }}], + stats: vec![PlayerStats { h1: PeriodStats { goals: 2 }, h2: PeriodStats { goals: 0 }, assists: 2 }], first_scorer: Some(0), }, 0.0625, @@ -289,7 +302,7 @@ fn explore_2x2_pruned_2_goals() { Prospect { ht_score: Score { home: 0, away: 0 }, ft_score: Score { home: 1, away: 1 }, - stats: vec![PlayerStats { h1: SplitStats { goals: 0}, h2: SplitStats { goals: 2 }}], + stats: vec![PlayerStats { h1: PeriodStats { goals: 0}, h2: PeriodStats { goals: 2 }, assists: 2 }], first_scorer: Some(0), }, 0.0625, @@ -298,7 +311,7 @@ fn explore_2x2_pruned_2_goals() { Prospect { ht_score: Score { home: 0, away: 1 }, ft_score: Score { home: 0, away: 2 }, - stats: vec![PlayerStats { h1: SplitStats { goals: 1 }, h2: SplitStats { goals: 1 }}], + stats: vec![PlayerStats { h1: PeriodStats { goals: 1 }, h2: PeriodStats { goals: 1 }, assists: 2 }], first_scorer: Some(0), }, 0.0625, @@ -307,7 +320,7 @@ fn explore_2x2_pruned_2_goals() { Prospect { ht_score: Score { home: 1, away: 0 }, ft_score: Score { home: 1, away: 0 }, - stats: vec![PlayerStats { h1: SplitStats { goals: 1 }, h2: SplitStats { goals: 0 }}], + stats: vec![PlayerStats { h1: PeriodStats { goals: 1 }, h2: PeriodStats { goals: 0 }, assists: 1 }], first_scorer: Some(0), }, 0.0625, @@ -316,7 +329,7 @@ fn explore_2x2_pruned_2_goals() { Prospect { ht_score: Score { home: 0, away: 0 }, ft_score: Score { home: 1, away: 0 }, - stats: vec![PlayerStats { h1: SplitStats { goals: 0 }, h2: SplitStats { goals: 1 }}], + stats: vec![PlayerStats { h1: PeriodStats { goals: 0 }, h2: PeriodStats { goals: 1 }, assists: 1 }], first_scorer: Some(0), }, 0.0625, @@ -325,7 +338,7 @@ fn explore_2x2_pruned_2_goals() { Prospect { ht_score: Score { home: 0, away: 1 }, ft_score: Score { home: 0, away: 1 }, - stats: vec![PlayerStats { h1: SplitStats { goals: 1 }, h2: SplitStats { goals: 0 }}], + stats: vec![PlayerStats { h1: PeriodStats { goals: 1 }, h2: PeriodStats { goals: 0 }, assists: 1 }], first_scorer: Some(0), }, 0.0625, @@ -334,7 +347,7 @@ fn explore_2x2_pruned_2_goals() { Prospect { ht_score: Score { home: 0, away: 0 }, ft_score: Score { home: 0, away: 1 }, - stats: vec![PlayerStats { h1: SplitStats { goals: 0 }, h2: SplitStats { goals: 1 }}], + stats: vec![PlayerStats { h1: PeriodStats { goals: 0 }, h2: PeriodStats { goals: 1 }, assists: 1 }], first_scorer: Some(0), }, 0.0625, @@ -344,21 +357,21 @@ fn explore_2x2_pruned_2_goals() { assert_eq!(0.3125, exploration.pruned); assert_expected_prospects(&expected, &exploration.prospects); - let isolated_1gs_none = isolate( + let first_goalscorer_none = isolate( &OfferType::FirstGoalscorer, &OutcomeType::None, &exploration.prospects, &exploration.player_lookup, ); - assert_eq!(0.0625, isolated_1gs_none); + assert_eq!(0.0625, first_goalscorer_none); - let isolated_1gs_other = isolate( + let fist_goalscorer_other = isolate( &OfferType::FirstGoalscorer, &OutcomeType::Player(Player::Other), &exploration.prospects, &exploration.player_lookup, ); - assert_eq!(1.0 - 0.0625 - 0.3125, isolated_1gs_other); + assert_eq!(1.0 - 0.0625 - 0.3125, fist_goalscorer_other); } #[test] @@ -368,11 +381,8 @@ fn explore_3x3() { intervals: 3, h1_probs: ScoringProbs { home_prob: 0.25, away_prob: 0.25, common_prob: 0.25 }, h2_probs: ScoringProbs { home_prob: 0.25, away_prob: 0.25, common_prob: 0.25 }, - players: vec![], - prune_thresholds: PruneThresholds { - max_total_goals: u16::MAX, - min_prob: 0.0, - }, + player_probs: vec![], + prune_thresholds: Default::default(), expansions: Default::default(), }, 0..3, @@ -390,11 +400,8 @@ fn explore_4x4() { intervals: 4, h1_probs: ScoringProbs { home_prob: 0.25, away_prob: 0.25, common_prob: 0.25 }, h2_probs: ScoringProbs { home_prob: 0.25, away_prob: 0.25, common_prob: 0.25 }, - players: vec![], - prune_thresholds: PruneThresholds { - max_total_goals: u16::MAX, - min_prob: 0.0, - }, + player_probs: vec![], + prune_thresholds: Default::default(), expansions: Default::default(), }, 0..4, @@ -406,18 +413,15 @@ fn explore_4x4() { } #[test] -fn explore_1x1_player() { +fn explore_1x1_player_goal() { let player = Player::Named(Side::Home, "Markos".into()); let exploration = explore( &IntervalConfig { intervals: 1, h1_probs: ScoringProbs { home_prob: 0.25, away_prob: 0.25, common_prob: 0.25 }, h2_probs: ScoringProbs { home_prob: 0.25, away_prob: 0.25, common_prob: 0.25 }, - players: vec![(player.clone(), 0.25)], - prune_thresholds: PruneThresholds { - max_total_goals: u16::MAX, - min_prob: 0.0, - }, + player_probs: vec![(player.clone(), PlayerProbs { goal: Some(0.25), assist: None })], + prune_thresholds: Default::default(), expansions: Default::default(), }, 0..1, @@ -430,8 +434,8 @@ fn explore_1x1_player() { ht_score: Score { home: 0, away: 0 }, ft_score: Score { home: 0, away: 0 }, stats: vec![ - PlayerStats { h1: SplitStats { goals: 0 }, h2: SplitStats { goals: 0 }}, - PlayerStats { h1: SplitStats { goals: 0 }, h2: SplitStats { goals: 0 }} + PlayerStats { h1: PeriodStats { goals: 0 }, h2: PeriodStats { goals: 0 }, assists: 0 }, + PlayerStats { h1: PeriodStats { goals: 0 }, h2: PeriodStats { goals: 0 }, assists: 0 } ], first_scorer: None, }, @@ -442,8 +446,8 @@ fn explore_1x1_player() { ht_score: Score { home: 0, away: 0 }, ft_score: Score { home: 1, away: 1 }, stats: vec![ - PlayerStats { h1: SplitStats { goals: 0 }, h2: SplitStats { goals: 0 }}, - PlayerStats { h1: SplitStats { goals: 0 }, h2: SplitStats { goals: 2 }} + PlayerStats { h1: PeriodStats { goals: 0 }, h2: PeriodStats { goals: 0 }, assists: 0 }, + PlayerStats { h1: PeriodStats { goals: 0 }, h2: PeriodStats { goals: 2 }, assists: 2 } ], first_scorer: Some(1), }, @@ -454,8 +458,8 @@ fn explore_1x1_player() { ht_score: Score { home: 0, away: 0 }, ft_score: Score { home: 1, away: 1 }, stats: vec![ - PlayerStats { h1: SplitStats { goals: 0 }, h2: SplitStats { goals: 1 }}, - PlayerStats { h1: SplitStats { goals: 0 }, h2: SplitStats { goals: 1 }} + PlayerStats { h1: PeriodStats { goals: 0 }, h2: PeriodStats { goals: 1 }, assists: 0 }, + PlayerStats { h1: PeriodStats { goals: 0 }, h2: PeriodStats { goals: 1 }, assists: 2 } ], first_scorer: Some(1), }, @@ -466,8 +470,8 @@ fn explore_1x1_player() { ht_score: Score { home: 0, away: 0 }, ft_score: Score { home: 1, away: 1 }, stats: vec![ - PlayerStats { h1: SplitStats { goals: 0 }, h2: SplitStats { goals: 1 }}, - PlayerStats { h1: SplitStats { goals: 0 }, h2: SplitStats { goals: 1 }} + PlayerStats { h1: PeriodStats { goals: 0 }, h2: PeriodStats { goals: 1 }, assists: 0 }, + PlayerStats { h1: PeriodStats { goals: 0 }, h2: PeriodStats { goals: 1 }, assists: 2 } ], first_scorer: Some(0), }, @@ -478,8 +482,8 @@ fn explore_1x1_player() { ht_score: Score { home: 0, away: 0 }, ft_score: Score { home: 1, away: 0 }, stats: vec![ - PlayerStats { h1: SplitStats { goals: 0 }, h2: SplitStats { goals: 0 }}, - PlayerStats { h1: SplitStats { goals: 0 }, h2: SplitStats { goals: 1 }} + PlayerStats { h1: PeriodStats { goals: 0 }, h2: PeriodStats { goals: 0 }, assists: 0 }, + PlayerStats { h1: PeriodStats { goals: 0 }, h2: PeriodStats { goals: 1 }, assists: 1 } ], first_scorer: Some(1), }, @@ -490,8 +494,8 @@ fn explore_1x1_player() { ht_score: Score { home: 0, away: 0 }, ft_score: Score { home: 1, away: 0 }, stats: vec![ - PlayerStats { h1: SplitStats { goals: 0 }, h2: SplitStats { goals: 1 }}, - PlayerStats { h1: SplitStats { goals: 0 }, h2: SplitStats { goals: 0 }} + PlayerStats { h1: PeriodStats { goals: 0 }, h2: PeriodStats { goals: 1 }, assists: 0 }, + PlayerStats { h1: PeriodStats { goals: 0 }, h2: PeriodStats { goals: 0 }, assists: 1 } ], first_scorer: Some(0), }, @@ -502,8 +506,8 @@ fn explore_1x1_player() { ht_score: Score { home: 0, away: 0 }, ft_score: Score { home: 0, away: 1 }, stats: vec![ - PlayerStats { h1: SplitStats { goals: 0 }, h2: SplitStats { goals: 0 }}, - PlayerStats { h1: SplitStats { goals: 0 }, h2: SplitStats { goals: 1 }} + PlayerStats { h1: PeriodStats { goals: 0 }, h2: PeriodStats { goals: 0 }, assists: 0 }, + PlayerStats { h1: PeriodStats { goals: 0 }, h2: PeriodStats { goals: 1 }, assists: 1 } ], first_scorer: Some(1), }, @@ -513,68 +517,89 @@ fn explore_1x1_player() { assert_eq!(0.0, exploration.pruned); assert_expected_prospects(&expected, &exploration.prospects); - let isolated_1gs_none = isolate( + let first_goalscorer_none = isolate( &OfferType::FirstGoalscorer, &OutcomeType::None, &exploration.prospects, &exploration.player_lookup, ); - assert_eq!(0.25, isolated_1gs_none); + assert_eq!(0.25, first_goalscorer_none); - let isolated_1gs_player = isolate( + let first_goalscorer_player = isolate( &OfferType::FirstGoalscorer, &OutcomeType::Player(player.clone()), &exploration.prospects, &exploration.player_lookup, ); - assert_eq!(0.09375, isolated_1gs_player); + assert_eq!(0.09375, first_goalscorer_player); - let isolated_1gs_other = isolate( + let first_goalscorer_other = isolate( &OfferType::FirstGoalscorer, &OutcomeType::Player(Player::Other), &exploration.prospects, &exploration.player_lookup, ); - assert_eq!(1.0 - 0.25 - 0.09375, isolated_1gs_other); + assert_eq!(1.0 - 0.25 - 0.09375, first_goalscorer_other); - let isolated_anytime_none = isolate( + let anytime_goalscorer_none = isolate( &OfferType::AnytimeGoalscorer, &OutcomeType::None, &exploration.prospects, &exploration.player_lookup, ); - assert_eq!(0.25, isolated_anytime_none); + assert_eq!(0.25, anytime_goalscorer_none); - let isolated_anytime_player = isolate( + let anytime_goalscorer_player = isolate( &OfferType::AnytimeGoalscorer, &OutcomeType::Player(player.clone()), &exploration.prospects, &exploration.player_lookup, ); - assert_eq!(0.125, isolated_anytime_player); + assert_eq!(0.125, anytime_goalscorer_player); - let isolated_anytime_other = isolate( + let anytime_goalscorer_other = isolate( &OfferType::AnytimeGoalscorer, &OutcomeType::Player(Player::Other), &exploration.prospects, &exploration.player_lookup, ); - assert_eq!(0.6875, isolated_anytime_other); + assert_eq!(0.6875, anytime_goalscorer_other); + + let anytime_assist_none = isolate( + &OfferType::AnytimeAssist, + &OutcomeType::None, + &exploration.prospects, + &exploration.player_lookup, + ); + assert_eq!(0.25, anytime_assist_none); + + let anytime_assist_player = isolate( + &OfferType::AnytimeAssist, + &OutcomeType::Player(player.clone()), + &exploration.prospects, + &exploration.player_lookup, + ); + assert_eq!(0.0, anytime_assist_player); + + let anytime_assist_other = isolate( + &OfferType::AnytimeAssist, + &OutcomeType::Player(Player::Other), + &exploration.prospects, + &exploration.player_lookup, + ); + assert_eq!(1.0 - 0.25, anytime_assist_other); } #[test] -fn explore_2x2_player() { +fn explore_2x2_player_goal() { let player = Player::Named(Side::Home, "Markos".into()); let exploration = explore( &IntervalConfig { intervals: 2, h1_probs: ScoringProbs { home_prob: 0.25, away_prob: 0.25, common_prob: 0.25 }, h2_probs: ScoringProbs { home_prob: 0.25, away_prob: 0.25, common_prob: 0.25 }, - players: vec![(player.clone(), 0.25)], - prune_thresholds: PruneThresholds { - max_total_goals: u16::MAX, - min_prob: 0.0, - }, + player_probs: vec![(player.clone(), PlayerProbs { goal: Some(0.25), assist: None })], + prune_thresholds: Default::default(), expansions: Default::default(), }, 0..2, @@ -583,44 +608,41 @@ fn explore_2x2_player() { assert_eq!(1.0, exploration.prospects.values().sum::()); assert_eq!(0.0, exploration.pruned); - let isolated_1gs_none = isolate( + let first_goalscorer_none = isolate( &OfferType::FirstGoalscorer, &OutcomeType::None, &exploration.prospects, &exploration.player_lookup, ); - assert_eq!(0.0625, isolated_1gs_none); + assert_eq!(0.0625, first_goalscorer_none); - let isolated_1gs_player = isolate( + let first_goalscorer_player = isolate( &OfferType::FirstGoalscorer, &OutcomeType::Player(player.clone()), &exploration.prospects, &exploration.player_lookup, ); - assert_eq!(0.1171875, isolated_1gs_player); + assert_eq!(0.1171875, first_goalscorer_player); - let isolated_1gs_other = isolate( + let first_goalscorer_other = isolate( &OfferType::FirstGoalscorer, &OutcomeType::Player(Player::Other), &exploration.prospects, &exploration.player_lookup, ); - assert_eq!(1.0 - 0.0625 - 0.1171875, isolated_1gs_other); + assert_eq!(1.0 - 0.0625 - 0.1171875, first_goalscorer_other); } #[test] -fn explore_2x2_player_asymmetric() { +fn explore_2x2_player_goal_asymmetric() { let player = Player::Named(Side::Home, "Markos".into()); let exploration = explore( &IntervalConfig { intervals: 2, h1_probs: ScoringProbs { home_prob: 0.3, away_prob: 0.2, common_prob: 0.1 }, h2_probs: ScoringProbs { home_prob: 0.3, away_prob: 0.2, common_prob: 0.1 }, - players: vec![(player.clone(), 0.25)], - prune_thresholds: PruneThresholds { - max_total_goals: u16::MAX, - min_prob: 0.0, - }, + player_probs: vec![(player.clone(), PlayerProbs { goal: Some(0.25), assist: None })], + prune_thresholds: Default::default(), expansions: Default::default(), }, 0..2, @@ -629,27 +651,27 @@ fn explore_2x2_player_asymmetric() { assert_float_relative_eq!(1.0, exploration.prospects.values().sum::()); assert_eq!(0.0, exploration.pruned); - let isolated_1gs_none = isolate( + let first_goalscorer_none = isolate( &OfferType::FirstGoalscorer, &OutcomeType::None, &exploration.prospects, &exploration.player_lookup, ); - assert_float_relative_eq!(0.16, isolated_1gs_none); + assert_float_relative_eq!(0.16, first_goalscorer_none); - let isolated_1gs_player = isolate( + let first_goalscorer_player = isolate( &OfferType::FirstGoalscorer, &OutcomeType::Player(player.clone()), &exploration.prospects, &exploration.player_lookup, ); - assert_float_relative_eq!(0.1225, isolated_1gs_player); + assert_float_relative_eq!(0.1225, first_goalscorer_player); - let isolated_1gs_other = isolate( + let first_goalscorer_other = isolate( &OfferType::FirstGoalscorer, &OutcomeType::Player(Player::Other), &exploration.prospects, &exploration.player_lookup, ); - assert_float_relative_eq!(1.0 - 0.16 - 0.1225, isolated_1gs_other); + assert_float_relative_eq!(1.0 - 0.16 - 0.1225, first_goalscorer_other); } diff --git a/brumby-soccer/src/model.rs b/brumby-soccer/src/model.rs index e69de29..47247ec 100644 --- a/brumby-soccer/src/model.rs +++ b/brumby-soccer/src/model.rs @@ -0,0 +1,28 @@ +use rustc_hash::FxHashMap; + +use crate::domain::{Offer, OfferType, Player}; +use crate::interval::{PlayerProbs, ScoringProbs}; + +pub mod period_fitter; + +#[derive(Debug)] +pub struct SplitScoringProbs { + pub h1: ScoringProbs, + pub h2: ScoringProbs, +} + +#[derive(Debug)] +pub struct Model { + pub scoring_probs: Option, + pub player_probs: FxHashMap, + pub offers: FxHashMap +} +impl Model { + pub fn new() -> Self { + Self { + scoring_probs: None, + player_probs: Default::default(), + offers: Default::default(), + } + } +} \ No newline at end of file diff --git a/brumby-soccer/src/model/period_fitter.rs b/brumby-soccer/src/model/period_fitter.rs new file mode 100644 index 0000000..5d4b467 --- /dev/null +++ b/brumby-soccer/src/model/period_fitter.rs @@ -0,0 +1,34 @@ +use brumby::opt::HypergridSearchConfig; +use crate::model::Model; + +pub struct FitterConfig<'a> { + poisson_search: HypergridSearchConfig<'a>, + binomial_search: HypergridSearchConfig<'a>, +} +impl FitterConfig<'_> { + fn validate(&self) -> Result<(), anyhow::Error> { + self.poisson_search.validate()?; + self.binomial_search.validate()?; + Ok(()) + } +} + +pub struct PeriodFitter<'a> { + config: FitterConfig<'a> +} +impl PeriodFitter<'_> { + pub fn fit(model: &mut Model) -> Result<(), anyhow::Error> { + todo!() + } +} + +impl<'a> TryFrom> for PeriodFitter<'a> { + type Error = anyhow::Error; + + fn try_from(config: FitterConfig<'a>) -> Result { + config.validate()?; + Ok(Self { + config, + }) + } +} \ No newline at end of file diff --git a/brumby-soccer/src/scoregrid.rs b/brumby-soccer/src/scoregrid.rs index 0b4a1af..fb17547 100644 --- a/brumby-soccer/src/scoregrid.rs +++ b/brumby-soccer/src/scoregrid.rs @@ -163,7 +163,7 @@ pub fn from_interval( intervals, h1_probs, h2_probs, - players: vec![], + player_probs: vec![], prune_thresholds: PruneThresholds { max_total_goals, min_prob: 0.0, @@ -171,8 +171,9 @@ pub fn from_interval( expansions: Expansions { ft_score: true, ht_score: false, - player_stats: false, - player_split_stats: false, + player_goal_stats: false, + player_split_goal_stats: false, + max_player_assists: 0, first_goalscorer: false, } }, diff --git a/brumby/src/market.rs b/brumby/src/market.rs index 53f8c59..ffb5d98 100644 --- a/brumby/src/market.rs +++ b/brumby/src/market.rs @@ -88,7 +88,7 @@ impl Market { } pub fn offered_booksum(&self) -> f64 { - self.prices.iter().map(|price| 1.0 / price).sum() + self.prices.invert().sum() } pub fn fit(method: &OverroundMethod, prices: Vec, fair_sum: f64) -> Self { diff --git a/brumby/src/opt.rs b/brumby/src/opt.rs index bbf82d5..494374e 100644 --- a/brumby/src/opt.rs +++ b/brumby/src/opt.rs @@ -1,4 +1,5 @@ use std::ops::RangeInclusive; +use anyhow::bail; use crate::capture::Capture; use crate::comb::{count_permutations, pick}; @@ -11,6 +12,17 @@ pub struct UnivariateDescentConfig { pub max_steps: u64, pub acceptable_residual: f64, } +impl UnivariateDescentConfig { + pub fn validate(&self) -> Result<(), anyhow::Error> { + if self.min_step <= 0.0 { + bail!("min step must be positive") + } + if self.acceptable_residual < 0.0 { + bail!("acceptable residual must be non-negative") + } + Ok(()) + } +} #[derive(Debug)] pub struct UnivariateDescentOutcome { @@ -24,6 +36,8 @@ pub fn univariate_descent( config: &UnivariateDescentConfig, mut loss_f: impl FnMut(f64) -> f64, ) -> UnivariateDescentOutcome { + config.validate().unwrap(); + let mut steps = 0; let mut residual = loss_f(config.init_value); if residual <= config.acceptable_residual { @@ -78,6 +92,24 @@ pub struct HypergridSearchConfig<'a> { pub bounds: Capture<'a, Vec>, [RangeInclusive]>, pub resolution: usize, } +impl HypergridSearchConfig<'_> { + pub fn validate(&self) -> Result<(), anyhow::Error> { + if self.max_steps <= 0 { + bail!("at least one step must be specified") + } + if self.acceptable_residual < 0.0 { + bail!("acceptable residual must be non-negative") + } + if self.bounds.is_empty() { + bail!("at least one search dimension must be specified") + } + const MIN_RESOLUTION: usize = 3; + if self.resolution < MIN_RESOLUTION { + bail!("search resolution must be at least {MIN_RESOLUTION}") + } + Ok(()) + } +} #[derive(Debug)] pub struct HypergridSearchOutcome { @@ -90,6 +122,7 @@ pub fn hypergrid_search( config: &HypergridSearchConfig, mut constraint_f: impl FnMut(&[f64]) -> bool, mut loss_f: impl FnMut(&[f64]) -> f64) -> HypergridSearchOutcome { + config.validate().unwrap(); let mut steps = 0; let mut values = Vec::with_capacity(config.bounds.len());