Transforms, Expressions & Chaining - C#

Quote

“If you torture the data long enough, it will confess to anything.”

Ronald Coase, attributed remark (c. 1960s)

Suppress CS1701/CS1702 assembly version warnings in .NET Interactive. Run this cell once before any cells that use NuGet packages.

Runs the notebook warning-suppression setup cell. Runs the example cell.

using System.Reflection;
using Microsoft.DotNet.Interactive;
using Microsoft.DotNet.Interactive.CSharp;
 
var csharpKernel = (CSharpKernel)Kernel.Root.FindKernelByName("csharp");
var optionsField = typeof(CSharpKernel).GetField("_scriptOptions",
    BindingFlags.NonPublic | BindingFlags.Instance);
 
var scriptOptions = optionsField.GetValue(csharpKernel);
var withWarningLevel = scriptOptions.GetType().GetMethod("WithWarningLevel");
var newOptions = withWarningLevel.Invoke(scriptOptions, new object[] { 0 });
optionsField.SetValue(csharpKernel, newOptions);

Install NuGet packages and import namespaces. Alias Microsoft.Data.Analysis as MDA so DataFrame continues to refer to Polars.NET inside mixed examples.

Loads the notebook packages, aliases, and HTML formatters used by the rest of the note. Runs the example cell.

#r "nuget: Polars.NET, 0.4.0"
#r "nuget: Polars.NET.Native.win-x64, 0.4.0"
#r "nuget: Microsoft.Data.Analysis, 0.23.0"
 
using System;
using System.IO;
using System.Linq;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using Polars.CSharp;
using static Polars.CSharp.Polars;
using MDA = Microsoft.Data.Analysis;
using Microsoft.DotNet.Interactive.Formatting;
 
Formatter.Register<DataFrame>((df, writer) =>
{
    var html = df.ToHtml();
    html = System.Text.RegularExpressions.Regex.Replace(html, @"(&gt;|>)(.+?)(&lt;|<)", @"$1$2$3");
    html = System.Text.RegularExpressions.Regex.Replace(html, @">""(.+?)""<", @">$1<");
    writer.Write(html);
}, "text/html");
Formatter.Register<Polars.CSharp.Series>((s, writer) =>
    writer.Write($"<pre style='font-size:14px'>{s}</pre>"), "text/html");
 
var DATA = Path.Combine("..", "data");
Console.WriteLine($"Data directory: {Path.GetFullPath(DATA)}");
Data directory: c:\Users\aperi\DEV\LANG\data

Load the primary dataset used throughout this notebook. Both libraries read the same CSV, so the rest of the page focuses on transform style, execution model, and notebook ergonomics rather than data differences.

Loads the shared CSV into both dfP and df so later examples can compare the same rows. Runs the example cell.

var dfP = DataFrame.ReadCsv(Path.Combine(DATA, "eurostoxx50_ohlcv.csv"), tryParseDates: true);
var df = MDA.DataFrame.LoadCsv(Path.Combine(DATA, "eurostoxx50_ohlcv.csv"));
 
display($"Polars: {dfP.Shape}  |  MDA: ({df.Rows.Count}, {df.Columns.Count})");
Polars: (66355, 12)  |  MDA: (66355, 12)

Current API and execution-model check | 2026-04

Polars’ expressions guide and lazy optimization guide document expressions, window functions, folds, categorical data, and optimizer-driven execution as first-class concepts. Microsoft’s DataFrame, PrimitiveDataFrameColumn<T>, and StringDataFrameColumn document an eager typed-column API centered on explicit materialization. In practice, Polars favors declarative transform graphs; MDA favors direct CLR-native column operations.


Column Transforms

Adding, modifying, and overwriting columns is the core dataframe workflow. Polars.NET expresses these transforms declaratively through .WithColumns(), while Microsoft.Data.Analysis usually computes typed target columns explicitly and then appends them to a cloned or newly constructed frame.

Polars expressions vs MDA materialization

Polars DataFrames stay close to an expression graph: .WithColumns() returns a new frame and the transform can later participate in lazy optimization. Microsoft.Data.Analysis is eager and explicit: Clone(), Columns.Add(...), and new MDA.DataFrame(...) materialize each added column immediately. That makes debugging straightforward, but it also means the developer owns more of the transform plumbing.

Polars.NET / Microsoft.Data.Analysis | Arithmetic Columns

Polars.NET | Add a computed column with WithColumns

.WithColumns() accepts one or more expressions. Each expression references existing columns via Col(), applies arithmetic or logic, and is named with .Alias(). The result is a new DataFrame with the additional column appended.

Computes a range column as high − low for every row in the 66K-row dataset, then previews the first 5 ABI.BR rows confirming intraday ranges between 0.97 and 2.07. Runs the example cell.

var dfRange = dfP.WithColumns(
    (Col("high") - Col("low")).Alias("range"));
 
dfRange.Select("symbol", "date", "high", "low", "range").Head(5)
symboldatehighlowrange
ABI.BR2021-01-0458.8556.782.07
ABI.BR2021-01-0557.9856.751.23
ABI.BR2021-01-0658.9457.391.55
ABI.BR2021-01-0758.8657.880.98
ABI.BR2021-01-0858.457.430.97

Microsoft.Data.Analysis | Add a computed column by cloning and appending

Microsoft.Data.Analysis does not expose a Polars-style expression builder. The standard pattern is to cast the source columns to typed PrimitiveDataFrameColumn<T> objects, compute the derived column directly, name it with SetName, and append it to a cloned frame.

Computes range = high - low, appends it to a cloned MDA frame, and previews symbol, date, high, low, and range for the first five rows. Runs the example cell.

var highColM = (MDA.PrimitiveDataFrameColumn<float>)df.Columns["high"];
var lowColM = (MDA.PrimitiveDataFrameColumn<float>)df.Columns["low"];
var rangeColM = highColM - lowColM;
rangeColM.SetName("range");
 
var dfRangeM = df.Clone();
dfRangeM.Columns.Add(rangeColM);
 
var selectedM = new MDA.DataFrame(dfRangeM.Columns["symbol"], dfRangeM.Columns["date"], dfRangeM.Columns["high"], dfRangeM.Columns["low"], dfRangeM.Columns["range"]);
selectedM.Head(5)
symboldatehighlowrange
ABI.BR2021-01-04 00:00:00Z58.8556.782.0699997
ABI.BR2021-01-05 00:00:00Z57.9856.751.2299995
ABI.BR2021-01-06 00:00:00Z58.9457.391.5499992
ABI.BR2021-01-07 00:00:00Z58.8657.880.97999954
ABI.BR2021-01-08 00:00:00Z58.457.430.9700012

Polars.NET / Microsoft.Data.Analysis | Column Overwrite via Cast

Polars.NET | Overwrite a column with Cast

When .Alias() matches an existing column name, the new expression replaces that column. .Cast(DataType.X) converts the column’s data type. This is useful for promoting integer columns to float for downstream arithmetic that requires fractional precision.

Casts volume from i64 to f64 by aliasing the expression to the same column name, confirms the dtype change via DataTypeName, and shows the first 3 rows where integer values are preserved as floats. Runs the example cell.

var dfCast = dfP.WithColumns(
    Col("volume").Cast(DataType.Float64)
);
 
display($"Before: {dfP["volume"].DataTypeName}  |  After: {dfCast["volume"].DataTypeName}");
dfCast.Select("symbol", "date", "volume").Head(3)

Before: i64 | After: f64

symboldatevolume
ABI.BR2021-01-041513937
ABI.BR2021-01-051382722
ABI.BR2021-01-061370204

Microsoft.Data.Analysis | Add a converted numeric column explicitly

MDA type conversion is usually explicit: read the source values from the existing column, create a new typed target column, and populate it row by row. This is more verbose than Polars Cast, but it makes the materialization step obvious and debuggable.

Converts volume into a new volume_f64 column, appends it to a cloned frame, prints the source and target CLR types, and previews the first three rows. Runs the example cell.

var volColM = df.Columns["volume"];
var volDoubleM = new MDA.PrimitiveDataFrameColumn<double>("volume_f64", df.Rows.Count);
 
for(long i = 0; i < df.Rows.Count; i++)
{
    if (volColM[i] != null) volDoubleM[i] = Convert.ToDouble(volColM[i]);
}
 
var dfCastM = df.Clone();
dfCastM.Columns.Add(volDoubleM);
 
display($"Before: {volColM.DataType.Name}  |  After: {volDoubleM.DataType.Name}");
new MDA.DataFrame(dfCastM.Columns["symbol"], dfCastM.Columns["date"], dfCastM.Columns["volume_f64"]).Head(3)
Before: Single  |  After: Double
symboldatevolume_f64
ABI.BR2021-01-04 00:00:00Z1513937
ABI.BR2021-01-05 00:00:00Z1382722
ABI.BR2021-01-06 00:00:00Z1370204

