ML02. 로지스틱 회귀 (Logistic Regression) 합격과 불합격의 문제

개요

지난 ML01 강좌에서는 공부 시간에 따른 시험 점수를 예측하는 선형 회귀(Linear Regression)를 알아봤습니다.

하지만 세상에는 "몇 점인가?"보다 "합격인가, 불합격인가?" 혹은 "스팸 메일인가, 아닌가?" 처럼

두 가지 상태 중 하나를 분류 (Classification)해야 하는 문제가 더 많습니다. 

오늘은 머신러닝 분류알고리즘의 기초이자 핵심인 로지스틱 회귀 (Logistic regression)에 대해 알아보겠습니다.


분류의 문제

아래 표처럼 시험공부를 1시간부터 9시간까지 진행한다고 가정할 때, 7시간정도 하면 합격이라는 가상의 데이터가 있습니다.

7시간쯤 공부하면 합격한다는 결정 경계선 (Decision boundary) 을 학습하고 싶다면 어떻게 해야 할까요?

공부 시간 (X) 결과 (y, 합격=1, 불합격=0)
1시간 0 (불합격)
3시간 0 (불합격)
5시간 0 (불합격)
7시간 1 (합격)
9시간 1 (합격)

미리 말씀드리면, 우리가 원하는 결과는 아래와 같습니다.

5시간은 불합격, 7시간 공부하면 합격이니 대략 6시간이 그 결정경계가 아닌가 생각됩니다.

[학습 후 6시간 공부의 예측결과]

당연하게도 위에서 예상한 합격, 불합격 여부를 학습하는 것은 우리 인간의 몫이 아닙니다.

기계에게 데이터만 제공하고 알아서 학습하도록 하고 싶은 것이죠.😊

근사하지 않나요? 

같은 원리로 스팸메일과 정상메일을 구분해 던져주면 기계가 학습을 통해 새로 수신하는 메일의 스팸여부를 높은 확률로 정확히 판단하는 것입니다.

로지스틱회귀의 과정은 지금부터 파이썬 코드로 진행해 보겠습니다.


1. 입출력 데이터 정의

지도 학습(Supervised Learning)이므로 몇시간 공부하면 합격, 불합격이 결정되는지는 미리 정의되어야 합니다.

import numpy as np

X = np.array([1, 3, 5, 7, 9])
y = np.array([0,0,0,1,1])

보통 X, y 를 합쳐서 Training dataset 이라고 합니다.


2. 하이퍼 파라미터 정의

이제 학습을 위한 가중치(W), 편향(b), 학습률(lr), 학습횟수(epochs), 정규화(_lambda) 등 파라미터를 초기화 합니다.

하이퍼파라미터(Hyperparameter)는 머신러닝, 딥러닝 모델 학습을 시작하기 전, 사용자가 직접 설정하는 외부 구성 변수입니다.

모델 내부에서 학습되는 '가중치, 편향 파라미터 '와 달리, 학습률(Learning rate), 배치 크기(Batch size), 에포크(Epochs) 등 모델의 구조와 성능(과적합 방지 등)을 제어하는 핵심적인 역할을 합니다.

W = 0
b = 0
lr = 0.01
epochs = 3000
_lambda = 0

잘 이해되지 않는다면 앞선 ML01. 선형회귀과정을 살펴보고 오는 것이 좋습니다.

왜냐하면 로지스틱회귀는 선형회귀와 비슷한 과정이며 선형변환 후 시그모이드함수를 거친다는 차이, 비용함수에 차후 언급할 로그함수를 사용하는 것만 제외하면 동일하기 때문입니다.


3. 기계학습

학습을 진행하기 위해 logistic_func.py 파일에서 gradient_descent, sigmoid 함수를 불러옵니다. (아래 소개)

학습은 gradient_descent() 함수를 호출함으로서 시작됩니다.

from logistic_func import gradient_descent, sigmoid
W_final, b_final = gradient_descent(X, y, W, b, lr, epochs, _lambda)


4. logistic_func.py 

이 파일은 가독성, 유지보수, 코드 재활용 등을 위해  로지스틱 회귀관련 함수로만 구성되어 있습니다.

import numpy as np

def sigmoid(Z):
    return 1/(1+np.exp(-Z))

def compute_cost(X, y, W, b, _lambda=0):
    m = X.shape[0]
    cost = 0

    for i in range(m):
        z = X[i]*W + b
        y_hat = sigmoid(z)
        cost += -y[i]*np.log(y_hat)-(1-y[i])*np.log(1-y_hat)

    L2 = _lambda/(2*m)*(W**2)
    cost = cost / m + L2
    return cost

