pandas를 사용해서 간단한 데이터를 분석하고, 몇가지 기능을 연습했습니다. 그리고 연습은 충분합니다. 이제 야생의 데이터(Wild Data)에 대해서 고민해볼 시간입니다. 현업에서 마주하는 데이터는 앞선 예제나 실습에서 처럼 깔끔하지 않습니다. Hadley Wickham은 흔하게 발견되는 지저분한 데이터의 유형을 5가지로 정리했습니다[1]. 해당 실습은 pandas의 강력한 도구들(melt, pivot_table, extract 등)을 사용하여, 이 5가지 유형의 지저분한 데이터를 Tidy Data로 변환하는 간단한 연습을 해보도록 하겠습니다.
해당 실습에서 사용하게 될 몇가지 주요 내용 중 groupby, melt, pivot_table, query 등은 이후 장에서 상세하게 다룹니다. 해당 실습을 통해서 pandas 사용법이 친숙해질 수 있기를 희망합니다.
9.1 동물 관측 데이터
이번 실습에서는 실제 지저분한 데이터셋을 단계별로 정제하고 분석하는 과정을 다룹니다. animal_data_dirty1.csv 파일은 여러 데이터 품질 문제를 포함하고 있어, 이를 해결하는 과정을 통해 실전 데이터 정제 기술을 익힙니다.
9.1.1 데이터 로드 및 초기 탐색
데이터 분석을 시작하기 전에 필요한 라이브러리를 불러오고, 한글 폰트를 설정한 후 데이터를 로드합니다. sep=';' 매개변수는 CSV 파일이 세미콜론으로 구분되어 있을 때 사용하며, 기본 콤마 구분자가 아닌 경우를 처리하기 위함입니다. head() 메서드는 데이터의 처음 5개 행을 확인하여 데이터 구조와 내용을 빠르게 파악할 수 있게 해줍니다.
import pandas as pdimport numpy as npimport matplotlib.pyplot as pltimport seaborn as snsfrom datetime import datetimefrom platform import systemif system() =="Windows": plt.rcParams["font.family"] ="Malgun Gothic"else: plt.rcParams["font.family"] ="AppleGothic"plt.rcParams["axes.unicode_minus"] =Falsedf = pd.read_csv("../data/animal_data_dirty1.csv", sep=";", dtype_backend="pyarrow", engine="pyarrow",)df.head(2)
Animal type
Country
...
Observation date
Data compiled by
0
<NA>
<NA>
...
03.01.2024
James Johnson
1
<NA>
<NA>
...
03.02.2024
James Johnson
2 rows × 11 columns
9.1.2 빈 행 제거 및 기본 정제
데이터 정제의 첫 단계로 구조적 문제를 해결합니다. dropna(how='all')은 모든 열이 결측인 행만 제거하며, 일부 열만 결측인 행은 유지합니다. drop_duplicates()는 완전히 동일한 행을 제거하여 데이터 중복을 방지합니다. str.strip()은 컬럼명 앞뒤의 공백을 제거하여 이후 인덱싱 시 오류를 방지합니다. 이러한 기본 정제 작업은 데이터 품질을 향상시키고 이후 분석의 정확성을 보장합니다.
데이터 타입을 올바르게 변환하는 것은 분석의 정확성을 위해 필수적입니다. pd.to_datetime()은 문자열 날짜를 datetime 객체로 변환하며, format='%d.%m.%Y'로 날짜 형식을 명시하여 파싱 오류를 방지합니다. errors='coerce'는 변환 불가능한 값을 NaN으로 처리하여 데이터 손실 없이 진행할 수 있게 합니다. 숫자형 변환도 동일하게 errors='coerce'를 사용하여 문자열이나 잘못된 값이 있어도 안전하게 처리합니다. 올바른 데이터 타입으로 변환하면 이후 통계 계산과 시각화가 정확하게 수행됩니다.
df["Observation date"] = pd.to_datetime( df["Observation date"],format="%d.%m.%Y", errors="coerce",)numeric_cols = ["Weight kg","Body Length cm","Latitude","Longitude",]for col in numeric_cols: df[col] = pd.to_numeric(df[col], errors="coerce")
9.1.4 동물 이름 표준화 (오타 수정)
실제 데이터에서는 동일한 개체가 다양한 오타나 표기로 입력되는 경우가 많습니다. replace() 메서드는 딕셔너리를 사용하여 오타를 표준화된 이름으로 일괄 변환합니다. 이 과정을 통해 동일한 동물이 서로 다른 이름으로 분류되는 문제를 해결하고, 이후 그룹별 집계나 분석의 정확성을 보장합니다. 딕셔너리의 키는 원본 데이터에 있는 오타 형태이고, 값은 표준화할 이름입니다.
성별 데이터의 값 분포를 확인한 후 표준화합니다. value_counts()는 각 값의 빈도를 확인하여 데이터 품질을 점검하는 데 사용됩니다. ’not determined’와 같은 다양한 표현을 ’unknown’으로 통일하여 범주형 변수의 일관성을 유지합니다. 이는 이후 그룹별 분석에서 카테고리가 명확하게 구분되도록 보장합니다.
Gender
male 423
female 401
not determined 5
Name: count, dtype: int64[pyarrow]
Gender
male 423
female 401
unknown 5
Name: count, dtype: int64[pyarrow]
9.1.7 이상치 탐지 및 처리
이상치는 데이터 품질에 큰 영향을 미치므로 반드시 탐지하고 처리해야 합니다. 불리언 인덱싱을 사용하여 조건을 만족하는 행의 개수를 확인하고, .loc을 사용하여 이상치를 NaN으로 변환합니다. 음수 값이나 물리적으로 불가능한 값(체중 5000kg 이상, 몸길이 1000cm 이상 등)은 명백한 오류이므로 제거합니다. 위도/경도는 지구 좌표계의 유효 범위(-90~90, -180~180)를 벗어나면 잘못된 데이터이므로 NaN으로 처리합니다. 이상치를 제거하지 않고 NaN으로 변환하는 이유는 다른 유효한 정보는 보존하면서 통계 계산에서만 제외하기 위함입니다.
데이터 정제 후 최종 품질을 점검합니다. 결측값 비율은 각 열의 데이터 완전성을 나타내며, 높은 비율의 결측값이 있는 열은 분석 시 주의가 필요합니다. value_counts()로 범주형 변수의 분포를 확인하여 데이터 불균형이나 이상한 카테고리를 발견할 수 있습니다. 날짜 범위는 데이터의 시간적 범위를 확인하여 분석 기간을 파악하는 데 사용됩니다. 이러한 요약 정보는 데이터의 전반적인 품질을 평가하고, 이후 분석 전략을 수립하는 데 중요한 기준이 됩니다.
# 최종 데이터 품질 리포트print(f"\n결측값 비율:")missing_pct = (df.isnull().sum() /len(df) *100).round(2)for col, pct in missing_pct.items():if pct >0:print(f" {col}: {pct}%")print(f"\n동물 종류별 관측 수:")print(df["Animal type"].value_counts())print(f"\n국가별 관측 수:")print(df["Country"].value_counts())print(f"\n날짜 범위:")print(f" 시작: {df['Observation date'].min()}")print(f" 종료: {df['Observation date'].max()}")
결측값 비율:
Animal type: 2.25%
Country: 1.3%
Weight kg: 2.73%
Body Length cm: 2.84%
Gender: 1.78%
Animal code: 100.0%
Latitude: 10.9%
Longitude: 10.9%
Animal name: 95.5%
Observation date: 4.15%
동물 종류별 관측 수:
Animal type
red squirrel 413
hedgehog 275
lynx 71
European bison 63
ledgehod 3
Name: count, dtype: int64[pyarrow]
국가별 관측 수:
Country
Poland 192
Germany 173
Slovakia 151
...
DE 5
Australia 4
CC 4
Name: count, Length: 12, dtype: int64[pyarrow]
날짜 범위:
시작: 2024-01-03 00:00:00
종료: 2024-11-03 00:00:00
9.1.9 기본 통계 분석
그룹별 집계를 통해 각 동물 종류의 특성을 파악합니다. groupby()와 agg()를 함께 사용하여 여러 통계량을 한 번에 계산할 수 있습니다. count는 각 그룹의 관측 수를, mean과 std는 중심 경향과 산포도를, min과 max는 범위를 나타냅니다. 이러한 통계량은 각 동물 종류의 생물학적 특성을 이해하고, 이상치나 패턴을 발견하는 데 도움이 됩니다.
# 동물 종류별 기본 통계print("동물 종류별 체중 통계 (kg):")weight_stats = df.groupby("Animal type")["Weight kg"].agg( ["count", "mean", "std", "min", "max"])print(weight_stats.round(2))print("\n동물 종류별 몸길이 통계 (cm):")length_stats = df.groupby("Animal type")["Body Length cm"].agg(["count", "mean", "std", "min", "max"])print(length_stats.round(2))
동물 종류별 체중 통계 (kg):
count mean std min max
Animal type
European bison 63 588.4 244.03 24.0 1100.0
hedgehog 272 0.8 0.23 0.4 1.2
ledgehod 3 0.87 0.29 0.7 1.2
lynx 71 23.27 18.4 11.0 171.0
red squirrel 404 0.3 0.03 0.2 0.36
동물 종류별 몸길이 통계 (cm):
count mean std min max
Animal type
European bison 63 244.24 62.37 100 350
hedgehog 270 20.21 8.8 11 151
ledgehod 3 22.0 3.46 20 26
lynx 71 82.9 15.23 52 131
red squirrel 405 20.52 1.83 14 24
9.1.10 시각화
시각화는 데이터의 패턴과 특성을 직관적으로 이해하는 데 필수적입니다. 박스플롯은 그룹별 분포를 비교하여 중앙값, 사분위수, 이상치를 한눈에 확인할 수 있게 해줍니다. 막대 그래프는 범주형 변수의 빈도를 비교하는 데 적합하며, 시계열 그래프는 시간에 따른 변화 추이를 보여줍니다. 지리적 분포는 산점도를 사용하여 위도/경도 좌표를 시각화하고, 색상으로 동물 종류를 구분합니다. dropna()를 사용하여 시각화에 필요한 열만 결측값이 없는 데이터를 선택하고, tight_layout()으로 레이블이 잘리지 않도록 조정합니다.
9.1.10.1 동물 종류별 체중 분포
동물 종류에 따라 체중 분포가 어떻게 다른지 비교하기 위해 박스플롯을 사용합니다. 박스플롯은 수치형 변수의 분포를 그룹별로 비교할 때 매우 유용한 시각화 방법입니다. 각 박스는 중앙값(박스 내부 선), 제1사분위수와 제3사분위수(박스의 하단과 상단), 그리고 이상치(박스 밖의 점)를 한눈에 보여줍니다.
코드에서는 먼저 dropna(subset=['Weight kg', 'Animal type'])를 사용하여 체중과 동물 종류 정보가 모두 있는 행만 선택합니다. 이는 결측값이 있는 데이터를 시각화하면 오해의 소지가 있기 때문입니다. sns.boxplot() 함수에 x='Animal type'과 y='Weight kg'을 지정하여 x축에는 동물 종류를, y축에는 체중을 배치합니다. plt.xticks(rotation=45)는 x축 레이블이 겹치지 않도록 45도 회전시킵니다. plt.tight_layout()은 그래프의 레이블이나 제목이 잘리지 않도록 여백을 자동으로 조정합니다.
그래프 결과를 해석하면, 각 동물 종류별로 체중 분포의 차이를 명확히 확인할 수 있습니다. 박스의 높이가 클수록 해당 동물 종류의 체중 변동성이 크다는 의미이고, 박스의 위치가 높을수록 평균적으로 체중이 크다는 것을 나타냅니다. 또한 박스 밖에 표시된 점들은 이상치로, 해당 동물 종류의 일반적인 체중 범위를 벗어나는 개체들을 의미합니다. 이를 통해 어떤 동물 종류가 체중 측면에서 더 다양성을 보이는지, 또는 특정 종류가 다른 종류보다 체중이 큰 경향이 있는지 등을 파악할 수 있습니다.
데이터가 수집된 국가별로 얼마나 많은 동물 관측이 이루어졌는지 비교하기 위해 막대 그래프를 사용합니다. 막대 그래프는 범주형 변수의 빈도나 개수를 비교할 때 직관적인 시각화 방법입니다. 각 국가별로 관측된 동물의 수를 막대의 높이로 표현하여 어느 국가에서 더 많은 데이터가 수집되었는지 쉽게 파악할 수 있습니다.
코드에서는 plt.figure(figsize=(7, 5))로 그래프의 크기를 가로 10인치, 세로 6인치로 설정합니다. df['Country'].value_counts()는 국가 열의 각 값별로 빈도를 계산하여 시리즈를 반환합니다. 이 시리즈의 인덱스는 국가명이고, 값은 해당 국가의 관측 수입니다. sns.barplot()에 x=country_counts.index와 y=country_counts.values를 전달하여 x축에는 국가명을, y축에는 관측 수를 표시합니다. plt.xlabel()과 plt.ylabel()로 각 축의 레이블을 명확히 지정하고, plt.xticks(rotation=45)로 국가명이 길 경우 겹치지 않도록 회전시킵니다.
그래프 결과를 해석하면, 각 국가별로 동물 관측 데이터의 양을 비교할 수 있습니다. 막대가 높을수록 해당 국가에서 더 많은 관측이 이루어졌다는 의미입니다. 이를 통해 데이터의 지역적 편향성을 확인할 수 있으며, 특정 국가에 데이터가 집중되어 있는지, 아니면 여러 국가에 고르게 분포되어 있는지 등을 파악할 수 있습니다. 만약 특정 국가의 막대가 현저히 높다면, 해당 국가의 데이터가 전체 분석 결과에 더 큰 영향을 미칠 수 있음을 의미합니다.
# 2. 국가별 관측 수plt.figure(figsize=(7, 5))country_counts = df["Country"].value_counts()sns.barplot(x=country_counts.index, y=country_counts.values)plt.title("국가별 동물 관측 수")plt.xlabel("국가")plt.ylabel("관측 수")plt.xticks(rotation=45)plt.tight_layout()plt.show()
그림 9.2: 국가별 동물 관측 수.
9.1.10.3 시간별 관측 추이
시간에 따라 동물 관측이 어떻게 변화했는지 추이를 파악하기 위해 시계열 그래프를 사용합니다. 시계열 그래프는 시간 변수를 x축에, 관측값을 y축에 배치하여 시간에 따른 변화 패턴을 선으로 표현합니다. 이를 통해 계절성, 추세, 특정 시기의 급격한 변화 등을 확인할 수 있습니다.
코드에서는 plt.figure(figsize=(7, 5))로 그래프를 가로 12인치, 세로 6인치로 설정하여 시간 축에 충분한 공간을 확보합니다. df.groupby('Observation date').size()는 관측 날짜별로 그룹화하여 각 날짜에 몇 개의 관측이 있었는지 계산합니다. 결과는 날짜를 인덱스로 하고 관측 수를 값으로 하는 시리즈입니다. plt.plot()에 날짜 인덱스와 관측 수 값을 전달하여 선 그래프를 그립니다. marker='o' 옵션은 각 데이터 포인트에 원형 마커를 표시하여 개별 관측 날짜를 명확히 보여줍니다. plt.xticks(rotation=45)는 날짜 레이블이 겹치지 않도록 회전시킵니다.
그래프 결과를 해석하면, 시간에 따른 동물 관측의 변화 패턴을 확인할 수 있습니다. 선이 상승하는 구간은 관측이 증가하는 시기를, 하강하는 구간은 관측이 감소하는 시기를 나타냅니다. 만약 특정 시기에 급격한 증가나 감소가 있다면, 그 시기에 특별한 이벤트나 계절적 요인이 작용했을 가능성이 있습니다. 또한 선이 일정하게 유지되는 구간은 관측이 안정적인 시기를 의미합니다. 이러한 패턴을 통해 데이터 수집의 일관성, 계절적 변동성, 장기적인 추세 등을 종합적으로 분석할 수 있습니다.
# 3. 시간별 관측 추이plt.figure(figsize=(7, 5))daily_counts = df.groupby("Observation date").size()plt.plot( daily_counts.index, daily_counts.values, marker="o")plt.title("일별 동물 관측 수 추이")plt.xlabel("날짜")plt.ylabel("관측 수")plt.xticks(rotation=45)plt.tight_layout()plt.show()
그림 9.3: 일별 동물 관측 수 추이.
9.1.10.4 지리적 분포
동물 관측이 지리적으로 어떻게 분포되어 있는지 확인하기 위해 산점도를 사용합니다. 산점도는 위도와 경도 좌표를 사용하여 각 관측 지점을 지도상에 표시하고, 색상으로 동물 종류를 구분합니다. 이를 통해 특정 동물 종류가 특정 지역에 집중되어 있는지, 또는 지리적으로 고르게 분포되어 있는지 등을 파악할 수 있습니다.
코드에서는 먼저 if df[['Latitude', 'Longitude']].notna().all(axis=1).sum() > 0: 조건문으로 위도와 경도 정보가 모두 있는 데이터가 하나라도 존재하는지 확인합니다. notna().all(axis=1)은 각 행에서 위도와 경도가 모두 결측값이 아닌지 확인하고, sum()은 그런 행의 개수를 계산합니다. 조건이 참일 때만 그래프를 그립니다. df_geo = df.dropna(subset=['Latitude', 'Longitude'])로 위도와 경도가 모두 있는 데이터만 선택합니다. df_geo['Animal type'].astype('category')는 동물 종류를 범주형 데이터로 변환하여 각 종류에 고유한 숫자 코드를 부여합니다. plt.scatter()에서 x=df_geo['Longitude'], y=df_geo['Latitude']로 경도를 x축, 위도를 y축에 배치합니다. c=cat_series.cat.codes는 각 동물 종류의 숫자 코드를 색상으로 매핑하고, cmap='tab10'은 최대 10가지 색상을 사용하는 색상 맵을 지정합니다. alpha=0.6은 투명도를 설정하여 겹치는 점들이 보이도록 하고, s=50은 점의 크기를 설정합니다. 범례를 생성하기 위해 scatter.legend_elements()를 사용하여 색상별 범례 요소를 가져오고, cat_series.cat.categories.tolist()로 실제 동물 종류 이름을 레이블로 사용합니다.
그래프 결과를 해석하면, 각 동물 종류가 지리적으로 어떻게 분포되어 있는지 확인할 수 있습니다. 같은 색상의 점들이 특정 지역에 집중되어 있다면, 해당 동물 종류가 그 지역에 서식하거나 그 지역에서 주로 관측되었다는 의미입니다. 반대로 다양한 색상의 점들이 전 지역에 고르게 분포되어 있다면, 여러 동물 종류가 다양한 지역에서 관측되었다는 것을 의미합니다. 또한 점의 밀도가 높은 지역은 관측이 활발한 지역으로 해석할 수 있으며, 점이 거의 없는 지역은 관측이 부족한 지역으로 볼 수 있습니다. 이러한 지리적 분포 정보는 동물의 서식지 특성, 관측 활동의 지역적 편향성 등을 분석하는 데 중요한 통찰을 제공합니다.
# 4. 지리적 분포 (위도/경도가 있는 경우)if ( df[["Latitude", "Longitude"]].notna().all(axis=1).sum()>0): plt.figure(figsize=(12, 8)) df_geo = df.dropna(subset=["Latitude", "Longitude"]) cat_series = df_geo["Animal type"].astype("category") scatter = plt.scatter( df_geo["Longitude"], df_geo["Latitude"], c=cat_series.cat.codes, alpha=0.6, s=50, cmap="tab10", ) plt.xlabel("경도") plt.ylabel("위도") plt.title("동물 관측 지리적 분포")# 범례 생성 handles, labels = scatter.legend_elements( prop="colors", alpha=0.6 )# 레이블 변경 plt.legend( handles, cat_series.cat.categories.tolist(), title="동물 종류", loc="best", ) plt.tight_layout() plt.show()
그림 9.4: 동물 관측의 지리적 분포.
9.1.11 최종 정제된 데이터 저장
정제 작업이 완료된 데이터는 별도 파일로 저장하여 이후 분석에 재사용할 수 있도록 합니다. copy()를 사용하여 원본 데이터를 보존하고, index=False로 인덱스를 파일에 저장하지 않아 불필요한 열 생성을 방지합니다. 저장된 데이터는 이후 분석이나 다른 프로젝트에서 바로 사용할 수 있는 깨끗한 형태입니다.
# 정제된 데이터를 새 파일로 저장df_clean = df.copy()df_clean.to_csv("../data/animal_data_clean.csv", index=False)print(f"최종 데이터 크기: {df_clean.shape}")
최종 데이터 크기: (844, 11)
9.2 통계청 “출생아수 및 사망자수”
이 섹션에서는 통계청의 “출생아수 및 사망자수” 데이터를 분석합니다. 이 데이터는 전형적인 지저분한 데이터 구조를 가지고 있습니다:
문제점 1: 열 헤더가 변수명이 아닌 값(날짜)입니다
문제점 2: 변수(날짜, 성별)가 행과 열 모두에 저장되어 있습니다
9.2.1 데이터 로드 및 구조 파악
통계청 데이터는 멀티레벨 헤더 구조를 가지고 있어 먼저 데이터 구조를 파악해야 합니다. columns 속성으로 열 이름을 확인하고, shape로 데이터 크기를 파악하며, head()로 실제 데이터 형태를 확인합니다. 이러한 탐색 작업은 이후 변환 작업의 방향을 결정하는 데 중요한 정보를 제공합니다.
import pandas as pdimport numpy as npimport matplotlib.pyplot as pltimport seaborn as snsfrom datetime import datetime# 한글 폰트는 이 장 첫 셀에서 OS별로 이미 설정했으므로 다시 지정하지 않습니다.# 데이터 로드 (첫 번째 행은 날짜 헤더, 두 번째 행이 실제 컬럼명)df_raw = pd.read_csv("../data/kosis-202605.csv", dtype_backend="pyarrow")print("\n컬럼 구조:")print(df_raw.columns[:10])print(f"\n데이터 크기: {df_raw.shape}")print("원본 데이터 구조:")print(df_raw.head(2))
컬럼 구조:
Index(['시군구별', '1997.01', '1997.01.1', '1997.01.2', '1997.02', '1997.02.1',
'1997.02.2', '1997.03', '1997.03.1', '1997.03.2'],
dtype='str')
데이터 크기: (19, 1009)
원본 데이터 구조:
시군구별 1997.01 ... 2024.12.1 2024.12.2
0 시군구별 계 (명) ... 남자 (명) 여자 (명)
1 전국 63268 ... 9414 8842
[2 rows x 1009 columns]
9.2.2 Melt를 사용한 Long 형식 변환
원본 데이터에서 날짜와 성별 정보가 열 이름에 저장되어 있어 Tidy Data 원칙을 위반합니다. melt()를 사용하여 이러한 열들을 행으로 변환합니다. id_vars=['시군구별']은 그대로 유지할 식별자 열을 지정하며, 나머지 모든 열(날짜와 성별이 결합된 열들)이 variable과 value 열로 변환됩니다. 이 변환을 통해 각 행이 하나의 관측치(시군구-날짜-성별 조합)를 나타내는 Long 형식이 됩니다.
# Melt를 사용하여 날짜와 성별 정보를 행으로 변환df_melted = df_raw.melt( id_vars=["시군구별"],)print("Melt 후 데이터 구조:")print(df_melted.head(20))print(f"\n총 행 수: {len(df_melted)}")
Melt 후 데이터 구조:
시군구별 variable value
0 시군구별 1997.01 계 (명)
1 전국 1997.01 63268
2 서울특별시 1997.01 13205
.. ... ... ...
17 경상남도 1997.01 4020
18 제주특별자치도 1997.01 734
19 시군구별 1997.01.1 남자 (명)
[20 rows x 3 columns]
총 행 수: 19152
9.2.3 날짜와 성별 정보 분리
variable 열에는 “연도.월.성별” 형식의 정보가 압축되어 있습니다. str.split()을 사용하여 점(.)을 구분자로 하여 연도, 월, 성별을 분리합니다. expand=True는 분리된 결과를 여러 열로 반환하며, 인덱스로 각 부분을 선택합니다. 성별 코드(“1”, “2”)는 의미 있는 이름(“남자”, “여자”)으로 변환하고, 결측값은 “전체”로 채웁니다. 마지막으로 열 이름을 더 직관적인 이름으로 변경하여 데이터의 가독성을 높입니다.
시군구별 기간 ... 월 성별
1 전국 1997.01 ... 01 전체
2 서울특별시 1997.01 ... 01 전체
[2 rows x 6 columns]
9.2.4 숫자형 변환
출생아수 데이터에 “-” 같은 문자열이 포함되어 있을 수 있습니다. 이를 NaN으로 변환한 후 astype("float64[pyarrow]")로 숫자형으로 변환합니다. describe()로 변환된 데이터의 기본 통계를 확인하여 변환이 올바르게 수행되었는지 검증합니다.
import numpy as npdf_melted["출생아수"] = df_melted["출생아수"].replace("-", np.nan)df_melted["출생아수"] = df_melted["출생아수"].astype("float64[pyarrow]")df_melted["출생아수"].describe()
count 17604.0
mean 2791.377187
std 6237.809267
...
50% 941.0
75% 1776.25
max 63268.0
Name: 출생아수, Length: 8, dtype: double[pyarrow]
9.2.5 Pivot을 사용한 Wide 형식 변환
분석을 용이하게 하기 위해 성별을 다시 열로 변환합니다. pivot_table()은 index로 지정한 열들을 행으로 유지하고, columns로 지정한 열(성별)의 값들을 새로운 열로 만듭니다. values는 셀에 들어갈 값을 지정합니다. reset_index()는 인덱스를 일반 열로 변환하여 이후 분석이 편리하도록 합니다. 이 변환을 통해 각 행이 시군구-연도-월 조합을 나타내고, 성별별 출생아수가 별도 열로 분리된 형태가 됩니다.
# Pivot을 사용하여 성별을 열로 변환 (중복이 없으므로 pivot 사용)df_pivoted = df_melted.pivot_table( index=["시군구별", "연도", "월"], columns="성별", values="출생아수",).reset_index()print(df_pivoted.head(2))
성별 시군구별 연도 ... 여자 전체
0 강원특별자치도 1997 ... 877.0 1794.0
1 강원특별자치도 1997 ... 804.0 1648.0
[2 rows x 6 columns]
9.2.6 데이터 품질 검증
변환된 데이터의 품질을 점검합니다. isnull().sum()으로 각 열의 결측값 개수를 확인하고, describe()로 숫자형 열의 기본 통계를 확인합니다. value_counts()로 시군구별 데이터 개수를 확인하여 데이터 불균형이나 누락을 발견할 수 있습니다. 이러한 검증은 데이터 변환이 올바르게 수행되었는지 확인하고, 이후 분석의 신뢰성을 보장하는 중요한 단계입니다.
# 결측값 확인print("결측값 개수:")print(df_pivoted.isnull().sum())# 기본 통계print("\n기본 통계:")print(df_pivoted.describe())# 시군구별 데이터 개수print("\n시군구별 데이터 개수:")print(df_pivoted["시군구별"].value_counts().head(10))
결측값 개수:
성별
시군구별 0
연도 0
월 0
남자 0
여자 0
전체 0
dtype: int64
기본 통계:
성별 남자 여자 전체
count 5868.0 5868.0 5868.0
mean 2165.501363 2021.564417 4187.065781
std 4511.661185 4197.471259 8708.468702
... ... ... ...
50% 761.0 709.0 1470.5
75% 1279.0 1177.0 2449.0
max 33266.0 30002.0 63268.0
[8 rows x 3 columns]
시군구별 데이터 개수:
시군구별
강원특별자치도 336
경기도 336
경상남도 336
...
부산광역시 336
서울특별시 336
울산광역시 336
Name: count, Length: 10, dtype: int64[pyarrow]
9.2.7 시계열 분석
전국 데이터를 추출하여 시간에 따른 출생아수 추이를 분석합니다. 여기서 주의할 점이 하나 있습니다. 이 데이터는 월 단위이므로 한 연도에 12개의 값이 들어 있습니다. x축에 연도를 그대로 쓰면 같은 연도의 12개 점이 수직으로 겹쳐 톱니 모양의 그래프가 나옵니다. 따라서 연도와 월을 이어 붙인 뒤 pd.to_datetime()으로 변환하여 진짜 시간축을 만듭니다. 시간축을 datetime으로 만들어 두면 정렬이 정확해지고, 이후 시계열 장에서 다룰 resample()이나 rolling() 같은 연산으로도 바로 이어집니다.
subplot()을 사용하여 두 개의 그래프를 세로로 배치하고, 첫 번째 그래프는 선 그래프로 전체, 남자, 여자 출생아수 추이를 비교합니다. 두 번째 그래프는 막대 그래프로 연도별 총 출생아수를 보여줍니다. 연도별 합계는 월별 값을 groupby('연도')로 합산하여 계산합니다. 이러한 시각화는 장기적인 추세와 성별 차이를 한눈에 파악할 수 있게 해줍니다.
# 전국 데이터 추출df_national = df_pivoted[ df_pivoted["시군구별"] =="전국"].copy()# 연도와 월을 합쳐 시간축을 만든다 (연도만 쓰면 한 해에 12개 값이 겹친다)df_national["연월"] = pd.to_datetime( df_national["연도"] +"-"+ df_national["월"],format="%Y-%m",)df_national = df_national.sort_values("연월")print("전국 데이터:")print(df_national.head(10))print(f"\n사용 가능한 컬럼: {df_national.columns.tolist()}")# 시계열 시각화plt.figure(figsize=(15, 8))# 전체 출생아수 추이plt.subplot(2, 1, 1)plt.plot( df_national["연월"], df_national["전체"], label="전체", linewidth=2,)plt.plot( df_national["연월"], df_national["남자"], label="남자", linewidth=1.5, alpha=0.7,)plt.plot( df_national["연월"], df_national["여자"], label="여자", linewidth=1.5, alpha=0.7,)plt.title("전국 출생아수 추이 (1997-2024)")plt.xlabel("연월")plt.ylabel("출생아수 (명)")plt.legend()plt.grid(True, alpha=0.3)# 연도별 합계plt.subplot(2, 1, 2)yearly = df_national.groupby("연도")["전체"].sum()plt.bar( yearly.index, yearly.values, alpha=0.7, color="steelblue",)plt.title("연도별 총 출생아수")plt.xlabel("연도")plt.ylabel("출생아수 (명)")plt.xticks(rotation=45)plt.grid(True, alpha=0.3, axis="y")plt.tight_layout()plt.show()
전국 데이터:
성별 시군구별 연도 ... 전체 연월
3852 전국 1997 ... 63268.0 1997-01-01
3853 전국 1997 ... 58144.0 1997-02-01
3854 전국 1997 ... 62160.0 1997-03-01
... ... ... ... ... ...
3859 전국 1997 ... 53254.0 1997-08-01
3860 전국 1997 ... 55461.0 1997-09-01
3861 전국 1997 ... 57136.0 1997-10-01
[10 rows x 7 columns]
사용 가능한 컬럼: ['시군구별', '연도', '월', '남자', '여자', '전체', '연월']
그림 9.5: 전국 출생아수의 월별 추이(위)와 연도별 합계(아래). 시간축을 연월로 만들어야 월별 값이 겹치지 않는다.
9.2.8 지역별 비교 분석
주요 지역을 선택하여 지역 간 출생아수를 비교합니다. isin()을 사용하여 관심 있는 지역만 필터링하고, 특정 연도(2023년)의 데이터를 선택하여 비교합니다. groupby()로 지역별 합계를 계산하고, sort_values()로 내림차순 정렬하여 출생아수가 많은 지역부터 표시합니다. 가로 막대 그래프(barh)를 사용하여 긴 지역명이 잘 보이도록 하고, 색상을 다르게 하여 각 지역을 구분합니다.
# 주요 지역 선택 (광역시/도만)major_regions = ["서울특별시","부산광역시","대구광역시","인천광역시","경기도","경상북도","경상남도","전라남도",]df_regions = df_pivoted[ df_pivoted["시군구별"].isin(major_regions)].copy()print(df_regions.head(2))# # 2023년 데이터로 비교df_2023 = ( df_regions[df_regions["연도"] =="2023"] .groupby("시군구별")["전체"] .sum() .sort_values(ascending=False))print(df_2023)print("2023년 지역별 총 출생아수:")# 시각화plt.figure(figsize=(7, 5))colors = plt.cm.Set3(range(len(df_2023)))plt.barh(df_2023.index, df_2023.values, color=colors)plt.title("2023년 주요 지역별 총 출생아수")plt.xlabel("출생아수 (명)")plt.ylabel("지역")plt.tight_layout()plt.show()
성별 시군구별 연도 ... 여자 전체
336 경기도 1997 ... 6354.0 13440.0
337 경기도 1997 ... 5924.0 12334.0
[2 rows x 6 columns]
시군구별
경기도 68817.0
서울특별시 39456.0
인천광역시 13659.0
...
경상북도 10186.0
대구광역시 9410.0
전라남도 7828.0
Name: 전체, Length: 8, dtype: double[pyarrow]
2023년 지역별 총 출생아수:
그림 9.6: 2023년 주요 지역별 총 출생아수.
9.2.9 성비 분석
성비는 남자 출생아수를 여자 출생아수로 나눈 값으로, 1.0에 가까울수록 균형잡힌 성비를 의미합니다. 벡터 연산을 사용하여 모든 행에 대해 성비를 계산하고, 전국 데이터만 필터링하여 시간에 따른 성비 추이를 분석합니다. axhline()로 1:1 기준선을 그어 성비가 기준선보다 높거나 낮은지 쉽게 파악할 수 있게 합니다. 평균 성비와 최근 5년 평균을 계산하여 전체적인 추세와 최근 경향을 비교합니다.
# 성비 계산 (남자/여자 비율)df_pivoted["성비"] = df_pivoted["남자"] / df_pivoted["여자"]# 전국 성비 추이 (시간축은 위와 동일하게 연월 기준)df_national_ratio = df_pivoted[ df_pivoted["시군구별"] =="전국"].copy()df_national_ratio["연월"] = pd.to_datetime( df_national_ratio["연도"]+"-"+ df_national_ratio["월"],format="%Y-%m",)df_national_ratio = df_national_ratio.sort_values("연월")plt.figure(figsize=(7, 5))plt.plot( df_national_ratio["연월"], df_national_ratio["성비"], linewidth=2, color="darkred",)plt.axhline( y=1.0, color="gray", linestyle="--", alpha=0.5, label="1:1 비율",)plt.title("전국 출생 성비 추이 (남자/여자)")plt.xlabel("연월")plt.ylabel("성비 (남자/여자)")plt.legend()plt.grid(True, alpha=0.3)plt.tight_layout()plt.show()print(f"평균 성비: {df_national_ratio['성비'].mean():.3f}")print(f"최근 5년 평균 성비: { df_national_ratio[df_national_ratio['연도'] >='2020']['성비'].mean():.3f}")
그림 9.7: 전국 출생 성비(남자/여자) 추이. 1997년 1.10에서 최근 1.05 부근까지 낮아졌다.
평균 성비: 1.068
최근 5년 평균 성비: 1.049
9.2.10 월별 계절성 분석
월별 평균 출생아수를 계산하여 계절성 패턴을 분석합니다. groupby('월')로 월별로 그룹화하고 mean()으로 평균을 계산하면, 여러 연도의 데이터를 종합하여 월별 일반적인 패턴을 파악할 수 있습니다. 막대 그래프로 월별 평균을 시각화하면 특정 월에 출생아수가 많거나 적은 계절성을 쉽게 확인할 수 있습니다. 이러한 분석은 출생 패턴의 주기성을 이해하는 데 도움이 됩니다.
# 월별 평균 출생아수 (계절성 분석)monthly_avg = df_national.groupby("월")["전체"].mean()plt.figure(figsize=(7, 5))months = ["1월","2월","3월","4월","5월","6월","7월","8월","9월","10월","11월","12월",]plt.bar(range(1, 13), monthly_avg.values, color="skyblue", alpha=0.7,)plt.xticks(range(1, 13), months, rotation=45)plt.title("월별 평균 출생아수 (계절성 분석)")plt.xlabel("월")plt.ylabel("평균 출생아수 (명)")plt.grid(True, alpha=0.3, axis="y")plt.tight_layout()plt.show()print("월별 평균 출생아수:")for month, value in monthly_avg.items():print(f"{month}월: {value:,.0f}명")
모든 변환과 분석이 완료된 데이터를 저장합니다. encoding='utf-8-sig'는 Excel에서 한글이 깨지지 않도록 BOM(Byte Order Mark)을 포함한 인코딩을 사용합니다. 저장된 데이터는 Tidy Data 형태로, 이후 다양한 분석이나 시각화에 바로 사용할 수 있습니다.
# 정제된 데이터 저장df_final = df_pivoted.copy()df_final.to_csv("../data/kosis_births_tidy.csv", index=False, encoding="utf-8",)print(f"최종 데이터 크기: {df_final.shape}")print(f"\n컬럼: {df_final.columns.tolist()}")
최종 데이터 크기: (5868, 7)
컬럼: ['시군구별', '연도', '월', '남자', '여자', '전체', '성비']
[1]
Wickham, H., “Tidy Data”, Journal of Statistical Software, Vol 59, 호 10, 2014, pp 1–23.