pandas의 불만에서 시작한다
WHY POLARS EXISTS
데이터 과학자와 분석가 대다수는 pandas 라이브러리에 익숙하다. pandas로 데이터셋을 Series나 DataFrame 구조로 정리하고, 그 라이브러리가 제공하는 다양한 함수로 데이터를 다룬다. 그러나 pandas를 향한 주된 불만 가운데 하나는 큰 데이터셋을 다룰 때의 느린 속도와 비효율이다. pandas가 애초에 메모리에 들어가는 표 형식 데이터를 다루도록 설계되었기 때문이다. 큰 데이터셋을 상대할 때 pandas는 데이터를 메모리로 들이고 내보내기를 반복해야 하므로 느려진다.
큰 데이터셋을 다룰 때의 이 비효율을 해결하려는 경쟁 라이브러리가 Polars다. 이 장의 앞부분은 Polars 입문과 그것을 pandas처럼 다루는 법을 제공하고, 뒷부분은 DuckDB로 Polars DataFrame에 질의하는 법을 보인다.
제4장의 성격을 미리 밝혀 두면, 이 장은 DuckDB에 관한 장이 아니다. 절반 이상이 Polars에 관한 장이며, DuckDB는 그 위에 SQL이라는 손잡이를 달아 주는 역할로 뒤늦게 등장한다. 두 도구가 왜 서로를 밀어내지 않고 겹쳐 쓰이는가, 그것이 이 장의 물음이다.
원서 PDF는 코드가 지면 폭에서 가로로 잘려 있어, 이 장의 예제 데이터를 담은 파이썬 사전 리터럴도 오른쪽 끝이 잘려 있다. 다행히 원서는 뒤에서 df.rows()의 출력을 여덟 행 전부 온전히 인쇄한다. 그래서 이 페이지의 자동차 표는 잘린 리터럴이 아니라 그 rows() 출력을 정본으로 삼아 복원했다.
표기 규칙은 앞 세 장과 같다. 문맥으로 일의적으로 복원되는 부분은 점선 밑줄, 복원하지 않은 부분은 …, 검토자가 데이터에서 계산한 값은 표에서 점선을 두른 글자로 표시하고 근거를 붙였다.
Polars가 겨눈 다섯 가지
INTRODUCTION TO POLARS
Polars는 전적으로 Rust로 작성된 DataFrame 라이브러리다. 다음 다섯 가지를 염두에 두고 설계되었다.
속도
speed성능으로 알려진 시스템 프로그래밍 언어 Rust를 활용한다.
병렬성
parallelism다중 코어 프로세서를 활용할 수 있어, CPU에 매인 연산에서 상당한 속도 향상을 낸다.
메모리 효율
memory efficiency지연 평가를 쓴다. 곧 필요해질 때까지 연산을 수행하지 않는다. 또한 질의를 연쇄하고 실행 전에 최적화할 수 있어, 훨씬 효율적인 실행으로 이어진다.
효율적 데이터 저장
columnar storage데이터를 열 지향 형식으로 저장하며, 이는 pandas의 행 기반 저장보다 효율적이다.
쓰임의 편의
ease of use데이터 가공에 SQL을 닮은 구문을 지원해 폭넓은 사용자층이 곧바로 접근할 수 있다. 아울러 pandas와 비슷한 메서드가 많아 pandas 사용자가 옮겨 오기가 매우 쉽다.
네 번째 항목에서 이 총서의 첫 장이 되돌아온다. 열 지향 저장은 DuckDB의 주초였고 Parquet의 성격이었으며, 이제 Polars의 설계 지향이기도 하다. 세 도구가 같은 결정을 공유한다는 사실이, 이들이 서로 잘 붙는 까닭을 미리 알려 준다.
!pip install polars원서에서 쓰는 Polars의 판본은 1.8.2다.
여덟 대의 자동차 — DataFrame 만들기
CREATING A POLARS DATAFRAME
기초에서 시작한다. 파이썬 사전으로 Polars DataFrame을 만든다. 다음 코드는 여섯 개 열과 여덟 개 행을 담은 Polars DataFrame을 만든다.
import polars as pl df = pl.DataFrame( { 'Model': ['Camry','Corolla','RAV4', 'Mustang','F-150','Escape', 'Golf','Tiguan'], 'Year': [1982,1966,1994,1964,1975,2000,… 'Engine_Min':[2.5,1.8,2.0,2.3,2.7,1.5,1… 'Engine_Max':[3.5,2.0,2.5,5.0,5.0,2.5,2… 'AWD':[False,False,True,False,True,True… 'Company': ['Toyota','Toyota','Toyota',… 'Ford','Ford','Volkswagen',… } ) df
원서는 뒤에서 df.rows()의 출력을 여덟 행 모두 온전히 인쇄한다. 그것이 이 데이터의 정본이다.
df.rows() [('Camry', 1982, 2.5, 3.5, False, 'Toyota'), ('Corolla', 1966, 1.8, 2.0, False, 'Toyota'), ('RAV4', 1994, 2.0, 2.5, True, 'Toyota'), ('Mustang', 1964, 2.3, 5.0, False, 'Ford'), ('F-150', 1975, 2.7, 5.0, True, 'Ford'), ('Escape', 2000, 1.5, 2.5, True, 'Ford'), ('Golf', 1974, 1.0, 2.0, True, 'Volkswagen'), ('Tiguan', 2007, 1.4, 2.0, True, 'Volkswagen')]
| Model | Year | Engine_Min | Engine_Max | AWD | Company |
|---|---|---|---|---|---|
| str | i64 | f64 | f64 | bool | str |
| Camry | 1982 | 2.5 | 3.5 | false | Toyota |
| Corolla | 1966 | 1.8 | 2.0 | false | Toyota |
| RAV4 | 1994 | 2.0 | 2.5 | true | Toyota |
| Mustang | 1964 | 2.3 | 5.0 | false | Ford |
| F-150 | 1975 | 2.7 | 5.0 | true | Ford |
| Escape | 2000 | 1.5 | 2.5 | true | Ford |
| Golf | 1974 | 1.0 | 2.0 | true | Volkswagen |
| Tiguan | 2007 | 1.4 | 2.0 | true | Volkswagen |
pandas와 마찬가지로 Jupyter Notebook은 Polars DataFrame을 출력할 때 보기 좋게 정렬해 인쇄한다.
출력을 살펴보면 pandas DataFrame과 비슷하되 두 가지가 다르다.
- 인덱스가 없다 Polars DataFrame에는 인덱스가 없다. 이는 Polars의 설계 철학 가운데 하나다. DataFrame의 인덱스는 쓸모가 없고 좀처럼 필요하지 않다는 판단이다.
- 유형이 함께 인쇄된다 DataFrame의 헤더 아래에 Polars가 각 열의 데이터 유형을 표시한다. str, i64, f64, bool이다.
인덱스를 없앤 것은 사소한 생략이 아니라 선언이다. 행에 이름을 붙이지 않겠다는 것, 곧 행을 번호로 집어 오지 말고 조건으로 골라내라는 요구다. 이 요구가 뒤의 filter() 절에서 되풀이된다.
# 각 열의 데이터 유형을 온전한 이름으로 본다 df.dtypes [String, Int64, Float64, Float64, Boolean, String] # 열 이름을 얻는다 df.columns # ['Model', 'Year', 'Engine_Min', 'Engine_Max', 'AWD', 'Company']
열을 고르는 법 · 표현식이라는 문법
SELECTING COLUMNS
DataFrame의 특정 열을 고르려면 select() 메서드를 쓴다.
df.select( 'Model' )
| Model |
|---|
| str |
| Camry |
| Corolla |
| RAV4 |
| Mustang |
| F-150 |
| Escape |
| Golf |
| Tiguan |
pandas에 익숙하다면 대괄호 인덱싱이 여전히 통하는지 궁금할 것이다. df['Model']은 select() 메서드를 쓰는 것과 똑같이 동작한다. 그러나 Polars 문서는 대괄호 인덱싱 방식이 때로 혼란스럽기 때문에 Polars에서는 안티패턴이라고 명시한다. 따라서 df['Model']이 동작하기는 하지만, 앞으로의 판본에서 이 방식이 제거될 가능성이 있다.
열을 둘 이상 가져와야 한다면 열 이름을 목록으로 감싸거나, 그냥 추가 열 이름을 이어 적는다.
df.select( ['Model','Company'] # 또는 'Model','Company' )
DataFrame에서 문자열 유형(pl.String)의 모든 열을 가져오려면 select() 안에 표현식을 쓴다.
df.select( pl.col(pl.String) )
pl.col(pl.String) 문장을 Polars에서는 표현식(expression)이라 부른다. 이 표현식은 “데이터 유형이 String인 모든 열을 가져와라”로 읽힌다.
| Model | Company |
|---|---|
| str | str |
| Camry | Toyota |
| Corolla | Toyota |
| RAV4 | Toyota |
| Mustang | Ford |
| F-150 | Ford |
| Escape | Ford |
| Golf | Volkswagen |
| Tiguan | Volkswagen |
표현식은 Polars에서 강력하다. 예컨대 여러 표현식을 이어 붙일 수 있다.
df.select( pl.col(['Year','Model','Engine_Max']) .sort_by(['Engine_Max','Year'],descending =… )
첫 표현식이 Year, Model, Engine_Max 세 열을 고른다. 그 결과가 둘째 표현식으로 넘겨져, Engine_Max 열은 오름차순으로 Year 열은 내림차순으로 정렬된다.
| Year | Model | Engine_Max |
|---|---|---|
| i64 | str | f64 |
| 2007 | Tiguan | 2.0 |
| 1974 | Golf | 2.0 |
| 1966 | Corolla | 2.0 |
| 2000 | Escape | 2.5 |
| 1994 | RAV4 | 2.5 |
| 1982 | Camry | 3.5 |
| 1975 | F-150 | 5.0 |
| 1964 | Mustang | 5.0 |
여러 표현식을 목록으로 묶을 수도 있다. 다음은 모든 문자열 열에 Year 열을 더해 나열한다.
df.select( [pl.col(pl.String), 'Year'] )
행을 고르는 법 · 번호가 아니라 조건으로
SELECTING ROWS
Polars DataFrame에서 특정 행을 얻으려면 row() 메서드에 행 번호를 넘긴다. 여러 행을 얻으려면 대괄호 인덱싱을 쓸 수 있으나 권장되지 않는다.
df.row(0) # ('Camry', 1982, 2.5, 3.5, False, 'Toyota') df[1:3] # 둘째와 셋째 행을 돌려준다
대괄호 인덱싱 대신 Polars는 더 명시적인 질의 형태와 데이터 가공 함수의 사용을 권한다. 현실에서는 특정 행 번호가 아니라 어떤 기준에 따라 행을 가져오는 일이 잦다. 그럼에도 Polars는 최소한 지금까지는 대괄호 인덱싱을 계속 지원한다.
pandas처럼 Polars도 head(), tail(), sample() 같은 흔한 메서드를 지원한다.
행을 고르는 데에 Polars가 권하는 것은 filter() 메서드다. Toyota의 자동차가 담긴 모든 행을 고르려면 다음 표현식과 함께 쓴다.
df.filter( pl.col('Company') == 'Toyota' )
| Model | Year | Engine_Min | Engine_Max | AWD | Company |
|---|---|---|---|---|---|
| str | i64 | f64 | f64 | bool | str |
| Camry | 1982 | 2.5 | 3.5 | false | Toyota |
| Corolla | 1966 | 1.8 | 2.0 | false | Toyota |
| RAV4 | 1994 | 2.0 | 2.5 | true | Toyota |
논리 연산자로 여러 조건을 지정할 수도 있다. 원서는 다섯 가지 어법을 나란히 보인다.
# ① Toyota 또는 Ford df.filter( (pl.col('Company') == 'Toyota') | (pl.col('Company') == 'Ford') ) # ② 여러 상표를 맞출 때는 is_in()이 더 간편하다 df.filter( (pl.col('Company').is_in(['Toyota','Ford'])) ) # ③ Toyota이면서 1980년 이후에 나온 것 df.filter( (pl.col('Company') == 'Toyota') & (pl.col('Year') > 1980) ) # ④ Toyota가 아닌 것 — 부정 연산자 df.filter( ~(pl.col('Company') == 'Toyota') ) # ⑤ != 연산자로도 같은 일을 한다 df.filter( (pl.col('Company') != 'Toyota') )
각 조건을 괄호 한 쌍으로 감싸는 것을 잊지 않는다. 파이썬에서 &와 |의 연산 우선순위가 비교 연산자보다 높아, 괄호를 빼면 뜻이 달라진다.
행과 열을 함께 고르기
SELECTING ROWS AND COLUMNS
열을 고르는 select()와 행을 고르는 filter()를 보았으므로, 이제 둘을 이어 붙여 특정 행과 열을 고른다. Toyota의 모든 모델을 얻으려면 filter()와 select()를 연쇄한다.
df.filter( pl.col('Company') == 'Toyota' ).select( 'Model' ) # 여러 열을 고르려면 열 이름을 목록으로 담는다 df.filter( pl.col('Company') == 'Toyota' ).select( ['Model','Year'] )
| Model |
|---|
| str |
| Camry |
| Corolla |
| RAV4 |
연쇄의 순서를 눈여겨볼 만하다. 여과를 먼저 하고 열을 고르는 이 순서가, 뒤에 나올 지연 평가에서 최적화기가 알아서 재배치하는 바로 그 순서다. 사람이 손으로 하는 최적화를 기계가 대신하게 되는 지점이 곧 온다.
Polars 자신의 SQL · SQLContext
USING SQL ON POLARS
Polars의 여러 메서드로 DataFrame에서 행과 열을 고를 수 있지만, SQL로 Polars DataFrame에 직접 질의할 수도 있다. 이는 SQLContext 클래스를 통해 이루어진다. Polars에서 SQLContext는 SQL 구문으로 Polars DataFrame에 SQL 문을 실행하는 길을 제공한다.
ctx = pl.SQLContext(cars = df) ctx.execute("SELECT * FROM cars", eager=True)
회사별 최소 배기량과 최대 배기량의 평균을 구하는 예다.
ctx.execute(''' SELECT Company, AVG(Engine_Min) AS avg_engine_min, AVG(Engine_Max) AS avg_engine_max FROM cars GROUP BY Company; ''', eager=True)
| Company | avg_engine_min | avg_engine_max |
|---|---|---|
| str | f64 | f64 |
| Toyota | 2.100000 | 2.666667 |
| Ford | 2.166667 | 4.166667 |
| Volkswagen | 1.200000 | 2.000000 |
여기까지가 Polars DataFrame에서 행과 열을 고르는 기법이다. 그러나 Polars를 써야 할 가장 설득력 있는 이유는 아직 보지 않았다. 지연 평가다.
때를 기다리는 법 · 지연 평가
UNDERSTANDING LAZY EVALUATION IN POLARS
Polars의 핵심 기능 하나는 지연 평가의 지원이다. 지연 평가는 일련의 연산을 나타내는 질의 계획을 세우되 그것을 곧바로 실행하지 않는 기법이다. 연산은 최종 결과가 명시적으로 요청될 때에야 실행된다. 이 방식은 불필요한 계산을 피하므로, 큰 데이터셋이나 복잡한 변환을 상대할 때 매우 효율적이다.
이 효율 조치가 왜 그토록 중요한지 알려면 먼저 pandas에서 일이 어떻게 되는지 알아야 한다. pandas에서는 통상 read_csv() 함수로 CSV 파일을 pandas DataFrame으로 읽는다.
import pandas as pd df = pd.read_csv('flights.csv') df
pandas의 전형적인 작업은 CSV 파일을 DataFrame으로 적재한 뒤 그 위에서 여과를 수행하는 것이다.
df = pd.read_csv('flights.csv') df = df[(df['MONTH'] == 5) & (df['ORIGIN_AIRPORT'] == 'SFO') & (df['DESTINATION_AIRPORT'] == 'SEA')] df
제1장에서 DuckDB가 같은 낭비를 지적하던 대목이 여기서 되풀이된다. 다만 DuckDB는 SQL과 read_csv_auto()로 그것을 우회했고, Polars는 지연 평가로 우회한다. Polars의 지연 평가에는 두 가지가 있다.
| 구분 | 뜻 | 대표 함수 |
|---|---|---|
| 암묵적 지연 평가 | 본래 지연 평가를 지원하는 함수를 쓰는 경우다. | scan_csv() |
| 명시적 지연 평가 | 본래 지연 평가를 지원하지 않는 함수를 쓰면서, 지연 평가를 쓰도록 명시적으로 만드는 경우다. | read_csv() + .lazy() |
암묵적 지연 평가 — scan과 read의 갈림
read_csv() 함수 대신 scan_csv() 함수를 쓴다. 이 함수는 polars.lazyframe.frame.LazyFrame 유형의 객체를 돌려주는데, 이는 DataFrame에 대한 지연 계산 그래프 또는 질의를 나타낸다. 간단히 말해 scan_csv()로 CSV 파일을 적재하면 파일의 내용이 즉시 적재되지 않는다. 대신 이 함수는 뒤이을 질의를 기다렸다가, CSV 파일의 내용을 적재하기 전에 질의 전체를 최적화한다.
import polars as pl # 지연 — LazyFrame을 돌려준다 q = pl.scan_csv('flights.csv') type(q) # polars.lazyframe.frame.LazyFrame # 즉시 — DataFrame을 돌려준다 df = pl.read_csv('flights.csv') type(df) # polars.dataframe.frame.DataFrame
LazyFrame 객체를 얻었으면 그 위에 질의를 적용한다.
q = pl.scan_csv('flights.csv') q = q.select(['MONTH', 'ORIGIN_AIRPORT','DESTINATION_AIRPORT']) q = q.filter( (pl.col('MONTH') == 5) & (pl.col('ORIGIN_AIRPORT') == 'SFO') & (pl.col('DESTINATION_AIRPORT') == 'SEA'))
select()와 filter() 메서드는 Polars DataFrame에서도, LazyFrame 객체에서도 동작한다. 같은 이름의 메서드가 즉시 실행과 지연 실행 양쪽에 걸쳐 있으므로, 코드의 겉모습만으로는 어느 쪽인지 알 수 없다. 시작점이 scan_인지 read_인지가 유일한 단서다.
가독성을 위해서는 괄호 한 쌍으로 여러 메서드를 이어 붙이는 편이 좋다.
q = (
pl.scan_csv('flights.csv')
.select(['MONTH', 'ORIGIN_AIRPORT','DESTINATION_AIRPORT'])
.filter(
(pl.col('MONTH') == 5) &
(pl.col('ORIGIN_AIRPORT') == 'SFO') &
(pl.col('DESTINATION_AIRPORT') == 'SEA'))
)show_graph() 메서드로 실행 그래프를 볼 수 있다. 이 페이지 머리의 그림이 그 두 판본을 나란히 옮긴 것이다.
# 최적화된 계획. 먼저 CSV를 훑고(그래프 위쪽) 이어서 여과한다(아래쪽) q.show_graph(optimized=True) # 최적화되지 않은 계획. CSV를 훑어 31개 열을 모두 적재한 뒤 여과를 하나씩 수행한다 q.show_graph(optimized=False)
show_graph()는 기본적으로 질의를 최적화된 형태로 인쇄한다. 그러나 q 객체를 그대로 인쇄하면 최적화되지 않은 형태의 그래프가 표시된다. 같은 객체를 두 방법으로 보면 서로 다른 그림이 나온다는 뜻이므로, 어느 쪽을 보고 있는지 늘 확인해야 한다.
질의를 실행하려면 collect() 메서드를 부른다. 이 메서드는 질의의 결과를 Polars DataFrame으로 돌려준다.
q.collect()명시적 지연 평가 — lazy() 한 줄의 위치
앞서 read_csv() 함수로 CSV 파일을 읽으면 Polars가 즉시 실행을 써서 DataFrame을 곧바로 적재한다고 했다. 다음 코드를 보자.
df = (
pl.read_csv('flights.csv')
.select(['MONTH', 'ORIGIN_AIRPORT','DESTINATION_AIRPORT'])
.filter(
(pl.col('MONTH') == 5) &
(pl.col('ORIGIN_AIRPORT') == 'SFO') &
(pl.col('DESTINATION_AIRPORT') == 'SEA')
)
)
dfCSV가 적재된 뒤의 모든 질의가 최적화되도록 하려면, read_csv() 함수 바로 뒤에 lazy() 메서드를 써서 지연 평가를 쓰겠다고 명시한다.
q = (
pl.read_csv('flights.csv')
.lazy()
.select(['MONTH', 'ORIGIN_AIRPORT','DESTINATION_AIRPORT'])
.filter(
(pl.col('MONTH') == 5) &
(pl.col('ORIGIN_AIRPORT') == 'SFO') &
(pl.col('DESTINATION_AIRPORT') == 'SEA')
)
)
df = q.collect()
display(df)lazy() 함수는 LazyFrame 객체를 돌려주며, 그것으로 select(), filter() 같은 메서드를 이어 붙일 수 있다. 이제 모든 질의가 실행 전에 최적화된다.
다만 이 명시적 방식에는 한계가 남는다. read_csv()가 이미 파일을 다 읽은 뒤에 lazy()가 붙으므로, 최적화되는 것은 그 뒤에 이어지는 질의들이다. 파일을 읽는 그 자체의 낭비는 scan_csv()로만 없앤다. lazy()가 놓이는 자리를 눈여겨보아야 하는 까닭이다.
두 집을 잇는 다리 · Arrow
QUERYING POLARS DATAFRAMES USING DUCKDB
쓰임이 쉽다고는 해도 Polars DataFrame을 다루는 일에는 여전히 약간의 연습이 필요하고, 초심자에게는 학습 곡선이 비교적 급하다. 그런데 개발자 대다수는 이미 SQL에 익숙하다. 그렇다면 DataFrame을 SQL로 직접 다루는 편이 더 편하지 않겠는가. 이 방식을 쓰면 두 세계의 좋은 점을 모두 갖는다.
- Polars의 함수 여러 함수를 모두 써서 Polars DataFrame에 질의할 수 있다.
- SQL의 자연스러움 원하는 데이터를 뽑아내는 데 SQL이 훨씬 자연스럽고 쉬운 경우에는 SQL을 쓸 수 있다.
반가운 소식은 DuckDB가 Apache Arrow를 통해 Polars DataFrame을 지원한다는 것이다. 곧 SQL로 Polars DataFrame에 직접 질의할 수 있다.
Apache Arrow는 메모리 내 분석을 위한 개발 플랫폼이다. 빅데이터 시스템이 데이터를 빠르게 저장하고 처리하고 옮길 수 있게 하는 기술의 집합을 담는다. PyArrow는 Arrow의 파이썬 구현이다.
이 다리가 놓일 수 있는 까닭은 앞에서 이미 나왔다. DuckDB도 Polars도 데이터를 열 단위로 쥐고 있기 때문이다. 저장 형식이 같으므로 복사가 아니라 가리키기로 주고받는다. 제1장에서 pandas DataFrame을 이름만 불러 질의했던 그 일이, 여기서는 Arrow라는 공용 규격 위에서 되풀어진다.
pip install pyarrow
설치는 Jupyter Notebook에서도, 터미널이나 명령 프롬프트에서도 할 수 있다. Jupyter Notebook에서 설치한 뒤에는 커널을 다시 시작하는 것을 잊지 않는다.
sql() 함수
df의 모든 행을 고르려면 duckdb 모듈의 sql() 함수를 쓴다.
import duckdb result = duckdb.sql(''' SELECT * FROM df ''') result
DuckDBPyRelation 객체는 DuckDB의 관계형 API의 일부이며 질의를 구성하는 데 쓸 수 있다. 이 객체를 Polars DataFrame으로 바꾸려면 pl() 메서드를 쓴다.
result.pl() # Polars DataFrame으로 result.df() # pandas DataFrame으로
DuckDBPyRelation 객체로는 여러 일을 할 수 있다. describe() 메서드로 DataFrame의 각 열에 대한 기초 통계, 예컨대 최소·최대·중앙값·개수를 만들어 낸다. describe()의 결과 또한 DuckDBPyRelation 객체이므로, 원하면 Polars나 pandas DataFrame으로 바꿀 수 있다.
result.describe() # 기초 통계 result.order('Year') # 연도 오름차순 result.order('Year DESC') # 연도 내림차순 result.apply('min', 'Year') # Year 열의 최솟값
| Model | Year | Company |
|---|---|---|
| Mustang | 1964 | Ford |
| Corolla | 1966 | Toyota |
| Golf | 1974 | Volkswagen |
| F-150 | 1975 | Ford |
| Camry | 1982 | Toyota |
| RAV4 | 1994 | Toyota |
| Escape | 2000 | Ford |
| Tiguan | 2007 | Volkswagen |
DuckDBPyRelation 객체의 여러 메서드로 데이터를 뽑을 수 있지만, 같은 일을 SQL로 하는 편이 더 쉬운 경우가 늘 있다. 예컨대 회사 다음에 모델 순으로 행을 정렬하는 일은 SQL로 아주 쉽다.
duckdb.sql(''' SELECT Company, Model FROM df ORDER BY Company, Model ''').pl()
| Company | Model |
|---|---|
| str | str |
| Ford | Escape |
| Ford | F-150 |
| Ford | Mustang |
| Toyota | Camry |
| Toyota | Corolla |
| Toyota | RAV4 |
| Volkswagen | Golf |
| Volkswagen | Tiguan |
회사별 모델 수를 세려면 SQL의 GROUP BY 문을 쓴다. 같은 질의를 Polars로도 할 수 있는데, 두 표현을 나란히 놓고 보면 이 장이 왜 두 도구를 함께 쓰라고 권하는지가 분명해진다.
# SQL 쪽 duckdb.sql(''' SELECT Company, count(Model) as count FROM df GROUP BY Company ''').pl() # Polars 쪽 result.pl().select( pl.col('Company').value_counts() ).unnest('Company')
| Company | count |
|---|---|
| str | i64 |
| Toyota | 3 |
| Ford | 3 |
| Volkswagen | 2 |
SQL 없이 SQL을 하다 · 관계형 API
USING THE DUCKDBPYRELATION OBJECT
앞 절들에서 DuckDBPyRelation 객체가 여러 번 언급되었다. 이 객체는 데이터베이스에서 데이터를 뽑는 질의를 구성하는 또 하나의 길이다. 통상 SQL 질의에서, 또는 연결 객체에서 곧바로 만든다.
먼저 DuckDB 연결을 만들고 그 연결로 세 테이블 customers, products, sales를 만든다.
import duckdb conn = duckdb.connect() conn.execute(''' CREATE TABLE customers (customer_id INTEGER PRIMARY KEY, name STRING) ''') conn.execute(''' CREATE TABLE products (product_id INTEGER PRIMARY KEY, product_name STRING) ''') conn.execute(''' CREATE TABLE sales (customer_id INTEGER, product_id INTEGER, qty INTEGER, PRIMARY KEY(customer_id,product_id)) ''')
테이블이 만들어졌으므로 conn 객체의 table() 메서드로 특정 테이블을 적재한다. 그 결과가 duckdb.DuckDBPyRelation 객체이며, 앞에서 익힌 대로 pandas나 Polars DataFrame으로 바꿀 수 있다.
customers_relation = conn.table('customers') # 세 행을 넣는다 customers_relation.insert([1, 'Alice']) customers_relation.insert([2, 'Bob']) customers_relation.insert([3, 'Charlie']) products_relation = conn.table('products') products_relation.insert([10, 'Paperclips']) products_relation.insert([20, 'Staple']) products_relation.insert([30, 'Notebook']) sales_relation = conn.table("sales") sales_relation.insert([1,20,1]) sales_relation.insert([1,10,2]) sales_relation.insert([2,30,7]) sales_relation.insert([3,10,3]) sales_relation.insert([3,20,2])
조인 — join() 메서드
세 테이블을 나타내는 세 개의 DuckDBPyRelation 객체가 있으므로, join() 메서드로 테이블 사이의 조인을 수행한다.
result = customers_relation.join( sales_relation, condition = "customer_id", how = "inner" ).join( products_relation, condition = "product_id", how = "inner" )
| customer_id | name | product_id | qty | product_name |
|---|---|---|---|---|
| 1 | Alice | 20 | 1 | Staple |
| 1 | Alice | 10 | 2 | Paperclips |
| 2 | Bob | 30 | 7 | Notebook |
| 3 | Charlie | 10 | 3 | Paperclips |
| 3 | Charlie | 20 | 2 | Staple |
여과 — 두 가지 길
조인을 수행한 뒤에는 그 결과에서 원하는 행을 filter() 메서드로 뽑는다. 또는 execute() 메서드에 SQL 문을 넘긴다. 같은 결과에 이르는 두 길이 나란히 놓인다.
# 관계형 API 쪽 result.filter('customer_id = 1') # SQL 쪽 — result를 테이블처럼 참조한다 conn.execute(''' SELECT * FROM result WHERE customer_id = 1 ''').pl()
| customer_id | name | product_id | qty | product_name |
|---|---|---|---|---|
| 1 | Alice | 20 | 1 | Staple |
| 1 | Alice | 10 | 2 | Paperclips |
집계 — aggregate() 메서드
DuckDBPyRelation 객체로 집계도 수행한다. 모든 고객의 구매를 합산하려면 aggregate() 메서드를 쓴다. 첫 인수는 집계 표현식을 받고, 둘째 인수는 묶음 표현식을 받는다.
result.aggregate('customer_id, MAX(name) AS Name… 'SUM(qty) as "Total Qty"', 'customer_id')
| customer_id | Name | Total Qty |
|---|---|---|
| 1 | Alice | 3 |
| 2 | Bob | 7 |
| 3 | Charlie | 5 |
이 집계 함수는 다음 GROUP BY 문과 동일하다.
SELECT customer_id as 'Customer ID', MAX(name) AS Name, sum(qty) as 'Total Qty' FROM result GROUP BY customer_id
열 사영과 행 제한 — project()와 limit()
DuckDBPyRelation 객체로 표시할 특정 열을 고르려면 project() 메서드를 쓴다. 돌려주는 행 수를 제한하려면 limit() 메서드를 쓴다. 셋째 행에서 시작해 다음 세 행을 표시하려면 행 수 다음에 시작 위치를 지정한다.
result.project('name, qty, product_name') result.limit(3) # 처음 세 행 result.limit(3,2) # 세 행, 위치 2(셋째 행)에서 시작
| name | qty | product_name |
|---|---|---|
| Alice | 1 | Staple |
| Alice | 2 | Paperclips |
| Bob | 7 | Notebook |
| Charlie | 3 | Paperclips |
| Charlie | 2 | Staple |
| name | qty | product_name |
|---|---|---|
| Bob | 7 | Notebook |
| Charlie | 3 | Paperclips |
| Charlie | 2 | Staple |
이 절의 메서드 이름들을 늘어놓으면 낯익은 목록이 된다. join, filter, aggregate, project, limit. 이는 관계 대수의 연산 이름들이며, SQL이 그 위에 얹은 문법을 걷어 낸 맨 골격이다. SQL을 쓰지 않고도 SQL이 하는 일을 하는 길이 여기 열려 있다는 뜻이다. 다만 원서의 태도는 분명하다. 둘 가운데 하나를 고르라는 것이 아니라, 쉬운 쪽을 그때그때 쓰라는 것이다.