이미지 로딩 중...

Polars 윈도우 함수로 데이터 분석 레벨업하기 - 슬라이드 1/11
A

AI Generated

2025. 11. 15. · 2 Views

Polars 윈도우 함수로 데이터 분석 레벨업하기

데이터 분석에서 그룹별 통계나 이동 평균을 계산할 때 필수적인 윈도우 함수를 Polars로 마스터해보세요. 판다스보다 빠르고 직관적인 윈도우 함수 활용법을 실무 예제와 함께 배워봅니다.


목차

  1. over() 기본 - 그룹별 집계의 시작
  2. rolling() - 이동 평균과 시계열 분석
  3. rank() - 순위 계산의 정석
  4. shift() - 이전/다음 값 참조하기
  5. cum_sum() - 누적 합계로 트렌드 파악
  6. partition_by() - 그룹별 상위 N개 추출
  7. first()와 last() - 그룹의 첫/마지막 값
  8. filter() 내에서 윈도우 함수 활용
  9. lead()와 lag() - 다음/이전 값 참조하기
  10. ntile() - 데이터 분위수로 나누기

1. over() 기본 - 그룹별 집계의 시작

시작하며

여러분이 매출 데이터를 분석할 때 각 제품의 매출액과 함께 해당 카테고리 전체의 평균 매출을 같은 행에 표시하고 싶었던 적 있나요? 판다스에서는 groupby()와 merge()를 반복적으로 사용해야 했던 그 번거로운 작업 말이죠.

이런 문제는 실제 데이터 분석 현장에서 매일 발생합니다. 그룹별 통계를 원본 데이터와 함께 보고 싶지만, 데이터프레임을 여러 번 조인해야 하는 복잡한 과정 때문에 코드가 길어지고 성능도 저하됩니다.

바로 이럴 때 필요한 것이 Polars의 over() 함수입니다. 단 한 줄로 그룹별 집계와 원본 데이터를 동시에 처리할 수 있습니다.

개요

간단히 말해서, over() 함수는 그룹별로 계산한 값을 원본 데이터의 모든 행에 브로드캐스트하는 윈도우 함수입니다. SQL의 OVER() 절과 동일한 개념으로, 데이터를 그룹화하되 행의 개수는 유지하면서 집계 결과를 각 행에 매핑합니다.

예를 들어, 직원별 급여와 부서 평균 급여를 한 번에 조회하고 싶을 때, 또는 제품별 판매량과 카테고리 전체 판매량을 비교하고 싶을 때 매우 유용합니다. 판다스에서는 groupby()로 집계한 후 merge()나 transform()을 사용했다면, Polars에서는 over()로 한 번에 처리할 수 있습니다.

코드가 간결해질 뿐만 아니라 성능도 월등히 빠릅니다. over()의 핵심 특징은 첫째, 원본 행 수를 유지한다는 점, 둘째, 여러 컬럼으로 그룹화가 가능하다는 점, 셋째, 다양한 집계 함수와 조합할 수 있다는 점입니다.

이러한 특징들이 복잡한 분석 쿼리를 단순하게 만들어줍니다.

코드 예제

import polars as pl

# 매출 데이터 생성
df = pl.DataFrame({
    "product": ["A", "B", "C", "A", "B", "C"],
    "category": ["전자", "전자", "가구", "전자", "전자", "가구"],
    "sales": [100, 150, 200, 120, 180, 220]
})

# 카테고리별 평균 매출을 각 행에 추가
result = df.with_columns([
    pl.col("sales").mean().over("category").alias("category_avg"),
    pl.col("sales").sum().over("category").alias("category_total")
])

print(result)

설명

이것이 하는 일: 위 코드는 제품별 매출 데이터에서 각 제품이 속한 카테고리의 평균 매출과 총 매출을 계산하여 원본 데이터에 추가합니다. 첫 번째로, pl.col("sales").mean().over("category")는 "category" 컬럼을 기준으로 그룹을 나누고, 각 그룹의 sales 평균을 계산합니다.

이 평균값은 해당 카테고리에 속한 모든 행에 동일하게 적용됩니다. 왜냐하면 over()는 그룹별 결과를 원본 행 수만큼 확장(브로드캐스트)하기 때문입니다.

그 다음으로, pl.col("sales").sum().over("category")가 실행되면서 각 카테고리의 총 매출을 계산합니다. 내부적으로 Polars는 메모리 효율적인 방식으로 그룹별 계산을 수행하고, 결과를 원본 데이터프레임과 정렬하여 매핑합니다.

마지막으로, with_columns()가 이 두 개의 새로운 컬럼(category_avg, category_total)을 원본 데이터프레임에 추가하여 최종적으로 6개 행 그대로 유지하면서 추가 정보가 담긴 데이터프레임을 만들어냅니다. 여러분이 이 코드를 사용하면 각 제품의 매출이 카테고리 평균 대비 어떤지, 전체 매출에서 차지하는 비율은 얼마인지 즉시 파악할 수 있습니다.

판다스에서 3-4줄로 작성해야 할 코드를 1줄로 해결하며, 대용량 데이터에서는 10배 이상 빠른 성능을 보입니다.

실전 팁

💡 over()는 여러 컬럼으로 그룹화할 수 있습니다. over(["category", "region"])처럼 리스트를 전달하면 다중 그룹 집계가 가능합니다.

💡 흔한 실수: over() 없이 mean()만 쓰면 전체 평균이 계산됩니다. 반드시 over("컬럼명")을 붙여야 그룹별 계산이 됩니다.

💡 성능 최적화: 같은 그룹에 대해 여러 집계를 할 때는 with_columns() 안에서 한 번에 처리하세요. 메모리 효율이 훨씬 좋습니다.

💡 디버깅 팁: 결과가 예상과 다르면 먼저 group_by().agg()로 그룹별 값을 확인한 후 over()로 전환하세요. 집계 로직을 먼저 검증하는 것이 중요합니다.

💡 over()와 filter()를 조합하면 "상위 10% 매출을 기록한 제품만 필터링" 같은 고급 분석이 가능합니다.


2. rolling() - 이동 평균과 시계열 분석

시작하며

여러분이 주식 가격 데이터를 분석하면서 7일 이동 평균선을 그려야 하는 상황을 상상해보세요. 매일의 가격 데이터에서 최근 7일간의 평균을 계산하되, 각 날짜마다 다른 7일 구간을 봐야 합니다.

이런 문제는 시계열 데이터 분석에서 핵심적입니다. 주가 분석, 매출 트렌드 파악, 센서 데이터 노이즈 제거 등 다양한 분야에서 이동 윈도우 계산이 필요하지만, 반복문으로 구현하면 속도가 너무 느립니다.

바로 이럴 때 필요한 것이 Polars의 rolling() 함수입니다. 벡터화된 연산으로 이동 평균, 이동 합계, 이동 표준편차 등을 초고속으로 계산합니다.

개요

간단히 말해서, rolling() 함수는 지정된 윈도우 크기만큼 슬라이딩하면서 각 위치에서 집계 함수를 적용하는 시계열 분석 도구입니다. 윈도우 크기를 지정하면 첫 번째 행부터 해당 크기만큼의 데이터로 계산을 시작하고, 한 행씩 이동하면서 계속 계산합니다.

예를 들어, 3일 이동 평균이라면 1-3일, 2-4일, 3-5일... 이런 식으로 윈도우가 슬라이딩합니다.

주식 기술적 분석의 이동평균선(MA), 매출 데이터의 트렌드 분석, IoT 센서 데이터의 노이즈 제거 등에 필수적입니다. 기존에는 for 루프로 각 윈도우를 수동으로 슬라이싱했다면, 이제는 rolling_mean(), rolling_sum(), rolling_std() 같은 함수로 한 번에 처리할 수 있습니다.

