2026-03-18
Data ScienceWOE và IV: Nghệ thuật 'gạn đục khơi trong' cho dữ liệu tín dụng
Làm sao để tìm ra những 'viên ngọc quý' trong đống dữ liệu thô? Công thức WOE, ngưỡng IV cụ thể, optimal binning, missing value treatment và cách kiểm tra stability của WOE bins theo thời gian.
Tóm lược
- WOE (Weight of Evidence):
WOE_i = ln(%Good_i / %Bad_i)— mã hóa từng bin thành một con số phản ánh mức độ rủi ro tương đối so với tổng thể. - IV (Information Value):
IV = Σ (%Good_i − %Bad_i) × WOE_i— thang đo cụ thể: < 0.02 = vô nghĩa, 0.02-0.1 = yếu, 0.1-0.3 = trung bình, 0.3-0.5 = mạnh, > 0.5 = nghi ngờ leakage. - Dữ liệu Missing không phải trash — hãy cho nó một bin riêng, nó thường mang predictive power cao bất ngờ.
- WOE stability theo thời gian cũng quan trọng không kém IV cao: bin của năm ngoái có còn dùng được cho năm nay không?
Giới thiệu
Hãy tưởng tượng bạn là người đãi vàng. Trước mắt là hàng tấn đất cát (dữ liệu thô). Làm sao biết đâu là vàng, đâu là sỏi?
Trong Credit Scoring, WOE và IV là bộ đôi sàng và cân chuyên dụng. Nhưng không giống như nhiều bài viết chỉ giải thích khái niệm, mình sẽ đi thẳng vào công thức, ngưỡng cụ thể, và những cạm bẫy mà ngay cả DS giàu kinh nghiệm cũng hay mắc phải.
Công thức WOE và IV
Convention: Trong credit scoring, thường quy ước:
- Good = khách hàng không nợ xấu (target = 0)
- Bad = khách hàng nợ xấu (target = 1)
WOE_i = ln(Distribution_Good_i / Distribution_Bad_i)
= ln(%Good_i / %Bad_i)
Trong đó:
%Good_i = (số khách hàng Good trong bin i) / (tổng số khách hàng Good)
%Bad_i = (số khách hàng Bad trong bin i) / (tổng số khách hàng Bad)
IV = Σ (%Good_i − %Bad_i) × WOE_i
Đọc WOE:
- WOE > 0: bin này có tỷ lệ Good cao hơn trung bình → low risk
- WOE < 0: bin này có tỷ lệ Bad cao hơn trung bình → high risk
- WOE ≈ 0: bin này không phân biệt được Good/Bad
Ví dụ tính WOE và IV với số cụ thể:
Giả sử biến thu nhập hàng tháng với tổng 5,000 khách hàng (1,000 bad, 4,000 good):
| Bin | Bad trong bin | Good trong bin | % Bad | % Good | WOE | IV contribution |
|---|---|---|---|---|---|---|
| 0 – 5 triệu | 400 | 600 | 40% | 15% | ln(40%/15%) = +0.98 | (0.40−0.15)×0.98 = 0.245 |
| 5 – 15 triệu | 450 | 2,100 | 45% | 52.5% | ln(45%/52.5%) = −0.15 | (0.45−0.525)×(−0.15) = 0.011 |
| > 15 triệu | 150 | 1,300 | 15% | 32.5% | ln(15%/32.5%) = −0.77 | (0.15−0.325)×(−0.77) = 0.135 |
| Tổng IV | 0.391 |
Đọc kết quả:
- Bin 0–5tr có WOE = +0.98 → nhóm này có tỷ lệ bad cao hơn trung bình 2.7 lần (e^0.98 ≈ 2.7)
- Bin >15tr có WOE = −0.77 → nhóm này bad ít hơn trung bình đáng kể
- IV tổng = 0.391 → biến mạnh, đáng đưa vào model (nhưng cần check leakage)
- WOE giảm dần từ bin thấp đến cao → monotone ✅ — hợp logic nghiệp vụ
Ngưỡng IV cụ thể
| Chỉ số IV | Ý nghĩa thực sự | Hành động |
|---|---|---|
| < 0.02 | Biến mờ nhạt, ít giá trị | Loại bỏ |
| 0.02 – 0.10 | Yếu — có thể đưa vào pool | Xem xét kỹ |
| 0.10 – 0.30 | Trung bình — có triển vọng | Kiểm tra monotonicity và stability |
| 0.30 – 0.50 | Mạnh — candidate tốt | Kiểm tra leakage |
| > 0.50 | Rất mạnh — đáng nghi | Kiểm tra nguồn data ngay |
Code thực tế: Tính WOE/IV với Python
Hai thư viện phổ biến nhất trong ngành: scorecardpy (đơn giản, phổ biến tại châu Á) và optbinning (nghiêm ngặt hơn, hỗ trợ monotonicity constraint).
import numpy as np
import pandas as pd
import scorecardpy as sc
# --- Cách 1: scorecardpy — chuẩn production ---
# Tự động binning + enforce monotonicity
bins = sc.woebin(
df, y='bad_flag',
x=['monthly_income', 'dpd_max_6m', 'job_tenure_months'],
bin_num_limit=6,
# monotonic_binning=True # bật nếu muốn enforce
)
# Tóm tắt IV của tất cả biến
iv_table = pd.DataFrame({
col: {'IV': bins[col]['total_iv'].iloc[0]}
for col in bins
}).T.sort_values('IV', ascending=False)
print(iv_table)
# monthly_income 0.391
# dpd_max_6m 0.312
# job_tenure_months 0.087
# Transform sang WOE values để đưa vào Logistic Regression
train_woe = sc.woebin_ply(train_df, bins)
# ⚠️ QUAN TRỌNG: lock bảng bins này khi deploy
# Không recalculate lại trên data mới — WOE map phải được freeze
# --- Cách 2: optbinning — mạnh hơn, hỗ trợ nhiều constraint ---
from optbinning import BinningProcess
binning_process = BinningProcess(
variable_names=['monthly_income', 'dpd_max_6m'],
categorical_variables=[],
max_n_bins=6,
min_bin_size=0.05, # mỗi bin tối thiểu 5% population
monotonic_trend='auto'
)
binning_process.fit(X_train, y_train)
print(binning_process.summary()[['name', 'iv', 'js']].sort_values('iv', ascending=False))
Python Implementation
import pandas as pd
import numpy as np
def woe_iv(df: pd.DataFrame, feature: str, target: str,
bins: int = 10, min_bin_pct: float = 0.05) -> tuple[pd.DataFrame, float]:
"""
Compute WOE and IV for a numeric feature.
target: 1 = Bad (default), 0 = Good (non-default)
"""
df = df[[feature, target]].copy()
# Handle missing values as a separate bin
missing_mask = df[feature].isna()
df_non_missing = df[~missing_mask].copy()
df_missing = df[missing_mask].copy()
# Bin non-missing values using quantile cuts
df_non_missing['bin'] = pd.qcut(
df_non_missing[feature], q=bins, duplicates='drop'
)
def compute_stats(subset):
agg = subset.groupby('bin', observed=True)[target].agg(['sum', 'count'])
agg.columns = ['bad', 'total']
agg['good'] = agg['total'] - agg['bad']
return agg
stats = compute_stats(df_non_missing)
# Add missing bin if it exists
if len(df_missing) > 0:
missing_stats = pd.DataFrame({
'bad': [df_missing[target].sum()],
'total': [len(df_missing)],
'good': [len(df_missing) - df_missing[target].sum()],
}, index=['MISSING'])
stats = pd.concat([stats, missing_stats])
total_good = stats['good'].sum()
total_bad = stats['bad'].sum()
stats['pct_good'] = stats['good'] / total_good
stats['pct_bad'] = stats['bad'] / total_bad
# Avoid log(0)
stats['pct_good'] = stats['pct_good'].clip(lower=1e-4)
stats['pct_bad'] = stats['pct_bad'].clip(lower=1e-4)
stats['woe'] = np.log(stats['pct_good'] / stats['pct_bad'])
stats['iv_bin'] = (stats['pct_good'] - stats['pct_bad']) * stats['woe']
iv_total = stats['iv_bin'].sum()
return stats, round(iv_total, 4)
# Usage
woe_table, iv = woe_iv(df, feature='monthly_income', target='is_default')
print(f"IV = {iv}")
print(woe_table[['bad', 'good', 'pct_bad', 'pct_good', 'woe', 'iv_bin']])
Monotonicity: WOE phải mạch lạc
Sau khi binning, xu hướng WOE phải đơn điệu (monotonic). Ví dụ với monthly_income:
| Bin | Avg Income | WOE | Interpretation |
|---|---|---|---|
| 1 | < 3M | -1.82 | Very high risk |
| 2 | 3-5M | -0.94 | High risk |
| 3 | 5-8M | -0.21 | Moderate risk |
| 4 | 8-12M | +0.43 | Low risk |
| 5 | > 12M | +1.15 | Very low risk |
WOE tăng dần từ bin 1 đến 5 → monotonic → binning hợp lý.
Nếu WOE nhảy zig-zag (ví dụ: -1.2, +0.3, -0.8, +1.1): binning quá vụn, hoặc feature này không có mối quan hệ linear với risk. Giải pháp: coarsen bins, hoặc tạo feature mới (log transform, bucketing tay dựa trên domain knowledge).
Missing Value Bin: Đừng bỏ qua
# ĐỪNG làm thế này
df['monthly_income'].fillna(df['monthly_income'].median(), inplace=True)
# HÃY làm thế này
# Để WOE/IV tính separate bin cho missing
# Sau đó quyết định dựa trên WOE của MISSING bin
# Nếu WOE(MISSING) = -1.5 → khách hàng thiếu income data = high risk
# → Missing mang thông tin quan trọng, không được fill trước khi WOE
# Nếu WOE(MISSING) ≈ 0 → missing ngẫu nhiên, có thể fill sau
Trong thực tế tại digital bank: khách hàng không khai báo thu nhập thường có WOE rất âm (high risk) — vì họ không muốn lộ thu nhập thấp. Đây là signal mạnh mà fillna sẽ xóa sạch.
WOE Stability: Bins năm ngoái dùng cho năm nay?
IV cao không đủ — cần kiểm tra WOE của từng bin có stable theo thời gian không. Dùng PSI trên WOE distribution (không phải raw feature):
def woe_stability_check(dev_woe_table, prod_df, feature, target, bins=10):
"""Check if WOE table from dev period is still valid on current data."""
# Apply dev bins to prod data
prod_woe_table, _ = woe_iv(prod_df, feature, target, bins)
# Compare WOE values per bin
comparison = dev_woe_table[['woe']].rename(columns={'woe': 'woe_dev'}).join(
prod_woe_table[['woe']].rename(columns={'woe': 'woe_prod'}),
how='outer'
)
comparison['woe_shift'] = abs(comparison['woe_dev'] - comparison['woe_prod'])
return comparison
Nếu WOE của một bin shift > 0.5 giữa dev và prod → bin đó không còn reliable → cần rebinning.
Rule of thumb: WOE table cần được rebuild ít nhất mỗi 12 tháng, hoặc bất cứ khi nào PSI của feature đó vượt 0.20.
Leakage: Dấu hiệu nhận biết
IV > 0.5 thường là dấu hiệu leakage. Các leakage patterns phổ biến trong credit:
| Pattern | Ví dụ | Tại sao sai |
|---|---|---|
| Post-outcome feature | days_overdue_at_observation | Chỉ có sau khi nợ xấu xảy ra |
| Future feature | balance_next_month | Dùng thông tin tương lai để predict quá khứ |
| Proxy feature | collection_flag | Trực tiếp reflect outcome |
# Kiểm tra leakage đơn giản: correlation với target
suspicious = df.corrwith(df['is_default']).abs()
high_corr = suspicious[suspicious > 0.5]
print("Suspicious high-correlation features:")
print(high_corr.sort_values(ascending=False))
Kết luận
WOE và IV là kính lúp, không phải kính thiên văn. Chúng không thấy được quan hệ nhân quả, nhưng cực kỳ tốt ở việc cho bạn biết: "Feature này có predictive power không, và mối quan hệ có mạch lạc không?" Kết hợp với kiến thức nghiệp vụ và stability check theo thời gian — đây là bộ công cụ không thể thiếu cho bất kỳ credit scoring project nào.