03 결과 도출 — 옵션 비교표·그림, 이전 판 대비 변화, 검증¶

02_분석.ipynb의 산출물로 4장의 옵션 비교표와 그림을 만든다. 마지막 셀은 다음을 대조한다(반올림 범위 안에서 일치해야 통과).

  • 정비판 그리드(정비판 외생) ↔ 2026-10 정비 실행에서 동결한 기록 그리드(data/published/grid_summary_*.csv)와 핵심 수치(config.PUBLISHED)
  • 이전 판 외생으로 다시 계산한 그리드 ↔ 이전 공표 그리드(data/published_prev/, 2026-08-28·09-01 실행)와 논문 1–3 본문 인용값(config.PREVIOUS)
In [1]:
import config as C          # 선형대수 스레드를 1개로 고정하므로 numpy보다 먼저 불러온다
import os, hashlib, time
import numpy as np, pandas as pd
import varx_grid as V
pd.set_option("display.float_format", lambda v: f"{v:,.3f}")
pd.set_option("display.max_columns", 30)
C.setup()
import matplotlib.pyplot as plt
import koreanize_matplotlib  # noqa: F401  (한글 글꼴)
grids = {g: pd.read_csv(os.path.join(C.OUTPUT, f"grid_{g}.csv"), encoding="utf-8-sig") for g in C.GRIDS}
prev = {g: pd.read_csv(os.path.join(C.OUTPUT, f"grid_prev_{g}.csv"), encoding="utf-8-sig") for g in C.GRIDS}
ORDER = [o["옵션"] for o in V.options()]
M = ["RMSE_train", "RMSE_valid", "RMSE_test"]
Python 3.12.10 · pandas 2.2.3 · numpy 2.4.6 · matplotlib 3.11.0 · BLAS 스레드 1
표본 2000Q1–2025Q4 · train ~2021Q4 · valid ~2023Q4 · test 이후 · 옵션 12개 · P=4, Q=2, λ=0.2
  있음  data\inputs\levels_sa.csv
  있음  data\inputs\levels_62.csv
  있음  data\inputs\levels_69.csv
  있음  data\inputs\levels_agg.csv
  있음  data\inputs\io_linkage_W.csv
  있음  data\inputs\io_linkage_62.csv
  있음  data\inputs\io_linkage_69.csv
  있음  data\inputs\exog_hist_v2_20260828.csv
  있음  data\inputs\exog_hist_v2.csv

표 1. 12개 옵션의 예측오차(RMSE, 100·log 수준) — 분류별 valid·test(정비판)¶

62·69분류는 36개 공표 산업을 고정 비중으로 나눈 패널이어서 서로 다른 성장률 계열이 각각 32개·34개뿐이다(01 2절). 따라서 분류 간 RMSE 차이는 산업 동학의 차이보다 산업별 가중의 차이를 많이 반영하며, 비교는 같은 분류 안의 옵션끼리 하는 것이 맞다.

In [2]:
t1 = pd.concat({g: grids[g].set_index("옵션")[["RMSE_valid", "RMSE_test"]] for g in ["36", "62", "69"]}, axis=1).loc[ORDER]
t1.to_csv(os.path.join(C.OUTPUT, "table1_grid_rmse.csv"), encoding="utf-8-sig")
t1.style.format("{:.3f}").highlight_min(axis=0, props="font-weight:bold")
Out[2]:
  36 62 69
  RMSE_valid RMSE_test RMSE_valid RMSE_test RMSE_valid RMSE_test
