CS/Python

파이썬으로 MLP 찍먹하기

munsik22 2026. 8. 11. 12:40

🧩 MLP란?

퍼셉트론

[출처] GeeksforGeeks

퍼셉트론(Perceptron)은 1957년 프랑크 로젠블라트가 고안한 최초의 인공 신경망 알고리즘이다. 인간의 뇌 신경세포인 뉴런이 전기 신호를 전달하는 모습을 모방하여, 여러 개의 입력값을 받아 각각의 가중치를 곱하고 더한 뒤, 그 합이 임계치(역치)를 넘으면 1, 아니면 0(또는 -1)을 출력하는 방식이다.

MLP

[출처] GeeksforGeeks

MLP(Multi-Layer Perceptron)는 퍼셉트론을 여러 층(Layer)으로 쌓은 인공 신경망이다. 각 층에서는 활성함수를 통해 입력을 처리한다.

  • 입력층 (Input Layer): 외부에서 데이터를 처음 받아들이는 층
  • 은닉층 (Hidden Layer): 입력층과 출력층 사이에 위치하며, 복잡한 패턴과 비선형 관계를 찾아내는 핵심적인 층
  • 출력층 (Output Layer): 은닉층에서 전달된 정보를 바탕으로 최종 결과나 예측값을 내보내는 층

딥러닝

Deep Learning은 은닉층을 깊게(Deep) 쌓은 인공신경망 전체를 뜻한다. 종류로는 CNN, RNN, Transformer 등이 있으며, MLP는 딥러닝의 아주 기초적인 형태이다.

🧩 MLP 구현하기

목표는 이전과 같다.

게임 시작 전 챔피언 픽, 밴, 소환사 주문 정보를 보고 팀1의 승리 여부를 예측한다.

필요한 라이브러리 세팅

import copy
import random

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

import torch
import torch.nn as nn
from torch.utils.data import TensorDataset, DataLoader

from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import OneHotEncoder
from sklearn.metrics import accuracy_score, roc_auc_score

 

PyTorch에서 신경망 레이어는 보통 nn.Module을 상속해 만들며, nn.Linear는 입력에 선형 변환을 적용하는 레이어다.

재현성을 위한 시드(seed) 설정

신경망 학습은 초기 가중치와 데이터 셔플링 순서에 따라 결과가 조금씩 달라질 수 있다.

SEED = 42

random.seed(SEED)
np.random.seed(SEED)
torch.manual_seed(SEED)

if torch.cuda.is_available():
    torch.cuda.manual_seed_all(SEED)

데이터 불러오기

df = pd.read_csv("dataset/games.csv")

# 팀1 승리면 1, 팀2 승리면 0
df["target"] = (df["winner"] == 1).astype(int)

champ_cols = [
    "t1_champ1id", "t1_champ2id", "t1_champ3id",
    "t1_champ4id", "t1_champ5id",

    "t2_champ1id", "t2_champ2id", "t2_champ3id",
    "t2_champ4id", "t2_champ5id"
]

summoner_spell_cols = [
    "t1_champ1_sum1", "t1_champ1_sum2",
    "t1_champ2_sum1", "t1_champ2_sum2",
    "t1_champ3_sum1", "t1_champ3_sum2",
    "t1_champ4_sum1", "t1_champ4_sum2",
    "t1_champ5_sum1", "t1_champ5_sum2",

    "t2_champ1_sum1", "t2_champ1_sum2",
    "t2_champ2_sum1", "t2_champ2_sum2",
    "t2_champ3_sum1", "t2_champ3_sum2",
    "t2_champ4_sum1", "t2_champ4_sum2",
    "t2_champ5_sum1", "t2_champ5_sum2"
]

ban_cols = [
    "t1_ban1", "t1_ban2", "t1_ban3", "t1_ban4", "t1_ban5",
    "t2_ban1", "t2_ban2", "t2_ban3", "t2_ban4", "t2_ban5"
]

feature_cols = (
    ["seasonId"]
    + champ_cols
    + summoner_spell_cols
    + ban_cols
)

X = df[feature_cols]
y = df["target"]

 

