本文为 Python 60 Days Challenge 课程学习整理,原作者与原 Notebook 链接见「课程资料」。代码按章节顺序运行;语法示意和故意报错的反例已单独标注。

数据准备: 将课程仓库中的 data.csv/data.xlsx 放在 Notebook 的当前工作目录,路径已移除原作者电脑的盘符。读取 Excel 需要 openpyxl。各节额外依赖按文中安装说明准备。

文中保留的截图、性能表和数值属于原讲义的历史示例,不是本次修订后的重新训练结果。实际输出受数据、版本、随机种子和训练预算影响。

本次校订: 预处理顺序已改为先拆分、再在训练集拟合填补和收入截断边界;测试行不会因全数据 IQR 规则而被删除。固定映射用于教学,标称变量更适合独热编码。不同模型的测试报告仅作预先设定的教学比较,实际选型需验证集或交叉验证,测试集留到最终一次评估。

打卡任务

  • 阅读本节讲义或 Notebook
  • 跑通示例代码
  • 整理 3 个关键点
  • 完成当日打卡输出

课程资料

学习内容

  1. 把之前所有的处理手段都处理一遍,回顾一下全流程,以后就用处理好的部分直接完成
  2. 开始机器学习建模(简单建模,不涉及调参)和评估

一、数据预处理

1.1 导入所需要的包

这里其实是写完后一起整理到这里的

import pandas as pd    #用于数据处理和分析,可处理表格数据。
import numpy as np     #用于数值计算,提供了高效的数组操作。
import matplotlib.pyplot as plt    #用于绘制各种类型的图表
import seaborn as sns   #基于matplotlib的高级绘图库,能绘制更美观的统计图形。
import warnings
# 保留警告,便于发现弃用接口和数值问题。
 
 # 设置中文字体(解决中文显示问题)
plt.rcParams['font.sans-serif'] = ['SimHei']  # Windows系统常用黑体字体
plt.rcParams['axes.unicode_minus'] = False    # 正常显示负号

1.2 查看数据信息

data = pd.read_csv('data.csv')    #读取数据
data.head()

1.3 特征名映射

# 创建特征名中文映射字典
feature_name_mapping = {
    'Annual Income': '年收入',
    'Years in current job': '当前工作年限',
    'Tax Liens': '税收留置权',
    'Number of Open Accounts': '开放账户数量',
    'Years of Credit History': '信用历史年限',
    'Maximum Open Credit': '最大开放信用额度',
    'Number of Credit Problems': '信用问题数量',
    'Months since last delinquent': '距上次拖欠月数',
    'Bankruptcies': '破产次数',
    'Current Loan Amount': '当前贷款金额',
    'Current Credit Balance': '当前信用余额',
    'Monthly Debt': '月债务',
    'Credit Score': '信用评分',
    'Home Ownership': '房屋所有权',
    'Term': '贷款期限',
    'Purpose': '贷款目的',
    'Credit Default': '信用违约'
}

# 重命名数据框的列名
data = data.rename(columns=feature_name_mapping)
data.head()

1.4 删除无用列

# 删除无用列,如ID列
data = data.drop(columns=['Id'])
data.head()

1.5 编码映射

先打印看一下可能有问题的几个特征的分布,分别是房屋所有权、当前工作年限、贷款目的、贷款期限

这里我们没有再用根据变量类型来筛选的方法了,因为那样很粗糙,我们实际上肯定要理解数据的

# 先打印看一下可能有问题的几个特征的分布,分别是房屋所有权、当前工作年限、贷款目的、贷款期限
print(data['房屋所有权'].value_counts())
# 对房屋所有权进行映射
mappings = {
    "房屋所有权": {
        "Rent": 0, # 租房
        "Have Mortgage": 1, # 有房贷
        "Home Mortgage": 1, # 有房贷
        "Own Home": 2, # 自有住房
    }
}
data = data.replace(mappings)
data['房屋所有权'].value_counts()
print(data['当前工作年限'].value_counts())
# 对当前工作年限进行映射
work_year_mappings = {
    "当前工作年限": {
        "< 1 year": 0,
        "1 year": 1,
        "2 years": 2,
        "3 years": 3,
        "4 years": 4,
        "5 years": 5,
        "6 years": 6,
        "7 years": 7,
        "8 years": 8,
        "9 years": 9,
        "10+ years": 10
    }
}
data = data.replace(work_year_mappings)
data['当前工作年限'].value_counts()
print(data['贷款目的'].value_counts())

