02 분석 — 바텀업·총량 전망, 결합 가중치 검증, 정합화¶

계산 구조(2026-10 정비판). 시나리오 $s$, 연도 $t$, 산업 $i$에 대하여

  1. 바텀업 $\hat y_{i,s,t}$: 산업별 차분·산업연관표·VARX(4장 선택)를 전체 표본으로 적합하고 3장 외생 경로로 2026Q1–2035Q4를 재귀 전망한다. 이때 상수항(드리프트)을 재설정한다. 차분 VARX의 장기 성장률은 표본(2000–2025) 평균이므로, 모든 산업의 장기 성장률을 $\delta$ = (표본 산업 합계 평균 성장률 − BOKDPM 잠재성장률 1.8%)만큼 같은 폭으로 낮춘다(절편 보정). 산업 간 성장 격차와 외생·동학 계수는 그대로이다.
  2. 통제 총량의 성장인자 $g^M_{s,t} = \dfrac{GDP^{BOKDPM}_t}{GDP^{BOKDPM}_{2025}} \times \dfrac{AGG_{s,t}}{AGG_{S1,t}}$: 기준선은 BOKDPM 실질GDP(잠재성장률 1.8% 수렴), 시나리오 간 차이는 총량 VARX(수준·산업연관표·VARX)의 S1 대비 비율이다. 3장 외생과 같은 '기준선 + 시나리오 편차' 구성이다.
  3. 결합과 정합화: $g_{s,t} = \exp[\,w\ln g^P_t + (1-w)\ln g^M_{s,t}\,]$, $T_{s,t} = \big(\sum_i VA^{실적}_{i,2025}\big)\,g_{s,t}$, $VA_{i,s,t} = T_{s,t}\,\hat y_{i,s,t}/\sum_j \hat y_{j,s,t}$. $g^P$는 기존 전망(2024–2034판) 전산업의 성장인자, $w$는 2절의 유사표본외 검증으로 고른다.

마지막 식은 같은 연도·시나리오의 모든 산업에 같은 비례계수 $T_{s,t}/\sum_j \hat y_{j,s,t}$를 곱하는 결정론적 비례 벤치마킹이다. 산업 구성비는 바텀업 전망 그대로 두고 합계만 통제 총량에 맞춘다. 이는 가중최소자승(계층 예측) 사영과 다른 절차이다.

이전 판과 달라진 점. ① 외생(3장 정비판), ② 바텀업 드리프트 재설정, ③ 통제 총량의 기준선을 총량 VARX에서 BOKDPM 기준선으로, ④ $w$ 재검증(0.45 → 0.40). ③의 이유는 4절에 적었다. 총량 VARX는 수준 명세에서 자기 시차 계수의 합이 1보다 작아, 10년 전망에서 성장률이 기계적으로 줄어든다.

산출물. output/prior_trend_factors.json, shrink_w_validation.csv, wstar.json, bottomup_annual_{36,62,69}.csv, bottomup_raw_annual_{36,62,69}.csv, drift_anchor.csv, topdown_varx_annual.csv, topdown_annual.csv, forecast_annual_{36,62,69}.csv, reconcile_factors.csv, benchmark_only_totals.csv, forecast_summary_{36,62,69}.csv, forecast_total_{36,62,69}.csv, base_va_62.csv, base_va_69.csv, gap_decomposition.csv, method_comparison.csv, scenario_exog_decomp.csv, shap_62.csv·shap_62.json·shap_check.csv(10절 산업별 성장 요인 분해 — 전망 갱신 카드의 요인 분해·자동 해설 자료)

In [1]:
import os
os.environ.setdefault("OPENBLAS_NUM_THREADS", "1")   # numpy보다 먼저 지정한다(config.py 주석 참조)
import json
import numpy as np, pandas as pd
import config as C
import gva_results as R
pd.set_option("display.float_format", lambda v: f"{v:,.3f}")
C.setup()
import varx_engine as V
prev = pd.read_csv(C.PREV_KEIS, encoding="utf-8-sig")
lgdp = pd.read_csv(C.UP["lgdp"], index_col=0, encoding="utf-8-sig", float_precision="round_trip")["LGDP_kor"]
Python 3.12.10 · pandas 2.2.3 · numpy 2.4.6 · openpyxl 3.1.5 · matplotlib 3.11.0
  드리프트 재설정 목표 1.8% · 수축 가중치 w=0.4(이전 판 0.45) · 원본 엑셀 대조: 없음
  기존 전망 62     있음  data\inputs\prev_keis_va_62.csv
  전달 템플릿       있음  data\inputs\template_62_VA_v1.xlsx
  분기 자료(상류)    있음  data\inputs\upstream
  정비판 기록       있음  data\published
  이전 판 공표      있음  data\published_prev

1. 기존 전망의 성장인자 $g^P$¶

기존 전망 전산업 행의 2025–2035년 값을 2025년 값으로 나눈다. 이전 판 파이프라인이 저장해 쓴 prior_trend_factors.json과 같은지 확인한다.

In [2]:
prior = R.prior_factors(prev)
pub_prior = {int(k): v for k, v in json.load(open(os.path.join(C.PREV_DIR, "prior_trend_factors.json"), encoding="utf-8")).items()}
d_prior = max(abs(prior[y] - pub_prior[y]) for y in pub_prior)
print(f"이전 판 저장 파일 대비 최대 절대차: {d_prior:.1e}")
assert set(prior) == set(pub_prior) and d_prior < 1e-12
R.json_dump({str(k): v for k, v in prior.items()}, os.path.join(C.OUTPUT, "prior_trend_factors.json"))
gp = pd.Series(prior, name="g^P")
pd.DataFrame({"g^P (2025=1)": gp, "연 증가율(%)": gp.pct_change() * 100}).T
이전 판 저장 파일 대비 최대 절대차: 0.0e+00
Out[2]:
2025 2026 2027 2028 2029 2030 2031 2032 2033 2034 2035
g^P (2025=1) 1.000 1.016 1.034 1.052 1.072 1.091 1.110 1.128 1.146 1.164 1.181
연 증가율(%) NaN 1.600 1.750 1.800 1.842 1.780 1.719 1.660 1.603 1.548 1.494

