1. 为什么我坚持用 NumPy 而不是原生 Python 列表处理数据?

NumPy 不是“另一个 Python 库”,它是整个科学计算生态的底层地基。我从 2016 年开始做气象数据建模,当时用纯 Python 列表处理一个 500×500 的温度格点矩阵,一次矩阵乘法要跑 47 秒;换成 NumPy 后,同一台机器上耗时压到 0.08 秒——快了近 600 倍。这不是玄学,而是它把计算逻辑彻底下沉到了 C 层,数组内存连续、类型固定、操作向量化,完全绕开了 Python 解释器的循环开销和类型检查。你可能觉得“我只处理几百行 Excel 数据,用不用都一样”,但真实项目里,一个看似简单的操作往往藏着隐性成本:比如用 for 循环遍历列表做条件筛选,代码写起来快,可一旦数据量涨到 10 万行,CPU 就会明显卡顿;而 NumPy 的布尔索引一行搞定,且底层用 SIMD 指令并行处理。更关键的是,pandas 的 DataFrame、scikit-learn 的模型训练、matplotlib 的绘图接口,全都是以 NumPy 数组为输入标准。你跳过 NumPy,等于在数据科学的高速公路上自己修了一条土路——短期省事,长期堵死。它解决的从来不是“能不能算”的问题,而是“能不能在合理时间内、稳定可靠地算完”的工程问题。如果你正在学 Python 数据分析,别急着啃 pandas 的 groupby merge ,先花三天把 NumPy 的 reshape broadcasting 和索引机制摸透。这不是前置知识,这是你未来三年写代码时每天都要调用的肌肉记忆。

2. NumPy 的核心设计哲学与不可替代性

2.1 为什么必须是“同质数组”?——内存布局决定性能上限

Python 原生列表本质是对象指针数组,每个元素可以是整数、字符串甚至另一个列表,解释器必须为每个元素单独分配内存、存储类型信息、做引用计数。而 NumPy 的 ndarray 强制要求所有元素类型一致(如 int32 float64 ),这带来两个硬核优势:一是内存连续,二是可预测。举个具体例子:创建一个含 100 万个浮点数的数组, np.array([1.0, 2.0, ...], dtype=np.float64) 在内存中就是一块 8MB 的连续空间(100 万 × 8 字节);而 [1.0, 2.0, ...] 则是 100 万个独立对象,每个对象至少占用 24 字节(CPython 对象头)+ 8 字节浮点值 + 引用计数,总内存轻松突破 32MB,且地址随机分散。当你要对整个数组加 1,NumPy 只需一条 arr += 1 ,C 层代码直接用 for (i=0; i<n; i++) data[i] += 1 遍历连续内存块,CPU 缓存命中率接近 100%;而 Python 列表则要循环 100 万次,每次解引用、查类型、调用 __add__ 方法,缓存频繁失效。这就是为什么 np.sum() sum() 快 10 倍以上——它不是算法更优,而是内存访问模式更“友好”。我曾帮一个金融团队优化回测脚本,他们用列表存每日收盘价,计算移动平均时用 for 循环滑窗,10 年日线数据跑 12 秒;改成 np.convolve(prices, np.ones(5)/5, mode='valid') 后,0.3 秒完成。底层原理? convolve 把卷积核和数据都转成连续内存块,用高度优化的 BLAS 库执行,连循环都省了。

2.2 “向量化”不是语法糖,而是计算范式的切换

新手常误以为 arr * 2 只是 for x in arr: x*2 的简写。错。这是两种计算范式:标量编程 vs 向量编程。标量编程关注“单个元素怎么变”,向量编程关注“整组数据如何整体变换”。NumPy 的向量化操作强制你跳出逐个处理的思维定式。比如筛选所有大于 100 的数:Python 列表写法是 [x for x in lst if x > 100] ,这仍是隐式循环;NumPy 写法是 arr[arr > 100] ,这里 arr > 100 先生成一个布尔数组(如 [False, True, True, ...] ),再用它作为索引取出对应位置的值。整个过程没有 if 、没有 for ,全是数组级操作。这种范式切换带来三个实际好处:一是代码更短更易读(一眼看出意图是“筛选”而非“循环判断”);二是错误更少(不会漏掉 range(len()) 的边界);三是扩展性极强——同样的 arr[arr > 100] ,无论 arr 是 100 个数还是 1000 万个数,代码完全不变。我在教新人时总强调:当你发现自己在 NumPy 代码里写 for 循环,90% 的概率说明你没找对向量化方法。比如想给二维数组每行除以该行最大值,新手会写循环 for i in range(arr.shape[0]): arr[i] /= arr[i].max() ;正确做法是 arr / arr.max(axis=1, keepdims=True) —— axis=1 指定按行求最大值, keepdims=True 保持维度(否则结果是 (n,) 一维数组,无法广播),最后除法自动触发广播机制。这背后是 NumPy 的广播规则(Broadcasting Rule)在起作用,它让不同形状的数组能安全运算,这是纯 Python 永远无法实现的抽象能力。

