首页 / 资讯中心 / 文章详情

NumPy生成一列全NaN数组:np.full与dtype实践

NumPy生成一列全NaN数组:np.full与dtype实践 ★ FEATURED ARTICLE
做数据回填、宽表拼接、特征矩阵预留列这类活儿的时候几乎绕不开一个动作先生成一列全 NaN 的占位数据等后面算出来再往里填。很多人第一次写就是np.array([np.nan]*n)能跑但跑到十几万行、上百万行的时候会开始怀疑人生还有人图省事写成np.zeros(n)结果后面一算全是 0查半天才发现自己压根没填进去。这篇就把python numpy 如何生成一列都是 nan 的数组这件事从头到尾拆开讲清楚几种写法各自适合什么场景、dtype 为什么总在关键时候咬你一口、往已有数组里插一列 NaN 有哪几种姿势、以及判断这列是不是全 NaN时那些反直觉的地方。刚上手 numpy 的、从 pandas 转过来写底层数组的、以及天天跟缺失值打交道的数据岗都能从里面挑到能直接抄的写法。1. 先把需求掰开这里的一列 NaN到底指什么1.1 三种语境下的一列写法完全不同生成一列都是 NaN 的数组这句话在不同人嘴里含义能差出十万八千里。第一种语境是纯一维数组也就是array([nan, nan, nan])这种它没有行列概念只是一串长度 N 的浮点数常见于给某个特征列做初始化。第二种语境是二维矩阵中的某一列形状是(N, 1)它在内存里是 N 行 1 列能直接column_stack拼到别的矩阵右边也能通过arr[:, i]定位到。第三种语境是表格结构里的一列比如 pandas 的 DataFrame 新增一列或者结构化数组structured array里加一个浮点字段这时候你操作的对象已经不是裸 ndarray 了。这三种语境最容易混的地方在形状上。np.full(5, np.nan)得到的 shape 是(5,)np.full((5, 1), np.nan)得到的 shape 是(5, 1)。前者拿去做矩阵乘法会触发广播后者拿去当一维索引用会多出一个维度。我见过不少人在这里翻车明明只想加一列结果拼完之后矩阵从(1000, 5)变成(1000, 1000)内存直接炸掉回头一看是(N,)和(N, 1)没对齐。所以动手之前先问自己一句我要的是一列数据还是一个列向量这两者在 numpy 里不是一回事。判断方法很简单打印一下.shape和.ndim就清楚了。一维是ndim1列向量是ndim2。如果你的下游代码是 pandas、sklearn 这类吃二维输入的东西老老实实用(N, 1)如果只是当个一维占位序列(N,)更省事也更快。1.2 NaN 只能住在浮点类型里这不是 numpy 的任性NaN 是 IEEE 754 浮点标准里定义的一个特殊值全称 Not a Number。它的存在前提是这个类型有专门留出来的位模式来表示非法运算结果。整数类型没有这个位模式所以 int32、int64 里根本装不下 NaN。这就是为什么np.full(3, np.nan, dtypenp.int64)会直接抛ValueError: cannot convert float NaN to integer一点商量的余地都没有。这个约束会一路传导下去。你要往一个 int 数组的某一列塞 NaN第一步必须是arr arr.astype(np.float64)先把整块内存从整数重解释成浮点才能往里写 NaN。这个转换不是免费的对于大数组它是一次完整的内存拷贝几 GB 的数据转一次要几秒。所以如果你的流程是先建 int 再转 float 再填 NaN不如一开始建的时候就定成 float省一次来回。还有一类隐蔽的坑np.array([1, 2, np.nan])会自动把 dtype 提升成 float64这没问题但np.array([1, 2, None])得到的是dtypeobject里面的元素变成了 Python 对象后续做向量化运算会慢到怀疑人生。None 和 np.nan 在构造数组这件事上待遇完全不同别混着用。1.3 NaN 最反直觉的一点它不等于自己写过if x np.nan的人应该都被坑过这个表达式永远是 False。原因在 IEEE 754 的定义里NaN 表示一个不确定的结果两个不确定的结果当然不能被认为相等。所以检测 NaN 必须用np.isnan(x)检测整列是否全为 NaN 用np.isnan(col).all()。这个特性会引发一连串连锁反应。比如你去重的时候np.unique会把多个 NaN 保留下来排序的时候np.sort会把 NaN 扔到最后做集合运算、用in判断元素存在全都不按直觉走。更麻烦的是如果你的数组是 object dtypenp.isnan还会直接报错因为它只认数值类型。我在实际项目里养成了一个习惯只要一个数组可能含 NaN第一件事就是打印arr.dtype确认它是 float 系float16/32/64 或者 complex然后再往下写。这一步花两秒钟能省掉后面半小时的排查。2. 五种生成写法逐一拆解哪种该用哪种别用2.1 np.full默认答案也是最不容易出错的写法如果只能记一种写法记这个import numpy as np # 一维长度 5 的全 NaN 数组 col_1d np.full(5, np.nan) print(col_1d) # [nan nan nan nan nan] print(col_1d.dtype) # float64 print(col_1d.shape) # (5,) # 二维列向量5 行 1 列 col_2d np.full((5, 1), np.nan) print(col_2d.shape) # (5, 1)np.full(shape, fill_value, dtypeNone, orderC)的语义非常直白按给定形状创建数组每个位置都填成 fill_value。dtype 不指定时numpy 会根据 fill_value 推断np.nan是 Python float推断结果就是 float64。你可以显式指定dtypenp.float32来省一半内存行为一致。它最大的优势是意图明确。别人读你的代码一眼就知道你要一块全 NaN 的数组不需要在大脑里做类型推断或者运算模拟。相比之下np.zeros(n) * np.nan这种写法读者得先想0 乘 NaN 是 NaN 吧多绕一层。还有两个近亲值得知道。np.full_like(a, np.nan)会照着已有数组a的形状和 dtype 生成一个全 NaN 的副本适合我要一个跟它一样大的占位区这种场景。np.empty_like(a)加后手 fill 也能达到类似效果但顺序上更啰嗦。注意np.full的 fill_value 如果不是标量而是数组会触发广播或者直接报错别拿它当批量赋值工具用。2.2 np.empty 加 fill追求极致时的那条路col np.empty(5, dtypenp.float64) col.fill(np.nan)np.empty只分配内存不做初始化理论上比np.full少一次写操作速度上有微弱优势。但这里有两个必须知道的事实。第一np.empty里的内容是未定义的垃圾值可能是上一次某个数组释放后留下的残渣也可能是 0。小数组从堆上分配往往会读到脏数据大数组走 mmap 由内核分配很多时候读出来是 0但这纯属运气不能依赖。所以np.empty之后必须紧跟一次填充中间不能有任何读取操作。第二ndarray.fill(value)是原地操作返回 None。写成col np.empty(5).fill(np.nan)会得到 None然后你在下一行调col.shape就会收获一个 AttributeError。这个坑我见过至少三次都是赶时间的时候手滑。性能上np.full和np.empty fill的差距在百万级元素上大概只在毫秒量级除非你在一个循环里反复生成几十万次否则这个差异对你的程序没有任何实质影响。我的建议是默认用np.full只有在 profiling 明确指到这里是瓶颈时才换np.empty。2.3 np.repeat 和 np.tile把标量复制成一整列a np.repeat(np.nan, 5) b np.tile(np.nan, 5)这两个函数本来是做数组复制的拿标量当输入时效果就是把这个标量重复 N 次结果都是长度 5 的 float64 全 NaN 数组。np.repeat的语义是每个元素重复几次np.tile是整体平铺几次对一个标量来说两者结果一样但对数组输入就完全不同了。用它们生成占位列好处是语义上更贴近复制这个动作如果你后面本来就要对某个已有数组做 repeat顺手把种子换成 np.nan 也挺自然。坏处是可读性稍差而且np.repeat在处理多维输入时返回值形状可能跟你预期不一样容易在后面拼列的时候出问题。我的实际用法是只有在确实需要对一列数据做扩展复制的时候才用 repeat单纯造占位数组还是回到np.full。2.4 zeros 乘 NaN能跑通但属于巧合正确c np.zeros(5) * np.nan d np.ones(5) * np.nan e np.zeros(5) np.nan这三种写法都能得到全 NaN 数组因为 0 乘 NaN 等于 NaN1 乘 NaN 也等于 NaN0 加 NaN 还是 NaN。从数学上讲没毛病从工程上讲我不推荐。原因是它把生成全 NaN这个意图包装成了先造一个零数组再做一次算术运算读者需要在大脑里跑一遍运算规则才能确认结果。而且如果哪天你手滑把np.nan写成了np.infnp.zeros(5) * np.inf会给你一个[nan, nan, nan, nan, nan]——看起来一样但中间走了0 乘无穷等于 NaN的路径语义已经完全跑偏了。这类看起来对但其实靠巧合的代码在团队协作里是定时炸弹。还有一种变体是np.zeros(5) np.nan逻辑一样问题也一样。把它们当作了解即可别往生产代码里放。2.5 列表推导转 array最直观但最慢f np.array([np.nan] * 5) g np.array([np.nan for _ in range(5)])这是绝大多数人第一次写出来的版本因为它最符合直觉先造一个 Python 列表再交给 numpy 转成数组。对于几十、几百个元素这么做完全没问题代码也好懂。问题出在规模上。[np.nan] * 5这种列表乘法本身很快但np.array()要把列表里的每个 Python float 对象逐个拆箱、转成 C 层的 double这个过程是解释器层面的循环元素上千万的时候会明显拖后腿比np.full慢一到两个数量级。而且中间那个 Python 列表会实实在在占用内存一万个 float 对象的开销远大于一万个 double。真要给新人解释为什么不能这么写我会这么类比这就像你要一块白色的砖墙np.full是直接用模具压出来列表推导是先一块一块手捏出砖坯再垒起来最后看起来一样但工序完全不同。3. 往已有结构里塞一列 NaN 的几种实操姿势3.1 直接对二维数组整列赋值最省事但要先看 dtype如果你已经有一个二维的浮点矩阵想把它某一列整体置为 NaN直接做切片赋值就行arr np.arange(12, dtypenp.float64).reshape(4, 3) arr[:, 1] np.nan print(arr) # [[ 0. nan 2.] # [ 3. nan 5.] # [ 6. nan 8.] # [ 9. nan 11.]]arr[:, 1]选中的是第 1 列所有行右侧np.nan是一个标量numpy 会自动广播到整列一次写入。这个操作是原地修改不产生新内存。关键前提是arr.dtype必须是浮点。如果你的矩阵是dtypenp.int32这一行会直接抛ValueError: cannot convert float NaN to integer。有人会想那我先arr[:, 1] 0占位行不行行但这就不是 NaN 了。正确的做法是在建数组时就用 float或者提前astype(np.float64)。还有一个容易忽略的点如果你要新增一列而不是覆盖某一列裸 ndarray 是改不了形状的arr[:, 3] np.nan越界会报IndexError。ndarray 的大小是固定的要加列只能拼接生成新数组。3.2 column_stack 和 hstack拼出一条新列给已有矩阵追加一列最常用的是np.column_stackarr np.arange(12, dtypenp.float64).reshape(4, 3) new_col np.full((arr.shape[0], 1), np.nan) arr2 np.column_stack([arr, new_col]) print(arr2.shape) # (4, 4)这里new_col的形状必须是(4, 1)跟arr.shape[0]对齐。如果写成np.full(4, np.nan)形状是(4,)np.column_stack其实也能处理——它会把一维数组当作列来对待结果同样是(4, 4)。但这就埋了一个隐患换个场景比如你用的是np.concatenate一维数组的轴对不上就会直接报ValueError: all the input arrays must have same number of dimensions。np.hstack相比column_stack更宽松对一维输入的处理是直接首尾相接而不是当列看。所以拼列这件事column_stack的语义最稳hstack适合拼一维水平序列concatenate适合你明确知道axis是什么的情况。我给自己的规则是拼列就用 column_stack且新列一律显式写成(N, 1)。多敲几个字符换后面少调半小时。3.3 结构化数组给指定字段填 NaN结构化数组structured array在科学计算里用得不少它的每个字段相当于表格的一列dtype 在创建时就写死了dt np.dtype([(id, i4), (value, f8)]) records np.zeros(3, dtypedt) records[id] [1, 2, 3] records[value] np.nan print(records) # [(1, nan) (2, nan) (3, nan)]这里records[value] np.nan是一次广播赋值把整个 float64 字段填满。注意id字段是 i4你没法给它赋 NaN会报错。结构化的好处是列名清晰、内存紧凑坏处是操作起来没有 DataFrame 方便字段顺序也不能随便改。另一条路是掩码数组np.ma。np.ma.masked_all(5)会生成一个所有元素都被 mask 掉的数组打印出来全是--。它在语义上比 NaN 更狠NaN 表示值是缺失的mask 表示这个位置我压根不承认。如果你的算法需要参与计算的元素数量动态变化掩码数组有时比 NaN 更好用代价是 numpy 的很多函数对它支持不如对 NaN 完善。3.4 pandas 场景DataFrame 加一列全 NaN虽然标题问的是 numpy但实际工作中八成人最后是在 pandas 里干这事import pandas as pd import numpy as np df pd.DataFrame({a: [1, 2, 3], b: [4.0, 5.0, 6.0]}) df[new_col] np.nan print(df.dtypes) # a int64 # b float64 # new_col float64新增列赋np.nanpandas 会把这一列推断成 float64长度自动对齐到 DataFrame 的行数不用你操心 shape。这一点比裸 numpy 舒服很多。但要注意几个版本差异。较新的 pandas 里引入了可空类型Float64注意大写它允许整数列带缺失值df[x] pd.NA在某些推断规则下会得到Float64而不是float64。如果你的下游代码对 dtype 敏感赋值前先确定目标类型必要时用astype显式转换别依赖自动推断。4. 参数选择、dtype 与性能取舍4.1 shape 的三种写法与维度对齐np.full的第一个参数可以是一个整数也可以是一个元组差异很大写法结果 shape适用场景np.full(5, np.nan)(5,)一维占位序列、后续做向量运算np.full((5, 1), np.nan)(5, 1)作为矩阵的一列、column_stack 拼接np.full((1, 5), np.nan)(1, 5)作为矩阵的一行、广播加法np.full((5, 0), np.nan)(5, 0)空列矩阵个别场景下用来初始化最容易搞错的是第三行。有人想生成一列结果写成(1, 5)得到的是 1 行 5 列的横向量拼到列方向上一乘就出广播错误。记住口诀(N, 1)是竖着的列(1, N)是横着的行第一个维度永远先行后列。如果你的行数来自另一个数组别硬编码数字用arr.shape[0]取。这样后面数据量变了代码不用改。4.2 dtype 选择与内存估算浮点家族里NaN 能住的类型主要是三个dtype每元素字节能表示 NaN典型用途float162是显存吃紧的推理场景float324是大规模特征矩阵、深度学习输入float648是默认选择精度和兼容性最好complex64/1288/16是实部或虚部信号处理内存估算很简单字节数乘以元素个数。一亿个 float64 元素就是 800MB换成 float32 直接砍一半到 400MB。如果你的占位列后面会被真实数据完全覆盖而真实数据的精度只要求 float32那从一开始就定 float32 是明智的省下来的是实实在在的物理内存。但别盲目降精度。float16 只有 3 位十进制有效数字做累加会快速累积误差NaN 虽然能存但中间的计算结果可能已经不可信了。我的原则是存的时候可以省算的时候用 float64占位数组用 float32 建涉及统计的环节先astype(np.float64)再算。4.3 几种写法的性能对照我在一台普通开发机上做过粗略对比数组长度 1000 万每种方式重复 100 次取平均量级大致如下写法相对耗时说明np.full(n, np.nan)1.0基准语义清晰性能优秀np.empty(n)fill0.8 到 1.0差异很小看具体实现np.repeat(np.nan, n)1.2 到 1.5多一层函数调用开销np.zeros(n) * np.nan1.5 到 2.0多了分配和乘法两次操作np.array([np.nan]*n)5 到 15Python 对象构造是瓶颈具体数字会随机器和 numpy 版本浮动但排序关系是稳定的。这张表想说明的就一件事当规模上到千万级只有最后一种写法是真的会拖慢流程的。前面几种之间的差异除非你在热循环里反复调用否则根本感知不到。所以别过度纠结微优化先选语义最直白的那种。4.4 order 参数与大规模场景下的一个细节np.full有个order参数取值C或F决定内存在行方向还是列方向连续。默认是 C 序行优先。如果你的数据后面要频繁按列切片——比如大量arr[:, i]操作——用 F 序在某些场景下缓存命中率更好。不过说实话这个优化在纯 Python 层面的收益经常被解释器开销吃掉。真正需要关注 order 的是你要把数组交给 C 扩展或者跟其他库对接的时候内存布局不一致会触发隐式拷贝。另一个大数组相关的细节是惰性分配。操作系统给大块内存时往往只是标记这块归你了真正写数据时才分配物理页。所以np.full在刚创建完的那一刻内存占用可能还没涨上去等你开始往里写其他值RSS 才慢慢爬。如果你用监控工具观察进程内存别被这个现象吓到这不是泄漏。5. 常见问题与排查技巧实录5.1 赋值后类型变了或者直接抛 ValueError这是最高频的问题症状通常是arr[:, 0] np.nan报cannot convert float NaN to integer。原因就是数组本身是整数类型。排查顺序建议这样走先打印arr.dtype如果是int32/int64/uint8之类说明得先转。转换写法arr arr.astype(np.float64)注意这是返回新数组不是原地改所以必须重新赋值给变量。有人写arr.astype(np.float64)然后继续用arr结果还是原来那个 int 数组白忙一场。还有一种更隐蔽的情况数组里混了 Nonedtype 是 object。这种数组赋 np.nan 不会报错但后续np.isnan会抛TypeError。解决办法是先np.array(arr.tolist(), dtypenp.float64)重新构造把 object 洗成数值类型。补充一个危险操作np.array([np.nan]).astype(np.int64)。在某些平台和 numpy 版本上它会把 NaN 转换成一个极小的整数比如 -9223372036854775808并且可能只给一条 RuntimeWarning不中断程序。这种错误值混进数据后极难排查因为看起来是个正常数字。永远不要对含 NaN 的数组做 astype(int)先处理缺失值。5.2 判断 NaN 时那些反直觉的结果np.nan np.nan是 Falsenp.nan in [np.nan]反而可能是 True列表的 in 走的是身份比较优先这两件事经常让人抓狂。正确的做法统一如下col np.full(5, np.nan) print(col np.nan) # [False False False False False] print(np.isnan(col)) # [ True True True True True] print(np.isnan(col).all()) # True print(np.isnan(col).any()) # True如果你要判断这一列是不是全 NaN用.all()判断有没有 NaN用.any()统计 NaN 个数用np.isnan(col).sum()因为布尔值参与求和时 True 当 1。这里有个小陷阱np.isnan只接受数值类型。对 object 数组、字符串数组调用会报TypeError: ufunc isnan not supported for the input types。遇到这种情况先查 dtype别死磕。5.3 全 NaN 列做统计结果全是 NaN这是 NaN 的传播特性导致的任何包含 NaN 的算术运算结果都是 NaN。所以np.mean(col)、np.sum(col)、np.max(col)对一个含 NaN 的数组都会返回 NaN而不是跳过缺失值。numpy 提供了一组nan前缀的聚合函数来跳过 NaNnp.nanmean、np.nansum、np.nanmax、np.nanstd、np.nanmedian。用法跟普通版本一模一样。但有个边界情况要小心如果整个数组都是 NaNnp.nanmean会返回 nan 并抛一条RuntimeWarning: Mean of empty slice因为它实际上是在对零个有效元素求均值。这个警告在批量处理时会把日志刷满可以在确定能接受 nan 结果的地方用warnings.catch_warnings抑制或者在调用前先if np.isnan(col).all(): return None提前拦一道。5.4 None、np.nan、pd.NA、NaT 到底该用哪个这四个东西经常被混用各自定位其实很清晰对象所属典型场景参与运算的行为np.nannumpy / float数值数组缺失值传播结果为 NaNNonePython 内置函数默认返回、对象缺省在 object 数组里保持原样pd.NApandas 可空类型Int64、boolean等可空 dtype传播结果为pd.NANaTpandas / numpy时间类型缺失值传播结果为NaT对数值数组用 np.nan。对时间列用 NaT。对可空整数列新版本 pandas 的Int64用 pd.NA。混用的代价是类型推断混乱比如np.array([1, None])变成 object 数组后续所有向量化操作全部退化成 Python 循环性能断崖式下跌。我自己的经验是入口处统一成 np.nan出口处按目标系统的要求再转换。中间环节别让两三种缺失值表示同时存在那是自找麻烦。5.5 问题速查表现象最可能原因处理方式ValueError: cannot convert float NaN to integer目标数组是整数类型先astype(np.float64)arr[, 3] np.nan报IndexErrorndarray 无法原地扩列改用column_stacknp.isnan(arr)报 TypeErrordtype 是 object 或字符串检查 dtype重新构造为 float拼接后 shape 从(N, 5)变(N, N)一维数组触发了广播新列显式写成(N, 1)统计结果全是 NaNNaN 的传播特性改用np.nanmean等函数col np.empty(n).fill(np.nan)得到 Nonefill是原地操作返回 None分两行写astype(int) 后出现极大负数NaN 强转整数的未定义行为先处理 NaN 再转换pandas 新列 dtype 是 object数据源里混了 None 或字符串显式指定 dtype 或先清洗5.6 几条踩过坑之后总结的实操心得第一条建数组的时候就把 dtype 定死。np.full(n, np.nan, dtypenp.float32)比先默认 float64 再 astype 到 float32少一次全量拷贝数据量大的时候差别很明显。第二条形状用变量算不要写魔法数字。np.full((arr.shape[0], 1), np.nan)这样的代码换一批数据、换一个输入源都不用改。硬编码成np.full((1000, 1), np.nan)的代码三个月后没人知道这个 1000 是从哪来的。第三条赋值完立刻断言一下。在关键步骤后面加一行assert np.isnan(new_col).all()成本几乎为零但能在数据流跑偏的第一现场抓住问题。如果等到下游模型报错才回头查定位成本会高一个数量级。第四条别在循环里反复创建大数组。如果每一轮迭代都要一块全 NaN 的临时缓冲区把它挪到循环外面创建一次循环内用buf.fill(np.nan)重置能省下大量分配和回收开销。我在实际使用中的体会是生成全 NaN 数组这件事本身没什么技术含量真正花时间的是生成之后它是什么类型、什么形状、能不能被下游正确消费。把这几个问题在写第一行代码之前想清楚比事后 debug 划算得多。下次再遇到类似的一行代码解决的需求不妨先花三十秒确认 dtype 和 shape这三十秒通常能省掉后面三十分钟。
阅读完成 · 觉得有帮助?
咨询建站