📕 第3章 深度学习的体验
📄 一句话概括

NumPy 提供一种数据结构 ndarray 和一堆针对它的运算。 深度学习里所有数据都是它——图片是三维数组、词向量是二维数组、权重是矩阵。 要盯住三件事:shape(形状)、dtype(类型)、视图还是拷贝。九成的报错都出在形状上。 这一节把三件事都跑了一遍:3×1 加 1×4 广播成 3×4;切片改一个字原数组跟着变; 同样的求和,for 循环 25.0 毫秒、向量化 0.31 毫秒,差八十倍。

📝 本节和前面几节的关系

上一节讲了在哪儿写代码,这一节讲写代码时用得最多的库。 第 4 章之后所有 Demo 的第一步几乎都是 import numpy as np。 这一节的四个概念(shape、广播、视图、向量化)会反复出现。 如果只用过 Python 的 list,把 NumPy 想成「能整体做加减乘除的表格」就好。

一、三个属性:shape、dtype、轴

np.array([1,2,3,4,5,6]).reshape(2,3) 造出一个 2 行 3 列的数组,shape 是 (2, 3), dtype 是 int64。 shape 是一串数,从外往里数:(2, 3) 是「2 行、每行 3 个」; 一张彩色图是 (高, 宽, 3),一批图是 (张数, 高, 宽, 3)。 轴(axis)就是维度的编号,从 0 开始。a.sum(axis=0) 是「沿着第 0 维压扁」, 也就是把各行加起来得到一个长度 3 的数组。搞不清 axis 时,记住「axis=k 表示这一维被消掉」。

二、创建与变形

常用的几个构造函数:np.array(从列表造)、np.zeros(全 0)、np.ones(全 1)、 np.arange(等差数列)、np.linspace(等分区间)。 reshape 不改变数据,只换一个看法——(2,3) 变成 (3,2) 还是那 6 个数,只是排列方式变了。 所以元素总数必须对得上,对不上直接报错。 深度学习里最常做的一次变形是 flatten 或 reshape(n, -1):把一张 32×32 的图摊成 1024 维的向量, 喂给全连结层。-1 的意思是「这一维你自己算」。

三、广播

两个形状不同的数组做运算,NumPy 会试着把它们「拉」成同一个形状,这叫广播。 规则一句话:从最后一维往前比,每一维要么相等、要么其中一个是 1。满足就拉长,不满足就报错。 实测:一个 3×1 加一个 1×4,结果是 3×4。3×1 被横向复制 4 份,1×4 被纵向复制 3 份,然后逐元素相加。 广播的意义是省内存:它不真的复制,只是在计算时按需要取值。 所以 (1000, 1) + (1, 1000) 不会占一百万个元素的额外空间。

四、视图与拷贝

切片给的是视图(view),不是拷贝。s = v[2:5] 之后改 s[0],v 也跟着变。 实测:v = [0 1 2 3 4 5],取 s = v[2:5],改 s[0] = 99,v 变成 [0 1 99 3 4 5]。 要真拷贝得写 v[2:5].copy()。 这个坑在处理图像时最致命:你只想改一份副本,结果把原图改了,后面所有实验的数据都污染了。 判断办法:s.base is v 为 True 就是视图。或者干脆养成习惯——凡是要改的,先 .copy()。

五、向量化为什么快

Python 的 for 循环每转一圈都要做类型检查、对象查找、函数调用; NumPy 的运算一次交给底层的 C 循环,中间不过解释器。 实测:对 20 万个浮点数求和,逐个 for 循环 25.0 毫秒,.sum() 只要 0.31 毫秒——差八十倍。 所以写深度学习代码有一条铁律:能写成数组运算的,绝不写 for。第 5 章的卷积如果真用四重 for 写, 跑一张图就要几秒;换成 im2col 加矩阵乘,快两个数量级。

六、动手 Demo:形状、广播、视图

四段:五个常见表达式的 shape 与 dtype、一次广播、一次视图改写、一次向量化与 for 循环的速度对比。

import numpy as np

a = np.array([1, 2, 3, 4, 5, 6]).reshape(2, 3)
print(a.shape, a.dtype, a.T.shape)      # (2, 3) int64 (3, 2)
print(np.zeros((2, 2, 2)).shape)        # (2, 2, 2)
print(a.reshape(3, 2).shape)            # (3, 2)

