17 NumPy数组计算(下):向量化运算、广播机制与聚合函数

上篇我们讲了NumPy数组的创建、索引切片和基本属性。本篇继续深入,把NumPy真正"用起来"——向量化运算让你摆脱for循环,广播机制让你用不同形状的数组直接做运算,聚合函数让你一行代码完成统计摘要。这三板斧,是NumPy在数据分析里真正的杀手锏。


一、为什么NumPy运算比Python快?

先来回答这个根本问题。Python的列表做运算,本质上是逐个元素循环,速度慢得可怜。NumPy的数组运算是向量化(Vectorization) 的——底层用C/Fortran实现,一次指令处理整块数据,速度快几十倍甚至上百倍。

import numpy as np
import time

# Python列表:要计算100万元素的平方
lst = list(range(1000000))
start = time.time()
result = [x**2 for x in lst]
print(f"列表推导耗时: {time.time() - start:.4f}秒")

# NumPy数组:同样的操作
arr = np.arange(1000000)
start = time.time()
result = arr ** 2
print(f"NumPy向量化耗时: {time.time() - start:.4f}秒")

在这里插入图片描述

运行一下你就明白差距了。在笔者的机器上,列表推导大约需要120ms,而NumPy几乎瞬间完成(约2-3ms)。这不是10%的提升,是两个数量级的差距。


二、向量化运算:告别for循环

2.1 算术运算直接写

