大数跨境

导师:“为什么你模型一训练就崩!” 我:“代码没错啊…” 导师:“八成是没做特征缩放,收敛慢、结果飘,全都藏在这一步里”

导师:“为什么你模型一训练就崩!” 我:“代码没错啊…” 导师:“八成是没做特征缩放,收敛慢、结果飘,全都藏在这一步里” 机器学习和人工智能AI
2026-07-20
6
导读:哈喽,大家好~今儿和大家聊聊:数据预处理方法,特征缩放。

哈喽,大家好~

今儿和大家聊聊:数据预处理方法,特征缩放。

特征缩放作为预处理的核心步骤之一,直接影响距离度量类模型、基于梯度下降的优化以及带正则化的线性模型的性能、稳定性与收敛速度

简单来说,特征缩放的目标是控制不同特征量纲的影响,使模型的学习过程更稳健、更公平、更高效。

为什么需要特征缩放

距离度量的公平性

多数算法隐含地使用欧氏距离或内积作为度量与相似的定义。

若不同特征的量纲差异巨大,例如一个特征以“千”为单位,另一个以“个位”为单位,欧氏距离:

将被高量纲的特征主导,致使模型忽略其他特征的重要信息。

这会导致:

  • kNN分类器错误地选择近邻;
  • K-means聚类的质心与簇划分被“尺度最大”的特征严重影响;
  • 基于核函数的SVM在相似度计算中产生偏置。

缩放后,各特征在距离计算中权重更加均衡,提升模型对多维信息的敏感度。

梯度下降法的收敛与条件数:

考虑凸二次优化,如线性回归的最小二乘问题的目标函数近似为:

其中 是海森矩阵(例如 )。梯度下降的收敛速度与条件数 密切相关。若特征尺度差异巨大, 会很大,导致梯度下降沿某些方向过快、另一些方向过慢,训练过程振荡或缓慢收敛。

标准化后, 更接近单位矩阵,其条件数得到改善,从而显著提升优化的收敛速度与稳定性。

正则化的公平性(L2/L1):

带正则化的线性模型通过惩罚项约束参数大小:

  • L2正则:
  • L1正则:

如果特征尺度不同,参数的相对大小并不能公平反映特征的“重要性”:为了匹配一个尺度大的特征,模型可能需要学到较小的参数;

相反,对于尺度小的特征,参数可能较大。

这会导致正则化在不同特征上施加不公平的惩罚。缩放后的各特征在近似同一尺度下,“统一的惩罚”机制才更合理。

SVM间隔与缩放不变性的破坏:

线性SVM的原始问题是:

若对特征进行缩放 ,为了得到相同的分类结果,需要相应调整权重为 。然而由于正则化项是 ,在不同缩放下该项的相对大小会改变,导致最优解发生变化。

因此,缩放影响SVM的“间隔”,以及最优解的形状与方向。这就是为什么在SVM中强烈建议先做适当的缩放。

常见特征缩放方法

Z-Score标准化:

对每个维度 ,定义均值 与标准差 ,标准化变换为:

在矩阵形式中,设数据矩阵 ,均值向量 ,标准差对角矩阵 ,标准化为:

性质:

  • 使各特征均值约为0、方差约为1;
  • 不一定改变数据分布的形状(非高斯数据仍然非高斯);
  • 对于梯度法、正则化、距离度量较友好。

最小-最大缩放:

将每个特征线性映射到固定区间(通常是 ):

或映射到

性质:

  • 保持相对顺序;
  • 对异常值敏感(max/min极端值会改变缩放范围);
  • 在使用激活函数有界模型(如某些神经网络)或需要标准范围时常用。

稳健缩放:

使用统计上更抗异常值的指标:中位数与四分位间距

性质:

  • 稳健性强,降低异常值影响;
  • 使数据居中于0附近但不保证方差为1;
  • 更适合重尾分布或频繁异常值的场景。

单位向量缩放

对样本向量进行归一化(常用于文本特征TF-IDF):

L2归一化:

L1归一化:

性质:

  • 使每个样本的整体尺度一致;
  • 更适合基于余弦相似度的任务;
  • 不改变各特征的相对比率。

对数变换与幂变换:

对正值特征,为降低偏度与重尾:

  • 对数变换:
  • Box-Cox(要求正值):
  • Yeo-Johnson(可处理零和负值,常见于scikit-learn的PowerTransformer)。

性质:

  • 改善偏度、使分布更近似高斯;
  • 常与标准化组合使用。

白化:

白化将协方差矩阵变为单位阵,使特征去相关、方差为1。设数据零均值,协方差 ,其中 为特征向量, 是特征值。PCA白化:

可验证:

ZCA白化(保留原数据方向感):

性质:

  • 去相关、单位方差;
  • 对噪声敏感,需要正则化(如加 );
  • 常用于视觉特征的预处理、某些深度学习管线。

归一化层

深度学习中,归一化层在训练过程中动态缩放:

BatchNorm(按批统计):

LayerNorm(按通道/特征统计),在NLP与Transformer中常见。

性质:

  • 加速训练、稳定梯度;
  • 在推理阶段使用移动平均统计量;
  • 与传统预处理缩放思想一致,但融入网络参数学习与动态性。

完整案例

数据集

这里,我们构造一个三分类问题(类别A/B/C),包含6个特征,特征分布有意引入尺度差异与重尾分布,以及异常值。

  • f1:正态分布(中等尺度),不同类均值略有差异;
  • f2:大尺度正态分布(均值在千级),作为“量纲巨大”的特征;
  • f3:对数正态(重尾)并手动引入少量极端异常值;
  • f4:Poisson计数型(非负,偏度较大);
  • f5:二元特征(0/1),影响有限但与其他特征相关;
  • f6:与f1线性相关(加入噪声),模拟共线性。

目标:比较不同缩放策略(无缩放、StandardScaler、MinMaxScaler、RobustScaler)对模型训练(SVM、KNN、逻辑回归)的影响,展示分布变化、决策边界与优化收敛。

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

from sklearn.preprocessing import StandardScaler, MinMaxScaler, RobustScaler
from sklearn.model_selection import train_test_split, cross_val_score, StratifiedKFold
from sklearn.svm import SVC
from sklearn.neighbors import KNeighborsClassifier
from sklearn.decomposition import PCA
from sklearn.metrics import accuracy_score, f1_score, classification_report, confusion_matrix
from sklearn.pipeline import Pipeline

import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import TensorDataset, DataLoader

np.random.seed(42)
torch.manual_seed(42)

# 1) 数据集:三类,六个特征
def generate_data(n_per_class=500):
    # 类A
    f1_A = np.random.normal(loc=50, scale=10, size=n_per_class)
    f2_A = np.random.normal(loc=1200, scale=200, size=n_per_class)  # 大尺度
    f3_A = np.random.lognormal(mean=2.0, sigma=0.5, size=n_per_class)
    f4_A = np.random.poisson(lam=8, size=n_per_class)
    f5_A = np.random.binomial(n=1, p=0.3, size=n_per_class)
    f6_A = f1_A * 0.8 + np.random.normal(loc=0, scale=5, size=n_per_class)  # 与f1相关

    # 类B
    f1_B = np.random.normal(loc=60, scale=12, size=n_per_class)
    f2_B = np.random.normal(loc=1500, scale=220, size=n_per_class)
    f3_B = np.random.lognormal(mean=2.2, sigma=0.6, size=n_per_class)
    f4_B = np.random.poisson(lam=12, size=n_per_class)
    f5_B = np.random.binomial(n=1, p=0.5, size=n_per_class)
    f6_B = f1_B * 0.85 + np.random.normal(loc=0, scale=6, size=n_per_class)

    # 类C
    f1_C = np.random.normal(loc=55, scale=9, size=n_per_class)
    f2_C = np.random.normal(loc=1000, scale=180, size=n_per_class)
    f3_C = np.random.lognormal(mean=1.8, sigma=0.7, size=n_per_class)
    f4_C = np.random.poisson(lam=5, size=n_per_class)
    f5_C = np.random.binomial(n=1, p=0.4, size=n_per_class)
    f6_C = f1_C * 0.75 + np.random.normal(loc=0, scale=4, size=n_per_class)

    # 汇总
    X_A = np.vstack([f1_A, f2_A, f3_A, f4_A, f5_A, f6_A]).T
    X_B = np.vstack([f1_B, f2_B, f3_B, f4_B, f5_B, f6_B]).T
    X_C = np.vstack([f1_C, f2_C, f3_C, f4_C, f5_C, f6_C]).T

    X = np.vstack([X_A, X_B, X_C])
    y = np.array([0]*n_per_class + [1]*n_per_class + [2]*n_per_class)

    # 注入异常值:在f2和f3中添加部分极端值
    n_outliers = int(0.02 * X.shape[0])
    outlier_idx = np.random.choice(X.shape[0], size=n_outliers, replace=False)
    X[outlier_idx, 1] *= np.random.uniform(2.54.0, size=n_outliers)  # 放大f2
    X[outlier_idx, 2] *= np.random.uniform(3.05.0, size=n_outliers)  # 放大f3

    columns = ['f1''f2''f3''f4''f5''f6']
    df = pd.DataFrame(X, columns=columns)
    df['y'] = y
    return df

df = generate_data(n_per_class=500)
X = df[['f1''f2''f3''f4''f5''f6']].values
y = df['y'].values

# 2) 划分训练和测试集
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.25, random_state=42, stratify=y
)

# 3) 定义缩放器
scalers = {
    'none'None,
    'standard': StandardScaler(),
    'minmax': MinMaxScaler(),
    'robust': RobustScaler()
}

# 4) 模型:SVM与kNN,比较不同缩放器
def evaluate_models_with_scalers(X_train, y_train, X_test, y_test, scalers):
    results = []
    for name, scaler in scalers.items():
        if scaler isnotNone:
            scaler.fit(X_train)
            Xtr = scaler.transform(X_train)
            Xte = scaler.transform(X_test)
        else:
            Xtr = X_train.copy()
            Xte = X_test.copy()

        # SVM
        svm = SVC(kernel='rbf', gamma='scale', C=1.0, random_state=42)
        svm.fit(Xtr, y_train)
        y_pred_svm = svm.predict(Xte)
        acc_svm = accuracy_score(y_test, y_pred_svm)
        f1_svm = f1_score(y_test, y_pred_svm, average='macro')

        # KNN
        knn = KNeighborsClassifier(n_neighbors=7)
        knn.fit(Xtr, y_train)
        y_pred_knn = knn.predict(Xte)
        acc_knn = accuracy_score(y_test, y_pred_knn)
        f1_knn = f1_score(y_test, y_pred_knn, average='macro')

        results.append({
            'scaler': name,
            'svm_acc': acc_svm, 'svm_f1': f1_svm,
            'knn_acc': acc_knn, 'knn_f1': f1_knn
        })
    return pd.DataFrame(results)

results_df = evaluate_models_with_scalers(X_train, y_train, X_test, y_test, scalers)
print("缩放器对SVM与KNN性能的影响:")
print(results_df)

# 5) PCA降维用于决策边界可视化(2D)
def pca_2d_transform(X_train, X_test, scaler):
    if scaler isnotNone:
        scaler.fit(X_train)
        Xtr = scaler.transform(X_train)
        Xte = scaler.transform(X_test)
    else:
        Xtr = X_train.copy()
        Xte = X_test.copy()
    pca = PCA(n_components=2, random_state=42)
    pca.fit(Xtr)
    return pca.transform(Xtr), pca.transform(Xte), pca

# 6) PyTorch逻辑回归(多类)用于收敛曲线展示
class LogisticRegressionTorch(nn.Module):
    def __init__(self, in_features, num_classes):
        super().__init__()
        self.linear = nn.Linear(in_features, num_classes)
    def forward(self, x):
        return self.linear(x)

def train_logreg_torch(X_train, y_train, X_test, y_test, scaler, num_epochs=100, lr=0.05, batch_size=64):
    # 缩放
    if scaler isnotNone:
        scaler.fit(X_train)
        Xtr = scaler.transform(X_train)
        Xte = scaler.transform(X_test)
    else:
        Xtr = X_train.copy()
        Xte = X_test.copy()

    # 张量
    Xtr_t = torch.tensor(Xtr, dtype=torch.float32)
    ytr_t = torch.tensor(y_train, dtype=torch.long)
    Xte_t = torch.tensor(Xte, dtype=torch.float32)
    yte_t = torch.tensor(y_test, dtype=torch.long)

    dataset = TensorDataset(Xtr_t, ytr_t)
    loader = DataLoader(dataset, batch_size=batch_size, shuffle=True)

    model = LogisticRegressionTorch(in_features=X_train.shape[1], num_classes=len(np.unique(y_train)))
    optimizer = optim.SGD(model.parameters(), lr=lr)
    criterion = nn.CrossEntropyLoss()

    losses = []
    accs = []

    for epoch in range(num_epochs):
        model.train()
        epoch_loss = 0.0
        for xb, yb in loader:
            optimizer.zero_grad()
            logits = model(xb)
            loss = criterion(logits, yb)
            loss.backward()
            optimizer.step()
            epoch_loss += loss.item() * xb.shape[0]

        epoch_loss /= len(loader.dataset)
        losses.append(epoch_loss)

        # 评估
        model.eval()
        with torch.no_grad():
            logits_te = model(Xte_t)
            preds_te = torch.argmax(logits_te, dim=1)
            acc = (preds_te == yte_t).float().mean().item()
            accs.append(acc)
    return losses, accs

# 7) 可视化
def plot_complex_figure(df, X_train, y_train, X_test, y_test, scalers):
    plt.figure(figsize=(94))

    # 子图1:原始数据的分布分析(选f2、f3、f6),各类别KDE叠加
    ax1 = plt.subplot(221)
    colors = ['#FF006E''#3A86FF''#FFBE0B']
    for i, feat in enumerate(['f2''f3''f6']):
        for cls in [0,1,2]:
            sns.kdeplot(
                df.loc[df['y']==cls, feat],
                ax=ax1,
                color=colors[cls],
                lw=2,
                label=f'{feat}-class{cls}'if i==0elseNone,
                alpha=0.7
            )
    ax1.set_title('子图1:未缩放下的关键特征分布(f2、f3、f6)', fontsize=12)
    ax1.set_xlabel('值域')
    ax1.set_ylabel('密度')
    ax1.legend(loc='upper right', ncol=2)

    # 子图2:不同缩放器后的分布对比(箱线图,选f2、f3、f6)
    ax2 = plt.subplot(222)
    feat_sel = ['f2''f3''f6']
    plot_data = []
    for name, scaler in scalers.items():
        if scaler isnotNone:
            scaler.fit(X_train)
            Xtr = scaler.transform(X_train)
        else:
            Xtr = X_train.copy()
        df_tr = pd.DataFrame(Xtr, columns=['f1''f2''f3''f4''f5''f6'])
        for f in feat_sel:
            vals = df_tr[f].values
            for v in vals:
                plot_data.append({'scaler': name, 'feature': f, 'value': v})
    box_df = pd.DataFrame(plot_data)
    sns.boxplot(
        data=box_df, x='feature', y='value', hue='scaler'
        palette=['#8338EC''#FB5607''#00F5D4''#FF006E'], ax=ax2
    )
    ax2.set_title('子图2:缩放器对分布的影响(箱线图比较 f2、f3、f6)', fontsize=12)
    ax2.set_xlabel('特征')
    ax2.set_ylabel('缩放后数值')
    ax2.legend(loc='upper right')

    # 子图3:PCA降至2D后的SVM决策边界(叠加不同缩放器)
    ax3 = plt.subplot(223)

    scaler_colors = {
        'none''#D00000',       
        'standard''#3A86FF',   
        'minmax''#FFBE0B',     
        'robust''#2EC4B6'      
    }
    # 显示训练样本散点(类别)
    X_all = np.vstack([X_train, X_test])
    y_all = np.hstack([y_train, y_test])
    pca = PCA(n_components=2, random_state=42).fit(StandardScaler().fit_transform(X_all))
    Xtr_std = StandardScaler().fit_transform(X_train)
    Xte_std = StandardScaler().fit_transform(X_test)
    Xtr_pca = pca.transform(Xtr_std)
    Xte_pca = pca.transform(Xte_std)
    # 用标准化+PCA的空间绘制点(保持视觉一致)
    for cls, c in zip([0,1,2], colors):
        idx_tr = (y_train == cls)
        ax3.scatter(Xtr_pca[idx_tr,0], Xtr_pca[idx_tr,1], color=c, s=20, alpha=0.6, label=f'class{cls}-train')
    ax3.set_title('子图3:PCA(2D)空间中的SVM决策边界(不同缩放器)', fontsize=12)
    ax3.set_xlabel('PC1')
    ax3.set_ylabel('PC2')

    # 在同一个PCA坐标系中叠加不同缩放器训练出的边界(使用投影一致:先各自缩放->各自PCA->将网格通过逆变换映射)
    # 简化:在标准化+PCA空间中画网格,分别训练各缩放器->用线性判别边界的轮廓近似叠加(采用SVM在该空间重新训练)
    # 注意:此处为演示可比较性,严格对应空间略复杂;我们统一在标准化+PCA空间下训练SVM以对比缩放器带来的间接影响。
    grid_x1 = np.linspace(Xtr_pca[:,0].min()-1, Xtr_pca[:,0].max()+1300)
    grid_x2 = np.linspace(Xtr_pca[:,1].min()-1, Xtr_pca[:,1].max()+1300)
    xx, yy = np.meshgrid(grid_x1, grid_x2)
    grid = np.c_[xx.ravel(), yy.ravel()]

    # 为了公平比较,我们对每个缩放器:先缩放原始数据,再用PCA(2D)拟合,并在对应PCA空间中训练SVM;
    # 然后把它们的决策函数在各自PCA空间中求值。
    for name, scaler in scalers.items():
        if scaler isnotNone:
            scaler.fit(X_train)
            Xtr_s = scaler.transform(X_train)
        else:
            Xtr_s = X_train.copy()
        pca_s = PCA(n_components=2, random_state=42).fit(Xtr_s)
        Xtr_s_pca = pca_s.transform(Xtr_s)
        svm = SVC(kernel='rbf', gamma='scale', C=1.0, random_state=42)
        svm.fit(Xtr_s_pca, y_train)
        # 为了叠加到统一图中,我们近似使用同一网格(标准化PCA空间的grid),
        # 并用一个线性仿射对齐(近似):将标准化PCA空间中的grid点逆投影到原空间,
        # 再用当前缩放器和PCA投影到当前空间下求决策函数。
        # 简单近似:用标准化PCA的逆变换回到标准化空间,再逆标准化到原空间。
        # 注意:此近似会引入变形,但利于直观对比。
        # 逆标准化PCA -> 标准化空间
        # 构造:grid_std = pca.inverse_transform(grid)
        grid_std = pca.inverse_transform(grid)   # 回到标准化后的原特征空间
        # 逆标准化 -> 原空间
        std = StandardScaler().fit(X_all)  # 用所有样本估计标准化,仅用于近似可视化
        grid_orig = std.inverse_transform(grid_std)
        # 应用当前缩放器和PCA -> 当前空间
        if scaler isnotNone:
            grid_s = scaler.transform(grid_orig)
        else:
            grid_s = grid_orig.copy()
        grid_s_p = pca_s.transform(grid_s)
        # 决策函数
        Z = svm.decision_function(grid_s_p)
        if Z.ndim == 1:
            Z = Z.reshape(xx.shape)
            # 二分类等值线(但我们是三类,decision_function将是对每个类别的距离;此处绘制零等值线近似分类边界)
            cs = ax3.contour(xx, yy, Z, levels=[0], colors=[scaler_colors[name]], linewidths=2, linestyles='solid')
        else:
            # 多类:绘制每两类之间的分隔线(找每个类相对于其他类的0等值线近似)
            # 这里简单绘制对每个类的得分差的0等值线,用更稀疏绘制以避免太拥挤
            for k in range(Z.shape[1]):
                Zk = Z[:, k].reshape(xx.shape)
                try:
                    ax3.contour(xx, yy, Zk, levels=[0], colors=[scaler_colors[name]], linewidths=1.5, linestyles='dashed', alpha=0.7)
                except Exception:
                    pass
    ax3.legend(loc='lower left')

    # 子图4:PyTorch逻辑回归的训练收敛曲线(不同缩放器)
    ax4 = plt.subplot(224)
    for name, scaler in scalers.items():
        losses, accs = train_logreg_torch(X_train, y_train, X_test, y_test, scaler, num_epochs=120, lr=0.08, batch_size=64)
        ax4.plot(losses, label=f'loss-{name}', lw=2)
    ax4.set_title('子图4:逻辑回归(SGD)训练收敛曲线(不同缩放器)', fontsize=12)
    ax4.set_xlabel('Epoch')
    ax4.set_ylabel('Loss')
    ax4.legend(loc='upper right')

    plt.tight_layout()
    plt.show()

