8.2 검증 패턴#
이 챕터에서 배우는 것
한 계약의 BEL / RA / CSM이 어떤 테이블, 어떤 rate, 어떤 cash flow 로 계산되는지 추적하기 —
gmm.trace월 단위 BEL backward recursion 의 항별 분해 와 손계산과의 매칭 —
gmm.trace_bel_step월 단위 CSM forward recursion 의 이자부리 / 환입 분해 —
gmm.trace_csm_step가정 변경 (mortality +10%) 이 각 단계에 어떻게 전파 되는지 비교 —
gmm.trace_diff
이 챕터는 회사 데이터로 평가를 돌려보고 결과를 신뢰 하기 위한 검증 워크플로를 모읍니다. “엔진이 뭘 하는지 보이지 않는다” 는 가장 흔한 도입 마찰을 정면으로 푸는 도구들입니다.
검증 워크플로 — 왜 / 언제#
평가 엔진은 정의상 블랙박스 입니다. fastcashflow 도 예외가 아닙니다.
한 줄의 measure() 호출이 수십 개의 테이블 lookup, 월별 cash flow 산출,
backward / forward recursion 을 거쳐 BEL / RA / CSM 하나의 숫자로
요약됩니다. 그 숫자를 사내 계리위원회 / 감독당국 / 회계감사인에게
설명해야 할 때, 그리고 회사 손계산 결과와 어긋날 때, 어디서 어떻게
계산됐는지 한눈에 보이는 도구 가 필요합니다.
전형적인 검증 시나리오는 셋입니다:
신규 도입 — 처음 회사 데이터를 넣고 BEL 값을 받았는데, 사내 별도 산출치와 X% 차이. 어디서 어긋났는가?
분기 결산 — 직전 분기 대비 BEL이 Y% 움직였는데, 어느 가정 / 어느 segment 가 주범인가?
시나리오 / 민감도 — “사망률을 10% 올리면 BEL이 얼마나 움직이나” — 결과 숫자만 보지 말고 어디서 어떻게 변했는지 추적.
세 시나리오 모두 같은 출발점에서 풉니다: 단일 계약 (one model point) 의 계산 경로 전체 를 보는 것입니다. 포트폴리오 전체를 한꺼번에 보면 노이즈 가 너무 많아 원인이 가려집니다. fastcashflow 의 검증 도구 4종은 한 계약 에 초점을 맞춥니다.
모델링 매핑 — 4개의 도구#
도구 |
출력 |
언제 쓰나 |
|---|---|---|
|
한 계약의 전체 계산 트리 — segment / 테이블 / coverage / 연도별 rate / 월별 cash flow / discount / BEL roll-forward / CSM / 최종 headline |
첫 검증. “엔진이 어느 segment 의 어느 테이블을 적용했나” |
|
월 t 의 BEL 식을 항별로 풀어 표시 — |
손계산과 엔진 값이 어긋날 때. 어느 항이 다른지 |
|
월 t 의 CSM 식 — 이전 CSM / 이자부리 / coverage_unit 환입비율 / 환입액 / 잔차 |
CSM 흐름 검증. 손실부담계약 (onerous = 손실부담) 의 floor 가 작동 하는지 확인 |
|
두 가정 (basis) 비교 — 바뀐 테이블 / 연도별 rate 변화 / cash flow 의 전파 / BEL / RA / CSM의 절대·% 변화 |
시나리오 / 민감도. mortality +10% 가 BEL에 16.2%, RA에 9.9% 라면 각각 어디서 왔는지 |
모두 ASCII 트리로 출력합니다. 외부 의존성 없고, Jupyter / REPL /
파이프로 파일에 넣기 자유롭습니다. 실제 계산은 새로 하지 않고
measure() 의 결과를 슬라이스해서 보여줄 뿐이므로 1M 계약 포트폴리오
에서 한 계약만 추적해도 그 한 계약의 비용만 듭니다.
최소 작동 예제 — gmm.trace 부터#
샘플 워크북의 첫 계약 (TERM_LIFE_A / FC 채널, 가입연령 35, 보험기간 20년) 을 추적합니다.
import fastcashflow as fcf
mp = fcf.samples.model_points()
basis = fcf.samples.basis()
fcf.gmm.trace(0, mp, basis)
mp_index=0 은 model_points 의 첫 행 (0-based) 입니다. basis 가
dict 일 때 — read_basis() / samples.basis() 가
돌려주는 형태 — 그 행의 (product, channel) 로 자동 라우팅합니다.
단일 Basis 개체를 직접 넣어도 됩니다.
출력 (발췌):
mp[0] (TERM_LIFE_A/FC, sex=남, issue_age=35, term=240m, premium_term=240m, count=1)
├─ Basis (segment-level)
│ ├─ mortality_annual -> MORTALITY_STD
│ ├─ lapse_annual -> LAPSE_TERM_FC
│ ├─ waiver_incidence -> WAIVER_STD
│ ├─ discount_annual = ndarray len=101 [0.03103, ..., 0.0405]
│ ├─ expense_inflation = ndarray len=1 [0.02, ..., 0.02]
│ ├─ expense_items = tuple (len=2)
│ │ ├─ ExpenseItem('acquisition', basis='alpha_fixed', value=700000)
│ │ └─ ExpenseItem('maintenance', basis='gamma_fixed', value=90000)
│ ├─ ra: method='confidence_level', conf=0.75
│ └─ cv: mort=0.1 morb=0.12 long=0 disab=0
├─ Coverages (rate-driven, n=5)
│ ├─ 'DEATH' method=DEATH risk=0 is_diagnosis=False rate -> MORTALITY_STD
│ ├─ 'INPATIENT' method=MORBIDITY risk=1 is_diagnosis=False rate -> INPATIENT_STD
│ ├─ 'CANCER' method=DIAGNOSIS risk=1 is_diagnosis=True rate -> CANCER_STD
│ ├─ 'ADB' method=DEATH risk=0 is_diagnosis=False rate -> ADB_STD
│ └─ 'DISEASE_DEATH' method=DEATH risk=0 is_diagnosis=False rate -> DISEASE_DEATH_STD
├─ Rates (annual, evaluated for this MP)
│ ├─ axes: sex=0, issue_age=35, issue_class=0, elapsed_at_issue=0m
│ ├─ year mort(an) lapse(an) waiver(an) DEATH(an) INPATIENT(an) CANCER(an) ADB(an) DISEASE_DEATH(an)
│ ├─ 0 0.000780 0.107640 0.000980 0.000780 0.079173 0.001587 0.000036 0.000630
│ ├─ 1 0.000860 0.142678 0.001081 0.000860 0.079173 0.001587 0.000039 0.000694
│ ├─ 2 0.000940 0.142678 0.001181 0.000940 0.079173 0.001587 0.000043 0.000759
│ ├─ 3 0.001020 0.142678 0.001282 0.001020 0.079173 0.001587 0.000047 0.000823
│ ├─ 4 0.001090 0.142678 0.001370 0.001090 0.079173 0.001587 0.000050 0.000880
│ ├─ 10 0.001720 0.036800 0.002281 0.001720 0.091039 0.002712 0.000066 0.001477
│ └─ 19 0.003910 0.036800 0.005285 0.003910 0.103026 0.004092 0.000149 0.003424
├─ Cash flows (annual sum over 240m horizon)
│ ├─ year premium claim morbidity expense annuity surrender disability
│ ├─ 0 449,802 59,260 0 786,204 0 512 0
│ ├─ 1 393,535 57,233 0 77,014 0 4,352 0
│ ├─ 2 336,705 53,606 0 67,311 0 10,543 0
│ ├─ 3 288,029 49,853 0 58,841 0 18,738 0
│ ├─ 4 246,348 45,669 0 51,449 0 28,453 0
│ ├─ 5 212,969 42,130 0 45,489 0 32,352 0
│ ├─ 6 188,584 39,995 0 41,206 0 33,010 0
│ ├─ 7 170,943 39,885 0 38,216 0 31,165 0
│ ├─ 8 158,521 40,922 0 36,260 0 26,690 0
│ ├─ 9 150,309 42,035 0 35,176 0 19,469 0
│ ├─ 10 144,222 42,955 0 34,532 0 21,844 0
│ ├─ 11 138,341 44,228 0 33,899 0 23,739 0
│ ├─ 12 132,657 46,503 0 33,278 0 25,644 0
│ ├─ 13 127,149 49,657 0 32,667 0 27,552 0
│ ├─ 14 121,800 53,576 0 32,063 0 29,455 0
│ ├─ 15 116,601 56,919 0 31,468 0 31,343 0
│ ├─ 16 111,552 59,732 0 30,883 0 33,212 0
│ ├─ 17 106,657 62,630 0 30,306 0 35,053 0
│ ├─ 18 101,909 65,589 0 29,739 0 36,860 0
│ ├─ 19 97,301 69,116 0 29,181 0 38,625 0
│ └─ maturity benefit at t=240m: 2,159,681
├─ Undiagnosed share (key months, per coverage)
│ └─ 'CANCER':
│ ├─ t= 0m: undiagnosed=1.000000
│ ├─ t= 12m: undiagnosed=0.998413
│ ├─ t= 60m: undiagnosed=0.992090
│ ├─ t= 120m: undiagnosed=0.981269
│ ├─ t= 228m: undiagnosed=0.952287
│ └─ t= 240m: undiagnosed=0.948391
├─ Discount factors (key months)
│ ├─ t= 0m: ds=1.000000
│ ├─ t= 12m: ds=0.969904
│ ├─ t= 60m: ds=0.835840
│ ├─ t= 120m: ds=0.679134
│ ├─ t= 228m: ds=0.471163
│ └─ t= 240m: ds=0.452457
├─ BEL roll-forward (key months)
│ ├─ BEL[t] = annuity[t] - premium[t] + (claim+morbidity+disability+expense+surrender)[t] * (1+i)^(-1/2) + BEL[t+1] * (1+i)^(-1)
│ ├─ BEL[ 240] = 2,159,680.98 (maturity seed -- a single payment at term)
│ ├─ BEL[ 228] = 2,112,593.93
│ ├─ BEL[ 120] = 1,389,753.94
│ ├─ BEL[ 60] = 819,188.11
│ ├─ BEL[ 12] = 4,269.76
│ └─ BEL[ 0] = 403,359.82
├─ CSM roll-forward (key months)
│ ├─ FCF[0] = BEL[0] + RA[0] = 403,359.82 + 47,359.86 = 450,719.68
│ ├─ CSM[0] = max(0, -FCF[0]) = 0.00
│ ├─ loss_comp = max(0, FCF[0]) = 450,719.68
│ ├─ csm[t+1] = csm[t] + accretion[t] - release[t]
│ ├─ t= 0m: csm= 0.00 acc= 0.00 rel= 0.00
│ ├─ t= 12m: csm= 0.00 acc= 0.00 rel= 0.00
│ ├─ t= 60m: csm= 0.00 acc= 0.00 rel= 0.00
│ ├─ t= 120m: csm= 0.00 acc= 0.00 rel= 0.00
│ ├─ t= 228m: csm= 0.00 acc= 0.00 rel= 0.00
│ └─ t= 240m: csm= 0.00 (past last accretion month)
└─ Final (headline numbers, per policy)
├─ BEL = 403,359.82
├─ RA = 47,359.86
├─ FCF = BEL + RA = 450,719.68
├─ CSM = max(0,-FCF)= 0.00
└─ loss_component = 450,719.68
아홉 섹션이 한 화면에 다 들어옵니다. 검증 관점에서 가장 자주 보는 것:
Basis / Coverages — “엔진이 내가 의도한 테이블을 잡았나?”
MORTALITY_STD/LAPSE_TERM_FC가 매칭. 만약 워크북에LAPSE_TERM_GA만 있는 segment 인데 여기LAPSE_TERM_FC가 잡혔다면 segment 라우팅 오류.Rates — 첫 행의
axes가sex=0, issue_age=35같이 model point 의 실제 축. 각 연도의 rate 값이 자기 손계산 테이블의 그 셀과 일치해야 함.Cash flows — 연도별 premium / claim 합계. 첫 해 premium 이
premium × 12 × in-force와 어림셈으로 일치하는지.Final — headline 4 개. 손실부담계약이면
CSM = 0이고loss_component = FCF > 0.
결과 해석 — 트리의 핵심 섹션#
Basis 블록의 _fcf_table_id#
mortality_annual -> MORTALITY_STD 형태로 표시된 부분은 rate 함수가
어느 워크북 시트의 어느 table_id 에서 왔는지 알려줍니다.
read_basis() 가 rate 함수에 _fcf_table_id 메타데이터를
붙여 두기 때문입니다 — 이 라벨이 보이면 자기 입력 → 엔진 의 경로가
끊기지 않은 신호입니다.
만약 자신의 rate 를 직접 lambda 로 작성해 넣으면 <callable> 로 표시
됩니다. 추적은 가능하지만 어떤 table 인지 라벨이 없으므로 검토 가능성
이 줄어듭니다. 가능하면 워크북 / read_basis() 경로로 통일하는
편이 검증에 유리합니다.
Coverages 블록의 risk 와 is_diagnosis#
각 coverage 행의 risk 는 RA (Risk Adjustment = 위험조정) 계산 시 어느
변동계수 (mortality_cv / morbidity_cv) 와 묶이는지 결정합니다.
is_diagnosis=True 인 coverage 는 진단 시 한 번만 지급되고 in-force 의
“미진단 풀” 이 줄어드는 형태입니다 (CANCER 행). False 면 재발 가능한
형태 (INPATIENT 행 — 입원 발생).
Rates 블록의 axes 라인#
axes: sex=0, issue_age=35, issue_class=0, elapsed_at_issue=0m —
이 model point 의 실제 축입니다. 그 아래 표가 그 축으로 rate 함수를
호출했을 때의 결과 입니다. 자기 워크북의 사망률 시트에서 (sex=0, age=35, year=0) 셀을 찾아 일치하는지 확인할 수 있습니다.
BEL roll-forward 블록의 식#
BEL[t] = annuity[t] - premium[t]
+ (claim+morbidity+disability+expense+surrender)[t] * (1+i)^(-1/2)
+ BEL[t+1] * (1+i)^(-1)
IFRS 17 의 backward recursion 입니다. seed (=시작값) 가
BEL[term] = maturity_benefit 이고, 거기서부터 거꾸로 한 달씩 내려옵니다.
만기환급 없는 정기보험은 BEL[term] = 0 으로 시작합니다. 만기금이 큰
저축성 / 단기납 종신은 큰 양수로 시작합니다 — 어떤 모양인지 한 줄로
보입니다.
CSM 블록의 onerous 판정#
FCF[0] = BEL[0] + RA[0] = 460,509.71 이 양수이므로 이 계약은 손실
부담계약 (onerous contract) 입니다. IFRS 17 §47-48 에 따라
CSM = 0, loss_component = FCF. 가입 시점에 즉시 손실 인식.
샘플 워크북의 정기보험 가격은 의도적으로 loss-making 으로 설정돼 있어
샘플 트레이스의 CSM 트랙이 거의 비어 있게 보입니다. 회사 데이터로
바꾸면 보통은 CSM > 0 가 정상 흐름입니다.
자주 쓰는 변형#
월별 BEL 식 전개 — gmm.trace_bel_step#
gmm.trace 가 BEL의 궤적 을 anchor 월에서 값으로 보여준다면,
gmm.trace_bel_step 은 한 달의 BEL 식을 항별로 풀어 보여줍니다.
import fastcashflow as fcf
mp = fcf.samples.model_points()
basis = fcf.samples.basis()
fcf.gmm.trace_bel_step(0, mp, basis, months=[0, 12, 239, 240])
months= 인자로 풀어볼 월을 지정합니다. 기본은 {0, 12, term//2, term-1, term} — 시작, 1년 끝, 중간, 마지막 step, seed.
출력 (t=0 부분):
mp[0] BEL step-by-step (TERM_LIFE_A/FC, sex=남, issue_age=35, term=240m)
├─ Recursion (back-pass)
│ ├─ BEL[t] = annuity[t] - premium[t]
│ ├─ + (claim + morbidity + disability + expense + surrender)[t] * (1 + i[t])^(-1/2)
│ ├─ + BEL[t+1] * (1 + i[t])^(-1)
│ └─ seed: BEL[240] = maturity_benefit = 2,159,680.98
├─ Steps
│ ├─ t= 0
│ │ ├─ i[t] = 0.002550
│ │ ├─ half = (1+i)^(-1/2) = 0.998728
│ │ ├─ full = (1+i)^(-1) = 0.997457
│ │ ├─ premium[t] = 39,502.00
│ │ ├─ annuity[t] = 0.00
│ │ ├─ claim[t] = 5,201.86
│ │ ├─ morbidity[t] = 0.00
│ │ ├─ disability[t] = 0.00
│ │ ├─ expense[t] = 707,500.00
│ │ ├─ surrender[t] = 0.00
│ │ ├─ mid-month sum = 712,701.86
│ │ ├─ mid-month piece (×half) = 711,794.98
│ │ ├─ BEL[t+1] = -269,618.88
│ │ ├─ tail piece (BEL[t+1]×full)= -268,933.16
│ │ ├─ recomputed BEL[t] = 403,359.82
│ │ └─ engine BEL[t] = 403,359.82 (residual +0.0000e+00)
│ ├─ t= 12
│ │ ├─ i[t] = 0.002550
│ │ ├─ half = (1+i)^(-1/2) = 0.998728
│ │ ├─ full = (1+i)^(-1) = 0.997457
│ │ ├─ premium[t] = 35,187.98
│ │ ├─ annuity[t] = 0.00
│ │ ├─ claim[t] = 5,114.54
│ │ ├─ morbidity[t] = 0.00
│ │ ├─ disability[t] = 0.00
│ │ ├─ expense[t] = 6,821.66
│ │ ├─ surrender[t] = 185.26
│ │ ├─ mid-month sum = 12,121.47
│ │ ├─ mid-month piece (×half) = 12,106.04
│ │ ├─ BEL[t+1] = 27,421.44
│ │ ├─ tail piece (BEL[t+1]×full)= 27,351.70
│ │ ├─ recomputed BEL[t] = 4,269.76
│ │ └─ engine BEL[t] = 4,269.76 (residual +0.0000e+00)
│ ├─ t= 239
│ │ ├─ i[t] = 0.003382
│ │ ├─ half = (1+i)^(-1/2) = 0.998313
│ │ ├─ full = (1+i)^(-1) = 0.996630
│ │ ├─ premium[t] = 7,935.97
│ │ ├─ annuity[t] = 0.00
│ │ ├─ claim[t] = 5,657.88
│ │ ├─ morbidity[t] = 0.00
│ │ ├─ disability[t] = 0.00
│ │ ├─ expense[t] = 2,410.67
│ │ ├─ surrender[t] = 3,285.04
│ │ ├─ mid-month sum = 11,353.59
│ │ ├─ mid-month piece (×half) = 11,334.44
│ │ ├─ BEL[t+1] = 2,159,680.98
│ │ ├─ tail piece (BEL[t+1]×full)= 2,152,402.14
│ │ ├─ recomputed BEL[t] = 2,155,800.61
│ │ └─ engine BEL[t] = 2,155,800.61 (residual +0.0000e+00)
│ └─ t= 240 (seed -- no recursion below)
│ └─ BEL[240] = 2,159,680.98 (= maturity_benefit)
└─ Inception BEL
└─ BEL[0] = 403,359.82
residual (잔차 = recomputed - engine) 이 핵심입니다. 모든 step 에서 +0.0000e+00 (float64 정밀도) 이면 출력된 식과 엔진이 정확히 일치한다는 뜻 — “엔진은 이 식을 따른다” 의 증거.
손계산을 같은 월에 만들고 위 각 항과 비교하면 어느 항에서 어긋났는지 한눈에 잡힙니다. 잘 보면 좋은 항:
i[t]— 월 할인율. 샘플 할인은 국고채 곡선이라 연도별로 다름 — 1년차 약 3.10% 면(1.0310)^(1/12) - 1 = 0.002550premium[t]—premium × in-force와 어림셈으로 일치해야claim[t]—coverage_amount × in-force × mortality_monthly정도
t = term (시드) 행은 maturity_benefit 만 표시하고 recursion 식은
없습니다 (그 아래 월이 없기 때문).
월별 CSM 식 전개 — gmm.trace_csm_step#
CSM은 BEL의 반대 방향 — forward recursion 입니다. 시작값
csm[0] = max(0, -(BEL+RA)) 에서 출발해 매월 이자부리 + coverage_unit
환입.
fcf.gmm.trace_csm_step(0, mp, basis, months=[1, 60, 120, 240])
샘플의 정기보험은 손실부담이라 모든 step 에서 csm = 0. 의미는
“floor 가 작동” — 차라리 가입 시 CSM이 음수가 될 것을 0 으로 막은 것.
출력의 Seed (t = 0) 블록이 명시적으로 알려줍니다:
mp[0] CSM step-by-step (TERM_LIFE_A/FC, sex=남, issue_age=35, term=240m)
├─ Recursion (forward-pass)
│ ├─ csm[0] = max(0, -(BEL[0] + RA[0]))
│ ├─ csm[t] = csm[t-1] + accretion[t-1] - release[t-1]
│ ├─ accretion[t-1] = csm[t-1] * i[t-1]
│ └─ release[t-1] = (csm[t-1] + accretion[t-1]) * coverage_units[t-1] / sum(coverage_units[t-1:])
├─ Seed (t = 0)
│ ├─ BEL[0] = 403,359.82
│ ├─ RA[0] = 47,359.86
│ ├─ FCF[0] = BEL + RA = 450,719.68
│ ├─ csm[0] = max(0,-FCF) = 0.00
│ └─ onerous contract -- csm = 0 throughout; release/accretion are 0 by construction.
├─ Steps
│ ├─ t= 1
│ │ ├─ csm[t-1] = 0.00
│ │ ├─ i[t-1] = 0.002550
│ │ ├─ accretion[t-1] = csm*i = 0.00
│ │ ├─ accreted = csm + acc = 0.00
│ │ ├─ coverage_units[t-1] = 1.000000
│ │ ├─ cu_tail[t-1] = sum(cu[t-1:]) = 97.866195
│ │ ├─ release fraction = cov_units / cu_tail = 1.000000 / 97.866195 = 0.010218
│ │ ├─ release[t-1] = accreted * frac = 0.00
│ │ ├─ recomputed csm[t] = 0.00
│ │ └─ engine csm[t] = 0.00 (residual +0.0000e+00)
│ ├─ t= 60
│ │ ├─ csm[t-1] = 0.00
│ │ ├─ i[t-1] = 0.003363
│ │ ├─ accretion[t-1] = csm*i = 0.00
│ │ ├─ accreted = csm + acc = 0.00
│ │ ├─ coverage_units[t-1] = 0.487350
│ │ ├─ cu_tail[t-1] = sum(cu[t-1:]) = 54.822982
│ │ ├─ release fraction = cov_units / cu_tail = 0.487350 / 54.822982 = 0.008890
│ │ ├─ release[t-1] = accreted * frac = 0.00
│ │ ├─ recomputed csm[t] = 0.00
│ │ └─ engine csm[t] = 0.00 (residual +0.0000e+00)
│ ├─ t= 120
│ │ ├─ csm[t-1] = 0.00
│ │ ├─ i[t-1] = 0.003535
│ │ ├─ accretion[t-1] = csm*i = 0.00
│ │ ├─ accreted = csm + acc = 0.00
│ │ ├─ coverage_units[t-1] = 0.318446
│ │ ├─ cu_tail[t-1] = sum(cu[t-1:]) = 31.998881
│ │ ├─ release fraction = cov_units / cu_tail = 0.318446 / 31.998881 = 0.009952
│ │ ├─ release[t-1] = accreted * frac = 0.00
│ │ ├─ recomputed csm[t] = 0.00
│ │ └─ engine csm[t] = 0.00 (residual +0.0000e+00)
│ └─ t= 240
│ ├─ csm[t-1] = 0.00
│ ├─ i[t-1] = 0.003382
│ ├─ accretion[t-1] = csm*i = 0.00
│ ├─ accreted = csm + acc = 0.00
│ ├─ coverage_units[t-1] = 0.216665
│ ├─ cu_tail[t-1] = sum(cu[t-1:]) = 0.216665
│ ├─ release fraction = cov_units / cu_tail = 0.216665 / 0.216665 = 1.000000
│ ├─ release[t-1] = accreted * frac = 0.00
│ ├─ recomputed csm[t] = 0.00
│ └─ engine csm[t] = 0.00 (residual +0.0000e+00)
└─ End CSM
└─ csm[240] = 0.00
수익성 있는 계약 (BEL < 0) 으로 바꿔 보면 매월 recursion 이 의미를 가집니다:
import numpy as np
from fastcashflow.basis import Basis
from fastcashflow.modelpoints import ModelPoints
# 사망률 함수 -- 연 0.05% 의 평탄 사망률 (보험금 대비 매우 낮은 율)
def death_fn(s, ia, d, ic, em):
return np.full(d.shape, 0.0005)
# 해지율 함수 -- 연 2% 의 평탄 해지율
def lapse_fn(s, ia, d, ic, em):
return np.full(d.shape, 0.02)
# 산출기초 -- 이익이 나는 (CSM > 0) 시나리오
profitable = Basis(
mortality_annual = death_fn, # 보유계약 감쇠용 사망률 (연 0.05%)
lapse_annual = lapse_fn, # 해지율 (연 2%)
discount_annual = 0.03, # 연 할인율 3%
ra_confidence = 0.75, # 위험조정 신뢰수준 75%
mortality_cv = 0.05, # 사망률 변동계수 5%
coverages = (fcf.CoverageRate("DEATH", death_fn),), # 사망 보장 1 종 (청구 rate = death_fn)
)
# 모델 포인트 -- 보험금 1 억, 월납 보험료 20 만, 5 년 만기 한 계약
mp_one = ModelPoints(
issue_age = np.array([40.0]), # 가입연령 40 세
premium = np.array([200_000.0]), # 월납 보험료 20 만
term_months = np.array([60]), # 보험기간 60 개월 (5 년)
benefits = {0: np.array([100_000_000.0])}, # 0 번 보장 (= DEATH) 의 보험금 1 억
calculation_methods = {"DEATH": fcf.CalculationMethod.DEATH}, # 코드 → 산출방식 매핑
)
fcf.gmm.trace_csm_step(0, mp_one, profitable, months=[1, 30, 60])
마지막 step (t = 60) 의 행:
mp[0] CSM step-by-step (-/-, sex=남, issue_age=40, term=60m)
├─ Recursion (forward-pass)
│ ├─ csm[0] = max(0, -(BEL[0] + RA[0]))
│ ├─ csm[t] = csm[t-1] + accretion[t-1] - release[t-1]
│ ├─ accretion[t-1] = csm[t-1] * i[t-1]
│ └─ release[t-1] = (csm[t-1] + accretion[t-1]) * coverage_units[t-1] / sum(coverage_units[t-1:])
├─ Seed (t = 0)
│ ├─ BEL[0] = -10,411,844.82
│ ├─ RA[0] = 7,463.29
│ ├─ FCF[0] = BEL + RA = -10,404,381.52
│ └─ csm[0] = max(0,-FCF) = 10,404,381.52
├─ Steps
│ ├─ t= 1
│ │ ├─ csm[t-1] = 10,404,381.52
│ │ ├─ i[t-1] = 0.002466
│ │ ├─ accretion[t-1] = csm*i = 25,660.01
│ │ ├─ accreted = csm + acc = 10,430,041.53
│ │ ├─ coverage_units[t-1] = 1.000000
│ │ ├─ cu_tail[t-1] = sum(cu[t-1:]) = 57.048193
│ │ ├─ release fraction = cov_units / cu_tail = 1.000000 / 57.048193 = 0.017529
│ │ ├─ release[t-1] = accreted * frac = 182,828.60
│ │ ├─ recomputed csm[t] = 10,247,212.93
│ │ └─ engine csm[t] = 10,247,212.93 (residual +0.0000e+00)
│ ├─ t= 30
│ │ ├─ csm[t-1] = 5,629,160.18
│ │ ├─ i[t-1] = 0.002466
│ │ ├─ accretion[t-1] = csm*i = 13,883.03
│ │ ├─ accreted = csm + acc = 5,643,043.21
│ │ ├─ coverage_units[t-1] = 0.951199
│ │ ├─ cu_tail[t-1] = sum(cu[t-1:]) = 28.737298
│ │ ├─ release fraction = cov_units / cu_tail = 0.951199 / 28.737298 = 0.033100
│ │ ├─ release[t-1] = accreted * frac = 186,783.66
│ │ ├─ recomputed csm[t] = 5,456,259.55
│ │ └─ engine csm[t] = 5,456,259.55 (residual +0.0000e+00)
│ └─ t= 60
│ ├─ csm[t-1] = 190,495.34
│ ├─ i[t-1] = 0.002466
│ ├─ accretion[t-1] = csm*i = 469.81
│ ├─ accreted = csm + acc = 190,965.16
│ ├─ coverage_units[t-1] = 0.903220
│ ├─ cu_tail[t-1] = sum(cu[t-1:]) = 0.903220
│ ├─ release fraction = cov_units / cu_tail = 0.903220 / 0.903220 = 1.000000
│ ├─ release[t-1] = accreted * frac = 190,965.16
│ ├─ recomputed csm[t] = 0.00
│ └─ engine csm[t] = 0.00 (residual +0.0000e+00)
└─ End CSM
└─ csm[60] = 0.00
마지막 월에서 release fraction = 1.0 (잔존 coverage_unit 전부 환입)
이라 csm[60] = 0. 보장 단위의 경계조건이 출력에 그대로 보입니다.
가정 변경의 전파 추적 — gmm.trace_diff#
“사망률 +10% 면 BEL이 얼마나 움직이나” 는 시나리오 / 민감도의 가장 기본적인 질문입니다. 결과값만 보지 말고 어디서 어떻게 전파됐는지 한 화면에 봅니다.
import fastcashflow as fcf
from dataclasses import replace
mp = fcf.samples.model_points()
basis = fcf.samples.basis()
baseline = basis[('TERM_LIFE_A', 'FC')]
# mortality x 1.10 shock — rate 함수를 wrap
def shock(rate_fn, factor):
def wrapped(sex, issue_age, duration, issue_class, elapsed):
return rate_fn(sex, issue_age, duration, issue_class, elapsed) * factor
wrapped._fcf_table_id = getattr(rate_fn, '_fcf_table_id', None)
wrapped._fcf_modifiers = getattr(rate_fn, '_fcf_modifiers', ()) + (f'x{factor}',)
return wrapped
# 사망률 테이블 +10% -- 같은 MORTALITY_STD 가 in-force 감쇠(mortality_annual)
# 와 사망보장 claim(DEATH 담보 rate) 양쪽을 굴리므로 두 자리를 함께 shock
new_coverages = tuple(
replace(c, rate=shock(c.rate, 1.10)) if c.code == "DEATH" else c
for c in baseline.coverages
)
shocked = replace(baseline,
mortality_annual = shock(baseline.mortality_annual, 1.10),
coverages = new_coverages)
fcf.gmm.trace_diff(0, mp, baseline, shocked,
label_a='baseline', label_b='mort+10%')
출력 (Final 블록):
diff mp[0] (TERM_LIFE_A/FC, sex=남, issue_age=35, term=240m, premium_term=240m, count=1)
labels: 'baseline' -> 'mort+10%'
├─ Assumption changes
│ ├─ mortality_annual : MORTALITY_STD -> MORTALITY_STD (+x1.1)
│ └─ coverage[DEATH].rate : MORTALITY_STD -> MORTALITY_STD (+x1.1)
├─ Rate deltas (per policy year)
│ ├─ axes: sex=0, issue_age=35, issue_class=0, elapsed_at_issue=0m
│ ├─ year 0
│ │ ├─ mortality(annual) 0.000780 -> 0.000858 ( +0.000078, +10.00%)
│ │ └─ DEATH(annual) 0.000780 -> 0.000858 ( +0.000078, +10.00%)
│ ├─ year 1
│ │ ├─ mortality(annual) 0.000860 -> 0.000946 ( +0.000086, +10.00%)
│ │ └─ DEATH(annual) 0.000860 -> 0.000946 ( +0.000086, +10.00%)
│ ├─ year 2
│ │ ├─ mortality(annual) 0.000940 -> 0.001034 ( +0.000094, +10.00%)
│ │ └─ DEATH(annual) 0.000940 -> 0.001034 ( +0.000094, +10.00%)
│ ├─ year 3
│ │ ├─ mortality(annual) 0.001020 -> 0.001122 ( +0.000102, +10.00%)
│ │ └─ DEATH(annual) 0.001020 -> 0.001122 ( +0.000102, +10.00%)
│ ├─ year 4
│ │ ├─ mortality(annual) 0.001090 -> 0.001199 ( +0.000109, +10.00%)
│ │ └─ DEATH(annual) 0.001090 -> 0.001199 ( +0.000109, +10.00%)
│ ├─ year 10
│ │ ├─ mortality(annual) 0.001720 -> 0.001892 ( +0.000172, +10.00%)
│ │ └─ DEATH(annual) 0.001720 -> 0.001892 ( +0.000172, +10.00%)
│ └─ year 19
│ ├─ mortality(annual) 0.003910 -> 0.004301 ( +0.000391, +10.00%)
│ └─ DEATH(annual) 0.003910 -> 0.004301 ( +0.000391, +10.00%)
├─ Cash flow deltas (annual sum, non-zero rows only)
│ ├─ year stream sum(baseline) sum(mort+10%) Δ %Δ
│ ├─ 0 premium 449,802 449,786 -16 -0.00%
│ ├─ 0 claim 59,260 65,186 +5,926 +10.00%
│ ├─ 0 expense 786,204 786,201 -3 -0.00%
│ ├─ 1 premium 393,535 393,489 -46 -0.01%
│ ├─ 1 claim 57,233 62,952 +5,718 +9.99%
│ ├─ 1 expense 77,014 77,005 -9 -0.01%
│ ├─ 2 premium 336,705 336,635 -69 -0.02%
│ ├─ 2 claim 53,606 58,957 +5,351 +9.98%
│ ├─ 2 expense 67,311 67,297 -14 -0.02%
│ ├─ 2 surrender 10,543 10,542 -1 -0.01%
│ ├─ 3 premium 288,029 287,942 -87 -0.03%
│ ├─ 3 claim 49,853 54,824 +4,971 +9.97%
│ ├─ 3 expense 58,841 58,823 -18 -0.03%
│ ├─ 3 surrender 18,738 18,735 -2 -0.01%
│ ├─ 4 premium 246,348 246,247 -101 -0.04%
│ ├─ 4 claim 45,669 50,218 +4,549 +9.96%
│ ├─ 4 expense 51,449 51,428 -21 -0.04%
│ ├─ 4 surrender 28,453 28,448 -5 -0.02%
│ ├─ 5 premium 212,969 212,858 -111 -0.05%
│ ├─ 5 claim 42,130 46,321 +4,191 +9.95%
│ ├─ 5 expense 45,489 45,465 -24 -0.05%
│ ├─ 5 surrender 32,352 32,345 -7 -0.02%
│ ├─ 6 premium 188,584 188,463 -121 -0.06%
│ ├─ 6 claim 39,995 43,969 +3,974 +9.94%
│ ├─ 6 expense 41,206 41,180 -26 -0.06%
│ ├─ 6 surrender 33,010 33,002 -8 -0.02%
│ ├─ 7 premium 170,943 170,811 -132 -0.08%
│ ├─ 7 claim 39,885 43,842 +3,957 +9.92%
│ ├─ 7 expense 38,216 38,186 -29 -0.08%
│ ├─ 7 surrender 31,165 31,156 -9 -0.03%
│ ├─ 8 premium 158,521 158,376 -145 -0.09%
│ ├─ 8 claim 40,922 44,976 +4,054 +9.91%
│ ├─ 8 expense 36,260 36,227 -33 -0.09%
│ ├─ 8 surrender 26,690 26,682 -9 -0.03%
│ ├─ 9 premium 150,309 150,148 -161 -0.11%
│ ├─ 9 claim 42,035 46,192 +4,157 +9.89%
│ ├─ 9 expense 35,176 35,139 -38 -0.11%
│ ├─ 9 surrender 19,469 19,462 -7 -0.04%
│ ├─ 10 premium 144,222 144,043 -178 -0.12%
│ ├─ 10 claim 42,955 47,196 +4,241 +9.87%
│ ├─ 10 expense 34,532 34,489 -43 -0.12%
│ ├─ 10 surrender 21,844 21,835 -9 -0.04%
│ ├─ 11 premium 138,341 138,146 -196 -0.14%
│ ├─ 11 claim 44,228 48,587 +4,358 +9.85%
│ ├─ 11 expense 33,899 33,852 -48 -0.14%
│ ├─ 11 surrender 23,739 23,728 -11 -0.05%
│ ├─ 12 premium 132,657 132,444 -213 -0.16%
│ ├─ 12 claim 46,503 51,076 +4,573 +9.83%
│ ├─ 12 expense 33,278 33,225 -53 -0.16%
│ ├─ 12 surrender 25,644 25,631 -13 -0.05%
│ ├─ 13 premium 127,149 126,918 -231 -0.18%
│ ├─ 13 claim 49,657 54,529 +4,872 +9.81%
│ ├─ 13 expense 32,667 32,607 -59 -0.18%
│ ├─ 13 surrender 27,552 27,537 -15 -0.06%
│ ├─ 14 premium 121,800 121,550 -250 -0.21%
│ ├─ 14 claim 53,576 58,820 +5,243 +9.79%
│ ├─ 14 expense 32,063 31,998 -66 -0.21%
│ ├─ 14 surrender 29,455 29,437 -18 -0.06%
│ ├─ 15 premium 116,601 116,331 -270 -0.23%
│ ├─ 15 claim 56,919 62,474 +5,555 +9.76%
│ ├─ 15 expense 31,468 31,396 -73 -0.23%
│ ├─ 15 surrender 31,343 31,323 -21 -0.07%
│ ├─ 16 premium 111,552 111,262 -290 -0.26%
│ ├─ 16 claim 59,732 65,543 +5,811 +9.73%
│ ├─ 16 expense 30,883 30,802 -80 -0.26%
│ ├─ 16 surrender 33,212 33,188 -24 -0.07%
│ ├─ 17 premium 106,657 106,346 -311 -0.29%
│ ├─ 17 claim 62,630 68,703 +6,073 +9.70%
│ ├─ 17 expense 30,306 30,218 -88 -0.29%
│ ├─ 17 surrender 35,053 35,026 -28 -0.08%
│ ├─ 18 premium 101,909 101,578 -332 -0.33%
│ ├─ 18 claim 65,589 71,925 +6,336 +9.66%
│ ├─ 18 expense 29,739 29,642 -97 -0.33%
│ ├─ 18 surrender 36,860 36,829 -31 -0.09%
│ ├─ 19 premium 97,301 96,948 -353 -0.36%
│ ├─ 19 claim 69,116 75,765 +6,649 +9.62%
│ ├─ 19 expense 29,181 29,075 -106 -0.36%
│ ├─ 19 surrender 38,625 38,590 -36 -0.09%
│ └─ maturity benefit at t=240m: 2,159,681 -> 2,151,383 (-8,298)
├─ Discount factor deltas (key months)
│ ├─ t= 0m: ds 1.000000 -> 1.000000 (+0.000000)
│ ├─ t= 12m: ds 0.969904 -> 0.969904 (+0.000000)
│ ├─ t= 60m: ds 0.835840 -> 0.835840 (+0.000000)
│ ├─ t= 120m: ds 0.679134 -> 0.679134 (+0.000000)
│ ├─ t= 228m: ds 0.471163 -> 0.471163 (+0.000000)
│ └─ t= 240m: ds 0.452457 -> 0.452457 (+0.000000)
├─ BEL deltas (key months)
│ ├─ BEL[ 0] 403,359.82 -> 470,449.29 ( +67,089.47, +16.63%)
│ ├─ BEL[ 12] 4,269.76 -> 67,408.91 ( +63,139.14, +1478.75%)
│ ├─ BEL[ 60] 819,188.11 -> 869,834.02 ( +50,645.91, +6.18%)
│ ├─ BEL[ 120] 1,389,753.94 -> 1,428,944.08 ( +39,190.14, +2.82%)
│ ├─ BEL[ 228] 2,112,593.93 -> 2,111,350.80 ( -1,243.14, -0.06%)
│ └─ BEL[ 240] 2,159,680.98 -> 2,151,383.05 ( -8,297.92, -0.38%)
├─ CSM deltas (key months)
│ ├─ CSM[ 0] 0.00 -> 0.00 ( +0.00, --)
│ ├─ CSM[ 12] 0.00 -> 0.00 ( +0.00, --)
│ ├─ CSM[ 60] 0.00 -> 0.00 ( +0.00, --)
│ ├─ CSM[ 120] 0.00 -> 0.00 ( +0.00, --)
│ ├─ CSM[ 228] 0.00 -> 0.00 ( +0.00, --)
│ └─ CSM[ 240] 0.00 -> 0.00 ( +0.00, --)
└─ Final (headline change, per policy)
├─ BEL 403,359.82 -> 470,449.29 ( +67,089.47, +16.63%)
├─ RA 47,359.86 -> 52,035.88 ( +4,676.03, +9.87%)
├─ FCF = BEL+RA 450,719.68 -> 522,485.17 ( +71,765.49, +15.92%)
├─ CSM = max(0,-FCF) 0.00 -> 0.00 ( +0.00, --)
└─ loss_component 450,719.68 -> 522,485.17 ( +71,765.49, +15.92%)
BEL이 +16.24% 움직였습니다. 이 16% 가 어디서 왔는지 위쪽 섹션이 설명해 줍니다:
Rate deltas — 매년 mortality(annual) 와 DEATH 담보 rate 가 (둘 다 같은
MORTALITY_STD) 정확히 +10.00%. lapse / waiver / 나머지 담보는 변화 없음 (출력에서 자동 숨김).Cash flow deltas — claim 이 매년 +10% 근처. 동시에 premium 이 소폭 감소 (-0.05% 정도) — 사망률이 올라가니 in-force 가 빨리 줄어 미래 premium 이 적어지는 자연스러운 전파.
BEL deltas (key months) —
BEL[0]의 +16.24% 가BEL[240]의 -0.38% (만기금이 줄어든 효과) 와 합쳐져 만들어진 결과.
+16.24% 라는 숫자 하나가 아니라 각 단계가 어떻게 얽혔는지 가
보이는 게 핵심입니다. 사내 검토 시 “왜 BEL이 16% 움직였는지” 의
질문에 일직선으로 답할 수 있습니다.
함정 — 검증 시 자주 마주치는 것#
함정 1 — residual 이 0 인데 손계산과 안 맞음#
gmm.trace_bel_step 의 잔차는 엔진의 식 vs 엔진 자신 의 비교입니다.
0 이라는 것은 엔진이 출력된 식을 정확히 따른다는 뜻일 뿐, 엔진과
사용자 손계산 이 일치한다는 뜻은 아닙니다.
손계산과 엔진이 어긋나면 보통 다음 셋 중 하나입니다:
사용자의 손계산이 다른 식 — 예를 들어 IFRS 17 의 mid-month 할인 (
(1+i)^(-1/2)) 대신 month-start 할인을 가정 (§B71 의 한 해석). 식이 다르면 결과도 다름. step 행의half = .../full = ...자리에 자기 손계산의 할인을 대입해 보면 어느 쪽 정의를 썼는지 분명해집니다.rate 환산 차이 — 자기 손계산은 월 사망률
q_m = q_a / 12, 엔진은1 - (1 - q_a)^(1/12)(constant-force = 사력 일정 가정). 두 값은 작은 q_a 에서는 근사 같지만 q_a 가 크면 (10% 이상) 차이 명확.A/E factor / improvement / age_shift 누락 — 엔진은 적용했는데 손계산은 raw rate 만 썼을 가능성.
gmm.trace의 Basis 행에<callable -> MORTALITY_STD (+improvement, +ae)>같은 modifier 가 붙어 있으면 wrap 이 걸린 것.
함정 2 — 자기 손계산 단순화의 영향 잊기#
검증을 단순화하려고 lapse = 0, expense = 0, discount = 0, RA cv = 0 으로 두면 손계산이 손쉽지만, 그러면 엔진도 같은 단순화로 돌려야
공정한 비교입니다. 챕터 1 의 1.6 검증 절이 정확히 그 패턴 — 2개월,
사망률 1%, 그 외 전부 0.
gmm.trace 의 Basis 블록은 검증에 필수: 자신이 단순화한 항목이
실제로 0 인지 (예: expense_items 가 비어 있는지) 확인합니다.
함정 3 — shock 이 의도와 다르게 전파됨#
gmm.trace_diff 에서 mortality 만 올렸는데 lapse / expense / surrender
가 미세하게 움직이는 것은 버그가 아닙니다. mortality 가 in-force
trajectory 를 바꾸고, 그 in-force 가 lapse_flow / expense / surrender
의 베이스를 바꾸는 자연스러운 전파입니다. 변화 폭이 mortality 의 변화
폭과 격이 다르면 (mortality +10% 인데 expense +50%) 그때 의심.
함정 4 — gmm.trace* 가 무거울 거라 생각해 안 씀#
이 도구들 모두 단일 행 subset 후 measure() 만 호출합니다. 1M 계약
포트폴리오에서 gmm.trace(0, mp, basis) 를 호출해도 단일 계약 측정
비용만 발생합니다. 검토 회의 중에 화면 공유로 즉석 호출해도 부담 없는
수준입니다.
함정 5 — mp_id 가 문자열일 거라고 기대#
도구의 첫 인자는 model_points 의 0-based 정수 인덱스 입니다.
워크북 / CSV 의 mp_id 컬럼 (P001, P002, …) 과 다릅니다. 문자열 ID
로 찾고 싶다면 폴리어스 / 판다스로 인덱스를 미리 뽑아 둡니다:
import fastcashflow as fcf
import polars as pl
# 샘플 파일 저장 (본인 파일 있으면 생략)
fcf.samples.export("samples", template="gmm", quiet=True) # basis.xlsx + policies / coverages / calculation_methods (+ inforce)
# 만들어진 샘플 파일 읽어 들이기
basis = fcf.read_basis("samples/basis.xlsx") # 산출기초
mp = fcf.read_model_points(
"samples/policies.csv", # 계약 스펙
coverages = "samples/coverages.csv", # 담보 가입금액
calculation_methods = "samples/calculation_methods.csv", # 담보별 산출방식
)
# mp_id (문자열) → 0-based 정수 인덱스
pol = pl.read_csv("samples/policies.csv")
idx = pol.with_row_index("idx").filter(pl.col("mp_id") == "P002")["idx"][0]
fcf.gmm.trace(int(idx), mp, basis)
인접 레시피#
이 챕터를 읽고 자연스럽게 갈 다음 자리들:
8.1 시나리오 / 민감도 분석 —
gmm.trace_diff의 활용을 단일 계약 검증 너머 포트폴리오 단위 민감도로 확장.정기보험 의 “함정 — 흔한 실수와 잡는 방법” 절 — 2 개월 손계산 패턴. 이 챕터의 도구로 그 검증을 다시 추적하면 식이 어떻게 매핑되는지 명확.
7.2 워크북 — 다중 segment / 다종 상품 —
gmm.trace의 segment 라우팅이 맞는지 확인하는 자리.
기본 튜토리얼의 5장 (BEL 계산) / 7장 (CSM 계산) 이 gmm.trace_bel_step /
gmm.trace_csm_step 이 풀어 보여주는 식의 derivation (유도) 을 다룹니다.
검증으로 무엇이 보장되고 무엇이 안 보장되나
이 챕터의 도구들은 엔진이 출력된 식을 정확히 따른다 는 것을 보여줍니다 (residual ≈ 0). 식 자체가 회사 상품 / 회사 가정 / IFRS 17 의 의도에 맞는지는 사용자 판단 입니다.
검증으로 잡히는 것: 입력 / 출력 / 식의 매핑이 끊기지 않았다는 사실.
검증으로 안 잡히는 것: 입력 가정이 옳은지, 식의 선택이 회사 상황에 적합한지, IFRS 17 의 §B71 mid-month 가정이 회사 회계정책과 정합한지. 이 판단은 도구 너머의 영역입니다.