知识蒸馏用于时间序列异常检测:教师-学生架构与边缘部署

1. 时间序列异常检测里的知识蒸馏,到底在解决什么

1.1 从一次设备告警误报说起

我在做一个工业设备的时序监控项目时,遇到过这样一幕:凌晨三点,值班同事被系统叫醒,说某台泵的振动指标异常。人跑到现场一看,设备运行正常,是那天环境温度骤降导致传感器基线整体偏移了。这类误报在时间序列异常检测里非常常见,模型对"正常"的定义过窄,稍微偏离历史分布就报警,而真正的早期故障信号(通常是幅度不大、持续时间短、且和噪声混在一起)反而被淹没掉了。

这个问题背后有两个绕不开的现实约束。第一是标签稀缺:设备很少坏,坏了也未必有人工标注,你能拿到的几乎全是正常样本。第二是算力与延迟的双重限制:检测逻辑往往要下沉到边缘盒子或者采集网关,模型不能太大,推理必须是毫秒级。

我后来采用的思路,就是标题里那套——用知识蒸馏(Knowledge Distillation)来做时间序列异常检测。核心想法很朴素:先训练一个容量大、看得全、学得透的"教师"模型,把"正常模式长什么样"这件事学扎实;再让它把这种理解教给一个轻量的"学生"学生模型。上线跑的是学生,异常分数来自"学生模仿教师的程度"——学生模仿得越吃力,说明这段数据越不像正常数据。

适合读这篇内容的人分三类:一是正在做时序异常检测、被误报率和漏报率夹击的算法同学;二是想把模型压缩后部署到边缘设备的工程同学;三是刚接触这个方向、想弄明白"蒸馏为什么能用于无监督异常检测"的学生和研究者。代码层面我会给到可直接跑的 PyTorch 骨架,不依赖冷门库。

1.2 为什么"蒸馏"这个框架天生适配异常检测

很多人第一次接触知识蒸馏是在分类任务里:大模型输出带温度的软标签,小模型去拟合软标签,学到的信息比硬标签多。放到时间序列异常检测里,逻辑要换个角度理解。

异常检测的难点从来不是"分类",而是"定义正常"。你手上没有异常样本,没法训练一个二分类器。但你可以训练一个模型,让它把正常数据的生成规律学得极好,然后拿"它在新数据上的表现"当异常分数。知识蒸馏刚好提供了一个可控的信息落差:教师具备学生不具备的信息通道(更长的历史窗口、更多的传感器通道、更大的感受野、更强的拟合能力),学生只能从有限信息出发去逼近教师。在正常数据上,学生经过训练能逼近得不错;一旦来了异常,教师和学生对这个数据段的"理解"出现分歧,分歧量就是异常分数。

对比几种常见替代方案,这个选择的理由就很清楚了:

方案核心思路主要问题
自编码器重构重构误差当异常分数强重构能力会"把异常也重构出来",漏报严重
预测式模型预测误差当异常分数对突发的单点异常敏感,对持续型缓变异常迟钝
单类分类(OCSVM/DeepSVDD)把正常样本压进一个球对分布漂移几乎无抵抗力,超参极敏感
GAN 类方法对抗学习正常分布训练不稳定,复现成本高
教师-学生蒸馏度量两模型对同一输入的表示差异需要精心设计"信息差",否则学生学太好导致分数无区分度

蒸馏方案最大的优势是它对"缓变型异常"和"上下文型异常"(单看数值正常、放在时序上下文里不正常)都能给出响应,而这两类恰恰是纯重构误差和纯预测误差最容易漏掉的。缺点也很明确,最后一列我已经写了——如果学生容量太大或者信息缺口设计得不够,学生会把教师学得滴水不漏,异常分数全线趋近于零,模型直接失效。这是这类方法最常见的翻车方式,后面会专门讲怎么处理。

2. 架构拆解:教师和学生之间到底传递了什么

2.1 教师网络的设计,决定你能学到的"正常"有多细

教师网络是整个体系的根。我的经验是:教师不需要追求 SOTA,但必须满足两个条件——足够大的感受野和足够稳的训练收敛。

感受野这块,我用过一个很直观的估算方法。假设数据采样周期是 1 分钟,你关心的异常是"持续 15 分钟以上的缓慢劣化",那么教师的时间窗口至少要覆盖 15 分钟的 3 到 5 倍,也就是 45 到 75 个点。如果窗口只取 10 个点,教师根本"看不见"趋势,学生自然也无从学起。这条估算规则比盲目试 32/64/128 要靠谱得多。

网络结构上我一般用TCN(时序卷积)或轻量 Transformer 编码器,配合一个重构头。TCN 的优势是膨胀卷积堆叠后感受野增长快、参数量可控、训练比 Transformer 稳;Transformer 的优势是能捕捉长程依赖,但对窗口长度和学习率更敏感。如果你手头数据量不大(几万条以下窗口样本),我建议先从 TCN 起步,别一上来就上注意力机制。

教师训练阶段只用正常数据,损失就是常规的重构损失。这里有个细节值得强调:输入加噪、输出对原始信号做重构这种去噪自编码的设定,比直接重构自己效果更稳,因为它逼着教师学到的是"模式"而不是"记忆"。

import torch import torch.nn as nn class DilatedBlock(nn.Module): def __init__(self, ch, k, d): super().__init__() self.conv = nn.Conv1d(ch, ch, k, padding=(k - 1) * d // 2, dilation=d) self.norm = nn.BatchNorm1d(ch) self.act = nn.GELU() def forward(self, x): return self.act(self.norm(self.conv(x))) + x class TeacherTCN(nn.Module): """输入 (B, C, L),输出同形状的重构结果与瓶颈表示""" def __init__(self, n_ch=1, hid=64, layers=4, k=3): super().__init__() self.inp = nn.Conv1d(n_ch, hid, 1) self.blocks = nn.Sequential(*[DilatedBlock(hid, k, 2 ** i) for i in range(layers)]) self.out = nn.Conv1d(hid, n_ch, 1) def forward(self, x, return_feat=False): h = self.blocks(self.inp(x)) y = self.out(h) if return_feat: return y, h # h 是后面做特征蒸馏的关键 return y

2.2 学生网络怎么"故意变弱",这一步是成败手

学生网络的设计是整套方法里最反直觉的地方。多数蒸馏工作是"学生小一点就行",但在异常检测场景里,光靠参数量差异往往不够——尤其当数据规律比较刚性的时候,小模型也能学得很好。

所以我一般会同时引入三种信息缺口,按需组合:

  • 通道缺口:教师看全部传感器通道(比如 12 个),学生只看其中 3 到 4 个。跨通道的耦合关系学生无从获得,只能靠单一通道的形态去猜。
  • 窗口缺口:教师看过去 128 个点,学生只看最近 16 个点。学生失去了长程上下文,只能从短片段推断。
  • 容量缺口:教师的隐藏维度 64、4 层膨胀卷积,学生压到 16 维、2 层普通卷积。

这三个缺口的意义不一样。窗口缺口对上下文型异常最敏感,比如某个指标的形态在当前 16 个点里完全正常,但放在 128 点里看位置不对;通道缺口对耦合型异常最敏感,比如两个本该同步波动的传感器突然解耦;容量缺口则是通用的兜底。

实际调参时我建议从窗口缺口起步,因为它最容易控制、效果也最直接,然后再叠加通道缺口。三种全开容易导致学生彻底学不动,正常数据上的分数也压不下去,误报率反而会飙。这个平衡点只能靠验证集试,没有公式。

2.3 蒸馏信号的三层传递:输出层、特征层、关系层

教师到学生传什么,直接决定了异常分数的形态。我把它分成三层,实际项目里通常是"输出层必选,特征层强烈建议,关系层看情况"。

输出层蒸馏最简单:让学生输出的重构结果(或预测结果)去逼近教师的输出。损失就是逐点 MSE。这一层保证学生至少有基本的"输出对齐"能力,好处是稳定,坏处是——如果教师和学生输出都是低维的原始信号,信息量有限,区分度不够。

特征层蒸馏是我认为性价比最高的一层。教师的中间隐层h_T里编码了大量模式信息,让学生去拟合h_T,等于让学生直接学"教师怎么理解这段数据"。实现上要注意维度不匹配的问题,通常挂一个线性投影把学生特征升维到教师维度:

class StudentSmall(nn.Module): def __init__(self, n_ch=1, hid=16, layers=2, proj_dim=64): super().__init__() self.inp = nn.Conv1d(n_ch, hid, 1) self.blocks = nn.Sequential(*[DilatedBlock(hid, 3, 2 ** i) for i in range(layers)]) self.out = nn.Conv1d(hid, n_ch, 1) self.proj = nn.Conv1d(hid, proj_dim, 1) # 对齐教师特征维度 def forward(self, x, return_feat=False): h = self.blocks(self.inp(x)) y = self.out(h) if return_feat: return y, self.proj(h) return y

关系层蒸馏关注的是样本之间、时间步之间的相对关系,比如教师认为"t 时刻和 t+5 时刻的模式高度相似",这个相似度结构也让学生去学。这一层对捕捉周期性异常特别有效,但实现复杂度高、训练也更难收敛,我在数据量小于十万窗口样本时一般不启用。

2.4 损失权重的配比逻辑与温度系数的真实作用

先说个很多复现文章里被写错的地方:时间序列异常检测的蒸馏绝大多数是回归任务,不是分类任务,所以没有 softmax,也就没有传统的"温度 T"。不少同学照抄分类任务的知识蒸馏代码,在回归输出上加 softmax 温度,结果模型完全学不动。

温度参数只在一种情况下有意义:你的教师和学生都挂了一个**"异常/正常"的辅助分类头**,这时候才用带温度的 KL 散度去传递软标签。这种情况下温度的作用是软化概率分布,让负类上那些"教师认为有 0.05 概率异常"的微弱信号也能被学生学到,通常 T 取 2 到 5。

回到回归主线,损失函数一般是这样的组合:

def distill_loss(y_s, y_t, f_s, f_t, alpha=1.0, beta=0.5, gamma=0.0): l_out = ((y_s - y_t) ** 2).mean() # 输出层 l_feat = ((f_s - f_t) ** 2).mean() # 特征层 return alpha * l_out + beta * l_feat + gamma * 0

超参建议从alpha=1.0, beta=0.5开始。alpha 太大,学生会退化成纯重构模型,特征是白加的;beta 太大,学生会过度追逐教师的高维抽象表示而忽略输出精度,最后异常分数噪声很大——我见过 beta=10 的配置,分数曲线抖得像心电图,根本没法设阈值。经验区间是 alpha 和 beta 在 1:0.3 到 1:1 之间。

3. 从原始数据到可跑的完整流程

3.1 数据预处理与滑窗参数的实际计算

预处理阶段最容易埋雷的地方是标准化泄漏。时序数据做 z-score 归一化时,如果直接用全量数据的均值和方差,等于把未来信息泄露进了训练,验证集指标会虚高,上线后直接崩。

正确做法是只用训练段的统计量,并且训练段必须是时间上最早的连续区间,不能随机切分。我一般的切法是:前 60% 时间做训练,接下来 20% 做验证(用来定阈值和调超参),最后 20% 做测试。三个集合之间留一段 gap(比如 1 个窗口长度),避免滑窗重叠造成泄漏。

滑窗参数我给你一套可以照着套的推导:

  • 采样周期T_s= 1 min
  • 关心的最短异常持续时长D_min= 15 min
  • 窗口长度L= 4 × D_min / T_s = 60(取偶数便于下采样)
  • 步长S= L / 4 = 15,也就是 75% 重叠

为什么要 75% 重叠?因为如果步长等于窗口长度(完全不重叠),每个时间点只被评估一次,异常分数序列会呈阶梯状,边界处抖动剧烈;重叠率高一些,同一个时间点会被多个窗口覆盖,分数可以取平均,平滑很多。代价是计算量上升 4 倍,但推理时学生很小,这个成本可以接受。

import numpy as np def make_windows(arr, L, S): """arr: (T, C) -> (N, C, L)""" idx = range(0, len(arr) - L + 1, S) return np.stack([arr[i:i + L].T for i in idx], axis=0) def fit_scaler(train_arr): mu = train_arr.mean(axis=0, keepdims=True) sd = train_arr.std(axis=0, keepdims=True) + 1e-8 return mu, sd

还有个细节:某些传感器是单调递增的累计量(比如累计运行时长、累计产量)。这类通道直接送进模型,模型会学到"数值大小"而不是"形态",导致训练后期样本全被判为异常。处理方法是对它做一阶差分,或者转换成增量占比。这个坑我在两个项目里都踩过,一定要在预处理阶段先扫一遍通道的量纲和单调性。

3.2 教师模型的训练要点

教师训练是无监督的,只有正常数据。损失函数我倾向于用SmoothL1 而不是 MSE,原因是时序数据里偶尔存在采集毛刺,MSE 会被这些离群点带偏,导致教师把"毛刺"也当成正常模式的一部分学进去。

训练轮次不用太多,通常 30 到 50 个 epoch 就收敛了。判断收敛的信号不是 loss 降到多低,而是验证集上的重构误差分布是否稳定——如果正常段的重构误差方差很大,说明教师学得不稳,后面用它做蒸馏源会把这个不稳定性传染给学生。

def train_teacher(teacher, loader, epochs=40, lr=1e-3, device='cpu'): teacher.to(device).train() opt = torch.optim.AdamW(teacher.parameters(), lr=lr, weight_decay=1e-4) crit = nn.SmoothL1Loss() for ep in range(epochs): total = 0.0 for xb in loader: xb = xb.to(device) noise = torch.randn_like(xb) * 0.05 # 输入加噪,输出重构原信号 y = teacher(xb + noise) loss = crit(y, xb) opt.zero_grad(); loss.backward(); opt.step() total += loss.item() * xb.size(0) print(f"epoch {ep:02d} loss {total / len(loader.dataset):.6f}") return teacher

加噪幅度 0.05 是相对于标准化后的数据(标准差约为 1)来说的,相当于 5% 的信噪扰动。这个值太小起不到正则作用,太大教师会学成"模糊平均",重构出来的曲线过于平滑,反而丢失高频细节。你可以按 0.03 / 0.05 / 0.08 做一个小网格试一下。

3.3 蒸馏训练脚本与关键控制点

蒸馏阶段教师必须冻结,只更新学生。这一点看起来是常识,但我见过有人忘了加requires_grad=False,结果教师被学生的梯度带着跑,两个模型一起塌陷,异常分数全线归零。

def train_student(teacher, student, loader, epochs=30, lr=1e-3, alpha=1.0, beta=0.5, device='cpu'): teacher.eval() for p in teacher.parameters(): p.requires_grad = False student.to(device).train() opt = torch.optim.AdamW(student.parameters(), lr=lr, weight_decay=1e-4) for ep in range(epochs): total = 0.0 for (xt, xs) in loader: # 教师视角全通道长窗口;学生视角部分通道短窗口 xt, xs = xt.to(device), xs.to(device) with torch.no_grad(): y_t, f_t = teacher(xt, return_feat=True) # 教师输出裁剪到学生对应时间区间,保证时间对齐 y_t = y_t[:, :, -xs.shape[-1]:] f_t = f_t[:, :, -xs.shape[-1]:] y_s, f_s = student(xs, return_feat=True) loss = alpha * ((y_s - y_t) ** 2).mean() + beta * ((f_s - f_t) ** 2).mean() opt.zero_grad(); loss.backward() torch.nn.utils.clip_grad_norm_(student.parameters(), 1.0) opt.step() total += loss.item() * xs.size(0) print(f"[student] epoch {ep:02d} loss {total / len(loader.dataset):.6f}") return student

这段代码里有三个容易出问题的点,我逐个说明。

时间对齐:教师看的是长窗口,学生看的是长窗口的末尾一小段,所以教师的输出和特征必须切到对应位置再比。如果直接整段对比,形状对不上会报错;如果粗暴插值对齐,蒸馏信号就失真了。

梯度裁剪:蒸馏损失的梯度在训练初期可能很大(学生输出和教师差得远),不裁剪容易一步把学生推到一个坏区域,后面再也爬不出来。阈值 1.0 是个稳妥的起点。

教师 eval 模式:BatchNorm 在 eval 下用滑动统计量,如果忘了切 eval,每个 batch 的统计量会跟着变,教师输出就不稳定,学生学的是噪声。这个错误排查起来很费时间,因为 loss 曲线看起来"只是抖了一点"。

3.4 异常打分与阈值的确定方法

训练完之后,打分公式是:

score_t = ||y_S(t) - y_T(t)||² + λ · ||f_S(t) - f_T(t)||²

逐时间点计算后,把多个覆盖窗口的结果做平均,得到一条长度等于原始序列的分数曲线。

阈值这块,我强烈建议别用简单的 3σ。原因很直接:分数分布是右偏的,长尾很重,3σ 会把尾巴上大量正常波动误判成异常。我一般用两种方式,看场景选:

分位数法:取验证集正常数据分数的 99.0 到 99.5 分位数作为阈值。简单、可解释,业务方也好沟通。缺点是如果验证集里混了几段没标注出来的异常,阈值会被抬高。

POT(Peak Over Threshold):基于极值理论,对超过初始阈值的尾部数据拟合广义帕累托分布,反推目标误报率对应的阈值。这个方法在论文里用得多,实际部署里也稳,代码量不大:

from scipy.stats import genpareto import numpy as np def pot_threshold(scores, init_q=0.98, target_rate=1e-3): init_thr = np.quantile(scores, init_q) exc = scores[scores > init_thr] - init_thr if len(exc) < 20: return init_thr c, _, scale = genpareto.fit(exc, floc=0) n, nt = len(scores), len(exc) return init_thr + (scale / c) * ((nt / (n * target_rate)) ** c - 1)

这里target_rate是你期望的误报比例,1e-3 意味着大约每 1000 个时间点允许一次误报。这个参数必须和业务方谈,不能自己拍。我见过有团队默认设 1e-5,结果阈值高到天上去,一整月一个告警都没出,等于模型没上线。

4. 效果评估与调参经验实录

4.1 评价指标的选择,比模型本身更容易出错

时序异常检测的评估有个老生常谈的问题:点级指标会严重高估性能。因为绝大多数方法在异常区间边界附近都会有一段"半高"的分数,这些点全被算作命中,F1 会虚高十几个百分点。

我更关注三个指标的组合:

指标含义我的使用场景
Point-wise F1逐点判定的精确率与召回率快速对比不同方案,只看相对高低
Range-based Recall异常区间内只要有一个点命中就算召回业务上"能不能发现"的直观度量
Detection Delay从异常真实起点到首次告警的时间差决定这个模型能不能用于预警
False Alarm per Day每天平均误报次数直接和值班同事的接受度挂钩

第四个指标才是一线最关心的。指标表上 AUC 0.92 的模型,如果每天误报 40 次,业务方三天就会要求下线。所以我在调参时会把"误报/天"作为硬约束,先卡住它,再在剩余空间里优化召回。

4.2 关键超参的敏感度与推荐区间

下面这张表是我在两个不同项目(一个是设备振动,一个是服务指标)上汇总出来的经验区间。要说明的是,这是基于常见实践的归纳,不是普适定律,你自己的数据一定要重新扫一遍。

超参推荐区间敏感度调大的后果
教师窗口长度 L3–5 倍异常持续时长高教师能看的上下文更多,但单窗口样本内正常模式变复杂,学生更难学
学生窗口长度L 的 1/4 到 1/8极高缺口变小,异常分数整体下降,区分度丢失
特征损失权重 beta0.3–1.0中高分数曲线噪声上升,阈值难定
学生隐藏维度12–24中容量缺口消失,学生学太好
加噪幅度0.03–0.08中教师过度平滑,高频细节丢失
训练轮次25–50低过拟合正常数据,正常段分数被压得过低

从敏感度排序看,学生窗口长度和特征损失权重是两个必须精调的参数。前者直接决定信息缺口的物理大小,后者决定蒸馏信号的质量。其他的在推荐区间内浮动,性能差异通常在 2 个百分点以内。

4.3 消融实验怎么做才有说服力

消融实验最容易写成形式主义:"去掉特征蒸馏,F1 下降 0.8,说明特征蒸馏有效"。这种结论没什么信息量。真正有价值的消融应该回答的是:这个组件对哪一类异常起了作用。

我的做法是把测试集按异常类型拆开,至少分成四类:突刺型(单点大幅偏移)、持续偏移型(整体上移或下移)、上下文型(数值正常但时序位置不对)、耦合型(多通道关系异常)。然后逐类算 F1,做成下面的对照:

配置突刺型持续偏移型上下文型耦合型
仅输出层蒸馏0.810.760.520.48
+ 特征层蒸馏0.830.790.710.55
+ 通道缺口0.820.780.730.78
+ 窗口缺口0.840.800.820.79

这张表(数据是示意,不是真实结果)能讲清楚的故事是:特征层蒸馏主要提升上下文型,通道缺口主要提升耦合型,两者互补。这样的消融结论才能指导后续优化方向——如果某类异常在你的业务里最重要,你就知道该往哪个组件上加码。

5. 踩坑记录与问题速查

5.1 学生学得太好,异常分数全线归零

这是我遇到频率最高的问题。表现是:训练集和验证集上的异常分数都非常小,正常段和异常段的分数分布几乎完全重叠,怎么调阈值都分不开。

根本原因是信息缺口不足。学生有足够的能力把教师模仿到位,那它就失去了"被异常难住"的可能。排查顺序建议是:

  1. 先看学生参数量是不是接近教师,是的话把隐藏维度砍到 1/4;
  2. 再看学生输入窗口是不是和教师一样长,是的话砍到 1/4 到 1/8;
  3. 最后检查是否所有通道都给了学生,试着砍掉一半通道。

还有个小概率情况:教师的表示本身就很弱。如果教师的重构误差在正常数据上就很大,说明它也没学到什么东西,学生自然学无可学,这种时候先回去把教师训好。

5.2 分数在正常段剧烈抖动,阈值无法覆盖

表现是正常段的分数曲线毛刺很多,峰值接近异常段,导致阈值只能设得极高,漏报严重。

这个问题通常来自两个方向。一是教师 eval 模式没切,BatchNorm 统计量在变,教师输出不稳定。二是特征层蒸馏权重过大,高维特征在早期训练阶段本来就不稳定,学生跟着抖。处理办法是把 beta 从 0.5 降到 0.2 试试,同时对分数曲线做一个短窗口的滑动中值滤波(窗口取 3 到 5 个点),只平滑掉单点毛刺,不改变整体形态。

要注意的是滤波不能过度。我见过有人用窗口 21 的中值滤波,结果持续型异常的上升沿被削平了,检测延迟从 5 分钟拖到 40 分钟,等于白做。

5.3 部署到边缘设备后的性能与内存问题

学生模型本来是轻量的,但上线后仍然可能遇到两个问题。

输入队列的内存占用。推理时要维护一个长度为教师窗口的环形缓冲区,如果通道数多、采样频率高,缓冲区本身可能比模型还占内存。比如 128 个点 × 64 通道 × float32,也就 32KB,问题不大;但如果采样周期是秒级、窗口拉到几千点,就要考虑降采样或者只缓存必要的通道。

分数聚合的计算成本。如果重叠率是 75%,每个时间点要处理 4 个窗口的分数再平均,这部分开销容易被忽略。实际部署时我一般改成"每个窗口只对最后一个时间点打分输出,其余点靠队列累计",这样推理量降下来 4 倍,平滑效果靠后处理补上。

5.4 分布漂移导致的慢性失效

这是最隐蔽的问题:模型上线三个月都好好的,第四个月开始误报频率缓慢上升。原因是设备老化、季节更替、工艺调整,正常数据的分布已经和训练时不一样了,教师和学生对这个"新正常"的理解都在变,但变化不同步。

处理上没有银弹,但有两条实用做法。一是定期用最近一段时间的正常数据微调教师(学生重新蒸馏),频率按业务漂移速度定,我一般设成两个月一次。二是监控分数分布本身的统计量,把每周的分数 95 分位数画成趋势图,如果这个指标连续三周单调上升,就该触发重训了。这个简单的监控比任何复杂的漂移检测算法都有效,成本也低。

5.5 问题速查表

现象大概率原因优先动作
异常分数普遍接近 0信息缺口不足缩小学生窗口或隐藏维度
正常段分数抖动大特征蒸馏权重过高 / BatchNorm 未切 eval降 beta,检查 model.eval()
漏报持续型异常窗口太短,教师看不到趋势拉长教师窗口至 3–5 倍异常时长
漏报耦合型异常学生拿到了全部通道砍掉部分通道制造缺口
误报每天几十次阈值取了 3σ换成分位数法或 POT,与业务对齐误报率
上线后指标明显低于验证归一化用了全量统计量改为仅用训练段统计量
训练 loss 不降梯度爆炸或教师本身没训好加梯度裁剪,先单独验证教师重构误差

我个人在实际操作中的体会是:这套方法的效果上限,八成取决于"信息缺口设计得对不对",剩下两成才取决于网络结构和损失配比。所以别在模型结构上反复雕花,先把"教师能看见什么、学生看不见什么"这件事想清楚,往往改一个窗口长度就能带来比换三个结构更大的提升。