구조체
구조체 (Structs)
Struct 데이터 타입은 하나의 열 안에 여러 필드를 저장할 수 있는 복합(composite) 데이터 타입이에요. Python 사용자에게 비유하자면 Struct는 Python 딕셔너리와 비슷하고, Python 타이핑에 익숙하다면 typing.TypedDict로 생각해도 좋아요.
출처: 공식문서
이 페이지에서는 Struct 데이터 타입이 어떤 상황에서 등장하는지, 왜 등장하는지, 그리고 Struct 값을 어떻게 다루는지 살펴볼게요. 먼저 미국의 몇몇 주에 걸친 영화 평균 평점을 담은 데이터프레임으로 시작해 봅시다.
import polars as pl
ratings = pl.DataFrame(
{
"Movie": ["Cars", "IT", "ET", "Cars", "Up", "IT", "Cars", "ET", "Up", "Cars"],
"Theatre": ["NE", "ME", "IL", "ND", "NE", "SD", "NE", "IL", "IL", "NE"],
"Avg_Rating": [4.5, 4.4, 4.6, 4.3, 4.8, 4.7, 4.5, 4.9, 4.7, 4.6],
"Count": [30, 27, 26, 29, 31, 28, 28, 26, 33, 28],
}
)
print(ratings)
shape: (10, 4)
┌───────┬─────────┬────────────┬───────┐
│ Movie ┆ Theatre ┆ Avg_Rating ┆ Count │
│ --- ┆ --- ┆ --- ┆ --- │
│ str ┆ str ┆ f64 ┆ i64 │
╞═══════╪═════════╪════════════╪═══════╡
│ Cars ┆ NE ┆ 4.5 ┆ 30 │
│ IT ┆ ME ┆ 4.4 ┆ 27 │
│ ET ┆ IL ┆ 4.6 ┆ 26 │
│ Cars ┆ ND ┆ 4.3 ┆ 29 │
│ Up ┆ NE ┆ 4.8 ┆ 31 │
│ IT ┆ SD ┆ 4.7 ┆ 28 │
│ Cars ┆ NE ┆ 4.5 ┆ 28 │
│ ET ┆ IL ┆ 4.9 ┆ 26 │
│ Up ┆ IL ┆ 4.7 ┆ 33 │
│ Cars ┆ NE ┆ 4.6 ┆ 28 │
└───────┴─────────┴────────────┴───────┘
Struct 데이터 타입 마주치기 (Encountering the data type Struct)
Struct 열로 이어지는 흔한 연산 중 하나는 탐색적 데이터 분석에서 자주 쓰이는 value_counts 함수예요. 어떤 주가 데이터에서 몇 번 등장하는지 세는 작업은 이렇게 하죠.
result = ratings.select(pl.col("Theatre").value_counts(sort=True))
print(result)
shape: (5, 1)
┌───────────┐
│ Theatre │
│ --- │
│ struct[2] │
╞═══════════╡
│ {"NE",4} │
│ {"IL",3} │
│ {"ME",1} │
│ {"ND",1} │
│ {"SD",1} │
└───────────┘
Struct 데이터 타입이 없는 도구에서 온 사람이라면 꽤 뜻밖의 출력이에요. 하지만 위험할 것 없어요. 더 익숙한 출력으로 돌아가려면 Struct 열에 함수 unnest를 쓰면 됩니다.
result = ratings.select(pl.col("Theatre").value_counts(sort=True)).unnest("Theatre")
print(result)
shape: (5, 2)
┌─────────┬───────┐
│ Theatre ┆ count │
│ --- ┆ --- │
│ str ┆ u32 │
╞═════════╪═══════╡
│ NE ┆ 4 │
│ IL ┆ 3 │
│ ME ┆ 1 │
│ ND ┆ 1 │
│ SD ┆ 1 │
└─────────┴───────┘
함수 unnest는 Struct의 각 필드를 각자의 열로 바꿔 줍니다.
value_counts가Struct를 돌려주는 이유: Polars 표현식은 항상 단일 시리즈에 대해 연산하고 또 다른 시리즈를 돌려줘요.Struct는 우리가 표현식에 여러 열을 입력으로 주거나 표현식에서 여러 열을 출력으로 받을 수 있게 해 주는 데이터 타입입니다. 그래서value_counts를 쓸 때 각 값과 그 개수를 지정하려면Struct를 쓸 수 있는 거예요.
딕셔너리에서 Struct 데이터 타입 추론 (Inferring the data type Struct from dictionaries)
시리즈나 데이터프레임을 만들면 Polars는 딕셔너리를 Struct 데이터 타입으로 변환해요.
rating_series = pl.Series(
"ratings",
[
{"Movie": "Cars", "Theatre": "NE", "Avg_Rating": 4.5},
{"Movie": "Toy Story", "Theatre": "ME", "Avg_Rating": 4.9},
],
)
print(rating_series)
shape: (2,)
Series: 'ratings' [struct[3]]
[
{"Cars","NE",4.5}
{"Toy Story","ME",4.9}
]
필드의 개수, 이름, 타입, 순서는 처음 보이는 딕셔너리에서 추론돼요. 이 필드 순서는 결과 Struct에 그대로 유지됩니다. 그다음에 오는 불일치들은 null 값이나 오류를 만들 수 있어요.
null_rating_series = pl.Series(
"ratings",
[
{"Movie": "Cars", "Theatre": "NE", "Avg_Rating": 4.5},
{"Mov": "Toy Story", "Theatre": "ME", "Avg_Rating": 4.9},
{"Movie": "Snow White", "Theatre": "IL", "Avg_Rating": "4.7"},
],
strict=False, # To show the final structs with `null` values.
)
print(null_rating_series)
shape: (3,)
Series: 'ratings' [struct[4]]
[
{"Cars","NE","4.5",null}
{null,"ME","4.9","Toy Story"}
{"Snow White","IL","4.7",null}
]
Struct의 개별 값 추출 (Extracting individual values of a Struct)
위에서 만든 시리즈의 Struct에서 "Movie" 필드만 얻고 싶다고 해 볼게요. 이때 함수 field를 쓰면 됩니다.
result = rating_series.struct.field("Movie")
print(result)
shape: (2,)
Series: 'Movie' [str]
[
"Cars"
"Toy Story"
]
Struct의 개별 필드 이름 바꾸기 (Renaming individual fields of a Struct)
Struct 열의 개별 필드 이름을 바꿔야 한다면 함수 rename_fields를 써요. 실제로 필드 이름이 바뀌었는지 확인하기 위해, 결과만을 열로 갖는 데이터프레임을 만들고 함수 unnest를 써서 각 필드를 각자의 열로 만들어 볼게요. 열 이름이 방금 한 이름 바꾸기 연산을 그대로 반영할 거예요.
result = rating_series.struct.rename_fields(["Film", "State", "Value"])
print(result)
print(
result.to_frame().unnest("ratings"),
)
shape: (2, 3)
┌───────────┬───────┬───────┐
│ Film ┆ State ┆ Value │
│ --- ┆ --- ┆ --- │
│ str ┆ str ┆ f64 │
╞═══════════╪═══════╪═══════╡
│ Cars ┆ NE ┆ 4.5 │
│ Toy Story ┆ ME ┆ 4.9 │
└───────────┴───────┴───────┘
Struct 열의 실용적 사용 사례 (Practical use-cases of Struct columns)
중복 행 식별 (Identifying duplicate rows)
ratings 데이터로 돌아가 볼게요. "Movie"와 "Theatre" 수준에서 중복이 있는 경우를 식별하고 싶다고 해 봅시다. 바로 여기서 Struct 데이터 타입이 빛을 발합니다.
result = ratings.filter(pl.struct("Movie", "Theatre").is_duplicated())
print(result)
shape: (5, 4)
┌───────┬─────────┬────────────┬───────┐
│ Movie ┆ Theatre ┆ Avg_Rating ┆ Count │
│ --- ┆ --- ┆ --- ┆ --- │
│ str ┆ str ┆ f64 ┆ i64 │
╞═══════╪═════════╪════════════╪═══════╡
│ Cars ┆ NE ┆ 4.5 ┆ 30 │
│ ET ┆ IL ┆ 4.6 ┆ 26 │
│ Cars ┆ NE ┆ 4.5 ┆ 28 │
│ ET ┆ IL ┆ 4.9 ┆ 26 │
│ Cars ┆ NE ┆ 4.6 ┆ 28 │
└───────┴─────────┴────────────┴───────┘
이 수준에서 고유한 경우는 is_unique로도 식별할 수 있어요!
다중 열 순위 (Multi-column ranking)
중복이 있다는 걸 알았으니, 어떤 평점이 더 높은 우선순위를 받을지 정하고 싶다고 해 봅시다. "Count" 열이 가장 중요하고, "Count" 열에서 동률이면 "Avg_Rating" 열을 고려한다고 정할 수 있어요. 그러면 이렇게 하면 됩니다.
result = ratings.with_columns(
pl.struct("Count", "Avg_Rating")
.rank("dense", descending=True)
.over("Movie", "Theatre")
.alias("Rank")
).filter(pl.struct("Movie", "Theatre").is_duplicated())
print(result)
shape: (5, 5)
┌───────┬─────────┬────────────┬───────┬──────┐
│ Movie ┆ Theatre ┆ Avg_Rating ┆ Count ┆ Rank │
│ --- ┆ --- ┆ --- ┆ --- ┆ --- │
│ str ┆ str ┆ f64 ┆ i64 ┆ u32 │
╞═══════╪═════════╪════════════╪═══════╪══════╡
│ Cars ┆ NE ┆ 4.5 ┆ 30 ┆ 1 │
│ ET ┆ IL ┆ 4.6 ┆ 26 ┆ 2 │
│ Cars ┆ NE ┆ 4.5 ┆ 28 ┆ 3 │
│ ET ┆ IL ┆ 4.9 ┆ 26 ┆ 1 │
│ Cars ┆ NE ┆ 4.6 ┆ 28 ┆ 2 │
└───────┴─────────┴────────────┴───────┴──────┘
꽤 복잡한 요구사항인데 Polars에서는 아주 우아하게 처리됐죠! 위에서 쓴 함수 over에 대해 더 배우려면 윈도우 함수 유저 가이드 섹션을 참고하세요.
단일 표현식에서 여러 열 사용 (Using multiple columns in a single expression)
앞서 언급했듯이 Struct 데이터 타입은 표현식에 여러 열을 입력으로 넘겨야 할 때도 유용해요. 예를 들어 데이터프레임의 두 열에 Ackermann 함수를 계산하고 싶다고 해 봅시다. Polars 표현식을 조합해서 Ackermann 함수를 계산할 방법은 없으므로[^1], 사용자 정의 함수를 정의합니다.
def ack(m, n):
if not m:
return n + 1
if not n:
return ack(m - 1, 1)
return ack(m - 1, ack(m, n - 1))
이제 그 인자들에 Ackermann 함수 값을 계산하려면, 먼저 필드 m과 n을 가진 Struct를 만들고 함수 map_elements로 함수 ack를 각 값에 적용하면 돼요.
values = pl.DataFrame(
{
"m": [0, 0, 0, 1, 1, 1, 2],
"n": [2, 3, 4, 1, 2, 3, 1],
}
)
result = values.with_columns(
pl.struct(["m", "n"])
.map_elements(lambda s: ack(s["m"], s["n"]), return_dtype=pl.Int64)
.alias("ack")
)
print(result)
shape: (7, 3)
┌─────┬─────┬─────┐
│ m ┆ n ┆ ack │
│ --- ┆ --- ┆ --- │
│ i64 ┆ i64 ┆ i64 │
╞═════╪═════╪═════╡
│ 0 ┆ 2 ┆ 3 │
│ 0 ┆ 3 ┆ 4 │
│ 0 ┆ 4 ┆ 5 │
│ 1 ┆ 1 ┆ 3 │
│ 1 ┆ 2 ┆ 4 │
│ 1 ┆ 3 ┆ 5 │
│ 2 ┆ 1 ┆ 5 │
└─────┴─────┴─────┘
데이터에 사용자 정의 Python 함수를 적용하는 방법에 대해 더 배우려면 이 유저 가이드 섹션을 참고하세요.
[^1]: 뭔가 "할 수 없다"고 말하는 것은 꽤 대담한 주장이에요. 우리가 틀렸다고 증명해 주시면 알려주세요!