☰
慢特征分析SFA原理详解与Python实现:从时序数据提取稳定特征
2026/10/20 16:53:08 网站建设 项目流程

1. 从“慢”出发,理解SFA的核心想法

第一次听到“慢特征分析”(Slow Feature Analysis,简称SFA),很多人会下意识地问一句:特征不是越有区分度越好吗,“慢”有什么好处?

这个直觉放在静态任务里没错,但一旦面对的是时间序列、视频、传感器流、脑电这类随时间变化的数据,“慢”反而意味着稳定、本质、可泛化。举个例子:你观察一只猫从远处走近,猫的姿态、光影、遮挡都在快速变化,但“它在移动”这个事实几乎是稳定的。如果我们能从快速变化的输入信号里,把这种变化速率很低的潜在因素提取出来,那才是真正有意义的高层特征。SFA做的就是这件事:给出一段随时间变化的序列,自动寻找变换,使得输出信号随时间变化尽量缓慢。

我第一次接触SFA是在做动作识别相关实验的时候。当时手上有一段关节角度记录,包含了不同的挥手、行走动作,传统的PCA、ICA在那批数据上都出现过“特征在某个时间段剧烈抖动”的问题。换用SFA之后,输出特征不仅稳定,而且天然携带了“动作快慢”的层次,后续分类器几乎不用调参就能达到很好的效果。后来我仔细读了一遍Wiskott和Sejnowski提出的原始数学框架,才意识到这个方法的优雅程度远比我想象中高——它不依赖标注、不需要手工设计损失函数,只需要一段有时间结构的数据,就能学到一种有序的特征表示。

这篇博文适合三类人来看。第一类是刚接触特征工程和时序数据、想知道“除了傅里叶变换和小波还有什么思路”的学习者;第二类是已经在用PCA等线性降维方法、想引入时间结构信息的工程师;第三类是准备复现论文、需要一份可直接改动的Python代码的科研人员。我会把数学原理讲明白,再把代码逐段拆开,最后附上实际踩坑记录。整篇文章的目标是:你用一把 numpy 就能完整跑通SFA,并且理解每一个中间矩阵是做什么的。

2. SFA的数学原理,一次性讲清楚

2.1 优化目标:让输出信号的变化速率最小

SFA的正式定义很简洁。给定一个多维输入信号 x(t)(t 是时间索引),我们想找一个映射 g: x → y,使得输出信号 y(t)=g(x(t)) 满足两个条件:

  • 输出均值为零,也就是归一化后的中心化条件;
  • 输出方差为1,约束输出尺度,防止平凡的常数解(比如全部输出都是0,变化率倒是极小,但毫无意义)。

在这两个条件的约束下,我们最小化输出信号的“变化速度”:

Δ(y) = ⟨ ẏ² ⟩

这里的 ẏ 表示 y 对时间的一阶差分,尖括号表示时间平均。为什么最小化差分平方就能得到慢特征?因为如果一个信号在时间上变化得很缓慢,那么相邻时间点之间的差异就会很小,差分平方的平均值自然就小。这等价于在说:我们要找到一组权重,使得投影后的信号尽可能地“平缓”。

这个目标的精妙之处在于,它没有直接定义“什么特征重要”,而是定义了“什么样的特征是本质的”。时间上越稳定,越有可能是底层变量。光照变化、噪声、短期抖动都是快速分量,它们会被自动排在后面;物体身份、场景类别、状态属性这类长期稳定信息,会排在最前面。这就是SFA输出特征的排序逻辑——按慢到快排列,第一个输出最慢,第二个次之,依此类推。

2.2 非线性扩展:线性变换解决不了的问题

你可能会觉得,SFA不就是在找一个线性变换吗?PCA也能找到一个线性变换,让我把输入投影到某个方向上。两者到底差在哪?

关键的区别在于,PCA只寻找原始特征空间中的线性方向,即便原始数据是高度非线性的流形,PCA也只能看到一个线性影子。SFA虽然最终的求解过程是线性的(特征值问题),但它在进入这个过程之前,会先把输入信号做一个非线性映射,把每个原始特征变换到更高维的“特征空间”。最常见做法是二阶多项式扩展:

h(x) = [x₁, x₂, ..., x_D, x₁², x₁x₂, ..., x₁x_D, x₂², ...]

也就是把原来的 D 维向量变成一个包含线性项、平方项、交叉项的更高维向量。这样一来,在扩展后的空间里去做线性变换,就等价于在原始空间里学习一个多项式形式的非线性映射,能学习到特征之间的非线性关系。

