통계 그래프 만들기

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

개념

좋은 그래프는 질문에서 출발합니다. “분포의 모양이 어떤가?”에는 히스토그램, “두 집단이 다른가?”에는 상자그림, “두 변수가 함께 변하는가?”에는 산점도, “각 범주가 몇 개인가?”에는 막대그래프가 맞습니다. 그래프 종류를 먼저 고르는 게 아니라, 알고 싶은 질문을 먼저 정하고 거기에 맞는 그래프를 고르는 것이 순서입니다.

3차원 효과를 넣거나 세로축 눈금을 0이 아닌 값에서 시작하는 그래프는 차이를 실제보다 부풀려 보이게 하는 잘못된 그래프의 대표 사례입니다.

아래 앱은 데이터 탐색기와 같은 교육용 예시(가상) 데이터(고등학생 25명)를 씁니다. 그래프 종류와 변수를 바꿔 가며 각 그래프가 어떤 질문에 답하는지 확인해 보세요.

만지며 배우기

#| '!! 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(statistics) + 인라인 SVG 로만 그린다
import statistics as st
from shiny import App, render, ui

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

# 교육용 예시(가상) 데이터 — 고등학생 25명
DATA = {
    "키(cm)": [172.5, 158.3, 165.0, 181.2, 160.4, 175.8, 168.9, 155.2,
               178.4, 162.7, 170.1, 152.8, 183.6, 166.3, 159.5, 174.2,
               161.8, 169.4, 187.0, 157.6, 171.3, 164.5, 176.9, 153.9, 167.2],
    "통학시간(분)": [15, 30, 10, 45, 25, 5, 60, 20, 35, 12, 40, 8, 55,
                    18, 22, 70, 28, 14, 33, 48, 9, 26, 38, 16, 52],
    "수면시간(시간)": [6.5, 7.2, 5.8, 6.0, 7.5, 8.0, 5.5, 6.8, 7.0, 6.2,
                      8.5, 7.8, 5.2, 6.4, 7.1, 6.6, 8.2, 5.9, 6.1, 7.4,
                      6.9, 5.6, 7.7, 6.3, 8.8],
    "성별": ["남", "여", "여", "남", "여", "남", "남", "여", "남", "여",
             "남", "여", "남", "여", "여", "남", "여", "남", "남", "여",
             "남", "여", "남", "여", "남"],
}

NUM = ["키(cm)", "통학시간(분)", "수면시간(시간)"]

MSG = {
    "hist": "히스토그램은 '분포의 모양이 어떤가?'라는 질문에 맞는 그래프입니다.",
    "box": "상자그림은 '두 집단이 다른가?'처럼 집단을 비교하는 질문에 맞습니다.",
    "scatter": "산점도는 '두 변수가 함께 변하는가?'라는 관계 질문에 맞습니다.",
    "bar": "막대그래프는 '각 범주가 몇 개인가?'라는 도수 질문에 맞습니다.",
}

W, H = 440, 320

def _svg(inner):
    return (f'<svg viewBox="0 0 {W} {H}" style="width:100%;height:auto;'
            f'font-family:-apple-system,sans-serif;">{inner}</svg>')

