☰
Pandas Series vs NumPy Array:索引、缺失值与选型全解析
2026/10/15 14:44:01 网站建设 项目流程

前阵子有位读者给我发来一段代码,里面反反复复在df['列']、.values、np.array()之间来回切换,他说自己始终搞不清 Pandas 里的 Series 和 NumPy 里的 array 到底有什么区别,每次写完都怕类型出错。这个问题其实问得非常好,因为这两个对象外观相似、底层纠缠,但设计目标和行为逻辑完全不同,用错之后不只是慢一点的问题,而是会静默地给出错误结果,或者让你在数据对齐、缺失值处理上白踩一堆坑。

这篇东西就把 Series 和 array 的差异讲透。我会从它们的“出身”讲起,再分别聊索引、缺失值、运算行为、API 和性能,最后给出实际项目里我用下来的选型底线和转换手段。无论你是刚学 Pandas 的新手,还是写了好几年数据处理脚本但一直靠.values糊弄的老同学,都应该能从里面找到自己需要的那一块拼图。

1. 先从“出身”讲起:两个对象各自为了什么问题而生

1.1 NumPy 的 ndarray 是为纯数值计算设计的“裸数组”

NumPy 在 Python 生态里的地位不必多说,它最核心的数据结构就是ndarray(n 维数组),我们平时口语里常说的“array”指的就是它。在 NumPy 出现之前,Python 开发者在做科学计算时主要用内置 list,但 list 的每个元素是一个独立的 Python 对象,存储分散、类型解释成本高,循环遍历更是慢到让人怀疑人生。

ndarray 解决的就是这个问题。它把同类型的数据连续地放在内存块里,底层走 C 语言的高速路径,配合上向量化运算和通用的广播规则,让 Python 能够以接近原生编译语言的速度做矩阵运算、数值计算、傅里叶变换、线性代数这类纯数学操作。你可以把 ndarray 想象成一个标准集装箱:规格统一、排布紧密、装卸高效,但集装箱本身不关心里面装的是什么“业务含义”。

1.2 Pandas 的 Series 是为表格分析设计的“带标签的数组”

Pandas 出现的场景比 NumPy 更贴近实际业务:它面对的是带列名的 Excel 表格、带时间戳的行情记录、带 ID 的销售明细。这类数据的核心需求不是“快速做矩阵乘法”,而是“我能不能根据某一列的名字直接取数”“两个表能不能按关键字自动对齐”“这一列里有些缺失数据能不能友好地表示”。

Series 就是 Pandas 里的一维带标签数组,你可以把它理解为 DataFrame 单独抽出来的一行或一列,也可以理解为“一个数组外加一个索引(Index)”。它的底层存储本质上仍然依赖 NumPy 数组,但在数组外面包了一层更聪明的外壳:每个数据点都有一个明确标签,标签可以是字符串、整数、时间戳,甚至多层索引。

1.3 一张表快速看懂定位差异

对比维度numpy.ndarraypandas.Series
所属库NumPyPandas
设计目标科学计算、矩阵运算、通用数组容器表格分析、标签索引、数据对齐
索引方式只能按整数位置索引,从 0 开始标签索引(显式)+ 位置索引(隐式)
缺失值表示没有原生缺失概念,整数数组放 NaN 会变 float原生支持 NaN / NaT / NA
类型体系基础 dtype:整型、浮点、布尔、字符串、复数等在基础 dtype 之上还有 category、string、nullable 类型
典型场景数值计算、算法底层、矩阵变换数据清洗、统计分析、可视化前处理

所以第一个问题的答案已经呼之欲出了:array 是“纯数据容器”,Series 是“带业务语义的表格切片”。理解了这点后,后面的索引、对齐、缺失值差异就都是在为这一句话补充细节。

2. 索引机制:这是两者最根本的分水岭

2.1 隐式位置索引 vs 显式标签索引

ndarray 的索引只有一个维度逻辑:位置。arr[0]取第一个元素,arr[3]取第四个元素,切片arr[1:4]取第 1 到第 3 个元素,永远按从左到右的物理顺序来。你不可能给一个 ndarray 里的元素起名字,然后说“给我那个叫 ‘a’ 的元素”,这不是它设计的职责。

Series 就完全不同。它自带index,所以理论上你有两套取数方式:.loc走标签,.iloc走整数位置。官方强烈推荐这两个显式方法,而不是直接对 Series 用s[0]这种写法。

import numpy as np import pandas as pd arr = np.array([10, 20, 30, 40]) s = pd.Series([10, 20, 30, 40], index=['a', 'b', 'c', 'd']) print(arr[0]) # 10 print(s.loc['a']) # 10,标签索引 print(s.iloc[0]) # 10,位置索引

