범주형 데이터와 Enum
범주형 데이터와 Enum (Categorical data and enums)
가질 수 있는 값의 개수가 제한적인 문자열 값들을 범주형 데이터(categorical data)라고 불러요. 보통 가능한 값의 개수는 행 수보다 훨씬 적죠. 국적, 컴퓨터의 운영체제, 좋아하는 오픈소스 프로젝트의 라이선스 같은 것들이 전형적인 예시입니다.
출처: 공식문서
범주형 데이터를 다룰 때 Polars의 전용 타입인 Categorical과 Enum을 쓰면 쿼리를 더 빠르게 만들 수 있어요. 이 페이지에서는 두 데이터 타입 Categorical과 Enum의 차이와, 어떤 상황에서 어느 쪽을 써야 하는지를 보여 드릴게요. 마지막에는 왜 Categorical과 Enum이 일반 문자열 값보다 더 효율적인지에 대한 설명도 덧붙입니다.
한 줄로 요약하면, 성능 관점에서는 가능한 한 Enum을 Categorical보다 선호해요. 요구사항이 도중에 바뀌게 되면 둘 사이를 언제든 캐스팅할 수 있으니 부담 가지지 마세요.
Enum vs Categorical
Enum과 Categorical에는 몇 가지 핵심 차이가 있어요. Enum의 범주는 고정되어 있고, 사전에 정의되며, **정렬(ordered)**될 수 있습니다. 범주를 사전에 알 수 없거나 동적으로 늘어나는 상황이라면 Categorical을 쓸 수 있는데, 그 경우에는 정렬할 수 없어요.
Enum 데이터 타입
Enum 만들기 (Creating an Enum)
Enum 데이터 타입은 정렬된 범주형 데이터 타입이에요. Enum을 쓰려면 범주를 미리 지정해서 만들어야 합니다. 그런 다음 새 시리즈, 새 데이터프레임을 만들거나 문자열 열을 캐스팅할 때 해당 Enum 변형을 사용할 수 있어요.
import polars as pl
bears_enum = pl.Enum(["Polar", "Panda", "Brown"])
bears = pl.Series(["Polar", "Panda", "Brown", "Brown", "Polar"], dtype=bears_enum)
print(bears)
잘못된 값 (Invalid values)
범주가 존재하는 모든 값을 포함하지 않는 Enum 타입을 지정하려 하면 Polars는 오류를 던져요.
from polars.exceptions import InvalidOperationError
try:
bears_kind_of = pl.Series(
["Polar", "Panda", "Brown", "Polar", "Shark"],
dtype=bears_enum,
)
except InvalidOperationError as exc:
print("InvalidOperationError:", exc)
가능한 모든 값을 미리 알 수 없고, 알 수 없는 값에서 오류가 나는 게 의미상 틀리다면, Categorical 타입을 써야 할 수 있어요.
범주 정렬과 비교 (Category ordering and comparison)
Enum 타입은 정렬되어 있고, 그 순서는 범주를 지정한 순서로 결정됩니다. 아래 코드는 정렬된 Enum이 유용한 곳을 로그 레벨(log level)로 보여 줍니다.
log_levels = pl.Enum(["debug", "info", "warning", "error"])
logs = pl.DataFrame(
{
"level": ["debug", "info", "debug", "error"],
"message": [
"process id: 525",
"Service started correctly",
"startup time: 67ms",
"Cannot connect to DB!",
],
},
schema_overrides={
"level": log_levels,
},
)
non_debug_logs = logs.filter(
pl.col("level") > "debug",
)
print(non_debug_logs)
이 예시는 Enum 값을 문자열과 비교할 수 있음을 보여 주는데, 그 문자열이 Enum 값 중 하나와 일치할 때만 가능해요. "level" 열을 "debug", "info", "warning", "error"가 아닌 다른 문자열과 비교하면 Polars는 예외를 던집니다.
try:
logs.select(pl.col("level") > "Pretty bad")
except InvalidOperationError as err:
print(err)
else:
raise AssertionError("Expected an InvalidOperationError")
Enum 타입의 열은 같은 Enum 타입의 다른 열이나 문자열을 담은 열과 비교할 수 있는데, 단 모든 문자열이 유효한 Enum 값이어야 해요.
str_series = pl.Series(["info", "debug", "debug", "error"])
print(logs["level"] == str_series)
Categorical 데이터 타입
Categorical 데이터 타입은 Enum의 더 유연한 버전으로 볼 수 있어요. 범주를 사전에 정의할 필요가 없는데, 데이터가 처리되면서 매핑이 동적으로 자라나기 때문입니다.
Categorical 시리즈 만들기 (Creating a Categorical Series)
Categorical 타입을 쓰려면 문자열 열을 캐스팅하거나, 시리즈나 데이터프레임 열의 데이터 타입으로 Categorical을 지정하면 돼요.
bears_cat = pl.Series(
["Polar", "Panda", "Brown", "Brown", "Polar"], dtype=pl.Categorical
)
print(bears_cat)
범주를 Polars가 추론하게 두는 것이 범주를 미리 나열하는 것보다 쉬워 보일 수 있는데, 이렇게 계속 추론하고 기록을 유지하는 작업에는 성능 비용이 붙어요. 그래서 가능하다면 Enum을 써야 합니다. 그 이유는 Categorical 인코딩에 관한 부분에서 배울 수 있어요.
Categories 객체 사용 (Using Categories objects)
기본적으로 모든 Categorical 열은 전역 매핑을 공유하므로, 독립적으로 만든 열도 같은 인코딩을 쓰고 재인코딩 없이 서로 결합할 수 있어요. 더 세밀한 제어가 필요하면 열을 만들 때 pl.Categories 객체를 넘길 수 있습니다.
bear_categories = pl.Categories(name="bear_species", physical=pl.UInt8)
bears = pl.DataFrame(
{"species": ["Polar", "Brown", "Panda", "Brown", "Polar"]},
schema_overrides={"species": pl.Categorical(bear_categories)},
)
print(bears)
pl.Categories는 다음 인자를 받아요.
name: 매핑을 식별한다. 두Categorical열은 이름, 네임스페이스, 물리 타입이 같은Categories를 참조할 때 호환되는 인코딩을 쓴다.namespace: 이름의 선택적 범위. 여러 독립된 범주 공간이 같은 이름을 공유할 때 유용하다.physical: 인코딩을 저장하는 데 쓰는 정수 타입.pl.UInt32(기본값)는 40억 개 이상의 범주를 인코딩할 수 있고,pl.UInt16은 최대 65,535개,pl.UInt8은 최대 255개를 인코딩한다. 범주 수가 적을 때 더 작은 타입은 메모리 사용량을 줄여 준다.
문자열과의 어휘 비교 (Lexical comparison with strings)
Categorical 열을 문자열과 비교하면 Polars는 어휘 비교(lexical comparison)를 수행해요. 문자열 맥락에서 이는 알파벳 순서로 어느 쪽이 먼저 오는지를 따지는 것을 뜻합니다.
print(
pl.DataFrame({"categorical": bears_cat}).with_columns(
(pl.col("categorical") < "Cat").alias('categorical < "Cat"')
)
)
문자열 열을 Categorical 열과 비교할 수도 있고, 그 비교도 어휘적으로 이루어집니다.
print(
pl.DataFrame(
{
"categorical": bears_cat,
"string": pl.Series(["Panda", "Brown", "Brown", "Polar", "Polar"]),
}
).with_columns(
(pl.col("categorical") == pl.col("string")).alias("categorical == string"),
)
)
문자열 열을 범주형 열과 비교하는 것이 가능하긴 하지만, 두 Categorical 열을 직접 비교하는 것이 보통 더 효율적이에요. 두 Categorical 열을 비교하는 것은 항상 어휘 비교를 수행합니다.
Categorical 열 결합 (Combining Categorical columns)
Categorical 열은 기본적으로 전역 매핑을 공유하므로, 결합할 때(예: 두 데이터프레임을 세로로 연결) 재인코딩이 필요 없습니다.
male_bears = pl.DataFrame(
{
"species": ["Polar", "Brown", "Panda"],
"weight": [450, 500, 110], # kg
},
schema_overrides={"species": pl.Categorical},
)
female_bears = pl.DataFrame(
{
"species": ["Brown", "Polar", "Panda"],
"weight": [340, 200, 90], # kg
},
schema_overrides={"species": pl.Categorical},
)
bears = pl.concat([male_bears, female_bears], how="vertical")
print(bears)
명시적인 pl.Categories 객체를 쓸 때도 마찬가지로, 두 Categorical 열이 같은 Categories를 공유하는 한 재인코딩 없이 결합될 수 있어요. 인코딩이 어떻게 동작하는지 더 깊이 보려면 아래 성능 섹션을 참고하세요.
범주형 데이터 타입의 성능 고려사항 (Performance considerations on categorical data types)
이 부분에서는 왜 범주형 데이터 타입이 일반 문자열 리터럴보다 더 나은 성능을 내는지 설명할게요.
인코딩 (Encodings)
범주형 데이터는 가능한 값의 집합이 유한한(그리고 보통 열의 길이보다 훨씬 작은) 문자열 데이터를 표현해요. 이 값들을 일반 문자열로 저장하면 같은 문자열이 계속 반복되므로 메모리가 낭비되고 성능도 떨어져요. 게다가 조인 같은 연산은 비싼 문자열 비교를 요구합니다.
Enum과 Categorical 같은 범주형 데이터 타입은 문자열 리터럴을 원래 문자열 리터럴에 매핑되는 저렴한 숫자 자리표시자로 표현합니다. 예를 들어 Polars는 유한한 범주 집합을 부호 없는 정수로 표현할 수 있는데, 아래 그림은 일반 문자열 열과 Polars 범주형 열의 가능한 표현을 보여 줍니다.
| 문자열 열 | 범주형 열 (물리 값) | 범주형 열 (범주) |
|---|---|---|
| Polar | 0 | Polar |
| Panda | 1 | Panda |
| Brown | 2 | Brown |
| Panda | 1 | |
| Brown | 2 | |
| Brown | 2 | |
| Polar | 0 |
이 경우 물리 표현 0은 값 Polar를, 1은 Panda를, 2는 Brown을 인코딩(또는 매핑)해요. 이 인코딩의 이점은 문자열 값을 한 번만 저장한다는 것이고, 추가로 (정렬·세기 같은) 연산을 수행할 때 문자열 데이터를 다루는 것보다 훨씬 빠른 물리 표현에 직접 작업할 수 있다는 것입니다.
Enum 타입의 인코딩은 고정 (Encodings for the data type Enum are fixed)
Enum 타입을 쓸 때는 범주를 미리 지정해요. 이렇게 하면 Polars가 서로 다른 열, 심지어 서로 다른 데이터셋까지도 같은 인코딩을 갖도록 보장할 수 있어요. 그 덕분에 비싼 재인코딩이나 캐시 조회가 필요 없습니다.
Categorical 데이터 타입과 인코딩 (Data type Categorical and encodings)
Categorical 타입의 범주가 추론된다는 사실에는 비용이 따릅니다. 여기서 핵심 비용은 이 인코딩들이 만들어지는 순서를 우리가 제어할 수 없다는 것이에요. 다음 두 범주형 시리즈를 이어 붙이는 경우를 생각해 보세요.
cat_bears = pl.Series(
["Polar", "Panda", "Brown", "Brown", "Polar"], dtype=pl.Categorical
)
cat2_series = pl.Series(
["Panda", "Brown", "Brown", "Polar", "Polar"], dtype=pl.Categorical
)
print(cat_bears.extend(cat2_series))
Polars는 문자열 값을 등장하는 순서대로 인코딩해요. cat_bears는 물리 값 0(Polar), 1(Panda), 2(Brown) 순으로 인코딩되는 반면, cat2_series는 0(Panda), 1(Brown), 2(Polar) 순으로 인코딩된다는 문제가 생길 수 있죠.
Polars는 기본적으로 모든 Categorical 열에 공유 전역 매핑을 사용해 이 문제를 피합니다. 같은 문자열은 열을 가로질러 항상 같은 물리 값을 갖게 되어 결합 연산이 저렴해지는 거예요. 여러 독립된 범주 공간이 필요할 때는 이름과 네임스페이스로 구분된 명시적인 pl.Categories 객체를 쓰면 되고, 같은 Categories를 공유하는 열은 항상 서로 호환됩니다. 다만 전역 매핑에 대한 조회와 편집에는 잠금(locking)이 발생할 수 있는 비용이 따라요. Enum은 매핑이 불변이어서 자유롭게 공유되기 때문에 이런 비용이 발생하지 않습니다.