포스트

PyTorch 기초 (1) - 텐서 기본 문법

텐서 생성부터 속성과 dtype, shape 변경, indexing, 조건 선택, Broadcasting, dim 집계, 결합, 정렬, NumPy 상호 변환, device까지 PyTorch 텐서의 기본 문법을 정리했습니다.

PyTorch 기초 (1) - 텐서 기본 문법

PyTorch 기초 시리즈(6편)의 1편입니다. 전체 목차는 0편에 있습니다. 이 편은 PyTorch의 기본 자료구조인 텐서의 문법을, 이미 익숙한 NumPy 기초 (2)의 ndarray 문법과 견주며 정리합니다.

내용
텐서 생성torch.tensor, arange/linspace, zeros/ones/full, 난수와 시드
텐서의 속성shape/ndim/numel, dtype/element_size, device/requires_grad
dtype과 형변환float32 기본값, float()/long()/to()
텐서의 shape 변경reshapeview, contiguous, squeeze/unsqueeze, permute
indexing과 slicing기본 indexing, fancy indexing, slicing, view와 clone
Conditional selectionboolean 마스크, & | ~ 결합, where, clamp
텐서 연산elementwise 연산, Broadcasting, *@, in-place 연산
Aggregation과 dimsum/mean/max, dimkeepdim, argmax, item
텐서 mergecat, stack
Sortsort, argsort, topk
NumPy와의 상호 변환from_numpy, numpy, 메모리 공유, float64 함정
deviceCPU와 GPU, to, 같은 device 규칙

텐서란 무엇인가

NumPy를 아는 사람에게 텐서를 설명하는 가장 빠른 방법은 이렇습니다. 텐서는 NumPy의 ndarray에 두 가지가 더해진 것입니다. 하나는 GPU에서 연산할 수 있다는 것이고, 다른 하나는 연산 과정을 기록해 자동으로 미분할 수 있다는 것(다음 편의 autograd)입니다. 그 둘을 빼면, 텐서를 다루는 문법은 NumPy와 거의 같습니다.

그래서 이 편의 상당 부분은 “NumPy와 같다”입니다. 같은 것은 짧게 넘기고, 이름이 바뀐 것과 동작이 다른 것에 분량을 씁니다. 아래 다섯 가지가 이 편에서 실제로 새로 익힐 지점입니다.

NumPyPyTorch무엇이 다른가
axisdim이름만 다름
float64가 실수 기본값float32가 실수 기본값딥러닝의 기본 정밀도
reshapereshapeviewview는 연속 메모리에서만 동작
없음device텐서가 CPU와 GPU 중 어디에 놓이는가
없음requires_grad이 텐서를 미분 대상으로 추적하는가

실습 환경 준비는 0편에 있습니다. 아래 코드는 모두 import torch 를 전제로 합니다.

텐서 생성

생성 함수는 NumPy와 이름까지 거의 같습니다. 쓰임에 따라 네 가지로 나뉩니다. 1. 기존 데이터를 텐서로 바꿀 때, 2. 수열을 만들 때, 3. 초기화된 텐서를 만들 때, 4. 난수를 만들 때입니다.

torch.tensor

이미 있는 데이터(list, tuple, ndarray)를 텐서로 바꿉니다.

1
2
3
4
5
import torch

torch.tensor([1, 2, 3])                        # tensor([1, 2, 3])
torch.tensor([[1, 2], [3, 4]])                 # 중첩 list는 2차원 텐서로
torch.tensor([1, 2, 3], dtype=torch.float32)   # dtype을 지정해서 생성

이름이 비슷한 torch.Tensor(대문자 T)도 있지만 쓰지 않습니다. torch.Tensor(3)은 값이 3인 텐서가 아니라 길이 3짜리 초기화되지 않은 텐서를 만듭니다. 데이터를 텐서로 만들 때는 항상 소문자 torch.tensor를 씁니다.

torch.arange, torch.linspace

규칙적인 수열을 만듭니다. NumPy와 규칙이 같습니다.

1
2
torch.arange(0, 10, 2)     # tensor([0, 2, 4, 6, 8]), 시작/끝(미포함)/간격
torch.linspace(0, 10, 5)   # tensor([0.0, 2.5, 5.0, 7.5, 10.0]), 시작/끝(포함)/개수

