数据分析与挖掘实战

写在前面

好,话不多说,咱们直接开始。

数据分析和挖掘重点就是:数据探索(数据清洗+初步分析可视化)->特征工程->模型选择->调参优化这几个步骤,学完本节你能在心里建立一套流程,对每个步骤都有个大概了解,知道这步为什么这么做,以及如果不这么做会怎样。

上案例:信用卡违约风险预测

题目描述:给定数据集 credit.csv,包含某银行部分信用卡用户数据及违约情况。字段包括”income”、”age”、”gender”、”limit”、”default_fre”、”province”、”default”等。需建立违约风险模型,预测 results.csv 中新客户是否存在违约风险。

步骤一:数据探索

  • 读取 credit.csv,查看数据基本信息
  • 按照合理方法处理异常值
  • 填充表中空值
  • 性别用【0、1】替换,省份用 One-Hot 编码替换

步骤二:数据分析与挖掘

  • 画出反映不同特征条件下的违约情况
  • 按照 7:3 切分训练集、测试集
  • 建立用户违约风险模型

步骤三:调参与优化

  • 模型参数调优
  • 提交预测模型
  • 系统将执行模型,预测 results.csv 中的新用户风险,输出 F1 值并自动评分

我们按数据分析和挖掘的流程走:数据探索 → 特征工程 → 模型选择 → 调参优化


第一步:数据探索

1.1 读取数据——把数据先拿进来

1
2
3
import pandas as pd

df = pd.read_csv('credit.csv')

就这么简单,不过一般数据格式有很多种,比如 Excel、SQL数据库 等。你根据实际情况选择合适的读取姿势:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 读 Excel
df = pd.read_excel('credit.xlsx', sheet_name='Sheet1')

# 读 SQL 数据库
import sqlite3
conn = sqlite3.connect('bank.db')
df = pd.read_sql('SELECT * FROM credit', conn)

# 读 JSON 文件——API 返回的数据、NoSQL 导出的数据经常是这个格式
df = pd.read_json('credit.json') # 每行一个 JSON 对象(lines=False,默认)
df = pd.read_json('credit.json', lines=True) # JSON Lines 格式(每行独立 JSON,大数据常用)

# 读嵌套 JSON——如果 JSON 里有数组或嵌套对象,直接读会变成一列 object
# 需要用 json_normalize 把嵌套结构展平
import json
from pandas import json_normalize
with open('nested_credit.json', 'r') as f:
data = json.load(f) # 读成 Python dict/list
df = json_normalize(data) # 把嵌套字段展平成多列
# 比如 {"user": {"name": "张三", "age": 30}} → user.name, user.age 两列

# 大文件分块读取——里遇到几百万行的数据就用这招
chunk_iter = pd.read_csv('huge_credit.csv', chunksize=10000)
for chunk in chunk_iter:
# 每次只加载 10000 行到内存,逐块处理
process(chunk)

**read_excel**——它是 pandas 读 Excel 的主力函数,常用以下几个参数可增强读取功能:

1
2
3
4
5
6
7
8
9
10
11
# 完整一点的写法
df = pd.read_excel(
'credit.xlsx',
sheet_name='Sheet1', # 读哪个 sheet,传名字或序号(0 表示第一个);不传默认读第一个
header=0, # 哪一行作为列名,默认第 0 行
usecols='A:D', # 只读 A~D 列,省内存;也可传列表 [0, 2, 4]
nrows=1000, # 只读前 1000 行,先看一眼数据长啥样再决定要不要全读
dtype={'idcard': str}, # 指定列类型,避免 pandas 把身份证号读成科学计数法
na_values=['NA', 'N/A'] # 把这些字符串识别为缺失值
)

注意:

  • 身份证号、手机号这种长数字串读进来很容易变成 1.23E+18 科学计数法,必须用 dtype=str 强制按字符串读,否则后面匹配就废了。
  • Excel 单元格里有公式的话,pandas 默认读到的是公式计算后的,不是公式本身。如果单元格显示空白但实际有公式,可能读到 NaN,注意区分。

分块读取这个技巧,平时用不到,但特定场景里是加分项。
例子:”几 GB 的 CSV 怎么读”?
你就说 chunksize 参数,再补一句 Dask 或者 Modin 也行,显得你工具箱很丰富。

小白的疑问:分块读取出来的数据和上面 df = pd.read_csv() 读出来的处理方式一样吗?我看它根本没用 df 来保存啊。

好问题。答案是——不一样,处理姿势有本质区别:

  • pd.read_csv() 一次性把整张表读进内存,赋值给一个 df,后面所有操作(fillnagroupbydescribe…)都直接对这个 df 调用,因为数据是”完整的一坨”。
  • chunksize 返回的不是 DataFrame,而是一个迭代器(TextFileReader),每次 next 吐出来的是一小块 DataFrame(chunk)。它没有”全局 df”的概念,你只能在循环里对每一块单独处理。

这意味着凡是需要”看全局”的操作都搞不定,比如:

  • df.describe() 看不出整体统计量,只能逐块算完再合并
  • df.dropna(subset=['age']) 只能在块内删,删不干净
  • 排序、全表去重、相关性热力图——一个块算不出来

正确的姿势是”逐块处理 + 累加合并”:

1
2
3
4
5
6
7
8
9
10
# 例子:统计几千万行数据里每个省份的违约人数
chunk_iter = pd.read_csv('huge_credit.csv', chunksize=10000)
result = None
for chunk in chunk_iter:
# 每块先清洗,再聚合
chunk = chunk.dropna(subset=['province', 'default'])
counts = chunk.groupby('province')['default'].sum()
# 累加到全局结果上
result = counts if result is None else result.add(counts, fill_value=0)
print(result)

一句话总结:**普通读取”先全量进内存再处理”,分块读取”边读边处理再合并”**。能用普通读取就别用分块,分块是为内存兜底的最后一招。

1.2 三板斧:shape、info()、describe()

数据读进来之后,千万别急着跑模型。你还连数据长啥样子都不知道着,有的人一上来就 fit(),结果到后面发现有空值没处理、数据类型不对,全部白做。先花两分钟做这三件事:

1
2
# 第一斧:看看数据规模——几行几列
print(df.shape) # 输出 (行数, 列数)

shape 告诉你数据规模。1000 行和 100 万行的处理策略完全不同——小数据在预处理的就不能直接删除异常值了,数据本来就少,而大数据得考虑内存和计算成本。

1
2
# 第二斧:看看每列的类型和空值情况
print(df.info())

info() 是最关键的一步。它会告诉你:

  • 每列叫什么名字
  • 每列的数据类型(int64、float64、object……)
  • 每列有多少非空值——这一行直接暴露哪些列有缺失

如果 info() 显示某列 non-null 只有 800 而总行数是 1000,那就是有 200 个缺失值,后面必须处理。

1
2
# 第三斧:看看数值列的统计摘要
print(df.describe())

describe() 会输出每列的均值mean、标准差std、最小值min、最大值max、四分位数25%、50%、75%。

重点看最小值和最大值。如果 age 列最小值是 -3,那肯定是脏数据;如果收入最大值是 9999999,可能是异常值或者占位符。

实战经验:describe() 里的 50% 就是中位数。如果均值比中位数大很多,说明数据右偏,不是正态分布——这在收入类字段里非常常见,后面填缺失值时要用到这个判断。

1.3 异常值处理——数据清洗的第一关

异常值处理我建议放在空值处理前,因为异常值可能影响数据分布,从而影响空值填充的结果。

在处理前先最好备份一下读取的数据,避免你后面想对照一下的时候只能重新读取。

1
df_backup = df.copy()

题目说”按照合理方法处理异常值”,”合理”两个字很关键——你得说出你用的什么方法、为什么合理。

第一步:可视化辅助判断
刚才通过 describe() 看到大概的数据异常情况,现在通过可视化更精准地看一下数据情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
import matplotlib.pyplot as plt
import seaborn as sns

fig, axes = plt.subplots(1, 2, figsize=(12, 5))

# 箱线图——一眼看出异常值
sns.boxplot(y=df['income'], ax=axes[0])
axes[0].set_title('Income 箱线图')

