🚢 titanic

[python] 항해일지 05. 더 선명하게 항해도 그리기: seaborn

Floaty 2026. 6. 27. 10:00

matplotlib를 어느 정도 익혔다면 이제 seaborn을 배울 차례입니다. seaborn은 matplotlib를 기반으로 하지만 통계적 시각화를 훨씬 적은 코드로 만들 수 있게 해 줍니다. 특히 그룹별 비교, 분포 비교에 강력하죠. 이번 글에서는 타이타닉 데이터를 활용해 seaborn의 주요 그래프를 살펴보고 마지막에는 matplotlib로 같은 그래프를 그릴 때 얼마나 코드가 길어지는지도 비교해 보겠습니다.

 

 

 

라이브러리 불러오기

import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from utils.theme import set_plot_theme, get_palette
from utils.font import set_korean_font
from utils.save import save_fig

set_plot_theme()
set_korean_font()

df = pd.read_csv("data/titanic.csv")

matplotlib와 비슷하지만 이번에는 seaborn이 주인공이니 `sns`로 seaborn도 함께 불러옵니다. 

 

 

 

막대그래프 - countplot

fig, ax = plt.subplots(figsize=(6.5, 4.5))

sns.countplot(       # 범주별 데이터 개수를 세어 막대 그래프를 그림
    data=df,         # 사용할 데이터프레임
    x="sex",         # x축: 성별(male/female)
    hue="survived",  # 막대를 생존 여부(0/1)로 색상 구분
    palette=get_palette(2),  # 막대 색상
    ax=ax,                   # 그릴 캔버스 지정
)

ax.set_title("성별-생존 여부")
ax.legend(        # 범례 설정
    title="생존",  # 범례 제목
    labels=["사망(0)", "생존(1)"],  # 범례 항목 이름
)

plt.tight_layout()
save_fig(fig, "sns-gender_survival", "tutorial")
plt.show()

막대그래프는 두 범주형 변수의 조합별 데이터 개수를 비교할 때 가장 적합합니다. '성별에 따라 생존/삼아 패턴이 어떻게 다른가?'처럼 두 범주가 교차하는 지점의 빈도를 직관적으로 보고 싶을 때 사용합니다. 

 

코드를 보면 `x='sex'`로 성별을 x축에 배치하고, `hue='survived'`로 생존 여부에 따라 막대 색상을 나눕니다. `countplot`은 y축 값을 별도로 집계하지 않아도 되고 데이터프레임을 그대로 넘기면 각 그룹의 개수를 자동으로 세어줍니다.

 

주의할 점은 `hue` 지정 여부입니다. `hue`를 지정하지 않으면 같은 x범주의 막대가 하나로 합쳐져 생존/사망 구분이 전혀 보이지 않습니다. 같은 x범주 안에서 세부 그룹을 나누고 싶다면 반드시 hue에 값을 지정해야 합니다. 

 

이 그래프에서는 '남성/여성 중 '생존자와 사망자가 각각 몇 명인지'를 한눈에 비교할 수 있어서 성별에 따른 생존율 차이를 직관적으로 전달하는 데 효과적입니다.

 

 

 

평균 막대그래프 - barplot

fig, ax = plt.subplots(figsize=(6.5, 4.5))

sns.barplot(  # y값의 평균을 막대로, 신뢰구간을 에러바로 표시
    data=df,
    x="pclass",    # x축: 객실등급(1,2,3)
    y="survived",  # y축: 생존 여부 평균(= 생존율)
    hue="pclass",  # 막대 색상을 pclass 기준으로 구분
    palette=get_palette(3),
    legend=False,  # hue 범례 표시 여부(pclass가 이미 x축에 있으므로 생략)
    ax=ax,
    errorbar=("ci", 95),  # 에러바 종류: 95% 신뢰구간(Confidence Interval)
)

