개요

데이터 가공 이후 이를 사람이 쉽게 분석하기(EDA) 위해 자료들을 시각화할 라이브러리가 필요하다! 이에 주로 사용되는 Matplotlib와 코드 가독성 및 그래프를 쉽게 그릴 수 있게 도와주는 Seaborn 라이브러리를 정리하려한다.

지난 글에서 정리한 NumPy·Pandas가 데이터를 계산하는 단계였다면, 이번엔 그 결과를 사람 눈에 보이게 만드는 단계다. 왜 굳이 그림으로 바꿔야 할까?

print(df.describe())
# 숫자 40개가 화면에 쏟아진다. 그래서... 뭐가 이상한 건데?

describe()가 뱉은 숫자표를 아무리 노려봐도 “3등석 요금에 이상하게 비싼 값이 하나 껴 있다” 같은 건 안 보인다. 그런데 그걸 산점도로 한 번 찍으면 1초 만에 보인다. 사람의 눈은 표를 읽는 데는 느리지만 패턴을 찾는 데는 압도적으로 빠르기 때문이다. 시각화는 예쁘게 꾸미는 작업이 아니라, 뇌의 패턴 인식 능력을 강제로 켜는 스위치에 가깝다.

Matplotlib = 손으로 하나하나 그리는 붓 / Seaborn = 자주 쓰는 그림을 미리 그려둔 스탬프

Seaborn은 Matplotlib을 대체하는 게 아니라 Matplotlib 위에 얹혀 있다. sns.barplot()이 만들어낸 것도 결국 Matplotlib의 Axes 객체라서, 제목·축 이름은 plt.title()로 그대로 붙일 수 있다. C++에서 상속받은 클래스가 부모 클래스 메서드를 그대로 쓰는 것과 같은 구조다.

Matplotlib

파이썬 시각화의 기본기. NumPy 배열을 그대로 받아서 그림으로 바꿔준다.

준비 — 한글 폰트부터 잡고 시작하자

이걸 먼저 안 하면 그래프 제목이 전부 네모(□□□)로 깨진다. 처음 시각화를 배울 때 가장 먼저 만나는 벽인데, 원인은 간단하다. Matplotlib의 기본 폰트에 한글 글자가 아예 들어있지 않아서다. 사전에 없는 단어를 찾으면 빈칸이 나오는 것과 같다.

import platform

import numpy as np              # 수치 계산
import pandas as pd             # 표 데이터
import matplotlib.pyplot as plt # 그래프 그리기 (기본)
import seaborn as sns           # 그래프 그리기 (통계 전문)

# 운영체제마다 설치된 한글 폰트 이름이 다르다
if platform.system() == 'Windows':
    plt.rcParams['font.family'] = 'Malgun Gothic'   # 윈도우: 맑은 고딕
elif platform.system() == 'Darwin':
    plt.rcParams['font.family'] = 'AppleGothic'     # 맥
else:
    plt.rcParams['font.family'] = 'NanumGothic'     # 리눅스 / 코랩

plt.rcParams['axes.unicode_minus'] = False          # 음수 부호(-) 깨짐 방지
plt.rcParams['figure.figsize'] = (8, 5)             # 기본 그림 크기

rcParams는 전역 설정 딕셔너리다. 한 번 바꿔두면 그 뒤에 그리는 모든 그래프에 적용된다. C#의 static 필드나 C++의 전역 변수와 성격이 같아서, 편한 만큼 “어디서 바꿨는지 모르겠는” 문제도 똑같이 생긴다.

axes.unicode_minus = False는 왜 필요할까? Matplotlib은 기본적으로 음수를 진짜 마이너스 기호(U+2212)로 그리는데, 한글 폰트에는 그 글자가 없어서 또 네모가 된다. 그래서 평범한 하이픈(-)을 쓰라고 알려주는 설정이다.

기본 4종 세트

matplotlib에서 실제로 쓰는 함수는 사실 몇 개 안 된다. 질문의 종류가 곧 그래프의 종류다.

기본 4종

선 그래프 - plot()

“시간이 지나면서 어떻게 변했나?”를 볼 때 쓴다. x축이 순서를 가진 값(시간, 나이, 온도)일 때만 의미가 있다.

x = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
y = np.array([220, 250, 230, 280, 260, 290, 270, 310, 290, 320, 330, 350])

plt.plot(x, y)
plt.title("월별 매출 추이")
plt.xlabel("")
plt.ylabel("매출 (만원)")
plt.show()

여기서 눈여겨볼 게 있다. plt.plot() 다음 줄의 plt.title()은 어떤 그래프에 제목을 붙일지 안 알려줬는데도 제대로 붙는다. Matplotlib이 “지금 작업 중인 그림”을 내부에 몰래 기억하고 있기 때문이다. 게임 그래픽스의 immediate mode나 OpenGL의 컨텍스트와 완전히 같은 방식이다. 편하지만, 그래프가 여러 개가 되는 순간 “어디에 붙은 거야?”로 헷갈리기 시작한다. (해결책은 뒤의 서브플롯에서 다룬다.)

  • 선 꾸미기와 여러 줄 겹쳐 그리기
plt.plot(x, y, color='red', linestyle='--', linewidth=2, marker='o')
plt.title("월별 매출 추이", fontsize=16)
plt.grid(alpha=0.3)   # alpha = 투명도. 0이면 안 보이고 1이면 진하다
plt.show()

# 여러 개를 겹쳐 그릴 땐 label을 주고 legend()로 범례를 켠다
plt.plot(x, np.sin(x), label='sin(x)', color='blue')
plt.plot(x, np.cos(x), label='cos(x)', color='red')
plt.legend()          # label을 안 주면 범례가 비어서 나온다
plt.show()

alpha는 게임이나 그래픽스 프로그래밍의 알파 블렌딩과 똑같은 개념이다. C#의 Color(r, g, b, a), C++의 glColor4f에서 마지막 값이 하던 그 역할이다.

막대 그래프 - bar()

“어느 쪽이 더 큰가?”를 볼 때 쓴다. x축이 순서 없는 이름표(지역, 등급, 메뉴)일 때 쓴다.

지역 = ['강남', '홍대', '잠실', '신촌']
매출 = [46700, 51000, 45400, 57800]

plt.bar(지역, 매출)
plt.title('지역별 매출')
plt.ylabel('매출')
plt.show()

Pandas 집계 결과를 그대로 꽂을 수도 있다.

df = pd.read_csv('titanic.csv')

등급별_생존율 = df.groupby('pclass')['survived'].mean()
print(등급별_생존율)
# pclass
# 1    0.629630     1등석 63%
# 2    0.472826     2등석 47%
# 3    0.242363     3등석 24%
# Name: survived, dtype: float64

plt.bar(등급별_생존율.index, 등급별_생존율.values)
plt.title('좌석 등급별 생존율')
plt.xlabel('좌석 등급')
plt.ylabel('생존율')
plt.xticks([1, 2, 3])   # 1.5, 2.5 같은 이상한 눈금이 생기는 걸 막는다
plt.show()

groupby() 결과인 Series는 index(그룹 이름)와 values(집계값) 두 덩어리로 되어 있어서, 그대로 bar()의 x, y에 넣으면 된다. SQL로 치면 SELECT pclass, AVG(survived) FROM titanic GROUP BY pclass의 결과를 바로 그린 셈이다.

산점도 - scatter()

“두 값이 서로 관계가 있나?”를 볼 때 쓴다.

# alpha=0.5 : 반투명. 점이 겹칠 때 필수 옵션이다
plt.scatter(df['age'], df['fare'], alpha=0.5)
plt.title('나이와 요금의 관계')
plt.xlabel('나이')
plt.ylabel('요금')
plt.show()

산점도의 점 하나 = 데이터 한 줄(승객 한 명)이다. 그래서 “왼쪽 아래에 점이 뭉쳐 있다”는 곧 “젊고 요금 싼 승객이 많다”는 뜻이 된다.

alpha를 빼면 안 되는 이유가 있다. 점이 891개나 겹치면 까만 덩어리 하나가 되어서 그 안에 10명이 있는지 500명이 있는지 알 수가 없다. 반투명으로 그리면 겹친 곳이 진해져서 밀집도가 눈에 보인다.

히스토그램 - hist()

“값이 어떻게 퍼져 있나?”를 볼 때 쓴다. 막대그래프와 생김새는 비슷하지만 목적이 전혀 다르다.

