17  넘파이(numpy) 다차원배열

넘파이(numpy)는 파이썬에서 과학, 수리 계산을 위한 핵심 가운데 핵심인 패키지로, 다차원 배열 객체와 다양한 수학 함수를 제공한다. 뒤에서 설명할 판더스(pandas), 매트플롯립(matplotlib) 등은 물론이고 통계 분석을 위한 statsmodels 패키지난 scipy.stats 모듈, 머신러닝을 위한 scikit-learn 패키지 등도 넘파이를 기반으로 한다.

넘파이에는 많은 함수와 메서드가 있지만 그것들을 하나씩 설명하는 것은 의미가 없다고 본다. 넘파이는 뒤(back)에서 일을 하는 경우가 많고, 사용자들은 넘파이가 아닌, 내부에서 넘파이에 의존하는 고수순 함수들을 사용할 가능성이 훨씬 높기 때문이다.

중요한 것은 좀 더 큰 그림에서 넘파이가 어떤 것이고 또 어떤 역할을 하는지 이해하는 것이다. 좀 더 나아가서 PyTorchTensorFlow 같은 머신러닝 프레임워크의 기초를 이루는 텐서(tensor)를 이해하는 데도 도움을 준다. 텐서(tensor) 다차원 배열은 넘파이의 배열(array)을 확장한 개념이기 때문이다(물론 넘파이 배열보다는 훨씬 강력하게 진화했다).

17.1 다차원 배열(ndarray)

넘파이의 핵심 데이터 타입은 다차원 배열(ndarray)이다. ndarray는 “N-dimensional array”의 약자로, N차원 배열을 의미한다. 이 배열은 동일한 데이터 타입(자료형, dtype)을 가진 요소들로 구성되며, 벡터화된 연산(vetorized operation)을 지원한다. 즉, 배열 단위로 연산을 수행할 수 있어 반복문 없이도 빠른 계산이 가능하다.

다시 말해 ndarray는 다음과 같은 특징을 가진다:

  • 동일한 데이터 타입: ndarray는 모든 요소가 동일한 데이터 타입을 가져야 한다. 이는 메모리 사용을 최적화하고 연산 속도를 높이는 데 도움이 된다.

  • 다차원 배열: ndarray는 1차원(벡터), 2차원(행렬), 3차원 이상의 배열을 지원한다. 이를 통해 다양한 형태의 데이터를 표현할 수 있다.

  • 벡터화된 연산: ndarray는 배열 단위로 연산을 수행할 수 있어, 반복문 없이도 빠른 계산이 가능하다. 이는 파이썬의 기본 리스트(list)보다 훨씬 빠르다. R 언어에 보면 벡터(vector)와 유사한 개념이다.

17.1.1 ndarray 만들기

넘파이 배열을 만들기 위해서는 numpy 패키지를 먼저 임포트해야 한다. 일반적으로 np라는 별칭을 사용한다. 물론 다른 별칭을 사용할 수는 있지만, 대부분의 사람들이 np를 사용하기 때문에 이를 따르는 것이 좋다.

import numpy as np

ndarray가 파이썬 클래스(class)이기 때문에 이 클래스의 생성자(constructor)를 호출하여 배열을 만들 수 있지만, 그렇게 사용하는 경우는 극히 드물다. 대시 로는 numpy 패키지에서 제공하는 다양한 함수를 사용하여 배열을 생성한다. 가장 많이 사용하는 함수는 np.array() 함수이다. 이 함수는 파이썬의 리스트(list)나 튜플(tuple) 등을 입력으로 받아서 ndarray를 생성한다.

d1a = np.array([1, 2, 3])  # 1차원 배열
d1a
array([1, 2, 3])
d2a = np.array([[1, 2, 3], [4, 5, 6]])  # 2차원 배열
d2a
array([[1, 2, 3],
       [4, 5, 6]])
d3a= np.array([[[1, 2], [3, 4]], [[5, 6], [7, 8]]])  # 3차원 배열
d3a
array([[[1, 2],
        [3, 4]],

       [[5, 6],
        [7, 8]]])

