变星光变曲线这东西外行看着就是一堆上下抖动的散点内行看到的却是恒星的身份档案。过去几年里我断断续续在做一件事把这条曲线喂给一维卷积神经网络让机器自己去判断它属于哪一类变星。这件事听起来像是把图像识别的套路搬过来但真动手之后你会发现光变曲线的坑和图片完全不是一回事——采样不等间隔、幅度差几个数量级、类别长尾严重、周期信息藏在相位里。这篇东西就把我从数据清洗到模型上线这一路的做法和踩过的坑完整摊开适合已经有点深度学习基础、又想往时序科学数据方向挪一挪的读者也适合做天文数据处理、想找个自动化分类方案的同行参考。1. 从“人眼看曲线”到“网络看曲线”问题到底难在哪1.1 变星分类的真实工作现场先说说这项工作的原始场景。一次长时间跨度的测光观测会产出几十万到上亿条光变曲线每条曲线是“时间—亮度星等—测量误差”的三元组序列。人工分类的做法是把曲线画出来看形状——是尖锐的食变凹陷还是平滑的正弦起伏看周期——几小时还是几百天看振幅——0.01 星等还是 5 个星等再结合颜色、光谱辅助判断。一个熟练的标注员一条曲线快的话十几秒慢的话要反复折叠相位试好几个周期才能定下来。问题在于规模。几十万条曲线靠人工过一遍就算十个人全职干也要按年算。而且这种判断带主观性两个标注员对同一条半规则变星给出不同标签是非常常见的事。所以自动化分类不是“炫技需求”是硬生生的产能需求。目标很朴素——把 80% 以上容易判断的曲线自动定下来剩下 20% 模糊的交给人工复核。1.2 手工特征路线为什么走不远在深度学习介入之前主流做法是提手工特征周期、振幅、偏度、峰度、上升/下降沿时间比、傅里叶分解的各阶振幅比与相位差、小波系数能量分布等等。这条路我走过能用但天花板很明显。第一特征工程是“针对已知类别”设计的。你能想到用上升沿时间比去区分天琴座 RR 型的 ab 和 c 亚型是因为你事先知道这两类的光变形状差异体现在哪里。一旦来了一类你没预料到的样本特征集就失效了。第二周期搜索本身是个易错环节。常用的周期图方法在采样有规律性缺口时会产生大量混叠峰选错周期后面所有基于相位的特征全部跑偏。我见过一批样本因为选到了二倍周期被整体错分成另一类。第三手工特征丢信息。傅里叶分解取前几阶等于把曲线做了低通滤波那些尖锐的食变次级凹陷、米拉变星上偶尔出现的激波隆起全被抹平了。而这些恰恰是分类的关键证据。一维卷积的好处就在这儿它不需要你告诉它看哪里卷积核在整条序列上滑动自己去找那些有判别力的局部形态。1.3 一维卷积与光变序列的天然契合为什么是一维卷积而不是循环网络或者 Transformer我的理由有三条都是实测出来的。一是局部形态的重要性。光变曲线的判别信息高度集中在局部食双星的“V 形或 U 形凹陷”是一个局部片段RR 型的“快速上升、缓慢下降”也是局部陡峭度的差异。卷积核天生就是局部感受野加权重共享处理这类模式效率极高。二是参数量和训练成本。一条曲线重采样到 1000~2000 个点如果上 LSTM序列长度带来的计算开销很可观而且长序列上的梯度传播不好调。1D-CNN 堆五六层卷积几万到几十万参数就能跑出很好的效果单卡训练几小时收敛这对反复做实验太重要了。三是对相位对齐不敏感。这一点经常被忽略。循环网络对输入顺序敏感而光变曲线的起点是任意的——你把序列循环移位 500 个点物理上是同一条曲线。卷积加全局池化的组合对这种循环移位有天然的容忍度这也是我后来在数据增强里敢大胆做随机相位平移的底气。注意一维卷积适合的是“已经折叠对齐好的单周期或多周期序列”。如果你直接喂原始的不等间隔时间序列卷积核的滑动就失去了物理意义效果会明显变差。这是很多人第一步就走错的地方。2. 数据决定了模型的上限光变曲线的清洗、对齐与重采样2.1 测光表的三个必做清洗动作拿到原始测光数据别急着往网络里灌。我固定会做三件事顺序不能乱。第一件是剔除明显异常点。测光数据里混着宇宙线击中、坏像素、背景饱和导致的离群值。我的做法是对整条曲线算中位数和 MAD中位数绝对偏差把偏离超过 5 倍 MAD 的点直接丢掉。为什么用 MAD 而不是标准差因为标准差本身会被离群点拉大用标准差去剔除离群点等于让贼来抓贼。MAD robust 得多。第二件是过滤低信噪比点。每条测光记录通常自带误差估计我把误差大于该曲线误差中位数 3 倍的点丢掉。这一步能砍掉接近地平线、大气质量过大时拍的废点。第三件是剔除过短的曲线。有效观测点数少于某个阈值我用的是 30 个点的曲线形状信息根本不完整强行分类只会污染训练集。这批样本统一打上“数据不足”标签走人工通道。清洗完要记录清洗比例。我统计过一个典型的地面巡天数据集清洗阶段平均会丢掉 8%~15% 的点。如果某批数据丢点率突然飙到 30% 以上说明观测条件或数据处理管线出了问题这时候应该停下来查而不是硬着头皮往下跑。2.2 不等间隔采样到定长向量的三条路卷积网络要求输入是定长、等间隔的向量而天文观测天生不等间隔——白天不能观测季节变化有观测空档天气好坏决定采样密度。所以必须做重采样。三条路我都试过方案做法优点缺点线性/样条插值在时间轴上均匀取 N 个点插值补值实现简单保留绝对时间跨度大段观测空档被插值“编造”产生虚假形态相位折叠分箱先定周期折叠到相位 0~1分 N 个 bin 取中位数物理意义明确天然对齐依赖周期准确性错周期直接毁掉样本核回归平滑用高斯核在相位空间加权平均对噪声鲁棒bin 内样本不均时表现好计算量大核宽需要调我最终选的是“相位折叠 分箱取中位数”N 取 200。理由很实际分类的依据本来就是相位形态而不是绝对时间跨度。一条周期 0.5 天的 RR 型和一条周期 300 天的米拉变星在绝对时间轴上的形态完全不可比但折叠到相位空间后形态差异就变得可比了。分箱取中位数而不是均值是因为中位数对残余离群点更稳。bin 里没有观测点的我用相邻 bin 线性插值填充并且额外生成一个“掩码通道”告诉网络哪些 bin 是插值来的——这个掩码通道后来被证明挺有用模型学会了在观测稀疏的相位区间降低置信度。import numpy as np def phase_fold_bin(t, mag, period, n_bins200): phase ((t / period) % 1.0) order np.argsort(phase) phase, mag phase[order], mag[order] edges np.linspace(0, 1, n_bins 1) idx np.digitize(phase, edges) - 1 idx np.clip(idx, 0, n_bins - 1) binned np.full(n_bins, np.nan) mask np.zeros(n_bins, dtypenp.float32) for i in range(n_bins): sel mag[idx i] if sel.size 0: binned[i] np.median(sel) mask[i] 1.0 # 空洞用线性插值补并保持 mask0 nan_idx np.isnan(binned) if nan_idx.all(): return None, None binned[nan_idx] np.interp( np.flatnonzero(nan_idx), np.flatnonzero(~nan_idx), binned[~nan_idx] ) return binned.astype(np.float32), mask2.3 归一化方式选错模型学到的全是幅度这是我最开始栽的一个坑。星等是个对数量纲不同变星的亮度差异巨大——食双星可能掉 3 个星等而某些小振幅变星只有 0.02 星等。如果我直接喂原始星等网络会优先学“幅度”把所有大振幅的都归成一类把所有小振幅的归成另一类。第一次训练出来的混淆矩阵简直惨不忍睹模型把大振幅食双星和长周期米拉变星混成一团只因为它们的峰谷差都很大。正确做法是逐条曲线做标准化。我的处理是先把星等转成流量或者直接取负星等然后除以该曲线的峰值流量让最大亮度恒为 1再做一次减均值除标准差。这样既保留了相对形态又把幅度信息压掉了。等一下这里有个细节值得多说一句。完全压掉幅度真的好吗不一定。有些类别之间振幅本身就是判别特征——大振幅米拉型和小振幅半规则型形态相似度挺高。所以我的做法是双通道输入通道一放归一化后的形态序列通道二放一条常数序列常数取该曲线的原始振幅标准化后。这相当于给网络一个全局标量提示让它自己决定要不要用这个信息。实测这个改动让米拉型与半规则型的混淆率降了大约 6 个百分点。3. 网络骨架的取舍1D-CNN的每一层该长什么样3.1 卷积核尺寸和感受野的一笔账网络结构我没有一上来就堆很深。200 个 bin 的输入先用大核再逐层缩小是我试下来最省参数又有效的配置。这里要说清楚感受野的账怎么算。单层卷积的感受野RF KK 为核宽stride1无空洞。多层堆叠时感受野的增量是 (K-1) 乘以之前所有层的步长乘积。在 stride 全为 1 的情况下总感受野就是各层 (K-1) 之和再加 1。以我的配置为例第一层核宽 7第二层 5第三层 3感受野 1 6 4 2 13。这只能看到 13 个 bin相当于一个相位周期的 6.5%太窄了看不到完整形态。所以我继续堆两层核宽 3 的卷积感受野变成 13 2 2 17。还是不够。这里就遇到一个取舍要么堆更多层参数增加、梯度传播变难要么用 stride2 的池化快速扩大感受野要么用大核。我最后的选择是前两层大核15 和 7然后接两个 stride2 的最大池化感受野能达到接近 60 个 bin也就是 30% 的相位周期。这个量级刚好够覆盖一个食变凹陷的完整宽度。为什么不直接用核宽 51 的单层大核参数是 51×通道数其实不算多但大核对局部尖锐特征的响应会被平均掉——食双星的次级凹陷只有几个 bin 宽大核滑过去几乎没反应。小核堆叠才是兼顾局部细节和全局视野的解法。实际用的结构大概是这样import torch.nn as nn class LightCurveCNN(nn.Module): def __init__(self, n_classes): super().__init__() self.features nn.Sequential( nn.Conv1d(2, 32, 15, padding7), nn.BatchNorm1d(32), nn.ReLU(), nn.Conv1d(32, 64, 7, padding3), nn.BatchNorm1d(64), nn.ReLU(), nn.MaxPool1d(2), # 200 - 100 nn.Conv1d(64, 128, 5, padding2), nn.BatchNorm1d(128), nn.ReLU(), nn.MaxPool1d(2), # 100 - 50 nn.Conv1d(128, 128, 3, padding1), nn.BatchNorm1d(128), nn.ReLU(), nn.AdaptiveAvgPool1d(1), ) self.head nn.Sequential( nn.Flatten(), nn.Dropout(0.3), nn.Linear(128, 64), nn.ReLU(), nn.Linear(64, n_classes), ) def forward(self, x): return self.head(self.features(x))3.2 池化层、全局平均池化与残差连接池化这块最大池化和平均池化我都试过。结论是池化用最大收尾用平均。中间层用最大池化是因为光变曲线里的判别特征往往体现为“尖锐的极值”——食变的谷底、RR 型的峰顶这些是稀疏但关键的位置平均池化会把它们稀释掉。最后的全局层用自适应平均池化是为了把所有局部响应汇总成一个稳定的全局描述子同时避免全连接层参数爆炸。残差连接要不要加我的实验结论是五层以内不用加加到七八层以上不加会明显退化。我现在的配置是 5 个卷积层没加残差训练稳定。但如果你想把输入长度拉到 2000 个 bin 去保留更精细的形态层数必然要加深这时候残差连接就是必需品了。还有一个我加了之后效果不错的模块通道注意力。在每次池化之前给每个通道算一个权重让网络自己决定哪些卷积分支更重要。实现上就是一个全局平均池化接两层全连接再接 sigmoid多出来几百个参数但让宏平均 F1 提升了约 1.5 个百分点。代价是训练慢了一点收敛轮数多了大概 15%。3.3 分类头与损失函数的搭配分类头不要搞太复杂。我见过有人在最后接三层全连接每层 512 维结果严重过拟合训练集准确率 99%验证集只有 78%。我的做法是全局池化后直接 dropout 0.3接一个 64 维的隐层然后输出。损失函数的选择和类别分布直接相关。如果你的数据集里最大类和最小类的样本数差在 10 倍以内交叉熵就够了。超过 10 倍就得动点手脚。关于这一点在下一节详细说。另外提一个容易忽略的点标签平滑。天文标签本身就有噪声很多样本的类别归属在专家之间都有争议。用 0.05 到 0.1 的标签平滑等于告诉模型“别对标签太自信”实测能降低过拟合验证集损失曲线更平滑。我用的是 0.1。4. 长尾分布下的训练我试过的几种打法4.1 类别加权、过采样与focal loss的实测排序变星类别的分布是典型的长尾。常见的光变类型占了样本的绝大部分而那些稀有类型可能只有几百条甚至几十条。我处理的数据集里最大类和最小类样本量差了将近 200 倍。三种手段我都试过说说真实效果类别加权是最省事的。给每个类别的损失乘一个权重权重取该类样本数的倒数再归一化。改动只有一行代码好处是不改变数据分布坏处是极端稀有的类别权重会非常大导致模型对这类样本过度敏感产生大量误报。我试过权重按 1/sqrt(N) 来算比纯倒数温和一些效果更好。过采样是把稀有类复制多份。简单粗暴但直接复制会导致严重过拟合——同一条曲线反复出现模型把它背下来了。改进版是在复制时加随机扰动相位平移、加噪声效果明显好转。Focal loss是我最终采用的。它通过调制因子降低易分样本的损失贡献让模型把注意力集中在难分样本上。对长尾数据特别合适因为稀有类往往就是难分的那些。参数上gamma 取 2alpha 按类别频率设置。三者组合的实际效果对比同一验证集宏平均 F1策略宏平均 F1最小类召回纯交叉熵0.710.32类别加权1/N0.780.55类别加权1/√N0.810.61过采样 扰动0.800.58Focal loss0.830.66Focal loss 轻度加权0.850.69最后一行是我现在用的配置。Focal loss 打底保证难样本被关注轻度加权权重取 1/√N 的平方根再归一化更温和作为补充避免极端类别被完全忽略。4.2 学习率调度与批大小的配合批大小我定在 64。试过 32 和 12832 的时候梯度噪声大收敛慢但最终效果略好128 的时候训练快但小类别的梯度容易被大类别淹没最小类召回掉了 4 个百分点。64 是个折中。学习率用 1e-3 起步配余弦退火。这里有个细节warmup 很有必要。前 3 个 epoch 用线性 warmup 从 1e-5 爬到 1e-3能显著降低早期训练不稳定的概率。不加 warmup 的时候我遇到过好几次 loss 直接飙到 nan尤其是 batch 里有极端振幅样本的时候。优化器用 AdamW权重衰减 1e-4。为什么不用 SGD试过收敛需要的轮数多得多而且对学习率非常敏感调参成本太高。AdamW 在默认参数下就能跑出不错的结果对反复做消融实验很友好。早停的判据不要用准确率用验证集的宏平均 F1。理由是准确率会被大类主导模型只要把大类分对准确率就能很好看但小类可能全军覆没。我设置的是连续 8 个 epoch 宏 F1 没有提升就停。4.3 数据增强清单和它们的副作用光变曲线的数据增强比图像有意思因为很多增强是有物理意义的。我用的清单随机相位平移把整条序列循环移位随机步数。物理上完全等价因为相位起点本来就是任意的。这个增强几乎零成本收益最大。随机周期缩放把周期乘以 0.98~1.02 的随机因子再重新折叠。模拟周期测量误差。这个增强要慎用缩放太多会破坏形态。加性高斯噪声按曲线的信噪比水平加噪声。模拟不同观测条件。随机丢点随机把 5%~15% 的 bin 置为插值值同时更新掩码。模拟观测空档。幅度缩放把归一化后的形态乘以 0.9~1.1。模拟测量系统误差。副作用要说清楚。随机周期缩放用过头会伤害周期性强的类别。我一开始把缩放范围设成 ±5%结果天琴座 RR 型 ab 和 c 亚型的区分度明显下降——因为这两类的主要差异就是周期和波形的关系缩放把这种关系搞乱了。后来收到 ±2%问题解决。随机丢点要控制比例。丢太多插值出来的形态是假的模型会学到虚假模式。我上限设在 15%超过这个比例验证集指标开始掉。还有一个反直觉的发现水平翻转把曲线左右镜像不能用。图像里翻转是常规增强但光变曲线的上升沿和下降沿不对称性恰恰是重要判别特征RR 型就是快速上升慢速下降。翻转等于把这个特征反过来了会直接教坏模型。这一条我踩过加进去之后验证集指标掉了 3 个百分点查了半天才发现是这个增强的问题。5. 评估环节最容易被糊弄过去的地方5.1 宏平均F1背后的每类召回模型训完第一眼看什么很多人看整体准确率。我不看因为没意义。在一个长尾数据集上把所有样本都预测成最大类准确率可能就有 60%。这种模型一文不值。我看的是混淆矩阵加每类召回。宏平均 F1 是每类 F1 的算术平均它对小类敏感是个更诚实的指标。但我还会单独把每类召回拉出来看因为宏 F1 也会掩盖问题——假如某一类召回只有 0.2但精确率很高F1 可能还有 0.33看着不算太烂实际这类基本废了。我的验收标准是分层的大类召回要求 0.95 以上这些是自动化的主力中等类 0.85 以上稀有类 0.7 以上。达不到的类别单独拎出来分析原因而不是整体重训。5.2 三组高频混淆对的成因剖析跑了几十次实验混淆矩阵里反复出现的就是那几对说说成因和我怎么处理的。第一对天琴座 RR 型 ab 与 c 亚型。这两类形态相似都是短周期、较大振幅的脉动变星主要差异在波形对称性和周期分布。ab 型上升沿陡、下降沿缓c 型接近正弦。在相位折叠图上看差异其实挺细微的。我通过提高 bin 数从 200 提到 400让波形细节更清晰混淆率降了一些。另外把周期作为额外特征拼进分类头效果更明显——因为这两类的周期分布本来就有差异之前的信息被我归一化步骤给压掉了。第二对食双星中的分离型与半接型。这两类的区别在于是否有物质交流光变曲线上表现为次级凹陷的形状和深度差异。有些样本的次级凹陷非常浅信噪比低的时候几乎看不见。这一对靠单条曲线很难彻底分开我的处理是接受一定的混淆率把置信度低的样本推给人工而不是硬调模型。硬调的结果往往是这类分对了另一类崩了。第三对米拉型与半规则型。这两类的边界在学术上本身就模糊——半规则型有时候会表现出接近米拉的周期性和振幅。标注数据里本身就存在标签噪声。我的做法是对这一对做标签合并试验看看合并成一个“长周期脉动”大类后整体指标如何。结果显示合并后宏 F1 提升明显说明模型确实分不开那就承认现实把粒度放粗。这个思路值得强调分类粒度和模型能力要匹配。如果你的数据质量和模型能力只能支撑到“族”级别就不要硬追求“亚型”级别那只会制造虚假的精确度。5.3 置信度阈值与人工复核队列的设计自动化分类的价值不在于全自动而在于把人力集中到刀刃上。所以我设计了一个两阈值机制。模型输出 softmax 概率取最大概率作为置信度。置信度高于 0.9 的直接采用预测标签低于 0.5 的直接进人工队列中间的灰色地带按类别分别设定阈值——大类阈值可以低一些因为大类样本多错一点无所谓稀有类阈值要高错一个稀有类的代价大。实测下来大约 72% 的样本能落在高置信区且这批样本的人工抽检准确率在 96% 以上。剩下 28% 进人工人力成本降到原来的三成不到。还有一个细节置信度需要校准。神经网络的 softmax 输出往往过于自信——预测概率 0.95 的样本实际正确率可能只有 0.85。我用温度缩放做了校准在验证集上拟合一个温度参数 T推理时把 logits 除以 T 再 softmax。校准之后置信度和实际准确率的对应关系好了很多阈值设定才有依据。6. 把实验代码变成能跑的流水线6.1 批处理推理的内存与显存账notebook 里跑通了不代表能处理百万级数据。推理阶段第一个撞上的是内存。一条曲线 200 个 bin、双通道、float32就是 200×2×4 1600 字节。一百万条是 1.6 GB听起来还好。但预处理阶段要做相位折叠、分箱、插值如果一次性把中间结果全 load 进内存实际占用会翻好几倍。我的做法是分块流式处理每块 5000 条处理完立刻转成定点数组存盘内存峰值控制在 2 GB 以内。显存这边推理时 batch size 可以设得比训练大很多因为没有反向传播的中间激活。我设的是 512一张中等显存的卡跑满负载没问题。但要注意推理时必须包在torch.no_grad()里不然显存会持续增长直到 OOM——这个坑我踩过跑到一半崩掉查了半天才发现是忘了加装饰器。还有一个性能点把模型转成推理模式。model.eval()不只是关掉 dropout 和 batchnorm 的统计更新它还会影响某些层的计算路径。忘了加这一步推理结果会和验证时对不上这个 bug 极其隐蔽因为结果只是“稍微差一点”不会报错。6.2 数据漂移与模型版本管理模型上线之后最容易被忽略的问题是数据漂移。望远镜的 detector 会老化滤光片会更换观测策略会调整这些都会让新数据的分布和老数据产生偏移。一个在旧数据上训练到 0.85 宏 F1 的模型两年后可能只有 0.75。我的应对是三个动作。一是定期抽检每个月从新数据里随机抽 500 条人工标注后算指标画一条时间序列曲线。二是监控输入分布统计每条曲线的振幅分布、采样点数分布、信噪比分布和训练集做对比用简单的 KL 散度或者分位数对比就能发现异常。三是保留模型版本每次重训都存下模型权重、训练数据的时间范围、超参数配置。这样出问题能快速回滚也能对比不同版本的差异。重训的触发条件我设了两条抽检指标下降超过 5 个百分点或者输入分布偏移超过阈值。任意一条触发就启动重训流程。6.3 与人工标注员的协作接口最后说说流程上的事。自动化分类不是替代人是重新分配人的注意力。所以工具设计要以标注员为中心。我做的界面很简单左边显示相位折叠后的曲线图带误差棒右边显示模型给出的 top-3 类别和对应置信度下面是几个快捷键对应常见类别。标注员看一眼曲线如果同意模型的首选按回车确认不同意就按对应类别的键覆盖。所有覆盖操作都被记录这些记录是下一轮重训最宝贵的训练数据——它们是模型真正犯错的地方。有个经验值得分享让标注员看到模型的理由。我给界面加了一个小模块显示模型在曲线的哪个相位区间激活最强用类别激活映射算出来的。标注员反馈说这个很有用因为当模型关注的位置明显不合理时他们会更警惕。这实际上是一种人机互补——模型提供候选人提供把关而模型的“注意力”又反过来提示人去看容易忽略的地方。数据回流也要设计好。所有人工修正过的样本打上“已复核”标记进入一个专门的缓冲区。重训时这个缓冲区的样本会被重复采样权重更高。这样模型能快速从错误中学习而不是等到下一批大规模标注。我个人在实际操作中的体会是这套流程里最难的从来不是网络结构而是数据清洗的规范和标签一致性的维护。结构可以抄超参数可以调但如果训练集里同类样本的标签标准前后不一再好的模型也学不出稳定的决策边界。我现在的做法是给每一类写一份“判别标准文档”把边界情况写清楚新标注员上岗前先做 200 条的一致性测试和已有标注结果对比一致率低于 90% 的不让上岗。这个办法笨但很有效。另外分享一个小技巧如果你的数据量不足以从头训练可以先用大量无标签的光变曲线做自监督预训练——比如让模型预测曲线被遮挡的一段或者判断两个随机裁剪片段是否来自同一条曲线。这套做法在时序数据上效果不错我试过用 30 万条无标签数据预训练再用 2 万条有标签数据微调宏 F1 比直接从零训练高了 4 个百分点左右对小类别尤其明显。
阅读完成 · 觉得有帮助?