표본평균의 분포

단원 04 통계적 추정 · 「확률과 통계」 · 1차시(45분)

개념

모평균 \(m\), 모표준편차 \(\sigma\)인 모집단에서 크기 \(n\)인 표본을 뽑을 때, 표본평균 \(\bar{X}\)는 표본마다 값이 달라지는 확률변수이고 그 자체로 분포를 가집니다.

\[E(\bar{X}) = m, \qquad \sigma(\bar{X}) = \frac{\sigma}{\sqrt{n}}\]

02단원에서 시행을 반복할수록 상대도수가 확률에 가까워지는 것을 보았습니다. 같은 원리로, \(n\)이 커질수록 표본평균은 모평균 \(m\) 주위로 점점 촘촘히 몰립니다.

놀라운 점은 모집단이 어떤 모양이든 — 균등하든, 한쪽으로 치우쳤든 — \(n\)이 충분히 크면 \(\bar{X}\)의 분포가 정규분포 \(N\!\left(m,\ \left(\dfrac{\sigma}{\sqrt{n}}\right)^{2}\right)\)에 가까워진다는 것입니다. 이것이 중심극한정리입니다. 아래 앱에서 모집단의 모양을 바꿔 가며 오른쪽 표본평균의 분포가 종 모양 곡선에 달라붙는 모습을 확인해 보세요.

만지며 배우기

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

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

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

M, SIGMA = 50, 10  # 모든 모집단을 m=50, σ=10으로 맞춤

def draw_one(dist):
    if dist == "unif":
        w = 10 * sqrt(3)
        return random.uniform(M - w, M + w)
    if dist == "skew":
        return 40 + random.expovariate(1 / 10)  # 지수분포(평균 10)
    return random.gauss(M, SIGMA)

def hist_svg(data, bins, color, title, xlab, m=None, se=None):
    W, H, ml, mr, mt, mb = 230, 290, 30, 10, 24, 26
    pw, ph = W - ml - mr, H - mt - mb
    lo, hi = min(data), max(data)
    if hi <= lo:
        hi = lo + 1
    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
    n = len(data)
    dens = [c / (n * bw) for c in counts]
    ymax = max(dens) or 1
    cpts = None
    if m is not None:
        cx = [lo + (hi - lo) * i / 120 for i in range(121)]
        cy = [exp(-((x - m) ** 2) / (2 * se * se)) / (se * sqrt(2 * pi)) for x in cx]
        ymax = max(ymax, max(cy))
        cpts = list(zip(cx, cy))
    def X(v):
        return ml + ((v - lo) / (hi - lo)) * pw
    def Y(v):
        return mt + ph - (v / ymax) * ph
    bars = ""
    for k, d in enumerate(dens):
        x = X(lo + k * bw)
        w = X(lo + (k + 1) * bw) - x
        bars += (f'<rect x="{x:.1f}" y="{Y(d):.1f}" width="{max(0.5,w-0.5):.1f}" '
                 f'height="{Y(0)-Y(d):.1f}" fill="{color}" opacity="0.85"/>')
    mline = (f'<line x1="{X(M):.1f}" y1="{mt}" x2="{X(M):.1f}" y2="{Y(0):.1f}" '
             f'stroke="#9ca3af" stroke-width="0.7" stroke-dasharray="2 3"/>'
             if lo <= M <= hi else "")
    curve = ""
    if cpts:
        curve = ('<path d="M' + " L".join(f"{X(x):.1f},{Y(y):.1f}" for x, y in cpts)
                 + f'" fill="none" stroke="{INK}" stroke-width="1.5"/>')
    return (
        f'<svg viewBox="0 0 {W} {H}" style="width:100%;height:auto;'
        'font-family:-apple-system,sans-serif;">'
        f'<line x1="{ml}" y1="{Y(0):.1f}" x2="{W-mr}" y2="{Y(0):.1f}" stroke="#9ca3af"/>'
        f'{bars}{mline}{curve}'
        f'<text x="{W/2:.0f}" y="15" font-size="11" fill="{INK}" '
        f'text-anchor="middle">{title}</text>'
        f'<text x="{W-mr:.0f}" y="{Y(0)+16:.0f}" font-size="10" fill="{MUTED}" '
        f'text-anchor="end">{xlab}</text>'
        '</svg>'
    )