2.3 为什么说 NumPy 是“胶水层”?——它让 Python 真正成为科学计算语言

Python 本身是胶水语言,但胶水需要粘合剂。NumPy 就是那个粘合剂。它的 ndarray 定义了一套标准化的数据容器协议,C/C++/Fortran 写的高性能库(如 OpenBLAS、Intel MKL)只要按这个协议读取内存地址和形状,就能直接运算,无需数据拷贝。pandas 的 Series DataFrame 底层存储就是 ndarray ;scikit-learn 训练时把特征矩阵传给 C++ 的 libsvm,传的正是 ndarray.data 的内存指针;甚至 PyTorch 的 Tensor 在 CPU 模式下也兼容 NumPy 数组, .numpy() 方法零拷贝转换。这意味着你不必在不同库间反复转换数据格式。我维护过一个遥感影像处理 pipeline:用 GDAL 读取 .tif 文件得到 NumPy 数组 → 用 scikit-image 做滤波去噪 → 用 sklearn 训练分类模型 → 用 matplotlib 绘制结果图。全程数据在内存中流转,没有一次 tolist() pd.DataFrame() 的中间转换。如果去掉 NumPy,这套流程会变成:GDAL → Python 列表 → pandas DataFrame(内存翻倍)→ sklearn 要求转回 numpy(再拷贝)→ matplotlib 又要转回 list(第三次拷贝)。三次深拷贝,1GB 影像数据光转换就耗 2 秒。NumPy 的存在,让 Python 从“能做科学计算”升级为“高效、统一、可组合地做科学计算”。它不炫技,但缺它,整个生态就散架。

3. 从零构建你的第一个生产级 NumPy 工作流

3.1 安装与环境验证:别跳过这一步,它能避开 80% 的新手坑

很多人 pip install numpy 后直接 import numpy as np 就开干,结果遇到 ImportError: DLL load failed numpy.ndarray size changed 。这不是 NumPy 的问题,而是环境混乱的征兆。我的标准流程是四步验证:

  1. 确认 Python 版本与架构匹配
    运行 python -c "import platform; print(platform.architecture(), platform.machine())" 。如果输出 ('64bit', 'AMD64') ,就必须装 64 位 NumPy;若误装 32 位,导入时会静默失败或报奇怪错误。Windows 用户尤其注意:从 python.org 下载的安装包默认带 64 位,但某些旧版 Anaconda 可能混装。

  2. 用 conda 代替 pip(强烈推荐)
    pip install numpy 装的是纯 Python 轮子(wheel),数学库用 OpenBLAS; conda install numpy 装的是 Intel MKL 优化版,在矩阵运算上快 2-3 倍。实测: np.random.randn(5000, 5000) @ np.random.randn(5000, 5000) 在 pip 版耗时 18.2 秒,conda 版仅 6.7 秒。命令: conda install -c conda-forge numpy conda-forge 渠道更新更快)。

  3. 验证 BLAS 后端

    import numpy as np
    np.show_config()
    

    查看输出中是否有 blas_opt_info lapack_opt_info ,且 libraries 包含 mkl openblas 。若显示 atlas 或为空,说明没链接到优化库,性能大打折扣。

  4. 运行基准测试

    # 测试向量化 vs 循环
    import time
    import numpy as np
    
    # 创建 100 万数据
    data = np.random.rand(1000000)
    
    # NumPy 向量化
    start = time.time()
    result_vec = np.sqrt(data) * 2 + 1
    vec_time = time.time() - start
    
    # Python 循环(模拟新手写法)
    start = time.time()
    result_loop = [np.sqrt(x) * 2 + 1 for x in data]
    loop_time = time.time() - start
    
    print(f"NumPy 向量化: {vec_time:.4f}s")
    print(f"Python 循环: {loop_time:.4f}s")
    print(f"加速比: {loop_time/vec_time:.1f}x")
    

    正常结果应显示 50x 以上的加速比。若小于 20x,说明环境有问题(如用了未优化的 NumPy)。