2. 결합 가중치 $w$의 유사표본외 검증¶

기존 전망의 과거 판(vintage)은 남아 있지 않으므로, 기존 전망과 성격이 같은 추세 연장(각 산업 로그수준을 학습 표본의 평균 로그성장으로 연장)을 대용으로 쓴다. 62분류 차분·IO·VARX를 2000Q1–2019Q4로 적합하고 2020Q1–2025Q4를 실측 외생으로 1기 앞씩 재귀 전망한 뒤, 산업별 로그수준을 $\tilde y = w\cdot \text{추세} + (1-w)\cdot \text{모형}$으로 결합한다. 연간 수준(십억 원)으로 환산해 산업 평균제곱오차의 제곱근(RMSE)을 구하고, 검증 구간(2020–2022)에서 RMSE가 가장 작은 $w$를 고른 뒤 시험 구간(2023–2025)에서 성능을 보고한다. $w$는 0부터 1까지 0.05 간격으로 탐색한다. 외생이 바뀌었으므로(실질금리 1차 차분) 이전 판(0.45)과 다른 값이 나올 수 있으며, 이 패키지는 검증으로 고른 값을 쓴다.

In [3]:
vw = V.validate_shrink_w(C.UP["levels_62"], C.UP["exog_hist"], C.UP["io_62"])
cur = pd.DataFrame({"w": vw["ws"], "검증 RMSE(2020–22)": vw["val"], "시험 RMSE(2023–25)": vw["test_curve"]})
cur.to_csv(os.path.join(C.OUTPUT, "shrink_w_validation.csv"), index=False, encoding="utf-8-sig")
t = vw["test"]
wj = {"ws": vw["ws"], "val": [round(r, 1) for r in vw["val"]], "wstar": vw["wstar"],
      "test": {"model": round(t["model"], 1), "shrink": round(t["shrink"], 1), "trend": round(t["trend"], 1)}}
R.json_dump(wj, os.path.join(C.OUTPUT, "wstar.json"))
pw = json.load(open(os.path.join(C.PREV_DIR, "wstar.json"), encoding="utf-8"))
print(f"선택 w* = {vw['wstar']} (검증 최소, config.W_PRIOR = {C.W_PRIOR}) · 이전 판 w* = {pw['wstar']}")
print(f"시험 RMSE: 모형만 {t['model']:,.1f} · 결합(w*) {t['shrink']:,.1f} · 추세만 {t['trend']:,.1f}"
      f" → 결합의 모형 대비 변화 {(t['shrink'] / t['model'] - 1) * 100:+.1f}% (이전 판 {(pw['test']['shrink'] / pw['test']['model'] - 1) * 100:+.1f}%)")
assert vw["wstar"] == C.W_PRIOR, "검증으로 고른 w가 config.W_PRIOR와 다르다"
cur.iloc[::2]
선택 w* = 0.4 (검증 최소, config.W_PRIOR = 0.4) · 이전 판 w* = 0.45
시험 RMSE: 모형만 6,552.6 · 결합(w*) 6,866.8 · 추세만 7,582.4 → 결합의 모형 대비 변화 +4.8% (이전 판 -7.0%)
Out[3]:
w 검증 RMSE(2020–22) 시험 RMSE(2023–25)
0 0.000 3,256.219 6,552.555
2 0.100 3,229.498 6,618.257
4 0.200 3,210.329 6,692.604
6 0.300 3,198.877 6,775.493
8 0.400 3,195.252 6,866.801
10 0.500 3,199.514 6,966.383
12 0.600 3,211.664 7,074.080
14 0.700 3,231.648 7,189.719
16 0.800 3,259.360 7,313.119
18 0.900 3,294.642 7,444.089
20 1.000 3,337.295 7,582.437

검증 RMSE는 $w$에 대해 완만한 U자형이며 정비판에서는 0.40에서 가장 작다(0.30–0.50 구간의 차이는 0.2% 이내). 이전 판과 달리 시험 구간(2023–2025)에서는 결합 전망이 모형 단독보다 오차가 크다. 실질금리를 차분으로 넣은 정비판 모형의 시험 구간 오차가 이전 판보다 작아졌기 때문이다(모형 단독 RMSE가 줄었다). 선택 규칙(검증 구간 최소)은 이전 판과 같게 유지하되, 기존 전망 결합의 시험 구간 개선 효과가 사라졌다는 점을 기록한다(README 쟁점). 또한 이 검증은 산업별 로그수준을 결합해 $w$를 고르고, 정합화는 같은 $w$를 전산업 성장인자의 결합에 쓴다.

3. 바텀업 전망 — 산업 VARX와 드리프트 재설정¶

36·62·69분류에 차분·산업연관표·VARX를 전체 표본(2000Q1–2025Q4)으로 적합하고, 3장 S1–S5 외생 경로로 2026Q1–2035Q4를 1기 앞씩 재귀 전망한 뒤 연도별로 합산한다.

드리프트 재설정. 차분 VARX $\Delta y_t = c + \sum_l A_l\Delta y_{t-l} + \Gamma x_t$의 장기 평균은 $\mu = (I-\sum A_l)^{-1}(c+\Gamma\bar x)$이며, 표본 안에서는 산업별 2000–2025년 평균 성장률이다. 이 기간 산업 합계는 연 3.3% 성장했으나 잠재성장률은 2000년대 4–5%에서 현재 2% 안팎으로 낮아졌다(3장 평활 잠재성장률). 재설정하지 않으면 산업모형이 2000년대가 섞인 표본 평균 성장률을 전망에 이어 붙이게 된다. 3장이 BOKDPM 정상상태를 바꾼 것과 같은 이유로, $c \leftarrow c - (I-\sum A_l)\,\delta\,\mathbf 1$을 써서 모든 산업의 장기 성장률을 같은 폭 $\delta$만큼 낮춘다. $\delta$는 표본 산업 합계 평균 성장률에서 BOKDPM 잠재성장률(1.8%)을 뺀 값이며, 추정한 값이 아니다.

