by Yngie

Numpy (2) - 인덱싱(Indexing)과 슬라이싱(Slicing)

|

이번 포스팅의 내용은 파이썬 라이브러리를 활용한 데이터 분석(2판) 의 내용을 참조하여 작성하였습니다.

Indexing and Slicing

이번 게시물에서는 ndarray 의 요소를 조작하기 위한 인덱싱과 슬라이싱에 대해서 알아보도록 하겠습니다.

Basic

기본적으로 배열의 색인은 파이썬의 리스트(python list)를 다루는 방법과 유사합니다. 슬라이싱의 결과물이 리스트가 아니라 ndarray 라는 점에서만 차이가 있지요.

>>> import numpy as np

>>> arr1 = np.arange(7)
>>> arr1
array([0, 1, 2, 3, 4, 5, 6])

>>> arr1[3]
3

>>> arr1[4:6]
array([4, 5])

Broadcasting

넘파이에는 브로드캐스팅(broadcasting)이라는 기능이 있어서 슬라이싱한 객체에 특정한 값을 대입해주면 모두 해당하는 값으로 변형이 됩니다. 아래 예시를 보겠습니다.

>>> arr1[4:6] = -1
>>> arr1
array([0, 1, 2, 3, -1, -1, 6])

원래 array([4, 5]) 가 있던 자리에 할당된 -1 이 들어가 있음을 확인할 수 있습니다. 이 동작은 파이썬 리스트에서는 작동하지 않습니다.

>>> lst1 = [i for i in range(7)]
>>> lst1[4:6] = -1
TypeError: can only assign an iterable
    
# 만약에 할당하려면 언패킹(Unpacking)을 활용합니다.
>>> lst1[4:6] = -1, -1
>>> lst1
[0, 1, 2, 3, -1, -1, 6]

위와 같이 파이썬 리스트에서는 슬라이싱 객체에 iterable 을 할당하지 않으면 TypeError 가 발생합니다.

View

ndarray 와 파이썬 리스트의 슬라이싱 객체는 브로드캐스팅 외에도 차이점을 가지고 있습니다. 배열 조각이 원본 배열의 뷰(View)라는 점인데요. 뷰는 원본 데이터의 복사본이 아닙니다. 그렇기 때문에 뷰를 배열 조각을 변경할 경우에 원본 데이터의 원소도 변경됩니다. 아래 예시를 보도록 하겠습니다.

>>> arr2 = np.arange(7)
>>> part_of_arr2 = arr2[3:6]
>>> part_of_arr2[1] = -1
>>> print(f"""part_of_arr2 : {part_of_arr2}
arr2 : {arr2}""")

part_of_arr2 : [ 3 -1  5]
arr2 : [ 0  1  2  3 -1  5  6]

arr2의 배열 조각인 part_of_arr2의 원소를 변경해주었을 뿐인데 원본 배열 arr2 의 요소도 함께 바뀌었습니다. 동일한 코드를 파이썬 리스트에 실행하면 어떻게 될까요?

>>> lst2 = [i for i in range(7)]
>>> part_of_lst2 = lst2[3:6]
>>> part_of_lst2[1] = -1
>>> print(f"""part_of_lst2 : {part_of_lst2}
lst2 : {lst2}""")

part_of_lst2 : [3, -1, 5]
lst2 : [0, 1, 2, 3, 4, 5, 6]

리스트 조각 part_of_lst2 의 요소를 변경해주었음에도 원본 리스트 lst2 의 요소는 변함이 없음을 확인할 수 있습니다. 넘파이가 위와 같이 배열 조각을 복사하지 않고 뷰 형태로 나타내는 이유는 메모리를 절약하기 위해서입니다. 애초부터 대용량 데이터 처리를 염두에 두고 설계되었기에 복사로 인한 메모리 남용을 최소화 하기 위한 방책인 것이지요. 그래도 복사본을 만들고 싶다면 .copy() 를 사용할 수 있습니다. 아래는 해당 메서드를 사용하여 배열 조각에 대한 복사본을 저장하고, 복사본의 원소를 변경하여도 원본의 데이터는 변경되지 않음을 확인할 수 있습니다.

>>> arr3 = np.arange(7)
>>> part_of_arr3 = arr2[3:6].copy()
>>> part_of_arr3[1] = -1
>>> print(f"""part_of_arr3 : {part_of_arr3}
arr3 : {arr3}""")

part_of_arr3 : [ 3 -1  5]
arr3 : [0 1 2 3 4 5 6]

in n-D array

Indexing

다음으로 다차원 배열에서의 인덱싱과 슬라이싱에 대해서 알아보도록 하겠습니다. 다차원 배열에서는 바깥부터 안쪽으로 하나하나 접근해 들어가야 합니다. 다음과 같은 2차원 배열이 있다고 해보겠습니다.

