
这篇是NumPy笔记的扩展篇。上一篇把ndarray创建、基础索引、数学运算这些底子过了一遍这篇我打算往深里聊视图和副本到底怎么区分、广播机制背后是什么逻辑、向量化为什么能把Python代码提速几个数量级再带几个科学计算里高频的场景线性代数、随机数、统计聚合最后用pandas、matplotlib、scikit-learn和NumPy的实际配合收尾。笔记扩展部分会追加两个有点意思的实战案例01背包的向量化写法和层次聚类里的距离矩阵计算。适合已经能用NumPy做简单数组操作、但想在工程和算法里真正用好它的人。1. 先从“视图和副本”说起你的数组到底被改没改1.1 切片是引用不是复制很多从Python列表转过来的人第一次在NumPy上栽跟头基本都是这个列表切片产生的是新列表NumPy切片返回的却是原数组的一个“视图”。什么叫视图就是新数组和原数组共享同一块底层内存。你改了视图里的元素原数组也跟着变。反过来原数组变了视图里的数据也会变。import numpy as np a np.arange(12).reshape(3, 4) print(a) # [[ 0 1 2 3] # [ 4 5 6 7] # [ 8 9 10 11]] b a[0] # 取第一行b是a的视图 b[0] 99 # 改b第一个元素 print(a[0]) # [99 1 2 3]这个设计是刻意的。数组一般占用连续内存切片操作本质上只是“改了起始位置、步长和形状”这几个元数据根本没有复制数据所以速度快、内存省。但代价就是你意识不到自己动的是“原件”。我自己的经验是在写数据预处理代码时如果不能100%确定一个切片是否需要独立数据直接.copy()把它变成副本。多花一点内存好过半夜调试时发现源数据被改了却找不到凶手。1.2 花式索引和布尔索引这次是副本视图和副本的分界线很多人以为是“切片就是视图”其实不够准确。规则的完整说法是基本切片用:和整数组合返回视图花式索引传数组、列表返回副本布尔索引返回副本整数数组索引返回副本举个例子a np.arange(12).reshape(3, 4) idx [0, 2] c a[idx] # 花式索引返回副本 c[0, 0] 100 print(a[0, 0]) # 还是0说明c没影响a为什么花式索引返回副本因为你想取的这些行不一定在内存里连续NumPy没法用简单的“偏移量步长”来表达这个结果只能重新复制出一块连续数据。实际工作中我一般这样判断用固定的行号取数据如果是“连续区间或按固定步长取”比如a[1:100:2]放心用视图如果是“按一个不规则的索引列表取”比如a[[3, 7, 9]]它就是副本。1.3 reshape、ravel、flatten三个“拉平”各有脾气把一个多维数组变成一维是数据预处理里的高频操作。NumPy里有三个常见工具ravel、flatten、reshape(-1)。方法返回视图还是副本场景建议a.ravel()尽量返回视图数据不连续时才复制只读、想省内存时首选a.flatten()总是返回副本需要独立数据避免副作用a.reshape(-1)返回视图前提是能表达想改变形状顺手改数据时用还有一个容易忽略点reshape返回的是视图不是副本。所以如果你写b a.reshape(-1)然后改了ba也会变。想要真正独立的数据还是得.copy()。reshape(-1)里的-1是“自动推断”的意思你只指定其它维NumPy自己算这一维的大小。比如形状是(3, 4)的数组reshape(2, -1)会自动变成(2, 6)。这个语法在深度学习的数据预处理里很常用但前提是元素总数必须对得上否则直接报错。2. 广播broadcastingNumPy最被低估的机制2.1 广播三条规则一次讲清楚广播允许两个形状不完全一样的数组做运算。很多人一看到“广播”两个字就觉得难其实就是几条规则从最后一个维度开始往前对齐两个维度要么相等要么其中一个是1要么其中一个数组在这个维度上没有可以认为是1满足条件就自动扩展成较大的形状不满足就报ValueError举个最经典的例子一个形状(3, 1)的数组加上一个形状(1, 4)的数组结果会是(3, 4)。a np.array([[1], [2], [3]]) # shape: (3, 1) b np.array([10, 20, 30, 40]) # shape: (4,)看作(1, 4) c a b print(c.shape) # (3, 4) print(c) # [[11 21 31 41] # [12 22 32 42] # [13 23 33 43]]a在第二个维度上扩展成4列b在第一个维度上扩展成3行两边都“补”成了(3, 4)再逐元素相加。如果两个形状完全不兼容呢比如形状(3,)和(4,)相加从后往前第0个维度是3和4不相等也没有一个是1直接报错。这种报错信息通常长这样operands could not be broadcast together with shapes (3,) (4,)。看到这个第一反应就是去检查两边shape。2.2 一维数组的“形状陷阱”广播最容易踩的坑是一维数组的形状到底算行向量还是列向量。a np.array([1, 2, 3]) print(a.shape) # (3,)注意不是(1, 3)也不是(3, 1)。它是一个纯一维数组。当你拿它和二维数组做广播时它在对齐规则中只当作“长度为3的最后一维”不会自动变成行向量或列向量。想让它“变成”一列用a[:, None]或a.reshape(-1, 1)结果是(3, 1)。 想让它“变成”一行用a[None, :]结果是(1, 3)。a np.array([1, 2, 3]) col a[:, None] row a[None, :] print(col.shape) # (3, 1) print(row.shape) # (1, 3)None在这里就是np.newaxis的简写作用是“加一个长度为1的轴”。我在实际项目里给特征矩阵做标准化、给二维网格数据升维时天天用到这个操作。记住这个技巧你的广播理解就算过关一半了。2.3 广播实战数据标准化和网格计算广播最大的价值是省内存、免循环。比如数据标准化z-score公式是(x - mean) / stddata np.random.default_rng(42).normal(size(1000, 5)) # 1000行5个特征 mean data.mean(axis0) # shape: (5,) std data.std(axis0) # shape: (5,) scaled (data - mean) / std # (1000, 5) 和 (5,) 广播这里mean是长度5的一维数组它和(1000, 5)从后往前对齐5对5相等再往前mean没有第0维当作1和1000去对齐于是mean被“扩展”成1000行。整个过程中 NumPy 并没有真的复制出1000份mean它只是按规则在C层做循环内存开销几乎为零。另一个高频场景是生成网格坐标x np.linspace(0, 1, 5) y np.linspace(0, 1, 4) X, Y np.meshgrid(x, y) # X, Y 都是 (4, 5) Z np.sin(X * np.pi) np.cos(Y * np.pi)np.meshgrid内部本质就是用的广播把x扩展成多行把y扩展成多列你再对X、Y做任意元素级运算就得到了整个网格上的函数值。这个套路在等高线图、热力图、有限差分计算里特别常见。提示用a b这种原地操作时也要满足广播规则。如果a的形状是(3, 4)b的形状是(4,)那a b没问题但如果a是(3,)、b是(4,)直接原地加就会报错因为结果形状(3, 4)放不进原来只有3个元素的a里。3. 向量化把Python循环换成C循环3.1 为什么说“能不用for就不用for”Python的循环为什么慢因为每一次迭代都要做类型检查、创建Python对象、执行字节码解释器开销非常大。而NumPy的数组运算是把整个操作丢给预编译的C/Fortran循环一次遍历完成中间没有Python对象参与。我自己做过一个粗糙的性能对比import numpy as np import time n 1_000_000 x np.random.rand(n) # 方式一Python for循环 start time.perf_counter() s 0.0 for v in x: s v * 2.0 1.0 t_loop time.perf_counter() - start # 方式二NumPy向量化 start time.perf_counter() y x * 2.0 1.0 t_vec time.perf_counter() - start print(fPython loop: {t_loop:.4f} s) print(fNumPy vectorized: {t_vec:.4f} s)在我机器上向量化版本通常比Python循环快几十到上百倍数据量越大差距越明显。所以写NumPy代码的第一原则就是把循环“藏”到数组运算里去。3.2 ufunc真正干活的“通用函数”add、multiply、power、exp、log、sin这些逐元素运算函数在NumPy里统称ufuncuniversal function。除了常规的逐个元素计算ufunc还带几个进阶方法很多人没用过reduce沿轴做归约操作。np.add.reduce(a)等于np.sum(a)np.multiply.reduce(a)是全部元素相乘。accumulate逐步累积。np.add.accumulate(a)返回前缀和数组。outer外积。np.add.outer(a, b)可以得到一个矩阵每个位置是a[i] b[j]。reduceat分段归约适合按区间做聚合。举个outer的应用场景。假设你要算所有样本两两之间的距离直接用np.subtract.outer配合平方求和比纯写两层循环清晰得多。本质上是把“求和”当成一个ufunc的归约操作这是很多人略过的一个宝藏功能。a np.array([1, 2, 3]) b np.array([10, 20]) print(np.add.outer(a, b)) # [[11 21] # [12 22] # [13 23]]3.3 搞懂 axis聚合函数才算入门sum、mean、max、std这些聚合函数最让人困惑的就是axis参数。我找到一个比较好记的理解方式axis0表示“沿着这个轴移动把这一个方向上的元素压掉剩下的维度组成结果”。对形状是(3, 4)的二维数组a.sum(axis0)是把每一列的元素加起来得到形状(4,)a.sum(axis1)是把每一行的元素加起来得到形状(3,)如果你不确定结果形状有一个土办法结果形状等于原形状去掉axis指定的那个维度。比如(3, 4)axis0去掉第0维剩下(4,)axis1去掉第1维剩下(3,)。更高维时同理。形状(2, 3, 4)沿axis1求和结果就是(2, 4)。写代码前先在草稿纸上标一下维度能少走好多弯路。3.4 数组构造技巧别再傻傻 for 循环生成了有些数组构造如果用Python循环写又慢又啰嗦。NumPy有几个函数专门干这事np.fromfunction根据下标函数直接生成数组值np.meshgrid生成网格坐标上面已经用过np.repeat和np.tile重复数组但语义不同。repeat是把每个元素重复N次tile是把整个数组当成一个砖块平铺。np.r_和np.c_按行或按列拼接在交互式分析里很好用# 生成一个5x5的矩阵第(i,j)个元素等于i*j m np.fromfunction(lambda i, j: i * j, (5, 5), dtypeint) print(m) # [[ 0 0 0 0 0] # [ 0 1 2 3 4] # [ 0 2 4 6 8] # [ 0 3 6 9 12] # [ 0 4 8 12 16]] a np.array([1, 2, 3]) print(np.repeat(a, 2)) # [1 1 2 2 3 3] print(np.tile(a, 2)) # [1 2 3 1 2 3]4. 科学计算三件套线性代数、随机数、统计聚合4.1 线性代数numpy.linalg 是隐藏主力numpy.linalg里有大量实用的线性代数工具做数据分析、数值计算时绕不开。最常用的几个np.linalg.solve(A, b)解线性方程组Ax bnp.linalg.eig(A)特征值和特征向量np.linalg.svd(A)奇异值分解np.linalg.lstsq(A, b)最小二乘解np.linalg.inv(A)矩阵求逆能不用尽量不用效率低且数值不稳定我做个最小二乘拟合的典型例子。假设有一组带噪声的线性数据y 2x 3 noise我们想从观测样本里反推出系数rng np.random.default_rng(42) x np.linspace(0, 10, 50) true_w, true_b 2.0, 3.0 y true_w * x true_b rng.normal(scale1.0, sizex.size) # 构造设计矩阵每行是 (x, 1) X np.column_stack([x, np.ones_like(x)]) # 最小二乘求解 coeffs, residuals, rank, s np.linalg.lstsq(X, y, rcondNone) w, b coeffs print(f拟合结果: w{w:.3f}, b{b:.3f})注意我构造X的时候用了np.column_stack把x和一列1拼起来。这个“1”代表截距项因为线性模型里有常数项b。lstsq解出的结果就是让残差平方和最小的参数组合实际输出会很接近真实的w2.0, b3.0。所谓“机器学习入门模型的数学底子”很大一部分就是这种最小二乘。4.2 随机数请改用 default_rng很多人写随机数还是老一套np.random.seed(0)np.random.rand()。这套老接口不是不能用只是它维护一个全局随机状态在函数库、并行计算里很容易引发问题。现在官方推荐的做法是创建独立的Generator对象rng np.random.default_rng(42) a rng.normal(loc0.0, scale1.0, size(3, 3)) # 标准正态 b rng.integers(0, 10, size5) # 0到9的整数 c rng.choice(np.arange(100), size10, replaceFalse) # 无放回抽样 d rng.permutation(np.arange(10)) # 打乱顺序 # 洗牌 arr np.arange(12).reshape(3, 4) rng.shuffle(arr) # 沿第0维随机打乱行default_rng的好处是随机源是独立对象想复现实验结果就固定种子想并行就每个进程各造一个rng互不干扰。我在写可复现的算法实验时都会把rng作为参数传进函数而不是直接调全局np.random。4.3 处理NaN别让缺失值毁掉聚合结果真实数据里经常带NaN。直接用np.mean、np.std去算返回的也是NaN一个缺失值就能污染整列统计量。NumPy提供了一组带nan前缀的版本data np.array([[1.0, 2.0, np.nan], [4.0, np.nan, 6.0], [7.0, 8.0, 9.0]]) print(np.nanmean(data, axis0)) # [4. 5. 7.5] print(np.nanstd(data, axis1)) # [0.81649658 1.41421356 0.81649658]还有np.isnan(data)可以拿到缺失值位置配合布尔索引做过滤或填充。clean data[~np.isnan(data)] # 转成一维丢掉NaN print(clean) # [1. 2. 4. 6. 7. 8. 9.]填充缺失值可以这样np.where(np.isnan(data), 0.0, data)把所有NaN替换成0。不过实际工作里到底填0还是填均值要根据业务场景和模型类型来定别一刀切。5. 和pandas、matplotlib、scikit-learn配合的实战姿势5.1 pandas读进来NumPy算出去项目里最常见的链路是pandas负责读Excel、CSV和清洗NumPy负责数值计算再用pandas封装结果。pandas的DataFrame和Series底层存储就是NumPy数组或扩展数组。取回纯NumPy数组最规范的方法是.to_numpy()import pandas as pd df pd.DataFrame({ x: [1, 2, 3, 4], y: [2.0, 4.5, 6.0, 9.0] }) X df[[x]].to_numpy() # shape (4, 1) y df[y].to_numpy() # shape (4,)老代码里常见的.values也能用但官方推荐to_numpy()因为它在遇到扩展类型时表现更明确。如果DataFrame里有缺失值to_numpy()得到的是object或带NaN的浮点数组后面做数值运算时要注意处理。5.2 matplotlib画图横坐标太密集是高频问题用matplotlib画数据时一个常见尴尬是横坐标标签全叠在一起根本看不清。热搜词里也出现了“python画图横坐标太密集”说明很多人被这个折磨过。这种问题通常出现在横坐标是时间戳或很多分类标签的场景。解决办法有两个方向一是让标签旋转二是限制显示数量。import numpy as np import matplotlib.pyplot as plt dates [f2025-01-{i:02d} for i in range(1, 32)] values np.random.default_rng(1).normal(size31) fig, ax plt.subplots(figsize(10, 4)) ax.plot(range(len(dates)), values) ax.set_xticks(range(len(dates))) # 每隔5个显示一个标签避免全部挤在一起 step 5 visible_ticks range(0, len(dates), step) ax.set_xticks(list(visible_ticks)) ax.set_xticklabels([dates[i] for i in visible_ticks], rotation45, haright) ax.grid(True) plt.tight_layout() plt.show()如果数据点特别多更好的方式是直接让matplotlib自动挑刻度from matplotlib.ticker import MaxNLocator ax.xaxis.set_major_locator(MaxNLocator(nbins6)) # 最多显示6个刻度还有热词里那个经典画图开头import numpy as np、import matplotlib.pyplot as plt、t np.linspace(0, 2*np.pi, 200)。实际上如果画多个函数图像可以把t直接广播成一个二维数组一次plot就能画多条曲线性能比一条条画好很多。t np.linspace(0, 2 * np.pi, 200) y np.column_stack([np.sin(t), np.cos(t), np.sin(2 * t)]) plt.plot(t, y) plt.xlabel(t) plt.ylabel(value) plt.legend([sin(t), cos(t), sin(2t)]) plt.show()5.3 scikit-learn为什么只认NumPy数组scikit-learn的接口约定是特征矩阵必须是二维的形状通常是(n_samples, n_features)。你用pandas的DataFrame传进去很多模型也能跑但偶尔会因为列名包含特殊字符、类别特征没编码等问题闹脾气。最稳妥的做法是进模型前统一转成NumPy数组from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LinearRegression X df[[x]].to_numpy() # (n, 1) y df[y].to_numpy() # (n,) scaler StandardScaler() X_scaled scaler.fit_transform(X) # 返回的就是NumPy数组 model LinearRegression().fit(X_scaled, y) print(model.coef_, model.intercept_)这里fit_transform的结果是NumPy数组不是DataFrame所以下游所有运算可以直接用数组操作。整个生态的“接口语言”就是NumPy数组掌握好ndarray的形状和dtype用sklearn基本不会碰到莫名奇妙的报错。6. 两个实战案例01背包和层次聚类6.1 01背包动态规划的向量化写法01背包是算法里很经典的动态规划题有若干物品每个物品有重量w和价值v背包容量有限问能装下的最大价值。常规Python写法是这样的items [(2, 3), (3, 4), (4, 5), (5, 8)] # (重量, 价值) capacity 8 dp [0] * (capacity 1) for w, v in items: for j in range(capacity, w - 1, -1): dp[j] max(dp[j], dp[j - w] v) print(dp[-1]) # 12这里内层循环必须从capacity倒着往w走因为每个物品只能用一次防止同一个物品被重复使用。用NumPy可以把内层循环整体向量化dp np.zeros(capacity 1, dtypeint) for w, v in items: dp_new dp.copy() dp_new[w:] np.maximum(dp_new[w:], dp[:-w] v) dp dp_new print(dp[-1]) # 12原理是dp[:-w]表示“容量从0到 capacity-w 的所有状态”加上v就对应“把当前物品放进去”后的价值dp_new[w:]表示“容量从w到capacity的所有状态”。逐个位置取最大值就是内层状态转移。因为是一次性更新一整段所以必须用dp.copy()提前保存旧状态否则更新后面的位置时已经改过的值会污染引用它的状态。这个写法在数据量大的时候比纯Python循环快很多。但不是说让你所有DP题都硬上NumPy而是理解这个思路动态规划的状态转移如果是一段连续索引上的操作就能用“切片向量化运算”替代内层循环。6.2 层次聚类的距离矩阵广播一步到位层次聚类里的核心操作之一是算两两样本之间的距离矩阵。用双层Python循环写不仅慢代码还丑。而NumPy的广播可以直接完成这个操作。假设有20个二维点想算所有点对的欧氏距离rng np.random.default_rng(42) points rng.normal(size(20, 2))利用广播让points分别“扮演”两个矩阵一个形状是(20, 1, 2)另一个是(1, 20, 2)相减后得到(20, 20, 2)就是每个点对在各维度上的差值再平方求和开根号。diff points[:, None, :] - points[None, :, :] # (20, 20, 2) dist np.sqrt((diff ** 2).sum(axis-1)) # (20, 20) np.fill_diagonal(dist, np.inf) # 自己到自己设为无穷大这里dist[i, j]就是第i个点和第j个点的距离。得到距离矩阵后单链接Single-link层次聚类的核心循环就是不断找当前距离最小的两个簇合并然后更新距离矩阵。n points.shape[0] clusters {i: [i] for i in range(n)} # 初始每个样本一个簇 for _ in range(n - 1): idx np.unravel_index(np.argmin(dist), dist.shape) i, j min(idx), max(idx) # 合并簇 clusters[i].extend(clusters.pop(j)) # 单链接新簇到其它簇的距离 两簇间最小距离 for k in clusters: if k i: continue d_ik dist[i, k] d_jk dist[j, k] dist[i, k] min(d_ik, d_jk) dist[k, i] min(d_ik, d_jk) # 把已合并的j行、j列全部设为inf表示不再参与后续计算 dist[j, :] np.inf dist[:, j] np.inf这个示例里用来展示思想。生产环境直接调scipy.cluster.hierarchy.linkage就行底层是C实现性能好得多。但亲手用NumPy写一遍对广播和矩阵索引的理解会加深不少尤其是np.unravel_index(np.argmin(dist), dist.shape)这个组合拳能把“打平”数组的最小值坐标还原回二维坐标非常实用。6.3 案例背后的思维模式两个案例的共同点是把循环内对“单个元素”的操作转换成对“切片/整段数组”的操作。01背包里for j in range(w, capacity 1)变成了dp[w:]层次聚类里双重循环算距离变成了points[:, None, :] - points[None, :, :]这种思维不是天生就会的我的练习方法是每次写完Python循环代码后先不急着优化而是观察循环体内对数组的访问是不是“按固定偏移的一段连续索引”。如果是大概率能改成切片操作。改完跑一下结果和原来一致再顺手对比一下耗时这种正反馈会让你越来越有感觉。7. 常见问题与排查实录7.1 导入就报 UserWarning: failed to initialize numpy热词里出现过这个报错UserWarning: failed to initialize numpy: No module named numpy。遇到这个99%是环境问题不是代码问题。常见原因有三个当前终端里用的Python和你pip install装numpy的Python不是一个环境装了多个Python版本命令python和python3指向不同解释器虚拟环境没激活或者IDE里选了解释器但没装包我的排查顺序是which python python --version python -m pip show numpy python -c import numpy; print(numpy.__version__)如果pip show numpy显示已安装但python -c import numpy报错说明解释器不是同一个。这时直接换成python -m pip install numpy确保安装到当前python对应的环境里。使用venv或conda创建独立环境能彻底避免这类问题我现在的所有项目都会在虚拟环境里干活从不在系统Python里装科学计算包。7.2 Windows 下 DLL load failed 和版本选择Windows上常见的ImportError: DLL load failed while importing numpy基本原因也是版本和环境不匹配。解决办法直接装官方预编译wheel不要碰源码编译python -m pip install numpy注意Python是64位还是32位装对应版本如果你手动下载了.whl文件用python -m pip install 文件名.whl安装不要直接双击关于版本选择热词里出现了Ubuntu安装numpy 2.2.5的搜索。新版NumPy对Python版本有最低要求装新版本之前先看一眼官方Release Notes确认你当前Python版本能支持。比如NumPy 2.x系列通常要求Python 3.9以上如果系统默认Python比较老建议先升级Python或者用conda环境装一个较新的Python。7.3 安装成功但运行时出现奇怪行为如果代码在A机器正常在B机器异常先查numpy.__version__和Python版本是否一致。版本差异导致的API变化很常见比如np.float在老版本里存在在较新版本里被移除了。这时要么统一环境要么在代码里用兼容写法比如用float代替np.float用rng.integers代替老的np.random.randint。还有一个容易被忽视的点用pip install numpy --upgrade升级numpy之后有些依赖旧版行为的第三方库可能会出问题。升级前最好在测试环境跑一遍你的核心流程。我遇到过一次升级numpy后某个绘图函数突然报cannot cast array data from dtype(float64) to dtype(float32)排查了半天最后发现是API行为变化导致的。7.4 广播、索引报错信息读不懂怎么办广播报错operands could not be broadcast together with shapes解决思路是打印两边的shape然后手动按规则对齐。我自己调试时经常用np.broadcast_shapes预览np.broadcast_shapes((3, 4), (4,)) # 返回 (3, 4) np.broadcast_shapes((3, 4), (5,)) # 报错提前看出问题索引报错too many indices for array一般是把一维数组当二维用了或者reshape时元素总数对不上。多输出.shape和.ndim基本就能定位。7.5 内存爆炸数组太大八个G都顶不住np.random.default_rng().normal(size(100000, 100000))这种代码会直接创建80GB的浮点数组几乎必挂。解决办法用dtypenp.float32降低精度内存减半用np.memmap做内存映射文件适合超大型矩阵能用视图不动用副本少写几次.copy()分块计算不要一次性把整个数组load进内存检查当前数组占用内存大小用a.nbytes比如一个(10000, 10000)的float64数组是800MB如果占满内存最好换策略。7.6 pytorch/TensorFlow 用户容易搞混的 dtype 问题如果你经常从深度学习框架切回NumPy会发现torch.float32和np.float32有时在互转时不对齐。这其实不是NumPy的问题而是框架接口的约定不同。只要记得在互转时手动指定dtype别用默认值能省掉很多隐性bug。8. 一点个人体会我用NumPy这么多年最大的感受是这东西的“上手门槛”很低但“用好门槛”比想象中高。很多人学完np.array和reshape就认为自己会了真正做起项目来却总在广播报错、视图副本、axis方向、dtype这些细节上反复翻车。这篇笔记扩展部分就是想把这些看似零碎、实则高频的坑一次性串起来。如果你只记住一句话我建议是写NumPy代码时永远先想清楚“我手里的数组是什么形状我要的结果是什么形状中间通过什么操作能把前一个变成后一个”。这个习惯比记住任何函数都重要。形状清楚了广播不会错axis不会混淆调试报错也有方向感。说实话NumPy的官方文档已经写得很好了但我还是建议你在自己的项目里主动用它去重写一段旧Python代码。不用多找一段四五十行的数据处理或算法逻辑改成NumPy风格跑通后再对比下性能和执行时间。你会很快体会到“用数组思考”和“用循环思考”的差别而且这种体会光看教程是永远得不到的。