모비율의 추정

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

개념

찬성 비율, 지지율처럼 모집단에서 어떤 성질을 가진 비율 \(p\)(모비율)를 추정할 때는, \(n\)명을 조사해 얻은 표본비율 \(\hat{p}\)을 사용합니다. \(n\)이 충분히 크면 모비율 \(p\)의 신뢰구간은 다음과 같습니다.

\[\hat{p} - z\sqrt{\frac{\hat{p}(1-\hat{p})}{n}} \;\le\; p \;\le\; \hat{p} + z\sqrt{\frac{\hat{p}(1-\hat{p})}{n}} \qquad (z = 1.96,\ 2.576)\]

여기서 익힌 오차범위 읽기는 다음 모듈(05 여론조사 읽기)에서 실제 뉴스 기사 해석으로 이어집니다.

\(\pm\) 뒤의 값 \(z\sqrt{\hat{p}(1-\hat{p})/n}\)이 뉴스에서 말하는 오차범위입니다. “지지율 52%, 오차범위 ±3.1%p”라면 진짜 지지율은 48.9%~55.1% 어디쯤이라는 뜻이므로, 구간이 50%를 걸치면 누가 앞선다고 단정할 수 없습니다. 아래 앱에서 \(n\)\(\hat{p}\)을 움직여 오차범위가 어떻게 변하는지 확인해 보세요.

만지며 배우기

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

# matplotlib·numpy 없이 stdlib + 인라인 SVG 로만 그린다
from math import sqrt
from shiny import App, render, ui

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

Z = {"95": 1.96, "99": 2.576}

def ci_svg(phat, lo, hi, margin):
    W, H, ml, mr, mt, mb = 470, 220, 16, 16, 44, 34
    pw, ph = W - ml - mr, H - mt - mb
    xlo = max(0.0, phat - 5 * margin)
    xhi = min(1.0, phat + 5 * margin)
    if xhi <= xlo:
        xhi = xlo + 0.01
    def X(v):
        return ml + ((v - xlo) / (xhi - xlo)) * pw
    cy = mt + ph / 2
    body = (f'<line x1="{X(lo):.1f}" y1="{cy:.1f}" x2="{X(hi):.1f}" y2="{cy:.1f}" '
            f'stroke="{ACCENT}" stroke-width="7" opacity="0.85" stroke-linecap="round"/>'
            f'<circle cx="{X(phat):.1f}" cy="{cy:.1f}" r="6" fill="{ACCENT}"/>'
            f'<text x="{X(phat):.1f}" y="{cy-16:.1f}" font-size="11" fill="{INK}" '
            f'text-anchor="middle">p̂ = {phat*100:.0f}%</text>')
    if xlo <= 0.5 <= xhi:
        body += (f'<line x1="{X(0.5):.1f}" y1="{mt}" x2="{X(0.5):.1f}" y2="{mt+ph}" '
                 f'stroke="{INK}" stroke-width="1" stroke-dasharray="5 3"/>'
                 f'<text x="{X(0.5):.1f}" y="{mt-4:.0f}" font-size="10" fill="{INK}" '
                 f'text-anchor="middle">50%</text>')
    ticks = ""
    for i in range(5):
        v = xlo + (xhi - xlo) * i / 4
        ticks += (f'<text x="{X(v):.1f}" y="{mt+ph+16:.0f}" font-size="9" '
                  f'fill="{MUTED}" text-anchor="middle">{v*100:.0f}%</text>')
    return (f'<svg viewBox="0 0 {W} {H}" style="width:100%;height:auto;'
            f'font-family:-apple-system,sans-serif;">'
            f'<line x1="{ml}" y1="{mt+ph:.0f}" x2="{W-mr}" y2="{mt+ph:.0f}" stroke="#9ca3af"/>'
            f'{body}{ticks}'
            f'<text x="{W-mr:.0f}" y="{H-6}" font-size="10" fill="{MUTED}" '
            f'text-anchor="end">p</text></svg>')

app_ui = ui.page_sidebar(
    ui.sidebar(
        ui.input_slider("n", "응답자 수 n", min=100, max=3000, value=1000, step=100),
        ui.input_slider("phat", "표본비율 p̂", min=0.10, max=0.90, value=0.52, step=0.01),
        ui.input_select("level", "신뢰수준", {"95": "95%", "99": "99%"}),
        width=240,
    ),
    ui.card(
        ui.card_header("모비율 p의 신뢰구간 — 점선은 50% 기준선"),
        ui.output_ui("ci_plot"),
    ),
    ui.card(ui.output_ui("readout")),
    fillable=True,
)

def server(input, output, session):
    @render.ui
    def ci_plot():
        n, phat = input.n(), input.phat()
        margin = Z[input.level()] * sqrt(phat * (1 - phat) / n)
        return ui.HTML(ci_svg(phat, phat - margin, phat + margin, margin))

    @render.ui
    def readout():
        n, phat = input.n(), input.phat()
        level = input.level()
        z = Z[level]
        margin = z * sqrt(phat * (1 - phat) / n)
        lo, hi = phat - margin, phat + margin
        if lo <= 0.5 <= hi:
            verdict = ("구간이 50%를 걸치므로 절반보다 많다(우세)고 "
                       "단정할 수 없습니다.")
        elif lo > 0.5:
            verdict = "구간 전체가 50%보다 위에 있어 절반을 넘는다고 볼 수 있습니다."
        else:
            verdict = "구간 전체가 50%보다 아래에 있어 절반에 못 미친다고 볼 수 있습니다."
        return ui.HTML(
            f'<p class="hs-readline">'
            f"표본비율 {phat * 100:.1f}%, {level}% 신뢰구간 "
            f"[{lo * 100:.1f}%, {hi * 100:.1f}%] — 오차범위 ±{margin * 100:.1f}%p. "
            f"n을 4배로 늘리면 오차범위는 절반이 됩니다. {verdict}</p>"
        )

app = App(app_ui, server)

여론조사 기사의 오차범위는 이 한 줄 계산에서 나옵니다.

from math import sqrt

n, phat, z = 1000, 0.52, 1.96          # 응답자 1000명, 표본비율 52%, 95%

margin = z * sqrt(phat * (1 - phat) / n)   # 오차범위 ≈ 0.031 (±3.1%p)
lo, hi = phat - margin, phat + margin

print(f"{lo:.3f} ~ {hi:.3f}")              # 0.489 ~ 0.551 → 50%를 걸친다

스스로 확인

오차범위가 약 \(\pm 3.1\)%p라 구간 \([48.9\%, 55.1\%]\)이 50%를 걸치기 때문입니다. 앱에서 \(n\)을 키워 보면 \(n = 2500\) 근처부터 구간의 아래끝이 50%를 넘어섭니다(\(\pm 2.0\)%p 이하). 오차범위는 \(\sqrt{n}\)에 반비례하므로 \(n\)을 4배로 하면 절반이 됩니다.

\(\hat{p} = 0.5\)에서 가장 큽니다. \(\hat{p}(1-\hat{p})\)\(0.5 \times 0.5 = 0.25\)로 최대가 되기 때문입니다. 그래서 여론조사 기관은 안전하게 \(\hat{p} = 0.5\)를 가정한 최대 오차범위를 함께 표기하곤 합니다.