본문 바로가기
TYLER SONGBlog
블로그 목록
LLM Research

NumPy Logistic Regression: 예측에서 정답을 빼면 왜 그게 기울기가 되는가

이제 숫자를 예측하는 게 아니라 "0인가 1인가"를 맞추는 분류 문제를 다룬다. 시그모이드 함수로 확률을 만들고, binary cross entropy로 얼마나 틀렸는지 재고, 경사하강으로 파라미터를 고쳐나가는 과정을 NumPy로 직접 구현한다. Day 앞부분에서 배운 행렬 연산과 경사하강이 실제로 어떻게 분류 모델 하나를 완성하는지 눈으로 확인하는 날이다

송민성8분 읽기

오늘의 목표

지금까지는 "입력에서 숫자를 예측하는" 회귀(regression)를 다뤘다면, 오늘은 "이 점이 A그룹인지 B그룹인지 판별하는" 분류(classification)를 배운다. 목표는 2차원 평면 위에 흩어진 점들을 보고, 경사하강(gradient descent)만으로 두 그룹을 나누는 직선(결정경계)을 스스로 찾아내는 모델을 NumPy로 만드는 것이다.

개념과 직관

왜 회귀로는 분류를 못 하나 집값 예측처럼 "숫자를 그대로 맞추는" 문제와 달리, 분류는 "0 아니면 1"이라는 정답을 맞춰야 한다. 그런데 모델이 내놓는 값은 그냥 실수(-5든 100이든 나올 수 있음)라서, 이걸 "0~1 사이 확률"로 바꿔주는 장치가 필요하다. 그게 시그모이드(sigmoid) 함수다.

시그모이드(sigmoid): 어떤 실수든 입력받아서 0과 1 사이 값으로 눌러주는 함수. 그림으로 그리면 S자 곡선이다. 입력이 아주 크면 1에 가까워지고, 아주 작으면(음수로 크면) 0에 가까워지고, 0 근처에서는 0.5 근처를 지난다.

텍스트 수식으로 쓰면:

text
sigmoid(z) = 1 / (1 + exp(-z))

여기서 z는 선형 결합, 즉 z = w1*x1 + w2*x2 + b (직선의 방정식과 똑같은 모양)다.

직관: "이 점이 얼마나 오른쪽/위쪽에 있느냐"를 z라는 하나의 점수로 압축하고, 그 점수를 시그모이드에 통과시켜 "1일 확률"로 바꾸는 것이다. z가 0이면 딱 경계선 위, 즉 확률 0.5.

이진 분류(binary classification): 정답이 0 또는 1 둘 중 하나인 문제. 오늘 실습에서는 2D 점이 "빨간 그룹(0)"인지 "파란 그룹(1)"인지 맞춘다.

binary cross entropy (BCE, 이진 교차 엔트로피): 모델이 낸 확률(p)이 진짜 정답(y)과 얼마나 다른지 재는 손실 함수. 직관적으로는 "정답이 1인데 모델이 낮은 확률을 줬다면 크게 벌점, 정답을 맞게 예측했으면 벌점 거의 없음"이다.

텍스트 수식:

text
loss = -( y * log(p) + (1-y) * log(1-p) )
  • y=1일 때: loss = -log(p) → p가 1에 가까울수록 loss는 0에 가까움, p가 0에 가까우면 loss가 무한대로 폭발
  • y=0일 때: loss = -log(1-p) → 반대로 p가 0에 가까울수록 좋음

gradient (예측 - 정답)의 마법 로지스틱 회귀에서 손실을 w(가중치)로 미분하면, 놀랍게도 아주 단순한 형태로 정리된다:

text
gradient_w = (p - y) * x
gradient_b = (p - y)

직관: "모델이 예측한 확률(p)과 실제 정답(y)의 차이"가 곧 얼마나, 어느 방향으로 파라미터를 고쳐야 하는지 알려준다. p가 y보다 크면(과대예측) w를 줄이는 방향, 작으면 늘리는 방향으로 이동한다. 이 식은 직접 미분을 유도하지 않아도 "예측 - 정답"이라는 형태만 기억해도 코드로 옮길 수 있다. (미분 유도 과정 자체는 오늘 다루지 않는다. 결과 식만 사용한다.)

