TorchLean

2.3.Β Data and LoadersπŸ”—

A model type tells Lean the shape of one input and one output. A dataset must eventually provide values of exactly those shapes, but real data begins in a less orderly form: rows in a CSV file, arrays in an NPY file, text tokens, simulator output, or tensors generated on demand.

TorchLean treats loading as a boundary. Parsing and dimension checks happen before a value becomes a typed training sample. Once the boundary succeeds, the training loop does not need to ask on every step whether a row had the right number of columns.

It helps to follow one row through that boundary. Two feature columns and one target column become a pair of tensors with shapes [2] and [1]. A loader may then join five such pairs into one item with shapes [5, 2] and [5, 1]. Neither the file's row count nor the batch size changes what one feature means. Keeping source rows, dataset items, and model axes separate is what lets the same training loop consume an in-memory table or a checked file.

Named Lean blocks and their outputs are checked when this page is built. Shell and Python blocks are reproduction instructions or recorded transcripts. Comparing the same twenty-five rows through three file boundaries checks this example; agreement of printed losses alone does not establish identical tensor bits or parser equivalence.

The Dataset TypeπŸ”—

The trainer-facing type is:

-- The two shape parameters describe one input item and one
-- target item.
Trainer.Dataset input target

Its shape parameters describe one training item. The scalar type is chosen later, through the structure's single field:

-- Materialization chooses the scalar type and returns a -- stream through IO. @Trainer.Dataset.materialize : {input target : Shape} β†’ Trainer.Dataset input target β†’ {Ξ± : Type} β†’ [inst : Storage Ξ±] β†’ [Context Ξ±] β†’ [Runtime.FromFloat Ξ±] β†’ IO (Data.SampleStream (Sample.Supervised Ξ± input target))#check @Trainer.Dataset.materialize
@Trainer.Dataset.materialize : {input target : Shape} β†’
  Trainer.Dataset input target β†’
    {Ξ± : Type} β†’
      [inst : Storage Ξ±] β†’
        [Context Ξ±] β†’ [Runtime.FromFloat Ξ±] β†’ IO (Data.SampleStream (Sample.Supervised Ξ± input target))

Read that signature from the inside out. A dataset is a function waiting for a scalar type. It returns samples in IO, because opening a file, parsing values, and reporting a malformed row all belong there. The scalar type Ξ± stays universally quantified, so one dataset value serves a native Float32 run and a FloatLib binary32 run without being rebuilt. Materialization chooses one arithmetic semantics for every numeric input and target in that run; it is not a per-column dtype schema.

The same host-Float data source can therefore feed several arithmetic implementations through one materialization interface. A proof-level real tensor cannot enter this IO training loop: Runtime.FromFloat Ξ± has no instance for ℝ.

The signature also separates constructing a dataset from asking it for data. A value of Trainer.Dataset input target records how materialization should happen. Calling materialize supplies the scalar instances and runs the action; the resulting stream then supplies individual samples by index. Some constructors can create those samples lazily. Materializing a dataset therefore does not universally mean allocating an array containing every tensor in advance, although a file parser may need to read its source before returning the stream.

For tensors already in memory, the public constructor shows how the sample shapes are inferred:

-- The shared leading n pairs every input row with exactly -- one target row. @Data.fromTensors : {n : β„•} β†’ {Οƒ Ο„ : Shape} β†’ Tensor Float (Οƒ.prependDim n) β†’ Tensor Float (Ο„.prependDim n) β†’ Trainer.Dataset Οƒ Ο„#check @Data.fromTensors
@Data.fromTensors : {n : β„•} β†’
  {Οƒ Ο„ : Shape} β†’ Tensor Float (Οƒ.prependDim n) β†’ Tensor Float (Ο„.prependDim n) β†’ Trainer.Dataset Οƒ Ο„

The common dimension n is the number of samples. It is removed from the dataset's input and target types, so the caller does not repeat [2] and [1] after those shapes are already known from tensors of shape [4, 2] and [4, 1]. Because n appears in both arguments, a pair of tensors whose sample counts disagree is rejected before any file is read.

In the materialization signature, Storage Ξ± concerns how a sample's scalars are represented, Context Ξ± supplies the operations available to a generated sample, and Runtime.FromFloat Ξ± handles conversion from host-authored numeric data. None of those arguments changes the sample shapes. This distinction is useful when comparing arithmetic modes: a change in scalar representation should not also silently change the number of features, target layout, or selection of rows.

Data Validation StagesπŸ”—

Data failures are easier to diagnose when each fact is tied to the boundary where it becomes known:

Stage

What is known

Typical failure

source description

paths, format, requested dimensions, tokenizer or preprocessing choice

unsupported format or incomplete configuration

parsing

bytes or text can be decoded into values

missing file, malformed number, corrupt header

shape validation

element count and sample dimensions agree

short row, mismatched leading counts, invalid token id

arithmetic materialization

numeric values are converted to the selected runtime arithmetic, possibly with rounding

unsupported source dtype; narrowing can change values without a diagnostic

batching

each emitted item has the model's input and target shape

final partial batch under a fixed-size policy

training

the runner receives typed samples

numerical failure, unsupported operation, or backend rejection

The runs later in this chapter illustrate file, parsing, shape, dtype, and layout failures:

data_csv: missing CSV dataset: NN/Examples/Data/small_regression.csv
TabularSupervisedSource.load: csv row 2, col 2: unparsed suffix: oops
TabularSupervisedSource.load: csv: expected 1 values for shape [1], got 0
data_npy: npy: unsupported dtype: <i4
SupervisedSource.load: npy: prefix row loading requires C-order NPY arrays
data_npy: X.npy: expected shape (N,2), got #[25, 1]

A width check can accept a row with the right number of fields even if the feature columns have been exchanged. The loader needs a declared column order, and the data producer must use that order.

Sample Shapes In MemoryπŸ”—

The XOR table makes the distinction between a stored tensor and a training item explicit. Four rows share one input tensor and one target tensor:

-- Keep all four XOR inputs and their targets in matching -- leading-row order. def dlXs : Tensor Float [4, 2] := [[0.0, 0.0], [0.0, 1.0], [1.0, 0.0], [1.0, 1.0]] def dlYs : Tensor Float [4, 1] := [[0.0], [1.0], [1.0], [0.0]] def dlXorData := Data.fromTensors dlXs dlYs dlXorData : Trainer.Dataset [2] [1]#check dlXorData
dlXorData : Trainer.Dataset [2] [1]

No annotation was written on dlXorData. The leading dimension of dlXs and dlYs is the sample count, Data.fromTensors checks that both counts agree, and the item shapes are what remain:

-- fromTensors removes the shared sample-count axis from the
-- shape of each item.
whole input tensor    [4, 2]
one input sample         [2]

whole target tensor   [4, 1]
one target sample        [1]

Nothing has been read from the dataset yet. Asking for the samples means choosing a scalar type and running the IO action, which is exactly what the trainer does on your behalf:

-- Materialize at Float, then read the second paired sample -- using zero-based indexing. samples = 4 input = [0.000000, 1.000000] target = [1.000000] #eval show IO Unit from do let stream ← dlXorData.materialize (Ξ± := Float) IO.println s!"samples = {stream.size}" match stream.get? 1 with | some sample => IO.println s!"input = {sample.input}" IO.println s!"target = {sample.target}" | none => IO.println "index 1 is out of range"
samples = 4
input  = [0.000000, 1.000000]
target = [1.000000]

get? 1 returns the second XOR row, [0, 1], with its target [1]. The none branch handles a missing stream position; a returned sample already has the dimensions promised by the dataset.

This is the same role played by PyTorch's TensorDataset, whose indexing returns the pair of tensors for one sample (Paszke et al., 2019):

>>> # Select the second input row together with its
>>> # corresponding target.
>>> import torch
>>> from torch.utils.data import TensorDataset, DataLoader, random_split
>>> xs = torch.tensor([[0., 0.], [0., 1.], [1., 0.], [1., 1.]])
>>> ys = torch.tensor([[0.], [1.], [1.], [0.]])
>>> ds = TensorDataset(xs, ys)
>>> print("len(ds) =", len(ds), "sample 1 =", [t.tolist() for t in ds[1]])
len(ds) = 4 sample 1 = [[0.0, 1.0], [1.0]]

The values agree. The difference is where the sample shape lives: PyTorch keeps it in the tensors and TorchLean also keeps it in the type, so Trainer.Dataset [2] [1] cannot be handed to a model that expects three features.

Sample counts that disagree are rejected while the module elaborates, before any training code exists (deΒ Moura and Ullrich, 2021):

-- Three target rows cannot be paired with four input rows -- by this constructor. def dlYsShort : Tensor Float [3, 1] := [[0.0], [1.0], [1.0]] def dlMismatched := Data.fromTensors dlXs Application type mismatch: The argument dlYsShort has type Tensor Float [3, 1] but is expected to have type Tensor Float (Shape.prependDim ?m.6 4) in the application Data.fromTensors dlXs dlYsShortdlYsShort
Application type mismatch: The argument
  dlYsShort
has type
  Tensor Float [3, 1]
but is expected to have type
  Tensor Float (Shape.prependDim ?m.6 4)
in the application
  Data.fromTensors dlXs dlYsShort

The metavariable in that message is the unsolved sample shape. Lean already committed to a sample count of 4 from the first argument, so the second tensor cannot supply three rows no matter what its item shape turns out to be. If the mismatch instead arrives from runtime files, the loader reports it as an IO error when materializing the dataset; the shape constraint is checked later.

Use the smallest constructor that matches where the samples come from:

  • Data.fromTensors inputs targets splits two batched tensors along the sample axis;

  • Data.fromSamples values wraps an in-memory array of concrete Float samples;

  • Data.fromSample sample wraps one concrete Float sample, which is enough to smoke-test a model;

  • Data.defer action materializes one concrete Float sample from an IO action;

  • Data.generate builder constructs samples directly in the arithmetic runtime selected by the trainer.

All of them return the same Trainer.Dataset input target type. The separate names describe the data boundary, not different dataset representations. Writing the samples out by hand looks like this:

-- Each record owns its input-target pair before the records -- are collected into a dataset. def dlPair : Trainer.Dataset [2] [1] := Data.fromSamples #[{ input := [0.0, 1.0], target := [1.0] }, { input := [1.0, 1.0], target := [0.0] }] samples = 2 #eval show IO Unit from do let stream ← dlPair.materialize (Ξ± := Float) IO.println s!"samples = {stream.size}"
samples = 2

The count is two because the array contains two supervised records. Each record keeps its input and target together, which prevents shuffling from independently reordering the two sides. Choosing Data.fromSamples is useful when labels are computed one record at a time or samples already arrive as pairs. Data.fromTensors instead starts with two stacked tensors and uses their common leading index to construct those same pairs. Both routes eventually expose the same per-item interface to the trainer.

The Data.defer variant is the one to reach for when the sample lives in a file. Note that the file is read when the trainer materializes the dataset, not while the module elaborates, so defining this dataset does not read the file:

-- Delay file access until materialization while retaining
-- the required input shape.
def dlOneSample (path : System.FilePath) : Trainer.Dataset [2] [1] :=
  Data.defer do
    let input : Tensor Float [2] ← Tensor.load path
    pure { input := input, target := [3.75] }

Held-out data is a dataset operation rather than a training-loop operation, and it is deterministic given its seed:

-- A seeded split assigns three of the four paired items to -- training. def dlSplit : Data.DatasetSplit [2] [1] := Data.randomSplit 3 dlXorData (seed := 17) train = 3, test = 1 #eval show IO Unit from do let train ← dlSplit.train.materialize (Ξ± := Float) let test ← dlSplit.test.materialize (Ξ± := Float) IO.println s!"train = {train.size}, test = {test.size}"
train = 3, test = 1

