RSS
菜单
全部文章快讯开发科技深度热点

第 35 章 特征工程与数据预处理

内容摘要

本篇把 pandas 与 scikit-learn 串成一条可运行的预处理链路,覆盖缺失值策略、类别编码、数值缩放与分箱、时间特征、文本 TF-IDF、特征选择,以及最容易翻车的"训练集/测试集划分与防数据泄漏"。前置知识:第 7 章缺失值、第 8 章类型转换、第 14 章时间序列。学完后你能把一份脏数据变成模型可直接吃下的特征矩阵,并自信地说"我这套预处理没有泄漏未来信息"。

📖 本篇简介:本篇把 pandas 与 scikit-learn 串成一条可运行的预处理链路,覆盖缺失值策略、类别编码、数值缩放与分箱、时间特征、文本 TF-IDF、特征选择,以及最容易翻车的"训练集/测试集划分与防数据泄漏"。前置知识:第 7 章缺失值、第 8 章类型转换、第 14 章时间序列。学完后你能把一份脏数据变成模型可直接吃下的特征矩阵,并自信地说"我这套预处理没有泄漏未来信息"。
模型上线效果差,80% 不是模型问题,是特征工程问题:缺失值乱填、编码顺序错了、缩放器 fit 到了全量数据、时间特征泄漏了未来。本篇按 缺失值 → 类别编码 → 数值缩放 → 时间特征 → 文本特征 → 特征选择 → 防泄漏 的顺序,给出 pandas + sklearn 的最小可运行链路。

目录

1. 预处理全链路总览

2. 缺失值处理策略对比

3. 类别编码:Label / One-Hot / get_dummies

4. 数值缩放与分箱

5. 时间特征提取

6. 文本特征:jieba 分词 + TF-IDF

7. 特征构造与特征选择

8. 训练集/测试集划分与防数据泄漏

9. 常见坑与速查表

10. 本章小结与练习


1. 预处理全链路总览

# 安装:pip install scikit-learn pandas numpy jieba
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split

# 模拟一份"真实业务"脏数据:缺失、类别、时间、文本都有
np.random.seed(42)
N = 1000
df = pd.DataFrame({
    "年龄": np.random.randint(18, 70, N),
    "收入": np.random.normal(15000, 5000, N).round(0),
    "城市": np.random.choice(["北京", "上海", "广州", "深圳", "杭州"], N),
    "性别": np.random.choice(["男", "女", np.nan], N, p=[0.45, 0.45, 0.1]),
    "注册时间": pd.date_range("2023-01-01", periods=N, freq="6h"),
    "评论": np.random.choice(["物流很快包装完好", "客服态度差回复慢", "价格实惠性价比高",
                              "质量一般不太满意", "发货快东西不错"], N),
    "是否付费": np.random.choice([0, 1], N, p=[0.7, 0.3]),
})
# 故意再撒一些缺失值
df.loc[np.random.choice(N, 100, replace=False), "收入"] = np.nan

print(df.head())
print(df.isna().sum())        # 看每列缺失多少

整条链路的固定顺序(顺序错了就泄漏,第 8 节细讲):

原始数据
  └── 1. train_test_split(先切!)
        ├── X_train / y_train
        └── X_test  / y_test
  └── 2. 只在 X_train 上 fit 所有"统计量类"转换器(均值、编码器、缩放器、Vectorizer)
  └── 3. transform 同时作用于 X_train 和 X_test
  └── 4. 模型训练 / 评估

2. 缺失值处理策略对比

四种主流策略,按"数据分布 + 缺失机制"选:

| 策略 | 适用 | pandas/sklearn 做法 | 风险 |

|------|------|---------------------|------|

| 直接删除 | 缺失行极少(<5%)、MCAR(随机缺失) | df.dropna() / dropna(subset=[...]) | 删多了样本量缩水 |

| 常量/统计量填充 | 缺失较多、数值列近似平稳 | SimpleImputer(strategy="mean/median/most_frequent") | 引入偏差,掩盖分布 |