# 直方图——看分布形态
axes[1].hist(df['income'], bins=30, edgecolor='black')
axes[1].set_title('Income 分布')
plt.show()

画图不是浪费时间,是帮你验证判断。如果箱线图里那些”异常点”其实是一条合理的长尾分布,你就不该处理它们。

什么叫”长尾分布”?为什么就不能处理?

长尾分布指的是——大部分数据集中在一个范围内,但右边拖着一条长长的”尾巴”,零星地分布着一些大值。收入、财富、城市人口、电商单品销量都是这种形状。

长尾上的点虽然被 IQR 判定为”异常”,但其实是真实存在的样本,不是脏数据。比如马云的资产在你的样本里就是那个”异常点”,可他确实是用户,把他删了或盖帽成”普通收入”——模型反而学不到”超高净值用户的违约模式”。

怎么判断一个点是”真异常”还是”长尾”?看直方图。如果直方图呈现一个明显的尖峰 + 右边慢慢拖长(像滑梯),那它是长尾分布;如果点是孤立地飘在远处(中间完全断开),那才是真异常。本例里 income 有多个 9999999,明显是系统上限占位符——这种才是真异常,不是长尾。

顺便说说怎么读箱线图——这图信息量比你想的大:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
            ┌──┐
│ │ ← 上须线:延伸到"正常范围"内的最大实际数据点
│ │ (正常范围上限 = Q3 + 1.5*IQR)
┌────┤ ├────┐
│ │ │ │
───────┤ │ │ │─────── Q3(75% 分位数)
│ │ │ │
│ │ │ │ ← 箱体高度 = IQR(Q3 - Q1)
│ │ │ │
───────┤ │ │ │─────── Q2(中位数,箱体里的横线)
│ │ │ │
│ │ │ │
└────┤ ├────┘─────── Q1(25% 分位数)
│ │
│ │ ← 下须线:延伸到"正常范围"内的最小实际数据点
│ │ (正常范围下限 = Q1 - 1.5*IQR)
● ← 离群点:超过须线范围的点

:图上是不是没有画出 Q1-1.5IQR 和 Q3+1.5IQR 这两条线?

对——箱线图不会直接画出这两条边界线。它们是隐含的判断标准:

  • 上须线:延伸到”不超过 Q3+1.5*IQR”的最大实际数据点(如果最大点本身就小于这个边界,须线就停在最大点)
  • 下须线:延伸到”不低于 Q1-1.5*IQR”的最小实际数据点(同理)
  • 离群点:超出须线范围的点才会被单独标出来(用 ● 符号)

读图四步走:

  1. 看中位数线在箱体的位置——居中说明对称;偏上说明数据集中在高值区间(左偏);偏下说明集中在低值区间(右偏)。
  2. 看箱体长度(IQR)——长说明数据分散,短说明数据集中。
  3. 看上下须的对称性——上须长下须短就是右偏(典型如收入);左右对称就是近似正态。
  4. 看离群点的数量和方向——只在上方有成堆离群点,往往是长尾分布(不要乱删);上下都有少量孤立点,更可能是真异常。

回到本例:income 既有 9999999 这种”孤立在上方且数值明显不连续”的点(系统占位符,要处理),也可能有真正的高收入人群(如果呈现长尾,保留)。先画图,凭分布形态决策,不要无脑删。

我们注意到income列有多个9999999,这看着可能是达到系统上限默认取成这个数字了,这是异常值,如果直接删除异常值会丢失数据,样本量变少,异常值如果给换成中位数又会丢失这个异常值的含义,更好的办法是盖帽法。

盖帽法保留数据点,只是把极端值拉回到正常范围

计算异常值边界常用的有IQR 法和 3σ 法。

IQR 法(四分位数法)分两步:先用箱线图判断异常值,计算异常值的边界,再用盖帽法拉回正常范围。

1
2
3
4
5
6
7
8
9
10
11
12
# 计算 IQR
Q1 = df['income'].quantile(0.25)
Q3 = df['income'].quantile(0.75)
IQR = Q3 - Q1

# 定义异常值边界
lower_bound = Q1 - 3 * IQR
upper_bound = Q3 + 3 * IQR

# 盖帽法前打印一下边界看是否合适
print(f"正常范围: [{lower_bound:.0f}, {upper_bound:.0f}]")

为什么这里用 3 * IQR 而不是常见的 1.5 * IQR?这两个数有啥讲究?

这两个数都来自统计学经验,背后是对”异常 vs 极端”的分级

| 系数 | 严格程度 | 适用场景 |
| ——– | ————————————- |
| 1.5IQR | 宽松 | 标准箱线图默认值;数据干净、想抓更多异常值 |
| 3
IQR | 严格 | 数据本身分散或有长尾;只想抓”极端异常” |

直觉理解:在正态分布下,1.5IQR 大概对应 2.7σ(覆盖 99.3% 数据),3IQR 大概对应 4.7σ(覆盖 99.999% 数据)。也就是说:

  • 1.5:1000 个点里平均会误判 7 个为异常——抓得狠,但会误伤;
  • 3:10000 个点里才平均误判 1 个——只抓最离谱的,不会乱杀。

本例为什么选 3 而不是 1.5?因为收入是右偏长尾数据,用 1.5 的话一大批真实存在的高收入人群会被误判为异常,盖帽会把他们的收入拉低,丢失业务信息。换 3*IQR 之后,只有 9999999 这种明显不合理的系统占位符会被识别出来。

怎么选?给个口诀:

  • 数据近似正态、干净 → 用 1.5(标准做法,箱线图默认)
  • 数据右偏、有长尾 → 用 3(避免误伤)
  • 保守一点没错的 → 先用 1.5 画图看,离群点太多就放宽到 3,再盖帽
1
2
3
# 盖帽法:把超出范围的值拉回边界
df['income'] = df['income'].clip(lower_bound, upper_bound)

如果你通过上面的可视化看到数据是近似正态分布,那么就可以用3σ 法。

1
2
3
4
5
6
7
# 3σ 法:计算均值和标准差,定义异常值边界
mean = df['age'].mean()
std = df['age'].std()
lower = mean - 3 * std
upper = mean + 3 * std
# 盖帽法:把超出范围的值拉回边界
df['age'] = df['age'].clip(lower, upper)

3σ 法假设数据正态分布,超过 3 倍标准差的就是异常值。但收入这种右偏数据用 3σ 法不合适——右偏分布的均值被拉高,标准差也大,用 3σ 法可能漏掉很多真正的异常值。

实战经验:IQR 法是万能选手,3σ 法只适合正态数据。不确定就用 IQR,不会出错。

现在把异常值问题解决了,下面处理缺失值。

1.4 缺失值处理——数据清洗的第二关

通过刚才的 info(),发现数据有空值,空值不能放着不管,先看看到底哪些列有空值、有多少:

1
print(df.isnull().sum())

假设输出是这样的:

1
2
3
4
5
6
7
income         120
age 5
gender 0
limit 30
default_fre 0
province 0
default 0

income 缺了 120 条,age 缺了 5 条,limit 缺了 30 条。怎么办?

原则一:缺得少且不重要的列,直接删行。 age 只缺 5 条,占总数据 0.5%,删了也不心疼,不过你要是数据本身就少你还是别删,想办法填充缺失值更好:

1
2
# 删除age列空值的行
df = df.dropna(subset=['age'])

原则二:缺得多但重要的列,要填充。 填什么值?这是有讲究的:

1
2
3
4
5
# 收入用中位数填充——不是均值!
df['income'] = df['income'].fillna(df['income'].median())

# 信用额度也用中位数
df['limit'] = df['limit'].fillna(df['limit'].median())

为什么收入用中位数而不是均值?因为刚看到数据中收入是右偏分布——少数人收入特别高,把均值拉上去了。比如 10 个人里 9 个收入 5000,1 个收入 500000,均值是 54500,中位数是 5000。你用 54500 去填一个普通人的缺失收入,直接就给填飞了。

还有些缺失值填充策略了解一下,每种都有它的”主场”:

① 均值填充——适合近似正态分布的数值列

1
2
# 身高这种数据,多数人集中在 165~175,分布对称,均值能代表"典型值"
df['height'] = df['height'].fillna(df['height'].mean())