재설정하지 않은 전망(bottomup_raw_annual_*.csv)도 함께 저장해 8절의 괴리 분해에 쓴다.

In [4]:
bu, bu_raw, anc = {}, {}, []
spec, prior_k, em = C.SPEC["산업"]
for r in C.RES:
    bu_raw[r] = V.run_forecast(C.UP[f"levels_{r}"], C.UP["exog_hist"], C.UP["exog_scen"], C.UP[f"io_{r}"], spec, prior_k, em)
    bu[r] = V.run_forecast(C.UP[f"levels_{r}"], C.UP["exog_hist"], C.UP["exog_scen"], C.UP[f"io_{r}"], spec, prior_k, em, anchor=C.ANCHOR_GROWTH)
    bu[r].to_csv(os.path.join(C.OUTPUT, f"bottomup_annual_{r}.csv"), index=False, encoding="utf-8-sig")
    bu_raw[r].to_csv(os.path.join(C.OUTPUT, f"bottomup_raw_annual_{r}.csv"), index=False, encoding="utf-8-sig")
    L, _, _, Bm, _ = V.fit_full(C.UP[f"levels_{r}"], C.UP["exog_hist"], C.UP[f"io_{r}"], spec, prior_k, em)
    tr, ta_ = R.totals(bu_raw[r]), R.totals(bu[r])
    a25 = tr.loc[C.BASE_YEAR, "실적"]
    anc.append({"분류": r, "표본 산업 합계 평균 성장률(%)": V.sum_growth_mean(L), "목표(잠재성장률, %)": C.ANCHOR_GROWTH,
                "δ(연 %p)": V.anchor_drift(L, Bm, C.ANCHOR_GROWTH)[1],
                "바텀업 S1 연평균 — 재설정 전(%)": R.cagr(a25, tr.loc[C.END_YEAR, "S1"], 10),
                "바텀업 S1 연평균 — 재설정 후(%)": R.cagr(a25, ta_.loc[C.END_YEAR, "S1"], 10)})
anc = pd.DataFrame(anc).set_index("분류")
anc.to_csv(os.path.join(C.OUTPUT, "drift_anchor.csv"), encoding="utf-8-sig")
tot = {r: R.totals(bu[r]) for r in C.RES}
gap = max((tot[r]["실적"].dropna() - tot[62]["실적"].dropna()).abs().max() for r in C.RES)
assert gap < 0.1, "36·62·69 실적 합계가 일치하지 않는다"
anc
Out[4]:
표본 산업 합계 평균 성장률(%) 목표(잠재성장률, %) δ(연 %p) 바텀업 S1 연평균 — 재설정 전(%) 바텀업 S1 연평균 — 재설정 후(%)
분류
36 3.324 1.800 1.524 3.965 2.457
62 3.324 1.800 1.524 3.996 2.519
69 3.324 1.800 1.524 4.002 2.525

재설정 후에도 바텀업 합계의 연평균은 잠재성장률(1.8%)보다 높다. 나머지 차이의 대부분은 구성 효과이다. 각 산업이 자기 연평균으로 일정하게 성장한다고 두어도, 성장이 빠른 산업(전자부품·통신장비 등)의 비중이 커지므로 합계 증가율은 해마다 높아진다. 아래 표의 둘째 행이 그 크기이다.

In [5]:
b62 = bu[62]
a62 = b62[(b62["시나리오"] == "실적") & (b62["연도"] == C.BASE_YEAR)].set_index("산업")[R.VAL]
f62 = b62[b62["시나리오"] == "S1"].pivot(index="연도", columns="산업", values=R.VAL)
g62 = f62.sum(axis=1); g62.loc[C.BASE_YEAR] = a62.sum(); g62 = g62.sort_index()
c62 = (f62.loc[C.END_YEAR] / a62) ** 0.1                                     # 산업별 연평균 성장인자
comp = pd.Series({y: (a62 * c62 ** (y - C.BASE_YEAR)).sum() for y in range(C.BASE_YEAR, C.END_YEAR + 1)})
w25 = a62 / a62.sum()
print(f"2025년 비중 가중 산업 연평균(구성 효과 없는 합계 증가율): {((w25 * (c62 - 1)).sum()) * 100:.2f}% · 합계 연평균 {R.cagr(a62.sum(), g62[C.END_YEAR], 10):.2f}%")
pd.DataFrame({"62분류 S1 바텀업 합계 증가율(%)": g62.pct_change() * 100,
              "산업별 연평균 고정 시 합계 증가율(%)": comp.pct_change() * 100}).loc[2026:2035].T
2025년 비중 가중 산업 연평균(구성 효과 없는 합계 증가율): 2.20% · 합계 연평균 2.52%
Out[5]:
2026 2027 2028 2029 2030 2031 2032 2033 2034 2035
62분류 S1 바텀업 합계 증가율(%) 2.023 2.137 2.275 2.426 2.547 2.615 2.679 2.752 2.829 2.908
산업별 연평균 고정 시 합계 증가율(%) 2.196 2.265 2.335 2.406 2.478 2.552 2.626 2.701 2.777 2.854

4. 통제 총량 — BOKDPM 기준선과 총량 VARX의 시나리오 편차¶

총량 VARX만으로는 10년 총량 경로를 정하기 어렵다. 4장에서 총량(실질GDP 한 계열)은 수준 명세가 1기 앞 예측에서 가장 정확했다. 그러나 수준 명세에서 자기 시차 계수의 합이 1보다 작으면 (아래 출력) 추세항이 없는 모형이 일정한 수준으로 수렴하려 하므로, 전망 기간이 길수록 성장률이 기계적으로 낮아진다. 이전 판에서는 실질금리 수준의 상승 경로가 이 감소를 가렸다(6장 진단). 정비판은 총량의 기준선을 장기 수렴값을 정비한 BOKDPM 실질GDP 전망(잠재성장률 1.8%로 수렴)에서 가져오고, 총량 VARX는 시나리오 외생 경로에 대한 반응의 차이(S1 대비 비율)를 내는 데만 쓴다.

