plotly는 마우스 호버, 확대/축소, 범례 클릭으로 항목 숨기기 등 인터랙티브 기능을 제공하는 라이브러리입니다. 첨부하는 이미지들은 전부 정적 캡처이지만, 실제로 주피터 노트북이나 Streamlit 등에서 실행하면 마우스를 올렸을 때 값이 표시되고, 범례를 클릭하면 범례 항목을 켜고 끌 수 있습니다.
plotly 그래프를 이미지로 저장하기
(venv) pip install kaleido
(venv) pip freeze > requirements.txt
Plotly 그래프를 PNG 이미지로 저장하려면 `kaleido` 라이브러리가 필요합니다. 이번 글에서는 블로그에 정적 이미지를 첨부하기 위해 사용했지만, Plotly 그래프도 일반 이미지처럼 저장할 수 있다는 점을 함께 소개하고 싶어 추가했습니다. 다만 이 기능은 필수는 아닙니다. 앞에서 살펴본 '03. 선실 정리함 채우기: utils와 ModuleNotFoundError'처럼 선택 사항이라고 생각하시면 됩니다.
# utils/save.py
from pathlib import Path
# 최상위 폴더 고정
FIGURES_DIR = Path("figures")
# 허용된 하위 폴더 목록
VALID_FOLDERS = {"analysis", "tutorial"}
def save_fig(fig, filename, folder, dpi=160):
# 허용되지 않은 폴더명일 경우 에러 발생
if folder not in VALID_FOLDERS:
raise ValueError(f"folder는 {VALID_FOLDERS} 중 하나여야 합니다.")
# 최종 저장 경로 조합 ex. figures/tutorial
folder_path = FIGURES_DIR / folder
# 폴더 없으면 생성, 있어도 에러 없이 통과
folder_path.mkdir(parents=True, exist_ok=True)
# .png 확장자 자동 추가
file_path = folder_path / f"{filename}.png"
# plotly Figure인지
if hasattr(fig, "write_image"):
scale = dpi / 80
fig.write_image(file_path, scale=scale)
# matplotlib Figure인지
elif hasattr(fig, "savefig"):
# bbox_inches: tight, 여백 자동 조정
fig.savefig(file_path, dpi=dpi, bbox_inches="tight")
else:
raise TypeError("지원하지 않는 Figure 타입입니다.")
print(f"save: {file_path}")
return file_path
간단하게 추가한 내용을 설명하자면, 기존의 `save_fig`는 matplotlib 전용으로 만든 이미지 저장 함수였습니다. 그런데 plotly에서도 이미지를 저장할 수 있기 때문에 같은 함수에서 matplotlib 그래프와 plotly 그래프를 모두 저장할 수 있도록 수정했습니다.
여기서 `hasattr()`은 특정 객체가 어떤 속성이나 메서드를 가지고 있는지 확인하는 함수입니다. 예를 들어 Plotly Figure는 `write_image()` 메서드를 가지고 있고, matplotlib Figure는 `savefig()` 메서드를 가지고 있습니다. 그래서 `hasattr(fig, "write_image")`이면 Plotly 그래프로 판단하여 `write_image()`를 호출하고, `hasattr(fig, "savefig")`이면 matplotlib 그래프로 판단하여 `savefig()`를 호출합니다. 이렇게 하나의 함수로 두 라이브러리를 모두 처리할 수 있습니다.
막대그래프
dead_color, survived_color = get_palette(2)
fig = px.histogram( # 특정 컬럼의 값이 몇 번 등장하는지 집계하여 막대그래프로
df,
x="sex", # x축: 범주형 변수
color="survived", # 막대 색상을 구분할 기준 변수
barmode="group", # 색상 그룹별 막대를 나란히 표시
color_discrete_map={0: dead_color, 1: survived_color},
title="성별-생존 여부",
labels={"sex": "성별", "count": "인원 수", "survived": "생존"}, # 축/범례 이름
)
save_fig(fig, "px-gender_survival", "tutorial")
fig.show()

