Trần Quốc Việt
← All posts

2026-04-24

Banking Domain

Gini, KS, PSI: Ba câu hỏi mà mọi model credit risk cần trả lời

AUC tháng trước 0.72, tháng này vẫn 0.72. Nhưng khách hàng đã thay đổi, policy đã thay đổi. Model có còn hoạt động không? Ba metrics này sẽ trả lời.

View
Lang

Tóm tắt

Ba câu hỏi cơ bản khi giám sát model credit risk:

  1. Model còn phân biệt được good vs bad không? → Gini (hay AUC)
  2. Điểm tách biệt cực đại ở đâu? → KS
  3. Khách hàng hôm nay có khác lúc train không? → PSI

Ba câu hỏi độc lập. Có thể Gini ổn nhưng PSI cảnh báo. Có thể PSI cao nhưng không phải vì model kém mà vì policy thay đổi. Cần cả ba để có bức tranh đầy đủ.


Ba màn hình trong phòng ICU

Bác sĩ trong ICU theo dõi đồng thời ba chỉ số: SpO₂ (nồng độ oxy), huyết áp, nhịp tim. Không ai nói "chỉ cần nhịp tim là đủ." Mỗi màn hình đo một thứ khác nhau, cùng nhau tạo thành bức tranh toàn diện về bệnh nhân.

Model credit risk cũng vậy. AUC 0.72 tuần này và 0.72 tuần trước trông giống nhau trên báo cáo. Nhưng nếu khách hàng đang apply loan tháng này là một population hoàn toàn khác (seasonal workers, new channel, macro shock), model đó có thể đang cho ra quyết định sai hàng loạt — mà dashboard AUC không hề thấy.


Metric 1: Gini / AUC — "Model còn phân biệt được không?"

AUC (Area Under ROC Curve) đo xác suất model xếp hạng một good customer cao hơn một bad customer nếu chọn ngẫu nhiên.

Gini = 2 × AUC − 1

Cách đọc thực tế:

AUCGiniĐánh giá trong credit
0.500.00Random, vô dụng
0.650.30Chấp nhận được (subprime)
0.720.44Tốt (retail credit)
0.800.60Xuất sắc
> 0.85> 0.70Đáng nghi — có thể leakage

Threshold thực tế cho retail credit tại ngân hàng: Gini ≥ 35% trên OOT là acceptable. Dưới 30% cần review nghiêm túc.

Khi Gini giảm, hỏi 3 câu:

  • Giảm trên train hay chỉ trên OOT? (Nếu chỉ OOT: model aging hoặc population shift)
  • Giảm đồng đều hay chỉ ở một số segment? (Drill down by channel, product, vintage)
  • Approval mix có thay đổi không? (Approve nhiều hơn ở score thấp sẽ kéo Gini xuống)

Metric 2: KS — "Điểm tách biệt cực đại ở đâu?"

KS (Kolmogorov-Smirnov) là khoảng cách tối đa giữa cumulative distribution của good customers và bad customers theo thứ tự score.

Hình dung: xếp tất cả khách hàng theo score từ thấp đến cao. Vẽ hai đường: % bad đã "bắt được" tích lũy theo score, và % good đã "bắt được" tích lũy theo score. KS là khoảng cách tối đa giữa hai đường đó.

KS = 42 nghĩa là tại một điểm trên score distribution, model đã capture được 42% khoảng cách giữa bad và good population.

KSĐánh giá
< 20Yếu
20 – 30Chấp nhận được
30 – 40Tốt
> 40Rất tốt

KS thường dùng để làm gì trong thực tế?

  • Tìm điểm cutoff tối ưu: điểm có KS cao nhất thường là vùng phân biệt tốt nhất
  • So sánh hai model: model nào có KS cao hơn ở cùng score range?
  • Visualize bằng Lorenz curve để trình bày cho risk committee

Lưu ý quan trọng: KS nhạy cảm với approval mix. Nếu bạn suddenly approve nhiều hơn ở score thấp (policy loosening), KS có thể giảm không phải vì model kém mà vì population on-book thay đổi.


Metric 3: PSI — "Khách hàng có thay đổi không?"

PSI (Population Stability Index) so sánh distribution của score (hoặc một feature) giữa thời điểm hiện tạithời điểm training. Đây là metric quan trọng nhất cho monitoring hàng tuần.

Công thức:

PSI = Σ (Actual% − Expected%) × ln(Actual% / Expected%)

Trong đó:

  • Expected% = % khách hàng rơi vào mỗi score bin lúc training
  • Actual% = % khách hàng rơi vào mỗi score bin hiện tại

Thresholds chuẩn ngành:

PSITín hiệuHành động
< 0.10✅ Ổn địnhMonitor định kỳ
0.10 – 0.25⚠️ Cần chú ýInvestigate, tìm nguyên nhân
> 0.25🔴 Shift nghiêm trọngReview model, cân nhắc retrain

Ví dụ thực tế: PSI đột ngột tăng lên 0.31 vào tháng 3. Trước khi panic, hãy kiểm tra:

  • Tháng 3 có seasonal effect không? (ví dụ: Tết → applicant profile khác hoàn toàn)
  • Có campaign marketing mới targeting segment khác không?
  • Có thay đổi channel distribution không?

