18 분 소요

NumPy ndarray: 다차원 배열 객체

가장 먼저 넘파이 모듈을 임포트한다. 물론 import numpy만 해도 충분하지만, as np를 추가해 약어로 모듈을 표현해주는 게 관례convention다.

import numpy as np

넘파이의 기반 데이터 타입은 ndarray다. 이 ndarray를 이용해 넘파이에서 다차원(Multi-dimension) 배열을 쉽게 생성하고 다양한 연산을 수행할 수 있다. 여기서 ndarray란 N차원의 배열 객체인데 파이썬에서 사용할 수 있는 대규모 데이터 집합을 담을 수 있는 빠르고 유연한 자료구조다. 배열은 스칼라 원소 간의 연산에 사용하는 문법과 비슷한 방식을 사용해서 전체 데이터 블록에 수학적인 연산을 수행할 수 있도록 해준다.

# 임의의 값을 생성
data = np.random.randn(2, 3) # (2, 3) 크기의 표준정규분포 난수 생성

data
array([[-1.20045566,  1.16925713, -0.46201875],
       [ 0.38633024, -2.23341042,  0.30618922]])

data에 산술 연산을 할 수 있다.

data * 10 # data의 각 원소에 10이 곱해짐
array([[-12.00455659,  11.69257127,  -4.62018746],
       [  3.86330241, -22.33410421,   3.06189218]])
data + data # data 배열에서 같은 위치의 값끼리 서로 더함
array([[-2.40091132,  2.33851425, -0.92403749],
       [ 0.77266048, -4.46682084,  0.61237844]])

ndarray는 같은 종류의 데이터를 담을 수 있는 포괄적인 다차원 배열로 모든 원소는 같은 자료형을 가져야 한다. ndarray.shape는 ndarray의 차원과 크기를 튜플(tuple) 형태로 나타내 준다.

data.shape # data 차원의 크기
(2, 3)

[[-2.40091132, 2.33851425, -0.92403749], [ 0.77266048, -4.46682084, 0.61237844]]인 data의 shape는 (2, 3)이다. 이는 2차원 array로 2개의 로우와 3개의 칼럼으로 구성되어 2*3=6개의 데이터를 가지고 있음을 뜻한다.

머신러닝 알고리즘과 데이터 세트 간의 입출력과 변환을 수행하다 보면 명확히 1차원 데이터 또는 다차원 데이터를 요구하는 경우가 있어서 차원의 크기 차이를 이해하는 것은 매우 중요하다. 분명히 데이터값으로는 서로 동일하나 차원이 달라서 오류가 발생하는 경우가 빈번하다.

ndarray.dtype은 ndarray에 저장된 자료형을 알려주는 dtype이라는 객체를 가지고 있다.

data.dtype
dtype('float64')

ndarray 생성하기

넘파이 array() 함수는 파이썬의 리스트와 같은 다양한 인자를 입력 받아서 ndarray로 변환하는 기능을 수행한다. 순차적인 객체(다른 배열도 포함하여)를 넘겨받고, 넘겨받은 데이터가 들어 있는 새로운 NumPy 배열을 생성한다.

data1 = [6, 7.5, 8, 0, 1] # 파이썬의 리스트

arr1 = np.array(data1) # NumPy 배열로 변환

arr1
array([6. , 7.5, 8. , 0. , 1. ])

같은 길이를 가지는 리스트를 내포하고 있는 순차 데이터는 다차원 배열로 변환 가능하다.

data2 = [[1, 2, 3, 4], [5, 6, 7, 8]] # 파이썬의 중첩 리스트

arr2 = np.array(data2) # NumPy 배열로 변환

arr2
array([[1, 2, 3, 4],
       [5, 6, 7, 8]])

ndarray.ndim은 각 array의 차원을 나타내 준다.

print('arr2는 {0}차원이고 크기는 {1}입니다.'.format(arr2.ndim, arr2.shape))
arr2는 2차원이고 크기는 (2, 4)입니다.

np.array는 새로운 배열을 생성하기 위한 여러 함수를 가지고 있다. 특정 크기와 차원을 가진 ndarray를 연속값이나 0또는 1로 초기화해 쉽게 생성해야 할 필요가 있는 경우가 발생할 수 있다. 주로 테스트용으로 데이터를 만들거나 대규모의 데이터를 일괄적으로 초기화해야 할 경우에 사용된다. 다음은 표준 배열 생성 함수의 목록이다. NumPy는 산술 연산에 초점이 맞춰져 있기 때문에 자료형을 명시하지 않으면 float64(부동소수점)가 될 것이다.

