10P by GN⁺ 1일전 | ★ favorite | 댓글 4개
  • 필자는 NumPy에 대한 불만을 주제로 여러 예시와 함께 문제점을 설명함
  • 간단한 배열 연산은 NumPy로 쉽지만, 차원이 늘어나면 복잡성과 혼란이 급격히 증가함
  • 브로드캐스팅과 고급 인덱싱 등 NumPy의 설계는 명확성과 추상화 측면에서 부족함
  • 명시적으로 축을 지정하는 대신 추측과 시행착오에 의존하는 코드 작성이 필수임
  • 개선된 배열 언어에 대한 아이디어를 제시하며 구체적인 대안을 다음 글에서 소개할 예정임

서론: NumPy에 대한 애증

  • 필자는 오랜 기간 NumPy을 사용해왔지만, 그 한계에 많이 실망했음을 밝힘
  • NumPy는 파이썬에서 배열 연산을 위한 필수적이고 영향력 있는 라이브러리임
  • PyTorch 등의 현대 머신러닝 라이브러리에도 NumPy와 유사한 문제들이 존재함

NumPy의 쉬운 점과 어려운 점

  • 기본적인 선형 방정식 풀이와 같은 간단한 연산은 명확하고 우아한 문법으로 가능함
  • 그러나 배열 차원이 높아지거나 연산이 복잡해지면, for 루프 없이 일괄 처리가 필요해짐
  • 루프를 쓰지 못하는 환경(GPU 연산 등)에서는 특이한 벡터화 문법이나 특별한 함수 호출 방식이 필요함
  • 하지만 이러한 함수들의 정확한 사용법이 모호하고 문서만으로도 명확하게 알기 어려움
  • 실제로 numpy의 linalg.solve 함수는 고차원 배열인 경우 어떻게 써야 올바른지 누구도 확신하기 힘듦

NumPy의 문제점

  • NumPy는 다차원 배열의 일부 또는 특정 축에 연산을 적용하는 데 일관된 이론이 부족함
  • 배열 차원이 2 이하일 때는 명확하지만, 3차원 이상에서는 각 배열마다 연산 대상 축의 지정이 불분명함
  • 명시적으로 차원을 맞추기 위해 None 사용, 브로드캐스팅, np.tensordot 등 복잡한 방법을 강제함
  • 이러한 방식은 실수 유발, 코드 가독성 저하, 버그의 가능성 증가를 초래함

반복문과 명확성

  • 실제로 반복문을 허용한다면 더욱 간결하고 명확한 코드 작성이 가능함
  • 반복문 코드가 덜 세련돼 보일 수 있으나, 명확성 측면에서는 큰 장점이 있음
  • 반면, 배열 차원이 바뀌면 transpose나 축 순서를 일일이 고민해야 하고, 복잡성이 증가함

np.einsum: 예외적으로 좋은 함수

  • np.einsum은 축의 이름을 지정할 수 있는 유연한 도메인 특화 언어를 제공하여 강력함
  • einsum은 연산의 의도가 명확하고 일반화도 뛰어나, 복잡한 축 연산을 명시적으로 구현 가능함
  • 하지만 einsum과 유사한 방식의 연산 지원이 일부 연산에 한정되고, 예를 들어 linalg.solve에는 못 씀

브로드캐스팅의 문제점

  • NumPy의 핵심 트릭인 브로드캐스팅은 차원이 안 맞을 때 자동으로 맞춰주는 기능임
  • 간단한 경우에는 편리하지만, 실제로는 차원을 명확하게 알기 어렵게 만들고, 오류 사례가 많음
  • 브로드캐스팅이 암묵적이라 코드를 읽을 때 매번 연산이 어떻게 작동하는지 확인해야 함

인덱싱의 불명확함

  • NumPy의 고급 인덱싱은 배열 shape 예측이 매우 어렵고 불명확함
  • 다양한 인덱싱 조합에 따라 결과 배열의 shape가 달라지므로, 실제로 다뤄본 경험 없이는 예측이 곤란함
  • 인덱싱 규칙 설명 문서도 길고 복잡하여, 익히는 데 큰 시간 소모를 유발함
  • 단순 인덱싱만 쓰려고 해도 특정 연산에서는 어쩔 수 없이 고급 인덱싱을 사용하게 됨

NumPy 함수 설계의 한계

  • 많은 NumPy 함수들은 특정 배열 shape에만 최적화되어 있음
  • 고차원 배열에는 추가적인 axes 인자, 별도 함수명, 관례를 사용해야 하고, 함수마다 일관성이 없음
  • 추상화와 재사용이 기본인 프로그래밍 원칙에 역행하는 구조임
  • 특정 문제를 해결하는 함수를 써도, 다양한 배열과 축에 재적용하려면 아예 다른 코드로 다시 작성해야 함

실제 예시: self-attention 구현

  • self-attention 구현을 NumPy로 작성할 때, 반복문을 쓰면 명확하나, 벡터화를 강제하면 코드가 복잡해짐
  • 다중 헤드 attention과 같이 고차원 연산이 필요할 때, einsum과 축 변환을 복합적으로 써야 하고 코드가 난해해짐

