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.
Table of Contents
Related Topics
Quick Actions