함수 설명
array 입력 데이터(리스트, 튜플, 배열 또는 다른 순차형 데이터)를 ndarray로 변환하며 dtype을 명시하지 않은 경우 자료형을 추론하여 저장한다. 기본적으로 입력 데이터는 복사된다.
asarray 입력 데이터를 ndarray로 변환하지만 입력 데이터가 이미 ndarray일 경우 복사가 일어나지 않는다.
arange 내장 range 함수와 유사하지만 리스트대신 ndarray를 반환한다.
ones, ones_like 주어진 dtype과 모양을 가지는 배열을 생성하고 내용을 모두 1로 초기화한다. ones_like는 주어진 배열과 동일한 모양과 dtype을 가지는 배열을 새로 생성하여 모두 1로 초기화한다.
zeros, zeros_like ones, ones_like와 동일하지만 내용을 0으로 채운다.
empty, empty_like 메모리를 할당하여 새로운 배열을 생성하지만 ones나 zeros처럼 값을 초기화하지 않고 ‘가비지’값으로 채워진 배열을 반환한다.
full, full_like 인자로 받은 dtype과 배열의 모양을 가지는 배열을 생성하고 인자로 받은 값으로 배열을 채운다.
eye, identity N x N 크기의 단위행렬을 생성한다(좌상단에서 우하단을 잇는 대각선은 1로 채워지고 나머지는 0으로 채워진다).
np.arange(10)
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
np.zeros(10)
array([0., 0., 0., 0., 0., 0., 0., 0., 0., 0.])
np.ones((3, 6))
array([[1., 1., 1., 1., 1., 1.],
       [1., 1., 1., 1., 1., 1.],
       [1., 1., 1., 1., 1., 1.]])
np.eye(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.]])

ndarray의 dtype

dtype은 ndarray가 메모리에 있는 특정 데이터를 해석하기 위해 필요한 정보 (또는 메타데이터)를 담고 있는 특수한 객체다.

ndarray내의 데이터값은 숫자 값, 문자열 값, 불 값 등이 모두 가능하다. 숫자형의 경우 int형(8bit, 16bit, 32bit), unsigned int형(8bit, 16bit, 32bit), float형(16bit, 32bit, 64bit, 128bit), 그리고 이보다 더 큰 숫자 값이나 정밀도를 위해 complex 타입도 제공한다. NumPy의 모든 dtype을 외울 필요는 없으며, 앞서 말한 주로 사용하게 될 자료형의 일반적인 종류만 신경 쓰면 된다. 주로 대용량 데이터가 메로리나 디스크에 저장되는 방식을 제어해야 할 피룡가 있을 때 알아두면 좋다.

ndarray내의 데이터 타입은 그 연산의 특성상 같은 데이터 타입만 가능하다. 즉, 한개의 ndarray 객체에 int와 float가 함께 있을 수 없다. 만약 다른 데이터 유형이 섞여 있는 리스트를 ndarray로 변경하면 데이터 크기가 더 큰 데이터 타입으로 형 변환을 일괄 적용한다.

arr1 = np.array([1, 2, 3], dtype=np.float64)
arr2 = np.array([1, 2, 3], dtype=np.int32)

print('arr1의 dtype: {}, arr2의 dtype: {}'.format(arr1.dtype, arr2.dtype))
arr1의 dtype: float64, arr2의 dtype: int32
list1 = [1, 2, 'test'] # int형과 string형이 섞여 있는 리스트
array1 = np.array(list1) # NumPy 배열로 변환
print(array1, array1.dtype) # array1.dtype에서의 U는 유니코드 문자열 값을 뜻한다.
['1' '2' 'test'] < U11
list2 = [1, 2, 3.0] # int형과 float형이 섞여 있는 리스트
array2 = np.array(list2) # NumPy 배열로 변환
print(array2, array2.dtype) # array2.dtype의 자료형이 int32가 아닌 float64인 것을 확인할 수 있다.
[1. 2. 3.] float64

ndarray 내 데이터값의 타입 변경도 astype() 메서드를 이용해서 dtype을 다른 형으로 명시적으로 변환(또는 캐스팅) 가능하다. astype()에 인자로 원하는 타입을 문자열로 지정하면 된다. 이렇게 데이터 타입을 변경하는 경우는 대용량 데이터의 ndarray를 만들 때 많은 메모리가 사용되는데, 메모리를 더 절약해야 할 때 보통 이용된다. 예를 들어, int형으로 충분한 경우인데, 데이터 타입이 float라면 int형으로 바꿔서 메모리를 절약할 수 있다.

파이썬 기반의 머신러닝 알고리즘은 대부분 메모리로 데이터를 전체 로딩한 다음 이를 기반으로 알고리즘을 적용하기 때문에 대용량의 데이터를 로딩할 때는 수행속도가 느려지거나 메모리 부족으로 오류가 발생할 수 있다.

arr = np.array([1, 2, 3, 4, 5])

arr.dtype
dtype('int32')
float_arr = arr.astype(np.float64) # arr.astype('float64')와 동일

float_arr.dtype
dtype('float64')