이전과 마찬가지로 데이터 누수를 막기 위해 경기 종료 후에 알 수 있는 정보는 제외해야 한다.

# 사용하지 않는 예시
# gameDuration
# firstBlood
# t1_towerKills, t2_towerKills
# t1_dragonKills, t2_dragonKills
# ...

Train / Validation / Test 데이터 분리

  • Train: 모델 가중치 학습용
  • Validation: 학습 중 모델 설정 및 과적합 여부 확인용
  • Test: 최종 성능 평가용
# 1차 분할: train 80%, test 20%
X_train_full, X_test, y_train_full, y_test = train_test_split(
    X,
    y,
    test_size=0.2,
    random_state=SEED,
    stratify=y
)

# 2차 분할: train_full 중 일부를 validation으로 사용
# 최종 비율: train 64%, validation 16%, test 20%
X_train, X_val, y_train, y_val = train_test_split(
    X_train_full,
    y_train_full,
    test_size=0.2,
    random_state=SEED,
    stratify=y_train_full
)

print("Train:", X_train.shape)		# Train: (32953, 41)
print("Validation:", X_val.shape)	# Validation: (8239, 41)
print("Test:", X_test.shape)		# Test: (10298, 41)

One-Hot Encoding

ID값은 크기 자체에 의미가 없는 범주형 데이터이므로 신경망에 숫자를 그대로 넣지 않고 One-Hot Encoding을 적용한다.

encoder = Pipeline(
    steps=[
        ("imputer", SimpleImputer(
            strategy="constant",
            fill_value=-1
        )),
        ("onehot", OneHotEncoder(
            handle_unknown="ignore"
        ))
    ]
)

 

인코더는 훈련 데이터로만 학습해야 한다.

X_train_encoded = encoder.fit_transform(X_train)
X_val_encoded = encoder.transform(X_val)
X_test_encoded = encoder.transform(X_test)
  • fit_transform()은 train 데이터에만 사용
  • validation/test에는 transform()만 사용

희소 행렬을 Dense 배열로 변환

OneHotEncoder 결과는 전체 값 중 대부분이 0으로 채워진 희소 행렬(sparse matrix)이다. MLP에 넣기 위해 float32 형태의 NumPy 배열로 변환해야 한다.

def to_dense_float32(x):
    if hasattr(x, "toarray"):
        return x.toarray().astype(np.float32)
    return np.asarray(x, dtype=np.float32)

X_train_np = to_dense_float32(X_train_encoded)
X_val_np = to_dense_float32(X_val_encoded)
X_test_np = to_dense_float32(X_test_encoded)

y_train_np = y_train.to_numpy(dtype=np.float32).reshape(-1, 1)
y_val_np = y_val.to_numpy(dtype=np.float32).reshape(-1, 1)
y_test_np = y_test.to_numpy(dtype=np.float32).reshape(-1, 1)

print("인코딩 후 입력 특성 개수:", X_train_np.shape[1]) # 인코딩 후 입력 특성 개수: 2951

NumPy 배열을 PyTorch Tensor로 변환

X_train_tensor = torch.tensor(X_train_np, dtype=torch.float32)
y_train_tensor = torch.tensor(y_train_np, dtype=torch.float32)

X_val_tensor = torch.tensor(X_val_np, dtype=torch.float32)
y_val_tensor = torch.tensor(y_val_np, dtype=torch.float32)

X_test_tensor = torch.tensor(X_test_np, dtype=torch.float32)
y_test_tensor = torch.tensor(y_test_np, dtype=torch.float32)

TensorDatasetDataLoader 만들기

# TensorDataset 만들기
train_dataset = TensorDataset(X_train_tensor, y_train_tensor)
val_dataset = TensorDataset(X_val_tensor, y_val_tensor)
test_dataset = TensorDataset(X_test_tensor, y_test_tensor)

# DataLoader 만들기
BATCH_SIZE = 256

train_loader = DataLoader(
    train_dataset,
    batch_size=BATCH_SIZE,
    shuffle=True
)

val_loader = DataLoader(
    val_dataset,
    batch_size=BATCH_SIZE,
    shuffle=False
)

