2  张量思维

“我试图想象信息在计算机中穿行的样子。”

—— 《创:战纪》 (2010)

主角萨姆第一次进入“电子世界”(The Grid)。在这里,没有任何实体,目之所及皆是流动的光轨、几何体和飞驰的光之摩托。这就是计算机内部的真实景象——纯粹的数据流动。

2.1 从标量到张量

在数学和物理学中,张量(Tensor)是对标量、向量和矩阵的广义化(Generalization)。我们可以根据张量的阶(Rank/Order)——即数据拥有多少个维度(轴)——来对它们进行分类。

为了便于直观理解,我们采用“数学定义 + R torch 代码”对照的方式来学习:

\[ \begin{array}{cccc} \text{Scalar} & \text{Vector} & \text{Matrix} & \text{Tensor} \\[1em] 1 & \begin{bmatrix} 1 \\ 2 \end{bmatrix} & \begin{bmatrix} 1 & 2 \\ 3 & 4 \end{bmatrix} & \begin{bmatrix} [1, 2] & [3, 2] \\ [1, 7] & [5, 4] \end{bmatrix} \end{array} \]

标量 (Scalar)

数学定义:一个独立的数值,只有大小,没有方向。是一个零阶张量。

  • 符号表示:\(x \in \mathbb{R}\)
  • 阶数(Rank):0
  • 形状(Shape):() (空)

在 R 语言原生环境中,其实不存在严格意义上的“标量”(即使是 x <- 5,也是一个长度为 1 的向量)。但在 torch 中,标量是真实存在的。

# 创建一个 0 阶张量 (标量)
scalar <- torch_tensor(5)

print(scalar)
# 输出: torch_tensor
# 5
# [ CPUFloatType{} ]

# 查看形状
scalar$size() 
# 输出: list()  <-- 注意这里是空的,表示没有维度

向量 (Vector)

数学定义:一列有序排列的数。是一个一阶张量。

  • 符号表示:\(\mathbf{x} \in \mathbb{R}^n\)
  • 阶数(Rank):1
  • 形状(Shape):(n)
  • 应用场景:偏置(Bias)、一维时间序列。
# 创建一个 1 阶张量 (向量)
vector <- torch_tensor(c(1, 2, 3))
vector$size() 
# 输出: [[1]] 3   <-- 只有一个轴,长度为 3

矩阵 (Matrix)

数学定义:由行和列组成的二维数组。是一个二阶张量。

  • 符号表示:\(\mathbf{A} \in \mathbb{R}^{m \times n}\)
  • 阶数(Rank):2
  • 形状(Shape):(m, n)
  • 应用场景:结构化表格数据、线性层的权重矩阵。
matrix_r <- matrix(1:6, nrow = 2, ncol = 3)
matrix_tensor <- torch_tensor(matrix_r)
matrix_tensor$shape
# 输出: [1] 2 3

高阶张量

数学定义:矩阵向更高维度的扩展。可以将 3 阶张量想象为“立方体”或“多个矩阵摞在一起”。

  • 符号表示:\(\mathcal{T} \in \mathbb{R}^{d_1 \times d_2 \times \dots \times d_n}\)
  • 阶数(Rank):\(n\) (\(n \ge 3\))
  • 应用场景:
    • 3阶:彩色图像 (通道数 \(C \times\)\(H \times\)\(W\))
    • 4阶:图像批次 (批大小 \(N \times C \times H \times W\))
    • 5阶:视频数据 (批大小 \(N \times\) 帧数 \(T \times C \times H \times W\))

这也是 R 用户最容易感到困惑的地方。R 的 array 是列优先(Column-major),而 torch 也是兼容的,但深度学习通常遵循特定的维度约定(如 NCHW)。

# 创建一个随机的 3 阶张量 (模拟一张 224x224 的 RGB 彩色图片)
# 维度顺序通常为: (Channels, Height, Width)
image_tensor <- torch_randn(3, 224, 224)

print(image_tensor$shape) # $shape 是 $size() 的别名
# 输出: [3, 224, 224]

Rank 与 Shape 的防混淆指南:

  • Rank (阶):指张量有多少个轴(Axis)。即,你需要几个索引才能定位到一个具体的数字。image_tensor[1, 10, 10] 需要 3 个索引,所以 Rank = 3。
  • Shape (形状):指每个轴上具体的长度。例如上图的 Shape 是 (3, 224, 224)。

2.2 张量的核心操作

