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

- Name
- 이동영
- Github
- @Github
TensorTonic의 Logistic Regression 문제를 NumPy만 사용해서 경사하강법으로 풀면서 정리했다. 선형 회귀에서 이어지는 내용이지만 BCE 손실 함수와 시그모이드 미분이 조합되는 지점이 핵심이라 그 부분을 중심으로 다룬다.
로지스틱 회귀 모델
로지스틱 회귀는 입력 특성 를 받아 이진 라벨 을 예측한다. 선형 회귀처럼 연속값을 바로 뱉을 수는 없으니 선형 결합을 시그모이드에 통과시켜 범위의 확률로 변환한다.
는 "이 샘플이 클래스 1에 속할 확률"로 해석한다. 예측 레이블은 면 1, 아니면 0으로 정한다.
BCE (Binary Cross-Entropy) 손실 함수
선형 회귀에서는 MSE를 썼지만 분류에서는 Binary Cross-Entropy를 쓴다.
처음 보면 생김새가 당황스럽지만 가 0 또는 1이라는 점을 이용하면 둘 중 한 항만 살아남는 구조라는 게 보인다.
- 일 때: 이므로 뒷항이 사라지고 만 남는다.
- 일 때: 이므로 앞항이 사라지고 만 남는다.
즉 BCE는 "정답 클래스에 모델이 부여한 확률에 를 씌운 값의 평균"이다. 정답에 높은 확률을 주면 보상(낮은 loss), 낮은 확률을 주면 벌(높은 loss).
왜 인가
함수는 확률에 대한 "상벌 함수"로 설계된 것처럼 딱 들어맞는 성질을 가지고 있다.
- : 완벽한 예측()일 때 벌점 0.
- : 완전히 틀린 예측()일 때 벌점 무한대.
- 0 근처에서 급격히 감소: "약간 틀림"과 "자신만만하게 틀림"을 다르게 처벌.
정답이 인 샘플 기준으로 예측 확률별 loss를 계산해 보면 이 비대칭성이 뚜렷하다.
| 예측 | 해석 | |
|---|---|---|
| 0.99 | 0.01 | 거의 맞춤 |
| 0.5 | 0.69 | 찍은 수준 |
| 0.1 | 2.30 | 꽤 틀림 |
| 0.01 | 4.61 | 심하게 틀림 |
| 0.001 | 6.91 | 확신하며 틀림 |
확률이 10배 낮아질 때마다 loss가 일정량씩 쌓인다. "반반 확신에서 10% 확신으로 가는 것"보다 "10% 확신에서 1% 확신으로 가는 것"이 더 큰 실수라는 직관을 수치로 반영한다.
또한 로그는 곱을 합으로 바꾸는 성질이 있다.
확률론적으로 보면 모든 샘플을 맞출 확률을 최대화하는 것이 목표인데 독립 샘플이면 이것은 확률들의 곱이다. 샘플이 많으면 이 곱은 극도로 작아져서 부동소수점 언더플로우가 발생한다. 로그를 씌워 합으로 바꾸면 수치적으로 안정적으로 최적화할 수 있다.
BCE의 밑은 관례적으로 자연로그 이다. 밑을 바꿔도 loss는 상수배만 되고 learning rate가 이를 흡수하므로 최적화 결과는 동일하지만 미분이 깔끔한 이 표준이다. NumPy의 np.log도 기본이 자연로그다.
기울기 유도
경사하강법을 적용하려면 을 와 에 대해 미분해야 한다. 그런데 BCE 식만 보면 가 보이지 않는다. 속에 숨어 있기 때문이다.
즉 은 의 합성함수이고 체인룰로 미분한다.
샘플 한 개 기준으로 세 조각을 차례로 구한다.
1단계:
를 로 미분한다.
공통분모 로 통일하고 분자를 전개하면
분모에 가 생긴 것이 포인트다. 다음 단계와 소거된다.
2단계: (시그모이드 미분)
시그모이드의 도함수는 자기 자신으로 표현되는 유명한 형태다.
3단계:
이므로 의 계수인 만 남는다.
조각 합치기: 기적의 소거
세 조각을 곱하면
1단계에서 생긴 분모 와 2단계의 가 정확히 상쇄된다. BCE와 시그모이드를 짝지어 쓰는 실용적인 이유가 바로 이것이다. MSE에 시그모이드를 붙이면 이 소거가 일어나지 않아 학습 초기 기울기가 매우 작아진다.
도 같은 방식으로 유도하면 이므로
개 샘플에 대해 평균 내고 벡터화하면 문제에서 주어진 공식이 된다.
선형 회귀 때와 달리 계수 가 없는 이유는 BCE 정의에서 제곱이 없기 때문이다. 손실 함수가 바뀌었을 뿐 "오차 에 입력을 곱해 기울기를 얻는다"는 구조는 동일하다.
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)
선형 회귀 구현과 거의 동일한 뼈대 위에 시그모이드 한 줄만 추가된 구조다. 기울기 공식에서 계수 이 로 바뀐 것을 제외하면 업데이트 로직도 같다.
반환 타입 맞추기
return w.tolist(), float(b)
문제의 docstring은 weights is a list and bias is a float을 요구한다. w는 numpy.ndarray이고 b는 b -= lr * db 과정에서 numpy.float64로 바뀌므로 파이썬 기본 타입으로 명시적으로 변환해야 한다. 채점기가 isinstance(b, float) 같은 검사를 하면 numpy.float64는 통과하지 못할 수 있다.
시그모이드 오버플로우
def sigmoid(z):
return 1 / (1 + np.exp(-z))
가 매우 큰 음수이면 np.exp(-z)가 오버플로우해서 RuntimeWarning이 뜬다. 결과 자체는 여전히 으로 수렴하므로 수학적으로는 맞지만 경고를 없애고 싶다면 의 부호에 따라 분기한다.
def sigmoid(z):
return np.where(
z >= 0,
1 / (1 + np.exp(-z)),
np.exp(z) / (1 + np.exp(z)),
)
이면 이므로 로 안전하고 이면 로 안전하다. 두 식은 분자/분모에 를 곱한 관계라 수학적으로 동일하다.
학습률과 반복 횟수
기본값 lr=0.01, n_iters=1000은 문제에서 요구한 값이다. 실제로는 데이터 스케일에 따라 튜닝이 필요하다. 입력 특성의 스케일이 크면 z가 쉽게 포화 영역(시그모이드의 양 끝)으로 가서 기울기가 소실되므로 학습 전 표준화(standardization)를 하는 것이 일반적이다.
예측과 임계값
위 구현은 학습만 담당한다. 실제로 새로운 입력을 0 또는 1로 분류하려면 학습된 로 확률을 계산한 뒤 임계값을 적용해야 한다.
def predict(X, w, b, threshold=0.5):
y_hat = sigmoid(X @ w + b) # 확률 (0~1)
return (y_hat >= threshold).astype(int) # 0 또는 1
학습 중에는 를 실수 확률 그대로 사용했다는 점을 짚을 필요가 있다. 손실과 기울기 계산은 가 연속값이라는 전제 위에서 돌아간다.
- 를 0/1로 먼저 바꾸면 미분이 불가능해진다. 계단 함수는 불연속이라 기울기가 없다.
- BCE 공식 자체가 를 요구하는데 가 0이면 로 발산한다.
- 실수 확률을 써야 "얼마나 확신했는지"가 기울기 크기에 반영된다. 0.51로 간신히 맞춘 것과 0.99로 확실히 맞춘 것이 학습에 다르게 기여해야 한다.
0/1로의 변환은 학습이 끝난 뒤 분류 결과를 꺼내는 단계에서만 일어난다.
왜 기본 임계값이 0.5인가
시그모이드의 중간점이 0.5이기 때문이다.
즉 이면 가 되어 클래스 1로 분류되고 그 반대면 클래스 0으로 분류된다. 결정 경계(decision boundary)는 정확히
이라는 초평면이다. 0.5는 이 초평면을 기준으로 자연스럽게 양쪽을 나누는 값이다.
임계값은 예측 시 사용자가 정하는 값
중요한 포인트는 임계값이 모델의 일부가 아니라는 것이다. 학습된 는 "확률을 뱉는 함수"까지만 정의하고 그 확률을 어느 선에서 자를지는 별개의 결정이다.
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, 비용 함수 등을 기준으로 임계값을 따로 튜닝한다.
선형 회귀와 나란히 두고 비교
두 모델을 나란히 놓으면 구조가 거의 같다는 것이 선명하게 보인다.
| 선형 회귀 | 로지스틱 회귀 | |
|---|---|---|
| 출력 | ||
| 정답 타입 | 실수 | 0 또는 1 |
| 손실 함수 | MSE | BCE |
| 공식 | ||
| 공식 |
손실 함수와 활성화 함수가 모두 바뀌었는데 기울기 공식이 거의 똑같다는 것이 이 조합을 짝지어 설계한 결과다. 구현 관점에서는 시그모이드 호출 한 줄만 추가하면 분류 모델이 된다.
마무리
이번 구현에서 가장 와닿은 점은 속에 가 숨어 있다는 것이다. BCE 식만 보면 파라미터가 없어 보이지만 합성함수 구조를 따라가면 체인룰이 자연스럽게 적용된다. 그리고 BCE와 시그모이드의 도함수가 서로를 상쇄시키는 설계 덕분에 최종 기울기는 "오차 × 입력"이라는 단순한 형태로 떨어진다.
선형 회귀에서 "예측 → 오차 → 기울기 → 업데이트"라는 경사하강법의 뼈대를 배웠다면 로지스틱 회귀는 그 뼈대 위에 시그모이드와 BCE를 올려 분류로 확장하는 사례다. 여기서 은닉층을 쌓고 활성화를 교체하면 바로 신경망으로 이어진다.