diff --git a/brumby-soccer/benches/cri_isolate.rs b/brumby-soccer/benches/cri_isolate.rs index c26e84f..272eab9 100644 --- a/brumby-soccer/benches/cri_isolate.rs +++ b/brumby-soccer/benches/cri_isolate.rs @@ -1,7 +1,8 @@ use brumby_soccer::domain::{OfferType, OutcomeType, Player, Side}; use criterion::{criterion_group, criterion_main, Criterion}; -use brumby_soccer::interval::{explore, isolate, Exploration, IntervalConfig, ScoringProbs, PruneThresholds}; +use brumby_soccer::interval::{explore, Exploration, IntervalConfig, ScoringProbs, PruneThresholds}; +use brumby_soccer::interval::query::isolate; fn criterion_benchmark(c: &mut Criterion) { let player = Player::Named(Side::Home, "Markos".into()); @@ -33,7 +34,7 @@ fn criterion_benchmark(c: &mut Criterion) { ); assert!(isolated > 0.0); - c.bench_function("cri_isolate_first_goalscorer_18_unbounded", |b| { + c.bench_function("cri_isolate_first_goalscorer_18", |b| { let exploration = prepare(18, u16::MAX, player.clone()); b.iter(|| { isolate( diff --git a/brumby-soccer/src/bin/soc_prices.rs b/brumby-soccer/src/bin/soc_prices.rs index 4a13689..54cb302 100644 --- a/brumby-soccer/src/bin/soc_prices.rs +++ b/brumby-soccer/src/bin/soc_prices.rs @@ -18,7 +18,7 @@ use brumby::{factorial, poisson}; use brumby_soccer::{scoregrid}; use brumby_soccer::domain::{OfferType, OutcomeType, Over, Period, Player, Side}; use brumby_soccer::domain::Player::Named; -use brumby_soccer::interval::{Expansions, explore, IntervalConfig, isolate, PruneThresholds, ScoringProbs}; +use brumby_soccer::interval::{Expansions, explore, IntervalConfig, PruneThresholds, ScoringProbs}; use brumby::linear::matrix::Matrix; use brumby::market::{Market, Overround, OverroundMethod, PriceBounds}; use brumby::opt::{ @@ -28,6 +28,7 @@ use brumby::opt::{ use brumby::probs::SliceExt; use brumby_soccer::scoregrid::{from_correct_score, home_away_expectations}; use brumby_soccer::data::{ContestSummary, download_by_id}; +use brumby_soccer::interval::query::isolate; const OVERROUND_METHOD: OverroundMethod = OverroundMethod::OddsRatio; const SINGLE_PRICE_BOUNDS: PriceBounds = 1.01..=301.0; diff --git a/brumby-soccer/src/interval.rs b/brumby-soccer/src/interval.rs index 87103b9..70a15f7 100644 --- a/brumby-soccer/src/interval.rs +++ b/brumby-soccer/src/interval.rs @@ -1,8 +1,13 @@ -use rustc_hash::FxHashMap; use std::ops::Range; +use rustc_hash::FxHashMap; + +use brumby::lookup::Lookup; + use crate::domain::{OfferType, OutcomeType, Player, Score, Side}; +pub mod query; + #[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] pub struct Prospect { pub score: Score, @@ -54,9 +59,15 @@ pub struct Expansions { impl Expansions { fn validate(&self) { if self.player_split_stats { - assert!(self.player_stats, "cannot expand player split stats without player stats"); + assert!( + self.player_stats, + "cannot expand player split stats without player stats" + ); } - assert!(self.ft_score || self.player_stats || self.first_goalscorer, "at least one expansion must be enabled") + assert!( + self.ft_score || self.player_stats || self.first_goalscorer, + "at least one expansion must be enabled" + ) } } @@ -74,7 +85,7 @@ impl Default for Expansions { #[derive(Debug)] pub struct PruneThresholds { pub max_total_goals: u16, - pub min_prob: f64 + pub min_prob: f64, } #[derive(Debug)] @@ -89,7 +100,7 @@ pub struct IntervalConfig { #[derive(Debug)] pub struct Exploration { - pub player_lookup: Vec, + pub player_lookup: Lookup, pub prospects: Prospects, pub pruned: f64, } @@ -118,10 +129,10 @@ enum Half { Second, } -pub fn explore(config: &IntervalConfig, explore_intervals: Range) -> Exploration { +pub fn explore(config: &IntervalConfig, include_intervals: Range) -> Exploration { config.expansions.validate(); - let mut player_lookup = Vec::with_capacity(config.players.len() + 1); + let mut player_lookup = Lookup::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; @@ -150,7 +161,7 @@ pub fn explore(config: &IntervalConfig, explore_intervals: Range) -> Explora current_prospects.insert(Prospect::init(player_lookup.len()), 1.0); let mut pruned = 0.0; - for interval in explore_intervals { + for interval in include_intervals { let half = if interval < config.intervals / 2 { Half::First } else { @@ -295,7 +306,10 @@ fn merge( merged.score.home += 1; } - if expansions.first_goalscorer && merged.first_scorer.is_none() && partial.first_scoring_side.unwrap() == &Side::Home { + if expansions.first_goalscorer + && merged.first_scorer.is_none() + && partial.first_scoring_side.unwrap() == &Side::Home + { merged.first_scorer = Some(scorer); } } @@ -314,7 +328,10 @@ fn merge( merged.score.away += 1; } - if expansions.first_goalscorer && merged.first_scorer.is_none() && partial.first_scoring_side.unwrap() == &Side::Away { + if expansions.first_goalscorer + && merged.first_scorer.is_none() + && partial.first_scoring_side.unwrap() == &Side::Away + { merged.first_scorer = Some(scorer); } } @@ -324,99 +341,87 @@ fn merge( .or_insert(merged_prob); } -#[derive(Debug)] -pub enum IsolationState { - None, - PlayerLookup(usize) -} - -#[must_use] -pub fn isolate( - offer_type: &OfferType, - outcome_type: &OutcomeType, - prospects: &Prospects, - player_lookup: &[Player], -) -> f64 { - match offer_type { - OfferType::HeadToHead(_) => unimplemented!(), - OfferType::TotalGoalsOverUnder(_, _) => unimplemented!(), - OfferType::CorrectScore(_) => unimplemented!(), - OfferType::DrawNoBet => unimplemented!(), - OfferType::AnytimeGoalscorer => { - isolate_anytime_goalscorer(outcome_type, prospects, player_lookup) - } - OfferType::FirstGoalscorer => { - isolate_first_goalscorer(outcome_type, prospects, player_lookup) - } - OfferType::PlayerShotsOnTarget(_) => unimplemented!(), - OfferType::AnytimeAssist => unimplemented!(), - } -} - -#[must_use] -fn isolate_first_goalscorer( - outcome_type: &OutcomeType, - prospects: &Prospects, - player_lookup: &[Player], -) -> f64 { - match outcome_type { - OutcomeType::Player(player) => prospects - .iter() - .filter(|(prospect, _)| { - prospect - .first_scorer - .map(|scorer| &player_lookup[scorer] == player) - .unwrap_or(false) - }) - .map(|(_, prob)| prob) - .sum(), - OutcomeType::None => prospects - .iter() - .filter(|(prospect, _)| prospect.first_scorer.is_none()) - .map(|(_, prob)| prob) - .sum(), - _ => panic!( - "{outcome_type:?} unsupported in {:?}", - OfferType::FirstGoalscorer - ), - } -} - -#[must_use] -fn isolate_anytime_goalscorer( - outcome_type: &OutcomeType, - prospects: &Prospects, - player_lookup: &[Player], -) -> f64 { - match outcome_type { - OutcomeType::Player(player) => prospects - .iter() - .filter(|(prospect, _)| { - let scorer = prospect - .stats - .iter() - .enumerate() - .find(|(scorer_index, _)| &player_lookup[*scorer_index] == player); - match scorer { - None => { - panic!("missing {player:?} from stats") - } - Some((_, stats)) => stats.h1.goals > 0 || stats.h2.goals > 0, - } - }) - .map(|(_, prob)| prob) - .sum(), - OutcomeType::None => prospects - .iter() - .filter(|(prospect, _)| prospect.first_scorer.is_none()) - .map(|(_, prob)| prob) - .sum(), - _ => panic!( - "{outcome_type:?} unsupported in {:?}", - OfferType::AnytimeGoalscorer - ), - } -} +// #[must_use] +// pub fn isolate( +// offer_type: &OfferType, +// outcome_type: &OutcomeType, +// prospects: &Prospects, +// player_lookup: &Lookup, +// ) -> f64 { +// match offer_type { +// OfferType::HeadToHead(_) => unimplemented!(), +// OfferType::TotalGoalsOverUnder(_, _) => unimplemented!(), +// OfferType::CorrectScore(_) => unimplemented!(), +// OfferType::DrawNoBet => unimplemented!(), +// OfferType::AnytimeGoalscorer => { +// isolate_anytime_goalscorer(outcome_type, prospects, player_lookup) +// } +// OfferType::FirstGoalscorer => { +// isolate_first_goalscorer(outcome_type, prospects, player_lookup) +// } +// OfferType::PlayerShotsOnTarget(_) => unimplemented!(), +// OfferType::AnytimeAssist => unimplemented!(), +// } +// } +// +// #[must_use] +// fn isolate_first_goalscorer( +// outcome_type: &OutcomeType, +// prospects: &Prospects, +// player_lookup: &Lookup, +// ) -> f64 { +// match outcome_type { +// OutcomeType::Player(player) => prospects +// .iter() +// .filter(|(prospect, _)| { +// prospect +// .first_scorer +// .map(|scorer| &player_lookup[scorer] == player) +// .unwrap_or(false) +// }) +// .map(|(_, prob)| prob) +// .sum(), +// OutcomeType::None => prospects +// .iter() +// .filter(|(prospect, _)| prospect.first_scorer.is_none()) +// .map(|(_, prob)| prob) +// .sum(), +// _ => panic!("{outcome_type:?} unsupported"), +// } +// } +// +// #[must_use] +// fn isolate_anytime_goalscorer( +// outcome_type: &OutcomeType, +// prospects: &Prospects, +// player_lookup: &Lookup, +// ) -> f64 { +// match outcome_type { +// OutcomeType::Player(player) => prospects +// .iter() +// .filter(|(prospect, _)| { +// let scorer = prospect +// .stats +// .iter() +// .enumerate() +// .find(|(scorer_index, _)| &player_lookup[*scorer_index] == player); +// match scorer { +// None => { +// panic!("missing {player:?} from stats") +// } +// Some((_, stats)) => stats.h1.goals > 0 || stats.h2.goals > 0, +// } +// }) +// .map(|(_, prob)| prob) +// .sum(), +// OutcomeType::None => prospects +// .iter() +// .filter(|(prospect, _)| prospect.first_scorer.is_none()) +// .map(|(_, prob)| prob) +// .sum(), +// _ => panic!("{outcome_type:?} unsupported"), +// } +// } #[cfg(test)] mod tests; diff --git a/brumby-soccer/src/interval/query.rs b/brumby-soccer/src/interval/query.rs new file mode 100644 index 0000000..38d7171 --- /dev/null +++ b/brumby-soccer/src/interval/query.rs @@ -0,0 +1,169 @@ +use brumby::lookup::Lookup; +use crate::domain::{OfferType, OutcomeType, Player}; +use crate::interval::{Expansions, Prospect, Prospects}; + +#[derive(Debug)] +pub enum QuerySpec { + None, + PlayerLookup(usize), + NoFirstGoalscorer, + NoAnytimeGoalscorer +} + +#[must_use] +pub fn requirements(offer_type: &OfferType) -> Expansions { + match offer_type { + OfferType::HeadToHead(_) => unimplemented!(), + OfferType::TotalGoalsOverUnder(_, _) => unimplemented!(), + OfferType::CorrectScore(_) => unimplemented!(), + OfferType::DrawNoBet => unimplemented!(), + OfferType::FirstGoalscorer => { + requirements_first_goalscorer() + } + OfferType::AnytimeGoalscorer => { + requirements_anytime_goalscorer() + } + OfferType::PlayerShotsOnTarget(_) => unimplemented!(), + OfferType::AnytimeAssist => unimplemented!(), + } +} + +#[inline] +#[must_use] +fn requirements_first_goalscorer() -> Expansions { + Expansions { + ft_score: false, + player_stats: false, + player_split_stats: false, + first_goalscorer: true, + } +} + +#[inline] +#[must_use] +fn requirements_anytime_goalscorer() -> Expansions { + Expansions { + ft_score: false, + player_stats: true, + player_split_stats: false, + first_goalscorer: false, + } +} + +#[must_use] +pub fn prepare( + offer_type: &OfferType, + outcome_type: &OutcomeType, + player_lookup: &Lookup, +) -> QuerySpec { + match offer_type { + OfferType::HeadToHead(_) => unimplemented!(), + OfferType::TotalGoalsOverUnder(_, _) => unimplemented!(), + OfferType::CorrectScore(_) => unimplemented!(), + OfferType::DrawNoBet => unimplemented!(), + OfferType::FirstGoalscorer => { + prepare_first_goalscorer(outcome_type, player_lookup) + } + OfferType::AnytimeGoalscorer => { + prepare_anytime_goalscorer(outcome_type, player_lookup) + } + OfferType::PlayerShotsOnTarget(_) => unimplemented!(), + OfferType::AnytimeAssist => unimplemented!(), + } +} + +#[inline] +#[must_use] +fn prepare_first_goalscorer( + outcome_type: &OutcomeType, + player_lookup: &Lookup, +) -> QuerySpec { + match outcome_type { + OutcomeType::Player(player) => { + QuerySpec::PlayerLookup(player_lookup.index_of(player).unwrap()) + }, + OutcomeType::None => QuerySpec::NoFirstGoalscorer, + _ => panic!("{outcome_type:?} unsupported"), + } +} + +#[inline] +#[must_use] +fn prepare_anytime_goalscorer( + outcome_type: &OutcomeType, + player_lookup: &Lookup, +) -> QuerySpec { + match outcome_type { + OutcomeType::Player(player) => { + QuerySpec::PlayerLookup(player_lookup.index_of(player).unwrap()) + }, + OutcomeType::None => QuerySpec::NoAnytimeGoalscorer, + _ => panic!("{outcome_type:?} unsupported"), + } +} + +#[must_use] +pub fn filter(offer_type: &OfferType, + query: &QuerySpec, + prospect: &Prospect) -> bool { + match offer_type { + OfferType::HeadToHead(_) => unimplemented!(), + OfferType::TotalGoalsOverUnder(_, _) => unimplemented!(), + OfferType::CorrectScore(_) => unimplemented!(), + OfferType::DrawNoBet => unimplemented!(), + OfferType::AnytimeGoalscorer => { + filter_anytime_goalscorer(query, prospect) + } + OfferType::FirstGoalscorer => { + filter_first_goalscorer(query, prospect) + } + OfferType::PlayerShotsOnTarget(_) => unimplemented!(), + OfferType::AnytimeAssist => unimplemented!(), + } +} + +#[inline] +#[must_use] +fn filter_first_goalscorer(query: &QuerySpec, prospect: &Prospect) -> bool { + match query { + QuerySpec::PlayerLookup(target_player) => match prospect.first_scorer { + None => false, + Some(scorer) => scorer == *target_player, + }, + QuerySpec::NoFirstGoalscorer => prospect.first_scorer.is_none(), + _ => panic!("{query:?} unsupported"), + } +} + +#[inline] +#[must_use] +fn filter_anytime_goalscorer(query: &QuerySpec, prospect: &Prospect) -> bool { + match query { + QuerySpec::PlayerLookup(target_player) => { + let stats = &prospect.stats[*target_player]; + stats.h1.goals > 0 || stats.h2.goals > 0 + }, + QuerySpec::NoAnytimeGoalscorer => { + !prospect.stats.iter().any(|stats| stats.h1.goals > 0 || stats.h2.goals > 0) + } + _ => panic!("{query:?} unsupported"), + } +} + +#[must_use] +pub fn isolate( + offer_type: &OfferType, + outcome_type: &OutcomeType, + prospects: &Prospects, + player_lookup: &Lookup, +) -> f64 { + let query = prepare(offer_type, outcome_type, player_lookup); + prospects.iter().filter(|(prospect, _)| filter(offer_type, &query, prospect)).map(|(_, prob)|prob).sum() + // prospects.iter().map(|(prospect, prob)| { + // if filter(offer_type, &query, prospect) { + // *prob + // } else { + // 0.0 + // } + // }).sum() +} \ No newline at end of file diff --git a/brumby-soccer/src/interval/tests.rs b/brumby-soccer/src/interval/tests.rs index ee65f16..37b5ff4 100644 --- a/brumby-soccer/src/interval/tests.rs +++ b/brumby-soccer/src/interval/tests.rs @@ -1,6 +1,7 @@ use super::*; use crate::domain::Player; use assert_float_eq::*; +use crate::interval::query::isolate; fn print_prospects(prospects: &Prospects) { for (prospect, prob) in prospects { diff --git a/brumby-soccer/src/scoregrid.rs b/brumby-soccer/src/scoregrid.rs index fd4b118..81e5b70 100644 --- a/brumby-soccer/src/scoregrid.rs +++ b/brumby-soccer/src/scoregrid.rs @@ -170,16 +170,16 @@ pub fn from_interval( intervals: u8, explore_intervals: Range, max_total_goals: u16, - h1_params: ScoringProbs, - h2_params: ScoringProbs, + h1_probs: ScoringProbs, + h2_probs: ScoringProbs, scoregrid: &mut Matrix, ) { assert_eq!(scoregrid.rows(), scoregrid.cols()); let exploration = explore( &IntervalConfig { intervals, - h1_probs: h1_params, - h2_probs: h2_params, + h1_probs, + h2_probs, players: vec![], prune_thresholds: PruneThresholds { max_total_goals, diff --git a/brumby/src/lookup.rs b/brumby/src/lookup.rs index a1c1a5a..6f6affd 100644 --- a/brumby/src/lookup.rs +++ b/brumby/src/lookup.rs @@ -1,31 +1,36 @@ -use std::hash::Hash; use rustc_hash::FxHashMap; +use std::hash::Hash; +use std::ops::Index; #[derive(Debug)] -pub struct Index { +pub struct Lookup { item_to_index: FxHashMap, index_to_item: Vec, } -impl Index { +impl Lookup { pub fn with_capacity(capacity: usize) -> Self { - let index_to_item = FxHashMap::with_capacity_and_hasher(capacity, Default::default()); - let item_to_index = Vec::with_capacity(capacity); + let item_to_index = FxHashMap::with_capacity_and_hasher(capacity, Default::default()); + let index_to_item = Vec::with_capacity(capacity); Self { - item_to_index: index_to_item, - index_to_item: item_to_index + item_to_index, + index_to_item, } } - pub fn push(&mut self, item: T) where T: Clone { - self.item_to_index.insert(item.clone(), self.item_to_index.len()); + pub fn push(&mut self, item: T) + where + T: Clone, + { + self.item_to_index + .insert(item.clone(), self.item_to_index.len()); self.index_to_item.push(item); } - pub fn item(&self, index: usize) -> Option<&T> { + pub fn item_at(&self, index: usize) -> Option<&T> { self.index_to_item.get(index) } - pub fn index(&self, item: &T) -> Option { + pub fn index_of(&self, item: &T) -> Option { self.item_to_index.get(&item).map(|&index| index) } @@ -34,25 +39,63 @@ impl Index { } } +impl Index for Lookup { + type Output = T; + + fn index(&self, index: usize) -> &Self::Output { + self.item_at(index) + .unwrap_or_else(|| panic!("no item at index {index}")) + } +} + +impl From> for Lookup { + fn from(index_to_item: Vec) -> Self { + let mut item_to_index = + FxHashMap::with_capacity_and_hasher(index_to_item.len(), Default::default()); + for (index, item) in index_to_item.iter().enumerate() { + item_to_index.insert(item.clone(), index); + } + Self { + item_to_index, + index_to_item, + } + } +} + #[cfg(test)] mod tests { use super::*; #[test] - fn push_and_lookup() { - let mut index = Index::with_capacity(3); - assert_eq!(0, index.len()); - index.push("zero"); - index.push("one"); - assert_eq!(2, index.len()); + fn push_and_resolve() { + let mut lookup = Lookup::with_capacity(3); + assert_eq!(0, lookup.len()); + lookup.push("zero"); + lookup.push("one"); + assert_eq!(2, lookup.len()); - assert_eq!(Some(&"zero"), index.item(0)); - assert_eq!(Some(0), index.index(&"zero")); + assert_eq!(Some(&"zero"), lookup.item_at(0)); + assert_eq!(Some(0), lookup.index_of(&"zero")); - assert_eq!(Some(&"one"), index.item(1)); - assert_eq!(Some(1), index.index(&"one")); + assert_eq!(Some(&"one"), lookup.item_at(1)); + assert_eq!(Some(1), lookup.index_of(&"one")); - assert_eq!(None, index.item(2)); - assert_eq!(None, index.index(&"two")); + assert_eq!(None, lookup.item_at(2)); + assert_eq!(None, lookup.index_of(&"two")); + } + + #[test] + fn from_vec() { + let lookup = Lookup::from(vec!["zero", "one"]); + assert_eq!(Some(&"zero"), lookup.item_at(0)); + assert_eq!(Some(1), lookup.index_of(&"one")); + assert_eq!(2, lookup.len()); } -} \ No newline at end of file + + #[test] + #[should_panic(expected = "no item at index 2")] + fn no_item_at_index() { + let lookup = Lookup::from(vec!["zero", "one"]); + lookup[2]; + } +}