더 높은 차원의 배열도 만들 수 있겠지만 이 정도를 가지고 넘파이를 이해해 보자.

넘파이 배열을 만들 때는 np.array() 함수 외에도 다양한 함수를 사용할 수 있다. 예를 들어, 다음과 같은 함수들이 있다. 이런 함수들은 존재하는 이유는 선형 대수(linear algebra)나 과학 계산(scientific computing)에서 자주 사용되는 배열을 쉽게 생성하기 위해서이다.

  • np.zeros(shape): 주어진 형태(shape)의 배열을 생성하고 모든 요소를 0으로 초기화한다.
  • np.ones(shape): 주어진 형태의 배열을 생성하고 모든 요소를 1로 초기화한다.
  • np.arange(start, stop, step): 주어진 범위의 값을 가지는 1차원 배열을 생성한다.
  • np.linspace(start, stop, num): 주어진 범위의 값을 균등하게 나눈 1차원 배열을 생성한다.
  • np.eye(n): n x n 단위 행렬(identity matrix)을 생성한다.
  • np.random.rand(shape): 주어진 형태의 배열을 생성하고, 요소를 0과 1 사이의 균등 분포에서 무작위로 초기화한다.
  • np.random.randn(shape): 주어진 형태의 배열을 생성하고, 요소를 표준 정규 분포에서 무작위로 초기화한다.
  • np.random.randint(low, high, size): 주어진 범위의 정수를 가지는 배열을 생성한다.

17.1.2 차원(dimension), 형태(shape), 크기(size)

위에서 만든 d1a, d2a, d3a는 각각 1차원, 2차원, 3차원 배열이다. 이 배열들은 모두 ndarray 클래스의 인스턴스(instance)이며, 모두 정수로 구성되어 있다.

  • ndarray의 차원(dimension)은 배열의 구조를 말하는데, .ndim 속성은 배열의 차원 수를 반환한다.
d1a.ndim # 차원 수 
1
 d2a.ndim # 2개의 차원
2
d3a.ndim # 3개의 차원
3
  • ndarray의 형태(shape)는 배열의 각 차원의 크기를 나타내는 튜플(tuple)이다. .shape 속성은 배열의 형태를 튜플로 반환한다.
d1a.shape # 각 차원의 크기
(3,)

(3, )은 하나의 요소를 가진 튜플이어서 1차원 배열임을 의미하고, 그 크기가 3임을 의미한다.

d2a.shape
(2, 3)

(2, 3)은 두 개의 요소를 가진 튜플이어서 2차원 배열임을 의미하고, 1차원의 크기는2, 2차원의 크기는 3임을 의미한다.

d3a.shape
(2, 2, 2)

(2, 2, 2)는 세 개의 요소를 가진 튜플이어서 3차원 배열임을 의미하고, 1차원의 크기가 2, 2차원의 크기가 2, 3차원의 크기가 2임을 의미한다.

  • ndarray의 크기(size)는 배열의 전체 요소 수를 나타낸다. .size 속성은 배열의 크기를 반환한다.
d1a.size # 배열의 전체 요소 수, 3개
3
d2a.size # 2차원 배열의 전체 요소 수, 6개
6
d3a.size # 3차원 배열의 전체 요소 수, 8개 
8

17.1.3 데이터 타입(dtype)

ndarray의 데이터 타입(dtype)은 배열의 요소들이 어떤 자료형을 가지는지를 나타낸다. 하나의 배열은 동일한 데이터 타입을 가져야 한다. 데이터 타입은 .dtype 속성을 통해 확인할 수 있다.

d1a.dtype # 데이터 타입, int64
dtype('int64')
d2a.dtype # 2차원 배열의 데이터 타입, int64
dtype('int64')
d3a.dtype # 3차원 배열의 데이터 타입, int64
dtype('int64')

다음은 부동 소수점 수(실수)로 구성된 배열을 만둘어 보자.

