做数据挖掘这几年,我最大的一条体会是:很多项目最后跑不动或者结果离谱,问题往往不是出在模型上,而是出在数据处理那一步。而数据处理里最基础、也最容易被低估的工具,就是NumPy数组运算。不少同学一上来先背模型API,结果清洗数据时连一个数组的拼接、筛选、规范化都做得磕磕绊绊。这篇文章我就用数据挖掘实战的视角,把NumPy数组运算从创建、形状、轴、广播、筛选到标准化完整过一遍,所有代码都可以直接跑,后半部分大篇幅留给易错点避坑——那些文档里不会写、但实际工作中天天遇到的坑,我尽量一次说透。
1. 数组的基本盘:从创建到形状控制
1.1 list与ndarray:看着像,里子完全不一样
先解决一个最基础的困惑:Python自带的list明明也能存数据,为什么数据挖掘里偏偏要用NumPy的ndarray?
表面上两者都能arr[0]、len(arr),但底层机制差别很大。Python的list是“对象指针数组”,每个元素实际上是指向一个Python对象的指针,这些对象分散在内存各处,类型可以五花八门。而ndarray是同一类型元素的连续内存块,所有数据紧挨着存放在一起。可以用个不严谨的比喻:list像超市储物柜,每个格子大小不一、各放各的东西;ndarray像一个标准仓库货架,所有格子统一规格,搬运时可以直接整排整排地挪。
这个区别带来两个直接结果:第一,ndarray在内存访问上比list高效得多;第二,NumPy可以把底层循环交给C语言去做,还支持SIMD向量化指令,一次处理一整块数据。所以数据挖掘里动辄几十万、上百万条样本,用向量化写法跟用for循环逐条算,性能差距往往是一个数量级以上。
我建议你可以自己做个简单对比:生成一个一百万元素的数组,分别用Python求和循环和np.sum求和,体会一下什么叫“一个在天上一个在地下”。代码如下:
import numpy as np import time a_list = list(range(1_000_000)) a_arr = np.arange(1_000_000) # Python原生循环求和 start = time.time() s = 0 for x in a_list: s += x print("Python循环:", time.time() - start, s) # NumPy求和 start = time.time() s = a_arr.sum() print("NumPy求和:", time.time() - start, s)在我自己的机器上,循环版本大概要几十毫秒,np.sum通常一毫秒内完成。数据量越大,差距越明显。
1.2 创建数组的常用姿势
数组创建是每个数据挖掘项目的起点。我常用的创建方式就下面这几种,按使用频率排序:
import numpy as np # 1. 从Python列表创建 arr = np.array([[1, 2, 3], [4, 5, 6]]) # 2. 等差数列:arange用法类似range,但支持浮点步长 arr_range = np.arange(0, 1, 0.2) # 0.0 0.2 0.4 0.6 0.8 # 3. 指定起点、终点、个数:linspace最常用于生成坐标轴 arr_lin = np.linspace(0, 1, 5) # 0. 0.25 0.5 0.75 1. # 4. 全零、全一、对角矩阵 zeros = np.zeros((3, 2)) # 3行2列全0 ones = np.ones((2, 3)) # 2行3列全1 eye = np.eye(3) # 3阶单位矩阵 # 5. 随机数组:数据挖掘里生成模拟数据的标配 rng = np.random.default_rng(42) # 固定随机种子,结果可复现 arr_rand = rng.normal(0, 1, size=(5, 3)) # 标准正态分布5行3列 arr_choice = rng.integers(0, 10, size=(4, 4)) # 4x4整数随机数这里最关键的习惯是:固定随机种子。做数据挖掘的人通常会反复对比不同预处理方式的效果,如果不固定种子,每次生成的随机数据都不一样,对比结果就失去意义。np.random.default_rng(42)是官方推荐的新写法,比老式np.random.seed()更干净,生成的随机数质量也更好。
还要养成检查dtype的习惯。数组是“同构”的,如果创建时混入字符串,整个数组都会被转成字符串类型,后面做数值运算直接报错。所以我处理从外部读入的脏数据时,第一步永远是print(arr.dtype, arr.shape)。
1.3 形状重塑:reshape、ravel与转置
数据挖掘里,模型输入对数组形状有严格要求,比如大部分模型要求“样本行、特征列”的二维结构。一维的预测结果要变成二维,最常用的就是reshape。
arr_1d = np.arange(12) # shape=(12,) arr_2d = arr_1d.reshape(3, 4) # 变成3行4列 arr_col = arr_1d.reshape(-1, 1) # 变成12行1列,-1表示自动推算 arr_row = arr_1d.reshape(1, -1) # 变成1行12列-1这个写法很实用,它告诉NumPy“这个维度我自己算”。比如只关心变成列向量,就写reshape(-1, 1),NumPy会自动把剩下的元素数填进去。
展平用的是ravel(),它把任意维数组拉成一维。转置是.T,把行列互换。这里有一个坑我先预告:reshape和T默认返回的是视图(view),不是副本,也就是新旧数组共享同一块内存,改一个另一个也会变。关于视图和副本的详细坑,后面避坑章节专门讲。
2. 轴axis:数据挖掘里最容易掰扯不清的概念
2.1 axis的两个方向怎么记
轴(axis)是NumPy里最劝退的概念,没有之一。很多人卡住的点是:axis=0到底是横着算还是竖着算?
正确理解方式是这样的:axis的编号对应shape中每个维度的位置。一个形状为(3, 4)的二维数组,第0轴对应数字3(行方向),第1轴对应数字4(列方向)。arr.sum(axis=0)的意思是“沿着第0轴方向移动,把这个轴压缩掉”,也就是对每一列做聚合,得到4个数值;axis=1则是“沿着第1轴移动”,对每一行做聚合,得到3个数值。
我给个实用记忆法:先想清楚结果形状,再判断axis。(3,4)数组用axis=0聚合后,剩下形状(4,),就是“每一行被压成一列里的一个数字”,所以结果是每列的和(或均值);用axis=1聚合后,剩下(3,),结果是每行的和(或均值)。
看代码更直观:
arr = np.array([[1, 2, 3], [4, 5, 6]]) print(arr.shape) # (2, 3) print(arr.sum(axis=0)) # [5 7 9] 每列之和 print(arr.sum(axis=1)) # [ 6 15] 每行之和数据挖掘里,行通常是样本、列通常是特征,所以axis=0的聚合结果往往是“每个特征在所有样本上的汇总值”,比如均值向量、标准差向量。这一点在后面的标准化环节会反复用到。
2.2 聚合运算里axis的真实行为
sum、mean、std、max、min这些聚合函数,axis的规则完全一样,但有几个容易翻车的细节。
第一个是keepdims。如果不加keepdims=True,arr.mean(axis=0)的结果形状是(3,),一维数组;加上keepdims=True后结果形状是(1,3),保持了维度结构。这两者在做广播运算时区别很大,后面讲广播的时候会看到。
第二个是argmax和argmin。默认不加axis时,它会先把数组展平成一维,再找全局最大值的索引。比如一个(2,3)数组,argmax()返回的可能是一个0到5之间的数字,而不是“第几行第几列”。如果想让结果可读,必须指定axis。
arr = np.array([[10, 20, 30], [40, 50, 60]]) print(arr.argmax()) # 5,全局最大值的扁平索引 print(arr.argmax(axis=0)) # [1 1 1],每列最大值的行号 print(arr.argmax(axis=1)) # [2 2],每行最大值的列号我见过不止一次,有人想找“每行最大值的列位置”,结果忘写axis,拿到一个全局索引,排查了半天。这种问题很好定位:打印结果长度和shape,如果长度不对,十有八九是axis没写对。
2.3 三维数组与更多轴的判断方法
二维数组的axis已经让不少人头疼,三维数组怎么办?
方法还是同一个:看shape。形状(2,3,4)的数组,第0轴长度2,第1轴长度3,第2轴长度4。执行sum(axis=1),剩下的形状是(2,4),意思是“把中间那个维度压缩掉,保留外层和内层”。
简单口诀:axis是你要消掉的维度。你问自己,我要把哪一维消掉,把剩下的形状写出来,你就知道结果长什么样了。
数据挖掘里三维数组很常见,比如多通道图像数据(batch_size, height, width),或者时间序列数据(batch_size, time_steps, features)。处理这类数据时,重点想清楚你要聚合的是哪个维度:是要在时间步上聚合,还是在特征上聚合?想清楚了再写axis。
3. 广播机制:小数组和大数组怎么一起算
3.1 广播到底是怎么发生的
广播(broadcasting)是NumPy最强大的特性之一,也是新手最容易踩坑的地方。简单说,就是形状不完全相同的数组在运算时,NumPy会自动把小的那个“扩展”成大的形状,然后再逐元素计算。
广播规则有三条,按顺序理解:
- 从最右边的维度开始往左比较。
- 如果两个维度相等,继续比较下一个。
- 如果某个维度上有一个是1,另一个是任意数,那么那个为1的维度会被扩展成另一个数;如果两个都不为1且不相等,就报错。
比如形状(3,1)和(1,4)两个数组相加,右对齐后:3对1(可以广播),1对4(可以广播),最终结果形状(3,4)。这就好比一张竖条海报和一张横条海报互相叠印,最后得到一整张巨幅画面。
无法广播的例子也常见:(3,4)和(5,)相加,右对齐后4对5,既不等也不为1,直接报错operands could not be broadcast together with shapes (3,4) and (5,)。
3.2 数据挖掘里的典型广播场景
广播在数据挖掘里几乎无处不在。最典型的三个场景:
场景一:数组加标量。arr + 1就是一个广播,标量被扩展成和arr一样形状。做偏移量调整时很常用。
**场景二:按列中心化。**这一步是特征预处理的核心操作。先算每列均值,得到一维数组mean_arr,然后让整个矩阵减去这个均值向量:
arr = rng.normal(0, 1, size=(100, 5)) # 100个样本,5个特征 mean_arr = arr.mean(axis=0) # 形状(5,) arr_centered = arr - mean_arr # 广播:每个样本对应特征减去均值这里(100,5)和(5,)能广播,因为右对齐后末尾维度相等。这样每个样本的第几个特征,减去的都是该特征在所有样本上的均值,完全符合中心化的定义。
**场景三:按行归一化。**比如要对每个样本的所有特征求和后做归一化,必须用keepdims=True保住“行”这个维度:
row_sum = arr.sum(axis=1, keepdims=True) # 形状(100,1),不写成(100,) arr_norm = arr / row_sum # 每行数值除以该行总和如果漏掉keepdims=True,row_sum形状是(100,),跟(100,5)做除法时右对齐5对100,直接报错。这是我在实际项目里反复见到的错误,解决方式就是一句话:打印参与运算的两个形状,一切立马清楚。
4. 向量化与通用函数:把for循环扫地出门
4.1 循环慢在哪,向量化快在哪
数据挖掘里,代码慢的最大元凶往往不是模型训练,而是数据处理阶段写了大量for循环。我之前带过一个模拟项目,某数据负责人用Python循环给两百万条样本逐条打标签,跑了快一分钟,改成向量化写法之后,整个环节压到几毫秒。
for循环慢在两点:一是Python解释器一条一条地执行字节码,每条指令都有固定开销;二是每次循环还伴随动态类型检查。而NumPy的ufunc(通用函数)直接把一个运算作用到整个数组上,底层是编译好的C循环,还能利用CPU的SIMD指令一次处理多个数据。
不管是np.add、np.exp还是arr + 1,本质上都是ufunc,它们对数组的每个元素执行同一个运算,效率远高于Python级循环。
4.2 常用ufunc与聚合统计
日常数据处理里,我常用的ufunc就这些:
arr = np.array([1, 4, 9, 16]) np.sqrt(arr) # 开方 [1. 2. 3. 4.] np.exp(arr) # 指数 np.log(arr) # 自然对数,负数和0会变nan或-inf np.abs(arr) # 绝对值 np.power(arr, 2) # 幂运算 np.maximum(arr, 5) # 逐元素取max,常用于下限截断 np.minimum(arr, 5) # 逐元素取min,常用于上限截断 np.clip(arr, 0, 10) # 区间截断,等价于上面的组合聚合统计同样重要,sum、mean、std、var、min、max、argmin、argmax是最常用的。数据挖掘里有一类特殊问题:真实数据几乎不可能是一尘不染的,经常混着缺失值nan。带着nan做sum会直接得到nan,一个缺失值毁掉整列统计。
这时候就要用nan族函数:
data = np.array([1.0, np.nan, 3.0, 4.0]) print(np.nanmean(data)) # 2.666...,忽略nan后求均值 print(np.nansum(data)) # 8.0,忽略nan后求和我记得自己第一次处理真实数据时,就因为忽略了这个细节,整个特征矩阵的均值全是nan,模型直接摆烂。从那以后,我养成了先np.isnan(data).sum()看一眼缺失值数量的习惯。
5. 布尔索引与条件筛选:数据挖掘里的“WHERE”
5.1 布尔掩码筛选样本
数据挖掘里最常做的事就是“按条件筛样本”。NumPy里对应的工具是布尔索引(布尔掩码)。先通过比较运算得到一个布尔数组,再把它当作索引传给原数组:
ages = np.array([15, 28, 35, 60, 72]) mask = ages >= 18 # [False True True True True] adults = ages[mask] # [28 35 60 72]比一维数组更常用的是对二维数组按某列条件筛选行:
data = rng.normal(0, 1, size=(10, 3)) mask = data[:, 0] > 0 # 第一列大于0的样本 filtered = data[mask] # 只保留满足条件的行组合条件时用&(与)、|(或)、~(非),并且每个条件外面必须加括号:
# 筛选第一列在0到1之间的样本 mask = (data[:, 0] > 0) & (data[:, 0] < 1) result = data[mask]这里有个新手高频报错:用Python的and或or去连接数组条件,会报The truth value of an array with more than one element is ambiguous。因为and会尝试把整个数组转成布尔值,而数组有多个元素,没法判断真假。记住:数组之间只能用&和|,别用and和or。
5.2 np.where实现三目逻辑
条件筛选还有一种常见需求:根据条件给数据打标签或替换数值,相当于Excel里的IF。这时候用np.where:
scores = np.array([45, 78, 92, 60, 34]) labels = np.where(scores >= 60, "pass", "fail") # ['fail' 'pass' 'pass' 'pass' 'fail']更实用的是嵌套np.where做多区间分桶。比如把分数分成三档:
levels = np.where(scores >= 90, "A", np.where(scores >= 70, "B", "C"))还有个容易忽略的用法:np.where(condition)单独调用时,会返回满足条件位置的索引元组。想定位异常值在第几行第几列,用它最快。
5.3 花式索引取行取列
花式索引(fancy indexing)是指用整数数组或列表来索引。它和切片的最大区别是:花式索引会复制数据,不共享内存。
data = np.arange(20).reshape(5, 4) # 取第0、2、4行 rows = data[[0, 2, 4]] # 取第1行和第3行的第2、3列 block = data[[1, 3]][:, [2, 3]]花式索引在数据挖掘里常用于按照某个索引顺序重排样本,比如随机打乱数据时先生成一个打乱的索引序列,再用花式索引按这个顺序取数据。
6. 实战:从模拟数据出发,完成一次特征标准化
6.1 生成模拟特征矩阵
下面把前面这些知识点拼起来,走一遍数据挖掘里最常见的预处理流程。假设我们有一张500行4列的特征表,代表500个样本、4个特征,部分特征量纲差异很大:
import numpy as np rng = np.random.default_rng(42) # 特征1、2用正态分布,特征3用均匀分布放大,特征4用指数分布 X = np.column_stack([ rng.normal(50, 10, 500), # 身高量纲 rng.normal(70, 15, 500), # 体重量纲 rng.uniform(0, 1, 500), # 比例量纲 rng.exponential(5, 500) # 偏态分布 ]) print(X.shape) # (500, 4) print(X[:5]) # 前5行注意我用了np.column_stack把几个一维数组按列拼成二维矩阵,这是构造特征矩阵的常用方式,等价于np.stack(..., axis=1)。
6.2 中心化与标准化
特征量纲不一样,直接送进距离计算、梯度下降这类模型会出问题,大的特征会把小的特征“淹没”。标准做法是Z-score标准化:每个特征减去均值,再除以标准差。
mean_arr = X.mean(axis=0) # 每个特征的均值,形状(4,) std_arr = X.std(axis=0) # 每个特征的标准差,形状(4,) X_std = (X - mean_arr) / std_arr # 广播:一行代码完成全部标准化 print(np.round(X_std.mean(axis=0), 6)) # 接近[0 0 0 0] print(np.round(X_std.std(axis=0), 6)) # 接近[1 1 1 1]验证结果时,均值理论上为0,标准差为1。浮点误差存在,所以要用np.round看近似值。
这里有一个容易忽略的知识点:X.std(axis=0)默认计算的是总体标准差(ddof=0),分母是样本数n。而Pandas里的.std()默认是样本标准差(ddof=1),分母是n-1。两个库默认值不一样,如果混用会导致标准化结果有细微差异。做数据处理时,最好统一用ddof=0或ddof=1,别换来换去。
标准化的意义在于让每个特征都处在同一尺度上,但它的前提是“这个特征在所有样本上的分布相对稳定”。如果特征里有异常值,均值会被带偏,所以有时候我们会在标准化前先做异常值截断。
6.3 缺失值填充与异常值截断
真实数据里缺失值躲不掉。处理方式之一是用该列均值填充。假设我们故意把第2列的一部分置成nan:
X_with_nan = X.copy() nan_idx = rng.choice(500, 20, replace=False) X_with_nan[nan_idx, 1] = np.nan # 对第1列(下标1)做均值填充 col = X_with_nan[:, 1] col_mean = np.nanmean(col) # 求均值时忽略nan col_filled = np.where(np.isnan(col), col_mean, col) X_with_nan[:, 1] = col_filled核心技巧是np.nanmean配对np.isnan用:先用isnan找到缺失位置,再用where把缺失值替换成均值。如果是多列都要处理,写个循环逐列处理即可。
异常值截断也常用np.clip。比如把每个特征限制在10%到90%分位数之内,可以先用np.percentile取上下限,再clip:
lo = np.percentile(X[:, 0], 10) hi = np.percentile(X[:, 0], 90) X[:, 0] = np.clip(X[:, 0], lo, hi)这一套“生成模拟数据—标准化—缺失值填充—异常值截断”基本覆盖了数据挖掘前期最常碰到的数据整理动作。
7. 易错点避坑实录:直接抄作业的排查清单
7.1 视图与副本:切片不是复制
这是新手最容易踩、也最容易怀疑人生的坑。NumPy里,普通索引切片(比如arr[1:3])、reshape、T返回的很多时候是视图,不是副本。视图和原数组共享同一块内存,修改其中一个,另一个跟着变。
a = np.arange(10) b = a[2:8] # b是a的视图 b[0] = 999 print(a[2]) # 999,a也被改了解决办法很直接:需要独立数据时就加.copy():
b = a[2:8].copy()排查方法也简单:检查b.base是否为None。如果base不是None,说明b是视图,底层数据还被a管着。
7.2 原地修改方法返回None
NumPy里有一部分方法名和Python内置方法类似,但行为是“原地修改”,且返回None。最典型的是arr.sort()。很多人写b = arr.sort(),以为b是排序后的数组,结果b是None,后面所有代码全崩。
记住一个区分法:方法名带下划线动作的,大概率是原地操作。想要“返回新数组”就用函数式写法:
arr.sort() # 原地排序,返回None np.sort(arr) # 返回新排序数组 arr.resize(2, 5) # 原地改形状,返回None np.resize(arr, (2, 5)) # 返回新数组7.3 整数数组的除法陷阱
整数数组做除法时,结果会自动变成浮点数,这符合直觉。但很多人在Python里习惯了//地板除,到了NumPy里还用//,结果得到一堆截断后的整数,跟期望完全不符。
arr_int = np.array([1, 2, 3]) print(arr_int / 2) # [0.5 1. 1.5] float print(arr_int // 2) # [0 1 1] 地板除另一个相关坑是:如果数组是dtype=bool,减法和除法会行为诡异。一律建议:数值参与复杂运算前,先用astype(float)统一类型。
7.4 axis和argmax的隐藏行为
前面提过argmax默认是全局展平索引,这里再补一个实际案例。做数据挖掘时经常要找到“每个时间步上的最大特征”,如果你写:
arr = np.array([[1, 5, 3], [7, 2, 4]]) print(arr.argmax()) # 3,全局最大7的扁平位置想拿“每列最大值的行号”,得写axis=0;想拿“每行最大值的列号”,写axis=1。先看结果长度是否符合预期,这是快速定位这类错误的方法。
7.5 广播形状不匹配的两种修法
广播报错是高频问题。上次在某公司做数据处理协作时,同组的人跑一个(500,3) - (500,)直接就报错,报错信息写得很清楚:operands could not be broadcast together with shapes (500,3) (500,)。
修法通常两个:
- 改形状:把
(500,)改成(500,1),用reshape(-1, 1)。 - 改计算:在聚合时直接加
keepdims=True,让参与运算的数组天然保持可广播形状。
row_mean = arr.mean(axis=1, keepdims=True) # (500,1) arr_centered = arr - row_mean # 可正常广播我个人的调试习惯是,遇到广播报错不猜,先把两个数组的shape分别打出来,从右往左逐位比对,立马就能定位问题。
8. 最后说几句实操习惯
学了这么多,最终还是要落到一个习惯上:不要试图背NumPy的函数,用一个记一个,用多了自然就熟了。我自己的做法是,每次遇到一个不太好记的操作,就随手记在一个备注里,比如“按列标准化三步:mean(axis=0)、std(axis=0)、(X-mean)/std”,下次直接用,省得反复查文档。
还有一点是,遇到报错不要慌,先把shape打印出来。我敢说NumPy的问题十个里有八个靠这一招能解决。再不行就把参与运算的数组dtype也看一眼,往往就是类型或形状不匹配的事。
最后一个小技巧:把常用的数据清洗套路封装成几个小函数,比如标准化、缺失值填充、异常值截断,扔在自己的工具文件里,项目里直接导入调用。这样既能保证每次处理逻辑一致,也方便复现和排查。这个内容后续如果你感兴趣,可以再延伸到Pandas和可视化那一层,但NumPy这个地基先打牢,后面就顺了。