ax.set_title("객실 등급별 생존율")
ax.set_xlabel("객실 등급(pclass)")
ax.set_ylabel("생존율")

plt.tight_layout()
save_fig(fig, "sns-survival-rate-by-pclass", "tutorial")
plt.show()

그룹별 수치의 평균을 비교하고 동시에 그 추정의 신뢰 구간(불확실성)까지 함께 보고 싶을 때 사용합니다. 단순히 '어느 그룹이 더 높은가'를 넘어서 '그 차이가 통계적으로 의미 있는가'에 대한 단서도 제공합니다. 

 

코드를 보면 `y='survived'`인데 `survived`는 0과 1을 가지는 이진값이므로, 평균을 구하면 곧 생존율이 됩니다. `barplot`이 `pclass` 그룹별 평균을 자동으로 계산해 막대에 표시하는 것이죠. 막대 위의 검은 세로선은 에러바(errorbar)라고 하며, 95% 신뢰구간을 나타냅니다. 

 

추가적으로 부트스트래핑(Bootstrapping)에 대해서 알아보자면  seaborn의 신뢰구간은 부투스트래핑 방식으로 계산한 뒤 그 분포의 상하 2.5%를 잘라낸 범위를 95% 신뢰구간으로 사용하는 방식입니다. 샘플 수가 적은 그룹일수록 에러바가 길어지는 이유가 여기에 있습니다. 에러바가 겹치지 않을수록 그룹 간 차이가 통계적으로 유의미할 가능성이 높습니다. 

 

`countplot`과 달리 `barplot`의 y축은 개수가 아닌 평균값으로 y에는 수치형 변수를 넣어야 의미 있는 그래프가 그려집니다. 

 

 

 

상자 그림 - boxplot

fig, ax = plt.subplots(figsize=(6.5, 4.5))

sns.boxplot(  # 중앙값, IQR, 이상치를 상자-수염 형태로 표시
    data=df,
    x="pclass",    # x축: 객실 등급
    y="fare",      # y축: 요금
    hue="pclass",  # 상자 색상을 pclass 기준으로 구분
    palette=get_palette(3),
    legend=False,
    ax=ax,
)

ax.set_title("객실 등급별 요금 분포")
ax.set_xlabel("객실 등급")
ax.set_ylabel("요금")
ax.set_ylim(0, 300)  # y축 범위를 0 ~ 300으로 제한(극단적 이상치 클리핑)

plt.tight_layout()
save_fig(fig, "sns-rate_distribution_by_pclass", "tutorial")
plt.show()

그룹별 분포의 모양(중앙값, 퍼짐 정도, 이상치)을 한눈에 비교하고 싶을 때 씁니다. 단순 평균만 보여주는 막대그래프와 달리 데이터가 얼마나 퍼져 있는지, 그리고 극단적인 값(이상치)은 어느 정도인지까지 함께 보여줍니다. 

 

코드를 보면, 객실 등급별 요금의 분포를 박스플롯으로 비교합니다. 상자의 중앙선은 중앙값이며, 상자의 범위는 IQR(25~75%), 수염 너머의 점은 이상치입니다. 

 

`ax.set_ylim(0, 300)`은 시각적 클리핑입니다. 300 이상의 요금 데이터를 삭제하는 게 아니라, 통계 계산(중앙값, IQR 등)에는 그대로 포함된 채로 박스가 그려지되 이상치 점만 화면에서 잘려 보이지 않게 됩니다. 이상치가 너무 극단적일 때 전체 그래프의 스케일이 무너지는 걸 막기 위한 방법입니다.

 

`fare` 칼럼에 결측치가 있어도 seaborn이 해당 행을 자동으로 무시하므로 별도 처리 없이도 오류가 발생하지 않습니다. 하지만 오류가 나지 않는다는 것과 결과가 올바르다는 건 다른 이야기입니다. 결측치가 얼마나 있는지는 미리 확인하는 것이 좋습니다. 

 

 

 