부동소수점수를 정수형 dtype으로 변환하면 소수점 이하는 당연히 모두 버려진다.

arr = np.array([3.7, -1.2, -2.6, 0.5, 12.9, 10.1])

arr
array([ 3.7, -1.2, -2.6,  0.5, 12.9, 10.1])
arr.astype('int32') # 소수점 아래 자리가 모두 버려진 모습
array([ 3, -1, -2,  0, 12, 10])

astype을 호출하면 새로운 dtype이 이전 dtype과 동일해도 항상 새로운 배열을 생성(데이터를 복사)한다.

ndarray의 차원과 크기를 변경

reshape() 메서드는 ndarray를 특정 차원 및 크기로 변환한다. 변환을 원하는 크기를 함수 인자로 부여하면 된다. reshape()는 지정된 사이즈로 변경이 불가능하면 오류를 발생한다.

array1 = np.arange(10)
array1
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
array2 = array1.reshape(2, 5)
array2
array([[0, 1, 2, 3, 4],
       [5, 6, 7, 8, 9]])
array3 = array1.reshape(5, 2)
array3
array([[0, 1],
       [2, 3],
       [4, 5],
       [6, 7],
       [8, 9]])
array4 = array1.reshape(3, 4)
array4

reshape()를 실전에서 더욱 효율적으로 사용하는 경우는 아마도 인자로 -1을 적용하는 경우일 것이다. 인자에 -1을 부여하면 -1에 해당하는 axis의 크기는 가변적이되, -1이 아닌 인자값에 해당하는 axis 크기는 인자값으로 고정하여 ndarray의 shape를 변환한다.

array1 = np.arange(10)
array1
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
array2 = array1.reshape(-1, 5) # 2차원 ndarray로 변환하되, 
array2                         #고정된 5개의 컬럼에 맞는 로우를 자동으로 새롭게 생성해 변환, 즉(2, 5)
array([[0, 1, 2, 3, 4],
       [5, 6, 7, 8, 9]])
array3 = array1.reshape(5, -1) # 10개의 1차원 데이터와 호환될 수 있는 고정된 5개의 로우에 맞는
array3                         # 칼럼인 2를 자동으로 새롭게 생성해 변환, 즉 (5, 2)
array([[0, 1],
       [2, 3],
       [4, 5],
       [6, 7],
       [8, 9]])

-1 인자는 reshape(-1,1)와 같은 형태로 자주 사용된다. reshape(-1,1)은 원본 ndarray가 어떤 형태라도 2차원이고, 여러 개의 로우를 가지되 반드시 1개의 칼럼을 가진 ndarray로 변환됨을 보장한다. 여러 개의 ndarray는 stack이나 concat으로 결합할 때 각각의 ndarray의 형태를 통일해 유용하게 사용된다.

array1 = np.arange(8)
array3d = array1.reshape((2, 2, 2))
array3d, array3d.shape
(array([[[0, 1],
         [2, 3]],
 
        [[4, 5],
         [6, 7]]]),
 (2, 2, 2))
# 3차원 ndarray를 2차원 ndarray로 변환
array2 = array3d.reshape(-1, 1)
array2, array2.shape
(array([[0],
        [1],
        [2],
        [3],
        [4],
        [5],
        [6],
        [7]]),
 (8, 1))
# 1차원 ndarray를 2차원 ndarray로 변환
array3 = array1.reshape(-1, 1)
array3, array3.shape
(array([[0],
        [1],
        [2],
        [3],
        [4],
        [5],
        [6],
        [7]]),
 (8, 1))

NumPy 배열의 산술 연산

배열의 중요한 특징은 for 문을 작성하지 않고 데이터를 일괄 처리할 수 있다는 것이다. 이를 벡터화vectorized라고 하는데, 같은 크기의 배열 간의 산술 연산은 배열의 각 원소 단위로 적용된다.

arr = np.array([[1., 2., 3.], [4., 5., 6.]])

arr
array([[1., 2., 3.],
       [4., 5., 6.]])
arr * arr # 각 원소 단위의 곱
array([[ 1.,  4.,  9.],
       [16., 25., 36.]])
arr - arr # 각 원소 단위의 차
array([[0., 0., 0.],
       [0., 0., 0.]])

스칼라 인자가 포함된 산술 연산의 경우 배열 내의 모든 원소에 스칼라 인자가 적용된다.

1 / arr
array([[1.        , 0.5       , 0.33333333],
       [0.25      , 0.2       , 0.16666667]])
arr ** 0.5
array([[1.        , 1.41421356, 1.73205081],
       [2.        , 2.23606798, 2.44948974]])

같은 크기를 가지는 배열 간의 비교 연산은 불리언 배열을 반환한다.

arr2 = np.array([[0., 4., 1.], [7., 2., 12.]])

