도수분포표와 히스토그램

단원 01 자료와 그래프 · 중학 통계 복습 · 1차시(45분)

개념

자료를 일정한 구간(계급)으로 나누고, 각 계급에 속하는 자료의 개수(도수)를 센 표가 도수분포표입니다. 각 계급의 도수를 전체 자료 수로 나눈 값이 상대도수이며, 상대도수를 모두 더하면 항상 1이 됩니다.

\[\text{상대도수} = \frac{\text{그 계급의 도수}}{\text{전체 도수}}\]

“우리 반 학생들의 하루 스마트폰 사용 시간”처럼 학급 설문으로 모은 자료가 바로 이런 표와 그래프의 좋은 재료입니다.

도수분포표를 그래프로 옮긴 것이 히스토그램입니다. 가로축은 계급, 세로축은 도수(또는 상대도수)이고, 계급이 연속이므로 막대 사이를 붙여 그립니다. 계급의 크기를 어떻게 잡느냐에 따라 같은 자료도 전혀 다르게 보일 수 있습니다. 아래 앱에서 직접 확인해 보세요.

만지며 배우기

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| viewerHeight: 700

# matplotlib·numpy 없이 stdlib + 인라인 SVG 로만 그린다
# (다운로드 약 12 MB 절감 + Pyodide 한글 폰트 문제 근본 해소)
import math
from shiny import App, render, ui

ACCENT = "#2563eb"
INK = "#111827"
MUTED = "#6b7280"
GRID = "#e5e7eb"

# 학생 40명의 하루 스마트폰 사용 시간(분)
DATA = [
    42, 55, 63, 71, 78, 84, 90, 96,
    101, 107, 112, 118, 121, 125, 128, 132,
    135, 139, 142, 146, 149, 153, 157, 161,
    164, 168, 173, 179, 185, 192, 200, 209,
    218, 228, 238, 249, 258, 267, 276, 288,
]
LO = 40  # 첫 계급의 시작값

def bin_edges(width):
    n_bins = int(math.ceil((max(DATA) - LO) / width))
    return [LO + width * i for i in range(n_bins + 1)]

def histogram(edges):
    counts = [0] * (len(edges) - 1)
    last = len(edges) - 2
    for x in DATA:
        for i in range(len(edges) - 1):
            if edges[i] <= x < edges[i + 1] or (i == last and x == edges[-1]):
                counts[i] += 1
                break
    return counts

def hist_svg(edges, counts, relative):
    n = len(DATA)
    vals = [c / n for c in counts] if relative else list(counts)
    W, H, ml, mr, mt, mb = 480, 300, 46, 12, 14, 42
    pw, ph = W - ml - mr, H - mt - mb
    vmax = max(vals) or 1
    nb = len(counts)
    bw = pw / nb
    grid = ""
    for t in range(5):
        v = vmax * t / 4
        yy = mt + ph - (v / vmax) * ph
        lbl = f"{v:.2f}" if relative else f"{int(round(v))}"
        grid += (f'<line x1="{ml}" y1="{yy:.1f}" x2="{W-mr}" y2="{yy:.1f}" '
                 f'stroke="{GRID}"/>'
                 f'<text x="{ml-6}" y="{yy+3:.1f}" font-size="10" fill="{MUTED}" '
                 f'text-anchor="end">{lbl}</text>')
    bars = ""
    for i, v in enumerate(vals):
        bh = (v / vmax) * ph
        x = ml + i * bw
        y = mt + ph - bh
        bars += (f'<rect x="{x:.1f}" y="{y:.1f}" width="{bw-1:.1f}" '
                 f'height="{bh:.1f}" fill="{ACCENT}" opacity="0.85" '
                 f'stroke="{INK}" stroke-width="0.6"/>')
    xlab = ""
    for i in range(nb + 1):
        xx = ml + i * bw
        xlab += (f'<text x="{xx:.1f}" y="{mt+ph+14:.1f}" font-size="9" '
                 f'fill="{MUTED}" text-anchor="middle">{edges[i]}</text>')
    ylab = "상대도수" if relative else "도수(명)"
    return (
        f'<svg viewBox="0 0 {W} {H}" style="width:100%;height:auto;'
        'font-family:-apple-system,sans-serif;">'
        f'{grid}{bars}{xlab}'
        f'<text x="{ml+pw/2:.0f}" y="{H-4}" font-size="10" fill="{MUTED}" '
        'text-anchor="middle">사용 시간(분)</text>'
        f'<text x="10" y="{mt-2}" font-size="10" fill="{MUTED}">{ylab}</text>'
        '</svg>'
    )

