데이터 구조 (Data Structures: DataFrame/Series)

데이터 구조 (Data Structures: DataFrame/Series)

Polars가 제공하는 핵심 기본 데이터 구조는 SeriesDataFrame 두 가지예요. 이 두 개념이 Polars의 모든 작업의 출발점이 되는데요, 데이터를 어떻게 담고 어떻게 다루는지 하나씩 살펴볼게요.

Series

Series는 1차원 동질(homogeneous) 데이터 구조예요. 여기서 '동질'이라는 말은 시리즈 안의 모든 원소가 같은 데이터 타입을 가진다는 뜻이에요. 서로 다른 타입이 섞여 들어갈 수 없다는 점을 먼저 기억해 두면 좋아요.

이름이 있는 Series를 만드는 코드를 볼게요:

import polars as pl

s = pl.Series("ints", [1, 2, 3, 4, 5])
print(s)
shape: (5,)
Series: 'ints' [i64]
[
    1
    2
    3
    4
    5
]

시리즈를 만들 때 Polars는 여러분이 넣은 값에서 데이터 타입을 자동으로 추론(infer) 해요. 그런데 특정 타입으로 강제하고 싶을 때가 있죠? 그럴 땐 구체적인 데이터 타입을 지정해서 추론 방식을 덮어쓸 수 있어요:

s1 = pl.Series("ints", [1, 2, 3, 4, 5])
s2 = pl.Series("uints", [1, 2, 3, 4, 5], dtype=pl.UInt64)
print(s1.dtype, s2.dtype)
Int64 UInt64

같은 [1, 2, 3, 4, 5] 값을 넣었는데도 s1Int64, s2UInt64로 서로 다른 타입이 됐죠. 이렇게 dtype 파라미터로 원하는 타입을 명시하면 추론을 무시하고 그 타입으로 시리즈를 만들어요.

DataFrame

DataFrame은 2차원 이종(heterogeneous) 데이터 구조로, 이름이 고유한 Series들의 모음이에요. 즉 각 열(column)은 하나의 Series이고, 열마다 다른 데이터 타입을 가질 수 있어요.

데이터를 DataFrame에 담아 두면 Polars API로 데이터를 조작하는 쿼리를 작성할 수 있어요. 이건 Polars가 제공하는 contextexpression을 사용해서 가능한데, 이 둘에 대해서는 다음에 따로 다룰게요.

리스트의 딕셔너리(dictionary of lists)로 DataFrame을 만드는 코드를 볼게요:

from datetime import date

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

namestr, birthdatedate, weightheightf64 타입이에요. 열마다 타입이 다른 걸 이종(heterogeneous)이라고 표현하는 거예요.

DataFrame 살펴보기

DataFrame을 빠르게 훑어보는 데 유용한 메서드 몇 가지를 볼게요. 앞에서 만든 DataFrame을 그대로 사용할게요.

head 함수는 DataFrame의 처음 몇 행을 보여줘요. 기본적으로 첫 5행을 보여주는데, 원하는 행 개수를 직접 지정할 수도 있어요:

print(df.head(3))
shape: (3, 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   │
└──────────────┴────────────┴────────┴────────┘

Glimpse

glimpse는 처음 몇 행의 값을 보여준다는 점은 head와 같지만, 출력 형식이 달라요. 여기서는 출력의 각 줄이 하나의 열에 대응돼요. 그래서 열이 많은(wide) 데이터프레임을 살펴볼 때 이 방식이 더 편리해요:

print(df.glimpse(return_type="string"))
Rows: 4
Columns: 4
$ name       <str> 'Alice Archer', 'Ben Brown', 'Chloe Cooper', 'Daniel Donovan'
$ birthdate <date> 1997-01-10, 1985-02-15, 1983-03-22, 1981-04-30
$ weight     <f64> 57.9, 72.5, 53.6, 83.1
$ height     <f64> 1.56, 1.77, 1.65, 1.75

Info: glimpse는 Python 사용자만 사용할 수 있어요.

Tail

tail 함수는 DataFrame의 마지막 몇 행을 보여줘요. 기본적으로 마지막 5행을 보여주는데, head와 마찬가지로 원하는 행 개수를 지정할 수 있어요:

print(df.tail(3))
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   │
└────────────────┴────────────┴────────┴────────┘

Sample

데이터프레임의 첫 행이나 마지막 행이 데이터를 대표하지 않는다고 생각된다면, sample을 사용해서 무작위로 선택된 임의의 행을 얻을 수 있어요. 반환되는 행의 순서가 데이터프레임에 나타난 순서와 반드시 같지는 않다는 점을 주의하세요:

pl.set_random_seed(42)  # For reproducibility.

print(df.sample(2))
shape: (2, 4)
┌────────────────┬────────────┬────────┬────────┐
│ name           ┆ birthdate  ┆ weight ┆ height │
│ ---            ┆ ---        ┆ ---    ┆ ---    │
│ str            ┆ date       ┆ f64    ┆ f64    │
╞════════════════╪════════════╪════════╪════════╡
│ Alice Archer   ┆ 1997-01-10 ┆ 57.9   ┆ 1.56   │
│ Daniel Donovan ┆ 1981-04-30 ┆ 83.1   ┆ 1.75   │
└────────────────┴────────────┴────────┴────────┘

재현 가능한 결과를 위해 pl.set_random_seed(42)로 시드를 고정했어요.

Describe

describe를 사용하면 데이터프레임의 모든 열에 대한 요약 통계를 계산할 수 있어요:

print(df.describe())
shape: (9, 5)
┌────────────┬────────────────┬─────────────────────┬───────────┬──────────┐
│ statistic  ┆ name           ┆ birthdate           ┆ weight    ┆ height   │
│ ---        ┆ ---            ┆ ---                 ┆ ---       ┆ ---      │
│ str        ┆ str            ┆ str                 ┆ f64       ┆ f64      │
╞════════════╪════════════════╪═════════════════════╪═══════════╪══════════╡
│ count      ┆ 4              ┆ 4                   ┆ 4.0       ┆ 4.0      │
│ null_count ┆ 0              ┆ 0                   ┆ 0.0       ┆ 0.0      │
│ mean       ┆ null           ┆ 1986-09-04 00:00:00 ┆ 66.775    ┆ 1.6825   │
│ std        ┆ null           ┆ null                ┆ 13.560082 ┆ 0.097082 │
│ min        ┆ Alice Archer   ┆ 1981-04-30          ┆ 53.6      ┆ 1.56     │
│ 25%        ┆ null           ┆ 1983-03-22          ┆ 57.9      ┆ 1.65     │
│ 50%        ┆ null           ┆ 1985-02-15          ┆ 72.5      ┆ 1.75     │
│ 75%        ┆ null           ┆ 1985-02-15          ┆ 72.5      ┆ 1.75     │
│ max        ┆ Daniel Donovan ┆ 1997-01-10          ┆ 83.1      ┆ 1.77     │
└────────────┴────────────────┴─────────────────────┴───────────┴──────────┘

count, null_count, mean, std, min, 사분위수(25%, 50%, 75%), max 같은 통계를 확인할 수 있어요. 문자열 열인 name은 평균 같은 통계를 계산할 수 없으니 null로 표시되는 걸 볼 수 있죠.

Schema

데이터를 이야기할 때(데이터프레임이든 아니든) 우리는 **스키마(schema)**라는 표현을 써요. 스키마는 열(또는 시리즈)의 이름과 그 열의 데이터 타입을 연결하는 매핑이에요.

데이터프레임의 스키마는 schema로 확인할 수 있어요:

print(df.schema)
Schema({'name': String, 'birthdate': Date, 'weight': Float64, 'height': Float64})

Series와 마찬가지로, DataFrame을 만들 때 Polars는 스키마를 자동으로 추론해요. 하지만 필요하다면 추론 방식을 덮어쓸 수도 있어요.

Python에서는 딕셔너리로 열 이름을 데이터 타입에 매핑해서 명시적인 스키마를 지정할 수 있어요. 특정 열의 추론을 덮어쓰고 싶지 않다면 그 열에 None 값을 사용하면 돼요:

df = pl.DataFrame(
    {
        "name": ["Alice", "Ben", "Chloe", "Daniel"],
        "age": [27, 39, 41, 43],
    },
    schema={"name": None, "age": pl.UInt8},
)

print(df)
shape: (4, 2)
┌────────┬─────┐
│ name   ┆ age │
│ ---    ┆ --- │
│ str    ┆ u8  │
╞════════╪═════╡
│ Alice  ┆ 27  │
│ Ben    ┆ 39  │
│ Chloe  ┆ 41  │
│ Daniel ┆ 43  │
└────────┴─────┘

여기서는 name은 그대로 두고(None) agepl.UInt8로 덮어썼어요. 스키마 출력을 보면 ageu8로 나오는 걸 확인할 수 있어요.

일부 열의 추론만 덮어쓰고 싶다면 schema_overrides 파라미터가 더 편리해요. 이 방식은 덮어쓰고 싶지 않은 열을 아예 생략할 수 있게 해주거든요:

df = pl.DataFrame(
    {
        "name": ["Alice", "Ben", "Chloe", "Daniel"],
        "age": [27, 39, 41, 43],
    },
    schema_overrides={"age": pl.UInt8},
)

print(df)
shape: (4, 2)
┌────────┬─────┐
│ name   ┆ age │
│ ---    ┆ --- │
│ str    ┆ u8  │
╞════════╪═════╡
│ Alice  ┆ 27  │
│ Ben    ┆ 39  │
│ Chloe  ┆ 41  │
│ Daniel ┆ 43  │
└────────┴─────┘

결과는 앞선 schema 방식과 같아요. ageu8로 지정했고 name은 추론에 맡겼죠. schema_overrides는 열이 많을 때 덮어쓸 열만 골라 쓰기 좋아요.

데이터 타입 내부

Polars는 데이터를 배치하는 방식으로 Arrow Columnar Format을 사용해요. 이 스펙을 따르기 때문에 Polars는 같은 Arrow 스펙을 쓰는 다른 도구들과 데이터를 거의 오버헤드 없이 주고받을 수 있어요.

Polars가 성능을 내는 핵심은 쿼리 엔진, 쿼리 플랜에 적용하는 최적화, 그리고 expression을 실행할 때 사용하는 병렬화(parallelization) 덕분이에요.

부동소수점(Floating point) 숫자

Polars는 Float32Float64에 대해 일반적으로 IEEE 754 부동소수점 표준을 따르는데, 몇 가지 예외가 있어요:

  • 모든 NaN은 다른 어떤 NaN과도 동일하게 비교되고, NaN이 아닌 어떤 값보다도 큰 것으로 취급돼요.
  • 연산은 0이나 NaN의 부호, 그리고 NaN 값의 페이로드에 대해 특정 동작을 보장하지 않아요. 이는 산술 연산에만 국한되지 않아요. 예를 들어 정렬(sort)이나 group by 연산이 효율적인 동등 비교를 위해 모든 0을 +0으로, 모든 NaN을 페이로드 없는 양의 NaN으로 정규화할 수 있어요.

Polars는 부동소수점 계산에 대해 항상 합리적으로 정확한 결과를 제공하려고 노력하지만, 별도로 명시하지 않는 한 오차에 대한 보장은 하지 않아요. 일반적으로 100% 정확한 결과를 얻는 것은 실현 불가능할 정도로 비용이 크기 때문에(64비트 float보다 훨씬 큰 내부 표현이 필요해서요), 어느 정도의 오차는 항상 존재한다고 보는 게 맞아요.