실질GDP는 산업 부가가치의 합에 순생산물세를 더한 값이며 연쇄가격 합계는 가법적이지 않으므로, 정합화에는 총량의 수준이 아니라 2025년 대비 성장인자만 쓴다.

In [6]:
spec_a, prior_a, em_a = C.SPEC["총량"]
agg_varx = V.run_forecast(C.UP["levels_agg"], C.UP["exog_hist"], C.UP["exog_scen"], None, spec_a, prior_a, em_a)
agg_varx.to_csv(os.path.join(C.OUTPUT, "topdown_varx_annual.csv"), index=False, encoding="utf-8-sig")
agg = R.topdown_bokdpm(lgdp, agg_varx)
agg.to_csv(os.path.join(C.OUTPUT, "topdown_annual.csv"), index=False, encoding="utf-8-sig")
La, _, _, Ba, _ = V.fit_full(C.UP["levels_agg"], C.UP["exog_hist"], None, spec_a, prior_a, em_a)
print(f"총량 VARX(수준) 자기 시차 계수의 합: {Ba[:V.P, 0].sum():.4f}")
tv, tb_ = R.totals(agg_varx), R.totals(agg)
rows = []
for s in C.SCENARIOS:
    rows.append({"시나리오": f"{s} {C.SCN_NAME[s]}",
                 "총량 VARX 연평균(%)": R.cagr(tv.loc[C.BASE_YEAR, "실적"], tv.loc[C.END_YEAR, s], 10),
                 "통제 총량(BOKDPM × VARX 편차) 연평균(%)": R.cagr(tb_.loc[C.BASE_YEAR, "실적"], tb_.loc[C.END_YEAR, s], 10)})
display(pd.DataFrame(rows).set_index("시나리오"))
yy = list(range(2026, 2036))
pd.DataFrame({"총량 VARX S1 증가율(%)": [100 * (tv.loc[y, "S1"] / (tv.loc[y - 1, "S1"] if y > 2026 else tv.loc[2025, "실적"]) - 1) for y in yy],
              "BOKDPM 실질GDP 증가율(%)": [100 * (tb_.loc[y, "S1"] / (tb_.loc[y - 1, "S1"] if y > 2026 else tb_.loc[2025, "실적"]) - 1) for y in yy]},
             index=yy).T
총량 VARX(수준) 자기 시차 계수의 합: 0.9883
총량 VARX 연평균(%) 통제 총량(BOKDPM × VARX 편차) 연평균(%)
시나리오
S1 기준 1.393 1.913
S2 공급제약 1.373 1.893
S3 제약완화 1.413 1.934
S4 기술도약 1.384 1.905
S5 지정학 1.401 1.921
Out[6]:
2026 2027 2028 2029 2030 2031 2032 2033 2034 2035
총량 VARX S1 증가율(%) 1.720 1.371 1.476 1.516 1.473 1.392 1.330 1.271 1.217 1.166
BOKDPM 실질GDP 증가율(%) 2.192 1.726 2.240 1.903 1.891 1.860 1.842 1.832 1.826 1.823

5. 정합화 — 비례 벤치마킹과 기존 전망 수축 결합¶

세 분류(36·62·69)와 다섯 시나리오에 같은 통제 총량을 적용한다. 결과는 소수 셋째 자리(백만 원 단위)에서 반올림한다. 단계별 효과를 보기 위해 기존 전망 결합 없이 총량 벤치마킹만 한 경우($w=0$)의 합계도 함께 저장한다.

In [7]:
rec, facs, bench = {}, [], []
for r in C.RES:
    rec[r], fac = R.reconcile(bu[r], agg, prior, C.W_PRIOR)
    rec[r].to_csv(os.path.join(C.OUTPUT, f"forecast_annual_{r}.csv"), index=False, encoding="utf-8-sig")
    facs.append(fac.assign(분류=r))
    r0, _ = R.reconcile(bu[r], agg, prior, 0.0)
    bench.append(R.totals(r0).stack().rename("합계_십억원").reset_index().assign(분류=r))
    ind25 = rec[r][(rec[r]["시나리오"] == "실적") & (rec[r]["연도"] == C.BASE_YEAR)][R.VAL].sum()
    t35 = rec[r][(rec[r]["시나리오"] == "S1") & (rec[r]["연도"] == C.END_YEAR)][R.VAL].sum()
    print(f"[{r}] 정합화 완료 | S1 전산업 2035 = {t35/1000:,.1f}조 (연평균 {R.cagr(ind25, t35, 10):+.3f}%)")
fac_all = pd.concat(facs)[["분류", "시나리오", "연도", "바텀업합_십억원", "총량모형_gM", "기존전망_gP", "결합_g", "통제총량_십억원", "비례계수"]]
fac_all.to_csv(os.path.join(C.OUTPUT, "reconcile_factors.csv"), index=False, encoding="utf-8-sig")
pd.concat(bench)[["분류", "시나리오", "연도", "합계_십억원"]].to_csv(os.path.join(C.OUTPUT, "benchmark_only_totals.csv"), index=False, encoding="utf-8-sig")
f62 = fac_all[(fac_all["분류"] == 62) & (fac_all["시나리오"] == "S1")].set_index("연도")
f62.assign(바텀업합_조원=f62["바텀업합_십억원"] / 1e3, 통제총량_조원=f62["통제총량_십억원"] / 1e3)[
    ["바텀업합_조원", "총량모형_gM", "기존전망_gP", "결합_g", "통제총량_조원", "비례계수"]].style.format("{:,.4f}")
[36] 정합화 완료 | S1 전산업 2035 = 2,609.0조 (연평균 +1.820%)
[62] 정합화 완료 | S1 전산업 2035 = 2,609.0조 (연평균 +1.820%)
[69] 정합화 완료 | S1 전산업 2035 = 2,609.0조 (연평균 +1.820%)
Out[7]:
  바텀업합_조원 총량모형_gM 기존전망_gP 결합_g 통제총량_조원 비례계수