| 分组填充 | 有明显分组结构(如各城市收入中位数不同) | df.groupby(c)["x"].transform(lambda s: s.fillna(s.median())) | 小心分组内全空 |

| 插值 | 时间序列、连续变化 | df.interpolate(method="time"/"linear") | 非线性序列别硬插 |

| 模型预测填充 | 缺失有规律、对结果关键 | 用其他列训个回归/分类器预测缺失值 | 二次泄漏风险高,慎用于最终模型 |

from sklearn.impute import SimpleImputer

# ---- 策略 A:数值列用"中位数"填(比均值抗 outliers)----
num_cols = ["年龄", "收入"]
imp_median = SimpleImputer(strategy="median")       # fit 只吃 X_train
X_train_num = imp_median.fit_transform(X_train[num_cols])
X_test_num  = imp_median.transform(X_test[num_cols])
print(imp_median.statistics_)                       # 每个列用的填充值

# ---- 策略 B:类别列用"众数"或专门一个 "缺失" 类 ----
cat_cols = ["城市", "性别"]
imp_mode = SimpleImputer(strategy="most_frequent")
X_train_cat = imp_mode.fit_transform(X_train[cat_cols])
X_test_cat  = imp_mode.transform(X_test[cat_cols])

# ---- 策略 C:分组填充(pandas 原生,最贴近业务)----
# 例:按"城市"分组,缺失收入用同城市中位数填
df["收入"] = df.groupby("城市")["收入"].transform(lambda s: s.fillna(s.median()))
# 若某城市整体缺失,会留 NaN,再兜底一个全局中位数
df["收入"] = df["收入"].fillna(df["收入"].median())

# ---- 策略 D:时间序列插值 ----
# df["余额"] = df["余额"].interpolate(method="time")   # 按时间间隔加权
坑:别在切分训练集之前 df.fillna(df.mean())——那等于把测试集的均值信息泄漏给了训练过程。均值应该只从 X_train 算出来。

3. 类别编码:Label / One-Hot / get_dummies

三种常用编码,适用场景完全不同:

| 方法 | 输出 | 适用 | 陷阱 |

|------|------|------|------|

| LabelEncoder | 一列整数 0,1,2… | 有序类别(如 低/中/高)、树模型内部 | 无序类别用它会引入"大小关系"假象 |

| OneHotEncoder | 每个类别一列 0/1 | 无序类别、线性模型/神经网络 | 高基数类别会爆列数 |

| pd.get_dummies | 同 One-Hot,但 pandas 原生 | 快速探索、小数据 | 测试集出现新类别时会丢列,不工程化 |

from sklearn.preprocessing import OneHotEncoder, LabelEncoder

# ---- 3.1 无序类别:OneHotEncoder(工程首选)----
# sparse_output=False 让它直接返回 DataFrame 友好的 ndarray
ohe = OneHotEncoder(sparse_output=False, handle_unknown="ignore")
X_train_ohe = ohe.fit_transform(X_train[["城市"]])
X_test_ohe  = ohe.transform(X_test[["城市"]])
print(ohe.get_feature_names_out(["城市"]))
# ['城市_上海' '城市_北京' '城市_广州' '城市_深圳' '城市_杭州']

# handle_unknown="ignore" 的意义:测试集出现训练时没见过的"苏州",
# 该样本所有城市列变 0,而不是直接报错——工程必备。

# ---- 3.2 有序类别:LabelEncoder(仅当类别真的有序)----
le = LabelEncoder()
# 例:用户等级 低<中<高,这种"大小关系"是真实的,用整数编码合理
# X_train["等级编码"] = le.fit_transform(X_train["等级"])
# print(list(le.classes_))

# ---- 3.3 pd.get_dummies(探索阶段快,但有坑)----
dummy_train = pd.get_dummies(X_train[["城市"]], prefix="城市")
dummy_test  = pd.get_dummies(X_test[["城市"]],  prefix="城市")
# 坑:若测试集有"苏州"而训练集没有,dummy_test 会多一列;
# 若训练集有"杭州"而测试集没有,dummy_test 会少一列 → 模型维度对不上!
# 解决:用 OneHotEncoder(handle_unknown="ignore"),或手动 reindex 对齐列。
dummy_test = dummy_test.reindex(columns=dummy_train.columns, fill_value=0)

高基数类别(城市 1000+ 种)怎么办:别直接 One-Hot,会爆列。常见做法:

# 目标编码(Target Encoding):用该类别对应的 y 均值替换类别
# 注意:必须在 CV 内做,否则泄漏;这里只示意思路
def target_encode_simple(df, col, target, n_average=10):
    """用 (组内均值*n + 全局均值*n_average) / (n+n_average) 做平滑"""
    glob_mean = df[target].mean()
    agg = df.groupby(col)[target].agg(["mean", "count"])
    smoothed = (agg["mean"] * agg["count"] + glob_mean * n_average) / (agg["count"] + n_average)
    return df[col].map(smoothed)

4. 数值缩放与分箱

4.1 三种缩放

from sklearn.preprocessing import StandardScaler, MinMaxScaler, RobustScaler

# StandardScaler:减均值除标准差 → 均值 0、方差 1(高斯分布假设)
ss = StandardScaler()
X_train_ss = ss.fit_transform(X_train[["年龄", "收入"]])
X_test_ss  = ss.transform(X_test[["年龄", "收入"]])

# MinMaxScaler:压到 [0,1](神经网络 / 图像类特征常用)
mms = MinMaxScaler()
X_train_mm = mms.fit_transform(X_train[["年龄", "收入"]])

# RobustScaler:用中位数和四分位距,抗 outliers(收入这种长尾数据优先用它)
rs = RobustScaler()
X_train_rs = rs.fit_transform(X_train[["年龄", "收入"]])

| 缩放器 | 适合分布 | outliers 敏感? | 何时用 |

|--------|----------|------------------|--------|

| StandardScaler | 近似正态 | 是 | 线性回归、逻辑回归、SVM、PCA |

| MinMaxScaler | 有界 | 是 | 神经网络、图像处理 |

| RobustScaler | 长尾 / 有极端值 | 否 | 收入、金额、血压等医疗数据 |

树模型不需要缩放:RandomForest / XGBoost 对单调变换不敏感,缩放纯属浪费。只有线性模型、SVM、深度学习、距离类算法(KNN / KMeans / PCA)才必须缩放。

4.2 分箱:cut vs qcut

# cut:按"取值范围"等宽切(每箱宽度相同,箱内样本数差异大)
df["收入_bin_cut"] = pd.cut(df["收入"], bins=5, labels=False)
# bins=5 也可以传区间列表:bins=[0, 5000, 10000, 20000, np.inf]

# qcut:按"分位数"切(每箱样本数相同,箱宽不同)
df["收入_bin_qcut"] = pd.qcut(df["收入"], q=5, labels=False)   # 5 分位 = 五档

print(df.groupby("收入_bin_qcut", observed=True)["收入"].agg(["min", "max", "count"]))

| 函数 | 切分依据 | 箱内样本 | 典型用途 |

|------|----------|----------|----------|

| cut | 取值区间等宽 | 不均 | 把"年龄"切成 18-25/25-35/… |

| qcut | 分位数等频 | 均等 | 把"收入"均匀分成五档做评级 |


5. 时间特征提取

dt = pd.to_datetime(df["注册时间"])     # 确保是 datetime64

# ---- 5.1 基础时间分量 ----
feats = pd.DataFrame({
    "年": dt.dt.year,
    "月": dt.dt.month,
    "日": dt.dt.day,
    "星期": dt.dt.weekday + 1,        # 周一=1;pandas 里 weekday 从 0 开始
    "小时": dt.dt.hour,
    "是否周末": (dt.dt.weekday >= 5).astype(int),
    "季度": dt.dt.quarter,
})

# ---- 5.2 周期特征化(把"小时"转成 sin/cos,避免 23 点和 0 点被模型认为差很远)----
feats["小时_sin"] = np.sin(2 * np.pi * dt.dt.hour / 24)
feats["小时_cos"] = np.cos(2 * np.pi * dt.dt.hour / 24)
feats["月_sin"]   = np.sin(2 * np.pi * dt.dt.month / 12)
feats["月_cos"]   = np.cos(2 * np.pi * dt.dt.month / 12)