test_loader = DataLoader(
    test_dataset,
    batch_size=BATCH_SIZE,
    shuffle=False
)
  • TensorDataset: 입력 데이터와 정답 데이터를 하나로 묶음
  • DataLoader: 데이터를 작은 batch 단위로 나눠서 모델에 전달함
    • train_loader: shuffle=True → 매 epoch마다 데이터 순서를 섞음
    • val_loader/test_loader: shuffle=False → 평가 시에는 순서를 섞을 필요 없음

MLP 모델 정의

신경망 구조 다이어그램

class MLP(nn.Module):
    def __init__(self, input_dim):
        super().__init__()

        self.network = nn.Sequential(
            nn.Linear(input_dim, 256),
            nn.ReLU(),
            nn.Dropout(0.3),

            nn.Linear(256, 64),
            nn.ReLU(),
            nn.Dropout(0.2),

            nn.Linear(64, 1)
        )

    def forward(self, x):
        return self.network(x)
  • ReLU: 신경망에 비선형성을 추가해 단순 선형 모델보다 복잡한 패턴을 학습 가능하게 하는 역할
  • Dropout: 학습 중 일부 값을 무작위로 0으로 만들어 과적합을 줄이는 역할
  • 손실 함수로 내부적으로 Sigmoid가 포함된 nn.BCEWithLogitLoss()를 사용할 것이므로 Sigmoid()를 출력층에 넣지 않음

Device, 모델, 손실 함수, Optimizer 설정

# GPU 또는 CPU 사용
device = torch.device(
    "cuda" if torch.cuda.is_available() else "cpu"
)
print("사용 장치:", device) # 사용 장치: cpu

# 모델 생성
input_dim = X_train_np.shape[1]
model = MLP(input_dim=input_dim).to(device)
print(model)

'''
MLP(
  (network): Sequential(
    (0): Linear(in_features=2951, out_features=256, bias=True)
    (1): ReLU()
    (2): Dropout(p=0.3, inplace=False)
    (3): Linear(in_features=256, out_features=64, bias=True)
    (4): ReLU()
    (5): Dropout(p=0.2, inplace=False)
    (6): Linear(in_features=64, out_features=1, bias=True)
  )
)
'''

# 손실 함수 및 optimizer 정의
criterion = nn.BCEWithLogitsLoss()
optimizer = torch.optim.AdamW(
    model.parameters(),
    lr=0.001,
    weight_decay=1e-4
)
  • criterion: 모델 예측과 실제 정답의 차이를 계산
  • optimizer: 손실이 줄어들도록 모델 가중치를 수정
  • lr: learning rate, 한 번 업데이트할 때 이동하는 크기
  • weight_decay: 과적합 완화에 도움을 줄 수 있는 규제 항목

평가 함수 만들기

@torch.no_grad() # 미분 자동 계산 OFF
def evaluate(model, data_loader, criterion, device):
    model.eval() # Dropout 비활성화

    total_loss = 0
    all_probs = []
    all_targets = []

    for X_batch, y_batch in data_loader:
        X_batch = X_batch.to(device)
        y_batch = y_batch.to(device)

        logits = model(X_batch)
        loss = criterion(logits, y_batch)

        total_loss += loss.item() * X_batch.size(0)

        probs = torch.sigmoid(logits)

        all_probs.extend(probs.cpu().numpy().ravel())
        all_targets.extend(y_batch.cpu().numpy().ravel())

    all_probs = np.array(all_probs)
    all_targets = np.array(all_targets)

    predictions = (all_probs >= 0.5).astype(int)

    avg_loss = total_loss / len(data_loader.dataset)
    accuracy = accuracy_score(all_targets, predictions)
    auc = roc_auc_score(all_targets, all_probs)

    return avg_loss, accuracy, auc

학습 루프 만들기

# 학습 루프 만들기
'''
1. 배치 데이터 가져오기
2. 모델 예측
3. 손실 계산
4. 기존 gradient 초기화
5. 역전파(backpropagation)
6. optimizer로 가중치 업데이트
'''
EPOCHS = 50
PATIENCE = 5

best_val_auc = -np.inf
best_model_state = None
patience_count = 0

history = {
    "train_loss": [],
    "val_loss": [],
    "val_accuracy": [],
    "val_auc": []
}

