답사기 · 제4장 정독

미루는 것이
아끼는 것이다

이 장의 주인공은 부지런함이 아니라 게으름이다. 580만 행을 먼저 다 읽어 놓고 그중 몇 행만 골라내는 일이 부지런함이라면, 무엇을 골라낼지 다 듣고 나서야 비로소 파일에 손을 대는 일은 게으름이다. 그리고 여기서는 후자가 이긴다.

원전
DuckDB: Up and Running
지은이
Wei-Meng Lee
펴낸곳
O'Reilly Media, 2024
대상
Chapter 4. Using DuckDB with Polars
show_graph(optimized=False) CSV SCAN — 31개 열 전부 π 31/31 · σ — SELECT — 세 열만 남긴다 FILTER — MONTH == 5 FILTER — ORIGIN == 'SFO' FILTER — DEST == 'SEA' 읽고 나서 하나씩 걸러 낸다 show_graph(optimized=True) CSV SCAN — 필요한 세 열만 π 3/31 · σ 조건이 이미 밀려 내려왔다 FILTER — 세 조건을 한 번에 무엇을 물을지 다 들은 뒤에 읽기 시작한다. 그래서 읽는 양 자체가 줄어든다. · 열 31개 → 3개 (projection pushdown) · 여과 4단 → 1단 (predicate pushdown) · 실행 시점 = collect() 를 부르는 순간

답사의 요지. 제1장이 기둥의 장, 제2장이 문의 장, 제3장이 칸의 장이었다면 제4장은 때의 장이다. 같은 질의가 두 개의 그림으로 갈리는데, 차이는 연산의 우열이 아니라 언제 읽기 시작하는가에 있다. 왼쪽은 서른한 개 열을 다 펼쳐 놓고 조건을 하나씩 대어 보고, 오른쪽은 조건을 다 듣고 나서 세 개 열만 펼친다. 게으름이 미덕이 되는 드문 자리다.

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의 설계 지향이기도 하다. 세 도구가 같은 결정을 공유한다는 사실이, 이들이 서로 잘 붙는 까닭을 미리 알려 준다.

Jupyter Notebook설치
!pip install polars
일러두기 — 판본

원서에서 쓰는 Polars의 판본은 1.8.2다.

여덟 대의 자동차 — DataFrame 만들기

CREATING A POLARS DATAFRAME

기초에서 시작한다. 파이썬 사전으로 Polars DataFrame을 만든다. 다음 코드는 여섯 개 열과 여덟 개 행을 담은 Polars DataFrame을 만든다.

Python원서 지면 그대로
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
사전 리터럴은 여섯 줄 모두 오른쪽 끝이 잘려 있다. 특히 Company 목록은 잘린 조각만 보면 rows() 출력과 어긋나 보이므로, 이 리터럴을 데이터의 근거로 삼을 수 없다

원서는 뒤에서 df.rows()의 출력을 여덟 행 모두 온전히 인쇄한다. 그것이 이 데이터의 정본이다.

Pythonrows() — 원서 인쇄분 전문
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')]
행은 튜플의 목록으로 돌아온다. 이 페이지의 모든 표와 산출값의 근거가 이 여덟 줄이다
ModelYearEngine_MinEngine_MaxAWDCompany
stri64f64f64boolstr
Camry19822.53.5falseToyota
Corolla19661.82.0falseToyota
RAV419942.02.5trueToyota
Mustang19642.35.0falseFord
F-15019752.75.0trueFord
Escape20001.52.5trueFord
Golf19741.02.0trueVolkswagen
Tiguan20071.42.0trueVolkswagen
도판 4-1 · 여섯 열 여덟 행의 Polars DataFrame. 헤더 아래 회색 줄이 Polars가 표시하는 열별 데이터 유형이다
일러두기

pandas와 마찬가지로 Jupyter Notebook은 Polars DataFrame을 출력할 때 보기 좋게 정렬해 인쇄한다.