# ---- 5.3 节假日(需要 chinese_calendar,pip install chinesecalendar)----
try:
    import chinese_calendar as cnc
    feats["是否节假日"] = dt.apply(lambda d: int(cnc.is_holiday(d.date())))
except ImportError:
    # 没装库就先置 0,提示用户装
    feats["是否节假日"] = 0

# ---- 5.4 滞后特征(lag):只在"按时间排序的序列"上做 ----
# 例:用户第 t 天的收入,与 t-1 天 / t-7 天均值对比
# 必须在"按时间切分 train/test"之后做,且 shift 只在 train 上向前取——这就是防泄漏
df_sorted = df.sort_values("注册时间")
df_sorted["收入_lag1"]  = df_sorted["收入"].shift(1)    # 昨天
df_sorted["收入_lag7"]  = df_sorted["收入"].shift(7)    # 一周前
df_sorted["收入_roll7"] = df_sorted["收入"].shift(1).rolling(7).mean()  # 前 7 日均值
铁律:滞后特征用 shift(k) 取的是"过去 k 行",永远不要用 `rolling().mean()` 包含当前行——那等于把未来信息告诉模型。

6. 文本特征:jieba 分词 + TF-IDF

# 安装:pip install jieba scikit-learn
import jieba
from sklearn.feature_extraction.text import TfidfVectorizer

# 6.1 自定义分词器:jieba 切词 + 去停用词
STOPWORDS = set("的 了 和 是 在 我 有 就 不 人 都 也 很 到 说 要 去 会 着".split())

def cn_tokenizer(text):
    words = jieba.lcut(str(text))
    return [w for w in words if w.strip() and w not in STOPWORDS and len(w) > 1]

# 6.2 TfidfVectorizer:把文本列转成稀疏矩阵
# tokenizer 接收分好的词;max_features 限制列数;ngram_range 加短语
tfidf = TfidfVectorizer(
    tokenizer=cn_tokenizer,
    token_pattern=None,          # 用了自定义 tokenizer,要关掉默认正则
    max_features=500,            # 只保留权重最高的 500 个词,防爆列
    ngram_range=(1, 2),          # 同时考虑单字和 2 字词组("物流 快")
)

# fit 只在 X_train 的评论文本上做!测试集只 transform
X_train_tfidf = tfidf.fit_transform(X_train["评论"])
X_test_tfidf  = tfidf.transform(X_test["评论"])
print(X_train_tfidf.shape)       # (n_train, 500)
print(tfidf.get_feature_names_out()[:10])
坑:TfidfVectorizer 的 fit 会统计词频与逆文档频率,fit 全量数据等于让测试集词汇影响训练集权重,标准泄漏。

7. 特征构造与特征选择

7.1 特征构造(人工组合)

# 业务上有意义的组合:
df["收入_年龄比"] = df["收入"] / df["年龄"]          # 单位年龄产出
df["是否年轻高收入"] = ((df["年龄"] < 30) & (df["收入"] > df["收入"].median())).astype(int)
df["注册天数"] = (pd.Timestamp("2025-01-01") - df["注册时间"]).dt.days   # 注意锚点是"现在"

7.2 特征选择三板斧

from sklearn.feature_selection import VarianceThreshold, SelectKBest, f_classif

# A) 方差阈值:去掉几乎只有一个值的列(信息量为 0)
vt = VarianceThreshold(threshold=0.01)
X_vt = vt.fit_transform(X_train_num)
print("方差保留列数:", X_vt.shape[1])

# B) 单变量统计:保留与 y 相关性最高的 k 个特征
skb = SelectKBest(score_func=f_classif, k=10)
X_skb = skb.fit_transform(X_train_num, y_train)

# C) 模型重要性:树模型跑完看 feature_importances_
from sklearn.ensemble import RandomForestClassifier
rf = RandomForestClassifier(n_estimators=100, random_state=42)
rf.fit(X_train_ready, y_train)
imp = pd.Series(rf.feature_importances_, index=feature_names).sort_values(ascending=False)
print(imp.head(10))