>>> arr2d1 = np.arange(9).reshape(3, -1)
>>> arr2d1
array([[0, 1, 2],
       [3, 4, 5],
       [6, 7, 8]])

이 배열에서 5를 고르려면 어떻게 해야 할까요. 5는 가장 바깥 차원을 기준으로 1번째 인덱스에 위치하고 있는 배열인 [3,4,5] 안에 있으며 해당 배열 안에선 2번째 리스트에 있습니다. 그렇기 때문에 해당 배열에서 5를 인덱싱하는 방법은 아래와 같습니다.

>>> arr2d1[1][2]
5

# 매번 괄호를 써서 인덱싱하기 귀찮으므로
# , 를 사용하여 인덱싱 할 수 있습니다.
>>> arr2d1[1,2]
5

3차원 배열도 마찬가지 입니다. 아래와 같은 배열에서 5를 인덱싱하는 예시를 알아보겠습니다.

>>> arr3d1 = np.arange(12).reshape(2, 3, -1)
>>> arr3d1
array([[[ 0,  1],
        [ 2,  3],
        [ 4,  5]],
       [[ 6,  7],
        [ 8,  9],
        [10, 11]]])

>>> arr3d1[0, 2, 1]
5

전체 3차원 배열에서 5가 포함되어 있는 2차원 배열인 [[0,1],[2,3],[4,5]] 가 0번째 인덱스에 위치하고 있습니다. 그리고 그 안에서 [4,5] 는 2번째 인덱스에 위치하고 있고, 5는 [4,5] 중 1번째 인덱스에 있습니다.

Slicing

슬라이싱도 유사한 방법으로 할 수 있습니다. 위에서 사용했던 2차원 배열 arr2d1에서 [[3,4], [6,7]] 만 슬라이싱해보겠습니다.

>>> arr2d1[1:, :2]
array([[3, 4],
       [6, 7]])

먼저 가장 바깥 차원에서 필요한 부분은 [[3,4,5],[6,7,8]] 이므로 1: 로 슬라이싱 합니다. 다음으로 각 1차원 배열의 0번째와 1번째 인덱스만 필요하므로 :2 로 슬라이싱합니다. 3차원 배열도 마찬가지 입니다. arr3d1 에서 [[[0], [2], [4]]] 만 슬라이싱 해보겠습니다.

>>> arr3d1[:1, :, :1]
array([[[0],
        [2],
        [4]]])

:1 슬라이싱은 어차피 0번째 인덱스만 가져옵니다. 그렇다면 0 인덱싱으로 인덱싱하는 것과 어떤 차이점을 갖고 있을까요? 정답은 배열 조각의 차원입니다. 슬라이싱으로 가져오면 원래 배열의 차원이 유지되지만 인덱스로 가져오면 해당 위치의 배열 차원이 사라집니다. 아래는 동일하게 0,2,4 라는 원소를 가진 배열 조각을 인덱싱을 섞어 가져오는 방법을 나타낸 코드입니다.

>>> arr3d1[0, :, :1]
array([[0],
       [2],
       [4]])

>>> arr3d1[:1, :, 0]
array([[0, 2, 4]])

>>> arr3d1[0, :, 0]
array([0, 2, 4])

arr3d1[:1, :, :1] 처럼 원래 3차원 배열에 모두 슬라이싱을 사용하여 얻어낸 배열 조각은 3차원입니다. 여기서 앞이나 뒤에 있는 :10 으로 인덱싱하면 각 위치에서의 차원이 사라지면서 2차원 배열을 얻어낼 수 있습니다. 각각 array([[0],[2],[4]])array([[0, 2, 4]]) 이라는 결과가 나왔습니다. :1 을 모두 0 으로 대체하면 2개의 차원이 사라지면서 1차원 배열 array([0, 2, 4]) 이 나오게 됨을 확인할 수 있습니다.

Boolean Indexing

불리언 인덱싱(Boolean indexing, 불린 인덱싱)은 배열에서 원하는 조건에 맞는 원소를 선택하는 방법입니다. 아래의 코드를 보겠습니다.

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

>>> bool_odd = (arr%2 == 1)
>>> bool_odd
array([False, True, False, True, False, True])

>>> bool_odd.dtype
dtype('bool')

위와 같이 설정한 조건에 맞는 불리언 배열이 생성됩니다. 이 불리언 배열을 사용하여 특정 배열을 인덱싱해보겠습니다.

>>> weight_init = np.random.randn(6, 4)
>>> weight_init
array([[ 0.82125035,  1.17102989, -0.33640184,  1.21870209],
       [-1.01272699,  1.69531067, -0.72790492, -0.76726316],
       [-0.0283028 ,  1.07953567, -0.3576    , -0.02536179],
       [ 0.65688213,  1.46257328, -0.10013135,  2.19944658],
       [ 0.26648601, -1.67300499, -0.5105097 ,  1.08691335],
       [ 0.08560957,  1.38428115, -0.82035441,  0.22561346]])