def compute_gradient(X, y, W, b, _lambda=0):
    m = X.shape[0]

    dj_dw = 0
    dj_db = 0

    for i in range(m):
        z = X[i]*W+b
        y_hat = sigmoid(z)        

        dj_dw += (y_hat-y[i])*X[i]
        dj_db += (y_hat-y[i])

    L2 = _lambda/m*W
    dj_dw = dj_dw / m + L2
    dj_db = dj_db / m

    return dj_dw, dj_db

def gradient_descent(X, y, W, b, lr, epochs, _lambda=0):
    for i in range(epochs):
        cost = compute_cost(X, y, W, b, _lambda)

        dj_dw, dj_db = compute_gradient(X, y, W, b, _lambda)

        W = W - lr * dj_dw
        b = b - lr * dj_db

        if i%100==0:
            print(f'epoch={i}, W={W:.3f}, b={b:.3f}, cost={cost:.3f}')

    return W, b

파일에 작성된 순서가 아니라 학습이 진행되는 과정에서 호출되는 함수순서 기준으로 설명할께요.

  • gradient_descent() Func
    • 학습과정 중 비용감소 표시를 위해 현재 Cost 얻기.
    • 선형결합을 진행하고 결과를 시그모이드 입력으로 넣기 (순전파)
    • y_hat(예측치)를 y(실제값)과 비교한 Loss 를 미분 (역전파)
    • W, b를 업데이트
    • 위 과정을 반복해 Loss를 최소화하는 W, b 찾기
    • 람다항이 있다면 L2 정규화
  • compute_cost() Func
    • BCE(Binary Cross-Entropy) 함수로 Loss 계산
    • Loss를 합쳐 전체 Cost 비용 계산
    • 람다항이 있다면 L2 정규화
  • sigmoid Func()
    • 선형결합의 결과를 0~1 사이 출력으로 변환
  • compute_gradient() Func
    • 순전파, 역전파를 진행


위 epochs 만큼의 순전파, 역전파 과정이 끝나면 gradient_descent() 의 W, b는 경사하강법에 의해 Loss를 줄이는 방향으로 업데이트됩니다. 😀

즉, 비용을 최소화하는 가중치(W)와 편향(b)를 찾는 과정을 "학습", "훈련" 이라고 표현합니다.

아래 표에서 Cost함수를 미분함으로써 경사를 타고 내려가는 모습이고, W의 최소지점에 도달하면 학습이 완료된 것입니다.

[경사하강법이 W를 업데이트하는 과정 예시]

5. 예측

이제 학습이 끝나면 w, b는 주어진 입력에서 Loss를 최소화하는 값으로 업데이트 됩니다.

모든 머신러닝, 딥러닝의 목적은 바로 여기에 있습니다.

남은것은 X_test를 4, 6, 8시간 등으로 predict() 함수에 넣어보며 합격선에 대한 결정경계를 잘 학습했는지 확인하면 끝입니다.

이때 W, b는 반드시 위에서 학습W_fianl, b_final을 사용해야 합니다.

def predict(X, W, b):
    z = W*X+b
    return sigmoid(z)

# predict
X_test = 6
y_pred = predict(X_test, W_final, b_final)
result = 'Pass' if y_pred >=0.5 else 'Fail'
print(f'X_test={X_test}, y_pred={y_pred:.2f}, Result={result}')

학습이 진행됨에 따라 Cost가 감소하는 것을 확인할 수 있으며 이는 w, b가 업데이트되며 손실을 줄이는 과정입니다.

[epoch 진행 과정, 예측결과]

마지막에 6시간 공부하면 합격~ 성공적으로 잘 학습되었음을 확인가능합니다.


6. 시각화

학습을 마치고 아래 코드를 추가하면 학습된 결과물에 대한 시각화가 가능합니다.

# 그래프 설정
import matplotlib.pyplot as plt
X_range = np.linspace(0, 10, 100)
y_range = predict(X_range, W_final, b_final)

plt.figure(figsize=(10, 6))
plt.scatter(X, y, color='red', s=100, label='Input', zorder=5)

# 모델이 예측한 시그모이드 곡선 (파란 선)
plt.plot(X_range, y_range, color='blue', linewidth=2, label='Logistic Curve')

# 결정 경계선(확률 0.5 지점)
plt.axhline(y=0.5, color='gray', linestyle='--', label='Threshold (0.5)')
plt.axvline(x=-b_final/W_final if W_final != 0 else 0, color='green', linestyle=':', label='Decision Boundary')