Polars.NET / Microsoft.Data.Analysis | Percentage Change

Polars.NET | Percentage change with expressions

Compound expressions chain arithmetic operators directly on Col() references. Lit(100.0) injects a scalar constant into the expression tree. The entire expression is evaluated in a single vectorized pass — no intermediate Series objects are allocated.

Computes (close − open) / open * 100 as a single named expression daily_return_pct in one pass, and previews the first 5 ABI.BR rows showing daily returns ranging from −1.62% to +1.40%. Runs the example cell.

var dfPct = dfP.WithColumns(
    ((Col("close") - Col("open")) / Col("open") * Lit(100.0)).Alias("daily_return_pct")
);
 
dfPct.Select("symbol", "date", "open", "close", "daily_return_pct").Head(5)
symboldateopenclosedaily_return_pct
ABI.BR2021-01-0458.1557.21-1.616509028
ABI.BR2021-01-0556.957.180.4920913884
ABI.BR2021-01-0657.9658.771.397515528
ABI.BR2021-01-0758.6858.4-0.4771642808
ABI.BR2021-01-0858.1657.86-0.5158184319

Microsoft.Data.Analysis | Percentage change with typed column arithmetic

Once numeric inputs are typed as PrimitiveDataFrameColumn<float>, MDA supports elementwise arithmetic directly. The transform still materializes eagerly, but the code remains vector-style for straightforward numeric feature engineering.

Computes daily_return_pct = (close - open) / open * 100, appends it to a cloned frame, and previews the first five rows. Runs the example cell.

var openColM = (MDA.PrimitiveDataFrameColumn<float>)df.Columns["open"];
var closeColM = (MDA.PrimitiveDataFrameColumn<float>)df.Columns["close"];
 
var pctColM = (closeColM - openColM) / openColM * 100.0f;
pctColM.SetName("daily_return_pct");
 
var dfPctM = df.Clone();
dfPctM.Columns.Add(pctColM);
 
new MDA.DataFrame(dfPctM.Columns["symbol"], dfPctM.Columns["date"], dfPctM.Columns["open"], dfPctM.Columns["close"], dfPctM.Columns["daily_return_pct"]).Head(5)
symboldateopenclosedaily_return_pct
ABI.BR2021-01-04 00:00:00Z58.1557.21-1.6165131
ABI.BR2021-01-05 00:00:00Z56.957.180.49208924
ABI.BR2021-01-06 00:00:00Z57.9658.771.3975179
ABI.BR2021-01-07 00:00:00Z58.6858.4-0.47716218
ABI.BR2021-01-08 00:00:00Z58.1657.86-0.5158171

Polars.NET / Microsoft.Data.Analysis | Multiple Transforms in One Pass

Polars.NET | Multiple expressions in a single WithColumns call

.WithColumns() accepts multiple comma-separated expressions. Polars evaluates them in a single pass over the data, avoiding repeated scans. This is both more readable and more performant than chaining multiple .WithColumns() calls.

Adds range, midpoint, and daily_return_pct in a single .WithColumns() call with one data scan, and previews 5 ABI.BR rows showing all three derived columns computed simultaneously. Runs the example cell.

var dfMulti = dfP.WithColumns(
    (Col("high") - Col("low")).Alias("range"),
    ((Col("high") + Col("low")) / Lit(2.0)).Alias("midpoint"),
    ((Col("close") - Col("open")) / Col("open") * Lit(100.0)).Alias("daily_return_pct")
);
 
dfMulti.Select("symbol", "date", "range", "midpoint", "daily_return_pct").Head(5)
symboldaterangemidpointdaily_return_pct
ABI.BR2021-01-042.0757.815-1.616509028
ABI.BR2021-01-051.2357.3650.4920913884
ABI.BR2021-01-061.5558.1651.397515528
ABI.BR2021-01-070.9858.37-0.4771642808
ABI.BR2021-01-080.9757.915-0.5158184319

Microsoft.Data.Analysis | Multiple transforms with eager column adds

MDA has no single-call WithColumns equivalent. The practical pattern is to compute each derived column first and then append them to a cloned frame. This keeps the control flow explicit, but each new column is materialized eagerly.

Adds range, midpoint, and daily_return_pct to a cloned frame and previews all three derived columns together. Runs the example cell.

var midColM = (highColM + lowColM) / 2.0f;
midColM.SetName("midpoint");
 
var dfMultiM = df.Clone();
dfMultiM.Columns.Add(rangeColM);
dfMultiM.Columns.Add(midColM);
dfMultiM.Columns.Add(pctColM);
 
new MDA.DataFrame(dfMultiM.Columns["symbol"], dfMultiM.Columns["date"], dfMultiM.Columns["range"], dfMultiM.Columns["midpoint"], dfMultiM.Columns["daily_return_pct"]).Head(5)
symboldaterangemidpointdaily_return_pct
ABI.BR2021-01-04 00:00:00Z2.069999757.815-1.6165131
ABI.BR2021-01-05 00:00:00Z1.229999557.3649980.49208924
ABI.BR2021-01-06 00:00:00Z1.549999258.1651.3975179
ABI.BR2021-01-07 00:00:00Z0.9799995458.370003-0.47716218
ABI.BR2021-01-08 00:00:00Z0.970001257.915-0.5158171

Expression System

Polars’ expression engine is the core differentiator in this chapter. Expressions are composable and can later flow into lazy optimization, while Microsoft.Data.Analysis centers the API on eager DataFrame / DataFrameColumn operations and explicit per-column materialization.

Polars expressions vs MDA managed columns

In Polars, Col("x") + Col("y") is an expression object that remains reusable across Select, WithColumns, Filter, window functions, and lazy plans. In Microsoft.Data.Analysis, PrimitiveDataFrameColumn<T> arithmetic executes eagerly and produces a concrete target column immediately. That difference matters most once transformation graphs become long, stateful, or production-bound.


flowchart LR
    A["Col(close)"] --> B["Multiply by Lit(1.10)"]
    B --> C["Alias as close_usd"]
    C --> D[".WithColumns()"]
    D --> E["New DataFrame"]
    style A fill:#292e42,stroke:#565f89,color:#c0caf5
    style B fill:#292e42,stroke:#565f89,color:#c0caf5
    style C fill:#292e42,stroke:#565f89,color:#c0caf5
    style D fill:#24283b,stroke:#7aa2f7,color:#c0caf5
    style E fill:#1a1b26,stroke:#9ece6a,color:#c0caf5

Polars.NET / Microsoft.Data.Analysis | Col, Lit, and Alias

Polars.NET | Col, Lit, and Alias basics

Col("name") references a column by name, Lit(value) injects a scalar constant, and .Alias("name") assigns a name to the resulting expression. These three primitives compose into arbitrarily complex expressions passed to .Select() or .WithColumns().

Selects symbol and close, injects 1.10 as a literal eur_to_usd column, and multiplies close * 1.10 to produce close_usd — showing the first 5 ABI.BR rows with all four columns. Runs the example cell.

var dfExpr = dfP.Select(
    Col("symbol"),
    Col("close"),
    Lit(1.10).Alias("eur_to_usd"),
    (Col("close") * Lit(1.10)).Alias("close_usd")
);
 
dfExpr.Head(5)
symbolcloseeur_to_usdclose_usd
ABI.BR57.211.162.931
ABI.BR57.181.162.898
ABI.BR58.771.164.647
ABI.BR58.41.164.24
ABI.BR57.861.163.646

Microsoft.Data.Analysis | Explicit constants and typed column arithmetic

MDA does not have Col, Lit, or Alias. To express the same idea, you build the constant column explicitly, apply typed arithmetic on the underlying columns, and materialize the final projection into a new dataframe.

Creates a constant eur_to_usd column, computes close_usd = close * 1.10, and shows the first five rows of the projected result. Runs the example cell.

var eurToUsdColM = new MDA.PrimitiveDataFrameColumn<float>("eur_to_usd", df.Rows.Count);
for (long i = 0; i < df.Rows.Count; i++) eurToUsdColM[i] = 1.10f;
 
var closeUsdColM = closeColM * 1.10f;
closeUsdColM.SetName("close_usd");
 
var dfExprM = new MDA.DataFrame(df.Columns["symbol"], df.Columns["close"], eurToUsdColM, closeUsdColM);
dfExprM.Head(5)
symbolcloseeur_to_usdclose_usd
ABI.BR57.211.162.931
ABI.BR57.181.162.898003
ABI.BR58.771.164.647
ABI.BR58.41.164.240005
ABI.BR57.861.163.646004

Polars.NET / Microsoft.Data.Analysis | Conditional Expressions

Polars.NET | Conditional column with IfElse

