개요

지난 글에서 데이터를 정제하는 데까지 왔다. 이번엔 scikit-learn이다.

이 라이브러리를 처음 만졌을 때 놀란 게 있다. 코드가 너무 짧다. 모델 학습이 세 줄이면 끝난다.

model = LogisticRegression()
model.fit(X_train, y_train)
y_pred = model.predict(X_test)

그런데 짧아서 오히려 위험하다. 어려운 건 문법이 아니라 순서인데, 순서를 어겨도 에러가 안 난다. 대신 성능 숫자가 조용히 거짓말을 시작한다.

이번 글은 그 순서와 이유를 정리한다.


1. sklearn은 문법이 하나다

먼저 좋은 소식. scikit-learn은 거의 모든 도구가 똑같은 3단계로 동작한다.

① 객체 만들기   →  도구 = 무언가()
② 학습하기      →  도구.fit(...)
③ 사용하기      →  도구.predict(...)  또는  도구.transform(...)
  • 예측하는 도구(분류·회귀 모델)는 ③에서 predict()
  • 변환하는 도구(스케일러, PCA)는 ③에서 transform()

이 규칙 하나만 알면 처음 보는 모델도 바로 쓸 수 있다. 실제로 문서를 안 보고 이름만 바꿔 끼워도 돌아간다.

from sklearn.ensemble import RandomForestClassifier

model = RandomForestClassifier()   # 이름만 바뀌었다
model.fit(X_train, y_train)
y_pred = model.predict(X_test)

그래서 이 글의 나머지는 문법이 아니라 순서 이야기다.

이 설계가 처음엔 그냥 편하다고만 생각했는데, 나중에 보니 꽤 영리한 결정이었다.

모델마다 문법이 달랐다면 새 모델을 쓸 때마다 문서를 읽어야 한다. 문법을 하나로 통일해두면 사용자가 모델을 갈아 끼우는 비용이 거의 0이 된다. 그러면 사람들이 여러 모델을 시도하게 되고, 그게 라이브러리를 더 쓰게 만든다.

인터페이스를 통일하는 게 왜 중요한지를 실감한 사례였다. 백엔드에서 여러 저장소를 같은 인터페이스로 감싸두면 나중에 교체가 쉬워지는 것과 같은 이야기다.


2. 데이터를 나누는 이유

문제집 한 권을 다 풀고 그 문제집으로 시험을 보면 당연히 100점이 나온다. 답을 외웠으니까. 그건 실력이 아니다.

그래서 데이터를 나눈다.

이름 역할 비율
Train (학습) 모델이 패턴을 배우는 데이터 60~80%
Validation (검증) 학습 중 점검하고 설정을 조정 10~20%
Test (테스트) 최종 성능 측정. 학습에 절대 사용 금지 10~20%

Validation과 Test는 왜 따로 있나

처음에 이게 이해가 안 됐다. 둘 다 “안 배운 데이터”인데 왜 두 개나 필요한가?

Validation은 모의고사다. 이걸 보면서 “학습을 더 할까? 층을 늘릴까?” 하고 조정한다. 그런데 조정을 반복하다 보면 모델이 그 모의고사에 맞춰서 튜닝되어 버린다. 그래서 Validation 점수도 결국 살짝 부풀려진다.

Test는 실전 수능이다. 모든 조정이 끝난 뒤 딱 한 번만 본다. 그래야 진짜 실력이 나온다.

코드

from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y
)

결과가 왜 4개인가? 문제(X)와 정답(y)을 각각 학습용·테스트용으로 나누니 2×2 = 4개다.

           문제(X)      정답(y)
학습용     X_train     y_train
테스트용    X_test      y_test

순서를 절대 바꾸면 안 된다. 나는 이걸 한 번 틀려서 반나절을 날렸다.

# ❌ 이렇게 쓰면 에러는 안 나는데 결과가 통째로 엉망이 된다
X_train, y_train, X_test, y_test = train_test_split(X, y, ...)