# bins=20      : 나이를 20개 구간으로 쪼개서 각 구간에 몇 명인지 센다
# edgecolor    : 막대 테두리. 없으면 막대끼리 붙어 보여서 읽기 힘들다
# .dropna()    : 결측치(NaN)를 미리 빼둔다 (이유는 아래 '실무 함정' 참고)
plt.hist(df['age'].dropna(), bins=20, edgecolor='black')
plt.title('승객 나이 분포')
plt.xlabel('나이')
plt.ylabel('사람 수')
plt.show()

bins는 값을 담는 바구니 개수다. 해시 테이블의 버킷과 같은 발상이라고 보면 이해가 빠르다. 이 숫자를 바꾸면 그림의 인상이 확 달라지므로, 하나만 보고 결론 내리지 말고 10, 20, 50 정도로 바꿔가며 확인하는 습관이 좋다.

  막대그래프 bar() 히스토그램 hist()
x축 이름표(범주) 숫자 구간
막대 사이 띄어져 있음 붙어 있음
답하는 질문 “어느 쪽이 큰가” “어디에 몰려 있나”
순서 바꿔도 됨 바꾸면 안 됨

차트 커스터마이징

등급별_생존율 = df.groupby('pclass')['survived'].mean()

plt.figure(figsize=(8, 5))                          # 그림 크기 (가로, 세로) 인치
plt.bar(등급별_생존율.index, 등급별_생존율.values,
        color='steelblue', label='생존율')            # 색 + 범례용 이름
plt.title('좌석 등급별 생존율', fontsize=15)
plt.xlabel('좌석 등급', fontsize=12)
plt.ylabel('생존율', fontsize=12)
plt.xticks([1, 2, 3], ['1등석', '2등석', '3등석'])     # 눈금 글자 바꾸기
plt.ylim(0, 1)                                       # y축 범위 (반드시 0부터!)
plt.legend(loc='upper right')                        # 범례 위치
plt.grid(axis='y', alpha=0.3)                        # 가로 보조선만
plt.tight_layout()                                   # 잘린 여백 자동 정리
plt.show()

tight_layout()은 축 이름이 그림 밖으로 잘려 나가는 걸 막아주는 마무리 함수다. 한글 축 이름은 영어보다 길어서 특히 잘 잘리니, 습관적으로 붙여두면 좋다.

서브플롯 - subplots()

여러 그래프를 한 장에 배치할 때 쓴다. 그리고 여기서부터 작성 방식이 근본적으로 바뀐다.

fig, axes = plt.subplots(2, 2, figsize=(12, 8))   # 2행 2열

등급 = df.groupby('pclass')['survived'].mean()
axes[0, 0].bar(등급.index, 등급.values)
axes[0, 0].set_title('등급별 생존율')

성별 = df.groupby('sex')['survived'].mean()
axes[0, 1].bar(성별.index, 성별.values, color='coral')
axes[0, 1].set_title('성별 생존율')

axes[1, 0].hist(df['age'].dropna(), bins=20, edgecolor='black')
axes[1, 0].set_title('나이 분포')

axes[1, 1].scatter(df['age'], df['fare'], alpha=0.4)
axes[1, 1].set_title('나이 vs 요금')

plt.tight_layout()   # 서브플롯 간격 자동 조정
plt.show()

여기서 두 가지가 눈에 띈다.

첫째, fig, axes = plt.subplots(…)는 값 두 개를 한 번에 받는 문법이다. C++17의 구조적 바인딩(auto [fig, axes] = …)이나 C#의 튜플 분해(var (fig, axes) = …)와 완전히 같다. fig는 도화지 전체, axes는 그 위에 그려진 칸 하나하나를 가리킨다.

둘째, plt.title()이 axes[0, 0].set_title()로 바뀌었다. “현재 그림”에 몰래 붙이는 방식(전역 상태)에서, “이 칸”이라고 명확히 지목하는 방식(객체 메서드)으로 넘어간 것이다. C#으로 치면 Console.WriteLine() 같은 정적 호출에서 myLabel.SetText() 같은 인스턴스 호출로 바뀐 셈이다.

상황 스타일 예시
그래프 1개, 빠르게 확인 pyplot (전역) plt.title(‘제목’)
그래프 여러 개, 코드로 관리 객체 지향 ax.set_title(‘제목’)

