Python NumPy 핵심 정리 — 리스트로 버티다 ndarray로 갈아탄 이유

리스트로 버티다가 numpy를 만났다

Python으로 데이터를 다루기 시작하면 처음에는 리스트로 대부분 해결한다. 반복문 돌리고, append 하고, 조건문으로 필터링하고. 소규모 데이터에서는 이걸로 충분하다.

그런데 데이터가 많아지면 이야기가 달라진다. 리스트에 요소마다 연산을 걸면 for문이 돌아가는 시간이 체감될 정도로 느려지더라. numpy의 ndarray를 처음 써봤을 때 “이걸 왜 이제 알았지”라는 생각이 들었다. 벡터 연산이라는 게 이런 거구나 싶었다.

numpy는 고성능 과학계산과 선형대수를 위한 Python 패키지다. 핵심 자료구조인 ndarray는 벡터 연산을 수행하기 때문에, 요소 각각에 연산을 적용하는 속도가 기존의 list나 tuple, set에 비해 빠르다. 머신러닝이나 딥러닝에서 numpy가 기본 중의 기본인 이유가 여기에 있다.

한 가지 알아둘 점은, ndarray는 모든 요소가 동일한 자료형으로 구성된다는 것이다. 리스트처럼 정수와 문자열을 섞어 담을 수 없다. 서로 다른 자료형을 넣으면 numpy가 자동으로 하나의 자료형으로 통일해버린다.

import numpy as np

# 서로 다른 자료형 -- numpy가 문자열로 통일
ar = np.array([['100', 200, 300], [400, 500, 600]])
print(ar)

# dtype을 지정하면 원하는 자료형으로 강제 변환
ar = np.array([['100', 200, 300], [400, 500, 600]], dtype=int)
print(ar)

ndarray의 구조를 파악하는 속성들도 알아두면 디버깅할 때 유용하다.

속성설명
dtype각 요소의 자료형
ndim배열의 차원 수
shape각 차원의 크기를 저장한 정수 튜플
size전체 요소 개수
itemsize요소 하나가 차지하는 메모리 크기
nbytes전체가 차지하는 메모리 크기
ar = np.array([[100, 200, 300], [400, 500, 600]])
print(ar.ndim)   # 2 -- 2차원
print(ar.shape)  # (2, 3) -- 2행 3열

배열을 만드는 여러 가지 방법

ndarray를 생성하는 방법은 꽤 다양한데, 실무에서 자주 쓰는 것 위주로 정리했다.

array() — 기존 데이터로 생성

가장 기본적인 방법이다. 리스트나 튜플 같은 iterator 객체를 넘기면 ndarray로 바꿔준다. 생성할 때 dtype 매개변수로 자료형도 지정할 수 있고, 설정하지 않으면 자료형을 유추해서 생성한다.

arange() — 일정 간격의 숫자 패턴

range()의 numpy 버전이라고 보면 된다. start부터 stop 바로 앞까지 step 간격으로 생성한다. start를 생략하면 0, step을 생략하면 1이다.

ar = np.arange(0, 100, 1)  # 0부터 99까지
print(ar)

linspace() — 균등 분할

start부터 stop까지를 지정한 개수(num)로 균등하게 나눈다. 기본적으로 stop이 포함되고, endpoint=False로 설정하면 마지막 값을 제외한다.

ar = np.linspace(0, 10, num=10)
print(ar)

특수 행렬: zeros, ones, eye

zeros나 ones로 0이나 1로 채워진 배열을 만들 수 있다. eye 함수는 대각선 방향으로 1이 채워진 단위행렬을 생성한다. 직접 만들 일은 드물지만, 머신러닝 라이브러리들이 내부적으로 이런 배열을 많이 사용한다.

# 크기가 3인 단위행렬
ar = np.eye(3)
print(ar)

# k 매개변수로 대각선 위치 조정
ar = np.eye(3, k=1)
print(ar)

