데이터 탐색기

단원 05 통계와 사회 · 「통계와 사회」 · 1차시(45분)

개념

자료를 손에 넣었을 때 가장 먼저 할 일은 탐색입니다. 탐색의 기본은 자료를 세 가지 방식으로 보는 것입니다. 로 원자료를 하나하나 확인하고, 그래프로 분포의 전체 모양을 살피고, 평균·중앙값·표준편차 같은 요약값으로 특징을 숫자 몇 개로 압축합니다.

아래 데이터는 교육용으로 만든 가상 데이터입니다. 실제 탐구에서는 우리 반 친구들에게 직접 설문해서 나만의 데이터를 모아 보세요.

세 방식은 서로를 보완합니다. 표만 보면 전체 모양이 안 보이고, 요약값만 보면 특이한 값이 숨습니다. 아래 앱에서 가상의 고등학생 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))  # 최솟값, 최댓값

스스로 확인

평균(약 29.4분)이 중앙값(26분)보다 큽니다. 히스토그램을 보면 통학시간이 긴 쪽으로 꼬리가 길게 늘어져 있는데, 60분·70분 같은 큰 값이 평균을 끌어올리기 때문입니다. 분포가 한쪽으로 치우치면 평균과 중앙값이 벌어집니다.

성별은 수가 아니라 범주(남/여)인 범주형 변수이기 때문입니다. 범주형 자료는 각 범주의 도수와 비율로 요약하고, 그래프도 히스토그램이 아니라 막대그래프를 씁니다. 변수의 종류에 따라 요약 방법이 달라집니다.