결론 및 대안

  • 필자는 NumPy가 "다른 배열 언어들보다 나쁜 점이 많지만 그만큼 시장에서 중요해진 유일한 선택지"임을 밝힘
  • NumPy의 여러 문제점(브로드캐스팅, 인덱싱 불명확성, 함수의 비일관성 등)을 극복하기 위해 개선된 배열 언어의 프로토타입을 만들었음을 예고함
  • 구체적인 개선안(새로운 배열 언어 API)은 추후 별도의 글에서 소개할 계획임

Julia가 왜 탄생했는지 이야기 같네요. 라이브러리들을 공부해야 하지만, Numpy의 많은 문제들을 해결해준다는 점에서 정말 매력적인 선택지 같습니다.

numpy 는 vectorization 이거 잘 사용 못하면 성능은 망하죠. 그런거 고려해서 작성하는게 스트레스고 어렵죠.

좀 오래된 파이썬 라이브러리들은 다 비슷한 문제가 있는거 같아요

Hacker News 의견
  • 첫 번째 예시에서 b의 타입만 보고 문서를 보면 읽기 어렵지만, 반환되는 shape에 대한 설명이 있으므로 실제로 b 벡터가 행렬 형태인지(특히 K=1인 경우) 확인해야 함
  • 배열의 차원이 2개가 넘으면 Numpy 배열에 차원 이름을 추가해주는 Xarray를 쓰는 것을 추천함, 차원 맞추기나 transpose 작업 없이 브로드캐스팅/정렬이 자동이라 이런 문제의 대부분이 해결됨, Xarray는 선형대수 측면에서는 NumPy보다 약하지만 쉽게 NumPy로 돌아갈 수 있고 도우미 함수만 만들면 됨, Xarray를 쓰면 3차원 이상의 데이터 다룰 때 생산성이 크게 높아짐임
    • Xarray는 Pandas와 NumPy의 장점을 합친 느낌임, da.sel(x=some_x).isel(t=-1).mean(["y", "z"]) 같은 인덱싱이 쉽고, 차원 이름이 존중되어 브로드캐스팅도 명확함, 여러 CRSs의 지리공간 데이터 처리에 강점이 있음, Arviz와의 활용도 탁월해서 베이지안 분석에서 추가 차원 처리도 쉬움, 여러 배열을 하나의 dataset에 묶어서 공통된 좌표도 공유 가능해 ds.isel(t=-1)처럼 시간 축을 가진 모든 배열에 쉽게 동작시킬 수 있음
    • Xarray 덕분에 초보적인 NumPy 사용이 많이 줄고 훨씬 생산성이 오름
    • Tensorflow, Keras, Pytorch 같은 프레임워크엔 비슷한 게 있는지 궁금함, 예전에 언급한 내용을 어렵게 디버깅했던 기억이 있음
    • 소개 고맙고 꼭 써볼 계획임, array[:, :, None] 같은 문법이 불편했던 건 자신만 그런 줄 알았는데 같은 의견이라 반가움
    • biosignal 분야에선 NeuroPype가 NumPy 위에서 n차원 텐서용 이름 붙은 축 지원과 각 축별로 per-element 데이터(채널명, 위치 등) 저장 가능함
    • NumPy가 예전 Numeric과 Numarray 라이브러리에서 파생되던 시점이 떠오름, Numarray 파가 20년 동안 계속 주장을 이어오다 자금을 받고 Xarray로 이름을 바꿔 이제 NumPy를 이겼다고 상상해봄(물론 대부분 허구임)
  • Julia를 쓰기 시작한 이유 중 하나는 NumPy 문법이 너무 어려웠기 때문임, MATLAB에서 NumPy로 넘어가니 프로그래밍이 더 서툴러져서 수학보다 성능 트릭을 익히는 데 시간을 썼음, Julia에선 벡터화도 루프도 잘 동작해 코드 가독성에만 신경 쓸 수 있음, 이런 경험과 감정을 글에서 그대로 느꼈음, np.linalg.solve 같은 걸 최고로 빠르다고 생각해서 무조건 맞춰 쓰라는 식의 ‘블랙박스’ 접근은 옳지 않다고 봄, 문제 특화 커널을 직접 짜는 게 더 나은 여러 이유도 존재함
    • Julia가 과학 계산용으로 설계된 언어이고 NumPy는 과학 계산용이 아닌 언어 위에 억지로 얹힌 라이브러리라는 점이 원인임, 언젠가 Julia가 승리해서 네트워크 효과 때문에 파이썬을 쓰는 사람들이 해방되길 바람
    • MATLAB도 벡터화 없이 루프 돌리면 Python만큼 느림, Python의 느림이 가장 큰 문제이고 Julia는 분명 장점이 있지만 실제로는 급격하게 한정적인 용도에 밖에 못 씀, Python엔 JIT hack 같은 게 생겼지만 여전히 불완전함, Python 대안이 절실함
    • MATLAB이 정말 다를까? 루프가 느린 것은 변함없고, 가장 빠른 건 '' 연산자처럼 완전히 최적화된 블랙박스임
    • Fortran 최신 버전도 Julia처럼 벡터화와 루프 모두 빠르게 동작하므로 가독성에만 집중 가능함
  • Matlab, Julia 대비 numpy의 불만을 정리하면, 함수마다 축 관련 인자와 네이밍, 벡터화 제공 방식이 제각각이고, 어떤 축에 함수 적용하려면 코드를 완전히 다시 써야 한다는 점임, 프로그래밍의 기본이 추상화인데 NumPy는 이를 어렵게 함, Matlab에선 벡터화 코드는 거의 그대로 돌아가거나 수정이 명확하지만 NumPy는 항상 문서를 뒤져보고 transpose/reshape 등 타입 맞추기가 일관되지 않아 애매함
    • Matlab의 3차원 이상 배열 지원이 너무 약해 오히려 글에서 언급된 문제는 잘 생기지 않음
    • 두 번째 문제는 jax의 vmap을 시도해볼 만함
    • 2x2 배열에 특정 함수 작성 후 3x2x2 배열의 일부에 적용하고 싶다는 건 슬라이스와 squeeze 등으로 가능함, 이 문제 자체가 이해가 안 갈 정도로 애매함
    • reshape로 처리 가능함
  • numpy에서 가장 혼란스러운 점은 어떤 연산이 벡터화되어 동작하나 명확하지 않고, Julia처럼 dot 문법으로 명시할 수 없다는 것임, 반환 타입 관련해서도 다양한 함정이 많음, 예를 들어 poly1d 객체 P를 오른쪽에서 z0로 곱하면 poly1d가 나오지만 왼쪽에서 z0*P 형태로 곱하면 배열만 반환되어 타입 변환이 조용히 일어남, quadratic의 leading coefficient도 P.coef[0]과 P[2] 두 방식이 가능해서 혼란하기 쉬움, 공식적으로 poly1d는 ‘오래된’ API이고 새 코드는 Polynomial 클래스를 권장하지만 실제로는 deprecated 경고도 없음, 이런 타입 변환과 데이터타입의 불일치처럼 라이브러리 곳곳에 지뢰가 있어서 디버깅 악몽임
  • 저자가 지적한 내용에 공감함, Matlab에서 Numpy로 갈 때 불편함이 많았고, 데이터 슬라이싱도 Numpy가 Matlab/Julia보다 더 불편하다고 느낌, 하지만 Matlab의 toolbox 라이선스 비용을 감안하면 Numpy의 단점이 커버됨, 글에서 제시한 문제는 2차원 초과 텐서에서 주로 발생하며 Numpy는 원래 행렬(2D) 기반이니 그 한계가 당연함, Torch 같은 전용 라이브러리가 낫지만 그것도 쉽진 않음, 결국 "NumPy는 다른 어떤 array 언어들보다 약간 더 별로지만 그렇다고 쓸 수 있는 게 별로 없음"이 정답인 느낌임
    • Numpy는 최초부터 N차원 배열을 목표로 numarray의 연장선에 있었으므로 2D에만 머문 것은 아님
  • Python 데이터 사이언스 생태계의 가장 큰 문제는 모든 것이 비표준임, 10여 개의 라이브러리가 4개 언어만큼 각기 다르게 동작하고 그나마 to_numpy() 정도만 통일됨, 결국 문제를 푸는 시간보다 데이터 포맷 변환에 시간이 더 소모됨, Julia도 장점만 있는 건 아니지만 단위 및 불확실성 등 다양한 라이브러리 간 연동이 잘 되고 Python은 항상 보일러플레이트 코드가 많이 필요함
    • array-api 프로젝트가 Python 생태계 전체에서 배열 조작 API를 표준화하려 노력 중임
    • R은 오히려 4개의 클래스 시스템 때문에 더 복잡함
  • 사람들이 왜 sage 대신 numpy를 쓰는지 궁금함
  • 일부 문제는 numpysane와 gnuplotlib을 쓰면 해결됨, 이 조합이 생긴 후 numpy를 모든 작업에 적극 활용함, 없었다면 도저히 못 쓸 수준임
    • numpysane는 결국 파이썬 루프임, 실제 벡터화와는 다름
    • 소개 고맙고, 이런 문제로 종종 투덜거렸기에 간단한 상위 라이브러리가 있단 생각을 못 했음
  • vectorized multi-head attention을 위해 모든 행렬곱을 einsum에 넣고 optimize="optimal"로 matrix chain 곱 알고리즘을 써서 성능을 올려보았음, 실제로 일반 벡터화 구현 대비 2배 정도 빨라지긴 했으나 놀랍게도 루프 방식 순진구현이 더 빠름, 이유가 궁금한 사람은 코드를 참고 바람, einsum 내부의 cache coherency가 더 개선될 여지가 있다고 추정함