# 대각 요소만 뽑아서 1차원 배열 생성
ar = np.diag(ar)
print(ar)

참고로 0이 대부분이고 다른 데이터가 드문 행렬을 희소행렬(Sparse Matrix)이라고 한다. 넷플릭스처럼 고객이 본 영화를 행렬로 만들면, 고객은 거의 모든 영화를 보지 않았을 가능성이 크기 때문에 희소행렬이 된다. 이런 경우 밀집행렬로 만들면 메모리 부담이 크므로, scipy의 sparse.csr_matrix를 이용해서 희소행렬로 변환하면 효율적이다.

인덱싱에서 가장 헷갈리는 것: 참조 vs 복사

numpy 인덱싱에서 처음에 가장 헷갈렸던 부분이 참조와 복사의 차이다. 슬라이싱으로 잘라낸 배열은 원본의 참조(링크)를 전달한다. 원본을 바꾸면 슬라이싱 결과도 같이 바뀐다.

ar = np.array([100, 200, 300, 400])

# 슬라이싱 -- 참조 전달
br = ar[0:2]

# copy() -- 독립 복사
cr = ar[0:2].copy()

# 원본 변경
ar[0] = 10000
print(br)  # [10000, 200] -- 원본 변경에 영향 받음
print(cr)  # [100, 200]   -- 영향 없음

반면 Fancy Indexing(리스트로 인덱싱)은 항상 copy를 생성한다. 이 차이를 모르면 데이터가 의도치 않게 바뀌는 버그를 만날 수 있다.

ar = np.empty((10, 3))

# Fancy Indexing -- copy 생성
br = ar[[0, 1, 3, 5]]

ar[0, 0] = 90
print(br[0, 0])  # 원래 값 유지 -- copy이므로

# 2차원에서 ix_ 함수를 이용한 Fancy Indexing
dr = ar[np.ix_([3, 5], [0, 2])]  # 3,5행의 0,2열 선택
print(dr)

R은 데이터 분석 목적이라 기본적으로 copy인데, Python의 분석 라이브러리(pandas 포함)도 copy가 기본이다. 하지만 numpy 슬라이싱은 참조가 기본이라서, 이 차이를 모르면 데이터가 예상치 못하게 변하는 버그를 겪게 된다. 원본을 바꾸고 싶으면 옵션을 설정해야 하는 pandas와, 참조가 기본인 numpy의 차이를 잘 기억해두자.

boolean indexing도 자주 쓰는 기법이다. 조건을 만족하는 요소만 깔끔하게 뽑아낸다.

ar = np.array([100, 200, 301, 28])
print(ar % 2 == 0)         # [True, True, False, True]
print(ar[ar % 2 == 0])     # [100, 200, 28] -- 짝수만 추출

Broadcast 연산: 차원이 달라도 계산이 된다

numpy를 쓰면서 “이게 되네?”라고 놀랐던 기능 중 하나가 broadcast 연산이다.

배열과 스칼라(단일 값)의 연산은 배열의 모든 요소에 적용된다.

ar = np.array([100, 200, 500])
result = ar - 100
print(result)  # [0, 100, 400]

차원이 다른 배열끼리도, 연산을 하는 배열의 크기가 맞으면 적은 차원의 배열을 큰 차원에 전부 연산해서 큰 차원의 배열로 리턴한다.

ar = np.array([100, 200, 500])       # 1차원, 크기 3
cr = np.arange(0, 6).reshape(2, 3)   # 2차원, 2x3

result = ar + cr
print(result)
# [[100 201 502]
#  [103 204 505]]

다만 차원은 다르더라도 연산하는 배열끼리의 크기는 같아야 한다. 크기가 안 맞으면 에러가 난다.

통계 함수와 axis 옵션

데이터 분석에서 numpy 통계 함수를 안 쓸 수가 없다. sum, mean, median, max, min, var, std 같은 기본 함수들이 있고, 이것들은 pandas의 Series나 DataFrame에서도 그대로 사용 가능하다.