이름 규칙도 살짝 다르니 주의하자. 전역은 plt.xlabel()인데 객체는 ax.set_xlabel()처럼 set_ 접두사가 붙는다. 처음엔 이것 때문에 에러가 많이 난다.

Seaborn

통계 차트 전문가. Matplotlib 위에서 동작하며, “자주 그리는 그림”을 함수 하나로 줄여준다.

Matplotlib과 무엇이 다른가

같은 그래프를 두 방식으로 그려보면 차이가 확 드러난다.

# Matplotlib : 내가 직접 집계해서 넘겨야 한다
등급 = df.groupby('pclass')['survived'].mean()
plt.bar(등급.index, 등급.values)

# Seaborn : 집계를 알아서 해준다
sns.barplot(data=df, x='pclass', y='survived')

Seaborn은 data=(어느 표), x=(가로축 열 이름), y=(세로축 열 이름)만 알려주면 내부에서 groupby와 평균 계산까지 다 한다. 이게 EDA에서 Seaborn이 압도적으로 편한 이유다.

구조를 보면 SQL과 거의 1:1로 대응한다.

SELECT pclass, AVG(survived) FROM titanic GROUP BY pclass;
sns.barplot(data=df, x='pclass', y='survived')
#            FROM       GROUP BY    AVG(대상)

data=가 FROM, x=가 GROUP BY, y=가 집계 대상이다. SQL을 아는 사람이라면 Seaborn은 사실상 이미 배운 것이나 다름없다.

countplot / barplot

sns.countplot(data=df, x='survived')       # 개수를 센다
plt.title('생존자 / 사망자 수')
plt.xticks([0, 1], ['사망(0)', '생존(1)'])
plt.show()

sns.barplot(data=df, x='pclass', y='survived')   # 평균을 낸다
plt.title('등급별 생존율')
plt.ylabel('생존율')
plt.show()

이 둘은 이름이 비슷해서 자주 헷갈린다.

함수 세는 것 y 지정 SQL 대응
countplot() 범주별 개수(빈도) 지정하지 않음 COUNT(*)
barplot() 범주별 평균(기본값) 숫자형 컬럼 지정 AVG(컬럼)

barplot() 막대 위에 붙는 검은 세로 막대기는 오차 막대(신뢰구간)다. “이 평균을 얼마나 믿을 만한가”를 뜻하며, 샘플이 적을수록 길어진다. 길게 나왔다면 “이 그룹은 사람이 적어서 평균을 믿기 어렵다”는 경고로 읽으면 된다. 발표 자료처럼 깔끔함이 우선이면 errorbar=None으로 끌 수 있지만, 탐색 단계에서는 켜두는 쪽을 권한다.

histplot - 분포 + 곡선 + 그룹 비교

seaborn hue

sns.histplot(data=df, x='age', bins=20)                    # 기본
sns.histplot(data=df, x='age', bins=20, kde=True)          # + 부드러운 곡선
sns.histplot(data=df, x='age', bins=20, hue='survived')    # + 그룹 분리
plt.title('생존 여부별 나이 분포')
plt.show()

kde=True는 막대 꼭대기를 부드럽게 이은 곡선을 얹어준다. 막대는 bins 개수에 따라 인상이 흔들리는데, KDE 곡선은 그 영향을 덜 받아서 분포의 큰 모양을 보기에 좋다.

그리고 hue= 이 한 줄이 Seaborn의 핵심이다. Matplotlib으로 같은 걸 하려면 데이터를 직접 두 덩어리로 쪼개고, 두 번 그리고, 색을 맞추고, 범례를 붙여야 한다. Seaborn은 그걸 파라미터 하나로 끝낸다. SQL로 치면 GROUP BY에 키를 하나 더 추가하는 것과 같다.

scatterplot / regplot

sns.scatterplot(data=df, x='age', y='fare', hue='survived', alpha=0.6)
plt.title('나이·요금과 생존 여부')
plt.show()

# 추세선까지 같이 보려면 regplot
sns.regplot(data=df, x='age', y='fare', color='red', scatter_kws={'alpha': 0.4})
plt.title('나이와 요금 (추세선 포함)')
plt.show()

