本节你会学到
- 说清楚「从零搭建二维分类神经网络:模型到底怎么分开两类点」解决的核心问题
- 知道它在「深度学习项目」中的位置
- 把 神经网络、Linear 层、分类与回归 这些关键词联系起来
线性回归是连续预测,分类是离散判断。这篇从零搭建一个二分类神经网络,用二维数据让你能"看到"模型怎么把两类点分开。
学习路线:深度学习项目 · 第 60 课
通过回归、二维分类、表格分类和图像分类项目,把 Dataset、模型、损失、优化器、评估和推理串起来。
学完本阶段你能做到:能独立完成一个完整深度学习项目,从数据到推理全流程,并整理出可复用的项目目录结构。
推荐读法:建议顺序阅读并同步跑代码;每个项目都要形成可复用目录。
查看完整阶段 · 10 篇先看目标,再带着问题读正文。读完后用练习确认自己真的理解了。
先检查模型参数、输入 Tensor、标签 Tensor 是否在同一个设备上。常见修正是把它们统一 `.to(device)`。
因为训练是否正确经常取决于 shape、dtype、device 和梯度流,API 名字只能告诉你工具,不会保证数据契约正确。
`输入数据 → forward → loss → backward → optimizer.step()`,并知道本文主题位于这条链路的哪一环。
上一篇我们用线性回归跑通了完整流程。今天升级到分类任务——模型不再预测一个数字,而是判断“属于哪一类”。
用二维数据是因为你能画出来——亲眼看到模型把两类点分开,比看 loss 曲线直观一百倍。
第 29 篇讲过逻辑回归——分类的本质是用 Sigmoid 把输出压到 0-1 之间表示概率。第 46 篇讲过二分类的 Shape 约定:输出 [B, 1],标签 [B, 1] 且 float,用 BCEWithLogitsLoss。第 50 篇讲了怎么用 nn.Module 定义模型。今天把它们合起来。
造两组二维点(比如两个簇),训练一个神经网络把它们分开。模型输入 [x1, x2],输出“属于第 1 类的概率”。
import torch
import matplotlib.pyplot as plt
torch.manual_seed(42)
# 两类点,各 200 个
n = 200
# 类别 0:中心在 (2, 2)
x0 = torch.randn(n, 2) + torch.tensor([2.0, 2.0])
y0 = torch.zeros(n, 1)
# 类别 1:中心在 (-2, -2)
x1 = torch.randn(n, 2) + torch.tensor([-2.0, -2.0])
y1 = torch.ones(n, 1)
# 拼接
x = torch.cat([x0, x1], dim=0) # [400, 2]
y = torch.cat([y0, y1], dim=0) # [400, 1]
# 打乱
idx = torch.randperm(len(x))
x, y = x[idx], y[idx]
print(f"x shape: {x.shape}") # [400, 2]
print(f"y shape: {y.shape}") # [400, 1]
print(f"y dtype: {y.dtype}") # float
# 可视化
plt.scatter(x0[:, 0], x0[:, 1], label='类别 0', alpha=0.5)
plt.scatter(x1[:, 0], x1[:, 1], label='类别 1', alpha=0.5)
plt.legend()
plt.title('训练数据')
plt.show()
from torch.utils.data import TensorDataset, DataLoader
dataset = TensorDataset(x, y)
train_loader = DataLoader(dataset, batch_size=32, shuffle=True)
import torch.nn as nn
class BinaryClassifier(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Linear(2, 16), # 输入 2 维 → 隐藏 16 维
nn.ReLU(),
nn.Linear(16, 8), # 隐藏 16 → 隐藏 8
nn.ReLU(),
nn.Linear(8, 1) # 隐藏 8 → 输出 1(logit)
)
def forward(self, x):
return self.net(x) # [B, 2] → [B, 1]
model = BinaryClassifier()
关键:最后一层是 Linear(8, 1),输出的是 logit,不是概率。不要在这里加 Sigmoid——BCEWithLogitsLoss 内部自带。
criterion = nn.BCEWithLogitsLoss() # 二分类标准损失
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
| 选择 | 理由 |
|---|---|
BCEWithLogitsLoss |
二分类标准,内部带 Sigmoid |
Adam |
入门友好 |
lr=0.01 |
简单数据,稍大收敛快 |
epochs = 50
for epoch in range(epochs):
model.train()
total_loss = 0
correct = 0
total = 0
for batch_x, batch_y in train_loader:
optimizer.zero_grad()
logits = model(batch_x) # [B, 1]
loss = criterion(logits, batch_y) # batch_y: [B, 1], float
loss.backward()
optimizer.step()
total_loss += loss.item()
# 准确率:logit > 0 等价于 sigmoid > 0.5 等价于 预测类别 1
preds = (logits > 0).float()
correct += (preds == batch_y).sum().item()
total += batch_y.size(0)
if (epoch + 1) % 10 == 0:
print(f"Epoch {epoch+1}: Loss={total_loss/total:.4f}, Acc={correct/total:.4f}")
典型输出:
Epoch 10: Loss=0.2341, Acc=0.9175
Epoch 20: Loss=0.1023, Acc=0.9750
Epoch 30: Loss=0.0512, Acc=0.9925
Epoch 40: Loss=0.0287, Acc=0.9975
Epoch 50: Loss=0.0178, Acc=1.0000
model.eval()
with torch.no_grad():
logits = model(x) # 全部数据
probs = torch.sigmoid(logits) # 转成概率
preds = (probs > 0.5).float() # 0.5 为阈值
accuracy = (preds == y).float().mean()
print(f"总准确率: {accuracy:.4f}")
# 模型输出的是 logit(未归一化的分数)
logit = model(x) # 可以是任意实数,如 -3.2 或 5.1
# 用 Sigmoid 转成概率
prob = torch.sigmoid(logit) # 压缩到 (0, 1)
# 0.5 为分类阈值
pred = (prob > 0.5).float() # >0.5 → 1,否则 → 0
为什么不直接在模型里加 Sigmoid? 因为 BCEWithLogitsLoss 内部用了一个数值更稳定的公式(LogSumExp 技巧),比手动 Sigmoid + BCELoss 更安全。
# ❌ 标签是 long(多分类的写法)
y = torch.zeros(n, 1).long()
criterion(logits, y) # 报错
# ✅ 二分类标签是 float
y = torch.zeros(n, 1).float()
第 46 篇的表:多分类标签 long,二分类标签 float。
# ❌ 重复 Sigmoid
self.net = nn.Sequential(..., nn.Linear(8, 1), nn.Sigmoid())
criterion = nn.BCEWithLogitsLoss() # 内部又 Sigmoid 一次
# ✅ 不要加
self.net = nn.Sequential(..., nn.Linear(8, 1))
# 技术上没错(logit > 0 等价 prob > 0.5),但不直观
preds = (logits > 0).float()
# 更清晰的写法
probs = torch.sigmoid(logits)
preds = (probs > 0.5).float()
练习 1:把两类点的中心改成 (1, 1) 和 (-1, -1)(更近),观察准确率变化。
练习 2:模型只有一层 nn.Linear(2, 1)(没有隐藏层和 ReLU),能分开这两类点吗?为什么?
练习 3:怎么把这个二分类改成三分类?列出需要改的地方。
练习 1:两类中心更近,重叠更多,准确率会下降。可能需要更深的模型或更多训练。
练习 2:能。因为这两类点是线性可分的(两个簇不重叠),一条直线就能分开。nn.Linear(2, 1) 就是画一条线。但如果数据不是线性可分的(比如同心圆),单层就不够,需要隐藏层 + 激活函数引入非线性。
练习 3:
nn.Linear(8, 3),输出 3 个 logitnn.CrossEntropyLoss()[B](1 维),类型 longtorch.argmax(logits, dim=1)BCEWithLogitsLoss[B, 1],类型 float——和多分类不同torch.sigmoid 更清晰下一篇把这个分类结果画出来——决策边界可视化,看模型到底学出了什么形状。
留言讨论