Kaggle 数据科学 | 从二分类问题学习数据预处理
Daisy Author

这是 Kaggle Playground Series 在24年8月的一场比赛:

赛题:Binary Prediction of Poisonous Mushrooms

比赛的内容较为简单,是有关蘑菇毒性的二元分类问题。比赛的难点在于数据集样本的特征中存在较多缺失值,同时,特征的描述为符号语言,也及到对文本特征进行编码的问题。这场比赛是学习数据预处理的良好契机,推荐 kaggle beginner 积极尝试。

数据概览

拿到数据后,我们首先读取数据,打印并观察数据集特征维数、含义等基础信息。

1
2
3
4
train_data = pd.read_csv("/kaggle/input/playground-series-s4e8/train.csv")
test_data = pd.read_csv("/kaggle/input/playground-series-s4e8/test.csv")

print(train_data.head(5), test_data.head(5))

img1

img2

注意到数据除了特征之外,有列名为 id 的一列,这是数据集样本本身的编号,对分类问题没有直接帮助,我们将其 drop 掉。

1
2
train_data = train_data.drop(columns=['id'])
test_data = test_data.drop(columns=['id'])

完成后,我们打印 DataFrame 的 info,查看特征标签和对应数据类型。

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
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 3116945 entries, 0 to 3116944
Data columns (total 21 columns):
# Column Dtype
--- ------ -----
0 class object
1 cap-diameter float64
2 cap-shape object
3 cap-surface object
4 cap-color object
5 does-bruise-or-bleed object
6 gill-attachment object
7 gill-spacing object
8 gill-color object
9 stem-height float64
10 stem-width float64
11 stem-root object
12 stem-surface object
13 stem-color object
14 veil-type object
15 veil-color object
16 has-ring object
17 ring-type object
18 spore-print-color object
19 habitat object
20 season object
dtypes: float64(3), object(18)
memory usage: 499.4+ MB

对于数据较多的 DataFrame ,更推荐使用 describe 方法查看数据的统计特征。

观察到其中大多数特征都是 object 数据类型,一般是字符串或者空值,这些会在后续处理。

我们将这些特征为 object 类型的特征名提取出来,查看他们的成员情况。

1
2
3
4
categorical_columns = test_data.select_dtypes(include=['object']).columns
unique_values = {col: test_data[col].nunique() for col in categorical_columns}
for col, unique_count in unique_values.items():
print(f"{col}: {unique_count} unique values")

output:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class: 2 unique values
cap-shape: 74 unique values
cap-surface: 83 unique values
cap-color: 78 unique values
does-bruise-or-bleed: 26 unique values
gill-attachment: 78 unique values
gill-spacing: 48 unique values
gill-color: 63 unique values
stem-root: 38 unique values
stem-surface: 60 unique values
stem-color: 59 unique values
veil-type: 22 unique values
veil-color: 24 unique values
has-ring: 23 unique values
ring-type: 40 unique values
spore-print-color: 32 unique values
habitat: 52 unique values
season: 4 unique values

同理查看测试集上的 unique values,这有助于我们对数据集整体结构有个大概的认知。

查看特征集合。

1
print(train_data.columns, test_data.columns)

output:

1
2
3
4
5
6
7
8
9
10
11
12
(Index(['class', 'cap-diameter', 'cap-shape', 'cap-surface', 'cap-color',
'does-bruise-or-bleed', 'gill-attachment', 'gill-spacing', 'gill-color',
'stem-height', 'stem-width', 'stem-root', 'stem-surface', 'stem-color',
'veil-type', 'veil-color', 'has-ring', 'ring-type', 'spore-print-color',
'habitat', 'season'],
dtype='object'),
Index(['cap-diameter', 'cap-shape', 'cap-surface', 'cap-color',
'does-bruise-or-bleed', 'gill-attachment', 'gill-spacing', 'gill-color',
'stem-height', 'stem-width', 'stem-root', 'stem-surface', 'stem-color',
'veil-type', 'veil-color', 'has-ring', 'ring-type', 'spore-print-color',
'habitat', 'season'],
dtype='object'))

对数据集有个大致的了解后,我们着手开始处理缺失值。

缺失值处理

我们查看训练集和测试集中每一个特征的缺失值占比,并打印其中大于 0 的内容。

1
2
3
4
5
6
7
8
missing_train = train_data.isna().mean() * 100
missing_test = test_data.isna().mean() * 100