贷款目的是一个需要独热编码的特征,这里为了方便,我们还是沿用便签编码了-----法无定则

注意我们之前说了,如果独热编码的话,就需要先填补缺失值,再独热编码比较合适

# 根据 value_counts() 频率顺序定义映射字典
purpose_ordinal_map = {
    "debt consolidation": 0,
    "other": 1,
    "home improvements": 2,
    "business loan": 3,
    "buy a car": 4,
    "medical bills": 5,
    "major purchase": 6,
    "take a trip": 7,
    "buy house": 8,
    "small business": 9,
    "wedding": 10,
    "moving": 11,
    "educational expenses": 12,
    "vacation": 13,
    "renewable energy": 14
}

# 直接替换原列
data['贷款目的'] = data['贷款目的'].replace(purpose_ordinal_map)

print(data['贷款目的'].value_counts())
print(data['贷款期限'].value_counts())
# 对贷款期限进行映射
term_mapping = {
        "Short Term": 0,
        "Long Term": 1
}
data['贷款期限'] = data['贷款期限'].replace(term_mapping)
data['贷款期限'].value_counts()
data.head()

现在已经没有非数值类型的特征了

data.info()

1.6 填补缺失值

print(data.isna().sum())
# 此处只检查缺失值;填补器在后面的训练集划分之后拟合。

1.7 异常值处理

  1. 我们之前没说异常值的处理,实际上是可处理可不处理的,因为这需要考验你对于实际数据的理解,而且不处理也会增强泛化性和鲁棒性,因为实际中很多收上来的数据本身就有问题
  2. 异常值一般不处理,或者结合对照试验处理和不处理都尝试下,但是论文中要写这个,作为工作量
data.head()

这里我们选择年收入来处理吧,我们选择四分位数范围 (IQR),他是基于箱线图原理来定义异常值边界

image.png

# 绘制年收入的箱线图
plt.figure(figsize=(8, 6))
sns.boxplot(x=data['年收入'])
plt.title('年收入箱线图')
plt.xlabel('年收入')
plt.show()
print(data['年收入'].describe())
# 不按全体数据的分位数删除测试样本;后面仅用训练集估计截断边界。
# 绘制年收入的箱线图
plt.figure(figsize=(8, 6))
sns.boxplot(x=data['年收入'])
plt.title('年收入箱线图')
plt.xlabel('年收入')
plt.show()

为什么删除了异常值后箱线图上还是有点?
这是因为箱线图是一个“递归”的概念,每次您移除异常值并重新绘制时,箱体和边界都会重新计算。

  1. 每次对新数据 q1和q3都会重新计算,所以上下限会重新计算
  2. 但是即使如此,新的箱线图会分布更加集中

解决这个问题直接设置好固定上限即可

data.info()

可以看到,删除异常值是删除了所有的样本,也可以只删除这个特征值而不删除整个样本,再用缺失值填补即可。

1.8 可视化分析

这部分不在赘述,3类图,自行选择

1.9 其他核心部分

特征工程

  • 衍生新特征:根据已有特征创建新的特征,可能会对模型性能有提升。例如,可以计算“Debt - to - Income Ratio”(负债收入比),即“Monthly Debt”与“Annual Income”的比值,来反映客户的债务负担情况。
  • 特征选择:通过相关性分析等方法,选择与目标变量“Credit Default”相关性较高的特征,去除相关性较低或冗余的特征,以降低模型的复杂度和过拟合的风险。

此外,数据不平衡输出我们后面再说

此时你可能会好奇,怎么还没有归一化/标准化?这就需要我们引入数据泄露的观点了

我们之所以推迟归一化或标准化步骤,正是为了避免关键的训练集/测试集数据泄露问题。一旦在划分数据集之前对全集应用此类预处理,训练过程就间接利用了测试集的均值和标准差等统计信息,这会导致对模型在未知数据上性能的乐观估计。课上反复提及的核心:

考试理论:

  1. 不要提前知道考试题。
  2. 调参就需要考2次

二、机器学习模型建模

2.1 数据划分

data.columns
from sklearn.model_selection import train_test_split
from sklearn.impute import SimpleImputer
X = data.drop(columns=['信用违约'])
y = data['信用违约']
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y)
imputer = SimpleImputer(strategy='median', keep_empty_features=True)
X_train = pd.DataFrame(imputer.fit_transform(X_train), columns=X.columns, index=X_train.index)
X_test = pd.DataFrame(imputer.transform(X_test), columns=X.columns, index=X_test.index)
q1, q3 = X_train['年收入'].quantile([0.25, 0.75])
lower, upper = q1 - 1.5 * (q3 - q1), q3 + 1.5 * (q3 - q1)
X_train['年收入'] = X_train['年收入'].clip(lower, upper)
X_test['年收入'] = X_test['年收入'].clip(lower, upper)
print(X_train.shape, X_test.shape)