연도            
2026 2,222.5616 1.0219 1.0160 1.0195 2,221.0731 0.9993
2027 2,270.0475 1.0396 1.0338 1.0372 2,259.6269 0.9954
2028 2,321.6828 1.0628 1.0524 1.0586 2,306.2547 0.9934
2029 2,378.0176 1.0831 1.0718 1.0785 2,349.5813 0.9880
2030 2,438.5900 1.1035 1.0908 1.0985 2,392.9632 0.9813
2031 2,502.3568 1.1241 1.1096 1.1183 2,436.1243 0.9735
2032 2,569.4023 1.1448 1.1280 1.1381 2,479.2299 0.9649
2033 2,640.1137 1.1658 1.1461 1.1579 2,522.3827 0.9554
2034 2,714.8086 1.1870 1.1638 1.1777 2,565.6383 0.9451
2035 2,793.7447 1.2087 1.1812 1.1976 2,609.0280 0.9339

비례계수가 1에 가까울수록 바텀업과 통제 총량이 가깝다. 이전 판의 62분류 S1 비례계수는 2035년 0.77이었다(바텀업 3,508조 원을 2,689조 원으로 줄였다).

6. 비례 벤치마킹의 성질 점검¶

(가) 같은 연도·시나리오에서 모든 산업의 조정 비율 $VA/\hat y$가 하나의 값(비례계수)이다. (나) 따라서 산업 구성비는 바텀업 전망과 같다. (다) 세 분류의 통제 총량이 같으므로 전산업 합계가 분류와 관계없이 같다. (라) 구성비가 바뀌지 않으므로 정합화 결과를 다시 정합화해도 결과가 같다(멱등). 모든 점검은 소수 셋째 자리 반올림의 범위 안에서 판정한다.

In [8]:
b, r62 = bu[62], rec[62]
m = r62.merge(b, on=["시나리오", "연도", "산업"], suffixes=("", "_바텀업"))
m = m[m["시나리오"] != "실적"]
m["비율"] = m[R.VAL] / m[R.VAL + "_바텀업"]
spread = m.groupby(["시나리오", "연도"])["비율"].agg(lambda x: x.max() - x.min()).max()
sh_r = m[R.VAL] / m.groupby(["시나리오", "연도"])[R.VAL].transform("sum")
sh_b = m[R.VAL + "_바텀업"] / m.groupby(["시나리오", "연도"])[R.VAL + "_바텀업"].transform("sum")
T = {r: R.totals(rec[r]) for r in C.RES}
tgap = max((T[r] - T[62]).abs().max().max() for r in C.RES)
r62b, _ = R.reconcile(r62, agg, prior, C.W_PRIOR)
idem = (r62b[R.VAL] - r62[R.VAL]).abs().max()
props = pd.DataFrame([
    {"점검": "(가) 산업 간 조정 비율의 최대 범위", "값": spread, "기준": "< 1e-4 (작은 산업의 반올림 영향)"},
    {"점검": "(나) 산업 구성비 최대 절대차", "값": float((sh_r - sh_b).abs().max()), "기준": "< 1e-8"},
    {"점검": "(다) 분류 간 전산업 합계 최대차(십억 원)", "값": tgap, "기준": "< 0.035 (69 × 0.0005)"},
    {"점검": "(라) 재정합화 후 최대 절대차(십억 원)", "값": float(idem), "기준": "≤ 0.002"},
])
assert spread < 1e-4 and (sh_r - sh_b).abs().max() < 1e-8 and tgap < 0.035 and idem <= 0.002 + 1e-9
props.style.format({"값": "{:.2e}"}).hide(axis="index")
Out[8]:
점검 값 기준
(가) 산업 간 조정 비율의 최대 범위 3.11e-05 < 1e-4 (작은 산업의 반올림 영향)
(나) 산업 구성비 최대 절대차 4.12e-10 < 1e-8
(다) 분류 간 전산업 합계 최대차(십억 원) 8.00e-03 < 0.035 (69 × 0.0005)
(라) 재정합화 후 최대 절대차(십억 원) 1.00e-03 ≤ 0.002

7. 요약표·총계 시계열·보고서2 입력¶

산업별 요약표(2025·2035년 수준과 연평균)와 전산업 총계 시계열(조 원, 실적과 S1–S5, 2025년 접합)을 만든다. 정합화 후 S1(2025년 실적 + 2026–2035년 전망)을 보고서2 4장(AI 확산 시나리오) 재현 패키지의 입력 형식(base_va_62.csv·base_va_69.csv)으로 저장한다.

In [9]:
for r in C.RES:
    R.summary_table(rec[r]).to_csv(os.path.join(C.OUTPUT, f"forecast_summary_{r}.csv"), index=False, encoding="utf-8-sig")
    R.total_series(rec[r]).to_csv(os.path.join(C.OUTPUT, f"forecast_total_{r}.csv"), encoding="utf-8-sig")
for r in (62, 69):
    R.base_scenario(rec[r]).to_csv(os.path.join(C.OUTPUT, f"base_va_{r}.csv"), index=False, encoding="utf-8-sig")
R.total_series(rec[62]).loc[[2000, 2010, 2020, 2024, 2025, 2026, 2030, 2035]]
Out[9]:
실적 S1 S2 S3 S4 S5
연도
2000 955.100 NaN NaN NaN NaN NaN
2010 1,460.500 NaN NaN NaN NaN NaN
2020 1,890.800 NaN NaN NaN NaN NaN
2024 2,145.700 NaN NaN NaN NaN NaN
2025 2,178.500 2,178.500 2,178.500 2,178.500 2,178.500 2,178.500
2026 NaN 2,221.100 2,198.300 2,233.400 2,233.600 2,203.200
2030 NaN 2,393.000 2,391.100 2,394.500 2,400.100 2,393.800
2035 NaN 2,609.000 2,605.900 2,612.200 2,607.700 2,610.200