app_ui = ui.page_sidebar(
    ui.sidebar(
        ui.input_slider("width", "계급의 크기(분)", min=10, max=60, value=30, step=10),
        ui.input_checkbox("relative", "상대도수로 보기", False),
        width=240,
    ),
    ui.card(
        ui.card_header("히스토그램 — 계급의 크기에 따라 모양이 달라집니다"),
        ui.output_ui("hist"),
    ),
    ui.card(ui.output_ui("readout")),
    fillable=True,
)

def server(input, output, session):
    @render.ui
    def hist():
        edges = bin_edges(input.width())
        counts = histogram(edges)
        return ui.HTML(hist_svg(edges, counts, input.relative()))

    @render.ui
    def readout():
        edges = bin_edges(input.width())
        counts = histogram(edges)
        n = len(DATA)

        td = 'style="border:1px solid #d1d5db; padding:2px 10px; text-align:center;"'
        rows = ""
        for i, c in enumerate(counts):
            rows += (f"<tr><td {td}>{edges[i]} 이상 ~ {edges[i+1]} 미만</td>"
                     f"<td {td}>{c}</td>"
                     f"<td {td}>{c / n:.3f}</td></tr>")
        rows += (f"<tr><td {td}><b>합계</b></td>"
                 f"<td {td}><b>{n}</b></td>"
                 f"<td {td}><b>1.000</b></td></tr>")
        table = (
            '<table style="border-collapse:collapse; margin:0 auto; font-size:0.9em;">'
            f"<tr><th {td}>계급(분)</th><th {td}>도수(명)</th><th {td}>상대도수</th></tr>"
            f"{rows}</table>"
        )
        return ui.HTML(
            table
            + '<p class="hs-readline">'
            f"계급의 크기가 {input.width()}분일 때 계급은 모두 {len(counts)}개입니다. "
            "계급의 크기를 키우면 표와 그래프는 단순해지지만, "
            "분포의 세부 모양은 점점 뭉개집니다.</p>"
        )

app = App(app_ui, server)

도수분포표는 표준 라이브러리만으로 셀 수 있습니다(numpy 불필요).

data = [42, 55, 63, 71, 78, 84, 90, 96, 101, 107]  # 사용 시간(분)
edges = [40, 70, 100, 130]                          # 계급의 경계 (크기 30)

counts = [0] * (len(edges) - 1)
for x in data:
    for i in range(len(edges) - 1):
        if edges[i] <= x < edges[i + 1]:
            counts[i] += 1
            break

for i, c in enumerate(counts):
    print(f"{edges[i]} 이상 ~ {edges[i+1]} 미만: 도수 {c}, "
          f"상대도수 {c / len(data):.2f}")

스스로 확인

계급이 좁으면 막대가 많아져 자료의 세부가 드러나지만 들쭉날쭉해지고, 계급이 넓으면 매끈해지는 대신 분포의 모양(어디에 몰려 있는지)이 뭉개집니다. 자료의 특징이 가장 잘 보이는 “적당한” 계급의 크기를 직접 찾아보세요.

상대도수는 모든 도수를 같은 수(전체 도수 40)로 나눈 값이라, 세로축의 눈금만 바뀌고 막대들 사이의 비율은 그대로이기 때문입니다. 상대도수는 전체 자료 수가 다른 두 집단을 비교할 때 특히 유용합니다.