Published on

NumPy로 로지스틱 회귀 밑바닥부터 구현하기

Authors

TensorTonic의 Logistic Regression 문제를 NumPy만 사용해서 경사하강법으로 풀면서 정리했다. 선형 회귀에서 이어지는 내용이지만 BCE 손실 함수와 시그모이드 미분이 조합되는 지점이 핵심이라 그 부분을 중심으로 다룬다.

로지스틱 회귀 모델

로지스틱 회귀는 입력 특성 xRd\mathbf{x} \in \mathbb{R}^d를 받아 이진 라벨 y{0,1}y \in \{0, 1\}을 예측한다. 선형 회귀처럼 연속값을 바로 뱉을 수는 없으니 선형 결합을 시그모이드에 통과시켜 (0,1)(0, 1) 범위의 확률로 변환한다.

z=wx+bz = \mathbf{w}^\top \mathbf{x} + b y^=σ(z)=11+ez\hat{y} = \sigma(z) = \frac{1}{1 + e^{-z}}

y^\hat{y}는 "이 샘플이 클래스 1에 속할 확률"로 해석한다. 예측 레이블은 y^0.5\hat{y} \geq 0.5면 1, 아니면 0으로 정한다.

BCE (Binary Cross-Entropy) 손실 함수

선형 회귀에서는 MSE를 썼지만 분류에서는 Binary Cross-Entropy를 쓴다.

L=1ni=1n[yilog(y^i)+(1yi)log(1y^i)]L = -\frac{1}{n} \sum_{i=1}^{n} \left[ y_i \log(\hat{y}_i) + (1 - y_i) \log(1 - \hat{y}_i) \right]

처음 보면 생김새가 당황스럽지만 yy가 0 또는 1이라는 점을 이용하면 둘 중 한 항만 살아남는 구조라는 게 보인다.

  • y=1y = 1일 때: (1y)=0(1-y) = 0이므로 뒷항이 사라지고 Li=log(y^i)L_i = -\log(\hat{y}_i)만 남는다.
  • y=0y = 0일 때: y=0y = 0이므로 앞항이 사라지고 Li=log(1y^i)L_i = -\log(1 - \hat{y}_i)만 남는다.

즉 BCE는 "정답 클래스에 모델이 부여한 확률에 log-\log를 씌운 값의 평균"이다. 정답에 높은 확률을 주면 보상(낮은 loss), 낮은 확률을 주면 벌(높은 loss).

log-\log인가

log-\log 함수는 확률에 대한 "상벌 함수"로 설계된 것처럼 딱 들어맞는 성질을 가지고 있다.

  • log(1)=0\log(1) = 0: 완벽한 예측(y^=1\hat{y} = 1)일 때 벌점 0.
  • log(0)=\log(0) = -\infty: 완전히 틀린 예측(y^0\hat{y} \to 0)일 때 벌점 무한대.
  • 0 근처에서 급격히 감소: "약간 틀림"과 "자신만만하게 틀림"을 다르게 처벌.

정답이 y=1y = 1인 샘플 기준으로 예측 확률별 loss를 계산해 보면 이 비대칭성이 뚜렷하다.

예측 y^\hat{y}log(y^)-\log(\hat{y})해석
0.990.01거의 맞춤
0.50.69찍은 수준
0.12.30꽤 틀림
0.014.61심하게 틀림
0.0016.91확신하며 틀림

확률이 10배 낮아질 때마다 loss가 일정량씩 쌓인다. "반반 확신에서 10% 확신으로 가는 것"보다 "10% 확신에서 1% 확신으로 가는 것"이 더 큰 실수라는 직관을 수치로 반영한다.

또한 로그는 곱을 합으로 바꾸는 성질이 있다.

log(y^1y^2y^n)=log(y^1)+log(y^2)++log(y^n)\log(\hat{y}_1 \cdot \hat{y}_2 \cdots \hat{y}_n) = \log(\hat{y}_1) + \log(\hat{y}_2) + \cdots + \log(\hat{y}_n)

확률론적으로 보면 모든 샘플을 맞출 확률을 최대화하는 것이 목표인데 독립 샘플이면 이것은 확률들의 곱이다. 샘플이 많으면 이 곱은 극도로 작아져서 부동소수점 언더플로우가 발생한다. 로그를 씌워 합으로 바꾸면 수치적으로 안정적으로 최적화할 수 있다.