2.2 数据归一化

只需要对连续特征归一化即可,离散特征编码后虽然是数值,但是不用动


from sklearn.model_selection import train_test_split 
from sklearn.preprocessing import StandardScaler, MinMaxScaler

# ----------------------------------------------------------------------
# 定义连续特征列 (需要归一化的特征)
# ----------------------------------------------------------------------
continuous_features = [
    '年收入', 
    '当前工作年限', 
    '开放账户数量', # 虽为计数,但一般也进行缩放
    '信用历史年限', 
    '最大开放信用额度', 
    '距上次拖欠月数',
    '当前贷款金额', 
    '当前信用余额', 
    '月债务', 
    '信用评分'
]

# 初始化归一化器 (MinMaxScaler)
scaler = MinMaxScaler() 


# 仅在训练集上 fit (学习最大值和最小值)
# 然后对训练集进行 transform (应用缩放)
# 注意:Scikit-learn 返回 NumPy 数组,需要重新赋值给 DataFrame
X_train[continuous_features] = scaler.fit_transform(X_train[continuous_features])

# 使用训练集学到的参数 (scaler) 直接对测试集进行 transform
# 绝对不能对测试集使用 fit_transform()
X_test[continuous_features] = scaler.transform(X_test[continuous_features])
X_test

2.3 模型训练与评估

三行经典代码

  1. 模型实例化
  2. 模型训练(代入训练集)
  3. 模型预测 (代入测试集)

测试集的预测值和测试集的真实值进行对比,得到混淆矩阵

  • 基于混淆矩阵,计算准确率、召回率、F1值,这些都是固定阈值的评估指标
  • AUC是基于不同阈值得到不同的混淆矩阵,然后计算每个阈值对应FPR和TPR,讲这些点连成线,最后求曲线下的面积,得到AUC值
# #安装xgboost库
# !pip install xgboost -i https://pypi.tuna.tsinghua.edu.cn/simple/ 
# #安装lightgbm库 
# !pip install lightgbm  -i https://pypi.tuna.tsinghua.edu.cn/simple/ 
# #安装catboost库
# !pip install catboost -i https://pypi.tuna.tsinghua.edu.cn/simple/
from sklearn.svm import SVC #支持向量机分类器
from sklearn.neighbors import KNeighborsClassifier #K近邻分类器
from sklearn.linear_model import LogisticRegression #逻辑回归分类器
import xgboost as xgb #XGBoost分类器
import lightgbm as lgb #LightGBM分类器
from sklearn.ensemble import RandomForestClassifier #随机森林分类器
from catboost import CatBoostClassifier #CatBoost分类器
from sklearn.tree import DecisionTreeClassifier #决策树分类器
from sklearn.naive_bayes import GaussianNB #高斯朴素贝叶斯分类器
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score # 用于评估分类器性能的指标
from sklearn.metrics import classification_report, confusion_matrix #用于生成分类报告和混淆矩阵
import warnings #用于忽略警告信息
# 保留警告,便于发现弃用接口和数值问题。 # 忽略所有警告信息
# SVM
svm_model = SVC(random_state=42)
svm_model.fit(X_train, y_train)
svm_pred = svm_model.predict(X_test)

print("\nSVM 分类报告:")
print(classification_report(y_test, svm_pred))  # 打印分类报告
print("SVM 混淆矩阵:")
print(confusion_matrix(y_test, svm_pred))  # 打印混淆矩阵

# 计算 SVM 评估指标,这些指标默认计算正类的性能
svm_accuracy = accuracy_score(y_test, svm_pred)
svm_precision = precision_score(y_test, svm_pred)
svm_recall = recall_score(y_test, svm_pred)
svm_f1 = f1_score(y_test, svm_pred)
print("SVM 模型评估指标:")
print(f"准确率: {svm_accuracy:.4f}")
print(f"精确率: {svm_precision:.4f}")
print(f"召回率: {svm_recall:.4f}")
print(f"F1 值: {svm_f1:.4f}")

classification_report它会生成所有类别的指标

准确率(Accuracy)是一个全局指标,衡量所有类别预测正确的比例 (TP + TN) / (TP + TN + FP + FN)。它不区分正负类,所以它只有一个值,不区分类别

