Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

23 분류 프로젝트

Updated: 09 jun 2026

이 장에서는 UCI Machine Learning Repository의 레드 와인 품질 데이터셋을 이용하여 품질이 좋은 와인을 찾는 이진 분류 문제를 다룬다. 분류 모델을 훈련하기 전에 데이터의 구조와 분포를 충분히 살펴보고, 정확도뿐 아니라 혼동 행렬, 정밀도, 재현율을 함께 해석한다.

기본 설정

머신러닝 프로젝트에 필요한 기본 라이브러리를 불러온다.

  • numpy: 어레이 기반 데이터 처리

  • pandas: 데이터프레임 기반 데이터 처리

  • matplotlib.pyplot: 데이터 시각화

  • seaborn: 통계 기반 데이터 시각화

  • sklearn: 머신러닝 모델 훈련

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

seaborn 라이브러리의 기본 시각화 테마를 흰색 격자 배경을 사용하는 스타일로 지정한다.

sns.set_theme(style="whitegrid")

데이터프레임 내 부동소수점을 소수점 이하 6자리까지만 출력하도록 지정한다.

pd.set_option('display.precision', 6)

데이터 저장소

data_url = 'https://raw.githubusercontent.com/codingalzi/code-workout-datasci/refs/heads/master/data/'

23.1프로젝트 흐름

분류 프로젝트의 진행 과정은 회귀 프로젝트와 기본적으로 동일하다. 보통 다음 순서로 진행된다.

  1. 문제 정의

  2. 모델 유형 확인

  3. 데이터 적재

  4. 데이터 구조 파악

  5. 훈련셋과 테스트셋

  6. 탐색적 데이터 분석

  7. 전처리

  8. 모델 선택과 훈련

  9. 모델 평가

23.21단계: 문제 정의

데이터셋

와인 품질 데이터셋은 포르투갈 비뉴 베르드 레드 와인의 물리화학적 측정값과 시음 평가로 매긴 품질 점수를 담고 있다.

특성설명
fixed acidity고정 산도
volatile acidity휘발성 산도
citric acid구연산 함량
residual sugar잔당
chlorides염화물 함량
free sulfur dioxide유리 이산화황
total sulfur dioxide총 이산화황
density밀도
pH산성도 지표
sulphates황산염
alcohol알코올 도수
quality품질 점수, 원래 타깃

문제 정의

quality가 7 이상인 와인을 good, 나머지를 ordinary로 정의하고, 이 두 클래스를 예측하는 분류 문제를 머신러닝 모델로 해결한다.

23.32단계: 모델 유형 확인

quality 값을 기준으로 와인을 good 또는 ordinary 중 하나로 구분하는 머신러닝 모델을 훈련시켜야 한다. 예측값은 두 개의 범주 중 하나이므로 이진 분류 과제에 해당한다.

이 과제를 해결하기 위해 분류용 머신러닝 모델을 선택하고, 와인 품질 데이터셋을 이용하여 모델을 훈련시킨다. 모델 훈련은 지도 학습 방식으로 진행된다. 이는 와인 품질 데이터셋에 정답에 해당하는 품질 레이블이 포함되어 있으며, 모델이 주어진 입력 특성을 바탕으로 와인의 품질 등급을 최대한 정확하게 예측하도록 학습하기 때문이다.

23.43단계: 데이터 적재

UCI에서 제공하는 CSV 파일을 읽고, 품질 점수를 기준으로 이진 타깃을 만든다. head()는 열 이름과 값의 형태를 빠르게 확인하는 데 도움이 된다.

wine_url = "https://archive.ics.uci.edu/ml/machine-learning-databases/wine-quality/winequality-red.csv"
wine_df = pd.read_csv(wine_url, sep=";")

불러온 데이터는 다음과 같다.

wine_df.head()
Loading...

23.54단계: 데이터 구조 확인

데이터의 크기, 열의 자료형, 결측치, 기본 통계량, 타깃 클래스 구성을 확인한다.

와인 데이터셋은 총 1599개의 샘플과 12개의 특성으로 구성되었다.

wine_df.shape
(1599, 12)

각 열의 자료형과 결측치가 아닌 값의 수를 확인하면서 동시에 수치형 특성과 범주형 특성을 확인한다. 결측치는 전혀 없는 것으로 확인되며, quality 특성 이외에는 모두 부동소수점을 값으로 갖는 수치형 특성이다.

wine_df.info()
<class 'pandas.DataFrame'>
RangeIndex: 1599 entries, 0 to 1598
Data columns (total 12 columns):
 #   Column                Non-Null Count  Dtype  