如果说张量是深度学习的原子,那么张量操作就是我们在微观世界中操纵物质的手段。对于熟悉 R 语言的读者来说,好消息是 R torch 包在设计上极力模仿了 R 原生数组(Array)和矩阵(Matrix)的行为逻辑;坏消息是,为了满足高性能计算和自动微分的需求,它引入了一些必须遵守的特定规则。

一、创建与数据类型 (Dtype)

在 R 中,numeric 默认是双精度浮点数(Float64)。但在深度学习中,为了节省显存和加速计算,标准类型通常是单精度(Float32)或整数(Int64)。

# 从 R 向量创建,默认转为 Float32
v <- c(1, 2, 3)
t1 <- torch_tensor(v) 

# 工厂函数生成特定张量
x_rand <- torch_randn(3, 3)  # 标准正态分布
x_zeros <- torch_zeros(2, 5) # 全 0
x_ones  <- torch_ones(2, 5)  # 全 1
x_seq <- torch_arange(start = 1, end = 10, step = 1) # 序列
x_eye <- torch_eye(3) # 生成 3x3 的单位矩阵
x_diag <- torch_diag(torch_tensor(1:3)) # 生成对角矩阵

# 类型转换 (Casting):实战高频操作
x_int <- t1$to(dtype = torch_int64())
x_bool <- t1$to(dtype = torch_bool())

二、内存引用 vs 写时复制

这是 R 用户最容易写出 Bug 的地方。R 语言采用写时复制(Copy-on-modify),而 Torch 张量默认是内存引用(Reference)。

操作 a <- c(1,2) a <- torch_tensor(c(1,2))
赋值给新变量 b <- a b <- a
修改新变量 b[1] <- 99 b[1] <- 99
原变量结果 a 保持不变,仍为 c(1, 2) a 也会变成 c(99, 2)

如果你需要一个完全独立的副本,必须使用 $clone()

b <- a$clone() # 此时修改 b 不会影响 a

三、索引、切片与条件查找

好消息是,R torch 保留了 R 语言从 1 开始索引(1-based indexing)的习惯。你可以无缝迁移 matrix 的操作经验。

x <- torch_randn(4, 4)

row_1 <- x[1, ]       # 选取第 1 行
subset <- x[1:2, 3]   # 选取第 1 到 2 行,第 3 列
rest <- x[-1, ]       # 负索引:排除第 1 行

# 逻辑索引 (Masking)
positive_vals <- x[x > 0]

# 条件查找 (类似 R 的 ifelse)
# torch_where(条件, 为真时返回, 为假时返回)
x_clipped <- torch_where(x > 0, x, torch_tensor(0))

2.3 形状与维度操作

在搭建神经网络时,数据的维度在不同层之间不断重塑。掌握维度操作是写出顺畅代码的关键。

一、View 与 Reshape

  • view():要求张量在内存中连续,效率极高,不复制数据。
  • reshape():更通用,如果内存不连续会自动克隆数据。
x <- torch_arange(1, 12)

# 变为 3x4 矩阵
m <- x$view(c(3, 4))

# 维度推断:使用 -1 (表示"行数我定了,列数你自己算")
m_auto <- x$view(c(3, -1))

二、维度交换

R 基础包使用 aperm() 交换数组维度,在 torch 中使用 permute()。图像处理中经常需要将 R 习惯的 (Height, Width, Channels) 转换为 PyTorch 模型需要的 (Channels, Height, Width)

img <- torch_randn(256, 256, 3) # (H, W, C)

# 将第 3 维提到第 1 维,其余顺延
img_permuted <- img$permute(c(3, 1, 2)) # 新形状: (3, 256, 256)

三、挤压、扩张与拼接

在批处理(Batch)运算中,维度对齐至关重要。有时我们需要人为地增加或减少一个维度。

  • unsqueeze(dim):在指定位置插入一个长度为 1 的维度。
  • squeeze(dim):移除指定位置长度为 1 的维度。
x <- torch_tensor(c(1, 2, 3)) # 形状: (3)

# 增加 Batch 维度,使其变成 "Batch Size 为 1 的向量"
x_batch <- x$unsqueeze(1)     # 形状: (1, 3)

# 恢复原状
x_original <- x_batch$squeeze(1) # 形状: (3)

当你把一张图片输入模型预测时,模型通常期待 (Batch, Channel, Height, Width)。如果你只读入了一张图 (C, H, W),必须使用 unsqueeze(1) 变成 (1, C, H, W) 才能被模型接受。

torch_cat() vs torch_stack():拼接数据的两把利器。

  • cat:在现有维度拼接(不改变 Rank)。
  • stack:沿着新维度堆叠(Rank + 1)。