arange는 간격을 기준으로 세며 끝을 포함하지 않고, linspace는 개수를 기준으로 세며 끝을 포함합니다. 그래서 arange의 결과는 정수(int64), linspace의 결과는 실수(float32)입니다.

torch.zeros, torch.ones, torch.full

초기화된 텐서를 만듭니다. NumPy는 shape를 튜플 하나로 받지만, PyTorch는 크기를 그냥 나열해도 받습니다.

1
2
3
4
torch.zeros(2, 3)        # 0으로 채운 (2, 3), 튜플로 감싸지 않아도 된다
torch.ones(2, 3)         # 1로 채운 (2, 3)
torch.full((2, 2), -1)   # 지정값으로 채움, 이쪽은 shape를 튜플로 받는다
torch.eye(3)             # 단위행렬 (3, 3)

기존 텐서와 같은 shape, dtype, device의 텐서가 필요할 때는 _like 계열을 씁니다. 모양을 손으로 다시 적지 않아도 되고, device까지 따라오므로 GPU 학습 코드에서 특히 편합니다.

1
2
3
4
m = torch.arange(6).reshape(2, 3)

torch.zeros_like(m)   # m과 같은 shape, dtype, device의 0 텐서
torch.ones_like(m)

난수와 시드

난수 텐서는 분포에 따라 함수가 나뉩니다.

1
2
3
torch.rand(3)               # 0~1 균등분포 실수 3개
torch.randn(2, 3)           # 표준정규분포 (2, 3)
torch.randint(0, 10, (5,))  # 0~9 정수 5개, shape는 튜플로

재현성을 위한 시드 고정은 torch.manual_seed입니다. NumPy가 default_rng(시드)로 생성기 객체를 만드는 것과 달리, PyTorch는 전역 시드를 고정하는 방식이 기본입니다.

1
2
3
4
5
6
7
torch.manual_seed(42)
torch.randn(2, 3)
# tensor([[ 0.3367,  0.1288,  0.2345],
#         [ 0.2303, -1.1229, -0.1863]])

torch.manual_seed(42)
torch.randn(2, 3)   # 같은 값이 다시 나온다

가중치 초기화와 데이터 셔플이 모두 난수를 쓰기 때문에, 시드를 고정하지 않으면 같은 코드가 매번 다른 결과를 냅니다. 학습 전체의 재현성을 맞추는 방법은 (5)편에서 따로 다룹니다.

텐서의 속성

텐서는 자기 자신에 대해 다음 다섯 가지를 알고 있습니다. 앞의 세 개는 NumPy에도 있고, 뒤의 두 개가 PyTorch에만 있습니다.

1
2
3
4
5
6
7
8
t = torch.arange(12).reshape(3, 4)

t.shape           # torch.Size([3, 4]), 각 차원의 크기
t.ndim            # 2, 차원 수
t.numel()         # 12, 전체 원소 수 (NumPy의 size)
t.dtype           # torch.int64, 원소의 자료형
t.device          # cpu, 텐서가 놓인 장치
t.requires_grad   # False, 미분 추적 대상인지

이름이 바뀐 것이 하나 있습니다. NumPy의 arr.size(전체 원소 수)에 해당하는 것이 PyTorch에서는 t.numel() 입니다. PyTorch에서 t.size()는 원소 수가 아니라 shape를 돌려주므로, NumPy 습관대로 읽으면 값을 잘못 해석하게 됩니다.

1
2
t.size()     # torch.Size([3, 4]), shape와 같다
t.size(0)    # 3, 0번 차원의 크기만

메모리 사용량은 원소 하나의 크기와 전체 크기로 확인합니다.

1
2
t.element_size()   # 8, 원소 하나의 byte 수 (int64)
t.nbytes           # 96, 텐서 전체의 byte 수 (= numel × element_size)

shape 튜플을 읽는 법은 NumPy와 같습니다. 바깥에서 안쪽으로 읽습니다. 딥러닝에서는 이 첫 번째 축이 거의 항상 배치입니다. 이미지 텐서 (32, 3, 224, 224)는 224×224 크기에 채널 3장짜리 이미지가 32장 묶여 있다는 뜻입니다. 학습 코드에서 만나는 shape 에러의 대부분은 이 축 순서를 잘못 맞춘 것입니다.