출력을 살펴보면 pandas DataFrame과 비슷하되 두 가지가 다르다.

  • 인덱스가 없다 Polars DataFrame에는 인덱스가 없다. 이는 Polars의 설계 철학 가운데 하나다. DataFrame의 인덱스는 쓸모가 없고 좀처럼 필요하지 않다는 판단이다.
  • 유형이 함께 인쇄된다 DataFrame의 헤더 아래에 Polars가 각 열의 데이터 유형을 표시한다. str, i64, f64, bool이다.

인덱스를 없앤 것은 사소한 생략이 아니라 선언이다. 행에 이름을 붙이지 않겠다는 것, 곧 행을 번호로 집어 오지 말고 조건으로 골라내라는 요구다. 이 요구가 뒤의 filter() 절에서 되풀이된다.

Pythondtypes · columns
# 각 열의 데이터 유형을 온전한 이름으로 본다
df.dtypes
[String, Int64, Float64, Float64, Boolean, String]

# 열 이름을 얻는다
df.columns
# ['Model', 'Year', 'Engine_Min', 'Engine_Max', 'AWD', 'Company']

열을 고르는 법 · 표현식이라는 문법

SELECTING COLUMNS

DataFrame의 특정 열을 고르려면 select() 메서드를 쓴다.

Pythonselect()
df.select(
    'Model'
)
Model
str
Camry
Corolla
RAV4
Mustang
F-150
Escape
Golf
Tiguan
도판 4-2 · Model 열만 인쇄된 DataFrame
도움말 — 대괄호는 안티패턴이다

pandas에 익숙하다면 대괄호 인덱싱이 여전히 통하는지 궁금할 것이다. df['Model']select() 메서드를 쓰는 것과 똑같이 동작한다. 그러나 Polars 문서는 대괄호 인덱싱 방식이 때로 혼란스럽기 때문에 Polars에서는 안티패턴이라고 명시한다. 따라서 df['Model']이 동작하기는 하지만, 앞으로의 판본에서 이 방식이 제거될 가능성이 있다.

열을 둘 이상 가져와야 한다면 열 이름을 목록으로 감싸거나, 그냥 추가 열 이름을 이어 적는다.

Python여러 열
df.select(
    ['Model','Company']        # 또는 'Model','Company'
)

DataFrame에서 문자열 유형(pl.String)의 모든 열을 가져오려면 select() 안에 표현식을 쓴다.

Python표현식
df.select(
    pl.col(pl.String)
)
도움말 — 표현식이란

pl.col(pl.String) 문장을 Polars에서는 표현식(expression)이라 부른다. 이 표현식은 “데이터 유형이 String인 모든 열을 가져와라”로 읽힌다.

ModelCompany
strstr
CamryToyota
CorollaToyota
RAV4Toyota
MustangFord
F-150Ford
EscapeFord
GolfVolkswagen
TiguanVolkswagen
도판 4-3 · 표현식으로 골라낸 두 문자열 열. 열 이름을 적지 않고 유형만으로 골랐다

표현식은 Polars에서 강력하다. 예컨대 여러 표현식을 이어 붙일 수 있다.

