ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于机器学习的机票价格预测:从特征工程到Stacking融合实战

基于机器学习的机票价格预测:从特征工程到Stacking融合实战

简介:基于机器学习的机票价格预测研究,是一份围绕民航票价动态变化问题的毕业论文与课程论文参考文档,适合在校学生、毕业生及机器学习初学者研读。论文系统梳理了价格预测研究的完整流程:通过中国航信API与分布式爬虫从OTA平台采集全年主要机场经济舱票价数据,完成格式统一与缺失值处理;围绕季节性、节假日、竞争航司策略等因素构建特征工程;进而采用随机森林与XGBoost算法搭建预测模型,并结合五折交叉验证与MAE指标评估效果,最后对经典模型进行改进实验,实验表明改进后的模型预测效果进一步增强。资源包为1个docx文档,大小约78KB,内容涵盖摘要、引言、数据准备、特征工程、模型训练与融合评估等完整章节。已有113人学习/下载,适合作为毕业设计、课程设计的方法论参考,也可为类似回归预测课题提供写作思路与实验设计依据,对理解数据质量、业务认知与模型选择之间的关联很有帮助。

1. 机票价格预测:这个毕设选题为什么值得照着重做一遍

做机票价格预测,最容易翻车的不是模型选型,而是你根本凑不齐论文里那个数据规模。机票价格预测这个课题看着不算新鲜,但热度一直不减——从毕设选题到 OTA 算法岗的实战问题,都绕不开这个典型的机器学习回归任务。这份《基于机器学习的机票价格预测研究》论文资源,发表在 2020 年《现代计算机》上,作者本身就是在线旅行行业从业者,用 92 个主要机场、2019 全年、每航班未来 60 天的经济舱价格数据,完整走完了数据获取、特征工程、随机森林与 XGBoost 建模、Stacking 融合的闭环。它适合两类人:一类是正在写毕业设计或课程论文、需要一篇规范论文做结构和思路参照的学生;另一类是想入行价格预测、想看看一线业务人员怎么把业务理解变成模型特征的工程师。需要提醒的是,这份资源是论文文档而非源码包,抱着“下载就能跑”的心态会失望,但把论文里的实验链路拆出来,自己完全能复现。

2. 数据获取与聚合:航信 API 与 OTA 爬虫两条数据线怎么落地

2.1 两条数据来源:航信 API 与 OTA 爬虫,准确性和成本的权衡

论文里把数据来源讲得很直白:一是机票销售的业务数据,通过中国航信的 API 接口查询获得,数据准确但价格高;二是竞争对手的价格监控数据,通过爬虫从各大 OTA 页面抓取,数据不完全可靠但成本低。这个取舍几乎是所有价格预测项目都要做的选择题,没有“既要又要”的选项。

对比项航信 APIOTA 爬虫
数据准确性高,官方接口,接近真实成交口径中,页面解析可能出现缺字段或错位
获取成本按查询次数计费,价格高服务器、带宽与人力维护成本
覆盖范围自己渠道的销售记录全网公开可查的在售价格
维护难度低,接口稳定高,需要持续应对页面改版和反爬

论文的解决思路是两条线同时跑:航信 API 作为准确数据底座,OTA 爬虫负责补全竞对价格和公开渠道价格。这个设计背后有一个业务细节值得注意——国内定期航班超过 4000 个,全量抓取既不现实也没必要,论文只选了年旅客吞吐量超过 100 万人的 92 个主要机场,把主要机场之间的航班纳入分析范围,提取 2019 年全年每天每个航班未来 60 天的经济舱价格。这个“92 个机场 + 60 天窗口”的选择,本质上是覆盖度与数据量的折中,也是国内机票价格预测研究中比较有代表性的一份数据范围设定。

2.2 爬虫的三个门槛:IP 限制、动态页面与验证码,应对套路是什么

论文专门列了一节讲爬虫的关键技术,说明真实业务里的数据采集远不是写个 requests 循环那么简单。归纳下来是三个门槛:

一是 IP 限制。单 IP 大量查询很容易触发反爬虫机制,论文给的方案是分布式爬虫,部署在拨号 VPS 上,每次拨号换一个 IP,把请求分散到多个节点。这个思路在爬虫工程里属于常规操作,关键点是“请求频率要分摊到足够多的 IP 上”,而不是单纯加延时。

