Link

실습: 기본 연산

이번에는 파이토치 텐서들을 활용한 기본 연산들에 대해서 살펴보겠습니다.

>>> import torch

요소별 산술 연산

다음과 같이 두 개의 텐서(행렬) a와 b가 있다고 가정하겠습니다.

\[\begin{gathered} a=\begin{bmatrix} 1 & 2 \\ 3 & 4 \end{bmatrix}, b=\begin{bmatrix} 2 & 2 \\ 3 & 3 \end{bmatrix} \end{gathered}\]

이것을 파이썬 코드로 옮기면 다음과 같습니다.

>>> a = torch.FloatTensor([[1, 2],
...                        [3, 4]])
>>> b = torch.FloatTensor([[2, 2],
...                        [3, 3]])

그럼 우리는 이 두 행렬 사이의 덧셈을 수행할 수 있을 것입니다.

\[\begin{gathered} a+b=\begin{bmatrix} 1 & 2 \\ 3 & 4 \end{bmatrix}+\begin{bmatrix} 2 & 2 \\ 3 & 3 \end{bmatrix}=\begin{bmatrix} 1+2 & 2+2 \\ 3+3 & 4+3 \end{bmatrix}=\begin{bmatrix} 3 & 4 \\ 6 & 7 \end{bmatrix} \end{gathered}\]

이것을 파이토치에서 구현하면 다음과 같습니다.

>>> a + b
tensor([[3., 4.],
        [6., 7.]])

마찬가지로 뺄셈, 곱셈, 나눗셈 연산을 파이토치 코드로 구현하면 다음과 같습니다.

>>> a - b # 뺄셈
tensor([[-1.,  0.],
        [ 0.,  1.]])
>>> a * b # 곱셈
tensor([[ 2.,  4.],
        [ 9., 12.]])
>>> a / b # 나눗셈
tensor([[0.5000, 1.0000],
        [1.0000, 1.3333]])

제곱 연산도 비슷하게 취해볼 수 있습니다.

\[\begin{gathered} \begin{bmatrix} 1^2 & 2^2 \\ 3^3 & 4^3 \end{bmatrix}=\begin{bmatrix} 1 & 4 \\ 27 & 64 \end{bmatrix} \end{gathered}\]

이 연산을 파이토치 코드로 구현하면 다음과 같습니다.

>>> a**b
tensor([[ 1.,  4.],
        [27., 64.]])

논리 연산자도 마찬가지로 쉽게 구현할 수 있습니다. 다음의 코드는 행렬의 각 위치의 요소가 같은 값일 경우 True, 다른 값일 경우 False를 갖도록 하는 연산입니다.

>>> a == b
tensor([[False,  True],
        [ True, False]])

마찬가지로 != 연산자를 사용하게 되면, 다른 값일 경우 True, 같은 값일 경우 False를 갖게 됩니다.

>>> a != b
tensor([[ True, False],
        [False,  True]])

인플레이스 연산

앞서 수행한 연산들의 결과 텐서는 메모리에 새롭게 할당됩니다. 빈 메모리의 공간이 결과 텐서를 위한 공간이 할당되고, 여기에 결과 텐서의 값이 위치하게 되는 것입니다. 하지만 이제 설명할 인플레이스in-place연산은 같은 산술 연산을 수행하되, 기존 텐서에 결과가 저장되는 점이 다릅니다.

처음에 아까 선언한 행렬 a를 프린트하면 다음과 같이 실행될 것입니다.

>>> print(a)
tensor([[1., 2.],
        [3., 4.]])

여기서 다음의 파이토치 코드를 실행하면 앞서 소개한 $a\times{b}$ 연산과 똑같습니다.

>>> print(a.mul(b))
tensor([[ 2.,  4.],
        [ 9., 12.]])

a.mul(b)의 연산 결과 텐서는 새로운 메모리에 할당됩니다. 따라서 다음과 같이 다시 텐서 a를 출력하면, a의 값은 그대로인 것을 볼 수 있습니다.

>>> print(a)
tensor([[1., 2.],
        [3., 4.]])

인플레이스 연산들은 밑줄underscore이 함수명 뒤에 붙어있는 것이 특징입니다. 따라서 곱셈 함수의 인플레이스 연산 함수는 mul_()으로 대응됩니다.

>>> print(a.mul_(b))
tensor([[ 2.,  4.],
        [ 9., 12.]])

연산 결과는 아까와 똑같은 것을 볼 수 있는데요. 사실은 다음의 파이토치 코드 수행 결과에서 볼 수 있듯이, 이 곱셈 연산의 결과는 텐서 a에 저장되어 있습니다.