arr2
array([[ 0.,  4.,  1.],
       [ 7.,  2., 12.]])
arr2 > arr # 각 자리에 대응되는 원소끼리 값을 비교하여 불리언 값을 반환
array([[False,  True, False],
       [ True, False,  True]])

크기가 다른 배열 간의 연산은 브로드캐스팅broadcasting이라고 한다.

인덱싱과 슬라이싱 기초

인덱싱은 ndarray 내의 일부 데이터 세트나 특정 데이터만을 선택할 수 있도록 하는 기능이다.

  1. 특정한 데이터만 추출: 원하는 위치의 인덱스 값을 지정하면 해당 위치의 데이터가 반환된다.

  2. 슬라이싱(Slicing): 슬라이싱은 연속된 인덱스상의 ndarray를 추출하는 방식이다. ‘:’ 기호 사이에 시작 인덱스와 종료 인덱스를 표시하면 시작 인덱스에서 종료 인덱스-1 위치에 있는 데이터의 ndarray를 반환한다.

  3. 팬시 인덱싱(Fancy Indexing): 일정한 인덱싱 집합을 리스트 또는 ndarray 형태로 지정해 해당 위치에 있는 데이터의 ndarray를 반환한다.

  4. 불린 인덱싱(Boolean Indexing): 특정 조건에 해당하는지 여부인 True/False 값 인덱싱 집합을 기반으로 True에 해당하는 인덱스 위치에 있는 데이터의 ndarray를 반환한다.

단일 값 추출

1개의 데이터값을 선택하려면 ndarray 객체에 해당하는 위치의 인덱스 값을 [] 안에 입력하면 된다.

# 1부터 9까지의 1차원 ndarray 생성
array1 = np.arange(1, 10)
print('array1:', array1)
# index는 0부터 시작하므로 array1[2]는 3번째 index 위치의 데이터값을 의미
value = array1[2]
print('value:', value)
print(type(value))
array1: [1 2 3 4 5 6 7 8 9]
value: 3
<class 'numpy.int32'>

인덱스에 마이너스 기호를 이용하면 맨 뒤에서부터 데이터를 추출할 수 있다. 인덱스 -1은 맨 뒤의 데이터값을 의미한다. -2는 맨 뒤에서 두 번째 있는 데이터값이다.

print('맨 뒤의 값:', array1[-1], ' 맨 뒤에서 두 번째 값:', array1[-2])
맨 뒤의 값: 9  맨 뒤에서 두 번째 값: 8

단일 인덱스를 이용해 ndarray 내의 데이터값도 간단히 수정 가능하다.

array1[0] = 9
array1[8] = 0
print('array1:', array1)
array1: [9 2 3 4 5 6 7 8 0]

다음은 다차원 ndarray에서 단일 값을 추출하는 방법이다. 1차원과 2차원 ndarray에서의 데이터 접근의 차이는 2차원의 경우 콤마(,)로 분리된 로우와 칼럼 위치의 인덱스를 통해 접근하는 것이다.

array1d = np.arange(1, 10) # 1부터 9까지의 NumPy 배열
array2d = array1d.reshape(3, 3) # (3, 3)의 2차원 NumPy 배열
print(array2d)

print('(row=0, col=0) index 가리키는 값:', array2d[0, 0]) # array2d[0][0]과 같음
print('(row=0, col=1) index 가리키는 값:', array2d[0, 1]) # array2d[0][1]과 같음
print('(row=1, col=0) index 가리키는 값:', array2d[1, 0]) # array2d[1][0]과 같음
print('(row=2, col=2) index 가리키는 값:', array2d[2, 2]) # array2d[2][2]과 같음
[[1 2 3]
 [4 5 6]
 [7 8 9]]
(row=0, col=0) index 가리키는 값: 1
(row=0, col=1) index 가리키는 값: 2
(row=1, col=0) index 가리키는 값: 4
(row=2, col=2) index 가리키는 값: 9

axis_matrix 2차원이므로 axis 0, axis 1로 구분되며, 3차원 ndarray의 경우는 axis 0, axis 1, axis 2로 3개의 축을 가지게 된다(행, 열, 높이로 이해하면 된다). 이런 식으로 넘파이의 다차원 ndarray는 axis 구분을 가진다.

axis 0이 '로우' 방향 축, axis 1이 '칼럼' 방향 축임을 이해하는 것은 중요하다. 다차원 ndarray의 경우 축(axis)에 따른 연산을 지원하기 때문이다. 축 기반의 연산에서 axis가 생략되면 axis 0을 의미한다.

슬라이싱

’:’기호를 이용해 연속한 데이터를 슬라이싱해서 추출할 수 있다. 단일 데이터값 추출을 제외하고 슬라이싱, 팬시 인덱싱, 불린 인덱싱으로 추출된 데이터 세트는 모두 ndarray 타입니다. ‘:’ 사이에 시작 인덱스와 종료 인덱스를 표시하면 시작 인덱스에서 종료 인덱스-1의 위치에 있는 데이터의 ndarray를 반환한다.

