Files
vyndr/src/services/bayesianEngine.js
T

150 lines
4.0 KiB
JavaScript

const DISTRIBUTION_SHAPES = {
points: 'normal',
rebounds: 'normal',
assists: 'normal',
home_runs: 'negative_binomial',
stolen_bases: 'negative_binomial',
pitcher_strikeouts: 'bimodal_mixture',
walks: 'poisson',
hits: 'normal',
total_bases: 'normal',
rbis: 'normal',
runs_scored: 'poisson',
strikeouts_batter: 'poisson',
earned_runs: 'poisson',
outs_recorded: 'normal',
walks_allowed: 'poisson',
hits_allowed: 'normal',
pitches_thrown: 'normal',
};
/**
* Get the distribution shape for a stat type.
* @param {string} statType
* @returns {string} Distribution shape name
*/
function getDistributionShape(statType) {
return DISTRIBUTION_SHAPES[statType] || 'normal';
}
/**
* Normal CDF using rational approximation.
*/
function normalCDF(x, mean, stddev) {
if (stddev <= 0) return x >= mean ? 1 : 0;
const z = (x - mean) / stddev;
const t = 1 / (1 + 0.2316419 * Math.abs(z));
const d = 0.3989422804014327;
const p = d * Math.exp(-z * z / 2) *
(t * (0.3193815 + t * (-0.3565638 + t * (1.781478 + t * (-1.8212560 + t * 1.3302744)))));
return z > 0 ? 1 - p : p;
}
/**
* Poisson CDF: P(X <= x) for Poisson(lambda).
* @param {number} x - Value (floored to integer)
* @param {number} lambda - Rate parameter
* @returns {number} Cumulative probability
*/
function poissonCDF(x, lambda) {
if (lambda <= 0) return 1;
const k = Math.floor(x);
if (k < 0) return 0;
let cdf = 0;
let term = Math.exp(-lambda);
cdf += term;
for (let i = 1; i <= k; i++) {
term *= lambda / i;
cdf += term;
}
return Math.min(1, cdf);
}
/**
* Negative Binomial CDF: P(X <= x) for NB(r, p).
* Uses direct summation of PMF.
* @param {number} x - Value (floored to integer)
* @param {number} r - Number of successes
* @param {number} p - Probability of success per trial
* @returns {number} Cumulative probability
*/
function negativeBinomialCDF(x, r, p) {
if (r <= 0 || p <= 0 || p > 1) return 0;
const k = Math.floor(x);
if (k < 0) return 0;
let cdf = 0;
// log of binomial coefficient using lgamma approximation
function logGamma(z) {
// Stirling approximation for lgamma
if (z < 0.5) return Math.log(Math.PI / Math.sin(Math.PI * z)) - logGamma(1 - z);
z -= 1;
const coeffs = [
76.18009172947146, -86.50532032941677, 24.01409824083091,
-1.231739572450155, 0.001208650973866179, -0.000005395239384953,
];
let x = 0.99999999999980993;
for (let i = 0; i < coeffs.length; i++) {
x += coeffs[i] / (z + i + 1);
}
const t = z + coeffs.length - 0.5;
return 0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(t) - t + Math.log(x);
}
for (let i = 0; i <= k; i++) {
const logCoeff = logGamma(i + r) - logGamma(i + 1) - logGamma(r);
const logProb = logCoeff + r * Math.log(p) + i * Math.log(1 - p);
cdf += Math.exp(logProb);
}
return Math.min(1, Math.max(0, cdf));
}
/**
* Calculate probability based on distribution shape.
* @param {string} shape - Distribution type
* @param {object} params - Distribution parameters
* @param {number} line - Prop line
* @param {string} direction - 'over' or 'under'
* @returns {number} Probability 0-1
*/
function calculateProbability(shape, params, line, direction) {
let cdf;
switch (shape) {
case 'normal':
cdf = normalCDF(line, params.mean, params.stddev);
break;
case 'poisson':
cdf = poissonCDF(line, params.lambda);
break;
case 'negative_binomial':
cdf = negativeBinomialCDF(line, params.r, params.p);
break;
case 'bimodal_mixture':
// Weighted mixture of two normals
const w1 = params.weight1 || 0.5;
const cdf1 = normalCDF(line, params.mean1, params.stddev1);
const cdf2 = normalCDF(line, params.mean2, params.stddev2);
cdf = w1 * cdf1 + (1 - w1) * cdf2;
break;
default:
cdf = normalCDF(line, params.mean, params.stddev);
}
return direction === 'over' ? 1 - cdf : cdf;
}
module.exports = {
DISTRIBUTION_SHAPES,
getDistributionShape,
calculateProbability,
normalCDF,
poissonCDF,
negativeBinomialCDF,
};