리스트와 배열
리스트와 배열 (Lists and arrays)
Polars는 두 가지 동질(homogeneous) 컨테이너 데이터 타입인 List와 Array를 일급(first-class)으로 지원해요. 두 타입 모두 다양한 연산을 지원하고 API도 겹치는 부분이 많아서, 이 섹션에서는 어떤 상황에서 어느 타입을 골라야 하는지 명확히 해 둘게요.
출처: 공식문서
리스트 vs 배열 (Lists vs arrays)
List 데이터 타입
List 데이터 타입은 길이가 서로 다른 균일한 1차원 컨테이너를 값으로 갖는 열에 적합합니다. 아래 데이터프레임에는 List 타입의 열 세 가지 예시가 들어 있어요.
from datetime import datetime
import polars as pl
df = pl.DataFrame(
{
"names": [
["Anne", "Averill", "Adams"],
["Brandon", "Brooke", "Borden", "Branson"],
["Camila", "Campbell"],
["Dennis", "Doyle"],
],
"children_ages": [
[5, 7],
[],
[],
[8, 11, 18],
],
"medical_appointments": [
[],
[],
[],
[datetime(2022, 5, 22, 16, 30)],
],
}
)
print(df)
여기서 주의할 점이 있어요. List 데이터 타입은 요소가 어떤 타입이어도 되는 Python의 list 타입과는 달라요. 진짜 Python 리스트를 열에 저장하고 싶다면 Object 타입으로 할 수 있지만, 그러면 앞으로 다룰 리스트 조작 기능은 쓰지 못합니다.
Array 데이터 타입
Array 데이터 타입은 알려진 고정된 shape을 가진, 임의 차원의 동질 컨테이너를 값으로 갖는 열에 적합합니다. 아래 데이터프레임에는 Array 타입의 열 두 가지 예시가 들어 있어요.
df = pl.DataFrame(
{
"bit_flags": [
[True, True, True, True, False],
[False, True, True, True, True],
],
"tic_tac_toe": [
[
[" ", "x", "o"],
[" ", "x", " "],
["o", "x", " "],
],
[
["o", "x", "x"],
[" ", "o", "x"],
[" ", " ", "o"],
],
],
},
schema={
"bit_flags": pl.Array(pl.Boolean, 5),
"tic_tac_toe": pl.Array(pl.String, (3, 3)),
},
)
print(df)
위 예시는 "bit_flags"와 "tic_tac_toe" 열이 Array 타입임을 지정하는 방법을 보여 줘요. 이때 Array는 그 안에 담긴 요소의 데이터 타입과 각 배열의 shape으로 파라미터화됩니다.
일반적으로 Polars는 성능상 이유로 열이 Array 타입임을 추론하지 않고, 기본적으로 List 타입의 적절한 변형을 사용해요. Python에서 이 규칙의 예외는 NumPy 배열로 열을 만들 때예요. 그 경우 Polars는 NumPy로부터 모든 하위 배열의 shape이 같다는 보장을 받으므로, $n+1$ 차원 배열은 $n$ 차원 배열의 열을 만들어 냅니다.
import numpy as np
array = np.arange(0, 120).reshape((5, 2, 3, 4)) # 4D array
print(pl.Series(array).dtype) # Column with the 3D subarrays
언제 무엇을 쓸까 (When to use each)
요약하면, Array가 List보다 메모리 효율이 좋고 성능도 좋으므로 가능하면 Array를 선호해요. Array를 쓸 수 없다면 List를 쓰면 되고, 그 경우는 대개 다음과 같아요.
- 열 안의 값들이 고정된 shape을 갖지 않을 때; 또는
- 리스트 API에만 있는 함수가 필요할 때.
리스트 다루기 (Working with lists)
list 네임스페이스
Polars는 List 타입의 값을 다루는 많은 함수를 제공하는데, 이 함수들은 list 네임스페이스 안에 모여 있어요. 이제 이 네임스페이스를 조금 살펴볼게요.
경고: 과거 Polars 버전에서는 리스트 연산 네임스페이스가
arr였어요. 이제arr는Array데이터 타입의 네임스페이스입니다. StackOverflow 등에서arr네임스페이스를 참조하는 글을 보면, 그 자료는 오래된 것일 수 있음을 참고하세요.
아래에 정의한 weather 데이터프레임은 한 지역의 여러 기상 관측소 데이터를 담고 있어요. 관측소가 결과를 얻지 못한 경우에는 그 시점의 실제 온도 대신 오류 코드가 기록됩니다.
weather = pl.DataFrame(
{
"station": [f"Station {idx}" for idx in range(1, 6)],
"temperatures": [
"20 5 5 E1 7 13 19 9 6 20",
"18 8 16 11 23 E2 8 E2 E2 E2 90 70 40",
"19 24 E9 16 6 12 10 22",
"E2 E0 15 7 8 10 E1 24 17 13 6",
"14 8 E0 16 22 24 E1",
],
}
)
print(weather)
프로그래밍 방식으로 리스트 만들기
위에서 정의한 weather 데이터프레임이 주어졌을 때, 각 관측소가 기록한 온도에 대한 분석을 수행해야 할 가능성이 높아요. 그러려면 먼저 개별 온도 측정값을 얻을 수 있어야 하는데, 이때 str 네임스페이스를 쓸 수 있습니다.
weather = weather.with_columns(
pl.col("temperatures").str.split(" "),
)
print(weather)
자연스러운 후속 작업은 온도 리스트를 explode해서 각 측정값이 각자의 행에 오도록 하는 거예요.
result = weather.explode("temperatures")
print(result)
하지만 Polars에서는 리스트 요소에 연산을 할 때 이렇게까지 하는 경우가 많지 않아요.
리스트 연산 (Operating on lists)
Polars는 List 타입의 열에 대한 몇 가지 표준 연산을 제공해요. 문자열에서 하듯이, 리스트도 head, tail, slice 함수로 슬라이싱할 수 있습니다.
result = weather.with_columns(
pl.col("temperatures").list.head(3).alias("head"),
pl.col("temperatures").list.tail(3).alias("tail"),
pl.col("temperatures").list.slice(-3, 2).alias("two_next_to_last"),
)
print(result)
리스트 내 요소별 연산 (Element-wise computation within lists)
가장 많은 오류를 내는 관측소를 찾으려면 다음 단계가 필요해요.
- 측정값을 숫자로 변환하려 시도하고;
- 리스트 안의 숫자가 아닌 값(즉
null값)의 개수를 행 단위로 세고; - 이 출력 열 이름을 "errors"로 바꿔 관측소를 쉽게 식별한다.
이 단계를 수행하려면 리스트 값 안의 각 측정값에 캐스팅 연산을 적용해야 해요. 그 진입점이 함수 eval인데, 리스트 요소에 연산을 수행하게 해 줍니다. eval 안에서는 컨텍스트 element로 리스트의 각 요소를 개별적으로 참조할 수 있고, 그 요소에 어떤 Polars 표현식이든 쓸 수 있어요.
result = weather.with_columns(
pl.col("temperatures")
.list.eval(pl.element().cast(pl.Int64, strict=False).is_null())
.list.sum()
.alias("errors"),
)
print(result)
다른 방법으로는 정규 표현식으로 측정값이 문자로 시작하는지 확인할 수 있어요.
result2 = weather.with_columns(
pl.col("temperatures")
.list.eval(pl.element().str.contains("(?i)[a-z]"))
.list.sum()
.alias("errors"),
)
print(result.equals(result2))
str 네임스페이스나 regex의 (?i) 표기가 낯설다면, 지금이 Polars에서 문자열과 정규 표현식을 다루는 방법을 살펴볼 좋은 기회예요.
집계와 정렬 (Aggregation & sorting)
데이터프레임의 select처럼, 서로 관련된 두 함수 eval과 agg도 리스트 요소를 집계하거나 정렬하는 데 쓸 수 있어요. 맨 처음에 썼던 예시 데이터를 약간 변형한 버전을 다시 사용할게요.
df = pl.DataFrame(
{
"children": [
[
{"name": "Anne", "age": 5},
{"name": "Averill", "age": 7},
],
[
{"name": "Brandon", "age": 12},
{"name": "Brooke", "age": 9},
{"name": "Branson", "age": 11},
],
[{"name": "Camila", "age": 19}],
[
{"name": "Dennis", "age": 8},
{"name": "Doyle", "age": 11},
{"name": "Dina", "age": 18},
],
],
}
)
print(df)
eval을 사용하면 리스트 요소를 정렬하거나 몇 가지 집계를 계산할 수 있어요.
result = df.select(
pl.col("children")
.list.eval(
pl.element()
.sort_by(pl.element().struct.field("age"), descending=True)
.struct.field("name")
)
.alias("names_by_age"),
pl.col("children")
.list.eval(pl.element().struct.field("age").min())
.alias("min_age"),
pl.col("children")
.list.eval(pl.element().struct.field("age").max())
.alias("max_age"),
)
print(result)
eval은 항상 리스트를 돌려줘요. 원소 하나짜리 리스트 대신 min_age와 max_age를 스칼라 값으로 얻고 싶다면 agg를 쓰면 됩니다.
result = df.select(
pl.col("children")
.list.eval(
pl.element()
.sort_by(pl.element().struct.field("age"), descending=True)
.struct.field("name")
)
.alias("names_by_age"),
pl.col("children")
.list.agg(pl.element().struct.field("age").min())
.alias("min_age"),
pl.col("children")
.list.agg(pl.element().struct.field("age").max())
.alias("max_age"),
)
print(result)
평가되는 표현식이 정적으로 단 하나의 값만 돌려준다고 판단되면, agg는 결과 리스트를 자동으로 내부 값으로 explode해요. 이는 df.group_by(...).agg(...)가 하는 일과 같아서 이름도 agg입니다. eval은 이런 언래핑을 수행하지 않는다는 점과 대비되지요.
.list.sum() 같은 일부 집계 함수는 list 네임스페이스에 바로 있지만, entropy 같은 더 특이한 집계는 agg/eval로만 접근할 수 있어요.
result = df.with_columns(
pl.col("children")
.list.agg(pl.element().struct.field("age").entropy())
.alias("age_entropy"),
)
print(result)
행 단위 계산 (Row-wise computations)
pl.all()을 pl.concat_list(...)와 조합하면 열들의 부분집합에 대해 행 단위 집계를 수행할 수 있어요. 실제로 보여 주기 위해, 날씨 데이터를 좀 더 담은 데이터프레임을 하나 더 만들어 볼게요.
weather_by_day = pl.DataFrame(
{
"station": [f"Station {idx}" for idx in range(1, 11)],
"day_1": [17, 11, 8, 22, 9, 21, 20, 8, 8, 17],
"day_2": [15, 11, 10, 8, 7, 14, 18, 21, 15, 13],
"day_3": [16, 15, 24, 24, 8, 23, 19, 23, 16, 10],
}
)
print(weather_by_day)
이제 관측소 간에 측정된, 일별 온도의 백분위 순위(percentage rank)를 계산해 볼게요. Polars는 이걸 직접 해 주는 함수를 제공하지 않지만, 표현식이 워낙 유연해서 최고 온도의 백분위 순위 표현식을 우리가 직접 만들 수 있어요. 시도해 봅시다.
rank_pct = (pl.element().rank(descending=True) / pl.element().count()).round(2)
result = weather_by_day.with_columns(
# create the list of homogeneous data
pl.concat_list(pl.all().exclude("station")).alias("all_temps")
).select(
# select all columns except the intermediate list
pl.all().exclude("all_temps"),
# compute the rank by calling `list.eval`
pl.col("all_temps").list.eval(rank_pct, parallel=True).alias("temps_rank"),
)
print(result)
shape: (10, 5)
┌────────────┬───────┬───────┬───────┬────────────────────┐
│ station ┆ day_1 ┆ day_2 ┆ day_3 ┆ temps_rank │
│ --- ┆ --- ┆ --- ┆ --- ┆ --- │
│ str ┆ i64 ┆ i64 ┆ i64 ┆ list[f64] │
╞════════════╪═══════╪═══════╪═══════╪════════════════════╡
│ Station 1 ┆ 17 ┆ 15 ┆ 16 ┆ [0.33, 1.0, 0.67] │
│ Station 2 ┆ 11 ┆ 11 ┆ 15 ┆ [0.83, 0.83, 0.33] │
│ Station 3 ┆ 8 ┆ 10 ┆ 24 ┆ [1.0, 0.67, 0.33] │
│ Station 4 ┆ 22 ┆ 8 ┆ 24 ┆ [0.67, 1.0, 0.33] │
└────────────┴───────┴───────┴───────┴────────────────────┘
배열 다루기 (Working with arrays)
배열 열 만들기 (Creating an array column)
위에서 보았듯이 Polars는 보통 Array 타입을 자동으로 추론하지 않아요. NumPy 배열로 열을 만드는 경우가 아니라면, 시리즈/데이터프레임을 만들 때 Array 타입을 직접 지정하거나 열을 명시적으로 캐스팅해야 합니다.
arr 네임스페이스
Array 데이터 타입은 최근에 도입되어 아직 제공하는 기능이 비교적 많지 않아요. 그래도 arr 네임스페이스에는 배열을 다루는 여러 함수가 모여 있습니다. (과거 버전에서 리스트 연산 네임스페이스가 arr였다는 점, 이제 arr는 Array 타입의 네임스페이스라는 점을 다시 기억해 두세요.)
arr 네임스페이스의 전체 함수 목록은 API 문서가 잘 보여 주는데, 그중 몇 가지를 소개할게요.
df = pl.DataFrame(
{
"first_last": [
["Anne", "Adams"],
["Brandon", "Branson"],
["Camila", "Campbell"],
["Dennis", "Doyle"],
],
"fav_numbers": [
[42, 0, 1],
[2, 3, 5],
[13, 21, 34],
[73, 3, 7],
],
},
schema={
"first_last": pl.Array(pl.String, 2),
"fav_numbers": pl.Array(pl.Int32, 3),
},
)
result = df.select(
pl.col("first_last").arr.join(" ").alias("name"),
pl.col("fav_numbers").arr.sort(),
pl.col("fav_numbers").arr.max().alias("largest_fav"),
pl.col("fav_numbers").arr.sum().alias("summed"),
pl.col("fav_numbers").arr.contains(3).alias("likes_3"),
)
print(result)