Skip to main content

Statistics

These methods compute the descriptive statistics that LINQ leaves out: median, percentiles, standard deviation, mode, outlier removal and a straight-line trend. Reach for them when you summarize latencies, order totals or daily counts and do not want to add a math library.

All methods live in the System.Collections.Generic namespace, so you do not need a using directive.

At a glance​

MethodWhat it does
MedianReturns the middle value; for an even count, the average of the two middle values.
PercentileReturns the value below which a given percent of the values fall, interpolated like Excel's PERCENTILE.INC.
StandardDeviationReturns the population standard deviation (divides by n).
WithoutOutliersDrops values outside Tukey's fences (1.5 × IQR) and keeps the rest in their original order.
ModeReturns the most frequent item of any type.
LinearTrendFits a least-squares line through values (against their index) or through (X, Y) points.

Supported numeric types​

C# does not convert an IEnumerable<int> to an IEnumerable<double>, so each numeric method has one overload per receiver type. The table shows what each overload returns.

Methoddoubleintlongdecimal
Median()doubledoubledoubledecimal
Percentile(double percentile)doubledoubledoubledecimal
StandardDeviation()doubledoubledoubledouble
WithoutOutliers()IEnumerable<double>IEnumerable<int>IEnumerable<long>not available
LinearTrend()(double Slope, double Intercept)not availablenot availablenot available

Mode<T>() works on a sequence of any item type.

To compute a statistic over a property, project first:

Project a property, then compute
Order[] orders =
{
new Order("ORD-1", 19.99m, 3),
new Order("ORD-2", 250.00m, 1),
new Order("ORD-3", 42.50m, 4),
new Order("ORD-4", 87.25m, 2),
};

decimal medianTotal = orders.Select(order => order.Total).Median(); // → 64.875m
double medianItems = orders.Select(order => order.ItemCount).Median(); // → 2.5

public sealed record Order(string Id, decimal Total, int ItemCount);

Central values and spread​

Median​

Median() sorts a copy of the values and returns the middle one. For an even count it returns the average of the two middle values. The input does not need to be sorted, and it is not changed.

Median of unsorted values
double middleOfThree = new[] { 9.0, 1.0, 5.0 }.Median(); // → 5
double middleOfFour = new[] { 4.0, 1.0, 3.0, 2.0 }.Median(); // → 2.5

int[] itemsPerOrder = { 4, 1, 3, 2 };
double medianItems = itemsPerOrder.Median(); // → 2.5

long[] responseBytes = { 30L, 10L, 20L };
double medianBytes = responseBytes.Median(); // → 20

decimal[] invoiceTotals = { 0.3m, 0.1m, 0.2m, 0.4m };
decimal medianInvoice = invoiceTotals.Median(); // → 0.25m

The int and long overloads return a double, because the average of two middle integers can be a fraction. The decimal overload returns an exact decimal.

Median() is the same as Percentile(50).

Empty and null input

An empty sequence throws InvalidOperationException ("Sequence contains no elements."). A null sequence throws ArgumentNullException with the parameter name source.

Percentile​

Percentile(double percentile) returns the value below which percentile percent of the values fall. percentile runs from 0 to 100, so latencies.Percentile(95) is the p95 latency. Percentile(0) is the minimum and Percentile(100) is the maximum.

Percentiles of a few sequences
int[] scores = Enumerable.Range(1, 10).ToArray(); // 1, 2, ..., 10
double p95 = Math.Round(scores.Percentile(95), 2); // → 9.55

double p25 = new[] { 4.0, 3.0, 2.0, 1.0 }.Percentile(25); // → 1.75
double lowest = new[] { 5.0, -2.0, 9.0 }.Percentile(0); // → -2
double highest = new[] { 5.0, -2.0, 9.0 }.Percentile(100); // → 9
double onlyValue = new[] { 42.0 }.Percentile(95); // → 42

decimal[] orderTotals = { 10m, 20m, 30m, 40m };
decimal p50Total = orderTotals.Percentile(50); // → 25.0m

Which definition​

The method uses linear interpolation between the closest ranks. This is the same definition as Excel's PERCENTILE.INC and NumPy's default linear method:

  1. Sort the values.
  2. Compute the rank r = percentile / 100 × (n − 1), where n is the count. Ranks start at 0.
  3. Take the values at positions floor(r) and ceiling(r), and interpolate between them by the fractional part of r.

For the ten values 1 to 10 and percentile = 95: r = 0.95 × 9 = 8.55, so the result is 9 + (10 − 9) × 0.55 = 9.55.

Because the result is interpolated, it is often not one of the input values. A sequence with a single value returns that value for every percentile.

