산점도와 상관관계

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

개념

키와 몸무게처럼 짝을 이룬 두 변량을 좌표평면 위의 점으로 나타낸 그래프가 산점도입니다. 한 변량이 커질 때 다른 변량도 커지는 경향이 있으면 양의 상관관계, 반대로 작아지는 경향이 있으면 음의 상관관계가 있다고 합니다.

\[-1 \;\le\; r \;\le\; 1\]

상관관계가 있다고 해서 인과관계가 있는 것은 아닙니다. 아이스크림 판매량과 물놀이 사고는 함께 늘지만, 원인은 둘 다 “더운 날씨”입니다.

상관관계의 방향과 강도를 하나의 수로 나타낸 것이 상관계수 \(r\)입니다. \(r\)이 1에 가까울수록 점들이 오른쪽 위로 향하는 직선에 가깝게 모이고, \(-1\)에 가까울수록 오른쪽 아래로 향하는 직선에 모이며, 0 근처이면 뚜렷한 직선 경향이 없습니다. 아래 앱에서 \(r\)을 움직여 산점도의 모양이 어떻게 변하는지 관찰해 보세요.

만지며 배우기

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

ACCENT = "#2563eb"
INK = "#111827"
MUTED = "#6b7280"
GRID = "#e5e7eb"
LO, HI = -3.5, 3.5

def fit(x, y):
    # 표본상관계수 r, 최소제곱 추세선(기울기·절편)을 stdlib 로 계산
    n = len(x)
    mx, my = sum(x) / n, sum(y) / n
    sxy = sum((a - mx) * (b - my) for a, b in zip(x, y))
    sxx = sum((a - mx) ** 2 for a in x)
    syy = sum((b - my) ** 2 for b in y)
    if sxx == 0 or syy == 0:
        return 0.0, 0.0, my
    return sxy / sqrt(sxx * syy), sxy / sxx, my - (sxy / sxx) * mx

def scatter_svg(x, y, trend, slope, intercept):
    W, H, ml, mr, mt, mb = 440, 340, 26, 14, 14, 28
    pw, ph = W - ml - mr, H - mt - mb
    def X(v):
        return ml + ((v - LO) / (HI - LO)) * pw
    def Y(v):
        return mt + ph - ((v - LO) / (HI - LO)) * ph
    axes = (f'<line x1="{X(LO):.1f}" y1="{Y(0):.1f}" x2="{X(HI):.1f}" y2="{Y(0):.1f}" stroke="{GRID}"/>'
            f'<line x1="{X(0):.1f}" y1="{Y(LO):.1f}" x2="{X(0):.1f}" y2="{Y(HI):.1f}" stroke="{GRID}"/>')
    dots = "".join(
        f'<circle cx="{X(a):.1f}" cy="{Y(b):.1f}" r="3.4" fill="{ACCENT}" opacity="0.7"/>'
        for a, b in zip(x, y) if LO <= a <= HI and LO <= b <= HI
    )
    line = ""
    if trend:
        line = (f'<line x1="{X(LO):.1f}" y1="{Y(slope*LO+intercept):.1f}" '
                f'x2="{X(HI):.1f}" y2="{Y(slope*HI+intercept):.1f}" '
                f'stroke="{INK}" stroke-width="1.6"/>')
    return (
        f'<svg viewBox="0 0 {W} {H}" style="width:100%;height:auto;'
        'font-family:-apple-system,sans-serif;">'
        f'{axes}{line}{dots}'
        f'<text x="{X(HI)-4:.0f}" y="{Y(0)-6:.0f}" font-size="11" fill="{MUTED}" '
        'text-anchor="end">x</text>'
        f'<text x="{X(0)+8:.0f}" y="{mt+10:.0f}" font-size="11" fill="{MUTED}">y</text>'
        '</svg>'
    )

app_ui = ui.page_sidebar(
    ui.sidebar(
        ui.input_slider("r", "상관계수 r", min=-1.0, max=1.0, value=0.7, step=0.05),
        ui.input_slider("n", "점의 개수 n", min=20, max=200, value=60, step=5),
        ui.input_checkbox("trend", "추세선 보기", False),
        ui.input_action_button("regen", "다시 생성"),
        width=240,
    ),
    ui.card(
        ui.card_header("산점도 — r이 점들의 모양을 정합니다"),
        ui.output_ui("cloud"),
    ),
    ui.card(ui.output_ui("readout")),
    fillable=True,
)

def server(input, output, session):
    @reactive.calc
    def points():
        input.regen()  # 버튼을 누르면 새로 생성
        r, n = input.r(), input.n()
        x = [random.gauss(0, 1) for _ in range(n)]
        e = [random.gauss(0, 1) for _ in range(n)]
        y = [r * a + sqrt(max(0.0, 1 - r * r)) * ei for a, ei in zip(x, e)]
        return x, y

    @render.ui
    def cloud():
        x, y = points()
        rs, slope, intercept = fit(x, y)
        return ui.HTML(scatter_svg(x, y, input.trend(), slope, intercept))

    @render.ui
    def readout():
        x, y = points()
        rs, _, _ = fit(x, y)

        if abs(rs) < 0.1:
            desc = "상관관계가 거의 없습니다"
        else:
            direction = "양" if rs > 0 else "음"
            if abs(rs) >= 0.7:
                strength = "강한"
            elif abs(rs) >= 0.3:
                strength = "어느 정도의"
            else:
                strength = "약한"
            desc = f"{strength} {direction}의 상관관계가 보입니다"
        return ui.HTML(
            '<p class="hs-readline">'
            f"설정한 r = {input.r():.2f} · 이 표본에서 계산한 상관계수 = {rs:.3f} — {desc}. "
            "점의 개수 n이 작을수록 두 값의 차이가 커지고, "
            "'다시 생성'을 누를 때마다 표본상관계수가 조금씩 달라집니다.</p>"
        )

app = App(app_ui, server)

상관계수가 \(r\)인 자료를 만들고 확인하는 핵심 코드입니다.

import random
from math import sqrt

r, n = 0.7, 60
x = [random.gauss(0, 1) for _ in range(n)]
e = [random.gauss(0, 1) for _ in range(n)]
y = [r * a + sqrt(1 - r * r) * ei for a, ei in zip(x, e)]

# 표본상관계수 (피어슨) — numpy 없이
mx, my = sum(x) / n, sum(y) / n
sxy = sum((a - mx) * (b - my) for a, b in zip(x, y))
sxx = sum((a - mx) ** 2 for a in x)
syy = sum((b - my) ** 2 for b in y)
print(sxy / sqrt(sxx * syy))        # r 에 가까움
print(sxy / sxx, my - sxy / sxx * mx)  # 추세선 기울기·절편

스스로 확인

모든 점이 하나의 직선 위에 완벽하게 늘어섭니다. \(r = 1\)이면 오른쪽 위로, \(r = -1\)이면 오른쪽 아래로 향하는 직선입니다. 상관계수의 절댓값은 “점들이 직선에 얼마나 가깝게 모이는가”를 재는 수임을 확인해 보세요.

표본이 작으면 계산된 상관계수가 0 근처부터 0.6 이상까지 크게 출렁입니다. 심지어 부호가 뒤집히기도 합니다. 적은 자료에서 관찰한 상관관계를 성급하게 믿으면 안 되는 이유입니다. n을 200으로 키워 비교해 보세요.