适用场景:身高、考试成绩、温度这种分布对称、没明显偏斜的连续值。判断方法——describe() 看均值和 50%(中位数)接近不接近,接近就用均值。

② 中位数填充——适合有偏(尤其右偏)的数值列

1
2
3
# 收入、房价这种右偏数据,少数巨富把均值拉飞了,得用中位数
df['income'] = df['income'].fillna(df['income'].median())
df['house_price'] = df['house_price'].fillna(df['house_price'].median())

适用场景:收入、资产、消费金额这类”少数值特别大”的字段。右偏数据的中位数远小于均值,更贴近”普通人”。

③ 众数填充——适合分类变量

1
2
# 性别、职业、教育水平这种类别型字段,没有"均值"的概念,用出现最多的那个值填
df['occupation'] = df['occupation'].fillna(df['occupation'].mode()[0])

适用场景:类别型字段,比如职业、城市、学历。注意 mode() 返回的是 Series(可能不止一个众数),所以要加 [0] 取第一个。

④ 前向/后向填充——时间序列的专属技能

1
2
3
# 股票收盘价、传感器温度——昨天的值对今天有强参考意义
df['temperature'] = df['temperature'].ffill() # 用前一个非空值填
df['price'] = df['price'].bfill() # 用后一个非空值填

适用场景:时间序列(温度、股价、PM2.5、传感器读数)。逻辑是”数据有惯性,离得近的样本值相近”。注意 ffill 只能往前看,第一个值缺失就填不了,这时候要配合 bfill 用。

怎么选?给个决策顺序:类别用众数 → 数值看分布(正态用均值、偏态用中位数)→ 时间序列用 ffill

还有一种更干脆的做法——直接删列,不过请尽量避免,可能关键信息就在这列中,缺失值本身就是一种特征,例如:

“缺失即信号”的几种典型场景:

  • 贷款申请表里的”配偶收入”列:填了的人通常配偶也有稳定收入,违约率低;没填的人往往单身或配偶无业,违约率反而高。这列缺不缺失,本身就是一个强信号。

这种情况下,与其删列或乱填,不如新建一列”是否缺失”的标记,把”缺失”这个信号保留下来:

1
2
3
4
5
# 先把"是否缺失"这个信号单独存一列
df['income_missing'] = df['income'].isnull().astype(int)

# 然后再决定怎么填 income 本身
df['income'] = df['income'].fillna(df['income'].median())

真的要删列的话,怎么操作——

1
2
3
# 如果某列缺失超过 40%,直接扔掉这列
threshold = len(df) * 0.6
df = df.dropna(axis=1, thresh=threshold)

⚠️ 重点提醒dropnathresh 参数是”至少要有多少个非空值才保留这列“。

1.4 分类变量编码——非数字值转为数字编码

题目说”性别用【0、1】替换,省份用 One-Hot 编码替换”,这是因为汉字不能直接用模型处理,必须先转换成数字。

Label Encoding(标签编码)——把类别映射成整数:

1
2
3
4
5
6
# 性别:假设原始值是 '男'、'女'
df['gender'] = df['gender'].replace({'男': 1, '女': 0})
# 或者更好的方式
from sklearn.preprocessing import LabelEncoder
le = LabelEncoder()
df['gender'] = le.fit_transform(df['gender'])

为什么性别用 Label Encoding?因为性别是二分类变量,只有两个值,0 和 1 就够了,不需要搞成两个列。

One-Hot Encoding(独热编码)——把每个类别变成一个 0/1 列:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 省份:有多个类别,用 One-Hot Encoding
from sklearn.preprocessing import OneHotEncoder
ohe = OneHotEncoder(drop='first', # 删第一列,避免共线性
handle_unknown='ignore', # 遇到没见过的省份补全0
sparse_output=False) # 返回普通数组,方便查看
# 注意:必须是二维!用双括号 df[['province']]
province_encoded = ohe.fit_transform(df[['province']])

# 转成 DataFrame 方便看
province_cols = ohe.get_feature_names_out(['province'])
df_province = pd.DataFrame(province_encoded, columns=province_cols)
# 拼回去,删掉原来的省份列
df = pd.concat([df.drop('province', axis=1), df_province], axis=1)

drop_first=True 是什么意思?比如省份有北京、上海、广东三个值,One-Hot 会生成三列:province_北京province_上海province_广东。但三列其实用两列就能表示了,第三列能用另外两列算出来(三列加起来肯定等于 1),这就产生了多重共线性——模型会被搞混。drop_first=True 删掉第一列,留两列就能完整表达三个类别。

生活化类比:你让两个员工 A 和 B 一起完成一项任务,但他们的工作内容 100% 重叠——做完了你根本分不清谁的功劳大,发奖金时怎么分都不公平。最干脆的办法是”去掉一个”(drop_first),让剩下的人独立完成,功劳清清楚楚。

注意:树模型(决策树、随机森林、XGBoost)对共线性不敏感,因为它们是按特征排序切分的,不靠权重线性组合。但线性模型(逻辑回归、线性回归)和神经网络对它非常敏感——只要用了这些模型,One-Hot 必须删一列。

什么时候用 Label Encoding,什么时候用 One-Hot?

  • 有序类别(如学历:高中 < 本科 < 硕士):用 Label Encoding,保序关系
  • 无序类别且类别少(如性别、是否吸烟):Label Encoding 即可(二值)或 One-Hot
  • 无序类别且类别多(如省份、职业):One-Hot,否则模型会误以为类别之间有大小关系

为什么 Label Encoding 会引入”假大小关系”?

Label Encoding 的逻辑很简单:把类别按出现顺序(或字母顺序)映射成 0, 1, 2, 3… 这样的整数。问题是——这些数字本来只是”标签”,但很多模型会把它们当数值来算,这种强加的数学结构会让模型学出错误的模式——比如”省份值在顺序中越靠后违约率越高”这种毫无依据的规律。

哪些模型会中招?

  • 会中招:线性模型(逻辑回归、线性回归)、SVM、KNN、神经网络——它们直接把特征当数值算距离或权重。
  • 不太中招:树模型(决策树、随机森林、XGBoost)——它们只看”以哪个值做切分点”,不关心数值大小,所以 Label Encoding 对树模型勉强能接受。但即便用树模型,One-Hot 在多类别时也会更好(除非维度爆炸)。

这就是为什么对无序多类别,One-Hot 是默认选择——每个类别一个独立列,互不干扰,模型绝不会把”是上海”和”是北京”建立任何数值关系。

拓展分析:用 One-Hot 编码省份,会不会因为测试集出现训练集没见过的省份而导致编码失败?

会,这是一个非常常见的工程坑。sklearn 的 OneHotEncoder 默认遇到没见过的类别会直接报错

1
2
3
4
5
6
# 训练集只有 北京、上海、广东
ohe.fit(X_train[['province']]) # 学到了 3 个类别

# 测试集出现了一个 '新疆'
ohe.transform(X_test[['province']])
# ❌ ValueError: Found unknown categories ['新疆'] in column 0

这就是未知类别问题——训练集分布不等于生产环境分布,新省份随时可能出现。两种典型解法:

1
2
3
4
5
6
7
# 解法一:handle_unknown='ignore' —— 未知类别所有 One-Hot 列全填 0
ohe = OneHotEncoder(handle_unknown='ignore', drop='first')

# 解法二:把低频类别预先合并成 '其他',未来新省份也归入 '其他'
counts = df['province'].value_counts()
rare = counts[counts < 50].index # 出现少于 50 次的省份
df['province'] = df['province'].replace(rare, '其他')

解法一简单但丢失信息——新省份的所有 One-Hot 列都是 0,等价于”哪个已知省份都不是”,模型完全没法学到它的特征。解法二更工程化,但需要预先知道哪些类别会高频出现。生产环境里推荐两者结合:先合并罕见类别,再开 handle_unknown='ignore' 兜底。

如果省份有 34 个省,One-Hot 出来 33 列,会不会维度爆炸?答案是:会的。这时候可以考虑目标编码(Target Encoding),用每个省份的平均违约率来替代省份名。但考试里一般不会问这么深,用 One-Hot 就行。

