2026_MLB_Model / models /zone_matchup_model.py
Syntrex's picture
Promote strikeout v2 and harden telemetry models
50dc123
raw
history blame
4.36 kB
from __future__ import annotations
from typing import Any
def _safe_float(value: Any, default: float | None = None) -> float | None:
try:
if value is None:
return default
text = str(value).strip().lower()
if text in {"", "nan", "none"}:
return default
return float(value)
except Exception:
return default
def _clamp(value: float, lo: float, hi: float) -> float:
return max(lo, min(hi, value))
def _reliability(sample_size: Any, k: float = 120.0) -> float:
sample = max(0.0, float(_safe_float(sample_size, 0.0) or 0.0))
return _clamp(sample / (sample + max(1.0, k)), 0.0, 1.0)
def compute_zone_matchup_adjustment(
batter_zone_row: dict[str, Any],
pitcher_zone_row: dict[str, Any],
*,
pitch_zone_weights: dict[str, float] | None = None,
handedness_context: dict[str, Any] | None = None,
) -> dict[str, float]:
adjustment = {
"hr_zone_boost": 0.0,
"hit_zone_boost": 0.0,
"tb2p_zone_boost": 0.0,
"damage_zone_boost": 0.0,
"whiff_zone_risk": 0.0,
}
pitch_families = ["fastball", "breaking", "offspeed"]
zones = ["heart", "shadow", "chase", "waste"]
total_weight = 0.0
weighted_samples = 0.0
context_weight_used = False
handedness_context = handedness_context or {}
handedness_bonus = 1.0
if str(handedness_context.get("batter_stand") or "").strip().upper() == str(handedness_context.get("pitcher_hand") or "").strip().upper():
handedness_bonus = 0.98
elif handedness_context:
handedness_bonus = 1.02
for family in pitch_families:
for zone in zones:
base_attack_rate = _safe_float(pitcher_zone_row.get(f"{family}_{zone}_rate"))
context_attack_rate = _safe_float((pitch_zone_weights or {}).get(f"{family}_{zone}"))
sample_size = _safe_float(pitcher_zone_row.get(f"sample_size_{family}_{zone}"), 0.0) or 0.0
sample_reliability = _reliability(sample_size, k=80.0)
if context_attack_rate is not None:
context_weight_used = True
if base_attack_rate is None:
attack_rate = context_attack_rate
else:
attack_rate = (context_attack_rate * 0.72) + (base_attack_rate * 0.28 * sample_reliability)
else:
attack_rate = base_attack_rate
if attack_rate is None or attack_rate <= 0:
continue
attack_rate *= handedness_bonus
hr_prob = _safe_float(batter_zone_row.get(f"hr_prob_{family}_{zone}"))
hit_prob = _safe_float(batter_zone_row.get(f"hit_prob_{family}_{zone}"))
tb2p_prob = _safe_float(batter_zone_row.get(f"tb2p_prob_{family}_{zone}"))
whiff_prob = _safe_float(batter_zone_row.get(f"whiff_prob_{family}_{zone}"))
damage_prob = _safe_float(batter_zone_row.get(f"damage_prob_{family}_{zone}"))
batter_sample = _safe_float(batter_zone_row.get(f"sample_size_{family}_{zone}"), 0.0) or 0.0
batter_reliability = _reliability(batter_sample, k=70.0)
combined_reliability = sample_reliability * batter_reliability
if hr_prob is not None:
adjustment["hr_zone_boost"] += attack_rate * hr_prob * combined_reliability
if hit_prob is not None:
adjustment["hit_zone_boost"] += attack_rate * hit_prob * combined_reliability
if tb2p_prob is not None:
adjustment["tb2p_zone_boost"] += attack_rate * tb2p_prob * combined_reliability
if damage_prob is not None:
adjustment["damage_zone_boost"] += attack_rate * damage_prob * combined_reliability
if whiff_prob is not None:
adjustment["whiff_zone_risk"] += attack_rate * whiff_prob * combined_reliability
total_weight += attack_rate
weighted_samples += sample_size
if total_weight > 0:
for k in adjustment:
adjustment[k] = adjustment[k] / total_weight
adjustment["sample_size"] = weighted_samples if weighted_samples > 0 else total_weight
adjustment["reliability"] = round(_reliability(weighted_samples, k=320.0), 4)
adjustment["context_weight_used"] = context_weight_used
return adjustment