파이썬 넘파이 Numpy 배열의 연산 broadcast, 벡터의 내적, 행렬곱

파이썬 라이브러리 넘파이 (numpy) 학습 두번째 시간, 지난 시간에는 numpy란 무엇인지와 기본적인 어레이(array)를 생성하는 방법에 대해 알아보았다.

이전글 : 넘파이 어레이 (numpy array) 생성과 dtype, 차원 변경

오늘은 numpy array의 연산에 대해 알아본다.

numpy 배열의 연산은 다음과 같은 특징을 가진다.

  • 반복문을 회피하여 수식이 간편하고 속도가 빠르다
  • broadcast (element-wise) 연산방식 : 같은 위치 요소들끼리의 사칙연산 가능
  • 기본적으론 shape이 동일해야 하나, 확장 가능한 경우 shape이 달라도 연산을 지원

이 특징들을 한가지씩 실습을 통해 익혀본다.

numpy 배열과 list 연산 비교

왜 넘파이 배열로 연산하는 게 좋은지, 파이썬 리스트를 만들어서 연산하는 경우와 비교해 보자.

test1 = list(range(10))
test1

[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]   # 결과

🔺 test1 이라는 변수에 range함수를 이용하여 0에서 9까지의 값을 갖는 리스트를 만들었다. 이것을 각각 2배씩 곱해주고 싶다고 하자.

test1 * 2

[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]   # 결과

🔺 리스트 변수명에 다짜고짜 곱하기 2를 하면 위와 같이 리스트 두개를 이어붙인 형태가 되어버린다. 원소들의 값 자체를 두배씩으로 바꿔주고 싶다면 반복문을 쓰는 수밖에 없다.

result1 = []
for i in test1:
  result1.append(i*2)
result1

[0, 2, 4, 6, 8, 10, 12, 14, 16, 18]  # 결과

🔺 for 반복문을 이용해서 리스트 내의 원소들을 하나씩 순차적으로 불러오고 그것들을 2배해서 새로 생성해놓은 빈 리스트에 추가해주는 작업을 하도록 만들었다.

만약 원소의 개수가 10000개라면 반복문을 10000번 수행해야 한다. 얼핏 생각해도 속도가 매우 느릴 것 같다.

반면에 넘파이 어레이를 통해 이 연산을 하면 어떤 식이 될까.

import numpy as np

test2 = np.arange(10)
test2

array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])   # 결과

🔺 import로 numpy를 불러온 뒤, arange 함수를 이용해 0에서 9까지의 숫자를 갖는 1차원 어레이를 생성하였다. 각각을 두배씩 해주고 싶다면,

test2 *2

array([ 0,  2,  4,  6,  8, 10, 12, 14, 16, 18])   # 결과

🔺 그냥 이렇게 곱하기 2를 해주는 것으로 원하는 결과를 얻을 수 있다. 아까 리스트에서와 달리 array 배열은 사칙연산자 기호에 의해 각 요소별 연산이 된다. 따라서 반복문을 거치지 않고 보다 빠르게 작업을 수행할 수 있다.

그리고 매번 반복문 코드 짜는 것보다 numpy array를 써주는 것이 코드도 훨씬 간단해진다. 넘파이 배열의 연산 방법들을 좀 더 구체적으로 살펴보자.

broadcast 연산

broadcast 연산이라는 것은 element-wise 연산 방식이라고도 하는데, 배열의 각 요소별로 연산과정을 수행한다는 의미이다.

a = np.arange(15).reshape(3,5)
b = np.arange(20,35).reshape(3,5)

a            # a array 내용출력
array([[ 0,  1,  2,  3,  4],
       [ 5,  6,  7,  8,  9],
       [10, 11, 12, 13, 14]])

b            # b array 내용출력
array([[20, 21, 22, 23, 24],
       [25, 26, 27, 28, 29],
       [30, 31, 32, 33, 34]])

🔺 위와 같이 3행 5열 shape에 다른 원소값을 갖는 a, b 두개의 배열을 생성하였다. 이 두개로 사칙연산을 시켜보자.

더하기

a+b

array([[20, 22, 24, 26, 28],
       [30, 32, 34, 36, 38],
       [40, 42, 44, 46, 48]])


np.add(a,b)

array([[20, 22, 24, 26, 28],
       [30, 32, 34, 36, 38],
       [40, 42, 44, 46, 48]])

🔺 연산자 기호 + 또는 연산 함수 np.add(배열1, 배열2) 형태로 각각의 합을 구한다.

빼기

a-b

array([[-20, -20, -20, -20, -20],
       [-20, -20, -20, -20, -20],
       [-20, -20, -20, -20, -20]])


np.subtract(a,b)

array([[-20, -20, -20, -20, -20],
       [-20, -20, -20, -20, -20],
       [-20, -20, -20, -20, -20]])

🔺 연산자 기호 – 또는 연산 함수 np.subtract(배열1, 배열2) 형태로 각각의 합을 구한다.

곱하기

a*b

array([[  0,  21,  44,  69,  96],
       [125, 156, 189, 224, 261],
       [300, 341, 384, 429, 476]])


np.multiply(a,b)

array([[  0,  21,  44,  69,  96],
       [125, 156, 189, 224, 261],
       [300, 341, 384, 429, 476]])

🔺 연산자 기호 * 또는 연산 함수 np.multiply(배열1, 배열2) 형태로 각각의 합을 구한다.

나누기

a/b