针对这种情况现实工程中最常用的还是 Target Encoding——它既不增加维度,又能保留类别的预测信号,配合交叉验证防过拟合效果很好。sklearn 里有现成的 category_encoders 库或者 1.4 版本后的 TargetEncoder

1
2
3
4
5
from sklearn.preprocessing import TargetEncoder

te = TargetEncoder(target_type='binary', random_state=42)
df['province_encoded'] = te.fit_transform(df[['province']], df['default'])
# 省份名直接变成"该省平均违约率",比如北京→0.12,海南→0.25

注意 Target Encoding 必须用交叉验证的方式 fit(sklearn 内部默认做了),否则会用标签信息”作弊”,导致严重过拟合——训练集上的”北京违约率”包含了当前样本自己的标签,等于偷看答案。

1.6 数据类型转换——小细节别翻车

1
2
3
4
5
6
7
8
# 确保 default 列是整数类型(标签列必须是 int)
df['default'] = df['default'].astype(int)

# 确保 gender 是整数
df['gender'] = df['gender'].astype(int)

# 检查最终的数据类型
print(df.dtypes)

这步看起来无聊,但因为数据类型不对导致模型报错的人太多了。object 类型的列如果忘了编码,sklearn 会直接抛 ValueError


第二步:数据分析与挖掘

刚才进行了数据预处理,可以认为干净数据已经准备好了,下面开始读懂数据。

2.1 可视化——画图不是炫技,是找线索

题目说”画出反映不同特征条件下的违约情况”,意思是要你分层看违约率,找出哪些特征跟违约强相关。

收入与违约率的关系——分箱后看趋势

1
2
3
4
5
6
7
8
9
10
11
# 将收入分成 5 个区间
df['income_bin'] = pd.cut(df['income'], bins=5) # bin表示箱,bins表示箱的数量

# 计算每个区间的违约率
default_rate = df.groupby('income_bin')['default'].mean()

default_rate.plot(kind='bar', figsize=(8, 5))
plt.title('不同收入区间的违约率')
plt.ylabel('违约率')
plt.xticks(rotation=45)
plt.show()

如果你看到低收入组的违约率明显高于高收入组,这意味着收入是违约的有效预测特征,后面建模时收入特征权重应该比较大。反过来,如果违约率在各收入区间差不多,那收入这个特征可能对模型帮助不大。

性别与违约率——分组柱状图

1
2
3
4
5
6
gender_default = df.groupby('gender')['default'].mean()
gender_default.plot(kind='bar', color=['#4ECDC4', '#FF6B6B'])
plt.title('不同性别的违约率')
plt.ylabel('违约率')
plt.xticks([0, 1], ['女', '男'], rotation=0)
plt.show()

信用额度与违约率——箱线图对比

1
2
3
sns.boxplot(x='default', y='limit', data=df)
plt.title('违约/非违约用户的信用额度分布')
plt.show()

如果违约用户的额度中位数明显低于非违约用户,说明额度低的用户更容易违约——这符合业务直觉。

相关性热力图——全局视角

1
2
3
4
plt.figure(figsize=(10, 8))
sns.heatmap(df.corr(), annot=True, fmt='.2f', cmap='RdBu_r', center=0)
plt.title('特征相关性矩阵')
plt.show()

热力图看什么?两件事:

  1. 特征与标签的相关性:哪个特征和 default 的相关性绝对值最大,哪个就是最有预测力的特征,值越接近 1,说明越相关(正相关和负相关)。
  2. 特征之间的相关性:如果两个特征之间相关性 > 0.8,说明它们信息重叠(多重共线性),模型会不稳定,需要酌情去掉一个

为啥要去掉一个?

为啥信息重叠就要删? 直觉上:两个特征相关性 0.95,等于它们说的是”同一件事”。比如 incomemonthly_income(年收入 / 12),它俩一起进模型——

  • 冗余信息:模型学一遍就够了,两个都留等于把信号放大 2 倍,但有效信息没增加。
  • 权重打架:模型分不清”该把重要性记到谁头上”,结果两个权重都给得很小,单独看哪个都不重要,加起来又很强——可解释性崩坏。
  • 数值不稳定:线性模型的权重求解依赖矩阵求逆,高度共线会让矩阵接近奇异,求出来的权重对训练集扰动极其敏感。

可视化的解读能力是加分项。给你一张图,不是让你描述图上有什么,而是问你”这说明什么”——你要能从图里读出业务含义,然后决定下一步操作。

常用可视化方法怎么选?

  • 想看一个变量的分布 → 数值用直方图,类别用柱状图;既要分布又要异常值,用箱线图。
  • 想看一个变量在不同组下的差异 → 分组箱线图(数值)或分组柱状图(聚合后的统计量)。
  • 想看两个变量之间的关系 → 散点图;如果点太密,换成 hexbin 或 2D 直方图。
  • 想看时间趋势 → 折线图;多变量同时画就叠加,颜色区分。
  • 想全局扫一眼相关性 → 热力图;要细看每对关系用 pairplot。
  • 想看占比 → 饼图(仅 2~5 类);类别多直接换横向柱状图。

举几个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 例子 1:想看"不同省份的违约率谁高谁低"——分类 × 比例 → 柱状图
df.groupby('province')['default'].mean().plot(kind='bar')

# 例子 2:想看"收入这个字段分布长啥样、有没有异常值"——单变量 → 直方图 + 箱线图(并排)
fig, ax = plt.subplots(1, 2)
ax[0].hist(df['income'], bins=30) # 看形态
sns.boxplot(y=df['income'], ax=ax[1]) # 看异常值

# 例子 3:想看"收入和信用额度是不是线性相关"——两个连续变量 → 散点图
df.plot.scatter(x='income', y='limit', alpha=0.3) # alpha 透明化避免点重叠

# 例子 4:想看"所有数值特征两两之间的相关性"——多变量矩阵 → 热力图
sns.heatmap(df.corr(), annot=True, cmap='RdBu_r', center=0)

# 例子 5:想看"违约/非违约用户的收入分布对比"——分组 × 分布 → 小提琴图
sns.violinplot(x='default', y='income', data=df) # 比分组箱线图更细,能看到密度

常见误区提醒

  • 饼图滥用:饼图只适合类别极少(2~5 个)且需要强调”占比”的场景。10 个类别的饼图是灾难——人眼很难比较相邻扇形的大小。
  • 折线图用在无序数据上:把不同省份画成折线图,会暗示”上海 → 北京 → 广东”有先后顺序,但实际没有。无序类别一律用柱状图。
  • 散点图点太密:大数据量直接 scatter 会糊成一团,加 alpha=0.3 透明化,或换成 hexbin(df.plot.hexbin)。
  • 箱线图看不出分布密度:箱线图只显示分位数,分布是多峰还是单峰看不出来——这时候小提琴图更好(密度+箱体合体)。
  • 3D 图炫技:3D 散点图看着酷,但人眼很难在 3D 空间准确比较深度,最后还是看 2D 投影。能用 2D 就别上 3D。

2.2 特征工程——数据科学的真正核心

有人说”数据和特征决定了机器学习的上限,模型和算法只是逼近这个上限”。特征工程就是帮你把上限往上推的操作。

标准化 vs 归一化——本质区别

1
2
3
4
5
6
7
8
9
from sklearn.preprocessing import StandardScaler, MinMaxScaler

# 标准化:把数据变成均值0、标准差1——适合近似正态分布的数据
scaler_std = StandardScaler()
df[['income_scaled', 'limit_scaled']] = scaler_std.fit_transform(df[['income', 'limit']])

# 归一化:把数据压缩到 [0, 1] 区间——适合分布不明但有明确上下界的数据
scaler_mm = MinMaxScaler()
df[['income_norm', 'limit_norm']] = scaler_mm.fit_transform(df[['income', 'limit']])

区别是什么?

操作 公式 结果 适用场景
标准化 (x - μ) / σ 均值0,标准差1 数据近似正态;SVM、逻辑回归、神经网络
归一化 (x - min) / (max - min) 压缩到 [0,1] 数据范围差异大;KNN、神经网络

实战经验:树模型(决策树、随机森林、XGBoost)不需要标准化和归一化,因为树模型是按特征值排序做切分的,尺度变化不影响排序结果。但 SVM 和 KNN 就必须做,因为它们依赖距离计算。问到”哪些模型需要特征缩放”,记住这条就行。