BCE의 밑은 관례적으로 자연로그 ln\ln이다. 밑을 바꿔도 loss는 상수배만 되고 learning rate가 이를 흡수하므로 최적화 결과는 동일하지만 미분이 깔끔한 ln\ln이 표준이다. NumPy의 np.log도 기본이 자연로그다.

기울기 유도

경사하강법을 적용하려면 LLw\mathbf{w}bb에 대해 미분해야 한다. 그런데 BCE 식만 보면 w,b\mathbf{w}, b가 보이지 않는다. y^\hat{y} 속에 숨어 있기 때문이다.

y^=σ(wx+b)\hat{y} = \sigma(\mathbf{w}^\top \mathbf{x} + b)

LLw,b\mathbf{w}, b합성함수이고 체인룰로 미분한다.

Lw=Ly^y^zzw\frac{\partial L}{\partial \mathbf{w}} = \frac{\partial L}{\partial \hat{y}} \cdot \frac{\partial \hat{y}}{\partial z} \cdot \frac{\partial z}{\partial \mathbf{w}}

샘플 한 개 기준으로 세 조각을 차례로 구한다.

1단계: L/y^\partial L / \partial \hat{y}

L=ylog(y^)(1y)log(1y^)L = -y \log(\hat{y}) - (1-y) \log(1 - \hat{y})y^\hat{y}로 미분한다.

Ly^=yy^+1y1y^\frac{\partial L}{\partial \hat{y}} = -\frac{y}{\hat{y}} + \frac{1-y}{1-\hat{y}}

공통분모 y^(1y^)\hat{y}(1 - \hat{y})로 통일하고 분자를 전개하면

Ly^=y(1y^)+(1y)y^y^(1y^)=y^yy^(1y^)\frac{\partial L}{\partial \hat{y}} = \frac{-y(1-\hat{y}) + (1-y)\hat{y}}{\hat{y}(1-\hat{y})} = \frac{\hat{y} - y}{\hat{y}(1-\hat{y})}

분모에 y^(1y^)\hat{y}(1-\hat{y})가 생긴 것이 포인트다. 다음 단계와 소거된다.

2단계: y^/z\partial \hat{y} / \partial z (시그모이드 미분)

시그모이드의 도함수는 자기 자신으로 표현되는 유명한 형태다.

y^z=σ(z)(1σ(z))=y^(1y^)\frac{\partial \hat{y}}{\partial z} = \sigma(z)(1 - \sigma(z)) = \hat{y}(1 - \hat{y})

3단계: z/w\partial z / \partial \mathbf{w}

z=wx+bz = \mathbf{w}^\top \mathbf{x} + b 이므로 w\mathbf{w}의 계수인 x\mathbf{x}만 남는다.

zw=x\frac{\partial z}{\partial \mathbf{w}} = \mathbf{x}

조각 합치기: 기적의 소거

세 조각을 곱하면

Lw=y^yy^(1y^)y^(1y^)x=(y^y)x\frac{\partial L}{\partial \mathbf{w}} = \frac{\hat{y} - y}{\hat{y}(1-\hat{y})} \cdot \hat{y}(1-\hat{y}) \cdot \mathbf{x} = (\hat{y} - y) \mathbf{x}

1단계에서 생긴 분모 y^(1y^)\hat{y}(1-\hat{y})와 2단계의 y^(1y^)\hat{y}(1-\hat{y})가 정확히 상쇄된다. BCE와 시그모이드를 짝지어 쓰는 실용적인 이유가 바로 이것이다. MSE에 시그모이드를 붙이면 이 소거가 일어나지 않아 학습 초기 기울기가 매우 작아진다.

bb도 같은 방식으로 유도하면 z/b=1\partial z / \partial b = 1이므로

Lb=y^y\frac{\partial L}{\partial b} = \hat{y} - y

nn개 샘플에 대해 평균 내고 벡터화하면 문제에서 주어진 공식이 된다.

Lw=1nX(y^y)\frac{\partial L}{\partial \mathbf{w}} = \frac{1}{n} X^\top (\hat{\mathbf{y}} - \mathbf{y}) Lb=1ni=1n(y^iyi)\frac{\partial L}{\partial b} = \frac{1}{n} \sum_{i=1}^{n} (\hat{y}_i - y_i)