d1b = np.array([1.0, 2.0, 3.0])  # 1차원 배열
d1b
array([1., 2., 3.])
att = ["ndim", "shape", "size", "dtype"]
for a in att:
    print(f"d1b.{a} = {getattr(d1b, a)}")
d1b.ndim = 1
d1b.shape = (3,)
d1b.size = 3
d1b.dtype = float64

1차원 배열이고, 3개의 요소를 가지며, 데이터 타입은 float64이다.

intfloat 다음에 오는 것은 하나의 요소가 차지하는 비트(bit) 수이다. int64는 64비트 정수, float64는 64비트 부동 소수점 수를 의미한다.

nd.array() 함수는 주어진 데이터에서 타입을 자동으로 추론하여 배열을 만들다. 만약 명시적으로 데이터 타입을 지정하고 싶다면 dtype 매개변수를 사용할 수 있다. 예를 들어, 다음과 같이 int64 타입으로 지정할 수 있다. 참고로 dtype 인자는 numpy의 데이터 타입 객체를 사용한다. 즉 np.int64, np.float64 등을 사용한다.

# float를 주었으나 int로 바뀜
d2b = np.array([[1.0, 2.0, 3.0], 
                [4.0, 5.0, 6.0]], dtype=np.int64)  
d2b
array([[1, 2, 3],
       [4, 5, 6]])
att = ["ndim", "shape", "size", "dtype"]
for a in att:
    print(f"d2b.{a} = {getattr(d2b, a)}")
d2b.ndim = 2
d2b.shape = (2, 3)
d2b.size = 6
d2b.dtype = int64

넘파이는 다양한 데이터 타입을 지원하며, 기본적으로 다음과 같은 데이터 타입이 있다:

  • int: 정수형 데이터 타입, 예: int32, int64
  • float: 부동 소수점 수형 데이터 타입, 예: float32, float64
  • bool: 불리언 데이터 타입, True 또는 False
  • str: 문자열 데이터 타입, 예: str_, unicode_
  • object: 파이썬 객체를 저장할 수 있는 데이터 타입
  • datetime64: 날짜와 시간을 저장할 수 있는 데이터 타입
  • timedelta64: 시간 간격을 저장할 수 있는 데이터 타입

17.2 동일한 데이터 타입을 요소로 가질 때의 장점

넘파이 배열은 동일한 데이터 타입을 가진 요소들로 구성되어 있기 때문에, 컴퓨터 입장에서 보면 메모리를 효율적으로 사용할 수 있고 연산 속도를 높인다.

예를 들어 파이썬 리스트는 서로 다른 데이터 타입을 가질 수 있는데, 한 리스트가 저장하는 것을 어떤 값들에 대한 레퍼런스(reference)로, 즉 메모리의 주소를 저장하는 방식으로 구현되어 있다. 따라서 리스트를 가지고 계산할 때는 다시 값들을 찾아서 연산을 수행하기 때문에 속도가 느리다.

넘파이는 배열의 모든 요소가 동일한 데이터 타입을 가지고 있으며, 또한 저장될 때 메모리 상에 일렬로 연속적으로 저장된다. 그래서 컴퓨터는 다음 값이 어디에서 있는지 쉽게 알 수 있다. 예를 들어 int64 타입의 배열이 있다고 가정하면, 첫 번째 요소가 0번지에 저장되어 있다면, 두 번째 요소는 64비트(8 바이트) 뒤에 저장되어 있다. 그 다음은 그 다음 64비트 위에 저장되어 있다. 따라서 컴퓨터는 다음 요소의 위치를 쉽게 계산할 수 있다.

17.3 뱀 큐브(snake cube)와 넘파이 배열

넘파이 배열은 메모리에서 연속적으로 저장된다. 그런데 1차원 배열을 쉽게 이해할 수 있지만, 2차원, 3차원 배열은 어떻게 저장될까? 이것을 이해하는 데 뱀 큐브가 도움이 될 수 있다. 뱀 큐브는 쭉 이어진 조각들을 적절히 움직여서 여러 가지 형태를 만들 수 있는 퍼즐이다.

