ML.NET - feed a pipeline from NumSharp memory, read it back into NumSharp
NumSharp.Interop.MLNet connects NDArray to ML.NET's
data model - the IDataView pipeline currency and the VBuffer<T> vector primitive. An array becomes an
IDataView that a trained Microsoft.ML pipeline reads lazily through the array's own strides (no
per-row POCO fill, no densifying copy), and a transformer's output column comes straight back as an
NDArray, ready for np.argmax, Postprocess.Softmax, TopK and everything else NumSharp does. The
whole "shape my data into an IDataView / read the scores out of one" boilerplate every ML.NET sample
hand-writes with a POCO class and a PredictionEngine becomes two verbs.
On this page: From zero to a prediction · The verbs ·
The shapes: a vector column, scalar columns, a column per array · Lifetime ·
Post-processing · Dtypes · Why only Microsoft.ML.DataView ·
Limits
Verified on ML.NET 2.0.1 (the floor -
Microsoft.ML.DataView2.0.0), 4.0.2 and 5.0.1 · net8.0/net10.0 · Windows, Linux, macOS. Every claim below is reproduced by a test inNumSharp.Tests.Interop.MLNet(realMicrosoft.MLtransforms and trainers; no Python at test time).
From zero to a prediction
dotnet add package NumSharp.Interop.MLNet
dotnet add package Microsoft.ML # the pipeline runtime you already use for training
The interop depends only on Microsoft.ML.DataView - the standalone package that defines IDataView,
VBuffer<T>, DataViewSchema and DataViewType. ML.NET's runtime (MLContext, transforms, trainers)
lives in Microsoft.ML, which you reference to train or load a model anyway; the package composes with
whichever version you bring (NuGet unifies the DataView dependency upward).
using NumSharp;
using NumSharp.Interop.MLNet;
using NumSharp.Interop.OnnxRuntime; // Postprocess (this package references the ONNX Runtime bridge for it)
using Microsoft.ML;
// A (rows, features) matrix in NumSharp.
NDArray features = np.array([[5.1f, 3.5f], [4.9f, 3.0f], [6.3f, 3.3f]]);
// Feed it to a trained pipeline as an IDataView, read the Score column straight back into NumSharp.
using NDArrayDataView input = features.AsDataView("Features");
IDataView output = trainedModel.Transform(input);
using NDArray scores = output.ToNDArray("Score");
using NDArray predictions = Postprocess.Argmax(scores, axis: -1); // np, not a hand-written loop
That is the whole bridge. IDataView is ML.NET's universal currency - everything a pipeline consumes and
produces is an IDataView - so once an NDArray can become one and a column can be read out of one,
you have bridged training-data preparation, transforms and predictions alike, with no per-transformer
runner and no POCO class.
The verbs
As… shares memory (a lazy, pinned view of the array); To… copies.
| Verb | Direction | What it does |
|---|---|---|
nd.AsDataView("Features") |
NumSharp → ML.NET | An IDataView with one vector column - the feature-matrix shape a model consumes. Reads the array lazily through its strides (any layout), ARC-pinned. |
nd.AsDataView(["a","b",…]) |
NumSharp → ML.NET | An IDataView with one scalar column per feature - the tabular / training-data shape. |
columns.AsDataView() |
NumSharp → ML.NET | An IDataView with one column per array of an IReadOnlyDictionary<string, NDArray> - a 2-D array is a vector column, a 1-D array a scalar column, each in its own dtype and layout (a Features matrix and a Label vector in one view). |
nd.ToDataView(…), columns.ToDataView() |
NumSharp → ML.NET | Same, over an independent snapshot (mutate/dispose the source freely afterward). |
nd.ToVBuffer<T>() |
NumSharp → ML.NET | A dense VBuffer<T> (a copy - export can't be zero-copy, see Limits). |
view.ToNDArray("col") |
ML.NET → NumSharp | Materialize a column across all rows: a scalar column → 1-D (R,); a fixed-size vector column → 2-D (R, C). |
vbuffer.AsNDArray<T>() |
ML.NET → NumSharp | A zero-copy 1-D view over a dense VBuffer<T>'s backing array (write-through, pinned); sparse/empty fall back to a copy. |
vbuffer.ToNDArray() |
ML.NET → NumSharp | A dense 1-D NDArray copy (sparse buffers are densified: absent entries become zero). |
The input view reads any layout through the array's strides - C-contiguous, Fortran, sliced,
transposed, negative-stride and broadcast views all work with no densifying copy, because the cursor
computes each element's physical offset itself. (AsOrtValue in the ONNX bridge needs C-contiguity;
AsDataView does not, because ML.NET pulls values one row at a time rather than handing a dense pointer
to a native runtime.)
view.ToNDArray reads any IDataView, not only this bridge's: a model's output, a TextLoader file, a
Microsoft.Data.Analysis.DataFrame (which is one). A DataFrame column's nulls arrive as its IDataView
writes them - NaN in a float column, 0 or false in an integer or bool column - with no mask. Where they
matter, mask them from the DataFrame's own null map:
np.ma.masked_array(df.ToNDArray("col"), np.array(df["col"].ElementwiseIsNull().Select(isNull => isNull == true).ToArray())).
The other way, nd.AsDataView(names).ToDataFrame(-1) builds a DataFrame from an array; without the -1,
ToDataFrame keeps the first 100 rows.
The shapes
ML.NET has two idioms for "a table of numbers", and the two AsDataView overloads of an array produce each; a
dictionary of arrays makes a table whose columns are different arrays:
One vector column (AsDataView("Features")) - the shape a trained pipeline consumes. A 2-D (R, C)
array becomes R rows, each a length-C VBuffer; a 1-D (C,) array becomes a single row (one sample).
using var v = features.AsDataView("Features"); // (150, 4) -> 150 rows of a 4-vector
One scalar column per feature (AsDataView(string[] names)) - the shape for building training data or
feeding pipelines that reference named scalar columns (then Concatenate them into "Features"). A 2-D
(R, C) array becomes R rows and C named scalar columns; a 1-D (R,) array with one name becomes R
rows and one scalar column. Every name must be distinct: ML.NET would let a later column hide an earlier one
of the same name, so a repeat - say a copy-paste slip in a long feature list - throws ArgumentException
naming both positions instead of making a feature unreachable. Names compare as ML.NET compares them, case
included ("a" and "A" are two columns).
using var t = data.AsDataView(["SepalLength", "SepalWidth", "Label"]); // (150, 3) table
var pipeline = ml.Transforms.Concatenate("Features", "SepalLength", "SepalWidth")
.Append(ml.Regression.Trainers.Sdca(labelColumnName: "Label"));
var model = pipeline.Fit(t);
One column per array (columns.AsDataView() over an IReadOnlyDictionary<string, NDArray>) - the shape
for training data whose columns are separate arrays of different dtypes: a Features matrix and a Label
vector. A 2-D (R, C) array becomes a length-C vector column and a 1-D (R,) array a scalar column; each
array keeps its own dtype and is read through its own strides, and every array must have the same R rows.
The columns follow the dictionary's enumeration order (ML.NET finds them by name). columns.ToDataView() is
the snapshot form.
using var train = new Dictionary<string, NDArray> { ["Features"] = x, ["Label"] = y }.AsDataView(); // (R, 4) float32 + (R,) bool
Reading a column back mirrors the shapes: a scalar "Score" column of R rows comes back as a 1-D
(R,) array; a fixed-size vector column of width C comes back as (R, C). A variable-length vector
column whose rows disagree on length is refused - NumSharp arrays are rectangular, and NumPy refuses ragged
rows too; read it with ML.NET's own view.GetColumn<float[]>("col") and pad or trim the rows to one length
before np.array. (Rows that all share one length read as usual, declared size or not.)
Labels. The label is one more array of a dictionary view, in the type its trainer reads (bool for a
binary classifier, float32 for a regression and for the MapValueToKey of the multiclass recipe). A
trainer reads the label when it fits only, so the trained model scores new rows without a label column:
using var train = new Dictionary<string, NDArray> { ["Features"] = x, ["Label"] = y }.AsDataView(); // y: (R,) bool
ITransformer model = ml.BinaryClassification.Trainers.LbfgsLogisticRegression().Fit(train);
using var newRows = xNew.AsDataView("Features"); // no label needed
using NDArray predicted = model.Transform(newRows).ToNDArray("PredictedLabel");
A single-array view carries a label as one more column of the feature dtype (np.hstack(x, y) with
AsDataView(string[])); a trainer that wants another label type then gets it from a conversion step
(ml.Transforms.Conversion.ConvertType("Label", outputKind: DataKind.Boolean)) fitted on its own, outside the
model, so that the model reads the feature columns only.
A model whose chain maps the label itself - ML.NET's multiclass recipe,
MapValueToKey("Label").Append(...) - asks for a Label column when it scores too
(Could not find input column 'Label'). Score a dictionary view that carries the label, or score label-less
rows with the chain after its label step:
new TransformerChain<ITransformer>(((IEnumerable<ITransformer>)model).Skip(1).ToArray()).Transform(view).
Large tables and multi-pass trainers. Concatenate builds each row's feature vector from its scalar
columns again on every pass over the data, and a trainer such as SDCA or L-BFGS makes many passes. Put
ML.NET's cache between them, ml.Transforms.Concatenate(...).AppendCacheCheckpoint(ml).Append(trainer):
on 2,000,000 x 32 float32 rows SDCA (3 iterations) trained in 5.7-5.9 s instead of 9.2-10.8 s - the speed
of a POCO list (5.1-5.3 s) - with the cache holding one more copy of the columns (peak 1.44 GB instead of
1.11 GB; the POCO list: 1.47 GB).
Lifetime: who frees what
AsDataView returns an NDArrayDataView. Unlike a bare IDataView, it is IDisposable: dispose it
(a using, or wire it into your MLContext's lifetime) to release the pin it holds on the NumSharp
buffer (a dictionary view holds one per array, taken all or none). The pin takes its own atomic reference, so:
- The array stays valid for the view even if you dispose or drop the source array while the pipeline still reads it - the opposite of a raw ML.NET data source over a managed array, which would read freed data.
- Every cursor takes its own pin too, so a cursor mid-iteration survives the view being disposed underneath it.
- Not disposing the view leaks the pin until the finalizer reclaims it (a safety net, not a lifetime
model). Cursors have the same net: ML.NET abandons cursors itself -
ShuffleRows, which training pipelines use, never disposes the cursor it opens on its input, and the time-series package'sDetectEntireAnomalyBySrCnnleaves two per call - and before the net one shuffle kept the NumSharp buffer referenced for the life of the process (the pool never got it back, andresize(refcheck)refused). Until a collection finalizes such a cursor, its pin still holds the buffer: disposing the array and the view gives the memory back only then. - Reachable means valid. Whatever can reach the view - you, a transform's output, a cursor, a getter
delegate (its closure holds the cursor, the cursor the view) - keeps the pins and the buffer alive, and a
getter keeps its cursor reachable until its read is done, so a collection that lands while it copies a row
cannot release the row under it (in a probe, 117 of 279 cursors held only by their getter were finalized
during that getter's own call). The finalizers of the view and of its cursors only give their reference back
(never free eagerly), so an alias of the buffer you still hold stays valid after they run, in any order.
The full analysis:
docs/plans/interop-lifetime-gc-analysis.md. - ML.NET reads lazily, so dispose the view after the last read.
model.Transform(view)reads nothing: it returns a view that reads yours when it is read itself. Ausingscope that ends between the two (using (var v = x.AsDataView("Features")) output = model.Transform(v);, thenoutput.ToNDArray("Score")) throwsObjectDisposedExceptionat the read - for aToDataViewsnapshot too, since it is the view that was disposed. Read the output inside the scope, or let the scope cover the whole pipeline. - A
VBuffer<T>view books the array it pins.vbuffer.AsNDArray()pins the buffer's array until the last NumSharp view over it (derived slices included) is disposed or collected, and books the array's bytes with NumSharp's memory accounting meanwhile. The GC counts the array, but a forgotten view's pin keeps it alive past the collection that found the view dead: 5,000 forgotten views of fresh 602 KB buffers held 2.3-2.7 GB with nothing booked and 96-299 MB booked. Views you dispose cost nothing: the booking ends with the pin.
Cursors follow ML.NET's own contract, checked against the cursors of ML.NET's Cache,
FilterRowsByColumn, TakeRows and LoadFromEnumerable:
Positionis-1before the first row, after the last one and afterDispose.- A getter called off a row throws ML.NET's own
InvalidOperationException("Values cannot be fetched at this time...") and reads nothing. Off-row getters used to read the element before the first row or past the last one - and, afterDispose, a buffer the cursor no longer kept alive. MoveNextstaysfalseonce it returnedfalseor the cursor was disposed.Schemastill answers after the view is disposed (the view's own property throws then).
ToDataView copies first, so its view has no coupling to the source at all.
A released source is refused. Every export verb takes its reference on the source's buffer before its
first read of it - the Half / Decimal conversion, ToDataView's snapshot, ToVBuffer's copy - and throws
ObjectDisposedException if the buffer has already been released. Reading first would read memory the pool
may already have handed to another array, and serve that array's values. Refusals that need only the shape,
the dtype and the names (rank, Complex, a column-name mismatch, the size limit below) come first, so nothing
is converted, snapshotted or allocated for an array that cannot cross.
NDArrayMLNetInterop.LiveExports counts live views - the tests assert it returns to its baseline after
every case, so each test doubles as a no-leak gate.
Post-processing
Postprocess turns a score column into a prediction with np.* compositions instead of hand-written
loops - the softmax / argmax / top-k every classifier re-implements over a float[]. It is the ONNX Runtime
bridge's class, which this package references, so using NumSharp.Interop.OnnxRuntime; brings it (one
Postprocess however many bridges an app uses; calling it never loads ONNX Runtime):
using NDArray scores = output.ToNDArray("Score"); // (batch, classes)
using NDArray probs = Postprocess.Softmax(scores); // stable exp(x-max)/Σ, rows sum to 1
using NDArray top = Postprocess.Argmax(probs, axis: -1); // class id, first on ties
var (values, idx) = Postprocess.TopK(scores, k: 5); // sorted, ties lower-index-first
Softmax, LogSoftmax, Sigmoid, Argmax, TopK - nothing new, just np.max / np.exp / np.sum /
np.argmax / np.argsort / np.take_along_axis.
Dtypes
Eleven dtypes map directly to an ML.NET column type:
| NumSharp | ML.NET column type |
|---|---|
Boolean |
BooleanDataViewType |
Byte / SByte / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 |
the matching NumberDataViewType |
Single / Double |
NumberDataViewType.Single / .Double |
Char |
NumberDataViewType.UInt16 (UTF-16 code units, one-way - reads back as UInt16) |
ML.NET has no half, decimal or complex column type, so the view-producing verbs convert:
Half → Single (lossless - every Half is an exact Single) and Decimal → Double (lossy beyond
~16 significant digits), in a temporary the view owns. Complex is refused - split it into real and
imaginary planes with np.real / np.imag and feed those. Text arrays (U, S, StringDType) are
refused too: the bridge maps no ML.NET text column. In the other direction ToNDArray refuses a text
column as well; ML.NET's own column reader (in Microsoft.ML) reads one in a line -
np.array(view.GetColumn<string>("Text").ToArray()) is a fixed-width U array, and a vector-of-text
column (TokenizeIntoWords) is np.array(view.GetColumn<string[]>("Tokens").ToArray()), (rows, width)
when every row has the same length.
Key columns read back as their keys. PredictedLabel of a multiclass model or of k-means is a key
column: ToNDArray returns its uint32 keys, 1-based indices into the model's key space (0 is a missing
value), not the label values - MapValueToKey numbers the labels in the order it first sees them, by
default. Append MapKeyToValue("PredictedLabel") to the pipeline to read the label values back, or
subtract 1 for a 0-based index into the model's classes (the columns of its Score). The values come back
in the labels' own type: numeric labels through ToNDArray; string labels (the species of ML.NET's Iris
tutorial) make PredictedLabel a text column, which ToNDArray refuses - read it with
np.array(scored.GetColumn<string>("PredictedLabel").ToArray()), a U array.
ML.NET's trainers read Single features. A Double column crosses as Double, but the trainers
and most transforms take Vector<Single> only, and NumSharp's default float dtype is float64, as NumPy's
(np.random.rand, np.array(new[] { 1.5, 2.5 }), np.loadtxt). Convert before the view -
x.astype(np.float32).AsDataView("Features") - or the pipeline stops with ML.NET's
Schema mismatch for feature column 'Features': expected Vector<Single>, got Vector<Double>.
Dependency
The package references Microsoft.ML.DataView alone - the standalone contract package. There is no
separate Microsoft.ML.Data NuGet package (ML.NET ships Microsoft.ML.Data.dll inside the full
Microsoft.ML), and ITransformer is deliberately not needed: because IDataView is the universal
currency, feeding a transformer (transformer.Transform(nd.AsDataView(...))) and reading its output
(output.ToNDArray("Score")) are both just conversions this package provides. So the dependency stays the
lightweight contract, and the heavy Microsoft.ML you already reference for training composes on top -
the exact mirror of how NumSharp.Interop.OnnxRuntime depends on Microsoft.ML.OnnxRuntime.Managed and
leaves the native runtime to the consumer.
Any Microsoft.ML.DataView from 2.0.0 on is accepted, with no upper bound - the form Microsoft.ML itself
declares it in - so moving your Microsoft.ML to a newer major never fails the restore. The suite runs on
ML.NET 2.0.1, on the current release and on the newest preview.
Limits
VBuffer<T>zero-copy is one-directional. Reading a denseVBuffer<T>INTO NumSharp is zero-copy -vbuffer.AsNDArray<T>()reaches the buffer's privateT[]through a compiled-expression accessor, pins it, and wraps it write-through. But the reverse (NDArray → VBuffer) cannot be:VBuffer<T>is backed by a managedT[]at every ML.NET version (verified 2.0.0 / 3.0.0 / 4.0.2 / 5.0.1 and the 6.0 previews - there is noReadOnlyMemory<T>and no non-public constructor), and a managed array cannot alias NumSharp's unmanaged buffer, soToVBuffer<T>copies. The bulk NumSharp→ML.NET path (AsDataView) is already zero-copy on its source; theVBufferis a per-vector convenience. Aliasing caveat forAsNDArray: the view shares the VBuffer's array, so a VBuffer from a cursor getter (which reuses one buffer per row) must useToNDArray()instead; zero-copy is safe for a standalone VBuffer you own.- Rank 1 or 2 only. An
IDataViewis a table of rows;AsDataViewaccepts 1-D and 2-D arrays. Reshape higher-rank data first. - A feature axis of length 0 is a variable-length column to ML.NET. ML.NET has no fixed-size vector of
width 0 (a vector size of 0 means "variable length"), so the vector column of an
(R, 0)array round-trips throughToNDArrayas(R, 0)but is refused by ML.NET's transforms and trainers with their own text (Wrong column type for input column ... Got: Vector<Single>). An empty array with features,(0, C), is an ordinary column of no rows. - ML.NET's buffers stop at 2,146,435,071 elements (its internal
ArrayUtils.ArrayMaxSize, a little belowArray.MaxLength):VBufferEditor.Create- behind every cursor getter andVBuffer.CopyTo- refuses a longer length. So a vector column wider than that (AsDataView/ToDataView) and aToVBufferlonger than that are refused at export, before anything is allocated; use more rows, or expose a long 1-D array as one scalar column. - The view shuffles itself.
NDArrayDataView.CanShuffleistrue: an array reads any row at the same cost, so a cursor ML.NET opens with aRandomwalks every row once in a random permutation drawn from it (row ids stay the source rows; a seeded context gives the same order every run). A trainer that shuffles - SDCA - uses it. That matters for rows stored in class order (np.vstackof class blocks): ML.NET's fallback for a source that cannot shuffle (aLoadFromEnumerablelist, for one) is a pool shuffle that cannot mix them, and SDCA on 20,000 class-sorted rows scored 0.50 accuracy through it. The permutation costs 4 bytes a row per shuffled cursor; a view of more rows than a managed array holds does not shuffle. - Text files lose infinities.
np.savetxtwrites NumPy'sinf/-inf, and ML.NET'sTextLoaderreads those as NaN - silently, as Double and as Single (it readsInfinity,-Infinity,∞and overflowing numbers such as1e309as infinities). The rest of NumPy's default%.18etext reads back bit for bit,-0.0and subnormals included. Hand the array to ML.NET withAsDataViewinstead of a file. - No
Complexcolumn. ML.NET has no complex type (see Dtypes). ToNDArrayrectangularizes. A variable-length vector column cannot become a rectangular NDArray and is refused with a message naming the cause.- Imports have no managed-array cap.
ToNDArraystages rows straight into NumSharp memory - one exact allocation when the view reports its row count, growing chunks copied together once when it does not (an ML.NET row filter) - so a column of more thanArray.MaxLengthelements in total imports; it used to stage through a managedT[]and fail there. A sparseVBufferdensifies at any size (the clear used to count bytes in anintand threw at 2 GB).
See also
- An example project - a console app to copy: a binary classifier trained from a NumSharp array exposed as named scalar columns (the label converted outside the model, so the model scores features alone), its predictions read back and scored with NumSharp next to ML.NET's own evaluation, and k-means on one vector column
- Train in ML.NET, serve with ONNX Runtime -
a console app to copy: a regression trained on a NumSharp table, exported with ML.NET's
ConvertToOnnx, served from NumSharp arrays through the ONNX Runtime bridge, and checked against ML.NET's own scores - Interoperability overview - the contract every bridge builds on
- ONNX Runtime - the sibling bridge for ONNX models