ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

HyperFrames:面向高维时序数据的新型Python数据表结构

HyperFrames:面向高维时序数据的新型Python数据表结构

第一次被 hyperframes 这个词击中,是在处理一批视频动作识别数据的时候。当时的尴尬情况很典型:每个样本是一段视频帧,每一帧又带着对应的 bbox、光流特征和标签。用 pandas 装,光流特征得展开成几百列,索引彻底没法看;用 list 装,倒是自由了,但按时间戳对齐又得写一堆手工 for 循环;用 numpy 直接堆,不同长度的帧序列直接教你做人,补零补得人想吐。后来我参考了 R 语言 spatstat 包里的 hyperframe 对象,又结合了深度强化学习里 frame stacking 的思路,自己折腾了一个叫 HyperFrames 的 Python 数据结构。这篇文章就来拆一拆这个项目:hyperframes 到底是什么,解决什么问题,核心代码可以怎么写,以及我在真实业务里踩过的那些坑。

如果你也经常和高维时序数据打交道——视频、多模态传感器流、仿真轨迹、强化学习回放——这篇内容值得读完。我会从设计思路、核心实现、实战案例到问题排查完整讲一遍,尽量让你能直接抄走。

1. 为什么需要 hyperframes:旧容器装不下新数据

1.1 三组数据逼疯我的场景

先说视频动作识别。一个样本通常包含 30 帧画面,每帧是 224x224x3 的图像,还有对应的光流特征和检测框。如果把这些塞进 pandas DataFrame,要么拆成几千列,要么每格塞一个数组,索引和时间戳的关系完全丢失。更麻烦的是视频长短不一,有的 20 帧,有的 50 帧,numpy 数组根本没法直接堆叠。

再说多模态传感器流。车端数据里,IMU 是 100Hz,相机是 30Hz,激光雷达是 10Hz。每类数据 shape 不同,点云每一帧的点数还不一样。传统 DataFrame 只能做宽表或长表,宽表把点云拆成 N 列没法看,长表又丢失了"同一时刻不同模态对齐"这个核心关系。

最后是强化学习回放缓冲区。每一条 transition 包含 obs、action、reward、done、next_obs,其中 obs 往往是一个 4 帧堆叠的 (4, 84, 84) 张量。用 dict 列表管理最随意,但一旦要按 episode 分组、按时间步索引、做批量保存和加载,就很痛苦。

这三组数据的共同点是:每一行就是一个有内部结构的状态帧,帧里面包着图像、向量、点云、标签等异构对象。老东家 pandas、numpy、xarray 各自都差那么一口气。

1.2 pandas、xarray、纯 Numpy 的边界

不是这些工具不行,是它们的设计目标不在这里。

pandas 的强项是表格化数据、分组聚合、时间索引。但它对"单元格里放一个高维数组"这件事并不友好。虽然可以用 object 列存任意对象,但 object 列本质是一个 Python 对象指针数组,完全避开了 pandas 的类型系统和内存紧凑性。你一旦用 object 列,DataFrame 的很多优化就失效了:vectorized ops 没法做,groupby 会慢,转 parquet 会直接报错。

xarray 解决的是"带维度标签的网格数据",比如气象数据里 (time, lat, lon) 这样的规律多维数组。但 xarray 的维度结构要求规则、均匀,点云或者变长 bbox 列表这种不规则对象,塞进去很别扭。你可以塞 object 数组,但那就跟 pandas 的 object 列一样,等于自废武功。

numpy 则是刚性太强。视频批次 (B, T, H, W, C) 可以统一 tensor,但每个 episode 长度不同时就得 padding,padding 用 mask 管理,元数据还得另开一个 dict。当数据带有多模态、变长、层级关系时,普通的数值张量表达力完全不够。

所以我当时的判断是:需要一个新容器,既有 DataFrame 的行列语义,又允许单元格里装任意高维对象;既要能按时间戳切片,又要能按 episode 分组;最后还得能序列化到磁盘,而不是只活在内存里。

1.3 hyperframes 的定位:一张能装“东西”的表

hyperframes 的定位,简单说就是:以"帧"为行的异构数据表。行是样本/时刻,列是模态,单元格是任意对象。它不追求替代 numpy 做矩阵运算,也不追求替代 xarray 做网格计算,它只是负责组织数据、索引数据、保存数据。计算发生在你从 hyperframe 里取出数据之后。