이 그래프는 성별에 따라 생존자와 사망자의 수가 어떻게 다른지 비교할 때 사용합니다. `x='sex'`로 성별을 기준으로 나누고 `color='survived'`를 사용해 생존 여부에 따라 막대 색상을 다르게 표시합니다. `barmode='group'`은 같은 성별 안에서 생존/사망 막대를 나란히 보여줍니다. 그래서 남성과 여성 각각에서 생존자 수와 사망자 수를 직접 비교하기 좋습니다.
주의할 점은 `color_discrete_map`은 반드시 `color`에 들어간 값과 연결된다는 점입니다. 여기서는 `color='survived'`이고, `survived` 값이 0, 1이므로 `{0: 색상1, 1: 색상2}` 형태로 지정해야 합니다.
파이차트
# class 컬럼의 빈도수를 계산한 뒤 데이터프레임 형태로 변환
pclass_counts = df["class"].value_counts().reset_index()
# index, count로 생성된 컬럼명을 class, count로 변경
pclass_counts.columns = ["class", "count"]
fig = px.pie( # 전체에서 각 범주가 차지하는 비율을 원형 그래프로
pclass_counts,
names="class", # 조각을 나눌 기준 범주
values="count", # 각 조각의 크기를 결정할 값
title="객실 등급별 인원 비율",
color_discrete_sequence=get_palette(3),
)
save_fig(fig, "px-room_ratio_by_room_class", "tutorial")
fig.show()

이 그래프는 전체 승객 중 객실 등급별 인원 비중이 어느 정도인지 확인할 때 사용합니다. `df['class'].value_counts()`로 객실 등급별 인원수를 먼저 계산한 뒤, 그 결과를 파이 차트로 시각화합니다. `names='class'`는 파이 조각을 객실 등급별로 나누겠다는 뜻이고, `values='count'`는 각 조각의 크기를 인원수 기준으로 정하겠다는 뜻입니다. 파이 차트는 비율을 보기에는 좋지만, 값의 차이를 정밀하게 비교하기에는 막대그래프보다 약합니다. 그렇기에 '대략적인 구성 비율'을 보여줄 때 적합합니다.
산점도
dead_color, survived_color = get_palette(2)
fig = px.scatter( # 두 수치형 변수의 관계를 산점도로
df,
x="age", # x축: 수치형 변수
y="fare", # y축: 수치형 변수
color="survived", # 점 색상을 구분할 기준
color_discrete_map={0: dead_color, 1: survived_color},
title="나이 vs. 요금",
labels={"age": "나이", "fare": "요금", "survived": "생존"}, # 축/범례 이름 설정
opacity=0.6, # 점의 투명도 설정
)
save_fig(fig, "px-age_vs_fare", "tutorial")
fig.show()

이 그래프는 나이와 요금 사이에 어떤 관계가 있는지, 그 관계가 생존 여부와 연결되는지를 볼 때 사용합니다. `x='age'`, `y='fare'`로 각 승객을 하나의 점으로 표시하고, `color='survived'`로 생존 여부를 색상으로 구분합니다. `opacity=0.6`은 점을 약간 투명하게 만들어 겹쳐진 데이터가 덜 답답하게 보이도록 합니다. 산점도에서는 데이터가 많이 겹칠 수 있기 때문에 투명도를 주는 것이 유용합니다. 산점도는 관계나 패턴을 탐색하는 데 좋지만 정확한 인과관계를 설명해주지는 않습니다. 예를 들어 요금이 높을수록 생존율이 높아 보이더라도 실제로는 객실 등급 같은 다른 변수가 함께 영향을 줬을 수 있습니다.
박스플롯
fig = px.box( # 그룹별 수치 데이터의 분포/중앙값/사분위수/이상치를 보여줌
df,
x="pclass", # x축: 그룹 변수
y="fare", # y축: 분포를 확인할 수치형 변수
color="pclass", # 박스 색상을 구분할 기준 변수
title="객실 등급별 요금 분포",
labels={"pclass": "객실 등급", "fare": "요금"}, # 축/범례 이름
color_discrete_sequence=get_palette(3),
)
# y축 범위: 0~300, 이상치로 인해 그래프가 눌려 보임
fig.update_yaxes(range=[0, 300])
save_fig(fig, "px-rate_dstribution_by_room_class", "tutorial")
fig.show()