plot_complex_figure(df, X_train, y_train, X_test, y_test, scalers)

# 8) 进一步的性能输出
# 使用交叉验证比较不同缩放器+SVM、KNN的稳定性
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
for name, scaler in scalers.items():
    if scaler isnotNone:
        pipeline_svm = Pipeline([('scaler', scaler), ('clf', SVC(kernel='rbf', gamma='scale', C=1.0, random_state=42))])
        pipeline_knn = Pipeline([('scaler', scaler), ('clf', KNeighborsClassifier(n_neighbors=7))])
    else:
        pipeline_svm = Pipeline([('clf', SVC(kernel='rbf', gamma='scale', C=1.0, random_state=42))])
        pipeline_knn = Pipeline([('clf', KNeighborsClassifier(n_neighbors=7))])

    scores_svm = cross_val_score(pipeline_svm, X, y, cv=cv, scoring='accuracy')
    scores_knn = cross_val_score(pipeline_knn, X, y, cv=cv, scoring='accuracy')
    print(f"{name} -> SVM CV acc: {scores_svm.mean():.4f} ± {scores_svm.std():.4f}, KNN CV acc: {scores_knn.mean():.4f} ± {scores_knn.std():.4f}")

未缩放下的关键特征分布:

展示了不同类别在关键特征上的分布情况。f2(大尺度)与f3(重尾)显著影响整体度量,可能导致距离类算法不公平;f6与f1相关,说明共线性存在。

缩放器对分布的影响:

比较不同缩放器的缩放后分布形态,理解“标准化”“最小-最大”“稳健缩放”对偏度与离群点的不同处理方式。

PCA(2D)空间中的SVM决策边界:

直观展示不同缩放器对决策边界的影响。虽然同一数据集,但由于缩放改变了正则化与数据几何结构,SVM边界位置会发生改变。

逻辑回归(SGD)训练收敛曲线:

展示缩放对优化收敛速度与稳定性的影响。

总结

整体来看,缩放显著改善距离度量的公平性、优化稳定性与正则化的公平性;并非对所有模型无影响,尤其与SVM与梯度类模型结合时,缩放改变最优解的几何形状。

在应用中,通过一个有尺度差异、重尾与异常值的多类数据集,展示了不同缩放器(无缩放、Standard、MinMax、Robust)的效果,并通过SVM、KNN与PyTorch逻辑回归的训练与可视化进行验证。

最后

最近准备了16大块的内容,124个算法问题的总结,完整的机器学习小册,免费领取~
领取:备注「算法小册」即可~
扫码如有问题,记得添加微信号:xiaobai_ml012

【声明】内容源于网络
0
0
机器学习和人工智能AI
让我们一起期待 AI 带给我们的每一场变革!推送最新行业内最新最前沿人工智能技术!
内容 355
粉丝 0
机器学习和人工智能AI 让我们一起期待 AI 带给我们的每一场变革!推送最新行业内最新最前沿人工智能技术!
总阅读3.1k
粉丝0
内容355