TerryYou/Usage_Location
0
1import streamlit as st2import pandas as pd3import matplotlib.pyplot as plt4import seaborn as sns5import numpy as np6from matplotlib import patches7from sklearn.covariance import MinCovDet8from scipy.stats import chi29from numpy.linalg import eig10import io11from PIL import Image12 13# Set the page configuration for a wider layout14st.set_page_config(layout="wide")15 16# --- User Login and Authentication ---17def check_password():18 """Returns `True` if the user's password is correct."""19 def password_entered():20 """Checks whether a password entered by the user is correct."""21 if st.session_state["password"] == st.secrets["password"]:22 st.session_state["password_correct"] = True23 st.session_state["page"] = "app"24 else:25 st.session_state["password_correct"] = False26 27 if "password_correct" not in st.session_state:28 st.text_input(29 "Password", type="password", on_change=password_entered, key="password"30 )31 return False32 elif not st.session_state["password_correct"]:33 st.text_input(34 "Password", type="password", on_change=password_entered, key="password"35 )36 st.error("๐ Password incorrect")37 return False38 else:39 return True40 41@st.cache_data42def load_data():43 """44 Loads the baseball data from a parquet file.45 Caches the data to avoid reloading on every interaction.46 """47 file_path = "mlb_statcast_data_2025-03-18_to_2025-09-28_regular_season.parquet"48 try:49 df = pd.read_parquet(file_path)50 except FileNotFoundError:51 st.error(f"Error: The file '{file_path}' was not found.")52 st.stop()53 return df54 55def categorize_count(df):56 """57 Categorizes the pitch counts into 'Ahead', 'Even', 'Behind', and '0-0'.58 Also adds a boolean column for 2-strike counts.59 """60 ahead_counts = [(0, 1), (0, 2), (1, 2)]61 even_counts = [(1, 1), (2, 2)]62 behind_counts = [(1, 0), (2, 0), (2, 1), (3, 0), (3, 1)]63 two_strike_counts = [(0, 2), (1, 2), (2, 2), (3, 2)]64 65 df["count_category"] = "Unknown"66 df.loc[df[["balls", "strikes"]].apply(tuple, axis=1).isin(even_counts), "count_category"] = "Even"67 df.loc[df[["balls", "strikes"]].apply(tuple, axis=1).isin(ahead_counts), "count_category"] = "Ahead"68 df.loc[df[["balls", "strikes"]].apply(tuple, axis=1).isin(behind_counts), "count_category"] = "Behind"69 df.loc[df[["balls", "strikes"]].apply(tuple, axis=1) == (0, 0), "count_category"] = "0-0"70 df.loc[df["count_category"] == "Unknown", "count_category"] = "0-0"71 72 df["is_2_strikes"] = df[["balls", "strikes"]].apply(tuple, axis=1).isin(two_strike_counts)73 return df74 75def map_pitch_types(df):76 """77 Maps pitch type abbreviations to full names.78 """79 pitch_type_map = {80 "FF": "4-Seam Fastball", "SI": "Sinker", "FC": "Cutter", "CH": "Changeup",81 "FS": "Split-finger", "FO": "Forkball", "SC": "Screwball", "CU": "Curveball",82 "KC": "Knuckle Curve", "CS": "Slow Curve", "SL": "Slider", "ST": "Sweeper",83 "SV": "Slurve", "KN": "Knuckleball", "EP": "Eephus", "PO": "Pitchout", "FA": "Other"84 }85 df["pitch_type"] = df["pitch_type"].map(pitch_type_map).fillna(df["pitch_type"])86 return df87 88def plot_pitch_usage(df, player_name, date_range):89 """90 Plots a grid of pie charts showing pitch usage by count category and batter hand.91 Returns the generated figure.92 """93 fig, axes = plt.subplots(2, 5, figsize=(15, 8))94 pitch_colors = {95 "4-Seam Fastball": "#FF007D", "Sinker": "#98165D", "Cutter": "#BE5FA0",96 "Changeup": "#F79E70", "Split-finger": "#FE6100", "Forkball": "#F08223",97 "Screwball": "#FFB000", "Curveball": "#67E18D", "Knuckle Curve": "#1BB999",98 "Slow Curve": "#376748", "Slider": "#311DB8", "Sweeper": "#59c9eb",99 "Slurve": "#274BFC", "Knuckleball": "#648FFF", "Eephus": "#867A08", "Pitchout": "#472C30", "Other": "#C0C0C0"100 }101 categories = ["0-0", "Ahead", "Behind", "Even", "2 Strikes"]102 custom_order = [103 "4-Seam Fastball", "Sinker", "Cutter", "Changeup", "Split-finger", "Forkball", "Screwball",104 "Curveball", "Knuckle Curve", "Slow Curve", "Slider", "Sweeper", "Slurve", "Knuckleball", "Eephus", "Pitchout", "Other"105 ]106 107 available_pitch_types = df["pitch_type"].unique()108 ordered_pitch_types = [pitch for pitch in custom_order if pitch in available_pitch_types]109 110 for i, batter_hand in enumerate(["L", "R"]):111 hand_df = df[df["stand"] == batter_hand]112 for j, category in enumerate(categories):113 ax = axes[i, j]114 115 if category == "2 Strikes":116 subset = hand_df[hand_df["is_2_strikes"] == True]117 else:118 subset = hand_df[hand_df["count_category"] == category]119 120 if not subset.empty:121 pitch_counts = subset["pitch_type"].value_counts()122 sorted_pitches = pitch_counts.index123 colors = [pitch_colors[p] for p in sorted_pitches if p in pitch_colors]124 125 pitch_counts.plot.pie(126 autopct='%1.1f%%',127 colors=colors,128 ax=ax, labels=None129 )130 else:131 ax.pie([1], colors=['white'], labels=[''])132 133 ax.set_ylabel("")134 ax.set_title(f"{category} vs. {batter_hand}HB")135 136 plt.suptitle(f"{date_range}\n\nPitch Usage by Count for {player_name}", fontsize=16, fontweight="bold", y=1.02)137 fig.text(1.02, 1.02, "Made by Terry",138 fontsize=12, color="gray", weight="bold",139 verticalalignment="top", horizontalalignment="left")140 plt.tight_layout()141 142 handles = [plt.Line2D([0], [0], marker='o', color='w', markerfacecolor=pitch_colors[p], markersize=10)143 for p in ordered_pitch_types]144 plt.legend(handles=handles, labels=ordered_pitch_types, loc='center left', bbox_to_anchor=(1.05, 1.05))145 return fig146 147def plot_robust_mahalanobis_ellipse(ax, data, label, pitch_colors):148 """149 Plots a robust Mahalanobis ellipse for a given pitch type.150 """151 if data.empty or len(data) < 3:152 return153 154 try:155 mcd = MinCovDet().fit(data[["px", "pz"]])156 robust_mean = mcd.location_157 robust_cov = mcd.covariance_158 except ValueError:159 return160 161 eigenvalues, eigenvectors = eig(robust_cov)162 if min(eigenvalues) < 1e-3:163 return164 165 sorted_indices = np.argsort(eigenvalues)[::-1]166 eigenvalues = eigenvalues[sorted_indices]167 eigenvectors = eigenvectors[:, sorted_indices]168 169 chi_square_val = np.sqrt(chi2.ppf(0.68, df=2))170 width, height = 2 * chi_square_val * np.sqrt(eigenvalues)171 angle = np.degrees(np.arctan2(eigenvectors[1, 0], eigenvectors[0, 0]))172 173 mean_x, mean_y = robust_mean174 175 ellipse = patches.Ellipse(176 (mean_x, mean_y), width, height, angle=angle,177 edgecolor=pitch_colors[label],178 facecolor=pitch_colors[label],179 alpha=0.3180 )181 ax.add_patch(ellipse)182 183 mean_point_size = 10 + len(data) * 2184 ax.scatter(mean_x, mean_y, color=pitch_colors[label], s=mean_point_size,185 edgecolor="black", linewidth=1.2)186 187def plot_pitch_locations(df, player_name):188 """189 Plots a grid of scatter plots showing pitch locations by count category and batter hand,190 with robust Mahalanobis ellipses.191 Returns the generated figure.192 """193 df["px"] = df["plate_x"] * -1194 df["pz"] = df["plate_z"]195 196 fig, axes = plt.subplots(2, 5, figsize=(15, 8))197 pitch_colors = {198 "4-Seam Fastball": "#FF007D", "Sinker": "#98165D", "Cutter": "#BE5FA0",199 "Changeup": "#F79E70", "Split-finger": "#FE6100", "Forkball": "#F08223",200 "Screwball": "#FFB000", "Curveball": "#67E18D", "Knuckle Curve": "#1BB999",201 "Slow Curve": "#376748", "Slider": "#311DB8", "Sweeper": "#59c9eb",202 "Slurve": "#274BFC", "Knuckleball": "#648FFF", "Eephus": "#867A08",203 "Pitchout": "#472C30", "Other": "#C0C0C0"204 }205 categories = ["0-0", "Ahead", "Behind", "Even", "2 Strikes"]206 207 used_pitch_types = set()208 209 for i, batter_hand in enumerate(["L", "R"]):210 hand_df = df[df["stand"] == batter_hand]211 212 for j, category in enumerate(categories):213 ax = axes[i, j]214 215 if category == "2 Strikes":216 subset = hand_df[hand_df["is_2_strikes"] == True]217 else:218 subset = hand_df[hand_df["count_category"] == category]219 220 if not subset.empty:221 used_pitch_types.update(subset["pitch_type"].unique())222 for pitch_type, pitch_data in subset.groupby("pitch_type"):223 plot_robust_mahalanobis_ellipse(ax, pitch_data, pitch_type, pitch_colors)224 225 strike_zone = plt.Rectangle((-0.83, 1.5), 1.66, 2, linewidth=2, edgecolor='black', facecolor='none')226 ax.add_patch(strike_zone)227 ax.set_xlim(-2, 2)228 ax.set_ylim(0, 5)229 ax.set_xticks([])230 ax.set_yticks([])231 ax.set_title(f"{category} vs. {batter_hand}HB")232 ax.set_xlabel("")233 ax.set_ylabel("")234 235 plt.suptitle(f"Pitch Locations by Count for {player_name}", fontsize=16, fontweight="bold", y=1)236 fig.text(0.5, 0, "Pitcher's POV", ha='center', fontsize=14)237 238 used_pitch_types = [p for p in used_pitch_types if p == p]239 custom_order = [240 "4-Seam Fastball", "Sinker", "Cutter", "Changeup", "Split-finger", "Forkball", "Screwball",241 "Curveball", "Knuckle Curve", "Slow Curve", "Slider", "Sweeper", "Slurve", "Knuckleball", "Eephus", "Pitchout", "Other"242 ]243 used_pitch_types = [p for p in custom_order if p in used_pitch_types]244 245 handles = [plt.Line2D([0], [0], marker='o', color='w', markerfacecolor=pitch_colors[p], markersize=10)246 for p in used_pitch_types]247 248 fig.subplots_adjust(right=0.85)249 fig.legend(handles=handles, labels=used_pitch_types, loc='center left', bbox_to_anchor=(1, 0.46))250 plt.tight_layout()251 return fig252 253def main():254 """255 Main function to run the Streamlit application.256 """257 if check_password():258 st.title("2025 MLB Pitch Usage and Locations")259 260 df = load_data()261 262 if "game_date" in df.columns:263 df["game_date"] = pd.to_datetime(df["game_date"])264 start_date = df["game_date"].min()265 end_date = df["game_date"].max()266 else:267 st.error("The 'game_date' column is missing from the dataset.")268 st.stop()269 270 # Get a list of unique player names271 player_names_raw = sorted(df["player_name"].unique())272 # Format "Last, First" to "First Last"273 player_names = [f"{n.split(', ')[1]} {n.split(', ')[0]}" if ", " in n else n for n in player_names_raw]274 player_name_map = {f"{n.split(', ')[1]} {n.split(', ')[0]}" if ", " in n else n: n for n in player_names_raw}275 276 # Add interactive filters277 st.sidebar.header("Filters")278 selected_player_formatted = st.sidebar.selectbox("Select Player:", player_names)279 selected_player_raw = player_name_map[selected_player_formatted]280 281 date_range = st.sidebar.date_input(282 "Select Date Range:",283 value=(start_date, end_date),284 min_value=start_date,285 max_value=end_date286 )287 288 if len(date_range) == 2:289 start_date_filter, end_date_filter = date_range290 df_filtered = df[291 (df["game_date"] >= pd.Timestamp(start_date_filter)) &292 (df["game_date"] <= pd.Timestamp(end_date_filter))293 ]294 date_range_str = f"{start_date_filter.strftime('%Y-%m-%d')} to {end_date_filter.strftime('%Y-%m-%d')}"295 else:296 st.warning("Please select a valid date range.")297 df_filtered = pd.DataFrame() # Create an empty DataFrame298 date_range_str = "Unknown Date Range"299 300 # Filter dataset for the selected player301 df_filtered_player = df_filtered[df_filtered["player_name"] == selected_player_raw]302 303 if df_filtered_player.empty:304 st.warning("No data available for the selected player and date range.")305 else:306 df_processed = categorize_count(df_filtered_player.copy())307 df_processed = map_pitch_types(df_processed)308 309 # --- Displaying original plots ---310 st.subheader("Pitch Usage Analysis")311 usage_fig = plot_pitch_usage(df_processed, selected_player_formatted, date_range_str)312 st.pyplot(usage_fig)313 314 st.subheader("Pitch Location Analysis")315 location_fig = plot_pitch_locations(df_processed, selected_player_formatted)316 st.pyplot(location_fig)317 318 # --- Combine plots and create download button ---319 st.subheader("Combined Plot")320 321 # Create in-memory buffers for each figure322 usage_buf = io.BytesIO()323 location_buf = io.BytesIO()324 325 # Save figures to buffers326 usage_fig.savefig(usage_buf, format='png', bbox_inches='tight')327 location_fig.savefig(location_buf, format='png', bbox_inches='tight')328 329 # Reset buffer pointers330 usage_buf.seek(0)331 location_buf.seek(0)332 333 # Open images from buffers334 usage_img = Image.open(usage_buf)335 location_img = Image.open(location_buf)336 337 # Get dimensions and create a new combined image338 width1, height1 = usage_img.size339 width2, height2 = location_img.size340 341 combined_width = max(width1, width2)342 combined_height = height1 + height2343 344 combined_img = Image.new('RGB', (combined_width, combined_height), 'white')345 346 # Paste images onto the new canvas347 combined_img.paste(usage_img, (0, 0))348 combined_img.paste(location_img, (0, height1))349 350 # Save the combined image to a final buffer351 final_buf = io.BytesIO()352 combined_img.save(final_buf, format='png')353 final_buf.seek(0)354 355 # Create a download button for the final image356 st.download_button(357 label="Save Combined Plots as PNG",358 data=final_buf,359 file_name=f"{selected_player_formatted}_pitch_analysis_combined.png",360 mime="image/png"361 )362 363if __name__ == "__main__":364 main()