브로드캐스팅(Broadcasting)
두 행렬 A, B가 있다고 해봅시다. 행렬의 덧셈과 뺄셈에 대해 알고계신다면, 이 덧셈과 뺄셈을 할 때에는 두 행렬 A, B의 크기가 같아야한다는 것을 알고계실겁니다. 그리고 두 행렬이 곱셈을 할 때에는 A의 마지막 차원과 B의 첫번째 차원이 일치해야합니다.
물론, 이런 규칙들이 있지만 딥 러닝을 하게되면 불가피하게 크기가 다른 행렬 또는 텐서에 대해서 사칙 연산을 수행할 필요가 있는 경우가 생깁니다. 이를 위해 파이토치에서는 자동으로 크기를 맞춰서 연산을 수행하게 만드는 브로드캐스팅이라는 기능을 제공합니다.
우선 같은 크기일 때 연산을 하는 경우를 보겠습니다.
m1 = torch.FloatTensor([[3, 3]])
m2 = torch.FloatTensor([[2, 2]])
print(m1 + m2)
tensor([[5., 5.]])
여기서 m1,과 m2의 크기는 둘 다 (1, 2)입니다. 그래서 문제없이 덧셈 연산이 가능합니다. 이번에는 크기가 다른 텐서들 간의 연산을 보겠습니다. 아래는 벡터와 스칼라가 덧셈 연산을 수행하는 것을 보여줍니다. 물론, 수학적으로는 원래 연산이 안 되는게 맞지만 파이토치에서는 브로드캐스팅을 통해 이를 연산합니다.
# Vector + scalar
m1 = torch.FloatTensor([[1, 2]])
m2 = torch.FloatTensor([3]) # [3] -> [3, 3]
print(m1 + m2)
tensor([[4., 5.]])
원래 m1의 크기는 (1, 2)이며 m2의 크기는 (1,)입니다. 그런데 파이토치는 m2의 크기를 (1, 2)로 변경하여 연산을 수행합니다. 이번에는 벡터 간 연산에서 브로드캐스팅이 적용되는 경우를 보겠습니다.
# 2 x 1 Vector + 1 x 2 Vector
m1 = torch.FloatTensor([[1, 2]])
m2 = torch.FloatTensor([[3], [4]])
print(m1 + m2)
tensor([4., 5.],
[5., 6.]])
m1의 크기는 (1, 2) m2의 크기는 (2, 1)였습니다. 이 두 벡터는 원래 수학적으로는 덧셈을 수행할 수 없습니다. 그러나 파이토치는 두 벡터의 크기를 (2, 2)로 변경하여 덧셈을 수행합니다.
# 브로드캐스팅 과정에서 실제로 두 텐서가 어떻게 변경되는지 보겠습니다.
[1, 2]
==> [[1, 2],
[1, 2]]
[3]
[4]
==> [[3, 3],
[4, 4]]
브로드캐스팅은 편리하지만, 자동으로 실행되는 기능이므로 사용자 입장에서 굉장히 주의해서 사용해야 합니다. 예를 들어 A 텐서와 B 텐서가 있을 때, 사용자는 이 두 텐서의 크기가 같다고 착각하고 덧셈 연산을 수행했다고 가정해보겠습니다. 하지만 실제로 이 두 텐서의 크기는 달랐고 브로드캐스팅이 수행되어 덧셈 연산이 수행되었습니다. 만약, 두 텐서의 크기가 다르다고 에러를 발생시킨다면 사용자는 이 연산이 잘못되었음을 바로 알 수 있지만 브로드캐스팅은 자동으로 수행되므로 사용자는 나중에 원하는 결과가 나오지 않았더라도 어디서 문제가 발생했는지 찾기가 굉장히 어려울 수 있습니다.
자주 사용되는 기능들
1) 행렬 곱셈과 곱셈의 차이(Matrix Multiplication Vs. Multiplication)
행렬로 곱셈을 하는 방법은 크게 두 가지가 있습니다. 바로 행렬 곱셈(.matmul)과 원소 별 곱셈(.mul)입니다.
파이토치 텐서의 행렬 곱셈을 보겠습니다. 이는 matmul()을 통해 수행합니다.
m1 = torch.FloatTensor([[1, 2], [3, 4]])
m2 = torch.FloatTensor([[1], [2]])
print('Shape of Matrix 1: ', m1.shape) # 2 x 2
print('Shape of Matrix 2: ', m2.shape) # 2 x 1
print(m1.matmul(m2)) # 2 x 1
Shape of Matrix 1: torch.Size([2, 2])
Shape of Matrix 2: torch.Size([2, 1])
tensor([[ 5.],
[11.]])
위의 결과는 2 x 2 행렬과 2 x 1 행렬(벡터)의 행렬 곱셈의 결과를 보여줍니다.
행렬 곱셈이 아니라 element-wise 곱셈이라는 것이 존재합니다. 이는 동일한 크기의 행렬이 동일한 위치에 있는 원소끼리 곱하는 것을 말합니다. 아래는 서로 다른 크기의 행렬이 브로드캐스팅이 된 후에 element-wise 곱셈이 수행되는 것을 보여줍니다. 이는 * 또는 mul()을 통해 수행합니다.
m1 = torch.FloatTensor([[1, 2], [3, 4]])
m2 = torch.FloatTensor([[1], [2]])
print('Shape of Matrix 1: ', m1.shape) # 2 x 2
print('Shape of Matrix 2: ', m2.shape) # 2 x 1
print(m1 * m2) # 2 x 2
print(m1.mul(m2))
Shape of Matrix 1: torch.Size([2, 2])
Shape of Matrix 2: torch.Size([2, 1])
tensor([[1., 2.],
[6., 8.]])
tensor([[1., 2.],
[6., 8.]])
m1 행렬의 크기는 (2, 2)이었습니다. m2 행렬의 크기는 (2, 1)였습니다. 이때 element-wise 곱셈을 수행하면, 두 행렬의 크기는 브로드캐스팅이 된 후에 곱셈이 수행됩니다. 더 정확히는 여기서 m2의 크기가 변환됩니다.
# 브로드캐스팅 과정에서 m2 텐서가 어떻게 변경되는지 보겠습니다.
[1]
[2]
==> [[1, 1],
[2, 2]]
2) 평균(Mean)
평균을 구하는 방법도 제공하고 있습니다. 이는 Numpy에서의 사용법과 매우 유사합니다.
우선 1차원인 벡터를 선언하여 .mean()을 사용하여 원소의 평균을 구합니다.
t = torch.FloatTensor([1, 2])
print(t.mean())
tensor(1.5000)
1과 2의 평균인 1.5가 나옵니다. 이번에는 2차원인 행렬을 선언하여 .mean()을 사용해봅시다. 우선 2차원 행렬을 선언합니다.
t = torch.FloatTensor([[1, 2], [3, 4]])
print(t)
tensor([[1., 2.],
[3., 4.]])
2차원 행렬이 선언되었습니다. 이제 .mean()을 사용합니다.
print(t.mean())
tensor(2.5000)
4개의 원소의 평균인 2.5가 나왔습니다. 이번에는 dim. 즉, 차원(dimension)을 인자로 주는 경우를 보겠습니다.
print(t.mean(dim=0))
tensor([2., 3.])
dim=0이라는 것은 첫번째 차원을 의미합니다. 행렬에서 첫번째 차원은 '행'을 의미합니다. 그리고 인자로 dim을 준다면 해당 차원을 제거한다는 의미가 됩니다. 다시 말해 행렬에서 '열'만을 남기겠다는 의미가 됩니다. 기존 행렬의 크기는 (2, 2)였지만 이를 수행하면 열의 차원만 보존되면서 (1, 2)가 됩니다. 이는 (2,)와 같으며 벡터입니다. 열의 차원을 보존하면서 평균을 구하면 아래와 같이 연산합니다.
# 실제 연산 과정
t.mean(dim=0)은 입력에서 첫번째 차원을 제거한다.
[[1., 2.],
[3., 4.]]
1과 3의 평균을 구하고, 2와 4의 평균을 구한다.
결과 ==> [2., 3.]
이번에는 인자로 dim=1을 주겠습니다. 이번에는 두번째 차원을 제거합니다. 즉, 열이 제거된 텐서가 되어야 합니다.
print(t.mean(dim=1))
tensor([1.5000, 3.5000])
열의 차원이 제거되어야 하므로 (2, 2)의 크기에서 (2, 1)의 크기가 됩니다. 이번에는 1과 2의 평균을 구하고 3과 4의 평균을 구하게 됩니다. 그렇다면 결과는 아래와 같습니다.
# 실제 연산 결과는 (2 × 1)
[1. 5]
[3. 5]
하지만 (2 × 1)은 결국 1차원이므로 (1 × 2)와 같이 표현되면서 위와 같이 [1.5, 3.5]로 출력됩니다. 이번에는 dim=-1를 주는 경우를 보겠습니다. 이는 마지막 차원을 제거한다는 의미이고, 결국 열의 차원을 제거한다는 의미와 같습니다. 그러므로 위와 출력 결과가 같습니다.
print(t.mean(dim=-1))
tensor([1.5000, 3.5000])
3) 덧셈(Sum)
덧셈(Sum)은 평균(Mean)과 연산 방법이나 인자가 의미하는 바는 정확히 동일합니다. 다만, 평균이 아니라 덧셈을 할 뿐입니다.
t = torch.FloatTensor([[1, 2], [3, 4]])
print(t)
tensor([[1., 2.],
[3., 4.]])
print(t.sum()) # 단순히 원소 전체의 덧셈을 수행
print(t.sum(dim=0)) # 행을 제거
print(t.sum(dim=1)) # 열을 제거
print(t.sum(dim=-1)) # 열을 제거
tensor(10.)
tensor([4., 6.])
tensor([3., 7.])
tensor([3., 7.])
4) 최대(Max)와 아그맥스(ArgMax)
최대(Max)는 원소의 최대값을 리턴하고, 아그맥스(ArgMax)는 최대값을 가진 인덱스를 리턴합니다.
(2, 2) 크기의 행렬을 선언하고 Max를 사용해봅시다.
t = torch.FloatTensor([[1, 2], [3, 4]])
print(t)
tensor([[1., 2.],
[3., 4.]])
우선 (2, 2) 행렬을 선언하였습니다. 이제 .max()를 사용합니다.
print(t.max()) # Returns one value: max
tensor(4.)
원소 중 최대값인 4를 리턴합니다.
이번에는 인자로 dim=0을 주겠습니다. 첫번째 차원을 제거한다는 의미입니다.
print(t.max(dim=0)) # Returns two values: max and argmax
(tensor([3., 4.]), tensor([1, 1]))
행의 차원을 제거한다는 의미이므로 (1, 2) 텐서를 만듭니다. 결과는 [3, 4]입니다.
그런데 [1, 1]이라는 값도 함께 리턴되었습니다. max에 dim 인자를 주면 argmax도 함께 리턴하는 특징 때문입니다. 첫번째 열에서 3의 인덱스는 1이었습니다. 두번째 열에서 4의 인덱스는 1이었습니다. 그러므로 [1, 1]이 리턴됩니다. 어떤 의미인지는 아래 설명해봤습니다.
# [1, 1]가 무슨 의미인지 봅시다. 기존 행렬을 다시 상기해봅시다.
[[1, 2],
[3, 4]]
첫번째 열에서 0번 인덱스는 1, 1번 인덱스는 3입니다.
두번째 열에서 0번 인덱스는 2, 1번 인덱스는 4입니다.
다시 말해 3과 4의 인덱스는 [1, 1]입니다.
만약 두 개를 함께 리턴받는 것이 아니라 max 또는 argmax만 리턴받고 싶다면 다음과 같이 리턴값에도 인덱스를 부여하면 됩니다. 0번 인덱스를 사용하면 max 값만 받아올 수 있고, 1번 인덱스를 사용하면 argmax 값만 받아올 수 있습니다.
print('Max: ', t.max(dim=0)[0])
print('Argmax: ', t.max(dim=0)[1])
Max: tensor([3., 4.])
Argmax: tensor([1, 1])
이번에는 dim=1로 인자를 주었을 때와 dim=-1로 인자를 주었을 때를 보겠습니다.
print(t.max(dim=1))
print(t.max(dim=-1))
(tensor([2., 4.]), tensor([1, 1]))
(tensor([2., 4.]), tensor([1, 1]))