dtype과 형변환

여기가 NumPy에서 넘어올 때 가장 먼저 걸리는 지점입니다. PyTorch의 실수 기본값은 float32이고, NumPy는 float64입니다.

1
2
3
4
torch.tensor([1, 2, 3]).dtype      # torch.int64
torch.tensor([1., 2., 3.]).dtype   # torch.float32

np.array([1., 2., 3.]).dtype       # float64, NumPy는 배정밀도가 기본

딥러닝이 float32를 기본으로 쓰는 이유는 정밀도보다 속도와 메모리가 중요하기 때문입니다. 파라미터가 수백만 개인 모델에서 배정밀도는 메모리를 두 배로 쓰고 GPU 연산도 느립니다. 학습에 필요한 정밀도는 단정밀도로 충분하고, 최근에는 float16이나 bfloat16까지 내려 쓰기도 합니다.

형변환은 to나 축약 메서드로 합니다.

1
2
3
4
5
6
x = torch.tensor([1, 2, 3])

x.float()             # torch.float32로 변환
x.to(torch.float32)   # 같은 의미, dtype과 device를 함께 지정할 수 있어 실무에서 더 자주 쓴다
x.double()            # float64
x.long()              # int64

정수로 내리는 변환은 반올림이 아니라 버림입니다. NumPy의 astype과 같은 동작이지만, 라벨을 정수로 바꿀 때 자주 사고가 나는 자리라 한 번 짚어둘 만합니다.

1
torch.tensor([1.7, -1.7]).long()   # tensor([1, -1]), 0 방향으로 버림

모델이 요구하는 dtype은 대체로 정해져 있습니다. 입력과 가중치는 float32, 분류 라벨은 int64입니다. CrossEntropyLoss에 라벨을 실수로 넘기면 에러가 나는 것이 이 규칙 때문입니다.

텐서의 shape 변경

reshape와 view

reshape는 NumPy와 동작이 같습니다. 같은 데이터를 다른 shape로 읽는 텐서를 반환하고, 원본은 그대로입니다.

1
2
3
4
5
a = torch.arange(12)   # shape (12,)
b = a.reshape(3, 4)    # 반환값을 받는다

a.shape   # torch.Size([12]), 원본은 그대로
b.shape   # torch.Size([3, 4])

b가 데이터를 복사해 간 것은 아닙니다. 원소 12개짜리 메모리는 하나이고, a는 그것을 (12,)로, b(3, 4)로 읽습니다.

1
2
b[0, 0] = 99
a[0]      # tensor(99), 메모리를 공유한다

shape 자리의 -1도 그대로 동작합니다.

1
2
a.reshape(3, -1)   # (3, 4), 12개를 3행으로 나누면 열은 4
a.reshape(-1)      # (12,), 1차원으로 펴기

PyTorch에는 여기에 view가 하나 더 있습니다. 둘 다 모양을 바꾸지만 보장하는 것이 다릅니다. view는 메모리를 반드시 공유하고, 대신 텐서가 메모리에 연속으로 놓여 있어야만 동작합니다.

1
2
3
4
5
6
m = torch.arange(6).reshape(2, 3)

m.view(3, 2)     # 동작한다, m은 연속 텐서
m.T.view(6)      # RuntimeError: view size is not compatible with input tensor's
                 # size and stride ... Use .reshape(...) instead.
m.T.reshape(6)   # tensor([0, 3, 1, 4, 2, 5]), 필요하면 복사해서 처리한다

m.T는 값을 옮기지 않고 읽는 순서만 뒤집은 텐서라, 메모리 배치가 새 모양과 맞지 않습니다.

1
m.T.is_contiguous()   # False, 메모리에 연속으로 놓여 있지 않다

정리하면 이렇습니다. 애매하면 reshape를 씁니다. reshape는 가능하면 view를 반환하고 불가능하면 복사하므로 항상 성공합니다. view는 복사가 절대 일어나지 않아야 하는 자리에서 의도를 드러내려고 씁니다. transposepermute 뒤에 모양을 바꾼다면 reshape를 쓰거나 contiguous()를 먼저 부릅니다.

