파젠 창 문서의 그림 생성 코드다. if __name__ == "__main__": 아래가 자체 테스트다.

# 파젠 창 문서의 그림을 만든다: 15_parzen-window_fig1.svg
# 실행: ~/.venvs/vault-plots/bin/python 15_parzen-window_plot.py  (matplotlib, numpy 필요)
import os

import matplotlib
matplotlib.use("svg")
import matplotlib.pyplot as plt
import numpy as np
from matplotlib import font_manager

HERE = os.path.dirname(os.path.abspath(__file__))
PREFIX = "15_parzen-window"
INK = "#888888"                                         # 밝은 테마와 어두운 테마 모두에서 보이는 회색
C = ["#3b82c4", "#d9622b", "#3a9a5b", "#9b59b6", "#c49a1b"]
_fonts = {f.name for f in font_manager.fontManager.ttflist}
FONT = next((n for n in ["Apple SD Gothic Neo", "NanumGothic", "Noto Sans CJK KR"] if n in _fonts), "DejaVu Sans")
plt.rcParams.update({
    "font.family": FONT, "mathtext.fontset": "dejavusans",
    "font.size": 11, "axes.unicode_minus": False, "svg.fonttype": "path", "svg.hashsalt": PREFIX,
    "axes.edgecolor": INK, "axes.labelcolor": INK, "xtick.color": INK, "ytick.color": INK,
    "text.color": INK, "legend.frameon": False,
    "axes.facecolor": "none", "figure.facecolor": "none", "savefig.transparent": True,
    "axes.spines.top": False, "axes.spines.right": False,
})

DATA = np.array([3, 4, 5, 5, 6, 7, 12, 13, 14, 20], dtype=float)


def save(fig, k):
    fig.savefig(os.path.join(HERE, f"{PREFIX}_fig{k}.svg"), bbox_inches="tight", metadata={"Date": None})
    plt.close(fig)


def parzen(x, h):
    """상자 창: x를 가운데 둔 폭 h인 구간에 든 표본 수 / (n h)."""
    x = np.atleast_1d(x).astype(float)
    diff = x[:, None] - DATA[None, :]
    inside = (diff > -h / 2 - 1e-12) & (diff < h / 2 - 1e-12)   # 경계 한 점에서만 생기는 뾰족한 선을 그리지 않는다
    return inside.sum(axis=1) / (len(DATA) * h)


def fig1():
    xs = np.linspace(-4, 28, 6401)
    notes = {1: "창이 너무 작다: 표본마다 뾰족한 기둥", 4: "두 무리와 그 사이 골짜기가 보인다", 10: "창이 너무 크다: 골짜기가 메워진다"}
    fig, axes = plt.subplots(3, 1, figsize=(7.2, 6.6), sharex=True)
    for ax, h, c in zip(axes, (1, 4, 10), C):
        ax.step(xs, parzen(xs, h), where="mid", color=c, lw=1.8)
        ax.plot(DATA, np.full_like(DATA, -0.012), "|", color=INK, ms=9, mew=1.5, clip_on=False)
        ax.set_ylim(-0.02, 0.33)
        ax.set_ylabel("추정한 밀도")
        ax.text(0.99, 0.9, f"창 폭 h = {h}\n{notes[h]}", transform=ax.transAxes, ha="right", va="top", color=c)
        for q in (5.2, 9.6):
            v = parzen(q, h)[0]
            ax.plot([q], [v], "o", color=c, ms=5)
            ax.annotate(f"{v:.3g}", (q, v), textcoords="offset points", xytext=(6, 8), color=c, fontsize=10)
    axes[-1].set_xlabel("머리카락 길이 x (cm). 아래 세로 눈금이 표본 10개")
    fig.tight_layout()
    save(fig, 1)


if __name__ == "__main__":
    # 그림에 쓴 핵심 값 확인
    assert np.allclose(parzen(5.2, 1), 0.2) and np.allclose(parzen(5.2, 4), 0.125) and np.allclose(parzen(5.2, 10), 0.06)
    assert np.allclose(parzen(9.6, 1), 0) and np.allclose(parzen(9.6, 4), 0) and np.allclose(parzen(9.6, 10), 0.07)
    xs = np.linspace(-10, 40, 500001)
    for h in (1, 4, 10):
        area = parzen(xs, h).sum() * (xs[1] - xs[0])
        assert abs(area - 1) < 2e-3, (h, area)
    fig1()
    print("ALL CHECKS PASSED")