t1 <- torch_randn(3, 4)
t2 <- torch_randn(3, 4)

# 拼接:形状变为 (6, 4) -> 依然是 2 阶矩阵
c_tensor <- torch_cat(list(t1, t2), dim = 1) 

# 堆叠:形状变为 (2, 3, 4) -> 变成了 3 阶张量
s_tensor <- torch_stack(list(t1, t2), dim = 1)

2.4 线性代数运算实战

如果张量是静止的数据,线性代数就是驱动神经网络运转的引擎。

2.4.1 基础算数

我们可以对张量进行所有通常的数学运算:加、减、除……这些操作可以作为函数(以 torch_ 开头)以及对象的方法(用$-syntax调用)。例如,以下是等价的:

> t1 <- torch_tensor(c(1, 2))
> t2 <- torch_tensor(c(3, 4))
> t1 + t2
torch_tensor
 4
 6
[ CPUFloatType{2} ]
> torch_add(t1, t2)
torch_tensor
 4
 6
[ CPUFloatType{2} ]
> t1$add(t2)
torch_tensor
 4
 6
[ CPUFloatType{2} ]

同理还有 torch_sub(), torch_mul(), torch_div() 等函数,以及 $sub(), $mul(), $div() 等方法。这里有个小知识点需要注意,原地操作:

  • 所有以 _ 结尾的方法都是原地操作,会直接修改调用它的张量,而不是返回新的张量。
  • 例如,$add_() 会将调用它的张量的值加上传入的参数,而不是返回一个新的张量。
x <- torch_tensor(c(1, 2, 3))
y <- x$sub(1)    # 非原地:创建新张量 [0, 1, 2],x 还是 [1, 2, 3]
x$sub_(1)        # 原地:直接修改 x 为 [0, 1, 2]

点积(Dot Product)是这样计算的:

> t1 <- torch_tensor(c(1, 2))
> t2 <- torch_tensor(c(3, 4))
> torch_dot(t1, t2)
torch_tensor
 11
[ CPUFloatType{1} ]
> t1$dot(t2)
torch_tensor
 11
[ CPUFloatType{1} ]

这是最基础的运算,对应于 R 语言中的标准算术操作。当两个张量形状完全一致时,加减乘除都是发生在对应位置的元素之间。

  • Hadamard 积:即逐元素相乘。在 R 中我们使用 *,在 torch 中也是 *(或 torch_mul)。
  • 数学表示:\(\mathbf{C} = \mathbf{A} \odot \mathbf{B}\),即 \(c_{ij} = a_{ij} \times b_{ij}\)
library(torch)

a <- torch_tensor(matrix(c(1, 2, 3, 4), ncol = 2))
b <- torch_tensor(matrix(c(1, 0, 1, 0), ncol = 2))

# 逐元素相乘
# 等价为 torch_multiply(a, b)
# 结果:
# 1  3
# 0  0
res <- a * b

注意:这不是矩阵乘法。注意区别 *matmul

2.4.2 矩阵乘法

矩阵乘法(Matrix Multiplication)是几乎所有神经网络层的计算核心。

  • R 的习惯:我们习惯使用 %*% 算符。
  • Torch 的方式:使用 $matmul() 方法或 torch_matmul() 函数。

1. 基础矩阵乘法

规则:\((M \times K)\) 的矩阵乘以 \((K \times N)\) 的矩阵,得到 \((M \times N)\) 的结果。

# 形状: (2, 3)
A <- torch_randn(2, 3)
# 形状: (3, 4)
B <- torch_randn(3, 4)

# 结果形状: (2, 4)
C <- A$matmul(B)

2. 批量矩阵乘法 (Batch Matmul)

这是 Torch 强大的地方。假设你有一个 Batch 的数据,形状为 \((B, N, M)\),你需要对每个样本都乘以同一个权重矩阵 \((M, K)\)。Torch 会自动识别并处理前面的 Batch 维度,这被称为“批次感知”。

# Batch size=10, 序列长度=20, 特征维度=5
inputs <- torch_randn(10, 20, 5) 

# 线性层权重: 5 -> 8
weights <- torch_randn(5, 8)

# 自动处理 Batch 维度
# 结果形状: (10, 20, 8)
outputs <- inputs$matmul(weights)

2.4.3 广播机制

在 R 语言的基础操作中,如果你把长度为 3 的向量加到长度为 10 的向量上,R 会尝试“循环补齐(Recycle)”,如果不整除则会给出一个 Warning。