1
m.T.contiguous().view(6)   # 연속으로 만든 뒤에는 view도 된다

flatten

1차원으로 펴는 것은 flatten입니다. NumPy의 flatten이 항상 복사본을 주는 것과 달리, PyTorch의 flatten은 가능하면 view를 반환합니다.

1
2
m.flatten()          # tensor([0, 1, 2, 3, 4, 5])
m.flatten(start_dim=1)   # 1번 차원부터 펴기, 배치 축은 남긴다

CNN에서 합성곱 출력 (배치, 채널, 높이, 너비)를 완전연결층에 넣기 전에 flatten(start_dim=1)으로 배치 축만 남기고 펴는 것이 흔한 사용법입니다.

squeeze와 unsqueeze

크기가 1인 차원을 넣고 빼는 함수입니다. NumPy에도 있지만 PyTorch에서 훨씬 자주 쓰게 됩니다. 모델이 대부분 배치 차원을 요구하기 때문입니다.

1
2
3
4
img = torch.randn(3, 224, 224)   # 이미지 한 장

img.unsqueeze(0).shape           # torch.Size([1, 3, 224, 224]), 배치 차원 추가
img.unsqueeze(0).squeeze(0).shape  # torch.Size([3, 224, 224]), 도로 제거

학습이 끝난 모델에 이미지 한 장을 넣을 때 unsqueeze(0)으로 배치 하나짜리 묶음을 만들어 넣습니다. 반대로 모델 출력 (1, 1)에서 값 하나를 꺼낼 때 squeeze를 씁니다.

인자 없이 부르는 squeeze()는 크기가 1인 차원을 전부 없애므로, 배치 크기가 우연히 1일 때 배치 축까지 사라집니다. 없앨 축을 지정하는 습관이 안전합니다.

1
2
torch.zeros(1, 3, 1).squeeze().shape    # torch.Size([3]), 두 축이 모두 사라졌다
torch.zeros(1, 3, 1).squeeze(2).shape   # torch.Size([1, 3]), 지정한 축만

transpose, permute, T

축을 바꾸는 함수는 세 개입니다. NumPy의 transpose 하나가 여기서는 역할별로 나뉘어 있습니다.

1
2
3
m.T                      # 2차원 전용 전치, (2, 3) -> (3, 2)
m.transpose(0, 1)        # 지정한 두 축만 서로 바꾼다
img.permute(1, 2, 0)     # 축 순서를 통째로 재배열, (3, 224, 224) -> (224, 224, 3)

transpose는 축 두 개를 맞바꾸고, permute는 축 순서 전체를 새로 지정합니다. 3차원 이상에서는 permute를 쓰게 됩니다. PyTorch는 이미지를 (채널, 높이, 너비)로 다루고 matplotlib은 (높이, 너비, 채널)을 요구하므로, 텐서를 그림으로 확인할 때 permute(1, 2, 0)이 관용구처럼 붙습니다.

이 세 함수의 결과는 모두 메모리를 공유하는 비연속 텐서입니다. 바로 위에서 본 view 제약이 여기서 따라옵니다.

indexing과 slicing

indexing

indexing과 slicing 문법은 NumPy와 같습니다.

1
2
3
4
5
t = torch.arange(12).reshape(3, 4)

t[0, 2]       # tensor(2), 0행 2열
t[1]          # tensor([4, 5, 6, 7]), 1행 전체
t[-1]         # 마지막 행

fancy indexing

정수 리스트로 원하는 위치를 한 번에 골라냅니다.

1
t[[0, 2]]     # 0행과 2행만 골라 (2, 4)

slicing

1
2
3
t[:, 2]       # tensor([2, 6, 10]), 모든 행의 2열
t[0:2, 1:3]   # 부분 행렬
t[::2]        # 행을 하나 걸러 하나씩

view와 clone

NumPy와 마찬가지로 slice는 view입니다. 슬라이스에 값을 쓰면 원본이 바뀝니다.