Python표현식 연결
df.select(
    pl.col(['Year','Model','Engine_Max'])
      .sort_by(['Engine_Max','Year'],descending =
)

첫 표현식이 Year, Model, Engine_Max 세 열을 고른다. 그 결과가 둘째 표현식으로 넘겨져, Engine_Max 열은 오름차순으로 Year 열은 내림차순으로 정렬된다.

YearModelEngine_Max
i64strf64
2007Tiguan2.0
1974Golf2.0
1966Corolla2.0
2000Escape2.5
1994RAV42.5
1982Camry3.5
1975F-1505.0
1964Mustang5.0
도판 4-4 · 원서 도판은 이미지로만 실려 있어, 본문의 정렬 설명과 rows() 출력으로 검토자가 재구성한 순서다

여러 표현식을 목록으로 묶을 수도 있다. 다음은 모든 문자열 열에 Year 열을 더해 나열한다.

Python표현식과 이름 섞기
df.select(
    [pl.col(pl.String), 'Year']
)
유형으로 고른 것과 이름으로 고른 것이 한 목록에 나란히 놓인다

행을 고르는 법 · 번호가 아니라 조건으로

SELECTING ROWS

Polars DataFrame에서 특정 행을 얻으려면 row() 메서드에 행 번호를 넘긴다. 여러 행을 얻으려면 대괄호 인덱싱을 쓸 수 있으나 권장되지 않는다.

Pythonrow() · 슬라이스
df.row(0)
# ('Camry', 1982, 2.5, 3.5, False, 'Toyota')

df[1:3]     # 둘째와 셋째 행을 돌려준다
도움말 — 왜 권장하지 않는가

대괄호 인덱싱 대신 Polars는 더 명시적인 질의 형태와 데이터 가공 함수의 사용을 권한다. 현실에서는 특정 행 번호가 아니라 어떤 기준에 따라 행을 가져오는 일이 잦다. 그럼에도 Polars는 최소한 지금까지는 대괄호 인덱싱을 계속 지원한다.

pandas처럼 Polars도 head(), tail(), sample() 같은 흔한 메서드를 지원한다.

행을 고르는 데에 Polars가 권하는 것은 filter() 메서드다. Toyota의 자동차가 담긴 모든 행을 고르려면 다음 표현식과 함께 쓴다.

Pythonfilter()
df.filter(
    pl.col('Company') == 'Toyota'
)
ModelYearEngine_MinEngine_MaxAWDCompany
stri64f64f64boolstr
Camry19822.53.5falseToyota
Corolla19661.82.0falseToyota
RAV419942.02.5trueToyota
도판 4-5 · Toyota의 자동차만 담긴 DataFrame

논리 연산자로 여러 조건을 지정할 수도 있다. 원서는 다섯 가지 어법을 나란히 보인다.

Python조건의 다섯 어법
# ① 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()를 연쇄한다.

Pythonfilter → select
df.filter(
    pl.col('Company') == 'Toyota'
).select(
    'Model'
)

# 여러 열을 고르려면 열 이름을 목록으로 담는다
df.filter(
    pl.col('Company') == 'Toyota'
).select(
    ['Model','Year']
)
Model
str
Camry
Corolla
RAV4
도판 4-6 · Toyota의 모든 모델

연쇄의 순서를 눈여겨볼 만하다. 여과를 먼저 하고 열을 고르는 이 순서가, 뒤에 나올 지연 평가에서 최적화기가 알아서 재배치하는 바로 그 순서다. 사람이 손으로 하는 최적화를 기계가 대신하게 되는 지점이 곧 온다.

Polars 자신의 SQL · SQLContext

USING SQL ON POLARS

Polars의 여러 메서드로 DataFrame에서 행과 열을 고를 수 있지만, SQL로 Polars DataFrame에 직접 질의할 수도 있다. 이는 SQLContext 클래스를 통해 이루어진다. Polars에서 SQLContext는 SQL 구문으로 Polars DataFrame에 SQL 문을 실행하는 길을 제공한다.

PythonSQLContext
ctx = pl.SQLContext(cars = df)
ctx.execute("SELECT * FROM cars", eager=True)
도판 4-7 · SQLContext는 이름 붙은 매개변수(여기서는 cars)를 받고 그 값에 Polars DataFrame을 놓는다. 결과는 여덟 행 전부다

회사별 최소 배기량과 최대 배기량의 평균을 구하는 예다.

Python + SQLGROUP BY
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)
Companyavg_engine_minavg_engine_max
strf64f64
Toyota2.1000002.666667
Ford2.1666674.166667
Volkswagen1.2000002.000000
도판 4-8 · 회사별 평균 배기량. 값은 rows() 출력에서 검토자가 계산했으며, 각 값에 산식을 붙여 두었다. 행의 순서는 GROUP BY의 결과이므로 보장되지 않는다