---  ------                --------------  -----  
 0   fixed acidity         1599 non-null   float64
 1   volatile acidity      1599 non-null   float64
 2   citric acid           1599 non-null   float64
 3   residual sugar        1599 non-null   float64
 4   chlorides             1599 non-null   float64
 5   free sulfur dioxide   1599 non-null   float64
 6   total sulfur dioxide  1599 non-null   float64
 7   density               1599 non-null   float64
 8   pH                    1599 non-null   float64
 9   sulphates             1599 non-null   float64
 10  alcohol               1599 non-null   float64
 11  quality               1599 non-null   int64  
dtypes: float64(11), int64(1)
memory usage: 150.0 KB

quality 특성을 제외한 수치형 특성의 기본 통계량을 확인한다. 특성마다 값의 범위가 크게 다르면 표준화 등 일부 특성의 전처리가 필요할 수 있다.

wine_df.select_dtypes(include="float").describe()
Loading...

수치형 특성별로 히스토그램을 통해 다음 정보를 얻을 수 있다.

  • 각 특성마다 사용되는 단위와 스케일이 다르다. 1 미만 단위부터 백 단위까지 다양하다.

  • 일부 특성은 한쪽으로 치우쳐저 있다. 특히 residual sugar, chlorides, free sulfur dioxide, total sulfur dioxide, sulphate, alcohol은 오른쪽 꼬리가 길다.

wine_df.hist(bins=35, figsize=(12, 10))
plt.show()
<Figure size 1200x1000 with 12 Axes>

quality 특성은 1점과 10점 사이의 정수 품질 점수이며, 높은 값일 수록 좋은 품질을 의미하기에 수치형 특성으로 간주하는 게 원칙이다. 하지만 여기서는 7 이상을 good, 아니면 ordinary 두 특성으로 구분하는 용도로 사용하기에 수치형 특성 여부는 전혀 중요하지 않다. 먼저 value_counts() 메서드를 이용하여 어떤 점수가 몇 번씩 사용되었는지 확인한다.

wine_df['quality'].value_counts().sort_index()
quality 3 10 4 53 5 681 6 638 7 199 8 18 Name: count, dtype: int64

훈련에 사용되는 특성

quality가 7 이상인 와인을 구별해 내기 위해 quality_label 특성이 필요하다. 특성값은 quality > 7이면 good, 나머지를 ordinary로 지정된다.

wine_df["quality_label"] = (wine_df["quality"] >= 7).map({True: "good", False: "ordinary"})

good 범주와 ordinaly 범주는 각각 13.6%, 86.4%로 ordinary 범주가 압도적으로 많다.

wine_df["quality_label"].value_counts(normalize=True)
quality_label ordinary 0.86429 good 0.13571 Name: proportion, dtype: float64

quality, quality_label 두 특성 이외의 다른 특성은 모델 훈련에 필요한 입력 특성으로 사용된다.

23.65단계: 훈련셋과 테스트셋

모델 훈련을 시작하기 전에 전체 데이터셋을 훈련셋과 테스트셋으로 나눈다. 훈련셋은 모델을 학습시키는 데 사용하고, 테스트셋은 훈련이 끝난 뒤 모델의 성능을 평가하는 데 사용한다.

X는 모델 입력에 사용할 특성값으로 구성된 데이터프레임이고, y는 예측 대상인 타깃 레이블로 구성된 시리즈이다. 아래 코드는 train_test_split() 함수를 이용하여 전체 데이터셋의 20%를 테스트셋으로 분리한다. stratify=y는 타깃인 quality_label의 클래스 비율이 훈련셋과 테스트셋에서 비슷하게 유지되도록 층화 샘플링을 적용한다. good 클래스가 상대적으로 적기 때문에, 단순 무작위 분할보다 층화 샘플링을 사용하는 편이 평가 결과를 더 안정적으로 해석하는 데 도움이 된다.

from sklearn.model_selection import train_test_split

X = wine_df.drop(columns=["quality", "quality_label"]) # 입력 특성 데이터셋
y = wine_df["quality_label"]                           # 타깃 레이블 데이터셋

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

층화 샘플링으로 얻은 표본이 전체 데이터의 분포를 잘 반영하는지도 그래프로 확인할 수 있다. 아래에서는 전체 데이터셋과 층화 샘플링 방식으로 나눈 두 데이터셋을 비교한다.

quality_label_ratio = pd.DataFrame({
    "Full Dataset": wine_df["quality_label"].value_counts(normalize=True),
    "Train Set": y_train.value_counts(normalize=True),
    "Test Set": y_test.value_counts(normalize=True),
})