| 方法 | 干什么 | 何时用 |

|------|--------|--------|

| 方差阈值 | 删常数列 | 预处理第一步 |

| 相关性分析 | 删冗余特征(两两相关 >0.95 留一个) | 数值特征多的时候 |

| SelectKBest | 按统计检验挑 Top-k | 快速降维 |

| feature_importances_ | 树模型自带 | 树模型训练完直接看 |


8. 训练集/测试集划分与防数据泄漏

这是本章最重要的一节。所有 fit 出来的东西都只吃 X_train:

import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer

# ---------- 第 0 步:分离特征 X 和标签 y ----------
X = df.drop(columns=["是否付费"])
y = df["是否付费"]

# ---------- 第 1 步:先切分!顺序永远是 split 在最前 ----------
# stratify=y 保证训练/测试集正负样本比例一致(分类问题强烈建议)
# random_state 固定,结果可复现
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y
)

# ---------- 第 2 步:所有"统计量类"转换器只 fit(X_train) ----------
# 2.1 缺失值
num_cols = ["年龄", "收入"]
imp_num = SimpleImputer(strategy="median").fit(X_train[num_cols])
X_train_num = imp_num.transform(X_train[num_cols])
X_test_num  = imp_num.transform(X_test[num_cols])

# 2.2 缩放
scaler = StandardScaler().fit(X_train_num)         # 注意 fit 在已经 impute 过的数据上
X_train_num = scaler.transform(X_train_num)
X_test_num  = scaler.transform(X_test_num)

# 2.3 类别编码
cat_cols = ["城市", "性别"]
imp_cat = SimpleImputer(strategy="most_frequent").fit(X_train[cat_cols])
X_train_cat = imp_cat.transform(X_train[cat_cols])
X_test_cat  = imp_cat.transform(X_test[cat_cols])

ohe = OneHotEncoder(sparse_output=False, handle_unknown="ignore").fit(X_train_cat)
X_train_cat = ohe.transform(X_train_cat)
X_test_cat  = ohe.transform(X_test_cat)

# ---------- 第 3 步:拼回特征矩阵,喂模型 ----------
from sklearn.linear_model import LogisticRegression
X_train_ready = np.hstack([X_train_num, X_train_cat])
X_test_ready  = np.hstack([X_test_num, X_test_cat])

clf = LogisticRegression(max_iter=1000).fit(X_train_ready, y_train)
print("测试集 AUC:", clf.score(X_test_ready, y_test))

典型泄漏反面教材:

# ❌ 错误 1:先 fillna 再 split——测试集均值泄漏
df["收入"] = df["收入"].fillna(df["收入"].mean())
X_train, X_test = train_test_split(df, ...)

# ❌ 错误 2:scaler.fit 全量数据
scaler = StandardScaler().fit(df[num_cols])

# ❌ 错误 3:时间序列随机切分——未来数据被拿去训练
# 时间序列必须按时间切:X_train = df[df.time < cut]; X_test = df[df.time >= cut]

# ❌ 错误 4:滞后特征用了未来行(shift(-1) 或者 rolling 含当前行)

# ✅ 正确:用 sklearn Pipeline 把上面一串包起来,fit 一次,自动防泄漏
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer

num_pipe = Pipeline([("imp", SimpleImputer(strategy="median")),
                     ("sc", StandardScaler())])
cat_pipe = Pipeline([("imp", SimpleImputer(strategy="most_frequent")),
                     ("ohe", OneHotEncoder(handle_unknown="ignore"))])

pre = ColumnTransformer([("num", num_pipe, num_cols),
                         ("cat", cat_pipe, cat_cols)])

final_pipe = Pipeline([("pre", pre), ("clf", LogisticRegression(max_iter=1000))])
final_pipe.fit(X_train, y_train)        # 只吃 X_train,自动按列分发
print("AUC:", final_pipe.score(X_test, y_test))

9. 常见坑与速查表

9.1 速查表

| 任务 | 首选工具 | 关键参数 |