特征分箱——把连续变量变离散

1
2
3
4
5
# 年龄分成 3 组:青年、中年、老年
df['age_bin'] = pd.cut(df['age'], bins=[0, 30, 50, 100], labels=['青年', '中年', '老年'])

# 再对分箱结果做 Label Encoding
df['age_bin'] = df['age_bin'].map({'青年': 0, '中年': 1, '老年': 2})

分箱什么时候有用?当特征和标签之间不是简单线性关系的时候。比如年龄和违约率可能是 U 型关系(年轻人和老年人违约率高,中年人低),线性模型抓不住这种关系,分箱之后反而能学到。

PCA 降维——降了个寂寞还是真有用?

1
2
3
4
5
6
7
8
9
10
11
12
from sklearn.decomposition import PCA

# 先标准化(PCA 前必须标准化!)
X_scaled = StandardScaler().fit_transform(X)

# 降到 5 维
pca = PCA(n_components=5)
X_pca = pca.fit_transform(X_scaled)

# 查看各主成分的方差解释比例
print(pca.explained_variance_ratio_)
print(f"累计方差解释率: {pca.explained_variance_ratio_.sum():.2%}")

PCA 降维的核心判断:累计方差解释率要达到 85% 以上,否则你丢掉的信息太多,等于降了个寂寞。

“方差解释率”和”累计方差解释率”到底是个啥?

先理清概念:PCA 的本质是把原始数据投影到新的坐标系上,新坐标轴(主成分)按”能解释多少原始数据的方差”排序。方差越大,说明这根轴能承载的信息越多。

  • 方差解释率(explained variance ratio):单个主成分能解释原始数据总方差的比例。
    • 比如 PC1 的方差解释率是 0.45,意思是”原始数据里 45% 的信息(方差)被 PC1 这根轴承载了”。
    • 计算方式:主成分的方差 / 所有原始特征的总方差之和。
  • 累计方差解释率:前 k 个主成分的方差解释率之和。
    • 比如 PC1=0.45, PC2=0.25, PC3=0.15 → 前 3 个主成分累计解释率 = 0.45+0.25+0.15 = 0.85(85%)。
    • 意思是:”用 3 个主成分就能保留原始数据 85% 的信息”。

**直觉理解——“信息”在这里等价于”方差”**:

  • 为什么方差大 = 信息多?因为方差衡量的是”数据的丰富程度”。如果一个特征全是同一个值(方差=0),那它对预测没帮助——“所有人收入都是 5000”这条信息等于没信息。
  • PCA 把方差大的方向排在前面,本质上是”优先保留信息量大的维度”,扔掉方差小的维度(信息量小,丢了不心疼)。

为什么要看累计方差解释率?

  • PCA 降维是把 20 个原始特征压缩成 5 个主成分。压缩完了你得回答一个问题:”压缩后的 5 个主成分,到底保留了原始数据多少信息?”
  • 答案就在累计方差解释率里。如果前 5 个主成分累计解释率是 85%,说明”原始数据 85% 的信息被压缩进这 5 个维度了,剩下 15% 被扔了”。
  • 如果累计解释率只有 60%,说明你扔了 40% 的信息——降维太狠,模型效果会大幅下降。

85% 这个阈值怎么来的? 经验值:

  • 70% 太低,丢掉的信息可能包含关键信号
  • 85% 是工程上的”安全线”,大部分场景下效果还行
  • 90%+ 更保守,但主成分数量会增加,降维效果减弱

怎么看、怎么选? 用代码输出一目了然:

1
2
3
4
5
6
7
8
9
10
11
12
# 输出每个主成分的方差解释率和累计值
print("各主成分方差解释率:", pca.explained_variance_ratio_)
print("累计方差解释率:", pca.explained_variance_ratio_.cumsum())

# 画图看"拐点"
import matplotlib.pyplot as plt
plt.plot(range(1, len(pca.explained_variance_ratio_) + 1),
pca.explained_variance_ratio_.cumsum(), 'b-o')
plt.xlabel('主成分数量')
plt.ylabel('累计方差解释率')
plt.axhline(y=0.85, color='r', linestyle='--') # 标出 85% 线
plt.show()

图上会看到一条曲线:前几个主成分累计解释率上升很快(斜率大),后面越来越慢(斜率变小)。拐点之后的主成分解释力已经很弱,可以扔掉。85% 的线帮你找到”保留几个主成分合适”的答案。

一句话总结:方差解释率是”单个主成分承载了多少信息”,累计方差解释率是”前几个主成分一共承载了多少信息”。降维前先看累计解释率,保证 >=85%,否则别降。

PCA 为什么之前要标准化?
因为 PCA 找的是方差最大的方向,如果特征量纲不同(收入上万、年龄几十),收入那个维度天然方差大,PCA 会优先保留收入方向,忽略年龄方向——这不是因为年龄不重要,纯粹是因为单位不同。

特征交叉——两个弱特征组合成强特征

1
2
# 收入 × 额度 = 实际可用杠杆
df['income_limit_ratio'] = df['income'] / df['limit']

这个特征什么意思?一个人的额度占收入的比例。如果额度远超收入,违约风险自然高。这种业务驱动的特征交叉,往往比 PCA 这种纯数学降维更有效。

2.3 数据集划分——7:3 还是 8:2?

题目说”按照 7:3 切分训练集、测试集”,那咱就 7:3:

1
2
3
4
5
6
7
8
from sklearn.model_selection import train_test_split

X = df.drop('default', axis=1) # 特征
y = df['default'] # 标签

X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42, stratify=y
)

注意 stratify=y 这个参数——这叫分层抽样,保证训练集和测试集里正负样本的比例一致。分类问题必须加,不加的话万一测试集里违约样本全被分走了,你怎么评估?

test_size表示测试集占比。

random_state=42 表示以固定数值42作为随机种子,确保结果可复现。

train_test_split方法还有个参数,叫shuffle,用于随机打乱数据。
时间序列数据不能随便 shuffle! 如果数据是按时间排的(比如每月的交易记录),你 train_test_split 默认会打乱顺序,导致”用未来数据预测过去”——这叫数据泄漏。时间序列要用 shuffle=False 或者按时间截断:

1
2
3
4
# 时间序列切分——不能 shuffle
split_point = int(len(df) * 0.7)
X_train, X_test = X[:split_point], X[split_point:]
y_train, y_test = y[:split_point], y[split_point:]

为什么是 7:3 或 8:2?这是经验值。训练数据太少模型学不好,测试数据太少评估不可靠。数据量特别大(几十万条)时可以用 9:1,因为测试集哪怕只有 10% 也够评估了。

2.4 不平衡数据处理——SMOTE 过采样

先看看正负样本比例:

1
2
print(y_train.value_counts())
print(f"违约比例: {y_train.mean():.2%}")

如果违约样本只占 5%,那模型只要全部预测”不违约”就有 95% 的准确率——但这个模型毫无用处。这就是类别不平衡问题。

SMOTE 过采样——用 imblearn 三行代码搞定:

1
2
3
4
5
6
7
from imblearn.over_sampling import SMOTE

smote = SMOTE(random_state=42)
X_train_res, y_train_res = smote.fit_resample(X_train, y_train)

print(f"过采样前: {y_train.value_counts().to_dict()}")
print(f"过采样后: {y_train_res.value_counts().to_dict()}")

SMOTE 的原理:找到少数类样本的 K 近邻,在连线上随机插值生成新样本。不是简单复制,是”合成”新的少数类样本。

问:SMOTE 会不会导致过拟合?

  • 会,如果你在过采样之后才做交叉验证的话。因为过采样生成的样本是基于训练集的,如果这些合成样本也出现在验证集里,验证集就不独立了——这又是数据泄漏。
  • 正确做法:在每个 fold 内部单独做 SMOTE,而不是先 SMOTE 再分 fold。sklearn 的 Pipeline 配合 imblearn 的 make_pipeline 可以实现。

欠采样作为对比:

1
2
3
4
from imblearn.under_sampling import RandomUnderSampler

rus = RandomUnderSampler(random_state=42)
X_train_under, y_train_under = rus.fit_resample(X_train, y_train)

