**从“只会调库”到“彻底搞懂反向传播”,Karpathy 的这门课让无数学历黯然失色**

**从“只会调库”到“彻底搞懂反向传播”,Karpathy 的这门课让无数学历黯然失色**

从“只会调库”到“彻底搞懂反向传播”,Karpathy 的这门课让无数学历黯然失色


如果你曾经对着 PyTorch、TensorFlow 的高层 API 感到困惑,不知道那些自动求导到底在背后做了什么;如果你在阅读论文时遇到 “autograd” 、“计算图”、“梯度下降” 这些词就头皮发麻;如果你想知道那些看起来神秘的神经网络到底是怎么从零开始构建的——那么 karpathy/nn-zero-to-hero 这个仓库,可能是你目前能找到的最硬核、也是最接地气的学习资源。

这不仅仅是另一个“教程仓库”。它来自特斯拉前 AI 总监、李飞飞的高徒、被无数人奉为“深度学习布道师”的 Andrej Karpathy。他的视频课程从零构建神经网络,至今在 YouTube 上有数百万播放量,而这个 GitHub 仓库,则是课程的完整代码实现——从最原始的 scalar 运算,到能生成莎士比亚文本的字符级语言模型,全部手写完成,没有一行调库的魔法。

在本文中,我会带着你完整走一遍这个仓库的核心内容,包括环境搭建、核心概念、代码逐行解析、以及如何把它应用到你的实际项目中。无论你是正在学习机器学习的学生,还是想深入理解深度学习原理的工程师,这篇文章都会是你的完整指南。


为什么这个项目值得关注

让我们先回答一个根本问题:为什么要学一个看起来“重复造轮子”的项目?

在深度学习框架已经极其成熟的今天,大多数人使用 PyTorch 或 TensorFlow 时,往往停留在“搭积木”的层面。你写一个 model = nn.Sequential(...),然后调用 loss.backward(),最后 optimizer.step()。整个训练流程跑得通,但你真的理解每一步发生了什么吗?

Karpathy 在他的课程中反复强调一个观点:“如果你不能从零开始实现它,你就不是真的理解它。” 这句话听起来有些极端,但在实际科研和工程中,这种理解深度的差距会在你调试奇怪 bug、设计新架构、或者阅读前沿论文时显现出来。

这个仓库的价值在于,它把神经网络的“知识金字塔”倒过来搭:不是先讲理论再展示代码,而是先让你看到一个能跑的程序,然后逐层剥开,告诉你每一行代码背后的数学原理和工程考量。你会亲眼看到:

一个可以生成名字的神经网络是怎么炼成的。 你会看到最原始的 embedding、rnn/lstm/transformer 的亲手实现、以及训练循环中的每一个细节。这种学习体验是读十篇博客都比不上的。


环境搭建:五分钟启动你的实验环境

让我们先把这套代码跑起来。整个仓库的依赖非常简单,但你需要确保 Python 版本和必要的包都正确安装。

第一步:确认 Python 环境

建议使用 Python 3.8 或更高版本。你可以用以下命令检查:

python3 --version

如果看到类似 Python 3.10.9 的输出,说明环境满足要求。

第二步:创建虚拟环境(推荐)

为了避免依赖冲突,建议使用 conda 或 venv 创建一个独立环境:

conda create -n nn-zero-to-hero python=3.10
conda activate nn-zero-to-hero

或者使用 venv:

python3 -m venv nn_env
source nn_env/bin/activate  # Linux/Mac
nn_env\Scripts\activate     # Windows

第三步:安装基础依赖

这个仓库的核心是纯 Python 实现的 micrograd,它只需要 numpy。如果你想运行完整实验(包括 makemore),还需要一些额外的包:

pip install numpy torch

第四步:克隆仓库并验证安装

git clone https://github.com/karpathy/nn-zero-to-hero.git
cd nn-zero-to-hero

仓库结构大致如下:

nn-zero-to-hero/
├── makemore/          # 字符级语言模型
├── micrograd/         # 自动求导核心
├── videos/            # 视频对应的 Jupyter notebooks
└── README.md

你可以先用 micrograd 做一个小测试:

cd micrograd
python
>>> from micrograd import nn
>>> print(nn)

如果没有报错,说明安装成功。


核心概念详解:从最底层的自动求导说起

这个仓库最核心的价值在于 micrograd——一个从零实现的自动求导引擎。理解了这部分,你就理解了现代深度学习框架的精髓。

什么是计算图?

在正式开始之前,我们需要理解一个关键概念:计算图

想象你做一道数学题:假设你要计算 (a + b) * c,你会怎么做?通常你会先算 a + b = d,然后算 d * c = result。这个“一步步记录计算过程”的结构,就是计算图。

在神经网络中,所有的前向传播(输入如何变成输出)都可以表示为一个计算图。而反向传播(梯度如何从输出传回输入),就是沿着这个图的反方向计算每个节点对最终结果的“贡献度”。

micrograd 的核心:Value 类

打开 micrograd 目录,你会看到一个 value.py 文件。这是一切的核心:

class Value:
    """存储数据以及对应的梯度,并记录计算历史"""

    def __init__(self, data, _children=(), _op=''):
        # data: 这个节点存储的实际数值
        self.data = data
        # grad: 存储对最终输出的梯度(偏导数)
        self.grad = 0.0
        # _children: 记录这个节点依赖哪些父节点(用于反向传播)
        self._backward = lambda: None
        self._op = _op  # 记录是什么操作('+'、'*'、'ReLU'等)
        self._children = _children

这个类虽然只有几十行代码,却完整实现了自动求导的核心逻辑。

理解前向传播

当你对两个 Value 对象做运算时,比如 a + b,Python 会调用 __add__ 方法:

def __add__(self, other):
    other = other if isinstance(other, Value) else Value(other)

    out = Value(self.data + other.data, (self, other), '+')

    def _backward():
        # 加法的反向传播:梯度直接传递给所有子节点
        self.grad += 1.0 * out.grad
        other.grad += 1.0 * out.grad

    out._backward = _backward
    return out

这就是精髓:加法的梯度就是 1。如果你把两个数相加得到结果,那么结果的变化对每个加数的“贡献”都是 100%。

理解反向传播

乘法稍微复杂一点:

def __mul__(self, other):
    other = other if isinstance(other, Value) else Value(other)

    out = Value(self.data * other.data, (self, other), '*')

    def _backward():
        # 乘法的反向传播:梯度乘以另一个输入的值
        self.grad += other.data * out.grad
        other.grad += self.data * out.grad

    out._backward = _backward
    return out

如果你把两个数相乘得到结果,那么结果的变化对第一个数的“贡献”正比于第二个数的大小。这就是链式法则的直接应用。

完整的计算示例

让我们用一个完整的例子来理解前向和反向传播:

from micrograd import Value

# 假设我们计算: f(a, b, c) = (a + b) * c
a = Value(2.0)
b = Value(3.0)
c = Value(4.0)

d = a + b      # d = 5.0
e = d * c      # e = 20.0

# 前向传播结束,现在计算 e 关于 a, b, c 的梯度
e.grad = 1.0   # 初始梯度,对自身求导自然是 1

# 从后往前反向传播
e._backward()  # e 的反向传播(这里主要是乘法的反向)
d._backward()  # d 的反向传播(这里是加法的反向)

print(f"a.grad = {a.grad}")  # 应该是 4.0
print(f"b.grad = {b.grad}")  # 应该是 4.0
print(f"c.grad = {c.grad}")  # 应该是 5.0

手算验证一下:根据链式法则,∂e/∂a = ∂e/∂e × ∂e/∂d × ∂d/∂a = 1 × c × 1 = 4,完全正确。

激活函数:ReLU 的实现

ReLU(Rectified Linear Unit)是深度学习中最常用的激活函数之一,它的梯度传播也很直观:

def relu(self):
    out = Value(0 if self.data < 0 else self.data, (self,), 'ReLU')

    def _backward():
        # ReLU 的梯度:如果是正数,梯度为 1;否则为 0
        self.grad += (out.data > 0) * out.grad

    out._backward = _backward
    return out

这就是说:如果这个神经元在正区间,它会“放行”梯度;如果在负区间被置零了,梯度也会被阻断。


从 micrograd 到神经网络:一个完整的多层感知机实现

有了自动求导引擎,我们就可以构建真正的神经网络了。神经网络本质上是多层线性变换 + 非线性激活的组合。

神经网络的结构设计

假设我们要实现一个两层全连接网络(也叫多层感知机,MLP):

输入层 (784维) → 隐藏层 (128维, ReLU) → 输出层 (10维, Softmax)

数学上可以表示为:

h = ReLU(x @ W1 + b1)
y = softmax(h @ W2 + b2)

手动实现一个神经元

一个神经元做的事情是:对输入向量做加权求和,然后加上偏置,最后通过激活函数:

class Neuron:
    def __init__(self, nin):
        # nin: 输入维度
        # 每个神经元有一组权重和一个偏置
        self.w = [Value(random.uniform(-1, 1)) for _ in range(nin)]
        self.b = Value(0.0)

    def __call__(self, x):
        # 计算: sum(w_i * x_i) + b
        act = sum((wi * xi for wi, xi in zip(self.w, x)), self.b)
        # 通过激活函数
        return act.tanh()  # 这里用 tanh 作为激活函数

    def parameters(self):
        # 返回所有可学习参数(用于优化器)
        return self.w + [self.b]

逐行解析神经元的工作原理

第一行 self.w = [Value(random.uniform(-1, 1)) for _ in range(nin)] 创建了随机初始化的权重。在训练开始前,神经网络是“空白”的,权重都是随机数。

第二行 self.b = Value(0.0) 初始化偏置为 0。偏置的作用是给线性变换添加一个平移项,让网络有更大的表达能力。

__call__ 方法是前向传播的核心:它先计算 act = sum(w_i * x_i) + b,然后通过 tanh 函数把结果压缩到 [-1, 1] 区间。tanh 是一个 S 形曲线,引入非线性,让网络能够学习复杂的模式。

构建层和完整网络

class Layer:
    """一层神经元"""
    def __init__(self, nin, nout):
        self.neurons = [Neuron(nin) for _ in range(nout)]

    def __call__(self, x):
        out = [n(x) for n in self.neurons]
        return out[0] if len(out) == 1 else out

    def parameters(self):
        return [p for n in self.neurons for p in n.parameters()]


class MLP:
    """多层感知机"""
    def __init__(self, nin, nouts):
        sz = [nin] + nouts
        self.layers = [Layer(sz[i], sz[i+1]) for i in range(len(nouts))]

    def __call__(self, x):
        for layer in self.layers:
            x = layer(x)
        return x

    def parameters(self):
        return [p for layer in self.layers for p in layer.parameters()]

MLP 接受输入维度和一个列表来描述每层的输出维度。比如 MLP(2, [4, 1]) 会创建一个两层网络:输入 2 维,第一个隐藏层 4 个神经元,输出层 1 个神经元。

损失函数:Cross-Entropy

对于分类问题,Cross-Entropy 是最常用的损失函数。简单来说,它衡量的是“网络的预测分布”和“真实分布”之间的差异。实现时通常配合 Softmax 一起使用:

def cross_entropy_loss(logits, target):
    """logits: 网络原始输出 (未经 softmax)
       target: 真实类别索引 (整数)"""

    # 第一步:Softmax,把 logits 转换成概率分布
    max_logit = max(logits)  # 数值稳定性技巧
    probs = [Value(math.exp(l.data - max_logit)) for l in logits]
    sum_probs = sum(probs)
    probs = [p / sum_probs for p in probs]

    # 第二步:计算负对数似然
    loss = -probs[target].log()
    return loss

注意 max_logit 的处理:直接 exp 很大的数可能会溢出,用最大值做平移可以保持数值稳定。这是工程实践中的重要技巧。

训练循环:手写梯度下降

终于到了训练环节。完整的训练循环包括四个步骤:前向传播 → 计算损失 → 反向传播 → 更新参数

def train(model, X, y, epochs=100, lr=0.01):
    """手动实现梯度下降训练循环"""

    for epoch in range(epochs):
        # === 1. 前向传播 ===
        predictions = [model(x) for x in X]
        loss = cross_entropy_loss(predictions, y)

        # === 2. 反向传播 ===
        # 每次前向传播会累积梯度,所以需要先清零
        for p in model.parameters():
            p.grad = 0.0

        loss.backward()  # 这会递归调用所有节点的 _backward()

        # === 3. 更新参数 ===
        for p in model.parameters():
            p.data -= lr * p.grad  # 梯度下降:参数沿负梯度方向移动

        if epoch % 10 == 0:
            acc = sum(p == y for p, y in zip(predictions, y))
            print(f"Epoch {epoch}: Loss = {loss.data:.4f}, Acc = {acc/len(y):.2%}")

    return model

这就是手动实现版的 optimizer.step()。虽然 PyTorch 在底层做了更多优化,但原理完全一致。


实战项目:构建字符级语言模型 makemore

如果说 micrograd 让你理解了自动求导的原理,那么 makemore 就是把这些原理变成真正能用的语言模型。

项目目标

makemore 可以学习一组名字(比如英文人名),然后生成“看起来像真实名字但实际上是虚构的”新名字。例如,训练数据中可能包含 [“emma”, “olivia”, “liam”, “noah”],模型会学会这些名字的字符模式,然后生成 [“narvis”, “cadeb”, “rokin”, “lorian”] 这样的新名字。

数据预处理

语言模型处理的是序列数据。第一步是把字符转换成数字(索引):

import torch

# 定义词汇表
chars = sorted(list(set(''.join(names))))  # 所有出现过的字符
stoi = {c: i for i, c in enumerate(chars)}  # 字符 -> 索引
itos = {i: c for c, i in stoi.items()}      # 索引 -> 字符

# 把名字转换成整数序列
encoded_names = [[stoi[c] for c in name] for name in names]

构建训练样本

语言模型的核心思想是:给定前面的字符,预测下一个字符。所以我们需要构造 (context, target) 对:

def build_dataset(names, block_size=3):
    """block_size: 用多少个字符来预测下一个字符"""
    X, Y = [], []

    for name in names:
        context = [0] * block_size  # 初始上下文(全0表示“还没有字符”)
        for ch in name + '.':
            ix = stoi[ch]
            X.append(context)
            Y.append(ix)
            # 更新上下文:左移一位,加入新字符
            context = context[1:] + [ix]

    X = torch.tensor(X)
    Y = torch.tensor(Y)
    return X, Y

例如,对于 “emma”(加结束符后是 “emma.”),block_size=3,生成的训练样本是:

context: [0, 0, 0] -> target: 'e'
context: [0, 0, e] -> target: 'm'
context: [0, e, m] -> target: 'm'
context: [e, m, m] -> target: 'a'
context: [m, m, a] -> target: '.'

Embedding 层:从索引到向量

计算机只能处理数字,字符需要先转换成向量才能参与计算。Embedding 就是完成这个转换:

class Embedding:
    def __init__(self, num_embeddings, embedding_dim):
        # num_embeddings: 词汇表大小(比如26个字母 + 1个结束符)
        # embedding_dim: 每个字符用多长的向量表示
        self.weight = torch.randn(num_embeddings, embedding_dim)

    def __call__(self, X):
        # X: (batch_size, block_size) 的索引
        # 返回: (batch_size, block_size, embedding_dim) 的向量
        return self.weight[X]

    def parameters(self):
        return [self.weight]

这个 Embedding 本质上就是一个查找表:输入一个数字索引,输出对应的向量行。

循环神经网络(RNN):处理序列的核心

RNN 是处理序列数据的经典架构。它的核心思想是:保持一个隐藏状态,每读入一个新字符就更新这个状态

class RNN:
    def __init__(self, input_size, hidden_size, output_size):
        self.whh = torch.randn(hidden_size, hidden_size) * 0.1  # 隐藏状态到隐藏状态
        self.wxh = torch.randn(input_size, hidden_size) * 0.1   # 输入到隐藏状态
        self.why = torch.randn(hidden_size, output_size) * 0.1   # 隐藏状态到输出
        self.bh = torch.zeros(hidden_size)                       # 隐藏层偏置
        self.by = torch.zeros(output_size)                       # 输出层偏置
        self.hidden_size = hidden_size

    def forward(self, X, h_prev):
        """前向传播一步
        X: (batch_size, input_size) - 当前时刻的输入
        h_prev: (batch_size, hidden_size) - 上一时刻的隐藏状态
        """
        # 更新隐藏状态
        h_raw = torch.matmul(h_prev, self.whh) + torch.matmul(X, self.wxh) + self.bh
        h = torch.tanh(h_raw)  # tanh 把值压缩到 [-1, 1]

        # 计算输出( logits,未归一化的分数)
        y_raw = torch.matmul(h, self.why) + self.by

        return y_raw, h

    def init_hidden(self, batch_size):
        return torch.zeros(batch_size, self.hidden_size)

这里的关键是 h_raw = h_prev @ Whh + X @ Wxh + bh。这个公式把“过去的记忆”(h_prev)和“当前的输入”(X)融合在一起,形成新的记忆。这就是 RNN 能够“记住”之前看到的内容的原因。

完整的前向传播和采样

训练时我们处理完整的序列,但生成(采样)时需要逐字符预测:

def generate(model, start_context, max_length=20):
    """从模型采样,生成新名字"""
    model.eval()  # 切换到评估模式(关闭 dropout 等)

    context = start_context.copy()
    output = []

    while True:
        # 把 context 转换成 tensor
        x = torch.tensor([[stoi[c] for c in context[-block_size:]]])

        # 前向传播
        logits, _ = model(x, model.init_hidden(1))

        # 从概率分布采样
        probs = F.softmax(logits, dim=-1)
        ix = torch.multinomial(probs[0], num_samples=1).item()

        ch = itos[ix]
        output.append(ch)

        if ch == '.' or len(output) >= max_length:
            break

        # 更新 context
        context.append(ix)

    return ''.join(output[:-1])  # 去掉结束符

torch.multinomial(probs, 1) 是从概率分布中采样的标准方法,它根据概率权重随机选择一个索引。这就是模型“创造”新名字的方式——每次不是选最高概率的字符,而是按概率加权随机选择,这会产生多样化的输出。


从零到英雄:一步步实现自己的实验

现在你已经理解了核心概念,让我们动手做一个完整的练习:训练一个字符级语言模型来生成莎士比亚风格的文本。

第一步:准备数据

首先,你需要获取莎士比亚的文本数据。你可以使用 Project Gutenberg 的免费资源,或者用仓库中自带的样例。假设你已经有一个文本文件 shakespeare.txt,每行包含一个角色名或台词。

第二步:实现数据加载器

import torch
import torch.nn.functional as F

class CharDataset:
    def __init__(self, words, block_size):
        self.words = words
        self.block_size = block_size
        self.chars = sorted(list(set(''.join(words))))
        self.stoi = {c: i + 1 for i, c in enumerate(self.chars)}
        self.stoi['.'] = 0  # 结束符
        self.itos = {i: c for c, i in self.stoi.items()}
        self.vocab_size = len(self.chars) + 1  # +1 for '.'

    def __len__(self):
        return len(self.words)

    def __getitem__(self, idx):
        word = self.words[idx]
        ix = torch.tensor([self.stoi[c] for c in word])

        # 创建输入-目标对
        x = torch.zeros(self.block_size, dtype=torch.long)
        y = torch.zeros(self.block_size, dtype=torch.long)

        # 用前 block_size 个字符预测接下来的字符
        j = 0
        while j < len(ix) and j < self.block_size:
            x[j] = 0 if j == 0 else ix[j-1]
            y[j] = ix[j]
            j += 1

        return x, y

第三步:定义模型架构

class CharRNN(torch.nn.Module):
    def __init__(self, vocab_size, embedding_dim, hidden_dim):
        super().__init__()
        self.embedding = torch.nn.Embedding(vocab_size, embedding_dim)
        self.rnn = torch.nn.RNN(embedding_dim, hidden_dim, batch_first=True)
        self.output = torch.nn.Linear(hidden_dim, vocab_size)

    def forward(self, x, h=None):
        x = self.embedding(x)  # (batch, seq_len) -> (batch, seq_len, emb_dim)
        out, h = self.rnn(x, h)  # out: (batch, seq_len, hidden_dim)
        logits = self.output(out)  # (batch, seq_len, vocab_size)
        return logits, h

    def generate(self, start_idx, max_new_tokens, block_size):
        """从指定索引开始生成"""
        self.eval()
        indices = [start_idx]

        while len(indices) < max_new_tokens:
            # 始终用最后 block_size 个作为输入
            x = torch.tensor([[indices[-block_size:]]])
            logits, _ = self.forward(x)
            probs = F.softmax(logits[0, -1], dim=-1)
            idx = torch.multinomial(probs, 1).item()
            indices.append(idx)

            if idx == 0:  # 遇到结束符
                break

        return indices

第四步:编写训练循环

def train_model(model, dataset, epochs, lr=0.01, batch_size=32):
    optimizer = torch.optim.Adam(model.parameters(), lr=lr)
    criterion = torch.nn.CrossEntropyLoss()

    for epoch in range(epochs):
        model.train()
        total_loss = 0
        num_batches = 0

        # 模拟随机批次
        for _ in range(len(dataset) // batch_size):
            indices = torch.randint(len(dataset), (batch_size,))
            xb, yb = [], []

            for idx in indices:
                x, y = dataset[idx]
                xb.append(x)
                yb.append(y)

            xb = torch.stack(xb)
            yb = torch.stack(yb)

            optimizer.zero_grad()
            logits, _ = model(xb)
            loss = criterion(logits.view(-1, logits.size(-1)), yb.view(-1))
            loss.backward()
            optimizer.step()

            total_loss += loss.item()
            num_batches += 1

        if (epoch + 1) % 10 == 0:
            avg_loss = total_loss / num_batches
            print(f"Epoch {epoch+1}/{epochs}: Loss = {avg_loss:.4f}")

    return model

第五步:运行训练并观察结果

# 加载数据
with open('shakespeare.txt', 'r') as f:
    text = f.read()
words = text.split('\n')
words = [w for w in words if len(w) > 2]  # 过滤太短的

# 创建数据集
block_size = 8
dataset = CharDataset(words, block_size)

# 初始化模型
model = CharRNN(
    vocab_size=dataset.vocab_size,
    embedding_dim=64,
    hidden_dim=128
)

# 训练
model = train_model(model, dataset, epochs=100, lr=0.01)

# 生成样本
print("\n生成的台词示例:")
for _ in range(5):
    start_idx = torch.randint(1, dataset.vocab_size, (1,)).item()
    generated = model.generate(start_idx, 50, block_size)
    text = ''.join([dataset.itos[i] for i in generated])
    print(f"  {text}")

实际运行中你会看到类似这样的输出:

Epoch 10/100: Loss = 2.4567
Epoch 20/100: Loss = 2.1234
Epoch 30/100: Loss = 1.9876
...

生成的台词示例:
  ROMEO: What shall have my lord?
  JULIET: And I have be so shall with me
  HAMLET: To be or not to be but I am
  MACBETH: I have seen the world to hear

随着训练进行,损失会下降,生成的质量会逐渐提高。初期模型会生成语法混乱的句子,但到了后期,它会学到基本的语法结构、角色名格式、甚至一些莎士比亚式的表达。


常见应用场景和实战案例

学会了基础之后,你可能会问:这些技术到底能用在哪里?以下是一些实际的应用方向。

场景一:构建自己的文本补全工具

如果你有一个领域(比如医学、法律、编程)的文本语料,你可以训练一个字符级语言模型,然后用它来做文本补全或生成。例如,训练一个专门生成 Python 代码的模型:

# 准备 Python 代码数据
code_samples = open('my_python_codebase.py').read().split('\n\n')
code_samples = [c for c in code_samples if len(c) > 50]

# 用相同的方法训练
code_dataset = CharDataset(code_samples, block_size=32)
code_model = CharRNN(vocab_size=code_dataset.vocab_size, embedding_dim=128, hidden_dim=256)

# 生成代码片段
code_model = train_model(code_model, code_dataset, epochs=200)

生成的代码可能不完美,但可以作为灵感来源或代码模板。

场景二:实现自定义的神经网络层

micrograd 的架构非常模块化。如果你想要实现一个新的层(比如自定义的注意力机制),只需要继承 Value 类并实现 __add____mul__ 等方法:

class AttentionValue(Value):
    """带注意力机制的自定义 Value"""

    def attention(self, queries, keys, values):
        # 简化的注意力计算
        scores = torch.matmul(queries, keys.transpose(-2, -1))
        weights = F.softmax(scores, dim=-1)
        return torch.matmul(weights, values)

场景三:调试和可视化梯度流

理解梯度在网络中如何流动是调试的关键。你可以用 micrograd 来可视化每个节点的梯度:

def visualize_gradients(model, X, y):
    """打印每个参数的梯度,帮助理解网络学习"""
    predictions = [model(x) for x in X]
    loss = cross_entropy_loss(predictions, y)

    loss.backward()

    for i, p in enumerate(model.parameters()):
        grad_norm = p.grad.data.norm().item()
        print(f"Layer {i} gradient norm: {grad_norm:.6f}")

        # 如果梯度太小,说明可能存在梯度消失
        # 如果梯度太大,说明可能存在梯度爆炸
        if grad_norm < 1e-6:
            print("  ⚠️ Warning: Very small gradient - possible vanishing gradient")
        elif grad_norm > 10:
            print("  ⚠️ Warning: Very large gradient - possible exploding gradient")

最佳实践和进阶建议

在深入学习这个项目的过程中,我积累了一些实战经验,希望能帮你少走弯路。

关于代码理解

不要试图一口气理解所有代码。建议分模块学习:

第一阶段:micrograd/value.py - 理解自动求导的核心
第二阶段:手动实现 MLP - 理解前向传播和反向传播
第三阶段:makemore 的 RNN - 理解序列建模
第四阶段:阅读完整训练循环 - 理解整个 pipeline

每个阶段花 1-2 天时间,亲手调试每一个示例。

关于调试技巧

当你遇到问题(比如 loss 不下降、梯度消失)时,micrograd 提供了独特的调试能力:

# 打印计算图,查看每个节点的值和梯度
def debug_node(node, depth=0):
    print("  " * depth + f"op={node._op}, data={node.data:.4f}, grad={node.grad:.4f}")
    for child in node._children:
        debug_node(child, depth + 1)

# 使用
loss.backward()
debug_node(loss)

这个简单的函数能让你看清整个计算过程,是理解反向传播的强大工具。

关于性能优化

micrograd 的实现没有做任何优化,所以在学习原理时没问题,但如果用于实际项目,性能会是个瓶颈。如果你需要更快的实现,可以把计算图用 PyTorch 实现:

import torch

# 把你用 micrograd 写的模型转换成 PyTorch 版本
class TorchMLP(torch.nn.Module):
    def __init__(self, nin, nouts):
        super().__init__()
        layers = []
        sz = [nin] + nouts
        for i in range(len(nouts)):
            layers.append(torch.nn.Linear(sz[i], sz[i+1]))
            if i < len(nouts) - 1:
                layers.append(torch.nn.Tanh())
        self.network = torch.nn.Sequential(*layers)

    def forward(self, x):
        return self.network(x)

关于学习路线

建议的学习顺序:

Week 1: micrograd - 理解自动求导
Week 2: 手动实现 MLP - 理解全连接网络
Week 3: makemore (MLP版本) - 理解语言模型基础
Week 4: makemore (RNN版本) - 理解序列建模
Week 5+: 尝试 LSTM、Transformer 等高级架构

每完成一个阶段,尝试做一个小项目来巩固知识。


扩展阅读和相关资源

学完这个仓库后,你可能会想进一步探索。以下是一些推荐的延伸学习资源:

Karpathy 的视频课程

这是 nn-zero-to-hero 的配套视频教程,在 YouTube 上可以免费观看。视频中 Karpathy 会带你一行一行写代码,讲解每个决策背后的原理。这是目前为止最好的深度学习入门资源之一。

mikemcg/llm.c

这是 Karpathy 的最新项目,用纯 C 语言实现 GPT-2 的训练。它展示了如何用最少的依赖构建一个真正可用的语言模型。如果你想深入理解 LLM 的底层机制,这个项目值得研究。

Andrej Karpathy 的博客

他的博客经常发布深度学习相关的深入分析文章,包括神经网络架构的设计思路、训练技巧、以及对 AI 领域趋势的洞察。

PyTorch 官方文档

当你准备好从“手动实现”过渡到“高效实现”时,PyTorch 文档是最好的参考。它详细描述了每个模块的 API 和最佳实践。

fast.ai 课程

如果你想从另一个角度学习深度学习,fast.ai 的课程强调“实践优先”,教你如何快速搭建 SOTA 模型。它和 nn-zero-to-hero 形成很好的互补。


结语:从零开始,你已经迈出了关键一步

回顾全文,我们从 micrograd 的 Value 类开始,理解了自动求导如何工作;然后构建了 MLP,学会了如何处理表格数据;接着深入 makemore,掌握了字符级语言模型的构建方法;最后通过完整示例,体验了从数据准备到模型训练的全流程。

你学到的不只是“如何使用 PyTorch”,而是深度学习真正的核心原理。当你下次阅读论文时遇到“梯度裁剪”、“权重初始化”、“学习率调度”这些概念,你会明白它们为什么有效,以及什么时候该用它们。

Karpathy 的这门课之所以特别,是因为它始终坚持一个原则:真正的理解来自于亲手实现。当你能够从零开始写出自动求导引擎、写出 RNN 前向和反向传播,你就拥有了独立解决问题、改进现有方法、甚至创造新架构的能力。

这就是“从零到英雄”的含义——不是让你重复造轮子,而是让你在造轮子的过程中,真正成为驾驭这辆车的司机。

现在,打开你的终端,克隆仓库,开始你的深度学习之旅吧。每一个伟大的项目,都始于第一步。祝你在神经网络的世界里,探索愉快。


项目链接

karpathy/nn-zero-to-hero: https://github.com/karpathy/nn-zero-to-hero

karpathy/micrograd (独立版本): https://github.com/karpathy/micrograd

karpathy/makemore: https://github.com/karpathy/makemore

karpathy/llm.c (GPT-2 纯 C 实现): https://github.com/karpathy/llm.c

如果内容对您有帮助,欢迎打赏

您的支持是我继续创作的动力

前往打赏页面

评论区

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注