Python技术迷

100行Python代码,带你优雅地搭建神经网络

哎我昨天晚上就是那个…写到一半被外卖电话打断了,回来一看 IDE 里一堆 print,笑死。反正我就想跟你说个事:你们别一上来就“我要上 PyTorch”,不是说它不好哈,是很多人第一步就把“搭网络”这件事给跳过去了,结果训练一崩就只会改 learning rate…然后就开始怀疑人生。

我这边给你整一个“能跑、能训、还能看懂”的小网络,真的就一百来行(我说的是核心代码,别抬杠哈),你拿它去喂个玩具数据,loss 能往下掉,你就能把反向传播那条链子在脑子里过一遍。像我这种老程序员,最怕“看懂了”但手上写不出来,对吧。

场景我给你换成很生活化的:就当你在做一个“垃圾短信/正常短信”的二分类(先别纠结数据),本质就是:输入一堆特征,最后输出两个概率。我们就用最土的两层全连接:Dense -> ReLU -> Dense -> Softmax,然后交叉熵。你会发现,所谓“优雅”,其实就是把每一层的 forward/backward 写清楚,梯度别丢。

代码我放下面,直接跑就行(需要 numpy)。里面我用随机造了两团点当数据,模拟二分类,别嫌土,土才好排错…我中间还留了几句注释,免得你看着像天书。

import numpy as np

# ---- utils ----
defone_hot(y, k):
    oh = np.zeros((y.size, k))
    oh[np.arange(y.size), y] = 1
return oh

defsoftmax(logits):
    z = logits - logits.max(axis=1, keepdims=True)  # 稳一点,别溢出
    exp = np.exp(z)
return exp / exp.sum(axis=1, keepdims=True)

defcross_entropy(probs, y_onehot):
    eps = 1e-12
return -np.mean(np.sum(y_onehot * np.log(probs + eps), axis=1))

# ---- layers ----
classDense:
def__init__(self, in_dim, out_dim):
# He/Xavier 随便选,这里简单点用 sqrt(2/in)
        self.W = np.random.randn(in_dim, out_dim) * np.sqrt(2.0 / in_dim)
        self.b = np.zeros((1, out_dim))
        self.x = None
        self.dW = None
        self.db = None

defforward(self, x):
        self.x = x
return x @ self.W + self.b

defbackward(self, grad_out):
# grad_out: (N, out_dim)
        self.dW = self.x.T @ grad_out / self.x.shape[0]
        self.db = grad_out.mean(axis=0, keepdims=True)
return grad_out @ self.W.T  # 传给上一层

defstep(self, lr):
        self.W -= lr * self.dW
        self.b -= lr * self.db

classReLU:
def__init__(self):
        self.mask = None

defforward(self, x):
        self.mask = (x > 0)
return x * self.mask

defbackward(self, grad_out):
return grad_out * self.mask

# ---- model ----
classMLP:
def__init__(self, in_dim, hidden_dim, out_dim):
        self.fc1 = Dense(in_dim, hidden_dim)
        self.act = ReLU()
        self.fc2 = Dense(hidden_dim, out_dim)

defforward(self, x):
        h = self.fc1.forward(x)
        h = self.act.forward(h)
        logits = self.fc2.forward(h)
        probs = softmax(logits)
return probs

defbackward(self, probs, y_onehot):
# softmax + cross-entropy 的梯度: (p - y) / N
        grad = (probs - y_onehot) / y_onehot.shape[0]
        grad = self.fc2.backward(grad)
        grad = self.act.backward(grad)
        _ = self.fc1.backward(grad)

defstep(self, lr):
        self.fc1.step(lr)
        self.fc2.step(lr)

# ---- toy data ----
np.random.seed(7)
n = 800
x0 = np.random.randn(n // 2, 2) + np.array([-2.0, 0.0])
x1 = np.random.randn(n // 2, 2) + np.array([ 2.0, 0.0])
X = np.vstack([x0, x1]).astype(np.float32)
y = np.array([0] * (n // 2) + [1] * (n // 2))
Y = one_hot(y, 2)

# 简单打乱一下,不然你会以为模型“按顺序学会了”
idx = np.random.permutation(n)
X, y, Y = X[idx], y[idx], Y[idx]

# ---- train ----
model = MLP(in_dim=2, hidden_dim=16, out_dim=2)
lr = 0.2

for epoch in range(1, 201):
    probs = model.forward(X)
    loss = cross_entropy(probs, Y)

    pred = probs.argmax(axis=1)
    acc = (pred == y).mean()

    model.backward(probs, Y)
    model.step(lr)

if epoch % 20 == 0:
        print(f"epoch={epoch:3d} loss={loss:.4f} acc={acc:.3f}")

# 你要是想“像线上那样”看看输出概率长啥样
sample = np.array([[0.0, 0.0], [-3.0, 0.0], [3.0, 0.0]], dtype=np.float32)
print("sample probs:\n", model.forward(sample))

你跑完大概率会看到 loss 从一开始的 0.6/0.7 左右慢慢往下走,acc 往 0.9 靠,别太追求 1.0 哈,这数据本来就有噪声。要是你一跑就 NaN,我跟你讲十有八九是你把 softmax 那个减 max 给删了,或者学习率开太大,反正就是那种“我以为没事”的小细节,线上最爱搞人。

然后“优雅”这俩字我怎么理解呢,就是你以后想加一层、换个激活、加个 dropout(先别加,容易把自己绕晕),你只改一小块,不会牵一身。比如你想把 ReLU 换成 Tanh,你就写个 Tanh.forward/backward,其他不用动,训练照样能跑。你们很多人写着写着就把梯度写散了,最后 debug 的时候跟抓包一样痛苦…我说真的。