Floating-point results

The double overloads use floating-point arithmetic, so 9.55 can come back as 9.549999999999999. Round before you display or compare. The decimal overload interpolates in decimal, so Percentile(50) over 10m, 20m, 30m, 40m is exactly 25.0m (it prints as 25.0). Its percentile argument is still a double.

Exceptions
  • percentile below 0, above 100 or equal to double.NaN throws ArgumentOutOfRangeException with the parameter name percentile.
  • An empty sequence throws InvalidOperationException.
  • A null sequence throws ArgumentNullException with the parameter name source.

Each call sorts a full copy of the sequence. To get several percentiles from a lazy or expensive query, materialize it once with ToArray() so it is not enumerated again for each call.

StandardDeviation​

StandardDeviation() returns the population standard deviation: how far values typically are from their average. It divides by n, not n − 1. All four overloads return a double; the decimal overload converts the values to double first.

Population standard deviation
double[] deliveryDays = { 2.0, 4, 4, 4, 5, 5, 7, 9 };
double spread = deliveryDays.StandardDeviation(); // → 2

decimal[] refunds = { 2m, 4m, 4m, 4m, 5m, 5m, 7m, 9m };
double refundSpread = refunds.StandardDeviation(); // → 2

double single = new[] { 3.5 }.StandardDeviation(); // → 0
double flat = new[] { 4, 4, 4 }.StandardDeviation(); // → 0
Population, not sample

This method matches the spreadsheet function STDEV.P. If your values are a sample and you want the sample estimate (STDEV.S, which divides by n − 1), multiply by √(n / (n − 1)):

Convert to the sample standard deviation
double[] deliveryDays = { 2.0, 4, 4, 4, 5, 5, 7, 9 };
int count = deliveryDays.Length;

double population = deliveryDays.StandardDeviation(); // → 2
double sample = population * Math.Sqrt(count / (count - 1.0)); // → 2.138089935299395

A single value returns 0. An empty sequence throws InvalidOperationException, and a null sequence throws ArgumentNullException with the parameter name source.

Robust statistics​

WithoutOutliers​

WithoutOutliers() removes values that sit far from the rest, using Tukey's fences. A value is an outlier when it is below Q1 − 1.5 × IQR or above Q3 + 1.5 × IQR. Q1 and Q3 are the 25th and 75th percentiles, computed like Percentile, and IQR = Q3 − Q1. The remaining values keep their original order.

Use it before you compute an average or a spread that one stuck request or one typo should not dominate.

Drop a stray value
double[] checkoutSeconds = { 10.0, 12, 11, 13, 12, 95 };
IEnumerable<double> typicalCheckouts = checkoutSeconds.WithoutOutliers();
// → [10, 12, 11, 13, 12]

double[] balances = { -50.0, 10, 12, 11, 13, 12 };
IEnumerable<double> typicalBalances = balances.WithoutOutliers();
// → [10, 12, 11, 13, 12]

int[] cartSizes = { 10, 12, 11, 13, 12, 95 };
IEnumerable<int> typicalCarts = cartSizes.WithoutOutliers();
// → [10, 12, 11, 13, 12]

In the first example, Q1 is 11.25 and Q3 is 12.75, so the fences are 9 and 15. Only 95 falls outside them.

Overloads exist for double, int and long. Each returns the element type it receives.

Edge cases
  • Fewer than four values are returned unchanged, because quartiles of so few values are not meaningful: new[] { 1.0, 1000, 2 }.WithoutOutliers() → [1, 1000, 2].
  • An empty sequence returns an empty sequence; it does not throw.
  • If all values are equal, the IQR is 0 and every value is kept.
  • The sequence is read into an array when you call the method; it is not streamed.
  • A null sequence throws ArgumentNullException with the parameter name source.

Mode​

Mode<T>() returns the most frequent item. It works on any item type with sensible equality: numbers, strings, characters, enums, records. Items are compared with the default equality comparer.

Most frequent item
string[] orderStatuses = { "Shipped", "Pending", "Shipped", "Cancelled", "Pending", "Shipped" };
string commonStatus = orderStatuses.Mode(); // → "Shipped"

int commonRating = new[] { 3, 1, 3, 2, 1, 3 }.Mode(); // → 3
char commonLetter = "mississippi".Mode(); // → 's'

On a tie, the item that reached the winning count first wins. This is not always the item that appears first in the sequence:

Ties
string winner = new[] { "b", "a", "a", "b" }.Mode(); // → "a"
int firstOfDistinct = new[] { 7, 8, 9 }.Mode(); // → 7

In the first line, "a" reaches a count of 2 at the third item, before "b" does at the fourth.

