본문 바로가기

정보과학융합탐구

[정융탐] EPL 축구 경기 승부예측 AI 개발 프로젝트 (3)

1. 모델 개발 및 시행착오

 

. LSTM

세웠던 계획에 따라 먼저 저번 달에 전처리한 데이터를 바탕으로 RNN 기반인 LSTM 모델부터 학습시켜 보았다. 먼저, 학습 데이터셋은 현재 2005년부터 2023년까지 순서대로 정리되어있는 상태이고 그 팀명과 연도도 열에 포함이 되어있다. 그러나 데이터의 순서가 연도를 나타내고, 그 데이터의 승률, FIFA 평점 등이 그 팀을 특정지으므로 연도와 홈/어웨이 팀명은 학습에 필요가 없다. 따라서 연도와 홈/어웨이 팀명은 훈련 데이터에서 제거하였다. 이후 label 데이터에서 각각의 라벨은 H(홈 팀 승리), A(어웨이 팀 승리), D(무승부) 이렇게 3개로 모두 문자로 되어있다. 따라서 StringLookup 레이어를 사용하여 문자열 라벨을 인덱스로 변환했다. 한편, 훈련 데이터셋을 train datavalidation data8:2 비율로 분할하였다. 이때, LSTM 모델의 학습에서는 시계열 데이터 분석이기 때문에 현재 시간 순서대로 정리되어있는 훈련 데이터가 마구 섞이면 안되므로 shuffle=False로 설정해주어 섞이는 것을 막았다. 다음으로 모델 구조를 정의했다. 입력 데이터의 크기(data.shape[1])을 이용하여 입력 계층을 추가하고, LSTM 레이어를 추가한 뒤, 완전 연결 계층과 출력 계층을 추가하였다. 이때 완전 연결 계층의 활성화 함수는 ReLU를 사용했고, 출력 계층의 활성화 함수로는 다중 분류이므로 softmax를 사용했다. 손실 함수로는 sparse_categorical_crossentropy, 옵티마이저로는 adam을 사용했다. 이후 모델을 훈련시켰는데, 이때도 LSTM 모델의 시계열 데이터 분석의 특징을 살리기 위해 shuffle=False로 설정해주었다. 여러 번 훈련시켜본 결과 데이터셋도 작고 해서 에포크는 10, 배치 사이즈는 16 정도로 했을 때가 결과가 가장 잘 나와서 하이퍼파라미터는 이렇게 설정해주었다. 또한 테스트 데이터도 훈련 데이터와 마찬가지로 전처리해준 다음 모델 테스트도 해 보았다. 사용한 코드는 아래와 같다.

 

import tensorflow as tf

from tensorflow.keras.models import Sequential

from tensorflow.keras.layers import Dense, LSTM, Reshape, StringLookup

from sklearn.utils.class_weight import compute_class_weight

from sklearn.model_selection import train_test_split

import matplotlib.pyplot as plt

import pandas as pd

import numpy as np

# CSV 파일에서 데이터 로드

data_df = pd.read_csv('/content/drive/MyDrive/footballAI/training_dataset.csv')

labels_df = pd.read_csv('/content/drive/MyDrive/footballAI/training_labels.csv')

# 데이터 전처리

data = data_df.iloc[:,3:].values.astype(np.float32)

print(data)

labels = labels_df['R'].values

# 라벨을 문자열에서 인덱스로 변환하는 StringLookup 레이어 생성

lookup = StringLookup(output_mode='int')

lookup.adapt(labels)

# 인덱스로 변환된 라벨

encoded_labels = lookup(labels).numpy()

# 데이터셋을 train과 validation으로 분리

X_train, X_val, y_train, y_val = train_test_split(data, encoded_labels, test_size=0.2, shuffle=False)

# 모델 생성

model = Sequential()

model.add(tf.keras.Input(shape=(data.shape[1],)))

model.add(Reshape((1, data.shape[1])))

model.add(LSTM(64, return_sequences=False))

model.add(Dense(32, activation='relu'))

model.add(Dense(len(lookup.get_vocabulary()), activation='softmax'))

# 모델 컴파일