array1 = np.arange(1, 10)
array2 = array1[0:3]
print(array2)
print(type(array2))
[1 2 3]
<class 'numpy.ndarray'>

슬라이싱 기호인’:’사이의 시작, 종료 인덱스는 생략이 가능하다.

  1. ’:’기호 앞에 시작 인덱스를 생략하면 자동으로 맨 처음 인덱스인 0으로 간주한다.

  2. ’:’기호 뒤에 종료 인덱스를 생략하면 자동으로 맨 마지막 인덱스로 간주한다.

  3. ’:’ 기호 앞/뒤에 시작/종료 인덱스를 생략하면 자동으로 맨 처음/마지막 인덱스로 간주한다.

array1 = np.arange(1, 10)
array2 = array2[:3]
print(array2)

array3 = array1[3:]
print(array3)

array4 = array1[:]
print(array4)
[1 2 3]
[4 5 6 7 8 9]
[1 2 3 4 5 6 7 8 9]

2차원 배열에서 각 색인에 해당하는 요소는 스칼라값이 아니라 로우 축(axis 0)의 1차원 배열이다.

array1d = np.arange(1, 10)
array2d = array1d.reshape(3, 3)
print('array2d:\n', array2d)
print('\narray2d[2]:\n', array2d[2])
array2d:
 [[1 2 3]
 [4 5 6]
 [7 8 9]]

array2d[2]:
 [7 8 9]

따라서 개별 요소는 재귀적으로 접근해야 한다. 하지만 그렇게 하기는 귀찮기 때문에 콤마로 구분된 색인 리스트를 넘기면 된다.

print(array2d[0][2], array2d[0, 2]) # 동일하다
3 3

2차원 ndarray에서의 슬라이싱도 1차원 ndarray에서의 슬라이싱과 유사하며, 단지 콤마(,)로 로우와 칼럼 인덱스를 지칭하는 부분만 다르다.

array1d = np.arange(1, 10)
array2d = array1d.reshape(3, 3)
print('array2d:\n', array2d)

print('array2d[0:2, 0:2]\n', array2d[0:2, 0:2])
print('array2d[1:3, 0:3]\n', array2d[1:3, 0:3])
print('array2d[1:3, :]\n', array2d[1:3, :])
print('array2d[:, :]\n', array2d[:, :])
print('array2d[:2, 1:]\n', array2d[:2, 1:])
print('array2d[:2, 0]\n', array2d[:2, 0])
array2d:
 [[1 2 3]
 [4 5 6]
 [7 8 9]]
array2d[0:2, 0:2]
 [[1 2]
 [4 5]]
array2d[1:3, 0:3]
 [[4 5 6]
 [7 8 9]]
array2d[1:3, :]
 [[4 5 6]
 [7 8 9]]
array2d[:, :]
 [[1 2 3]
 [4 5 6]
 [7 8 9]]
array2d[:2, 1:]
 [[2 3]
 [5 6]]
array2d[:2, 0]
 [1 4]

브로드 캐스팅과 뷰

arr = np.arange(10)

arr
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
arr[5:8] = 12

arr
array([ 0,  1,  2,  3,  4, 12, 12, 12,  8,  9])

arr[5:8] = 12처럼 배열 조각에 스칼라값을 대입하면 12가 선택 영역 전체로 전파(또는 브로드캐스팅)된다. 리스트와의 중요한 차이점은 배열 조각은 원본 배열의 라는 점이다. 즉, 데이터는 복사되지 않고 뷰에 대한 변경은 그대로 원본 배열에 반영된다.

arr_slice = arr[5:8]

arr_slice
array([12, 12, 12])
arr_slice[1] = 12345

arr # 원본 arr의 4~7이 바뀌어 있는 모습
array([    0,     1,     2,     3,     4,    12, 12345,    12,     8,
           9])
arr_slice[:] = 64

arr # arr의 4~7을 참조하고 있는 값을 64로 바꾼 결과가 원본에 그대로 반영
array([ 0,  1,  2,  3,  4, 64, 64, 64,  8,  9])

NumPy는 대용량의 데이터 처리를 염두에 두고 설계되었기 때문에 데이터는 복사되지 않는다. 만약 NumPy가 데이터 복사를 남발한다면 성능과 메모리 문제에 마주치게 될 것이다.

만약에 뷰 데신 ndarray 슬라이스의 복사본을 얻고 싶다면 arr[5:8].copy()와 같이 copy()를 사용해서 명시적으로 배열을 복사해야 한다.

arr_copy = arr[5:8].copy()
arr_copy[:] = 100

arr # arr[5:8]에 100이 적용되지 않은 모습
array([ 0,  1,  2,  3,  4, 64, 64, 64,  8,  9])

