All Projects

Home Credit 信用违约预测

基于Kaggle Home Credit Default Risk竞赛数据,进行探索性数据分析(EDA)与特征工程,构建信用风险评估模型。

pythonpandassklearn信用风险EDA特征工程

项目概述

本项目基于 Kaggle Home Credit Default Risk 竞赛数据集,对贷款申请人的信用违约风险进行预测分析。项目涵盖了数据探索、特征工程和模型构建的完整流程。

数据说明

数据集包含多张关联表格:

表名说明
application_train主表,贷款申请记录及TARGET标签
bureau征信局历史信用记录
previous_application历史贷款申请记录
credit_card_balance信用卡余额月度快照
installments_payments分期付款还款记录
POS_CASH_balancePOS消费贷款月度快照

主要工作

1. 探索性数据分析(EDA)

  • 数据规模与变量类型概览
  • 违约比例分析(TARGET分布)
  • 关键风控变量分布特征
  • 箱线图与缺失值分析

2. 特征工程

  • 缺失值处理策略
  • 类别变量编码
  • 异常值检测与处理
  • 多表关联特征构建

3. 建模思路

  • 信用风控核心指标选取
  • 收入(AMT_INCOME_TOTAL)、贷款金额(AMT_CREDIT)、每月还款(AMT_ANNUITY)等关键特征分析
  • 后续计划:LightGBM / XGBoost 模型构建与评估

技术栈

  • 语言: Python 3
  • 数据处理: Pandas, NumPy
  • 可视化: Matplotlib, Seaborn
  • 建模: Scikit-learn(规划中)

Code Snippets

main.pypython
import pandas as pd

# 读取数据
df = pd.read_csv("data/application_train.csv")

# 查看前5行
print("=" * 50)
print("前5行数据")
print("=" * 50)
print(df.head())

# 数据规模
print("=" * 50)
print("数据规模")
print("=" * 50)
print(df.shape)
01_EDA.pypython
# # Home Credit Loan Risk Prediction
# ## 01 - Exploratory Data Analysis (EDA)
# 作者:赵心悦
# 日期:2026-07-02
# %% Cell 1
#采用pandas库——处理表格数据
import pandas as pd
df = pd.read_csv("data/application_train.csv")

# %% Cell 2
#查看前五行
df.head()

# %% Cell 3
#查看数据大小
df.shape

# %% Cell 4
#查看变量类型
df.info()

# %% Cell 5
#有多少比例的人违约
#value_counts():统计每个类别出现了多少次
#normalize=Tru:把“数量”变成“比例(百分比)”
df["TARGET"].value_counts(normalize=True)

# %% Cell 6
#4. 基础EDA分析
#导入基础库
#seaborn = 更高级、更好看的统计图工具
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

# %% Cell 7
#4.1 查看违约比例与缺失值情况
#看违约比例——看风险大不大
df['TARGET'].value_counts()
df['TARGET'].mean() #因为是01变量,所以均值就是占比

#看缺失值情况(快速EDA)
#df.isnull():查看缺失值——得到的结果:01
#df.isnull().mean():计算缺失值的均值,因为缺失值判断得到的结果是01,1:缺失,所以均值就等于缺失的占比
#sort_values(ascending=False):按“缺失率(False)从高到低排序”
missing = df.isnull().mean().sort_values(ascending=False)
print(missing.head(20))

#	这些都是房产、居住环境类特征(公共区域面积、建筑年限等),这些变量的缺失率非常高,在风控里意味着:数据采集不完整、这些变量的信息不稳定、不能直接丢进模型;

# %% Cell 8
#4.2 选择关键变量(风控核心变量)
#先使用经典信用风控变量-生成列表
#AMT_INCOME_TOTAL:收入
#AMT_CREDIT:贷款金额
#AMT_ANNUITY:每月还款
#DAYS_BIRTH:年龄(负数表示)
#DAYS_EMPLOYED:工作天数(负数)
#负数(表示“往过去数多少天”)
features = [
    'AMT_INCOME_TOTAL',
    'AMT_CREDIT',
    'AMT_ANNUITY',
    'DAYS_BIRTH',
    'DAYS_EMPLOYED'
]
#4.3 变量vs违约
#方法1:均值对比
for col in features:
    print('\n',col)
    
    #输出:df数据的col特征按照target分组之后再求均值的结果,得到的是分组后col的均值
    print(df.groupby('TARGET')[col].mean())

