회귀(Regression)란?

1편 종합 분류표에서 지도학습은 회귀분류로 나뉜다고 했습니다. 이번 글에서는 그 중 회귀를 깊이 파고듭니다.

회귀는 연속적인 숫자 값을 예측하는 문제입니다.

문제 유형예시출력
회귀집값 예측, 주가 예측, 온도 예측연속값 (350만원, 25.3°C)
분류스팸 판별, 질병 진단범주 (스팸/정상, 양성/음성)

1. 선형 회귀 (Linear Regression)

핵심 아이디어

데이터에 가장 잘 맞는 **직선(또는 초평면)**을 찾는 것입니다.

$$\hat{y} = w_1x_1 + w_2x_2 + \cdots + w_nx_n + b$$

  • $$\hat{y}$$: 예측값
  • $$w_i$$: 가중치 (각 특성의 중요도)
  • $$b$$: 편향 (절편)
  • $$x_i$$: 입력 특성

손실 함수: 평균 제곱 오차 (MSE)

"예측이 얼마나 틀렸는가"를 측정하는 함수입니다.

$$MSE = \frac{1}{n}\sum_{i=1}^{n}(y_i - \hat{y}_i)^2$$

목표는 이 MSE를 최소화하는 $$w$$와 $$b$$를 찾는 것입니다.

최소화 방법 1: 정규 방정식 (Normal Equation)

수학적으로 한 번에 최적해를 구할 수 있습니다.

$$\mathbf{w} = (\mathbf{X}^T\mathbf{X})^{-1}\mathbf{X}^T\mathbf{y}$$

  • 장점: 반복 없이 한 번에 계산
  • 단점: 특성 수가 많으면 역행렬 계산이 느림 ($$O(n^3)$$)

최소화 방법 2: 경사 하강법 (Gradient Descent)

기울기(gradient)를 따라 조금씩 내려가면서 최솟값을 찾습니다.

$$w := w - \alpha \frac{\partial MSE}{\partial w}$$

  • $$\alpha$$: 학습률 (한 번에 얼마나 움직일지)
  • 학습률이 너무 크면 발산, 너무 작으면 수렴이 느림
# 경사 하강법 직접 구현
import numpy as np

def gradient_descent(X, y, lr=0.01, epochs=1000):
    m, n = X.shape
    w = np.zeros(n)
    b = 0

    for _ in range(epochs):
        y_pred = X @ w + b
        error = y_pred - y

        # 기울기 계산
        dw = (2/m) * (X.T @ error)
        db = (2/m) * np.sum(error)

        # 가중치 업데이트
        w -= lr * dw
        b -= lr * db

    return w, b

Python 실습: 단순 선형 회귀

import numpy as np
import matplotlib.pyplot as plt
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_squared_error, r2_score

# 데이터 생성 (y = 3x + 5 + 노이즈)
np.random.seed(42)
X = np.random.rand(100, 1) * 10
y = 3 * X.squeeze() + 5 + np.random.randn(100) * 2

# 모델 학습
model = LinearRegression()
model.fit(X, y)

# 결과 확인
print(f"가중치 (기울기): {model.coef_[0]:.4f}")  # ≈ 3
print(f"편향 (절편): {model.intercept_:.4f}")      # ≈ 5

# 예측 및 평가
y_pred = model.predict(X)
print(f"MSE: {mean_squared_error(y, y_pred):.4f}")
print(f"R² Score: {r2_score(y, y_pred):.4f}")

# 시각화
plt.scatter(X, y, alpha=0.5, label='실제 데이터')
plt.plot(X, y_pred, color='red', linewidth=2, label='회귀선')
plt.xlabel('X')
plt.ylabel('y')
plt.title('단순 선형 회귀')
plt.legend()
plt.show()

2. 다항 회귀 (Polynomial Regression)

핵심 아이디어

데이터가 직선으로 설명되지 않을 때, 입력 특성을 거듭제곱하여 새로운 특성을 만들고 선형 회귀를 적용합니다.

$$\hat{y} = w_1x + w_2x^2 + w_3x^3 + \cdots + b$$

본질적으로는 확장된 특성 공간에서의 선형 회귀입니다.

Python 실습: 곡선 데이터 피팅

from sklearn.preprocessing import PolynomialFeatures
from sklearn.pipeline import make_pipeline

# 비선형 데이터 생성 (y = 0.5x² - 2x + 3 + 노이즈)
np.random.seed(42)
X = np.random.rand(100, 1) * 6 - 3  # -3 ~ 3
y = 0.5 * X.squeeze()**2 - 2 * X.squeeze() + 3 + np.random.randn(100) * 0.5

# 차수별 다항 회귀 비교
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
degrees = [1, 2, 10]

