표현식 확장

표현식 확장 (Expression expansion)

표현식과 컨텍스트에서 보셨듯이, 표현식 확장(expression expansion)은 하나의 표현식이 여러 개의 서로 다른 표현식으로 확장될 수 있게 해주는 기능이에요. 확장되는 형태는 표현식이 쓰이는 컨텍스트의 스키마에 따라 달라질 수 있죠.

이 기능은 단지 장식이나 문법적 설탕이 아니에요. 코드에서 DRY 원칙을 아주 강력하게 적용할 수 있게 해줍니다. 여러 칼럼을 지시하는 하나의 표현식은 표현식의 리스트로 확장되는데, 그 말인즉슨 하나의 표현식을 한 번만 작성하고 그 계산을 재사용할 수 있다는 거예요. 이 섹션에서는 표현식 확장의 여러 형태를 살펴볼 텐데, 그 기준이 될 데이터프레임은 아래와 같아요.

출처: 공식문서

import polars as pl

df = pl.DataFrame(
    {  # As of 14th October 2024, ~3pm UTC
        "ticker": ["AAPL", "NVDA", "MSFT", "GOOG", "AMZN"],
        "company_name": ["Apple", "NVIDIA", "Microsoft", "Alphabet (Google)", "Amazon"],
        "price": [229.9, 138.93, 420.56, 166.41, 188.4],
        "day_high": [231.31, 139.6, 424.04, 167.62, 189.83],
        "day_low": [228.6, 136.3, 417.52, 164.78, 188.44],
        "year_high": [237.23, 140.76, 468.35, 193.31, 201.2],
        "year_low": [164.08, 39.23, 324.39, 121.46, 118.35],
    }
)

print(df)
shape: (5, 7)
┌────────┬───────────────────┬────────┬──────────┬─────────┬───────────┬──────────┐
│ ticker ┆ company_name      ┆ price  ┆ day_high ┆ day_low ┆ year_high ┆ year_low │
│ ---    ┆ ---               ┆ ---    ┆ ---      ┆ ---     ┆ ---       ┆ ---      │
│ str    ┆ str               ┆ f64    ┆ f64      ┆ f64     ┆ f64       ┆ f64      │
╞════════╪═══════════════════╪════════╪══════════╪═════════╪═══════════╪══════════╡
│ AAPL   ┆ Apple             ┆ 229.9  ┆ 231.31   ┆ 228.6   ┆ 237.23    ┆ 164.08   │
│ NVDA   ┆ NVIDIA            ┆ 138.93 ┆ 139.6    ┆ 136.3   ┆ 140.76    ┆ 39.23    │
│ MSFT   ┆ Microsoft         ┆ 420.56 ┆ 424.04   ┆ 417.52  ┆ 468.35    ┆ 324.39   │
│ GOOG   ┆ Alphabet (Google) ┆ 166.41 ┆ 167.62   ┆ 164.78  ┆ 193.31    ┆ 121.46   │
│ AMZN   ┆ Amazon            ┆ 188.4  ┆ 189.83   ┆ 188.44  ┆ 201.2     ┆ 118.35   │
└────────┴───────────────────┴────────┴──────────┴─────────┴───────────┴──────────┘

col 함수 (Function col)

col 함수는 Polars에서 표현식 확장 기능을 활용하는 가장 흔한 방법이에요. 보통 데이터프레임의 한 칼럼을 가리키는 데 쓰이지만, 이 섹션에서는 col(Rust에서는 그 변형)을 쓸 수 있는 다른 방법들을 살펴볼게요.

칼럼 이름으로 명시적 확장 (Explicit expansion by column name)

표현식 확장의 가장 단순한 형태는 col 함수에 여러 칼럼 이름을 넘길 때 발생해요.

아래 예시는 하나의 col 함수에 여러 칼럼 이름을 넘겨 값을 USD에서 EUR로 변환합니다:

eur_usd_rate = 1.09  # As of 14th October 2024