array([[0.        , 0.04761905, 0.09090909, 0.13043478, 0.16666667],
       [0.2       , 0.23076923, 0.25925926, 0.28571429, 0.31034483],
       [0.33333333, 0.35483871, 0.375     , 0.39393939, 0.41176471]])


np.divide(a,b)

array([[0.        , 0.04761905, 0.09090909, 0.13043478, 0.16666667],
       [0.2       , 0.23076923, 0.25925926, 0.28571429, 0.31034483],
       [0.33333333, 0.35483871, 0.375     , 0.39393939, 0.41176471]])

🔺 연산자 기호 / 또는 연산 함수 np.divide(배열1, 배열2) 형태로 각각의 합을 구한다.

이것은 연산한 결과값만 보여주는 것이고 배열 a와 b의 원래 내용을 바꾸는 것은 아니다. 연산 결과를 새로운 array로 생성하고 싶다면, 

c = np.divide(a, b) 와 같이 새로운 변수명에 받아주면 된다.

벡터의 내적, 행렬곱

행렬곱으로 벡터의 내적을 구할 수 있는데, 벡터와 내적의 의미 같은 것은 수학 공부시간에 따로 다루기로 하고 여기서는 numpy array를 통해 연산하는 방법만 공부한다.

벡터의 내적, 행렬곱 그림

🔺 행렬곱은 이렇게 첫번째 행렬의 m 행과 두번째 행렬의 n 열 요소들간에 곱의 합을 구해서 결과값을 m행 n열의 원소로 넣는 것이다.

내적 : 곱끼리의 합 이라고만 일단 알아둔다.

딱 저렇게 m x n  하나의 연산만 존재하도록 1차원 배열끼리 먼저 해보자.

a = np.array([10, 20, 30])
b = np.array([5, 10, 15])

a, a.shape, b, b.shape

(array([10, 20, 30]), (3,), array([ 5, 10, 15]), (3,))

이렇게 1행3열짜리 어레이 두개를 만든 뒤 벡터의 내적을 구할 때, 방법은 4가지이다.

np.sum(a * b)

np.dot(a, b)

a.dot(b)

a @ b


700     # 결과

🔺 4가지 중 어떤 방법으로 해도 결과는 모두 같게 나온다.

10*5 + 20*10 + 30*15 = 50 + 200 + 450 = 700

이제 2차원 배열끼리 연산을 해보자.

c = np.arange(12).reshape(3,4)
d = np.arange(10,22).reshape(3,4)


c, c.shape

(array([[ 0,  1,  2,  3],
        [ 4,  5,  6,  7],
        [ 8,  9, 10, 11]]), (3, 4))


d, d.shape

(array([[10, 11, 12, 13],
        [14, 15, 16, 17],
        [18, 19, 20, 21]]), (3, 4))

🔺 이렇게 3행 4열짜리 어레이 두개를 생성하고 벡터 내적 연산을 실행하면

행열 불일치시 벡터내적 오류

ValueError: matmul: Input operand 1 has a mismatch in its core dimension 0, with gufunc signature (n?,k),(k,m?)->(n?,m?) (size 3 is different from 4)

ValueError가 발생한다. 벡터의 내적은 곱의 합이라고 했는데, 이걸 하려면 첫번째 행렬의 열 개수 (가로 원소 수)와 두번째 행렬의 행 개수 (세로 원소 수)가 일치해야 한다. 즉 shape이 (3, 4) 하고 (4, 3) 두개를 행렬곱 시켜야 정상적인 결과가 출력된다.

c @ d.T

array([[ 74,  98, 122],
       [258, 346, 434],
       [442, 594, 746]])

🔺 이때는 이렇게 d.T 를 이용해서 두번째 행렬의 shape을 변환해주고 내적 연산을 하면 결과를 얻을 수 있다. 

np.matmul(c, d.T)

array([[ 74,  98, 122],
       [258, 346, 434],
       [442, 594, 746]])

🔺 dot 함수들은 내적을 구하는 것이고, 행렬곱은 matmul 함수를 이용하여 계산하는데 이렇게 2차원 배열까지는 결과값이 동일하다. 3차원부터는 결과가 달라진다.

크기다른 배열간의 연산

numpy array 연산은 element wise 방식이라 두 배열의 shape이 같아야 한다고 했는데, 크기가 다르더라도 연산이 가능한 경우가 있다.

차원이 다른 배열간의 연산

🔺 이렇게 차원이 다른 배열간에 연산을 수행하는 경우, 작은 차원의 배열을 큰 차원의 배열만큼 확장해서 계산을 수행해 준다. 빈칸에 같은 내용이 반복되어 있다고 가정하고 확장해서 연산한다는 뜻이다.

a = np.arange(12).reshape(3,4)
a, a.shape

(array([[ 0,  1,  2,  3],
        [ 4,  5,  6,  7],
        [ 8,  9, 10, 11]]), (3, 4))


b = np.array([1,2,3,4])
b, b.shape

(array([1, 2, 3, 4]), (4,))


a + b

array([[ 1,  3,  5,  7],
       [ 5,  7,  9, 11],
       [ 9, 11, 13, 15]])

🔺 shape(3, 4) 배열 a에 shape(4, )인 배열 b를 더하면 서로 형태가 다르지만 마치 b의 한 행 데이터가 세 줄 있는 것처럼 확장해서 a의 각각의 행에 더하기를 해준다.

이렇게 numpy array 간의 간단한 연산 방법에 대해 알아보았다. 다음은 배열 내에서 특정 데이터를 찾고 추출하는 인덱싱과 슬라이싱에 대해 알아본다.