#结果显示:工作时间竟然大于年龄,而且为负值;原因在于:DAYS_EMOPLOYED中有一个特殊值:365243(无工作),所以数据才会如此奇怪。
#通过groupby均值分析发现,基础金融变量在违约与非违约人群之间差异较小,说明单变量区分能力有限,
#后续需要结合非线性模型或特征工程提升预测能力;
#同时工作年限变量存在异常值编码问题,需要进一步清洗处理。

# %% Cell 9
#方法2:箱线图
#plt.figure(figsize=(10,6)):设置画布大小;10 = 宽;6 = 高
plt.figure(figsize=(10,6))
sns.boxplot(x='TARGET',y='AMT_INCOME_TOTAL',data=df)
plt.title("Income vs Default")
plt.show

#结果显示存在异常值:1.2亿的收入,但也可能是富豪

# %% Cell 10
#4.4多变量快速对比(批量画图)
for col in features:
    plt.figure(figsize=(6,4))
    sns.boxplot(x='TARGET',y=col,data=df)
    plt.title(f"{col} vs TARGET")
    plt.show()

#结果显示:单个变量对违约(TARGET)的区分能力都不强,但存在一些结构性差异 + 数据异常问题,所以需要数据清洗+变换


# %% Cell 11
#4.5简单相关性
corr = df[features+['TARGET']].corr()
print(corr['TARGET'].sort_values())
#负的:一个越大另一个越小

# %% Cell 12
# 4.6 全部数值变量与TARGET的相关性排序(补充:不能只靠业务直觉选变量,要看数据本身)
numeric_cols = df.select_dtypes(include=['float64', 'int64']).columns
corr_all = df[numeric_cols].corr()['TARGET'].sort_values()

print("与TARGET相关性最强的10个变量(负相关):")
print(corr_all.head(10))
print("\n与TARGET相关性最强的10个变量(正相关):")
print(corr_all.tail(11))  # 包含TARGET自己,所以多取一个

# 结果预期:EXT_SOURCE_1/2/3应该会排在相关性最强的前几名,
# 远高于AMT_INCOME_TOTAL、AMT_CREDIT等最初凭业务直觉选的变量

# %% Cell 13
# 4.7 EXT_SOURCE_2 / EXT_SOURCE_3 专项分析
ext_features = ['EXT_SOURCE_2', 'EXT_SOURCE_3']

# 缺失率
print("缺失率:")
print(df[ext_features].isnull().mean())

# 均值对比:违约 vs 非违约,这两组分数是否有明显差异
print("\n分组均值对比:")
for col in ext_features:
    print(f"\n{col}")
    print(df.groupby('TARGET')[col].mean())

# %% Cell 14
# 4.8 箱线图:直观看这两个变量在违约/非违约客户之间的分布差异
for col in ['EXT_SOURCE_2', 'EXT_SOURCE_3']:
    plt.figure(figsize=(6, 4))
    sns.boxplot(x='TARGET', y=col, data=df)
    plt.title(f"{col} vs TARGET")
    plt.show()

# 预期结果:违约客户(TARGET=1)的EXT_SOURCE分数中位数会明显更低,
# 两组箱体的重叠程度比之前AMT_INCOME_TOTAL等变量要小得多,
# 这说明EXT_SOURCE的区分能力确实更强,跟决策树算出的特征重要性能对上

# ## Home Credit Loan Risk Prediction
# ## 02 - 特征工程
# #### 修复异常值、构造新变量、统一尺度、清理不可用字段等
# 作者:赵心悦
# 日期:2026-07-05
# #### 调整说明原来的写法是:先在**全部数据**上处理异常值、填补缺失值、构造新特征,最后才划分训练/测试集。问题:填补缺失值用的中位数/众数,如果是在切分之前用全部数据算出来的,测试集的数值就已经"参与"了训练过程要用的统计量,这叫**数据泄漏(data leakage)**,会让模型在测试集上的表现看起来比实际部署时更好。调整后的顺序:1. 先做**跟统计量无关**的修复(异常值编码修正、生成年龄/工作年限、衍生比率特征、log变换、类别变量编码)——这些是按行计算的确定性规则,不依赖整体数据分布,可以放在切分前2. 检查并删除**缺失率过高**的变量(这些变量本身可信度低,填充意义不大)3. **划分训练集/测试集**(分层抽样,保证两边违约比例一致)4. 用**训练集**算出的中位数/众数去填补缺失值,同样的规则应用到测试集(不能用测试集自己的统计量)
# %% Cell 17
# 1. 修复已知的异常值编码问题
df = df.copy()