>>> print(a)
tensor([[ 2.,  4.],
        [ 9., 12.]])

즉, 메모리의 새로운 공간에 계산 결과가 저장되는 것이 아닌, 기존 a의 공간에 계산 결과가 저장되는 것입니다. 얼핏 생각하면 새로운 메모리의 공간을 할당하는 작업이 생략되기 때문에 속도나 공간 사용 측면에서 훨씬 효율적일 것 같지만, 파이토치 측에서는 어차피 가비지 컬렉터가 효율적으로 동작하기 때문에 굳이 인플레이스 연산을 사용할 필요는 없다고 밝히고 있습니다.

차원 축소 연산: 합과 평균

다음과 같은 텐서 x가 있다고 가정해보겠습니다.

\[\begin{gathered} x=\begin{bmatrix} 1 & 2 \\ 3 & 4 \end{bmatrix} \end{gathered}\]
>>> x = torch.FloatTensor([[1, 2],
...                        [3, 4]])

그럼 다음과 같이 sum() 함수 또는 mean() 함수를 통해 행렬 전체 요소들의 합이나 평균을 구할 수 있습니다. 행렬 요소 전체의 합이나 평균은 텐서나 행렬이 아닌 스칼라scalar 값으로 저장되므로 차원이 축소된다고 볼 수 있습니다.

>>> print(x.sum())
tensor(10.)

>>> print(x.mean())
tensor(2.5000)

여기서 함수의 dim 인자에 연산을 원하는 차원을 넣어줄 수 있습니다. 사실 그냥 이렇게 이해하면 헷갈리기 쉬운데, dim 인자의 값은 없어지는 차원이라고 생각하면 쉽습니다.

>>> print(x.sum(dim=0))
tensor([4., 6.])

여기서 dim=0 이면, 첫 번째 차원을 이야기하는 것이므로 행렬의 세로축에 대해서 합sum 연산을 수행합니다. 이것을 수식으로 표현하면 다음과 같이 표현될 수 있습니다. 수식에서 $^\intercal$ 표시는 행렬의 행과 열을 바꿔 표현하는 전치transpose 연산을 의미합니다.[1]

\[\begin{gathered} \text{sum}(x, \text{dim}=0)=\begin{bmatrix} 1 & 2 \\ + & + \\ 3 & 4 \end{bmatrix}=\begin{bmatrix} 4 & 6 \end{bmatrix}^\intercal \end{gathered}\]

행렬의 세로 축인 첫 번째 차원에 대해서 축소 연산이 수행되는 것을 확인할 수 있습니다. dim 인자의 값으로는 -1 도 줄 수 있는데요. 이처럼 -1 을 차원의 값으로 넣어주게 되면, 뒤에서 첫 번째 차원을 의미합니다. 여기서는 2개의 차원만 존재하므로 dim=1 을 넣어준 것과 동일할 것입니다. 만약 -2 를 넣어주면, 뒤에서 두 번째 차원을 의미하겠지요.

>>> print(x.sum(dim=-1))
tensor([3., 7.])
\[\begin{gathered} \text{sum}(x, \text{dim}=-1)=\begin{bmatrix} 1 & + & 2 \\ 3 & + & 4 \end{bmatrix}=\begin{bmatrix} 3 \\ 7 \end{bmatrix} \end{gathered}\]

[1]: 2차원인 행렬의 차원이 축소되어 벡터가 되었으므로 세로로 표현되는 것이 맞지만, 이해를 돕기 위해 전치transpose 연산을 통해 가로 벡터로 표현합니다.

브로드캐스트 연산

이전까지의 산술 연산들은 두 텐서의 크기가 같은 경우를 예시로 보여드렸습니다. 그럼 크기가 다른 두 텐서들을 통해 산술 연산을 수행하면 어떻게 될까요?

텐서 + 스칼라

가장 먼저 쉽게 생각해볼 수 있는 것은 행렬(또는 텐서)에 스칼라를 더하는 것입니다.

>>> x = torch.FloatTensor([[1, 2],
...                        [3, 4]])
>>> y = 1

앞서 코드를 통해 텐서 x와 스칼라 y를 선언하였습니다. 다음의 코드는 x와 y를 더하여 z에 저장한 후, z의 값과 z의 크기를 출력하도록 하는 코드입니다. 다음의 코드를 실행하면 어떻게 될까요?

>>> z = x + y
>>> print(z)
tensor([[2., 3.],
        [4., 5.]])
>>> print(z.size())
torch.Size([2, 2])

행렬 x의 각 요소들에 모두 1이 더해진 것을 볼 수 있습니다. 여기까지는 쉽게 유추해볼 수 있을 것입니다.

텐서 + 벡터

