NumPy 结构化数组

在实际数据处理中,经常需要处理包含不同类型数据的表格,如混合了字符串、整数、浮点数的列。NumPy的结构化数组(Structured Arrays)允许在一个数组中存储复合数据类型,每个元素可以包含多个字段(field),类似于Excel表格或SQL数据库中的行。

1. 什么是结构化数组

结构化数组是ndarray的一种,其元素不是单一的数值,而是一个结构体(类似于C语言的struct)。每个结构体由多个字段组成,每个字段有自己的数据类型和名称。结构化数组非常适合存储异构表格数据,且内存布局紧凑,访问高效。

2. 创建结构化数组

2.1 通过 dtype 定义字段

首先需要定义一个复合数据类型(dtype),描述每个字段的名称和类型。可以使用字符串代码或元组列表。

import numpy as np

# 定义 dtype:包含 name(字符串,长度10),age(int32),weight(float64)
dt = np.dtype([('name', 'U10'), ('age', 'i4'), ('weight', 'f8')])

# 创建结构化数组
data = np.array([('Alice', 25, 55.5),
                 ('Bob', 30, 75.2),
                 ('Charlie', 35, 68.0)], dtype=dt)

print("结构化数组:")
print(data)
print("数据类型:", data.dtype)

输出:

结构化数组:
[('Alice', 25, 55.5) ('Bob', 30, 75.2) ('Charlie', 35, 68. )]
数据类型: [('name', '<U10'), ('age', '<i4'), ('weight', '<f8')]

2.2 使用字典定义更复杂的dtype

可以使用字典指定字段名、类型和偏移量等高级选项,但通常用元组列表即可。

# 等价的字典定义
dt_dict = np.dtype({'names': ['name', 'age', 'weight'],
                    'formats': ['U10', 'i4', 'f8']})
data2 = np.array([('David', 28, 70.5)], dtype=dt_dict)
print(data2)

2.3 从现有数据创建结构化数组

可以使用 np.array() 传入元组列表,并指定dtype。注意元组列表的顺序必须与dtype字段一致。

3. 访问字段

结构化数组的字段可以通过类似字典的方式按名称访问,返回的是原数组的视图(view),因此修改字段会影响原数组。

# 访问 age 字段
ages = data['age']
print("年龄:", ages)

# 修改 age 字段
ages[0] = 26
print("修改后 data:", data)

# 访问多个字段(返回新结构化数组,但也是视图?实际上返回的是视图,但字段子集)
names_weights = data[['name', 'weight']]
print("姓名和体重:\n", names_weights)

输出:

年龄: [25 30 35]
修改后 data: [('Alice', 26, 55.5) ('Bob', 30, 75.2) ('Charlie', 35, 68. )]
姓名和体重:
 [('Alice', 55.5) ('Bob', 75.2) ('Charlie', 68. )]

4. 字段访问的底层机制

通过字段名访问得到的是原数组的视图,共享内存。但通过多个字段名列表访问得到的是一个新结构化数组,它也是视图(因为它是原数组的子视图),但视图的dtype是新的结构化dtype。

# 验证视图关系
ages_view = data['age']
ages_view[0] = 99
print("data['age'][0] 修改后 data:", data[0])  # data 也被修改

# 验证子字段数组是否为视图
subset = data[['name', 'age']]
subset[0]['age'] = 100
print("修改 subset 后 data[0]:", data[0])  # data 也被修改

输出:

data['age'][0] 修改后 data: ('Alice', 99, 55.5)
修改 subset 后 data[0]: ('Alice', 100, 55.5)

5. 对结构化数组进行排序

可以使用 np.sortndarray.sort 对结构化数组排序,需要指定 order 参数。

# 按 age 排序
sorted_by_age = np.sort(data, order='age')
print("按年龄排序:\n", sorted_by_age)

# 按多个字段排序(先 weight 后 age)
sorted_multi = np.sort(data, order=['weight', 'age'])
print("按体重和年龄排序:\n", sorted_multi)

输出:

按年龄排序:
 [('Bob', 30, 75.2) ('Charlie', 35, 68. ) ('Alice', 100, 55.5)]
按体重和年龄排序:
 [('Alice', 100, 55.5) ('Charlie', 35, 68. ) ('Bob', 30, 75.2)]

6. 记录数组 (np.recarray)

