NumPy로 Linear Regression: 경사하강으로 직선을 학습시켜보기
오늘은 가장 단순한 모델인 y=wx+b 직선을 데이터에 맞춰보면서, 딥러닝 학습의 핵심 루프인 "손실 계산 → 미분 → 파라미터 업데이트"를 처음부터 직접 구현한다. 라이브러리 없이 NumPy만으로 경사하강을 짜보면서 학습률이 너무 크거나 작을 때 무슨 일이 생기는지 직접 눈으로 확인한다. 이 루프는 앞으로 배울 모든 신경망 학습, 나아가 LLM 학습에서도
오늘의 목표
데이터에 숨어있는 직선의 기울기(w)와 절편(b)을, 정답을 모르는 상태에서 경사하강(gradient descent)이라는 방법으로 조금씩 찾아내는 것이 오늘 목표다. 즉 "모델이 학습한다"는 말이 실제로 컴퓨터 안에서 어떤 계산인지 코드 레벨에서 이해하는 날이다.
개념과 직관
선형회귀 모델: 우리가 만들 모델은 아주 단순하다.
y = w * x + bx를 입력하면 y를 예측하는 직선 하나짜리 모델이다. w는 기울기(slope), b는 절편(intercept, y축과 만나는 점)이다. 우리는 w와 b의 진짜 값을 모른다고 가정하고, 데이터만 보고 이 두 숫자를 맞춰볼 것이다.
손실 함수(loss function): 모델이 예측한 값이 실제 정답과 얼마나 다른지 숫자 하나로 나타낸 것. 오늘은 평균제곱오차(MSE, Mean Squared Error)를 쓴다.
예측값 y_pred = w*x + b
오차 = y_pred - y_true
손실 = (오차 하나하나를 제곱해서 평균낸 값)제곱을 하는 이유는 두 가지다. 오차가 양수든 음수든 다 "얼마나 틀렸는지"로 바꿔주고, 많이 틀린 점에 더 큰 벌점을 주기 위해서다.
그래디언트(gradient): 손실을 w로 미분하면 "w를 아주 조금 늘렸을 때 손실이 얼마나 늘어나는가"를 알 수 있다. b도 마찬가지다. 이 값을 그래디언트라고 부른다.
비유: 안개 낀 산에서 눈을 감고 가장 낮은 곳(손실이 가장 작은 지점)을 찾는다고 생각해보자. 발밑의 경사(기울기)를 느껴서, 내리막 방향으로 한 걸음씩 옮기면 결국 골짜기(최적의 w, b)에 도달한다. 그래디언트는 "지금 서 있는 자리의 경사가 어느 방향으로 얼마나 가파른가"를 알려주는 값이고, 우리는 그 반대 방향으로 이동한다.
최소한의 수식(텍스트):
MSE 손실은 다음과 같다.
loss = (1/n) * sum( (w*x_i + b - y_i)^2 ) # i는 데이터 1번부터 n번까지이걸 w로 미분하면 (직접 손으로 전개해보면 나오는 결과):
dloss/dw = (2/n) * sum( (w*x_i + b - y_i) * x_i )
dloss/db = (2/n) * sum( (w*x_i + b - y_i) )학습률(learning rate): 그래디언트 방향으로 얼마나 크게 한 걸음 내딛을지 정하는 숫자. 갱신 규칙은 이렇다.
w = w - 학습률 * dloss/dw
b = b - 학습률 * dloss/db이 과정을 수백~수천 번 반복하면 w와 b가 점점 정답에 가까워진다.
코드로 직접 해보기
1단계: 정답을 알고 있는 합성 데이터 만들기
실제로는 w, b를 모르지만, 검증을 위해 일부러 정답(w=3, b=5)을 정해놓고 노이즈(잡음, noise)를 살짝 섞은 데이터를 만든다.
import numpy as np
np.random.seed(0)
true_w, true_b = 3.0, 5.0
n_samples = 100
x = np.random.uniform(-10, 10, n_samples)
noise = np.random.normal(0, 1, n_samples) # 평균 0, 표준편차 1인 잡음
y = true_w * x + true_b + noise
print(x[:5])
print(y[:5])2단계: 손실 함수 직접 짜기
def compute_loss(w, b, x, y):
y_pred = w * x + b
error = y_pred - y
loss = np.mean(error ** 2)
return loss3단계: 그래디언트 계산 함수
위에서 손으로 유도한 미분 공식을 그대로 코드로 옮긴다.
def compute_gradients(w, b, x, y):
n = len(x)
y_pred = w * x + b
error = y_pred - y
dw = (2 / n) * np.sum(error * x)
db = (2 / n) * np.sum(error)
return dw, db4단계: 경사하강 루프
w, b = 0.0, 0.0 # 아무것도 모르는 상태에서 시작
lr = 0.01 # 학습률
n_epochs = 1000 # 반복 횟수
loss_history = []
for epoch in range(n_epochs):
dw, db = compute_gradients(w, b, x, y)
w = w - lr * dw
b = b - lr * db
loss = compute_loss(w, b, x, y)
loss_history.append(loss)
if epoch % 100 == 0:
print(f"epoch {epoch}: loss={loss:.4f}, w={w:.3f}, b={b:.3f}")
print(f"최종: w={w:.3f}, b={b:.3f} (정답: w=3.0, b=5.0)")이 코드를 실행하면 처음엔 손실이 크게 나오다가 점점 줄어들고, w와 b가 3.0과 5.0 근처로 수렴하는 걸 볼 수 있다. 여기서 "수렴한다(converge)"는 건 값이 더 이상 크게 변하지 않고 정답 근처에 안정적으로 머무는 상태를 말한다.
오늘의 실험
학습률만 바꿔가면서 같은 루프를 4번 돌려본다: 0.001, 0.01, 0.1, 1.0.
learning_rates = [0.001, 0.01, 0.1, 1.0]
for lr in learning_rates:
w, b = 0.0, 0.0
losses = []
for epoch in range(200):
dw, db = compute_gradients(w, b, x, y)
w = w - lr * dw
b = b - lr * db
loss = compute_loss(w, b, x, y)
losses.append(loss)
print(f"lr={lr}: 마지막 손실={losses[-1]:.4f}, 첫 손실={losses[0]:.4f}")관찰 포인트:
- lr=0.001: 손실이 아주 천천히 줄어든다. 200 epoch 안에 수렴하지 못할 수 있다.
- lr=0.01: 적당히 잘 줄어든다.
- lr=0.1: 더 빠르게 줄어들 수도 있고, 데이터 스케일에 따라 진동할 수도 있다.
- lr=1.0: 손실이 줄어들다가 갑자기 숫자가 매우 커지거나 nan(정의되지 않은 값)이 뜨는 발산(divergence) 현상을 볼 가능성이 높다.
matplotlib으로 각 학습률의 손실 곡선을 한 그래프에 그려서 비교하면 "발산하는 지점"을 눈으로 확인할 수 있다.
import matplotlib.pyplot as plt
plt.figure()
for lr in learning_rates:
w, b = 0.0, 0.0
losses = []
for epoch in range(200):
dw, db = compute_gradients(w, b, x, y)
w = w - lr * dw
b = b - lr * db
losses.append(compute_loss(w, b, x, y))
plt.plot(losses, label=f"lr={lr}")
plt.yscale("log") # 손실이 폭발하면 값 차이가 너무 커서 log 스케일이 보기 편하다
plt.legend()
plt.xlabel("epoch")
plt.ylabel("loss")
plt.show()기록할 것: 각 학습률에서 (1) 200 epoch 후 최종 손실 값, (2) 손실이 발산했는지 여부, (3) "적당한" 학습률의 범위를 노트에 적어둔다. 이 감각은 나중에 신경망 학습에서 학습률을 고를 때 그대로 재사용된다.
왜 이걸 배우나 (LLM / Post-training 연결)
오늘 만든 "손실 계산 → 그래디언트 계산 → 파라미터 업데이트" 3단계 루프는 파라미터가 3개든 수십억 개든 형태가 똑같다. LLM을 학습시키거나 post-training(사전학습 이후 미세조정, RLHF 등)을 할 때도 결국 이 루프를 반복하며, 다만 그래디언트를 손으로 유도하는 대신 역전파(backpropagation)라는 자동 계산 방식을 쓴다는 점만 다르다. 오늘 학습률을 바꿔보며 발산을 직접 겪어본 경험은, 나중에 대형 모델 학습이 왜 갑자기 loss spike(손실이 튀는 현상)를 겪는지 이해하는 데 바로 연결된다.
초보자가 막히는 지점
- 그래디언트 부호를 헷갈림: 갱신할 때
w = w - lr*dw인지w = w + lr*dw인지 혼동하기 쉽다. 손실을 "줄이는" 방향으로 가야 하므로 항상 그래디언트의 반대 방향, 즉 빼기(-)다. - 초기값을 0으로 두는 것에 대한 불안: w=0, b=0에서 시작해도 문제없다. 선형회귀는 손실 함수 모양이 오목한 그릇 형태(convex)라서 시작점과 상관없이 적절한 학습률이면 결국 정답에 도달한다. (신경망은 이렇게 단순하지 않다는 것만 기억해두자.)
- 학습률 1.0에서 nan이 뜨는 이유를 모름: 발산할 때 w, b 값이 매 스텝마다 더 커지다가 결국 너무 커진 숫자의 제곱이 파이썬이 표현 가능한 범위를 넘어서
inf나nan이 된다. 이건 버그가 아니라 학습률이 너무 크다는 신호다. - 데이터 스케일과 학습률의 관계: x의 범위가 -10~10처럼 크면 그래디언트 값도 커져서 같은 학습률에도 더 쉽게 발산한다. 나중에 배울 정규화(normalization)가 왜 필요한지 여기서 감을 잡아두면 좋다.
- 미분 공식을 통째로 외우려 함: 오늘은 공식을 암기하기보다, "손실이 예측 오차의 제곱평균이고, 그걸 w나 b로 미분하면 이런 형태가 나온다"는 흐름만 이해하면 충분하다. 정확한 미분 유도는 이번 주 뒤에 나올 수학 파트에서 다시 다룬다.
알고리즘 (병행 1시간)
오늘은 LeetCode Easy 1문제 + Medium 1문제(구현 위주)를 풀어본다. 주제는 자유롭게 골라도 되지만, 배열/반복문을 다루는 문제로 시작하는 걸 추천한다 (예: 배열 순회, 투 포인터 계열).
풀이 후 반드시 기록할 것:
- 접근 방식: 어떤 아이디어로 풀었는지 한두 문장
- 시간복잡도: Big-O로 표기 (예: O(n), O(n log n))
- 틀렸다면 왜 틀렸는지: 엣지 케이스를 놓쳤는지, 인덱스 계산 실수인지, 시간 초과인지 구체적으로 적어둔다. 이 기록이 나중에 비슷한 실수를 줄여준다.