Ray's Blog

(三)张量

引言

欢迎回到零基础深度学习之旅的第三篇。前面我们聊了优化和梯度下降,但还没说这些计算到底在什么”东西”上进行。今天就来填这个坑。

可能你已经在各种教程里见过这个词,很多深度学习教程首先讲的概念就是张量(Tensor)。有一些把它讲的非常复杂,因为这个概念运用非常广,在不同的学科中的运用和理解有所不同。在深度学习里,张量就是数据的统一格式。不管是一张图片、一段文字,还是神经网络的参数,本质上都用张量来表示。

到底为什么需要张量?从我们在上一篇遇到的问题就能看出端倪:深度学习要处理各种形状的数据——有时是一维的列表,有时是二维的表格,有时是三维、四维甚至更高维的数据。张量让这些不同维度的数据有了统一的表示方式,你不用为图像、文本、音频分别写一套处理逻辑。这也是为什么TensorFlow要叫这个名字——Tensor + Flow,张量的流动。

之后的所有内容都会大量依赖张量及其操作,所以千万不要想着跳过这篇文章。个人觉得这个概念非常有趣,光是了解这个概念就很有收获。

标量、向量、矩阵与张量

首先来看几个相关的术语:

标量(Scalar):单一数值(如 3.1442),在程序中用整数或浮点数表示。 向量(Vector):一个有序的数值序列,在程序中实现为一维数组,如 [1, 2, 3, 4]矩阵(Matrix):二维数值网格,在程序中表示为二维数组,如: [[1, 2, 3], [4, 5, 6]]

相信你至少听过向量这个概念。在物理和几何中,向量常被描述为具有大小和方向的量。但在深度学习中,我们更关注向量作为数据容器的特性,把它当作数组来处理就可以了。这是因为数组是计算机内存中存储和操作数据最自然、最高效的方式,而且硬件(CPU/GPU)对数组运算有高度优化。 什么是张量?

同理标量是一个单一的、没有方向的数值。理解成一个数值类型即可;矩阵在线性代数中用于表示线性变换、方程组求解,在这里我们把它理解成一个二维数组。

你可能已经注意到了一个规律:标量→向量→矩阵,这个序列有什么共同特征吗?特征就是每一个都是下一个的元素:向量的每一个元素都是一个标量,矩阵的每一个元素都是一个向量……

现在请想象一个更通用的数据结构,该结构可以看作是标量、向量、矩阵的统一概念:

它有一个属性阶(Rank),这个属性是一个变量,它的值是一个自然数:

当阶为零的时候,它就是标量,如 5.0; 当阶为一的时候,它就是向量,如 [1, 2, 3]; 当阶为二的时候,它就是矩阵,如 [[1,2], [3,4]]; 当阶为三的时候,可以想象成多个矩阵叠在一起,如一个 RGB 彩色图片; 更高阶的情况虽然难以在脑中可视化成空间维度,但概念是一样的。

这个结构就是张量。

注意:这里用空间维度来帮助理解张量的概念,但是’阶’不是指空间维度。一个3阶张量不一定代表3D空间中的数据,它只是说需要3个索引来定位其中的元素。在计算机程序中,用多维数组来实现张量。有的地方会把Rank翻译成秩,它和矩阵的秩又不是一个概念。它是一个简单的概念,但是很容易混淆,运用在不同场景的时候需谨慎。

张量的另一个重要的属性是形状(shape),用来表示该张量在每一阶的元素个数。 比如:$[5.0 \ \ 7.18 \ \ π]$ $[2.0\ 1.0\ 4.0\ 3.0] $都是一阶张量,但是第一个内部有三个零阶张量,第二个内部有有四个零阶张量。我们用Python里的元组符号也就是括号来表示张量的形状,因此这两个一阶张量的形状分别为(3,)和(4,)。

再看几个更难的例子:

例子 形状
$8$ 0 ()
$[[[[8]]]]$ 4 (1, 1, 1, 1)
$[[[5] [6] [7]] [[8] [9] [0]]]$ 3 (2, 3, 1)
$[[[8\ 8\ 8]] [[8\ 8\ 8]] [[8\ 8\ 8 ]]] $ 3 (3, 1, 3)

仔细观察判断一个张量的阶只要数第一个元素的左括号的数量就可以了,判断形状的话就需要慢慢数每一个阶的元素个数了。这么无聊的事情还是交给计算机来做好了:

from collections.abc import Callable
from typing import Any, TypeGuard

# 根据上面的定义,标量要么是整数,要么是浮点数
# “|”这个语法直到Python3.10才支持,之前的版本用Union
Scalar = int | float

# 只要一个列表的子元素都是张量,它本身也是张量
Tensor = Scalar | list['Tensor']

# 张量要么是标量要么是列表,意味着不是列表的张量就一定是标量
def is_scalar(t: Tensor) -> bool:
    return not isinstance(t, list)
    
# 新的语法也支持下面这种写法。但是这里可能会遇到类型提示报错。
# Python的类型提示实在是一言难尽,Ptyhon3.8之后开始支持上面使用字符串的方式表示递归类型定义,但是在这样的场景中还是会报错,建议直接忽略。
# 后文中我也会尽量使用正确的类型提示,但是有时候会变得非常长,不直观,处理起来太浪费时间,我也可能会做一些省略。
def is_scalar(t: Tensor) -> TypeGuard[Scalar]:
    return isinstance(t, Scalar)

# 标量的阶为0,如果不是标量就记1,直到数到标量为止
# 如果这些递归代码看不懂建议阅读我之前关于递归的博客
def rank(t: Tensor) -> int:
    return 0 if is_scalar(t) else 1 + rank(t[0])

# 形状同理,标量的形状用空元组表示
# 另这里只数了第一个元素的数量,也就是假设所有同阶元素子元素数量相同
def shape(t: Tensor) -> tuple[int]:
    return () if is_scalar(t) else (len(t), *shape(t[0]))

# 带入几个数据测试一下
print(rank([1, 2, 3]))
print(rank([[1, 2], [3, 4]]))
print(rank([[[8, 8, 8]],[[8, 8, 8]],[[8, 8, 8]]]))
print(rank([[[[8]]]]))

print(shape([1, 2, 3]))
print(shape([[1, 2], [3, 4]]))
print(shape([[[8, 8, 8]],[[8, 8, 8]],[[8, 8, 8]]]))
print(shape([[[[8]]]]))

# 输出结果:
# 1
# 2
# 3
# 4
# (3,)
# (2, 2)
# (3, 1, 3)
# (1, 1, 1, 1)

张量操作

接下来看看对于张量的操作。张量操作有很多,这里我把它们分为三类:

  1. 逐元素二元操作(例如加法、乘法):将两个同形状张量对应位置的元素进行二元运算。

    比如向量加法$[a, b] + [c, d] = [a + b, c + d]$ ;哈达玛积 $[a,b] ⊙ [c,d] = [ac, bd]$

  2. 按元素映射:将标量→标量函数映射到张量的每个元素。

    又叫做广播,比如之前实现过的sqr函数;再比如标量乘法$a × [b, c] = [a × b, a × c]$

  3. 降维操作:对张量沿最外层维度经行操作,结果张量的阶降低 1。

    比如之前用到过的sum函数

  4. 其他

    上一篇文章中实现的点积等等

编程实现:

# 向量加法
def tadd1(t1: Tensor, t2: Tensor) -> Tensor:
    return list(map(lambda a, b: a + b, t1, t2))

# 向量乘法
def tmul1(t1: Tensor, t2: Tensor) -> Tensor:
    return list(map(lambda a, b: a * b, t1, t2))

上面两个实现似乎有共同的逻辑,我们可以抽象出一个通用的按元素映射函数,来处理任意阶张量的逐元素操作

# 上面用的是map,下面用列表推导式,可能会让人觉得困惑,实际上逻辑相同。
# 混用是因为Python中map是惰性的,不会直接返回列表,而+,-等操作符不是可以直接传递的函数,代码会更占空间
def tmap(f: Callable[[Scalar], Scalar], t: Tensor) -> Tensor:
    return f(t) if is_scalar(t) else [f(x) for x in t]

# 二元操作逻辑相同
def tmap2(f: Callable[[Scalar, Scalar], Scalar], t: Tensor, u: Tensor) -> Tensor:
    if is_scalar(t) and is_scalar(u):
        return f(t, u)
    else:
        return [f(x, y) for x, y in zip(t, u)]