그럼 행렬에 벡터가 더해지는 경우는 어떻게 될까요?

>>> x = torch.FloatTensor([[1, 2],
...                        [4, 8]])
>>> y = torch.FloatTensor([3,
...                        5])

>>> print(x.size())
torch.Size([2, 2])
>>> print(y.size())
torch.Size([2])

위의 코드를 실행하면 $2\times2$ 행렬 x와 2개의 요소를 갖는 벡터 y를 선언하고, 다음과 같이 각 텐서의 크기를 출력합니다. 이제 이 크기가 다른 두 텐서를 더해보려 합니다. 앞서 두 텐서의 크기를 출력한 것을 보았습니다. 그럼 크기가 다른 두 텐서 사이의 연산을 위해 브로드캐스팅broadcasting이 적용될 경우, 다음과 같이 적용됩니다. 차원에 맞춰 줄을 세우고, 빈 칸의 값이 1이라고 가정할 때, 다른 한쪽에 똑같이 맞춥니다.

[2, 2]     [2, 2]     [2, 2]
[   2] --> [1, 2] --> [2, 2]

이렇게 같은 모양을 맞춘 이후에, 덧셈 연산을 수행합니다. 이것을 수식으로 나타내면 다음과 같습니다. 이 수식에서 $\oplus$ 는 브로드캐스트 덧셈 연산을 의미합니다.

\[\begin{gathered} \begin{bmatrix} 1 & 2 \\ 4 & 8 \end{bmatrix}\oplus\begin{bmatrix} 3 \\ 5 \end{bmatrix}= \begin{bmatrix} 1 & 2 \\ 4 & 8 \end{bmatrix}+\begin{bmatrix} 3 & 5 \\ 3 & 5 \end{bmatrix}=\begin{bmatrix} 4 & 7 \\ 7 & 13 \end{bmatrix} \end{gathered}\]

다음의 코드를 실행하면 우리가 예측한 정답이 나오는 것을 볼 수 있습니다.

>>> z = x + y
>>> print(z)
tensor([[ 4.,  7.],
        [ 7., 13.]])

>>> print(z.size())
torch.Size([2, 2])

그럼 다음의 텐서들의 덧셈은 어떻게 진행될까요? 먼저 다음의 코드를 통해 텐서를 선언하고 크기를 출력합니다.

>>> x = torch.FloatTensor([[[1, 2]]])
>>> y = torch.FloatTensor([3,
...                        5])

>>> print(x.size())
torch.Size([1, 1, 2])
>>> print(y.size())
torch.Size([2])

실행 결과를 보면, 텐서들의 크기를 확인할 수 있습니다. 그럼 좀 전의 규칙을 똑같이 적용해볼 수 있습니다.

[1, 1, 2]     [1, 1, 2]
[      2] --> [1, 1, 2]

이에따라 다음 코드를 수행하면 결과를 얻을 수 있을 것입니다.

>>> z = x + y
>>> print(z)
tensor([[[4., 7.]]])
>>> print(z.size())
torch.Size([1, 1, 2])

텐서 + 텐서

이 브로드캐스팅 규칙은 차원의 크기가 1인 차원에 대해서도 비슷하게 적용됩니다. 다음과 같이 두 텐서를 선언하고, 크기를 출력합니다.

>>> x = torch.FloatTensor([[1, 2]])
>>> y = torch.FloatTensor([[3],
...                        [5]])

>>> print(x.size())
torch.Size([1, 2])
>>> print(y.size())
torch.Size([2, 1])

그럼 출력 결과를 통해 마찬가지로 텐서들의 크기를 확인할 수 있습니다. 여기에도 브로드캐스팅 규칙을 적용하면 다음과 같이 크기가 변화하며 덧셈 연산을 수행할 수 있을 것입니다.

[1, 2] --> [2, 2]
[2, 1] --> [2, 2]

그럼 덧셈 연산을 수행하면 다음과 같은 결과를 얻을 수 있을 것입니다.

>>> z = x + y
>>> print(z)
tensor([[4., 5.],
        [6., 7.]])
>>> print(z.size())
torch.Size([2, 2])

이처럼 브로드캐스팅을 지원하는 연산의 경우에는 크기가 다른 텐서끼리 연산을 수행할 수 있습니다. 다만 앞서 예제에서 볼 수 있듯이 브로드캐스팅 규칙 자체가 복잡하기 때문에, 잘 적용한다면 편리하겠지만, 실수한다면 잘못된 결과를 불러오기 쉽습니다.

자세한 브로드캐스팅 규칙은 파이토치 문서를 참고하세요: https://pytorch.org/docs/stable/notes/broadcasting.html