for epoch in range(EPOCHS):
    model.train()

    total_train_loss = 0

    for X_batch, y_batch in train_loader:
        X_batch = X_batch.to(device)
        y_batch = y_batch.to(device)

        # 1. 예측
        logits = model(X_batch)

        # 2. 손실 계산
        loss = criterion(logits, y_batch)

        # 3. 이전 gradient 초기화
        optimizer.zero_grad()

        # 4. 역전파
        loss.backward()

        # 5. 모델 파라미터 업데이트
        optimizer.step()

        total_train_loss += loss.item() * X_batch.size(0)

    # Epoch별 평균 train loss
    train_loss = total_train_loss / len(train_loader.dataset)

    # Validation 평가
    val_loss, val_accuracy, val_auc = evaluate(
        model=model,
        data_loader=val_loader,
        criterion=criterion,
        device=device
    )

    history["train_loss"].append(train_loss)
    history["val_loss"].append(val_loss)
    history["val_accuracy"].append(val_accuracy)
    history["val_auc"].append(val_auc)

    print(
        f"Epoch [{epoch + 1:02d}/{EPOCHS}] | "
        f"Train Loss: {train_loss:.4f} | "
        f"Val Loss: {val_loss:.4f} | "
        f"Val Accuracy: {val_accuracy:.4f} | "
        f"Val AUC: {val_auc:.4f}"
    )

    # Early Stopping
    if val_auc > best_val_auc:
        best_val_auc = val_auc
        best_model_state = copy.deepcopy(model.state_dict())
        patience_count = 0
    else:
        patience_count += 1

        if patience_count >= PATIENCE:
            print(f"\nEarly Stopping: Epoch {epoch + 1}에서 학습 종료")
            break

model.load_state_dict(best_model_state)

 

학습을 오래 하면 train 데이터에서는 성능이 계속 좋아질 수 있지만, validation 성능은 오히려 나빠질 수 있다. 이것이 과적합(Overfitting)의 대표적인 신호이며, Early Stopping은 validation 성능이 더 이상 좋아지지 않을 때 학습을 멈추는 방법이다.

validation ROC-AUC가 5 epoch 동안 개선되지 않으면 학습을 종료함

학습 과정 시각화

# Loss 그래프
plt.figure(figsize=(10, 5))

plt.plot(history["train_loss"], label="Train Loss")
plt.plot(history["val_loss"], label="Validation Loss")

plt.title("Training / Validation Loss")
plt.xlabel("Epoch")
plt.ylabel("Loss")
plt.legend()
plt.show()

과적합 가능성이 큰 결과가 나왔다...😓

  • Train Loss와 Validation Loss가 함께 감소: 좋은 학습 흐름
  • Train Loss만 계속 감소하고 Validation Loss가 증가: 과적합 가능성
  • 둘 다 거의 감소하지 않음: 학습률, 구조, 데이터 표현을 점검할 필요
# Validation ROC-AUC 그래프
plt.figure(figsize=(10, 5))

plt.plot(history["val_auc"], marker="o", label="Validation ROC-AUC")

plt.title("Validation ROC-AUC")
plt.xlabel("Epoch")
plt.ylabel("ROC-AUC")
plt.legend()
plt.show()

최종 Test 데이터 평가

test_loss, test_accuracy, test_auc = evaluate(
    model=model,
    data_loader=test_loader,
    criterion=criterion,
    device=device
)

print(f"Test Loss: {test_loss:.4f}")		# Test Loss: 1.6895
print(f"Test Accuracy: {test_accuracy:.4f}")	# Test Accuracy: 0.5262
print(f"Test ROC-AUC: {test_auc:.4f}")		# Test ROC-AUC: 0.5296
모델 Test Accuracy Test ROC-AUC
Dummy Classifier 0.5 0.5
Logistic Regression 0.5316 0.5409
MLP 0.5262 0.5296

 

로지스틱 회귀 결과와 비교했을 때 MLP의 결과가 높았다면 챔피언/밴/주문 사이의 비선형 관계를 일부 학습했을 가능성이 있었겠지만, 비교 결과 오히려 성능이 더 낮게 나왔다.

  • 현재 데이터에는 복잡한 모델이 필요하지 않음
  • 신경망 구조가 적절하지 않음
  • 학습률 또는 Dropout 비율이 맞지 않음
  • 입력 특성 자체에 승패 정보가 제한적임
  • 과적합이 발생함

