从零开始手撸一个神经网络框架(一)

部署运行你感兴趣的模型镜像

导言

神经网络是人工智能时代的核心技术。无论是深度学习,还是当前大热的大模型,其底层都以神经网络为基础。可以毫不夸张地说,没有神经网络,就没有今日人工智能的蓬勃景象——它是整个领域的基石

然而,对许多初学者而言,神经网络的入门并不轻松。我们经常看到一些过度抽象的类比:把神经网络比做人脑,把梯度下降比作从山坡往下走。这些类比虽然有助于建立直觉,但总让人觉得隔了一层,不够扎实。另一种常见情况则是,一上来就被带进复杂的数学推导,面对密密麻麻的符号和公式,不仅云里雾里,还容易迷失方向——只见树木,不见森林。

我看了安德烈·卡帕斯(Andrej Karpathy)在 YouTube 上的一节神经网络教学视频。他的切入点和讲法令人耳目一新,甚至可以说是拍案叫绝。不愧是大佬中的大佬。这篇文章的主要内容,正是基于他那近两个小时的课程,我将其中的核心观点提炼出来,并结合图示、代码,将这些关键思想串联成一个易于理解的整体。

阅读本文,你只需要具备一点 Python 基础,以及初高中水平的数学知识即可

现在,让我们正式开始吧。

回顾导数知识

在开始之前,我们需要复习一个最关键的数学概念——导数。当然,这里我们并不需要像在学校那样,去背各种导数公式,或者手工推导复杂表达式的导数。在神经网络中,模型的结构往往包含大量的嵌套函数和参数,想靠手工求导几乎是不可能的。

在神经网络的语境下,我们真正需要理解的不是“怎么求导”,而是导数的含义是什么、它代表了什么信息、以及它在神经网络中发挥什么作用。掌握了这些核心概念,就能够理解神经网络是如何学习、如何优化的。

导数的定义与本质

导数用于衡量当输入发生微小变化时,函数输出会随之发生多大变化。直观来说,它回答了一个问题:,导数刻画的是:函数对输入参数有多敏感,即:当我微微改变输入,输出会改变多少?。

因此,导数是描述函数变化的重要工具。从函数变化率的角度来看,导数可以用来描述函数在某一点的变化速度、变化趋势、单调性以及极值点。

假设有一个单变量函数 f(x)f(x)f(x),变量为 xxx

函数的导数可以表示为:

f′(x)=lim⁡h→0f(x+h)−f(x)hf'(x) = \lim_{h \to 0} \frac{f(x+h) - f(x)}{h}f(x)=h0limhf(x+h)f(x)

这里,hhh 是自变量 xxx 的一个很小的增量(趋近于 0),分子表示函数值的变化量,分母表示变量的变化量。导数 f′(x)f'(x)f(x) 描述了函数在每一点的变化快慢和方向。

从图像角度理解导数

下面这张图展示了函数 f(x)f(x)f(x) 与其导数 f′(x)f'(x)f(x) 的关系:

下方曲线 f(x)f(x)f(x): 表示函数的形状。可以看到函数先上升、再下降,最后快速上升。

上方曲线 f′(x)f'(x)f(x):表示函数在各点的导数值,也就是函数变化的快慢和方向。

**红点:**标记了函数上的对应点,通过垂线可以看到每个点的切线斜率如何映射到导数图上:

  • 当函数上升时,导数为正(红点对应在上方正区间)。
  • 当函数下降时,导数为负(红点对应在下方负区间)。
  • 当函数达到极值(峰顶或谷底)时,导数为 0(红点位于 x 轴上)。

理解了导数的本质,就能明白它为什么如此重要。神经网络本质上就是一个超级复杂的函数,而我们做的所有操作,本质上都是在与这个函数打交道。

导数告诉我们函数对输入参数的敏感程度,是理解和调控神经网络的核心工具。随着学习的深入,大家会对它的重要性有更深刻的体会。

从最简单的加、乘法运算开始

万丈高楼平地起。虽然神经网络看起来复杂,但其底层运算依然基于数学最基本的操作——主要是加法和乘法(尤其是在矩阵和向量运算中)。下面,我们先从最简单的**“加、乘”运算开始,逐步理解神经网络的运算机制。**