|------|----------|----------|

| 缺失数值列 | SimpleImputer(median) | 抗 outliers |

| 缺失类别列 | SimpleImputer(most_frequent) 或补 "缺失" 类 | 给模型一个显式信号 |

| 时间序列缺失 | Series.interpolate(method="time") | 按时间加权 |

| 无序类别 | OneHotEncoder(handle_unknown="ignore") | 防测试集新类别报错 |

| 有序类别 | LabelEncoder / pandas map | 确认真有大小关系 |

| 高基数类别 | Target Encoding / Embedding | CV 内做 |

| 线性模型缩放 | StandardScaler | fit 仅在 train |

| 长尾数值缩放 | RobustScaler | 收入/金额首选 |

| 神经网络缩放 | MinMaxScaler | 压到 [0,1] |

| 等宽分箱 | pd.cut | 区间自定义 |

| 等频分箱 | pd.qcut | 分位数 |

| 时间周期特征 | sin/cos | 小时、月 |

| 中文文本 | jieba + TfidfVectorizer | tokenizer + token_pattern=None |

| 删常数列 | VarianceThreshold | 预处理第一步 |

| 防泄漏 | Pipeline + ColumnTransformer | fit 只吃 train |

9.2 常见坑

| 坑 | 现象 | 解决 |

|----|------|------|

| 在 split 前 fillna / scale | 测试集指标虚高 | 所有 fit 移到 split 之后 |

| pd.get_dummies 后列不对齐 | 训练/测试列数不同 | reindex(columns=train.columns, fill_value=0) 或换 OneHotEncoder |

| 树模型也缩放 | 训练变慢,无收益 | 树模型跳过缩放 |

| LabelEncoder 用在无序类别 | 模型学到假的大小关系 | 换 One-Hot 或 Target Encoding |

| TfidfVectorizer fit 全量 | 文本特征泄漏 | fit 仅在 X_train |

| 时间序列随机 split | 测试集 AUC 虚高 | 按时间切分 |

| 滞后特征含当前行 | 模型作弊 | shift(1) 起步,rolling 只包过去 |

| 类别列 fillna 为字符串"nan" | 误把 NaN 当一个类别 | 要么显式 "缺失",要么先 imputer |


10. 本章小结与练习

小结

  • 缺失值:少量删除、统计量填充、分组填充、时间插值;fit 都只吃 X_train;
  • 类别编码:无序用 OneHot(handle_unknown="ignore"),有序用 Label,高基数用 Target Encoding;
  • 数值缩放:Standard / MinMax / Robust 按分布选;树模型不用缩;
  • 分箱:cut 等宽、qcut 等频;
  • 时间特征:年/月/星期/小时 + sin/cos 周期化 + 滞后/滚动;
  • 文本:jieba 分词 + TfidfVectorizer;
  • 特征选择:方差 → 相关性 → SelectKBest → feature_importances;
  • 防泄漏:split 永远第一步,fit 永远只吃 X_train,Pipeline 包一串最稳。

练习题

1. 给一份含 5% 缺失的数值列,分别用均值、中位数、分组中位数填充,对比 fillna 前后该列的分布。

2. 对"城市"列做 OneHotEncoder,故意在测试集里塞一个新城市,观察 handle_unknown="ignore" 的效果。

3. 用 RobustScaler 处理一个有极端收入的值,画缩放前后直方图对比。

4. 从一个 datetime 列提取出 年/月/星期/是否周末/小时_sin/小时_cos 共 7 个特征。

5. 把第 8 节那套流程用 Pipeline + ColumnTransformer 完整重写一遍,并故意制造一次"先 fillna 再 split"的泄漏,对比 AUC 差异。


下一篇预告:第 36 章 pandas 与 sklearn 建模完整流程 —— 从预处理到交叉验证、调参与模型评估的端到端实战。
— 全文完 —回到顶部 ↑
下载推广海报

文章推广海报

《第 35 章 特征工程与数据预处理》完整推广海报
DISCUSSION

文章回复

0 条公开回复
未登录回复需要审核后公开
还没有回复,欢迎参与讨论。