PAYATHON 2026

scikit-learn 二分类流水线的模块划分、特征筛选和处理顺序是否合理?

架构老王

结论

按职责拆分函数是合理的,但流程需要调整:先划分训练集和测试集,再在训练数据上拟合特征筛选、缺失值填补和缩放步骤。如果使用交叉验证,还要在每一折的训练部分重新拟合这些步骤,可以用 Pipeline 管理。

  • 在完整数据上计算特征与目标变量的相关性,会让测试集标签参与特征选择,造成数据泄漏。
  • 在划分前调用 MinMaxScaler.fit_transform,会让测试集的特征分布参与缩放参数计算,也会造成数据泄漏。
  • LinearRegression 可以拟合数值化的二元标签,但不适合用作这里的常规分类模型。可以用 LogisticRegression 作为线性分类基线。
  • 模型选择应在训练集内部通过交叉验证完成,保留测试集用于最后评估。

模块如何划分

现有函数名称基本能表达职责,主要问题是 preprocess_data 同时承担了固定规则清洗和需要从数据中学习的特征选择。

可以按下面的顺序组织:

  1. 读取数据,检查列名,按明确规则转换标签。
  2. 分离 X 和 y,划分训练集和测试集。
  3. 构建包含预处理和分类器的 Pipeline。
  4. 在训练集内部进行交叉验证和参数选择。
  5. 使用选定的流程评估测试集,并保留整个流程供后续预测使用。

固定的标签映射,例如 {"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 缩进后才能运行。

备注:内容仅供参考。