Skip to content

add chunked HDF5 feature storage with a FeatureStore backend - #146

Open
KenyaOtsuka wants to merge 11 commits into
KamitaniLab:devfrom
KenyaOtsuka:refactor/chunked-feature-store
Open

KenyaOtsuka wants to merge 11 commits into
KamitaniLab:devfrom
KenyaOtsuka:refactor/chunked-feature-store

Conversation

@KenyaOtsuka

@KenyaOtsuka KenyaOtsuka commented Sep 29, 2026 •

Copy link
Copy Markdown

Partially addresses #144.

Summary

This PR adds chunked HDF5 storage for Features, enabling partial reads of large DNN features without loading the entire layer into memory.

The existing per-stimulus .mat format remains supported.

Main changes

  • Add a private FeatureStore backend abstraction:
    • MatFeatureStore for the existing .mat layout
    • HDF5FeatureStore for chunked HDF5 storage
  • Add feature_slice support to Features.get() for partial feature reads.
  • Add Features.iter_chunks() for streaming large layers.
  • Add save_features() and FeatureWriter for HDF5 output.
  • Add convert_features_to_hdf5() for migrating existing feature directories.
  • Auto-detect .mat / HDF5 storage per feature directory.
  • Validate the HDF5 schema and label consistency when files are opened.

The HDF5 format uses one file per layer:

features/
    conv5.h5
    fc8.h5

with /features and /labels datasets. Chunk shapes are chosen automatically but can also be configured explicitly.

feature_slice intentionally supports only basic forward indexing (positive-step slices with integer bounds, integers, Ellipsis, and tuples of those). More complex NumPy indexing can be applied after reading.

Compatibility

Existing Features APIs and .mat datasets remain supported. HDF5 storage is opt-in.

DecodedFeatures is not changed in this PR because its additional (subject, roi, fold) metadata requires a different storage schema. Support for DecodedFeatures will be handled separately before closing #144.

Existing feature_index issues are also left unchanged and will be addressed separately.

Testing

Tests cover backend equivalence, partial reads, chunk iteration, schema validation, writing/conversion, and failure/overwrite cases.

No new test failures are introduced by this PR.

HDF5 reads whole chunks even when only a few of their elements are
selected, so the chunk shape is what decides the cost of a partial read.
Add choose_chunk_shape(), which derives a chunk shape from a byte budget
(1 MiB by default, per h5py guidance) instead of hard-coding an extent
along any one axis.

Both axes callers actually slice are chunked: axis 0 (samples) and axis 1
(channels/units). Trailing spatial axes are kept whole -- they are small
and never sliced, and splitting them would only shrink the chunks. The
budget is split evenly between the two chunked axes, with each absorbing
the headroom the other leaves, so a short sample axis does not force a
narrow feature chunk.
…kend

Features was welded to one on-disk layout: a directory per layer holding
one .mat file per stimulus. That layout has a file boundary only on the
sample axis, so reading a few channels still costs a full read of every
stimulus (issue KamitaniLab#144).

Introduce a FeatureStore interface so the layout is a backend detail:

- MatFeatureStore carries today's behaviour unchanged (per-label paths,
  key 'feat', _mat_v73.loadmat_key, the multiprocessing fan-out).
- HDF5FeatureStore reads <layer>.h5 holding /features and /labels, with
  /features explicitly chunked, and pushes feature-axis slices down to
  h5py so only the covered chunks are read. Root attributes bdpy_format
  and bdpy_format_version identify the file and are validated on open.

Features holds one store per dpath and dispatches reads to whichever
store owns each label, so a mix of layouts across dpath entries works and
a future backend needs no change here. The format is auto-detected per
directory and can be forced with format='mat'/'hdf5'.

New read API, identical on both backends:

    features.get(layer, label=..., feature_slice=np.s_[128:256])
    features.iter_chunks(layer, axis=1)

feature_slice is a logical slice rather than the get_chunk() of the
issue, so the HDF5 chunk concept stays out of the public API; on the
legacy backend it loads in full and slices, on HDF5 it is a real partial
read. iter_chunks yields (slice, block) so callers can place results back
without tracking offsets, and defaults to the on-disk chunk extent so
every element is read exactly once.

Existing behaviour is untouched: labels, index, layers, feature_index,
get, get_features, statistic, the single-slot layer cache and
statistics.pkl all behave exactly as before, including caller-order row
selection. Sliced reads deliberately bypass the layer cache, which exists
to hold a whole layer.
Reading the chunked format is no use without a way to produce it, so add
the write side and the migration path:

- save_features() writes a whole layer at once.
- FeatureWriter appends incrementally onto resizable datasets, which is
  what feature extraction needs since it yields one stimulus at a time.
- convert_features_to_hdf5() migrates an existing .mat tree. It reads
  through MatFeatureStore, so the output is by construction what the
  legacy reader sees, and streams in batches so a layer is never held in
  memory in full.

All three are exported from bdpy.dataform. save_feature() is left alone;
it belongs to the separate MATLAB-write deprecation.

Tests cover the part that matters most: every combination of label
selection and feature slicing is run against both backends and compared,
and against ground truth, so the two layouts are indistinguishable
through the Features API -- including out-of-order labels, repeated
labels, fancy feature indices and mixed-layout dpaths. Partial reads are
asserted by spying on the selection handed to h5py, since byte counts are
not portable.

Note on feature_index: its happy path is deliberately left untested. Such
index files are struct .mat files, which _mat_v73.loadmat_key cannot
load (dense arrays only), so the feature has been broken since the
hdf5storage -> h5py read-path change of issue KamitaniLab#106. That regression is
independent of storage backends and is left untouched here rather than
silently changed; the test file records it.
HDF5 allocates whole chunks, so an extent that divides its axis unevenly
pads the edge chunks. For (200, 256, 13, 13) float32 that cost 41.2 MiB
on disk for 33.0 MiB of data.

Shrink each extent to the smallest one needing the same number of chunks.
That can only lower the chunk size, so the byte budget still holds, and
it brings the same file to 34.1 MiB. Covered by a test capping the
overhead at 10%.
…abels

Three review findings on the chunked feature storage, all of which returned
wrong data or lost it silently.

feature_slice + feature_index returned the wrong units. get() sliced the
feature axes first, then applied a unit index that addresses the flattened
FULL feature space to the already-narrowed array: silently the wrong units
when the index fit, IndexError when it did not. Refuse the combination with
ValueError instead of guessing. iter_chunks' existing feature_index guard
moves from RuntimeError to ValueError so the two read the same. The standalone
feature_index defects are untouched and remain out of scope.

A failed conversion published a truncated file. FeatureWriter opened the final
path with mode "w", truncating it up front, and the converter closed the writer
from a finally block, so an error mid-layer left a short file exactly where the
skip-if-exists check would take it for a finished one -- permanently, across
re-runs. Writes now stage in a hidden .partial sibling and publish with a single
os.replace, and close()/abort() split commit from discard, with __exit__ routing
to abort() when the body raises. The converter uses the context manager, so a
failure leaves nothing behind and re-running just works. An overwrite keeps the
old file readable until the swap. save_features and FeatureWriter also stop
clobbering: an existing target raises FileExistsError unless overwrite=True.
Staging opens with mode "x" rather than going through tempfile.mkstemp, so
published files keep umask permissions instead of being tightened to 0600, and
the scratch name is dotted and non-.h5 so a concurrent reader's *.h5 scan never
sees it. A weakref finalizer removes the scratch file if a writer is dropped.

Layers were not checked for agreeing labels. HDF5FeatureStore read labels from
the first layer only and reused that label -> row map against every layer's
/features, so a layer written in a different order returned another stimulus'
rows. Validate at construction that every layer holds the same labels in the
same order, reporting which layer differs and how, matching the invariant
MatFeatureStore already enforces.

Tests cover all three, and were checked to fail against the code before this
commit.
A second review found six defects. Three of them came from one decision --
generalising feature_slice towards full NumPy indexing -- so they are
removed by narrowing the API rather than by teaching it NumPy's index
arithmetic.

feature_slice is now basic forward indexing only: positive-step slices
(negative start/stop included), integers, one Ellipsis, and tuples of
those. Everything else raises ValueError on both backends. h5py handles
exactly the allowed set with NumPy's semantics but rejects a negative
step, so anything wider either makes the layouts disagree or needs NumPy
reimplemented to keep them together. np.s_[::-1] used to work on .mat and
raise inside h5py; now both refuse it the same way. bool is rejected
before the integer check, since isinstance(True, int) is True in Python.

iter_chunks loses its feature_slice parameter. Composing an arbitrary
index with the per-slab slice is the same arithmetic problem, and it was
wrong: iter_chunks(feature_slice=np.s_[:-1], size=5) returned 46 feature
columns where the slice itself has 19, and an integer feature_slice
raised IndexError. Slice the blocks as they come out instead. This
deletes _compose_slice, _compose_two_slices, _sliced_shape and _is_fancy
outright.

