2 个版本
0.7.3 | 2023年12月7日 |
---|---|
0.7.2 | 2023年12月7日 |
#257 在 科学
260KB
6.5K SLoC
brumby-racing
一个快速、无分配的赛车事件前-N名的蒙特卡洛模型。仅根据获胜概率推导出任意位置的放置概率。还可以推导出具有任意(精确和前-N名)排名的多个跑者的联合概率。
性能
使用 tinyrand RNG 对前4名中的14名跑者进行约15M次/秒的模拟。 (每个线程,在 Apple M2 Pro 上基准测试。) 大约70%的执行时间用于 RNG 例程。
示例
来自 examples/multi.rs
。要尝试此示例,请在命令行中运行 just multi
。您需要安装 just。
use std::error::Error;
use std::path::PathBuf;
use stanza::renderer::console::Console;
use stanza::renderer::Renderer;
use brumby::display::DisplaySlice;
use brumby::file::ReadJsonFile;
use brumby::market::{Market, OverroundMethod};
use brumby::selection::{Rank, Runner};
use brumby_racing::model::{Fitter, FitterConfig, WinPlace, Model};
use brumby_racing::model::cf::Coefficients;
use brumby_racing::model::fit::FitOptions;
use brumby_racing::print;
fn main() -> Result<(), Box<dyn Error>> {
// prices taken from a popular website
let win_prices = vec![
1.65,
7.0,
15.0,
9.5,
f64::INFINITY, // a scratched runner
9.0,
7.0,
11.0,
151.0,
];
let place_prices = vec![
1.12,
1.94,
3.2,
2.3,
f64::INFINITY, // a scratched runner
2.25,
1.95,
2.55,
28.0,
];
// load coefficients from a file and create a fitter
let coefficients = Coefficients::read_json_file(PathBuf::from("config/thoroughbred.cf.json"))?;
let config = FitterConfig {
coefficients,
fit_options: FitOptions::fast() // use the default presents in production; fast presets are used for testing
};
let fitter = Fitter::try_from(config)?;
// fit Win and Place probabilities from the supplied prices, undoing the overrounds
let wp_markets = WinPlace {
win: Market::fit(&OverroundMethod::Multiplicative, win_prices, 1.),
place: Market::fit(&OverroundMethod::Multiplicative, place_prices, 3.),
places_paying: 3,
};
// we have overrounds for Win and Place; extrapolate for Top-2 and Top-4 markets
let overrounds = wp_markets.extrapolate_overrounds()?;
// fit a model using the Win/Place prices and extrapolated overrounds
let model = fitter.fit(&wp_markets, &overrounds)?.value;
// nicely format the derived price matrix
let table = print::tabulate_derived_prices(&model.prices().as_price_matrix());
println!("\n{}", Console::default().render(&table));
// simulate a same-race multi for a chosen selection vector using the previously fitted model
let selections = vec![
Runner::number(6).top(Rank::number(1)),
Runner::number(7).top(Rank::number(2)),
Runner::number(8).top(Rank::number(3)),
];
let multi_price = model.derive_multi(&selections)?.value;
println!(
"{} with probability {:.6} is priced at {:.2}",
DisplaySlice::from(&*selections),
multi_price.probability,
multi_price.price
);
Ok(())
}
离线/在线与仅离线 API
Brumby 使用离线和在线拟合的组合来确定初始加权概率(离线阶段)并在之后微调这些权重(在线阶段),以使推导出的前3名(或前2名,取决于可支付的排名数量)市场价格与提供的 Place 市场价格相一致。在线阶段确保模型相对于用于生成 Place 价格的某些其他模型提供无套利价格。换句话说,离线阶段在去除运动中存在的系统性偏差的大部分方面进行了泛化;例如,热门-冷门偏差和获胜-排名偏差。在线阶段专注于特定比赛中的特定偏差(偏差);这些偏差在很大程度上是未知的,但存在于 Place 模型中。
尽管计算量更大,但强烈建议在 Place 价格由外部模型生成时运行在线阶段。上述示例使用 Fitter
API 进行在线拟合。结果是 FittedModel
结构,可以随后用于获取前-N名(单场)价格和多重价格。
在没有 Place 模型的情况下,Brumby 可用于推导 Place 价格。在此,在线拟合不再适用;而是使用 Primer
API 实例化模型结构,跳过在线拟合步骤。
工作原理
Brumby 基于回归拟合的加权蒙特卡洛(MC)模拟。
理想情况下,人们会从一个公平概率集开始。但在不太理想的情况下,可能会应用过度赔率的价格;首先需要移除过度赔率,以获得一组公平概率。Brumby支持一系列过度赔率方法。(详细内容将在下一节中介绍。)
我们从一种简单的蒙特卡洛模型开始。它使用提供的获胜概率集模拟领奖台排名。简单模型以概率向量P为输入,并使用有种子伪随机数生成器运行一系列随机试验。Pj是选手j获得第一名的概率。在每次试验中,一个均匀分布的随机数被映射到一个与提供的概率成比例的区间。这产生了那次试验的获胜者。在相同的试验中,然后我们消除获胜者的区间,并相应地调整RNG的输出范围。接下来的随机数指向第二名获得者,依此类推,直到建立所需的领奖台。领奖台记录为那次试验,然后开始下一次试验。随着试验的进行,系统为每个选手建立了一个从1..N的最高排名概率矩阵。(每行一个排名,每列一个选手。)我们观察到,100K次试验通常足以填充一个具有足够精度的概率矩阵,以便用于投注应用,如定价衍生品。然而,准确性却是另一回事。
很容易看出,简单的蒙特卡洛模型是Harville方法的随机等价物。它通过将选手击败剩余赛场的概率相加来确定选手获得第二名的概率,条件是其他选手获胜。在每种情况下,“击败赛场”的概率是通过去除获胜者后的剩余概率归一化获得的。Harville方法推广到所有完成排名。
上述模型,无论是其随机形式还是分析形式,都没有意识到系统性偏差,例如热门选手-冷门选手偏差,也没有意识到冷门选手的排名可能会比他们的获胜概率所暗示的更高。(相反,热门选手的排名可能性较低。)我们在此不深入细节,只需说,现实生活中的竞争者根据他们在比赛中的位置有不同的动机。他们也可能受到指示。简单的蒙特卡洛(或Harville)模型假设一个没有动机、只有获得第一名的选手的赛场比赛。
简单模型显然是不够的。它在任何我们知道的竞争性运动中所做的假设都是不真实的。虽然在一个能力相当的比赛场中准确性可能令人满意,但在不同选手能力和竞争策略的比赛场中就会崩溃。由于目标应用主要是由后者组成,我们必须修改模型,以在不影响获胜概率的情况下调整选手获得2至N名(对于N名领奖台)的概率。我们引入了一个能够针对特定排名位置的个别选手进行定位的精细粒度偏差层。在简单模型中使用概率向量P的情况下,有偏模型使用矩阵W——每行一个排名。Wi,j是选手j获得排名i的相对概率。(i ∈ ℕ ∩ [1, N], j ∈ ℕ ∩ [1, M].)矩阵W第一行的值等于P。第2至N行根据每个选手相对排名概率的变化进行调整。
注意,当所有行都相同,有偏模型的行为与简单模型相同。即,_M__naive_ ≡ _M__biased_ ⇔ ∀ i, k ∈ ℕ ∩ [1, N], j ∈ ℕ ∩ [1, M] : Wi,j = Wk,j.
以一个有6个选手、获胜概率P = (0.05, 0.1, 0.25, 0.1, 0.35, 0.15)的赛场为例。对于两个位置的领奖台,分解后的W可能如下所示
_W_1,_ = (0.05, 0.1, 0.25, 0.1, 0.35, 0.15) = P;
_W_2,_ = (0.09, 0.13, 0.22, 0.13, 0.28, 0.15)。
换句话说,高概率跑者的相对排名概率被压制,而低概率跑者的概率反而被提升。这反映了我们更新的假设,即低(/高)概率跑者被一个简单的模型低估(高估)了。
一个相关的问题是,在给定 P 和可能的其他数据的情况下,如何分配第2行至第N行的相对概率。一种直观的方法是根据历史数据拟合概率。Brumby 使用一个可配置回归因子的线性回归模型。例如,一个由跑者价格和场地大小组成的立方多项式。(我们发现这是一个相当有效的预测器。)对于不同的比赛类型、竞争类别、跑道条件等,可能使用不同的模型。拟合过程是在线进行的;其输出是一组回归因子和相应的系数。
离线拟合的模型没有考虑到个别比赛中存在的特定偏差,并且更重要的是,它没有保护模型用户免受内部套利机会的影响。假设让赔率市场支付X个位置的赔率,其中X通常是2或3。当仅从胜出赔率推导出Top-1..N价格矩阵时,如果后者的来源是另一个模型,Top-X价格可能与Place价格不同。这造成了一个内部价格不一致性,其中半理性的投注者会选择两个价格中的较高者,其他条件相同。在极端情况下,价格差异可能会在投注中暴露价值,甚至使理性的投注者能够在一个不一致的市场对上获得无风险的位置。
这个问题最好通过统一模型来解决,使得Place价格直接从Top-1..N矩阵中获取。通常这不可行,尤其是当运营商从商品定价供应商那里获取胜出和Place市场,并/或手动进行交易时。因此,Brumby 允许将Top-X价格拟合到提供的Place价格上。拟合是完全在线进行的,通常在价格更新后进行,在调整W__X时迭代,直到Top-X价格在某个可接受的误差范围内匹配Place价格。
将Top-X市场拟合到Place市场是一个闭环过程,使用拟合的残差来调整后续的调整,并最终终止拟合过程。在每次迭代中,对于每个排名i和每个跑者j,都会拟合一个价格并与样本价格进行比较。差异用于调整W__i,_j_中的概率。例如,让拟合的价格f为2.34,样本价格s为2.41,对于排名3的跑者5。调整因子是s / f = 1.03。_W′_3,5 = _W_3,5 × 1.03。
除了对Place排名的闭环拟合外,Brumby 还支持(默认启用)其他市场的开环拟合。其理由如下:如果Place排名有错误,归因于一些具体但未知偏差,那么其他排名也可能有类似的错误,归因于相同的偏差。换句话说,任何特定的偏差不太可能仅限于一个排名。因此,除了对Top-X价格进行闭环拟合外,在线模型还将相同的调整应用到其他排名上。这种假设在实践中似乎是合理的;在历史价格上测试,开环拟合始终表现出较低的误差。
开环拟合不是一个简单的开关设置;相反,它表示为一个指数:区间 [0, 1] 内的实数。调整 W 中概率的公式为 _W′_i,_j_ = W__i,_j_ × (s__i,_j_ / f__i,_j_)_t_,其中 t 是开环指数。当 t = 0 时,该过程纯粹是闭环的:除了排名外的其他排名没有进行调整。当 t = 1 时,其他排名的调整幅度等于排名的调整幅度。当 t 取某个中间值时,将进行开环调整,尽管调整幅度小于对应的排名。例如,当 t = 0.5 时,其他排名的调整等于排名调整的平方根。默认设置是 t = 1。
超赔
Brumby 支持多种超赔方法的开环和闭环支持。拟合和闭环能力是互补的:“拟合”指的是从现有的市场报价中去除超赔(其中已知超赔值和超赔方法,但方法参数是隐藏的);“闭环”指的是将超赔应用于一组公平概率,以获得相应的一组市场价格。
乘法方法
这是最简单且最常用的方法之一,其中每个公平价格乘以一个常数以实现所需的超赔。拟合操作是闭环的逆操作;在两种情况下,缩放系数都是简单的封闭形式可获得的。对于闭环
_m__j_ = _p__j_-1 / v,
其中 m 是市场价格,p 是公平概率,v 是所需的超赔。拟合是逆操作
_p__j_ = _m__j_-1 / v,其中 v 通过将隐含概率 _m_1-1 到 _m__M-1 求和得到。
当结果价格 > 1 时,乘法方法具有保持恒定利润率的特点,无论投注金额在提供的预期结果上的分布如何——每个结果都向玩家提供相同的回报。当该方法被天真地应用时,结果价格可能 ≤ 1 在高概率的赛跑者中。因此,必须限制其输出以确保价格大于 1。在这种情况下,受影响赛跑者的利润率会降低。
幂方法
该方法大致基于 Stephen Clarke 在 Adjusting Bookmaker’s Odds to Allow for Overround 中的描述。原始工作在体育博彩行业中的应用有限,适合具有相等概率结果的游戏,如轮盘赌。我们通过仅将 k 的计算作为初始估计,然后迭代优化 k 以最小化拟合和理想超赔之间的相对误差来增强 Clarke 的方法。
给定指数 k,市场价格通过 m = _p-_k 获得。初始估计的 k 为 _k̂0 = 1 + log(1/v) / log(N)。
使用拉格朗日乘数,可以证明当概率相等时,超赔达到最大值;任何偏离相等都会导致更低的超赔。因此,我们得出结论,初始搜索方向是减少 k。拟合过程也是一样:我们使用观察到的超赔来估计初始的 k 并迭代,最初朝着减少 k 的方向前进,直到概率之和为 1。
幂方法具有将利润率偏向(即,对低概率结果进行过度收费)的特点,实行了热门-冷门偏差。
赔率比方法
该方法基于张骞在《固定赔率投注与传统赔率》一文中的描述。它不是在传统概率的术语下操作,而是将概率转换为赔率,其中 _o__j_ = _p__j_ / (1 - _p__j_)。这种方法的核心在于确保在应用总赔率之后,每一对公平赔率和相应的市场赔率保持一个恒定的比率。_m__j_ = ((1 / _p__j_) - 1) / d + 1,其中 d 对所有 j 是一个常数。
使用优化器为具有任意数量结果的字段获取 d。在拟合过程中,反向进行同样的操作。
与功率方法类似,赔率比方法将边缘偏向低概率结果。据传,赔率比方法偶尔仍被博彩公司用于从获胜赔率中设置位置和每种方式的价格——通过强制总赔率。
蒙特卡洛引擎
Brumby 依赖于一个定制的 MC 引擎,该引擎利用池化内存缓冲区以避免昂贵的分配——《malloc》和《free》系统调用。它还使用一个定制的矩阵数据结构,将所有行压缩成一个连续的向量,从而避免内存稀疏性,并利用 CPU 级别缓存来更新和检索附近的数据点。这提供了一个表现良好的、无分配的 MC 模拟器,对一个 14 个位置的 4 位领奖台的试验需要 ~65 纳秒。
优化器
用于拟合和设置总赔率的优化器是一种单变量搜索形式,它以固定的步长沿着残差曲线下降,直到残差减小。相反,在残差增加的步骤之后,搜索方向反转,步长减半。当残差在可接受值范围内,或者步骤数或反转数耗尽时,搜索终止。这些参数,包括初始值和初始搜索方向,都是可配置的。
线性回归
Brumby 包含一个线性回归(LR)模型拟合器,也可以用作预测器。这使离线拟合可以直接在 Brumby 中进行,而不是将系数拟合委托给单独的统计软件包,如 R。我们的 LR 模型基于linregress,归功于慕尼黑工业大学计算系统医学组,但进行了一些重要的改进。其中之一是支持无截距模型的拟合。此外,我们还修改了此类情况中模型 R 平方的计算。简而言之,传统的 R 平方实际上是将参考模型与比较,该模型仅使用样本均值进行预测(即仅包含截距的线性模型),其中 总平方和(SST)项从样本值中减去均值,并平方差异。消除截距使得这种比较不太有意义。对于无截距模型,我们使用与 R 相同的计算——其中 SST 项是噪声——平方样本值。我们还修改了调整 R 平方计算中的自由度,以模仿 R 的方法。
模型拟合器可以在定义在 .r.json
文件中的回归器公式上操作。回归器公式在结构上类似于 R 的 lm
公式,但表示为抽象语法树。每个顶级节点对应线性求和的项。在最简单的情况下,顶级节点可能直接对应一个自变量或截距项。较低级别的节点用于将回归器项从多个自变量中组合而成。假设自变量 a 和 b,考虑以下示例。
公式 ~ a + b
[
{ "Variable": "a" },
{ "Variable": "b" },
"Intercept"
]
公式 ~ a + b + 0(没有截距)
[
{ "Variable": "a" },
{ "Variable": "b" },
"Origin"
]
公式 ~ _a_2 + b
[
{ "Exp": [{ "Variable": "a" }, 2] },
{ "Variable": "b" },
"Intercept"
]
公式 ~ _a_2 + _b_2 + ab
[
{ "Exp": [{ "Variable": "a" }, 2] },
{ "Exp": [{ "Variable": "b" }, 2] },
{ "Product": [{ "Variable": "a" }, { "Variable": "b" }] },
"Intercept"
]
必须提供一个常数项:要么是 Intercept
(截距),要么是 Origin
(原点)。这与 R 的公式不同,R 的公式默认启用截距。
训练
这里我们描述离线拟合过程。它包括三个步骤:1)提取合适的训练数据集,2)选择和拟合回归系数,3)通过测试集评估模型性能。这些步骤是解耦的。虽然 Brumby 有执行所有步骤的工具,但也可以使用统计软件包,如 R,来拟合系数。Brumby 的 linear.regression
模块与 R_'s lm
函数的行为相似。
数据集提取
使用 rac_datadump
二进制文件生成训练集。
通过迭代历史比赛数据快照,优化加权 MC 概率,直到导出的(单场)价格在某个误差范围内与快照价格匹配,从而生成训练集。Brumby 使用 racing-scraper 格式,该格式可以根据各种市场数据提供者进行调整。
对于每场比赛,权重将写入指定的 CSV 文件——每行一个参赛者。CSV 文件以标题行为开头——如果使用 R 分析数据,则必须删除标题行。
根据来源,历史数据可能包含重大的定价异常。许多来源不关心它们内部定价模型输出的对齐,为内部套利留下了充足的空间。模型连贯性差会导致价格与完成排名之间的关系出现异常;尝试将回归模型拟合到这样的数据将损害模型的泛化能力。
Brumby 的数据集提取器有一个基本的质量控制过滤器——使用 -c
标志激活的 出发截止。出发是快照 Place 价格与快照 Top-2/3 价格之间相对差异的度量,通过取价格对的绝对差异并将其除以两个价格中较大的一个得到。最坏情况下的出发是比赛中出发值中的最大值。在一个理想上连贯的模型中,这个值是零。出发截止标志丢弃所有最坏情况下的出发值高于设定值的比赛。
以下示例从存储在 ~/archive
的历史数据中提取了一个纯血马数据集,并将输出写入 data/thoroughbred.csv
。使用了 0.3 的出发截止过滤器。
just rac_datadump -d 0.3 -r thoroughbred ~/archive data/thoroughbred.csv
选择和拟合回归器
使用 rac_backfit
二进制文件将回归系数拟合到由 rac_datadump
生成的数据集。
该应用程序接受训练数据集和定义在 .r.json
文件中的回归器公式作为输入。有三个公式——每个非冠军排名的奖台都有一个。默认情况下,回归系数和摘要统计信息输出到控制台。输出格式再次类似于 R 的 summary
函数。输出到控制台让您可以进行试运行,而不必将系数保存到文件,也许可以尝试不同的回归器组合。
一旦选择了合适的回归变量,请使用-o
(输出)标志将回归变量及其对应的系数保存到.cf.json
文件中。
just rac_backfit data/thoroughbred.csv brumby-racing/config/thoroughbred.r.json -o brumby-racing/config/thoroughbred.cf.json
上述示例将拟合的系数持久化到brumby-racing/config/thoroughbred.cf.json
,并打印出包括标准误差、p值和R平方值的摘要统计数据,包括每个三个预测因子的值。以下是一个预测因子的输出样本。
╔═══════════════════════════════╤════════════╤═══════════╤═════════╤═════╗
║Regressor │ Coefficient│ Std. error│ P-value│ ║
╠═══════════════════════════════╪════════════╪═══════════╪═════════╪═════╣
║Variable(Weight0) │ 1.13191021│ 0.017757│ 0.000000│*** ║
╟───────────────────────────────┼────────────┼───────────┼─────────┼─────╢
║Exp(Variable(Weight0), 2) │ -3.49457066│ 0.110952│ 0.000000│*** ║
╟───────────────────────────────┼────────────┼───────────┼─────────┼─────╢
║Exp(Variable(Weight0), 3) │ 3.82793135│ 0.180875│ 0.000000│*** ║
╟───────────────────────────────┼────────────┼───────────┼─────────┼─────╢
║Variable(ActiveRunners) │ 0.02373357│ 0.000701│ 0.000000│*** ║
╟───────────────────────────────┼────────────┼───────────┼─────────┼─────╢
║Exp(Variable(ActiveRunners), 2)│ -0.00308830│ 0.000119│ 0.000000│*** ║
╟───────────────────────────────┼────────────┼───────────┼─────────┼─────╢
║Exp(Variable(ActiveRunners), 3)│ 0.00010562│ 0.000005│ 0.000000│*** ║
╟───────────────────────────────┼────────────┼───────────┼─────────┼─────╢
║Origin │ 0.00000000│ 0.000000│ 1.000000│ ║
╚═══════════════════════════════╧════════════╧═══════════╧═════════╧═════╝
r_squared: 0.991777
r_squared_adj: 0.991741
星号模仿R的显著性代码:0 ‘***’ 0.001 ‘**’ 0.01 ‘*’ 0.05 ‘.’ 0.1 ‘ ’ 1。
评估模型
使用rac_evaluate
二进制文件来评估模型对历史测试集的预测能力。
与rac_datadump
类似,rac_evaluate
还接受一个可选的出发截止标志来忽略不连贯的数据。结果是按分位数总结的一组排序的RMSRE(均方根相对误差)分数,为排名前25和后25的比赛提供更详细的统计数据。
just rac_evaluate -d 0.3 ~/archive
依赖关系
~12–25MB
~391K SLoC