NumPy数组的算术运算符(+ - * / ** % //)都是逐元素(element-wise) 操作的,不需要写循环:

import numpy as np

a = np.array([1, 2, 3, 4, 5])
b = np.array([10, 20, 30, 40, 50])

print(a + 2)      # [ 3  4  5  6  7]  每个元素加2
print(a * 3)       # [ 3  6  9 12 15]  每个元素乘3
print(a ** 2)      # [ 1  4  9 16 25]  每个元素平方
print(a + b)        # [11 22 33 44 55]  逐元素相加
print(a * b)        # [10 40 90 160 250] 逐元素相乘
print(b / a)        # [10. 10. 10. 10. 10.] 逐元素相除
print(b % a)        # [ 0  0  0  0  0]  逐元素取余

在这里插入图片描述

注意:* 是逐元素乘法,不是矩阵乘法!矩阵乘法用 @ 或 np.matmul(),这个后面会讲。

2.2 比较运算也返回数组

比较运算符(> < >= <= == !=)对数组使用时,返回的是布尔数组,这在数据筛选时极其有用:

scores = np.array([85, 92, 78, 55, 98, 61, 73])

print(scores > 80)
# [ True  True False False  True False False]

print(scores == 98)
# [False False False False  True False False]

# 结合布尔索引,一行代码筛选出所有及格分数
passed = scores[scores >= 60]
print(passed)   # [85 92 78 98 61 73]

这张表总结了所有可用的逐元素比较函数:

函数运算符说明
np.greater(a, b)a > b大于
np.greater_equal(a, b)a >= b大于等于
np.less(a, b)a < b小于
np.less_equal(a, b)a <= b小于等于
np.equal(a, b)a == b等于
np.not_equal(a, b)a != b不等于

2.3 通用函数(ufunc):NumPy的运算引擎

NumPy里所有逐元素操作,底层都是通用函数(ufunc = universal function)。它们对ndarray的每个元素独立操作,支持广播(下一节讲)。常用的ufunc分两类:

数学函数:

x = np.array([0, np.pi/2, np.pi, np.pi*3/2])

print(np.sin(x))    # 正弦函数
print(np.cos(x))    # 余弦函数
print(np.tan(x))    # 正切函数
print(np.arcsin(np.array([0, 0.5, 1])))  # 反正弦

取整函数:

a = np.array([-1.7, -1.5, -0.2, 0.2, 1.5, 1.7, 2.0])

print(np.floor(a))  # 向下取整: [-2. -2. -1.  0.  1.  1.  2.]
print(np.ceil(a))   # 向上取整: [-1. -1. -0.  1.  2.  2.  2.]

对数函数:

print(np.exp(1))        # e的1次方: 2.718...
print(np.log(10))       # 自然对数: 2.302...
print(np.log2(8))      # 以2为底: 3.0
print(np.log10(100))    # 以10为底: 2.0

在这里插入图片描述


三、广播机制(Broadcasting):不同形状也能运算

这是NumPy最容易让初学者困惑、但也最强大的特性之一。

3.1 什么是广播?

广播指的是:当两个数组形状不同时,NumPy会自动"扩展"较小数组的形状,使其与较大数组兼容,然后再做运算。这个扩展是虚拟的(不占用额外内存)。

规则只有两条,记住就够用:

规则1:如果两个数组的后缘维度(trailing dimension) 相同,或者其中一个为1,则可以广播。

规则2:如果两个数组的维度数量不同,较小数组会在前面补1,使其维度数量一致。

说人话就是:从后往前比,要么相等,要么其中一个是1,就能运算。

3.2 广播实战

import numpy as np

# 案例1:数组 + 标量(最基础的广播)
arr = np.array([[1, 2, 3],
                [4, 5, 6]])
print(arr + 10)
# 标量10被广播成 [[10,10,10], [10,10,10]],然后逐元素相加
# 结果: [[11 12 13], [14 15 16]]

# 案例2:(3,3) 数组 + (3,) 数组
a = np.array([[1, 2, 3],
              [4, 5, 6],
              [7, 8, 9]])
b = np.array([10, 20, 30])   # shape: (3,)
print(a + b)
# b被广播成 [[10,20,30], [10,20,30], [10,20,30]]
# 结果: [[11 22 33], [14 25 36], [17 28 39]]

# 案例3:(2,3) 数组 + (3,) 数组
a = np.array([[1, 2, 3],
              [4, 5, 6]])   # shape: (2,3)
b = np.array([10, 20, 30])  # shape: (3,)
print(a + b)  # ✅ 可以运算,b沿着行方向广播

# 案例4:(2,3) 数组 + (2,1) 数组
a = np.array([[1, 2, 3],
              [4, 5, 6]])   # shape: (2,3)
b = np.array([[10],
              [20]])        # shape: (2,1)
print(a + b)
# b被广播成 [[10,10,10], [20,20,20]]

在这里插入图片描述

3.3 广播失败的典型错误

a = np.array([[1, 2, 3],
              [4, 5, 6]])   # shape: (2,3)
b = np.array([10, 20])       # shape: (2,) —— 后缘维度 2≠3,不能广播!

# print(a + b)  # ❌ ValueError: operands could not be broadcast together

解决方法:用 np.newaxis 或 reshape 手动调整形状:

b = np.array([10, 20])       # shape: (2,)
b_reshaped = b[:, np.newaxis] # shape: (2,1) —— 现在可以广播了!
print(a + b_reshaped)

在这里插入图片描述

3.4 广播规则速查表

数组A的shape数组B的shape能否广播结果shape
(2, 3)(3,)✅(2, 3)
(2, 3)(2, 1)✅(2, 3)
(3, 1)(3,)✅(3, 3)
(2, 3)(2,)❌—
(2, 3, 4)(3, 4)✅(2, 3, 4)
(2, 3, 4)(1, 4)✅(2, 3, 4)

四、聚合函数:一行代码搞定统计摘要

数据分析里最常见的需求是什么?算总和、平均值、最大最小值、标准差。NumPy把这些都封装成了聚合函数,而且比Python原生快得多。

4.1 基础聚合函数

import numpy as np

scores = np.array([[85, 92, 78],
                   [55, 98, 61],
                   [73, 88, 91]])

print(np.sum(scores))      # 全体总分: 721
print(np.mean(scores))     # 全体平均分: 80.11...
print(np.max(scores))      # 最高分: 98
print(np.min(scores))      # 最低分: 55
print(np.std(scores))      # 标准差: 约12.47
print(np.var(scores))      # 方差: 约155.58
print(np.prod(scores))     # 所有元素乘积(注意:结果会非常大!)

4.2 沿指定轴聚合(重点!)

这是最容易搞混的地方。axis=0 是沿着行方向(跨行计算,结果压缩行),axis=1 是沿着列方向(跨列计算,结果压缩列)。

scores = np.array([[85, 92, 78],   # 三个学生,三门课
                   [55, 98, 61],
                   [73, 88, 91]])

# axis=0:跨行计算(压缩行)→ 得到每列的统计值
print(np.sum(scores, axis=0))   # [213 278 230]  → 每门课的总分
print(np.mean(scores, axis=0))  # [71.0 92.7 76.7] → 每门课的平均分

# axis=1:跨列计算(压缩列)→ 得到每行的统计值
print(np.sum(scores, axis=1))   # [255 214 252]  → 每个学生的总分
print(np.mean(scores, axis=1))  # [85.0 71.3 84.0] → 每个学生的平均分

在这里插入图片描述

记忆口诀:axis=N 表示沿着第N个轴的方向计算,结果里第N维消失。

4.3 更多实用聚合函数

a = np.array([[3, 0, 7, 3],
              [4, 9, 6, 2],
              [8, 5, 11, 10]])

# 返回最大/最小值的索引(而不是值本身)
print(np.argmax(a, axis=0))  # [2 1 2 2] → 每列最大值的行索引
print(np.argmin(a, axis=1))  # [1 3 0]   → 每行最小值的列索引

# 返回非零元素的索引
print(np.nonzero(a > 5))
# (array([0, 1, 1, 2, 2, 2]), array([2, 1, 2, 0, 2, 3]))
# 解读:a>5 为True的位置是(0,2),(1,1),(1,2),(2,0),(2,2),(2,3)

# where:条件筛选的神器
# 只传condition:返回符合条件的元素索引(同nonzero)
print(np.where(a > 5))  

# 传三个参数:condition成立返回x,不成立返回y
print(np.where(a > 5, "大", "小"))
# [['小' '小' '大' '小']
#  ['小' '大' '大' '小']
#  ['大' '小' '大' '大']]

4.4 聚合函数速查表

函数说明示例
np.sum(arr, axis)求和np.sum(arr, axis=0)
np.mean(arr, axis)平均值np.mean(arr, axis=1)
np.max(arr, axis)最大值np.max(arr)
np.min(arr, axis)最小值np.min(arr, axis=0)
np.std(arr, axis)标准差np.std(arr)
np.var(arr, axis)方差np.var(arr, axis=1)
np.argmax(arr, axis)最大值索引np.argmax(arr, axis=1)
np.argmin(arr, axis)最小值索引np.argmin(arr)
np.cumsum(arr, axis)累加(保留中间结果)np.cumsum([1,2,3]) → [1,3,6]
np.cumprod(arr, axis)累乘np.cumprod([1,2,3]) → [1,2,6]

五、线性代数操作

NumPy内置了完整的线性代数工具(np.linalg 模块),对于数据分析来说,下面这些是最常用的。

5.1 矩阵乘法(划重点)

不要用 * 做矩阵乘法! * 是逐元素乘法。矩阵乘法要用 @ 或 np.dot() 或 np.matmul():

import numpy as np

A = np.array([[1, 2],
              [3, 4]])
B = np.array([[5, 6],
              [7, 8]])

# 逐元素乘法(Hadamard积)
print(A * B)
# [[ 5 12]
#  [21 32]]

# 矩阵乘法(点积)
print(A @ B)
# [[19 22]
#  [43 50]]
# 计算方式:第i行 × 第j列 求和

print(np.dot(A, B))      # 同上,经典写法
print(np.matmul(A, B))   # 同上,语义更清晰

在这里插入图片描述

5.2 矩阵转置与形状变换

A = np.array([[1, 2, 3],
              [4, 5, 6]])   # shape: (2,3)

print(A.T)   # 转置,shape变成 (3,2)
# [[1 4]
#  [2 5]
#  [3 6]]

# reshape:改变形状,元素总数不变
print(A.reshape(3, 2))  # shape: (3,2)
print(A.reshape(6, -1))  # -1表示自动推断,这里是(6,1)
print(A.reshape(-1))      # 扁平化成一维: [1 2 3 4 5 6]
print(A.flatten())        # 同上,扁平化成一维

5.3 常用线性代数函数

import numpy as np
import numpy.linalg as LA

A = np.array([[1, 2],
              [3, 4]])

print(LA.det(A))     # 行列式: -2.0
print(LA.eig(A))     # 特征值与特征向量(返回两个数组)
print(LA.inv(A))     # 逆矩阵
print(LA.norm(A))    # 矩阵范数(默认Frobenius范数)
print(np.trace(A))    # 迹(对角线元素之和): 1 + 4 = 5

# 解线性方程组 Ax = b
# 比如: 1x + 2y = 5, 3x + 4y = 11
A = np.array([[1, 2], [3, 4]])
b = np.array([5, 11])
x = LA.solve(A, b)
print(x)  # [1. 2.] → x=1, y=2

六、综合案例:用NumPy分析学生成绩数据

把上面学的所有知识串起来,做一个完整的小项目。

需求:有一个班级的成绩数据(3个班级,每班5个学生,4门课),需要:

  1. 计算每个学生的总分和平均分
  2. 计算每门课的平均分
  3. 找出每门课的最高分学生
  4. 将所有成绩标准化(减去均值,除以标准差)
  5. 找出所有不及格(<60)的成绩位置
import numpy as np

# 设置随机种子,保证结果可复现
np.random.seed(42)

# 模拟数据:3个班级,每班5个学生,4门课(语数英物)
# 成绩服从正态分布,均值75,标准差12,限制在0-100之间
raw = np.random.normal(75, 12, size=(3, 5, 4))
scores = np.clip(raw, 0, 100).round(1)  # 限制范围并保留1位小数

print("原始成绩数据:")
print(scores)
print(f"数据形状: {scores.shape}  →  3个班级 × 5个学生 × 4门课")

# 1. 每个学生的总分和平均分(沿着课的维度,即axis=2)
total_by_student = np.sum(scores, axis=2)   # shape: (3,5)
mean_by_student = np.mean(scores, axis=2)   # shape: (3,5)
print("\n各班级学生总分:")
print(total_by_student)
print("\n各班级学生平均分:")
print(mean_by_student)

# 2. 每门课的平均分(沿着学生维度,即axis=1)
mean_by_course = np.mean(scores, axis=1)    # shape: (3,4)
print("\n各班级各门课平均分:")
print(mean_by_course)

# 3. 每门课最高分的学生(在每个班级内,沿着学生维度找)
for class_idx in range(3):
    class_data = scores[class_idx]  # shape: (5,4)
    # 计算每个学生总分
    student_total = np.sum(class_data, axis=1)  # shape: (5,)
    top_student = np.argmax(student_total)
    print(f"\n班级{class_idx+1}最高分学生:学生{top_student+1},总分{student_total[top_student]:.1f}")

# 4. 成绩标准化(Z-score normalization)
# 对所有成绩: (x - mean) / std
scores_mean = np.mean(scores)
scores_std = np.std(scores)
scores_normalized = (scores - scores_mean) / scores_std
print("\n标准化后的成绩(前5个):")
print(scores_normalized.flatten()[:5])

# 5. 找出所有不及格成绩的位置
fail_mask = scores < 60
fail_positions = np.argwhere(fail_mask)
print(f"\n不及格成绩数量: {np.sum(fail_mask)}")
print("不及格成绩位置(班级, 学生, 课程):")
for pos in fail_positions:
    class_id, student_id, course_id = pos
    print(f"  班级{class_id+1}-学生{student_id+1}-课程{course_id+1}: {scores[tuple(pos)]}")

在这里插入图片描述


七、NumPy常见坑点与调试技巧

坑点1:视图(view)vs 副本(copy)

a = np.array([1, 2, 3, 4, 5])
b = a[::2]      # 切片返回视图,b和a共享内存!
b[0] = 999
print(a)  # [999   2   3   4   5]  ← a也被改了!

# 如果你不想要这种共享行为,用copy()
c = a[::2].copy()
c[0] = 111
print(a)  # [999   2   3   4   5]  ← a没变,安全!

坑点2:整数除法在Python 3里是正常的

Python 3里 / 就是真除法(结果是浮点数),// 才是整除。这点上NumPy和Python一致,不用担心。

坑点3:axis 参数搞不清

回到那句口诀:axis=N 沿着第N个轴的方向计算,结果里第N维消失。

或者更直观:在纸上把数组写出来,axis=0 是往下看(跨行),axis=1 是往右看(跨列)。


八、本文总结

知识点核心要点
向量化运算数组运算是逐元素的,无需for循环,速度极快
广播机制形状不同也能运算,规则:从后往前比,相等或其中一个为1
聚合函数sum/mean/max/min/std/var,注意axis参数的含义
矩阵乘法用@或np.dot(),不要用*
布尔索引arr[arr > 60] 一行代码完成筛选
视图vs副本切片返回视图(共享内存),.copy() 创建副本

NumPy的内容到这里就告一段落了。下一篇开始进入Pandas——Python数据分析的真正的"主力工具"。如果你觉得NumPy的数组操作已经很强大了,那Pandas会让你觉得"这才是我要的东西"。


动手练习

  1. 创建一个 shape 为 (5, 5) 的随机整数数组(范围1-100),然后计算:(a)每列的平均值;(b)每行的最大值索引;(c)找出所有大于50的元素位置。

  2. 有两个数组 A = np.array([1,2,3]) 和 B = np.array([[10],[20]]),预测 A + B 的结果形状,然后验证你的预测。

  3. 用NumPy模拟抛10000次硬币(用 np.random.randint(0, 2, 10000) 表示正面反面),计算正面的比例,并与理论值0.5比较。

  4. 创建一个 (3, 4) 的数组,用 np.where 把所有大于平均值的数标记为1,小于等于平均值的标记为0。


下一篇预告:《18 Pandas(一):Series与DataFrame详解——数据分析的真正的入口》

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