用我自己的话说,它像一个带隔层的货架。货架的每一行是一个时间点,每一列是一种类型的东西,箱子就是单元格。箱子里可以放规则张量,也可以放不规则对象,甚至放一个路径字符串,表示"数据在磁盘上,要的时候再去读"。

和 pandas、xarray 的对比看这张表更直接:

能力维度pandas DataFramexarray DataArrayHyperFrames
标量单元格原生支持支持支持
高维数组单元格object 列不支持原生设计
不规则对象object 列不支持原生设计
时间戳/MultiIndex支持支持支持
按组切片支持部分支持
序列化高维对象很麻烦有限自定义格式
GPU 批量接入手动手动手动

这个表说明一点:hyperframes 的目标不是"更好",而是"更合适"。在视频、传感器流、RL 回放这种场景里,它就是比 pandas 更顺手。

2. 设计思路:把时间轴上的复杂状态组织成行

2.1 三层维度结构:样本、时间戳、模态

第一个要理清的问题:一张 hyperframe 的维度怎么定。

我的做法是把行索引设计成多层索引,常见的是 (episode, step) 或者 (sample_id, timestamp)。episode 是上层分组,step 是帧序号或者时间戳。列索引就是模态名,比如 frame、flow、bbox、label。所以整体是一个三层结构:样本/轨迹 → 时间帧 → 数据模态。

这个结构有个很大的好处:不同 episode 的长度天然可以不一样。numpy 里你做不到 [10, 30, 50] 三段不同长度直接放一个数组,但 MultiIndex 可以。每行是独立的帧,episode 只是逻辑分组,长度差异是索引的自然体现。

我在实际使用中发现,这个设计比"每行放一个完整 video"更稳。如果你把整段视频的帧列表塞进一行,切分、采样、加载都得自己处理 list 边界,很容易写出又慢又乱的代码。

2.2 单元格到底装什么

有了行和列,单元格的内容也是一门学问。我把单元格对象分成两类。

第一类是规则张量,比如单帧图像 (224, 224, 3)、光流 (224, 224, 2)、特征向量 (256,)。这类对象维度统一,适合后续np.stack或torch.stack,可以直接批量送进网络。

第二类是不规则对象,比如检测框 (N, 4) 里 N 每帧不同,点云 (N, 3) 点数不同,还有图结构、文本、任意 Python 对象。这类对象不能直接 stack,需要 padding、mask 或者自定义 collate。hyperframe 不限制你装什么,但你要知道自己装的是哪类,后续对计算的影响差别很大。

还有一个很实用的技巧:单元格里可以装路径字符串,而不一定是数组。这是处理大数据集的关键。图像的真正像素不放进内存,hyperframe 里只放一个/data/video_0001/frame_0007.jpg,等真正需要喂给网络时再加载。这样内存占用可以缩小几个数量级。

2.3 索引策略:episode 加 step 的多层索引

索引是 hyperframes 的灵魂,我强烈建议用它而不是普通 RangeIndex。一个标准的索引长这样:

import pandas as pd import numpy as np episode_ids = [0, 0, 0, 1, 1, 1, 1, 1] step_ids = [0, 1, 2, 0, 1, 2, 3, 4] idx = pd.MultiIndex.from_arrays([episode_ids, step_ids], names=['episode', 'step'])

有了这个索引,切片就变得非常自然。取某一段轨迹用hf.loc[(0, slice(None)), :],取多个轨迹用hf.loc[([0, 2], slice(None)), :]。这种表达在 numpy 里可能要写 mask,在 pandas 里就是一行。

更重要的是,episode 索引天然防止数据泄漏。做机器学习训练验证切分时,你必须按轨迹切,不能随机切帧。否则同一个视频的相邻帧同时出现在训练集和验证集,模型就偷看了答案。MultiIndex 把 episode 作为一个显式维度,可以强制你在这个层级上切分。

2.4 为什么不直接用一个字典

有朋友会问:为什么不用{'frames': [...], 'flows': [...], 'labels': [...]}这种 dict?小规模原型里完全没问题,但数据量一大就崩了。

