# 1.3. 使用ndarray准备数据
Rust的所有矩阵类型全部使用[`ndarray`](https://docs.rs/ndarray/0.17/ndarray/)提供的类型。你如果你想要更加详细地了解ndarray的进阶用法,比如广播规则、各类迭代器、线性代数辅助函数,可以去看[官方ndarray文档](https://docs.rs/ndarray/0.17/ndarray/)。
RustyML 0.15依赖ndarray 0.17。在你自己的`Cargo.toml`里需要使用相同的ndarry版本:
```toml
[dependencies]
ndarray = "0.17"
rustyml = { version = "0.15", features = ["full"] }
```
## 1.3.1. f64与f32
RustyML中经典机器学习矩阵元素类型是**`f64`**,神经网络栈张量元素用**`f32`**。
经典机器学习估计器(详见[`machine_learning`](../Chapter-02/2.0._经典机器学习.md))接受一个二维的**`Array2<f64>`**特征矩阵,按样本x特征排布(每行一个样本),还需要接收一个一维的**`Array1<_>`**目标向量。目标的元素类型随模型而变:
| `LinearRegression` | `Array1<f64>` | `Array1<f64>` |
| `LogisticRegression` | `Array1<f64>`(0.0 / 1.0) | `Array1<f64>`(0.0 / 1.0) |
| `DecisionTree` | `Array1<f64>` | `Array1<f64>` |
| `SVC`、`LinearSVC` | `Array1<f64>` | `Array1<f64>` |
| `LDA` | `Array1<i32>` | `Array1<i32>` |
| `KNN<T>` | `Array1<T>`(`T: Clone + Hash + Eq`) | `Array1<T>` |
| `KMeans`、`MeanShift`、`DBSCAN` | *(无监督)* | `Array1<isize>`(簇id,`-1`表示噪声) |
| `IsolationForest` | *(无监督)* | `Array1<i32>`(`-1`离群 / `+1`正常;分数走`score_samples`) |
| `PCA`、`KernelPCA` | *(无监督)* | `Array2<f64>`(经由`transform`) |
神经网络栈使用[`Tensor`](https://docs.rs/rustyml/latest/rustyml/neural_network/type.Tensor.html),它是一个类型别名:
```rust,ignore
pub type Tensor = ArrayD<f32>;
```
`Tensor`相比经典机器学习的矩阵有两处不同:
- 元素类型是**`f32`**:精度减半、内存减半,也是每个深度学习框架都统一采用的位宽,因为神经网络是计算密集型任务,值得用部分进度换取更大的吞吐量。
- 维度是**动态**的:`ArrayD`(等价于 `Array<f32, IxDyn>`)在运行时携带自己的阶(rank),而非把它编进类型里。阶不对会在运行时以`Err`报错,详见[1.3.7](#137-形状不匹配错误)。
## 1.3.2. 构造数组
以下是一些常见的构造`ndarray`矩阵类型的方式:
```rust
use ndarray::{array, Array, Array1, Array2};
fn main() {
// `array!`: 形状由嵌套推断而来,比如这是一个3x2的`Array2<f64>`
let m = array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]];
assert_eq!(m.shape(), &[3, 2]);
// `from_shape_vec`: (形状, 行主序扁平 Vec)
// 长度必须等于形状各维之积,否则返回 Err
let x = Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
assert_eq!(x.shape(), &[2, 3]);
// `from_shape_fn`: 通过参数指定形状,并且可以通过闭包来制造每个元素的值
let g = Array2::from_shape_fn((3, 2), |(i, j)| (i * 2 + j) as f64);
assert_eq!(g[[2, 1]], 5.0);
// `from_vec`: 把`Vec<T>`转化成`Array1<T>`
let y = Array1::from_vec(vec![2.0, 4.0, 6.0]);
// `from_iter`: 使用迭代器的值来填充`Array1<T>`
let labels = Array1::from_iter(0..3i32);
// 常量 / 全0 / 全1填充,需要指定元素类型
let zeros = Array2::<f64>::zeros((2, 2));
let ones = Array2::<f64>::ones((2, 2));
let filled = Array::from_elem((2, 2), 7.0f64);
println!("{} {} {} {}", y.len(), labels.len(), zeros.sum(), ones.sum());
println!("{}", filled.sum());
}
```
大部分的数据会以`Vec`嵌套的形式出现,这时候就应该使用`from_shape_vec`。它按**行主序**读取这个`Vec`。`from_shape_vec`的返回值被`Result`包裹,如果返回值是`Err`,大概率是形状没写对。
很多时候我们希望**随机**初始化矩阵内容。这时候可以使用[`ndarray-rand` crate](https://crates.io/crates/ndarray-rand)提供了`Array::random`,你需要在`Cargo.toml`里面把它加入依赖项:
```toml
[dependencies]
ndarray-rand = "0.16"
ndarray = "0.17"
```
代码例:
```rust
use ndarray::Array;
use ndarray_rand::RandomExt; // 给ndarray的矩阵类型使用的用于构建随机元素生成的扩展trait
// 把它引入作用域才有`Array::random`可以用
use ndarray_rand::rand_distr::Uniform; // 在指定的上限和下限之间均匀采样以生成随机数
fn main() {
let w = Array::random((4, 3), Uniform::new(-1.0, 1.0).unwrap());
println!("{:?}", w.shape());
}
```
搭配RustyML使用时不要手动去给`ndarray-rand`的随机数生成器播种。RustyML有自己的随机性管理系统(`set_global_seed`、每个模型上的`with_random_state`,以及`Sequential::new_with_seed`),你可以通过RustyML提供的API来控制随机性,详见[7.1 可复现性与随机种子](../Chapter-07/7.1._可复现性与随机种子.md)。
## 1.3.3. 经典机器学习估计器的输入
下面是典型的*有监督*经典估计器`fit`和`predict`的签名长相:
```rust,ignore
pub fn fit<S1, S2>(&mut self, x: &ArrayBase<S1, Ix2>, y: &ArrayBase<S2, Ix1>) -> Result<&mut Self, Error>
where
S1: Data<Elem = f64>,
S2: Data<Elem = f64>,
```
有两处与它不同,在[1.3.1](#131-f64与f32)的表格里都能看到:`LDA`的`y`是`Data<Elem = i32>`而不是`f64`;无监督的估计器(`KMeans`、`DBSCAN`、`MeanShift`、`PCA`、`KernelPCA`)只接收特征矩阵,根本没有`y`这个参数。
ndarray中有两种引用类型:
- 拥有型数组的引用(`&Array`)
- 视图(`ArrayView`)
- 这两种写法都是零拷贝的,RustyML两种都接受。但参数类型是*引用*,所以要写成`&array`或`&array.view()`,直接传`array.view()`是通不过类型检查的。
同时还得保证特征矩阵是二维(`Ix2`)的,目标向量是一维的(`Ix1`)。
```rust
use rustyml::machine_learning::LinearRegression;
use ndarray::{s, Array1, Array2};
fn main() {
let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
let y = Array1::from_vec(vec![2.0, 4.0, 6.0, 8.0]);
let mut model = LinearRegression::new(true);
// 拥有型数组的引用
model.fit(&x, &y).unwrap();
// 视图的引用
model.fit(&x.view(), &y.view()).unwrap();
//切片的引用:只在前三行上训练
// 这个子视图是对`x`的借用
let x_head = x.slice(s![0..3, ..]);
let y_head = y.slice(s![0..3]);
model.fit(&x_head, &y_head).unwrap();
}
```
`.view()` 和 `.slice(..)` 真正的价值在它们让你能把数组的一个**子集**(若干行、跨步窗口、reshape过的单列)直接喂给`fit`或`predict`,而不必先把这个子集实例化成一个新的拥有型数组。假如说在一个1000行矩阵的0到800行上训练时就可以使用`x.slice(s![0..800, ..])`。
## 1.3.4. 神经网络栈的输入
神经网络层要的是`ArrayD<f32>`,但你几乎从不直接构造它。最常见的做法是先建一个固定维度的数组,然后转为动态维度`IxDyn`。这个转换只是一层轻量包装,不碰底层数据:
```rust
use rustyml::neural_network::sequential::Sequential;
use rustyml::neural_network::layers::{Activation, Dense};
use rustyml::neural_network::optimizers::Adam;
use rustyml::neural_network::losses::MeanSquaredError;
use ndarray::Array2;
fn main() {
// 4个样本,2个特征,先建成`Array2<f32>`,再转为`ArrayD`
let x = Array2::from_shape_vec(
(4, 2),
vec![0.0f32, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0],
)
.unwrap()
.into_dyn(); // 转为动态维度张量
let y = Array2::from_shape_vec((4, 1), vec![0.0f32, 1.0, 1.0, 0.0])
.unwrap()
.into_dyn(); // 转为动态维度张量
let mut model = Sequential::new();
model
.add(Dense::new(2, 4, Activation::ReLU).unwrap())
.add(Dense::new(4, 1, Activation::Linear).unwrap());
model.compile(Adam::new(0.001, 0.9, 0.999, 1e-8, 0.0).unwrap(), MeanSquaredError::new());
model.fit(&x, &y, 5).unwrap();
let preds = model.predict(&x).unwrap();
println!("prediction tensor shape: {:?}", preds.shape());
}
```
输出:
```
prediction tensor shape: [4, 1]
```
## 1.3.5. 输入形状
RustyML的输入格式为**一行是一个样本,一列是一个特征**。
神经网络层的张量形状跟Keras默认的`(batch, height, width, channels)`一致。通道轴永远是最后一根,batch轴永远是最前一根,空间轴夹在中间。对于Keras的用户来说应该很好上手。下面是精确的布局:
| `Conv1D` | `[batch, length, Cin]` | `[k, Cin, filters]` | `[filters]` | `[batch, out_len, filters]` |
| `Conv2D` | `[batch, height, width, Cin]` | `[kh, kw, Cin, filters]` | `[filters]` | `[batch, out_h, out_w, filters]` |
| `Conv3D` | `[batch, depth, height, width, Cin]` | `[kd, kh, kw, Cin, filters]` | `[filters]` | `[batch, out_d, out_h, out_w, filters]` |
| `Conv1DTranspose` | `[batch, length, Cin]` | `[k, filters, Cin]` | `[filters]` | `[batch, out_len, filters]` |
| `Conv2DTranspose` | `[batch, height, width, Cin]` | `[kh, kw, filters, Cin]` | `[filters]` | `[batch, out_h, out_w, filters]` |
| `Conv3DTranspose` | `[batch, depth, height, width, Cin]` | `[kd, kh, kw, filters, Cin]` | `[filters]` | `[batch, out_d, out_h, out_w, filters]` |
| `DepthwiseConv1D` | `[batch, length, C]` | `[k, C, dm]` | `[C·dm]` | `[batch, out_len, C·dm]` |
| `DepthwiseConv2D` | `[batch, height, width, C]` | `[kh, kw, C, dm]` | `[C·dm]` | `[batch, out_h, out_w, C·dm]` |
| `SeparableConv1D` | `[batch, length, Cin]` | depthwise `[k, Cin, dm]`、pointwise `[1, Cin·dm, filters]` | `[filters]` | `[batch, out_len, filters]` |
| `SeparableConv2D` | `[batch, height, width, Cin]` | depthwise `[kh, kw, Cin, dm]`、pointwise `[1, 1, Cin·dm, filters]` | `[filters]` | `[batch, out_h, out_w, filters]` |
| `MaxPooling{1,2,3}D`、`AveragePooling{1,2,3}D` | `[batch, spatial…, C]` | — | — | `[batch, pooled…, C]` |
| `GlobalMaxPooling{1,2,3}D`、`GlobalAveragePooling{1,2,3}D` | `[batch, spatial…, C]` | — | — | `[batch, C]` |
| `SpatialDropout{1,2,3}D` | `[batch, spatial…, C]` | — | — | 与输入同形 |
| `BatchNormalization`、`GroupNormalization`、`InstanceNormalization` | `[batch, spatial…, C]` | `gamma`/`beta` `[C]` | — | 与输入同形 |
| `PReLU` | `[batch, d1…]` | `alpha [d1…]`,每个共享轴上为 `1` | — | 与输入同形 |
例子:
- 一批8张单通道28×28的图像是`[8, 28, 28, 1]`
- 一组产生16张特征图、kernel为3×3的滤波器,权重形状是`[3, 3, 1, 16]`
下面是一个代码例:
```rust
use rustyml::neural_network::layers::{Activation, Conv1D};
use rustyml::neural_network::traits::Layer;
use ndarray::Array;
fn main() {
// filters=2, kernel=3, input_shape=[batch, length, channels], stride=1
let mut conv = Conv1D::new(2, 3, vec![1, 6, 1], 1, Activation::Linear).unwrap();
// 通道在后:[batch=1, length=6, channels=1]
let input = Array::from_shape_vec((1, 6, 1), vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0])
.unwrap()
.into_dyn();
let output = conv.forward(&input).unwrap();
println!("conv output shape: {:?}", output.shape()); // [1, 4, 2]
}
```
构造器的`input_shape`参数记录了这一层针对的`[batch, length, channels]`。传入一个阶或通道数对不上的张量,会在`forward`时被拒。逐层的细节见[3.5 卷积层](../Chapter-03/3.5._卷积层.md)与[3.6 池化层](../Chapter-03/3.6._池化层.md)。
## 1.3.6. 实战建议
真实数据很少直接以`Array2`的形式到来。最常见的形态是 `Vec<Vec<f64>>`(每个内层`Vec`对应解析出来的一行CSV)。转换的办法是把它压平成一个行主序`Vec<T>`,再数出`(rows, cols)`一起交给`from_shape_vec`:
```rust
use ndarray::Array2;
fn main() {
// 假设从CSV解析而来:3行 2列
let rows: Vec<Vec<f64>> = vec![
vec![1.0, 2.0],
vec![3.0, 4.0],
vec![5.0, 6.0],
];
let n_rows = rows.len();
let n_cols = rows[0].len();
// `flatten()`按行主序展平嵌套向量
let flat: Vec<f64> = rows.into_iter().flatten().collect();
let x = Array2::from_shape_vec((n_rows, n_cols), flat).unwrap();
assert_eq!(x.shape(), &[3, 2]);
println!("{:?}", x.row(0)); // [1.0, 2.0]
}
```
如果你的行长短不齐`flatten`照样能展平嵌套向量,但它的长度不会等于 `n_rows * n_cols`,`from_shape_vec`会返回`Err`。
其他的使用工具:
- [`s!`](https://docs.rs/ndarray/0.17/ndarray/macro.s.html)宏可以用来表达区间和跨步
- `concatenate`沿已有的轴把数组接起来
- `outer_iter`(等价于 `axis_iter(Axis(0))`)把每一行当作一维视图逐个走过,这正是逐样本迭代的方式
代码例:
```rust
use ndarray::{array, concatenate, s, Axis};
fn main() {
let x = array![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]];
// 切片: 所有行,前两列(丢掉一个特征)
let first_two_cols = x.slice(s![.., 0..2]);
assert_eq!(first_two_cols.shape(), &[3, 2]);
// 切片: 取单行作为子矩阵
let middle = x.slice(s![1..2, ..]);
assert_eq!(middle.shape(), &[1, 3]);
// 沿0轴拼接两个矩阵(追加行)
let more = array![[10.0, 11.0, 12.0]];
let stacked = concatenate(Axis(0), &[x.view(), more.view()]).unwrap();
assert_eq!(stacked.shape(), &[4, 3]);
// 以`ArrayView1`迭代每一行
// 逐样本的惯用写法
for (i, row) in x.outer_iter().enumerate() {
println!("row {i} sums to {}", row.sum());
}
}
```
把原始标签变成模型想要的`Array1`,通常就是对源数据`map`一下再`collect`成数组。当某个模型或损失函数要的是**one-hot**目标、而不是整数类别id时,用RustyML提供的[`to_categorical`](../Chapter-04/4.3._标签编码.md)函数,它把一个装着类别id的`Array1<i32>`变成`Array2<f64>`的one-hot矩阵:
```rust
use rustyml::utils::to_categorical;
use ndarray::Array1;
fn main() {
// 把字符串标签映射为整数类别id,再转成`Array1<i32>`
let raw = ["cat", "dog", "cat", "bird"];
let ids: Array1<i32> = raw
.iter()
.map(|s| match *s {
"cat" => 0,
"dog" => 1,
_ => 2,
})
.collect::<Vec<_>>()
.into();
// one-hot编码:4个样本,3个类别 -> (4, 3)的`Array2<f64>`
let onehot = to_categorical(&ids, None).unwrap();
assert_eq!(onehot.shape(), &[4, 3]);
println!("{:?}", onehot.row(1)); // [0.0, 1.0, 0.0]
}
```
标签编码、它的逆操作,以及保留映射关系的各种变体,在[4.3 标签编码](../Chapter-04/4.3._标签编码.md)里有完整介绍。用`standardize`和`normalize`预处理特征矩阵则见[4.2 标准化与归一化](../Chapter-04/4.2._标准化与归一化.md)。
## 1.3.7. 形状不匹配错误
有两套不同的错误系统在抓形状问题。发生在你**构造**数组阶段的错误来自ndarray,比如当缓冲区长度和形状对不上时,`from_shape_vec`返回ndarray的`ShapeError`。发生在**RustyML**运行期间的错误会返回RustyML自己的[`Error`](../Chapter-01/1.6._错误处理.md)枚举,它还会对错误进行进一步细分:
- `Error::DimensionMismatch { expected, found }`:两个标量不一致,比如`predict`时的特征数与`fit`时不一致,又比如`x.nrows()`与`y.len()`不一致
- `Error::ShapeMismatch { expected, found }`:两个完整的张量形状对不上,比如神经网络栈里某个梯度和它要流入的激活形状不符
- `Error::InvalidInput(_)`:输入的阶是错的,比如把一个二维张量递给期望三维的 `Conv1D`
最常撞上的错误是特征数不匹配,比如用`p`个特征训练,随后拿一个列数不同的矩阵去调`predict`。RustyML会校验这一点并返回`DimensionMismatch`:
```rust
use rustyml::machine_learning::LinearRegression;
use rustyml::error::Error;
use ndarray::{Array1, Array2};
fn main() {
// 用2个特征训练
let x = Array2::from_shape_vec((3, 2), vec![1.0, 2.0, 2.0, 3.0, 3.0, 4.0]).unwrap();
let y = Array1::from_vec(vec![6.0, 9.0, 12.0]);
let mut model = LinearRegression::new(true);
model.fit(&x, &y).unwrap();
// 用3个特征预测
// 模型会返回错误
let bad = Array2::from_shape_vec((1, 3), vec![4.0, 5.0, 6.0]).unwrap();
match model.predict(&bad) {
Err(Error::DimensionMismatch { expected, found }) => {
println!("expected {expected} features, got {found}");
}
other => println!("unexpected: {other:?}"),
}
}
```
因为`Error`标了`#[non_exhaustive]`,对它做`match`时必须要带`_ =>`这种分支,这样在新增错误变体时代码依然能编译,详细的错误处理信息见[1.6 错误处理](../Chapter-01/1.6._错误处理.md)。