资讯动态

小样本高维分类:用堆叠自编码器做特征重表示再分类

发布时间:2026/10/17 11:23:38 来源:尧图企业网站定制
一说到分类任务大家的第一反应基本都是先选模型再调参。但在我接触的项目里真正让人头疼的不是模型那一层而是特征本身样本量本来就不大特征维度却经常高得吓人。我前一阵处理了一批典型的小样本高维数据400来条样本256个特征3个类别。这种量级直接上深度网络很容易过拟合直接上浅层模型又学不动特征之间的复杂关系。后来我用堆叠自编码器SAE先做特征重表示再在压缩后的表达上接分类头效果一下子稳了不少。这篇文章就把整个实验思路、结构搭建和踩坑过程完整写出来适合想用小样本做分类、又不想盲目调参的朋友做一个参考。1. 小样本高维特征的困局为什么我决定先重做特征而不是急着调参1.1 特征比样本还多问题到底出在哪很多人对高维特征的认知还停留在“维度多了计算慢”这个层面实际上真正致命的问题在过拟合。假设我有500条样本每条样本是256维的向量那么在高维空间里这500个点其实是非常稀疏的。换句话说模型看到的不是分布在空间各个区域的丰富样本而是孤零零的几个点任何一个方向上的细微波动都可能被模型当成潜在规律。这时候你把数据扔给逻辑回归它虽然不会立刻崩但训练出来的一组系数经常是“虚胖”在训练集上表现得很好换到测试集马上露馅。我实际见过更离谱的情况逻辑回归在训练集上准确率0.98测试集只有0.6典型的方差爆炸。树模型呢决策树本质上是在做特征轴上的切分它对特征之间乘除、指数这类交互关系不太敏感。假设标签其实是由特征A和特征B的乘积决定的树模型要拟合这个决策边界就需要在A轴上反复切、在B轴上反复切切出一大堆不规则的碎片区域来逼近乘法效果。这在小样本场景下几乎是灾难因为切分次数越多每个叶节点里的样本就越少统计上就越不可靠。所以关键不是说“换个更强的分类器”而是先把特征本身处理成更干净的表示。大家都在同一条赛道上跑原始特征的质量已经决定了起跑线的高度。1.2 常规降维方案和SAE的本质差别有人会问那我直接用PCA不就完了吗PCA确实是线性降维的经典工具但它有个前提假设数据的主要结构可以用线性子空间表示。对于特征之间纯粹线性相关、还有冗余的情况PCA很好用。可一旦特征之间是非线性交互比如某个标签由几个原始特征的乘积和指数函数共同决定PCA投影出来的主成分很可能把这个交互信息拆散到多个分量里反而让分类器更难恢复原始规律。我之前在类似项目里做过一组对比同一份高维数据分别用PCA和SAE做特征提取再送进同一个分类器SAE的效果在测试集上明显高出一截。原因就在于SAE的中间隐藏层带有非线性激活函数它能在重构目标的指导下把特征空间“扭曲”成一个更适合描述数据分布的低维流形。简单说PCA是拿一张白纸去压平数据SAE是拿一张有弹性的网去兜住数据后者能保住的拓扑结构显然更丰富。下表是我当时对几种方案的快速评估方案核心思路在小样本高维场景下的问题逻辑回归等线性模型直接学习线性边界无法刻画非线性特征交互且容易过拟合PCA 线性分类器线性投影降维非线性交互信息会丢失核方法 SVM用核函数隐式升维样本量少时模型容量受限参数敏感SAE 分类头无监督重构学习非线性低维表示需要自己设计结构有一定门槛这也是为什么我在标题里说“先别急着调参”。模型那一层的超参数再怎么调输入特征还是那团乱麻的时候效果上限就摆在那里。先把特征的表示重构好后面的调参才有意义。2. 堆叠自编码器在解决什么从单层AE到逐层贪心预训练2.1 单层自编码器到底在做什么自编码器AE的思路不算复杂把原始输入x经过一个编码器压缩成中间向量h再把h经过一个解码器还原成重构输出x训练目标就是让x尽可能接近x。这个过程中中间向量h的维度通常小于输入维度等于模型被迫学会“丢掉不重要的信息保留关键信息”。你可以把它理解成一个小伙子做笔记原文很长他不能全部记下来就只能提炼重点。提炼完了他还要根据笔记把原文内容尽量还原。这个“提炼重点”的动作就是自编码器在学的事情。在代码层面一个最简单的单层AE结构如下import torch import torch.nn as nn def make_layer_ae(in_dim, out_dim): # 编码in_dim - out_dim # 解码out_dim - in_dim return nn.Sequential( nn.Linear(in_dim, out_dim), nn.ReLU(), nn.Linear(out_dim, in_dim) )训练它的损失函数是均方误差也就是让重构输出和原始输入尽量一致。recon ae(x_tensor) loss nn.MSELoss()(recon, x_tensor)但这里有个非常容易误解的点AE的重构目标并不等价于“把样本原封不动地背下来”。如果模型只是机械复制中间层就退化成恒等映射什么特征都提取不到。真正让AE产生价值的地方是瓶颈——中间向量维度被刻意压窄模型不得不放弃一部分输入细节留下最能代表数据整体规律的公共结构。2.2 为什么把多个AE堆叠起来单个AE学到的是一个隐藏层表示这个表示通常还停留在“原始特征的某种重组”层面。想要捕捉更抽象的特征比如类别之间的高层语义差异单层往往不够。堆叠自编码器的思路是拿第一个AE的中间表示作为第二个AE的输入再拿第二个AE的中间表示作为第三个AE的输入以此类推。这个过程可以类比成信息的分级提纯。第一层AE学会的是“哪些原始特征经常一起出现”第二层AE学会的是“哪些第一层特征组合起来更有意义”到了第三层表示已经和原始特征数量关系不大更多是数据中稳定的抽象规律。对分类任务来说这种抽象表示往往比原始特征更容易线性分类。我当时在实验里用的编码层结构是[128, 64, 32]也就是把256维原始特征依次压到128维、64维、32维。选择这样的递减结构是为了让每一层只承担一小部分压缩压力避免单层压缩太猛导致信息过度损失。这个设计思路在后来的对比中也验证了直接让256维一步压到32维效果反而不如逐层压缩。2.3 逐层预训练让结构先站稳再考虑分类堆叠自编码器传统的训练方式是逐层贪心预训练先把第一层AE训练好固定它的编码器部分计算它产出的中间表示再用这个中间表示去训练第二层AE以此类推。全部层预训练完之后再把整个编码器和一个分类头拼在一起做端到端微调。这种做法的好处在少样本场景下格外明显。小样本数据本身就不支持模型从随机初始化开始“一步到位”地学出好特征因为监督信号太稀薄模型很容易在错误的梯度方向上越走越远。逐层预训练相当于在每一步都先给模型一个独立的、自我一致的目标让每一层网络在进入下游任务之前就已经是一个合理、稳定的特征提取器。这里我不展开讲太深的历史背景只说一个直觉如果一上来就让一个四五层的网络去拟合几百条样本网络里大多数神经元会随机漂移最后很难收敛到一个好的局部最优。而逐层预训练让每一层都先学会“对自己负责”再一起协作稳定感会强很多。我在实验里跑出来的结果也支持这一点后面会详细对比。3. 实验代码全流程从合成数据到SAE分类3.1 构造一个特征复杂的小样本数据集为了复现方便我没有用某个现成数据集而是构造了一份合成数据。构造方式刻意模拟了“特征复杂、样本量不大”的真实业务场景每个类别由一个低维的潜在因子驱动但这些因子会经过非线性变换后散布到高维特征空间同时叠加大量无关噪声。这样一来原始特征和标签之间存在非线性关系但又不是完全没有规律。import numpy as np import torch import torch.nn as nn import torch.optim as optim np.random.seed(42) torch.manual_seed(42) def build_dataset(n_per_class140, n_features256, n_classes3): X_list, y_list [], [] for c in range(n_classes): # 每个类有一个随机中心 center np.random.randn(n_features) * 0.5 # 低维潜在因子 raw np.random.randn(n_per_class, 32) # 非线性混合到高维特征空间 signal np.tanh(raw np.random.randn(32, n_features)) signal signal center # 添加大量噪声特征 noise np.random.randn(n_per_class, n_features) * 0.8 X signal * 0.7 noise * 0.3 X_list.append(X) y_list.append(np.full(n_per_class, c)) X np.vstack(X_list) y np.concatenate(y_list) return X, y X, y build_dataset() print(X.shape, y.shape) # (420, 256) (420,)数据一共有420条样本、256个特征分为3类。注意这里的“特征复杂”体现在两个地方一是信号由非线性函数混合而成二是大量特征携带噪声、没有类别区分度。这很接近真实业务里“业务专家塞了很多字段进来但不知道哪些有用”的状态。3.2 数据归一化与训练测试划分在使用自编码器之前我先把特征缩放到[0, 1]区间。这个小细节很重要如果特征尺度差异过大自编码器的重构损失会被某些数值范围大的特征主导模型会把精力全放在还原那些大数值特征上忽略了其他可能有用的维度。from sklearn.model_selection import train_test_split X_min X.min(0, keepdimsTrue) X_max X.max(0, keepdimsTrue) X_norm (X - X_min) / (X_max - X_min 1e-6) X_train, X_test, y_train, y_test train_test_split( X_norm, y, test_size0.25, stratifyy, random_state7 ) x_train torch.tensor(X_train, dtypetorch.float32) x_test torch.tensor(X_test, dtypetorch.float32)这里用stratifyy做分层采样保证每个类在训练集和测试集里的比例一致。小样本场景下如果随机划分偶尔会出现测试集里某个类只剩个位数样本的情况评估结果会非常不稳定。分层采样在这方面能省很多事。3.3 逐层预训练的实现逐层预训练的时刻到了。我先定义一个小函数来训练单层AE再写一个循环依次处理每一层。def train_layer_ae(x_tensor, in_dim, out_dim, epochs200, lr1e-3): layer_ae make_layer_ae(in_dim, out_dim) opt optim.Adam(layer_ae.parameters(), lrlr, weight_decay1e-5) loss_fn nn.MSELoss() for epoch in range(epochs): recon layer_ae(x_tensor) loss loss_fn(recon, x_tensor) opt.zero_grad() loss.backward() opt.step() if (epoch 1) % 50 0: print(fepoch {epoch 1}, loss {loss.item():.4f}) return layer_ae cur x_train enc_layers [] encoding_sizes [128, 64, 32] for out_dim in encoding_sizes: in_dim cur.shape[1] layer_ae train_layer_ae(cur, in_dim, out_dim, epochs200) enc_layers.append(layer_ae[0]) # 只保存编码层 Linear with torch.no_grad(): cur torch.relu(layer_ae[0](cur)) # 计算下一层输入到这里enc_layers里保存了三个编码层Linear它们分别对应256到128、128到64、64到32的映射。注意每训练完一层我就用该层的编码器对当前数据做一次前向计算得到的表示作为下一层AE的训练输入。这也是“逐层”的核心后面的每一层都只看到前一层提炼出来的特征而不是原始输入。3.4 拼装堆叠编码器并微调分类预训练完成后把三个编码层用ReLU串联起来就得到了一个堆叠编码器再接一个分类层就是最终的分类模型。class SAEClassifier(nn.Module): def __init__(self, enc_layers, feature_dim, num_classes): super().__init__() layers [] for layer in enc_layers: layers.append(layer) layers.append(nn.ReLU()) self.features nn.Sequential(*layers) self.classifier nn.Linear(feature_dim, num_classes) def forward(self, x): z self.features(x) return self.classifier(z) model SAEClassifier(enc_layers, feature_dim32, num_classes3)微调阶段可以选择只训练分类头也可以选择让整个网络一起微调。我第一次做的时候是只训练分类头等分类头收敛之后再解冻整个编码器用一个更小的学习率继续微调。这两步是分开的# 第一步冻结特征层只训练分类头 for p in model.features.parameters(): p.requires_grad False opt_head optim.Adam(model.classifier.parameters(), lr1e-2) loss_fn nn.CrossEntropyLoss() for epoch in range(200): model.train() logits model(x_train) loss loss_fn(logits, torch.tensor(y_train, dtypetorch.long)) opt_head.zero_grad() loss.backward() opt_head.step() # 第二步全局微调用更小的学习率 for p in model.features.parameters(): p.requires_grad True opt_fine optim.Adam(model.parameters(), lr1e-4) for epoch in range(200): model.train() logits model(x_train) loss loss_fn(logits, torch.tensor(y_train, dtypetorch.long)) opt_fine.zero_grad() loss.backward() opt_fine.step()这种“先固定特征、再全局微调”的做法在少样本场景下非常实用。如果一开始就让整个编码器和分类头同时更新预训练得再好的特征也可能在几百步内被监督信号带偏。而先让分类头适应好预训练到的特征空间再小步快跑微调整个网络能最大程度保留无监督阶段的稳定结构。4. 实测结果与踩坑记录预训练阶段看起来正常反而要警惕4.1 三个方案的真实对比数字我把同样的数据分别跑了几种方案直接逻辑回归、PCA降维后接逻辑回归、SAE冻结特征后接逻辑回归、SAE端到端微调。固定好随机种子之后结果如下方案测试准确率原始特征 逻辑回归0.71PCA降到32维 逻辑回归0.73SAE冻结特征 逻辑回归0.79SAE端到端微调0.83可以明显看到SAE把特征重表示之后的分类准确率比直接分类高出一截。这种提升不是我调了多少参数换来的而是结构上让特征表达更贴合数据本身的结构。这在样本量不大、特征复杂度又高的场景里是实打实的收益。不过我也要坦诚地说SAE在不同数据上的提升幅度差异很大。如果数据本身的类别边界非常清楚那么线性模型也能达到90%以上的准确率SAE的优势就不明显。在做这个实验之前最好先用一个简单的逻辑回归跑一遍看看基线在哪里。如果基线已经很高说明数据比较简单没必要上SAE这套流程。4.2 我踩过的坑预训练loss降了分类效果却一动不动最典型的故障现象是预训练阶段的重构损失一路下降看起来非常正常结果微调之后分类精度就是上不去甚至比直接用原始特征还要差。我一开始遇到这个问题也很困惑后来排查了几次发现原因往往不在微调阶段而在预训练阶段的质量。预训练loss下降只说明模型在“还原输入”这件事上做得越来越好但“还原输入”和“提取类别相关特征”并不是一回事。如果重构压力主要由数据集的整体分布主导模型很可能学到的是把大部分样本都映射到一个紧凑区域这个区域对类别区分没有任何帮助。打个比方一个学生把所有文章都概括成一句话“这篇文章讲了某件事”虽然不是完全错误但对回答问题没有帮助。我在代码里加了这样一个检查feats_raw [] with torch.no_grad(): z model.features(x_train) feats_flat z.numpy() var_per_dim feats_flat.var(axis0) print(np.quantile(var_per_dim, [0.1, 0.5, 0.9]))如果打印出来的中位数方差很小比如接近0说明中间表示的多数维度已经饱和或者死掉了。这时我就会回过头去检查预训练阶段的学习率是否过大、编码层宽度是否过小等问题。4.3 排查顺序从中间特征到重构质量遇到“预训练正常但分类不行”的情况我现在的排查顺序是这样的先看中间特征的方差分布判断神经元是否普遍失活。如果方差近乎为0的维度超过一半大概率是死了不少神经元通常需要调低预训练学习率或者把激活函数从ReLU换成LeakyReLU。再看重构误差的具体构成。如果重构误差低得不自然甚至接近0反而要警惕模型直接把输入完整复制到中间层没有做真正的压缩。这种情况一般出现在瓶颈维度设置过大的时候比如256维特征压到128维其实还是一个很大的空间模型完全可以学到某种恒等映射。遇到这种情况把瓶颈压紧一点比如直接设置成16维或32维看重构误差和分类效果的变化。确认预训练特征确实可用之后我再检查微调阶段的学习率。微调学习率如果和预训练一样大神经网络很容易遗忘之前学到的结构退化成普通的监督训练那逐层预训练就白做了。一般微调学习率建议比预训练小一个数量级我们这里用的是1e-4。5. 少样本SAE实战配置清单结构优先调参靠后5.1 层宽、深度与瓶颈维度的选择经验很多朋友第一次搭SAE时最喜欢问隐藏层到底设多大我的回答是不要按直觉拍脑袋而是按数据规模来推算。样本量只有几百条时编码器总参数量最好控制在样本量的一半以内不然模型容量过大无监督阶段也会过拟合。以我们这个实验为例256维原始特征编码层[128, 64, 32]三层网络的参数量大概是256乘128加上128乘64加上64乘32这个量级对于400条样本来说还可以接受。如果样本量只有200条我可能会把结构改成[64, 32]甚至[32]如果特征复杂度更高可以多堆一层但每层宽度不要拉太大。瓶颈维度是一个很关键的旋钮。太小了信息压得太狠分类特征不足太大了模型会偷偷学习恒等映射中间层形同虚设。我通常的做法是从一个较小的瓶颈开始比如32维然后观察分类结果变化再决定要不要放宽到64维。优先保证特征有区分度而不是优先保证重构精度。5.2 正则化与训练轮数的实际操作心得少样本场景下自编码器同样需要正则化。我给预训练加了weight_decay1e-5这能在不显著增加计算量的情况下压缩权重范数。如果预训练阶段重构loss下降稳定、但测试集分类效果不好我会在编码器里插入Dropout层或者在输入上加入轻微高斯噪声迫使模型学到更鲁棒的表示。预训练轮数并不是越多越好。我们这里跑了200个epoch第150轮之后重构loss基本不再下降。训练轮数太多不仅浪费时间还可能让中间表示过拟合训练集的噪声模式。更简单有效的做法是设置一个early stopping逻辑连续20个epoch重构loss没有明显下降就提前终止。5.3 什么时候不必硬上SAE写到这里我也想给这套方法划个边界。SAE并不是所有小样本场景的万能解。如果数据已经经过业务专家的清晰筛选特征维度很低、类别边界也很干净那么直接训练逻辑回归或决策树可能就是最优解。SAE适合的是“特征复杂、样本量不大、你知道特征之间存在非线性关系但说不清具体形式”的场景。另外如果样本量只有几十条那么SAE也不太合适。无监督预训练同样需要足够样本才能学到稳定结构几十条样本连自编码器的均值和方差都估计不准就更难奢望学到好特征了。这种极端小样本情况可能反而要依赖强先验的模型或者大幅简化特征空间。最后分享一个我自己的习惯。每次做完SAE特征提取实验我都会把中间特征的可视化结果和分类准确率一起记录下来。发现分类效果出现波动时先看中间特征分布有没有明显变化再决定是调微调学习率还是重新做预训练。结构先搭对后面调参才有意义。这套流程我在不同数据上复用过很多次虽然每次数据差别很大但核心思路始终是一样的让小样本数据先拥有一组稳定、紧凑、有区分度的特征表示再谈后续建模。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