torch.utils.data.random_split plays the same role and takes the sizes of every part rather than the size of the first part:

>>> # Request three training samples and one held-out sample
>>> # from the same dataset.
>>> tr, te = random_split(ds, [3, 1],
...     generator=torch.Generator().manual_seed(17))
>>> print("train =", len(tr), "test =", len(te))
train = 3 test = 1

A single held-out sample is useful for a plumbing check but gives little information about generalization. The seeded split is reproducible only if materializations return the same ordered samples: train and test each materialize the source separately. For a changing source, capture one snapshot before splitting. Statistical evaluation and formal certificates and bounds answer different questions.

Loading A Regression TableπŸ”—

The data-generation script writes a 25-row regression table with columns x1,x2,y, along with equivalent NPY arrays:

Recorded output
$ python3 NN/Examples/Data/generate_small_data.py
wrote NN/Examples/Data/small_regression.csv rows=25
wrote NN/Examples/Data/small_regression_X.npy shape=(25, 2) dtype=float32
wrote NN/Examples/Data/small_regression_y.npy shape=(25, 1) dtype=float32

The rows are a deterministic 5 by 5 grid over [-1,1]^2 with targets y = 0.7x_1 - 0.4x_2 + 0.5x_1x_2. This fixed target lets us compare loading paths on a controlled regression problem:

Recorded output
$ lake exe torchlean data_csv --device cpu --batch 5 --steps 5 --seed 2026
== CSV loader training tutorial ==
model:
Sequential: [5, 2] -> [5, 1], layers=3, params=33, state=33
  [0] Linear(2, 8): [5, 2] -> [5, 8] params=24, state=24 [[8, 2], [8]]
  [1] ReLU: [5, 8] -> [5, 8] params=0, state=0 []
  [2] Linear(8, 1): [5, 8] -> [5, 1] params=9, state=9 [[1, 8], [1]]
data_dir = NN/Examples/Data
csv_path  = NN/Examples/Data/small_regression.csv
seed      = 2026
train     = Adam(lr=0.05), steps=5, batch_size=5, shuffle=true, drop_last=true
dataset size = 5
mean_loss(before training) = 0.210192
mean_loss(after training) = 0.055578
steps=5 arithmetic=native scalar=Float32 loss=0.210192 -> 0.055578
predict(batch=heldout) = [[0.303611], [0.303611], [0.303611], [0.303611], [0.303611]]

dataset size = 5 counts materialized minibatches: twenty-five source rows become five items, each with input shape [5, 2] and target shape [5, 1]. The prediction contains five identical rows because the probe Tensor.full [5, 2] 0.25 repeats the same point across the batch axis. This particular model requires that axis in its input type.

The loss is reduced over each batch item and the reported dataset mean is taken over the five items. Because every item here contains five rows, those groups have equal weight in the report. That interpretation depends on the full-batch policy: an arbitrary collection of unequal-sized items would need an explicit choice between averaging item losses and weighting by their sample counts. The printed batch_size=5 and drop_last=true tell us which grouping produced this transcript.

The loader that produced that run is six lines of NN/Examples/Data/Loaders/Csv.lean:

-- CSV widths describe the columns per row; batchSize
-- determines the leading tensor axis.
let csvOptions : Data.CsvOptions := { skipHeader := true }
let data :=
  Data.fromCsv csvPath batchSize inputWidth outputWidth
    (csvOptions := csvOptions) (shuffle := true) (seed := seed)
Data.requireFile exeName "CSV dataset" csvPath missingCsvHint
let trained ← trainer.train data { steps := steps }

The two width arguments say how many columns belong to the input and how many to the target, and a separate batch-size argument precedes those widths and becomes part of the resulting type:

-- The result already contains a batch axis in both input -- and target shapes. @Data.fromCsv : System.FilePath β†’ (batchSize inputWidth targetWidth : β„•) β†’ optParam Data.CsvOptions { } β†’ optParam Bool true β†’ optParam β„• 0 β†’ Trainer.Dataset [batchSize, inputWidth] [batchSize, targetWidth]#check @Data.fromCsv
@Data.fromCsv : System.FilePath β†’
  (batchSize inputWidth targetWidth : β„•) β†’
    optParam Data.CsvOptions { } β†’
      optParam Bool true β†’ optParam β„• 0 β†’ Trainer.Dataset [batchSize, inputWidth] [batchSize, targetWidth]

Nothing about column meaning is inferred from the file. A header line is skipped only because skipHeader := true says so, and the remaining parsing choices are the fields of Data.CsvOptions:

Field

Default

Meaning

delimiter

','

cell separator

skipHeader

false

drop the first line before parsing rows

trimCells

true

trim ASCII whitespace around cells and rows

allowEmptyLines

true

ignore blank lines instead of failing on them

Two of those defaults are deliberately permissive and one is deliberately strict. Trimming and blank-line tolerance absorb the harmless variation that text editors and spreadsheet exports introduce. Header skipping is off by default because guessing that the first row is a header would silently drop a real sample from a headerless file, and a silently dropped sample is much harder to notice than a parse error.

File And Schema ErrorsπŸ”—

A missing file, an invalid numeric cell, and a short row fail at different stages. A missing file is caught before parsing:

Recorded output
$ lake exe torchlean data_csv --csv /tmp/no-such-data.csv
...
train     = Adam(lr=0.05), steps=30, batch_size=5, shuffle=true, drop_last=true
error: data_csv: missing CSV dataset: /tmp/no-such-data.csv
Generate the small regression CSV with:
  python3 NN/Examples/Data/generate_small_data.py

The program stops at Data.requireFile, with a hint naming the script that produces the file.

Now corrupt one cell. Replacing the value -0.5 in the second data row with the text oops gives:

Recorded output
error: TabularSupervisedSource.load: csv row 2, col 2: unparsed suffix: oops

Deleting that value instead, so the row has two fields where three are expected, gives a different message from a later stage:

Recorded output
error: TabularSupervisedSource.load: csv: expected 1 values for shape [1], got 0

The loader splits the row at the declared input width of two. Both remaining fields are consumed as input, leaving no field for the target, so validation reports a missing target value. The message omits the row index; locating the malformed row therefore requires another inspection of the file.

NumPy rejects both files as well, and its width message is the better one:

>>> # A malformed row and a missing file fail at different
>>> # boundaries.
>>> np.loadtxt("short_row.csv", delimiter=",", skiprows=1, dtype=np.float32)
ValueError: the number of columns changed from 3 to 2 at row 2; use `usecols` to
select a subset and avoid this error
>>> np.loadtxt("does_not_exist.csv", delimiter=",")
FileNotFoundError

The short row contains valid numbers but lacks a target. Repairing numeric parsing would not fix it, just as changing the expected shape would not turn oops into a number. Distinct diagnostics tell us which part of the file to repair.

NPY And Other Numeric SourcesπŸ”—

NPY preserves numeric dtype and array dimensions, so it is a cleaner boundary than CSV for numeric tensors that are already prepared. A single tensor loads directly; dataset records describe paired training data:

Artifact

Public boundary

one numeric tensor

Tensor.load

paired input and target tensors

Data.SupervisedSource

input tensors and integer labels

Data.LabeledSource

one CSV with input and target columns

Data.TabularSupervisedSource

The expected result type supplies both the scalar type and the dimensions, which is why Tensor.load takes no shape argument:

-- The expected tensor type supplies the shape that the -- loaded payload must satisfy. @Tensor.load : {Ξ± : Type} β†’ [inst : Storage Ξ±] β†’ [Runtime.FromFloat Ξ±] β†’ {shape : Shape} β†’ System.FilePath β†’ optParam Data.CsvOptions { } β†’ IO (Tensor Ξ± shape)#check @Tensor.load
@Tensor.load : {Ξ± : Type} β†’
  [inst : Storage Ξ±] β†’
    [Runtime.FromFloat Ξ±] β†’ {shape : Shape} β†’ System.FilePath β†’ optParam Data.CsvOptions { } β†’ IO (Tensor Ξ± shape)

So a caller writes the shape once, in the type it wants back:

-- Accept this file only if its payload can be loaded as the
-- declared two-by-three tensor.
def dlMatrix : IO (Tensor Float [2, 3]) :=
  Tensor.load "data/matrix.npy"

The extension decides the parser, and the file metadata is checked against shape before the tensor is returned. Paired training data uses a source record instead, which keeps the paths, the sample count, and the per-sample dimensions in one value:

-- A file source carries the expected sample count and -- per-sample shapes into materialization. @Data.SupervisedSource.fromFiles : System.FilePath β†’ System.FilePath β†’ β„• β†’ Shape β†’ Shape β†’ optParam Data.CsvOptions { } β†’ Data.SupervisedSource#check @Data.SupervisedSource.fromFiles
@Data.SupervisedSource.fromFiles : System.FilePath β†’
  System.FilePath β†’ β„• β†’ Shape β†’ Shape β†’ optParam Data.CsvOptions { } β†’ Data.SupervisedSource

Named arguments distinguish the two paths, the sample count, and the two sample shapes:

-- Describe the two file roles and their common sample count
-- before loading their values.
def dlSource : Data.SupervisedSource :=
  Data.SupervisedSource.fromFiles
    (inputPath := "data/x.npy") (targetPath := "data/y.npy")
    (sampleCount := 25) (input := [2]) (target := [1])

def dlNpyData : Trainer.Dataset [2] [1] :=
  Data.fromSupervisedSource dlSource

Data.fromSupervisedSource turns the source into a trainer dataset, and the two companion constructors cover the other shapes of supervised file data: Data.fromLabeledSource one-hot encodes checked integer labels, and Data.fromCsv splits each row at the declared input width.

CSV, NPY, And PyTorch Data ComparisonπŸ”—

The generator wrote the same twenty-five samples as a CSV table and as a pair of NPY arrays. With matching training settings, the NPY run reproduces the displayed CSV losses and prediction:

Recorded output
$ lake exe torchlean data_npy --device cpu --steps 5 --seed 2026
== NPY loader training tutorial ==
model:
Sequential: [5, 2] -> [5, 1], layers=3, params=33, state=33
  [0] Linear(2, 8): [5, 2] -> [5, 8] params=24, state=24 [[8, 2], [8]]
  [1] ReLU: [5, 8] -> [5, 8] params=0, state=0 []
  [2] Linear(8, 1): [5, 8] -> [5, 1] params=9, state=9 [[1, 8], [1]]
data_dir = NN/Examples/Data
x_path   = NN/Examples/Data/small_regression_X.npy
y_path   = NN/Examples/Data/small_regression_y.npy
seed     = 2026
train    = Adam(lr=0.05), steps=5, batch_size=5, shuffle=true, drop_last=true
X.npy dtype=<f4 shape=#[25, 2]
y.npy dtype=<f4 shape=#[25, 1]
dataset size = 5
mean_loss(before training) = 0.210192
mean_loss(after training) = 0.055578
steps=5 arithmetic=native scalar=Float32 loss=0.210192 -> 0.055578
predict(batch=heldout) = [[0.303611], [0.303611], [0.303611], [0.303611], [0.303611]]

The losses and prediction agree to the printed precision. Converting this input file to float64 also preserves the run's displayed values: its entries started as float32 and survive the round trip through the wider format (Goldberg, 1991).

The three input files used from here to the end of this section are not in the repository. Two use a dtype or memory layout that the loader does not support; the third is a widened copy of the input. The following commands build all three from the generated float32 array:

Terminal
# Keep the values from the original binary32 array while
# writing alternate NumPy storage dtypes.
python3 - <<'PY'
import numpy as np
X = np.load("NN/Examples/Data/small_regression_X.npy")
np.save("NN/Examples/Data/X_f64.npy", X.astype(np.float64))
np.save("NN/Examples/Data/X_i32.npy", X.astype(np.int32))
np.save("NN/Examples/Data/X_fortran.npy", np.asfortranarray(X))
PY