# 重新实现加法
tadd1 = lambda t, u: tmap2((lambda a, b: a + b), t, u)
# 重新实现乘法
tmul1 = lambda t, u: tmap2((lambda a, b: a * b), t, u)

# 测试
print(tadd1([1, 2, 3], [4, 5, 6]))  # 输出: [5, 7, 9]
print(tmul1([1, 2, 3], [4, 5, 6]))  # 输出: [4, 10, 18]

但是如果是矩阵加法或者乘法呢?其实很简单只需要使用两次tmap就可以了

def tadd2(t1: Tensor, t2: Tensor) -> Tensor:
    return tmap2(tadd1, t1, t2)

# 测试
print(tadd2([[1, 2], [3, 4]], [[5, 6], [7, 8]]))  # 输出: [[6, 8], [10, 12]]

显然,对于三阶张量,只需在tadd2的基础上再使用一次tmap/tmap2就可以了。但是如果是更多阶张量或者不知道阶数呢?因为张量是一个递归结构,我们可以使用递归来处理任意阶张量的逐元素操作。

# 扩展一元操作到任意阶张量
def ext1(f: Callable[[Scalar], Any]) -> Callable[[Tensor], Any]:
    def op(t: Tensor) -> Any:
        if is_scalar(t):
            return f(t)
        else:
            return tmap(ext1(f), t)
    return op

但是我们之前实现了很多处理一阶张量的函数了,而上面这个函数只能拓展针对标量的操作,还是需要一个更通用的函数。

# 首先需要一个辅助函数来判断张量的阶数
def of_rank(n: int, t: Tensor) -> bool:
    return rank(t) == n

def ext1(f: Callable[[Tensor], Any], r: int) -> Callable[[Tensor], Any]:
    def op(t: Tensor) -> Any:
        if of_rank(r, t):
            return f(t)
        else:
            return tmap(ext1(f, r), t)
    return op

# 现在拓展一些函数
add1: Callable[[Tensor], Tensor] = ext1((lambda x: x + 1), 0)
zeros: Callable[[Tensor], Tensor] = ext1((lambda _: 0), 0)
tsqr: Callable[[Tensor], Tensor] = ext1((lambda x: x * x), 0)
tsqrt: Callable[[Tensor], Tensor] = ext1((lambda x: x ** 0.5), 0)

def sum_1(t: Tensor, i: int = 0, acc: float = 0.0) -> float:

    if i == len(t):
        return acc
    else:
        return sum_n1(t, i + 1, t[i] + acc)

tsum:: Callable[[Tensor], Tensor] = ext1((lambda x: sum_1(x)), 1)


def flatten2(t: Tensor) -> list[Scalar]:
    assert of_rank(2, t), "Input tensor must be of rank 2"
    return [i for element  in t for i in element]

flatten: Callable[[Tensor], Tensor] = ext1(flatten2, 2)

在新的ext1实现中,除了接受一个函数f作为参数,还有另一个参数代表参数函数f所操作的张量的阶r。现在ext1函数并不会一直递归到参数(f的参数)变成Scalar为止,而是到阶数n为止。

add1这个函数举例理解:ext1接受两个参数,f是一个给任何标量加1的函数;r这个参数表示f(这里就是x+1这个匿名函数)的参数的阶,因此该函数的r值是0;ext1返回的函数(也就是add1)是一个增强版的f,可以处理任意阶的张量。原理是,只要该张量的元素不是标量,便会调用map函数,也就是递归地处理该张量的每一个元素。

同理:zero函数的参数是一个任意阶的张量,返回值是一个阶数和形状与参数相同,但是数值全是零的张量;tsqr/tsqrt返回值是一个阶数和形状与参数张量相同,但是所有最内部元素(也就是标量的值)是参数对应值的平方/平方根组成的张量;sum_1函数原本是处理一阶张量/数组的函数,被ext1拓展成tsum。tsum可以处理更高阶(高于1的任意阶)的张量,结果也是一个张量,不过最内部的一阶张量被加总成一个值所以结果张量比参数张量阶减少1; flatten原本是把一个二阶张量/矩阵变成一个一阶张量/数组的函数,现在能让所有高阶张量的阶数减少2。

# 二元操作逻辑相同
def of_ranks(n: int, t: Tensor, m: int, u: Tensor) -> bool:
    return rank(t1) == r1 and rank(t2) == r2

