2026_MLB_Model / models /matchup_model.py
Syntrex's picture
Audit-confirmed fixes: matchup confidence blend + platoon unknown handling
95e27f5
raw
history blame
15.8 kB
from __future__ import annotations
from typing import Any
def _safe_float(value: Any) -> float | None:
try:
if value is None:
return None
text = str(value).strip().lower()
if text in {"", "nan", "none"}:
return None
return float(value)
except Exception:
return None
def calculate_matchup_score(
batter_row: Any = None,
pitcher_profile: dict[str, Any] | None = None,
venue_name: str | None = None,
temperature_f: Any = None,
wind_speed_mph: Any = None,
pitcher_adj: dict[str, Any] | None = None,
zone_matchup_adj: dict[str, Any] | None = None,
**kwargs,
) -> dict[str, Any]:
pitcher_profile = pitcher_profile or {}
pitcher_adj = pitcher_adj or {}
zone_matchup_adj = zone_matchup_adj or {}
# If richer live-state pitcher adjustments are not passed, fall back to pitcher_profile.
if not pitcher_adj:
pitcher_adj = pitcher_profile
notes: list[str] = []
matchup_score = 50.0
# ----------------------------
# Batter quality
# ----------------------------
ev90 = _safe_float(getattr(batter_row, "get", lambda *_: None)("ev90"))
hard_hit_rate = _safe_float(getattr(batter_row, "get", lambda *_: None)("hard_hit_rate"))
barrel_rate = _safe_float(getattr(batter_row, "get", lambda *_: None)("barrel_rate"))
xwoba = _safe_float(getattr(batter_row, "get", lambda *_: None)("xwoba"))
if ev90 is not None:
ev_component = max(-8.0, min(10.0, (ev90 - 100.0) * 1.5))
matchup_score += ev_component
if ev_component >= 4:
notes.append("Strong EV90")
if hard_hit_rate is not None:
hh_component = max(-5.0, min(6.0, (hard_hit_rate - 0.40) * 20.0))
matchup_score += hh_component
if hh_component >= 2:
notes.append("Hard-hit edge")
if barrel_rate is not None:
barrel_component = max(-4.0, min(7.0, (barrel_rate - 0.07) * 45.0))
matchup_score += barrel_component
if barrel_component >= 2:
notes.append("Barrel upside")
if xwoba is not None:
xwoba_component = max(-5.0, min(7.0, (xwoba - 0.320) * 35.0))
matchup_score += xwoba_component
if xwoba_component >= 2:
notes.append("xwOBA support")
# ----------------------------
# Pitcher weakness / live weakness
# ----------------------------
fatigue_score = _safe_float(pitcher_adj.get("fatigue_score"))
degradation_score = _safe_float(pitcher_adj.get("degradation_score"))
velo_delta = _safe_float(pitcher_adj.get("velo_delta"))
spin_delta = _safe_float(pitcher_adj.get("spin_delta"))
extension_delta = _safe_float(pitcher_adj.get("extension_delta"))
pitch_count = _safe_float(pitcher_adj.get("pitch_count"))
times_through_order = _safe_float(pitcher_adj.get("times_through_order"))
trust_live_score = _safe_float(pitcher_adj.get("trust_live_score"))
# fallback to pitcher_profile if those fields live there instead
if fatigue_score is None:
fatigue_score = _safe_float(pitcher_profile.get("fatigue_score"))
if degradation_score is None:
degradation_score = _safe_float(pitcher_profile.get("degradation_score"))
if velo_delta is None:
velo_delta = _safe_float(pitcher_profile.get("velo_delta"))
if spin_delta is None:
spin_delta = _safe_float(pitcher_profile.get("spin_delta"))
if extension_delta is None:
extension_delta = _safe_float(pitcher_profile.get("extension_delta"))
if pitch_count is None:
pitch_count = _safe_float(pitcher_profile.get("pitch_count"))
if times_through_order is None:
times_through_order = _safe_float(pitcher_profile.get("times_through_order"))
if trust_live_score is None:
trust_live_score = _safe_float(pitcher_profile.get("trust_live_score"))
# older/static pitcher profile fields
ev_allowed = _safe_float(pitcher_profile.get("ev_allowed"))
hard_hit_rate_allowed = _safe_float(pitcher_profile.get("hard_hit_rate_allowed"))
barrel_rate_allowed = _safe_float(pitcher_profile.get("barrel_rate_allowed"))
hr_per_pa = _safe_float(pitcher_profile.get("hr_per_pa"))
xwoba_allowed = _safe_float(pitcher_profile.get("xwoba_allowed"))
if ev_allowed is not None:
ev_allowed_component = max(-4.0, min(6.0, (ev_allowed - 89.0) * 1.2))
matchup_score += ev_allowed_component
if ev_allowed_component >= 2:
notes.append("Pitcher EV allowed")
if hard_hit_rate_allowed is not None:
hh_allowed_component = max(-3.0, min(5.0, (hard_hit_rate_allowed - 0.38) * 16.0))
matchup_score += hh_allowed_component
if hh_allowed_component >= 1.5:
notes.append("Hard contact allowed")
if barrel_rate_allowed is not None:
barrel_allowed_component = max(-3.0, min(6.0, (barrel_rate_allowed - 0.07) * 35.0))
matchup_score += barrel_allowed_component
if barrel_allowed_component >= 1.5:
notes.append("Barrels allowed")
if hr_per_pa is not None:
hr_allowed_component = max(-3.0, min(6.0, (hr_per_pa - 0.04) * 60.0))
matchup_score += hr_allowed_component
if hr_allowed_component >= 1.5:
notes.append("HR-prone pitcher")
if xwoba_allowed is not None:
xwoba_allowed_component = max(-3.0, min(5.0, (xwoba_allowed - 0.315) * 28.0))
matchup_score += xwoba_allowed_component
if xwoba_allowed_component >= 1.5:
notes.append("xwOBA allowed")
if fatigue_score is not None:
fatigue_component = max(-2.0, min(8.0, fatigue_score * 8.0))
matchup_score += fatigue_component
if fatigue_component >= 2:
notes.append("Pitcher fatigue")
if degradation_score is not None:
degradation_component = max(-2.0, min(10.0, degradation_score * 10.0))
matchup_score += degradation_component
if degradation_component >= 2:
notes.append("Live degradation")
if velo_delta is not None:
velo_component = max(-2.0, min(6.0, (-velo_delta) * 3.0))
matchup_score += velo_component
if velo_component >= 1.5:
notes.append("Velo down")
if spin_delta is not None:
spin_component = max(-2.0, min(4.0, (-spin_delta) * 0.015))
matchup_score += spin_component
if spin_component >= 1.0:
notes.append("Spin down")
if extension_delta is not None:
extension_component = max(-1.5, min(3.0, (-extension_delta) * 2.0))
matchup_score += extension_component
if extension_component >= 1.0:
notes.append("Extension down")
if pitch_count is not None:
pitch_count_component = max(0.0, min(4.0, (pitch_count - 70.0) * 0.06))
matchup_score += pitch_count_component
if pitch_count_component >= 1.0:
notes.append("Elevated pitch count")
if times_through_order is not None:
tto_component = max(0.0, min(3.0, (times_through_order - 2.0) * 1.5))
matchup_score += tto_component
if tto_component >= 1.0:
notes.append("Times-through-order edge")
# ----------------------------
# Zone matchup
# ----------------------------
zone_hr_boost = _safe_float(zone_matchup_adj.get("hr_zone_boost"))
zone_hit_boost = _safe_float(zone_matchup_adj.get("hit_zone_boost"))
zone_tb2p_boost = _safe_float(zone_matchup_adj.get("tb2p_zone_boost"))
if zone_hr_boost is not None:
hr_zone_component = max(-3.0, min(8.0, zone_hr_boost * 35.0))
matchup_score += hr_zone_component
if hr_zone_component >= 1.5:
notes.append("HR zone fit")
if zone_hit_boost is not None:
hit_zone_component = max(-2.0, min(6.0, zone_hit_boost * 20.0))
matchup_score += hit_zone_component
if hit_zone_component >= 1.0:
notes.append("Hit zone fit")
if zone_tb2p_boost is not None:
tb_zone_component = max(-2.0, min(6.0, zone_tb2p_boost * 20.0))
matchup_score += tb_zone_component
if tb_zone_component >= 1.0:
notes.append("Power zone fit")
# ----------------------------
# Environment
# ----------------------------
temperature_f = _safe_float(temperature_f)
wind_speed_mph = _safe_float(wind_speed_mph)
if temperature_f is not None:
temp_component = max(-2.0, min(3.0, (temperature_f - 72.0) * 0.08))
matchup_score += temp_component
if temp_component >= 1.0:
notes.append("Warm weather")
if wind_speed_mph is not None:
wind_component = max(0.0, min(2.5, wind_speed_mph * 0.08))
matchup_score += wind_component
if wind_component >= 1.0:
notes.append("Wind support")
if venue_name:
venue_text = str(venue_name).strip()
if venue_text:
notes.append(f"Venue: {venue_text}")
if trust_live_score is not None and trust_live_score < 0.25:
notes.append("Low live-state confidence")
matchup_score = max(0.0, min(100.0, matchup_score))
if matchup_score >= 70:
matchup_rating = "Great"
elif matchup_score >= 58:
matchup_rating = "Good"
elif matchup_score >= 45:
matchup_rating = "Neutral"
else:
matchup_rating = "Poor"
return {
"matchup_score": matchup_score,
"matchup_rating": matchup_rating,
"matchup_notes": notes[:5],
}
def compute_zone_matchup_adjustment(
batter_zone_row: dict,
pitcher_zone_row: dict,
) -> dict[str, float]:
adjustment = {
"hr_zone_boost": 0.0,
"hit_zone_boost": 0.0,
"tb2p_zone_boost": 0.0,
}
pitch_families = ["fastball", "breaking", "offspeed"]
zones = ["heart", "shadow", "chase", "waste"]
total_weight = 0.0
for family in pitch_families:
family_usage_rate = pitcher_zone_row.get(f"{family}_usage_rate")
if family_usage_rate is None:
continue
try:
family_usage_rate = float(family_usage_rate)
except Exception:
continue
if family_usage_rate <= 0:
continue
for zone in zones:
zone_cond_rate = pitcher_zone_row.get(f"{family}_{zone}_cond_rate")
if zone_cond_rate is None:
continue
try:
zone_cond_rate = float(zone_cond_rate)
except Exception:
continue
if zone_cond_rate <= 0:
continue
weight = family_usage_rate * zone_cond_rate
if weight <= 0:
continue
hr_prob = batter_zone_row.get(f"hr_prob_{family}_{zone}")
hit_prob = batter_zone_row.get(f"hit_prob_{family}_{zone}")
tb2p_prob = batter_zone_row.get(f"tb2p_prob_{family}_{zone}")
if hr_prob is not None:
try:
adjustment["hr_zone_boost"] += weight * float(hr_prob)
except Exception:
pass
if hit_prob is not None:
try:
adjustment["hit_zone_boost"] += weight * float(hit_prob)
except Exception:
pass
if tb2p_prob is not None:
try:
adjustment["tb2p_zone_boost"] += weight * float(tb2p_prob)
except Exception:
pass
total_weight += weight
if total_weight > 0:
adjustment["hr_zone_boost"] /= total_weight
adjustment["hit_zone_boost"] /= total_weight
adjustment["tb2p_zone_boost"] /= total_weight
return adjustment
def compute_family_zone_matchup_adjustment(
batter_family_zone_row: dict,
pitcher_family_zone_row: dict,
) -> dict[str, float]:
adjustment = {
"family_zone_hr_boost": 0.0,
"family_zone_hit_boost": 0.0,
"family_zone_tb2p_boost": 0.0,
"family_zone_whiff_risk": 0.0,
}
pitch_families = ["fastball", "breaking", "offspeed"]
zones = ["heart", "shadow", "chase", "waste"]
total_weight = 0.0
for family in pitch_families:
for zone in zones:
usage_overall = _safe_float(
pitcher_family_zone_row.get(f"usage_rate_overall_{family}_{zone}")
)
if usage_overall is None or usage_overall <= 0:
continue
batter_hr_rate = _safe_float(
batter_family_zone_row.get(f"hr_rate_{family}_{zone}")
)
batter_hit_rate = _safe_float(
batter_family_zone_row.get(f"hit_rate_{family}_{zone}")
)
batter_damage_rate = _safe_float(
batter_family_zone_row.get(f"damage_rate_{family}_{zone}")
)
batter_whiff_rate = _safe_float(
batter_family_zone_row.get(f"whiff_rate_{family}_{zone}")
)
batter_xwoba = _safe_float(
batter_family_zone_row.get(f"xwoba_{family}_{zone}")
)
pitcher_hr_allowed = _safe_float(
pitcher_family_zone_row.get(f"hr_allowed_rate_{family}_{zone}")
)
pitcher_hit_allowed = _safe_float(
pitcher_family_zone_row.get(f"hit_allowed_rate_{family}_{zone}")
)
pitcher_damage_allowed = _safe_float(
pitcher_family_zone_row.get(f"damage_allowed_rate_{family}_{zone}")
)
pitcher_whiff_rate = _safe_float(
pitcher_family_zone_row.get(f"whiff_rate_{family}_{zone}")
)
pitcher_xwoba_allowed = _safe_float(
pitcher_family_zone_row.get(f"xwoba_allowed_{family}_{zone}")
)
hr_signal_parts = [x for x in [batter_hr_rate, pitcher_hr_allowed] if x is not None]
hit_signal_parts = [x for x in [batter_hit_rate, pitcher_hit_allowed] if x is not None]
damage_signal_parts = [x for x in [batter_damage_rate, pitcher_damage_allowed] if x is not None]
whiff_signal_parts = [x for x in [batter_whiff_rate, pitcher_whiff_rate] if x is not None]
xwoba_signal_parts = [x for x in [batter_xwoba, pitcher_xwoba_allowed] if x is not None]
if not hr_signal_parts and not hit_signal_parts and not damage_signal_parts:
continue
hr_signal = sum(hr_signal_parts) / len(hr_signal_parts) if hr_signal_parts else 0.0
hit_signal = sum(hit_signal_parts) / len(hit_signal_parts) if hit_signal_parts else 0.0
damage_signal = sum(damage_signal_parts) / len(damage_signal_parts) if damage_signal_parts else 0.0
whiff_signal = sum(whiff_signal_parts) / len(whiff_signal_parts) if whiff_signal_parts else 0.0
xwoba_signal = sum(xwoba_signal_parts) / len(xwoba_signal_parts) if xwoba_signal_parts else 0.0
tb_signal = damage_signal
if xwoba_signal > 0:
tb_signal += max(-0.05, min(0.10, (xwoba_signal - 0.320) * 0.50))
adjustment["family_zone_hr_boost"] += usage_overall * hr_signal
adjustment["family_zone_hit_boost"] += usage_overall * hit_signal
adjustment["family_zone_tb2p_boost"] += usage_overall * tb_signal
adjustment["family_zone_whiff_risk"] += usage_overall * whiff_signal
total_weight += usage_overall
if total_weight > 0:
adjustment["family_zone_hr_boost"] /= total_weight
adjustment["family_zone_hit_boost"] /= total_weight
adjustment["family_zone_tb2p_boost"] /= total_weight
adjustment["family_zone_whiff_risk"] /= total_weight
adjustment["sample_size"] = total_weight
return adjustment