IfElse(condition, true_value, false_value) is the Polars.NET conditional expression. It evaluates the condition per row and returns the corresponding value. Nest IfElse calls for multi-branch logic. The condition, true, and false branches are all expressions — they can reference columns, literals, or further nested expressions.

Builds a return_pct column, then classifies each row as “up”, “down”, or “flat” using two nested IfElse calls on close vs. open, showing 8 ABI.BR rows where 5 of 8 sessions close below the open.

Polars.NET uses IfElse, not When/Then/Otherwise

The Python Polars API uses pl.when().then().otherwise() for conditional logic. Polars.NET 0.4.0 does not expose this API — use IfElse() instead. The summary table at the bottom of this page reflects this difference.

Correct Polars.NET conditional pattern

IfElse(Col("a") > Col("b"), Lit("yes"), Lit("no")).Alias("result")

Runs the conditional-expression example described above. Runs the example cell.

var dailyRet = (Col("close") - Col("open")) / Col("open") * Lit(100.0);
 
var dfCond = dfP.WithColumns(
    dailyRet.Alias("return_pct"),
    IfElse(
        Col("close") > Col("open"),
        Lit("up"),
        IfElse(Col("close") < Col("open"), Lit("down"), Lit("flat"))
    ).Alias("direction")
);
 
dfCond.Select("symbol", "date", "open", "close", "return_pct", "direction").Head(8)
symboldateopenclosereturn_pctdirection
ABI.BR2021-01-0458.1557.21-1.616509028down
ABI.BR2021-01-0556.957.180.4920913884up
ABI.BR2021-01-0657.9658.771.397515528up
ABI.BR2021-01-0758.6858.4-0.4771642808down
ABI.BR2021-01-0858.1657.86-0.5158184319down

Microsoft.Data.Analysis | Conditional column with an explicit string pass

Conditional logic in MDA is usually an explicit per-row pass that writes the result into a StringDataFrameColumn or a typed boolean column. That is straightforward to debug, but it does not become a reusable expression tree the way it does in Polars.

Builds a direction string column from close versus open, appends both daily_return_pct and direction, and previews the first eight rows. Runs the example cell.

var dirColM = new MDA.StringDataFrameColumn("direction", df.Rows.Count);
for (long i = 0; i < df.Rows.Count; i++)
{
    float c = closeColM[i].GetValueOrDefault();
    float o = openColM[i].GetValueOrDefault();
    dirColM[i] = c > o ? "up" : (c < o ? "down" : "flat");
}
 
var dfCondM = df.Clone();
dfCondM.Columns.Add(pctColM);
dfCondM.Columns.Add(dirColM);
 
new MDA.DataFrame(dfCondM.Columns["symbol"], dfCondM.Columns["date"], dfCondM.Columns["open"], dfCondM.Columns["close"], dfCondM.Columns["daily_return_pct"], dfCondM.Columns["direction"]).Head(8)
symboldateopenclosedaily_return_pctdirection
ABI.BR2021-01-04 00:00:00Z58.1557.21-1.6165131down
ABI.BR2021-01-05 00:00:00Z56.957.180.49208924up
ABI.BR2021-01-06 00:00:00Z57.9658.771.3975179up
ABI.BR2021-01-07 00:00:00Z58.6858.4-0.47716218down
ABI.BR2021-01-08 00:00:00Z58.1657.86-0.5158171down

Polars.NET | Nested IfElse for multiple conditions

For multi-tier classification, nest IfElse calls — the false branch of each outer IfElse becomes the next condition. The volume column is cast to Float64 first because Lit() with numeric constants produces float comparisons.

Classifies all 66K rows into “low” / “medium” / “high” / “very_high” volume tiers using three nested IfElse calls, then groups by tier to confirm 27K low, 25K medium, 8.4K very_high, and 5.9K high rows. Runs the example cell.

var volFloat = Col("volume").Cast(DataType.Float64);
 
var dfTier = dfP.WithColumns(
    IfElse(volFloat > Lit(10_000_000.0), Lit("very_high"),
        IfElse(volFloat > Lit(5_000_000.0), Lit("high"),
            IfElse(volFloat > Lit(1_000_000.0), Lit("medium"), Lit("low"))))
    .Alias("vol_tier")
);
 
display(dfTier.Select("symbol", "date", "volume", "vol_tier").Head(8));
 
dfTier.GroupBy("vol_tier").Agg(Col("vol_tier").Count().Alias("count"))
symboldatevolumevol_tier
ABI.BR2021-01-041513937medium
ABI.BR2021-01-051382722medium
ABI.BR2021-01-061370204medium
ABI.BR2021-01-071469911medium
ABI.BR2021-01-081428681medium
vol_tiercount
medium24964
low27061
high5887
very_high8443

Microsoft.Data.Analysis | Multi-branch classification with manual thresholds

Multi-branch logic is just an explicit pass over the typed source column. This notebook writes the final label into a string column and then uses ValueCounts() to validate the distribution across the full dataset.

Classifies each row into low, medium, high, or very_high volume tiers, previews the first eight rows, and then counts the tier distribution. Runs the example cell.

var tierColM = new MDA.StringDataFrameColumn("vol_tier", df.Rows.Count);
for (long i = 0; i < df.Rows.Count; i++)
{
    double v = volDoubleM[i].GetValueOrDefault();
    if (v > 10000000) tierColM[i] = "very_high";
    else if (v > 5000000) tierColM[i] = "high";
    else if (v > 1000000) tierColM[i] = "medium";
    else tierColM[i] = "low";
}
 
var dfTierM = df.Clone();
dfTierM.Columns.Add(tierColM);
 
display(new MDA.DataFrame(dfTierM.Columns["symbol"], dfTierM.Columns["date"], dfTierM.Columns["volume"], dfTierM.Columns["vol_tier"]).Head(8));
dfTierM.Columns["vol_tier"].ValueCounts()
symboldatevolumevol_tier
ABI.BR2021-01-04 00:00:00Z1513937medium
ABI.BR2021-01-05 00:00:00Z1382722medium
ABI.BR2021-01-06 00:00:00Z1370204medium
ABI.BR2021-01-07 00:00:00Z1469911medium
ABI.BR2021-01-08 00:00:00Z1428681medium
ValuesCounts
medium24964
low27061
high5887
very_high8443

Polars.NET / Microsoft.Data.Analysis | Horizontal Arithmetic

Polars.NET | Horizontal arithmetic across columns

Horizontal operations combine values across multiple columns within each row. Polars.NET 0.4.0 does not expose horizontal_mean, so the OHLC average is computed as manual arithmetic over four Col() references divided by Lit(4.0).

Sums open, high, low, and close in a single expression divided by Lit(4.0) to produce ohlc_avg, and shows 5 ABI.BR rows with per-row OHLC averages between 57.20 and 58.46. Runs the example cell.

var dfHoriz = dfP.WithColumns(
    ((Col("open") + Col("high") + Col("low") + Col("close")) / Lit(4.0)).Alias("ohlc_avg")
);
 
dfHoriz.Select("symbol", "date", "open", "high", "low", "close", "ohlc_avg").Head(5)
symboldateopenhighlowcloseohlc_avg
ABI.BR2021-01-0458.1558.8556.7857.2157.7475
ABI.BR2021-01-0556.957.9856.7557.1857.2025
ABI.BR2021-01-0657.9658.9457.3958.7758.265
ABI.BR2021-01-0758.6858.8657.8858.458.455
ABI.BR2021-01-0858.1658.457.4357.8657.9625

Microsoft.Data.Analysis | Horizontal arithmetic with column addition

Horizontal row-wise math is still written as typed column arithmetic. Because there is no horizontal expression namespace, the notebook computes the OHLC average directly from the four typed numeric columns.

Computes ohlc_avg from open, high, low, and close, appends it to a cloned frame, and previews the first five rows. Runs the example cell.

var ohlcAvgM = (openColM + highColM + lowColM + closeColM) / 4.0f;
ohlcAvgM.SetName("ohlc_avg");
 
var dfHorizM = df.Clone();
dfHorizM.Columns.Add(ohlcAvgM);
 
new MDA.DataFrame(dfHorizM.Columns["symbol"], dfHorizM.Columns["date"], dfHorizM.Columns["open"], dfHorizM.Columns["high"], dfHorizM.Columns["low"], dfHorizM.Columns["close"], dfHorizM.Columns["ohlc_avg"]).Head(5)
symboldateopenhighlowcloseohlc_avg
ABI.BR2021-01-04 00:00:00Z58.1558.8556.7857.2157.747498
ABI.BR2021-01-05 00:00:00Z56.957.9856.7557.1857.2025
ABI.BR2021-01-06 00:00:00Z57.9658.9457.3958.7758.265
ABI.BR2021-01-07 00:00:00Z58.6858.8657.8858.458.455
ABI.BR2021-01-08 00:00:00Z58.1658.457.4357.8657.962498

