哈喽,大家好~
今儿,咱们来聊聊,归一化方法中分位数变换的逻辑~
之前有人提过,特征偏得厉害,StandardScaler 处理完还是不好用,怎么办?
可以试试分位数变换~
什么是分位数变换
普通标准化关注的是均值和标准差:
问题在于,均值和标准差很容易被极端值拉偏。
比如大部分用户消费金额在 100 元左右,少数用户消费 10 万元。经过标准化以后,大量普通用户的数据会被挤在很小的范围内。
分位数变换换了一个思路:不太关心原始数值有多大,而是关注它排在什么位置。
假设数据为:
数字 1000 虽然特别大,但从排序角度看,它只是排在第 100% 分位。这样一来,极端值的影响就被限制住了。
核心原理
分位数变换通常分为两步。
第一步,使用经验累积分布函数,把原始数据映射为分位数:
其中, 表示小于等于 的数据比例, 位于 之间。
第二步,根据目标分布继续映射:
是目标分布的逆累积分布函数。如果目标是均匀分布,直接保留 ;如果目标是标准正态分布,就使用正态分布的逆函数。
一句话概括:先把数值换成排名,再把排名放进指定分布。
它是一种非线性变换,但不会改变样本在单个特征上的大小顺序。
完整案例
我们构造一个二分类数据集,再对特征进行指数、三次方变换,并加入少量异常值。这样更接近业务中常见的偏态数据。
下面同时完成两组分析:
-
对比变换前后的分类空间和决策边界; -
逐渐增加异常值比例,比较标准化与分位数变换的稳定性。
import numpy as np
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler, QuantileTransformer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.metrics import accuracy_score
# 构造带偏态和异常值的数据
def make_data(n=1600, outlier_rate=0.05, seed=42):
rng = np.random.default_rng(seed)
# 潜在特征原本接近正态分布
Z = rng.normal(size=(n, 2))
score = Z[:, 0] + 0.8 * Z[:, 1] + rng.normal(0, 0.55, n)
y = (score > 0).astype(int)
# 使用非线性变换制造明显偏态
X = np.empty_like(Z)
X[:, 0] = np.sign(Z[:, 0]) * np.expm1(1.5 * np.abs(Z[:, 0]))
X[:, 1] = Z[:, 1] ** 3
# 加入随机极端值
k = int(n * outlier_rate)
idx = rng.choice(n, k, replace=False)
X[idx] += rng.normal(0, 35, size=(k, 2))
return X, y
X, y = make_data()
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, stratify=y, random_state=42
)
# 模型1:标准化 + 逻辑回归
standard_model = make_pipeline(
StandardScaler(),
LogisticRegression(max_iter=2000)
)
standard_model.fit(X_train, y_train)
# 模型2:分位数变换 + 逻辑回归
qt = QuantileTransformer(
n_quantiles=300,
output_distribution="normal",
random_state=42
)
X_train_q = qt.fit_transform(X_train)
X_test_q = qt.transform(X_test)
quantile_model = LogisticRegression(max_iter=2000)
quantile_model.fit(X_train_q, y_train)
acc_std = accuracy_score(y_test, standard_model.predict(X_test))
acc_qt = accuracy_score(y_test, quantile_model.predict(X_test_q))
# 绘制决策空间
def draw_boundary(ax, X_plot, y_plot, model, title):
x1_min, x1_max = np.percentile(X_plot[:, 0], [1, 99])
x2_min, x2_max = np.percentile(X_plot[:, 1], [1, 99])
xx, yy = np.meshgrid(
np.linspace(x1_min, x1_max, 260),
np.linspace(x2_min, x2_max, 260)
)
grid = np.c_[xx.ravel(), yy.ravel()]
prob = model.predict_proba(grid)[:, 1].reshape(xx.shape)
ax.contourf(
xx, yy, prob,
levels=np.linspace(0, 1, 12),
cmap="cool",
alpha=0.65
)
ax.contour(xx, yy, prob, levels=[0.5],
colors="#FFF200", linewidths=2.5)
ax.scatter(
X_plot[:, 0], X_plot[:, 1],
c=y_plot, cmap="spring",
s=22, edgecolors="white", linewidths=0.25, alpha=0.8
)
ax.set_title(title, fontsize=13, fontweight="bold")
ax.set_xlabel("Feature 1")
ax.set_ylabel("Feature 2")
fig, axes = plt.subplots(1, 2, figsize=(13, 5))
draw_boundary(
axes[0], X_test, y_test, standard_model,
f"StandardScaler + LR | Accuracy={acc_std:.3f}"
)
draw_boundary(
axes[1], X_test_q, y_test, quantile_model,
f"QuantileTransformer + LR | Accuracy={acc_qt:.3f}"
)
plt.suptitle(
"Decision Space Before and After Quantile Transform",
color="#00F5FF", fontsize=16, fontweight="bold"
)
plt.tight_layout()
plt.show()
# 分析不同异常值比例下的稳定性
rates = np.linspace(0, 0.15, 7)
std_scores, qt_scores = [], []
for rate in rates:
one_std, one_qt = [], []
for seed in range(8):
X, y = make_data(outlier_rate=rate, seed=seed)
Xtr, Xte, ytr, yte = train_test_split(
X, y, test_size=0.3, stratify=y, random_state=seed
)
m1 = make_pipeline(
StandardScaler(),
LogisticRegression(max_iter=2000)
)
m2 = make_pipeline(
QuantileTransformer(
n_quantiles=300,
output_distribution="normal",
random_state=seed
),
LogisticRegression(max_iter=2000)
)
m1.fit(Xtr, ytr)
m2.fit(Xtr, ytr)
one_std.append(m1.score(Xte, yte))
one_qt.append(m2.score(Xte, yte))
std_scores.append(one_std)
qt_scores.append(one_qt)
std_scores = np.array(std_scores)
qt_scores = np.array(qt_scores)
plt.figure(figsize=(9, 5))
for scores, color, label in [
(std_scores, "#FF2D95", "StandardScaler"),
(qt_scores, "#00E5FF", "QuantileTransformer")
]:
mean = scores.mean(axis=1)
std = scores.std(axis=1)
plt.plot(rates, mean, marker="o", linewidth=3,
color=color, label=label)
plt.fill_between(rates, mean - std, mean + std,
color=color, alpha=0.18)
plt.xlabel("Outlier Rate")
plt.ylabel("Test Accuracy")
plt.title("Model Robustness Under Increasing Outliers",
fontsize=15, fontweight="bold")
plt.grid(alpha=0.2)
plt.legend()
plt.tight_layout()
plt.show()
第一张图展示的是联合特征空间和模型决策边界。左侧原始空间被偏态和极端值拉伸,普通样本容易挤在一起;右侧经过分位数变换后,样本展开得更均匀,逻辑回归更容易找到有效边界。
第二张图不是简单看一次准确率,而是逐步提高异常值比例,并在多个随机数据集上重复实验。实线表示平均准确率,阴影表示波动范围。
如果青色曲线下降更慢,说明分位数变换面对极端值更加稳定。总之就是说,它不是让异常值消失,而是限制异常值对尺度和模型参数的支配能力。
注意点
output_distribution="normal"适合逻辑回归、神经网络等偏好规则尺度的模型;设置为"uniform"则会把特征映射到
。
n_quantiles控制经验分位点数量。数值越大,映射越细,但不能超过训练样本数。实际使用时必须只在训练集上fit,否则会发生数据泄漏。
另外,分位数变换会改变样本间的原始距离,也会压缩极端值信息。如果异常高金额本身具有明确业务意义,就不能不加判断地使用。对于决策树这类基于排序切分的模型,收益通常也没有线性模型明显。
总结
分位数变换的核心,就是把“原始数值”换成“数据排名”,再映射到均匀分布或正态分布。它特别适合偏态明显、异常值较多,同时又依赖特征尺度的模型。
接下来大家可以继续尝试对比RobustScaler,看看谁在不同异常值比例下更稳;也可以把逻辑回归换成KNN或神经网络,观察距离结构变化以后,模型效果会发生什么变化~