quality_label_ratio
Loading...
quality_label_ratio.plot.bar(rot=0, figsize=(9, 5))

plt.xlabel("")
plt.ylabel("Proportion")
plt.show()
<Figure size 900x500 with 1 Axes>

23.76단계: 탐색적 데이터 분석

머신러닝 모델 훈련을 본격적으로 시작하기에 앞서 탐색적 데이터 분석(EDA)을 진행한다. 여기서는 다음 질문에 답해 본다.

물리화학적 측정값이 와인의 품질 등급 예측에 도움이 될까?

지금까지와 달리, 여기서는 훈련셋만을 대상으로 EDA를 진행한다.

EDA에 사용할 훈련셋 데이터프레임을 준비한다. X_train에는 모델 입력 특성만 포함되어 있으므로, 원래 품질 점수 quality와 이진 타깃 quality_label을 함께 붙여 분석용 데이터프레임을 만든다.

데이터 불균형

원래 품질 점수 분포와 이진 타깃 분포를 막대그래프로 확인하면, 점수 7 이상을 good으로 묶었을 때 두 클래스의 불균형이 어떻게 생기는지 시각적으로 확인할 수 있다.

wine_train = X_train.copy()
wine_train["quality"] = wine_df.loc[X_train.index, "quality"]
wine_train["quality_label"] = y_train

fig, axes = plt.subplots(1, 2, figsize=(11, 4))

sns.countplot(data=wine_train, x="quality", ax=axes[0])
axes[0].set_title("Quality score counts")
axes[0].set_xlabel("quality")
axes[0].set_ylabel("count")

sns.countplot(data=wine_train, x="quality_label", order=["ordinary", "good"], ax=axes[1])
axes[1].set_title("Binary quality label counts")
axes[1].set_xlabel("quality label")
axes[1].set_ylabel("count")

plt.tight_layout()
plt.show()
<Figure size 1100x400 with 2 Axes>

전체 특성을 한꺼번에 살펴보기보다, 선행 연구와 변수 중요도 분석에서 품질 예측에 중요한 것으로 나타난 알코올 도수, 휘발성 산도, 황산염, 밀도를 먼저 살펴본다. 이 변수들은 각각 알코올 함량, 초산 계열 산도, 황산염 농도, 와인의 전체적인 조성에 따른 밀도를 측정한다.

selected_features = [
    "alcohol",
    "volatile acidity",
    "sulphates",
    "density",
]

wine_train[selected_features + ["quality", "quality_label"]].head()
Loading...

상자 그림

선택한 특성의 클래스별 분포를 상자그림으로 비교한다. 상자그림은 두 클래스의 중앙값과 분포 범위가 어느 정도 다르고, 얼마나 겹치는지 보여준다.

그래프를 보면 good 와인은 ordinary 와인보다 알코올 도수와 황산염 값이 전반적으로 높은 편이다. 반대로 휘발성 산도와 밀도는 good 와인에서 상대적으로 낮은 경향을 보인다.

fig, axes = plt.subplots(2, 2, figsize=(10, 7))

for feature, ax in zip(selected_features, axes.ravel()):
    sns.boxplot(data=wine_train, x="quality_label", y=feature, order=["ordinary", "good"], ax=ax)
    ax.set_title(feature)
    ax.set_xlabel("quality label")

plt.tight_layout()
plt.show()
<Figure size 1000x700 with 4 Axes>

산점도

알코올 도수와 휘발성 산도의 조합을 산점도로 확인한다. 한 개 특성만 볼 때보다 두 특성을 함께 보았을 때 good 와인이 모이는 경향이 있는지 파악할 수 있다.

산점도를 보면 good 와인은 대체로 알코올 도수가 높고 휘발성 산도가 낮은 영역에 비교적 많이 분포한다. 특히 알코올 도수가 11 이상이면서 휘발성 산도가 0.5 이하인 구간에서 good 샘플이 눈에 띈다. 하지만 같은 영역에도 ordinary 와인이 함께 존재하므로, 두 특성만으로 두 클래스를 완전히 분리하기는 어렵다.

sns.scatterplot(
    data=wine_train,
    x="alcohol",
    y="volatile acidity",
    hue="quality_label",
    hue_order=["ordinary", "good"],
    alpha=0.7,
)
plt.title("Alcohol and volatile acidity by quality label")
plt.show()
<Figure size 640x480 with 1 Axes>

상관 계수