Polars.NET / Microsoft.Data.Analysis | String Operations

Polars.NET | Build a direction tag with IfElse

String-valued expressions work the same way as numeric ones — Lit("UP") creates a string constant. This example builds a directional label per row using nested IfElse.

Assigns “UP”, “DOWN”, or “FLAT” to a tag column using two nested IfElse expressions on close vs. open, showing 5 ABI.BR rows where the first row tags as “DOWN” (close 57.21 < open 58.15). Runs the example cell.

var dfLabel = dfP.WithColumns(
    IfElse(
        Col("close") > Col("open"),
        Lit("UP"),
        IfElse(Col("close") < Col("open"), Lit("DOWN"), Lit("FLAT"))
    ).Alias("tag")
);
 
dfLabel.Select("symbol", "date", "close", "open", "tag").Head(5)
symboldatecloseopentag
ABI.BR2021-01-0457.2158.15DOWN
ABI.BR2021-01-0557.1856.9UP
ABI.BR2021-01-0658.7757.96UP
ABI.BR2021-01-0758.458.68DOWN
ABI.BR2021-01-0857.8658.16DOWN

Microsoft.Data.Analysis | Build a direction tag with explicit string assignment

MDA string transforms usually mean allocating a StringDataFrameColumn and filling it inside a loop. For short label-generation logic, that is perfectly serviceable and keeps the resulting column strongly associated with the source frame.

Builds a tag column with UP, DOWN, or FLAT based on close versus open and previews the first five rows. Runs the example cell.

var tagColM = new MDA.StringDataFrameColumn("tag", df.Rows.Count);
for (long i = 0; i < df.Rows.Count; i++)
{
    float c = closeColM[i].GetValueOrDefault();
    float o = openColM[i].GetValueOrDefault();
    tagColM[i] = c > o ? "UP" : (c < o ? "DOWN" : "FLAT");
}
 
var dfLabelM = df.Clone();
dfLabelM.Columns.Add(tagColM);
new MDA.DataFrame(dfLabelM.Columns["symbol"], dfLabelM.Columns["date"], dfLabelM.Columns["close"], dfLabelM.Columns["open"], dfLabelM.Columns["tag"]).Head(5)
symboldatecloseopentag
ABI.BR2021-01-04 00:00:00Z57.2158.15DOWN
ABI.BR2021-01-05 00:00:00Z57.1856.9UP
ABI.BR2021-01-06 00:00:00Z58.7757.96UP
ABI.BR2021-01-07 00:00:00Z58.458.68DOWN
ABI.BR2021-01-08 00:00:00Z57.8658.16DOWN

Type Casting

Type casting converts column data into the representation your downstream code actually needs. Polars.NET keeps casting inside expressions; Microsoft.Data.Analysis usually builds an explicitly typed target column and populates it value by value or through typed column arithmetic.

Polars.NET / Microsoft.Data.Analysis | Numeric Cast

Polars.NET | Cast a column to Float64 with Col.Cast

.Cast(DataType.Float64) converts the column’s underlying storage type. When aliased to a new name, the original column is preserved alongside the cast version.

Casts id (i64) to a new id_float (f64) column using a different alias, confirms both dtype names with DataTypeName, and shows 3 rows where integer values 21160–21162 are preserved in both columns. Runs the example cell.

var dfCast1 = dfP.WithColumns(
    Col("id").Cast(DataType.Float64).Alias("id_float")
);
 
display($"id dtype: {dfCast1["id"].DataTypeName}  |  id_float dtype: {dfCast1["id_float"].DataTypeName}");
dfCast1.Select("id", "id_float").Head(3)

id dtype: i64 | id_float dtype: f64

idid_float
2116021160
2116121161
2116221162

Microsoft.Data.Analysis | Convert id to float with a typed target column

MDA exposes each column’s CLR type, but cross-type conversion still usually means constructing a new typed target column and populating it explicitly. This example also checks for column existence first so the notebook fails gracefully if the CSV schema changes.

Checks that id exists, converts it into id_float, prints both data types, and previews the first three rows. Runs the example cell.

MDA.DataFrame resultM = null;
 
if (df.Columns.IndexOf("id") >= 0)
{
    var idColM = df.Columns["id"];
    var idFloatM = new MDA.PrimitiveDataFrameColumn<double>("id_float", df.Rows.Count);
    for (long i = 0; i < df.Rows.Count; i++) if (idColM[i] != null) idFloatM[i] = Convert.ToDouble(idColM[i]);
 
    Console.WriteLine($"id dtype: {idColM.DataType.Name}  |  id_float dtype: {idFloatM.DataType.Name}");
 
    resultM = new MDA.DataFrame(idColM, idFloatM).Head(3);
}
else
{
    Console.WriteLine("'id' column not present in this CSV structure.");
}
 
resultM
id dtype: Single  |  id_float dtype: Double
idid_float
2116021160
2116121161
2116221162

Polars.NET / Microsoft.Data.Analysis | Date Parsing

Polars.NET | Parse string dates with Str.ToDate

.Str.ToDate(format) parses a string column into a Polars Date type using a strftime format string. Since tryParseDates: true already parsed the date column during CSV load, this example first casts the date back to string to demonstrate the parsing roundtrip.

First casts date back to str to simulate a raw string input, then re-parses it with Str.ToDate("%Y-%m-%d"), confirming the roundtrip restores the date dtype and that 3 sample rows show the same 2021-01-04/05/06 values. Runs the example cell.

var dfDateStr = dfP.WithColumns(
    Col("date").Cast(DataType.String).Alias("date_str")
);
 
var dfDateParsed = dfDateStr.WithColumns(
    Col("date_str").Str.ToDate("%Y-%m-%d").Alias("date_parsed")
);
 
display($"date_str dtype: {dfDateParsed["date_str"].DataTypeName}  |  date_parsed dtype: {dfDateParsed["date_parsed"].DataTypeName}");
dfDateParsed.Select("date_str", "date_parsed").Head(3)

date_str dtype: str | date_parsed dtype: date

date_strdate_parsed
2021-01-042021-01-04
2021-01-052021-01-05
2021-01-062021-01-06

Microsoft.Data.Analysis | Parse string dates into a typed DateTime column

Date parsing is explicit in MDA: allocate a PrimitiveDataFrameColumn<DateTime> and write parsed values only when DateTime.TryParse succeeds. That gives you predictable null behavior when parsing semi-clean strings.

Parses date into a new date_parsed column, prints both data types, and previews the first three rows. Runs the example cell.

var dateStrColM = df.Columns["date"];
var dateParsedColM = new MDA.PrimitiveDataFrameColumn<DateTime>("date_parsed", df.Rows.Count);
 
for (long i = 0; i < df.Rows.Count; i++)
{
    var val = dateStrColM[i]?.ToString();
    if (DateTime.TryParse(val, out var dt)) dateParsedColM[i] = dt;
}
 
display($"date dtype: {dateStrColM.DataType.Name}  |  date_parsed dtype: {dateParsedColM.DataType.Name}");
new MDA.DataFrame(dateStrColM, dateParsedColM).Head(3)
date dtype: DateTime  |  date_parsed dtype: DateTime
datedate_parsed
2021-01-04 00:00:00Z2021-01-04 00:00:00Z
2021-01-05 00:00:00Z2021-01-05 00:00:00Z
2021-01-06 00:00:00Z2021-01-06 00:00:00Z

Polars.NET / Microsoft.Data.Analysis | Categorical Encoding

Polars.NET | Cast to Categorical for memory-efficient string storage

.Cast(DataType.Categorical) dictionary-encodes the column — each unique string is stored once, and the column stores integer codes. This dramatically reduces memory for columns with high repetition (e.g., 66K rows but only 50 unique symbols).

Casts symbol to a cat column aliased as symbol_cat, reports the dtype change from str to cat and 50 unique values, then shows 3 rows confirming that display values remain human-readable strings. Runs the example cell.

var dfCat = dfP.WithColumns(
    Col("symbol").Cast(DataType.Categorical).Alias("symbol_cat")
);
 
display($"symbol dtype: {dfCat["symbol"].DataTypeName}  |  symbol_cat dtype: {dfCat["symbol_cat"].DataTypeName}");
display($"Unique symbols: {dfCat["symbol_cat"].NUnique}");
dfCat.Select("symbol", "symbol_cat").Head(3)

symbol dtype: str | symbol_cat dtype: cat

Unique symbols: 50

symbolsymbol
ABI.BRABI.BR
ABI.BRABI.BR
ABI.BRABI.BR

Microsoft.Data.Analysis | No native categorical type in the dataframe API