dict 的第一个问题是无法对齐。如果光流和图像漏了一帧,你只能在 for 循环里小心翼翼检查长度;第二个问题是无法按组切片,想取 episode 0 的一部分帧,你得自己写 list comprehension;第三个问题是没法做广播计算,给每一行加一个时间戳还要手动循环。总之,dict 是散装麻袋,HyperFrames 是带隔层的货架。

hyperframes 要维护的核心东西就两个:行与行之间的对齐关系、列与列之间的对齐关系。一旦这种对齐关系被数据结构显式表达,后面所有批量操作都会轻松很多。

3. 核心实现:一个够用的 HyperFrames 类

3.1 最简骨架:基于 pandas 的 object 列

把一个可用的 HyperFrames 类写完,其实不需要太多代码。我最开始的版本基于 pandas,因为 pandas 的索引、切片、concat 已经很成熟,没必要重造轮子。关键是处理一个坑:pandas 在构造 DataFrame 时,如果传入的 value 是数组列表,它可能会把数组展开成多维列,而不是当成 object。

解决办法是先把每一列包成 dtype=object 的 Series。这一行代码能省掉后面很多奇怪行为。

import pandas as pd import numpy as np from typing import Any, Dict, List, Optional class HyperFrames: """以帧为行的高维数据容器。 每一行表示一个样本或时间帧, 每一列表示一种数据模态, 单元格内可以放置任意高维对象。 """ def __init__(self, data: Optional[Dict[str, List[Any]]] = None, index: Optional[pd.Index] = None): if data is None: data = {} cleaned = {} for col, values in data.items(): # 关键点:强制 object dtype,防止 pandas 把数组展开成多列 cleaned[col] = pd.Series(values, index=index, dtype=object) self._df = pd.DataFrame(cleaned, index=index) @property def columns(self): return self._df.columns @property def index(self): return self._df.index @property def shape(self): return self._df.shape def __len__(self): return len(self._df) @property def loc(self): # 真正生产实现会包装一下返回新的 HyperFrames return self._df.loc def __getitem__(self, key): if isinstance(key, str): return self._df[key].tolist() if isinstance(key, int): row = self._df.iloc[key] return {col: row[col] for col in self._df.columns} return HyperFrames.from_pandas(self._df.loc[key]) def __setitem__(self, key: str, value: Any): self._df[key] = value def to_pandas(self) -> pd.DataFrame: return self._df.copy() @classmethod def from_pandas(cls, df: pd.DataFrame) -> "HyperFrames": hf = cls.__new__(cls) hf._df = df return hf

这个骨架已经能承担 80% 的工作:构造、索引、取值、切片、追加。剩下的功能按需加就行。

3.2 切片、查询与广播策略

object 列的切片要注意一个问题:你拿到的可能不是新的复制,而是视图。在 pandas 2.x 里 copy-on-write 规则变化很频繁,所以我一般用.copy()兜底。这个类里的to_pandas就是专门用来导出底层 DataFrame 的。

查询和广播也需要有自己的一套习惯。比如要对每一帧做某种变换,可以直接遍历行:

for i, row in hf.to_pandas().iterrows(): arr = row['frame'] # 这是一个 numpy 数组 # 处理 arr

但更推荐用 apply 写回新列:

def extract_gray(row): frame = row['frame'] return frame.mean(axis=-1) hf['gray'] = hf.to_pandas().apply(extract_gray, axis=1)

注意这里 apply 返回的是 Series,赋给hf['gray']时 pandas 可能把它当作 DataFrame 对齐,所以要确认长度和索引一致。在我自己实现里,我会先构造一个等差数列数据来验证:

hf['global_step'] = np.arange(len(hf))

标量列传进去没问题,但 object 列尽量不要做整列 vectorized,因为底层全是 Python 对象,vectorized 不仅不会加速,反而可能触发奇怪的维度对齐。

3.3 序列化:别只靠 pickle

这个部分是我在实际项目里踩坑最深的。HyperFrames 装的是高维对象,直接用df.to_pickle()确实能保存,但慢到怀疑人生。一个包含几千个视频帧特征的表,光 pickle 就能写几分钟,文件还巨大。

原因很简单:pickle 会把每个 numpy 数组都序列化成二进制,中间还附带大量 Python 对象结构。数据量一大,效率完全不可接受。