Exceptions
  • An empty sequence throws InvalidOperationException ("Sequence contains no elements.").
  • A null sequence throws ArgumentNullException with the parameter name source.
  • T has a notnull constraint. A null item in the sequence throws ArgumentNullException, so filter null items out first.

LinearTrend​

LinearTrend() fits a straight line through your data with ordinary least squares. It returns the line's Slope and Intercept as a named tuple. There are two overloads.

Values in order. On an IEnumerable<double>, each value is plotted against its index (0, 1, 2, ...). The slope is the average change per step, and the intercept is the fitted value at index 0.

Trend of daily sign-ups
double[] dailySignups = { 120, 135, 150, 160, 178 };

(double slope, double intercept) = dailySignups.LinearTrend();
// slope → 14.1 (about 14 more sign-ups each day)
// intercept → 120.4 (after rounding; the fitted value for day 0)

double forecastDay5 = Math.Round((slope * 5) + intercept, 1); // → 190.9

(double Slope, double Intercept) falling = new[] { 10.0, 8, 6 }.LinearTrend();
// → (Slope: -2, Intercept: 10)

Points. On an IEnumerable<(double X, double Y)>, the line is Y = Slope × X + Intercept. The X values can have any spacing.

Revenue against ad spend
(double X, double Y)[] spendToRevenue =
{
(1000, 5200),
(2000, 9100),
(3000, 13300),
(4000, 16900),
};

(double slope, double intercept) = spendToRevenue.LinearTrend();
// slope → 3.93 (each extra unit of spend brings about 3.93 of revenue)
// intercept → 1300

A plain (double, double)[] such as new[] { (1.0, 3.0), (2.0, 5.0), (3.0, 7.0) } also works, because tuple element names do not affect the type. That call returns (Slope: 2, Intercept: 1).

Exceptions
  • Fewer than two values or points throws InvalidOperationException.
  • Points that all share the same X throw InvalidOperationException ("All points have the same X, so the slope is undefined.").
  • A null sequence throws ArgumentNullException with the parameter name source (values overload) or points (points overload).
Integer data

LinearTrend() has no int or long overload. Project first: dailyCounts.Select(count => (double)count).LinearTrend().

Example: a latency report​

This example summarizes the response times of ten requests. One request hung at 870 ms. The report shows the percentiles you would put on a dashboard, the same figures with the outlier removed, and the direction of the daily p95 over the last five days.

LatencyReport.cs
double[] latenciesMs = { 120, 95, 110, 105, 130, 98, 102, 870, 115, 108 };
double[] typicalMs = latenciesMs.WithoutOutliers().ToArray(); // 870 removed

double[] dailyP95Ms = { 180, 185, 196, 201, 214 };
(double slope, _) = dailyP95Ms.LinearTrend(); // slope → 8.4

string report = $"""
requests {latenciesMs.Length}
p50 {latenciesMs.Median():0.#} ms
p90 {latenciesMs.Percentile(90):0.#} ms
p95 {latenciesMs.Percentile(95):0.#} ms
p99 {latenciesMs.Percentile(99):0.#} ms
std dev {latenciesMs.StandardDeviation():0.#} ms
outliers {latenciesMs.Length - typicalMs.Length}
p95 (no outliers) {typicalMs.Percentile(95):0.#} ms
std dev (no outliers) {typicalMs.StandardDeviation():0.#} ms
p95 trend {slope:+0.#;-0.#} ms/day
""";

Console.WriteLine(report);
Output
requests 10
p50 109 ms
p90 204 ms
p95 537 ms
p99 803.4 ms
std dev 228.4 ms
outliers 1
p95 (no outliers) 126 ms
std dev (no outliers) 10.4 ms
p95 trend +8.4 ms/day

What the numbers say:

  • The median (109 ms) ignores the hung request, but p95 and p99 do not. With only ten requests, p95 interpolates between the 9th value (130 ms) and the 10th (870 ms).
  • WithoutOutliers() drops the 870 ms request, and the spread falls from 228.4 ms to 10.4 ms. Report both: the raw figures show what users felt, and the cleaned figures show normal behaviour.
  • A positive slope on the daily p95 means latency grows by about 8.4 ms per day.
Culture

The 0.# format strings use the current culture, so the decimal separator can be a comma. Format with CultureInfo.InvariantCulture when a machine reads the output.

NaN values

Median, Percentile and WithoutOutliers sort double.NaN before every other value, so a NaN shifts the result instead of throwing. For example, new[] { 1.0, double.NaN, 3.0 }.Median() returns 1. Filter it out first with Where(value => !double.IsNaN(value)).

See also​