Unlike Polars categorical casting, the MDA dataframe API centers on primitive and string columns. A practical notebook pattern is to keep the string column and inspect distinct cardinality with ValueCounts(); if you need encoded features, create numeric codes explicitly at the model-facing layer.

Reports the symbol column type, counts distinct values with ValueCounts(), and previews the first three rows of the original string column.

Where should encoding happen?

Keep business-readable labels in the dataframe while you are exploring or validating the data. If you need stable numeric encoding for a model, build that encoding deliberately in the feature-engineering or ML pipeline stage instead of assuming the dataframe library has a native categorical abstraction.

Runs the distinct-count check against the original MDA string column. Runs the example cell.

var symbolStrColM = (MDA.StringDataFrameColumn)df.Columns["symbol"];
var uniqueCountM = symbolStrColM.ValueCounts().Rows.Count;
 
display($"symbol dtype: {symbolStrColM.DataType.Name}");
display($"Unique symbols: {uniqueCountM}");
new MDA.DataFrame(symbolStrColM).Head(3)
symbol dtype: String
Unique symbols: 50
symbol
ABI.BR
ABI.BR
ABI.BR

Method Chaining & Window Functions

Method chaining composes multiple dataframe operations into a readable pipeline. In Polars.NET, the chain is still expression-oriented and can later move toward lazy execution. In Microsoft.Data.Analysis, the same work is usually expressed as a sequence of named intermediate frames and typed columns.

When should rolling or window logic stay in the dataframe layer?

If the data is already local and the goal is notebook-side feature engineering, either library is acceptable. If the same rolling or window logic becomes a recurring production transform over larger partitions, prefer Polars or move the computation upstream into SQL, Spark, or a streaming engine so you do not own custom loop code as pipeline infrastructure.

Polars.NET / Microsoft.Data.Analysis | Fluent Chaining

Polars.NET | Fluent chain with Filter, WithColumns, Sort, Head, Select

Each method in the chain returns a new DataFrame, allowing .Filter().WithColumns().Sort().Head().Select() to read as a single declarative pipeline. The query planner can optimize across the entire chain.

Filters to ASML.AS rows, adds return_pct, sorts ascending by return_pct to surface the 10 worst sessions (led by −16% on 2024-10-15), and projects 5 columns — all in a single fluent expression. Runs the example cell.

var dfChain = dfP
    .Filter(Col("symbol") == Lit("ASML.AS"))
    .WithColumns(
        ((Col("close") - Col("open")) / Col("open") * Lit(100.0)).Alias("return_pct")
    )
    .Sort("return_pct", false)   // ascending: worst sessions first
    .Head(10)
    .Select("symbol", "date", "open", "close", "return_pct");
 
dfChain
symboldateopenclosereturn_pct
ASML.AS2024-10-15795.7668.1-16.03619455
ASML.AS2026-01-2813001194.4-8.123076923
ASML.AS2024-07-17946870.9-7.938689218
ASML.AS2025-04-10623577.6-7.287319422
ASML.AS2022-01-10667.5622.3-6.771535581

Microsoft.Data.Analysis | Equivalent staged pipeline

MDA can express the same pipeline, but each stage is a named intermediate: filter, compute a return column, append it, sort, then trim. That explicitness is useful in debugging-heavy notebook work, but less concise than Polars chaining when transform graphs grow.

Filters to ASML.AS, computes return_pct, sorts ascending by that derived value to surface the same worst sessions as Polars, and projects the final columns. Runs the example cell.

var isAsmlMaskM = (MDA.PrimitiveDataFrameColumn<bool>)((MDA.StringDataFrameColumn)df.Columns["symbol"]).ElementwiseEquals("ASML.AS");
var dfAsmlM = df.Filter(isAsmlMaskM);
 
var dfAsmlChronM = dfAsmlM.OrderBy("date");
var asmlOpenM = (MDA.PrimitiveDataFrameColumn<float>)dfAsmlChronM.Columns["open"];
var asmlCloseM = (MDA.PrimitiveDataFrameColumn<float>)dfAsmlChronM.Columns["close"];
var asmlRetM = (asmlCloseM - asmlOpenM) / asmlOpenM * 100.0f;
asmlRetM.SetName("return_pct");
 
dfAsmlChronM.Columns.Add(asmlRetM);
 
var dfChainM = dfAsmlChronM.OrderBy("return_pct").Head(10);
new MDA.DataFrame(dfChainM.Columns["symbol"], dfChainM.Columns["date"], dfChainM.Columns["open"], dfChainM.Columns["close"], dfChainM.Columns["return_pct"])
symbol | date | open | close | return_pct
ASML.AS | 2024-10-15 | 795.7 | 668.1 | -16.0362
ASML.AS | 2026-01-28 | 1300 | 1194.4 | -8.123075
ASML.AS | 2024-07-17 | 946 | 870.9 | -7.938687
ASML.AS | 2025-04-10 | 623 | 577.6 | -7.287323
ASML.AS | 2022-01-10 | 667.5 | 622.3 | -6.771538
ASML.AS | 2025-07-16 | 669.5 | 625.8 | -6.527261
ASML.AS | 2023-08-24 | 643.5 | 603.6 | -6.20047
ASML.AS | 2022-06-16 | 478.05 | 448.85 | -6.108144
ASML.AS | 2021-09-28 | 708.4 | 665.2 | -6.098251
ASML.AS | 2022-07-05 | 434.2 | 409 | -5.80378

Polars.NET / Microsoft.Data.Analysis | Window Functions

Polars.NET | Group-level mean with Over

.Over("column") is Polars’ window function — it partitions the data by the given column, computes the aggregate within each partition, and broadcasts the result back to every row. This is equivalent to SQL’s AVG(close) OVER (PARTITION BY symbol).

Computes the mean close per symbol across all 50 partitions simultaneously and broadcasts it back to every row, so all 8 ABI.BR rows show the same mean_close_by_symbol of ~54.86. Runs the example cell.

var dfOver = dfP.WithColumns(
    Col("close").Mean().Over("symbol").Alias("mean_close_by_symbol")
);
 
dfOver.Select("symbol", "date", "close", "mean_close_by_symbol").Head(8)
symboldateclosemean_close_by_symbol
ABI.BR2021-01-0457.2154.86423366
ABI.BR2021-01-0557.1854.86423366
ABI.BR2021-01-0658.7754.86423366
ABI.BR2021-01-0758.454.86423366
ABI.BR2021-01-0857.8654.86423366

Microsoft.Data.Analysis | Group-level mean via dictionary broadcast

There is no Over-style window expression in MDA. The equivalent pattern is to compute group aggregates in dictionaries and then broadcast the result back into a new column. This works well for modest in-memory frames but gets verbose for richer window logic.

Computes the mean close per symbol, broadcasts it back into mean_close_by_symbol, and previews the first eight rows. Runs the example cell.

var symbolsM = (MDA.StringDataFrameColumn)df.Columns["symbol"];
var sumsM = new Dictionary<string, double>();
var countsM = new Dictionary<string, int>();
 
for (long i = 0; i < df.Rows.Count; i++)
{
    var s = symbolsM[i];
    var c = closeColM[i];
    if (s != null && c.HasValue)
    {
        if (!sumsM.ContainsKey(s)) { sumsM[s] = 0; countsM[s] = 0; }
        sumsM[s] += c.Value;
        countsM[s]++;
    }
}
 
var meanColM = new MDA.PrimitiveDataFrameColumn<float>("mean_close_by_symbol", df.Rows.Count);
for (long i = 0; i < df.Rows.Count; i++)
{
    var s = symbolsM[i];
    if (s != null && countsM.ContainsKey(s)) meanColM[i] = (float)(sumsM[s] / countsM[s]);
}
 
var dfOverM = df.Clone();
dfOverM.Columns.Add(meanColM);
new MDA.DataFrame(dfOverM.Columns["symbol"], dfOverM.Columns["date"], dfOverM.Columns["close"], dfOverM.Columns["mean_close_by_symbol"]).Head(8)
symboldateclosemean_close_by_symbol
ABI.BR2021-01-04 00:00:00Z57.2154.864235
ABI.BR2021-01-05 00:00:00Z57.1854.864235
ABI.BR2021-01-06 00:00:00Z58.7754.864235
ABI.BR2021-01-07 00:00:00Z58.454.864235
ABI.BR2021-01-08 00:00:00Z57.8654.864235

Polars.NET / Microsoft.Data.Analysis | Rolling Aggregates

Polars.NET | Rolling mean with RollingMean

.RollingMean("20i") computes a rolling average over a window of 20 rows. The "20i" syntax specifies an integer-indexed window (20 rows). Polars fills partial windows at the start of the series with the available data, so the first row’s rolling mean equals the first value itself.

