Python高级技术之:`NumPy`的`ndarray`:内存布局、`strides`和`broadcast`的底层机制。

大家好,欢迎来到“NumPy底层探秘”讲座,我是今天的导游老王。今天咱们不聊虚的,直接扒开NumPy的心脏,看看ndarray这玩意儿是怎么在内存里安家的,strides又是怎么玩转索引的,还有broadcast这魔法是如何炼成的。准备好了吗?系好安全带,发车!

第一站:内存布局——ndarray的安身之所

想象一下,你是一位房地产开发商,手头有一堆数据要安排入住。NumPy的ndarray就是你的小区,而你的任务就是给这些数据分配房间。

ndarray在内存中存储数据时,采用的是连续的内存块。这就好比小区里的房子是挨个儿盖的,保证了数据的紧凑性。但是,数据类型不同,需要的房间大小也不同啊!

比如,int32类型的数组,每个元素需要4个字节;float64类型的数组,每个元素需要8个字节。NumPy会根据你指定的数据类型,提前规划好整个小区的大小。

import numpy as np

# 创建一个 int32 类型的数组
arr_int = np.array([1, 2, 3, 4, 5], dtype=np.int32)
print(f"数组数据类型: {arr_int.dtype}")
print(f"每个元素占用的字节数: {arr_int.itemsize}") # 输出 4
print(f"数组总字节数: {arr_int.nbytes}") # 输出 20 (5个元素 * 4字节/元素)

# 创建一个 float64 类型的数组
arr_float = np.array([1.0, 2.0, 3.0], dtype=np.float64)
print(f"数组数据类型: {arr_float.dtype}")
print(f"每个元素占用的字节数: {arr_float.itemsize}") # 输出 8
print(f"数组总字节数: {arr_float.nbytes}") # 输出 24 (3个元素 * 8字节/元素)

这里itemsize属性告诉我们每个元素占用的字节数,nbytes属性告诉我们整个数组占用的总字节数。

C-order 和 F-order:选哪个学区房?

等等,房子盖好了,还要考虑入住的顺序啊!NumPy提供了两种主要的内存布局方式:C-order(行优先)和 F-order(列优先)。

  • C-order (行优先): 就像我们读书写字一样,从左到右,从上到下。二维数组中,同一行的元素在内存中是相邻的。这是NumPy默认的存储方式。

  • F-order (列优先): 就像古代写字一样,从上到下,从左到右。二维数组中,同一列的元素在内存中是相邻的。

这两种布局方式会影响数据的访问效率。如果你的代码经常按行访问数据,C-order会更快;如果经常按列访问,F-order会更合适。

# 创建一个 C-order 的数组
arr_c = np.array([[1, 2, 3], [4, 5, 6]], order='C')
print(f"C-order 数组:n{arr_c}")

# 创建一个 F-order 的数组
arr_f = np.array([[1, 2, 3], [4, 5, 6]], order='F')
print(f"F-order 数组:n{arr_f}")

#  查看数组的内存布局方式
print(f"C-order 数组的内存布局: {arr_c.flags['C_CONTIGUOUS']}") # True
print(f"F-order 数组的内存布局: {arr_f.flags['F_CONTIGUOUS']}") # True

flags属性可以查看数组的内存布局方式。C_CONTIGUOUS为True表示是C-order,F_CONTIGUOUS为True表示是F-order。

第二站:strides——小区里的寻路指南

房子盖好了,入住顺序也安排好了,但怎么找到自己家呢?这就需要strides这个寻路指南了。

strides是一个元组,它告诉我们沿着每个维度移动到下一个元素需要跳过多少个字节。可以把它想象成小区里的楼间距,单位是字节。

举个例子,对于一个形状为 (3, 4) 的 int32 数组,它的strides可能是 (16, 4)。这意味着:

  • 沿着第一个维度(行)移动到下一行,需要跳过 16 个字节(4个元素 * 4字节/元素)。
  • 沿着第二个维度(列)移动到下一列,需要跳过 4 个字节(1个元素 * 4字节/元素)。
arr = np.array([[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]], dtype=np.int32)
print(f"数组:n{arr}")
print(f"数组的形状: {arr.shape}") # (3, 4)
print(f"数组的 strides: {arr.strides}") # (16, 4)

# 手动计算访问元素 arr[1, 2] 的内存地址
base_address = arr.ctypes.data # 数组的起始地址
row_stride = arr.strides[0] # 行的 strides
col_stride = arr.strides[1] # 列的 strides

element_address = base_address + 1 * row_stride + 2 * col_stride
print(f"元素 arr[1, 2] 的内存地址: {element_address}")

# 使用数组本身访问
print(f"元素 arr[1, 2] 的值: {arr[1, 2]}")

上面的代码演示了如何使用strides手动计算数组中任意元素的内存地址。虽然我们通常不需要这样做,但理解strides的工作原理对于理解NumPy的底层机制至关重要。

strides的妙用:创建视图

strides最厉害的地方在于,它可以让我们在不复制数据的情况下,创建数组的视图。视图就像是共享同一块内存区域的不同窗口。

# 创建一个数组
arr = np.array([1, 2, 3, 4, 5, 6])

# 创建一个视图,只包含偶数索引的元素
view = arr[::2]
print(f"原始数组: {arr}")
print(f"视图: {view}")

# 修改视图中的元素,会影响原始数组
view[0] = 100
print(f"修改后的原始数组: {arr}") # 原始数组也被修改了

