산포도와 상자그림

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

개념

대푯값이 같아도 자료가 흩어진 정도는 다를 수 있습니다. 흩어진 정도를 재는 값이 산포도입니다. 각 값과 평균의 차이(편차)를 제곱해 평균 낸 것이 분산, 그 양의 제곱근이 표준편차입니다.

\[\sigma^2 = \frac{(x_1-m)^2 + (x_2-m)^2 + \cdots + (x_n-m)^2}{n}, \qquad \sigma = \sqrt{\sigma^2}\]

두 반의 수학 평균이 똑같이 70점이어도, 한 반은 모두 70점 근처이고 다른 반은 40점과 100점이 뒤섞여 있을 수 있습니다. 평균만 보면 이 차이가 전혀 보이지 않습니다.

자료를 크기순으로 4등분하는 값이 사분위수(\(Q_1, Q_2, Q_3\))이고, 이를 그림으로 요약한 것이 상자그림입니다. 상자의 아래·위 변이 \(Q_1\)\(Q_3\), 가운데 선이 중앙값 \(Q_2\)이며, 상자의 높이 \(Q_3 - Q_1\)사분위범위(IQR)입니다. 상자가 길수록, 수염이 넓게 퍼질수록 자료의 흩어짐이 큽니다. 아래 앱에서 평균이 같은 두 그룹의 표준편차를 다르게 바꿔 보세요.

만지며 배우기

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

# matplotlib·numpy 없이 stdlib(statistics) + 인라인 SVG 로만 그린다
import random
import statistics as st
from shiny import App, reactive, render, ui

ACCENT = "#2563eb"
INK = "#111827"
MUTED = "#6b7280"
GRID = "#e5e7eb"
N = 100  # 각 그룹의 자료 수

def box_stats(d):
    s = sorted(d)
    q1, q2, q3 = st.quantiles(s, n=4)
    iqr = q3 - q1
    lo, hi = q1 - 1.5 * iqr, q3 + 1.5 * iqr
    inl = [v for v in s if lo <= v <= hi]
    wlo, whi = (min(inl), max(inl)) if inl else (s[0], s[-1])
    out = [v for v in s if v < lo or v > hi]
    return q1, q2, q3, wlo, whi, out

def box_svg(a, b):
    W, H, ml, mr, mt, mb = 440, 340, 44, 14, 14, 28
    pw, ph = W - ml - mr, H - mt - mb
    allv = a + b
    ymin, ymax = min(allv), max(allv)
    pad = (ymax - ymin) * 0.07 or 1.0
    ymin, ymax = ymin - pad, ymax + pad
    def Y(v):
        return mt + ph - ((v - ymin) / (ymax - ymin)) * ph
    body = ""
    for t in range(5):
        val = ymin + (ymax - ymin) * t / 4
        yy = Y(val)
        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">{val:.0f}</text>')
    for cx, label, data in ((ml + pw * 0.30, "A", a), (ml + pw * 0.70, "B", b)):
        q1, q2, q3, wlo, whi, out = box_stats(data)
        hw = pw * 0.11
        for v in data:
            jx = cx + random.uniform(-hw * 0.7, hw * 0.7)
            body += (f'<circle cx="{jx:.1f}" cy="{Y(v):.1f}" r="2" '
                     f'fill="#9ca3af" opacity="0.25"/>')
        body += (f'<line x1="{cx:.1f}" y1="{Y(whi):.1f}" x2="{cx:.1f}" y2="{Y(q3):.1f}" stroke="{INK}"/>'
                 f'<line x1="{cx:.1f}" y1="{Y(wlo):.1f}" x2="{cx:.1f}" y2="{Y(q1):.1f}" stroke="{INK}"/>'
                 f'<line x1="{cx-hw*0.5:.1f}" y1="{Y(whi):.1f}" x2="{cx+hw*0.5:.1f}" y2="{Y(whi):.1f}" stroke="{INK}"/>'
                 f'<line x1="{cx-hw*0.5:.1f}" y1="{Y(wlo):.1f}" x2="{cx+hw*0.5:.1f}" y2="{Y(wlo):.1f}" stroke="{INK}"/>'
                 f'<rect x="{cx-hw:.1f}" y="{Y(q3):.1f}" width="{2*hw:.1f}" '
                 f'height="{Y(q1)-Y(q3):.1f}" fill="white" fill-opacity="0.6" stroke="{INK}"/>'
                 f'<line x1="{cx-hw:.1f}" y1="{Y(q2):.1f}" x2="{cx+hw:.1f}" y2="{Y(q2):.1f}" '
                 f'stroke="{ACCENT}" stroke-width="1.8"/>')
        for v in out:
            body += f'<circle cx="{cx:.1f}" cy="{Y(v):.1f}" r="2.5" fill="none" stroke="{INK}"/>'
        body += (f'<text x="{cx:.1f}" y="{mt+ph+16:.1f}" font-size="11" '
                 f'fill="{MUTED}" text-anchor="middle">{label}</text>')
    return (f'<svg viewBox="0 0 {W} {H}" style="width:100%;height:auto;'
            f'font-family:-apple-system,sans-serif;">{body}'
            f'<text x="10" y="{mt+8}" font-size="10" fill="{MUTED}">점수</text></svg>')

