Run Expectancy Matrices

Advanced 10 min read 0 views Nov 26, 2025
# Run Expectancy Matrices: A Complete Guide to Baseball's Most Powerful Analytical Tool ## Introduction to Run Expectancy Run Expectancy (RE) is one of the fundamental concepts in baseball analytics that quantifies the offensive value of every possible game state. At its core, run expectancy answers a simple question: **Given the current base-out state, how many runs can we expect to score before the end of the inning?** This seemingly straightforward metric has revolutionized how we evaluate player performance, strategic decisions, and game situations. Unlike traditional statistics that focus on outcomes (hits, runs, RBIs), run expectancy provides a probabilistic framework for understanding the true value of every plate appearance and managerial decision. The power of run expectancy lies in its ability to: - Measure player contributions independent of context - Evaluate strategic decisions (bunts, stolen bases, intentional walks) - Compare players across different eras and environments - Identify situational hitting strengths and weaknesses - Provide a foundation for advanced metrics like WPA and RE24 ## The 24 Base-Out States Every offensive situation in baseball can be categorized into one of 24 distinct base-out states. These states are defined by two factors: 1. **Number of outs**: 0, 1, or 2 outs 2. **Base occupancy**: 8 possible configurations (empty, runner on 1st, 2nd, 3rd, 1st & 2nd, 1st & 3rd, 2nd & 3rd, bases loaded) ### The 24 States Matrix | State | Bases | 0 Outs | 1 Out | 2 Outs | |-------|-------|--------|-------|--------| | 1 | Empty | High | Medium | Low | | 2 | 1st | Higher | Medium+ | Low+ | | 3 | 2nd | Higher+ | High | Medium | | 4 | 3rd | Highest | High+ | Medium+ | | 5 | 1st & 2nd | Very High | High | Medium | | 6 | 1st & 3rd | Very High+ | High+ | Medium+ | | 7 | 2nd & 3rd | Highest+ | Very High | High | | 8 | Loaded | Maximum | Very High+ | High+ | Each state has a specific run expectancy value that changes based on the number of outs. Understanding these values is crucial for evaluating player performance and strategic decisions. ## How Run Expectancy Matrices Are Calculated Run expectancy matrices are built from historical play-by-play data using a straightforward empirical approach: ### Calculation Method 1. **Collect play-by-play data** for a large sample (typically multiple seasons) 2. **Identify each base-out state** at the start of every plate appearance 3. **Track runs scored** from that point until the end of the inning 4. **Calculate the average** runs scored for each state across all occurrences ### Mathematical Formula For each base-out state (s): ``` RE(s) = Σ(runs scored from state s to end of inning) / count(occurrences of state s) ``` ### Example Calculation Suppose we observe the state "Runner on 1st, 0 outs" 10,000 times in our dataset: - 2,000 times: 0 runs scored that inning - 3,500 times: 1 run scored - 2,500 times: 2 runs scored - 1,500 times: 3 runs scored - 500 times: 4+ runs scored (averaging 4.5 runs) ``` RE = (2000×0 + 3500×1 + 2500×2 + 1500×3 + 500×4.5) / 10000 RE = (0 + 3500 + 5000 + 4500 + 2250) / 10000 RE = 15,250 / 10,000 = 1.525 runs ``` ### Key Considerations - **Sample size matters**: Larger datasets provide more stable estimates - **Era adjustments**: Offensive environments change over time - **Park factors**: Can be adjusted for stadium effects - **Leverage**: Different states have different variability (standard deviation) ## Historical Run Expectancy Tables Run expectancy values have changed significantly throughout baseball history due to shifts in offensive levels, rule changes, and strategic evolution. ### 2010-2019 MLB Run Expectancy Matrix | Bases | 0 Outs | 1 Out | 2 Outs | |-------|--------|-------|--------| | Empty | 0.481 | 0.254 | 0.098 | | 1st | 0.859 | 0.509 | 0.214 | | 2nd | 1.100 | 0.664 | 0.315 | | 3rd | 1.356 | 0.950 | 0.348 | | 1st & 2nd | 1.437 | 0.908 | 0.429 | | 1st & 3rd | 1.784 | 1.140 | 0.494 | | 2nd & 3rd | 1.964 | 1.352 | 0.580 | | Loaded | 2.292 | 1.541 | 0.736 | ### 2020-2024 MLB Run Expectancy Matrix | Bases | 0 Outs | 1 Out | 2 Outs | |-------|--------|-------|--------| | Empty | 0.466 | 0.247 | 0.095 | | 1st | 0.831 | 0.493 | 0.207 | | 2nd | 1.068 | 0.644 | 0.305 | | 3rd | 1.314 | 0.921 | 0.337 | | 1st & 2nd | 1.393 | 0.880 | 0.416 | | 1st & 3rd | 1.729 | 1.105 | 0.479 | | 2nd & 3rd | 1.904 | 1.311 | 0.562 | | Loaded | 2.221 | 1.494 | 0.713 | ### Historical Trends **Deadball Era (1901-1919)**: RE values were significantly lower, with bases empty/0 outs around 0.35-0.40 runs **Live Ball Era (1920-1941)**: Dramatic increase to 0.50-0.55 runs for the same state **Integration & Expansion (1947-1968)**: Gradual stabilization around 0.48-0.52 runs **Lowered Mound (1969-1992)**: Increased to 0.52-0.56 runs **Steroid Era (1993-2006)**: Peak values of 0.54-0.58 runs **Post-Steroid (2007-2014)**: Decline to 0.48-0.50 runs **Launch Angle Era (2015-2019)**: Slight increase to 0.48-0.52 runs **Modern Era (2020+)**: Variable based on rule changes (pitch clock, shift ban) ## The Run Expectancy (RE24) Metric RE24 is a player evaluation metric that measures a batter's contribution to their team's run-scoring potential relative to the average player. ### How RE24 Works For each plate appearance: 1. Record the base-out state **before** the PA 2. Record the base-out state **after** the PA 3. Calculate the change in run expectancy 4. Add any runs scored during the PA **Formula**: `RE24 = RE(state after) - RE(state before) + Runs Scored` ### Example Calculation **Situation**: Runner on 1st, 1 out (RE = 0.509) **Outcome**: Double, runner scores **New State**: Runner on 2nd, 1 out (RE = 0.664) **Runs**: 1 **RE24 value**: 0.664 - 0.509 + 1 = **+1.155 runs** ### RE24 for Different Outcomes Using 2010-2019 matrix (bases empty, 0 outs, RE = 0.481): | Outcome | New State | New RE | Runs | RE24 | |---------|-----------|--------|------|------| | Single | 1st, 0 out | 0.859 | 0 | +0.378 | | Double | 2nd, 0 out | 1.100 | 0 | +0.619 | | Triple | 3rd, 0 out | 1.356 | 0 | +0.875 | | Home Run | Empty, 0 out | 0.481 | 1 | +1.000 | | Walk | 1st, 0 out | 0.859 | 0 | +0.378 | | Strikeout | Empty, 1 out | 0.254 | 0 | -0.227 | | Groundout | Empty, 1 out | 0.254 | 0 | -0.227 | | Flyout | Empty, 1 out | 0.254 | 0 | -0.227 | ### Interpreting RE24 - **Positive RE24**: Player increased their team's run-scoring potential - **Negative RE24**: Player decreased run-scoring potential - **Season totals**: Sum of all PA values shows total runs contributed above average - **League average**: Approximately 0 by definition (it's a zero-sum statistic) ## RE24 vs WPA: Understanding the Difference While both RE24 and Win Probability Added (WPA) measure player contributions, they serve different purposes and use different contexts. ### Run Expectancy (RE24) **What it measures**: Change in expected runs **Context**: Base-out state only **When constant**: Assumes all game situations equal (1st inning = 9th inning) **Scale**: Measured in runs **Use case**: Evaluating true offensive contribution independent of game leverage **Example**: A solo HR in the 1st inning of a 10-0 game = +1.00 RE24 ### Win Probability Added (WPA) **What it measures**: Change in win probability **Context**: Score, inning, base-out state **When matters**: Emphasizes high-leverage situations **Scale**: Measured in win probability (0-1 or 0-100%) **Use case**: Identifying clutch performance and game-changing moments **Example**: A solo HR in the 1st inning of a 10-0 game ≈ +0.01 WPA (minimal impact) ### Comparison Table | Aspect | RE24 | WPA | |--------|------|-----| | Context dependency | Low | High | | Leverage weighting | None | Heavy | | Seasonal stability | High | Medium | | Predictive value | Higher | Lower | | "Clutch" measurement | No | Yes | | Best for evaluation | Yes | No | | Best for narrative | No | Yes | ### When to Use Each **Use RE24 when**: - Evaluating true offensive talent - Comparing players across different team contexts - Predicting future performance - Analyzing situational hitting independent of leverage **Use WPA when**: - Identifying the most important moments in a game - Evaluating performance in high-pressure situations - Creating compelling narratives about season highlights - Understanding who contributed most to actual wins ## Situational Hitting Evaluation Run expectancy provides a sophisticated framework for evaluating how players perform in different game situations. ### Context-Specific RE24 Players can be evaluated by splitting their RE24 by situation: **By Base State**: - Bases empty - Runner(s) in scoring position (2nd/3rd) - Runners on base (any) - Bases loaded **By Out State**: - 0 outs - 1 out - 2 outs **By Leverage**: - High leverage (close game, late innings) - Medium leverage - Low leverage (blowouts) ### Expected vs Actual Performance A powerful application is comparing actual RE24 to expected RE24 based on a player's overall performance: 1. Calculate player's overall offensive level (wOBA, wRC+, etc.) 2. Determine expected RE24 for each situation based on this level 3. Compare actual RE24 to expected in each context 4. Identify strengths and weaknesses ### Example Analysis **Player A - Season RE24 Split**: | Situation | PA | RE24 | RE24/PA | Expected | Difference | |-----------|-----|------|---------|----------|------------| | Bases Empty | 350 | +8.5 | +0.024 | +7.0 | +1.5 | | RISP | 180 | +15.2 | +0.084 | +9.0 | +6.2 | | 2 Outs RISP | 75 | +7.8 | +0.104 | +3.8 | +4.0 | **Interpretation**: Player A significantly outperforms expectations with runners in scoring position, suggesting genuine situational hitting skill or mental toughness. ### Common Misconceptions **Myth**: "Clutch hitting" is purely psychological and doesn't exist **Reality**: Small but measurable differences exist, though smaller than traditional stats suggest **Myth**: RE24 completely controls for situation **Reality**: RE24 measures contribution in situations encountered; players still face different frequencies **Myth**: High RE24 with RISP means a player is "clutch" **Reality**: Must compare to expected values; good hitters naturally have higher RE24 everywhere ## Sacrifice Bunt Analysis Using Run Expectancy Run expectancy provides objective analysis of when sacrifice bunts make strategic sense. ### The Cost of the Bunt A successful sacrifice bunt trades an out for advancing runners. Let's analyze the trade-off: **Scenario**: Runner on 1st, 0 outs (RE = 0.859) **Successful Bunt**: Runner on 2nd, 1 out (RE = 0.664) **Change**: 0.664 - 0.859 = **-0.195 runs** The bunt **decreases** run expectancy by about 0.2 runs on average, even when successful. ### Comprehensive Bunt Analysis | Before State | RE | After State | RE | Change | Run Cost | |-------------|-----|-------------|-----|--------|----------| | 1st, 0 out | 0.859 | 2nd, 1 out | 0.664 | -0.195 | -0.195 | | 2nd, 0 out | 1.100 | 3rd, 1 out | 0.950 | -0.150 | -0.150 | | 1st & 2nd, 0 out | 1.437 | 2nd & 3rd, 1 out | 1.352 | -0.085 | -0.085 | | 1st, 1 out | 0.509 | 2nd, 2 out | 0.315 | -0.194 | -0.194 | **Key Insight**: Sacrifice bunts almost always decrease run expectancy but may increase probability of scoring at least one run. ### When Bunts Make Sense **Situation 1: Tie game, late innings, weak hitter** The goal shifts from maximizing runs to scoring one run. While RE decreases, probability of scoring ≥1 run may increase: - Runner on 2nd, 1 out: ~63% chance of scoring - Runner on 1st, 0 outs: ~40% chance of scoring (weak hitter) **Situation 2: Pitcher batting (pre-DH National League)** With expected RE24 of approximately -0.25 for pitcher PAs, a successful bunt (-0.195) is actually an improvement. **Situation 3: Elite speed on first base** Bunting for a hit changes the calculus entirely, as it's no longer a sacrifice. ### Failed Bunt Consequences Failed bunts are devastating: **Runner on 1st, 0 outs to Empty, 1 out**: 0.859 → 0.254 = **-0.605 runs** Even with 80% success rate: - Expected value: (0.80 × -0.195) + (0.20 × -0.605) = -0.277 runs This is worse than even a poor hitter's expected contribution. ## Stolen Base Break-Even Points Run expectancy enables precise calculation of when stolen base attempts are worthwhile. ### The Stolen Base Trade-Off **Successful Steal**: - Runner on 1st, 0 outs (RE = 0.859) - Runner on 2nd, 0 outs (RE = 1.100) - Gain: +0.241 runs **Caught Stealing**: - Runner on 1st, 0 outs (RE = 0.859) - Empty, 1 out (RE = 0.254) - Loss: -0.605 runs ### Break-Even Calculation For the attempt to be neutral in expectation: ``` Success% × Gain + (1 - Success%) × Loss = 0 Success% × 0.241 + (1 - Success%) × (-0.605) = 0 0.241 × Success% - 0.605 + 0.605 × Success% = 0 0.846 × Success% = 0.605 Success% = 0.715 or 71.5% ``` **Break-even point**: Must succeed approximately **71-72%** of the time for the attempt to be worthwhile. ### Break-Even Table by Situation | Situation | Gain (Success) | Loss (CS) | Break-Even % | |-----------|----------------|-----------|--------------| | 1st to 2nd, 0 out | +0.241 | -0.605 | 71.5% | | 1st to 2nd, 1 out | +0.155 | -0.255 | 62.2% | | 1st to 2nd, 2 out | +0.101 | -0.116 | 53.5% | | 2nd to 3rd, 0 out | +0.256 | -0.619 | 70.7% | | 2nd to 3rd, 1 out | +0.286 | -0.410 | 58.9% | | 2nd to 3rd, 2 out | +0.033 | -0.217 | 86.8% | ### Key Insights 1. **Two outs is complicated**: Lower break-even with 2 outs and runner on 1st (easier to justify), but much higher with runner on 2nd (rarely worth it) 2. **Context matters**: Break-even points change based on: - Batter quality (better hitter = less worth stealing) - Pitcher quality (worse pitcher = less worth stealing) - Catcher arm strength - Game situation (tied late = prioritize not making outs) 3. **Historical success rates**: League average SB% is typically 70-75%, right near break-even 4. **Elite base stealers**: Players with 85%+ success rates create significant value ### Advanced Considerations **Game theory**: Stolen base threats change pitcher behavior (more fastballs, quicker delivery), which may benefit the hitter **Double steals**: Can have higher break-even points due to larger potential gains **Delayed steals**: Same mathematics but different probability of success **First-to-third on singles**: Often overlooked but similar run expectancy principles apply ## Building Your Own Run Expectancy Matrix Creating a custom RE matrix allows you to analyze specific contexts: particular seasons, parks, leagues, or even game states. ### Data Requirements **Minimum data needed**: - Game ID and inning number - Outs before and after each play - Base state before and after each play - Runs scored on each play - Final runs in the inning **Ideal additional data**: - Score differential - Home/away team - Park factors - Weather conditions - Batter/pitcher handedness ### Step-by-Step Construction Process **Step 1: Data Collection** - Obtain play-by-play data (Retrosheet, Baseball Savant, proprietary sources) - Parse into structured format with clear state transitions **Step 2: State Encoding** - Encode base states (0-7 for eight configurations) - Track outs (0, 1, 2) - Create 24 state buckets **Step 3: Run Tracking** - For each PA, calculate runs scored from that point to end of inning - Track both immediate runs and future runs in same inning **Step 4: Aggregation** - Group all PAs by their starting state - Calculate mean runs scored for each state - Calculate standard deviation for uncertainty estimates **Step 5: Validation** - Verify totals sum correctly - Check for anomalies or data quality issues - Compare to published matrices for reasonableness ## Python Code Examples ### Building RE Matrix from Play-by-Play Data ```python import pandas as pd import numpy as np from collections import defaultdict class RunExpectancyMatrix: def __init__(self): self.states = {} self.base_out_states = [] def encode_state(self, outs, runners): """ Encode base-out state as integer 0-23 outs: 0, 1, or 2 runners: tuple of (on_1st, on_2nd, on_3rd) as boolean """ base_code = (runners[0] * 1 + runners[1] * 2 + runners[2] * 4) return outs * 8 + base_code def decode_state(self, state_code): """Decode state code back to outs and runners""" outs = state_code // 8 base_code = state_code % 8 runners = ( bool(base_code & 1), bool(base_code & 2), bool(base_code & 4) ) return outs, runners def build_matrix(self, play_by_play_df): """ Build RE matrix from play-by-play data Expected DataFrame columns: - game_id: unique game identifier - inning: inning number - outs_before: outs before play (0-2) - on_1b_before, on_2b_before, on_3b_before: boolean - runs_on_play: runs scored on this play - is_inning_end: boolean indicating last play of inning """ # Initialize state tracking state_runs = defaultdict(list) # Group by game and inning for (game_id, inning), inning_data in play_by_play_df.groupby(['game_id', 'inning']): inning_data = inning_data.sort_values('play_id') # Calculate remaining runs for each play total_runs = inning_data['runs_on_play'].sum() for idx, play in inning_data.iterrows(): # Get state before this play state = self.encode_state( play['outs_before'], (play['on_1b_before'], play['on_2b_before'], play['on_3b_before']) ) # Calculate runs from this point to end of inning remaining_plays = inning_data.loc[idx:] runs_remaining = remaining_plays['runs_on_play'].sum() # Store runs for this state state_runs[state].append(runs_remaining) # Calculate average for each state re_matrix = {} for state in range(24): if state in state_runs: re_matrix[state] = { 'mean': np.mean(state_runs[state]), 'std': np.std(state_runs[state]), 'count': len(state_runs[state]) } else: re_matrix[state] = { 'mean': 0.0, 'std': 0.0, 'count': 0 } self.matrix = re_matrix return re_matrix def get_re(self, outs, runners): """Get run expectancy for a given state""" state = self.encode_state(outs, runners) return self.matrix[state]['mean'] def to_dataframe(self): """Convert matrix to readable DataFrame""" base_states = [ 'Empty', '1st', '2nd', '1st_2nd', '3rd', '1st_3rd', '2nd_3rd', 'Loaded' ] data = [] for outs in range(3): for base_code in range(8): state = outs * 8 + base_code data.append({ 'Outs': outs, 'Bases': base_states[base_code], 'RE': self.matrix[state]['mean'], 'StdDev': self.matrix[state]['std'], 'Count': self.matrix[state]['count'] }) df = pd.DataFrame(data) # Pivot for traditional matrix view pivot = df.pivot(index='Bases', columns='Outs', values='RE') return pivot # Example usage def load_retrosheet_data(file_path): """Load and parse Retrosheet event files""" # This is a simplified example # Actual Retrosheet parsing is more complex df = pd.read_csv(file_path) return df # Build matrix re_calculator = RunExpectancyMatrix() pbp_data = load_retrosheet_data('events_2023.csv') matrix = re_calculator.build_matrix(pbp_data) # Display results print(re_calculator.to_dataframe()) ``` ### Calculating RE24 for Individual Players ```python class RE24Calculator: def __init__(self, re_matrix): self.re_matrix = re_matrix def calculate_re24(self, play_data): """ Calculate RE24 for a single play play_data should contain: - outs_before, outs_after - bases_before, bases_after (as tuples) - runs_scored """ # Get RE before and after state_before = self.re_matrix.encode_state( play_data['outs_before'], play_data['bases_before'] ) # Handle end of inning (3 outs) if play_data['outs_after'] >= 3: re_after = 0.0 else: state_after = self.re_matrix.encode_state( play_data['outs_after'], play_data['bases_after'] ) re_after = self.re_matrix.get_re( play_data['outs_after'], play_data['bases_after'] ) re_before = self.re_matrix.get_re( play_data['outs_before'], play_data['bases_before'] ) # Calculate RE24 re24 = re_after - re_before + play_data['runs_scored'] return re24 def calculate_player_season(self, player_data): """Calculate total RE24 for a player's season""" total_re24 = 0.0 play_results = [] for _, play in player_data.iterrows(): re24 = self.calculate_re24(play) total_re24 += re24 play_results.append({ 'date': play['date'], 'opponent': play['opponent'], 'result': play['play_result'], 're24': re24 }) return { 'total_re24': total_re24, 're24_per_pa': total_re24 / len(player_data), 'plays': play_results } def leaderboard(self, all_players_data, min_pa=300): """Generate RE24 leaderboard""" results = [] for player_id, player_data in all_players_data.groupby('player_id'): if len(player_data) >= min_pa: season_stats = self.calculate_player_season(player_data) results.append({ 'player_id': player_id, 'player_name': player_data.iloc[0]['player_name'], 'pa': len(player_data), 'total_re24': season_stats['total_re24'], 're24_per_pa': season_stats['re24_per_pa'] }) df = pd.DataFrame(results) return df.sort_values('total_re24', ascending=False) # Example usage re_calc = RE24Calculator(re_calculator) # Single play example play = { 'outs_before': 0, 'bases_before': (False, False, False), 'outs_after': 0, 'bases_after': (False, True, False), 'runs_scored': 0 } print(f"RE24 for double: {re_calc.calculate_re24(play):.3f}") # Season calculation player_pbp = pbp_data[pbp_data['batter_id'] == 'trout001'] season_stats = re_calc.calculate_player_season(player_pbp) print(f"Mike Trout RE24: {season_stats['total_re24']:.1f}") ``` ### Analyzing Strategic Decisions ```python class StrategyAnalyzer: def __init__(self, re_matrix): self.re_matrix = re_matrix def analyze_bunt(self, outs, runners, success_rate=0.80): """ Analyze sacrifice bunt decision Returns expected value and recommendation """ # Current state RE current_re = self.re_matrix.get_re(outs, runners) # Success: advance runners, add out if outs >= 2: # Can't bunt with 2 outs return { 'recommendation': 'NEVER', 'reason': 'Cannot sacrifice with 2 outs' } # Determine new runner state after successful bunt new_runners = self._advance_on_bunt(runners) success_re = self.re_matrix.get_re(outs + 1, new_runners) # Failure: usually out at first, runners stay # Simplified: assume most failures are just an out failure_re = self.re_matrix.get_re(outs + 1, runners) # Expected value expected_re = (success_rate * success_re + (1 - success_rate) * failure_re) ev_change = expected_re - current_re # Determine recommendation if ev_change > -0.05: rec = 'ACCEPTABLE' elif ev_change > -0.15: rec = 'QUESTIONABLE' else: rec = 'NOT RECOMMENDED' return { 'current_re': current_re, 'expected_re': expected_re, 'ev_change': ev_change, 'success_re': success_re, 'failure_re': failure_re, 'recommendation': rec, 'break_even': self._calculate_breakeven( current_re, success_re, failure_re ) } def _advance_on_bunt(self, runners): """Determine runner positions after successful bunt""" on_1b, on_2b, on_3b = runners # Simplified logic: runners advance one base # (In reality, this depends on specific situation) if on_1b and not on_2b: return (False, True, on_3b) elif on_2b and not on_3b: return (on_1b, False, True) elif on_1b and on_2b: return (False, True, True) else: return runners # Can't really bunt here def _calculate_breakeven(self, current_re, success_re, failure_re): """Calculate break-even success rate""" if success_re == failure_re: return None # current_re = p*success_re + (1-p)*failure_re # Solve for p breakeven = ((current_re - failure_re) / (success_re - failure_re)) return max(0, min(1, breakeven)) def analyze_steal(self, outs, runners, steal_success_rate=0.75): """Analyze stolen base attempt""" on_1b, on_2b, on_3b = runners if not on_1b and not on_2b: return {'recommendation': 'NO RUNNER', 'reason': 'No steal possible'} # Determine which base is being stolen if on_1b and not on_2b: # Stealing second success_runners = (False, True, on_3b) failure_runners = (False, on_2b, on_3b) base_stolen = '2nd' elif on_2b and not on_3b: # Stealing third success_runners = (on_1b, False, True) failure_runners = (on_1b, False, on_3b) base_stolen = '3rd' else: return {'recommendation': 'COMPLEX', 'reason': 'Multiple runners'} # Calculate expectancies current_re = self.re_matrix.get_re(outs, runners) success_re = self.re_matrix.get_re(outs, success_runners) # Caught stealing: add an out if outs >= 2: failure_re = 0.0 # Inning over else: failure_re = self.re_matrix.get_re(outs + 1, failure_runners) expected_re = (steal_success_rate * success_re + (1 - steal_success_rate) * failure_re) ev_change = expected_re - current_re break_even = self._calculate_breakeven(current_re, success_re, failure_re) if steal_success_rate >= break_even + 0.05: rec = 'RECOMMENDED' elif steal_success_rate >= break_even: rec = 'ACCEPTABLE' else: rec = 'NOT RECOMMENDED' return { 'base_stolen': base_stolen, 'current_re': current_re, 'expected_re': expected_re, 'ev_change': ev_change, 'success_rate': steal_success_rate, 'break_even': break_even, 'recommendation': rec } def compare_strategies(self, outs, runners, hitter_skill='average'): """Compare multiple strategic options""" # Define expected values for different hitter skills hitter_values = { 'poor': -0.10, # Pitcher, weak hitter 'below_avg': -0.03, 'average': 0.00, 'above_avg': 0.03, 'good': 0.06, 'elite': 0.10 } current_re = self.re_matrix.get_re(outs, runners) hitter_ev = current_re + hitter_values.get(hitter_skill, 0) strategies = { 'swing_away': hitter_ev } # Analyze bunt if applicable if outs < 2 and (runners[0] or runners[1]): bunt_analysis = self.analyze_bunt(outs, runners) strategies['sacrifice_bunt'] = bunt_analysis['expected_re'] # Analyze steal if applicable if (runners[0] or runners[1]) and not (runners[0] and runners[1]): steal_analysis = self.analyze_steal(outs, runners) strategies['stolen_base'] = steal_analysis['expected_re'] # Find best strategy best_strategy = max(strategies.items(), key=lambda x: x[1]) return { 'strategies': strategies, 'best_strategy': best_strategy[0], 'best_ev': best_strategy[1], 'current_re': current_re } # Example usage analyzer = StrategyAnalyzer(re_calculator) # Should we bunt? bunt_decision = analyzer.analyze_bunt( outs=0, runners=(True, False, False), success_rate=0.85 ) print(f"Bunt recommendation: {bunt_decision['recommendation']}") print(f"EV change: {bunt_decision['ev_change']:.3f} runs") print(f"Break-even success rate: {bunt_decision['break_even']:.1%}") # Should we steal? steal_decision = analyzer.analyze_steal( outs=1, runners=(True, False, False), steal_success_rate=0.78 ) print(f"\nSteal recommendation: {steal_decision['recommendation']}") print(f"EV change: {steal_decision['ev_change']:.3f} runs") print(f"Break-even success rate: {steal_decision['break_even']:.1%}") ``` ### Visualizing RE Matrix as Heatmap ```python import matplotlib.pyplot as plt import seaborn as sns def plot_re_matrix(re_matrix_obj, season_label="2023"): """Create heatmap visualization of RE matrix""" # Get matrix as DataFrame df = re_matrix_obj.to_dataframe() # Reorder bases for better visualization base_order = ['Empty', '1st', '2nd', '3rd', '1st_2nd', '1st_3rd', '2nd_3rd', 'Loaded'] df = df.reindex(base_order) # Create figure fig, ax = plt.subplots(figsize=(10, 8)) # Create heatmap sns.heatmap(df, annot=True, fmt='.3f', cmap='RdYlGn', center=0.7, vmin=0, vmax=2.5, cbar_kws={'label': 'Run Expectancy'}, ax=ax) ax.set_title(f'Run Expectancy Matrix - {season_label}', fontsize=16, fontweight='bold') ax.set_xlabel('Outs', fontsize=12) ax.set_ylabel('Base State', fontsize=12) plt.tight_layout() return fig def plot_re_comparison(re_matrix_1, re_matrix_2, label1="2010-2019", label2="2020-2024"): """Compare two RE matrices""" df1 = re_matrix_1.to_dataframe() df2 = re_matrix_2.to_dataframe() # Calculate difference diff = df2 - df1 base_order = ['Empty', '1st', '2nd', '3rd', '1st_2nd', '1st_3rd', '2nd_3rd', 'Loaded'] diff = diff.reindex(base_order) fig, ax = plt.subplots(figsize=(10, 8)) sns.heatmap(diff, annot=True, fmt='.3f', cmap='RdBu_r', center=0, vmin=-0.1, vmax=0.1, cbar_kws={'label': 'RE Difference'}, ax=ax) ax.set_title(f'Run Expectancy Change: {label2} vs {label1}', fontsize=16, fontweight='bold') ax.set_xlabel('Outs', fontsize=12) ax.set_ylabel('Base State', fontsize=12) plt.tight_layout() return fig def plot_player_re24_distribution(player_data, re_calculator, player_name): """Plot distribution of RE24 values for a player""" re24_values = [] for _, play in player_data.iterrows(): re24 = re_calculator.calculate_re24(play) re24_values.append(re24) fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5)) # Histogram ax1.hist(re24_values, bins=50, edgecolor='black', alpha=0.7) ax1.axvline(np.mean(re24_values), color='red', linestyle='--', linewidth=2, label=f'Mean: {np.mean(re24_values):.3f}') ax1.set_xlabel('RE24 per PA', fontsize=12) ax1.set_ylabel('Frequency', fontsize=12) ax1.set_title(f'{player_name} - RE24 Distribution', fontsize=14) ax1.legend() ax1.grid(alpha=0.3) # Cumulative sorted_re24 = sorted(re24_values) cumulative = np.arange(1, len(sorted_re24) + 1) / len(sorted_re24) ax2.plot(sorted_re24, cumulative, linewidth=2) ax2.axvline(0, color='red', linestyle='--', alpha=0.5, label='Zero RE24') ax2.set_xlabel('RE24 per PA', fontsize=12) ax2.set_ylabel('Cumulative Probability', fontsize=12) ax2.set_title(f'{player_name} - Cumulative RE24', fontsize=14) ax2.legend() ax2.grid(alpha=0.3) plt.tight_layout() return fig # Example usage fig1 = plot_re_matrix(re_calculator, "2023 MLB") fig1.savefig('re_matrix_2023.png', dpi=300, bbox_inches='tight') # Compare eras # re_matrix_2010s = RunExpectancyMatrix() # re_matrix_2020s = RunExpectancyMatrix() # ... build matrices ... # fig2 = plot_re_comparison(re_matrix_2010s, re_matrix_2020s) # fig2.savefig('re_matrix_comparison.png', dpi=300, bbox_inches='tight') ``` ## R Code Examples ### Building RE Matrix in R ```r library(tidyverse) library(data.table) # Function to encode base-out state encode_state <- function(outs, on_1b, on_2b, on_3b) { base_code <- on_1b * 1 + on_2b * 2 + on_3b * 4 state_code <- outs * 8 + base_code return(state_code) } # Function to decode state decode_state <- function(state_code) { outs <- state_code %/% 8 base_code <- state_code %% 8 list( outs = outs, on_1b = base_code %% 2 == 1, on_2b = (base_code %/% 2) %% 2 == 1, on_3b = (base_code %/% 4) %% 2 == 1 ) } # Build RE matrix from play-by-play data build_re_matrix <- function(pbp_data) { # Ensure data is sorted by game, inning, and play sequence pbp_data <- pbp_data %>% arrange(game_id, inning, play_id) # Calculate runs remaining in each inning pbp_data <- pbp_data %>% group_by(game_id, inning) %>% mutate( total_inning_runs = sum(runs_on_play), runs_remaining = total_inning_runs - cumsum(runs_on_play) + runs_on_play ) %>% ungroup() # Encode states pbp_data <- pbp_data %>% mutate( state = encode_state(outs_before, on_1b_before, on_2b_before, on_3b_before) ) # Calculate RE for each state re_matrix <- pbp_data %>% group_by(state) %>% summarise( re = mean(runs_remaining), sd = sd(runs_remaining), count = n(), .groups = 'drop' ) # Decode states for readability re_matrix <- re_matrix %>% rowwise() %>% mutate( state_info = list(decode_state(state)) ) %>% unnest_wider(state_info) return(re_matrix) } # Convert to traditional matrix format format_re_matrix <- function(re_matrix) { # Create base state labels re_matrix <- re_matrix %>% mutate( base_state = case_when( !on_1b & !on_2b & !on_3b ~ "Empty", on_1b & !on_2b & !on_3b ~ "1st", !on_1b & on_2b & !on_3b ~ "2nd", !on_1b & !on_2b & on_3b ~ "3rd", on_1b & on_2b & !on_3b ~ "1st_2nd", on_1b & !on_2b & on_3b ~ "1st_3rd", !on_1b & on_2b & on_3b ~ "2nd_3rd", on_1b & on_2b & on_3b ~ "Loaded" ) ) # Pivot to traditional matrix re_pivot <- re_matrix %>% select(base_state, outs, re) %>% pivot_wider(names_from = outs, values_from = re, names_prefix = "outs_") return(re_pivot) } # Example usage # pbp_data <- read_csv("retrosheet_2023.csv") # re_matrix <- build_re_matrix(pbp_data) # re_pivot <- format_re_matrix(re_matrix) # print(re_pivot) ``` ### Calculating RE24 in R ```r # Calculate RE24 for individual plays calculate_re24 <- function(pbp_data, re_matrix) { # Encode before and after states pbp_data <- pbp_data %>% mutate( state_before = encode_state(outs_before, on_1b_before, on_2b_before, on_3b_before), state_after = encode_state( pmin(outs_after, 3), # Cap at 3 for end of inning on_1b_after, on_2b_after, on_3b_after ) ) # Join RE values pbp_data <- pbp_data %>% left_join(re_matrix %>% select(state, re_before = re), by = c("state_before" = "state")) %>% left_join(re_matrix %>% select(state, re_after = re), by = c("state_after" = "state")) # Handle end of inning (3 outs) pbp_data <- pbp_data %>% mutate( re_after = if_else(outs_after >= 3, 0, re_after), re24 = re_after - re_before + runs_scored ) return(pbp_data) } # Calculate player season RE24 player_re24_season <- function(pbp_data, player_id, min_pa = 0) { player_data <- pbp_data %>% filter(batter_id == player_id) if (nrow(player_data) < min_pa) { return(NULL) } summary <- player_data %>% summarise( pa = n(), total_re24 = sum(re24, na.rm = TRUE), mean_re24 = mean(re24, na.rm = TRUE), median_re24 = median(re24, na.rm = TRUE), sd_re24 = sd(re24, na.rm = TRUE), .groups = 'drop' ) return(summary) } # Create RE24 leaderboard create_re24_leaderboard <- function(pbp_data, min_pa = 300) { leaderboard <- pbp_data %>% group_by(batter_id, batter_name) %>% summarise( pa = n(), total_re24 = sum(re24, na.rm = TRUE), re24_per_pa = mean(re24, na.rm = TRUE), .groups = 'drop' ) %>% filter(pa >= min_pa) %>% arrange(desc(total_re24)) return(leaderboard) } ``` ### Strategic Decision Analysis in R ```r # Analyze sacrifice bunt decision analyze_bunt <- function(re_matrix, outs, on_1b, on_2b, on_3b, success_rate = 0.80) { if (outs >= 2) { return(list( recommendation = "NEVER", reason = "Cannot sacrifice with 2 outs" )) } # Current state RE current_state <- encode_state(outs, on_1b, on_2b, on_3b) current_re <- re_matrix %>% filter(state == current_state) %>% pull(re) # Success state (advance runners, add out) # Simplified: runner on 1st moves to 2nd, runner on 2nd moves to 3rd success_state <- encode_state( outs + 1, FALSE, # Batter out on_1b | on_2b, # Someone moves to 2nd on_2b | on_3b # Someone moves to 3rd ) success_re <- re_matrix %>% filter(state == success_state) %>% pull(re) # Failure state (out, runners stay) failure_state <- encode_state(outs + 1, on_1b, on_2b, on_3b) failure_re <- re_matrix %>% filter(state == failure_state) %>% pull(re) # Expected value expected_re <- success_rate * success_re + (1 - success_rate) * failure_re ev_change <- expected_re - current_re # Break-even calculation if (success_re != failure_re) { break_even <- (current_re - failure_re) / (success_re - failure_re) } else { break_even <- NA } # Recommendation recommendation <- case_when( ev_change > -0.05 ~ "ACCEPTABLE", ev_change > -0.15 ~ "QUESTIONABLE", TRUE ~ "NOT RECOMMENDED" ) return(list( current_re = current_re, expected_re = expected_re, ev_change = ev_change, success_re = success_re, failure_re = failure_re, break_even = break_even, recommendation = recommendation )) } # Analyze stolen base attempt analyze_steal <- function(re_matrix, outs, on_1b, on_2b, on_3b, success_rate = 0.75) { # Current state current_state <- encode_state(outs, on_1b, on_2b, on_3b) current_re <- re_matrix %>% filter(state == current_state) %>% pull(re) # Determine stolen base scenario if (on_1b & !on_2b) { # Stealing 2nd success_state <- encode_state(outs, FALSE, TRUE, on_3b) failure_state <- encode_state(min(outs + 1, 3), FALSE, FALSE, on_3b) base_stolen <- "2nd" } else if (on_2b & !on_3b) { # Stealing 3rd success_state <- encode_state(outs, on_1b, FALSE, TRUE) failure_state <- encode_state(min(outs + 1, 3), on_1b, FALSE, FALSE) base_stolen <- "3rd" } else { return(list(recommendation = "COMPLEX", reason = "Multiple runners")) } # Get RE values success_re <- re_matrix %>% filter(state == success_state) %>% pull(re) # Handle caught stealing ending inning if (outs + 1 >= 3) { failure_re <- 0 } else { failure_re <- re_matrix %>% filter(state == failure_state) %>% pull(re) } # Expected value expected_re <- success_rate * success_re + (1 - success_rate) * failure_re ev_change <- expected_re - current_re # Break-even break_even <- (current_re - failure_re) / (success_re - failure_re) # Recommendation recommendation <- case_when( success_rate >= break_even + 0.05 ~ "RECOMMENDED", success_rate >= break_even ~ "ACCEPTABLE", TRUE ~ "NOT RECOMMENDED" ) return(list( base_stolen = base_stolen, current_re = current_re, expected_re = expected_re, ev_change = ev_change, success_rate = success_rate, break_even = break_even, recommendation = recommendation )) } ``` ### Visualization in R ```r library(ggplot2) library(viridis) # Visualize RE matrix as heatmap plot_re_matrix_heatmap <- function(re_matrix, title = "Run Expectancy Matrix") { # Prepare data plot_data <- re_matrix %>% mutate( base_state = case_when( !on_1b & !on_2b & !on_3b ~ "Empty", on_1b & !on_2b & !on_3b ~ "1st", !on_1b & on_2b & !on_3b ~ "2nd", !on_1b & !on_2b & on_3b ~ "3rd", on_1b & on_2b & !on_3b ~ "1st & 2nd", on_1b & !on_2b & on_3b ~ "1st & 3rd", !on_1b & on_2b & on_3b ~ "2nd & 3rd", on_1b & on_2b & on_3b ~ "Loaded" ), base_state = factor(base_state, levels = c( "Empty", "1st", "2nd", "3rd", "1st & 2nd", "1st & 3rd", "2nd & 3rd", "Loaded" )) ) # Create heatmap ggplot(plot_data, aes(x = factor(outs), y = base_state, fill = re)) + geom_tile(color = "white", size = 1) + geom_text(aes(label = sprintf("%.3f", re)), color = "black", size = 5, fontface = "bold") + scale_fill_viridis(option = "plasma", direction = -1, name = "Run\nExpectancy") + labs(title = title, x = "Outs", y = "Base State") + theme_minimal(base_size = 14) + theme( plot.title = element_text(hjust = 0.5, face = "bold", size = 16), axis.text = element_text(face = "bold"), panel.grid = element_blank() ) } # Compare two RE matrices plot_re_comparison <- function(re_matrix_1, re_matrix_2, label1 = "Era 1", label2 = "Era 2") { # Join matrices and calculate difference comparison <- re_matrix_1 %>% select(state, re1 = re) %>% inner_join(re_matrix_2 %>% select(state, re2 = re), by = "state") %>% mutate( diff = re2 - re1, state_info = map(state, decode_state) ) %>% unnest_wider(state_info) %>% mutate( base_state = case_when( !on_1b & !on_2b & !on_3b ~ "Empty", on_1b & !on_2b & !on_3b ~ "1st", !on_1b & on_2b & !on_3b ~ "2nd", !on_1b & !on_2b & on_3b ~ "3rd", on_1b & on_2b & !on_3b ~ "1st & 2nd", on_1b & !on_2b & on_3b ~ "1st & 3rd", !on_1b & on_2b & on_3b ~ "2nd & 3rd", on_1b & on_2b & on_3b ~ "Loaded" ), base_state = factor(base_state, levels = c( "Empty", "1st", "2nd", "3rd", "1st & 2nd", "1st & 3rd", "2nd & 3rd", "Loaded" )) ) # Create comparison heatmap ggplot(comparison, aes(x = factor(outs), y = base_state, fill = diff)) + geom_tile(color = "white", size = 1) + geom_text(aes(label = sprintf("%+.3f", diff)), color = "black", size = 4.5, fontface = "bold") + scale_fill_gradient2(low = "blue", mid = "white", high = "red", midpoint = 0, name = "RE\nDifference") + labs(title = sprintf("Run Expectancy Change: %s vs %s", label2, label1), x = "Outs", y = "Base State") + theme_minimal(base_size = 14) + theme( plot.title = element_text(hjust = 0.5, face = "bold", size = 16), axis.text = element_text(face = "bold"), panel.grid = element_blank() ) } # Plot RE24 distribution for a player plot_player_re24 <- function(pbp_data, player_id, player_name) { player_data <- pbp_data %>% filter(batter_id == player_id) mean_re24 <- mean(player_data$re24, na.rm = TRUE) ggplot(player_data, aes(x = re24)) + geom_histogram(bins = 50, fill = "steelblue", color = "black", alpha = 0.7) + geom_vline(xintercept = mean_re24, color = "red", linetype = "dashed", size = 1.5) + annotate("text", x = mean_re24, y = Inf, label = sprintf("Mean: %.3f", mean_re24), vjust = 2, color = "red", fontface = "bold") + labs(title = sprintf("%s - RE24 Distribution", player_name), x = "RE24 per Plate Appearance", y = "Frequency") + theme_minimal(base_size = 14) + theme(plot.title = element_text(hjust = 0.5, face = "bold")) } ``` ## Conclusion Run Expectancy matrices represent one of baseball analytics' most powerful and versatile tools. From evaluating player performance through RE24 to analyzing strategic decisions like bunts and stolen bases, RE provides an objective, quantitative framework for understanding the game. Key takeaways: - **24 base-out states** form the foundation of run expectancy analysis - **RE matrices change over time** reflecting different offensive environments - **RE24 measures true offensive contribution** independent of leverage - **Strategic decisions** can be objectively evaluated using expected value calculations - **Building custom matrices** allows for context-specific analysis Whether you're a front office analyst, a fantasy baseball enthusiast, or simply a fan seeking deeper understanding, mastering run expectancy concepts will fundamentally change how you view and evaluate baseball.

Discussion

Have questions or feedback? Join our community discussion on Discord or GitHub Discussions.