单独调用的 precision_score, recall_score, f1_score 在二分类中默认只计算正类(标签 1)的性能。由于模型从未成功预测出类别 1(TP=0),所以这些指标对类别 1 来说都是 0。

# KNN
knn_model = KNeighborsClassifier()
knn_model.fit(X_train, y_train)
knn_pred = knn_model.predict(X_test)

print("\nKNN 分类报告:")
print(classification_report(y_test, knn_pred))
print("KNN 混淆矩阵:")
print(confusion_matrix(y_test, knn_pred))

knn_accuracy = accuracy_score(y_test, knn_pred)
knn_precision = precision_score(y_test, knn_pred)
knn_recall = recall_score(y_test, knn_pred)
knn_f1 = f1_score(y_test, knn_pred)
print("KNN 模型评估指标:")
print(f"准确率: {knn_accuracy:.4f}")
print(f"精确率: {knn_precision:.4f}")
print(f"召回率: {knn_recall:.4f}")
print(f"F1 值: {knn_f1:.4f}")
# 逻辑回归
logreg_model = LogisticRegression(random_state=42)
logreg_model.fit(X_train, y_train)
logreg_pred = logreg_model.predict(X_test)

print("\n逻辑回归 分类报告:")
print(classification_report(y_test, logreg_pred))
print("逻辑回归 混淆矩阵:")
print(confusion_matrix(y_test, logreg_pred))

logreg_accuracy = accuracy_score(y_test, logreg_pred)
logreg_precision = precision_score(y_test, logreg_pred)
logreg_recall = recall_score(y_test, logreg_pred)
logreg_f1 = f1_score(y_test, logreg_pred)
print("逻辑回归 模型评估指标:")
print(f"准确率: {logreg_accuracy:.4f}")
print(f"精确率: {logreg_precision:.4f}")
print(f"召回率: {logreg_recall:.4f}")
print(f"F1 值: {logreg_f1:.4f}")

我们来解读一下这个输出的表格,看看能看出来哪些信息?

  1. Precision (精确率) 在所有模型预测为该类别的样本中,真正属于该类别的比例。
  2. Recall (召回率) 在所有真正属于该类别的样本中,被模型正确识别的比例。

因此,分类报告必须给出 $0$ 和 $1$ 的详细指标,以便了解模型在预测两种不同结果时的偏向和能力差异。

准确率 (Accuracy): 这是整体指标,计算的是 $(TP + TN) / Total$,与 $0$ 或 $1$ 无关,所以只有一个总值。

在二分类问题中,Scikit-learn 的评估函数(如 precision_score, recall_score, f1_score)在默认情况下,会将标签 $1$ 视为重点关注的正类来计算指标。

此外,support(样本数)显示,类别 $0$ 有 $997$ 个样本,而类别 $1$ 只有 $400$ 个样本。这是一个不平衡数据集。逻辑回归模型严重偏向于预测类别 $0$(未违约),模型在预测 $1$(违约)时很“谨慎”(高 Precision: $0.89$),但它错过了大量真正的违约者(低 Recall: $0.28$)。

  1. 精确率关注的是“误报”(False Positive)——即把非违约客户错判为违约客户的错误。预测为违约的客户中有 $89.43%$ 是正确的。误报率低。
  2. 召回率关注的是“漏报”(False Negative)——即把真正违约的客户错判为未违约客户的错误。模型只识别出了所有真正违约客户中的 $27.50%$。漏报率极高。
# 朴素贝叶斯
nb_model = GaussianNB()
nb_model.fit(X_train, y_train)
nb_pred = nb_model.predict(X_test)

print("\n朴素贝叶斯 分类报告:")
print(classification_report(y_test, nb_pred))
print("朴素贝叶斯 混淆矩阵:")
print(confusion_matrix(y_test, nb_pred))

nb_accuracy = accuracy_score(y_test, nb_pred)
nb_precision = precision_score(y_test, nb_pred)
nb_recall = recall_score(y_test, nb_pred)
nb_f1 = f1_score(y_test, nb_pred)
print("朴素贝叶斯 模型评估指标:")
print(f"准确率: {nb_accuracy:.4f}")
print(f"精确率: {nb_precision:.4f}")
print(f"召回率: {nb_recall:.4f}")
print(f"F1 值: {nb_f1:.4f}")
# 决策树
dt_model = DecisionTreeClassifier(random_state=42)
dt_model.fit(X_train, y_train)
dt_pred = dt_model.predict(X_test)

print("\n决策树 分类报告:")
print(classification_report(y_test, dt_pred))
print("决策树 混淆矩阵:")
print(confusion_matrix(y_test, dt_pred))

dt_accuracy = accuracy_score(y_test, dt_pred)
dt_precision = precision_score(y_test, dt_pred)
dt_recall = recall_score(y_test, dt_pred)
dt_f1 = f1_score(y_test, dt_pred)
print("决策树 模型评估指标:")
print(f"准确率: {dt_accuracy:.4f}")
print(f"精确率: {dt_precision:.4f}")
print(f"召回率: {dt_recall:.4f}")
print(f"F1 值: {dt_f1:.4f}")
# 随机森林
rf_model = RandomForestClassifier(random_state=42)
rf_model.fit(X_train, y_train)
rf_pred = rf_model.predict(X_test)

print("\n随机森林 分类报告:")
print(classification_report(y_test, rf_pred))
print("随机森林 混淆矩阵:")
print(confusion_matrix(y_test, rf_pred))

rf_accuracy = accuracy_score(y_test, rf_pred)
rf_precision = precision_score(y_test, rf_pred)
rf_recall = recall_score(y_test, rf_pred)
rf_f1 = f1_score(y_test, rf_pred)
print("随机森林 模型评估指标:")
print(f"准确率: {rf_accuracy:.4f}")
print(f"精确率: {rf_precision:.4f}")
print(f"召回率: {rf_recall:.4f}")
print(f"F1 值: {rf_f1:.4f}")
# XGBoost
xgb_model = xgb.XGBClassifier(random_state=42)
xgb_model.fit(X_train, y_train)
xgb_pred = xgb_model.predict(X_test)

print("\nXGBoost 分类报告:")
print(classification_report(y_test, xgb_pred))
print("XGBoost 混淆矩阵:")
print(confusion_matrix(y_test, xgb_pred))

xgb_accuracy = accuracy_score(y_test, xgb_pred)
xgb_precision = precision_score(y_test, xgb_pred)
xgb_recall = recall_score(y_test, xgb_pred)
xgb_f1 = f1_score(y_test, xgb_pred)
print("XGBoost 模型评估指标:")
print(f"准确率: {xgb_accuracy:.4f}")
print(f"精确率: {xgb_precision:.4f}")
print(f"召回率: {xgb_recall:.4f}")
print(f"F1 值: {xgb_f1:.4f}")
# LightGBM
lgb_model = lgb.LGBMClassifier(random_state=42)
lgb_model.fit(X_train, y_train)
lgb_pred = lgb_model.predict(X_test)

print("\nLightGBM 分类报告:")
print(classification_report(y_test, lgb_pred))
print("LightGBM 混淆矩阵:")
print(confusion_matrix(y_test, lgb_pred))

lgb_accuracy = accuracy_score(y_test, lgb_pred)
lgb_precision = precision_score(y_test, lgb_pred)
lgb_recall = recall_score(y_test, lgb_pred)
lgb_f1 = f1_score(y_test, lgb_pred)
print("LightGBM 模型评估指标:")
print(f"准确率: {lgb_accuracy:.4f}")
print(f"精确率: {lgb_precision:.4f}")
print(f"召回率: {lgb_recall:.4f}")
print(f"F1 值: {lgb_f1:.4f}")
模型名称 准确率 精确率(正类) 召回率(正类) F1值(正类) 精确率(负类) 召回率(负类) F1值(负类)
SVM 0.7060 0.0000 0.0000 0.0000 0.71 1.00 0.83
KNN 0.6753 0.4102 0.2381 0.3013 0.73 0.86 0.79
逻辑回归 0.7560 0.8571 0.2041 0.3297 0.75 0.99 0.85
朴素贝叶斯 0.4267 0.3377 0.9887 0.5035 0.98 0.19 0.32
决策树 0.6773 0.4564 0.5102 0.4818 0.79 0.75 0.77
随机森林 0.7700 0.7857 0.2993 0.4335 0.77 0.97 0.86
XGBoost 0.7473 0.6192 0.3651 0.4593 0.77 0.91 0.84
LightGBM 0.7660 0.7009 0.3560 0.4722 0.78 0.94 0.85

对于指标怎么看呢?

  1. 一般文章都是会选择一个作为主指标,比如F1分数,然后筛选出最好的模型
  2. 一般最好的指标对应的模型,其他的指标也都很好
  3. 数学建模中有评价任务,比如根据熵权法进行判断。