NumPy 结构化数组
结构化数组(Structured Arrays)是 NumPy 中一种特殊的数据类型,允许数组中的每个元素包含多个字段,每个字段可以有不同的数据类型。这使得结构化数组非常适合处理表格数据、数据库记录或类似 C 语言结构体的数据。本章将详细介绍结构化数组的创建、操作和应用。
什么是结构化数组?
普通 NumPy 数组要求所有元素必须是相同的数据类型。但在实际数据处理中,我们经常需要处理包含多种数据类型的记录,例如:
- 学生信息:姓名(字符串)、年龄(整数)、成绩(浮点数)
- 员工记录:ID(整数)、姓名(字符串)、入职日期(日期)、薪资(浮点数)
- 实验数据:时间戳、温度、压力、状态码
结构化数组正是为解决这类需求而设计的。每个数组元素是一个"结构",包含多个命名字段,每个字段有自己的数据类型。
结构化数组 vs Pandas DataFrame
结构化数组与 Pandas DataFrame 有相似之处,但定位不同:
| 特性 | 结构化数组 | Pandas DataFrame |
|---|---|---|
| 内存效率 | 高,连续存储 | 较低 |
| 操作灵活性 | 基础操作 | 丰富的数据操作 |
| 与 C 交互 | 直接兼容 | 需要转换 |
| 适合场景 | 底层计算、C 接口 | 数据分析、数据清洗 |
如果只需要简单的表格数据处理,建议使用 Pandas。如果需要与 C 代码交互或进行底层内存操作,结构化数组更合适。
创建结构化数组
使用列表元组定义
最常用的方式是使用 dtype 参数指定字段定义:
import numpy as np
# 定义结构化数据类型
# 每个字段由 (字段名, 数据类型) 定义
dt = np.dtype([('name', 'U10'), # Unicode 字符串,最大长度 10
('age', 'i4'), # 32 位整数
('height', 'f4')]) # 32 位浮点数
# 创建结构化数组
students = np.array([
('Alice', 20, 165.5),
('Bob', 22, 178.0),
('Charlie', 21, 172.3)
], dtype=dt)
print(f"结构化数组:\n{students}")
print(f"\n数据类型: {students.dtype}")
dtype 定义方式详解
NumPy 提供了多种定义结构化 dtype 的方式:
import numpy as np
# 方式1:元组列表(推荐)
dt1 = np.dtype([
('name', 'U20'),
('age', 'i4'),
('score', 'f8')
])
# 方式2:字符串格式(简洁但不灵活)
dt2 = np.dtype('U20, i4, f8') # 字段名默认为 f0, f1, f2
# 方式3:字典格式(最灵活,可控制字节偏移)
dt3 = np.dtype({
'names': ['name', 'age', 'score'],
'formats': ['U20', 'i4', 'f8']
})
print(f"方式1: {dt1}")
print(f"方式2: {dt2}")
print(f"方式3: {dt3}")
数据类型代码说明
结构化数组中常用的数据类型代码:
| 代码 | 说明 | 等价写法 |
|---|---|---|
'i4' | 32 位有符号整数 | np.int32 |
'i8' | 64 位有符号整数 | np.int64 |
'u4' | 32 位无符号整数 | np.uint32 |
'f4' | 32 位浮点数 | np.float32 |
'f8' | 64 位浮点数 | np.float64 |
'U10' | Unicode 字符串,最大长度 10 | - |
'S10' | 字节字符串,最大长度 10 | - |
'?' | 布尔类型 | np.bool_ |
'O' | Python 对象 | np.object_ |
创建空结构化数组
import numpy as np
# 定义数据类型
dt = np.dtype([('id', 'i4'), ('name', 'U20'), ('active', '?')])
# 创建空数组
empty_arr = np.empty(5, dtype=dt)
print(f"空数组:\n{empty_arr}")
# 创建全零数组
zeros_arr = np.zeros(3, dtype=dt)
print(f"\n全零数组:\n{zeros_arr}")
访问字段
访问单个字段
通过字段名可以访问特定的列数据:
import numpy as np
dt = np.dtype([('name', 'U10'), ('age', 'i4'), ('score', 'f8')])
data = np.array([
('Alice', 20, 89.5),
('Bob', 22, 92.0),
('Charlie', 21, 85.5)
], dtype=dt)
# 访问单个字段(返回视图)
names = data['name']
ages = data['age']
scores = data['score']
print(f"姓名: {names}")
print(f"年龄: {ages}")
print(f"成绩: {scores}")
print(f"\n姓名数组类型: {type(names)}, dtype: {names.dtype}")
访问多个字段
可以同时访问多个字段,返回一个新的结构化数组视图:
import numpy as np
dt = np.dtype([('id', 'i4'), ('name', 'U10'), ('age', 'i4'), ('score', 'f8')])
data = np.array([
(1, 'Alice', 20, 89.5),
(2, 'Bob', 22, 92.0),
(3, 'Charlie', 21, 85.5)
], dtype=dt)
# 访问多个字段
subset = data[['name', 'score']]
print(f"姓名和成绩:\n{subset}")
print(f"\n子集的 dtype: {subset.dtype}")
# 注意:这是视图,修改会影响原数组
subset['score'] = [100, 100, 100]
print(f"\n修改后的原数组成绩: {data['score']}")
访问单个元素
索引访问返回的是一个结构化的标量:
import numpy as np
dt = np.dtype([('name', 'U10'), ('age', 'i4'), ('score', 'f8')])
data = np.array([
('Alice', 20, 89.5),
('Bob', 22, 92.0)
], dtype=dt)
# 访问单个元素
first_student = data[0]
print(f"第一个学生: {first_student}")
print(f"类型: {type(first_student)}")
# 通过字段名访问
print(f"\n姓名: {first_student['name']}")
print(f"年龄: {first_student['age']}")
# 也可以通过索引访问(按字段顺序)
print(f"\n通过索引访问: {first_student[0]}, {first_student[1]}, {first_student[2]}")
修改数据
修改字段值
import numpy as np
dt = np.dtype([('name', 'U10'), ('age', 'i4'), ('score', 'f8')])
data = np.array([
('Alice', 20, 89.5),
('Bob', 22, 92.0)
], dtype=dt)
print(f"原始数据:\n{data}")
# 修改单个字段
data['age'] = [21, 23]
print(f"\n修改年龄后:\n{data}")
# 修改特定元素的字段
data[0]['score'] = 95.0
print(f"\n修改 Alice 成绩后:\n{data}")
# 使用条件修改
data['score'][data['name'] == 'Bob'] = 98.0
print(f"\n修改 Bob 成绩后:\n{data}")
添加和删除记录
NumPy 数组大小固定,添加和删除需要创建新数组:
import numpy as np
dt = np.dtype([('name', 'U10'), ('age', 'i4')])
data = np.array([('Alice', 20), ('Bob', 22)], dtype=dt)
# 添加记录:使用 np.concatenate
new_record = np.array([('Charlie', 21)], dtype=dt)
data = np.concatenate([data, new_record])
print(f"添加后:\n{data}")
# 删除记录:使用布尔索引
mask = data['name'] != 'Bob'
data = data[mask]
print(f"\n删除 Bob 后:\n{data}")
高级数据类型
嵌套结构
结构化数组支持嵌套结构:
import numpy as np
# 嵌套结构:地址字段包含多个子字段
address_dtype = np.dtype([
('street', 'U30'),
('city', 'U20'),
('zip', 'U10')
])
person_dtype = np.dtype([
('name', 'U20'),
('age', 'i4'),
('address', address_dtype)
])
data = np.array([
('Alice', 30, ('Main St', 'New York', '10001')),
('Bob', 25, ('Oak Ave', 'Boston', '02101'))
], dtype=person_dtype)
print(f"嵌套结构数组:\n{data}")
# 访问嵌套字段
print(f"\nAlice 的城市: {data[0]['address']['city']}")
print(f"所有城市: {data['address']['city']}")
子数组字段
字段可以是固定大小的数组:
import numpy as np
# 每个元素包含一个 3x3 的矩阵
dt = np.dtype([
('id', 'i4'),
('matrix', 'f8', (3, 3)) # 子数组:3x3 浮点矩阵
])
data = np.array([
(1, [[1, 2, 3], [4, 5, 6], [7, 8, 9]]),
(2, [[9, 8, 7], [6, 5, 4], [3, 2, 1]])
], dtype=dt)
print(f"包含子数组的结构化数组:\n{data}")
# 访问子数组
print(f"\n第一个矩阵:\n{data[0]['matrix']}")
print(f"\n所有矩阵的第一行:\n{data['matrix'][:, 0, :]}")
记录数组(Record Arrays)
记录数组是结构化数组的子类,允许通过属性访问字段:
import numpy as np
# 创建记录数组
dt = np.dtype([('name', 'U10'), ('age', 'i4'), ('score', 'f8')])
data = np.rec.array([
('Alice', 20, 89.5),
('Bob', 22, 92.0)
], dtype=dt)
# 通过属性访问字段
print(f"姓名: {data.name}")
print(f"年龄: {data.age}")
print(f"成绩: {data.score}")
# 属性访问和索引访问可以混用
print(f"\n第一个学生的姓名: {data[0].name}")
# 注意:记录数组会占用更多内存
print(f"\n类型: {type(data)}")
从结构化数组转换为记录数组
import numpy as np
# 先创建结构化数组
dt = np.dtype([('name', 'U10'), ('age', 'i4')])
structured = np.array([('Alice', 20), ('Bob', 22)], dtype=dt)
# 转换为记录数组
records = structured.view(np.recarray)
print(f"记录数组: {records}")
print(f"通过属性访问: {records.name}")
内存布局与性能
内存对齐
默认情况下,NumPy 会紧密排列字段。可以使用 align=True 进行内存对齐:
import numpy as np
# 紧密排列
dt_packed = np.dtype([('a', 'i1'), ('b', 'i8'), ('c', 'i1')])
print(f"紧密排列:")
print(f" itemsize: {dt_packed.itemsize} 字节")
print(f" 字段偏移: {[dt_packed.fields[name][1] for name in dt_packed.names]}")
# 内存对齐(可能提高访问性能)
dt_aligned = np.dtype([('a', 'i1'), ('b', 'i8'), ('c', 'i1')], align=True)
print(f"\n内存对齐:")
print(f" itemsize: {dt_aligned.itemsize} 字节")
print(f" 字段偏移: {[dt_aligned.fields[name][1] for name in dt_aligned.names]}")
字节顺序
可以指定字节顺序(大端或小端):
import numpy as np
# 小端(默认)
dt_little = np.dtype([('value', '<i4')]) # < 表示小端
# 大端
dt_big = np.dtype([('value', '>i4')]) # > 表示大端
print(f"小端: {dt_little}")
print(f"大端: {dt_big}")
实际应用示例
示例1:处理 CSV 格式的数据
import numpy as np
# 模拟从 CSV 读取的数据
csv_data = """name,age,salary,active
Alice,30,50000.0,True
Bob,25,45000.0,False
Charlie,35,60000.0,True"""
# 定义结构化类型
dt = np.dtype([
('name', 'U20'),
('age', 'i4'),
('salary', 'f8'),
('active', '?')
])
# 解析数据(简化版)
lines = csv_data.strip().split('\n')[1:] # 跳过标题
records = []
for line in lines:
parts = line.split(',')
records.append((
parts[0],
int(parts[1]),
float(parts[2]),
parts[3] == 'True'
))
data = np.array(records, dtype=dt)
print(f"CSV 数据:\n{data}")
# 数据分析
print(f"\n平均年龄: {np.mean(data['age']):.1f}")
print(f"平均薪资: {np.mean(data['salary']):.2f}")
print(f"活跃员工: {np.sum(data['active'])} 人")
# 筛选
high_salary = data[data['salary'] > 50000]
print(f"\n高薪员工: {high_salary['name']}")
示例2:科学实验数据
import numpy as np
# 定义实验数据结构
dt = np.dtype([
('timestamp', 'f8'), # 时间戳
('temperature', 'f4'), # 温度
('pressure', 'f4'), # 压力
('status', 'i2') # 状态码
])
# 生成模拟实验数据
n_samples = 100
timestamps = np.linspace(0, 10, n_samples)
temperatures = 25 + 2 * np.sin(timestamps) + np.random.randn(n_samples) * 0.5
pressures = 101.3 + 0.5 * np.cos(timestamps) + np.random.randn(n_samples) * 0.1
statuses = np.random.randint(0, 3, n_samples, dtype='i2')
experiment_data = np.zeros(n_samples, dtype=dt)
experiment_data['timestamp'] = timestamps
experiment_data['temperature'] = temperatures
experiment_data['pressure'] = pressures
experiment_data['status'] = statuses
print(f"实验数据前 5 条:\n{experiment_data[:5]}")
# 统计分析
print(f"\n温度统计:")
print(f" 平均: {np.mean(experiment_data['temperature']):.2f}°C")
print(f" 范围: [{np.min(experiment_data['temperature']):.2f}, {np.max(experiment_data['temperature']):.2f}]°C")
print(f"\n压力统计:")
print(f" 平均: {np.mean(experiment_data['pressure']):.2f} kPa")
print(f" 标准差: {np.std(experiment_data['pressure']):.2f} kPa")
# 筛选异常数据
normal_status = experiment_data[experiment_data['status'] == 0]
print(f"\n正常状态记录数: {len(normal_status)}")
示例3:与 C 结构体交互
结构化数组与 C 结构体有相似的内存布局,可以直接用于与 C 代码交互:
import numpy as np
# 假设 C 结构体定义:
# struct Point {
# int id;
# float x;
# float y;
# float z;
# };
# 创建对应的 NumPy 结构化数组
dt = np.dtype([
('id', 'i4'), # int
('x', 'f4'), # float
('y', 'f4'),
('z', 'f4')
], align=True) # 使用 align=True 匹配 C 的内存对齐
points = np.array([
(1, 1.0, 2.0, 3.0),
(2, 4.0, 5.0, 6.0),
(3, 7.0, 8.0, 9.0)
], dtype=dt)
print(f"点数据:\n{points}")
print(f"\n每个元素大小: {dt.itemsize} 字节")
print(f"数组总大小: {points.nbytes} 字节")
# 可以直接传递给 C 函数(通过 ctypes)
# 这里演示如何获取内存地址
print(f"\n数据指针: {points.ctypes.data}")
示例4:时间序列数据
import numpy as np
# 时间序列数据结构
dt = np.dtype([
('datetime', 'datetime64[us]'), # 微秒精度时间戳
('open', 'f8'),
('high', 'f8'),
('low', 'f8'),
('close', 'f8'),
('volume', 'i8')
])
# 创建股票数据
stock_data = np.array([
('2024-01-01T09:30:00', 100.0, 105.0, 99.0, 103.0, 10000),
('2024-01-01T09:31:00', 103.0, 107.0, 102.0, 106.0, 15000),
('2024-01-01T09:32:00', 106.0, 108.0, 104.0, 105.0, 8000),
], dtype=dt)
print(f"股票数据:\n{stock_data}")
# 计算价格变化
price_change = stock_data['close'] - stock_data['open']
print(f"\n价格变化: {price_change}")
# 计算平均交易量
avg_volume = np.mean(stock_data['volume'])
print(f"平均交易量: {avg_volume:.0f}")
结构化数组辅助函数
NumPy 提供了一些辅助函数来操作结构化数组:
import numpy as np
from numpy.lib import recfunctions as rfn
# 创建示例数据
dt = np.dtype([('name', 'U10'), ('age', 'i4'), ('score', 'f8')])
data = np.array([
('Alice', 20, 89.5),
('Bob', 22, 92.0)
], dtype=dt)
# 添加新字段
new_field = np.array(['A+', 'A'])
data_with_grade = rfn.append_fields(data, 'grade', new_field, dtypes='U2')
print(f"添加字段后:\n{data_with_grade}")
# 删除字段
data_reduced = rfn.drop_fields(data_with_grade, 'grade')
print(f"\n删除字段后:\n{data_reduced}")
# 结构化数组转普通数组
values = rfn.structured_to_unstructured(data[['age', 'score']])
print(f"\n转为普通数组:\n{values}")
# 普通数组转结构化数组
new_data = np.array([[25, 85.0], [30, 90.0]])
structured = rfn.unstructured_to_structured(
new_data,
dtype=np.dtype([('age', 'i4'), ('score', 'f8')])
)
print(f"\n转为结构化数组:\n{structured}")
常见陷阱
字段名冲突
import numpy as np
# 问题:字段名与 ndarray 属性同名
dt = np.dtype([('size', 'i4'), ('shape', 'i4'), ('data', 'i4')])
data = np.zeros(3, dtype=dt)
# 这些字段名会与 ndarray 属性冲突
# data['size'] 实际返回数组的大小,而不是字段值!
print(f"data.size (数组属性): {data.size}") # 3
print(f"data['size'] (字段值): {data['size']}") # [0, 0, 0]
# 解决方案:避免使用 size, shape, dtype, data 等保留名
字符串长度截断
import numpy as np
# 字符串长度不足
dt = np.dtype([('name', 'U5')]) # 最大长度 5
data = np.array([('HelloWorld',)], dtype=dt)
print(f"原字符串: 'HelloWorld'")
print(f"存储结果: '{data[0]['name']}'") # 被截断为 'Hello'
# 解决方案:预估足够大的长度
dt_large = np.dtype([('name', 'U50')])
data_large = np.array([('HelloWorld',)], dtype=dt_large)
print(f"足够长度: '{data_large[0]['name']}'")
类型转换问题
import numpy as np
dt = np.dtype([('value', 'i4')])
data = np.array([(1,), (2,), (3,)], dtype=dt)
# 浮点数会被截断
data['value'] = [1.9, 2.5, 3.7]
print(f"浮点赋值后: {data['value']}") # [1, 2, 3]
# 解决方案:使用正确的数据类型
dt_float = np.dtype([('value', 'f8')])
data_float = np.array([(1.9,), (2.5,), (3.7,)], dtype=dt_float)
print(f"正确类型: {data_float['value']}")
小结
本章介绍了 NumPy 结构化数组的核心功能:
| 功能 | 说明 |
|---|---|
| 创建 | 使用 dtype 定义字段结构 |
| 字段访问 | 通过字段名索引 arr['field'] |
| 多字段访问 | 使用字段名列表 arr[['f1', 'f2']] |
| 嵌套结构 | 支持多层嵌套的结构化类型 |
| 子数组字段 | 字段可以是固定大小的数组 |
| 记录数组 | 通过属性访问字段 arr.field |
| 内存对齐 | align=True 匹配 C 结构体布局 |
| 辅助函数 | numpy.lib.recfunctions 模块 |
结构化数组适合以下场景:
- 处理固定格式的表格数据
- 与 C 代码进行数据交换
- 二进制文件的读写
- 需要高效内存利用的场景
对于更复杂的数据分析需求,建议使用 Pandas 库。
练习
- 创建一个存储学生信息(姓名、学号、3 门课程成绩)的结构化数组,并计算每个学生的平均成绩
- 使用嵌套结构创建一个存储人员信息的数组,包含地址子字段
- 将一个结构化数组转换为普通的二维数组
- 创建一个记录数组,比较通过属性访问和通过索引访问的差异
- 设计一个结构化数组来存储传感器数据,并实现简单的异常检测