batch vs mini-batch vs SGD 경사하강을 할 때 "한 번 업데이트할 때 데이터를 몇 개나 볼 것인가"의 차이다.

  • 배치 경사하강(batch GD): 전체 데이터를 다 보고 나서 한 번 업데이트. 방향은 정확하지만 데이터가 크면 느림.
  • 확률적 경사하강(SGD, stochastic gradient descent): 데이터 딱 1개만 보고 바로 업데이트. 빠르지만 방향이 들쭉날쭉함.
  • 미니배치 경사하강(mini-batch GD): 데이터를 예를 들어 16개, 32개씩 묶어서 그 묶음마다 업데이트. 실무에서 가장 흔히 쓰는 절충안.

비유: 산에서 내려오는 길을 찾을 때, batch는 "지도 전체를 보고 정확한 방향을 계산한 뒤 한 걸음"이고, SGD는 "발밑만 보고 대충 감으로 한 걸음씩 빠르게", mini-batch는 "주변 몇 미터를 보고 방향 잡아서 한 걸음"이다.

코드로 직접 해보기

1단계: 데이터 만들기 (2D 점 두 그룹)

python
import numpy as np np.random.seed(0) # 그룹 0: (-2,-2) 근처에 흩어진 점들 class0 = np.random.randn(50, 2) * 0.7 + np.array([-2, -2]) # 그룹 1: (2, 2) 근처에 흩어진 점들 class1 = np.random.randn(50, 2) * 0.7 + np.array([2, 2]) X = np.vstack([class0, class1]) # shape (100, 2) y = np.hstack([np.zeros(50), np.ones(50)]) # shape (100,) print(X.shape, y.shape)

2단계: 시그모이드와 예측 함수

python
def sigmoid(z): return 1 / (1 + np.exp(-z)) def predict_proba(X, w, b): z = X @ w + b # (100,2) @ (2,) -> (100,) return sigmoid(z)

3단계: 손실 함수 (BCE)

python
def bce_loss(p, y, eps=1e-8): # log(0)을 막기 위해 eps로 살짝 클리핑 p = np.clip(p, eps, 1 - eps) return -np.mean(y * np.log(p) + (1 - y) * np.log(1 - p))

4단계: 경사하강 학습 루프 (배치 GD)

python
w = np.zeros(2) b = 0.0 lr = 0.1 n = X.shape[0] for epoch in range(1000): p = predict_proba(X, w, b) loss = bce_loss(p, y) grad_w = X.T @ (p - y) / n # (2,100) @ (100,) -> (2,) grad_b = np.mean(p - y) w -= lr * grad_w b -= lr * grad_b if epoch % 200 == 0: print(f"epoch {epoch}, loss {loss:.4f}") print("최종 w, b:", w, b)

여기서 X.T @ (p - y) / n이 바로 위에서 본 "(예측-정답) * x"를 전체 데이터에 대해 평균 낸 것이다.

5단계: 결정경계 시각화

python
import matplotlib.pyplot as plt plt.scatter(class0[:,0], class0[:,1], label="class 0") plt.scatter(class1[:,0], class1[:,1], label="class 1") # w1*x1 + w2*x2 + b = 0 을 x2에 대해 풀면 결정경계 직선이 나온다 x1_line = np.linspace(-4, 4, 100) x2_line = -(w[0] * x1_line + b) / w[1] plt.plot(x1_line, x2_line, color="black", label="decision boundary") plt.legend() plt.show()

오늘의 실험

batch GD, mini-batch GD, SGD 세 가지를 같은 데이터와 같은 초기값(w=0, b=0)에서 돌려보고 비교한다.

  1. batch GD: 위 코드처럼 매 epoch마다 전체 100개 데이터로 gradient 계산.
  2. mini-batch GD: 데이터를 섞은 뒤 batch_size=16 정도로 잘라서, 한 epoch 안에서 여러 번 업데이트.
  3. SGD: batch_size=1로 극단적으로 설정.

