聊到机器学习实战,很多人偏爱有图形、有图像的数据集,觉得“看得见”才有感觉。而我始终认为,表格型数据才是真正考验基本功的战场。这个系列前面几篇,我写过房价预测、写过信用评分、也写过文本分类——这一篇,咱们来点不一样的:天文数据挖掘。
不卖关子了,这次选的是某大数据竞赛平台上一道天体分类赛题。简单来说,平台给了一批模拟巡天望远镜观测数据,每行对应一个天体,字段是各个波段的星等、红移、坐标之类的数值,要求你判断这个天体究竟是恒星、星系还是类星体。听起来挺浪漫,上手之后你会明白,它本质上是一道非常经典的多分类问题,但夹杂着严重类别不均衡、数值分布长尾、特征物理含义极强这三重考验,非常适合拿来练手一整条数据挖掘流程。
这篇文章,我会把从数据理解到模型融合的完整过程全部摊开讲,包括我踩过的坑和最终决策时的思考逻辑。如果你正准备参加类似的表格赛题,或者想看看除了调参之外还能做点什么,这篇应该能给你不少可复现的参考。
1. 赛题解读与整体方案设计
1.1 先搞清楚:这到底是一道什么题
这道题给的训练集大概有几万条样本,测试集也有好几万条,都是模拟数据。列名不算多,大概二十几个字段,包含天体编号、赤经赤纬、五个波段的星等、红移、几个测光误差项,以及目标标签。目标标签就三类:恒星(star)、星系(galaxy)、类星体(quasar)。
有人看到“天文”两个字就发怵,担心需要懂很多物理知识。实际我的体会是:不需要你成为天文学家,但如果你愿意花半小时了解这些字段的含义,你对特征工程的把控力会上升一个台阶,而不只是对着浮点数瞎试。
从机器学习角度看,这个赛题有三个显著特点。第一,它是一个标准的多分类问题,但标签不均衡:样本里恒星占了接近六成,星系占三成左右,类星体只有不到一成。第二,几乎全部特征都是连续浮点数,没有特别复杂的文本、时间、序列信息,意味着特征工程的重心应该放在数值变换和物理组合上。第三,评估指标不是简单准确率,而是标记了宏平均F1之类的指标,这对少数类的处理提出了更高要求。
我见过不少参赛者一上来就无脑LightGBM默认参数,跑个基线完事,然后开始调参。这样做倒也不会错,但天花板很低。正确做法是先把评价指标吃透,再反过来设计整个方案,因为评价指标决定了你在“少数类”和“多数类”之间如何取舍。如果赛方用macro F1,那类星体哪怕只占8%,它的价值也和占六成的恒星完全一样——这个信号非常重要,直接决定了后面要不要做采样、要不要调整类别权重、要不要用集成投票。
1.2 评价指标不是随便看看就行的
多分类问题常见的指标有准确率、加权F1、宏平均F1、宏平均AUC等。它们之间的差别很大。举个直观例子:假设90%的样本是A类,你全猜A,准确率有90%,但宏平均F1可能只有0.3不到。因为B类和C类的精确率、召回率都是0。评委只报一个数字,如果你不去想清楚这个数字怎么算,后续优化会非常迷茫。
我当时的做法是把混淆矩阵打印出来,盯着少数类的召回率看了很久。宏平均F1会对每个类别算F1再取平均,这就意味着模型必须在多数类上不崩,在少数类上也能抓住足够多正例才行。你不能为了少数类牺牲太多多数类的F1,因为几个类的F1都会进入最终平均。这是一个微妙的平衡。
因此,我的整体策略定为:以宏平均F1作为本地验证的核心参考指标,同时用分类报告里的每个类别F1做辅助分析。这也直接决定了我在采样阶段不入场——因为SMOTE或者随机过采样虽然能提升少数类召回率,但往往会把多数类的精确率拉下来,在宏平均F1下未必占优。我把更多精力放在调整LightGBM的scale_pos_weight参数以及后处理阈值上,这两者才是对指标更敏感的杠杆。
1.3 整体技术路线:为什么我选树模型而不是深度学习
赛题出来以后,身边有人问我:要不要上深度学习?我看了一眼数据类型,直接否了。这不是因为神经网络不行,而是因为表格数据里,深度模型相比梯度提升树并没有稳定优势,尤其是在几万条量级下,树模型无论是训练速度还是超参数鲁棒性都更好。
所以我的方案路线定为:数据清洗 → 探索性可视化 → 物理特征构造 → 分层划分 → LightGBM/XGBoost基线 → 交叉验证调参 → 概率融合。简单说,就是“先让树模型吃透特征,再考虑集成”。这套路不是最花哨的,但它胜在稳定、可控、可解释。一个现实原因是,竞赛中后期你通常没有大量试错时间,与其追求模型的复杂度,不如先保证每个环节不出错。
后面的章节,我会按这个路线一步步展开,重点讲操作细节和决策原因。
2. 数据探索:先摸清这批星星的底细
2.1 字段看不懂?看懂了就是特征金矿
拿到数据第一步,我没有直接跑模型,而是打开数据字典,逐字段过了一遍。这里强烈建议大家都这么做。这个赛题的字段大致分四类:
- 坐标类:赤经、赤纬,表征天体在天球上的位置。多数情况下,它们对分类的贡献很弱,但可以拿来做训练集和测试集的分布校验。
- 测光类:u、g、r、i、z五个波段的星等。星等是天文学里的亮度单位,数值越小越亮。这五个波段覆盖了紫外线到近红外范围,是天文学最经典的五色测光系统。
- 红移类:所谓红移,简单理解就是天体光源因宇宙膨胀导致波长拉长的程度。类星体通常红移特别大,所以这一列几乎直接就是“找类星体”的信号。
- 误差类:每个星等对应一个测光误差,数值越小说明观测越可靠。误差值有一定信息量,但噪声大,后续要做截断或变换。
有人会问:不懂天文学,看到这些列名一脸茫然怎么办?我的办法是:先把字段和物理含义列成一张表,再问自己一个问题——“如果我是天文学家,我靠哪几个数区分这三类天体?”答案其实就是颜色和亮度。天文学家会告诉你,不同天体在不同波段上的亮暗差异是巨大的,这个差异往往比单个波段的星等更有判别力。
于是我接下来的特征构造方向就明确了:基于五个波段的星等做两两差值,得到颜色指数。这部分我在第三节细讲。
2.2 缺失值和异常值:哪些可以删,哪些必须留
表格数据都逃不过缺失值这一关。原始训练集里,几个测光误差列缺失率大概在2%左右,不多,但也要处理。在这样一个量级下,直接删样本会损失太多,我选择的是列维度做策略区分:
- 红移字段缺失率极低,但恰好少数缺失样本多数是恒星。恒星因为距离远、大气色散等原因红移测不准很常见,所以不能直接填中位数了事,我用的是按类别分组的填充策略,先用mode大致填充,不追求精确,只求不引入系统性偏差。
- 测光误差列缺失,就填该字段的中位数。中位数比均值更抗异常值,因为这个字段本身右偏严重。
异常值处理上,我的原则是“少动刀”。太远的离群点例如星等为-999这种哨兵值,肯定是缺失编码,直接置为NaN。但如果没有哨兵值,我不会轻易删掉那些看起来很大的数值,因为天文数据真的天然存在长尾,真实物理现象可能就出现在长尾里。盲目删除,反而会让模型对极端天体的泛化能力变差。
当时我用pandas描述性统计对比过训练集和测试集的均值、方差,发现整体分布非常接近,说明平台切分数据时基本是按均匀方式抽样的,这让我对本地划分数据的可靠性增加了一点信心。不过,在分布校验时也要关注坐标字段是否覆盖同一片天区,如果训练集和测试集的天区没有重叠,那么基于坐标的特征就尽量不要做,否则很容易引入偏移。
2.3 类别不均衡:别一上来就采样
标签分布我看了下:恒星约61%,星系约30%,类星体约9%。很多人第一反应是“类星体太少,得处理一下”。但我的建议是,先不要急着做任何采样操作,先跑一个真实的基线模型看混淆矩阵,再决定有没有必要。
原因是什么呢?LightGBM这类基于叶子的树模型天生对类别权重敏感,你可以在模型里直接加类别权重,或者用scale_pos_weight控制少数类的梯度。本质上这和采样是同一个目的,但实现更精细。而且你如果用SMOTE造出一堆假类星体,万一合成样本落在物理上不可能的参数空间里,模型学到的边界是失真。
我在这次项目中,初期就没有做采样,而是先用LightGBM跑默认参数看基线F1。事实证明,类星体的召回率虽然偏低,但还没到不可救药的地步。后来我调整了类别权重,再配合概率校准和阈值搜索,类星体F1从0.61一路涨到了0.84左右,这个幅度已经非常可观。
3. 特征工程:从“天文直觉”里挖金子
3.1 颜色指数:五波段测光数据最经典的特征
如果你去问一个做星系分类的天文学家,他大概率会跟你聊聊“颜色-星等图”。在天文领域,通过不同波段星等的差值来构造颜色指数,是最基本也最有效的分析手段。从机器学习视角看,这其实就是一种特征组合方法,只不过这个组合不是随机做的,而是有物理意义的。
定义非常简单:u-g = u波段的星等减去g波段的星等,g-r、r-i、i-z同理。这里我构造了四个颜色指数。为什么这么做?因为单个星等的绝对值受距离影响很大,同样一颗恒星,放近一点就亮一点,放到远处就暗一点,单看一个波段亮度根本没法分类。而颜色指数做的是差值,距离的影响在相减中抵消了,留下的是天体自身的物理颜色信息——这才是判别天体类型的好特征。
实际可视化时,我用g-r做横轴、u-g做纵轴画散点图,发现恒星基本聚在一个斜条带上,星系分布在一个宽泛区域内,类星体则集中在蓝端。这说明颜色指数确实能捕捉到不同类别的核心差异,比直接用原始星等列训练效果显著提升。
为了检验这个判断,我做过一个对比实验:只用原始五个波段的星等训练LightGBM,验证集macro-F1大约0.89;加入四个颜色指数后直接提升到0.915。这接近2.5个百分点的涨幅,几乎不花什么成本,所以我后来做特征工程更加有信心了。
3.2 红移的“魔法”:对数变换和交互项
红移这个字段是这次比赛最大的宝藏。道理很直白:类星体的红移普遍远大于恒星,光凭这一列,你就能把大部分类星体从剩下两类里挑出来。但它的数值分布极其长尾,最大能到5以上,而恒星的红移基本都在0.1附近。直接把原始值丢给线性模型,少数极端值会主导梯度更新,模型很容易被带偏。
树模型虽然不需要做严格归一化,但对数变换依然有价值。它能把长尾压缩,让模型在3到5的红移区间也有机会细分出更多模式,而不是简单地在某个大阈值上切一刀。我构造了log1p(redshift)这一列,虽然涨幅不如颜色指数那么夸张,但验证集F1确实又前进了一小步。
同时,我还尝试了红移和颜色指数的交互项,比如redshift * (u-g),以及红移的平方项。这些特征背后的想法是:类星体在不同红移区间,颜色特性会有系统性变化,加了交互项后,树模型更容易在某一个红移区间内学到专属的颜色界限。实测下来,一两个交互项有微小增益,加多了反而过拟合。所以我的原则是:交互特征适可而止,每加一个都要在交叉验证里看到收益才保留。
3.3 归一化:不是所有模型都需要,但你最好还是做
关于归一化,我印象最深的教训是:树模型不需要,但任何会用到距离度量的方法必然需要。如果你计划做KNN、逻辑回归、SVM,或者最终概率融合时使用MLP,那么归一化逃不掉。这次项目里,我最后还是加了MLP做融合,所以一开始就用StandardScaler把特征全部标准化了。
但有一个细节很多人会忽略:标准化只应该fit在训练集上,再把同样的均值和标准差应用到测试集上。如果你图省事,把训练集和测试集拼在一起先fit,再切回去,这就是信息泄漏了。这种泄漏在本地验证集上可能看不出来,线上反而会崩,后面我会在踩坑章节专门展开。
此外,如果某个特征严重右偏,先做log变换再做标准化,效果会更好。红移就是一个典型例子,log之后分布更接近正态,不管是对树模型还是后续融合模型都更友好。我最终的标准化特征池里,包含了原始星等、颜色指数、log红移和部分误差项,大约二十几维,不多,但每个都是能说出物理含义的。
4. 建模调参:从0.85到0.93的进阶之路
4.1 一份干净的划分方式,比模型更重要
调参前必须先确定验证策略,否则后面所有操作都等于白费。这里我用的方法是:分层采样,按标签类别比例划分,训练集80%,验证集20%。为避免随机种子带来的偶然性,我跑了一个5折交叉验证脚本,每次都记录每折的macro-F1和标准差,最终以5折均值作为模型效果的评估标准。
为什么要强调“分层”?因为如果随机划分,类星体占比本来就少,万一验证集里分到的类星体更少,你看到的F1就偏乐观,模型实际泛化能力可能没那么好。stratify=y这个参数虽然只是一句话的事,但它能保证每个折里三类比例都接近总体分布,这样本地分数和线上分数相关性更高。
另外,我特意避免在验证集上做太多早停。LightGBM的early_stopping是在训练过程中监控验证集来确定最优迭代次数,用的依然是验证集信息。如果你反复用同一个验证集调参、调早停,最终本地分数会被严重乐观估计。我们会在每个方案上只做一次早停,然后以交叉验证结果为准。这不严谨,但竞赛中基本都这么干。
4.2 基线模型对比:别让默认参数骗了你
我第一个模型跑的是逻辑回归,图它快、稳、逻辑清晰。但在归一化后的特征上,LR只有0.87的F1,短板在于类星体召回率低,只有0.55左右。原因是线性模型无法很好捕捉多个特征之间的非线性关系,颜色指数和红移的交互只能靠特征工程来弥补,已经补了,但依然不够。
接着是随机森林,默认参数下F1约0.89,比LR略好。但随机森林对噪声和异常值敏感,在个别折里波动大,标准差0.012,明显偏高。然后我上了LightGBM,默认参数直接到0.905,标准差0.006,无论效果还是稳定性都明显优于前两者。XGBoost成绩和LightGBM接近,但训练速度慢了一截。
最终我把LightGBM作为主模型,XGBoost作为辅助,MLP作为融合组件。这是一个典型的“树模型为主、深度学习为辅”的配置。MLP单飞成绩并不高,0.89左右,但它与树模型的相关性低,融合后能带来额外提升。
4.3 LightGBM调参:顺序比参数重要
LightGBM调参最容易犯的错误是同时调整七八个超参,结果一团乱麻。我的调参路线是分阶段进行的:
第一阶段,确定学习率和树数量。先固定learning_rate=0.05,num_leaves=31,max_depth=-1,然后让早停在200轮内找最优树数量。实测大概在600棵左右收敛,验证集F1约0.908。
第二阶段,调结构参数。num_leaves从16扫到64,步长8,发现32附近最优,太小欠拟合,太大过拟合。这里的逻辑是:num_leaves控制了树的复杂度,叶子太少学不到复杂边界,叶子太多则容易死记训练集噪声。
第三阶段,调样本采样和特征采样。feature_fraction设0.8,bagging_fraction设0.8,bagging_freq=1,验证集F1提升到0.915左右,并且方差有所下降。树模型引入随机性后,泛化能力通常会有小幅提升。
第四阶段,调正则化。lambda_l1、lambda_l2在小范围内尝试,最终取lambda_l2=1.0。正则化在样本量只有几万时尤其重要,它能压制过拟合,让验证集和测试集分数更接近。
整个调参过程下来,我的LightGBM-5折平均F1从0.908涨到了0.924。不算巨大,但对于竞赛来说,每一分都值得。
4.4 模型融合:为什么多个模型投票能再涨一点
单模型F1到了0.924以后,我尝试了模型融合。常用的方案有两种:硬投票和软投票。硬投票就是每个模型预测一个类别,少数服从多数;软投票则是每个模型输出各类别预测概率,然后取平均或加权平均,最后取最大值对应的类别。
我采用的是软投票加权平均。原因是硬投票会丢失概率信息,而类星体在多数模型里的概率分布并不极端,有时候只有零点五几,直接投票等于把信息二值化,浪费了细节。加权平均时,LightGBM和XGBoost的权重各0.35,MLP权重0.3,我是用验证集网格搜索得到的这个权重组合。
融合之后,5折平均macro-F1到了0.931,标准差0.004。提升幅度接近0.7个百分点,主要贡献来自类星体的召回率,因为不同模型在少数类上的错误模式不一样,概率平均后,原本分开踩错的样本总有一部分被拉回来。
5. 踩坑实录:那些让分数原地踏步的坑
5.1 红移没做变换,差点漏掉一个亿
我第一次跑LR时,红移直接用了原始值,类星体召回率0.48,惨不忍睹。那时我还没意识到长尾分布对模型有多大影响。后来对红移做log变换后,LR类星体召回率直接跳到0.66。这个坑让我明白了:再好的特征工程,也要配合合适的数值变换才能发挥效果。对于右偏严重、跨度大、存在少量极端值的特征,先log变换再建模,通常是最保险的选择。
5.2 用全体数据做标准化,当场翻车
这个坑最隐蔽。当时调MLP时,我图方便,把训练集和测试集拼到一起,fit了StandardScaler再拆开,本地验证F1看着特别高,因为标准化时已经“偷看”了测试集的分布信息。后来线上评分一出来,比本地少了一大截,才反应过来。正确的做法是先拆分,再fit,再transform,测试集的分布信息只能通过训练集近似,绝对不能直接用。
5.3 用准确率过早停了,少数类被牺牲
LightGBM早停有一个坑,默认的metric是multi_logloss,不是macroF1。如果你早停只看logloss,模型最终阶段可能为了降低整体损失而牺牲少数类,导致宏平均F1不升反降。我后来设置metric='macro_f1',早停效果立刻不同。这个操作很多人完全没意识到,但它对竞赛结果的影响非常大。
5.4 融合不是万灵药,无脑融合反而掉分
我试过一次把五个模型全部纳入软投票,包括两个效果一般的模型,结果F1反而比只用三个好模型更低。原因很简单:差模型带了更多噪声,在平均概率时把好模型的判断稀释了。融合的前提是每个成员模型本身质量过关,相关性越低越好,而不是数量越多越好。后来我写了一个简单的选模逻辑:先看单个模型的验证F1,低于0.90的一律不参与融合。
下面把实战中遇到频率最高的问题整理成一个简单的速查表:
| 问题表现 | 排查方向 | 建议解法 |
|---|---|---|
| 验证集F1高,线上F1明显低 | 检查是否在所有预处理中泄漏了测试集信息 | 统一用Pipeline封装,确保fit只基于训练集 |
| 类星体召回率长期偏低 | 评价指标可能是宏平均F1 | 调节类别权重,或者按验证集阈值搜索后处理 |
| 模型在5折间方差大 | 特征不够稳,或训练数据量太小 | 增强特征稳健性,使用bagging_fraction增加随机性 |
| 调参调了半天没提升 | 可能停留在同一超参空间 | 先扩大搜索范围,再回看特征工程是否有遗漏 |
| 融合后分数反而下降 | 掺入了弱模型或强相关模型 | 只保留F1高于阈值的模型,并检查模型间的相关性 |
6. 可复现的代码骨架:从数据到提交一步到位
文章最后,我把整个流程的核心代码给出来。这套骨架你直接换数据就能用,重点关注几个关键点:分层划分、Pipeline封装、LightGBM早停metric,以及融合部分的权重搜索。
import pandas as pd import numpy as np from sklearn.model_selection import StratifiedKFold from sklearn.preprocessing import StandardScaler from sklearn.pipeline import Pipeline from lightgbm import LGBMClassifier from sklearn.metrics import f1_score, classification_report train = pd.read_csv('train.csv') test = pd.read_csv('test.csv') y = train['label'] train = train.drop(columns=['label']) # 特征工程:颜色指数 + 红移变换 for df in (train, test): df['u-g'] = df['u'] - df['g'] df['g-r'] = df['g'] - df['r'] df['r-i'] = df['r'] - df['i'] df['i-z'] = df['i'] - df['z'] df['log_redshift'] = np.log1p(df['redshift']) features = [c for c in train.columns if c not in ['objid', 'ra', 'dec']] # 分层5折交叉验证 skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) oof = np.zeros((len(train), 3)) test_pred = np.zeros((len(test), 3)) for fold, (idx_tr, idx_va) in enumerate(skf.split(train, y)): X_tr, X_va = train.iloc[idx_tr][features], train.iloc[idx_va][features] y_tr, y_va = y.iloc[idx_tr], y.iloc[idx_va] # 注意:标准化只fit训练折 scaler = StandardScaler() X_tr = scaler.fit_transform(X_tr) X_va = scaler.transform(X_va) model = LGBMClassifier( objective='multiclass', num_class=3, learning_rate=0.05, n_estimators=1000, num_leaves=32, feature_fraction=0.8, bagging_fraction=0.8, bagging_freq=1, lambda_l2=1.0, metric='macro_f1', random_state=42 ) model.fit(X_tr, y_tr, eval_set=[(X_va, y_va)], early_stopping_rounds=50) oof[idx_va] = model.predict_proba(X_va) test_pred += model.predict_proba(scaler.transform(test[features])) / 5 # 搜索阈值,让宏平均F1最大化 best_f1 = 0 best_thr = 0.5 for thr in np.arange(0.3, 0.7, 0.01): pred = np.argmax(oof, axis=1) f1 = f1_score(y, pred, average='macro') if f1 > best_f1: best_f1 = f1 best_thr = thr final_pred = np.argmax(test_pred, axis=1)这套代码省略了融合部分,但主流程足够跑通一个不错的基线。实际比赛中,我会再把MLP概率和XGBoost概率叠加进去,权重用验证集网格搜索确定。
7. 最后再分享一点个人心得
这个赛题打下来,我最大的感受是:结构化赛题到最后拼的往往不是模型复杂度,而是对数据和业务语义的理解深度。天文学家几百年前就意识到颜色能区分天体类型,你把颜色指数构造出来,模型的效果立刻上一个台阶——这就是领域知识对机器学习的杠杆作用。如果只盯着调参工具,永远只能跟在特征工程认真做的人后面。
另外我建议,处理类似表格赛题时,可以多画一些二维分布图。别只盯着机器学习的损失曲线看,把不同类别的样本在特征空间里的分布用散点图画出来,你会找到很多灵感。我当时把g-r和u-g的密度图叠加后,发现类星体的分布集中在一个非常小的蓝色区域,顺着这个方向做了密度估计特征,意外地又涨了零点几个点,这个细节是我这次实战最满意的收获。