이 그래프는 객실 등급별 요금의 분포가 어떻게 다른지 확인할 때 사용합니다. 단순히 평균만 보는 것이 아니라 중앙값, 사분위 범위, 이상치까지 함께 볼 수 있습니다.
`x='pclass'`는 객실 등급별로 그룹을 나누겠다는 뜻이고, `y='fare'`는 각 그룹에서 요금 분포를 확인하겠다는 뜻입니다. 박스의 가운데 선은 중앙값이고 박스의 범위는 데이터 중간 50% 구간을 나타냅니다. `fig.udpate_yaxes(range=[0,300])`은 y축 범위를 0부터 300까지로 제한하는 코드입니다. 타이타닉 데이터에는 매우 높은 요금의 이상치가 있어서 그대로 그리면 대부분의 박스가 아래쪽에 눌려 보일 수 있어요. 주의할 점은 y축 범위를 제한하면 300보다 큰 값은 그래프에서 잘려 보일 수 있다는 것입니다. 그래서 전체 이상치까지 보고 싶은 그래프인지, 주요 분포를 보기 좋은 그래프인지 목적에 따라 y축 범위를 정해야 해요.
선그래프
# 나이를 10년 단위의 연령대로 구간화
age_bins = pd.cut(df["age"], bins=[0, 10, 20, 30, 40, 50, 60, 70, 80])
# 연령대별로 그룹화한 뒤 생존율 계산
age_survival = df.groupby(age_bins, observed=False)["survived"].mean().reset_index()
# 연령대 구간을 문자열로 변환하여 그래프의 x축에 표시
age_survival["age_group"] = age_survival["age"].astype(str)
fig = px.line( # x축 순서에 따라 y값의 변화를 선으로 연결해 보여줌
age_survival,
x="age_group", # x축: 연령대 구간
y="survived", # y축: 생존율
markers=True, # 각 데이터 지점에 마커 표시
title="연령대별 생존율",
labels={"age_group": "연령대", "survived": "생존율"}, # 축 이름 변경
color_discrete_sequence=get_palette(1),
)
save_fig(fig, "px-survival_rate_by_age_group", "tutorial")
fig.show()

이 그래프는 연령대가 달라질수록 생존율이 어떻게 변화하는지 확인할 때 사용합니다. 나이를 그대로 사용하면 값이 너무 세분화되기 때문에 먼저 `pd.cut()`을 사용해 0~10세, 10~20세처럼 구간을 나눕니다. 주의할 점은 `pd.cut()`으로 만드는 구간에 따라 그래프 해석이 달라질 수 있습니다. 만약 10년 단위로 나누면 큰 흐름은 보기 좋지만 세부적인 차이가 사라질 수 있습니다. `df.groupby(age_bins)["survived"].mean()`은 연령대별 생존율을 계산합니다. `survived`는 0과 1로 이루어진 값이므로 평균을 구하면 곧 생존율이 됩니다. 예를 들어 평균이 0.75라면 해당 연령대 승객의 75%가 생존했다는 의미입니다.
이번 글에서는 Plotly Express를 사용해 기본적인 시각화를 만들어봤습니다.
사실 plotly의 가장 큰 장점은 그래프를 보는 것이 아니라 탐색하는 것에 있어요. 마우스를 올려 값을 확인하고 범례를 클릭해 특정 데이터만 비교하고, 원하는 영역을 확대하는 기능은 실제 데이터 분석 과정에서 매우 자주 활용된다고 합니다.
'🚢 titanic' 카테고리의 다른 글
| [python] 탐사일지 02. 쓸 것과 버릴 것: 컬럼 감별과 결측치 전략 (1) | 2026.06.30 |
|---|---|
| [python] 탐사일지 01. 닻을 내리기 전에: 질문부터 던지고 데이터 첫인상 파악하기 (0) | 2026.06.29 |
| [python] 항해일지 07. 함교에 올라 조종하기: streamlit (0) | 2026.06.28 |
| [python] 항해일지 05. 더 선명하게 항해도 그리기: seaborn (0) | 2026.06.27 |
| [python] 항해일지 04. 갑판 위 풍경 스케치: matplotlib (0) | 2026.06.26 |