02 분석 — BOKDPM 재추정, 기준선 전망, 시나리오와 산업 외생 6종¶

계산 경로.

  1. 정상상태 정비: 기준선의 장기 수렴값을 정하는 정상상태 13개를 현재 여건(공식 추정·목표치, 최근 10년 평균)에 맞게 바꾼다(2026-10 정비).
  2. 재추정: BOKDPM(70개 방정식)의 행태계수 20개와 구조충격 표준편차 34개를 2000Q1–2025Q4 관측 20변수로 베이지언 추정한다.
  3. 기준선: 과거는 칼만 평활값, 미래(2026Q1–2035Q4)는 사후최빈값 모수로 계산한 무충격 전망이다.
  4. 시나리오 설계: 인구 가정(저·중·고위)과 감쇠 충격 4종(미국수요 yus·유가 oil·교역비용 tcost·내수 dshk)을 레버로 하여 S1–S5를 정한다.
  5. 거시 블록: 레버를 캘리브레이션 5식 모형(IS·필립스·테일러·UIP·오쿤)에 넣어 시나리오별 거시경로를 구한다.
  6. 산업 외생 6종: 기준선에 시나리오 편차를 더한 뒤 BOKDPM 변수의 관측 가능한 조합(유가·세계수요·실질금리·실질환율·취업자 수·국내 경기)을 만든다. 외생의 정의에는 회귀계수가 없다.
  7. 투입형 변환: 적분차수에 맞추어 수준 또는 차분으로 바꾼다. 2026-10 정비에서 실질금리를 1차 차분으로 바꾸었다.

실행 방식. 기본 실행은 저장된 사후 표본·평활값·전망값을 읽으며, 2–3단계의 Dynare 계산을 다시 하지 않는다. 환경변수 FULL=1을 주면 2절과 3절에서 Octave+Dynare로 추정과 전망을 다시 실행한다(약 2시간 15분, Octave 11.1·Dynare 7.1 필요).

산출물. output/의 steady_state_revision.csv, posterior_summary.csv, mcmc_diagnostics.csv, baseline_forecast_70.csv, baseline_annual.csv, bokdpm_lgdp_2000_2035.csv, scenario_levers.csv, scenario_lever_paths.csv, dsge_macro_paths.csv, dsge_exog_paths.csv, exog_levels_2000_2035.csv, 외생변수_수준_시나리오별(2000-2035).xlsx, lever_reach.csv, unit_root_real_rate.csv, exog_hist_v2.csv, exog_scen_v2.csv

In [1]:
import os, time
import numpy as np, pandas as pd
import config as C
import bokdpm_macro as B
pd.set_option("display.float_format", lambda v: f"{v:,.4f}")
pd.set_option("display.width", 160)
env = C.setup()
lab = pd.read_csv(C.LABOR, index_col=0, encoding="utf-8-sig")
pop = pd.read_csv(C.POP, encoding="utf-8-sig")
cpi = pd.read_csv(C.CPI, encoding="utf-8-sig")
keis = pd.read_csv(C.KEIS, encoding="utf-8-sig")
Python 3.12.10 · pandas 2.2.3 · numpy 2.4.6 · scipy 1.18.0 · matplotlib 3.11.0
  실행 방식   ONLINE=0 (ECOS·FRED 재수집) · FULL=0 (Dynare 재추정)
  ECOS 키     없음 · FRED 키 없음  (ONLINE=0이면 쓰지 않는다)
  Octave      있음 · Dynare 있음  (FULL=0이면 쓰지 않는다)
  BOKDPM_data_2025.m               있음      23,440 bytes
  bokdpm_forecast_2026_2035.csv    있음      56,135 bytes
  bokdpm_posterior_draws.csv.gz    있음   5,426,716 bytes
  bokdpm_posterior_mode.csv        있음       2,909 bytes
  bokdpm_smoothed_2000_2025.csv    있음     142,828 bytes
  cpi_kor_1999_2000.csv            있음         146 bytes
  ecos_labor_force.csv             있음       2,620 bytes
  keis_labor_supply_2024_2034.csv  있음         353 bytes
  kosis_생산연령인구.csv                 있음         735 bytes

1. 모형 구조¶

BOKDPM은 한국은행 조사국이 개발한 준구조 거시모형이다(모형 파일 머리말: Kang 2008, Pyoun·Park 2009, Kim·Kim 2010). 한국 블록(IS 곡선, 필립스 곡선, 테일러 준칙, 금리 기간구조와 위험프리미엄, 동태적 오쿤 법칙, 경상수지와 환율, 주택가격), 미국 블록(IS, 필립스, 테일러), 유가 방정식으로 이루어진다. 행태계수 20개와 충격 표준편차 34개를 추정하고, 나머지 계수와 정상상태 값은 캘리브레이션한다. 아래 수치는 dynare/BOKDPM_2025.mod에서 직접 읽는다.

In [2]:
mod = B.parse_mod(C.MODFILE)
assert mod["관측변수"] == B.VAROBS
struct = pd.Series({"내생변수": mod["내생변수"], "방정식": mod["방정식"], "구조충격": mod["외생충격"],
                    "관측변수": len(mod["관측변수"]),
                    "추정 행태계수": int((mod["사전분포"]["구분"] == "parameters").sum()),
                    "추정 충격 표준편차": int((mod["사전분포"]["구분"] == "shocks_std").sum())}, name="개수")
print("estimation(" + mod["추정옵션"] + ")")
struct.to_frame()
estimation(datafile=BOKDPM_data_2025, nobs=104, diffuse_filter, mode_compute=5, mh_replic=30000, mh_nblocks=2, mh_jscale=0.3, mh_drop=0.3)
Out[2]:
개수
내생변수 70
방정식 70
구조충격 34
관측변수 20
추정 행태계수 20
추정 충격 표준편차 34