조각들이 이어진 것은 컴퓨터 메모리에서 배열이 저장되는 모습을 연상시킨다. 똑같은 1차원 배열을 요리조리 움직여 여러 가지 형태를 만드는 것은, 1차원 배열을 가지고 여러 가지 2차원, 3차원, 그 이상의 배열을 만들어 내는 것과 유사하다.

그림 17.2: 왼쪽은 뱀 큐브가 1차원적으로 배열된 모습을 오른쪽은 뱀 큐브로 만든 형태들을 보여준다.

17.3.1 보폭(strides)과 기저(base) 배열

넘파이 배열을 배열하는 방법은 C (C-style)와 F (Fortran-style) 두 가지가 있는데, 디폴트는 C 스타일이다. F 스타일은 설명하지 않는다. C 스타일을 행 우선(row-major) 방식이라고도 한다. 즉, 행으로 먼저 채우고 다음 열을 채우는 방식이다. 다음 넘파이 배열을 보자.

d2a
array([[1, 2, 3],
       [4, 5, 6]])

위 배열을 행으로 풀어보면 다음과 같다. .flatten() 메서드를 사용하면 배열을 1차원으로 평탄화(flatten)할 수 있다.

d2a.flatten()
array([1, 2, 3, 4, 5, 6])

이렇게 메모리에서 나열된 값으로 구성된 배열을 원래의 d2a 배열로 되돌리려면 어떤 정보가 필요할까? 넘파이는 이 정보를 strides라는 속성으로 제공한다. strides는 각 차원에서 다음 요소로 이동하기 위해 필요한 바이트 수를 나타낸다.

d2a.strides
(24, 8)

이 값을 이해해 보자.

  • 8은 한 칸 넘어가는 데 필요한 보폭으로, 64비트 정수형 데이터 타입이기 때문에 8바이트를 의미로 8 바이트이다.
  • 24는 다음 행으로 넘어가는 데 필요한 보폭으로, 3개의 요소가 있는 행이므로 8 바이트 x 3 = 24바이트이다.
그림 17.3: Nature에 소개된 넘파이 Artcle에서 인용1

이제 .reshape() 메서드를 사용하여 넘파이를 만들거나 넘파이 형태를 바꾸는 방법을 보자. 다음은 24개의 요소를 가지고 여러 형태의 배열을 만드는 예시이다. 곱해서 24가 되면 될 것이다.

arr = np.arange(24)  # 0부터 23까지의 정수로 구성된 1차원 배열
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])
arr.reshape((8, 3)) # 8 X 3배열의 형태
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]])
arr.reshape((2, 3, 4))  # 2x3x4 형태로 재구성
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]]])
arr.reshape((3, 2, 4))  # 3x2x4 형태로 재구성   
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]]])

이와 같이 선형으로 나열된 배열을 가지고 다양한 형태의 배열을 만들 수 있고, 내부에서 strides 속성을 바꿈으로써 하나를 가지고 다양한 형태를 만들 수 있다. 위 여러 경우들이 모두 같은 메모리에 존재하는 배열을 바탕으로 하는데, 이 바탕의 되는 배열을 “기저(base) 배열”이라고 하고, base 속성으로 확인할 수 있다.

arr.reshape((3, 2, 4)).base
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])
그림 17.4: 위 3개의 넘파이 배열을 모두 메모리에 존재하는 base에서 파생된 배열이다. 이것은 나중에 view 개념을 이해하는 데도 중요하다.

base 속성은 배열이 어떤 기저(base) 배열에서 파생되었는지를 나타낸다. 다시 간단한 예로 같은 메모리를 공유하는지 이해해 보자.

a = np.array([1, 2, 3, 4, 5, 6])
b = a.reshape((2, 3))  # 2x3 형태로 재구성
c = a.reshape((3, 2))  # 3x2 형태로 재구성
print(a)
print("---")
print(b)
print("---")
print(c)
[1 2 3 4 5 6]
---
[[1 2 3]
 [4 5 6]]
