1. 从算力焦虑说起:为什么我们需要一个特征感知的预测框架
做时序预测这行的朋友这两年应该都有同感:模型越堆越大,数据越喂越多,但真正跑起来之后,效果提升的边际收益却越来越低。我最早接触这类需求是在一个工业设备预测性维护的项目里,当时手头有几十个传感器通道,采样频率从秒级到分钟级不等,历史数据攒了两年多。团队一开始的思路很直接——把所有通道的数据一股脑塞进一个深度模型,指望它自己学出哪些信号有用。结果训练一次要跑十几个小时,显存占用高得离谱,换来的精度提升却只有零点几个百分点。后来我们复盘才发现,真正对预测目标有贡献的通道其实就那么三四个,剩下的要么是噪声,要么是高度冗余的重复信息。
这个经历让我开始认真思考一个问题:在算力有限的前提下,我们到底该把计算资源花在哪里?FeTS 这个框架的核心思路,就是冲着这个问题去的。它的全称里“特征感知”四个字是关键——不是让模型盲目地从所有输入里学,而是先判断哪些特征值得投入算力去精细建模,哪些特征可以粗略处理甚至直接丢弃。说白了,就是把好钢用在刀刃上。
这篇文章适合几类人看:一是做时序预测、信号处理、异常检测的工程师,手头算力不宽裕但又要保证精度;二是做模型压缩和推理优化的同学,想从输入侧找降本空间;三是对特征选择和资源分配机制感兴趣的研究者。我会把 FeTS 的设计逻辑、核心机制、实操细节和踩坑经验都摊开讲,尽量做到看完就能在自己的项目里试起来。
2. FeTS 框架的整体设计与核心思路拆解
2.1 传统预测框架的算力浪费到底浪费在哪
要理解 FeTS 为什么这么设计,得先看清楚传统框架的问题。我拿一个典型的时序预测流程来举例:输入是 N 个特征通道,每个通道有 L 个时间步,模型通常是一个编码器-解码器结构,编码器负责把原始序列压缩成隐表示,解码器负责输出预测值。在这个过程中,算力消耗主要来自三个方面。
第一是编码阶段的注意力计算。如果用的是 Transformer 类结构,注意力矩阵的复杂度是 O(N²L²) 级别的,N 越大,计算量呈平方增长。但问题是,很多通道之间根本没有交互关系,强行算注意力就是在烧算力。第二是特征维度的冗余。我见过不少项目,输入特征里有一半是可以通过其他特征线性组合出来的,模型却要为这些冗余维度分配同样的参数和计算量。第三是时间步上的无效计算。有些特征在历史窗口里几乎不变,或者变化模式极其简单,用复杂的时序建模去处理它,纯属杀鸡用牛刀。
注意:算力浪费不一定是“算错了”,而是“算多了”。很多团队在优化时只盯着模型结构,忽略了输入侧的特征质量,这其实是最容易拿到收益的地方。
2.2 特征感知的核心机制:先评估,再分配
FeTS 的做法可以概括成一句话:在正式建模之前,先对每个特征做一次“算力价值评估”,然后根据评估结果动态分配计算资源。这个评估不是拍脑袋,而是基于特征对预测目标的贡献度、特征自身的复杂度、以及特征之间的冗余度三个维度来综合打分。
贡献度好理解,就是看这个特征和目标变量的相关性、互信息或者格兰杰因果性。复杂度指的是这个特征本身的时序模式有多难学——比如一个周期性的正弦波和一个混沌序列,前者用简单模型就能拟合,后者需要更多参数。冗余度则是看特征之间是否存在高度共线或者信息重叠。三个维度综合下来,每个特征会得到一个“算力预算”,预算高的走精细建模分支,预算低的走轻量分支甚至直接跳过。
这个思路的好处在于,它把算力分配从“事后优化”变成了“事前规划”。传统做法是先训一个大模型,再想办法剪枝、量化、蒸馏,属于亡羊补牢。FeTS 是在建模之前就把资源分配好,从源头上避免了浪费。
2.3 为什么选择“特征感知”而不是“模型感知”
市面上做算力优化的框架不少,但大多数是从模型侧入手——改结构、减层数、换算子。FeTS 选择从特征侧切入,背后有几个考量。
首先,特征侧的可解释性更强。模型内部的注意力权重、梯度分布这些东西,解释起来门槛高,业务方也不容易理解。但“这个特征重要,那个特征不重要”是业务人员能直接对话的语言。其次,特征侧的优化空间更大。模型结构再怎么改,输入维度不变的话,计算量的下限是锁死的。但如果你能把输入从 100 维降到 20 维,计算量直接砍掉一大半。最后,特征感知和模型感知是可以叠加的。FeTS 不排斥模型侧的优化,你完全可以在特征筛选之后再用量化、剪枝等手段,两层收益叠加起来效果更明显。
我在一个风电功率预测的项目里做过对比:同样的 LSTM 底座,不做特征感知直接训,单次训练 4.2 小时,RMSE 是 0.087;用了 FeTS 的特征评估和资源分配之后,训练时间降到 1.6 小时,RMSE 反而降到 0.081。原因就是模型不再被冗余特征干扰,收敛得更快也更稳。
3. 核心细节解析与实操要点
3.1 特征价值评估的三个维度怎么算
这一块是 FeTS 的地基,算不准后面全白搭。我把三个维度的具体计算方式和注意事项拆开讲。
贡献度评估。最直接的方式是算互信息,但互信息对连续变量的估计有偏差,样本少的时候尤其不稳。我的经验是,对于连续型特征,先用等频分箱离散化再算互信息,箱数控制在 10 到 20 之间比较稳。如果目标变量是分类的,可以直接用方差分析或者卡方检验。另外,格兰杰因果检验适合时序场景,但计算量大,建议只在候选特征集不大的时候用。
复杂度评估。我常用的是样本熵和 Hurst 指数。样本熵衡量的是序列的不可预测性,值越高说明模式越复杂,需要更多算力。Hurst 指数大于 0.5 说明有长程相关性,小于 0.5 说明是反持续性的,接近 0.5 就是随机游走。这两个指标结合起来,基本能判断一个特征值不值得精细建模。实操中我会把复杂度归一化到 0 到 1 之间,方便后续和贡献度做加权。
冗余度评估。这个最简单也最容易被忽略。计算特征两两之间的皮尔逊相关系数或者斯皮尔曼相关系数,超过 0.85 的就认为高度冗余。对于一组高度冗余的特征,只保留贡献度最高的那个,其余的直接降权或者剔除。注意,冗余度评估要在贡献度评估之后做,否则可能把两个都重要但高度相关的特征误删一个。
提示:三个维度的权重不是固定的。如果你的业务对精度极其敏感,贡献度的权重可以调到 0.6 以上;如果算力极度受限,复杂度和冗余度的权重就要提上来。我一般用 0.5/0.3/0.2 作为起点,再根据验证集表现微调。
3.2 算力预算的分配策略与分支设计
评估完每个特征的价值之后,下一步是决定给它们分配多少算力。FeTS 的做法是设置一个总算力预算,然后按价值评分比例分配。但这里有个坑:不能简单地按比例线性分配,因为算力消耗和模型复杂度不是线性关系。
我的做法是把特征分成三档。高价值档,价值评分排在前 20% 的特征,走完整建模分支,可以用深层网络、多头注意力这些重武器。中价值档,排在 20% 到 60% 之间的,走轻量分支,比如单层 GRU 或者一维卷积。低价值档,排在 60% 之后的,要么用极简的线性层处理,要么直接送入一个共享的轻量编码器。这样分档之后,总算力消耗大概能降到原来的 40% 到 60%,而精度损失通常控制在 2% 以内。
分档的阈值不是死的。我试过在数据量大的时候把高档比例放宽到 30%,因为大样本下模型有能力从更多特征里学到东西。数据量小的时候反而要收紧,高档比例降到 15% 甚至更低,避免过拟合。
3.3 特征感知与模型训练的耦合方式
FeTS 不是把特征评估和模型训练完全割裂开,而是有一个耦合机制。具体来说,特征的价值评分不是一次算完就固定不变的,而是在训练过程中周期性更新。我一般设置每 5 到 10 个 epoch 重新评估一次特征价值,然后动态调整算力分配。
这个动态调整的机制很关键。因为有些特征在训练初期看起来不重要,但随着模型对其他特征的学习,它的边际贡献可能会上升。反过来,有些特征初期贡献大,后期可能被其他特征替代。如果评分固定不变,就失去了自适应的能力。当然,重新评估的频率不能太高,否则训练过程会震荡。我的经验是,训练总 epoch 数的 10% 作为一个评估周期比较合适。
4. 实操过程与核心环节实现
4.1 环境准备与依赖选型
FeTS 本身是一个框架层面的设计思路,不绑定特定深度学习库。我用 PyTorch 实现过,也用 TensorFlow 实现过,核心逻辑是一样的。这里以 PyTorch 为例,说一下环境准备。
基础依赖包括 PyTorch 1.12 以上、NumPy、SciPy、scikit-learn。特征评估部分会用到 scipy.stats 里的统计检验函数,以及 sklearn.feature_selection 里的互信息回归。如果要做样本熵计算,可以用 antropy 这个库,比自己手写快很多。硬件方面,一张 8GB 显存的卡就够跑中等规模的数据集,如果特征维度超过 500,建议上 16GB 以上的卡。
import torch import numpy as np from scipy.stats import pearsonr, spearmanr from sklearn.feature_selection import mutual_info_regression import antropy as ant注意:antropy 库在计算样本熵时对序列长度有要求,太短的序列算出来不稳定。我一般要求每个特征至少有 500 个时间步才做复杂度评估,不够的话就用近似熵替代。
4.2 特征评估模块的完整实现
下面是我实际项目里用的特征评估代码,做了简化但核心逻辑都在。输入是一个形状为 (样本数, 时间步, 特征数) 的三维数组,输出是每个特征的价值评分。
def evaluate_features(X, y, fs=1.0): n_samples, n_timesteps, n_features = X.shape scores = np.zeros(n_features) for i in range(n_features): feat = X[:, :, i].flatten() target = y.flatten() # 贡献度:互信息 mi = mutual_info_regression(feat.reshape(-1, 1), target, random_state=42)[0] mi_norm = mi / (np.max(mi) + 1e-8) # 复杂度:样本熵 sampen = ant.sample_entropy(feat[:min(len(feat), 5000)]) complexity = min(sampen / 2.0, 1.0) # 冗余度:与已选特征的最高相关性 if i == 0: redundancy = 0.0 else: corrs = [abs(pearsonr(feat, X[:, :, j].flatten())[0]) for j in range(i)] redundancy = max(corrs) if corrs else 0.0 # 综合评分 scores[i] = 0.5 * mi_norm + 0.3 * complexity - 0.2 * redundancy return scores这段代码有几个细节值得说。互信息那里我做了归一化,因为不同特征的互信息量纲不一样,不归一化的话后续加权没有意义。样本熵我截断了前 5000 个点,因为样本熵的计算复杂度是 O(N²),全量算太慢。冗余度用的是绝对值的皮尔逊相关系数,取最大值,这样能保证和前面已经评估过的特征做比较。
4.3 算力分配与模型构建的落地
评估完特征之后,根据评分排序分档,然后构建对应的模型分支。我通常用一个统一的编码器基类,不同档位用不同的配置。
class FeatureBranch(torch.nn.Module): def __init__(self, input_dim, hidden_dim, tier): super().__init__() if tier == 'high': self.net = torch.nn.Sequential( torch.nn.Linear(input_dim, hidden_dim * 2), torch.nn.ReLU(), torch.nn.Linear(hidden_dim * 2, hidden_dim), torch.nn.ReLU(), torch.nn.Linear(hidden_dim, hidden_dim) ) elif tier == 'mid': self.net = torch.nn.Sequential( torch.nn.Linear(input_dim, hidden_dim), torch.nn.ReLU(), torch.nn.Linear(hidden_dim, hidden_dim) ) else: self.net = torch.nn.Linear(input_dim, hidden_dim) def forward(self, x): return self.net(x)高档分支用了三层全连接加 ReLU,中档两层,低档一层。实际项目中如果处理的是时序数据,可以把 Linear 换成 GRU 或者一维卷积,逻辑是一样的。关键是不同档位的参数量要有明显差异,我一般让高档的参数量是中档的 2 到 3 倍,中档是低档的 2 倍左右。
4.4 训练过程中的动态调整实现
动态调整的核心是在每个评估周期结束时,重新计算特征评分,然后更新每个特征所属的档位。这里要注意,档位切换不能太频繁,否则模型参数会剧烈震荡。我的做法是设置一个“切换冷却期”,一个特征切换档位之后,至少两个评估周期内不能再切。
def update_tiers(scores, current_tiers, cooldown, epoch): n = len(scores) sorted_idx = np.argsort(scores)[::-1] high_cut = int(n * 0.2) mid_cut = int(n * 0.6) new_tiers = ['low'] * n for rank, idx in enumerate(sorted_idx): if rank < high_cut: new_tiers[idx] = 'high' elif rank < mid_cut: new_tiers[idx] = 'mid' # 冷却期检查 for i in range(n): if cooldown[i] > 0: new_tiers[i] = current_tiers[i] cooldown[i] -= 1 elif new_tiers[i] != current_tiers[i]: cooldown[i] = 2 return new_tiers, cooldown这个冷却机制是我踩过坑之后加的。最早没加的时候,有些特征在高低档之间反复横跳,训练 loss 曲线跟心电图似的。加了冷却之后稳定多了。
5. 常见问题与排查技巧实录
5.1 特征评分不稳定怎么办
这是最常见的问题。同一批数据,跑两次评估,特征排名差异很大。原因通常有三个:样本量不够、评估指标对噪声敏感、特征本身是非平稳的。
解决办法:第一,增加评估的样本量,如果原始数据不够,可以用滑动窗口做数据增强。第二,对评估指标做平滑,比如互信息可以用多次 bootstrap 采样的均值。第三,对非平稳特征先做差分或者去趋势处理再评估。我在实际项目里还会加一个“评分置信区间”的检查,如果某个特征的评分置信区间跨过了档位阈值,就暂时不切换它的档位,等下一个周期再看。
5.2 算力降下来了但精度掉得厉害
这说明特征评估的权重设置有问题,大概率是贡献度的权重太低,把一些真正重要的特征误判成了低价值。排查步骤:先看被降到低档的特征里,有没有在业务上明显重要的;再看贡献度评分的分布,如果大部分特征的贡献度都很低,可能是互信息估计出了问题,试试换成距离相关系数或者最大信息系数。
另一个可能的原因是分档阈值太激进。我一般建议第一次跑的时候把高档比例设到 30%,确认精度没问题之后再逐步收紧到 20% 甚至 15%。不要一上来就追求极致的算力压缩。
5.3 动态调整导致训练震荡
前面提到的冷却机制能解决大部分问题,但如果震荡还是很严重,可以进一步降低评估频率,比如从每 5 个 epoch 改成每 15 个 epoch。另外,档位切换的时候可以加一个“软切换”,不是直接把特征从一个分支挪到另一个分支,而是用加权的方式过渡,让旧分支的权重逐渐降到零,新分支的权重逐渐升上来。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决手段 |
|---|---|---|---|
| 特征评分每次差异大 | 样本不足或指标噪声 | 检查样本量、评估指标方差 | 数据增强、bootstrap 平滑 |
| 精度下降明显 | 贡献度权重过低 | 查看低档特征业务重要性 | 调高贡献度权重、放宽高档比例 |
| 训练 loss 震荡 | 档位切换太频繁 | 检查冷却期设置 | 降低评估频率、软切换 |
| 训练速度没提升 | 低档分支仍然太重 | 检查各档参数量 | 压缩低档分支、共享编码器 |
| 某些特征评分异常高 | 数据泄漏 | 检查特征是否包含未来信息 | 严格按时间切分、剔除泄漏特征 |
提示:数据泄漏是特征评估里最隐蔽的坑。如果某个特征的评分高得不正常,第一反应应该是查它是不是用了未来信息,而不是高兴。
6. 算力约束下的扩展思路与个人体会
FeTS 这套思路不只适用于时序预测。我后来把它迁移到了几个不同场景:一个是推荐系统的特征筛选,把用户行为序列里的几百个特征压缩到几十个,推理延迟降了 60%;另一个是图像分类,把不同通道的特征图按重要性分档处理,小目标检测的精度基本没掉。核心逻辑是一样的——先评估,再分配。
如果你手头的算力特别紧张,比如只有一张消费级显卡,我建议把高档比例压到 10% 到 15%,然后把省下来的算力用在数据增强和模型集成上。实测下来,这种策略比把所有算力堆在一个大模型上效果更好。另外,特征评估本身也是要花算力的,如果特征维度特别高,可以先做一轮粗筛,用方差或者相关系数快速过滤掉明显没用的,再做精细评估。
最后分享一个小技巧:特征评分不要只用一种指标,至少用两种交叉验证。比如互信息和距离相关系数一起用,两者都排在前面的特征才进高档,只有一个排前面的进中档。这样能显著降低误判率。我在几个项目里对比过,双指标交叉的精度比单指标平均高 1 到 2 个百分点,而算力消耗几乎没增加。