Duplicate labels are now invalid. Labels map to row indices once, so
["a", "a"] resolved both rows to the last one and silently dropped the
first. The legacy layout cannot express duplicates -- the file name is
the label -- so rejecting them keeps the layouts equivalent. Refused on
read, in save_features, and in FeatureWriter both within one extend()
call and across earlier calls, checked before writing so a refused batch
is a no-op.

Schema validation is consolidated and tightened: the version attribute
must be present, integral and in 1..SUPPORTED_FORMAT_VERSION (missing and
zero were accepted before, contradicting the docs), /features and /labels
must have the expected rank, and their row counts must agree.

Format detection keys off the files that are actually present instead of
the bare existence of a subdirectory, so a stray directory no longer
flips a chunked tree to .mat. A directory holding both layouts now raises
instead of silently choosing.

save_features becomes a thin wrapper over FeatureWriter. Writing a whole
layer is the degenerate case of writing it incrementally, and keeping two
write paths is what let a zero-sample layer succeed through one and fail
through the other. The unified path costs nothing measurable: the same
layer is 34.08 MiB with identical chunks; only the sample axis becomes
extensible.

Also: drop the layer attribute and the layer= arguments, since the file
name carries it and nothing validated the attribute; rename _GROW_BLOCK
to _LABELS_CHUNK to match what it does; de-duplicate _load_array_with_key
and _determine_num_parallel; and state the multi-directory limit of
iter_chunks in its docstring instead of claiming one slab unconditionally.

choose_chunk_shape now rejects empty feature axes and a negative sample
axis, and clamps a zero-sample layer to a chunk of at least 1, which HDF5
requires. Its budget is documented as an upper bound only where the
budget is reachable, which is not so when the trailing axes alone exceed
it. Its tests assert invariants and no longer pin chunk values, padding
ratios or budget usage -- those are a performance policy that should stay
free to change.

Every added test was run against the code before this commit to confirm
it fails there.
A third review found two ways to get a wrong result without an error, plus
validation that was looser than documented.

convert_features_to_hdf5(batch_size=-1) published an empty layer as a
finished one. range(0, n, -1) is empty, so the loop wrote no samples and
close() then committed that file; the converter's skip-if-exists check
would treat it as complete on every later run. batch_size is now checked
before anything is opened.

iter_chunks validated its arguments only on the path that goes through
FeatureStore. Labels spread across several dpath entries take a fallback
in Features instead, where size=-1 silently yielded nothing and an
out-of-range axis raised IndexError rather than ValueError. The checks
move to Features.iter_chunks, ahead of the dispatch, so both paths reject
the same inputs the same way; a negative axis is normalised there too.

Validation is tightened where it was weaker than claimed:

- bdpy_format_version is required to be an integer scalar rather than
  coerced with int(), which let 1.5 round down into a supported version.
  bool is rejected explicitly, being a subclass of int.
- feature_slice now checks slice components, not just the sign of the
  step: start, stop and step must each be None or an integer, so
  slice(1.5, 3) fails with ValueError instead of reaching NumPy or h5py.

The FeatureSlice alias contained a "ellipsis" forward reference, which is
not a Python name, so typing.get_type_hints() raised NameError on any
annotated member. It now uses builtins.ellipsis under TYPE_CHECKING and
type(Ellipsis) at runtime (types.EllipsisType is 3.10+, the floor here is
3.8), and includes np.integer to match what is accepted.

Two documentation mismatches: the feature_slice docstrings still
advertised array-like indexing, which is rejected, and the docs claimed
np.newaxis raises when a bare np.newaxis is None and so reads as "no
slice"; only a newaxis inside a tuple is refused.

Finally, a test-cleanup miss from the previous commit: an assertion in
test_feature_store.py still pinned "trailing axes are kept whole", a
heuristic detail that was supposed to stop being fixed, while its name
promised a check on the feature axis that it never made. The chunk-policy
assertion moves to test_feature_chunking.py against a representative
large shape, where both sliceable axes really are split -- that one is
issue KamitaniLab#144's core promise, not tuning. The n_samples_known=False test now
compares two unknown-size calls that differ only in the current sample
count, which is the contract itself rather than a particular extent.