---
[[1 2]
 [3 4]
 [5 6]]

a, b, c는 모두 같은 메모리를 공유한다. 즉, bca의 기저(base) 배열에서 파생된 배열이다. 이를 확인해 보자.

print(b.base is a)  
print(c.base is a)  
True
True

그렇기 때문에 어떤 배열을 바꾸면 다른 배열도 바뀐다. 예를 들어 a의 첫 번째 요소를 바꾸면 bc도 바뀐다. 다음 코드로 확인해 보자.

a[0] = 10
a
array([10,  2,  3,  4,  5,  6])
b
array([[10,  2,  3],
       [ 4,  5,  6]])
c
array([[10,  2],
       [ 3,  4],
       [ 5,  6]])

17.4 벡터화 연산(vectorized operation)

넘파이의 가장 큰 장점 중 하나는 벡터화 연산(vectorized operation)을 지원한다는 것이다. R 언어의 벡터(vector)와 유사한 개념으로, 배열 단위로 연산을 수행할 수 있어 반복문 없이도 빠른 계산이 가능하다.

예를 들어, 다음과 같이 두 개의 배열을 더할 수 있다.

a = np.array([1, 2, 3])
b = np.array([4, 5, 6])
c = a + b  # 배열 단위로 더하기
c
array([5, 7, 9])

계산된 결과를 보면 대응하는 요소끼리 연산이 수행되는 것을 알 수 있다.

또한, 배열에 스칼라 값을 더하는 것도 가능하다. 이 경우 스칼라 값이 배열의 모든 요소에 더해진다.

# a의 모든 요소에 10을 더하기
d = a + 10  # 스칼라 값을 더하기
d
array([11, 12, 13])

파이썬 리스트(list)인 경우에는 for 반복문을 사용하거나 list comprehension을 사용해야 했었다.

넘파이를 직접 다룰 경우는 흔하지 않을 것이기 때문에 자세히 설명하지는 않는다. 판더스(pandas)을 사용할 때 축(axis)라는 개념이 나올 것이기 때문에 축은 이해하고 넘어갈 필요가 있다.

17.5 축(axis) 이해하기

넘파이 배열의 형태는 .shape 속성으로 확인할 수 있다. 이 형태는 배열의 각 차원의 크기를 나타내는 튜플이다. 예를 들어, 2차원 배열의 형태는 (행의 수, 열의 수)로 표현된다. 이런 튜플이 주어질때, 축(axis)이라는 것은 각 차원을 나타내는 인덱스(index)이다. 넘파이에서는 축을 0부터 시작하는 인덱스로 표현한다.

  • (행의 수, 열의 수) 형태의 2차원 배열에서: 행의 수 부분은 축 0(axis 0), 열의 수 부분은 축 1(axis 1)로 표현된다.
  • (2, 3, 5) 형태의 3차원 배열에서: 2 부분은 축 0(axis 0), 3 부분은 축 1(axis 1), 5 부분은 축 2(axis 2)로 표현된다.

3차원까지는 머릿속에서 그리기가 어렵지 않다. (2, 3) 형태의 2차원 배열을 생각해 보자. 이 배열은 2개의 행과 3개의 열을 가진다.

a1 = np.array([[1, 2, 3], 
              [4, 5, 6],
              [7, 8, 9],
              [10, 11, 12]])  # 2X3 형태의 배열
a1
array([[ 1,  2,  3],
       [ 4,  5,  6],
       [ 7,  8,  9],
       [10, 11, 12]])

이 경우에는 axis 0은 행을 나타내고, axis 1은 열을 나타낸다.

그림 17.5: 행은 axis 0, 열을 axis 1

(2, 3, 5) 형태의 배열을 생각해 보자. 넘파이에서 배열을 생각할 때는 끝에서부터 시작하는 것이 좋다. 3 X 5 행렬이 2개가 쌓인 것으로 생각하면 된다.

a2 = np.arange(1, 31).reshape((2, 3, 5))  # 2X3x5 형태의 배열
a2
array([[[ 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]]])
그림 17.6: 3x5 배열이 겹쳐 있는 형태

