- 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):
z = np.asarray(z, dtype=float)
exp_neg_abs = np.exp(-np.abs(z))
return np.where(
z >= 0,
1 / (1 + exp_neg_abs),
exp_neg_abs / (1 + exp_neg_abs),
)
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에 맞춰 배열은 리스트로, 편향은 Python float로 반환한다. 일부 NumPy 스칼라는 Python float의 인스턴스로 인식되기도 하지만, 명시 변환은 반환 타입과 직렬화 경계를 분명하게 한다.
시그모이드 오버플로우
def sigmoid_naive(z):
return 1 / (1 + np.exp(-z))
단순한 1 / (1 + np.exp(-z))는 큰 음수에서 exp가 오버플로우한다. np.where 안에 이 식과 np.exp(z)를 각각 넣어도 두 인자가 선택 전에 계산되므로 경고를 피하지 못한다. 위 구현은 두 구간에서 공통으로 쓸 수 있는 exp(-abs(z))를 먼저 계산한다.
def sigmoid(z):
z = np.asarray(z, dtype=float)
exp_neg_abs = np.exp(-np.abs(z))
return np.where(
z >= 0,
1 / (1 + exp_neg_abs),
exp_neg_abs / (1 + exp_neg_abs),
)
exp(-abs(z))는 유한한 실수 입력에서 0과 1 사이 값이므로 양쪽 식을 모두 계산해도 오버플로우하지 않는다. 큰 절댓값에서 0으로 언더플로우하는 것은 극한값 0·1을 표현하는 데 문제가 없다. NaN 입력 검증은 별도다.
학습률과 반복 횟수
기본값 lr=0.01, n_iters=1000은 문제에서 요구한 값이다. 실제로는 데이터 스케일에 따라 튜닝이 필요하다. 입력 특성의 스케일 차이가 크면 안정적인 학습률 선택이 어려워진다. BCE와 시그모이드를 결합한 로짓 기울기는 이미 유도한 y_hat - y이므로, 시그모이드 포화만으로 잘못 분류한 샘플의 학습 기울기도 사라진다고 볼 수는 없다. 표준화는 훈련 데이터의 통계로 적용하고 검증 데이터에는 그 변환을 재사용한다.
예측과 임계값
위 구현은 학습만 담당한다. 실제로 새로운 입력을 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 |
| 공식 | ||
| 공식 |
손실 함수와 활성화 함수가 모두 바뀌었는데 기울기 공식이 거의 똑같다는 것이 이 조합을 짝지어 설계한 결과다. 구현 관점에서는 시그모이드 호출 한 줄만 추가하면 분류 모델이 된다.
작은 입력으로 실행 확인
NumPy 2.3.5에서 아래 입력과 출력을 확인했다. 작은 예제의 동작 확인이며 검증 데이터의 일반화 성능을 측정한 결과는 아니다.
X = np.array([[-2.], [-1.], [1.], [2.]])
y = np.array([0, 0, 1, 1])
w, b = logistic_regression(X, y, lr=0.1, n_iters=2000)
predicted = (sigmoid(X @ np.asarray(w) + b) >= 0.5).astype(int)
print(predicted.tolist())
assert predicted.tolist() == [0, 0, 1, 1]
with np.errstate(over="raise", invalid="raise"):
assert np.allclose(sigmoid(np.array([-1000., 0., 1000.])), [0., 0.5, 1.])
print("Extreme inputs: OK")
[0, 0, 1, 1]
Extreme inputs: OK
마무리
이번 구현에서 가장 와닿은 점은 속에 가 숨어 있다는 것이다. BCE 식만 보면 파라미터가 없어 보이지만 합성함수 구조를 따라가면 체인룰이 자연스럽게 적용된다. 그리고 BCE와 시그모이드의 도함수가 서로를 상쇄시키는 설계 덕분에 최종 기울기는 "오차 × 입력"이라는 단순한 형태로 떨어진다.
선형 회귀에서 "예측 → 오차 → 기울기 → 업데이트"라는 경사하강법의 뼈대를 배웠다면 로지스틱 회귀는 그 뼈대 위에 시그모이드와 BCE를 올려 분류로 확장하는 사례다. 여기서 은닉층을 쌓고 활성화를 교체하면 바로 신경망으로 이어진다.
이 글은 학습 내용을 AI의 도움을 받아 정리했습니다.