옵션            
차분_minnesota_varx 7.296 3.626 6.188 3.332 6.232 3.525
차분_minnesota_var 7.405 3.597 6.355 3.322 6.389 3.581
차분_io_varx 6.744 3.106 6.181 3.306 6.230 3.449
차분_io_var 6.885 2.916 6.316 3.274 6.358 3.443
수준_minnesota_varx 13.939 4.755 13.426 4.539 12.944 5.128
수준_minnesota_var 14.823 5.178 14.118 4.866 13.795 5.279
수준_io_varx 10.325 3.804 12.476 4.485 12.475 4.442
수준_io_var 10.417 3.827 13.566 4.907 13.491 4.735
VECM_minnesota_varx 8.111 4.173 7.600 4.155 8.067 4.963
VECM_minnesota_var 8.387 4.166 7.906 4.286 8.395 5.008
VECM_io_varx 8.660 4.153 7.490 4.091 7.929 4.969
VECM_io_var 8.739 4.196 7.837 4.215 8.344 4.867

표 2. 분류별 선택 옵션(valid 최소)과 시험 구간 성능(정비판)¶

선택 옵션과 시험 구간 최소 옵션을 함께 보인다. 총량은 수준 명세가 선택되며 두 사전분포는 같은 모형이다(동률). 5장은 총량의 경로를 BOKDPM 기준선에서 가져오고, 총량 VARX(수준·산업연관표·VARX)는 시나리오 간 차이를 내는 데만 쓴다.

In [3]:
t2 = []
for g, d in grids.items():
    best = V.select(d)
    r = d.set_index("옵션").loc[best[0]]
    tmin = d.loc[d["RMSE_test"].idxmin()]
    t2.append({"분류": g, "선택 옵션(valid 최소)": " = ".join(best), **{m: r[m] for m in M},
               "test 최소 옵션": tmin["옵션"], "test 최소": tmin["RMSE_test"],
               "이전 판 선택": " = ".join(V.select(prev[g]))})
t2 = pd.DataFrame(t2).set_index("분류")
t2.to_csv(os.path.join(C.OUTPUT, "table2_selected.csv"), encoding="utf-8-sig")
t2.style.format({m: "{:.3f}" for m in M} | {"test 최소": "{:.3f}"})
Out[3]:
  선택 옵션(valid 최소) RMSE_train RMSE_valid RMSE_test test 최소 옵션 test 최소 이전 판 선택
분류              
36 차분_io_varx 3.691 6.744 3.106 차분_io_var 2.916 차분_io_varx
62 차분_io_varx 2.023 6.181 3.306 차분_io_var 3.274 차분_io_varx
69 차분_io_varx 2.098 6.230 3.449 차분_io_var 3.443 차분_io_varx
AGG 수준_minnesota_var = 수준_io_var 0.883 0.414 0.554 수준_minnesota_var 0.554 수준_minnesota_var = 수준_io_var

표 3. 축별 평균 RMSE — 명세·사전분포·외생(정비판)¶

36·62·69분류 각각에서 해당 축의 수준을 공유하는 옵션(4개 또는 6개)의 평균이다. 총량은 사전분포 축이 의미가 없으므로 뺀다.

In [4]:
three = {g: grids[g] for g in ["36", "62", "69"]}
t3 = pd.concat({"valid": V.axis_means(three, "RMSE_valid"), "test": V.axis_means(three, "RMSE_test")}, axis=1)
t3.to_csv(os.path.join(C.OUTPUT, "table3_axis_means.csv"), encoding="utf-8-sig")
t3.style.format("{:.3f}")
Out[4]:
    valid test
  분류 36 62 69 36 62 69
축 수준            
명세 VECM 8.474 7.708 8.184 4.172 4.187 4.952
수준 12.376 13.397 13.176 4.391 4.699 4.896
차분 7.083 6.260 6.302 3.311 3.308 3.500
사전 io 8.629 8.978 9.138 3.667 4.046 4.317
minnesota 9.994 9.266 9.304 4.249 4.083 4.581
외생 var 9.443 9.350 9.462 3.980 4.145 4.485
varx 9.179 8.894 8.980 3.936 3.985 4.413

그림 1. 69분류 12개 옵션의 구간별 RMSE(valid 오름차순, 정비판)¶

In [5]:
d = grids["69"].sort_values("RMSE_valid")
x = np.arange(len(d))
fig, ax = plt.subplots(figsize=(10, 4.2))
for col, mk, lab in [("RMSE_train", "o", "train"), ("RMSE_valid", "s", "valid"), ("RMSE_test", "^", "test")]:
    ax.plot(x, d[col], mk + "-", label=lab)