선형 회귀 때와 달리 계수 22가 없는 이유는 BCE 정의에서 제곱이 없기 때문이다. 손실 함수가 바뀌었을 뿐 "오차 y^y\hat{y} - y에 입력을 곱해 기울기를 얻는다"는 구조는 동일하다.

NumPy 구현

import numpy as np

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

def logistic_regression(X, y, lr=0.01, n_iters=1000):
    """
    Returns:
        tuple: (weights, bias) where weights is a list and bias is a float
    """
    X = np.asarray(X, dtype=float)
    y = np.asarray(y, dtype=float)

    n_samples, n_features = X.shape

    w = np.zeros(n_features)
    b = 0.0

    for _ in range(n_iters):
        z = X @ w + b
        y_hat = sigmoid(z)
        error = y_hat - y

        dw = X.T @ error / n_samples
        db = np.sum(error) / n_samples

        w -= lr * dw
        b -= lr * db

    return w.tolist(), float(b)

선형 회귀 구현과 거의 동일한 뼈대 위에 시그모이드 한 줄만 추가된 구조다. 기울기 공식에서 계수 2/n2/n1/n1/n로 바뀐 것을 제외하면 업데이트 로직도 같다.

반환 타입 맞추기

return w.tolist(), float(b)

문제의 docstring은 weights is a list and bias is a float을 요구한다. wnumpy.ndarray이고 bb -= lr * db 과정에서 numpy.float64로 바뀌므로 파이썬 기본 타입으로 명시적으로 변환해야 한다. 채점기가 isinstance(b, float) 같은 검사를 하면 numpy.float64는 통과하지 못할 수 있다.

시그모이드 오버플로우

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

zz가 매우 큰 음수이면 np.exp(-z)가 오버플로우해서 RuntimeWarning이 뜬다. 결과 자체는 여전히 1/=01 / \infty = 0으로 수렴하므로 수학적으로는 맞지만 경고를 없애고 싶다면 zz의 부호에 따라 분기한다.

def sigmoid(z):
    return np.where(
        z >= 0,
        1 / (1 + np.exp(-z)),
        np.exp(z) / (1 + np.exp(z)),
    )

z0z \geq 0이면 z0-z \leq 0이므로 ez1e^{-z} \leq 1로 안전하고 z<0z < 0이면 ez<1e^z < 1로 안전하다. 두 식은 분자/분모에 eze^z를 곱한 관계라 수학적으로 동일하다.

학습률과 반복 횟수

기본값 lr=0.01, n_iters=1000은 문제에서 요구한 값이다. 실제로는 데이터 스케일에 따라 튜닝이 필요하다. 입력 특성의 스케일이 크면 z가 쉽게 포화 영역(시그모이드의 양 끝)으로 가서 기울기가 소실되므로 학습 전 표준화(standardization)를 하는 것이 일반적이다.

예측과 임계값

위 구현은 학습만 담당한다. 실제로 새로운 입력을 0 또는 1로 분류하려면 학습된 w,b\mathbf{w}, b로 확률을 계산한 뒤 임계값을 적용해야 한다.

def predict(X, w, b, threshold=0.5):
    y_hat = sigmoid(X @ w + b)             # 확률 (0~1)
    return (y_hat >= threshold).astype(int) # 0 또는 1

학습 중에는 y^\hat{y}실수 확률 그대로 사용했다는 점을 짚을 필요가 있다. 손실과 기울기 계산은 y^\hat{y}가 연속값이라는 전제 위에서 돌아간다.

  • y^\hat{y}를 0/1로 먼저 바꾸면 미분이 불가능해진다. 계단 함수는 불연속이라 기울기가 없다.
  • BCE 공식 자체가 log(y^)\log(\hat{y})를 요구하는데 y^\hat{y}가 0이면 log(0)=\log(0) = -\infty로 발산한다.
  • 실수 확률을 써야 "얼마나 확신했는지"가 기울기 크기에 반영된다. 0.51로 간신히 맞춘 것과 0.99로 확실히 맞춘 것이 학습에 다르게 기여해야 한다.

0/1로의 변환은 학습이 끝난 뒤 분류 결과를 꺼내는 단계에서만 일어난다.