8. 바텀업과 총량의 괴리 — 분해와 절차 비교¶

62분류 S1의 2025–2035년 연평균으로 바텀업과 통제 총량의 차이를 단계별로 나눈다.

  • 표본 평균 성장률 효과: 재설정 전 바텀업 − 재설정 후 바텀업. 산업모형이 2000년대가 섞인 표본 평균 성장률을 잇는 데서 생긴 차이이다.
  • 구성 효과: 재설정 후 바텀업 − 2025년 비중 가중 산업 연평균. 성장이 빠른 산업의 비중이 커지는 데서 생긴다.
  • 나머지: 2025년 비중 가중 산업 연평균 − 통제 총량. 외생 경로·동학과 BOKDPM 기준선의 차이이다.

이어서 같은 외생으로 이전 판 절차(드리프트 재설정 없음, 통제 총량 = 총량 VARX, $w=0.45$)를 적용한 결과와 정비판 절차를 비교한다. 이전 판 절차로는 바텀업과 총량이 크게 벌어진다.

In [10]:
a25 = R.totals(bu[62]).loc[C.BASE_YEAR, "실적"]
cg = lambda t, s="S1": R.cagr(a25, t.loc[C.END_YEAR, s], 10)
tr_, ta_ = R.totals(bu_raw[62]), R.totals(bu[62])
wavg = float((w25 * (c62 - 1)).sum() * 100)
tot_ctrl = R.cagr(R.totals(agg).loc[C.BASE_YEAR, "실적"], R.totals(agg).loc[C.END_YEAR, "S1"], 10)
gd = pd.DataFrame([
    {"단계": "바텀업(재설정 전)", "연평균(%)": cg(tr_)},
    {"단계": "바텀업(재설정 후)", "연평균(%)": cg(ta_), "차이(%p)": cg(tr_) - cg(ta_), "설명": "표본 평균 성장률 효과"},
    {"단계": "2025년 비중 가중 산업 연평균", "연평균(%)": wavg, "차이(%p)": cg(ta_) - wavg, "설명": "구성 효과"},
    {"단계": "통제 총량 g^M(BOKDPM × VARX 편차)", "연평균(%)": tot_ctrl, "차이(%p)": wavg - tot_ctrl, "설명": "외생·동학과 기준선의 차이"},
]).set_index("단계")
gd.to_csv(os.path.join(C.OUTPUT, "gap_decomposition.csv"), encoding="utf-8-sig")
display(gd.style.format({"연평균(%)": "{:.3f}", "차이(%p)": "{:+.3f}"}, na_rep=""))

rows = []
for lab, bsrc, asrc, w in [("이전 판 절차(재설정 없음, 총량 VARX, w=0.45)", bu_raw, agg_varx, C.W_PRIOR_PREV),
                           ("정비판 절차(재설정, BOKDPM 기준선, w=0.40)", bu, agg, C.W_PRIOR)]:
    rr, ff = R.reconcile(bsrc[62], asrc, prior, w)
    ta2, tb2, tr2 = R.totals(asrc), R.totals(bsrc[62]), R.totals(rr)
    for s in C.SCENARIOS:
        rows.append({"절차": lab, "시나리오": s, "바텀업 연평균(%)": cg(tb2, s),
                     "총량 연평균(%)": R.cagr(ta2.loc[C.BASE_YEAR, "실적"], ta2.loc[C.END_YEAR, s], 10),
                     "정합화 2035(조원)": tr2.loc[C.END_YEAR, s] / 1e3, "정합화 연평균(%)": cg(tr2, s),
                     "비례계수 2035": float(ff[(ff["시나리오"] == s) & (ff["연도"] == C.END_YEAR)]["비례계수"].iloc[0])})
mc = pd.DataFrame(rows)
mc.to_csv(os.path.join(C.OUTPUT, "method_comparison.csv"), index=False, encoding="utf-8-sig")
mc[mc["시나리오"] == "S1"].set_index("절차").drop(columns="시나리오").style.format("{:,.3f}")
  연평균(%) 차이(%p) 설명
단계      
바텀업(재설정 전) 3.996
바텀업(재설정 후) 2.519 +1.477 표본 평균 성장률 효과
2025년 비중 가중 산업 연평균 2.196 +0.323 구성 효과
통제 총량 g^M(BOKDPM × VARX 편차) 1.913 +0.282 외생·동학과 기준선의 차이
Out[10]:
  바텀업 연평균(%) 총량 연평균(%) 정합화 2035(조원) 정합화 연평균(%) 비례계수 2035
절차          
이전 판 절차(재설정 없음, 총량 VARX, w=0.45) 3.996 1.393 2,533.690 1.522 0.786
정비판 절차(재설정, BOKDPM 기준선, w=0.40) 2.519 1.913 2,609.028 1.820 0.934

9. 시나리오 차이의 외생별 분해¶

정비판에서는 시나리오 간 차이가 작다. 2035년 합계의 시나리오 차이(각 시나리오 − S1, %)를 외생별로 나눈다. 외생 $k$의 기여는 S1 외생 경로에서 $k$ 하나만 해당 시나리오의 경로로 바꾸어 다시 전망한 값과 S1 전망의 차이이다. 통제 총량의 시나리오 편차를 내는 총량 VARX와 62분류 바텀업(드리프트 재설정)을 함께 본다. 모형이 로그 수준에서 선형이므로 기여의 합은 전체 차이와 근사적으로 같다.

In [11]:
dec_a = V.scenario_exog_decomp(C.UP["levels_agg"], C.UP["exog_hist"], C.UP["exog_scen"], None, *C.SPEC["총량"])
dec_b = V.scenario_exog_decomp(C.UP["levels_62"], C.UP["exog_hist"], C.UP["exog_scen"], C.UP["io_62"], *C.SPEC["산업"], anchor=C.ANCHOR_GROWTH)
dec = pd.concat({"총량 VARX": dec_a, "바텀업 62": dec_b})
dec.to_csv(os.path.join(C.OUTPUT, "scenario_exog_decomp.csv"), encoding="utf-8-sig")
dec.style.format("{:+.3f}")
Out[11]:
    전체(%) 유가(%) 세계수요(%) 실질금리(%) 실질환율(%) 고용_취업자(%) 우리경기(%)
  시나리오              