print(f"原始数组的 strides: {arr.strides}") # (8,)
print(f"视图的 strides: {view.strides}") # (16,)

可以看到,视图的strides是原始数组的strides的两倍。这是因为视图只访问原始数组中每隔一个的元素。

再来一个更复杂的例子:转置

转置操作(arr.T)也是通过修改strides来实现的,而不是复制数据。

arr = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.int32)
print(f"原始数组:n{arr}")
print(f"原始数组的形状: {arr.shape}") # (2, 3)
print(f"原始数组的 strides: {arr.strides}") # (12, 4)

arr_t = arr.T
print(f"转置后的数组:n{arr_t}")
print(f"转置后的数组的形状: {arr_t.shape}") # (3, 2)
print(f"转置后的数组的 strides: {arr_t.strides}") # (4, 12)

可以看到,转置后的数组的strides和原始数组的strides交换了位置。

第三站:broadcast——小区里的共享单车

有时候,我们需要对形状不同的数组进行运算。比如,一个 (3, 1) 的数组和一个 (1, 3) 的数组相加。NumPy通过broadcast机制来解决这个问题。

broadcast可以理解为小区里的共享单车。当两个数组的形状不兼容时,NumPy会“借用”一些维度,使得它们的形状变得兼容。

broadcast的规则如下:

  1. 如果两个数组的维度数不同,维度数较小的数组会在其形状的前面补1,直到维度数相等。
  2. 如果两个数组在某个维度上的大小相同,或者其中一个数组在该维度上的大小为1,则它们在该维度上是兼容的。
  3. 如果两个数组在所有维度上都兼容,它们就可以一起进行broadcast
  4. broadcast之后,每个数组表现出的形状是两个数组形状的元素级的最大值。
  5. 如果两个数组在某个维度上大小不相等,且没有一个数组在该维度上的大小为1,则会引发异常。
# 示例1:数组和标量相加
arr = np.array([1, 2, 3])
scalar = 5
result = arr + scalar
print(f"数组: {arr}")
print(f"标量: {scalar}")
print(f"相加结果: {result}") # [6 7 8]

# 示例2:两个数组相加
arr1 = np.array([[1, 2, 3]]) # (1, 3)
arr2 = np.array([[4], [5], [6]]) # (3, 1)
result = arr1 + arr2
print(f"数组1:n{arr1}")
print(f"数组2:n{arr2}")
print(f"相加结果:n{result}")

在第一个例子中,标量scalarbroadcast成了和数组arr相同的形状。在第二个例子中,arr1broadcast成了 (3, 3) 的数组,arr2也被broadcast成了 (3, 3) 的数组。

broadcast的底层实现

broadcast的底层实现并没有真正复制数据。相反,它创建了一个新的视图,该视图的strides在需要broadcast的维度上为0。

这意味着,沿着该维度移动时,实际上并没有跳过任何字节,而是始终指向同一个内存位置。

# 模拟 broadcast
arr1 = np.array([[1, 2, 3]]) # (1, 3)
arr2 = np.array([[4], [5], [6]]) # (3, 1)

# 手动模拟 arr1 的 broadcast
new_shape1 = (3, 3)
new_strides1 = (0, arr1.strides[1]) # (0, 8)
arr1_broadcasted = np.lib.stride_tricks.as_strided(arr1, shape=new_shape1, strides=new_strides1)
print(f"arr1 broadcasted:n{arr1_broadcasted}")

# 手动模拟 arr2 的 broadcast
new_shape2 = (3, 3)
new_strides2 = (arr2.strides[0], 0) # (8, 0)
arr2_broadcasted = np.lib.stride_tricks.as_strided(arr2, shape=new_shape2, strides=new_strides2)
print(f"arr2 broadcasted:n{arr2_broadcasted}")

print(f"arr1 strides: {arr1.strides}")
print(f"arr1 broadcasted strides: {arr1_broadcasted.strides}")

print(f"arr2 strides: {arr2.strides}")
print(f"arr2 broadcasted strides: {arr2_broadcasted.strides}")

print(f"arr1的内存地址:{arr1.__array_interface__['data'][0]}")
print(f"arr1_broadcasted的内存地址:{arr1_broadcasted.__array_interface__['data'][0]}")

# 验证 broadcast 后的结果
result = arr1_broadcasted + arr2_broadcasted
print(f"模拟 broadcast 后的结果:n{result}")

np.lib.stride_tricks.as_strided函数可以让我们手动创建具有指定形状和strides的视图。 通过将某个维度的strides设置为0,我们就可以模拟broadcast的效果。注意:使用as_strided需要非常小心,确保你的理解是正确的,否则可能会导致程序崩溃。

总结:NumPy的魅力所在

今天我们一起深入了解了NumPy ndarray的内存布局、stridesbroadcast机制。这些底层机制是NumPy高性能的关键。

  • 内存布局: 连续的内存块保证了数据的紧凑性,C-order和F-order提供了不同的访问方式。
  • strides 就像小区里的寻路指南,它告诉我们如何在内存中找到数组中的元素。更重要的是,它可以让我们在不复制数据的情况下创建数组的视图。
  • broadcast 就像小区里的共享单车,它可以让形状不同的数组进行运算,而无需复制数据。

理解这些底层机制可以帮助我们更好地利用NumPy,写出更高效的代码。

最后,留一道思考题:

如何使用strides创建一个数组的滑动窗口视图?

今天的讲座就到这里,谢谢大家!希望大家以后在使用NumPy的时候,能够更加得心应手。下次有机会再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注