ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

PCA降维与特征脸:从SVD到人脸识别工程实践

PCA降维与特征脸:从SVD到人脸识别工程实践

简介:面向Python初学者与课程实践者的PCA人脸识别完整代码包,围绕主成分分析降维与距离分类流程,提供可直接运行的识别示例与图形化界面,适合用作模式识别、机器学习课程设计或毕业项目的基础原型。压缩包共37个文件,含3个Python脚本、30张训练与测试人脸图片、2个db文件及说明文本、缓存文件,整体约255KB;三个脚本分别承担功能函数、wxPython图形界面和实验参数调整,需在Python3.7环境并安装wxPython后使用。已有2128人学习,说明其作为入门级实战素材具有一定参考度。读者可据此理解PCA特征提取、训练样本组织、界面调用与测试流程,并借助现成训练/测试目录快速复现实验、修改参数或扩展识别功能。

1. 特征脸不是凭空来的:一张脸压成几十维为什么还能认出人

把一张 100×100 的灰度人脸拉成 10000 维向量,用 PCA 压到只剩 40 维,系统还是能把人认个七七八八——第一次看到这个结果的人多半会觉得是玄学。降维丢掉了 99.6% 的维度,按直觉,区分张三和李四的那些细节应该一起被扔掉才对。

关键在于人脸像素之间高度相关。10000 个维度里,绝大多数方向上的方差接近于零,那些方向承载的是光照的明暗过渡、传感器的底噪、脸颊和额头的平滑区域,跟“这是谁”几乎无关。PCA 做的事就是找出方差最大的少数几个正交方向,把脸投影上去。

所以那几十个数字不是缩略图,而是这张脸相对平均脸的偏移,在若干种典型变化模式上的分量。业界把这些模式的可视化结果叫特征脸。搞懂这套机制,你才能回答一个很实际的问题:为什么调参时把维度从 40 提到 200,准确率不升反降。

2. 从像素矩阵到协方差谱:PCA 到底把什么结构分离出来了

PCA 的全部内容只有一句话:找一组正交基,让数据投影后的方差依次最大。这句话写过很多遍,但真正决定这套方法能不能在人脸上跑起来的,是三个容易被跳过的细节——中心化、矩阵规模,以及秩的上界。

2.1 中心化:平均脸为什么必须先被减掉

方差的定义本身就依赖均值。协方差矩阵写出来是E[(x-μ)(x-μ)ᵀ],如果 μ 不是零,你求出来的“最大方差方向”就不再是数据内部的变化方向,而是数据整体偏离原点的方向。放到人脸图上,这个差别是致命的:一张人脸的灰度均值大概在 100 到 150 之间,而两张不同人脸在同一像素上的差异往往只有正负二三十。不减去均值,第一主成分会毫不犹豫地指向“整体亮度”,把身份信息挤到后面去。

减去均值之后的那个向量本身就是一张图,叫平均脸,看起来是一张模糊的、没有任何个人特征的人脸轮廓。它承担了两个作用:一是作为投影时的原点,二是在重构时加回去,让输出重新回到像素空间。训练阶段算出的均值必须存下来,识别阶段用同一份,不能各自算各自的。

代码里最容易出错的地方是轴向。X.mean(axis=0)得到的形状是(D,),X.mean(axis=1)得到的是(N,),后者在广播时会悄悄算出一堆错误结果却不报错。常见的实现思路是先把X的形状断言成(n_samples, n_features),再动手。

注意:中心化用的均值必须来自训练集,并且只算一次。用测试集的均值去中心化测试集,是最隐蔽的数据泄漏之一。

2.2 协方差矩阵:10000×10000 的分解为什么根本算不动

假设你有 200 张 100×100 的图,数据矩阵A的形状是(200, 10000)。协方差矩阵C = AᵀA / (N-1)的形状是(10000, 10000),元素个数一亿,用双精度存就是 800MB。这还只是存储,特征分解的复杂度是O(D³),量级在 10¹² 次浮点运算,单机上跑到天亮也出不来结果。

