跳到主要内容

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 库。

练习

  1. 创建一个存储学生信息(姓名、学号、3 门课程成绩)的结构化数组,并计算每个学生的平均成绩
  2. 使用嵌套结构创建一个存储人员信息的数组,包含地址子字段
  3. 将一个结构化数组转换为普通的二维数组
  4. 创建一个记录数组,比较通过属性访问和通过索引访问的差异
  5. 设计一个结构化数组来存储传感器数据,并实现简单的异常检测