class Value:
    # 构造函数初始化对象
    def __init__(self,data):
       self.data=data
    
    # 定义对象的字符串表示形式
    def __repr__(self):
        return f"Value(data={self.data})"
    
    # 实现加法运算
    def __add__(self,other):
        out =Value(self.data+other.data)
        return out

    # 定义乘法运算
    def __mul__(self,other):
        out=Value(self.data*other.data)
        return out

上面的代码定义了一个简单的类,它像一个“小容器”,用来封装数字。计算前的输入和计算后的结果都可以用这个类来表示。

虽然,现在它只支持最基本的加法和乘法运算,但随着学习的深入,我们会逐步丰富它的功能,让它不仅能做更多运算,还能支撑更多的功能。

假设要计算表达式 a×b+ca×b+ca×b+c 的值,就可以先用 Value 类将数字封装成对象,然后在这些对象上进行运算,如下面代码:

a=Value(2.0)
b=Value(3.0)
c=Value(4.0)

print(a*b+c)                      # 表达式计算
print((a.__mul__(b)).__add__(c))  # 函数式计算

输出结果为:
Value(data=10.0) Value(data=10.0)

修改Value类已支持可视化展示

上面的代码实现了最基本的加法和乘法运算,运行的表达式也比较简单。但在实际中,神经网络对应的表达式会非常庞大、复杂。为了更好地理解神经网络的计算过程,我们有必要对运算进行可视化。

例如,考虑如下表达式:

L=(a×b+c)×fL=(a×b+c)×fL=(a×b+c)×f

(a=2,b=−3,c=10,f=−2)(a=2, b=-3, c=10, f=-2)(a=2,b=3,c=10,f=2) 时,我们希望展示如下图所示的计算过程。
这张图直观、清晰地呈现了整个表达式的运算流程,使我们能够方便地理解每一步的计算过程及其结果。

为了实现可视化计算的目标,我们需要对 Value 类进行如下改进。在上一个版本的基础上新增了三个参数

  • _prevNodes:记录当前 Value 对象是由哪些之前的 Value 对象计算得到的,它用于构建表达式的计算图或树形结构,可以追踪每一步计算的来源。在可视化或反向传播中,可以通过 _prevNodes 找到计算的依赖关系。

  • _op:用于在可视化图形中显示运算符号(如加号、乘号),便于直观理解计算操作。

  • _label:在可视化计算图时,可以在图中椭圆里显示运算符号,让每个节点代表的运算类型一目了然。

如下面代码:

class Value:

    # 构造函数初始化对象
    def __init__(self,data,_prevNodes=(),_op='',_label=''):
       """
       data:       实际的标量值
       _prevNodes: 前置节点,每次表达式计算,在得到新的值中,将原值作为_prevNodes
       _op:        用于标识运算
       """
       self.data=data
       self._prevNodes=set(_prevNodes)                 
       self._op=_op
       self._label=_label
    
    # 定义对象的字符串表示形式
    def __repr__(self):
        return f"Value(data={self.data})"
    
    # 实现加法运 +
    def __add__(self,other):
        out =Value(self.data+other.data,(self,other),'+')
        return out

    # 定义乘法运 *
    def __mul__(self,other):
        out=Value(self.data*other.data,(self,other),'*')
        return out

使用修改后的 Value 类进行计算,我们来计算表达式L=(a×b+c)×fL = (a \times b + c) \times fL=(a×b+c)×f,,代码如下:

a = Value(2.0, _label='a')
b = Value(-3.0, _label='b')
c = Value(10.0, _label='c')
f = Value(-2.0, _label='f')

# 逐步计算中间结果
e = a * b
e._label = 'e'

d = e + c
d._label = 'd'

L = d * f
L._label = 'L'

print(L)
  • 首先将每个数字用 Value 类封装,并添加 _label 以标记变量名。
  • 然后按计算顺序依次生成中间值 ed,最后计算最终结果 L
  • 打印 L 时,输出的是 Value 对象,方便后续进行可视化或追踪计算。

计算结果为:Value(data=-8.0)

编写格式化工具类

前面我们已经对 Value 类进行了扩展,使其可以记录前置节点、运算符和标签,从而支持计算图的可视化展示。

下面,我们将展示一段稍微复杂的示例代码,用于生成并可视化整个表达式的计算过程。该方法是独立实现的,如果不想深入研究,可以暂时把它当作一个“黑盒”使用。代码中附有详细注释,有兴趣的读者可以仔细查看。

⚠️ 注意:此示例需要安装 graphviz Python 库,同时系统中必须安装 Graphviz 可执行程序(Digraph 所依赖的 dot),并配置好环境变量。具体安装和配置方法可以参考官方文档或使用 AI 完成。

from graphviz import Digraph

def trace(root):
    """
    从最终结果节点 root 出发,
    递归遍历整个表达式图,收集所有节点(nodes)和边(edges),
    为后续绘图做准备。
    """
    nodes, edges = set(), set()    # nodes 存储所有参与运算的 Value 节点;edges 存储节点之间的连接关系

    def recursionBuild(node):
        # 避免重复添加同一个节点
        if node not in nodes:
            nodes.add(node)
            # 遍历当前节点的所有前置节点(即参与生成该节点的输入节点)
            for child in node._prevNodes:
                edges.add((child, node))   # 记录 child -> node 的连接关系
                recursionBuild(child)      # 递归处理 child,继续向上追溯整个计算图

    recursionBuild(root)   # 从最终输出 root 开始向前递归追踪
    return nodes, edges


def draw_dot(root, format='svg', rankdir='LR'):
    """
    将表达式的计算过程绘制为可视化计算图。
    
    format: 输出图像格式,如 'png'、'svg'
    rankdir: 图的方向
        - 'LR': 从左到右布局
        - 'TB': 从上到下布局
    """
    assert rankdir in ['LR', 'TB']
    
    # 获取整个计算图的节点与边
    nodes, edges = trace(root)

    # 创建一个 Graphviz 的 Digraph 对象,用于绘制图
    dot = Digraph(
        format='png',
        graph_attr={'rankdir': rankdir, 'size': '11,11'}  # 设置图的方向和大小
    )

    # 遍历所有节点,绘制变量节点和运算符节点
    for n in nodes:
        sid = str(id(n))  # 使用节点内存地址作为节点的唯一 ID

        # 绘制变量节点(方框:显示 label 与 data)
        dot.node(
            name=sid,
            label="{%s | data %.1f }" % (n._label, n.data),  # 显示标签与数据值
            shape='record'
        )

        # 如果当前节点是由某个运算生成的(例如 + 或 *)
        if n._op:
            # 额外绘制一个小圆节点显示运算符
            dot.node(
                name=sid + n._op,
                label=n._op
            )
            # 连接运算符节点 → 变量节点
            dot.edge(sid + n._op, sid)

    # 绘制所有边: child → op → parent
    for n1, n2 in edges:
        dot.edge(
            str(id(n1)),
            str(id(n2)) + n2._op
        )

    return dot

有了可视化方法,我们就可以应用到上面的表达式L=(a×b+c)×fL = (a \times b + c) \times fL=(a×b+c)×f 上。

当得到最终结果 L 后,只需将它传入 draw_dot(L) 这个可视化函数,整个计算过程就会被直观地呈现在图上。

  • 矩形表示 Value 对象,同时显示了变量的 _label 名称和数值。
  • 椭圆形表示运算符,每一步加法或乘法都清晰标注。

从左到右,计算流程按顺序展现,每个变量、每个运算步骤都一目了然。

这种可视化不仅帮助我们快速理解复杂表达式的计算过程,也为后续更深入的研究打下基础——正所说,“磨刀不误砍柴工”。

由于文章内容较多,为了提升写作效率,同时减轻读者的阅读负担,我们将整篇内容拆分为多篇文章。本篇主要讲解导数的概念,以及最基本的数字封装与计算可视化。

在后续文章中,我们会逐步深入,最终一步步构建出一个完整的神经网络框架。请大家耐心等待后续内容的发布。

未完待续

您可能感兴趣的与本文相关的镜像

Python3.8

Python3.8

Conda
Python

Python 是一种高级、解释型、通用的编程语言,以其简洁易读的语法而闻名,适用于广泛的应用,包括Web开发、数据分析、人工智能和自动化脚本

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值