paper-dynasty-card-creation/retrosheet_transformer.py
Cal Corum 92256cb29c Update scouting data and card creation scripts
- Regenerate scouting CSVs with latest player ratings
- Update archetype calculator with BP-HR whole number rule
- Refresh retrosheet normalized data
- Minor script updates for Kalin Young card creation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 16:25:42 -06:00

312 lines
9.7 KiB
Python

"""
Retrosheet CSV Format Transformer
This module transforms newer Retrosheet CSV formats into the legacy format
expected by retrosheet_data.py. Includes smart caching to avoid redundant
transformations.
Author: Claude Code
"""
import os
import logging
from pathlib import Path
import pandas as pd
import numpy as np
# Set up logging
logger = logging.getLogger(f'{__name__}')
def get_normalized_csv_path(source_path: str) -> str:
"""
Generate the cached/normalized CSV path from source path.
Args:
source_path: Path to the source CSV file
Returns:
Path to the normalized cache file
"""
source = Path(source_path)
cache_name = f"{source.stem}_normalized{source.suffix}"
return str(source.parent / cache_name)
def needs_transformation(source_path: str, cache_path: str) -> bool:
"""
Check if transformation is needed based on file modification times.
Args:
source_path: Path to source CSV
cache_path: Path to cached normalized CSV
Returns:
True if transformation needed, False if cache is valid
"""
if not os.path.exists(cache_path):
logger.info(f"Cache file not found: {cache_path}")
return True
source_mtime = os.path.getmtime(source_path)
cache_mtime = os.path.getmtime(cache_path)
if source_mtime > cache_mtime:
logger.info(f"Source file is newer than cache, transformation needed")
return True
logger.info(f"Using cached normalized file: {cache_path}")
return False
def transform_event_type(row: pd.Series) -> str:
"""
Derive event_type from boolean columns in new format.
Priority order matches baseball scoring conventions.
"""
if row['hr'] == 1:
return 'home run'
elif row['triple'] == 1:
return 'triple'
elif row['double'] == 1:
return 'double'
elif row['single'] == 1:
return 'single'
elif row['walk'] == 1 or row['iw'] == 1:
return 'walk'
elif row['k'] == 1:
return 'strikeout'
elif row['hbp'] == 1:
return 'hit by pitch'
else:
return 'generic out'
def transform_batted_ball_type(row: pd.Series) -> str:
"""
Derive batted_ball_type from boolean columns.
Returns 'f' (fly), 'G' (ground), 'l' (line), or empty string.
"""
if row['fly'] == 1:
return 'f'
elif row['ground'] == 1:
return 'G'
elif row['line'] == 1:
return 'l'
else:
return ''
def transform_hit_val(row: pd.Series) -> str:
"""
Derive hit_val from hit type columns.
Returns '1', '2', '3', '4' for singles through home runs.
"""
if row['hr'] == 1:
return '4'
elif row['triple'] == 1:
return '3'
elif row['double'] == 1:
return '2'
elif row['single'] == 1:
return '1'
else:
return ''
def bool_to_tf(val) -> str:
"""Convert 1/0 or True/False to 't'/'f' strings."""
if pd.isna(val):
return 'f'
return 't' if val == 1 or val is True else 'f'
def transform_retrosheet_csv(source_path: str) -> pd.DataFrame:
"""
Transform new Retrosheet CSV format to legacy format.
Args:
source_path: Path to source CSV file
Returns:
Transformed DataFrame in legacy format
"""
logger.info(f"Reading source CSV: {source_path}")
df = pd.read_csv(source_path, low_memory=False)
logger.info(f"Transforming {len(df)} rows to legacy format")
# Create new dataframe with legacy column names
transformed = pd.DataFrame()
# Simple renames (with case conversion for handedness)
transformed['game_id'] = df['gid']
transformed['batter_id'] = df['batter']
transformed['pitcher_id'] = df['pitcher']
transformed['batter_hand'] = df['bathand'].str.lower() # Convert R/L/B to r/l/b
transformed['pitcher_hand'] = df['pithand'].str.lower() # Convert R/L to r/l
transformed['hit_location'] = df['loc'].astype(str) # Ensure string type for .str operations
# Derive event_type from multiple columns
logger.info("Deriving event_type from hit/walk/strikeout columns")
transformed['event_type'] = df.apply(transform_event_type, axis=1)
# Derive batted_ball_type
logger.info("Deriving batted_ball_type from fly/ground/line columns")
transformed['batted_ball_type'] = df.apply(transform_batted_ball_type, axis=1).astype(str)
# Derive hit_val
logger.info("Deriving hit_val from hit type columns")
transformed['hit_val'] = df.apply(transform_hit_val, axis=1).astype(str)
# Boolean conversions to 't'/'f' format
logger.info("Converting boolean columns to 't'/'f' format")
transformed['batter_event'] = df['pa'].apply(bool_to_tf)
transformed['ab'] = df['ab'].apply(bool_to_tf)
transformed['bunt'] = df['bunt'].apply(bool_to_tf)
transformed['tp'] = df['tp'].apply(bool_to_tf)
# Combine gdp + othdp for double play indicator
transformed['dp'] = (df['gdp'].fillna(0) + df['othdp'].fillna(0)).apply(lambda x: 't' if x > 0 else 'f')
# Convert batter handedness to actual batting side for each PA
# Switch hitters (B) bat left vs RHP and right vs LHP
def get_result_batter_hand(row):
bathand = row['bathand'].upper()
pithand = row['pithand'].upper()
if bathand == 'B': # Switch hitter
# Switch hitters bat from opposite side of pitcher
return 'l' if pithand == 'R' else 'r'
else:
# Regular batters always bat from same side
return bathand.lower()
logger.info("Converting switch hitter handedness based on pitcher matchups")
transformed['result_batter_hand'] = df.apply(get_result_batter_hand, axis=1)
# Add placeholder columns that may be referenced but aren't critical for stats
# These can be populated if needed in the future
transformed['event_id'] = range(1, len(df) + 1)
transformed['batting_team'] = ''
transformed['inning'] = df['inning'] if 'inning' in df.columns else ''
transformed['outs'] = ''
transformed['balls'] = ''
transformed['strikes'] = ''
transformed['pitch_seq'] = ''
transformed['vis_score'] = ''
transformed['home_score'] = ''
transformed['result_batter_id'] = df['batter']
transformed['result_pitcher_id'] = df['pitcher']
transformed['result_pitcher_hand'] = df['pithand']
transformed['def_c'] = ''
transformed['def_1b'] = ''
transformed['def_2b'] = ''
transformed['def_3b'] = ''
transformed['def_ss'] = ''
transformed['def_lf'] = ''
transformed['def_cf'] = ''
transformed['def_rf'] = ''
transformed['run_1b'] = ''
transformed['run_2b'] = ''
transformed['run_3b'] = ''
transformed['event_scoring'] = ''
transformed['leadoff'] = ''
transformed['pinch_hit'] = ''
transformed['batt_def_pos'] = ''
transformed['batt_lineup_pos'] = ''
transformed['sac_hit'] = df['sh'].apply(bool_to_tf) if 'sh' in df.columns else 'f'
transformed['sac_fly'] = df['sf'].apply(bool_to_tf) if 'sf' in df.columns else 'f'
transformed['event_outs'] = ''
transformed['rbi'] = ''
transformed['wild_pitch'] = df['wp'].apply(bool_to_tf) if 'wp' in df.columns else 'f'
transformed['passed_ball'] = df['pb'].apply(bool_to_tf) if 'pb' in df.columns else 'f'
transformed['fielded_by'] = ''
transformed['foul_ground'] = ''
logger.info(f"Transformation complete: {len(transformed)} rows")
return transformed
def load_retrosheet_csv(source_path: str, force_transform: bool = False) -> pd.DataFrame:
"""
Load Retrosheet CSV, using cached normalized version if available.
This is the main entry point for loading Retrosheet data. It handles:
- Checking for cached normalized data
- Transforming if needed
- Saving transformed data for future use
Args:
source_path: Path to source Retrosheet CSV
force_transform: If True, ignore cache and force transformation
Returns:
DataFrame in legacy format ready for retrosheet_data.py
"""
logger.info(f"Loading Retrosheet CSV: {source_path}")
if not os.path.exists(source_path):
raise FileNotFoundError(f"Source file not found: {source_path}")
cache_path = get_normalized_csv_path(source_path)
# Check if we need to transform
if force_transform or needs_transformation(source_path, cache_path):
# Transform the data
df = transform_retrosheet_csv(source_path)
# Save to cache
logger.info(f"Saving normalized data to cache: {cache_path}")
df.to_csv(cache_path, index=False)
logger.info(f"Cache saved successfully")
return df
else:
# Load from cache
logger.info(f"Loading from cache: {cache_path}")
# Explicitly set dtypes for string columns to ensure .str accessor works
dtype_dict = {
'game_id': 'str',
'hit_val': 'str',
'hit_location': 'str',
'batted_ball_type': 'str'
}
return pd.read_csv(cache_path, dtype=dtype_dict, low_memory=False)
if __name__ == '__main__':
# Test the transformer
import sys
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
if len(sys.argv) > 1:
test_file = sys.argv[1]
else:
test_file = 'data-input/retrosheet/retrosheets_events_2005.csv'
print(f"\n{'='*60}")
print(f"Testing Retrosheet Transformer")
print(f"{'='*60}\n")
df = load_retrosheet_csv(test_file)
print(f"\nTransformed DataFrame Info:")
print(f"Shape: {df.shape}")
print(f"\nColumns: {list(df.columns)}")
print(f"\nSample rows:")
print(df.head(3))
print(f"\nEvent type distribution:")
print(df['event_type'].value_counts())
print(f"\nBatted ball type distribution:")
print(df['batted_ball_type'].value_counts())