1
2
3
4
t = torch.arange(12).reshape(3, 4)
row = t[0]      # view, 새 텐서가 아니다
row[0] = 99
t[0, 0]         # tensor(99), 원본이 바뀌었다

원본과 분리하려면 명시적으로 복사합니다. NumPy의 copy()에 해당하는 것이 PyTorch에서는 clone() 입니다.

1
row = t[0].clone()   # 이제 row를 수정해도 t는 무관

clone()은 복사하면서도 계산 그래프 연결은 유지합니다. 값만 떼어내고 미분 추적까지 끊으려면 detach()와 함께 씁니다. 이 조합의 의미는 autograd를 다루는 (2)편에서 이어집니다.

Conditional selection

boolean 마스크

비교 연산이 텐서 전체에 적용되어 True/False 마스크가 나오고, 그 마스크로 원소를 고릅니다. NumPy와 같습니다.

1
2
3
4
a = torch.tensor([3., -1., 7., 0., -5., 2.])

mask = a > 0    # tensor([True, False, True, False, False, True])
a[mask]         # tensor([3., 7., 2.])

마스크는 bool 텐서라서 합을 구하면 개수가 나옵니다. 정확도 계산이 이 관용구 위에 서 있습니다.

1
2
(a > 0).sum()          # tensor(3), True의 개수
(a > 0).float().mean() # tensor(0.5), True의 비율

조건 결합

and/or가 아니라 &, |, ~를 쓰고 각 조건을 괄호로 감쌉니다. NumPy, pandas와 같은 규칙입니다.

1
2
3
a[(a > 0) & (a < 5)]   # tensor([3., 2.])
a[(a < 0) | (a > 5)]   # tensor([-1., 7., -5.])
a[~(a > 0)]            # tensor([-1., 0., -5.])

where와 clamp

조건에 따라 값을 바꿀 때는 torch.where, 범위를 자를 때는 torch.clamp입니다.

1
2
torch.where(a > 0, a, 0.)   # tensor([3., 0., 7., 0., 0., 2.])
torch.clamp(a, min=0)       # tensor([3., 0., 7., 0., 0., 2.]), 음수를 0으로

위 두 줄의 결과가 같은 것은 우연이 아닙니다. 둘 다 ReLU가 하는 일입니다. 활성화 함수와 기울기 클리핑처럼 학습에서 값의 범위를 다루는 코드가 clamp 위에 서 있습니다.

텐서 연산

elementwise 연산

산술 연산은 같은 위치의 원소끼리 적용됩니다.

1
2
3
4
5
6
x = torch.tensor([1., 2., 3.])
y = torch.tensor([10., 20., 30.])

x + y     # tensor([11., 22., 33.])
x * y     # tensor([10., 40., 90.])
x ** 2    # tensor([1., 4., 9.])

Broadcasting

Broadcasting 규칙도 NumPy와 동일합니다. 두 텐서의 shape를 뒤에서부터 축끼리 비교해서, 크기가 같거나 한쪽이 1이면 호환됩니다.

1
2
3
4
5
6
7
8
9
M = torch.tensor([[1., 2., 3.],
                  [4., 5., 6.]])          # shape (2, 3)

M + 10                                    # 스칼라가 전체에 적용
M + torch.tensor([10., 20., 30.])         # (3,)이 행마다 적용
M + torch.tensor([[10.], [20.]])          # (2, 1)이 열마다 적용
M + torch.tensor([10., 20.])
# RuntimeError: The size of tensor a (3) must match the size of tensor b (2)
# at non-singleton dimension 1

마지막 에러 메시지는 학습 코드에서 자주 보게 됩니다. 대개 배치 축과 특성 축을 헷갈렸을 때 나오므로, 메시지의 “dimension 1”이 어느 축인지부터 확인하면 원인이 빨리 좁혀집니다.

elementwise 곱과 행렬 곱

*는 원소별 곱, @는 행렬 곱입니다.

1
2
3
4
5
6
A = torch.tensor([[1., 2.], [3., 4.]])
B = torch.tensor([[5., 6.], [7., 8.]])

A * B          # tensor([[5., 12.], [21., 32.]]), 같은 위치끼리
A @ B          # tensor([[19., 22.], [43., 50.]]), 행렬 곱
A.matmul(B)    # @와 같다