Every added test was run against the code before this commit; 18 of them
fail there.
The feature_slice docstrings on FeatureStore.read and Features.get still
said numpy.newaxis raises ValueError. It does not: numpy.newaxis *is*
None, which is the "no slice" default, so a bare newaxis reads the whole
feature tensor and only a newaxis inside a tuple is refused. The Markdown
docs already said this; now all three agree, and both docstrings also
mention the non-integer slice bound check.

tests/dataform/test_feature_hdf5.py imported SUPPORTED_FORMAT_VERSION
twice. Ruff never caught it because pyproject excludes tests from linting;
an AST sweep over tests/dataform confirms this was the only duplicate.

No behaviour change: 349 passed either way.
@KenyaOtsuka
KenyaOtsuka marked this pull request as ready for review September 29, 2026 09:27
@KenyaOtsuka

KenyaOtsuka commented Sep 29, 2026 •

Copy link
Copy Markdown
Author

I also benchmarked the storage design from two perspectives:
(A) the new HDF5 backend against the legacy per-stimulus .mat layout, and
(B) the default chunk policy within HDF5.

A. Legacy .mat vs HDF5

Ratios below are .mat time / HDF5 time, so values above 1 mean HDF5 is faster.

shape 1 channel 8 channels sample-wise full layer streaming
conv1_1 54.5× 33.6× 7.1× 3.8× 13.6×
conv3_4 140.8× 129.5× 1.7× 9.3× 16.4×
conv4_4 130.8× 145.3× 1.4× 4.2× 11.2×
conv5_4 154.0× 105.6× 1.7× 5.8× 9.5×
(1200,256,13,13) 81.5× 88.4× 1.7× 11.6× 13.6×
flat (6000,4096) 153.0× 174.3× 0.74× 10.1× 28.1×
flat (3000,100000) 1788.8× 1325.9× 0.14× 5.8× 17.1×

The streaming column was re-measured after the .mat iter_chunks()
implementation was finalized, using Features.iter_chunks() directly for both
backends. The legacy .mat backend now loads the selected features once and
then slices them in memory instead of rereading the same stimulus files for
every slab.

The main benefit is the intended one: feature-wise partial reads are much faster
than with the legacy layout, while full-layer reads and streaming are also
faster. The main trade-off is sample-wise access to very large flat features,
where the legacy one-file-per-sample layout remains faster.

B. Chunk policy

For a representative (1200, 256, 13, 13) float32 array, using each policy's
natural chunk-aligned iter_chunks(axis=1):

policy chunk shape streaming 200 sample-wise reads full layer
balanced (39,37,13,13) 0.229 s 0.038 s 0.164 s
channel-prioritized (1200,1,13,13) 0.105 s 6.002 s 0.155 s
sample-prioritized (6,256,13,13) 0.177 s 0.022 s 0.162 s
capped channels (8) (172,8,13,13) 0.210 s 0.619 s 0.248 s

The trade-off becomes clearer for partial reads on other feature shapes:

shape / access balanced channel-prioritized sample-prioritized
conv5_4, 1-channel read 1.536 s 0.172 s 26.460 s
conv5_4, sample-wise 0.100 s 3.161 s 0.015 s
conv3_4, 1-channel read 0.535 s 0.221 s 6.434 s
conv3_4, sample-wise 0.298 s 1.755 s 0.180 s
flat (6000,4096), 1-channel read 0.412 s 0.035 s 4.226 s
flat (6000,4096), sample-wise 0.007 s 2.830 s 0.004 s
flat (3000,100000), 1-channel read 0.023 s 0.007 s 49.275 s
flat (3000,100000), sample-wise 1.571 s 73.666 s 0.020 s

The specialized policies are much faster for the access pattern they target,
but can be substantially slower for the opposite pattern. The balanced policy
avoids those large regressions while still providing efficient feature-wise
streaming, so it is used as the default. Explicit chunk shapes remain available
for workloads with a known access pattern.

…slab

FeatureStore.iter_chunks called read() for every slab, and MatFeatureStore.read
loads every selected stimulus file. A k-slab feature-axis iteration therefore
re-read the whole layer k times: 1088 s for conv3_4 (100, 256, 56, 56) against
5 s for one read plus in-memory slicing. The legacy layout has no file boundary
on the feature axes, so the per-slab read bought nothing.