app_ui = ui.page_sidebar(
    ui.sidebar(
        ui.input_slider("sa", "그룹 A의 표준편차 σ_A", min=2, max=20, value=5, step=1),
        ui.input_slider("sb", "그룹 B의 표준편차 σ_B", min=2, max=20, value=12, step=1),
        ui.input_action_button("regen", "다시 생성"),
        width=240,
    ),
    ui.card(
        ui.card_header("상자그림 — 두 그룹 모두 평균 70점 근처입니다"),
        ui.output_ui("boxes"),
    ),
    ui.card(ui.output_ui("readout")),
    fillable=True,
)

def server(input, output, session):
    @reactive.calc
    def groups():
        input.regen()  # 버튼을 누르면 새로 생성
        a = [random.gauss(70, input.sa()) for _ in range(N)]
        b = [random.gauss(70, input.sb()) for _ in range(N)]
        return a, b

    @render.ui
    def boxes():
        a, b = groups()
        return ui.HTML(box_svg(a, b))

    @render.ui
    def readout():
        a, b = groups()
        sa, sb = st.pstdev(a), st.pstdev(b)
        qa, qb = st.quantiles(a, n=4), st.quantiles(b, n=4)
        iqr_a, iqr_b = qa[2] - qa[0], qb[2] - qb[0]
        wide = "B" if sb > sa else "A"
        return ui.HTML(
            '<p class="hs-readline">'
            f"A: 평균 {st.mean(a):.1f} · 표준편차 {sa:.2f} · 분산 {sa**2:.1f} · IQR {iqr_a:.1f} — "
            f"B: 평균 {st.mean(b):.1f} · 표준편차 {sb:.2f} · 분산 {sb**2:.1f} · IQR {iqr_b:.1f}. "
            f"두 그룹의 평균은 거의 같아도 {wide} 그룹의 상자와 수염이 더 길어, "
            "흩어짐(산포)이 더 크다는 것을 한눈에 볼 수 있습니다.</p>"
        )

app = App(app_ui, server)

표준편차·분산·사분위수는 표준 라이브러리 statistics로 구합니다(numpy 불필요).

import random, statistics as st

a = [random.gauss(70, 5) for _ in range(100)]    # 평균 70, 표준편차 5
b = [random.gauss(70, 12) for _ in range(100)]   # 평균은 같고 더 넓게 퍼진

print(st.pstdev(a), st.pstdev(b))   # 표준편차 σ
print(st.pvariance(a), st.pvariance(b))  # 분산 σ²

q1, q2, q3 = st.quantiles(b, n=4)   # 사분위수 Q1, Q2, Q3
print(q3 - q1)                      # 사분위범위 IQR

스스로 확인

아닙니다. 같은 분포에서 뽑아도 매번 다른 100명이 뽑히므로 상자그림은 조금씩 다르게 나옵니다. 다만 상자의 위치와 길이는 비슷한 범위 안에서 움직입니다. 이 “표본마다 달라지는 정도”는 04단원 통계적 추정의 출발점입니다.

상자(\(Q_1\)~\(Q_3\))가 세로로 길어지고 수염도 넓게 퍼집니다. 즉 IQR과 전체 범위가 함께 커집니다. 반대로 중앙값(상자 속 선)의 위치는 평균이 70으로 같으므로 거의 움직이지 않습니다.