欠采样是随机删掉多数类样本,让正负样本平衡。缺点是丢数据,优点是训练快。考试里数据量通常不大,优先用 SMOTE。

2.5 算法选择与建模——先跑 Baseline 再上 Boosting

不要一上来就 XGBoost,调了半天参数,结果发现数据没洗干净,F1 值还不如逻辑回归。正确的策略是:先跑一个简单模型看效果,再逐步升级。

Baseline:逻辑回归

1
2
3
4
5
6
7
8
9
10
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, f1_score

lr = LogisticRegression(max_iter=1000, random_state=42)
lr.fit(X_train_res, y_train_res)

y_pred_lr = lr.predict(X_test)
print("=== 逻辑回归 ===")
print(classification_report(y_test, y_pred_lr))
print(f"F1 值: {f1_score(y_test, y_pred_lr):.4f}")

逻辑回归的输出有概率,可解释性好,训练快。它是你的锚点——后面的模型至少要比它好才算有意义。

升级:随机森林

1
2
3
4
5
6
7
8
9
from sklearn.ensemble import RandomForestClassifier

rf = RandomForestClassifier(n_estimators=100, random_state=42)
rf.fit(X_train_res, y_train_res)

y_pred_rf = rf.predict(X_test)
print("=== 随机森林 ===")
print(classification_report(y_test, y_pred_rf))
print(f"F1 值: {f1_score(y_test, y_pred_rf):.4f}")

终极大招:XGBoost

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from xgboost import XGBClassifier

xgb = XGBClassifier(
n_estimators=200,
max_depth=5,
learning_rate=0.1,
random_state=42,
use_label_encoder=False,
eval_metric='logloss'
)
xgb.fit(X_train_res, y_train_res)

y_pred_xgb = xgb.predict(X_test)
print("=== XGBoost ===")
print(classification_report(y_test, y_pred_xgb))
print(f"F1 值: {f1_score(y_test, y_pred_xgb):.4f}")

LightGBM 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
from lightgbm import LGBMClassifier

lgbm = LGBMClassifier(
n_estimators=200,
max_depth=5,
learning_rate=0.1,
random_state=42,
verbose=-1
)
lgbm.fit(X_train_res, y_train_res)

y_pred_lgbm = lgbm.predict(X_test)
print(f"LightGBM F1 值: {f1_score(y_test, y_pred_lgbm):.4f}")

各算法的”出场时机”:

算法 优点 缺点 什么时候用
逻辑回归 快、可解释 只能学线性关系 Baseline;需要解释性的场景
决策树 可视化直观 容易过拟合 理解数据结构;特征选择
随机森林 稳定、抗过拟合 慢、模型大 中等数据量;特征多
XGBoost 比赛霸主 调参复杂 追求极致效果
LightGBM 比 XGBoost 更快 小数据容易过拟合 大数据量;训练时间有限

比赛策略:先跑逻辑回归当 baseline(5 分钟),再跑随机森林(10 分钟),最后 XGBoost 精调(剩下时间全给它)。别上来就死磕 XGBoost,你不确定数据是否干净,调了白调。

2.6 模型评估——不只是看准确率

1
2
3
4
5
from sklearn.metrics import (
confusion_matrix, accuracy_score, precision_score,
recall_score, f1_score, roc_auc_score, log_loss,
roc_curve
)

混淆矩阵——一切评估指标的基础

1
2
cm = confusion_matrix(y_test, y_pred_xgb)
print(cm)

输出长这样:

1
2
3
            预测=0  预测=1
实际=0 TN FP
实际=1 FN TP

从混淆矩阵衍生出四个核心指标:

指标 公式 直觉 什么时候重要
准确率 (TP+TN) / 全部 整体对不对 正负样本均衡时
精确率 TP / (TP+FP) 预测为正的有多少真为正 误报代价大时(如垃圾邮件)
召回率 TP / (TP+FN) 真正的有多少被找到 漏报代价大时(如癌症检测)
F1 值 2 × P × R / (P+R) 精确率和召回率的调和平均 正负样本不均衡时

问”F1 为什么用调和平均不用算术平均?”
因为调和平均对短板更敏感。精确率 0.9、召回率 0.1,算术平均 0.5 看着还行,调和平均只有 0.18,直接暴露问题。

ROC 曲线与 AUC 值

1
2
3
4
5
6
7
8
9
10
11
12
13
y_prob = xgb.predict_proba(X_test)[:, 1]  # 预测为正类的概率

fpr, tpr, thresholds = roc_curve(y_test, y_prob)
auc = roc_auc_score(y_test, y_prob)

plt.figure(figsize=(8, 6))
plt.plot(fpr, tpr, label=f'XGBoost (AUC = {auc:.3f})')
plt.plot([0, 1], [0, 1], 'k--', label='随机猜测')
plt.xlabel('假正率 (FPR)')
plt.ylabel('真正率 (TPR)')
plt.title('ROC 曲线')
plt.legend()
plt.show()

AUC 的直觉含义:随机取一个正样本和一个负样本,模型把正样本排在前面的概率。AUC = 0.5 就是瞎猜,AUC = 1.0 就是完美。

对数损失(Log Loss)

1
2
logloss = log_loss(y_test, y_prob)
print(f"Log Loss: {logloss:.4f}")

Log Loss 衡量的是预测概率和真实标签之间的差距,比准确率更严格。准确率只看你预测的类别对不对,Log Loss 还看你预测的概率准不准,关心的是你”猜得有多自信、有多准”,哪怕你两次都猜对了,一个 51% 勉强对、一个 99% 稳稳对,Log Loss 会给后者更低的(更好的)分数。

Log Loss 什么时候比准确率更有参考价值?当你的下游任务需要概率输出时。比如风险控制,你要根据违约概率决定授信额度,不只是判断”违约/不违约”,这时候 Log Loss 更能反映模型质量。


第三步:调参与优化

3.1 交叉验证——不只是 cross_val_score 一行代码

1
2
3
4
5
6
7
from sklearn.model_selection import cross_val_score, StratifiedKFold

# 5折分层交叉验证
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

scores = cross_val_score(xgb, X_train_res, y_train_res, cv=skf, scoring='f1')
print(f"5折CV F1: {scores.mean():.4f} ± {scores.std():.4f}")

为什么用 StratifiedKFold 而不是普通 KFold?因为分类问题里每个 fold 的正负比例要一致,否则某个 fold 里全是不违约样本,F1 值没法算。

cross_val_score的参数:

  • estimator:模型实例,这里用的是上面实例化的XGboBoost模型xgb
  • X:特征矩阵,这里可以看到训练集和测试集都是用的原始训练集进行划分的,保证不会进行数据泄露
  • y:目标变量,这里用的是原始训练集的目标变量
  • cv:交叉验证策略,这里用的是5折分层交叉验证
  • scoring:评估指标,这里用的是F1值

交叉验证的深层意义:单次 train_test_split 的结果有随机性,可能你运气好分到一个简单的测试集,F1 值虚高。交叉验证跑 5 次,看均值和方差,均值高且方差小说明模型真的稳。

工程考量:交叉验证的 fold 数怎么选?

  • 5 折:最常用,计算成本和评估稳定性的平衡
  • 10 折:数据量小时用,充分利用每条数据

3.2 网格搜索——暴力但可靠

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from sklearn.model_selection import GridSearchCV

param_grid = {
'n_estimators': [100, 200, 300],
'max_depth': [3, 5, 7],
'learning_rate': [0.01, 0.1, 0.2],
'min_child_weight': [1, 3, 5]
}

grid_search = GridSearchCV(
XGBClassifier(random_state=42, use_label_encoder=False, eval_metric='logloss'), # 空壳模型实例
param_grid,
cv=5, # 5折分层交叉验证
scoring='f1', # 评估指标,这里用的是F1值
n_jobs=-1, # 用所有 CPU 核心并行
verbose=1 # 打印进度
)
grid_search.fit(X_train_res, y_train_res)

print(f"最佳参数: {grid_search.best_params_}")
print(f"最佳 F1: {grid_search.best_score_:.4f}")