훈련셋의 수치형 특성들과 원래 품질 점수 사이의 상관계수를 히트맵으로 시각화한다. 상관계수는 선형적인 관계를 요약하므로, 품질과 관련이 큰 후보 특성을 찾는 출발점으로 사용할 수 있다.

히트맵에서는 품질 점수와 각 수치형 특성의 관계뿐만 아니라 입력 특성들 사이의 관계도 함께 확인할 수 있다. 예를 들어 fixed aciditycitric acid, fixed aciditydensity처럼 서로 관련이 큰 특성 쌍이 있으며, 이런 관계는 모델 해석이나 특성 선택을 고민할 때 참고할 수 있다.

corr = wine_train.drop(columns="quality_label").corr()

plt.figure(figsize=(10, 8))
sns.heatmap(corr, annot=True, fmt=".2f", cmap="Blues", vmin=-1, vmax=1)
plt.title("Correlation between numerical features")
plt.show()
<Figure size 1000x800 with 2 Axes>

품질 점수와 각 특성의 상관계수만 따로 정렬하면 어떤 특성이 상대적으로 강한 양의 관계 또는 음의 관계를 갖는지 더 쉽게 비교할 수 있다. 단, 상관관계만으로 품질을 결정하는 원인이라고 결론 내릴 수는 없다.

훈련셋에서는 alcohol이 품질 점수와 가장 큰 양의 상관관계를 보이고, sulphatescitric acid도 양의 상관관계를 보인다. 반대로 volatile acidity는 가장 큰 음의 상관관계를 보이며, total sulfur dioxide, density, chlorides도 품질 점수와 음의 관계를 보인다.

quality_correlation = (
    corr["quality"]
    .drop("quality")
    .sort_values()
    .rename("correlation_with_quality")
)

quality_correlation.to_frame()
Loading...

훈련셋을 대상으로 한 EDA 결과에서 다음을 확인할 수 있다.

  • 품질 점수가 7 이상인 good 와인은 ordinary 와인보다 적어 클래스 불균형이 존재한다.

  • good 와인은 ordinary 와인보다 알코올 도수와 황산염 값이 상대적으로 높은 경향을 보인다.

  • volatile acidity는 품질 점수와 가장 뚜렷한 음의 상관관계를 보이며, good 와인에서 상대적으로 낮은 경향을 보인다.

  • density, chlorides, total sulfur dioxide도 품질 점수와 음의 관계를 보이지만, 관계의 크기는 volatile acidity보다 작다.

  • 각 클래스의 분포가 완전히 분리되지는 않으므로 여러 특성을 함께 사용하는 분류 모델이 필요하다.

23.87단계: 전처리

특성마다 값의 범위가 다르므로 표준화를 적용한다. 표준화 기준은 훈련셋에서만 계산하고, 테스트셋에는 같은 기준을 적용하여 테스트 정보가 훈련 과정에 섞이지 않도록 한다.

scaler = StandardScaler()

X_train_scaled = pd.DataFrame(
    scaler.fit_transform(X_train),
    columns=selected_features,
    index=X_train.index,
)
X_test_scaled = pd.DataFrame(
    scaler.transform(X_test),
    columns=selected_features,
    index=X_test.index,
)

23.98단계: 모델 선택과 훈련

표준화된 입력으로 로지스틱 회귀 모델을 훈련한다. 로지스틱 회귀는 각 샘플이 good 클래스일 확률을 추정한 뒤 클래스를 예측하는 기본적인 분류 모델이다.

wine_model = LogisticRegression(max_iter=1000)
wine_model.fit(X_train_scaled, y_train)
Loading...

23.109단계: 모델 평가

테스트셋에 대해 예측하고 정확도를 계산한다. 정확도는 전체 샘플 중 맞게 예측한 비율이지만, 클래스 불균형이 있는 이 데이터에서는 다른 지표와 함께 해석해야 한다.

y_pred = wine_model.predict(X_test_scaled)
accuracy = accuracy_score(y_test, y_pred)

accuracy
0.8708333333333333

23.10.1혼동 행렬

혼동 행렬은 모델이 어떤 클래스를 어떤 클래스로 예측했는지 보여준다. 실제 good 와인을 ordinary로 놓친 경우와, 실제 ordinary 와인을 good으로 잘못 선택한 경우를 구별할 수 있다.

labels = ["ordinary", "good"]
cm = confusion_matrix(y_test, y_pred, labels=labels)

ConfusionMatrixDisplay(cm, display_labels=labels).plot(cmap="Blues")
plt.title("Confusion matrix")
plt.show()
<Figure size 640x480 with 2 Axes>

23.10.2정밀도와 재현율