print("percentage of missing train_data:")
print(missing_train[missing_train > 0])

print("percentage of missing test_data:")
print(missing_test[missing_test > 0])

output:

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
percentage of missing train_data:
cap-diameter 0.000128
cap-shape 0.001283
cap-surface 21.528227
cap-color 0.000385
does-bruise-or-bleed 0.000257
gill-attachment 16.809280
gill-spacing 40.373988
gill-color 0.001829
stem-root 88.452732
stem-surface 63.551362
stem-color 0.001219
veil-type 94.884350
veil-color 87.936970
has-ring 0.000770
ring-type 4.134818
spore-print-color 91.425482
habitat 0.001444
dtype: float64
percentage of missing test_data:
cap-diameter 0.000337
cap-shape 0.001492
cap-surface 21.506821
cap-color 0.000626
does-bruise-or-bleed 0.000481
gill-attachment 16.834796
gill-spacing 40.404694
gill-color 0.002358
stem-height 0.000048
stem-root 88.452543
stem-surface 63.595327
stem-color 0.001011
veil-type 94.878689
veil-color 87.880445
has-ring 0.000914
ring-type 4.148051
spore-print-color 91.417224
habitat 0.001203
dtype: float64

可以看到,有些列的缺失值占比达到百分之五十以上,更有甚者达到百分之94+。

对于缺失值太多的特征,插值往往不会有很好的效果,我们设置一个阈值,将缺失比例高于阈值的特征列进行 drop.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
missing_threshold = 0.95

high_missing_columns = train_data.columns[train_data.isnull().mean() > missing_threshold]
train_data = train_data.drop(columns=high_missing_columns)
test_data = test_data.drop(columns=high_missing_columns)
target = 'class'

for column in train_data.columns:
if train_data[column].isnull().any():
if train_data[column].dtype == 'object':
mode_value = train_data[column].mode()[0]
train_data[column].fillna(mode_value, inplace=True)
test_data[column].fillna(mode_value, inplace=True)
else:
median_value = train_data[column].median()
train_data[column].fillna(median_value, inplace=True)
test_data[column].fillna(median_value, inplace=True)

在这些代码中,我们丢弃了缺失值高于 missing_threshold 的特征列。然后,为了保持训练集和测试集插值的统一性,我们以训练集为参考对象,每次选取一个包含缺失值的特征列,如果该列的数据类型为 object ,我们提取其中出现次数最多的元素作为插值元素,并使用 fillna 方法,对训练集与测试集的该特征进行插值。

到这里我们完成了对缺失值的初步处理,需要注意的是,训练集和测试集中有缺失值的特征不一定相同,我们以训练集为插值参考对象插值后,测试集可能仍然存在缺失值特征没有被插值,这一类情况在后续讨论。

K近邻插值是一种基于实例的学习方法,通过找到与缺失值最接近的 K 个邻居数据点,并根据这些邻居的特征估算缺失值。我们使用 KNN 插值对数据集做进一步处理。

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

def knn_impute(df, n_neighbors=5):
df_encoded = df.copy()
for col in df_encoded.select_dtypes(include='object').columns:
df_encoded[col] = df_encoded[col].astype('category').cat.codes

knn_imputer = KNNImputer(n_neighbors=n_neighbors)
df_imputed = pd.DataFrame(knn_imputer.fit_transform(df_encoded), columns=df_encoded.columns)

for col in df.select_dtypes(include='object').columns:
df_imputed[col] = df_imputed[col].round().astype(int).map(
dict(enumerate(df[col].astype('category').cat.categories))
)
return df_imputed

train_data_imputed = knn_impute(train_data, n_neighbors=5)
test_data_imputed = knn_impute(test_data, n_neighbors=5)

train_data_imputed.head(5), test_data_imputed.head(5)

到这一步,我们已经填充了训练集和测试集所有的缺失值,是时候对特征列进行编码表示了。

特征向量编码

我们将缺失值处理后的训练数据、测试数据数据框中的类别型特征转换为数值型。

首先,我们选择类别型特征,同时过滤掉 class,后面单独编码类标签。

1
2
cat_cols_train = train_data_imputed.select_dtypes(include=['object']).columns
cat_cols_train = cat_cols_train[cat_cols_train != 'class']