二是动态页面。现在 OTA 站点大量使用前后端分离和浏览器渲染,纯后端爬虫拿不到渲染后的价格数据。论文的方案是使用 Headless Browser 或浏览器插件。实操中常见选择是 Playwright 或 Puppeteer,用无头浏览器加载页面后等待价格节点渲染完成再抓取,代价是单请求耗时明显变长,并发规模要相应调低。

三是验证码。请求量一大就会触发机器人检测,论文很务实地建议,为这种任务做自动图像识别得不偿失,人工打码是更简单有效的方法。这个判断在业务场景里是成立的——验证码拦截的是“量”,不是“来源”,只要把触发频率控制在人工打码能跟上的水平,项目就能跑下去。

这三个门槛对应的是同一件事:数据采集的稳定性决定了后面建模的数据质量。爬虫挂了三天没人发现,特征工程做得再好,模型也救不回来。

2.3 数据聚合:把散落在多库多表的价格快照归集成一张宽表

论文里提到,这些数据在公司日常业务进行的过程中已经存入了业务数据库,只是散落在多个数据库与表中,研究开始前需要写脚本归集到一个表里。这个环节在论文里只有一小段,但实际占掉的项目时间往往最多。我按常见业务库结构补一个最小可用的聚合脚本,思路与论文描述一致:

import pandas as pd from sqlalchemy import create_engine # 业务库:销售快照;爬虫库:OTA 价格监控 engine_biz = create_engine('mysql+pymysql://user:pass@host:3306/flight_biz') engine_crawl = create_engine('mysql+pymysql://user:pass@host:3306/flight_crawl') df_biz = pd.read_sql('SELECT * FROM price_snapshot', engine_biz) df_crawl = pd.read_sql('SELECT * FROM ota_price_monitor', engine_crawl) # 两张表字段名不一致,先统一命名 df_crawl = df_crawl.rename(columns={ 'query_time': 'snapshot_ts', 'flight_date': 'dep_date', }) # 以业务库为主表,爬虫数据做补充 df = df_biz.merge( df_crawl, on=['route', 'flight_no', 'dep_date', 'snapshot_ts'], how='left', suffixes=('_biz', '_ota') ) # 业务库缺失的价格,用同航班同渠道最近一次快照回填 df['price'] = df['price_biz'].fillna(df['price_ota']) df = df.sort_values('snapshot_ts').groupby( ['route', 'flight_no', 'dep_date'], as_index=False ).fillna(method='ffill') print(df.shape, df['price'].isna().sum())

这段代码的逻辑说明:merge 的四个 key 里,route 和 flight_no 确定航班身份,dep_date 确定起飞日期,snapshot_ts 确定是哪一天哪一次查询产生的快照,四个字段合起来才唯一对应一条价格记录,少任何一个都可能在后续聚合时产生重复或错配。回填用 ffill(前向填充)是因为价格时间序列的特性——同一个航班的最近一次快照价格,是当前缺失时刻最合理的估计值,这比用全航线均值填充要准得多。

参数层面有几个值得留意的点:suffixes必须显式指定,否则两个来源的 price 字段会在 merge 后变成 price_biz 和 price_ota,不指定的话 pandas 会自动加后缀但列名不可控;fillna(method='ffill')在 groupby 场景下是逐组回填的,能避免跨航班串数据;最后打印的isna().sum()是给数据质量兜底的,如果回填后仍有缺失,说明爬虫侧有整段数据没抓到,需要回到采集环节排查,而不是硬着头皮进特征工程。论文里写的“关键特征缺失的数据已经被排除在外”,对应的就是这个检查动作。

3. 特征工程:把机票业务逻辑翻译成模型输入,附完整预处理流程

3.1 特征选择的业务逻辑:供应端稳定,需求端才是波动源

论文在特征工程章节给了一个很重要的方法论:这部分更多是从业务理解出发,而不是从数据分析出发,开始就已经把想要的特征考虑好,然后去获取这些数据。也就是说,先想清楚价格由什么决定,再去找数据验证,而不是拿到数据先跑相关性矩阵。

民航市场化程度很高,机票价格最终由供求关系决定。供应端的主要因素包括航线航司分布、航司与代理的博弈、代理之间的竞争以及主管部分的政策影响,这些因素相对稳定,短期内不会有大的变化;需求端则完全不同,热门冷门线路、消费偏好、临时性计划性需求、重大节假日,都会让价格在短时间内剧烈波动。所以特征工程的重心放在需求端。