但这里有一个数学上很漂亮的事实:A的形状是(N, D),它的秩最多是min(N, D);中心化之后,所有列的和为零,所以秩的上界进一步收紧到N-1。200 个样本,最多只有 199 个非零特征值。换句话说,10000 维空间里的人脸数据,其实全部躺在一个 199 维的子空间里,剩下 9801 个方向上的方差严格为零。

这就是文献里说的小样本问题:样本数远小于特征维度。它带来一个直接后果——你根本不需要算 10000×10000 的矩阵,因为那个矩阵的绝大部分特征值本来就是零。

2.3 小样本捷径:从 A·Aᵀ 到 SVD 的两条等价路线

既然秩只有 N-1,就可以改算L = A Aᵀ,形状是(N, N),200×200 的矩阵分解起来是毫秒级的。设u是L的特征向量、对应特征值 λ,那么v = Aᵀu / ‖Aᵀu‖就是AᵀA的特征向量,特征值同为 λ。这条路径在早期的特征脸论文里被反复提到,好处是内存小,坏处是要额外做一次归一化,而且当 λ 很小时数值不稳定。

工程上更常用的是直接对A做奇异值分解。A = USVᵀ,其中Vᵀ的每一行就是主成分方向,S²/(N-1)就是对应的方差。SVD 不显式构造协方差矩阵,数值稳定性好得多,这也是各类线性代数库默认推荐的路线。

import numpy as np A = X - X.mean(axis=0) # (N, D),已中心化 U, S, Vt = np.linalg.svd(A, full_matrices=False) # Vt: (min(N,D), D),每一行是一个主成分方向(特征脸) # S: (min(N,D),),奇异值,按降序排列 explained_variance = (S ** 2) / (X.shape[0] - 1) explained_ratio = explained_variance / explained_variance.sum()

full_matrices=False这个参数值得单独说一句:默认值True会返回完整的(N, N)和(D, D)矩阵,当 D 是一万时直接爆内存;设成False后只返回min(N, D)个奇异向量,正好是你需要的数量。explained_ratio用来判断取多少维合适,累计到 0.9 左右通常是个合理起点。

还有一个容易忽略的事实:特征向量的符号是不确定的。v和-v都是合法的解,投影后的距离完全不受影响,但你保存下来的模型文件每次重新训练都可能长得不一样,可视化出来的特征脸也可能黑白翻转。想让结果可复现,就得在保存前对每个分量做符号规范化,比如强制让绝对值最大的那个元素为正。

3. 特征脸空间:训练阶段每个数组到底装了什么

理解了上面的数学,再看代码就不会觉得是在调库。训练阶段真正被保存下来的只有四个数组,识别阶段的所有运算都是它们之间的乘加。

3.1 数据形状:四个数组撑起整个模型

X是(N, D),每行一张拉平并归一化过的脸;mean_是(D,)的平均脸;components_是(k, D)的前 k 个特征脸,按方差从大到小排列;explained_variance_是(k,),记录每个方向上的方差。如果做的是闭集分类,还要额外存一份(N,)的标签和图库投影结果(N, k)。

存储量算一下就知道为什么特征脸适合小算力场景:k=50、D=10000 时,components_只占 50×10000×4 字节,大约 2MB。图库有 1000 人时,投影结果也只有 1000×50 个浮点数。整个模型可以塞进几百 KB 到几 MB,这对嵌入式设备是有意义的。

预处理必须在训练前统一确定:缩放尺寸、灰度化、直方图均衡、除以 255 之类的归一化,全部固定成一套流程,训练和推理走同一个函数。任何一处不一致,都会在投影阶段被放大成距离上的偏移。

3.2 fit 的源码级展开:中心化、SVD、截断

