이번 시간에는 numpy의 다양한 기능에 대해 공부한다.

NumPy

넘파이(NumPy)는 다양한 과학적 연산에서 파이썬의 한계를 극복하기 위해 개발된 패키지이다. 파이썬은 인터프리터 언어로써 속도가 상당히 느린데, 넘파이는 다양한 연산에서 빠른 속도를 제공하여 단점을 해결해준다. 다음은 넘파이의 다양한 특징이다.

  • 일반 리스트에 비해 빠르고 메모리 효율적이다.
  • 반복문 없이 데이터 배열에 대한 처리를 지원한다.
  • 선형대수와 관련된 다양한 기능을 제공한다.
  • C, C++, 포트란 등의 언어와 통합 가능하다.


ndarray

넘파이는 다음과 같이 호출한다.

>>> import numpy as np

넘파이는 np.array 함수를 이용해서 배열을 생성한다. 하나의 데이터 타입만을 배열에 넣을 수 있으며, 파이썬 리스트의 가장 큰 특징이라고 할 수 있는 동적 타이핑(dynamic typing)을 지원하지 않는다. 대신 C의 배열을 이용해서 생성한다.

>>> test_array = np.array([1, 4, 5, 8], float)
>>> print(test_array)
[1. 4. 5. 8.]

cf. 파이썬 리스트에 대해 공부할 때 꼭 짚고 넘어가는 부분이 바로 copy와 deepcopy의 차이일 것이다. 파이썬 리스트는 리스트를 직접 저장하는 것이 아니라 리스트 안에 메모리 주소를 저장하고 있다. 따라서 리스트를 복사하면 주소가 복사된다. 하지만 넘파이의 배열은 주소 대신 값을 저장한다.

shape은 배열의 모양을, dtype은 데이터 타입을 반환한다.

>>> test_array = np.array([1, 4, 5, 8], float)
>>> print(test_array)
[1. 4. 5. 8.]
>>> print(test_array.shape)     # 배열 모양
(4,)
>>> print(test_array.dtype)     # 배열 데이터 타입
float64

특히 배열의 모양을 파악하는 것은 중요하다. 예를 들어 위 test_arrayshape은 다음과 같다.


만약 2차원이면 어떻게 출력될까?


3차원이라면?


규칙을 파악하는 것이 중요하다. 새로운 차원이 하나씩 추가될 때마다 기존 숫자는 뒤로 한 칸씩 밀려난다.

배열의 차원과 크기(데이터 개수)를 파악하는 코드는 다음과 같다.

>>> tensor  = [[[1,2,5,8],[1,2,5,8],[1,2,5,8]], 
...            [[1,2,5,8],[1,2,5,8],[1,2,5,8]], 
...            [[1,2,5,8],[1,2,5,8],[1,2,5,8]], 
...            [[1,2,5,8],[1,2,5,8],[1,2,5,8]]]
>>> np.array(tensor, int).ndim      # 차원 파악
3
>>> np.array(tensor, int).size      # 데이터 개수 파악
48

넘파이에서 많이 사용하는 데이터 타입은 int, float32 등이 있다. 데이터 타입을 선언하면 각 요소가 차지하는 메모리의 크기가 결정된다.


배열의 모양 다루기

배열의 모양을 바꾸는 가장 쉬운 방법은 reshape를 사용하는 것이다.

>>> test_matrix = [[1, 2, 3, 4], [1, 2, 5, 8]]
>>> np.array(test_matrix).shape
(2, 4)
>>> np.array(test_matrix).reshape(8, )
array([1, 2, 3, 4, 1, 2, 5, 8])
>>> np.array(test_matrix).reshape(8, ).shape
(8,)

reshape을 사용하면 요소의 개수는 변하지 않고 모양만 변경한다. 만약 행, 열 중 하나만 지정하고 나머지는 컴퓨터가 알아서 처리해주기를 바란다면 행이나 열을 다음과 같이 ‘-1’로 지정하면 된다.

>>> test_matrix = [[1, 2, 3, 4], [1, 2, 5, 8]]
>>> np.array(test_matrix).reshape(-1, 2)        # 하나만 지정할테니 나머지는 알아서 해줘!
array([[1, 2],
       [3, 4],
       [1, 2],
       [5, 8]])
>>> np.array(test_matrix).reshape(-1, 2).shape
(4, 2)

1차원 배열로 변환하기 위한 flatten도 있다.