여기까지가 Polars DataFrame에서 행과 열을 고르는 기법이다. 그러나 Polars를 써야 할 가장 설득력 있는 이유는 아직 보지 않았다. 지연 평가다.

때를 기다리는 법 · 지연 평가

UNDERSTANDING LAZY EVALUATION IN POLARS

Polars의 핵심 기능 하나는 지연 평가의 지원이다. 지연 평가는 일련의 연산을 나타내는 질의 계획을 세우되 그것을 곧바로 실행하지 않는 기법이다. 연산은 최종 결과가 명시적으로 요청될 때에야 실행된다. 이 방식은 불필요한 계산을 피하므로, 큰 데이터셋이나 복잡한 변환을 상대할 때 매우 효율적이다.

이 효율 조치가 왜 그토록 중요한지 알려면 먼저 pandas에서 일이 어떻게 되는지 알아야 한다. pandas에서는 통상 read_csv() 함수로 CSV 파일을 pandas DataFrame으로 읽는다.

Pythonpandas의 방식
import pandas as pd

df = pd.read_csv('flights.csv')
df
도판 4-9 · CSV 파일이 크면 모든 행을 pandas DataFrame으로 적재하는 데 오랜 시간과 많은 메모리를 쓴다. flights.csv는 580만 행이 넘는다

pandas의 전형적인 작업은 CSV 파일을 DataFrame으로 적재한 뒤 그 위에서 여과를 수행하는 것이다.

Python적재 후 여과
df = pd.read_csv('flights.csv')
df = df[(df['MONTH'] == 5) &
        (df['ORIGIN_AIRPORT'] == 'SFO') &
        (df['DESTINATION_AIRPORT'] == 'SEA')]
df
부분집합만 걸러 내기 위해 CSV 파일 전체를 메모리에 올려야 하므로 비효율적이다

제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 파일의 내용을 적재하기 전에 질의 전체를 최적화한다.

Pythonscan_csv 대 read_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
read_csv()는 pandas DataFrame과 비슷한 객체를 돌려주며 즉시 실행(eager execution) 방식이다. 곧 다른 질의를 수행하기 전에 데이터셋 전체를 DataFrame으로 곧바로 적재한다

LazyFrame 객체를 얻었으면 그 위에 질의를 적용한다.

PythonLazyFrame에 질의 쌓기
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_인지가 유일한 단서다.

가독성을 위해서는 괄호 한 쌍으로 여러 메서드를 이어 붙이는 편이 좋다.

Python연쇄 형식
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() 메서드로 실행 그래프를 볼 수 있다. 이 페이지 머리의 그림이 그 두 판본을 나란히 옮긴 것이다.

Pythonshow_graph()
# 최적화된 계획. 먼저 CSV를 훑고(그래프 위쪽) 이어서 여과한다(아래쪽)
q.show_graph(optimized=True)

# 최적화되지 않은 계획. CSV를 훑어 31개 열을 모두 적재한 뒤 여과를 하나씩 수행한다
q.show_graph(optimized=False)
도판 4-10 · 4-11 · 두 그래프의 차이가 이 절의 요점이다
일러두기 — 기본값의 비대칭

show_graph()는 기본적으로 질의를 최적화된 형태로 인쇄한다. 그러나 q 객체를 그대로 인쇄하면 최적화되지 않은 형태의 그래프가 표시된다. 같은 객체를 두 방법으로 보면 서로 다른 그림이 나온다는 뜻이므로, 어느 쪽을 보고 있는지 늘 확인해야 한다.

질의를 실행하려면 collect() 메서드를 부른다. 이 메서드는 질의의 결과를 Polars DataFrame으로 돌려준다.

Pythoncollect()
q.collect()
도판 4-12 · collect()가 돌려준 DataFrame. 이 한 줄이 앞서 쌓아 둔 모든 계획을 실제 계산으로 바꾼다

명시적 지연 평가 — lazy() 한 줄의 위치

앞서 read_csv() 함수로 CSV 파일을 읽으면 Polars가 즉시 실행을 써서 DataFrame을 곧바로 적재한다고 했다. 다음 코드를 보자.