def ext2(f: Callable[[Tensor, Tensor], Any], n: int, m: int) -> Callable[[Tensor, Tensor], Any]:

    def op(t: Tensor, u: Tensor) -> Any:
        if of_ranks(n, m, t, u):
            return f(t, u)
        else:
            return desc(ext2(f, n, m), n, t, m, u)
    return op

不过我们对二元操作的拓展要求更高,还希望它能自动按需拓展(也叫广播(broadcast))某个标量,使得上面提到的按元素映射操作成为可能。例如,标量 2 和向量 [1, 2, 3] 相加时,tadd(2, [1, 2, 3]) 会“广播” 2 到 [2, 2, 2],结果为 [3, 4, 5]。再如,向量 [1, 2] 加到矩阵 [[1, 1], [2, 2]],会沿行广播,结果为 [[2, 3], [3, 4]]

要做到这样可能需要对tmap2逻辑做出一些修改:

    if of_ranks(n, t, m, u):
        return f(t, u)
        # 想象一下标量和向量乘法,比如1 * [1,2,3],等于[1*1, 1*2, 1*3]
    elif of_rank(n, t):
        return tmap(lambda x: f(t, x), u)
        # 相反依然
    elif of_rank(m, u):
        return tmap(lambda x: f(x, u), t)
        # 形状相同直接使用tmap
    elif len(t) == len(u):
        return tmap2(f, t, u)
        # 剩下的则是处理阶数不相同的自动广播,比如plane函数中需要执行[w1, w2]和[[x01,x02]...]操作
    elif rank_greater(t, u):
        return tmap(lambda x: f(x, u), t)
    else:
        return tmap(lambda x: f(t, x), u)

或者把这段逻辑做成一个帮助函数

def desc(g: Callable, n: int, t: Tensor, m: int, u: Tensor) -> Tensor:
    if of_rank(n, t):
        return tmap(lambda x: g(t, x), u)
    elif of_rank(m, u):
        return tmap(lambda x: g(x, u), t)
    elif shape(t) == shape(u):
        return tmap2(g, t, u)
    elif rank_greater(u, t):
        return tmap(lambda x: g(t, x), u)
    else:
        return tmap(lambda x: g(x, u), t)

def ext2(f: Callable, n: int, m: int) -> Callable[[Tensor, Tensor], Any]:

    def op(t: Tensor, u: Tensor) -> Any:
        if of_ranks(n, m, t, u):
            return f(t, u)
        else:
            return desc(ext2(f, n, m), n, t, m, u)
    return op

tsub: Callable[[Tensor, Tensor], Tensor] = ext2((lambda m, n: m - n), 0, 0)
tmul: Callable[[Tensor, Tensor], Tensor] = ext2((lambda m, n: m * n), 0, 0)
tadd: Callable[[Tensor, Tensor], Tensor] = ext2((lambda m, n: m + n), 0, 0)
tdiv: Callable[[Tensor, Tensor], Tensor] = ext2((lambda m, n: m / n), 0, 0)

def dot(t: Tensor, u: Tensor) -> Scalar:
    return sum(tmul(t, u))

dot = ext2(dot1, 1, 1)
star = ext2(tmul, 2, 1)
# 可以自己找几个例子测试一下

这种自动维度对齐机制正是深度学习框架中的广播机制核心原理。有了自动广播的拓展二元操作的函数,就能很容易得到任意两个标量的各种基本的数学操作了。

以上代码逻辑可能会显得比较抽象,这里值得多花点时间,多找几个例子帮助自己理解是非常有必要的。善用工具,比如代码编辑器的debug功能,对于思考高级张量操作非常有用。也可以对比其他实现,比如你可以发现我们实现的dot函数与numpy.dot相比,在向量操作上逻辑基本相同,但是在矩阵或者更高阶张量上的操作就很不一样了。

通过递归实现张量操作,我们不仅解决了高维数据处理问题,更深刻理解了函数式编程的强大表现力,希望你已经感受到函数式编程的魅力了。

用张量重构训练流程

有了张量这个概念,以及上面这些针对张量的操作函数,我们可以重建前文的一些代码。

首先我们应该把所有的输入输入输出和参数都用张量来表示,并且使用上面的张量操作,而不是+、—等操作符号:

# 很多类型提示都需要从list[float]改成Tensor,例如:
def line(xs: Tensor) -> Callable[[Tensor, Tensor], Tensor]:
    def _line_theta(theta):
        w, b = theta
        return   tadd(tmul(w, xs), b)