1-1. 정상상태(장기 수렴값) 정비¶

모형의 정상상태 값은 무충격 전망이 수렴하는 장기값이며, 관측방정식의 추세(observation_trends: 실질GDP·물가·디플레이터·주택가격·미국 GDP·미국 물가·유가)에도 들어간다. 원 모형은 이 값을 2000년대 표본 평균으로 정하였다(파일 주석 "til '08q3", "til '09q4"). 그 결과 이전 판의 기준선은 잠재성장률 4.29%, 물가목표 3.0%, 유가 상승률 연 9.88%로 수렴하여 2030년대 실질GDP 성장률이 연 4%대, 두바이유가 2035년 배럴당 약 163달러가 되었다.

2026-10 정비는 정상상태 13개를 다음 원칙으로 바꾸었다.

  1. 공식 추정·목표치가 있으면 그 값을 쓴다: 잠재성장률(한국은행 2025–2029년 연평균 1.8%), 물가목표(2%), 중립금리(한국은행 명목 중간값 2.55% − 물가목표), 미국 잠재성장률(CBO 1.8%), 미국 중립금리(FOMC 장기 연방기금금리 3.2% − 미국 CPI 목표).
  2. 공식 값이 없으면 물가안정목표 2% 도입 이후 최근 10년(2016Q1–2025Q4) 평균을 쓴다: 금리 기간프리미엄 3개, 주택가격 상승률, 재정지출 증가율.
  3. 유가 상승률은 장기 실질유가 불변(달러 기준 상승률 = 미국 CPI 목표 2.3%)으로 둔다. 2000년대 원유 호황기의 연 9.9%는 장기 수렴값으로 쓸 근거가 없고, 2010–2025년 두바이유는 오히려 하락하였다.

정상상태는 선형 모형의 절편이므로 동학 계수(결정규칙)를 바꾸지 않는다. 다만 관측방정식의 추세가 바뀌므로 모수의 사후분포와 평활값이 달라지며, 따라서 Dynare로 다시 추정한다(2절). 아래 표의 이전 값과 새 값은 원본 모형 파일(dynare/original/)과 정비한 모형 파일에서 직접 읽어 정비표와 대조한 것이다.

In [3]:
ss_tab = B.steady_state_table(C.MODFILE, C.MODFILE_PREV)
ss_tab.to_csv(os.path.join(C.OUTPUT, "steady_state_revision.csv"), encoding="utf-8-sig")
obs = B.read_dynare_data(C.DATAFILE)
recent = B.recent_averages(obs)
print("최근 10년(2016Q1–2025Q4) 평균 — 정비표 근거값:")
print(recent.round(3).to_string())
for k in ["def_kor_ss", "tprem_kor_ss", "tprem_tb_kor_ss", "tprem_cb_kor_ss", "house_kor_ss", "ge_kor_ss"]:
    if k != "def_kor_ss":
        assert abs(round(recent[k], 2) - ss_tab.loc[k, "새 값"]) < 1e-9, k