打个比方,你用眼睛看一个旋转的立方体,顶点坐标在快速变化,但在更高维的表示里,“立方体本身”这种不变关系是可以被线性编码的。SFA借助非线性扩展,构造出一个足够丰富的空间,使得复杂任务在这个空间里变得线性可分、线性可投影。

注意:扩展的阶数并不是越高越好。阶数越高,维度爆炸就越明显,后面白化步骤里的协方差矩阵会变得非常不稳定,计算代价也急剧上升。实践中最常用的是二阶,偶尔用三阶,再高就要结合正则化或随机特征扩展方案。

2.3 白化:为慢特征求解铺路

在求解慢特征之前,必须先对扩展后的信号做白化。这个步骤表面上看起来只是“标准化”,但其数学角色远不止于此。

白化的目标是让扩展后的数据在各个方向上具有单位方差,并且彼此不相关。也就是说,我们希望输入信号经过某个线性变换后,协方差矩阵变成单位矩阵 I。具体做法:

  1. 对扩展后的数据去均值,获得零均值信号;
  2. 计算协方差矩阵 C = ⟨h hᵀ⟩;
  3. 对 C 做特征值分解 C = UΛUᵀ;
  4. 定义白化变换 W_white = U Λ^(−1/2) Uᵀ 或者更简单的左乘形式,令白化后的信号 z = W_white h。

白化为什么是必要的?因为慢特征的求解依赖广义特征值分解。广义特征值问题的格式是 A w = λ B w,其中 A 是差分协方差矩阵,B 是信号自身的协方差矩阵。如果 B 不是单位阵,问题会变成需要同时处理两个矩阵,数值上容易出问题。白化之后,B 变成单位矩阵,广义特征值问题就退化成了普通特征值分解,直接解 A w = λ w 即可。

从另一个角度看,白化也是在消除各维度量纲和尺度差异的影响。想象一个二维输入,x₁ 的取值范围是 0~100,x₂ 的取值范围是 0~1,如果不做白化,慢特征学习会被 x₁ 的尺度主导,完全忽视 x₂ 中的有用信息。白化把各方向拉齐,让后续优化更公平。

3. Python代码实现:从零开始写一个SFA

3.1 整体代码结构设计

我不太喜欢直接把一个封装好的SFA类丢出来让人黑盒调用,那样出了bug也不知道在哪。更推荐的方式是先把每一步写成独立函数,最后再组装成一个类。这样每一步都能单独调试,也方便你替换不同的非线性扩展策略。

整个实现分为四层:

  1. 非线性扩展层,负责把原始输入映射到高维多项式空间;
  2. 白化层,负责去均值、标准化、去相关;
  3. 慢特征求解层,负责计算时序差分协方差矩阵,做特征值分解;
  4. 投影层,负责把新数据映射到慢特征空间。

这种分层的设计还有一个好处:测试的时候可以单独对白化层做断言,检查协方差矩阵是否逼近单位矩阵;也可以单独对慢特征求解层做断言,检查输出变化率是否真的递减。

3.2 构建输入矩阵的约定

我习惯把输入数据组织成形状 (T, D) 的二维数组,T 是时间步数,D 是原始特征维度。如果你拿到的是 (D, T),先转置一下。这个约定和大部分科学计算库一致,后续做矩阵乘法时不需要反复transpose,逻辑更清晰。

对于单条时间序列,T 可以看作是采样点数;对于视频或传感器数据,T 是帧数或采集时刻数。不同的数据只要整理成这个形状,下面的代码就都能用。

3.3 非线性扩展函数的实现

下面这段代码实现二阶多项式扩展。为了清晰,我把扩展分成三块:线性项、平方项、两两交叉项。

import numpy as np def polynomial_expansion(x, degree=2, with_cross_terms=True): """ 对输入序列做多项式扩展。 参数: x: 形状 (T, D) 的输入信号 degree: 多项式阶数,目前支持1和2 with_cross_terms: 是否包含交叉项 返回: 扩展后的特征矩阵,形状由包含的项决定 """ T, D = x.shape terms = [x] if degree >= 2: # 平方项: x_i^2 terms.append(x ** 2) if with_cross_terms: # 交叉项: x_i * x_j, i < j for i in range(D): for j in range(i + 1, D): terms.append((x[:, i] * x[:, j])[:, np.newaxis]) return np.hstack(terms)

