Spaces:
Running
Running
Add props confidence breakdown diagnostics
Browse files- analytics/props_mapper.py +191 -3
- models/hr_probability_engine.py +51 -0
- models/shared_matchup_engine.py +267 -0
- models/strikeout_probability_engine.py +48 -2
- models/strikeout_probability_engine_v2.py +363 -0
- tests/test_props_mapper.py +56 -0
- tests/test_shared_matchup_engine.py +17 -0
- tests/test_strikeout_probability_engine_v2.py +19 -0
- visualization/debug_page.py +253 -0
- visualization/props_page.py +131 -2
analytics/props_mapper.py
CHANGED
|
@@ -29,6 +29,14 @@ def build_strikeout_probability_result(*args, **kwargs):
|
|
| 29 |
return _build_strikeout_probability_result(*args, **kwargs)
|
| 30 |
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
def _build_statcast_name_index(statcast_df: pd.DataFrame) -> dict[str, str]:
|
| 33 |
if statcast_df.empty or "player_name" not in statcast_df.columns:
|
| 34 |
return {}
|
|
@@ -105,6 +113,98 @@ def _compute_verdict(
|
|
| 105 |
return "pass"
|
| 106 |
|
| 107 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
def _classify_hr_probability_status(
|
| 109 |
*,
|
| 110 |
threshold_int: int,
|
|
@@ -691,6 +791,7 @@ def map_hr_props_to_model(
|
|
| 691 |
"calibrated_hr_prob": probability_result.get("calibrated_hr_prob"),
|
| 692 |
"pregame_hr_prob": probability_result.get("pregame_hr_prob"),
|
| 693 |
"probability_mode": probability_result.get("mode"),
|
|
|
|
| 694 |
"is_modeled": is_modeled,
|
| 695 |
"threshold": threshold_int,
|
| 696 |
"confidence_score": probability_result.get("confidence_score"),
|
|
@@ -721,6 +822,23 @@ def map_hr_props_to_model(
|
|
| 721 |
"platoon_hr_adjustment": probability_result.get("platoon_hr_adjustment"),
|
| 722 |
"trajectory_hr_adjustment": probability_result.get("trajectory_hr_adjustment"),
|
| 723 |
"rolling_hr_adjustment": probability_result.get("rolling_hr_adjustment"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 724 |
"pitcher_reliability": probability_result.get("pitcher_reliability"),
|
| 725 |
"trend_reliability": probability_result.get("trend_reliability"),
|
| 726 |
"zone_reliability": probability_result.get("zone_reliability"),
|
|
@@ -850,6 +968,20 @@ def map_strikeout_props_to_model(
|
|
| 850 |
selection_side=selection_side,
|
| 851 |
game_row=_build_game_context_from_row(row),
|
| 852 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 853 |
|
| 854 |
fair_prob = probability_result.get("fair_prob")
|
| 855 |
if fair_prob is not None and implied is not None:
|
|
@@ -873,10 +1005,66 @@ def map_strikeout_props_to_model(
|
|
| 873 |
"model_k_prob": fair_prob,
|
| 874 |
"bet_ev": bet_ev,
|
| 875 |
"edge": edge,
|
| 876 |
-
"confidence_score":
|
| 877 |
-
"confidence_bucket":
|
| 878 |
-
"confidence_reasons":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 879 |
"expected_strikeouts": probability_result.get("expected_strikeouts"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 880 |
"pitcher_swstr_rate": probability_result.get("pitcher_swstr_rate"),
|
| 881 |
"pitcher_csw_rate": probability_result.get("pitcher_csw_rate"),
|
| 882 |
"pitcher_ball_rate": probability_result.get("pitcher_ball_rate"),
|
|
|
|
| 29 |
return _build_strikeout_probability_result(*args, **kwargs)
|
| 30 |
|
| 31 |
|
| 32 |
+
def build_strikeout_probability_result_v2(*args, **kwargs):
|
| 33 |
+
from models.strikeout_probability_engine_v2 import (
|
| 34 |
+
build_strikeout_probability_result_v2 as _build_strikeout_probability_result_v2,
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
return _build_strikeout_probability_result_v2(*args, **kwargs)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
def _build_statcast_name_index(statcast_df: pd.DataFrame) -> dict[str, str]:
|
| 41 |
if statcast_df.empty or "player_name" not in statcast_df.columns:
|
| 42 |
return {}
|
|
|
|
| 113 |
return "pass"
|
| 114 |
|
| 115 |
|
| 116 |
+
def _confidence_display_remap(raw_score: float | None) -> float | None:
|
| 117 |
+
try:
|
| 118 |
+
raw = float(raw_score)
|
| 119 |
+
except Exception:
|
| 120 |
+
return None
|
| 121 |
+
if raw <= 40.0:
|
| 122 |
+
return max(1.0, min(100.0, raw))
|
| 123 |
+
return max(1.0, min(100.0, 40.0 + ((raw - 40.0) * 1.45)))
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _normalize_confidence_components(value: Any) -> list[dict[str, Any]]:
|
| 127 |
+
if not isinstance(value, list):
|
| 128 |
+
return []
|
| 129 |
+
normalized: list[dict[str, Any]] = []
|
| 130 |
+
for item in value:
|
| 131 |
+
if not isinstance(item, dict):
|
| 132 |
+
continue
|
| 133 |
+
label = str(item.get("label") or "").strip()
|
| 134 |
+
if not label:
|
| 135 |
+
continue
|
| 136 |
+
try:
|
| 137 |
+
component_value = float(item.get("value") or 0.0)
|
| 138 |
+
except Exception:
|
| 139 |
+
component_value = 0.0
|
| 140 |
+
normalized.append(
|
| 141 |
+
{
|
| 142 |
+
"label": label,
|
| 143 |
+
"value": round(component_value, 1),
|
| 144 |
+
"direction": str(item.get("direction") or "").strip().lower() or None,
|
| 145 |
+
}
|
| 146 |
+
)
|
| 147 |
+
return normalized
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _select_confidence_primary_driver(
|
| 151 |
+
penalties: list[dict[str, Any]],
|
| 152 |
+
bonuses: list[dict[str, Any]],
|
| 153 |
+
) -> dict[str, Any] | None:
|
| 154 |
+
penalty_candidates = [item for item in penalties if float(item.get("value") or 0.0) > 0.0]
|
| 155 |
+
bonus_candidates = [item for item in bonuses if float(item.get("value") or 0.0) > 0.0]
|
| 156 |
+
if penalty_candidates:
|
| 157 |
+
return max(penalty_candidates, key=lambda item: float(item.get("value") or 0.0))
|
| 158 |
+
if bonus_candidates:
|
| 159 |
+
return max(bonus_candidates, key=lambda item: float(item.get("value") or 0.0))
|
| 160 |
+
return None
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _build_strikeout_confidence_payload(
|
| 164 |
+
probability_result: dict[str, Any],
|
| 165 |
+
probability_result_v2: dict[str, Any],
|
| 166 |
+
) -> dict[str, Any]:
|
| 167 |
+
source = "strikeout_v2_shadow" if probability_result_v2.get("confidence_score_v2") is not None else "strikeout_v1_live"
|
| 168 |
+
if source == "strikeout_v2_shadow":
|
| 169 |
+
raw_score = probability_result_v2.get("confidence_score_raw_v2", probability_result_v2.get("confidence_score_v2"))
|
| 170 |
+
raw_bucket = probability_result_v2.get("confidence_bucket_v2")
|
| 171 |
+
reasons = list(probability_result_v2.get("confidence_reasons_v2") or [])
|
| 172 |
+
bonuses = _normalize_confidence_components(probability_result_v2.get("confidence_component_bonuses_v2"))
|
| 173 |
+
penalties = _normalize_confidence_components(probability_result_v2.get("confidence_component_penalties_v2"))
|
| 174 |
+
else:
|
| 175 |
+
raw_score = probability_result.get("confidence_score_raw", probability_result.get("confidence_score"))
|
| 176 |
+
raw_bucket = probability_result.get("confidence_bucket")
|
| 177 |
+
reasons = list(probability_result.get("confidence_reasons") or [])
|
| 178 |
+
bonuses = _normalize_confidence_components(probability_result.get("confidence_component_bonuses"))
|
| 179 |
+
penalties = _normalize_confidence_components(probability_result.get("confidence_component_penalties"))
|
| 180 |
+
|
| 181 |
+
raw_score_float = float(raw_score) if raw_score is not None else None
|
| 182 |
+
display_score = _confidence_display_remap(raw_score_float)
|
| 183 |
+
display_bucket = None
|
| 184 |
+
if display_score is not None:
|
| 185 |
+
if display_score >= 75:
|
| 186 |
+
display_bucket = "high"
|
| 187 |
+
elif display_score >= 55:
|
| 188 |
+
display_bucket = "medium"
|
| 189 |
+
else:
|
| 190 |
+
display_bucket = "low"
|
| 191 |
+
primary_driver = _select_confidence_primary_driver(penalties, bonuses)
|
| 192 |
+
summary_label = str((primary_driver or {}).get("label") or "").strip() or None
|
| 193 |
+
|
| 194 |
+
return {
|
| 195 |
+
"confidence_score_raw": round(raw_score_float, 1) if raw_score_float is not None else None,
|
| 196 |
+
"confidence_score_display": round(display_score, 1) if display_score is not None else None,
|
| 197 |
+
"confidence_source": source,
|
| 198 |
+
"confidence_component_bonuses": bonuses,
|
| 199 |
+
"confidence_component_penalties": penalties,
|
| 200 |
+
"confidence_primary_driver": primary_driver,
|
| 201 |
+
"confidence_summary_label": summary_label,
|
| 202 |
+
"confidence_bucket_raw": raw_bucket,
|
| 203 |
+
"confidence_bucket_display": display_bucket,
|
| 204 |
+
"confidence_reasons": reasons[:5],
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
|
| 208 |
def _classify_hr_probability_status(
|
| 209 |
*,
|
| 210 |
threshold_int: int,
|
|
|
|
| 791 |
"calibrated_hr_prob": probability_result.get("calibrated_hr_prob"),
|
| 792 |
"pregame_hr_prob": probability_result.get("pregame_hr_prob"),
|
| 793 |
"probability_mode": probability_result.get("mode"),
|
| 794 |
+
"formula_version": probability_result.get("formula_version"),
|
| 795 |
"is_modeled": is_modeled,
|
| 796 |
"threshold": threshold_int,
|
| 797 |
"confidence_score": probability_result.get("confidence_score"),
|
|
|
|
| 822 |
"platoon_hr_adjustment": probability_result.get("platoon_hr_adjustment"),
|
| 823 |
"trajectory_hr_adjustment": probability_result.get("trajectory_hr_adjustment"),
|
| 824 |
"rolling_hr_adjustment": probability_result.get("rolling_hr_adjustment"),
|
| 825 |
+
"damage_zone_alignment_subscore": probability_result.get("damage_zone_alignment_subscore"),
|
| 826 |
+
"pitch_mix_exposure_subscore": probability_result.get("pitch_mix_exposure_subscore"),
|
| 827 |
+
"tunnel_damage_subscore": probability_result.get("tunnel_damage_subscore"),
|
| 828 |
+
"count_pattern_damage_subscore": probability_result.get("count_pattern_damage_subscore"),
|
| 829 |
+
"handedness_damage_subscore": probability_result.get("handedness_damage_subscore"),
|
| 830 |
+
"arsenal_fit_subscore": probability_result.get("arsenal_fit_subscore"),
|
| 831 |
+
"environment_amplification_subscore": probability_result.get("environment_amplification_subscore"),
|
| 832 |
+
"hr_opportunity_projection": probability_result.get("hr_opportunity_projection"),
|
| 833 |
+
"matchup_coverage_confidence": probability_result.get("matchup_coverage_confidence"),
|
| 834 |
+
"component_source_map": probability_result.get("component_source_map"),
|
| 835 |
+
"expected_pitch_mix_by_count": probability_result.get("expected_pitch_mix_by_count"),
|
| 836 |
+
"expected_zone_mix_by_count": probability_result.get("expected_zone_mix_by_count"),
|
| 837 |
+
"expected_pitch_zone_mix_by_count": probability_result.get("expected_pitch_zone_mix_by_count"),
|
| 838 |
+
"tunnel_pair_scores": probability_result.get("tunnel_pair_scores"),
|
| 839 |
+
"predicted_attack_regions": probability_result.get("predicted_attack_regions"),
|
| 840 |
+
"predicted_damage_regions": probability_result.get("predicted_damage_regions"),
|
| 841 |
+
"predicted_whiff_regions": probability_result.get("predicted_whiff_regions"),
|
| 842 |
"pitcher_reliability": probability_result.get("pitcher_reliability"),
|
| 843 |
"trend_reliability": probability_result.get("trend_reliability"),
|
| 844 |
"zone_reliability": probability_result.get("zone_reliability"),
|
|
|
|
| 968 |
selection_side=selection_side,
|
| 969 |
game_row=_build_game_context_from_row(row),
|
| 970 |
)
|
| 971 |
+
probability_result_v2 = build_strikeout_probability_result_v2(
|
| 972 |
+
pitcher_statcast_df=pitcher_df,
|
| 973 |
+
pitcher_name=pitcher_name,
|
| 974 |
+
batter_statcast_df=batter_statcast_df,
|
| 975 |
+
opponent_batters=opponent_batters,
|
| 976 |
+
opponent_team=opponent_team,
|
| 977 |
+
line=float(line) if line is not None and str(line).strip() not in {"", "nan", "None"} else None,
|
| 978 |
+
selection_side=selection_side,
|
| 979 |
+
game_row=_build_game_context_from_row(row),
|
| 980 |
+
)
|
| 981 |
+
confidence_payload = _build_strikeout_confidence_payload(
|
| 982 |
+
probability_result=probability_result,
|
| 983 |
+
probability_result_v2=probability_result_v2,
|
| 984 |
+
)
|
| 985 |
|
| 986 |
fair_prob = probability_result.get("fair_prob")
|
| 987 |
if fair_prob is not None and implied is not None:
|
|
|
|
| 1005 |
"model_k_prob": fair_prob,
|
| 1006 |
"bet_ev": bet_ev,
|
| 1007 |
"edge": edge,
|
| 1008 |
+
"confidence_score": confidence_payload.get("confidence_score_display"),
|
| 1009 |
+
"confidence_bucket": confidence_payload.get("confidence_bucket_display"),
|
| 1010 |
+
"confidence_reasons": confidence_payload.get("confidence_reasons"),
|
| 1011 |
+
"confidence_score_raw": confidence_payload.get("confidence_score_raw"),
|
| 1012 |
+
"confidence_score_display": confidence_payload.get("confidence_score_display"),
|
| 1013 |
+
"confidence_source": confidence_payload.get("confidence_source"),
|
| 1014 |
+
"confidence_component_bonuses": confidence_payload.get("confidence_component_bonuses"),
|
| 1015 |
+
"confidence_component_penalties": confidence_payload.get("confidence_component_penalties"),
|
| 1016 |
+
"confidence_primary_driver": confidence_payload.get("confidence_primary_driver"),
|
| 1017 |
+
"confidence_summary_label": confidence_payload.get("confidence_summary_label"),
|
| 1018 |
+
"confidence_bucket_raw": confidence_payload.get("confidence_bucket_raw"),
|
| 1019 |
+
"confidence_bucket_display": confidence_payload.get("confidence_bucket_display"),
|
| 1020 |
+
"confidence_score_v1": probability_result.get("confidence_score"),
|
| 1021 |
+
"confidence_bucket_v1": probability_result.get("confidence_bucket"),
|
| 1022 |
+
"confidence_score_raw_v1": probability_result.get("confidence_score_raw", probability_result.get("confidence_score")),
|
| 1023 |
"expected_strikeouts": probability_result.get("expected_strikeouts"),
|
| 1024 |
+
"expected_strikeouts_v2": probability_result_v2.get("expected_strikeouts_v2"),
|
| 1025 |
+
"projected_pitch_count": probability_result_v2.get("projected_pitch_count"),
|
| 1026 |
+
"projected_batters_faced": probability_result_v2.get("projected_batters_faced"),
|
| 1027 |
+
"projected_innings": probability_result_v2.get("projected_innings"),
|
| 1028 |
+
"projected_k_rate": probability_result_v2.get("projected_k_rate"),
|
| 1029 |
+
"fair_prob_v2": probability_result_v2.get("fair_prob_v2"),
|
| 1030 |
+
"raw_k_prob_v2": probability_result_v2.get("raw_k_prob_v2"),
|
| 1031 |
+
"calibrated_k_prob_v2": probability_result_v2.get("calibrated_k_prob_v2"),
|
| 1032 |
+
"confidence_score_v2": probability_result_v2.get("confidence_score_v2"),
|
| 1033 |
+
"confidence_score_raw_v2": probability_result_v2.get("confidence_score_raw_v2"),
|
| 1034 |
+
"confidence_score_display_v2": probability_result_v2.get("confidence_score_display_v2"),
|
| 1035 |
+
"confidence_source_v2": probability_result_v2.get("confidence_source_v2"),
|
| 1036 |
+
"confidence_bucket_v2": probability_result_v2.get("confidence_bucket_v2"),
|
| 1037 |
+
"confidence_reasons_v2": probability_result_v2.get("confidence_reasons_v2"),
|
| 1038 |
+
"confidence_component_bonuses_v2": probability_result_v2.get("confidence_component_bonuses_v2"),
|
| 1039 |
+
"confidence_component_penalties_v2": probability_result_v2.get("confidence_component_penalties_v2"),
|
| 1040 |
+
"confidence_primary_driver_v2": probability_result_v2.get("confidence_primary_driver_v2"),
|
| 1041 |
+
"confidence_summary_label_v2": probability_result_v2.get("confidence_summary_label_v2"),
|
| 1042 |
+
"k_rate_pitch_signal": probability_result_v2.get("k_rate_pitch_signal"),
|
| 1043 |
+
"k_rate_anchor": probability_result_v2.get("k_rate_anchor"),
|
| 1044 |
+
"bb_rate_anchor": probability_result_v2.get("bb_rate_anchor"),
|
| 1045 |
+
"command_efficiency_signal": probability_result_v2.get("command_efficiency_signal"),
|
| 1046 |
+
"swing_miss_subscore": probability_result_v2.get("swing_miss_subscore"),
|
| 1047 |
+
"called_strike_subscore": probability_result_v2.get("called_strike_subscore"),
|
| 1048 |
+
"command_efficiency_subscore": probability_result_v2.get("command_efficiency_subscore"),
|
| 1049 |
+
"lineup_whiff_subscore": probability_result_v2.get("lineup_whiff_subscore"),
|
| 1050 |
+
"zone_matchup_subscore": probability_result_v2.get("zone_matchup_subscore"),
|
| 1051 |
+
"family_zone_matchup_subscore": probability_result_v2.get("family_zone_matchup_subscore"),
|
| 1052 |
+
"arsenal_fit_subscore": probability_result_v2.get("arsenal_fit_subscore"),
|
| 1053 |
+
"tunneling_subscore": probability_result_v2.get("tunneling_subscore"),
|
| 1054 |
+
"release_consistency_subscore": probability_result_v2.get("release_consistency_subscore"),
|
| 1055 |
+
"sequencing_subscore": probability_result_v2.get("sequencing_subscore"),
|
| 1056 |
+
"count_leverage_subscore": probability_result_v2.get("count_leverage_subscore"),
|
| 1057 |
+
"leash_risk_subscore": probability_result_v2.get("leash_risk_subscore"),
|
| 1058 |
+
"times_through_order_penalty": probability_result_v2.get("times_through_order_penalty"),
|
| 1059 |
+
"variance_band_low": probability_result_v2.get("variance_band_low"),
|
| 1060 |
+
"variance_band_high": probability_result_v2.get("variance_band_high"),
|
| 1061 |
+
"matchup_coverage_confidence": probability_result_v2.get("matchup_coverage_confidence"),
|
| 1062 |
+
"component_source_map": probability_result_v2.get("component_source_map"),
|
| 1063 |
+
"predicted_whiff_regions": probability_result_v2.get("predicted_whiff_regions"),
|
| 1064 |
+
"predicted_attack_regions": probability_result_v2.get("predicted_attack_regions"),
|
| 1065 |
+
"predicted_damage_regions": probability_result_v2.get("predicted_damage_regions"),
|
| 1066 |
+
"tunnel_pair_scores": probability_result_v2.get("tunnel_pair_scores"),
|
| 1067 |
+
"formula_version": probability_result_v2.get("formula_version"),
|
| 1068 |
"pitcher_swstr_rate": probability_result.get("pitcher_swstr_rate"),
|
| 1069 |
"pitcher_csw_rate": probability_result.get("pitcher_csw_rate"),
|
| 1070 |
"pitcher_ball_rate": probability_result.get("pitcher_ball_rate"),
|
models/hr_probability_engine.py
CHANGED
|
@@ -14,6 +14,7 @@ from models.rolling_form_model import (
|
|
| 14 |
build_pitcher_rolling_form_row,
|
| 15 |
compute_upcoming_rolling_adjustment,
|
| 16 |
)
|
|
|
|
| 17 |
from models.trajectory_model import build_trajectory_features, compute_trajectory_adjustment
|
| 18 |
|
| 19 |
|
|
@@ -46,6 +47,7 @@ def _empty_result(player_name: str, mode: str) -> dict[str, Any]:
|
|
| 46 |
"player_name": player_name,
|
| 47 |
"pitcher_name": "",
|
| 48 |
"mode": mode,
|
|
|
|
| 49 |
"baseline_hr_prob": None,
|
| 50 |
"adjusted_hr_prob": None,
|
| 51 |
"raw_hr_prob": None,
|
|
@@ -103,6 +105,23 @@ def _empty_result(player_name: str, mode: str) -> dict[str, Any]:
|
|
| 103 |
"trajectory_reliability": 0.0,
|
| 104 |
"rolling_reliability": 0.0,
|
| 105 |
"opportunity_reliability": 0.0,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
"model_voice_reason_candidates": [],
|
| 107 |
"model_voice_tags": [],
|
| 108 |
"reason_candidate_count": 0,
|
|
@@ -451,6 +470,24 @@ def build_hr_probability_result(
|
|
| 451 |
_sample_reliability(batter_pa, 180.0),
|
| 452 |
_sample_reliability(pitcher_row.get("sample_size"), 180.0),
|
| 453 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
zone_eff = 0.0
|
| 455 |
batter_zone_row: dict[str, Any] = {}
|
| 456 |
pitcher_zone_row: dict[str, Any] = {}
|
|
@@ -515,6 +552,7 @@ def build_hr_probability_result(
|
|
| 515 |
"missing_pitcher_zone_profile" if int(_safe_float(pitcher_zone_row.get("zone_sample_size"), 0.0) or 0.0) <= 0 else
|
| 516 |
"available_zero_effect"
|
| 517 |
)
|
|
|
|
| 518 |
result["family_zone_status"] = (
|
| 519 |
"applied" if abs(result["family_zone_hr_adjustment"]) > 1e-6 else
|
| 520 |
"missing_batter_family_zone_profile" if int(_safe_float(batter_family_zone_row.get("family_zone_sample_size"), 0.0) or 0.0) <= 0 else
|
|
@@ -573,6 +611,15 @@ def build_hr_probability_result(
|
|
| 573 |
"missing_pitcher_arsenal_profile" if int(_safe_float(pitcher_arsenal_row.get("arsenal_sample_size"), 0.0) or 0.0) <= 0 else
|
| 574 |
"available_zero_effect"
|
| 575 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 576 |
hr_prob = _clamp(hr_prob + result["arsenal_hr_adjustment"], 0.005, 0.25)
|
| 577 |
if abs(result["arsenal_hr_adjustment"]) > 1e-6:
|
| 578 |
applied_layers.append("arsenal")
|
|
@@ -585,6 +632,7 @@ def build_hr_probability_result(
|
|
| 585 |
)
|
| 586 |
|
| 587 |
result["platoon_hr_adjustment"] = platoon_adj
|
|
|
|
| 588 |
hr_prob = _clamp(hr_prob + platoon_adj, 0.005, 0.25)
|
| 589 |
if abs(platoon_adj) > 1e-6:
|
| 590 |
applied_layers.append("platoon")
|
|
@@ -659,6 +707,7 @@ def build_hr_probability_result(
|
|
| 659 |
_safe_float(traj_adj.get("hr_adj")),
|
| 660 |
result["trajectory_reliability"],
|
| 661 |
)
|
|
|
|
| 662 |
hr_prob = _clamp(hr_prob + result["trajectory_hr_adjustment"], 0.005, 0.25)
|
| 663 |
if abs(result["trajectory_hr_adjustment"]) > 1e-6:
|
| 664 |
applied_layers.append("trajectory")
|
|
@@ -731,6 +780,7 @@ def build_hr_probability_result(
|
|
| 731 |
result["pa_multiplier"] = opportunity.get("pa_multiplier")
|
| 732 |
result["opportunity_mode"] = opportunity.get("opportunity_mode")
|
| 733 |
result["opportunity_reason"] = opportunity.get("opportunity_reason")
|
|
|
|
| 734 |
if lineup_slot is not None and team_total is not None:
|
| 735 |
result["opportunity_reliability"] = 1.0 if result["lineup_slot_source"] == "confirmed" else 0.82
|
| 736 |
elif lineup_slot is not None:
|
|
@@ -765,6 +815,7 @@ def build_hr_probability_result(
|
|
| 765 |
raw_prob=hr_prob,
|
| 766 |
baseline_prob=result.get("baseline_hr_prob"),
|
| 767 |
)
|
|
|
|
| 768 |
if mode == "pregame":
|
| 769 |
result["pregame_hr_prob"] = result["calibrated_hr_prob"]
|
| 770 |
else:
|
|
|
|
| 14 |
build_pitcher_rolling_form_row,
|
| 15 |
compute_upcoming_rolling_adjustment,
|
| 16 |
)
|
| 17 |
+
from models.shared_matchup_engine import compose_shared_matchup_context
|
| 18 |
from models.trajectory_model import build_trajectory_features, compute_trajectory_adjustment
|
| 19 |
|
| 20 |
|
|
|
|
| 47 |
"player_name": player_name,
|
| 48 |
"pitcher_name": "",
|
| 49 |
"mode": mode,
|
| 50 |
+
"formula_version": "hr_v1_shared_matchup",
|
| 51 |
"baseline_hr_prob": None,
|
| 52 |
"adjusted_hr_prob": None,
|
| 53 |
"raw_hr_prob": None,
|
|
|
|
| 105 |
"trajectory_reliability": 0.0,
|
| 106 |
"rolling_reliability": 0.0,
|
| 107 |
"opportunity_reliability": 0.0,
|
| 108 |
+
"damage_zone_alignment_subscore": None,
|
| 109 |
+
"pitch_mix_exposure_subscore": None,
|
| 110 |
+
"tunnel_damage_subscore": None,
|
| 111 |
+
"count_pattern_damage_subscore": None,
|
| 112 |
+
"handedness_damage_subscore": None,
|
| 113 |
+
"arsenal_fit_subscore": None,
|
| 114 |
+
"environment_amplification_subscore": None,
|
| 115 |
+
"hr_opportunity_projection": None,
|
| 116 |
+
"matchup_coverage_confidence": None,
|
| 117 |
+
"component_source_map": {},
|
| 118 |
+
"expected_pitch_mix_by_count": {},
|
| 119 |
+
"expected_zone_mix_by_count": {},
|
| 120 |
+
"expected_pitch_zone_mix_by_count": {},
|
| 121 |
+
"tunnel_pair_scores": [],
|
| 122 |
+
"predicted_attack_regions": [],
|
| 123 |
+
"predicted_damage_regions": [],
|
| 124 |
+
"predicted_whiff_regions": [],
|
| 125 |
"model_voice_reason_candidates": [],
|
| 126 |
"model_voice_tags": [],
|
| 127 |
"reason_candidate_count": 0,
|
|
|
|
| 470 |
_sample_reliability(batter_pa, 180.0),
|
| 471 |
_sample_reliability(pitcher_row.get("sample_size"), 180.0),
|
| 472 |
)
|
| 473 |
+
shared_matchup = compose_shared_matchup_context(
|
| 474 |
+
batter_name=batter_name,
|
| 475 |
+
pitcher_name=result["pitcher_name"],
|
| 476 |
+
batter_statcast_df=batter_df,
|
| 477 |
+
pitcher_statcast_df=pitcher_df,
|
| 478 |
+
batter_features=batter_features,
|
| 479 |
+
pitcher_row=pitcher_row,
|
| 480 |
+
game_row=game_row,
|
| 481 |
+
)
|
| 482 |
+
result["expected_pitch_mix_by_count"] = shared_matchup.get("expected_pitch_mix_by_count") or {}
|
| 483 |
+
result["expected_zone_mix_by_count"] = shared_matchup.get("expected_zone_mix_by_count") or {}
|
| 484 |
+
result["expected_pitch_zone_mix_by_count"] = shared_matchup.get("expected_pitch_zone_mix_by_count") or {}
|
| 485 |
+
result["tunnel_pair_scores"] = shared_matchup.get("tunnel_pair_scores") or []
|
| 486 |
+
result["predicted_attack_regions"] = shared_matchup.get("predicted_attack_regions") or []
|
| 487 |
+
result["predicted_damage_regions"] = shared_matchup.get("predicted_damage_regions") or []
|
| 488 |
+
result["predicted_whiff_regions"] = shared_matchup.get("predicted_whiff_regions") or []
|
| 489 |
+
result["matchup_coverage_confidence"] = shared_matchup.get("matchup_coverage_confidence")
|
| 490 |
+
result["component_source_map"] = shared_matchup.get("component_source_map") or {}
|
| 491 |
zone_eff = 0.0
|
| 492 |
batter_zone_row: dict[str, Any] = {}
|
| 493 |
pitcher_zone_row: dict[str, Any] = {}
|
|
|
|
| 552 |
"missing_pitcher_zone_profile" if int(_safe_float(pitcher_zone_row.get("zone_sample_size"), 0.0) or 0.0) <= 0 else
|
| 553 |
"available_zero_effect"
|
| 554 |
)
|
| 555 |
+
result["damage_zone_alignment_subscore"] = round(_safe_float(zone_matchup_adj.get("hr_zone_boost"), 0.0), 4) if "zone_matchup_adj" in locals() else 0.0
|
| 556 |
result["family_zone_status"] = (
|
| 557 |
"applied" if abs(result["family_zone_hr_adjustment"]) > 1e-6 else
|
| 558 |
"missing_batter_family_zone_profile" if int(_safe_float(batter_family_zone_row.get("family_zone_sample_size"), 0.0) or 0.0) <= 0 else
|
|
|
|
| 611 |
"missing_pitcher_arsenal_profile" if int(_safe_float(pitcher_arsenal_row.get("arsenal_sample_size"), 0.0) or 0.0) <= 0 else
|
| 612 |
"available_zero_effect"
|
| 613 |
)
|
| 614 |
+
result["pitch_mix_exposure_subscore"] = round(
|
| 615 |
+
_safe_float(shared_matchup.get("arsenal_matchup", {}).get("arsenal_hr_boost"), 0.0),
|
| 616 |
+
4,
|
| 617 |
+
)
|
| 618 |
+
result["count_pattern_damage_subscore"] = round(
|
| 619 |
+
sum(float(item.get("score") or 0.0) for item in (result["predicted_damage_regions"] or [])[:3]),
|
| 620 |
+
4,
|
| 621 |
+
)
|
| 622 |
+
result["arsenal_fit_subscore"] = round(_safe_float(arsenal_matchup_adj.get("arsenal_hr_boost"), 0.0), 4) if "arsenal_matchup_adj" in locals() else 0.0
|
| 623 |
hr_prob = _clamp(hr_prob + result["arsenal_hr_adjustment"], 0.005, 0.25)
|
| 624 |
if abs(result["arsenal_hr_adjustment"]) > 1e-6:
|
| 625 |
applied_layers.append("arsenal")
|
|
|
|
| 632 |
)
|
| 633 |
|
| 634 |
result["platoon_hr_adjustment"] = platoon_adj
|
| 635 |
+
result["handedness_damage_subscore"] = round(_safe_float(platoon_adj, 0.0), 4)
|
| 636 |
hr_prob = _clamp(hr_prob + platoon_adj, 0.005, 0.25)
|
| 637 |
if abs(platoon_adj) > 1e-6:
|
| 638 |
applied_layers.append("platoon")
|
|
|
|
| 707 |
_safe_float(traj_adj.get("hr_adj")),
|
| 708 |
result["trajectory_reliability"],
|
| 709 |
)
|
| 710 |
+
result["tunnel_damage_subscore"] = round(_safe_float(trajectory_row.get("tunnel_score"), 0.0), 4)
|
| 711 |
hr_prob = _clamp(hr_prob + result["trajectory_hr_adjustment"], 0.005, 0.25)
|
| 712 |
if abs(result["trajectory_hr_adjustment"]) > 1e-6:
|
| 713 |
applied_layers.append("trajectory")
|
|
|
|
| 780 |
result["pa_multiplier"] = opportunity.get("pa_multiplier")
|
| 781 |
result["opportunity_mode"] = opportunity.get("opportunity_mode")
|
| 782 |
result["opportunity_reason"] = opportunity.get("opportunity_reason")
|
| 783 |
+
result["hr_opportunity_projection"] = round(_safe_float(opportunity.get("expected_pa"), 0.0), 3)
|
| 784 |
if lineup_slot is not None and team_total is not None:
|
| 785 |
result["opportunity_reliability"] = 1.0 if result["lineup_slot_source"] == "confirmed" else 0.82
|
| 786 |
elif lineup_slot is not None:
|
|
|
|
| 815 |
raw_prob=hr_prob,
|
| 816 |
baseline_prob=result.get("baseline_hr_prob"),
|
| 817 |
)
|
| 818 |
+
result["environment_amplification_subscore"] = round(_safe_float(result["env_hr_adjustment"], 0.0), 4)
|
| 819 |
if mode == "pregame":
|
| 820 |
result["pregame_hr_prob"] = result["calibrated_hr_prob"]
|
| 821 |
else:
|
models/shared_matchup_engine.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
import pandas as pd
|
| 6 |
+
|
| 7 |
+
from models.arsenal_matchup_model import compute_arsenal_matchup_adjustment
|
| 8 |
+
from models.batter_arsenal_model import build_batter_arsenal_feature_row
|
| 9 |
+
from models.batter_zone_model import build_batter_zone_feature_row
|
| 10 |
+
from models.family_zone_profile_store import (
|
| 11 |
+
build_batter_family_zone_feature_row,
|
| 12 |
+
build_pitcher_family_zone_feature_row,
|
| 13 |
+
)
|
| 14 |
+
from models.matchup_model import compute_family_zone_matchup_adjustment
|
| 15 |
+
from models.pitch_sequence_model import build_sequence_features, predict_next_pitch_distribution
|
| 16 |
+
from models.pitcher_arsenal_model import build_pitcher_arsenal_feature_row
|
| 17 |
+
from models.pitcher_zone_model import build_pitcher_zone_feature_row
|
| 18 |
+
from models.trajectory_model import build_trajectory_features
|
| 19 |
+
from models.zone_matchup_model import compute_zone_matchup_adjustment
|
| 20 |
+
|
| 21 |
+
_COUNT_STATES: tuple[tuple[int, int], ...] = (
|
| 22 |
+
(0, 0),
|
| 23 |
+
(1, 0),
|
| 24 |
+
(0, 1),
|
| 25 |
+
(1, 1),
|
| 26 |
+
(0, 2),
|
| 27 |
+
(1, 2),
|
| 28 |
+
(2, 2),
|
| 29 |
+
(3, 2),
|
| 30 |
+
)
|
| 31 |
+
_PITCH_FAMILIES: tuple[str, ...] = ("fastball", "breaking", "offspeed")
|
| 32 |
+
_ZONES: tuple[str, ...] = ("heart", "shadow", "chase", "waste")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _safe_float(value: Any, default: float = 0.0) -> float:
|
| 36 |
+
try:
|
| 37 |
+
if value is None:
|
| 38 |
+
return default
|
| 39 |
+
text = str(value).strip().lower()
|
| 40 |
+
if text in {"", "nan", "none"}:
|
| 41 |
+
return default
|
| 42 |
+
return float(value)
|
| 43 |
+
except Exception:
|
| 44 |
+
return default
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _clamp(value: float, lo: float, hi: float) -> float:
|
| 48 |
+
return max(lo, min(hi, value))
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _reliability(sample_size: Any, k: float) -> float:
|
| 52 |
+
sample = max(0.0, _safe_float(sample_size, 0.0))
|
| 53 |
+
return _clamp(sample / (sample + max(1.0, float(k))), 0.0, 1.0)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _component_source_map() -> dict[str, dict[str, str]]:
|
| 57 |
+
return {
|
| 58 |
+
"zone_matchup": {"classification": "reuse_as_is", "source_module": "models.zone_matchup_model"},
|
| 59 |
+
"family_zone_matchup": {"classification": "reuse_as_is", "source_module": "models.matchup_model"},
|
| 60 |
+
"arsenal_matchup": {"classification": "reuse_as_is", "source_module": "models.arsenal_matchup_model"},
|
| 61 |
+
"trajectory": {"classification": "reuse_as_is", "source_module": "models.trajectory_model"},
|
| 62 |
+
"sequencing": {"classification": "upgrade_existing_module", "source_module": "models.pitch_sequence_model"},
|
| 63 |
+
"count_context": {"classification": "upgrade_existing_module", "source_module": "models.pitch_sequence_model"},
|
| 64 |
+
"shared_composer": {"classification": "new_source_of_truth_component", "source_module": "models.shared_matchup_engine"},
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _build_pitch_zone_mix(
|
| 69 |
+
sequence_profiles: dict[str, dict[str, Any]],
|
| 70 |
+
) -> dict[str, float]:
|
| 71 |
+
combined: dict[str, float] = {}
|
| 72 |
+
if not sequence_profiles:
|
| 73 |
+
return combined
|
| 74 |
+
|
| 75 |
+
count_weight = 1.0 / float(len(sequence_profiles))
|
| 76 |
+
for payload in sequence_profiles.values():
|
| 77 |
+
fb = _safe_float(payload.get("fastball_prob"))
|
| 78 |
+
br = _safe_float(payload.get("breaking_prob"))
|
| 79 |
+
os = _safe_float(payload.get("offspeed_prob"))
|
| 80 |
+
zone_probs = payload.get("zone_probs", {}) or {}
|
| 81 |
+
family_probs = {
|
| 82 |
+
"fastball": fb,
|
| 83 |
+
"breaking": br,
|
| 84 |
+
"offspeed": os,
|
| 85 |
+
}
|
| 86 |
+
for family, family_prob in family_probs.items():
|
| 87 |
+
for zone in _ZONES:
|
| 88 |
+
zone_prob = _safe_float(zone_probs.get(zone))
|
| 89 |
+
combined[f"{family}_{zone}"] = combined.get(f"{family}_{zone}", 0.0) + (
|
| 90 |
+
family_prob * zone_prob * count_weight
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
total = sum(combined.values())
|
| 94 |
+
if total > 0:
|
| 95 |
+
for key in list(combined.keys()):
|
| 96 |
+
combined[key] = combined[key] / total
|
| 97 |
+
return combined
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _top_regions(weighted_map: dict[str, float], limit: int = 4) -> list[dict[str, Any]]:
|
| 101 |
+
rows = [
|
| 102 |
+
{"region": key, "score": round(float(val), 6)}
|
| 103 |
+
for key, val in weighted_map.items()
|
| 104 |
+
if float(val) > 0
|
| 105 |
+
]
|
| 106 |
+
rows.sort(key=lambda item: item["score"], reverse=True)
|
| 107 |
+
return rows[:limit]
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def compose_shared_matchup_context(
|
| 111 |
+
*,
|
| 112 |
+
batter_name: str,
|
| 113 |
+
pitcher_name: str,
|
| 114 |
+
batter_statcast_df: pd.DataFrame | None,
|
| 115 |
+
pitcher_statcast_df: pd.DataFrame | None,
|
| 116 |
+
batter_features: dict[str, Any] | None = None,
|
| 117 |
+
pitcher_row: dict[str, Any] | None = None,
|
| 118 |
+
game_row: dict[str, Any] | None = None,
|
| 119 |
+
) -> dict[str, Any]:
|
| 120 |
+
empty = {
|
| 121 |
+
"expected_pitch_mix_by_count": {},
|
| 122 |
+
"expected_zone_mix_by_count": {},
|
| 123 |
+
"expected_pitch_zone_mix_by_count": {},
|
| 124 |
+
"tunnel_pair_scores": [],
|
| 125 |
+
"predicted_attack_regions": [],
|
| 126 |
+
"predicted_damage_regions": [],
|
| 127 |
+
"predicted_whiff_regions": [],
|
| 128 |
+
"handedness_context": {},
|
| 129 |
+
"count_context_profile": {},
|
| 130 |
+
"matchup_coverage_confidence": 0.0,
|
| 131 |
+
"component_source_map": _component_source_map(),
|
| 132 |
+
"zone_matchup": {},
|
| 133 |
+
"family_zone_matchup": {},
|
| 134 |
+
"arsenal_matchup": {},
|
| 135 |
+
"trajectory": {},
|
| 136 |
+
"sequence_profiles": {},
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
batter_df = batter_statcast_df if batter_statcast_df is not None else pd.DataFrame()
|
| 140 |
+
pitcher_df = pitcher_statcast_df if pitcher_statcast_df is not None else batter_df
|
| 141 |
+
if batter_df.empty or pitcher_df.empty or not batter_name or not pitcher_name:
|
| 142 |
+
return empty
|
| 143 |
+
|
| 144 |
+
batter_features = dict(batter_features or {})
|
| 145 |
+
pitcher_row = dict(pitcher_row or {})
|
| 146 |
+
game_row = dict(game_row or {})
|
| 147 |
+
|
| 148 |
+
batter_zone_row = build_batter_zone_feature_row(batter_df, batter_name)
|
| 149 |
+
pitcher_zone_row = build_pitcher_zone_feature_row(pitcher_df, pitcher_name)
|
| 150 |
+
zone_matchup = compute_zone_matchup_adjustment(batter_zone_row, pitcher_zone_row)
|
| 151 |
+
|
| 152 |
+
batter_family_zone_row = build_batter_family_zone_feature_row(batter_df, batter_name)
|
| 153 |
+
pitcher_family_zone_row = build_pitcher_family_zone_feature_row(pitcher_df, pitcher_name)
|
| 154 |
+
family_zone_matchup = compute_family_zone_matchup_adjustment(
|
| 155 |
+
batter_family_zone_row=batter_family_zone_row,
|
| 156 |
+
pitcher_family_zone_row=pitcher_family_zone_row,
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
batter_arsenal_row = build_batter_arsenal_feature_row(batter_df, batter_name)
|
| 160 |
+
pitcher_arsenal_row = build_pitcher_arsenal_feature_row(pitcher_df, pitcher_name)
|
| 161 |
+
arsenal_matchup = compute_arsenal_matchup_adjustment(
|
| 162 |
+
batter_arsenal_row=batter_arsenal_row,
|
| 163 |
+
pitcher_arsenal_row=pitcher_arsenal_row,
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
trajectory = build_trajectory_features(
|
| 167 |
+
statcast_df=pitcher_df,
|
| 168 |
+
pitcher_name=pitcher_name,
|
| 169 |
+
pitcher_id=game_row.get("pitcher_id"),
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
handedness_context = {
|
| 173 |
+
"batter_stand": str(batter_features.get("batter_stand", "") or "").strip().upper(),
|
| 174 |
+
"pitcher_hand": str(pitcher_row.get("p_throws", "") or "").strip().upper(),
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
count_context_profile: dict[str, dict[str, Any]] = {}
|
| 178 |
+
expected_pitch_mix_by_count: dict[str, dict[str, float]] = {}
|
| 179 |
+
expected_zone_mix_by_count: dict[str, dict[str, float]] = {}
|
| 180 |
+
sequence_profiles: dict[str, dict[str, Any]] = {}
|
| 181 |
+
for balls, strikes in _COUNT_STATES:
|
| 182 |
+
count_key = f"{balls}-{strikes}"
|
| 183 |
+
seq_features = build_sequence_features(
|
| 184 |
+
game_row={**game_row, "balls": balls, "strikes": strikes},
|
| 185 |
+
pitcher_row=pitcher_row,
|
| 186 |
+
batter_row=batter_features,
|
| 187 |
+
pitcher_family_zone_row=pitcher_family_zone_row,
|
| 188 |
+
)
|
| 189 |
+
seq_profile = predict_next_pitch_distribution(seq_features)
|
| 190 |
+
sequence_profiles[count_key] = seq_profile
|
| 191 |
+
expected_pitch_mix_by_count[count_key] = {
|
| 192 |
+
family: round(_safe_float(seq_profile.get(f"{family}_prob")), 6)
|
| 193 |
+
for family in _PITCH_FAMILIES
|
| 194 |
+
}
|
| 195 |
+
expected_zone_mix_by_count[count_key] = {
|
| 196 |
+
zone: round(_safe_float((seq_profile.get("zone_probs") or {}).get(zone)), 6)
|
| 197 |
+
for zone in _ZONES
|
| 198 |
+
}
|
| 199 |
+
leverage = "neutral"
|
| 200 |
+
if strikes >= 2:
|
| 201 |
+
leverage = "putaway"
|
| 202 |
+
elif balls >= 2:
|
| 203 |
+
leverage = "hitter_ahead"
|
| 204 |
+
count_context_profile[count_key] = {
|
| 205 |
+
"balls": balls,
|
| 206 |
+
"strikes": strikes,
|
| 207 |
+
"count_leverage": leverage,
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
expected_pitch_zone_mix_by_count = _build_pitch_zone_mix(sequence_profiles)
|
| 211 |
+
|
| 212 |
+
attack_regions = _top_regions(expected_pitch_zone_mix_by_count, limit=5)
|
| 213 |
+
|
| 214 |
+
damage_region_map: dict[str, float] = {}
|
| 215 |
+
whiff_region_map: dict[str, float] = {}
|
| 216 |
+
for key, attack_weight in expected_pitch_zone_mix_by_count.items():
|
| 217 |
+
try:
|
| 218 |
+
family, zone = key.split("_", 1)
|
| 219 |
+
except ValueError:
|
| 220 |
+
continue
|
| 221 |
+
batter_damage = _safe_float(batter_family_zone_row.get(f"damage_rate_{family}_{zone}"))
|
| 222 |
+
pitcher_damage = _safe_float(pitcher_family_zone_row.get(f"damage_allowed_rate_{family}_{zone}"))
|
| 223 |
+
batter_whiff = _safe_float(batter_family_zone_row.get(f"whiff_rate_{family}_{zone}"))
|
| 224 |
+
pitcher_whiff = _safe_float(pitcher_family_zone_row.get(f"whiff_rate_{family}_{zone}"))
|
| 225 |
+
damage_region_map[key] = attack_weight * ((batter_damage * 0.6) + (pitcher_damage * 0.4))
|
| 226 |
+
whiff_region_map[key] = attack_weight * ((batter_whiff * 0.55) + (pitcher_whiff * 0.45))
|
| 227 |
+
|
| 228 |
+
tunnel_score = _safe_float(trajectory.get("tunnel_score"))
|
| 229 |
+
release_score = _safe_float(trajectory.get("release_consistency_score"))
|
| 230 |
+
tunnel_pair_scores = [
|
| 231 |
+
{
|
| 232 |
+
"pair": "arsenal_tunnel_profile",
|
| 233 |
+
"tunnel_score": round(tunnel_score, 6),
|
| 234 |
+
"release_consistency_score": round(release_score, 6),
|
| 235 |
+
"deception_score": round(_safe_float(trajectory.get("deception_score")), 6),
|
| 236 |
+
}
|
| 237 |
+
] if (tunnel_score or release_score) else []
|
| 238 |
+
|
| 239 |
+
coverage_signals = [
|
| 240 |
+
_reliability(batter_zone_row.get("zone_sample_size"), 200.0),
|
| 241 |
+
_reliability(pitcher_zone_row.get("zone_sample_size"), 200.0),
|
| 242 |
+
_reliability(batter_family_zone_row.get("family_zone_sample_size"), 220.0),
|
| 243 |
+
_reliability(pitcher_family_zone_row.get("family_zone_sample_size"), 220.0),
|
| 244 |
+
_reliability(batter_arsenal_row.get("arsenal_sample_size"), 180.0),
|
| 245 |
+
_reliability(pitcher_arsenal_row.get("arsenal_sample_size"), 180.0),
|
| 246 |
+
_reliability(trajectory.get("trajectory_sample_size"), 240.0),
|
| 247 |
+
]
|
| 248 |
+
matchup_coverage_confidence = round(sum(coverage_signals) / len(coverage_signals), 4)
|
| 249 |
+
|
| 250 |
+
return {
|
| 251 |
+
"expected_pitch_mix_by_count": expected_pitch_mix_by_count,
|
| 252 |
+
"expected_zone_mix_by_count": expected_zone_mix_by_count,
|
| 253 |
+
"expected_pitch_zone_mix_by_count": expected_pitch_zone_mix_by_count,
|
| 254 |
+
"tunnel_pair_scores": tunnel_pair_scores,
|
| 255 |
+
"predicted_attack_regions": attack_regions,
|
| 256 |
+
"predicted_damage_regions": _top_regions(damage_region_map, limit=5),
|
| 257 |
+
"predicted_whiff_regions": _top_regions(whiff_region_map, limit=5),
|
| 258 |
+
"handedness_context": handedness_context,
|
| 259 |
+
"count_context_profile": count_context_profile,
|
| 260 |
+
"matchup_coverage_confidence": matchup_coverage_confidence,
|
| 261 |
+
"component_source_map": _component_source_map(),
|
| 262 |
+
"zone_matchup": zone_matchup,
|
| 263 |
+
"family_zone_matchup": family_zone_matchup,
|
| 264 |
+
"arsenal_matchup": arsenal_matchup,
|
| 265 |
+
"trajectory": trajectory,
|
| 266 |
+
"sequence_profiles": sequence_profiles,
|
| 267 |
+
}
|
models/strikeout_probability_engine.py
CHANGED
|
@@ -68,6 +68,14 @@ def _bucket(score: float) -> str:
|
|
| 68 |
return "low"
|
| 69 |
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
def _normalize_name(value: Any) -> str:
|
| 72 |
return " ".join(str(value or "").strip().lower().split())
|
| 73 |
|
|
@@ -225,8 +233,15 @@ def build_strikeout_probability_result(
|
|
| 225 |
"trajectory_release_consistency_score": None,
|
| 226 |
"sequencing_score": None,
|
| 227 |
"confidence_score": None,
|
|
|
|
|
|
|
|
|
|
| 228 |
"confidence_bucket": None,
|
| 229 |
"confidence_reasons": [],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
"applied_layers": "",
|
| 231 |
"skipped_layers": "",
|
| 232 |
"reason_tags_for": [],
|
|
@@ -380,27 +395,52 @@ def build_strikeout_probability_result(
|
|
| 380 |
|
| 381 |
confidence = 52.0
|
| 382 |
confidence_reasons: list[str] = []
|
|
|
|
|
|
|
| 383 |
if sample_size >= 400:
|
| 384 |
confidence += 10
|
|
|
|
| 385 |
elif sample_size < 150:
|
| 386 |
confidence -= 12
|
| 387 |
confidence_reasons.append("Limited pitcher pitch sample")
|
|
|
|
| 388 |
if opponent_overlay.get("lineup_sample_size", 0) >= 7:
|
| 389 |
confidence += 8
|
|
|
|
| 390 |
else:
|
| 391 |
confidence -= 6
|
| 392 |
confidence_reasons.append("Projected opponent lineup is incomplete")
|
|
|
|
| 393 |
if traj_reliability >= 0.45:
|
| 394 |
confidence += 5
|
|
|
|
| 395 |
else:
|
| 396 |
confidence_reasons.append("Trajectory/tunneling sample is thin")
|
|
|
|
| 397 |
if seq_reliability >= 0.40:
|
| 398 |
confidence += 4
|
|
|
|
| 399 |
else:
|
| 400 |
confidence_reasons.append("Sequencing signal is still noisy")
|
|
|
|
| 401 |
if abs(calibrated_prob - 0.50) > 0.28:
|
| 402 |
confidence -= 5
|
| 403 |
confidence_reasons.append("Fair probability is still high-variance")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
|
| 405 |
result.update(
|
| 406 |
{
|
|
@@ -417,9 +457,15 @@ def build_strikeout_probability_result(
|
|
| 417 |
"trajectory_tunnel_score": tunnel,
|
| 418 |
"trajectory_release_consistency_score": release_consistency,
|
| 419 |
"sequencing_score": sequencing_score,
|
| 420 |
-
"confidence_score":
|
| 421 |
-
"
|
|
|
|
|
|
|
| 422 |
"confidence_reasons": confidence_reasons[:5],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
"applied_layers": "|".join(applied_layers),
|
| 424 |
"reason_tags_for": reasons_for[:4],
|
| 425 |
"reason_tags_against": reasons_against[:4],
|
|
|
|
| 68 |
return "low"
|
| 69 |
|
| 70 |
|
| 71 |
+
def _confidence_component(label: str, value: float, direction: str) -> dict[str, Any]:
|
| 72 |
+
return {
|
| 73 |
+
"label": label,
|
| 74 |
+
"value": round(float(value), 1),
|
| 75 |
+
"direction": direction,
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
def _normalize_name(value: Any) -> str:
|
| 80 |
return " ".join(str(value or "").strip().lower().split())
|
| 81 |
|
|
|
|
| 233 |
"trajectory_release_consistency_score": None,
|
| 234 |
"sequencing_score": None,
|
| 235 |
"confidence_score": None,
|
| 236 |
+
"confidence_score_raw": None,
|
| 237 |
+
"confidence_score_display": None,
|
| 238 |
+
"confidence_source": "strikeout_v1_live",
|
| 239 |
"confidence_bucket": None,
|
| 240 |
"confidence_reasons": [],
|
| 241 |
+
"confidence_component_bonuses": [],
|
| 242 |
+
"confidence_component_penalties": [],
|
| 243 |
+
"confidence_primary_driver": None,
|
| 244 |
+
"confidence_summary_label": None,
|
| 245 |
"applied_layers": "",
|
| 246 |
"skipped_layers": "",
|
| 247 |
"reason_tags_for": [],
|
|
|
|
| 395 |
|
| 396 |
confidence = 52.0
|
| 397 |
confidence_reasons: list[str] = []
|
| 398 |
+
confidence_component_bonuses: list[dict[str, Any]] = []
|
| 399 |
+
confidence_component_penalties: list[dict[str, Any]] = []
|
| 400 |
if sample_size >= 400:
|
| 401 |
confidence += 10
|
| 402 |
+
confidence_component_bonuses.append(_confidence_component("Strong pitcher sample", 10, "bonus"))
|
| 403 |
elif sample_size < 150:
|
| 404 |
confidence -= 12
|
| 405 |
confidence_reasons.append("Limited pitcher pitch sample")
|
| 406 |
+
confidence_component_penalties.append(_confidence_component("Limited pitcher pitch sample", 12, "penalty"))
|
| 407 |
if opponent_overlay.get("lineup_sample_size", 0) >= 7:
|
| 408 |
confidence += 8
|
| 409 |
+
confidence_component_bonuses.append(_confidence_component("Projected lineup mostly complete", 8, "bonus"))
|
| 410 |
else:
|
| 411 |
confidence -= 6
|
| 412 |
confidence_reasons.append("Projected opponent lineup is incomplete")
|
| 413 |
+
confidence_component_penalties.append(_confidence_component("Projected opponent lineup is incomplete", 6, "penalty"))
|
| 414 |
if traj_reliability >= 0.45:
|
| 415 |
confidence += 5
|
| 416 |
+
confidence_component_bonuses.append(_confidence_component("Strong telemetry coverage", 5, "bonus"))
|
| 417 |
else:
|
| 418 |
confidence_reasons.append("Trajectory/tunneling sample is thin")
|
| 419 |
+
confidence_component_penalties.append(_confidence_component("Trajectory/tunneling sample is thin", 0, "penalty"))
|
| 420 |
if seq_reliability >= 0.40:
|
| 421 |
confidence += 4
|
| 422 |
+
confidence_component_bonuses.append(_confidence_component("Sequencing sample is stable", 4, "bonus"))
|
| 423 |
else:
|
| 424 |
confidence_reasons.append("Sequencing signal is still noisy")
|
| 425 |
+
confidence_component_penalties.append(_confidence_component("Sequencing signal is still noisy", 0, "penalty"))
|
| 426 |
if abs(calibrated_prob - 0.50) > 0.28:
|
| 427 |
confidence -= 5
|
| 428 |
confidence_reasons.append("Fair probability is still high-variance")
|
| 429 |
+
confidence_component_penalties.append(_confidence_component("Fair probability is still high-variance", 5, "penalty"))
|
| 430 |
+
|
| 431 |
+
confidence_raw = _clamp(confidence, 1.0, 100.0)
|
| 432 |
+
primary_penalty = max(
|
| 433 |
+
[item for item in confidence_component_penalties if float(item.get("value") or 0.0) > 0.0],
|
| 434 |
+
key=lambda item: float(item.get("value") or 0.0),
|
| 435 |
+
default=None,
|
| 436 |
+
)
|
| 437 |
+
primary_bonus = max(
|
| 438 |
+
[item for item in confidence_component_bonuses if float(item.get("value") or 0.0) > 0.0],
|
| 439 |
+
key=lambda item: float(item.get("value") or 0.0),
|
| 440 |
+
default=None,
|
| 441 |
+
)
|
| 442 |
+
primary_driver = primary_penalty or primary_bonus
|
| 443 |
+
summary_label = str((primary_driver or {}).get("label") or "").strip() or None
|
| 444 |
|
| 445 |
result.update(
|
| 446 |
{
|
|
|
|
| 457 |
"trajectory_tunnel_score": tunnel,
|
| 458 |
"trajectory_release_consistency_score": release_consistency,
|
| 459 |
"sequencing_score": sequencing_score,
|
| 460 |
+
"confidence_score": confidence_raw,
|
| 461 |
+
"confidence_score_raw": confidence_raw,
|
| 462 |
+
"confidence_score_display": confidence_raw,
|
| 463 |
+
"confidence_bucket": _bucket(confidence_raw),
|
| 464 |
"confidence_reasons": confidence_reasons[:5],
|
| 465 |
+
"confidence_component_bonuses": confidence_component_bonuses,
|
| 466 |
+
"confidence_component_penalties": confidence_component_penalties,
|
| 467 |
+
"confidence_primary_driver": primary_driver,
|
| 468 |
+
"confidence_summary_label": summary_label,
|
| 469 |
"applied_layers": "|".join(applied_layers),
|
| 470 |
"reason_tags_for": reasons_for[:4],
|
| 471 |
"reason_tags_against": reasons_against[:4],
|
models/strikeout_probability_engine_v2.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import math
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
import pandas as pd
|
| 7 |
+
|
| 8 |
+
from models.pitcher_adjustment import build_pitcher_feature_row
|
| 9 |
+
from models.shared_matchup_engine import compose_shared_matchup_context
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _safe_float(value: Any, default: float | None = None) -> float | None:
|
| 13 |
+
try:
|
| 14 |
+
if value is None:
|
| 15 |
+
return default
|
| 16 |
+
text = str(value).strip().lower()
|
| 17 |
+
if text in {"", "nan", "none"}:
|
| 18 |
+
return default
|
| 19 |
+
return float(value)
|
| 20 |
+
except Exception:
|
| 21 |
+
return default
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _clamp(value: float, lo: float, hi: float) -> float:
|
| 25 |
+
return max(lo, min(hi, value))
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _reliability(sample_size: Any, k: float = 120.0) -> float:
|
| 29 |
+
sample = max(0.0, float(_safe_float(sample_size, 0.0) or 0.0))
|
| 30 |
+
return _clamp(sample / (sample + max(1.0, k)), 0.0, 1.0)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _confidence_component(label: str, value: float, direction: str) -> dict[str, Any]:
|
| 34 |
+
return {
|
| 35 |
+
"label": label,
|
| 36 |
+
"value": round(float(value), 1),
|
| 37 |
+
"direction": direction,
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _poisson_prob_over(expected_value: float, line: float) -> float:
|
| 42 |
+
if expected_value <= 0:
|
| 43 |
+
return 0.0
|
| 44 |
+
target = int(math.floor(line))
|
| 45 |
+
cumulative = 0.0
|
| 46 |
+
for k in range(0, target + 1):
|
| 47 |
+
cumulative += math.exp(-expected_value) * (expected_value ** k) / math.factorial(k)
|
| 48 |
+
return _clamp(1.0 - cumulative, 0.0, 1.0)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _poisson_prob_under(expected_value: float, line: float) -> float:
|
| 52 |
+
return _clamp(1.0 - _poisson_prob_over(expected_value, line), 0.0, 1.0)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _calibrate(probability: float) -> float:
|
| 56 |
+
centered = probability - 0.50
|
| 57 |
+
return _clamp(0.50 + (centered * 0.92), 0.02, 0.98)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def build_strikeout_probability_result_v2(
|
| 61 |
+
pitcher_statcast_df: pd.DataFrame,
|
| 62 |
+
pitcher_name: str,
|
| 63 |
+
batter_statcast_df: pd.DataFrame | None = None,
|
| 64 |
+
opponent_batters: list[str] | None = None,
|
| 65 |
+
opponent_team: str | None = None,
|
| 66 |
+
line: float | None = None,
|
| 67 |
+
selection_side: str | None = None,
|
| 68 |
+
game_row: dict[str, Any] | None = None,
|
| 69 |
+
) -> dict[str, Any]:
|
| 70 |
+
result: dict[str, Any] = {
|
| 71 |
+
"formula_version": "strikeout_v2_shadow",
|
| 72 |
+
"raw_k_prob_v2": None,
|
| 73 |
+
"calibrated_k_prob_v2": None,
|
| 74 |
+
"fair_prob_v2": None,
|
| 75 |
+
"expected_strikeouts_v2": None,
|
| 76 |
+
"projected_pitch_count": None,
|
| 77 |
+
"projected_batters_faced": None,
|
| 78 |
+
"projected_innings": None,
|
| 79 |
+
"projected_k_rate": None,
|
| 80 |
+
"k_rate_pitch_signal": None,
|
| 81 |
+
"k_rate_anchor": None,
|
| 82 |
+
"bb_rate_anchor": None,
|
| 83 |
+
"command_efficiency_signal": None,
|
| 84 |
+
"swing_miss_subscore": None,
|
| 85 |
+
"called_strike_subscore": None,
|
| 86 |
+
"command_efficiency_subscore": None,
|
| 87 |
+
"lineup_whiff_subscore": None,
|
| 88 |
+
"zone_matchup_subscore": None,
|
| 89 |
+
"family_zone_matchup_subscore": None,
|
| 90 |
+
"arsenal_fit_subscore": None,
|
| 91 |
+
"tunneling_subscore": None,
|
| 92 |
+
"release_consistency_subscore": None,
|
| 93 |
+
"sequencing_subscore": None,
|
| 94 |
+
"count_leverage_subscore": None,
|
| 95 |
+
"leash_risk_subscore": None,
|
| 96 |
+
"times_through_order_penalty": None,
|
| 97 |
+
"variance_band_low": None,
|
| 98 |
+
"variance_band_high": None,
|
| 99 |
+
"matchup_coverage_confidence": None,
|
| 100 |
+
"component_source_map": {},
|
| 101 |
+
"predicted_whiff_regions": [],
|
| 102 |
+
"predicted_attack_regions": [],
|
| 103 |
+
"predicted_damage_regions": [],
|
| 104 |
+
"tunnel_pair_scores": [],
|
| 105 |
+
"applied_layers_v2": "",
|
| 106 |
+
"skipped_layers_v2": "",
|
| 107 |
+
"confidence_score_v2": None,
|
| 108 |
+
"confidence_score_raw_v2": None,
|
| 109 |
+
"confidence_score_display_v2": None,
|
| 110 |
+
"confidence_source_v2": "strikeout_v2_shadow",
|
| 111 |
+
"confidence_bucket_v2": None,
|
| 112 |
+
"confidence_reasons_v2": [],
|
| 113 |
+
"confidence_component_bonuses_v2": [],
|
| 114 |
+
"confidence_component_penalties_v2": [],
|
| 115 |
+
"confidence_primary_driver_v2": None,
|
| 116 |
+
"confidence_summary_label_v2": None,
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
if (
|
| 120 |
+
pitcher_statcast_df is None
|
| 121 |
+
or pitcher_statcast_df.empty
|
| 122 |
+
or not pitcher_name
|
| 123 |
+
or line is None
|
| 124 |
+
or selection_side not in {"over", "under"}
|
| 125 |
+
):
|
| 126 |
+
result["skipped_layers_v2"] = "missing_pitcher_or_line"
|
| 127 |
+
return result
|
| 128 |
+
|
| 129 |
+
pitcher_row = build_pitcher_feature_row(pitcher_statcast_df, pitcher_name)
|
| 130 |
+
sample_size = int(pitcher_row.get("sample_size") or 0)
|
| 131 |
+
reliability = _reliability(sample_size, k=180.0)
|
| 132 |
+
swstr = _safe_float(pitcher_row.get("swstr_rate"))
|
| 133 |
+
csw = _safe_float(pitcher_row.get("csw_rate"))
|
| 134 |
+
ball = _safe_float(pitcher_row.get("ball_rate"))
|
| 135 |
+
|
| 136 |
+
strike_anchor = None
|
| 137 |
+
walk_anchor = None
|
| 138 |
+
if swstr is not None:
|
| 139 |
+
strike_anchor = _clamp(0.12 + ((swstr - 0.11) * 1.15), 0.12, 0.42)
|
| 140 |
+
if ball is not None:
|
| 141 |
+
walk_anchor = _clamp(0.05 + ((ball - 0.36) * 0.75), 0.02, 0.14)
|
| 142 |
+
|
| 143 |
+
matchup_rows: list[dict[str, Any]] = []
|
| 144 |
+
if batter_statcast_df is not None and not batter_statcast_df.empty and opponent_batters:
|
| 145 |
+
for batter_name in opponent_batters[:9]:
|
| 146 |
+
matchup_rows.append(
|
| 147 |
+
compose_shared_matchup_context(
|
| 148 |
+
batter_name=batter_name,
|
| 149 |
+
pitcher_name=pitcher_name,
|
| 150 |
+
batter_statcast_df=batter_statcast_df,
|
| 151 |
+
pitcher_statcast_df=pitcher_statcast_df,
|
| 152 |
+
pitcher_row=pitcher_row,
|
| 153 |
+
game_row=game_row,
|
| 154 |
+
batter_features={"batter_stand": "L"},
|
| 155 |
+
)
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
if matchup_rows:
|
| 159 |
+
def _avg(path: tuple[str, ...], default: float = 0.0) -> float:
|
| 160 |
+
vals: list[float] = []
|
| 161 |
+
for row in matchup_rows:
|
| 162 |
+
cur: Any = row
|
| 163 |
+
for key in path:
|
| 164 |
+
if not isinstance(cur, dict):
|
| 165 |
+
cur = default
|
| 166 |
+
break
|
| 167 |
+
cur = cur.get(key)
|
| 168 |
+
if cur is not None:
|
| 169 |
+
vals.append(float(_safe_float(cur, default) or default))
|
| 170 |
+
return sum(vals) / len(vals) if vals else default
|
| 171 |
+
|
| 172 |
+
matchup = {
|
| 173 |
+
"predicted_whiff_regions": matchup_rows[0].get("predicted_whiff_regions") or [],
|
| 174 |
+
"predicted_attack_regions": matchup_rows[0].get("predicted_attack_regions") or [],
|
| 175 |
+
"predicted_damage_regions": matchup_rows[0].get("predicted_damage_regions") or [],
|
| 176 |
+
"tunnel_pair_scores": matchup_rows[0].get("tunnel_pair_scores") or [],
|
| 177 |
+
"matchup_coverage_confidence": _avg(("matchup_coverage_confidence",), 0.0),
|
| 178 |
+
"component_source_map": matchup_rows[0].get("component_source_map") or {},
|
| 179 |
+
"zone_matchup": {"hit_zone_boost": _avg(("zone_matchup", "hit_zone_boost"), 0.0)},
|
| 180 |
+
"family_zone_matchup": {"family_zone_whiff_risk": _avg(("family_zone_matchup", "family_zone_whiff_risk"), 0.0)},
|
| 181 |
+
"arsenal_matchup": {"arsenal_whiff_risk": _avg(("arsenal_matchup", "arsenal_whiff_risk"), 0.0)},
|
| 182 |
+
"trajectory": matchup_rows[0].get("trajectory") or {},
|
| 183 |
+
"count_context_profile": matchup_rows[0].get("count_context_profile") or {},
|
| 184 |
+
}
|
| 185 |
+
else:
|
| 186 |
+
matchup = {
|
| 187 |
+
"predicted_whiff_regions": [],
|
| 188 |
+
"predicted_attack_regions": [],
|
| 189 |
+
"predicted_damage_regions": [],
|
| 190 |
+
"tunnel_pair_scores": [],
|
| 191 |
+
"matchup_coverage_confidence": 0.0,
|
| 192 |
+
"component_source_map": {},
|
| 193 |
+
"zone_matchup": {},
|
| 194 |
+
"family_zone_matchup": {},
|
| 195 |
+
"arsenal_matchup": {},
|
| 196 |
+
"trajectory": {},
|
| 197 |
+
"count_context_profile": {},
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
zone_matchup_subscore = _safe_float((matchup.get("zone_matchup") or {}).get("hit_zone_boost"), 0.0) or 0.0
|
| 201 |
+
family_zone_matchup_subscore = _safe_float((matchup.get("family_zone_matchup") or {}).get("family_zone_whiff_risk"), 0.0) or 0.0
|
| 202 |
+
arsenal_fit_subscore = _safe_float((matchup.get("arsenal_matchup") or {}).get("arsenal_whiff_risk"), 0.0) or 0.0
|
| 203 |
+
trajectory = matchup.get("trajectory") or {}
|
| 204 |
+
tunneling_subscore = _safe_float(trajectory.get("tunnel_score"), 0.5) or 0.5
|
| 205 |
+
release_consistency_subscore = _safe_float(trajectory.get("release_consistency_score"), 0.5) or 0.5
|
| 206 |
+
sequencing_profiles = matchup.get("count_context_profile") or {}
|
| 207 |
+
putaway_states = [v for k, v in sequencing_profiles.items() if str(k).endswith("-2")]
|
| 208 |
+
count_leverage_subscore = 0.58 if putaway_states else 0.50
|
| 209 |
+
sequencing_subscore = _clamp(0.5 + ((count_leverage_subscore - 0.5) * 0.6), 0.0, 1.0)
|
| 210 |
+
|
| 211 |
+
swing_miss_subscore = _clamp((swstr or 0.11) / 0.18, 0.0, 1.0)
|
| 212 |
+
called_strike_subscore = _clamp((csw or 0.28) / 0.36, 0.0, 1.0)
|
| 213 |
+
command_efficiency_signal = _clamp(1.0 - ((ball or 0.36) - 0.30) / 0.12, 0.0, 1.0)
|
| 214 |
+
command_efficiency_subscore = command_efficiency_signal
|
| 215 |
+
lineup_whiff_subscore = _clamp(
|
| 216 |
+
(
|
| 217 |
+
family_zone_matchup_subscore * 0.55
|
| 218 |
+
+ arsenal_fit_subscore * 0.45
|
| 219 |
+
) / 0.35 if (family_zone_matchup_subscore or arsenal_fit_subscore) else 0.5,
|
| 220 |
+
0.0,
|
| 221 |
+
1.0,
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
# Opportunity layer: explicit but conservative until richer workload data is added.
|
| 225 |
+
pitch_count = 88.0
|
| 226 |
+
if swstr is not None:
|
| 227 |
+
pitch_count += (swstr - 0.11) * 18.0
|
| 228 |
+
if csw is not None:
|
| 229 |
+
pitch_count += (csw - 0.28) * 12.0
|
| 230 |
+
if ball is not None:
|
| 231 |
+
pitch_count -= (ball - 0.36) * 28.0
|
| 232 |
+
if opponent_batters and len(opponent_batters) >= 7:
|
| 233 |
+
pitch_count += 1.5
|
| 234 |
+
leash_risk_score = _clamp(1.0 - command_efficiency_signal, 0.0, 1.0)
|
| 235 |
+
pitch_count -= leash_risk_score * 8.0
|
| 236 |
+
pitch_count = _clamp(pitch_count, 64.0, 108.0)
|
| 237 |
+
|
| 238 |
+
pitches_per_bf = 3.85
|
| 239 |
+
if ball is not None:
|
| 240 |
+
pitches_per_bf += (ball - 0.36) * 2.4
|
| 241 |
+
projected_batters_faced = _clamp(pitch_count / max(3.1, pitches_per_bf), 16.0, 31.0)
|
| 242 |
+
projected_innings = _clamp(projected_batters_faced / 4.35, 3.8, 7.8)
|
| 243 |
+
times_through_order_penalty = _clamp(max(0.0, projected_batters_faced - 24.0) * 0.004, 0.0, 0.06)
|
| 244 |
+
|
| 245 |
+
pitch_signal = (
|
| 246 |
+
swing_miss_subscore * 0.34
|
| 247 |
+
+ called_strike_subscore * 0.18
|
| 248 |
+
+ command_efficiency_subscore * 0.14
|
| 249 |
+
+ lineup_whiff_subscore * 0.10
|
| 250 |
+
+ _clamp(zone_matchup_subscore / 0.40, 0.0, 1.0) * 0.08
|
| 251 |
+
+ _clamp(family_zone_matchup_subscore / 0.40, 0.0, 1.0) * 0.06
|
| 252 |
+
+ _clamp(arsenal_fit_subscore / 0.35, 0.0, 1.0) * 0.05
|
| 253 |
+
+ tunneling_subscore * 0.03
|
| 254 |
+
+ release_consistency_subscore * 0.01
|
| 255 |
+
+ sequencing_subscore * 0.01
|
| 256 |
+
)
|
| 257 |
+
k_rate_pitch_signal = _clamp(0.12 + (pitch_signal * 0.26), 0.14, 0.42)
|
| 258 |
+
k_rate_anchor = strike_anchor if strike_anchor is not None else k_rate_pitch_signal
|
| 259 |
+
projected_k_rate = (
|
| 260 |
+
k_rate_pitch_signal * (1.0 - reliability)
|
| 261 |
+
+ k_rate_anchor * reliability
|
| 262 |
+
)
|
| 263 |
+
projected_k_rate = _clamp(projected_k_rate - times_through_order_penalty, 0.12, 0.40)
|
| 264 |
+
|
| 265 |
+
expected_strikeouts = _clamp(projected_batters_faced * projected_k_rate, 1.5, 11.5)
|
| 266 |
+
raw_prob = _poisson_prob_over(expected_strikeouts, float(line)) if selection_side == "over" else _poisson_prob_under(expected_strikeouts, float(line))
|
| 267 |
+
calibrated_prob = _calibrate(raw_prob)
|
| 268 |
+
variance = _clamp(0.35 + leash_risk_score * 0.9 + (1.0 - reliability) * 0.8, 0.35, 1.8)
|
| 269 |
+
|
| 270 |
+
confidence = 56.0
|
| 271 |
+
reasons: list[str] = []
|
| 272 |
+
bonuses: list[dict[str, Any]] = []
|
| 273 |
+
penalties: list[dict[str, Any]] = []
|
| 274 |
+
if sample_size >= 400:
|
| 275 |
+
confidence += 10.0
|
| 276 |
+
bonuses.append(_confidence_component("Strong pitcher sample", 10.0, "bonus"))
|
| 277 |
+
elif sample_size < 150:
|
| 278 |
+
confidence -= 10.0
|
| 279 |
+
reasons.append("Limited pitcher pitch sample")
|
| 280 |
+
penalties.append(_confidence_component("Limited pitcher pitch sample", 10.0, "penalty"))
|
| 281 |
+
if matchup.get("matchup_coverage_confidence", 0.0) >= 0.45:
|
| 282 |
+
confidence += 8.0
|
| 283 |
+
bonuses.append(_confidence_component("Strong telemetry and zone coverage", 8.0, "bonus"))
|
| 284 |
+
else:
|
| 285 |
+
confidence -= 6.0
|
| 286 |
+
reasons.append("Thin telemetry and zone-coverage sample")
|
| 287 |
+
penalties.append(_confidence_component("Thin telemetry and zone-coverage sample", 6.0, "penalty"))
|
| 288 |
+
if opponent_batters and len(opponent_batters) >= 7:
|
| 289 |
+
confidence += 5.0
|
| 290 |
+
bonuses.append(_confidence_component("Projected lineup mostly complete", 5.0, "bonus"))
|
| 291 |
+
else:
|
| 292 |
+
confidence -= 5.0
|
| 293 |
+
reasons.append("Projected opponent lineup is incomplete")
|
| 294 |
+
penalties.append(_confidence_component("Projected opponent lineup is incomplete", 5.0, "penalty"))
|
| 295 |
+
if leash_risk_score >= 0.55:
|
| 296 |
+
confidence -= 7.0
|
| 297 |
+
reasons.append("Pitch-count and leash risk remain elevated")
|
| 298 |
+
penalties.append(_confidence_component("Pitch-count and leash risk remain elevated", 7.0, "penalty"))
|
| 299 |
+
|
| 300 |
+
confidence_raw = _clamp(confidence, 1.0, 100.0)
|
| 301 |
+
bucket = "high" if confidence_raw >= 75 else "medium" if confidence_raw >= 55 else "low"
|
| 302 |
+
primary_penalty = max(
|
| 303 |
+
[item for item in penalties if float(item.get("value") or 0.0) > 0.0],
|
| 304 |
+
key=lambda item: float(item.get("value") or 0.0),
|
| 305 |
+
default=None,
|
| 306 |
+
)
|
| 307 |
+
primary_bonus = max(
|
| 308 |
+
[item for item in bonuses if float(item.get("value") or 0.0) > 0.0],
|
| 309 |
+
key=lambda item: float(item.get("value") or 0.0),
|
| 310 |
+
default=None,
|
| 311 |
+
)
|
| 312 |
+
primary_driver = primary_penalty or primary_bonus
|
| 313 |
+
summary_label = str((primary_driver or {}).get("label") or "").strip() or None
|
| 314 |
+
|
| 315 |
+
result.update(
|
| 316 |
+
{
|
| 317 |
+
"raw_k_prob_v2": raw_prob,
|
| 318 |
+
"calibrated_k_prob_v2": calibrated_prob,
|
| 319 |
+
"fair_prob_v2": calibrated_prob,
|
| 320 |
+
"expected_strikeouts_v2": expected_strikeouts,
|
| 321 |
+
"projected_pitch_count": round(pitch_count, 2),
|
| 322 |
+
"projected_batters_faced": round(projected_batters_faced, 2),
|
| 323 |
+
"projected_innings": round(projected_innings, 2),
|
| 324 |
+
"projected_k_rate": round(projected_k_rate, 4),
|
| 325 |
+
"k_rate_pitch_signal": round(k_rate_pitch_signal, 4),
|
| 326 |
+
"k_rate_anchor": round(k_rate_anchor, 4) if k_rate_anchor is not None else None,
|
| 327 |
+
"bb_rate_anchor": round(walk_anchor, 4) if walk_anchor is not None else None,
|
| 328 |
+
"command_efficiency_signal": round(command_efficiency_signal, 4),
|
| 329 |
+
"swing_miss_subscore": round(swing_miss_subscore, 4),
|
| 330 |
+
"called_strike_subscore": round(called_strike_subscore, 4),
|
| 331 |
+
"command_efficiency_subscore": round(command_efficiency_subscore, 4),
|
| 332 |
+
"lineup_whiff_subscore": round(lineup_whiff_subscore, 4),
|
| 333 |
+
"zone_matchup_subscore": round(zone_matchup_subscore, 4),
|
| 334 |
+
"family_zone_matchup_subscore": round(family_zone_matchup_subscore, 4),
|
| 335 |
+
"arsenal_fit_subscore": round(arsenal_fit_subscore, 4),
|
| 336 |
+
"tunneling_subscore": round(tunneling_subscore, 4),
|
| 337 |
+
"release_consistency_subscore": round(release_consistency_subscore, 4),
|
| 338 |
+
"sequencing_subscore": round(sequencing_subscore, 4),
|
| 339 |
+
"count_leverage_subscore": round(count_leverage_subscore, 4),
|
| 340 |
+
"leash_risk_subscore": round(leash_risk_score, 4),
|
| 341 |
+
"times_through_order_penalty": round(times_through_order_penalty, 4),
|
| 342 |
+
"variance_band_low": round(_clamp(expected_strikeouts - variance, 0.5, 12.0), 2),
|
| 343 |
+
"variance_band_high": round(_clamp(expected_strikeouts + variance, 0.5, 12.5), 2),
|
| 344 |
+
"matchup_coverage_confidence": matchup.get("matchup_coverage_confidence"),
|
| 345 |
+
"component_source_map": matchup.get("component_source_map") or {},
|
| 346 |
+
"predicted_whiff_regions": matchup.get("predicted_whiff_regions") or [],
|
| 347 |
+
"predicted_attack_regions": matchup.get("predicted_attack_regions") or [],
|
| 348 |
+
"predicted_damage_regions": matchup.get("predicted_damage_regions") or [],
|
| 349 |
+
"tunnel_pair_scores": matchup.get("tunnel_pair_scores") or [],
|
| 350 |
+
"applied_layers_v2": "opportunity|pitch_win|probability|uncertainty",
|
| 351 |
+
"skipped_layers_v2": "",
|
| 352 |
+
"confidence_score_v2": round(confidence_raw, 1),
|
| 353 |
+
"confidence_score_raw_v2": round(confidence_raw, 1),
|
| 354 |
+
"confidence_score_display_v2": round(confidence_raw, 1),
|
| 355 |
+
"confidence_bucket_v2": bucket,
|
| 356 |
+
"confidence_reasons_v2": reasons[:5],
|
| 357 |
+
"confidence_component_bonuses_v2": bonuses,
|
| 358 |
+
"confidence_component_penalties_v2": penalties,
|
| 359 |
+
"confidence_primary_driver_v2": primary_driver,
|
| 360 |
+
"confidence_summary_label_v2": summary_label,
|
| 361 |
+
}
|
| 362 |
+
)
|
| 363 |
+
return result
|
tests/test_props_mapper.py
CHANGED
|
@@ -663,8 +663,11 @@ class TestPropsMapper(unittest.TestCase):
|
|
| 663 |
"fair_prob": 0.58,
|
| 664 |
"expected_strikeouts": 7.2,
|
| 665 |
"confidence_score": 72.0,
|
|
|
|
| 666 |
"confidence_bucket": "medium",
|
| 667 |
"confidence_reasons": ["Projected opponent lineup is incomplete"],
|
|
|
|
|
|
|
| 668 |
"pitcher_swstr_rate": 0.14,
|
| 669 |
"pitcher_csw_rate": 0.31,
|
| 670 |
"pitcher_ball_rate": 0.34,
|
|
@@ -679,6 +682,54 @@ class TestPropsMapper(unittest.TestCase):
|
|
| 679 |
"reason_tags_for": ["Misses bats consistently", "Strong pitch tunneling"],
|
| 680 |
"reason_tags_against": ["Projected opponent lineup is incomplete"],
|
| 681 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 682 |
):
|
| 683 |
result = map_props_to_models(
|
| 684 |
props_df,
|
|
@@ -693,6 +744,11 @@ class TestPropsMapper(unittest.TestCase):
|
|
| 693 |
self.assertEqual(row["selection_scope"], "pitcher")
|
| 694 |
self.assertIn(row["verdict"], {"bet", "watch", "pass"})
|
| 695 |
self.assertTrue(bool(str(row["model_voice_for"])))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 696 |
|
| 697 |
|
| 698 |
if __name__ == "__main__":
|
|
|
|
| 663 |
"fair_prob": 0.58,
|
| 664 |
"expected_strikeouts": 7.2,
|
| 665 |
"confidence_score": 72.0,
|
| 666 |
+
"confidence_score_raw": 72.0,
|
| 667 |
"confidence_bucket": "medium",
|
| 668 |
"confidence_reasons": ["Projected opponent lineup is incomplete"],
|
| 669 |
+
"confidence_component_bonuses": [{"label": "Strong pitcher sample", "value": 10, "direction": "bonus"}],
|
| 670 |
+
"confidence_component_penalties": [{"label": "Projected opponent lineup is incomplete", "value": 6, "direction": "penalty"}],
|
| 671 |
"pitcher_swstr_rate": 0.14,
|
| 672 |
"pitcher_csw_rate": 0.31,
|
| 673 |
"pitcher_ball_rate": 0.34,
|
|
|
|
| 682 |
"reason_tags_for": ["Misses bats consistently", "Strong pitch tunneling"],
|
| 683 |
"reason_tags_against": ["Projected opponent lineup is incomplete"],
|
| 684 |
},
|
| 685 |
+
), patch(
|
| 686 |
+
"analytics.props_mapper.build_strikeout_probability_result_v2",
|
| 687 |
+
return_value={
|
| 688 |
+
"formula_version": "strikeout_v2_shadow",
|
| 689 |
+
"fair_prob_v2": 0.61,
|
| 690 |
+
"expected_strikeouts_v2": 7.0,
|
| 691 |
+
"projected_pitch_count": 94.0,
|
| 692 |
+
"projected_batters_faced": 25.0,
|
| 693 |
+
"projected_innings": 5.7,
|
| 694 |
+
"projected_k_rate": 0.28,
|
| 695 |
+
"raw_k_prob_v2": 0.61,
|
| 696 |
+
"calibrated_k_prob_v2": 0.61,
|
| 697 |
+
"confidence_score_v2": 64.0,
|
| 698 |
+
"confidence_score_raw_v2": 64.0,
|
| 699 |
+
"confidence_score_display_v2": 64.0,
|
| 700 |
+
"confidence_source_v2": "strikeout_v2_shadow",
|
| 701 |
+
"confidence_bucket_v2": "medium",
|
| 702 |
+
"confidence_reasons_v2": ["Projected opponent lineup is incomplete"],
|
| 703 |
+
"confidence_component_bonuses_v2": [{"label": "Strong pitcher sample", "value": 10, "direction": "bonus"}],
|
| 704 |
+
"confidence_component_penalties_v2": [{"label": "Projected opponent lineup is incomplete", "value": 5, "direction": "penalty"}],
|
| 705 |
+
"confidence_primary_driver_v2": {"label": "Projected opponent lineup is incomplete", "value": 5, "direction": "penalty"},
|
| 706 |
+
"confidence_summary_label_v2": "Projected opponent lineup is incomplete",
|
| 707 |
+
"k_rate_pitch_signal": 0.28,
|
| 708 |
+
"k_rate_anchor": 0.27,
|
| 709 |
+
"bb_rate_anchor": 0.06,
|
| 710 |
+
"command_efficiency_signal": 0.69,
|
| 711 |
+
"swing_miss_subscore": 0.77,
|
| 712 |
+
"called_strike_subscore": 0.73,
|
| 713 |
+
"command_efficiency_subscore": 0.69,
|
| 714 |
+
"lineup_whiff_subscore": 0.58,
|
| 715 |
+
"zone_matchup_subscore": 0.19,
|
| 716 |
+
"family_zone_matchup_subscore": 0.22,
|
| 717 |
+
"arsenal_fit_subscore": 0.24,
|
| 718 |
+
"tunneling_subscore": 0.64,
|
| 719 |
+
"release_consistency_subscore": 0.62,
|
| 720 |
+
"sequencing_subscore": 0.66,
|
| 721 |
+
"count_leverage_subscore": 0.58,
|
| 722 |
+
"leash_risk_subscore": 0.34,
|
| 723 |
+
"times_through_order_penalty": 0.01,
|
| 724 |
+
"variance_band_low": 6.0,
|
| 725 |
+
"variance_band_high": 8.0,
|
| 726 |
+
"matchup_coverage_confidence": 0.48,
|
| 727 |
+
"component_source_map": {"shared_composer": "ok"},
|
| 728 |
+
"predicted_whiff_regions": ["shadow"],
|
| 729 |
+
"predicted_attack_regions": ["shadow"],
|
| 730 |
+
"predicted_damage_regions": ["heart"],
|
| 731 |
+
"tunnel_pair_scores": [{"pair": "FF/CH", "score": 0.62}],
|
| 732 |
+
},
|
| 733 |
):
|
| 734 |
result = map_props_to_models(
|
| 735 |
props_df,
|
|
|
|
| 744 |
self.assertEqual(row["selection_scope"], "pitcher")
|
| 745 |
self.assertIn(row["verdict"], {"bet", "watch", "pass"})
|
| 746 |
self.assertTrue(bool(str(row["model_voice_for"])))
|
| 747 |
+
self.assertEqual(row["confidence_source"], "strikeout_v2_shadow")
|
| 748 |
+
self.assertGreater(float(row["confidence_score"]), 64.0)
|
| 749 |
+
self.assertEqual(row["confidence_summary_label"], "Projected opponent lineup is incomplete")
|
| 750 |
+
self.assertTrue(bool(row["confidence_component_bonuses"]))
|
| 751 |
+
self.assertTrue(bool(row["confidence_component_penalties"]))
|
| 752 |
|
| 753 |
|
| 754 |
if __name__ == "__main__":
|
tests/test_shared_matchup_engine.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
|
| 3 |
+
from models.shared_matchup_engine import compose_shared_matchup_context
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_compose_shared_matchup_context_empty_inputs():
|
| 7 |
+
result = compose_shared_matchup_context(
|
| 8 |
+
batter_name="",
|
| 9 |
+
pitcher_name="",
|
| 10 |
+
batter_statcast_df=pd.DataFrame(),
|
| 11 |
+
pitcher_statcast_df=pd.DataFrame(),
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
assert result["expected_pitch_mix_by_count"] == {}
|
| 15 |
+
assert result["predicted_attack_regions"] == []
|
| 16 |
+
assert "shared_composer" in result["component_source_map"]
|
| 17 |
+
|
tests/test_strikeout_probability_engine_v2.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
|
| 3 |
+
from models.strikeout_probability_engine_v2 import build_strikeout_probability_result_v2
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_strikeout_probability_result_v2_missing_inputs():
|
| 7 |
+
result = build_strikeout_probability_result_v2(
|
| 8 |
+
pitcher_statcast_df=pd.DataFrame(),
|
| 9 |
+
pitcher_name="",
|
| 10 |
+
line=None,
|
| 11 |
+
selection_side=None,
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
assert result["formula_version"] == "strikeout_v2_shadow"
|
| 15 |
+
assert result["expected_strikeouts_v2"] is None
|
| 16 |
+
assert result["skipped_layers_v2"] == "missing_pitcher_or_line"
|
| 17 |
+
assert result["confidence_score_v2"] is None
|
| 18 |
+
assert result["confidence_component_bonuses_v2"] == []
|
| 19 |
+
assert result["confidence_component_penalties_v2"] == []
|
visualization/debug_page.py
CHANGED
|
@@ -110,6 +110,142 @@ _LADDER_TB2P_FIELDS = [
|
|
| 110 |
("Final (simulated)", "tb2p_prob"),
|
| 111 |
]
|
| 112 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
|
| 114 |
# ---------------------------------------------------------------------------
|
| 115 |
# Private diagnostic helpers
|
|
@@ -887,6 +1023,83 @@ def render_debug(
|
|
| 887 |
hide_index=True,
|
| 888 |
)
|
| 889 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 890 |
with st.expander("Shared Baseline Diagnostics", expanded=False):
|
| 891 |
baseline_summary_frames: list[pd.DataFrame] = []
|
| 892 |
batter_meta = (baseline_bundle or {}).get("batter_baseline_meta", pd.DataFrame())
|
|
@@ -1079,6 +1292,46 @@ def render_debug(
|
|
| 1079 |
else:
|
| 1080 |
st.info("Open the Props page in this session to capture HR health diagnostics.")
|
| 1081 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1082 |
with st.expander("Debug Event Row Read Status", expanded=False):
|
| 1083 |
read_status = debug_event_row_status or {}
|
| 1084 |
if read_status:
|
|
|
|
| 110 |
("Final (simulated)", "tb2p_prob"),
|
| 111 |
]
|
| 112 |
|
| 113 |
+
_MODEL_RUBRIC_WEIGHTS = {
|
| 114 |
+
"shared_telemetry": 18,
|
| 115 |
+
"explicit_opportunity": 16,
|
| 116 |
+
"explicit_components": 18,
|
| 117 |
+
"pitch_level_backbone": 12,
|
| 118 |
+
"hr_damage_modeling": 12,
|
| 119 |
+
"k_shadow_v2": 10,
|
| 120 |
+
"provenance_debug": 8,
|
| 121 |
+
"uncertainty_outputs": 6,
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _build_model_upgrade_rubric(
|
| 126 |
+
props_hr_health_debug: dict[str, Any] | None,
|
| 127 |
+
shared_component_debug: dict[str, Any] | None,
|
| 128 |
+
) -> tuple[pd.DataFrame, dict[str, Any]]:
|
| 129 |
+
shared_component_debug = shared_component_debug or {}
|
| 130 |
+
props_hr_health_debug = props_hr_health_debug or {}
|
| 131 |
+
rows = pd.DataFrame(shared_component_debug.get("rows") or [])
|
| 132 |
+
|
| 133 |
+
has_shared = not rows.empty
|
| 134 |
+
has_hr_components = has_shared and any(
|
| 135 |
+
col in rows.columns
|
| 136 |
+
for col in [
|
| 137 |
+
"damage_zone_alignment_subscore",
|
| 138 |
+
"pitch_mix_exposure_subscore",
|
| 139 |
+
"tunnel_damage_subscore",
|
| 140 |
+
"count_pattern_damage_subscore",
|
| 141 |
+
]
|
| 142 |
+
)
|
| 143 |
+
has_k_v2 = has_shared and "expected_strikeouts_v2" in rows.columns
|
| 144 |
+
has_opportunity = has_shared and any(
|
| 145 |
+
col in rows.columns
|
| 146 |
+
for col in ["projected_pitch_count", "projected_batters_faced", "projected_innings"]
|
| 147 |
+
)
|
| 148 |
+
has_uncertainty = has_shared and any(
|
| 149 |
+
col in rows.columns
|
| 150 |
+
for col in ["variance_band_low", "variance_band_high", "matchup_coverage_confidence"]
|
| 151 |
+
)
|
| 152 |
+
has_provenance = has_shared and "component_source_map" in rows.columns
|
| 153 |
+
has_pitch_backbone = has_shared and any(
|
| 154 |
+
col in rows.columns
|
| 155 |
+
for col in [
|
| 156 |
+
"zone_matchup_subscore",
|
| 157 |
+
"family_zone_matchup_subscore",
|
| 158 |
+
"arsenal_fit_subscore",
|
| 159 |
+
"tunneling_subscore",
|
| 160 |
+
"sequencing_subscore",
|
| 161 |
+
]
|
| 162 |
+
)
|
| 163 |
+
has_explicit_components = has_shared and any(
|
| 164 |
+
col in rows.columns
|
| 165 |
+
for col in [
|
| 166 |
+
"damage_zone_alignment_subscore",
|
| 167 |
+
"arsenal_fit_subscore",
|
| 168 |
+
"zone_matchup_subscore",
|
| 169 |
+
"count_leverage_subscore",
|
| 170 |
+
]
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
grade_rows = [
|
| 174 |
+
{
|
| 175 |
+
"category": "Shared telemetry framework",
|
| 176 |
+
"weight": _MODEL_RUBRIC_WEIGHTS["shared_telemetry"],
|
| 177 |
+
"old_system": 2,
|
| 178 |
+
"current_system": _MODEL_RUBRIC_WEIGHTS["shared_telemetry"] if has_shared else 0,
|
| 179 |
+
"status": "active" if has_shared else "missing",
|
| 180 |
+
"evidence": "shared_matchup_engine + shared diagnostics" if has_shared else "not captured yet",
|
| 181 |
+
},
|
| 182 |
+
{
|
| 183 |
+
"category": "Explicit opportunity modeling",
|
| 184 |
+
"weight": _MODEL_RUBRIC_WEIGHTS["explicit_opportunity"],
|
| 185 |
+
"old_system": 3,
|
| 186 |
+
"current_system": _MODEL_RUBRIC_WEIGHTS["explicit_opportunity"] if has_opportunity else 0,
|
| 187 |
+
"status": "active" if has_opportunity else "missing",
|
| 188 |
+
"evidence": "projected pitch count / BF / innings" if has_opportunity else "old heuristic only",
|
| 189 |
+
},
|
| 190 |
+
{
|
| 191 |
+
"category": "Explicit modeled components",
|
| 192 |
+
"weight": _MODEL_RUBRIC_WEIGHTS["explicit_components"],
|
| 193 |
+
"old_system": 8,
|
| 194 |
+
"current_system": _MODEL_RUBRIC_WEIGHTS["explicit_components"] if has_explicit_components else 0,
|
| 195 |
+
"status": "active" if has_explicit_components else "partial",
|
| 196 |
+
"evidence": "named subscores emitted" if has_explicit_components else "implicit heuristics",
|
| 197 |
+
},
|
| 198 |
+
{
|
| 199 |
+
"category": "Pitch-level backbone",
|
| 200 |
+
"weight": _MODEL_RUBRIC_WEIGHTS["pitch_level_backbone"],
|
| 201 |
+
"old_system": 8,
|
| 202 |
+
"current_system": _MODEL_RUBRIC_WEIGHTS["pitch_level_backbone"] if has_pitch_backbone else 0,
|
| 203 |
+
"status": "active" if has_pitch_backbone else "missing",
|
| 204 |
+
"evidence": "zone/family-zone/arsenal/tunnel/sequencing present" if has_pitch_backbone else "not surfaced",
|
| 205 |
+
},
|
| 206 |
+
{
|
| 207 |
+
"category": "HR damage-zone modeling",
|
| 208 |
+
"weight": _MODEL_RUBRIC_WEIGHTS["hr_damage_modeling"],
|
| 209 |
+
"old_system": 8,
|
| 210 |
+
"current_system": _MODEL_RUBRIC_WEIGHTS["hr_damage_modeling"] if has_hr_components else 0,
|
| 211 |
+
"status": "active" if has_hr_components else "missing",
|
| 212 |
+
"evidence": "damage-zone and pitch-mix exposure subscores" if has_hr_components else "legacy HR layers only",
|
| 213 |
+
},
|
| 214 |
+
{
|
| 215 |
+
"category": "K shadow v2 readiness",
|
| 216 |
+
"weight": _MODEL_RUBRIC_WEIGHTS["k_shadow_v2"],
|
| 217 |
+
"old_system": 0,
|
| 218 |
+
"current_system": _MODEL_RUBRIC_WEIGHTS["k_shadow_v2"] if has_k_v2 else 0,
|
| 219 |
+
"status": "active" if has_k_v2 else "missing",
|
| 220 |
+
"evidence": "strikeout v2 outputs captured" if has_k_v2 else "current K engine only",
|
| 221 |
+
},
|
| 222 |
+
{
|
| 223 |
+
"category": "Provenance and debug traceability",
|
| 224 |
+
"weight": _MODEL_RUBRIC_WEIGHTS["provenance_debug"],
|
| 225 |
+
"old_system": 4,
|
| 226 |
+
"current_system": _MODEL_RUBRIC_WEIGHTS["provenance_debug"] if has_provenance else 0,
|
| 227 |
+
"status": "active" if has_provenance else "missing",
|
| 228 |
+
"evidence": "component_source_map exposed" if has_provenance else "limited traceability",
|
| 229 |
+
},
|
| 230 |
+
{
|
| 231 |
+
"category": "Uncertainty outputs",
|
| 232 |
+
"weight": _MODEL_RUBRIC_WEIGHTS["uncertainty_outputs"],
|
| 233 |
+
"old_system": 3,
|
| 234 |
+
"current_system": _MODEL_RUBRIC_WEIGHTS["uncertainty_outputs"] if has_uncertainty else 0,
|
| 235 |
+
"status": "active" if has_uncertainty else "missing",
|
| 236 |
+
"evidence": "variance bands / coverage confidence" if has_uncertainty else "point estimate only",
|
| 237 |
+
},
|
| 238 |
+
]
|
| 239 |
+
|
| 240 |
+
rubric_df = pd.DataFrame(grade_rows)
|
| 241 |
+
summary = {
|
| 242 |
+
"old_architecture_score": int(rubric_df["old_system"].sum()),
|
| 243 |
+
"current_architecture_score": int(rubric_df["current_system"].sum()),
|
| 244 |
+
"max_score": int(rubric_df["weight"].sum()),
|
| 245 |
+
"modeled_hr_rows_total": int(props_hr_health_debug.get("modeled_hr_rows_total") or 0),
|
| 246 |
+
}
|
| 247 |
+
return rubric_df, summary
|
| 248 |
+
|
| 249 |
|
| 250 |
# ---------------------------------------------------------------------------
|
| 251 |
# Private diagnostic helpers
|
|
|
|
| 1023 |
hide_index=True,
|
| 1024 |
)
|
| 1025 |
|
| 1026 |
+
with st.expander("Strikeout Confidence Diagnostics", expanded=False):
|
| 1027 |
+
strikeout_df = exec_df[
|
| 1028 |
+
exec_df.get("market_family", pd.Series(index=exec_df.index, dtype="object"))
|
| 1029 |
+
.astype(str)
|
| 1030 |
+
.str.lower()
|
| 1031 |
+
.eq("k")
|
| 1032 |
+
].copy()
|
| 1033 |
+
if strikeout_df.empty:
|
| 1034 |
+
st.info("No strikeout props are currently available.")
|
| 1035 |
+
else:
|
| 1036 |
+
summary_cols = [
|
| 1037 |
+
"player_name_raw",
|
| 1038 |
+
"sportsbook",
|
| 1039 |
+
"display_label",
|
| 1040 |
+
"selection_side",
|
| 1041 |
+
"fair_prob",
|
| 1042 |
+
"confidence_score",
|
| 1043 |
+
"confidence_score_raw",
|
| 1044 |
+
"confidence_score_display",
|
| 1045 |
+
"confidence_source",
|
| 1046 |
+
"confidence_bucket",
|
| 1047 |
+
"confidence_bucket_raw",
|
| 1048 |
+
"confidence_bucket_display",
|
| 1049 |
+
"confidence_summary_label",
|
| 1050 |
+
"confidence_reasons",
|
| 1051 |
+
"confidence_score_v1",
|
| 1052 |
+
"confidence_bucket_v1",
|
| 1053 |
+
"confidence_score_v2",
|
| 1054 |
+
"confidence_score_raw_v2",
|
| 1055 |
+
"confidence_score_display_v2",
|
| 1056 |
+
"confidence_bucket_v2",
|
| 1057 |
+
"confidence_reasons_v2",
|
| 1058 |
+
"projected_pitch_count",
|
| 1059 |
+
"projected_batters_faced",
|
| 1060 |
+
"projected_innings",
|
| 1061 |
+
"expected_strikeouts",
|
| 1062 |
+
"expected_strikeouts_v2",
|
| 1063 |
+
]
|
| 1064 |
+
st.write("Card-facing strikeout confidence rows")
|
| 1065 |
+
st.dataframe(
|
| 1066 |
+
strikeout_df[[c for c in summary_cols if c in strikeout_df.columns]],
|
| 1067 |
+
use_container_width=True,
|
| 1068 |
+
hide_index=True,
|
| 1069 |
+
)
|
| 1070 |
+
|
| 1071 |
+
component_rows: list[dict[str, Any]] = []
|
| 1072 |
+
for _, row in strikeout_df.iterrows():
|
| 1073 |
+
player_name = row.get("player_name_raw") or row.get("player_name")
|
| 1074 |
+
display_label = row.get("display_label")
|
| 1075 |
+
for item in row.get("confidence_component_bonuses") or []:
|
| 1076 |
+
component_rows.append(
|
| 1077 |
+
{
|
| 1078 |
+
"player_name": player_name,
|
| 1079 |
+
"display_label": display_label,
|
| 1080 |
+
"component_type": "bonus",
|
| 1081 |
+
"label": item.get("label"),
|
| 1082 |
+
"value": item.get("value"),
|
| 1083 |
+
"source": row.get("confidence_source"),
|
| 1084 |
+
}
|
| 1085 |
+
)
|
| 1086 |
+
for item in row.get("confidence_component_penalties") or []:
|
| 1087 |
+
component_rows.append(
|
| 1088 |
+
{
|
| 1089 |
+
"player_name": player_name,
|
| 1090 |
+
"display_label": display_label,
|
| 1091 |
+
"component_type": "penalty",
|
| 1092 |
+
"label": item.get("label"),
|
| 1093 |
+
"value": item.get("value"),
|
| 1094 |
+
"source": row.get("confidence_source"),
|
| 1095 |
+
}
|
| 1096 |
+
)
|
| 1097 |
+
if component_rows:
|
| 1098 |
+
st.write("Confidence component math")
|
| 1099 |
+
st.dataframe(pd.DataFrame(component_rows), use_container_width=True, hide_index=True)
|
| 1100 |
+
else:
|
| 1101 |
+
st.info("No confidence component rows are present yet.")
|
| 1102 |
+
|
| 1103 |
with st.expander("Shared Baseline Diagnostics", expanded=False):
|
| 1104 |
baseline_summary_frames: list[pd.DataFrame] = []
|
| 1105 |
batter_meta = (baseline_bundle or {}).get("batter_baseline_meta", pd.DataFrame())
|
|
|
|
| 1292 |
else:
|
| 1293 |
st.info("Open the Props page in this session to capture HR health diagnostics.")
|
| 1294 |
|
| 1295 |
+
with st.expander("Shared Matchup Component Diagnostics", expanded=False):
|
| 1296 |
+
shared_component_debug = st.session_state.get("props_shared_component_debug") or {}
|
| 1297 |
+
if shared_component_debug:
|
| 1298 |
+
st.caption(
|
| 1299 |
+
f"Captured from Props market: {str(shared_component_debug.get('market_type') or 'unknown').upper()}"
|
| 1300 |
+
)
|
| 1301 |
+
rows_df = pd.DataFrame(shared_component_debug.get("rows") or [])
|
| 1302 |
+
if not rows_df.empty:
|
| 1303 |
+
st.dataframe(rows_df, use_container_width=True, hide_index=True)
|
| 1304 |
+
else:
|
| 1305 |
+
st.info("No shared-component rows captured in this session.")
|
| 1306 |
+
else:
|
| 1307 |
+
st.info("Open the Props page in this session to capture shared matchup diagnostics.")
|
| 1308 |
+
|
| 1309 |
+
with st.expander("Model Grading Rubric", expanded=False):
|
| 1310 |
+
props_hr_health_debug = st.session_state.get("props_hr_health_debug") or {}
|
| 1311 |
+
shared_component_debug = st.session_state.get("props_shared_component_debug") or {}
|
| 1312 |
+
rubric_df, rubric_summary = _build_model_upgrade_rubric(
|
| 1313 |
+
props_hr_health_debug=props_hr_health_debug,
|
| 1314 |
+
shared_component_debug=shared_component_debug,
|
| 1315 |
+
)
|
| 1316 |
+
c1, c2, c3 = st.columns(3)
|
| 1317 |
+
c1.metric(
|
| 1318 |
+
"Old Architecture Grade",
|
| 1319 |
+
f"{int(rubric_summary.get('old_architecture_score') or 0)}/{int(rubric_summary.get('max_score') or 100)}",
|
| 1320 |
+
)
|
| 1321 |
+
c2.metric(
|
| 1322 |
+
"Current Architecture Grade",
|
| 1323 |
+
f"{int(rubric_summary.get('current_architecture_score') or 0)}/{int(rubric_summary.get('max_score') or 100)}",
|
| 1324 |
+
)
|
| 1325 |
+
c3.metric(
|
| 1326 |
+
"Modeled 1+ HR Rows",
|
| 1327 |
+
int(rubric_summary.get("modeled_hr_rows_total") or 0),
|
| 1328 |
+
)
|
| 1329 |
+
st.caption(
|
| 1330 |
+
"This is an architecture and model-readiness rubric, not a live ROI or hit-rate grade. "
|
| 1331 |
+
"Replace or augment it with rolling backtest metrics as the evaluation layer is built."
|
| 1332 |
+
)
|
| 1333 |
+
st.dataframe(rubric_df, use_container_width=True, hide_index=True)
|
| 1334 |
+
|
| 1335 |
with st.expander("Debug Event Row Read Status", expanded=False):
|
| 1336 |
read_status = debug_event_row_status or {}
|
| 1337 |
if read_status:
|
visualization/props_page.py
CHANGED
|
@@ -223,6 +223,13 @@ def _render_props_ui_styles() -> None:
|
|
| 223 |
.props-metric-value.bad {
|
| 224 |
color: #ff7f7f;
|
| 225 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
.props-verdict {
|
| 227 |
display: inline-block;
|
| 228 |
border-radius: 999px;
|
|
@@ -341,6 +348,71 @@ def _format_confidence(val: float | None) -> str:
|
|
| 341 |
return "-"
|
| 342 |
|
| 343 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
def _market_label(value: Any) -> str:
|
| 345 |
text = str(value or "").strip().lower()
|
| 346 |
labels = {
|
|
@@ -638,6 +710,10 @@ def _build_market_modeling_payload(
|
|
| 638 |
)
|
| 639 |
else:
|
| 640 |
st.session_state.pop("props_hr_health_debug", None)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 641 |
|
| 642 |
return {
|
| 643 |
"baseline_request": baseline_request,
|
|
@@ -1038,6 +1114,51 @@ def _build_hr_health_debug(display: pd.DataFrame, extra_context: dict[str, Any]
|
|
| 1038 |
return out
|
| 1039 |
|
| 1040 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1041 |
def render_props_hero(display_df: pd.DataFrame, view_model: dict[str, Any] | None = None) -> None:
|
| 1042 |
st.markdown(
|
| 1043 |
"""
|
|
@@ -1136,7 +1257,7 @@ def render_featured_hr_cards(featured_df: pd.DataFrame) -> None:
|
|
| 1136 |
</div>
|
| 1137 |
<div>
|
| 1138 |
<div class="props-metric-label">Confidence</div>
|
| 1139 |
-
|
| 1140 |
</div>
|
| 1141 |
</div>
|
| 1142 |
<div class="props-voice">
|
|
@@ -1216,7 +1337,7 @@ def render_best_on_slate_cards(best_df: pd.DataFrame, summary: dict[str, Any] |
|
|
| 1216 |
</div>
|
| 1217 |
<div>
|
| 1218 |
<div class="props-metric-label">Confidence</div>
|
| 1219 |
-
|
| 1220 |
</div>
|
| 1221 |
</div>
|
| 1222 |
<div class="props-voice">
|
|
@@ -1291,8 +1412,15 @@ def render_player_hr_details(player_details: dict[str, Any]) -> None:
|
|
| 1291 |
"model_voice_for",
|
| 1292 |
"model_voice_against",
|
| 1293 |
"confidence_score",
|
|
|
|
|
|
|
|
|
|
| 1294 |
"confidence_bucket",
|
| 1295 |
"confidence_reasons",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1296 |
"opportunity_hr_adjustment",
|
| 1297 |
"expected_pa",
|
| 1298 |
"lineup_slot_used",
|
|
@@ -1380,6 +1508,7 @@ def render_player_hr_row(player_entry: dict[str, Any]) -> None:
|
|
| 1380 |
st.caption("Why this rating")
|
| 1381 |
for line in why_lines:
|
| 1382 |
st.write(f"- {line}")
|
|
|
|
| 1383 |
render_player_hr_details(details)
|
| 1384 |
st.divider()
|
| 1385 |
|
|
|
|
| 223 |
.props-metric-value.bad {
|
| 224 |
color: #ff7f7f;
|
| 225 |
}
|
| 226 |
+
.props-metric-subvalue {
|
| 227 |
+
color: #88a0bb;
|
| 228 |
+
font-size: 0.72rem;
|
| 229 |
+
line-height: 1.25;
|
| 230 |
+
margin-top: 0.12rem;
|
| 231 |
+
min-height: 1rem;
|
| 232 |
+
}
|
| 233 |
.props-verdict {
|
| 234 |
display: inline-block;
|
| 235 |
border-radius: 999px;
|
|
|
|
| 348 |
return "-"
|
| 349 |
|
| 350 |
|
| 351 |
+
def _confidence_summary_label(value: Any) -> str:
|
| 352 |
+
text = str(value or "").strip()
|
| 353 |
+
return text if text else "-"
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
def _build_confidence_metric_html(row: pd.Series | dict[str, Any]) -> str:
|
| 357 |
+
score = _format_confidence(row.get("confidence_score"))
|
| 358 |
+
summary = _confidence_summary_label(row.get("confidence_summary_label"))
|
| 359 |
+
if summary == "-":
|
| 360 |
+
summary = " "
|
| 361 |
+
return (
|
| 362 |
+
f"<div class=\"props-metric-value\">{score}</div>"
|
| 363 |
+
f"<div class=\"props-metric-subvalue\">{summary}</div>"
|
| 364 |
+
)
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
def _render_confidence_breakdown(details_row: dict[str, Any]) -> None:
|
| 368 |
+
raw_score = details_row.get("confidence_score_raw")
|
| 369 |
+
display_score = details_row.get("confidence_score_display", details_row.get("confidence_score"))
|
| 370 |
+
confidence_source = str(details_row.get("confidence_source") or "").strip() or "-"
|
| 371 |
+
final_bucket = str(details_row.get("confidence_bucket") or details_row.get("confidence_bucket_display") or "-").strip()
|
| 372 |
+
summary_label = str(details_row.get("confidence_summary_label") or "").strip()
|
| 373 |
+
bonuses = details_row.get("confidence_component_bonuses") or []
|
| 374 |
+
penalties = details_row.get("confidence_component_penalties") or []
|
| 375 |
+
|
| 376 |
+
if (
|
| 377 |
+
raw_score is None
|
| 378 |
+
and display_score is None
|
| 379 |
+
and confidence_source == "-"
|
| 380 |
+
and not bonuses
|
| 381 |
+
and not penalties
|
| 382 |
+
):
|
| 383 |
+
return
|
| 384 |
+
|
| 385 |
+
st.caption("Confidence Breakdown")
|
| 386 |
+
metric_cols = st.columns(4)
|
| 387 |
+
metric_cols[0].metric("Raw/Base", _format_confidence(raw_score))
|
| 388 |
+
metric_cols[1].metric("Display", _format_confidence(display_score))
|
| 389 |
+
metric_cols[2].metric("Source", confidence_source.replace("_", " ").title())
|
| 390 |
+
metric_cols[3].metric("Bucket", final_bucket.title() if final_bucket else "-")
|
| 391 |
+
|
| 392 |
+
if bonuses:
|
| 393 |
+
st.write("Bonuses")
|
| 394 |
+
for bonus in bonuses:
|
| 395 |
+
st.write(f"- +{float(bonus.get('value') or 0.0):.0f} {str(bonus.get('label') or '-').strip()}")
|
| 396 |
+
|
| 397 |
+
if penalties:
|
| 398 |
+
st.write("Penalties")
|
| 399 |
+
for penalty in penalties:
|
| 400 |
+
value = float(penalty.get("value") or 0.0)
|
| 401 |
+
prefix = f"-{value:.0f}" if value > 0 else "0"
|
| 402 |
+
st.write(f"- {prefix} {str(penalty.get('label') or '-').strip()}")
|
| 403 |
+
|
| 404 |
+
why_lines: list[str] = []
|
| 405 |
+
if summary_label:
|
| 406 |
+
why_lines.append(f"Primary driver: {summary_label}")
|
| 407 |
+
for reason in details_row.get("confidence_reasons") or []:
|
| 408 |
+
if str(reason).strip():
|
| 409 |
+
why_lines.append(str(reason).strip())
|
| 410 |
+
if why_lines:
|
| 411 |
+
st.write("Why It Landed Here")
|
| 412 |
+
for line in why_lines:
|
| 413 |
+
st.write(f"- {line}")
|
| 414 |
+
|
| 415 |
+
|
| 416 |
def _market_label(value: Any) -> str:
|
| 417 |
text = str(value or "").strip().lower()
|
| 418 |
labels = {
|
|
|
|
| 710 |
)
|
| 711 |
else:
|
| 712 |
st.session_state.pop("props_hr_health_debug", None)
|
| 713 |
+
st.session_state["props_shared_component_debug"] = _build_shared_component_debug(
|
| 714 |
+
mapped,
|
| 715 |
+
market_type=market_type,
|
| 716 |
+
)
|
| 717 |
|
| 718 |
return {
|
| 719 |
"baseline_request": baseline_request,
|
|
|
|
| 1114 |
return out
|
| 1115 |
|
| 1116 |
|
| 1117 |
+
def _build_shared_component_debug(display: pd.DataFrame, market_type: str) -> dict[str, Any]:
|
| 1118 |
+
if display is None or display.empty:
|
| 1119 |
+
return {"market_type": market_type, "rows": []}
|
| 1120 |
+
|
| 1121 |
+
cols = [
|
| 1122 |
+
"player_name",
|
| 1123 |
+
"player_name_raw",
|
| 1124 |
+
"display_label",
|
| 1125 |
+
"resolved_pitcher_name",
|
| 1126 |
+
"formula_version",
|
| 1127 |
+
"matchup_coverage_confidence",
|
| 1128 |
+
"damage_zone_alignment_subscore",
|
| 1129 |
+
"pitch_mix_exposure_subscore",
|
| 1130 |
+
"tunnel_damage_subscore",
|
| 1131 |
+
"count_pattern_damage_subscore",
|
| 1132 |
+
"handedness_damage_subscore",
|
| 1133 |
+
"arsenal_fit_subscore",
|
| 1134 |
+
"environment_amplification_subscore",
|
| 1135 |
+
"hr_opportunity_projection",
|
| 1136 |
+
"projected_pitch_count",
|
| 1137 |
+
"projected_batters_faced",
|
| 1138 |
+
"projected_innings",
|
| 1139 |
+
"projected_k_rate",
|
| 1140 |
+
"expected_strikeouts_v2",
|
| 1141 |
+
"zone_matchup_subscore",
|
| 1142 |
+
"family_zone_matchup_subscore",
|
| 1143 |
+
"tunneling_subscore",
|
| 1144 |
+
"release_consistency_subscore",
|
| 1145 |
+
"sequencing_subscore",
|
| 1146 |
+
"count_leverage_subscore",
|
| 1147 |
+
"leash_risk_subscore",
|
| 1148 |
+
"times_through_order_penalty",
|
| 1149 |
+
"variance_band_low",
|
| 1150 |
+
"variance_band_high",
|
| 1151 |
+
"predicted_attack_regions",
|
| 1152 |
+
"predicted_damage_regions",
|
| 1153 |
+
"predicted_whiff_regions",
|
| 1154 |
+
"component_source_map",
|
| 1155 |
+
]
|
| 1156 |
+
return {
|
| 1157 |
+
"market_type": market_type,
|
| 1158 |
+
"rows": display[[c for c in cols if c in display.columns]].head(30).to_dict("records"),
|
| 1159 |
+
}
|
| 1160 |
+
|
| 1161 |
+
|
| 1162 |
def render_props_hero(display_df: pd.DataFrame, view_model: dict[str, Any] | None = None) -> None:
|
| 1163 |
st.markdown(
|
| 1164 |
"""
|
|
|
|
| 1257 |
</div>
|
| 1258 |
<div>
|
| 1259 |
<div class="props-metric-label">Confidence</div>
|
| 1260 |
+
{_build_confidence_metric_html(row)}
|
| 1261 |
</div>
|
| 1262 |
</div>
|
| 1263 |
<div class="props-voice">
|
|
|
|
| 1337 |
</div>
|
| 1338 |
<div>
|
| 1339 |
<div class="props-metric-label">Confidence</div>
|
| 1340 |
+
{_build_confidence_metric_html(row)}
|
| 1341 |
</div>
|
| 1342 |
</div>
|
| 1343 |
<div class="props-voice">
|
|
|
|
| 1412 |
"model_voice_for",
|
| 1413 |
"model_voice_against",
|
| 1414 |
"confidence_score",
|
| 1415 |
+
"confidence_score_raw",
|
| 1416 |
+
"confidence_score_display",
|
| 1417 |
+
"confidence_source",
|
| 1418 |
"confidence_bucket",
|
| 1419 |
"confidence_reasons",
|
| 1420 |
+
"confidence_component_bonuses",
|
| 1421 |
+
"confidence_component_penalties",
|
| 1422 |
+
"confidence_primary_driver",
|
| 1423 |
+
"confidence_summary_label",
|
| 1424 |
"opportunity_hr_adjustment",
|
| 1425 |
"expected_pa",
|
| 1426 |
"lineup_slot_used",
|
|
|
|
| 1508 |
st.caption("Why this rating")
|
| 1509 |
for line in why_lines:
|
| 1510 |
st.write(f"- {line}")
|
| 1511 |
+
_render_confidence_breakdown(details.get("best_primary_row") or {})
|
| 1512 |
render_player_hr_details(details)
|
| 1513 |
st.divider()
|
| 1514 |
|