特征组具体特征示例数值化方式
航线基础出发/到达城市、是否千万级机场、航线历史热度标签编码或目标编码
航班属性航班号、起飞时刻、是否直飞、执飞航司独热编码
时间窗口距起飞天数、星期、月份、是否节假日及节前节后数值 + 独热编码
竞争环境同航线在售航司数、同航线最低价、近 7 天价格走势数值

这张表是论文表 1 的归纳展开,论文原文概括为热门冷门线路、消费偏好、临时性计划性因素、重大节假日四类。实操中有一个经验:竞争环境这类特征往往被学生忽略,但同航线最低价和同航线在售航司数对价格的影响非常直接,这属于“业务理解带来的一类特征”,单纯从数据统计出发不容易想到。

3.2 预处理三个动作:删天数、填异常、变换目标分布

论文的数据预处理只有三个动作,但每个动作背后都有明确理由,值得逐条拆开看。

第一个是删天数。通过均值分析发现 daysToFlight 大于 45 后对价格影响很小,所以删除 daysToFlight > 45 的数据。这个操作的含义是:距离起飞超过 45 天时航班还远未进入销售主力期,价格与最终成交价的相关性弱,留着反而会稀释样本质量。

第二个是处理异常价格。先通过查看频数确定正常边界,用边界值填充异常价格。这里强调的是“用边界值填充”而不是删除,因为对回归任务来说,删除异常样本会丢失这条航线的部分分布信息,而用边界值截断则保留了样本的位置信息。

第三个是目标变量变换。价格不服从正态分布,回归之前必须做变换。论文明确说尝试了对数变换和无界约翰逊分布拟合,无界约翰逊分布拟合更好。但有意思的是,论文后面训练代码里目标变量叫train_y_ln,说明实际落地用的是对数变换。这个矛盾我在第 5 章还会专门展开,复现时建议两种变换都跑一遍对比。

3.3 预处理代码:过滤、截断、对数变换一组完成

import numpy as np import pandas as pd from scipy.stats import johnsonsu # 过滤距起飞超过 45 天的快照 df = df[df['days_to_flight'] <= 45].copy() # 按航线和舱位统计价格频数分布,取 0.5% 和 99.5% 分位作为边界 def price_bounds(group): lo, hi = group['price'].quantile([0.005, 0.995]) return pd.Series({'price_lo': lo, 'price_hi': hi}) bounds = df.groupby(['route', 'cabin']).apply(price_bounds) df = df.merge(bounds, on=['route', 'cabin']) # 边界值填充,而不是直接删行 df.loc[df['price'] < df['price_lo'], 'price'] = df['price_lo'] df.loc[df['price'] > df['price_hi'], 'price'] = df['price_hi'] # 目标变量对数变换,log1p 避免价格 0 值时出现负无穷 df['price_ln'] = np.log1p(df['price']) # 论文提到的备选方案:无界约翰逊分布拟合 # s, loc, scale = johnsonsu.fit(df['price']) # df['price_johnson'] = johnsonsu.cdf(df['price'], s, loc, scale) df = df.drop(columns=['price_lo', 'price_hi'])

这段代码的逻辑说明:异常值截断的分位点选 0.5% 和 99.5% 而不是 1% 和 99%,是因为价格数据右尾偏长,用更靠边的分位点能保留更多真实的高价样本,这些高价样本往往对应节假日或特殊航线,恰恰是预测中最难也最有价值的部分。目标变量用np.log1p而不是np.log,是因为 log1p 在价格接近 0 时更稳定,后续预测还原时用np.expm1即可逆。

参数层面需要留意:边界统计按route + cabin分组,而不是全样本统一边界——头等舱和两舱价格量级差异很大,统一边界会把低价舱位的正常波动误杀;johnsonsu 拟合那段代码论文没有给出具体参数,注释里只是保留尝试入口,实际操作时建议对比 Johnson 变换后的训练集验证集 MAE 再决定用哪种。

4. 模型训练与融合:随机森林、XGBoost 和 Stacking 的完整链路

4.1 为什么是集成模型:bagging 与 boosting 在票价场景的差异

论文选择了集成学习算法,并把集成方式分为两类来讲。

Bagging 独立训练多个基学习器,基学习器相互独立、可并行训练,最后用平均法投票法或 stacking 方式集成。代表算法是随机森林,森林里的每棵树都是随机的,预测结果取 N 棵树的平均,保证模型不会过度依赖某些特征。随机森林对机票价格这类高维稀疏特征场景比较友好,不容易过拟合,训练速度也可控。