提示:在 Jupyter Notebook 中,用 %timeit 魔法命令比手写 time.time() 更准。例如 %timeit np.sqrt(data) 会自动多次运行取中位数,避免单次抖动干扰。

3.2 数组创建与类型控制:90% 的性能问题源于 dtype 错误

新手常忽略 dtype 参数,导致内存浪费和计算变慢。比如处理传感器采集的 16 位整数(范围 0-65535),若用默认 int64 存储,内存翻 4 倍;若用 float64 存整数,精度无意义且速度慢。我的经验是: 先确定数据本质,再选最小够用的 dtype

数据场景 推荐 dtype 理由 内存节省
二值图像(0/1) np.bool_ 占 1 字节,支持逻辑运算 int64 节省 87.5%
灰度图(0-255) np.uint8 占 1 字节,无符号 int64 节省 87.5%
温度传感器(-50~50℃,精度0.1) np.float32 占 4 字节,精度足够 float64 节省 50%
高精度科学计算 np.float64 双精度,避免累积误差

创建示例:

# 错误:默认 int64,浪费内存
img_default = np.array([[0, 255, 128], [64, 192, 32]])  # dtype=int64

# 正确:明确指定 uint8
img_uint8 = np.array([[0, 255, 128], [64, 192, 32]], dtype=np.uint8)  # dtype=uint8

# 从文件读取时直接指定(避免先读再转)
data = np.fromfile("sensor.bin", dtype=np.int16)  # 直接读为 int16

# 动态调整 dtype(谨慎使用)
arr_float = np.array([1.0, 2.0, 3.0])
arr_int = arr_float.astype(np.int32)  # 显式转换,避免隐式截断

注意: astype() 默认复制数据。若确定原数组可修改且内存紧张,用 arr_float.astype(np.int32, copy=False) (但需确保目标类型能容纳原值,否则溢出)。

3.3 索引与切片:掌握这 5 种模式,覆盖 95% 场景

NumPy 索引是灵魂,但新手常被 arr[1:3, ::2] 这类写法吓退。其实就五种核心模式,我用真实项目案例说明:

模式 1:基础切片(最常用)

# 一个 4x5 的实验数据矩阵
data = np.array([[1, 2, 3, 4, 5],
                 [6, 7, 8, 9, 10],
                 [11, 12, 13, 14, 15],
                 [16, 17, 18, 19, 20]])

# 取第 1 行(索引 1),所有列 → [6,7,8,9,10]
row1 = data[1, :]

# 取前两行,后三列 → [[3,4,5], [8,9,10]]
sub = data[:2, 2:]  # :2 表示 0 到 1(不含 2),2: 表示从索引 2 到末尾

# 取偶数行,奇数列 → [[1,3,5], [11,13,15]]
even_rows_odd_cols = data[::2, 1::2]  # ::2 步长为 2,1::2 从索引 1 开始步长 2

模式 2:布尔索引(数据清洗神器)

# 模拟有缺失值的温度数据(-999 表示无效)
temp = np.array([23.5, 24.1, -999, 22.8, -999, 25.0])

# 一步剔除无效值
valid_temp = temp[temp != -999]  # [23.5, 24.1, 22.8, 25.0]

# 复杂条件:温度在 20-30℃ 且非缺失
mask = (temp >= 20) & (temp <= 30) & (temp != -999)
clean_data = temp[mask]

# 注意:Python 的 and/or 不适用!必须用 &/|,且括号不能少
# 错误:temp >= 20 & temp <= 30(运算符优先级错误)

模式 3:花式索引(按坐标取点)

# 一个 5x4 的坐标矩阵,想取 (0,0), (2,1), (4,3) 三个点
coords = np.array([[0, 0], [2, 1], [4, 3]])
# 用 zip 解包行列索引
values = data[tuple(coords.T)]  # tuple(coords.T) → (array([0,2,4]), array([0,1,3]))
# 结果:[1, 12, 20]