바이올린 그림 - violinplot

fig, ax = plt.subplots(figsize=(6.5, 4.5))

sns.violinplot(  # KDE로 분포 형태를 바이올린 모양으로 표시
    data=df,
    x="sex",          # x축: 성별
    y="age",          # y축: 나이
    hue="survived",   # 바이올린을 생존여부로 좌/우 분할
    split=True,       # True: hue 두 그룹을 좌우로 나눠 하나의 바이올린에 합침
    cut=0,            # KDE를 실제 데이터 범위 밖으로 연장하지 않음
    inner="quartile", # 바이올린 내부에 표시할 요소: 사분위수 선
    palette=get_palette(2),
    ax=ax,
)


ax.set_title("성별/생존여부별 나이 분포")
ax.set_ylim(0, None)  # y축 하한을 0으로 고정
plt.tight_layout()
save_fig(fig, "sns-age_distribution_by_gender_survival", "tutorial")
plt.show()

바이올린 그림은 `boxplot`보다 분포의 세밀한 모양까지 보고 싶을 때 사용합니다. 바이올린의 너비가 해당 구간에 데이터가 얼마나 몰려있는지를 나타냅니다. 다만 처음 보는 사람에게는 해석이 어려울 수 있어, 데이터 분석 보고서나 발표자료에는 유용하지만 일반 요약 자료에는 `boxplot`이나 `histogram`이 더 쉽게 전달됩니다.

 

처음 보면 뭔가 잘못된 것 같지만, 이 모양이 맞습니다. 좌우로 갈라진 바이올린을 통해 사망과 생존 그룹의 나이 분포 차이를 한 그래프 안에서 비교할 수 있습니다. 

 

`split=True`는 `hue`가 정확히 2개의 값일 때만 동작합니다. 3개 이상이면 오류가 발생하므로 주의해야 해요. `cut=0`도 중요한 옵션입니다. 기본값으로 그리면 KDE 추정 과정에서 나이 0세 미만 영역까지 곡선이 번져 보일 수 있습니다. `cut=0`을 지정하면 실제 데이터 범위(0세 이상) 밖으로 그래프가 그려지지 않아 더 정확하고 깔끔한 모양이 됩니다. 

 

여기서 KDE(Kernel Density Estimation)는 히스토그램처럼 데이터를 구간으로 나누는 대신 각 데이터 포인트 주변에 작은 곡선(커널)을 하나씩 올려놓고 이를 모두 합쳐 부드러운 분포 곡선을 만드는 방법입니다. 바이올린의 폭이 높을수록 그 나이대에 데이터가 많다는 의미입니다. 

 

 

 

히트맵 - heatmap

fig, ax = plt.subplots(figsize=(6.5, 5.5))

# 수치형 열만 선택
num_df = df[["survived", "pclass", "age", "sibsp", "parch", "fare"]]  
corr = num_df.corr()  # 피어슨 상관계수 행렬 계산
print(corr)
"""
          survived    pclass       age     sibsp     parch      fare
survived  1.000000 -0.338481 -0.077221 -0.035322  0.081629  0.257307
pclass   -0.338481  1.000000 -0.369226  0.083081  0.018443 -0.549500
age      -0.077221 -0.369226  1.000000 -0.308247 -0.189119  0.096067
sibsp    -0.035322  0.083081 -0.308247  1.000000  0.414838  0.159651
parch     0.081629  0.018443 -0.189119  0.414838  1.000000  0.216225
fare      0.257307 -0.549500  0.096067  0.159651  0.216225  1.000000
"""
custom_cmap = LinearSegmentedColormap.from_list(  # 색상 리스트로 커스텀 컬러맵 생성
    "custom",        # 컬러맵 이름 (임의 지정)
    get_palette(3),  # 시작 ~ 끝 색상을 정의할 색상 리스트
)
sns.heatmap(     # 2D 행렬 데이터를 색상으로 인코딩해 표시
    corr,        # 색상으로 나타낼 2D 데이터(상관계수 행렬)
    annot=True,  # 각 셀에 수치 표시 여부
    cmap=custom_cmap,
    fmt=".2f",   # annot 수치의 표맷 (소수점 2자리)
    ax=ax,
    vmin=-1,  # 색상 스케일 최솟값
    vmax=1,   # 색상 스케일 최댓값
    linewidths=0.5,  # 셀 구분선 두께
    linecolor="white",
)