4차원으로 가면 그림으로 그리기가 어렵다. 4차원 이상의 배열에 대해서는 웹 검색 등으로 통해서 별도로 공부하길 권한다.

17.5.1 축(axis)을 이용한 연산

앞에서 만든 a1, a2 배열을 가지고 축(axis)을 이용한 연산을 해보자. 축(axis)을 이용한 연산은 배열의 특정 축을 기준으로 연산을 수행하는 것을 의미한다. 해당 축으로 따라가면서(along the axis) 연산을 수행한다고 생각하면 좋다.

a1
array([[ 1,  2,  3],
       [ 4,  5,  6],
       [ 7,  8,  9],
       [10, 11, 12]])
# axis 0을 따라 합계 계산
a1.sum(axis=0)
array([22, 26, 30])
# axis 1을 따라 합계 계산
a1.sum(axis=1)
array([ 6, 15, 24, 33])
a2
array([[[ 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]]])
# axis 0을 따라 합계 계산
a2.sum(axis=0)
array([[17, 19, 21, 23, 25],
       [27, 29, 31, 33, 35],
       [37, 39, 41, 43, 45]])
# axis 1을 따라 합계 계산
a2.sum(axis=1)
array([[18, 21, 24, 27, 30],
       [63, 66, 69, 72, 75]])
# axis 2를 따라 합계 계산
a2.sum(axis=2)
array([[ 15,  40,  65],
       [ 90, 115, 140]])

17.6 인덱싱과 슬라이싱

넘파이 배열은 파이썬의 기본 리스트(list)와 유사하게 인덱싱(indexing)과 슬라이싱(slicing)을 지원한다.

a1
array([[ 1,  2,  3],
       [ 4,  5,  6],
       [ 7,  8,  9],
       [10, 11, 12]])
# 첫 번째 행(row) 선택
a1[0]
array([1, 2, 3])
# 첫 번째 열(column) 선택
a1[:, 0]  # 모든 행에서 첫 번째 열 선택
array([ 1,  4,  7, 10])

넘파이 배열을 직접 인덱싱해야 하는 경우는 그렇게 많지 않을 것이고, 대부분 판더스(pandas)와 같은 고수준 라이브러리를 통해서 인덱싱을 할 것이기 때문에 자세히 설명하지 않는다.

17.7 뷰(view)와 복사(copy)의 개념

넘파이 배열은 뷰(view)와 복사(copy)를 지원한다. 뷰는 메모리에 있는 원본 배열의 데이터를 공유하는 새로운 배열을 생성하는 것이고, 복사는 원본 배열의 데이터를 복사하여 새로운 배열을 생성하는 것이다.

앞에서 base 속성을 사용하여 기저(base) 배열을 확인하는 방법을 소개했다. 그림 17.4을 다시 보자.

위 3개의 배열은 모두 아래 배열에서 파생된 것이다. 이와 같은 뷰(views)라고 한다.

위 3개의 배열은 모두 아래 배열에서 파생된 것이다. 이와 같은 뷰(views)라고 한다.

이와 같이 뷰를 사용하면 원본 배열의 데이터를 공유하기 때문에 메모리를 절약할 수 있다. 하지만 주의할 점은 뷰의 값을 바꾸면 원본도 바뀐다는 사실이다.

기본 인덱싱 역시 뷰를 생성한다. 다음과 같이 a 배열에서 슬라이싱을 통해 b 배열을 만들 때, b 배열을 a 배열의 뷰가 된다. 즉, b 배열은 a 배열의 데이터를 공유한다.

a = np.array([1, 2, 3, 4, 5, 6])
b = a[1:3]

뷰는 데이터를 공유하기 때문에, b 배열의 값을 변경하면 a 배열에도 영향을 미친다. 예를 들어, 다음과 같이 b 배열의 값을 변경하면 a 배열도 변경된다.

b[0] = 10
a
array([ 1, 10,  3,  4,  5,  6])

