PyTorch 첫걸음: Tensor에서 기울기가 흐르는 길 추적하기
텐서(tensor, 여러 숫자를 한 덩어리로 다루는 자료구조)를 만들고 CPU와 GPU 사이에서 옮기는 법을 익힌다. 자동미분(autograd, 계산 과정을 기록해 미분값을 자동으로 구하는 기능)의 결과를 유한차분과 비교하며 `backward`, `grad`, `detach`가 실제로 무엇을 하는지 확인한다. 마지막으로 CPU와 GPU의 계산 시간을 직접
By 송민성
오늘의 목표
PyTorch 텐서로 스칼라와 벡터 계산을 수행하고, 자동미분으로 각 입력의 기울기를 구한다. device, requires_grad, backward, .grad, zero_grad, no_grad, detach가 계산과 기울기 흐름에 미치는 영향을 코드로 확인한다.
개념과 직관
1. 텐서
텐서(tensor, 여러 숫자를 규칙적인 모양으로 담는 자료구조)는 신경망이 데이터를 주고받는 기본 단위다.
스칼라: 3.0 모양: []
벡터: [1.0, 2.0, 3.0] 모양: [3]
행렬: [[1.0, 2.0], 모양: [2, 2]
[3.0, 4.0]]파이썬 리스트와 비슷해 보이지만, 텐서는 다음 기능을 제공한다.
- 여러 숫자를 한꺼번에 계산한다.
- CPU와 GPU에서 같은 형태의 코드를 사용할 수 있다.
- 자신이 만들어진 계산 과정을 기록할 수 있다.
- 기록된 계산을 거꾸로 따라가며 미분값을 구할 수 있다.
import torch
scalar = torch.tensor(3.0)
vector = torch.tensor([1.0, 2.0, 3.0])
matrix = torch.tensor([
[1.0, 2.0],
[3.0, 4.0],
])
print(scalar.shape) # torch.Size([])
print(vector.shape) # torch.Size([3])
print(matrix.shape) # torch.Size([2, 2])
print(matrix.dtype) # 보통 torch.float32shape는 텐서의 모양, dtype은 숫자의 자료형이다. 신경망 계산에서는 주로 float32를 사용한다.
2. 장치: CPU와 GPU
장치(device, 텐서와 계산이 놓이는 하드웨어)는 주로 CPU 또는 GPU다.
CPU는 소수의 복잡한 작업에 강하다. GPU는 같은 형태의 숫자 계산을 대량으로 처리하는 데 강하다. 큰 행렬을 반복해서 곱하는 신경망 학습에서 GPU가 유리한 이유다.
CPU 메모리의 텐서 ── .to(device) ──▶ GPU 메모리의 텐서import torch
device = torch.device(
"cuda" if torch.cuda.is_available() else "cpu"
)
x = torch.tensor([1.0, 2.0, 3.0])
x_on_device = x.to(device)
print(device)
print(x.device)
print(x_on_device.device)계산에 참여하는 텐서는 같은 장치에 있어야 한다.
a = torch.tensor([1.0, 2.0], device=device)
b = torch.tensor([3.0, 4.0], device=device)
print(a + b)CPU 텐서와 GPU 텐서를 바로 더하면 장치가 다르다는 오류가 발생한다.
3. 기울기와 자동미분
기울기(gradient, 입력을 조금 바꿨을 때 출력이 얼마나 변하는지 나타내는 값)를 생각해 보자.
함수가 다음과 같다고 하자.
y = x²x가 3일 때 기울기는 6이다. x를 아주 조금 늘리면 y가 그보다 약 6배만큼 변한다는 뜻이다.
x = 3.000
y = 9.000
x = 3.001
y ≈ 9.006PyTorch의 자동미분(autograd, 텐서 연산을 기록하고 미분값을 자동 계산하는 기능)은 계산을 화살표로 연결한 계산 그래프(computation graph, 값이 어떤 연산을 거쳐 만들어졌는지 나타내는 구조)를 만든다.
x ── 제곱 ──▶ y
x²requires_grad=True는 “이 텐서까지 이어지는 계산을 기록하라”는 표시다.
import torch
x = torch.tensor(3.0, requires_grad=True)
y = x ** 2
y.backward()
print(x.grad) # tensor(6.)backward()는 출력에서 시작해 계산 그래프를 거꾸로 이동한다. 계산된 기울기는 입력 텐서의 .grad에 저장된다.
4. 벡터에서는 출력 하나가 필요하다
backward()는 기본적으로 숫자 하나인 스칼라 출력에서 시작한다.
import torch
x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)
y = (x ** 2).sum()
y.backward()
print(y) # tensor(14.)
print(x.grad) # tensor([2., 4., 6.])계산식은 다음과 같다.
y = x₁² + x₂² + x₃²각 입력의 기울기는 다음과 같다.
x₁의 기울기 = 2 × x₁
x₂의 기울기 = 2 × x₂
x₃의 기울기 = 2 × x₃벡터 출력에 바로 backward()를 호출하면 PyTorch는 어떤 출력들을 어떤 비율로 합쳐야 하는지 알 수 없다. 처음에는 .sum()이나 .mean()으로 출력 하나를 만든 뒤 호출하면 이해하기 쉽다.
5. 기울기는 누적된다
PyTorch의 .grad에는 새 기울기가 덮어써지는 대신 더해진다.
import torch
x = torch.tensor(2.0, requires_grad=True)
for step in range(3):
y = x ** 2
y.backward()
print(step, x.grad)출력:
0 tensor(4.)
1 tensor(8.)
2 tensor(12.)매번 올바른 기울기 4가 계산됐지만 .grad에 계속 더해졌다. 반복 학습에서는 다음 계산 전에 기울기를 0으로 초기화해야 한다.
import torch
x = torch.tensor(2.0, requires_grad=True)
for step in range(3):
if x.grad is not None:
x.grad.zero_()
y = x ** 2
y.backward()
print(step, x.grad)신경망 학습에서는 보통 옵티마이저(optimizer, 기울기를 이용해 모델 파라미터를 갱신하는 도구)의 optimizer.zero_grad()를 사용한다.
6. no_grad와 detach
torch.no_grad()는 코드 구간 전체에서 계산 그래프 기록을 끈다.
import torch
x = torch.tensor(2.0, requires_grad=True)
with torch.no_grad():
y = x * 3
print(y.requires_grad) # False모델 평가처럼 기울기가 필요 없는 계산에서 메모리 사용량을 줄일 때 사용한다.
detach()는 특정 텐서를 현재 계산 그래프에서 떼어 낸다.
원래 흐름: x ─▶ z ─▶ y
분리 흐름: x ─▶ z z.detach() ─▶ y
여기서 기울기 연결이 끊김값은 그대로 유지되지만, 분리된 경로를 통한 기울기는 이전 연산으로 돌아가지 않는다.
코드로 직접 해보기
1. 라이브러리 없이 유한차분 계산하기
유한차분(finite difference, 입력을 아주 조금 바꿔 출력 변화로 기울기를 근사하는 방법)은 다음 계산을 사용한다.
기울기 ≈ (f(x + h) - f(x - h)) / (2 × h)h는 0.0001처럼 작은 숫자다.
def f(x):
return x**3 + 2*x
x = 2.0
h = 0.0001
finite_diff = (f(x + h) - f(x - h)) / (2 * h)
print("유한차분:", finite_diff)
print("정확한 값:", 3 * x**2 + 2)x가 2일 때 정확한 기울기는 다음과 같다.
3 × 2² + 2 = 14유한차분 결과도 14에 가까워야 한다.
2. 자동미분 결과와 비교하기
import torch
x = torch.tensor(2.0, requires_grad=True)
y = x**3 + 2*x
y.backward()
print("자동미분:", x.grad.item())item()은 숫자 하나를 담은 텐서에서 파이썬 숫자를 꺼낸다.
전체 비교:
import torch
def python_f(x):
return x**3 + 2*x
value = 2.0
h = 0.0001
finite_diff = (
python_f(value + h) - python_f(value - h)
) / (2 * h)
x = torch.tensor(value, requires_grad=True)
y = x**3 + 2*x
y.backward()
print("유한차분:", finite_diff)
print("자동미분:", x.grad.item())
print("차이:", abs(finite_diff - x.grad.item()))두 결과가 완전히 같지 않을 수 있다. 컴퓨터가 실수를 유한한 정밀도로 저장하기 때문이다. 차이가 매우 작다면 계산이 맞다고 판단할 수 있다.
3. 벡터 함수의 기울기 확인하기
함수는 다음과 같다.
f(x) = x₁² + x₂² + x₃²먼저 각 원소의 유한차분을 구한다.
def vector_f(values):
return sum(value**2 for value in values)
values = [1.0, 2.0, 3.0]
h = 0.0001
finite_grads = []
for index in range(len(values)):
plus = values.copy()
minus = values.copy()
plus[index] += h
minus[index] -= h
grad = (
vector_f(plus) - vector_f(minus)
) / (2 * h)
finite_grads.append(grad)
print(finite_grads)PyTorch 자동미분과 비교한다.
import torch
x = torch.tensor(
[1.0, 2.0, 3.0],
requires_grad=True,
)
y = (x**2).sum()
y.backward()
print(x.grad)두 결과 모두 [2, 4, 6]에 가까워야 한다.
4. detach로 기울기 흐름 끊기
먼저 모든 경로에서 기울기가 흐르는 경우다.
import torch
x = torch.tensor(2.0, requires_grad=True)
z = x * 3
y = z**2 + z * 5
y.backward()
print(x.grad) # tensor(51.)계산 경로는 두 개다.
x ─▶ z ─▶ z² ─┐
├─▶ y
└▶ 5z ──┘이번에는 두 번째 경로를 detach()로 끊는다.
import torch
x = torch.tensor(2.0, requires_grad=True)
z = x * 3
y = z**2 + z.detach() * 5
y.backward()
print(x.grad) # tensor(36.)z.detach() * 5의 값은 최종 결과에 포함된다. 하지만 해당 경로의 기울기는 x로 돌아가지 않는다.
x ─▶ z ─▶ z² ─────────▶ y
└─ 끊김 ─▶ 5z ──▶ ydetach()는 값을 삭제하지 않는다. 기울기 연결만 끊는다.
5. no_grad로 파라미터 직접 갱신하기
다음 코드는 x를 움직여 x²을 작게 만든다.
import torch
x = torch.tensor(3.0, requires_grad=True)
learning_rate = 0.1
for step in range(5):
if x.grad is not None:
x.grad.zero_()
loss = x**2
loss.backward()
with torch.no_grad():
x -= learning_rate * x.grad
print(
step,
"x =", round(x.item(), 4),
"loss =", round(loss.item(), 4),
)파라미터 갱신까지 계산 그래프에 기록하면 다음 반복의 그래프가 불필요하게 길어진다. 그래서 갱신 구간을 torch.no_grad()로 감싼다.
오늘의 실험
같은 행렬 곱셈을 CPU와 GPU에서 실행하고 시간을 기록한다. GPU가 없다면 CPU 결과만 측정한다.
import time
import torch
def measure(device, size=1000, repeats=20):
a = torch.randn(size, size, device=device)
b = torch.randn(size, size, device=device)
# 첫 실행 준비 비용이 측정값을 흔들지 않도록 예열한다.
for _ in range(3):
_ = a @ b
if device.type == "cuda":
torch.cuda.synchronize()
start = time.perf_counter()
for _ in range(repeats):
_ = a @ b
if device.type == "cuda":
torch.cuda.synchronize()
elapsed = time.perf_counter() - start
return elapsed
cpu_time = measure(torch.device("cpu"))
print("CPU 시간:", round(cpu_time, 4), "초")
if torch.cuda.is_available():
gpu_time = measure(torch.device("cuda"))
print("GPU 시간:", round(gpu_time, 4), "초")
print("CPU/GPU 시간 비율:", round(cpu_time / gpu_time, 2))
else:
print("CUDA GPU를 사용할 수 없습니다.")다음 조건을 바꿔 표로 기록한다.
| 행렬 크기 | 반복 횟수 | CPU 시간 | GPU 시간 | 더 빠른 장치 |
|---|---|---|---|---|
| 100 | 20 | |||
| 500 | 20 | |||
| 1000 | 20 |
작은 계산에서는 GPU가 더 느릴 수 있다. GPU 작업을 준비하고 전달하는 비용이 실제 계산보다 클 수 있기 때문이다. 행렬이 커지면 병렬 계산의 이점이 나타나는지 관찰한다.
CUDA 연산은 비동기(asynchronous, CPU가 GPU 작업 완료를 기다리지 않고 다음 코드로 넘어가는 방식)로 실행될 수 있다. 정확한 시간 측정을 위해 torch.cuda.synchronize()로 GPU 작업 완료를 기다린다.
왜 이걸 배우나 (LLM / Post-training 연결)
LLM의 입력, 가중치, 출력, 손실은 모두 텐서다. 사후학습(Post-training, 이미 사전학습된 모델을 목적에 맞게 추가 학습하는 과정)에서도 손실에 backward()를 호출해 기울기를 구하고, 그 기울기로 모델 파라미터를 갱신한다.
detach와 no_grad는 기준 모델의 출력을 고정하거나 평가 단계에서 계산 그래프 생성을 막을 때 다시 등장한다. LLM Research Engineer는 기울기가 필요한 경로와 끊어야 하는 경로를 구분해야 메모리 낭비와 잘못된 학습을 피할 수 있다.
초보자가 막히는 지점
.grad가 None이다
requires_grad=True가 빠졌거나 backward()를 아직 호출하지 않았는지 확인한다.
x = torch.tensor(2.0, requires_grad=True)
y = x**2
y.backward()
print(x.grad)스칼라가 아닌 출력에서 오류가 난다
벡터 출력은 .sum()이나 .mean()으로 숫자 하나를 만든다.
x = torch.tensor([1.0, 2.0], requires_grad=True)
y = x**2
y.sum().backward()두 번째 backward()에서 오류가 난다
backward()가 끝나면 사용한 계산 그래프는 기본적으로 해제된다. 다음 계산에서는 y를 다시 만든다.
x = torch.tensor(2.0, requires_grad=True)
for _ in range(2):
if x.grad is not None:
x.grad.zero_()
y = x**2
y.backward()
print(x.grad)기울기가 예상보다 계속 커진다
.grad가 누적되고 있을 가능성이 크다. 다음 반복 전에 zero_() 또는 optimizer.zero_grad()를 호출한다.
장치가 다르다는 오류가 난다
연산에 참여하는 모든 텐서와 모델을 같은 장치로 옮긴다.
device = torch.device(
"cuda" if torch.cuda.is_available() else "cpu"
)
a = torch.tensor([1.0, 2.0]).to(device)
b = torch.tensor([3.0, 4.0]).to(device)
print(a + b)detach()와 no_grad()는 같은 기능이 아니다. detach()는 특정 텐서의 연결을 끊고, no_grad()는 코드 구간 전체의 계산 기록을 중단한다.
알고리즘 (병행 1시간)
- LeetCode Easy 733번
Flood Fill을 BFS(너비 우선 탐색, 가까운 위치부터 차례로 탐색하는 방법)로 푼다. - LeetCode Medium 200번
Number of Islands를 BFS로 푼다.
큐(queue, 먼저 넣은 값을 먼저 꺼내는 자료구조)는 파이썬 표준 라이브러리의 collections.deque를 사용한다.
from collections import deque
queue = deque([(0, 0)])
while queue:
row, col = queue.popleft()
print(row, col)각 문제를 푼 뒤 다음 세 항목을 기록한다.
- 접근: 시작점을 어떻게 찾고, 방문 여부를 어떻게 표시했는가
- 시간복잡도: 격자의 행 수를 R, 열 수를 C라고 할 때 보통 O(R × C)
- 틀린 이유: 범위 검사 누락, 방문 중복, 행과 열 혼동, 시작 칸 처리 누락 중 무엇이었는가