지금까지 NumPy의 기본적인 구성 요소와 사용법을 살펴보았습니다. 이번 섹션부터는 NumPy가 파이썬 데이터 과학 생태계에서 왜 그토록 중요한 위치를 차지하는지 알아보겠습니다. 바로 NumPy가 배열 연산을 최적화할 수 있는 쉽고 유연한 인터페이스를 제공하기 때문입니다.
NumPy 배열 연산은 어떻게 사용하느냐에 따라 속도가 천차만별입니다. 연산 속도를 극적으로 높이는 핵심은 바로 ‘벡터화(vectorized)’ 작업을 사용하는 것이며, 이는 주로 NumPy의 범용 함수(ufuncs)를 통해 구현됩니다. 이번 장에서는 배열 요소에 대한 반복적인 계산을 훨씬 효율적으로 만들어주는 ufunc의 필요성을 설명하고, NumPy에서 자주 쓰이는 유용한 산술 ufunc들을 소개하겠습니다.
반복문의 성능 한계
파이썬의 기본 구현체인 CPython은 일부 작업을 매우 느리게 처리합니다. 이는 파이썬의 동적이고 해석(interpreted)되는 언어적 특성 때문입니다. 데이터 타입이 유연하다 보니, C나 포트란처럼 연산 과정을 효율적인 기계어로 미리 컴파일해 두기가 어렵습니다. 이러한 약점을 보완하기 위해 파이썬 코드를 실행 시점에 컴파일하는 PyPy 프로젝트, 파이썬 코드를 C 코드로 변환하는 Cython 프로젝트, 코드 조각을 빠른 LLVM 바이트코드로 변환하는 Numba 프로젝트 등 다양한 시도가 있어왔습니다. 각각 장단점이 있지만, 아직까지는 표준 CPython의 범용성과 인기를 넘어서지는 못한 상태입니다.
파이썬이 유독 느려지는 상황은 주로 작은 작업들이 수없이 반복될 때, 예를 들어 배열의 각 요소를 하나씩 훑으며 연산할 때 발생합니다. 만약 배열에 담긴 각 값의 역수를 계산해야 한다고 가정해 봅시다. 가장 먼저 떠오르는 방식은 다음과 같을 것입니다.
import numpy as nprng = np.random.default_rng(seed=1701)def compute_reciprocals(values): output = np.empty(len(values))for i inrange(len(values)): output[i] =1.0/ values[i]return outputvalues = rng.integers(1, 10, size=5)compute_reciprocals(values)
2.61 s ± 192 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
백만 개의 연산을 처리하고 결과를 저장하는 데 몇 초나 걸립니다! 최신 스마트폰의 처리 속도가 기가플롭스(GFLOPS, 초당 수십억 번의 부동 소수점 연산) 단위로 측정되는 것을 생각하면, 이는 상상하기 힘들 정도로 느린 속도입니다. 사실 성능을 떨어뜨리는 주범은 연산 자체가 아니라, 반복문이 돌 때마다 CPython이 수행하는 타입 확인과 함수 호출 오버헤드입니다. 역수를 계산할 때마다 파이썬은 먼저 객체의 타입을 확인하고, 해당 타입에 맞는 적절한 함수를 동적으로 찾아내는 과정을 반복합니다. 만약 컴파일된 코드를 사용했다면 이러한 타입 정보가 실행 전에 이미 결정되어 있어, 훨씬 효율적으로 계산을 마칠 수 있었을 것입니다.
범용 함수(Ufuncs) 소개
NumPy는 이러한 정적 타입 기반의 컴파일된 루틴을 편리하게 사용할 수 있도록 ‘벡터화(vectorized)’ 연산 인터페이스를 제공합니다. 간단한 요소별 나눗셈 같은 작업의 경우, 배열 객체에 직접 파이썬 산술 연산자를 사용하기만 하면 벡터화가 적용됩니다. 벡터화 방식은 반복 연산을 NumPy 내부의 컴파일된 레이어로 밀어 넣어 실행 속도를 비약적으로 높여줍니다.
ufunc를 이용한 벡터화 연산은 파이썬 반복문을 사용한 방식보다 거의 항상 효율적이며, 특히 배열이 커질수록 그 차이는 더욱 극명해집니다. 따라서 NumPy 스크립트에서 반복문을 보게 된다면, 이를 벡터화된 표현식으로 바꿀 수 있을지 반드시 고민해 봐야 합니다.
다양한 Ufunc 살펴보기
Ufunc는 하나의 입력을 받는 단항 ufunc(unary ufuncs)와 두 개의 입력을 받는 이진 ufunc(binary ufuncs) 두 가지 형태로 나뉩니다. 각각의 대표적인 예시들을 살펴보겠습니다.
배열 산술 연산
NumPy의 ufunc는 파이썬의 기본 산술 연산자를 그대로 사용할 수 있어 매우 직관적입니다. 더하기, 빼기, 곱하기, 나누기 등 표준 연산자를 모두 쓸 수 있습니다.
x = np.arange(4)print("x =", x)print("x + 5 =", x +5)print("x - 5 =", x -5)print("x * 2 =", x *2)print("x / 2 =", x /2)print("x // 2 =", x //2) # 정수 나눗셈
x = [0 1 2 3]
x + 5 = [5 6 7 8]
x - 5 = [-5 -4 -3 -2]
x * 2 = [0 2 4 6]
x / 2 = [0. 0.5 1. 1.5]
x // 2 = [0 0 1 1]
부호를 바꾸는 단항 연산자, 거듭제곱을 위한 ** 연산자, 나머지를 구하는 % 연산자도 제공합니다.
x가 매우 0에 가까울 때, 이 함수들은 일반적인 np.log나 np.exp를 쓰는 것보다 훨씬 더 정밀한 계산 결과를 보여줍니다.
특수 목적 Ufuncs
NumPy에는 이 밖에도 쌍곡선 삼각 함수, 비트 연산, 비교 연산, 라디안-각도 변환, 반올림 등 수많은 ufunc가 있습니다. NumPy 공식 문서를 훑어보는 것만으로도 흥미로운 기능들을 많이 발견할 수 있을 것입니다.
더욱 전문적인 수학 함수가 필요하다면 scipy.special 서브모듈을 추천합니다. 데이터 분석 중에 만나게 되는 생소한 수학 함수들은 대부분 이 모듈에 이미 구현되어 있을 가능성이 큽니다. 기능이 너무 많아 모두 나열할 수는 없지만, 통계 분석에서 자주 쓰이는 몇 가지 예시를 보시죠.
NumPy와 scipy.special은 이 밖에도 훨씬 방대한 ufunc 라이브러리를 보유하고 있습니다. 상세 문서는 온라인에서 쉽게 찾을 수 있으므로, 필요한 함수가 있다면 구글링을 통해 관련 정보를 찾아보는 습관을 들이는 것이 좋습니다.
Ufunc의 고급 기능
많은 사용자가 기본적인 기능만으로 ufunc를 활용하지만, 알아두면 유용한 고급 기능들도 몇 가지 있습니다.
결과물 저장 위치 지정하기
연산 규모가 클 때는 계산 결과가 저장될 배열을 미리 지정해 두는 것이 효율적일 수 있습니다. 모든 ufunc는 out 인자를 통해 결과값이 들어갈 위치를 설정합니다.
x = np.arange(5)y = np.empty(5)np.multiply(x, 10, out=y)print(y)
[ 0. 10. 20. 30. 40.]
이 기능은 배열 뷰(view)와 함께 사용하면 더욱 강력해집니다. 예를 들어 계산 결과를 기존 배열의 특정 위치(예: 한 칸씩 띄워서)에 바로 기록합니다.
y = np.zeros(10)np.power(2, x, out=y[::2])print(y)
[ 1. 0. 2. 0. 4. 0. 8. 0. 16. 0.]
만약 y[::2] = 2 ** x라고 썼다면, 먼저 2 ** x의 결과를 담을 임시 배열이 생성된 뒤 그 값이 y로 복사되는 두 단계를 거쳤을 것입니다. 작은 배열에서는 큰 차이가 없지만, 대용량 데이터를 다룰 때는 out 인자를 활용해 메모리 낭비를 줄이는 것이 매우 중요합니다.
집계(Aggregates)
이진 ufunc의 경우 객체에서 바로 집계 연산을 수행합니다. 예를 들어 배열을 특정 연산으로 축소(reduce)하고 싶다면 reduce() 메서드를 사용합니다. 이 메서드는 결과값이 하나만 남을 때까지 주어진 연산을 배열 요소들에 반복적으로 적용합니다.
예를 들어 add ufunc에서 reduce를 호출하면 배열 내 모든 요소의 합을 구합니다.
x = np.arange(1, 6)np.add.reduce(x)
15
마찬가지로 multiply ufunc에서 reduce를 쓰면 모든 요소의 곱을 구합니다.
np.multiply.reduce(x)
120
계산 중간 과정을 모두 기록하고 싶다면 accumulate() 메서드를 대신 사용하면 됩니다.
np.add.accumulate(x)
array([ 1, 3, 6, 10, 15])
np.multiply.accumulate(x)
array([ 1, 2, 6, 24, 120])
사실 이러한 합계나 곱셈 연산은 NumPy에서 이미 전용 함수(np.sum, np.prod, np.cumsum, np.cumprod)로 제공하고 있습니다. 이에 대해서는 집계: 최소값, 최대값 그리고 그 밖의 모든 것 섹션에서 자세히 다루겠습니다.
외적(Outer products)
마지막으로 모든 ufunc는 outer() 메서드를 사용해 서로 다른 두 입력값의 모든 조합에 대한 연산 결과를 구합니다. 이를 이용하면 코드 한 줄로 구구단 같은 행렬을 아주 쉽게 만들 수 있습니다.