개요
지난 글까지가 scikit-learn이었다. 표 형태 데이터라면 거기서 대부분 끝난다.
그런데 이미지나 텍스트처럼 복잡한 데이터를 다루려면 직접 신경망을 만들어야 하고, 그때 쓰는 게 PyTorch다.
처음 PyTorch를 열었을 때 든 생각은 이거였다.
“NumPy랑 똑같이 생겼는데?”
맞다. 거의 똑같다. 다른 점은 딱 두 가지인데, 그 두 가지가 전부를 바꾼다.
- GPU에서 돌릴 수 있다
- 미분을 자동으로 해준다
이번 글은 그 두 가지의 정체를 뜯어본다.
1. 텐서 — NumPy 배열에 기능이 붙은 것
텐서(Tensor)는 PyTorch의 기본 재료다. 이름이 거창한데 그냥 숫자를 담는 상자다.
| 차원 | 이름 | 예 |
|---|---|---|
| 0차원 | 스칼라 | 5 |
| 1차원 | 벡터 | [1, 2, 3] |
| 2차원 | 행렬 | [[1,2],[3,4]] |
| 3차원 이상 | 텐서 | 이미지 여러 장 등 |
import torch
a = torch.tensor([[1., 2.], [3., 4.]])
b = torch.zeros(2, 3) # 0으로 채운 2×3
c = torch.randn(2, 3) # 정규분포 난수
d = torch.arange(0, 10, 2) # 0, 2, 4, 6, 8
NumPy를 써봤다면 이름이 거의 같다는 걸 알아챘을 것이다. 실제로 대부분의 문법이 그대로 통한다.
반드시 확인해야 하는 세 가지
PyTorch를 쓰면서 에러를 만나면, 십중팔구 이 셋 중 하나가 어긋난 것이다.
print(x.shape) # torch.Size([2, 3]) — 모양
print(x.dtype) # torch.float32 — 자료형
print(x.device) # cpu — 어디에 있나
이 세 줄을 찍어보는 습관이 디버깅 시간을 진짜로 줄여준다. 나는 초반에 이걸 안 찍고 코드만 노려보다가 시간을 많이 버렸다.
-
.shape— 행렬 곱이 안 맞을 때 범인 -
.dtype—Double과Float이 충돌할 때 범인 -
.device— CPU 텐서와 GPU 텐서를 섞었을 때 범인
2. Autograd — 미분을 자동으로
여기가 PyTorch의 핵심이다.
지난 시리즈에서 신경망은 기울기를 구해서 반대 방향으로 값을 옮기며 배운다고 정리했다. 그 기울기를 손으로 구하려면 연쇄법칙을 층마다 적용해야 하는데, 층이 수십 개면 사람이 할 일이 아니다.
PyTorch는 이걸 대신 해준다. 방법이 재밌다.
requires_grad=True — 영수증 켜기
x = torch.tensor([2.0, 3.0], requires_grad=True)
이걸 붙이면 PyTorch가 이 텐서로 하는 모든 계산을 몰래 기록하기 시작한다.
계산기에 “영수증 출력” 기능을 켠 것과 같다. 어떤 순서로 어떤 계산을 했는지 전부 적어둔다. 나중에 그 영수증을 거꾸로 읽으면서 미분을 계산한다.
실제로 해보기
x = torch.tensor([2.0, 3.0], requires_grad=True)
y = x ** 2 + 3 * x + 1
z = y.sum() # 숫자 하나로 만들기
z.backward() # 역전파 시작
print(x.grad) # tensor([7., 9.])
손으로 검증해보자. y = x² + 3x + 1을 x로 미분하면 2x + 3이다.
- x=2일 때: 2×2 + 3 = 7 ✓
- x=3일 때: 2×3 + 3 = 9 ✓
정확히 맞는다.
이걸 직접 해보기 전까지는 .backward()가 좀 미덥지 않았다. 뭘 어떻게 계산하는지 안 보이는 채로 결과만 나오니 믿고 쓰기가 찜찜했다.
그런데 손으로 구한 7과 9가 그대로 나오는 걸 보고 나서 생각이 바뀌었다. Autograd는 미분을 아는 게 아니라, 순서를 기억했다가 되짚는 것이다. 원리를 알고 나니 그다음부터는 마음 편히 쓰게 됐다.
라이브러리를 쓸 때 한 번은 손으로 검증해보는 게 결국 시간을 아낀다는 걸 여기서 배웠다. 검증해두면 나중에 결과가 이상할 때 “라이브러리가 틀렸나?”를 후보에서 지울 수 있다. 그것만으로도 디버깅 범위가 확 줄어든다.
왜 .sum()으로 숫자 하나를 만드나
.backward()는 스칼라(숫자 하나)에서만 호출할 수 있다.
미분은 “결과 하나가 각 입력에 얼마나 민감한가”를 묻는 것인데, 결과가 여러 개면 질문 자체가 애매해지기 때문이다.
실제 학습에서는 손실이 항상 숫자 하나로 나오니 자연스럽게 해결된다. 손실 함수가 마지막에 평균이나 합을 내주기 때문이다.
.grad는 덮어쓰지 않고 더해진다
이건 함정이라 따로 짚고 간다.
loss.backward()
print(weight.grad) # tensor([[12.]])
loss2.backward()
print(weight.grad) # tensor([[24.]]) ← 12 + 12! 덮어쓴 게 아니라 더해졌다
PyTorch는 .grad를 계속 더한다. 큰 배치를 조각내서 계산한 뒤 합치는 기법을 위해 그렇게 설계됐다.
하지만 일반적인 학습에서는 매번 지워줘야 한다. 안 지우면 지난 계산이 계속 쌓여서 엉뚱한 방향으로 움직인다.
weight.grad.zero_()
나중에 옵티마이저를 쓰면 optimizer.zero_grad() 한 줄로 끝난다. 다음 글의 핵심 주제이기도 하다.
torch.no_grad() — 기록 끄기
with torch.no_grad():
weight -= 0.01 * weight.grad
파라미터를 고치는 이 계산 자체는 미분 대상이 아니다. 그런데 requires_grad=True인 텐서를 건드리니 PyTorch가 이것도 영수증에 적으려 한다.
torch.no_grad()는 “여기서는 영수증 쓰지 마”라는 뜻이다. 메모리를 아끼고 속도도 빨라진다.
평가할 때도 반드시 쓴다. 평가는 학습이 아니니 미분이 필요 없다. 이걸 빼먹으면 데이터가 많을 때 메모리 부족으로 터진다.
with torch.no_grad():
예측 = model(테스트데이터)
손으로 하는 경사 하강법 한 바퀴
지금까지 배운 걸 합치면 이렇게 된다.
weight = torch.tensor([[3.0]], requires_grad=True)
bias = torch.tensor([[1.0]], requires_grad=True)
x = torch.tensor([[2.0]])
y_true = torch.tensor([[4.0]])
학습률 = 0.01
for step in range(100):
y_pred = x @ weight + bias # 1. 예측
loss = torch.mean((y_pred - y_true) ** 2) # 2. 손실
loss.backward() # 3. 기울기 계산
with torch.no_grad(): # 4. 업데이트
weight -= 학습률 * weight.grad
bias -= 학습률 * bias.grad
weight.grad.zero_() # 5. 기울기 초기화
bias.grad.zero_()
if step % 20 == 0:
print(f"step {step}: loss={loss.item():.4f}")
.item()은 텐서에서 순수 파이썬 숫자만 꺼내는 함수다. tensor(9.0) → 9.0. 출력이나 기록할 때 자주 쓴다.
다음 글에서 이 다섯 단계가 세 줄로 줄어든다. 하지만 그 세 줄이 정확히 이 코드를 하고 있다는 걸 알면 순서를 헷갈릴 일이 없다.
3. dtype 함정 — Double과 Float
이 에러는 NumPy 데이터를 PyTorch로 가져올 때 거의 반드시 한 번은 만난다.
RuntimeError: mat1 and mat2 must have the same dtype, but got Double and Float
원인이 어이없이 단순하다.
| 기본 실수 타입 | |
|---|---|
| NumPy |
float64 (= Double) |
| PyTorch |
float32 (= Float) |
기본값이 서로 다르다. 그래서 NumPy 배열을 그대로 텐서로 바꾸면 float64가 되고, PyTorch 모델의 가중치는 float32라서 충돌한다.
# ✅ 만들 때 아예 지정하기
x = torch.tensor(numpy배열, dtype=torch.float32)
# ✅ 이미 만들어진 걸 바꾸기
x = x.float() # float32로
정수는 또 다르다. 분류 문제의 정답 라벨은 long(정수)이어야 한다.
y = torch.tensor(라벨배열, dtype=torch.long)
처음엔 왜 이렇게 까다로운가 싶었는데, GPU 연산은 타입이 조금만 달라도 계산 자체가 성립하지 않는다. 파이썬이 평소에 알아서 맞춰주던 걸 여기서는 안 해준다고 생각하면 이해가 쉽다.
NumPy와 오갈 때 한 가지 더
변환 방법이 두 가지인데 동작이 다르다. 이걸 모르고 쓰다가 값이 저절로 바뀌는 걸 보고 당황한 적이 있다.
import numpy as np
arr = np.array([1.0, 2.0, 3.0])
a = torch.from_numpy(arr) # 메모리를 공유한다
b = torch.tensor(arr) # 복사한다
arr[0] = 999.0
print(a) # tensor([999., 2., 3.], dtype=torch.float64) ← 같이 바뀐다!
print(b) # tensor([1., 2., 3.], dtype=torch.float64) ← 그대로
| 함수 | 동작 | 언제 |
|---|---|---|
torch.from_numpy(arr) |
메모리 공유 — 한쪽을 바꾸면 다른 쪽도 바뀐다 | 복사 비용을 아끼고 싶을 때 |
torch.tensor(arr) |
복사 — 서로 독립 | 원본을 건드리고 싶지 않을 때 |
메모리 공유는 복사 비용이 없어서 빠르지만, 원본을 나중에 수정하면 텐서까지 조용히 바뀐다. 안전한 쪽을 기본으로 쓰고, 큰 데이터에서 성능이 아쉬울 때만 공유를 쓰는 게 낫다.
반대 방향도 주의할 게 하나 있다.
arr = 텐서.numpy() # CPU 텐서만 가능
arr = 텐서.cpu().numpy() # GPU에 있다면 CPU로 먼저 옮겨야 한다
GPU 텐서에 .numpy()를 바로 부르면 에러가 난다. NumPy는 GPU 메모리를 읽을 수 없기 때문이다.
4. nn.Module — 모델의 기본 틀
이제 모델을 만들 차례다. PyTorch에서 모델은 클래스로 만든다.
import torch.nn as nn
class 내모델(nn.Module):
def __init__(self, 입력차원, 은닉차원, 출력차원):
super().__init__()
self.층1 = nn.Linear(입력차원, 은닉차원)
self.활성화 = nn.ReLU()
self.층2 = nn.Linear(은닉차원, 출력차원)
def forward(self, x):
x = self.층1(x)
x = self.활성화(x)
x = self.층2(x)
return x
구조가 딱 두 부분이다.
| 메서드 | 역할 |
|---|---|
__init__ |
어떤 부품을 쓸지 선언 |
forward |
그 부품을 어떤 순서로 통과시킬지 |
super().__init__()은 부모 클래스인 nn.Module의 초기화를 부르는 것이다. 이게 있어야 파라미터 관리나 GPU 이동 같은 기능이 동작한다. 빼먹으면 나중에 이상한 에러가 난다.
사용할 때는 forward를 직접 부르지 않는다
model = 내모델(2, 16, 2)
y = model(x) # ✅ 이렇게
y = model.forward(x) # ❌ 이러면 안 된다 (동작은 하지만)
model(x)로 부르면 PyTorch가 앞뒤로 필요한 처리를 함께 해준다. 훅(hook) 같은 기능이 여기 걸려 있어서, forward를 직접 부르면 그게 건너뛰어진다.
5. 내가 제일 오래 헤맨 실수
forward를 쓰다가 이런 에러를 만났다.
RuntimeError: mat1 and mat2 shapes cannot be multiplied (5x2 and 16x2)
코드는 이랬다.
def forward(self, x):
self.층1(x)
self.활성화(x)
self.층2(x)
return x
문법 에러가 없어서 한참 못 찾았다. 원인은 x = 를 빼먹은 것이었다.
세탁기에 빨래를 돌렸는데, 세탁이 끝난 옷을 꺼내지 않고 “빨기 전 바구니”를 계속 들고 있는 상황이다. 세탁기는 분명 돌았는데 결과물을 안 챙긴 것이다.
모양을 따라가 보면 명확해진다. 입력이 (5, 2)이고 은닉 차원이 16이라면,
self.층1(x) # (5,2) → (5,16) 결과가 나오지만 버려짐
self.활성화(x) # 여전히 원본 (5,2)에 적용
self.층2(x) # 💥 16을 기대하는데 2가 들어옴
그리고 에러 메시지가 정확히 이걸 말하고 있었다.
-
5x2= 내가 실제로 넣은 모양 -
16x2= 층2가 기대한 모양
“순전파”라는 말 자체가 “결과를 다음 층으로 흘려보내는 것”이다. 그 흘려보내기가 바로
x =다.그 뒤로 습관을 하나 만들었다. forward의 모든 줄은
x =로 시작한다.
6. 자주 쓰는 층 네 가지
nn.Linear(입력, 출력) # 선형 변환 — 가장 기본
nn.ReLU() # 활성화 함수 — 비선형성 추가
nn.Dropout(p=0.3) # 과적합 방지
nn.Sequential(...) # 층을 한 줄로 묶기
nn.Dropout — 일부러 일부를 끄기
self.드롭아웃 = nn.Dropout(p=0.3) # 30%를 무작위로 0으로
학습할 때마다 뉴런의 일부를 무작위로 꺼버린다. 처음 봤을 때 “왜 일부러 망가뜨리지?” 싶었다.
이유는 특정 뉴런에만 의존하지 못하게 하려는 것이다. 조별 과제에서 한 명만 계속 일하면 그 사람이 빠졌을 때 조가 무너진다. 무작위로 몇 명씩 빠지게 하면 모두가 일을 배우게 된다.
nn.Sequential — 한 줄로 묶기
self.네트워크 = nn.Sequential(
nn.Linear(입력, 은닉),
nn.ReLU(),
nn.Dropout(0.3),
nn.Linear(은닉, 출력),
)
def forward(self, x):
return self.네트워크(x) # 한 줄로 끝
층을 순서대로만 통과시키면 되는 단순한 모델이라면 이게 훨씬 깔끔하다. 덤으로 x = 실수를 원천적으로 막아준다.
다만 중간에 갈라지거나 건너뛰는 구조라면 forward를 직접 써야 한다.
7. model.train()과 model.eval()
이 두 줄을 처음엔 그냥 관례인 줄 알았는데, 실제로 동작이 달라진다.
| 모드 | Dropout | 언제 |
|---|---|---|
model.train() |
켜진다 (일부를 끔) | 학습할 때 |
model.eval() |
꺼진다 (전부 사용) | 평가·추론할 때 |
평가할 때 Dropout이 켜져 있으면 똑같은 입력을 넣어도 매번 다른 답이 나온다. 실력을 재야 하는데 결과가 흔들리면 곤란하다.
model.train() # 학습 시작 전
# ... 학습 ...
model.eval() # 평가 전
with torch.no_grad():
예측 = model(X_test)
model.eval()과torch.no_grad()는 하는 일이 다르다. 이걸 헷갈렸다.
model.eval()— Dropout 같은 층의 동작 방식을 바꾼다torch.no_grad()— 미분 기록을 끈다둘 다 필요해서 평가할 때는 항상 같이 쓴다.
8. 모델이 제대로 만들어졌는지 확인하기
학습을 돌리기 전에 이 세 가지를 확인하면 시간을 아낀다.
# ① 구조 출력
print(model)
# ② 파라미터 개수
총개수 = sum(p.numel() for p in model.parameters())
print(f"파라미터 {총개수:,}개")
# ③ 더미 입력으로 미리 테스트
더미 = torch.randn(4, 입력차원) # 배치 4개짜리 가짜 데이터
print(model(더미).shape) # 기대한 모양이 나오나?
③번이 특히 유용하다. 데이터를 다 준비하고 학습을 돌린 다음에 모양 에러를 만나면 준비 시간이 통째로 날아간다. 가짜 데이터로 1초 만에 미리 확인하면 그럴 일이 없다.
정리
- 텐서는 NumPy 배열에 GPU와 자동 미분이 붙은 것
- 에러를 만나면
.shape·.dtype·.device세 줄부터 찍어본다 -
Autograd는 계산 순서를 기록해뒀다가
.backward()에서 거꾸로 읽는다-
.backward()는 숫자 하나에서만 부를 수 있다 -
.grad는 덮어쓰지 않고 더해진다 → 매번 지워야 한다 -
torch.no_grad()는 기록을 끄는 것. 업데이트와 평가에서 쓴다
-
- NumPy는 float64, PyTorch는 float32 — 기본값이 달라서 충돌한다
-
nn.Module—__init__에 부품 선언,forward에 통과 순서-
forward의 모든 줄은x =로 시작한다. 안 그러면 결과가 버려진다 - 호출은
model(x)로.model.forward(x)는 쓰지 않는다
-
-
model.train()/model.eval()은 Dropout 동작을 바꾼다.torch.no_grad()와는 역할이 다르다 - 학습 전에 더미 입력으로 모양을 미리 확인하면 시간을 아낀다
다음 글에서
다음 글에서는 학습 루프를 다룬다. zero_grad → backward → step 세 줄의 순서가 왜 그래야 하는지, 그리고 GPU를 쓰다 만나는 에러들을 어떻게 3분 만에 진단하는지 정리한다.
한줄 평
- 글을 잘읽어가며 실습을 진행하고, 이를 정리한 문서를 다시금 읽어가며 반복숙달하는게 필수..