Support N-D array columns when constructing a Dataset from Julia data
PR #54 added Dataset(::AbstractDict) / Dataset(::NamedTuple) but rejects array-valued
(multi-dimensional) columns loudly rather than silently transposing them. This issue
scopes out how to lift that restriction, and pairs naturally with the batch-stacking gap
(item 7 in the internal review). Filing to collect design decisions before implementing.
Python / datasets conventions
Feature types for arrays:
Value(dtype) — scalars
List(feature) / Sequence(feature) — variable-length (or fixed-length) nested lists
Array2D/Array3D/Array4D/Array5D(shape, dtype) — fixed-shape tensors; shape is
Python row-major, first axis may be None for a variable dim
Image / Audio / Video — modality features
Verified empirically against the pinned datasets 4.x:
- Inference —
from_dict with no explicit features infers List(List(Value)) for a
column of 2-D arrays, not Array2D. Rectangular data still works; Array2D only adds
shape/dtype enforcement.
- Orientation — Arrow/numpy are row-major, Julia is column-major. Our
jl2numpy
reverses axes on write ((2,3)→(3,2)), numpy2jl (DLPack) reverses them back; the
package already guarantees numpy2jl(jl2numpy(x)) == x.
- Read format matters:
- python/default format → array cells come back as nested Python lists (no numpy).
This is what our "julia" format uses today, so an array column reads as
Vector{Vector{…}}, never a real Matrix.
- numpy format → array cells come back as numpy
ndarrays, and a range index
stacks rows into an (N, dims…) tensor.
- ragged columns under numpy format → object array (can't stack) → must fall back to
per-row.
- Per-column formatting works —
set_format("numpy", columns=[…], output_all_columns=True)
gives numpy for the array columns and leaves string/scalar columns as Python, so mixed
schemas are fine.
The core problem
Our current "julia" format = python default format + py2jl, so array columns come back
as nested lists (Vector{Vector}), never a real N-D array — and a naive jl2numpy write
path does not line up with that nested read. Supporting N-D requires making write and
read symmetric.
The recipe that works (verified):
- Write — each element via
jl2numpy (axis-reversed numpy); features inferred
List(List) or explicit ArrayND with the reversed shape.
- Read — route array columns through numpy format, then
numpy2jl. The two axis
reversals cancel:
Dataset(d)[i]["x"] == d["x"][i] # single row → real Julia Matrix ✓
Dataset(d)[:]["x"] → (dims…, N) tensor # batch stacks, observation axis LAST (MLUtils convention) ✓
This one mechanism solves N-D tensor columns and batch-stacking together.
Design choices to settle
1. Read semantics under the "julia" format
- R1 — leave as nested lists (
Vector{Vector}); no true N-D.
- R2 — auto-route numeric array columns through per-column numpy format by default. Real
N-D + free stacking, but changes today's Vector{Vector} batch semantics.
- R3 — opt-in, e.g.
with_format(ds, "julia"; stack=true) or a distinct format; default
behavior unchanged, incremental.
2. Write path (constructor)
- W1 — reject N-D (status quo).
- W2 —
jl2numpy each element, infer List(List). Simplest; rectangular round-trips;
ragged allowed but won't stack.
- W3 —
jl2numpy + explicit ArrayND features (reversed shape). Enforces shape/dtype,
reliable stacking; needs exposing Array2D…5D (ties into the Features/ClassLabel
view work).
- W4 — a
features= kwarg for user-supplied schema. Most flexible; larger surface.
3. Input shapes to accept for an array column
Vector{Matrix} (element = one observation), and/or
- a single stacked
(dims…, N) array (last axis = observations) — mirrors exactly what
the read path returns, giving clean symmetry
(Dataset((; x=batch))[:]["x"] == batch).
4. Ragged / non-numeric columns
- Ragged numeric columns can't numpy-stack → fall back to vector-of-arrays on read.
- Only numeric dtypes go through DLPack; strings/mixed stay on the python-default path.
Recommendation
Implement the symmetric write + read recipe (covers N-D construction and batch stacking
in one pass, sharing the axis-reversal logic), accept both input shapes, and gate the
stacking read behind an opt-in first (R3) so current "julia" semantics don't change —
promoting to default (R2) can follow once it's proven. Start with inferred List(List)
features (W2) and add explicit ArrayND (W3) alongside the planned Features view.
Context: follow-up to #54.
Support N-D array columns when constructing a
Datasetfrom Julia dataPR #54 added
Dataset(::AbstractDict)/Dataset(::NamedTuple)but rejects array-valued(multi-dimensional) columns loudly rather than silently transposing them. This issue
scopes out how to lift that restriction, and pairs naturally with the batch-stacking gap
(item 7 in the internal review). Filing to collect design decisions before implementing.
Python /
datasetsconventionsFeature types for arrays:
Value(dtype)— scalarsList(feature)/Sequence(feature)— variable-length (or fixed-length) nested listsArray2D/Array3D/Array4D/Array5D(shape, dtype)— fixed-shape tensors;shapeisPython row-major, first axis may be
Nonefor a variable dimImage/Audio/Video— modality featuresVerified empirically against the pinned
datasets4.x:from_dictwith no explicitfeaturesinfersList(List(Value))for acolumn of 2-D arrays, not
Array2D. Rectangular data still works;Array2Donly addsshape/dtype enforcement.
jl2numpyreverses axes on write (
(2,3)→(3,2)),numpy2jl(DLPack) reverses them back; thepackage already guarantees
numpy2jl(jl2numpy(x)) == x.This is what our
"julia"format uses today, so an array column reads asVector{Vector{…}}, never a realMatrix.ndarrays, and a range indexstacks rows into an
(N, dims…)tensor.per-row.
set_format("numpy", columns=[…], output_all_columns=True)gives numpy for the array columns and leaves string/scalar columns as Python, so mixed
schemas are fine.
The core problem
Our current
"julia"format = python default format +py2jl, so array columns come backas nested lists (
Vector{Vector}), never a real N-D array — and a naivejl2numpywritepath does not line up with that nested read. Supporting N-D requires making write and
read symmetric.
The recipe that works (verified):
jl2numpy(axis-reversed numpy); features inferredList(List)or explicitArrayNDwith the reversed shape.numpy2jl. The two axisreversals cancel:
This one mechanism solves N-D tensor columns and batch-stacking together.
Design choices to settle
1. Read semantics under the
"julia"formatVector{Vector}); no true N-D.N-D + free stacking, but changes today's
Vector{Vector}batch semantics.with_format(ds, "julia"; stack=true)or a distinct format; defaultbehavior unchanged, incremental.
2. Write path (constructor)
jl2numpyeach element, inferList(List). Simplest; rectangular round-trips;ragged allowed but won't stack.
jl2numpy+ explicitArrayNDfeatures (reversed shape). Enforces shape/dtype,reliable stacking; needs exposing
Array2D…5D(ties into theFeatures/ClassLabelview work).
features=kwarg for user-supplied schema. Most flexible; larger surface.3. Input shapes to accept for an array column
Vector{Matrix}(element = one observation), and/or(dims…, N)array (last axis = observations) — mirrors exactly whatthe read path returns, giving clean symmetry
(
Dataset((; x=batch))[:]["x"] == batch).4. Ragged / non-numeric columns
Recommendation
Implement the symmetric write + read recipe (covers N-D construction and batch stacking
in one pass, sharing the axis-reversal logic), accept both input shapes, and gate the
stacking read behind an opt-in first (R3) so current
"julia"semantics don't change —promoting to default (R2) can follow once it's proven. Start with inferred
List(List)features (W2) and add explicit
ArrayND(W3) alongside the plannedFeaturesview.Context: follow-up to #54.