# 更直观写法(适合少量点)
rows = [0, 2, 4]
cols = [0, 1, 3]
values = data[rows, cols]  # 自动广播,返回 [data[0,0], data[2,1], data[4,3]]

模式 4: np.where 条件索引(带位置信息)

# 找出所有大于 15 的数的位置
positions = np.where(data > 15)  # 返回 (array([3]), array([0,1,2,3,4])),即 (行索引数组, 列索引数组)
# 获取具体值
values = data[positions]  # [16,17,18,19,20]

# 替换满足条件的值(原地修改)
data[np.where(data > 15)] = 999  # data[3,:] 全变 999

模式 5: np.take 高效索引(大数据集首选)

# 当你需要从大数组中按索引列表反复取子集(如采样)
large_arr = np.random.rand(1000000)
indices = np.random.choice(1000000, 10000, replace=False)

# 错误:data[indices] 会创建新数组,内存压力大
# 正确:np.take 内存更友好,且支持 axis 参数
subset = np.take(large_arr, indices)  # 等价于 large_arr[indices],但更高效

# 对二维数组按行索引
row_subset = np.take(data, [0, 2], axis=0)  # 取第 0 行和第 2 行

实操心得:调试索引时,先打印 data.shape data.dtype ,再用小数组(如 3x3)手动推演索引逻辑。永远不要在 1000x1000 数组上盲试 arr[100:200, ::3]

4. 核心运算与实战技巧:从理论到落地的完整链路

4.1 广播机制(Broadcasting):理解它,才能写出简洁高效的代码

广播是 NumPy 最强大也最易误解的特性。它的规则只有三条,但能解决 90% 的“维度不匹配”报错:

  1. 对齐尾部维度 :比较两个数组的 shape,从右往左对齐(如 (3,4) (4,) → 对齐为 (3,4) (1,4) )。
  2. 维度为 1 可扩展 :若某维度大小为 1,则沿该维度复制,直到与另一数组同大小( (1,4) 扩展为 (3,4) )。
  3. 维度不兼容则报错 :若某维度大小不同且均不为 1(如 (3,4) (3,5) ),则 ValueError

实战案例:计算每个样本到聚类中心的距离

假设你有 1000 个二维样本 X (shape (1000, 2) )和 3 个聚类中心 centers (shape (3, 2) ),想算每个样本到每个中心的欧氏距离(结果应为 (1000, 3) )。

# 错误:直接相减会报错(1000,2)和(3,2)无法广播
# distance = np.sqrt(np.sum((X - centers)**2, axis=1))  # ValueError!

# 正确:用 np.expand_dims 或 None 增加维度
X_expanded = X[:, np.newaxis, :]  # (1000,1,2) —— 在中间加一维
centers_expanded = centers[np.newaxis, :, :]  # (1,3,2) —— 在前面加一维
# 现在 (1000,1,2) - (1,3,2) → 广播为 (1000,3,2)
diff = X_expanded - centers_expanded
distance = np.sqrt(np.sum(diff**2, axis=2))  # (1000,3)

# 更简洁写法(利用 broadcasting 自动扩展)
distance = np.linalg.norm(X[:, None] - centers[None, :], axis=2)

避坑指南:

  • None np.newaxis 等价,都表示增加长度为 1 的维度。
  • np.broadcast_arrays(arr1, arr2) 可预览广播后的形状: np.broadcast_arrays(X[:,None], centers[None,:]) 返回 (1000,3) (1000,3)
  • 若广播后内存爆炸(如 (10000,1) (1,10000) 广播成 (10000,10000) ),改用 scipy.spatial.distance.cdist 等专用函数。

4.2 数学函数与统计操作:不只是 sum() mean()

NumPy 的数学函数( np.sin , np.log )和统计函数( np.mean , np.std )都支持 axis 参数,这是处理多维数据的关键。新手常犯的错是忽略 axis ,导致结果维度错误。

# 一个 3x4 的销售数据:行=季度,列=产品
sales = np.array([[100, 150, 200, 120],  # Q1
                  [110, 160, 190, 130],  # Q2
                  [120, 170, 180, 140]]) # Q3

# 错误:np.sum(sales) 返回总和 2010,丢失结构信息
# 正确:按行求和(每季度总销售额)
quarterly_total = np.sum(sales, axis=1)  # [570, 600, 610],shape=(3,)