Python즉시 실행 — 계단식으로 이어진다
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')
    )
)
df
CSV를 적재한 뒤 열 선택과 행 여과가 이루어진다. 이 질의들은 계단식으로 이어져 차례로 수행된다. read_csv()가 지연 평가를 암묵적으로 지원하지 않기 때문이다

CSV가 적재된 뒤의 모든 질의가 최적화되도록 하려면, read_csv() 함수 바로 뒤에 lazy() 메서드를 써서 지연 평가를 쓰겠다고 명시한다.

Pythonlazy() 삽입
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

Apache Arrow는 메모리 내 분석을 위한 개발 플랫폼이다. 빅데이터 시스템이 데이터를 빠르게 저장하고 처리하고 옮길 수 있게 하는 기술의 집합을 담는다. PyArrow는 Arrow의 파이썬 구현이다.

이 다리가 놓일 수 있는 까닭은 앞에서 이미 나왔다. DuckDB도 Polars도 데이터를 열 단위로 쥐고 있기 때문이다. 저장 형식이 같으므로 복사가 아니라 가리키기로 주고받는다. 제1장에서 pandas DataFrame을 이름만 불러 질의했던 그 일이, 여기서는 Arrow라는 공용 규격 위에서 되풀어진다.

Shell설치
pip install pyarrow
도움말

설치는 Jupyter Notebook에서도, 터미널이나 명령 프롬프트에서도 할 수 있다. Jupyter Notebook에서 설치한 뒤에는 커널을 다시 시작하는 것을 잊지 않는다.

sql() 함수

df의 모든 행을 고르려면 duckdb 모듈의 sql() 함수를 쓴다.

Python + SQLduckdb.sql()
import duckdb

result = duckdb.sql('''
    SELECT *
    FROM df
''')
result
도판 4-13 · sql() 함수는 duckdb.DuckDBPyRelation 객체를 돌려주며, Jupyter Notebook에서 인쇄되면 표로 표시된다

DuckDBPyRelation 객체는 DuckDB의 관계형 API의 일부이며 질의를 구성하는 데 쓸 수 있다. 이 객체를 Polars DataFrame으로 바꾸려면 pl() 메서드를 쓴다.

Python변환
result.pl()      # Polars DataFrame으로
result.df()      # pandas DataFrame으로

DuckDBPyRelation 객체로는 여러 일을 할 수 있다. describe() 메서드로 DataFrame의 각 열에 대한 기초 통계, 예컨대 최소·최대·중앙값·개수를 만들어 낸다. describe()의 결과 또한 DuckDBPyRelation 객체이므로, 원하면 Polars나 pandas DataFrame으로 바꿀 수 있다.

Pythondescribe · order · apply
result.describe()          # 기초 통계

result.order('Year')        # 연도 오름차순
result.order('Year DESC')   # 연도 내림차순

result.apply('min', 'Year')  # Year 열의 최솟값
도판 4-14 · 4-15 · 4-16 · describe()의 출력은 통계량이 많아 지면 도판으로만 실려 있어 이 페이지에서는 수치를 옮기지 않았다
ModelYearCompany
Mustang1964Ford
Corolla1966Toyota
Golf1974Volkswagen
F-1501975Ford
Camry1982Toyota
RAV41994Toyota
Escape2000Ford
Tiguan2007Volkswagen
도판 4-15 · order('Year')의 결과 순서. 검토자가 rows() 출력에서 정렬한 것이며, 최솟값이 1964이므로 도판 4-16의 apply('min','Year') 결과도 1964다

DuckDBPyRelation 객체의 여러 메서드로 데이터를 뽑을 수 있지만, 같은 일을 SQL로 하는 편이 더 쉬운 경우가 늘 있다. 예컨대 회사 다음에 모델 순으로 행을 정렬하는 일은 SQL로 아주 쉽다.