model.compile(optimizer='adam', loss='sparse_categorical_crossentropy', metrics=['accuracy'])

# 모델 요약

model.summary()

# 모델 훈련

history = model.fit(X_train, y_train, epochs=10, batch_size=1, shuffle=False, validation_data=(X_val, y_val))#, class_weight=class_weight_dict)

plt.figure(figsize=(10, 5))

plt.subplot(1, 2, 1)

plt.plot(history.history['loss'])

plt.plot(history.history['val_loss'])

plt.title('Model Loss')

plt.ylabel('Loss')

plt.xlabel('Epoch')

plt.legend(['Train', 'Validation'], loc='upper right')

plt.subplot(1, 2, 2)

plt.plot(history.history['accuracy'])

plt.plot(history.history['val_accuracy'])

plt.title('Model Accuracy')

plt.ylabel('Accuracy')

plt.xlabel('Epoch')

plt.legend(['Train', 'Validation'], loc='lower right')

plt.tight_layout()

plt.show()

# 테스트 데이터 로드

test_data_df = pd.read_csv('/content/drive/MyDrive/footballAI/test_dataset.csv')

test_labels_df = pd.read_csv('/content/drive/MyDrive/footballAI/test_labels.csv')

# 테스트 데이터 전처리

test_data = test_data_df.iloc[:, 3:].values.astype(np.float32)

test_labels = test_labels_df['R'].values

encoded_test_labels = lookup(test_labels).numpy()

# 테스트 데이터 예측 및 평가

test_loss, test_accuracy = model.evaluate(test_data, encoded_test_labels)

print(f'Test Loss: {test_loss}, Test Accuracy: {test_accuracy}')

# 테스트 데이터 예측 결과

predictions = model.predict(test_data)

predicted_indices = np.argmax(predictions, axis=1)

# 디코딩을 위한 StringLookup 레이어 생성 (invert=True)

inverse_lookup = StringLookup(vocabulary=lookup.get_vocabulary(), invert=True)

predicted_classes = inverse_lookup(predicted_indices)

print("Predicted classes:", predicted_classes.numpy())

model.save('/content/drive/MyDrive/footballAI/simple_LSTM_')

학습 과정에서의 손실(Loss)과 정확도(Accuracy)이다. 학습이 진행될수록 미세하지만 손실이 줄어들고, 정확도는 증가하고 있다. 그러나 에포크가 지나갈수록 그 학습되는 정도가 거의 없어서 에포크는 10번만 설정한 것이다. 또한 정확도를 보면 훈련 정확도와 validation 정확도가 거의 차이가 나지 않는 것으로 보아 과적합의 우려는 적은 것으로 보인다. 테스트 손실과 정확도는 다음과 같았다 - Test Loss: 0.9834573268890381, Test Accuracy: 0.5304877758026123. 이때 홈 승리, 어웨이 승리, 무승부 각각의 클래스별로 데이터 개수가 다르므로 f1-score 등 다양한 지표도 출력해 보았다.

 

. 단순 RNN

앞선 LSTM 모델에서 RNN 계층만 단순 RNN(SimpleRNN)으로 바꾸어서 다시 훈련을 시켜보았다. 이외 모든 전처리 과정과 하이퍼파라미터, 손실함수 및 활성화 함수 등은 동일하게 설정해주었다.

모델 구조

model = Sequential()
model.add(tf.keras.Input(shape=(data.shape[1],)))
model.add(Reshape((1, data.shape[1])))
model.add(SimpleRNN(64, return_sequences=False))
model.add(Dense(32, activation='relu'))
model.add(Dense(len(lookup.get_vocabulary()), activation='softmax'))

훈련 과정에서의 손실 값 감소와 정확도 증가를 나타내는 그래프이다. LSTM보다 좀 더 울퉁불퉁하게 학습이 진행되었음을 알 수 있었다. 이 모델 역시 훈련 정확도와 validation 정확도의 차이가 적은 편이다. 테스트 손실 및 정확도는 다음과 같았다. - Test Loss: 0.9831950664520264, Test Accuracy: 0.5365853905677795. 이 모델 역시 f1-score 등 다양한 지표를 출력해 보았다.

. 양방향 LSTM

