Published on

NumPy로 선형 회귀 밑바닥부터 구현하기

Authors

TensorTonic의 Linear Regression from Scratch 문제를 NumPy만 사용해서 경사하강법(Gradient Descent)으로 풀면서 막혔거나 헷갈렸던 지점들을 정리했다.

선형 회귀 모델

선형 회귀는 입력 특성 xRd\mathbf{x} \in \mathbb{R}^d와 출력 yRy \in \mathbb{R} 사이의 관계를 다음과 같은 선형 함수로 가정한다.

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

여기서 wRd\mathbf{w} \in \mathbb{R}^d는 가중치 벡터, bb는 편향(bias)이다. 전체 학습 데이터를 행렬 XRn×dX \in \mathbb{R}^{n \times d}로 쌓으면 예측값은 한 번에 다음과 같이 계산된다.

y^=Xw+b\hat{\mathbf{y}} = X\mathbf{w} + b

학습의 목표는 예측값 y^\hat{\mathbf{y}}가 실제 값 y\mathbf{y}에 가까워지도록 w\mathbf{w}bb를 찾는 것이다.

손실 함수

"가깝다"는 것을 정량화하기 위해 평균제곱오차(MSE, Mean Squared Error)를 사용한다.

L(w,b)=1ni=1n(y^iyi)2L(\mathbf{w}, b) = \frac{1}{n} \sum_{i=1}^{n} (\hat{y}_i - y_i)^2

MSE는 두 가지 이유로 선형 회귀의 표준 손실 함수다.

  • 미분 가능: 모든 점에서 매끄럽게 미분된다. 절댓값 오차(MAE)는 0에서 미분 불가능하다.
  • 볼록(Convex) 함수: 선형 모델에 MSE를 결합하면 손실 함수가 w,b\mathbf{w}, b에 대해 볼록이다. 따라서 지역 최솟값이 곧 전역 최솟값이며 경사하강법이 반드시 수렴한다.

학습의 목표는 "손실 = 0"이 아니다

처음에 가장 크게 헷갈린 지점이다. "손실함수가 0이 되도록 w,b\mathbf{w}, b를 찾는 것"이 학습이라고 생각했는데, 사실은 그렇지 않다.

현실 데이터에는 노이즈가 있어서 하나의 선형 함수로 모든 점을 정확히 지나게 할 수는 없다. 같은 입력 x\mathbf{x}에 서로 다른 yy가 달린 두 샘플이 있다면 이미 L=0L = 0은 달성 불가능하다. 설령 학습 데이터에 대해 L=0L = 0이 됐다면 그건 과적합(overfitting) 신호에 가깝다.

진짜 목표는 "주어진 모델로 만들 수 있는 가장 낮은 손실"에 도달하는 것이다. 그 최솟점의 특징은 다음과 같다.

Lw=0,Lb=0\frac{\partial L}{\partial \mathbf{w}} = 0, \quad \frac{\partial L}{\partial b} = 0

손실 자체가 아니라 손실의 기울기가 0이 되는 지점을 찾는다. 고등학교 때 배운 "미분해서 0인 지점이 극값" 바로 그것이다.

구분하자면:

  • 손실 = 0: 모든 예측이 정답과 완벽히 일치. 현실에서는 거의 불가능.
  • 기울기 = 0: 더 이상 손실을 줄일 수 없는 지점. 이게 우리가 찾는 곳.

"손실 = 0 → 기울기 = 0"은 참이지만(최솟값이니까), 역은 성립하지 않는다. 골짜기 바닥의 높이가 0이 아닐 뿐 경사가 없으면 충분하다는 뜻이다.

경사하강법은 이 "기울기 = 0 지점"을 찾는 수치적 도구다. 기울기의 반대 방향으로 조금씩 내려가면, 기울기가 0에 가까워지고 자연스럽게 이동량도 줄면서 수렴한다.

w\mathbf{w}bb를 모두 미분해야 하나