recarray 是结构化数组的子类,它允许通过属性方式访问字段(如 data.name 而非 data['name']),更加方便。但会带来轻微的性能开销。

# 创建记录数组
rec_data = data.view(np.recarray)
# 或者使用 np.rec.array
rec_data2 = np.rec.array([('Alice', 25, 55.5), ('Bob', 30, 75.2)],
                          dtype=[('name', 'U10'), ('age', 'i4'), ('weight', 'f8')])

print("通过属性访问姓名:", rec_data.name)
rec_data.age[0] = 27
print("修改 age 属性后原 data:", data[0])  # 仍与原数组共享内存

输出:

通过属性访问姓名: ['Alice' 'Bob' 'Charlie']
修改 age 属性后原 data: ('Alice', 27, 55.5)

记录数组在交互式环境中使用很方便,但在需要高性能的循环中可能稍慢,建议在原型设计或小型数据时使用。

7. 结构化数组的常见操作

7.1 添加/删除字段

NumPy 不直接支持添加字段,但可以通过重新创建结构化数组实现,或使用 np.lib.recfunctions 中的辅助函数。

from numpy.lib import recfunctions as rfn

# 添加一个新字段(例如 BMI)
# 首先计算 BMI = weight / (height/100)**2,但我们没有 height,这里仅为示例
# 演示添加字段的通用方法:rfn.append_fields
# 假设我们有一个新数组 new_field 与 data 长度相同
new_field = np.array([22.1, 23.4, 21.8])
data_with_bmi = rfn.append_fields(data, 'bmi', new_field, usemask=False)
print("添加 BMI 字段后:\n", data_with_bmi)

# 删除字段
data_without_age = rfn.drop_fields(data, 'age')
print("删除 age 字段后:\n", data_without_age)

输出:

添加 BMI 字段后:
 [('Alice', 27, 55.5, 22.1) ('Bob', 30, 75.2, 23.4)
 ('Charlie', 35, 68. , 21.8)]
删除 age 字段后:
 [('Alice', 55.5) ('Bob', 75.2) ('Charlie', 68. )]

7.2 合并两个结构化数组

使用 rfn.merge_arraysnp.concatenate 但需注意dtype兼容。

# 创建另一个结构化数组
dt2 = np.dtype([('name', 'U10'), ('salary', 'i4')])
data_salary = np.array([('Alice', 50000), ('Bob', 60000), ('Charlie', 55000)], dtype=dt2)

# 按 name 合并
merged = rfn.merge_arrays([data, data_salary], flatten=True)
print("合并后的数组:\n", merged)

7.3 结构化数组与 Pandas DataFrame 的转换

虽然结构化数组功能强大,但对于复杂的表格操作,Pandas 的 DataFrame 更灵活。可以轻松转换:

import pandas as pd

# 结构化数组转 DataFrame
df = pd.DataFrame(data)
print("DataFrame:\n", df)

# DataFrame 转结构化数组
arr_from_df = df.to_records(index=False)
print("从 DataFrame 转回:\n", arr_from_df)

8. 内存布局与性能

结构化数组在内存中是连续存储的,每个字段可以按顺序存储(默认)或按对齐方式存储(通过设置 align=True)。对齐可能会在字段之间填充字节以满足内存对齐要求,提高访问速度但可能增加内存占用。

# 不对齐(紧凑)
dt_packed = np.dtype([('a', 'i1'), ('b', 'i4')])
# 对齐(可能产生间隙)
dt_aligned = np.dtype([('a', 'i1'), ('b', 'i4')], align=True)

print("Packed itemsize:", dt_packed.itemsize)   # 5 (1+4)
print("Aligned itemsize:", dt_aligned.itemsize) # 8 (可能由于对齐)
提示: 结构化数组适用于同构的表格数据,但如果需要更复杂的操作(如分组、连接、缺失值处理等),建议使用 Pandas。NumPy的结构化数组更底层,适合作为高性能计算的中间数据格式。

总结

结构化数组扩展了 NumPy 的能力,使其能够处理混合数据类型的表格。通过定义复合 dtype,可以高效存储和访问字段,支持排序、筛选等操作。结合 recarrayrecfunctions 模块,可以完成许多常见的数据整理任务。下一章我们将讨论 NumPy 的性能优化技巧。