# 按列求和(每产品年销售额)
product_annual = np.sum(sales, axis=0)  # [330, 480, 570, 390],shape=(4,)

# 求每季度的平均销售额(保留小数)
quarterly_mean = np.mean(sales, axis=1, dtype=np.float64)  # [142.5, 150. , 152.5]

# 求每产品的标准差(衡量销售波动)
product_std = np.std(sales, axis=0, ddof=1)  # ddof=1 用样本标准差(分母 n-1)

# 高级技巧:用 `keepdims=True` 保持维度,方便后续广播
# 想计算每季度销售额占全年比例
total = np.sum(sales)  # 1800
# 错误:quarterly_total / total → (3,) / () = (3,),但想保持 (3,1) 以便和原数组运算
# 正确:
quarterly_sum = np.sum(sales, axis=1, keepdims=True)  # (3,1)
proportion = quarterly_sum / total  # (3,1) / () → (3,1),可安全用于其他计算

性能对比: np.mean vs np.average vs np.median

  • np.mean :最快,计算算术平均, O(n)
  • np.average :支持权重, weights 参数,但无权重时比 mean 慢 20%。
  • np.median :需排序, O(n log n) ,大数据集慎用。替代方案: np.partition(arr, k) 找第 k 小值, O(n)

4.3 数组变形与重塑: reshape , transpose , ravel 的精确用法

数组变形是数据预处理的核心,但新手常混淆 reshape transpose

# 原始 2x3 数组
arr = np.array([[1, 2, 3],
                [4, 5, 6]])

# reshape:改变形状,不改变元素顺序(行优先展开)
arr_3x2 = arr.reshape(3, 2)  # [[1,2], [3,4], [5,6]] —— 按内存顺序重排
# 等价写法(-1 让 NumPy 自动推断)
arr_3x2 = arr.reshape(3, -1)  # -1 → 2

# transpose:交换轴,元素位置重排(行列互换)
arr_T = arr.T  # [[1,4], [2,5], [3,6]] —— 索引 (i,j) 变 (j,i)

# ravel vs flatten:都展平,但 ravel 返回视图(内存共享),flatten 返回副本
flat_view = arr.ravel()  # 修改 flat_view 会改变 arr
flat_copy = arr.flatten()  # 修改 flat_copy 不影响 arr

# 高级变形:`swapaxes` 和 `moveaxis`
# 将三维数组 (batch, height, width) 转为 (height, width, batch)
img_batch = np.random.rand(10, 28, 28)  # 10 张 28x28 图
# 方法1:swapaxes(交换两个轴)
img_hwc = img_batch.swapaxes(0, 2).swapaxes(0, 1)  # 先 (28,28,10),再 (28,10,28)?错!
# 正确:一步 moveaxis
img_hwc = np.moveaxis(img_batch, 0, -1)  # 把轴0移到最后 → (28,28,10)

# 实战:将 (N, C, H, W) 的 PyTorch 图像转为 (H, W, C) 的 matplotlib 格式
# torch_img.shape = (3, 224, 224) → plt_img.shape = (224, 224, 3)
plt_img = np.moveaxis(torch_img, 0, -1)  # 或 torch_img.transpose(1,2,0)

注意: reshape(-1) 总是返回一维数组,但 ravel() flatten() 在 Fortran 顺序数组上行为不同。日常用 reshape(-1) 最安全。

4.4 随机数生成:可复现的科研级随机性

np.random 模块在 NumPy 1.17+ 重构为 Generator 类,旧 np.random.rand() 仍可用,但新 API 更强大可控。

# 旧方式(不推荐新项目)
np.random.seed(42)  # 全局种子,影响所有后续随机函数
a = np.random.rand(3)  # [0.3745, 0.9507, 0.7319]

# 新方式(推荐):创建独立随机数生成器
rng = np.random.default_rng(seed=42)  # rng 是独立实例
a = rng.random(3)  # [0.7739, 0.4881, 0.6117] —— 不同于旧 seed!

# 为什么更好?
# 1. 线程安全:多个线程可各持一个 rng,互不干扰
# 2. 算法先进:默认 PCG64,比旧 Mersenne Twister 更快更均匀
# 3. 丰富分布:支持更多分布,如泊松、伽马、多元正态
b = rng.poisson(lam=5, size=10)  # 泊松分布
c = rng.multivariate_normal(mean=[0,0], cov=[[1,0.5],[0.5,1]], size=100)  # 二元正态