scatter_kws={‘alpha’: 0.4}처럼 딕셔너리로 하위 옵션을 넘기는 방식은 Seaborn 전반에서 반복되는 패턴이다. “점에 대한 설정은 점 담당에게 통째로 전달한다”는 뜻으로, C#의 옵션 객체 패턴이나 C++의 설정 struct 전달과 발상이 같다.

boxplot - 이상치를 잡아내는 그물

sns.boxplot(data=df, y='fare')                          # 전체 분포
sns.boxplot(data=df, x='pclass', y='fare')              # 등급별 비교 ← 진짜 용도
sns.boxplot(data=df, x='pclass', y='age', hue='sex')    # 등급 × 성별
plt.title('등급·성별 나이 분포')
plt.show()

박스 하나에 요약 통계가 통째로 들어 있다. 그림으로 보면 한 번에 이해된다.

박스플롯 읽는 법

  • 박스 안의 선 = 중앙값. 순서대로 줄 세웠을 때 딱 가운데 있는 값
  • 박스의 위아래 끝 = 위쪽 25% 경계(Q3)와 아래쪽 25% 경계(Q1). 즉 한가운데 절반이 이 박스 안에 들어 있다
  • 수염(선) = 정상 범위의 끝
  • 수염 밖의 점 = 이상치. “얘 혼자 유별나다”는 표시

평균은 극단값 하나에 크게 휘둘리는데, 중앙값은 그렇지 않다. 회사 10명 중 9명이 월급 200만원이고 사장 혼자 5억이면 평균은 5천만원이 넘지만 중앙값은 여전히 200만원이다. 그래서 이상치가 의심되면 평균보다 박스플롯을 먼저 봐야 한다.

titanic 나이를 예로 들면 이렇게 읽힌다.

v = df['age'].dropna()
print(v.quantile([0.25, 0.5, 0.75]).tolist())  # [20.125, 28.0, 38.0]
print(v.mean())                                # 29.699118

가운데 절반(714명 중 357명)이 20세에서 38세 사이에 몰려 있고, 중앙값 28세보다 평균 29.7세가 약간 높다. 위쪽에 60~80세 이상치가 몇 명 있어서 평균을 끌어올린 것이다. 평균과 중앙값이 벌어져 있으면 “한쪽으로 꼬리가 길다”는 신호로 읽으면 된다.

heatmap - 표를 색으로 바꿔 읽기

상관계수 히트맵

cols = ['survived', 'pclass', 'age', 'sibsp', 'parch', 'fare']
corr = df[cols].corr()   # 볼 열을 직접 골라서 상관계수 표를 만든다

plt.figure(figsize=(9, 6))
sns.heatmap(
    corr,
    annot=True,      # 칸 안에 숫자도 표시
    fmt=".2f",       # 소수점 2자리
    cmap="coolwarm", # 색 팔레트 (파랑 ↔ 빨강)
    vmin=-1, vmax=1, # 색의 범위를 -1~1로 고정
    center=0,        # 0을 색의 중앙(흰색)으로
    linewidths=0.5)  # 칸 사이 선
plt.title("상관계수 히트맵")
plt.show()

상관계수는 두 값이 같이 움직이는 정도를 -1 ~ 1 사이 숫자로 나타낸 것이다.

  • +1에 가까움 : 한쪽이 오르면 다른 쪽도 오른다 (형제 수 sibsp와 부모·자녀 수 parch = 0.41. 가족은 같이 타니까 당연하다)
  • -1에 가까움 : 한쪽이 오르면 다른 쪽은 내린다 (좌석 등급 pclass와 요금 fare = -0.55. 등급 숫자가 커질수록 싸구려 좌석이니 당연하다)
  • 0에 가까움 : 별 관계 없다 (survived와 sibsp = -0.04)

여기서 열을 직접 골라준 데는 이유가 있다. df.corr(numeric_only=True)를 그냥 쓰면 엉뚱한 열이 딸려 들어온다.

print(df.corr(numeric_only=True).columns.tolist())
# ['survived', 'pclass', 'age', 'sibsp', 'parch', 'fare', 'adult_male', 'alone']
#                                                          ↑ True/False 열이 끼어들었다
print(df.dtypes[df.dtypes == bool].index.tolist())   # ['adult_male', 'alone']