왜 기본 임계값이 0.5인가

시그모이드의 중간점이 0.5이기 때문이다.

σ(0)=0.5\sigma(0) = 0.5

z=wx+b>0z = \mathbf{w}^\top \mathbf{x} + b > 0이면 y^>0.5\hat{y} > 0.5가 되어 클래스 1로 분류되고 그 반대면 클래스 0으로 분류된다. 결정 경계(decision boundary)는 정확히

wx+b=0\mathbf{w}^\top \mathbf{x} + b = 0

이라는 초평면이다. 0.5는 이 초평면을 기준으로 자연스럽게 양쪽을 나누는 값이다.

임계값은 예측 시 사용자가 정하는 값

중요한 포인트는 임계값이 모델의 일부가 아니라는 것이다. 학습된 w,b\mathbf{w}, b는 "확률을 뱉는 함수"까지만 정의하고 그 확률을 어느 선에서 자를지는 별개의 결정이다.

y_hat = sigmoid(X_test @ w + b)
# 같은 모델로 다양한 임계값 정책을 적용할 수 있다

y_pred_strict = (y_hat >= 0.9).astype(int)   # 확실할 때만 1
y_pred_default = (y_hat >= 0.5).astype(int)  # 기본
y_pred_loose = (y_hat >= 0.3).astype(int)    # 조금만 의심돼도 1

재학습 없이 임계값만 바꾸면 precision과 recall이 달라진다. ROC 곡선이나 PR 곡선을 그리는 것도 결국 임계값을 0에서 1까지 쓸어가며 성능을 측정하는 작업이다.

상황에 따른 선택은 대체로 이런 식이다.

  • 암 진단: 놓치면 치명적이므로 임계값을 낮춰서 "조금만 의심돼도 양성"으로 판단 (재현율 우선).
  • 스팸 필터: 정상 메일을 스팸 처리하면 불편하므로 임계값을 높여서 "확실할 때만 스팸"으로 판단 (정밀도 우선).

따라서 0.5는 "시그모이드의 중간점"이라는 수학적 편의에서 온 기본값일 뿐 언제나 최적이라는 뜻은 아니다. 실제 운영에서는 검증 데이터에서 F1 score, 비용 함수 등을 기준으로 임계값을 따로 튜닝한다.

선형 회귀와 나란히 두고 비교

두 모델을 나란히 놓으면 구조가 거의 같다는 것이 선명하게 보인다.

선형 회귀로지스틱 회귀
출력y^=wx+b\hat{y} = \mathbf{w}^\top \mathbf{x} + by^=σ(wx+b)\hat{y} = \sigma(\mathbf{w}^\top \mathbf{x} + b)
정답 타입실수0 또는 1
손실 함수MSEBCE
dwdw 공식2nX(y^y)\frac{2}{n} X^\top (\hat{\mathbf{y}} - \mathbf{y})1nX(y^y)\frac{1}{n} X^\top (\hat{\mathbf{y}} - \mathbf{y})
dbdb 공식2n(y^iyi)\frac{2}{n} \sum (\hat{y}_i - y_i)1n(y^iyi)\frac{1}{n} \sum (\hat{y}_i - y_i)

손실 함수와 활성화 함수가 모두 바뀌었는데 기울기 공식이 거의 똑같다는 것이 이 조합을 짝지어 설계한 결과다. 구현 관점에서는 시그모이드 호출 한 줄만 추가하면 분류 모델이 된다.

마무리

이번 구현에서 가장 와닿은 점은 y^\hat{y} 속에 w,b\mathbf{w}, b가 숨어 있다는 것이다. BCE 식만 보면 파라미터가 없어 보이지만 합성함수 구조를 따라가면 체인룰이 자연스럽게 적용된다. 그리고 BCE와 시그모이드의 도함수가 서로를 상쇄시키는 설계 덕분에 최종 기울기는 "오차 × 입력"이라는 단순한 형태로 떨어진다.

선형 회귀에서 "예측 → 오차 → 기울기 → 업데이트"라는 경사하강법의 뼈대를 배웠다면 로지스틱 회귀는 그 뼈대 위에 시그모이드와 BCE를 올려 분류로 확장하는 사례다. 여기서 은닉층을 쌓고 활성화를 교체하면 바로 신경망으로 이어진다.