result = df.with_columns(
    (
        pl.col(
            "price",
            "day_high",
            "day_low",
            "year_high",
            "year_low",
        )
        / eur_usd_rate
    ).round(2)
)
print(result)
shape: (5, 7)
┌────────┬───────────────────┬────────┬──────────┬─────────┬───────────┬──────────┐
│ ticker ┆ company_name      ┆ price  ┆ day_high ┆ day_low ┆ year_high ┆ year_low │
│ ---    ┆ ---               ┆ ---    ┆ ---      ┆ ---     ┆ ---       ┆ ---      │
│ str    ┆ str               ┆ f64    ┆ f64      ┆ f64     ┆ f64       ┆ f64      │
╞════════╪═══════════════════╪════════╪══════════╪═════════╪═══════════╪══════════╡
│ AAPL   ┆ Apple             ┆ 210.92 ┆ 212.21   ┆ 209.72  ┆ 217.64    ┆ 150.53   │
│ NVDA   ┆ NVIDIA            ┆ 127.46 ┆ 128.07   ┆ 125.05  ┆ 129.14    ┆ 35.99    │
│ MSFT   ┆ Microsoft         ┆ 385.83 ┆ 389.03   ┆ 383.05  ┆ 429.68    ┆ 297.61   │
│ GOOG   ┆ Alphabet (Google) ┆ 152.67 ┆ 153.78   ┆ 151.17  ┆ 177.35    ┆ 111.43   │
│ AMZN   ┆ Amazon            ┆ 172.84 ┆ 174.16   ┆ 172.88  ┆ 184.59    ┆ 108.58   │
└────────┴───────────────────┴────────┴──────────┴─────────┴───────────┴──────────┘

확장하고 싶은 칼럼 이름을 나열하면, 그 표현식이 무엇으로 확장될지 미리 예측할 수 있어요. 이 경우 환율 변환을 수행하는 표현식은 다섯 개의 표현식 리스트로 확장됩니다:

exprs = [
    (pl.col("price") / eur_usd_rate).round(2),
    (pl.col("day_high") / eur_usd_rate).round(2),
    (pl.col("day_low") / eur_usd_rate).round(2),
    (pl.col("year_high") / eur_usd_rate).round(2),
    (pl.col("year_low") / eur_usd_rate).round(2),
]

result2 = df.with_columns(exprs)
print(result.equals(result2))
True

데이터 타입으로 확장 (Expansion by data type)

앞의 예시에서는 다섯 개의 칼럼 이름을 직접 입력해야 했는데요, col 함수는 데이터 타입 하나 또는 여러 개도 편리하게 받아들일 수 있어요. 칼럼 이름 대신 데이터 타입을 주면, 그 표현식은 제공된 데이터 타입 중 하나에 해당하는 모든 칼럼으로 확장됩니다.

아래 예시는 앞서와 정확히 같은 계산을 수행해요:

result = df.with_columns((pl.col(pl.Float64) / eur_usd_rate).round(2))
print(result)

표현식 확장에 데이터 타입을 쓰면, 하나의 표현식이 몇 개의 칼럼으로 확장될지 미리 알 수 없어요. 최종적으로 적용될 표현식 리스트를 정확히 알려면 입력 데이터프레임의 스키마가 필요합니다.

만약 가격 칼럼들이 Float64인지 Float32인지 확신이 없다면, 두 데이터 타입을 모두 지정할 수 있어요:

result2 = df.with_columns(
    (
        pl.col(
            pl.Float32,
            pl.Float64,
        )
        / eur_usd_rate
    ).round(2)
)
print(result.equals(result2))
True

패턴 매칭으로 확장 (Expansion by pattern matching)

칼럼 이름을 매칭하는 데 정규 표현식(regular expression)도 쓸 수 있어요. 일반 칼럼 이름과 패턴 매칭 확장을 구분하기 위해, 정규 표현식은 각각 ^$로 시작하고 끝납니다. 즉, 패턴은 칼럼 이름 문자열 전체와 매칭되어야 해요.

