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
| Method | What it does |
|---|---|
Median | Returns the middle value; for an even count, the average of the two middle values. |
Percentile | Returns the value below which a given percent of the values fall, interpolated like Excel's PERCENTILE.INC. |
StandardDeviation | Returns the population standard deviation (divides by n). |
WithoutOutliers | Drops values outside Tukey's fences (1.5 × IQR) and keeps the rest in their original order. |
Mode | Returns the most frequent item of any type. |
LinearTrend | Fits 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.
| Method | double | int | long | decimal |
|---|---|---|---|---|
Median() | double | double | double | decimal |
Percentile(double percentile) | double | double | double | decimal |
StandardDeviation() | double | double | double | double |
WithoutOutliers() | IEnumerable<double> | IEnumerable<int> | IEnumerable<long> | not available |
LinearTrend() | (double Slope, double Intercept) | not available | not available | not available |
Mode<T>() works on a sequence of any item type.
To compute a statistic over a property, project first:
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.
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).
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.
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:
- Sort the values.
- Compute the rank
r = percentile / 100 × (n − 1), wherenis the count. Ranks start at 0. - Take the values at positions
floor(r)andceiling(r), and interpolate between them by the fractional part ofr.
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.
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.
percentilebelow 0, above 100 or equal todouble.NaNthrowsArgumentOutOfRangeExceptionwith the parameter namepercentile.- An empty sequence throws
InvalidOperationException. - A
nullsequence throwsArgumentNullExceptionwith the parameter namesource.
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.
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
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)):
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.
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.
- 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
nullsequence throwsArgumentNullExceptionwith the parameter namesource.
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.
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:
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.
- An empty sequence throws
InvalidOperationException("Sequence contains no elements."). - A
nullsequence throwsArgumentNullExceptionwith the parameter namesource. Thas anotnullconstraint. Anullitem in the sequence throwsArgumentNullException, so filternullitems out first.
Trends
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.
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.
(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).
- 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
nullsequence throwsArgumentNullExceptionwith the parameter namesource(values overload) orpoints(points overload).
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.
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);
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.
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.
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
- Querying sequences — more LINQ-style helpers for reading sequences.
- Transforming sequences — reshape and group values before you summarize them.
- Numbers — rounding and formatting the results.
- Examples — longer scenarios that combine several areas.