# 테스트 포인트 (5시간 지점 표시)
y_test = predict(X_test, W_final, b_final)
plt.scatter(X_test, y_test, color='orange', marker='x', s=150, linewidth=3, label=f'X={X_test} (Prob={y_test:.2f})', zorder=6)

# 그래프 범례
plt.title('Logistic Regression: Study Hour vs Pass,Fail', fontsize=15)
plt.xlabel('Study Hours (X)', fontsize=12)
plt.ylabel('Pro. Passing (y_hat)', fontsize=12)
plt.legend()
plt.grid(True, alpha=0.3)
plt.show()


그런데 왜 결정경계 (Decision boundary)의 $x$ 위치(14번 라인)가 아래와 같을까요?

저는 처음 로지스틱회귀를 공부할 때 시그모이드 함수의 위, 아래로 구분된다는 착각을 했었습니다.

[잘못된 결정경계 구분]

왜 그런지? 결정경계가 유도되는 과정을 설명해 보겠습니다.

1) 시그모이드 함수 정의

선형 회귀 결과인 $z = wx + b$를 입력으로 받는 시그모이드 함수 $\sigma(z)$는 다음과 같습니다.

$$\sigma(z) = \frac{1}{1 + e^{-z}}$$

여기서 $z$는 선형 결합식인 $wx + b$를 대입하면 다음과 같이 표현됩니다.

$$f(x) = \frac{1}{1 + e^{-(wx + b)}}$$


2) 결정경계의 기준

일반적으로 이진 분류(Binary Classification)에서 출력값 $f(x)$가 0.5가 되는 지점을 기준으로 클래스를 나눕니다.

즉, 결정경계는 다음 조건을 만족하는 지점입니다.

$$f(x) = 0.5$$

여기서 모든수의 0승은 1이므로 $z = wx + b$, 즉 $z$ 가 0이 되는 지점입니다.

$$\frac{1}{1 + e^{-(wx + b)}} = \frac{1}{1 + e^{0}} = \frac{1}{2}$$


3) 수식 전개 과정

이제 위 식을 $x$에 대해 정리해 보겠습니다. $z$ 대신 0


$$wx + b = 0$$
$$wx = -b$$
$$\therefore x = -\frac{b}{w}$$

결론적으로 시그모이드 함수가 0.5가 되는 $x$지점을 찾는 과정이었습니다.

그 지점은 모든수의 0승은 1이므로 $z$가 0 일때 입니다. Good 👏


7. 전체 코드

학습에 사용된 전체 코드이며, 위에서 설명한 logistic_func.py 가 필요합니다.

import numpy as np

X = np.array([1, 3, 5, 7, 9])
y = np.array([0,0,0,1,1])

W = 0
b = 0
lr = 0.01
epochs = 3000
_lambda = 0

from logistic_func import gradient_descent, sigmoid
W_final, b_final = gradient_descent(X, y, W, b, lr, epochs, _lambda)

def predict(X, W, b):
    z = W*X+b
    return sigmoid(z)

# predict
X_test = 6
y_pred = predict(X_test, W_final, b_final)
result = 'Pass' if y_pred >=0.5 else 'Fail'
print(f'X_test={X_test}, y_pred={y_pred:.2f}, Result={result}')

# 그래프 설정
import matplotlib.pyplot as plt
X_range = np.linspace(0, 10, 100)
y_range = predict(X_range, W_final, b_final)

plt.figure(figsize=(10, 6))
plt.scatter(X, y, color='red', s=100, label='Input', zorder=5)

# 모델이 예측한 시그모이드 곡선 (파란 선)
plt.plot(X_range, y_range, color='blue', linewidth=2, label='Logistic Curve')

# 결정 경계선(확률 0.5 지점)
plt.axhline(y=0.5, color='gray', linestyle='--', label='Threshold (0.5)')
plt.axvline(x=-b_final/W_final if W_final != 0 else 0, color='green', linestyle=':', label='Decision Boundary')

# 테스트 포인트 (5시간 지점 표시)
y_test = predict(X_test, W_final, b_final)
plt.scatter(X_test, y_test, color='orange', marker='x', s=150, linewidth=3, label=f'X={X_test} (Prob={y_test:.2f})', zorder=6)

# 그래프 범례
plt.title('Logistic Regression: Study Hour vs Pass,Fail', fontsize=15)
plt.xlabel('Study Hours (X)', fontsize=12)
plt.ylabel('Pro. Passing (y_hat)', fontsize=12)
plt.legend()
plt.grid(True, alpha=0.3)
plt.show()