rolling()의 핵심 특징은 첫째, 시간 기반(예: 7일) 또는 행 기반(예: 7개 행) 윈도우를 지정할 수 있고, 둘째, min_periods 옵션으로 최소 데이터 개수를 설정할 수 있으며, 셋째, 다양한 집계 함수(mean, sum, std, max, min 등)를 지원한다는 점입니다. 이러한 특징들이 시계열 분석을 간단하고 빠르게 만들어줍니다.

코드 예제

import polars as pl
from datetime import datetime, timedelta

# 일별 주가 데이터 생성
dates = [datetime(2024, 1, 1) + timedelta(days=i) for i in range(10)]
df = pl.DataFrame({
    "date": dates,
    "price": [100, 102, 101, 105, 103, 107, 110, 108, 112, 115]
})

# 3일 이동 평균 계산
result = df.with_columns([
    pl.col("price").rolling_mean(window_size=3).alias("ma_3"),
    pl.col("price").rolling_std(window_size=3).alias("volatility_3")
])

print(result)

설명

이것이 하는 일: 위 코드는 10일간의 주가 데이터에서 3일 이동 평균과 3일 이동 표준편차(변동성)를 계산하여 트렌드를 분석합니다. 첫 번째로, rolling_mean(window_size=3)은 현재 행을 포함한 최근 3개 행의 평균을 계산합니다.

첫 번째 행에서는 데이터가 1개뿐이므로 100.0, 두 번째 행에서는 (100+102)/2 = 101.0, 세 번째 행부터는 완전한 3개 윈도우로 (100+102+101)/3 = 101.0이 계산됩니다. 기본적으로 min_periods=1이라서 데이터가 부족해도 계산이 진행됩니다.

그 다음으로, rolling_std(window_size=3)가 실행되면서 각 윈도우의 표준편차를 계산합니다. 내부적으로 Polars는 SIMD 연산을 활용해 벡터화된 방식으로 모든 윈도우를 동시에 처리하므로, 파이썬 반복문보다 수백 배 빠릅니다.

마지막으로, with_columns()가 이 두 개의 새로운 컬럼을 추가하여 원본 가격 데이터와 함께 이동 평균선과 변동성 지표를 한눈에 볼 수 있는 데이터프레임을 만들어냅니다. 여러분이 이 코드를 사용하면 주가가 이동 평균선 위에 있는지(상승 추세), 변동성이 커지는지(리스크 증가) 등을 즉시 파악할 수 있습니다.

금융 데이터 분석에서는 5일선, 20일선, 60일선 등 여러 이동평균을 동시에 계산해 골든크로스, 데드크로스 같은 매매 신호를 포착합니다.

실전 팁

💡 min_periods 파라미터를 설정하면 윈도우 크기가 충분하지 않을 때 null을 반환합니다. rolling_mean(window_size=7, min_periods=7)처럼 쓰면 7개 데이터가 모일 때까지 null이 됩니다.

💡 흔한 실수: 날짜 컬럼이 정렬되지 않은 상태에서 rolling을 쓰면 잘못된 결과가 나옵니다. 반드시 sort("date")를 먼저 실행하세요.

💡 성능 최적화: 여러 윈도우 크기(3일, 7일, 30일)를 계산할 때는 한 번의 with_columns()에서 모두 처리하면 데이터를 한 번만 읽어서 효율적입니다.

💡 center=True 옵션을 쓰면 중앙 정렬 이동 평균이 됩니다. 미래 데이터 누수 없이 과거 데이터만으로 분석하려면 기본값(center=False)을 사용하세요.

💡 rolling_sum()과 shift()를 조합하면 "전일 대비 변화량" 같은 복잡한 지표도 쉽게 계산할 수 있습니다.


3. rank() - 순위 계산의 정석

시작하며

여러분이 학생들의 성적 데이터를 분석하면서 각 과목별로 순위를 매기고, 동점자는 같은 순위로 처리하고 싶었던 적 있나요? 전체 순위가 아니라 반별로, 또는 학년별로 순위를 나누어야 하는 복잡한 요구사항 말이죠.

이런 문제는 실제 데이터 분석에서 자주 발생합니다. 매출 순위, 성과 순위, 검색 결과 순위 등 순위는 데이터 분석의 핵심이지만, 동점자 처리, 그룹별 순위, 오름차순/내림차순 등 다양한 요구사항을 구현하려면 복잡한 로직이 필요합니다.

바로 이럴 때 필요한 것이 Polars의 rank() 함수입니다. SQL의 RANK(), DENSE_RANK(), ROW_NUMBER()와 동일한 기능을 제공하며, over()와 조합하면 그룹별 순위도 쉽게 계산할 수 있습니다.

개요

간단히 말해서, rank() 함수는 데이터의 순위를 계산하되, 동점자 처리 방식을 다양하게 선택할 수 있는 순위 함수입니다. 동점자가 있을 때 같은 순위를 부여할지(RANK), 순위를 건너뛰지 않고 연속으로 매길지(DENSE_RANK), 아예 무시하고 행 번호를 매길지(ROW_NUMBER) 선택할 수 있습니다.

예를 들어, 영업사원별 매출 순위를 매기되 동일 매출은 같은 순위로 처리하거나, 제품별 판매량 상위 10개를 추출할 때 매우 유용합니다. 판다스에서는 rank() 메서드가 있지만 그룹별 순위를 매기려면 groupby().rank()를 사용해야 했다면, Polars에서는 rank().over()로 더 직관적으로 표현할 수 있습니다.

rank()의 핵심 특징은 첫째, method 파라미터로 동점자 처리 방식(average, min, max, dense, ordinal)을 선택할 수 있고, 둘째, descending 옵션으로 오름차순/내림차순을 지정할 수 있으며, 셋째, over()와 결합하여 그룹별 순위를 쉽게 계산할 수 있다는 점입니다. 이러한 특징들이 복잡한 순위 로직을 단순하게 만들어줍니다.

코드 예제

import polars as pl

# 학생 성적 데이터
df = pl.DataFrame({
    "name": ["철수", "영희", "민수", "지영", "동현"],
    "class": ["A", "A", "B", "B", "A"],
    "score": [95, 95, 88, 92, 85]
})

# 전체 순위와 반별 순위 계산
result = df.with_columns([
    # 전체 순위 (동점자는 같은 순위, 다음 순위는 건너뜀)
    pl.col("score").rank(method="min", descending=True).alias("rank_overall"),
    # 반별 순위 (밀집 순위 - 순위 건너뛰지 않음)
    pl.col("score").rank(method="dense", descending=True).over("class").alias("rank_in_class")
])

print(result)

설명

이것이 하는 일: 위 코드는 학생들의 성적 데이터에서 전체 순위와 반별 순위를 각각 다른 방식으로 계산합니다. 첫 번째로, rank(method="min", descending=True)는 전체 학생 중에서 점수가 높은 순서대로 순위를 매깁니다.

method="min"은 동점자가 있을 때 가장 작은 순위를 부여하는 방식입니다. 철수와 영희가 모두 95점이면 둘 다 1위가 되고, 다음 순위는 3위로 건너뜁니다(2위는 없음).

descending=True는 높은 점수가 1위가 되도록 내림차순 정렬을 의미합니다. 그 다음으로, rank(method="dense", descending=True).over("class")가 실행되면서 각 반 내에서의 순위를 계산합니다.

method="dense"는 밀집 순위 방식으로, 동점자가 있어도 다음 순위를 건너뛰지 않습니다. A반에서 철수와 영희가 공동 1위라도 동현은 2위가 됩니다(3위가 아님).

over("class")가 있어서 A반과 B반의 순위가 각각 독립적으로 계산됩니다. 마지막으로, with_columns()가 이 두 개의 순위 컬럼을 추가하여 각 학생의 전체 위치와 반 내 위치를 동시에 파악할 수 있는 데이터프레임을 만들어냅니다.

여러분이 이 코드를 사용하면 "전체 3위지만 우리 반에서는 1위"처럼 다각도로 데이터를 분석할 수 있습니다. 실무에서는 영업 성과 분석(전국 순위 vs 지역 순위), 제품 판매 순위(전체 카테고리 vs 세부 카테고리), 웹사이트 트래픽 분석(전체 페이지 순위 vs 섹션별 순위) 등에 활용됩니다.

실전 팁

💡 method 옵션 정리: "min"(표준 순위), "dense"(밀집 순위), "ordinal"(행 번호), "average"(평균 순위). 상위 N개를 필터링할 때는 "dense"가 유용합니다.

💡 흔한 실수: rank()는 1부터 시작합니다. 0부터 시작하는 인덱스와 혼동하지 마세요. 순위 1위는 실제로 1입니다.

💡 성능 팁: 순위를 매긴 후 상위 10개만 필요하다면 filter(pl.col("rank") <= 10)로 바로 필터링하세요. 정렬보다 빠릅니다.

💡 null 값 처리: 기본적으로 null은 순위 계산에서 제외되지만, nulls_last=False 옵션으로 null을 맨 앞에 배치할 수도 있습니다.

💡 여러 컬럼 기준 순위: pl.struct(["score", "age"]).rank()처럼 구조체를 만들면 여러 컬럼을 기준으로 순위를 매길 수 있습니다(먼저 score로 정렬, 동점이면 age로 정렬).


4. shift() - 이전/다음 값 참조하기

시작하며

여러분이 일별 매출 데이터를 분석하면서 "전일 대비 매출 증감률"을 계산하고 싶은 상황을 생각해보세요. 오늘 매출에서 어제 매출을 빼야 하는데, 어제 데이터는 이전 행에 있습니다.

이런 문제는 시계열 데이터에서 가장 흔합니다. 전일 대비 변화, 전월 대비 성장률, 전년 동기 대비 비교 등 "이전 시점과의 비교"는 트렌드 분석의 핵심이지만, 인덱스로 수동 접근하면 코드가 복잡해지고 경계 조건(첫 행) 처리도 까다롭습니다.

바로 이럼 때 필요한 것이 Polars의 shift() 함수입니다. 데이터를 위나 아래로 이동시켜서 이전 행이나 다음 행의 값을 현재 행에서 쉽게 참조할 수 있습니다.

개요

간단히 말해서, shift() 함수는 컬럼의 값을 지정한 만큼 위쪽이나 아래쪽으로 이동시켜서, 이전 또는 다음 행의 값을 현재 행에서 참조할 수 있게 해주는 함수입니다. shift(1)은 값을 한 칸 아래로 밀어서 이전 행의 값을 가져오고, shift(-1)은 한 칸 위로 밀어서 다음 행의 값을 가져옵니다.

예를 들어, 주식의 전일 종가, 매출의 전월 데이터, 센서 측정값의 이전 시점 값 등을 참조할 때 필수적입니다. 이동된 자리는 자동으로 null로 채워집니다.

판다스에서는 shift() 메서드가 있지만 그룹별로 shift하려면 groupby().shift()를 써야 했다면, Polars에서는 shift().over()로 더 간결하게 표현할 수 있습니다. shift()의 핵심 특징은 첫째, 양수는 이전 값, 음수는 다음 값을 가져오며, 둘째, fill_value 옵션으로 null 대신 다른 값을 채울 수 있고, 셋째, over()와 결합하여 그룹별로 독립적인 shift가 가능하다는 점입니다.

이러한 특징들이 시계열 비교 분석을 단순하게 만들어줍니다.

코드 예제

import polars as pl
from datetime import datetime, timedelta

# 일별 매출 데이터
dates = [datetime(2024, 1, 1) + timedelta(days=i) for i in range(7)]
df = pl.DataFrame({
    "date": dates,
    "product": ["A", "A", "A", "B", "B", "B", "B"],
    "sales": [100, 120, 110, 200, 220, 210, 230]
})

# 전일 매출과 증감률 계산
result = df.with_columns([
    pl.col("sales").shift(1).over("product").alias("prev_sales"),
    ((pl.col("sales") - pl.col("sales").shift(1).over("product")) /
     pl.col("sales").shift(1).over("product") * 100).alias("growth_rate")
])

print(result)

설명

이것이 하는 일: 위 코드는 제품별 일일 매출 데이터에서 각 날짜의 전일 매출과 전일 대비 증감률을 계산합니다. 첫 번째로, pl.col("sales").shift(1).over("product")는 sales 컬럼의 값을 한 칸 아래로 이동시키되, product별로 독립적으로 처리합니다.

제품 A의 첫 번째 행은 이전 데이터가 없으므로 null이 되고, 두 번째 행은 첫 번째 행의 값(100)을 가져옵니다. over("product")가 중요한 이유는 제품 A의 마지막 행 다음에 제품 B가 나와도, 제품 B의 첫 행은 제품 A의 값을 참조하지 않고 null로 시작하기 때문입니다.

그 다음으로, growth_rate 계산이 실행되면서 (현재 매출 - 전일 매출) / 전일 매출 * 100 공식으로 증감률을 구합니다. 내부적으로 Polars는 shift된 값과 원본 값의 산술 연산을 벡터화하여 처리하므로, 반복문 없이 모든 행을 동시에 계산합니다.

마지막으로, with_columns()가 prev_sales와 growth_rate 컬럼을 추가하여 매일의 매출과 함께 전일 매출, 증감률을 한눈에 볼 수 있는 데이터프레임을 만들어냅니다. 첫 행은 전일 데이터가 없어서 null이 됩니다.

여러분이 이 코드를 사용하면 매출이 전일 대비 몇 퍼센트 증가했는지, 어느 날 급등했는지 등을 즉시 파악할 수 있습니다. 실무에서는 주가의 일일 수익률, 웹사이트 방문자의 일일 증감, 재고 변동량 등 다양한 변화 추적에 활용됩니다.

shift()와 rolling()을 함께 쓰면 "7일 이동평균 대비 오늘 매출" 같은 고급 지표도 만들 수 있습니다.

실전 팁

💡 shift(1)은 이전 값, shift(-1)은 다음 값입니다. 헷갈리기 쉬우니 주의하세요. 양수는 데이터를 아래로 밀고, 음수는 위로 밉니다.

💡 흔한 실수: 그룹별 shift 없이 전체 컬럼을 shift하면 다른 그룹의 값을 참조하게 됩니다. 반드시 over("그룹컬럼")을 사용하세요.

💡 성능 팁: shift를 여러 번 호출할 때(전일, 전전일, 전전전일)는 각각 shift(1), shift(2), shift(3)으로 한 번에 계산하세요. 중간 결과를 저장하지 않아도 됩니다.

💡 null 처리: shift로 생긴 null은 fill_null(0)로 0으로 채우거나, forward_fill()로 이전 값으로 채울 수 있습니다. 증감률 계산 전에 처리하세요.

💡 디버깅: shift 결과가 이상하면 먼저 sort()로 날짜 순서를 확인하세요. 정렬되지 않은 데이터에 shift를 쓰면 의미 없는 비교가 됩니ans.


5. cum_sum() - 누적 합계로 트렌드 파악

시작하며

여러분이 월별 매출 데이터를 보면서 "연초부터 지금까지의 누적 매출"을 계산하고 싶은 상황을 생각해보세요. 1월 매출, 1-2월 합계, 1-3월 합계...

이렇게 계속 누적되는 값 말이죠. 이런 문제는 재무 분석, 목표 달성률 추적, 재고 관리 등에서 필수적입니다.

연초부터 현재까지의 누적 실적(YTD), 분기별 누적 매출, 일일 신규 가입자 누적 등 "지금까지 쌓인 총량"을 보여줘야 하는데, 반복문으로 하나씩 더하면 비효율적입니다. 바로 이럴 때 필요한 것이 Polars의 cum_sum() 함수입니다.

첫 행부터 현재 행까지의 누적 합계를 자동으로 계산하며, SQL의 SUM() OVER (ORDER BY ...) 윈도우 함수와 동일한 기능을 제공합니다.

개요

간단히 말해서, cum_sum() 함수는 첫 번째 행부터 현재 행까지의 누적 합계를 각 행에 계산해주는 누적 집계 함수입니다. 각 행의 값은 이전 행까지의 누적 합계에 현재 행의 값을 더한 결과가 됩니다.

예를 들어, 월별 신규 회원 수가 [100, 150, 120]이면 누적 회원 수는 [100, 250, 370]이 됩니다. 재무제표의 누적 손익, 마케팅의 누적 전환수, 제조업의 누적 생산량 등 "지금까지의 총계"를 추적할 때 매우 유용합니다.

판다스에서는 cumsum() 메서드를 제공하지만 그룹별 누적을 하려면 groupby().cumsum()을 써야 했다면, Polars에서는 cum_sum().over()로 더 명확하게 표현할 수 있습니다. cum_sum()의 핵심 특징은 첫째, 각 행마다 시작부터 현재까지의 합계를 저장하므로 마지막 행은 전체 합계가 되고, 둘째, reverse=True 옵션으로 역방향 누적(현재부터 끝까지)도 가능하며, 셋째, over()와 결합하여 그룹별로 독립적인 누적 계산이 가능하다는 점입니다.

이러한 특징들이 복잡한 누적 분석을 단순하게 만들어줍니다.

코드 예제

import polars as pl

# 월별 매출 데이터
df = pl.DataFrame({
    "month": ["2024-01", "2024-02", "2024-03", "2024-01", "2024-02", "2024-03"],
    "region": ["서울", "서울", "서울", "부산", "부산", "부산"],
    "sales": [1000, 1200, 900, 800, 850, 920]
})

# 지역별 누적 매출 계산
result = df.with_columns([
    pl.col("sales").cum_sum().over("region").alias("cumulative_sales"),
    # 누적 매출의 전체 매출 대비 비율
    (pl.col("sales").cum_sum().over("region") /
     pl.col("sales").sum().over("region") * 100).alias("ytd_percentage")
])

print(result)

설명

이것이 하는 일: 위 코드는 지역별 월별 매출 데이터에서 각 월까지의 누적 매출과 전체 매출 대비 누적 비율을 계산합니다. 첫 번째로, pl.col("sales").cum_sum().over("region")은 sales 컬럼의 누적 합계를 계산하되, region별로 독립적으로 처리합니다.

서울 지역의 첫 번째 행(1월)은 1000, 두 번째 행(2월)은 1000+1200=2200, 세 번째 행(3월)은 2200+900=3100이 됩니다. over("region")이 있어서 부산 지역은 부산만의 누적 합계를 별도로 계산합니다(800, 1650, 2570).

그 다음으로, ytd_percentage 계산이 실행되면서 누적 매출을 전체 매출로 나눈 비율을 구합니다. pl.col("sales").sum().over("region")은 각 지역의 총 매출이므로, 서울은 3100, 부산은 2570입니다.

내부적으로 Polars는 누적 합계와 전체 합계를 모두 벡터화하여 계산하므로 매우 빠릅니다. 마지막으로, with_columns()가 cumulative_sales와 ytd_percentage 컬럼을 추가하여 각 월의 매출과 함께 누적 실적, 목표 달성률을 한눈에 볼 수 있는 데이터프레임을 만들어냅니다.

3월 데이터를 보면 전체 연간 매출 중 몇 퍼센트를 달성했는지 알 수 있습니다. 여러분이 이 코드를 사용하면 분기별 목표 대비 진척도, 월별 누적 실적 추이, 연간 매출 달성률 등을 실시간으로 모니터링할 수 있습니다.

실무에서는 재무팀의 손익 누적 추적, 영업팀의 분기별 목표 달성률, 마케팅팀의 캠페인 누적 전환수 등에 활용됩니다. cum_sum()과 shift()를 조합하면 "이번 달 누적 증가분"처럼 더 세밀한 분석도 가능합니다.

실전 팁

💡 reverse=True 옵션을 쓰면 역방향 누적 합계가 됩니다. 마지막 행부터 시작해서 첫 행까지 더하므로, "남은 재고" 같은 개념에 유용합니다.

💡 흔한 실수: 날짜 순으로 정렬하지 않고 cum_sum()을 쓰면 의미 없는 누적이 됩니다. 반드시 sort("date")를 먼저 실행하세요.

💡 성능 팁: cum_sum(), cum_max(), cum_min()을 동시에 계산할 때는 한 번의 with_columns()에 모두 넣으면 데이터를 한 번만 스캔합니다.

💡 null 처리: 중간에 null이 있으면 누적이 중단되지 않고 건너뜁니다. null을 0으로 처리하려면 fill_null(0)을 먼저 적용하세요.

💡 cum_sum()의 마지막 행은 전체 합계와 같습니다. 전체 합계를 확인하는 용도로도 사용할 수 있습니다. 결과의 마지막 값과 sum()이 일치하는지 검증하면 데이터 정합성을 체크할 수 있습니다.


6. partition_by() - 그룹별 상위 N개 추출

시작하며

여러분이 카테고리별로 매출 상위 3개 제품을 추출하고 싶은 상황을 상상해보세요. 전체에서 3개가 아니라, 전자제품 카테고리에서 3개, 가구 카테고리에서 3개...

이런 식으로 각 그룹마다 독립적으로 상위 N개를 가져와야 합니다. 이런 문제는 데이터 분석에서 매우 흔합니다.

부서별 성과 상위자, 지역별 인기 상품, 카테고리별 추천 아이템 등 "각 그룹에서 베스트 N"을 뽑는 것은 추천 시스템, 대시보드, 보고서의 핵심이지만, 일반적인 필터링으로는 구현하기 어렵습니다. 바로 이럴 때 필요한 것이 Polars의 partition_by()와 윈도우 함수의 조합입니다.

데이터를 그룹으로 나눈 후 각 그룹 내에서 순위를 매기고, 상위 N개만 필터링할 수 있습니다.

개요

간단히 말해서, partition_by()는 데이터를 그룹으로 분할하고 각 그룹에 대해 독립적인 연산을 수행하는 함수로, rank().over()와 filter()를 조합하여 그룹별 상위 N개를 추출하는 강력한 패턴을 만들 수 있습니다. SQL의 PARTITION BY와 유사한 개념으로, 전체 데이터를 여러 파티션으로 나누되 각 파티션 내에서만 순위나 집계를 계산합니다.

예를 들어, 전자상거래 사이트에서 카테고리별 베스트 상품 3개를 메인 페이지에 노출하거나, 부서별 상위 성과자 5명을 시상할 때 매우 유용합니다. 판다스에서는 groupby().apply(lambda x: x.nlargest(3))처럼 복잡하게 작성해야 했다면, Polars에서는 rank().over()와 filter()를 체이닝하여 더 명확하고 빠르게 표현할 수 있습니다.

이 패턴의 핵심 특징은 첫째, 각 그룹이 독립적으로 처리되므로 그룹 크기가 달라도 문제없고, 둘째, rank()의 다양한 method 옵션으로 동점자 처리를 세밀하게 제어할 수 있으며, 셋째, filter() 조건을 바꿔서 상위 N개, 하위 N개, 중간 N개 등 자유롭게 추출할 수 있다는 점입니다. 이러한 특징들이 복잡한 그룹별 필터링을 단순하게 만들어줍니다.

코드 예제

import polars as pl

# 제품 판매 데이터
df = pl.DataFrame({
    "category": ["전자", "전자", "전자", "전자", "가구", "가구", "가구"],
    "product": ["노트북", "태블릿", "스마트폰", "이어폰", "책상", "의자", "책장"],
    "sales": [5000, 3000, 8000, 1500, 2000, 2500, 1800]
})

# 카테고리별 매출 상위 2개 제품 추출
result = (
    df.with_columns(
        pl.col("sales").rank(method="dense", descending=True)
        .over("category")
        .alias("rank_in_category")
    )
    .filter(pl.col("rank_in_category") <= 2)
    .sort(["category", "rank_in_category"])
)

print(result)

설명

이것이 하는 일: 위 코드는 제품 판매 데이터에서 각 카테고리별로 매출 상위 2개 제품만 추출합니다. 첫 번째로, pl.col("sales").rank(method="dense", descending=True).over("category")는 각 카테고리 내에서 매출 순위를 계산합니다.

전자 카테고리에서는 스마트폰(8000)이 1위, 노트북(5000)이 2위, 태블릿(3000)이 3위, 이어폰(1500)이 4위가 됩니다. 가구 카테고리는 독립적으로 의자(2500)가 1위, 책상(2000)이 2위, 책장(1800)이 3위로 매겨집니다.

over("category")가 핵심으로, 이것이 없으면 전체 순위가 됩니다. 그 다음으로, filter(pl.col("rank_in_category") <= 2)가 실행되면서 각 카테고리에서 1, 2위만 남깁니다.

내부적으로 Polars는 먼저 모든 행에 순위를 매긴 후, 조건에 맞는 행만 선택하는 방식으로 동작합니다. 이 과정이 벡터화되어 있어서 수백만 행도 빠르게 처리됩니다.

마지막으로, sort(["category", "rank_in_category"])가 카테고리와 순위 순으로 정렬하여 보기 좋은 형태로 만들어냅니다. 결과는 전자 카테고리 2개(스마트폰, 노트북), 가구 카테고리 2개(의자, 책상) 총 4개 행이 됩니다.

여러분이 이 코드를 사용하면 대시보드에서 "카테고리별 베스트 상품", 추천 시스템에서 "장르별 인기 영화 Top 5", 인사 시스템에서 "부서별 고성과자" 등을 효율적으로 추출할 수 있습니다. 실무에서는 이 패턴을 응용해서 "지역별 상위 10% 매장", "연령대별 인기 상품 3개", "시간대별 최다 검색어 5개" 등 다양한 분석에 활용됩니다.

partition_by()를 명시적으로 쓰지 않아도 over()가 파티셔닝 역할을 하므로, rank().over() + filter() 조합만 기억하면 됩니다.

실전 팁

💡 동점자 처리가 중요하면 method="dense"를 쓰세요. 만약 2위가 2명이어도 정확히 N개만 뽑고 싶다면 row_number()를 over()와 조합하면 됩니다.

💡 흔한 실수: filter를 먼저 하고 rank를 나중에 하면 원하는 결과가 안 나옵니다. 반드시 rank를 먼저 계산한 후 filter하세요.

💡 성능 팁: 그룹이 매우 많고 각 그룹이 크다면, 먼저 대략적인 필터링(예: 매출 1000 이상)으로 데이터를 줄인 후 rank를 계산하면 더 빠릅니다.

💡 하위 N개를 뽑으려면 descending=False로 바꾸고 filter(rank <= N)하면 됩니다. 또는 descending=True로 하고 filter(rank > total - N)도 가능합니다.

💡 여러 컬럼 기준 순위: 매출이 같을 때 리뷰 점수로 정렬하고 싶다면 pl.struct(["sales", "rating"]).rank()를 사용하세요. 복합 정렬 기준을 적용할 수 있습니다.


7. first()와 last() - 그룹의 첫/마지막 값

시작하며

여러분이 사용자별 구매 이력 데이터에서 각 사용자의 첫 구매 날짜와 최근 구매 날짜를 추출하고 싶은 상황을 생각해보세요. 수천 명의 사용자가 각각 수십 건의 구매를 했는데, 각 사용자마다 가장 오래된 기록과 가장 최근 기록만 필요합니다.

이런 문제는 고객 분석, 세션 추적, 이벤트 로그 분석에서 매우 흔합니다. "최초 가입일", "마지막 로그인 시간", "첫 구매 상품", "최근 검색어" 등 시계열 데이터의 양 끝 값을 추출하는 것은 사용자 행동 분석의 기본이지만, 정렬과 그룹화를 수동으로 하면 복잡합니다.

바로 이럴 때 필요한 것이 Polars의 first()와 last() 함수입니다. 그룹별로 정렬된 데이터에서 첫 번째 값과 마지막 값을 단 한 줄로 추출할 수 있습니다.

개요

간단히 말해서, first()는 그룹의 첫 번째 행 값을, last()는 마지막 행 값을 반환하는 함수로, over()와 함께 사용하면 각 그룹의 시작과 끝 값을 모든 행에서 참조할 수 있습니다. 정렬된 데이터에서 first()는 가장 오래된 또는 가장 작은 값을, last()는 가장 최근 또는 가장 큰 값을 의미합니다.

예를 들어, 날짜순으로 정렬된 주문 데이터에서 first("order_date")는 최초 주문일, last("order_date")는 최근 주문일이 됩니다. 고객 생애 가치(LTV) 분석, 이탈률 계산, 재방문 주기 파악 등에 필수적입니다.

판다스에서는 groupby().first()가 있지만 원본 행 수를 유지하려면 transform()을 써야 했다면, Polars에서는 first().over()로 더 직관적으로 표현할 수 있습니다. first()와 last()의 핵심 특징은 첫째, 정렬 순서에 따라 결과가 달라지므로 반드시 sort()를 먼저 해야 하고, 둘째, over()와 함께 쓰면 원본 행 수를 유지하며 각 행에 그룹의 첫/마지막 값을 브로드캐스트하며, 셋째, group_by().agg()에서도 사용할 수 있어서 요약 통계를 만들 때도 유용하다는 점입니다.

이러한 특징들이 시계열 데이터의 양 끝 값 추출을 단순하게 만들어줍니다.

코드 예제

import polars as pl
from datetime import datetime, timedelta

# 사용자 구매 이력
dates = [datetime(2024, 1, 1) + timedelta(days=i*10) for i in range(6)]
df = pl.DataFrame({
    "user_id": ["A", "A", "A", "B", "B", "B"],
    "purchase_date": dates,
    "amount": [100, 200, 150, 300, 250, 400]
})

# 각 사용자의 첫 구매와 최근 구매 정보 추가
result = (
    df.sort("purchase_date")
    .with_columns([
        pl.col("purchase_date").first().over("user_id").alias("first_purchase"),
        pl.col("purchase_date").last().over("user_id").alias("last_purchase"),
        pl.col("amount").first().over("user_id").alias("first_amount"),
        # 고객 생애 기간 (일수)
        (pl.col("purchase_date").last().over("user_id") -
         pl.col("purchase_date").first().over("user_id")).dt.total_days().alias("customer_lifetime_days")
    ])
)

print(result)

설명

이것이 하는 일: 위 코드는 사용자별 구매 이력에서 각 사용자의 첫 구매 날짜, 최근 구매 날짜, 첫 구매 금액, 고객 생애 기간을 계산합니다. 첫 번째로, sort("purchase_date")로 날짜 순서대로 데이터를 정렬합니다.

이 단계가 매우 중요한데, first()와 last()는 현재 데이터의 물리적인 순서에서 첫/마지막을 가져오기 때문입니다. 정렬하지 않으면 의미 없는 값이 나옵니다.

그 다음으로, pl.col("purchase_date").first().over("user_id")가 실행되면서 각 사용자의 첫 구매 날짜를 찾습니다. 사용자 A의 모든 행에는 2024-01-01(A의 첫 구매일)이 브로드캐스트되고, 사용자 B의 모든 행에는 2024-02-20(B의 첫 구매일)이 브로드캐스트됩니다.

over("user_id")가 있어서 각 사용자가 독립적으로 처리됩니다. 세 번째로, last().over()가 동일한 방식으로 최근 구매 날짜를 모든 행에 추가합니다.

내부적으로 Polars는 각 그룹의 경계를 파악한 후 첫 행과 마지막 행의 값을 효율적으로 브로드캐스트합니다. 마지막으로, customer_lifetime_days 계산이 실행되면서 최근 구매일에서 첫 구매일을 뺀 후 .dt.total_days()로 일수를 구합니다.

사용자 A는 3개 구매가 50일 간격으로 있어서 50일, 사용자 B도 마찬가지입니다. 여러분이 이 코드를 사용하면 각 고객이 언제 첫 거래를 했는지, 얼마나 오래 거래했는지, 최근 활동은 언제인지 등을 모든 거래 기록과 함께 볼 수 있습니다.

실무에서는 "30일 내 재구매하지 않은 고객" 필터링, "첫 구매 금액 대비 최근 구매 금액 증가율" 계산, "가입 후 첫 구매까지 걸린 시간" 분석 등에 활용됩니다. first()와 last()를 group_by().agg()에서 쓰면 사용자당 한 행으로 요약된 리포트도 만들 수 있습니다.

실전 팁

💡 반드시 sort()를 먼저 하세요! first()와 last()는 정렬된 순서에서 첫/마지막을 의미합니다. 정렬하지 않으면 무작위 값이 나옵니다.

💡 흔한 실수: 날짜 컬럼으로 정렬했는데 다른 컬럼의 first()를 가져오면, 날짜 기준 첫 행의 그 컬럼 값이 나옵니다. 정렬 기준과 가져올 컬럼의 관계를 명확히 하세요.

💡 성능 팁: first()와 last()를 여러 컬럼에 적용할 때는 한 번의 with_columns()에 모두 넣으세요. 그룹 경계를 한 번만 계산합니다.

💡 null 처리: 그룹의 첫 행이 null이면 first()도 null을 반환합니다. drop_nulls()로 null을 제거한 후 first()를 쓰거나, coalesce()로 기본값을 지정하세요.

💡 group_by().agg([pl.first("amount"), pl.last("amount")])처럼 쓰면 각 그룹당 한 행으로 요약된 결과를 얻습니다. over()는 원본 행 수 유지, agg()는 그룹당 한 행으로 축소입니다.


8. filter() 내에서 윈도우 함수 활용

시작하며

여러분이 "평균보다 높은 매출을 기록한 제품만 필터링"하고 싶은데, 평균이 전체 평균이 아니라 각 카테고리의 평균이어야 하는 상황을 생각해보세요. 즉, 전자제품은 전자제품 평균과 비교하고, 가구는 가구 평균과 비교해야 합니다.

이런 문제는 이상치 탐지, 성과 평가, 추천 시스템에서 자주 발생합니다. "우리 팀 평균보다 높은 성과", "동일 연령대 평균보다 많은 소비", "같은 장르의 평균 평점보다 높은 영화" 등 상대적 기준으로 필터링해야 하는데, 평균을 먼저 계산하고 조인하는 방식은 복잡합니다.

바로 이럴 때 필요한 것이 filter() 안에서 윈도우 함수를 사용하는 패턴입니다. 평균, 중앙값, 표준편차 등을 그룹별로 계산하고, 동시에 각 행이 그 기준을 만족하는지 바로 필터링할 수 있습니다.

개요

간단히 말해서, filter() 조건문 안에서 mean().over(), median().over() 같은 윈도우 함수를 직접 사용하면, 중간 컬럼 생성 없이 그룹별 기준으로 바로 필터링할 수 있습니다. filter(pl.col("sales") > pl.col("sales").mean().over("category"))처럼 쓰면, 각 행의 매출을 해당 카테고리 평균과 비교하여 평균을 초과하는 행만 남깁니다.

예를 들어, 고성과자 선별(팀 평균 이상), 이상 거래 탐지(고객 평균 대비 큰 금액), 추천 상품 선정(카테고리 평균 이상 평점) 등에 매우 유용합니다. 판다스에서는 평균을 먼저 계산해서 새 컬럼으로 만든 후 비교해야 했다면, Polars에서는 한 줄의 filter()로 끝낼 수 있습니다.

중간 결과를 저장하지 않아서 메모리 효율도 좋습니다. 이 패턴의 핵심 특징은 첫째, with_columns()로 중간 컬럼을 만들 필요 없이 바로 필터링하므로 코드가 간결하고, 둘째, 다양한 집계 함수(mean, median, std, quantile 등)를 조건에 사용할 수 있으며, 셋째, 여러 조건을 &(and)나 |(or)로 조합하여 복잡한 필터링도 가능하다는 점입니다.

이러한 특징들이 상대적 기준 필터링을 단순하게 만들어줍니다.

코드 예제

import polars as pl

# 제품 판매 데이터
df = pl.DataFrame({
    "category": ["전자", "전자", "전자", "가구", "가구", "가구"],
    "product": ["노트북", "태블릿", "스마트폰", "책상", "의자", "책장"],
    "sales": [5000, 3000, 8000, 2000, 2500, 1800],
    "reviews": [450, 300, 600, 180, 220, 150]
})

# 카테고리 평균보다 매출이 높고, 리뷰 수도 평균 이상인 제품
result = df.filter(
    (pl.col("sales") > pl.col("sales").mean().over("category")) &
    (pl.col("reviews") > pl.col("reviews").mean().over("category"))
)

print(result)

설명

이것이 하는 일: 위 코드는 제품 데이터에서 각 카테고리의 평균 매출보다 높고, 동시에 평균 리뷰 수보다 많은 제품만 추출합니다. 첫 번째로, pl.col("sales") > pl.col("sales").mean().over("category") 조건이 평가됩니다.

전자 카테고리의 평균 매출은 (5000+3000+8000)/3 ≈ 5333이므로, 스마트폰(8000)만 이 조건을 만족합니다. 가구 카테고리의 평균 매출은 (2000+2500+1800)/3 ≈ 2100이므로, 의자(2500)만 조건을 만족합니다.

over("category")가 핵심으로, 각 카테고리가 독립적인 평균을 갖습니다. 그 다음으로, pl.col("reviews") > pl.col("reviews").mean().over("category") 조건이 평가됩니다.

전자 카테고리 평균 리뷰는 450, 가구는 약 183입니다. 스마트폰(600)과 의자(220)가 각각의 카테고리 평균을 초과합니다.

세 번째로, & 연산자가 두 조건을 결합하여 둘 다 만족하는 행만 남깁니다. 내부적으로 Polars는 각 윈도우 함수를 벡터화하여 계산한 후, 불리언 마스크를 생성하여 필터링합니다.

중간 컬럼을 메모리에 저장하지 않고 바로 필터링하므로 효율적입니다. 마지막으로, filter()가 조건을 만족하는 2개 행(스마트폰, 의자)만 반환합니다.

각 카테고리에서 평균 이상의 고성과 제품이 선별됩니다. 여러분이 이 코드를 사용하면 "우리 카테고리에서 잘 팔리고 인기도 많은 제품", "우리 팀에서 평균 이상 성과를 낸 사원", "동일 연령대에서 평균 이상 소비하는 고객" 등을 간단히 찾을 수 있습니다.

실무에서는 이상 거래 탐지(평균 대비 3배 이상 거래), 프리미엄 고객 선별(카테고리별 상위 20%), A/B 테스트 분석(대조군 평균 대비 개선) 등에 활용됩니다. std().over()를 써서 "평균 ± 2 표준편차" 범위로 이상치를 탐지하는 것도 가능합니다.

실전 팁

💡 복잡한 조건은 먼저 with_columns()로 중간 컬럼을 만들어서 디버깅한 후, filter()로 옮기세요. 코드는 길어지지만 이해하기 쉽습니다.

💡 흔한 실수: over() 없이 mean()만 쓰면 전체 평균과 비교됩니다. 그룹별 비교가 필요하면 반드시 over("그룹컬럼")을 붙이세요.

💡 성능 팁: 여러 윈도우 함수를 filter에서 쓸 때, 가능하면 계산 비용이 낮은 조건을 먼저 쓰세요. &는 단락 평가를 하므로 첫 조건이 false면 두 번째는 계산 안 됩니다.

💡 quantile().over()를 쓰면 상위 25%, 중앙값 이상 등 백분위수 기준 필터링도 가능합니다. filter(pl.col("sales") > pl.col("sales").quantile(0.75).over("category"))처럼 쓰면 상위 25%를 추출합니다.

💡 디버깅: 필터 결과가 예상과 다르면 먼저 with_columns()로 평균값을 컬럼에 추가해서 눈으로 확인하세요. 집계 로직이 맞는지 검증하는 것이 중요합니다.


9. lead()와 lag() - 다음/이전 값 참조하기

시작하며

여러분이 주식 데이터를 분석하면서 "오늘 종가와 내일 시가의 차이"를 계산하고 싶은 상황을 상상해보세요. 오늘 행에서 내일 행의 값을 참조해야 하는데, 미래 데이터에 접근하는 것이 필요합니다.

이런 문제는 시계열 데이터 분석에서 자주 발생합니다. "다음 달 예측과 실제 비교", "다음 행동 예측(Next Action Prediction)", "갭 분석(Gap Analysis)" 등 현재 시점에서 미래 시점의 값을 참조하거나, 좀 더 세밀하게 과거를 참조해야 하는 경우가 많지만, 인덱스 계산은 복잡하고 오류가 발생하기 쉽습니다.

바로 이럴 때 필요한 것이 Polars의 lead()와 lag() 함수입니다. lead()는 다음 행의 값을, lag()는 이전 행의 값을 가져오는 SQL 윈도우 함수와 동일한 기능을 제공합니다.

shift()와 유사하지만 더 직관적인 이름으로 SQL 사용자에게 친숙합니다.

개요

간단히 말해서, lag()는 이전 N번째 행의 값을, lead()는 다음 N번째 행의 값을 현재 행에서 참조할 수 있게 해주는 윈도우 함수입니다. lag(1)은 바로 이전 행, lag(2)는 2행 전, lead(1)은 바로 다음 행, lead(2)는 2행 후의 값을 가져옵니다.

예를 들어, 시계열 예측 모델에서 "실제 값 vs 1시간 후 예측값", 재무 분석에서 "현재 분기 vs 다음 분기 가이던스", 웹 로그 분석에서 "현재 페이지 vs 다음 방문 페이지" 등을 추적할 때 매우 유용합니다. Polars에서는 shift()와 기능이 거의 동일하지만, lag/lead는 SQL의 LAG/LEAD와 동일한 이름이라 SQL 배경이 있는 분들에게 더 직관적입니다.

shift(1)은 lag(1)과 같고, shift(-1)은 lead(1)과 같습니다. lag()와 lead()의 핵심 특징은 첫째, offset 파라미터로 몇 행 떨어진 값을 가져올지 지정할 수 있고, 둘째, default 옵션으로 범위를 벗어났을 때의 기본값을 설정할 수 있으며, 셋째, over()와 결합하여 그룹별로 독립적인 참조가 가능하다는 점입니다.

이러한 특징들이 시계열 데이터의 전후 관계 분석을 단순하게 만들어줍니다.

코드 예제

import polars as pl
from datetime import datetime, timedelta

# 주식 일봉 데이터
dates = [datetime(2024, 1, 1) + timedelta(days=i) for i in range(5)]
df = pl.DataFrame({
    "date": dates,
    "close": [100, 105, 103, 108, 110],
    "open": [99, 101, 104, 104, 107]
})

# 전일 종가, 다음날 시가, 갭 계산
result = df.with_columns([
    pl.col("close").shift(1).alias("prev_close_shift"),  # shift 방식
    pl.col("close").lag(1).alias("prev_close_lag"),      # lag 방식 (동일)
    pl.col("open").shift(-1).alias("next_open_shift"),   # shift 방식
    pl.col("open").lead(1).alias("next_open_lead"),      # lead 방식 (동일)
    # 오늘 종가와 내일 시가의 갭 (오버나잇 갭)
    (pl.col("open").lead(1) - pl.col("close")).alias("overnight_gap")
])

print(result)

설명

이것이 하는 일: 위 코드는 주식 일봉 데이터에서 전일 종가, 다음날 시가, 오버나잇 갭(종가-다음날시가)을 계산합니다. 첫 번째로, pl.col("close").lag(1)은 종가 컬럼에서 이전 행의 값을 가져옵니다.

첫 번째 행은 이전 데이터가 없으므로 null이 되고, 두 번째 행은 첫 번째 행의 100, 세 번째 행은 두 번째 행의 105를 가져옵니다. shift(1)과 완전히 동일하게 동작하지만, "lag"라는 이름이 "이전 값"을 명확히 표현합니다.

그 다음으로, pl.col("open").lead(1)이 실행되면서 시가 컬럼에서 다음 행의 값을 가져옵니다. 첫 번째 행은 두 번째 행의 101을, 두 번째 행은 세 번째 행의 104를 가져옵니다.

마지막 행은 다음 데이터가 없으므로 null이 됩니다. shift(-1)과 동일하지만, "lead"라는 이름이 "다음 값"을 명확히 표현합니다.

세 번째로, overnight_gap 계산이 실행되면서 오늘 종가와 내일 시가의 차이를 구합니다. 예를 들어 첫 번째 날(종가 100)과 두 번째 날 시가(101)의 차이는 101-100=1입니다.

이 값이 양수면 상승 갭, 음수면 하락 갭을 의미합니다. 마지막으로, with_columns()가 이 모든 컬럼을 추가하여 각 날의 가격 데이터와 함께 전일 종가, 다음날 시가, 갭 정보를 한눈에 볼 수 있는 데이터프레임을 만들어냅니다.

여러분이 이 코드를 사용하면 주식의 오버나잇 갭(장 마감 후 뉴스로 인한 가격 변동), 다음 행동 예측(현재 페이지 방문 후 다음 페이지), 고객 여정 분석(현재 단계 후 다음 단계) 등을 분석할 수 있습니다. 실무에서는 웹 로그의 페이지 전환 흐름, 게임의 다음 행동 예측, 제조 라인의 다음 공정 소요시간 등에 활용됩니다.

lag/lead를 여러 번 쓰면 "3일 전과 오늘 비교", "5단계 후 이탈률" 같은 장기 패턴도 분석할 수 있습니다.

실전 팁

💡 lag()와 shift(양수)는 동일하고, lead()와 shift(음수)는 동일합니다. SQL 배경이 있으면 lag/lead가, Python 배경이 있으면 shift가 더 익숙할 수 있습니다.

💡 흔한 실수: default 값을 설정하지 않으면 범위 밖은 null이 됩니다. 계산에서 null은 전파되므로 (lead(1) - col)처럼 쓰면 마지막 행이 null이 됩니다.

💡 성능 팁: lag/lead를 여러 offset으로 호출할 때(lag(1), lag(2), lag(3))는 한 번의 with_columns()에 모두 넣으세요. 데이터를 한 번만 스캔합니다.