import numpy as np class EigenFaceModel: def __init__(self, n_components=50): self.n_components = n_components def fit(self, X): # X: (N, D) float32/float64,每行一张已预处理的拉平人脸 X = np.asarray(X, dtype=np.float64) assert X.ndim == 2 self.mean_ = X.mean(axis=0) # (D,) 平均脸 A = X - self.mean_ # 中心化,不可省略 # 对 A 直接做 SVD,避免构造 D×D 的协方差矩阵 U, S, Vt = np.linalg.svd(A, full_matrices=False) k = min(self.n_components, Vt.shape[0]) self.components_ = Vt[:k].copy() # (k, D) 特征脸 self.explained_variance_ = (S[:k] ** 2) / max(X.shape[0] - 1, 1) # 符号规范化:让每个分量绝对值最大的元素为正,保证可复现 idx = np.argmax(np.abs(self.components_), axis=1) signs = np.sign(self.components_[np.arange(k), idx]) self.components_ *= signs[:, None] return self def transform(self, X): X = np.asarray(X, dtype=np.float64) return (X - self.mean_) @ self.components_.T # (N, k) def inverse_transform(self, Z): return Z @ self.components_ + self.mean_ # (N, D) 重构回像素空间

fit里最值得注意的是截断的位置。SVD 一次算出全部min(N, D)个奇异向量,然后只取前 k 个——对小数据集来说这已经足够快;样本上万时,常见的实现思路是换成随机化 SVD,只近似求前 k 个奇异三元组,代价是结果带一点随机性,需要固定随机种子才能复现。

transform那一行是两次矩阵乘法:先减均值,再乘components_的转置。形状上(N, D) @ (D, k) = (N, k),得到的就是每张脸在前 k 个特征脸方向上的坐标。注意这里没有除以奇异值,所以坐标的尺度与方差正相关,方差大的方向坐标数值也大,这在后面做距离度量时会产生影响。

inverse_transform是重构:把 k 维坐标乘回(k, D)再加平均脸。它给出的不是原图,而是“用 k 个特征脸能拼出的最接近的近似”。

3.3 平均脸与特征脸:为什么前几个分量像光照模板

把components_的每一行 reshape 回图像尺寸,你会看到一个很典型的分布:前几个分量是大面积的明暗渐变,左右脸一强一弱,像是把光从一侧打过来的效果;中间几个分量开始出现眼窝、鼻梁、下颌这些局部结构;再往后基本就是高频噪声,肉眼几乎看不出人脸形状。

这个分布不是巧合。方差最大的方向,必然是变化最剧烈的方向,而同一批人脸数据里变化最剧烈的东西往往不是身份,是光照方向、整体对比度和姿态造成的形变。PCA 是无监督的,它只关心方差,不关心这个方差是不是来自“不同的人”。这是特征脸方法最根本的局限,也是后面调参时需要反复权衡的地方。

注意:把特征脸按方差从大到小排列后做可视化,前十几个通常还能看出结构,超过 100 维之后基本是噪点。这提示你一个经验:靠后的分量携带的判别信息极少,但过拟合风险很高。

4. 识别阶段:一张新脸是怎么被投进空间再比出来的

训练完成后,识别只剩下三步:预处理、投影、比对。真正决定系统好不好用的不是前两步,是第三步的距离度量和拒绝策略。

4.1 投影:新脸的坐标由内积决定

一张新脸x进来,减去训练集均值得到x - μ,再与每个特征脸做内积,得到 k 个坐标。几何上这是把x - μ投影到由特征脸张成的子空间里,坐标就是投影在各基向量上的长度。

如果用的是 SVD 而非协方差分解,注意components_已经是单位正交向量,内积就直接等于投影长度,不需要额外归一化。如果自己用 Gram 矩阵路线算出的v忘了单位化,投影坐标会被整体缩放,距离阈值随之失效——这是自己手写实现时最常见的翻车点。

投影还有个隐含前提:新脸在预处理后的像素尺度必须和训练集一致。训练图缩放到 100×100 并做了直方图均衡,推理时却忘了均衡,投影坐标会整体偏移,表现出来就是“所有人都被判成库里最接近的那个”。

4.2 距离与阈值:最近邻加一条拒绝线

最简单的分类器是最近邻:把新脸的 k 维坐标和图库里每个人的投影比一遍,取距离最小的那个标签。欧氏距离是最常用的度量,‖z_probe - z_gallery‖₂。因为特征脸互相正交,这个距离在原空间里等价于“两个人在子空间内的差异”,残差部分被丢掉了。