다음으로 RNN 계층에 과거, 미래의 정보를 모두 파악하게 해 준다는 양방향 RNN을 사용해보았다. 역시 다른 조건은 모두 동일하게 설정해주었다.

모델 구조

model = Sequential()
model.add(tf.keras.Input(shape=(data.shape[1],)))
model.add(Reshape((1, data.shape[1])))
model.add(Bidirectional(LSTM(64, return_sequences=False)))
model.add(Dense(32, activation='relu'))
model.add(Dense(len(lookup.get_vocabulary()), activation='softmax'))

훈련 과정에서의 손실, 정확도 변화이다. 그다지 그 변화가 크진 않은 것으로 보아 제대로 학습이 되고 있지는 않은 것으로 보인다. 최종 테스트 손실과 정확도는 다음과 같았다 - Test Loss: 0.9807664155960083, Test Accuracy: 0.5274389982223511. 아래는 본 모델에 대한 또 다른 지표들 값이다.

. 선행논문 참고

RNN 기반 모델을 3개나 바꿔가며 사용해보아도 그 정확도가 약 53%로 거의 모두 같았고 진전이 보이지 않았다. 따라서 앞서 선행논문: European Soccer League Outcome Predictor, Sang Ahn(Stanford University) , 2022.에서 사용한 모델을 만들어서 직접 학습시켜 보았다. 논문에서는 다음과 같이 나와 있었다: “사용된 핵심 알고리즘은 기존 팀 지표와 함께 플레이어 수준 데이터로 보강된 완전 연결 신경망이다. 모델 아키텍처는 Leaky ReLU 활성화 함수, 소프트맥스 출력 레이어, 향상된 일반화를 위한 드롭아웃 정규화를 갖춘 5개의 숨겨진 레이어로 구성했다. 활용되는 손실 함수는 범주형 교차 엔트로피이다. 또한 효율적인 경사 하강을 위해 Adam 최적화를 사용했다.” 따라서 이에 맞게 모델 구조를 다시 설정했다.

모델 구조

# 모델 생성
model = Sequential()
model.add(tf.keras.Input(shape=(data.shape[1],)))
# 숨겨진 레이어 5개 추가
for _ in range(5):
    model.add(Dense(64))
    model.add(LeakyReLU(alpha=0.01))
    model.add(Dropout(0.5))
# 출력 레이어 추가
model.add(Dense(len(lookup.get_vocabulary()), activation='softmax'))

또한 본 모델의 경우 시계열 데이터 분석이 아니므로 shuffle=False를 설정해주지 않았고, 여러 번 학습 결과 제일 잘 나왔던 파라미터인 100번의 에포크, 32의 배치 사이즈로 학습을 시켰다.

학습 중의 loss 감소와 정확도 증가 그래프이다. loss는 초반에 급격히 감소하다가 이후 거의 감소하지 않았고, 훈련과 validation 값이 거의 같았다. 정확도는 꾸준히 증가하였으며, 오히려 훈련 정확도보다 validation 정확도가 더 높은 모습을 보였다. 최종 손실과 정확도는 다음과 같았다 - Test Loss: 1.002458095550537, Test Accuracy: 0.5640243887901306. 56.4%RNN을 이용한 모델보다 정확도가 더 높게 나온 모습을 볼 수 있었다. 기존 선행 논문에서 이 모델을 사용했을 때의 정확도가 50.46%였다는 점에서 이는 많은 발전이라고 볼 수 있다. 아래는 이 모델에 대한 다른 지표들을 나타낸 것이다.

 

. 드롭아웃 적용

~라의 모델의 그다지 과적합의 모습은 보이지 않았지만. 더욱 성능을 높여보고 싶어서 LSTM 모델 구조에 드롭아웃을 적용시켜 보았다.

모델 구조

model = Sequential()
model.add(tf.keras.Input(shape=(data.shape[1],)))
model.add(Reshape((1, data.shape[1])))
model.add(LSTM(128, return_sequences=True))
model.add(Dropout(0.5))
model.add(LSTM(64, return_sequences=False))
model.add(Dropout(0.5))
model.add(Dense(32, activation='relu'))
model.add(BatchNormalization())
model.add(Dense(len(unique_labels), activation='softmax'))