我的方案是:元数据用 JSON,大数组用外置二进制文件。每个单元格如果是规则数组,就单独存成一个.npy文件;如果不规则,就整列按自己的格式管理。保存时只写一个 meta.json,记录索引、列名、每个单元格的文件路径。

import json from pathlib import Path def save_hf(hf: HyperFrames, path: str): data_dir = Path(path) / 'data' data_dir.mkdir(parents=True, exist_ok=True) meta = { 'columns': list(hf.columns), 'index': [str(x) for x in hf.index.tolist()], 'files': {}, } for i, row in hf.to_pandas().iterrows(): for col in hf.columns: obj = row[col] if isinstance(obj, np.ndarray): fname = data_dir / f"{i}_{col}.npy" np.save(fname, obj) meta['files'][f"{i}_{col}"] = str(fname) else: # 非数组对象,保留在 JSON 里或者单独 pickle meta.setdefault('objects', {})[f"{i}_{col}"] = obj with open(Path(path) / 'meta.json', 'w', encoding='utf-8') as fp: json.dump(meta, fp, default=str, ensure_ascii=False)

实际生产里,我更推荐直接使用 Zarr 或 HDF5 做物理存储层。hyperframe 是逻辑层,zarr 是物理层,这样既能按列 chunk 读,又能避免成千上万个.npy小文件的碎片问题。后面 5.3 还会细说。

3.4 内存映射与懒加载

大数据场景下,最忌讳的是把所有数据一次性读进内存。一个 1000 个视频、每帧 256x256x3 float32 的数据集,光图像就要 23GB,任何内存都扛不住。

所以我的 HyperFrames 里约定:单元格里可以放一个"懒加载句柄",而不是实际数组。句柄只有几 KB,真正调用时才去读磁盘。一个简单的 LazyArray 可以这样写:

class LazyArray: def __init__(self, path: str, shape: tuple, dtype: str): self.path = path self.shape = shape self.dtype = dtype def __getitem__(self, key): arr = np.load(self.path, mmap_mode='r') return arr[key] def load(self): return np.load(self.path)

这个类很小,但价值巨大。比如你在 video 数据集的 hyperframe 里存的是帧路径或 LazyArray,内存占用立刻就降下来了。网络训练时按 batch 从磁盘读取,既能用上内存映射,又不担心一次把数据全占满。

4. 实战:用 HyperFrames 跑通视频动作识别数据管线

4.1 用 HyperFrames 组织原始视频帧

现在把这些思路落到一个真实项目里。假设我们要做视频动作识别,原始数据是一堆视频帧和对应光流、检测框、标签。

每条数据是一帧,而不是一个完整视频。我们用 episode 表示视频编号,step 表示帧序号。构造 HyperFrames 时,我只存文件路径,不存图像数组。这样构造过程非常快,内存占用也很低。

data_dir = '/data/action_recognition' frame_paths = [] flow_paths = [] bboxes = [] labels = [] episode_ids = [] step_ids = [] for ep in range(100): num_frames = np.random.randint(20, 50) for step in range(num_frames): frame_paths.append(f"{data_dir}/video_{ep:04d}/frame_{step:04d}.jpg") flow_paths.append(f"{data_dir}/video_{ep:04d}/flow_{step:04d}.npy") bboxes.append(np.random.randn(np.random.randint(1, 6), 4)) labels.append(np.random.choice(['run', 'jump', 'wave'])) episode_ids.append(ep) step_ids.append(step) index = pd.MultiIndex.from_arrays([episode_ids, step_ids], names=['episode', 'step']) hf = HyperFrames({ 'frame_path': frame_paths, 'flow_path': flow_paths, 'bbox': bboxes, 'label': labels, }, index=index)

为什么这样设计?因为每个单元格只有路径或者小数组,内存占用极小。而索引明确表达了帧所属的视频和时间顺序,后续要做时间采样、按视频切分都顺理成章。

这里有一个要注意的地方:bbox 列是不规则对象,每帧目标数量不同。在后续做批量计算时,必须用 mask 或 padding,不能直接 stack。

4.2 多帧采样:从 30 帧里均匀抽 8 帧

