“05_01_29”这个编号,如果不加解释,谁都会觉得像一串随手乱填的数字。但在我本地机器学习项目的实验日志里,它代表一个非常具体的节点:5月1号当天跑的第29轮实验。那天的任务是一个销量预测模型的迭代,整个流程包括数据处理、特征构造、模型训练、效果评估,中间还踩了几个不大不小的坑。
很多朋友问过我,为什么自己跑模型总是“跑完就忘”,复盘时根本想不起上次改了什么参数。多数问题的根源不是模型有多复杂,而是实验过程没有编号、没有记录、没有形成规范。今天这篇不是什么高深理论,就拿“05_01_29”当样本,把一次完整实验的来龙去脉拆开讲清楚:从实验编号怎么定,到数据处理、特征工程、模型选型,再到结果评估和排错复盘。适合刚入门机器学习、想建立规范实验流程、或者是被“复现不出结果”折磨过的同学参考。
1. 别再让实验记录烂在文件夹里:05_01_29 背后的编号与溯源
1.1 编号拆开看:一个实验 ID 到底该包含什么信息
很多人看到“05_01_29”,第一反应是“5月1号第29个版本”。日期加序号的组合确实比没有强,但严格来说,这样的命名信息量还是太少了,因为它没有回答几个关键问题:这是什么项目?跑的是什么算法?对应的数据和代码版本是什么?
我自己常用的编号规则是:项目代号 + 业务场景 + 实验序号 + 算法缩写。比如proj05_sale029_lgb,看起来长一点,但哪怕三个月后翻出来,也能一眼看出这是第5个项目里、销量预测场景下、第29轮 LightGBM 实验。像“05_01_29”这种简写,更适合作为整个实验目录的缩写别名,具体信息还是靠目录结构和配置文件去承载,不能只靠这串数字。
我的实际做法是,每次开新实验前先建一个标准文件夹,结构大概是:
experiments/ 05_01_29/ code/ config.yaml data_sample.csv metrics.json notes.md编号负责“快速定位”,文件夹内部的文件负责“完整复原”。不管编号多么简单,只要内部有配置、有代码、有结果记录,那这个实验就是可追溯的。如果只有一串编号、里面却是空空荡荡,那这个编号也救不了你。
1.2 为什么说没有编号的模型等于没练
关于这一点,我是吃过亏的。早先做模型迭代的时候,觉得直接改参数重新训练就行,文件夹里堆了一堆“最终版”“最终版2”“真的最终版”的模型文件,训练脚本也经常原地覆盖。结果有一次跑出一个效果特别好的模型,一周后想复现,怎么都找不到当时用的超参数,连数据清洗脚本是不是最新版本都分不清,只能靠记忆拼凑,最后也没能还原。
后来我痛定思痛,强制自己给每一轮实验编号,并且要求“配置文件和代码版本一起落下”。为什么要这样做?因为模型文件本身只是一堆权重,真正决定模型行为的是数据和超参数。没有记录的话,模型文件就相当于一个没有配方的神秘料理,看起来能吃,但你永远不知道第二份怎么复刻出来。
所以现在我会在每轮实验开始前先复制上一轮的完整目录,再在副本上修改。跑完以后,立刻更新notes.md,把“做的什么改动、为什么改、验证集指标是多少、有哪些异常现象”写清楚。这步操作每次只多花五分钟,却能避免后面几天的返工。
2. 从 0 到 1 搭建一次迭代实验:数据准备与基线的选择
2.1 任务背景与数据准备
回到“05_01_29”这轮实验本身。我先交代一下任务背景:要做的是一个商品销量预测模型,目标是根据历史销售记录预测未来7天每个门店、每个SKU的销量。这类需求在零售供应链里非常常见,直接关系到备货、调拨和促销计划。我手里的数据包含四个核心字段:门店编号、商品编号、销售日期、当日销量,另外还有商品价格、是否有促销、节假日标记等辅助信息。
数据量不算大,大概几十万行,但脏数据问题不少。最典型的是缺售记录:有些门店某些天没有销量,不是真没货,而是没录入或者门店没营业;如果把缺失等价于销量为0,模型会学习到虚假的规律。清洗时我会先把“存在商品主档但当天没有任何记录”的样本保留下来,通过后端的商品上架日期、门店营业状态字段判断到底是0销量还是缺失,再决定是否用0填充。
数据准备阶段还有一件事容易被忽略:日期处理的时区问题。销量数据通常按自然日统计,但如果数据来自不同区域,日期字段的时区不一致,简单按天聚合会把不同天的数据混在一起。我在那轮实验里先把日期统一成UTC+8的自然日,再按门店和商品排序,避免后续特征拼接出错。
2.2 训练集与验证集切分:时间顺序不能乱
很多刚做预测模型的人最容易犯的一个错误,就是用随机切分的方式划分训练集和验证集。对普通分类任务没问题,但对时序预测任务来说,这是致命的。原因是销量数据天然有先后依赖,如果随机打散,模型在训练阶段就可能看到“未来”的数据,验证集的指标会被严重高估,上线后真实效果立刻现出原形。
按时间顺序切分是最稳妥的做法。我会把数据按日期排序,比如前12个月作为训练集,接着的1个月作为验证集,再往后的1个月作为测试集,绝不打乱顺序。为了更稳健,我偶尔会使用带间隔的多折切分,比如训练前11个月、验证第12个月,再训练前12个月、验证第13个月,模拟模型在真实滚动预测时的表现。
切分时还要注意不能直接用train_test_split(shuffle=True)这类默认参数。类似 scikit-learn 里的TimeSeriesSplit就是为时序任务准备的,但它默认是等宽划分,不一定会贴合真实业务的预测节奏。如果业务目标是预测下一周,那验证集最好也按每个折“最后一周”的方式来切,让评估口径和线上一致。
from sklearn.model_selection import TimeSeriesSplit tscv = TimeSeriesSplit(n_splits=3) for train_index, val_index in tscv.split(df): train_df = df.iloc[train_index] val_df = df.iloc[val_index] # 在这个循环里做特征工程和训练代码虽然简单,但背后的思路很重要:验证集永远是训练集之后发生的数据,这样评估出的指标才有参考意义。
2.3 先做一个“笨但稳”的基线模型
做复杂模型之前,我会强制自己先跑一个非常简单的基线模型。这个基线不需要聪明,甚至可以用最朴素的规则,比如“预测未来7天销量 = 过去14天平均销量”或者“预测值 = 去年同期销量乘以一个季节系数”。
我在那轮实验中用的基线是最近28天销量均值,同时把每个门店的销量规模作为缩放因子。评估下来,验证集的MAE大概是13.8,看起来不低,但这条基线有两个作用:一是验证整个数据处理和评估链路是通的,数据从原始文件到最终指标没有断点;二是给后续复杂模型提供一个对照物。
很多初学者上来就调LightGBM或神经网络,结果发现训练代码和评估代码有bug,跑出的指标异常低,却找不到错在哪里。如果先跑一个规则基线,一旦后续模型的指标比基线还差,就有足够理由怀疑是特征泄漏、数据切分错误或代码bug,而不是急着调参数。
基线的实现也很快,几十行pandas代码就能搞定。我把基线预测结果保存成CSV,特征工程做错时还能拿它做交叉验证。记住一句话:基线不是为了成为最终方案,而是为了筛查流程问题。
3. 特征工程、模型训练与效果评估的完整实操
3.1 特征工程第一步:先画出“可用信息”的边界
特征工程开始前,必须先想清楚一个问题:在预测的那一天,我们手上到底能拿到哪些数据?如果用了预测日之后的信息,就叫特征泄漏,训练指标会异常好看,线上却完全不可用。
我习惯把所有候选特征分成三类:一类是“截止到昨天的历史统计”,比如过去7天、14天、28天销量;一类是“已知的未来计划”,比如未来促销排期,因为促销计划通常提前制定,所以可以安全用;还有一类是“绝对不能碰的未来值”,比如未来实际销量、未来天气实况、未来价格。
这中间有个特别容易踩坑的边界:滚动窗口统计特征。比如我要构造“过去7天平均销量”,看起来只用历史数据,但如果实现时忘记把当前日期对齐,很容易把当天的销量也包含进去,甚至用了未来几天的数据。正确做法是先按门店和商品做shift,把目标值未来值移到过去位置,再做滚动聚合。
下面这段代码是所有时序特征工程的骨架:
df = df.sort_values(["store_id", "item_id", "date"]) # 用 shift 构造滞后特征 for lag in [1, 7, 14, 28]: df[f"sales_lag_{lag}"] = ( df.groupby(["store_id", "item_id"])["sales"].shift(lag) ) # 滚动窗口特征:先 shift(1) 排除当天,再做 7 日均值 df["sales_roll_mean_7"] = ( df.groupby(["store_id", "item_id"])["sales"] .transform(lambda x: x.shift(1).rolling(7, min_periods=1).mean()) )注意第二段里的shift(1),它的作用是把序列整体往后挪一天,这样计算rolling窗口时就不会包含当天的销量。很多人漏掉这一步,把当天的目标变量放进了特征,直接造成“未来泄漏”。从实验日志回看,“05_01_29”这轮实验一开始就因此翻过车,后面会专门讲。
3.2 特征清单怎么整理:一份随手能查的表
为了不让特征越堆越乱,我会在建特征时同步维护一张特征表,记录特征名、含义、时间窗口、是否可用。比如“05_01_29”这轮实验的特征表大概长这样:
| 特征名 | 含义 | 窗口/来源 | 是否可用 |
|---|---|---|---|
| sales_lag_1 | 前一天销量 | 昨天 | 可用 |
| sales_lag_7 | 一周前销量 | 上周同日 | 可用 |
| sales_roll_mean_7 | 近7天日均销量 | shift后滚动 | 可用 |
| sales_roll_std_14 | 近14天销量波动 | shift后滚动 | 可用 |
| promo_known | 未来7天是否有已知促销 | 计划表 | 可用 |
| holiday | 当天是否节假日 | 日历 | 可用 |
| sales_future_mean | 未来7天实际销量 | 未来值 | 禁用 |
这张表的价值不只是记录,它还可以在特征重要性排序出来后帮助我们反向排查。如果某个禁用特征意外出现在数据集里,训练阶段没报错,但特征重要性会显得很不正常,有这张表就能快速定位。
3.3 为什么这次选树模型:选型背后的逻辑
完成特征构造后,下一步是选模型。“05_01_29”这轮我选用的是LightGBM,原因有几个:第一,销量预测问题里特征之间有不少非线性关系,门店规模、促销幅度、季节效应相互作用,线性模型较难处理;第二,表格数据里常有缺失值和稀疏分类变量,树模型天然能应对一些缺失,不需要做太复杂的填充;第三,训练速度要快,那轮实验特征大概有几十列,数据量几十万行,LightGBM在单机上几分钟就能跑完,方便快速试错。
当时也考虑过XGBoost和CatBoost,XGBoost效果通常也很好,但LightGBM的直方图算法在相同数据量下训练更快;CatBoost对类别特征处理方便,不过那轮实验里的门店和商品数量不多,我用label encoding就够了,于是选择LightGBM。
这不是说LightGBM永远最优,而是“当时场景下最合适”。如果你面对的是高维稀疏特征,线性模型或神经网络可能更合适;如果类别特征极多且基数极大,CatBoost可能更省心。模型选型没有银弹,关键是结合数据量、特征类型和训练效率综合判断。
3.4 模型参数设置与训练代码核心片段
用LightGBM训练要做三件事:配置参数、构造数据集、训练并早停。以那轮实验为例,配置逻辑如下:
import lightgbm as lgb params = { "objective": "regression", "metric": "l1", "learning_rate": 0.05, "num_leaves": 31, "max_depth": 6, "min_data_in_leaf": 30, "feature_fraction": 0.8, "bagging_fraction": 0.8, "bagging_freq": 1, "lambda_l1": 0.1, "lambda_l2": 0.1, "verbosity": -1, "seed": 42, } train_data = lgb.Dataset(X_train, label=y_train) val_data = lgb.Dataset(X_val, label=y_val, reference=train_data) model = lgb.train( params, train_data, num_boost_round=2000, valid_sets=[val_data], callbacks=[lgb.early_stopping(100), lgb.log_evaluation(100)], )讲一下关键参数的理由。learning_rate=0.05控制每棵树的贡献步长,调小一点会让模型更稳健,通常伴随更多棵树;num_leaves=31对应树的复杂度,太大会让单棵树过度细分,容易过拟合;min_data_in_leaf=30限制每个叶子节点最少样本数,对销量这类偏长尾分布的标签特别重要,能减少异常值带来的噪声;feature_fraction和bagging_fraction是列采样和行采样,等于给模型加随机扰动,降低过拟合风险;early_stopping(100)是看验证集指标连续100轮不再提升就停止,用验证集而不是训练集决定树的规模,这是防止过拟合最直接的手段。
参数不是越多越好,关键是通过实验记录去判断哪些参数真正起作用。“05_01_29”这轮里,我发现修改min_data_in_leaf从10到30之后,验证集指标提升明显,而微调num_leaves的影响反而没那么大。这个观察只有多做记录才能沉淀出来。
3.5 评估指标怎么选:不要只盯一个数
训练完成后,不能只看训练集或验证集上的某一个指标就拍板。那轮实验里,我同时记录了三个回归指标:MAE、RMSE、MAPE。MAE表示平均绝对误差,单位跟销量一致,最容易向业务解释;RMSE因为对误差做了平方,单个大偏差样本会被放大,适合惩罚严重欠预测或过预测的情况;MAPE是百分比误差,适合在不同门店规模之间对比,但销量接近0时MAPE会变得非常不稳定。
那轮实验的评估结果大概是:
| 模型 | MAE | RMSE | MAPE |
|---|---|---|---|
| 28天均值基线 | 13.8 | 24.5 | 34.2% |
| LightGBM 首版 | 10.6 | 19.1 | 25.7% |
| LightGBM + 修正泄漏后 | 9.4 | 17.8 | 22.6% |
表格里的数字是用来做决策的:对比基线和首版时,MAE下降了约23%,说明复杂模型带来了实际收益;但真正让我放心的是修正特征泄漏后指标再降一截,这提醒我很多“效果提升”其实来自数据泄漏造成的虚假信号,上线前必须做严格检查。
选择核心指标时,我还会考虑业务场景。如果是为每个门店做备货,我更看重MAE;如果担心少部分热门SKU预测不准导致断货,RMSE更能突出这类风险。一个模型可以同时输出多个指标,但给业务方汇报时,最好只选一个主指标,避免大家各看各的,结论完全相反。
4. 跑模型时容易踩的坑与排查实录
4.1 训练集逆天、验证集崩盘:先查泄漏和过拟合
我在跑“05_01_29”前几轮时就遇到了一个典型问题:LightGBM在训练集上的MAPE低到3%以内,验证集却接近25%。这个反差过于离谱,稍微一想就知道不是单纯的过拟合,更可能是泄漏。
排查过程从特征入手。我先打印了模型的特征重要性,发现排名第一的特征竟然是一个被我用来排序用的“记录序号”。因为数据按时间排列后,行号和日期高度相关,模型直接学出了“后面的行销量偏低”这种规律,但这不是真正业务里可复现的信号。删除行号、重训后,训练和验证的差距才回到正常范围。
这个问题说明,特征工程中需要特别警惕那些看似无害的索引类变量,因为它们往往携带未来信息。排查泄漏时,可以写一段代码扫描所有特征列,看是否存在与目标列高度相关但不应该有因果关系的强特征。
# 快速扫描疑似泄漏特征 candidates = [c for c in X.columns if "id" in c.lower() or "index" in c.lower()] for col in candidates: corr = abs(X[col].corr(y)) if corr > 0.3: print(f"warning: {col} corr with target = {corr:.3f}")这段代码不严谨,但能帮你快速发现异常。遇到可疑特征时,宁可先删掉再训练一轮,也不要留到后面解释不清。
4.2 重跑结果不一致:随机种子与版本管理
另一个让我头疼的问题是:同一份数据、同一份代码,第二次运行结果居然和第一次差了不少。起初我以为是代码里有随机性,后来才发现LightGBM的训练过程中,默认没有固定全局随机种子,行列采样、特征抽样都会引入随机波动。如果业务需要每次结果稳定,必须在参数里显式设置seed,并在浮点数运算环境尽量保持一致。
但这只是表层问题。更深的问题是环境依赖版本不统一。LightGBM在某个小版本升级后,默认线程数或数值处理逻辑可能有差异,导致同一套参数结果不同。我现在会在实验目录里额外保存一份requirements.txt,记录当前环境的关键库版本。有条件的话,用Docker把环境做成镜像再训练,能彻底避免这个问题。
排查时不要一上来就怀疑代码有bug。先确认随机种子是否固定,再看环境版本是否一致,最后才考虑算法本身是否有非确定性。很多时候“复现不出来”是实验管理问题,不是代码bug。
4.3 窗口函数导致的隐性数据泄漏
数据泄漏不一定来自明显的未来值,更多时候来自那些“看似用历史、实际偷偷看了未来”的窗口特征。比如我一开始构造“过去7天销量均值”时,为了省事直接做了groupby().rolling(7).mean(),没有做shift(1),结果这个特征包含了当天的销量。当天销量本身就是我们要预测的目标,模型自然能在验证集上表现“超常”。
这个问题我花了很久才发现,因为单独看特征构造代码非常符合直觉,没有报错,也很少有人会去检查滚动窗口里是否夹带了当天数据。排查方式很简单:对特征做一次“时间穿越测试”。用T日的信息预测T日,理论上任何特征在时间轴上都不应该依赖T日之后的数据。我写了一个简单的检查函数,构造一份目标值滞后一个周期的副本,用相关性检查特征和“未来目标”是否存在强相关。
操作上,最建议的办法还是严格遵守“先shift再rolling”的顺序,并在代码注释里写明时间对齐逻辑,避免过了一个月自己都看不明白。
4.4 一套可复用的排查顺序
遇到效果不对的实验,我现在不急着调参,而是按固定顺序排查:
- 跑一个规则基线,确认数据和评估链路没断;
- 检查特征构造的时间边界,尤其是shift和rolling是否对齐;
- 删除所有索引类、ID类、行号类特征;
- 固定随机种子并复跑两次,看结果波动范围;
- 打印特征重要性,找出排名异常的特征;
- 再翻实验编号对应的notes.md,对比上一轮改了什么。
这套顺序从“数据链路”到“特征边界”再到“随机稳定性”逐步排查,能覆盖绝大多数问题。真到第6步还没解决,大概率是更深层的业务定义问题,需要和需求方重新对齐,而不再是无脑调参。
5. 能沉淀下来的才是能力:从一次迭代到一套流程
5.1 用一张实验记录表管住所有迭代
“05_01_29”这轮实验最终被验证为有效迭代,但我没有让它成为孤例,而是把它沉淀成了一套记录规范。我现在习惯用一张表格来维护所有实验,每一行代表一次迭代,字段包括:日期、实验编号、目标场景、数据版本、特征版本、模型类型、关键超参数、验证集指标、结论和下一步计划。
用表格管理最大的好处是,模型组内部沟通时不用频繁翻代码。别人问某个特征是否试过,我直接在表里搜一下就能给出答案。如果只有代码没有记录,需要重新跑一遍才能确认,沟通成本太高。
那轮实验的最终记录里会写:“修正特征泄漏后,MAE从10.6降到9.4,MAPE降到22.6%,确认上线。特征是去除行号 + 修正滚动窗口shift。”这些内容看起来琐碎,却是后续迭代最可靠的依据。
5.2 从手写编号到工具化:什么时候该上实验管理系统
实验少的时候,自己建目录、维护表格完全够用。但当实验数量到几百次、多人协作时,就得考虑引入实验管理工具了。比如MLflow、Weights & Biases、Neptune这类平台,能自动记录每次训练的超参数、指标、模型文件和环境信息,搜索和对比也方便很多。
我在迁移到工具化阶段后,仍然保留了“项目编号”的习惯,因为业务上讨论问题时,说“帮我查一下proj05_sale029_lgb的结果”总比在工具里胡乱搜一圈要高效。实验工具负责自动记录,编号负责建立起人和实验之间的语义连接,两者并不冲突。
如果你刚开始做模型工作,我建议不要急着上重工具,先把编号、目录、配置、记录这套基本功练起来。工具解决的是“记录成本”问题,但解决不了“没有记录意识”的问题。
5.3 如果再做一次,我会在哪些地方做得更快
回看“05_01_29”这轮实验,我觉得有两个步骤可以优化:一是特征工程前先把特征表画出来,而不是边写代码边补特征,能少走弯路;二是第一版模型跑通后,应该立刻跑基线做对比,再决定是否深入调参,而不是沉浸在复杂模型的效果里。
现在每次开始新实验,我会先复制上一轮“跑得最顺”的目录结构,把编号往前推进一格,然后立即在配置文件里写上本次要验证的假设。这个习惯坚持半年后,模型迭代效率提升非常明显。记录和编号本身没有魔法,真正有用的是它们逼着你在动手前把问题想清楚,又能在做完后把经验留住。
如果你也经常遇到“跑完就忘”的情况,不妨从下一个实验开始,认真起一个有规则的编号,再简单写上两三行结论。等二十轮之后再回头看那些记录,你会感谢当初愿意做这些“乏味小事”的自己。