[Data Processing, Trouble Shooting] Pandas & Polars
Kaggle 대회 Jane Street Real-Time Market Data Forecasting 에 참여하면서 대용량 데이터를 다루는데 Pandas 보다 더 빠르면서 메모리 효율이 좋은 Polars 를 다루게 되었다.
이 기회에 Pandas 와 Polars 의 차이를 정리하고, Pandas 및 Polars 의 문법을 비교하며 정리하려 한다. Pandas 까지 다루는 이유는 아직 Polars 의 경우 scikit-learn
과 호환이 잘 안되기 때문에 Pandas 로 변환해줘야 하기 때문이다.
Parquet
- Pandas 와 Polars 에 대해 정리하기 전에, 위 대회에서 사용되는 데이터 포맷인
parquet
에 대해 먼저 간단히 알아보자. - Pandas 보다 Polars 가 대용량 데이터에서 더 성능이 좋은 것처럼, Parquet 이 CSV 보다 메모리 효율적이다. 실제로 Parquet 은 작은 파일 사이즈와 낮은 I/O 사용을 목적으로 개발되었다.
- 파케이(parquet)란 데이터를 저장하는 방식 중 하나로 하둡 Ecosystem 에서 많이 사용되는 파일 포맷이다.
- 하둡 Ecosystem 은 하둡 프레임워크를 이루는 다양한 프로젝트들의 모임을 의미한다. 빅데이터 문제를 효율적으로 해결하기 위해 만들어진 서브 프로젝트들의 집합으로, 수집, 처리, 저장 등의 프로젝트가 포함된다.
- 이 때, 빅데이터를 처리하기 위해서는 많은 시간과 비용이 들어가기 때문에 빠르게 읽고, 저장에 있어 압축률이 좋아야 한다.
- 이러한 특징을 가진 파일 포맷으로는 Parquet, ORC, Avro(에이브로)가 있다.
- Parquet 와 ORC 는 열 기반으로 데이터를 저장하는 반면 Avro 는 행 기반 형식으로 데이터를 저장한다.
- 데이터베이스에는 행 기반으로 저장하는 방식(MySQL 등)과 열 기반(BigQuery 등)으로 저장하는 방식이 있다.
- 열 기반 데이터베이스는 읽기가 많은 분석 워크로드에 최적화 되어있다.
- 행 기반 데이터베이스는 쓰기가 많은 트랜잭션 워크로드에 최적화 되어있다.
- 이처럼 데이터를 저장하고 공유하기 위한 파일 포맷에도 행 기반의 CSV 가 있는 반면 열 기반의 Parquet 이 있다.
-
Parquet 는 프로그래밍 언어, 데이터 모델 혹은 데이터 처리 엔진과는 독립적으로 column 기반으로 데이터를 효율적으로 저장하여 처리 성능을 비약적으로 향상시킬 수 있다.
- 위 그림의 맨 왼쪽을 보면, 열 기반으로 저장되는 포맷에 대해 확인할 수 있다. 이렇게 열 기반으로 저장하게 되면 압축률이 좋다.
- 열(Column)기반의 저장이 압축률이 좋은 이유는, 같은 column 에는 유사한 데이터가 연속된 구조로 저장되어 있고 데이터가 균일해지기 때문이다.
- 특히 같은 문자열의 반복은 매우 작게 압축할 수 있다. 데이터의 종류에 따라 다르지만, column 기반 데이터베이스는 압축되지 않은 row 기반 데이터베이스와 비교하면 1/10 이하로 압축 가능하다.
- 데이터 분석에서는 종종 일부 column 만 집계 대상이 되기 때문에, 이렇게 column 기반으로 압축하면 필요한 feature(column) 만을 빠르게 읽고 집계할 수 있다.
- 따라서 column 을 기반으로 데이터를 처리하면 row 를 기반으로 압축했을 때에 비해 데이터의 압축률이 더 높고, 필요한 column 의 데이터만 읽어서 처리하는 것이 가능하기 때문에 데이터 처리에 들어가는 자원을 절약할 수 있다.
- 그렇다면 column 기반으로 저장하는 것이 좋다라고 생각할 수 있는데, MySQL 과 같이 row 기반 저장 방식의 데이터베이스는 매일 발생하는 대량의 트랜잭션을 지연 없이 처리하기 위해 데이터 추가를 효율적으로 할 수 있도록 한다. 즉 새로운 레코드를 추가할 경우 끝부분에 추가되기 때문에 고속으로 쓰기가 가능하다.
- 이처럼 Parquet 는 하둡 에코시스템 안에서 언제든지 사용 가능한 데이터 저장 포맷으로, Spark SQL 은 자동으로 기존의 데이터 스키마를 유지하는 Parquet 파일의 읽기와 쓰기를 지원한다.
- 유사한 파일 형식의 ORC 도 Parquet 와 마찬가지로 압축률이 높고 스키마를 가지고 있으며 처리속도가 빠르지만 ORC 는 Hive 에 최적화된 형식이고, Parquet 은 Spark 에 최적화된 형식이라고 볼 수 있다.
- 또한 Parquet 파일을 쓸 때 모든 column 은 호환성을 위해 자동으로
null
을 허용하도록 변경된다.
장점
- Parquet 는 높은 압축률을 가지고, column 단위로 구성하면 데이터가 더 균일하므로 압축률이 높아진다. 즉 Parquet 는 열 단위로 압축을 수행하며 이로써 파일의 크기도 작아지기 때문에 용량을 덜 차지하게 된다.
- 또한 데이터 유형별로 유연한 압축 옵션과 확장 가능한 인코딩 스키마를 지원하도록 구촉되어 있다. column 에 동일한 데이터 타입이 저장되기 때문에 column 별로 적합한(데이터형에 유리한) 인코딩을 사용할 수 있다. 따라서 정수 및 문자열 데이터를 압축하는데 서로 다른 인코딩을 사용한다.
-
빅데이터를 분석하거나 쿼리를 실행할 때, 전체 column 중에서 일부 필요한 column 을 선택해서 가져오는 형식이므로 선택되지 않은 column 의 데이터에서는 I/O 가 발생하지 않게 된다. 이로써 스캔되는 데이터 양이 훨씬 적어진다.
- 위 그림에서 왼쪽은 파일 형식에 따른 I/O 시 메모리 사용량과 파일의 크기를 비교한 파이콘 발표 자료다.
- 또한 오른쪽 그림은 Pandas 를 기준으로 측정된 지표이긴 하지만, 파일을 저장할 때 CSV 보다는 Parquet, HDF5 등의 형식을 사용하면 시간과 메모리를 절약할 수 있다.
- 이처럼 Parquet 은 복잡한 데이터를 대량으로 다루는데 최적화되어 있다. 따라서 CSV 와 비교했을 때 스토리지를 줄여주고 쿼리 런타임도 대폭 줄어들게 된다.
- 해당 블로그에서는 Parquet 의 파일 구조를 설명하고 있다. 참고해보자.
- 참고로 Parquet 는 오픈소스 Apache 하둡 생태계의 일부로서 활발하게 개발 진행 중이며 사용자 및 개발자 커뮤니티에 의해 지속적으로 개선 및 유지 관리되고 있다. 또한 데이터를 오픈 포맷으로 저장하면 공급업체에 묶이지 않고 유연성 높일 수 있으며 많은 최신 고성능 DB 에서 사용하는 독점 파일 포맷과 비교 가능하다.
- 즉, 특정 DB 공급업체에 묶이지 않고도 동일한 데이터 레이크 아키텍처 내에서 AWS Athena, AWS Redshift, Qubole 과 같은 다양한 쿼리 엔진을 사용 가능하다.
Polars 란?
- Polars 는 Pandas 와 같은 기존의 데이터 처리 라이브러리가 가진 성능적 한계를 극복하기 위해 탄생했다.
Rust
라는 프로그래밍 언어로 작성된 Polars 는 멀티스레딩과 병렬 처리를 지원하여, 대규모 데이터셋을 보다 빠르고 효율적으로 처리할 수 있도록 설계되었다.- 주요 목적은 데이터 과학자와 엔지니어들이 대용량 데이터를 다룰 때 직면하는 성능 문제를 해결하고, 메모리 사용을 최적화하며, 직관적이고 간결한 API를 통해 사용성을 높이는 것이다.
- 단일 머신의 자원을 최대한 활용할 수 있도록 병렬 처리와 벡터화 연산을 통해 column 기반 처리를 최적화하고, 캐싱도 효율적으로 관리하여 Vectorized Query Engine 라이브러리라고 불리기도 한다.
-
오픈소스라면 아무리 성능이 좋고 사용성이 뛰어나도 사용자가 적다면 금방 없어질 수도 있고 버전업이 더딜 수도 있다. 그러나 Polars 의 Github star 수를 보면 빠른 성장세를 보여주고 있다.
Pandas 의 단점
- Pandas 는 NumPy 와 Matplotlib 기반으로 이루어진 Data 분석용 라이브러리다. 자체적으로 Series 와 DataFrame 2 가지의 형식을 제공하면서 다양한 통계적인 연산 등을 제공한다.
- NumPy 기반의 혼합 Python Package 이며 일부 연산은 최적화를 위한 Cython 등으로 구현되어 있다.
- Pandas 는 정형 데이터를 다루는데 최적화 되어있다. 현재도 데이터 처리 라이브러리 중 가장 많이 사용되는 것은 Pandas 다.
-
Pandas 의 단점은 “느리다”는 것이다. Pandas 가 NumPy를 기반으로 만들어졌다고 했는데 실제 NumPy 와 같은 연산을 했을 경우 다음과 같이 차이가 많이 난다.
- 그래서 왜 Pandas 는 느린걸까? 여러 곳에서 교차적으로 조사한 결과 다음과 같은 공통적인 이야기가 존재한다.
- NumPy array 에 추가적으로 Label, row, columns, index 와 그 외의 Metadata 가 필요하다.
- 기능이 많기 때문에 이러한 기능을 사용하기 위한 무결성과 유연성을 위하여 추가적인 작업, 변환이 필요하다.
- Python 으로 코드가 작성되어있다(GIL). 따라서 기본적으로 Single 코어에서 동작하고 인터프리터 언어로서 태생적으로 느린 코드 한계가 존재한다.
- 이러한 이유로 인하여 다른 언어 기반으로 만들어진 모듈에 비해 속도가 느릴 수 밖에 없는 한계가 존재한다.
- H20.ai 에서는 다양한 데이터 처리 라이브러리를 비교했다. 해당 링크를 보면 알 수 있지만, Pandas 의 경우 속도가 느리거나 Out of Memory 오류가 자주 생긴다.
Polars 의 장점
- Polars User Guide 에서 말하는 Polars 가 추구하는 Goal 은 다음과 같다.
- 시스템에서 사용할 수 있는 모든 코어 사용
- 쿼리 최적화를 통한 불필요한 작업 / 메모리 사용 줄이기
- 사용 가능한 RAM 보다 더 큰 Dataset 다루기
- 일관성 있고 예측 가능한 API
- 엄격한 Schema (쿼리 실행 전 데이터 유형을 알아야 한다.)
- 위의 Goal 들을 실현하기 위해 Polars 는 Pandas 와 아래 섹션들에 해당하는 특징을 가진다. 이것이 Polars 의 장점이 된다.
1. Rust 기반
- Polars 의 이름을 보면 Pola + rs(rust)일 정도로 Rust 로 구현됐다는 것을 강조하고 있다.
- Rust 의 소유권 모델 덕분에 메모리 관리에 대한 오버헤드가 없으며, 안전한 동시성과 병렬 처리가 가능하다.
- 메모리 캐싱과 재사용성 또한 높다. 이러한 특징이 데이터 처리 성능을 극대화하는 데에 크게 기여한다.
- 이처럼 Polars 의 코어는 Rust 로 구현되어 있으며 이를 사용하기 위한 인터페이스로 Python, R, Javascript 를 지원한다고 이해할 수 있다. 인터페이스 역할을 하는 언어는 앞으로도 계속 추가 예정이라고 한다.
Rust
에 대해서는 추후에 다뤄보자.
2. 멀티코어 병렬 처리 & Apache Arrow 기반
Pandas
는 기본적으로 Python 을 기반으로 단일 스레드에서 동작한다. 그래서 여러 코어를 가진 컴퓨터라 할지라도 한 번에 한 가지만 처리한다. 이는 작은 데이터셋에서는 괜찮지만, 데이터가 많아지면 처리가 느려진다.Polars
는 여기서 한 단계 더 나아가 멀티코어 병렬 처리를 지원한다. 여러 코어를 사용해 한 번에 여러 작업을 처리할 수 있어 데이터가 클수록 성능 차이가 극명하게 나타난다.- 추가적으로 최적화된 알고리즘을 이용하여 중복 복사를 최소화하고, 효율적인 캐시 메모리 사용, 병렬 시 경합을 최소화시켜 더욱 빠르게 동작할 수 있게 한다.
- 또한 Polars 는 프로그래밍 언어 독립적인 컬럼 기반(Columnar) 메모리 포맷인
Apache Arrow
모델을 사용하여 메모리 상에서 column 구조로 데이터를 정의하고, 이를 기반으로 벡터화(vectorized) 연산과 SIMD(Single Instruction Multiple Data)를 사용한 CPU 최적화를 하여 성능을 높였다.- 이를 통해 zero-copy 데이터 공유가 가능하고 직렬화/역직렬화 효율이 매우 높아서 여러 코어나 프로세스가 작업할 때 데이터 교환 비용을 줄일 수 있다.
- 참고로 SIMD 란, 병렬 컴퓨팅의 한 종류로 하나의 명령어로 여러 개의 데이터를 동시에 계산하는 방식(GPU 등과 같은 벡터 프로세서에서 많이 사용되는 방식)이다.
- 이와 관련해서는 Arrow Columnar Format 을 확인해보자.
- 최근 Pandas(v2.0 이후)나 Dask, Ray 등의 오픈소스에서 Arrow 를 채택하고 있고 이를 위해
PyArrow
라는 구현체를 사용하는데, Polars 에서는 이것도 Rust 로 개발된 구현체를 사용하여 내부에서 사용한다. - Arrow 를 사용하는 경우, ArrowTable 형태로 타 오픈소스와의 호환성을 어느 정도 유지하면서 데이터를 주고받을 수 있다(ex. Ray Dataset 과 Polars DataFrame).
3. Lazy execution 지원
- Pandas 는 데이터에 무언가를 하라고 명령하면 바로바로 실행하는 ‘즉시 실행(Eager execution)‘ 방식을 사용한다. 데이터 필터링이나 집계 등은 실행할 때마다 처리되어 결과를 바로 보여준다.
- 이는 큰 데이터에서 매번 실행할 때마다 시간이 많이 걸리고, 메모리도 그만큼 소모된다.
- Polars 는 ‘지연 실행(Lazy execution)‘ 방식을 사용한다. 즉, 여러 명령을 한 번에 묶어 최적화한 다음, 마지막에 한꺼번에 처리하는 방식이다.
- 쉽게 말해 Polars 의 Lazy API 는 즉시 연산을 수행하지 않고, Query Plan 이라고 하는 연산 계획을 수립한 후 최적의 시점에 연산을 실행하는 지연 평가(Lazy evaluation) 방식이다.
- 이를 통해 모든 작업을 최적화된 방식으로 한 번에 처리하기 때문에 속도가 훨씬 빠르고, 불필요한 중간 연산을 줄일 수 있다.
- 또한 필터링과 pushdown 등의 최적화 기술을 사용하여 필요한 데이터만 읽어와서 처리하기 때문에 메모리 소모와 연산 복잡도를 줄여 준다.
- 참고로 pushdown 이란, 쿼리 연산을 스토리지 계층으로 한 단계 내려서 데이터 로드 비용을 최소화하는 기술이다. pushdown 에 대한 정책과 구현은 솔루션이나 오픈소스마다 다르지만, 해당 개념은 최적화를 위해 대부분 채택하고 있다.
- Polars 에서는 아래 세 가지 pushdown 을 수행하여 최적화를 수행한다.
- Predicate pushdown(조건자 푸시다운): 필터를 적용해 요청한 데이터만 읽는 방식(filter pushdown 이라고도 함)
- Project pushdown: 필요한 열만 읽는 방식
- Slice pushdown: 필요한 슬라이스(일부 행)만 읽는 방식
- 자세한 최적화 정보는 Polars user guide 의 Optimizations 를 참고하자.
- 이러한 Lazy API 의 특징으로 Polars 는 Pandas 와 유사한 Series, DataFrame 과 더불어
LazyFrame
을 지원한다. 이는 즉각적으로 연산을 하는 게 아니라 쿼리 플랜만 담아두고 있다가 값이 필요할 때, 즉 구체화(materialize)할 때collect()
함수를 호출하여 연산하는 방식이다. - 정리하면, Polars 는 필요한 부분만 연산하는 Lazy 방식을 지원함으로써 필요한 부분은 빠르고, 필요하지 않은 부분은 무시하는 방식의 연산을 가능하게 해준다. 이를 통하여 메모리 효율적이면서, 빠르게 작업이 가능하다.
- 실제로 우아한 형제들에서 약 1.8GB 의 parquet 파일(70,765,275 rows * 26 columns)로 진행한 실험의 결과는 아래와 같다.
- Eager API: 메모리 소모(peak) 17.84GB, 실행 시간 9.07초
- Lazy API: 메모리 소모(peak) 5.9GB, 실행 시간 1.009초
- 같은 Polars 로 처리하지만 어떤 방식으로 처리했는지에 따라 엄청난 차이가 나는 것을 확인할 수 있다. 이와 같이 큰 차이가 나는 것은 Lazy 연산 시에 내부적으로 여러 최적화를 하기 때문이다.
4. 메모리 효율성 및 IO 기능
- Pandas 는 데이터가 메모리에 전부 올라가 있기 때문에, 데이터 크기가 커질수록 메모리 사용량도 크게 증가한다. 이로 인해 Out of Memory(OOM) 에러를 만날 때가 많다.
- 반면 Polars 는 필요할 때만 메모리를 사용하는 매우 효율적인 구조를 가지고 있다. 특히 Polars 의 Lazy API 를 사용하면, 필요하지 않은 중간 결과들을 메모리에 올리지 않고 최적화된 방식으로 처리해 메모리 사용량을 최소화할 수 있다.
- 로컬 파일, 클라우드 스토리지, 데이터베이스 등 다양한 데이터 스토리지 계층을 지원하고 성능 또한 매우 우수하다.
- 기본적으로 CSV, JSON, Parquet, Avro 등 다양한 포맷에 대한 읽기/쓰기를 지원하고 파일을 읽을 때도 별표(asterisk, *)와 같은 Globs Pattern 을 활용해서 여러 파일을 읽어올 수 있어서 매우 편리하다.
- 실무에서는 데이터베이스나 Trino 와 같은 쿼리 엔진에 쿼리를 제출하고 그 결과로
polars.DataFrame
을 반환하는read_database()
기능도 자주 사용하고 있다. read_*
와 같은 함수 대신scan_*
이라는 함수를 사용하게 되면 Lazy API 를 위한LazyFrame
으로 반환되어 이를 활용하여 바로 Lazy 연산을 수행할 수 있다. 이렇게 되면 즉각적으로 모든 데이터를 메모리에 올리는 것이 아닌 최적화를 한 후 실제 연산을 수행하여 더 효율적으로 처리할 수 있다.- 실제로 우아한 형제들에서 710MB parquet file (7,373,092 rows * 64 columns) 로 읽기를 테스트해 본 IO 성능 실험은 아래와 같다.
- Polars 의
read_parquet
: 4s 554ms - Pandas 의
read_parquet
: 33s 916ms
- Polars 의
5. Out of core 방식(Streaming API)
- Polars 에서는
streaming
이라는 기능을 활용해서 out of core 방식으로 연산을 수행할 수 있다. - 여기서 out of core 방식이란 external memory 알고리즘이라고도 하는데, 메모리에 담기 너무 큰 데이터를 처리할 때 디스크나 네트워크 등을 통해 일정 단위로 데이터를 가져와서 처리하는 방식을 말한다.
-
즉, 한 번에 모든 데이터를 메모리에 올리는 것이 아니라 일정 단위로 데이터를 자르고 그 조각을 가져와서 처리하고 이를 반복하는 것이다.
- 이 streaming 기능의 사용법은 매우 간단하다. 바로 Lazy API 를 사용할 때
collect()
함수 내에서streaming=True
옵션만 추가하면 된다. 즉, 일반적인 Lazy 연산으로 개발하고 마지막 구체화 시점에 streaming 옵션만 추가해 주면 된다. - 마찬가지로 우아한 형제들에서 용량의 총합이 8.8GB 인 다수의 parquet 파일을 가지고 테스트를 진행했다. 해당 테스트용 파일을 가지고 filtering 과 mean 집계 연산 등을 실행하면 Pandas 로는 OOM(out of memory) 으로 불가능하다. 또한 Polars 기본 연산으로도 수십 기가의 메모리를 소모한다.
- 그러나 streaming 기능을 이용하면 메모리 소모는 3.71 GB, 실행 시간은 6.835 초가 걸린다.
- Polars 의 발표 자료에서는 이 기능을 활용하면 200GB 정도의 데이터도 개인 노트북 환경에서 처리할 수 있다고 한다. 따라서 잘 활용한다면 매우 유용할 것이다.
- 추가적으로 Polars 의 성능에 대한 자료는 많지만 최근 업데이트된 벤치마킹 자료를 참고하면 도움이 될 것이다.
6. 사용 편의성
- Pandas 의 장점 중 하나는 매우 직관적인 API 다. 누구나 금방 익힐 수 있고, 데이터를 빠르게 처리할 수 있다. 그러나 큰 데이터에서 성능이 부족한 것은 분명한 단점이다.
- Polars 는 Pandas 와 매우 유사한 API 를 제공하면서도, 더 빠르고 강력한 성능을 보여준다. 또한 사용성 측면에서의 장점도 많다.
- Polars 의 Lazy API 의 사용
- 위에서 다룬 Lazy API 는 공식문서를 참고하면 금방 익숙해질 수 있으며, Pandas 보다 훨씬 더 복잡한 작업을 효율적으로 처리할 수 있다.
- SQL 과 비슷한 구조
- Polars 는 SQL 과 비슷한 표현식과 직관적인 문법을 가지고 있기 때문에 배우기 쉽고 가독성도 좋은 편이다.
- column 선택 시 편리함
- 데이터를 분석하거나 feature 엔지니어링을 할 때 원하는 column 을 선택해야 하는 경우가 많다.
- 그럴 때 column 이름을 리스트로 받아오거나 type 을 받아와서 필터링하기도 하는데 Polars 에서는 타입에 따라 선택할 수도 있고 정규식을 활용할 수도 있다.
- 학습이나 추론을 할 때 인코딩된 column 을 굉장히 자주 다루는데 아래 코드에서처럼 정규식으로 처리하면 매우 편리하다.
- 또한 테이블 생성을 위해 데이터 타입을 일괄 변경하거나, 정수형 혹은 숫자형 column 을 일괄 선택하는 기능도 사용하기 편하다.
- 시계열 연산
- Polars 는 시계열 지원이 좋은 편이다. 시간과 관련된 여러 타입을 지원하며, 리샘플링이나 시간 윈도우 기반 그룹화 등의 기능도 제공한다.
- 중간 보간법으로 Upsample 을 쉽게 할 수 있고,
group_by_dynamic
함수로 고정 윈도우 연산이나 롤링 윈도우 연산이 가능하다. - 그리고 일치하는 키가 존재하지 않을 때 가장 가까운 값을 기준으로 join 하는 기능인
asof join
도 지원해서 실무에서 자주 사용하고 있다. - 자세한 코드 예제는 아래에서 Pandas 와 비교하는 부분을 참고하자.
- SQL 직접 사용
- 최근 DuckDB 와 같은 도구에서 로컬 및 클라우드 스토리지 내 파일이나 DataFrame 객체에 직접 SQL 쿼리를 할 수 있도록 지원하는데, Polars 역시 비슷한 기능을 제공한다.
- Python 기반으로 연산하는게 익숙치 않다면 SQL 을 사용할 수도 있고, 이 기능을 사용하면 쿼리 최적화가 수행되기 때문에 성능을 높일 수도 있다.
실무 적용 예시
- 우아한 형제들에서는 이러한 Polars 를 실무에 도입하여 배달예상시간 학습 파이프라인 개선, 사용자 정의 함수 적용 개선, 준실시간 추론 파이프라인을 개선했다.
- 학습 파이프라인 개선
- 학습 파이프라인에 포함되어 있는 수천만 행의 학습 데이터(parquet 파일)를 가져와서 읽고 이를 전처리하는 부분을 Polars 로 변경한 결과, 기존에 사용하던 Dask 대비 실행시간은 80% 수준으로 단축하고 메모리는 40% 수준으로 감소시켰다고 한다.
- UDF 적용 개선
- Pandas 에서는
apply
를 활용해 사용자 정의 함수(User Defined Function, UDF)를 행 단위로 수행할 수 있다. 물론 Polars 도 같은 기능을 지원한다. - 사용자 정의 함수를 적용하여 변환하는 부분을 Polars 로 변경하고 나서 성능 개선을 체감할 수 있었다고 한다. 물론 Polars 도 Python UDF 를 적용할 때는 자체 지원 함수를 사용할 때보다 성능이 현저하게 떨어지게 되고 이에 대한 경고 또한 공식 문서에 포함되어 있다.
- 왜냐하면 UDF 실행 때문에 Rust 코어의 장점이나 벡터화 연산의 장점을 많이 잃어버리기 때문이다. 그러나 Pandas 대비 훌륭한 실행시간 개선을 보여준다.
- 데이터 전처리를 할 때 위/경도 데이터를 가져와서 지구 공간 인덱스인 H3 index 로 변환하는 부분이 있는데, 이 부분을 Polars 코드로 변경했더니 기존 Pandas 로 24.3초 정도 걸리던 연산이 5.87초로 단축되었다. 거의 5배 가량 빨라진 것이다.
- 중요하고 많이 사용하는 UDF 의 경우 함수 자체를 Rust 로 개발하고 이를 Python 코드에서 호출해서 사용하면 성능을 더 높일 수 있다.
- Pandas 에서는
- 준실시간 추론 파이프라인 개선
- 배달의 민족에서는 고객에게 더 정확한 시간을 안내하기 위해 준실시간 추론을 하게 된다. 배달예상시간의 경우, 사용자가 지면을 조회하면서 여러 가게에 대한 시간을 확인하기 때문에 모든 케이스를 고려하여 미리 featyre 를 생성해 놓고 준실시간 추론 시점에 feature 를 가져와 메모리에 올리고 간단한 처리를 하여 모델에 넣게 된다. 그리고 이 모델의 추론 결과를 비즈니스 로직과 전시를 담당하는 쪽에 이벤트로 만들어 전달해야 한다.
- 이 과정에서도 Polars 를 적용하여 눈에 띄는 성능 효율화를 가져올 수 있었다고 한다.
- 구체적으로 수행한 작업은 파일 읽기, group_by 및 aggregation 작업, 이벤트 형태로 변환하기 위한 UDF 적용 작업이었는데, 매번 실행할 때마다 24GB 정도의 메모리를 소모하던 것을 대략 40% 이하로 줄여서 10GB 정도로 감소시켰다. 이를 통해 k8s pod 사이즈를 줄여서 비용 효율을 높일 수 있었다고 한다.
Pandas vs. Polars
- 이제 Pandas 와 Polars 에서 특정 기능들이 어떤 함수와 메서드로 이루어져 있는지 알아보고, 성능을 비교해보도록 하자. Pandas 와 Polars 의 각 버전은 아래와 같다.
polars = "^1.17.1"
pandas = "^2.2.3"
- 비교를 위해 사용할 데이터는 Kaggle 대회 Jane Street Real-Time Market Data Forecasting 에서 제공하는 학습 데이터 중
partition_id=9
를 사용한다. 파일 형식은.parquet
, 파일 크기는 1.64GB, 92 개의 column 과 6,274,576 개의 row 로 이루어져 있다. - 구체적으로 Pandas 와 Polars 의 Eager API(즉시 실행), Lazy API(지연 실행) 를 비교해보자.
IO
- 파일 입출력(IO)에 대해서 Pandas 와 Polars 모두 읽기는
read_
, 쓰기는write_
의 형식을 따른다. - 각 공식문서(pandas, polars)를 보면 CSV, Parquet, JSON, execl 등 파일 포맷에 대한 데이터 읽기/쓰기 기능을 제공한다. 참고로
.pkl
과 같은 Pickle 파일은 Python 의 객체 직렬화 형식이기 때문에 Pandas 에서만 읽을 수 있다.
pandas
- pandas 는
.read_parquet
을 이용하여 parquet 파일을 읽는다.
pandas.read_parquet(path, engine='auto', columns=None, storage_options=None,
use_nullable_dtypes=<no_default>, dtype_backend=<no_default>, filesystem=None, filters=None,
**kwargs)
path
에는file://localhost/path/to/tables
또는s3://bucket/partition_dir
와 같이 URL 형태로도 가능하다. 이 때 필요한 정보들은storage_options
에 주면 된다.engine
에는auto, pyarrow, fastparquet
가 가능하다. 기본값은auto
이며 먼저pyarrow
를 시도하고 이후에fastparquet
을 시도한다. 이를 통해 ArrowTable 형태로 타 오픈소스와의 호환성을 어느 정도 유지하면서 데이터를 주고받을 수 있다(ex. Ray Dataset 과 Polars DataFrame).- 즉 pandas 는 주로 numpy 배열을 데이터 저장 및 처리를 위한 내부 구조로 사용하여 Arrow 를 직접 기반으로 하지 않지만, Arrow 를 활용할 수 있는 I/O 연산과 데이터 교환 기능을 제공하는 것이다.
columns
에는 사용할 column 을 list 로 건네준다. 또한filters
에는 데이터를 읽을 때(column, operation, val)
의 형식으로 조건을 주면 필터링된 데이터를 읽게 된다.- 자세한 옵션은 공식문서를 참고하자.
import pandas as pd
pd_df = pd.read_parquet(path)
# CPU times: user 3.58 s, sys: 3.59 s, total: 7.17 s
# Wall time: 3.43 s
sys.getsizeof(pd_df) # 메모리 확인
# 2214925472
polars
- polars 의 Eager API 도 마찬가지로
.read_parquet
을 이용하여 parquet 파일을 읽는다.
# polars (eager)
polars.read_parquet(source: FileSource, *,
columns: list[int] | list[str] | None = None,
n_rows: int | None = None,
row_index_name: str | None = None,
row_index_offset: int = 0,
parallel: ParallelStrategy = 'auto',
use_statistics: bool = True,
hive_partitioning: bool | None = None,
glob: bool = True,
schema: SchemaDict | None = None,
hive_schema: SchemaDict | None = None,
try_parse_hive_dates: bool = True,
rechunk: bool = False,
low_memory: bool = False,
storage_options: dict[str, Any] | None = None,
credential_provider: CredentialProviderFunction | Literal['auto'] | None = 'auto',
retries: int = 2,
use_pyarrow: bool = False,
pyarrow_options: dict[str, Any] | None = None,
memory_map: bool = True,
include_file_paths: str | None = None,
allow_missing_columns: bool = False) -> DataFrame[source]
- 위 함수를 보면 argument 가 pandas 보다 많다. 이중에 기억하면 좋은 것은
glob=True
로 되어있어 위 polars 의 장점에서 본 것처럼 globs pattern 을 사용해서 파일을 읽어올 수 있다. 자세한 코드는 아래에서 보자. - 또한 pandas 와 마찬가지로 cloud 내에 있는 데이터를 읽어올 수 있으며, 이 때 필요한 정보는
storage_options
에 dict 형태로 넣어주면 된다. - 이 외에 각 컬럼의 data type 을 특정할 수 있는
schema
도 있다. 이에 대해서는 아래 data type 섹션에서 다룰 것이다. 이외에 자세한 argument 들은 공식문서를 참조하자.
import polars as pl
pl_df = pl.read_parquet(path)
# CPU times: user 2.77 s, sys: 1.47 s, total: 4.23 s
# Wall time: 1.22 s
sys.getsizeof(pl_df) # 메모리 확인
# 48
- 위 결과를 보면 pandas 보다 더 빠르게 데이터를 읽어오는 것을 확인할 수 있다. 이는 아래의 내부 구현 방식들에서 비롯된 이유들 때문이다.
- polars 는 Rust 언어로 작성되어 Rust 의 네이티브 성능 덕분에 파일 읽기/쓰기와 같은 IO 작업이 더 빠르게 수행된다. 반면 pandas 는 Python 으로 작성된 고수준 API로, C 와 Cython 확장을 사용하지만 Python 인터프리터의 오버헤드로 인해 IO 속도가 제한적이다.
- 위에서 살펴본 것처럼 polars 는 IO 작업에서 멀티스레딩을 적극 활용하여 CPU 코어를 병렬로 사용할 수 있다. 예를 들어, CSV 나 Parquet 파일을 읽을 때 파일을 여러 청크로 나눠 병렬로 처리한다. 반면에 pandas 는 기본적으로 단일 스레드에서 작업하기 때문에 큰 파일을 처리할 때 성능이 제한된다.
- polars 는 컬럼 지향(columnar) 데이터 처리 방식을 사용하여 파일의 특정 컬럼만 선택적으로 로드하거나, 로드 중에 즉시 최적화된 데이터 타입으로 변환할 수 있다. 이는 메모리 사용을 줄이고 불필요한 데이터를 로드하지 않기 때문에 IO 성능이 더 뛰어나다.
- polars 는 Rust 기반의 빠른 압축/해제 라이브러리를 사용해 GZIP, Parquet, Feather 등 다양한 형식의 파일을 매우 효율적으로 읽고 쓸 수 있다. 물론 pandas 도 압축된 파일들을 읽고 쓸 수 있지만, 특히 Parquet, Arrow 와 같은 컬럼 지향 포맷에 최적화가 잘 되어 있어 Pandas 보다 월등히 빠르다.
- polars 는 Zero-Copy 기술을 사용하여 데이터의 복사를 최소화한다. 예를 들어, Arrow 기반 포맷 파일(Parquet 등)을 읽을 때 데이터 복사를 하지 않고 그대로 사용할 수 있어 성능이 뛰어나다. 반면, pandas 는 데이터를 읽으면서 Python 객체로 변환해야 하기 때문에 오버헤드가 크다.
- 마지막으로 polars 는 Rust 컴파일러가 미리 최적화한 코드를 실행하여 파일 IO 와 관련된 많은 부분에서 CPU 캐시와 SIMD 명령어를 효과적으로 사용한다. 반면 pandas 는 런타임에 Python 해석기와 Cython 코드를 실행하기 때문에 최적화 수준이 제한된다.
- 다음으로 polars 의 Lazy API 인
.scan_parquet
에 대해 알아보자.
# polars (lazy)
polars.scan_parquet(source: FileSource, *,
n_rows: int | None = None,
row_index_name: str | None = None,
row_index_offset: int = 0,
parallel: ParallelStrategy = 'auto',
use_statistics: bool = True,
hive_partitioning: bool | None = None,
glob: bool = True,
schema: SchemaDict | None = None,
hive_schema: SchemaDict | None = None,
try_parse_hive_dates: bool = True,
rechunk: bool = False,
low_memory: bool = False,
cache: bool = True,
storage_options: dict[str, Any] | None = None,
credential_provider: CredentialProviderFunction | Literal['auto'] | None = 'auto',
retries: int = 2,
include_file_paths: str | None = None,
allow_missing_columns: bool = False) -> LazyFrame[source]
- 위 출력값인
polars.LazyFrame
은 즉각적으로 연산을 하는 게 아니라 쿼리 플랜만 담아두고 있다가 값이 필요할 때, 즉 구체화(materialize)할 때collect()
함수를 호출하여 연산하는 방식이다. - 이러한 Eager API 와 Lazy API 의 차이는 실제 연산에 들어갔을 때 눈에 띄게 된다. 여기에
streaming
기능을 사용하면 더 큰 데이터도 개인 랩톱 환경에서 처리할 수 있다.
pl_df_lz = pl.scan_parquet(path)
# CPU times: user 69 μs, sys: 3 μs, total: 72 μs
# Wall time: 75.1 μs
sys.getsizeof(pl_df_lz) # 메모리 확인
# 48
- 다음으로 polars 에서 globs pattern 을 이용하여 파일을 읽어보자. 이를 이용하면 아래 path 에서
part-0.parquet
,part-1.parquet
,part-100.parquet
등을 한 번에 읽어올 수 있다.
pl_df_glob = pl.read_parquet("/Users/baekkwanghyun/Desktop/Bkkhyunn/bkkhyunn.github.io/practice/data/part-*.parquet")
# CPU times: user 2.99 s, sys: 1.26 s, total: 4.25 s
# Wall time: 1.39 s
- 한 가지 기억하면 써먹을 수 있는 것은, polars 는
read_database
라는 함수를 통해query
와uri
를 건네주어 데이터베이스에서 직접 데이터를 가져올 수도 있다. 또한 아래와 같이 cloud storage 에서도 바로 데이터를 읽어올 수 있다.
# Cloud Storage 에서 데이터 읽어오기
df = pl.read_parquet("s3://bucket/*.parquet")
# read from DB
df = pl.read_database_uri(
query="SELECT * FROM foo",
uri="postgresql://username:password@server:port/database"
)
df = pl.read_database(
query="SELECT * FROM test_data",
connection=user_conn,
schema_overrides={"normalised_score": pl.UInt8},
)
- 읽기 이외에 쓰기에서도 pandas 보다 polars 가 더 빠르고 메모리 효율적으로 동작한다. 쓰기의 경우 pandas 에서는
to_확장자
, polars 에서는.write_확장자
형태의 함수를 이용하며, argument 로compression
방식을 주어 gzip 형태로도 저장이 가능하다. - gzip 파일을 읽을 때는 위에서 사용한
read_parquet
또는scan_parquet
(polars Lazy API) 을 사용하면서 path 에gzip
파일을 넣거나, polars 의 경우*.gzip
과 같이 globs pattern 을 쓰면 파일의 압축 형식을 자동으로 처리하여 읽을 수 있다. - 마지막으로 polars 에서는 행 기반 포맷인 CSV 에 대해서도 two-pass 알고리즘을 이용해서 SIMD 연산을 증가시키고 성능을 올렸다고 한다. 또한 클라우드에 있는 데이터에 대한
scan
성능도 올렸다고 하니, polars 의 기술 블로그를 참고해보자.
data type
- pandas 와 polars 뿐 아니라, 데이터 분석이나 ML 에서 데이터 타입은 중요하다. ML 모델이나 DL 모델에 데이터를 입력시키기 위해서는 int 나 float 과 같은 숫자형 데이터여야 하기 때문이다.
- 이 섹션에서 pandas 와 polars 가 다루는 데이터 타입과 데이터 타입의 변환, 생성 등을 알아보고 비교해보자.
pandas
- pandas 공식문서에 따르면, pandas 는 DataFrame 의 Series 나 개별 column 에 대해 Numpy array 와 데이터 타입(dtypes)을 사용한다.
- Numpy 는
float, int, bool, timedelta64[ns], datetime64[ns]
타입을 지원하고, timezone-aware datetime 은 지원하지 않는다. - 아래와 같은 방법으로 pandas 의 데이터타입을 확인할 수 있다.
for dtype in dir(pd):
if dtype.endswith("Dtype"):
print(dtype[:-5], end=", ")
# Arrow, Boolean, Categorical, DatetimeTZ, Float32, Float64, Int16, Int32, Int64,
# Int8, Interval, Period, Sparse, String, UInt16, UInt32, UInt64, UInt8
- 위 공식문서에서 각 데이터 타입 별로 String Aliases 를 통해 데이터 타입을 확인하거나 변경할 수 있다.
- 또한
.dtypes
또는.info()
를 통해 각 column 이 어떤 데이터 타입으로 되어 있는지 확인할 수 있다.
pd_df.dtypes
# date_id int16
# time_id int16
# symbol_id int8
# weight float32
# feature_00 float32
# ...
# responder_4 float32
# responder_5 float32
# responder_6 float32
# responder_7 float32
# responder_8 float32
# Length: 92, dtype: object
.dtypes
에서 맨 마지막 line 에 object 가 나오는 이유는,.dtypes
의 결과가 Series 이고 해당 Series 가 object 데이터 타입을 가진다는 것이다.- 우리가 pandas 를 통해 데이터를 불러올 때 기본적으로 문자가 포함되어 있다면 object 자료형이 된다.
- pandas 에서 자주 보는 데이터 타입이 object 타입과 category 타입이다. 일반적인 문자열을 갖는 column 은 object 로 사용하고, 값이 종류가 제한적이거나 encoding 을 한다면 category 를 사용하게 된다.
- 만약 pandas 에서 DataFrame 을 생성할 때 각 column 의 데이터 타입을 명시해주지 않거나 여러 데이터 타입이 섞여 있다면 자동적으로 object 타입이 된다.
- pandas 에서는 string 을 저장할 때, string 을 포함한 python 객체들을 모두 포함하는
object
데이터 타입과 오직 string 만 나타내는StringDtype
이 있다.- 따라서 일반적으로 문자열은 object 타입이 되고, String 타입으로 사용하기 위해서는 사용자가 별도로 변경해야 한다.
.infer_objects()
은 데이터 타입이 object 인 column 에 대해서 적당한 데이터 타입을 추론한다. 이 함수는 만들어진 DataFrame 에 데이터 타입을 명시하지 않았을 때 편리하게 사용할 수 있다.
- 정수 타입은 기본적으로
int64
이고, 부동소수점 타입은 기본적으로float64
다. 이는 32-bit 나 64-bit 의 운영체제와는 관계 없이 항상 기본값이다. 그러나 numpy 배열은 platform-dependent 하여 32-bit 운영체제에서는int32
가 된다. - 추가적으로 pandas 는 기본적으로 numpy 의 upcasting rule 을 따른다.
- upcasting 은 더 작은 범위의 데이터 타입(ex. int32, float32)이 더 큰 범위의 데이터 타입(ex. float64)으로 자동 변환되는 것을 말한다. 이는 연산 중 데이터 타입의 일관성과 정확성을 유지하기 위해 수행된다.
- 예를 들어 정수(int)와 부동소수점(float) 간 연산은 float 로 변환되고, float32 와 float64 간 연산은 더 정밀한 float64 로 변환되는 것이다.
- 그러나 pandas 고유의 확장된 데이터 타입 때문에 일부 경우에는 다르게 동작할 수 있다. pandas 는 numpy 에 없는 확장 데이터 타입을 제공하며, 이는 upcasting 시 추가적인 동작을 초래할 수 있다. 예를 들어 Nullable Integer (Int32, Int64) 나 Categorical, Datetime with Timezone 이 이에 해당한다.
- 실제로 Nullable Integer 와 부동소수점의 연산은 float64 로 upcasting 된다.
- pandas 에서 데이터 타입을 변경할 때는
.astype()
을 이용한다.- 기본적으로
.astype()
을 통해 변환된 결과는 deep copy 를 반환한다. 만약 데이터 타입이 변경되지 않아도 복사본이 반환된다. 이를 변경하려면copy=False
를 전달하면 된다. - astype 연산이 잘못된 경우에는 exception 이 발생한다.
- 기본적으로
- 이 외에 object 타입의 column 은
to_numeric()
,to_datetime()
,to_timedelta()
로 데이터 타입을 바꿀 수 있다. - 이 때 argument 로
errors=coerce
를 주면 에러를 발생시키는 것이 아니라 문제가 발생하는 데이터 포인트마다pd.NaT
(datetime 이나 timedelta) 나np.nan
(numeric) 으로 변경하고 그대로 데이터 타입을 변환한다.
pd_df["date_id"].astype(np.int32)
pd_df["feature_02"].astype(np.float64)
select_dtypes()
은 인자로 데이터 타입을 주어 해당 데이터 타입을 가진 column 을 출력한다.select_dtypes()
는 두 개의 argument 를 가지는데,include=[ ]
와exclude=[ ]
이다. 각 리스트 안에 포함시킬 데이터 타입이나 제외할 데이터 타입을 넣으면 된다.
pd_df.select_dtypes(include=["object"]) # 6274576 rows × 0 columns
pd_df.select_dtypes(exclude=["object"]) # 6274576 rows × 92 columns
- pandas 의 DataFrame 을 만들 때 아래와 같이 각 column 의 데이터 타입을 미리 지정해줄 수 있다.
d = {'col1': [1, 2], 'col2': [3, 4]}
df = pd.DataFrame(data=d, dtype=np.int8)
df.dtypes
# col1 int8
# col2 int8
# dtype: object
polars
- polars 는 Arrow 포맷과 Rust 기반 최적화를 바탕으로 numpy 및 pandas 와는 다른 접근 방식을 채택했다. polars 는 내부적으로 Arrow 데이터 타입을 기반으로 한다. Arrow 는 컬럼 지향(columnar) 데이터 형식으로, 성능과 메모리 효율성을 극대화하도록 설계된 오픈소스 프로젝트다.
- polars 의 데이터 타입은 Arrow 의 데이터 타입과 1:1로 매핑된다.
- Arrow 는 범용성과 효율성을 위해 설계되었으며, 다양한 프로그래밍 언어와 도구에서 호환 가능하다.
- polars 는 멀티스레딩과 SIMD(Vectorized Operations)를 활용하여 Arrow 데이터를 빠르게 처리한다.
- polars 는 데이터 정렬 방식을 Arrow Columnar Format 을 기반으로 한다. 이를 통해 Arrow 사양을 사용하는 다른 도구(Ray 또는 일부 pandas 등)와 데이터를 주고받을 때 거의 오버헤드 없이 처리할 수 있다.
- polars 공식문서에 따르면, polars 는 아래와 같은 총 18 개의 데이터 타입을 지원한다. 이는
__dir__
을 통해서도 확인할 수 있다.Boolean
: Boolean type that is bit-packed efficiently.Int8, Int16, Int32, and Int64
: varying-precision integer types.UInt8, UInt16, UInt32, and UInt64
: varying-precision unsigned integer types.Float32 and Float64
: varying-precision float types.Decimal
: 128-bit decimal type with optional precision and non-negative scale. This gives you fine-grained control over the precision of your floats.String
: variable length UTF-8 encoded string data, typically human-readable.Binary
: variable length, arbitrary raw binary data.Date
: represents a calendar date.Time
: represents a time of day.Datetime
: represents a calendar date and time of day.Duration
: represents a time duration.Array
: homogeneous arbitrary dimension array with a fixed shape.List
: homogeneous 1D container with variable length.Categorical
: efficient encoding of string data where the categories are inferred at runtime.Enum
: efficient ordered encoding of a set of predetermined string categories.Struct
: composite product type that can store multiple fields.Object
: wraps arbitrary Python objects.Null
: represents null values.
- 위 데이터 타입들은 아래와 같이 분류된다.
- Numeric data type: 부호가 있는 정수(signed integers), 부호가 없는 정수(unsigned integers), 부동소수점 숫자(floating point numbers), 소수(Decimals)
- 중첩 데이터 타입(Nested data types): 리스트(lists), 구조체(structs), 배열(arrays)
- 시간 관련 데이터 타입(Temporal): 날짜(dates), 날짜와 시간(datetimes), 시간(times), 시간 차이(time deltas)
- 기타 데이터 타입(Miscellaneous): 문자열(strings), 이진 데이터(binary data), 불리언(Booleans), 범주형 데이터(categoricals), 열거형 데이터(enums), 객체(objects)
dir(pl.datatypes)
# 'Array', 'Binary', 'Boolean', 'Categorical', 'Date', 'Datetime', 'Decimal', 'Duration', 'Enum',
# 'Float32', 'Float64', 'Int16', 'Int32', 'Int64', 'Int8', 'IntegerType',
# 'List', 'Null', 'Object', 'String', 'Struct', 'Time',
# 'UInt16', 'UInt32', 'UInt64', 'UInt8', 'Utf8'
- 이러한 데이터 타입들에 대한 설명을 읽어보면 세세한 부분에서도 polars 가 효율성을 위해 개발된 것임을 알 수 있다.
- Boolean 타입 (
pl.Boolean
) 은 효율적으로 비트 단위로 압축(bit-packed efficiently)된다.True(1)
나False(0)
를 나타내는데 단 하나의 비트만 필요하며, 1 바이트는 8비트로 구성되므로 polars 는 8 개의 Boolean 값을 저장하는 데 1바이트를 사용한다. - 따라서 polars 의 Series 에 $n$ 개의 Boolean 값이 있으면 polars 는 해당 Series 를 저장하는 데 $n/8$ 바이트를 사용한다.
- 또한 polars 가 밝힌 내용에 따르면, 결측값을 나타내는 polars 의
null
값은 Python 의None
과 같다.
s = pl.Series([1, None, 3, 4, 5, None])
print(s.count()) # 4
print(s.is_null().sum()) # 2
- 이 때 흥미로운 점은
is_null
함수를 사용해 열(column)에서 어떤 값이 결측인지 확인하는 작업은 polars 에서 비용이 들지 않는다. - 이는 polars 가 Series 내에서 결측값 여부를 나타내는 유효성 마스크(validity mask)를 저장하기 때문이다. 이 유효성 마스크는 Boolean 값으로 구성되어 있으며, Series 의 항목 중 어떤 값이 결측인지 표시한다. 유효성 마스크는 Boolean 열과 동일한 방식으로 비트 단위로 압축(bit-packed)되어 저장되기 때문에 메모리 오버헤드가 최소화된다.
- polars 에는 Temporal Data Types 가 있는데, 여기에는
Date, Time, Datetime, Duration
이 속한다. 특징적인 것은 pandas 와는 달리 polars 는 날짜 간의 차이를 나타내는Duration
이 지원된다. 또한 timezone 을 지정해줄 수도 있다.
from datetime import datetime, timedelta, timezone
now = datetime.now()
datetimes = [
now,
now.replace(tzinfo=timezone(timedelta(hours=1))),
now.replace(tzinfo=timezone(timedelta(hours=-3))),
]
s = pl.Series(datetimes)
print(s) # All values are converted to UTC.
# shape: (3,)
# Series: '' [datetime[μs]]
# [
# 2024-11-22 19:14:25.468051
# 2024-11-22 18:14:25.468051
# 2024-11-22 22:14:25.468051
# ]
print(s.dt.convert_time_zone("Europe/Amsterdam"))
# shape: (3,)
# Series: '' [datetime[μs, Europe/Amsterdam]] # <-- new TZ shows in the data type
# [
# 2024-11-25 11:23:55.322912 CET # <-- times are adjusted to new TZ
# 2024-11-25 10:23:55.322912 CET
# 2024-11-25 14:23:55.322912 CET
# ]
bedtime = pl.Series([
datetime(2024, 11, 22, 23, 56),
datetime(2024, 11, 24, 0, 23),
datetime(2024, 11, 24, 23, 37),
])
wake_up = pl.Series([
datetime(2024, 11, 23, 7, 30),
datetime(2024, 11, 24, 7, 30),
datetime(2024, 11, 25, 8, 0),
])
sleep = wake_up - bedtime
print(sleep)
# shape: (3,)
# Series: '' [duration[μs]]
# [
# 7h 34m
# 7h 7m
# 8h 23m
# ]
- Decimal 데이터 타입은 소수점 아래 자릿수를 지정할 수 있다. 그러나 현재(24.11 기준)로서는 불안정한 기능이라고 한다.
dtype=pl.Decimal()
으로 지정해줄 수 있으며, 두 개의 argument 가 들어간다. 첫번째 인수는 각 숫자의 최대 총 자릿수를 지정하며, 이를None
으로 설정하면 polars 가 필요한 값을 추론한다. 두번째 인수로는 소수점 이하 자릿수를 설정한다. - polars 에서 범주형(categorical) 변수는 항상 문자열 데이터를 기반으로 파생된다. polars 에서 범주형 데이터를 처리할 때는 두 가지 유사한 데이터 타입을 제공한다. 바로
Enum
과Categorical
이다.- 각각에 대한 차이는 polars 의 공식문서를 참고하자.
- Enum 데이터 타입은 범주형 데이터를 처리할 때 사용해야 하는 권장 데이터 타입이다. Enum 데이터 타입은 시각적으로 출력될 때 문자열 값과 동일하게 보이지만, 내부적으로는 더 효율적으로 작동한다고 한다.
- 이는 polars 가 해당 Series 에 고정된 문자열 집합만이 유효한 값이라는 사실을 알고 있기 때문에, Enum 타입이 메모리를 더 적게 사용하고, Series 에서의 연산이 일반적으로 더 빠르며, Enum 타입에 포함되지 않는 값을 넣으려고 하면 오류가 발생한다.
- 또한, Enum 데이터 타입을 사용하면 범주(category)를 순서대로 다룰 수 있는 이점도 있다. 즉 학력 수준이라는 변수에서 “고졸” < “대졸” < “석사” < “박사” 과 같은 것이다.
- Categorical 데이터 타입은 Enum 과 달리 유효한 값을 사전에 지정할 필요가 없다. 대신 polars 가 자동으로 이러한 값을 추론한다.
- polars 는 일반적으로, Enum 을 실질적으로 사용할 수 없는 경우에만 Categorical 을 사용하는 것을 권장하고 있다.
- Enum 이든 Categorical 이든 상관없이
.cat
네임스페이스와get_categories
함수로 사용 중인 고유 카테고리 값을 추출할 수 있다.
- 마지막으로 polars 는 Object 데이터 타입을 통해 Series 에 임의의 Python 객체를 저장할 수 있다. Object 데이터 타입은 모든 데이터를 포괄할 수 있는 범용 타입(catch-all type)으로, 매우 일반적이기 때문에 polars 는 이 타입에서 임의의 객체를 처리하는 데 특화된 기능을 제공하지 않는다.
- Python 에서 제공하는 object 타입과 비슷한 방식으로 동작한다. 이를 이용하면 특정 데이터 타입이 필요하지 않을 때 유연하게 데이터를 다룰 수 있다.
- 그러나 Object 타입은 매우 일반적이기 때문에, 가능한 경우 더 구체적인 데이터 타입을 사용하는 것이 바람직하다.
- 부동소수점 Floating point 관련해서 polars 는 일반적으로 IEEE 754 부동소수점 표준을 따르지만 아래와 같은 예외가 있다.
- NaN 비교 규칙: NaN 은 다른 NaN 값과 동일한 값으로 간주된다. 또한 NaN 은 NaN 이 아닌 값보다 항상 크다고 간주된다.
- 0 및 NaN 부호(sign) 처리: 연산 결과에서 0 이나 NaN 의 부호에 대해 특정한 동작을 보장하지 않는다. NaN 값의 payload(부가 데이터)도 특정한 동작을 보장하지 않는다. 예를 들어, 정렬(sorting) 또는 그룹화(group by) 연산 중에 모든 0 은 +0 으로 통일되고, 모든 NaN 은 payload 가 없는 양수 NaN 으로 정규화(canonicalize)될 수 있다. 이는 효율적인 동등성 검사(equality checks)를 위해 수행된다.
- 정확성에 대한 한계: polars 는 부동소수점 연산에서 합리적으로 정확한 결과를 제공하려 하지만, 명시적으로 언급되지 않는 한 오차에 대해 보장하지 않는다. 일반적으로, 100% 정확한 결과를 제공하는 것은 비현실적으로 높은 비용을 요구하며, 64비트 부동소수점보다 훨씬 큰 내부 표현이 필요하다고 한다. 따라서 항상 어느 정도의 오차가 있을 수 있다.
- 자세한 데이터 타입에 대한 내용은 해당 polars 의 블로그 글을 참고하자.
- 위에서 살펴봤듯 만약 데이터의 특정 column 만 선택적으로 로드한다면, polars 는 columnar(컬럼 지향) 데이터 처리 방식을 사용하기 때문에 메모리 사용을 줄이고 IO 성능이 더욱 뛰어나다. 이 때 polars 는 데이터 로드 또는 DataFrame 생성 중에 즉시 최적화된 데이터 타입으로 변환할 수 있다.
- 이를 위해서
schema
를 사용한다.schema
는 열(column) 또는 시리즈(series)의 이름을 해당 열 또는 시리즈의 데이터 타입과 매핑한 것을 의미한다. - polars 는 데이터프레임이나 시리즈를 생성할 때 schema 를 자동으로 추론(infer)하지만, 필요하다면 이 추론 시스템을 재정의(override)할 수 있다. 이를 이용해서 데이터 로드 중 최적화된 데이터 타입으로 변환하는 것이다.
df = pl.DataFrame(
{
"name": ["Alice", "Ben", "Chloe", "Daniel"],
"age": [27, 39, 41, 43],
},
schema={"name": None, "age": pl.UInt8},
)
# shape: (4, 2)
# ┌────────┬─────┐
# │ name ┆ age │
# │ --- ┆ --- │
# │ str ┆ u8 │
# ╞════════╪═════╡
# │ Alice ┆ 27 │
# │ Ben ┆ 39 │
# │ Chloe ┆ 41 │
# │ Daniel ┆ 43 │
# └────────┴─────┘
- 위처럼 schema 가
None
인 경우 특정 열에 대해 추론을 재정의하지 않은 것이고 polars 가 추론하게 된다. - 이 때 전체 스키마를 지정하려면
schema
를, 일부만 재정의하려면schema_overrides
를 사용하면 좋다. 즉schema_overrides
는 추론을 재정의하고 싶지 않은 열을 생략할 수 있다.
df = pl.DataFrame(
{
"name": ["Alice", "Ben", "Chloe", "Daniel"],
"age": [27, 39, 41, 43],
},
schema_overrides={"age": pl.UInt8},
)
# shape: (4, 2)
# ┌────────┬─────┐
# │ name ┆ age │
# │ --- ┆ --- │
# │ str ┆ u8 │
# ╞════════╪═════╡
# │ Alice ┆ 27 │
# │ Ben ┆ 39 │
# │ Chloe ┆ 41 │
# │ Daniel ┆ 43 │
# └────────┴─────┘
- 물론 데이터를 폴더 혹은 cloud storage, DB 에서 읽을 때도
schema
argument 를 사용 가능하다. 이 때 기억해야 하는 점은 아래와 같다.read_확장자
함수에서의 argument 인schema
는 polars 의 schema 추론을 사용하지 않고 명시하는 것이다. 이 때 schema 에 전해주는 dict 에는 원본 데이터의 컬럼 순서와 같은 순서로column name : dtype
의 형태로 주어져야 한다.- 또한 parquet 과 같이 schema 가 내장된 데이터의 경우, 해당 데이터가 가진 data type 과 같은 type 으로 구성된
schema
를 건네주어야 한다. 즉 다른 data type 으로 바꾸는 것은 안된다. polars 에서는 실제로 parquet 파일을 읽을 때schema
를 명시하는 것이 불안정하다고 말하니, 만약 data type 을 바꿀 필요가 있다면 내장된schema
를 쓸 수 있도록 먼저 DataFrame 으로 읽고 이후에 바꾸도록 하자. - schema 를 내장한 파일 포맷은 parquet, Avro, ORC, Feather/Arrow IPC 등이 있다. 이러한 파일 포맷은 파일 내부에 각 컬럼의 데이터 타입과 이름을 저장한다. 따라서
schema_overrides
는 지원되지 않는다. - polars 의
schema_overrides
는 CSV 나 JSON 처럼 schema 가 내장되지 않은 데이터 파일에서 유용하다.
- 또한 DataFrame 에
.schema
를 사용하면 각 컬럼 별 data type 을 알아낼 수 있다.
pl_df.schema
# Schema([('date_id', Int16),
# ('time_id', Int16),
# ('symbol_id', Int8),
# ('weight', Float32),
# ('feature_00', Float32),
# ...
- polars 에서 pandas 의
astype()
처럼 data type 을 바꾸는 함수는.cast()
다. 이 때 아래 코드와 같이 3 가지 방법으로 사용할 수 있다.
from datetime import date
df = pl.DataFrame(
{
"foo": [1, 2, 3],
"bar": [6.0, 7.0, 8.0],
"ham": [date(2020, 1, 2), date(2021, 3, 4), date(2022, 5, 6)],
}
)
# 각 컬럼 별 데이터 타입 변환
df.cast({"foo": pl.Float32, "bar": pl.UInt8})
# shape: (3, 3)
# ┌─────┬─────┬────────────┐
# │ foo ┆ bar ┆ ham │
# │ --- ┆ --- ┆ --- │
# │ f32 ┆ u8 ┆ date │
# ╞═════╪═════╪════════════╡
# │ 1.0 ┆ 6 ┆ 2020-01-02 │
# │ 2.0 ┆ 7 ┆ 2021-03-04 │
# │ 3.0 ┆ 8 ┆ 2022-05-06 │
# └─────┴─────┴────────────┘
# 데이터 타입 전체 변환
df.cast({pl.Date: pl.Datetime})
# shape: (3, 3)
# ┌─────┬─────┬─────────────────────┐
# │ foo ┆ bar ┆ ham │
# │ --- ┆ --- ┆ --- │
# │ i64 ┆ f64 ┆ datetime[μs] │
# ╞═════╪═════╪═════════════════════╡
# │ 1 ┆ 6.0 ┆ 2020-01-02 00:00:00 │
# │ 2 ┆ 7.0 ┆ 2021-03-04 00:00:00 │
# │ 3 ┆ 8.0 ┆ 2022-05-06 00:00:00 │
# └─────┴─────┴─────────────────────┘
# 데이터 프레임 전체 변환
df.cast(pl.String).to_dict(as_series=False)
# {'foo': ['1', '2', '3'],
# 'bar': ['6.0', '7.0', '8.0'],
# 'ham': ['2020-01-02', '2021-03-04', '2022-05-06']}
- 또한 pandas 의
select_dtypes()
처럼 polars 에서 특정 data type 을 가진 column 을 가져오는 방법은df.select(pl.col(pl.String, pl.Int64, ...))
와 같은 방법을 사용한다.
pl_df.select(pl.col(pl.Int16))
# shape: (6_274_576, 3)
# date_id time_id feature_11
# i16 i16 i16
# 1530 0 76
# 1530 0 76
# 1530 0 59
# 1530 0 11
# 1530 0 9
# … … …
# 1698 967 150
# 1698 967 195
# 1698 967 297
# 1698 967 214
# 1698 967 522
structures
- pandas 와 polars 의 데이터 구조에 대해 간단하게 알아보자.
pandas
- pandas 에는 익히 알고 있듯
pd.Series
와pd.DataFrame
이 있다. 두 데이터 구조의 가장 근본적인 차이는 column 개수에 있다. Series
- pandas 에서 일종의 list 나 dict 처럼 사용된다.
- 모든 유형의 데이터를 보유하는 1 차원 배열이다.
- 길이가
n
일 때, 0 에서n-1
의 index 로 값에 접근할 수 있다. 이 때, 하나의 index 에 하나의 값만 가진다.
DataFrame
DataFrame
은 2차원 배열 또는 행과 열이 있는 테이블과 같은 2차원 데이터 구조다. 이 때 서로 다른 데이터 타입을 column 으로 가질 수 있다.Series
는 말그대로DataFrame
의 단일 column 에 대한 데이터 구조다. 즉,DataFrame
의 데이터가 실제로 메모리에는Series
의 컬렉션으로 저장되는 것이다.
- 둘 다 매우 유사한 API 를 가지고 있지만
DataFrame
메서드는 항상 둘 이상의 열이 있을 가능성을 충족한다는 것을 알 수 있다. - 물론
DataFrame
에 다른Series
(또는 이에 상응하는 객체)를 추가할 수 있으며 다른Series
에Series
를 추가하면DataFrame
이 생성된다.
import pandas as pd
# series 생성
data = {'a':1, 'b':2, 'c':3}
pd.Series(data)
# a 1
# b 2
# c 3
# dtype: int64
# series 결합 dataframe
l1 = [1, 2, 3]
l2 = [100, 200, 300]
data1 = pd.Series(l1)
data2 = pd.Series(l2)
frame = {1 : data1, 2 : data2}
res = pd.DataFrame(frame)
print(res)
# 1 2
# 0 1 100
# 1 2 200
# 2 3 300
# dataframe 에 series 추가
l3 = [1000, 2000, 3000]
data3 = pd.Series(l3)
res[3] = data3
print(res)
# 1 2 3
# 0 1 100 1000
# 1 2 200 2000
# 2 3 300 3000
- 두 데이터 구조에 대한 자세한 옵션과 생성 방법은 pandas 공식문서를 확인해보자.
polars
- polars 에는 pandas 와 마찬가지로
pl.Series
와pl.DataFrame
이 있고, 추가적으로pl.LazyFrame
이 존재한다. pl.Series
는 1차원 동질적(homogeneous) 데이터 구조다. 여기서 homogeneous 이라는 것은 Series 내부의 모든 요소가 동일한 데이터 타입을 가진다는 것을 의미한다. 이는 pandas 도 마찬가지다.- Series 를 생성할 때 polars 는 제공된 값들을 기반으로 데이터 타입을 추론한다. 그러나 위에서 본 것처럼 추론 메커니즘을 무시하고 특정 데이터 타입을 지정할 수도 있다.
pl.DataFrame
은 2차원 이질적(heterogeneous) 데이터 구조로, 고유한 이름을 가진 여러 Series 를 포함한다.- 데이터를 DataFrame 에 보관하면 polars API 를 사용하여 데이터를 조작하는 쿼리를 작성할 수 있다.
- 이를 위해 polars 가 제공하는 contexts 와 expressions 을 활용할 수 있다.
- 아래에서 list 를 value 로 하는 dict 을 사용하여 DataFrame 을 생성해보자.
import polars as pl
s = pl.Series("ints", [1, 2, 3, 4, 5])
print(s)
# shape: (5,)
# Series: 'ints' [i64]
# [
# 1
# 2
# 3
# 4
# 5
# ]
s1 = pl.Series("ints", [1, 2, 3, 4, 5])
s2 = pl.Series("uints", [1, 2, 3, 4, 5], dtype=pl.UInt64)
print(s1.dtype, s2.dtype) # Int64 UInt64
from datetime import date
df = pl.DataFrame(
{
"name": ["Alice Archer", "Ben Brown", "Chloe Cooper", "Daniel Donovan"],
"birthdate": [
date(1997, 1, 10),
date(1985, 2, 15),
date(1983, 3, 22),
date(1981, 4, 30),
],
"weight": [57.9, 72.5, 53.6, 83.1], # (kg)
"height": [1.56, 1.77, 1.65, 1.75], # (m)
}
)
print(df)
# shape: (4, 4)
# ┌────────────────┬────────────┬────────┬────────┐
# │ name ┆ birthdate ┆ weight ┆ height │
# │ --- ┆ --- ┆ --- ┆ --- │
# │ str ┆ date ┆ f64 ┆ f64 │
# ╞════════════════╪════════════╪════════╪════════╡
# │ Alice Archer ┆ 1997-01-10 ┆ 57.9 ┆ 1.56 │
# │ Ben Brown ┆ 1985-02-15 ┆ 72.5 ┆ 1.77 │
# │ Chloe Cooper ┆ 1983-03-22 ┆ 53.6 ┆ 1.65 │
# │ Daniel Donovan ┆ 1981-04-30 ┆ 83.1 ┆ 1.75 │
# └────────────────┴────────────┴────────┴────────┘
- 위에서 본 것처럼 polars 는 즉시 실행(Eager)과 지연 실행(Lazy) 두 가지 모드를 지원한다.
- 즉시 실행 모드에서는 쿼리가 바로 실행된다. 반면, 지연 실행 API 를 사용하면 쿼리는
collect
명령이 호출될 때까지 쿼리가 실행되지 않는다. - 실행을 마지막 순간까지 미루는 것은 성능상 큰 이점을 제공할 수 있기 때문에 대부분의 경우 지연 실행 API 가 선호된다.
- 즉시 실행 모드에서는 쿼리가 바로 실행된다. 반면, 지연 실행 API 를 사용하면 쿼리는
- 예를 들어 Iris 데이터셋으로 두 모드의 실행 과정을 설명해보자.
- eager API 를 사용하면 먼저 1) Iris 데이터셋을 읽고, 2) 꽃받침 길이(sepal length)를 기준으로 데이터셋을 필터링하고, 3) 종(species)별로 꽃받침 너비(sepal width)의 평균 계산할 수 있다.
- 이 때 각 단계가 즉시 실행되며, 중간 결과를 반환한다. 그러나 이 방식은 데이터셋이 매우 크거나 주어진 메모리가 작을 때 비효율적일 수 있다. 즉 사용되지 않는 데이터를 읽거나 불필요한 작업을 수행할 가능성이 있다.
- Lazy API 를 사용하면, 모든 단계가 정의될 때까지 실행을 연기할 수 있다. 실행 전에 polars 의 쿼리 플래너(query planner)가 다양한 최적화를 수행한다.
- 대표적으로 아래와 같은 최적화가 진행된다.
- 조건(Predicate) 푸시다운: 데이터셋을 읽을 때 가능한 한 빨리 필터를 적용한다. 예를 들어, 꽃받침 길이가 5 보다 큰 행만 읽도록 최적화한다.
- 프로젝션(Projection) 푸시다운: 필요한 열만 선택해서 읽는다. 예를 들어, 꽃잎 길이(petal length)와 꽃잎 너비(petal width) 같은 불필요한 열을 읽지 않도록 최적화한다.
- 이러한 최적화의 이점은 메모리 및 CPU 의 부하를 크게 줄일 수 있고, 더 큰 데이터셋을 메모리에 적재하고 빠르게 처리할 수 있다.
- Lazy API 로 쿼리를 정의한 후,
collect
를 호출하면 polars 가 정의된 쿼리들을 순차적으로 실행하도록 요청한다. - 또한 Lazy API 를 사용할 때,
explain
함수를 사용하여 쿼리 계획(query plan)을 출력할 수 있다. 이 기능은 쿼리에 대해 어떤 최적화를 수행하는지 확인하고 싶을 때 유용하다. - 일반적으로 이러한 Lazy API 에 사용되는 데이터 구조가
pl.LazyFrame
이다.
schema = pl.Schema(
{
"int_1": pl.Int16,
"int_2": pl.Int32,
"float_1": pl.Float64,
"float_2": pl.Float64,
"float_3": pl.Float64,
}
)
print(
pl.LazyFrame(schema=schema)
.select((pl.col(pl.Float64) * 1.1).name.suffix("*1.1"))
.explain()
)
# SELECT [[(col("float_1")) * (1.1)].alias("float_1*1.1"), [(col("float_2")) * (1.1)].alias("float_2*1.1"), [(col("float_3")) * (1.1)].alias("float_3*1.1")] FROM
# DF ["int_1", "int_2", "float_1", "float_2"]; PROJECT 3/5 COLUMNS
- 위 코드와 같이
pl.LazyFrame
으로 데이터를 생성하지는 않고, Lazy API 의 특성상 크기가 매우 큰 데이터셋을scan_format
을 이용하여pl.LazyFrame
으로 읽은 뒤, 쿼리를 정의하고collect
로 실행한다. - 추가적으로 polars 에서 새로운 column 을 만들때는
.with_columns()
함수를 사용한다.
filtering & selection
- 위에서 본 것처럼 pandas 는 데이터를 read 할 때부터 filter 기능을 사용할 수 있고, 이후에 indexing, slicing, 조건문을 통해 직관적인 방법으로 필터링할 수 있다.
- polars 는
.filter()
와.select()
를 이용하는데, pandas 보다 효율적으로 동작한다. 아래에서 자세하게 비교해보자.
pandas
-
pandas 의 기본적인 indexing 은 아래와 같다.
Operation Syntax Result Select Column df[col]
Series Select Row by label df.loc[label]
Series Select Element by label, column df.loc[label, col]
Element Select Row by integer location df.iloc[loc]
Series Select Element by integer location on low, column df.iloc[loc, loc]
Element Slice rows df[5:10]
DataFrame Select rows by boolean vector df[bool_vec]
DataFrame -
이를 코드로 확인해보자.
pd_df["date_id"]
# 0 1530
# 1 1530
# 2 1530
# 3 1530
# 4 1530
# ...
# 6274571 1698
# 6274572 1698
# 6274573 1698
# 6274574 1698
# 6274575 1698
# Name: date_id, Length: 6274576, dtype: int16
pd_df.loc[0]
# date_id 1530.000000
# time_id 0.000000
# symbol_id 0.000000
# weight 3.084694
# feature_00 1.153571
# ...
# responder_4 0.746552
# responder_5 0.552013
# responder_6 3.071231
# responder_7 0.914794
# responder_8 0.997124
# Name: 0, Length: 92, dtype: float32
pd_df.loc[0, 'date_id'] # np.int16(1530)
pd_df.iloc[0]
# date_id 1530.000000
# time_id 0.000000
# symbol_id 0.000000
# weight 3.084694
# feature_00 1.153571
# ...
# responder_4 0.746552
# responder_5 0.552013
# responder_6 3.071231
# responder_7 0.914794
# responder_8 0.997124
# Name: 0, Length: 92, dtype: float32
pd_df.iloc[0, 0] # np.int16(1530)
.loc
과.iloc
은 pandas 에서 filtering 과 selecting 을 할 때 가장 많이 사용하는 메서드다.- pandas 의 공식문서 중 Indexing and selecting data 에는 다양한 응용방식이 소개되어 있다.
polars
- 기본적인 indexing 은 아래와 같다.
aggregation operations
grouping operations
sorting
시계열 연산
SQL 사용
Reference
- https://butter-shower.tistory.com/245
- https://techblog.woowahan.com/18632/
- https://modulabs.co.kr/blog/polars
- https://qphone.tistory.com/4
- https://pola.rs/posts/understanding-polars-data-types
댓글 남기기