PSI cao không tự động nghĩa model cần retrain. Nó nghĩa là population đã thay đổi — và bạn cần hiểu tại sao trước khi quyết định.


Bảng action khi metrics vượt ngưỡng

SignalNguyên nhân phổ biếnHành động
Gini giảm > 5pt (OOT)Model aging, feature driftFeature-level PSI, OOT drill-down by vintage
Gini giảm nhưng PSI ổnApproval mix shiftCheck segment distribution, review cutoff
KS giảm > 10ptApproval policy looseningVintage analysis by score band
PSI > 0.25Population shift, new channelFeature-level PSI để tìm culprit variable
PSI cao + Gini giảmGenuine driftTrigger model review, escalate
PSI cao nhưng Gini ổnPolicy/seasonal effectDocument, continue monitoring
Tất cả ổn nhưng NPL tăngMacro/external shockPolicy review, không phải model review

Monitoring cadence thực tế

Không phải mọi metric đều cần theo dõi hàng ngày — đó là recipe cho alert fatigue:

MetricCadenceLý do
PSI (score)WeeklyNhẹ, chạy tự động, catch shift sớm
PSI (key features)Bi-weeklyTìm culprit variable khi score PSI cao
Gini / KSMonthlyCần đủ volume để estimate ổn định
Calibration checkQuarterlyCần outcome đã mature (3–6 months)
Full OOT re-runSemi-annuallyĐánh giá toàn diện, báo cáo risk committee

Code thực tế: Tính Gini, KS, PSI trong một pipeline

python
import numpy as np
import pandas as pd
from sklearn.metrics import roc_auc_score
from scipy.stats import ks_2samp

# --- Gini ---
def calc_gini(y_true: np.ndarray, y_score: np.ndarray) -> float:
    return 2 * roc_auc_score(y_true, y_score) - 1

# --- KS ---
def calc_ks(y_true: np.ndarray, y_score: np.ndarray) -> float:
    bad_scores  = y_score[y_true == 1]
    good_scores = y_score[y_true == 0]
    ks_stat, _ = ks_2samp(bad_scores, good_scores)
    return ks_stat

# --- PSI --- (breakpoints từ expected/training, không từ actual)
def calc_psi(expected: pd.Series, actual: pd.Series, n_bins: int = 10) -> float:
    breakpoints = np.nanpercentile(expected, np.linspace(0, 100, n_bins + 1))
    breakpoints = np.unique(breakpoints)

    exp_cnt, _ = np.histogram(expected, bins=breakpoints)
    act_cnt, _ = np.histogram(actual,   bins=breakpoints)

    exp_pct = np.where(exp_cnt == 0, 1e-4, exp_cnt / len(expected))
    act_pct = np.where(act_cnt == 0, 1e-4, act_cnt / len(actual))

    return float(np.sum((act_pct - exp_pct) * np.log(act_pct / exp_pct)))

# --- Monitoring report hàng tuần ---
gini = calc_gini(y_true, y_pred_prob)
ks   = calc_ks(y_true, y_pred_prob)
psi  = calc_psi(train_scores, live_scores)

print(f"Gini : {gini:.3f}  {'' if gini > 0.35 else '⚠️' if gini > 0.30 else '🔴'}")
print(f"KS   : {ks:.3f}  {'' if ks > 0.30 else '⚠️' if ks > 0.20 else '🔴'}")
print(f"PSI  : {psi:.4f} {'✅ Stable' if psi < 0.10 else '⚠️ Investigate' if psi < 0.25 else '🔴 Review'}")

# --- Feature-level PSI khi score PSI > 0.25 ---
feature_psi = {
    col: calc_psi(train_df[col].dropna(), live_df[col].dropna())
    for col in model_feature_cols
}
print(pd.Series(feature_psi).sort_values(ascending=False).head(5))
# Xác định biến nào đang drift  bắt đầu investigation từ đây

Pitfalls phổ biến

Alert fatigue. Đặt quá nhiều threshold → mọi người ignore hết. Prioritize: PSI của score là tier 1, feature PSI là tier 2, Gini/KS là tier 3.

Confuse PSI cao với model kém. PSI đo population shift, không đo model performance. Một model hoàn hảo vẫn có PSI cao nếu khách hàng thay đổi. Luôn pair PSI với Gini/KS.

Gini trên approve-only vs through-the-door. Nếu bạn chỉ tính Gini trên approved population (vì đó là người có outcome), Gini sẽ cao hơn thực tế — bạn đang loại bỏ phần khó của distribution. Phân biệt rõ hai loại report này.

Không drill down khi tín hiệu đỏ. PSI > 0.25 không phải là điểm kết thúc investigation — đó là điểm bắt đầu. Channel nào? Segment nào? Feature nào dẫn đầu shift?


Takeaway

Gini hỏi: "Model còn phân biệt được risk không?" KS hỏi: "Điểm tách biệt tốt nhất của model nằm ở đâu?" PSI hỏi: "Khách hàng hôm nay có phải là người model đã học không?"

Ba câu hỏi khác nhau, ba công cụ khác nhau. Dùng cả ba, hiểu giới hạn của từng metric, và đừng để một con số duy nhất che giấu toàn bộ bức tranh.


Bài liên quan / Related posts