Python + SQLORDER BY
duckdb.sql('''
    SELECT Company, Model
    FROM df
    ORDER BY Company, Model
''').pl()
CompanyModel
strstr
FordEscape
FordF-150
FordMustang
ToyotaCamry
ToyotaCorolla
ToyotaRAV4
VolkswagenGolf
VolkswagenTiguan
도판 4-17 · 회사와 모델로 정렬한 결과. 검토자가 산출한 순서다

회사별 모델 수를 세려면 SQL의 GROUP BY 문을 쓴다. 같은 질의를 Polars로도 할 수 있는데, 두 표현을 나란히 놓고 보면 이 장이 왜 두 도구를 함께 쓰라고 권하는지가 분명해진다.

Python같은 일, 두 어법
# 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')
Companycount
stri64
Toyota3
Ford3
Volkswagen2
도판 4-18 · 회사별 모델 수. GROUP BY의 결과이므로 행의 순서는 보장되지 않는다

SQL 없이 SQL을 하다 · 관계형 API

USING THE DUCKDBPYRELATION OBJECT

앞 절들에서 DuckDBPyRelation 객체가 여러 번 언급되었다. 이 객체는 데이터베이스에서 데이터를 뽑는 질의를 구성하는 또 하나의 길이다. 통상 SQL 질의에서, 또는 연결 객체에서 곧바로 만든다.

먼저 DuckDB 연결을 만들고 그 연결로 세 테이블 customers, products, sales를 만든다.

Python + SQL세 테이블
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))
''')
sales 테이블은 customer_id와 product_id를 묶은 복합 기본키를 가진다. 같은 고객이 같은 상품을 두 번 살 수 없다는 뜻이다

테이블이 만들어졌으므로 conn 객체의 table() 메서드로 특정 테이블을 적재한다. 그 결과가 duckdb.DuckDBPyRelation 객체이며, 앞에서 익힌 대로 pandas나 Polars DataFrame으로 바꿀 수 있다.

Pythontable() · insert()
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() 메서드로 테이블 사이의 조인을 수행한다.

Pythonjoin()
result = customers_relation.join(
    sales_relation,
    condition = "customer_id",
    how = "inner"
).join(
    products_relation,
    condition = "product_id",
    how = "inner"
)
customers 테이블이 sales에 조인되고, 그 결과가 다시 products에 조인된다. 제3장의 SQL 다중 조인과 같은 일을 메서드 연쇄로 표현한 것이다
customer_idnameproduct_idqtyproduct_name
1Alice201Staple
1Alice102Paperclips
2Bob307Notebook
3Charlie103Paperclips
3Charlie202Staple
도판 4-19 · 조인 결과. sales의 다섯 행이 그대로 다섯 행이 된다. 검토자가 insert된 값에서 산출했다

여과 — 두 가지 길

조인을 수행한 뒤에는 그 결과에서 원하는 행을 filter() 메서드로 뽑는다. 또는 execute() 메서드에 SQL 문을 넘긴다. 같은 결과에 이르는 두 길이 나란히 놓인다.

Pythonfilter() 대 execute()
# 관계형 API 쪽
result.filter('customer_id = 1')

# SQL 쪽 — result를 테이블처럼 참조한다
conn.execute('''
    SELECT *
    FROM result
    WHERE customer_id = 1