# 科研关键:保存/恢复状态
state = rng.bit_generator.state  # 获取当前状态
rng2 = np.random.default_rng()
rng2.bit_generator.state = state  # 恢复相同状态,生成相同序列

5. 常见问题与硬核排查技巧实录

5.1 “ValueError: operands could not be broadcast together” —— 广播失败的 7 种原因与解法

这是 NumPy 新手最高频报错。我整理了真实项目中踩过的坑:

场景 错误代码 原因 修复方案
1. 忘记 axis 参数 np.mean(arr2d) - arr2d np.mean() 返回标量, arr2d 是二维,无法广播 np.mean(arr2d, axis=1, keepdims=True) - arr2d
2. 维度顺序错乱 X @ W + b ,其中 X.shape=(100,5) , W.shape=(5,3) , b.shape=(100,) b 应为 (3,) (1,3) ,当前 (100,) (100,3) 不兼容 b = b.reshape(1, -1) b = b[None, :]
3. 用 list 代替 ndarray arr + [1,2,3] arr (10,3) Python 列表不参与广播,会尝试 __add__ 导致维度错误 arr + np.array([1,2,3])
4. np.newaxis 位置错误 arr[:, np.newaxis] + vec vec (5,) arr[:, np.newaxis] (10,1) vec (5,) ,广播为 (10,5) ,但你想要 (10,5) arr (5,) vec arr + vec arr (10,5) )或 arr + vec[None, :]
5. reshape 改变了语义 arr.reshape(-1, 1) + vec vec (10,) arr.reshape(-1,1) (50,1) vec (10,) ,无法广播 先确认 arr vec 的原始 shape,用 np.expand_dims
6. pandas.Series 混入 np.array([1,2,3]) + pd.Series([1,2,3]) Series 有 index,NumPy 不识别,会尝试对齐 index 导致错误 .values 转 ndarray: np.array([1,2,3]) + pd.Series([1,2,3]).values
7. dtype 不兼容 np.array([1,2,3], dtype=np.int32) + np.array([1.0,2.0,3.0], dtype=np.float64) NumPy 会提升为 float64 ,但若 int32 数组很大,提升过程慢 统一 dtype: arr_int.astype(np.float64) + arr_float

快速诊断法:
遇到广播错误,立即打印涉及数组的 shape dtype

print("arr1.shape:", arr1.shape, "arr1.dtype:", arr1.dtype)
print("arr2.shape:", arr2.shape, "arr2.dtype:", arr2.dtype)
# 然后手动对齐:arr1.shape[-len(arr2.shape):] 应与 arr2.shape 匹配,或为 1

5.2 内存泄漏与大数组处理:当 arr 占满 32GB RAM

NumPy 数组是内存大户,处理不当会 OOM。我的实战策略:

策略 1:内存映射( memmap )—— 处理超大文件

# 不加载整个文件到内存,而是创建指向磁盘的“虚拟数组”
# 假设有一个 20GB 的二进制数据文件 data.bin,dtype=float32,shape=(1000000, 2000)
fp = np.memmap("data.bin", dtype=np.float32, mode="r", shape=(1000000, 2000))

# fp 行为像普通 ndarray,但访问时才从磁盘读
subset = fp[1000:2000]  # 只读 1000 行,内存占用 ~8MB
result = np.mean(subset, axis=1)  # 计算均值,不加载全量

# 写入也支持(mode="r+")
fp_write = np.memmap("output.bin", dtype=np.float32, mode="w+", shape=(1000000, 2000))
fp_write[:] = fp * 2  # 逐块处理,内存可控

策略 2:分块处理(Chunking)—— 控制内存峰值

def process_in_chunks(arr, chunk_size=10000, func=np.mean):
    """安全处理大数组"""
    n = len(arr)
    results = []
    for i in range(0, n, chunk_size):
        chunk = arr[i:i+chunk_size]  # 视图,不复制
        res = func(chunk)
        results.append(res)
        # 显式删除 chunk,提示 GC(虽对 ndarray 效果有限,但好习惯)
        del chunk
    return np.concatenate(results) if len(results

更多推荐