총량 VARX S2 -0.202 -0.023 +0.069 +0.015 +0.039 -0.222 -0.080
S3 +0.202 -0.009 -0.035 -0.009 -0.022 +0.230 +0.045
S4 -0.085 +0.000 -0.076 -0.017 -0.047 -0.034 +0.090
S5 +0.077 +0.043 +0.029 +0.012 +0.029 +0.027 -0.064
바텀업 62 S2 -0.024 -0.075 +0.003 +0.000 -0.000 -0.051 +0.112
S3 -0.028 -0.029 +0.001 -0.000 +0.000 +0.051 -0.051
S4 -0.089 +0.000 +0.004 -0.006 +0.007 -0.002 -0.090
S5 +0.263 +0.163 +0.000 +0.000 -0.000 +0.000 +0.087

10. 산업별 성장 요인 분해 — 62분류 S1(선형 SHAP)¶

전망 갱신 카드(forecast-va-2025-2035.html)의 '성장 요인 분해' 막대와 자동 해설이 이 절의 산출물을 쓴다. 62분류 바텀업(드리프트 재설정 후) S1 전망의 2025Q4→2035Q4 로그 성장(×100, %p)을 과거추세(상수항), 산업 동학(자기·교차 시차), 외생 6종의 기여로 나눈다. 차분 VARX $\Delta y_t = c + \sum_l A_l\Delta y_{t-l} + \sum_{s=0}^{2}\Gamma_s x_{t-s}$에서는 매 분기의 전망값이 세 묶음의 합이므로 40분기를 더하면 분해가 정확하다(성분의 합 = 10년 로그 성장). 외생 기여는 그 분기의 계수 × 외생값(기준점 0)이며, 외생 충격이 시차를 거쳐 다음 분기로 퍼지는 몫은 산업 동학에 들어간다. 계산식은 이전 판 분해(6장 진단 패키지의 선형 SHAP, 카드의 이전 판 자료)와 같고, 달라진 것은 정비판 외생(실질금리 1차 차분 등)과 드리프트 재설정 계수이다.

  • 과거추세는 재설정 후 상수항의 기여이다. 재설정은 산업 $i$의 상수항을 $(1-\sum_{l,j}A_{l,ij})\,\delta/4$만큼 낮추므로, 과거추세는 재설정 전보다 $10\,(1-\sum_{l,j}A_{l,ij})\,\delta$ %p 작다.
  • 이 분해는 정합화 이전 바텀업 경로를 대상으로 한다. 최종 전망은 같은 연도의 모든 산업에 같은 비례계수 $k_t$를 곱하므로, 통제 총량 맞춤의 효과는 모든 산업에서 $100\ln k_{2035}$ %p로 같다(연간 수준 기준).
  • 기여는 모형이 추정한 과거 공행(동조) 관계에 따른 분해이며 인과 효과가 아니다.

점검: (가) 성분의 합 = 합계, (나) 합계 = 분해 경로의 100·ln(2035Q4/2025Q4), (다) 분해 경로의 연간 합 = 3절 바텀업 전망(bottomup_annual_62.csv, 소수 셋째 자리 반올림 범위), (라) 과거추세의 재설정분 = $-10\,(1-\sum A)\,\delta$.

In [12]:
spec, prior_k, em = C.SPEC["산업"]
args62 = (C.UP["levels_62"], C.UP["exog_hist"], C.UP["exog_scen"], C.UP["io_62"], spec, prior_k, em)
sh, path = V.shap_decompose(*args62, anchor=C.ANCHOR_GROWTH, scenario="S1")
sh_raw, _ = V.shap_decompose(*args62, anchor=None, scenario="S1")              # 재설정 전 분해(점검 (라)용)
L62, _, _, B62, _ = V.fit_full(C.UP["levels_62"], C.UP["exog_hist"], C.UP["io_62"], spec, prior_k, em)
n62 = L62.shape[1]
lagsum = B62[:V.P * n62, :].reshape(V.P, n62, n62).sum(axis=(0, 1))         # 방정식 i의 내생 시차 계수 합
delta = V.anchor_drift(L62, B62, C.ANCHOR_GROWTH)[1]
b62s1 = bu[62][bu[62]["시나리오"] == "S1"].pivot(index="연도", columns="산업", values=R.VAL)
path_a = V.q_to_annual(path.drop(index="2025Q4"))[b62s1.columns]
k35 = float(fac_all[(fac_all["분류"] == 62) & (fac_all["시나리오"] == "S1") & (fac_all["연도"] == C.END_YEAR)]["비례계수"].iloc[0])
chk = pd.DataFrame([
    {"점검": "(가) 성분 합 − 합계, 최대 절대차(%p)", "값": float((sh.drop(columns="total").sum(axis=1) - sh["total"]).abs().max()), "허용오차": 1e-9},
    {"점검": "(나) 합계 − 100·ln(경로 2035Q4/2025Q4), 최대 절대차(%p)",
     "값": float((sh["total"] - 100 * np.log(path.loc["2035Q4"] / path.loc["2025Q4"])).abs().max()), "허용오차": 1e-9},
    {"점검": "(다) 분해 경로 연간 합 − 바텀업 S1(bottomup_annual_62.csv), 최대 절대차(십억 원)",
     "값": float((path_a.loc[2026:2035] - b62s1.loc[2026:2035]).abs().max().max()), "허용오차": 0.0005 + 1e-9},
    {"점검": "(라) 과거추세 재설정분 − (−10(1−ΣA)δ), 최대 절대차(%p)",
     "값": float(((sh["drift"] - sh_raw["drift"]) + 10 * (1 - lagsum) * delta).abs().max()), "허용오차": 1e-9},
])
chk["통과"] = chk["값"] <= chk["허용오차"]
chk.to_csv(os.path.join(C.OUTPUT, "shap_check.csv"), index=False, encoding="utf-8-sig")
assert chk["통과"].all(), chk
sh.to_csv(os.path.join(C.OUTPUT, "shap_62.csv"), encoding="utf-8-sig")
meta = {"version": "2026-10 정비판", "scenario": "S1", "period": "2025Q4–2035Q4", "unit": "10년 로그 성장 기여(100·Δln, %p)",
        "target": "정합화 이전 바텀업(드리프트 재설정 후)", "delta": round(delta, 3), "k_2035": round(k35, 4),
        "reconcile_2035": round(100 * np.log(k35), 2)}