신경망의 한 층이 하는 계산이 x @ W.T + b이므로, 이 구분은 (3)편nn.Linear에서 바로 다시 나옵니다. @는 3차원 이상에서 배치 행렬 곱으로 동작합니다. 앞쪽 축은 배치로 두고 뒤 두 축만 행렬로 봅니다.

1
(torch.randn(2, 3, 4) @ torch.randn(2, 4, 5)).shape   # torch.Size([2, 3, 5])

in-place 연산

메서드 이름 끝의 언더스코어는 원본을 그 자리에서 바꾼다는 표시입니다. PyTorch 고유의 관례입니다.

1
2
3
4
5
z = torch.zeros(3)

z.add(1)    # tensor([1., 1., 1.])를 반환, z는 그대로 0
z.add_(1)   # z 자체가 tensor([1., 1., 1.])로 바뀐다
z.zero_()   # z를 0으로 채운다

메모리를 아끼지만, 미분에 필요한 값을 덮어쓰면 autograd가 에러를 냅니다. 학습 코드에서 in-place 연산은 기울기를 비우는 zero_()처럼 관례가 굳은 자리에서만 쓰고, 나머지는 새 텐서를 받는 편이 안전합니다.

Aggregation과 dim

sum, mean, min, max

1
2
3
4
5
6
t = torch.arange(12).reshape(3, 4).float()

t.sum()    # tensor(66.)
t.mean()   # tensor(5.5)
t.min()    # tensor(0.)
t.max()    # tensor(11.)

mean은 정수 텐서에서 에러가 납니다. NumPy는 정수 배열의 평균을 실수로 계산해주지만 PyTorch는 거부합니다.

1
2
3
torch.arange(12).mean()
# RuntimeError: mean(): could not infer output dtype.
# Input dtype must be either a floating point or complex dtype. Got: Long

.float()를 먼저 붙이면 됩니다. 위에서 .float()를 붙여 예제를 시작한 이유가 이것입니다.

dim과 keepdim

NumPy의 axis가 PyTorch에서는 dim입니다. 이름만 바뀌었고, 지정한 축이 사라진다는 규칙도 같습니다.

1
2
t.sum(dim=0)          # tensor([12., 15., 18., 21.]), 열별 합, shape (4,)
t.sum(dim=1)          # tensor([6., 22., 38.]), 행별 합, shape (3,)

PyTorch에는 여기에 keepdim이 붙습니다. 접힌 축을 크기 1로 남겨두는 옵션입니다.

1
2
t.sum(dim=1).shape                 # torch.Size([3]), 축이 사라진다
t.sum(dim=1, keepdim=True).shape   # torch.Size([3, 1]), 축이 남는다

축을 남기는 이유는 Broadcasting입니다. 행별 합으로 각 행을 나누려 할 때, (3,)은 원본 (3, 4)와 뒤에서부터 비교되어 맞지 않고 (3, 1)은 열 방향으로 퍼집니다. 정규화나 softmax 계산에서 keepdim=True가 붙는 것이 이 때문입니다.

1
t / t.sum(dim=1, keepdim=True)   # 각 행을 그 행의 합으로 나눈다

argmax와 max의 반환값

argmax는 위치를 돌려줍니다. 분류 모델의 출력에서 예측 라벨을 뽑는 것이 이 함수입니다.

1
2
3
4
logits = torch.tensor([[2.0, 1.0, 0.1],
                       [0.5, 3.0, 0.2]])

logits.argmax(dim=1)   # tensor([0, 1]), 샘플마다 가장 큰 클래스의 위치

maxdim을 주면 NumPy와 반환값이 다릅니다. 최댓값과 그 위치를 한 쌍으로 돌려줍니다.

1
2
3
4
5
6
7
logits.max(dim=1)
# torch.return_types.max(
# values=tensor([2., 3.]),
# indices=tensor([0, 1]))

logits.max(dim=1).values    # tensor([2., 3.])
logits.max(dim=1).indices   # tensor([0, 1]), argmax와 같다

dim 없이 부른 logits.max()는 전체 최댓값 하나만 돌려줍니다. 이 차이를 모르고 결과를 그대로 다음 계산에 넘기면 튜플이 흘러들어가 엉뚱한 에러가 납니다.

