-
Notifications
You must be signed in to change notification settings - Fork 0
Pipeline
This page is the vocabulary: every verb, in the order you write it. All of it runs today — reading a file, declaring its columns, putting the rows in order, working out features and indicators, dividing the rows three ways, filling gaps, encoding categories, six scales, the evidence a run produces, and the handover to whatever learns — each learning step fitted on the training rows alone and replayed unchanged everywhere after, with what it learned written into the file beside the declaration. What is still design is the part after the handover: the measures of a trained model, and the learners themselves. Those sections say so where they begin.
Why it is built this way at all is PDD. The same pipeline written block by block, with the data under any block, is the Notebook. And the reference of every verb exactly as a file holds it — VERBS.md — is written by the steps themselves, so it cannot drift from them.
The verbs live in DeepSharp.Pipelines, which is its own package: it knows nothing about tensors, and a
service that only prepares data never carries an engine it does not call. The indicators and the data-frame
reader are packages of their own too.
using DeepSharp.Pipelines;
var passengers = Pdd.Create()
.ReadCsv("titanic.csv")
.Declare(schema => schema
.Integer("survived", "pclass", "sibsp", "parch")
.Number("fare")
.Optional("age", ColumnKind.Number)
.Category("sex", "embarked"))
.AddFeature("family", "sibsp", Arithmetic.Plus, "parch")
.SplitStratified("survived", train: 0.70, validation: 0.15) // test is the rest
// ---- nothing above this line is allowed to learn from the data ----
.FillMissing("age", With.Median)
.EncodeCategories()
.Normalise("age", "fare", "family")
.Target("survived")
.Build()
.Run();
var train = passengers.Batch(Part.Train); // rows of numbers, their names, and the answers apartEverything is reached through a factory and built as a chain, and the chain is not decoration: the stages are different types, so the steps that learn from the data are not offered until you have said how to split.
The chain is where you meet that rule; it is not where the rule lives. A step that learns says so in its own type, and the check is made on the finished declaration — so it holds whether the step came from the chain, from a verb another package added, out of a file somebody edited by hand, or from a notebook.
Whichever way a pipeline was written, the declaration refuses it unless:
- it has at most one source, one schema, one split, one order and one target — a second one used to be ignored, so a file said one thing and the numbers came from another;
- the source is the first step, and the schema comes directly after it, because everything else works on columns;
- nothing that learns from the data stands above the split;
- rows are dropped and put in order above the split, never below it, because the split divides the rows it is given once;
- a step that reads the rows in their order stands below the step that says what that order is;
- every column a step reads is there where it reads it, of a kind it can work on;
- every step does something the run acts on.
A refusal names every fault at once, each with the step it is at — DeclarationException.Faults — and
PipelineDeclaration.FaultsIn(steps) gives the same list without refusing, for something that writes a
pipeline a piece at a time.
One verb per source, each an extension method living in the package that brings what it needs:
.ReadCsv(path) |
DeepSharp.Pipelines |
A comma-separated file. |
.Read(rows, description) |
DeepSharp.Pipelines |
Rows handed in: anything that can be turned into an IRowSource. The file records what they were, rather than pretending it can open them again. |
.ReadDataFrame(frame) |
DeepSharp.Pipelines.DataFrame |
A data frame you already hold. |
.ReadCsvFrame(path) |
DeepSharp.Pipelines.DataFrame |
A CSV read through the frame's own reader. |
.ReadDbAsync(reader) |
DeepSharp.Pipelines.DataFrame |
The rows of a database query, through any ADO.NET provider. |
Reading is a solved problem with a long tail — pandas exposes nineteen readers — and reproducing that tail is a
library in itself. Anything else is one small IRowSource away.
A relative path is read from the folder the pipeline sits in: the folder of the file it was read from, or of the
notebook it was written in, and the working directory for a pipeline written in code. So a pipeline file moved
together with its data still finds it. Hand the folder over with new Pipeline(declaration, rows, folder) and
SourceFolder.OfDocument(path).
A live source is fetched, then read — design, not yet built. An endpoint answers differently every time it is called, so a pipeline that read from one during training would train on different numbers tomorrow while claiming to be the same pipeline. A fetch will land the result as a file, recording what it asked for, and the pipeline reads that file like any other.
.Declare(...) comes directly after the source. It names the columns that take part and what each holds —
Text, Number, Integer, Boolean, Timestamp, Category — marks the ones that may be absent with
Optional, and says what becomes of the rest: dropped by default, because a column nobody declared is a
column nobody checked. Remainder.Keep carries them along as text, for a pipeline still finding out what is
in the file, and Remainder.Refuse stops the run when the file holds one.
Everything below is checked against it. The columns are followed from the schema down, each step saying what it leaves behind, so a step that reads a column the schema left out, or one a step above took away, is refused where it stands — not a whole run later, as a column nobody could find. When the file is opened, the declared columns are checked against the ones really there before the first step runs.
Values are read the same way on every machine: numbers and dates under the invariant culture, true and false in
the spellings files actually use — True and False included. A cell that cannot be read as its column's kind
is refused with its row, its column and its value, and a number too large to hold, such as 1e999, is refused
rather than read as infinity.
using DeepSharp.Pipelines;
var prices = Pdd.Create()
.ReadCsv("apple.csv")
.Declare(schema => schema.Timestamp("Date").Number("AAPL.High", "AAPL.Low", "AAPL.Close"))
.OrderBy("Date") // oldest first
.AddIndicator("rsi", Indicator.Rsi, ["AAPL.Close"], 14)
.DropWarmUp()
.SplitByTime("Date", train: 0.70, validation: 0.15)
.Build()
.Run();A file does not promise an order. An export or a query without an ordering hands rows over in whatever order it
happened to hold them, and a step that reads the rows before a row then reads the wrong ones: the same prices
listed newest first gave a five-day average of 98.352 where the right one is 99.74. So a step that reads the
order — an indicator, DropWarmUp, a fill that carries the previous value forward — needs an OrderBy above
it, and is refused without one.
OrderBy takes one or more columns and puts the smallest first, comparing the values in their own kind. It
refuses a gap in a key, and two rows whose keys are equal: nothing then says which came first, so add a second
key column.
A feature is worked out from a single row — or, for an indicator, from the rows that came before it — and learns nothing from the data as a whole, so features stand above the split.
.AddFeature(name, left, Arithmetic.Plus, right) |
A column from two others: Plus, Minus, Times, DividedBy. |
.Cyclical(column, Period.HourOfDay, form) |
A moment as a place on a circle: HourOfDay, DayOfWeek, DayOfMonth, MonthOfYear. |
.TimeParts(column, TimePart.Month, …) |
The pieces people reason with — Minute, Hour, DayOfWeek, DayOfMonth, Month, Quarter, Season, Year — as categories. |
.TimePartsAsNumbers(column, …) |
The same pieces as numbers, for the one where the order really is the point. |
.Reshape(column, Maths.Log) |
Log, Log1P, Reciprocal, Sqrt, Square, ArcSin, Abs, Sign. |
.AddIndicator(name, Indicator.Rsi, columns, period) |
An indicator, from DeepSharp.Pipelines.Indicators. |
Cyclical values. An hour of day is not a number between 0 and 23: 23:00 and 00:00 are neighbours, and a model given the plain number is told they are as far apart as possible. The angle is written as a sine and a cosine, which puts the wrap where it belongs, in one of three forms:
Form.Signed |
one column each, -1..1 | the plain value |
Form.Unit |
one column each, (x+1)/2 into 0..1 |
everything on one range; zero is now 0.5 |
Form.SplitSign |
two columns each, 0..1 |
pos = max(x,0), neg = max(-x,0)
|
Signed and Unit are the same feature up to a shift, so a layer with a bias absorbs the difference.
SplitSign adds something: each half gets its own weight, so a rise and a fall can be answered differently. It
has to be the last form a value takes — normalising one half by its own extremes would give one quantity two
slopes — so a scale on a half is refused.
The parts of a moment are categories, because a month is not a quantity: March is not three of anything.
Indicators — Sma, Ema, Rsi, Atr, Adx, Cci, WilliamsR, Obv, Macd, BollingerBands,
Stochastic, Vwap — are borrowed from MatPlotLibNet rather than written again. Two things about them are
measured rather than promised: each is held to an impulse test — change one row, and no earlier row may move —
and the warm-up arrives as a gap, because an indicator of period N has nothing to say about the first N rows.
DropWarmUp() drops the rows at the start that no column can speak for yet: 506 Apple rows with a twenty-day
band on them are 487 rows of data.
.Drop(columns) |
Leaves columns out from here on — above the split or below it. |
.DropGaps(columns) |
Drops every row with a gap in one of the named columns, above the split. |
.DropWarmUp() |
Drops the rows at the start that an indicator cannot yet speak for, above the split. |
Rows are dropped before they are divided, never after: dropping rows below the split would quietly change how many each part holds, which is what the split promised.
.SplitByTime(...), .SplitAtRandom(...) and .SplitStratified(...) are the line in the chain. Above it
nothing may learn from the data; below it the steps that do become available. Which one to use is not the
library's decision — by time when you predict forward, at random for rows that do not depend on one another,
stratified when an answer is rare — so none of them is the default.
A split divides rows by what they say, not by where they stand. Each row is ranked by a digest of its own contents and the seed, so the same rows are dealt the same way whatever order the file lists them in, and every copy of a repeated row lands where its first copy does. A split in time never divides a moment: every row of one moment lands on the side of the line its first row does.
A row does not belong to a split, it belongs to a part: you split the rows, and Part.Train,
Part.Validation, Part.Test and Part.Predict are what they land in.
You say what training takes and what validation takes. The share you are measured on is whatever is left — it is not a parameter, so nothing can add up to more than there is.
using DeepSharp.Pipelines;
var byTime = Pdd.Create()
.ReadCsv("apple.csv")
.Declare(schema => schema.Timestamp("Date").Number("AAPL.Close"))
.SplitByTime("Date", train: 80, validation: 10) // test: 10
.Build();
var twoWays = Pdd.Create()
.ReadCsv("titanic.csv")
.Declare(schema => schema.Integer("survived").Number("fare"))
.SplitAtRandom(0.80) // 0.80 and 0.20, no validation
.Build();Percentages and fractions say the same thing — 80, 10 and 0.80, 0.10 — and mixing them in one call is
refused rather than read as eight-tenths of a percent. Three numbers that have to add to one is a rule that
breaks quietly: 0.70, 0.15, 0.10 leaves a twentieth of the rows in no part at all.
Predict(10) holds a tenth out of everything. Nothing is fitted on it and nothing is measured on it, so
running a trained network over those rows is the closest thing to running it tomorrow; with a split in time
they are the newest days in the file.
using DeepSharp.Pipelines;
var prices = Pdd.Create()
.ReadCsv("apple.csv")
.Declare(schema => schema.Timestamp("Date").Number("AAPL.Close"))
.Predict(10) // held out of everything
.SplitByTime("Date", train: 65, validation: 15) // test: 10 — the four make a whole
.Normalise("AAPL.Close", Scale.Robust)
.Drop("Date") // a moment is not a number a model takes
.Build()
.Run();
var later = prices.Batch(Part.Predict); // handed over like any other partIt is written once, before the split, and the split is what sets it aside — so a pipeline that holds rows back and then never divides anything is refused where it is built.
Two things usually thrown onto one heap are kept apart. A value is missing when it was never there — an empty field, a NULL — and that is data. A value is not a number when arithmetic produced none, which is almost always a fault further upstream.
using DeepSharp.Pipelines;
var filled = Pdd.Create()
.ReadCsv("titanic.csv")
.Declare(schema => schema.Integer("survived").Optional("age", ColumnKind.Number))
.SplitStratified("survived", train: 0.70, validation: 0.15)
.FillMissing("age", With.Median, refuseAbove: 0.5) // With.Mean, With.Zero, With.Constant(0),
.Build() // With.Previous, With.Refuse
.Run();The strategy is a named value rather than a flag, because Fill("trades", true, false) tells the next reader
nothing. Whatever fills the gap is learned, from the training rows alone, and so is what the fill counts:
the Titanic ages have 177 gaps in the file and 133 among the rows a stratified split trains on.
A column is written alongside, always, saying where the gaps were — age_was_missing. Filling destroys the
difference between "absent" and "the value happened to be that", and it destroys it for good; if you do not
want the column, you drop it.
A fill has a point beyond which it is invention. deck is empty in 688 of the 891 rows, and filling a
column like it would manufacture three quarters of it. refuseAbove is the share of the training rows that may be gaps and
still be filled. Above it the column is not filled, and the column that marks the gaps speaks for it. It has no
default, because a number nobody chose would be the same quiet decision somewhere else.
.FillNaN(column) deals with a value that is not a finite number, and refusing it is the default: a
not-a-number or an infinity is somebody's broken division, and carrying it into a model as if it were a
measurement is the one thing a pipeline should not do quietly. Every fit refuses one among its training values,
naming fill.nan, and the handover refuses one too.
.Encode(column, As.OneHot) learns which categories exist — from the training rows, like everything else
below the line. As.Ordinal writes one column of places instead. An unfamiliar category turns up in production
sooner or later, so what happens then is part of the declaration: Unseen.Reserve keeps a slot for it,
Unseen.Refuse says plainly that the data is outside what the model was trained on.
Which columns are categories is said where the data is declared — Category("sex", "embarked") in the schema —
and .EncodeCategories() then takes them all by name, as one step. A category that never became numbers is
refused at the handover rather than dropped in silence.
Each kind learns something different, which is why the kind is declared and what it learned is stored apart:
| what it does | what it learns | |
|---|---|---|
Scale.Standard |
(x - mean) / spread |
mean and spread |
Scale.MinMax |
into 0..1 | the smallest and largest value |
Scale.MaxAbs |
divide by the largest magnitude | that magnitude |
Scale.Robust |
median and the middle half of the data | median and quartiles |
Scale.Quantile |
rank, then spread evenly | the shape of the distribution |
Scale.Power |
reshape towards a bell curve | one parameter per column |
Standard and min-max are both pulled around by a single extreme value, so one spike can squeeze everything
else into a sliver of the range. On prices and volumes that is the normal case, which is what Robust and
Quantile are for.
Two choices are made here rather than discovered later:
-
Outside the learned range, while the model is running:
OutOfRange.Passlets the value through,OutOfRange.Clipholds it at the edge,OutOfRange.Refusesays the data has moved. -
The target. Normalise what you predict and the way back is part of the saved pipeline:
prepared.BackToOriginal(prediction)puts a prediction back in the units it was read in, walking every step that touched the target backwards. The run checks that the way back really leads back before handing anything over.
.ClipOutliers(column, Bounds.Iqr) holds the extremes to bounds learned from the training rows — by quantile,
by spread or by the middle half — and what happens beyond them is your choice: Outlier.Clip,
Outlier.Blank or Outlier.Refuse.
.NormaliseRow(Norm.L2, columns) is a different animal: it works across a row rather than down a column and
learns nothing, which is what you want when the direction of a row matters and its size does not. A column is
named once in it.
using DeepSharp.Pipelines;
var looked = Pdd.Create()
.ReadCsv("titanic.csv")
.Declare(schema => schema.Integer("survived", "pclass").Optional("age", ColumnKind.Number).Number("fare"))
.Profile()
.Correlation(["survived", "pclass", "age", "fare"])
.SplitStratified("survived", train: 0.70, validation: 0.15)
.Build()
.Run();
var profile = (DataProfile)looked.Evidence[2];Evidence is declared with the pipeline, before the numbers exist, so every run produces the same proof and a report cannot shrink to whatever happened to look good. Both kinds are measured on the rows the split trains on — the split below them as much as one above — because a profile over every row lets the rows a model is measured on shape what it is shown.
A profile gives, per column, the rows, the gaps, the values that are not numbers and the distinct values,
and for numbers the smallest, the largest, the mean and the median. For each thing it finds it names the step
that answers it: a gap with fill.missing, a value that is not a number with fill.nan, a column that never
changes with drop.columns, words with an encoder. It also counts the rows that are there more than once.
A correlation says which training rows it may be drawn from — those with a number in every column named — how many that is, and the rule that left the others out, because a correlation over the rows that happened to be complete is a different number from one over all of them. Whatever draws it works the correlation out: the Notebook draws a heatmap.
What they produce is in PreparedData.Evidence, by the step's place, and never in the pipeline's file: evidence
is output, not the pipeline. A replay produces none.
The data after any step. pipeline.ViewAt(steps) gives the table as it stands after any number of steps,
with where every row stands — in the part the split puts it in, dropped before it gets there, or undivided when
nothing divides it. It is what the grid under a notebook block shows.
Batch(part) is where the pipeline stops: rows of numbers, their column names in a fixed order, and the
answer handed over separately when Target named one. A network built here, a trainer from another .NET
library and something you wrote all take the same handover, which is the only reason two of them can honestly
be compared. A column still holding words, a gap, and a value that is not a finite number are refused there
rather than passed on.
Replay(rows) runs the same declaration over rows nobody had seen, with the numbers the training rows produced
and nothing fitted again. It walks the steps exactly as the run did — it puts the rows in order and drops the
warm-up rows too. Served(rows) hands the result over the way Batch does, without an answer — a served row is
the question — and with which of the handed-in rows each served row is, so a prediction finds its way back.
using DeepSharp.Pipelines;
var prepared = Pdd.Create()
.ReadCsv("titanic.csv")
.Declare(schema => schema.Integer("survived").Optional("age", ColumnKind.Number))
.SplitStratified("survived", train: 0.70, validation: 0.15)
.FillMissing("age", With.Median)
.Build()
.Run();
var text = prepared.ToJson(); // both halves
var again = PreparedData.FromJson(text, StepCatalog.BuiltIn()); // anywhere, no data needed
var declared = PipelineDeclaration.FromJson(text, StepCatalog.BuiltIn()); // the person's half aloneA built pipeline is saved as one file with two halves, because they have two different authors: the declaration, which is what you wrote, and the fitted half, which is what the fit learned.
{
"version": 2,
"declaration": [
{ "step": "read.csv", "path": "titanic.csv" },
{ "step": "declare", "remainder": "drop", "columns": [
{ "name": "survived", "kind": "integer", "optional": false },
{ "name": "age", "kind": "number", "optional": true } ] },
{ "step": "split.stratified", "column": "survived", "train": 0.7, "validation": 0.15, "test": 0.15, "predict": 0, "seed": 20260923 },
{ "step": "fill.missing", "column": "age", "with": "median" }
],
"fitted": [
{ "step": "split.stratified", "prefix": "…",
"learned": { "rows.predict": 0, "rows.test": 135, "rows.train": 623, "rows.validation": 133, "digest": "…" } },
{ "step": "fill.missing", "prefix": "…", "learned": { "gaps": 133, "value": 29 } }
]
}Keeping the halves apart is what lets you re-fit the same declaration on fresh data, compare two runs by their declarations alone, and run a trained model in a service that never knew the builder existed. Every fitted entry carries a key made from its own step and every step above it, so a fit is never used under steps that changed after it was learned. The split writes an entry of its own: how many rows went to each part, and a digest of the rows it divided.
A file is read with a catalog — the list of verbs the reader knows. StepCatalog.BuiltIn() knows this
package's own; add .WithIndicators() for a file that holds indicators. A step nobody registered is refused
rather than skipped, and so is a key a step does not take.
Everything wrong with a file is said at once, each fault at its line and column:
btceur.pipeline.json(9,5): Step 4: 'feature.indicator' is a step from DeepSharp.Pipelines.Indicators, which is not registered here. Reference the package and register its steps with the catalog that reads this file.
btceur.pipeline.json(10,5): Step 5: The step 'split.byTime' cannot be read: The shares add up to 1.3 and a split has to use every row.
btceur.pipeline.json(11,5): Step 6: 'normalize' is not a step anything here knows. The nearest one it knows is 'normalise'.
"I do not know this step" and "I know it, but it is not installed" are two different problems for the person reading, so they are two different messages.
The file names its version, and every verb the version from which it means what it says now. A file from
0.2 names none and is read as the first version: its declaration loads, except for the verbs whose meaning has
changed since — the three splits and drop.warmup — which are refused by name rather than run the new way. A
fitted half from 0.2 is refused: fit again. A file newer than the library is refused whole.
The schema and the template come from the same description the chain is built from. The JSON Schema of the file — pipeline.schema.json — the template a new step starts from, the verb reference and the notebook's form are all generated from each step's own list of parameters, so a file cannot be valid in a way the code is not.
.Report(r => r.Measure(Metric.Rmse, Metric.Mae, Metric.R2)
.On(Part.Train, Part.Validation, Part.Test)
.As(Show.Grid | Show.Chart))
The profile and the correlation are the evidence about the data, and they run today. The measures of a trained model join them when there is a model: named before the numbers exist, on the three parts side by side — a low error on training beside a high one on test is overfitting, seen rather than deduced.
Everything up to here is the same whatever is going to learn from the result, so this is where the pipeline stops. What learns is your choice — a network built here, a trainer from an established .NET machine-learning library, or one you wrote — and they all plug in at the same handover.
That is what makes a comparison mean anything. Two learners measured on the same prepared data with the same declared measures can be put beside each other; two learners each fed by their own preparation cannot. The same data is not the same representation for everyone, though: a network wants everything numeric, expanded and on one scale, while a tree does not care about scale and is hurt by a wide one-hot column. So a learner will say what it needs, and the steps it does not need are skipped by declaration and written down as skipped — never dropped quietly, because the artefact has to keep saying what each run actually saw.