for ax, degree in zip(axes, degrees):
    model = make_pipeline(
        PolynomialFeatures(degree=degree),
        LinearRegression()
    )
    model.fit(X, y)

    X_plot = np.linspace(-3, 3, 100).reshape(-1, 1)
    y_plot = model.predict(X_plot)

    ax.scatter(X, y, alpha=0.5, s=20)
    ax.plot(X_plot, y_plot, color='red', linewidth=2)
    ax.set_title(f'차수 = {degree} (R² = {r2_score(y, model.predict(X)):.3f})')
    ax.set_ylim(-2, 15)

plt.tight_layout()
plt.show()

주의: 차수가 너무 높으면 **과적합(overfitting)**이 발생합니다. 10차 다항식은 학습 데이터에 완벽히 맞지만, 새로운 데이터에는 엉망인 예측을 합니다.


3. 정규화 회귀: 과적합을 막는 방법

과적합을 방지하기 위해 **가중치가 너무 커지지 않도록 제한(패널티)**을 주는 방법입니다.

릿지 회귀 (Ridge Regression) — L2 정규화

$$MSE_{Ridge} = \frac{1}{n}\sum_{i=1}^{n}(y_i - \hat{y}i)^2 + \alpha\sum{j=1}^{p}w_j^2$$

  • 모든 가중치를 골고루 줄임 (0에 가깝게, 하지만 정확히 0은 아님)
  • 모든 특성을 살려두면서 과적합 방지

라쏘 회귀 (Lasso Regression) — L1 정규화

$$MSE_{Lasso} = \frac{1}{n}\sum_{i=1}^{n}(y_i - \hat{y}i)^2 + \alpha\sum{j=1}^{p}|w_j|$$

  • 일부 가중치를 정확히 0으로 만듦 → 자동 특성 선택(Feature Selection)
  • 불필요한 특성을 제거하여 모델을 단순화

엘라스틱넷 (ElasticNet) — L1 + L2

$$MSE_{Elastic} = MSE + \alpha_1\sum|w_j| + \alpha_2\sum w_j^2$$

L1과 L2의 장점을 결합한 방식입니다.

비교 표

방법패널티특성 선택적합한 상황
릿지 (L2)$$\sum w_j^2$$X (모든 특성 유지)특성이 모두 유의미할 때
라쏘 (L1)$$\sum |w_j|$$O (자동 제거)불필요한 특성이 많을 때
엘라스틱넷L1 + L2O (부분 제거)상관된 특성이 많을 때

Python 실습: 정규화 비교

from sklearn.linear_model import Ridge, Lasso, ElasticNet

# 고차원 데이터 (특성 20개 중 실제 유효한 건 5개)
np.random.seed(42)
n_samples, n_features = 100, 20
X = np.random.randn(n_samples, n_features)
true_w = np.zeros(n_features)
true_w[:5] = [3, -2, 1.5, -1, 0.5]  # 5개만 유효
y = X @ true_w + np.random.randn(n_samples) * 0.5

# 세 모델 비교
models = {
    '선형 회귀': LinearRegression(),
    '릿지 (α=1)': Ridge(alpha=1.0),
    '라쏘 (α=0.1)': Lasso(alpha=0.1),
}

print(f"{'모델':<15} {'MSE':>8} {'0이 아닌 가중치 수':>18}")
print("-" * 45)

for name, model in models.items():
    model.fit(X, y)
    y_pred = model.predict(X)
    mse = mean_squared_error(y, y_pred)
    nonzero = np.sum(np.abs(model.coef_) > 0.01)
    print(f"{name:<15} {mse:>8.4f} {nonzero:>18}")

# 가중치 시각화
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
for ax, (name, model) in zip(axes, models.items()):
    ax.bar(range(n_features), model.coef_)
    ax.set_title(name)
    ax.set_xlabel('특성 번호')
    ax.set_ylabel('가중치')
    ax.axhline(y=0, color='gray', linestyle='--')

plt.tight_layout()
plt.show()

라쏘가 불필요한 특성의 가중치를 0으로 만들어 자동으로 특성 선택을 하는 것을 확인할 수 있습니다.


4. 모델 평가 지표

회귀 모델이 얼마나 좋은지 판단하는 핵심 지표들입니다.

지표수식의미
MSE$$\frac{1}{n}\sum(y - \hat{y})^2$$평균 제곱 오차 (단위가 제곱)
RMSE$$\sqrt{MSE}$$원래 단위로 변환된 오차
MAE$$\frac{1}{n}\sum|y - \hat{y}|$$평균 절대 오차 (이상치에 강건)
R² Score$$1 - \frac{\sum(y - \hat{y})^2}{\sum(y - \bar{y})^2}$$설명력 (1에 가까울수록 좋음)
from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score

