모델은 어떻게 학습하는가
- #AI
- #Backpropagation
- #Loss Function
- #Gradient Descent
이전 글에서는 AI의 전반적인 개념과 발전 과정을 다뤘다. 이번 글은 더 나아가 모델이 어떻게 학습하는지, 그중에서도 개인적으로 헷갈렸던 개념인 손실함수와 역전파를 좀 더 깊게 정리해 볼 예정이다.
학습이란
AI에서 모델은 입력을 받아 출력을 내놓는 함수다. 그 함수의 동작은 안에 들어 있는 수많은 숫자(파라미터, 가중치 와 편향 )에 따라 정해진다.
학습은 그 숫자들을 계속 미세하게 조정해서 모델의 출력이 정답에 가까워지도록 만드는 과정이다. 출력이 숫자(회귀 — 예: 집값)라면 정답값과의 차이를, 카테고리(분류 — 예: 고양이/강아지)라면 모델이 정답 카테고리에 매긴 확률이 1에 얼마나 가까운지를 본다. 그 차이를 줄이는 방향으로 다시 숫자들을 미세하게 조정하고, 이걸 반복한다.
한 줄로 간단하게 정리하자면 "학습은 예측과 정답의 차이를 줄이는 방향으로 파라미터를 조금씩 조정하는 일을, 데이터를 한 묶음씩 보면서 반복한다."
그 한 묶음마다 네 단계를 거친다.
- ① 순전파(forward) — 모델이 입력으로 예측을 만든다
- ② 손실(loss) — 정답과 예측의 차이를 숫자 하나로 나타낸다
- ③ 역전파(backward) — "각 파라미터를 어디로 조정하면 손실이 줄어들까?"를 모든 파라미터에 대해 계산한다 (= gradient)
- ④ 업데이트(update) — 그 방향으로 파라미터를 학습률만큼 움직인다
한 step에서 실제로 일어나는 일을 보면 batch 안 모든 데이터(예: 32개)를 한꺼번에 모델에 넣어 예측 32개를 만들고, 각 데이터의 손실을 따로 계산한 뒤 평균낸 값(= batch 손실) 하나로 파라미터를 한 번만 조정한다. 32개를 32번 따로 업데이트하는 게 아니라, 평균 손실에 대해 한 걸음만 움직인다는 뜻이다.
이 사이클을 데이터 전체에 대해 여러 번 돌리면 (한 바퀴를 epoch라고 부른다) 모델의 예측값이 점점 정답에 가까워진다.
데이터 준비
학습 사이클을 돌리기 전에, 먼저 데이터를 모델이 받을 수 있는 형태로 준비해야 한다. 보통 다음과 같은 순서를 거친다.
- 수집·정제 — 데이터 모으고 깨진 값·중복 걷어내기
- 분할 — train(학습 데이터 70%) / validation(검증 데이터 15%) / test(평가 데이터 15%)로 나누기
- 정규화 — 변수 간 숫자 스케일 맞추기
- 인코딩 — 텍스트·카테고리를 숫자 벡터로 바꾸기
- 배치화 — 묶음(batch) 단위로 쪼개기
실제 프로젝트에선 모델 코드를 짜는 것보다 데이터 준비에 손이 더 많이 간다고 한다. 다만 공부하거나 빠르게 검증이 필요할 땐 Hugging Face의 datasets 라이브러리에서 공개 데이터셋을 가져와 실험해 볼 수 있다.
from datasets import load_dataset
# 캘리포니아 집값 데이터셋 (1990년 인구조사 기반, 회귀 task)
ds = load_dataset("gvlassis/california_housing")
print(ds)
# DatasetDict({
# train: Dataset({ features: [...], num_rows: 16640 }),
# validation: Dataset({ features: [...], num_rows: 2000 }),
# test: Dataset({ features: [...], num_rows: 2000 }),
# })
print(ds["train"][0])
# {'MedInc': 8.3252, 'HouseAge': 41.0, ..., 'MedHouseVal': 4.526}순전파
순전파는 지금 가진 파라미터로 모델이 어떤 답을 내는지 계산하는 단계다. 데이터가 입력에서 출력으로 한 방향으로 흐른다고 해서 순전파(forward propagation) 라고 부른다.
가장 단순한 선형 모델이라면:
입력 에 를 곱하고 를 더하면 끝이다. 파라미터는 , 두 개 — 이 두 숫자가 모델의 동작을 결정한다.
import numpy as np
w, b = 2.0, 1.0 # 파라미터
x = np.array([1.0, 2.0, 3.0]) # 입력 3개
y_hat = w * x + b # 순전파
print(y_hat) # [3. 5. 7.]신경망은 이 변환을 여러 층 쌓고 사이에 비선형(ReLU 같은 함수)을 끼운 형태다. 그 덕에 직선뿐 아니라 곡선 패턴까지 학습할 수 있다.
가장 단순한 2-layer 신경망이라면:
선형 변환 → 비선형(ReLU) → 선형 변환 을 차례로 거친다. 층을 더 쌓고 싶으면 같은 패턴(선형 → 비선형)을 반복하면 된다.
import numpy as np
# 2-layer 신경망: x → ReLU(W1·x + b1) → W2·a1 + b2 = ŷ
x = np.array([1.0, 2.0])
W1 = np.array([[0.5, -0.3], # 1층 가중치 (3 × 2)
[0.8, 0.2],
[-0.1, 0.4]])
b1 = np.array([0.1, -0.2, 0.05])
W2 = np.array([0.7, -0.5, 0.3]) # 2층 가중치 (1 × 3)
b2 = 0.1
z1 = W1 @ x + b1 # 1층 선형
a1 = np.maximum(0, z1) # ReLU (비선형)
y_hat = W2 @ a1 + b2 # 2층 선형 → 출력
print(y_hat) # -0.175학습 시작 전 , 는 랜덤한 숫자다. 그래서 처음 순전파의 예측값은 정답과 한참 동떨어져 있는 경우가 많다. 학습이 진행되며 , 가 조금씩 조정되면서 비로소 정답에 가까워진다.
손실함수
순전파가 예측 을 내놨다. 정답은 . 여기서 데이터 하나에 대한 정답과 예측이 얼마나 다른지를 숫자 하나로 나타내는 게 손실함수(loss function)의 역할이다.
입력은 두 개(정답, 예측), 출력은 양수 하나. 두 값이 비슷할수록 출력 값이 작고, 멀수록 크다. task의 종류에 따라 사용되는 함수가 다른데 가장 대표적인 두 가지 손실함수를 먼저 알아보자.
회귀 — MSE (Mean Squared Error)
집값처럼 연속된 숫자를 맞히는 task에서는 평균제곱오차(MSE) 를 쓴다.
각 데이터마다 정답과 예측의 차이를 제곱한 뒤 모두 평균낸다. 여기서 제곱하는 이유는 두 가지인데, 부호를 없애서 양/음 오차가 상쇄되지 않게 하고, 큰 오차에 더 무거운 벌점을 주기 위함이다. 예를 들어 오차가 2면 제곱해서 4지만, 오차가 10이면 100이 된다. 다섯 배 차이가 스물다섯 배로 벌어지니, 큰 실수일수록 손실에 훨씬 크게 반영된다.
import numpy as np
y = np.array([3.0, 5.0, 7.0])
y_hat = np.array([2.5, 5.5, 8.0])
mse = np.mean((y - y_hat) ** 2)
print(mse) # 0.5분류 — Cross-Entropy
"고양이/강아지"처럼 카테고리를 맞히는 task에서는 교차엔트로피(cross-entropy) 를 쓴다.
분류 모델 출력은 보통 카테고리별 확률 분포다 — 예: [고양이 0.7, 강아지 0.2, 새 0.1]. 정답은 정답 자리만 1, 나머지는 0인 벡터(one-hot)로 표현한다 — 예: [1, 0, 0] (정답이 고양이). 두 분포가 얼마나 다른지 재는 게 cross-entropy다.
식의 는 정답 one-hot의 번째 값, 는 모델이 그 자리에 매긴 확률이다.
한번 직접 계산해 보면 단순함이 드러난다. 정답이 [1, 0, 0], 예측이 [0.7, 0.2, 0.1]일 때:
정답이 아닌 자리는 가 0이라 항이 통째로 사라지고, 정답 자리 항만 살아남는다. 결국 cross-entropy는 모델이 정답 자리에 매긴 확률 하나에 를 씌운 값으로 줄어든다 — "모델이 정답에 얼마나 자신 있는가"만 측정하는 셈이다.
정답 확률이 1에 가까우면 손실은 0에 가깝고, 0에 가까울수록 폭발한다. 자신 있게 맞히면 보상, 자신 있게 틀리면 큰 벌점을 준다.
import numpy as np
y_hat = np.array([0.7, 0.2, 0.1]) # 모델이 매긴 확률
y = np.array([1.0, 0.0, 0.0]) # 정답: 0번 카테고리
ce = -np.sum(y * np.log(y_hat))
print(ce) # 0.3567손실함수 선택은 단순하다
- 출력이 연속값 → MSE 계열 (회귀)
- 출력이 카테고리 → Cross-entropy (분류)
- 공통 — 미분 가능해야 한다.
왜 미분이 가능해야 하는지는 다음 두 섹션(역전파, 업데이트)에서 설명한다.
역전파
손실을 줄이려면 각 파라미터를 어느 방향으로 얼마나 움직여야 손실이 줄어드는지 알아야 한다. 그 정보를 담은 값이 기울기(gradient) 다.
파라미터 하나에 대한 기울기 는 "지금 를 살짝 늘리면 손실이 얼마나 늘어나는가"를 알려주는 숫자 하나다.
- 양수/음수는 방향을 담는다 — 양수면 를 늘릴수록 손실이 커진다는 뜻, 음수면 그 반대.
- 크기는 손실이 그 방향으로 얼마나 가파르게 변하는지를 담는다 — 큰 값이면 한 걸음에 영향이 크고, 0에 가까우면 거의 평지.
둘을 합치면 어느 방향으로 얼마나 움직여야 할지가 정해진다.
다만 모델 안에 파라미터가 한 개가 아니라는 게 문제다. 한 step 갱신을 위해 모든 파라미터에 대해 각각의 기울기가 필요하다 — .
실제 신경망은 파라미터가 수억 개고, 각 파라미터가 손실까지 도달할 때까지 여러 층을 거친다. 직접 일일이 미분해서 기울기를 구하는 건 거의 불가능하다.
그래서 나온 게 연쇄법칙(chain rule) 이라는 미분의 기본 규칙과, 그걸 활용하는 알고리즘인 역전파(backpropagation) 다.
연쇄법칙
함수가 두 단계 합성으로 되어 있다고 하자. 예를 들어 이고 .
가 바뀌면 → 가 바뀌고 → 이 바뀐다. 도미노 같은 사슬이다.
연쇄법칙의 핵심은 단순하다 — 가 에 미치는 영향은 각 단계의 미분(변화율)을 모두 곱한 것과 같다.
직접 계산해 보면:
- (의 미분)
- (의 미분)
- 곱하면
신경망도 똑같다. 단지 합성이 layer 수만큼 길어졌을 뿐. 한 가중치 가 손실 에 미친 영향은 각 layer의 미분을 끝까지 곱하면 얻을 수 있다.
왜 "뒤로" 계산하나?
곱해야 할 사슬은 정해졌다. 이걸 어느 방향으로 계산해야 효율적일까?
핵심은 여러 파라미터가 사슬의 끝부분(L 쪽)을 공유한다는 점이다. 그 공통 부분을 매번 다시 계산하느냐, 한 번만 계산하고 재활용하느냐가 갈림길.
작은 비유로 — , , 를 한꺼번에 다 구해야 한다고 하자. 앞에서부터 매번 처음부터 곱하면 같은 곱셈이 반복되지만, 뒤에서부터 누적해 가면 () 직전 결과를 그대로 재활용한다.
신경망에서도 똑같다.
- 앞에서부터 (파라미터 → 손실) — 가중치마다 자기 자리에서 L까지 사슬을 매번 처음부터 끝까지 따라가야 한다. 가중치가 수억 개면 같은 뒷부분을 수억 번 반복
- 뒤에서부터 (손실 → 파라미터) — L 쪽 미분을 한 번 구해 두고 한 층씩 내려가며 곱한다. 공통 부분이 자연스럽게 재활용되어 단 한 번의 backward로 모든 파라미터의 기울기가 다 나옴
이게 "역(reverse)전파"라고 부르는 이유다. 데이터는 앞으로(forward), 기울기는 뒤로(backward).
비용 차이는 엄청나다 — 파라미터마다 따로 계산하면 layer 수의 제곱에 비례하지만, 역전파는 forward 한 번 + backward 한 번이라 layer 수에 정비례한다. 이 효율성 덕분에 거대한 신경망 학습이 가능해졌다.
신경망에선 어떻게 작동하나
작은 2-layer 신경망으로 흐름을 확인하자. 순전파는 입력 가 , ReLU, 를 차례로 거쳐 손실 까지 한 방향으로 흘러간다.
역전파는 손실 에서 시작해 거꾸로 한 단계씩 기울기를 흘려보낸다.
각 단계에서 하는 일은 단순하다 — 이전 단계에서 받은 기울기 × 현재 단계의 미분. 한 layer를 지날 때마다 그 layer의 가중치 기울기 가 하나씩 떨어져 나오고, 동시에 다음 layer로 넘길 기울기가 만들어진다. 끝까지 가면 모든 layer의 가중치 기울기가 손에 다 들려 있게 된다.
# 앞 순전파 코드의 `x, z1, a1, z2, y, W1, W2`가 있다고 가정
# 역전파: L에서 시작해 거꾸로 한 단계씩
dL_dz2 = -2 * (y - z2) # 시작: ∂L/∂z₂
dL_dW2 = dL_dz2 * a1 # × a₁ → W₂의 기울기
dL_da1 = dL_dz2 * W2 # × W₂ (다음 단계로 전달)
dL_dz1 = dL_da1 * (z1 > 0) # × ReLU′ (z₁>0이면 1, 아니면 0)
dL_dW1 = np.outer(dL_dz1, x) # × x → W₁의 기울기매 줄이 "이전 단계에서 받은 기울기 × 현재 단계의 미분" 패턴이다. dL_dW2는 2층 가중치, dL_dW1은 1층 가중치의 기울기 — backward가 한 layer를 지날 때마다 그 layer의 가중치 기울기가 하나씩 떨어진 것이다. 이렇게 모은 모든 layer의 가중치 기울기를 다음 단계인 업데이트에 넘기면 한 step이 마무리된다.
업데이트
역전파가 모든 파라미터의 기울기 를 한꺼번에 구해줬다. 이제 그 기울기를 받아 파라미터를 어떻게 움직일지 정하면 한 step이 끝난다.
경사하강법
파라미터 값이 바뀌면 손실 값도 바뀐다. 같은 데이터라도 가 달라지면 예측이 달라지고, 그래서 손실도 달라진다. 한 파라미터에 대해 (, 손실) 쌍들을 좌표평면에 찍어 이으면 U자 곡선 — 손실 곡선이 그려진다. 파라미터가 둘 이상이면 곡선이 곡면이 되지만 본질은 같다.
우리가 가고 싶은 곳은 그 곡면의 최저점. 하지만 곡선 전체를 미리 알고 단번에 짚는 게 아니라, 어떤 (보통 랜덤)에서 시작해 지금 위치의 기울기만 보고 한 걸음씩 옮긴다. 안개 낀 산에서 하산하는 것과 똑같다 — 멀리는 안 보여도 발 밑 기울기는 알 수 있으니, 그걸 따라 한 발씩 내려가면 된다.
기울기가 양수인지 음수인지에 따라 어느 쪽이 내리막인지 알려준다.
- 기울기 양수 → 를 늘리면 손실 ↑ → 를 줄여야 손실 감소
- 기울기 음수 → 를 늘리면 손실 ↓ → 를 늘려야 손실 감소
두 경우 모두 기울기와 반대 방향으로 가면 손실이 줄어든다. 한 줄 식으로 정리한 게 경사하강법(gradient descent) 의 업데이트 규칙이다.
- — 기울기 그 자체 (역전파가 구해 준 값)
- (마이너스) — 기울기의 반대 방향으로 가겠다는 뜻
- (에타) — 학습률(learning rate), 한 걸음을 얼마나 크게 옮길지의 보폭
학습률 가 크면 보폭이 길고, 작으면 짧다. 너무 크면 최저점을 한 번에 건너뛰어 반대편 비탈로 튕긴다(발산). 너무 작으면 한참 걸어도 별로 안 움직인다. 보통 0.001 ~ 0.1 사이에서 고른다.
작은 예제로 직접 돌려 보기
손실이 , 정답이 인 단순한 상황이다. 가 어디서 시작하든 결국 3으로 수렴해야 한다.
먼저 기울기를 손으로 구해두자. 을 에 대해 미분하면 이다.
미분이 익숙하지 않다면 — 제곱한 식을 미분할 땐 지수 2가 앞으로 내려오면서 안쪽 이 그대로 한 번 살아남는다. 앞의 2는 그래서 나온 미분 계수이고, 학습률 는 아래 코드의
lr = 0.1로 따로 등장한다.
이걸 매 step마다 계산해서 를 기울기 반대 방향으로 학습률만큼 옮긴다.
w = 0.0 # 초기값
lr = 0.1 # 학습률
for step in range(20):
grad = 2 * (w - 3) # 기울기 dL/dw
w = w - lr * grad # 기울기 반대 방향으로 한 걸음
loss = (w - 3) ** 2
print(f"step {step:2d} w={w:.4f} loss={loss:.4f}")출력 (앞과 마지막):
step 0 w=0.6000 loss=5.7600
step 1 w=1.0800 loss=3.6864
step 2 w=1.4640 loss=2.3593
...
step 19 w=2.9654 loss=0.0012
20 step 만에 거의 정답에 도착. 매번 기울기 한 번 보고 그 반대로 0.1만큼 옮긴 게 전부인데 자연스럽게 최저점으로 수렴한다. 이 예제는 파라미터가 하나라 손으로 미분이 가능했지만, 실제 신경망은 파라미터가 수억 개라 그 기울기를 한꺼번에 구하려면 앞 섹션에서 본 역전파가 필요했던 것이다.
옵티마이저
역전파가 이미 각 파라미터의 기울기 를 다 구해 줬다. 그럼 위 1D 규칙을 수억 개 파라미터에 어떻게 적용할까?
핵심은 단순하다 — 같은 규칙을 파라미터마다 따로따로 적용한다. 각자 자기 기울기만큼만 옮기면 끝.
수억 줄을 따로 적기는 번거로우니, 보통은 모든 파라미터를 묶어 로 표기하고 한 줄로 줄여 쓴다.
여기서 "모든 파라미터에 동시에"는 한 step에 다 갱신된다는 뜻이지 모두가 같은 값으로 움직인다는 뜻이 아니다. 파라미터마다 자기 기울기가 다르니 옮기는 방향과 보폭도 제각각이다.
이 업데이트 단계를 수행하는 알고리즘을 통틀어 옵티마이저(optimizer) 라고 부른다. 한 번 호출하면 모델의 모든 파라미터가 위 규칙대로 한꺼번에 갱신된다.
지금까지 본 가장 단순한 형태가 SGD(Stochastic Gradient Descent) 다. 그 외에도 학습률을 자동 조절하거나 관성을 더한 Adam, RMSprop 같은 더 똑똑한 옵티마이저들이 있다 — 모두 경사하강의 변형이라 위 식을 약간씩 보정한 형태이며, 실무에선 보통 Adam을 기본값처럼 쓴다.
전부 합쳐서 — PyTorch로 모델 학습시키기
이제 네 단계를 PyTorch 라이브러리로 묶어 하나의 학습 루프로 돌려본다. 앞 섹션들에선 각 단계를 더 잘 보여주기 위해 코드를 하나하나 작성했지만, 실제론 PyTorch의 여러 유용한 메소드들(nn.Linear, nn.MSELoss, optim.SGD, loss.backward())이 그 일을 해 준다.
정답이 인 데이터 100개로 모델이 정답(w=2, b=1)을 찾아낼 수 있도록 학습시켜 보자.
import torch
import torch.nn as nn
# 데이터 — 정답: y = 2x + 1 (+ 약간의 noise)
x = torch.linspace(-3, 3, 100).unsqueeze(1)
y = 2 * x + 1 + torch.randn(100, 1) * 0.3
# 모델 · 손실 · 옵티마이저
model = nn.Linear(1, 1) # 학습 가능한 w, b 내장
loss_fn = nn.MSELoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.05)
# 학습 루프 — 네 단계의 골격
for epoch in range(200):
y_hat = model(x) # ① 순전파
loss = loss_fn(y_hat, y) # ② 손실
optimizer.zero_grad() # 이전 step의 기울기 초기화
loss.backward() # ③ 역전파 (autograd가 자동 계산)
optimizer.step() # ④ 업데이트
w, b = model.weight.item(), model.bias.item()
print(f"학습 후: w={w:.2f}, b={b:.2f}")
# 학습 후: w≈2.00, b≈1.00 (노이즈에 따라 미세 변동)핵심 메소드 설명
nn.Linear(in, out)— 학습 가능한 , 를 내장한 선형 변환 layermodel.parameters()— 옵티마이저에 넘길 학습 가능 파라미터들nn.MSELoss()— 평균제곱오차 (회귀용 손실 함수)torch.optim.SGD(params, lr)— 경사하강 옵티마이저model(x)— 순전파 실행 (내부적으로forward(x)호출)loss.backward()— autograd가 forward 그래프를 거꾸로 타고 모든 파라미터의 기울기를 자동 계산optimizer.step()— 계산된 기울기로 파라미터를 한꺼번에 업데이트optimizer.zero_grad()— 이전 step의 기울기를 비움 (안 비우면 누적됨)
200 epoch 만에 정답 근처에 수렴했다.
마무리
이 글에서 본 네 단계가 모든 신경망 학습의 공통적인 구조다.
- 순전파 — 지금의 파라미터로 모델이 무슨 답을 내는지 계산하고
- 손실함수 — 그 답이 정답과 얼마나 다른지 숫자 하나로 나타내고
- 역전파 — 손실을 줄일 방향(기울기)을 모든 파라미터에 대해 한 번에 효율적으로 계산하고
- 업데이트 — 그 방향으로 모든 파라미터를 학습률만큼 한꺼번에 옮긴다 (경사하강법)
신경망이 깊어지고 task가 복잡해져도 학습 루프의 기본 뼈대는 이 네 단계다. 모델의 종류에 따라 여기서 단계가 추가되거나 변형되는 경우도 있지만, transformer, GPT, ViT 같은 모델 대부분이 이 뼈대 위에서 돌아간다.
이렇게 글로 정리하면서 각 단계를 한층 깊이 들여다볼 수 있어서 좋았다. 특히 경사하강법과 손실함수처럼 처음엔 막연하게만 알고 있던 개념도, 천천히 조사하고 직접 풀어 쓰는 과정을 거치니 비로소 머릿속에서 정리되는 것 같다.
이 글은 사실관계나 해석에 오류가 있을 수 있습니다. 잘못된 내용이나 질문이 있으면 댓글로 편하게 남겨 주세요.