Torch 的规则完全不同且更加严格。广播机制(Broadcasting)允许我们在不同形状的张量之间进行算术运算,但必须满足特定的几何条件。

规则:从两个张量的最右边(最后一个维度)开始向左匹配,如果满足以下任一条件,则认为该维度是兼容的:

  1. 两个维度的大小相等。
  2. 其中一个维度的大小为 1。

如果维度为 1,Torch 就会在这个维度上将数据“复制”或“拉伸”,直到与另一个张量匹配。

实战:图像归一化

假设我们有一批 RGB 图像,形状为 (32, 3, 256, 256) —— (Batch, Channel, Height, Width)。 我们要对每个通道减去均值 mean = c(0.5, 0.5, 0.5)

mean 张量的原始形状是 (3)。直接相减会报错,因为从右边对齐时,2563 不匹配。我们需要将 mean 变形为 (3, 1, 1)

# 模拟图像数据
images <- torch_randn(32, 3, 256, 256)

# 均值向量
means <- torch_tensor(c(0.5, 0.5, 0.5))

# 正确写法:利用 view 调整维度以满足广播规则
# 变成 (1, 3, 1, 1) 或 (3, 1, 1),从右向左匹配
means_broadcastable <- means$view(c(3, 1, 1))

# 广播过程:
# means (3, 1, 1) 在第 2, 3 维被拉伸成 (3, 256, 256)
# 然后在第 0 维(Batch)被逻辑复制 32 次
normalized <- images - means_broadcastable

广播就像是只有当一个维度是“1”时,可以变成任意数字;否则它必须和对方严丝合缝。

2.4.4 聚合操作

在计算损失函数(如 MSE 或 CrossEntropy)时,我们经常需要将高维张量压缩成一个标量或低维张量。

  • sum(): 求和
  • mean(): 求平均
  • max() / argmax(): 求最大值及其索引

关键参数:dimkeepdim

R 用户习惯 colMeansrowMeans,但在 Torch 中,我们统一使用 dim 参数指定“要消灭的维度”。

x <- torch_tensor(matrix(c(1, 2, 3, 4), ncol = 2))
# 全局平均
avg_all <- x$mean()

# 按行求平均(消灭列维度,即第 2 维)
# 结果形状: (2)
row_means <- x$mean(dim = 2)

# 保持维度 (Keepdim)
# 结果形状: (2, 1) —— 这是一个列向量形状
# 这对于后续的广播运算非常重要!
row_means_keep <- x$mean(dim = 2, keepdim = TRUE)

2.4.5 爱因斯坦求和约定

在实现复杂的现代网络(如 Transformer 中的多头注意力)时,维度的 permutematmul 组合会让代码变得晦涩难懂。函数 torch_einsum 提供了一种用字符串描述运算的优雅方式。它允许你用一个简洁的字符串公式来描述复杂的张量运算,比如矩阵乘法、转置、张量收缩、加权求和等。

假设我们要计算 \(C = \sum_k A_{ik} B_{kj}\) (矩阵乘法):

A <- torch_randn(2, 3)
B <- torch_randn(3, 4)

# 这里的 "ik,kj->ij" 像写公式一样描述了运算逻辑
# 此表达式等价于 A$matmul(B)
C <- torch_einsum("ik,kj->ij", list(A, B))

图像加权灰度化:

img <- torch_randn(c(3,5,5))   # RGB图像
w   <- torch_tensor(c(0.3,0.6,0.1))
gray <- torch_einsum("chw,c->hw", list(img, w))
# 输出 5x5 灰度图

虽然初看起来抽象,但在处理高维张量逻辑时,它是最清晰的文档。

2.5 计算设备切换

张量操作的最后一步,通常是将数据移动到计算设备上。还记得上一章最后演示矩阵乘法在不同环境下的差别吗?这是 torch 区别于普通 R 矩阵的杀手级功能。

# 检查 GPU 是否可用
device <- if (backends_mps_is_available()) {
  torch_device("mps") # Mac M1/M2/M3
} else if (cuda_is_available()) {
  torch_device("cuda") # NVIDIA GPU
} else {
  torch_device("cpu")
}

x <- torch_randn(100, 100)

# 将张量移动到 GPU
x_gpu <- x$to(device = device)

# 在 GPU 上进行运算
result <- x_gpu$matmul(x_gpu)

# 将结果移回 CPU(例如为了绘图或保存)
result_cpu <- result$to(device = "cpu")

至此,所有的准备工作就绪。在下一章,我们将赋予这些数学公式以“智能”——探索自动微分(Autograd)的奥秘,看看机器是如何完成学习的。