개념
선형 회귀가 분류에 실패하는 이유
공부 시간(study hour)을 바탕으로 합격/불합격(pass/fail, 1/0)을 예측한다고 해 봅니다. 선형 회귀(linear regression)는 데이터를 지나는 직선을 적합(fit)합니다.
hours: 1 2 3 4 5 6 7 8 9 10
actual: 0 0 0 0 1 1 1 1 1 1
선형 적합(linear fit)은 1시간에서 -0.2, 10시간에서 1.3 같은 예측값을 만들 수 있습니다. 이 값은 확률이 아닙니다. 0보다 작거나 1보다 큽니다. 더 나쁘게는, 50시간 공부한 이상치(outlier) 하나가 전체 직선을 끌어당겨 모든 예측을 바꿀 수 있습니다.
분류에는 다음을 만족하는 함수(function)가 필요합니다.
- 출력이 0과 1 사이입니다(확률).
- 가파른 전이(sharp transition), 즉 결정 경계(decision boundary)를 만듭니다.
- 경계에서 멀리 떨어진 이상치에 크게 왜곡(distortion)되지 않습니다.
시그모이드 함수(Sigmoid function)
시그모이드 함수는 정확히 이 일을 합니다.
sigmoid(z) = 1 / (1 + e^(-z))
성질:
- z가 크고 양수(positive)이면
sigmoid(z)는 1에 가까워집니다.
- z가 크고 음수(negative)이면
sigmoid(z)는 0에 가까워집니다.
z = 0이면 sigmoid(z) = 0.5입니다.
- 출력은 항상 0과 1 사이입니다.
- 함수는 매끄럽고(smooth) 모든 지점에서 미분 가능(differentiable everywhere)합니다.
도함수(derivative)도 편리합니다. sigmoid'(z) = sigmoid(z) * (1 - sigmoid(z))입니다. 덕분에 기울기 계산(gradient computation)이 효율적입니다.
로지스틱 회귀 = 선형 모델 + 시그모이드
모델은 선형 회귀와 같은 z = wx + b를 계산한 뒤 시그모이드를 적용합니다.
flowchart LR
X[Input features x] --> L["Linear: z = wx + b"]
L --> S["Sigmoid: p = 1/(1+e^-z)"]
S --> D{"p >= 0.5?"}
D -->|Yes| P[Predict 1]
D -->|No| N[Predict 0]
출력 p는 P(y=1 | x), 즉 입력이 클래스 1(class 1)에 속할 확률로 해석합니다. 결정 경계는 wx + b = 0인 지점입니다. 이때 시그모이드 출력은 정확히 0.5입니다.
이진 교차 엔트로피 손실(Binary cross-entropy loss)
로지스틱 회귀에는 평균 제곱 오차(MSE)를 사용하면 안 됩니다. 시그모이드와 결합한 MSE는 여러 지역 최솟값(local minimum)을 갖는 비볼록(non-convex) 비용 곡면(cost surface)을 만듭니다. 대신 이진 교차 엔트로피, 즉 로그 손실(log loss)을 사용합니다.
Loss = -(1/n) * sum(y * log(p) + (1-y) * log(1-p))
작동 방식:
y=1이고 p가 1에 가까우면 log(1)=0이므로 손실이 거의 0입니다.
y=1이고 p가 0에 가까우면 log(0)이 음의 무한대(negative infinity)에 가까워지므로 손실이 매우 큽니다.
y=0이고 p가 0에 가까우면 손실이 거의 0입니다.
y=0이고 p가 1에 가까우면 손실이 매우 큽니다.
이 손실 함수는 로지스틱 회귀에서 볼록(convex)하므로 유일한 전역 최솟값(single global minimum)을 보장합니다.
로지스틱 회귀의 경사 하강법(Gradient Descent)
시그모이드와 이진 교차 엔트로피를 함께 쓰면 기울기(gradient)가 깔끔합니다.
dL/dw = (1/n) * sum((p - y) * x)
dL/db = (1/n) * sum(p - y)
선형 회귀의 기울기와 거의 동일해 보입니다. 차이는 p = wx + b가 아니라 p = sigmoid(wx + b)라는 점입니다. 시그모이드가 비선형성(nonlinearity)을 도입하지만 기울기 갱신 규칙(gradient update rule)은 그대로 유지됩니다.
flowchart TD
A[Initialize w=0, b=0] --> B[Forward pass: z = wx+b, p = sigmoid z]
B --> C[Compute loss: binary cross-entropy]
C --> D["Compute gradients: dw = (1/n) * sum((p-y)*x)"]
D --> E[Update: w = w - lr*dw, b = b - lr*db]
E --> F{Converged?}
F -->|No| B
F -->|Yes| G[Model trained]
결정 경계(Decision Boundary)
2차원 입력, 즉 특성(feature)이 두 개이면 결정 경계는 다음 직선입니다.
w1*x1 + w2*x2 + b = 0
한쪽 점은 클래스 1로, 다른 쪽 점은 클래스 0으로 분류(classify)됩니다. 로지스틱 회귀는 항상 선형 결정 경계(linear decision boundary)를 만듭니다. 곡선 경계가 필요하면 다항 특성(polynomial feature)을 추가하거나 비선형 모델(nonlinear model)을 사용합니다.
소프트맥스(Softmax)를 사용한 다중 클래스 분류
이진 로지스틱 회귀는 클래스 두 개를 다룹니다. k개 클래스에는 소프트맥스 함수(softmax function)를 사용합니다.
softmax(z_i) = e^(z_i) / sum(e^(z_j) for all j)
각 클래스는 자신만의 가중치 벡터(weight vector)를 가집니다. 모델은 각 클래스의 점수 z_i를 계산하고, 소프트맥스가 점수를 합이 1인 확률로 바꿉니다. 예측된 클래스(predicted class)는 확률이 가장 높은 클래스입니다.
손실 함수는 범주형 교차 엔트로피(categorical cross-entropy)가 됩니다.
Loss = -(1/n) * sum(sum(y_k * log(p_k)))
여기서 y_k는 실제 클래스(true class)에서는 1이고 나머지는 0인 원-핫 인코딩(one-hot encoding)입니다.
평가 지표(Evaluation Metrics)
정확도(accuracy)만으로는 충분하지 않습니다. 음성(negative)이 95%, 양성(positive)이 5%인 데이터셋(dataset)에서 항상 음성만 예측하는 모델은 95% 정확도를 얻지만 쓸모가 없습니다.
혼동 행렬(Confusion Matrix)
| 예측: 양성 | 예측: 음성 |
|---|
| 실제: 양성 | 참 양성(True Positive; TP) | 거짓 음성(False Negative; FN) |
| 실제: 음성 | 거짓 양성(False Positive; FP) | 참 음성(True Negative; TN) |
정밀도(Precision): 양성이라고 예측한 것 중 실제 양성은 얼마나 되는가?
Precision = TP / (TP + FP)
재현율(Recall, 민감도(Sensitivity)): 실제 양성 중 얼마나 잡아냈는가?
Recall = TP / (TP + FN)
F1 점수(F1 Score): 정밀도와 재현율의 조화 평균(harmonic mean)입니다. 두 지표(metric)를 균형 있게 반영합니다.
F1 = 2 * (Precision * Recall) / (Precision + Recall)
우선순위:
- 정밀도(Precision): 거짓 양성(false positive)의 비용이 클 때 우선합니다. 스팸 필터(spam filter)에서 정상 이메일(legitimate email)을 막으면 안 됩니다.
- 재현율(Recall): 거짓 음성(false negative)의 비용이 클 때 우선합니다. 암 검진(cancer screening)에서 종양을 놓치면 안 됩니다.
- F1: 두 지표의 균형을 잡은 단일 지표가 필요할 때 사용합니다.
만들어 보기
Step 1: 시그모이드 함수와 데이터 생성
import random
import math
def sigmoid(z):
z = max(-500, min(500, z))
return 1.0 / (1.0 + math.exp(-z))
random.seed(42)
N = 200
X = []
y = []
for _ in range(N // 2):
X.append([random.gauss(2, 1), random.gauss(2, 1)])
y.append(0)
for _ in range(N // 2):
X.append([random.gauss(5, 1), random.gauss(5, 1)])
y.append(1)
combined = list(zip(X, y))
random.shuffle(combined)
X, y = zip(*combined)
X = list(X)
y = list(y)
print(f"{N}개 샘플을 생성했습니다(클래스 2개, 특성 2개)")
print("클래스 0 중심: (2, 2), 클래스 1 중심: (5, 5)")
print("처음 5개 샘플:")
for i in range(5):
print(f" 특성: [{X[i][0]:.2f}, {X[i][1]:.2f}], 레이블: {y[i]}")
두 클래스와 두 특성을 가진 합성 분류 데이터(synthetic classification data)를 만듭니다. 클래스 0은 (2, 2) 근처, 클래스 1은 (5, 5) 근처에 모입니다.
Step 2: 밑바닥부터 만드는 로지스틱 회귀
class LogisticRegression:
def __init__(self, n_features, learning_rate=0.01):
self.weights = [0.0] * n_features
self.bias = 0.0
self.lr = learning_rate
self.loss_history = []
def predict_proba(self, x):
z = sum(w * xi for w, xi in zip(self.weights, x)) + self.bias
return sigmoid(z)
def predict(self, x, threshold=0.5):
return 1 if self.predict_proba(x) >= threshold else 0
def compute_loss(self, X, y):
n = len(y)
total = 0.0
for i in range(n):
p = self.predict_proba(X[i])
p = max(1e-15, min(1 - 1e-15, p))
total += y[i] * math.log(p) + (1 - y[i]) * math.log(1 - p)
return -total / n
def fit(self, X, y, epochs=1000, print_every=200):
n = len(y)
n_features = len(X[0])
for epoch in range(epochs):
dw = [0.0] * n_features
db = 0.0
for i in range(n):
p = self.predict_proba(X[i])
error = p - y[i]
for j in range(n_features):
dw[j] += error * X[i][j]
db += error
for j in range(n_features):
self.weights[j] -= self.lr * (dw[j] / n)
self.bias -= self.lr * (db / n)
loss = self.compute_loss(X, y)
self.loss_history.append(loss)
if epoch % print_every == 0:
print(f" Epoch {epoch:4d} | Loss: {loss:.4f} | w: [{self.weights[0]:.3f}, {self.weights[1]:.3f}] | b: {self.bias:.3f}")
return self
def accuracy(self, X, y):
correct = sum(1 for i in range(len(y)) if self.predict(X[i]) == y[i])
return correct / len(y)
split = int(0.8 * N)
X_train, X_test = X[:split], X[split:]
y_train, y_test = y[:split], y[split:]
print("\n=== 로지스틱 회귀 학습 ===")
model = LogisticRegression(n_features=2, learning_rate=0.1)
model.fit(X_train, y_train, epochs=1000, print_every=200)
print(f"\n학습 정확도: {model.accuracy(X_train, y_train):.4f}")
print(f"테스트 정확도: {model.accuracy(X_test, y_test):.4f}")
print(f"가중치: [{model.weights[0]:.4f}, {model.weights[1]:.4f}]")
print(f"편향(bias): {model.bias:.4f}")
LogisticRegression 클래스는 predict_proba, predict, compute_loss, fit, accuracy를 구현합니다. fit에서는 모든 샘플에 대해 확률을 계산하고, (p - y) 오차로 가중치(weight)와 편향(bias)의 기울기를 누적합니다.
Step 3: 밑바닥부터 만드는 혼동 행렬과 평가 지표
class ClassificationMetrics:
def __init__(self, y_true, y_pred):
self.tp = sum(1 for t, p in zip(y_true, y_pred) if t == 1 and p == 1)
self.tn = sum(1 for t, p in zip(y_true, y_pred) if t == 0 and p == 0)
self.fp = sum(1 for t, p in zip(y_true, y_pred) if t == 0 and p == 1)
self.fn = sum(1 for t, p in zip(y_true, y_pred) if t == 1 and p == 0)
def accuracy(self):
total = self.tp + self.tn + self.fp + self.fn
return (self.tp + self.tn) / total if total > 0 else 0
def precision(self):
denom = self.tp + self.fp
return self.tp / denom if denom > 0 else 0
def recall(self):
denom = self.tp + self.fn
return self.tp / denom if denom > 0 else 0
def f1(self):
p = self.precision()
r = self.recall()
return 2 * p * r / (p + r) if (p + r) > 0 else 0
def print_confusion_matrix(self):
print("\n Confusion Matrix:")
print(" Predicted")
print(" Pos Neg")
print(f" Actual Pos {self.tp:4d} {self.fn:4d}")
print(f" Actual Neg {self.fp:4d} {self.tn:4d}")
def print_report(self):
self.print_confusion_matrix()
print(f"\n Accuracy: {self.accuracy():.4f}")
print(f" Precision: {self.precision():.4f}")
print(f" Recall: {self.recall():.4f}")
print(f" F1 Score: {self.f1():.4f}")
y_pred_test = [model.predict(x) for x in X_test]
print("\n=== 분류 리포트 (테스트셋) ===")
metrics = ClassificationMetrics(y_test, y_pred_test)
metrics.print_report()
ClassificationMetrics 클래스는 TP, TN, FP, FN을 세고 정확도, 정밀도, 재현율, F1 점수를 계산합니다.
Step 4: 결정 경계 분석(Decision Boundary Analysis)
print("\n=== 결정 경계(Decision Boundary) ===")
w1, w2 = model.weights
b = model.bias
print(f"결정 경계: {w1:.4f}*x1 + {w2:.4f}*x2 + {b:.4f} = 0")
if abs(w2) > 1e-10:
print(f"x2에 대해 정리: x2 = {-w1/w2:.4f}*x1 + {-b/w2:.4f}")
print("\n경계 근처 샘플 예측:")
test_points = [
[3.0, 3.0],
[3.5, 3.5],
[4.0, 4.0],
[2.5, 2.5],
[5.0, 5.0],
]
for point in test_points:
prob = model.predict_proba(point)
pred = model.predict(point)
print(f" [{point[0]}, {point[1]}] -> 확률={prob:.4f}, 클래스={pred}")
경계 근처 샘플의 확률과 클래스를 확인하면 임계값(threshold)이 어떤 결정을 만드는지 볼 수 있습니다.
Step 5: 소프트맥스를 사용한 다중 클래스 분류
class SoftmaxRegression:
def __init__(self, n_features, n_classes, learning_rate=0.01):
self.n_features = n_features
self.n_classes = n_classes
self.lr = learning_rate
self.weights = [[0.0] * n_features for _ in range(n_classes)]
self.biases = [0.0] * n_classes
def softmax(self, scores):
max_score = max(scores)
exp_scores = [math.exp(s - max_score) for s in scores]
total = sum(exp_scores)
return [e / total for e in exp_scores]
def predict_proba(self, x):
scores = [
sum(self.weights[k][j] * x[j] for j in range(self.n_features)) + self.biases[k]
for k in range(self.n_classes)
]
return self.softmax(scores)
def predict(self, x):
probs = self.predict_proba(x)
return probs.index(max(probs))
def fit(self, X, y, epochs=1000, print_every=200):
n = len(y)
for epoch in range(epochs):
grad_w = [[0.0] * self.n_features for _ in range(self.n_classes)]
grad_b = [0.0] * self.n_classes
total_loss = 0.0
for i in range(n):
probs = self.predict_proba(X[i])
for k in range(self.n_classes):
target = 1.0 if y[i] == k else 0.0
error = probs[k] - target
for j in range(self.n_features):
grad_w[k][j] += error * X[i][j]
grad_b[k] += error
true_prob = max(probs[y[i]], 1e-15)
total_loss -= math.log(true_prob)
for k in range(self.n_classes):
for j in range(self.n_features):
self.weights[k][j] -= self.lr * (grad_w[k][j] / n)
self.biases[k] -= self.lr * (grad_b[k] / n)
if epoch % print_every == 0:
print(f" Epoch {epoch:4d} | Loss: {total_loss / n:.4f}")
return self
def accuracy(self, X, y):
correct = sum(1 for i in range(len(y)) if self.predict(X[i]) == y[i])
return correct / len(y)
random.seed(42)
X_3class = []
y_3class = []
centers = [(1, 1), (5, 1), (3, 5)]
for label, (cx, cy) in enumerate(centers):
for _ in range(50):
X_3class.append([random.gauss(cx, 0.8), random.gauss(cy, 0.8)])
y_3class.append(label)
combined = list(zip(X_3class, y_3class))
random.shuffle(combined)
X_3class, y_3class = zip(*combined)
X_3class = list(X_3class)
y_3class = list(y_3class)
split_3 = int(0.8 * len(X_3class))
X_train_3 = X_3class[:split_3]
y_train_3 = y_3class[:split_3]
X_test_3 = X_3class[split_3:]
y_test_3 = y_3class[split_3:]
print("\n=== 다중 클래스 소프트맥스 회귀 (클래스 3개) ===")
softmax_model = SoftmaxRegression(n_features=2, n_classes=3, learning_rate=0.1)
softmax_model.fit(X_train_3, y_train_3, epochs=1000, print_every=200)
print(f"\n학습 정확도: {softmax_model.accuracy(X_train_3, y_train_3):.4f}")
print(f"테스트 정확도: {softmax_model.accuracy(X_test_3, y_test_3):.4f}")
print("\n샘플 예측:")
for i in range(5):
probs = softmax_model.predict_proba(X_test_3[i])
pred = softmax_model.predict(X_test_3[i])
print(f" 실제: {y_test_3[i]}, 예측: {pred}, 확률: [{', '.join(f'{p:.3f}' for p in probs)}]")
SoftmaxRegression은 클래스마다 가중치 벡터(weight vector)를 갖고, 원시 점수(raw score)를 소프트맥스 확률로 변환합니다. 실제 클래스의 확률에 범주형 교차 엔트로피(categorical cross-entropy)를 적용해 학습합니다.
Step 6: 임계값 조정(Threshold Tuning)
print("\n=== 임계값 조정(Threshold Tuning) ===")
print("기본 임계값은 0.5입니다. 임계값을 조정하면 정밀도와 재현율 사이에서 절충이 일어납니다.\n")
thresholds = [0.3, 0.4, 0.5, 0.6, 0.7]
print(f"{'임계값':>10} {'정확도':>10} {'정밀도':>10} {'재현율':>10} {'F1':>10}")
print("-" * 52)
for t in thresholds:
y_pred_t = [1 if model.predict_proba(x) >= t else 0 for x in X_test]
m = ClassificationMetrics(y_test, y_pred_t)
print(f"{t:>10.1f} {m.accuracy():>10.4f} {m.precision():>10.4f} {m.recall():>10.4f} {m.f1():>10.4f}")
스팸 필터처럼 거짓 양성(false positive)을 줄이고 싶으면 임계값을 높입니다. 암 검진처럼 거짓 음성(false negative)을 줄이고 싶으면 임계값을 낮춥니다.
사용하기
사이킷런(scikit-learn)으로는 같은 일을 더 안정적으로 수행할 수 있습니다.
from sklearn.linear_model import LogisticRegression as SklearnLR
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score
from sklearn.metrics import confusion_matrix, classification_report
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
import numpy as np
np.random.seed(42)
X_0 = np.random.randn(100, 2) + [2, 2]
X_1 = np.random.randn(100, 2) + [5, 5]
X_sk = np.vstack([X_0, X_1])
y_sk = np.array([0] * 100 + [1] * 100)
X_tr, X_te, y_tr, y_te = train_test_split(X_sk, y_sk, test_size=0.2, random_state=42)
scaler = StandardScaler()
X_tr_sc = scaler.fit_transform(X_tr)
X_te_sc = scaler.transform(X_te)
lr = SklearnLR()
lr.fit(X_tr_sc, y_tr)
y_pred = lr.predict(X_te_sc)
print("=== 사이킷런(Scikit-learn) 로지스틱 회귀 ===")
print(f"정확도: {accuracy_score(y_te, y_pred):.4f}")
print(f"정밀도: {precision_score(y_te, y_pred):.4f}")
print(f"재현율: {recall_score(y_te, y_pred):.4f}")
print(f"F1: {f1_score(y_te, y_pred):.4f}")
print(f"\n혼동 행렬:\n{confusion_matrix(y_te, y_pred)}")
print(f"\n분류 리포트:\n{classification_report(y_te, y_pred)}")
밑바닥부터(from-scratch) 구현한 결과는 같은 결정 경계와 평가 지표를 만듭니다. 사이킷런은 솔버 옵션(liblinear, lbfgs, saga), 자동 정규화(automatic regularization), 다중 클래스 전략(one-vs-rest, multinomial), 수치적 안정성 최적화(numerical stability optimization)를 제공합니다.
산출물 만들기
이 lesson의 최종 산출물은 code/logistic_regression.py와 outputs/skill-classification-baseline.md입니다. 로지스틱 회귀를 분류 기준선(classification baseline)으로 세우고, 평가 지표와 임계값을 통해 모델이 실제로 쓸 만한지 판단합니다.
연습문제
- (쉬움) 선형 분리 가능하지 않은(linearly separable) 데이터셋, 예를 들어 동심원(concentric circle) 두 개를 생성합니다. 로지스틱 회귀를 학습시키고 실패를 관찰합니다. 이어서 다항 특성(
x1^2, x2^2, x1*x2)을 추가해 다시 학습합니다. 정확도가 개선되는지 보입니다.
- (중간) 3-클래스 소프트맥스 모델을 위한 다중 클래스 혼동 행렬을 구현합니다. 클래스별(per-class) 정밀도와 재현율을 계산합니다. 어떤 클래스가 가장 분류하기 어려운가요?
- (어려움) ROC 곡선(ROC curve)을 밑바닥부터 만듭니다. 임계값 0부터 1까지 100개에 대해 참 양성 비율(true positive rate)과 거짓 양성 비율(false positive rate)을 계산합니다. 사다리꼴 공식(trapezoidal rule)으로 AUC(area under the curve)를 계산합니다.
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| 로지스틱 회귀(Logistic regression) | "분류를 위한 회귀" | 선형 모델 뒤에 시그모이드 함수를 붙여 클래스 확률을 출력하는 모델입니다. |
| 시그모이드 함수(Sigmoid function) | "S자 곡선" | 어떤 실수든 (0, 1) 범위로 사상(mapping)하는 1/(1+e^(-z)) 함수입니다. |
| 이진 교차 엔트로피(Binary cross-entropy) | "로그 손실(Log loss)" | 자신감 있게 틀린 예측(confident wrong prediction)을 강하게 벌점(penalize)하는 -[y*log(p) + (1-y)*log(1-p)] 손실입니다. |
| 결정 경계(Decision boundary) | "구분선" | 모델 출력 확률이 0.5가 되는 표면입니다. 예측된 클래스를 나눕니다. |
| 소프트맥스(Softmax) | "다중 클래스용 시그모이드" | 점수 벡터를 합이 1인 확률로 변환하는 함수입니다. |
| 정밀도(Precision) | "선택된 것 중 적합한 것의 비율" | TP / (TP + FP)입니다. 양성 예측 중 실제 양성의 비율(fraction)입니다. |
| 재현율(Recall) | "적합한 것 중 선택된 것의 비율" | TP / (TP + FN)입니다. 실제 양성 중 모델이 올바르게 식별한 비율입니다. |
| F1 점수(F1 score) | "균형 잡힌 정확도" | 정밀도와 재현율의 조화 평균입니다. 2*P*R / (P+R)입니다. |
| 혼동 행렬(Confusion matrix) | "오차 내역" | TP, TN, FP, FN의 개수를 보여주는 표입니다. |
| 임계값(Threshold) | "기준점" | 이 확률 이상이면 클래스 1로 예측하는 기준입니다. 기본값은 0.5이며 조정 가능합니다. |
| 원-핫 인코딩(One-hot encoding) | "범주별 이진 열(binary column)" | 클래스 k를 위치 k만 1이고 나머지는 0인 벡터로 표현합니다. |
| 범주형 교차 엔트로피(Categorical cross-entropy) | "다중 클래스용 로그 손실" | 원-핫 레이블을 사용해 k-클래스로 확장한 이진 교차 엔트로피입니다. |