어떤 배열이 다른 배열의 뷰인지를 확인하려면 base 속성을 사용한다. 만약 base 속성이 None이 아니라면, 해당 배열은 다른 배열의 뷰임을 의미한다.

b.base is a  # True, b는 a의 뷰
True

복사(copy)는 원본 배열의 데이터를 복사하여 새로운 배열을 생성하는 것이다. 이 경우, 원본 배열과 복사된 배열은 서로 독립적이다. 즉, 하나의 배열을 변경해도 다른 배열에는 영향을 미치지 않는다.

앞에서 다차원 배열을 평탄화(flatten)하는 flatten() 메서드를 사용하여 배열을 평탄화할 수 있다고 했다. 비슷한 함수로 ravel() 메서드가 있다. 이 두 메서드는 배열을 1차원으로 평탄화하는 데 사용되지만, flatten()은 항상 복사를 수행하고, ravel()은 가능한 경우 뷰를 반환한다. 즉, ravel()은 원본 배열의 데이터를 공유하는 뷰를 반환할 수 있다.

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

r = a.ravel()
f = a.flatten()
r
array([1, 2, 3, 4])
f   
array([1, 2, 3, 4])

r, f는 모두 모양과 값은 같지만 r은 뷰(view)이고 f는 복사(copy)이다. 이를 확인해 보자.

r.base 
array([[1, 2],
       [3, 4]])
f.base # None

17.8 배열의 모양 변경(reshape 등)

baseviews의 관계를 이해했다면, 배열의 모양을 변경하는 여러 메서드들도 쉽게 이해할 수 있을 것이다. 넘파이 패키지에는 배열을 모양을 바꾸는 다양한 메서드가 있다. 넘파이는 이렇게 모양이 바뀐다고 해도 가급적이면 뷰(view)를 반환하여 메모리를 절약하려고 시도하고, 그게 안 되면 복사(copy)를 반환한다.

.strides 속성은 변경하여 메모리 값을 바꾸지 않고도 배열의 인덱스 값을 바꾸는 방법으로 메모리를 절약할 수 있다.

  • reshape() 메서드는 배열의 모양을 변경하는 가장 일반적인 방법이다. 이 메서드는 새로운 형태를 지정하여 배열을 재구성한다. 만약 새로운 형태가 원본 배열의 요소 수와 일치하지 않으면 오류가 발생한다.

    a = np.array([[1, 2, 3], [4, 5, 6]])
    b = a.reshape((3, 2))# 3x2 형태로 재구성
    b
    array([[1, 2],
           [3, 4],
           [5, 6]])
    b.base 
    array([[1, 2, 3],
           [4, 5, 6]])

    b 배열은 a 배열의 뷰(view)이다. b 배열의 basea 배열을 가리키고 있다.

    b.base is a 
    True
  • np.resize() 메서드(클래스 메서드이다)는 배열의 모양을 변경하는 또 다른 방법이다. reshape()은 가급적 뷰를 반환하려고 하지만, resize()는 새로운 배열을 반환한다.

    a = np.array([[1, 2, 3], [4, 5, 6]])
    b = np.resize(a, (2, 3))# 3x2 형태로 재구성
    b
    array([[1, 2, 3],
           [4, 5, 6]])
    b.base 
    array([1, 2, 3, 4, 5, 6])
    b.base is a # False, b는 a의 뷰가 아니다.
    False
    b.base is a.base
    False

reshape()np.resize()의 차이를 이해하기 위해서는 다음 그림을 보자.

