☰
CNN-KELM图像分类实战:从特征提取到核极限学习机
2026/10/1 13:05:47 网站建设 项目流程

简介:面向图像分类与极限学习机(ELM)研究者,这份Python完整源码包实现了CNN与KELM(核极限学习机)结合的图像分类方案,从数据加载、模型构建到训练测试、标签修正形成完整闭环,适合具备PyTorch基础、希望探索深度特征与ELM融合策略的中高级开发者。压缩包共43个文件,以Python脚本为主(含训练、测试、提取标签向量、KELM分类等脚本),辅以PyCharm工程配置、CIFAR-10数据批次、Markdown说明等,整体大小162.18MB;核心代码涵盖VGG/ResNet特征提取、ELM与标签平滑模块、特征可视化、模型保存等功能模块,目录结构清晰,便于按功能快速定位与研读。已有363人下载学习,可作为图像分类实验的参考基线和扩展起点。借助这份资源可快速复现KELM分类流程,并借鉴其数据批处理、标签修正和特征提取思路,进一步迁移到自定义数据集,或在此基础上针对精度、泛化能力进行改进与对比实验。

1. 为什么图像分类要选 CNN-KELM:先看它在解决什么

图像分类任务里,CNN 负责把「长得像什么」变成「一组数字」,KELM 负责把这组数字映射到「类别标签」。单独用 CNN 做分类,最后一层通常是 Softmax,靠反向传播把整个网络从头训到尾;单独用 KELM,输入又是原始像素,根本提不出有效的空间特征。把两者接起来,等于让卷积层当特征提取器,让极限学习机当分类头,前段吃算力,后段吃速度,刚好避开 Softmax 分类头需要大量迭代微调的短板。

这套方案最实际的场景是中小规模数据集:几千到几万张图,类别数在 10 到 100 之间。数据量不够大时,微调整个 CNN 容易过拟合,而 CNN 卷积层用预训练权重冻住、只训 KELM,几分钟就能出一个精度不错、泛化还稳的模型。做毕设、做竞赛 baseline、做工业小样本质检,这条路都很常见。本文用的就是「CNN 特征提取 + KELM 分类」的固定套路,数据、特征、分类三个模块各自独立,任何一环都能换成自己的文件。

2. KELM 凭什么当分类头:核映射与输出权重的数学逻辑

2.1 ELM 到 KELM:从随机映射到核映射

极限学习机(ELM)的核心思想是:单隐层神经网络的输入权重和偏置可以随机初始化,训练时完全不更新它们,只求解输出权重。对 N 个样本,输入矩阵 X 经过随机权重 W 和激活函数 g 映射到隐层输出矩阵 H,分类目标 T 与 H 之间的关系是线性方程 Hβ = T,β 通过最小二乘直接解出来:

β = H⁺ T

H⁺ 是 H 的 Moore-Penrose 广义逆。这个过程的计算量远小于反向传播,因为不需要迭代。KELM 在 ELM 基础上引入核函数:不显式计算隐层输出 H,而是用核矩阵 Ω = HHᵀ 替代,其中 Ω(i,j) = K(xᵢ, xⱼ)。训练时解:

β = (Ω + λI)⁻¹ T

这里的 λ 是正则化系数,用来控制输出权重的范数,防止过拟合。推理时,新样本 x 的预测输出是:

f(x) = K(x, x₁), K(x, x₂), …, K(x, xₙ) · β

这个写法把高维隐层映射隐藏在了核函数里,避免了随机权重带来的不确定性。KELM 对同一个数据集多次训练,精度波动远小于 ELM,这是它更适合做分类头的一个关键原因。

2.2 核函数与正则化系数:两个必调参数

KELM 最常用的核是 RBF 核:K(x, y) = exp(-γ‖x-y‖²)。γ(也叫 sigma 的倒数)控制核的宽度:γ 太大,核矩阵接近单位阵,每个样本只影响自己,模型退化成近邻查找;γ 太小,核矩阵所有元素接近 1,类别信息被抹平。RBF 核的 γ 和 ELM 里的正则化系数 C(即 λ 的倒数)通常放在一起网格搜索。

参数搜索的常见做法是交叉验证。特征维度在几百到几千时,核矩阵大小是 N×N(N 为训练样本数),求逆一次 O(N³)。N 在 5000 以下完全能接受,超过 10000 就要考虑分块或者降采样。这也是为什么 CNN-KELM 适合中小规模数据:核矩阵的平方级复杂度锁死了它的上限。