Filters and sorts ASML.AS chronologically, then computes a 20-row rolling mean on close — showing partial windows filling from row 1 (mean=406.25) through row 5 (mean=407.19) up to row 25 where the full 20-row window first applies. Runs the example cell.

var dfAsml = dfP.Filter(Col("symbol") == Lit("ASML.AS")).Sort("date", false);
 
var dfRoll = dfAsml.WithColumns(
    Col("close").RollingMean("20i").Alias("close_ma20")
);
 
dfRoll.Select("symbol", "date", "close", "close_ma20").Head(25)
symboldatecloseclose_ma20
ASML.AS2021-01-04406.25406.25
ASML.AS2021-01-05406.9406.575
ASML.AS2021-01-06402.85405.3333333
ASML.AS2021-01-07403.9404.975
ASML.AS2021-01-08416.05407.19

Microsoft.Data.Analysis | Rolling mean with an explicit sliding loop

Rolling logic in MDA is stateful code over the ordered frame. The example sorts ASML rows and then computes a 20-row moving mean manually. This gives full control over window semantics, but the developer owns every edge-case detail.

Builds a 20-row rolling mean over ASML closes in chronological order and previews the first ten rows. Runs the example cell.

var dfRollBaseChronM = dfAsmlM.OrderBy("date").Clone();
var rollCloseColM = (MDA.PrimitiveDataFrameColumn<float>)dfRollBaseChronM.Columns["close"];
var ma20ColM = new MDA.PrimitiveDataFrameColumn<float>("close_ma20", dfRollBaseChronM.Rows.Count);
 
for (long i = 0; i < dfRollBaseChronM.Rows.Count; i++)
{
    float sum = 0;
    int count = 0;
    for (long j = 0; j < 20 && (i - j) >= 0; j++)
    {
        var val = rollCloseColM[i - j];
        if (val.HasValue) { sum += val.Value; count++; }
    }
    if (count > 0) ma20ColM[i] = sum / count;
}
 
dfRollBaseChronM.Columns.Add(ma20ColM);
new MDA.DataFrame(dfRollBaseChronM.Columns["symbol"], dfRollBaseChronM.Columns["date"], dfRollBaseChronM.Columns["close"], dfRollBaseChronM.Columns["close_ma20"]).Head(10)
symbol | date | close | close_ma20
ASML.AS | 2021-01-04 | 406.25 | 406.25
ASML.AS | 2021-01-05 | 406.9 | 406.575
ASML.AS | 2021-01-06 | 402.85 | 405.3333
ASML.AS | 2021-01-07 | 403.9 | 404.975
ASML.AS | 2021-01-08 | 416.05 | 407.19
ASML.AS | 2021-01-11 | 414.9 | 408.475
ASML.AS | 2021-01-12 | 418.95 | 409.9714
ASML.AS | 2021-01-13 | 422.45 | 411.5312
ASML.AS | 2021-01-14 | 447.35 | 415.5111
ASML.AS | 2021-01-15 | 435.85 | 417.545

Polars.NET / Microsoft.Data.Analysis | Cumulative Operations

Polars.NET | Cumulative sum with CumSum

.CumSum() computes the running total of a column. Combined with .Filter() and .Sort(), this builds a cumulative volume curve for a single symbol. The operation is vectorized and runs in a single pass.

Filters and sorts ASML.AS by date, applies .CumSum() to volume, building a running total that grows from 789K on day 1 to 9.05M by day 10. Runs the example cell.

var dfAsmlCum = dfP
    .Filter(Col("symbol") == Lit("ASML.AS"))
    .Sort("date", false)
    .WithColumns(
        Col("volume").CumSum().Alias("cum_volume")
    );
 
dfAsmlCum.Select("symbol", "date", "volume", "cum_volume").Head(10)
symboldatevolumecum_volume
ASML.AS2021-01-04789502789502
ASML.AS2021-01-057987871588289
ASML.AS2021-01-068757112464000
ASML.AS2021-01-078747803338780
ASML.AS2021-01-089752434314023

Microsoft.Data.Analysis | Cumulative volume with a running accumulator

Cumulative transforms are another explicit-state pattern in MDA: hold the running accumulator in a scalar, write each step into a typed target column, and append the result to the working frame.

Accumulates ASML trading volume into cum_volume and previews the first ten rows. Runs the example cell.

var dfCumBaseM = dfAsmlM.OrderBy("date").Clone();
var cumVolColM = new MDA.PrimitiveDataFrameColumn<double>("cum_volume", dfCumBaseM.Rows.Count);
var rollVolColM = dfCumBaseM.Columns["volume"];
double currentCumM = 0;
 
for (long i = 0; i < dfCumBaseM.Rows.Count; i++)
{
    double v = Convert.ToDouble(rollVolColM[i] ?? 0.0);
    currentCumM += v;
    cumVolColM[i] = currentCumM;
}
 
dfCumBaseM.Columns.Add(cumVolColM);
new MDA.DataFrame(dfCumBaseM.Columns["symbol"], dfCumBaseM.Columns["date"], dfCumBaseM.Columns["volume"], dfCumBaseM.Columns["cum_volume"]).Head(10)
symbol | date | volume | cum_volume
ASML.AS | 2021-01-04 | 789502 | 789502
ASML.AS | 2021-01-05 | 798787 | 1588289
ASML.AS | 2021-01-06 | 875711 | 2464000
ASML.AS | 2021-01-07 | 874780 | 3338780
ASML.AS | 2021-01-08 | 975243 | 4314023
ASML.AS | 2021-01-11 | 717929 | 5031952
ASML.AS | 2021-01-12 | 787472 | 5819424
ASML.AS | 2021-01-13 | 669646 | 6489070
ASML.AS | 2021-01-14 | 1272594 | 7761664
ASML.AS | 2021-01-15 | 1291058 | 9052722

Polars.NET / Microsoft.Data.Analysis | Rank

Polars.NET | Rank with Col.Rank expression

.Rank() assigns a rank to each value. By default, Polars uses the “average” method for ties (e.g., two values tied for rank 11 both receive 11.5). The ranking is computed within the filtered/sorted context — here, close prices for a single symbol.

Filters and sorts ASML.AS by date, ranks all close values across the full history using average-tie handling, and shows 10 rows — confirming rank 6 on 2021-01-04 (close=406.25) as the 6th-lowest ASML close price and rank 11.5 for a tied pair. Runs the example cell.

var dfRank = dfP
    .Filter(Col("symbol") == Lit("ASML.AS"))
    .Sort("date", false)
    .WithColumns(
        Col("close").Rank().Alias("close_rank")
    );
 
dfRank.Select("symbol", "date", "close", "close_rank").Head(10)
symboldatecloseclose_rank
ASML.AS2021-01-04406.256
ASML.AS2021-01-05406.97
ASML.AS2021-01-06402.853
ASML.AS2021-01-07403.95
ASML.AS2021-01-08416.0513

Microsoft.Data.Analysis | Rank values by sorting indexed observations

MDA has no built-in rank expression, so ranking is a two-step algorithm: gather the non-null values with their row positions, sort them, and then write the rank back into a new numeric column. To keep the comparison aligned with the Polars example, this version assigns average ranks across ties.

Ranks ASML close prices by value with average-tie handling and previews the first ten chronological rows. Runs the example cell.

var dfRankBaseM = dfAsmlM.OrderBy("date").Clone();
var rankCloseColM = (MDA.PrimitiveDataFrameColumn<float>)dfRankBaseM.Columns["close"];
var rankColM = new MDA.PrimitiveDataFrameColumn<double>("close_rank", dfRankBaseM.Rows.Count);
var indexedClosesM = new List<(long Index, float Value)>();
 
for (long i = 0; i < dfRankBaseM.Rows.Count; i++)
{
    if (rankCloseColM[i].HasValue) indexedClosesM.Add((i, rankCloseColM[i].Value));
}
 
var sortedClosesM = indexedClosesM.OrderBy(x => x.Value).ThenBy(x => x.Index).ToList();
for (int pos = 0; pos < sortedClosesM.Count;)
{
    int groupEnd = pos;
    while (groupEnd + 1 < sortedClosesM.Count && sortedClosesM[groupEnd + 1].Value == sortedClosesM[pos].Value)
    {
        groupEnd++;
    }
 
    double avgRank = ((pos + 1) + (groupEnd + 1)) / 2.0;
    for (int i = pos; i <= groupEnd; i++)
    {
        rankColM[sortedClosesM[i].Index] = avgRank;
    }
 
    pos = groupEnd + 1;
}
 