💡 그룹별 lag/lead가 필요하면 반드시 over()를 사용하세요. over("user_id")를 쓰면 각 사용자의 이전/다음 행동만 참조하고, 다른 사용자의 데이터는 참조하지 않습니다.

💡 정렬 주의: lead/lag는 현재 데이터 순서에서 물리적인 다음/이전 행을 참조합니다. 반드시 sort()를 먼저 해서 의미 있는 순서를 만드세요.


10. ntile() - 데이터 분위수로 나누기

시작하며

여러분이 고객 데이터를 분석하면서 구매 금액 기준으로 상위 25%, 중위 50%, 하위 25% 같이 균등하게 4개 그룹으로 나누고 싶은 상황을 생각해보세요. 단순히 순위가 아니라, 전체를 정확히 N등분해서 각 그룹에 번호를 매기고 싶습니다.

이런 문제는 고객 세그먼테이션, RFM 분석, 성과 평가에서 매우 중요합니다. "상위 20% 고객", "중간 60% 고객", "하위 20% 고객"처럼 균등 분할로 세그먼트를 만들어야 하는데, 수동으로 경계값을 계산하면 복잡하고 데이터가 바뀔 때마다 다시 계산해야 합니다.

바로 이럴 때 필요한 것이 Polars의 ntile() 함수(또는 cut() 함수)입니다. 데이터를 지정한 개수의 버킷으로 균등하게 나누어 각 행이 어느 버킷에 속하는지 번호를 매겨줍니다.

개요

간단히 말해서, ntile(n) 함수는 데이터를 정렬한 후 거의 동일한 크기의 N개 그룹으로 나누고, 각 행이 속한 그룹 번호(1부터 N까지)를 반환하는 분위수 함수입니다. ntile(4)는 데이터를 4등분(quartile)하여 1, 2, 3, 4 번호를 부여하고, ntile(10)은 10등분(decile)하여 1~10 번호를 부여합니다.

예를 들어, 100명의 고객을 구매액 기준으로 ntile(5)하면, 상위 20명은 그룹 5, 다음 20명은 그룹 4... 하위 20명은 그룹 1을 받습니다.

고객 등급 분류(VIP/Gold/Silver/Bronze), 성과 등급(S/A/B/C/D), 리스크 분석(High/Medium/Low) 등에 필수적입니다. SQL의 NTILE() 윈도우 함수와 동일한 개념으로, 판다스에서는 qcut() 함수와 유사하지만 Polars에서는 더 윈도우 함수 스타일로 표현할 수 있습니다.

ntile()의 핵심 특징은 첫째, 각 버킷의 크기가 거의 동일하도록 균등 분할하며(행 수가 N으로 나누어떨어지지 않으면 앞쪽 버킷이 1개 더 많음), 둘째, over()와 결합하여 그룹별로 독립적인 분위수 분할이 가능하고, 셋째, 결과가 정수 번호라서 바로 카테고리 라벨과 매핑하기 쉽다는 점입니다. 이러한 특징들이 복잡한 세그먼테이션을 단순하게 만들어줍니다.

코드 예제

import polars as pl

# 고객 구매 데이터
df = pl.DataFrame({
    "customer_id": [f"C{i:03d}" for i in range(1, 11)],
    "total_purchase": [1000, 5000, 3000, 8000, 2000, 6000, 4000, 9000, 1500, 7000]
})

# 구매액 기준 4분위수로 고객 등급 분류
result = (
    df.sort("total_purchase", descending=True)
    .with_columns([
        # qcut을 사용한 분위수 분할 (Polars 0.19+)
        pl.col("total_purchase").qcut(4, labels=["Bronze", "Silver", "Gold", "VIP"]).alias("tier_qcut"),
        # 또는 순위 기반 직접 계산
        ((pl.col("total_purchase").rank(method="ordinal", descending=True) - 1)
         * 4 / pl.len()).floor().cast(pl.Int32).alias("tier_num")
    ])
    .with_columns([
        # tier_num을 라벨로 변환
        pl.when(pl.col("tier_num") == 0).then(pl.lit("VIP"))
        .when(pl.col("tier_num") == 1).then(pl.lit("Gold"))
        .when(pl.col("tier_num") == 2).then(pl.lit("Silver"))
        .otherwise(pl.lit("Bronze")).alias("tier_label")
    ])
)

print(result)

설명

이것이 하는 일: 위 코드는 10명의 고객을 구매액 기준으로 정렬한 후 4개 등급(VIP/Gold/Silver/Bronze)으로 균등 분할합니다. 첫 번째로, sort("total_purchase", descending=True)로 구매액이 높은 순서대로 정렬합니다.

9000원 고객이 1위, 8000원이 2위... 1000원이 10위가 됩니다.

정렬이 중요한 이유는 분위수가 정렬된 순서에서 구간을 나누기 때문입니다. 그 다음으로, qcut(4, labels=["Bronze", "Silver", "Gold", "VIP"])가 실행되면서 데이터를 4등분합니다.

10개 행을 4로 나누면 각 버킷이 2.5개씩인데, Polars는 가능한 한 균등하게 나눕니다. 상위 2-3명은 VIP, 다음 2-3명은 Gold...

이런 식으로 분할됩니다. 직접 라벨을 지정했으므로 숫자 대신 문자열 카테고리가 반환됩니다.

세 번째로, rank 기반 직접 계산 방식도 보여줍니다. rank(method="ordinal")로 1부터 N까지 순위를 매긴 후, (rank-1) * 4 / len으로 0~3.99 범위로 정규화하고, floor()로 내림하여 0, 1, 2, 3 정수로 만듭니다.

이 방식은 ntile() 함수가 없는 환경에서 동일한 결과를 만드는 방법입니다. 마지막으로, when-then-otherwise 체인으로 숫자를 라벨로 변환합니다.

0은 VIP(최상위), 1은 Gold, 2는 Silver, 3은 Bronze(최하위)로 매핑됩니다. 여러분이 이 코드를 사용하면 고객을 자동으로 등급별로 분류하여 VIP 고객 혜택, Gold 등급 할인, 하위 등급 재활성화 캠페인 등을 실행할 수 있습니다.

실무에서는 RFM 분석(Recency, Frequency, Monetary 각각을 5분위수로 나눠서 125개 세그먼트 생성), 성과 평가(직원을 S/A/B/C/D 등급으로 균등 분할), 리스크 관리(거래를 위험도별 10분위수로 분류) 등에 활용됩니다. over()와 조합하면 "지역별로 각각 4등급 분류" 같은 그룹별 세그먼테이션도 가능합니다.

실전 팁

💡 qcut()의 labels 파라미터로 직접 라벨을 지정하면 숫자 대신 의미 있는 카테고리를 바로 얻을 수 있습니다. ["Low", "Medium", "High"]처럼 순서대로 지정하세요.

💡 흔한 실수: 정렬하지 않고 qcut을 쓰면 무작위 분할이 됩니다. 반드시 기준 컬럼으로 sort()를 먼저 하세요.

💡 성능 팁: 여러 컬럼을 각각 분위수로 나눌 때(RFM 분석)는 각 컬럼마다 qcut을 호출하되, 한 번의 with_columns()에 모두 넣으세요.

💡 그룹별 분위수: over()와 조합하면 각 그룹 내에서 독립적으로 분위수를 나눌 수 있습니다. qcut(4).over("region")처럼 쓰면 각 지역마다 4등급 분류가 됩니다.

💡 cut() vs qcut(): cut()은 값의 범위로 나누고(0-1000, 1000-2000...), qcut()은 개수가 균등하도록 나눕니다. 등급 분류는 qcut(), 점수 구간은 cut()을 사용하세요.


#Polars#Window Functions#over#rolling#rank#데이터분석,Python,Polars

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.