news 2026/9/7 12:58:41

PyTorch入门:从线性回归到二分类神经网络的训练实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PyTorch入门:从线性回归到二分类神经网络的训练实践

机器学习入门最常踩的两个坑,一个是知道线性回归公式,但不知道如何交给深度学习框架自动求导;另一个是换到分类任务后,仍然沿用回归任务的模型结构、损失函数和评估方式。PyTorch 对新手相对友好的地方在于,张量计算、自动求导和神经网络模块都在同一套体系里,你只要把前向过程写清楚,反向传播和参数更新都可以交给框架完成。下面这条路线会从线性回归开始,理解训练循环和 autograd 的工作方式;再把同样一套基础迁移到二分类任务,搭建一个离职预测神经网络;最后集中处理叶子节点、梯度为 None、梯度爆炸以及 loss 变成 NaN 等常见报错。整个过程适合刚安装好 PyTorch、准备完成第一个机器学习小项目的读者,跑通后可以直接迁移到员工流失、客户流失、风险标记等二分类场景。

1. 先看清任务本质:线性回归和二分类差的不只是损失函数

1.1 线性回归为什么是入门第一站

线性回归的输出是一个连续数值,比如预测房价、气温、销售额。它的数学表达是y = x * w + b,在 PyTorch 里可以用一个nn.Linear(1, 1)表示。对于新手来说,线性回归最大的价值不是模型本身,而是它足够简单,能让人把注意力集中在 PyTorch 最重要的训练循环上:

  1. 前向计算:输入数据通过模型得到预测值。
  2. 计算损失:用预测值和真实值计算误差。
  3. 反向传播:调用loss.backward(),让 autograd 自动计算每个参数对损失的梯度。
  4. 参数更新:优化器根据梯度更新模型参数。

线性回归的数据、公式、损失函数都很直观,所以一旦训练循环出问题,你能很快判断是代码问题还是模型设计问题。如果一上来就写图像分类或文本模型,各种张量维度和数据增强的问题会淹没“训练循环到底怎么工作”这条主线。

从 PyTorch 源码和底层设计来看,nn.Linear内部维护了weightbias两个参数。这两个参数默认requires_grad=True,意味着它们需要被计算梯度并更新。线性回归里真正需要学习的也只有这两个参数。

1.2 二分类任务如何在神经网络上落地

二分类任务的输出不是连续数值,而是“属于类别 A”还是“属于类别 B”。最稳妥的做法不是让模型直接输出 0 或 1,而是输出一个概率值,再用阈值判定类别。以离职预测为例,模型可以输出“该员工离职”的概率,概率大于 0.5 判为离职,小于 0.5 判为在职。

在神经网络里,这一步通常分两层看:

  • 最后一层线性层输出一个未经过激活的数值,称为 logit。
  • 对 logit 做 sigmoid 变换,得到 0 到 1 之间的概率。

在损失函数选择上,二分类通常使用交叉熵损失。PyTorch 里有两个常见选择:BCELossBCEWithLogitsLossBCEWithLogitsLoss内部已经包含了 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^2x=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.0b = 2.0。加入标准差为 0.1 的高斯噪声后,xy不会完全落在一条直线上,但模型仍然应该学到一个接近3.02.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,最后stepzero_grad的作用是把上次迭代计算出的梯度清零,否则梯度会累加到这一轮。这也是 PyTorch 新手经常踩的坑:忘记zero_grad,导致梯度越来越大,loss 不稳定。

在反向传播发生时,PyTorch 会为“需要梯度”的参数计算梯度。这里的model.weightmodel.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.0bias: 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来说,weightbias都是叶子节点,并且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时,按以下顺序排查:

  1. 该参数是否还没有被优化器或 backward 作用。
  2. 该参数是否被requires_grad_(False)关闭了梯度。
  3. 该参数是否没有参与计算图中的任何损失计算。
  4. 使用with torch.no_grad()后,后续操作不会记录梯度。

对应检查代码:

for name, param in model.named_parameters(): print(name, param.requires_grad, param.grad is not None)

如果某个参数打印出requires_grad=False,可以检查模型构建是否意外调用了freezedetach。对分类模型来说,隐藏层参数和输出层参数都应该参与训练。

梯度爆炸是反向传播过程中梯度值不断增大,导致参数变化过大,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,通常不是模型容量问题,而是数据和损失计算的问题。按优先级检查:

  • 输入数据是否包含naninf,使用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.float32torch.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}")

这段脚本是完整的可运行版本,顺序上先造数据,再拆分和标准化,然后定义模型和训练循环,最后输出混淆矩阵。运行前需要安装torchscikit-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%,距离真正上线还有一段路。工程化需要补齐以下内容:

  1. 特征管线固化:把数据读取、缺失值处理、标准化、特征工程写成可重复调用的函数,而不是散落在训练脚本里。
  2. 模型文件持久化:训练结束后保存模型权重,部署时只加载权重,不需要重新训练。
torch.save(model.state_dict(), "attrition_model.pt")
  1. 标准化器持久化:上线预测时,新数据必须用训练时保存的scaler转换,不能重新 fit。可以将标准化参数保存为np.save或 pickle 文件。
import joblib joblib.dump(scaler, "scaler.pkl")
  1. 预测接口:如果是离线批量预测,可以写一个定时任务读取最新员工数据,输出风险名单。如果是实时接口,可以用 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_rate0.0001 到 0.01收敛慢,可能需要更多 epoch训练不稳定,loss 容易震荡或 NaN
batch_size32 到 128梯度过拟合到小批次,训练慢训练稳定但更新次数少
hidden_units8 到 64模型容量不足容易过拟合,训练变慢
epoch20 到 100欠拟合风险过拟合风险
隐藏层数1 到 3 层表达能力弱参数多,数据少时过拟合

对离职预测来说,50 个 epoch 以内的模型通常已经足够。如果发现验证指标停滞,继续增大 epoch 并不会有多少收益,不如先检查特征质量和数据量。

7.3 发布前检查清单

在把模型发布到测试或生产环境前,建议逐项确认:

  • 是否统一了随机种子,保证训练结果可复现。
  • 训练集、验证集、测试集是否严格分开,标准化器只在训练集上 fit。
  • 是否处理了缺失值和异常值,字段类型是否统一。
  • 是否保存了模型权重和标准化器,并在预测代码中按相同顺序加载。
  • 是否记录了数据版本、训练时间、特征列表和超参数。
  • 是否监控线上预测分布变化,防止业务数据漂移。
  • 是否定义了预测结果的业务动作,比如触发关注、访谈、调薪或培训。

7.4 下一步扩展方向

离职预测跑通后,可以在以下几个方向继续深入:

  • 使用sklearnLogisticRegressionRandomForestXGBoost作为 baseline,和神经网络对比。
  • 引入类别不平衡处理,比如class_weight、过采样、欠采样或Focal Loss
  • 使用SHAP分析每个特征对预测结果的影响,让模型可解释。
  • 使用交叉验证替代单次随机划分,更稳定地评估模型性能。
  • 将同样思路迁移到客户流失、设备故障、贷款违约等二分类问题。
  • 如果特征变成文本或时序数据,可以继续学习 RNN、CNN、Transformer,但分类头、损失函数和评估方式仍然相同。

回到最开始看到的两个坑:线性回归兼容不了分类任务,分类评估也不能只看准确率。PyTorch 的学习核心不是背 API,而是理解计算图、数据维度和损失函数之间的关系。只要把从线性回归到离职预测的这条链路完整跑通,再遇到新的二分类问题,你完全可以按照同样的顺序处理:定义任务、准备数据、拆分训练集、搭建结构、选损失函数、训练、评估、保存和部署。建议新手把本文中的脚本保存为attrition_demo.py,在本地反复运行并修改参数,直到你能不看代码也说出训练循环里四个步骤的顺序和原因,这才是入门 PyTorch 最可靠的标志。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 12:55:30

C#跨平台移动工业监控:从TCP Socket到MVVM的完整实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 12:52:52

数据备份系统设计与实现:从增量备份到快速恢复的工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 12:52:35

网站模板二次修改太痛苦?组件划分规范让效率翻倍

简介:一套基于 Vue.js 的门户网站模板源码,定位给需要快速搭建企业展示、产品介绍或新闻资讯类站点的前端开发者与设计人员。项目采用组件化划分,导航、内容区块、侧栏、底部等模块边界清晰,移动端与 PC 端自适应适配,…

作者头像 李华
网站建设 2026/9/7 12:50:18

GPU图形计算链路全解析:从线程束到光栅化的硬件运作

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 12:50:00

从市场到财务:奶茶品牌战略规划92页方案的五层拆解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 12:49:42

零基础学51单片机:2026新版教程与开发板实战避坑指南

做嵌入式这些年,我经常被人问:零基础学单片机到底该买什么板子、跟谁学才不会走弯路?我过去会列一堆资料,后来发现多数新手根本看不完,真正能让人学进去的,就是一套结构清晰的视频教程加一块能练手的开发板…

作者头像 李华