dfRankBaseM.Columns.Add(rankColM);
new MDA.DataFrame(dfRankBaseM.Columns["symbol"], dfRankBaseM.Columns["date"], dfRankBaseM.Columns["close"], dfRankBaseM.Columns["close_rank"]).Head(10)
symbol | date | close | close_rank
ASML.AS | 2021-01-04 | 406.25 | 6
ASML.AS | 2021-01-05 | 406.9 | 7
ASML.AS | 2021-01-06 | 402.85 | 3
ASML.AS | 2021-01-07 | 403.9 | 5
ASML.AS | 2021-01-08 | 416.05 | 13
ASML.AS | 2021-01-11 | 414.9 | 11.5
ASML.AS | 2021-01-12 | 418.95 | 14
ASML.AS | 2021-01-13 | 422.45 | 16
ASML.AS | 2021-01-14 | 447.35 | 44
ASML.AS | 2021-01-15 | 435.85 | 25

Polars.NET / Microsoft.Data.Analysis | Percent Change

Polars.NET | Day-over-day percent change with PctChange

.PctChange(n) computes (current - previous) / previous with a configurable lag. The first row returns null because there is no prior value. This is one of the most common operations in financial time series analysis.

Filters and sorts ASML.AS chronologically, computes .PctChange(1) on close, producing null on 2021-01-04 and fractional daily returns thereafter (e.g., +0.16% on 2021-01-05, then −0.995% on 2021-01-06). Runs the example cell.

var dfPctChg = dfP
    .Filter(Col("symbol") == Lit("ASML.AS"))
    .Sort("date", false)
    .WithColumns(
        Col("close").PctChange(1).Alias("close_pct_change")
    );
 
dfPctChg.Select("symbol", "date", "close", "close_pct_change").Head(10)
symboldatecloseclose_pct_change
ASML.AS2021-01-04406.25null
ASML.AS2021-01-05406.90.0016
ASML.AS2021-01-06402.85-0.00995330548
ASML.AS2021-01-07403.90.002606429192
ASML.AS2021-01-08416.050.03008170339

Microsoft.Data.Analysis | Day-over-day percent change with explicit lag logic

Lag-based transforms are explicit loops in MDA. The current row and prior row are read from the typed price column, and the result is written only when both values are present and the prior value is nonzero.

Computes day-over-day percent change for ASML closes in chronological order and previews the first ten rows. Runs the example cell.

var dfPctBaseM = dfAsmlM.OrderBy("date").Clone();
var pctCloseColM = (MDA.PrimitiveDataFrameColumn<float>)dfPctBaseM.Columns["close"];
var pctChangeColM = new MDA.PrimitiveDataFrameColumn<float>("close_pct_change", dfPctBaseM.Rows.Count);
 
for (long i = 1; i < dfPctBaseM.Rows.Count; i++)
{
    var curr = pctCloseColM[i];
    var prev = pctCloseColM[i - 1];
    if (curr.HasValue && prev.HasValue && prev.Value != 0)
    {
        pctChangeColM[i] = (curr.Value - prev.Value) / prev.Value;
    }
}
 
dfPctBaseM.Columns.Add(pctChangeColM);
new MDA.DataFrame(dfPctBaseM.Columns["symbol"], dfPctBaseM.Columns["date"], dfPctBaseM.Columns["close"], dfPctBaseM.Columns["close_pct_change"]).Head(10)
symbol | date | close | close_pct_change
ASML.AS | 2021-01-04 | 406.25 | <null>
ASML.AS | 2021-01-05 | 406.9 | 0.00159999
ASML.AS | 2021-01-06 | 402.85 | -0.00995328
ASML.AS | 2021-01-07 | 403.9 | 0.0026064
ASML.AS | 2021-01-08 | 416.05 | 0.03008169
ASML.AS | 2021-01-11 | 414.9 | -0.00276408
ASML.AS | 2021-01-12 | 418.95 | 0.00976143
ASML.AS | 2021-01-13 | 422.45 | 0.00835422
ASML.AS | 2021-01-14 | 447.35 | 0.05894187
ASML.AS | 2021-01-15 | 435.85 | -0.02570694

Apply / Map / UDF

User-defined functions (UDFs) apply custom logic element-wise or row-wise. Polars encourages native expressions over UDF-style code because expressions stay vectorized and optimizable. Microsoft.Data.Analysis is more comfortable with explicit CLR loops, but those loops are still eager notebook code rather than reusable query semantics.

Prefer built-in column operations over manual loops

In Polars, expression-based transforms keep the work vectorized and compatible with lazy optimization. In Microsoft.Data.Analysis, typed column arithmetic is still preferable to per-row loops whenever possible because it keeps the code shorter and reduces custom state handling.

Polars.NET / Microsoft.Data.Analysis | Element-wise UDF

Polars.NET | Element-wise UDF via extract-transform-add

MapElements is not available in Polars.NET 0.4.0. The workaround extracts the column to a C# array with .ToArray<T>(), applies a LINQ .Select() transform, wraps the result as a Polars.CSharp.Series, and stacks it onto the DataFrame with .HStack().

Extracts ASML.AS close as a double[], applies Math.Log() element-wise via LINQ, wraps the result as a named Series, and attaches it with .HStack() — showing the first 8 rows with natural log prices between 6.007 and 6.046.

MapElements not available in Polars.NET 0.4.0

The Python Polars map_elements() function has no direct equivalent in the .NET bindings at version 0.4.0. Use the extract-transform-add pattern shown below.

Preferred workaround

Keep the transformation in Polars expressions whenever possible. Use extract-transform-add only for CLR-specific logic that genuinely cannot be expressed with the current Polars.NET API surface.

Runs the extract-transform-add workaround described above. Runs the example cell.

var dfAsmlU = dfP
    .Filter(Col("symbol") == Lit("ASML.AS"))
    .Sort("date");
 
var closeArr = dfAsmlU.Column("close").ToArray<double>();
var logArr = closeArr.Select(v => Math.Log(v)).ToArray();
var logSeries = Polars.CSharp.Series.From("log_close", logArr);
 
var dfUdf = dfAsmlU.HStack(logSeries);
dfUdf.Select("symbol", "date", "close", "log_close").Head(8)
symboldatecloselog_close
ASML.AS2021-01-04406.256.006968734
ASML.AS2021-01-05406.96.008567455
ASML.AS2021-01-06402.855.998564284
ASML.AS2021-01-07403.96.001167323
ASML.AS2021-01-08416.056.030805445

Microsoft.Data.Analysis | Element-wise transform with an explicit target column

In MDA, custom elementwise transforms are usually direct loops over a typed source column. That is mechanically simple and fits CLR-native math well, but it is still eager, notebook-local materialization rather than an optimizable expression.

Applies Math.Log() to ASML close prices, appends log_close, and previews the first eight rows. Runs the example cell.

var dfAsmlLogM = dfAsmlM.OrderBy("date");
var asmlLogCloseColM = (MDA.PrimitiveDataFrameColumn<float>)dfAsmlLogM.Columns["close"];
var logColM = new MDA.PrimitiveDataFrameColumn<double>("log_close", dfAsmlLogM.Rows.Count);
 
for (long i = 0; i < dfAsmlLogM.Rows.Count; i++)
{
    if (asmlLogCloseColM[i].HasValue) logColM[i] = Math.Log(asmlLogCloseColM[i].Value);
}
 
dfAsmlLogM.Columns.Add(logColM);
new MDA.DataFrame(dfAsmlLogM.Columns["symbol"], dfAsmlLogM.Columns["date"], dfAsmlLogM.Columns["close"], dfAsmlLogM.Columns["log_close"]).Head(8)
symboldatecloselog_close
ASML.AS2021-01-04 00:00:00Z406.256.006968733643947
ASML.AS2021-01-05 00:00:00Z406.96.008567440007606
ASML.AS2021-01-06 00:00:00Z402.855.998564299374044
ASML.AS2021-01-07 00:00:00Z403.96.001167307457915
ASML.AS2021-01-08 00:00:00Z416.056.03080541600614

Polars.NET / Microsoft.Data.Analysis | Row-wise Logic

Polars.NET | Row-wise logic with expressions (preferred over UDFs)

Multi-column row-wise conditions are best expressed with Polars’ expression combinators using & (AND) and | (OR). This keeps the operation vectorized and optimizable. The volume column is cast to Float64 for comparison with the Lit() constant.

Flags rows where close > open AND volume > 2,000,000 as bullish_high_vol, finding 13,931 such rows across all 50 symbols — previewing 8 ABI.BR rows which are all false due to low volume. Runs the example cell.

var dfRowWise = dfP.WithColumns(
    IfElse(
        (Col("close") > Col("open")) & (Col("volume").Cast(DataType.Float64) > Lit(2_000_000.0)),
        Lit(true),
        Lit(false)
    ).Alias("bullish_high_vol")
);
 
display($"Bullish high-vol rows: {dfRowWise.Filter(Col("bullish_high_vol") == Lit(true)).Shape}");
dfRowWise.Select("symbol", "date", "close", "open", "volume", "bullish_high_vol").Head(8)