2.3 为什么特征提取必须用 CNN:KELM 吃不了原始像素

原始图像输入 KELM,等价于把每个像素当独立维度。CIFAR-10 的 32×32×3 是 3072 维,一张 224×224 的图是 15 万维,核矩阵算不动,特征里也全是冗余。CNN 的卷积层通过局部感受野和池化,把空间信息压缩成低维、高语义的特征向量。实践里常用最后一个池化层或全局平均池化的输出,维度通常在 512 到 2048,对 KELM 来说正好。

用预训练 CNN(ResNet50、VGG16、EfficientNet 都行)做特征提取器时,卷积层权重完全冻住,不参与 KELM 的训练。这套组合的本质是:CNN 提供强大的先验特征,KELM 用闭式解学习一个分类面。两者互补的点在于——CNN 的尾部全连接层和 Softmax 是数据驱动的,容易在小数据集上过拟合,而 KELM 的正则化项天然压制了这个问题。

提示:如果你在 KELM 之前接的是 CNN 倒数第二层全连接输出,注意先做 L2 归一化,否则特征范数差异会直接干扰核距离计算。

3. 跑通 CNN-KELM 最小代码:数据、特征提取、训练一条线

3.1 环境与依赖:最少装什么

Python 3.8+ 环境下,核心依赖只有四个:numpy 做矩阵运算,scikit-learn 提供数据集划分和精度指标,Pillow 处理图像读取,PyTorch 或 TensorFlow 加载 CNN 模型。KELM 本身不需要专门的库,十几行 numpy 就能实现。

安装命令:

pip install numpy scikit-learn pillow torch torchvision

torch 和 torchvision 的体积较大,如果本机有 CUDA 的 GPU,建议装 CUDA 版;没有 GPU 也没关系,特征提取只需要前向推理,CPU 上跑 ResNet50 处理几千张图也就几分钟。CPU 版直接装就好。

3.2 数据准备:目录结构就是标签

假设你的图像数据按类别放在不同子目录里:

data/ train/ cat/ 0001.jpg 0002.jpg ... dog/ 0001.jpg 0002.jpg ... val/ cat/ ... dog/ ...

读取时不需要手写解析器,torchvision 的 ImageFolder 直接按目录名生成标签。这里有个坑:目录名的排序顺序就是标签索引,所以类目名最好用纯英文,排序稳定,避免中文名在不同操作系统下排序不一致导致标签错位。

3.3 特征提取:把整个数据集过一遍 CNN

下面的代码把训练集和验证集图片全部转成特征向量,存成 numpy 文件:

import torch import numpy as np from torchvision import models, transforms from torch.utils.data import DataLoader from torchvision.datasets import ImageFolder device = torch.device("cuda" if torch.cuda.is_available() else "cpu") transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) train_ds = ImageFolder("data/train", transform=transform) val_ds = ImageFolder("data/val", transform=transform) model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1) model = torch.nn.Sequential(*list(model.children())[:-1]) # 去掉最后的全连接层 model.to(device).eval() def extract_features(dataset, batch_size=64): loader = DataLoader(dataset, batch_size=batch_size, shuffle=False) feats, labels = [], [] with torch.no_grad(): for xs, ys in loader: xs = xs.to(device) out = model(xs) # (B, 2048, 1, 1) out = out.view(out.size(0), -1) # 展平为 (B, 2048) feats.append(out.cpu().numpy()) labels.append(ys.numpy()) return np.vstack(feats), np.concatenate(labels) X_train, y_train = extract_features(train_ds) X_val, y_val = extract_features(val_ds) # 特征做 L2 归一化 X_train = X_train / np.linalg.norm(X_train, axis=1, keepdims=True) X_val = X_val / np.linalg.norm(X_val, axis=1, keepdims=True) np.savez("features.npz", X_train=X_train, y_train=y_train, X_val=X_val, y_val=y_val) print("feature shapes:", X_train.shape, X_val.shape)

逻辑说明:model.children()把 ResNet50 拆层,去掉最后一个全连接层后,输出是 2048 维的池化特征。shuffle=False保证特征顺序和标签顺序一致。L2 归一化这一步对 RBF 核很重要,因为归一化后特征都落在单位球面上,欧氏距离的差异只反映方向差异,更符合图像语义相似度的直觉。

