- Authors

- Name
- Youngju Kim
- @fjvbn20031
스포츠 데이터 분석 완전 정복: 머니볼부터 AI 전술 분석까지
2002년, 오클랜드 애슬레틱스의 단장 빌리 빈(Billy Beane)은 메이저리그에서 가장 낮은 예산으로 가장 높은 승률을 기록했습니다. 그의 무기는 스카우터의 직감이 아니라 데이터였습니다. 이 사건은 스포츠 역사상 가장 중요한 패러다임 전환 중 하나였고, 지금 우리가 살고 있는 스포츠 데이터 분석의 시대를 열었습니다.
오늘날 NBA 팀들은 초당 25프레임으로 모든 선수의 움직임을 추적하고, 유럽 축구 클럽들은 AI로 5년 후 선수 가치를 예측하며, 야구 투수들은 피치 터널링(Pitch Tunneling) 데이터를 보며 타자를 속이는 전략을 짭니다. 이 글은 그 혁명의 전모를 파이썬 코드와 함께 설명합니다.
1. 스포츠 데이터 분석의 혁명: 머니볼 이야기
1.1 오클랜드 애슬레틱스와 빌리 빈의 혁신
2001년 시즌이 끝난 후, 오클랜드 애슬레틱스는 핵심 선수 세 명(제이슨 지암비, 조니 데이먼, 제이슨 이스링하우젠)을 뉴욕 양키스와 보스턴 레드삭스에 빼앗겼습니다. 대체 예산은 단 900만 달러. 양키스의 단일 선수 연봉과 맞먹는 금액으로 팀 전체를 꾸려야 했습니다.
빌리 빈과 하버드 경제학 출신의 조력자 폴 디포데스타(Paul DePodesta)는 기존의 스카우팅 방식에 근본적인 의문을 제기했습니다. "우리는 선수를 사는 것이 아니라 승리를 사야 한다. 그리고 승리는 점수를 내는 것에서 온다. 점수를 내려면 아웃을 당하지 않아야 한다."
이 철학에서 나온 결론이 바로 OBP(출루율, On-Base Percentage) 의 재발견이었습니다.
1.2 OBP가 타율보다 중요한 이유
당시 야구계는 타율(AVG, Batting Average)을 타자의 가치를 평가하는 핵심 지표로 사용했습니다. 그러나 세이버메트릭스(Sabermetrics) 연구자들은 이미 1970년대부터 OBP가 팀 득점과 훨씬 강한 상관관계를 가진다는 것을 증명하고 있었습니다.
타율은 안타수를 타수로 나눈 값입니다. 하지만 볼넷(BB)은 타수에 포함되지 않으면서도 출루라는 동일한 결과를 만들어냅니다. 즉, 타율이 0.250인 타자가 볼넷을 많이 얻어 OBP가 0.380이라면, 타율이 0.280이지만 볼넷이 없어 OBP가 0.310인 타자보다 팀 득점에 훨씬 더 큰 기여를 합니다.
2002년 오클랜드는 이 논리를 적용해 다른 팀들이 저평가한 OBP 높은 선수들을 저렴하게 영입했습니다. 그 결과 당시 메이저리그 역사상 최장 연승 기록인 20연승을 달성하며 포스트시즌에 진출했습니다.
1.3 머니볼 이후: 스포츠 분석의 폭발적 성장
머니볼의 성공은 전 스포츠계로 확산되었습니다. 2005년 이후 MLB 32개 구단 모두가 전문 분석팀을 운영하기 시작했고, NBA, NFL, EPL, NBA도 차례로 데이터 분석 혁명을 맞이했습니다.
2. 야구(Baseball) 데이터 분석
2.1 전통 지표 vs 세이버메트릭스
야구는 스포츠 중 데이터 분석이 가장 발달한 종목입니다. 150년 이상의 기록 데이터가 존재하고, 이산적(discrete) 이벤트로 구성된 경기 특성이 통계 분석에 적합하기 때문입니다.
전통 지표의 한계
| 지표 | 설명 | 한계 |
|---|---|---|
| AVG (타율) | 안타수 / 타수 | 볼넷, 장타력 반영 안 됨 |
| RBI (타점) | 본인이 득점시킨 주자 수 | 팀 타선 환경 의존 |
| ERA (방어율) | 9이닝당 실점 | 수비력, 파크팩터 미반영 |
| W-L (승패) | 투수의 승패 기록 | 팀 타선 지원 의존 |
세이버메트릭스 현대 지표
wOBA (가중 출루율, Weighted On-Base Average): 단순 출루율을 넘어 각 타격 결과(단타, 2루타, 3루타, 홈런, 볼넷)에 실제 득점 기여도에 따른 가중치를 부여합니다.
wOBA = (0.69 * BB + 0.72 * HBP + 0.89 * 1B + 1.27 * 2B + 1.62 * 3B + 2.10 * HR)
/ (AB + BB - IBB + SF + HBP)
FIP (필딩 독립 투구율, Fielding Independent Pitching): 투수가 수비의 도움 없이 통제할 수 있는 요소(삼진, 볼넷, 홈런)만으로 계산한 ERA 유사 지표입니다.
FIP = ((13 * HR) + (3 * (BB + HBP)) - (2 * K)) / IP + FIP_constant
2.2 WAR (Wins Above Replacement): 야구 분석의 성배
WAR은 "이 선수가 교체 수준 선수(마이너리그에서 부를 수 있는 평균적인 선수) 대비 몇 승을 더 만들어냈는가"를 나타내는 통합 지표입니다.
WAR 해석 기준:
- 0~1 WAR: 교체 수준 (대체 선수)
- 2 WAR: 백업 수준
- 3~4 WAR: 레귤러 스타터
- 5~6 WAR: 올스타 수준
- 7+ WAR: MVP 수준
- 10+ WAR: 역대급 시즌
2023년 기준 주요 선수 WAR: 로날드 아쿠냐 주니어 9.4 WAR, 쇼헤이 오타니 9.0 WAR(투수+타자 합산).
2.3 투구 분석: Statcast 혁명
2015년 MLB는 모든 구장에 Statcast 시스템을 도입했습니다. 레이더와 광학 추적 기술을 결합해 모든 투구와 타격의 물리적 데이터를 측정합니다.
주요 Statcast 투구 지표:
- 회전수(Spin Rate): RPM 단위로 측정. 패스트볼은 회전수가 높을수록 떠오르는 효과(rise effect)가 강해 타자가 헛스윙하기 쉽습니다. 2400 RPM 이상이면 엘리트 수준
- 익스텐션(Extension): 투수가 공을 릴리즈하는 순간 홈플레이트까지의 거리. 익스텐션이 길수록 타자의 반응 시간이 줄어듭니다
- 버티컬/호리즌탈 무브먼트: 공의 수직·수평 이동량. 투심 패스트볼의 테일(tail), 커브의 드롭 등을 수치화
- 피치 터널링: 서로 다른 구종이 가능한 오랫동안 같은 경로로 날아오다가 분기하는 지점 분석
2.4 Python으로 야구 데이터 분석: pybaseball
# pybaseball 설치: pip install pybaseball
import pybaseball as pb
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
# Statcast 데이터 가져오기 (오타니 2023 시즌)
pb.cache.enable()
# 특정 선수의 Statcast 데이터
ohtani_id = 660271 # 오타니 MLB ID
data = pb.statcast_pitcher(
start_dt='2023-04-01',
end_dt='2023-10-01',
player_id=ohtani_id
)
print(f"총 투구 수: {len(data)}")
print(data[['pitch_type', 'release_speed', 'release_spin_rate',
'pfx_x', 'pfx_z']].describe())
# 구종별 회전수 분포 시각화
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
# 구종별 평균 구속
pitch_speed = data.groupby('pitch_type')['release_speed'].mean().sort_values(ascending=False)
axes[0].bar(pitch_speed.index, pitch_speed.values, color='steelblue')
axes[0].set_title("오타니 구종별 평균 구속 (mph)")
axes[0].set_xlabel("구종")
axes[0].set_ylabel("구속 (mph)")
# 구종별 회전수
pitch_spin = data.groupby('pitch_type')['release_spin_rate'].mean().sort_values(ascending=False)
axes[1].bar(pitch_spin.index, pitch_spin.values, color='coral')
axes[1].set_title("오타니 구종별 평균 회전수 (RPM)")
axes[1].set_xlabel("구종")
axes[1].set_ylabel("회전수 (RPM)")
plt.tight_layout()
plt.savefig('ohtani_pitch_analysis.png', dpi=150)
plt.show()
# 투구 무브먼트 차트 (피치 차트)
def plot_pitch_movement(data):
pitch_types = data['pitch_type'].unique()
colors = plt.cm.Set1(range(len(pitch_types)))
color_map = dict(zip(pitch_types, colors))
fig, ax = plt.subplots(figsize=(10, 8))
for pt in pitch_types:
subset = data[data['pitch_type'] == pt]
ax.scatter(
subset['pfx_x'] * 12, # 인치 변환
subset['pfx_z'] * 12,
label=pt,
alpha=0.3,
color=color_map[pt],
s=10
)
ax.axhline(y=0, color='gray', linestyle='--', alpha=0.5)
ax.axvline(x=0, color='gray', linestyle='--', alpha=0.5)
ax.set_xlabel("수평 무브먼트 (인치, 포수 시점)")
ax.set_ylabel("수직 무브먼트 (인치)")
ax.set_title("투구 무브먼트 차트")
ax.legend(title="구종")
plt.tight_layout()
plt.show()
plot_pitch_movement(data)
2.5 KBO 데이터 분석 예시
# KBO 데이터는 직접 크롤링하거나 KBO 공식 API를 활용합니다
# 여기서는 pandas를 이용한 분석 예시를 보여줍니다
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.font_manager as fm
# 한글 폰트 설정 (macOS)
plt.rcParams['font.family'] = 'AppleGothic'
plt.rcParams['axes.unicode_minus'] = False
# 가상의 KBO 2023 타자 데이터 (실제로는 KBO 통계 사이트에서 가져옵니다)
kbo_batters = pd.DataFrame({
'선수명': ['이정후', '박병호', '양의지', '최형우', '강백호'],
'팀': ['키움', 'NC', '두산', 'KIA', 'KT'],
'타율': [0.349, 0.282, 0.311, 0.301, 0.285],
'출루율': [0.421, 0.385, 0.392, 0.378, 0.362],
'장타율': [0.561, 0.512, 0.478, 0.489, 0.501],
'홈런': [23, 34, 15, 21, 28],
'타점': [112, 101, 78, 89, 95]
})
# OPS (출루율 + 장타율) 계산
kbo_batters['OPS'] = kbo_batters['출루율'] + kbo_batters['장타율']
# wRC+ 근사값 계산 (간략화)
# 실제 wRC+는 파크팩터와 리그 평균을 고려한 복잡한 수식을 사용합니다
league_avg_obp = kbo_batters['출루율'].mean()
league_avg_slg = kbo_batters['장타율'].mean()
kbo_batters['wRC_approx'] = (
(kbo_batters['출루율'] / league_avg_obp) * 0.5 +
(kbo_batters['장타율'] / league_avg_slg) * 0.5
) * 100
print("\nKBO 2023 타자 분석:")
print(kbo_batters[['선수명', '팀', '타율', '출루율', 'OPS', 'wRC_approx']].to_string(index=False))
# OPS 시각화
fig, ax = plt.subplots(figsize=(10, 6))
bars = ax.barh(kbo_batters['선수명'], kbo_batters['OPS'],
color=['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd'])
ax.set_xlabel('OPS (출루율 + 장타율)')
ax.set_title('KBO 2023 주요 타자 OPS 비교')
ax.axvline(x=kbo_batters['OPS'].mean(), color='red',
linestyle='--', label=f'리그 평균: {kbo_batters["OPS"].mean():.3f}')
ax.legend()
plt.tight_layout()
plt.show()
3. 농구(Basketball) 데이터 분석
3.1 전통 지표의 한계와 고급 지표
NBA의 전통적인 스탯 박스(득점, 리바운드, 어시스트, 블록, 스틸)는 선수의 실제 기여도를 제대로 반영하지 못합니다. 예를 들어, 20득점을 기록한 선수가 40%의 필드골 성공률로 30번 시도했다면 팀에 실제로 해가 됩니다.
PER (Player Efficiency Rating): 존 홀링거(John Hollinger)가 개발한 통합 효율 지표. 리그 평균은 15로 정규화됩니다. 단점은 수비 기여도를 제대로 반영하지 못합니다.
BPM (Box Plus/Minus): 100번의 공격/수비 기회당 해당 선수가 팀에 기여하는 점수 차. 리그 평균은 0이며, 5.0 이상이면 MVP 수준입니다.
VORP (Value Over Replacement Player): 교체 수준의 선수 대비 실제 기여 가치를 측정합니다. 야구의 WAR과 개념적으로 유사합니다.
Win Shares: 선수가 팀 승리에 기여한 승수를 나타냅니다. 공격 승리 기여(Offensive Win Shares)와 수비 승리 기여(Defensive Win Shares)로 분리됩니다.
3.2 4 Factors: 딘 올리버의 승리 공식
NBA 통계학자 딘 올리버(Dean Oliver)는 농구 경기의 승패를 결정짓는 4가지 핵심 요소를 발견했습니다.
-
이펙티브 필드골율 (eFG%): 3점슛의 가치를 반영한 조정 필드골율
eFG% = (FGM + 0.5 * 3PM) / FGA -
턴오버율 (TOV%): 100번의 공격 기회당 턴오버 발생 횟수
TOV% = TOV / (FGA + 0.44 * FTA + TOV) -
오펜시브 리바운드율 (ORB%): 가능한 공격 리바운드 중 실제로 잡은 비율
-
자유투 획득율 (FT Rate): 필드골 시도 대비 자유투 획득 비율
FT Rate = FTA / FGA
이 4가지 요소의 중요도: eFG% (40%) > TOV% (25%) > ORB% (20%) > FT Rate (15%)
3.3 NBA의 3점 혁명: 스테판 커리 이후
2015년 골든스테이트 워리어스가 챔피언십을 차지하면서 NBA는 완전히 바뀌었습니다. 스테판 커리는 단순한 3점 슈터가 아니었습니다. 그는 3점 라인 훨씬 뒤에서 수비의 혼란을 유발하고, 헤지팡(hedge-and-drop) 픽앤롤 수비를 무력화시키는 새로운 농구 언어를 만들었습니다.
2014-15 시즌: 리그 평균 3점 시도 20.8개/경기 2022-23 시즌: 리그 평균 3점 시도 35.1개/경기
3점 시도가 10년 만에 68% 증가한 것입니다. 이 변화는 단순한 유행이 아니라 기대값(Expected Value) 수학에 근거합니다.
2점 지역에서의 기대값 = 슛 성공률 * 2점
3점 지역에서의 기대값 = 슛 성공률 * 3점
예시:
- 미드레인지 2점 슛 (45% 성공률): 기대값 = 0.45 * 2 = 0.90점
- 코너 3점 슛 (38% 성공률): 기대값 = 0.38 * 3 = 1.14점
코너 3점은 수비수가 시야각 때문에 방어하기 어렵고, 성공률이 38-40%로 가장 높습니다. 따라서 팀 관점에서 미드레인지 2점보다 훨씬 효율적인 선택입니다.
3.4 플레이어 트래킹: Second Spectrum
NBA는 2013년부터 SportVU, 이후 Second Spectrum 카메라 시스템을 모든 구장에 설치했습니다. 초당 25프레임으로 코트 위 모든 물체의 3D 좌표를 추적합니다.
이 데이터로 가능한 분석:
- 수비 거리: 수비수가 공격수에게서 평균 몇 피트 떨어져 있는가
- 컨테스트율: 슛 시도 시 수비수가 얼마나 밀착했는가 (Tight/Open/Wide Open 분류)
- 스피드/가속도: 선수별 코트 커버리지
- 오프볼 무브먼트: 공 없이 스크린을 서거나 커팅하는 선수의 움직임
3.5 Python으로 NBA 데이터 분석: nba_api
# nba_api 설치: pip install nba_api
from nba_api.stats.endpoints import playercareerstats, leaguedashplayerstats
from nba_api.stats.static import players
import pandas as pd
import matplotlib.pyplot as plt
import time
# 선수 ID 찾기
def get_player_id(name):
all_players = players.get_players()
for p in all_players:
if p['full_name'].lower() == name.lower():
return p['id']
return None
# 르브론 제임스 커리어 스탯
lebron_id = get_player_id('LeBron James')
time.sleep(1) # API 레이트 리밋 방지
career = playercareerstats.PlayerCareerStats(player_id=lebron_id)
career_df = career.get_data_frames()[0]
# 시즌별 PTS, AST, REB 추이
career_df['PTS_PER_GAME'] = career_df['PTS'] / career_df['GP']
career_df['AST_PER_GAME'] = career_df['AST'] / career_df['GP']
career_df['REB_PER_GAME'] = career_df['REB'] / career_df['GP']
fig, axes = plt.subplots(1, 3, figsize=(18, 6))
metrics = [
('PTS_PER_GAME', '득점', 'royalblue'),
('AST_PER_GAME', '어시스트', 'forestgreen'),
('REB_PER_GAME', '리바운드', 'crimson')
]
for ax, (col, label, color) in zip(axes, metrics):
ax.plot(career_df['SEASON_ID'], career_df[col],
marker='o', color=color, linewidth=2)
ax.set_title(f'르브론 제임스 시즌별 {label}')
ax.set_xlabel('시즌')
ax.set_ylabel(f'{label}/경기')
ax.tick_params(axis='x', rotation=45)
ax.grid(alpha=0.3)
plt.suptitle("르브론 제임스 커리어 스탯 추이", fontsize=14, fontweight='bold')
plt.tight_layout()
plt.show()
# 2022-23 시즌 리그 전체 선수 효율 분석
time.sleep(1)
season_stats = leaguedashplayerstats.LeagueDashPlayerStats(
season='2022-23',
per_mode_simple='PerGame'
)
df = season_stats.get_data_frames()[0]
# 최소 500분 출장 선수만 필터링
df_filtered = df[df['MIN'] >= 500].copy()
df_filtered['eFG'] = (df_filtered['FGM'] + 0.5 * df_filtered['FG3M']) / df_filtered['FGA']
# 득점 vs eFG% 산점도
fig, ax = plt.subplots(figsize=(12, 8))
scatter = ax.scatter(
df_filtered['eFG'],
df_filtered['PTS'],
c=df_filtered['MIN'],
cmap='viridis',
alpha=0.6,
s=50
)
plt.colorbar(scatter, label='출장 시간 (분)')
ax.set_xlabel('이펙티브 필드골율 (eFG%)')
ax.set_ylabel('경기당 득점')
ax.set_title('2022-23 NBA 선수별 득점 vs 효율 (색상: 출장 시간)')
# 상위 선수 라벨 추가
top_scorers = df_filtered.nlargest(5, 'PTS')
for _, row in top_scorers.iterrows():
ax.annotate(row['PLAYER_NAME'],
(row['eFG'], row['PTS']),
textcoords="offset points",
xytext=(5, 5),
fontsize=8)
plt.tight_layout()
plt.show()
4. 축구(Football/Soccer) 데이터 분석
4.1 xG (Expected Goals): 슛의 품질을 정량화하다
축구에서 가장 혁명적인 지표는 단연 **xG (Expected Goals, 기대골)**입니다. xG는 "이 상황에서 슛이 골이 될 확률"을 0과 1 사이의 숫자로 표현합니다.
xG 모델이 고려하는 요소:
- 슛 위치: 골대까지의 거리와 각도 (가장 중요)
- 슛 유형: 인사이드킥, 발리슛, 헤딩 (헤딩은 일반적으로 xG가 낮습니다)
- 어시스트 유형: 크로스, 스루패스, 세트피스, 리바운드
- 수비수와의 위치: 수비수가 시야를 가리는 정도
- 오픈 플레이 vs 세트피스: 코너킥, 프리킥 상황
패널티킥의 xG는 약 0.76입니다. 반면 골대 정면 5미터 이내의 원터치 슛은 0.50.8, 30미터 이상의 중거리 슛은 0.020.05 정도입니다.
xG 실용 예시:
- 손흥민 2022-23 시즌: 실제 골 17개, xG 12.3 → xG 대비 4.7골 초과 달성 (뛰어난 슈팅 능력 입증)
- 어떤 선수의 시즌: 실제 골 8개, xG 14.5 → xG 대비 6.5골 미달 (불운 또는 슈팅 품질 문제)
4.2 패스 네트워크 분석
패스 네트워크(Pass Network)는 팀의 패스 흐름을 시각화하는 강력한 도구입니다. 각 선수를 노드(node)로, 패스를 엣지(edge)로 표현하며 두께는 패스 빈도, 크기는 패스 참여도를 나타냅니다.
이 분석으로 알 수 있는 것:
- 팀의 빌드업 패턴 (어떤 포지션을 통해 공이 이동하는가)
- 핵심 연결 플레이어 (패스 허브)
- 팀의 중심 이동 (왼쪽 편중 vs 균형 빌드업)
4.3 PPDA: 압박 강도의 정량화
**PPDA (Passes Per Defensive Action)**는 리버풀의 위르겐 클롭 감독이 유명하게 만든 게겐프레싱(Gegenpressing) 전략의 효과를 측정하는 지표입니다.
PPDA = 상대팀 패스 수 / (태클 + 인터셉트 + 반칙 + 압박 성공)
PPDA가 낮을수록 압박이 효과적입니다 (적은 수의 상대 패스마다 수비 액션 발생).
- 10 이하: 매우 강한 압박 (클롭 리버풀 전성기 약 7)
- 10~12: 강한 압박
- 12 이상: 중~낮은 압박 블록 수비
4.4 Python으로 축구 데이터 분석
# statsbombpy, mplsoccer 설치
# pip install statsbombpy mplsoccer
from statsbombpy import sb
import mplsoccer
from mplsoccer import Pitch, VerticalPitch, FontManager
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
# StatsBomb 오픈 데이터에서 경기 목록 가져오기
# (StatsBomb은 일부 대회 데이터를 무료로 제공합니다)
competitions = sb.competitions()
print(competitions[['competition_id', 'competition_name', 'season_name']].head(20))
# 2018 FIFA 월드컵 경기 데이터
matches = sb.matches(competition_id=43, season_id=3) # 2018 월드컵
print(f"총 경기 수: {len(matches)}")
# 특정 경기 이벤트 데이터 로드 (예: 프랑스 vs 크로아티아 결승전)
final_id = matches[
(matches['home_team'] == 'France') &
(matches['away_team'] == 'Croatia')
]['match_id'].values[0]
events = sb.events(match_id=final_id)
# 슛 이벤트만 필터링
shots = events[events['type'] == 'Shot'].copy()
shots_expanded = pd.json_normalize(shots['shot'].dropna())
# xG 시각화
pitch = VerticalPitch(
pitch_type='statsbomb',
pitch_color='grass',
line_color='white',
half=True
)
fig, ax = pitch.draw(figsize=(8, 12))
# 팀별 슛 표시
for team_color, team in [('#1f77b4', 'France'), ('#d62728', 'Croatia')]:
team_shots = shots[shots['team'] == team]
for _, shot in team_shots.iterrows():
x = shot['location'][0]
y = shot['location'][1]
try:
xg = shot['shot']['statsbomb_xg']
outcome = shot['shot']['outcome']['name']
marker = '*' if outcome == 'Goal' else 'o'
size = xg * 1000 # xG를 크기로 표현
ax.scatter(y, x, s=size, c=team_color,
marker=marker, alpha=0.7, edgecolors='white', linewidths=0.5)
except (KeyError, TypeError):
pass
ax.set_title('2018 FIFA 월드컵 결승전\n프랑스 vs 크로아티아 슛 맵\n(크기=xG, 별=골)',
fontsize=12, pad=20)
plt.tight_layout()
plt.savefig('shot_map_final.png', dpi=150, bbox_inches='tight')
plt.show()
# 패스 네트워크 시각화 함수
def plot_pass_network(events, team_name, ax, pitch):
team_events = events[events['team'] == team_name]
passes = team_events[
(team_events['type'] == 'Pass') &
(team_events['pass_outcome'].isna()) # 성공한 패스만
].copy()
# 선수별 평균 위치 계산
player_positions = {}
for player in passes['player'].unique():
player_passes = passes[passes['player'] == player]
avg_x = player_passes['location'].apply(lambda loc: loc[0]).mean()
avg_y = player_passes['location'].apply(lambda loc: loc[1]).mean()
player_positions[player] = (avg_x, avg_y)
# 패스 빈도 계산
pass_counts = passes.groupby(['player', 'pass_recipient']).size().reset_index(name='count')
# 패스 라인 그리기
for _, row in pass_counts[pass_counts['count'] >= 3].iterrows():
if row['player'] in player_positions and row['pass_recipient'] in player_positions:
x1, y1 = player_positions[row['player']]
x2, y2 = player_positions[row['pass_recipient']]
lw = row['count'] / 5
pitch.lines(y1, x1, y2, x2, ax=ax,
lw=lw, color='white', alpha=0.5, zorder=1)
# 선수 포지션 표시
for player, (x, y) in player_positions.items():
pitch.scatter(y, x, ax=ax, s=300, color='gold',
edgecolors='black', linewidths=1.5, zorder=2)
last_name = player.split()[-1]
ax.annotate(last_name, (y, x), textcoords="offset points",
xytext=(0, 8), ha='center', fontsize=7, color='white', fontweight='bold')
4.5 손흥민 xG vs 실제 골 비교 분석
손흥민은 단순한 득점 기계가 아닙니다. 그의 슈팅 능력은 xG 모델 기준으로도 리그 최상위권에 속합니다.
분석 관점:
- 마무리 능력 (Finishing Ability): xG 대비 초과 달성 골 수. 손흥민은 EPL 통산 xG를 꾸준히 초과 달성합니다
- 슈팅 존: 페널티 박스 내 왼발 슈팅이 주력이지만, 30미터 이상 장거리 슛 성공률도 리그 평균보다 높습니다
- 비득점 기여: xG 생성(슈팅이 아닌 패스로 만든 기회)도 분석하면 어시스트 이외의 공격 기여도가 보입니다
5. AI & Machine Learning in Sports
5.1 부상 예측 모델
선수 부상은 스포츠 팀이 직면하는 가장 큰 리스크 중 하나입니다. NBA 구단들은 스타 선수 한 명의 부상으로 수천만 달러의 손실을 입습니다.
현대 부상 예측 시스템의 구성:
- 생체 역학 데이터: GPS 트래커, 가속도계로 선수의 점프 횟수, sprint 거리, 방향 전환 빈도 등을 기록합니다
- 누적 피로 지표: 훈련 부하(Training Load)와 회복 지표의 비율(ACWR, Acute:Chronic Workload Ratio). ACWR이 1.5 이상이면 부상 위험이 급증합니다
- 생체 지표: 심박수 변동성(HRV), 수면 품질, 혈액 마커(크레아틴 키나아제 등)
- 게임 데이터: 고강도 스프린트 횟수, 충돌 횟수 등
# 부상 예측 모델 예시 (간략화)
import pandas as pd
import numpy as np
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import classification_report, roc_auc_score
# 가상의 선수 훈련 데이터
np.random.seed(42)
n_players = 500
data = pd.DataFrame({
'acute_load': np.random.normal(450, 80, n_players), # 지난 1주 훈련 부하
'chronic_load': np.random.normal(400, 60, n_players), # 지난 4주 평균 부하
'sleep_quality': np.random.uniform(3, 10, n_players), # 수면 품질 점수
'hrv': np.random.normal(65, 15, n_players), # 심박 변동성
'sprint_distance': np.random.normal(350, 50, n_players), # 일일 스프린트 거리
'days_since_rest': np.random.randint(0, 7, n_players),
'age': np.random.randint(18, 38, n_players),
'previous_injuries': np.random.randint(0, 5, n_players)
})
# ACWR 계산 (Acute:Chronic Workload Ratio)
data['acwr'] = data['acute_load'] / data['chronic_load']
# 부상 레이블 생성 (실제 데이터에서는 의료 기록 사용)
injury_prob = (
0.1 +
0.3 * (data['acwr'] > 1.5).astype(float) +
0.15 * (data['sleep_quality'] < 5).astype(float) +
0.1 * (data['hrv'] < 50).astype(float) +
0.05 * (data['previous_injuries'] > 2).astype(float)
)
data['injured'] = (np.random.random(n_players) < injury_prob).astype(int)
print(f"부상 발생률: {data['injured'].mean():.2%}")
# 모델 학습
features = ['acwr', 'sleep_quality', 'hrv', 'sprint_distance',
'days_since_rest', 'age', 'previous_injuries']
X = data[features]
y = data['injured']
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
model = GradientBoostingClassifier(
n_estimators=200,
max_depth=4,
learning_rate=0.05,
random_state=42
)
model.fit(X_train_scaled, y_train)
# 평가
y_pred = model.predict(X_test_scaled)
y_prob = model.predict_proba(X_test_scaled)[:, 1]
print("\n분류 리포트:")
print(classification_report(y_test, y_pred))
print(f"ROC-AUC: {roc_auc_score(y_test, y_prob):.4f}")
# 피처 중요도
importance_df = pd.DataFrame({
'feature': features,
'importance': model.feature_importances_
}).sort_values('importance', ascending=False)
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(10, 6))
ax.barh(importance_df['feature'], importance_df['importance'], color='steelblue')
ax.set_title('부상 예측 모델 - 피처 중요도')
ax.set_xlabel('중요도')
plt.tight_layout()
plt.show()
5.2 선수 영입 최적화 (Recruitment Analytics)
현대 축구 클럽들은 이적 시장에서 AI를 활용한 선수 영입 의사결정을 합니다.
Wyscout / InStat / Statsbomb 같은 데이터 플랫폼에서 수천 개의 리그 데이터를 수집하고, 클러스터링(Clustering) 분석으로 유사한 플레이 스타일의 선수를 찾습니다.
주요 접근법:
- K-means 클러스터링: 포지션별 플레이 스타일 그룹화
- 코사인 유사도: 특정 선수와 가장 유사한 선수 탐색
- 이적 가치 예측: XGBoost 모델로 5년 후 선수 가치 예측
5.3 경기 결과 예측 모델
# 축구 경기 결과 예측 (포아송 분포 기반)
import numpy as np
from scipy.stats import poisson
def predict_match(home_attack, home_defense, away_attack, away_defense,
home_advantage=1.1):
"""
포아송 분포를 이용한 경기 결과 예측
attack: 공격력 지수 (리그 평균 = 1.0)
defense: 방어력 지수 (리그 평균 = 1.0, 낮을수록 수비가 강함)
"""
league_avg_goals = 1.35 # EPL 평균 경기당 득점
home_goals_exp = home_attack * away_defense * league_avg_goals * home_advantage
away_goals_exp = away_attack * home_defense * league_avg_goals
max_goals = 10
home_win = 0
draw = 0
away_win = 0
for i in range(max_goals):
for j in range(max_goals):
p = poisson.pmf(i, home_goals_exp) * poisson.pmf(j, away_goals_exp)
if i > j:
home_win += p
elif i == j:
draw += p
else:
away_win += p
return {
'home_goals_exp': round(home_goals_exp, 2),
'away_goals_exp': round(away_goals_exp, 2),
'home_win': round(home_win, 3),
'draw': round(draw, 3),
'away_win': round(away_win, 3)
}
# 맨체스터 시티 vs 아스널 예시
result = predict_match(
home_attack=1.4, # 시티 공격력
home_defense=0.7, # 시티 수비력
away_attack=1.3, # 아스널 공격력
away_defense=0.75 # 아스널 수비력
)
print("맨체스터 시티 vs 아스널 예측:")
for k, v in result.items():
print(f" {k}: {v}")
6. Python 실습: 종합 스포츠 데이터 분석 프로젝트
6.1 pandas로 선수 성적 분석
import pandas as pd
import numpy as np
# 가상의 NBA 선수 시즌 데이터
np.random.seed(42)
n_players = 100
players_data = pd.DataFrame({
'player_name': [f'Player_{i:03d}' for i in range(n_players)],
'team': np.random.choice(['LAL', 'GSW', 'BOS', 'MIA', 'DEN'], n_players),
'position': np.random.choice(['PG', 'SG', 'SF', 'PF', 'C'], n_players),
'age': np.random.randint(19, 38, n_players),
'games_played': np.random.randint(30, 82, n_players),
'minutes': np.random.normal(28, 8, n_players).clip(8, 40),
'points': np.random.normal(14, 6, n_players).clip(0, 40),
'rebounds': np.random.normal(5, 2.5, n_players).clip(0, 15),
'assists': np.random.normal(4, 2.5, n_players).clip(0, 12),
'fg_pct': np.random.normal(0.46, 0.06, n_players).clip(0.2, 0.7),
'three_pct': np.random.normal(0.35, 0.08, n_players).clip(0.1, 0.55),
'ft_pct': np.random.normal(0.78, 0.1, n_players).clip(0.4, 1.0),
'turnovers': np.random.normal(2.5, 1, n_players).clip(0, 6),
'steals': np.random.normal(1.2, 0.5, n_players).clip(0, 3.5),
'blocks': np.random.normal(0.8, 0.6, n_players).clip(0, 4),
'salary_million': np.random.lognormal(2.5, 0.7, n_players)
})
# 고급 지표 계산
# TS% (True Shooting Percentage)
players_data['ts_pct'] = players_data['points'] / (
2 * (players_data['points'] / players_data['fg_pct'] + 0.44 * players_data['points'] / players_data['ft_pct'])
).clip(lower=0.001)
# PER 근사값 (단순화)
players_data['per_approx'] = (
players_data['points'] +
players_data['rebounds'] * 1.2 +
players_data['assists'] * 1.5 +
players_data['steals'] * 2 +
players_data['blocks'] * 2 -
players_data['turnovers'] * 1.5
) / players_data['minutes'] * 15
# 급여 효율 (WAR per dollar)
players_data['value_score'] = players_data['per_approx'] / players_data['salary_million']
# 분석 리포트
print("=== NBA 선수 분석 리포트 ===\n")
print("포지션별 평균 스탯:")
pos_stats = players_data.groupby('position')[
['points', 'rebounds', 'assists', 'per_approx']
].mean().round(2)
print(pos_stats)
print("\n\n상위 10 가성비 선수 (per_approx / salary):")
top_value = players_data.nlargest(10, 'value_score')[
['player_name', 'team', 'position', 'points', 'per_approx', 'salary_million', 'value_score']
]
print(top_value.to_string(index=False))
6.2 scikit-learn으로 성과 예측 모델
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import cross_val_score
import matplotlib.pyplot as plt
# 다음 시즌 득점 예측 모델
features = ['age', 'minutes', 'fg_pct', 'three_pct', 'ft_pct',
'rebounds', 'assists', 'turnovers', 'steals', 'blocks']
target = 'points'
X = players_data[features]
y = players_data[target]
model = RandomForestRegressor(n_estimators=100, random_state=42)
cv_scores = cross_val_score(model, X, y, cv=5, scoring='r2')
print(f"5-Fold CV R² 점수: {cv_scores.mean():.4f} (±{cv_scores.std():.4f})")
model.fit(X, y)
# 피처 중요도 시각화
importance_df = pd.DataFrame({
'feature': features,
'importance': model.feature_importances_
}).sort_values('importance', ascending=False)
fig, ax = plt.subplots(figsize=(10, 6))
ax.barh(importance_df['feature'], importance_df['importance'],
color=plt.cm.RdYlGn(np.linspace(0.3, 0.9, len(features))))
ax.set_title('NBA 득점 예측 모델 - 피처 중요도')
ax.set_xlabel('상대적 중요도')
plt.tight_layout()
plt.show()
7. 스포츠 데이터 분석 커리어 로드맵
스포츠 데이터 분석가가 되기 위한 구체적인 로드맵:
1단계 - 기초 (0~6개월):
- Python (pandas, numpy, matplotlib, seaborn)
- 통계학 기초 (확률, 회귀분석, 가설 검정)
- 좋아하는 스포츠의 기본 지표 공부
2단계 - 심화 (6~18개월):
- 머신러닝 (scikit-learn, XGBoost)
- SQL 데이터베이스
- 스포츠 전용 라이브러리 (pybaseball, nba_api, statsbombpy, mplsoccer)
- Tableau / Power BI 시각화
3단계 - 전문화 (18개월 이후):
- 컴퓨터 비전 (선수 추적, 영상 분석)
- 자연어처리 (미디어 감성 분석)
- 실제 팀/클럽 인턴십 또는 오픈 소스 기여
추천 자료:
- 책: "The Book: Playing the Percentages in Baseball" (탕고 외)
- 책: "Basketball on Paper" (딘 올리버)
- 온라인: StatsBomb 블로그, FiveThirtyEight, The Athletic
8. 퀴즈: 스포츠 데이터 분석
퀴즈 1: 세이버메트릭스의 핵심 개념
질문: 야구에서 FIP(Fielding Independent Pitching)가 ERA보다 투수 평가에 더 유용한 이유는 무엇인가요?
정답: FIP는 투수가 직접 통제할 수 있는 요소(삼진, 볼넷, 홈런)만으로 계산하기 때문에 수비력의 영향을 받지 않습니다.
설명: ERA(방어율)는 수비수가 놓친 타구가 안타로 기록되면 투수에게 불이익을 줍니다. 반면 FIP는 타자가 친 공이 수비 가능 지역에 떨어지는 상황(인플레이 아웃)을 제거하고 투수 고유의 기술만 측정합니다. 장기적으로 같은 투수의 ERA와 FIP의 차이가 크다면, ERA가 FIP 쪽으로 수렴하는 경향이 있어 투수의 미래 성과 예측에 FIP가 더 안정적입니다.
퀴즈 2: NBA 3점 혁명의 수학적 근거
질문: NBA에서 코너 3점 슛(성공률 38%)과 미드레인지 2점 슛(성공률 45%)의 기대값을 계산하고, 어떤 슛이 더 효율적인지 설명하세요.
정답: 코너 3점이 더 효율적입니다. 코너 3점 기대값 = 0.38 × 3 = 1.14점, 미드레인지 2점 기대값 = 0.45 × 2 = 0.90점.
설명: 기대값은 슛 성공률에 득점을 곱한 값입니다. 코너 3점의 기대값(1.14점)이 미드레인지 2점(0.90점)보다 약 27% 높습니다. 이것이 현대 NBA가 미드레인지 슛을 지양하고 3점 슛과 페인트 존 슛에 집중하는 이유입니다. 미드레인지 2점이 기대값 면에서 3점과 동등하려면 성공률이 최소 57%가 되어야 합니다.
퀴즈 3: xG 해석
질문: 어떤 공격수가 한 시즌에 xG 합계 15.3을 기록했지만 실제 골은 8개에 그쳤습니다. 이 데이터를 어떻게 해석해야 할까요?
정답: 이 선수는 xG 대비 7.3골 미달로, 불운이나 마무리 능력 부족이 원인일 수 있습니다. 다음 시즌에는 성적이 개선될 가능성이 있습니다.
설명: xG는 슛의 품질을 나타내므로, 15.3 xG는 "평균적인 슈터라면 이 슛들로 약 15~16골을 넣었을 것"을 의미합니다. 8골은 크게 낮은 수치입니다. 원인으로는 골키퍼의 선방, 포스트 타격, 마무리 기술 부족 등이 있습니다. 통계적으로 극단적인 과소 달성은 평균으로 회귀하는 경향이 있어 다음 시즌 성적 개선 가능성이 높습니다.
퀴즈 4: ACWR과 부상 예측
질문: ACWR(Acute:Chronic Workload Ratio)가 1.5 이상일 때 부상 위험이 증가하는 이유는 무엇이며, 이 지표를 어떻게 관리해야 할까요?
정답: ACWR 1.5 이상은 최근 훈련 부하가 몸이 적응된 수준의 1.5배를 초과했다는 의미로, 신체가 회복하지 못한 채 과부하 상태임을 나타냅니다.
설명: Acute(급성) 부하는 지난 1주의 훈련량, Chronic(만성) 부하는 지난 4주 평균 훈련량입니다. ACWR 1.01.3은 "훈련 적응 구간(Sweet Spot)"으로 부상 위험 없이 체력 향상이 됩니다. 1.5 이상에서는 근육, 힘줄, 인대가 적응보다 빠르게 손상되어 부상 위험이 47배 증가합니다. 관리 방법: 급격한 훈련량 증가(주 10% 이상)를 피하고, GPS 트래커로 부하를 모니터링하며, 고강도 훈련 후 충분한 회복 시간을 확보합니다.
퀴즈 5: 패스 네트워크 분석
질문: 축구의 패스 네트워크 분석에서 '매개 중심성(Betweenness Centrality)'이 높은 선수가 부상으로 결장할 때 팀에 어떤 영향이 생기는지 설명하세요.
정답: 매개 중심성이 높은 선수는 팀 패스 흐름의 허브(hub) 역할을 하므로, 결장 시 팀 전체의 빌드업이 단절되고 공격 흐름이 크게 약화됩니다.
설명: 매개 중심성은 그래프 이론에서 특정 노드(선수)가 다른 노드들 간의 최단 경로 위에 얼마나 많이 위치하는지를 나타냅니다. 축구에서 이 수치가 높은 선수(예: 딥라잉 미드필더)는 수비진과 공격진을 연결하는 핵심 연결고리입니다. 이 선수가 빠지면 팀은 롱볼에 의존하거나 경기 패턴이 예측 가능해지며, 상대 팀의 압박에 더 취약해집니다.
마치며
스포츠 데이터 분석은 단순히 숫자를 다루는 것이 아닙니다. 수십 년간 경험과 직감에 의존하던 스포츠의 세계에 과학적 사고방식을 도입하는 지적 혁명입니다. 오클랜드의 작은 야구팀이 세상을 바꿨듯이, 여러분도 좋아하는 스포츠와 데이터 사이에서 새로운 진실을 발견할 수 있습니다.
Python 코드와 함께 시작해보세요. 가장 좋아하는 팀의 데이터를 내려받고, 한 가지 지표만 분석해보는 것으로 충분합니다. 그 첫 발걸음이 여러분을 스포츠 분석의 세계로 이끄는 머니볼의 시작입니다.