지금까지 우리는 모델이 어떻게 예측하고, 손실을 계산하고, 기울기를 따라 가중치를 수정하는지 배웠습니다. 앞 장에서는 Momentum, RMSProp, Adam 같은 최적화 알고리즘이 단순한 경사하강법보다 더 영리하게 파라미터를 움직인다는 것도 살펴보았습니다.
그런데 여기서 중요한 질문이 하나 남습니다. PyTorch에서 모델의 입력, 가중치, 출력, 손실, 기울기는 실제로 무엇으로 표현될까요?
답은 거의 항상 같습니다. 텐서입니다.
PyTorch 코드를 처음 보면 torch.tensor, x.shape, x.to(device), x @ W, loss.backward() 같은 표현이 계속 등장합니다. 처음에는 낯설지만, 텐서를 이해하고 나면 이 코드들이 갑자기 하나의 그림처럼 보이기 시작합니다. 데이터라는 숫자 배열이 모델 안으로 흘러 들어가고, 여러 계산을 지나 예측이 되고, 손실이 되고, 다시 기울기의 형태로 되돌아오는 그림입니다.
이번 장의 목표는 텐서를 어렵게 정의하는 것이 아닙니다. 딥러닝 코드에서 텐서가 어떤 역할을 하는지, 특히 PyTorch에서 텐서를 어떻게 읽고 다루는지 익히는 것입니다. 텐서를 이해하면 PyTorch는 더 이상 마법 상자가 아닙니다. 숫자들이 정해진 모양을 가지고 흘러가는 계산 그래프가 됩니다.