ax.set_title("수치형 변수 간 상관관계")
plt.tight_layout()

save_fig(fig, "sns-corr_numeric_variables", "tutorial")
plt.show()

히트맵은 여러 변수 간의 관계를 한 번에 파악할 때 가장 효율적입니다. 특히 상관관계 분석에서 '어떤 변수끼리 관련이 있는지' 빠르게 스크리닝(후보를 좁히는 초기 탐색)할 때 자주 사용합니다. 

 

코드를 보면 수치형 변수 6개를 골라 `corr()`로 피어슨 상관계수 행렬을 계산한 뒤 `heatmap()`으로 색상의 강도를 통해 상관관계의 방향(양/음)과 강도를 한눈에 나타냅니다. `vmin=-1, vmax=1`로 고정해 상관계수의 실제 범위를 색상에 매핑합니다.

 

예를 들어 `survived`와 `pclass`의 -0.34입니다. 셀에는 소수점 2자리로 반올림해 표시되기 때문에 `print(corr)`에서 출력되는 -0.338과 그래프에서 보이는 -0.34가 달라 보이지만 같은 값을 다르게 표시하는 것뿐입니다. 

 

추가적으로 `num_df`를 만들 때 `copy()`를 명시하는 것을 권장합니다. `df[...]`처럼 열을 선택하면 원본의 뷰(view) 일 수도, 복사본일 수도 있는 애매한 상태가 됩니다. 이후 `num_df`를 수정하면 `SettingWithCopyWarning`이 발생할 수 있으므로 수정 의도가 있다면 `df[...].copy()`로 명시적으로 복사본을 만드는 것이 안전합니다.

 

`LinearSegmentedColormap.form_list`에 색상 3개를 넣으면 시작-중간-끝 색상을 자동으로 보간(interpolation)합니다. 보간은 두 값 사이의 중간값을 수학적으로 채워주는 것으로 여기서는 3가지 색상 사이를 부드럽게 이어주는 그러데이션이 자동으로 만들어집니다. 

 

 

 

페어플롯 - pariplot

sub = df[["survived", "age", "fare", "pclass"]].dropna()
# 결측치 있는 행 제거 후 서브셋 생성

g = sns.pairplot(  # 변수 쌍마다 산점도, 대각선엔 분포를 그리는 격자 그래프
    sub,
    hue="survived",  # 색상 기준 변수
    palette=get_palette(2),
    plot_kws={"alpha": 0.5, "s": 15},  # 산점도에 전달할 추가 옵션 (투명도, 점 크기)
)

# g.fig: pairplot이 반환하는 PairGrid의 figure 객체
g.fig.suptitle("pairplot (변수 간 관계 종합)", y=1.02)
save_fig(g.fig, "sns-pairplot", "tutorial")

plt.show()

여러 변수 간의 관계를 탐색적으로 빠르게 훑어보는 초기 분석(EDA) 단계에서 사용합니다. 변수가 많아질수록 그래프 수가 기하급수적으로 늘어나 가독성이 떨어지므로 중요한 변수 4~5개만 추려서 사용하는 것을 권장합니다. 

 

코드를 보면 선택한 4개 변수의 모든 쌍 조합에 대해 산점도를 격자로 그리고 대각선에는 각 변수의 KDE 분포를 표시합니다. `hue='survived'`로 생존/사망 그룹을 색상으로 구분해 어떤 변수 조합에서 두 그룹이 잘 분리되는지를 한 번에 탐색할 수 있습니다. 

 