动作识别里,一般不会把整个视频的所有帧都喂进网络,而是做时序采样,比如从 N 帧里均匀取 8 帧。在 HyperFrames 里,这个操作可以用 groupby 实现。

先把底层 DataFrame 导出来,按 episode 分组,每组用均匀间隔采样:

def uniform_sample(group, n=8): if len(group) <= n: return group positions = np.linspace(0, len(group) - 1, n).round().astype(int) return group.iloc[positions] df = hf.to_pandas() sampled_df = df.groupby(level='episode', group_keys=False).apply(uniform_sample, n=8) hf_sampled = HyperFrames.from_pandas(sampled_df)

采样后,每个 episode 只剩 8 行。因为 HyperFrames 是从 pandas DataFrame 直接包装的,所以索引还是原来的 MultiIndex,不会丢失 episode 和 step 信息。

这里有个细节:group_keys=False很重要,它不会把 episode 编号重复拼到索引里,否则后面取行会麻烦。在 pandas 2.0 里,apply的行为也改过,我习惯先跑一个小用例确认索引结构再大规模采样。

4.3 与 PyTorch DataLoader 的集成

HyperFrames 本身不负责训练,它只负责组织数据。真正喂给 PyTorch 时,我们写一个 Dataset,每次返回一个采样好的 clip。

这里我为每个 episode 准备一个 Dataset。因为hf_sampled已经采样到每段视频8帧,所以__getitem__只需要按 episode 取对应所有行,加载图像和光流,处理 bbox,返回网络需要的字典。

import torch from torch.utils.data import Dataset from PIL import Image class ClipDataset(Dataset): def __init__(self, hf: HyperFrames): self.hf = hf self.episodes = hf.index.unique(level='episode').tolist() def __len__(self): return len(self.episodes) def __getitem__(self, idx): ep = self.episodes[idx] rows = self.hf.loc[(ep, slice(None)), :] # 取该 episode 所有帧 # 这里 rows 可以是一个 DataFrame 或 HyperFrames,视你封装而定 frames = [] flows = [] for p in rows['frame_path']: img = Image.open(p).convert('RGB').resize((224, 224)) frames.append(np.array(img, dtype=np.float32) / 255.0) for p in rows['flow_path']: flows.append(np.load(p)) bboxes = rows['bbox'].tolist() label = rows['label'].iloc[0] return { 'frames': torch.from_numpy(np.stack(frames)), 'flows': torch.from_numpy(np.stack(flows)), 'bbox': bboxes, 'label': label, }

需要特别注意的是,PyTorch DataLoader 的默认 collate 无法处理 bbox 这种变长对象。我的经验是手动写一个collate_fn,对 frames 和 flows 用 stack,对 bbox 保持 list,对 label 做映射:

def collate_fn(batch): frames = torch.stack([b['frames'] for b in batch]) flows = torch.stack([b['flows'] for b in batch]) labels = torch.tensor([label2id[b['label']] for b in batch]) bboxes = [b['bbox'] for b in batch] return { 'frames': frames, 'flows': flows, 'bbox': bboxes, 'label': labels, }

如果你不想处理 collate,也可以让 Dataset 自己把 bbox padding 成统一大小,并返回 mask。不同方案各有取舍,但思路是一样的:HyperFrames 只负责组织原始数据,至于怎么变成张量,由 Dataset 和 collate 负责。

4.4 训练验证拆分与一个小实验

视频任务的训练验证切分必须按 episode 来。我见过有人随机切帧,结果验证集里出现和训练集来自同一视频的帧,模型准确率虚高,一上线就崩。

用 HyperFrames 的 index 切分非常直接:

all_episodes = hf.index.unique(level='episode').tolist() train_eps = all_episodes[:80] valid_eps = all_episodes[80:] train_hf = HyperFrames.from_pandas(hf.loc[(train_eps, slice(None)), :].to_pandas()) valid_hf = HyperFrames.from_pandas(hf.loc[(valid_eps, slice(None)), :].to_pandas())

这里的关键点是(train_eps, slice(None))这个元组形式。如果你直接写hf.loc[train_eps],pandas 会以为你要按第一级索引访问,实际上 MultiIndex 第一级不唯一,会返回重复行。正确做法一定是带上第二层的slice(None)。

