텐서 연산을 이해하려면 어떤 값이 계산되는지와 함께, 연산 전후에 데이터의 모양이 어떻게 달라지는지를 읽어야 한다. 축의 의미와 크기를 구분하면 필요한 연산을 선택하고 결과가 의도한 구조인지 확인할 수 있다.
PyTorch에서 텐서(Tensor)는 같은 자료형의 원소를 다차원 배열로 표현하는 자료구조이다. 스칼라·벡터·행렬은 각각 0차원·1차원·2차원 텐서로 표현할 수 있으며, 텐서라는 말이 3차원 이상만을 뜻하지는 않는다.
축(Axis)은 원소의 위치를 구분하는 각 방향이고, 모양(Shape)은 축별 크기를 순서대로 나타낸 것이다. t.ndim 또는 t.dim()은 축의 개수, t.shape 또는 t.size()는 전체 모양, t.size(dim)은 지정한 축의 크기를 알려준다. 축 번호는 0부터 시작하며 dim=-1은 마지막 축을 가리킨다.
shape가 (B, F)인 텐서는 축이 두 개이고 원소는 개이다. 이를 샘플 개의 특성 개를 저장한 구조로 정했다면, 0번 축은 샘플을, 1번 축은 특성을 구분한다. 함께 처리하는 샘플 묶음을 배치(Batch), 그 샘플 수를 배치 크기(Batch Size)라고 한다. 축의 크기는 shape로 알 수 있지만, 축의 의미는 데이터를 구성한 방식으로 정해진다.
(F,), (1, F), (F, 1)은 원소 수가 같아도 서로 다른 모양이다. (F,)는 축이 하나인 벡터이고, 나머지는 각각 행과 열이 있는 2차원 텐서이다. 수학에서 성분이 개인 벡터를 차원 벡터라고 부르는 것과, PyTorch에서 축의 개수를 차원 수라고 부르는 것도 구분해야 한다.
모양 외에도 자료형(Data Type)인 dtype와 데이터를 저장하고 연산하는 장치(Device)인 device를 확인한다. shape가 같다고 원소의 표현 방식이나 연산 장치까지 같은 것은 아니다.
원소별 연산(Element-wise Operation)은 대응되는 위치의 원소끼리 계산하는 연산이다. 덧셈이나 *, mul을 이용한 곱셈이 여기에 해당한다.
브로드캐스팅(Broadcasting)은 서로 다른 모양의 텐서를 정해진 규칙으로 맞추어 원소별 연산을 수행하는 방식이다. 마지막 축부터 두 입력을 나란히 비교한다. 대응하는 크기가 같거나 한쪽이 1이면 호환되고, 한쪽에 없는 앞쪽 축은 크기 1로 간주한다. 크기가 1인 축의 값은 상대 축에 반복되는 것처럼 계산되며, 이를 위해 입력 데이터를 실제로 복제해 둘 필요는 없다.
반면 행렬 곱(Matrix Multiplication)은 왼쪽 행과 오른쪽 열의 대응 성분을 곱한 뒤 더한다. 두 입력이 2차원일 때 @ 또는 matmul은 다음 조건을 따른다.
안쪽 크기 가 같아야 하며 결과에는 왼쪽의 행 수 과 오른쪽의 열 수 이 남는다. 원소별 곱과 달리, 행렬을 곱하는 두 축에는 이 조건이 적용된다. 두 입력이 모두 2차원 이상인 matmul에서는 마지막 두 축을 행렬로 다루고, 그 앞의 배치 축에 브로드캐스팅을 적용한다.
축소 연산(Reduction)은 지정한 축을 따라 여러 값을 합계·평균·최댓값 등으로 모으는 연산이다. sum, mean, max의 dim은 결과를 남길 축이 아니라 값을 모을 축을 지정한다.
샘플과 특성을 각각 행과 열에 놓은 (B, F) 텐서라면 dim=0은 샘플을 모아 특성별 결과를 만든다. dim=1은 특성을 모아 샘플별 결과를 만든다.
| 지정 | 계산의 의미 | 기본 결과 shape | keepdim=True |
|---|---|---|---|
dim=0 | 특성마다 샘플들의 값을 모음 | (F,) | (1, F) |
dim=1 | 샘플마다 특성들의 값을 모음 | (B,) | (B, 1) |
기본적으로 계산한 축은 제거된다. keepdim=True는 그 축의 크기를 1로 남긴다. 이 결과를 원래 텐서와 다시 계산할 때, 어느 축에 값을 반복 적용해야 하는지를 보존할 수 있다.
max(dim=...)는 최댓값과 해당 축 안에서의 인덱스를 함께 반환한다. 최댓값의 위치만 필요하면 argmax(dim=...)를 사용한다. 위치를 나타내는 인덱스는 0부터 시작하며, 같은 최댓값이 여러 개면 첫 번째 위치가 선택된다. max()처럼 축을 지정하지 않으면 전체 최댓값만 반환한다.
뷰(View)는 원본과 데이터를 공유하는 텐서이다. 모양을 지정하는 view는 데이터를 복사하지 않고 같은 원소를 새로운 shape로 읽게 한다. 따라서 변경 전후의 전체 원소 수가 같아야 한다. 크기 하나를 -1로 지정하면 나머지 크기와 전체 원소 수로부터 추론하며, -1은 한 번만 사용할 수 있다.
다만 원소 수가 같은 것만으로 충분하지는 않다. 스트라이드(Stride)는 각 축에서 인덱스가 한 칸 이동할 때 저장 공간에서 건너뛰는 원소 수이다. view가 가능하려면 이 메모리 배치가 새 모양과 호환되어야 한다. reshape는 원소 수에 맞는 모양을 요청하면 가능한 경우 뷰를 반환하고, 필요하면 데이터를 복사한다.
모양을 바꾸는 것과 축을 교환하는 것도 구분해야 한다. (B, F)를 (F, B)로 view했다고 해서 샘플 축과 특성 축이 서로 바뀌지는 않는다. 두 축을 교환하려면 transpose, 여러 축의 순서를 바꾸려면 permute를 사용한다.
unsqueeze(dim)는 지정한 위치에 크기 1인 축을 추가한다. squeeze(dim)는 지정한 축의 크기가 1일 때 그 축을 제거한다. 두 연산 모두 원소 수를 유지하며, 값의 모양을 연산이나 모델의 입력 조건에 맞출 때 사용한다.
squeeze()처럼 축을 생략하면 크기 1인 모든 축이 사라진다. 배치 크기가 1인 경우 배치 축까지 제거될 수 있으므로, 유지해야 할 축이 있다면 제거할 축을 지정한다.
연결(Concatenation)은 기존 축을 따라 텐서를 이어 붙이는 방식이며 cat으로 수행한다. 연결할 축 이외의 크기는 같아야 하고, 결과의 축 개수는 유지된다. 여기서는 비어 있지 않은 일반적인 텐서를 기준으로 설명한다.
쌓기(Stacking)는 같은 shape의 텐서들을 새로운 축으로 구분해 묶는 방식이며 stack으로 수행한다. 모든 입력의 shape가 같아야 하고, 결과에는 축이 하나 늘어난다. 기존 샘플 묶음을 더 길게 만들지, 묶음 자체를 구분할 축을 만들지에 따라 선택한다.
타입 캐스팅(Type Casting)은 원소의 자료형을 바꾸는 연산이다. x.float()는 float32, x.long()은 int64인 결과를 반환한다. 이후 연산에는 이 반환값을 사용해야 한다. 정수 텐서의 평균이 필요할 때 실수형으로 변환하는 경우처럼, 연산이 요구하는 자료형을 맞추는 데 사용한다.
ones_like(x)와 zeros_like(x)는 각각 1과 0으로 채운 새 텐서를 만든다. 별도로 지정하지 않으면 입력의 shape·dtype·device를 따르므로, 기존 데이터와 조건을 맞춘 텐서를 만들 수 있다.
인플레이스 연산(In-place Operation)은 새 계산 결과만 만드는 대신 기존 데이터를 직접 수정하는 연산이다. PyTorch 텐서 메서드에서는 이름 끝의 밑줄 _로 구분하는 경우가 많다. x.mul(2)는 원본을 유지하며 결과를 반환하고, x.mul_(2)는 원본 값을 바꾼다.
x = x * 2는 새 결과를 이름 x에 연결한다. x.mul_(2)와 이후 x의 값이 같아도, 원래 데이터를 공유하던 다른 텐서에 미치는 영향은 다르다. 특히 뷰로 연결된 텐서는 같은 데이터를 보므로, 공유된 데이터를 직접 바꾸면 다른 뷰에서 읽는 값도 달라진다.
두 샘플이 각각 세 특성을 가진다고 하자. 계산에 사용할 실수형 텐서 x는 다음 행렬 를 저장하며, shape는 (2, 3)이다.
0번 축은 샘플, 1번 축은 특성이므로 배치 크기는 2이다. 아래 연산은 별도로 밝히지 않는 한 이 원래 x에 각각 적용한다.
| 연산 | 결과 값 | 결과 shape |
|---|---|---|
x.sum() | 27 | () |
x.sum(dim=0) | [9, 9, 9] | (3,) |
x.mean(dim=0) | [4.5, 4.5, 4.5] | (3,) |
x.mean(dim=1) | [5, 4] | (2,) |
x.mean(dim=1, keepdim=True) | [[5], [4]] | (2, 1) |
x.max(dim=1).values | [8, 7] | (2,) |
x.argmax(dim=1) | [1, 0] | (2,) |
dim=0의 평균은 같은 특성에 속한 두 샘플의 값을 모은다. 첫 특성이라면 이다. dim=1의 평균은 같은 샘플의 세 특성을 모으므로 첫 샘플은 가 된다.
첫 샘플의 최댓값은 8이며 세 특성 중 인덱스 1에 있다. 두 번째 샘플의 최댓값은 7이고 인덱스는 0이다. 최댓값과 그 위치는 같은 계산에서 얻더라도 서로 다른 정보이다.
각 샘플에서 그 샘플의 평균을 빼려면, 위에서 구한 평균을 (2, 1)로 유지한다. row_mean이 x.mean(dim=1, keepdim=True)의 결과라면 x - row_mean은 다음과 같다.
뒤쪽 축의 크기 3과 1이 호환되므로, 각 행의 평균 하나가 그 행의 세 특성에 적용된다. 결과 shape는 (2, 3)이다. 반면 평균을 (2,)로 두면 마지막 축의 3과 2가 맞지 않아 이 뺄셈은 실패한다.
같은 x의 각 특성에 [1, 0, -1]을 곱하면 원소별 결과는 [[2, 0, -5], [7, 0, -4]]이고 shape는 (2, 3)이다. 이 가중치를 (3, 1)인 행렬 로 만들어 행렬 곱을 하면, 곱한 값들을 샘플별로 더한 결과가 나온다.
이때 입력 shape는 (2, 3)과 (3, 1), 결과는 (2, 1)이다. 원소별 곱은 각 특성의 계산값을 남기고, 행렬 곱은 이 값들을 합쳐 샘플별 결과 하나를 만든다.
x가 메모리에 연속적으로 배치된 텐서라고 하자. 원소가 6개이므로 x.view(3, 2)의 결과는 [[2, 8], [5, 7], [1, 4]]이다. 반면 x.transpose(0, 1)은 [[2, 7], [8, 1], [5, 4]]이다. 둘 다 shape는 (3, 2)이지만, 전자는 원소를 다른 크기로 묶고 후자는 행과 열의 역할을 교환한다.
| 연산 | 결과 shape | 달라지는 구조 |
|---|---|---|
x.view(-1) | (6,) | 여섯 원소를 한 축으로 읽음 |
x.unsqueeze(0) | (1, 2, 3) | 앞에 크기 1인 축을 추가 |
x.unsqueeze(0).squeeze(0) | (2, 3) | 추가한 축을 제거 |
torch.cat([x, x], dim=0) | (4, 3) | 샘플 축을 따라 연결 |
torch.cat([x, x], dim=1) | (2, 6) | 특성 축을 따라 연결 |
torch.stack([x, x], dim=0) | (2, 2, 3) | 두 묶음을 구분하는 축을 추가 |
cat의 첫 결과는 샘플 네 개를 한 묶음에 담고, stack의 결과는 샘플 두 개짜리 묶음을 두 개 담는다. 전체 원소 수는 같지만 결과를 읽는 축의 구조가 다르다.
dtype와 device도 별도로 확인한다.keepdim은 축소한 결과를 원래 텐서와 다시 연산할 때 유용하다.연산이 성공했다는 사실만으로 의도한 계산이 되었다고 판단할 수는 없다. 입력과 결과의 shape를 적고 각 축이 무엇을 뜻하는지 확인하면, 오류가 난 이유뿐 아니라 실행은 되지만 잘못 대응된 계산도 찾을 수 있다.