`dropna()`는 원본 데이터프레임을 수정하지 않고 결측치가 제거된 새 데이터프레임을 반환합니다. `sub`에만 영향을 주고 원본 데이터프레임은 그대로입니다. `pairplot`은 `fig`, `ax`가 아닌 PairGrid 객체를 반환합니다. (그렇기에 주로 `g`를 사용합니다.) 따라서 `save_fig(fig, ...)` 대신 `save_fig(g.fig, ..)`처럼 전체 `figure`에 접근하려면 `g.fig`, 개별 `axes`에 접근하려면 `g.axes`를 사용해야 합니다.

 

변수가 많아질수록 렌더링이 눈에 띄게 느려지는데, 변수 수의 제곱만큼 서브플롯이 생성되기 때문입니다. 요금처럼 한쪽으로 치우친 분포에서는 산점도가 한 곳에 뭉쳐 보여 정보가 잘 드러나지 않을 수 있다는 점도 단점입니다.

 

 

 

누적 막대그래프 - plot

fig, ax = plt.subplots(figsize=(6.5, 4.5))
pclass_survival = pd.crosstab(  # 두 범주형 변수의 교차 빈도표(혹은 비율표) 생성
    df["pclass"],       # 행(index)으로 사용할 변수
    df["survived"],     # 열(columns)로 사용할 변수
    normalize="index",  # 각 행(pclass)의 합이 1이 되도록 비율로 변환
)
pclass_survival.plot(  # pandas DataFrame의 plot: matplotlib 기반 그래프 직접 생성
    kind="bar",    # 막대그래프
    stacked=True,  # 막대를 누적 형태로
    color=get_palette(2),
    ax=ax,
)

ax.set_title("객실 등급별 생존 확률")
ax.legend(title="생존", labels=["사망(0)", "생존(1)"])
plt.xticks(rotation=0)  # x축 레이블 회전 각도 (0= 수평)
plt.tight_layout()
save_fig(fig, "sns-survival_by_pclass", "tutorial")

plt.show()

각 그룹의 비율 구성을 비교할 때 가장 직관적입니다. '1등급 생존자가 더 많고 3등급은 사망자가 더 많다'는 사실이 색의 면적으로 즉시 전달되기 때문에 단순 평균을 보여주는 `barplot`보다 오히려 직관적인 경우가 많습니다.

 

코드를 해석해 보면 `pd.crosstab`으로 `pclass`별 사망/생존 비율을 먼저 계산한 뒤 pandas 내장 `plot()`으로 누적 막대그래프를 그립니다. 각 막대의 전체 높이는 1(100%)이며 색의 면적으로 생존/사망 비율을 표현합니다.

 

`normalize` 옵션의 차이를 이해하는 것이 중요한데, 현재 코드에서는 `normalize='index'`를 사용합니다. 이는 행 기준 졍규화로 각 `pclass` 내에서 생존/사망 비율의 합이 1이 됩니다. '각 등급 내에서 생존 비율이 얼마나 되는가?'라는 질문에 답합니다. 하지만 `normalize='columns'`은 열 기준 정규화입니다. 이 경우에는 '사망자 전체 중 각 등급이 차지하는 비율은?'이라는 전혀 다른 질문에 답하게 됩니다.

 

이 그래프는 seaborn이 아닌 `pandas.plot()` 메서드를 사용합니다. seaborn의 barplot과 달리 에러바가 없고 `crosstab`으로 미리 집계한 데이터프레임 형태그를 그대로 그래프로 변환하기에 이 방식이 더 적합합니다. `ax.legend(labels=...)`의 라벨 순서는 `crosstab` 결과의 열 순서(0 → 1)를 따르므로 데이터의 칼럼 순서를 먼저 확인하고 라벨을 맞춰야 합니다. 

 

 

 