The loader takes --x exactly as written and does not join it to data_dir, so these paths are spelled out in full:

Recorded output
$ lake exe torchlean data_npy --steps 5 --seed 2026 \
    --x NN/Examples/Data/X_f64.npy
...
steps=5 arithmetic=native scalar=Float32 loss=0.210192 -> 0.055578

The loader does not issue a diagnostic for this narrowing. A float64 source can be accepted even when conversion to float32 changes its values, so successful dtype validation does not establish lossless conversion. The header records the source representation; scalar=Float32 records the arithmetic used for training.

For this widened copy, the source header changes but the original binary32 values remain representable on conversion back. That explains the matching printed run without requiring all binary64 inputs to behave the same way. A newly generated binary64 dataset could contain values between adjacent binary32 numbers; materialization would then choose representable values in the runtime scalar. The file dtype, conversion, and arithmetic label describe three successive steps in the value's path from disk to a loss computation.

We can also keep the file and select a different arithmetic at materialization:

The following transcript predates the FloatLib migration and retains its recorded scalar labels and numerical results. Current .ieee execution uses FloatLib binary32.

Recorded output
$ lake exe torchlean data_csv --batch 5 --steps 5 --seed 2026 --arithmetic ieee
...
steps=5 arithmetic=ieee scalar=IEEE32Exec loss=0.210192 -> 0.055578

The printed losses agree here. The chapter on floating-point semantics examines the rounding behavior behind that comparison.

NPY Header And Shape ErrorsπŸ”—

Three ways of feeding the NPY loader something structurally wrong, and the three messages they produce:

Recorded output
$ lake exe torchlean data_npy --x NN/Examples/Data/small_regression_y.npy
error: data_npy: X.npy: expected shape (N,2), got #[25, 1]

$ lake exe torchlean data_npy --x NN/Examples/Data/X_i32.npy
error: data_npy: npy: unsupported dtype: <i4

$ lake exe torchlean data_npy --x NN/Examples/Data/X_fortran.npy
error: SupervisedSource.load: npy: prefix row loading requires C-order NPY arrays

The first is a shape check, the second a dtype check, and the third a memory-order check. Saving the result of numpy.asfortranarray sets the NPY header's fortran_order field to true. TorchLean's prefix loader computes contiguous offsets for leading-axis slices, which requires C-order storage. It rejects Fortran-order arrays at this boundary. Use numpy.save(path, numpy.ascontiguousarray(array)) to make the saved array C-order; numpy.save can preserve Fortran order when given a Fortran-contiguous array.

For .pt, .pth, .npz, image folders, and specialized scientific containers, convert the data to NPY or CSV in Python. The Lean loader then checks the resulting artifact's supported dtype and shape. It cannot check that the converter selected the intended fields or preprocessing.

Next-Token DatasetsπŸ”—

Language models need a vocabulary, a tokenization rule, a context window, and a target shift. Those are semantic choices, not generic CSV parsing.

A next-token dataset uses a context length L. From the token sequence

t_0,t_1,\ldots,t_n

it forms input/target windows at starting positions for which the extra target token exists:

x_i=(t_i,\ldots,t_{i+L-1}),\qquad y_i=(t_{i+1},\ldots,t_{i+L}).

Both shapes may be [batch, L], so the types alone will not tell you whether the shift is there. The shift is the learning problem (Vaswani et al., 2017), and TorchLean keeps it in one named function rather than in each model's data preparation:

-- A five-token window supplies four inputs and their -- one-position-shifted targets. def dlWindow : Tensor Nat [5] := [10, 11, 12, 13, 14] def dlShifted : Sample.Supervised Nat [4] [4] := Data.CausalLM.tokenSample [] 4 dlWindow input = [10, 11, 12, 13] target = [11, 12, 13, 14] #eval show IO Unit from do IO.println s!"input = {dlShifted.input}" IO.println s!"target = {dlShifted.target}"
input  = [10, 11, 12, 13]
target = [11, 12, 13, 14]

Five tokens in, four positions out. The argument type requires one extra token, and the implementation uses castSucc for inputs and succ for targets to preserve the intended shift:

-- Input and target have the same shape even though they -- select different token positions. dlShifted : Sample.Supervised β„• [4] [4]#check dlShifted
dlShifted : Sample.Supervised β„• [4] [4]

At position zero the model sees 10 and must predict 11; at position three it sees 13 and must predict 14. The final target accounts for the extra token required by the argument type. A caller with only L tokens must obtain another token or make an explicit padding choice before calling this function.

PyTorch writes the same shift as two slices, and nothing in the shapes records which slice was which:

>>> # Pair every input token with the token immediately
>>> # after it.
>>> tokens = torch.tensor([10, 11, 12, 13, 14])
>>> print("input  =", tokens[:-1].tolist())
>>> print("target =", tokens[1:].tolist())
input  = [10, 11, 12, 13]
target = [11, 12, 13, 14]

Token ID ValidationπŸ”—

An embedding lookup requires each token id to name a valid table row. Data.CausalLM.tokenBatch validates the whole batch against the vocabulary size at the corpus boundary and returns Fin-indexed tensors, so model code has no out-of-range case left to handle:

-- Sample two windows and check their input and target token -- bounds against vocabulary size 16. "input = [[4, 5, 6, 7], [4, 5, 6, 7]]"#eval match Data.CausalLM.tokenBatch 16 2 4 ([1, 2, 3, 4, 5, 6, 7, 8] : Tensor Nat [8]) 0 0 with | .ok sample => s!"input = {sample.input}" | .error e => e
"input = [[4, 5, 6, 7], [4, 5, 6, 7]]"

Both rows select the same window. Offsets come from a deterministic keyed hash of the seed, step, and row, with replacement, so different rows can select the same starting position. Repeated windows are especially visible in this short corpus.