ax.set_xticks(x); ax.set_xticklabels(d["옵션"], rotation=45, ha="right", fontsize=8)
ax.set_ylabel("RMSE(100·log 수준)"); ax.grid(alpha=.3); ax.legend(frameon=False)
ax.set_title("69분류 · 12개 옵션의 1기 앞 예측오차", loc="left")
fig.tight_layout(); fig.savefig(os.path.join(C.OUTPUT, "fig1_rmse_69.png"), dpi=150)
No description has been provided for this image

그림 2. 분류 × 옵션 valid RMSE(정비판)¶

In [6]:
h = pd.DataFrame({g: grids[g].set_index("옵션")["RMSE_valid"] for g in ["36", "62", "69"]}).loc[ORDER]
fig, ax = plt.subplots(figsize=(6.2, 6.4))
im = ax.imshow(h.values, cmap="Blues", aspect="auto")
ax.set_xticks(range(3)); ax.set_xticklabels([f"{g}분류" for g in h.columns])
ax.set_yticks(range(len(h))); ax.set_yticklabels(h.index, fontsize=8)
for i in range(h.shape[0]):
    for j in range(h.shape[1]):
        v = h.values[i, j]
        ax.text(j, i, f"{v:.2f}", ha="center", va="center", fontsize=8, color="white" if v > np.nanmedian(h.values) * 1.4 else "black")
fig.colorbar(im, ax=ax, shrink=.7, label="valid RMSE")
ax.set_title("valid RMSE(낮을수록 정확)", loc="left")
fig.tight_layout(); fig.savefig(os.path.join(C.OUTPUT, "fig2_valid_heatmap.png"), dpi=150)
No description has been provided for this image

표 4. 점검과 민감도 요약¶

02 4–6절의 결과를 모은다. 'A 정비 영향'은 실질금리 정의 변경 전후의 RMSE 차이와 선택이다. 비고의 'io−미네소타'는 차분·VARX에서 두 사전분포의 valid RMSE 차이이며, 음수이면 산업연관표가 더 정확하다.

In [7]:
ex = pd.read_csv(os.path.join(C.OUTPUT, "check_prev_vs_new_summary.csv"), index_col=0, encoding="utf-8-sig")
g36n = pd.read_csv(os.path.join(C.OUTPUT, "check_io36_normalized.csv"), encoding="utf-8-sig")
lam = pd.read_csv(os.path.join(C.OUTPUT, "lambda_sensitivity.csv"), encoding="utf-8-sig")
t4 = [{"점검": "A 정비 영향(실질금리 수준→차분)", "분류": g, "선택": ex.loc[g, "선택(정비판)"],
       "비고": f"이전 판 선택 {ex.loc[g, '선택(이전 판)']}, 최대 |Δvalid| {ex.loc[g, '최대 |Δvalid|']:.3f}, |Δtest| {ex.loc[g, '최대 |Δtest|']:.3f}"} for g in ex.index]
t4.append({"점검": "B 36 행렬 정규화", "분류": "36", "선택": " = ".join(V.select(g36n)),
           "비고": f"valid {g36n['RMSE_valid'].min():.3f}"})
for _, r in lam[lam["λ"] != C.LAM].iterrows():
    t4.append({"점검": f"λ={r['λ']}", "분류": str(r["분류"]), "선택": r["valid 최소 옵션"],
               "비고": f"valid {r['RMSE_valid']:.3f}, test {r['RMSE_test']:.3f}, 차분·VARX의 io−미네소타 {r['io − 미네소타']:+.3f}"})