def _yaxis(ml, mr, mt, ph, vmax, fmt):
    out = ""
    for t in range(5):
        v = vmax * t / 4
        yy = mt + ph - (v / vmax) * ph
        out += (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">{fmt(v)}</text>')
    return out

def hist_svg(data, xlab):
    ml, mr, mt, mb = 42, 12, 14, 40
    pw, ph = W - ml - mr, H - mt - mb
    lo, hi = min(data), max(data)
    if hi <= lo:
        hi = lo + 1
    bins = 8
    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
    vmax = max(counts) or 1
    def X(v):
        return ml + ((v - lo) / (hi - lo)) * pw
    def Y(c):
        return mt + ph - (c / vmax) * ph
    body = _yaxis(ml, mr, mt, ph, vmax, lambda v: f"{v:.0f}")
    for k in range(bins):
        x = X(lo + k * bw)
        w = X(lo + (k + 1) * bw) - x
        body += (f'<rect x="{x:.1f}" y="{Y(counts[k]):.1f}" width="{w-1:.1f}" '
                 f'height="{Y(0)-Y(counts[k]):.1f}" fill="{ACCENT}" opacity="0.85"/>')
    for v in (lo, (lo + hi) / 2, hi):
        body += (f'<text x="{X(v):.1f}" y="{mt+ph+14:.1f}" font-size="9" '
                 f'fill="{MUTED}" text-anchor="middle">{v:g}</text>')
    body += (f'<text x="{ml+pw/2:.0f}" y="{H-4}" font-size="10" fill="{MUTED}" '
             f'text-anchor="middle">{xlab}</text>'
             f'<text x="6" y="{mt-2}" font-size="10" fill="{MUTED}">도수</text>')
    return _svg(body)

def box_svg(groups, labels, ylab):
    ml, mr, mt, mb = 44, 12, 14, 30
    pw, ph = W - ml - mr, H - mt - mb
    allv = [v for g in groups for v in g]
    lo, hi = min(allv), max(allv)
    pad = (hi - lo) * 0.08 or 1
    lo, hi = lo - pad, hi + pad
    def Y(v):
        return mt + ph - ((v - lo) / (hi - lo)) * ph
    body = ""
    for t in range(5):
        v = lo + (hi - lo) * t / 4
        yy = Y(v)
        body += (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">{v:.0f}</text>')
    for i, (g, lab) in enumerate(zip(groups, labels)):
        cx = ml + pw * (0.3 + 0.4 * i)
        hw = pw * 0.12
        s = sorted(g)
        q1, q2, q3 = st.quantiles(s, n=4)
        body += (f'<line x1="{cx:.1f}" y1="{Y(s[0]):.1f}" x2="{cx:.1f}" y2="{Y(s[-1]):.1f}" stroke="{INK}"/>'
                 f'<rect x="{cx-hw:.1f}" y="{Y(q3):.1f}" width="{2*hw:.1f}" '
                 f'height="{Y(q1)-Y(q3):.1f}" fill="white" fill-opacity="0.6" stroke="{INK}"/>'
                 f'<line x1="{cx-hw:.1f}" y1="{Y(q2):.1f}" x2="{cx+hw:.1f}" y2="{Y(q2):.1f}" '
                 f'stroke="{ACCENT}" stroke-width="1.8"/>'
                 f'<text x="{cx:.1f}" y="{mt+ph+16:.1f}" font-size="11" fill="{MUTED}" '
                 f'text-anchor="middle">{lab}</text>')
    body += f'<text x="6" y="{mt-2}" font-size="10" fill="{MUTED}">{ylab}</text>'
    return _svg(body)

def scatter_svg(xs, ys, xlab, ylab):
    ml, mr, mt, mb = 46, 14, 14, 38
    pw, ph = W - ml - mr, H - mt - mb
    xlo, xhi = min(xs), max(xs)
    ylo, yhi = min(ys), max(ys)
    xp, yp = (xhi - xlo) * 0.06 or 1, (yhi - ylo) * 0.06 or 1
    xlo, xhi, ylo, yhi = xlo - xp, xhi + xp, ylo - yp, yhi + yp
    def X(v):
        return ml + ((v - xlo) / (xhi - xlo)) * pw
    def Y(v):
        return mt + ph - ((v - ylo) / (yhi - ylo)) * ph
    body = ""
    for t in range(5):
        yv = ylo + (yhi - ylo) * t / 4
        yy = Y(yv)
        body += (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">{yv:.0f}</text>')
    for a, b in zip(xs, ys):
        body += f'<circle cx="{X(a):.1f}" cy="{Y(b):.1f}" r="3.6" fill="{ACCENT}" opacity="0.8"/>'
    for v in (xlo, (xlo + xhi) / 2, xhi):
        body += (f'<text x="{X(v):.1f}" y="{mt+ph+14:.1f}" font-size="9" '
                 f'fill="{MUTED}" text-anchor="middle">{v:g}</text>')
    body += (f'<text x="{ml+pw/2:.0f}" y="{H-4}" font-size="10" fill="{MUTED}" '
             f'text-anchor="middle">{xlab}</text>'
             f'<text x="6" y="{mt-2}" font-size="10" fill="{MUTED}">{ylab}</text>')
    return _svg(body)

def bar_svg(counts, labels, ylab):
    ml, mr, mt, mb = 42, 12, 14, 30
    pw, ph = W - ml - mr, H - mt - mb
    vmax = max(counts) or 1
    def Y(c):
        return mt + ph - (c / vmax) * ph
    body = _yaxis(ml, mr, mt, ph, vmax, lambda v: f"{v:.0f}")
    for i, (c, lab) in enumerate(zip(counts, labels)):
        cx = ml + pw * (0.3 + 0.4 * i)
        bw = pw * 0.2
        body += (f'<rect x="{cx-bw/2:.1f}" y="{Y(c):.1f}" width="{bw:.1f}" '
                 f'height="{Y(0)-Y(c):.1f}" fill="#9ca3af"/>'
                 f'<text x="{cx:.1f}" y="{Y(c)-6:.1f}" font-size="11" fill="{INK}" '
                 f'text-anchor="middle">{c}</text>'
                 f'<text x="{cx:.1f}" y="{mt+ph+16:.1f}" font-size="11" fill="{MUTED}" '
                 f'text-anchor="middle">{lab}</text>')
    body += f'<text x="6" y="{mt-2}" font-size="10" fill="{MUTED}">{ylab}</text>'
    return _svg(body)

app_ui = ui.page_sidebar(
    ui.sidebar(
        ui.input_select(
            "kind", "그래프 종류",
            {"hist": "히스토그램 (숫자 1개)",
             "box": "상자그림 (숫자 1개, 성별 비교)",
             "scatter": "산점도 (숫자 2개)",
             "bar": "막대그래프 (성별 도수)"},
        ),
        ui.input_select("xvar", "x 변수", {k: k for k in NUM}),
        ui.input_select("yvar", "y 변수 (산점도에서만 사용)",
                        {k: k for k in NUM}, selected="수면시간(시간)"),
        width=240,
    ),
    ui.card(
        ui.card_header("그래프"),
        ui.output_ui("chart"),
    ),
    ui.card(ui.output_ui("readout")),
    fillable=True,
)

def server(input, output, session):
    @render.ui
    def chart():
        kind, xv = input.kind(), input.xvar()
        if kind == "hist":
            out = hist_svg(DATA[xv], xv)
        elif kind == "box":
            boys = [x for x, g in zip(DATA[xv], DATA["성별"]) if g == "남"]
            girls = [x for x, g in zip(DATA[xv], DATA["성별"]) if g == "여"]
            out = box_svg([boys, girls], ["남", "여"], xv)
        elif kind == "scatter":
            yv = input.yvar()
            out = scatter_svg(DATA[xv], DATA[yv], xv, yv)
        else:
            counts = [DATA["성별"].count("남"), DATA["성별"].count("여")]
            out = bar_svg(counts, ["남", "여"], "도수(명)")
        return ui.HTML(out)

    @render.ui
    def readout():
        line = (MSG[input.kind()] + " 정리하면 — 분포의 모양은 히스토그램, "
                "집단 비교는 상자그림, 두 변수 관계는 산점도, "
                "범주 개수는 막대그래프입니다.")
        return ui.HTML(f'<p class="hs-readline">{line}</p>')

app = App(app_ui, server)

그래프 선택은 묻는 질문변수의 종류로 정해집니다.

# 질문 → 알맞은 그래프 (변수 종류로 자동 판별)
def choose_chart(question, x_type, y_type=None):
    if question == "분포의 모양" and x_type == "수치형":
        return "히스토그램"
    if question == "집단 비교":
        return "상자그림"          # 수치형 × 범주형
    if question == "두 변수 관계" and x_type == y_type == "수치형":
        return "산점도"
    if question == "범주별 도수":
        return "막대그래프"

print(choose_chart("분포의 모양", "수치형"))   # 히스토그램
print(choose_chart("두 변수 관계", "수치형", "수치형"))  # 산점도

스스로 확인

집단 비교 질문이므로 상자그림입니다. 앱에서 상자그림을 고르고 x 변수를 키(cm)로 두면, 두 집단의 중앙값·퍼짐·범위를 한눈에 비교할 수 있습니다. 히스토그램 하나로는 두 집단이 섞여 보이지 않습니다.

산점도에서 x 변수를 통학시간(분), y 변수를 수면시간(시간)으로 고르면 통학시간이 긴 학생일수록 수면시간이 짧은 음의 경향이 어렴풋이 보입니다. 다만 25명뿐인 가상 데이터이므로, 이런 경향이 진짜인지는 더 많은 자료로 확인해야 합니다.