numeric_only=True는 “숫자만 골라라”인데, 파이썬에서 True는 1, False는 0이라 bool도 숫자로 취급된다. C++이나 C#에서 bool이 정수로 암묵 변환되는 것과 똑같은 함정이다. 그래서 볼 열을 내가 직접 적어주는 편이 안전하다.

반대로 sex 같은 문자열 열은 아예 못 낀다. 넣고 싶다면 숫자로 바꿔줘야 한다.

df_corr = df.copy()   # 원본을 건드리지 않도록 반드시 복사!
df_corr["sex"] = df_corr["sex"].map({"male": 0, "female": 1})
print(df_corr[["sex", "survived"]].corr().iloc[0, 1])   # 0.5434

0.54면 상당히 강한 양의 상관이다. 여성(1)일수록 생존(1)과 같이 움직인다는 뜻으로, 앞에서 본 “여성 74.2% / 남성 18.9%”와 정확히 같은 이야기를 숫자 하나로 압축한 것이다.

vmin, vmax, center를 지정하는 것도 중요하다. 안 주면 그 표에 있는 값의 최대·최소에 맞춰 색이 자동으로 늘어나서, 0.05짜리 약한 관계가 새빨갛게 보이는 사고가 난다.

여기서 반드시 짚고 갈 것 하나. 상관관계는 인과관계가 아니다. “요금이 비쌀수록 생존율이 높다”가 “돈을 더 냈더니 살아남았다”를 뜻하지는 않는다. 비싼 요금 → 상위 등급 객실 → 구명보트와 가까움 이라는 숨은 원인이 뒤에 있을 뿐이다. 히트맵은 “여기 뭔가 있다”는 힌트지, 결론이 아니다.

pairplot - 한 방에 전부 보기

# .dropna()로 기준 행을 통일한다 (안 하면 칸마다 표본 수가 달라진다)
cols = ['survived', 'age', 'fare', 'pclass']
sns.pairplot(df[cols].dropna(), hue='survived', corner=True)
plt.show()

수치형 열들의 모든 조합을 격자로 한 번에 그려준다. 대각선은 각 열 자체의 분포, 나머지 칸은 두 열의 산점도다. corner=True를 주면 위쪽 절반(같은 내용의 거울상)을 생략해서 훨씬 읽기 좋아진다.

다만 열이 많으면 순식간에 느려진다. 열 n개면 그림이 n² 개다. 10개만 넘어가도 100칸이라 몇십 초씩 걸리니, 관심 있는 열 4~6개만 골라서 쓰는 게 실전 요령이다.

그래프, 어떻게 고르는가

여기서부터는 강의 자료 밖의 이야기다. 함수를 아무리 외워도 “그래서 지금 뭘 그려야 하지?”에서 막히는 게 진짜 문제다.

그래프 선택 가이드

순서는 언제나 질문이 먼저, 그래프가 나중이다.

궁금한 것 그래프 matplotlib / seaborn
시간에 따른 변화 선 그래프 plot() / lineplot()
범주별 크기 비교 막대 그래프 bar() / barplot()
범주별 개수 막대 그래프 — / countplot()
값의 분포 히스토그램 hist() / histplot()
두 값의 관계 산점도 scatter() / scatterplot()
그룹별 분포·이상치 박스플롯 — / boxplot()
열끼리의 관계 전체 히트맵·페어플롯 — / heatmap(), pairplot()

그리고 파이 차트는 웬만하면 쓰지 말자. 사람 눈은 각도 크기를 잘 구분하지 못한다. 30%와 33%짜리 조각을 눈으로 구별할 수 있는 사람은 거의 없다. 같은 데이터를 막대그래프로 그리면 길이 비교라 훨씬 정확하게 읽힌다.

실무에서 만나는 함정

1. y축을 0에서 시작하지 않으면 없는 차이가 생긴다

가장 흔하면서 가장 위험한 함정이다.

y축 함정

vals = [0.62, 0.58, 0.55]

plt.bar(['A안', 'B안', 'C안'], vals)
plt.ylim(0.5, 0.65)   # ← A안이 압도적으로 좋아 보인다
plt.show()

