본문 바로가기
kaggle study

Big Data Derby 2022

by kangdev 2024. 12. 5.

대회명 : Big Data Derby 2022

경마 데이터를 분석하여 말의 건강과 경쟁 전략 개선

https://www.kaggle.com/competitions/big-data-derby-2022/overview

[Big Data Derby 2022

Analyze horse racing data to improve the health of the horse and strategy of competition

www.kaggle.com](https://www.kaggle.com/competitions/big-data-derby-2022/overview)

- 대회 설명

  • 주어진 경주 데이터를 기반으로 말의 성과를 예측하는 문제.
  • 특정 조건에서 말의 속도, 랩타임, 피로도 등을 평가.

- 평가 방식

  • 혁신
    • 데이터를 보는 새로운 관점인가, 접근 방식이 표준이가, 결론이 현재 경마현상에 도전할 수 있는가
  • 관련성
    • 결론이 말의 복지, 성능 혹은 라이더의 의사결정에 영향을 미칠 수 있는가
    • 미래의 연구 기초가 될 수 있는가
    • 경마참가자가 이해,소화,토론할 수 있는가
  • 역량
    • 통계적 모델이 주어진 데이터에 적절한가
    • 결론이 데이터에 의해 뒷바침되는가
    • 분석이 정확한가
  • 프레젠테이션
    • 글이 정확한가
    • 차트와 표가 흥미롭고 시각적으로 매력적이며 정확한가
    • 프레젠테이션 내내 분석 스레드를 따라갈 수 있는가

- 수상작 관련 설명 및 정보

  • 분석 결과를 통해 경주마 트레이너, 경마 운영자, 베팅 참여자 등에게 실질적인 도움을 줄 수 있는 인사이트를 도출.
  • 어떤 말이 특정 조건에서 더 유리한지 예측, 경주 전략을 개선할 수 있는 방법을 제시.

https://www.kaggle.com/code/tanmay111999/big-data-derby-2022-eda-insights

[Big Data Derby 2022 : EDA Insights

Explore and run machine learning code with Kaggle Notebooks | Using data from Big Data Derby 2022

www.kaggle.com](https://www.kaggle.com/code/tanmay111999/big-data-derby-2022-eda-insights)

- 데이터 전처리

  • 데이터 소개: 뉴욕 경마 협회(NYRA) 데이터
    • nyra_start_table.csv - 말/joc 키 레이스 데이터
    • nyra_race_table.csv - 경마장 경주 데이터
    • nyra_tracking_table.csv - 트랙 데이터
    • nyra_2019_complete.csv - 위의 세 파일을 합친 표
  • 데이터 확인
df.isnull().sum()

 

  • data의 칼럼 부분에 데이터가 들어가있는 현상 발견
df.columns

 

 

 

  • d 데이터 프레임을 생성하고 원본의 0번째 데이터를 생성, 원본 데이터 프레임의 칼럼명을 생성
complete_table = pd.read_csv(f'{data_path}/nyra_2019_complete.csv') 

# 새 DataFrame d를 생성하며, complete_table의 컬럼 이름들을 하나의 행으로 추가
# 원본 DataFrame의 columns에 있는 데이터를 보존하기 위한 작업
d = pd.DataFrame(columns=range(len(list(complete_table.columns))))  # 열 개수만큼의 빈 DataFrame 생성 (range를 통해 2차원형태의 튜플로 생성)
d.loc[0] = list(complete_table.columns)  # complete_table의 컬럼 이름들을 첫 번째 행에 추가

# complete_table의 컬럼 이름을 새로 지정
# 컬럼 이름을 간소화하거나 더 의미 있는 이름으로 변경
complete_table.columns = ['track_id', 'race_date', 'race_number', 'program_number', 'trakus_index', 'latitude', 'longitude', 
                          'distance_id', 'course_type', 'track_condition', 'run_up_distance', 'race_type', 'purse', 
                          'post_time', 'weight_carried', 'jockey', 'odds', 'position_at_finish']

# 위에서 생성한 DataFrame d의 컬럼 이름을 동일하게 설정 (일관성)
d.columns = ['track_id', 'race_date', 'race_number', 'program_number', 'trakus_index', 'latitude', 'longitude', 
             'distance_id', 'course_type', 'track_condition', 'run_up_distance', 'race_type', 'purse', 
             'post_time', 'weight_carried', 'jockey', 'odds', 'position_at_finish']

 

# complete_table의 첫 번째 행에 컬럼 정보가 포함된 DataFrame d를 위에 결합
# 실제 데이터 아래에 메타데이터 행이 추가됨 (분석 과정에서 유용하게 사용 가능)
complete_table = pd.concat([d, complete_table]).reset_index(drop=True)

-> 두 데이터프레임을 concat을 해줌으로서 해당 문제를 해결

 

# start_table의 컬럼 이름을 의미 있게 변경
start_table.columns = ['track_id', 'race_date', 'race_number', 'program_number', 
                       'weight_carried', 'jockey', 'odds', 'position_at_finish']

-> 칼럼을 가지고 있지 않은 또 다른 원본데이터에 칼럼을 생성

 

 

  • 데이터의 타입변환 및 정규표현식을 사용한 추출
# 'race_number' 컬럼을 int8 타입으로 변환 (더 적은 메모리 사용)
complete_table['race_number'] = complete_table['race_number'].astype('int8') # 1 - race_number

 

 

  • 확인을 위한 type체크
# program_number의 type 체크
from collections import Counter # list 내부 요소 values count를 위한 라이브러리

emt_list = []
for i in complete_table['program_number']:
    emt_list.append(type(i))
    
type_check = Counter(emt_list)

for key in type_check:
    print(key, type_check[key]) # program_number에 맞도록 해당 object타입을 int로 통일

 

 

  • 숫자 추출
# 정규표현식을 사용해 문자열에서 숫자를 추출
from tqdm import tqdm
import re
r1 = re.compile('\d+')  # 숫자를 탐지하는 정규표현식
is_string = lambda x : type(x) == str  # 문자열인지 확인하는 람다 함수

# program_number 컬럼의 각 값을 처리
l1 = [i.split()[0] if type(i) == str else i for i in tqdm(list(complete_table['program_number']))] # 공백으로 구분된 첫 번째 값 추출
l2 = []  # 결과를 저장할 리스트

for i in tqdm(l1):
    if is_string(i):  # 문자열인 경우
        try:
            if int(r1.findall(i)[0]) == int(i):  # 정규표현식으로 추출한 숫자가 원래 값과 일치하면
                l2.append(int(r1.findall(i)[0]))  # 숫자로 변환해 추가
        except ValueError:  # 변환 실패 시 원래 값을 추가
            l2.append(i)
    else:
        l2.append(i)  # 숫자인 경우 그대로 추가

# 변환된 값을 program_number 컬럼에 재저장
complete_table['program_number'] = l2 # 2 - program_number

 

 

  • 추가 숫자형 칼럼의 타입 변환
complete_table['trakus_index'] = complete_table['trakus_index'].astype('int16')  # 3 - trakus_index
complete_table['latitude'] = complete_table['latitude'].astype('float16')  # 4 - latitude
complete_table['longitude'] = complete_table['longitude'].astype('float16')  # 5 - longitude
complete_table['distance_id'] = complete_table['distance_id'].astype('int16')  # 6 - distance_id
complete_table['run_up_distance'] = complete_table['run_up_distance'].astype('int16')  # 7 - run_up_distance
complete_table['purse'] = complete_table['purse'].astype(float).astype('int32')  # 8 - purse (float -> int32 변환)
complete_table['weight_carried'] = complete_table['weight_carried'].astype('int8')  # 9 - weight_carried
complete_table['odds'] = complete_table['odds'].astype('int16')  # 10 - odds
complete_table['position_at_finish'] = complete_table['position_at_finish'].astype('int8')  # 11 - position_at_finish

 

 

  • 시간데이터 처리
# post_time 컬럼을 문자열로 변환 (시간 데이터 포맷 작업 준비)
complete_table['post_time'] = complete_table['post_time'].astype(str)  # 12 - post_time

# post_time과 race_date를 조합하여 정확한 시간 계산
l1 = list(complete_table['post_time'])  # post_time 값을 리스트로 변환
l2 = list(complete_table['race_date'])  # race_date 값을 리스트로 변환
l = []  # 처리된 시간을 저장할 리스트

for i, j in tqdm(zip(l1, l2)):  # post_time과 race_date를 함께 반복
    if len(i) == 3:  # 시간 형식이 'HMM'일 경우
        l.append(i[:1] + ':' + i[1:] + ' ' + j)  # 'H:MM' 형식으로 변환
    elif len(i) == 4:  # 시간 형식이 'HHMM'일 경우
        l.append(i[:2] + ':' + i[2:] + ' ' + j)  # 'HH:MM' 형식으로 변환
    elif len(i) == 5:  # 시간 형식이 'HH:MM'일 경우
        l.append(i[1:3] + ':' + i[3:] + ' ' + j)  # 이미 형식화된 값을 결합

# 리스트를 datetime 형식으로 변환
l = pd.to_datetime(pd.Series(l), format='%H:%M %Y-%m-%d')  # 'HH:MM YYYY-MM-DD' 형식
complete_table['post_time'] = l  # 변환된 값으로 post_time 업데이트

 

 

  • 텍스트 데이터를 라벨 인코딩으로 수치화하고, 변환된 값과 원래 값을 비교, 출력하여 데이터 준비를 위한 필수 전처리 작업을 수행
from sklearn.preprocessing import LabelEncoder  # Label Encoding을 위한 라이브러리 import

# Label Encoder 객체 생성
le = LabelEncoder()

# 변환 대상 텍스트 컬럼 리스트
text_data_features = ['track_id', 'course_type', 'track_condition', 'race_type', 'jockey']

# 결과를 저장할 리스트 초기화
l3 = []  # 인코딩된 고유 숫자 값 저장
l4 = []  # 고유 텍스트 값(원본) 저장

print('Label Encoder Transformation')  # 변환 과정 출력
# 컬럼별 Label Encoding 수행

for i in tqdm(text_data_features):  # 텍스트 데이터 컬럼 리스트를 순회
    complete_table[i] = le.fit_transform(complete_table[i])  # 해당 컬럼을 Label Encoding 수행
    l3.append(list(complete_table[i].unique()))  # 변환된 고유 숫자 값을 저장
    l4.append(list(le.inverse_transform(complete_table[i].unique())))  # 원래 텍스트 값을 저장
    print(i, ' : ', complete_table[i].unique(), ' = ', le.inverse_transform(complete_table[i].unique()))
# 텍스트 데이터의 라벨-텍스트 대응 관계를 저장하기 위한 딕셔너리 생성
tf1 = {}  # 각 텍스트 피처에 대한 라벨-텍스트 매핑을 저장하는 딕셔너리

# 텍스트 피처를 순회
for i in range(len(text_data_features)):
    # 현재 텍스트 피처에 대해 빈 딕셔너리를 생성하여 tf1에 추가
    tf1[text_data_features[i]] = {}
    
    # l3[i]는 라벨링된 숫자 값, l4[i]는 해당 라벨의 원래 텍스트 값
    # zip()으로 두 리스트를 묶어 (라벨 값, 텍스트 값) 쌍을 생성
    for j, k in zip(l3[i], l4[i]):
        # 라벨 값(j)을 키로, 원래 텍스트 값(k)을 값으로 저장
        tf1[text_data_features[i]][j] = k

# 최종적으로 tf1에는 각 텍스트 피처별 라벨-텍스트 매핑이 저장됨
  • 데이터 볼륨 최소화를 위해 pickle파일로 저장 및 수행
import pickle

complete_table.to_pickle('complete_table' + '.pkl',compression = 'gzip' )
with open('tf1.pkl','wb') as f:
    pickle.dump(tf1,f)

 

 

  • start table과 같은 다른 데이터들도 마찬가지로 전처리 작업 수행 후 로드
df1 = pd.read_pickle('./complete_table.pkl',compression = 'gzip')
df2 = pd.read_pickle('./race_table.pkl',compression = 'gzip')
df3 = pd.read_pickle('./start_table.pkl',compression = 'gzip')

with open('./tf1.pkl', 'rb') as file:  
    tf1 = pickle.load(file)
with open('./tf2.pkl', 'rb') as file:  
    tf2 = pickle.load(file)    
with open('./tf3.pkl', 'rb') as file:  
    tf3 = pickle.load(file)
    
colors = ['#0047AB','000000']

 

 

- 데이터 분석

  • 경마장과 다른 특성에 대한 인사이트를 얻는 것을 목표로 함
plt.figure(figsize=(15, 5))
ax = sns.countplot(x='race_number', data=df2, palette=colors, edgecolor='black')  # 'x'로 명시적으로 지정, 지정하지 않을 시 에러발생
for rect in ax.patches:
    ax.text(rect.get_x() + rect.get_width() / 2, rect.get_height() + 2, rect.get_height(), 
            horizontalalignment='center', fontsize=11)
title = 'Total Number of Races : ' + str(sum(df2['race_number'].value_counts()))
plt.title(title)

race_number 8 =  212개의 경기, race_number 13 = 3개의 경기이를 통해 경주 번호가 증가함에 따라 경주의 수가 급격히 감소하는 패턴이 나타난다고 해석

 

 

plt.figure(figsize = (15,5))
ax = sns.countplot(x='track_id',data = df2,palette = colors, edgecolor = 'black') # 마찬가지로 x를 명시적 지정
for rect in ax.patches:
    ax.text(rect.get_x() + rect.get_width() / 2, rect.get_height() + 2, rect.get_height(), horizontalalignment='center', fontsize = 11)
ax.set_xticklabels(tf2['track_id'].values())
title = 'Number of Races conducted on Different Racetracks'
plt.title(title)

* AUK(Aqueduct)가 가장 많은 825번의 레이스를 진행 * BEL(벨몬트) 772번의 레이스 * SAR(사라토가)이 403번의 레이스로 가장 낮음

 

 

 

### 이제 위 트랙이 경마에 사용된 달이 언제인지 확인
plt.figure(figsize = (15,5))
sns.lineplot(x='race_date', y='race_number',data=df2, hue='track_id',ci=None)
plt.legend(tf2['track_id'].values())
plt.title('Races Throughout the Year on Different Tracks')

```

  • 경마 시즌은 아마도 10월 말, 즉 11월 초에 시작된다고 할 수 있는데, 이는 y축의 race_number 1에 닿는 거대한 스파이크 현상 때문.
  • AUK(Aqueduct)는 10월/11월-4월에 몇 달 동안 레이스를 개최.
  • BEL(벨몬트) 레이스에서 2개월 동안 진행되는 레이스가 SAR(사라토가) 경마장에서 진행되는 레이스로 인해 중단됨.
  • BEL(벨몬트) 레이스는 9월 한 달부터 10월 말까지(11월 시작) 다시 시작
  • AUK(Aqueduct) 트랙에서 진행되는 레이스는 나머지 두 개의 트랙에서 진행되는 결선 토너먼트로 향하는 일종의 예선 레이스라고 추측

 

  • 각 경주로(track_id)에 따른 거리(distance_id)의 분포
plt.figure(figsize=(15, 5))
ax = sns.boxplot(x='track_id', y='distance_id', data=df2, palette=colors)  # 'track_id'별로 'distance_id'의 분포를 나타냄

lines = ax.get_lines()  # 그래프의 선(Line) 객체를 가져옴
categories = ax.get_xticks()  # x축의 각 카테고리(경주로 ID) 위치 가져오기

# 각 경주로에 대해 상자 그림의 중앙값에 라벨 추가
for cat in categories: # cat = 현재 x축의 카테고리(경주로 ID)의 인덱스
    y = round(lines[4 + cat * 6].get_ydata()[0], 1)  # 중앙값(y값)을 계산
    # cat = 0 (첫 번째 카테고리): 4 + 0 * 6 = 4 → 첫 번째 카테고리의 중앙값 선.
    # cat = 1 (두 번째 카테고리): 4 + 1 * 6 = 10 → 두 번째 카테고리의 중앙값 선.
    # -->여러 카테고리의 중앙값을 반복적으로 참조할 수 있도록 작성한 코드
    ax.text(cat, y, f'{y}', ha='center', va='center', fontweight='semibold', size=12,
            color='white', bbox=dict(facecolor='#828282', edgecolor='#828282'))  # 중앙값 표시

ax.set_xticklabels(tf2['track_id'].values())  # x축 라벨을 변환 (예: track_id의 이름값으로 변경)
plt.title('Distance of each Tracks')

  • 세 개의 모든 경주로는 700 야드의 중앙값 거리를 가지고 있다. 
  • 하지만, 1400 야드(14 펄롱)를 초과하는 이상치가 BEL(Belmont)과 SAR(Saratoga) 경주로에서 발견될 수 있음.

 

 

plt.figure(figsize = (15,5))
ax = sns.boxplot(x = 'track_id',y = 'run_up_distance',data = df2, palette = colors);
lines = ax.get_lines()
categories = ax.get_xticks()

for cat in categories:
    y = round(lines[4+cat*6].get_ydata()[0],1) 
    ax.text(cat,y,f'{y}',ha='center',va='center',fontweight='semibold',size=12,color='white',bbox=dict(facecolor='#828282', edgecolor='#828282'))    
ax.set_xticklabels(tf2['track_id'].values())
plt.title('Distance from Gate to the Start of the Race of the Race Tracks')

  •  AQU(Aqueduct)와 SAR(Saratoga)의 run_up_distance(출발 거리) 중앙값은 동일하며, 52피트이다.
  • 반면 **BEL(Belmont)**의 run_up_distance는 65피트로 다른 두 경주로보다 더 길다.
  • 이상치를 포함한 가장 높은 run_up_distance 값은 SAR(Saratoga) 경주로에서 발견되며, 250피트 이상이다.

 

 

plt.figure(figsize = (15,5))
ax = sns.countplot(x='race_type',hue = 'track_id',data = df2,palette = colors, edgecolor = 'black')
ax.set_xticklabels([tf2['race_type'][i] for i in sorted(df2['race_type'].unique())])
plt.legend(tf2['track_id'].values())
title = 'Different Types of Races Conducted at Different Race Tracks'
plt.title(title)

 

plt.figure(figsize = (15,5))
ax = sns.boxplot(x = 'track_id',y = 'purse',data = df2, palette = colors)
lines = ax.get_lines()
categories = ax.get_xticks()

for cat in categories:
    y = round(lines[4+cat*6].get_ydata()[0],1) 
    ax.text(cat,y,f'{y}',ha='center',va='center',fontweight='semibold',size=12,color='white',bbox=dict(facecolor='#828282', edgecolor='#828282'))    
ax.set_xticklabels(tf2['track_id'].values())
plt.title('Purse values at Different Racetracks')​

  •  BEL(Belmont) 경주로에서 가장 높은 이상치로 기록된 상금(purse) 값은 140만 달러를 초과함.
  • SAR(Saratoga) 경주로는 적은 수의 경주가 열렸지만, 중앙값 상금(purse) 값이 8만 달러로 다른 두 경주로보다 더 높다.

 

Lesson Learned

해당 수상작을 구현하며 datatype 변경과  csv보다 pickle를 사용할때의 데이터 볼륨 차이를 신경쓰는것을 보며 이런 많은 양의 데이터에서는 사이즈를 신경써야 한다는것을 다시한번 느꼈고,  비정형데이터의 인코더에서 매핑을 하는것을 보고 추후 증분데이터 처리 혹은 추적관리를 굉장히 신경쓴다는것을 알 수 있었다.

'kaggle study' 카테고리의 다른 글

Loan Approval Prediction  (2) 2024.12.13
Regression of Used Car Prices  (4) 2024.12.09
Data Science and MLOps Landscape in Industry  (0) 2024.12.03