여론조사 읽기
개념
선거철 뉴스에는 “지지율 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면 '경합'스스로 확인
힌트문제 1 — 응답자 수를 4배로 늘리면 오차범위는 어떻게 변하는가?
오차범위는 \(\sqrt{n}\)에 반비례하므로 절반이 됩니다. 앱에서 n을 500에서 2000으로 바꿔 확인해 보세요. 오차범위를 절반으로 줄이려면 조사 비용은 4배가 든다는 뜻이기도 합니다.
힌트문제 2 — 격차가 오차범위 안인데 ’A 후보 역전!’이라고 보도하면 왜 문제인가?
오차범위 안의 격차는 표본을 다시 뽑으면 뒤집힐 수도 있는, 통계적으로 구분할 수 없는 차이이기 때문입니다. 실제 지지율은 그대로인데 표본의 우연한 변동만으로 순위가 바뀌어 보일 수 있으므로, ’역전’이 아니라 ’경합’으로 읽는 것이 올바른 해석입니다.