폴라스 시작하기 (Getting Started)

폴라스 시작하기 (Getting Started)

원문 출처: Polars User Guide — Getting started (한국어 해설, 기술 용어 보존)

이 장은 폴라스(Polars)를 처음 시작하는 분들을 위한 안내예요. 초기 설치와 설정부터 핵심 기능까지, 라이브러리의 기초를 차근차근 다루니까요 신규 사용자도 부담 없이 따라올 수 있어요. 이미 데이터프레임에 익숙한 고급 사용자라면 다음 장(설치 옵션)으로 건너뛰셔도 좋아요.

폴라스 설치하기

파이썬에서는 pip 한 줄이면 끝나요.

pip install polars

Rust에서 쓸 때는 cargo add로 의존성을 추가하면 돼요. lazy 피처를 켜서 레이지 프레임도 쓸 수 있게 해두는 걸 추천해요.

cargo add polars -F lazy

또는 Cargo.toml에 직접 적을 수도 있어요.

[dependencies]
polars = { version = "x", features = ["lazy", ...]}

읽기와 쓰기

폴라스는 흔한 파일 형식(csv, json, parquet 등), 클라우드 스토리지(S3, Azure Blob, BigQuery), 데이터베이스(postgres, mysql 등)의 읽기와 쓰기를 모두 지원해요. 아래에서는 작은 데이터프레임을 하나 만들고, 그걸 디스크에 쓴 뒤 다시 읽어 오는 흐름을 보여드릴게요.

먼저 파이썬에서 데이터프레임을 만들어 볼게요. 이름, 생년월일, 몸무게, 키를 가진 네 명의 데이터를 넣었어요.

import polars as pl
import datetime as dt

