前两篇把结构化数据深度学习的基线讲完了第一篇聊了为什么深度模型值得在表格数据上尝试第二篇动手搭了MLP说了归一化、类别编码这些基本功。按原计划第三篇应该重点回答一个一直被追着问的问题深度模型在结构化数据上到底能不能真的超过树模型以及用什么结构、怎么落地。这篇就把我自己的结论、复现过的结构和踩坑记录一并写下来内容偏实践适合已经跑通至少一个深度学习小项目、想在表格数据和序列化数据上做出可用模型的读者。1. 结构化的边界比你想的更宽1.1 结构化数据不只有SQL表很多朋友一提到结构化数据脑子里浮现的就是一张宽表客户ID、年龄、收入、最近一次消费金额横平竖直一行一个样本。这确实是结构化数据最常见的样子但结构化不等于“关系型数据库里的表”。我这些年的经验是凡是字段边界清晰、每个样本能对应一组确定维度的数据都属于广义结构化数据。常见的至少有三类关系型表格行为样本列为属性这是最标准的表格数据。长表/事件流比如用户点击日志、传感器读数、网络会话记录每条记录有固定的字段但一条完整样本往往由多条连续记录拼起来。高维稀疏特征向量比如用户标签体系、多值类别特征展开后的One-Hot向量本质仍是一行样本。这个区分不是文字游戏。它直接决定你后面怎么构建输入。很多深度模型在结构化数据上表现不好不是你模型没选对而是你把事件流硬当宽表喂进去了丢失了顺序信息或者把一个高基数类别直接做了整数编码让模型学到了假的数值大小关系。1.2 看清数据形态才好决定喂给谁我的习惯是先问三个问题数据是按行独立的还是带有前后依赖特征主要是数值型还是类别型占大头样本量是几千、几万还是百万级这三个问题基本能勾勒出建模路线。表格行独立的数据优先考虑GBDT或带Embedding的深度模型事件流和序列数据就要考虑窗口化之后用CNN、RNN或Transformer如果数据既有表格字段又有长短不一的序列字段那就是多模态表格问题TabTransformer这类结构会更合适。这些东西之间没有绝对的优劣只有匹配不匹配。2. 树模型和深度模型不是敌人是分工2.1 GBDT的舒适区与硬伤先说实话在纯表格数据上LightGBM、XGBoost这类树模型依然是性价比极高的选择。原因有两个。第一树模型对特征尺度和分布不敏感你不需要做精细的归一化缺失值可以直接处理第二树模型天然擅长找特征交叉像“年龄大于30且收入小于5000”这种规则型模式树模型会非常自然地切出来。但树模型有几个硬伤是结构性的。一是无法做高基数类别特征的表征学习比如用户ID、商品ID这种动辄几十万取值的类别One-Hot展开后稀疏得没法看整数编码又不能直接喂给树。二是很难利用“列之间的顺序信息”比如一段流量行为序列树模型只能靠你手工抽统计特征而手工统计特征往往丢掉时序模式。三是树模型缺乏迁移和预训练基础你很难在源域上预训练一个模型再微调到目标域。2.2 深度模型真正占优的三类场景深度模型在这些场景下会明显占优高基数类别密集的表格。商品ID、用户ID、地域码这些离散值可以通过Embedding映射成稠密向量模型能学到“相似ID有相似行为”的隐含语义。这是树模型做不了的。表格与序列混合的多模态数据。比如同时有用户静态属性和过去30天的行为序列深度模型可以把表格字段和序列字段统一到一个网络里树模型则很难端到端处理序列。大样本下的复杂非线性交互。样本量达到百万级、特征维度大且交互关系不规律时深度模型的上限通常比树模型高代价是训练成本和调参成本也高。2.3 一张表帮你决定这次用谁我在实际项目里经常用下面这个简洁的选型逻辑列出供参考信号类型样本量特征构成更推荐方案行独立、特征规则清晰1万以下数值低基数类别LightGBM行独立、特征复杂10万以上数值中高基数类别MLP/FT-Transformer类别特征极多任意规模高基数类别为主TabTransformer带时间顺序的结构化流10万以上数值/序列混合一维CNN或Transformer真实项目里我不会一句“深度模型肯定更好”就给客户换方案而是先跑一版树模型做baseline再上深度模型看增益。大多数情况下深度模型赢在3-8个点的AUC输在训练和部署成本。这个增益是否值得完全取决于业务场景。3. 从特征工程到表征学习一张表的“深度学习化”3.1 类别Embedding的底层逻辑与维度选择深度模型处理类别列的第一步就是把整数ID变成稠密向量。Embedding的核心思想是每个类别学一个可训练的向量让模型根据损失函数自动调整把相似行为的类放得近不相似的放得远。这等于把特征工程里最费力的“手工相似度定义”交给了梯度下降。代码层面非常简单我用PyTorch写过无数次import torch.nn as nn class CategoryEmbedding(nn.Module): def __init__(self, num_categories, embed_dim): super().__init__() self.embedding nn.Embedding(num_categories, embed_dim) self.embed_dim embed_dim def forward(self, x): # x: [batch_size] return self.embedding(x) # - [batch_size, embed_dim]embedding维度的经验公式是min(50, (num_categories // 2))但实际还要看类别本身的语义丰富度。取值只有10个的星期几给4-8维足够10万个商品的ID我一般给32-64维。维度太高容易过拟合太低表达不了差异。有人会问是不是维度越多效果越好不是。维度过高会在小样本上带来严重的过拟合而且训练慢。3.2 时间列和周期特征别把时间戳直接当数值喂时间戳在很多表格里被当成普通数值列处理。这其实是个不大不小的错误。拿Unix时间戳举例数值大、分布线性和语义不连续直接丢给模型模型只能学到“数字越小越早”很难学到“周一和周日可能有不同行为”这种周期性规律。我的做法是拆时间字段从时间戳里拆出小时、星期、是否节假日、距当前时间差等。周期特征再进一步做正弦余弦转换比如小时用sin(2π * hour / 24)和cos(2π * hour / 24)两个字段星期一0和星期日23之间的距离就不会被模型误判成“离得很远”。这在用户行为预测、调度类任务里非常有用。3.3 缺失值、离群值的设计思路树模型可以无缝处理NaN深度模型不行。大部分人的第一反应是均值填充或删除但这会损失信息。我的处理思路是数值列缺失填一个“不可能出现”的边界值比如-999同时增加一个二值指示列is_missing让模型自己学习“缺失到底意味着什么”。类别列缺失填一个特殊的__UNKNOWN__类别并把它也放进Embedding让模型自己区分“未知”和“存在的类别”。离群值不要无脑截断。如果离群值本身代表着某种业务异常比如交易金额突然特别大保留它反而有强信号。我会做一个is_outlier字段而不是直接删掉样本。本质上是把“要不要处理缺失”这个决策从人工规则变成可学习信号。模型如果发现缺失列没有预测力权重视网络自己调节相关权重会自然变小。4. 需要认识的两类表格深度结构TabTransformer和FT-Transformer4.1 TabTransformer类别特征进Transformer数值特征走MLPTabTransformer是2021年提出来的结构最早是为了解决推荐场景里类别特征太多太杂的问题。核心思路很直接把所有类别特征都过Embedding拼起来输入几层Transformer Encoder让类别与类别之间先做自注意力交互得到的信息和原始数值特征拼接后一起送入MLP。这么做的好处是类别特征之间的高阶交互不再需要你手工设计。比如“行业”和“岗位级别”这两个类别很多情况下单独看都没啥预测力但组合起来可能非常强Transformer的自注意力机制恰好擅长捕捉这种组合模式。一个代码骨架大致是class TabTransformer(nn.Module): def __init__(self, cat_dims, cat_embed_dims, num_cont_dim, d_model, nhead2, num_layers2, hidden_dim64): super().__init__() self.embeds nn.ModuleList([ nn.Embedding(n, d) for n, d in zip(cat_dims, cat_embed_dims) ]) self.cont_bn nn.BatchNorm1d(num_cont_dim) encoder_layer nn.TransformerEncoderLayer( d_modeld_model, nheadnhead, batch_firstTrue, dim_feedforwardhidden_dim * 2, dropout0.1 ) self.transformer nn.TransformerEncoder(encoder_layer, num_layersnum_layers) self.fc nn.Sequential( nn.Linear(d_model * len(cat_dims) num_cont_dim, hidden_dim), nn.ReLU(), nn.Dropout(0.3), nn.Linear(hidden_dim, 1) ) def forward(self, x_cat, x_cont): embs [emb(x_cat[:, i]) for i, emb in enumerate(self.embeds)] cat_feat self.transformer(torch.stack(embs, dim1)) # [B, num_cat, d_model] cat_feat cat_feat.reshape(cat_feat.size(0), -1) cont_feat self.cont_bn(x_cont) return self.fc(torch.cat([cat_feat, cont_feat], dim1))我这里把每个类别Embedding维度设成一样的d_model方便堆Transformer。实际项目里可以根据每个列单独设再通过线性层统一维度但那样实现复杂度高一些多数场景不需要。4.2 FT-Transformer给数值特征也升维FT-Transformer的思想更进一步。它不再区分“类别特征走Transformer数值特征走旁路”而是把所有特征包括数值特征都先通过一个带学习的标度变换映射成高维向量然后再统一扔进Transformer堆栈。对照TabTransformerFT-Transformer通过一个Per-feature Transform将每个标量数值特征转换成d维向量比如用nn.Linear(1, d)对每个数值列做一次就能把age35转换为一个35维的向量。这样Transformer的每个token不再只是类别而是所有特征列——数值特征之间、数值与类别之间都可以做交互。我在实验里发现FT-Transformer在特征维度几十以上的中等大表格上确实经常比TabTransformer更稳。原因也好理解它没有把数值特征晾在外面而是让所有特征统一在高维语义空间里做交互。4.3 选型意见先看类别占比再看训练资源这两个结构怎么选我的经验很直接如果数据里类别特征特别多数值特征很少用TabTransformer省参数而且训练快。如果数值和类别一半一半或者数值列可能和类别列有强交互用FT-Transformer。如果你的样本量只有几千这两个结构都容易过拟合不如老老实实用MLP强正则化。训练资源方面FT-Transformer因为每个特征都要做升维Transformer序列长度是“特征列数”列数几十上百时计算量和显存会明显上升。CPU环境能跑但训练小批量大时很慢。我一般建议先用小数据集验证趋势再上GPU。5. 把序列和图像化结构交给CNN5.1 恶意软件识别典型案例字节转灰度图很多结构化数据问题做着做着就发现光按行处理不够了。比如恶意软件识别很多人喜欢把它归到“网络安全深度学习”里但实际上它跟结构化数据建模高度相关一个二进制文件本质就是一串字节序列属于字段边界固定的结构化流数据。经典的思路之一是把二进制文件的每个字节映射成0-255的灰度值按固定顺序填成二维图像。一个文件变成一张灰度图然后交给CNN做分类。这个做法在2016-2018年恶意软件分类比赛里被反复验证过我当时复现的效果也确实不错。原因并不玄学卷积核能自动学会局部字节模式很多恶意代码的特征其实藏在固定的指令序列和结构体布局里CNN提取局部模式的能力刚好匹配。实操上文件大小需要截断到一个固定长度。我一般会做实验把文件前1M字节保留下来不足的补零生成1024x1024的灰度图。太小的文件信息量不够但足够长的头部往往已经能区分恶意样本的很多特征。维度太大内存受不了可先用小尺寸256x256试一轮。5.2 一维CNN处理日志、点击流等时间序列除了二维化还有一种更常见的做法直接用一维CNN处理结构化时间序列。例如用户30天点击流特征是(点击时间间隔, 点击页面类型ID, 停留时长)把每次点击看成一个时间步30天可能就是一个2000步左右的序列。一维CNN的做法是滑动一个窗口大小的卷积核在时间方向上提取局部模式比如“连续三次快速点击然后长停留”这种模式一维卷积很容易学到。代码上如果每个时间步是一个向量可以利用nn.Conv1d把输入维度设为[B, feature_dim, seq_len]或者用nn.Conv1d按通道方式处理。相比LSTMCNN训练快、可并行而且不容易在长序列上出现梯度消失。我在点击流预测场景对比过一维CNN的效果和LSTM基本持平但训练时间只有LSTM的1/5。5.3 CNN、RNN、Transformer序列建模怎么选我做序列建模选型时有一条简化判断看序列长度和依赖距离。序列短50步以内、依赖模式近CNN足够。序列中等几百步、依赖距离有长有短LSTM/GRU更灵活。序列长且样本量充足比如论文级实验Transformer更强但需要大量调参和数据。结构化数据任务里大多数是“短到中等序列”所以我个人用得最多的是CNN和LSTM的组合前面几层一维卷积做局部特征提取后面接一个双向GRU做长程建模。这个组合在小数据集上比纯Transformer更稳而且对显存要求低。6. 结构化数据建模的坑位清单6.1 目标泄漏第一坑结构化数据任务里目标泄漏比深度学习其他任务更隐蔽。我踩过最典型的一次是在做用户复购预测时把“用户是否在30天内复购”作为标签但训练集特征里混进了“用户最近一次优惠券使用时间”。这个时间实际包含未来信息——领券行为发生在标签定义的时间窗口之后模型等于直接看了答案离线指标虚高上线立刻崩塌。排查方法只有一个把特征构建过程按严格时间轴过一遍确保任何特征都不可能用到标签定义时点之后的数据。我建议在数据清洗后做一次“反向验证”故意把特征顺序打乱重新训练如果指标明显下降说明模型依赖了特征顺序或潜在时间信息很可能有泄漏。6.2 类别编码和Embedding维度的经验法则类别编码这块有两个典型错误一是把所有类别做整数编码后直接喂给深度模型模型会学习到“3比2大、比4小”这样完全不存在的排序关系二是对高基数类别和低基数类别统一使用相同维度Embedding浪费参数。我自己的经验维度表类别取值数经验Embedding维度 1008-16100-100016-321000-1000032-64 1000064-128维度上限压住配合Dropout和早停能有效防过拟合。6.3 训练验证的最佳划分与早停结构化数据往往带有时间属性所以train/validation/test不能随机乱切。一律按时间切用前70%的数据训练后30%按时间顺序切成验证和测试。如果数据里没有时间列也要先检查样本ID是否和采集顺序有关避免ID排序信息泄漏。早停和优化器设置也有讲究。我在表格深度模型上常用的配置是AdamW学习率1e-3batch size 256到1024之间验证集上耐心值20个epoch。另外要开着BatchNorm——表格深度模型里BN带来的稳定作用比CNN里更明显。7. 最小可复现流水线从CSV到能跑的模型7.1 环境准备Miniconda PyTorchCPU也能跑到了实战部分我先把环境讲清楚。最省心的方式是Miniconda建独立环境不装CUDA也能跑CPU版PyTorch小数据集完全够用conda create -n tabular_dl python3.10 -y conda activate tabular_dl pip install torch --index-url https://download.pytorch.org/whl/cpu pip install pandas scikit-learn如果机器有NVIDIA GPU就把--index-url那行换成正常安装装CUDA版即可。新手经常在这里纠结半天版本号我建议直接装PyTorch官方稳定版不要追新。7.2 数据准备、Embedding模型结构与训练循环数据部分用一个公开的成人收入数据集举例假设特征是数值列和类别列混合。首先要区分类别列和数值列然后做一个轻量封装import torch from torch.utils.data import Dataset class TabularDataset(Dataset): def __init__(self, df, cat_cols, num_cols): self.cat torch.tensor(df[cat_cols].values, dtypetorch.long) self.num torch.tensor(df[num_cols].values, dtypetorch.float32) self.label torch.tensor(df[label].values, dtypetorch.float32) def __len__(self): return len(self.label) def __getitem__(self, idx): return self.cat[idx], self.num[idx], self.label[idx]训练循环不加花哨操作关键是把验证集loss和train loss分开打印看着两个loss的差值来调Dropout、学习率。我习惯每轮都做一次验证不等到全部epoch跑完再看这样能快速发现过拟合。7.3 导出ONNX和封装推理服务时的经验模型训练完不能只在Jupyter里跑。结构化数据模型的部署量级一般不大但有两个点必须注意。一是导出ONNX前要把训练用的预处理步骤固化成固定逻辑特别是归一化所用的mean/std必须保存下来线上推理完全复用不能被业务代码重新算。二是类别列的映射关系需要存成字典线上请求的新类别如果训练里没见过要让它走__UNKNOWN__分支而不是直接报错。ONNX导出代码很简单import torch model.eval() dummy_cat torch.randint(0, 100, (1, num_cat_features)) dummy_num torch.randn(1, num_num_features) torch.onnx.export(model, (dummy_cat, dummy_num), model.onnx, input_names[cat, num], output_names[pred], opset_version13)但要注意如果模型里用了自定义无参Module导出前最好简化结构用标准Layer替换。自己写的attention模块如果和ONNX算子不兼容容易导出失败。7.4 CPU和NPU部署的一些观察当模型到了部署阶段结构化数据模型一般都不大参数量通常在几个MB到几十MB。我做过多次CPU部署推理速度完全够用。如果环境是NPU核心思路和GPU部署类似差别主要在算子适配比如Sigmoid这类常见算子通常都支持好。但表格模型里如果用到了动态shape比如变长序列NPU支持往往比GPU差最好把输入长度全部padding到固定值再导出。8. 上线之后监控分布漂移和及时再训练8.1 如何判断模型“过期”模型上线不是终结而是另一套工作的开始。结构化数据的业务环境变化很快用户习惯、商品结构、季节周期都会导致特征分布变化。我用来监控的指标有三个线上预测分布和离线训练时预测分布是否一致。关键特征的真实分布是否发生明显偏移比如平均值或分位数变化超过一定阈值。业务指标是否出现连续下跌排除营销活动等外部因素后再怀疑模型本身。不少团队把模型上线后当“黑盒子”放着直到业务方投诉才回头查。我个人建议至少每周跑一次特征分布对齐检查用一张报表就能实现。8.2 再训练节奏与数据回放一旦发现漂移明显必须尽快做再训练。结构化数据模型的再训练成本低通常用最近三个月的数据就能覆盖新分布。我的经验是先加新数据做增量训练验证集用最近一个月的数据如果增量训练效果不理想再考虑全量重训。数据回放这块值得多说一句旧数据不能全丢掉要按时间衰减采样保证模型不会只记得新业务而忘记该长期保持的行为。比如三个月前样本权重降到0.6一个月前样本权重0.9这周样本权重1.0。加权采样的实现很简单但效果非常直接。最后分享一点个人体会结构化数据的深度学习真正的护城河不在模型结构有多新而在于你把数据形态想清楚、把特征表征做到位、把训练验证时间轴弄干净。TabTransformer、FT-Transformer这些结构值得复现但它们的提升通常是可解释、可复现的工程提升不是玄学。如果你正在从树模型转向深度模型我的建议是先拿自己的数据集把MLP和Embedding跑通再上注意力结构最后再碰序列和图像化改造。每一个阶段都能留下可对比的baseline以后换方案的时候才有底气。