Boosting 串行训练基学习器,基学习器之间相互依赖,每训练完一个就调整样本权重,再训练下一个。代表算法是 XGBoost,它在数值优化上做得更充分,对缺失值有内置处理策略,Kaggle 上大量 TOP 方案都用它。论文提到 Kaggle 的 TOP 算法一半以上使用 XGBoost,这个判断在近年依然成立。

选型逻辑不难理解:随机森林适合作为稳定基线,XGBoost 负责在基线之上压榨精度,两者结构差异大,为后面的 Stacking 融合提供了多样性。

4.2 训练与评估:五折交叉验证加 MAE 的代码实现

论文的评价标准是 MAE(Mean Absolute Error),也就是预测价格与实际价格的绝对误差均值。相比 RMSE,MAE 对离群值没那么敏感,更贴近业务上“买贵了多少”的直观感受。训练使用经典五折交叉验证,多轮验证来减小过拟合。

from sklearn.ensemble import RandomForestRegressor from sklearn.metrics import make_scorer, mean_absolute_error from sklearn.model_selection import cross_val_score from xgboost.sklearn import XGBRegressor import pandas as pd # 特征列与目标列:train_y_ln 是对数变换后的价格 feature_cols = [c for c in train_df.columns if c not in ['price', 'price_ln']] train_X = train_df[feature_cols] train_y_ln = train_df['price_ln'] models = [ RandomForestRegressor(n_estimators=200, max_depth=12, n_jobs=-1), XGBRegressor(n_estimators=100, max_depth=6, objective='reg:squarederror'), ] result = {} for model in models: name = str(model).split('(')[0] scores = cross_val_score( model, train_X, train_y_ln, cv=5, scoring=make_scorer(mean_absolute_error), verbose=0 ) result[name] = scores print(f'{name} finished')

这段代码从论文原代码补全而来,逻辑说明:交叉验证把训练集切成 5 份,每轮用 4 份训练、1 份验证,循环 5 次,每个模型都拿到 5 个独立验证分数,比单次 train_test_split 的结论稳得多。目标变量用的是train_y_ln,也就是对数变换后的价格,这意味着所有模型输出的 MAE 都发生在 log 空间,看分数时要意识到数值量级和真实价格误差不是直接对应的,论文表格里也没有明确说明这一点,复现时要自己注意。

参数层面的说明:随机森林n_estimators=200、max_depth=12是论文场景下的合理默认值,max_depth设置 12 层是为了控制单棵树复杂度,避免深度过大把训练集的噪声也学进去;XGBoost 的objective='reg:squarederror'是对应回归任务的损失函数,如果用的 XGBoost 版本较老,默认 objective 可能是 reg:linear,会打印警告但结果差异不大。还有一个隐藏细节——cross_val_score配合make_scorer(mean_absolute_error)时,sklearn 默认返回的是负 MAE(越大越好),打印出来的分数是负数,实际 MAE 要取绝对值。论文原代码没有处理这一步,很多人第一次跑的时候会被负分吓到。

4.3 Stacking 融合:用初级学习器的预测训练次级学习器

Stacking 是 Kaggle 比赛里最常用的融合手段。论文描述的流程很标准:先把训练集拆成 m 份,每次用 m-1 份训练初级模型、预测剩余 1 份,同时用同样的 m-1 份数据训练模型后在真正测试集上预测;重复 m 次后,把训练集上的 m 次预测叠加成一列,测试集上的 m 次预测取均值融合成一列;多个初级模型都这样做之后,用这些预测结果作为特征,训练次级学习器。

用 sklearn 实现这个流程,最直接的方式是 StackingRegressor:

from sklearn.ensemble import StackingRegressor from sklearn.linear_model import LinearRegression stack_model = StackingRegressor( estimators=[ ('rf', RandomForestRegressor(n_estimators=200, max_depth=12, n_jobs=-1)), ('xgb', XGBRegressor(n_estimators=100, max_depth=6, objective='reg:squarederror')) ], final_estimator=LinearRegression(), # 次级学习器 cv=5, # 内部同样做 5 折 passthrough=False, # 只把初级预测作为次级特征 n_jobs=-1 ) stack_model.fit(train_X, train_y_ln)

逻辑说明:cv=5让 StackingRegressor 内部按照论文描述的 m 折流程生成初级模型的 out-of-fold 预测,避免初级模型用全量训练集拟合后直接预测训练集导致的数据泄漏;passthrough=False对应论文里“使用第一阶段的预测作为特征训练次级学习器”的描述,次级学习器只拿到随机森林和 XGBoost 各自预测的两列;final_estimator用线性回归是因为价格预测的次级学习器需要的是把两个初级预测做加权组合,线性模型在这里足够且不容易过拟合。

参数层面的关键提醒:如果passthrough=True,次级学习器会额外拿到全部原始特征,特征维度从 2 列变成几十列,这时用线性回归容易欠拟合,需要换成带正则的模型或者干脆保持 False;如果final_estimator用 XGBoost,则与初级模型同质化,融合多样性下降,实践中提升很有限。论文原文没有给出 Stacking 的具体代码,只有流程描述和效果图,这个实现是按论文流程用 sklearn 补的。

4.4 融合效果:MAE 数字对比,以及论文没写清的细节

论文给出的数字是 Stacking-LR 训练集 MAE 33.40,验证集 MAE 36.67。全样本均价 712,验证集误差 36.67 意味着平均预测偏差约为票价的 5.2%。论文还提到融合后相较融合前又有提高,并以成都-广州某航班 1 月到 9 月起飞前 7 天的预测效果对比图作为证据。

模型训练集 MAE验证集 MAE
随机森林论文未公布论文未公布
XGBoost论文未公布论文未公布
Stacking(RF+XGB → LR)33.4036.67

这张表值得玩味:论文只公布了融合模型的数字,单模型数字完全没给。这意味着如果你复现时只跑随机森林,拿到 40 左右的 MAE,会误以为自己翻车了,实际上这恰恰说明融合确实带来了提升。论文没有展开讲的部分还有三个:MAE 是在 log 空间算的还是还原后的真实价格空间算的、预测目标具体是起飞前多少天的价格、以及特征列最终保留了多少维。这三个问题在第 5 章会进一步展开。

5. 避坑与排查:复现这份论文最容易翻车的五个位置

5.1 目标变量变换:论文正文与训练代码自相矛盾

现象:论文文字说无界约翰逊分布拟合得更好,但训练代码里的目标变量叫train_y_ln,明显是对数变换后的结果。

原因:这属于论文写作过程中的改动遗漏,正文描述的是探索阶段对比实验的结论,代码则是最终实验使用的版本。两者共存说明作者在探索阶段确实对比过两种变换,但最终落地选择了对数变换,正文没有同步修改。

解决:复现时两种都跑一遍。具体做法是把价格分别做np.log1p和johnsonsu.cdf变换,各训练一次模型,看验证集 MAE 的差异。不要默认“正文写了约翰逊就只用约翰逊”,也不要默认“代码用了 ln 就只跑 ln”,这个对比本身就是论文里最有价值的实验之一。

5.2 days_to_flight 过滤后的预测空窗

现象:论文删除了 daysToFlight > 45 的数据,训练集里没有距起飞 45 天以上的样本。但产品上线后,用户提前 60 天查票时,模型给出的是超出训练分布的预测,表现明显变差。

原因:这个过滤是训练前的数据裁剪,不是模型的内在逻辑。模型只在 0-45 天窗口内见过价格模式,对窗口外的输入只能外推,而树模型基本不具备外推能力,遇到没见过的天数区间时预测会退化到训练集均值附近。

解决:两条路任选。产品上把预测窗口裁剪到 45 天以内,超过 45 天的查询统一返回“建议临近再查”;模型上则保留全部天数样本,但把 days_to_flight 单独作为强特征,让模型自己学习价格随距离变化的曲线。第二种方案更符合论文想表达的“价格形成机制”,但需要更多数据支撑。

5.3 sklearn 的 StackingRegressor 默认丢掉了原始特征

现象:用 sklearn 的 StackingRegressor 复现融合模型,发现结果和论文的 36.67 对不上,次级学习器只有两列特征,模型提升幅度很小。

原因:passthrough=False是默认值,次级学习器只能看到初级模型的预测输出。论文描述的流程确实是“用第一阶段预测作为特征”,所以这个默认值在逻辑上没有错,但实际融合效果高度依赖初级模型的质量。当随机森林和 XGBoost 的预测相关性较高时,两列特征的信息量不足,线性回归很难学到有效组合。

解决:把passthrough=True跑一组对比,让次级学习器同时看到原始特征和初级预测。如果验证集 MAE 下降,说明初级模型之间的互补信息不足,需要原始特征补充;如果反而上升,说明特征维度过大导致线性模型欠拟合,这时可以换带 L2 正则的 Ridge 作为最终估计器。

5.4 测试集 MAE 36,线上 50,差距从哪来

现象:论文结语明确说,模型应用到实际场景后,预测效果虽不及测试集,但 MAE 仍能控制在 50 以内。36 到 50,误差放大了近 40%。

原因:测试集是历史数据,和训练集同分布;线上预测的是未来,特征分布已经发生偏移。节假日临时调价、航司运价政策变化、突发天气导致航班取消,这些事件在历史数据里没有对应样本,模型无法预知。另一个原因是注意安全:测试集的真实价格是已经发生的成交价,而线上做预测时未来价格尚未发生,模型本质上是在预测一个还没定下来的数字。

解决:把模型改成滚动重训,每周用最新快照数据重训一次,让模型始终跟随近期价格水平;同时监控特征分布漂移,比如 days_to_flight 的平均值突然变化,说明采集链路出了问题;对节假日等特殊日期,可以单独在预测结果上加一个扰动上界,避免模型给出过于乐观的价格。

5.5 单模型数字没有公布,别拿融合结果对标自己的复现

现象:论文只给了 Stacking-LR 的训练集 MAE 33.40、验证集 MAE 36.67,随机森林和 XGBoost 各自的分数完全没有。复现时只跑单模型,看到验证集 MAE 在 40 以上,以为自己哪里有 bug。

原因:不是你有 bug,是论文没有公布单模型数字。从融合结果倒推,单模型 MAE 大概率在 40 左右,融合提升了大约 10%,这在集成学习里是正常幅度。但如果没有这个参照,很容易在单模型阶段就停下来调参,浪费大量时间。

解决:复盘时把每个模型的分数都记录完整,单模型、融合模型、训练集、验证集分开列。这个自己的基准表比论文里的数字重要得多——数据范围和特征不完全一致,跨论文对标没有意义,同条件对比才是判断改进是否有效的标准。

6. 从预测到产品:把回归结果改造成买贵预警的两个小技巧

6.1 最小复现路径:没有航信 API 时怎么凑齐数据

学生和独立开发者拿不到航信 API,这是复现这份论文最大的现实障碍。最小可行方案是用公开 OTA 页面做定时抓取,数据范围缩小到 3 到 5 条热门航线,比如京沪、京广、沪蓉,每天抓一次未来 60 天的经济舱价格,连续积累三个月以上,覆盖一个完整的节假日周期。这个数据规模比论文的 92 个机场小很多,但足以支撑一套完整的特征工程和模型训练流程,作为毕设的实验数据也说得过去。

特征集可以进一步精简:距起飞天数、起飞星期、起飞月份、是否节假日、航线历史热度、同航线最低价、同航线在售航司数。前三类直接从航班信息里算出来,后几类需要从抓取结果中聚合。不需要做复杂的 embedding 或目标编码,论文已经证明了常规特征加树模型在这个场景下够用。

6.2 买贵预警:把回归问题改造成二分类

论文结语提到“买没买贵的二元预测”,这是模型落地到产品时一个很自然的改造。回归模型输出的是预测价格,但用户真正需要的不是“未来会涨到 800 元”,而是“现在下单还是再等”。把回归输出和当前可购价比较,就能得到一个分类结果:

import numpy as np from sklearn.metrics import recall_score # 把 log 空间的预测还原为真实价格 pred_price = np.expm1(stack_pred) # 二元目标:起飞前 7 天实际票价相对当前可购价的涨幅是否超过 5% threshold = 1.05 y_binary = (actual_price_7d_before / curr_price > threshold).astype(int) # 预测侧同样做比较 pred_binary = (pred_price / curr_price > threshold).astype(int) # 重点看召回率:真正“买贵”的样本里,模型抓到了多少 print('buy-high recall:', recall_score(y_binary, pred_binary))

这段代码的逻辑说明:5% 阈值是一个业务参数,代表“涨了 5% 以上才算买贵”,可以按航线热度调整——热门航线波动大,阈值可以放宽到 10%,冷门航线价格平稳,5% 就够。评估指标选召回率而不是准确率,是因为这个场景下漏掉一个“买贵”用户比误报一个“买贵”用户的代价更大,漏掉意味着用户真的多花了钱,误报只是让用户继续观望。论文原话里“对买没买贵的二元预测仍然有很高的准确率”也印证了这个方向。

从那以后我每次拿到一份价格预测类的论文资源,都会先做一件事:把论文里所有 MAE 数字和代码变量名对一遍,把没公布的数字单独列出来,再决定要不要往下复现。文档是死的,数据是活的,只有自己重跑一遍,才算真把这份资源读透了。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进