Quick answer: To build an ECL model in Python, assign each loan to Stage 1, 2 or 3, estimate PD, LGD and EAD by period, discount expected losses at the effective interest rate, and weight the results across economic scenarios. Stage 1 uses 12-month ECL; Stages 2 and 3 use lifetime ECL. This applies to IFRS 9 and Ind AS 109.
Most guides to expected credit loss stop at the formula. This one builds the whole calculation in Python on a synthetic loan portfolio: staging, 12-month and lifetime PD, LGD, EAD, discounting at the effective interest rate (EIR) and scenario weighting. Run the code blocks in order and you can reproduce the results table at the end.
It is written for credit risk analysts, provisioning teams and model validators who know basic pandas and want to see how the pieces of an ECL model in Python fit together. The method follows the impairment approach common to IFRS 9 and Ind AS 109.
Important: all data here is synthetic and this is a teaching model. It is not a production or regulatory model. The section on limitations explains what a real model adds.
Expected credit loss (ECL) is the probability-weighted, discounted estimate of the cash shortfalls you expect on a financial asset. The impairment model applies to financial assets at amortised cost, debt instruments at fair value through other comprehensive income, lease receivables, loan commitments and financial guarantee contracts. For each future period, the building block is:
ECL = marginal PD × LGD × EAD × discount factor
You sum this across the relevant horizon. The standards add three requirements (paragraph 5.5.17 in both): the estimate must be unbiased and probability-weighted, reflect the time value of money, and use reasonable and supportable information about past events, current conditions and forecasts of future conditions.
Impairment follows a three-stage approach:
| Stage | Trigger | ECL horizon |
| Stage 1 | No significant increase in credit risk (SICR) since initial recognition, or the low credit risk exemption is applied | 12-month ECL |
| Stage 2 | SICR, but not credit-impaired | Lifetime ECL |
| Stage 3 | Credit-impaired (Appendix A events, usually aligned with your default definition) | Lifetime ECL, with PD treated as 100% |
Two points trip up newcomers.
First, 12-month ECL is not the cash shortfall you expect in the next year. It is the part of lifetime ECL that results from defaults possible within the next 12 months.
Second, the standards do not give one fixed definition of default. You define it consistently with your internal credit risk management, and the standard carries a rebuttable presumption that default does not occur later than 90 days past due. Similarly, there is a rebuttable presumption that credit risk has increased significantly once payments are more than 30 days past due. Our code uses both as policy choices.
IFRS 9 is the IASB’s international standard. Ind AS 109 is its counterpart under India’s converged accounting standards. The staging, SICR, 12-month versus lifetime and probability-weighting concepts used below apply to both.
What differs is who applies the standard and what regulators add on top. For NBFCs covered by Rule 4 of the Companies (Indian Accounting Standards) Rules, 2015, RBI’s current NBFC Financial Statements Directions, 2025 (updated 1 July 2026) set out regulatory guidance on Ind AS implementation, carried forward from its 13 March 2020 circular. These points matter for ECL modelling:
RBI also restates the 30-days-past-due rebuttable presumption for SICR and adds a limit: an NBFC shall not defer recognising a significant increase in credit risk for any exposure overdue beyond 60 days. It recommends that the accounting definition of default be guided by the regulatory one, and it points to the Basel Committee’s December 2015 guidance on credit risk and accounting for expected credit losses as a reference.
For banks, regulatory provisioning follows RBI’s prudential norms, and that framework has been evolving. Check RBI’s latest directions before using any model like this for regulatory provisioning. This article does not cover bank provisioning rules.
We generate 1,000 loans with a fixed random seed so the results are reproducible. pd_orig is the 12-month PD at origination and pd_now is the current 12-month PD. In practice both come from your PD model; here they are simulated.
import numpy as np
import pandas as pd
# 1. SYNTHETIC portfolio (illustrative only: no real bank data)
rng = np.random.default_rng(42)
n = 1000
loans = pd.DataFrame({
‘loan_id’: np.arange(1, n + 1),
‘ead’: rng.integers(2, 50, n) * 100_000.0, # outstanding exposure, INR
‘eir’: rng.uniform(0.09, 0.16, n), # effective interest rate
‘term_yrs’: rng.integers(1, 6, n), # remaining life, years
‘secured’: rng.random(n) < 0.55,
‘pd_orig’: rng.uniform(0.005, 0.03, n), # 12-month PD at origination
})
loans[‘pd_now’] = np.clip(loans[‘pd_orig’] * rng.lognormal(0.2, 0.7, n), 0.001, 0.60)
loans[‘dpd’] = np.where(rng.random(n) < 0.12, rng.integers(1, 140, n), 0)
Staging decides how long a horizon each loan gets, so it is often the biggest driver of the final number. Our rules are deliberately simple:
The relative-plus-absolute test stops tiny PD moves on very low-risk loans from triggering Stage 2. The standard looks at the change in the risk of default over the expected life. Using 12-month PD as a proxy is allowed only where it does not mislead (paragraphs B5.5.13 and B5.5.14), so loans whose risk is not evenly spread over time need a lifetime comparison. These thresholds are illustrative. A real policy calibrates and documents them, and adds qualitative indicators such as watchlist status and forbearance, which we omit.
# 2. Staging (thresholds are illustrative policy choices, not a standard)
def assign_stage(r):
if r.dpd > 90:
return 3
sicr = (r.dpd > 30) or (r.pd_now >= 2 * r.pd_orig and r.pd_now – r.pd_orig >= 0.005)
return 2 if sicr else 1
loans[‘stage’] = loans.apply(assign_stage, axis=1)
Lifetime ECL needs a PD for every year of the loan, not just the next 12 months. We take the simplest route: assume a constant annual hazard rate derived from the 12-month PD.
Stage 1 loans use year 1 only. Stage 2 loans use every year to maturity. Production models replace the constant hazard with survival models, migration matrices or vintage curves, and make PDs point-in-time by linking them to macroeconomic variables.
The function in the next step turns one scenario’s PD and LGD assumptions into loan-level ECL. It makes four simplifying assumptions, all stated in the code comments:
The standards require a probability-weighted outcome, so we run the full calculation under three scenarios and weight the loan-level results.
| Scenario | Probability weight | PD multiplier | LGD multiplier |
| Base | 50% | 1.00 | 1.00 |
| Upside | 20% | 0.80 | 0.90 |
| Downside | 30% | 1.50 | 1.15 |
These weights and multipliers are placeholders. Stage allocation is also held fixed across scenarios here; an institution may instead assess SICR on a scenario-weighted basis. Real models derive them from macroeconomic forecasts and satellite models that link GDP, unemployment or interest rates to PD and LGD.
# 3. Scenarios: name -> (probability weight, PD multiplier, LGD multiplier)
scen = {‘base’: (0.50, 1.00, 1.00),
‘upside’: (0.20, 0.80, 0.90),
‘downside’: (0.30, 1.50, 1.15)}
MAX_T = 5
def scenario_ecl(df, pd_mult, lgd_mult):
pd12 = np.clip(df[‘pd_now’] * pd_mult, 0, 0.99)
hazard = -np.log(1 – pd12) # constant annual hazard (simplification)
lgd = np.clip(np.where(df[‘secured’], 0.25, 0.45) * lgd_mult, 0, 1)
ecl = np.zeros(len(df))
for t in range(1, MAX_T + 1):
marg_pd = np.exp(-hazard * (t – 1)) – np.exp(-hazard * t) # default in year t
ead_t = df[‘ead’] * np.clip((df[‘term_yrs’] – t + 1) / df[‘term_yrs’], 0, 1)
disc = (1 + df[‘eir’]) ** -t # discount at EIR
in_horizon = np.where(df[‘stage’] == 1, t <= 1, t <= df[‘term_yrs’])
ecl += np.where(in_horizon, marg_pd * lgd * ead_t * disc, 0.0)
return np.where(df[‘stage’] == 3, lgd * df[‘ead’], ecl) # Stage 3: PD = 100%
# 4. Probability-weighted ECL
for name, (w, pm, lm) in scen.items():
loans[f’ecl_{name}’] = scenario_ecl(loans, pm, lm)
loans[‘ecl_weighted’] = sum(w * loans[f’ecl_{k}’] for k, (w, _, _) in scen.items())
# 5. Results (INR crore)
cr = 1e7
out = loans.groupby(‘stage’).agg(loans=(‘loan_id’, ‘count’), ead=(‘ead’, ‘sum’),
ecl_base=(‘ecl_base’, ‘sum’),
ecl_weighted=(‘ecl_weighted’, ‘sum’))
out[‘coverage_pct’] = 100 * out[‘ecl_weighted’] / out[‘ead’]
print((out[[‘ead’, ‘ecl_base’, ‘ecl_weighted’]] / cr).round(2).join(out[[‘loans’, ‘coverage_pct’]].round(2)))
print({k: round(float(loans[f’ecl_{k}’].sum() / cr), 2) for k in scen}, round(float(loans[‘ecl_weighted’].sum() / cr), 2))
Running the script with seed 42 gives these figures, in INR crore:
| Stage | Loans | EAD | ECL (base) | ECL (weighted) | Coverage |
| Stage 1 | 702 | 177.89 | 0.95 | 1.10 | 0.62% |
| Stage 2 | 253 | 65.25 | 1.83 | 2.10 | 3.22% |
| Stage 3 | 45 | 13.35 | 4.61 | 4.73 | 35.41% |
| Total | 1,000 | 256.49 | 7.39 | 7.93 | 3.09% |
Total ECL by scenario is 7.39 (base), 6.16 (upside) and 10.01 (downside). The weighted figure of 7.93 sits above the base case. The reason is asymmetry: the downside scenario adds about 2.6 crore to base ECL, while the upside removes only about 1.2 crore. Here, the base case alone would understate the provision. Losses respond non-linearly to PD and LGD, which is one reason the standard asks for an unbiased, probability-weighted amount (paragraph 5.5.17(a)).
The stage mix, with about a quarter of loans in Stage 2, comes from our synthetic assumptions. The data also generates days past due and PD independently, which real portfolios do not. Do not read it as a benchmark.
Stage 3 dominates in this run: 45 loans make up about 60% of weighted ECL. In this synthetic portfolio, LGD and staging assumptions move the total more than Stage 1 detail does.
A production ECL model differs from this example in at least these ways:
Before you trust any ECL output, test it:
What is the ECL formula in IFRS 9? For each period, ECL is marginal PD × LGD × EAD × discount factor, summed over the relevant horizon and weighted across scenarios. The discount rate is the effective interest rate, or an approximation of it.
What is the difference between 12-month and lifetime ECL? 12-month ECL counts losses from defaults possible in the next 12 months. Lifetime ECL counts losses from defaults over the full remaining life. Stage 1 uses the first; Stages 2 and 3 use the second.
IFRS 9 vs Ind AS 109: what is the difference? The core ECL method (three stages, SICR, 12-month or lifetime ECL, probability weighting) is the same in both. The practical differences lie in who must apply the standard and in the regulatory overlay. For NBFCs applying Ind AS, RBI requires a parallel IRACP provision and an Impairment Reserve where the Ind AS allowance is lower.
How do you discount ECL? Discount each period’s expected shortfall back to the reporting date at the loan’s effective interest rate, as the code above does with (1 + eir) ** -t.
Can I use this model for regulatory provisioning? No. It is a teaching model on synthetic data. A bank or NBFC needs a governed, validated model that follows its accounting policy and applicable regulation.
Which Python libraries do I need? This example uses only numpy and pandas. For real PD models you would typically add statsmodels or scikit-learn, and a survival-analysis package for term structures.
A small script shows the mechanics. Production ECL work needs PD, LGD and EAD development, scenario design and validation done properly. If you want structured, instructor-led training on this, see DexLab’s Advanced Certificate in IFRS 9 Modeling
.
Credit and Market Risk, Credit Risk, credit risk analysis, Credit Risk Analytics And Modeling, credit risk analytics training, Credit risk certification India, Credit risk modeling course India, Credit Risk Modelling, Credit Risk Modelling Using SAS, Credit scorecard modeling, Expected credit loss ECL, IFRS 9 modeling course India, Logistic regression scorecard, PD LGD EAD modeling, Python credit risk modeling
Comments are closed here.