팬시 인덱싱

팬시 인덱싱(Fancy Indexing)은 리스트나 ndarray로 인덱스 집합을 지정하면 해당 위치의 인덱스에 해당하는 ndarray를 반환하는 인덱싱 방식이다. 정수 배열을 사용한 색인을 설명하기 위해 NumPy에서 차용했다.

arr = np.empty((8, 4)) # 초기화되지 않은 값으로 채워진 (8, 4) ndarray

for i in range(8):
    arr[i] = i 

arr
array([[0., 0., 0., 0.],
       [1., 1., 1., 1.],
       [2., 2., 2., 2.],
       [3., 3., 3., 3.],
       [4., 4., 4., 4.],
       [5., 5., 5., 5.],
       [6., 6., 6., 6.],
       [7., 7., 7., 7.]])

특정한 순서로 로우를 선택하고 싶다면 그냥 원하는 순서가 명시된 정수가 담긴 ndarray나 리스트를 넘기면 된다.

arr[[4, 3, 0, 6]] # 4, 3, 0, 6의 로우 순으로 출력
array([[4., 4., 4., 4.],
       [3., 3., 3., 3.],
       [0., 0., 0., 0.],
       [6., 6., 6., 6.]])
arr[[-3, -5, -7]] # 음수도 가능
array([[5., 5., 5., 5.],
       [3., 3., 3., 3.],
       [1., 1., 1., 1.]])

다차원 색인 배열을 넘기면 각각의 색인 튜플에 대응하는 1차원 배열이 선택된다.

arr = np.arange(32).reshape((8, 4))

arr
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, 30, 31]])
arr[[1, 5, 7, 2], [0, 3, 1, 2]]
array([ 4, 23, 29, 10])

여기서 결과를 보면 (1, 0), (5, 3), (7, 1), (2, 2)에 대응하는 원소들이 선택되었다. 배열이 몇 차원이든지 팬시 인덱싱의 결과는 항상 1차원이다.

행렬의 행(로우)과 열(칼럼)에 대응하는 사각형 모양의 값을 선택하려면 다음과 같이 한다.

arr[[1, 5, 7, 2]][:, [0, 3, 1, 2]] # 앞의 대괄호에서는 해당 로우를 선택, 뒤의 대괄호에서는 순서
array([[ 4,  7,  5,  6],
       [20, 23, 21, 22],
       [28, 31, 29, 30],
       [ 8, 11,  9, 10]])

팬시 인덱싱은 슬라이싱과는 달리 선택된 데이터를 새로운 배열로 복사한다.

불린 인덱싱

불린 인덱싱(Boolean indexing)은 조건 필터링과 검색을 동시에 할 수 있기 때문에 매우 자주 사용되는 인덱싱 방식이다. 불린 인덱싱을 이용하면 for loop/if else 문보다 훨씬 간단하게 구현할 수 있다. 불린 인덱싱은 ndarray의 인덱스를 지정하는 [] 내에 조건문을 그대로 기재하기만 하면 된다.

불린 인덱싱이 동작하는 단계

  1. ndarray의 필터링 조건을 []안에 기재

  2. False 값을 무시하고 True 값에 해당하는 인덱스값만 저장(유의해야 할 사항은 True값 자체인 1을 저장하는 것이 아니라 True값을 가진 인덱스를 저장한다는 것이다)

  3. 저장된 인덱스 데이터 세트로 ndarray 조회

names = np.array(['Bob', 'Joe', 'Will', 'Bob', 'Will', 'Joe', 'Joe'])
data = np.random.randn(7, 4)

names
array(['Bob', 'Joe', 'Will', 'Bob', 'Will', 'Joe', 'Joe'], dtype='< U4')
data
array([[-0.11480779,  0.98256768, -0.07996555, -0.0450252 ],
       [ 0.30684238, -0.9468277 ,  0.01313506, -0.84677292],
       [ 0.72140019,  0.1295876 ,  0.20976242,  0.20960562],
       [ 0.91097294,  0.96304394, -0.38445383,  0.02049829],
       [ 1.71568255,  1.11549509,  0.83597503, -0.71208414],
       [-0.05988664,  1.5749902 ,  0.24494478, -1.9485911 ],
       [ 0.13558595, -0.87632899,  1.4681147 ,  0.8106315 ]])

각각의 이름은 data 배열의 각 로우에 대응한다고 가정한다. 만약 전체 로우에서 ‘Bob’과 같은 이름을 선택하려면 산술 연산과 마찬가지로 배열에 대한 비교 연산(== 같은)도 벡터화되므로 names를 ‘Bob’ 문자열과 비교하면 불리언 배열을 반환한다.

names == 'Bob'
array([ True, False, False,  True, False, False, False])