# DAYS_EMPLOYED里的365243是异常编码(代表“无工作”),换成缺失值,后面再统一填补
df['DAYS_EMPLOYED'] = df['DAYS_EMPLOYED'].replace(365243, np.nan)

# 年龄(DAYS_BIRTH是负数,表示距今多少天,除以365转换成岁数)
df['AGE'] = -df['DAYS_BIRTH'] / 365

# 工作年限(同样是负数转正)
df['EMPLOYMENT_YEARS'] = -df['DAYS_EMPLOYED'] / 365

# %% Cell 18
# 2. 检查缺失率,删除缺失率过高的变量
# 缺失率超过50%的变量,填补的意义不大(相当于一半以上是“编造”出来的数据),因此可以直接删掉
missing_ratio = df.isnull().mean().sort_values(ascending=False)
high_missing_cols = missing_ratio[missing_ratio > 0.5].index.tolist()
print(f"缺失率超过50%的变量共 {len(high_missing_cols)} 个,将被删除:")
print(high_missing_cols)
df = df.drop(columns=high_missing_cols)
print("\n删除后数据形状:", df.shape)

# %% Cell 19
# 3. 衍生比率特征(按行计算,不依赖整体统计量,切分前后做没有区别)

# 贷款压力:贷款金额相对收入的倍数
df['CREDIT_INCOME_RATIO'] = df['AMT_CREDIT'] / (df['AMT_INCOME_TOTAL'] + 1)

# 月供压力:月供占收入的比例
df['ANNUITY_INCOME_RATIO'] = df['AMT_ANNUITY'] / (df['AMT_INCOME_TOTAL'] + 1)

# 贷款期限的粗略估计:贷款总额 / 月供
df['CREDIT_ANNUITY_RATIO'] = df['AMT_CREDIT'] / (df['AMT_ANNUITY'] + 1)

# # 检验:这几个新构造的比率特征,跟TARGET的相关性有没有比原始变量强
# new_features = ['CREDIT_INCOME_RATIO', 'ANNUITY_INCOME_RATIO', 'CREDIT_ANNUITY_RATIO']
# print(df[new_features + ['TARGET']].corr()['TARGET'].sort_values())
# %% Cell 21
# 4. log变换:金额类变量通常右偏(少数人金额极高),取log让分布更接近正态,模型更容易学习
df['AMT_CREDIT_LOG'] = np.log1p(df['AMT_CREDIT'])
df['AMT_INCOME_LOG'] = np.log1p(df['AMT_INCOME_TOTAL'])
df['AMT_ANNUITY_LOG'] = np.log1p(df['AMT_ANNUITY'])

# %% Cell 22
# 5. 类别变量编码(独热编码)
# 说明:get_dummies是按行把“文字类别”转成0/1列,不涉及计算训练集的均值/中位数这类统计量,
# 所以放在切分前做,风险比数值型缺失值填补小得多,是业界普遍接受的做法
cat_cols = df.select_dtypes(include='object').columns
    
df = pd.get_dummies(df, columns=cat_cols, drop_first=True)
print("编码后特征数:", df.shape[1])

# #### 划分训练集/测试集到这一步为止的处理(异常值修正、删除高缺失率列、衍生特征、log变换、类别编码)都不依赖整体数据的统计量,所以放在切分前是安全的。接下来的**缺失值填补**需要用到中位数/众数,这类统计量必须只从训练集计算,再应用到测试集,所以先在这里切分。
# %% Cell 24
from sklearn.model_selection import train_test_split
X = df.drop('TARGET', axis=1)
y = df['TARGET']
# stratify=y:保证训练集和测试集里违约(TARGET=1)的比例一致,
# 不然万一切分时违约样本本来就少,随机切分可能导致某一边违约比例明显偏离整体水平
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42,stratify=y)
print("训练集形状:", X_train.shape)
print("测试集形状:", X_test.shape)
print("训练集违约比例:", y_train.mean())
print("测试集违约比例:", y_test.mean())

# %% Cell 25
# 6. 缺失值填补:只用训练集计算中位数/众数,再应用到训练集和测试集# 这样测试集的数值完全没有参与统计量的计算,避免数据泄漏
for col in X_train.columns:    
    if X_train[col].dtype in ['float64', 'int64']:        
        fill_value = X_train[col].median()    
    else:        
        fill_value = X_train[col].mode()[0]    
        
    X_train[col] = X_train[col].fillna(fill_value)    
    X_test[col] = X_test[col].fillna(fill_value)
print("训练集剩余缺失值:", X_train.isnull().sum().sum())
print("测试集剩余缺失值:", X_test.isnull().sum().sum())

# %% Cell 26
# 7. 最终检查
print("最终训练集特征数:", X_train.shape[1])
print("训练集形状:", X_train.shape)
print("测试集形状:", X_test.shape)

X_train.to_csv('data/X_train.csv', index=False)
X_test.to_csv('data/X_test.csv', index=False)
y_train.to_csv('data/y_train.csv', index=False)
y_test.to_csv('data/y_test.csv', index=False)

# # 训练第一个模型(逻辑回归)
# %% Cell 28
#1.标准化数值特征
#逻辑回归对特征尺度敏感(不像树模型),必须做标准化,且只能用训练集fit:
from sklearn.preprocessing import StandardScaler

scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)   # 训练集:fit + transform
X_test_scaled = scaler.transform(X_test)          # 测试集:只transform,不能fit

# %% Cell 29
#2. 训练模型(用class_weight='balanced'处理不平衡问题)
from sklearn.linear_model import LogisticRegression

model = LogisticRegression(class_weight='balanced', max_iter=1000, random_state=42)
model.fit(X_train_scaled, y_train)

# %% Cell 30
#3. 预测 + 算五个指标
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, roc_auc_score

y_pred = model.predict(X_test_scaled)              # 预测的类别(0/1)
y_pred_proba = model.predict_proba(X_test_scaled)[:, 1]  # 预测为违约的概率,AUC需要用这个

print("Accuracy:", accuracy_score(y_test, y_pred))
print("Precision:", precision_score(y_test, y_pred))
print("Recall:", recall_score(y_test, y_pred))
print("F1:", f1_score(y_test, y_pred))
print("AUC:", roc_auc_score(y_test, y_pred_proba))

# # 训练第二个模型:决策树
# 跟逻辑回归比,有几个关键区别要注意:
# 不需要标准化:决策树是按"某个特征是否大于某个阈值"来分裂节点的,不受量纲影响,直接用原始的X_train、X_test就行,不用X_train_scaled
# 同样要处理类别不平衡:用class_weight='balanced'
# 需要限制树的深度:决策树如果不限制,容易长得很深、完全记住训练集(过拟合),导致训练集表现很好、测试集表现很差,所以要设置max_depth
# %% Cell 32
# 训练第二个模型:决策树
from sklearn.tree import DecisionTreeClassifier

# max_depth:限制树的深度,防止过拟合(不限制的话,树会长到把训练集背下来)
# 具体设多少合适,可以先设一个值跑跑看,后面再调整对比
tree_model = DecisionTreeClassifier(
    class_weight='balanced',
    max_depth=6,
    random_state=42
)

tree_model.fit(X_train, y_train)   # 注意:这里用原始X_train,不用scaled版本

# %% Cell 33
# 预测 + 算五个指标
y_pred_tree = tree_model.predict(X_test)
y_pred_proba_tree = tree_model.predict_proba(X_test)[:, 1]

print("Accuracy:", accuracy_score(y_test, y_pred_tree))
print("Precision:", precision_score(y_test, y_pred_tree))
print("Recall:", recall_score(y_test, y_pred_tree))
print("F1:", f1_score(y_test, y_pred_tree))
print("AUC:", roc_auc_score(y_test, y_pred_proba_tree))

# %% Cell 34
import pandas as pd

importance = pd.Series(tree_model.feature_importances_, index=X_train.columns)
print(importance.sort_values(ascending=False).head(15))

# # 训练第三个模型:XGBoost
# XGBoost是集成树模型,比单棵决策树更强,几个关键点:
# 同样不需要标准化,用原始的X_train、X_test
# 处理类别不平衡:XGBoost不用class_weight,而是用scale_pos_weight参数,计算方法是"负样本数/正样本数"
# 需要先安装/导入xgboost库
# %% Cell 36


# %% Cell 37
from xgboost import XGBClassifier

# scale_pos_weight:负样本(不违约)数量 / 正样本(违约)数量,让模型更重视违约这个少数类
scale_pos_weight = (y_train == 0).sum() / (y_train == 1).sum()
print("scale_pos_weight:", scale_pos_weight)

xgb_model = XGBClassifier(
    scale_pos_weight=scale_pos_weight,
    max_depth=6,
    n_estimators=200,       # 树的数量
    learning_rate=0.05,     # 学习率,越小越稳但需要更多树
    random_state=42,
    eval_metric='auc'       # 训练过程中监控AUC
)

xgb_model.fit(X_train, y_train)

# %% Cell 38
# 预测 + 算五个指标
y_pred_xgb = xgb_model.predict(X_test)
y_pred_proba_xgb = xgb_model.predict_proba(X_test)[:, 1]

print("Accuracy:", accuracy_score(y_test, y_pred_xgb))
print("Precision:", precision_score(y_test, y_pred_xgb))
print("Recall:", recall_score(y_test, y_pred_xgb))
print("F1:", f1_score(y_test, y_pred_xgb))
print("AUC:", roc_auc_score(y_test, y_pred_proba_xgb))

# %% Cell 39
# 特征重要性(跟决策树对比,看结论是否一致)
importance_xgb = pd.Series(xgb_model.feature_importances_, index=X_train.columns)
print(importance_xgb.sort_values(ascending=False).head(15))

# # 训练第四个模型:神经网络
# %% Cell 41
from sklearn.neural_network import MLPClassifier

# hidden_layer_sizes=(64, 32):两层隐藏层,第一层64个神经元,第二层32个
# max_iter:最大训练轮数,神经网络通常需要更多轮才能收敛
nn_model = MLPClassifier(
    hidden_layer_sizes=(64, 32),
    max_iter=300,
    random_state=42,
    early_stopping=True  # 如果验证集效果不再提升,提前停止,防止过拟合
)

nn_model.fit(X_train_scaled, y_train)   # 注意用scaled版本

# %% Cell 42
# 预测 + 算五个指标
y_pred_nn = nn_model.predict(X_test_scaled)
y_pred_proba_nn = nn_model.predict_proba(X_test_scaled)[:, 1]

print("Accuracy:", accuracy_score(y_test, y_pred_nn))
print("Precision:", precision_score(y_test, y_pred_nn))
print("Recall:", recall_score(y_test, y_pred_nn))
print("F1:", f1_score(y_test, y_pred_nn))
print("AUC:", roc_auc_score(y_test, y_pred_proba_nn))

# MLPClassifier没有class_weight参数,没有任何机制告诉它"违约样本很少但很重要,要多关注",模型在训练时发现"预测全部为0"就已经能让损失函数很小了(因为92%的人确实是0),于是它就真的这么做了。这不是代码写错了,是MLPClassifier本身的局限性,加上不平衡数据共同作用的结果。
# 怎么解决——用过采样,不用装新库
# MLPClassifier不支持class_weight,但我们可以换个思路:在训练前,把训练集里violate的少数类样本"复制"多几份,让神经网络在训练时"看到"更多违约样本,不需要安装新的库(用sklearn自带的resample就行,不用再折腾一次Homebrew那种环境配置):
# %% Cell 44
from sklearn.utils import resample
import numpy as np

# 把训练集拆成"违约"和"不违约"两部分
X_train_scaled_df = pd.DataFrame(X_train_scaled)  # 转成DataFrame方便操作
majority = X_train_scaled_df[y_train.values == 0]
minority = X_train_scaled_df[y_train.values == 1]

# 把少数类(违约)过采样到和多数类一样多
minority_upsampled = resample(
    minority,
    replace=True,        # 允许重复抽样
    n_samples=len(majority),  # 抽到跟多数类一样多
    random_state=42
)

# 合并成新的平衡训练集
X_train_balanced = pd.concat([majority, minority_upsampled])
y_train_balanced = pd.concat([
    pd.Series([0]*len(majority)),
    pd.Series([1]*len(minority_upsampled))
])

print("平衡后训练集违约比例:", y_train_balanced.mean())  # 应该接近0.5

# %% Cell 45
# 用平衡后的数据重新训练神经网络
nn_model_balanced = MLPClassifier(
    hidden_layer_sizes=(64, 32),
    max_iter=300,
    random_state=42,
    early_stopping=True
)

nn_model_balanced.fit(X_train_balanced, y_train_balanced)

# 注意:测试集不用动,还是用原始的X_test_scaled、y_test,
# 因为测试集要反映真实世界的分布,只有训练集需要平衡
y_pred_nn2 = nn_model_balanced.predict(X_test_scaled)
y_pred_proba_nn2 = nn_model_balanced.predict_proba(X_test_scaled)[:, 1]

print("Accuracy:", accuracy_score(y_test, y_pred_nn2))
print("Precision:", precision_score(y_test, y_pred_nn2))
print("Recall:", recall_score(y_test, y_pred_nn2))
print("F1:", f1_score(y_test, y_pred_nn2))
print("AUC:", roc_auc_score(y_test, y_pred_proba_nn2))

# %% Cell 46