< 문제 발견 및 정의 >
ufc 경기는 배당률이나 승패를 예측하는 것이 어려워 '오즈메이커'라는 전문 직업이 필요하다. 이 '오즈메이커'가 배당률을 예측하여, 예측값과 크게 다를 경우 승부조작을 의심해 볼 수 있다고 한다. 머신러닝 모델로 배당률을 예측하여 승부 조작을 예방할 수 있을 것이라 기대되어 주제로 선정했다.
< 데이터 셋 >
kaggle의 데이터셋 2개를 사용했다.
1. ufc-master.csv
https://www.kaggle.com/datasets/mdabbert/ultimate-ufc-dataset
R_, B_ | 해당 수치가 Red 선수인지 Blue 선수인지 |
R_fighter : Fighter names | 선수의 이름 |
R_odds : The American odds that the fighter will win. Usually scraped from bestfightodds.com | 배당률 |
R_ev : The American odds that the fighter will win. Usually scraped from bestfightodds.com | 배당금 |
date : The date of the fight | 날짜 |
country : The country the fight occurs in location : The location of the fight |
장소 |
Winner : The winner of the fight | 승자 [Red, Blue, or Draw] |
title_bout : Was this a title bout? | tilte_bout인지 아닌지 |
R_weight_class : The weight class of the bout | 체급 |
R_gender : Gender of the combatants | 성별 |
no_of_rounds: The number of rounds in the fight | 해당 경기에서 몇 라운드를 진행하였는지 |
R_current_lose_streak: Current losing streak R_current_win_streak: Current winning streak |
현재 연속적으로 이기거나 진 횟수 |
R_draw: Number of draws | 비긴 횟수 (뜻이 확실치 않지만 승패와 관련된 피처 같아서 제외했습니다.) |
R_avg_SIG_STR_landed R_avg_SIG_STR_pct R_avg_SUB_ATT R_avg_TD_landed R_avg_TD_pct |
일정 단위(분)당 한 공격 횟수 |
R_longest_win_streak: Longest winning streak |
가장 긴 연승 횟수 |
R_losses: Total number of losses | 총 몇번 졌는지 |
R_total_rounds_fought: Total rounds fought | 경기를 한 횟수 |
R_total_title_bouts: Total number of title bouts | title bouts 경기를 한 횟수 |
R_win_by_Decision_Majority R_win_by_Decision_Split R_win_by_Decision_Unanimous R_win_by_KO/TKO R_win_by_Submission R_win_by_TKO_Doctor_Stoppage |
승리 방식(1 or 0) |
R_wins: Total career wins | 총 승리 횟수 |
R_stance: Fighter stance | 선수의 자세 |
R_Height_cms R_Reach_cms R_Weight_lbs R_age |
선수의 키, 리치, 몸무게, 나이 |
empty_arena | 관객이 있는지 없는지 |
constant_1 | The number 1 |
R_match_weightclass_rank | 체급 내에서의 몸무게 순위 |
R_Women's Flyweight_rank R_Women's Featherweight_rank R_Women's Strawweightrank R_Women's Bantamweight_rank R_Heavyweight_rank R_Light Heavyweight_rank R_Middleweight_rank R_Welterweight_rank R_Lightweight_rank R_Featherweight_rank R_Bantamweight_rank R_Flyweight_rank R_Pound-for-Pound_rank |
해당 선수의 체급(1 or 0) |
better_rank: Who has the better rank (Red, Blue, neither) | 누가 더 rank가 높은지(Blue, Red, neither) |
finish: How the fight finished | 어떤 방법으로 끝냈는지(SUB, KO/TKO, S-DEC) |
finish_details: More details about the finish if available. | 어떤 공격으로 끝냈는지(Punch, Nake 등) |
finish_round: The round the fight ended | 경기를 끝낸 라운드 |
finish_round_time: Time in the round of the finish | 마지막 라운드에 걸린 시간 |
total_fight_time_secs: Total time of the fight in seconds | 총 경기 시간 |
https://www.kaggle.com/code/tanujdhiman/ultimate-ufc-analyzation-visualization
피처의 의미는 위 링크를 참고했다.
1993년도부터 2021년도까지 ufc 경기에 관한 데이터다. 열의 개수는 119개로 앞에 R_이 붙은 피처는 Red 선수의 데이터를 나타내고, B_가 붙은 피처는 Blue 선수의 데이터를 나타낸다. 대부분의 피처에 결측치가 없었지만, 몇몇 피처에는 비어있는 칸이 안 비어있는 칸보다 많았다.
예를 들어, 선수가 해당 체급에서 몇번째로 무거운지를 나타내는 피처는 선수의 해당 등급 칸에만 값이 있었다. 게다가 categorical feature중에 균형이 맞지 않거나 중복된 값, 승리를 예측할 수 있는 값 등등 데이터 전처리가 필요했다.
2. raw_fighter_detail.csv
https://www.kaggle.com/datasets/rajeevw/ufcdata
fighter_name | 선수의 이름 |
Height | 선수의 신장 |
Weight | 선수의 몸무게 |
Reach | 선수의 리치 |
Stance | 선수의 공격 자세 |
DOB | 생년월일 |
SLpM: Significant Strikes Landed per Minute | 분당 스트라이크 횟수 |
Str_Acc | 스트라이크 정확도 |
SApM | 모든 상대 선수가 해당 선수에게 기록한 스트라이크 값(상당한 타격만 포함) |
Str_Def: Significant Strike Defence (the % of opponents strikes that did not land) | 스트라이크 방어율 |
TD_Avg: Average Takedowns Landed per 15 minutes | 15분당 테이크다운 횟수 |
TD_Acc: Takedown Accuracy | 테이크다운 정확도 |
TD_Def | 테이크다운 방어율 |
Sub_Avg | 15분당 서브미션 횟수 |
ufc 선수에 대한 데이터이다. 선수의 커리어 동안 기록한 모든 공격과 방어를 합산해 시간 별로 나눈 값이다. 열의 수는 14개로, 1번 데이터와 겹치는 열이 있다. 선수의 Height, Reach, Stance에 결측치가 많았는데 1번 데이터에 있는 선수 데이터(1번 데이터와 2번 데이터에 공통으로 있는 선수의 경기 데이터만 사용했다)만 사용해서 따로 보강하지는 않았다. 1번 데이터에는 R선수와 B선수에 대한 데이터가 따로 있어서 2번 데이터를 각 선수에 대해 따로 넣었다.
< 데이터 전처리 >
1. 날짜 변경
날짜를 나타내는 date가 2/21/2010처럼 월/일/년도의 순서로 저장되어 있었다. 날짜를 일관된 숫자로 바꾸는 과정이 필요했다. 경기 데이터가 1997년도부터이니 1997년 1월 1일을 1로 바꾸고, 하루마다 1을 더했다. df로는 다루기 어려워 list 형태로 파일을 읽은 후 문자열을 정수로 바꿨다.
# 리스트로 csv 파일 읽기
lst = []
f = open('/content/ufc-master.csv', 'r', encoding='utf-8')
lines = csv.reader(f)
for line in lines:
lst.append(line)
# 날짜 변경
for i in range(1, len(lst)): # date
line = lst[i]
temp = line[6].split('/')
result = int(int(temp[0])*30.5+int(temp[1])+(int(temp[2])-1997)*365)
line[6] = result
df = pd.DataFrame(lst)
2. 필요없는 열 삭제
필요없는 열의 이름을 리스트에 모은 후 한 번에 지웠다. 주제와 적합하지 않거나 승패를 예측할 수 있는 피처를 제거했다.
# 필요없는 열 지우기
# 해당 체급 안에서의 몸무게 순위를 나타내서 지움
col_list = []
# ev: 배당금
for i in range(4, 6):
col_list.append(ufc.columns[i])
# no_of_round: 경기를 몇 라운드동안 진행했는지
col_list.append(ufc.columns[13])
for i in range(16, 33): # win_by
col_list.append(ufc.columns[i])
# win_by: 경기를 어떤 기술로 이겼는지
for i in range(39, 56):
col_list.append(ufc.columns[i])
# loss, win, dif: 해당 경기에서 몇번 지고 이겼는지, 상대와 이긴 횟수 차이 등
for i in range(62, 77):
col_list.append(ufc.columns[i])
# constant_1로 채워진 값과 선수가 해당 체급에서 몇 번째로 무거운지
for i in range(78, 107):
col_list.append(ufc.columns[i])
# 경기가 어떤 기술로 이겼는지와 그 이긴 기술에 대한 배당률
for i in range(108, 119): # finish_detail, odds
col_list.append(ufc.columns[i])
df2 = ufc.drop(columns=col_list, axis=1)
1) current streak
R과 B가 연속적으로 몇 판 이기고 있는지를 나타낸다. B가 연속적으로 3판 연속 이긴 경우, B_current_win_streak는 3이고, B_current_lost_streak는 0이다. 이렇게 하나가 정수이면 하나는 0이기 때문에 둘을 합쳐 차원을 축소시켰다.
df['R_current'] = df['R_current_win_streak'] - df['R_current_lose_streak']
df['B_current'] = df['B_current_win_streak'] - df['B_current_lose_streak']
df = df.drop(columns=['B_current_win_streak', 'B_current_lose_streak'], axis=1)
df = df.drop(columns=['R_current_win_streak', 'R_current_lose_streak'], axis=1)
2) gender
남자 데이터와 여자 데이터의 불균형이 심해서 여자 데이터는 제거하고, 남자 데이터로만 학습을 시켰다.
MALE | 4246 |
FEMALE | 670 |
df = df[df['gender'] == 'MALE']
df = df.drop(columns=['gender'], axis=1)
3. class mapping
체급에 따른 매핑은 나중에 데이터를 분리한 후 진행했다. (목차에서 6. 키, 몸무게, 리치 매핑 부분)
1) 선수들의 이름
R_fighter과 B_fighter을 따로 매핑을 할 경우, 같은 선수임에도 위치나 순서가 달라서 다른 번호가 매겨질 수 있었다. 일단 이름을 다 같은 리스트에 넣고, 중복을 제거한 후 name_mapping에 이름과 숫자를 같이 넣었다. 선수의 이름은 총 1526개다. 같은 방식으로 location과 country도 매핑했다.
combined_list = [[x, y] for x, y in zip(df['R_fighter'], df['B_fighter'])] # 이름 읽기
name_list = []
for i in range(len(combined_list)): # 이름을 한 리스트에 모으기
name_list.append(combined_list[i][0])
name_list.append(combined_list[i][1])
name_list = list(set(name_list)) # 중복 제거
name_mapping = {} # mapping dict
for i in range(len(name_list)): # 이름에 차례대로 숫자 매기기
name_mapping[name_list[i]] = i
df['R_fighter'] = df['R_fighter'].map(name_mapping) # 매핑
df['B_fighter'] = df['B_fighter'].map(name_mapping)
2) 그 외의 값
stance, title_bout, Winner, rank는 적절한 정수로 매핑해주었다.
stance_mapping = { # 선수가 어떤 자세로 경기를 하는지
'Switch': 0,
'Open Stance': 1,
'Southpaw': 2,
'Orthodox': 3
}
df['R_Stance'] = df['R_Stance'].map(stance_mapping)
df['B_Stance'] = df['B_Stance'].map(stance_mapping)
tf_mapping = { # title bout인지 아닌지
True:1,
False:0
}
df['title_bout'] = df['title_bout'].map(tf_mapping)
winner_mapping = { # 누가 이겼는지
'Red':1,
'Blue':0
}
df['Winner'] = df['Winner'].map(winner_mapping)
rank_mapping = { # 누가 랭크가 더 높은지
"Red":1,
"neither":0,
"Blue":-1
}
df["better_rank"] = df["better_rank"].map(rank_mapping)
3) NULL, NAN 제거
df.info()로 확인하니 모든 데이터의 수가 같아서 결측치는 없는 것 같았다. 하지만 csv 파일을 열어보니 빈칸이 있어 공백도 nan값으로 바꿔 제거했다. 공백은 1개 있었다. 그래서 전처리 후 총 데이터는 4245개다.
df = pd.read_csv('/content/ufc4.csv', na_values=[''])
df2 = df.dropna(axis=1, how='all')
df2.info()
4. 선수 데이터 추가
선수의 데이터에서 필요 없는(중복) 열을 제거하고, df와 같이 선수 이름을 숫자로 매핑한다. 선수 데이터가 있는 경기 데이터만 총 4047개였다.
선수 데이터가 있는 경기 데이터 | 4047 |
선수 데이터가 없는 경기 데이터 | 198 |
player = player.drop(['Height', 'Weight', 'Reach', "Stance", "DOB"], axis=1)
player['fighter_name'] = player['fighter_name'].map(name_mapping)
import pandas as pd
# 선수의 이름을 기준으로 합치기
merged_df = df.merge(player, how='left', left_on='B_fighter', right_on='fighter_name')
# 중복된 열 제거하고 결과 출력
merged_df.drop('fighter_name', axis=1, inplace=True)
# B 선수의 정보 추가
merged_df = merged_df.rename(columns = {'SLpM':'B_SLpM', 'Str_Acc':'B_Str_Acc', 'SApM':'B_SApM', 'Str_Def':'B_Str_Def', 'TD_Avg':'B_TD_Avg', 'TD_Acc':'B_TD_Acc', 'TD_Def':'B_TD_Def', 'Sub_Avg':'B_Sub_Avg'})
result = merged_df.dropna(subset=['B_Str_Acc'])
5. 파일 분할
전처리를 하는 과정에서 전체 데이터를 활용해야 해서 cheating을 예방하기 위해 데이터셋을 분리했다. 랜덤으로 섞은 후 train: validation: test = 7: 1: 2 로 나눠서 사용했다.
dt = df.sample(frac=1).reset_index(drop=True)
d_val = dt.loc[[i for i in range(404)]]
d_test = dt.loc[[i for i in range(404, 1214)]]
d_train = dt.loc[[i for i in range(1214, 4047)]]
d_val.to_csv("val.csv")
d_test.to_csv("test.csv")
d_train.to_csv("train.csv")
6. 키, 몸무게, 리치 매핑
ufc 경기는 체급으로 나눠서 하기 때문에 체급인 weight_class에 크기별로 0부터 11까지의 값으로 매핑해줬다. 무체급전인 'Catch Weight'은 데이터가 적어서 -1로 매핑해서 제외했다. weight_class 중 여자 체급(0~3)은 제거했다. 체급 내에서의 키, 몸무게, 리치는 절대값보다는 해당 체급 내에서 얼마나 차이 나는지가 중요할 것이라 생각했다. 그래서 체급 별로 나눠서 선수의 몸무게, 키, 리치의 차이를 Min-Max Scaling 해줬다.
WEIGHT_CLASS = {
"Heavyweight":11,
"Light Heavyweight": 10,
"Middleweight": 9,
"Welterweight": 8,
"Lightweight": 7,
"Featherweight": 6,
"Bantamweight": 5,
"Flyweight": 4,
"Women's Flyweight": 3,
"Women's Bantamweight": 2,
"Women's Strawweight": 1,
"Women's Featherweight":0,
'Catch Weight':-1
}
df['weight_class'] = df['weight_class'].map(WEIGHT_CLASS)
df = df[df['weight_class'] >=4] # 무체급전, 여자 경기 제외
# mms_mapping(df, 체급의 종류, red 선수, blue 선수, 두 선수의 차이, 체급의 크기
def mms_mapping(df, n, R, B, dif, k): # 체급별로 scaling하는 함수
mms_rows = df[df['weight_class'] == n] # 해당 체급의
mms_rows[dif] = (mms_rows[R] - mms_rows[B]) /k # 두 선수의 차이를 체급의 크기로 나눠줬다.
df.loc[df['weight_class'] == n, dif] = mms_rows[dif]
mms_mapping(df, 4, 'R_Reach_cms', 'B_Reach_cms', 'Reach_dif', 10)
mms_mapping(df, 5, 'R_Reach_cms', 'B_Reach_cms', 'Reach_dif', 10)
mms_mapping(df, 6, 'R_Reach_cms', 'B_Reach_cms', 'Reach_dif', 10)
mms_mapping(df, 7, 'R_Reach_cms', 'B_Reach_cms', 'Reach_dif', 15)
mms_mapping(df, 8, 'R_Reach_cms', 'B_Reach_cms', 'Reach_dif', 15)
mms_mapping(df, 9, 'R_Reach_cms', 'B_Reach_cms', 'Reach_dif', 15)
mms_mapping(df, 10, 'R_Reach_cms', 'B_Reach_cms', 'Reach_dif', 20)
mms_mapping(df, 11, 'R_Reach_cms', 'B_Reach_cms', 'Reach_dif', 60)
mms_mapping(df, 4, 'R_Weight_lbs', 'B_Weight_lbs', 'Weight_dif', 10)
mms_mapping(df, 5, 'R_Weight_lbs', 'B_Weight_lbs', 'Weight_dif', 10)
mms_mapping(df, 6, 'R_Weight_lbs', 'B_Weight_lbs', 'Weight_dif', 10)
mms_mapping(df, 7, 'R_Weight_lbs', 'B_Weight_lbs', 'Weight_dif', 15)
mms_mapping(df, 8, 'R_Weight_lbs', 'B_Weight_lbs', 'Weight_dif', 15)
mms_mapping(df, 9, 'R_Weight_lbs', 'B_Weight_lbs', 'Weight_dif', 15)
mms_mapping(df, 10, 'R_Weight_lbs', 'B_Weight_lbs', 'Weight_dif', 20)
mms_mapping(df, 11, 'R_Weight_lbs', 'B_Weight_lbs', 'Weight_dif', 60)
mms_mapping(df, 4, 'R_Height_cms', 'B_Height_cms', 'Height_dif', 10)
mms_mapping(df, 5, 'R_Height_cms', 'B_Height_cms', 'Height_dif', 10)
mms_mapping(df, 6, 'R_Height_cms', 'B_Height_cms', 'Height_dif', 10)
mms_mapping(df, 7, 'R_Height_cms', 'B_Height_cms', 'Height_dif', 15)
mms_mapping(df, 8, 'R_Height_cms', 'B_Height_cms', 'Height_dif', 15)
mms_mapping(df, 9, 'R_Height_cms', 'B_Height_cms', 'Height_dif', 15)
mms_mapping(df, 10, 'R_Height_cms', 'B_Height_cms', 'Height_dif', 20)
mms_mapping(df, 11, 'R_Height_cms', 'B_Height_cms', 'Height_dif', 60)
df = df.drop(['B_Height_cms', 'B_Reach_cms', 'B_Weight_lbs', 'R_Height_cms', 'R_Reach_cms', 'R_Weight_lbs'], axis=1)
< 방법론 >
데이터는 train: validation: test = 7: 2: 1로 나누어 학습시키고 성능을 평가했다.
1. 회귀: 한 선수의 배당률
validation set | linear regression | Polynomial regression | Ridge regression | Random forest |
R2 score | 0.17 | -0.68 | 0.19 | 0.29 |
MSE | 0.78 | 1.4 | 0.76 | 0.69 |
MAE | 0.72 | 0.9 | 0.61 | 0.66 |
MAPE | 1.2 | 1.6 | 1.2 | 3.19 |
linear regression, Polynomial regression, Ridge regression, Random forest 로 총 4개의 모델로 학습을 시켜봤다. validation set을 사용하여 성능을 테스트했다. 가장 성능이 좋게 나온 Random forest 모델을 사용했음에도 성능이 낮아, 성능을 높이기 위한 방법을 생각해봤다.
R2 score | 0.23 |
MSE | 0.71 |
MAE | 0.66 |
MAPE | 2.81 |
1) underfitting or overfitting?
train | test | |
n_estimatores = 70, | 0.897 | 0.25 |
n_estimatores = 60, | 0.894 | 0.26 |
n_estimatores = 50, | 0.894 | 0.23 |
n_estimatores = 40, | 0.891 | 0.23 |
n_estimatores = 30, | 0.88 | 0.22 |
train score가 test score보다 커서 오버피팅으로 생각했지만, 학습 횟수를 낮출수록 성능이 떨어졌다. 이후 모든 모델은 성능이 가장 좋게 나온 n_estimators=60으로 학습 시켰다.
2) 데이터 수가 많은 클래스만 사용
categorical 피처 중에 불균형이 심한 클래스를 골라 데이터가 적은 클래스를 제거해봤다. 그렇다고 모든 피처의 불균형을 없애기 위해 적은 쪽의 데이터를 제거하다보면 데이터의 수가 너무 적어서 학습이 제대로 안됐다. 만약 학습이 잘 된다하더라도 범용성이 떨어져 좋은 모델이라 보기 어렵다. 그래서 제거할 데이터의 수가 적은 title_bout == 0인, 즉 title_bout 경기가 아닌 데이터만으로 학습시켜봤다.
r2 | 0.24 |
mse | 0.72 |
mae | 0.67 |
mape | 2.71 |
성능이 비슷하게 나오고, 클래스를 제거할 수록 데이터의 수가 줄어들고 범용성이 떨어지니 좋은 방법으로 보이지 않았다.
3) 중간 결론
여러 모델을 학습시키고 다양한 방법을 시도해보았으나 성능이 낮게 나왔다. 그래서 해당 데이터로만 배당률은 예측하기는 어렵다는 생각이 들었다. 내가 알기로는 질 확률이 높은 선수는 배당률이 높고, 이길 확률이 낮은 선수는 배당률이 높다고 한다. 그러면 어느 선수가 이길지 알 수 있다면 배당률을 예측하는데 도움이 될 수 있을 것 같았다. 그래서 추가적으로 승패를 예측하는 분류 모델을 통해 승패 데이터를 얻고자하였다.
2. 분류: 승패 예측
가설이 맞는지 확인하기 위해 실험 삼아 분류 모델을 사용한 것이라 분류 모델 간의 성능 비교는 하지 않았다. 배당률을 예측할 때 사용할 것이기 때문에 확률 값도 알 수 있는 linear regression 모델로 선택했다. accuracy는 0.65로 회귀 모델보다 예측을 잘 했다.
accuracy: 0.65 | precision | recall | f1-score | support |
0(red가 winner) | 0.61 | 0.43 | 0.51 | 325 |
1(blue가 winner | 0.68 | 0.82 | 0.74 | 485 |
accuracy | 0.66 | 810 | ||
macro avg | 0.65 | 0.62 | 0.62 | 810 |
weighted avg | 0.65 | 0.66 | 0.65 | 810 |
3-1. 회귀: 승패를 활용한 배당률 예측
만약 성능이 괜찮은 분류 모델을 찾아서 승패 여부를 잘 예측한다면 배당률 예측에 도움이 될 것 같았다. 확인하기 위해 학습시킬때 승패를 나타내는 Winner 데이터를 제거하지 않고 학습시켜봤다.
X_train = train.drop(['R_odds', 'B_odds'], axis=1) # Cheating 방지, 배당금 관련, 승패여부 feature 제외
y_train = train['R_odds'].values
X_val = val.drop(['R_odds', 'B_odds'], axis=1) # Cheating 방지, 배당금 관련, 승패여부 feature 제외
y_val = val['R_odds'].values
X_test = test.drop(['R_odds', 'B_odds'], axis=1) # Cheating 방지, 배당금 관련, 승패여부 feature 제외
y_test = test['R_odds'].values
r2 | 0.25 |
mse | 0.69 |
mae | 0.65 |
mape | 3.29 |
생각과는 다르게 성능이 비슷했다.
3-2. 회귀: 승패 확률을 활용한 회귀
r2 | 0.27 |
mse | 0.68 |
mae | 0.65 |
mape | 3.02 |
그림과 같이 데이터를 나눠서 성능을 평가했다. 전체 데이터 수는 줄었지만 train과 test를 잘 분리했다. 데이터를 분류 모델로 경기의 승률을 예측한 다음, 예측한 승률을 피처로 추가하여 회귀모델을 학습시켰다. 기대한 것 보다는 낮았지만 test set으로 성능을 평가한 모델 중에는 R2 Score가 가장 높은 값이 나왔다!
< 결론과 향후 계획 >
해당 데이터만으로는 배당률을 예측하기 어려울 것으로 보인다. 특히 승률로도 배당률을 예측하기 어려웠다. 이를 통해 배당률은 선수의 경기 실력 뿐만 아니라 다른 여러 요인들이 관련된 것 같다.
배당률을 예측하는 모델을 만들기 위해서는 해당 데이터 뿐만 아니라 선수의 외모(보기에 얼마나 강해 보이는지), 경제 상황(사람들의 자금) 등등 다른 피처를 추가하여 더 깊은 모델을 만들어야 할 것 같다.
'Project' 카테고리의 다른 글
논문 리뷰반 피드백 정리 (0) | 2023.07.23 |
---|---|
YOLOv4-tiny 모델과 경진대회 (0) | 2023.07.23 |
Mahotas 모듈을 이용한 유사한 이미지 찾기 모델 (0) | 2023.01.25 |