모델 수정하기

Validation Loss가 계속 증가했고 모델 성능이 선형 회귀 모델보다 나쁘게 나왔으므로 과적합이 의심된다. 선형 회귀 모델의 성능이 더 좋게 나왔으므로 현재 모델이 데이터에 비해 너무 복잡할 가능성이 있다.

class SmallMLP(nn.Module):
    def __init__(self, input_dim):
        super().__init__()

        self.network = nn.Sequential(
            nn.Linear(input_dim, 64),
            nn.ReLU(),
            nn.Dropout(0.3),

            nn.Linear(64, 1)
        )

    def forward(self, x):
        return self.network(x)

 

기존 MLP보다 SmallMLP에서 Validation Loss의 기울기가 더 완만하게 증가하고 성능이 소폭 개선된 것을 확인할 수 있다.

이로써 다음과 같은 결론을 내릴 수 있다.

신경망이 항상 로지스틱 회귀보다 좋은 것은 아니다.

 

현재처럼 챔피언 ID, 밴 ID, 주문 ID를 One-Hot Encoding한 표 형태(tabular) 데이터에서는 로지스틱 회귀가 더 안정적일 수 있겠다🥲

새로운 경기 예측하기

new_game = pd.DataFrame([{
    "seasonId": 9,

    "t1_champ1id": 8,
    "t1_champ2id": 432,
    "t1_champ3id": 96,
    "t1_champ4id": 11,
    "t1_champ5id": 112,

    "t2_champ1id": 104,
    "t2_champ2id": 498,
    "t2_champ3id": 122,
    "t2_champ4id": 238,
    "t2_champ5id": 412,

    "t1_champ1_sum1": 12,
    "t1_champ1_sum2": 4,
    "t1_champ2_sum1": 3,
    "t1_champ2_sum2": 4,
    "t1_champ3_sum1": 4,
    "t1_champ3_sum2": 7,
    "t1_champ4_sum1": 11,
    "t1_champ4_sum2": 6,
    "t1_champ5_sum1": 4,
    "t1_champ5_sum2": 14,

    "t2_champ1_sum1": 4,
    "t2_champ1_sum2": 7,
    "t2_champ2_sum1": 6,
    "t2_champ2_sum2": 4,
    "t2_champ3_sum1": 14,
    "t2_champ3_sum2": 4,
    "t2_champ4_sum1": 4,
    "t2_champ4_sum2": 11,
    "t2_champ5_sum1": 4,
    "t2_champ5_sum2": 3,

    "t1_ban1": 92,
    "t1_ban2": 40,
    "t1_ban3": 69,
    "t1_ban4": 119,
    "t1_ban5": 141,

    "t2_ban1": 114,
    "t2_ban2": 67,
    "t2_ban3": 43,
    "t2_ban4": 16,
    "t2_ban5": 51
}])

new_game_encoded = encoder.transform(new_game)
new_game_np = to_dense_float32(new_game_encoded)

new_game_tensor = torch.tensor(
    new_game_np,
    dtype=torch.float32
).to(device)

model.eval()

with torch.no_grad():
    logit = model(new_game_tensor)
    probability = torch.sigmoid(logit).item()

prediction = int(probability >= 0.5)

print(f"팀1 승리 확률: {probability:.2%}")
print(f"예측 결과: {'팀1 승리' if prediction == 1 else '팀2 승리'}")

🧩 로지스틱 회귀 vs MLP

항목 로지스틱 회귀 MLP
구조 선형 모델 여러 은닉층을 가진 신경망
관계 학습 주로 선형 관계 비선형, 복잡한 관계
학습 속도 빠름 상대적으로 느림
해석 계수 확인이 쉬움 상대적으로 어려움
과적합 위험 비교적 낮음 상대적으로 높음
데이터 요구량 비교적 적음 보통 더 많은 데이터가 유리
시너지 조합 효과 제한적 일부 학습 가능