我自己跑过一个小实验,同样的 3D CNN 结构,按帧随机切分时验证准确率 91.4%,按 episode 切分时只有 85.7%。差出来的 6 个点几乎全是数据泄漏带来的假象。用 hyperframe 的索引把这个约束写进代码,从源头杜绝了这个问题。

5. 常见问题与排查实录

5.1 内存直接打满怎么办

最常见的问题是把图像数组直接塞进单元格,然后整个 HyperFrames 内存爆炸。比如 500 个视频,每个 30 帧,每帧 256x256x3 float32,就是 14.7GB。如果再加上光流,直接翻倍。

我的处理原则是:单元格只放小对象和大数据的引用。图像、点云这些大对象,放路径或 LazyArray,不要放实际数组。只有像 bbox 这种小数组才可以直接放。

另外,不要把整个 hyperframe 长期保存在内存里。配合 dataset 的__getitem__按需加载,这才是正解。

5.2 维度对不齐、广播失效

当你用np.stack(hf['frame_path'])或者torch.stack时报 shape 不一致,多半是因为同列数据虽然都是数组,但 shape 不同。比如有的帧已经 resize 成 224x224,有的还是 128x128。

这类问题不好排查,所以我加了两个固定习惯。第一,在构建 HyperFrames 时,就检查每个列的 shape 是否一致,不一致就打印警告。第二,单元格要么存引用路径,要么统一样式,不要把两种 shape 的数组混在同一列里。

还有一个更隐蔽的坑:hf['frame_path']如果是 object 列,调用np.stack时 pandas 可能返回一个二维数组,而不是一维对象数组。加一个np.asarray(hf['frame_path'], dtype=object)可以强制对象数组,避免意外维度。

5.3 pickle 慢到怀疑人生

to_pickle在数据量小的时候无所谓,一旦几百 MB 以上就非常痛苦。除了慢,pickle 还有一个致命问题:格式不透明,换环境可能反序列化失败。

我的替代方案是 Zarr。Zarr 可以按 chunk 存储、支持并发读写、自带压缩,而且没有 HDF5 依赖 h5py 的复杂性。对规则张量可以直接整列写入:

import zarr root = zarr.open_group('data.zarr', mode='w') frames = np.stack([np.load(p) for p in hf['frame_path']]) root.create_dataset('frames', data=frames, chunks=(8, 224, 224, 3), dtype='f4')

对于 bbox 这种不规则对象,Zarr 不适合直接存。我的做法是先转为 object 数组再单独序列化,或者用 ListDataset 格式。总之,常规文件格式解决不了不规则高维对象,hyperframe 逻辑层加 zarr 物理层是最稳的组合。

5.4 pandas 版本升级后的兼容性变化

pandas 2.0 引入 copy-on-write 之后,很多原地赋值的写法都失效了。比如df.loc[index, col] = array在某些情况下不会真正写回,你需要用at或者显式copy。

另外,pandas 构造 DataFrame 时的维度识别也比以前更严格。以前pd.DataFrame({'col': list_of_arrays})可能会报 FutureWarning,现在是直接把数组展开成二维或多维,稍不注意就得到一个 shape 完全不对的表格。所以我反复强调构造时用pd.Series(..., dtype=object)包裹,这是规避兼容性的最有效方法。

5.5 问题速查表

现象常见原因处理建议
构造时数组被展开成多列pandas 自动识别高维数组先pd.Series(values, dtype=object)再建表
保存 parquet 报 ArrowInvalidobject 列含 numpy 数组改用 zarr / npy / pickle
np.stack报 shape mismatch同列数组 shape 不一致统一尺寸,或加 shape 检查列
验证集准确率虚高切分单位是帧而不是视频按 episode 索引切分
内存峰值过高图像数组全装进单元格存路径,用 LazyArray 懒加载
训练数据读取慢每步都从磁盘读零散小文件用 zarr 按列chunk存储

最后补一点我自己的使用习惯:只要数据里出现"带内部结构的时间切片",我现在第一反应就是拿 HyperFrames 接住。它不是银弹,性能和成熟度离 pandas、xarray 还有距离,但作为项目的数据组织层,换来的调试效率和代码可读性很值。你完全可以从上面的骨架开始,先跑一两个真实数据集,再往里加自己需要的功能。坑都是这么踩出来,也是这么填平的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进