scikit-learn 二分类流水线的模块划分、特征筛选和处理顺序是否合理?
结论
按职责拆分函数是合理的,但流程需要调整:先划分训练集和测试集,再在训练数据上拟合特征筛选、缺失值填补和缩放步骤。如果使用交叉验证,还要在每一折的训练部分重新拟合这些步骤,可以用 Pipeline 管理。
- 在完整数据上计算特征与目标变量的相关性,会让测试集标签参与特征选择,造成数据泄漏。
- 在划分前调用
MinMaxScaler.fit_transform,会让测试集的特征分布参与缩放参数计算,也会造成数据泄漏。 LinearRegression可以拟合数值化的二元标签,但不适合用作这里的常规分类模型。可以用LogisticRegression作为线性分类基线。- 模型选择应在训练集内部通过交叉验证完成,保留测试集用于最后评估。
模块如何划分
现有函数名称基本能表达职责,主要问题是 preprocess_data 同时承担了固定规则清洗和需要从数据中学习的特征选择。
可以按下面的顺序组织:
- 读取数据,检查列名,按明确规则转换标签。
- 分离
X和y,划分训练集和测试集。 - 构建包含预处理和分类器的
Pipeline。 - 在训练集内部进行交叉验证和参数选择。
- 使用选定的流程评估测试集,并保留整个流程供后续预测使用。
固定的标签映射,例如 {"B": 0, "M": 1},不需要估计数据分布,可以在划分前执行。均值、方差、最小值、最大值以及特征与目标的相关性,都必须从相应的训练数据中计算。
训练函数最好返回模型或搜索对象。如果只打印分数,后续预测、保存模型和检查参数都会不方便。
相关性阈值能否用于特征选择
可以将相关性筛选作为一种简单的候选方法,但 abs(correlation) > 0.5 不能直接作为通用的特征筛选标准。
对于编码为 0/1 的目标,数值特征与目标的 Pearson 相关系数可以反映一定的单变量关联。这种方法有几个限制:
- 相关性较低的特征仍可能通过非线性关系或特征交互提供预测信息。
- 多个高相关特征可能包含重复信息。
- 常量列的相关系数可能为
NaN。 numeric_only=True会排除非数值列,不能代替类别特征编码。- 阈值 0.5 是人为选择,需要通过训练集内部的验证判断是否合适。
原代码利用目标列与自身的相关系数通常为 1,将目标列一起保留在返回结果中。虽然随后会把目标列从特征中删除,这种写法仍把特征选择与标签管理耦合在了一起。建议先拆分 X、y,让筛选器只处理 X 的列。
如果保留相关性筛选,应将它实现为兼容 scikit-learn 的转换器:在 fit(X, y) 中记录选中的列,在 transform(X) 中复用这些列,并将转换器放入 Pipeline。如果只在整个训练集上筛选一次,再进行交叉验证,验证折的信息仍会进入筛选过程。
初始基线可以先保留所有有效特征,再验证筛选是否带来收益。
缩放与划分的正确顺序
基本顺序是:
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
处理测试集时只能调用 transform,不能单独 fit,也不能与训练集合并后再 fit。
StandardScaler 和 MinMaxScaler 通常作为两种候选方案,不需要默认连续使用:
StandardScaler根据训练数据的均值和标准差进行标准化。MinMaxScaler根据训练数据的最小值和最大值缩放;测试数据超出训练范围时,转换结果可能超出[0, 1]。KNeighborsClassifier依赖距离,对特征尺度通常比较敏感。- 对
LogisticRegression使用缩放,通常有助于优化,并让正则化对不同特征的影响更可比。 - 对普通数值特征,
DecisionTreeClassifier通常不需要缩放。
进行交叉验证时,手动缩放容易遗漏折内隔离,使用 Pipeline 更容易保持正确顺序。
可参考的实现
下面的示例假设各行样本相互独立,特征均为适合直接建模的数值列,目标标签为 B/M 或 0/1。示例保留特征缺失值,在流水线内部填补,并通过训练集上的交叉验证比较三个分类器。
示例使用 ROC AUC 选择模型,这只是一个演示性的指标选择;实际任务应根据误报、漏报等代价确定指标。数据路径和目标列需要按实际文件修改。
import pandas as pd
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (
classification_report,
confusion_matrix,
roc_auc_score,
)
from sklearn.model_selection import (
GridSearchCV,
StratifiedKFold,
train_test_split,
)
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.tree import DecisionTreeClassifier
def prepare_xy(df, target_column):
if target_column not in df.columns:
raise ValueError(f"Missing target column: {target_column}")
df = df.dropna(subset=[target_column]).copy()
y = df[target_column].replace({"B": 0, "M": 1})
if not y.isin([0, 1]).all() or y.nunique() != 2:
raise ValueError("Target must contain exactly two classes: 0 and 1")
X = df.drop(columns=[target_column])
if X.shape[1] == 0:
raise ValueError("No feature columns available")
non_numeric = X.select_dtypes(exclude="number").columns.tolist()
if non_numeric:
raise ValueError(f"Encode non-numeric features first: {non_numeric}")
return X, y.astype(int)
def fit_models(X_train, y_train):
n_splits = 5
if y_train.value_counts().min() < n_splits:
raise ValueError("Each training class needs at least 5 samples")
pipeline = Pipeline([
("imputer", SimpleImputer(strategy="median")),
("scaler", StandardScaler()),
("classifier", LogisticRegression(max_iter=1000)),
])
param_grid = [
{
"classifier": [LogisticRegression(max_iter=1000)],
"classifier__C": [0.1, 1.0, 10.0],
},
{
"scaler": ["passthrough"],
"classifier": [DecisionTreeClassifier(random_state=42)],
"classifier__max_depth": [3, 5, None],
"classifier__min_samples_leaf": [1, 5],
},
{
"classifier": [KNeighborsClassifier()],
"classifier__n_neighbors": [3, 5],
},
]
cv = StratifiedKFold(
n_splits=n_splits, shuffle=True, random_state=42
)
search = GridSearchCV(
pipeline,
param_grid,
scoring="roc_auc",
cv=cv,
refit=True,
error_score="raise",
)
search.fit(X_train, y_train)
return search
def main():
df = pd.read_csv("your_data.csv")
X, y = prepare_xy(df, target_column="diagnosis")
X_train, X_test, y_train, y_test = train_test_split(
X,
y,
test_size=0.2,
random_state=42,
stratify=y,
)
search = fit_models(X_train, y_train)
model = search.best_estimator_
y_pred = model.predict(X_test)
positive_index = model.classes_.tolist().index(1)
y_score = model.predict_proba(X_test)[:, positive_index]
print("Best parameters:", search.best_params_)
print("Best CV ROC AUC:", search.best_score_)
print("Test ROC AUC:", roc_auc_score(y_test, y_score))
print("Confusion matrix:")
print(confusion_matrix(y_test, y_pred))
print(classification_report(y_test, y_pred, zero_division=0))
return model
if __name__ == "__main__":
main()
GridSearchCV 会在每一折内拟合填补器、缩放器和分类器。选定参数后,refit=True 会用整个训练集重新拟合最佳流程,再通过同一个流程对测试集生成预测。
这里的参数范围仅用于展示结构,没有依据表明它们对当前数据最优。代码也未在实际数据上运行,不能据此判断哪种模型表现最好。
为什么替换 LinearRegression,以及如何评估
LinearRegression 的输出是连续值,可能小于 0 或大于 1。即使人为设置分类阈值,也不能直接把这些输出当作有效概率。原代码打印的 MSE 与其他模型打印的 Accuracy,也不能直接比较大小来选择模型。
LogisticRegression 提供分类预测和概率输出,更适合作为这个二分类任务的基线。DecisionTreeClassifier 和 KNeighborsClassifier 也可以保留为候选,但应在相同的数据划分和指标下比较。
Accuracy 可以报告,但类别不平衡时,只看它容易掩盖少数类的漏判。可以结合混淆矩阵、Precision、Recall、F1,以及基于连续预测分数计算的 ROC AUC;正类稀少时也可关注 PR 曲线和 Average Precision。
如果需要调整分类阈值,应在训练集内部的验证流程中完成。反复查看测试集结果并据此改模型,会逐渐让测试集参与模型选择。
原代码还需检查的地方
- 核实列名和标签。默认路径、
diagnosis列名和B/M映射是否匹配,无法仅从代码确定。先检查实际列名与目标取值;astype(float)不会自动保证目标只有两个类别。 - 不要无条件删除所有缺失行。
dropna()可能丢弃大量样本,也可能改变样本分布。特征缺失值可以在流水线内部处理,目标缺失值则需要单独处理。 - 去重需要业务依据。完全相同的行可能是重复采集,也可能是合法的独立记录。同一患者、设备或实体有多条记录时,应考虑按组划分;时间预测任务应考虑按时间划分,不能直接套用示例的随机划分。
- 数值类型不等于连续变量。用整数表示的类别列可能需要编码;存在字符串类别列时,可通过
ColumnTransformer分别处理数值列和类别列。 - 保留拟合后的整个流程。预测新数据时,需要复用相同的填补、缩放和分类步骤,不能重新计算这些参数。
- 检查缩进。展示的代码缺少函数体等位置的缩进。如果实际文件也是如此,会先出现语法错误,需要恢复 Python 缩进后才能运行。
备注:内容仅供参考。