item

원소가 하나뿐인 텐서에서 Python 숫자를 꺼낼 때는 item()을 씁니다.

1
2
t.sum()          # tensor(66.), 여전히 텐서
t.sum().item()   # 66.0, Python float

학습 루프에서 손실값을 기록할 때 반드시 거치는 단계입니다. 텐서 그대로 리스트에 쌓으면 계산 그래프가 함께 붙어 있어 메모리가 계속 쌓입니다. loss.item()으로 숫자만 꺼내 기록하는 것이 표준 습관입니다.

텐서 merge

cat

기존 축을 따라 이어 붙입니다. NumPy concatenate의 축약형이고, 차원 수는 그대로입니다.

1
2
3
4
5
6
7
8
x = torch.tensor([1, 2, 3])
y = torch.tensor([4, 5, 6])

torch.cat([x, y])           # tensor([1, 2, 3, 4, 5, 6]), shape (6,)

P = torch.arange(6).reshape(2, 3)
torch.cat([P, P], dim=0)    # shape (4, 3), 세로로 이어 붙임
torch.cat([P, P], dim=1)    # shape (2, 6), 가로로 이어 붙임

stack

새 축을 만들어 쌓습니다. 차원 수가 하나 늘어납니다.

1
2
torch.stack([x, y])          # shape (2, 3), 새 축이 앞에 생긴다
torch.stack([x, y], dim=1)   # shape (3, 2)

이어 붙이면 cat, 쌓아서 차원을 늘리면 stack입니다. 실무에서 stack을 만나는 자리는 정해져 있습니다. 샘플 하나짜리 텐서 여러 개를 배치로 묶을 때입니다.

1
2
samples = [torch.randn(3, 32, 32) for _ in range(4)]   # 이미지 4장
torch.stack(samples).shape                             # torch.Size([4, 3, 32, 32])

(4)편DataLoader가 배치를 만들 때 내부에서 하는 일이 정확히 이것입니다.

Sort

sort

torch.sort는 정렬된 값과 그 위치를 함께 돌려줍니다. max(dim=...)과 같은 형태입니다.

1
2
3
4
5
6
7
8
scores = torch.tensor([92., 68., 85., 77.])

torch.sort(scores)
# torch.return_types.sort(
# values=tensor([68., 77., 85., 92.]),
# indices=tensor([1, 3, 2, 0]))

torch.sort(scores, descending=True).values   # tensor([92., 85., 77., 68.])

NumPy에는 원본을 바꾸는 a.sort()가 있지만 PyTorch에는 그 짝이 없습니다. scores.sort()는 메서드로 불러도 원본을 그대로 두고 정렬 결과를 반환합니다.

argsort와 topk

argsort는 위치만 돌려줍니다.

1
scores.argsort()   # tensor([1, 3, 2, 0]), 오름차순 순서의 위치

상위 몇 개만 필요하다면 전체를 정렬할 것 없이 topk를 씁니다. 값과 위치를 함께 돌려줍니다.

1
2
3
4
torch.topk(scores, 2)
# torch.return_types.topk(
# values=tensor([92., 85.]),
# indices=tensor([0, 2]))

분류 모델의 상위 k개 예측을 뽑는 지표(top-5 정확도)가 이 함수 위에 서 있습니다.

NumPy와의 상호 변환

from_numpy와 numpy

텐서와 NumPy 배열은 서로 변환됩니다. 여기에 꼭 알아야 할 함정이 있습니다. CPU에서 이 변환은 데이터를 복사하지 않고 메모리를 공유합니다.

1
2
3
4
5
6
7
import numpy as np

arr = np.array([1., 2., 3.])
t = torch.from_numpy(arr)   # NumPy에서 텐서로

arr[0] = 99
t                           # tensor([99., 2., 3.], dtype=torch.float64)

반대 방향인 t.numpy()도 마찬가지로 메모리를 공유합니다. 분리하려면 clone()이나 copy()로 명시적으로 복사합니다.

float64 함정