df = pl.DataFrame(
    {
        "name": ["Alice Archer", "Ben Brown", "Chloe Cooper", "Daniel Donovan"],
        "birthdate": [
            dt.date(1997, 1, 10),
            dt.date(1985, 2, 15),
            dt.date(1983, 3, 22),
            dt.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)

같은 데이터를 Rust로 만들면 이렇게 돼요. df! 매크로가 열 이름과 값을 의미 있는 단위로 묶어서 DataFrame을 만들어 줘요.

use chrono::prelude::*;
use polars::prelude::*;

let mut df: DataFrame = df!(
    "name" => ["Alice Archer", "Ben Brown", "Chloe Cooper", "Daniel Donovan"],
    "birthdate" => [
        NaiveDate::from_ymd_opt(1997, 1, 10).unwrap(),
        NaiveDate::from_ymd_opt(1985, 2, 15).unwrap(),
        NaiveDate::from_ymd_opt(1983, 3, 22).unwrap(),
        NaiveDate::from_ymd_opt(1981, 4, 30).unwrap(),
    ],
    "weight" => [57.9, 72.5, 53.6, 83.1],  // (kg)
    "height" => [1.56, 1.77, 1.65, 1.75],  // (m)
)
.unwrap();
println!("{df}");

어느 쪽이든 출력은 같아요. 열 네 개, 행 네 개짜리 표가 나오죠. 타입도 열마다 다르게 붙어 있는 걸 확인할 수 있어요(name은 문자열 str, birthdate는 날짜 date, weight와 height는 실수 f64).

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   │
└────────────────┴────────────┴────────┴────────┘

이제 이 데이터프레임을 output.csv라는 파일로 저장하고, read_csv로 다시 읽어서 확인해 볼게요.

df.write_csv("docs/assets/data/output.csv")
df_csv = pl.read_csv("docs/assets/data/output.csv", try_parse_dates=True)
print(df_csv)

Rust에서는 CsvWriter로 쓰고 CsvReadOptions로 다시 읽어요. 날짜를 자동으로 파싱하도록 with_try_parse_dates(true)를 켰어요. csv 기능은 csv 피처에서 제공돼요.

use std::fs::File;

let mut file = File::create("docs/assets/data/output.csv").expect("could not create file");
CsvWriter::new(&mut file)
    .include_header(true)
    .with_separator(b',')
    .finish(&mut df)?;

let df_csv = CsvReadOptions::default()
    .with_has_header(true)
    .with_parse_options(CsvParseOptions::default().with_try_parse_dates(true))
    .try_into_reader_with_file_path(Some("docs/assets/data/output.csv".into()))?
    .finish()?;
println!("{df_csv}");

다시 읽어 온 결과를 출력하면 처음과 같은 표가 나와요. 파일로 내보냈다가 되돌아와도 데이터가 그대로 보존된 걸 알 수 있죠.

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   │
└────────────────┴────────────┴────────┴────────┘

csv 뿐 아니라 다른 데이터 형식에 대한 더 많은 예시는 유저 가이드의 IO 섹션에서 확인할 수 있어요.

표현식(Expressions)과 컨텍스트(Contexts)

폴라스가 내세우는 가장 큰 강점 중 하나가 **표현식(Expression)**이에요. 변환 로직을 모듈처럼 잘게, 그리고 유연하게 표현할 수 있게 해주거든요.

표현식 하나를 예로 볼게요.

pl.col("weight") / (pl.col("height") ** 2)

대충 감이 오시죠? "weight" 열의 값을 "height" 열 값의 제곱으로 나눠서, 사람의 BMI를 계산하는 식이에요. 여기서 기억해야 할 포인트가 있어요. 이 코드는 추상적인 계산만 표현한 거예요. 실제 결과가 담긴 Series로 구체화(materalize)되는 건 이 표현식이 폴라스의 컨텍스트(context) 안에 들어갔을 때라는 점이죠.

이제 서로 다른 컨텍스트 안에서 표현식이 어떻게 쓰이는지 하나씩 볼게요.

  • select
  • with_columns
  • filter
  • group_by

표현식과 컨텍스트에 대한 더 자세한 설명은 표현식과 컨텍스트 장에 따로 정리돼 있어요.

select

select 컨텍스트는 데이터프레임에서 열을 골라내고 조작할 때 써요. 가장 단순한 경우, 넣어준 표현식 하나가 결과 데이터프레임의 열 하나로 매핑돼요.

result = df.select(
    pl.col("name"),
    pl.col("birthdate").dt.year().alias("birth_year"),
    (pl.col("weight") / (pl.col("height") ** 2)).alias("bmi"),
)
print(result)

alias로 새 열 이름을 붙였어요. 결과를 보면 원본 열 세 개(name, birth_year, bmi)가 그대로 담겨 있죠.

shape: (4, 3)
┌────────────────┬────────────┬───────────┐
│ name           ┆ birth_year ┆ bmi       │
│ ---            ┆ ---        ┆ ---       │
│ str            ┆ i32        ┆ f64       │
╞════════════════╪════════════╪═══════════╡
│ Alice Archer   ┆ 1997       ┆ 23.791913 │
│ Ben Brown      ┆ 1985       ┆ 23.141498 │
│ Chloe Cooper   ┆ 1983       ┆ 19.687787 │
│ Daniel Donovan ┆ 1981       ┆ 27.134694 │
└────────────────┴────────────┴───────────┘

폴라스는 **표현식 확장(expression expansion)**이라는 기능도 지원해요. 표현식 하나가 여러 표현식을 대신해 동작하는 방식이에요. 아래 예시에서는 pl.col("weight", "height")처럼 열 두 개를 한 번에 받아 * 0.95를 적용하고, .name.suffix("-5%")로 원래 열 이름에 접미사를 붙였어요.

result = df.select(
    pl.col("name"),
    (pl.col("weight", "height") * 0.95).round(2).name.suffix("-5%"),
)
print(result)

weight-5%height-5%라는 열 두 개가 생겼죠. 표현식 하나로 열 두 개를 동시에 조작한 셈이에요.

shape: (4, 3)
┌────────────────┬───────────┬───────────┐
│ name           ┆ weight-5% ┆ height-5% │
│ ---            ┆ ---       ┆ ---       │
│ str            ┆ f64       ┆ f64       │
╞════════════════╪═══════════╪═══════════╡
│ Alice Archer   ┆ 55.0      ┆ 1.48      │
│ Ben Brown      ┆ 68.88     ┆ 1.68      │
│ Chloe Cooper   ┆ 50.92     ┆ 1.57      │
│ Daniel Donovan ┆ 78.94     ┆ 1.66      │
└────────────────┴───────────┴───────────┘

기본 연산이나 표현식 확장에서의 열 선택은 유저 가이드의 다른 섹션에서 더 자세히 볼 수 있어요.

with_columns

with_columnsselect와 아주 비슷하지만, 결과만 고르는 대신 데이터프레임에 열을 추가한다는 차이가 있어요. 아래 예시의 결과를 보면 원본 열 네 개에다가 with_columns 안의 표현식이 만든 열 두 개(birth_year, bmi)가 더해진 열 여섯 개짜리 표가 나와요.

result = df.with_columns(
    birth_year=pl.col("birthdate").dt.year(),
    bmi=pl.col("weight") / (pl.col("height") ** 2),
)
print(result)

Rust 버전도 비슷한데, 여기서는 결과만 보여드릴게요.

let result = df
    .clone()
    .lazy()
    .with_columns([
        col("birthdate").dt().year().alias("birth_year"),
        (col("weight") / col("height").pow(2)).alias("bmi"),
    ])
    .collect()?;
println!("{result}");
shape: (4, 6)
┌────────────────┬────────────┬────────┬────────┬────────────┬───────────┐
│ name           ┆ birthdate  ┆ weight ┆ height ┆ birth_year ┆ bmi       │
│ ---            ┆ ---        ┆ ---    ┆ ---    ┆ ---        ┆ ---       │
│ str            ┆ date       ┆ f64    ┆ f64    ┆ i32        ┆ f64       │
╞════════════════╪════════════╪════════╪════════╪════════════╪═══════════╡
│ Alice Archer   ┆ 1997-01-10 ┆ 57.9   ┆ 1.56   ┆ 1997       ┆ 23.791913 │
│ Ben Brown      ┆ 1985-02-15 ┆ 72.5   ┆ 1.77   ┆ 1985       ┆ 23.141498 │
│ Chloe Cooper   ┆ 1983-03-22 ┆ 53.6   ┆ 1.65   ┆ 1983       ┆ 19.687787 │
│ Daniel Donovan ┆ 1981-04-30 ┆ 83.1   ┆ 1.75   ┆ 1981       ┆ 27.134694 │
└────────────────┴────────────┴────────┴────────┴────────────┴───────────┘

위 예시에서는 새 열 이름을 지정할 때 alias 메서드 대신 named expression 방식(birth_year=...처럼 이름을 직접 붙이는 방식)을 썼어요. selectgroup_by 같은 다른 컨텍스트에서도 named expression을 쓸 수 있어요.

filter

filter 컨텍스트는 원본 데이터프레임에서 조건에 맞는 행만 남겨 두 번째 데이터프레임을 만들 때 써요. 예를 들어 아래는 1982년 12월 31일부터 1996년 1월 1일 사이에 태어난 사람들을 뽑은 결과예요.

shape: (3, 4)
┌────────────────┬────────────┬────────┬────────┐
│ name           ┆ birthdate  ┆ weight ┆ height │
│ ---            ┆ ---        ┆ ---    ┆ ---    │
│ str            ┆ date       ┆ f64    ┆ f64    │
╞════════════════╪════════════╪════════╪════════╡
│ 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   │
└────────────────┴────────────┴────────┴────────┘

조건(predicate) 표현식은 여러 개를 별도 인자로 넘길 수 있어요. &로 하나로 묶는 것보다 이렇게 따로따로 넘기는 게 훨씬 편해요.

result = df.filter(
    pl.col("birthdate").is_between(dt.date(1982, 12, 31), dt.date(1996, 1, 1)),
    pl.col("height") > 1.7,
)
print(result)

Rust에서는 두 조건을 .and()로 연결해요.

let result = df
    .clone()
    .lazy()
    .filter(
        col("birthdate")
            .is_between(
                lit(NaiveDate::from_ymd_opt(1982, 12, 31).unwrap()),
                lit(NaiveDate::from_ymd_opt(1996, 1, 1).unwrap()),
                ClosedInterval::Both,
            )
            .and(col("height").gt(lit(1.7))),
    )
    .collect()?;
println!("{result}");

두 조건을 모두 만족하는 사람은 Ben Brown 한 명뿐이에요.

shape: (1, 4)
┌───────────┬────────────┬────────┬────────┐
│ name      ┆ birthdate  ┆ weight ┆ height │
│ ---       ┆ ---        ┆ ---    ┆ ---    │
│ str       ┆ date       ┆ f64    ┆ f64    │
╞═══════════╪════════════╪════════╪════════╡
│ Ben Brown ┆ 1985-02-15 ┆ 72.5   ┆ 1.77   │
└───────────┴────────────┴────────┴────────┘

group_by

group_by 컨텍스트는 하나 이상의 표현식에서 같은 값을 가진 행끼리 묶을 때 써요. 아래 예시는 각 연대(decade)에 태어난 사람이 몇 명인지 세는 코드예요.

result = df.group_by(
    (pl.col("birthdate").dt.year() // 10 * 10).alias("decade"),
    maintain_order=True,
).len()
print(result)

birthdate에서 연도를 뽑아 // 10 * 10으로 십의 자리만 남기니 1980, 1990 같은 연대값이 나와요. Rust에서는 group_by_stable을 쓰면 파이썬의 maintain_order=True와 같은 동작을 얻을 수 있어요.

// Use `group_by_stable` if you want the Python behaviour of `maintain_order=True`.
let result = df
    .clone()
    .lazy()
    .group_by([(col("birthdate").dt().year() / lit(10) * lit(10)).alias("decade")])
    .agg([len()])
    .collect()?;
println!("{result}");

결과를 보면 1980년대 태어난 사람이 3명, 1990년대가 1명이에요.

shape: (2, 2)
┌────────┬─────┐
│ decade ┆ len │
│ ---    ┆ --- │
│ i32    ┆ u32 │
╞════════╪═════╡
│ 1990   ┆ 1   │
│ 1980   ┆ 3   │
└────────┴─────┘

키워드 인자 maintain_order는 결과 그룹이 원본 데이터프레임에 나타난 순서 그대로 나오도록 강제해요. 이 옵션은 그룹화를 느리게 만들지만, 여기서는 예시 결과가 재현 가능하도록 하기 위해 켠 거예요.

이렇게 묶은 뒤에는 agg를 써서 그룹별 집계를 계산할 수 있어요. 아래는 연대별로 표본 수, 평균 몸무게, 가장 키 큰 사람을 뽑은 결과예요.

shape: (2, 4)
┌────────┬─────────────┬────────────┬─────────┐
│ decade ┆ sample_size ┆ avg_weight ┆ tallest │
│ ---    ┆ ---         ┆ ---        ┆ ---     │
│ i32    ┆ u32         ┆ f64        ┆ f64     │
╞════════╪═════════════╪════════════╪═════════╡
│ 1990   ┆ 1           ┆ 57.9       ┆ 1.56    │
│ 1980   ┆ 3           ┆ 69.73      ┆ 1.77    │
└────────┴─────────────┴────────────┴─────────┘

더 복잡한 쿼리

컨텍스트와 그 안의 표현식은 체이닝해서 더 복잡한 쿼리로 만들 수 있어요. 아래는 지금까지 본 컨텍스트를 조합한 예시예요.

result = (
    df.with_columns(
        (pl.col("birthdate").dt.year() // 10 * 10).alias("decade"),
        pl.col("name").str.split(by=" ").list.first(),
    )
    .select(
        pl.all().exclude("birthdate"),
    )
    .group_by(pl.col("decade"), maintain_order=True)
    .agg(
        pl.col("name"),
        pl.col("weight", "height").mean().round(2).name.prefix("avg_"),
    )
)
print(result)

순서대로 따라가 볼게요. 먼저 with_columnsdecade 열과 이름의 첫 단어(성) 열을 추가하고, selectbirthdate를 제외한 뒤, group_by로 연대별로 묶고, agg에서 이름 목록과 평균 몸무게·키를 구했어요. 여기서 strlist 네임스페이스를 써서 문자열을 쪼개고 리스트를 다뤘다 정도만 짚고 넘어갈게요.

shape: (2, 4)
┌────────┬────────────────────────────┬────────────┬────────────┐
│ decade ┆ name                       ┆ avg_weight ┆ avg_height │
│ ---    ┆ ---                        ┆ ---        ┆ ---        │
│ i32    ┆ list[str]                  ┆ f64        ┆ f64        │
╞════════╪════════════════════════════╪════════════╪════════════╡
│ 1990   ┆ ["Alice"]                  ┆ 57.9       ┆ 1.56       │
│ 1980   ┆ ["Ben", "Chloe", "Daniel"] ┆ 69.73      ┆ 1.72       │
└────────┴────────────────────────────┴────────────┴────────────┘

데이터프레임 합치기

폴라스는 데이터프레임 두 개를 합치는 다양한 도구를 제공해요. 여기서는 **조인(join)**과 연결(concatenation) 예시를 각각 하나씩 볼게요.

데이터프레임 조인하기

폴라스는 여러 가지 조인 알고리즘을 지원해요. 아래 예시는 두 데이터프레임 사이에 대응 관계를 정할 수 있는 고유 식별자 역할을 하는 열(여기서는 name)을 기준으로, left outer join으로 두 데이터프레임을 합치는 모습이에요.

df2 = pl.DataFrame(
    {
        "name": ["Ben Brown", "Daniel Donovan", "Alice Archer", "Chloe Cooper"],
        "parent": [True, False, False, False],
        "siblings": [1, 2, 3, 4],
    }
)
print(df.join(df2, on="name", how="left"))

Rust에서는 이렇게 작성해요.

let df2: DataFrame = df!(
    "name" => ["Ben Brown", "Daniel Donovan", "Alice Archer", "Chloe Cooper"],
    "parent" => [true, false, false, false],
    "siblings" => [1, 2, 3, 4],
)
.unwrap();
let result = df
    .clone()
    .lazy()
    .join(
        df2.lazy(),
        [col("name")],
        [col("name")],
        JoinArgs::new(JoinType::Left),
    )
    .collect()?;
println!("{result}");

name을 기준으로 왼쪽 데이터프레임(df)의 행마다 오른쪽(df2)에서 같은 이름을 찾아 붙여줬어요. 원래 네 열에 parent, siblings 열이 더해져 여섯 열이 됐죠.

shape: (4, 6)
┌────────────────┬────────────┬────────┬────────┬────────┬──────────┐
│ name           ┆ birthdate  ┆ weight ┆ height ┆ parent ┆ siblings │
│ ---            ┆ ---        ┆ ---    ┆ ---    ┆ ---    ┆ ---      │
│ str            ┆ date       ┆ f64    ┆ f64    ┆ bool   ┆ i64      │
╞════════════════╪════════════╪════════╪════════╪════════╪══════════╡
│ Alice Archer   ┆ 1997-01-10 ┆ 57.9   ┆ 1.56   ┆ false  ┆ 3        │
│ Ben Brown      ┆ 1985-02-15 ┆ 72.5   ┆ 1.77   ┆ true   ┆ 1        │
│ Chloe Cooper   ┆ 1983-03-22 ┆ 53.6   ┆ 1.65   ┆ false  ┆ 4        │
│ Daniel Donovan ┆ 1981-04-30 ┆ 83.1   ┆ 1.75   ┆ false  ┆ 2        │
└────────────────┴────────────┴────────┴────────┴────────┴──────────┘

다양한 조인 알고리즘에 대한 설명은 유저 가이드의 조인 섹션에서 볼 수 있어요.

데이터프레임 연결하기

연결(concatenation)은 사용하는 방식에 따라 데이터프레임을 더 길게(아래로) 또는 더 넓게(옆으로) 늘려요. 다른 사람들의 데이터를 담은 두 번째 데이터프레임이 있다면, **수직 연결(vertical concatenation)**로 더 긴 데이터프레임을 만들 수 있어요.

df3 = pl.DataFrame(
    {
        "name": ["Ethan Edwards", "Fiona Foster", "Grace Gibson", "Henry Harris"],
        "birthdate": [
            dt.date(1977, 5, 10),
            dt.date(1975, 6, 23),
            dt.date(1973, 7, 22),
            dt.date(1971, 8, 3),
        ],
        "weight": [67.9, 72.5, 57.6, 93.1],  # (kg)
        "height": [1.76, 1.6, 1.66, 1.8],  # (m)
    }
)
print(pl.concat([df, df3], how="vertical"))
let df3: DataFrame = df!(
    "name" => ["Ethan Edwards", "Fiona Foster", "Grace Gibson", "Henry Harris"],
    "birthdate" => [
        NaiveDate::from_ymd_opt(1977, 5, 10).unwrap(),
        NaiveDate::from_ymd_opt(1975, 6, 23).unwrap(),
        NaiveDate::from_ymd_opt(1973, 7, 22).unwrap(),
        NaiveDate::from_ymd_opt(1971, 8, 3).unwrap(),
    ],
    "weight" => [67.9, 72.5, 57.6, 93.1],  // (kg)
    "height" => [1.76, 1.6, 1.66, 1.8],  // (m)
)
.unwrap();
let result = concat([df.clone().lazy(), df3.lazy()], UnionArgs::default())?.collect()?;
println!("{result}");

pl.concatdfdf3how="vertical"로 넣으니 행이 아래로 이어져 총 8행짜리 데이터프레임이 됐어요.

shape: (8, 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   │
│ Ethan Edwards  ┆ 1977-05-10 ┆ 67.9   ┆ 1.76   │
│ Fiona Foster   ┆ 1975-06-23 ┆ 72.5   ┆ 1.6    │
│ Grace Gibson   ┆ 1973-07-22 ┆ 57.6   ┆ 1.66   │
│ Henry Harris   ┆ 1971-08-03 ┆ 93.1   ┆ 1.8    │
└────────────────┴────────────┴────────┴────────┘

폴라스는 수직·수평 연결뿐 아니라 **대각선 연결(diagonal concatenation)**도 지원해요. 자세한 내용은 유저 가이드의 연결 섹션에서 다뤄요.