>>> weight_init[bool_odd]
array([[-1.01272699,  1.69531067, -0.72790492, -0.76726316],
       [ 0.65688213,  1.46257328, -0.10013135,  2.19944658],
       [ 0.08560957,  1.38428115, -0.82035441,  0.22561346]])

bool_odd 의 원소 값이 True 인 홀수 행만 선택됨을 확인할 수 있습니다. 여기에 조건을 추가하여 선택할 수도 있습니다.

>>> weight_init[bool_odd, :-1]
array([[-1.01272699,  1.69531067, -0.72790492],
       [ 0.65688213,  1.46257328, -0.10013135],
       [ 0.08560957,  1.38428115, -0.82035441]])

마지막 열을 제외하고 슬라이싱 되었음을 확인할 수 있습니다. and(&)or(|) 논리 연산자를 사용하면 여러 개의 조건을 설정할 수 있습니다. 조건을 하나 더 설정한 뒤에

>>> bool_3multiple = (arr%3 == 0)
>>> bool_3multiple
array([ True, False, False,  True, False, False])

>>> weight_init[bool_odd & bool_3multiple]
array([[ 0.65688213,  1.46257328, -0.10013135,  2.19944658]])

>>> weight_init[bool_odd | bool_3multiple]
array([[ 0.82125035,  1.17102989, -0.33640184,  1.21870209],
       [-1.01272699,  1.69531067, -0.72790492, -0.76726316],
       [ 0.65688213,  1.46257328, -0.10013135,  2.19944658],
       [ 0.08560957,  1.38428115, -0.82035441,  0.22561346]])

and 를 사용하였을 때는 두 조건을 모두 만족하는 3번째 인덱스에 있는 배열만 선택되었고, or 을 사용하였을 때는 두 조건 중 하나라도 만족하는 0,1,3,5 번째 인덱스에 있는 배열이 선택되었습니다.

Fancy Indexing

팬시 인덱싱(Fancy indexing)은 정수 배열을 사용하여 배열을 인덱싱하는 방법입니다. 위에서 사용했던 2차원 배열 weight_init 에 팬시 인덱싱을 적용해보겠습니다.

>>> weight_init
array([[ 0.82125035,  1.17102989, -0.33640184,  1.21870209],
       [-1.01272699,  1.69531067, -0.72790492, -0.76726316],
       [-0.0283028 ,  1.07953567, -0.3576    , -0.02536179],
       [ 0.65688213,  1.46257328, -0.10013135,  2.19944658],
       [ 0.26648601, -1.67300499, -0.5105097 ,  1.08691335],
       [ 0.08560957,  1.38428115, -0.82035441,  0.22561346]])

>>> weight_init[[1, 5, 0, 2]]
array([[-1.01272699,  1.69531067, -0.72790492, -0.76726316],
       [ 0.08560957,  1.38428115, -0.82035441,  0.22561346],
       [ 0.82125035,  1.17102989, -0.33640184,  1.21870209],
       [-0.0283028 ,  1.07953567, -0.3576    , -0.02536179]])

1, 5, 0, 2 번째 인덱스에 위치한 행이 그대로 출력되었음을 확인할 수 있습니다. 기본 인덱싱과 같이 , 뒤에 다른 배열을 연결해주면 열(Column)에 대한 인덱싱도 할 수 있습니다.

>>> weight_init[[1, 5, 0, 2], [2, 1, 0, 3]]
array([-0.72790492,  1.38428115,  0.82125035, -0.02536179])

위와 같이 인덱싱한 결과는 [1,2], [5,1], [0,0], [2,3] 로 각각 인덱싱 했을 때의 성분이 하나의 배열로 묶여 있는 것과 같습니다. 만약 1,5,0,2 번째 행에 있는 모든 성분을 2,1,0,3의 순서로 선택하고 싶다면 아래와 같은 코드를 작성할 수 있습니다.

>>> weight_init[[1, 5, 0, 2]][:, [2, 1, 0, 3]]
array([[-0.72790492,  1.69531067, -1.01272699, -0.76726316],
       [-0.82035441,  1.38428115,  0.08560957,  0.22561346],
       [-0.33640184,  1.17102989,  0.82125035,  1.21870209],
       [-0.3576    ,  1.07953567, -0.0283028 , -0.02536179]])

이 행렬은 weight_init[[1, 5, 0, 2]] 로 인덱싱한 행렬을 [:, [2,1,0,3]] 으로 열만 다시 팬시 인덱싱한 것으로 순서가 바뀌어 있음을 알 수 있습니다.

Conclusion

이번 게시물에서는 색인과 슬라이싱을 사용하여 배열의 요소를 선택하고 조작하는 방법에 대해서 알아보았습니다. 다음에는 정말 Numerical 한 작업을 수행하는 유니버셜 함수에 대해서 알아보겠습니다.



Comments