1부 - Polars 기본 활용법
2부 - Polars 심화 활용법
아래 실습에 사용한 데이터 및 코드는 저의 Github에서 확인 가능합니다.
Python으로 데이터 분석을 시작할 때, 많은 사람들이 가장 먼저 사용하는 라이브러리가 바로 Pandas일 겁니다. Pandas는 다양한 파일 형식(csv, json, parquet)을 데이터프레임 형태로 손쉽게 읽고 다룰 수 있으며, 방대한 메소드와 기능 덕분에 데이터를 원하는 거의 모든 형태로 처리할 수 있습니다. 데이터베이스와의 통합, SQL 활용, 시각화 등 데이터 분석에 필요한 중요한 기능들을 라이브러리 하나로 대부분 해결할 수 있는 Pandas는 데이터 분석의 기본 도구로 자리 잡았습니다. 하지만 데이터 크기가 커지고 처리 속도가 중요한 상황에서는 아래와 같은 이유들로 인해 한계점이 명확해지죠.
1. 단일 스레드 기반 처리
pandas
는 기본적으로 단일 스레드에서 동작하도록 설계되어 있어, 대용량 데이터를 병렬 처리하지 못해 성능이 제한됩니다.2. Numpy에 기반한 데이터 구조
pandas
는 numpy
에 기반해 행 기반(row-oriented) 데이터 구조를 사용합니다. 이는 OLAP 작업과 같은 열 중심(column-oriented) 연산에서 효율이 떨어질 수 있습니다.3. Python을 통한 구현
pandas
는 python으로 구현되어 한 번에 하나의 스레드만 실행되도록 하는 GIL(Global Interpreter Lock) 제약에 따라 성능에 제한이 발생합니다.4. Lazy Evaluation 미지원
Lazy Evaluation
기능은 사용자의 명령을 최적화된 실행 계획으로 결합한 후 처리하기 때문에 불필요한 계산을 피하고 성능을 최적화하는데 중요한 역할을 합니다. 하지만 pandas
는 명령 실행시 즉시 데이터를 메모리에 로드하고 처리합니다. 이는 작업이 최적화되지 않아 불필요한 계산과 메모리 낭비를 초래할 수 있습니다.이번 글에서 소개할 polars
는 pandas
의 다양한 API를 활용하면서도 속도나 효율성 등에서 훨씬 뛰어난 성능을 보여줍니다. Rust 언어로 개발되어 Lazy Evaluation, Multi Core Processing, 최적화된 메모리 사용 등의 특성들을 통해 대용량 데이터를 다루는데 있어 최적화된 도구로 주목받고 있습니다.
실제로 약 800MB, 1144만 행을 가진 데이터를 읽는 속도를 비교해보았을 때, 아래와 같이 polars
가 pandas
에 비해 6배 이상 빠른 것을 확인할 수 있었습니다.
이렇게 뛰어난 성능 덕분에 pandas
, apache spark
등 다른 데이터 분석 툴들에 비해 github stars 수도 빠르게 늘고 있는 것을 알 수 있죠.
1부에서는 polars
의 설치부터 데이터 조회, 필터링, 집계, 정렬, JOIN 등 polars
를 활용해 데이터 분석을 하기 위한 기본적인 내용들을 전반적으로 다루어보았습니다.
pip install polars
import polars as pl
# 최대 100행까지 잘리지 않고 출력되도록 설정
cfg = pl.Config()
cfg.set_tbl_rows(100)
df = pl.read_csv("../dataset/emp.csv")
df.head()
df.schema
---
Schema([('First Name', String),
('Gender', String),
('Start Date', String),
('Last Login Time', String),
('Salary', Int64),
('Bonus', Float64),
('Senior Management', Boolean),
('Team', String)])
df.columns
---
['First Name', 'Gender', 'Start Date', 'Last Login Time', 'Salary', 'Bonus', 'Senior Management', 'Team']
df.describe()
df.with_row_index()
# 1
df['Gender']
# 2
df.get_column('Gender')
# 1
df[['Gender', 'Salary']]
# 2
df.select('Gender', 'Salary')
Apache Spark
와 비슷하게 with_columns
메소드를 활용해 새로운 컬럼 생성이 가능하며, 다양한 방법으로 표현이 가능하다.alias
를 활용하거나, key-value
형태로 작성함으로써 새로운 컬럼을 생성할 수 있다.# Bonus에 대한 금액 계산 컬럼(surplus) 추가
# 1
df.with_columns(
(df["Salary"]*df['Bonus']/100).round(2).alias('surplus')
)
# 2
df.with_columns(
(pl.col("Salary")*pl.col('Bonus')/100).round(2).alias('surplus')
)
# 3
df.with_columns(
surplus = (pl.col("Salary")*pl.col('Bonus')/100).round(2)
)
# 변경할 이름에 대한 dict 정보 생성
rename_dict = {
'Bonus' : 'bonus',
'Gender' : 'gender'
}
df.rename(rename_dict).head()
# 단일 컬럼 삭제
df.drop('Bonus')
# 복수 컬럼 삭제
df.drop('Bonus', 'Salary')
df.drop(['Bonus', 'Salary'])
# 특정 타입의 컬럼 삭제
import polars.selectors as cs
df.drop(cs.string()) # string 타입 컬럼 drop
df.drop(cs.integer()) # INT 타입 컬럼 drop
# 각 컬럼에 대한 null값 count를 출력한다.
df.null_count()
df.fill_null(0) # null값을 0으로 채우기
df.fill_null(strategy='forward') # 'forward', 'backward', 'min', 'max', 'mean', 'zero', 'one'
# 전체 컬럼 대상
df.drop_nulls()
# 특정 컬럼 대상
df.drop_nulls(subset='Gender')
# 특정 타입 대상
import polars.selectors as cs
df.drop_nulls(subset=cs.string())
## drop 대상 컬럼 저장 + drop 내용 바로 반영 (python의 pop과 비슷)
pop_df = df.drop_in_place('Gender') # 'Gender' 컬럼 데이터 저장
df # 'Gender' 컬럼이 drop된 df 저장
# INT -> STRING 변경
df = df.with_columns(
Salary = pl.col("Salary").cast(pl.String)
)
# STRING -> INT 변경
df = df.with_columns(
Salary = pl.col("Salary").cast(pl.Int32)
)
# Start Date를 datetime 형식으로 변환
df = df.with_columns(
pl.col("Start Date").str.strptime(pl.Date, format="%m/%d/%Y").alias("Start Date")
)
# Last Login Time을 시간 형식으로 변환
df = df.with_columns(
pl.col("Last Login Time").str.strptime(pl.Time, format="%I:%M %p").alias("Last Login Time")
)
df.with_columns(
year = pl.col("Start Date").dt.year(),
month = pl.col("Start Date").dt.month(),
day = pl.col("Start Date").dt.day()
).head()
# 이 외에도 weekday, week, epoch, hour, minute, second, month_start, month_end 등 시간 관련 다양한 메소드가 있다.
df.filter(pl.col('Gender').is_null())
df.filter(pl.col('Gender') == 'Female')
df.filter(pl.col('Bonus') >= 10)
df.filter(pl.col('Bonus').is_between(8, 12))
df.filter(pl.col('Team').is_in(['Finance', 'Sales']))
and_condition = (pl.col('Gender') == 'Female') & (pl.col('Bonus') >= 10)
or_condition = (pl.col('Gender') == 'Female') | (pl.col('Bonus') >= 10)
df.filter(and_condition)
# Bonus 컬럼을 기준으로 내림차순 정렬
df.sort(
by='Bonus',
descending=True
)
# Team 컬럼은 오름차순, Salary 컬럼은 내림차순 정렬
df.sort(
by=['Team', 'Salary'],
descending=[False, True],
nulls_last=True
)
df.group_by('Gender').agg(
gender_cnt=pl.col('Gender').count()
)
df.group_by('Gender').agg(
pl.count('Gender').alias('gender_cnt')
)
df.group_by('Team').agg(
cnt = pl.count('Team'),
max_sal = pl.max('Salary'),
mean_sal = pl.mean('Salary'),
min_sal = pl.min('Salary'),
mean_bonus = pl.mean('Bonus'),
).sort('mean_sal', descending=True)
group_by_dynamic
실습은 battle.csv
데이터를 사용한다.df = pl.read_csv("../dataset/battle.csv")
"""
group_by_dynamic(
index_column # 시간 기반 그룹화를 위한 타임스탬프 열을 지정
every # 그룹화 간격(예: '1d'는 1일 단위)
period # 그룹화 윈도우의 기간(기본값은 every와 동일)
offset # 그룹화 윈도우의 시작점을 조정하는 오프셋
include_boundaries # 그룹 경계(윈도우 시작 및 끝)을 결과에 포함할지 여부
closed # 윈도우 경계가 왼쪽, 오른쪽, 또는 양쪽에서 닫혔는지 지정
group_by # 추가로 그룹화할 컬럼 지정
)
"""
df.group_by_dynamic(
index_column='battle_datetime',
every="3h"
).agg(
gb = pl.col('winner_id'),
cnt = pl.col('id').count(),
id_max = pl.col('winner_id').max(),
id_min = pl.col('winner_id').min(),
).head()
trainer_pokemon.csv
, pokemon.csv
데이터를 사용한다.tp = pl.read_csv("../dataset/trainer_pokemon.csv")
pk = pl.read_csv("../dataset/pokemon.csv")
tp.join(
other=pk,
left_on='pokemon_id',
right_on='id',
how='left' # 'inner', 'left', 'right', 'full', 'semi', 'anti', 'cross'
)
"""
inner: 두 테이블 모두에서 값이 일치하는 행을 반환
left: 왼쪽 테이블의 모든 행과 오른쪽 테이블에서 일치하는 행을 반환
right: 오른쪽 테이블의 모든 행과 왼쪽 테이블에서 일치하는 행을 반환
full: 왼쪽 또는 오른쪽 테이블 중 하나라도 일치하는 경우 모든 행을 반환
cross: 두 테이블의 행 간 데카르트 곱을 반환
semi: 오른쪽 테이블에서 일치하는 값이 있는 왼쪽 테이블의 행만 반환
anti: 오른쪽 테이블에서 일치하지 않는 값만 있는 왼쪽 테이블의 행을 반환
"""
df1 = pl.DataFrame({
"time": [10, 20, 30],
"value": ["a", "b", "c"]
})
df2 = pl.DataFrame({
"time": [13, 23, 33],
"value_2": ["x", "y", "z"]
})
df1.join_asof(
df2,
on="time",
strategy='nearest' # 'backward', 'forward', 'nearest'
).head()
---
┌──────┬───────┬─────────┐
│ time┆ value ┆ value_2│
│ --- ┆ --- ┆ --- │
│ i64 ┆ str ┆ str │
╞══════╪═══════╪═════════╡
│ 10 ┆ a ┆ x │
│ 20 ┆ b ┆ y │
│ 30 ┆ c ┆ z │
└──────┴───────┴─────────┘