def evaluate_model(y_true, y_pred, model_name="모델"):
    mse = mean_squared_error(y_true, y_pred)
    rmse = np.sqrt(mse)
    mae = mean_absolute_error(y_true, y_pred)
    r2 = r2_score(y_true, y_pred)

    print(f"=== {model_name} 평가 ===")
    print(f"MSE:  {mse:.4f}")
    print(f"RMSE: {rmse:.4f}")
    print(f"MAE:  {mae:.4f}")
    print(f"R²:   {r2:.4f}")
    return {'mse': mse, 'rmse': rmse, 'mae': mae, 'r2': r2}

5. 과적합 vs 과소적합

과소적합 (Underfitting)적절한 적합과적합 (Overfitting)
학습 성능낮음높음매우 높음
테스트 성능낮음높음낮음
원인모델이 너무 단순모델이 너무 복잡
해결복잡도 높이기정규화, 데이터 추가

교차 검증 (Cross-Validation)

데이터를 K개로 나누어 번갈아 검증하는 방법입니다. 한 번의 학습/테스트 분할보다 더 신뢰할 수 있는 성능 추정을 제공합니다.

from sklearn.model_selection import cross_val_score

model = LinearRegression()
scores = cross_val_score(model, X, y, cv=5, scoring='r2')

print(f"5-Fold R² scores: {scores}")
print(f"평균 R²: {scores.mean():.4f} ± {scores.std():.4f}")

6. 토이 프로블럼: 캘리포니아 집값 예측

이제 배운 모든 것을 종합하여 실전 문제를 풀어봅시다.

6-1. 데이터 로드 및 탐색

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.datasets import fetch_california_housing

# 데이터 로드
housing = fetch_california_housing()
X = pd.DataFrame(housing.data, columns=housing.feature_names)
y = pd.Series(housing.target, name='MedHouseVal')

print("=== 데이터 개요 ===")
print(f"샘플 수: {X.shape[0]}, 특성 수: {X.shape[1]}")
print(f"타겟 범위: {y.min():.2f} ~ {y.max():.2f} (단위: $100,000)")
print()
print("=== 특성 설명 ===")
feature_desc = {
    'MedInc': '블록 그룹의 중위 소득',
    'HouseAge': '블록 그룹의 중위 주택 연령',
    'AveRooms': '가구당 평균 방 수',
    'AveBedrms': '가구당 평균 침실 수',
    'Population': '블록 그룹 인구',
    'AveOccup': '가구당 평균 거주자 수',
    'Latitude': '위도',
    'Longitude': '경도',
}
for feat, desc in feature_desc.items():
    print(f"  {feat:12s}: {desc}")

print()
print(X.describe().round(2))

6-2. 데이터 시각화

fig, axes = plt.subplots(2, 4, figsize=(16, 8))
axes = axes.ravel()

for i, col in enumerate(X.columns):
    axes[i].scatter(X[col], y, alpha=0.1, s=1)
    axes[i].set_xlabel(col)
    axes[i].set_ylabel('집값')
    axes[i].set_title(f'{col} vs 집값')

plt.tight_layout()
plt.show()
# 상관관계 히트맵
import seaborn as sns

df = X.copy()
df['MedHouseVal'] = y

corr = df.corr()
plt.figure(figsize=(10, 8))
sns.heatmap(corr, annot=True, fmt='.2f', cmap='RdBu_r', center=0)
plt.title('특성 간 상관관계')
plt.show()

print("\n집값과의 상관관계 (절대값 기준 정렬):")
print(corr['MedHouseVal'].drop('MedHouseVal').abs().sort_values(ascending=False))

6-3. 데이터 전처리

from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler

# 학습/테스트 분할
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)
print(f"학습 세트: {X_train.shape[0]}개")
print(f"테스트 세트: {X_test.shape[0]}개")

# 특성 스케일링 (정규화 회귀에 필수)
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)

왜 스케일링이 필요한가? 릿지와 라쏘는 가중치 크기에 패널티를 주므로, 특성의 스케일이 다르면 공정한 비교가 불가능합니다. 소득(015)과 인구(035,000)의 단위 차이가 가중치에 직접 영향을 줍니다.

6-4. 모델 학습 및 비교

from sklearn.linear_model import LinearRegression, Ridge, Lasso, ElasticNet
from sklearn.preprocessing import PolynomialFeatures
from sklearn.pipeline import make_pipeline
from sklearn.metrics import mean_squared_error, r2_score

# 모델 정의
models = {
    '선형 회귀': LinearRegression(),
    '릿지 (α=1)': Ridge(alpha=1.0),
    '릿지 (α=10)': Ridge(alpha=10.0),
    '라쏘 (α=0.01)': Lasso(alpha=0.01),
    '라쏘 (α=0.1)': Lasso(alpha=0.1),
    '엘라스틱넷': ElasticNet(alpha=0.01, l1_ratio=0.5),
}