바꿔볼 것: batch_size (1, 16, 32, 100), learning rate (0.01, 0.1, 1.0)

관찰하고 기록할 것:

  • 같은 epoch 수에서 loss가 얼마나 빨리 떨어지는가 (loss curve를 그려서 비교)
  • loss가 매끄럽게 떨어지는지, 아니면 들쭉날쭉 진동하는지
  • learning rate를 키웠을 때 batch GD는 안정적인데 SGD는 발산해버리는 경우가 생기는지

기록 형식 예시 (표나 노트에):

text
설정: batch_size=1, lr=0.1 -> 최종 loss=0.15, loss curve 진동 심함
설정: batch_size=16, lr=0.1 -> 최종 loss=0.05, 진동 적고 수렴 빠름
설정: batch_size=100, lr=0.1 -> 최종 loss=0.06, 매끄럽지만 epoch당 느림

결론은 "왜 실무에서 mini-batch를 기본값으로 쓰는지"를 본인 말로 한 줄 정리해보는 것.

왜 이걸 배우나 (LLM / Post-training 연결)

오늘 만든 "예측(p) - 정답(y)"이라는 gradient 형태는 로지스틱 회귀만의 특별한 우연이 아니라, LLM 학습에서 다음 단어를 예측할 때 쓰는 cross entropy loss에서도 똑같은 뼈대로 다시 등장한다. 또한 mini-batch 개념은 이후 LLM을 GPU 여러 장으로 학습시킬 때 batch size, gradient accumulation 같은 실무 세팅의 기초가 되고, RLHF나 preference tuning 같은 post-training 단계에서도 "모델 출력 확률과 선호도 정답의 차이"를 손실로 쓰는 방식이 계속 반복된다.

초보자가 막히는 지점

  • 행렬 곱 shape 에러: X @ w에서 X가 (100,2), w가 (2,)여야 하는데 w를 (2,1)로 잘못 만들면 broadcasting이 이상하게 되어 결과가 (100,100) 같은 엉뚱한 shape이 나온다. .shape을 항상 출력해서 확인하는 습관을 들일 것.
  • log(0) 에러: p가 정확히 0이나 1이 되면 np.log(0)-inf가 되어 loss가 nan으로 터진다. 위 코드처럼 np.clip으로 아주 작은 epsilon만큼 밀어주는 이유가 이것이다.
  • 결정경계 수식에서 w[1]이 0이면 나눗셈 에러: 데이터 배치가 우연히 그런 방향이면 발생할 수 있다. 실습 데이터에서는 거의 안 일어나지만 에러가 나면 원인을 이 나눗셈에서부터 확인.
  • learning rate가 너무 크면 loss가 줄지 않고 늘어남: 특히 SGD에서 심하다. loss가 nan이나 계속 커지면 lr을 10배 정도 줄여서 다시 시도.
  • gradient 공식을 외우려다 헷갈리는 경우: "(예측 - 정답) * 입력"이라는 형태만 기억하면 되고, 미분을 직접 손으로 유도할 필요는 오늘 단계에서는 없다.

알고리즘 (병행 1시간)

LeetCode Easy 1문제 + Medium 1문제(스택/큐 주제)를 푼다.

풀이 후 반드시 기록할 것:

  • 접근 방식: 문제를 보고 처음에 어떤 아이디어가 떠올랐는지, 실제로 어떤 자료구조(스택 또는 큐)를 썼는지
  • 시간복잡도: 내가 짠 풀이가 O(n)인지 O(n^2)인지, 왜 그런지 한 줄 근거
  • 틀렸다면 왜 틀렸는지: 엣지 케이스(빈 입력, 원소 1개)를 놓쳤는지, 스택/큐 연산 순서를 잘못 짰는지 구체적으로 적기. 오늘 배운 경사하강처럼, 틀린 이유를 기록해야 다음에 같은 실수를 반복하지 않는다.
© 2026 Tyler Song