안녕하세요. 지난 포스팅의 넘파이 알고 쓰자 - 덧셈, 곱셈, 뺄셈(Sums, products, differences)에서 중점적으로 확인한 것은 기존의 파이썬의 for loop를 이용한 연산과 math module을 이용한 연산, numpy module을 이용한 연산 사이의 속도를 비교하였습니다. 오늘은 넘파이 라이브러리에서도 가장 중요한 비중을 차지하고 있는 선형대수 라이브러리를 소개하도록 하겠습니다.
다른 프로그래밍 언어에서도 선형대수적인 기법을 제공하기 위해서 많은 방법이 고안되고 있습니다. 대표적으로 BLAS(Basic Linear Algebra Subprogramming), LAPACK(Linear Algebra PACKage) 등이 있습니다. 넘파이의 선형대수 라이브러리는 BLAS와 LAPACK을 기반으로 작성된 라이브러리임을 기억하시면 됩니다.
1. numpy.dot(a, b)
이 함수는 기능이 4가지로 나뉩니다.
- a, b 가 모두 벡터인 경우 : 두 벡터의 내적 계산
- a, b 가 모두 N차원 행렬인 경우 : 두 행렬의 행렬곱 계산
- a, b 가 모두 스칼라인 경우 : 두 스칼라의 스칼라곱 계산
- a는 N차원 행렬, b는 벡터인 경우 : a의 각 row와 b의 내적 계산
각 경우를 예시를 이용해서 알아보도록 하겠습니다. 먼저, a, b 가 모두 벡터인 경우부터 확인해보도록 하겠습니다. 내적을 계산하는 방법은 아주 간단합니다. 벡터의 각 요소별로 곱셈을 한 뒤 전부 더해주면 됩니다. 이를 수식적으로 표현해보도록 하겠습니다. $\mathcal{a} = <a_{1}, a_{2}, \dots, a_{n}>$, $\mathcal{b} = <b_{1}, b_{2}, \dots, b_{n}>$ 이라고 가정하면 $a \cdot b = a_{1} \times b_{1} + a_{2} \times b_{2} + \cdots + a_{n} \times b_{n} = \sum_{i=1}^{n} a_{i} \times b_{i}$입니다. 느낌이 오시겠습지만 만약 두 벡터의 길이, 즉 원소의 개수가 다르면 안된다는 것을 알 수 있습니다.
a = np.array([1, 2, 3])
b = np.array([4, 5, 6])
np.dot(a, b) # 32
a = np.array([1, 2, 3])
b = np.array([4, 5, 6, 7, 8])
np.dot(a, b)
# ---------------------------------------------------------------------------
# ValueError Traceback (most recent call last)
# <ipython-input-7-b9b8031abb6b> in <module>
# 2 b = np.array([4, 5, 6, 7, 8])
# 3
# ----> 4 np.dot(a, b) # 32
# <__array_function__ internals> in dot(*args, **kwargs)
# ValueError: shapes (3,) and (5,) not aligned: 3 (dim 0) != 5 (dim 0)
실제로 두 벡터의 원소의 개수가 같은 경우에는 이상없이 잘 계산되지만 만약, 벡터의 길이가 다르다면 ValueError로 shape가 다르다는 오류를 띄워주게 됩니다. 이 오류는 넘파이에서 행렬을 다룰 때 가장 많이 보는 오류이기 때문에 눈여겨 보시는 것을 추천드립니다.
다음은 두 행렬 사이의 곱입니다. 행렬 곱을 계산하는 방법도 아주 간단합니다. 먼저 곱할 두 행렬을 아래와 같이 정의를 하겠습니다.
$$A = \left[\begin{array}{ll} a_{11} & a_{12} & \cdots & a_{1m} \\ a_{21} & a_{22} & \cdots & a_{2m} \\ \vdots & \vdots & \cdots & \vdots \\ a_{n1} & a_{n2} & \cdots & a_{nm}\end{array}\right]$$
$$B = \left[\begin{array}{ll} b_{11} & b_{12} & \cdots & b_{1k} \\ b_{21} & b_{22} & \cdots & b_{2k} \\ \vdots & \vdots & \cdots & \vdots \\ b_{m1} & b_{m2} & \cdots & b_{mk}\end{array}\right]$$
그러면 두 행렬 $A, B$의 곱의 결과는 $C_{i, j} = a_{i, 1} \times b_{1, j} + a_{i, 2} \times b_{2, j} + \cdots + a_{i, m} \times b_{m, j} = \sum_{l = 1}^{m} a_{i, l} \times b_{l, j}$입니다. 이때, $A \in \mathcal{R}^{n \times m}$, $B \in \mathcal{R}^{m \times k}$입니다. 두 행렬곱이 정의되기 위해서는 행렬 $A$의 열의 개수와 행렬 $B$의 행의 개수가 동일해야합니다. 그렇지 않으면 애초에 곱셈이 정의가 되지 않겠죠. 만약, 행렬의 모양이 제대로 정의만 된다면 행렬의 곱 결과는 $C \in \mathcal{R}^{n \times k}$입니다. 이를 원칙을 넘파이스럽게 생각해보면 두 행렬의 임의의 shape은 행렬곱이 연산되기 위해 (n, m), (m, k)로 정의되야함을 알 수 있습니다. 또한 그 결과 행렬의 shape은 (n, k)라는 거죠. 아래의 예제 코드를 보도록 하겠습니다.
a = np.array([[1, 2, 3], [4, 5, 6]])
b = np.array([[7, 8, 9], [10, 11, 12], [13, 14, 15]])
print("a.shape = ", a.shape)
print("b.shape = ", b.shape)
# a.shape = (2, 3)
# b.shape = (3, 3)
np.dot(a, b)
# array([[ 66, 72, 78],
# [156, 171, 186]])
a = np.array([[1, 2, 3], [4, 5, 6]])
b = np.array([[7, 8, 9], [10, 11, 12]])
np.dot(a, b)
# ---------------------------------------------------------------------------
# ValueError Traceback (most recent call last)
# <ipython-input-13-84c86f58b2aa> in <module>
# 2 b = np.array([[7, 8, 9], [10, 11, 12]])
# 3
# ----> 4 np.dot(a, b)
# 5 # array([[ 66, 72, 78],
# 6 # [156, 171, 186]])
# <__array_function__ internals> in dot(*args, **kwargs)
# ValueError: shapes (2,3) and (2,3) not aligned: 3 (dim 1) != 2 (dim 0)
첫번째 코드에서 각 행렬의 shape을 보면 $a$는 (2, 3), $b$는 (3, 3)입니다. 따라서 행렬곱이 잘 정의되고 연산 결과 행렬의 shape이 (2, 3)이 되는 것까지 확인할 수 있습니다. 하지만 두번째 코드에서 각 행렬의 shpae을 보면 $a$는 (2, 3)이고, $b$는 (2, 3)입니다. 이는 행렬곱이 연산되기에 적절치않은 행렬이기 때문에 ValueError가 발생하게 됩니다.
다음으로 볼 것은 만약 두 행렬이 모두 스칼라인 경우입니다. 이는 그냥 두 값의 곱과 동일합니다. 이는 아래의 코드를 보시면 바로 이해가 가기 때문에 예시만 보시면 됩니다.
a = np.array(1)
b = np.array(2)
np.dot(a, b) # 2
마지막으로 확인해볼 것은 행렬과 벡터 사이의 곱입니다. 이 경우에는 바로 위에서 설명드렸다싶이 행렬의 각 행과 벡터 사이의 내적의 결과를 반환하는 함수입니다.
$$A = \left[\begin{array}{ll} a_{11} & a_{12} & \cdots & a_{1m} \\ a_{21} & a_{22} & \cdots & a_{2m} \\ \vdots & \vdots & \cdots & \vdots \\ a_{n1} & a_{n2} & \cdots & a_{nm}\end{array}\right]$$
$$b = \left[\begin{array}{ll} b_{1} \\ b_{2} \\ \vdots \\ b_{m}\end{array}\right]$$
그러면 행렬과 벡터 사이의 곱 결과는 아래와 같습니다.
$$C = \left[\begin{array}{ll} a_{1, :} \cdot b \\ a_{2, :} \cdot b \\ \vdots \\ a_{i, :} \cdot b \\ a_{n, :} \cdot b\end{array}\right]$$
여기서 $a_{i, :}$는 행렬의 $i$번째 행, $\cdot$은 벡터의 내적을 의미합니다. 바로 예제 코드를 보도록 하겠습니다.
a = np.arange(1, 7).reshape(2, 3)
b = np.array([7, 8, 9])
print("a.shape = ", a.shape)
print("b.shape = ", b.shape)
# a.shape = (2, 3)
# b.shape = (3,)
np.dot(a, b) # array([ 50, 122])
a = np.arange(1, 7).reshape(2, 3)
b = np.array([7, 8, 9, 10])
print("a.shape = ", a.shape)
print("b.shape = ", b.shape)
# a.shape = (2, 3)
# b.shape = (4,)
np.dot(a, b)
# ---------------------------------------------------------------------------
# ValueError Traceback (most recent call last)
# <ipython-input-19-e3edd6113717> in <module>
# 7 # b.shape = (3,)
# 8
# ----> 9 np.dot(a, b) # array([ 50, 122])
# <__array_function__ internals> in dot(*args, **kwargs)
# ValueError: shapes (2,3) and (4,) not aligned: 3 (dim 1) != 4 (dim 0)
이 역시 다른 경우들과 마찬가지로 만약 행렬, 벡터의 shape이 맞지 않으면 오류를 반환하게 됩니다.
2. numpy.linalg.multi_dot(arrays)
기본적으로 행렬 연산은 굉장히 많은 비용을 소비합니다. 그런데 재밌는 점은 행렬을 어떤 순서로 계산하느냐에 따라서 연산량이 완전히 달라지게 됩니다. 이를 빠르게 확인하여 연산량이 가장 적은 순으로 계산하는 알고리즘이 바로 matrix chain multiplication입니다. 이 알고리즘은 dynamic programming을 이용하여 어떤 순서로 계산해야 행렬곱이 가장 빠르게 계산될 지 확인합니다. 아래의 예제를 보도록 하겠습니다.
def cost(A, B):
return A.shape[0] * A.shape[1] * B.shape[1]
A = np.random.randn(10, 100)
B = np.random.randn(100, 5)
C = np.random.randn(5, 50)
cost(np.dot(A, B), C) # 2500
cost(A, np.dot(B, C)) # 50000
위 코드에서 cost 함수는 행렬곱의 연산량을 계산하는 함수입니다. 저희는 동일하게 행렬곱 $ABC$를 계산하지만 $A$와 $B$를 먼저 곱한 뒤 $C$를 곱하는 연산 순서와 $B$와 $C$를 먼저 곱한 뒤 $A$를 곱하는 연산 순서와 매우 큰 연산량 차이를 보입니다. multi_dot 함수에서는 이와 같이 연산량이 적은 연산순서를 찾아서 자동으로 계산을 빠르게 수행해주게 됩니다.
A = np.random.random((10000, 100))
B = np.random.random((100, 1000))
C = np.random.random((1000, 5))
D = np.random.random((5, 333))
_ = np.linalg.multi_dot([A, B, C, D])
_ = np.dot(np.dot(np.dot(A, B), C), D)
위의 코드같이 사용하면 됩니다. 위의 경우에 가장 빠르게 연산할 수 있는 순서는 $A$와 $B$를 계산 -> $C$ 계산 -> $D$ 계산입니다. 물론 행렬의 shape이 다르면 다른 순서가 더 빠를 수 있습니다. 평소에 위의 코드를 항상 어떤 순서로 계산해야 빠르게 계산가능한지 모르기 때문에 위 함수를 사용하면 아주 편리하고 빠르게 계산할 수 있습니다.
3. numpy.vdot( a , b )
이 함수는 dot 함수와 유사하지만 dot 함수가 처리하지 못하는 복소수를 포함하여 처리해주는 함수입니다. 하지만, 행렬에는 적합하지 않고 두 벡터 사이의 내적을 계산할 때만 사용하는 것이 좋습니다.
a = np.array([1+2j,3+4j])
b = np.array([5+6j,7+8j])
np.vdot(a, b) # (70-8j)
np.vdot(b, a) # (70+8j)
혹시라도 모르시는 분들을 위해 미리 말씀드리면 복소수 상에서 정의되는 내적은 실수 상에서 정의된 내적과 연산이 살짝 다릅니다. 실수에서는 그냥 두 벡터의 요소별 곱셈을 한 뒤 덧셈을 취하면 되지만 복소수 상에서는 먼저 $a$ 벡터에 켤레복소수를 취한 뒤에 연산을 적용하기 때문에 실수와는 다르게 $a \cdot b \neq b \cdot a$인 것을 볼 수 있습니다.
4, numpy.inner(a, b)
이 함수는 dot, vdot 함수와 거의 동일한 함수입니다. 차이점은 항상 행렬-벡터, 또는 벡터-벡터 사이의 연산을 취한다는 점과 복소수같은 경우에는 켤레복소수를 적용하지 않고 내적을 계산한다는 점입니다.
a = np.array([1, 2, 3])
b = np.array([4, 5, 6])
print("a.shape = ", a.shape)
print("b.shape = ", b.shape)
# a.shape = (3,)
# b.shape = (3,)
np.dot(a, b) # 32
np.inner(a, b) # 32
a = np.array([1+2j,3+4j])
b = np.array([5+6j,7+8j])
np.inner(a, b) # (-18+68j)
np.inner(b, a) # (-18+68j)
a = np.arange(1, 7).reshape(2, 3)
b = np.array([7, 8, 9])
print("a.shape = ", a.shape)
print("b.shape = ", b.shape)
# a.shape = (2, 3)
# b.shape = (3,)
np.dot(a, b) # array([ 50, 122])
np.inner(a, b) # array([ 50, 122])
위에서 첫번째 결과와 세번째 결과 모두 저희가 원하는 값이 나왔지만 두번째 결과는 수학적으로는 정확하지 않기 때문에 만약 복소수에서의 내적을 계산하려면 vdot 함수를 사용하는 것이 더 정확합니다.
5. numpy.outer(a, b)
이번에는 외적을 계산하는 함수입니다. 내적과 외적은 가장 큰 차이점을 보면 내적의 연산 결과는 실수, 또는 복소수의 스칼라이지만 외적은 벡터, 또는 행렬이 그 결과로 나옵니다. 먼저, 두 벡터를 정의하도록 하겠습니다.
$$a = \left[\begin{array}{ll} a_{1} \\ a_{2} \\ \vdots \\ a_{m}\end{array}\right]$$
$$b = \left[\begin{array}{ll} b_{1} \\ b_{2} \\ \vdots \\ b_{n}\end{array}\right]$$
그러면 두 벡터 사이의 외적은 아래와 같습니다.
$$a \times b = \left[\begin{array}{ll} a_{1} * b_{1} & a_{1} * b_{2} & \cdots & a_{1} * b_{n} \\ a_{2} * b_{1} & a_{2} * b_{2} & \cdots & a_{2} * b_{n} \\ \vdots & \vdots & \cdots & \vdots \\ a_{m} * b_{1} & a_{m} * b_{2} & \cdots & a_{m} * b_{n}\end{array}\right]$$
바로 예제 코드를 확인해보도록 하겠습니다.
rl = np.outer(np.ones((5,)), np.linspace(-2, 2, 5))
# array([[-2., -1., 0., 1., 2.],
# [-2., -1., 0., 1., 2.],
# [-2., -1., 0., 1., 2.],
# [-2., -1., 0., 1., 2.],
# [-2., -1., 0., 1., 2.]])
이는 두 벡터 $\left[\begin{array}{ll} 1 & 1 & 1 & 1 & 1 \end{array}\right]$와 $\left[\begin{array}{ll} -2 & -1 & 0 & 1 & 2 \end{array}\right]$ 사이의 외적을 계산하는 코드입니다. 이는 실제로 계산해보면 결과와 동일합니다.
6. numpy.matmul(x1, x2)
이 함수의 기본적인 기능은 두 배열의 행렬곱을 하는 것입니다. 그런데 지난 포스팅의 dot 함수를 기억하시나요? dot 함수 역시 두 배열의 행렬곱을 수행하는 함수인데, 두 함수에는 어떤 차이가 존재할까요? 일단, 2차원 이하의 배열의 대해서는 아래의 예제와 같이 두 함수 모두 동일한 결과를 보여줍니다.
a = np.arange(5)
b = np.arange(5)
np.dot(a, b) # 30
np.matmul(a, b) # 30
a = np.arange(9).reshape(3, 3)
b = np.arange(9).reshape(3, 3)
np.dot(a, b)
# array([[ 15, 18, 21],
# [ 42, 54, 66],
# [ 69, 90, 111]])
np.matmul(a, b)
# array([[ 15, 18, 21],
# [ 42, 54, 66],
# [ 69, 90, 111]])
그런데 3차원 이상부터는 두 함수의 결과가 달라지기 시작합니다.
a = np.arange(27).reshape(3, 3, 3)
b = np.arange(27).reshape(3, 3, 3)
np.dot(a, b)
# array([[[[ 15, 18, 21],
# [ 42, 45, 48],
# [ 69, 72, 75]],
# [[ 42, 54, 66],
# [ 150, 162, 174],
# [ 258, 270, 282]],
# [[ 69, 90, 111],
# [ 258, 279, 300],
# [ 447, 468, 489]]],
# [[[ 96, 126, 156],
# [ 366, 396, 426],
# [ 636, 666, 696]],
# [[ 123, 162, 201],
# [ 474, 513, 552],
# [ 825, 864, 903]],
# [[ 150, 198, 246],
# [ 582, 630, 678],
# [1014, 1062, 1110]]],
# [[[ 177, 234, 291],
# [ 690, 747, 804],
# [1203, 1260, 1317]],
# [[ 204, 270, 336],
# [ 798, 864, 930],
# [1392, 1458, 1524]],
# [[ 231, 306, 381],
# [ 906, 981, 1056],
# [1581, 1656, 1731]]]])
np.matmul(a, b)
# array([[[ 15, 18, 21],
# [ 42, 54, 66],
# [ 69, 90, 111]],
# [[ 366, 396, 426],
# [ 474, 513, 552],
# [ 582, 630, 678]],
# [[1203, 1260, 1317],
# [1392, 1458, 1524],
# [1581, 1656, 1731]]])
두 함수의 결과를 보면 애초에 shape부터 큰 차이를 보이는 것을 볼 수 있습니다. 어떤 차이가 있는 지 넘파이의 정식 문서를 기반으로 확인해보도록 하겠습니다. 제가 이전 포스팅에서 생략한 내용 중에 하나가 있습니다. 바로 dot 함수의 5번째 정의인 텐서곱(tensor product)입니다.. 이 5번째 정의에 의해서 두 함수가 다른 결과를 보여주게 됩니다. 아래는 dot 함수의 텐서곱 정의를 그대로 해석한 문장입니다.
If $a$ is an $N$-D array and $b$ is an $M$-D array where $M \ge 2$, it is a sum product over the last axis of $a$ and the second-to-last axis of $b$.
만약 $a$가 $N$차원 배열이고 $b$가 2차원 이상의 $M$차원 배열이라면 dot 함수는 $a$의 마지막 축과 $b$의 마지막에서 2번째 축의 곱의 합(내적)으로 정의된다.
이 정의를 통해서 알 수 있는 한가지는 $a$의 마지막 축의 길이와 $b$의 마지막에서 2번째 축의 길이가 일치해야한다는 것입니다. 그래야 대응되는 원소끼리 곱한 뒤 더할 수 있겠죠? 만약 맞지 않으면 짝이 맞지 않기 때문에 오류가 발생할 것입니다. 이를 간단한 예제를 통해 보도록 하겠습니다.
a = np.arange(4 * 6 * 3).reshape(4, 6, 3)
b1 = np.arange(4 * 6 * 3).reshape(4, 6, 3)
b2 = np.arange(4 * 6 * 3).reshape(4, 3, 6)
b3 = np.arange(4 * 6 * 3).reshape(6, 4, 3)
b4 = np.arange(4 * 6 * 3).reshape(6, 3, 4)
b5 = np.arange(4 * 6 * 3).reshape(3, 4, 6)
b6 = np.arange(4 * 6 * 3).reshape(3, 6, 4)
np.dot(a, b1) # ERROR -> (4, 6, 3) * (4, 6, 3) -> a의 마지막 축의 길이 = 3 != 6 = b1의 마지막에서 2번째 축의 길이
np.dot(a, b2) # OK -> (4, 6, 3) * (4, 3, 6) = (4, 6, 4, 6) -> a의 마지막 축의 길이 = 3 = 3 = b1의 마지막에서 2번째 축의 길이
np.dot(a, b3) # ERROR -> (4, 6, 3) * (6, 4, 3) -> a의 마지막 축의 길이 = 3 != 4 = b1의 마지막에서 2번째 축의 길이
np.dot(a, b4) # OK -> (4, 6, 3) * (6, 3, 4) = (4, 6, 6, 4) -> a의 마지막 축의 길이 = 3 = 3 = b1의 마지막에서 2번째 축의 길이
np.dot(a, b5) # ERROR -> (4, 6, 3) * (3, 4, 6) -> a의 마지막 축의 길이 = 3 != 4 = b1의 마지막에서 2번째 축의 길이
np.dot(a, b6) # ERROR -> (4, 6, 3) * (3, 6, 4) -> a의 마지막 축의 길이 = 3 != 6 = b1의 마지막에서 2번째 축의 길이
이 중에서 2번째, 4번째 결과만 $a$의 마지막 축의 길이와 $b$의 마지막에서 2번째 축의 길이가 동일하기 때문에 계산이 가능하고 나머지에서는 전부 오류가 발생합니다. 넘파이에서는 3차원 배열끼리의 dot 함수를 수식적으로 아래와 같이 정의한다고 합니다.
dot(a, b)[i, j, k, m] = sum(a[i, j, :] * b[k, :, m])
그렇다면 이를 좀 더 일반화해서 표현할 수 없을까요? 아주 간단합니다. $a$를 $N$ 차원 배열, $b$를 $M$차원 배열이라고 가정하면 아래와 같이 작성할 수 있습니다.
dot(a, b)[i1, i2, ... ,iN, j1, j2, ... ,jM] = sum(a[i1, i2, ..., i(N-1), :] * b[j1, j2, ..., :, jM])
음... 일단 dot 함수에 대해서 쭉 정리를 해보았습니다. 그렇다면 matmul 함수에 대한 정확한 정의는 무엇일까요? 다차원 배열에서의 matmul 함수의 정의는 아래와 같습니다.
If either argument is $N$-D, $N > 2$, it is treated as a stack of matrices residing in the last two indexes and broadcast accordingly.
2차원 배열보다 큰 $N$ 차원 배열은 마지막 2개의 axis에 대해서 나머지 축을 따라서 stacking 한 것으로 간주한다.
방금 전의 예시로 설명하면 (4, 6, 3) 행렬이 있다면, (6, 3) 행렬을 4개 가지고 있다는 것으로 간주한다는 것입니다. 이때, 행렬곱은 이 마지막 2개의 축만을 가지고 수행하기 때문에 만약 마지막 2개의 축의 shape이 조건에 맞지 않는 다면 오류가 발생합니다. 아래의 예제를 보도록 하죠.
a = np.arange(4 * 6 * 3).reshape(4, 6, 3)
b1 = np.arange(4 * 6 * 3).reshape(4, 6, 3)
b2 = np.arange(4 * 6 * 3).reshape(4, 3, 6)
b3 = np.arange(4 * 6 * 3).reshape(6, 4, 3)
b4 = np.arange(4 * 6 * 3).reshape(6, 3, 4)
b5 = np.arange(4 * 6 * 3).reshape(3, 4, 6)
b6 = np.arange(4 * 6 * 3).reshape(3, 6, 4)
np.matmul(a, b1) # ERROR -> (4, 6, 3) * (4, 6, 3) -> a의 마지막 2개의 shape = (6, 3) != (6, 3) = b1의 마지막 2개의 shape
np.matmul(a, b2) # OK -> (4, 6, 3) * (4, 3, 6) = (4, 6, 6) -> a의 마지막 2개의 shape = (6, 3) = (3, 6) = b2의 마지막 2개의 shape
np.matmul(a, b3) # ERROR -> (4, 6, 3) * (6, 4, 3) -> a의 마지막 2개의 shape = (6, 3) != (4, 3) = b3의 마지막 2개의 shape
np.matmul(a, b4) # ERROR -> (4, 6, 3) * (6, 3, 4) -> a의 마지막 2개의 shape = (6, 3) = (3, 4) = b4의 마지막 2개의 shape, but a의 개수 = 4 != 6 = b4의 개수
np.matmul(a, b5) # ERROR -> (4, 6, 3) * (3, 4, 6) -> a의 마지막 2개의 shape = (6, 3) != (4, 6) = b3의 마지막 2개의 shape
np.matmul(a, b6) # ERROR -> (4, 6, 3) * (3, 6, 4) -> a의 마지막 2개의 shape = (6, 3) != (6, 4) = b3의 마지막 2개의 shape
위의 예시를 보시면 마지막 2개의 축에 해당하는 행렬를 이용해서 행렬곱을 하게 됩니다. 이때, 두 행렬의 shape이 일치하더라도 나머지 축의 길이가 맞지 않으면 오류가 발생하는 것을 볼 수 있습니다.
정리하면, dot 함수와 matmul 함수는 2차원 이하의 행렬에서는 동일한 결과를 낸다. 하지만 3차원 이상의 다차원 행렬에서는 계산하는 방식이 달라지기 때문에 동일한 행렬에 대해서 계산이 되지 않을 수도 있고 계산되더라도 결과와 그 shape이 다르다는 것입니다.
'Programming > Python' 카테고리의 다른 글
넘파이 알고 쓰자 - Linear Algebra Library 3 : 행렬의 고윳값과 고유벡터 (0) | 2020.11.22 |
---|---|
넘파이 알고 쓰자 - Linear Algebra Library 2 : 아인슈타인 표기법 (0) | 2020.11.20 |
넘파이 알고 쓰자 - 덧셈, 곱셈, 뺄셈(Sums, products, differences) (0) | 2020.11.12 |
넘파이 알고 쓰자 - 쌍곡선 함수(Hyperbolic functions) (0) | 2020.11.06 |
넘파이 알고 쓰자 - 삼각함수(Trigonometric functions) (1) | 2020.11.04 |
안녕하세요. 지난 포스팅의 넘파이 알고 쓰자 - 덧셈, 곱셈, 뺄셈(Sums, products, differences)에서 중점적으로 확인한 것은 기존의 파이썬의 for loop를 이용한 연산과 math module을 이용한 연산, numpy module을 이용한 연산 사이의 속도를 비교하였습니다. 오늘은 넘파이 라이브러리에서도 가장 중요한 비중을 차지하고 있는 선형대수 라이브러리를 소개하도록 하겠습니다.
다른 프로그래밍 언어에서도 선형대수적인 기법을 제공하기 위해서 많은 방법이 고안되고 있습니다. 대표적으로 BLAS(Basic Linear Algebra Subprogramming), LAPACK(Linear Algebra PACKage) 등이 있습니다. 넘파이의 선형대수 라이브러리는 BLAS와 LAPACK을 기반으로 작성된 라이브러리임을 기억하시면 됩니다.
1. numpy.dot(a, b)
이 함수는 기능이 4가지로 나뉩니다.
- a, b 가 모두 벡터인 경우 : 두 벡터의 내적 계산
- a, b 가 모두 N차원 행렬인 경우 : 두 행렬의 행렬곱 계산
- a, b 가 모두 스칼라인 경우 : 두 스칼라의 스칼라곱 계산
- a는 N차원 행렬, b는 벡터인 경우 : a의 각 row와 b의 내적 계산
각 경우를 예시를 이용해서 알아보도록 하겠습니다. 먼저, a, b 가 모두 벡터인 경우부터 확인해보도록 하겠습니다. 내적을 계산하는 방법은 아주 간단합니다. 벡터의 각 요소별로 곱셈을 한 뒤 전부 더해주면 됩니다. 이를 수식적으로 표현해보도록 하겠습니다. $\mathcal{a} = <a_{1}, a_{2}, \dots, a_{n}>$, $\mathcal{b} = <b_{1}, b_{2}, \dots, b_{n}>$ 이라고 가정하면 $a \cdot b = a_{1} \times b_{1} + a_{2} \times b_{2} + \cdots + a_{n} \times b_{n} = \sum_{i=1}^{n} a_{i} \times b_{i}$입니다. 느낌이 오시겠습지만 만약 두 벡터의 길이, 즉 원소의 개수가 다르면 안된다는 것을 알 수 있습니다.
a = np.array([1, 2, 3])
b = np.array([4, 5, 6])
np.dot(a, b) # 32
a = np.array([1, 2, 3])
b = np.array([4, 5, 6, 7, 8])
np.dot(a, b)
# ---------------------------------------------------------------------------
# ValueError Traceback (most recent call last)
# <ipython-input-7-b9b8031abb6b> in <module>
# 2 b = np.array([4, 5, 6, 7, 8])
# 3
# ----> 4 np.dot(a, b) # 32
# <__array_function__ internals> in dot(*args, **kwargs)
# ValueError: shapes (3,) and (5,) not aligned: 3 (dim 0) != 5 (dim 0)
실제로 두 벡터의 원소의 개수가 같은 경우에는 이상없이 잘 계산되지만 만약, 벡터의 길이가 다르다면 ValueError로 shape가 다르다는 오류를 띄워주게 됩니다. 이 오류는 넘파이에서 행렬을 다룰 때 가장 많이 보는 오류이기 때문에 눈여겨 보시는 것을 추천드립니다.
다음은 두 행렬 사이의 곱입니다. 행렬 곱을 계산하는 방법도 아주 간단합니다. 먼저 곱할 두 행렬을 아래와 같이 정의를 하겠습니다.
$$A = \left[\begin{array}{ll} a_{11} & a_{12} & \cdots & a_{1m} \\ a_{21} & a_{22} & \cdots & a_{2m} \\ \vdots & \vdots & \cdots & \vdots \\ a_{n1} & a_{n2} & \cdots & a_{nm}\end{array}\right]$$
$$B = \left[\begin{array}{ll} b_{11} & b_{12} & \cdots & b_{1k} \\ b_{21} & b_{22} & \cdots & b_{2k} \\ \vdots & \vdots & \cdots & \vdots \\ b_{m1} & b_{m2} & \cdots & b_{mk}\end{array}\right]$$
그러면 두 행렬 $A, B$의 곱의 결과는 $C_{i, j} = a_{i, 1} \times b_{1, j} + a_{i, 2} \times b_{2, j} + \cdots + a_{i, m} \times b_{m, j} = \sum_{l = 1}^{m} a_{i, l} \times b_{l, j}$입니다. 이때, $A \in \mathcal{R}^{n \times m}$, $B \in \mathcal{R}^{m \times k}$입니다. 두 행렬곱이 정의되기 위해서는 행렬 $A$의 열의 개수와 행렬 $B$의 행의 개수가 동일해야합니다. 그렇지 않으면 애초에 곱셈이 정의가 되지 않겠죠. 만약, 행렬의 모양이 제대로 정의만 된다면 행렬의 곱 결과는 $C \in \mathcal{R}^{n \times k}$입니다. 이를 원칙을 넘파이스럽게 생각해보면 두 행렬의 임의의 shape은 행렬곱이 연산되기 위해 (n, m), (m, k)로 정의되야함을 알 수 있습니다. 또한 그 결과 행렬의 shape은 (n, k)라는 거죠. 아래의 예제 코드를 보도록 하겠습니다.
a = np.array([[1, 2, 3], [4, 5, 6]])
b = np.array([[7, 8, 9], [10, 11, 12], [13, 14, 15]])
print("a.shape = ", a.shape)
print("b.shape = ", b.shape)
# a.shape = (2, 3)
# b.shape = (3, 3)
np.dot(a, b)
# array([[ 66, 72, 78],
# [156, 171, 186]])
a = np.array([[1, 2, 3], [4, 5, 6]])
b = np.array([[7, 8, 9], [10, 11, 12]])
np.dot(a, b)
# ---------------------------------------------------------------------------
# ValueError Traceback (most recent call last)
# <ipython-input-13-84c86f58b2aa> in <module>
# 2 b = np.array([[7, 8, 9], [10, 11, 12]])
# 3
# ----> 4 np.dot(a, b)
# 5 # array([[ 66, 72, 78],
# 6 # [156, 171, 186]])
# <__array_function__ internals> in dot(*args, **kwargs)
# ValueError: shapes (2,3) and (2,3) not aligned: 3 (dim 1) != 2 (dim 0)
첫번째 코드에서 각 행렬의 shape을 보면 $a$는 (2, 3), $b$는 (3, 3)입니다. 따라서 행렬곱이 잘 정의되고 연산 결과 행렬의 shape이 (2, 3)이 되는 것까지 확인할 수 있습니다. 하지만 두번째 코드에서 각 행렬의 shpae을 보면 $a$는 (2, 3)이고, $b$는 (2, 3)입니다. 이는 행렬곱이 연산되기에 적절치않은 행렬이기 때문에 ValueError가 발생하게 됩니다.
다음으로 볼 것은 만약 두 행렬이 모두 스칼라인 경우입니다. 이는 그냥 두 값의 곱과 동일합니다. 이는 아래의 코드를 보시면 바로 이해가 가기 때문에 예시만 보시면 됩니다.
a = np.array(1)
b = np.array(2)
np.dot(a, b) # 2
마지막으로 확인해볼 것은 행렬과 벡터 사이의 곱입니다. 이 경우에는 바로 위에서 설명드렸다싶이 행렬의 각 행과 벡터 사이의 내적의 결과를 반환하는 함수입니다.
$$A = \left[\begin{array}{ll} a_{11} & a_{12} & \cdots & a_{1m} \\ a_{21} & a_{22} & \cdots & a_{2m} \\ \vdots & \vdots & \cdots & \vdots \\ a_{n1} & a_{n2} & \cdots & a_{nm}\end{array}\right]$$
$$b = \left[\begin{array}{ll} b_{1} \\ b_{2} \\ \vdots \\ b_{m}\end{array}\right]$$
그러면 행렬과 벡터 사이의 곱 결과는 아래와 같습니다.
$$C = \left[\begin{array}{ll} a_{1, :} \cdot b \\ a_{2, :} \cdot b \\ \vdots \\ a_{i, :} \cdot b \\ a_{n, :} \cdot b\end{array}\right]$$
여기서 $a_{i, :}$는 행렬의 $i$번째 행, $\cdot$은 벡터의 내적을 의미합니다. 바로 예제 코드를 보도록 하겠습니다.
a = np.arange(1, 7).reshape(2, 3)
b = np.array([7, 8, 9])
print("a.shape = ", a.shape)
print("b.shape = ", b.shape)
# a.shape = (2, 3)
# b.shape = (3,)
np.dot(a, b) # array([ 50, 122])
a = np.arange(1, 7).reshape(2, 3)
b = np.array([7, 8, 9, 10])
print("a.shape = ", a.shape)
print("b.shape = ", b.shape)
# a.shape = (2, 3)
# b.shape = (4,)
np.dot(a, b)
# ---------------------------------------------------------------------------
# ValueError Traceback (most recent call last)
# <ipython-input-19-e3edd6113717> in <module>
# 7 # b.shape = (3,)
# 8
# ----> 9 np.dot(a, b) # array([ 50, 122])
# <__array_function__ internals> in dot(*args, **kwargs)
# ValueError: shapes (2,3) and (4,) not aligned: 3 (dim 1) != 4 (dim 0)
이 역시 다른 경우들과 마찬가지로 만약 행렬, 벡터의 shape이 맞지 않으면 오류를 반환하게 됩니다.
2. numpy.linalg.multi_dot(arrays)
기본적으로 행렬 연산은 굉장히 많은 비용을 소비합니다. 그런데 재밌는 점은 행렬을 어떤 순서로 계산하느냐에 따라서 연산량이 완전히 달라지게 됩니다. 이를 빠르게 확인하여 연산량이 가장 적은 순으로 계산하는 알고리즘이 바로 matrix chain multiplication입니다. 이 알고리즘은 dynamic programming을 이용하여 어떤 순서로 계산해야 행렬곱이 가장 빠르게 계산될 지 확인합니다. 아래의 예제를 보도록 하겠습니다.
def cost(A, B):
return A.shape[0] * A.shape[1] * B.shape[1]
A = np.random.randn(10, 100)
B = np.random.randn(100, 5)
C = np.random.randn(5, 50)
cost(np.dot(A, B), C) # 2500
cost(A, np.dot(B, C)) # 50000
위 코드에서 cost 함수는 행렬곱의 연산량을 계산하는 함수입니다. 저희는 동일하게 행렬곱 $ABC$를 계산하지만 $A$와 $B$를 먼저 곱한 뒤 $C$를 곱하는 연산 순서와 $B$와 $C$를 먼저 곱한 뒤 $A$를 곱하는 연산 순서와 매우 큰 연산량 차이를 보입니다. multi_dot 함수에서는 이와 같이 연산량이 적은 연산순서를 찾아서 자동으로 계산을 빠르게 수행해주게 됩니다.
A = np.random.random((10000, 100))
B = np.random.random((100, 1000))
C = np.random.random((1000, 5))
D = np.random.random((5, 333))
_ = np.linalg.multi_dot([A, B, C, D])
_ = np.dot(np.dot(np.dot(A, B), C), D)
위의 코드같이 사용하면 됩니다. 위의 경우에 가장 빠르게 연산할 수 있는 순서는 $A$와 $B$를 계산 -> $C$ 계산 -> $D$ 계산입니다. 물론 행렬의 shape이 다르면 다른 순서가 더 빠를 수 있습니다. 평소에 위의 코드를 항상 어떤 순서로 계산해야 빠르게 계산가능한지 모르기 때문에 위 함수를 사용하면 아주 편리하고 빠르게 계산할 수 있습니다.
3. numpy.vdot( a , b )
이 함수는 dot 함수와 유사하지만 dot 함수가 처리하지 못하는 복소수를 포함하여 처리해주는 함수입니다. 하지만, 행렬에는 적합하지 않고 두 벡터 사이의 내적을 계산할 때만 사용하는 것이 좋습니다.
a = np.array([1+2j,3+4j])
b = np.array([5+6j,7+8j])
np.vdot(a, b) # (70-8j)
np.vdot(b, a) # (70+8j)
혹시라도 모르시는 분들을 위해 미리 말씀드리면 복소수 상에서 정의되는 내적은 실수 상에서 정의된 내적과 연산이 살짝 다릅니다. 실수에서는 그냥 두 벡터의 요소별 곱셈을 한 뒤 덧셈을 취하면 되지만 복소수 상에서는 먼저 $a$ 벡터에 켤레복소수를 취한 뒤에 연산을 적용하기 때문에 실수와는 다르게 $a \cdot b \neq b \cdot a$인 것을 볼 수 있습니다.
4, numpy.inner(a, b)
이 함수는 dot, vdot 함수와 거의 동일한 함수입니다. 차이점은 항상 행렬-벡터, 또는 벡터-벡터 사이의 연산을 취한다는 점과 복소수같은 경우에는 켤레복소수를 적용하지 않고 내적을 계산한다는 점입니다.
a = np.array([1, 2, 3])
b = np.array([4, 5, 6])
print("a.shape = ", a.shape)
print("b.shape = ", b.shape)
# a.shape = (3,)
# b.shape = (3,)
np.dot(a, b) # 32
np.inner(a, b) # 32
a = np.array([1+2j,3+4j])
b = np.array([5+6j,7+8j])
np.inner(a, b) # (-18+68j)
np.inner(b, a) # (-18+68j)
a = np.arange(1, 7).reshape(2, 3)
b = np.array([7, 8, 9])
print("a.shape = ", a.shape)
print("b.shape = ", b.shape)
# a.shape = (2, 3)
# b.shape = (3,)
np.dot(a, b) # array([ 50, 122])
np.inner(a, b) # array([ 50, 122])
위에서 첫번째 결과와 세번째 결과 모두 저희가 원하는 값이 나왔지만 두번째 결과는 수학적으로는 정확하지 않기 때문에 만약 복소수에서의 내적을 계산하려면 vdot 함수를 사용하는 것이 더 정확합니다.
5. numpy.outer(a, b)
이번에는 외적을 계산하는 함수입니다. 내적과 외적은 가장 큰 차이점을 보면 내적의 연산 결과는 실수, 또는 복소수의 스칼라이지만 외적은 벡터, 또는 행렬이 그 결과로 나옵니다. 먼저, 두 벡터를 정의하도록 하겠습니다.
$$a = \left[\begin{array}{ll} a_{1} \\ a_{2} \\ \vdots \\ a_{m}\end{array}\right]$$
$$b = \left[\begin{array}{ll} b_{1} \\ b_{2} \\ \vdots \\ b_{n}\end{array}\right]$$
그러면 두 벡터 사이의 외적은 아래와 같습니다.
$$a \times b = \left[\begin{array}{ll} a_{1} * b_{1} & a_{1} * b_{2} & \cdots & a_{1} * b_{n} \\ a_{2} * b_{1} & a_{2} * b_{2} & \cdots & a_{2} * b_{n} \\ \vdots & \vdots & \cdots & \vdots \\ a_{m} * b_{1} & a_{m} * b_{2} & \cdots & a_{m} * b_{n}\end{array}\right]$$
바로 예제 코드를 확인해보도록 하겠습니다.
rl = np.outer(np.ones((5,)), np.linspace(-2, 2, 5))
# array([[-2., -1., 0., 1., 2.],
# [-2., -1., 0., 1., 2.],
# [-2., -1., 0., 1., 2.],
# [-2., -1., 0., 1., 2.],
# [-2., -1., 0., 1., 2.]])
이는 두 벡터 $\left[\begin{array}{ll} 1 & 1 & 1 & 1 & 1 \end{array}\right]$와 $\left[\begin{array}{ll} -2 & -1 & 0 & 1 & 2 \end{array}\right]$ 사이의 외적을 계산하는 코드입니다. 이는 실제로 계산해보면 결과와 동일합니다.
6. numpy.matmul(x1, x2)
이 함수의 기본적인 기능은 두 배열의 행렬곱을 하는 것입니다. 그런데 지난 포스팅의 dot 함수를 기억하시나요? dot 함수 역시 두 배열의 행렬곱을 수행하는 함수인데, 두 함수에는 어떤 차이가 존재할까요? 일단, 2차원 이하의 배열의 대해서는 아래의 예제와 같이 두 함수 모두 동일한 결과를 보여줍니다.
a = np.arange(5)
b = np.arange(5)
np.dot(a, b) # 30
np.matmul(a, b) # 30
a = np.arange(9).reshape(3, 3)
b = np.arange(9).reshape(3, 3)
np.dot(a, b)
# array([[ 15, 18, 21],
# [ 42, 54, 66],
# [ 69, 90, 111]])
np.matmul(a, b)
# array([[ 15, 18, 21],
# [ 42, 54, 66],
# [ 69, 90, 111]])
그런데 3차원 이상부터는 두 함수의 결과가 달라지기 시작합니다.
a = np.arange(27).reshape(3, 3, 3)
b = np.arange(27).reshape(3, 3, 3)
np.dot(a, b)
# array([[[[ 15, 18, 21],
# [ 42, 45, 48],
# [ 69, 72, 75]],
# [[ 42, 54, 66],
# [ 150, 162, 174],
# [ 258, 270, 282]],
# [[ 69, 90, 111],
# [ 258, 279, 300],
# [ 447, 468, 489]]],
# [[[ 96, 126, 156],
# [ 366, 396, 426],
# [ 636, 666, 696]],
# [[ 123, 162, 201],
# [ 474, 513, 552],
# [ 825, 864, 903]],
# [[ 150, 198, 246],
# [ 582, 630, 678],
# [1014, 1062, 1110]]],
# [[[ 177, 234, 291],
# [ 690, 747, 804],
# [1203, 1260, 1317]],
# [[ 204, 270, 336],
# [ 798, 864, 930],
# [1392, 1458, 1524]],
# [[ 231, 306, 381],
# [ 906, 981, 1056],
# [1581, 1656, 1731]]]])
np.matmul(a, b)
# array([[[ 15, 18, 21],
# [ 42, 54, 66],
# [ 69, 90, 111]],
# [[ 366, 396, 426],
# [ 474, 513, 552],
# [ 582, 630, 678]],
# [[1203, 1260, 1317],
# [1392, 1458, 1524],
# [1581, 1656, 1731]]])
두 함수의 결과를 보면 애초에 shape부터 큰 차이를 보이는 것을 볼 수 있습니다. 어떤 차이가 있는 지 넘파이의 정식 문서를 기반으로 확인해보도록 하겠습니다. 제가 이전 포스팅에서 생략한 내용 중에 하나가 있습니다. 바로 dot 함수의 5번째 정의인 텐서곱(tensor product)입니다.. 이 5번째 정의에 의해서 두 함수가 다른 결과를 보여주게 됩니다. 아래는 dot 함수의 텐서곱 정의를 그대로 해석한 문장입니다.
If $a$ is an $N$-D array and $b$ is an $M$-D array where $M \ge 2$, it is a sum product over the last axis of $a$ and the second-to-last axis of $b$.
만약 $a$가 $N$차원 배열이고 $b$가 2차원 이상의 $M$차원 배열이라면 dot 함수는 $a$의 마지막 축과 $b$의 마지막에서 2번째 축의 곱의 합(내적)으로 정의된다.
이 정의를 통해서 알 수 있는 한가지는 $a$의 마지막 축의 길이와 $b$의 마지막에서 2번째 축의 길이가 일치해야한다는 것입니다. 그래야 대응되는 원소끼리 곱한 뒤 더할 수 있겠죠? 만약 맞지 않으면 짝이 맞지 않기 때문에 오류가 발생할 것입니다. 이를 간단한 예제를 통해 보도록 하겠습니다.
a = np.arange(4 * 6 * 3).reshape(4, 6, 3)
b1 = np.arange(4 * 6 * 3).reshape(4, 6, 3)
b2 = np.arange(4 * 6 * 3).reshape(4, 3, 6)
b3 = np.arange(4 * 6 * 3).reshape(6, 4, 3)
b4 = np.arange(4 * 6 * 3).reshape(6, 3, 4)
b5 = np.arange(4 * 6 * 3).reshape(3, 4, 6)
b6 = np.arange(4 * 6 * 3).reshape(3, 6, 4)
np.dot(a, b1) # ERROR -> (4, 6, 3) * (4, 6, 3) -> a의 마지막 축의 길이 = 3 != 6 = b1의 마지막에서 2번째 축의 길이
np.dot(a, b2) # OK -> (4, 6, 3) * (4, 3, 6) = (4, 6, 4, 6) -> a의 마지막 축의 길이 = 3 = 3 = b1의 마지막에서 2번째 축의 길이
np.dot(a, b3) # ERROR -> (4, 6, 3) * (6, 4, 3) -> a의 마지막 축의 길이 = 3 != 4 = b1의 마지막에서 2번째 축의 길이
np.dot(a, b4) # OK -> (4, 6, 3) * (6, 3, 4) = (4, 6, 6, 4) -> a의 마지막 축의 길이 = 3 = 3 = b1의 마지막에서 2번째 축의 길이
np.dot(a, b5) # ERROR -> (4, 6, 3) * (3, 4, 6) -> a의 마지막 축의 길이 = 3 != 4 = b1의 마지막에서 2번째 축의 길이
np.dot(a, b6) # ERROR -> (4, 6, 3) * (3, 6, 4) -> a의 마지막 축의 길이 = 3 != 6 = b1의 마지막에서 2번째 축의 길이
이 중에서 2번째, 4번째 결과만 $a$의 마지막 축의 길이와 $b$의 마지막에서 2번째 축의 길이가 동일하기 때문에 계산이 가능하고 나머지에서는 전부 오류가 발생합니다. 넘파이에서는 3차원 배열끼리의 dot 함수를 수식적으로 아래와 같이 정의한다고 합니다.
dot(a, b)[i, j, k, m] = sum(a[i, j, :] * b[k, :, m])
그렇다면 이를 좀 더 일반화해서 표현할 수 없을까요? 아주 간단합니다. $a$를 $N$ 차원 배열, $b$를 $M$차원 배열이라고 가정하면 아래와 같이 작성할 수 있습니다.
dot(a, b)[i1, i2, ... ,iN, j1, j2, ... ,jM] = sum(a[i1, i2, ..., i(N-1), :] * b[j1, j2, ..., :, jM])
음... 일단 dot 함수에 대해서 쭉 정리를 해보았습니다. 그렇다면 matmul 함수에 대한 정확한 정의는 무엇일까요? 다차원 배열에서의 matmul 함수의 정의는 아래와 같습니다.
If either argument is $N$-D, $N > 2$, it is treated as a stack of matrices residing in the last two indexes and broadcast accordingly.
2차원 배열보다 큰 $N$ 차원 배열은 마지막 2개의 axis에 대해서 나머지 축을 따라서 stacking 한 것으로 간주한다.
방금 전의 예시로 설명하면 (4, 6, 3) 행렬이 있다면, (6, 3) 행렬을 4개 가지고 있다는 것으로 간주한다는 것입니다. 이때, 행렬곱은 이 마지막 2개의 축만을 가지고 수행하기 때문에 만약 마지막 2개의 축의 shape이 조건에 맞지 않는 다면 오류가 발생합니다. 아래의 예제를 보도록 하죠.
a = np.arange(4 * 6 * 3).reshape(4, 6, 3)
b1 = np.arange(4 * 6 * 3).reshape(4, 6, 3)
b2 = np.arange(4 * 6 * 3).reshape(4, 3, 6)
b3 = np.arange(4 * 6 * 3).reshape(6, 4, 3)
b4 = np.arange(4 * 6 * 3).reshape(6, 3, 4)
b5 = np.arange(4 * 6 * 3).reshape(3, 4, 6)
b6 = np.arange(4 * 6 * 3).reshape(3, 6, 4)
np.matmul(a, b1) # ERROR -> (4, 6, 3) * (4, 6, 3) -> a의 마지막 2개의 shape = (6, 3) != (6, 3) = b1의 마지막 2개의 shape
np.matmul(a, b2) # OK -> (4, 6, 3) * (4, 3, 6) = (4, 6, 6) -> a의 마지막 2개의 shape = (6, 3) = (3, 6) = b2의 마지막 2개의 shape
np.matmul(a, b3) # ERROR -> (4, 6, 3) * (6, 4, 3) -> a의 마지막 2개의 shape = (6, 3) != (4, 3) = b3의 마지막 2개의 shape
np.matmul(a, b4) # ERROR -> (4, 6, 3) * (6, 3, 4) -> a의 마지막 2개의 shape = (6, 3) = (3, 4) = b4의 마지막 2개의 shape, but a의 개수 = 4 != 6 = b4의 개수
np.matmul(a, b5) # ERROR -> (4, 6, 3) * (3, 4, 6) -> a의 마지막 2개의 shape = (6, 3) != (4, 6) = b3의 마지막 2개의 shape
np.matmul(a, b6) # ERROR -> (4, 6, 3) * (3, 6, 4) -> a의 마지막 2개의 shape = (6, 3) != (6, 4) = b3의 마지막 2개의 shape
위의 예시를 보시면 마지막 2개의 축에 해당하는 행렬를 이용해서 행렬곱을 하게 됩니다. 이때, 두 행렬의 shape이 일치하더라도 나머지 축의 길이가 맞지 않으면 오류가 발생하는 것을 볼 수 있습니다.
정리하면, dot 함수와 matmul 함수는 2차원 이하의 행렬에서는 동일한 결과를 낸다. 하지만 3차원 이상의 다차원 행렬에서는 계산하는 방식이 달라지기 때문에 동일한 행렬에 대해서 계산이 되지 않을 수도 있고 계산되더라도 결과와 그 shape이 다르다는 것입니다.
'Programming > Python' 카테고리의 다른 글
넘파이 알고 쓰자 - Linear Algebra Library 3 : 행렬의 고윳값과 고유벡터 (0) | 2020.11.22 |
---|---|
넘파이 알고 쓰자 - Linear Algebra Library 2 : 아인슈타인 표기법 (0) | 2020.11.20 |
넘파이 알고 쓰자 - 덧셈, 곱셈, 뺄셈(Sums, products, differences) (0) | 2020.11.12 |
넘파이 알고 쓰자 - 쌍곡선 함수(Hyperbolic functions) (0) | 2020.11.06 |
넘파이 알고 쓰자 - 삼각함수(Trigonometric functions) (1) | 2020.11.04 |