注意一个小坑:如果 Series 的索引恰好是整数,直接写s[0]就可能产生歧义,它会优先按标签含义去解释,而不是位置。我见过不少新手在这里吃暗亏:

s_int = pd.Series([100, 200, 300], index=[5, 6, 7]) # s_int[0] 会抛 KeyError,因为标签 0 不存在 # s_int.iloc[0] 才是 100

2.2 索引自动对齐:两个 Series 相加与两个 array 相加是完全不同的运算

这是我觉得最值得展开讲的一点,因为它决定了你“能不能把 Series 当成高级数组用”。

两个 ndarray 相加,维度一样就按位置逐个加,Python 里就是这么简单直观:

a = np.array([1, 2, 3]) b = np.array([10, 20, 30]) print(a + b) # [11 22 33]

但两个 Series 相加的时候,Pandas 会先按索引做自动对齐,索引相同的元素才相加,索引只出现在其中一个 Series 里的位置会补成 NaN,不会报错。这个行为是醍醐灌顶级别的差异:

s1 = pd.Series({'apple': 3, 'banana': 5, 'pear': 8}) s2 = pd.Series({'banana': 2, 'pear': 4, 'lychee': 9}) print(s1 + s2)

运行结果:

apple NaN banana 7.0 pear 12.0 lychee NaN dtype: float64

为什么 Pandas 要做成自动对齐?因为真实数据里几乎没有两份记录是“恰好按同样顺序排好”的。比如你从两个不同系统导出的销售表,一个按门店 ID 排,一个按日期排,合并的时候如果不能自动按索引对上,那数据分析就全毁了。而 NumPy 是纯数值计算,它对“这批数字代表什么业务含义”完全不关心,所以只做逐位运算。

2.3 切片行为的差别也不一样

array 切片arr[1:4]按位置,从索引 1 到索引 3,不含结尾。Series 的切片分两种情况:.iloc[1:4]行为与 array 相同,但.loc['b':'d']这种标签切片跟 Python 内置 dict 的行为一致,是含端点的,也就是会把'd'也取出来。

print(s.iloc[1:3]) # b=20, c=30,不含位置 3 print(s.loc['b':'d']) # b=20, c=30, d=40,含端点

这个差异一旦记混,你会发现结果里不是多一行就是少一行,而更可怕的是它通常不报错,只是静默地给你一个“看起来差不多”的结果。

3. 缺失值处理与 dtype:为什么整数列放进 None 会变 float,而 array 里根本没有真正的 NaN

3.1 ndarray 对缺失值的“妥协”

NumPy 的 ndarray 有一个先天短板:它没有原生缺失值概念。数组里每个位置都必须有一个真实存在的同类型值,你不可能把一个元素标记为“这里没数据”同时保持整块内存整齐划一。

所以当你试图把np.nan放进整数数组时,NumPy 会发生类型提升,把数组转成 float64:

arr_with_nan = np.array([1, 2, np.nan]) print(arr_with_nan.dtype) # float64

如果你放的是 Python 的None,NumPy 则更无奈,只能把整个数组升级成 object 类型,变成“完全放弃连续内存优势”的 Python 对象数组:

arr_obj = np.array([1, 2, None]) print(arr_obj.dtype) # object

object 数组看着还是数组,但它已经失去了 ndarray 最核心的性能优势,而且每个元素重新变成了 Python 对象,向量化运算也会退化。更麻烦的是,你拿着一堆None想去做算术运算,结果往往是直接炸出 TypeError。

3.2 Series 的缺失值体系与 nullable 类型

Series 在缺失值处理上就从容多了。它在构造的时候就会识别None和np.nan,并且会给你一套完整的缺失值方法:isna()、notna()、dropna()、fillna(),还有专门表示“缺失”的pd.NA和表示“时间缺失”的pd.NaT。

s_mix = pd.Series([1, 2, None, 4]) print(s_mix.isna()) print(s_mix.sum(skipna=True)) # 7.0,默认跳过缺失值

而且 Pandas 还有 nullable 扩展类型(如Int64、Float64、boolean、string),它们允许你在不丢失类型语义的情况下安全地保留缺失值:

s_int_nullable = pd.Series([1, 2, None], dtype='Int64') print(s_int_nullable.dtype) # Int64 print(s_int_nullable.sum()) # 3,不会报错

这种表达能力是 ndarray 做不到的。你用一个普通 NumPy 数组去存“有缺失的整数”,必然被转成 float64,后续判断就得多一层“这是真实 0.0 还是 NaN”的纠结;而 Series 用Int64这种可空类型保存时,NaN 和 0 是清清楚楚分开的。

3.3 由此产生的类型推断误区

