Pandas에서 넘어오기
Pandas에서 넘어오기
pandas 경험이 있는 사람이 Polars를 시도할 때 알아두면 좋은 핵심 포인트를 정리해 봤어요. 두 라이브러리가 바탕에 둔 개념의 차이와, pandas 코드에 비해 Polars 코드를 어떻게 작성해야 하는지의 차이를 함께 다룹니다.
출처: 공식문서
Polars와 pandas의 개념적 차이
Polars에는 multi-index/index가 없다
pandas는 각 행에 index라는 레이블을 붙여요. Polars는 index를 사용하지 않고, 각 행은 테이블 안의 정수 위치로 식별됩니다.
Polars는 예측 가능한 결과와 읽기 쉬운 쿼리를 목표로 하는데, index가 이 목표에 도움이 되지 않는다고 생각해요. 쿼리의 의미가 index의 상태나 reset_index 호출에 따라 바뀌면 안 된다고 믿습니다.
Polars에서 DataFrame은 항상 이질적인(heterogeneous) 데이터 타입을 가진 2D 테이블이에요. 데이터 타입은 중첩될 수 있지만 테이블 자체는 중첩되지 않습니다. 리샘플링 같은 연산은 테이블에 '동사'처럼 작용하며 그 동사가 적용되는 컬럼을 명시하는 전용 함수나 메서드로 처리돼요. 그래서 index가 없다는 게 오히려 더 단순하고, 명시적이고, 읽기 쉬우며, 오류 가능성도 낮다고 생각합니다.
참고로 데이터베이스에서 아는 것과 같은 'index' 자료구조는 Polars에서 최적화 기법으로 사용됩니다.
Polars는 데이터를 메모리에 표현할 때 Apache Arrow 메모리 포맷을 따르고, pandas는 NumPy 배열을 쓴다
Polars는 메모리 상의 데이터를 Arrow 메모리 스펙에 따라 표현하고, pandas는 기본적으로 NumPy 배열로 표현해요. Apache Arrow는 인메모리 컬럼 분석을 위한 떠오르는 표준으로, 데이터 로드 시간을 가속하고 메모리 사용을 줄이며 계산을 빠르게 해 줍니다.
Polars는 to_numpy 메서드로 데이터를 NumPy 형식으로 변환할 수 있어요.
Polars는 pandas보다 병렬 연산 지원이 더 많다
Polars는 Rust의 강력한 동시성 지원을 활용해 많은 연산을 병렬로 실행합니다. pandas의 일부 연산은 멀티스레드이지만 라이브러리 핵심은 단일 스레드라서, 병렬화하려면 Dask 같은 추가 라이브러리가 필요해요. Polars는 pandas 코드를 병렬화하는 모든 오픈소스 솔루션보다 빠릅니다.
Polars는 여러 엔진을 지원한다
Polars는 인메모리 처리에 최적화된 엔진과, 대규모 데이터 처리에 최적화된 streaming 엔진을 네이티브로 지원해요. 게다가 CuDF 지원 엔진과도 네이티브로 통합됩니다. 모든 엔진이 Polars의 쿼리 옵티마이저 혜택을 받고, Polars는 모든 엔진 사이에서 의미론적 정확성(semantic correctness)을 보장합니다. 반면 pandas는 구현이 numpy와 Pyarrow 사이에서 디스패치될 수 있는데, pandas의 느슨한(strict하지 않은) 보장 때문에 백엔드 간 데이터 타입 출력과 의미론이 달라질 수 있어요. 이는 미묘한 버그로 이어질 수 있죠.
Polars는 쿼리를 lazy 평가하고 쿼리 최적화를 적용한다
Eager 평가는 코드를 실행하는 즉시 평가되는 것이고, lazy 평가는 코드 한 줄을 실행하면 그 로직이 평가되는 대신 쿼리 플랜에 추가되는 것을 말해요.
Polars는 eager 평가와 lazy 평가를 모두 지원하는 반면 pandas는 eager 평가만 지원합니다. lazy 평가 모드가 강력한 이유는 Polars가 쿼리 플랜을 검토하면서 쿼리를 가속하거나 메모리 사용을 줄일 방법을 찾는 자동 쿼리 최적화를 수행하기 때문이에요.
Dask도 쿼리 플랜을 생성할 때 lazy 평가를 지원합니다.
Polars는 strict하다
Polars는 데이터 타입에 대해 strict합니다. Polars의 데이터 타입 결정은 연산 그래프에 의존하는 반면, pandas는 타입을 느슨하게 변환해요(예: 새 결측 데이터가 정수 컬럼을 float로 바꿀 수 있음). 이런 strict함 덕분에 버그가 줄고 동작이 더 예측 가능해집니다.
Polars는 더 다재다능한 API를 갖는다
Polars는 표현식(expression) 위에 구축되어 거의 모든 연산에서 표현식 입력을 허용합니다. 즉 표현식의 동작 원리를 이해하면 Polars 지식이 그대로 확장돼요. pandas는 표현식 시스템이 없어서 원하는 복잡도를 표현하려면 종종 Python lambda가 필요합니다. Polars는 Python lambda의 필요성을 API의 표현력 부족으로 보고, 가능하면 네이티브 지원을 제공하려고 합니다.
핵심 문법 차이
pandas에서 넘어오는 사용자들이 보통 한 가지만 알면 됩니다...
polars != pandas
Polars 코드가 pandas 코드처럼 보인다면, 실행은 될지 몰라도 제 성능보다 느리게 돌 가능성이 높아요.
전형적인 pandas 코드를 몇 개 보면서 Polars로 어떻게 다시 쓰는지 확인해 볼게요.
데이터 선택
Polars에는 index가 없기 때문에 .loc이나 iloc 메서드도 없어요. SettingWithCopyWarning도 Polars에는 없습니다.
데이터를 선택하는 가장 좋은 방법은 표현식 API를 쓰는 거예요. 예를 들어 pandas에서 컬럼 하나를 선택하려면 다음 중 하나를 씁니다.
df["a"]
df.loc[:,"a"]
Polars에서는 .select 메서드를 사용해요.
df.select("a")
값을 기준으로 행을 선택하려면 Polars에서 .filter 메서드를 씁니다.
df.filter(pl.col("a") < 10)
아래 표현식 섹션에서 언급하듯, Polars는 .select와 filter에서 연산을 병렬로 실행할 수 있고, 데이터 선택 조건 전체에 쿼리 최적화를 적용할 수 있어요.
Lazy 하게 쓰기
lazy 평가 모드에서 작업하는 건 간단하며, lazy 모드를 쓰면 Polars가 쿼리 최적화를 수행할 수 있기 때문에 Polars의 기본값이 되어야 합니다.
lazy 모드는 scan_csv처럼 암묵적으로 lazy한 함수를 쓰거나, lazy 메서드를 명시적으로 사용해 실행할 수 있어요.
디스크에서 CSV 파일을 읽고 group by를 하는 간단한 예를 볼게요. CSV 파일은 컬럼이 많지만, 우리는 id 컬럼 중 하나(id1)로 group by하고 값 컬럼(v1)을 합산하려고만 합니다. pandas에서는 이렇게 씁니다:
df = pd.read_csv(csv_file, usecols=["id1","v1"])
grouped_df = df.loc[:,[ "id1","v1" ]].groupby("id1").sum()
Polars에서는 eager한 pandas 함수 read_csv를 암묵적으로 lazy한 Polars 함수 scan_csv로 바꿔서, 쿼리 최적화가 적용된 lazy 모드로 이 쿼리를 만들고 평가할 수 있어요:
df = pl.scan_csv(csv_file)
grouped_df = df.group_by("id1").agg(pl.col("v1").sum()).collect()
Polars는 이 쿼리를 최적화하면서 id1과 v1 컬럼만 관련 있다는 걸 파악하고, 그래서 CSV에서 이 컬럼만 읽어요. 두 번째 줄 끝의 .collect 메서드 호출로 Polars는 이 쿼리를 eager하게 평가하게 됩니다.
이 쿼리를 eager 모드로 실행하고 싶다면 Polars 코드에서 scan_csv를 read_csv로 바꾸기만 하면 돼요.
lazy 평가에 대해 더 자세히 배우려면 lazy API 섹션을 참고하세요.
표현하세요 (Express yourself)
전형적인 pandas 스크립트는 순차적으로 실행되는 여러 데이터 변환으로 구성돼요. 하지만 Polars에서는 이런 변환을 표현식을 이용해 병렬로 실행할 수 있습니다.
컬럼 할당
value라는 컬럼이 있는 데이터프레임 df가 있어요. 여기에 두 개의 새 컬럼을 추가하려고 합니다. value 컬럼에 10을 곱한 tenXValue 컬럼과, 100을 곱한 hundredXValue 컬럼이요.
pandas에서는 이렇게 씁니다:
df.assign(
tenXValue=lambda df_: df_.value * 10,
hundredXValue=lambda df_: df_.value * 100
)
이 컬럼 할당들은 순차적으로 실행됩니다.
Polars에서는 .with_columns 메서드로 df에 컬럼을 추가해요:
df.with_columns(
tenXValue=pl.col("value") * 10,
hundredXValue=pl.col("value") * 100,
)
이 컬럼 할당들은 병렬로 실행됩니다.
조건(predicate)에 기반한 컬럼 할당
여기서는 컬럼 a, b, c가 있는 데이터프레임 df가 있어요. a 컬럼의 값을 조건에 따라 다시 할당하려고 합니다. c 컬럼의 값이 2와 같으면 a의 값을 b의 값으로 바꾸는 거죠.
pandas에서는:
df.assign(a=lambda df_: df_["a"].mask(df_["c"] == 2, df_["b"]))
Polars에서는:
df.with_columns(
pl.when(pl.col("c") == 2)
.then(pl.col("b"))
.otherwise(pl.col("a")).alias("a")
)
Polars는 if -> then -> otherwise의 각 분기를 병렬로 계산할 수 있어요. 분기 계산이 더 비싸질수록 이게 가치가 커집니다.
필터링
주택 데이터가 있는 데이터프레임 df를 어떤 기준에 따라 필터링하려고 해요.
pandas에서는 query 메서드에 Boolean 표현식을 넘겨 데이터프레임을 필터링합니다:
df.query("m2_living > 2500 and price < 300000")
또는 마스크를 직접 평가해서:
df[(df["m2_living"] > 2500) & (df["price"] < 300000)]
Polars에서는 filter 메서드를 호출합니다:
df.filter(
(pl.col("m2_living") > 2500) & (pl.col("price") < 300000)
)
Polars의 쿼리 옵티마이저는 여러 개로 나눠 쓴 필터를 감지해, 최적화된 플랜에서 하나의 필터로 합칠 수도 있어요.
pandas transform
pandas 문서에는 group by에 대한 transform이라는 연산이 소개돼 있어요. 여기서는 데이터프레임 df가 있고, 각 그룹의 행 수를 보여주는 새 컬럼을 원한다고 해 볼게요.
pandas에서는:
df = pd.DataFrame({
"c": [1, 1, 1, 2, 2, 2, 2],
"type": ["m", "n", "o", "m", "m", "n", "n"],
})
df["size"] = df.groupby("c")["type"].transform(len)
여기서 pandas는 "c"로 group by하고, "type" 컬럼을 가져와 그룹 길이를 계산한 뒤, 결과를 원래 DataFrame에 다시 조인합니다.
c type size
0 1 m 3
1 1 n 3
2 1 o 3
3 2 m 4
4 2 m 4
5 2 n 4
6 2 n 4
Polars에서 같은 작업은 window 함수로 할 수 있어요:
df.with_columns(
pl.col("type").count().over("c").alias("size")
)
shape: (7, 3)
┌─────┬──────┬──────┐
│ c ┆ type ┆ size │
│ --- ┆ --- ┆ --- │
│ i64 ┆ str ┆ u32 │
╞═════╪══════╪══════╡
│ 1 ┆ m ┆ 3 │
│ 1 ┆ n ┆ 3 │
│ 1 ┆ o ┆ 3 │
│ 2 ┆ m ┆ 4 │
│ 2 ┆ m ┆ 4 │
│ 2 ┆ n ┆ 4 │
│ 2 ┆ n ┆ 4 │
└─────┴──────┴──────┘
전체 연산을 단일 표현식에 담을 수 있기 때문에, 여러 window 함수를 조합할 수 있고 심지어 서로 다른 그룹을 합치는 것도 가능해요!
Polars는 같은 그룹에 적용되는 window 표현식을 캐시하므로, 하나의 with_columns에 담는 것이 편리하면서 동시에 최적이에요. 다음 예제는 "c"에 대해 그룹 통계를 두 번 계산하는 경우를 보여줍니다:
df.with_columns(
pl.col("c").count().over("c").alias("size"),
pl.col("c").sum().over("type").alias("sum"),
pl.col("type").reverse().over("c").alias("reverse_type")
)
shape: (7, 5)
┌─────┬──────┬──────┬─────┬──────────────┐
│ c ┆ type ┆ size ┆ sum ┆ reverse_type │
│ --- ┆ --- ┆ --- ┆ --- ┆ --- │
│ i64 ┆ str ┆ u32 ┆ i64 ┆ str │
╞═════╪══════╪══════╪═════╪══════════════╡
│ 1 ┆ m ┆ 3 ┆ 5 ┆ o │
│ 1 ┆ n ┆ 3 ┆ 5 ┆ n │
│ 1 ┆ o ┆ 3 ┆ 1 ┆ m │
│ 2 ┆ m ┆ 4 ┆ 5 ┆ n │
│ 2 ┆ m ┆ 4 ┆ 5 ┆ n │
│ 2 ┆ n ┆ 4 ┆ 5 ┆ m │
│ 2 ┆ n ┆ 4 ┆ 5 ┆ m │
└─────┴──────┴──────┴─────┴──────────────┘
결측 데이터
pandas는 컬럼의 dtype에 따라 NaN 또는 None 값으로 결측값을 나타내요. 게다가 pandas의 동작은 기본 dtype을 쓰는지, 아니면 선택적인 nullable 배열을 쓰는지에 따라 달라집니다. Polars에서 결측 데이터는 모든 데이터 타입에서 null 값에 해당합니다.
float 컬럼의 경우 Polars는 NaN 값을 허용해요. 이 NaN 값은 결측 데이터로 간주되지 않고 특수한 부동소수점 값으로 취급됩니다.
pandas에서는 결측값이 있는 정수 컬럼이 (선택적 nullable 정수 dtype을 쓰지 않는 한) 결측값에 NaN이 있는 float 컬럼으로 캐스팅돼요. Polars에서는 정수 컬럼의 결측값이 그냥 null이고, 컬럼은 정수 컬럼으로 유지됩니다.
자세한 내용은 결측 데이터 섹션을 참고하세요.
Pipe 남용
pandas에서 흔한 사용법은 pipe를 이용해 어떤 함수를 DataFrame에 적용하는 거예요. 이 코딩 스타일을 Polars에 그대로 옮기면 관용적이지 않고 최적이 아닌 쿼리 플랜을 만들어냅니다.
아래 스니펫은 pandas의 흔한 패턴을 보여줘요.
def add_foo(df: pd.DataFrame) -> pd.DataFrame:
df["foo"] = ...
return df
def add_bar(df: pd.DataFrame) -> pd.DataFrame:
df["bar"] = ...
return df
def add_ham(df: pd.DataFrame) -> pd.DataFrame:
df["ham"] = ...
return df
(df
.pipe(add_foo)
.pipe(add_bar)
.pipe(add_ham)
)
이걸 Polars로 하면 with_columns 컨텍스트를 3개 만들게 되어, Polars가 3개의 pipe를 순차적으로 실행하도록 강제하고 병렬성을 0으로 만들어 버려요.
Polars에서 비슷한 추상화를 얻는 방법은 표현식을 만드는 함수를 만드는 거예요. 아래 스니펫은 단일 컨텍스트에서 실행되는 3개의 표현식을 만들어 병렬로 실행되게 합니다.
def get_foo(input_column: str) -> pl.Expr:
return pl.col(input_column).some_computation().alias("foo")
def get_bar(input_column: str) -> pl.Expr:
return pl.col(input_column).some_computation().alias("bar")
def get_ham(input_column: str) -> pl.Expr:
return pl.col(input_column).some_computation().alias("ham")
# This single context will run all 3 expressions in parallel
df.with_columns(
get_ham("col_a"),
get_bar("col_b"),
get_foo("col_c"),
)
표현식을 만드는 함수들에 스키마가 필요하다면, pipe를 한 번만 사용할 수 있어요:
from collections import OrderedDict
def get_foo(input_column: str, schema: OrderedDict) -> pl.Expr:
if "some_col" in schema:
# branch_a
...
else:
# branch b
...
def get_bar(input_column: str, schema: OrderedDict) -> pl.Expr:
if "some_col" in schema:
# branch_a
...
else:
# branch b
...
def get_ham(input_column: str) -> pl.Expr:
return pl.col(input_column).some_computation().alias("ham")
# Use pipe (just once) to get hold of the schema of the LazyFrame.
lf.pipe(lambda lf: lf.with_columns(
get_ham("col_a"),
get_bar("col_b", lf.schema),
get_foo("col_c", lf.schema),
))
표현식을 반환하는 함수를 작성하는 것의 또 다른 이점은 이런 함수들이 **합성 가능(composable)**하다는 거예요. 표현식은 체이닝되고 부분 적용될 수 있어서 설계에서 훨씬 더 많은 유연성을 얻을 수 있습니다.