Python数据分析(三):NumPy数组操作与矩阵计算
目录
一、矩阵基础
在正式探索复杂的数组变形、切片索引与矢量化计算之前,我们需要先把数据分析中最常打交道的矩阵彻底搞清楚
这里我们不谈复杂的线性代数推导,只从数据分析与编程落地的角度,来看看矩阵到底是什么、为什么它在现代数据科学中无处不在,以及它最基本的几项数学运算
1. 什么是矩阵
在数据分析的世界里,矩阵的定义非常直观:
矩阵就是一个排布成 "行" 与 "列" 的二维数字网格
如果你用过 Excel 电子表格或 SQL 数据库,你就已经对矩阵非常熟悉了:一个典型的 m * n 矩阵,就是包含了 m 行 和 n 列 的二维表
列 1 列 2 列 3
┌────────┬───────┬────────┐
行 1 │ 10 │ 180 │ 75 │
行 2 │ 25 │ 165 │ 52 │
行 3 │ 40 │ 175 │ 68 │
└────────┴───────┴────────┘
3 行 x 3 列 矩阵
在 NumPy 中,这种 m * n 的矩阵就是 ndim=2(shape 为 (m, n))的二维 ndarray 对象
2. 为什么数据分析都用矩阵
你在日常工作中处理的数据,无论来自用户行为日志、金融交易记录还是图像像素,最终绝大多数都可以表达为矩阵形式:
[ 特征矩阵 (Feature Matrix) ]
特征 1 特征 2 特征 3
(例如:年龄) (例如:身高) (例如:体重)
┌───────────┬───────────┬───────────┐
样本 1 │ 25 │ 175 │ 70 │
样本 2 │ 30 │ 165 │ 55 │
样本 3 │ 18 │ 180 │ 80 │
└───────────┴───────────┴───────────┘
数据分析统一采用矩阵结构,核心原因有两个:
-
天然契合 "样本-特征" 抽象:
-
每一行:代表一个样本(如一个用户、一笔订单、一张图片)
-
每一列:代表一个特征/属性(如年龄、价格、点击次数)
-
-
批量化计算效率: 如果用传统的方式处理 100 万个用户的属性,你需要写一个极长的循环逐行处理;但如果抽象为矩阵,算法可以把整张表看作一个单一实体,一次性完成 100 万个样本的批量映射与计算
3. 矩阵的基本运算
理解了矩阵的物理含义后,我们来看看在 NumPy 中最常用的四种矩阵运算。
3.1 矩阵加法
-
规则:只有当两个矩阵的维度相同时,才能进行加法运算。加法是对应坐标位置上的元素逐位相加
-
业务场景:合并同一业务在 "上半月" 与 "下半月" 的各维度销售指标
import numpy as np
A = np.array([[1, 2], [3, 4]])
B = np.array([[5, 6], [7, 8]])
# 矩阵加法
print("A + B =\n", A + B)
输出结果:

3.2 矩阵数乘
-
规则:用一个标量数字 k 乘以矩阵,代表让矩阵中的每一个元素都乘以该数字 k
-
业务场景:全量数据按比例缩放(如将所有商品的美元价格转换为人民币价格,统一乘以汇率 k=7.2)
A = np.array([[1, 2], [3, 4]])
# 矩阵数乘
print("2 * A =\n", 2 * A)
输出结果:

3.3 矩阵转置
-
规则:沿主对角线翻转矩阵,把原矩阵的行变成列,列变成行。一个 m * n 的矩阵转置后会变成 n * m 的矩阵
-
符号表示:
或在 NumPy 中直接使用 A.T
-
业务场景:改变数据维度形态,调整 "样本-特征" 的排列方向以适应特定模型的输入要求
A = np.array([[1, 2, 3], [4, 5, 6]]) # shape: (2, 3)
# 矩阵转置
print("A 的转置 (A.T) =\n", A.T) # shape: (3, 2)
输出结果:

3.4 矩阵乘法
重点注意:矩阵乘法不同于逐元素相乘,它是线性代数中最重要的组合运算
-
匹配规则:只有当第一个矩阵 A 的列数等于第二个矩阵 B 的行数时,才能进行乘法。即
-
计算规则:结果矩阵 C 中第 i 行第 j 列的元素,是由矩阵 A 的第 i 行向量与矩阵 B 的第 j 列向量对应点乘相加得到的
-
业务场景:计算多元线性回归的预测值、特征加权求和等
A = np.array([[1, 2], [3, 4]])
B = np.array([[5, 6], [7, 8]])
# 矩阵乘法运算符 @ (或者使用 np.dot / np.matmul)
print("A @ B =\n", A @ B)
输出结果:

4. 特殊矩阵
在日常分析与模型建模中,有两类特殊的方阵需要重点认识:
4.1 单位矩阵
-
概念:主对角线上的元素全为 1,其余位置全为 0 的方阵,通常用 I 表示
-
作用:单位矩阵相当于普通代数运算中的数字 1。任何矩阵 A 乘以单位矩阵 I,结果依然是它本身:
# 在 NumPy 中通过 np.eye 生成 3x3 单位矩阵
I = np.eye(3)
print("3x3 单位矩阵:\n", I)
输出结果:

4.2 对角矩阵
-
概念:除了主对角线上有非零数字外,其余位置所有元素全部为 0 的矩阵
-
作用:在数据预处理中,对角矩阵经常被用来对不同特征进行独立缩放
# 在 NumPy 中通过 np.diag 生成对角矩阵
D = np.diag([3, 0.5, 2])
print("3x3 对角矩阵:\n", D)
输出结果:

二、索引与切片
在获取或构建好 ndarray 对象后,数据分析中最基础的操作就是定位、提取和修改数据。这一过程在 NumPy 中被称为索引与切片
与 Python 原生列表相比,NumPy 针对多维数组设计了一套优雅且高效的索引语法,不仅支持快速提取单个元素,还能轻松切割出子矩阵
1. 一维数组的索引与切片
一维数组的索引和切片逻辑与 Python 标准 list 完全一致,遵循 0-indexed(从 0 开始计数)与负数倒序索引机制
基本语法:arr[start:stop:step]
-
start:起始位置(包含)。
-
stop:终止位置(不包含,半开区间 [start, stop))
-
step:步长(默认为 1,可以为负数代表逆序提取)
import numpy as np
arr = np.array([10, 20, 30, 40, 50, 60])
# 1. 单元素索引
print("第一个元素:", arr[0]) # 输出: 10
# 2. 连续切片
print("前 3 个元素:", arr[0:3]) # 输出: [10 20 30] (索引 0, 1, 2)
# 3. 带步长的切片
print("隔一个取一个:", arr[::2]) # 输出: [10 30 50]
输出结果:

2. 二维数组的索引与切片
在处理矩阵时,我们需要同时在行与列两个维度上进行定位
语法差异:
-
Python 原生 List:必须写两次方括号,如 list[row][col](原理是先取出子列表,再取元素)
-
ndarray:在单个方括号内用逗号分隔不同轴,格式为 arr[row_index, col_index]
① 定位单个元素
matrix = np.array([
[1, 2, 3, 4],
[5, 6, 7, 8],
[9, 10, 11, 12]
])
# 提取第 2 行、第 3 列的元素 (注意:索引从 0 开始)
element = matrix[1, 2]
print("第 2 行第 3 列的元素:", element) # 输出: 7
输出结果:

② 提取完整的行或列
在二维切片中,使用冒号 : 代表 "选择该轴上的所有元素"
# 提取第 0 行的所有列
row_0 = matrix[0, :]
print("第 0 行全量数据:", row_0) # 输出: [1 2 3 4]
# 提取第 1 列的所有行
col_1 = matrix[:, 1]
print("第 1 列全量数据:", col_1) # 输出: [2 6 10]
输出结果:

③ 子矩阵切片
格式为:matrix[row_start:row_stop, col_start:col_stop]
列 0 列 1 列 2 列 3
┌──────┬──────┬──────┬──────┐
行 0 │ 1 │ 2 │ 3 │ 4 │
├──────┼──────┼──────┼──────┤
行 1 │ 5 │ 6 │ 7 │ 8 │ <-- 提取子矩阵 [0:2, 1:3]
├──────┼──────┼──────┼──────┤
行 2 │ 9 │ 10 │ 11 │ 12 │
└──────┴──────┴──────┴──────┘
█ █
被切片选中的区域 (2行 x 2列)
# 提取 行 0~1,列 1~2 的 2x2 子矩阵
sub_matrix = matrix[0:2, 1:3]
print("提取的 2x2 子矩阵:\n", sub_matrix)
输出结果:

3. 多维数组索引
对于 3 维甚至更高维度的张量,索引语法依然保持用逗号分隔 N 个轴的切片规则
对于 3D 数组,坐标格式为:arr[axis_0, axis_1, axis_2]
# 创建一个 2x3x4 的 3D 数组 (2 块, 每块 3 行 4 列)
tensor = np.arange(24).reshape(2, 3, 4)
# 提取:第 0 块,第 1 行,第 2 列的元素
val = tensor[0, 1, 2]
print("3D 数组提取:", val) # 输出: 6
# 提取:第 1 块的所有行、第 0 列
slice_3d = tensor[1, :, 0]
print("3D 切片结果:", slice_3d) # 输出: [12 16 20]

4. 切片的核心机制
这是使用 NumPy 切片时最需要警惕的地方,也是 NumPy 性能极高的原因之一:
核心原则: NumPy 数组的切片返回的是原数组的 "视图",而不是 "副本"
在 Python 原生列表中,new_list = old_list[:] 会深拷贝出一个全新的列表;但在 NumPy 中,切片操作不会分配新的内存,它只是为原有的连续内存块建立了一个 "新视角"
这意味着:修改切片后的子数组,原数组的数据也会同步被修改
视图修改影响示例:
import numpy as np
# 原始数组
orig_arr = np.array([10, 20, 30, 40, 50])
# 获取切片 (视图)
sub_arr = orig_arr[1:4] # 包含 [20, 30, 40]
# 修改切片中的第一个元素
sub_arr[0] = 999
print("切片数组:", sub_arr) # 输出: [999 30 40]
print("原始数组:", orig_arr) # 输出: [10 999 30 40 50]
输出结果:

如何强制创建副本?
如果你希望对提取出的子数据进行独立修改而不破坏原数组,必须显式调用 .copy() 方法:
orig_arr = np.array([10, 20, 30, 40, 50])
# 使用 .copy() 强制深拷贝
safe_sub_arr = orig_arr[1:4].copy()
# 修改副本
safe_sub_arr[0] = 8888
print("副本数组:", safe_sub_arr) # 输出: [8888 30 40]
print("原数组:", orig_arr) # 输出: [10 20 30 40 50]
输出结果:

三、数组变形
在真实的数据分析与机器学习建模中,数据的原始形状往往不符合算法模型的输入要求
例如:图像处理模型要求输入的 Batch 形状为 (张数, 通道数, 高度, 宽度),而你读取的数据却是扁平的一维数组;或者你需要将单列特征拉直为一维向量以计算统计指标
数组变形允许我们在不改变底层数据内容的前提下,重新组织数组的维度与轴结构
1. reshape()
reshape() 是最常用的变形函数,它可以将数组重构为任意指定的新形状
原则:元素总数必须保持绝对一致
变形前后的数组必须满足 size_before == size_after。例如,包含 12 个元素的数组可以变形为 (3, 4)、(2, 6) 或 (2, 2, 3),但绝不能变形为 (3, 5)
import numpy as np
# 创建包含 12 个元素的一维数组
arr = np.arange(12)
# 转换为 3 行 4 列的二维矩阵
mat_34 = arr.reshape(3, 4)
print("3x4 矩阵:\n", mat_34)
# 转换为 2x2x3 的三维张量
tensor_3d = arr.reshape(2, 2, 3)
print("\n2x2x3 张量 shape:", tensor_3d.shape)
输出结果:

占位符 -1 的自动推导机制
在实际开发中,我们常常只确定了部分维度(如:希望将数据集重构为包含 4 列的特征矩阵,但懒得去算到底有多少行)
这时可以使用 -1 作为某个维度的占位符,NumPy 会根据元素总数自动推算出该维度的数值:
arr = np.arange(12)
# 将 12 个元素重构为 4 列,行的数量设为 -1 (自动计算为 12 / 4 = 3 行)
auto_mat = arr.reshape(-1, 4)
print("自动推导行的矩阵 shape:", auto_mat.shape) # 输出: (3, 4)
输出结果:

注意:在一个 reshape() 调用中,-1 只能使用一次,否则 NumPy 无法完成多元方程推导
2. flatten() 与 revel()
将多维矩阵压缩/拉直为一维向量的操作称为展平。NumPy 提供了两个名称相似但底层逻辑完全不同的函数:flatten() 与 ravel()
多维矩阵展平
│
┌──────────────────────┴──────────────────────┐
│ │
arr.flatten() arr.ravel()
┌─────────────────────────┐ ┌─────────────────────────┐
│ 物理内存: 深拷贝 (Copy) │ │ 物理内存: 视图 (View) │
│ 性能: 开辟新内存,较慢 │ │ 性能: 零拷贝,极速 │
│ 修改新数组: 原数组不变 │ │ 修改新数组: 原数组随之变 │
└─────────────────────────┘ └─────────────────────────┘
代码实测与对比:
import numpy as np
matrix = np.array([[1, 2], [3, 4]])
# 1. 使用 flatten():返回副本
flat_arr = matrix.flatten()
flat_arr[0] = 999
print("使用 flatten() 修改后:")
print("新数组:", flat_arr) # [999 2 3 4]
print("原矩阵:", matrix) # [[1 2] [3 4]]
# 2. 使用 ravel():返回视图
ravel_arr = matrix.ravel()
ravel_arr[0] = 888
print("\n使用 ravel() 修改后:")
print("新数组:", ravel_arr) # [888 2 3 4]
print("原矩阵:", matrix) # [[888 2] [3 4]]
输出结果:

最佳实践:
-
优先选择 ravel():在绝大多数算法链条中,使用 ravel() 可以省去内存开销,执行效率更高
-
显式选择 flatten():只有确定需要安全隔离原数组、防止下游逻辑意外修改原数据时,才选择 flatten()
3. transpose() 与 .T
在前文矩阵基础中,我们介绍了矩阵转置概念。从数组变形的角度来看,转置本质上是重新排列数组各个轴的顺序
① .T 属性:二维矩阵快速转置
对于 2D 矩阵,.T 属性直接交换 Axis 0(行)与 Axis 1(列)
mat = np.array([[1, 2, 3], [4, 5, 6]]) # shape: (2, 3)
print("转置后 shape:", mat.T.shape) # shape: (3, 2)
输出结果:

② transpose() :高维张量重排
对于 3 维及以上的数据,简单的 .T 属性无法满足复杂的通道置换需求,此时需要使用 transpose() 并传入指定轴顺序的索引元组
import numpy as np
# 模拟一张彩色图像:高 100,宽 200,3 通道 (RGB)
# shape 为 (100, 200, 3) -> 轴索引分别对应 (0, 1, 2)
img_hwc = np.zeros((100, 200, 3))
# 将格式转换为 PyTorch 所需的 (3, 100, 200) -> 轴顺序重排为 (2, 0, 1)
img_chw = img_hwc.transpose(2, 0, 1)
print("原始 HWC 形状:", img_hwc.shape) # 输出: (100, 200, 3)
print("转换后 CHW 形状:", img_chw.shape) # 输出: (3, 100, 200)
输出结果:

四、广播机制
在前面学习矩阵运算时,我们强调过 "只有当两个矩阵形状完全一致时,才能进行逐元素的加减法"。但在真实的数据清洗与特征工程中,我们经常遇到不同维度数据的计算需求
例如:如何将整张二维特征矩阵的每一列,都减去该列的平均值?
如果按照硬性形状匹配的规则,你必须先手动把一维的均值向量复制扩展成和特征矩阵一模一样的形状,这不仅代码繁琐,还会产生大量不必要的内存开销
NumPy 提供的 广播机制完美解决了这个问题,它是 NumPy 中最核心的技术特性之一
1. 什么是广播机制
广播机制,是指 NumPy 在执行逐元素算术运算时,自动隐式扩展不同形状数组以使其维度相匹配的机制
优势: 广播过程完全发生在 NumPy 底层的 C 语言层级,不会在内存中真实复制数据。它通过调整迭代步长来实现计算,既节省了大量内存,又极大地提升了计算速度
2. 广播机制规则
NumPy 能否对两个形状不同的数组成功执行广播计算,完全取决于以下两个匹配规则:

若在向左比较的过程中,发现某个维度既不相等、且其中没有一个是 1,系统就会直接抛出 ValueError 错误
3. 广播机制典型场景
让我们通过直观的视觉图解,看看 NumPy 底层是如何把 "较小" 的数组扩展开来的:
场景一:标量与二维矩阵相加
标量可以看作没有任何维度的点,它在计算时会被自动拉伸并广播到矩阵的每一个元素上

场景二:一维行向量与二维矩阵相加
假设矩阵形状为 (3, 3),一维向量形状为 (3,)(右对齐补 1 后为 (1, 3))。因为 Axis 1 长度均为 3,Axis 0 其中一个是 1,满足规则,向量沿 Axis 0(垂直向下) 复制广播

场景三:列向量 (3, 1) 与 行向量 (1, 3) 相加
当两个向量分别在不同的维度长度为 1 时,它们会向彼此拓展,最终生成一个 (3, 3) 的网格矩阵

4. 代码案例
数据中心化 / 中心去均值
在机器学习预处理中,常常需要把特征矩阵每一列的平均值减掉:
import numpy as np
# 模拟 4 个样本、3 个特征的数据矩阵
X = np.array([
[100, 10, 1.0],
[200, 20, 2.0],
[300, 30, 3.0],
[400, 40, 4.0]
]) # shape: (4, 3)
# 计算每一列的均值
mean = X.mean(axis=0)
print("每列均值 shape:", mean.shape)
# 利用广播机制:(4, 3) 矩阵直接减去 (3,) 向量
X_centered = X - mean
print("\n中心化后的矩阵:\n", X_centered)
输出结果:

五、矢量化计算
NumPy 之所以能够从众多计算库中脱颖而出,最核心的性能武器正是矢量化。本章我们将深入解析 NumPy 如何通过矢量化技术彻底取代传统循环,实现数十倍乃至上百倍的计算性能提升
1. 什么是矢量化
简单来说,矢量化是指将原本针对单个数值逐一执行的计算,直接应用到整个数组上的编程范式。
在传统编程思维中,处理一堆数据时,我们习惯通过 for 循环逐个取出元素并计算;而在 NumPy 的矢量化思维中,整个数组被看作一个单一的算术实体
import numpy as np
# 传统 Python 思路:显式 for 循环遍历
py_list = [1, 2, 3, 4]
res_list = []
for x in py_list:
res_list.append(x * 2)
# NumPy 矢量化思路:直接对整个数组执行算术运算
np_arr = np.array([1, 2, 3, 4])
res_arr = np_arr * 2 # 一行代码,向量化执行
2. 为什么矢量化计算效率高
NumPy 矢量化计算之所以高效,主要得益于两项核心技术支撑:

① C 语言高效驱动
NumPy 的核心计算例程全都是用预先编译好的 C 语言 编写的
当你在 Python 中写下 a + b 时,NumPy 会立刻将指令下沉至底层的 C 代码。C 语言循环直接在连续的物理内存块上按固定步长偏移并执行计算,完全跳过了 Python 解释器的类型检查与字节码解释开销
② SIMD 硬件级并行
SIMD 是 Single Instruction, Multiple Data(单指令多数据流) 的缩写,这是现代 CPU内置的核心并行计算技术
-
普通 CPU 处理方式(SISD):一条指令只能处理一对数字,计算 4 对数字需要执行 4 次加法指令
-
SIMD 硬件加速方式:CPU 内部拥有宽达 128 位、256 位或 512 位的向量寄存器。一条 SIMD 加法指令,可以在同一个 CPU 时钟周期内,并行对 4 个、8 个甚至 16 个浮点数同时完成加法
3. 代码实测:for 循环与矢量化计算
下面通过一段基准代码,来直观对比在对 100万个浮点数 执行复杂数学公式计算
y = sin(x) + x^2 时,两者的性能差距:
import time
import math
import numpy as np
N = 1_000_000
py_list = [float(x) for x in range(N)]
np_array = np.arange(N, dtype=float)
start = time.perf_counter()
python_result = [math.sin(x) + x**2 for x in py_list]
python_time = time.perf_counter() - start
start = time.perf_counter()
numpy_result = np.sin(np_array) + np_array**2
numpy_time = time.perf_counter() - start
print(f"Python 循环耗时:{python_time:.4f} 秒")
print(f"NumPy 运算耗时:{numpy_time:.4f} 秒")
print(f"NumPy 快约:{python_time / numpy_time:.1f} 倍")
输出结果:

六、矩阵乘法
在 NumPy 中,星号运算符进行的是 "逐元素相乘",只有当对应的形状匹配或满足广播规则时,它才会将相同位置上的数字相乘
而真正的线性代数矩阵乘法,在 NumPy 中由 dot()、matmul() 和 @ 运算符来实现
1. 矩阵乘法 API
NumPy 提供了三种方式来进行矩阵乘法,它们在二维矩阵上的计算结果完全一致,但在高维张量和语义表达上略有差异:
① np.dot()(经典点乘)
-
定位:NumPy 的通用点乘函数
-
行为逻辑:
-
1D 向量:计算向量内积,返回标量
-
2D 矩阵:执行标准矩阵乘法
-
高维张量:执行严格的张量积,即拿 A 的最后一个轴去与 B 的倒数第二个轴做乘加,结果会将高维轴进行组合拼接
-
② np.matmul()(专门的矩阵乘法函数)
-
定位:NumPy 针对现代数据科学与深度学习专门优化的矩阵乘法函数
-
行为逻辑:
-
1D 向量:自动补充维度进行向量乘法,返回标量
-
2D 矩阵:执行标准矩阵乘法
-
高维张量:将高维部分视作 Batch(批量)维度,自动进行批量矩阵乘法。这是深度学习和多通道图像处理中最需要的运算方式
-
③ 中缀运算符 @(现代代码推荐)
-
定位:Python 3.5+ 引入的中缀运算符
-
行为逻辑:A @ B 在底层完全等价于 np.matmul(A, B)
-
核心优势:极大地提升了公式代码的可读性,避免了多层嵌套函数调用
import numpy as np
A = np.array([[1, 2], [3, 4]])
B = np.array([[5, 6], [7, 8]])
# 1. 逐元素相乘 (*)
print("逐元素相乘 (*):\n", A * B)
# 2. 矩阵乘法 (@ / matmul / dot)
print("\n矩阵乘法 (@):\n", A @ B)
print("矩阵乘法 (matmul):\n", np.matmul(A, B))
print("矩阵乘法 (dot):\n", np.dot(A, B))
输出结果:

高维张量下 dot() 与 matmul() / @ 的差异示例:
假设我们要处理包含 10 个 Batch 的矩阵运算(每个矩阵均为 3 * 4 与 4 * 5):
# 形状分别为 (10, 3, 4) 与 (10, 4, 5)
batch_A = np.ones((10, 3, 4))
batch_B = np.ones((10, 4, 5))
# 使用 @:将前导维度 (10,) 看作 Batch,返回 10 个 (3, 5) 的矩阵
result_matmul = batch_A @ batch_B
print("matmul 高维形状:", result_matmul.shape) # 输出: (10, 3, 5)
# 使用 dot:会进行多维轴组合,生成复杂的张量积
result_dot = np.dot(batch_A, batch_B)
print("dot 高维形状: ", result_dot.shape) # 输出: (10, 3, 10, 5)
输出结果:

最佳实践:
在编写现代 Python / NumPy 数据分析代码时,一律推荐优先使用 @ 运算符(或显式调用 np.matmul()),仅在需要计算一维向量内积时使用 np.dot()
总结
本章围绕 NumPy 数组的操作展开,学习了数组的数据类型、索引与切片、数组变形以及矩阵的基本运算,并了解了广播机制和矢量化运算的工作原理。同时,我们还掌握了矩阵乘法的实现方法,能够利用 NumPy 高效地完成常见的数值计算任务
通过本章的学习,我们已经具备了使用 NumPy 对数组进行灵活操作和计算的能力。下一篇将继续介绍 NumPy 中常用的统计函数、数学函数以及数据处理函数,为后续的数据分析打下基础

更多推荐


所有评论(0)