app_ui = ui.page_sidebar(
    ui.sidebar(
        ui.input_select(
            "dist", "모집단 분포",
            {"unif": "균등분포", "skew": "치우친 분포(지수)", "norm": "정규분포"},
        ),
        ui.input_slider("n", "표본 크기 n", min=1, max=100, value=30),
        ui.input_slider("reps", "표본의 개수", min=100, max=2000, value=500, step=100),
        ui.input_action_button("redraw", "다시 추출"),
        width=240,
    ),
    ui.card(
        ui.card_header("모집단(왼쪽) vs 표본평균들의 분포(오른쪽)"),
        ui.output_ui("clt_plot"),
    ),
    ui.card(ui.output_ui("readout")),
    fillable=True,
)

def server(input, output, session):
    @reactive.calc
    def means():
        input.redraw()  # 버튼을 누르면 새로 추출
        dist, n, reps = input.dist(), input.n(), input.reps()
        return [sum(draw_one(dist) for _ in range(n)) / n for _ in range(reps)]

    @render.ui
    def clt_plot():
        n = input.n()
        pop = [draw_one(input.dist()) for _ in range(6000)]
        se = SIGMA / sqrt(n)
        left = hist_svg(pop, 40, "#d1d5db", "모집단", "x")
        right = hist_svg(means(), 30, ACCENT, "표본평균들", "x̄", m=M, se=se)
        return ui.HTML(
            '<div style="display:flex; gap:10px;">'
            f'<div style="flex:1;">{left}</div>'
            f'<div style="flex:1;">{right}</div></div>'
        )

    @render.ui
    def readout():
        n = input.n()
        se = SIGMA / sqrt(n)
        obs = st.pstdev(means())
        return ui.HTML(
            f'<p class="hs-readline">'
            f"이론값 σ/√n = 10/√{n} = {se:.2f} · "
            f"관측된 표본평균들의 표준편차 = {obs:.2f}. "
            f"모집단이 어떤 모양이든 n이 크면 표본평균의 분포는 "
            f"정규분포 N(50, (10/√{n})²)에 가깝습니다 — 중심극한정리.</p>"
        )

app = App(app_ui, server)

표본을 여러 번 뽑아 표본평균들의 표준편차를 이론값과 비교합니다.

import random, statistics as st
from math import sqrt

n = 30                                        # 표본 크기
def sample_mean():                            # 치우친 모집단(지수, 평균 50)
    return sum(40 + random.expovariate(1/10) for _ in range(n)) / n
xbar = [sample_mean() for _ in range(500)]    # 표본평균 500개

print(st.mean(xbar))        # ≈ 50  (= 모평균 m)
print(st.pstdev(xbar))      # ≈ 10 / sqrt(n) = 1.83 (= σ/√n)

스스로 확인

표본평균들의 분포가 \(m = 50\) 주위로 점점 좁게 몰립니다. 표준편차가 \(\sigma/\sqrt{n}\)이므로 \(n = 100\)이면 \(10/\sqrt{100} = 1\) — 모집단 표준편차의 \(\frac{1}{10}\)입니다. 또한 \(n = 1\)일 때는 모집단 모양 그대로이지만, \(n\)이 커지면 치우친 분포에서도 종 모양이 나타납니다.

\(n = 2\)에서는 표본평균의 분포도 오른쪽으로 치우쳐 곡선과 어긋나지만, \(n = 50\)이면 거의 완벽하게 겹칩니다. 중심극한정리가 말하는 “충분히 큰 \(n\)”의 효과입니다. 정규 모집단은 \(n\)이 작아도 처음부터 정확히 정규분포라는 점도 확인해 보세요.