Sessions 5-7a: 955 tests, deployment ready

This commit is contained in:
Kev
2026-06-08 18:35:13 -04:00
parent 06b82624a2
commit 1fa04dc776
371 changed files with 49366 additions and 955 deletions
+320
View File
@@ -0,0 +1,320 @@
"""
VYNDR Bayesian Distribution Engine
Shared by NBA and MLB. Per-stat-type weights. Similar game confidence modifier.
Skewness parameter. Data sufficiency smooth degradation curve.
"""
import numpy as np
import logging
logger = logging.getLogger('vyndr')
# INITIAL ESTIMATES — recalculate after 500+ resolved grades per stat type
# using grid search on historical Brier scores. Store optimized weights in global_calibration.
BAYESIAN_WEIGHTS = {
'strikeouts': {'prior': 0.40, 'recent': 0.40, 'context': 0.20},
'hits': {'prior': 0.30, 'recent': 0.45, 'context': 0.25},
'rbi': {'prior': 0.25, 'recent': 0.45, 'context': 0.30},
'home_runs': {'prior': 0.30, 'recent': 0.35, 'context': 0.35},
'total_bases': {'prior': 0.30, 'recent': 0.40, 'context': 0.30},
'walks': {'prior': 0.35, 'recent': 0.40, 'context': 0.25},
'points': {'prior': 0.35, 'recent': 0.45, 'context': 0.20},
'rebounds': {'prior': 0.40, 'recent': 0.40, 'context': 0.20},
'assists': {'prior': 0.30, 'recent': 0.50, 'context': 0.20},
'threes': {'prior': 0.35, 'recent': 0.45, 'context': 0.20},
'pts_reb_ast': {'prior': 0.35, 'recent': 0.45, 'context': 0.20},
'default': {'prior': 0.35, 'recent': 0.45, 'context': 0.20}
}
# Grade scale — LOCKED
GRADE_THRESHOLDS = {
'A+': (0.85, 1.00),
'A': (0.78, 0.84),
'A-': (0.72, 0.77),
'B+': (0.66, 0.71),
'B': (0.60, 0.65),
'B-': (0.55, 0.59),
'C+': (0.50, 0.54),
'C': (0.45, 0.49),
'C-': (0.40, 0.44),
'D': (0.30, 0.39),
'F': (0.00, 0.29)
}
ABSTENTION_RULES = {
'confidence_range': (0.40, 0.55),
'similar_games_below': 3,
'data_quality_limited': True
}
MIN_DATA_THRESHOLDS = {
'mlb_pitcher': {'min_starts': 3, 'min_pitches': 200},
'mlb_batter': {'min_pa': 50, 'min_games': 12},
'nba_player': {'min_games': 8, 'min_minutes_per_game': 15}
}
CALIBRATION_DISCLAIMER = (
"Model in calibration period. Confidence levels are estimated, not validated. "
"Track record begins building now."
)
SHADOW_MODE = True # Set to False after 2 weeks of verified accuracy
def norm_cdf(x, mean, std):
"""
Standard normal CDF using error function.
Args:
x: Value to evaluate.
mean: Distribution mean.
std: Distribution standard deviation.
Returns:
Cumulative probability P(X <= x).
"""
if std <= 0:
return 1.0 if x <= mean else 0.0
z = (x - mean) / std
return 0.5 * (1 + float(np.erf(z / np.sqrt(2))))
def similar_game_confidence_modifier(count):
"""
Adjust confidence based on historical similar game depth.
Args:
count: Number of similar games found.
Returns:
Float adjustment to confidence (positive = boost, negative = penalty).
"""
if count >= 10:
return 0.05
elif count >= 5:
return 0.02
elif count <= 1:
return -0.03
return 0.0
def calculate_bayesian_projection(prior_mean, prior_std, recent_mean, recent_std,
context_adjustment, line, over_under,
stat_type='default', similar_game_count=0):
"""
Produce a posterior distribution for a stat projection.
Uses per-stat-type Bayesian weights to blend prior (season baseline),
recent (last N games), and context (matchup/park/weather adjustments).
Args:
prior_mean: Season average for the stat.
prior_std: Season standard deviation.
recent_mean: Recent game average (last N games).
recent_std: Recent game standard deviation.
context_adjustment: Aggregate contextual adjustment value.
line: Prop line to evaluate against.
over_under: 'over' or 'under'.
stat_type: Stat type key for weight lookup (default='default').
similar_game_count: Number of similar historical games found.
Returns:
Dict with projected_value, projected_std, prob_clear_line, confidence,
similar_game_modifier, bayesian_weights_used, and distribution details.
"""
weights = BAYESIAN_WEIGHTS.get(stat_type, BAYESIAN_WEIGHTS['default'])
w_prior = weights['prior']
w_recent = weights['recent']
w_context = weights['context']
posterior_mean = (
prior_mean * w_prior +
recent_mean * w_recent +
(prior_mean + context_adjustment) * w_context
)
posterior_std = np.sqrt(
(prior_std ** 2 * w_prior + recent_std ** 2 * w_recent) /
(w_prior + w_recent)
)
# Ensure std is positive
posterior_std = max(posterior_std, 0.01)
if over_under == 'over':
prob = 1 - norm_cdf(line, posterior_mean, posterior_std)
else:
prob = norm_cdf(line, posterior_mean, posterior_std)
# Similar game confidence modifier
sim_modifier = similar_game_confidence_modifier(similar_game_count)
prob = max(0.0, min(1.0, prob + sim_modifier))
return {
'projected_value': round(float(posterior_mean), 1),
'projected_std': round(float(posterior_std), 2),
'prob_clear_line': round(float(prob), 3),
'confidence': round(float(prob), 3),
'similar_game_modifier': sim_modifier,
'bayesian_weights_used': weights,
'distribution': {
'mean': float(posterior_mean),
'std': float(posterior_std),
'p10': round(float(posterior_mean - 1.28 * posterior_std), 1),
'p90': round(float(posterior_mean + 1.28 * posterior_std), 1)
}
}
def calculate_skewness(game_log_values):
"""
Measure skew of a player's performance distribution.
Positive skew = occasional blowup games (favors alt line overs).
Negative skew = consistent, capped upside (favors standard line overs).
Args:
game_log_values: List of numeric stat values from game log.
Returns:
Float skewness value. Returns 0.0 if insufficient data (<10 games).
"""
if len(game_log_values) < 10:
return 0.0
try:
from scipy.stats import skew
return round(float(skew(game_log_values)), 2)
except ImportError:
# Manual skewness calculation as fallback
arr = np.array(game_log_values, dtype=float)
n = len(arr)
mean = np.mean(arr)
std = np.std(arr, ddof=1)
if std == 0:
return 0.0
return round(float((n / ((n - 1) * (n - 2))) * np.sum(((arr - mean) / std) ** 3)), 2)
def apply_data_sufficiency_modifier(confidence, games_played, min_games):
"""
Smooth confidence degradation near minimum threshold.
No hard cliff at min_games — gradual ramp from 70% to 100% of confidence.
Full confidence at 2x minimum games.
Args:
confidence: Raw confidence score.
games_played: Number of games the player has played this season.
min_games: Minimum games required for full confidence.
Returns:
Adjusted confidence score.
"""
if games_played < min_games:
return min(confidence, 0.54) # Below minimum = C+ cap
ramp = min(1.0, 0.70 + 0.30 * ((games_played - min_games) / max(min_games, 1)))
return confidence * ramp
def should_abstain(confidence, similar_game_count, data_quality):
"""
Determine if the model should abstain from grading.
A C grade that misses damages credibility more than no grade at all.
Args:
confidence: Calculated confidence score.
similar_game_count: Number of similar historical games found.
data_quality: 'full', 'limited', or 'minimal'.
Returns:
True if model should abstain, False if grade should be published.
"""
low, high = ABSTENTION_RULES['confidence_range']
if low <= confidence <= high and similar_game_count < ABSTENTION_RULES['similar_games_below']:
return True
if data_quality == 'limited' and confidence < 0.55:
return True
return False
def score_to_grade(score, global_offset=0.0):
"""
Map a confidence score to a letter grade.
Args:
score: Raw confidence score (0.0 to 1.0).
global_offset: Calibration adjustment from grade_outcomes analysis.
Applied BEFORE grade mapping. Starts at 0.0, updated monthly
after 100+ resolved grades.
Returns:
Grade string (A+ through F).
"""
adjusted_score = max(0.0, min(1.0, score + global_offset))
for grade, (low, high) in GRADE_THRESHOLDS.items():
if low <= adjusted_score <= high:
return grade
return 'F'
def calculate_global_offset(resolved_outcomes, min_resolved=100):
"""
Calculate global calibration offset from resolved grade outcomes.
Clamped to ±0.15 to prevent overcorrection.
Args:
resolved_outcomes: List of dicts with 'confidence' and 'hit' keys.
min_resolved: Minimum resolved grades before calculating offset.
Returns:
Float offset value, clamped between -0.15 and 0.15.
"""
if len(resolved_outcomes) < min_resolved:
return 0.0
grade_accuracy = {}
for grade_name, (low, high) in GRADE_THRESHOLDS.items():
grade_outcomes = [o for o in resolved_outcomes if low <= o['confidence'] <= high]
if len(grade_outcomes) >= 10:
hit_rate = sum(1 for o in grade_outcomes if o['hit']) / len(grade_outcomes)
expected_midpoint = (low + high) / 2
grade_accuracy[grade_name] = hit_rate - expected_midpoint
if not grade_accuracy:
return 0.0
avg_drift = sum(grade_accuracy.values()) / len(grade_accuracy)
return max(-0.15, min(0.15, avg_drift))
def calculate_brier_score(resolved_grades):
"""
Brier score = mean((predicted_probability - actual_outcome)^2).
Lower is better. 0.0 = perfect. 0.25 = coin flip.
Args:
resolved_grades: List of dicts with 'confidence' and 'hit' keys.
Returns:
Float Brier score, or None if no data.
"""
if not resolved_grades:
return None
total = sum(
(g['confidence'] - (1.0 if g['hit'] else 0.0)) ** 2
for g in resolved_grades
)
return round(total / len(resolved_grades), 4)
def get_disclaimer(resolved_count):
"""
Return calibration disclaimer if model is still in calibration period.
Args:
resolved_count: Number of resolved grades for the sport.
Returns:
Disclaimer string, or None if past calibration period.
"""
if resolved_count < 100:
return CALIBRATION_DISCLAIMER
return None