Use a copy-on-write workspace

WarningContent review status

This page contains unreviewed content created with large language model (LLM) tools.

This chapter creates a copy-on-write workspace over the dataset from part one and writes a quantized copy of one BOLD image to that workspace. The base dataset remains unchanged. The following chapters inspect the write’s lineage, export it as W3C PROV, and discard the workspace while retaining an audit record.

1 Before you start

Open the ds000114.bits dataset created in part one with workspace operations enabled:

from bitsio import BITSZarrDataset

ds = BITSZarrDataset.open("ds000114.bits", read_only=False)
print(len(ds.query(suffix="bold")), "BOLD runs")
4 BOLD runs

2 Open a workspace

A workspace is an isolated overlay over a dataset or another workspace. Open one named "int8-bold", then obtain a writable view of that overlay:

from tensorbids.exceptions import WorkspaceOpenError

WORKSPACE_NAME = "int8-bold"
ds.open_workspace(WORKSPACE_NAME)
exp = ds.at(WORKSPACE_NAME, read_only=False)
print([marker.name for marker in ds.workspaces().markers])
try:
    ds.write_table("split/other", ds.split("folds"))
except WorkspaceOpenError as error:
    print("refused:", type(error).__name__)
['int8-bold']
refused: WorkspaceOpenError

ds continues to address the base dataset, while exp reads the workspace overlay before falling back to the base. While a workspace is open, writes to its parent are rejected with WorkspaceOpenError. This restriction allows a later commit or discard to verify that the parent has not changed since the workspace was opened.

NoteWorkspace identity

The workspace identifier is derived from its name and the recorded state of its parent. A workspace ends by committing its changes or discarding them.

3 Write a run as int8 codes

Read subject 01’s BOLD run and write a quantized version whose stored codes span the full int8 range:

from dataclasses import dataclass

import numpy as np
from tensorbids.authoring import AffineQuantization
from tensorbids.provenance.ingest_record import IngestSource


@dataclass
class Volume:
    """Values to write, with their affine; a 4-D run is time first."""

    data: np.ndarray
    affine: np.ndarray

    @property
    def shape(self):
        return self.data.shape


(source,) = exp.query(subject="01", session="test", suffix="bold")
(image,) = exp.read(target_path=source.target_path, form="object")
bold = np.moveaxis(image.get_fdata(dtype="float32"), -1, 0)
volume = Volume(bold, image.affine)
rung = AffineQuantization.spanning(bold.min(), bold.max(), "int8")
target = "sub-01/ses-test/func/sub-01_ses-test_task-fingerfootlips_desc-int8_bold.nii"
exp.write_volume(
    volume,
    target_path=target,
    geometry=exp.default_geometry(volume, target, dtype="int8"),
    precision=rung,
    ingest_record=IngestSource(source_ref=source.target_path),
)
exp.index()
print(f"scale {rung.scale:.4f}, offset {rung.offset:.4f}")
(written,) = exp.read(target_path=target, form="tensor")
print("largest error", float(np.abs(written - bold).max()), "<=", rung.scale / 2)
(nifti,) = exp.read(target_path=target, form="object")
print(nifti.shape == image.shape, np.allclose(nifti.affine, image.affine))
scale 16.7647, offset 2145.8824
largest error 8.35302734375 <= 8.382352941176471
True True

AffineQuantization.spanning() derives a linear scale and offset from the observed value range. Quantization introduces an error of at most half a scale step, so the navigator’s fidelity contract is lossy. IngestSource records the source BOLD path in the lineage of the new stored array.

WarningA 4-D volume is written time first

write_volume() expects a four-dimensional volume in (time, i, j, k) order and restores NIfTI’s time-last order when materializing the file. Move the time axis explicitly before writing, as shown above; the API cannot infer an incorrectly ordered input array.

NoteNIfTI scaling comparison

The BITS tensor store contains the int8 codes directly. The reconstructed NIfTI object exposes dequantized float32 values; it is not an int8 NIfTI using scl_slope and scl_inter. The record-set API can return the codes and their scale parameters separately.

4 Train on the codes

Select the stored port and attach the scale and offset instead of dequantizing during the read:

import torch
from tensorbids.query import BIDSReference

codes = exp.records(
    BIDSReference(suffix="bold", desc="int8", port="stored", dequantize="attach")
)
data = torch.utils.data.DataLoader(codes.loader(batch_size=1, stack=True), batch_size=None)
batch = next(iter(data))
q = batch["bold"]
print(q.dtype, tuple(q.shape))
print(batch["bold.scale"].tolist() == [rung.scale], batch["bold.offset"].tolist() == [rung.offset])
values = q.double() * batch["bold.scale"].view(-1, 1, 1, 1, 1) + batch["bold.offset"].view(-1, 1, 1, 1, 1)
print(np.allclose(values[0].numpy(), written, rtol=1e-5))
torch.int8 (1, 184, 64, 64, 30)
True True
True

The batch contains int8 codes plus one scale and offset per record. Apply the affine transformation on the target device or inside the model. The result matches the resolver’s dequantized float32 read to numerical precision.

Lazy spatial windows work with stored codes in the same way as in part one:

windows.py
def window(record):
    """A random 32 x 32 x 16 crop of every frame of the run's codes."""
    return {"bold": record["bold"].random_window(i=32, j=32, k=16)}
from windows import window

crops = codes.select(window, seed=0).loader(batch_size=1, stack=True)
batch = next(iter(torch.utils.data.DataLoader(crops, batch_size=None)))
print(batch["bold"].dtype, tuple(batch["bold"].shape), batch["bold.scale"].tolist() == [rung.scale])
torch.int8 (1, 184, 32, 32, 16) True

A window retains the scale and offset of its source image because the affine dequantization is independent for each value.

WarningA workspace’s records stay in process

Record sets backed by a workspace view cannot currently be serialized to PyTorch worker processes. Use the default num_workers=0 for these loaders.

NoteMONAI distinction

Quantization is part of the stored representation, not a MONAI transform. The loader returns the stored codes and their scaling parameters.