(번외) 막대그래프(with. matplotlib)

import numpy as np

# [1] 데이터 집계
gender_survival_counts = df.groupby(["sex", "survived"]).size().unstack()
""" df.groupby(["sex", "survived"]).size()
sex     survived
female  0             81
        1            233
male    0            468
        1            109
"""

""" df.groupby(["sex", "survived"]).size().unstack()
survived    0    1
sex               
female     81  233
male      468  109
"""

numpy를 불러오고 데이터 집계를 하겠습니다. 

 

`groupby(['sex', 'survived'])`로 (female, 0), (female, 1), (male, 0), (male, 1) 총 4가지 조합의 그룹을 만들고, `size()`로 각 그룹의 행 개수를 셉니다. `unstack()`은 세로로 쌓인 멀티인덱스를 가로 열로 펼쳐 2D 표 형태로 만들 얼 줍니다. 막대그래프처럼 2D 형태의 데이터가 필요할 때 사용합니다.

 

# [2] 막대 위치 계산
gender_positions = np.arange(len(gender_survival_counts.index))
bar_width = 0.35  # 막대 하나의 너비

 

성별 2개에 대한 x축 위치(0,1)를 `np.arrange`로 계산합니다. 나중에 변수가 늘어날 경우를 대비해 숫자 2를 하드코딩하지 않고 `len(index)`로 유연하게 처리합니다.

 

# [3] 그래프 그리기
fig, ax = plt.subplots(figsize=(6.5, 4.5))

palette = get_palette(2)  # 2가지 색상 가져옴

# 중심에서 왼쪽으로 이동
dead_bars = ax.bar(                    # 사망(0) 막대 그래프를 그림
    gender_positions - bar_width / 2,  # 중심에서 왼쪽으로 절반만큼 이동
    gender_survival_counts[0],  # y축: 사망자 수
    width=bar_width,            # 막대 너비
    color=palette[0],           # 첫 번째 색상
    label="사망(0)",             # 범례 이름
)

# 중심에서 오른쪽으로 이동
survived_bars = ax.bar(                # 생존(1) 막대 그래프를 그림
    gender_positions + bar_width / 2,  # 중심에서 오른쪽으로 절반만큼 이동
    gender_survival_counts[1],         # y축: 생존자 수
    width=bar_width,                   # 막대 너비 
    color=palette[1],                  # 두 번째 색상
    label="생존(1)",                    # 범례 이름
)

 

사망 막대와 생존 막대를 각각 `ax.bar()`로 두 번 그립니다. 두 막대가 겹치지 않도록 x위치를 `bar_width / 2`씩 좌우로 이동시켜야 합니다.

 

# [4] 축, 범례 설정
ax.set_xticks(gender_positions)
ax.set_xticklabels(gender_survival_counts.index)
ax.set_title("성별-생존 여부")
ax.legend(title="생존")

plt.tight_layout()
save_fig(fig, "sns-mat-gender_survival", "tutorial")
plt.show()


x축 눈금 위치와 레이블을 별도로 설정해야 합니다. seaborn은 이 모든 것을 자동으로 처리해요. 결과를 보면 seaborn으로 그린 것과 male/female 순서가 반대인데, 데이터 정렬 방식의 차이 때문입니다. 그 외에는 동일한 그래프이지만 코드 길이와 가독성 면에서 seaborn이 훨씬 간결하다는 걸 체감할 수 있어요.

 

 

 

정리

그래프 함수 주요 용도
막대그래프 countplot 범주별 개수 비교
평균 막대 barplot 그룹별 평균 + 신뢰구간
상자 그림 boxplot 분포 모양 + 이상치 비교
바이올린 violinplot 세밀한 분포 모양 비교
히트맵 heatmap 변수 간 상관관계
페어플롯 pairplot 다변수 탐색적 분석(EDA)
누적막대 plot(stacked=True) 비율 구성 비교