''').pl()
customer_idnameproduct_idqtyproduct_name
1Alice201Staple
1Alice102Paperclips
도판 4-20 · Alice(고객 번호 1)가 산 상품

집계 — aggregate() 메서드

DuckDBPyRelation 객체로 집계도 수행한다. 모든 고객의 구매를 합산하려면 aggregate() 메서드를 쓴다. 첫 인수는 집계 표현식을 받고, 둘째 인수는 묶음 표현식을 받는다.

Pythonaggregate()
result.aggregate('customer_id, MAX(name) AS Name
                 'SUM(qty) as "Total Qty"',
                 'customer_id')
customer_idNameTotal Qty
1Alice3
2Bob7
3Charlie5
도판 4-21 · 고객별로 구매한 품목의 총 수량. 검토자가 insert된 값에서 산출했다

이 집계 함수는 다음 GROUP BY 문과 동일하다.

SQL동일한 표현
SELECT customer_id as 'Customer ID', MAX(name) AS Name,
       sum(qty) as 'Total Qty'
FROM result
GROUP BY customer_id
여기서 MAX(name)이 쓰인 것은 이름을 집계하려는 뜻이 아니라, GROUP BY 절에 없는 열을 선택 목록에 넣기 위한 관행적 우회다

열 사영과 행 제한 — project()와 limit()

DuckDBPyRelation 객체로 표시할 특정 열을 고르려면 project() 메서드를 쓴다. 돌려주는 행 수를 제한하려면 limit() 메서드를 쓴다. 셋째 행에서 시작해 다음 세 행을 표시하려면 행 수 다음에 시작 위치를 지정한다.

Pythonproject() · limit()
result.project('name, qty, product_name')

result.limit(3)        # 처음 세 행
result.limit(3,2)      # 세 행, 위치 2(셋째 행)에서 시작
nameqtyproduct_name
Alice1Staple
Alice2Paperclips
Bob7Notebook
Charlie3Paperclips
Charlie2Staple
도판 4-22 · 4-23 · project()로 고른 세 열의 전체 다섯 행. 음영이 든 세 행이 limit(3)의 결과다
nameqtyproduct_name
Bob7Notebook
Charlie3Paperclips
Charlie2Staple
도판 4-24 · limit(3,2)의 결과. 위치 2인 셋째 행에서 시작해 세 행을 표시한다

이 절의 메서드 이름들을 늘어놓으면 낯익은 목록이 된다. join, filter, aggregate, project, limit. 이는 관계 대수의 연산 이름들이며, SQL이 그 위에 얹은 문법을 걷어 낸 맨 골격이다. SQL을 쓰지 않고도 SQL이 하는 일을 하는 길이 여기 열려 있다는 뜻이다. 다만 원서의 태도는 분명하다. 둘 가운데 하나를 고르라는 것이 아니라, 쉬운 쪽을 그때그때 쓰라는 것이다.

맺음말 · SUMMARY

두 라이브러리를 함께 쓰면 두 세계의 좋은 점을 모두 갖는다. 이미 익숙한 메서드로 데이터를 다루면서, 익숙한 질의 언어로 효율적인 DataFrame에 질의한다.

제4장에서는 Polars DataFrame 라이브러리를 익혔다. 행과 열을 뽑아내는 기초에서 시작해, Polars에서 지연 평가가 어떻게 동작하는지에 대한 설명으로 이어졌다. 더 중요한 것은 DuckDB와 Polars를 함께 써서 DataFrame에 질의하는 법을 익혔다는 점이다. 두 라이브러리를 함께 쓰면 두 세계의 좋은 점을 모두 갖는다. 이미 익숙한 메서드로 데이터를 다룰 수 있고, 익숙한 질의 언어인 SQL로 효율적인 DataFrame에 질의할 수 있다.

답사를 마치며 이 장의 자리를 다시 적어 둔다. 제1장은 DuckDB가 왜 빠른지를 열 지향 저장으로 설명했다. 제4장은 Polars가 왜 빠른지를 같은 열 지향 저장과 지연 평가로 설명한다. 두 도구는 경쟁자가 아니라 같은 판단을 공유하는 동류였고, 그래서 Arrow라는 규격 위에서 복사 없이 손을 맞잡는다. 도구를 고르는 일이 곧 편을 정하는 일이 아니라는 것, 좋은 답사기가 두 절을 견주면서도 어느 한쪽을 헐지 않는 것과 같은 태도다.

그리고 이 장의 표제로 돌아간다. 미루는 것이 아끼는 것이다. 무엇을 물을지 다 듣고 나서 읽기 시작하면, 읽어야 할 양 자체가 줄어든다. 서른한 개 열이 세 개로 줄고 네 단의 여과가 한 단으로 접히는 그 그림이, 이 장이 남기는 한 장의 도면이다.