如果你的输入维度很多,交叉项数量会呈平方级增长。比如 D=20,交叉项就有 190 个,加上平方项和线性项,扩展后的维度是 20+20+190=230,勉强可以接受。但如果 D=100,扩展后就超过 5000 维,白化过程会非常吃力。这种情况我建议先对原始输入做PCA压缩,把 D 降到 10~20 维再做多项式扩展。

3.4 白化模块:稳定数值这样处理

白化模块是SFA的数值基石。我见过不少人在这一步出问题,主要原因是协方差矩阵特征值过小或接近零,导致除以一个接近零的量,数值爆炸。

def whiten(x, epsilon=1e-8): """ 对输入信号做白化处理。 参数: x: 形状 (T, M) 的扩展特征矩阵 epsilon: 特征值下限,防止除零 返回: whitened: 白化后的信号, 形状 (T, M) params: 包含均值、白化矩阵等参数,用于对新数据做同样变换 """ # 1. 去均值 mean = np.mean(x, axis=0) x_centered = x - mean # 2. 计算协方差矩阵 cov = np.cov(x_centered, rowvar=False) # 3. 特征值分解 eigvals, eigvecs = np.linalg.eigh(cov) # 4. 防止特征值过小 eigvals_safe = np.maximum(eigvals, epsilon) # 5. 白化矩阵: 旋转到特征向量方向,再按特征值缩放 whiten_matrix = eigvecs @ np.diag(1.0 / np.sqrt(eigvals_safe)) @ eigvecs.T # 6. 应用白化 whitened = x_centered @ whiten_matrix params = { 'mean': mean, 'whiten_matrix': whiten_matrix } return whitened, params

这里用np.linalg.eigh而不是np.linalg.eig。eigh专门用于对称矩阵,速度和稳定性都更好,而且返回的特征值有序排列。协方差矩阵天然是对称半正定的,直接用它就对了。

epsilon 参数的设定也有讲究。设得太小,比如 1e-12,等于没设;设得太大,比如 1e-2,会毁掉原本有意义的特征方向。我的经验是,先通过np.min(eigvals)查看一下特征值分布,再根据量级调整。如果特征值范围是 1e-3 ~ 10,设 1e-8 比较合适;如果输入本身已经做了标准化,特征值基本集中在 1 附近,设 1e-6 也够。

3.5 求解慢特征:差分协方差矩阵的意义

白化之后,信号 z(t) 已经是一个零均值、单位协方差的信号。接下来要计算时间结构上的变化,具体做法是计算相邻时间点的差分:

dz(t) = z(t+1) − z(t)

然后计算差分信号的协方差矩阵:

B = ⟨dz dzᵀ⟩

这个矩阵的对角线元素就是每个白化维度上的时间变化方差,非对角线元素则捕捉了维度之间变化的相关性。我们的目标是找一组权重 w,使得 wᵀ z 的变化率最小。由于 z 已白化,约束 w 的长度为1即可,问题就变成:

minimize wᵀ B w subject to ||w|| = 1

这恰好是瑞利商形式,最优解就是 B 的最小特征值对应的特征向量。按特征值从小到大排列,依次取前 K 个特征向量,得到的就是前 K 个慢特征变换方向。

def sfa_step(whitened, num_features=None): """ 从白化信号中求解慢特征。 参数: whitened: 白化后的信号, 形状 (T, M) num_features: 要保留的慢特征数量 返回: sfa_output: 投影后的慢特征, 形状 (T, num_features) sfa_matrix: 形状 (M, num_features),用于新数据投影 delta_vals: 对应特征值,代表慢度指标 """ T, M = whitened.shape # 计算时间差分 diff = whitened[1:] - whitened[:-1] # 差分信号的协方差 diff_cov = np.cov(diff, rowvar=False) # 特征值分解,特征值升序排列 eigvals, eigvecs = np.linalg.eigh(diff_cov) # 按慢度排序: 特征值越小越慢 # eigh 已经按升序排列,所以直接取前 num_features 个 if num_features is None: num_features = M sfa_vecs = eigvecs[:, :num_features] sfa_output = whitened @ sfa_vecs return sfa_output, sfa_vecs, eigvals[:num_features]