2차원 이상 배열에서 핵심은 axis 옵션이다. axis=0이면 열 단위, axis=1이면 행 단위로 연산한다. 이 개념은 pandas에서도 동일하게 적용되니 확실히 잡아두는 게 좋다.

ar = np.arange(1, 10).reshape(3, 3)
print(ar)
# [[1 2 3]
#  [4 5 6]
#  [7 8 9]]

print(np.sum(ar))          # 45 -- 전체 합계
print(np.sum(ar, axis=0))  # [12 15 18] -- 열 단위 합계
print(np.sum(ar, axis=1))  # [6 15 24] -- 행 단위 합계

None(NaN)이 포함된 데이터를 다룰 때는 nansum, nanprod를 쓰면 None을 0이나 1로 간주해서 계산해준다. None과의 일반 연산은 무조건 None이 되니까, 이걸 모르면 결과가 전부 NaN으로 도배되는 경험을 하게 된다.

그 외에 유용한 함수들을 정리하면:

함수기능
argmin, argmax최솟값/최댓값의 인덱스(위치)
cumsum, cumprod누적합, 누적곱
diff앞 요소와의 차이
unique중복 제거
where(조건, A, B)조건이 True면 A, False면 B에서 추출
ar = np.array([100, 200, 300, 400])
br = np.array([101, 201, 301, 401])
cond = np.array([True, False, False, True])

result = np.where(cond, ar, br)
print(result)  # [100, 201, 301, 400]

선형대수 기초: 행렬곱과 역행렬

데이터 분석이나 머신러닝을 조금이라도 깊이 들어가면 선형대수가 나온다. numpy는 linalg 모듈로 기본적인 선형대수 연산을 지원한다.

행렬의 곱은 행렬의 행과 열의 개수가 다른 행렬의 열과 행 개수와 같아야 성립한다.

import numpy

mat = numpy.array([[1, 2], [3, 4]])

# 역행렬
print(numpy.linalg.inv(mat))

# 행렬과 역행렬의 곱 -- 단위행렬이 나와야 정상
print(numpy.dot(mat, numpy.linalg.inv(mat)))

실수 연산 특성상 결과가 정확히 1.0, 0.0이 아니라 미세한 오차가 나온다. 이건 numpy의 문제가 아니라 부동소수점 연산의 본질적인 한계다.

주요 선형대수 함수 정리:

함수기능
dot행렬 곱
linalg.inv역행렬 (행렬의 곱이 단위행렬인 정방행렬)
linalg.det행렬식
linalg.eig고유값과 고유벡터 (차원 축소에 활용)
diag대각 요소 추출/대각행렬 생성
trace대각 요소의 합

고유값/고유벡터는 차원 축소에서 핵심적으로 사용된다. 상관관계가 있는 컬럼을 합치거나, 영향이 적은 컬럼을 제거해서 변수의 개수를 줄이는 데 활용된다.

정리하면서 느낀 점

numpy를 처음 배울 때는 함수가 너무 많아서 막막한데, 실제로 자주 쓰는 건 그리 많지 않다. ndarray 생성, 인덱싱(특히 참조 vs 복사), broadcast, 기본 통계 함수 — 이 네 가지만 확실히 잡아도 pandas로 넘어갈 때 훨씬 수월하다.

numpy는 pandas, sklearn, tensorflow, pytorch 같은 상위 라이브러리의 토대다. 래핑이 올라갈수록 편리하고 빠르게 쓸 수 있지만, 포기해야 하는 부분(변화 대응, 세밀한 제어 등)도 있다. 래핑을 벗기면 어렵지만 원리를 알고 변화에 둔감하지 않게 된다. 기초를 탄탄히 잡아두면 어떤 라이브러리를 만나도 금방 적응할 수 있다.


이 글은 2021년 Python 학습 노트를 기반으로 재구성한 글입니다.

Similar Posts