편미분이 두 개 등장하는 것도 처음엔 어색했다. 그냥 w\mathbf{w}만 업데이트하면 안 되나 싶었지만, 해보면 안 되는 이유가 분명하다.

정답이 y=3x+5y = 3x + 5인 데이터를 가정하자. bb를 0으로 고정한 채 ww만 움직이면 만들 수 있는 직선은 y=wxy = wx 형태뿐이다. 아무리 ww를 잘 맞춰도 절편이 5만큼 떠 있는 정답 직선에는 도달할 수 없다.

다변수 함수의 최솟점은 모든 변수의 편미분이 동시에 0인 지점이다. 하나라도 0이 아니면 그 방향으로 더 내려갈 수 있다는 뜻이므로 최솟값이 아니다. w\mathbf{w}bb는 서로 독립된 자유도라서, 각각 업데이트해 줘야 두 자유도 모두에서 최적에 도달한다.

이 원리는 파라미터 수가 늘어나도 그대로 확장된다. 다중 회귀에서 특성이 dd개면 파라미터는 d+1d + 1개, 신경망에서는 수백만에서 수천억 개다. 모든 파라미터에 대해 편미분을 계산하고 모두 동시에 업데이트한다. 하나라도 빠뜨리면 그 파라미터는 초기값에 갇혀서 학습에 기여하지 못한다.

경사하강법 유도

w\mathbf{w}bb에 대한 LL의 편미분을 구한다. 오차 ei=y^iyie_i = \hat{y}_i - y_i라 하면 연쇄 법칙에 의해

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

w\mathbf{w} 식에는 XX^\top이 곱해지고 bb 식에는 안 곱해지는 이유는, 제곱 안쪽 y^i=wxi+b\hat{y}_i = \mathbf{w}^\top \mathbf{x}_i + bwjw_j로 미분하면 xijx_{ij}가 남지만 bb로 미분하면 1이 남기 때문이다. (XX^\top이 왜 전치로 나타나는지는 구현 절에서 차원 관점으로 따로 다룬다.)

경사하강법의 업데이트 규칙은 다음과 같다.

wwηLw,bbηLb\mathbf{w} \leftarrow \mathbf{w} - \eta \frac{\partial L}{\partial \mathbf{w}}, \quad b \leftarrow b - \eta \frac{\partial L}{\partial b}

여기서 η\eta는 학습률(learning rate)이다. 기울기가 클수록 이동량이 크고, 최솟점에 가까워져 기울기가 작아지면 이동량도 자동으로 줄어든다.

NumPy 구현

import numpy as np

def linear_regression(X, y, lr, epochs):
    """
    Returns: tuple (weights, bias)
    """
    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(epochs):
        y_hat = X @ w + b

        error = y_hat - y

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

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

    return (w, b)

벡터화

이중 for 문 없이 행렬 연산 한 줄로 끝난다. X @ w로 모든 샘플의 예측값을 한 번에 계산하고, X.T @ error로 모든 특성의 기울기를 한 번에 계산한다. NumPy 내부에서는 이런 연산이 BLAS 같은 최적화된 C 라이브러리로 실행되므로 Python 레벨의 for 문보다 수십에서 수백 배 빠르다.

왜 예측은 X @ w인데 기울기는 X.T @ error인가

같은 X를 쓰는데 한 번은 그대로, 한 번은 전치한다. 구현 중 가장 직관이 안 잡혔던 지점이었다.

핵심은 결과 벡터가 어느 축에 놓여야 하는가다.

  • y_hat샘플별 예측이어야 한다. 길이 nn 벡터.
  • dw특성별 기울기여야 한다. 길이 dd 벡터.

y_hat을 만들 때는 각 샘플(X의 한 행)의 특성들을 w\mathbf{w}로 가중합한다. X를 그대로 쓰면 (n,d)×(d,)(n,)(n, d) \times (d,) \to (n,)로 계산되어 특성 축 dd가 사라지고 샘플 축 nn이 남는다.

반대로 dw를 만들 때는 각 특성에 대해 모든 샘플의 오차를 합산해야 한다. 편미분 수식을 풀어 쓰면