网格搜索的问题:3 × 3 × 3 × 3 = 81 种组合,每种跑 5 折交叉验证,就是 405 次训练。网格搜索相当于暴力遍历所有可能,参数多的时候,跑到天荒地老。

3.3 随机搜索——够快够用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from sklearn.model_selection import RandomizedSearchCV
from scipy.stats import randint, uniform

param_dist = {
'n_estimators': randint(100, 500),
'max_depth': randint(3, 10),
'learning_rate': uniform(0.01, 0.3),
'min_child_weight': randint(1, 10),
'subsample': uniform(0.6, 0.4),
'colsample_bytree': uniform(0.6, 0.4)
}

random_search = RandomizedSearchCV(
XGBClassifier(random_state=42, use_label_encoder=False, eval_metric='logloss'),
param_distributions=param_dist,
n_iter=50, # 只尝试 50 种随机组合
cv=5,
scoring='f1',
n_jobs=-1,
random_state=42,
verbose=1
)
random_search.fit(X_train_res, y_train_res)

print(f"最佳参数: {random_search.best_params_}")
print(f"最佳 F1: {random_search.best_score_:.4f}")

50 种组合 vs 81 种,省了不少时间。而且随机搜索有个理论保证:如果某个参数真的很重要,随机搜索大概率也能搜到好的值。

随机性能大大减少尝试的次数,缺点是可能错过最优解。

3.4 贝叶斯优化——比赛里的秘密武器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import optuna

def objective(trial):
params = {
'n_estimators': trial.suggest_int('n_estimators', 100, 500),
'max_depth': trial.suggest_int('max_depth', 3, 10),
'learning_rate': trial.suggest_float('learning_rate', 0.01, 0.3, log=True),
'min_child_weight': trial.suggest_int('min_child_weight', 1, 10),
'subsample': trial.suggest_float('subsample', 0.6, 1.0),
'colsample_bytree': trial.suggest_float('colsample_bytree', 0.6, 1.0),
'reg_alpha': trial.suggest_float('reg_alpha', 1e-8, 10.0, log=True),
'reg_lambda': trial.suggest_float('reg_lambda', 1e-8, 10.0, log=True)
}

model = XGBClassifier(**params, random_state=42, use_label_encoder=False, eval_metric='logloss')

skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(model, X_train_res, y_train_res, cv=skf, scoring='f1')
return scores.mean()

study = optuna.create_study(direction='maximize')
study.optimize(objective, n_trials=100, show_progress_bar=True)

print(f"最佳 F1: {study.best_value:.4f}")
print(f"最佳参数: {study.best_params}")

贝叶斯优化和网格搜索/随机搜索的本质区别:它会学习。每一轮试验的结果会被用来指导下一轮搜索的方向——上一轮发现 max_depth=5max_depth=3 好,下一轮就会在 5 附近多探索。所以同样 50 次试验,贝叶斯优化大概率比随机搜索找到更好的参数。

Optuna 是 Kaggle 比赛里几乎人手一个的库。考试里如果时间允许,用 Optuna 跑 50~100 轮,效果通常比手动调参好一截。

贝叶斯优化的原理(为什么它能“预测”更好的值?)

  • 网格搜索(Grid Search):像一张渔网把整个池塘捞一遍。傻瓜但全面,池子大了就捞不动。
  • 随机搜索(Random Search):像闭着眼睛往池塘里乱撒网。偶尔能抓到大鱼,但纯看运气。
    贝叶斯优化的核心思想:它会“看菜吃饭”,根据过去的经验来指导下一次的撒网位置。它主要依靠两个核心组件:
  1. 代理模型(Surrogate Model)—— 也就是“记忆大脑”
    贝叶斯优化内部维护着一个概率模型(通常是高斯过程 Gaussian Process)。刚开始,它什么都不懂,随便试几组参数(比如试了 max_depth=3得分 0.6,max_depth=8得分 0.8)。随着试验次数增加,这个“大脑”会根据已有的结果,拟合出一条“参数与分数关系的地形图”。这张图不仅能告诉你“哪个位置现在看起来最高”(均值),还能告诉你“哪个位置你还不太确定,可能有惊喜”(方差/不确定性)。
  2. 采集函数(Acquisition Function)—— 也就是“导航仪”
    有了地形图后,导航仪会综合两个因素来决定下一次去哪试:
  • 开采(Exploitation):去目前已知分数最高的区域附近(因为我已经知道那里不错)。
  • 探矿(Exploration):去目前还很“陌生”、不确定性很高的区域(万一那里藏着更高的分数呢?)。

✨ 这就是为什么说它“会学习”:
上一轮发现 max_depth=5比 max_depth=3好,贝叶斯优化不会像随机搜索那样无视这个信息,而是会在 5 附近(比如 4, 5, 6)多分配一些试验机会,同时偶尔去 9 或 10 看看会不会有意外之喜。

3.5 学习曲线——判断欠拟合还是过拟合的利器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from sklearn.model_selection import learning_curve
import numpy as np

train_sizes, train_scores, val_scores = learning_curve(
xgb, X_train_res, y_train_res,
train_sizes=np.linspace(0.1, 1.0, 10),
cv=5, scoring='f1', n_jobs=-1
)

train_mean = train_scores.mean(axis=1)
val_mean = val_scores.mean(axis=1)

plt.figure(figsize=(8, 6))
plt.plot(train_sizes, train_mean, 'o-', label='训练集 F1')
plt.plot(train_sizes, val_mean, 'o-', label='验证集 F1')
plt.xlabel('训练样本数')
plt.ylabel('F1 值')
plt.title('学习曲线')
plt.legend()
plt.show()

怎么解读学习曲线?

  • 欠拟合:训练集和验证集的 F1 都低,而且很接近——模型太简单了,学不到东西。对策:换更复杂的模型、加特征、减少正则化。
  • 过拟合:训练集 F1 很高,验证集 F1 很低,差距大——模型把训练集背下来了但泛化不了。对策:加正则化、减特征、增大训练数据、Early Stopping。
  • 刚刚好:两条曲线逐渐靠近并趋于平稳,且 F1 值可接受。

这图太好使了,判断模型是欠拟合还是过拟合,你就画这个图,一画就懂。

3.6 用最优模型做预测并提交

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 用调完参的最优模型
best_model = random_search.best_estimator_

# 预测测试集
y_pred_final = best_model.predict(X_test)
print(f"最终 F1 值: {f1_score(y_test, y_pred_final):.4f}")

# 读取 results.csv,对新用户做预测
results_df = pd.read_csv('results.csv')

# 注意:results.csv 的预处理要和训练数据完全一致!
# (编码、标准化、特征选择——步骤必须一模一样)
# 这里省略重复的预处理代码,实际考试中必须做同样的处理。可以将预处理包装成一个函数,训练时和测试时都调用。

# 对新数据做预测
X_new = results_df.drop('default', axis=1, errors='ignore') # errors='ignore' 是为了忽略 default 列,防止没有default列时删除default列导致错误
predictions = best_model.predict(X_new)

# 保存预测结果
submission = pd.DataFrame({'id': range(len(predictions)), 'default': predictions})
submission.to_csv('submission.csv', index=False)
print("预测结果已保存到 submission.csv")

考试最后 5 分钟最容易犯的错误:训练数据做了标准化、One-Hot,但预测数据忘了做同样的处理。结果模型输入格式不对,直接报错或者出垃圾结果。一定要用 Pipeline 把预处理和建模绑在一起,这样就不会漏步骤:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer

# 把特征按“数据类型”分类(本质上是按预处理需求分类),因为不同数据类型的特征需要不同的处理
numeric_features = ['income', 'age', 'limit', 'default_fre'] # 数值型(收入、年龄等)通常需要缩放
categorical_features = ['gender'] # 类别型(性别等)如果已经变成 0/1 编码了,就不需要再处理了

preprocessor = ColumnTransformer(
transformers=[
('num', StandardScaler(), numeric_features),
('cat', 'passthrough', categorical_features) # 不需要处理的用'passthrough'表示什么也不干
]
)

# 用 Pipeline 绑定预处理和模型
pipe = Pipeline([
('preprocessor', preprocessor),
('classifier', XGBClassifier(random_state=42, eval_metric='logloss'))
])

