이미지 로딩 중...
AI Generated
2025. 11. 15. · 4 Views
시계열 데이터 처리 완벽 가이드
시간에 따라 변화하는 데이터를 효율적으로 다루는 방법을 배워보세요. Polars를 활용한 고성능 시계열 분석부터 리샘플링, 이동 평균, 이상치 탐지까지 실무에서 바로 활용할 수 있는 핵심 기법들을 소개합니다.
목차
- 시계열 데이터 기본 구조 이해하기
- 리샘플링과 시간 단위 집계
- 이동 평균과 롤링 윈도우 계산
- 시계열 데이터의 결측치 처리
- 시간대(Timezone) 처리와 변환
- 시간차(Time Delta) 계산과 활용
- 날짜 범위 생성과 비즈니스 데이 계산
- 시계열 이상치 탐지와 아웃라이어 제거
- 계절성과 트렌드 분해 (Decomposition)
- 시계열 데이터 최적화와 메모리 효율
1. 시계열 데이터 기본 구조 이해하기
시작하며
여러분이 주식 가격, 서버 트래픽, IoT 센서 데이터를 분석할 때 이런 상황을 겪어본 적 있나요? 시간 순서대로 정렬되지 않은 데이터, 불규칙한 시간 간격, 중복된 타임스탬프 등으로 인해 분석이 어려워지는 경우 말이죠.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 시계열 데이터는 본질적으로 시간의 흐름에 따라 의미를 가지는데, 데이터가 제대로 구조화되지 않으면 트렌드 분석, 예측, 이상 탐지 같은 작업이 불가능해집니다.
바로 이럴 때 필요한 것이 시계열 데이터의 기본 구조 이해입니다. Polars를 사용하면 날짜/시간 타입을 정확하게 다루고, 시간 순서를 보장하며, 빠르게 데이터를 준비할 수 있습니다.
개요
간단히 말해서, 시계열 데이터는 시간을 인덱스로 가지는 데이터 구조입니다. 각 관측값은 특정 시점과 연결되어 있으며, 시간 순서가 매우 중요합니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 금융 거래 데이터를 분석할 때 거래 순서가 뒤바뀌면 잘못된 수익률을 계산할 수 있고, 서버 모니터링에서 시간 정보가 부정확하면 장애 발생 시점을 특정할 수 없습니다. 예를 들어, 분산 시스템에서 수집된 로그를 시간 순으로 정렬하고 타임존을 통일하는 작업은 필수입니다.
기존에는 Pandas로 datetime 타입을 다루면서 느린 성능과 메모리 오버헤드를 감수했다면, 이제는 Polars의 고속 처리와 타입 안정성으로 수백만 건의 시계열 데이터를 몇 초 안에 처리할 수 있습니다. 시계열 데이터의 핵심 특징은 첫째, 시간 순서 보장(temporal ordering), 둘째, 일정하거나 불규칙한 간격(regular or irregular intervals), 셋째, 시간대 인식(timezone awareness)입니다.
이러한 특징들이 데이터 품질과 분석 정확도에 직접적인 영향을 미치기 때문에 처음부터 올바르게 설정하는 것이 중요합니다.
코드 예제
import polars as pl
from datetime import datetime, timedelta
# 시계열 데이터 생성 - 주식 가격 예시
df = pl.DataFrame({
"timestamp": pl.datetime_range(
start=datetime(2024, 1, 1),
end=datetime(2024, 1, 10),
interval=timedelta(hours=1),
eager=True
),
"price": [100 + i * 0.5 for i in range(217)],
"volume": [1000 + i * 10 for i in range(217)]
})
# 시간 순서로 정렬 및 중복 제거
df = df.sort("timestamp").unique(subset=["timestamp"])
# 날짜/시간 컴포넌트 추출
df = df.with_columns([
pl.col("timestamp").dt.date().alias("date"),
pl.col("timestamp").dt.hour().alias("hour"),
pl.col("timestamp").dt.weekday().alias("weekday")
])
print(df.head())
설명
이것이 하는 일: 시계열 데이터를 생성하고, 시간 순서를 보장하며, 분석에 필요한 날짜/시간 정보를 추출하는 전체 파이프라인을 구축합니다. 첫 번째로, pl.datetime_range()는 지정된 시작일과 종료일 사이에 일정한 간격(여기서는 1시간)으로 타임스탬프를 자동 생성합니다.
이렇게 하는 이유는 실제 데이터에서 누락된 시점을 채우거나, 테스트 데이터를 빠르게 만들 때 매우 유용하기 때문입니다. eager=True 옵션은 즉시 실행을 의미하며, lazy evaluation을 원하면 생략할 수 있습니다.
그 다음으로, sort("timestamp").unique(subset=["timestamp"])가 실행되면서 시간 순서를 보장하고 중복된 타임스탬프를 제거합니다. 실제 운영 환경에서는 네트워크 지연, 시스템 시간 불일치 등으로 인해 데이터가 순서 없이 들어오거나 중복될 수 있는데, 이 단계에서 깔끔하게 정리됩니다.
마지막으로, dt 네임스페이스를 통해 날짜(date), 시간(hour), 요일(weekday) 같은 시간 컴포넌트를 추출합니다. 이렇게 하면 "월요일 오전 9시의 평균 거래량"처럼 특정 시간대나 요일별 패턴을 분석할 수 있습니다.
with_columns()는 기존 컬럼을 유지하면서 새 컬럼을 추가하는 효율적인 방법입니다. 여러분이 이 코드를 사용하면 원본 데이터의 구조를 훼손하지 않고 시간 기반 feature를 추가할 수 있고, 시간대별 집계나 패턴 분석을 즉시 시작할 수 있습니다.
또한 Polars의 병렬 처리 덕분에 수백만 행의 데이터도 몇 초 안에 처리됩니다.
실전 팁
💡 항상 데이터를 로드한 직후 sort("timestamp")로 정렬하세요. 이후 모든 시계열 연산이 시간 순서를 가정하기 때문입니다.
💡 타임존이 중요한 경우 dt.replace_time_zone("UTC")로 명시적으로 설정하세요. 서머타임이나 지역 시간 차이로 인한 오류를 예방할 수 있습니다.
💡 dt.truncate("1h") 같은 함수로 타임스탬프를 시간 단위로 내림하면 불규칙한 데이터를 규칙적으로 만들 수 있습니다.
💡 메모리 효율을 위해 필요하지 않은 시간 컴포넌트는 추출하지 마세요. 컬럼이 늘어날수록 메모리 사용량이 증가합니다.
💡 df.schema로 timestamp 컬럼이 pl.Datetime 타입인지 확인하세요. 문자열로 저장되면 시계열 함수를 사용할 수 없습니다.
2. 리샘플링과 시간 단위 집계
시작하며
여러분이 1초마다 수집되는 센서 데이터를 분석할 때, 이런 데이터를 그대로 시각화하면 너무 노이즈가 많아서 트렌드를 파악하기 어려운 경험을 해보셨나요? 또는 일별 데이터를 월별로 요약해야 하는데 어떻게 해야 할지 막막했던 적이 있으실 겁니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 고빈도 데이터는 저장 공간을 많이 차지하고, 분석 속도를 느리게 만들며, 시각화 시 가독성을 떨어뜨립니다.
반대로 너무 큰 시간 단위로 집계하면 중요한 패턴을 놓칠 수 있습니다. 바로 이럴 때 필요한 것이 리샘플링입니다.
데이터의 시간 해상도를 조정하여 분석 목적에 맞는 최적의 시간 단위로 변환할 수 있습니다.
개요
간단히 말해서, 리샘플링은 시계열 데이터의 시간 간격을 변경하는 작업입니다. 다운샘플링은 간격을 늘리고(예: 1초→1분), 업샘플링은 간격을 줄입니다(예: 1일→1시간).
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 실시간 주식 거래 데이터를 1분 캔들차트로 만들거나, 시간별 웹 트래픽을 일별로 요약하거나, 월별 매출을 분기별로 집계할 때 필수적입니다. 예를 들어, 대시보드에서 1년치 데이터를 보여줄 때 모든 로우 데이터를 표시하면 성능 문제가 발생하므로 일별이나 주별로 집계합니다.
기존에는 수동으로 시간 윈도우를 만들고 그룹화해서 집계했다면, 이제는 group_by_dynamic()으로 한 줄에 처리할 수 있습니다. 코드가 간결해지고 실수 가능성이 줄어듭니다.
리샘플링의 핵심 특징은 첫째, 유연한 시간 윈도우 설정(매 5분, 매 2시간, 매주 월요일 등), 둘째, 다양한 집계 함수 적용(평균, 합계, 최대/최소, 표준편차 등), 셋째, 윈도우 경계 처리(포함/제외, offset 설정)입니다. 이러한 특징들이 데이터의 시간적 특성을 보존하면서도 효율적인 분석을 가능하게 합니다.
코드 예제
import polars as pl
from datetime import datetime, timedelta
# 1분마다 측정된 서버 CPU 사용률 데이터
df = pl.DataFrame({
"timestamp": pl.datetime_range(
start=datetime(2024, 1, 1, 0, 0),
end=datetime(2024, 1, 1, 23, 59),
interval=timedelta(minutes=1),
eager=True
),
"cpu_usage": [50 + (i % 20) for i in range(1440)]
})
# 5분 단위로 다운샘플링 - 평균 CPU 사용률
resampled_5min = df.group_by_dynamic(
"timestamp",
every="5m", # 5분마다
closed="left" # 왼쪽 경계 포함
).agg([
pl.col("cpu_usage").mean().alias("avg_cpu"),
pl.col("cpu_usage").max().alias("max_cpu"),
pl.col("cpu_usage").min().alias("min_cpu")
])
# 1시간 단위로 다운샘플링 - offset 적용
resampled_1h = df.group_by_dynamic(
"timestamp",
every="1h",
offset="30m" # 30분 offset (예: 00:30, 01:30, ...)
).agg(pl.col("cpu_usage").mean().alias("avg_cpu"))
print(resampled_5min.head())
설명
이것이 하는 일: 고빈도 시계열 데이터를 분석에 적합한 시간 단위로 변환하고, 각 시간 윈도우 내에서 통계값을 계산합니다. 첫 번째로, group_by_dynamic("timestamp", every="5m")는 타임스탬프를 기준으로 5분 단위의 시간 윈도우를 자동으로 생성합니다.
이렇게 하는 이유는 1분 단위 1440개 데이터를 5분 단위 288개로 줄여서 처리 속도를 높이고, 노이즈를 줄여 트렌드를 명확하게 보기 위함입니다. closed="left"는 각 윈도우의 시작 시점은 포함하고 끝 시점은 제외한다는 의미입니다.
그 다음으로, agg() 내부에서 여러 집계 함수를 동시에 적용합니다. mean()으로 평균을 구하고, max()와 min()으로 해당 시간대의 최대/최소값을 추적합니다.
실무에서는 CPU 사용률의 평균만으로는 부족하고, 순간적인 스파이크를 감지하기 위해 최대값도 함께 모니터링해야 합니다. 마지막으로, offset="30m"을 사용하면 시간 윈도우의 시작점을 조정할 수 있습니다.
기본적으로는 정시(00:00, 01:00)부터 시작하지만, 비즈니스 요구사항에 따라 예를 들어 30분(00:30, 01:30)부터 시작하도록 변경할 수 있습니다. 이는 근무 시간이 9시 30분에 시작하는 회사에서 유용합니다.
여러분이 이 코드를 사용하면 데이터 크기를 획기적으로 줄이면서도 중요한 통계 정보는 보존할 수 있고, 시각화나 대시보드의 성능을 크게 개선할 수 있습니다. 또한 다양한 시간 단위로 실험하면서 최적의 분석 해상도를 찾을 수 있습니다.
실전 팁
💡 every 파라미터는 "5m", "1h", "1d", "1w" 같은 문자열로 지정하며, 숫자와 단위를 조합할 수 있습니다(예: "2h30m").
💡 집계 시 count()도 함께 확인하세요. 각 윈도우에 몇 개의 데이터 포인트가 있는지 알면 데이터 품질을 검증할 수 있습니다.
💡 업샘플링(간격 줄이기)을 할 때는 upsample()과 interpolate()를 함께 사용하여 빈 값을 채워야 합니다.
💡 truncate=True 옵션을 사용하면 불완전한 마지막 윈도우를 제거할 수 있습니다(예: 데이터가 23:57까지만 있을 때).
💡 여러 컬럼을 동시에 집계할 때는 리스트 내포보다 명시적으로 각각 지정하는 것이 가독성이 좋습니다.
3. 이동 평균과 롤링 윈도우 계산
시작하며
여러분이 주식 차트를 볼 때 5일 이동평균선, 20일 이동평균선을 본 적이 있으시죠? 또는 최근 7일간의 평균 매출을 계산해야 하는데, 날짜마다 수동으로 범위를 지정하기 번거로웠던 경험이 있으실 겁니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 시계열 데이터는 단기 변동(노이즈)과 장기 트렌드가 섞여 있어서, 특정 시점의 값만으로는 전체 흐름을 파악하기 어렵습니다.
매일 변동하는 수치를 일일이 추적하면 오히려 혼란스럽습니다. 바로 이럴 때 필요한 것이 이동 평균과 롤링 윈도우 계산입니다.
지정한 기간 동안의 값들을 자동으로 집계하여 부드러운 트렌드를 추출하고, 이상 패턴을 감지할 수 있습니다.
개요
간단히 말해서, 롤링 윈도우는 고정된 크기의 시간 창을 데이터 위에서 슬라이딩하면서 각 위치에서 집계 연산을 수행하는 기법입니다. 이동 평균은 그 중 가장 흔한 예시입니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 금융 데이터에서 단기/장기 추세를 비교하거나, 웹사이트 방문자 수의 7일 이동 평균을 계산하거나, 센서 데이터에서 순간적 스파이크를 제거할 때 핵심적입니다. 예를 들어, COVID-19 확진자 추이를 보도할 때 일별 확진자 수는 주말 효과로 들쭉날쭉하지만, 7일 이동평균을 사용하면 실제 증가/감소 추세를 명확히 볼 수 있습니다.
기존에는 for 루프로 각 시점마다 수동으로 범위를 계산하고 집계했다면, 이제는 rolling() 함수로 벡터화된 연산을 수행하여 수백 배 빠른 속도를 얻을 수 있습니다. 롤링 윈도우의 핵심 특징은 첫째, 고정된 윈도우 크기(예: 최근 7일), 둘째, 다양한 집계 함수 지원(mean, sum, std, min, max 등), 셋째, 시간 기반 또는 행 기반 윈도우 선택입니다.
이러한 특징들이 노이즈 제거, 트렌드 추출, 이상치 탐지에 강력한 도구가 됩니다.
코드 예제
import polars as pl
from datetime import datetime, timedelta
# 일별 주식 가격 데이터
df = pl.DataFrame({
"date": pl.datetime_range(
start=datetime(2024, 1, 1),
end=datetime(2024, 3, 31),
interval=timedelta(days=1),
eager=True
),
"price": [100 + i * 0.5 + (i % 7) * 2 for i in range(91)]
})
# 5일 이동 평균 (행 기반)
df = df.with_columns([
pl.col("price").rolling_mean(window_size=5).alias("ma_5"),
pl.col("price").rolling_mean(window_size=20).alias("ma_20")
])
# 7일 이동 표준편차 - 변동성 측정
df = df.with_columns([
pl.col("price").rolling_std(window_size=7).alias("volatility_7d")
])
# 시간 기반 윈도우 - 최근 1주일 최대값
df = df.with_columns([
pl.col("price").rolling_max(window_size="7d", by="date").alias("max_7d")
])
print(df.tail(10))
설명
이것이 하는 일: 시계열 데이터의 각 시점에서 과거 N일간의 통계값을 자동으로 계산하여, 노이즈를 제거하고 패턴을 명확하게 만듭니다. 첫 번째로, rolling_mean(window_size=5)는 현재 행을 포함한 최근 5개 행의 평균을 계산합니다.
이렇게 하는 이유는 주가의 단기적 변동을 완화하여 전체적인 상승/하락 추세를 파악하기 위함입니다. 5일 이동평균과 20일 이동평균을 함께 보면, 단기 평균이 장기 평균을 상향 돌파할 때를 매수 신호로 해석하는 "골든 크로스" 전략을 구현할 수 있습니다.
그 다음으로, rolling_std(window_size=7)가 실행되면서 최근 7일간의 가격 변동성을 측정합니다. 표준편차가 클수록 가격이 불안정하다는 의미이며, 리스크 관리나 옵션 거래에서 중요한 지표입니다.
내부적으로 Polars는 이 연산을 병렬로 처리하여 매우 빠르게 계산합니다. 마지막으로, rolling_max(window_size="7d", by="date")는 시간 기반 윈도우를 사용합니다.
행 기반(5개 행)과 달리, 시간 기반("7d")은 실제 날짜 범위를 고려합니다. 주말이나 공휴일로 데이터가 없어도 정확히 7일 전까지의 데이터를 포함합니다.
by="date" 파라미터는 어떤 컬럼을 시간축으로 사용할지 지정합니다. 여러분이 이 코드를 사용하면 복잡한 루프 없이 한 줄로 이동평균을 계산할 수 있고, 차트에 여러 이동평균선을 그려서 매매 타이밍을 분석하거나, 변동성 지표로 리스크를 정량화할 수 있습니다.
또한 센서 데이터에서 급격한 변화를 감지하는 이상치 탐지 시스템을 구축할 수 있습니다.
실전 팁
💡 윈도우 크기가 클수록 더 부드러운 곡선을 얻지만, 최신 변화에 대한 반응이 느려집니다. 용도에 맞게 조정하세요.
💡 min_periods 파라미터로 최소 데이터 개수를 지정하세요. 예를 들어 min_periods=5로 설정하면 5개 미만일 때는 null을 반환합니다.
💡 center=True 옵션을 사용하면 윈도우를 중앙 정렬하여 과거/미래를 균등하게 봅니다(단, 실시간 예측에는 부적합).
💡 rolling_apply()로 커스텀 함수를 적용할 수 있지만, 성능이 느리므로 가능하면 내장 함수를 사용하세요.
💡 여러 이동평균을 계산할 때는 with_columns()에 리스트로 한 번에 전달하면 효율적입니다.
4. 시계열 데이터의 결측치 처리
시작하며
여러분이 IoT 센서에서 수집한 데이터를 분석하려는데, 네트워크 장애로 중간중간 데이터가 빠져 있는 상황을 경험해보셨나요? 또는 주말과 공휴일에는 데이터가 없어서 주간 추세를 계산할 수 없었던 적이 있으실 겁니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 시계열 데이터는 연속성이 중요한데, 결측치가 있으면 이동 평균 계산이 부정확해지고, 머신러닝 모델 학습이 불가능하며, 시각화 시 그래프가 끊어집니다.
단순히 결측치를 무시하면 왜곡된 분석 결과를 얻을 수 있습니다. 바로 이럴 때 필요한 것이 시계열 결측치 처리 기법입니다.
시간의 흐름을 고려하여 합리적으로 빈 값을 채우거나, 결측치의 패턴을 분석하여 데이터 품질을 평가할 수 있습니다.
개요
간단히 말해서, 시계열 결측치 처리는 시간적 맥락을 고려하여 누락된 데이터를 채우거나 처리하는 작업입니다. 단순히 평균값으로 채우는 것이 아니라, 전후 시점의 값을 참고합니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 센서 데이터에서 일시적 통신 오류로 1-2분 데이터가 빠졌을 때 선형 보간으로 채우거나, 주식 시장이 휴장한 날의 가격을 마지막 거래일 가격으로 전진 채우거나, 계절성을 가진 데이터의 결측치를 작년 동일 시점 값으로 추정할 때 필수적입니다. 예를 들어, 시간별 전력 사용량 데이터에서 몇 시간이 빠졌다면, 전날 같은 시간대의 패턴을 참고하는 것이 단순 평균보다 훨씬 정확합니다.
기존에는 결측치를 찾고, 조건문으로 분기하고, 수동으로 계산해서 채워야 했다면, 이제는 interpolate(), fill_null() 같은 함수로 한 줄에 처리할 수 있습니다. 시계열 결측치 처리의 핵심 특징은 첫째, 시간 순서를 고려한 전진/후진 채우기(forward fill / backward fill), 둘째, 선형 또는 고차 보간(linear, quadratic, cubic interpolation), 셋째, 계절성 기반 채우기입니다.
이러한 특징들이 결측치를 자연스럽게 채우면서도 원본 데이터의 패턴을 왜곡하지 않도록 도와줍니다.
코드 예제
import polars as pl
from datetime import datetime, timedelta
import random
# 결측치가 있는 온도 센서 데이터 생성
dates = pl.datetime_range(
start=datetime(2024, 1, 1),
end=datetime(2024, 1, 10),
interval=timedelta(hours=1),
eager=True
)
temps = [20 + i * 0.1 if random.random() > 0.1 else None for i in range(len(dates))]
df = pl.DataFrame({
"timestamp": dates,
"temperature": temps
})
# 결측치 확인
print(f"결측치 개수: {df['temperature'].null_count()}")
# 전진 채우기 (Forward Fill) - 마지막 유효값으로 채움
df_ffill = df.with_columns([
pl.col("temperature").fill_null(strategy="forward").alias("temp_ffill")
])
# 선형 보간 (Linear Interpolation)
df_interp = df.with_columns([
pl.col("temperature").interpolate(method="linear").alias("temp_interp")
])
# 평균값으로 채우기 (비추천 - 시계열에서는 비현실적)
df_mean = df.with_columns([
pl.col("temperature").fill_null(pl.col("temperature").mean()).alias("temp_mean")
])
print(df_interp.filter(pl.col("temperature").is_null()).head())
설명
이것이 하는 일: 시계열 데이터의 결측치를 탐지하고, 시간적 맥락에 맞는 방법으로 채워서 연속적인 분석을 가능하게 합니다. 첫 번째로, null_count()로 결측치 개수를 확인합니다.
이렇게 하는 이유는 결측치 비율이 너무 높으면(예: 30% 이상) 보간의 신뢰성이 떨어지므로, 데이터 수집 프로세스 자체를 점검해야 하기 때문입니다. 결측치 패턴(무작위 vs 연속적)도 중요한 정보입니다.
그 다음으로, fill_null(strategy="forward")는 각 결측치를 바로 이전의 유효한 값으로 채웁니다. 주식 가격처럼 갑자기 변하지 않는 데이터에 적합하며, "마지막으로 알려진 값이 계속 유지된다"는 가정입니다.
반대로 strategy="backward"를 사용하면 다음 유효값으로 채우는데, 과거를 미래로 추정하는 것이므로 주의해야 합니다. 그리고 interpolate(method="linear")는 결측치 전후의 값을 선형으로 연결하여 그 사이 값을 추정합니다.
예를 들어 10시에 20도, 12시에 22도이고 11시가 결측이면 21도로 채웁니다. 온도, 습도처럼 서서히 변하는 데이터에 적합하며, 급격한 변화가 있는 데이터에는 부적합할 수 있습니다.
method="cubic"을 사용하면 더 부드러운 곡선으로 보간됩니다. 마지막으로, fill_null(pl.col("temperature").mean())은 전체 평균값으로 채우는 방법인데, 시계열 데이터에서는 거의 사용하지 않습니다.
시간 정보를 완전히 무시하기 때문에 패턴을 왜곡할 수 있습니다. 일반 테이블 데이터에서는 유용하지만, 시계열에서는 피해야 합니다.
여러분이 이 코드를 사용하면 데이터의 연속성을 확보하여 롤링 윈도우나 머신러닝 모델에 바로 투입할 수 있고, 결측치로 인한 분석 에러를 예방할 수 있습니다. 또한 여러 보간 방법을 비교하여 데이터 특성에 가장 적합한 방법을 선택할 수 있습니다.
실전 팁
💡 전진 채우기는 실시간 시스템에 적합하지만, 결측이 길게 이어지면 오래된 값이 계속 사용되므로 주의하세요.
💡 선형 보간은 결측치가 연속으로 너무 많으면(예: 10개 이상) 신뢰도가 떨어집니다. 이런 경우 데이터를 제거하는 것이 나을 수 있습니다.
💡 fill_null()과 interpolate() 중 선택할 때는 데이터의 물리적 특성을 고려하세요. 재고 수량은 전진 채우기, 온도는 보간이 적합합니다.
💡 보간 전후 데이터를 시각화하여 비현실적인 값이 생기지 않았는지 확인하세요(예: 음수 온도가 발생).
💡 is_null().sum()으로 결측치 위치를 확인하고, 특정 시간대에 집중되어 있는지 패턴을 분석하세요.
5. 시간대(Timezone) 처리와 변환
시작하며
여러분이 글로벌 서비스를 운영하면서 각 지역의 사용자 활동 로그를 분석할 때, 시간대가 뒤죽박죽 섞여서 순서가 엉망이었던 경험이 있으시죠? 또는 서머타임 전환 시점에 1시간이 중복되거나 사라져서 데이터가 꼬인 적이 있으실 겁니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 서버는 UTC로 기록하고, 사용자는 로컬 시간으로 보고 싶어 하며, 분석은 특정 지역 기준으로 해야 하는 등 시간대 불일치로 인한 혼란이 끊임없습니다.
잘못 처리하면 매출 집계가 하루 밀리거나, 이벤트 순서가 바뀌는 심각한 오류가 발생합니다. 바로 이럴 때 필요한 것이 명확한 시간대 처리입니다.
모든 타임스탬프에 시간대 정보를 명시하고, 필요에 따라 정확하게 변환하여 일관성을 유지할 수 있습니다.
개요
간단히 말해서, 시간대 처리는 타임스탬프가 어느 지역의 시간인지 명시하고, 다른 시간대로 변환하는 작업입니다. Timezone-aware datetime과 naive datetime의 차이를 이해하는 것이 핵심입니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 뉴욕과 도쿄에서 동시에 발생한 이벤트를 시간 순서대로 정렬하거나, 모든 로그를 UTC로 통일하여 저장한 후 각 사용자의 로컬 시간으로 표시하거나, 서머타임 전환으로 인한 오류를 자동으로 처리할 때 필수적입니다. 예를 들어, 금융 거래 시스템에서 뉴욕 장 마감(16:00 EST)과 도쿄 장 마감(15:00 JST)을 UTC로 변환하여 정확히 비교해야 합니다.
기존에는 pytz 라이브러리로 수동 변환하고, DST 경계를 직접 처리했다면, 이제는 Polars의 시간대 함수로 자동으로 처리할 수 있습니다. 시간대 처리의 핵심 특징은 첫째, 명시적 시간대 정보(timezone-aware), 둘째, DST 자동 처리, 셋째, 빠른 시간대 변환입니다.
이러한 특징들이 글로벌 시스템에서 시간 관련 버그를 예방하고, 정확한 데이터 분석을 보장합니다.
코드 예제
import polars as pl
from datetime import datetime
# Naive datetime (시간대 정보 없음)
df = pl.DataFrame({
"timestamp": pl.datetime_range(
start=datetime(2024, 3, 10, 0, 0), # DST 전환일
end=datetime(2024, 3, 10, 23, 0),
interval="1h",
eager=True
),
"event": [f"event_{i}" for i in range(24)]
})
# UTC 시간대로 명시 (기준 시간대 설정)
df = df.with_columns([
pl.col("timestamp").dt.replace_time_zone("UTC").alias("timestamp_utc")
])
# 뉴욕 시간대로 변환 (DST 자동 처리)
df = df.with_columns([
pl.col("timestamp_utc").dt.convert_time_zone("America/New_York").alias("timestamp_ny")
])
# 도쿄 시간대로 변환
df = df.with_columns([
pl.col("timestamp_utc").dt.convert_time_zone("Asia/Tokyo").alias("timestamp_tokyo")
])
# 시간대 정보 제거 (Naive로 변환 - 주의 필요)
df = df.with_columns([
pl.col("timestamp_ny").dt.replace_time_zone(None).alias("timestamp_naive")
])
print(df.head(10))
설명
이것이 하는 일: 시간대 정보가 없는 순수 타임스탬프에 지역 정보를 추가하고, 여러 시간대 간 변환을 정확하게 수행합니다. 첫 번째로, dt.replace_time_zone("UTC")는 naive datetime에 시간대 정보를 추가합니다.
이렇게 하는 이유는 "2024-03-10 12:00"이라는 시간이 어느 지역의 정오인지 모호하기 때문입니다. UTC로 명시하면 "이것은 협정 세계시 정오다"라고 분명해집니다.
주의할 점은 이 함수가 시간 값을 변경하지 않고 메타데이터만 추가한다는 것입니다. 그 다음으로, dt.convert_time_zone("America/New_York")는 UTC 시간을 뉴욕 시간으로 변환합니다.
UTC 12:00은 뉴욕의 07:00(EST) 또는 08:00(EDT)가 됩니다. Polars는 IANA 시간대 데이터베이스를 사용하여 서머타임 전환을 자동으로 처리합니다.
2024년 3월 10일 새벽 2시에 시계가 3시로 건너뛰는 것까지 정확히 반영됩니다. 그리고 여러 시간대로 변환할 때, 항상 UTC를 중간 기준으로 사용하는 것이 안전합니다.
예를 들어 뉴욕 시간을 도쿄 시간으로 직접 변환하지 말고, 뉴욕→UTC→도쿄로 변환하면 오류를 줄일 수 있습니다. 이 예제에서는 UTC에서 출발하므로 안전합니다.
마지막으로, dt.replace_time_zone(None)은 시간대 정보를 제거합니다. 이는 시간대를 모르는 레거시 시스템과 통합할 때 필요하지만, 가능하면 피해야 합니다.
시간대 정보를 잃으면 다시 모호성이 생기기 때문입니다. 정말 필요한 경우에만 사용하세요.
여러분이 이 코드를 사용하면 글로벌 사용자의 활동을 정확하게 시간 순서로 정렬할 수 있고, 각 지역별 대시보드에 올바른 시간을 표시할 수 있으며, 서머타임으로 인한 버그를 자동으로 예방할 수 있습니다. 또한 데이터베이스에는 UTC로 저장하고, 표시할 때만 로컬 시간으로 변환하는 베스트 프랙티스를 쉽게 구현할 수 있습니다.
실전 팁
💡 항상 데이터를 UTC로 저장하고, 표시할 때만 로컬 시간대로 변환하세요. 이것이 업계 표준입니다.
💡 "America/New_York" 같은 IANA 이름을 사용하세요. "EST"나 "EDT" 같은 약어는 서머타임을 처리하지 못합니다.
💡 시간대 변환 후에는 반드시 샘플 데이터를 눈으로 확인하세요. 13시간 차이가 나야 하는데 11시간 차이가 나면 뭔가 잘못된 것입니다.
💡 dt.offset_by()로 단순히 시간을 더하거나 빼는 것과, 시간대 변환은 완전히 다릅니다. 전자는 물리적 시간, 후자는 지역 시간입니다.
💡 서머타임 전환일에는 존재하지 않는 시간(2시3시)이나 중복되는 시간(1시2시)이 있습니다. Polars는 이를 자동 처리하지만, 중요한 금융 데이터라면 수동 검증하세요.
6. 시간차(Time Delta) 계산과 활용
시작하며
여러분이 사용자의 웹사이트 체류 시간을 분석하거나, 주문 접수부터 배송까지 걸린 시간을 계산할 때, 시작과 종료 시간을 일일이 빼서 계산하느라 번거로웠던 경험이 있으시죠? 특히 시간 단위가 초, 분, 시간, 일로 다양하면 변환이 복잡합니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 이벤트 간 경과 시간은 성능 분석, 사용자 행동 이해, 프로세스 최적화의 핵심 지표인데, 계산 실수나 단위 오류가 잦습니다.
날짜를 넘어가는 경우 계산이 더욱 복잡해집니다. 바로 이럴 때 필요한 것이 시간차 계산입니다.
두 시점 사이의 간격을 정확하게 계산하고, 원하는 단위로 변환하여 의미 있는 인사이트를 추출할 수 있습니다.
개요
간단히 말해서, 시간차는 두 타임스탬프 사이의 간격을 나타내는 데이터 타입입니다. Polars에서는 Duration 타입으로 표현되며, 다양한 시간 단위로 변환할 수 있습니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 고객 서비스에서 첫 응답 시간(First Response Time)을 측정하거나, 제조 공정에서 각 단계별 소요 시간을 분석하거나, 웹 애플리케이션에서 페이지 로딩 시간을 추적할 때 필수적입니다. 예를 들어, 이커머스에서 "장바구니 담기"와 "구매 완료" 사이 시간이 너무 길면 결제 프로세스를 개선해야 한다는 신호입니다.
기존에는 datetime 객체를 빼서 timedelta를 얻고, .total_seconds()로 변환한 뒤 수동으로 나누었다면, 이제는 Polars의 Duration 연산으로 한 번에 원하는 단위를 얻을 수 있습니다. 시간차 계산의 핵심 특징은 첫째, 간단한 뺄셈 연산, 둘째, 다양한 단위 변환(초, 분, 시간, 일, 주), 셋째, 집계 및 통계 분석 가능입니다.
이러한 특징들이 복잡한 시간 기반 메트릭을 쉽게 계산하고, 비즈니스 인사이트를 빠르게 도출하도록 도와줍니다.
코드 예제
import polars as pl
from datetime import datetime, timedelta
# 고객 서비스 티켓 데이터
df = pl.DataFrame({
"ticket_id": range(1, 6),
"created_at": [
datetime(2024, 1, 1, 9, 0),
datetime(2024, 1, 1, 10, 30),
datetime(2024, 1, 1, 14, 15),
datetime(2024, 1, 1, 16, 45),
datetime(2024, 1, 2, 8, 20)
],
"responded_at": [
datetime(2024, 1, 1, 9, 15),
datetime(2024, 1, 1, 11, 0),
datetime(2024, 1, 1, 14, 50),
datetime(2024, 1, 1, 18, 30),
datetime(2024, 1, 2, 9, 10)
]
})
# 응답 시간 계산 (Duration 타입)
df = df.with_columns([
(pl.col("responded_at") - pl.col("created_at")).alias("response_time")
])
# Duration을 분 단위로 변환
df = df.with_columns([
pl.col("response_time").dt.total_minutes().alias("response_minutes"),
pl.col("response_time").dt.total_hours().alias("response_hours")
])
# 통계 계산 - 평균 응답 시간
avg_response = df.select([
pl.col("response_minutes").mean().alias("avg_minutes"),
pl.col("response_minutes").max().alias("max_minutes")
])
print(df)
print(avg_response)
설명
이것이 하는 일: 이벤트의 시작과 종료 시점 사이 경과 시간을 계산하고, 비즈니스에 유용한 단위로 변환하여 성능 메트릭을 생성합니다. 첫 번째로, pl.col("responded_at") - pl.col("created_at")는 두 타임스탬프를 빼서 Duration 타입을 생성합니다.
이렇게 하는 이유는 각 티켓에 대한 응답 시간을 개별적으로 추적하기 위함입니다. Polars는 이 연산을 벡터화하여 수백만 행도 빠르게 처리합니다.
Duration은 내부적으로 나노초 단위로 저장되므로 매우 정밀합니다. 그 다음으로, dt.total_minutes()와 dt.total_hours()는 Duration을 사람이 이해하기 쉬운 단위로 변환합니다.
15분은 15.0, 1시간 30분은 1.5시간으로 표현됩니다. 실무에서는 SLA(Service Level Agreement)가 "평균 응답 시간 30분 이내" 같은 형태로 정의되므로, 이렇게 변환해야 비교할 수 있습니다.
그리고 mean()과 max()로 집계 통계를 계산합니다. 평균 응답 시간은 팀의 전반적인 성능을, 최대 응답 시간은 최악의 케이스를 나타냅니다.
평균만 보면 대부분은 빠른데 몇 건이 엄청 오래 걸리는 상황을 놓칠 수 있으므로, 백분위수(percentile)도 함께 확인하는 것이 좋습니다. 마지막으로, 이 데이터를 시각화하면 응답 시간의 분포를 한눈에 파악할 수 있습니다.
예를 들어 특정 시간대(점심시간)에 응답이 느린지, 특정 직원이 일관되게 빠른지 등을 분석하여 프로세스를 개선할 수 있습니다. 여러분이 이 코드를 사용하면 고객 서비스 품질을 정량화할 수 있고, 배송 시간, 제조 리드타임, 웹 페이지 로딩 시간 등 모든 시간 기반 KPI를 쉽게 계산할 수 있습니다.
또한 시간대별, 요일별로 그룹화하여 패턴을 찾고, 병목 구간을 식별할 수 있습니다.
실전 팁
💡 dt.total_seconds()는 부동소수점을 반환하므로, 밀리초 단위가 필요하면 1000을 곱하세요.
💡 음수 Duration이 나오면 데이터 오류입니다(응답이 생성보다 먼저). filter()로 검증하세요.
💡 영업일 기준 시간차를 계산하려면 주말과 공휴일을 제외해야 하는데, 이는 커스텀 로직이 필요합니다.
💡 dt.total_days()는 24시간 = 1일로 계산합니다. 달력상 날짜 차이는 dt.date()로 변환 후 빼세요.
💡 SLA 위반 여부를 체크하려면 pl.col("response_minutes") > 30처럼 조건식을 사용하세요.
7. 날짜 범위 생성과 비즈니스 데이 계산
시작하며
여러분이 일별 매출 리포트를 만들 때, 데이터가 없는 날짜는 0으로 표시해야 하는데 해당 날짜 행 자체가 없어서 고민했던 경험이 있으시죠? 또는 "영업일 기준 7일 후"를 계산해야 하는데 주말과 공휴일을 어떻게 제외할지 막막했던 적이 있으실 겁니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 불완전한 데이터는 시각화 시 그래프가 끊어지고, 비즈니스 데이(영업일) 계산은 금융, 물류, 계약 관리에서 필수인데 복잡한 로직이 필요합니다.
단순히 7일을 더하면 주말이 포함되어 잘못된 결과가 나옵니다. 바로 이럴 때 필요한 것이 날짜 범위 생성과 비즈니스 데이 계산입니다.
완전한 날짜 시퀀스를 자동으로 만들고, 주말과 공휴일을 고려한 정확한 일자 계산을 수행할 수 있습니다.
개요
간단히 말해서, 날짜 범위 생성은 시작일부터 종료일까지 모든 날짜를 포함하는 완전한 시퀀스를 만드는 것이고, 비즈니스 데이 계산은 주말과 공휴일을 제외한 실제 근무일만 계산하는 것입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 일별 리포트에서 데이터가 없는 날도 0으로 표시하여 연속성을 보장하거나, 계약서에 "영업일 기준 5일 이내 납품" 같은 조건을 정확히 계산하거나, 근무일수를 세어 급여를 산정할 때 필수적입니다.
예를 들어, 금요일에 "3 영업일 후"는 다음 주 수요일이지 월요일이 아닙니다. 기존에는 수동으로 날짜를 하나씩 생성하고, while 루프로 날짜를 증가시키며 요일을 확인했다면, 이제는 datetime_range()와 dt.weekday()를 조합하여 간단히 처리할 수 있습니다.
날짜 범위 생성의 핵심 특징은 첫째, 완전한 날짜 시퀀스 보장, 둘째, 요일 필터링으로 평일만 추출, 셋째, 공휴일 리스트와 조인하여 비즈니스 데이 계산입니다. 이러한 특징들이 불완전한 데이터를 보정하고, 비즈니스 규칙을 정확하게 구현하도록 도와줍니다.
코드 예제
import polars as pl
from datetime import datetime, timedelta
# 완전한 날짜 범위 생성 (2024년 1월 전체)
date_range = pl.DataFrame({
"date": pl.datetime_range(
start=datetime(2024, 1, 1),
end=datetime(2024, 1, 31),
interval=timedelta(days=1),
eager=True
)
})
# 요일 정보 추가 (0=월요일, 6=일요일)
date_range = date_range.with_columns([
pl.col("date").dt.weekday().alias("weekday"),
pl.col("date").dt.strftime("%Y-%m-%d").alias("date_str")
])
# 평일만 필터링 (월~금)
business_days = date_range.filter(pl.col("weekday") < 5)
# 공휴일 리스트 (예시)
holidays = pl.DataFrame({
"date": [datetime(2024, 1, 1), datetime(2024, 1, 15)] # 신정, 설날 가정
})
# 공휴일 제외한 실제 영업일
actual_business_days = business_days.join(
holidays.with_columns(pl.lit(True).alias("is_holiday")),
on="date",
how="left"
).filter(pl.col("is_holiday").is_null())
print(f"1월 평일 수: {len(business_days)}")
print(f"1월 실제 영업일 수: {len(actual_business_days)}")
# 실제 데이터와 조인하여 빈 날짜 채우기
sales_data = pl.DataFrame({
"date": [datetime(2024, 1, 2), datetime(2024, 1, 5)],
"sales": [1000, 1500]
})
complete_sales = date_range.join(sales_data, on="date", how="left").with_columns([
pl.col("sales").fill_null(0)
])
print(complete_sales.head(10))
설명
이것이 하는 일: 완전한 날짜 시퀀스를 생성하고, 비즈니스 규칙(주말, 공휴일 제외)을 적용하여 정확한 영업일을 계산하며, 실제 데이터와 조인하여 누락된 날짜를 보정합니다. 첫 번째로, pl.datetime_range()로 1월 1일부터 31일까지 모든 날짜를 생성합니다.
이렇게 하는 이유는 실제 매출 데이터에는 매출이 0인 날(휴무일 등)이 아예 없을 수 있는데, 시각화나 분석을 위해서는 모든 날짜가 있어야 하기 때문입니다. interval=timedelta(days=1)은 하루씩 증가하라는 의미입니다.
그 다음으로, dt.weekday()로 요일 정보를 추출하고, < 5 조건으로 월요일(0)부터 금요일(4)만 남깁니다. 토요일(5)과 일요일(6)은 제외됩니다.
이것만으로도 기본적인 영업일 계산이 가능하지만, 공휴일까지 고려하려면 추가 작업이 필요합니다. 그리고 공휴일 DataFrame을 만들고, join()으로 결합한 후 is_holiday가 null인(=공휴일이 아닌) 날짜만 필터링합니다.
실무에서는 공휴일 데이터를 외부 API나 데이터베이스에서 가져오거나, 한국은 korean-lunar-calendar 같은 라이브러리를 사용할 수 있습니다. 각 국가마다 공휴일이 다르므로 지역에 맞게 설정해야 합니다.
마지막으로, 완전한 날짜 범위와 실제 매출 데이터를 left join하여, 매출이 없는 날은 자동으로 null이 되고, fill_null(0)으로 0으로 채웁니다. 이제 그래프를 그리면 빠진 날짜 없이 연속적인 선이 나타나며, 매출이 0인 날과 데이터가 누락된 날을 구분할 수 있습니다.
여러분이 이 코드를 사용하면 일별 리포트에서 빠진 날짜로 인한 혼란을 없앨 수 있고, 계약 만료일이나 납품 예정일을 영업일 기준으로 정확히 계산할 수 있으며, 근태 관리 시스템에서 실제 근무일수를 자동으로 산정할 수 있습니다. 또한 시계열 예측 모델에 투입할 때 완전한 데이터를 제공할 수 있습니다.
실전 팁
💡 공휴일 데이터는 매년 업데이트해야 합니다. 신정, 설날, 추석 등 음력 공휴일은 양력 날짜가 매년 바뀝니다.
💡 dt.is_weekend() 같은 함수는 Polars에 없으므로 weekday() >= 5로 직접 체크하세요.
💡 월별 영업일수를 미리 계산하여 캐싱하면 반복 쿼리에서 성능이 향상됩니다.
💡 시간대가 다른 글로벌 서비스에서는 각 지역의 공휴일을 별도로 관리해야 합니다.
💡 "영업일 기준 N일 후"를 계산하려면 while 루프로 날짜를 증가시키며 카운트해야 하는데, 성능을 위해 영업일 시퀀스를 미리 만들어두는 것이 좋습니다.
8. 시계열 이상치 탐지와 아웃라이어 제거
시작하며
여러분이 서버 모니터링 데이터를 분석하는데, 갑자기 CPU 사용률이 200%로 찍히거나(물리적으로 불가능), 온도 센서가 -999도를 기록하는(센서 오류) 등 명백히 잘못된 값들을 발견한 경험이 있으시죠? 이런 이상치를 그대로 두면 평균이 왜곡되고 차트가 망가집니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 센서 고장, 네트워크 오류, 데이터 입력 실수 등으로 인해 비정상적인 값이 섞이는데, 이를 제대로 처리하지 않으면 분석 결과를 신뢰할 수 없고, 머신러닝 모델이 잘못 학습됩니다.
단순히 최대/최소값으로 필터링하면 정상적인 극값까지 제거될 수 있습니다. 바로 이럴 때 필요한 것이 통계적 이상치 탐지입니다.
데이터의 분포를 고려하여 비정상적인 값을 자동으로 찾아내고, 제거하거나 보정할 수 있습니다.
개요
간단히 말해서, 이상치 탐지는 데이터의 통계적 패턴을 벗어나는 값을 식별하는 작업입니다. 대표적으로 IQR(Interquartile Range) 방법과 Z-score 방법이 있습니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 주식 거래량 데이터에서 잘못 입력된 값을 제거하거나, IoT 센서 데이터에서 하드웨어 오류를 감지하거나, 웹 분석에서 봇 트래픽을 걸러낼 때 필수적입니다. 예를 들어, 평소 1초에 100건 처리하는 서버가 갑자기 1건만 처리했다면, 이는 장애 신호이므로 즉시 알림을 보내야 합니다.
기존에는 도메인 지식으로 임계값을 수동 설정했다면(예: "100 이상이면 이상치"), 이제는 IQR이나 Z-score로 데이터 자체의 분포에서 자동으로 임계값을 계산할 수 있습니다. 이상치 탐지의 핵심 특징은 첫째, 통계적 방법(IQR, Z-score)으로 자동 탐지, 둘째, 롤링 윈도우 기반 동적 임계값, 셋째, 이상치 제거 또는 캡핑(capping)입니다.
이러한 특징들이 데이터 품질을 자동으로 관리하고, 이상 징후를 조기에 발견하도록 도와줍니다.
코드 예제
import polars as pl
from datetime import datetime, timedelta
import random
# 이상치가 포함된 센서 데이터
random.seed(42)
df = pl.DataFrame({
"timestamp": pl.datetime_range(
start=datetime(2024, 1, 1),
end=datetime(2024, 1, 31),
interval=timedelta(hours=1),
eager=True
),
"temperature": [
20 + random.gauss(0, 2) if random.random() > 0.05 else random.choice([100, -50])
for _ in range(744)
]
})
# IQR 방법으로 이상치 탐지
q1 = df.select(pl.col("temperature").quantile(0.25)).item()
q3 = df.select(pl.col("temperature").quantile(0.75)).item()
iqr = q3 - q1
lower_bound = q1 - 1.5 * iqr
upper_bound = q3 + 1.5 * iqr
df = df.with_columns([
((pl.col("temperature") < lower_bound) | (pl.col("temperature") > upper_bound))
.alias("is_outlier")
])
print(f"이상치 개수: {df.filter(pl.col('is_outlier')).height}")
# Z-score 방법
mean = df.select(pl.col("temperature").mean()).item()
std = df.select(pl.col("temperature").std()).item()
df = df.with_columns([
((pl.col("temperature") - mean).abs() / std).alias("z_score")
])
# Z-score > 3인 값은 이상치로 간주
df = df.with_columns([
(pl.col("z_score") > 3).alias("is_outlier_zscore")
])
# 이상치 제거한 클린 데이터
df_clean = df.filter(~pl.col("is_outlier"))
# 또는 이상치를 경계값으로 캡핑
df_capped = df.with_columns([
pl.col("temperature").clip(lower_bound, upper_bound).alias("temp_capped")
])
print(df.filter(pl.col("is_outlier")).head())
설명
이것이 하는 일: 데이터의 통계적 분포를 분석하여 비정상적으로 벗어난 값을 자동으로 찾아내고, 제거하거나 보정하여 신뢰할 수 있는 분석을 가능하게 합니다. 첫 번째로, IQR 방법은 25% 백분위수(Q1)와 75% 백분위수(Q3)를 구하고, 그 차이(IQR)의 1.5배를 벗어난 값을 이상치로 간주합니다.
이렇게 하는 이유는 정규분포를 가정하지 않아도 되고, 극단값에 강건하며, 박스플롯의 수염(whisker) 범위와 일치하기 때문입니다. lower_bound = Q1 - 1.5*IQR, upper_bound = Q3 + 1.5*IQR로 계산하며, 이는 통계학에서 검증된 표준 방법입니다.
그 다음으로, Z-score 방법은 각 값이 평균에서 표준편차의 몇 배만큼 떨어져 있는지 계산합니다. (값 - 평균) / 표준편차로 계산하며, 일반적으로 |Z-score| > 3이면 이상치로 봅니다.
이는 정규분포에서 99.7%가 ±3σ 안에 있다는 3-시그마 규칙에 기반합니다. 데이터가 정규분포에 가까울수록 효과적입니다.
그리고 이상치를 처리하는 방법은 두 가지입니다. 첫째, filter(~is_outlier)로 완전히 제거하는 방법은 명백한 오류(센서 고장)에 적합합니다.
둘째, clip(lower_bound, upper_bound)로 경계값으로 캡핑하는 방법은 극단값이지만 의미 있는 정보(예: 실제로 매우 더운 날)일 때 적합합니다. 용도에 따라 선택하세요.
마지막으로, 롤링 윈도우 기반 이상치 탐지도 가능합니다. 예를 들어 최근 24시간 평균에서 크게 벗어나면 이상치로 간주하는 방법인데, 트렌드가 있는 데이터(계속 증가하는 사용자 수)에 더 적합합니다.
rolling_mean()과 rolling_std()를 조합하여 구현할 수 있습니다. 여러분이 이 코드를 사용하면 수동으로 데이터를 점검하지 않아도 자동으로 이상치를 찾을 수 있고, 데이터 품질 리포트를 생성하여 센서나 시스템의 문제를 조기에 발견할 수 있습니다.
또한 클린한 데이터로 정확한 통계 분석과 머신러닝 모델 학습이 가능해집니다.
실전 팁
💡 IQR과 Z-score 둘 다 계산하여 교차 검증하면 더 정확합니다. 둘 다 이상치로 표시된 값은 확실한 오류입니다.
💡 1.5 대신 3을 사용하면 더 보수적으로(이상치를 적게) 탐지합니다. 도메인에 따라 조정하세요.
💡 이상치를 제거하기 전에 원인을 파악하세요. 진짜 이상 현상(해킹 공격 등)일 수 있습니다.
💡 시계열 데이터는 계절성과 트렌드가 있으므로, 전체 기간의 통계보다 롤링 윈도우 기반이 더 정확할 수 있습니다.
💡 이상치 탐지 결과를 시각화하여 임계값이 적절한지 확인하세요. 너무 많이 또는 적게 탐지되면 파라미터를 조정해야 합니다.
9. 계절성과 트렌드 분해 (Decomposition)
시작하며
여러분이 월별 매출 데이터를 보는데, 매년 12월에 급증하고 1월에 급락하는 패턴이 반복되는 걸 알아챘지만, 전체적으로 매출이 늘고 있는 건지 줄고 있는 건지 헷갈렸던 경험이 있으시죠? 계절적 변동과 장기 추세가 섞여 있어서 실제 성장률을 파악하기 어렵습니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 시계열 데이터는 트렌드(장기 추세), 계절성(주기적 패턴), 잔차(랜덤 노이즈)가 혼합되어 있는데, 이를 분리하지 않으면 올바른 의사결정을 할 수 없습니다.
단순히 전월 대비 증감만 보면 계절 효과에 속을 수 있습니다. 바로 이럴 때 필요한 것이 시계열 분해(Time Series Decomposition)입니다.
데이터를 트렌드, 계절성, 잔차 세 가지 요소로 분리하여 각각의 특성을 명확히 파악할 수 있습니다.
개요
간단히 말해서, 시계열 분해는 관측값을 트렌드(장기 방향), 계절성(주기적 패턴), 잔차(설명되지 않는 변동) 세 요소로 나누는 분석 기법입니다. 가법 모델(additive)과 승법 모델(multiplicative)이 있습니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 소매업에서 명절 효과를 제거한 실제 성장세를 파악하거나, 전력 수요에서 계절적 변동과 장기 증가 추세를 분리하거나, 웹 트래픽에서 요일별 패턴과 전체 성장을 구분할 때 필수적입니다. 예를 들어, 12월 매출이 전년 대비 10% 증가했는데, 계절 효과를 제거하면 실제로는 2%만 증가한 것일 수 있습니다.
기존에는 Python의 statsmodels.tsa.seasonal_decompose를 사용했지만, Polars 자체로는 직접 구현이 필요합니다. 하지만 이동 평균을 활용하여 간단한 분해를 수행할 수 있습니다.
시계열 분해의 핵심 특징은 첫째, 트렌드 추출(장기 방향 파악), 둘째, 계절성 패턴 식별(주기적 변동), 셋째, 잔차 분석(랜덤 변동과 이상치)입니다. 이러한 특징들이 복잡한 시계열을 이해 가능한 구성 요소로 나누어, 예측과 의사결정을 개선합니다.
코드 예제
import polars as pl
from datetime import datetime, timedelta
import math
# 트렌드 + 계절성 + 노이즈가 있는 월별 매출 데이터 생성
months = 36 # 3년 데이터
df = pl.DataFrame({
"month": pl.datetime_range(
start=datetime(2021, 1, 1),
end=datetime(2023, 12, 1),
interval="1mo",
eager=True
),
"sales": [
# 트렌드 (선형 증가) + 계절성 (12개월 주기) + 노이즈
1000 + i * 10 + 200 * math.sin(2 * math.pi * i / 12) + (i % 3) * 50
for i in range(months)
]
})
# 1. 트렌드 추출 - 12개월 이동 평균 (계절 주기만큼)
df = df.with_columns([
pl.col("sales").rolling_mean(window_size=12, center=True).alias("trend")
])
# 2. 계절성 제거 (Detrended) = 원본 - 트렌드
df = df.with_columns([
(pl.col("sales") - pl.col("trend")).alias("detrended")
])
# 3. 계절성 패턴 계산 - 각 월의 평균 효과
seasonal = df.with_columns([
pl.col("month").dt.month().alias("month_num")
]).group_by("month_num").agg([
pl.col("detrended").mean().alias("seasonal_effect")
])
# 4. 원본 데이터에 계절성 조인
df = df.with_columns([
pl.col("month").dt.month().alias("month_num")
]).join(seasonal, on="month_num")
# 5. 잔차 계산 = 원본 - 트렌드 - 계절성
df = df.with_columns([
(pl.col("sales") - pl.col("trend") - pl.col("seasonal_effect")).alias("residual")
])
# 6. 계절성 조정된 매출 (Seasonally Adjusted)
df = df.with_columns([
(pl.col("sales") - pl.col("seasonal_effect")).alias("sales_adjusted")
])
print(df.select(["month", "sales", "trend", "seasonal_effect", "residual"]).tail(12))
설명
이것이 하는 일: 복잡한 시계열 데이터를 세 가지 이해하기 쉬운 요소(장기 추세, 반복 패턴, 랜덤 노이즈)로 분해하여, 각 요소의 영향을 정량화하고 계절 효과를 제거한 실제 성장을 파악합니다. 첫 번째로, rolling_mean(window_size=12, center=True)로 12개월 이동 평균을 계산하여 트렌드를 추출합니다.
이렇게 하는 이유는 계절성의 주기가 12개월이므로, 12개월 평균을 구하면 계절적 변동이 상쇄되어 순수한 장기 추세만 남기 때문입니다. center=True는 양방향으로 평균을 내어 더 정확한 중심 트렌드를 얻습니다(단, 예측에는 사용 불가).
그 다음으로, 원본에서 트렌드를 빼서 detrended 시리즈를 만듭니다. 이제 장기 추세는 제거되고 계절성과 노이즈만 남았습니다.
이 값을 월별로 그룹화하여 평균을 구하면, 각 월의 평균적인 계절 효과를 얻을 수 있습니다. 예를 들어 12월은 +200, 1월은 -150 같은 식입니다.
그리고 원본 데이터에 월 번호를 추가하고 계절성 테이블과 조인하여, 각 행에 해당 월의 계절 효과를 붙입니다. 이제 sales - trend - seasonal_effect로 잔차를 계산하면, 트렌드와 계절성으로 설명되지 않는 랜덤 변동만 남습니다.
잔차가 크면 특별한 이벤트(프로모션, 재난 등)나 모델 개선이 필요함을 의미합니다. 마지막으로, sales - seasonal_effect로 계절성 조정된(seasonally adjusted) 매출을 계산합니다.
이 값은 "만약 계절 효과가 없었다면 얼마였을까"를 보여주며, 순수한 성장세를 파악하는 데 사용됩니다. 경제 지표(GDP, 실업률 등)를 발표할 때 흔히 "계절 조정 기준"이라고 하는 것이 바로 이것입니다.
여러분이 이 코드를 사용하면 비즈니스의 실제 성장률을 정확히 측정할 수 있고, 마케팅 캠페인의 효과를 계절 효과와 분리하여 평가할 수 있으며, 예측 모델의 정확도를 개선할 수 있습니다(계절성을 별도로 모델링). 또한 잔차를 분석하여 예상치 못한 이벤트를 감지할 수 있습니다.
실전 팁
💡 계절 주기를 정확히 파악하세요. 주별 데이터는 7일, 일별 소매 데이터는 7일, 월별은 12개월이 일반적입니다.
💡 가법 모델(additive: Y = T + S + R)은 계절 변동폭이 일정할 때, 승법 모델(multiplicative: Y = T × S × R)은 변동폭이 트렌드에 비례할 때 적합합니다.
💡 데이터가 최소 2주기(예: 24개월) 이상 필요합니다. 그래야 계절 패턴을 신뢰할 수 있습니다.
💡 잔차를 시각화하여 패턴이 남아 있는지 확인하세요. 잔차에 패턴이 있으면 분해가 불완전한 것입니다.
💡 STL(Seasonal and Trend decomposition using Loess) 같은 고급 방법도 있지만, 간단한 이동 평균으로도 충분한 경우가 많습니다.
10. 시계열 데이터 최적화와 메모리 효율
시작하며
여러분이 수백만 건의 고빈도 센서 데이터를 처리하는데, 메모리가 부족하거나 쿼리가 너무 느려서 분석을 포기했던 경험이 있으시죠? 특히 타임스탬프와 부동소수점을 기본 설정으로 사용하면 불필요하게 많은 메모리를 차지합니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 시계열 데이터는 양이 많고 연속적이라 빠르게 증가하는데, 타입 선택과 저장 방식에 따라 성능과 비용이 몇 배씩 차이 납니다.
잘못 설계하면 서버 메모리가 폭발하거나 쿼리 타임아웃이 발생합니다. 바로 이럴 때 필요한 것이 시계열 데이터 최적화입니다.
적절한 데이터 타입, 압축, 파티셔닝을 통해 메모리 사용량을 줄이고 쿼리 속도를 높일 수 있습니다.
개요
간단히 말해서, 시계열 데이터 최적화는 데이터 타입을 최소화하고, 압축 형식으로 저장하며, 시간 기반 파티셔닝을 통해 성능과 비용을 개선하는 작업입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, IoT 플랫폼에서 초당 수만 건의 센서 데이터를 저장하거나, 주식 거래 시스템에서 밀리초 단위 데이터를 빠르게 조회하거나, 로그 분석에서 수십 TB 데이터를 효율적으로 관리할 때 필수적입니다.
예를 들어, Float64를 Float32로 바꾸면 메모리가 절반이 되고, Parquet 압축을 사용하면 디스크 공간이 1/10로 줄어듭니다. 기존에는 기본 타입을 그대로 사용하고 CSV로 저장했다면, 이제는 타입을 최적화하고 Parquet 같은 컬럼 기반 포맷을 사용하여 극적인 성능 향상을 얻을 수 있습니다.
시계열 최적화의 핵심 특징은 첫째, 데이터 타입 다운캐스팅(Int64→Int32, Float64→Float32), 둘째, 컬럼 기반 압축 포맷(Parquet), 셋째, 시간 기반 파티셔닝입니다. 이러한 특징들이 대규모 시계열 데이터를 실시간으로 처리 가능하게 만듭니다.
코드 예제
import polars as pl
from datetime import datetime, timedelta
# 원본 데이터 (기본 타입 - 비효율적)
df_original = pl.DataFrame({
"timestamp": pl.datetime_range(
start=datetime(2024, 1, 1),
end=datetime(2024, 12, 31),
interval=timedelta(minutes=1),
eager=True
),
"sensor_id": [i % 100 for i in range(527040)], # 100개 센서
"temperature": [20.5 + (i % 10) * 0.1 for i in range(527040)],
"humidity": [50 + (i % 20) for i in range(527040)]
})
print(f"원본 메모리: {df_original.estimated_size('mb'):.2f} MB")
# 1. 데이터 타입 최적화
df_optimized = df_original.with_columns([
pl.col("sensor_id").cast(pl.Int16), # 100개면 Int16로 충분
pl.col("temperature").cast(pl.Float32), # 소수점 정밀도 낮춤
pl.col("humidity").cast(pl.Int8) # -128~127 범위면 충분
])
print(f"최적화 후 메모리: {df_optimized.estimated_size('mb'):.2f} MB")
# 2. Parquet으로 저장 (컬럼 기반 압축)
df_optimized.write_parquet("/tmp/sensor_data.parquet", compression="snappy")
# 3. 시간 기반 파티셔닝 - 월별로 분할
df_partitioned = df_optimized.with_columns([
pl.col("timestamp").dt.strftime("%Y-%m").alias("year_month")
])
# 월별로 개별 파일 저장
for year_month in df_partitioned["year_month"].unique().sort():
partition_df = df_partitioned.filter(pl.col("year_month") == year_month)
partition_df.write_parquet(f"/tmp/sensor_data_{year_month}.parquet")
# 4. 특정 기간만 빠르게 로드
df_jan = pl.read_parquet("/tmp/sensor_data_2024-01.parquet")
print(f"1월 데이터만 로드: {df_jan.height} 행")
# 5. Lazy 모드로 대용량 데이터 처리
lazy_df = pl.scan_parquet("/tmp/sensor_data.parquet")
result = lazy_df.filter(pl.col("sensor_id") == 42).select(["timestamp", "temperature"]).collect()
print(f"Lazy 모드 결과: {result.height} 행")
설명
이것이 하는 일: 시계열 데이터의 타입을 최적화하고, 효율적인 저장 포맷을 사용하며, 시간 범위별로 분할하여 대규모 데이터를 빠르고 경제적으로 처리합니다. 첫 번째로, 데이터 타입 다운캐스팅을 수행합니다.
sensor_id는 099 범위이므로 Int64(기본) 대신 Int16(−32,76832,767)으로 충분하며, 메모리가 1/4로 줄어듭니다. temperature는 Float64(8바이트) 대신 Float32(4바이트)로 줄여도 소수점 6자리 정밀도는 유지되어 대부분의 경우 충분합니다.
humidity는 0100 범위라면 Int8(−128127)로도 가능합니다. 이렇게 하면 메모리 사용량이 50~70% 감소합니다.
그 다음으로, Parquet 포맷으로 저장하면 컬럼 기반 압축 덕분에 디스크 공간이 극적으로 줄어듭니다. Parquet은 같은 컬럼의 값들을 연속으로 저장하므로 압축률이 높고, 필요한 컬럼만 읽을 수 있어 I/O가 빠릅니다.
compression="snappy"는 빠른 압축/해제, "gzip"은 더 높은 압축률을 제공합니다. CSV 대비 510배 작고 35배 빠릅니다.
그리고 시간 기반 파티셔닝은 데이터를 시간 범위별로 나누어 저장합니다. 예를 들어 월별로 파일을 분리하면, 특정 월 데이터만 조회할 때 전체 데이터를 로드하지 않아도 됩니다.
1년치 데이터 중 1월만 필요하면 1/12만 읽으므로 속도가 12배 빨라집니다. 데이터베이스의 파티션 테이블과 같은 개념입니다.
마지막으로, Polars의 Lazy 모드(scan_parquet)를 사용하면 쿼리를 먼저 분석하고 필요한 부분만 읽습니다. 예를 들어 filter(sensor_id == 42)를 먼저 적용한 후 해당 행만 로드하므로, 전체 데이터를 메모리에 올리지 않아도 됩니다.
수 GB 데이터도 적은 메모리로 처리할 수 있습니다. 여러분이 이 코드를 사용하면 같은 하드웨어로 10배 많은 데이터를 처리할 수 있고, 클라우드 비용(스토리지, 메모리)을 절반 이하로 줄일 수 있으며, 쿼리 응답 시간을 초 단위에서 밀리초 단위로 단축할 수 있습니다.
특히 실시간 대시보드나 API에서 차이가 극명합니다.
실전 팁
💡 다운캐스팅 전에 max(), min()으로 실제 범위를 확인하세요. 예상과 다르면 데이터 오버플로우가 발생합니다.
💡 Parquet 파일은 한 번 쓰고 여러 번 읽는 용도에 최적화되어 있습니다. 자주 업데이트하는 데이터는 데이터베이스가 더 적합합니다.
💡 파티션 크기는 적당히 유지하세요. 너무 작으면(파일 1000개) 오버헤드가, 너무 크면(파일 1개) 효율이 떨어집니다. 월별이나 일별이 일반적입니다.
💡 estimated_size()로 메모리 사용량을 측정하여 최적화 효과를 검증하세요.
💡 시계열 데이터베이스(InfluxDB, TimescaleDB)도 고려하세요. 매우 큰 규모나 실시간 쿼리가 많으면 전용 DB가 더 나을 수 있습니다.