参数说明:Resize 到 224×224 是 ResNet 系列的标准输入;Normalize 的 mean/std 必须用 ImageNet 的统计值,因为预训练权重就是在这个分布上学的。如果你换用自己的 CNN 权重,归一化参数也要对应换。

3.4 KELM 训练与预测:numpy 二十行实现

特征准备好之后,KELM 的训练就是三次矩阵运算:

import numpy as np class KELM: def __init__(self, C=1.0, gamma=0.01): self.C = C self.gamma = gamma def _rbf(self, X1, X2): # 计算 RBF 核矩阵,避免双重循环 dist = -2.0 * X1 @ X2.T dist += np.sum(X2**2, axis=1) dist += np.sum(X1**2, axis=1)[:, np.newaxis] return np.exp(-self.gamma * np.clip(dist, 0, None)) def fit(self, X, y): self.X = X self.classes = np.unique(y) Y = np.zeros((len(y), len(self.classes))) for i, c in enumerate(self.classes): Y[y == c, i] = 1 # one-hot 标签 K = self._rbf(X, X) n = X.shape[0] # (K + I/C) 求逆,乘上核矩阵再乘标签 self.beta = np.linalg.solve(K + np.eye(n) / self.C, Y) return self def predict(self, X): K = self._rbf(X, self.X) scores = K @ self.beta return self.classes[np.argmax(scores, axis=1)] # 加载特征 data = np.load("features.npz") X_train, y_train = data["X_train"], data["y_train"] X_val, y_val = data["X_val"], data["y_val"] kelm = KELM(C=10.0, gamma=0.5) kelm.fit(X_train, y_train) pred = kelm.predict(X_val) acc = (pred == y_val).mean() print("KELM val accuracy:", round(acc, 4))

逻辑说明:np.linalg.solve直接求解 (K + I/C)β = Y,比显式计算逆矩阵更快更稳。RBF 核矩阵用展开式计算:X1 @ X2.T得到两两内积,再根据 ‖a-b‖² = ‖a‖² + ‖b‖² - 2a·b 展开,np.clip防止浮点误差导致负距离开根号出 NaN。

参数说明:C 是正则化项,C 越大对训练集的拟合越强,但太大容易把噪声也学进去;gamma 是核宽度的倒数,一般从 0.01、0.1、0.5、1.0 里试。二者要配合调,不能只动一个。

注意:如果你在_rbf里用np.sqrt后再平方,数值误差会被放大,建议直接用展开式的距离平方形式,省一次开方,精度也更好。

4. 参数怎么定:网格搜索、交叉验证与评估指标配置

4.1 为什么不能只调 gamma:C 和 gamma 是联动的

很多人调 KELM 时只来回动 gamma,C 一直保持默认值。但 RBF 核的 KELM 里,gamma 决定核矩阵的对角占优程度,C 决定输出权重 β 的范数惩罚力度。gamma 调大时核矩阵对角元素接近 1,非对角元素迅速衰减,模型复杂度上升,此时如果不增大 C 来加强正则化,训练集精度可能很高,验证集直接崩。

联动关系可以用一个直观现象来看:gamma 翻倍相当于特征空间里样本间的有效距离拉大 20%,C 不跟着动,α 向量就会被拉向更大的范数,边界更曲折。所以网格搜索必须把 C 和 gamma 放在同一组里遍历,而不是分开调。

4.2 网格搜索怎么做:把训练集再切一刀

最稳的做法是在训练集内部再切一个验证子集,专门用来定参数,定完再用全部训练集重新训练:

from sklearn.model_selection import GridSearchCV, StratifiedKFold from sklearn.base import BaseEstimator, ClassifierMixin class KELMClassifier(BaseEstimator, ClassifierMixin): def __init__(self, C=1.0, gamma=0.1): self.C = C self.gamma = gamma def fit(self, X, y): self.kelm_ = KELM(C=self.C, gamma=self.gamma) self.kelm_.fit(X, y) return self def predict(self, X): return self.kelm_.predict(X) param_grid = { "C": [0.01, 0.1, 1, 10, 100], "gamma": [0.001, 0.01, 0.1, 0.5, 1.0] } grid = GridSearchCV( KELMClassifier(), param_grid, cv=StratifiedKFold(n_splits=5, shuffle=True, random_state=42), scoring="balanced_accuracy", n_jobs=1 # KELM 的核矩阵求解很快,单线程就够 ) grid.fit(X_train, y_train) print("best params:", grid.best_params_) print("best cv score:", round(grid.best_score_, 4))

逻辑说明:StratifiedKFold保证每一折的类别比例和整体一致,类别不平衡时不会出现某一折里缺了某个类。balanced_accuracy对每个类单独算准确率再取平均,比普通 accuracy 更抗类别不均衡。

参数说明:n_jobs 不要设太大。因为 KELM 的 cv 重训是矩阵求逆,不是 I/O 密集型任务,多进程反而有额外开销。C 和 gamma 的搜索范围按数量级铺开,先用粗粒度定位,再在最优值附近细搜一轮。

提示:类别数特别多(比如 100 类)时,one-hot 矩阵 Y 是 N×100,求解 (K + I/C)⁻¹Y 还是 N×N 求逆占主导,类别多对训练时间影响不大,但对预测时的 argmax 有轻微影响,这在可控范围内。

4.3 评估指标:分类任务不只一个准确率

图像分类的评估最忌讳只报一个 accuracy。类别不均衡时,准确率会被大类别拉高,小类别全错也可能得出 90% 的精度。至少要额外看两部分:每个类别的 precision、recall、F1,以及混淆矩阵。

from sklearn.metrics import classification_report, confusion_matrix import pandas as pd pred = grid.best_estimator_.predict(X_val) print(classification_report(y_val, pred, target_names=val_ds.classes)) cm = confusion_matrix(y_val, pred) cm_df = pd.DataFrame(cm, index=val_ds.classes, columns=val_ds.classes) print(cm_df)

看混淆矩阵时重点关注两类错误:相似类别互相混(比如猫和狗),以及某个类别被大面积分到另一类。前者说明特征不够区分,可能要把 CNN 特征换成更深的网络;后者说明训练样本里某个类太少,KELM 的决策面被大类别带偏。KELM 本身不提供概率输出,它返回的是各类别的得分,不要试图把得分当置信度。

4.4 特征维度对精度的影响:不是越多越好

CNN 不同层输出的特征维度差别很大:ResNet50 池化后 2048 维,VGG16 全连接层 4096 维,EfficientNet-B0 是 1280 维。KELM 对特征维度的敏感度没有 SVM 那么高,但维度直接决定核矩阵计算时的一次矩阵乘法开销。2048 维和 4096 维在 N=5000 时,距离矩阵计算时间差一倍。

一般建议优先用池化层输出,而不是全连接层。全连接层特征里包含更多类别语义,但也更贴近训练集的分布,在 KELM 里容易过拟合。池化后的特征空间位置信息保留更多,泛化更好。如果你发现精度不够,可以先试不同网络同一层的特征,再试同一网络不同层的特征,不要一上来就堆维度。

5. 避坑指南:CNN-KELM 最常见的 5 个翻车现场

5.1 标签错位:特征和标签对不上

现象:训练精度很高,验证精度接近随机。

原因:特征提取时 DataLoader 的 shuffle=True,或者多次遍历数据集时顺序变化,导致 X 和 y 的对应关系错乱。另一个更隐蔽的原因是 ImageFolder 按目录名排序生成标签,增删类目后旧的特征文件没重新生成。

解决:特征提取时固定 shuffle=False,特征和标签在同一批循环里 append,存成 npz 前打印一次 X_train.shape 与 y_train.shape,再随机抽 5 个样本人工核对。换数据集结构后必须删掉旧的 features.npz 重新生成。

5.2 核矩阵内存炸了

现象:训练集有 3 万张图,特征提取很顺利,但 KELM 训练时内存占用几十 GB,程序被杀。

原因:核矩阵大小 N×N,N=30000 时是 9 亿个 float64,占 7.2GB。np.linalg.solve还要额外拷贝一份,峰值超过 14GB。这是 KELM 的固有瓶颈,不是代码问题。

解决:把 N 控制在 10000 以内,超过就随机采样一部分训练特征;或者用分块核矩阵迭代求解,但实现复杂且精度有损失,不如采样实在。另一个方案是换成 ELM(不计算核矩阵,只算 H⁺),但精度和稳定性都会差一些。

5.3 特征没归一化,核距离全被大数主导

现象:gamma 怎么调精度都不动,或者一直等于某个固定值。

原因:特征每维的数值范围不一致,比如某些维最大 100,某些维最大 0.01,RBF 核距离平方里大数值维度直接淹没其他维度。此时无论 C 和 gamma 怎么变,分类面几乎只由那几维决定。

解决:在 KELM 训练前做 L2 归一化,或者用 StandardScaler 做零均值单位方差。CNN 的池化特征一般 L2 归一化就够了,如果你自己设计了特征,建议两者都试,比较验证精度。

5.4 预训练权重被误更新了

现象:训练时间异常长,显存占用暴涨。

原因:定义模型时没有调用requires_grad_(False)或.eval(),torch 默认对参数求梯度。前向提取特征时虽然用了torch.no_grad(),但模型的 BN 层在 train/eval 模式下统计量的更新行为不同,no_grad不改变 BN 的 running_mean/running_var 更新逻辑。

解决:特征提取模型固定调用.eval(),如果没有微调需求,还可以挂上with torch.no_grad()并确认param.requires_grad == False。一个简单的验证方法:同样的图跑两次,特征输出应该完全一致。

5.5 精度比纯 CNN 还低,问题到底在哪

现象:KELM 验证精度比直接用 ResNet 微调低 5 到 10 个点。

原因:CNN 的 Softmax 分类器在特征空间里学的是一个线性分类面,但要经过整个网络的反向传播迭代调优;KELM 在冻住的 CNN 特征上追求的是该特征空间下的最优分类面。如果预训练 CNN 的特征本身对目标任务区分度不够,KELM 的上限就被锁死了。

解决:先验证特征本身的质量——把特征送入 SVM(RBF 核)看精度,如果 SVM 和 KELM 都低,说明特征没提好,应该微调 CNN 或换更深网络;如果 SVM 高 KELM 低,那多半是 C/gamma 没调好,回网格搜索。这是定位问题的核心手段:KELM 是最后一个环节,它只对特征负责。

6. 进阶技巧:把类别不平衡接进 KELM

类别不平衡是图像分类的常态,尤其在工业缺陷检测里,正品图几千张,瑕疵图可能只有五十张。KELM 的 one-hot 目标矩阵里每个类别等权,大类别会主导输出权重 β 的求解,小类别容易被牺牲。常见的补救有三招,按成本和效果排序。

第一招是类别权重加权。给训练误差矩阵 Y 乘上权重向量 w,w(i) = N / (n_classes × n_i),其中 n_i 是第 i 类的样本数。实现时只需在构造 Y 后逐列乘以权重:

n = len(y) weights = np.zeros(len(kelm.classes)) for i, c in enumerate(kelm.classes): weights[i] = n / (len(kelm.classes) * (y == c).sum()) Y *= weights

这行改动直接作用于目标端,让少样本类别的误差在最小二乘里占据更大权重。代价是训练集准确率会下降一点,但验证集的少数类 F1 会明显上升。

第二招是让 KELM 输出重新校准。由于 KELM 的得分不是概率,可以把输出的各类别得分做温度缩放,再取 argmax。温度 T > 1 会让得分分布更平,适合给低置信度样本一个「再看一眼」的机会;T < 1 会让得分更尖锐,适合类别分布比较平衡时用。温度参数不在训练阶段参与,独立调。

第三招是集成。用不同随机种子采样数据构造多个 KELM,投票决定最终类别。KELM 的核矩阵求解是确定性的,但采样改变了训练集分布,各模型的误差不相关,投票能平滑掉个别类别的不均衡影响。这一招实现简单,在 KELM 上效果比在 CNN 上更明显,因为 KELM 训练成本低,训 10 个模型也就是求 10 次逆矩阵的时间。

我最常用的组合是:先做特征归一化和类别权重加权,网格搜索 C/gamma,最后用验证集做温度校准。这三步从不失败——如果还不行,我会回头去检查特征提取头,而不是继续调 KELM。CNN-KELM 这套东西的边界很清楚:它擅长在有限算力和小数据条件下榨干预训练特征,而不是替代深度分类头去学更复杂的映射。把精力放在特征和参数上,比反复堆网络深度划算得多。希望这些方法能帮你把精度再往上顶一截。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询