이때 본 모델의 경우 과적합의 위험이 적으므로 학습이 충분히 되도록 에포크를 20번으로 하였고, 배치 사이즈는 1로 하였다.

위는 학습 중의 손실, 정확도 변화이다. 드롭아웃을 적용한 결과가 매우 큰 부작용을 낳았다. train 정확도와 loss는 변할 생각을 않고, validation 정확도와 loss는 너무 큰 폭으로 왔다갔다 거렸다. 결국 실제 test 결과는 처참했다. - Test Accuracy: 0.22256097197532654.

 

. 클래스 불균형 문제 해결 시도

~라의 모델에서 볼 수 있듯 무승부 예측은 못하고 있다. 선행 논문에서 무승부 예측을 거의 하지 못한다는 결과를 본 이후 이를 막기 위해 무승부 확률까지 데이터셋에 넣어주었음에도 불구하고 무승부 예측은 못하고 있었다. 이는 무승부가 홈 팀 승리, 어웨이 팀 승리에 비해 그 클래스 개수가 적어서 발생하는 문제로 판단했고, 따라서 클래스 불균형 문제를 해결하기 위해 smote를 이용한 오버샘플링으로 데이터를 다시 균형이 맞게 만들어 주었다. 이후 선행 연구에서의 모델, 단순 RNN, LSTM 모델에 이 오버샘플링한 데이터를 적용시켜 각각 학습해 보았다.

# 오버샘플링을 적용하여 데이터 균형 맞추기
smote = SMOTE()
X_resampled, y_resampled = smote.fit_resample(data, encoded_labels)

위에서부터 각각 선행 연구에서의 모델, 단순 RNN, LSTM 모델이다. 드롭아웃 계층을 적용시켰음에도 불구하고 과적합이 심하게 된 모습이다. 결국 실제 테스트 정확도도 세 개의 모두 모두 비슷하게 약 31% 정도로 매우 낮았다.

 

2. 사용자 친화적으로 개선

마지막으로 이 모델을 실제 사용자가 편리하게 쓸 수 있게 개선해주었다. 모델은 가장 높은 정확도를 보였던 라(선행논문 참고) 모델을 선택하였다. 사용자에게 홈 팀과 어웨이 팀을 입력받고, test 데이터로부터 모델이 승부 결과를 예측하는 데에 필요한 각 팀의 여러 데이터를 가져왔다. (승률, FIFA 평점 등) 이후 이 모델에게 예측을 시키고 그 결과를 출력하는 것으로 프로그램 코드를 짰다. 프로그램 코드는 다음과 같다.

model = load_model('/content/drive/MyDrive/footballAI/prv_model_')
home_team = input('home team: ')
away_team = input('away team: ')
l = []
if home_team not in teams or away_team not in teams:
  print("팀명이 정확한지 확인해주세요.")
  print(f'팀 목록:{teams}')
else:
  h_i = list(test_data_df['Home']).index(home_team)
  a_i = list(test_data_df['Away']).index(away_team)
  l.append(test_data_df.iloc[h_i,3])
  l.append(test_data_df.iloc[a_i,4])
  l.append(test_data_df.iloc[h_i,5])
  l.append(test_data_df.iloc[a_i,6])
  l.append(test_data_df.iloc[h_i,7])
  l.append(test_data_df.iloc[h_i,8])
  l.append(test_data_df.iloc[a_i,9])
  l.append(test_data_df.iloc[a_i,10])
  print(l)
  prediction = model.predict(np.array([l]).astype(np.float32))
  predicted_index = np.argmax(prediction, axis=1)
  predicted_class = inverse_lookup(predicted_index)
  p = predicted_class.numpy()
  if p[0] == b'A':
    print(f'{away_team} WINS!')
  elif p[0] == b'D':
    print("DRAW!")
  else:
    print(f'{home_team} WINS!')

아래 사진은 실제 사용 모습이다.

(슬프게도 북런던 더비에서 내가 좋아하는 팀인 토트넘이 아닌 아스날이 이긴다고 예측하고 있다 이는 최근 전적을 보면 옳은 예측이기는 하다...)