>>> test_matrix = [[1, 2, 3, 4], [1, 2, 5, 8]]
>>> np.array(test_matrix).flatten()
array([1, 2, 3, 4, 1, 2, 5, 8])


인덱싱과 슬라이싱

넘파이에서 인덱싱과 슬라이싱은 재미있는 것이 많다. 잘만 사용하면 그 어떤 반복문도 부럽지 않다.

먼저, 인덱싱을 살펴보자. 넘파이에서는 파이썬 리스트와는 다르게 a[0, 0]과 같은 표기법을 사용할 수 있다. 앞은 행, 뒤는 열을 의미한다.

>>> a = np.array([[1, 2, 3], [4.5, 5, 6]], int)
>>> print(a[0, 0])      # 첫 번째 행, 첫 번째 열
1
>>> print(a[0][0])      # 첫 번째 행, 첫 번째 열
1
>>> a[0, 0] = 12
>>> print(a)
[[12  2  3]
 [ 4  5  6]]
>>> a[0][0] = 5
>>> print(a)
[[5 2 3]
 [4 5 6]]

슬라이싱이 하이라이트다. 행과 열 부분을 나누어서 슬라이싱이 가능하다. 행렬의 일부분을 추출할 때 아주 유용하다. 기본 개념을 파악하기 위한 예시를 하나 보자.


일단 두 번째 행까지를 추출하고(:2), 열은 전체를 추출했다(:). 이제 다양한 응용 코드를 보자.

>>> a = np.array([[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]], int)
>>> a[:, 2:]
array([[ 3,  4,  5],
       [ 8,  9, 10]])

>>> a[1, 1:3]
array([7, 8])

>>> a[1:3]
array([[ 6,  7,  8,  9, 10]])

첫 번째 코드는 모든 행, 열은 세 번째부터 추출했다. 두 번째 코드는 행은 두 번째 행, 열은 2~3열을 추출했다. 마지막 코드는 행만 슬라이싱 했다.

고급지게 슬라이싱하면 다음과 같은 것도 가능하다. 사실 쓸 일이 있을지는 모르겠다


넘파이 배열 생성

넘파이 배열을 생성하는 방법은 여러 가지가 있다. 먼저, 배열의 범위를 지정하는 arange는 다음과 같이 쓴다.

>>> np.arange(30)
array([ 0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12, 13, 14, 15, 16,
       17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29])

>>> np.arange(0, 5, 0.5)
array([0. , 0.5, 1. , 1.5, 2. , 2.5, 3. , 3.5, 4. , 4.5])

>>> np.arange(30).reshape(5, 6)
array([[ 0,  1,  2,  3,  4,  5],
       [ 6,  7,  8,  9, 10, 11],
       [12, 13, 14, 15, 16, 17],
       [18, 19, 20, 21, 22, 23],
       [24, 25, 26, 27, 28, 29]])

두 번째 예시처럼 간격도 지정해줄 수 있다.

0으로만 가득차있거나(zeros), 1로만 가득하거나(ones), 비어있는 행렬(empty)을 만들 수도 있다.

>>> np.zeros(shape=(10, ), dtype=np.int8)
array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0], dtype=int8)

>>> np.ones(shape=(10, ), dtype = np.int8)
array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1], dtype=int8)

>>> np.ones((2, 5))     # 간단하게 모양만 지정
array([[1., 1., 1., 1., 1.],
       [1., 1., 1., 1., 1.]])

>>> np.empty(shape = (10, ), dtype = np.int8)
array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1], dtype=int8)

기존 넘파이 배열의 모양을 가져오는 something_like도 있다. 또한 단위 행렬(identity matrix), 대각선이 1인 행렬을 만들 수도 있다.

>>> test_matrix = np.arange(30).reshape(5,6)
>>> np.ones_like(test_matrix)
array([[1, 1, 1, 1, 1, 1],
       [1, 1, 1, 1, 1, 1],
       [1, 1, 1, 1, 1, 1],
       [1, 1, 1, 1, 1, 1],
       [1, 1, 1, 1, 1, 1]])

>>> np.identity(n = 3, dtype = np.int8)
array([[1, 0, 0],
       [0, 1, 0],
       [0, 0, 1]], dtype=int8)
>>> np.identity(5)
array([[1., 0., 0., 0., 0.],
       [0., 1., 0., 0., 0.],
       [0., 0., 1., 0., 0.],
       [0., 0., 0., 1., 0.],
       [0., 0., 0., 0., 1.]])