텐서는 숫자를 담는 그릇이다
딥러닝 모델은 결국 숫자로 계산합니다. 이미지는 픽셀 숫자들의 묶음이고, 소리는 파형의 숫자 배열이며, 문장은 토큰 번호나 임베딩 벡터로 바뀐 숫자 묶음입니다. 가중치와 편향도 숫자입니다. 손실도 숫자입니다.
이 숫자들을 담는 기본 그릇이 텐서입니다.
가장 단순한 숫자 하나도 텐서가 될 수 있습니다. 숫자 여러 개를 한 줄로 늘어놓아도 텐서입니다. 행과 열을 가진 표도 텐서입니다. 이미지처럼 높이, 너비, 색상 채널을 가진 자료도 텐서입니다. 여러 장의 이미지를 한 번에 묶은 배치도 텐서입니다.
수학에서는 차원에 따라 이름을 다르게 부르곤 합니다.
숫자 하나는 스칼라입니다.
숫자를 한 줄로 나열하면 벡터입니다.
행과 열이 있는 숫자 표는 행렬입니다.
축이 셋 이상인 숫자 배열은 고차원 배열입니다.
딥러닝에서는 이 모든 것을 하나의 관점으로 묶어 텐서라고 부릅니다. 스칼라도 텐서이고, 벡터도 텐서이고, 행렬도 텐서입니다. 즉 텐서는 '다차원 숫자 배열'을 부르는 통합된 이름이라고 이해하면 충분합니다.
PyTorch에서는 이렇게 만들 수 있습니다.
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, 5.0, 6.0]
])
print(scalar)
print(vector)
print(matrix)여기서 scalar, vector, matrix는 모두 PyTorch 텐서입니다. 차이는 담긴 숫자의 모양뿐입니다.
이 관점이 중요합니다. 딥러닝 모델은 입력을 특별한 물건으로 보지 않습니다. 이미지를 이미지답게, 문장을 문장답게 이해하는 것처럼 보이지만, 모델 내부에서는 모두 텐서입니다. 모델은 텐서의 숫자들을 행렬곱, 덧셈, 활성화 함수 같은 연산으로 변환해 나갑니다.
shape은 텐서의 문장이다
텐서를 볼 때 가장 먼저 확인해야 할 것은 값 자체보다 shape, 즉 모양입니다. 텐서의 shape은 각 축에 숫자가 몇 개씩 놓여 있는지를 알려 줍니다.
import torch
x0 = torch.tensor(3.0)
x1 = torch.tensor([1.0, 2.0, 3.0])
x2 = torch.tensor([
[1.0, 2.0, 3.0],
[4.0, 5.0, 6.0]
])
print(x0.shape)
print(x1.shape)
print(x2.shape)출력은 대략 다음과 같습니다.
torch.Size([])
torch.Size([3])
torch.Size([2, 3])torch.Size([])는 스칼라처럼 축이 없는 값입니다. torch.Size([3])는 길이가 3인 벡터입니다. torch.Size([2, 3])는 2행 3열의 행렬입니다.
딥러닝 코드에서 shape은 단순한 모양 정보가 아닙니다. shape은 데이터의 의미를 담습니다. 예를 들어 torch.Size([32, 10])이라는 텐서가 있다고 해 봅시다. 이것만 보면 숫자가 32행 10열로 놓여 있다는 뜻입니다. 하지만 딥러닝 문맥에서는 보통 이렇게 읽습니다.
첫 번째 축 32는 한 번에 처리하는 데이터 개수, 즉 배치 크기입니다. 두 번째 축 10은 각 데이터가 가진 특성의 개수입니다. 그러면 이 텐서는 '32개의 샘플이 있고, 각 샘플은 10개의 숫자 특성으로 표현된다'고 해석할 수 있습니다.
shape은 딥러닝 모델이 데이터를 읽는 방식입니다. 같은 숫자라도 shape이 달라지면 의미가 완전히 달라집니다.
배치, 특성, 채널, 시퀀스
딥러닝에서 shape을 읽는 능력은 매우 중요합니다. 모델이 원하는 shape과 실제 데이터의 shape이 맞지 않으면 계산이 멈춥니다. 더 무서운 경우도 있습니다. 코드가 멈추지는 않지만, 우리가 의도한 것과 다른 방향으로 계산될 수도 있습니다.
자주 만나는 shape을 먼저 익혀 둡시다.
표 형태 데이터나 MLP 입력은 보통
[batch, features]입니다.PyTorch 이미지 데이터는 보통
[batch, channels, height, width]입니다.텍스트 토큰 번호는 보통
[batch, sequence_length]입니다.텍스트 임베딩은 보통
[batch, sequence_length, embedding_dim]입니다.Attention 점수는 나중에
[batch, heads, sequence_length, sequence_length]같은 모양으로 등장합니다.
예를 들어 손글씨 숫자 이미지가 28×28 크기의 흑백 이미지라고 해 봅시다. 이미지 한 장은 채널이 1개, 높이가 28, 너비가 28이므로 [1, 28, 28]로 표현할 수 있습니다. 그런데 학습할 때는 보통 이미지를 한 장씩 넣지 않고 여러 장을 묶어 넣습니다. 64장을 한 번에 넣는다면 shape은 [64, 1, 28, 28]이 됩니다.
여기서 첫 번째 64는 배치 크기입니다. 두 번째 1은 채널 수입니다. 흑백 이미지는 채널이 1개이고, RGB 컬러 이미지는 보통 채널이 3개입니다. 세 번째와 네 번째는 높이와 너비입니다.
텍스트에서는 또 다른 모양이 등장합니다. 문장 8개를 한 번에 처리하고, 각 문장을 20개의 토큰으로 맞췄다고 해 봅시다. 그러면 토큰 번호 텐서는 [8, 20]이 될 수 있습니다. 이후 각 토큰을 128차원 임베딩 벡터로 바꾸면 shape은 [8, 20, 128]이 됩니다. 이때 8은 문장 개수, 20은 문장 안의 토큰 위치, 128은 각 토큰을 표현하는 숫자 벡터의 길이입니다.
이 감각은 뒤에서 Transformer를 배울 때 아주 중요해집니다. Transformer는 문장을 그냥 문자열로 처리하지 않습니다. 토큰들이 임베딩 벡터로 바뀌고, 그 텐서가 Attention 계산을 지나며 계속 변형됩니다. 결국 LLM도 거대한 텐서 계산의 연속입니다.
텐서 만들기와 기본 속성 살피기
PyTorch에서는 여러 방식으로 텐서를 만들 수 있습니다. 직접 값을 넣을 수도 있고, 0으로 채우거나, 1로 채우거나, 무작위 값으로 만들 수도 있습니다.
import torch
a = torch.tensor([1, 2, 3])
b = torch.zeros(2, 3)
c = torch.ones(2, 3)
d = torch.randn(2, 3)
e = torch.arange(0, 10)
print(a)
print(b)
print(c)
print(d)
print(e)torch.zeros(2, 3)은 2행 3열을 0으로 채운 텐서를 만듭니다. torch.ones(2, 3)은 1로 채웁니다. torch.randn(2, 3)은 정규분포에서 뽑은 무작위 숫자로 텐서를 만듭니다. 신경망의 가중치를 처음에 무작위로 초기화하는 이유를 떠올려 보면, 이런 함수들이 왜 자주 쓰이는지 이해할 수 있습니다.
텐서를 만들었다면 보통 세 가지를 확인합니다. shape, dtype, device입니다.
x = torch.randn(4, 5)
print(x.shape)
print(x.dtype)
print(x.device)shape은 텐서의 모양입니다. dtype은 숫자의 자료형입니다. 예를 들어 torch.float32는 소수점 계산에 자주 쓰이고, torch.int64는 클래스 라벨이나 토큰 번호처럼 정수값을 담을 때 자주 쓰입니다. device는 텐서가 어디에 놓여 있는지를 뜻합니다. 기본적으로는 CPU에 놓이지만, GPU를 사용할 때는 CUDA 장치에 놓일 수 있습니다.
딥러닝에서는 대부분의 모델 입력과 가중치가 float32 같은 실수형 텐서입니다. 반면 분류 문제의 정답 라벨은 정수형인 경우가 많습니다. 예를 들어 손글씨 숫자 분류에서 이미지 텐서는 실수형이고, 정답 라벨 0부터 9까지는 정수형입니다.
자료형이 맞지 않으면 PyTorch가 오류를 내기도 합니다. 모델 입력으로 정수 텐서를 넣었는데 모델이 실수 계산을 기대하면 문제가 생길 수 있습니다. 반대로 손실 함수가 정수 라벨을 기대하는데 라벨을 실수형으로 바꾸면 또 문제가 생깁니다. 텐서의 값만큼이나 dtype도 중요합니다.
인덱싱: 텐서에서 원하는 조각 꺼내기
텐서는 숫자 묶음이므로 필요한 부분만 꺼내야 할 때가 많습니다. 이를 인덱싱이라고 합니다.
먼저 2차원 텐서를 하나 만들어 봅시다.
x = torch.tensor([
[10, 11, 12],
[20, 21, 22],
[30, 31, 32],
[40, 41, 42]
])
print(x.shape)이 텐서의 shape은 [4, 3]입니다. 딥러닝 문맥으로 읽으면 4개의 샘플이 있고, 각 샘플은 3개의 특성을 가진다고 볼 수 있습니다.
첫 번째 샘플만 꺼내려면 이렇게 씁니다.
print(x[0])결과는 [10, 11, 12]입니다. 이번에는 모든 샘플에서 첫 번째 특성만 꺼내 봅시다.
print(x[:, 0])여기서 :는 해당 축의 모든 원소를 뜻합니다. 따라서 x[:, 0]은 '모든 행에서 0번째 열을 꺼내라'는 뜻입니다. 결과는 [10, 20, 30, 40]입니다.
이 차이는 매우 중요합니다. x[0]은 첫 번째 데이터 하나를 꺼내는 것이고, x[:, 0]은 모든 데이터의 첫 번째 특성을 꺼내는 것입니다. 초보자들이 shape 오류를 만날 때 상당수는 이 두 감각이 아직 익숙하지 않아서 생깁니다.
이미지 텐서에서는 인덱싱이 조금 더 입체적으로 보입니다.
images = torch.randn(64, 3, 32, 32)
first_image = images[0]
red_channel = images[:, 0, :, :]
small_patch = images[:, :, 0:16, 0:16]
print(first_image.shape)
print(red_channel.shape)
print(small_patch.shape)images의 shape은 [64, 3, 32, 32]입니다. images[0]은 배치에서 첫 번째 이미지 하나를 꺼내므로 shape이 [3, 32, 32]가 됩니다. images[:, 0, :, :]는 모든 이미지에서 0번째 채널만 꺼냅니다. RGB 이미지라면 보통 첫 번째 채널을 빨강 채널로 생각할 수 있습니다. images[:, :, 0:16, 0:16]는 모든 이미지와 모든 채널에 대해 왼쪽 위 16×16 영역만 잘라냅니다.
처음에는 괄호와 콜론이 복잡해 보이지만, 축을 하나씩 읽으면 어렵지 않습니다. 텐서 인덱싱은 '몇 번째 축에서 무엇을 가져올 것인가'를 쓰는 언어입니다.
reshape: 같은 숫자를 다른 모양으로 보기
텐서의 값은 그대로 두고 모양만 바꿔야 할 때가 많습니다. 대표적인 예가 이미지를 MLP에 넣을 때입니다.
손글씨 숫자 이미지 한 장은 [1, 28, 28] 모양일 수 있습니다. 하지만 MLP는 보통 [features] 형태의 벡터를 입력으로 받습니다. 그러면 1×28×28개의 픽셀을 길이 784짜리 벡터로 펼쳐야 합니다.
배치까지 포함하면 [64, 1, 28, 28]을 [64, 784]로 바꾸는 식입니다.
images = torch.randn(64, 1, 28, 28)
flat = images.reshape(64, 784)
print(flat.shape)여기서 중요한 점은 전체 원소 수가 같아야 한다는 것입니다. 원래 텐서의 원소 수는 64 * 1 * 28 * 28입니다. 바꾼 텐서의 원소 수는 64 * 784입니다. 둘 다 같기 때문에 reshape이 가능합니다.
PyTorch에서는 -1을 사용해 나머지 차원을 자동으로 계산하게 할 수 있습니다.
flat = images.reshape(64, -1)
print(flat.shape)64는 배치 크기로 유지하고, 나머지 차원은 알아서 곱해 하나의 축으로 만들라는 뜻입니다. 실제 모델 코드에서는 배치 크기가 매번 달라질 수 있으므로 다음처럼 쓰는 경우도 많습니다.
batch_size = images.shape[0]
flat = images.reshape(batch_size, -1)이 패턴은 앞으로 자주 보게 됩니다. 첫 번째 축인 배치 차원은 그대로 두고, 나머지 축을 펼쳐서 특성 벡터로 만드는 것입니다.
비슷한 함수로 view도 있습니다. 많은 경우 reshape과 비슷하게 쓸 수 있지만, 텐서가 메모리에 연속적으로 놓여 있지 않은 경우 view는 실패할 수 있습니다. 처음에는 reshape을 더 편하게 사용해도 됩니다. 나중에 성능과 메모리 구조를 깊게 다룰 때 view, contiguous, permute의 차이를 더 자세히 이해하면 됩니다.
차원을 추가하고 줄이기
딥러닝 코드를 쓰다 보면 차원이 하나 모자라거나 하나 더 많아서 오류가 나는 경우가 많습니다. 예를 들어 이미지 한 장의 shape이 [1, 28, 28]인데 모델은 배치 차원을 포함한 [batch, channels, height, width]를 기대할 수 있습니다. 이때 이미지 한 장을 배치 크기 1짜리 텐서로 바꿔야 합니다.
image = torch.randn(1, 28, 28)
image_batch = image.unsqueeze(0)
print(image.shape)
print(image_batch.shape)unsqueeze(0)은 0번째 위치에 길이 1짜리 차원을 추가합니다. 그래서 [1, 28, 28]이 [1, 1, 28, 28]이 됩니다. 앞의 1은 배치 크기이고, 두 번째 1은 채널 수입니다.
반대로 길이가 1인 차원을 제거할 수도 있습니다.
x = torch.randn(1, 1, 28, 28)
y = x.squeeze()
print(y.shape)squeeze()는 길이가 1인 차원을 모두 제거합니다. 다만 모든 길이 1 차원을 제거하면 의도하지 않은 차원까지 사라질 수 있습니다. 특정 위치의 차원만 제거하고 싶다면 squeeze(0)처럼 축을 지정하는 편이 안전합니다.
차원의 순서를 바꿔야 할 때도 있습니다. 예를 들어 어떤 이미지 데이터가 [batch, height, width, channels] 모양으로 들어왔는데 PyTorch의 CNN은 보통 [batch, channels, height, width]를 기대합니다. 이때 permute를 사용합니다.
x = torch.randn(64, 32, 32, 3)
y = x.permute(0, 3, 1, 2)
print(y.shape)permute(0, 3, 1, 2)는 기존 축의 순서를 새롭게 배치합니다. 기존 0번 축은 그대로 첫 번째에 두고, 기존 3번 축인 채널을 두 번째로 가져오고, 기존 1번과 2번 축인 높이와 너비를 뒤에 둡니다. 결과 shape은 [64, 3, 32, 32]입니다.
이처럼 reshape, unsqueeze, squeeze, permute는 텐서의 모양을 맞추는 도구입니다. 딥러닝을 하다 보면 모델 자체를 바꾸는 시간보다 텐서의 모양을 맞추는 시간이 더 길게 느껴질 때도 있습니다. 하지만 이것은 이상한 일이 아닙니다. shape을 맞춘다는 것은 모델이 데이터를 올바른 의미로 읽도록 통역하는 일입니다.
브로드캐스팅: 작은 텐서를 큰 계산에 맞추기
딥러닝 계산에서는 크기가 다른 텐서끼리 연산하는 일이 자주 있습니다. 예를 들어 [4, 3] 모양의 입력 텐서가 있고, 여기에 [3] 모양의 편향 벡터를 더한다고 해 봅시다.
x = torch.tensor([
[1.0, 2.0, 3.0],
[4.0, 5.0, 6.0],
[7.0, 8.0, 9.0],
[10.0, 11.0, 12.0]
])
bias = torch.tensor([0.1, 0.2, 0.3])
y = x + bias
print(y)x는 4개의 샘플과 3개의 특성을 가진 텐서입니다. bias는 특성 3개에 각각 더할 값입니다. PyTorch는 bias를 각 행에 반복해서 더해 줍니다. 실제로 메모리에 네 번 복사한다고 생각할 필요는 없습니다. 계산 규칙상 그렇게 확장된 것처럼 처리합니다. 이것을 브로드캐스팅이라고 합니다.
브로드캐스팅은 신경망에서 매우 자주 쓰입니다. 선형층의 편향을 배치 전체에 더할 때, 각 특성의 평균을 빼서 정규화할 때, Attention 마스크를 점수 행렬에 더할 때도 비슷한 아이디어가 쓰입니다.
브로드캐스팅의 기본 감각은 간단합니다. 뒤쪽 차원부터 비교했을 때 크기가 같거나, 둘 중 하나가 1이면 연산이 가능합니다.
x = torch.randn(4, 3)
mean = torch.randn(3)
centered = x - mean
print(centered.shape)여기서 x는 [4, 3]이고 mean은 [3]입니다. 뒤쪽 차원 3이 같으므로 mean이 배치 방향으로 확장되어 각 행에서 빠집니다.
반면 다음과 같은 연산은 되지 않습니다.
x = torch.randn(4, 3)
y = torch.randn(4)
# z = x + y # shape이 맞지 않아 오류가 납니다[4, 3]과 [4]를 뒤쪽 차원부터 비교하면 3과 4가 맞지 않습니다. 그래서 PyTorch는 어떤 방식으로 확장해야 할지 알 수 없습니다. 만약 각 행에 하나씩 값을 더하고 싶다면 [4, 1] 모양으로 바꿔야 합니다.
y = torch.randn(4)
y = y.unsqueeze(1)
z = x + y
print(z.shape)이제 y는 [4, 1]입니다. 뒤쪽 차원 1은 3으로 확장될 수 있고, 앞쪽 차원 4는 같습니다. 그래서 연산이 가능합니다.
브로드캐스팅은 편리하지만 조심해야 합니다. shape을 잘못 이해한 상태에서도 연산이 성공할 수 있기 때문입니다. 계산이 되었다고 해서 의미가 맞는 것은 아닙니다. 딥러닝 코드에서는 print(x.shape) 한 줄이 오랜 디버깅 시간을 줄여 줄 때가 많습니다.
행렬곱: 신경망 계산의 심장
이제 텐서 연산 중 가장 중요한 것 중 하나를 보겠습니다. 바로 행렬곱입니다.
앞에서 뉴런을 배울 때 우리는 입력에 가중치를 곱하고 편향을 더한다고 했습니다. 입력 특성이 3개이고 출력 뉴런이 2개인 선형층을 생각해 봅시다. 배치 크기는 4라고 하겠습니다.
입력 x의 shape은 [4, 3]입니다. 4개의 샘플, 각 샘플은 3개의 특성을 가집니다. 가중치 W의 shape은 [3, 2]입니다. 입력 특성 3개를 출력 2개로 바꾸는 역할입니다. 편향 b의 shape은 [2]입니다.
x = torch.randn(4, 3)
W = torch.randn(3, 2)
b = torch.randn(2)
y = x @ W + b
print(y.shape)출력 y의 shape은 [4, 2]입니다. 4개의 샘플 각각에 대해 출력값 2개가 생긴 것입니다.
이 한 줄이 신경망의 핵심 계산입니다.
y = x @ W + b@는 행렬곱 연산자입니다. x @ W는 입력과 가중치를 곱합니다. 그 결과에 편향 b를 더합니다. 이때 b는 [2] 모양이지만 브로드캐스팅되어 4개의 샘플 모두에 더해집니다.
이 계산을 앞에서 배운 뉴런의 관점으로 다시 읽어 봅시다. W의 각 열은 하나의 출력 뉴런이 입력 특성 3개를 어떻게 조합할지 담고 있습니다. 첫 번째 출력 뉴런은 입력 3개에 자기 가중치 3개를 곱해 더합니다. 두 번째 출력 뉴런도 자기 가중치 3개를 사용합니다. 이렇게 해서 각 샘플은 2개의 출력값으로 바뀝니다.
PyTorch의 nn.Linear도 본질적으로 같은 일을 합니다. 다만 PyTorch의 선형층은 가중치를 보통 [out_features, in_features] 모양으로 저장합니다. 그래서 내부적으로는 입력에 가중치의 전치 행렬을 곱하는 형태가 됩니다.
import torch
import torch.nn as nn
layer = nn.Linear(in_features=3, out_features=2)
x = torch.randn(4, 3)
y = layer(x)
print(layer.weight.shape)
print(layer.bias.shape)
print(y.shape)여기서 layer.weight.shape은 [2, 3]입니다. 출력 특성 2개, 입력 특성 3개라는 뜻입니다. 하지만 입력 x는 [4, 3]이고 출력 y는 [4, 2]입니다. 따라서 내부 계산은 개념적으로 x @ layer.weight.T + layer.bias에 가깝습니다.
초보 단계에서는 이 차이 때문에 헷갈릴 수 있습니다. 직접 손으로 행렬곱을 만들 때는 [in_features, out_features] 모양의 W를 쓰면 이해하기 쉽습니다. PyTorch의 nn.Linear를 사용할 때는 가중치가 [out_features, in_features]로 저장된다는 점만 기억하면 됩니다.
텐서는 계산 그래프를 따라 흐른다
텐서는 단순한 숫자 배열이지만, PyTorch에서는 그 이상의 역할을 합니다. 텐서에 연산을 적용하면 새로운 텐서가 만들어집니다. 그 텐서에 또 연산을 적용하면 다시 새로운 텐서가 만들어집니다. 이렇게 텐서들이 연산을 따라 이어지면 하나의 계산 흐름이 생깁니다.
x = torch.randn(4, 3)
W = torch.randn(3, 2)
b = torch.randn(2)
y = x @ W + b
loss = y.mean()
print(y.shape)
print(loss.shape)x, W, b가 만나 y가 되고, y가 평균 연산을 거쳐 loss가 됩니다. loss는 스칼라 텐서입니다. shape은 []입니다.
앞 장들에서 우리는 학습 과정을 이렇게 설명했습니다.
모델이 예측합니다. 예측이 얼마나 틀렸는지 손실로 계산합니다. 어느 가중치가 오차에 얼마나 영향을 주었는지 봅니다. 가중치를 조금 수정합니다. 이 과정을 반복합니다.
PyTorch 코드에서는 이 모든 과정이 텐서 계산으로 표현됩니다. 입력도 텐서이고, 가중치도 텐서이고, 예측도 텐서이고, 손실도 텐서입니다. 다음 장에서 배울 자동 미분은 이 텐서 연산의 흐름을 추적해 기울기를 계산하는 기능입니다.
즉 텐서를 이해하는 것은 단순히 자료구조 하나를 배우는 일이 아닙니다. 앞으로 나올 loss.backward()와 optimizer의 동작을 이해하기 위한 바닥을 다지는 일입니다. 역전파는 추상적인 마법이 아니라, 텐서 연산들이 만든 계산 그래프를 거꾸로 따라가며 기울기를 계산하는 과정입니다.
CPU 텐서와 GPU 텐서
딥러닝에서는 계산량이 매우 많습니다. 특히 행렬곱이 많습니다. CPU도 계산을 할 수 있지만, GPU는 많은 숫자 계산을 동시에 처리하는 데 강합니다. 그래서 큰 신경망을 학습할 때 GPU를 사용하면 속도가 크게 빨라질 수 있습니다.
PyTorch 텐서는 어느 장치에 놓여 있는지를 알고 있습니다.
x = torch.randn(2, 3)
print(x.device)일반적으로 기본 텐서는 CPU에 만들어집니다. GPU를 사용하려면 텐서를 GPU로 옮겨야 합니다.
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
x = torch.randn(2, 3)
x = x.to(device)
print(x.device)torch.cuda.is_available()는 현재 환경에서 CUDA GPU를 사용할 수 있는지 확인합니다. 사용할 수 있다면 device는 CUDA가 되고, 그렇지 않으면 CPU가 됩니다. 이렇게 작성하면 GPU가 있는 환경과 없는 환경에서 같은 코드를 비교적 안전하게 실행할 수 있습니다.
중요한 규칙이 하나 있습니다. 같이 계산하는 텐서들은 같은 장치에 있어야 합니다. CPU에 있는 텐서와 GPU에 있는 텐서를 바로 더하거나 행렬곱할 수 없습니다.
x = torch.randn(2, 3).to(device)
W = torch.randn(3, 4).to(device)
y = x @ W위 코드에서 x와 W가 같은 장치에 있으면 계산이 됩니다. 하지만 하나는 CPU에 있고 다른 하나는 GPU에 있으면 오류가 납니다.
모델을 사용할 때도 마찬가지입니다.
import torch.nn as nn
model = nn.Linear(3, 2)
model = model.to(device)
x = torch.randn(4, 3).to(device)
y = model(x)모델의 파라미터도 텐서입니다. 따라서 입력 텐서를 GPU로 옮겼다면 모델도 GPU로 옮겨야 합니다. 반대로 모델이 CPU에 있는데 입력만 GPU에 있으면 계산할 수 없습니다.
장치 이동은 공짜가 아닙니다. CPU와 GPU 사이로 데이터를 옮기는 데도 시간이 듭니다. 그래서 학습할 때는 보통 한 배치의 입력과 정답을 GPU로 옮긴 뒤, 모델도 GPU에 둔 상태로 계산합니다. 매 연산마다 CPU와 GPU를 왔다 갔다 하면 오히려 느려질 수 있습니다.
작은 신경망 계산을 텐서로 따라가기
이제 텐서 관점으로 아주 작은 분류 계산을 따라가 봅시다. 입력 특성이 3개인 샘플 5개가 있고, 각 샘플을 2개 클래스 중 하나로 분류한다고 하겠습니다.
import torch
torch.manual_seed(0)
x = torch.randn(5, 3)
W = torch.randn(3, 2)
b = torch.randn(2)
logits = x @ W + b
probs = torch.softmax(logits, dim=1)
target = torch.tensor([0, 1, 1, 0, 1])
loss_each = -torch.log(probs[torch.arange(5), target])
loss = loss_each.mean()
print('x shape:', x.shape)
print('logits shape:', logits.shape)
print('probs shape:', probs.shape)
print('loss_each shape:', loss_each.shape)
print('loss shape:', loss.shape)
print('loss:', loss)코드를 shape 중심으로 읽어 봅시다.
x는 [5, 3]입니다. 5개의 샘플이 있고, 각 샘플은 3개의 특성을 가집니다. W는 [3, 2]입니다. 입력 특성 3개를 클래스 점수 2개로 바꿉니다. b는 [2]이고, 브로드캐스팅되어 모든 샘플에 더해집니다.
logits는 [5, 2]입니다. 각 샘플마다 클래스 2개에 대한 점수가 있습니다. 아직 확률은 아닙니다. torch.softmax(logits, dim=1)은 각 샘플 안에서 클래스 점수들을 확률처럼 바꿉니다. 여기서 dim=1이 중요합니다. 1번 축은 클래스 축입니다. 우리는 각 샘플마다 클래스 방향으로 softmax를 적용하고 싶습니다.
target은 [5]입니다. 각 샘플의 정답 클래스 번호를 담고 있습니다. probs[torch.arange(5), target]은 각 샘플에서 정답 클래스에 해당하는 확률만 뽑아옵니다. 그 확률에 로그를 취하고 음수로 바꾸면 정답 확률이 낮을수록 큰 손실이 됩니다. 마지막으로 평균을 내면 loss가 됩니다.
여기서 주목할 점은 모든 것이 텐서라는 사실입니다. 입력도 텐서, 가중치도 텐서, 점수도 텐서, 확률도 텐서, 손실도 텐서입니다. 모델 학습은 이 텐서들의 흐름을 반복하는 일입니다.
다음 장에서 자동 미분을 배우면 여기에 한 줄이 더해집니다.
loss.backward()이 한 줄은 loss가 만들어진 계산 경로를 거꾸로 따라가며 W와 b가 손실에 얼마나 영향을 주었는지 계산합니다. 하지만 그 출발점은 오늘 배운 텐서와 텐서 연산입니다.
자주 만나는 shape 오류
PyTorch를 배우는 동안 shape 오류는 거의 반드시 만납니다. 오류를 만난다는 것은 실패했다는 뜻이 아닙니다. 텐서의 언어에 익숙해지는 과정입니다. 특히 다음 실수들이 자주 등장합니다.
배치 차원을 빼먹습니다. 이미지 한 장
[1, 28, 28]을 모델에 넣어야 하는데 모델은[batch, 1, 28, 28]을 기대할 수 있습니다.행렬곱의 안쪽 차원이 맞지 않습니다.
[4, 3] @ [2, 5]는 3과 2가 맞지 않아 계산할 수 없습니다.클래스 축을 잘못 지정합니다. softmax나 손실 계산에서
dim을 잘못 쓰면 샘플 방향으로 확률을 만들 수도 있습니다.CPU 텐서와 GPU 텐서를 섞습니다. 입력은 GPU에 있고 모델은 CPU에 있으면 계산할 수 없습니다.
라벨 dtype이 맞지 않습니다. 분류 손실 함수는 정수형 클래스 라벨을 기대하는 경우가 많습니다.
이런 오류를 줄이는 가장 좋은 습관은 중간중간 shape을 출력하는 것입니다.
print(x.shape)
print(logits.shape)
print(target.shape)복잡한 모델을 만들 때도 원리는 같습니다. 입력이 들어왔을 때 shape이 어떻게 바뀌는지 한 단계씩 확인하면 됩니다. 좋은 딥러닝 개발자는 머릿속으로 텐서의 shape을 따라갑니다. 코드가 실행되기 전에 대략 어떤 모양이 나와야 하는지 예상합니다.
텐서를 읽으면 모델이 보인다
처음 딥러닝 코드를 보면 복잡한 함수와 클래스가 먼저 눈에 들어옵니다. 하지만 조금만 익숙해지면 그 아래에서 흐르는 것은 텐서라는 사실을 보게 됩니다.
MLP는 [batch, features] 텐서를 여러 선형층과 활성화 함수를 거쳐 다른 텐서로 바꿉니다. CNN은 [batch, channels, height, width] 텐서에서 지역적인 패턴을 뽑아냅니다. RNN은 순서가 있는 텐서를 시간 방향으로 처리합니다. Transformer는 [batch, sequence_length, embedding_dim] 텐서에서 토큰 사이의 관계를 Attention으로 계산합니다.
구조는 달라도 기본 질문은 비슷합니다.
이 텐서의 shape은 무엇인가? 각 축은 무엇을 의미하는가? 어떤 연산을 지나면서 shape이 어떻게 바뀌는가? 그 결과가 모델의 예측과 손실로 어떻게 이어지는가?
이 질문에 답할 수 있으면 PyTorch 코드는 훨씬 덜 무섭습니다. 모델은 갑자기 튀어나온 블랙박스가 아니라, 숫자 배열을 받아 다른 숫자 배열로 바꾸는 계산 흐름이 됩니다.
이번 장에서 배운 텐서는 앞으로 우리가 사용할 모든 PyTorch 코드의 기본 단위입니다. 다음 장에서는 이 텐서들이 단순히 값을 담는 데서 끝나지 않고, 계산 과정을 기억하고 기울기를 자동으로 계산하는 방법을 배웁니다. 그 기능이 바로 PyTorch의 자동 미분입니다. 텐서가 딥러닝의 재료라면, 자동 미분은 그 재료들이 어떤 경로로 손실을 만들었는지 추적하는 지도입니다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.