The bounds check applies to the sampled windows, including their extra target token. A successful batch validates those selected identifiers; it does not scan every token in a large corpus to certify all future batches. After validation, the Fin vocabularySize entries carry the bounds that embedding lookup needs. This is also why changing the vocabulary mapping is more than changing a model width: the same integer can acquire a different meaning even if it remains within the accepted range.

A dataset that requires distinct windows within a batch needs a sampler that draws without replacement.

Shrink the vocabulary below the largest id and the same call reports it instead of training on a bad index:

-- The same sampled windows contain token identifiers -- outside the smaller vocabulary. "tensor contains an index outside the valid range [0, 4)"#eval match Data.CausalLM.tokenBatch 4 2 4 ([1, 2, 3, 4, 5, 6, 7, 8] : Tensor Nat [8]) 0 0 with | .ok _ => "ok" | .error e => e
"tensor contains an index outside the valid range [0, 4)"

A tokenizer file or vocabulary mapping should therefore be stored with the run, because changing it changes the meaning of every integer in the dataset, including whether that error fires.

GPT-2 Byte-Level BPE FilesπŸ”—

NN.API.Text.Bpe loads the conventional GPT-2 tokenizer pair directly in Lean. The pair implements byte-pair encoding (Sennrich et al., 2016) over a byte-level alphabet:

  • vocab.json is a flat JSON object from byte-escaped token spellings to natural-number ids;

  • merges.txt gives one adjacent symbol pair per non-comment line. File order determines rank, and a lower rank is applied first.

The two files form one artifact: using a vocabulary with a merge table from a different tokenizer can change token boundaries or leave a merged piece absent from the vocabulary. Pass both paths and record both files, preferably with content hashes, alongside a run. A malformed vocabulary or a missing file produces an error. The merge parser rejects malformed non-comment lines with a line number. Successful parsing still does not establish that these are the intended tokenizer files.

Encoding has four visible stages. It applies the GPT-2 pre-tokenizer, converts each fragment's UTF-8 bytes through GPT-2's reversible byte-to-Unicode table, repeatedly applies the best-ranked adjacent merge, and finally looks up every piece in vocab.json. GPT2BPE.encode returns an error when a piece has no id. GPT2BPE.decode performs the inverse vocabulary lookup and byte decoding, and reports an unknown id or invalid UTF-8 rather than inventing text.

Here is a direct round-trip, which runs from the repository root once a matching tokenizer pair sits at the shown paths. It is written with accented text on purpose, because that is where a byte-level alphabet earns its keep:

-- Load the vocabulary and merge rules before converting
-- text into vocabulary identifiers.
import NN.API.Text.Bpe
open TorchLean.text

def inspectBpe : IO Unit := do
  let tok ← GPT2BPE.load
    "data/real/gpt2/vocab.json"
    "data/real/gpt2/merges.txt"
  match GPT2BPE.encode tok "naΓ―ve cafΓ©" with
  | .error e => throw <| IO.userError e
  | .ok ids =>
      IO.println s!"ids = {repr ids}"
      match GPT2BPE.decode tok ids with
      | .error e => throw <| IO.userError e
      | .ok decoded => IO.println s!"decoded = {decoded}"

The generic-tokenizer adapter serves APIs whose interface cannot return tokenization errors, so it maps failures to empty output. Use the error-reporting GPT2BPE.encode and GPT2BPE.decode functions directly at an artifact-validation boundary.

Unicode Tables And Tokenizer ReproducibilityπŸ”—

GPT-2 pre-tokenization distinguishes Unicode letters, numbers, whitespace, contractions, and other symbols. NN.API.Text.Unicode supplies checked-in Unicode 15.1 category ranges for L* and N* plus the Unicode White_Space set. Tokenization therefore does not silently inherit an operating system regex engine, but the checked-in tables are still a semantic dependency: category changes between Unicode versions can change segmentation. Record the TorchLean revision as well as the BPE files when exact reproducibility matters.

Language Model Training With BPEπŸ”—

First prepare the example corpus, then run the CUDA trainer with both tokenizer paths:

Terminal
# Prepare the text corpus, then pass its path to the CUDA
# text-model training command.
python3 scripts/datasets/download_example_data.py --tiny-shakespeare

lake -R -K cuda=true exe torchlean text_gpt2 --device cuda \
  --data-file data/real/text/tiny_shakespeare.txt \
  --bpe-vocab data/real/gpt2/vocab.json \
  --bpe-merges data/real/gpt2/merges.txt \
  --allow-small-data --max-chars 20000 --steps 1 \
  --prompt "First Citizen:" --generate 0

The data script prepares the corpus; it does not supply the tokenizer pair. The command prints BPE loading progress, a first shifted token window, and before/after loss. Both BPE flags are required together. Omitting both selects the runner's byte-token path instead.

This runner is CUDA-only. It does not load OpenAI or Hugging Face model weights. Its BPE mode trains a randomly initialized TorchLean Transformer with batch size two, a one-token context, and a local projection of at most 512 observed GPT-2 ids; ids outside that retained set map to the local fallback id. It exercises real file parsing, tokenization, shifted-window construction, training, and decode plumbing, but it is neither GPT-2-small nor evidence of checkpoint-level tokenizer equivalence.

Tensor Batching And Gradient AccumulationπŸ”—

Stacking samples changes the shape a model receives. Accumulating their gradients changes how much data contributes to an optimizer update. TorchLean provides both operations.

Tensor minibatchesπŸ”—

Data.batch changes the item shapes, so the batch axis becomes part of the type:

-- Batching pairs of items adds a leading two to both sample -- shapes. def dlXorBatches := Data.batch 2 dlXorData (shuffle := true) (seed := 42) dlXorBatches : Trainer.Dataset (Shape.prependDim [2] 2) (Shape.prependDim [1] 2)#check dlXorBatches
dlXorBatches : Trainer.Dataset (Shape.prependDim [2] 2) (Shape.prependDim [1] 2)