余弦距离衡量的是方向而非长度,在光照造成的整体幅度变化明显时更稳一些。理论上更有依据的是马氏距离:如果把每个方向的坐标除以其标准差(这一步叫白化),再算欧氏距离,就等价于在原空间里按各方向方差归一化的马氏距离。白化的代价是低方差方向被放大,噪声也一起被放大,实践中要看数据决定。

import numpy as np def predict(model, gallery_X, gallery_y, probe, threshold=None): G = model.transform(gallery_X) # (M, k) 图库投影 z = model.transform(probe[None, :])[0] # (k,) 探针投影 d = np.linalg.norm(G - z, axis=1) # (M,) 欧氏距离 idx = int(np.argmin(d)) if threshold is not None and d[idx] > threshold: return -1, float(d[idx]) # -1 表示判为库外人员 return int(gallery_y[idx]), float(d[idx])

threshold这个参数不是拍脑袋定的。做法是拿一份独立验证集,统计同一人的距离分布(genuine)和不同人的距离分布(impostor),在两条分布的交叠处找一个工作点;如果业务更怕误拒,就往上调,更怕误认,就往下调。这个阈值的绝对数值和 k、图像尺寸、归一化方式全都相关,换一套预处理就得重标。

4.3 重构误差:PCA 顺手给出的一个真伪信号

inverse_transform在这里派上用场。把探针投影到 k 维再重构回像素空间,得到的x̂和原图x之间的残差‖x - x̂‖,衡量的是这张脸有多少内容落在特征脸张成的子空间之外。对库内人员的正常人脸照片,残差通常较小;对一张根本不是人脸的图,或者极端光照下的图,残差会明显偏大。

这就是开集识别里常用的拒识依据:距离阈值判断“像不像库里的人”,重构误差判断“像不像一张正常的人脸”。两者可以组成二维判决区域,也可以加权成一个分数。工程上还有个变体,把残差按像素数归一化,避免图像尺寸变化带来的偏差。

值得强调的是,重构误差和最近邻距离并不重复。前者衡量的是子空间的覆盖能力,后者衡量的是子空间内的接近程度。只用一个,都会在特定场景下失灵:只用距离,库外人员总会被硬塞给最近的那个人;只用残差,长得像但确实不是同一个人的样本会被放过。

5. 常见问题与排查:准确率卡在 60% 的真实原因

特征脸这套算法本身没什么非线性,出问题的地方几乎都在数据和工程细节上。下面这几条是在实际调试里反复遇到的。

5.1 数据泄漏与同源样本:准确率虚高的头号来源

现象:训练集和测试集上准确率都在 95% 以上,换一批真实场景的照片直接掉到 40%,怎么看都不像是算法问题。

原因:划分数据集时按图片随机划分,而不是按人划分。同一个人的多张照片往往来自同一段采集、同一光照、同一个角度,随机划分会让同一个人的近似副本同时出现在训练和测试里,模型等于在背答案。更隐蔽的情况是同一张图经过不同的数据增强后分到了两边。

解决:按身份划分,训练集和测试集的人员集合互不重叠(开集),或者至少保证同一个人的所有图片只出现在一侧(闭集)。测试集最好来自不同的时间段或不同的采集条件,这样得到的数字才代表真实泛化能力。这一步没做对,后面所有调参都是自欺欺人。

5.2 矩阵规模与内存:协方差分解的翻车现场

现象:程序跑起来之后内存一路飙升,最后被系统杀掉;或者跑了一晚上还没出结果,CPU 占满但进度不动。

原因:按教科书的公式老老实实算了C = AᵀA,构造出一个D×D的矩阵。图像尺寸一旦到 200×200,D 就是 40000,协方差矩阵有 16 亿个元素,双精度要 12GB 以上,特征分解的复杂度更是天文数字。

解决:永远不要显式构造协方差矩阵。数据量小时用np.linalg.svd(A, full_matrices=False),数据量大、k 远小于min(N, D)时用随机化 SVD。如果非要用协方差路线,就走N×N的 Gram 矩阵再换算回特征向量,同时记得做单位化。

