如果你和我一样最开始学Pytorch是用图像任务入门的那你大概率也有过这种困惑图像能通过卷积网络跑文本能通过词向量跑为什么拿到一张几百行的Excel表格反而不知道该怎么把数据和模型对接起来。我最近完整跑了一遍Titanic数据集用Pytorch从零手写了数据预处理、模型定义和训练循环整个过程走下来收获比单纯调库大很多。这篇文章就把整个流程拆开重点讲那些文档里不会写的细节。Titanic数据集是结构化数据建模入门最经典的样本之一特征不多、目标明确非常适合用来理解表格数据怎么转换成Pytorch张量、怎么自定义网络、怎么自己控制训练过程。如果你是刚学完深度学习基础知识的人应该能从这套流程里得到不少可复用的经验。1. 为什么拿Titanic数据集给Pytorch做“结构化数据”入门1.1 它其实是一个“小但完整”的建模样本先说数据本身。Titanic数据集里记录的是乘客的个人信息和是否幸存字段包括舱位等级、性别、年龄、兄弟姐妹/配偶数量、父母/子女数量、船票价格、登船港口这些。规模并不大训练集一般是几百行但对入门来说非常合适它既有连续数值特征也有离散类别特征还有缺失值需要处理甚至有一个字段本身不太适合直接喂给模型。你可以把它理解成一个“麻雀虽小五脏俱全”的样本一次数据处理需要遇到的绝大多数问题在这个数据集里都能碰到。很多人拿到数据后的第一反应是赶紧定义一个网络跑起来但实际上真正决定模型上限的往往不是网络结构而是数据怎么清洗、怎么构造、怎么编码。在图像任务里像素天然就是数值可以直接给模型表格任务里则要自己做特征工程把“登船港口”这种字符串转换成模型能够理解的数值向量把缺失的年龄填上合理估计值把特别大的票价数值尺度缩到合适范围。这些工作越扎实后面模型训练越丝滑。1.2 这类任务不一定非要用Pytorch但用它练手很值得必须说一句大实话如果只是想在Titanic数据集上拿高分传统模型往往更稳也不需要这么麻烦的手写训练循环。那为什么还要用Pytorch因为这里的目标不是“用最简单的方式拿到最好分数”而是理解深度学习处理结构化数据的完整链路。Pytorch能把每一步实验过程拆出来给人看你可以自定义网络层数、加Dropout、观察每个batch的loss变化这些都是调包时看不到的底层细节。另外现实中的结构化数据任务并不少比如用户流失预测、信用风控、广告点击率预估在这些场景里深度学习模型并不是万能解但如果数据量足够大、特征复杂度上去了Pytorch能提供的灵活度是传统模型比不了的。从这个角度说拿Titanic数据集把Pytorch的手写训练流程过一遍是一种很小的成本换很高的认知收益。即使后续你依然用传统模型干活至少你清楚了深度模型在表格数据上是怎么运转的以后真遇到需要上深度模型的项目不会一脸懵。1.3 动手前先明确交付物是什么我建议在写第一行代码之前先想清楚最终要交付什么。Titanic数据集的常规任务是对测试集乘客预测是否幸存提交结果通常是两列乘客ID和预测标签0或1。评估时一般看准确率或者AUC也有只看预测正确率的版本。明确这一点很重要因为它决定了你的数据处理流程会是怎样的你必须保证测试集的行顺序和最终提交文件的行顺序完全一致你必须在训练过程中留出验证集来观察模型泛化能力而不是只看训练集表现你还得决定用“大于0.5判为幸存”还是用其他阈值。这些看起来是小问题但到了提交阶段都是能直接影响结果的关键点。我见过不少人模型练得不错最后提交文件里因为索引错位导致分数很低问题恰恰出在最容易被忽略的交付环节。2. 数据清洗与特征工程比模型参数更影响上限的部分2.1 先读数据再处理缺失值和类型第一步当然是读数据。把训练集和测试集分别加载进来不要一开始就把它们合并。很多人喜欢直接合并处理图省事但这样容易把验证集的划分搞乱也容易不小心让测试集信息渗入训练过程。分开处理每一步都清晰一点后面不容易返工。import pandas as pd import numpy as np train_df pd.read_csv(train.csv) test_df pd.read_csv(test.csv) print(train_df.shape, test_df.shape) print(train_df.isnull().sum()) print(test_df.isnull().sum())Titanic数据集的常见缺失位置是年龄、登船港口、船票费用和客舱编号。年龄缺失一般用中位数填充因为年龄分布可能有偏中位数比均值更稳健登船港口缺失数量很少用众数填充即可测试集里的船票费用如果有缺失同样是填中位数。客舱编号的缺失比例非常高直接填充反而会引入大量噪声常规做法是构造一个“是否有客舱编号”这样的0/1特征或者干脆丢弃原始编号。这里提醒一个容易被忽视的细节填充缺失值时要分别对训练集和测试集计算统计值吗严格来说应该只在训练集上计算中位数或众数然后用到测试集上。这样才能模拟真实场景测试集在模型训练时是不可见的你不能用测试集的统计信息去填充它的缺失值。大多数情况下两者差别不大但这个习惯要养好。2.2 特征工程把原始字段翻译成数值向量表格数据不能直接进Pytorch需要把每个字段转成数值。性别字段简单映射成0/1登船港口可以映射成0/1/2三个值也可以做独热编码。用映射的方式会在模型眼里形成“1比0大”的假象因为数值大小会被模型错误理解为有序关系所以港口这种没有顺序含义的类别更好的做法是独热编码。性别本身是二分类映射成0/1没有问题。还可以构造少量有效特征。比如把兄弟姐妹/配偶数和父母/子女数加起来加1得到家庭人数如果一个乘客没有亲属同行可以标记为“独身”。这两个特征在Titanic数据集上经常是有效的因为生存率会受同行人数影响。票价可以做对数变换来拉近尺度差距当然也可以直接交给后续归一化处理。train_df[Sex] train_df[Sex].map({male: 0, female: 1}) test_df[Sex] test_df[Sex].map({male: 0, female: 1}) train_df[Embarked] train_df[Embarked].fillna(S) test_df[Embarked] test_df[Embarked].fillna(S) train_df pd.get_dummies(train_df, columns[Embarked], drop_firstTrue) test_df pd.get_dummies(test_df, columns[Embarked], drop_firstTrue) train_df[FamilySize] train_df[SibSp] train_df[Parch] 1 test_df[FamilySize] test_df[SibSp] test_df[Parch] 1 train_df[IsAlone] (train_df[FamilySize] 1).astype(int) test_df[IsAlone] (test_df[FamilySize] 1).astype(int)特征工程这部分不需要做得太多。Titanic数据集本身特征不多加了家庭人数和独身标识已经足够。穷举特征很容易让模型在小数据集上过拟合得不偿失。如果后续想提高分数可以尝试从姓名里提取称谓Mr、Mrs、Miss等但在入门阶段不用急着做。2.3 划分训练验证集时最容易忽略的分层细节特征准备好之后下一步是把训练集拆成训练和验证两部分。这里有一个非常关键的细节必须使用分层抽样。Titanic数据集里的幸存者比例大约是三成多如果你不做任何控制随机切分可能让验证集里的幸存比例变成两成或者五成那验证集就不能代表真实分布了。用模型评估时就会得到偏差很大的结论。from sklearn.model_selection import train_test_split feature_cols [Pclass, Sex, Age, SibSp, Parch, Fare, Embarked_Q, Embarked_S, FamilySize, IsAlone] X_train train_df[feature_cols].copy() y_train train_df[Survived].copy() X_train[Age] X_train[Age].fillna(X_train[Age].median()) X_test test_df[feature_cols].copy() X_test[Age] X_test[Age].fillna(X_train[Age].median()) X_test[Fare] X_test[Fare].fillna(X_train[Fare].median()) X_train_fit, X_val_fit, y_train_fit, y_val_fit train_test_split( X_train, y_train, test_size0.2, random_state42, stratifyy_train )注意年龄填充的表达式先用训练集的中位数填充训练集再用同一个值去填充测试集。这个细节上文提到过这里再次强调一下因为它体现的是“测试集不可见”原则。划分完成后接下来所有标准化操作都只能基于X_train_fit这一部分来拟合。3. 把表格数据喂给网络Dataset、DataLoader与张量对齐3.1 自定义Dataset类的标准写法Pytorch处理数据的基本单位是Dataset和DataLoader。Dataset负责定义“一条样本长什么样”DataLoader负责批量打乱和装载。写自定义Dataset不需要想得太复杂只要实现三个方法初始化方法、样本数量方法和取样本方法。import torch from torch.utils.data import Dataset, DataLoader class TitanicDataset(Dataset): def __init__(self, features, labelsNone): self.features features.astype(np.float32) self.labels labels if labels is not None: self.labels labels.astype(np.float32).values def __len__(self): return len(self.features) def __getitem__(self, idx): x torch.from_numpy(self.features.iloc[idx].values) if self.labels is not None: y torch.tensor(self.labels[idx]) return x, y return x一个小细节传给模型的特征用float32因为Pytorch默认浮点类型就是float32如果用double后面很多层会因为类型不匹配报错。标签用float32也值得注意二分类的BCEWithLogitsLoss期望标签是浮点数而不是整数。很多人第一次跑的时候把标签设成长整型然后莫名其妙报错根源就在这里。测试数据没有标签所以Dataset里要做一个分支处理当labels为None时只返回特征。这样训练时用带标签的Dataset预测时用不带标签的Dataset代码逻辑清晰。3.2 标准化和数值稳定性为什么必须先fit训练集特征里年龄、票价、家庭人数的尺度差异很大如果直接喂给神经网络模型训练会变得不稳定甚至梯度爆炸。常见做法是用StandardScaler做标准化让每个连续特征变成均值为0、方差为1的分布。这里的关键在于StandardScaler只允许fit到训练集上然后transform训练集、验证集和测试集。原因是标准化本身是数据预处理的一部分如果把所有数据混在一起计算均值和标准差验证集和测试集的信息就提前泄露给了模型。这样做会让人产生一种“验证集效果不错”的乐观幻觉但真正跑新数据时可能立刻打回原形。from sklearn.preprocessing import StandardScaler scaler StandardScaler() X_train_scaled scaler.fit_transform(X_train_fit) X_val_scaled scaler.transform(X_val_fit) X_test_scaled scaler.transform(X_test)fit_transform之后得到的是numpy数组可以直接丢给Dataset。注意这里用的是训练集fit而不是全部数据fit顺序一定不能颠倒。如果先对整个X_train做fit_transform再切分本质上是用到了训练结束后才该看到的信息。不过很多人在实践里嫌麻烦会把切分往后放结果一样但逻辑上不够严谨。3.3 DataLoader使用与常见坑有了Dataset之后用DataLoader批量加载数据就非常简单了。训练集要shuffle验证集和测试集不要shuffle。shuffle的目的是让每个epoch里的batch尽量有不一样的样本组合帮助模型摆脱样本顺序带来的偏差验证集和测试集不需要打乱因为只是前向计算打乱不会改变结果反而可能让行索引对不上。train_dataset TitanicDataset(X_train_scaled, y_train_fit) val_dataset TitanicDataset(X_val_scaled, y_val_fit) test_dataset TitanicDataset(X_test_scaled, None) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue) val_loader DataLoader(val_dataset, batch_size32, shuffleFalse) test_loader DataLoader(test_dataset, batch_size32, shuffleFalse)这里有几个常见的坑值得提一下。第一batch_size的选择会影响训练稳定性和内存占用表格数据通常不需要太大32、64都很常见第二如果你在Windows系统上跑num_workers建议先用0如果设置成多进程有些环境会出现莫名报错第三如果用了GPUDataLoader里可以打开pin_memoryTrue把数据锁页后转存到显存会更快这个小数据集上体感不强但习惯要养成。4. 用Pytorch搭建一个适合表格数据的全连接网络4.1 模型结构设计取舍宽度、深度、Dropout和BatchNorm表格数据不像图像有局部空间结构所以最合适的模型就是全连接网络。一个简单的多层感知机可以拆成三块输入层接收标准化后的特征中间用非线性激活函数增加表达能力最后一层输出一个标量logit。import torch.nn as nn class TitanicMLP(nn.Module): def __init__(self, in_features): super().__init__() self.layers nn.Sequential( nn.Linear(in_features, 64), nn.ReLU(), nn.Dropout(0.3), nn.Linear(64, 32), nn.ReLU(), nn.Dropout(0.2), nn.Linear(32, 1) ) def forward(self, x): return self.layers(x)这个结构有几个值得讨论的点。第一层用64个神经元对输入特征只有10个左右的Titanic数据集来说已经不算小了第二层压缩到32整体呈漏斗形让网络先拓宽再收窄有助于提取特征组合。Dropout放在每个全连接层后面作用是随机丢弃部分神经元的输出迫使网络不依赖某一两个节点是缓解过拟合最直接的手段之一。需要注意Dropout只在训练时生效调用model.eval()之后它会被自动关闭Pytorch的封装把这件事处理得很干净。还有一个常见问题是BatchNorm要不要加。BatchNorm在图像任务里很常用但在小规模表格数据上效果并不总是好。一个原因在于样本量小BatchNorm对batch内统计量非常敏感batch一波动均值和方差也跟着波动反而造成训练不稳定。入门阶段我建议先不加把注意力放在Dropout、学习率和数据质量上等完整流程跑通后再试加BatchNorm对比效果。4.2 损失函数与输出层选择BCEWithLogitsLoss的细节二分类任务最常用的损失函数是交叉熵。在Pytorch里如果模型最后一层输出的是原始logits也就是没有经过sigmoid的实数值那么应该搭配BCEWithLogitsLoss如果模型最后一层已经加了sigmoid再输出概率那么可以用BCELoss。两者看起来等价但BCEWithLogitsLoss内部把sigmoid和loss计算合在了一起数值稳定性更好不会因为极端概率出现计算溢出的问题。因此我建议始终使用BCEWithLogitsLoss并且让模型最后一层不要加sigmoid。criterion nn.BCEWithLogitsLoss() model TitanicMLP(in_featuresX_train_scaled.shape[1]) optimizer torch.optim.Adam(model.parameters(), lr0.001)等到需要输出概率做预测或者计算AUC的时候再单独调用torch.sigmoid把logits转成概率。这个设计看起来好像多了一步实际是最稳妥的做法。很多初学者为了“方便”在模型里直接sigmoid然后配合BCELoss虽然也能跑但遇到极端值可能就不稳定了而且模型结构里混入了激活函数会让后续分析logits分布变得更麻烦。4.3 训练循环、验证循环和评估指标的完整实现手写训练循环是理解Pytorch的关键每一行都不应该跳过。训练循环大体分五步清空梯度、前向传播、计算损失、反向传播、更新参数。验证循环则在torch.no_grad()下进行只做前向计算不更新参数。def train_one_epoch(model, loader, criterion, optimizer): model.train() total_loss 0 correct 0 total 0 for xb, yb in loader: if torch.cuda.is_available(): xb xb.to(cuda) yb yb.to(cuda) optimizer.zero_grad() logits model(xb).squeeze(1) loss criterion(logits, yb) loss.backward() optimizer.step() total_loss loss.item() * len(xb) preds (torch.sigmoid(logits) 0.5).float() correct (preds yb).sum().item() total len(xb) return total_loss / total, correct / total def evaluate(model, loader, criterion): model.eval() total_loss 0 correct 0 total 0 all_preds [] all_labels [] with torch.no_grad(): for xb, yb in loader: if torch.cuda.is_available(): xb xb.to(cuda) yb yb.to(cuda) logits model(xb).squeeze(1) loss criterion(logits, yb) total_loss loss.item() * len(xb) probs torch.sigmoid(logits) preds (probs 0.5).float() correct (preds yb).sum().item() total len(xb) all_preds.extend(probs.cpu().numpy()) all_labels.extend(yb.cpu().numpy()) return total_loss / total, correct / total epochs 30 for epoch in range(epochs): train_loss, train_acc train_one_epoch(model, train_loader, criterion, optimizer) val_loss, val_acc evaluate(model, val_loader, criterion) if (epoch 1) % 5 0: print(fepoch {epoch1:03d} | train_loss {train_loss:.4f} | train_acc {train_acc:.4f} | val_loss {val_loss:.4f} | val_acc {val_acc:.4f})如果你有GPU把tensor显式放到cuda上没有GPU也没有关系这个数据集很小CPU十多秒也能跑完一轮。关键在于model.train()和model.eval()的切换忘了切换训练时Dropout不生效或者验证时Dropout仍然生效都会造成结果失真。这是我见过出现频率最高的问题之一每次写循环前都要确认这两个状态。5. 训练阶段需要观察什么从过拟合信号到提交结果5.1 观察训练/验证Loss曲线与过拟合训练过程中最需要关注的是训练loss和验证loss的相对变化。理想情况是两者都在下降最终在一个小范围内波动。如果训练loss持续下降、验证loss却开始上升模型正在过拟合说白了就是它在记忆训练样本而不是学习规律。遇到这种情况优先考虑增加Dropout强度、减小网络宽度、加L2正则项或者提前停止训练。提前停止也很简单每轮记录验证loss连续几轮不再下降就停止训练用之前验证loss最低的那一轮参数。这里和图像任务有个很大的不同表格数据没有成熟的数据增强手段。图像可以随机裁剪、翻转、加噪声来扩大样本量表格数据一旦增加样本往往只能靠生成式方法或者更复杂的框架入门阶段不需要碰。所以表格任务对抗过拟合的主要手段是模型正则化和特征工程这也从侧面说明特征质量的重要性。5.2 模型预测与CSV提交索引顺序和PassengerId对齐训练结束后对测试集预测并生成提交文件是这个项目的临门一脚。很多人在这一步栽跟头核心原因就是索引错位。DataLoader不会追踪原始DataFrame的行顺序预测结果是用数组顺序保存的数组的第i个元素对应的是test_df的第i行这个对应关系依赖你在构建特征时没有额外排序或过滤操作。只要保持整个过程是按原始顺序进行的提交文件就不会乱。model.eval() pred_probs [] with torch.no_grad(): for xb in test_loader: if torch.cuda.is_available(): xb xb.to(cuda) logits model(xb).squeeze(1) probs torch.sigmoid(logits).cpu().numpy() pred_probs.extend(probs) submission pd.DataFrame({ PassengerId: test_df[PassengerId], Survived: (np.array(pred_probs) 0.5).astype(int) }) submission.to_csv(submission.csv, indexFalse)注意三个细节第一测试集的特征标准化用的一定是之前训练好的scaler不能再重新fit第二模型先切换回eval模式再把Dropout关掉才能得到稳定的预测第三生成submission时不要用reset_index直接取test_df最开始保留的PassengerId列。只要这三件事都没问题提交文件基本不会有格式错误。5.3 我在多次实战中总结的几条直接经验这个项目虽然小但反复做几次之后有些经验比代码更值得留下来。第一固定随机种子是必须的。Pytorch、numpy、Python自带的random三处种子都要固定否则每次跑出来的结果都不一样调参时根本没有可比性。第二评估指标建议同时看准确率和AUC。准确率直观但只在0.5阈值下反映单点表现AUC能看出排序能力验证集上如果AUC不低但准确率一般说明阈值可以调整。第三数据泄漏意识要时刻在线。标准化、缺失值统计、类别映射所有需要“学习”的环节都只能在训练集上学习这个观念越早建立越好。第四小数据集禁不起大模型一旦模型参数过多验证集波动会很大不同的随机初始化可能造成完全不同的结论。做人多实验时要在固定种子基础上调整结构不然你根本分不清结果是结构带来的还是随机性带来的。整套流程跑完后我最大的体感是Pytorch做结构化数据真正的难点不在网络结构而在于把数据从pandas世界准确搬进张量世界并且在整个过程中保证信息的严格隔离。一旦你能独立完成一条清洗、划分、建loader、手写训练循环、生成提交文件的链路后续遇到任何其他表格数据都有了一套稳定可靠的起点。