print(B.SS_UNCHANGED_NOTE)
ss_tab
최근 10년(2016Q1–2025Q4) 평균 — 정비표 근거값:
def_kor_ss        2.0830
tprem_kor_ss      0.1840
tprem_tb_kor_ss   0.3000
tprem_cb_kor_ss   0.6710
house_kor_ss      2.3340
ge_kor_ss         6.5650
참고: CPI 상승률       2.0640
참고: 실질GDP 성장률     2.4430
참고: 두바이유 상승률      3.1800
UNR_kor_ss(3.6083)는 바꾸지 않았다. UNR_kor_BAR가 확률보행이라 이 값은 전망·우도에 영향이 없다. blt_kor_ss·blt_us_ss·s_kor_ss는 0(갭 변수의 정상상태)이다.
Out[3]:
의미 이전 값 새 값 근거
파라미터
growth_kor_ss 한국 잠재성장률(%) 4.2925 1.8000 한국은행(2024.12) 「우리 경제의 잠재성장률과 향후 전망」: 2024–2026...
pietar_kor_ss 한국 물가목표(CPI, %) 3.0000 2.0000 한국은행 물가안정목표 2%(2016년 이후)
def_kor_ss GDP 디플레이터 상승률(%) 2.1943 2.0000 물가목표와 같게(최근 10년 평균 2.08%)
rr_kor_bar_ss 한국 중립 실질 정책금리(%) 1.5000 0.5500 한국은행(2024.11) BOK 경제연구 「한국의 중립금리 추정」: 명목 1.8–3...
tprem_kor_ss CD(91일) − 콜금리(%p) 0.3900 0.1800 최근 10년 평균
tprem_tb_kor_ss 국고채(3년) − 콜금리(%p) 0.7645 0.3000 최근 10년 평균
tprem_cb_kor_ss 회사채(3년, AA−) − 국고채(3년)(%p) 0.7419 0.6700 최근 10년 평균
house_kor_ss 주택가격 상승률(%) 4.2000 2.3300 최근 10년 평균
ge_kor_ss 통합재정 총지출 증가율(%) 7.5885 6.5700 최근 10년 평균
growth_us_ss 미국 잠재성장률(%) 2.3000 1.8000 미 의회예산처(CBO, 2025.1) 잠재GDP 증가율 2027–2035년 1.8%...
pietar_us_ss 미국 물가목표(CPI, %) 2.4982 2.3000 CBO 소비자물가(CPI-U) 장기 전망 2.3%(PCE 목표 2%에 CPI 괴리를...
rr_us_bar_ss 미국 중립 실질 정책금리(%) 2.0000 0.9000 FOMC 경제전망(2026.9) 장기 연방기금금리 중간값 3.2% − 미국 CPI ...
oil_g_us_ss 유가 상승률(달러 기준, %) 9.8763 2.3000 장기 실질유가 불변(= 미국 CPI 목표 2.3%)

2. 베이지언 재추정 — 메트로폴리스–헤이스팅스¶

estimation 명령은 확산 칼만필터(diffuse_filter)로 우도를 계산하고, 사후최빈값을 찾은 뒤(mode_compute=5) 메트로폴리스–헤이스팅스 표본을 2블록 × 30,000개 생성한다(제안분포 배율 0.3). 각 블록의 앞 30%(9,000개)를 버리고 남은 42,000개로 사후 평균·표준편차·90% 최고사후밀도(HPD) 구간을 계산한다. HPD 구간은 정렬한 표본에서 90%를 포함하는 가장 짧은 구간이며, 계산식은 Dynare 7.1의 posterior_moments.m과 같다.

사전분포로는 충격 표준편차에 평균과 표준편차가 같은 역감마분포, 0과 1 사이 계수에 베타분포, 양수 계수에 감마분포를 둔다. 사전분포와 추정 옵션은 이전 판과 같고 정상상태만 다르다.

기본 실행은 저장된 사후 표본(연속 반복을 한 행으로 묶어 저장한 파일)을 읽는다. FULL=1이면 Octave에서 dynare/run_est_local.m을 실행하여 표본을 새로 만들고 같은 방식으로 추출한다. Dynare의 난수 시드는 기본값(0)으로 고정되어 있다.

In [4]:
if C.FULL:
    info = C.setup(verbose=False)
    assert info["octave"] and info["dynare"], "FULL=1에는 Octave(OCTAVE_CLI)와 Dynare(DYNARE_MATLAB)가 필요하다"
    B.prepare_dynare_workdir(C.DYNARE_WORK, C.DYNARE_SRC, C.DATAFILE)
    t0 = time.time()
    B.run_octave("run_est_local.m", C.DYNARE_WORK, C.OCTAVE_CLI, C.DYNARE_MATLAB,
                 os.path.join(C.OUTPUT, "estimation_log.txt"), "END MOMENTS")
    draws_path = os.path.join(C.INTERIM, "bokdpm_posterior_draws.csv.gz")
    B.write_exact(B.extract_kept_draws(C.DYNARE_WORK), draws_path, index=False, compression={"method": "gzip", "mtime": 0})
    smoothed_path = os.path.join(C.INTERIM, "bokdpm_smoothed_2000_2025.csv")
    B.write_exact(B.extract_smoothed(os.path.join(C.DYNARE_WORK, "BOKDPM_2025", "Output", "BOKDPM_2025_results.mat")), smoothed_path)
    print(f"Dynare 재추정 완료({(time.time() - t0) / 60:.1f}분). 새 사후 표본과 평활값을 data/interim/에 저장하였다.")
else:
    draws_path, smoothed_path = C.DRAWS, C.SMOOTHED
    print("FULL=0: 저장된 사후 표본과 평활값을 사용한다. Dynare 추정은 실행하지 않았다.")
names, X, blk = B.load_kept_draws(draws_path)
print(f"사후 표본 {X.shape[0]:,}개 × 추정 대상 {X.shape[1]}개, 블록 {sorted(set(blk.tolist()))}")
FULL=0: 저장된 사후 표본과 평활값을 사용한다. Dynare 추정은 실행하지 않았다.
사후 표본 42,000개 × 추정 대상 54개, 블록 [1, 2]
In [5]:
post = B.posterior_summary(names, X, mod["사전분포"], C.MH_CONF)
post.to_csv(os.path.join(C.OUTPUT, "posterior_summary.csv"), index=False, encoding="utf-8-sig")
prev = pd.read_csv(C.REF_PREV["estimation_results"]).set_index("파라미터")["사후평균"]
t = post[post["구분"] == "parameters"].drop(columns="구분").set_index("파라미터")
t["이전 판 사후평균"] = prev.reindex(t.index)
t
Out[5]:
사전분포 사전평균 사전표준편차 사후평균 표준편차 HPD_low HPD_high 이전 판 사후평균
파라미터
gamma_kor1 beta 0.5000 0.1500 0.8415 0.0209 0.8081 0.8757 0.8826
gamma_kor3 gamma 0.5000 0.2000 0.4662 0.1180 0.2687 0.6538 0.5744
beta_kor1 beta 0.5000 0.1500 0.2112 0.0588 0.1129 0.3057 0.2025
beta_kor2 beta 0.1000 0.0700 0.0206 0.0150 0.0008 0.0425 0.0191
beta_kor3 gamma 0.1000 0.0500 0.0813 0.0409 0.0184 0.1419 0.0862
lambda_kor1 beta 0.5500 0.1500 0.3055 0.1138 0.1283 0.4926 0.3211
lambda_kor2 gamma 0.1500 0.0800 0.0834 0.0414 0.0212 0.1429 0.0920
lambda_kor3 beta 0.6500 0.1500 0.7656 0.0275 0.7246 0.8133 0.7531
alpha_kor1 beta 0.8000 0.1000 0.5791 0.0966 0.4211 0.7380 0.6112
alpha_kor2 gamma 0.3000 0.1200 0.0703 0.0146 0.0464 0.0942 0.0724
gamma_us1 beta 0.5000 0.1500 0.8870 0.0174 0.8601 0.9171 0.9157
gamma_us2 gamma 1.5000 0.2500 1.7214 0.2734 1.2627 2.1533 1.6772
gamma_us3 gamma 0.2000 0.1000 0.4619 0.1544 0.2079 0.7073 0.3491
beta_us1 beta 0.7000 0.1200 0.4517 0.0578 0.3593 0.5469 0.4464
beta_us2 beta 0.1500 0.0800 0.0386 0.0214 0.0064 0.0698 0.0408
lambda_us1 beta 0.4500 0.1500 0.3776 0.0980 0.2195 0.5430 0.3400
lambda_us2 gamma 0.0500 0.0300 0.0905 0.0238 0.0495 0.1277 0.0765
lambda_us3 beta 0.5000 0.1500 0.4313 0.0372 0.3681 0.4895 0.4386
rho_oil1 beta 0.9000 0.0500 0.8795 0.0493 0.7979 0.9585 0.8812
gamma_kor2 gamma 1.5000 0.1000 1.5395 0.1009 1.3678 1.7021 1.5953

수렴 진단¶

두 블록의 이동 비율(유지 표본에서 직전 값과 달라진 비율로, 채택률의 근사치이다)과 Gelman–Rubin 잠재척도축소계수(PSRF)를 계산한다. PSRF가 1.1을 넘으면 두 블록의 표본 분포가 아직 충분히 겹치지 않았다는 뜻이다.

In [6]:
diag = B.mcmc_diagnostics(names, X, blk)
diag.to_csv(os.path.join(C.OUTPUT, "mcmc_diagnostics.csv"), index=False, encoding="utf-8-sig")
is_par = diag["파라미터"].isin(post.loc[post["구분"] == "parameters", "파라미터"])
print(" · ".join(f"{k} {v:.3f}" for k, v in diag.attrs.items()))
print(f"PSRF > 1.1: {int((diag['PSRF'] > 1.1).sum())}개 · 행태계수 20개의 최대 PSRF {diag.loc[is_par, 'PSRF'].max():.3f}")
diag.sort_values("PSRF", ascending=False).head(6).set_index("파라미터")
이동비율_블록1 0.254 · 이동비율_블록2 0.265
PSRF > 1.1: 3개 · 행태계수 20개의 최대 PSRF 1.006
Out[6]:
PSRF
파라미터
RES_S_G_kor 1.8460
RES_RTB_kor_BAR 1.3486
RES_LS_kor_BAR 1.3439
RES_UNR_kor_BAR 1.0407
RES_PIE_E_kor4 1.0387
RES_S_kor 1.0352

정비판에서 PSRF가 1.1을 넘는 모수는 모두 구조충격 표준편차이며(환율 추세·추세 증가율, 국고채 실질 중립금리 추세 등 확률보행 추세의 충격), 행태계수 20개는 모두 1.01 미만이다. 추세 성분의 충격 크기는 관측자료로 잘 식별되지 않으므로 두 블록의 표본이 덜 겹친다. 이 모수들의 사후분포 요약은 해석에 주의가 필요하다. 기준선은 사후최빈값 모수의 전망이므로 이 진단의 영향을 받지 않는다.

3. 기준선 전망(2026Q1–2035Q4)¶

기준선은 표본 끝(2025Q4)의 상태에서 이후 충격을 0으로 두고 계산한 40분기 전망이다. Dynare는 mh_replic=0, forecast=40으로 사후최빈값을 다시 찾은 뒤 그 모수로 전망한다(dynare/BOKDPM_forecast.mod). 따라서 기준선은 사후평균이 아니라 사후최빈값 모수의 전망이다. 전망 실행에서 찾은 최빈값은 추정 실행의 최빈값과 같다(아래 출력).

정비한 정상상태에서 기준선의 실질GDP 성장률은 잠재성장률 1.8%로, CPI 상승률은 물가목표 2%로, 두바이유는 장기 실질가격 불변 경로로 수렴한다. 아래 표는 이전 판 기준선과 나란히 보인다.

In [7]:
if C.FULL:
    t0 = time.time()
    B.run_octave("run_forecast.m", C.DYNARE_WORK, C.OCTAVE_CLI, C.DYNARE_MATLAB,
                 os.path.join(C.OUTPUT, "forecast_log.txt"), "FORECAST_DONE")
    out_dir = lambda f: os.path.join(C.DYNARE_WORK, f, "Output", f"{f}_results.mat")
    fc = B.extract_forecast(out_dir("BOKDPM_forecast"))
    mode = pd.DataFrame({"추정 실행(MH)": B.extract_posterior_mode(out_dir("BOKDPM_2025")),
                         "전망 실행(mh_replic=0)": B.extract_posterior_mode(out_dir("BOKDPM_forecast"))})
    print(f"Dynare 전망 완료({(time.time() - t0) / 60:.1f}분)")
else:
    fc = B.read_exact(C.FORECAST, index_col=0)
    mode = B.read_exact(C.MODE, index_col=0)
    print("FULL=0: 저장된 기준선 전망을 사용한다.")
sm = B.read_exact(smoothed_path, index_col=0)
B.write_exact(fc, os.path.join(C.OUTPUT, "baseline_forecast_70.csv"))
print(f"사후최빈값: 추정 실행과 전망 실행의 최대 차이 {(mode.iloc[:, 0] - mode.iloc[:, 1]).abs().max():.2e}")
ann = B.annual_baseline(sm, fc)
ann.to_csv(os.path.join(C.OUTPUT, "baseline_annual.csv"), encoding="utf-8-sig")
# 5장 통제 총량의 기준선: 실질GDP(100·ln) 과거 평활값 + 기준선 전망
pd.concat([sm["LGDP_kor"], fc["LGDP_kor"]]).rename("LGDP_kor").rename_axis("분기").to_frame().pipe(
    B.write_exact, os.path.join(C.OUTPUT, "bokdpm_lgdp_2000_2035.csv"))
ann_prev = pd.read_csv(C.REF_PREV["baseline_annual"], index_col=0, encoding="utf-8-sig")
cols = ["실질GDP 성장률(%)", "CPI 상승률(%)", "콜금리(%)", "두바이유(달러/배럴)"]
pd.concat({"정비판": ann[cols], "이전 판": ann_prev[cols]}, axis=1)
FULL=0: 저장된 기준선 전망을 사용한다.
사후최빈값: 추정 실행과 전망 실행의 최대 차이 0.00e+00
Out[7]:
정비판 이전 판
실질GDP 성장률(%) CPI 상승률(%) 콜금리(%) 두바이유(달러/배럴) 실질GDP 성장률(%) CPI 상승률(%) 콜금리(%) 두바이유(달러/배럴)
연도
2024 1.5634 2.3217 3.3958 80.9908 1.5634 2.3217 3.3958 80.9908
2025 1.8322 2.1231 2.6042 69.3915 1.8322 2.1231 2.6042 69.3915
2026 2.1919 1.7174 2.4212 63.8591 2.3932 2.1489 2.7790 66.7794
2027 1.7264 1.7910 2.2395 65.6162 3.1539 2.7141 3.2426 74.0007
2028 2.2396 1.8713 2.2652 67.2367 3.9166 2.9400 3.7132 81.8466
2029 1.9029 1.9153 2.3016 68.7818 3.7975 3.0118 4.0074 90.3642
2030 1.8909 1.9222 2.3300 70.3598 4.0035 3.0026 4.1692 99.7441
2031 1.8602 1.9275 2.3505 71.9832 4.1305 2.9890 4.2544 110.0958
2032 1.8423 1.9311 2.3638 73.6503 4.2180 2.9797 4.2977 121.5222
2033 1.8322 1.9330 2.3718 75.3598 4.2764 2.9740 4.3191 134.1352
2034 1.8262 1.9338 2.3763 77.1109 4.3146 2.9707 4.3292 148.0578
2035 1.8226 1.9342 2.3788 78.9038 4.3394 2.9688 4.3337 163.4261

평활한 잠재성장률(G_kor)은 표본 초 연 4%대에서 표본 끝 2%대로 낮아지는 경로이며, 한국은행의 기간별 잠재성장률 추정(2000년대 초 5% 안팎 → 2020년대 2% 안팎)과 같은 방향이다. 정비한 모형은 이 하락을 표본 안의 충격으로 설명하고, 전망에서는 1.8%로 수렴시킨다.

In [8]:
g = sm["G_kor"]
print("평활 잠재성장률 G_kor(연율, %) — 5년 평균:")
yr = pd.Series([int(q[:4]) for q in g.index], index=g.index)
print(g.groupby((yr // 5) * 5).mean().round(2).to_string())
print(f"2025Q4 {g.iloc[-1]:.2f}% → 전망 2035Q4 {fc['G_kor'].iloc[-1]:.2f}%")
평활 잠재성장률 G_kor(연율, %) — 5년 평균:
2000   4.1600
2005   3.5800
2010   2.8100
2015   2.6600
2020   2.2800
2025   2.1000
2025Q4 2.08% → 전망 2035Q4 1.80%

4. 시나리오 설계 — 레버 5개¶

다섯 시나리오는 노동공급(인구 가정)과 대외·대내 환경(감쇠 충격 4종)의 조합이다. 모든 충격은 2026Q1에 최고치로 시작하여 $k$분기에 걸쳐 직선으로 0이 된다.

$$\mathrm{dec}(k)_t = \min\{\max(1 - t/k,\ 0),\ 1\}, \qquad t = 0\,(2026\mathrm{Q1}),\ 1,\ \dots$$

S1(기준)은 충격이 없고 중위 인구를 쓴다. 인구 가정은 노동공급을 통해 영구적인 수준 차이를 만들고, 충격은 감쇠하여 사라진다. 레버의 크기와 감쇠 기간은 이전 판과 같다.

In [9]:
levers = B.lever_table()
levers.to_csv(os.path.join(C.OUTPUT, "scenario_levers.csv"), encoding="utf-8-sig")
qs = B.quarters("2026Q1", B.H)
paths = pd.concat([pd.DataFrame(B.scenario_levers(s, B.H), index=qs).assign(시나리오=s) for s in B.SCENARIOS])
paths.index.name = "분기"
paths.reset_index()[["시나리오", "분기", "yus", "oil", "tcost", "dshk"]].to_csv(
    os.path.join(C.OUTPUT, "scenario_lever_paths.csv"), index=False, encoding="utf-8-sig")
levers
Out[9]:
인구(생산연령인구 추계) 미국수요 yus 유가 oil 교역비용 tcost 내수 dshk
시나리오
S1 기준 중위 — — — —
S2 공급제약+충격 저위 -1.2 (12분기) -8.0 (8분기) +1.5 (12분기) -1.0 (12분기)
S3 제약완화 고위 +0.6 (12분기) -3.0 (8분기) -1.0 (12분기) +0.5 (12분기)
S4 기술도약 중위 +0.8 (20분기) — — +0.6 (28분기)
S5 지정학 중위 -0.6 (10분기) +15.0 (8분기) +2.0 (10분기) -0.8 (10분기)

5. 거시 블록 — 레버에서 거시경로로¶

레버는 BOKDPM 한국 블록의 핵심 행태식을 옮긴 캘리브레이션 모형에 들어간다(원본 repo/ch4/code/ch4_dsge.py). 이 블록은 1–3절에서 추정한 BOKDPM(Dynare)과 별개의 결정론적 모형이며, 계수는 고정값이다. 블록의 출력은 시나리오 사이의 차이(각 시나리오 − S1)로만 쓰이므로 $\bar r$, $\pi^*$ 같은 수준 상수는 결과에 영향을 주지 않는다.

식 형태
IS $Y_t = 0.5Y_{t-1} + 0.1Y_{t+1} - 0.1(RS_{t-1} - \pi^* - \bar r) + 0.3\,(yus_t - 0.75\,tcost_t) - 0.01\,oil_t + dshk_t$
필립스 $\pi_t = 0.65\pi^* + 0.35\,[0.55\pi_{t+1} + 0.45\pi_{t-1} + 0.6\,Y_{t-1}]$
테일러 $RS_t = 0.5RS_{t-1} + 0.5\,[\bar r + \pi^* + 1.5(\pi_{t+1} - \pi^*) + 2\,Y_t]$
UIP(실질환율갭) $S_t = 0.9\,[0.5S_{t+1} + 0.5S_{t-1} - 0.25(RS_t - \pi^* - \bar r)] - 0.2Y_t$
오쿤(실업갭) $U_t = 0.8U_{t-1} - 0.3Y_t$

전향 항이 있으므로 64분기(40분기 + 여유 24분기) 구간에서 시간 순서대로 반복 대입하여 수렴할 때까지 푼다(Fair–Taylor 방식).

2026-10 정비 — 레버 전달 경로 두 가지.

  • 교역비용 tcost: 원 블록에서는 교역량 증가율 $trd\_g = 1 + 0.8\,yus - 0.6\,tcost$에만 들어갔고, 교역량이 외생에서 제외되어 어디에도 전달되지 않았다. 설계 교역량 식의 계수비를 그대로 써서, 교역비용 1단위를 미국수요 0.75단위(= 0.6/0.8)의 감소로 보고 IS 식의 해외수요 항을 실효 해외수요 $yus - 0.75\,tcost$로 바꾸었다. 새로 추정한 계수는 없다. 교역비용은 산출갭을 거쳐 실질금리·실질환율·실업률·국내 경기에 전달된다.
  • 고용 증가율 lab_g: 원 블록은 생산연령인구 증가율 + 참가율 추세 − Δ실업갭으로 계산하여(10년 누적 약 −11%), 외생 '취업자 수'가 쓰는 경제활동인구와 다른 노동공급을 가정하였다. 정비판은 외생과 같은 노동공급(경제활동인구 기준 경로 × 인구 레버)에서 Δ실업갭을 빼서 계산한다.
In [10]:
lf_all = B.labor_force_base(lab, keis)
lf41 = lf_all[-(B.H + 1):]                               # 2025Q4–2035Q4
ex, mac = B.dsge_paths(pop, lf41)
ex.to_csv(os.path.join(C.OUTPUT, "dsge_exog_paths.csv"), index=False, encoding="utf-8-sig")
mac.to_csv(os.path.join(C.OUTPUT, "dsge_macro_paths.csv"), index=False, encoding="utf-8-sig")
# 원본 처리와 같이, 기록한 파일을 다시 읽어 다음 단계에 넘긴다
ex = pd.read_csv(os.path.join(C.OUTPUT, "dsge_exog_paths.csv"), encoding="utf-8-sig")
mac = pd.read_csv(os.path.join(C.OUTPUT, "dsge_macro_paths.csv"), encoding="utf-8-sig")
ex_prev = pd.read_csv(C.REF_PREV["dsge_exog_paths"], encoding="utf-8-sig")
summ = pd.DataFrame({"2030Q1 산출갭(%)": mac[mac["분기"] == "2030Q1"].set_index("시나리오")["산출갭Y"],
                     "10년 누적 고용 증가율(%)": ex.groupby("시나리오")["lab_g"].sum(),
                     "이전 판 10년 누적 고용(%)": ex_prev.groupby("시나리오")["lab_g"].sum(),
                     "10년 실질금리 변화(%p)": ex.groupby("시나리오")["d_rrate"].sum()})
summ.index = [f"{s} {B.SCN_KO[s]}" for s in summ.index]
summ
Out[10]:
2030Q1 산출갭(%) 10년 누적 고용 증가율(%) 이전 판 10년 누적 고용(%) 10년 실질금리 변화(%p)
S1 기준 0.0000 -0.1690 -10.9160 0.0000
S2 공급제약+충격 0.0130 -1.9250 -12.6550 2.1670
S3 제약완화 -0.0070 1.4920 -9.2380 -1.2550
S4 기술도약 0.4630 -0.1710 -10.9000 -1.1280
S5 지정학 -0.0000 -0.1870 -10.9190 2.1100

6. 산업 외생 6종 — 관측 가능한 조합¶

산업 VARX의 외생변수는 BOKDPM 변수를 가감승제로 조합한 값이며, 결과가 관측 가능한 거시지표가 되도록 정의한다. 잠재 갭·추세(_GAP, _BAR)를 그대로 쓰지 않고, 회귀로 추정한 계수도 쓰지 않는다.

외생 산식 산업모형 투입 형태
유가 LOIL_us = 100·ln(두바이유, 달러/배럴) 1차 차분
세계수요 Y_us = 100·ln(미국 실질GDP/잠재GDP) 수준
실질금리 CB_kor − (LCPI_kor − LCPI_kor₋₄) = 회사채(3년, AA−) 수익률 − 전년동기 CPI 상승률 1차 차분(정비)
실질환율 LS_kor + LCPI_us − LCPI_kor = 로그 실질 원/달러 환율 1차 차분
취업자 수 경제활동인구 × (1 − UNR_kor/100), 천 명 로그 차분(×100)
국내 경기 Y_kor = 한국 산출갭 수준

실질금리의 명목금리는 정책금리가 아니라 회사채(3년, AA−) 수익률이다. BOKDPM IS 곡선의 실질금리 항이 회사채 실질금리(RCB_kor)이고, 산업의 자금조달 비용을 나타내기에 정책금리보다 적합하다. 이전 판의 일부 문서(용어 툴팁)는 '명목 정책금리 − 전년동기 CPI 상승률'로 적었으나 계산은 처음부터 회사채 수익률이었다.

과거(2000Q1–2025Q4) 는 추정 결과의 평활값으로 만든다. 관측변수의 평활값은 자료와 같으므로 과거 외생은 실측치와 정합한다. 미래(2026Q1–2035Q4) 는 기준선(BOKDPM 전망)에 시나리오 편차(각 시나리오 − S1)를 더한다.

외생 시나리오 편차
유가 거시 블록 유가 증가율 편차의 누적합 $\sum_{\tau \le t}(oil\_g_{s,\tau} - oil\_g_{S1,\tau})$. 일시적 충격이 영구적인 수준 차이로 남는다(6장 보완 과제)
세계수요 레버 yus를 그대로 더한다. 정비: S3(+0.6, 12분기)를 포함하여 모든 시나리오가 레버표를 따른다(이전 판은 S2·S4·S5만)
실질금리 거시 블록 실질금리 $RR$의 차이
실질환율 거시 블록 실질환율갭 $S$의 차이
취업자 수 경제활동인구 × 인구 레버 × (1 − (기준선 실업률 + 실업갭 차이)/100). 정비: 인구 레버를 곱한다
국내 경기 거시 블록 산출갭 $Y$의 차이

경제활동인구 기준 경로(정비). 관측 구간(2026Q2까지) 이후는 중장기 인력수급 전망의 기간별 증감을 로그증가율로 바꾸어 잇는다. 2024년 관측 평균을 기준으로 2024→2029년 +34.6만 명, 2029→2034년 −21.0만 명이며, 2035년은 후기 증가율을 잇는다. 인구 레버는 저위·고위 추계와 중위 추계의 분기 증가율 차이를 2026Q1부터 누적한 값으로, 2025Q4에는 모든 시나리오가 같다.

2000년 4개 분기의 실질금리는 1999년 물가가 있어야 계산되므로, 원본 BOKDPM 워크북의 1999–2000년 소비자물가지수로 구한 전년동기 상승률(소수 둘째 자리)을 회사채 수익률에서 뺀다(2026-09-02 보정).

In [11]:
lev = B.build_exog_levels(sm, fc, lab, keis, pop, mac, ex, cpi)
lev.to_csv(os.path.join(C.OUTPUT, "exog_levels_2000_2035.csv"), index=False, encoding="utf-8-sig")
lev = pd.read_csv(os.path.join(C.OUTPUT, "exog_levels_2000_2035.csv"), encoding="utf-8-sig")
B.write_levels_xlsx(lev, os.path.join(C.OUTPUT, "외생변수_수준_시나리오별(2000-2035).xlsx"))
print("2000년 실질금리(%):", lev[(lev["시나리오"] == "S1") & (lev["분기"] < "2001Q1")]["실질금리"].tolist())
emp = lev[(lev["시나리오"] == "S1") & (lev["분기"] == "2025Q4")]["고용_취업자"].iloc[0]
print(f"검산 2025Q4: 경제활동인구 × (1 − 실업률) = {emp:,.1f}천 명 · 관측 취업자 {lab.loc['2025Q4', '취업자']:,.0f}천 명")
e = lev[lev["시나리오"] == "S1"].set_index("분기")["고용_취업자"]
k = keis.set_index("구간")["취업자_증감_천명"]
print(f"취업자 수 S1: 2029Q4 − 2024Q4 {e['2029Q4'] - e['2024Q4']:+,.0f}천 명(인력수급 전망 2024→2029 {k['2024-2029']:+,.0f}) · "
      f"2034Q4 − 2029Q4 {e['2034Q4'] - e['2029Q4']:+,.0f}천 명(전망 2029→2034 {k['2029-2034']:+,.0f})")
t = lev[lev["분기"] == "2035Q4"].set_index("시나리오")[B.EXOG_COLS].rename(columns=B.EXOG_LABEL)
t.insert(1, "두바이유(달러)", np.exp(t["유가"] / 100))
t
2000년 실질금리(%): [8.1736, 8.2946, 6.1046, 5.9111]
검산 2025Q4: 경제활동인구 × (1 − 실업률) = 28,800.8천 명 · 관측 취업자 28,792천 명
취업자 수 S1: 2029Q4 − 2024Q4 +413천 명(인력수급 전망 2024→2029 +367) · 2034Q4 − 2029Q4 -203천 명(전망 2029→2034 -303)
Out[11]:
유가 두바이유(달러) 세계수요 실질금리 실질환율 취업자 수 국내 경기
시나리오
S1 437.6830 79.5853 -0.0016 1.3132 833.4609 28,762.7493 -0.0888
S2 419.6830 66.4753 -0.0016 1.3132 833.4609 28,267.5356 -0.0888
S3 430.9330 74.3906 -0.0016 1.3132 833.4609 29,249.7006 -0.0888
S4 437.6830 79.5853 -0.0016 1.3132 833.4589 28,767.7823 -0.0888
S5 471.4330 111.5341 -0.0016 1.3132 833.4609 28,762.4533 -0.0888

레버 전달 점검¶

레버 하나씩을 끄고(인구는 모든 시나리오를 중위로, 충격은 0으로) 외생 6종을 다시 만든 뒤, 2026–2035년의 최대 변화폭을 잰다. 값이 0이면 그 레버는 외생 6종에 전달되지 않는다. 이전 판에서는 인구와 교역비용 두 레버의 행이 모두 0이었다.

In [12]:
reach = B.lever_reach(pop, sm, fc, lab, keis, cpi, lev)
reach.to_csv(os.path.join(C.OUTPUT, "lever_reach.csv"), encoding="utf-8-sig")
none = [k for k, r in reach.iterrows() if (r == 0).all()]
print("전달되지 않는 레버:", none if none else "없음")
assert not none, "외생 6종에 전달되지 않는 레버가 있다"
reach.rename(columns=B.EXOG_LABEL)
전달되지 않는 레버: 없음
Out[12]:
유가 세계수요 실질금리 실질환율 취업자 수 국내 경기
끈 레버
인구 0.0000 0.0000 0.0000 0.0000 494.6216 0.0000
yus 0.0000 1.2000 0.9590 2.5030 138.5692 0.5390
oil 33.7500 0.0000 0.3660 0.8620 50.1373 0.2160
tcost 0.0000 0.0000 1.1450 2.9020 162.8720 0.6630
dshk 0.0000 0.0000 2.6640 6.9540 384.9143 1.4960

7. 산업모형 투입형 — 적분차수에 맞춘 변환¶

산업 VARX의 내생변수는 분기 성장률(100·Δlog 부가가치, 4장에서 선택한 차분 명세)이다. 성장률 식에 들어가는 외생은 정상(I(0))이어야 계수가 과거의 추세 공행을 담지 않는다. 그래서 정상 변수(세계수요·국내 경기 = 산출갭)는 수준으로, 단위근이나 추세를 지닌 변수(유가·실질환율·취업자 수)는 차분으로 넣는다.

실질금리 — 수준에서 1차 차분으로(2026-10 정비). 이전 판은 실질금리를 정상 변수로 보고 수준으로 넣었다. 그러나 아래 검정에서 실질금리 수준은 상수항만 둔 정상성 귀무가설(KPSS)이 1% 수준에서 기각되고, DF-GLS는 단위근을 기각하지 못하며, 상수항과 추세를 함께 두면 추세 정상으로 판정된다. 즉 실질금리는 표본 기간(2000–2025) 동안 하락 추세를 지닌 계열이다. 같은 기간 산업 성장률도 낮아졌으므로, 수준을 성장률 식에 넣으면 두 추세의 공행이 계수에 담겨 '실질금리가 높을수록 성장률이 높다'는 양(+)의 계수가 나온다(6장 진단의 기여 1위·이론과 반대 부호). 1차 차분은 모든 검정에서 정상이며, 성장률(차분) 식과 적분차수가 맞는다. 차분 투입에서 실질금리의 효과는 금리가 변하는 동안의 성장률 변화로 나타나고, 금리 수준이 높게 유지된다고 성장률이 계속 높아지지는 않는다.

각 시나리오의 첫 분기(2000Q1)는 차분값이 없으므로 다음 분기 값으로 채운다(원본 처리). 과거 구간은 S1 경로를 공통으로 쓰고, 미래 구간은 시나리오별로 저장한다.

In [13]:
h1 = lev[(lev["시나리오"] == "S1") & (lev["분기"] < "2026Q1")].set_index("분기")
ur = B.unit_root_battery({"실질금리(수준)": h1["실질금리"], "실질금리(1차 차분)": h1["실질금리"].diff(),
                          "실질금리(수준, 2001Q1~)": h1.loc["2001Q1":, "실질금리"]})
ur.to_csv(os.path.join(C.OUTPUT, "unit_root_real_rate.csv"), encoding="utf-8-sig")
ur.style.format({c: "{:.3f}" for c in ur.columns if c != "관측수"})
Out[13]:
    관측수 ADF p PP p DF-GLS p KPSS p
계열 결정항          
실질금리(수준) c 104 0.000 0.000 0.516 0.001
ct 104 0.010 0.002 0.501 0.188
실질금리(1차 차분) c 103 0.000 0.000 0.000 0.283
ct 103 0.011 0.000 0.000 0.436
실질금리(수준, 2001Q1~) c 100 0.038 0.064 0.083 0.002
ct 100 0.000 0.084 0.000 0.683
In [14]:
hist, scen = B.to_stationary(lev)
hist.to_csv(os.path.join(C.OUTPUT, "exog_hist_v2.csv"), index=False, encoding="utf-8-sig")
scen.to_csv(os.path.join(C.OUTPUT, "exog_scen_v2.csv"), index=False, encoding="utf-8-sig")
print(f"과거 {len(hist)}분기({hist['분기'].iloc[0]}–{hist['분기'].iloc[-1]}) · 미래 {len(scen) // 5}분기 × 5개 시나리오")
print("투입 형태:", " · ".join(f"{B.EXOG_LABEL[c]} {B.EXOG_DEF[c][1]}" for c in B.EXOG_COLS))
avg = scen.groupby("시나리오")[B.EXOG_COLS].mean().rename(columns=B.EXOG_LABEL)
avg.index = [f"{s} {B.SCN_KO[s]}" for s in avg.index]
avg.style.set_caption("미래 40분기 평균(투입형)").format("{:.3f}")
과거 104분기(2000Q1–2025Q4) · 미래 40분기 × 5개 시나리오
투입 형태: 유가 1차 차분 · 세계수요 수준 · 실질금리 1차 차분 · 실질환율 1차 차분 · 취업자 수 로그 차분(×100) · 국내 경기 수준
Out[14]:
미래 40분기 평균(투입형)
  유가 세계수요 실질금리 실질환율 취업자 수 국내 경기
S1 기준 0.561 0.153 0.009 0.104 -0.003 -0.150
S2 공급제약+충격 0.111 -0.042 0.009 0.104 -0.047 -0.555
S3 제약완화 0.392 0.250 0.009 0.104 0.039 0.078
S4 기술도약 0.561 0.363 0.009 0.104 -0.003 0.279
S5 지정학 1.404 0.070 0.009 0.104 -0.003 -0.473