# 之后 fit / predict 都走 Pipeline,不会漏步骤
pipe.fit(X_train, y_train)
y_pred = pipe.predict(X_test)

ColumnTransformer:它的任务是对不同的列,施加不同的处理魔法

  • ‘num’:别名,方便日志打印
  • StandardScaler():对数值特征进行的处理
  • numeric_features:待处理的数值特征列名列表

课后加餐:四个补充练习

练习一:Titanic 生存预测——练完整的二分类流程

经典入门题。数据包含乘客的年龄、性别、舱位等级、登船港口等,预测是否生还。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import f1_score

# 读取数据
df = pd.read_csv('titanic.csv')

# 缺失值处理
df['Age'].fillna(df['Age'].median(), inplace=True)
df['Embarked'].fillna(df['Embarked'].mode()[0], inplace=True)

# 编码
df['Sex'] = df['Sex'].map({'male': 1, 'female': 0})
df = pd.get_dummies(df, columns=['Embarked'], drop_first=True)

# 特征选择
features = ['Pclass', 'Sex', 'Age', 'SibSp', 'Parch', 'Fare']
features += [c for c in df.columns if c.startswith('Embarked_')]

X = df[features]
y = df['Survived']

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42, stratify=y)

# 建模
rf = RandomForestClassifier(n_estimators=100, random_state=42)
rf.fit(X_train, y_train)

y_pred = rf.predict(X_test)
print(f"F1 值: {f1_score(y_test, y_pred):.4f}")

# 特征重要性——看看哪些特征对生存预测最有用
importance = pd.Series(rf.feature_importances_, index=features).sort_values(ascending=False)
print("特征重要性:")
print(importance)

Titanic 的核心考点:完整的端到端二分类流程——从缺失值处理、编码、特征选择到建模评估,一步不落。

练习二:Iris 多分类——练决策边界可视化和多分类评估

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, confusion_matrix
import numpy as np
import matplotlib.pyplot as plt

# 加载数据
iris = load_iris()
X, y = iris.data, iris.target

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42, stratify=y)

rf = RandomForestClassifier(n_estimators=100, random_state=42)
rf.fit(X_train, y_train)
y_pred = rf.predict(X_test)

print(classification_report(y_test, y_pred, target_names=iris.target_names))

# 决策边界可视化——用前两个特征
x_min, x_max = X[:, 0].min() - 1, X[:, 0].max() + 1
y_min, y_max = X[:, 1].min() - 1, X[:, 1].max() + 1
xx, yy = np.meshgrid(np.arange(x_min, x_max, 0.02),
np.arange(y_min, y_max, 0.02))

Z = rf.predict(np.c_[xx.ravel(), yy.ravel(), np.zeros(xx.size), np.zeros(xx.size)])
Z = Z.reshape(xx.shape)

plt.contourf(xx, yy, Z, alpha=0.3)
scatter = plt.scatter(X[:, 0], X[:, 1], c=y, edgecolors='k')
plt.xlabel(iris.feature_names[0])
plt.ylabel(iris.feature_names[1])
plt.title('Iris 决策边界')
plt.show()

Iris 的核心考点:多分类的评估——每个类别分别算精确率、召回率、F1,看 classification_report决策边界可视化——直观理解模型是怎么划分不同类别的。

练习三:房价预测——练回归算法对比和连续值预测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from sklearn.datasets import fetch_california_housing
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LinearRegression, Ridge
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score
import numpy as np

# 加载数据
data = fetch_california_housing()
X, y = data.data, data.target

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)

# 对比三个回归模型
models = {
'线性回归': LinearRegression(),
'Ridge 回归': Ridge(alpha=1.0),
'随机森林': RandomForestRegressor(n_estimators=100, random_state=42)
}

for name, model in models.items():
model.fit(X_train, y_train)
y_pred = model.predict(X_test)
rmse = np.sqrt(mean_squared_error(y_test, y_pred))
mae = mean_absolute_error(y_test, y_pred)
r2 = r2_score(y_test, y_pred)
print(f"{name}: RMSE={rmse:.4f}, MAE={mae:.4f}, R²={r2:.4f}")

房价预测的核心考点:回归问题的评估指标——

指标 含义 越小/大越好
MAE 平均绝对误差 越小越好
RMSE 均方根误差 越小越好
决定系数 越接近 1 越好

R² = 1 表示完美预测,R² = 0 表示和预测均值一样差,R² < 0 表示比预测均值还差。

练习四:信用卡欺诈检测——专门练不平衡数据处理和 SMOTE

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, f1_score
from imblearn.over_sampling import SMOTE
from imblearn.pipeline import make_pipeline as make_imb_pipeline

# 模拟极度不平衡数据(欺诈只有 1%)
X, y = make_classification(
n_samples=10000, n_features=20,
n_informative=10, n_redundant=5,
weights=[0.99, 0.01], random_state=42
)

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42, stratify=y)

# 方案一:不处理不平衡
lr_plain = LogisticRegression(max_iter=1000, random_state=42)
lr_plain.fit(X_train, y_train)
y_pred_plain = lr_plain.predict(X_test)
print("=== 不处理不平衡 ===")
print(classification_report(y_test, y_pred_plain))

# 方案二:SMOTE 过采样(用 Pipeline 避免数据泄漏)
pipe_smote = make_imb_pipeline(
SMOTE(random_state=42),
LogisticRegression(max_iter=1000, random_state=42)
)
pipe_smote.fit(X_train, y_train)
y_pred_smote = pipe_smote.predict(X_test)
print("=== SMOTE 过采样 ===")
print(classification_report(y_test, y_pred_smote))

# 方案三:class_weight='balanced'(更轻量的替代方案)
lr_weighted = LogisticRegression(max_iter=1000, class_weight='balanced', random_state=42)
lr_weighted.fit(X_train, y_train)
y_pred_weighted = lr_weighted.predict(X_test)
print("=== class_weight balanced ===")
print(classification_report(y_test, y_pred_weighted))

print(f"不处理 F1: {f1_score(y_test, y_pred_plain):.4f}")
print(f"SMOTE F1: {f1_score(y_test, y_pred_smote):.4f}")
print(f"加权 F1: {f1_score(y_test, y_pred_weighted):.4f}")

欺诈检测的核心考点:极度不平衡数据下的处理策略对比。三种方案的适用场景:

方案 优点 缺点
不处理 简单 少数类召回率极低
SMOTE 效果好 可过拟合;需要 Pipeline
class_weight 不增加数据量 效果不如 SMOTE 但更稳健

实战建议:先试 class_weight='balanced',效果不够再上 SMOTE。大部分情况下 class_weight 就够用了。


全流程速查表

考试前花 5 分钟过一遍这张表,脑子里就有完整的流水线了:

1
2
3
4
5
6
7
8
9
10
11
12
1. 读取数据 → shape / info() / describe()
2. 异常值 → IQR 盖帽法 / 3σ 法(正态数据)
3. 缺失值 → dropna / fillna(中位数/众数)
4. 编码 → map(Label) / One-Hot, drop_first=True
5. 可视化 → 分组违约率柱状图 / 相关性热力图 / 箱线图
6. 特征工程 → 标准化(StandardScaler) / 分箱 / 特征交叉
7. 划分数据 → train_test_split(stratify=y)
8. 不平衡 → SMOTE / class_weight='balanced'
9. 建模 → 逻辑回归(Baseline) → 随机森林 → XGBoost
10. 评估 → F1 为主 + 混淆矩阵 + ROC-AUC
11. 调参 → 随机搜索(快) / Optuna(精)
12. 提交 → Pipeline 确保预处理一致 → predict → to_csv

写在最后

走完这一遍,你应该有了三样东西:

  1. 一条清晰的流水线:拿到任何脏数据,脑子里立刻跑出”清洗→探索→建模→调优”的步骤顺序
  2. 每个环节的工具包:知道用什么库、什么函数、什么参数——fillna 用中位数、One-Hot 要 drop_first、分类问题 stratify、调参先随机搜索再 Optuna
  3. 避坑的肌肉记忆:收入别用均值填、树模型不用标准化、SMOTE 要放 Pipeline 里、预测新数据预处理要和训练一致

这么长的流程都学完了,给自己点个赞吧!