이 불리언 배열은 배열의 인덱싱으로 사용할 수 있다.

data[names == 'Bob']
array([[-0.11480779,  0.98256768, -0.07996555, -0.0450252 ],
       [ 0.91097294,  0.96304394, -0.38445383,  0.02049829]])

불리언 배열은 반드시 색인하려는 축의 길이와 동일한 길이를 가져야 한다. 불리언 배열 인덱싱도 슬라이스나 요소를 선택하는 데 짜 맞출 수 있다.

불리언값으로 배열을 선택할 때는 불리언 배열의 크기가 다르더라도 실패하지 않으므로 이 기능을 사용할 때는 항상 주의하도록 한다.

data[names == 'Bob', 2:] # names == 'Bob'인 로우에서 2: 컬럼을 선택했다.
array([[-0.07996555, -0.0450252 ],
       [-0.38445383,  0.02049829]])

‘Bob’이 아닌 요소들을 선택하려면 != 연산자를 사용하거나 ~를 사용해서 조건절을 부인하면 된다.

names != 'Bob'
array([False,  True,  True, False,  True,  True,  True])
data[~(names == 'Bob')] 
array([[ 0.30684238, -0.9468277 ,  0.01313506, -0.84677292],
       [ 0.72140019,  0.1295876 ,  0.20976242,  0.20960562],
       [ 1.71568255,  1.11549509,  0.83597503, -0.71208414],
       [-0.05988664,  1.5749902 ,  0.24494478, -1.9485911 ],
       [ 0.13558595, -0.87632899,  1.4681147 ,  0.8106315 ]])

~ 연산자는 일반적인 조건을 반대로 쓰고 싶을 때 유용하다.

cond = names == 'Bob' # names가 'Bob'인 조건

data[~cond] # 위와 동일
array([[ 0.30684238, -0.9468277 ,  0.01313506, -0.84677292],
       [ 0.72140019,  0.1295876 ,  0.20976242,  0.20960562],
       [ 1.71568255,  1.11549509,  0.83597503, -0.71208414],
       [-0.05988664,  1.5749902 ,  0.24494478, -1.9485911 ],
       [ 0.13558595, -0.87632899,  1.4681147 ,  0.8106315 ]])

세 가지 이름 중에서 두 가지 이름을 선택하려면 &(and)나 |(or) 같은 논리 연산자를 사용한 여러 개의 불리언 조건을 사용하면 된다. 단, 파이썬 예약어인 andor은 불리언 배열에서는 사용할 수 없다.

mask = (names == 'Bob') | (names == 'Will') # names가 'Bob' 이거나 'Will'인 조건

mask
array([ True, False,  True,  True,  True, False, False])
data[mask]
array([[-0.11480779,  0.98256768, -0.07996555, -0.0450252 ],
       [ 0.72140019,  0.1295876 ,  0.20976242,  0.20960562],
       [ 0.91097294,  0.96304394, -0.38445383,  0.02049829],
       [ 1.71568255,  1.11549509,  0.83597503, -0.71208414]])

배열에 불린 인덱싱을 이용해서 데이터를 선택하면 반환되는 배열의 내용이 바뀌지 않더라도 항상 데이터 복사가 발생한다.

불리언 배열에 값을 대입할 수도 있다. data에 저장된 모든 음수를 0으로 대입하려면 다음과 같이 한다.

data[data < 0] = 0

data
array([[0.        , 0.98256768, 0.        , 0.        ],
       [0.30684238, 0.        , 0.01313506, 0.        ],
       [0.72140019, 0.1295876 , 0.20976242, 0.20960562],
       [0.91097294, 0.96304394, 0.        , 0.02049829],
       [1.71568255, 1.11549509, 0.83597503, 0.        ],
       [0.        , 1.5749902 , 0.24494478, 0.        ],
       [0.13558595, 0.        , 1.4681147 , 0.8106315 ]])

1차원 불리언 배열은 사용해서 전체 로우나 칼럼을 선택하는 것은 쉽게 할 수 있다.

data[names != 'Joe'] = 7 # names가 'Joe'가 아닌 값에 7 대입

data
array([[7.        , 7.        , 7.        , 7.        ],
       [0.30684238, 0.        , 0.01313506, 0.        ],
       [7.        , 7.        , 7.        , 7.        ],
       [7.        , 7.        , 7.        , 7.        ],
       [7.        , 7.        , 7.        , 7.        ],
       [0.        , 1.5749902 , 0.24494478, 0.        ],
       [0.13558595, 0.        , 1.4681147 , 0.8106315 ]])

불린 인덱싱은 내부적으로 여러 단계를 거쳐서 동작하지만, 코드 자체는 단순히 [] 내에 원하는 필터링 조건만 넣으면 해당 조건을 만족하는 ndarray 데이터 세트를 반환하기 때문에 사용자는 내부 로직을 크게 신경 쓰지 않고 쉽게 코딩할 수 있다. 나중에 살펴보겠지만 2차원 데이터에 대한 연산은 pandas를 이용해서 처리하는 것이 더 편리하다.