The displayed type is unevaluated on purpose: Shape.prependDim [2] 2 is definitionally [2, 2], and writing the annotation Trainer.Dataset [2, 2] [2, 1] on the definition is accepted and reads better. Materializing shows both what the batching did and what the shuffle did:

-- Read one shuffled batch as an input tensor and its -- aligned target tensor. batches = 2 input = [[1.000000, 0.000000], [0.000000, 1.000000]] target = [[1.000000], [1.000000]] #eval show IO Unit from do let stream ← dlXorBatches.materialize (Ξ± := Float) IO.println s!"batches = {stream.size}" match stream.get? 0 with | some batch => IO.println s!"input = {batch.input}" IO.println s!"target = {batch.target}" | none => IO.println "no batches"
batches = 2
input  = [[1.000000, 0.000000], [0.000000, 1.000000]]
target = [[1.000000], [1.000000]]

Seed 42 happened to put both positive XOR examples in the first batch. That is a fair reminder that shuffling does not stratify anything: even a uniform permutation groups the two ones in one of the two batches with probability one third, not one half. A fixed source order, seed, and shuffle implementation reproduce the grouping.

PyTorch reaches the same place with a DataLoader, and its shuffle is also seeded explicitly when you ask for reproducibility (Paszke et al., 2019):

>>> # Shuffle paired samples, then group them into batches
>>> # of two.
>>> dl = DataLoader(ds, batch_size=2, shuffle=True,
...     generator=torch.Generator().manual_seed(42))
>>> for i, (bx, by) in enumerate(dl):
...     print(f"batch {i}: x shape {list(bx.shape)} = {bx.tolist()}")
batch 0: x shape [2, 2] = [[0.0, 0.0], [1.0, 0.0]]
batch 1: x shape [2, 2] = [[0.0, 1.0], [1.0, 1.0]]

The permutations differ, because the two libraries use different generators, and neither ordering is more correct than the other. What must not differ is the multiset of samples, and it does not.

The model must accept [2, 2] and return [2, 1]. One forward and backward pass processes the whole tensor minibatch.

In [2, 2], the outer axis selects an example and the inner axis selects a feature. Both happen to have size two in this batch, so the dimensions alone would not reveal an accidental axis swap.

Groups of unbatched samplesπŸ”—

Trainer.TrainOptions.samplesPerStep counts dataset items per optimizer update, and it defaults to one item:

-- One dataset item is the default amount consumed by each -- optimizer update. 1#eval ({ steps := 5 } : Trainer.TrainOptions).samplesPerStep
1

On a model [2] β†’ [1], ordinary dataset items are individual samples. Every selected item is differentiated at the same parameter point, the resulting gradient packs are averaged, and the optimizer is applied once. The reported loss is the corresponding mean of the item losses. This path does not insert a leading tensor axis.

For a Data.batch, each item already contains a fixed-size tensor minibatch. Keep TrainOptions.samplesPerStep := 1 to perform one vectorized device pass per update. A larger value accumulates gradients across several tensor minibatches. On CUDA, generic accumulation currently synchronizes the parameter pack for the host optimizer update, so one typed batch item per update is the fast path for large batches.

Partial Batches And Fixed ShapesπŸ”—

A model accepting [5, 2] cannot receive [3, 2]; these are different Lean types. The fixed-size typed batching path therefore emits full batches only. Twenty-three samples at batch size five is a small example that shows it, and this one is generated rather than loaded so the whole experiment fits on the page:

-- Twenty-three ordered samples reveal both full batches and -- the dropped remainder. def dlRamp : Trainer.Dataset [1] [1] := Data.generate fun {_Ξ±} _ _ _ => Array.ofFn (n := 23) fun (i : Fin 23) => let x : Tensor Float [1] := [i.val.toFloat] let y : Tensor Float [1] := [2.0 * i.val.toFloat] { input := Tensor.map Runtime.ofFloat x target := Tensor.map Runtime.ofFloat y } batch 5 -> 4 items samples used = 20 batch 30 -> 0 items #eval show IO Unit from do let fivesData := Data.batch 5 dlRamp (shuffle := false) let thirtyData := Data.batch 30 dlRamp (shuffle := false) let fives ← fivesData.materialize (Ξ± := Float) let thirty ← thirtyData.materialize (Ξ± := Float) IO.println s!"batch 5 -> {fives.size} items" IO.println s!"samples used = {fives.size * 5}" IO.println s!"batch 30 -> {thirty.size} items"
batch 5  -> 4 items
samples used = 20
batch 30 -> 0 items

Four full batches, twenty samples used, three samples left on the floor. PyTorch's DataLoader makes the same policy a flag, and its default is the other choice:

drop_last=True: 4 batches, shapes [[5, 1], [5, 1], [5, 1], [5, 1]]
drop_last=False: 5 batches, shapes [[5, 1], [5, 1], [5, 1], [5, 1], [3, 1]]
batch_size=30, drop_last=True: 0 batches

The [3, 1] final batch in the second line is precisely what a shape-typed model cannot accept, so drop_last=False has no counterpart here. Notice also that PyTorch agrees on the batch-size-30 case: zero batches, no error.

The empty dataset is a valid batching result, but a positive-step training request rejects it. For example, a batch larger than this 25-row CSV cannot silently report a trained model:

Recorded output
$ lake exe torchlean data_csv --batch 30 --steps 3 --seed 2026
...
error: Trainer.train: no training samples; check the dataset and batch size (including drop_last)

A zero-step report or checkpoint workflow may still use an empty dataset; its reported zero is an empty-collection convention, not a fitted loss. Manual loader code can reject an empty epoch earlier with a diagnostic that includes the batch size and row count:

-- A batch size larger than the stream cannot produce a -- nonempty epoch with dropping enabled. dlRamp: no full minibatch available (batch=30, rows=23) #eval show IO Unit from do let samples ← dlRamp.materialize (Ξ± := Float) let loader := Data.Loader.fromStream samples 30 match Data.Loader.nextNonemptyEpoch "dlRamp" loader with | .ok epoch => IO.println s!"batches = {epoch.batches.size}" | .error message => IO.println message
dlRamp: no full minibatch available (batch=30, rows=23)