# 每一个参数也是一个张量
P = Tensor 
Theta = list[P]

# 所有针对张量的操作应该使用上面函数替代简单的加减乘除符号,比如l2_loss变成:
def l2_loss(target: Callable[[Tensor], Callable]) -> Callable:
    
    def expectant(xs: Tensor, ys: Tensor) -> Callable:
        
        def objective(theta: Theta) -> Scalar:
            
            pred_ys = target(xs)(theta)
            #使用tsub和tsum代替-和sum
            errors = tsub(ys, pred_ys)
            return tsum(tsqr(errors)) 
        return objective
    return expectant

# 梯度下降:
def gradient_descent(objective_func: Callable[[Theta], Scalar],
                     initial_theta: Theta,
                     learning_rate: Scalar,
                     num_revisions: int) -> Theta:
   
    def updata(theta: Theta) -> Theta:
        
        gradient = nabla(objective_func, theta)

        # 用tmul和tsub操作张量
        update_step = [tmul(g, learning_rate) for g in gradient]
        revised_theta = tsub(theta, update_step)
        return revised_theta

    return revise(updata, num_revisions, initial_theta)

需要注意的是我在前面说了参数用张量表示,比如在w和b可以分别是一个张量;但是没说参数集本身也是一个张量!之所以这样因为w和b的形状可能是不一样的。虽然我们的自动广播机制可以处理一些把整个参数集的当成张量的操作,为了使逻辑清楚,也为了避免不必要的错误,应该把每一个参数分开处理。

最后一个比较麻烦的是nabla函数:

import copy

# 首先需要两个帮助函数,用来获取和修改高阶张量中某个具体值
def get_tensor_value(nested_list: Tensor, indices: list[int]):
    """根据索引路径获张量中的值"""
    current = nested_list
    for idx in indices:
        current = current[idx]
    return current

def set_tensor_value(nested_list: Tensor, indices: list[int], value: Scalar):
    """根据索引路径设置张量中的值"""
    current = nested_list
    for idx in indices[:-1]:
        current = current[idx]
    current[indices[-1]] = value

def nabla(
    objective_func: Callable[[Theta], Scalar],
    theta: Theta,
    delta: Scalar = 1e-6
) -> Theta:
    
    base_loss = objective_func(theta)
    
    # 创建与theta结构相同的梯度容器
    grad_copy = copy.deepcopy(theta)
    
    def update_grad(theta_node: Theta, index_path: list[int] = []):
        
        for i, item in enumerate(theta_node):
            current_path = index_path + [i]
            # print(f"current_path: {current_path}")
            
            # --- Base Case: 如果是标量值 ---
            if is_scalar(item):
                # 创建theta的副本用于计算
                theta_copy = copy.deepcopy(theta)
                
                # 修改theta_copy中对应位置的值
                original_value = get_tensor_value(theta_copy, current_path)
                set_tensor_value(theta_copy, current_path, original_value + delta)
                
                # 计算新的损失
                new_loss = objective_func(theta_copy)
                
                # 计算梯度并存储到对应位置
                gradient = (new_loss - base_loss) / delta
                set_tensor_value(grad_copy, current_path, gradient)
                
            # --- Recursive Step: 如果是列表,递归处理 ---
            else:
                update_grad(item, current_path)
    
    update_grad(theta)
    return grad_copy

这里再一次用到了闭包!update_grad这个闭包循环地修改grad_copy,直到得到某个参数的完整的梯度。

有了这些函数,终于可以完成上文中遗留下来的测试了:

def plane(xs: Tensor) -> Callable[[Tensor, Tensor], Tensor]:
    def _plane_theta(theta):
        ws, b = theta
        return tadd(dot(ws, xs), b)

plane_xs = [[1.0, 2.05], [1.0, 3.0], [2.0, 2.0], [2.0, 3.9], [3.0, 6.13], [4.0, 8.09]]
plane_ys = [13.99, 15.99, 18.0, 22.4, 30.2, 37.94]  
# plane_ys = plane(plane_xs)([3.0, 2.0], 1.0)  

# 初始参数 (w1, w2, b)
initial_plane_theta = [[0.0, 0.0], 0.0]

# 目标函数
plane_objective = l2_loss(plane)(plane_xs, plane_ys)

