When Franjo von Allmen crossed the finish line in 111.61 seconds at the 2026 Olympic Downhill, he claimed gold. But the raw leaderboard only tells part of the story. How dominant was his performance? How tightly packed was the field? Were there outliers that skewed the results?
With the hi-folks/statistics PHP package, you can answer these questions using the same statistical tools that data scientists rely on, right from your PHP code.
In this article, we will walk through a real step-by-step analysis of the 2026 Olympic Men's Downhill race results.
Installing the package
composer require hi-folks/statistics
The package requires PHP 8.2+, and you can find the sources here on GitHub: https://github.com/Hi-Folks/statistics.
The data
We start with the official race results, 34 athletes, each with a name and finish time in seconds:
$results = [
["name" => "Franjo von ALLMEN", "time" => 111.61],
["name" => "Giovanni FRANZONI", "time" => 111.81],
["name" => "Dominik PARIS", "time" => 112.11],
// ... 28 more athletes ...
["name" => "Dmytro SHEPIUK", "time" => 120.11],
["name" => "Cormac COMERFORD", "time" => 124.4],
];
$times = array_column($results, "time");
I extracted the times into a flat array so we can feed them into the statistical functions.
The full PHP example and the full PHP data array are here: https://github.com/Hi-Folks/statistics/blob/main/examples/article-downhill-ski-analysis.php
Step 1: Descriptive statistics, understanding the shape of the data
Before building any model, we need to understand the raw numbers. The Stat class gives us the essential summary in a few lines:
use HiFolks\Statistics\Stat;
$mean = Stat::mean($times);
$median = Stat::median($times);
$std = Stat::stdev($times);
$quartiles = Stat::quantiles($times);
Output:
echo "Sample size: " . count($times) . PHP_EOL;
echo "Mean time: " . round($mean, 2) . " seconds" . PHP_EOL;
echo "Median time: " . round($median, 2) . " seconds" . PHP_EOL;
echo "Standard deviation: " . round($std, 2) . " seconds" . PHP_EOL;
echo "Min: " .
$min .
"s | Max: " .
$max .
"s | Range: " .
round($range, 2) .
"s" .
PHP_EOL;
echo "Quartiles (Q1, Q2, Q3): " .
round($quartiles[0], 2) .
"s, " .
round($quartiles[1], 2) .
"s, " .
round($quartiles[2], 2) .
"s" .
PHP_EOL;
/**
Sample size: 34
Mean time: 114.38 seconds
Median time: 113.60 seconds
Std dev: 2.60 seconds
Min: 111.61s | Max: 124.4s | Range: 12.79s
Quartiles (Q1, Q2, Q3): 112.95s, 113.60s, 114.77s
*/
A few things stand out immediately:
- The mean (114.38) is higher than the median (113.60). In a perfectly symmetric distribution, these two values are identical. When the mean is noticeably higher, it signals that the data is right-skewed; a few slow times are pulling the average up.
- The range is 12.79 seconds, which is enormous for a downhill race. Most athletes finished within a 3-second window (Q1 to Q3: 112.95 to 114.77), but the tail extends to 124.4 seconds (this kind of data, like timing in a downhill competition, depends on the events that occur during the race).
- The standard deviation is 2.60 seconds. This single number summarizes how spread out the results are. We will use it extensively in the next steps.
The Stat::quantiles() method divides the data into four equal groups. The result tells us that 25% of athletes finished under 112.95s (Q1), 50% under 113.60s (Q2/median), and 75% under 114.77s (Q3).
Step 1b: Robust central tendency
The mean (114.38s) is pulled upward by a few slow finishers — Comerford at 124.4s alone adds almost a full second of bias. The trimmed mean solves this by removing a fraction of the extreme values from each end before computing the average:
$trimmedMean10 = Stat::trimmedMean($times, 0.1, 2);
$trimmedMean20 = Stat::trimmedMean($times, 0.2, 2);
echo "Regular mean: " . round(Stat::mean($times), 2) . "s" . PHP_EOL;
echo "Trimmed mean (10%): " . $trimmedMean10 . "s" . PHP_EOL;
echo "Trimmed mean (20%): " . $trimmedMean20 . "s" . PHP_EOL;
/**
Regular mean: 114.38s
Trimmed mean (10%): 113.91s
Trimmed mean (20%): 113.76s
*/
The trimmedMean() method takes a $proportionToCut parameter, the fraction to remove from each side. With 0.1, we remove the 3 fastest and 3 slowest athletes (10% of 34 from each end), and the result drops by almost half a second. With 0.2, it drops further to 113.76s — much closer to the median (113.60s).
This tells us something important: the "typical" downhill time is closer to 113.8s than to 114.4s. The regular mean overstates it because of the tail. When you want a single number to describe "how fast did most athletes go?", the trimmed mean is a better answer.
Step 1c: Percentile analysis
Quartiles divide the data into 4 groups. But sometimes you want more precision "what time do you need to be in the top 10%?" The percentile() method lets you query any point on the distribution:
echo "P10: " . Stat::percentile($times, 10, 2) . "s — elite threshold" . PHP_EOL;
echo "P25: " . Stat::percentile($times, 25, 2) . "s — top quarter" . PHP_EOL;
echo "P50: " . Stat::percentile($times, 50, 2) . "s — median" . PHP_EOL;
echo "P75: " . Stat::percentile($times, 75, 2) . "s — bottom quarter" . PHP_EOL;
echo "P90: " . Stat::percentile($times, 90, 2) . "s — struggling" . PHP_EOL;
/**
P10: 112.21s — elite threshold
P25: 112.95s — top quarter
P50: 113.6s — median
P75: 114.77s — bottom quarter
P90: 118.2s — struggling
*/
Notice the gap between P75 (114.77s) and P90 (118.2s), over 3 seconds. Compare that to the gap between P10 (112.21s) and P25 (112.95s), less than a second. This asymmetry is the right skew we saw earlier, now quantified: the bottom of the field is much more spread out than the top.
Step 1d: Precision of the mean
How precise is our mean of 114.38s? With only 34 athletes, there is some uncertainty. The "Standard Error of the Mean (SEM)" quantifies this:
$sem = Stat::sem($times, 2);
echo "SEM: " . $sem . "s" . PHP_EOL;
echo "95% confidence interval: "
. round(Stat::mean($times) - 1.96 * $sem, 2) . "s to "
. round(Stat::mean($times) + 1.96 * $sem, 2) . "s" . PHP_EOL;
/**
SEM: 0.45s
95% confidence interval: 113.51s to 115.25s
*/
The SEM is the standard deviation divided by the square root of the sample size: stdev / sqrt(n). With 34 athletes, we can estimate that the "true" mean time for this course and field falls within roughly ±0.87s of our calculated mean, at 95% confidence.
Step 2: Fitting a normal distribution
The normal distribution (the classic bell curve) is one of the most widely used models in statistics. It is defined by just two parameters: the mean (mu) and the standard deviation (sigma). The NormalDist class lets you build one directly from your data:
use HiFolks\Statistics\NormalDist;
$normal = NormalDist::fromSamples($times);
echo "mu (mean): " . $normal->getMeanRounded(2); // 114.38
echo "sigma (std dev): " . $normal->getSigmaRounded(2); // 2.60
The fromSamples() method calculates both parameters from the data and returns a NormalDist object. From this point forward, you can ask probabilistic questions about the distribution.
Checking the fit: model median vs actual median
A quick sanity check: in a perfect normal distribution, the mean, median, and mode are all equal. We can verify this with getMedian():
echo "Model median: " . $normal->getMedianRounded(2); // 114.38
echo "Actual median: " . round($median, 2); // 113.60
The model median is 114.38 (equal to the mean, as expected), but the actual median is 113.60, a gap of 0.78 seconds. This confirms what we suspected: the data is right-skewed, and the normal distribution is not a perfect fit.
This is an important lesson: a normal distribution assumes symmetry. When your data has a long tail in one direction (as race results often do), the model will be an approximation. It is still useful for insight, but you should be aware of its limitations.
Step 3: Asking probabilistic questions
Once you have a NormalDist object, you can ask questions that would be difficult to answer from raw data alone.
"What percentage of racers finished under 113 seconds?"
The Cumulative Distribution Function (CDF) answers exactly this. It returns the probability that a random value from the distribution falls at or below a given point:
$target = 113.0;
$probUnder = $normal->cdfRounded($target, 4);
echo "Model: P(time <= 113s) = " . round($probUnder * 100, 1) . "%";
// Model: 29.8%
The model predicts 29.8%. The actual count is 9 out of 34 athletes (26.5%). The gap is a direct consequence of the skewness we identified earlier: the inflated mean shifts the entire curve to the right, making the model slightly overestimate the proportion of fast finishers.
"How likely is a time of exactly 113 seconds?"
The Probability Density Function (PDF) gives the relative likelihood of a specific value:
echo "PDF at 113s = " . $normal->pdfRounded(113.0, 6);
// 0.133488
The PDF value is not a probability (it can exceed 1.0 for narrow distributions), but it lets you compare how likely different times are relative to each other. A higher PDF value means that time is more "typical" for this distribution.
Step 4: Performance thresholds with the inverse CDF
The Inverse CDF works backwards: given a probability, it returns the corresponding value. This is perfect for setting performance thresholds:
$eliteThreshold = $normal->invCdfRounded(0.2, 2);
$slowThreshold = $normal->invCdfRounded(0.8, 2);
echo "Top 20% fastest (below): " . $eliteThreshold; // 112.19
echo "Slowest 20% (above): " . $slowThreshold; // 116.56
According to the model, a time under 112.19 seconds would place an athlete in the top 20%, while a time above 116.56 seconds would place an athlete in the slowest 20%. In practice, only 3 out of 34 athletes (about 9%) broke the 112.19s barrier, another sign that the skewness pushes the model's "elite" threshold to an unrealistically fast time.
Step 5: Z-scores, measuring performance in standard deviations
The z-score expresses a value as the number of standard deviations away from the mean. Negative z-scores mean faster than average, positive means slower:
$z = $normal->zscoreRounded(111.61, 2); // -1.06
Von Allmen's gold-medal time has a z-score of -1.06; he finished about one full standard deviation faster than the average racer. Here is how the z-scores look across the field:
Franjo von ALLMEN 111.61s z: -1.06 Elite
Giovanni FRANZONI 111.81s z: -0.99 Elite
Dominik PARIS 112.11s z: -0.87 Elite
Marco ODERMATT 112.31s z: -0.80 Strong
...
Jan ZABYSTRAN 114.39s z: +0.01 Average
...
Cormac COMERFORD 124.40s z: +3.86 Below avg
Z-scores are powerful because they are universal and comparable. A z-score of -1.06 means the same thing whether you are analyzing ski times, exam scores, or manufacturing tolerances. Comerford's z-score of +3.86 immediately flags him as an extreme outlier, nearly 4 standard deviations from the mean.
Step 5b: Outlier detection
Looking at the z-scores, Comerford's +3.86 stands out dramatically. But how do we systematically identify outliers instead of eyeballing them? The package provides two methods, each with different strengths.
Method 1: Z-score based detection
The outliers() method flags values whose absolute z-score exceeds a threshold. The default is 3.0 (the classic "three sigma rule"), but for a small dataset like ours, 2.5 is more practical:
$zscoreOutliers = Stat::outliers($times, 2.5);
foreach ($zscoreOutliers as $time) {
echo " " . $time . "s" . PHP_EOL;
}
/**
124.4s
*/
Only Comerford is flagged. The z-score method relies on the mean and standard deviation, and here's the catch, "the outliers themselves inflate the stdev", making it harder to detect them. It's a bit like asking a group that includes a giant "is anyone unusually tall?", the giant's height raises the average, making everyone seem more normal.
Method 2: IQR-based detection (box plot whiskers)
The iqrOutliers() method uses the Interquartile Range, which is based on quartiles, not the mean. This makes it robust: outliers don't influence the detection mechanism.
A value is flagged if it falls below Q1 - 1.5 * IQR or above Q3 + 1.5 * IQR:
$iqrOutliers = Stat::iqrOutliers($times);
foreach ($iqrOutliers as $time) {
echo " " . $time . "s" . PHP_EOL;
}
/**
119.24s
120.11s
124.4s
*/
The IQR method catches three outliers: Opmanis (119.24s), Shepiuk (120.11s), and Comerford (124.4s). These are the athletes whose times are far enough from the main pack to fall outside the "whiskers" of a box plot.
This is the key difference:
- Z-score detection: assumes a roughly normal distribution, sensitive to the outliers themselves
- IQR detection: makes no distributional assumptions, robust to extreme values
For skewed data like race results, IQR is generally the better choice.
Step 6: Classifying athletes into tiers
Combining the CDF with custom thresholds, we can classify every athlete into a performance tier:
$tierDefinitions = [
["max" => 0.20, "label" => "Elite"],
["max" => 0.50, "label" => "Strong"],
["max" => 0.80, "label" => "Average"],
["max" => 1.00, "label" => "Below avg"],
];
foreach ($results as $r) {
$percentile = $normal->cdf($r['time']);
$tier = "Below avg";
foreach ($tierDefinitions as $def) {
if ($percentile <= $def['max']) {
$tier = $def['label'];
break;
}
}
$z = $normal->zscoreRounded($r['time'], 2);
echo $r['name'] . " " . $r['time'] . "s " . $tier . " z: " . $z;
}
The CDF returns the percentile rank of each athlete within the model. An athlete at the 15th percentile (like Franzoni) has, according to the model, 85% of the distribution above them. Combined with the z-score, you get a complete picture: tier for quick classification, z-score for precise measurement.
Step 7: Visualizing the distribution with a frequency table
A frequency table groups data into classes and counts how many values fall into each class. The Freq class makes this straightforward:
use HiFolks\Statistics\Freq;
$freqTable = Freq::frequencyTableBySize($times, 1);
foreach ($freqTable as $class => $count) {
echo $class . "s " . str_repeat("*", $count) . " (" . $count . ")";
}
Output:
111s ** (2)
112s ****** (6)
113s ************ (12)
114s ******** (8)
115s * (1)
116s (0)
117s ** (2)
118s (0)
119s * (1)
120s * (1)
124s * (1)
The histogram reveals the story: a dense core of athletes between 111-115 seconds, then a gap, then a handful of stragglers trailing off to 124 seconds. This is the right skew in plain sight. A perfect bell curve would be symmetric around the mean; this clearly is not.
Step 8: Measuring the shape of the distribution with skewness and kurtosis
In Step 7 the frequency table hinted at something: the results are not symmetric.
There is a dense core of fast times on the left, and a long tail stretching right toward 124 seconds. But how asymmetric is the distribution, exactly? And how extreme are those outliers?
Two statistics answer these questions: skewness and kurtosis.
Skewness measures asymmetry. A value of 0 means perfectly balanced. Positive means the tail extends to the right; negative means it extends to the left.
Kurtosis (excess) measures tailedness. How much data lives in the extreme tails compared to a normal distribution. A value of 0 means normal-like tails. Positive means heavier tails with more extreme outliers.
use HiFolks\Statistics\Stat;
$skewness = Stat::skewness($times, 4);
// 2.3114
$kurtosis = Stat::kurtosis($times, 4);
// 6.3361
A skewness of 2.31 confirms what the frequency table showed visually: most racers finished with similar times clustered together, but a handful of really slow finishers (119s, 120s, 124s) drag the average up. The results are not balanced around the middle, there is a long tail of slow times on the right side.
A kurtosis of 6.34 tells us something extra that the frequency table alone could not: those slow finishers are not just a little slow, they are very far from the pack. The gap between the main group and the slowest racers is much larger than you would expect in a typical bell curve. The results have extreme outliers.
Together, these two numbers paint a clear picture: the race had a tight competitive group up front, and a few athletes who were significantly off the pace, making the distribution lopsided (skewness) with extreme stragglers (kurtosis).
The skewness() method uses the adjusted Fisher-Pearson formula, the same used by Excel's SKEW() and Python's scipy.stats.skew(bias=False).
The kurtosis() method returns the excess kurtosis, matching Excel's KURT() and Python's scipy.stats.kurtosis(bias=False).
Step 9: Dispersion beyond standard deviation
The standard deviation (2.60s) tells us how spread out the times are, but it's heavily influenced by the slow outliers. Are the times really that spread out, or is it just a few athletes pulling the number up?
Two alternative measures give us a clearer picture:
$stdev = Stat::stdev($times, 4);
$mad = Stat::meanAbsoluteDeviation($times, 4);
$medianAD = Stat::medianAbsoluteDeviation($times, 4);
echo "Standard deviation: " . $stdev . "s" . PHP_EOL;
echo "Mean Absolute Deviation: " . $mad . "s" . PHP_EOL;
echo "Median Absolute Deviation: " . $medianAD . "s" . PHP_EOL;
/**
Standard deviation: 2.5974s
Mean Absolute Deviation: 1.7056s
Median Absolute Deviation: 0.88s
*/
- The Mean Absolute Deviation (1.71s) uses absolute differences instead of squared ones, so it's less sensitive to extreme values. It tells us "on average, athletes finish about 1.7 seconds from the mean."
- The Median Absolute Deviation (0.88s) goes further, it measures the median distance from the median, making it highly resistant to outliers. It tells us the "core pack" of athletes is within about 1 second of each other.
The gap between stdev (2.60s) and median absolute deviation (0.88s) is striking. It reveals that the field has two distinct groups: a tightly packed main group separated by less than a second, and a handful of stragglers who inflate the traditional dispersion measures.
Step 10: Coefficient of Variation — comparing race tightness
How tight was this race? Standard deviation alone doesn't answer this, because it depends on the scale. A stdev of 2.6s means something very different in a 2-minute downhill than in a 30-second sprint.
The Coefficient of Variation (CV) expresses dispersion as a percentage of the mean, making it comparable across different events:
$cvFull = Stat::coefficientOfVariation($times, 2);
$top10 = array_slice($times, 0, 10);
$cvTop10 = Stat::coefficientOfVariation($top10, 2);
echo "Full field CV: " . $cvFull . "%" . PHP_EOL;
echo "Top 10 CV: " . $cvTop10 . "%" . PHP_EOL;
/**
Full field CV: 2.27%
Top 10 CV: 0.45%
*/
A CV of 2.27% means the times vary by about 2.3% around the mean, a relatively tight field. But the top 10 is five times tighter at just 0.45%. This quantifies what every ski fan knows intuitively: at the top, hundredths of a second matter. The margin between gold and 10th place (1.59s) is tiny compared to the margin between 10th and last (11.2s).
You could use CV to compare across sports or across different race editions: "Was the 2026 downhill tighter than 2022?"
Step 11: Weighted median — focus on the contenders
In a race analysis, not all athletes carry equal weight. The top-seeded racers are the main contenders, their times define the competitive field. The weightedMedian() method lets us give more importance to specific athletes:
$weights = [];
foreach ($results as $i => $r) {
$weights[] = $i < 15 ? 3.0 : 1.0; // top-15 seeded athletes weighted 3x
}
$wMedian = Stat::weightedMedian($times, $weights, 2);
echo "Regular median: " . round(Stat::median($times), 2) . "s" . PHP_EOL;
echo "Weighted median: " . $wMedian . "s" . PHP_EOL;
/**
Regular median: 113.6s
Weighted median: 113.28s
*/
The weighted median (113.28s) is lower than the regular median (113.60s) because we're giving triple weight to the top 15 athletes, whose times cluster in the faster range. This answers a different question: "What does a competitive time look like?", as opposed to "What does the typical time look like?"
This is useful for race analysis, but also in any domain where observations have different importance: survey responses with population weights, financial data with volume weights, or sensor readings with reliability weights.
When the normal distribution works (and when it doesn't)
The normal distribution is a great starting point for exploratory analysis, but it assumes symmetry. In our race data:
- The mean (114.38s) diverges from the median (113.60s) by 0.78 seconds
- The model predicts 29.8% of racers under 113s, while the actual figure is 26.5%
- Extreme outliers (Comerford at z = +3.86) have disproportionate influence on the model
This happens because Olympic downhill races have a natural lower bound (you can only ski so fast) but no upper bound (injuries, mistakes, and less experienced athletes create a long right tail).
For data like this, consider:
-
Using the median and quartiles (from
Stat::median()andStat::quantiles()) for robust summary statistics that are not affected by outliers - Trimming outliers before fitting the distribution, removing the slowest 3-4 finishers, would dramatically improve the normal fit for the competitive core
- Exploring other distributions in future analysis (log-normal distributions often fit race times better, as they naturally handle the right-skew)
That said, even an imperfect normal model gave us actionable insights: z-scores, percentile-based tiers, and probability estimates that go far beyond a simple leaderboard.
The complete example
The full script is available in the repository at examples/norm_dist.php. You can run it with:
php examples/norm_dist.php
Summary of the package features used
| Class | Method | What it does |
|---|---|---|
Stat |
mean(), median(), stdev()
|
Basic descriptive statistics |
Stat |
quantiles() |
Divide data into equal-probability intervals |
NormalDist |
fromSamples() |
Build a normal distribution from raw data |
NormalDist |
getMedian() |
Model median (equals mean for normal dist) |
NormalDist |
cdf() |
Probability of a value or lower |
NormalDist |
pdf() |
Relative likelihood of a specific value |
NormalDist |
invCdf() |
Find the value for a given probability |
NormalDist |
zscore() |
Standard deviations from the mean |
Freq |
frequencyTableBySize() |
Group data into classes for histograms |
Stat |
trimmedMean() |
Mean after removing outliers from each side |
Stat |
percentile() |
Value at any percentile (0–100) |
Stat |
weightedMedian() |
Median with weighted observations |
Stat |
sem() |
Standard error of the mean |
Stat |
coefficientOfVariation() |
Relative dispersion as a percentage (CV%) |
Stat |
meanAbsoluteDeviation() |
Average distance from the mean |
Stat |
medianAbsoluteDeviation() |
Median distance from the median (robust) |
Stat |
zscores() |
Z-score for each value in the dataset |
Stat |
outliers() |
Z-score based outlier detection |
Stat |
iqrOutliers() |
IQR-based outlier detection (box plot whiskers) |
Install it and start exploring your own data:
composer require hi-folks/statistics
Top comments (0)