plt.bar(['A안', 'B안', 'C안'], vals)
plt.ylim(0, 1)        # ← 사실 셋은 거의 같다
plt.show()

같은 숫자인데 결론이 정반대로 읽힌다. 막대그래프는 “막대의 길이”로 크기를 읽는 그래프라서, 밑동을 잘라내면 길이 비율이 통째로 거짓말이 된다. 막대그래프의 y축은 0에서 시작하는 것이 원칙이다. (반면 선 그래프는 변화의 방향을 보는 것이라 잘라도 괜찮다.)

2. 결측치는 에러보다 “조용한 무시”가 더 무섭다

여기는 직접 돌려보고 생각이 바뀐 부분이다. 예전 자료들은 “hist()에 NaN이 있으면 에러가 난다”고 설명하는데, 요즘 버전(matplotlib 3.10 기준)에서는 에러가 안 난다. 대신 더 나쁜 일이 벌어진다.

n, bins, _ = plt.hist(df['age'], bins=20)
print(n.sum())                  # 714.0  ← 891명인데 714명만 세어졌다!
print(df['age'].notna().sum())  # 714

에러를 내는 대신 NaN 177개를 조용히 빼고 그린다. 화면에는 멀쩡한 그래프가 나오고, 그래서 아무도 눈치를 못 챈다. 에러는 나를 멈춰 세우지만, 조용한 무시는 나를 틀린 채로 계속 가게 만든다. 이쪽이 훨씬 위험하다.

(참고로 값이 전부 NaN이면 그때는 진짜로 ValueError: autodetected range of [nan, nan] is not finite가 뜬다.)

그래서 dropna()를 쓰는 이유는 “에러를 막으려고”가 아니다. 몇 개가 빠졌는지 내가 알고 그리려고다.

print(df['age'].isna().sum())   # 177  ← 먼저 세어보고
plt.hist(df['age'].dropna(), bins=20, edgecolor='black')
plt.title('승객 나이 분포 (나이 정보 있는 714명)')   # 제목에 밝혀둔다

dropna()로 뺀 177명은 없는 사람이 아니라 나이를 모르는 사람이다. “나이 분포”라고 제목을 달아놓고 실제로는 “나이를 아는 사람들의 분포”를 그린 셈이니, 제목에라도 적어두는 게 정직하다.

pairplot()도 마찬가지다. NaN이 있어도 에러는 안 나지만 칸마다 쓰는 행 수가 달라진다.

sub = df[['survived', 'age', 'fare', 'pclass']]
print(len(sub), len(sub.dropna()))              # 891 714
print(sub[['age', 'fare']].dropna().shape[0])   # 714  ← age가 낀 칸
print(sub[['fare', 'pclass']].dropna().shape[0])# 891  ← age가 안 낀 칸

한 장의 그림 안에서 어떤 칸은 891명, 어떤 칸은 714명 기준이 된다. 나란히 놓고 비교하라고 만든 그림인데 기준이 서로 다르면 비교 자체가 성립하지 않는다. 그래서 .dropna()로 기준을 하나로 통일하고 그리는 것이다.

3. 점이 겹쳐서 밀집도가 안 보인다

plt.scatter(df['age'], df['fare'])              # 까만 덩어리
plt.scatter(df['age'], df['fare'], alpha=0.3)   # 밀집도가 보인다
plt.scatter(df['age'], df['fare'], alpha=0.3, s=10)  # 점 크기까지 줄이면 더 좋다

데이터가 수만 건이면 alpha로도 부족하다. 그때는 산점도 대신 hexbin()이나 histplot(…, bins=50)처럼 “개수를 세서 색으로 칠하는” 방식으로 갈아타야 한다.

4. 색깔만으로 구분하면 못 읽는 사람이 있다

빨강-초록 조합은 색각 이상이 있는 사람에게 같은 색으로 보인다. 남성 20명 중 1명꼴이니 발표 자리에서 결코 드문 일이 아니다.

# 색 + 모양을 같이 쓰면 색을 못 봐도 구분된다
sns.scatterplot(data=df, x='age', y='fare', hue='survived', style='survived')

# 팔레트를 색각 친화형으로
sns.set_palette("colorblind")

5. 저장하면 축 이름이 잘려 나간다

plt.savefig('chart.png')                                  # 축 이름이 잘림
plt.savefig('chart.png', dpi=150, bbox_inches='tight')    # 정답

