python은 표현할 수 있는 숫자에도 크기 제한이 있고, 속도가 매우 느린 편이기에 숫자 처리에는 적합하지 않은 프로그래밍 언어이다. 그러나 유연한 문법, mutable의 개념, 데이터 타입을 사용자가 직접 만들수 있다는 점 등 강점이 많았기 떄문에 python의 속도를 보완하여 숫자 연산을 하는 방향으로 numpy가 개발 되었다.
numpy는 기본적으로 C로 만들어졌다. 우리 눈에는 array가 2차원, 3차원 등 n개의 차원이 있어보이지만, 실제로 내부적으로는 메모리에서 1차원으로 관리한다. 이 때 C의 pointer 개념을 그대로 들고왔는데, pointer의 위치를 옮겨가며 데이터를 뽑아낸다. 아래는 array에서 데이터를 조회할 때 pointer가 얼만큼 움직여야하는지를 나타내는 코드이다.
>>> c = np.arange(24).reshape(4, 6)
>>> c.strides
(24, 4)
pointer를 한 칸 옆으로 움직일 때에는 메모리에서 4 byte 옆으로 이동하고, 한 row를 넘어가기 위해서는 24 byte를 이동해야 한다는 뜻을 가지고 있다.
이 덕분에 numpy는 매우 빠른 연산속도를 가질 수 있게 되었지만, 데이터타입을 numpy 데이터타입으로 바꿔야 하므로 데이터 변환에 리소스가 들어간다. 따라서 작은 데이터셋에서는 데이터타입 변환 때문에 다른 라이브러리에서 느릴 수도 있다.
numpy 객체를 만들 때에는 내부에 __array__가 정의되어 있으면 numpy 객체로 사용이 가능하다. 많은 데이터타입에서 __array__를 보유하고 있지만, 대표적인 예시로 pandas의 Series 객체를 확인해보자.
>>> s = pd.Series([1, 2, 3])
>>> '__array__' in dir(s)
True
>>> np.sum(s)
6
s에는 __array__가 있기 때문에 np.sum()
안에서 사용 가능하다.(np.sum
은 인자로 array를 받는다고 설명에 나와있음)
numpy에서 사용하는 데이터타입은 ndarray이다. 이는 'n dimention array'를 뜻한다. 이 의미만 보더라도 numpy를 통해서 2차원, 3차원 등 다차원 데이터를 다룰 수 있음을 알 수 있다. 프로그래밍(array)과 수학적 개념(1차원, 2차원, ...)을 합쳐버렸다.
ndarray는 아래와 같은 특징을 갖는다.(container 구분 참고)
또 ndarray가 가지는 중요한 특징은 shape이 같은 ndarray끼리는 같은 위치에 있는 요소만 계산할 수 있다.(=element wise) 이를 가능하게 해주는 것이 vectorization이다. 데이터를 vector로 표현해서 엘리먼트의 위치끼리 연산하므로 다른 라이브러리보다 빠른 처리가 가능하다.
numpy는 사용자들이 인스턴스화 방식이 너무 어려워서 조금 더 편하게 데이터를 정의할 수 있게 지원한다.(=팩토리 메소드) 그래서 데이터타입은 ndarray지만 array()
를 통해서도 데이터를 선언할 수 있다.
a = np.ndarray([1, 2, 3]) # 원래는 이렇게 해야 함 -> 인스턴스화
b = np.ndarray([4, 5, 6]) # 이렇게 하는 것도 지원 -> 팩토리 메소드
ndarray는 repr과 str(print 했을 때의 결과가 다르다.
# repr
>>> a
array([1, 2, 3])
# str
>>> print(a)
[1 2 3]
array라는 타입이 뜨는 것이 더 명확하므로 repr 방식으로 사용하는 것이 더 낫다.
ndarray는 언제나 직사각형 형태로 만든다. 아래 예시를 보자.
# 일부러 원소 하나 빼기
>>> b = np.array([[1,2,3], [4, 5]])
>>> b
array([list([1, 2, 3]), list([4, 5])], dtype=object)
ndarray는 dtype과 shape이 가장 중요하다. dtype과 shape이 같아야 연산이 가능하기 때문이다.(coercion과 broadcast를 지원하긴 함)
>>> a = np.arange(3)
>>> a.dtype # 데이터타입 -> default는 int32임
dtype('int32')
>>> a.shape # 행렬 모양 확인
(3,)
>>> a.size # 원소 개수 확인
3
>>> a.ndim # 차원 확인
1
ndarray가 갖는 메소드는 수학적 계산을 위해 그 개수가 매우 많다. 그래서 모든 것을 다 기억할 수 없기 때문에 자체적으로 help 기능을 제공한다.
# 'sum'이란 단어가 들어간 메소드 검색하기
np.lookfor('sum')
# 함수를 설명 검색
np.info(np.cumsum)
수학적 컨셉에 맞춰 ndarray를 만드는 함수를 여럿 지원한다. 그런데 우리는 데이터분석과 모델링이 우선이기 때문에 ndarray를 생성하는 함수는 간단하게만 알아보겠다. (나열하기만 할 테니 필요하면 사용해보기)
# 0행렬 만드는 방법
np.zeros((3, 4))
# 1로 채우기
np.ones((3, 4))
# 단위 행렬 만들기
np.eye(4)
# 하삼각행렬
np.tri(3)
# 원소가 0부터 시작하는 ndarray 만들기 & reshape으로 원하는 형태로 변경
np.arange(12 * 100).reshape(3,4,-1)
python에서 numpy가 갖는 영향은 매우 크기 때문에 numpy만 사용할 수 있는 특별한 연산자가 있다. 바로 @이다. @는 내적 연산(혹은 행렬곱)을 의미하는데, 연산하려는 두 ndarray의 행과 열의 개수를 맞춰줘야 한다.(=shape을 일치시켜줘야함)
a = np.array([1, 2, 3])
b = np.array([1, 2, 3])
# 내적 함수, 내적 메소드, 내적 연산자
np.dot(a, b), a.dot(b), a@b
aa1 = np.array([[1, 2, 3]])
bb1 = np.array([[1, 2, 3]])
# 2차원의 @는 행렬곱을 뜻한다!!!! => 그런데 정사각형 행렬이 아니므로 에러가 난다
aa1@bb1
# @ 연산자를 사용할 때에는 행과 열의 개수를 맞춰줘야 한다!
aa2 = np.arange(4).reshape(2,2)
bb2 = np.arange(4).reshape(2,2)
aa2@bb2
기본적으로 numpy는 원소의 위치를 가지고 연산하는 element wise 연산을 지원한다고 했다. 그런데 이 연산은 어떻게 되는걸까?
>>> a = np.arange(12).reshape(3,4)
>>> a+10
array([[10, 11, 12, 13],
[14, 15, 16, 17],
[18, 19, 20, 21]])
a는 행렬이고, 1은 정수니까 shape이 맞지 않아 계산이 불가해야되는 것 아닐까? 이 때 암묵적으로 shape을 변경시켜주는 기능인 broadcast가 작동한다. 1을 a의 shape에 맞춰 변경해주는 것이다. 풀어서 쓰면 아래와 같다.
# a+10을 풀어서 쓰기 -> a의 shape만큼 공간을 만들고 10으로 모두 채운 후 덧셈 연산
a + np.full(a.shape, 10)
broacast는 행이나 열의 길이가 일치하고, 다른 차원은 원소기 1일 때에도 작동하는데, 1이 있는 방향으로 원소들을 복사한다.
>>> c = np.arange(3).reshape(3, 1)
>>> cc = np.arange(6).reshape(3, 2)
>>> c + cc
array([[0, 1],
[3, 4],
[6, 7]]))
c는 행만 있다. 그런데 덧샘 연산을 하니 행을 복사해 그 옆으로 붙여 연산을 진행한다.(이건 이것 저것 실습 해보면서 이해하는 수 밖에 없다.)
# broadcast의 꽃은 이 문제! 이해하기
>>> x = np.array([[1, 2]])
>>> y = np.array([[1], [2]])
>>> x + y
array([[2, 3],
[3, 4]])
ndarray는 sequence 데이터이기 때문에 indexing이 가능하다. nedarray에서 사용되는 indexing 기법으로는 총 5 + 1가지가 있다.(나머지 1가지는 indexing이라고 하기엔 조금 애매함) indexing을 알아보기 위해 아래와 같은 데이터를 사용하자.
>>> a = np.arange(24).reshape(4, 6)
>>> a
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]])
인덱싱 차원의 구분을 콤마(,)로 구분한다.
>>> a[1, 3]
9
tuple로 indexing 조건을 제시한다. 제시하는 기준은 [(elem1의 1차원 위치, elem2의 1차원 위치), (elem1의 2차원 위치, elem2의 2차원 위치)] 이다.
>>> a[(2, 1), (3, 5)]
array([15, 17])
위의 예시에서는 결국 (2,3)위치의 원소와 (1,5) 위치의 원소를 찾으라는 말이다.
원래 indexing을 하면 차원이 낮아진다. 그런데 이 방법은 차원을 그대로 유지할 수 있는 장점이 있다. 대괄호를 두 번 써주면 된다. 이 방법을 사용하는 이유는 내가 원하는 순서대로 데이터를 뽑을 수 있기 때문이다.
# 1행, 2행을 뽑기
>>> a[[1,2]]
array([[ 6, 7, 8, 9, 10, 11],
[12, 13, 14, 15, 16, 17]])
# 2행, 1행 순으로
>>> a[[2, 1]]
array([[12, 13, 14, 15, 16, 17],
[ 6, 7, 8, 9, 10, 11]])
boolean indexing은 조건을 제시한 후 조건에 True인 결과만 반환한다.
>>> a[a > 5] # -> broadcast 기법 사용됨
array([ 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23])
원래 indexing을 할 때 2차원 데이터는 2차원으로 뽑혀야한다. 그래서 아래 결과는 1차원만 indexing 조건으로 제시해도 2차원으로 데이터를 뽑은 것이다. 따라서 그 뒤에 [:]가 숨겨져있다는 것을 알 수 있다.
>>> a[0]
array([0, 1, 2, 3, 4, 5])
>>> a[0][:] # 뒤에 [:]가 숨겨져있다!
array([0, 1, 2, 3, 4, 5])
# 이 세개가 다 같다!!!!! 모든 열을 지칭하는 [:]는 생략이 된다!
>>> a[0], a[0][:], a[0, :]
(array([0, 1, 2, 3, 4, 5]),
array([0, 1, 2, 3, 4, 5]),
array([0, 1, 2, 3, 4, 5]))
이게 3차원으로 가면 좀 더 복잡해보일 수 있다.
>>> b = np.arange(60).reshape(3, 4, 5)
>>> b[0], b[0][:][:]
(array([[ 0, 1, 2, 3, 4],
[ 5, 6, 7, 8, 9],
[10, 11, 12, 13, 14],
[15, 16, 17, 18, 19]]),
array([[ 0, 1, 2, 3, 4],
[ 5, 6, 7, 8, 9],
[10, 11, 12, 13, 14],
[15, 16, 17, 18, 19]]))
그런데 [:]가 두 번 이상 반복되는 것은 ...
으로 축약할 수 있다!
>>> b[0, ...]
array([[ 0, 1, 2, 3, 4],
[ 5, 6, 7, 8, 9],
[10, 11, 12, 13, 14],
[15, 16, 17, 18, 19]])
>>> b[..., 1]
array([[ 1, 6, 11, 16],
[21, 26, 31, 36],
[41, 46, 51, 56]])
indexing과 비슷하게 사용되지만, indexing은 아닌 기법이 있다. 데이터를 조회할 때 축을 한 개 늘리는 것인데, None
을 사용한다.
>>> a[None]
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]]])
# None은 축을 하나 추가한다는 뜻! 뒤에 :이 생략되어있다.
a.shape, a[None].shape, a[None, :, :].shape
((4, 6), (1, 4, 6), (1, 4, 6))
그런데 None이라고 쓰니 사용자들이 와닿지 않는다. 그래서 numpy에서는 np.newaxis라는 함수를 만들어 명시적으로 새로운 축을 추가하는 방법을 제시했다.
>>> np.newaxis == None
True
따라서 None자리에 np.newaxis을 사용해보자.