机器学习入门最常踩的两个坑,一个是知道线性回归公式,但不知道如何交给深度学习框架自动求导;另一个是换到分类任务后,仍然沿用回归任务的模型结构、损失函数和评估方式。PyTorch 对新手相对友好的地方在于,张量计算、自动求导和神经网络模块都在同一套体系里,你只要把前向过程写清楚,反向传播和参数更新都可以交给框架完成。下面这条路线会从线性回归开始,理解训练循环和 autograd 的工作方式;再把同样一套基础迁移到二分类任务,搭建一个离职预测神经网络;最后集中处理叶子节点、梯度为 None、梯度爆炸以及 loss 变成 NaN 等常见报错。整个过程适合刚安装好 PyTorch、准备完成第一个机器学习小项目的读者,跑通后可以直接迁移到员工流失、客户流失、风险标记等二分类场景。
1. 先看清任务本质:线性回归和二分类差的不只是损失函数
1.1 线性回归为什么是入门第一站
线性回归的输出是一个连续数值,比如预测房价、气温、销售额。它的数学表达是y = x * w + b,在 PyTorch 里可以用一个nn.Linear(1, 1)表示。对于新手来说,线性回归最大的价值不是模型本身,而是它足够简单,能让人把注意力集中在 PyTorch 最重要的训练循环上:
- 前向计算:输入数据通过模型得到预测值。
- 计算损失:用预测值和真实值计算误差。
- 反向传播:调用
loss.backward(),让 autograd 自动计算每个参数对损失的梯度。 - 参数更新:优化器根据梯度更新模型参数。
线性回归的数据、公式、损失函数都很直观,所以一旦训练循环出问题,你能很快判断是代码问题还是模型设计问题。如果一上来就写图像分类或文本模型,各种张量维度和数据增强的问题会淹没“训练循环到底怎么工作”这条主线。
从 PyTorch 源码和底层设计来看,nn.Linear内部维护了weight和bias两个参数。这两个参数默认requires_grad=True,意味着它们需要被计算梯度并更新。线性回归里真正需要学习的也只有这两个参数。
1.2 二分类任务如何在神经网络上落地
二分类任务的输出不是连续数值,而是“属于类别 A”还是“属于类别 B”。最稳妥的做法不是让模型直接输出 0 或 1,而是输出一个概率值,再用阈值判定类别。以离职预测为例,模型可以输出“该员工离职”的概率,概率大于 0.5 判为离职,小于 0.5 判为在职。
在神经网络里,这一步通常分两层看:
- 最后一层线性层输出一个未经过激活的数值,称为 logit。
- 对 logit 做 sigmoid 变换,得到 0 到 1 之间的概率。
在损失函数选择上,二分类通常使用交叉熵损失。PyTorch 里有两个常见选择:BCELoss和BCEWithLogitsLoss。BCEWithLogitsLoss内部已经包含了 sigmoid 操作,训练时数值更稳定,推荐新手使用。如果选用BCELoss,模型输出必须自己先做torch.sigmoid(logits),而且要小心边界情况,一旦 logit 过大,经过 sigmoid 后再算 log,数值上容易出现 NaN。
1.3 用“离职预测”串起整条入门路线
离职预测是人力资源场景里的一个典型二分类问题:根据员工的满意度、工作强度、工龄、晋升情况、加班情况等特征,预测该员工在下一个周期内是否会离职。这类问题有很多现成数据集,但直接用真实姓名和真实企业数据会有隐私问题,所以本文使用模拟数据演示完整流程。只要理解了特征构造、数据拆分、标准化、模型训练和评估,迁移到真实数据时思路是相同的。
离职预测非常适合作为分类任务入门,因为:
- 特征不像图片那样需要复杂处理,理解成本低。
- 正负样本不均衡的情况很典型,需要关心准确率之外的评价指标。
- 任务结果具有业务含义,便于解释模型是否合理。
- 从线性回归到分类模型,本身就是在同一个数据问题上做任务升级。
下面先用模拟数据完成线性回归,再进入离职预测分类。两张图放在一起看,你会更清楚为什么分类模型需要 sigmoid、交叉熵和混淆矩阵。
2. 环境准备:PyTorch 版本、安装方式和最小代码验证
2.1 安装 PyTorch:CPU 环境快速跑通
学习阶段不一定非要 GPU 环境。离职预测的特征数量很少,几十个 epoch 的训练用 CPU 也能很快完成。PyTorch 官方提供 CPU 版本的安装命令,体积更小,也不依赖 CUDA 驱动。在常见 Linux 或 Windows 环境下,可以用 pip 安装:
pip install torch --index-url https://download.pytorch.org/whl/cpu如果你已经具备 NVIDIA GPU 和匹配的 CUDA 驱动,可以到 PyTorch 官网选择对应版本的安装命令。要注意,CPU 版和 GPU 版不能混装同一个环境中,否则容易出现torch.cuda.is_available()返回 False,但安装包又附带 CUDA 运行时的情况。
离线环境下安装会更复杂一些。可以在一台联网机器上下载对应 Python 版本的 wheel 包,再拷贝到离线机器安装:
pip download torch --index-url https://download.pytorch.org/whl/cpu -d torch_packages pip install torch_packages/torch-*.whl安装前先确认 Python 版本,不同 Python 版本对应的 wheel 包不同,盲目安装很容易出现No matching distribution found。推荐使用 Python 3.9 或 3.10,兼容性较好。
2.2 确认环境是否正常
安装完成后的第一个动作是检查版本和可用设备:
import torch print("PyTorch 版本:", torch.__version__) print("CUDA 是否可用:", torch.cuda.is_available()) print("GPU 名称:", torch.cuda.get_device_name(0) if torch.cuda.is_available() else "无")能正常打印版本号,说明安装成功。接着做一个最小张量运算,验证 autograd 可以正常工作:
x = torch.tensor(2.0, requires_grad=True) y = x ** 2 y.backward() print(x.grad)预期输出是tensor(4.),因为y = x^2在x=2处的导数是 4。如果这一步能跑通,说明自动求导链路正常,后续训练代码才可能正常工作。
2.3 学习环境与生产环境的差异
很多初学者在自己电脑上跑通脚本后,直接把脚本放到生产任务里,结果出现各种版本和稳定性问题。学习环境和生产环境需要关注的点并不相同:
| 环境类型 | 目的 | 推荐做法 |
|---|---|---|
| 学习环境 | 快速跑通代码、理解概念 | 使用 CPU 版,装 jupyter 或 vscode,不追求性能 |
| 开发环境 | 调试模型、调参数 | 固定 Python 和 PyTorch 版本,使用 requirements.txt 或 poetry 管理依赖 |
| 测试环境 | 验证上线前流程 | 使用与生产一致的镜像和依赖版本,确认数据路径和模型输出 |
| 生产环境 | 稳定提供服务 | 使用 Docker 镜像,锁定版本,配置监控和日志,模型文件单独保存 |
一个常见的失败案例是本地 Python 3.10、PyTorch 2.1,生产环境 Python 3.8、PyTorch 1.13,结果本地保存的模型权重无法加载。建议从开发第一天就使用虚拟环境,并把依赖版本写入文件,避免“我电脑上明明可以跑”的问题。
3. 第一阶段:用 PyTorch 手动实现线性回归
3.1 生成或准备模拟数据
为了让效果直观,这里用一条带噪声的直线来模拟真实数据:
import torch import torch.nn as nn torch.manual_seed(0) x = torch.linspace(0, 1, 100).reshape(-1, 1) y = 3.0 * x + 2.0 + torch.randn_like(x) * 0.1这里真实参数是w = 3.0、b = 2.0。加入标准差为 0.1 的高斯噪声后,x和y不会完全落在一条直线上,但模型仍然应该学到一个接近3.0和2.0的参数。
3.2 用 nn.Linear 搭建模型
PyTorch 把模型、损失函数和优化器都封装成了类:
model = nn.Linear(1, 1) loss_fn = nn.MSELoss() optimizer = torch.optim.SGD(model.parameters(), lr=0.2)nn.Linear(1, 1)表示输入维度是 1,输出维度是 1。MSELoss是均方误差,适合回归任务。SGD 的lr=0.2表示每次更新参数时,沿着梯度的反方向走 0.2 倍梯度大小。这个学习率在当前任务下够用,但不同类型任务可能需要调整。
3.3 训练循环里必须理解的三件事
下面是最小训练循环:
for epoch in range(100): pred = model(x) loss = loss_fn(pred, y) optimizer.zero_grad() loss.backward() optimizer.step() if epoch % 10 == 0: print(f"epoch {epoch:3d}, loss {loss.item():.6f}")这里的顺序不能错。先zero_grad,再backward,最后step。zero_grad的作用是把上次迭代计算出的梯度清零,否则梯度会累加到这一轮。这也是 PyTorch 新手经常踩的坑:忘记zero_grad,导致梯度越来越大,loss 不稳定。
在反向传播发生时,PyTorch 会为“需要梯度”的参数计算梯度。这里的model.weight和model.bias都是叶子张量,autograd 会把它们的梯度存放在param.grad中。optimizer.step()做的事情是:
param.data = param.data - lr * param.grad为什么访问的是param.data而不是param?因为直接对叶子张量做param -= ...会触发“in-place 操作改变叶子节点”的报错。这个概念在后面的常见问题里再展开。
3.4 线性回归的训练结果怎么看
训练结束后,打印学到的参数:
print("weight:", model.weight.item()) print("bias:", model.bias.item())正常输出会接近weight: 3.0、bias: 2.0。如果有 0.01 级别的偏差,属于正常现象,因为数据本身带了噪声。
如果 loss 下降到 0.01 以下,说明回归任务已经跑通。如果 loss 在后期仍然很大,可以优先检查学习率是否过大、数据是否没有归一化、训练循环里的zero_grad是否被遗漏。线性回归是后续分类任务的基础,建议先把这个最小闭环跑通,再进入离职预测。
4. 第二阶段:构建离职预测的神经网络分类器
4.1 离职预测要准备什么样的特征
离职预测需要构造一个表格型特征矩阵。每个样本是一个人,每一列是一个特征。结合常见人力资源分析场景,可以使用以下特征:
| 特征 | 类型 | 说明 |
|---|---|---|
| satisfaction | 连续 | 员工满意度,取值 0 到 1 |
| evaluation | 连续 | 最近一次绩效评分,取值 0 到 1 |
| projects | 离散 | 参与项目数量 |
| hours | 连续 | 月平均工作小时数 |
| tenure | 离散 | 在当前公司工作年限 |
| promotion | 离散 | 最近 5 年晋升次数 |
| overtime | 离散 | 是否加班,0 表示否,1 表示是 |
出于演示目的,我们生成模拟数据,并让“离职概率”和“满意度低、项目多、工时高、工龄长、晋升少、经常加班”这些规律相关。这样模型经过训练后能够学到有意义的信号。
import numpy as np def make_attrition_data(n=5000, seed=42): rng = np.random.default_rng(seed) satisfaction = rng.uniform(0.1, 1.0, n) evaluation = rng.uniform(0.2, 1.0, n) projects = rng.integers(2, 7, n) hours = rng.integers(120, 320, n) tenure = rng.integers(1, 11, n) promotion = rng.integers(0, 4, n) overtime = rng.integers(0, 2, n) score = ( -1.2 * satisfaction - 0.5 * evaluation + 0.3 * (projects - 4) + 0.008 * (hours - 200) + 0.12 * tenure - 0.5 * promotion + 0.6 * overtime + rng.normal(0, 0.5, n) ) prob = 1 / (1 + np.exp(-score)) y = (prob > rng.random(n)).astype(np.float32) X = np.column_stack([ satisfaction, evaluation, projects, hours, tenure, promotion, overtime ]) return X, y这段代码用score作为对数几率,再用 sigmoid 转成概率。最后用随机阈值生成 0/1 标签。这样生成的模拟数据有业务含义,同时不存在真实个人数据泄露风险。
4.2 数据预处理:归一化、划分训练集和测试集
表格数据里,hours的取值范围可能是 120 到 320,而satisfaction只在 0 到 1 之间。如果直接送进神经网络,数值范围大的特征可能在梯度计算中占据主导地位,造成训练不稳定。因此要做标准化,让每个特征均值为 0、标准差为 1。
注意划分训练集和测试集的顺序:必须先划分,再用训练集拟合标准化器,然后分别转换训练集和测试集。如果先用全量数据拟合StandardScaler,会出现测试集信息泄漏,导致评估结果偏乐观。
from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler X, y = make_attrition_data() X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 ) scaler = StandardScaler().fit(X_train) X_train = scaler.transform(X_train).astype(np.float32) X_test = scaler.transform(X_test).astype(np.float32)astype(np.float32)是为了匹配 PyTorch 默认的浮点类型。如果 X 是float64,而模型参数默认是float32,运行时会出现 dtype 不匹配的报错。
4.3 DataLoader 的作用与写法
训练神经网络时,通常不会把全部样本一次性传入模型,而是按 batch 分批训练。这样做既能降低内存压力,也能引入一定的随机性,帮助模型收敛。DataLoader就是 PyTorch 提供的数据分批工具。
import torch from torch.utils.data import TensorDataset, DataLoader train_dataset = TensorDataset( torch.from_numpy(X_train), torch.from_numpy(y_train) ) test_dataset = TensorDataset( torch.from_numpy(X_test), torch.from_numpy(y_test) ) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=256, shuffle=False)batch_size=64表示每个批次取 64 个样本。训练时shuffle=True让每个 epoch 的批次顺序不同,避免模型记住固定的数据顺序。测试时不需要 shuffle,因为评估结果不应受顺序影响。
4.4 搭建多层感知机二分类模型
离职预测的特征维度不高,但也不是线性可分,所以可以用一个简单的多层感知机。它相当于在输入和输出之间插入若干隐藏层,每层之间使用 ReLU 激活函数。
import torch.nn as nn class AttritionNet(nn.Module): def __init__(self, input_dim=7): super().__init__() self.net = nn.Sequential( nn.Linear(input_dim, 16), nn.ReLU(), nn.Linear(16, 8), nn.ReLU(), nn.Linear(8, 1) ) def forward(self, x): return self.net(x)这里最后一个nn.Linear(8, 1)输出的就是 logit,而不是概率。概率需要在评估时额外做torch.sigmoid(logits)。如果直接把 logit 传给BCELoss而不经过激活,计算出的损失会是错的。
4.5 损失函数和评估指标:不能用线性回归的 MSE 评估分类
分类任务不能沿用回归任务里的MSELoss,因为回归误差是连续的,而分类预测本质是概率判断。离职预测使用BCEWithLogitsLoss:
model = AttritionNet(input_dim=X_train.shape[1]) loss_fn = nn.BCEWithLogitsLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)BCEWithLogitsLoss会同时完成 sigmoid 和交叉熵计算,内部做了数值稳定处理。它对每个样本计算:
loss = -[y_true * log(p) + (1 - y_true) * log(1 - p)]当真实标签是 1 时,模型预测概率越接近 1,损失越小;真实标签是 0 时,模型预测概率越接近 0,损失越小。
评估时也不要只盯着准确率。离职数据往往存在类别不平衡,比如只有 20% 的员工离职。如果模型全都预测“在职”,准确率也有 80%,但对业务没有价值。需要补充精确率、召回率、F1 和混淆矩阵:
| 指标 | 含义 | 对离职预测的意义 |
|---|---|---|
| 准确率 | 预测正确的样本占比 | 数据不均衡时可能失真 |
| 精确率 | 预测为离职的人中真正离职的比例 | 防止误判太多人离职 |
| 召回率 | 真正离职的人中被找出来的比例 | 防止漏掉离职风险人群 |
| F1 | 精确率和召回率的调和平均 | 综合衡量分类能力 |
5. 叶子节点、梯度报错和训练异常:从原理到排查
5.1 叶子节点是什么,为什么 requires_grad 总报错
叶子节点是指由用户直接创建、而不是由其他张量运算得到的张量。在 autograd 体系里,它是计算图的起点。对于nn.Linear来说,weight和bias都是叶子节点,并且requires_grad=True。
新手最常见的报错是:
RuntimeError: a leaf Variable that requires grad is being used in an in-place operation.这个错误通常是因为对模型的参数做了 in-place 修改。比如:
w = torch.tensor([1.0], requires_grad=True) loss = (w * 3).sum() loss.backward() w.add_(0.1) # 报错:叶子节点发生了 in-place 修改PyTorch 在反向传播时需要依赖叶子节点的原始值。一旦叶子节点被 in-place 修改,之前保存的梯度信息就可能失效。正确做法是让优化器去更新参数,不要手动对param进行add_、sub_等操作。如果确实需要手动更新,可以操作param.data,但不推荐,因为这会绕开 autograd 的检查,容易埋下隐患。
还有一种常见误解是:requires_grad被设置为False的张量不会产生梯度。如果后续又对它的某个运算结果调用backward,整个计算图中没有可计算的叶子节点,梯度就会是None。
5.2 梯度为 None 和梯度爆炸错乱如何排查
出现param.grad is None时,按以下顺序排查:
- 该参数是否还没有被优化器或 backward 作用。
- 该参数是否被
requires_grad_(False)关闭了梯度。 - 该参数是否没有参与计算图中的任何损失计算。
- 使用
with torch.no_grad()后,后续操作不会记录梯度。
对应检查代码:
for name, param in model.named_parameters(): print(name, param.requires_grad, param.grad is not None)如果某个参数打印出requires_grad=False,可以检查模型构建是否意外调用了freeze或detach。对分类模型来说,隐藏层参数和输出层参数都应该参与训练。
梯度爆炸是反向传播过程中梯度值不断增大,导致参数变化过大,loss 变成 NaN。常见原因有:
- 学习率过大。
- 特征没有标准化,导致某些特征方向梯度很大。
- 损失函数计算错误,比如分类任务误用回归损失。
- 网络层数过深,且没有残差或归一化结构。
排查时可以打印每一层的梯度范数:
for name, param in model.named_parameters(): if param.grad is not None: print(name, param.grad.norm().item())如果梯度范数达到几百甚至上千,优先降低学习率,或检查特征是否做了标准化。
5.3 训练 loss 一直是 NaN 的常见原因
loss从第一个 epoch 开始就是nan,通常不是模型容量问题,而是数据和损失计算的问题。按优先级检查:
- 输入数据是否包含
nan或inf,使用np.isnan(X).any()和np.isinf(X).any()检查。 - 学习率是否过大,比如线性任务使用
lr=10,参数会直接发散。 - 是否用了
BCELoss但没有做 sigmoid,或 logit 过大导致 log(0)。 - 是否在标准化前把
float64转成了float16,半精度计算出现下溢。
对于离职预测这种表格任务,nan概率最高的是数据问题和学习率问题。加入 L2 正则或降低lr是快速验证手段。
5.4 常见报错与处理对照表
| 报错现象 | 可能原因 | 处理方式 |
|---|---|---|
a leaf Variable that requires grad is being used in an in-place operation | 对模型参数直接 in-place 修改 | 使用优化器更新参数,或操作param.data并自行负责正确性 |
Trying to backward through the graph a second time | 同一个计算图被第二次backward,且没有retain_graph=True | 训练循环内每轮重新前向计算;确要复用图时指定retain_graph=True |
grad is None | 参数没有参与损失计算,或requires_grad=False | 检查模型和 forward 是否把该参数连接到了输出 |
Expected dtype Double but found Float | 输入是float64,模型参数是float32 | 输入统一转成np.float32或torch.float32 |
loss is nan | 学习率过大、数据含 NaN、损失函数用法错误 | 降低 lr,检查数据,改用BCEWithLogitsLoss |
Found no compiled module或 CUDA 相关报错 | CPU 版和 GPU 版混装,或驱动不匹配 | 重建虚拟环境,按官方命令重新安装对应版本 |
对于新手来说,看到报错先读第一行和最后一行,再配合torch.__version__、张量 shape、requires_grad状态一起检查,通常能快速定位问题。
6. 完整训练脚本与结果验证
6.1 把数据处理、训练、评估打包成一个脚本
前面分步讲解的模块,可以合并成一个可运行的脚本。代码里的注释说明关键步骤:
import numpy as np import torch import torch.nn as nn from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from torch.utils.data import TensorDataset, DataLoader def make_attrition_data(n=5000, seed=42): rng = np.random.default_rng(seed) satisfaction = rng.uniform(0.1, 1.0, n) evaluation = rng.uniform(0.2, 1.0, n) projects = rng.integers(2, 7, n) hours = rng.integers(120, 320, n) tenure = rng.integers(1, 11, n) promotion = rng.integers(0, 4, n) overtime = rng.integers(0, 2, n) score = ( -1.2 * satisfaction - 0.5 * evaluation + 0.3 * (projects - 4) + 0.008 * (hours - 200) + 0.12 * tenure - 0.5 * promotion + 0.6 * overtime + rng.normal(0, 0.5, n) ) prob = 1 / (1 + np.exp(-score)) y = (prob > rng.random(n)).astype(np.float32) X = np.column_stack([ satisfaction, evaluation, projects, hours, tenure, promotion, overtime ]) return X, y class AttritionNet(nn.Module): def __init__(self, input_dim=7): super().__init__() self.net = nn.Sequential( nn.Linear(input_dim, 16), nn.ReLU(), nn.Linear(16, 8), nn.ReLU(), nn.Linear(8, 1) ) def forward(self, x): return self.net(x) def evaluate(model, loader, loss_fn): model.eval() total = 0 correct = 0 total_loss = 0.0 all_pred = [] all_true = [] with torch.no_grad(): for X_batch, y_batch in loader: y_batch = y_batch.unsqueeze(1) logits = model(X_batch) loss = loss_fn(logits, y_batch) total_loss += loss.item() * X_batch.size(0) prob = torch.sigmoid(logits) pred = (prob >= 0.5).float() total += y_batch.size(0) correct += (pred == y_batch).sum().item() all_pred.append(pred.squeeze(1).cpu().numpy()) all_true.append(y_batch.squeeze(1).cpu().numpy()) avg_loss = total_loss / total acc = correct / total return avg_loss, acc, np.concatenate(all_pred), np.concatenate(all_true) X, y = make_attrition_data() X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 ) scaler = StandardScaler().fit(X_train) X_train = scaler.transform(X_train).astype(np.float32) X_test = scaler.transform(X_test).astype(np.float32) train_dataset = TensorDataset( torch.from_numpy(X_train), torch.from_numpy(y_train) ) test_dataset = TensorDataset( torch.from_numpy(X_test), torch.from_numpy(y_test) ) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=256, shuffle=False) torch.manual_seed(0) model = AttritionNet(input_dim=X_train.shape[1]) loss_fn = nn.BCEWithLogitsLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) for epoch in range(30): model.train() epoch_loss = 0.0 epoch_correct = 0 epoch_total = 0 for X_batch, y_batch in train_loader: y_batch = y_batch.unsqueeze(1) logits = model(X_batch) loss = loss_fn(logits, y_batch) optimizer.zero_grad() loss.backward() optimizer.step() epoch_loss += loss.item() * X_batch.size(0) pred = (torch.sigmoid(logits) >= 0.5).float() epoch_correct += (pred == y_batch).sum().item() epoch_total += y_batch.size(0) train_loss = epoch_loss / epoch_total train_acc = epoch_correct / epoch_total val_loss, val_acc, _, _ = evaluate(model, test_loader, loss_fn) if epoch % 5 == 0 or epoch == 29: print( f"epoch {epoch:2d}, " f"train_loss {train_loss:.4f}, " f"train_acc {train_acc:.4f}, " f"val_loss {val_loss:.4f}, " f"val_acc {val_acc:.4f}" ) _, _, y_pred, y_true = evaluate(model, test_loader, loss_fn) tn = np.sum((y_true == 0) & (y_pred == 0)) fp = np.sum((y_true == 0) & (y_pred == 1)) fn = np.sum((y_true == 1) & (y_pred == 0)) tp = np.sum((y_true == 1) & (y_pred == 1)) print("混淆矩阵:") print(f" TN={tn} FP={fp}") print(f" FN={fn} TP={tp}")这段脚本是完整的可运行版本,顺序上先造数据,再拆分和标准化,然后定义模型和训练循环,最后输出混淆矩阵。运行前需要安装torch和scikit-learn:
pip install torch scikit-learn实际项目中,不建议在脚本里随机生成数据,而是替换成从数据库、数据仓库或 CSV 文件读取数据,并保证数据接口稳定。
6.2 运行结果示例
运行脚本后,会看到类似下面的输出:
epoch 0, train_loss 0.6512, train_acc 0.6945, val_loss 0.6321, val_acc 0.7170 epoch 5, train_loss 0.5104, train_acc 0.7792, val_loss 0.5003, val_acc 0.7930 epoch 10, train_loss 0.4728, train_acc 0.8031, val_loss 0.4693, val_acc 0.8120 epoch 15, train_loss 0.4556, train_acc 0.8168, val_loss 0.4520, val_acc 0.8202 epoch 20, train_loss 0.4439, train_acc 0.8276, val_loss 0.4434, val_acc 0.8315 epoch 25, train_loss 0.4352, train_acc 0.8345, val_loss 0.4355, val_acc 0.8368 epoch 29, train_loss 0.4301, train_acc 0.8398, val_loss 0.4310, val_acc 0.8412这里给出的是基于模拟数据规律的参考范围,不保证每次完全一致。重点观察两个趋势:训练 loss 逐步下降,验证准确率逐步上升。如果验证 loss 在某个 epoch 后开始持续上升,而训练 loss 还在下降,说明出现了过拟合,可以增加正则化或减少网络层数。
6.3 如何判断模型真的可用
不能只凭准确率判断模型可用。离职本来就是少数事件,可能 80% 的员工不会离职,所以一个全部预测“在职”的模型也能拿到 80% 准确率。这时必须看混淆矩阵:
- 如果 TP 很低,说明大量真正离职的人没有被识别出来,模型召回率差。
- 如果 FP 很高,说明大量在职员工被误判为要离职,模型精确率差。
- 如果 FN 高而 FP 低,说明模型过于保守。
实际业务中,识别离职风险的代价和误判的代价不同。多数情况下,宁可多圈出一些需要关注的人,也不想漏掉真正要离职的人,所以召回率优先级更高。这时可以把判断阈值从 0.5 降到 0.3,再评估精确率和召回率的变化。阈值调整不是模型行为,只是决策规则,可以在不重新训练的情况下完成。
7. 离职预测的工程化落地和最佳实践
7.1 从实验模型到可用模型还差什么
模型在 Jupyter Notebook 里准确率达到 85%,距离真正上线还有一段路。工程化需要补齐以下内容:
- 特征管线固化:把数据读取、缺失值处理、标准化、特征工程写成可重复调用的函数,而不是散落在训练脚本里。
- 模型文件持久化:训练结束后保存模型权重,部署时只加载权重,不需要重新训练。
torch.save(model.state_dict(), "attrition_model.pt")- 标准化器持久化:上线预测时,新数据必须用训练时保存的
scaler转换,不能重新 fit。可以将标准化参数保存为np.save或 pickle 文件。
import joblib joblib.dump(scaler, "scaler.pkl")- 预测接口:如果是离线批量预测,可以写一个定时任务读取最新员工数据,输出风险名单。如果是实时接口,可以用 FastAPI 封装,接收特征 JSON,返回离职概率。
from fastapi import FastAPI import numpy as np app = FastAPI() @app.post("/predict") def predict(features: list[float]): x = np.array(features, dtype=np.float32).reshape(1, -1) x = scaler.transform(x) x_t = torch.from_numpy(x) model.eval() with torch.no_grad(): logit = model(x_t) prob = torch.sigmoid(logit).item() return {"attrition_probability": prob}这段代码只演示接口形态,实际要加入参数校验、日志、版本号和异常处理。
7.2 参数选择建议
离职预测这类中小型表格任务,参数选择不需要追求非常深的网络。推荐从一个不太复杂的模型开始,先确认数据流程正确,再调参:
| 参数 | 常见范围 | 调小影响 | 调大影响 |
|---|---|---|---|
| learning_rate | 0.0001 到 0.01 | 收敛慢,可能需要更多 epoch | 训练不稳定,loss 容易震荡或 NaN |
| batch_size | 32 到 128 | 梯度过拟合到小批次,训练慢 | 训练稳定但更新次数少 |
| hidden_units | 8 到 64 | 模型容量不足 | 容易过拟合,训练变慢 |
| epoch | 20 到 100 | 欠拟合风险 | 过拟合风险 |
| 隐藏层数 | 1 到 3 层 | 表达能力弱 | 参数多,数据少时过拟合 |
对离职预测来说,50 个 epoch 以内的模型通常已经足够。如果发现验证指标停滞,继续增大 epoch 并不会有多少收益,不如先检查特征质量和数据量。
7.3 发布前检查清单
在把模型发布到测试或生产环境前,建议逐项确认:
- 是否统一了随机种子,保证训练结果可复现。
- 训练集、验证集、测试集是否严格分开,标准化器只在训练集上 fit。
- 是否处理了缺失值和异常值,字段类型是否统一。
- 是否保存了模型权重和标准化器,并在预测代码中按相同顺序加载。
- 是否记录了数据版本、训练时间、特征列表和超参数。
- 是否监控线上预测分布变化,防止业务数据漂移。
- 是否定义了预测结果的业务动作,比如触发关注、访谈、调薪或培训。
7.4 下一步扩展方向
离职预测跑通后,可以在以下几个方向继续深入:
- 使用
sklearn的LogisticRegression、RandomForest、XGBoost作为 baseline,和神经网络对比。 - 引入类别不平衡处理,比如
class_weight、过采样、欠采样或Focal Loss。 - 使用
SHAP分析每个特征对预测结果的影响,让模型可解释。 - 使用交叉验证替代单次随机划分,更稳定地评估模型性能。
- 将同样思路迁移到客户流失、设备故障、贷款违约等二分类问题。
- 如果特征变成文本或时序数据,可以继续学习 RNN、CNN、Transformer,但分类头、损失函数和评估方式仍然相同。
回到最开始看到的两个坑:线性回归兼容不了分类任务,分类评估也不能只看准确率。PyTorch 的学习核心不是背 API,而是理解计算图、数据维度和损失函数之间的关系。只要把从线性回归到离职预测的这条链路完整跑通,再遇到新的二分类问题,你完全可以按照同样的顺序处理:定义任务、准备数据、拆分训练集、搭建结构、选损失函数、训练、评估、保存和部署。建议新手把本文中的脚本保存为attrition_demo.py,在本地反复运行并修改参数,直到你能不看代码也说出训练循环里四个步骤的顺序和原因,这才是入门 PyTorch 最可靠的标志。