col = np.array([1.0, 2.0, 3.0]).reshape(3, 1)      # 3 行 1 列
row = np.array([10.0, 20.0, 30.0, 40.0]).reshape(1, 4)
print((col + row).shape)                # (3, 4) 广播

v = np.arange(6)
s = v[2:5]        # 视图,不是拷贝
s[0] = 99
print(v)          # [ 0  1 99  3  4  5]

实跑输出:

--------------------------------------------------------------------
数值运算函式库 NumPy:形状、广播、视图
--------------------------------------------------------------------

  表达式                        形状        dtype     内容
  --------------------------------------------------------------
  np.array([1,2,3,4,5,6]).reshape(2,3)(2, 3)      int64     2 行 3 列
  a.T                           (3, 2)      int64     转置
  np.zeros((2,2,2))             (2, 2, 2)   float64   全 0
  np.arange(6)                  (6,)        int64     0~5
  a.reshape(3,2)                (3, 2)      int64     换个看法

  **shape 是 NumPy 最该盯住的东西。**九成的报错都是形状对不上,
  而不是数值算错——广播规则会默默帮你对齐,对不齐才报错。

  广播:一个 3×1 加一个 1×4,结果是 (3, 4)

     11.0   21.0   31.0   41.0
     12.0   22.0   32.0   42.0
     13.0   23.0   33.0   43.0

  **规则:从最后一维往前比,要么相等、要么有一个是 1。**
  3×1 和 1×4 在每一维上都满足「有一个是 1」,于是各自被拉长成 3×4。

  视图还是拷贝:v = [ 0  1 99  3  4  5],取切片 s = v[2:5] 再改 s[0] = 99
  现在 v = [ 0  1 99  3  4  5]

  **切片给的是视图,不是拷贝。**改 s 会连着 v 一起变。
  要真拷贝得写 `v[2:5].copy()`——这个坑在改图像像素时最容易踩,
  一改就把原图毁了,还找不到是哪一行干的。

  同样的求和,两种写法:
    逐个 for 循环  23.9 毫秒
    向量化 .sum()  0.17 毫秒
    两者结果 19999900000 与 19999900000

  **差了八十倍。**NumPy 的循环跑在 C 里,Python 的 for 每转一圈都要过解释器。
  这条规矩贯穿全书:**能写成数组运算的,绝不写 for。**

五个表达式的形状:(2,3)、转置 (3,2)、(2,2,2)、(6,)、reshape 后 (3,2)。 注意 (6,) 是一维,不是 (1,6)——这两个在矩阵乘里行为完全不同。 广播那一块:3×1 加 1×4 得 3×4,打出来是这样的四列:11、21、31、41 那一行,就是 1 加上 10、20、30、 40。每一行都是同一个 row 加上不同的 col。 视图那一块最该记住:改 s[0] 之后,v 里下标 2 那个位置变成了 99。 s 和 v 是同一块内存的两个名字。 速度那两行:25.0 毫秒对 0.31 毫秒。 同样 20 万个数、同样的结果,差八十倍。这就是为什么深度学习代码里几乎看不到 for 循环。

30 秒实验

  1. 把 a.reshape(3, 2) 改成 a.reshape(4, 2),看报什么错——这就是形状对不上的典型样子。
  2. 把广播那段改成 3×1 加 1×3,还能算出 3×3 吗?再改成 3×2 加 1×3,看报错。
  3. 把 .sum() 换成 np.add.reduce(big[:200000]),速度有差别吗?

常见误区

  1. 把 (6,) 和 (1,6) 当一回事。前者是一维,后者是 1 行 6 列。 矩阵乘、广播、索引的行为都不一样。
  2. 用 = 复制数组。b = a 只是别名,改 b 就是改 a。要复制用 a.copy()。
  3. 以为 reshape 会重排数据。它只换看法,数据在内存里的顺序没变。 真要重排得用 transpose 或手动索引。
自测三问(点开对答案) 1. `(2,3)` 与 `(3,2)` 的区别是什么? —— 前者 2 行 3 列,后者 3 行 2 列。元素个数都是 6,但形状不同,矩阵乘和广播的结果完全不同。 2. 广播的规则一句话怎么说? —— 从最后一维往前比,每一维要么相等、要么其中一个是 1。满足就自动拉长,不满足就报错。 3. 为什么切片会改到原数组? —— 因为切片返回的是视图,和原数组共享同一块内存。要独立副本得显式 `.copy()`。

数有了,接下来得看得见。下一节 Matplotlib 把数组画成线、画成图、画成矩阵。

相关笔记