정밀도는 모델이 어떤 클래스로 예측한 것 중 실제로 맞은 비율이다. 재현율은 실제 그 클래스인 샘플 중 모델이 찾아낸 비율이다.

좋은 와인을 추천 대상으로 고르는 상황이라면 good 정밀도가 중요할 수 있고, 좋은 와인을 최대한 놓치지 않는 것이 목적이라면 good 재현율이 더 중요할 수 있다.

print(classification_report(y_test, y_pred, labels=labels))
              precision    recall  f1-score   support

    ordinary       0.89      0.97      0.93       415
        good       0.56      0.22      0.31        65

    accuracy                           0.87       480
   macro avg       0.72      0.59      0.62       480
weighted avg       0.84      0.87      0.85       480

테스트셋의 일부 결과를 실제 클래스, 예측 클래스, good일 예측 확률과 함께 정리한다. 확률을 확인하면 모델이 어느 예측에 확신을 보였는지 더 구체적으로 살펴볼 수 있다.

good_index = list(wine_model.classes_).index("good")
results = pd.DataFrame({
    "actual": y_test,
    "predicted": y_pred,
    "probability_good": wine_model.predict_proba(X_test_scaled)[:, good_index],
})

results.head(10)
Loading...

23.10.3전체 특성과 비교

이번에는 전체 11개 입력 특성을 사용하여 같은 방식으로 로지스틱 회귀 모델을 훈련한다. EDA에서 선택한 네 특성만 사용했을 때와 전체 정보를 사용했을 때 평가 지표가 어떻게 달라지는지 비교한다.

X_all_train, X_all_test, y_all_train, y_all_test = train_test_split(
    X,
    y_quality,
    test_size=0.3,
    random_state=42,
    stratify=y_quality,
)

all_scaler = StandardScaler()
X_all_train_scaled = all_scaler.fit_transform(X_all_train)
X_all_test_scaled = all_scaler.transform(X_all_test)

wine_all_model = LogisticRegression(max_iter=1000)
wine_all_model.fit(X_all_train_scaled, y_all_train)

y_all_pred = wine_all_model.predict(X_all_test_scaled)

pd.DataFrame(
    {
        "model": ["selected features", "all features"],
        "accuracy": [accuracy, accuracy_score(y_all_test, y_all_pred)],
    }
)
Loading...

전체 특성 모델도 good 클래스를 얼마나 잘 찾는지 별도로 살펴보아야 한다. 정확도가 비슷해도 정밀도와 재현율의 균형은 달라질 수 있다.

print(classification_report(y_all_test, y_all_pred, labels=labels))
              precision    recall  f1-score   support

    ordinary       0.90      0.98      0.94       415
        good       0.66      0.29      0.40        65

    accuracy                           0.88       480
   macro avg       0.78      0.63      0.67       480
weighted avg       0.87      0.88      0.86       480

23.11정리

이번 장에서는 와인 품질 데이터셋으로 이진 분류 프로젝트의 기본 흐름을 살펴보았다.

  • 데이터 구조와 결측치, 통계량을 확인한 뒤 모델링을 시작한다.

  • EDA를 통해 타깃 클래스의 불균형과 후보 특성의 분포 차이를 파악할 수 있다.

  • 이진 분류는 두 클래스 중 하나를 예측하는 문제이다.

  • 클래스 비율이 치우친 데이터에서는 정확도만으로 모델을 평가하기 어렵다.

  • 혼동 행렬, 정밀도, 재현율을 함께 보면 모델이 어떤 실수를 하는지 이해할 수 있다.

  • 선택한 특성과 전체 특성을 사용한 모델을 비교하면 입력 정보의 효과를 확인할 수 있다.

실제 품질 판별 문제에서는 임계값 조정, 클래스 가중치, 추가 모델 비교 등을 통해 good 와인을 찾는 성능을 더 면밀히 개선할 수 있다.

23.12연습문제

문제 1

alcohol, sulphates 두 특성만 사용하여 모델을 훈련해 보아라. 네 개 특성을 사용했을 때와 정확도 및 good 재현율은 어떻게 달라지는가?

문제 2

혼동 행렬에서 모델이 좋은 와인을 얼마나 놓치는지 설명해 보아라. 이 문제에서 좋은 와인을 놓치는 실수가 중요한 이유는 무엇인가?

문제 3

classification_report()에서 goodordinary의 정밀도와 재현율을 비교해 보아라. 클래스 불균형과 어떤 관련이 있는가?

문제 4

quality >= 6으로 좋은 와인의 기준을 바꾸면 클래스 분포와 모델 평가 결과는 어떻게 달라지는가?