哈喽,大家好~
最近有同学问到:数据没有缺失值,也没有重复值,为什么模型效果还是不稳定?
我觉得是,数据尺度和分布,可能还没处理好
数据清洗解决的是“数据能不能用”,数据转换解决的则是“模型能不能更容易看懂”~
今儿咱们就来聊聊,常见的数据转换方法,看看不同转换方式如何改变数据空间和模型效果。
数据转换
假设一份数据中,收入范围是几千到几十万,而年龄只有十几到几十。如果直接交给模型,收入这个特征很容易因为数值大而占据主导。
数据转换,就是在尽量保留信息的前提下,调整特征的尺度或分布。
常见方法主要有这几类:
-
标准化:让数据均值接近0、标准差接近1。 -
归一化:把数据压缩到固定区间,例如 。 -
幂变换:改善偏态分布,让长尾数据更对称。 -
分位数变换:根据数据排序,将其映射到均匀分布或正态分布。
像逻辑回归、KNN、SVM和神经网络,对特征尺度都比较敏感。树模型受到的影响相对较小,因为它更关注特征的切分顺序。
也就是,数据转换不是增加新信息,而是把原来的信息换成模型更容易学习的表达方式。
核心原理
最常用的是标准化:
其中, 是原始值, 是特征均值, 是标准差。转换后的数据以0为中心,并具有相近的尺度。
但要注意,标准化只能改变数值大小,不能真正改变分布形状。一个严重右偏的特征,标准化以后依然右偏。
对于正值数据,Box-Cox幂变换可以写成:
控制转换强度。sklearn中的Yeo-Johnson变换原理类似,但它还能处理0和负数,实际使用更加方便。
分位数变换则先估计累计分布 ,再映射到标准正态分布:
这里 是标准正态分布的逆累计分布函数。你可以这样理解:它不太关心原始数值相差多少,而更关心每个样本排在什么位置。
完整案例
我们构造两个严重偏态的特征,并加入少量极端值。模型使用逻辑回归,比较原始数据、标准化、幂变换和分位数变换。
import numpy as np
import matplotlib.pyplot as plt
from sklearn.base import clone
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import (
FunctionTransformer, StandardScaler,
PowerTransformer, QuantileTransformer
)
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, f1_score, roc_auc_score
np.random.seed(42)
# 1. 数据
n = 2200
z = np.random.normal(size=(n, 2))
# 非线性单调变换,制造偏态和尺度差异
X = np.column_stack([
np.exp(1.2 * z[:, 0]),
np.sign(z[:, 1]) * np.abs(z[:, 1]) ** 3
])
# 添加少量极端值
idx = np.random.choice(n, int(n * 0.02), replace=False)
X[idx, 0] *= 8
X[idx, 1] *= 4
y = (
1.2 * z[:, 0] - 0.9 * z[:, 1]
+ 0.35 * np.random.randn(n) > 0
).astype(int)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, stratify=y, random_state=42
)
transformers = {
"Raw": FunctionTransformer(),
"Standard": StandardScaler(),
"Power": PowerTransformer(method="yeo-johnson"),
"Quantile": QuantileTransformer(
n_quantiles=300,
output_distribution="normal",
random_state=42
)
}
colors = ["#00E5FF", "#FF2E93"]
results, fitted = {}, {}
# 2. 训练不同数据转换下的模型
for name, trans in transformers.items():
t = clone(trans)
Xtr = t.fit_transform(X_train)
Xte = t.transform(X_test)
model = LogisticRegression(C=1.0, max_iter=2000)
model.fit(Xtr, y_train)
pred = model.predict(Xte)
prob = model.predict_proba(Xte)[:, 1]
results[name] = [
accuracy_score(y_test, pred),
f1_score(y_test, pred),
roc_auc_score(y_test, prob)
]
fitted[name] = (t, model, Xte)
# 3. 图1:转换空间与分类边界
fig, axes = plt.subplots(2, 2, figsize=(12, 9))
for ax, (name, (_, model, Xte)) in zip(axes.flat, fitted.items()):
# 限制到主要数据区域,避免极端值压缩画面
x1_min, x1_max = np.percentile(Xte[:, 0], [1, 99])
x2_min, x2_max = np.percentile(Xte[:, 1], [1, 99])
xx, yy = np.meshgrid(
np.linspace(x1_min, x1_max, 220),
np.linspace(x2_min, x2_max, 220)
)
grid = np.c_[xx.ravel(), yy.ravel()]
zz = model.predict_proba(grid)[:, 1].reshape(xx.shape)
ax.contourf(
xx, yy, zz, levels=np.linspace(0, 1, 12),
cmap="plasma", alpha=0.38
)
ax.contour(xx, yy, zz, levels=[0.5],
colors="#39FF14", linewidths=2.5)
for label, color in zip([0, 1], colors):
mask = y_test == label
ax.scatter(
Xte[mask, 0], Xte[mask, 1],
s=17, c=color, alpha=0.65,
edgecolors="none", label=f"Class {label}"
)
ax.set_title(
f"{name} | F1={results[name][1]:.3f}",
fontsize=13, fontweight="bold"
)
ax.set_xlabel("Feature 1")
ax.set_ylabel("Feature 2")
axes[0, 0].legend()
fig.suptitle(
"Feature Transformation and Decision Boundary",
fontsize=16, fontweight="bold"
)
plt.tight_layout()
plt.show()
# 4. 图2:不同C值的稳定性 + 综合指标
C_values = np.logspace(-3, 2, 10)
score_curve = {}
for name, trans in transformers.items():
scores = []
for C in C_values:
t = clone(trans)
Xtr = t.fit_transform(X_train)
Xte = t.transform(X_test)
model = LogisticRegression(C=C, max_iter=2000)
model.fit(Xtr, y_train)
scores.append(f1_score(y_test, model.predict(Xte)))
score_curve[name] = scores
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
line_colors = ["#FF1744", "#00B0FF", "#76FF03", "#D500F9"]
for (name, scores), color in zip(score_curve.items(), line_colors):
axes[0].plot(
C_values, scores, marker="o",
linewidth=2.5, color=color, label=name
)
axes[0].set_xscale("log")
axes[0].set_xlabel("Regularization parameter C")
axes[0].set_ylabel("F1 score")
axes[0].set_title("Model Stability under Different C")
axes[0].legend()
names = list(results.keys())
metrics = np.array(list(results.values()))
x = np.arange(len(names))
width = 0.23
metric_names = ["Accuracy", "F1", "ROC-AUC"]
bar_colors = ["#00E5FF", "#FF4081", "#76FF03"]
for i in range(3):
axes[1].bar(
x + (i - 1) * width,
metrics[:, i],
width,
label=metric_names[i],
color=bar_colors[i]
)
axes[1].set_xticks(x)
axes[1].set_xticklabels(names)
axes[1].set_ylim(0.5, 1.0)
axes[1].set_title("Overall Model Performance")
axes[1].legend()
plt.tight_layout()
plt.show()
print("Accuracy / F1 / ROC-AUC")
for name, value in results.items():
print(name, np.round(value, 3))
第一张图的重点是观察绿色分类边界与两类样本的关系。
原始数据中,极端值会拉伸坐标空间。标准化统一了尺度,但没有消除偏态;幂变换和分位数变换能够重新整理数据形状,让潜在关系更接近线性,因此逻辑回归更容易找到有效边界。
第二张图左侧展示不同正则化参数 下的F1变化。
曲线越高、越平稳,说明模型对参数越不敏感。右侧同时比较Accuracy、F1和ROC-AUC,避免只靠一个指标下结论。
实际项目中,转换器必须只在训练集上执行fit,再用同一个转换器处理验证集和测试集。否则测试集信息会提前进入训练过程,造成数据泄漏。
总结
数据转换的核心,不是机械地把所有特征都变成正态分布,而是让特征尺度、分布形状和模型假设更加匹配。标准化解决尺度问题,幂变换处理偏态,分位数变换对长尾和极端值更有优势。
接下来大家可以把逻辑回归换成KNN或SVM,观察距离模型对转换方式的敏感程度;也可以配合交叉验证,把转换方法作为Pipeline的一部分统一调参。总之就是说,先把数据表达处理好,再调模型,往往会省下不少时间~