很多同学看到df['score']输出的 dtype 是float64,第一反应是“这列数据是小数”。但实际上它很可能原本是整数列,只是因为混入了几个缺失值才被自动提升。Pandas 在read_csv、Series 构造等场景下的默认逻辑就是这样,并不是它想把你数据弄复杂,而是实在没有更合适的基础类型可以表达“整数 + 缺失”这个组合。

这类误解导致的连锁反应很常见:你拿df['score']去做astype(int),如果里面有 NaN,就会直接报ValueError: cannot convert float NaN to integer。这时候很多人第一反应是“我的数据有问题”,其实根源是你没有意识到 array 与 Series 在缺失值哲学上的根本差异。处理办法不是硬转,而是先fillna、dropna,或者直接用 pandas 的Int64可空整数类型。

4. 运算行为与视图复制:把 Series 当 array 用,最容易踩的“暗坑”

4.1 广播规则:围着索引转 vs 围着形状转

NumPy 广播大家应该都熟悉:形状不一致的多维数组相加时,NumPy 会在尾部维度对齐,能扩就扩,扩不了就报错。Series 在底层继承了 NumPy 的广播能力,s + 1也能像a + 1一样把所有元素逐个加一遍,这点没有区别:

print(arr + 1) # [11 21 31 41] print(s + 1) # a=11, b=21, c=31, d=41

但真正微妙的地方在于:当你拿一个 Series 和另一个长度不同的 Series 做运算时,对齐逻辑会先于广播逻辑生效。而且如果 Series 与一个 ndarray 做混合运算,Pandas 会尝试把 ndarray 当成无标签数据按位置对齐;一旦两者长度不匹配,你得到的可能不是广播结果,而是报错或 NaN 填充。所以我个人的原则是:在同一个表达式里,不要搞“半个 Series 半个 array”的混血运算,除非你非常清楚每一步的对齐规则。

4.2 就地修改、视图与链式索引问题

ndarray 切片默认返回视图,修改视图会改动原数组,这是很多老 Python 用户熟悉的行为:

arr2 = np.array([1, 2, 3, 4]) view = arr2[1:3] view[0] = 99 print(arr2) # [1 99 3 4]

Series 的切片也遵循类似的底层原理——它内部还是数组,切片不浅拷贝的话可能共享数据块。但 Pandas 里的链式索引问题要更阴间:你写s[s > 2]['xx'] = 5这种链式赋值,Pandas 很可能给你一个SettingWithCopyWarning,但数据到底改没改要取决于当时碰上的视图/复制组合。这类问题的根治办法只有一个:要么用.loc单步赋值,要么干脆显式.copy(),不要指望链式索引在 Pandas 里像原生数组那样有确定的视图语义。

4.3 想把 Series“降级”成纯计算对象,别只做表面转换

有些场景是你明确希望摆脱索引、缺值、对齐这些 Pandas 包装,拿到一块干净的、优化过的数值块去跑数值库。这时候如果只是用np.array(s)可能不够“干净”,因为它有可能产生 object 数组或额外拷贝。正确的姿势是用s.to_numpy(),还可以通过参数控制 dtype 和拷贝行为:

arr_from_s = s.to_numpy(dtype='float64', copy=True)

copy=False意味着尽可能视图化,省内存但存在互相影响的隐患;copy=True则保证后半段算法怎么折腾都不会回头污染你的 Series。这种明确的转换意识,比单纯写个.values要稳得多。

5. API 设计和性能的现实差异:什么场景下谁更顺手

5.1 Series 的独门 API,array 里没有

Series 是数据分析场景的产物,所以它有一大票“表格思维”的方法:value_counts()直接统计分类频数,map()做映射替换,rolling(n)做滚动窗口,resample()做时间重采样,groupby()配合聚合函数做分组统计。这些操作在 ndarray 上要么根本不存在,要么得自己用 loops 一点一点拼出来。

s_degree = pd.Series(['本科', '硕士', '本科', '博士', '硕士', '本科']) print(s_degree.value_counts())

一行代码完成数据透视级别的统计任务,这在纯 NumPy 里至少要五行循环加字典。日常数据分析里,这类“对值做频次统计、批量替换、分组计算”的需求远远多于矩阵乘法,所以如果说“哪个日常使用率高”,Series 在这些场景里完胜。

5.2 纯计算时性能到底差多少

我知道你们真正想问的是:如果我有一个 100 万长度的浮点数组,用 Series 会比 array 慢很多吗?结论是:会有差距,但通常没有想象中夸张。Series 的底层数据就是 ndarray,算术运算本身直接走 NumPy 的向量化路径,多出来的开销主要来自索引对齐检查、dtype 验证、包装层的函数调用这些“固定税”。