看到这里,细心的朋友可能会问:差分协方差矩阵 B 的特征值大小和不做差分时信号自身的方差有没有关系?有关系。白化已经保证了信号自身协方差是单位阵,即每个维度的平均功率一致。这种情况下,特征值越小,意味着该方向上的时间变化功率越小,也就是越慢。你不需要额外再做归一化,因为白化已经替你完成了这个步骤。

3.6 组装成完整的SFA,并加入标准化方法对齐

实际使用中,还需要解决一个问题:SFA输出的慢特征方差是1,但不同特征的变化率差异可能很大,这会让下游分类器误以为所有特征同等重要。常用做法是对输出的慢特征再做一个单位方差标准化,也就是除以各自的标准差。但从更标准的SFA文献来看,为了让输出满足“均值为0、方差为1”的约束,本来就应该做这一步。

整理一下完整的类:

class SlowFeatureAnalysis: """ 慢特征分析 用法: sfa = SlowFeatureAnalysis(n_features=5, degree=2) sfa.fit(x_train) y_train = sfa.transform(x_train) y_test = sfa.transform(x_test) """ def __init__(self, n_features=10, degree=2, epsilon=1e-8): self.n_features = n_features self.degree = degree self.epsilon = epsilon self.is_fitted = False def fit(self, x): """ 训练 SFA 模型。 参数: x: 形状 (T, D) 的时间序列,T 为时间步数 """ # 1. 非线性扩展 x_expanded = polynomial_expansion(x, degree=self.degree) # 2. 白化 z, whiten_params = whiten(x_expanded, epsilon=self.epsilon) # 3. 求解慢特征 sfa_out, sfa_vecs, delta_vals = sfa_step(z, num_features=self.n_features) # 4. 标准化输出 output_mean = np.mean(sfa_out, axis=0) output_std = np.std(sfa_out, axis=0) sfa_normalized = (sfa_out - output_mean) / output_std # 保存参数 self.whiten_mean = whiten_params['mean'] self.whiten_matrix = whiten_params['whiten_matrix'] self.sfa_matrix = sfa_vecs self.output_mean = output_mean self.output_std = output_std self.delta_vals = delta_vals self.is_fitted = True return sfa_normalized def transform(self, x): """ 对新的时间序列做变换。 参数: x: 形状 (T, D) 返回: 标准化后的慢特征,形状 (T, n_features) """ assert self.is_fitted, "模型尚未训练" x_expanded = polynomial_expansion(x, degree=self.degree) x_centered = x_expanded - self.whiten_mean z = x_centered @ self.whiten_matrix sfa_out = z @ self.sfa_matrix sfa_normalized = (sfa_out - self.output_mean) / self.output_std return sfa_normalized

这里有个细节必须强调:transform在应用白化时用的是训练阶段保存的whiten_mean,而不是重新计算测试数据的均值。SFA是无监督方法,但这不代表可以在新数据上重新计算统计量。和PCA一样,所有变换参数都应当只从训练数据中估计,否则测试阶段的变换就失去意义,数据分布偏移也会被掩盖掉。

4. 在一个合成信号上验证SFA的行为

4.1 构造一个有明确“快慢”结构的数据

空谈理论容易,动手验证才能建立信任。我构造一段合成时间序列:包含一个慢变分量和一个快变分量,再叠加一点噪声。

rng = np.random.default_rng(42) T = 2000 t = np.linspace(0, 20, T) # 慢变信号: 低频正弦 slow_signal = np.sin(0.4 * t) # 快变信号: 高频正弦 fast_signal = 0.5 * np.sin(3.0 * t) # 观测信号: 慢+快+噪声 x = np.column_stack([slow_signal + fast_signal + 0.1 * rng.normal(size=T), slow_signal - fast_signal + 0.1 * rng.normal(size=T)]) print(x.shape) # (2000, 2)

这是一个典型的二维输入,两个维度都混合了慢、快分量,又各有不同的组合系数。我们的预期是:SFA学出的第一个特征基本对应慢变分量,第二个特征对应快变分量。

4.2 训练并观察特征变化速度

sfa = SlowFeatureAnalysis(n_features=2, degree=2) features = sfa.fit(x) # 输出特征 s0 = features[:, 0] s1 = features[:, 1] # 计算特征的变化速度(差分平方均值) rate0 = np.mean(np.diff(s0) ** 2) rate1 = np.mean(np.diff(s1) ** 2) print(f"慢特征0变化速率: {rate0:.4f}") print(f"慢特征1变化速率: {rate1:.4f}")