5.3 预处理顺序与增量更新:两处容易忽略的坑

现象一:为了提高鲁棒性,对每张图单独做了 z-score 标准化(减自身均值、除自身标准差),结果识别率反而下降,同一人在不同光照下的距离变得更近,不同人之间却也开始混淆。

原因:PCA 依赖的是样本间的相对差异。对单张图做标准化会把“这张脸整体偏亮还是偏暗”这类信息抹掉,同时把低对比度图像的噪声放大到和信号同等量级。PCA 需要的是全局一致的中心化和缩放,不是逐样本的。

解决:预处理阶段只做几何和光照的物理归一化,比如缩放、灰度化、直方图均衡、除以 255;统计意义上的中心化和缩放统一放在模型里,用训练集统计量,并且只算一次。

现象二:图库新增了 20 个人,重训一次要跑十几分钟,线上服务只能停机更新。

原因:PCA 的基向量依赖全体样本,均值变了、协方差变了,所有特征脸都会变,老的投影结果全部作废。这不是实现问题,是方法本身的性质。

解决:分两条路。一是接受批量重算,把重训做成离线任务,用缓存保存中间结果,切换时原子替换模型文件;二是用增量 SVD,把新增样本的贡献合并进已有的奇异三元组,代价是精度略有损失,需要定期做一次全量重算来校正漂移。选哪条取决于更新频率和精度要求。

注意:任何一次预处理逻辑的修改,都必须同时重训模型并重新标定阈值。只换推理端的预处理,等于让模型在另一个分布上工作。

6. 设计取舍:留多少维、留多少脸,才不算自欺欺人

k 的选择是这套方法里唯一需要拍板的核心参数,而它没有普适答案。

k 的取值保留方差(经验量级)表现主要风险
10 到 2070% 到 85%速度最快,对光照变化不敏感欠拟合,不同人挤在一起
40 到 8090% 到 95%常规场景的平衡点对姿态和配准误差敏感
150 以上98% 以上训练集上几乎完美记住噪声,换数据就崩

一个反直觉的结论是:保留方差高不等于识别率高。方差大只说明这个方向变化剧烈,不说明它区分人。前几个主成分往往被光照和整体明暗占据,它们方差最大,判别力却最弱。所以单纯按“累计方差 95%”选 k,经常选出一堆对分类没帮助的方向。想验证这件事,把每一维单独拿去做最近邻,看哪个区间的维度贡献了主要准确率,你会看到一条先升后降的曲线,峰值通常远早于 95% 方差的位置。

还有一个常被忽略的取舍是子空间方法本身的边界。PCA 是无监督的,它不知道标签,所以不可能主动去找“类间距离大、类内距离小”的方向。想利用标签信息,就得换到线性判别分析那一类方法,代价是需要每个人有足够多的样本,否则类内散度矩阵会奇异。深度模型把特征提取这一层整个换掉了,在光照、姿态、遮挡这些维度上优势明显,但它需要的数据量和算力是特征脸方法的好几个数量级。

所以在今天,特征脸还值得研究吗?我的看法是值得,但理由不是性能。它把一个完整的人脸识别流水线压缩到几十行矩阵运算里,每个环节都能被数学解释清楚:为什么要中心化、为什么秩只有 N-1、为什么阈值要重标。这些东西在调深度模型时同样是底层直觉——你调 embedding 维度、挑距离度量、设拒识阈值时遇到的问题,本质上是同一个问题的不同表现形式。再加上它对算力和存储的要求极低,在嵌入式、离线批处理、教学演示这些场景里仍然是一个务实的选择。

模型上线前那次翻车我印象很深:训练集上 98%,现场演示的时候把两个人连续认错。查了半天是演示脚本里的预处理函数少了一行灰度化,图片以三通道喂进去,维度对不上被静默截断。从那以后我每次保存模型都强制走一遍端到端自检——加载、读一张图、打印投影坐标和距离、和训练时同一张图的输出比对,数值对不上就不许上线。这个习惯后来在别的项目里也救过我好几次。希望帮到你。

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

返回列表