위 출력에서 dtype=torch.float64를 눈여겨봐야 합니다. NumPy 실수 배열은 float64이고, from_numpy는 그 dtype을 그대로 가져옵니다. 그런데 모델 파라미터는 float32입니다. 그대로 모델에 넣으면 이런 에러가 납니다.

1
2
3
lin = torch.nn.Linear(3, 1)
lin(torch.from_numpy(np.random.rand(2, 3)))
# RuntimeError: mat1 and mat2 must have the same dtype, but got Double and Float

pandas로 준비한 데이터를 학습에 넣을 때 가장 흔하게 만나는 에러입니다. 변환 시점에 dtype을 맞춰두면 끝납니다.

1
2
3
torch.from_numpy(arr.astype(np.float32))   # NumPy 쪽에서 맞추거나
torch.from_numpy(arr).float()              # 텐서로 만든 뒤 맞추거나
torch.tensor(arr, dtype=torch.float32)     # 복사하면서 지정하거나

torch.from_numpy는 메모리를 공유하고 torch.tensor는 복사한다는 차이도 함께 기억해둘 만합니다. 공유가 필요 없으면 torch.tensor 쪽이 사고가 적습니다.

device

여기가 NumPy에 없는 부분입니다. 텐서는 어느 장치에 놓일지를 가집니다. 기본은 CPU이고, GPU로 옮기면 그 위에서 연산이 병렬로 돕니다.

1
2
3
4
5
torch.cuda.is_available()             # GPU(CUDA)를 쓸 수 있는가

device = "cuda" if torch.cuda.is_available() else "cpu"
q = torch.randn(2, 3).to(device)      # 텐서를 그 장치로
q.device                              # 확인

애플 실리콘 맥이라면 "cuda" 대신 "mps"를 쓸 수 있습니다. 규칙은 하나입니다. 연산에 참여하는 텐서들은 같은 device에 있어야 합니다.

1
2
3
q + torch.randn(2, 3)
# RuntimeError: Expected all tensors to be on the same device,
# but found at least two devices, mps:0 and cpu!

GPU 텐서는 NumPy로 바로 바꿀 수도 없습니다. NumPy는 CPU 메모리만 알기 때문입니다.

1
2
3
4
5
q.numpy()
# TypeError: can't convert mps:0 device type tensor to numpy.
# Use Tensor.cpu() to copy the tensor to host memory first.

q.cpu().numpy()   # CPU로 내린 뒤에 변환한다

그래서 학습 코드에는 모델과 입력을 같은 device로 옮기는 줄이 항상 따라붙고, 결과를 기록할 때는 .cpu().item()으로 내리는 줄이 따라붙습니다. 이 패턴을 학습 루프에 넣는 방법은 (5)편에서 정리합니다.

정리

항목NumPy와 비교요약
생성거의 같음tensor, arange, zeros, randn, _like 계열
원소 수이름 다름size가 아니라 numel(), size()는 shape
실수 기본 dtype다름float32(NumPy는 float64)
모양 바꾸기하나 더 있음reshape가 안전, view는 연속 텐서 전용
배치 차원PyTorch 특유unsqueeze(0)으로 넣고 squeeze로 뺌
indexing같음slice는 view, 분리하려면 clone()
조건 선택같음& | ~와 괄호, where, clamp
Broadcasting같음뒤에서부터 축 비교, 같거나 1이면 호환
집계 축이름 다름axis가 아니라 dim, keepdim으로 축 유지
max(dim=...)반환값 다름값과 위치를 함께 반환
in-placePyTorch 특유메서드 끝의 _는 원본을 바꿈
NumPy 변환주의메모리 공유, float64가 딸려온다
deviceNumPy에 없음연산은 같은 device에서만

텐서는 “무엇을 담는가”입니다. 문법을 훑어보면 알 수 있듯이, 여기까지는 NumPy를 아는 사람에게 이름이 몇 개 바뀐 정도입니다. 다음 편은 PyTorch를 NumPy와 결정적으로 다르게 만드는 것, 이 텐서들의 연산을 기록해 자동으로 미분하는 autograd입니다. 딥러닝 기초 (4)에서 손으로 짰던 역전파가 거기서 backward() 한 줄이 됩니다.

다음 글: PyTorch 기초 (2) - autograd: 자동 미분과 계산 그래프

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.