정규 표현식은 일반 칼럼 이름과 섞어 쓸 수 있습니다:

result = df.select(pl.col("ticker", "^.*_high$", "^.*_low$"))
print(result)
shape: (5, 5)
┌────────┬──────────┬─────────┬───────────┬──────────┐
│ ticker ┆ day_high ┆ day_low ┆ year_high ┆ year_low │
│ ---    ┆ ---      ┆ ---     ┆ ---       ┆ ---      │
│ str    ┆ f64      ┆ f64     ┆ f64       ┆ f64      │
╞════════╪══════════╪═════════╪═══════════╪══════════╡
│ AAPL   ┆ 231.31   ┆ 228.6   ┆ 237.23    ┆ 164.08   │
│ NVDA   ┆ 139.6    ┆ 136.3   ┆ 140.76    ┆ 39.23    │
│ MSFT   ┆ 424.04   ┆ 417.52  ┆ 468.35    ┆ 324.39   │
│ GOOG   ┆ 167.62   ┆ 164.78  ┆ 193.31    ┆ 121.46   │
│ AMZN   ┆ 189.83   ┆ 188.44  ┆ 201.2     ┆ 118.35   │
└────────┴──────────┴─────────┴───────────┴──────────┘

인자는 혼합 타입일 수 없음 (Arguments cannot be of mixed types)

Python에서 col 함수는 임의 개수의 문자열(칼럼 이름 또는 정규 표현식) 또는 임의 개수의 데이터 타입을 받아들이지만, 같은 함수 호출에서 둘을 섞을 수는 없어요:

try:
    df.select(pl.col("ticker", pl.Float64))
except TypeError as err:
    print("TypeError:", err)
TypeError: 'DataTypeClass' object is not an instance of 'str'

모든 칼럼 선택 (Selecting all columns)

Polars는 데이터프레임의 모든 칼럼을 가리키는 약식 표기로 all 함수를 제공합니다:

result = df.select(pl.all())
print(result.equals(df))
True

Note: all 함수는 col("*")의 문법적 설탕이지만, 인자 "*"가 특별한 경우라서, all이 영어 문장처럼 읽히기 때문에 all의 사용이 더 권장됩니다.

칼럼 제외 (Excluding columns)

Polars는 표현식 확장에서 특정 칼럼을 제외하는 메커니즘도 제공해요. 그때 쓰는 함수가 exclude인데, col과 정확히 같은 종류의 인자를 받습니다:

result = df.select(pl.all().exclude("^day_.*$"))
print(result)
shape: (5, 5)
┌────────┬───────────────────┬────────┬───────────┬──────────┐
│ ticker ┆ company_name      ┆ price  ┆ year_high ┆ year_low │
│ ---    ┆ ---               ┆ ---    ┆ ---       ┆ ---      │
│ str    ┆ str               ┆ f64    ┆ f64       ┆ f64      │
╞════════╪═══════════════════╪════════╪═══════════╪══════════╡
│ AAPL   ┆ Apple             ┆ 229.9  ┆ 237.23    ┆ 164.08   │
│ NVDA   ┆ NVIDIA            ┆ 138.93 ┆ 140.76    ┆ 39.23    │
│ MSFT   ┆ Microsoft         ┆ 420.56 ┆ 468.35    ┆ 324.39   │
│ GOOG   ┆ Alphabet (Google) ┆ 166.41 ┆ 193.31    ┆ 121.46   │
│ AMZN   ┆ Amazon            ┆ 188.4  ┆ 201.2     ┆ 118.35   │
└────────┴───────────────────┴────────┴───────────┴──────────┘

당연히 exclude 함수는 col 함수 다음에도 쓸 수 있어요:

result = df.select(pl.col(pl.Float64).exclude("^day_.*$"))
print(result)
shape: (5, 3)
┌────────┬───────────┬──────────┐
│ price  ┆ year_high ┆ year_low │
│ ---    ┆ ---       ┆ ---      │
│ f64    ┆ f64       ┆ f64      │
╞════════╪═══════════╪══════════╡
│ 229.9  ┆ 237.23    ┆ 164.08   │
│ 138.93 ┆ 140.76    ┆ 39.23    │
│ 420.56 ┆ 468.35    ┆ 324.39   │
│ 166.41 ┆ 193.31    ┆ 121.46   │
│ 188.4  ┆ 201.2     ┆ 118.35   │
└────────┴───────────┴──────────┘

칼럼 이름 바꾸기 (Column renaming)

기본적으로 표현식을 칼럼에 적용하면 결과는 원래 칼럼과 같은 이름을 유지해요. 그런데 이렇게 이름을 그대로 두는 게 의미상 틀린 경우가 있고, 어떤 경우에는 중복된 이름이 발생하면 Polars가 아예 오류를 던지기도 합니다:

from polars.exceptions import DuplicateError

gbp_usd_rate = 1.31  # As of 14th October 2024

try:
    df.select(
        pl.col("price") / gbp_usd_rate,  # This would be named "price"...
        pl.col("price") / eur_usd_rate,  # And so would this.
    )
except DuplicateError as err:
    print("DuplicateError:", err)

이런 오류를 막고, 사용자가 적절할 때 칼럼 이름을 바꿀 수 있도록 Polars는 칼럼 또는 칼럼 그룹의 이름을 바꾸는 여러 함수를 제공합니다.

alias로 단일 칼럼 이름 바꾸기 (Renaming a single column with alias)

alias 함수는 이 문서 전반에서 두루 쓰였는데, 단일 칼럼의 이름을 바꿀 수 있어요:

result = df.select(
    (pl.col("price") / gbp_usd_rate).alias("price (GBP)"),
    (pl.col("price") / eur_usd_rate).alias("price (EUR)"),
)

접두사·접미사 붙이기 (Prefixing and suffixing column names)

표현식 확장을 사용할 때는 alias를 쓸 수 없어요. alias는 단일 칼럼의 이름을 바꾸도록 설계되었기 때문이죠.

기존 이름에 정적인 접두사나 접미사를 붙이는 것으로 충분할 때는 name 네임스페이스의 prefixsuffix 함수를 쓸 수 있습니다:

result = df.select(
    (pl.col("^year_.*$") / eur_usd_rate).name.prefix("in_eur_"),
    (pl.col("day_high", "day_low") / gbp_usd_rate).name.suffix("_gbp"),
)
print(result)

동적 이름 치환 (Dynamic name replacement)

정적인 접두사/접미사가 부족하다면, name 네임스페이스는 기존 칼럼 이름을 받아 새 이름을 만드는 콜러블을 받는 map 함수도 제공합니다:

# There is also `.name.to_uppercase`, so this usage of `.map` is moot.
result = df.select(pl.all().name.map(str.upper))
print(result)

name 네임스페이스의 전체 내용은 API 레퍼런스를 참고하세요.

프로그래밍 방식으로 표현식 생성 (Programmatically generating expressions)

표현식 확장은 아주 유용하지만 모든 문제를 해결해 주지는 않아요. 예를 들어 데이터프레임 안의 주식 가격들의 일별·연도별 진폭(amplitude)을 계산하고 싶다면, 표현식 확장만으로는 해결되지 않습니다.

처음에는 for 루프를 쓰고 싶을 거예요:

result = df
for tp in ["day", "year"]:
    result = result.with_columns(
        (pl.col(f"{tp}_high") - pl.col(f"{tp}_low")).alias(f"{tp}_amplitude")
    )
print(result)

이렇게 하지 마세요. 대신 계산하고 싶은 모든 표현식을 프로그래밍 방식으로 생성해서, 그것들을 한 번에 하나의 컨텍스트에서만 사용하세요. 대략적으로 말하면, for 루프를 with_columns 컨텍스트로 바꾸는 셈이에요. 실제로는 다음과 같은 형태가 됩니다:

def amplitude_expressions(time_periods):
    for tp in time_periods:
        yield (pl.col(f"{tp}_high") - pl.col(f"{tp}_low")).alias(f"{tp}_amplitude")


result = df.with_columns(amplitude_expressions(["day", "year"]))
print(result)

이 방식은 같은 최종 결과를 만들어 내면서, 모든 표현식을 한 번에 지정해줌으로써 Polars가 다음을 할 수 있게 해줍니다:

  1. 쿼리 최적화를 더 잘 수행하고;
  2. 실제 계산의 실행을 병렬화합니다.

더 유연한 칼럼 선택 (More flexible column selections)

Polars는 selectors 서브모듈을 제공하는데, 표현식 확장을 위한 더 유연한 칼럼 선택을 작성할 수 있는 여러 함수가 들어 있어요.

Warning: 이 기능은 아직 Rust에서는 사용할 수 없어요. Polars 이슈 #10594를 참고하세요.

첫 번째 예시로, string 함수와 ends_with 함수, 그리고 selectors의 함수들이 지원하는 집합 연산을 써서 모든 문자열 칼럼과 이름이 "_high"로 끝나는 칼럼을 선택하는 방법을 볼게요:

import polars.selectors as cs

result = df.select(cs.string() | cs.ends_with("_high"))
print(result)

selectors 서브모듈은 데이터 타입에 따라 매칭하는 여러 셀렉터를 제공하는데, 그중 가장 유용한 건 유형의 전체 범주를 매칭하는 함수들이에요. 예를 들어 모든 숫자 데이터 타입에 대한 cs.numeric, 모든 시간 데이터 타입에 대한 cs.temporal 같은 거죠.

selectors 서브모듈은 또 칼럼 이름의 패턴에 따라 매칭하는 여러 셀렉터도 제공하는데, 위에서 본 cs.ends_with 같은 일반적 패턴을 더 편리하게 지정하게 해줍니다.

셀렉터를 집합 연산으로 결합 (Combining selectors with set operations)

여러 셀렉터를 집합 연산과 일반적인 Python 연산자를 써서 결합할 수 있어요:

연산자 연산
`A B`
A & B 교집합 (Intersection)
A - B 차집합 (Difference)
A ^ B 대칭 차집합 (Symmetric difference)
~A 여집합 (Complement)

다음 예시는 이름에 밑줄(_)을 포함하는 모든 비문자열 칼럼을 매칭합니다:

result = df.select(cs.contains("_") - cs.string())
print(result)

연산자 모호성 해결 (Resolving operator ambiguity)

표현식 함수는 셀렉터 위에 체이닝할 수 있어요:

result = df.select((cs.contains("_") - cs.string()) / eur_usd_rate)
print(result)

그런데 일부 연산자는 Polars 셀렉터와 표현식 양쪽에서 동작하도록 오버로딩되어 있어요. 예를 들어 셀렉터의 ~ 연산자는 집합 연산 "여집합"을 나타내고, 표현식에서는 부정(Boolean negation) 연산을 나타냅니다.

셀렉터를 사용한 뒤, 셀렉터에 대해 집합 연산자로 동작하는 연산자 중 하나를 표현식 컨텍스트에서 쓰고 싶다면, as_expr 함수를 쓰면 돼요.

아래에서는 "has_partner", "has_kids", "has_tattoos" 칼럼의 불리언 값을 부정하고 싶다고 가정할게요. 조심하지 않으면, ~ 연산자와 셀렉터 cs.starts_with("has_")의 조합이 우리가 원하지 않는 칼럼들을 오히려 선택하게 됩니다:

people = pl.DataFrame(
    {
        "name": ["Anna", "Bob"],
        "has_partner": [True, False],
        "has_kids": [False, False],
        "has_tattoos": [True, False],
        "is_alive": [True, True],
    }
)

wrong_result = people.select((~cs.starts_with("has_")).name.prefix("not_"))
print(wrong_result)

