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

2026-03-18

Data Science

WOE 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.

View
Lang

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):

BinBad trong binGood trong bin% Bad% GoodWOEIV contribution
0 – 5 triệu40060040%15%ln(40%/15%) = +0.98(0.40−0.15)×0.98 = 0.245
5 – 15 triệu4502,10045%52.5%ln(45%/52.5%) = −0.15(0.45−0.525)×(−0.15) = 0.011
> 15 triệu1501,30015%32.5%ln(15%/32.5%) = −0.77(0.15−0.325)×(−0.77) = 0.135
Tổng IV0.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.02Biến mờ nhạt, ít giá trịLoại bỏ
0.02 – 0.10Yếu — có thể đưa vào poolXem xét kỹ
0.10 – 0.30Trung bình — có triển vọngKiểm tra monotonicity và stability
0.30 – 0.50Mạnh — candidate tốtKiểm tra leakage
> 0.50Rất mạnh — đáng nghiKiể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).

python
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

python
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:

BinAvg IncomeWOEInterpretation
1< 3M-1.82Very high risk
23-5M-0.94High risk
35-8M-0.21Moderate risk
48-12M+0.43Low risk
5> 12M+1.15Very 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

python
# ĐỪ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,  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):

python
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:

PatternVí dụTại sao sai
Post-outcome featuredays_overdue_at_observationChỉ có sau khi nợ xấu xảy ra
Future featurebalance_next_monthDùng thông tin tương lai để predict quá khứ
Proxy featurecollection_flagTrực tiếp reflect outcome
python
# 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.


Bài liên quan / Related posts