>>> np.eye(N=3, M=5, dtype = np.int8)
array([[1, 0, 0, 0, 0],
       [0, 1, 0, 0, 0],
       [0, 0, 1, 0, 0]], dtype=int8)
>>> np.eye(3, 5, k = 2)
array([[0., 0., 1., 0., 0.],
       [0., 0., 0., 1., 0.],
       [0., 0., 0., 0., 1.]])

eye에서는 k를 조절하여 시작 위치를 설정할 수도 있다.

대각 행렬의 값을 추출하는 diag도 있다.

>>> matrix = np.arange(9).reshape(3, 3)
>>> np.diag(matrix)
array([0, 4, 8])

마지막으로, 확률 분포에 따른 샘플링으로 배열을 만들 수도 있다. 다음은 균등 분포와 정규 분포를 이용해 배열을 만든 예시이다.

>>> np.random.uniform(0, 1, 10).reshape(2, 5)       # 균등 분포
array([[0.37434249, 0.2431112 , 0.11059948, 0.8207099 , 0.18816905],
       [0.95101893, 0.65763392, 0.8738058 , 0.5828107 , 0.93530176]])

>>> np.random.normal(0, 1, 10).reshape(2, 5)        # 정규 분포
array([[-0.59301348,  0.10098297, -2.0741819 , -0.20759248, -1.65855981],
       [ 0.41234552, -0.84104753, -0.66586402, -0.92882025,  0.11833433]])


넘파이 행렬의 다양한 기능

넘파이 행렬 요소들의 합을 구할 때는 sum을 사용한다.

>>> test_array = np.arange(1, 11)
>>> test_array.sum(dtype = np.float)
55.0

넘파이 행렬에서는 축(axis)을 설정하는 것이 매우 중요하다. 다음 그림을 머릿 속에 박아놓자. 할 때마다 헷갈린다…


축을 설정해서 합을 구할 수도 있다. 행의 요소끼리 더한 결과나 열의 요소끼리 더한 결과가 출력된다.

>>> test_array = np.arange(1, 13).reshape(3, 4)
>>> test_array.sum(axis = 1)
array([10, 26, 42])
>>> test_array.sum(axis = 0)
array([15, 18, 21, 24])

shape에서와 마찬가지로, 새로 생기는 축이 항상 0이다.


여기서 본 것 이외에도 다양한 수학 연산자가 있다. 필요할 때마다 구글링을 해보자.

넘파이 행렬을 합치는 concatenate도 사용할 수 있다. vstackhstack을 쓸 수도 있고, 축을 지정할 수도 있다. 작동 방식과 코드는 다음과 같다.



>>> a = np.array([1, 2, 3])
>>> b = np.array([2, 3, 4])
>>> np.vstack((a, b))
array([[1, 2, 3],
       [2, 3, 4]])

>>> a = np.array([[1], [2], [3]])
>>> b = np.array([[2], [3], [4]])
>>> np.hstack((a, b))
array([[1, 2],
       [2, 3],
       [3, 4]])

>>> a = np.array([[1, 2, 3]])
>>> b = np.array([[2, 3, 4]])
>>> np.concatenate((a, b), axis = 0)
array([[1, 2, 3],
       [2, 3, 4]])

>>> a = np.array([[1, 2], [3, 4]])
>>> b = np.array([[5, 6]])
>>> np.concatenate((a, b.T), axis = 1)
array([[1, 2, 5],
       [3, 4, 6]])

마지막 코드에 나오듯이 전치 행렬은 .T로 쉽게 얻을 수 있다.


넘파이 행렬 계산

행렬끼리 계산하는 것은 아주 쉽다. 앞선 시간에 했던 코딩이 무색할 정도이다. 가장 먼저 행렬 간 합과 같은 위치의 곱을 살펴보자.

>>> test_a = np.array([[1, 2, 3], [4, 5, 6]], float)

>>> test_a + test_a
array([[ 2.,  4.,  6.],
       [ 8., 10., 12.]])

>>> test_a - test_a
array([[0., 0., 0.],
       [0., 0., 0.]])

>>> test_a * test_a
array([[ 1.,  4.,  9.],
       [16., 25., 36.]])

위의 곱(*)은 행렬 곱셉이 아니라 같은 위치의 숫자를 곱하는 연산임을 주의하자.

진짜 행렬의 곱셈은 dot함수를 사용한다.

>>> test_a = np.arange(1, 7).reshape(2, 3)
>>> test_b = np.arange(7, 13).reshape(3, 2)
>>> test_a.dot(test_b)
array([[ 58,  64],
       [139, 154]])