Bullish high-vol rows: (13931, 13)

symboldatecloseopenvolumebullish_high_vol
ABI.BR2021-01-0457.2158.151513937false
ABI.BR2021-01-0557.1856.91382722false
ABI.BR2021-01-0658.7757.961370204false
ABI.BR2021-01-0758.458.681469911false
ABI.BR2021-01-0857.8658.161428681false

Microsoft.Data.Analysis | Row-wise boolean flag with an explicit bool column

Multi-column row logic is expressed by reading the relevant typed columns together and writing the boolean outcome into a PrimitiveDataFrameColumn<bool>. This is the clearest MDA pattern when the condition cannot be reduced to a simpler precomputed numeric transform.

Flags rows where close > open and volume > 2,000,000, prints the filtered shape, and previews the first eight rows. Runs the example cell.

var bullishVolColM = new MDA.PrimitiveDataFrameColumn<bool>("bullish_high_vol", df.Rows.Count);
 
for (long i = 0; i < df.Rows.Count; i++)
{
    var c = closeColM[i];
    var o = openColM[i];
    var v = volDoubleM[i];
 
    bullishVolColM[i] = c.HasValue && o.HasValue && v.HasValue
                        && (c.Value > o.Value)
                        && (v.Value > 2_000_000.0);
}
 
var dfRowWiseM = df.Clone();
dfRowWiseM.Columns.Add(bullishVolColM);
 
var filteredBullishM = dfRowWiseM.Filter(bullishVolColM);
display($"Bullish high-vol rows: ({filteredBullishM.Rows.Count}, {filteredBullishM.Columns.Count})");
new MDA.DataFrame(dfRowWiseM.Columns["symbol"], dfRowWiseM.Columns["date"], dfRowWiseM.Columns["close"], dfRowWiseM.Columns["open"], dfRowWiseM.Columns["volume"], dfRowWiseM.Columns["bullish_high_vol"]).Head(8)
Bullish high-vol rows: (13931, 13)
symboldatecloseopenvolumebullish_high_vol
ABI.BR2021-01-04 00:00:00Z57.2158.151513937False
ABI.BR2021-01-05 00:00:00Z57.1856.91382722False
ABI.BR2021-01-06 00:00:00Z58.7757.961370204False
ABI.BR2021-01-07 00:00:00Z58.458.681469911False
ABI.BR2021-01-08 00:00:00Z57.8658.161428681False

Summary

This chapter is where the libraries diverge most clearly. Polars.NET gives you a declarative transformation DSL that scales from notebook experimentation into lazy analytical pipelines. Microsoft.Data.Analysis gives you explicit typed-column mechanics that fit .NET-centric preprocessing, ML.NET handoff, and situations where you want total control over every materialized step.

API Comparison

OperationPolars.NETMicrosoft.Data.Analysis
Add columndf.WithColumns(expr.Alias("name"))Build a typed column and append with Columns.Add(...) or new MDA.DataFrame(...)
Multiple transformsMany expressions in one .WithColumns(...)Compute each target column explicitly and append eagerly
Literal / scalar injectionLit(value)Build a constant target column explicitly
Conditional logicIfElse(cond, x, y)Manual pass into StringDataFrameColumn or PrimitiveDataFrameColumn<bool>
Horizontal arithmeticExpression arithmetic or foldsTyped column arithmetic across the source columns
Numeric castCol("c").Cast(DataType.X)Construct a new typed target column and populate it
Date parsingCol("d").Str.ToDate(fmt)Parse into PrimitiveDataFrameColumn<DateTime>
Categorical encodingCol("c").Cast(DataType.Categorical)No native categorical type in the dataframe API
Fluent chaining.Filter().WithColumns().Sort().Head()Named eager stages and intermediate frames
Window / broadcastCol("c").Mean().Over("g")Aggregate into dictionaries and broadcast back manually
Rolling meanCol("c").RollingMean(n)Explicit sliding loop over the ordered frame
Cumulative sumCol("c").CumSum()Running accumulator into a target column
RankCol("c").Rank()Sort indexed observations and write ranks back
Pct changeCol("c").PctChange(n)Manual prior-row loop
Element UDFExtract / transform / HStack workaround in 0.4.0Explicit loop into a target column
Row-wise logicPrefer expressions with IfElse, &, and ``

Decision Criteria

Applied architecture choices should stay reversible. Treat WithColumns(), Over(), RollingMean(), and PctChange() as signals that the transform is drifting toward an analytical pipeline rather than notebook-local preprocessing.

Prefer WithColumns() for analytical pipelines

If the work is mostly columnar arithmetic, conditional branching, windows, or repeated rolling features, Polars.NET keeps the logic declarative and easier to migrate beyond a notebook.

Summarizes the operations that most strongly favor the Polars expression engine. Runs the example cell.

var analyticalOps = new[] { "WithColumns", "Over", "RollingMean", "PctChange", "CumSum" };
Console.WriteLine(string.Join(", ", analyticalOps));
WithColumns, Over, RollingMean, PctChange, CumSum

Prefer DataFrame when the boundary is IDataView

If the notebook is preparing features immediately before ML.NET, Microsoft.Data.Analysis stays practical because DataFrame already fits the .NET-native handoff model.

Lists the .NET-specific boundaries that justify staying in MDA. Runs the example cell.

var mdaBoundaries = new[] { "IDataView", "CLR business rules", "explicit typed columns" };
Console.WriteLine(string.Join(" | ", mdaBoundaries));
IDataView | CLR business rules | explicit typed columns

Push repeated transforms upstream when possible

If the same transform could execute earlier in SQL, DuckDB, Spark, or a lakehouse engine, move it there instead of maintaining notebook-local loops and state handling.

Prints the preferred upstream execution boundaries for repeatable transforms. Runs the example cell.

var upstreamTargets = new[] { "SQL", "DuckDB", "Spark", "lakehouse" };
Console.WriteLine($"Upstream first: {string.Join(", ", upstreamTargets)}");
Upstream first: SQL, DuckDB, Spark, lakehouse

Operational Risks

These are the failure modes that most often make a dataframe transform look correct in prose while doing the wrong thing in code.

Reassign immutable Polars results

WithColumns(), Filter(), and Sort() return a new frame. If you do not capture the result, the transform is discarded even though the expression itself is valid.

Prints the safe reassignment pattern for immutable Polars transforms. Runs the example cell.

var safePattern = "dfP = dfP.WithColumns(...)";
Console.WriteLine(safePattern);
dfP = dfP.WithColumns(...)

Keep IfElse() syntax C#-native

IfElse() is the supported conditional shape in this note. Translating Python when().then().otherwise() examples literally will produce the wrong API for the .NET bindings discussed here.

Prints the supported Polars.NET conditional pattern. Runs the example cell.

var ifElsePattern = "IfElse(Col(\"a\") > Col(\"b\"), Lit(\"yes\"), Lit(\"no\"))";
Console.WriteLine(ifElsePattern);
IfElse(Col("a") > Col("b"), Lit("yes"), Lit("no"))

Map Arrow and CLR types deliberately

Cast() does not erase the difference between Arrow-native dtypes and CLR-native column types. Cross-library work stays safer when you decide the target type explicitly before the next transform.

Prints the type families that need deliberate mapping between libraries. Runs the example cell.

var typePairs = new[] { "Int64 -> long", "Float64 -> double", "Utf8 -> string" };
Console.WriteLine(string.Join(" | ", typePairs));
Int64 -> long | Float64 -> double | Utf8 -> string

C# Transforms, Expressions and Chaining Troubleshooting

Use these checks when the code compiles but the resulting frame or schema does not match the intended transform.

Result unchanged after WithColumns()

If a Polars transform appears unchanged, inspect whether the returned frame from WithColumns() or Filter() was assigned back into a working variable such as dfP.

Prints the first check for a no-op-looking Polars transform. Runs the example cell.

Console.WriteLine("Check reassignment before debugging expression logic.");
Check reassignment before debugging expression logic.

Cast() fails on dirty values

If Cast() raises a conversion error, guard the bad values first with IfElse() or clean the string/numeric source before attempting the type transition.

Prints the first remediation step for cast failures. Runs the example cell.

Console.WriteLine("Clean or guard invalid values before Cast().");
Clean or guard invalid values before Cast().

MDA schema mismatch after manual column construction

If an MDA column does not behave correctly downstream, verify that the chosen PrimitiveDataFrameColumn<T> or StringDataFrameColumn matches the CLR type you actually intend to materialize.

Prints the MDA type-check reminder for manual column construction. Runs the example cell.

Console.WriteLine("Match PrimitiveDataFrameColumn<T> to the final CLR type.");
Match PrimitiveDataFrameColumn<T> to the final CLR type.