화면에서는 멀쩡했는데 파일로 저장하면 글자가 잘리는 일이 자주 생긴다. bbox_inches=’tight’가 내용에 맞춰 여백을 다시 계산해준다.

그리고 savefig()는 show()보다 먼저 호출하는 걸 습관으로 두자.

plt.bar(x, y)
plt.savefig('chart.png', bbox_inches='tight')   # 먼저 저장하고
plt.show()                                       # 그다음 화면에 띄운다

이건 환경에 따라 결과가 달라지는 문제라 더 성가시다. 주피터·코랩의 기본 설정에서는 show()가 그림을 화면에 넘기면서 내부 도화지를 비워버려서, 순서를 바꾸면 빈 파일이 저장된다. 반면 스크립트로 돌릴 때는 멀쩡히 저장되기도 한다. “내 노트북에선 됐는데 서버에선 빈 파일이 나온다”는 사고가 여기서 나온다. 순서만 지키면 어느 쪽이든 안전하다.

EDA 순서 체크리스트

새 데이터를 받았을 때 나는 이 순서로 본다. 큰 그림부터 작은 그림으로 좁혀 들어가는 게 요령이다.

  1. 모양 확인 — df.shape, df.head(), df.info() 몇 줄 몇 열인지, 자료형이 뭔지. 여기서 숫자여야 할 열이 object면 이미 문제가 있는 것이다.
  2. 결측치 확인 — df.isna().sum() 빠진 값이 어디에, 얼마나 있는지. 이걸 모르고 그리면 4번에서 반드시 에러가 난다.
  3. 한 열씩 분포 보기 — histplot(), countplot(), boxplot() 숫자 열은 히스토그램, 이름표 열은 countplot. 이상치는 boxplot으로.
  4. 두 열씩 관계 보기 — scatterplot(), barplot(), boxplot(hue=) 여기서부터 진짜 발견이 나온다.
  5. 전체 관계 훑기 — heatmap(), pairplot() 놓친 조합이 있는지 마지막으로 확인.
  6. 가설 적기 “3등석 요금에 이상치가 있다”, “여성 생존율이 압도적으로 높다” 같은 문장으로 남긴다. 그림을 본 뒤 문장으로 안 적으면 다음 날 다 까먹는다.

절대 하면 안 되는 것은 1~2번을 건너뛰고 바로 예쁜 그래프부터 그리는 것이다. 결측치와 자료형을 확인하지 않은 그래프는 대개 틀린 그림이고, 틀린 그림은 틀린 결론으로 곧장 이어진다.

다른 언어와 견줘 보면

이번 라이브러리를 만지며 기존 문법과 겹쳐 보였던 지점들을 정리해둔다.

개념 Matplotlib / Seaborn 익숙한 다른 문법
“현재 그림”에 몰래 그리기 plt.title() OpenGL 컨텍스트, immediate mode
대상을 지목해 그리기 ax.set_title() C# 인스턴스 메서드 호출
값 두 개 동시 반환 fig, ax = plt.subplots() C++17 auto [a, b] = …, C# var (a, b) = …
전역 설정 plt.rcParams[…] C# static 필드, C++ 전역 변수
집계 후 그리기 sns.barplot(x=, y=) SQL GROUP BY + AVG()
그룹 하나 더 쪼개기 hue= SQL GROUP BY 키 추가
개수 세기 sns.countplot() SQL COUNT(*)
하위 옵션 묶음 전달 scatter_kws={…} C++ 설정 struct, C# 옵션 객체
투명도 alpha=0.3 알파 블렌딩(glColor4f의 네 번째 값)

특히 재밌었던 건 Seaborn이 사실상 SQL의 시각화판이라는 점이다. data=, x=, y=, hue=는 각각 FROM, GROUP BY, 집계 대상, 추가 GROUP BY 키에 그대로 대응한다. 이전에 SQL로 머리를 굴려본 경험이 여기서 그대로 재활용된다.

한줄 평

  • AI 활용으로 지금까지 데이터를 기준으로 작성을 진행해 보았다. 어찌보면 나도 새로 알게된 지식들이나 기존과 비교해 정리해준 지식들이 많아 도움이 많이 된다!