R.json_dump({"exog": V.EXOG, "meta": meta,
             "rows": [{"ind": i, **{c: round(float(v), 2) for c, v in r.items()}} for i, r in sh.iterrows()]},
            os.path.join(C.OUTPUT, "shap_62.json"))
print(f"δ = 연 {delta:.3f}%p · 비례계수 k_2035 = {k35:.4f} → 통제 총량 맞춤 {100 * np.log(k35):+.2f}%p(모든 산업 공통)")
chk.style.format({"값": "{:.2e}", "허용오차": "{:.1e}"}).hide(axis="index")
δ = 연 1.524%p · 비례계수 k_2035 = 0.9339 → 통제 총량 맞춤 -6.84%p(모든 산업 공통)
Out[12]:
점검 값 허용오차 통과
(가) 성분 합 − 합계, 최대 절대차(%p) 0.00e+00 1.0e-09 True
(나) 합계 − 100·ln(경로 2035Q4/2025Q4), 최대 절대차(%p) 3.89e-13 1.0e-09 True
(다) 분해 경로 연간 합 − 바텀업 S1(bottomup_annual_62.csv), 최대 절대차(십억 원) 4.99e-04 5.0e-04 True
(라) 과거추세 재설정분 − (−10(1−ΣA)δ), 최대 절대차(%p) 8.17e-14 1.0e-09 True

요인별 기여의 크기를 이전 판 분해(카드의 이전 판 자료 published_prev/va_detail_62.json: 드리프트 재설정 없음, 실질금리 수준 투입)와 비교한다. 이전 판에서 실질금리 수준 경로가 만들던 큰 양(+)의 기여는 1차 차분 투입에서 사라지고, 과거추세는 재설정만큼 작아진다. 아래 둘째 표는 합계 상·하위 5개 산업이다.

In [13]:
vd_prev = json.load(open(os.path.join(C.PREV_DIR, "va_detail_62.json"), encoding="utf-8"))
sp = pd.DataFrame([{"total": x["shap"]["total"], "drift": x["shap"]["drift"], "dynamics": x["shap"]["dynamics"], **x["shap"]["exog"]}
                   for x in vd_prev["inds"]])
cols = ["total", "drift", "dynamics"] + V.EXOG
NAME = {"total": "합계(10년 로그 성장)", "drift": "과거추세", "dynamics": "산업 동학"}
cmp_ = pd.DataFrame({"정비판 산업 평균(%p)": sh[cols].mean().values, "정비판 평균 절대 기여(%p)": sh[cols].abs().mean().values,
                     "이전 판 산업 평균(%p)": sp[cols].mean().values, "이전 판 평균 절대 기여(%p)": sp[cols].abs().mean().values},
                    index=[NAME.get(c, c) for c in cols])
display(cmp_.style.format("{:.2f}"))
top = sh.assign(최대외생=sh[V.EXOG].abs().idxmax(axis=1)).sort_values("total", ascending=False)
pd.concat([top.head(5), top.tail(5)])[cols + ["최대외생"]].round(2)
  정비판 산업 평균(%p) 정비판 평균 절대 기여(%p) 이전 판 산업 평균(%p) 이전 판 평균 절대 기여(%p)
합계(10년 로그 성장) 10.37 21.77 34.79 36.97
과거추세 4.08 23.53 11.71 29.02
산업 동학 5.46 14.21 -2.18 18.61
유가 0.69 0.90 3.83 4.75
세계수요 -0.34 0.83 -0.35 0.92
실질금리 0.02 0.06 19.97 25.19
실질환율 0.02 0.72 0.06 0.27
고용_취업자 -0.01 0.02 1.39 1.74
우리경기 0.45 0.78 0.37 0.55
Out[13]:
total drift dynamics 유가 세계수요 실질금리 실질환율 고용_취업자 우리경기 최대외생
산업
23.의료ㆍ정밀ㆍ광학기기ㆍ시계 74.960 97.780 -24.450 1.140 0.500 0.030 -0.570 -0.010 0.530 유가
22.전자부품ㆍ컴퓨터ㆍ영상ㆍ음향ㆍ통신장비 74.790 78.000 -3.880 0.850 0.200 0.070 -0.860 -0.010 0.410 실질환율
58.사회복지 서비스업 45.460 52.720 -7.650 0.170 -0.330 -0.080 0.450 -0.030 0.200 실질환율
57.의료ㆍ보건업 44.800 51.460 -7.060 0.210 -0.350 -0.070 0.420 -0.020 0.220 실질환율
47.보험ㆍ연금업 44.540 30.150 15.730 -0.540 0.190 0.140 -0.220 0.020 -0.920 우리경기
9.섬유ㆍ의복 -33.150 -30.670 -4.300 1.780 -0.420 0.050 -0.580 -0.030 1.020 유가
10.가죽제품 -33.590 -32.810 -2.540 1.800 -0.490 0.050 -0.570 -0.030 1.000 유가
5.금속ㆍ비금속광업 -34.120 -48.830 11.400 0.060 0.380 -0.010 1.620 -0.010 1.250 실질환율
4.석탄ㆍ원유ㆍ천연가스 -34.150 -61.670 23.770 0.180 0.340 -0.000 1.550 -0.010 1.680 우리경기
14.석탄ㆍ석유제품 -60.600 -46.440 -19.620 6.210 -2.070 -0.000 -0.270 -0.000 1.600 유가