t4 = pd.DataFrame(t4)
t4.to_csv(os.path.join(C.OUTPUT, "table4_checks.csv"), index=False, encoding="utf-8-sig")
t4
Out[7]:
점검 분류 선택 비고
0 A 정비 영향(실질금리 수준→차분) 36 차분_io_varx 이전 판 선택 차분_io_varx, 최대 |Δvalid| 0.049, |Δtest|...
1 A 정비 영향(실질금리 수준→차분) 62 차분_io_varx 이전 판 선택 차분_io_varx, 최대 |Δvalid| 0.013, |Δtest|...
2 A 정비 영향(실질금리 수준→차분) 69 차분_io_varx 이전 판 선택 차분_io_varx, 최대 |Δvalid| 0.022, |Δtest|...
3 A 정비 영향(실질금리 수준→차분) AGG 수준_minnesota_var = 수준_io_var 이전 판 선택 수준_minnesota_var = 수준_io_var, 최대 |Δval...
4 B 36 행렬 정규화 36 차분_io_varx valid 6.983
5 λ=0.05 36 차분_minnesota_varx valid 6.192, test 3.081, 차분·VARX의 io−미네소타 +0.079
6 λ=0.05 62 차분_minnesota_varx valid 5.166, test 2.654, 차분·VARX의 io−미네소타 +0.051
7 λ=0.05 69 차분_minnesota_varx valid 5.314, test 2.849, 차분·VARX의 io−미네소타 +0.086
8 λ=0.05 AGG 수준_minnesota_varx = 수준_io_varx valid 0.322, test 0.571, 차분·VARX의 io−미네소타 +0.000
9 λ=0.1 36 차분_io_varx valid 6.384, test 3.119, 차분·VARX의 io−미네소타 -0.009
10 λ=0.1 62 차분_minnesota_varx valid 5.406, test 2.804, 차분·VARX의 io−미네소타 +0.021
11 λ=0.1 69 차분_minnesota_varx valid 5.517, test 3.011, 차분·VARX의 io−미네소타 +0.059
12 λ=0.1 AGG 수준_minnesota_varx = 수준_io_varx valid 0.363, test 0.564, 차분·VARX의 io−미네소타 +0.000
13 λ=0.4 36 차분_io_varx valid 7.477, test 3.200, 차분·VARX의 io−미네소타 -1.298
14 λ=0.4 62 차분_minnesota_varx valid 7.374, test 4.241, 차분·VARX의 io−미네소타 +0.170
15 λ=0.4 69 차분_minnesota_varx valid 7.331, test 4.283, 차분·VARX의 io−미네소타 +0.109
16 λ=0.4 AGG 수준_minnesota_var = 수준_io_var valid 0.394, test 0.558, 차분·VARX의 io−미네소타 +0.000

표 5. 이전 판 대비 변화 — 선택 옵션과 VARX 옵션의 RMSE¶

실질금리 정의가 바뀌면 외생을 쓰는 VARX·VECM-X 옵션의 RMSE만 달라진다. 아래 표는 분류별 선택 옵션과 주요 VARX 옵션의 valid·test RMSE를 두 판에서 비교한다.

In [8]:
rows = []
for g in C.GRIDS:
    n, p_ = grids[g].set_index("옵션"), prev[g].set_index("옵션")
    for o in sorted(set(V.select(grids[g])[:1] + ["차분_io_varx", "수준_io_varx"])):
        rows.append({"분류": g, "옵션": o, "valid(정비판)": n.loc[o, "RMSE_valid"], "valid(이전 판)": p_.loc[o, "RMSE_valid"],
                     "test(정비판)": n.loc[o, "RMSE_test"], "test(이전 판)": p_.loc[o, "RMSE_test"]})
t5 = pd.DataFrame(rows).set_index(["분류", "옵션"])
t5.to_csv(os.path.join(C.OUTPUT, "table5_vs_prev.csv"), encoding="utf-8-sig")
t5.style.format("{:.3f}")
Out[8]:
    valid(정비판) valid(이전 판) test(정비판) test(이전 판)
