2. PyTorch 02 - XOR로 이해하는 신경망 구조와 순전파
해설글
작성 · 7분 읽기 · 조회수 —
들어가며
PyTorch 예제는 흔히 class Model(nn.Module)로 시작한다. 처음에는 클래스를 왜 만들어야 하는지도 낯설었고, nn.Linear(2, 4)를 보고 입력이 4개인데 왜 weight가 (4, 2)인지도 헷갈렸다.
XOR은 이 질문들을 확인하기 좋은 작은 문제다. 데이터는 네 개뿐이지만 직선 하나로 분리할 수 없어서 은닉층과 비선형 활성화 함수가 꼭 필요하다.
한 줄 요약: XOR의 네 샘플을 한 번에 넣으면
Linear(2,4)가 각 샘플의 feature 2개를 은닉 feature 4개로 바꾸고, Tanh가 선형 모델로는 만들 수 없는 결정경계를 만든다.
1. XOR은 왜 직선 하나로 풀리지 않을까?
X = torch.tensor(
[[0, 0],
[1, 0],
[0, 1],
[1, 1]],
dtype=torch.float32,
device=device,
)
y = torch.tensor(
[0, 1, 1, 0],
dtype=torch.float32,
device=device,
).reshape(-1, 1)
| 입력 | 정답 |
|---|---|
(0, 0) |
0 |
(1, 0) |
1 |
(0, 1) |
1 |
(1, 1) |
0 |
대각선으로 마주 보는 두 점끼리 같은 클래스다. 아무리 직선을 움직여도 한쪽에 0 두 개, 반대쪽에 1 두 개만 남길 수 없다. 그래서 입력을 새로운 feature 공간으로 옮긴 뒤 그 공간에서 분리해야 한다.
2. nn.Module로 모델 만들기
import torch.nn as nn
class XOR(nn.Module):
def __init__(self):
super().__init__()
self.hidden = nn.Linear(2, 4)
self.output = nn.Linear(4, 1)
def forward(self, x):
hidden_output = torch.tanh(self.hidden(x))
logits = self.output(hidden_output)
return logits
model = XOR().to(device)
nn.Module은 PyTorch 모델의 기본 클래스다. 이를 상속하면 내부에 등록한 layer와 parameter를 PyTorch가 추적할 수 있다.
super().__init__()은 부모인 nn.Module의 초기화를 실행한다. 이 호출이 있어야 self.hidden, self.output에 넣는 layer들이 하위 모듈로 정상 등록되고, 이후 model.parameters(), model.to(device), state_dict() 같은 기능이 작동한다.
3. nn.Linear(2, 4)가 만드는 것
nn.Linear(in_features=2, out_features=4)는 다음 연산을 수행한다.
PyTorch가 저장하는 parameter shape은 다음과 같다.
| parameter | shape | 의미 |
|---|---|---|
hidden.weight |
(4, 2) |
은닉 unit 4개가 각각 입력 feature 2개를 읽음 |
hidden.bias |
(4,) |
은닉 unit마다 bias 하나 |
output.weight |
(1, 4) |
출력 unit 1개가 은닉 feature 4개를 읽음 |
output.bias |
(1,) |
출력 unit의 bias |
입력 배치 X가 (4, 2)인 이유는 샘플 4개 × 샘플당 feature 2개이기 때문이다. 여기서 앞의 4와 hidden.weight 앞의 4는 같은 숫자지만 의미가 전혀 다르다.
- 첫 번째 4: batch에 들어 있는 샘플 수
- 두 번째 4: 각 샘플에서 만들어낸 은닉 feature 수
그래서 hidden.weight가 (4, 4)일 필요는 없다. batch 크기는 parameter의 shape을 결정하지 않는다.
4. forward를 shape로 읽기
def forward(self, x):
hidden_output = torch.tanh(self.hidden(x))
logits = self.output(hidden_output)
return logits
| 단계 | shape |
|---|---|
입력 x |
(4, 2) |
self.hidden(x) |
(4, 4) |
torch.tanh(...) |
(4, 4) |
self.output(...) |
(4, 1) |
반환 logits |
(4, 1) |
모델 인스턴스를 model(X)처럼 호출하면 PyTorch의 nn.Module.__call__()이 내부에서 forward(X)를 실행한다. 사용자가 직접 model.forward(X)를 호출할 수도 있지만, hook과 같은 nn.Module 기능이 적용되는 model(X) 방식이 일반적이다.
5. Tanh가 필요한 이유
은닉층 두 개를 쌓았더라도 사이에 활성화 함수가 없다면 전체는 여전히 하나의 선형변환이다.
\[W_2(W_1X+b_1)+b_2=(W_2W_1)X+(W_2b_1+b_2)\]층을 여러 개 둔 의미가 사라진다. torch.tanh는 원소별 비선형 변환을 넣어 입력을 휘어진 feature 공간으로 보낸다. 그 결과 출력층의 직선 하나가 원래 입력 공간에서는 XOR을 가르는 비선형 경계가 된다.
“은닉층이 있어서 XOR을 푼다”보다 “은닉층 사이에 비선형성이 있어서 XOR을 푼다”가 더 정확한 표현이다.
6. 왜 출력에 Sigmoid가 없을까?
모델은 마지막 layer의 원시 점수인 logits를 그대로 반환한다.
criterion = nn.BCEWithLogitsLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.05)
BCEWithLogitsLoss는 Sigmoid와 Binary Cross Entropy를 하나로 합친 손실함수다. 모델 안에서 Sigmoid를 먼저 적용한 뒤 이 손실에 넣으면 Sigmoid를 두 번 적용하는 셈이 된다.
학습할 때는 logits를 손실함수에 바로 넣고, 확률이 필요한 평가 단계에서만 Sigmoid를 적용한다.
model.eval()
with torch.inference_mode():
logits = model(X)
probability = torch.sigmoid(logits)
prediction = (probability >= 0.5).to(torch.int64)
| 값 | shape | 범위·의미 |
|---|---|---|
logits |
(4, 1) |
제한 없는 원시 점수 |
probability |
(4, 1) |
0~1 사이 클래스 1 확률 |
prediction |
(4, 1) |
0 또는 1 |
loss |
() |
기본 reduction="mean"으로 평균낸 스칼라 |
여기서 loss의 shape이 (4, 1)이라고 생각하기 쉽다. 개별 원소의 손실은 네 개지만, 기본 설정에서 평균을 내므로 최종 loss는 0차원 스칼라 텐서다.
7. 실제 학습 결과
2000 epoch 학습한 노트북에서는 다음 결과가 저장되었다.
Input Target Probability Prediction
[0, 0] 0 0.000003 0
[1, 0] 1 0.999754 1
[0, 1] 1 0.999753 1
[1, 1] 0 0.000412 0
Accuracy: 100.00%
출력 확률이 네 점을 모두 정확히 구분했다. 중요한 건 이 결과를 외운 것이 아니라, 입력 (4,2)가 은닉 표현 (4,4)를 거쳐 logits (4,1)로 바뀌는 과정을 읽을 수 있게 된 것이다.
헷갈리기 쉬운 포인트
in_features, out_features를 안 써도 되나?
nn.Linear(2, 4)
nn.Linear(in_features=2, out_features=4)
두 코드는 같다. 앞의 코드는 위치 인자로 전달하고, 뒤의 코드는 키워드 인자로 전달한다. 처음 구조를 읽을 때는 키워드를 쓰면 의미가 더 분명하다.
흑백 분류라 출력이 두 개여야 하나?
XOR은 이진분류이므로 logit 하나만 출력해도 된다. Sigmoid를 통과한 값 하나가 클래스 1일 확률이고, 클래스 0의 확률은 1 - p다. 서로 배타적인 두 클래스에 logits 두 개와 CrossEntropyLoss를 쓰는 방식도 가능하지만, 현재 코드는 logit 하나와 BCEWithLogitsLoss를 택했다.
model.train()과 model.eval()이 XOR에서도 결과를 바꾸나?
현재 모델에는 Dropout이나 BatchNorm이 없어서 두 모드의 forward 계산은 같다. 그래도 학습·평가 단계의 의도를 명시하고, 나중에 그런 layer가 추가됐을 때 코드가 올바르게 동작하도록 모드를 전환한다.
정리하며
XOR 모델은 작지만 PyTorch 모델의 기본 골격을 모두 갖고 있다.
nn.Module 상속 → __init__에서 layer 등록 → forward에서 데이터 흐름 정의 → logits 반환 → 손실함수로 학습 → 평가에서 확률로 변환
다음 글에서는 이 모델이 어떻게 스스로 가중치를 바꾸는지 살펴본다. 특히 loss.backward()에 모델을 전달하지 않았는데도 어떻게 모든 layer의 gradient를 찾는지, detach()와 no_grad()는 왜 필요한지 정리한다.
이전 글: PyTorch 01 - 텐서로 선형회귀를 직접 풀어보기 · 다음 글: PyTorch 03 - backward부터 detach까지, Autograd 흐름 읽기