- Published on
NumPy로 선형 회귀 밑바닥부터 구현하기
- Authors

- Name
- 이동영
- Github
- @Github
TensorTonic의 Linear Regression from Scratch 문제를 NumPy만 사용해서 경사하강법(Gradient Descent)으로 풀면서 막혔거나 헷갈렸던 지점들을 정리했다.
선형 회귀 모델
선형 회귀는 입력 특성 와 출력 사이의 관계를 다음과 같은 선형 함수로 가정한다.
여기서 는 가중치 벡터, 는 편향(bias)이다. 전체 학습 데이터를 행렬 로 쌓으면 예측값은 한 번에 다음과 같이 계산된다.
학습의 목표는 예측값 가 실제 값 에 가까워지도록 와 를 찾는 것이다.
손실 함수
"가깝다"는 것을 정량화하기 위해 평균제곱오차(MSE, Mean Squared Error)를 사용한다.
MSE는 두 가지 이유로 선형 회귀의 표준 손실 함수다.
- 미분 가능: 모든 점에서 매끄럽게 미분된다. 절댓값 오차(MAE)는 0에서 미분 불가능하다.
- 볼록(Convex) 함수: 선형 모델에 MSE를 결합하면 손실 함수가 에 대해 볼록이다. 따라서 지역 최솟값이 곧 전역 최솟값이며 경사하강법이 반드시 수렴한다.
학습의 목표는 "손실 = 0"이 아니다
처음에 가장 크게 헷갈린 지점이다. "손실함수가 0이 되도록 를 찾는 것"이 학습이라고 생각했는데, 사실은 그렇지 않다.
현실 데이터에는 노이즈가 있어서 하나의 선형 함수로 모든 점을 정확히 지나게 할 수는 없다. 같은 입력 에 서로 다른 가 달린 두 샘플이 있다면 이미 은 달성 불가능하다. 설령 학습 데이터에 대해 이 됐다면 그건 과적합(overfitting) 신호에 가깝다.
진짜 목표는 "주어진 모델로 만들 수 있는 가장 낮은 손실"에 도달하는 것이다. 그 최솟점의 특징은 다음과 같다.
즉 손실 자체가 아니라 손실의 기울기가 0이 되는 지점을 찾는다. 고등학교 때 배운 "미분해서 0인 지점이 극값" 바로 그것이다.
구분하자면:
- 손실 = 0: 모든 예측이 정답과 완벽히 일치. 현실에서는 거의 불가능.
- 기울기 = 0: 더 이상 손실을 줄일 수 없는 지점. 이게 우리가 찾는 곳.
"손실 = 0 → 기울기 = 0"은 참이지만(최솟값이니까), 역은 성립하지 않는다. 골짜기 바닥의 높이가 0이 아닐 뿐 경사가 없으면 충분하다는 뜻이다.
경사하강법은 이 "기울기 = 0 지점"을 찾는 수치적 도구다. 기울기의 반대 방향으로 조금씩 내려가면, 기울기가 0에 가까워지고 자연스럽게 이동량도 줄면서 수렴한다.
왜 와 를 모두 미분해야 하나
편미분이 두 개 등장하는 것도 처음엔 어색했다. 그냥 만 업데이트하면 안 되나 싶었지만, 해보면 안 되는 이유가 분명하다.
정답이 인 데이터를 가정하자. 를 0으로 고정한 채 만 움직이면 만들 수 있는 직선은 형태뿐이다. 아무리 를 잘 맞춰도 절편이 5만큼 떠 있는 정답 직선에는 도달할 수 없다.
다변수 함수의 최솟점은 모든 변수의 편미분이 동시에 0인 지점이다. 하나라도 0이 아니면 그 방향으로 더 내려갈 수 있다는 뜻이므로 최솟값이 아니다. 와 는 서로 독립된 자유도라서, 각각 업데이트해 줘야 두 자유도 모두에서 최적에 도달한다.
이 원리는 파라미터 수가 늘어나도 그대로 확장된다. 다중 회귀에서 특성이 개면 파라미터는 개, 신경망에서는 수백만에서 수천억 개다. 모든 파라미터에 대해 편미분을 계산하고 모두 동시에 업데이트한다. 하나라도 빠뜨리면 그 파라미터는 초기값에 갇혀서 학습에 기여하지 못한다.
경사하강법 유도
와 에 대한 의 편미분을 구한다. 오차 라 하면 연쇄 법칙에 의해
식에는 이 곱해지고 식에는 안 곱해지는 이유는, 제곱 안쪽 를 로 미분하면 가 남지만 로 미분하면 1이 남기 때문이다. (이 왜 전치로 나타나는지는 구현 절에서 차원 관점으로 따로 다룬다.)
경사하강법의 업데이트 규칙은 다음과 같다.
여기서 는 학습률(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은 샘플별 예측이어야 한다. 길이 벡터.dw는 특성별 기울기여야 한다. 길이 벡터.
y_hat을 만들 때는 각 샘플(X의 한 행)의 특성들을 로 가중합한다. X를 그대로 쓰면 로 계산되어 특성 축 가 사라지고 샘플 축 이 남는다.
반대로 dw를 만들 때는 각 특성에 대해 모든 샘플의 오차를 합산해야 한다. 편미분 수식을 풀어 쓰면
의 기울기는 의 번째 열(모든 샘플에서 특성 의 값들)과 오차 벡터의 내적이다. 그런데 행렬 곱은 "앞 행렬의 행"과 "뒤 벡터"를 내적한다. 열을 내적에 쓰려면 열을 행으로 돌려야 하고, 그게 전치다. 전치한 결과는 가 되어 이번엔 샘플 축 이 사라지고 특성 축 만 남는다.
정리하면 전치는 어느 축을 뭉개고 어느 축을 남길지를 결정하는 도구다. 뭉개야 할 축이 안쪽(행렬 곱의 가운데 차원)에 오도록 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 계수
손실 함수를 로 정의했기 때문에 미분에서 이 자연스럽게 따라붙는다. 이 상수를 생략하고 학습률을 2배로 올려도 수학적으로 동치지만, 수식을 그대로 옮기는 쪽이 실수를 줄인다.
경사하강법 vs 정규방정식
사실 선형 회귀는 경사하강법 없이도 닫힌 형태(closed-form)의 해가 존재한다.
이것을 정규방정식(Normal Equation)이라 한다. scikit-learn의 LinearRegression도 내부적으로는 경사하강법이 아니라 이 방식(정확히는 SVD 기반 의사역행렬)을 사용한다.
그럼에도 경사하강법을 배우는 이유는 두 가지다.
- 확장성: 정규방정식은 계산이 이라 특성 수가 많으면 불가능하다. 경사하강법은 반복당 로 확장성이 훨씬 좋다.
- 일반성: 로지스틱 회귀, 신경망 등 대부분의 모델은 닫힌 해가 없다. 경사하강법은 어디서나 통하는 범용 도구다.
마무리
직접 구현해 보니 "수식을 코드로 옮긴다"는 말의 의미가 분명해졌다. 특히 세 가지가 남았다.
- 학습은 손실을 0으로 만드는 게 아니라 기울기가 0인 지점을 찾는 과정이다.
- 파라미터는 서로 독립된 자유도이므로 모두 동시에 업데이트해야 한다.
X와X.T의 선택은 결과가 놓여야 할 축에 맞춰 차원을 돌려놓는 작업이다.
다음 단계는 같은 틀 위에서 시그모이드를 붙여 로지스틱 회귀를 구현하고, 은닉층을 쌓아 신경망으로 확장하는 것이다. 모델이 복잡해져도 "예측 → 오차 → 기울기 → 업데이트"라는 구조는 그대로 유지된다.