Split the feature-axis branch of the loop into _iter_feature_slabs, which gets
the slab slices the base class has already computed, and override it in
MatFeatureStore to read the selection once and cut the slabs out of it. Axis
and size validation, the slab boundaries and the sample-axis branch all stay in
one place, so the two backends cannot drift apart on semantics. The slices are
handed over as a generator rather than a list, so iterating a large axis at
size 1 does not first materialize one slice object per slab.

Sample-axis iteration is deliberately untouched: there each slab reads only its
own files, which is the one case where the legacy layout can stream a layer
larger than memory. Nothing is kept on the store, so this is a per-call
strategy and not a cache. The slabs are views into the selection, exactly as
they were when read separately, so holding a slab still holds the array.

Iterating an empty axis now returns before reading anything, matching the
previous behaviour where the loop body simply never ran.
@KenyaOtsuka

Copy link
Copy Markdown
Author

Sorry for the additional update after marking the PR as ready for review. It should now be fully ready for review.

The arithmetic behind iter_chunks lived inline next to the reads, and was
written more than once: axis normalisation in both Features.iter_chunks
and FeatureStore.iter_chunks (with different messages), slab
partitioning in the store and again in the multi-directory fallback, and
the "apply sl on axis" index three ways. feature_slice validation was
left to each backend's read(), and the .mat backend ran it only after
loading every file. Testing any of this meant going through files, which
is why the iteration test was a 36-case product over both backends.

Move the arithmetic into small pure functions -- _normalize_axis,
_check_size, _partition, _axis_index, _default_iter_size and
_iter_array_slabs -- and use them on every path; the
FeatureStore._default_iter_size method they replace is removed. Make
FeatureStore.read a template method that validates feature_slice and
then calls the backend's _read, so feature_slice validation happens
once, before the backend reads anything, on every backend.

Two behaviour changes:
- With size=None, the multi-directory fallback now splits by the same
  byte budget as a store without on-disk chunking, instead of yielding
  the whole axis as one slab. The data is unchanged; statistic() reads
  through get() and is unaffected.
- An out-of-range axis is reported as the caller passed it (axis -9 for
  a 4-D selection) rather than after normalisation (it used to say -5).

Tests: the helpers are checked in memory (TestIterationHelpers),
including the table of rejected feature_slice specs against the
validator. I/O tests keep only what each backend owns: cutting the right
slab (one feature-axis case per axis with a partial last slab and
repeated labels, replacing the product), rejecting a bad feature_slice
before its _read runs, and not overriding read(). The fallback's default
size is checked with the budget patched small enough to need several
slabs.

TestIterChunks tests that the new ones subsume are dropped:
test_yielded_slices_address_the_right_block and test_restricts_to_labels
(the per-axis ground-truth test),
test_slices_cover_the_axis_without_overlap (_partition) and
test_rejects_bad_axis (test_validation_precedes_selection_read);
test_reassembles_to_a_full_read keeps only the sample axis, which no
other test iterates on both backends.

@HirokiYasuda03 HirokiYasuda03 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly LGTM! Thank you!
I left one minor comment. Please check it

Comment thread bdpy/dataform/feature_hdf5.py Outdated

def _promote(tmp_path: str, path: str) -> None:
"""Move the finished scratch file into place (atomic on one filesystem)."""
os.replace(tmp_path, path)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

overwrite=False is only enforced in __init__; close() then calls os.replace, which overwrites an existing target without any check. If another process (or save_features) publishes conv5.h5 while this writer is still staging, the writer silently replaces it.

Suggestion: when overwrite=False, publish with os.link(tmp, path) and remove the temp file afterwards. os.link raises FileExistsError if the target exists, so the check and the publish become one atomic step.

overwrite=False was enforced only when the writer was created; close()
then published with os.replace, which replaces whatever is at the target.
If another process (or save_features) published the same path while this
writer was still staging, it was silently overwritten.

Without overwrite, publish with os.link, which fails if the target
exists, so checking for it and publishing become one atomic step; the
scratch file is removed afterwards. With overwrite, keep os.replace. On a
filesystem that cannot hard-link (EPERM, ENOTSUP/EOPNOTSUPP, ENOSYS),
fall back to checking right before the rename -- a short window remains
there, as documented, but no longer the whole duration of the write.

When close() finds the target taken, the writer discards its scratch
file, is left aborted, and raises FileExistsError. convert_features_to_hdf5
treats that as its existing skip-if-exists case: a layer another process
published meanwhile is skipped, and the next layer is converted.
@HirokiYasuda03

Copy link
Copy Markdown
Contributor

LGTM! Tnank you

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants