Python 실전 데이터 분석 | Pandas로 데이터 분석하기

이 글의 핵심

데이터 분석은 코드를 돌리는 것보다 무엇을 확인할지 정하는 일이 먼저입니다. 데이터를 이해하고 가설을 세운 뒤 검증해서 인사이트로 정리하는 흐름을 따라가며, 날짜 컬럼이 문자열로 남아 있어 resample이나 rolling이 실패하는 경우처럼 분석 도중 자주 막히는 지점과 그 확인 순서도 함께 짚습니다.

들어가며

실전 데이터 분석은 데이터를 이해하고 의미를 찾는 과정입니다.

이 글은 Pandas 문법 자체보다 “분석 순서”에 초점을 둡니다. 처음 분석을 해 보면 데이터를 불러오자마자 그래프부터 그리고 싶어지지만, 열의 타입이 틀렸거나 결측치가 섞인 상태에서 그린 그래프는 틀린 결론으로 이어지기 쉽습니다. 그래서 순서는 항상 “구조 확인 → 분포 확인 → 관계 확인 → 그룹·시간 축으로 쪼개 보기 → 결론”입니다. 예제에서 쓰는 sales_data.csv, customers.csv는 설명을 위한 가상의 파일이므로, 직접 실행해 보려면 열 이름을 자신의 데이터에 맞게 바꾸거나 마지막 절의 난수 데이터 예제로 시작하십시오.


데이터 로드와 기본 탐색

기본 탐색

CSV를 read_csv로 불러오면 큰 표 하나가 메모리에 올라온 상태입니다. shape로 행·열 개수를 보며, info·describe로 타입과 수치 분포를 훑은 뒤, head로 실제 값 몇 줄을 눈으로 확인합니다.

import pandas as pd
import numpy as np
# 데이터 로드
df = pd.read_csv('sales_data.csv')
# 기본 정보
print(f"데이터 크기: {df.shape}")
print(f"\n열 정보:")
print(df.info())
print(f"\n통계 요약:")
print(df.describe())
print(f"\n처음 5행:")
print(df.head())
# 결측치 확인
print(f"\n결측치:")
print(df.isnull().sum())

df.info()는 결과를 직접 출력하고 None을 반환하므로, print(df.info())로 감싸면 요약 뒤에 None이 한 줄 더 찍힙니다. 그냥 df.info()로 호출하면 됩니다.

이 단계에서 가장 먼저 볼 것은 info()의 dtype 열입니다. 숫자여야 할 열이 object로 나온다면 값 중에 "1,200"처럼 쉼표가 들어간 문자열이나 "-", "N/A" 같은 표기가 섞여 있다는 신호입니다. 이 상태로는 describe()에 그 열이 나오지 않고, 평균을 구하면 에러가 납니다. pd.to_numeric(df['price'], errors='coerce')로 변환한 뒤 새로 생긴 NaN 개수를 세어 보면 어떤 값이 문제였는지 찾을 수 있습니다. 날짜 열도 대부분 문자열로 읽히므로, 처음부터 read_csv(..., parse_dates=['date'])로 읽는 편이 뒤의 시계열 분석에서 막히지 않습니다.

describe()에서는 평균과 중앙값(50%)의 차이를 봅니다. 연봉이나 구매액처럼 오른쪽으로 긴 꼬리를 가진 데이터는 평균이 중앙값보다 크게 나오고, 이 경우 “평균 구매액”은 대표값으로 부적절할 수 있습니다. min·max에서는 나이 -1, 200 같은 불가능한 값이 없는지 확인합니다. 한국어 CSV를 읽을 때 UnicodeDecodeError: 'utf-8' codec can't decode byte가 나면 엑셀에서 저장한 CP949 파일일 가능성이 높으므로 encoding='cp949'를 지정합니다.


분포와 상관관계로 EDA

분포 확인

import matplotlib.pyplot as plt
# 히스토그램
df['age'].hist(bins=20)
plt.title('나이 분포')
plt.xlabel('나이')
plt.ylabel('빈도')
plt.show()
# 박스플롯 (이상치 확인)
df.boxplot(column='salary', by='department')
plt.title('부서별 연봉 분포')
plt.show()

그래프 제목과 축에 한글을 쓰면 기본 설정의 Matplotlib에서는 글자가 네모(□)로 나오고 UserWarning: Glyph ... missing from font(s) DejaVu Sans 경고가 뜹니다. 기본 글꼴에 한글이 없기 때문이며, plt.rcParams['font.family'] = 'Malgun Gothic'(Windows)이나 'AppleGothic'(macOS), Linux라면 나눔 글꼴 설치 후 'NanumGothic'을 지정합니다. 한글 글꼴로 바꾸면 음수 부호가 깨지는 경우가 있어 plt.rcParams['axes.unicode_minus'] = False도 함께 설정합니다.

히스토그램은 bins 값에 따라 모양이 크게 달라집니다. 구간이 너무 적으면 두 봉우리를 가진 분포가 하나로 뭉개지고, 너무 많으면 노이즈만 보입니다. 두세 가지 값으로 그려 보고 모양이 안정적인지 확인하는 것이 좋습니다. 박스플롯의 수염 밖 점들은 뒤에서 다룰 IQR 규칙으로 표시한 “이상치 후보”이지 곧바로 지울 데이터가 아닙니다. 영업 부서의 연봉 이상치는 성과급 때문일 수 있고, 그런 값이 분석의 핵심일 수도 있습니다.

상관관계 분석

import seaborn as sns
# 상관계수 계산
corr_matrix = df[['age', 'salary', 'experience']].corr()
print(corr_matrix)
# 히트맵
sns.heatmap(corr_matrix, annot=True, cmap='coolwarm')
plt.title('상관관계 히트맵')
plt.show()

corr()의 기본값은 피어슨 상관계수로, 직선 관계의 강도만 측정합니다. 나이와 연봉처럼 처음엔 오르다가 일정 나이 이후 평평해지는 관계는 실제로 강하게 연결되어 있어도 계수가 낮게 나올 수 있고, 극단값 몇 개가 계수를 크게 흔들기도 합니다. 순서만 중요하거나 이상치가 많다면 corr(method='spearman')을 함께 보는 것이 좋습니다. pandas 2.0부터는 문자열 열이 섞인 DataFrame에 corr()을 호출하면 ValueError: could not convert string to float가 나므로, 위 예제처럼 숫자 열만 골라서 넘기거나 numeric_only=True를 줍니다.

cmap='coolwarm'처럼 가운데가 흰색인 색상표를 쓸 때는 vmin=-1, vmax=1을 함께 지정해야 0이 항상 흰색이 됩니다. 지정하지 않으면 데이터의 최솟값·최댓값에 맞춰 색이 늘어나, 0.2 정도의 약한 상관도 진한 색으로 보여 과대 해석하게 됩니다. 그리고 가장 중요한 점으로, 경력과 연봉의 상관이 높다는 것은 “경력이 연봉을 올린다”는 인과가 아닙니다. 나이라는 제3의 변수가 둘 다에 영향을 줄 수 있습니다.


그룹별 집계 분석

집계 분석

# 부서별 평균 연봉
dept_avg = df.groupby('department')['salary'].mean()
print(dept_avg)
# 여러 집계
result = df.groupby('department').agg({
    'salary': ['mean', 'min', 'max'],
    'age': 'mean',
    'name': 'count'
})
print(result)
# 피벗 테이블
pivot = df.pivot_table(
    values='salary',
    index='department',
    columns='gender',
    aggfunc='mean'
)
print(pivot)

groupby(...).agg({...})에 리스트로 여러 집계를 주면 결과 열이 ('salary', 'mean') 같은 다중 인덱스(MultiIndex)가 됩니다. 이후 result['salary_mean']처럼 접근하면 KeyError가 나므로, 처음부터 named aggregation 문법으로 열 이름을 정하는 편이 다루기 쉽습니다.

result = df.groupby('department').agg(
    avg_salary=('salary', 'mean'),
    min_salary=('salary', 'min'),
    headcount=('name', 'count'),
)

'count'는 결측이 아닌 값의 개수이고, 결측 포함 행 수는 'size'입니다. 이름 열에 빈 값이 있으면 두 결과가 달라집니다. 부서별 평균을 비교할 때는 count를 함께 보는 것이 중요합니다. 인원이 두 명인 부서의 평균 연봉은 한 사람의 값에 크게 좌우되므로, 인원이 적은 그룹의 순위를 그대로 결론으로 쓰면 안 됩니다. 피벗 테이블에서 해당 조합의 데이터가 없는 칸은 NaN이 되며, 이것을 0으로 채우면 “평균 연봉 0원”처럼 의미가 바뀌므로 fill_value는 합계·건수에만 쓰는 것이 안전합니다.


시계열 분석

시계열 데이터

# 날짜 파싱
df['date'] = pd.to_datetime(df['date'])
df = df.set_index('date')
# 리샘플링 (일 → 월)
monthly = df.resample('M').sum()
# 이동 평균
df['ma7'] = df['sales'].rolling(window=7).mean()
df['ma30'] = df['sales'].rolling(window=30).mean()
# 시각화
plt.figure(figsize=(12, 6))
plt.plot(df.index, df['sales'], label='일별 매출', alpha=0.5)
plt.plot(df.index, df['ma7'], label='7일 이동평균')
plt.plot(df.index, df['ma30'], label='30일 이동평균')
plt.legend()
plt.title('매출 추이')
plt.show()

resample('M')은 월말 기준으로 묶는다는 뜻인데, pandas 2.2부터 'M' 별칭은 폐기 예정이라 FutureWarning: 'M' is deprecated and will be removed in a future version, please use 'ME' instead가 나옵니다. 새 코드에서는 월말은 'ME', 월초 기준은 'MS'를 씁니다. 또 df.resample(...).sum()은 모든 열을 합치려고 하므로 지역명 같은 문자열 열이 있으면 에러가 나거나 문자열이 이어 붙은 이상한 결과가 나옵니다. df['sales'].resample('ME').sum()처럼 필요한 열만 고르십시오.

rolling(window=7)은 7일이 아니라 7행입니다. 매출이 없는 날의 행이 빠져 있는 데이터라면 “7일 이동평균”이 실제로는 10일이나 2주에 걸친 평균이 됩니다. 날짜 인덱스가 있을 때는 rolling('7D')처럼 시간 간격으로 지정하면 빠진 날과 상관없이 정확히 7일 창을 씁니다. 반대로 빠진 날을 매출 0으로 봐야 한다면 먼저 resample('D').sum()으로 날짜를 채운 뒤 행 기준 rolling을 적용합니다. rolling을 적용하기 전에 df.sort_index()로 날짜순 정렬이 되어 있는지도 확인해야 하는데, CSV가 최신순으로 저장된 경우 정렬 없이 계산하면 에러 없이 틀린 값이 나옵니다.


고객 데이터 분석 예제

import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
# 데이터 로드
customers = pd.read_csv('customers.csv')
# 1. 기본 통계
print("=== 기본 통계 ===")
print(f"총 고객 수: {len(customers)}")
print(f"평균 나이: {customers['age'].mean():.1f}세")
print(f"평균 구매액: {customers['purchase_amount'].mean():,.0f}원")
# 2. 연령대별 분석
customers['age_group'] = pd.cut(
    customers['age'],
    bins=[0, 20, 30, 40, 50, 100],
    labels=['10대', '20대', '30대', '40대', '50대+'],
    right=False  # [20, 30) 구간: 20세는 '20대', 29세까지
)
age_analysis = customers.groupby('age_group').agg({
    'purchase_amount': ['mean', 'sum', 'count']
})
print("\n=== 연령대별 분석 ===")
print(age_analysis)
# 3. 시각화
fig, axes = plt.subplots(2, 2, figsize=(12, 10))
# 연령 분포
axes[0, 0].hist(customers['age'], bins=20, edgecolor='black')
axes[0, 0].set_title('연령 분포')
# 구매액 분포
axes[0, 1].hist(customers['purchase_amount'], bins=30, edgecolor='black')
axes[0, 1].set_title('구매액 분포')
# 연령대별 평균 구매액
age_group_avg = customers.groupby('age_group')['purchase_amount'].mean()
axes[1, 0].bar(age_group_avg.index, age_group_avg.values)
axes[1, 0].set_title('연령대별 평균 구매액')
axes[1, 0].tick_params(axis='x', rotation=45)
# 상관관계
numeric_cols = customers[['age', 'purchase_amount', 'visit_count']]
sns.heatmap(numeric_cols.corr(), annot=True, ax=axes[1, 1])
axes[1, 1].set_title('상관관계')
plt.tight_layout()
plt.savefig('customer_analysis.png', dpi=300)
plt.show()
# 4. 인사이트 도출
print("\n=== 인사이트 ===")
print(f"가장 많이 구매하는 연령대: {age_analysis[('purchase_amount', 'sum')].idxmax()}")
print(f"평균 구매액이 가장 높은 연령대: {age_group_avg.idxmax()}")

pd.cut의 기본값은 right=True, 즉 구간이 (0, 20], (20, 30]처럼 오른쪽 끝을 포함합니다. 이대로 두면 20세가 ‘10대’로, 30세가 ‘20대’로 분류되어 라벨과 어긋나므로 right=False로 왼쪽 끝을 포함시켰습니다. 또 bins 범위 밖의 값(나이 0이나 100 이상)은 NaN이 되어 그룹 집계에서 조용히 빠집니다. 연령대별 고객 수를 모두 더했을 때 전체 고객 수와 맞는지 확인하는 것이 이런 누락을 잡는 가장 간단한 방법입니다.

마지막 인사이트 두 줄은 서로 다른 질문입니다. “가장 많이 구매하는 연령대”는 총 구매액(sum)이 가장 큰 그룹이고, “평균 구매액이 가장 높은 연령대”는 1인당 평균(mean)이 가장 큰 그룹입니다. 20대 고객이 많아서 총액은 1위지만 1인당 평균은 50대가 더 높은 식으로 두 답이 갈리는 경우가 흔하고, 마케팅 예산을 어디에 쓸지는 이 차이에 따라 달라집니다. 처음 분석 보고서를 쓸 때 가장 자주 하는 실수가 이처럼 지표의 분모를 명시하지 않고 “가장 많이”라고 적는 것입니다.

age_group처럼 pd.cut으로 만든 범주형 열로 groupby하면 pandas 2.1 이후 observed 인자의 기본값이 바뀔 예정이라는 FutureWarning이 나옵니다. 데이터가 없는 범주(예: ‘10대’ 고객 0명)도 결과에 행으로 보이게 할지 결정해 observed=False 또는 True를 명시하면 경고가 사라집니다. savefig는 반드시 show()보다 먼저 호출해야 합니다. 일부 환경에서는 show()가 창을 닫으면서 그림을 비워, 그 뒤에 저장하면 빈 이미지가 됩니다.


데이터 이해 → 가설 → 검증 → 인사이트

탐색적 데이터 분석(EDA)은 질문을 세우고, 표와 그래프로 답을 찾는 과정입니다. 열 의미·타입·결측을 본 뒤 가설을 정하며, 시각화와 통계로 검증한 다음, 비즈니스에 쓸 수 있는 문장으로 정리합니다.

# 1. 데이터 이해
# - 각 열의 의미
# - 데이터 타입
# - 결측치/이상치
# 2. 가설 수립
# - "연령이 높을수록 구매액이 높을까?"
# - "주말에 매출이 높을까?"
# 3. 검증
# - 시각화
# - 통계 분석
# - 상관관계
# 4. 인사이트 도출
# - 비즈니스 의미
# - 액션 아이템

IQR 이상치 플래그와 흔한 실수

이상치 플래그와 그룹별 요약 테이블

