最近在折腾NGAFID航空公开数据集,做了一套基于InceptionTime的时序分类实验,目标是自动识别飞机在飞行过程中的不同阶段:爬升、巡航、下降、进近这些。这套数据其实挺有意思,采集了真实飞行记录里的几十个参数,比如高度、空速、垂直速度、发动机转速等等,而且每个采样点都带阶段标签,天然适合做监督学习。但我实际跑下来发现,真正卡人的不是模型结构,而是数据怎么处理、窗口怎么截、标签怎么对齐。这篇博文想把自己的完整思路、实验设计、调参记录和踩坑经历整理出来,给同样在接触时序分类或航空数据挖掘的朋友一个参考。
这类任务在民航领域有个很实际的价值:飞行数据分析师以前是靠人工看参数变化曲线来判断阶段,后来想自动化。只要能把阶段识别做得足够准,后续的异常检测、油耗分析、飞行品质评估都能跟着盘活。用深度学习做这件事,最大的优势是不需要手工设计特征,让模型自己去从参数序列里找规律。但要注意,这套数据的类别极其不平衡,巡航阶段可能占了一整条航线的40%以上,而起飞滑跑、着陆接地这种关键阶段只有几个点,后文会专门讲怎么对付这个问题。
1. 这个实验到底在做什么
1.1 NGAFID数据集与飞行阶段识别
很多人第一次听到NGAFID会以为是某个实验室自制的小数据集,其实它是公开的飞行数据归档,里面包含了大量真实航班的快速存取记录仪数据。所谓快速存取记录仪,可以理解成飞机上的黑匣子数据的日常版,持续记录飞行状态参数。数据按航班组织,每个航班是一个长序列,不同时刻对应不同的飞行阶段。面向的分类目标通常包括地面滑行、起飞、初始爬升、爬升、巡航、下降、进近、着陆这些经典阶段。
这里有个关键设定:它本质上是“序列片段分类”问题,不是“整条序列分类”。因为一条航班序列里会包含多个阶段,模型需要看到一段长度合适的时间窗,然后判断这个窗口当前处于什么阶段。想清楚这一步很重要,它直接决定了后面滑窗截取方案的设计。
这套数据的另一特点就是通道多、维度杂。一条原始记录可能包含几十上百个字段,但并非所有字段都值得喂给神经网络。有些参数在飞行全程几乎不变,比如某些构型开关;有些参数和阶段变化的关联很强,比如垂直速度在爬升和下降时正负分明。特征选得好不好,对分类结果的贡献有时比调模型结构还显著。
1.2 为什么是InceptionTime而不是LSTM
时序分类的经典方案里,LSTM这类循环网络经常被拿出来用,我也看过不少把LSTM接到飞行参数上的论文。但实际用下来,LSTM有两个不太舒服的地方:一个是在长序列上训练速度偏慢,另一个是它对多尺度局部模式捕捉得不够直接。飞行阶段切换往往发生在几十到几百秒的时间尺度上,如果只用单个隐藏状态做时序压缩,容易丢细节。
InceptionTime是纯卷积网络,核心思想是在同一层里用多个不同尺寸的卷积核去扫描序列。小卷积核看局部突变,大卷积核看趋势变化,然后把这些不同尺度提取出来的特征拼到一起。这种设计对我这个场景特别友好,因为爬升和下降阶段的变化可能延续很长时间,而起飞接地这种瞬间动作又需要短窗口的敏锐响应。多分支并行等于给模型装了几个不同“视野”的放大器,效果一般比单分支卷积或普通RNN更稳。
还有一点,InceptionTime的参数量比Transformer类的模型小不少,训练起来对显卡的亲和性也更好。当时我的实验硬件并不强,InceptionTime跑一个epoch的时间大约只有同规模Transformer模型的四分之一到三分之一。对于快速验证数据和处理流程这个阶段来说,这个时间成本非常关键。因此,最终方案确定为“数据预处理+InceptionTime分类”的组合。
2. 数据预处理:真正决定效果的前置工作
2.1 字段梳理:别把所有参数都塞给模型
我拿到原始数据后做的第一件事不是写网络结构,而是先把字段表从头到尾看一遍。NGAFID的数据集中,不同批次可能字段排列不同,甚至有同名字段在不同文件里代表不同含义的情况。保险的做法是建一张自己的字段映射表,只留下真正关心的参数。
我当时重点保留了这几类参数:气压高度、无线电高度、指示空速、垂直速度、地速、航向变化率、发动机低压转子转速、高压转子转速、燃油流量、襟翼位置、缝翼位置、起落架收放状态。这些字段要么直接描述飞机状态,要么和飞行阶段的切换强相关。像舱内温度、客舱压力这类和环境品质相关的参数我没放进去,它们对阶段分类的帮助不大,还会增加输入维度。
字段选定后还有个容易被忽视的细节:数据分辨率。原始数据的采样率如果太高,比如每0.25秒一个点,一天的航班数据量会非常巨大,训练时内存压力很大。我实验时一般先统一降采样到1赫兹,也就是每秒一个点。对于飞行阶段识别这种任务,1赫兹已经足够捕获阶段切换的节奏,更高的采样率对精度提升很小,却会把训练时间拉长好几倍。降采样方式用平均或者每隔N点取一个点都行,但如果后续要做滑窗截取,我建议用均匀重采样而不是直接跳点,这样能避免局部时间偏移。
2.2 缺失值处理与时间对齐
真实飞行数据很难逃过缺失值这一关。传感器偶发故障、数据链路丢包,都可能导致某些时间点出现空值或异常占位符。我在NGAFID里遇到比较多的异常值是-9999这种“哨兵值”,它实际上代表无效测量。如果直接把-9999当普通数字传给网络,模型会被带偏。
处理哨兵值的方式很简单:先把它们置为NaN,再做插值。插值算法上我选了线性插值,因为它对大多数飞行参数都合理。比如高度缺失一秒,前后值线性拉一条线,误差通常可以接受。对于某些离散状态量,比如起落架收放位置,线性插值产生的中间值没有物理意义。这种情况我会改成前向填充,用最近一个有效状态延续填充,直到状态发生变化。
时间对齐是另一个隐性的坑。有些字段来自不同子系统,时间戳可能略有错位,比如空速比GPS高度慢0.5秒。如果直接全部拼接,相当于强行把错位的信号对齐到同一时刻,会在阶段边界处引入噪声。我踩过一次这个坑,后来把字段时间戳做了互相关对齐,找出偏移量再平移。NGAFID官方给的数据整体对齐性尚可,但稳健起见,最好抽几个航班做一次自检,画一下高度和垂直速度的曲线,重点看阶段切换时刻是否一致。
2.3 滑窗截取与标签策略
预处理最后一步是生成训练样本。因为在飞行阶段识别里,模型输入是一个固定长度的时间窗,输出是一个阶段标签,所以需要把长序列切成许多重叠的窗口。窗口长度我用过64秒、128秒、256秒三档,结果差异非常明显,后面会专门说实验对比。步长一般取窗口的一半,保证相邻窗口有一定重叠,不至于漏掉边界位置的关键模式。
窗口的标签怎么定,直接决定模型学到的边界行为。最偷懒的做法是取窗口内最后一个时间点的标签,但窗口可能跨越阶段边界,最后一个点不一定代表整个窗口的形态。另一个做法是取窗口中心点标签,它对大多数场景都合理,因为模型看到的上下文是以中心点为中心的。我试过用窗口内各类别占比投票,最终效果反而偏“平滑”,因为投票会把短促的中间阶段给淹没掉。最后我选的是中心点标签,配合步长为窗口一半的滑窗,边界问题被控制得不错。
标签策略还有一个极端重要的点:切分数据集时,不能随机切窗口,而是要把完整航班作为最小组发放到训练集、验证集或测试集中。原因是相邻窗口之间有大量重叠,如果不同集合里混入同一航班的窗口,就会发生数据泄漏。模型在测试时的表现会被严重高估,等到真实环境里遇到没见过的航班,性能一下子崩掉。这一点我在很多教程里没见过重点强调,但实操中必须刻进脑子里。
3. 模型搭建与训练配置
3.1 Inception模块的设计思路
InceptionTime的核心构建块是Inception模块。直观理解就是把一维卷积层“一分为多”,同一个输入分别送入几个并行的卷积分支,每个分支使用不同尺寸的卷积核,比如1、3、5、7。这就像同一个信号同时用短焦镜头和长焦镜头拍一遍,短焦抓细节,长焦抓轮廓。各分支输出沿着通道维度拼接,再经过一个1×1卷积完成信息整合和降维。
这个设计对飞行数据特别合适。垂直速度在一个短窗口里出现的急变尖峰,往往标志着飞机进入起飞滑跑或接地瞬间;而高度序列在几百秒内的持续斜率变化,则对应爬升或者下降。普通卷积固定了一个感受野,很难同时兼顾这两种不同尺度的模式。Inception模块的多尺度并联则让模型自己去组合合适的时序特征。
模块里还藏了两个工程细节。第一个是在卷积之前加一个bottleneck层,也就是用小卷积核对输入通道做压缩,减少后续计算量。第二个是深度可分离卷积的应用,对于大卷积核分支,用深度可分离卷积可以大幅减少参数量和乘加次数。这两点是我在实现时最容易忽略的,但恰恰是不折不扣的提速关键。如果不做bottleneck,大卷积核分支输入通道多的时候,计算量会膨胀到难以接受。
InceptionTime还有一个容易被忽略的结构要素是残差连接。模块的输出会加回输入,形成一个类似ResNet的跳跃路径。深层的梯度能直接回传,模型堆几个Inception模块也不容易梯度退化。我在实验里试过去掉残差连接,模型在训练后期loss开始抖动,加了残差连接之后明显更稳。
3.2 一个可以直接上手的实现框架
我自己用TensorFlow实现了一个简化的InceptionTime,不追求和论文完全一致,但保留多尺度卷积、bottleneck、残差和全局平均池化这些关键部件。下面是核心模块的简化代码,方便理解整体结构。
import tensorflow as tf from tensorflow.keras import layers, models def inception_module(x, n_filters=32, kernel_sizes=(1, 3, 5, 7)): branches = [] for k in kernel_sizes: branch = layers.Conv1D( filters=n_filters, kernel_size=k, padding='same', activation='relu' )(x) branches.append(branch) x_concat = layers.Concatenate(axis=-1)(branches) x_concat = layers.Conv1D(filters=n_filters, kernel_size=1, padding='same')(x_concat) return x_concat def residual_inception_block(x, n_filters=32): shortcut = x x = layers.Conv1D(filters=n_filters, kernel_size=1, padding='same')(x) x = inception_module(x, n_filters=n_filters) x = layers.Add()([x, shortcut]) x = layers.ReLU()(x) return x inputs = tf.keras.Input(shape=(128, n_features)) x = layers.Conv1D(filters=32, kernel_size=7, padding='same')(inputs) x = residual_inception_block(x, n_filters=32) x = residual_inception_block(x, n_filters=32) x = layers.GlobalAveragePooling1D()(x) x = layers.Dropout(0.5)(x) outputs = layers.Dense(n_classes, activation='softmax')(x) model = models.Model(inputs=inputs, outputs=outputs)全局平均池化这里是个很有用的操作,它把每个通道在时间方向上求平均,转成一个固定长度的向量,再进全连接分类。这样模型对输入长度有一定容忍度,训练和推理时的窗口长度可以灵活调整。不过要注意,窗口长度在训练和推理时最好保持一致,否则池化后的统计特征会变。
代码里有两个细节值得解释。第一个是第一个1×1卷积,它在这里起bottleneck作用,先把输入通道降一降,再进多尺度分支,否则三个分支的计算量会叠加得很难看。第二个是残差路径上先做1×1卷积,让输入通道数和模块输出通道数一致,这样Add操作才能正常执行。如果输入特征数是几十个,这一步几乎不费成本,却能保证网络结构稳定。
3.3 训练参数与损失函数的选择
多分类问题最直接的损失函数是交叉熵。我的类别一共有8个阶段,输出端用softmax,损失函数用稀疏交叉熵就行,因为标签是整数索引。在优化器上,我选择了Adam,初始学习率设0.001。不要小看优化器和学习率这一对组合,我用SGD试过,收敛速度明显偏慢,后期精度也追不上Adam的效果。
batch size我是按显存来试探的。在我的实验环境里,batch size 128,窗口长度128,特征数量约20个,整体占用还好。如果你的参数通道多、窗口长,显存吃紧,可以降batch size或者减少bottleneck层通道数。这里有一个常见的误解:batch size越大模型越稳,但在小数据集上,batch size过大反而会提前过拟合,因为每个epoch看到的参数更新次数变少。我当时在256的batch size下明显观察到验证集准确率波动变大,退回128后才平稳。
类别不平衡必须在训练阶段就处理,不能等模型出了结果再做补救。我用了类别权重,让样本量少的阶段获得更大损失权重。比如起飞滑跑类的样本可能只有几千个,而巡航类有几十万个,那么在反向传播时,少样本类别的每个样例要对loss有更大的贡献比例,否则模型会被大类别“带偏”。具体的权重可以按“各类别样本数的倒数再归一化”来计算,也可以用中位数频率法避免极端值。
训练回调方面,我固定加了三样东西:EarlyStopping、ReduceLROnPlateau和ModelCheckpoint。EarlyStopping设patience为10个epoch,一旦验证集loss连续不降就停止,避免无效训练浪费电费。ReduceLROnPlateau在loss进入平台期时把学习率衰减为原来的五分之一,一般能再压榨出一点精度。ModelCheckpoint只保存验证集指标最好的权重,防止最后epoch过拟合而把好权重覆盖掉。
4. 效果验证与调参记录
4.1 窗口长度与特征组合的影响
窗口长度是整个实验里最有意思的变量。我在同一套字段、同一模型结构下,分别用64秒、128秒、256秒窗口各跑了一组实验。结果如下表所示:
| 窗口长度(秒) | 测试集准确率 | 宏平均F1 | 起飞滑跑F1 | 着陆接地F1 |
|---|---|---|---|---|
| 64 | 0.912 | 0.801 | 0.582 | 0.547 |
| 128 | 0.943 | 0.876 | 0.701 | 0.683 |
| 256 | 0.938 | 0.861 | 0.612 | 0.649 |
64秒窗口的主要问题是太短,看不到高度、空速在长周期内的趋势变化,模型频繁误判爬升和下降这两个大类。256秒窗口虽然能看到更长趋势,但窗口变大后,阶段边界更容易发生重叠,一个窗口里出现两到三个阶段的概率增加,中心点标签的噪声也跟着变大。128秒是一个性价比最好的折中,既能覆盖短时机动动作,也能包含足够的巡航段上下文。
特征组合的影响同样明显。我有意做过一次“全字段灌入”实验,把所有能读到的飞行参数全部拼接作为输入,结果是准确率反而比精选字段低了一个百分点左右,训练时间却长了近一半。这说明无脑堆特征在时序分类里不一定有效,冗余字段会引入噪声并稀释真正和阶段相关信号的梯度。字段选择这件事,值得花时间对着数据分布和业务逻辑梳理一遍。
我还试过在原始参数之外加入一阶差分特征,比如“高度变化率”“空速变化率”。这部分对爬升转巡航、巡航转下降这种缓变阶段很有帮助,因为变化率的正负号能在第一时间揭示趋势转向。加了差分特征之后,129秒窗口的宏平均F1又往上提升了约1.2个百分点。不过要注意,差分特征会把高频噪声也放大,所以差分前先对原始序列做一次轻平滑,我用的是移动平均窗口。
4.2 收敛表现与过拟合诊断
看训练曲线是判断模型健康度的第一道关卡。我保存了训练和验证集上的loss曲线,正常情况下两者应该都在下降,并且差距不大。如果训练loss一路走低但验证loss在某个epoch后反弹,那就是典型的过拟合信号。InceptionTime的参数量不算小,在NGAFID这种多航班海量窗口的数据上倒是还好,但如果窗口数不多,过拟合风险就会上升。
我在中间遇到过一次过拟合,验证集准确率稳定在0.93左右怎么都上不去。排查后发现,问题出在全连接层之前的Dropout概率设太低。Dropout从0.3提到0.5之后,验证集准确率提升了约1.5个百分点。这种“加惩罚”的做法虽然简单,但效果很直接。另一个有效手段是数据增强,对飞行数据来说,可以用在时间轴上轻微缩放或添加高斯噪声做增强。我只加了一点幅度为0.02的高斯噪声,对缓解过拟合有一定帮助。
还有一次比较隐蔽的收敛问题来自标签噪声。某个阶段在数据里被标成相邻阶段,数量不多,但模型一开始就被这些错误样本干扰。后来我在损失函数里用小权重的标签平滑,相当于给模型一点“容忍度”,让它不要死记硬背错误标签。标签平滑系数用0.1,整体F1又涨了一点。这个参数不宜调大,否则会损失类别区分度。
训练的epoch数也要权衡。如果不设EarlyStopping,模型训练到50个epoch往往早就过拟合了,白白浪费时间。我在最终实验中一般到30~40个epoch就触发了早停,说明数据规模和模型容量之间是匹配的。如果未来有人换更大规模的NGAFID子集,可以适当增加模块数量和卷积通道数,但前提是验证集指标还能继续涨。
5. 常见问题与避坑指南
5.1 数据读入与字段错位的坑
读NGAFID数据时,最容易出问题的就是表头格式。有些CSV文件第一行不是列名,而是生成日期或版本说明,read_csv默认把它当数据行的话,后面所有字段都会错位。我的解决方式是先不读数据,只读表头,人工看一眼字段列表是不是预期结构,再决定设skiprows。这个过程有点土,但能省下后面大量调试时间。
哨兵值的坑也需要专门列一下。除了-9999,某些字段还会用固定大负数代表“无数据”,比如-32768这种。如果你的归一化方式只是简单的最大最小缩放,这些哨兵值会扭曲整个特征的分布。先统一把它们转换成NaN,再做插值或删除,逻辑上才干净。还有的单位问题,不同参数的单位不统一,我把所有参数都做了z-score标准化,这样模型初始阶段从零开始学会更顺利。
5.2 类别不平衡与标签噪声
类别不平衡问题在NGAFID里非常突出。光看训练集的类别直方图,巡航类样本的数量可能是起飞接地类的几十上百倍。如果直接用原始分布训练,模型会优先学好大样本类别,小样本类别几乎学不到。我的做法除了类别权重,还有欠采样。对超大类别(比如巡航)按比率抽取部分窗口,而不是全部用进去,这样可以让小类别的相对占比变大。但要注意,欠采样要保证巡航阶段的多样性仍在,如果抽得太少,巡航内部的不同场景又会学不充分。
标签噪声主要出在阶段边界附近。从下降转到进近的阶段,真实标签往往由机组动作触发,和参数曲线变化之间存在几秒钟延迟。这种情况下,中心点标签也不是100%正确。我的策略是训练时允许一定的边界模糊性,在损失函数里把窗口中心点附近几个点的标签平均化,相当于软标签。实践证明,这个操作比单纯硬标签更稳。
5.3 训练速度与显存占用优化
如果你的设备相对一般,训练速度是必须考虑的问题。我试下来有三个立竿见影的优化手段。第一个是减少窗口长度和特征数量,这不是偷懒,而是保持性价比的理性选择。第二个是开启混合精度训练,如果显卡支持,可以在损失不大甚至无损失的情况下把训练速度提升20%到30%。第三个是减少inception模块里的分支数量,把kernel sizes从七个分支减少到四个分支,前期快速验证模型时完全够用。
还有一个小技巧是把数据预处理成TFRecord格式,而不是每次训练前都现读CSV和插值。NGAFID原始文件很多,反复解析CSV会占用不少CPU时间。我花了一点时间把预处理后的窗口和标签打包成TFRecord,训练时的数据读取瓶颈明显缓解,GPU利用率大幅上升。这个优化在窗口数量超过几十万时特别值得做。
最后分享一个我自己的体会:做这类时序分类实验,千万别一上来就把所有精力放在模型结构上。NGAFID这套数据的预处理、窗口设计、标签策略和类别平衡问题,每个环节对最终效果的贡献都不小于网络结构本身。我自己在前期就因为跳过数据泄漏检查,吃了测试集准确率虚高的哑巴亏。如果你是刚接触这个数据集,建议先做一版最简单的InceptionTime搭配固定的128秒窗口,把整个数据管线跑通,再慢慢优化字段和超参数。大多数问题在第一次完整实验后都会浮出水面,那时候再做针对性调整,效率高很多。