SensorFM 负责时序窗口表示、下游只接小样本分类器

文章导读
只有几百条标注时,把 SensorFM 当成固定特征提取器通常比端到端微调更可控:上游权重冻结、取中间层输出做定长聚合,下游先接线性分类器跑通一版基线,再决定是否解冻部分层。
📋 目录
  1. 一 冻结上游并取出中间层输出
  2. 二 把时序特征聚合成定长向量
  3. 三 用线性分类器跑第一版基线
  4. 四 对比预训练表示与随机初始化特征
  5. 五 判断是否要解冻部分层
A A

只有几百条标注时,把 SensorFM 当成固定特征提取器通常比端到端微调更可控:上游权重冻结、取中间层输出做定长聚合,下游先接线性分类器跑通一版基线,再决定是否解冻部分层。

适用场景是标注量在几百条量级、且窗口切分规则已经稳定。操作上先冻结上游、取倒数第二层隐状态做聚合,接标准化加逻辑回归,按人/设备分组做交叉验证,并同时跑一组随机初始化特征作为对照。判断是否解冻看验证指标是否还在提升、训练集与验证集是否明显背离;若验证指标停滞且两集接近,就停在冻结阶段,不必为了“用满模型”而解冻。

冻结上游并取出中间层输出

目标是先确认能拿到稳定、可复用的特征张量。取层位置通常选倒数第二层的隐状态,而不是最后一层:最后一层往往已经贴近预训练任务的头,语义被任务目标拉偏;倒数第二层保留更多通用时序结构。如果模型是 patch 化的时序 Transformer,中间层输出形状一般是 (B, P, H),即批大小、patch 数、隐藏维;如果是逐点编码,则是 (B, L, H)。这两个形状的差别决定了后面怎么聚合,所以必须先打印出来核对。

import torch

model.eval()
for p in model.parameters():
    p.requires_grad_(False)

with torch.no_grad():
    hidden = encoder(x)          # x: (B, L) 或 (B, C, L)
print('hidden', hidden.shape)    # 期望 (B, P, H) 或 (B, L, H)

# 抽一层做校验,接口名按实际封装替换
feat = encoder.forward_features(x, layer_index=-2)
print('feat', feat.shape, feat.dtype, torch.isfinite(feat).all())

验证方式有三步:同一批输入连跑两次,确认输出逐元素相同(说明 dropout 已关闭、没有随机增强);确认最后一维是隐藏维而不是时间维;确认没有 NaN/Inf。如果 encoder(x) 返回的是元组或字典,先打印 type() 和 keys(),别凭猜索引。窗口长度不固定时,patch 数 P 会随窗口变化,这会直接传给下一节的聚合环节。

把时序特征聚合成定长向量

线性分类器要求固定维度输入,所以必须把时间维压掉。三种常见做法各有代价。

SensorFM 负责时序窗口表示、下游只接小样本分类器
  • 平均池化:对 (B, P, H) 沿 P 取均值,得到 (B, H)。代价最低、对窗口长度不敏感,适合信息在窗口内分布较均匀的场景,比如持续振动或稳定温漂。风险是尖峰类事件会被平均掉。
  • 拼接统计量:把均值、标准差、最大最小、分位数等在 P 维上拼起来,得到 (B, kH)。能保留波动幅度和极值,适合故障冲击、异常突变这类靠幅值区分的任务。代价是维度变成 k 倍,几百条样本下更容易过拟合,建议 k 控制在 2~3。
  • 取末位状态:直接取 h[:, -1, :],得到 (B, H)。计算最省,适合单向因果编码且任务只关心窗口末尾状态的场景。若编码器是双向的或窗口内事件位置不定,末位状态通常不稳。
def pool(h):                       # h: (B, P, H)
    mean = h.mean(dim=1)
    std  = h.std(dim=1, unbiased=False)
    last = h[:, -1, :]
    return torch.cat([mean, std, last], dim=-1)   # (B, 3H)

建议先用单一的平均池化跑通,确认整条链路能出指标,再换成拼接统计量比较。切换聚合方式时,只改 pool 函数,特征提取部分不动。

用线性分类器跑第一版基线

第一版的目标不是拿最好成绩,而是判断表示是否近似线性可分。分类器越简单,结论越干净。代码骨架如下,特征标准化要放进流水线内,避免用验证集信息做缩放。

SensorFM 负责时序窗口表示、下游只接小样本分类器
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
from sklearn.model_selection import StratifiedGroupKFold, cross_val_score

clf = make_pipeline(
    StandardScaler(),
    LogisticRegression(C=1.0, max_iter=200, solver='lbfgs'),
)

cv = StratifiedGroupKFold(n_splits=5, shuffle=True, random_state=0)
scores = cross_val_score(clf, X, y, groups=subject_id,
                         cv=cv, scoring='f1_macro')
print(scores)
print(scores.mean(), scores.std())

两个细节必须固定:一是切分按人/设备分组(groups 传受试者或设备 ID),否则同一段窗口的相邻切片会同时落进训练和验证,指标虚高;二是 max_iter 和 C 固定成常量,不要在这一步调参,调参会把“表示好坏”和“超参好坏”混在一起。类别不平衡时用 class_weight='balanced' 或改用 f1_macro,别只看准确率。

对比预训练表示与随机初始化特征

为了排除“提升来自聚合方式或线性分类器本身”,需要一组对照:用同架构但随机初始化的编码器,在完全相同的切分、相同的分类器、相同的随机种子下提取特征。

X_pre  = extract(frozen_pretrained, X_raw)
X_rand = extract(frozen_random_init, X_raw)
# 之后用同一个 cv 对象、同一个 clf 分别评估

读结果时不要只看均值差,还要看折间波动:如果两组均值有差距,但各自的折间标准差量级接近这个差距,或者在换几个切分种子后差距方向不稳定,就不能据此断定预训练表示更好。稳妥做法是固定三到五个切分种子各跑一遍,观察两组指标的分布是否稳定分离。若两组几乎没有差别,先回头检查取层位置、窗口切分是否把标签信息提前泄露,再考虑表示本身的问题。

SensorFM 负责时序窗口表示、下游只接小样本分类器

判断是否要解冻部分层

解冻不是默认动作,而是冻结阶段给出信号之后的补救。建议按下面的判据表行动。

现象判据动作
验证指标在若干组超参下都不再提升,训练与验证指标接近冻结表示已够用停在冻结阶段,把精力放在切分和特征聚合上
训练集指标明显高于验证集指标过拟合先停在冻结,降特征维度、加正则、减少拼接纳统计量
训练集和验证集指标都低且彼此接近欠拟合试解冻最后一层或最后两层,用较小学习率,并保留早停
解冻后验证指标回落或波动变大解冻无效回退到冻结版本,不要继续加层

解冻时一次只放开最后若干层,BatchNorm 或 LayerNorm 的统计量是否跟着更新需要结合环境确认;如果小批次训练,冻结归一化层通常更稳。每次解冻都保存对应的验证指标,方便和前一次冻结结果直接对比。