比如一个 100 万元的加法操作,array 可能用几毫秒,Series 可能是它的 1.3 到 1.8 倍。这种差距在单次操作里确实无所谓,可一旦你把同样操作套进循环、迭代跑上一万次,差距就会被放大。反过来,如果你需要反复按标签取数,Series 的索引查找比裸 array 的线性扫描或条件筛选更自然,有时反而更快。

5.3 选型经验法则

使用场景推荐类型理由
数据读取、探索、清洗、统计Series / DataFrame带标签、缺失值友好、API 完整
矩阵变换、线性代数、FFTndarray直接与 NumPy 函数无缝协作
训练机器学习模型前的特征矩阵ndarray多数建模库输入更偏好纯数值矩阵
时间序列、分组聚合、透视Series索引对齐与时间重采样无可替代
自定义高性能算法、内存精确管理ndarray语义简洁、开销可控

一句话版本:数据分析和可视化包装阶段用 Series,数据到了真正要计算的时候再果断to_numpy()降级成 array,算完以后再用 Series 包装回来展示和保存。

6. Array、Series 与 DataFrame 之间的常见转换与翻车现场

6.1 推荐用to_numpy(),而不是.values

Pandas 早期文档里常见.values这个属性,它确实能返回底层 NumPy 数组,但它在处理Int64、category、string等扩展类型时行为不够稳定,有时返回的并不是规整的 ndarray,而是一个“半转换”的结果。to_numpy()更明确,也可以显式指定输出 dtype,所以我强烈建议新代码一律使用to_numpy()。

s = pd.Series([1, 2, 3]) a = s.to_numpy() print(type(a)) # <class 'numpy.ndarray'>

6.2df['列']、df[['列']]、df.loc[行]分别返回什么

这个“看似相同实则不同”的坑我见过无数次:

df = pd.DataFrame({'name': ['Alice', 'Bob'], 'score': [85, 92]}) col_s = df['score'] # Series,索引是行号 col_df = df[['score']] # DataFrame,仍然是二维结构 row_s = df.loc[0] # Series,但它的索引是列名 print(df['score'].to_numpy()) # [85 92] print(df.loc[0].to_numpy()) # ['Alice' 85]

取单列得到 Series,取多列组成的子表则是 DataFrame,取单行得到的 Series 索引是列名。转换到 ndarray 的时候,方向完全不同。如果不看形状和 dtype,直接用矩阵算法处理行数据,很容易产生维度错乱的 bug。

6.3 把多个 Series 塞进 np.array 会出现的意外维度

这也是新手常见翻车点。两个 Series 索引一致时,np.array([s1, s2])可能得到一个二维数组,看起来挺合理:

s_a = pd.Series([1, 2, 3]) s_b = pd.Series([4, 5, 6]) print(np.array([s_a, s_b]).shape) # (2, 3)

但如果两个 Series 长度不同,或者索引标签不一致,NumPy 根本不会帮你做对齐,它只会因为找不到统一维度而“被迫”生成一个 object 数组,或者更糟:在你没察觉的时候悄悄变成(2,)的一维数组、里面每个元素都是一个 Series 对象。这种隐藏结构对后续任何计算都是灾难。

出现这种需求时,正确的姿势通常是显式交给 Pandas:

df_combine = pd.DataFrame({'a': s_a, 'b': s_b})

让 Pandas 按索引对齐,再决定要不要收集成矩阵。这比你自己硬用np.array组合安全得多。

6.4 转换时保住或丢掉 index 的实用建议

如果你只是暂时需要一次快速计算,不希望 Pandas 的包装层干预,那么用s.to_numpy(copy=False)是最高效的。反过来,如果你需要把最终的 ndarray 结果重新包装成 Series,请记得传index,否则默认会用 0 到 n-1 重新生成整数索引,原数据的业务标签就丢了:

final_series = pd.Series(result_arr, index=original_index)

如果原索引很重要却在转换时弄丢了,事后补救就很麻烦。我一般在数据处理流水线的出口处做一次“索引快照”,先用orig_idx = s.index.copy()保存,等所有计算结束再还原回去。这个习惯帮我省了不少返工时间。


最后说一点我个人的操作习惯。我平时在项目里绝大多数时间是“纯 Series 工作者”,只有进入性能敏感段或矩阵运算段才会用to_numpy()降到 ndarray。而且我在做这种降级之前一定会问自己三个问题:当前的业务信息还在不在索引里?缺失值处理完了没有?阶段结果要不要回填到原表?如果有一个答案不明确,我就先不做转换。这种谨慎不是刻板的教条,而是踩过太多次“索引静默丢失”和“对象数组错乱”之后换来的直觉。你也可以从今天开始,把自己代码里的.values全部检查一遍,看看有没有可以换成to_numpy()、有没有本不该转成 array 却硬转的地方,这本身就是一个很好的学习过程。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询