여론조사 읽기

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

개념

선거철 뉴스에는 “지지율 45%, 오차범위 ±3.1%p(95% 신뢰수준)” 같은 문장이 자주 나옵니다. 오차범위는 표본으로 조사한 지지율이 실제 지지율과 얼마나 다를 수 있는지의 폭이고, 신뢰수준은 그 폭을 믿을 수 있는 정도입니다. 두 후보의 격차가 오차범위 안이라면 “누가 앞선다”고 단정할 수 없고 경합으로 읽어야 합니다.

이 오차범위 계산의 근거가 바로 04단원 모비율의 추정입니다. 최대오차는 \(z\sqrt{0.25/n}\)으로 구합니다.

아래 앱은 가상의 선거 여론조사입니다. 응답자 수와 지지율, 신뢰수준을 바꿔 가며 오차범위가 어떻게 변하고, 같은 격차가 언제 “경합”이 되고 언제 “우세”가 되는지 확인해 보세요.

만지며 배우기

#| '!! 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 + 인라인 SVG 로만 그린다
from math import sqrt
from shiny import App, render, ui

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

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

def poll_svg(a, b, moe):
    W, H, ml, mr, mt, mb = 340, 300, 42, 14, 16, 30
    pw, ph = W - ml - mr, H - mt - mb
    ymax = 80
    def Y(v):
        return mt + ph - (v / ymax) * ph
    grid = ""
    for t in range(0, 81, 20):
        yy = Y(t)
        grid += (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">{t}</text>')
    bw = pw * 0.24
    body = ""
    for cx, val, col, lab in ((ml + pw * 0.32, a, ACCENT, "A"),
                              (ml + pw * 0.68, b, "#9ca3af", "B")):
        body += (f'<rect x="{cx-bw/2:.1f}" y="{Y(val):.1f}" width="{bw:.1f}" '
                 f'height="{Y(0)-Y(val):.1f}" fill="{col}" opacity="0.9"/>')
        top, bot = min(ymax, val + moe), max(0, val - moe)
        body += (f'<line x1="{cx:.1f}" y1="{Y(bot):.1f}" x2="{cx:.1f}" '
                 f'y2="{Y(top):.1f}" stroke="{INK}" stroke-width="1.3"/>'
                 f'<line x1="{cx-7:.1f}" y1="{Y(top):.1f}" x2="{cx+7:.1f}" '
                 f'y2="{Y(top):.1f}" stroke="{INK}" stroke-width="1.3"/>'
                 f'<line x1="{cx-7:.1f}" y1="{Y(bot):.1f}" x2="{cx+7:.1f}" '
                 f'y2="{Y(bot):.1f}" stroke="{INK}" stroke-width="1.3"/>'
                 f'<text x="{cx:.1f}" y="{Y(val)-10:.1f}" font-size="12" '
                 f'font-weight="600" fill="{INK}" text-anchor="middle">{val}%</text>'
                 f'<text x="{cx:.1f}" y="{mt+ph+16:.1f}" font-size="11" '
                 f'fill="{MUTED}" text-anchor="middle">{lab}</text>')
    return (f'<svg viewBox="0 0 {W} {H}" style="width:100%;height:auto;'
            f'font-family:-apple-system,sans-serif;">{grid}{body}'
            f'<text x="8" y="{mt-2}" font-size="10" fill="{MUTED}">지지율(%)</text></svg>')

app_ui = ui.page_sidebar(
    ui.sidebar(
        ui.input_slider("n", "응답자 수 n", min=300, max=3000,
                        value=1000, step=100),
        ui.input_slider("a", "후보 A 지지율(%)", min=30, max=70,
                        value=45, step=1),
        ui.input_select("level", "신뢰수준", {"95": "95%", "99": "99%"}),
        width=240,
    ),
    ui.card(
        ui.card_header("가상 여론조사 — 막대 위 선이 오차범위"),
        ui.output_ui("bars"),
    ),
    ui.card(ui.output_ui("readout")),
    fillable=True,
)

def server(input, output, session):
    @render.ui
    def bars():
        a = input.a()
        b = 90 - a          # 무응답 10% 고정
        moe = Z[input.level()] * sqrt(0.25 / input.n()) * 100
        return ui.HTML(poll_svg(a, b, moe))

    @render.ui
    def readout():
        a = input.a()
        b = 90 - a
        moe = Z[input.level()] * sqrt(0.25 / input.n()) * 100
        gap = abs(a - b)
        leader = "A" if a > b else "B"
        if gap <= 2 * moe:
            verdict = (f"두 후보 격차 {gap}%p가 오차범위(2배 기준 "
                       f"{2 * moe:.1f}%p) 안이므로 '경합'으로 읽어야 합니다.")
        else:
            verdict = (f"두 후보 격차 {gap}%p가 오차범위(2배 기준 "
                       f"{2 * moe:.1f}%p) 밖이므로 {leader}가 앞선다고 "
                       "볼 수 있습니다.")
        line = (f"오차범위 ±{moe:.1f}%p ({input.level()}% 신뢰수준) — {verdict} "
                "기사를 읽을 때는 조사기관·응답률·조사기간도 꼭 확인하세요.")
        return ui.HTML(f'<p class="hs-readline">{line}</p>')

app = App(app_ui, server)

오차범위(최대오차) 계산은 한 줄입니다.

from math import sqrt

n = 1000                    # 응답자 수
z = 1.96                    # 95% 신뢰수준 (99%는 2.576)

moe = z * sqrt(0.25 / n) * 100   # 최대오차 ±%p
print(f"±{moe:.1f}%p")           # ±3.1%p

a, b = 45, 45               # 두 후보 지지율(%)
print(abs(a - b) <= 2 * moe)     # True면 '경합'

스스로 확인

오차범위는 \(\sqrt{n}\)에 반비례하므로 절반이 됩니다. 앱에서 n을 500에서 2000으로 바꿔 확인해 보세요. 오차범위를 절반으로 줄이려면 조사 비용은 4배가 든다는 뜻이기도 합니다.

오차범위 안의 격차는 표본을 다시 뽑으면 뒤집힐 수도 있는, 통계적으로 구분할 수 없는 차이이기 때문입니다. 실제 지지율은 그대로인데 표본의 우연한 변동만으로 순위가 바뀌어 보일 수 있으므로, ’역전’이 아니라 ’경합’으로 읽는 것이 올바른 해석입니다.