sklearn은 항상 “X 관련(학습→테스트), 그다음 y 관련(학습→테스트)” 순서로 돌려준다. 순서를 잘못 적으면 이름표가 뒤바뀐 상자가 되는데, 에러가 안 나니까 한참 뒤에야 “왜 정확도가 50%지?” 하고 헤매게 된다.

파라미터 세 개

test_size=0.2 — 전체의 20%를 테스트용으로 뗀다. 나머지 80%는 자동으로 학습용.

random_state=42 — 나누기 전에 섞는 방식을 고정한다.

이게 없으면 코드를 다시 돌릴 때마다 성능이 조금씩 바뀐다. 그러면 “내가 모델을 개선한 건가, 그냥 운이 좋았던 건가”를 구분할 수 없다. 재현 가능성은 실험의 기본이다.

(42는 특별한 의미가 없다. 관례적으로 많이 쓰는 숫자다.)

stratify=y — 이게 은근히 중요하다.

원래 데이터가 “A 60%, B 30%, C 10%”인데 무작위로 자르다 보면 테스트용에 C가 하나도 없을 수도 있다. 반 30명 중 안경 낀 애가 3명인데 무작위로 10명 뽑았더니 한 명도 안 뽑힌 상황이다.

stratify=y를 붙이면 원래 비율을 양쪽에 유지해준다. 분류 문제라면 습관적으로 넣는 게 좋다.

세 덩어리로 나누기

train_test_split은 한 번에 두 덩어리만 만든다. 3등분하려면 칼질을 두 번 해야 한다.

# 1차: 전체 → 학습 80% + 나머지 20%
X_train, X_temp, y_train, y_temp = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y
)

# 2차: 나머지 20% → 검증 10% + 테스트 10%
X_valid, X_test, y_valid, y_test = train_test_split(
    X_temp, y_temp, test_size=0.5, random_state=42, stratify=y_temp
)

왜 2차에서 0.5인가? 계산해보면 명확하다.

전체 100
 ↓ 1차 (test_size=0.2)
80 (train) + 20 (temp)
              ↓ 2차 (test_size=0.5) — 20의 절반은 10
             10 (valid) + 10 (test)

2차에서도 0.2를 쓰면 16%와 4%로 이상하게 갈린다. “남은 덩어리의 몇 %인지”를 계산해야 한다.

그리고 2차에서는 stratify=y_temp다. 지금 나누는 대상이 X_temp이기 때문이다. 원본 y를 넣으면 크기가 안 맞아 에러가 난다.


3. 스케일링 — 단위를 맞춘다

데이터를 열어보면 열마다 값의 크기가 완전히 다르다.

나이      :  20 ~ 60
연봉      :  3000 ~ 10000
만족도    :  0 ~ 1

한쪽은 미터로 재고 한쪽은 밀리미터로 잰 셈이다. 이러면 두 가지 문제가 생긴다.

  1. 최적화가 느리고 불안정해진다 — 어느 방향으로 얼마나 가야 할지 모델이 헷갈린다
  2. 거리를 재는 모델이 망가진다 — 연봉이 나이를 압도해서 나이는 있으나 마나가 된다
from sklearn.preprocessing import StandardScaler

scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_valid_scaled = scaler.transform(X_valid)
X_test_scaled  = scaler.transform(X_test)

StandardScaler는 각 열을 평균 0, 표준편차 1로 바꾼다. 모든 열을 같은 자로 다시 재는 것이다.


4. 이 글에서 가장 중요한 규칙

위 코드를 다시 보자. X_train에는 fit_transform, 나머지에는 transform만 썼다.

fit은 학습 데이터에서 딱 한 번만. 나머지는 항상 transform만.

fit과 transform

왜 그런가

fit()“평균과 표준편차를 계산해서 기억하는 과정”이다.

만약 학습·검증·테스트를 전부 합쳐서 평균을 낸다면? 아직 안 본 시험의 정보가 채점 기준에 미리 섞여 들어간 것이다. 이걸 데이터 유출(Data Leakage)이라고 한다.

분할과 전처리의 순서

무서운 점은 이거다.

에러가 나지 않는다. 오히려 성능이 더 좋게 나온다.