plane_optimize_history = gradient_descent(
    objective_func=plane_objective,
    initial_theta=initial_plane_theta,
    learning_rate=0.001,
    num_revisions=2000
)

optimized_plane_theta = plane_optimize_history[-1]
print("Optimized Plane Parameters:", optimized_plane_theta)

这里三维空间可视化不太直观,读者可自定义测试数据,并对比训练出来的结果。

Numpy, JAX

因为本文的目的在于演示张量操作,因此没有考虑效率问题,实际操作中使用numpy等库起到加速、避免递归深度限制等问题;对于GPU加速、自动微分等功能需求,JAX等框架提供工业级解决方案。以下代码显示了如何包装Numpy的操作:

from collections.abc import Callable
import numpy as np
from autograd import grad

# ndarry是Numpy实现的张量对象;jax中是jax.Array
Tensor =  np.ndarray | float | int

def is_scalar(x) -> bool:
    return np.isscalar(x) 

# Numpy.array函数是创建ndarry对象的构造函数
def tensor(elements) -> Tensor:
    return np.array(elements)

# Numpy中的阶和形状是元数据,可以直接通过对象的ndim和shap属性获取
def rank(t: Tensor) -> int:
    return 0 if is_scalar(t) else t.ndim

def shape(t: Tensor) -> tuple[int, ...]:
    return () if is_scalar(t) else t.shape



def tsum(t: Tensor) -> Tensor:
    return t if is_scalar(t) else np.sum(t)

def zeros(shape: tuple[int, ...]) -> Tensor:
    return np.zeros(shape)
    
# 或者rank = np.ndim;shape = np.shape;tsum = np.sum……

# Ndarray对象实现了__add__和__mul__等方法,因此可以直接使用加号或者星号来得到两个张量的和或者积

dot_product = np.dot(t1, t2)

# tmap、tmap2、ext1、ext2这些所有函数都只能对应到一个函数numpy.vectorize或者jax.vmap中
# numpy.vectorize实现逻辑和我们的相同只是列表推导式把所有参数传给上一个函数
# jax.vmap函数还能显式指定展开维度
def ext(func: Callable, *tensors: Tensor) -> Tensor:
    # 这里是高阶函数
    return np.vectorize(func)(*tensors)

    
def flatten(t: Tensor) -> Tensor:
    if not isinstance(x, np.ndarray):
        t = np.array(t)
    return t.flatten()

def nabla(
    objective_func: Callable[[Theta], Scalar],
    theta: Theta
) -> Theta:

    # 注意不管是autograd还是jax中的grad函数都是高阶函数
    # Autograd的grad函数无法正确地处理list[Tensor];JAX.grad更通用
    return grad(objective_func)(theta)

这里之所以提JAX而不是更常见的Pytorch、TensorFlow等框架是因为JAX显式遵循函数式编程范式,很容易把之前学到东西转换到JAX上(JAX可以显式指定按照某个维度展开,比我们的实现要更强大,除此之外跟我们的实现效果差不多)。而且JAX的自动微分实现方式比较巧妙,使得我们可以直接使用JAX.grad代替nabla函数而不用修改任何代码(如果你理解了其中的逻辑,迁移到其他的任何框架都没问题,不过想用Pytorch,你就得用它那一整套的东西)。

还需注意使用这些库所带来的速度提升只有在数据量比较大的情况下才能看到。目前为止我们接触的数据量都非常小,所以使用它们不仅不会带来明显的加速,反倒可能会显著减慢运行速度。

总结

本文从标量、向量、矩阵的基础概念出发,引入高阶张量,介绍了张量的核心属性 rankshape,并展示了按元素映射、二元运算、以及降维求和等常用操作。尝试用 Python 实现了上述操作,帮助大家更直观地理解书中嵌套递归的编程风格。最后,还学习一点Numpy 和JAX等Python库。

本文还拓展了Nabla函数,使其能计算任意张量的梯度,并解决了上文中遇到的问题。不过当前Nabla(梯度计算)函数实现依然是基于数值微分,这种实现方式不仅复杂而且还有一些重大的缺陷。这个缺陷我们可能会在一篇博文中遭遇,不过不用担心,我也会很快介绍自动微分的实现方式。

下一篇让我们回到梯度下降,看看梯度下降的那些变体。