第 1 课:张量 Tensor —— 一切的基础
第 1 课:张量 Tensor —— 一切的基础
代码位置:src/tensor.rs 演示入口:src/main.rs
1. 本课要搞懂的问题
- 什么是张量?为什么深度学习离不开它?
- 计算机内存是"一维"的,多维数据怎么存?
- 矩阵乘法到底在算什么?代码怎么写?
2. 什么是张量
张量(Tensor)就是多维数组,是深度学习里所有数据的统一容器:
| 维度 | 名称 | 例子 |
|---|---|---|
| 0 | 标量 (scalar) | 损失值 0.37 |
| 1 | 向量 (vector) | 一个词被表示成 [0.1, -0.3, 0.8, ...] |
| 2 | 矩阵 (matrix) | 一批词向量堆在一起 |
| 3 | 3 维张量 | 一批文本(每条文本是一串词向量) |
| 4 | 4 维张量 | 一批图像(高×宽×通道) |
在我们的实现里,张量由两部分构成:
pub struct Tensor {
pub data: Vec<f32>, // 所有数值,展平成一维数组
pub shape: Vec<usize>, // 每个维度的大小
}
关键思想:内存只有一维,但我们可以通过 shape 决定"怎么解释"这些数。
3. 行优先存储(Row-Major)
假设我们要存一个 2×3 的矩阵:
[ 1 2 3 ]
[ 4 5 6 ]
行优先就是"一行一行地平铺":[1, 2, 3, 4, 5, 6]。
那么"第 i 行第 j 列"的元素在数组里的下标是:
下标 = i * 列数 + j
例如矩阵里的元素 5,它在视觉上是"第 2 行第 2 列",但编程里的行号、列号都从 0 开始数,
所以行下标 i=1、列下标 j=1,列数=3:
下标 = 1*3 + 1 = 4,data[4] = 5 ✓
容易混淆的点:"第 1 行" 和 "行下标 1" 不是一回事。 行下标 1 其实是指视觉上的第 2 行。Rust 数组下标(
vec[i])也都是从 0 开始的。
我们把这段逻辑写成了通用函数 flat_index,支持任意维度:
fn flat_index(&self, index: &[usize]) -> usize {
let mut flat = 0;
for (i, &idx) in index.iter().enumerate() {
flat = flat * self.shape[i] + idx;
}
flat
}
4. 逐元素运算(Element-wise)
两个形状相同的张量,对应位置各自做运算:
- 加法:
c[i] = a[i] + b[i] - 减法:
c[i] = a[i] - b[i] - 乘法(Hadamard 积):
c[i] = a[i] * b[i]
Rust 里用 zip 可以优雅地实现:
pub fn add(&self, other: &Tensor) -> Tensor {
assert_eq!(self.shape, other.shape, "加法要求形状相同");
Tensor {
data: self.data.iter().zip(&other.data).map(|(a, b)| a + b).collect(),
shape: self.shape.clone(),
}
}
注意:逐元素乘法
*和矩阵乘法@是完全不同的两件事!很多人刚开始会混淆。
5. 矩阵乘法(Matrix Multiplication)
这是神经网络里最重要的运算。一个线性层 y = xW + b 本质就是矩阵乘法。
数学定义:设 A 是 m×k,B 是 k×n,则 C = A×B 是 m×n,其中:
C[i][j] = Σ(k) A[i][k] * B[k][j]
几何直觉:C 的第 i 行第 j 列 = A 的第 i 行 与 B 的第 j 列 的点积(对应位置相乘再求和)。
朴素实现(三重循环):
pub fn matmul(&self, other: &Tensor) -> Tensor {
let (m, k1) = (self.shape[0], self.shape[1]);
let (k2, n) = (other.shape[0], other.shape[1]);
assert_eq!(k1, k2, "矩阵乘法维度不匹配");
let mut result = vec![0.0; m * n];
for i in 0..m {
for j in 0..n {
let mut sum = 0.0;
for k in 0..k1 {
sum += self.data[i * k1 + k] * other.data[k * n + j];
}
result[i * n + j] = sum;
}
}
Tensor { data: result, shape: vec![m, n] }
}
验证一下:
[1 2] [5 6] [1*5+2*7 1*6+2*8] [19 22]
[3 4] × [7 8] = [3*5+4*7 3*6+4*8] = [43 50]
程序输出正是 [19, 22, 43, 50] ✓
提示:三重循环复杂度是 O(m·n·k)。真实框架会用各种优化(分块、SIMD、GPU), 我们后面课程会逐步改进,但先保证正确,再谈效率。
6. 转置与 reshape
- 转置 (transpose):m×n → n×m,
C[j][i] = A[i][j]。后续反向传播中很常用。 - reshape:只改变形状、不改变数据。
[1,2,3,4,5,6]可以是 2×3,也可以是 3×2,数据顺序不变。
let a = Tensor::from_vec(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], vec![2, 3]);
let b = a.reshape(vec![3, 2]); // 形状变了,数据还是 [1..6]
7. 防御性编程习惯
好代码应该"出错早、出错明显"。我们做了两个校验:
assert_eq!(data.len(), numel, "数据长度与形状要求不一致");
assert!(idx < self.shape[i], "索引越界");
这样一旦传错参数,程序立刻 panic 并告诉我们哪里错了,而不是静默产生错误结果。
8. 运行与测试
cargo run # 运行演示
cargo test # 运行单元测试(5 个全部通过)
9. 动手练习
- 写一个函数
scalar_dot(a: &Tensor, b: &Tensor) -> f32,计算两个 1 维张量的点积。 - 给
Tensor加一个apply(f: impl Fn(f32) -> f32)方法,对每个元素应用函数 (这个能力下一课做激活函数时会用到)。 - 思考:
matmul为什么要求"A 的列数 == B 的行数"?如果 A 是 3×2、B 是 2×4,结果形状是什么?
10. 本课总结
- 张量 = 数据 + 形状,内存用行优先存储
- 学会了:创建、reshape、转置、逐元素运算、矩阵乘法
- 验证了矩阵乘法结果的正确性
- 下一课:给张量加上"梯度"和自动微分,让它能学习!