그래서 알아채지 못한다. 진짜 새로운 데이터를 만나면 성능이 뚝 떨어지는데, 그때는 이미 늦었다.

저울 비유

코드 하는 일
fit_transform(X_train) 저울 눈금을 학습 데이터로 맞추고, 그 저울로 학습 데이터 무게도 잰다
transform(X_valid) 이미 맞춰둔 그 저울을 그대로 가져와서 검증 데이터만 잰다

검증 데이터로 눈금을 맞추면 기준이 흔들려서 두 데이터를 비교할 수 없게 된다.

# ❌ 세 번 다 fit — 잣대가 매번 바뀐다
X_train_scaled = scaler.fit_transform(X_train)
X_valid_scaled = scaler.fit_transform(X_valid)   # 잣대 변경!
X_test_scaled  = scaler.fit_transform(X_test)    # 또 변경!

국어는 우리 반 평균으로, 영어는 옆 반 평균으로, 수학은 전교 평균으로 채점하는 셈이다.

스케일링만의 이야기가 아니다

fit이 들어가는 모든 전처리에 똑같이 적용된다.

  • StandardScaler, MinMaxScaler — 스케일링
  • SimpleImputer결측치 채우기 (평균을 학습 데이터에서만 계산!)
  • PCA — 차원 축소
  • LabelEncoder, OneHotEncoder — 인코딩

지난 글에서 결측치를 df["가격"].mean()으로 채웠는데, 엄밀히 말하면 그것도 분할 후에 학습 데이터 평균으로 해야 한다. 탐색 단계에서는 편하게 해도 되지만, 성능을 재는 순간부터는 규칙을 지켜야 한다.

순서 총정리

1. 데이터 분할 (train / valid / test)
2. 학습 데이터로만 전처리 규칙 학습    →  fit
3. 그 규칙을 나머지에 적용             →  transform
4. 모델 학습 (학습 데이터로만)
5. 검증 데이터로 점검 및 조정
6. 테스트 데이터로 최종 평가 (딱 한 번)

1번과 2번을 절대 바꾸지 말 것.


5. 채점하기 — 정확도만 보면 안 되는 이유

모델을 만들었으면 채점해야 한다. 가장 먼저 나오는 게 정확도인데, 여기에 함정이 있다.

1000명 중 환자가 10명(1%)인 데이터에서 “전부 건강하다”고만 답하는 모델을 만들면 정확도가 99%다. 진단 로직이 한 줄도 없는데 말이다.

그래서 틀린 방식까지 쪼개서 봐야 한다.

from sklearn.metrics import confusion_matrix, classification_report

print(confusion_matrix(y_test, y_pred))
print(classification_report(y_test, y_pred))

classification_report 한 줄이면 정밀도·재현율·F1이 클래스별로 전부 나온다. 이 함수를 알기 전에는 직접 계산하고 있었는데, 알고 나서 코드가 확 줄었다.

지표 답하는 질문
Precision (정밀도) 양성이라고 한 것 중 진짜는 몇 %?
Recall (재현율) 실제 양성 중 몇 %를 찾아냈나?
F1 둘의 균형 (조화평균)

두 지표는 서로 밀고 당긴다. 의심스러우면 전부 양성이라 외치면 재현율은 100%가 되지만 정밀도가 바닥나고, 확실할 때만 외치면 반대가 된다.

어느 쪽이 더 중요한지는 문제가 정한다. 암 진단이면 놓치는 게 더 무서우니 재현율, 스팸 필터면 멀쩡한 메일을 버리는 게 더 아프니 정밀도다.

(이 지표들이 왜 필요한지는 SSAFY 시리즈의 지도학습 편에서 다뤘으니, 여기서는 코드 위주로 넘어간다.)


6. ROC 곡선과 AUC

여기서 처음 알게 된 개념이 하나 있다. 모델은 “환자다/아니다”라고 딱 잘라 말하지 않는다.

실제로는 “환자일 확률 73%”처럼 점수를 매긴다. 그럼 몇 % 이상일 때 환자라고 판정할지를 누군가 정해야 하는데, 이게 임계값이고 기본값이 50%다.

  • 임계값을 낮추면 (30%) → 환자를 거의 다 잡지만(재현율↑) 오진도 늘어난다(정밀도↓)
  • 임계값을 높이면 (90%) → 오진은 줄지만(정밀도↑) 환자를 놓친다(재현율↓)

ROC 곡선은 이 임계값을 0%부터 100%까지 전부 바꿔가며 성능을 한 장에 그린 것이다.

ROC와 AUC

from sklearn.metrics import roc_curve, roc_auc_score
import matplotlib.pyplot as plt

# ⚠️ predict()가 아니라 predict_proba()!
y_score = clf.predict_proba(X_test)[:, 1]

fpr, tpr, thresholds = roc_curve(y_test, y_score)
auc = roc_auc_score(y_test, y_score)

plt.plot(fpr, tpr, label=f"ROC-AUC = {auc:.3f}")
plt.plot([0, 1], [0, 1], linestyle="--", label="무작위 추측")
plt.xlabel("FPR (억울하게 걸린 비율)")
plt.ylabel("TPR (제대로 잡아낸 비율)")
plt.legend()
plt.show()

predict_proba()[:, 1]이 무슨 뜻인가

여기서 한 번 막혔다. predict()를 넣었더니 곡선이 각진 꺾은선으로 나와서 한참 들여다봤다.

predict()“0이야/1이야” 결과만 준다. ROC는 확률이 있어야 임계값을 움직일 수 있으니 predict_proba()를 써야 한다.

predict_proba()의 결과는 두 칸짜리 표다.

[0일 확률, 1일 확률]
[0.8,      0.2    ]  ← 첫 번째 데이터
[0.3,      0.7    ]  ← 두 번째 데이터

보통 “양성(1)일 확률”에 관심이 있으니 [:, 1]모든 행에서 두 번째 열만 뽑는다.

AUC 읽는 법

AUC = Area Under the Curve = 곡선 아래 넓이다.

AUC 의미
1.0 완벽
0.9 이상 아주 좋음
0.7 ~ 0.8 쓸 만함
0.5 동전 던지기와 다를 바 없음
0.5 미만 오히려 반대로 예측하는 게 나은 상태

그림의 대각선 점선이 “아무렇게나 찍었을 때”의 선이다. 내 모델 곡선이 이 선보다 얼마나 위에 있는지를 보면 된다.

AUC의 장점은 임계값을 어떻게 잡든 상관없이 모델 자체의 구별 능력을 숫자 하나로 준다는 것이다. 그래서 모델 여러 개를 비교할 때 편하다.

클래스가 3개 이상이면 위 코드는 그대로 안 된다. [:, 1]이 “양성일 확률”을 뜻하는 건 이진 분류일 때 이야기다. 다중 분류에서는 확률 전체를 넘기고 방식을 지정해야 한다.

y_score = clf.predict_proba(X_test)          # 전체 확률
auc = roc_auc_score(y_test, y_score, multi_class="ovr")

7. 교차검증 — 한 번 나눠서는 못 믿는다

train_test_split으로 딱 한 번 나눠 평가하면 문제가 있다. 우연히 쉬운 문제만 테스트로 뽑혔다면? 성능이 실제보다 좋게 나온다.

그래서 여러 번 나눠가며 평가하고 평균을 낸다.

from sklearn.model_selection import cross_val_score

scores = cross_val_score(clf, X_scaled, y, cv=5, scoring="accuracy")   # ⚠️ 함정

cv=5다섯 번 나눠가며 다섯 번 평가한다는 뜻이다.

그런데 이 코드에는 문제가 있다

이 글을 정리하다가 내가 쓰던 코드가 앞에서 그렇게 강조한 규칙을 어기고 있다는 걸 발견했다.

X_scaled전체 데이터로 fit_transform한 결과다. 그런데 교차검증은 그 안에서 다시 학습·검증으로 나눈다.

교차검증과 Pipeline

스케일러가 평균을 계산할 때 이미 5개 폴드를 전부 보고 난 뒤다. 매 회차의 검증 폴드 정보가 이미 기준값에 섞여 있다.

정확히 데이터 유출이다. 그리고 늘 그렇듯 에러는 안 나고 점수만 살짝 올라간다.

해결 — Pipeline으로 묶는다

전처리와 모델을 한 덩어리로 묶어서 넘기면 된다.

from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score

pipe = make_pipeline(
    StandardScaler(),
    LogisticRegression(max_iter=1000),
)

scores = cross_val_score(pipe, X, y, cv=5, scoring="accuracy")   # ✅ 원본 X를 넘긴다

X_scaled가 아니라 원본 X를 넘긴 것에 주목하자.

이렇게 하면 sklearn이 회차마다 알아서 이렇게 처리한다.

1회차 → 학습 폴드로만 fit → 그 기준으로 검증 폴드를 transform → 평가
2회차 → (새 학습 폴드로) 다시 fit → ... → 평가

매 회차 스케일러가 새로 만들어진다. 검증 폴드는 끝까지 안 본 상태로 유지된다.

Pipeline은 편의 기능이 아니다

처음엔 Pipeline을 “코드를 짧게 쓰는 도구” 정도로 생각했다. 그런데 실제로는 교차검증에서 유출을 막는 유일한 방법이다. 손으로는 회차마다 스케일러를 새로 만들어줄 수가 없다.

일반 학습에서도 그대로 쓸 수 있다.

pipe.fit(X_train, y_train)        # 스케일링 + 학습이 한 번에
y_pred = pipe.predict(X_test)     # 예측할 때도 같은 기준으로 자동 변환

transform을 빼먹을 일도, 잘못된 스케일러를 쓸 일도 없어진다. 순서를 지키는 걸 코드 구조로 강제하는 셈이다.

이 글을 쓰면서 가장 크게 배운 게 이거였다. “조심하자”로 막을 수 있는 실수는 결국 언젠가 난다.

데이터 유출은 에러도 안 나고 점수는 오히려 좋아지니, 주의력으로 막기에는 조건이 너무 나쁘다. 그런 종류의 실수는 애초에 틀리게 쓸 수 없는 구조로 바꿔야 한다.

타입 시스템이 잘못된 값을 컴파일 단계에서 막아주는 것과 성격이 같다. 사람이 기억해야 하는 규칙을 도구가 대신 지켜주게 만드는 것이다.

print(f"각 회차: {scores}")
print(f"평균: {scores.mean():.3f}{scores.std():.3f})")

표준편차를 같이 보는 게 중요하다. 평균이 같아도 성격이 완전히 다를 수 있다.

평균 0.90 ±0.01  →  어떤 데이터가 와도 비슷하게 잘한다  ✅
평균 0.90 ±0.15  →  운에 따라 0.75도 1.05도 나온다      ⚠️

두 번째는 믿을 수 없는 모델이다. 평균만 보고 넘어가면 이걸 못 잡는다.

다중 분류라면 scoring을 바꿔야 한다. "f1"은 이진 분류 전용이라 클래스가 3개 이상이면 에러가 난다. "f1_macro""f1_weighted"를 써야 한다. 이것도 에러 메시지를 한참 읽고서야 알았다.


8. 정답 없이 패턴 찾기 — PCA와 K-Means

마지막으로 비지도학습 도구 두 개다. 정답(y)이 없어도 쓸 수 있다.

PCA — 차원을 줄여서 눈으로 보기

특성이 13개면 13차원이다. 사람은 3차원까지밖에 못 본다. PCA는 정보를 최대한 지키면서 축의 개수를 줄인다.

from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler

# 1단계: 스케일링 (필수! 순서 중요!)
X_scaled = StandardScaler().fit_transform(X)

# 2단계: 2차원으로 압축
pca = PCA(n_components=2)
X_pca = pca.fit_transform(X_scaled)

스케일링이 먼저인 게 중요하다. PCA는 “값이 많이 퍼진 방향”을 중요하다고 보는데, 스케일링을 안 하면 단위가 큰 열이 무조건 중요해진다. 연봉이 나이를 이기는 그 문제가 그대로 나온다.

얼마나 버렸는지 확인하기

압축하면 정보가 손실된다. 얼마나 손실됐는지 반드시 확인해야 한다.

PCA 설명 비율

print(pca.explained_variance_ratio_)          # [0.362 0.192]
print(f"{pca.explained_variance_ratio_.sum():.1%}")   # 총 55.4%

# 축을 몇 개 남길지 정할 때
pca_전체 = PCA().fit(X_scaled)
print(pca_전체.explained_variance_ratio_.cumsum())

cumsum()은 누적 합계다. [0.36, 0.55, 0.66, 0.73, ...]처럼 나오니 “90%를 넘기려면 축이 몇 개 필요한가”를 눈으로 확인할 수 있다.

13차원을 2차원으로 줄이면 55%만 남는다. 그림으로 보기엔 충분하지만 나머지 45%를 버렸다는 사실은 기억해야 한다. 모델 학습에 쓸 거라면 보통 90~95%를 기준으로 축 개수를 정한다.

K-Means — 정답 없이 무리 짓기

from sklearn.cluster import KMeans

kmeans = KMeans(n_clusters=3, random_state=42, n_init=10)
그룹번호 = kmeans.fit_predict(X_pca)

n_init=10이 왜 있나? K-Means는 시작할 때 중심점을 아무 데나 찍는다. 운 나쁘게 찍으면 엉뚱한 곳에 갇힌다. n_init=1010번 다르게 시도해서 제일 좋은 걸 고르라는 뜻이다.

fit_predict와 fit_transform의 차이

PCA는 fit_transform, K-Means는 fit_predict를 썼다. 이게 헷갈렸는데 정리하니 간단했다.

메서드 하는 일 비유
.fit_transform() 데이터를 다른 형태로 변환 사진을 다른 각도에서 다시 찍기
.fit_predict() 데이터에 이름표를 붙임 이마에 조 번호 스티커 붙이기

PCA는 13칸을 2칸으로 바꾸니 형태가 변하고, K-Means는 원본은 그대로 두고 번호만 추가한다.

주의할 것 하나. K-Means가 매긴 조 번호는 실제 정답 번호와 일치할 이유가 없다. 0번 조가 실제로는 2번 클래스일 수 있다. 클러스터링은 “비슷한 것끼리 묶는” 것이지 “정답을 맞히는” 게 아니다.


정리

  • sklearn은 ① 만들고 ② fit ③ predict/transform — 문법이 하나다
  • 데이터는 학습 / 검증 / 테스트로 나눈다. 검증은 모의고사, 테스트는 수능
  • train_test_split 반환 순서는 X_train, X_test, y_train, y_test — 바꾸면 조용히 망가진다
  • 분류 문제면 stratify=y, 실험이면 random_state 고정
  • 가장 중요한 규칙 — fit은 학습 데이터에서 한 번만, 나머지는 transform만
    • 어기면 데이터 유출. 에러 없이 점수만 올라간다
    • 스케일러뿐 아니라 결측치 채우기·PCA·인코더 전부 해당
  • 분할이 전처리보다 먼저
  • 정확도만 보면 안 된다 → classification_report로 정밀도·재현율·F1을 함께
  • ROC/AUC는 임계값을 전부 바꿔가며 잰 성적. predict_proba()를 넘겨야 한다
  • 교차검증은 평균과 함께 표준편차를 봐야 한다
  • 교차검증에 미리 스케일링한 데이터를 넘기면 유출이다 → Pipeline으로 묶어서 원본을 넘긴다
  • PCA는 스케일링 후에, 그리고 얼마나 버렸는지 반드시 확인

다음 글에서

다음 글에서는 PyTorch로 넘어간다. 텐서가 NumPy 배열과 무엇이 다른지, 그리고 미분을 자동으로 해주는 Autograd의 정체를 뜯어본다.

한줄 평

  • 이 부분은 코드를 작성해가며 실습을 해도 이론을 놓치기 쉬웠다… 반복학습만이 답인거 같다!