줄기와 잎 그림·도수분포다각형
개념
수집한 자료를 잃지 않으면서 분포까지 보여 주는 정리 방법이 줄기와 잎 그림입니다. 각 값을 큰 자리(보통 십의 자리)인 줄기와 작은 자리(일의 자리)인 잎으로 나누어, 같은 줄기끼리 잎을 늘어놓습니다. 원래 값이 그대로 남아 있어 최댓값·최솟값·중앙값을 바로 읽을 수 있는 것이 장점입니다.
3 | 1 2 5 는 31, 32, 35 세 값을 뜻합니다. 줄기 하나에 잎이 몇 개인지가 그 계급의 도수이므로, 줄기와 잎 그림을 옆으로 눕히면 히스토그램 모양이 됩니다.
한편 도수분포표를 꺾은선으로 나타낸 것이 도수분포다각형입니다. 히스토그램에서 각 직사각형 윗변의 중점을 차례로 선분으로 잇고, 양 끝에는 도수가 0인 계급을 하나씩 더해 그래프가 가로축과 만나게 합니다. 이렇게 하면 다각형과 가로축이 둘러싼 넓이가 히스토그램의 넓이와 같아지고, 여러 자료 집단의 분포를 한 그림에 겹쳐 비교하기 좋습니다. 아래 앱에서 같은 자료를 줄기와 잎 그림과 도수분포다각형으로 동시에 살펴보세요.
만지며 배우기
#| '!! shinylive warning !!': |
#| shinylive does not work in self-contained HTML documents.
#| Please set `embed-resources: false` in your metadata.
#| standalone: true
#| viewerHeight: 740
import math
from collections import defaultdict
from shiny import App, render, ui
ACCENT = "#2563eb"
INK = "#111827"
MUTED = "#6b7280"
GRID = "#e5e7eb"
# 학생 30명의 1분간 윗몸일으키기 개수
DATA = [23, 31, 18, 42, 27, 35, 14, 29, 38, 21,
46, 25, 33, 19, 28, 41, 24, 36, 30, 17,
44, 26, 39, 22, 32, 15, 43, 28, 34, 20]
LO = 10 # 첫 계급의 시작값
def stem_leaf_html(data):
d = defaultdict(list)
for v in sorted(data):
d[v // 10].append(v % 10)
smin, smax = min(d), max(d)
th = ('style="padding:3px 14px;border-bottom:1px solid #9ca3af;'
'text-align:center;"')
td = 'style="padding:3px 14px;border-bottom:1px solid #e5e7eb;"'
rows = ""
for s in range(smin, smax + 1):
leaves = " ".join(str(x) for x in d.get(s, []))
rows += (f'<tr><td {td} style="text-align:center;font-weight:600;">{s}</td>'
f'<td {td}><span style="letter-spacing:2px;font-family:monospace;">'
f'{leaves}</span></td>'
f'<td {td} style="text-align:center;color:{MUTED};">'
f'{len(d.get(s, []))}</td></tr>')
return ui.HTML(
'<table style="border-collapse:collapse;margin:0 auto;font-size:0.95em;">'
f'<tr><th {th}>줄기(십)</th><th {th}>잎(일)</th><th {th}>도수</th></tr>'
f'{rows}</table>'
f'<p style="text-align:center;color:{MUTED};font-size:0.8em;margin-top:6px;">'
'읽는 법: 줄기 3, 잎 1 → 31개</p>'
)
def histogram(edges):
counts = [0] * (len(edges) - 1)
last = len(edges) - 2
for x in DATA:
for i in range(len(edges) - 1):
if edges[i] <= x < edges[i + 1] or (i == last and x == edges[-1]):
counts[i] += 1
break
return counts
def polygon_svg(width, show_hist):
n_bins = int(math.ceil((max(DATA) - LO) / width))
edges = [LO + width * i for i in range(n_bins + 1)]
counts = histogram(edges)
W, H, ml, mr, mt, mb = 480, 320, 44, 14, 14, 40
pw, ph = W - ml - mr, H - mt - mb
xlo, xhi = edges[0] - width, edges[-1] + width
vmax = max(counts) + 1
def X(v):
return ml + ((v - xlo) / (xhi - xlo)) * pw
def Y(c):
return mt + ph - (c / vmax) * ph
grid = ""
for t in range(vmax + 1):
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>')
bars = ""
if show_hist:
for i, c in enumerate(counts):
x = X(edges[i])
w = X(edges[i + 1]) - x
bars += (f'<rect x="{x:.1f}" y="{Y(c):.1f}" width="{w-1:.1f}" '
f'height="{Y(0)-Y(c):.1f}" fill="{ACCENT}" opacity="0.14"/>')
# 양 끝에 도수 0 계급을 더한 꺾은선
mids = [(edges[i] + edges[i + 1]) / 2 for i in range(len(counts))]
px = [edges[0] - width / 2] + mids + [edges[-1] + width / 2]
py = [0] + counts + [0]
poly = "M" + " L".join(f"{X(x):.1f},{Y(c):.1f}" for x, c in zip(px, py))
dots = "".join(f'<circle cx="{X(x):.1f}" cy="{Y(c):.1f}" r="3" fill="{ACCENT}"/>'
for x, c in zip(px, py))
xlab = ""
for e in edges:
xlab += (f'<text x="{X(e):.1f}" y="{mt+ph+14:.1f}" font-size="9" '
f'fill="{MUTED}" text-anchor="middle">{e}</text>')
return ui.HTML(
f'<svg viewBox="0 0 {W} {H}" style="width:100%;height:auto;'
'font-family:-apple-system,sans-serif;">'
f'{grid}{bars}<path d="{poly}" fill="none" stroke="{ACCENT}" stroke-width="1.8"/>'
f'{dots}{xlab}'
f'<text x="{ml+pw/2:.0f}" y="{H-4}" font-size="10" fill="{MUTED}" '
'text-anchor="middle">윗몸일으키기 개수</text>'
f'<text x="8" y="{mt+8}" font-size="10" fill="{MUTED}">도수(명)</text>'
'</svg>'
)
app_ui = ui.page_sidebar(
ui.sidebar(
ui.input_slider("width", "계급의 크기(개)", min=5, max=15, value=10, step=5),
ui.input_checkbox("hist", "히스토그램 함께 보기", True),
width=240,
),
ui.card(
ui.card_header("줄기와 잎 그림 — 원자료가 그대로 보입니다"),
ui.output_ui("stemleaf"),
),
ui.card(
ui.card_header("도수분포다각형 — 양 끝은 도수 0 계급"),
ui.output_ui("polygon"),
),
ui.card(ui.output_ui("readout")),
fillable=True,
)
def server(input, output, session):
@render.ui
def stemleaf():
return stem_leaf_html(DATA)
@render.ui
def polygon():
return polygon_svg(input.width(), input.hist())
@render.ui
def readout():
n = len(DATA)
s = sorted(DATA)
mid = (s[n // 2 - 1] + s[n // 2]) / 2
return ui.HTML(
'<p class="hs-readline">'
f"학생 {n}명의 자료입니다. 줄기와 잎 그림에서 최솟값 {min(DATA)}개, "
f"최댓값 {max(DATA)}개, 한가운데 값(중앙값) {mid:.1f}개를 바로 읽을 수 있습니다. "
"계급의 크기를 바꾸면 도수분포다각형의 봉우리 모양이 달라지지만, "
"줄기와 잎 그림은 원자료를 그대로 담고 있어 변하지 않습니다.</p>"
)
app = App(app_ui, server)
줄기와 잎 그림은 값을 10으로 나눈 몫과 나머지로 묶으면 됩니다.
from collections import defaultdict
data = [23, 31, 18, 42, 27, 35, 14, 29]
stems = defaultdict(list)
for v in sorted(data):
stems[v // 10].append(v % 10) # 줄기=몫, 잎=나머지
for stem in sorted(stems):
leaves = " ".join(str(x) for x in stems[stem])
print(f"{stem} | {leaves}") # 예: 3 | 1 5스스로 확인
원래 자료의 값이 그대로 남는다는 점입니다. 히스토그램은 계급으로 묶는 순간 개별 값을 알 수 없지만, 줄기와 잎 그림은 잎을 보면 31, 32처럼 정확한 값과 최댓값·최솟값·중앙값을 바로 찾을 수 있습니다. 대신 자료 수가 아주 많으면 잎이 길어져 히스토그램이 더 편합니다.
꺾은선이 가로축과 만나 닫힌 도형이 되게 하기 위해서입니다. 그래야 다각형과 가로축이 둘러싼 넓이가 히스토그램 전체 넓이와 같아지고, 도수의 총합이 그대로 보존됩니다. 또 여러 집단의 다각형을 겹쳐 그릴 때 시작과 끝이 분명해집니다.