분류 옵션        
36 수준_io_varx 10.325 10.327 3.804 3.859
차분_io_varx 6.744 6.793 3.106 3.093
62 수준_io_varx 12.476 12.479 4.485 4.569
차분_io_varx 6.181 6.179 3.306 3.293
69 수준_io_varx 12.475 12.476 4.442 4.488
차분_io_varx 6.230 6.235 3.449 3.430
AGG 수준_io_varx 0.419 0.417 0.554 0.543
수준_minnesota_var 0.414 0.414 0.554 0.554
차분_io_varx 0.489 0.425 0.632 0.586

표 6. 실질금리 계수의 부호(02 4-1절)¶

In [9]:
t6 = pd.read_csv(os.path.join(C.OUTPUT, "real_rate_sign.csv"), index_col=[0, 1], encoding="utf-8-sig")
t6.to_csv(os.path.join(C.OUTPUT, "table6_real_rate_sign.csv"), encoding="utf-8-sig")
t6.style.format({c: "{:.1f}" for c in t6.columns if "비율" in c} | {c: "{:+.4f}" for c in t6.columns if "가중" in c})
Out[9]:
    산업 수 동시항 음수 비율(%) 합계 음수 비율(%) 가중평균 동시항 가중평균 합계
분류 판          
36 정비판(1차 차분) 36 52.8 50.0 +0.0426 +0.1192
이전 판(수준) 36 27.8 19.4 +0.1478 +0.2126
62 정비판(1차 차분) 62 43.5 45.2 +0.0136 +0.0148
이전 판(수준) 62 25.8 19.4 +0.1008 +0.1948
69 정비판(1차 차분) 69 47.8 44.9 +0.0127 +0.0096
이전 판(수준) 69 27.5 24.6 +0.0995 +0.1979
AGG(수준·io·varx) 정비판(1차 차분) 1 100.0 0.0 -0.1257 +0.0651
이전 판(수준) 1 0.0 0.0 +0.0245 +0.1401

검증 — 정비판 기록 그리드, 이전 판 공표 그리드¶

  • 그리드 RMSE는 소수 셋째 자리로 반올림해 기록하므로 허용오차는 0.0005이다(판마다 4개 분류 × 12개 옵션 × 3개 구간 = 144항목).
  • 선택 옵션은 valid RMSE(소수 셋째 자리)가 가장 작은 옵션이다. 총량처럼 같은 모형이 동률이면 기록 선택이 동률 집합에 들어 있는지 확인한다.
  • 핵심 수치(선택 옵션의 valid·test)는 소수 둘째 자리이므로 허용오차는 0.005이다. 이전 판은 논문 1–3 본문 인용값과 대조한다.
In [10]:
rows = []
def grid_rows(label, calc_grids, pub_paths, sel):
    for g in C.GRIDS:
        pub = pd.read_csv(pub_paths[g], encoding="utf-8-sig").set_index("옵션")
        cal = calc_grids[g].set_index("옵션")
        assert set(pub.index) == set(cal.index) == set(ORDER)
        for o in ORDER:
            for m in M:
                rows.append({"판": label, "항목": f"{g} {o} {m}", "계산": cal.loc[o, m], "기록": pub.loc[o, m], "허용오차": 0.0005})
        assert pub.index[0] == sel[f"선택_{g}"], "기록 그리드 첫 행과 config의 선택 옵션이 다르다"
        rows.append({"판": label, "항목": f"{g} 선택 옵션(valid 최소)", "계산": " = ".join(V.select(calc_grids[g])),
                     "기록": sel[f"선택_{g}"], "허용오차": "동률 포함"})
grid_rows("정비판", grids, C.PUBLISHED_GRID, C.PUBLISHED)
grid_rows("이전 판", prev, C.PUBLISHED_GRID_PREV, C.PREVIOUS)
for g in ["36", "62", "69"]:
    for label, src, P, pre in [("정비판", grids, C.PUBLISHED, "정비"), ("이전 판", prev, C.PREVIOUS, "논문")]:
        cal = src[g].set_index("옵션").loc["차분_io_varx"]
        for m, key in [("RMSE_valid", "valid"), ("RMSE_test", "test")]:
            rows.append({"판": label, "항목": f"{pre}({g}분류) 차분·io·varx {key}", "계산": cal[m], "기록": P[f"{pre}_{g}_{key}"], "허용오차": 0.005})

