这门课布置到第三份作业时,不少同学盯着题目里的 Scene Recognition with Bag of Words 有点发懵。手里刚会的深度学习工具还没捂热,突然要求回到十年前的传统视觉套路,第一反应多半是:这都什么年代了,词袋模型还有必要认真做吗?我当时也是这个心态,真正动手后才发现,这个作业恰恰是把“从底层特征到高层语义”这条链路拆得最清楚的一次训练。做完它,再回头看 CNN,理解完全不一样。
这个作业的典型形式是给定一批场景图片,比如城市、海岸、森林、高速公路等类别,要求用 Bag of Words(词袋)模型完成分类识别。流程不算复杂,但每个环节都有不少细节容易踩坑。本文就围绕我完成这份作业的完整过程展开,从整体思路、特征提取、聚类建词典,到直方图编码、SVM 分类和最终调参,把所有关键选择和踩过的坑都写清楚。如果你是正在做类似作业的学生,或者想补一下传统视觉识别的基础,这篇文章可以作为直接的参考。
1. 作业目标拆解:词袋模型到底在解什么问题
1.1 场景识别任务的定义与数据集特点
场景识别,简单说就是给一张图片判断它属于哪种环境类型。这不同于物体识别——物体识别往往关注“图像里有什么”(比如一只猫、一辆车),场景识别关注的是“整个图像所处的环境语义”(比如森林、城市、开阔地)。正因为关注的是全局语义,场景图片往往没有单一的主体目标,而是由大量重复出现的局部结构构成。比如“森林”类图片里有大量树叶纹理、树干边缘,“城市”类图片里有大量建筑直线、窗框、道路边缘。
作业里常见的数据集规模不大,一般分为训练集和测试集。以经典的 8 类场景数据集为例,每类大约有 100 到 300 张图片,图片尺寸从几百乘几百到上千像素不等。因为总量不大,传统方法反而有优势:不需要 GPU,几十分钟就能跑完整个流程,非常适合用来理解特征工程和经典分类器的配合方式。
我用的课程数据集共有 8 个类别,每类 150 张训练图、80 张测试图。这个规模说不上大,但对词袋模型来说已经足够暴露问题了。如果直接在原始像素上做分类,效果会很差,原因很简单:像素值对光照、位置、微小形变太敏感,而词袋模型的核心思路就是绕过这个问题——先把局部特征提取出来,再把这些局部特征“翻译”成一种相对稳定的统计表示。
1.2 词袋模型的四步流水线
词袋模型本质上来源于文本检索里的 Bag of Words。文本里忽略语序,只统计词频,就能对文档进行一定程度的语义分类;图像也类似,把图片里反复出现的局部模式当作“视觉单词”,统计这些视觉单词出现的频率,形成一张图片的“词频直方图”,然后喂给分类器。
整个过程拆成四个环节:
- 特征提取:对每张图片提取若干局部描述子,常见的是 SIFT 特征。
- 构建视觉词典:把所有训练图的特征收集起来,用 K-Means 聚类,把聚类中心当作“视觉单词”。
- 图像编码:对每张图,将它的每个特征映射到最近的视觉单词,统计每个单词出现的次数,得到直方图。
- 分类:把直方图作为特征向量,训练 SVM 分类器。
这个流水线的核心假设是:不同场景在局部模式的出现频率上存在可区分的差异。比如森林里“树叶边缘”类的局部模式出现频率高,而高速公路里“直线重复结构”类的模式出现频率高。直方图就是用来捕获这种统计差异的。
1.3 为什么这个作业仍然值得认真做
也许你会说,深度学习里 CNN 本身就能端到端学习图像特征,何必再绕一圈?我的体会是,这个作业真正锻炼的是“拆解问题”的能力。词袋模型要求你理解每一个中间表示的意义:描述子是什么、聚类中心代表什么、直方图为什么是稀疏的、SVM 的决策边界在特征空间里是什么形状。这些理解在做 CNN 时往往被隐式封装了,一旦遇到新问题,缺少这种底层直觉会让你不知道从哪里下手调优。
另一个实际原因是,在很多嵌入式或一次性分析场景中,传统特征加上简单分类器依然有它的用武之地。模型小、速度快、解释性强,不需要高算力。对于课程作业来说,你还能通过这个项目学会 Python 里 OpenCV、scikit-learn、NumPy 的配合用法,这套技能迁移到任何其他视觉项目上都通用。
2. 特征提取选型:SIFT 描述子的参数与实现细节
2.1 为什么挑 SIFT 而不是 ORB 或 CNN 特征
场景识别作业中最常用的特征就是 SIFT。SIFT 对旋转、尺度缩放、光照变化都有不错的鲁棒性,这是它的经典之处。相比 ORB,SIFT 的描述子维度更高(128 维),信息量更丰富,虽然在关键点检测速度上不如 ORB,但本作业的图片量和特征数量完全不是瓶颈。相比 SURF,SIFT 在尺度空间的特征稳定性上更可控,而且在课程场景里,SURF 的专利问题也让它不如 SIFT 通用。
你可能还会想到直接用预训练 CNN 的特征图。这个思路当然可行,在某些任务上甚至能达到更好的准确率,但那就失去了作业的核心训练目标。我在完成作业时特意坚持用 SIFT,目的就是先把传统链路打通。如果你想验证两者差异,可以在作业完成后用 CNN 特征替换 SIFT,对比一下准确率变化,这会是很好的扩展实验。
2.2 密集采样与稀疏关键点采样的取舍
SIFT 有两种典型使用方式。第一种是稀疏关键点检测:用 DoG 检测器找出图像中的角点、边缘点等显著位置,然后在每个关键点周围计算描述子。第二种是密集采样(Dense SIFT):不依赖关键点检测,直接在图像上每隔固定像素步长取一个点,再在该点的邻域内计算 SIFT 描述子。
我的实验结果显示,在这个作业里,密集采样明显优于稀疏关键点检测。原因很直观:场景图像没有突出的单个物体,单纯靠 DoG 检测出的角点往往集中在纹理丰富区域,反而丢失了整体布局信息;密集采样则能让特征均匀覆盖整个图像,对各种场景类型都更公平。
密集采样的实现很简单,有两种路径。一种是用 OpenCV 手动构造关键点网格,再调用sift.compute();另一种是直接使用 VLFeat 的phow函数。这里贴出我在 Python 里的做法:
import cv2 import numpy as np def extract_dense_sift(img, step=6, size=16): """ 在图像上按固定步长提取密集SIFT描述子。 img: 灰度图 step: 采样步长(像素) size: 描述子计算区域的基础尺寸(像素) """ sift = cv2.SIFT_create( contrastThreshold=0.04, edgeThreshold=10, sigma=1.6 ) h, w = img.shape[:2] kps = [] for y in range(0, h - size, step): for x in range(0, w - size, step): center = (x + size // 2, y + size // 2) kps.append(cv2.KeyPoint(center[0], center[1], size)) kps, des = sift.compute(img, kps) return des步长step我尝试过 4、6、8、10 几种取值。步长 4 时特征太多,每张图约两万个特征,聚类耗时明显增加,准确率提升却很有限;步长 10 时特征太少,直方图统计噪声变大,准确率下降明显。最终选 6,在信息密度和计算量之间取了个平衡点。描述子计算区域size=16指的是每个采样点周围的 16x16 像素块,这个值基本沿用 SIFT 默认参数即可,不需要频繁调整。
2.3 描述子归一化和特征数量控制
SIFT 描述子本身在 OpenCV 的实现里已经做了 L2 归一化,但实际提取出的值仍然是浮点数。在送入 K-Means 之前,最好再手动做一次归一化,避免某些特征维度的数值范围过大。我用的归一化方式是 L2 归一化:
def l2_normalize(des): norms = np.linalg.norm(des, axis=1, keepdims=True) norms[norms == 0] = 1 # 防止除零 return des / norms这一步,孤立看并不显著,但配合后续聚类,会让聚类结果更稳定。我在实验中对比过,归一化后聚类中心的更新迭代更平滑,最终直方图的区分度也更清晰。
特征数量控制还有一个容易被忽略的问题:每张图的特征数不同,如果某一张图特征特别多,它会在视觉词典构建时“主导”聚类中心的方向。因此,最好限制每张图的特征数量上限。比如我对每张图密集提取后,如果特征数超过 6000,就随机采样到 6000。这不是什么高深理论,纯粹是为了防止聚类偏向某些图片。
注意:特征提取前一定要将图片转换为灰度图。SIFT 本身只处理灰度信息,彩色图需要先调用
cv2.cvtColor(img, cv2.COLOR_BGR2GRAY),否则 OpenCV 的SIFT_create会直接报错。
3. 视觉词典构建:K-Means 聚类的规模权衡与坑
3.1 训练集和测试集的信息泄漏问题
这是词袋模型里最容易错、也最影响结果可信度的坑。构建视觉词典时,用到的特征数据应该严格来自训练集。如果把测试集的特征也混进去做聚类,等于提前让分类器“看到”了测试数据的分布信息,最终准确率会虚高,作业答辩时如果被问到,很难解释清楚。
我在第一次跑通流程时,为了图省事,把所有图片的特征一股脑都合起来做了聚类。结果测试准确率比后来只用训练集聚类的版本高了将近三个百分点。当时还挺高兴,后来检查代码才发现问题,改成只用训练集特征后,准确率掉到了正常水平。这个过程中我对数据泄漏的敏感度提高了不少。
正确流程是:
train_des_all = [extract_dense_sift(img) for img in train_images] # 注意:只能用 train_images,不能用 test_images all_des = np.vstack(train_des_all) kmeans = MiniBatchKMeans(n_clusters=K, random_state=42, batch_size=10000) kmeans.fit(all_des)3.2 聚类中心数量 K 怎么选
K 是视觉词典的大小,也就是“视觉单词”的数量。K 太小,不同场景的局部模式被强行合并,直方图的区分度不够;K 太大,每个单词的频率统计变得稀疏,直方图对噪声敏感,而且聚类计算量大幅上升。
我用同一个特征提取消,分别试了 K = 100、200、400、800、1600 五组。结果如下:
| K 值 | 特征数量/图 | SVM 准确率 | 聚类耗时 |
|---|---|---|---|
| 100 | 约 5000 | 62% | 约 8 分钟 |
| 200 | 约 5000 | 68% | 约 15 分钟 |
| 400 | 约 5000 | 75% | 约 30 分钟 |
| 800 | 约 5000 | 73% | 约 55 分钟 |
| 1600 | 约 5000 | 70% | 约 2 小时 |
这里 K=400 是明显的拐点。K 继续增大,准确率不但没有提升,反而因为直方图过于稀疏而下降。经典的作业指导里也常说 K 在 200 到 1000 之间比较合理,具体最优值需要根据数据集的复杂程度和特征数量来确定。我的建议是:在 K = 200、400、800 这三个候选值下各跑一次 SVM,选准确率最高的那个。别贪多,K 翻倍带来的计算开销完全不划算。
3.3 Mini-Batch K-Means 加速与收敛判断
K-Means 在大规模特征集上很慢。我记得当时全训练集约几十万个 128 维特征,用标准 K-Means 要跑到 K=400 时,每次迭代都要计算所有样本到所有聚类中心的距离,非常吃力。后来改用 scikit-learn 里的MiniBatchKMeans,速度提升了至少五倍。
MiniBatchKMeans的核心思想是每次随机抽样一小部分样本来更新聚类中心,虽然达不到全局最优,但在这个任务上足够用。关键参数是batch_size,我设为 10000,这个值在大多数场景下都能保证聚类质量。另一个参数n_init建议保留默认值,它控制初始化次数,取较大值能减少随机性影响。
判断聚类是否收敛,我通常会看inertia_的下降趋势。MiniBatchKMeans因为使用随机抽样,inertia_会有些抖动,不会像标准 K-Means 那样严格下降。如果抖动幅度小于 1%,就可以认为收敛了。也可以设置tol=1e-4让算法自动停止。
一个重要细节:K-Means 是随机初始化,固定
random_state非常关键。不固定的话,每次运行视觉词典都不一样,实验的可复现性就没了。我顺手在代码里加了seed参数,方便复现任何一个实验配置。
4. 编码、归一化与空间金字塔:从特征到直方图的细节
4.1 硬编码直方图与稀疏表示
视觉词典构建完成后,每张图片的所有 SIFT 描述子都要被转换成直方图。最基本的做法是“硬编码”:对每个描述子,找到距离最近的视觉单词,把该单词的计数加一。最终得到一个长度为 K 的向量,每个元素是单词出现次数。
这个向量的很多维度是 0,因为一张图片通常只会覆盖视觉词典中一部分单词。所以它是一个典型的稀疏向量。虽然稀疏向量直接用没什么问题,但 SVM 在处理高维稀疏数据时有特殊优化,scikit-learn 的LinearSVC会自动利用稀疏结构,所以我们尽量保持稀疏表示,不要贸然转成稠密矩阵。
直方图编码的代码很短:
def build_histogram(descriptors, kmeans, K): labels = kmeans.predict(descriptors) hist = np.bincount(labels, minlength=K).astype(np.float32) # 这里先不归一化,稍后统一处理 return hist4.2 直方图归一化为什么直接影响准确率
直接统计频次得到的直方图有个问题:不同图片提取的特征数量不同,导致频次绝对值不可比。比如一张图片有 5000 个特征,另一张有 3000 个特征,即使它们的内容分布完全一样,直方图数值也差很多。因此,必须做归一化。
我试过两种归一化方式。第一种是 L1 归一化,把直方图除以所有计数之和,让它变成单词出现的概率分布。这是最常用的方式,效果稳定。第二种是 L2 归一化,除以向量 L2 范数,在 SVM 的高斯核场景下更常见。对于线性 SVM,我最终选用了 L1 归一化,准确率比 L2 归一化高约 1 到 2 个百分点。
归一化操作本身很简单:
hist = hist / hist.sum() # L1归一化但要注意一个极端情况:如果某张图片的特征在词典里全部落到同一个单词上,hist只含一个非零值,L1 归一化后其它维度都是 0,这样的向量依然可以参与 SVM 训练,只是区分度很差。好在实际数据中几乎不会出现这种情况。
4.3 空间金字塔匹配:一个便宜的准确率提升手段
如果只做全局直方图,图片的空间布局信息就完全丢了。比如“城市”和“街道”两类图片,局部模式可能相似,但布局不同:街道的上半部分是天空和建筑,下半部分是路面,而城市全景可能建筑占比更大。词袋模型原始版本无法表达这种差异,空间金字塔匹配(Spatial Pyramid Matching, SPM)就是为解决这个问题出现的。
SPM 的思路很朴素:把图片划分成多层网格,在每一层分别计算直方图,最后拼接起来。常见配置是三层:1x1、2x2、4x4,总共 1+4+16=21 个区域,每个区域独立做一次视觉单词直方图,最后把所有直方图拼接成一个长向量。不同层的直方图还可以赋予不同的权重:层数越细,权重可以适当降低。典型做法是 1x1 层权重 1/4,2x2 层权重 1/4,4x4 层权重 1/2。
实现也不难,在提取特征时记录每个特征对应的网格区域索引,然后按区域分别编码。我在作业中加入 SPM 后,准确率从 75% 提升到了 81%,而且计算量增加不多。如果作业要求不高,这一步属于“锦上添花”,但强烈建议做,因为它在答辩时是个很加分的分析切入点。
4.4 为什么直方图是稀疏的
我最初不理解为什么直方图会有那么多零值,直观上以为视觉单词应该覆盖所有特征。后来算了一下,当 K=400 时,一张图约有 3000 到 6000 个特征,理论上每个单词都可能被命中,但实际上大多数特征会集中落在少数几个单词上。这是因为 SIFT 描述子在高维空间里分布并不均匀,聚类中心会偏向那些纹理丰富的典型模式,而每种场景里起主导作用的模式就那么几种。
稀疏性带来的一个实际影响是,SVM 的训练速度会非常快,因为稀疏向量和权重向量的内积运算只需要处理非零维度。另一个影响是,如果 K 太大,比如超过 2000,直方图几乎每个样本都只有少数几个非零维度,分类器学到的决策边界噪声很大。这也是前面说 K 不宜过大的原因之一。
5. SVM 分类器:线性核、多分类策略与 C 参数调节
5.1 为什么线性 SVM 在这个任务上够用
在特征维度较高、样本数量一般的情况下,线性 SVM 往往比非线性核 SVM 效果更好。高维空间里样本更容易被线性超平面分开,而且线性 SVM 训练速度快,几乎没有过拟合风险。我用了 RBF 核做对比实验,在相同直方图特征下,准确率只提升了约 1%,但训练时间却多了近十倍。对于课程作业,这个时间成本很不划算。
scikit-learn 里我用的LinearSVC,它内部的优化算法是 LibLinear,专门处理高维稀疏数据。初始化时要注意设置dual=True或dual=False。当样本数远大于特征维度时,用dual=False更快;当特征维度远大于样本数时,dual=True更合适。在我们的场景里,特征维度是数百到数千,样本数也是数百,两者接近,我一般让代码自动选择。
5.2 一对多与一对一策略的影响
多分类 SVM 有两种常见策略。一对多(OvR)为每个类别训练一个二分类器,判断样本是否属于该类;一对一(OvO)为每两个类别训练一个二分类器,最后投票决定类别。LinearSVC默认使用一对多策略,而SVC默认使用一对一策略。
在场景识别任务中,一对多的实现更常见,因为类别数不算少,一对一需要训练类别数平方级别的分类器,训练开销大。我实际测试过,一对一策略的准确率比一对多略高一点点,但不稳定;一对多策略的结果更稳定,而且训练速度快得多。最终我选择了一对多,调参重心放在 C 参数上。
5.3 网格搜索调 C 的实际过程
SVM 的 C 参数控制容忍误分类的程度。C 越大,分类器越试图让训练集全部正确,容易过拟合;C 越小,分类器允许更多训练误差,但泛化能力可能更好。在词袋特征上,C 通常不需要太大,因为特征已经做了归一化,且维度适中。
我用了简单的网格搜索:
from sklearn.svm import LinearSVC from sklearn.model_selection import GridSearchCV param_grid = {'C': [0.1, 0.5, 1.0, 5.0, 10.0, 20.0]} svm = LinearSVC(multi_class='ovr', max_iter=5000) grid = GridSearchCV(svm, param_grid, cv=4, scoring='accuracy', n_jobs=-1) grid.fit(X_train, y_train) print(grid.best_params_)结果发现 C=5.0 时验证集准确率最高,比默认的 C=1.0 高约 2%。C 继续增大到 20 时,过拟合迹象开始出现,验证准确率反而下降。如果你在作业里时间有限,至少把 C 在 1、5、10 三个点上各测一次,选最优值,这比盲目用默认参数更能提升排名。
提醒:
LinearSVC的max_iter不能设太小。默认 1000 有时会不收敛,尤其在特征维度较高时。我直接设为 5000,发现训练时间几乎不受影响,但收敛警告就消失了。
6. 完整实验流程与常见错误复盘
6.1 从原始图片到最终评估的代码骨架
为了让你快速复现,我把整体流程串成一份简要代码骨架。这里的核心思路是:先提取所有训练图像特征,聚类得到词典,然后分别构建训练集和测试集的直方图,最后训练 SVM 并评估。
import numpy as np import cv2 from sklearn.cluster import MiniBatchKMeans from sklearn.svm import LinearSVC from sklearn.metrics import accuracy_score, confusion_matrix # 1. 读取训练和测试图片路径 train_paths = [...] # 你的训练图片路径列表 test_paths = [...] # 你的测试图片路径列表 # 2. 提取全部训练特征并构建词典 def extract_all_features(path_list, step=6): all_des = [] img_des_count = [] for p in path_list: img = cv2.imread(p, cv2.IMREAD_GRAYSCALE) des = extract_dense_sift(img, step=step) # 每张图限制特征数量 if len(des) > 6000: idx = np.random.choice(len(des), 6000, replace=False) des = des[idx] all_des.append(des) img_des_count.append(len(des)) return np.vstack(all_des), img_des_count train_des_all, _ = extract_all_features(train_paths) kmeans = MiniBatchKMeans(n_clusters=400, random_state=42, batch_size=10000) kmeans.fit(train_des_all) # 3. 构建直方图表示 def build_hist_for_list(path_list, kmeans, K): hists = [] for p in path_list: img = cv2.imread(p, cv2.IMREAD_GRAYSCALE) des = extract_dense_sift(img, step=6) labels = kmeans.predict(des) hist = np.bincount(labels, minlength=K).astype(np.float32) hist /= hist.sum() hists.append(hist) return np.array(hists) X_train = build_hist_for_list(train_paths, kmeans, 400) X_test = build_hist_for_list(test_paths, kmeans, 400) y_train = ... y_test = ... # 4. 训练SVM并评估 clf = LinearSVC(C=5.0, multi_class='ovr', max_iter=5000) clf.fit(X_train, y_train) pred = clf.predict(X_test) acc = accuracy_score(y_test, pred) print('accuracy:', acc) print(confusion_matrix(y_test, pred))这已经是一个可运行的完整流程。如果加入 SPM,只需要把build_hist_for_list改成按金字塔分层统计即可。
6.2 我踩过的高频坑和数据泄漏问题
第一个坑是特征数量限制。一开始我为了“不丢信息”刻意不限制每张图的特征数量,结果某些高纹理图片贡献了异常多的特征,聚类中心几乎会被它们主导。限制到统一上限后,词典质量有明显提升。
第二个坑是图片尺寸差异大。部分数据集图片的尺寸从 256x256 到 1024x768 不等,如果不做尺寸统一,密集采样得到的特征数量差异会非常大,直方图虽然归一化过,但尺度差异仍然会引入偏差。我最终把所有图片都缩放到长边不超过 512 像素,再提取特征,这样特征数量稳定在可控范围。
第三个坑是忘记灰度化。这个错误发生在第一次跑通流程时,当时直接用彩色图调用SIFT_create,OpenCV 直接抛异常。后来检查文档才知道 SIFT 只接受单通道图像。这类低级错误在课程作业中非常频繁,同学们要养成先灰度化再处理的好习惯。
第四个坑是数据泄漏。前面提过,视觉词典只能用训练集特征构建。我还发现有人把整个数据集放在一起做特征标准化后再切分,这也是数据泄漏的一种。在词袋模型里,唯一应该只从训练集计算的就是聚类中心。测试集只负责映射到词典,不能参与词典生成。
6.3 提升准确率的二次改进:特征降维与横竖比归一化
如果你做完基础流程想要更高分,有两条被验证有效的路。
第一条是 PCA 降维。SIFT 描述子通常是 128 维,但如果直接对描述子做 PCA,可以降到 64 维或 32 维。降维后,描述子本身的噪声减少,聚类效果有时反而更好。我做了 64 维降维实验,准确率提高了约 1%。用sklearn.decomposition.PCA(n_components=64, whiten=True)即可,但要记得 PCA 的参数要用训练集特征拟合,测试集特征只做变换,避免泄漏。
第二条是图像横竖比统一。很多场景图片的朝向很随意,手机拍照有的是横向有的是竖向。密集采样时,如果图片过宽或过高,边缘区域的特征分布不均匀。我最终把所有图片统一为较短边 256 像素、保持宽高比裁剪,这一步让准确率又提升了接近 1%。这类“土办法”效果虽小,但在作业排名里常常是决定性的。
还有个不太起眼但很实用的技巧:在构建直方图时,可以把 SIFT 描述子本身做一次权重的软分配,而不是硬分配。就是每个特征不只投给最近的单词,而是按距离给前几个单词分配权重。这个叫软量化(Soft Assignment),我试过后在准确率上又提升了 1% 左右。不过它会让直方图不再是严格稀疏,计算量也变大,建议在完成基础版本后再尝试。
完成这个作业后,我最大的感受是:与其说它考验的是视觉知识,不如说考验的是对整条数据处理管线的掌控力。特征怎么提、词典怎么建、直方图怎么编、分类器怎么调,每一环单独看都不算难,难的是把它们组合起来并保证每个环节都不泄漏信息、不浪费算力。做完这份作业,再看那些动不动就要上大模型的场景识别任务,你会下意识地问一句:真有必要吗?有时候一个经典的 K-Means 加线性 SVM,就已经能在小规模数据上做出非常体面的结果。这份对“简单方案”的敏感度,是深度学习框架给不了你的。