데이터 탐색기
개념
자료를 손에 넣었을 때 가장 먼저 할 일은 탐색입니다. 탐색의 기본은 자료를 세 가지 방식으로 보는 것입니다. 표로 원자료를 하나하나 확인하고, 그래프로 분포의 전체 모양을 살피고, 평균·중앙값·표준편차 같은 요약값으로 특징을 숫자 몇 개로 압축합니다.
아래 데이터는 교육용으로 만든 가상 데이터입니다. 실제 탐구에서는 우리 반 친구들에게 직접 설문해서 나만의 데이터를 모아 보세요.
세 방식은 서로를 보완합니다. 표만 보면 전체 모양이 안 보이고, 요약값만 보면 특이한 값이 숨습니다. 아래 앱에서 가상의 고등학생 25명 데이터를 변수별로 골라 표·그래프·요약값을 한 번에 살펴보세요.
만지며 배우기
#| '!! shinylive warning !!': |
#| shinylive does not work in self-contained HTML documents.
#| Please set `embed-resources: false` in your metadata.
#| standalone: true
#| viewerHeight: 760
# matplotlib·numpy 없이 stdlib(statistics) + 인라인 SVG 로만 그린다
import statistics as st
from shiny import App, render, ui
ACCENT = "#2563eb"
INK = "#111827"
MUTED = "#6b7280"
GRID = "#e5e7eb"
# 교육용 예시(가상) 데이터 — 고등학생 25명
DATA = {
"키(cm)": [172.5, 158.3, 165.0, 181.2, 160.4, 175.8, 168.9, 155.2,
178.4, 162.7, 170.1, 152.8, 183.6, 166.3, 159.5, 174.2,
161.8, 169.4, 187.0, 157.6, 171.3, 164.5, 176.9, 153.9, 167.2],
"통학시간(분)": [15, 30, 10, 45, 25, 5, 60, 20, 35, 12, 40, 8, 55,
18, 22, 70, 28, 14, 33, 48, 9, 26, 38, 16, 52],
"수면시간(시간)": [6.5, 7.2, 5.8, 6.0, 7.5, 8.0, 5.5, 6.8, 7.0, 6.2,
8.5, 7.8, 5.2, 6.4, 7.1, 6.6, 8.2, 5.9, 6.1, 7.4,
6.9, 5.6, 7.7, 6.3, 8.8],
"성별": ["남", "여", "여", "남", "여", "남", "남", "여", "남", "여",
"남", "여", "남", "여", "여", "남", "여", "남", "남", "여",
"남", "여", "남", "여", "남"],
}
W, H = 440, 250
def hist_svg(data, xlab, mean, median):
ml, mr, mt, mb = 42, 12, 16, 38
pw, ph = W - ml - mr, H - mt - mb
lo, hi = min(data), max(data)
if hi <= lo:
hi = lo + 1
bins = 8
bw = (hi - lo) / bins
counts = [0] * bins
for v in data:
k = int((v - lo) / bw)
k = 0 if k < 0 else (bins - 1 if k >= bins else k)
counts[k] += 1
vmax = max(counts) or 1
def X(v):
return ml + ((v - lo) / (hi - lo)) * pw
def Y(c):
return mt + ph - (c / vmax) * ph
body = ""
for t in range(5):
v = vmax * t / 4
yy = Y(v)
body += (f'<line x1="{ml}" y1="{yy:.1f}" x2="{W-mr}" y2="{yy:.1f}" stroke="{GRID}"/>'
f'<text x="{ml-6}" y="{yy+3:.1f}" font-size="9" fill="{MUTED}" '
f'text-anchor="end">{v:.0f}</text>')
for k in range(bins):
x = X(lo + k * bw)
w = X(lo + (k + 1) * bw) - x
body += (f'<rect x="{x:.1f}" y="{Y(counts[k]):.1f}" width="{w-1:.1f}" '
f'height="{Y(0)-Y(counts[k]):.1f}" fill="{ACCENT}" opacity="0.85"/>')
body += (f'<line x1="{X(mean):.1f}" y1="{mt}" x2="{X(mean):.1f}" y2="{mt+ph}" '
f'stroke="{ACCENT}" stroke-width="1.8"/>'
f'<line x1="{X(median):.1f}" y1="{mt}" x2="{X(median):.1f}" y2="{mt+ph}" '
f'stroke="{INK}" stroke-width="1.4" stroke-dasharray="5 3"/>')
for v in (lo, (lo + hi) / 2, hi):
body += (f'<text x="{X(v):.1f}" y="{mt+ph+14:.1f}" font-size="9" '
f'fill="{MUTED}" text-anchor="middle">{v:g}</text>')
leg = (f'<rect x="{W-108}" y="{mt}" width="10" height="10" fill="{ACCENT}"/>'
f'<text x="{W-94}" y="{mt+9}" font-size="10" fill="{MUTED}">평균</text>'
f'<rect x="{W-108}" y="{mt+15}" width="10" height="10" fill="{INK}"/>'
f'<text x="{W-94}" y="{mt+24}" font-size="10" fill="{MUTED}">중앙값</text>')
body += (leg + f'<text x="{ml+pw/2:.0f}" y="{H-4}" font-size="10" fill="{MUTED}" '
f'text-anchor="middle">{xlab}</text>'
f'<text x="6" y="{mt-4}" font-size="10" fill="{MUTED}">도수</text>')
return (f'<svg viewBox="0 0 {W} {H}" style="width:100%;height:auto;'
f'font-family:-apple-system,sans-serif;">{body}</svg>')
def bar_svg(counts, labels):
ml, mr, mt, mb = 42, 12, 16, 30
pw, ph = W - ml - mr, H - mt - mb
vmax = max(counts) or 1
def Y(c):
return mt + ph - (c / vmax) * ph
body = ""
for t in range(5):
v = vmax * t / 4
yy = Y(v)
body += (f'<line x1="{ml}" y1="{yy:.1f}" x2="{W-mr}" y2="{yy:.1f}" stroke="{GRID}"/>'
f'<text x="{ml-6}" y="{yy+3:.1f}" font-size="9" fill="{MUTED}" '
f'text-anchor="end">{v:.0f}</text>')
for i, (c, lab) in enumerate(zip(counts, labels)):
cx = ml + pw * (0.3 + 0.4 * i)
bw = pw * 0.2
body += (f'<rect x="{cx-bw/2:.1f}" y="{Y(c):.1f}" width="{bw:.1f}" '
f'height="{Y(0)-Y(c):.1f}" fill="#9ca3af"/>'
f'<text x="{cx:.1f}" y="{Y(c)-6:.1f}" font-size="11" fill="{INK}" '
f'text-anchor="middle">{c}</text>'
f'<text x="{cx:.1f}" y="{mt+ph+16:.1f}" font-size="11" fill="{MUTED}" '
f'text-anchor="middle">{lab}</text>')
body += f'<text x="6" y="{mt-4}" font-size="10" fill="{MUTED}">도수(명)</text>'
return (f'<svg viewBox="0 0 {W} {H}" style="width:100%;height:auto;'
f'font-family:-apple-system,sans-serif;">{body}</svg>')
app_ui = ui.page_sidebar(
ui.sidebar(
ui.input_select("var", "변수", {k: k for k in DATA}),
width=240,
),
ui.card(
ui.card_header("데이터 표"),
ui.output_ui("table"),
),
ui.card(
ui.card_header("분포"),
ui.output_ui("dist"),
),
ui.card(ui.output_ui("readout")),
fillable=True,
)
def server(input, output, session):
@render.ui
def table():
keys = list(DATA.keys())
th = 'style="padding:2px 6px;border-bottom:1px solid #9ca3af;text-align:center;"'
td = 'style="padding:2px 6px;border-bottom:1px solid #e5e7eb;text-align:center;"'
head = "".join(f"<th {th}>{k}</th>" for k in ["번호"] + keys)
rows = []
for i in range(25):
cells = f"<td {td}>{i + 1}</td>" + "".join(
f"<td {td}>{DATA[k][i]}</td>" for k in keys
)
rows.append(f"<tr>{cells}</tr>")
return ui.HTML(
'<div style="max-height:220px;overflow-y:auto;">'
'<table style="width:100%;border-collapse:collapse;font-size:0.8rem;">'
f"<thead><tr>{head}</tr></thead>"
f"<tbody>{''.join(rows)}</tbody></table></div>"
)
@render.ui
def dist():
v = input.var()
if v == "성별":
counts = [DATA["성별"].count("남"), DATA["성별"].count("여")]
return ui.HTML(bar_svg(counts, ["남", "여"]))
vals = DATA[v]
return ui.HTML(hist_svg(vals, v, st.mean(vals), st.median(vals)))
@render.ui
def readout():
v = input.var()
if v == "성별":
nm = DATA["성별"].count("남")
nf = DATA["성별"].count("여")
n = nm + nf
line = (f"남학생 {nm}명({nm / n * 100:.0f}%), "
f"여학생 {nf}명({nf / n * 100:.0f}%)으로 모두 {n}명입니다. "
"범주형 자료는 평균 대신 도수와 비율로 요약합니다.")
else:
vals = DATA[v]
line = (f"{v}의 평균은 {st.mean(vals):.1f}, 중앙값은 {st.median(vals):.1f}, "
f"표준편차는 {st.pstdev(vals):.1f}, 최솟값은 {min(vals):.1f}, "
f"최댓값은 {max(vals):.1f}입니다.")
return ui.HTML(f'<p class="hs-readline">{line}</p>')
app = App(app_ui, server)
요약값 계산은 표준 라이브러리 statistics 몇 줄이면 충분합니다(numpy 불필요).
import statistics as st
commute = [15, 30, 10, 45, 25, 5, 60, 20, 35, 12, 40, 8, 55,
18, 22, 70, 28, 14, 33, 48, 9, 26, 38, 16, 52]
print(st.mean(commute)) # 평균
print(st.median(commute)) # 중앙값
print(st.pstdev(commute)) # 표준편차
print(min(commute), max(commute)) # 최솟값, 최댓값스스로 확인
힌트문제 1 — 통학시간의 평균과 중앙값 중 어느 쪽이 더 큰가? 히스토그램 모양으로 이유를 설명해 보라.
평균(약 29.4분)이 중앙값(26분)보다 큽니다. 히스토그램을 보면 통학시간이 긴 쪽으로 꼬리가 길게 늘어져 있는데, 60분·70분 같은 큰 값이 평균을 끌어올리기 때문입니다. 분포가 한쪽으로 치우치면 평균과 중앙값이 벌어집니다.
힌트문제 2 — 성별을 선택하면 왜 평균·표준편차가 나오지 않는가?
성별은 수가 아니라 범주(남/여)인 범주형 변수이기 때문입니다. 범주형 자료는 각 범주의 도수와 비율로 요약하고, 그래프도 히스토그램이 아니라 막대그래프를 씁니다. 변수의 종류에 따라 요약 방법이 달라집니다.