def passed(r):
    if r["허용오차"] == "동률 포함":
        return r["기록"] in r["계산"].split(" = ")
    return abs(r["계산"] - r["기록"]) <= r["허용오차"] + 1e-9

res = pd.DataFrame(rows)
res["통과"] = res.apply(passed, axis=1)
res.to_csv(os.path.join(C.OUTPUT, "verification.csv"), index=False, encoding="utf-8-sig")
num = res[res["항목"].str.contains("RMSE_")].copy()
num["분류"] = num["항목"].str.split(" ").str[0]
summary = num.assign(차이=lambda x: (x["계산"].astype(float) - x["기록"].astype(float)).abs()) \
    .groupby(["판", "분류"]).agg(항목수=("통과", "size"), 최대차이=("차이", "max"), 통과=("통과", "all"))
display(summary)
display(res[~res["항목"].str.contains("RMSE_")])
assert res["통과"].all(), "기록 수치와 계산 결과가 다르다 — output/verification.csv에서 통과=False 항목을 확인한다"
print(f"검증 통과: 정비판·이전 판 그리드 각 144항목, 선택 옵션 각 {len(C.GRIDS)}항목, 핵심 수치 각 6항목, 모두 {len(res)}항목.")
항목수 최대차이 통과
판 분류
이전 판 36 36 0.000 True
62 36 0.000 True
69 36 0.000 True
AGG 36 0.000 True
정비판 36 36 0.000 True
62 36 0.000 True
69 36 0.000 True
AGG 36 0.000 True
판 항목 계산 기록 허용오차 통과
36 정비판 36 선택 옵션(valid 최소) 차분_io_varx 차분_io_varx 동률 포함 True
73 정비판 62 선택 옵션(valid 최소) 차분_io_varx 차분_io_varx 동률 포함 True
110 정비판 69 선택 옵션(valid 최소) 차분_io_varx 차분_io_varx 동률 포함 True
147 정비판 AGG 선택 옵션(valid 최소) 수준_minnesota_var = 수준_io_var 수준_io_var 동률 포함 True
184 이전 판 36 선택 옵션(valid 최소) 차분_io_varx 차분_io_varx 동률 포함 True
221 이전 판 62 선택 옵션(valid 최소) 차분_io_varx 차분_io_varx 동률 포함 True
258 이전 판 69 선택 옵션(valid 최소) 차분_io_varx 차분_io_varx 동률 포함 True
295 이전 판 AGG 선택 옵션(valid 최소) 수준_minnesota_var = 수준_io_var 수준_io_var 동률 포함 True
296 정비판 정비(36분류) 차분·io·varx valid 6.744 6.740 0.005 True
297 정비판 정비(36분류) 차분·io·varx test 3.106 3.110 0.005 True
298 이전 판 논문(36분류) 차분·io·varx valid 6.793 6.790 0.005 True
299 이전 판 논문(36분류) 차분·io·varx test 3.093 3.090 0.005 True
300 정비판 정비(62분류) 차분·io·varx valid 6.181 6.180 0.005 True
301 정비판 정비(62분류) 차분·io·varx test 3.306 3.310 0.005 True
302 이전 판 논문(62분류) 차분·io·varx valid 6.179 6.180 0.005 True
303 이전 판 논문(62분류) 차분·io·varx test 3.293 3.290 0.005 True
304 정비판 정비(69분류) 차분·io·varx valid 6.230 6.230 0.005 True
305 정비판 정비(69분류) 차분·io·varx test 3.449 3.450 0.005 True
306 이전 판 논문(69분류) 차분·io·varx valid 6.235 6.240 0.005 True
307 이전 판 논문(69분류) 차분·io·varx test 3.430 3.430 0.005 True
검증 통과: 정비판·이전 판 그리드 각 144항목, 선택 옵션 각 4항목, 핵심 수치 각 6항목, 모두 308항목.