从原理上说,特征0的速率会明显小于特征1。因为你用了二阶多项式扩展,再加上白化和最优投影,理论上第一个慢特征应该捕捉到最慢的潜在结构。

实测中,我第一次跑的时候出现过一个有意思的现象:第一个慢特征确实很慢,但和 slow_signal 的波形对不上,相位偏移很明显。后来我意识到,SFA学到的是缓慢结构在高维扩展空间中的投影,它不保证是原始分量的直接复制。如果输入里有两个同频率但不同相位的分量,SFA会重新组合它们,而不是原样输出。这点和ICA的“提取原始源”目标不同,SFA只在乎“慢”,不在乎“是否原始成分”。

4.3 如何验证特征质量

除了肉眼看波形,我习惯用三个指标评估SFA效果:

  1. 变化速率比。先计算原始输入每个通道的变化速率,再计算输出慢特征的变化速率。如果特征0的速率比原始任何通道都低,说明学到了慢信息;如果反而变高了,很可能白化出了数值问题。

  2. 特征间的不相关性。SFA理论保证不同特征之间彼此不相关(在训练数据上)。可以计算特征矩阵的协方差矩阵,检查非对角线是否接近0。这一项是对实现正确性的强检验。

  3. 泛化能力。用前80%的数据训练SFA,用后20%的数据计算变换后特征的均值和方差,看是否仍然接近0和1。如果偏离很大,说明数据不平稳或模型过拟合了训练段的时间结构。

# 不相关检验 feature_cov = np.cov(features.T, rowvar=False) print("特征间协方差矩阵:") print(feature_cov) # 泛化检验 half = T // 2 sfa_half = SlowFeatureAnalysis(n_features=2, degree=2) sfa_half.fit(x[:half]) gen_features = sfa_half.transform(x[half:]) print("后半段特征均值:", np.mean(gen_features, axis=0)) print("后半段特征方差:", np.var(gen_features, axis=0))

如果你的协方差矩阵对角线全为1、非对角线全为0,一般情况下代码就没问题。这时候再去看慢特征的实际语义,心里就有底了。

5. SFA能干哪些实事,以及怎么和现有流程配合

5.1 时序数据特征提取

SFA最典型的应用场景是作为时序数据的特征提取器。原始输入可能是高维传感器信号、关节角度、音频频谱,直接丢给分类器往往维度过高且噪声大。先跑一遍SFA,取前几个慢特征作为降维后的表示,保留最稳定的时间结构,再去接分类器。

我做过一个手势识别的小项目,原始输入是6个IMU通道,采样率50Hz,每段手势序列大约2~3秒。直接用原始信号训练一个常用分类器,准确率在88%左右;先做二阶SFA降到5维,再训练同一个分类器,准确率到了94%左右。提升的来源不只是降维,更关键的是SFA弱化了“手势执行速度快慢”的影响——同一个人不同次做同一个手势,快慢有差异,但慢特征捕捉到的是动作的基本结构。

5.2 作为自监督学习信号

近年自监督学习大火,很多方法通过对比损失、掩码重建等构造监督信号。SFA提供了一个非常干净的“时间正则”思路:学习一个表示,要求它在时间上平滑。你可以把它当作一种正则在深度网络的顶层加上去。

具体做法是:神经网络输出特征向量 f(t),SFA正则项计算 ‖f(t+1)−f(t)‖²。这个正则项会让网络学到的时间特征更稳定,对帧率变化、轻微平移更鲁棒。相比对比学习里复杂的正负样本构造,SFA正则只需要相邻特征对,实现简单,训练也稳定。

5.3 观察潜在状态变化

SFA在提取“缓慢变化的控制量”方面有独特价值。比如机器人关节电流信号随时间变化很快,但潜藏的负荷状态变化是缓慢的,SFA可以把这部分慢变量单独拎出来,用于异常监测。原始信号随便抖动,但慢特征在某个时间段发生显著偏移,那就说明系统进入了一个新的状态。这个思路在工业设备的退化检测、生物信号的状态估计里都成立。

5.4 明确SFA的边界

不能因为SFA看起来通用,就哪里都套。有一个边界很值得说:SFA学到的是数据内在的时间结构,而不是标签相关结构。如果某个类别之间的差异只体现在高频细节上,SFA会把那些信息排在后面甚至丢弃,因为它们“不够慢”。所以如果你的任务本质是捕捉快速瞬态事件(比如检测瞬时的脉冲信号),SFA不适合直接做特征提取,更适合放在一个多尺度框架里与其他特征并用。