The message includes both the batch size and the available row count. To retain incomplete groups, the program needs a different data contract, such as:

  • padding to five and carrying a validity mask;

  • bucketing examples so each group has a fixed length;

  • using a dynamic wrapper at a lower runtime layer;

  • choosing a batch size that divides the dataset.

Each alternative changes the data contract, and a mask in particular changes the loss. TorchLean refuses to silently change the shape of the last item.

Padding illustrates why retaining all rows is not just a container choice. If three real rows are padded to five, an unmasked mean gives the two inserted rows a share of the objective. A validity mask must therefore travel far enough to exclude them or assign the intended weight. Dropping the incomplete group avoids inventing samples, but changes which rows contribute to that epoch. The correct policy depends on the experiment and should be visible beside its sample counts.

Manual Streams And Epoch StateπŸ”—

Trainer.Dataset delays the arithmetic choice. Lower-level manual code may already own a lazily indexed stream at one selected element type:

-- A stream yields paired records without changing their
-- scalar type or tensor shapes.
Data.SampleStream (Sample.Supervised Ξ± input target)

Each item has named tensors sample.input and sample.target. Sample.mapInput and Sample.mapTarget transform one side without exposing the heterogeneous argument pack used by the graph runtime.

Data.Loader.fromStream samples batch constructs typed epoch state whose batch size appears in its type, and Data.Loader.nextEpoch name loader returns the epoch's full batches together with the loader state for the next epoch:

-- The returned loader carries the state needed to obtain -- the following epoch. epoch batches = 4 next seed = 332879054 #eval show IO Unit from do let samples ← dlRamp.materialize (Ξ± := Float) let loader := Data.Loader.fromStream samples 5 (shuffle := true) (seed := 7) match Data.Loader.nextEpoch "dlRamp" loader with | .ok epoch => IO.println s!"epoch batches = {epoch.batches.size}" IO.println s!"next seed = {epoch.nextLoader.seed}" | .error message => IO.println message
epoch batches = 4
next seed = 332879054

The loader starts with seed 7 and returns a next loader with seed 332879054. Passing epoch.nextLoader to the next epoch advances the shuffle state; reusing the original loader would repeat its state. Epoch order remains a function of the initial seed. Different seeds need not produce different permutations. There is no hidden global generator whose position depends on unrelated code that happened to draw a random number earlier.

To resume an interrupted epoch loop, retain nextLoader and the position within the current batches. Model weights alone do not identify the next batch.

Use the lower loader API for a custom epoch loop. Ordinary model training should prefer Data.batch, Data.fromCsv, or another dataset constructor and let trainer.train own the loop.

Generated StreamsπŸ”—

Some workloads are not finite passes over a stored dataset. Physics-informed networks resample collocation points (Raissi et al., 2019), reinforcement-learning agents collect new transitions into a replay buffer (Mnih et al., 2015), and language models may generate windows from a large file on demand. dlRamp above is the smallest instance of the pattern: its samples exist only inside materialize, and Data.generate receives the arithmetic instances, so the values are built directly in the runtime the trainer selected.

For step-indexed generation, trainer.trainStream takes a sample function Nat β†’ Sample.Supervised Float Οƒ Ο„, and a Trainer.Session accepts whatever sample the caller passes to step. The shapes stay fixed in the type while the values are generated or loaded lazily at each step.

This gives the loop explicit control over the step number, the generator state, the file position, the simulator state, and checkpoint restoration. A generated stream should log enough state to reproduce a batch. β€œSeed 42” is insufficient if the generator also depends on an evolving environment or a file cursor.

Data ReproducibilityπŸ”—

At minimum, record:

Choice

Example

model initialization

trainer seed

sample order

loader or shuffle seed

source identity

file path and preferably content hash

preprocessing

tokenizer, normalization, column split

batch policy

size, shuffling, dropping, padding

runtime

arithmetic semantics, backend, device

The CSV example passes 2026 to both model initialization and shuffling for convenience. They are conceptually separate choices and may be configured independently in a larger experiment. Running the same command twice reproduces every digit, and changing the one seed changes both the initialization and the sample order at once:

Recorded output
$ lake exe torchlean data_csv --batch 5 --steps 5 --seed 2026
mean_loss(before training) = 0.210192
mean_loss(after training) = 0.055578
predict(batch=heldout) = [[0.303611], ...]

$ lake exe torchlean data_csv --batch 5 --steps 5 --seed 2026
mean_loss(before training) = 0.210192
mean_loss(after training) = 0.055578
predict(batch=heldout) = [[0.303611], ...]

$ lake exe torchlean data_csv --batch 5 --steps 5 --seed 7
mean_loss(before training) = 0.740953
mean_loss(after training) = 0.205089
predict(batch=heldout) = [[-0.058836], ...]

The gap between 0.055578 and 0.205089 after five steps reflects a combined change in initialization and sample order. These commands do not isolate their individual effects. Recording both seeds and repeating the experiment makes that limitation visible.

Training can write a JSON TrainLog:

-- Store the training measurements alongside a title that
-- identifies this dataset and run.
let trained ← trainer.train data
  { steps := 200
    log := .json outPath
    title := "small regression" }

Keep the log with the source identity and configuration above. If the CSV and NPY runs diverge, those records let us check the input values, column split, batching, and arithmetic before attributing the difference to training.

CSV Training ExampleπŸ”—

Generate the small deterministic dataset once, then run the maintained loader-and-trainer example:

Terminal
# Generate the small regression files before invoking the
# example that reads their CSV rows.
python3 NN/Examples/Data/generate_small_data.py
lake exe torchlean data_csv \
  --device cpu --batch 5 --steps 5 --seed 2026

Swap data_csv for data_npy to train on the same twenty-five samples loaded from NPY.

Sources: