직접 해 보기: scikit-learn으로 손글씨 숫자 분류기 만들기
load_digits로 기준선 · 교차 검증 · GridSearchCV · 혼동 행렬 · 오류 분석까지 실행하는 프로젝트
먼저 알아야 할 것
다루는 내용
scikit-learn 내장 8×8 손글씨 숫자 1,797장으로 분류기를 처음부터 끝까지 만드는 실습입니다. 층화 분리와 기준선(0.1), 로지스틱 회귀 · SVM · 랜덤 포레스트의 5겹 교차 검증 비교, GridSearchCV 탐색, 테스트 정확도 0.975와 혼동 행렬 · 틀린 예 분석을 실제 실행 출력과 함께 보여 줍니다. 표준화 유무와 학습 데이터 양을 바꾼 추가 실험으로 전처리도 검증 대상임을 확인합니다.
해설
직접 해 보기: scikit-learn으로 손글씨 숫자 분류기 만들기
머신러닝 트랙에서 배운 것 — 데이터 나누기, 기준선, 여러 모델 비교, 교차 검증, 하이퍼파라미터 탐색, 혼동 행렬, 오류 분석 — 을 코드 한 벌로 묶습니다. scikit-learn에 들어 있는 8×8 손글씨 숫자 1,797장을 쓰므로 인터넷에서 아무것도 내려받지 않아도 됩니다. 아래 출력은 모두 실제로 실행한 결과입니다.
| 항목 | 내용 |
|---|---|
| 참고 자료 | scikit-learn 예제 'Recognizing hand-written digits' · scikit-learn.org/stable/auto_examples/classification/plot_digits_classification.html |
| 트랙 · 수준 | 머신러닝 · 기초(프로젝트) |
| 만드는 것 | 숫자 0~9를 맞히는 분류기, 모델 비교표, 혼동 행렬, 틀린 예 목록 |
| 걸리는 시간 | 코드 실행 1분 이내(노트북 CPU 기준), 읽고 바꿔 보기 1~2시간 |
| 먼저 알면 좋은 것 | 파이썬 기초, 로지스틱 회귀 · SVM · 랜덤 포레스트의 개념, 혼동 행렬 · 교차 검증 |
들어가기 전에
- 실습 준비: 파이썬 · 넘파이 · scikit-learn · PyTorch 최소한 —
fit·predict·score로 이루어진 scikit-learn의 공통 사용법을 알고 오면 됩니다. - 모델 평가와 과적합 — 학습 · 테스트 분리, 혼동 행렬, 교차 검증의 뜻을 이 프로젝트에서 그대로 씁니다.
한눈에 보기
- 데이터 모양을 확인하고 "아무것도 안 배운 모델"의 점수(기준선)부터 잽니다.
- 로지스틱 회귀 · SVM · 랜덤 포레스트를 같은 5겹 교차 검증으로 공정하게 비교합니다.
GridSearchCV로 SVM의C·gamma를 고르고, 테스트 데이터는 맨 마지막에 한 번만 씁니다.- 혼동 행렬과 틀린 예를 보고 어떤 숫자끼리 헷갈리는지 해석합니다.
- 전처리(표준화)도 하이퍼파라미터처럼 검증해야 한다는 것을 실험으로 확인합니다.
준비물
| 항목 | 이 글을 실행한 환경 |
|---|---|
| 파이썬 | 3.14.3 (Windows, CPU) |
| 라이브러리 | scikit-learn 1.9.0, numpy 2.5.1 |
| 데이터 | sklearn.datasets.load_digits (패키지에 포함, 내려받기 없음) |
| 난수 시드 | random_state=42로 고정 |
설치가 안 되어 있다면 pip install scikit-learn이면 numpy도 함께 설치됩니다. 아래 코드를 digits_project.py 한 파일에 차례로 붙여 실행하면 됩니다.
flowchart LR
A[load_digits 1797장] --> B[학습 80% · 테스트 20% 층화 분리]
B --> C[기준선: 최빈 클래스]
B --> D[세 모델 5겹 교차 검증]
D --> E[GridSearchCV로 SVM C · gamma 탐색]
E --> F[최적 모델을 학습 데이터 전체로 다시 학습]
F --> G[테스트로 한 번 평가: 정확도 · 혼동 행렬]
G --> H[틀린 예 분석 · 다음 실험 계획]1단계: 데이터 살펴보기
import numpy as np
from sklearn.datasets import load_digits
digits = load_digits()
X, y = digits.data, digits.target
print("X 모양:", X.shape, "y 모양:", y.shape)
print("픽셀 값 범위:", X.min(), "~", X.max())
print("클래스별 개수:", np.bincount(y))
print("첫 이미지(8x8):")
print(digits.images[0].astype(int))
print("첫 라벨:", y[0])실제 출력:
X 모양: (1797, 64) y 모양: (1797,)
픽셀 값 범위: 0.0 ~ 16.0
클래스별 개수: [178 182 177 183 181 182 181 179 174 180]
첫 이미지(8x8):
[[ 0 0 5 13 9 1 0 0]
[ 0 0 13 15 10 15 5 0]
[ 0 3 15 2 0 11 8 0]
[ 0 4 12 0 0 8 8 0]
[ 0 5 8 0 0 9 8 0]
[ 0 4 11 0 1 12 7 0]
[ 0 2 14 5 10 12 0 0]
[ 0 0 6 13 10 0 0 0]]
첫 라벨: 0해석: 이미지 한 장은 8×8 = 64개 픽셀이고, 이것을 한 줄로 편 64차원 벡터가 특성입니다. 데이터 설명서에 따르면 32×32 비트맵을 4×4 칸으로 나눠 켜진 점을 센 값이라 0~16 정수입니다. 숫자 0의 동그라미 모양이 숫자 배열에서도 보입니다. 클래스는 거의 균형(174~183장)이라 정확도를 주 지표로 써도 무리가 없습니다.
2단계: 학습 · 테스트 나누기와 기준선
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, stratify=y, random_state=42)
print("학습:", X_train.shape, "테스트:", X_test.shape)
from sklearn.dummy import DummyClassifier
dummy = DummyClassifier(strategy="most_frequent").fit(X_train, y_train)
print("기준선(최빈 클래스) 정확도:", round(dummy.score(X_test, y_test), 4))실제 출력:
학습: (1437, 64) 테스트: (360, 64)
기준선(최빈 클래스) 정확도: 0.1왜 이렇게 하나: stratify=y는 학습 · 테스트 모두에서 숫자별 비율이 같도록 나눕니다. 기준선 0.1(10%)은 "늘 같은 숫자로 찍기"의 점수입니다. 어떤 모델이든 이보다 확실히 높아야 의미가 있습니다. 테스트 데이터 360장은 이제 6단계 전까지 쓰지 않습니다.
3단계: 세 모델을 교차 검증으로 비교
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.svm import SVC
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import cross_val_score, StratifiedKFold
models = {
"로지스틱 회귀": make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)),
"SVM(RBF)": make_pipeline(StandardScaler(), SVC()),
"랜덤 포레스트": RandomForestClassifier(random_state=42),
}
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
for name, model in models.items():
scores = cross_val_score(model, X_train, y_train, cv=cv)
print(f"{name}: 5겹 평균 {scores.mean():.4f} (표준편차 {scores.std():.4f})")실제 출력:
로지스틱 회귀: 5겹 평균 0.9680 (표준편차 0.0074)
SVM(RBF): 5겹 평균 0.9812 (표준편차 0.0061)
랜덤 포레스트: 5겹 평균 0.9729 (표준편차 0.0081)해석과 설계 이유
- 셋 모두 기준선 0.1을 크게 넘습니다. 이 데이터에서는 SVM이 가장 높고, 차이(약 1~1.3%p)는 표준편차보다 큽니다.
- 파이프라인(
make_pipeline)을 쓰면 교차 검증의 각 겹에서 표준화의 평균 · 표준편차를 그 겹의 학습 부분으로만 계산합니다. 전체 데이터로 먼저 표준화하면 검증 부분 정보가 새어 들어갑니다(특성 공학의 누수 이야기). - 랜덤 포레스트는 트리라 스케일에 영향을 받지 않아 표준화를 넣지 않았습니다.
- 로지스틱 회귀는 기본
max_iter=100에서는 수렴 경고가 날 수 있어 2000으로 늘렸습니다.
4단계: GridSearchCV로 SVM 조율하기
from sklearn.model_selection import GridSearchCV
pipe = make_pipeline(StandardScaler(), SVC())
grid = {"svc__C": [0.1, 1, 10, 100], "svc__gamma": ["scale", 0.001, 0.01, 0.1]}
search = GridSearchCV(pipe, grid, cv=cv, n_jobs=1)
search.fit(X_train, y_train)
print("최적 설정:", search.best_params_)
print("최적 교차 검증 정확도:", round(search.best_score_, 4))실제 출력:
최적 설정: {'svc__C': 1, 'svc__gamma': 'scale'}
최적 교차 검증 정확도: 0.9812해석: 16가지 조합 × 5겹 = 80번 학습해 가장 좋은 조합을 골랐는데, 결과는 scikit-learn 기본값(C=1.0, gamma='scale')과 같았습니다. 기본값이 이미 좋은 출발점이라는 뜻입니다. C는 오분류 벌점의 세기, gamma는 RBF 커널의 폭입니다(서포트 벡터 머신과 커널). 파이프라인 안의 설정은 단계이름__설정이름 형식으로 지정합니다. GridSearchCV는 기본적으로 최적 조합을 찾은 뒤 학습 데이터 전체로 다시 학습한 모델을 best_estimator_에 둡니다.
5단계: 테스트로 한 번 최종 평가
from sklearn.metrics import accuracy_score, confusion_matrix, classification_report
best = search.best_estimator_
y_pred = best.predict(X_test)
print("테스트 정확도:", round(accuracy_score(y_test, y_pred), 4))
print("혼동 행렬(행=정답, 열=예측):")
print(confusion_matrix(y_test, y_pred))
print(classification_report(y_test, y_pred, digits=3))실제 출력(분류 보고서는 일부만):
테스트 정확도: 0.975
혼동 행렬(행=정답, 열=예측):
[[36 0 0 0 0 0 0 0 0 0]
[ 0 35 0 0 1 0 0 0 0 0]
[ 0 0 35 0 0 0 0 0 0 0]
[ 0 0 0 37 0 0 0 0 0 0]
[ 0 0 0 0 35 0 0 1 0 0]
[ 0 0 0 0 0 37 0 0 0 0]
[ 0 0 0 0 0 0 36 0 0 0]
[ 0 0 0 0 0 1 0 35 0 0]
[ 0 2 0 0 1 0 0 0 32 0]
[ 0 0 0 0 0 0 1 2 0 33]]
precision recall f1-score support
7 0.921 0.972 0.946 36
8 1.000 0.914 0.955 35
9 1.000 0.917 0.957 36
accuracy 0.975 360
macro avg 0.976 0.975 0.975 360해석
- 테스트 정확도 0.975(360장 중 351장)로, 교차 검증 추정 0.981과 가깝습니다. 교차 검증이 테스트 성능을 잘 예측했다는 뜻입니다.
- 혼동 행렬의 대각선 밖이 오류입니다. 8을 1로 2번, 9를 7로 2번 틀린 것이 가장 많습니다.
- 8과 9는 정밀도는 1.000이지만 재현율이 0.91대입니다. "8이라고 하면 늘 맞지만 8을 다 찾지는 못한다"는 뜻입니다. 7은 반대로 다른 숫자가 7로 잘못 들어와 정밀도가 0.921로 가장 낮습니다.
6단계: 틀린 예와 모델이 보는 곳
wrong = np.where(y_pred != y_test)[0]
print("틀린 개수:", len(wrong))
for i in wrong:
print(f" 테스트 #{i}: 정답 {y_test[i]} -> 예측 {y_pred[i]}")
rf = RandomForestClassifier(random_state=42).fit(X_train, y_train)
imp = rf.feature_importances_.reshape(8, 8)
print("픽셀 중요도(x1000, 반올림):")
print(np.round(imp * 1000).astype(int))실제 출력:
틀린 개수: 9
테스트 #51: 정답 8 -> 예측 1
테스트 #93: 정답 9 -> 예측 7
테스트 #115: 정답 4 -> 예측 7
테스트 #127: 정답 8 -> 예측 1
테스트 #135: 정답 9 -> 예측 6
테스트 #141: 정답 7 -> 예측 5
테스트 #145: 정답 9 -> 예측 7
테스트 #228: 정답 1 -> 예측 4
테스트 #254: 정답 8 -> 예측 4
픽셀 중요도(x1000, 반올림):
[[ 0 2 16 10 10 20 11 1]
[ 0 12 27 6 15 28 4 0]
[ 0 7 20 25 31 51 9 0]
[ 0 17 42 27 33 26 26 0]
[ 0 25 29 16 47 19 31 0]
[ 0 11 38 38 22 20 14 0]
[ 0 2 16 23 13 23 26 2]
[ 0 2 25 10 24 30 14 4]]해석: 틀린 예의 번호로 digits.images에서 해당 이미지를 꺼내 matplotlib.pyplot.imshow로 그려 보면, 사람이 봐도 애매한 글씨인지 모델이 놓친 것인지 판단할 수 있습니다. 랜덤 포레스트 중요도를 보면 맨 왼쪽 · 오른쪽 열은 거의 0입니다. 글씨가 가운데 쓰여 가장자리 픽셀에는 정보가 없기 때문입니다. 가운데 칸들이 숫자를 구분하는 데 주로 쓰입니다.
7단계: 바꿔 보기 실험(실제 실행)
같은 분할로 두 가지를 더 시험했습니다.
print("SVM 스케일링 없음:", round(cross_val_score(SVC(), X_train, y_train, cv=cv).mean(), 4))
for n in [50, 100, 200, 400, 800, 1437]:
m = make_pipeline(StandardScaler(), SVC()).fit(X_train[:n], y_train[:n])
print(f"학습 {n}개 -> 테스트 정확도 {m.score(X_test, y_test):.4f}")실제 출력:
SVM 스케일링 없음: 0.9882
학습 50개 -> 테스트 정확도 0.7083
학습 100개 -> 테스트 정확도 0.8694
학습 200개 -> 테스트 정확도 0.9139
학습 400개 -> 테스트 정확도 0.9389
학습 800개 -> 테스트 정확도 0.9667
학습 1437개 -> 테스트 정확도 0.9750해석
- 표준화가 늘 이득은 아닙니다. 이 데이터에서는 표준화하지 않은 SVM의 교차 검증 정확도(0.9882)가 표준화한 것(0.9812)보다 높았습니다. 픽셀은 모두 같은 단위(0~16)라 스케일을 맞출 필요가 적고, 거의 늘 0인 가장자리 픽셀까지 표준화로 키우면 잡음이 커질 수 있기 때문으로 보입니다(해석은 추정). 교훈은 "전처리도 검증 데이터로 고르는 선택지"라는 것입니다. 이 결과를 보고 표준화 여부를 그리드에 넣어 다시 탐색해 볼 수 있습니다.
- 데이터가 많을수록 좋아집니다. 학습 50장이면 71%, 1,437장이면 97.5%입니다. 곡선이 아직 오르는 중이므로 데이터를 더 모으면 더 좋아질 여지가 있습니다. 이것이 학습 곡선(learning curve)의 생각입니다.
흔한 오류와 해결
| 증상 | 원인 | 해결 |
|---|---|---|
ConvergenceWarning (로지스틱 회귀) | 반복 횟수 부족 | max_iter를 늘리거나 표준화 |
| 테스트 점수가 교차 검증보다 크게 낮음 | 테스트를 보며 설정을 여러 번 바꿈, 또는 누수 | 테스트는 마지막 한 번만, 전처리는 파이프라인 안에 |
ValueError: Invalid parameter (GridSearchCV) | 파이프라인 단계 이름 누락 | svc__C처럼 단계이름__설정 사용 |
| 결과가 실행마다 다름 | 난수 시드 미고정 | random_state 고정 |
더 해 볼 과제
- 표준화 여부를
GridSearchCV의 선택지로 넣어 보세요({"scaler": [StandardScaler(), "passthrough"]}형식, 파이프라인을Pipeline([("scaler", ...), ("svc", SVC())])로 직접 구성). - k-최근접 이웃(
KNeighborsClassifier)과 그래디언트 부스팅(HistGradientBoostingClassifier)을 비교표에 넣어 보세요. - PCA로 64차원을 20차원으로 줄인 뒤 SVM을 돌려 정확도와 학습 시간을 비교해 보세요.
- 틀린 9장을 그림으로 그려, 사람이 봐도 헷갈리는지 직접 판정해 보세요.
스스로 점검
- 기준선 정확도를 먼저 재는 이유는?
- 답: 모델이 실제로 무언가를 배웠는지 판단할 기준이 필요하기 때문입니다. 이 데이터에서 "늘 같은 숫자"는 10%이므로 97%는 큰 향상입니다.
- 표준화를 파이프라인 안에 넣는 이유는?
- 답: 교차 검증의 각 겹에서 학습 부분만으로 평균 · 표준편차를 계산해, 검증 부분의 정보가 새어 들어가지 않게 하려는 것입니다.
- 7단계 결과에서 얻을 수 있는 교훈 두 가지는?
- 답: 전처리 선택도 검증으로 정해야 한다는 것, 그리고 학습 데이터 양이 늘수록 성능이 오르므로 데이터를 더 모으는 것이 한 방법이라는 것입니다.
용어 정리
| 용어 | 쉬운 뜻 |
|---|---|
| 기준선(baseline) | 비교의 출발점이 되는 가장 단순한 모델의 점수 |
| 층화 분리(stratified split) | 클래스 비율을 유지하며 데이터를 나누기 |
| 파이프라인(pipeline) | 전처리와 모델을 한 덩어리로 묶어 함께 학습 · 예측 |
| 교차 검증(cross-validation) | 데이터를 여러 겹으로 나눠 돌아가며 검증 |
| 그리드 탐색(grid search) | 하이퍼파라미터 조합을 전부 시험해 최고를 고르기 |
| 혼동 행렬(confusion matrix) | 정답 × 예측 개수를 센 표 |
| 분류 보고서(classification report) | 클래스별 정밀도 · 재현율 · F1 요약 |
| 특성 중요도(feature importance) | 모델이 각 특성을 얼마나 사용했는지 |
| 학습 곡선(learning curve) | 학습 데이터 양에 따른 성능 변화 |
다음에 볼 것
- 스탠퍼드 CS229 머신러닝 강의 노트 — 오늘 쓴 모델들의 이론을 교재 수준으로 다시 정리합니다.
- 중간 점검: 머신러닝 트랙 — 문제 15개로 트랙 전체를 점검합니다.
- 직접 해 보기: 넘파이로 신경망을 처음부터 만들기 — 같은 숫자 데이터로 이번에는 신경망을 직접 만듭니다.
라이선스
이 글의 본문과 코드는 KAITABASE가 직접 작성했으며, 실행 출력은 위 환경에서 실제로 돌린 결과입니다. 참고한 scikit-learn 예제와 문서는 BSD-3-Clause 라이선스입니다. 숫자 데이터는 scikit-learn에 포함된 UCI 손글씨 숫자 데이터셋(E. Alpaydin 제작)의 사본입니다.
출처
- 참고: scikit-learn.org/stable/auto_examples/cla… (같은 데이터로 SVC 분류하는 공식 예제 — 이 글은 분할 · 모델 비교 · 탐색을 새로 구성)
- 데이터 설명: scikit-learn 1.9.0
load_digits().DESCR(1,797장, 64특성, 0~16 정수, 32×32 비트맵을 4×4 칸으로 센 값) - 기본값 확인: scikit-learn 1.9.0
SVC(C=1.0, gamma='scale') ·LogisticRegression(max_iter=100) 생성자 서명
AI 원리부터 이해하기 · 머신러닝 모듈
머신러닝 모듈 학습
이 글은 머신러닝 모듈의 학습 자료입니다. 전체 17개 자료 중 14번째입니다.
출처·원문
원문 보기요약만