보너스 : 수식 정리

로지스틱 회귀를 진행하며 작성된 코드를 수식으로 정리해두었습니다.

수학이 중요하지만 초반엔 직관(Intuition)이 생기는 것이 수식보다 더 중요합니다.


1. 가설함수 (Hypothesis Function)

로지스틱 회귀는 입력(x), 가중치(w)를 곱하고 편향(b)를 더한 선형 결합 결과인 $z$시그모이드(Sigmoid) 함수에 통과시켜 0과 1 사이의 확률값으로 변환합니다.

  • 선형결합

$$z = \mathbf{w}^T\mathbf{x} + b$$


  • 시그모이드 함수 (Sigmoid Function)

$$\sigma(z) = \frac{1}{1 + e^{-z}}$$


  • 최종 가설 (Model), 위 둘 합침

$$H(\mathbf{x}) = \sigma(\mathbf{w}^T\mathbf{x} + b) = \frac{1}{1 + e^{-(\mathbf{w}^T\mathbf{x} + b)}}$$



2. 손실 함수 (Loss Function)

로지스틱 회귀에서는 평균 제곱 오차(MSE) 대신 교차 엔트로피(Cross-Entropy) 손실 함수를 사용합니다.

이는 로그 함수를 이용해 정답과 예측값 사이의 오차를 극대화하여 학습 효율을 높이기 위함입니다.

y=1 (빨강), y=0 (녹색) 일 때 각각의 손실함수는 아래와 같습니다.

즉 y=1 인데 y_hat=1 (예측치) 이면 잘 예측했으니 벌주지 않고 y=1인데 y_hat=0 이면 로그로 큰벌을 주는 느낌입니다.

(desmos가 y_hat이 안먹혀 x로 표시, 실제는 y_hat)

[로지스틱 회귀 손실함수]


  • 손실함수, 바이너리 교차 엔트로피 (Binary Cross-Entropy)

$$L(y, \hat{y}) = -[y \log(\hat{y}) + (1 - y) \log(1 - \hat{y})]$$

(여기서 $y$는 실제 라벨, $\hat{y}$는 모델의 예측값 $H(\mathbf{x})$입니다.)


  • 비용함수, (Cost Funcstion)
$$\begin{aligned} J(\mathbf{w}, b) = -\frac{1}{m} \sum_{i=1}^{m} \big[ &y^{(i)} \log(\hat{y}^{(i)}) \\ & + (1 - y^{(i)}) \log(1 - \hat{y}^{(i)}) \big] \end{aligned}$$


아래는 제가 공부하며 정리해둔 필기입니다.

[Logistic Reg. 가설, 손실함수]

더 많은 소스코드 or 필기자료는 Git Link를 참조바랍니다.


3. 경사 하강법을 위한 미분 (Derivatives)

가중치($\mathbf{w}$)와 편향($b$)을 업데이트하기 위해 손실 함수 $J$를 각각 편미분합니다.

체인 룰(Chain Rule)을 적용하면 놀랍게도 선형 회귀와 매우 유사한 깔끔한 형태로 정리됩니다.

  • 가중치에 대한 편미분 ($\frac{\partial J}{\partial \mathbf{w}}$)

$$\frac{\partial J}{\partial \mathbf{w}} = \frac{1}{m} \sum_{i=1}^{m} (\hat{y}^{(i)} - y^{(i)})\mathbf{x}^{(i)}$$


  • 편향에 대한 편미분 ($\frac{\partial J}{\partial b}$)

$$\frac{\partial J}{\partial b} = \frac{1}{m} \sum_{i=1}^{m} (\hat{y}^{(i)} - y^{(i)})$$


비용함수 J에 대한 미분과정

[비용함수 J에 대한 미분]


4. 파라미터 업데이트 (Update Rule)

구해진 기울기에 학습률(Learning Rate, $\alpha$)을 곱해 파라미터를 반복적으로 업데이트합니다.

$$\mathbf{w} = \mathbf{w} - \alpha \frac{\partial J}{\partial \mathbf{w}}$$
$$b = b - \alpha \frac{\partial J}{\partial b}$$



이상으로 모든 설명을 마칩니다.

감사합니다.

댓글

이 블로그의 인기 게시물

Qt Designer 설치하기

파이썬을 활용한 PID 제어기 GUI 구현

PyQt5 기반 동영상 플레이어앱 만들기