8 Tidy Data와 데이터 분석
데이터 분석 프로젝트에서 시간이 많이 걸리는 단계는 어디일까요? 모델 튜닝이나 화려한 시각화가 아닙니다. 흔히 “데이터 랭글링(Data Wrangling)” 또는 “데이터 멍잉(Data Munging)”이라고 불리는 데이터 정제 및 준비(Cleaning) 과정이 전체 시간의 80%를 차지한다고 합니다[1].
이 과정은 단지 지루한 사전 작업이 아닙니다. 이 단계에서 데이터의 품질이 결정되고, 분석의 방향이 잡히기 때문입니다. 하지만 안타깝게도 이 중요한 과정을 어떻게 효율적으로 수행할지에 대한 ’표준’은 오랫동안 부재했습니다. 여전히 많은 분석가들이 데이터 정제 및 준비 과정을 어떻게 효율적으로 수행할지에 대해서 고민하고 있습니다.
우리는 데이터 정제의 광범위한 영역 중에서도 데이터 구조화(Tidying)에 집중합니다.
데이터 구조화(Tidying): 데이터의 ’형태(Shape)’와 ’구조(Structure)’를 분석하기 좋은 형태로 만드는 작업입니다.
데이터 정제(Cleaning): 결측치 채우기, 오타 수정, 이상치 제거 등 ‘값(Value)’ 자체의 오류를 수정하는 포괄적인 과정입니다.
데이터가 구조적으로 깔끔하다면(Tidy), 정제 작업도 훨씬 수월해집니다. Tidy Data는 데이터 분석의 고속도로를 까는 작업과 같습니다.
8.1 Tidy Data의 정의
Tidy Data의 논문에 아래와 같은 인용 구절이 있습니다.
“행복한 가정은 모두 비슷하지만, 불행한 가정은 제각각의 이유로 불행하다.” — 레프 톨스토이, 안나 카레니나(Anna Karenina)
데이터 분석의 세계에서도 이 명언은 통합니다. 깔끔한 데이터(Tidy Data)는 모두 비슷한 모습을 하고 있지만, 지저분한 데이터(Messy Data)는 제각각의 방식으로 지저분합니다[2]. Hadley Wickham이 제안한 Tidy Data의 개념은 데이터셋의 구조(물리적 배치)와 의미론(데이터의 의미)을 일치시키는 표준화된 프레임워크입니다.
8.1.1 Tidy Data의 3대 원칙
다음 세 가지 규칙을 만족한다면, 그 데이터는 Tidy Data입니다.
- 각 변수(variable)는 하나의 열(column)을 형성해야 합니다.
- 각 관측치(observation)는 하나의 행(row)을 형성해야 합니다.
- 각 관측 단위(observational unit)의 유형은 하나의 테이블(table)을 형성해야 합니다.
이 원칙은 데이터베이스 정규화(normalization) 이론, 특히 Codd의 제3정규형(3rd normal form)과 맞닿아 있습니다[3]. 하지만 통계분석과 데이터 과학의 관점에서 훨씬 실용적이고 직관적인 가이드라인을 제공합니다.
8.1.2 왜 Tidy Data인가?
이 구조가 중요한 이유는 Python의 pandas, R의 Tidyverse 등 현대적인 데이터 분석 도구들이 벡터 연산(vectorized operation)을 기반으로 설계되었기 때문입니다.
변수(variable)가 열(column)에 있으면
df["price"] * df["quantity"]와 같이 열 단위의 벡터 연산이 자연스럽게 가능합니다.관측치(observation)가 행(row)에 있으면 데이터의 필터링이나 집계가 논리적으로 명확해집니다.
반면, 지저분한 데이터에서는 단순한 평균을 구하는 것조차 복잡한 반복문이나 전치(transpose) 작업이 필요할 수 있습니다. Tidy Data는 도구와 데이터 사이의 마찰을 줄여, 분석가가 데이터 처리(logistics)가 아닌 문제 해결(insight)에 집중하게 해줍니다.
Tidy Data가 벡터 연산에 유리한 이유는 단순히 코드가 간결해서가 아니라, CPU의 파이프라이닝(pipelining)과 캐시 효율성을 극대화하기 때문입니다.
8.1.2.1 루프 vs 벡터 연산의 CPU 수준 차이
Python 루프와 벡터 연산의 성능 차이를 실제 코드로 비교하여, Tidy Data가 벡터 연산을 가능하게 하는 이유를 이해할 수 있습니다. Python 루프는 각 반복마다 인터프리터가 타입 검사, 메모리 할당, 참조 카운팅 등을 수행하여 매우 느립니다. 반면 벡터 연산은 NumPy/pandas가 내부적으로 C 레벨에서 최적화된 루프를 사용하고, CPU의 SIMD(단일 명령 다중 데이터) 명령어를 활용하여 여러 값을 동시에 처리합니다. Tidy Data 구조(변수가 열에 정렬)는 이러한 벡터 연산의 전제조건입니다.
아래 코드에서 반복문을 사용하는 것은 어쩌면 수 초가 걸릴 수 있지만, 벡터 연산을 사용하면 더 빠르게 처리됩니다. 이는 수십 배에서 수백 배의 성능 차이입니다. 벡터 연산이 가능한 이유는 같은 타입의 데이터가 메모리에 연속적으로 배치되어 있어 CPU 캐시 효율이 높고, SIMD 명령어로 4개 또는 8개 값을 동시에 처리할 수 있기 때문입니다. 변수가 열에 정렬되어 있으면, 같은 타입의 데이터가 메모리에 연속적으로 배치됩니다. 이는 벡터 연산의 전제조건입니다.
- Python 루프의 오버헤드
- 각 반복마다 Python 인터프리터가 타입 검사, 참조 카운팅을 수행
- CPU 분기 예측(branch prediction) 실패 가능성 높음
- 캐시 미스 빈번 (데이터 접근 패턴 불규칙)
- 벡터 연산의 이점
- C 레벨 루프: NumPy/pandas는 내부적으로 C로 구현된 최적화된 루프 사용
- SIMD 활용: AVX2 명령어로 4개 또는 8개 실수를 동시에 곱셈 수행
- 캐시 친화적: 메모리 연속성으로 프리페칭(prefetching) 효과
Tidy Data와 Messy Data의 구조적 차이가 벡터 연산 가능 여부에 미치는 영향을 비교할 수 있습니다. Tidy Data에서는 각 변수(price, quantity)가 별도의 열로 분리되어 있어, 같은 타입의 데이터가 메모리에 연속적으로 배치됩니다. 이는 벡터 연산의 전제조건이며, SIMD 명령어를 활용한 병렬 처리가 가능합니다. 반면 Messy Data에서는 가격과 수량이 같은 열에 섞여 있어, 가격만 추출하려면 행을 순회해야 하므로 벡터 연산이 불가능합니다.
아래 코드에서 df_tidy는 price와 quantity가 각각 별도 열로 분리되어 있어, df_tidy["price"] * df_tidy["quantity"]와 같은 벡터 연산이 즉시 가능합니다. 이 연산은 내부적으로 C 레벨 루프와 SIMD를 활용하여 매우 빠르게 실행됩니다.
반면 df_messy는 각 열에 [price, quantity] 리스트가 들어가 있어, 가격만 추출하려면 반복문을 사용해야 하므로 성능이 크게 저하됩니다.
이러한 차이는 대용량 데이터에서 수십 배에서 수백 배의 성능 격차를 만듭니다.
8.2 Messy Data의 5가지 문제점과 해결책
실제 데이터는 다양한 방식으로 엉망이 됩니다. Hadley Wickham은 다섯 가지 일반적인 문제를 정의했습니다[2].
8.2.1 열 헤더가 변수명이 아닌 값인 경우
- 문제: 변수가 여러 열에 흩어져 있습니다. 여기서
wk1,wk2, …,wk76은 변수명이 아니라 “주(week)” 변수의 값들입니다.
이런 데이터는 pandas의 melt() 함수를 사용합니다. 열 헤더가 변수명이 아닌 값인 경우(wk1, wk2, …)를 Tidy Data 형태로 변환할 수 있습니다. 원본 데이터에서 ’wk1, wk2, …, wk76’은 주(week) 변수의 값들이지만 열 이름으로 사용되어 있습니다. 이는 Tidy Data 원칙 1(각 변수는 하나의 열)을 위반합니다. melt() 함수는 여러 열을 하나의 week 변수와 rank 값으로 변환하여, 각 행이 하나의 관측치를 나타내는 Long 형식으로 만듭니다. id_vars는 그대로 유지할 식별자 열들을 지정합니다.
변환 전에는 각 곡이 한 행에 있고, 주차별 순위가 여러 열(‘wk1, wk2, …’)에 분산되어 있었습니다. 변환 후에는 각 곡-주차 조합이 별도의 행이 되어, groupby('week')나 filter() 같은 연산이 자연스럽게 가능합니다. 예를 들어 ’1주차에 1위를 한 곡’을 찾으려면 billboard_tidy[(billboard_tidy["week"] == "wk1") & (billboard_tidy["rank"] == 1)]로 간단히 필터링할 수 있습니다.
- id_vars: 그대로 유지할 열들
- var_name: 열 이름을 값으로 저장할 새 열의 이름
- value_name: 셀 값을 저장할 새 열의 이름
8.2.2 한 열에 여러 변수가 저장된 경우
열 이름에 여러 정보가 압축되어 있습니다. m014, m1524 등의 열 이름이 성별(m/f)과 나이 범위(014/1524 등)를 모두 포함합니다.
한 열에 여러 변수가 압축된 경우(m014, m1524 등)를 분리하여 Tidy Data로 변환할 수 있습니다. 원본 데이터의 열 이름(m014, m1524, f014 등)에는 성별(m/f)과 나이 범위(014, 1524 등) 두 가지 정보가 압축되어 있습니다. 먼저 melt()로 열 이름을 값으로 변환한 후, 정규표현식을 사용하여 성별과 나이를 분리합니다. str.extract()는 정규표현식의 캡처 그룹을 사용하여 여러 열로 분리할 수 있습니다. r"([mf])(\d+)"는 첫 번째 그룹이 성별(m/f), 두 번째 그룹이 나이 범위를 캡처합니다.
아래 코드에서 Step 1(melt) 후에는 demo 열에 m014, m1524 같은 값이 들어가 있습니다. Step 2(str.extract) 후에는 sex 열에 ‘m’ 또는 ‘f’, age 열에 014, 1524 등이 분리되어 저장됩니다. 이제 성별별, 나이대별 집계가 가능하며, tb_tidy.groupby(["sex", "age"])["cases"].sum()과 같은 연산이 자연스럽게 가능합니다.
import pandas as pd
tb = pd.read_csv("../data/tb_long.csv")
# Step 1: Melt (변수명을 값으로)
tb_melted = tb.melt(
id_vars=["country", "year"],
var_name="demo",
value_name="cases",
)
# Step 2: 열 분할 (한 열의 값을 여러 열로)
tb_tidy = tb_melted.copy()
tb_tidy[["sex", "age"]] = tb_melted["demo"].str.extract(
r"([mf])(\d+)"
)
tb_tidy = tb_tidy.drop("demo", axis=1)8.2.3 변수가 행과 열 모두에 저장된 경우
동일한 관측의 데이터가 여러 행에 분산되어 있습니다. 최고기온(tmax)과 최저기온(tmin)이 행에 저장되어 있고, 각 날짜가 열에 저장되어 있습니다.
변수가 행과 열 모두에 저장된 경우(최고기온/최저기온이 행에, 날짜가 열에)를 Tidy Data로 변환할 수 있습니다. 원본 데이터에서는 element 열에 ‘tmax’(최고기온)와 ‘tmin’(최저기온)이 행으로 저장되어 있고, 각 날짜(‘d1, d2, …’)가 열로 저장되어 있습니다. 먼저 melt()로 날짜 열들을 행으로 변환한 후, pivot_table()로 element 값을 열로 변환합니다. pivot_table()은 pivot()과 달리 중복값이 있을 때 aggregation 함수를 적용할 수 있어 더 안전합니다. reset_index()는 인덱스를 다시 일반 열로 변환합니다.
아래 코드에서 Step 1(melt) 후에는 각 행이 하나의 측정값(id, year, month, element, day, temp)을 나타냅니다. Step 2(pivot_table) 후에는 element 값(tmax, tmin)이 별도의 열로 분리되어, 각 행이 특정 날짜의 최고기온과 최저기온을 동시에 포함합니다. 이제 weather_tidy["tmax"] - weather_tidy["tmin"]로 일교차를 쉽게 계산할 수 있습니다.
import pandas as pd
weather = pd.read_csv("../data/weather.csv")
# Step 1: 날짜 열을 행으로 (Melt)
weather_melted = weather.melt(
id_vars=["id", "year", "month", "element"],
var_name="day",
value_name="temp",
)
# Step 2: element 값을 열로 (Pivot)
weather_tidy = weather_melted.pivot_table(
index=["id", "year", "month", "day"],
columns="element",
values="temp",
).reset_index()- index`: 행으로 유지할 열
- columns`: 새로운 열이 될 값의 원본 열
- values`: 셀 값의 원본 열
- pivot_table(): 데이터를 피벗하면서 중복값을 처리 (aggregation 함수 사용)
- reset_index(): 인덱스를 다시 열로 변환
8.2.4 중복값이 있을 때 집계(aggregation) 적용
pivot_table()의 aggregation 기능을 활용하여 중복값이 있을 때 처리 방법을 보여줄 수 있습니다. pivot()은 중복값이 있으면 에러를 발생시키지만, pivot_table()은 aggfunc 매개변수를 통해 중복값을 집계할 수 있습니다. mean, sum, first 등 다양한 함수를 사용할 수 있으며, 데이터에 중복이 있을 가능성이 있는 경우 pivot_table()을 사용하는 것이 안전합니다.
만약 같은 (id, year, month, day, element) 조합에 여러 temp 값이 있다면, aggfunc="mean"으로 평균을 계산합니다. 이는 데이터 품질 문제를 처리하는 동시에 Tidy Data 구조를 유지하는 방법입니다. 결과는 이전 예제와 동일한 구조이지만, 중복값이 있는 경우에도 안전하게 처리됩니다.
pandas 1.0.0+ 에서는 더 직관적인 wide_to_long() 함수를 제공합니다. 이 함수는 melt()보다 더 직관적으로 Wide 형식을 Long 형식으로 변환할 수 있습니다. stubnames는 열 이름의 공통 부분을 지정하고, suffix는 숫자 패턴을 매칭합니다. i는 식별자 변수, j는 새로 생성될 열 이름입니다. 이 방법은 melt()보다 열 이름 패턴을 더 명확하게 표현할 수 있습니다.
wide_to_long()은 melt()와 동일한 결과를 생성하지만, 열 이름 패턴이 명확한 경우 코드가 더 읽기 쉽습니다. 특히 여러 그룹의 wide 열(‘wk1, wk2, …’와 ’sales1, sales2, …’’)이 있을 때 각각 다른 stubnames로 처리할 수 있어 유연합니다. 결과는 각 행이 (artist, track, week) 조합을 나타내는 Long 형식입니다.
stack()과 unstack()을 사용하여 Wide와 Long 변환을 함수형 스타일로 수행할 수 있습니다. stack()은 열을 행으로 변환(Wide → Long)하고, unstack()은 행을 열로 변환(Long → Wide)합니다. set_index()로 변환할 열들을 인덱스로 설정한 후 stack()/unstack()을 적용하면 melt()/pivot()과 유사한 결과를 얻을 수 있습니다. 이 방법은 인덱스 기반 연산이므로 대용량 데이터에서 성능이 좋을 수 있습니다. element를 인덱스에 포함시키는 이유는 같은 (id, year, month, day) 조합에 tmax와 tmin이 모두 있을 때 중복을 방지하기 위함입니다.
아래 코드에서 stack() 후에는 날짜 열(‘d1, d2, …’)이 행으로 변환되어 day 열이 생성되고, 각 행이 하나의 측정값을 나타냅니다. unstack(["element", "day"])는 element와 day를 모두 열로 변환하여 원래 Wide 형식으로 복원합니다. 이 방법은 인덱스 기반이므로 복잡한 계층적 인덱스를 다룰 때 유용합니다.
import pandas as pd
weather = pd.read_csv("../data/weather.csv")
# Wide to Long (Stack)
# element를 인덱스에 포함시켜야 중복을 방지할 수 있습니다
long_df = (
weather.set_index(["id", "year", "month", "element"])
.stack()
.reset_index()
)
long_df.columns = [
"id",
"year",
"month",
"element",
"day",
"temp",
]
# element와 day를 모두 unstack하면 원래 형태로 복원됩니다
wide_df = (
long_df.set_index(
["id", "year", "month", "element", "day"]
)
.unstack(["element", "day"])
.reset_index()
)8.2.4.1 한 테이블에 여러 관측 단위가 있는 경우
서로 다른 수준의 데이터가 한 테이블에 섞여 있습니다. 원본 데이터에서 “곡(Song)” 정보와 “순위(Ranking)” 정보가 섞여 있습니다.
한 테이블에 여러 관측 단위가 섞인 경우(곡 정보 + 순위 정보)를 정규화하여 분리할 수 있습니다. 원본 데이터에는 곡 정보(year, artist, track, time)와 순위 정보(‘wk1, wk2, …’’)가 같은 테이블에 섞여 있어, 곡 정보가 여러 행에 반복됩니다. 이는 Tidy Data 원칙 3을 위반하며, 메모리 낭비와 데이터 불일치 위험을 초래합니다. 곡 정보를 별도 테이블로 분리하고 song_id로 참조하면, 곡 정보는 한 곳에서만 관리되고 필요시 join으로 결합할 수 있습니다.
songs 테이블은 각 곡의 고유 정보를 한 번만 저장하므로 메모리가 절약되고, 곡 정보 수정 시 한 곳만 수정하면 됩니다. ratings 테이블은 순위 정보만 저장하며, song_id를 통해 songs와 연결됩니다. 이 구조는 데이터베이스 정규화의 핵심 원칙이며, 데이터 무결성과 유지보수성을 크게 향상시킵니다.
import pandas as pd
billboard = pd.read_csv("../data/billboard.csv")
# Step 1: 곡 정보 테이블
songs = billboard[
["year", "artist", "track", "time"]
].drop_duplicates()
songs["song_id"] = range(len(songs))
# Step 2: 순위 정보 테이블
billboard["song_id"] = range(len(billboard))
ratings = billboard[
["song_id", "date.entered", "wk1", "wk2", "wk3"]
].copy()
ratings = ratings.melt(
id_vars=["song_id", "date.entered"],
var_name="week",
value_name="rank",
)- 중복 정보 제거 (메모리 절약)
- 일관성 유지 (곡 정보 수정 시 한 곳만 수정)
join()으로 필요시 결합 가능
정규화로 분리된 테이블들을 필요시 merge()로 다시 결합할 수 있습니다. 데이터를 정규화하여 분리한 후에도, 분석 시에는 두 테이블의 정보가 모두 필요할 수 있습니다. merge() 함수는 공통 열(song_id)을 기준으로 두 테이블을 결합합니다. 이렇게 하면 정규화의 이점(메모리 절약, 일관성)을 유지하면서도 분석이 가능합니다.
full_data는 ratings와 songs를 song_id로 결합한 결과로, 각 행에는 순위 정보와 해당 곡의 정보(artist, track 등)가 모두 포함됩니다. 이는 원본 데이터와 동일한 정보를 제공하지만, 저장 공간은 더 효율적이며(곡 정보 중복 제거), 곡 정보 수정 시 songs 테이블만 수정하면 모든 관련 행에 자동으로 반영됩니다.
8.2.4.2 한 관측 단위가 여러 테이블에 분산된 경우
같은 종류의 데이터가 여러 파일로 나누어져 있습니다.
여러 파일로 분산된 같은 종류의 데이터를 읽어서 하나의 테이블로 결합할 수 있습니다. 데이터가 연도별, 주차별 등으로 여러 파일로 나뉘어 있는 경우가 많습니다. os.listdir()로 디렉토리의 모든 파일을 순회하면서 CSV 파일을 읽고, 파일명에서 의미 있는 정보(주차 번호)를 추출하여 데이터에 추가합니다. 각 파일을 읽을 때마다 week_number 열을 추가하여 데이터의 출처를 추적할 수 있게 합니다. 마지막으로 pd.concat()으로 모든 데이터프레임을 세로로 결합합니다.
billboard_by_week는 모든 주차의 데이터가 하나의 테이블로 결합된 결과입니다. week_number 열이 추가되어 각 행이 어느 주차 데이터인지 알 수 있으며, 이제 전체 기간에 대한 분석(groupby("week_number"), 시계열 분석 등)이 가능합니다. ignore_index=True는 각 데이터프레임의 인덱스를 무시하고 새로운 연속 인덱스를 생성합니다.
import os
import pandas as pd
# Step 1: 모든 CSV 파일 읽기
dfs = []
file_folder = "../data/billboard-by-week/"
for filename in sorted(os.listdir(file_folder)):
if filename.endswith(".csv"):
# 파일명에서 주차 번호 추출 (billboard-01.csv -> 1)
week_num = int(
filename.replace("billboard-", "").replace(
".csv", ""
)
)
df = pd.read_csv(f"{file_folder}/{filename}")
df["week_number"] = (
week_num # `week_number` 열 추가
)
dfs.append(df)
# Step 2: 모든 데이터프레임 결합
billboard_by_week = pd.concat(dfs, ignore_index=True)pathlib.Path와 glob()을 사용하여 더 Python스럽고 안전한 방식으로 여러 파일을 처리할 수 있습니다. os.listdir()과 문자열 조작 대신 pathlib.Path를 사용하면 경로 처리가 더 안전하고 크로스 플랫폼 호환성이 좋습니다. glob('*.csv')는 패턴 매칭을 통해 CSV 파일만 자동으로 필터링하므로 if filename.endswith('.csv') 체크가 불필요합니다. filepath.stem은 확장자를 제외한 파일명만 추출하므로 더 간결합니다.
이전 예제와 동일한 결과를 생성하지만, 코드가 더 간결하고 읽기 쉽습니다. pathlib은 Python 3.4+에서 표준 라이브러리로 제공되며, 경로 조작 시 실수를 줄이고 코드 가독성을 향상시킵니다. 특히 Windows와 Unix 시스템 간 경로 구분자 차이를 자동으로 처리해주는 장점이 있습니다.
from pathlib import Path
dfs = []
file_folder = "../data/billboard-by-week/"
for filepath in sorted(Path(file_folder).glob("*.csv")):
week_number = int(
filepath.stem.replace("billboard-", "")
)
df = pd.read_csv(filepath)
df["week_number"] = week_number
dfs.append(df)
billboard_by_week = pd.concat(dfs, ignore_index=True)8.3 결측값 처리와 고급 기법
데이터 구조상 강제된 결측값(structural missing value)과 실제 결측값을 구분해야 합니다. Billboard 데이터에서 곡이 차트에 진입하지 않은 주차는 자연스럽게 결측값(NA)이 됩니다. 이는 데이터 구조상 강제된 결측값으로, 분석 목적에 따라 제거할 수 있습니다. dropna(subset=["rank"])는 rank 열이 결측인 행만 제거하므로, 다른 열에 결측이 있어도 유지됩니다. 이렇게 하면 실제로 차트에 진입한 주차만 분석할 수 있습니다.
billboard_tidy는 rank가 결측인 행이 제거된 결과입니다. 이는 곡이 실제로 차트에 진입한 주차만 포함하므로, ‘차트 진입 기간 분석’이나 ’순위 변동 추이’ 같은 분석에 적합합니다. 만약 결측값을 유지하려면 dropna()를 제거하고, 이후 분석에서 결측값을 적절히 처리하면 됩니다.
8.4 예제: 멕시코 사망 데이터 분석
Hadley Wickham의 원본 논문에서는 2008년 멕시코의 539,530건 사망 데이터를 분석합니다. 데이터는 다음을 포함합니다.
- 변수: 사망 시간(year, month, day, hour), 사망 원인(cod)
- 목표: 시간별 사망 패턴에서 이상한 질병 찾기
먼저, 실습을 진행하기전에 한글 폰트를 설정하고, 분석에 필요한 도구를 불러옵니다. statsmodels는 통계적 모델링을 위한 라이브러리로, 회귀 분석과 이상치 탐지에 사용됩니다. 회귀 분석을 위해 ols() 함수를 사용합니다.
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import platform
from scipy import stats
from statsmodels.formula.api import ols
from statsmodels.stats.outliers_influence import (
OLSInfluence,
)
if platform.system() == "Darwin":
plt.rcParams["font.family"] = "Apple SD Gothic Neo"
elif platform.system() == "Windows":
plt.rcParams["font.family"] = "Malgun Gothic"
else:
plt.rcParams["font.family"] = "NanumGothic"
plt.rcParams["axes.unicode_minus"] = False8.4.1 데이터 로드 및 전처리
멕시코 사망 데이터를 로드하고, 사망 원인 코드를 병합하여 분석 준비합니다. 원본 데이터에는 사망 원인이 코드(cod)로만 저장되어 있어, 코드 테이블과 병합해야 원인명을 알 수 있습니다. dropna()로 결측값을 제거하여 분석의 정확성을 보장합니다. merge(..., how='left')는 deaths의 모든 행을 유지하면서 codes의 정보를 추가하므로, 코드가 매칭되지 않는 경우에도 원본 데이터가 유지됩니다. rename()으로 열 이름을 더 직관적인 cause로 변경합니다.
deaths 데이터프레임에는 이제 cod(코드)와 cause(원인명)가 모두 포함되어 있어, 원인별 분석이 가능합니다. how='left'를 사용했으므로 deaths의 모든 행이 유지되며, 코드 테이블에 없는 코드는 cause가 NaN이 됩니다. 이후 분석에서는 cause 열을 사용하여 원인별 집계나 시각화를 수행할 수 있습니다.
8.4.2 시간별 사망 패턴 시각화
시간대(hod, hour of day)별 사망 수를 집계하고 시각화하여 전체적인 시간 패턴을 파악합니다. value_counts()는 각 시간대별 사망 건수를 세고, sort_index()로 시간 순서대로 정렬합니다. 이렇게 하면 하루 중 어느 시간대에 사망이 많이 발생하는지 패턴을 확인할 수 있습니다. lineplot()으로 시각화하면 시간에 따른 변화 추이를 직관적으로 파악할 수 있으며, 이상 패턴(특정 시간대의 급증)을 쉽게 발견할 수 있습니다.
그래프를 통해 하루 중 사망이 많이 발생하는 시간대를 확인할 수 있습니다. 일반적으로 새벽 시간대(2-5시)에 사망이 많고, 오후 시간대에는 상대적으로 적은 패턴을 보입니다. 이는 정상적인 생체리듬과 관련이 있을 수 있으며, 특정 시간대에 비정상적으로 높은 사망이 있다면 추가 조사가 필요합니다. 이 패턴을 기준으로 각 원인별 시간 패턴과 비교하여 이상치를 찾을 수 있습니다.
8.4.3 원인별 사망 수 집계
사망 원인별 총 사망 수를 집계하여 많은 사망을 일으키는 원인을 파악합니다. groupby("cod").size()는 각 원인 코드별로 행의 개수(사망 수)를 세고, reset_index(name="per_cause")로 결과를 데이터프레임으로 변환합니다. sort_values(..., ascending=False)로 사망 수가 많은 순서대로 정렬하여 주요 원인을 쉽게 확인할 수 있게 합니다. 이 정보는 이후 분석에서 통계적으로 유의미한 원인만 필터링하는 데 사용됩니다.
per_cause 데이터프레임은 각 원인별 총 사망 수를 보여줍니다. 사망 수가 적은 원인(10건 미만)은 통계적으로 신뢰할 수 없으므로, 이후 분석에서는 per_cause > 50 같은 조건으로 필터링하여 신뢰성 있는 원인만 분석합니다. 상위 10개 원인을 확인하면 전체 사망의 대부분을 차지하는 주요 원인을 파악할 수 있습니다.
8.4.4 시간별/원인별 비율 계산
각 원인별 시간 패턴(prop1)과 전체 시간 패턴(prop2)을 계산하여 비교 기준을 만듭니다. prop1은 각 원인별로 시간대별 사망 비율을 계산합니다. 예를 들어, ‘심장마비’ 원인의 경우 전체 심장마비 사망 중 몇 %가 새벽 3시에 발생했는지를 나타냅니다. transform("sum")을 사용하면 각 원인별 총합을 계산하면서도 원본 행 구조를 유지합니다. prop2는 전체 사망 중 각 시간대가 차지하는 비율을 계산하여, 각 원인의 시간 패턴과 비교할 기준선을 제공합니다.
prop1의 각 행은 (원인, 시간대) 조합의 비율을 나타내며, 같은 원인 내에서 시간대별 비율의 합은 1입니다. prop2는 전체 사망의 시간 분포를 나타내며, 이를 기준으로 각 원인의 시간 패턴이 정상적인지 이상한지 판단할 수 있습니다. 예를 들어, 전체적으로 새벽 3시에 5%의 사망이 발생하는데, 특정 원인은 새벽 3시에 20%가 발생한다면 이상 패턴으로 의심할 수 있습니다.
# prop1: 각 원인별 시간대별 비율
prop1 = (
deaths.groupby(["hod", "cause"])
.size()
.reset_index(name="per_hour_per_cause")
)
prop1["per_cause"] = prop1.groupby("cause")[
"per_hour_per_cause"
].transform("sum")
prop1["prop1"] = (
prop1["per_hour_per_cause"] / prop1["per_cause"]
)
# prop2: 전체 시간대별 비율
prop2 = (
deaths.groupby("hod")
.size()
.reset_index(name="per_hour")
)
prop2["total"] = prop2["per_hour"].sum()
prop2["prop2"] = prop2["per_hour"] / prop2["total"]8.4.5 시간 패턴 편차 계산
각 원인별 시간 패턴이 전체 시간 패턴과 얼마나 다른지를 수치화하여 이상 패턴을 찾습니다. prop1과 prop2를 merge하여 같은 시간대의 비율을 비교할 수 있게 합니다. ((prop1 - prop2)**2).mean()은 각 시간대별 차이의 제곱 평균을 계산하여, 원인의 시간 패턴이 전체 패턴과 얼마나 다른지를 측정합니다(평균 제곱 오차, MSE). per_cause > 50 조건으로 사망 수가 충분한 원인만 분석하여 통계적 신뢰성을 보장합니다. groupby(["cause", "n"])로 각 원인별로 편차를 계산하고, \(n\)(총 사망 수)도 함께 저장합니다.
dist 값이 클수록 해당 원인의 시간 패턴이 전체 패턴과 많이 다릅니다. 상위 10개 원인은 시간대별 사망 분포가 비정상적으로 편향되어 있을 가능성이 높습니다. 예를 들어, 특정 시간대에 집중적으로 발생하는 원인(교통사고가 출퇴근 시간에 집중)이나 특이한 시간 패턴을 보이는 원인을 발견할 수 있습니다. 이는 공중보건 정책이나 의학적 연구에 중요한 인사이트를 제공할 수 있습니다.
# 편차 계산: 각 원인별 시간 패턴이 전체 패턴과 얼마나 다른지
dist = (
prop1[prop1["per_cause"] > 50]
.merge(prop2[["hod", "prop2"]], on="hod", how="left")
.assign(
hour=lambda x: x["hod"], n=lambda x: x["per_cause"]
)
.groupby(["cause", "n"])
.apply(
lambda x: ((x["prop1"] - x["prop2"]) ** 2).mean(),
include_groups=False,
)
.reset_index(name="dist")
)
dist = dist.sort_values("dist", ascending=False)8.4.6 편차 시각화 및 회귀 분석
사망 수와 시간 편차의 관계를 시각화하고 회귀 분석하여 통계적 패턴을 파악합니다. sns.scatterplot()으로 사망 수(\(n\))와 시간 편차(dist)의 관계를 시각화하면, 일반적으로 사망 수가 적을수록 편차가 큰 경향이 있는지 확인할 수 있습니다. 로그 스케일을 사용하는 이유는 데이터의 범위가 넓고, 거듭제곱 관계를 선형으로 변환하기 위함입니다. 로그 회귀선을 그려서 전체적인 트렌드를 파악하고, 회귀선에서 크게 벗어나는 점들을 이상치로 식별할 수 있습니다. \(R^2\)와 p-value로 회귀 모델의 설명력과 유의성을 평가합니다.
일반적으로 사망 수가 적은 원인은 표본 크기가 작아 편차가 클 수 있습니다(소수 법칙). 회귀선은 이러한 일반적인 패턴을 나타내며, \(R^2\)가 높을수록 사망 수가 편차를 잘 설명합니다. 회귀선에서 크게 벗어나는 점들(특히 위쪽으로 벗어난 점들)은 사망 수에 비해 편차가 비정상적으로 큰 원인으로, 추가 조사가 필요한 이상 패턴을 나타냅니다. 상위 10개 원인에 라벨을 달아 어떤 원인이 이상한 패턴을 보이는지 명확히 확인할 수 있습니다.
# 편차 시각화
plt.figure(figsize=(12, 8))
sns.scatterplot(data=dist, x="n", y="dist", alpha=0.6)
# 상위 10개 원인 라벨링
top_10 = dist.nlargest(10, "dist")
for _, row in top_10.iterrows():
plt.annotate(
row["cause"],
(row["n"], row["dist"]),
fontsize=8,
alpha=0.7,
)
plt.xscale("log")
plt.yscale("log")
log_n = np.log(dist["n"])
log_dist = np.log(dist["dist"])
slope, intercept, r_value, p_value, std_err = (
stats.linregress(log_n, log_dist)
)
x_line = np.logspace(
np.log10(dist["n"].min()),
np.log10(dist["n"].max()),
100,
)
y_line = np.exp(intercept) * x_line**slope
plt.plot(
x_line,
y_line,
"r--",
alpha=0.5,
label=f"Regression (R-squared={r_value**2:.3f})",
)
plt.xlabel("사망 수")
plt.ylabel("시간 편차")
plt.title("멕시코 사망 원인별 시간 편차")
plt.legend()
plt.tight_layout()
plt.show()
8.4.7 이상치 탐지
회귀 모델의 잔차를 분석하여 통계적으로 유의미한 이상치를 객관적으로 식별합니다. 회귀선에서 예측값과 실제값의 차이(잔차)를 계산하면, 사망 수를 고려했을 때 편차가 비정상적으로 큰 원인을 찾을 수 있습니다. 표준화 잔차는 잔차를 표준편차로 나눈 값으로, 절대값이 1.5 이상이면 통계적으로 유의미한 이상치로 간주할 수 있습니다. 잔차 플롯을 시각화하면 회귀 모델의 가정(선형성, 등분산성)이 만족되는지도 확인할 수 있습니다.
표준화 잔차가 1.5보다 큰 원인들은 사망 수를 고려했을 때 예상보다 훨씬 큰 시간 편차를 보입니다. 이는 단순히 사망 수가 적어서 편차가 큰 것이 아니라, 실제로 시간 패턴이 비정상적임을 의미합니다. 예를 들어, 특정 시간대에 집중적으로 발생하는 질병이나 사고, 또는 데이터 오류 가능성이 있는 원인을 발견할 수 있습니다. unusual 데이터프레임에는 이러한 이상 패턴을 보이는 원인들이 저장되어 있으며, 다음 단계에서 이들의 구체적인 시간 패턴을 시각화하여 분석합니다.
# 회귀 잔차 계산
dist["log_n"] = np.log(dist["n"])
dist["log_dist"] = np.log(dist["dist"])
dist["predicted"] = intercept + slope * dist["log_n"]
dist["residual"] = dist["log_dist"] - dist["predicted"]
dist["std_residual"] = (
dist["residual"] / dist["residual"].std()
)
# 표준화 잔차 시각화
plt.figure(figsize=(7, 5))
sns.scatterplot(
data=dist, x="log_n", y="std_residual", alpha=0.6
)
plt.axhline(y=0, color="red", linestyle="--")
plt.axhline(
y=1.5, color="orange", linestyle="--", alpha=0.5
)
plt.axhline(
y=-1.5, color="orange", linestyle="--", alpha=0.5
)
plt.xlabel("log(Total deaths)")
plt.ylabel("Standardized residuals")
plt.title("Residual plot")
plt.tight_layout()
plt.show()
# 이상치 추출 (표준화 잔차 > 1.5)
unusual = dist[dist["std_residual"] > 1.5][
["cause", "n"]
].copy()
8.4.8 이상치 원인의 시간 패턴 시각화
이상 패턴을 보이는 원인들의 구체적인 시간별 분포를 시각화하여 왜 편차가 큰지 이해합니다. 각 이상 원인의 시간별 비율(prop1)과 전체 시간 패턴(prop2)을 같은 그래프에 그려서 비교하면, 어떤 시간대에 비정상적으로 집중되는지 명확히 볼 수 있습니다. subplot을 사용하여 여러 원인을 한 번에 비교할 수 있게 하고, 각 원인별로 원인별 비율과 전체 비율을 다른 색상으로 표시하여 차이를 강조합니다. melt()를 사용하여 prop1과 prop2를 하나의 "type" 열로 통합하면, 반복문으로 쉽게 두 선을 그릴 수 있습니다.
각 subplot에서 노란색 선(원인별 비율)과 파란색 선(전체 비율)의 차이를 보면, 해당 원인이 어떤 시간대에 비정상적으로 집중되는지 확인할 수 있습니다. 예를 들어, 특정 원인이 새벽 시간대에 급격히 증가한다면, 이는 생체리듬과 관련된 질병이거나 특정 약물의 부작용일 수 있습니다. 또는 특정 시간대에만 발생하는 사고(출퇴근 시간 교통사고)를 발견할 수 있습니다. 이러한 시각화는 공중보건 정책 수립이나 의학적 연구에 중요한 인사이트를 제공합니다.
# 이상치 원인들의 시간별 패턴 비교
unusual_causes = unusual["cause"].tolist()
plot_data = (
prop1[prop1["cause"].isin(unusual_causes)]
.merge(prop2[["hod", "prop2"]], on="hod", how="left")
.melt(
id_vars=["hod", "cause"],
value_vars=["prop1", "prop2"],
var_name="type",
value_name="value",
)
)
# 시각화
n_causes = len(unusual_causes)
n_cols = 3
n_rows = (n_causes + n_cols - 1) // n_cols
fig, axes = plt.subplots(
n_rows, n_cols, figsize=(12, 8 * n_rows)
)
axes = axes.flatten() if n_causes > 1 else [axes]
for idx, cause in enumerate(unusual_causes):
ax = axes[idx]
cause_data = plot_data[plot_data["cause"] == cause]
for type_name in ["prop1", "prop2"]:
type_data = cause_data[
cause_data["type"] == type_name
]
label = (
"원인별 비율"
if type_name == "prop1"
else "전체 비율"
)
color = (
"#FFBF0F" if type_name == "prop1" else "#0382E5"
)
ax.plot(
type_data["hod"],
type_data["value"],
marker="o",
label=label,
color=color,
alpha=0.7,
)
ax.set_title(cause, wrap=True)
ax.set_xlabel("시간대")
ax.set_ylabel("비율")
ax.legend()
ax.grid(True, alpha=0.3)
# 빈 subplot 제거
for idx in range(len(unusual_causes), len(axes)):
fig.delaxes(axes[idx])
plt.suptitle(
"편차가 큰 사망 원인\n원인별 시간 패턴과 전체 패턴 비교"
)
plt.tight_layout()
plt.show()
Tidy Data는 “정답”이 아니라 분석 목표에 맞춘 선택입니다. 같은 데이터를 다양한 형태로 변환하며 최적의 분석 형태를 찾아가는 과정이 데이터 과학입니다.