넘파이의 행렬 간 연산에서 가장 중요한 개념은 ‘broadcasting’이다. 모양이 맞지 않은 행렬끼리의 연산을 할 때 모양을 자동으로 맞춰주는 기능이다. 쉽게 말해 ‘이거 계산하고 싶은데 모양을 일일이 맞추기는 귀찮으니까 컴퓨터야 알잘딱으로 계산해줘’하고 싶을 때 쓰는 것이다. 작동 원리를 자세히 알고 싶으면 다음 그림을 참고하자.


가장 많이 쓰는 경우는 가운데 경우이다.

넘파이 연산은 일반적으로 list comprehension, for 반복문보다 훨씬 빠르다. 그러나 연산이 아닌 할당(ex. concatenate)에서는 속도의 이점이 없다.


넘파이 행렬 간 비교

데이터의 조건 만족 여부에 따라 true, false를 반환할 수 있다. all은 모두 만족하면 true, any는 하나라도 만족하면 true를 반환한다.

>>> a = np.arange(10)
>>> np.any(a > 5), np.any(a < 0)
(True, False)
>>> np.all(a > 5), np.all(a < 10)
(False, True)

두 배열의 크기가 동일할 때 크기 비교 결과를 출력할 수도 있다.

>>> test_a = np.array([1, 3, 0], float)
>>> test_b = np.array([5, 2, 1], float)
>>> test_a > test_b
array([False,  True, False])
>>> test_a == test_b
array([False, False, False])
>>> (test_a > test_b).any()
True

where은 다양한 활용법이 있다. 조건에 맞게 값을 지정할 수도 있고, 인덱스 값을 반환할 수도 있다.

>>> a = np.array([1, 3, 0], float)
>>> np.where(a > 0, 3, 2)           # 조건에 맞으면 3, 아니면 2
array([3, 3, 2])

>>> a = np.arange(10)               # 조건에 맞는 인덱스 반환
>>> np.where(a > 5)
(array([6, 7, 8, 9]),)

argmaxargmin은 각각 배열 내의 최댓값, 최솟값의 인덱스를 반환한다. 물론 축을 지정해줄 수도 있다.

>>> a = np.array([1, 2, 4, 5, 8, 78, 23, 3])
>>> np.argmax(a)
5
>>> np.argmin(a)
0

>>> a = np.array([[1, 2, 4, 7], [9, 88, 6, 45], [9, 76, 3, 4]])
>>> np.argmax(a, axis = 1)
array([3, 1, 1])
>>> np.argmin(a, axis = 0)
array([0, 0, 2, 2])


boolean, fancy 인덱스

넘파이 배열은 특정 조건에 따른 값을 배열 형태로 추출할 수 있다.

>>> test_a = np.array([1, 4, 0, 2, 3, 8, 9, 7], float)

>>> test_a > 3
array([False,  True, False, False, False,  True,  True,  True])

>>> test_a[test_a > 3]      # 조건이 True인 요소만 추출
array([4., 8., 9., 7.])

>>> condition = test_a < 3  # 조건을 따로 설정
>>> test_a[condition]
array([1., 0., 2.])

조건에 맞는 요소만 추출하거나 조건을 따로 설정해줄 수도 있다.

fancy 인덱스는 배열을 인덱스 값으로 해서 값을 추출하는 방식이다. 예시를 보자.

>>> a = np.array([2, 4, 6, 8], float)
>>> b = np.array([0, 0, 1, 3, 2, 1], int)
>>> a[b]
array([2., 2., 4., 8., 6., 4.])

a의 인덱싱에 b 배열을 사용한 것이다. b의 값이 각각 a의 위치를 지정하는 인덱스가 된다. 당연히 b의 데이터 타입은 정수형이 되어야 한다. 행렬에서도 가능하다.


이번 강의에 등장한 넘파이의 사용법을 모두 암기하기에는 어려움이 있다. 중요한 것은 어떠한 기능이 있는지를 우선 기억한 뒤에, 해당 기능이 필요한 상황이 오면 그 때 정확한 사용법을 찾아보는 것이다. 넘파이 배열을 가져다 놓고 for 반복문을 사용하는 것은 매우 좋지 않기 때문에 위 기능들이 존재한다는 사실은 꼭 기억해야 한다.



별도의 출처 표시가 있는 이미지를 제외한 모든 이미지는 강의자료에서 발췌하였음을 밝힙니다.

댓글남기기