ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

李宏毅ML2021作业1实操:疫情数据回归预测与特征工程全记录

李宏毅ML2021作业1实操:疫情数据回归预测与特征工程全记录 先说实话李宏毅老师的 ML2021Spring 第一个作业hw1我第一次看的时候以为要搞一个很唬人的流行病学模型。结果点开说明文档才发现核心任务就是一个再普通不过的回归问题给你一堆美国各州的历史数据让你预测未来一段时间每天的新增确诊人数。但它跟 MNIST 那种图像分类入门完全不一样这里的数据自带时间序列属性和一堆混杂特征处理起来比想象中要脏。整个作业做下来我的感受是模型结构反而不是主角数据预处理的思路和滚动预测的细节才是拉开分数差距的地方。这篇东西不是官方题解是我自己做完一遍之后沉淀下来的实操记录。从数据字段拆解、特征工程到模型选型、验证集切分再到测试集上真正的滚动预测坑点每一步我都会讲清楚为什么这么做以及哪些地方容易翻车。适合正在做这个作业、或者想通过一个完整的回归案例理解机器学习流程的朋友参考。1. 任务拆解hw1到底在预测什么1.1 训练资料与测试资料的真实面貌作业给的训练数据文件是covid_training.csv我印象里大概两万行左右。每一行对应的是美国某个州在 2020 年某一天的记录。字段除了常见的日期、州名、确诊人数之外还有人均收入、人口、失业率、测试数量、测试阳性率以及三列政策字符串就是类似于 stay at home order、state of emergency 之类的文本描述。这些字符串列在一开始特别容易被当成没法直接用来算的东西丢掉但后来我发现它们对模型预测效果的影响其实不小。测试集则是 2021 年开头一段时间的每一天、每个州的数据。注意测试集里没有给你真实的确诊数甚至连过去 5 天的确诊数这个关键特征也不是现成的。这跟平时 Kaggle 上那种把特征全给好、只缺 label 的比赛不太一样。比赛题通常把特征工程做完你直接灌进模型就行这个作业相当于逼你把特征生成和预测两个环节一起做掉而这恰恰是真实业务里最费时间的部分。官方提供的 sample code 里有一个select_days函数用来提取过去若干天的确诊数作为特征。很多人第一次看会以为这只是取数后来才反应过来这其实是在做滞后特征lag feature。也就是说模型并不是用今天的各种指标直接预测今天的新增人数而是用过去 5 天的确诊序列 当天的人口/政策等静态信息来预测下一天的数值。理解了这一点整个数据处理流程的思路就顺了。1.2 评测方式与提交格式里容易被忽略的细节评测指标是 MSE均方误差不是 MAE也不是 RMSE。这意味着模型对峰值日的惩罚特别重。美国疫情数据里某些州偶尔会出现单日暴增几万例的情况这些异常大值会把 MSE 拉得很高。所以训练时如果完全不做处理模型会花大量精力去拟合那几个极端峰值反而牺牲了平时数值的精度。我在后面会专门说怎么处理这个问题。提交格式是 JSON样子大概是这样{ 0101_AL: {value: 123.45}, 0102_AL: {value: 234.56} }key 的格式是月日_州名缩写value 就是该州这一天的预测确诊数。这里有个很容易踩的坑JSON 的 key 必须是字符串而且不能有多余空格value 必须是数字不能是字符串。我第一次生成时把 value 写成了123.45本地脚本没事但上传就报格式错误。这种低级失误特别浪费提交次数建议提交前先用一个自定义脚本严格校验一遍 JSON 结构和 key 的完整性。2. 数据处理与特征工程这道题 80% 的分数在这里2.1 时间窗口构造为什么是前 5 天官方 sample 里默认取前 5 天的确诊数作为特征。这是个很微妙的数字感染到确诊有潜伏期的延迟用太短的窗口比如 2 天抓不到增长趋势的变化用太长的窗口比如 14 天又会让特征之间高度共线增加过拟合风险而且训练数据量也会变少。5 天是个经验值既能反映近期走势又不会让模型被线性的历史拖累。实现上我是这么做的def select_days(df, window5): result [] for state in df[state].unique(): state_df df[df[state] state].sort_values(date) for i in range(window, len(state_df)): feature state_df.iloc[i - window:i][confirmed].values label state_df.iloc[i][confirmed] result.append((state, state_df.iloc[i][date], feature, label)) return result注意这里有个细节不同州是相互独立的不能把一个州的确诊数拿去给另一个州做窗口。我一开始图省事直接全局按日期排序滑窗结果某个州的过去 5 天里混进了其他州的数据验证集分数惨不忍睹。所以千万记得要按state分组后单独滑窗。2.2 字符串策略列的 embedding 处理三列政策字符串不能直接当数值用因为它们不是有序的类别。比如 stay at home order 和 state of emergency 之间没有任何数值意义上的大小关系。直接用label encoder变成 0、1、2 也不行因为模型会误以为 2 比 1 大、比 0 大。正确的做法是做 embedding也就是把这几个字符串当成离散 token每个 token 映射成一个可学习的稠密向量。作业提示里其实也暗示了这个方向具体做法分三步先遍历训练集和测试集的所有策略字符串建立字符串到整数索引的映射表注意一定要加入一个unktoken 兜底防止测试集出现训练集里没见过的字符串。把每行的策略字符串替换成对应的索引。在模型里给每个策略列单独设一个nn.Embedding层维度不用太大4 到 8 就够了。维度设太大反而容易过拟合因为策略类型的总量本来就不多。import torch.nn as nn policy_embedding nn.ModuleList([ nn.Embedding(num_embeddingslen(state2idx)1, embedding_dim4) for _ in range(3) ])这里我踩过一个很深的坑如果只用训练集的字符串建立映射表测试集中某个州出现了新的政策描述比如 vaccination campaign索引就会越界直接报错。所以建表的时候一定要把covid_test.csv也加进去扫描一遍。2.3 标准化与异常值处理人口、失业率、测试数量这些特征的量纲差异很大。人口是几百万的量级确诊数是几千失业率是百分之几。如果直接拼在一起喂给模型梯度更新会被大数值特征主导。所以归一化是必须的我用了最简单也最稳妥的 z-scoremean train[features].mean() std train[features].std() train[features] (train[features] - mean) / std注意这里有一个隐藏的陷阱测试集的数据不能重新算一份 mean 和 std必须直接用训练集的 mean 和 std 去转换。因为测试集在真实场景里是未来未知的数据你不能用它来改变特征分布否则就构成了数据泄漏。很多人在本地验证集上换个fit_transform写法结果上传分数暴跌大概率就是栽在这里。处理异常大值也很关键。我试过不对确诊数做任何变换直接训练loss 曲线一直在高位震荡因为某些州单日新增数万例会让 MSE 爆炸。后来我改用了一个小技巧训练时对 label 做log1p变换让大峰值的数值被压缩预测完再expm1还原。label np.log1p(label) # 训练时 pred np.expm1(pred) # 提交时这个操作在验证集上能明显降低误差。但要注意既然评测指标是原始数值上的 MSE搬回来之后分数才真实别直接提交 log 尺度的预测值。3. 模型选型从官方 baseline 到你自己的 DNN3.1 官方 sample code 为什么用线性回归官方给的 sample 就是线性回归再配合 embedding 后的策略向量本质上就是在做一个带时间窗口特征的线性模型。说实话这个 baseline 能跑通但效果很一般。因为确诊人数的增长机制不是线性的尤其是疫情爆发期有指数式增长。线性模型能捕捉过去高明天也高的惯性但很难模拟出连续几天增长之后突然加速这类非线性关系。我自己的建议是先用官方 baseline 跑通完整流程拿到第一份提交保证不报错、格式正确。然后再换模型提升。这是最稳的路线因为如果你一上来就写 DNN结果数据处理有 bug你根本分不清是模型的问题还是数据的问题。baseline 就像是一个自检工具它能把流程层面的错误暴露出来。3.2 轻量 DNN 网络设计换掉线性模型之后我用了一个很轻量的全连接网络。结构不复杂就三层但效果比线性模型好一大截。这里给出一份可以直接用的结构class COVIDRegressor(nn.Module): def __init__(self, num_numeric, num_embeddings, embed_dim4): super().__init__() self.embeds nn.ModuleList([ nn.Embedding(num_embeddings 1, embed_dim) for _ in range(3) ]) input_dim num_numeric 3 * embed_dim self.net nn.Sequential( nn.Linear(input_dim, 256), nn.BatchNorm1d(256), nn.ReLU(), nn.Dropout(0.2), nn.Linear(256, 128), nn.BatchNorm1d(128), nn.ReLU(), nn.Dropout(0.2), nn.Linear(128, 1) ) def forward(self, x_numeric, x_policy): embeds [emb(x_policy[:, i]) for i, emb in enumerate(self.embeds)] x torch.cat([x_numeric] embeds, dim1) return self.net(x).squeeze()选这个结构的原因很简单数据量只有两万行特征维度也不高一个过深的网络没有足够数据支撑反而容易过拟合。256-128 这个量级对于这个任务来说已经非常充裕。BatchNorm 是我强烈建议加的它能让训练稳定很多尤其是对数值尺度敏感的全连接层。Dropout 0.2 是怕模型记住训练集里的峰值日期。3.3 训练超参数配置的心得我最终使用的超参数组合是Adam 优化器初始学习率 1e-3batch size 512训练上限 300 个 epoch并搭配 early stoppingpatience20。这个组合的核心逻辑是batch size 偏大可以让梯度更稳定因为训练样本不多batch 太小反而容易震荡学习率 1e-3 是 Adam 的默认值在大多数回归任务上都是不错的起点如果 loss 曲线变得很平再把学习率降到 1e-4 续跑几个 epoch。早期我犯过一个经典的错误训练集 loss 降到很低验证集 loss 却一直下不去典型的过拟合。后来发现是 Dropout 没有加对位置或者干脆忘了加。这个作业的数据天然就带很多噪声过拟合是常态不用焦虑重点是通过早停找到那个验证集最低点的模型参数而不是训练 loss 最小的那一个。4. 完整实操从数据读到滚动预测4.1 数据读取与样本构建数据处理我建议全部用 pandas 和 numpy 完成构建好train_x、train_y之后再转成 PyTorch 的 Dataset。这样既方便调试也不容易把数据处理逻辑混进训练循环里。import numpy as np import pandas as pd from torch.utils.data import Dataset class COVIDDataset(Dataset): def __init__(self, df): self.x_numeric df[numeric_cols].values.astype(np.float32) self.x_policy df[policy_cols].values.astype(np.int64) self.y df[label].values.astype(np.float32) def __len__(self): return len(self.y) def __getitem__(self, idx): return ( torch.from_numpy(self.x_numeric[idx]), torch.from_numpy(self.x_policy[idx]), torch.tensor(self.y[idx]) )数值特征的排列顺序要固定顺序一变训练和预测就全乱了。我建议把numeric_cols定义成一个列表训练和测试都按这个列表取列不要依赖 DataFrame 的列名排序。4.2 验证集切分别让时间泄漏骗了你这个作业最阴险的坑在验证集切分。如果直接train_test_split(random_state42)随机打乱验证集分数会虚高。因为训练集和验证集都在 2020 年内日期重叠严重模型可以通过近邻样本偷看答案。但测试集是 2021 年的未来数据分布跟 2020 年有很大差异随机切分得到的分数根本不能反映线上表现。正确的做法是时间切分比如把 2020 年 11 月 1 日之后的样本全部作为验证集之前的全部作为训练集。这样验证集就是模型没见过的时间段更贴近测试集的真实场景。用这种切分方式你会发现验证集分数比随机切分高不少但这才是真实的水平。train_df raw_df[raw_df[date] 2020-11-01] valid_df raw_df[raw_df[date] 2020-11-01]4.3 训练与早停训练循环本身没什么特别的但我要强调早停的重要性。我自己跑的时候验证集 loss 通常在某个 epoch 后开始缓慢回升这时候继续训练只会让模型记住噪声。实现了一个简单的 patience 计数器验证集 loss 连续 20 个 epoch 没有刷新最低记录就停。best_loss float(inf) patience 0 for epoch in range(300): train_loss run_train_epoch(...) valid_loss evaluate(...) if valid_loss best_loss: best_loss valid_loss torch.save(model.state_dict(), best_model.pth) patience 0 else: patience 1 if patience 20: break每次 epoch 结束都保存最佳模型而不是最后一次模型这样即使后面过拟合了你也能加载回最优参数。4.4 滚动预测与 json 结果构造终于说到这个作业真正的重头戏了测试集的预测。很多人训练完模型之后直接把测试集的特征丢进模型然后把结果提交结果分数离谱。原因在于测试集根本没有提供过去 5 天的确诊数这个特征需要你自己通过模型滚动生成。我的做法是逐个州、按日期顺序做递归预测取训练数据中该州最后 5 天的确诊数作为初始窗口。对测试集第一天用窗口内的 5 个确诊数加上其他特征预测出该州当天确诊数。把预测值 append 进窗口丢掉窗口最早的这一天保持窗口长度为 5。继续预测第二天如此往复直到预测完整个测试时间段。for state in test_states: window last_days[state].tolist() # 长度为 5 for date in state_test_dates: features build_features(state, date, window) pred model(features) pred max(0.0, pred) # 确诊数不能为负 window.append(pred) window.pop(0) result[f{date}_{state}] {value: pred}这里有几个细节必须说明。第一窗口是每个州独立的不能跨州混用数据。第二预测值如果出现了负数不能直接喂给下一个窗口我选择直接 clip 到 0不然负的确诊数会导致后续特征越来越诡异。第三窗口长度必须跟训练时保持一致训练用 5 天预测也必须用 5 天。最后把 result 字典直接json.dump写入文件就行。写完后再检查一下 key 的数量是否等于测试集行数或者遍历测试集 id 检查是否都存在于提交文件中这样能挡掉九成以上的格式错误。5. 问题排查与避坑实录5.1 数据泄漏的第一现场我最早的一次提交分数差点让我怀疑人生排查到最后发现是验证集切分方式的问题。随机切分让模型在验证集上表现得过于聪明数据分布里混入了未来信息。这也提醒我处理时间序列相关的任务脑子里要时刻绷着一根弦任何不该看到的未来信息都可能是泄漏源。这个理解也可以平移到生活场景比如你想预测一支股票明天的涨跌如果训练集里用到了明天的新闻标题那模型在训练时当然表现完美一到实盘就废了。数据泄漏的样子千奇百怪但本质永远是信息的时间线错位。5.2 Embedding 的 OOV 崩溃我在前面提过covid_test.csv里出现了训练集没见过的政策字符串如果 index 越界训练直接崩个IndexError。这个问题在本地验证集上根本发现不了因为我切验证集的时候用的还是同一个映射表。真正上线预测的时候才暴露出来。解决办法是建映射时把训练集和测试集全量扫描一遍并留一个unktoken。这算是 NLP 里的 common sense放在这个作业里反而容易忽略毕竟它看起来更像一个传统表格数据任务。5.3 Loss 变 NaN 与预测值异常Loss 变成 NaN 是我在中途遇到过一次。排查下来发现是标准化后的特征里有缺失值没有处理某个州的失业率字段是空值pandas 直接给了一个 NaN喂进模型之后梯度就爆炸了。处理方式很简单读取数据后先fillna(0)或者用该列的中位数填充。还有一次是预测值大范围出现负数后来意识到是因为标签做了log1p但模型最后一层没有加任何约束输出可以从(-inf, inf)任意取值。虽然日志尺度的训练让数值稳定了很多但极端情况下仍然可能预测到负区间提交前 clip 一下是最快的兜底方案。5.4 复现别人的分数为什么总差一点很多同学喜欢去抄 GitHub 上的公开代码但复现出来的分数总是和作者贴出来的差一点点。这个现象绝大多数时候不是代码问题而是隐藏的随机性PyTorch 的初始化是随机的如果没有设置全局随机种子每次训练结果天然会有波动。另外不同机器上的浮点运算顺序也会带来微小差异无法完全消除。我的建议是不需要追求完全复现某个分数而是把注意力放在验证集误差上。只要你的验证集切分方式合理验证集分数和测试集分数的相对关系是稳定的用验证集选模型再提交测试集才是正确的工作流。6. 这个作业做完还能怎么用整套代码其实不限定在疫情数据上。把它抽象一下这就是一个通用的时间序列 静态特征回归框架。换份数据比如预测某个城市的每日用电量、某个店铺的日销售额、或者服务器每天的请求量只需要改一下字段名和窗口大小其他逻辑完全可以复用。这个作业最大的价值不是让你记住李宏毅课程里的某个公式而是让你完整走了一遍从原始表格到提交结果的机器学习闭环。我自己后来还试着把全连接层换成 LSTM效果在疫情数据上并没有明显提升因为窗口只有 5 天序列太短LSTM 的优势发挥不出来。但如果换到窗口更长的任务比如预测未来 7 天、用过去 30 天的数据序列模型的收益就会明显起来。这也是一个值得自己动手验证的方向。最后说一点题外话。很多入门教程都在讲 MNIST 那种干净到不能再干净的数据但真实世界里的数据永远是脏的、缺失的、分布漂移的。hw1 恰恰提供了一个接近真实场景的练习场字符串特征、时间窗口、滚动预测、未来数据不可见每一个环节都值得你停下来想想为什么。把这个作业吃透比刷十个 MNIST 项目都管用。
返回列表