그림 17.7: arr.reshap()np.resize()의 차이
  • reshape()은 원본 배열의 형태를 변경하는 것이고, 가능한 경우 뷰(view)를 반환한다.

  • np.resize()는 새로운 배열을 생성하고, 원본 배열의 데이터를 복사하여 새로운 배열을 반환한다. 따라서 np.resize()는 원본 배열의 데이터를 공유하지 않는다. 배열을 값을 복사하여 그 값들을 가지고 새로운 배열을 만든다.

  • flatten(), ravel() 메서드는 배열을 1차원으로 평탄화하는 데 사용된다. flatten()은 항상 복사를 수행하고, ravel()은 가능한 경우 뷰를 반환한다.

  • sqeeze() 메서드는 배열에서 크기가 1인 차원을 제거하는 데 사용된다(불필요한 차원을 제거한다). 예를 들어, (1, 3, 1) 형태의 배열을 (3,) 형태로 변환할 수 있다. axis를 지정하여 제거할 축을 선택할 수도 있고, 지정하지 않으면 크기가 1인 모든 축을 제거한다.

    a = np.array([[[0], [1], [2]]])
    a.shape
    (1, 3, 1)

    축을 지정하지 않으면 크기가 1인 모든 축을 제거한다.

    a.squeeze() # (3, ) 형태로 변환
    array([0, 1, 2])

    축을 지정하면 해당 축만 제거한다. 예를 들어, axis=0을 지정하면 첫 번째 축이 제거된다.

    a.squeeze(axis=0) # (3, 1) 형태로 변환
    array([[0],
           [1],
           [2]])
  • np.transpose(), arr.transpose(), arr.T 메서드는 배열의 축을 전치(transpose)하는 데 사용된다. 즉, 행과 열을 바꾸는 것이다. 예를 들어, (2, 3) 형태의 배열을 (3, 2) 형태로 변환할 수 있다. 머신러닝에는 전치(transpose)된 배열이 자주 사용된다.

    a = np.array([[1, 2, 3], 
                [4, 5, 6]])
    a.shape
    (2, 3)
    a.T # 전치된 배열
    array([[1, 4],
           [2, 5],
           [3, 6]])

    transpose() 메서드는 .strides 속성을 바꿔서 배열의 인덱스 값을 바꾸는 방법으로 작동한다. strides 속성만 바꿔도 전치된 배열을 얻을 수 있어서 매우 효율적이다.

    a.strides
    b = a.T
    b.strides
    (8, 24)

    3차원 이상의 배열에서도 축을 전치할 수 있다. 이 경우, axes 매개변수를 사용하여 전치할 축의 순서를 지정할 수 있다. 예를 들어, (2, 3, 4) 형태의 배열을 (4, 3, 2) 형태로 변환하려면 다음과 같이 할 수 있다.

    a = np.arange(24).reshape((2, 3, 4))# 2x3x4 형태의 배열
    a.shape
    (2, 3, 4)
    a.transpose((2, 1, 0))# 축을 전치하여 (4, 3, 2) 형태로 변환
    array([[[ 0, 12],
            [ 4, 16],
            [ 8, 20]],
    
           [[ 1, 13],
            [ 5, 17],
            [ 9, 21]],
    
           [[ 2, 14],
            [ 6, 18],
            [10, 22]],
    
           [[ 3, 15],
            [ 7, 19],
            [11, 23]]])

    a.transpose((2, 1, 0))a 배열의 축을 재배열하여 새로운 배열을 생성한다. 여기서 (2, 1, 0)은 새로운 축의 순서를 나타낸다. 즉, 첫 번째 축(0, size 2)은 세 번째 축(2)으로, 두 번째 축(1, size 3)은 두 번째 축(1)으로, 세 번째 축(2, size 4)은 첫 번째 축(0)으로 이동한다. 그래서 모양이 (4, 3, 2)가 된다.

17.9 정리

넘파이 배열은 다차원 배열을 효율적으로 처리할 수 있는 강력한 도구이고, 파이썬 데이터 과학에서 핵심 엔진 역할을 한다. 판더스(pandas)와 같은 고수준 라이브러리도 넘파이를 기반으로 하여 데이터를 처리한다. PyTorch, TensorFlow와 같은 머신러닝 프레임워크도 넘파이 배열과 거의 유사한 구조를 가지고 있어서, 넘파이를 이해하면 머신러닝 프레임워크를 이해하는 데도 도움이 된다.


  1. https://www.nature.com/articles/s41586-020-2649-2↩︎