# 학습 및 평가
results = []
for name, model in models.items():
    model.fit(X_train_scaled, y_train)

    train_pred = model.predict(X_train_scaled)
    test_pred = model.predict(X_test_scaled)

    results.append({
        '모델': name,
        'Train RMSE': np.sqrt(mean_squared_error(y_train, train_pred)),
        'Test RMSE': np.sqrt(mean_squared_error(y_test, test_pred)),
        'Train R²': r2_score(y_train, train_pred),
        'Test R²': r2_score(y_test, test_pred),
        '0이 아닌 특성': np.sum(np.abs(model.coef_) > 0.001),
    })

results_df = pd.DataFrame(results)
print(results_df.to_string(index=False))

6-5. 하이퍼파라미터 튜닝

from sklearn.model_selection import GridSearchCV

# 릿지 회귀 최적 α 탐색
ridge = Ridge()
param_grid = {'alpha': [0.01, 0.1, 1, 10, 100]}

grid_search = GridSearchCV(
    ridge, param_grid, cv=5,
    scoring='neg_mean_squared_error',
    return_train_score=True
)
grid_search.fit(X_train_scaled, y_train)

print(f"최적 α: {grid_search.best_params_['alpha']}")
print(f"최적 CV RMSE: {np.sqrt(-grid_search.best_score_):.4f}")

# α에 따른 성능 변화 시각화
cv_results = pd.DataFrame(grid_search.cv_results_)
alphas = param_grid['alpha']
train_scores = np.sqrt(-cv_results['mean_train_score'])
test_scores = np.sqrt(-cv_results['mean_test_score'])

plt.figure(figsize=(8, 5))
plt.plot(alphas, train_scores, 'o-', label='Train RMSE')
plt.plot(alphas, test_scores, 'o-', label='CV RMSE')
plt.xscale('log')
plt.xlabel('α (정규화 강도)')
plt.ylabel('RMSE')
plt.title('릿지 회귀: α에 따른 성능 변화')
plt.legend()
plt.grid(True, alpha=0.3)
plt.show()

6-6. 최종 모델 평가

# 최적 모델로 최종 평가
best_model = grid_search.best_estimator_
y_pred = best_model.predict(X_test_scaled)

print("=== 최종 모델 성능 (테스트 세트) ===")
print(f"RMSE: {np.sqrt(mean_squared_error(y_test, y_pred)):.4f}")
print(f"MAE:  {mean_absolute_error(y_test, y_pred):.4f}")
print(f"R²:   {r2_score(y_test, y_pred):.4f}")

# 특성 중요도 (가중치 절대값)
importance = pd.Series(
    np.abs(best_model.coef_),
    index=housing.feature_names
).sort_values(ascending=True)

plt.figure(figsize=(8, 5))
importance.plot(kind='barh')
plt.title('특성 중요도 (릿지 회귀 가중치 절대값)')
plt.xlabel('|가중치|')
plt.tight_layout()
plt.show()

6-7. 예측 결과 시각화

# 실제값 vs 예측값 산점도
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# 산점도
axes[0].scatter(y_test, y_pred, alpha=0.3, s=10)
axes[0].plot([0, 5], [0, 5], 'r--', linewidth=2)
axes[0].set_xlabel('실제 집값 ($100,000)')
axes[0].set_ylabel('예측 집값 ($100,000)')
axes[0].set_title('실제값 vs 예측값')

# 잔차 분포
residuals = y_test - y_pred
axes[1].hist(residuals, bins=50, edgecolor='black', alpha=0.7)
axes[1].axvline(x=0, color='red', linestyle='--')
axes[1].set_xlabel('잔차 (실제 - 예측)')
axes[1].set_ylabel('빈도')
axes[1].set_title(f'잔차 분포 (평균: {residuals.mean():.4f})')

plt.tight_layout()
plt.show()

정리

알고리즘언제 사용?핵심 포인트
선형 회귀특성과 타겟이 선형 관계일 때가장 단순하고 해석 쉬움
다항 회귀비선형 관계가 보일 때차수 선택이 핵심 (과적합 주의)
릿지모든 특성이 유의미할 때가중치를 골고루 줄임
라쏘불필요한 특성이 많을 때자동 특성 선택
엘라스틱넷상관된 특성이 많을 때릿지 + 라쏘의 장점 결합

다음 글에서는 지도학습의 나머지 절반인 분류 알고리즘을 다룹니다. 로지스틱 회귀, SVM, 의사결정 트리 등을 이론부터 붓꽃 분류 실습까지 진행합니다.