폴라스 시작하기 (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) 안에 들어갔을 때라는 점이죠.
이제 서로 다른 컨텍스트 안에서 표현식이 어떻게 쓰이는지 하나씩 볼게요.
selectwith_columnsfiltergroup_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_columns는 select와 아주 비슷하지만, 결과만 고르는 대신 데이터프레임에 열을 추가한다는 차이가 있어요. 아래 예시의 결과를 보면 원본 열 네 개에다가 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=...처럼 이름을 직접 붙이는 방식)을 썼어요. select나 group_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_columns로 decade 열과 이름의 첫 단어(성) 열을 추가하고, select로 birthdate를 제외한 뒤, group_by로 연대별로 묶고, agg에서 이름 목록과 평균 몸무게·키를 구했어요. 여기서 str과 list 네임스페이스를 써서 문자열을 쪼개고 리스트를 다뤘다 정도만 짚고 넘어갈게요.
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.concat에 df와 df3를 how="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)**도 지원해요. 자세한 내용은 유저 가이드의 연결 섹션에서 다뤄요.