📖 本篇简介:本篇正式进入 pandas 的"地基"——NumPy ndarray。我们会对比 ndarray 与 Python list 的本质区别,讲清 dtype/shape/ndim/size/itemsize 五大属性,系统演示 np.array、arange、linspace、zeros、ones、eye、full、empty 八种创建方式与 random 随机数套件,再覆盖 reshape/ravel/flatten/转置、正负索引、步长切片、布尔索引、花式索引以及"视图 vs 副本"这一最容易踩坑的主题。前置知识是本系列前 45 章的 pandas 基础操作。学完后你会真正看懂"pandas 底层到底是什么",并为第 47 章广播与向量化、第 48 章 NumPy 与 pandas 协同打好基础。
你在前面章节写的df["col"] * 2、df.groupby().sum(),背后跑的都是 NumPy。本篇把这层"地下结构"挖出来看清楚。
目录
1. 为什么学 NumPy
2. ndarray 与 Python list 的本质区别
3. ndarray 的核心属性
4. 用 array 从列表创建数组
5. 等差序列 arange 与 linspace
6. 特殊数组 zeros ones eye full empty
7. 随机数生成
8. 形状操作 reshape ravel flatten 与转置
9. 索引与切片
10. 布尔索引与花式索引
11. 视图与副本
12. NumPy 与 pandas 的关系
13. 常见坑
14. 本章小结与练习
1. 为什么学 NumPy
NumPy(Numerical Python)是 Python 生态里几乎所有科学计算库的底层依赖:pandas 的 Series/DataFrame、scikit-learn 的训练数据、matplotlib 的绘图数据,底层都是 NumPy 的 ndarray(n-dimensional array,多维数组)。
学 NumPy 的理由很实际:
- 看懂 pandas 的报错:很多 pandas 异常最终是 NumPy 的
ValueError(形状不匹配、类型不兼容),看不懂 NumPy 就只能瞎改。 - 写出更快的代码:pandas 的性能优化本质就是"把循环下沉到 NumPy 的 C 层",这正是第 47 章的主题。
- 无缝交接给其他库:
df.values拿到的就是 ndarray,直接喂给 sklearn / scipy / matplotlib。
环境确认(本机 Windows / Python 3.14.7 / numpy 2.5.3 / pandas 3.0.6):
import numpy as np
import pandas as pd
print("numpy", np.__version__)
print("pandas", pd.__version__)
numpy 2.5.3
pandas 3.0.6
惯例是 import numpy as np,几乎所有中文教程和开源项目都用这个别名。
2. ndarray 与 Python list 的本质区别
很多人说"ndarray 就是快速的 list",这个说法不准确。它们的区别是数据结构层面的:
| 维度 | Python list | ndarray |
|------|-------------|---------|
| 元素类型 | 可以混合(int、str、对象混放) | 同一块连续内存,所有元素 dtype 相同 |
| 内存布局 | 分散的对象指针链表 | 连续的 C 风格数组 |
| 运算语义 | list * 2 是"重复拼接" | arr * 2 是"逐元素计算" |
| 向量化 | 不支持,只能 for 循环 | 原生支持,直接下沉 C 层 |
| 多维 | 需要嵌套 list,操作笨拙 | 原生多维,shape 管理 |
直接看最经典的对比:
py_list = [1, 2, 3, 4, 5]
arr = np.array([1, 2, 3, 4, 5])
print("list * 2 =", py_list * 2) # list 是把自己重复一遍
print("array * 2 =", arr * 2) # ndarray 是每个元素乘 2
print("list + list =", py_list + py_list)
print("array + array =", arr + arr)
list * 2 = [1, 2, 3, 4, 5, 1, 2, 3, 4, 5]
array * 2 = [ 2 4 6 8 10]
list + list = [1, 2, 3, 4, 5, 1, 2, 3, 4, 5]
array + array = [ 2 4 6 8 10]
这就是"向量化"的直观感受:对整个数组说 arr * 2,NumPy 在 C 层一次性遍历 100 万元素,而不用你写一个 Python for 循环。
内存占用差距同样惊人(本机实测):
import sys
lst = list(range(1_000_000)) # Python list,存的是"指向 int 对象的指针"
arr1m = np.arange(1_000_000) # ndarray,连续内存块
print("Python list 1e6 元素占用(字节):", sys.getsizeof(lst))
print("ndarray nbytes:", arr1m.nbytes)
Python list 1e6 元素占用(字节): 8000056
ndarray nbytes: 8000000
注意:这里 list 的 8MB 只是指针数组的大小,100 万个 int 对象本身还各自占 28 字节左右,实际开销远大于此;而 ndarray 的 8MB 就是全部(int64 每个元素 8 字节)。当你在 pandas 里读入百万行数值列,省下的就是这种结构性开销。
⚠️ 常见坑:不要把 ndarray 当 list 用。arr.append(...)是不存在的(ndarray 长度固定),arr + [1,2]也不是拼接。需要"动态追加"请用 list 攒好再一次性np.array()。
3. ndarray 的核心属性
拿到一个 ndarray,先问五个问题:它是什么类型?几行几列?几维?多少个元素?每个元素占几个字节?这五个问题对应五个属性:
a = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.int32)
print("dtype:", a.dtype) # 元素数据类型
print("shape:", a.shape) # 各维度长度,元组
print("ndim:", a.ndim) # 维度数 = len(shape)
print("size:", a.size) # 元素总数 = shape 各维乘积
print("itemsize:", a.itemsize) # 单个元素占多少字节
print("nbytes:", a.nbytes) # 总字节数 = size * itemsize
dtype: int32
shape: (2, 3)
ndim: 2
size: 6
itemsize: 4 bytes
nbytes: 24
速查表:
| 属性 | 含义 | 本例值 |
|------|------|--------|
| dtype | 元素类型(int32/float64/bool/…) | int32 |
| shape | 各维长度组成的元组 | (2, 3) |
| ndim | 维度数(轴数) | 2 |
| size | 元素总数 | 6 |
| itemsize | 单元素字节数 | 4 |
| nbytes | 数据区总字节数 | 24 |
几个常用 dtype:np.int32、np.int64(64 位机器默认整数)、np.float32、np.float64(默认浮点)、np.bool_、np.str_。pandas 3.0 里字符串列默认后端已是 str dtype,底层与这里的 np.str_ 同源。
4. 用 array 从列表创建数组
最直接的创建方式是 np.array(),传入一个 Python list(一维)或嵌套 list(多维):
# 一维:传一层列表
v = np.array([1, 2, 3, 4])
print(v, v.shape)
# 二维:传嵌套列表,内层每个子列表是一行
m = np.array([[1, 2, 3], [4, 5, 6]])
print(m)
# 用 dtype 指定类型
f = np.array([1, 2, 3], dtype=np.float64)
print(f, f.dtype)
[1 2 3 4] (4,)
[[1 2 3]
[4 5 6]]
[1. 2. 3.] float64
⚠️ 常见坑:类型自动"向上转型"。ndarray 要求所有元素同 dtype,所以当你混入不同类型时,NumPy 会自动统一,结果往往不是你想要的:
d = np.array([1, "two", 3.0]) # int + str + float
print(d, d.dtype)
['1' 'two' '3.0'] <U32
三个数字字符串被统一成了字符串 dtype(<U32 表示最多 32 字符的 Unicode),原本的 1 和 3.0 都变成了文本,再也没法直接做加法。建数组前就要保证列表里元素类型一致,这也是 pandas 里读数据后要立刻 astype 的原因。
5. 等差序列 arange 与 linspace
造测试数据时,最常用的两个函数:
# arange(start, stop, step):类似 Python 的 range,左闭右开
print("arange(0, 10, 2):", np.arange(0, 10, 2))
print("arange(5):", np.arange(5))
print("arange(0, 1, 0.2):", np.arange(0, 1, 0.2))
# linspace(start, stop, num):等距切 num 个点,默认包含右端点
print("linspace(0, 1, 5):", np.linspace(0, 1, 5))
print("endpoint=False:", np.linspace(0, 1, 5, endpoint=False))
arange(0, 10, 2): [0 2 4 6 8]
arange(5): [0 1 2 3 4]
arange(0, 1, 0.2): [0. 0.2 0.4 0.6 0.8]
linspace(0, 1, 5): [0. 0.25 0.5 0.75 1. ]
endpoint=False: [0. 0.2 0.4 0.6 0.8]
选择建议:知道步长用 arange(点数由步长决定);知道点数用 linspace(步长由点数决定),画函数曲线、等距采样时更好用。
⚠️ 常见坑:arange用浮点步长时,由于浮点累加误差,点数可能和你预期差 1。例如np.arange(0, 1.1, 0.1)的右端点行为不稳定。需要精确点数时优先linspace。
6. 特殊数组 zeros ones eye full empty
造矩阵骨架时,下面几个函数比手写嵌套 list 快得多:
print("zeros((2,3)):\n", np.zeros((2, 3))) # 全 0,默认 float64
print("ones(3):", np.ones(3)) # 全 1
print("eye(3):\n", np.eye(3)) # 单位矩阵(对角线为 1)
print("full((2,2), 7):\n", np.full((2, 2), 7)) # 全部填同一个值
zeros((2,3)):
[[0. 0. 0.]
[0. 0. 0.]]
ones(3): [1. 1. 1.]
eye(3):
[[1. 0. 0.]
[0. 1. 0.]
[0. 0. 1.]]
full((2,2), 7):
[[7 7]
[7 7]]
最后一个 np.empty 最容易被误解:
e = np.empty(3)
print("empty(3) 未初始化:", e)
empty(3) 未初始化: [1. 1. 1.]
empty 只是分配内存、不写初始值,里面是那块内存上残留的"垃圾数据"(本例刚好是上一行 ones(3) 留下的 1)。它比 zeros 快,因为省了一次写 0 的动作,但绝对不要在没有立刻填满之前就去读它。正常业务用 zeros/ones/full,只有在你接下来马上要用计算结果覆盖整段内存时,才为了那点性能去用 empty。
| 函数 | 作用 | 默认 dtype |
|------|------|-----------|
| np.zeros(shape) | 全 0 数组 | float64 |
| np.ones(shape) | 全 1 数组 | float64 |
| np.eye(n) | n×n 单位矩阵 | float64 |
| np.full(shape, v) | 全部填 v | 按 v 推断 |
| np.empty(shape) | 分配但不初始化 | 按要求 |
| np.ones_like(a) | 和 a 同 shape 的全 1 | 与 a 相同 |
7. 随机数生成
做演示、造测试数据、切片抽样都离不开随机数。NumPy 的随机数在子模块 np.random 里。第一件永远要做的事是设种子:
np.random.seed(42) # 固定种子,保证每次运行结果可复现
print("rand(2,3):\n", np.random.rand(2, 3)) # 均匀分布 [0,1)
np.random.seed(42)
print("randn(3):", np.random.randn(3)) # 标准正态分布
np.random.seed(42)
print("randint(1,10,size=5):", np.random.randint(1, 10, size=5)) # 随机整数
np.random.seed(42)
print("uniform(0,1,3):", np.random.uniform(0, 1, 3)) # 均匀分布
np.random.seed(42)
print("choice:", np.random.choice(["a", "b", "c"], 5)) # 随机抽样
rand(2,3):
[[0.37454012 0.95071431 0.73199394]
[0.59865848 0.15601864 0.15599452]]
randn(3): [ 0.49671415 -0.1382643 0.64768854]
randint(1,10,size=5): [7 4 8 5 7]
uniform(0,1,3): [0.37454012 0.95071431 0.73199394]
choice: ['c' 'a' 'c' 'c' 'a']
要点:
seed(42)让随机序列可复现。教程、单测、汇报代码每次跑出来的数都一样,别人才复现得了你的结论。rand均匀分布、randn标准正态(均值 0 方差 1),名字相近别混;choice从给定序列里有放回抽样,pandas 里df.sample()底层思路类似。
8. 形状操作 reshape ravel flatten 与转置
数据经常要"变形"而不改数值:
x = np.arange(12)
print("arange(12):", x)
# reshape:把 12 个元素重排成 3 行 4 列
print("reshape(3,4):\n", x.reshape(3, 4))
# -1 表示"这一维我懒得算,你根据总数自己推"
print("reshape(-1,4):\n", x.reshape(-1, 4))
m = x.reshape(3, 4)
print("ravel:", m.ravel()) # 拉平成一维,尽量返回视图
print("flatten:", m.flatten()) # 拉平成一维,永远返回副本
print("T 转置:\n", m.T) # 行列互换
arange(12): [ 0 1 2 3 4 5 6 7 8 9 10 11]
reshape(3,4):
[[ 0 1 2 3]
[ 4 5 6 7]
[ 8 9 10 11]]
reshape(-1,4):
[[ 0 1 2 3]
[ 4 5 6 7]
[ 8 9 10 11]]
ravel: [ 0 1 2 3 4 5 6 7 8 9 10 11]
flatten: [ 0 1 2 3 4 5 6 7 8 9 10 11]
T 转置:
[[ 0 4 8]
[ 1 5 9]
[ 2 6 10]
[ 3 7 11]]
reshape 的要求是新 shape 的元素总数和原数组完全相等(3×4 = 12)。-1 是语法糖:你给一个维度,NumPy 反推另一个。
ravel vs flatten 的区别下一节"视图与副本"会展开:想省内存用 ravel(),想要一份独立数据用 flatten()。
9. 索引与切片
NumPy 索引基本沿用 Python list 的语法,但支持多维:
v = np.arange(10, 20) # [10..19]
print("v:", v)
print("v[0]:", v[0], "v[-1]:", v[-1]) # 正负索引都认
print("v[2:7]:", v[2:7]) # 切片,左闭右开
print("v[::2]:", v[::2]) # 步长 2
print("v[::-1]:", v[::-1]) # 负步长 = 反转
v: [10 11 12 13 14 15 16 17 18 19]
v[0]: 10 v[-1]: 19
v[2:7]: [12 13 14 15 16]
v[::2]: [10 12 14 16 18]
v[::-1]: [19 18 17 16 15 14 13 12 11 10]
二维数组用"逗号分隔各维度"的方式索引,比嵌套 list 的 mat[row][col] 清晰:
mat = np.arange(1, 13).reshape(3, 4)
print("mat:\n", mat)
print("mat[1, 2]:", mat[1, 2]) # 第 2 行第 3 列(从 0 数)
print("mat[0]:", mat[0]) # 取第 0 行
print("mat[:, 1]:", mat[:, 1]) # : 表示整维,这里取第 1 列
print("mat[0:2, 1:3]:\n", mat[0:2, 1:3]) # 行列同时切片
mat:
[[ 1 2 3 4]
[ 5 6 7 8]
[ 9 10 11 12]]
mat[1, 2]: 7
mat[0]: [1 2 3 4]
mat[:, 1]: [ 2 6 10]
mat[0:2, 1:3]:
[[2 3]
[6 7]]
记住这个心智模型:每个逗号隔开一个轴。三维数组就是 arr[i, j, k]。
10. 布尔索引与花式索引
这两种索引是 NumPy(也是 pandas)筛选数据的灵魂。
布尔索引:传一个和数组等长的布尔数组,挑出 True 位置的元素:
v = np.arange(10, 20)
print("v[v > 15]:", v[v > 15]) # 先算 v>15 得到布尔数组,再拿来筛
print("np.where(v > 15):", np.where(v > 15)) # where 返回满足条件的下标
v[v > 15]: [16 17 18 19]
np.where(v > 15): (array([6, 7, 8, 9]),)
花式索引(fancy indexing):传一个整数数组(或列表),按顺序挑出这些位置:
print("v[[1, 3, 5]]:", v[[1, 3, 5]]) # 挑下标 1、3、5
print("mat[[0,2]]:\n", mat[[0, 2]]) # 二维里挑第 0、2 行
print("mat[mat > 6]:", mat[mat > 6]) # 布尔筛选后自动拉平
v[[1, 3, 5]]: [11 13 15]
mat[[0,2]]:
[[ 1 2 3 4]
[ 9 10 11 12]]
mat[mat > 6]: [ 7 8 9 10 11 12]
对比记忆:
| 方式 | 传入 | 用途 |
|------|------|------|
| 切片 | start:stop:step | 取连续一段 |
| 布尔索引 | 布尔数组 | 按条件筛选 |
| 花式索引 | 整数数组/列表 | 按指定下标挑选、重排 |
⚠️ 常见坑:多个条件要用括号 +&/|/~,不能用 Python 的and/or/not:
v[(v > 12) & (v < 18)] # ✅ 对
v[v > 12 and v < 18] # ❌ ValueError,and 不能作用于数组
这个坑在第 47 章布尔掩码还会详细展开。
11. 视图与副本
这是 NumPy(以及第 18 章讲过的 pandas SettingWithCopyWarning)最经典的坑。切片得到的是视图,不是副本——它和原数组共享同一块内存:
src = np.array([1, 2, 3, 4, 5])
view = src[1:4] # 切片,是视图
view[0] = 999 # 改视图……
print("改 view 后 src:", src) # 原数组跟着变了!
改 view 后 src: [ 1 999 3 4 5]
view[0] 本是 src[1],改它等于改 src[1]。你以为在操作一个独立切片,实际动了原始数据。想要一份互不影响的拷贝,显式 .copy():
src2 = np.array([1, 2, 3, 4, 5])
cp = src2[1:4].copy()
cp[0] = 999
print(".copy() 后 src2:", src2)
.copy() 后 src2: [1 2 3 4 5]
什么时候需要 .copy():
- 你要在切片上做修改,但不想影响原数组;或把切片传给别人的函数,不希望对方内部修改波及你的原始数据;
- 后面接
ravel()/转置这类返回视图的操作时,如果要长期保存结果,先 copy。
什么时候不用 copy:只读不写、只是拿切片做计算/画图时,视图反而省内存(不复制数据)。
对应到 pandas:第 18 章讲过的 SettingWithCopyWarning,根源就是"链式索引可能拿到视图也可能拿到副本"。pandas 3.0 起默认开启 Copy-on-Write(CoW),链式赋值不再触发该警告,行为变得可预期;但 NumPy 原生 ndarray 没有 CoW,切片即视图的规则始终成立。
12. NumPy 与 pandas 的关系
最后把本篇和系列主线接上。pandas 的 Series 和 DataFrame,数值列的底层就是一个 ndarray:
import pandas as pd
s = pd.Series([1, 2, 3, 4])
print("Series.values:", s.values, type(s.values))
df = pd.DataFrame({"a": [1, 2], "b": [3, 4]})
print("df['a'].values:", df["a"].values, type(df["a"].values))
Series.values: [1 2 3 4] <class 'numpy.ndarray'>
df['a'].values: [1 2] <class 'numpy.ndarray'>
也就是说:
- 你对
df["a"]做的* 2、.sum()、布尔筛选,绝大多数最终都翻译成了本篇讲的 ndarray 操作; df.values/df.to_numpy()直接把底层 ndarray 交出来,可无缝喂给 sklearn、matplotlib、scipy;反过来任何 ndarray 都能直接pd.DataFrame(arr)包成表。
理解了 ndarray 的 dtype、shape、视图语义,pandas 里"为什么这列是 object""为什么改了切片原数据也变了""为什么两列相加报形状错误"这类问题都会迎刃而解。
13. 常见坑
1. 把 list 语义套到 ndarray 上:arr.append()、arr + [1] 都不存在;arr * 2 是逐元素而不是重复。
2. 混合类型自动转型:np.array([1, "x", 3.0]) 会整体变成字符串,数值列建数组前先统一类型。
3. `np.empty` 里是垃圾值:分配后没填满就读,会拿到不可预测的数据。
4. `arange` 浮点步长点数漂移:需要精确点数用 linspace。
5. 切片是视图:改切片波及原数组,需要独立数据就 .copy()。
6. 多条件用 `and/or/not`:数组多条件必须写 (a > 1) & (b < 2),且每个条件加括号。
7. 不设随机种子:教程/实验代码每次跑结果不同,无法复现。
14. 本章小结与练习
小结:ndarray 是同类型、连续内存、可向量化的多维数组;五大属性 dtype/shape/ndim/size/itemsize 描述它;创建看 array/arange/linspace/zeros/ones/eye/full/empty;变形靠 reshape/ravel/flatten/T;筛选靠切片、布尔索引与花式索引;切片是视图,独立数据要 .copy();pandas 列的底层就是 ndarray。
练习:
1. 用三种不同方式创建一个长度为 10、全为 3.14 的一维数组(提示:np.full、np.ones 再乘、np.array),并说出各自 dtype。
2. 创建 np.arange(20).reshape(4, 5),取出它的第 2 列、取出右下角 2×2 子矩阵、再把它转置。
3. 给定 arr = np.array([3, -1, 4, 1, 5, -9, 2, 6]),用布尔索引挑出所有绝对值大于等于 3 的元素,并验证你写出的条件表达式。
4. 解释下面这段代码最后 a 的值是什么,并说出原因:
a = np.array([10, 20, 30, 40])
b = a[1:3]
b[0] = 999
5. np.empty(5) 每次运行结果都一样吗?为什么?在什么场景下你会选择它而不是 np.zeros(5)?
6. 把一个 10×10 的 ndarray 通过 .values 的视角反向验证:先用 pd.DataFrame 包一个 5 行 3 列的随机数组,再用 df.to_numpy() 取出底层数组,确认它的 shape 与 dtype。
下一篇预告:第 47 章 NumPy 广播与向量化计算 —— 形状不同的数组怎么做加法?向量化凭什么比 Python 循环快几十上百倍?本篇用本机实测数据给出答案。
文章回复
0 条公开回复