배열 전치와 축 바꾸기

배열 전치는 데이터를 복사하지 않고 데이터의 모양이 바뀐 뷰를 반환하는 특별한 기능이다. ndarray는 transpose 메서드와 T라는 이름의 특수한 속성을 가지고 있다.

원 행렬에서 행과 열 위치를 교환한 원소로 구성한 행렬을 그 행렬의 전치 행렬이라고 한다.

arr = np.arange(15).reshape((3, 5))

print(arr)
print(arr.shape) # (3, 5)의 크기를 갖는다
[[ 0  1  2  3  4]
 [ 5  6  7  8  9]
 [10 11 12 13 14]]
(3, 5)
print(arr.T)
print(arr.T.shape) # (5, 3)의 크기를 갖는다
[[ 0  5 10]
 [ 1  6 11]
 [ 2  7 12]
 [ 3  8 13]
 [ 4  9 14]]
(5, 3)

행렬 계산을 할 때 자주 사용하게 될 텐데, 예를 들어 행렬의 내적은 np.dot을 이용해서 구할 수 있다. 행렬 내적은 행렬 곱이며, 두 행렬 A와 B의 내적은 np.dot()을 이용해 계산이 가능하다. 두 행렬 A와 B의 내적은 왼쪽 행렬의 로우(행)과 오른쪽 행렬의 칼럼(열)의 원소들을 순차적으로 곱한 뒤 그 결과를 모두 더한 값이다. 이러한 행렬 내적의 특성으로 왼쪽 행렬의 열 개수와 오른쪽 행렬의 행 개수가 동일해야 내적 연산이 가능하다.

arr = np.random.randn(6, 3)

arr
array([[ 0.00520735,  0.36793392, -0.70667543],
       [ 1.33762439,  1.20977267, -0.50327627],
       [-0.6289917 , -0.13863353, -0.3574128 ],
       [ 1.06845071, -1.37877486, -1.5979695 ],
       [ 0.38397114, -0.20671939, -0.25567017],
       [-1.15449339,  0.28683848,  0.57003397]])
np.dot(arr.T, arr) # (3, 6) dot (6, 3) = (3, 3)
array([[ 4.80677243, -0.17634366, -2.9156869 ],
       [-0.17634366,  3.64417388,  1.6002895 ],
       [-2.9156869 ,  1.6002895 ,  3.82423356]])

다차원 배열의 경우 transpose 메서드는 튜플로 축 번호를 받아서 치환한다.

arr = np.arange(30).reshape((2, 3, 5))

print(arr)
print(arr.shape)
[[[ 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]]]
(2, 3, 5)
print(arr.transpose((1, 0, 2)))
print(arr.transpose((1, 0, 2)).shape)
[[[ 0  1  2  3  4]
  [15 16 17 18 19]]

 [[ 5  6  7  8  9]
  [20 21 22 23 24]]

 [[10 11 12 13 14]
  [25 26 27 28 29]]]
(3, 2, 5)

첫 번째(2)와 두 번째(3)의 축 순서가 뒤바뀌었고 마지막 축(5)은 그대로 남았다.

T 속성을 이용하는 간단한 전치는 축을 뒤바꾸는 특별한 경우다. ndarray에는 swapaxes라는 메서드가 있는데 두 개의 축 번호를 받아서 배열을 뒤바꾼다.

print(arr)
print(arr.shape)
[[[ 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]]]
(2, 3, 5)
print(arr.swapaxes(1, 2))
print(arr.swapaxes(1, 2).shape)
[[[ 0  5 10]
  [ 1  6 11]
  [ 2  7 12]
  [ 3  8 13]
  [ 4  9 14]]

 [[15 20 25]
  [16 21 26]
  [17 22 27]
  [18 23 28]
  [19 24 29]]]
(2, 5, 3)

두 번째(3)와 마지막 축(5) 순서가 뒤바뀌었고 첫 번째 축(2)은 그대로 남았다.

swapaxes도 마찬가지로 데이터를 복사하지 않고 원래 데이터에 대한 뷰를 반환한다.

관련 포스트 더 보기

  • NumPy01: NumPy는 무엇인가?
  • NumPy03: 유니버셜 함수, 배열지향 프로그래밍, 파일 입출력, 선형대수, 난수 생성
  • Advanced NumPy01: ndarray 객체 구조, 고급 배열 조작 기법, 브로드캐스팅, 고급 ufunc 사용법
  • Advanced NumPy02: 구조화된 배열과 레코드 배열, 정렬, 고급 배열 입출, 성능 팁
  • Example of going up and down stairs: 간단한 NumPy 실습
  • 댓글남기기