Lwj=2ni=1nxijei\frac{\partial L}{\partial w_j} = \frac{2}{n} \sum_{i=1}^{n} x_{ij} \cdot e_i

wjw_j의 기울기는 XXjj번째 열(모든 샘플에서 특성 jj의 값들)과 오차 벡터의 내적이다. 그런데 행렬 곱은 "앞 행렬의 행"과 "뒤 벡터"를 내적한다. 열을 내적에 쓰려면 열을 행으로 돌려야 하고, 그게 전치다. 전치한 결과는 (d,n)×(n,)(d,)(d, n) \times (n,) \to (d,)가 되어 이번엔 샘플 축 nn이 사라지고 특성 축 dd만 남는다.

정리하면 전치는 어느 축을 뭉개고 어느 축을 남길지를 결정하는 도구다. 뭉개야 할 축이 안쪽(행렬 곱의 가운데 차원)에 오도록 X를 돌려놓는 것뿐이다.

초기화

w = np.zeros(n_features), b = 0.0으로 영 벡터에서 출발했다. 신경망에서는 영 초기화가 대칭성 문제로 학습을 망치지만, 선형 회귀는 손실이 볼록이므로 어디서 출발해도 같은 최솟값으로 수렴한다. 따라서 영 초기화가 가장 무난한 선택이다.

np.asarray로 입력 변환

X = np.asarray(X, dtype=float)
y = np.asarray(y, dtype=float)

리스트가 들어와도 동작하도록 ndarray로 변환하면서 dtype=float를 명시했다. 입력이 정수 배열이어도 이후 계산이 일관되게 부동소수점으로 처리되도록 하는 방어적 변환이다. np.array 대신 np.asarray를 쓴 이유는 이미 ndarray인 경우 불필요한 복사를 피하기 위함이다.

2/n 계수

손실 함수를 1nei2\frac{1}{n} \sum e_i^2로 정의했기 때문에 미분에서 2n\frac{2}{n}이 자연스럽게 따라붙는다. 이 상수를 생략하고 학습률을 2배로 올려도 수학적으로 동치지만, 수식을 그대로 옮기는 쪽이 실수를 줄인다.

경사하강법 vs 정규방정식

사실 선형 회귀는 경사하강법 없이도 닫힌 형태(closed-form)의 해가 존재한다.

w=(XX)1Xy\mathbf{w}^* = (X^\top X)^{-1} X^\top \mathbf{y}

이것을 정규방정식(Normal Equation)이라 한다. scikit-learnLinearRegression도 내부적으로는 경사하강법이 아니라 이 방식(정확히는 SVD 기반 의사역행렬)을 사용한다.

그럼에도 경사하강법을 배우는 이유는 두 가지다.

  1. 확장성: 정규방정식은 (XX)1(X^\top X)^{-1} 계산이 O(d3)O(d^3)이라 특성 수가 많으면 불가능하다. 경사하강법은 반복당 O(nd)O(nd)로 확장성이 훨씬 좋다.
  2. 일반성: 로지스틱 회귀, 신경망 등 대부분의 모델은 닫힌 해가 없다. 경사하강법은 어디서나 통하는 범용 도구다.

마무리

직접 구현해 보니 "수식을 코드로 옮긴다"는 말의 의미가 분명해졌다. 특히 세 가지가 남았다.

  • 학습은 손실을 0으로 만드는 게 아니라 기울기가 0인 지점을 찾는 과정이다.
  • 파라미터는 서로 독립된 자유도이므로 모두 동시에 업데이트해야 한다.
  • XX.T의 선택은 결과가 놓여야 할 축에 맞춰 차원을 돌려놓는 작업이다.

다음 단계는 같은 틀 위에서 시그모이드를 붙여 로지스틱 회귀를 구현하고, 은닉층을 쌓아 신경망으로 확장하는 것이다. 모델이 복잡해져도 "예측 → 오차 → 기울기 → 업데이트"라는 구조는 그대로 유지된다.