Ordinal Encoding 通过给每个类别分配唯一整数,将分类数据转换为数值数据。它的有点事保持特征空间紧凑,像 One-Hot-Encoding 则会显著增加数据集的维度。

1
2
ordinal_encoder = OrdinalEncoder(handle_unknown='use_encoded_value', unknown_value=-1)
# 对于未知类别,编码为-1

我们在训练集,测试集上应用转换。

1
2
train_data_imputed[cat_cols_train] = ordinal_encoder.fit_transform(train_data_imputed[cat_cols_train].astype(str)) 
test_data_imputed[cat_cols_train] = ordinal_encoder.transform(test_data_imputed[cat_cols_train]).astype(float)

fit_transform 计算数据的统计信息,并将这些信息应用于数据的转换,而 transform 将之前计算得到的统计信息应用到新数据上,而不重新计算参数

上述操作保持了训练集和测试集的一致性。

到这一步,我们完成了对特征向量的数值编码,可以查看一下数据类型来确认。

1
train_data_imputed.info()

output:

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
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 3116945 entries, 0 to 3116944
Data columns (total 21 columns):
# Column Dtype
--- ------ -----
0 class object
1 cap-diameter float64
2 cap-shape float64
3 cap-surface float64
4 cap-color float64
5 does-bruise-or-bleed float64
6 gill-attachment float64
7 gill-spacing float64
8 gill-color float64
9 stem-height float64
10 stem-width float64
11 stem-root float64
12 stem-surface float64
13 stem-color float64
14 veil-type float64
15 veil-color float64
16 has-ring float64
17 ring-type float64
18 spore-print-color float64
19 habitat float64
20 season float64
dtypes: float64(20), object(1)
memory usage: 499.4+ MB

将处理好的数据赋值给原始数据框。

1
2
train_data = train_data_imputed
test_data = test_data_imputed

在建模之前,我们还需要将训练集的类标签转换为数值编码:

1
2
le = LabelEncoder()
train_data['class'] = le.fit_transform(train_data['class'])

到这里,我们已经完成了所有的数据预处理工作,可以开始建模。

模型建立

训练集-测试集划分

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

y = train_data['class']
X = train_data.drop(['class'], axis=1)

train_X, test_X, train_y, test_y = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)

# stratify=y 使用 y 进行分层抽样,确保训练集和测试机中目标变量的分布与原始数据集相同

马修斯相关系数

使用马修斯相关系数作为模型性能的评价指标。

马修斯相关系数(Matthews Correlation Coefficient,MCC)是一种用于评估二分类模型性能的指标。它考虑了混淆矩阵中的所有四个类别(真正例、真负例、假正例、假负例)的计数,并对它们进行综合评估。

MCC 的值范围从 -1 到 1,其中”

  • 1 表示完美预测
  • 0 表示模型性能等同于随机预测
  • -1 表示模型预测与真实情况完全相反
1
2
3
4
5
6
7
from sklearn.metrics import matthews_corrcoef

def mcc_metric(y_pred, dmatrix):
y_true = dmatrix.get_label()
y_pred = (y_pred > 0.5).astype(int)
mcc = matthews_corrcoef(y_true, y_pred)
return 'mcc', mcc

XGBoost

一篇介绍 XGBoost 的好博客 [link]

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

model = XGBClassifier(
alpha=0.1,
subsample=0.8,
colsample_bytree=0.6,
objective="binary:logistic", # 学习任务/目标函数 本题为二分类
max_depth=14,
min_child_weight=7,
gamma=1e-6, # 分裂结点所需要的最少损失减小值 信息增益?
random_state=42,
n_estimators=100 # number of trees
)

XGB = model.fit(
train_X,
train_y,
eval_set=[(test_X, test_y)],
eval_metric=mcc_metric
)

sckit-learn 提供了 XGBoost 实现,我们可以通过简单的调用 api 来便捷地训练一个拟合现有数据的梯度提升决策树。

预测结果

预测标签的数值编码:

1
test_pred_prob = XGB.predict(test_data)

还原到原始文本标签:

1
test_pred_class = le.inverse_transform(test_pred_prob)

将数据导出在 csv 中:

1
2
3
4
df_sub = pd.read_csv('/kaggle/input/playground-series-s4e8/sample_submission.csv')
df_sub['class'] = test_pred_class

df_sub.to_csv('submission.csv', index = False)

到这里,我们已经将结果输出在 kaggle kernel 的 output 空间,可以提交结果看看评分啦。