올바른 해법은 as_expr를 쓰는 거예요:

result = people.select((~cs.starts_with("has_").as_expr()).name.prefix("not_"))
print(result)

셀렉터 디버깅 (Debugging selectors)

손에 쥔 것이 Polars 셀렉터인지 확실하지 않을 때는 cs.is_selector 함수로 확인할 수 있어요:

print(cs.is_selector(~cs.starts_with("has_").as_expr()))
False

이렇게 하면 표현식으로 작업하고 있다고 생각하면서 실제로는 셀렉터로 작업하고 있는 모호한 상황을 피할 수 있어요.

또 하나 유용한 디버깅 유틸리티는 expand_selector 함수입니다. 대상 프레임이나 스키마가 주어지면, 주어진 셀렉터가 어떤 칼럼들로 확장되는지 확인할 수 있어요:

print(
    cs.expand_selector(
        people,
        cs.starts_with("has_"),
    )
)
('has_partner', 'has_kids', 'has_tattoos')

셀렉터 전체 참조 (Complete reference)

아래 표들은 selectors 서브모듈에서 제공하는 함수들을 동작 유형별로 정리한 것입니다.

셀렉터 - 데이터 타입 (Selectors for data types)

칼럼의 데이터 타입에 따라 매칭하는 셀렉터입니다:

셀렉터 함수 매칭되는 데이터 타입
binary Binary
boolean Boolean
by_dtype 인자로 지정된 데이터 타입
categorical Categorical
date Date
datetime Datetime, 선택적으로 시간 단위/존으로 필터링
decimal Decimal
duration Duration, 선택적으로 시간 단위로 필터링
float 정밀도와 무관하게 모든 float 타입
integer 정밀도와 무관하게 모든 정수 타입 (signed, unsigned)
numeric 모든 숫자 타입, 즉 정수, float, Decimal
signed_integer 정밀도와 무관하게 모든 signed 정수 타입
string String
temporal 모든 시간 데이터 타입, 즉 Date, Datetime, Duration
time Time
unsigned_integer 정밀도와 무관하게 모든 unsigned 정수 타입

셀렉터 - 칼럼 이름 패턴 (Selectors for column name patterns)

칼럼 이름 패턴에 따라 매칭하는 셀렉터입니다:

셀렉터 함수 선택되는 칼럼
alpha 알파벳 이름을 가진 칼럼
alphanumeric 영숫자 이름(문자와 숫자 0-9)을 가진 칼럼
by_name 인자로 지정된 이름을 가진 칼럼
contains 이름에 지정된 부분 문자열을 포함하는 칼럼
digit 숫자 이름(숫자 0-9만)을 가진 칼럼
ends_with 이름이 주어진 부분 문자열로 끝나는 칼럼
matches 이름이 주어진 정규 표현식 패턴과 일치하는 칼럼
starts_with 이름이 주어진 부분 문자열로 시작하는 칼럼

위치 기반 셀렉터 (Positional selectors)

칼럼의 위치에 따라 매칭하는 셀렉터입니다:

셀렉터 함수 선택되는 칼럼
all 모든 칼럼
by_index 지정된 인덱스의 칼럼
first 컨텍스트의 첫 번째 칼럼
last 컨텍스트의 마지막 칼럼

기타 함수 (Miscellaneous functions)

selectors 서브모듈은 다음 함수도 제공합니다:

함수 동작
as_expr* 셀렉터를 표현식으로 변환
exclude 주어진 이름, 데이터 타입, 또는 셀렉터와 일치하는 칼럼을 제외한 모든 칼럼 선택
expand_selector 특정 프레임 또는 대상 스키마에 대해 셀렉터를 일치하는 칼럼들로 확장
is_selector 주어진 객체/표현식이 셀렉터인지 확인

*as_exprselectors 서브모듈에 정의된 함수가 아니라, 셀렉터에 정의된 메서드입니다.

더 알아보기 (Learn more)