另外一个常见的误用场景:把不平稳的数据硬塞给SFA。SFA假设训练数据包含的那个“慢结构”在全程都是同一套,如果中途发生了突变,SFA会把这个突变也当成需要平均掉的成分,结果反而让特征混乱。处理方式是把数据分段,分别建模,或者先做一次趋势项提取,再把残差拿去做SFA。

6. 实操中的陷阱与调试方法

6.1 特征值出现负值或接近零值怎么办

白化过程中,协方差矩阵的特征值理论上非负,但实际数据因为浮点误差,偶尔会出现小负值。eigh返回的负特征值一般非常小(比如 -1e-15),影响可以忽略。当你发现扩散矩阵的特征值里出现较大负值(比如 -0.5),说明数据里存在高度冗余的维度,或者扩展后的特征出现了精确线性相关。这种情况下白化矩阵会放大噪声,输出特征会剧烈抖动。

我的排查步骤是:先用SVD代替特征值分解重新白化,对比结果。SVD数值稳定性更好,对近奇异的协方差矩阵容忍度更高。如果SVD能用,而eigh不行,说明原始特征冗余度高,考虑减少扩展维度。

# 使用SVD替代eigh的白化方案 U, S, Vt = np.linalg.svd(x_centered, full_matrices=False) whiten_matrix_svd = Vt.T @ np.diag(1.0 / np.sqrt(S + epsilon)) @ Vt

6.2 慢特征的顺序忽然不稳定

多跑几次实验,有时会发现第一次训练得到的第一慢特征是某个方向,第二次变成另一个方向。原因有二:一是训练数据顺序变化(随机打乱了时间步),二是特征值很接近,特征向量空间有退化,微小扰动就导致特征向量旋转。

针对第二个问题,我通常增加一个正交性约束:在求解完前几个特征向量后,对后续特征向量做正交化。实现上可以在sfa_step里对得到的所有特征向量做一次 Gram-Schmidt。另外,SFA本质假设时间顺序不能任意打乱,训练时务必保持时间序列完整、有序。

6.3 输出特征在高频段仍有明显波动

如果第一个慢特征还不够平滑,优先检查是不是白化不够彻底。白化后信号的协方差应当接近单位阵,如果不满足,问题多半出在 epsilon 或者特征值数值精度上。

第二个检查点是差分计算方式。上面的代码使用了一阶前向差分,这在采样率较高时没问题;如果采样率低,相邻点间差异本身就大,差分协方差会被噪声主导。可以改用更平滑的差分估计:计算 z(t+Δ)−z(t−Δ),用 2Δ 作为间隔。这种方法相当于做了低通滤波,慢特征会明显更稳定。

6.4 调参建议速查

参数建议范围选择逻辑
degree1~3先试二阶,若数据有明显非线性再升高
n_features输入维度的1/5~1/2保留慢结构,过滤掉高频噪声
epsilon1e-8 ~ 1e-5依据协方差特征值量级确定
差分间隔1~5采样率高则取1,采样率低可适当增大

如果你同时处理多段序列,比如多段视频、多个实验片段,SFA的训练方式有两种选择。一种是把所有序列首尾拼接成一长段,但要注意拼接处的瞬时差异会形成伪高频成分。另一种是分别对每段计算差分协方差,然后累加求平均。后者更稳,推荐使用。

def fit_multiple_segments(segments, n_features=5, degree=2): """ 多段序列训练SFA:分别白化、累加差分协方差。 segments: list of (T_i, D) arrays """ # 暂存白化后的段 whitened_segments = [] diff_cov_sum = None # 先统一非线性扩展和白化参数 concat_x = np.vstack(segments) sfa_temp = SlowFeatureAnalysis(n_features, degree) z_all = sfa_temp.fit(concat_x) # 对每段做白化,计算差分协方差 seg_start = 0 for seg in segments: T_seg = len(seg) z_seg = z_all[seg_start:seg_start + T_seg] diff_seg = z_seg[1:] - z_seg[:-1] diff_cov_seg = np.cov(diff_seg, rowvar=False) diff_cov_sum = diff_cov_seg if diff_cov_sum is None else diff_cov_sum + diff_cov_seg seg_start += T_seg # 从这里开始求慢特征矩阵 eigvals, eigvecs = np.linalg.eigh(diff_cov_sum / len(segments)) sfa_vecs = eigvecs[:, :n_features] # 返回统一变换后的特征 return z_all @ sfa_vecs, sfa_temp, sfa_vecs

我建议正式项目中都用多段联合训练,而不是把数据硬拼起来。这样做的好处一是避免拼接处产生的伪时间结构,二是每段时长不同时,每段贡献的差分样本数不同,累加求平均会抑制长段对短段的压迫,训练更公平。

7. 扩展方向:SFA与深度学习的结合

7.1 SFA作为网络中的瓶颈层

SFA和深度学习的结合,现在已经有两个成熟方向。第一个方向是把SFA当作一个可微的瓶颈层插入网络。由于SFA的核心操作(白化、差分协方差特征值分解)都可以用可微的张量运算实现,可以把它嵌入到网络中间,让网络某个中间层的输出先经过SFA约束,再继续往后面走。这样网络既能学习到任务相关的特征,又保证了时间平滑性。

不过这个实现细节比较多,尤其是特征值分解的反向梯度传播,PyTorch提供了torch.linalg.eigh的反向传播支持,实现递减了不少工作量。

7.2 用SFA构造自监督任务

第二个方向是把SFA当作自监督训练中的辅助任务。具体做法是:训练一个共享编码器,把每个时刻的输入编码为特征向量,然后一方面用监督损失学习分类,另一方面用SFA损失约束相邻时刻特征的变化幅度。这种多任务模式在生产环境中很实用,因为SFA损失不需要任何额外的标注,白拿的时间通用知识。

我从一个对比实验里得到的直观感受是,加入SFA正则之后,模型在测试集上的鲁棒性明显提升,尤其在帧率偏移和时序噪声扰动这类测试上,表现更稳定。损失权重一般设0.1到0.5之间比较合适,太大容易让特征“过度平滑”,失去了细节区分度。

7.3 大规模数据上的运行效率优化

SFA最耗时的部分集中在二阶多项式扩展和高维白化。如果原始数据维度 D 是1000,二阶扩展后的维度大约是50万,这种规模直接做协方差特征值分解是不现实的。

一种做法是随机投影:先用一个随机高斯矩阵把扩展后的高维特征投影到几千维,再做白化和SFA求解。随机投影保留了相对距离信息,实际效果还不错。另一种做法是使用核技巧,在原始空间隐式地计算扩展空间的协方差,不显式展开高维特征。这一块较复杂,但理论上是画家的路线图已经清晰的。

# 随机投影示例: 减少扩展维度 expanded = polynomial_expansion(x, degree=2) # (T, M_high) random_mat = rng.normal(size=(expanded.shape[1], 500)) / np.sqrt(expanded.shape[1]) expanded_low = expanded @ random_mat # (T, 500) # 再喂给SFA

对于大多数中小规模项目,500到2000维的白化计算已经可以秒级完成,没必要为了炫技引入复杂的近似方案。

8. 最后分享一个我自己常用的技巧

结束之前,我分享一个试验中得到的实用手法。SFA输出的多个慢特征,通常第一个最慢、第二个第二慢,但并不是“越慢越好”。我过去做分类实验时,一度只选第一个特征,分类性能不理想。后来尝试把前3~5个慢特征一起留下,再交给简单分类器,效果反而提升明显。

原因是:最慢的特征通常对应最全局的结构,比如整段动作的粗粒度模式;第二、第三特征开始捕捉中粒度变化;高频特征则反映局部细节。分类任务往往需要多层信息配合,只取第一个特征就像只看了一幅画的构图,忽略了笔触和色彩层次。最佳的保留数量,我通常做一个简单的网格搜索,从1试到输入维度的一半,挑交叉验证性能最好的。

还有一个值得一提的细节:SFA对数据归一化非常敏感。如果你把所有通道统一缩放到0~1,和缩放到均值0方差1,会得到完全不同的结果。我建议把每个通道独立标准化到零均值单位方差,再进行多项式扩展。这一步看似简单,实际效果往往比换扩展阶数更明显。

用SFA这三年多,我最大的感受是它很适合作为探索数据时间结构的“第一根探针”。你不需要太多先验,也几乎不需要调参,单次训练就能给出关于数据中存在哪些慢变量的可靠猜想。如果你想深入这套数学框架的更多细节,建议去看原始综述和相关后续工作,但先把这篇代码跑通,把每个矩阵和每一步变换在真实数据上的表现摸透,会比读十篇论文更有收获。

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

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

立即咨询