아래는 수치 열에 대해 IQR 기준 이상치 행을 표시하며, 그룹별 요약을 뽑는 실행 가능한 스크립트입니다.

import numpy as np
import pandas as pd
rng = np.random.default_rng(7)
df = pd.DataFrame({
    "region": rng.choice(["A", "B", "C"], size=400),
    "revenue": rng.normal(100, 25, size=400),
})
def iqr_bounds(s: pd.Series, k: float = 1.5):
    q1, q3 = s.quantile([0.25, 0.75])
    iqr = q3 - q1
    lo, hi = q1 - k * iqr, q3 + k * iqr
    return lo, hi
lo, hi = iqr_bounds(df["revenue"])
df["outlier"] = (df["revenue"] < lo) | (df["revenue"] > hi)
summary = df.groupby("region")["revenue"].agg(["count", "mean", "std", "median"])
print(summary)
print("이상치 비율:", df["outlier"].mean())

IQR 규칙은 1사분위수와 3사분위수의 간격(IQR)의 1.5배를 벗어나는 값을 이상치 후보로 표시합니다. 평균과 표준편차 대신 사분위수를 쓰기 때문에 극단값 자체에 기준이 끌려가지 않는다는 장점이 있습니다. 이 예제의 데이터는 정규분포에서 뽑았으므로 이상치 비율이 1% 안팎으로 나오는데, 이론적으로 정규분포에서 IQR 1.5배 밖에 놓이는 비율은 약 0.7%입니다. 즉 정상 데이터에서도 이상치가 표시된다는 것이고, 표시된 행을 기계적으로 삭제하면 멀쩡한 데이터를 잃게 됩니다.

이 예제는 전체 데이터를 기준으로 경계를 한 번 계산했습니다. 지역마다 매출 규모가 크게 다르다면 작은 지역의 정상 값이 모두 “낮은 이상치”로 찍힐 수 있으므로, df.groupby("region")["revenue"].transform(...)으로 그룹별 경계를 계산하는 편이 맞습니다. df["outlier"].mean()이 비율이 되는 이유는 불린 값의 평균이 True의 비율과 같기 때문입니다.

결측치, 정렬 안 된 시계열, 유의성 없는 인과 해석

  • 결측치를 제거하지 않은 채 평균·상관을 계산하는 경우.
  • 시계열을 정렬하지 않고 rolling·diff를 적용하는 경우.
  • 시각화만 보고 통계적 유의성 없이 인과를 단정하는 경우.

IQR 규칙, 다중 검정, 재현 가능한 노트북

  • IQR 규칙은 분포 가정이 약하지만 비즈니스 규칙과 함께 써야 합니다.
  • 다중 검정 시 p-value 보정이 필요할 수 있습니다.
  • 노트북은 재현 가능한 시드와 데이터 스냅샷 버전을 명시합니다.
  • 결과는 지표 정의(분자/분모)와 함께 문서화합니다.

pandas 외에 쓸 만한 도구

도구역할
pandas테이블 변환·집계
SQL대용량 집계를 DB에 가깝게
Spark분산 배치

추가 리소스


데이터 분석 요약

  1. EDA: 데이터 탐색과 시각화
  2. 그룹 분석: groupby, pivot_table
  3. 시계열: resample, rolling
  4. 시각화: Matplotlib, Seaborn
  5. 인사이트: 데이터 → 의사결정

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. resample이나 rolling을 호출했는데 날짜 관련 에러가 나면 무엇을 확인하나요?

A. resample은 DatetimeIndex를 기준으로 동작하므로, 먼저 pd.to_datetime(df['date'])로 날짜 열을 datetime 타입으로 바꾸고 set_index('date')로 인덱스를 지정했는지 확인합니다. 날짜가 문자열 상태로 남아 있으면 리샘플링이 되지 않고 정렬도 문자열 기준으로 됩니다. rolling(window=7)은 앞쪽 6개 행이 NaN으로 나오는 것이 정상이므로, 이를 결측 데이터로 오해하지 않도록 주의합니다.