<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <title>dcon</title>
    <link rel="alternate" type="text/html" href="https://blogs.perl.org/users/dcon/" />
    <link rel="self" type="application/atom+xml" href="https://blogs.perl.org/users/dcon/atom.xml" />
    <id>tag:blogs.perl.org,2009-11-03:/users/dcon//4905</id>
    <updated>2026-10-28T17:32:43Z</updated>
    <subtitle>A blog about the Perl programming language</subtitle>
    <generator uri="http://www.sixapart.com/movabletype/">Movable Type Pro 4.38</generator>

<entry>
    <title>Stats::LikeR: high-performance basic statistics</title>
    <link rel="alternate" type="text/html" href="https://blogs.perl.org/users/dcon/2026/10/statsliker-high-performance-basic-statistics.html" />
    <id>tag:blogs.perl.org,2026:/users/dcon//4905.12152</id>

    <published>2026-10-28T16:46:54Z</published>
    <updated>2026-10-28T17:32:43Z</updated>

    <summary> Stats::LikeR: R’s statistics toolkit, native in Perl I got tired of writing CSV files just so I could run a t-test in R. My data was nearly always in Perl already. I’d parse a log or a lab export,...</summary>
    <author>
        <name>David Condon</name>
        <uri>https://www.linkedin.com/in/david-condon-743a8132/</uri>
    </author>
    
    <category term="statistics" label="statistics" scheme="http://www.sixapart.com/ns/types#tag" />
    
    <content type="html" xml:lang="en" xml:base="https://blogs.perl.org/users/dcon/">
        <![CDATA[<!-- ==================== BODY ==================== -->

<p><em>Stats::LikeR: R’s statistics toolkit, native in Perl</em></p>

<p>I got tired of writing CSV files just so I could run a t-test in R.</p>

<p>My data was nearly always in Perl already. I’d parse a log or a lab export, clean it up, dump it to disk, open R, run <code>t.test</code>, and paste the p-value back into the Perl script that built the report. Every one of those hops is a chance for a column to get reordered or an <code>NA</code> to turn into a zero without anyone noticing.</p>

<p><em>Before: Perl, CSV, R, copy by hand, Perl. After: one Perl script.</em></p>

<p><a href="https://metacpan.org/pod/Stats::LikeR">Stats::LikeR</a> is what I wrote so I could stop doing that. Version 0.34 is on CPAN now.</p>

<p><strong>In short:</strong></p>

<ul>
<li><strong>R’s functions, Perl’s data.</strong> <code>mean</code>, <code>sd</code>, <code>quantile</code>, <code>t_test</code>, <code>lm</code>, <code>aov</code>, <code>lmer</code>, <code>coxph</code>, <code>chisq_test</code> and over a hundred more, plus pandas-style data-frame tools: <code>group_by</code>, <code>agg</code>, <code>merge</code>, <code>pivot_table</code>, <code>read_table</code>, <code>write_table</code>. Everything takes and returns plain arrays and hashes.</li>
<li><strong>No second language.</strong> Unlike <code>Statistics::R</code>, there’s no background R session. Unlike <code>PDL</code>, there’s no object system or matrix class to convert into. The heavy lifting is in XS.</li>
<li><strong>Checked against R.</strong> The test suite compares results with R itself, or with pandas 2.2.3 and SciPy 1.15.2 where those are the reference.</li>
<li><strong>Fast at everyday sizes.</strong> Usually the quickest of Perl, R and Python up to a few hundred thousand rows, and the quickest at writing files at every size.</li>
</ul>

<!-- -->

<pre><code>cpanm Stats::LikeR
perl -MStats::LikeR -e 'h()'      # list every documented function
</code></pre>

<!-- ==================== /BODY ==================== -->

<!-- ==================== EXTENDED ==================== -->

<h2>One small analysis, three languages</h2>

<p>A typical job: read a CSV, summarize weight and age by sex, compare the two groups with a t-test, then fit a linear model. The file is made up; the calls are real.</p>

<p><strong>Perl</strong></p>

<pre><code>use Stats::LikeR;

my $df = read_table('patients.csv');    # array of hashes, one per row

my $by_sex = agg($df,
    by  =&gt; 'sex',
    agg =&gt; { wt =&gt; [ 'mean', 'sd' ], age =&gt; [ 'mean', 'count' ] },
);

my @f = map { $_-&gt;{wt} } grep { $_-&gt;{sex} eq 'f' } @$df;
my @m = map { $_-&gt;{wt} } grep { $_-&gt;{sex} eq 'm' } @$df;
my $t = t_test(\@f, \@m);
printf "t = %.3f, p = %.4g\n", $t-&gt;{statistic}, $t-&gt;{p_value};

my $fit = lm(formula =&gt; 'wt ~ age * sex', data =&gt; $df);
</code></pre>

<p><strong>R.</strong> Base <code>aggregate</code> takes one function, so several summaries mean writing an anonymous function that returns a vector:</p>

<pre><code>df &lt;- read.csv("patients.csv")
aggregate(cbind(wt, age) ~ sex, data = df,
          FUN = function(x) c(mean = mean(x), sd = sd(x), n = length(x)))
t.test(df$wt[df$sex == "f"], df$wt[df$sex == "m"])
fit &lt;- lm(wt ~ age * sex, data = df)
</code></pre>

<p><strong>Python</strong> needs three libraries (pandas, SciPy and statsmodels) for the same four steps:</p>

<pre><code>import pandas as pd
from scipy import stats
import statsmodels.formula.api as smf

df = pd.read_csv("patients.csv")
df.groupby("sex").agg({"wt": ["mean", "std"], "age": ["mean", "count"]})
stats.ttest_ind(df.wt[df.sex == "f"], df.wt[df.sex == "m"], equal_var=False)
fit = smf.ols("wt ~ age * sex", data=df).fit()
</code></pre>

<p>If you know R, the Perl will look familiar:</p>

<ul>
<li>Formulas use R’s syntax: <code>*</code> adds the interaction, <code>-</code> drops a term, <code>.</code> means every other column.</li>
<li><code>t_test</code> runs Welch’s test unless you pass <code>var_equal =&gt; 1</code>, the same default as R (Python makes you ask for it).</li>
<li>The result hash has the fields R prints: <code>statistic</code>, <code>df</code>, <code>p_value</code>, <code>conf_int</code>, <code>estimate</code>.</li>
</ul>

<p>And what comes back is ordinary Perl data. <code>$by_sex</code> is an array of hashes because that’s what went in; pass <code>output_type =&gt; 'hoh'</code> for a hash of hashes. Loop over it, dump it to JSON, or hand it to the rest of your program.</p>

<h2>Less typing for everyday jobs</h2>

<p>Most of what I wanted was for the obvious thing to happen without me remembering an argument.</p>

<h3>Reading files: the file name says what to do</h3>

<pre><code>my $csv  = read_table('samples.csv');
my $tsv  = read_table('samples.tsv');                    # tab-separated; no sep needed
my $gz   = read_table('cohort.tsv.gz');                  # decompressed for you
my $xlsx = read_table('results.xlsx', sheet =&gt; 'Sheet2');
</code></pre>

<p>One function for all four. pandas needs <code>sep="\t"</code> for the TSV and a separate <code>pd.read_excel</code> (plus <code>openpyxl</code>) for the spreadsheet. Base R needs <code>read.csv</code>, <code>read.delim</code> and the <code>readxl</code> package. (To be fair, <code>data.table::fread</code> guesses separators.) You can still pass <code>sep</code> when a file is named something odd.</p>

<p>It also reads a commented-out header, which many bioinformatics tools write:</p>

<pre><code># PDB   score
1a2b    10
3c4d    20
</code></pre>

<p><code>read_table</code> uses <code>PDB</code> and <code>score</code> as the column names. pandas and R skip that line as a comment, and you name the columns by hand.</p>

<h3>Writing files works the same way</h3>

<pre><code>write_table($df, 'out.csv');
write_table($df, 'out.xlsx');    # a real Excel workbook
write_table($df, 'out.tex');     # a LaTeX tabular
</code></pre>

<p>pandas spreads this over <code>to_csv</code>, <code>to_excel</code> (with <code>openpyxl</code> or <code>XlsxWriter</code> installed) and <code>to_latex</code>. R uses <code>write.csv</code>, <code>writexl</code> and <code>xtable</code>.</p>

<h3>Every column against every other column</h3>

<pre><code>my %data = (height =&gt; \@height, weight =&gt; \@weight, age =&gt; \@age);   # hash of arrays
my $r = col2col(\%data, 'cor');
print $r-&gt;{height}{weight};
</code></pre>

<p><code>col2col</code> runs any two-column function over every pair of columns. A correlation matrix is easy anywhere, but swap <code>'cor'</code> for <code>'t_test'</code> and you get a t-test for every pair. In R or pandas that’s a loop you write yourself.</p>

<h3>Table 1 without listing your factors</h3>

<pre><code>my $t1 = table_one(\@cohort, by =&gt; 'arm');
write_table($t1, 'table1.xlsx');
</code></pre>

<p><code>table_one</code> works out which columns are continuous (all numbers) and which are categorical, and picks a t-test, ANOVA or chi-squared test to match. R’s <code>tableone</code> wants the categorical columns listed in <code>factorVars</code>.</p>

<h3>ANOVA straight from groups</h3>

<pre><code>my $fit = aov({
    ctrl =&gt; [4.17, 5.58, 5.18, 6.11, 4.50],
    trt1 =&gt; [4.81, 4.17, 4.41, 3.59, 5.87],
    trt2 =&gt; [6.31, 5.12, 5.54, 5.50, 5.37],
});
</code></pre>

<p>With no formula, <code>aov</code> stacks the groups into long form itself. In R you’d write <code>aov(values ~ ind, data = stack(groups))</code>.</p>

<h3>Help without leaving the terminal</h3>

<pre><code>perl -MStats::LikeR -e 'h(*agg)'
</code></pre>

<p>That prints the documentation for <code>agg</code>. Passing the glob <code>*agg</code> saves you escaping quotes in a one-liner; inside a script, <code>h('agg')</code> works too.</p>

<h2>Resource use</h2>

<p>I timed each function against R 4.6.1 and Python 3.14.2, from 1,000 elements up to 300,000 rows for data frames and files, and up to 1,000,000 elements for vectors, seven runs each.</p>

<p>The short version: at the sizes most scripts handle, Stats::LikeR is usually the fastest of the three. At the top end, NumPy and pandas catch up on plain arithmetic and pull well ahead on joins and filtering. Writing files is where it wins at every size.</p>

<p><img src="https://bioinformaticsdcondonwordpress.wordpress.com/wp-content/uploads/2026/10/scaling.io_.png?w=640" alt="File I/O run time versus rows for Stats::LikeR, R and Python" title="" /></p>

<p><img src="https://bioinformaticsdcondonwordpress.wordpress.com/wp-content/uploads/2026/10/scaling.io_.ram_.png?w=640" alt="File I/O peak memory versus rows for Stats::LikeR, R and Python" title="" /></p>

<h3>Small vectors: overhead is everything</h3>

<p><em>sum over 1,000 numbers: Stats::LikeR 1.2 µs, R 2.9 µs, List::Util 4.5 µs, Python 24 µs</em></p>

<p>On a 1,000-element vector, the cost of the call matters more than the arithmetic, and an XS function given an array ref has very little overhead. That’s why <code>sum</code>, <code>min</code> and <code>max</code> are 3.6 to 5.7 times faster than <code>List::Util</code> 1.70 at this size.</p>

<p><img src="https://bioinformaticsdcondonwordpress.wordpress.com/wp-content/uploads/2026/10/scaling.vector.png?w=640" alt="Vector function run time versus length for Stats::LikeR, R and Python" title="" /></p>

<p>By a million elements the picture changes: R’s <code>sum</code> is faster (0.8 ms against 1.4 ms) and NumPy’s <code>mean</code> is about seven times faster than mine. <code>skew</code> and <code>kurtosis</code> are the exception and stay ahead of SciPy all the way up.</p>

<h3>Pass a reference, save the memory</h3>

<p><em>Peak memory: writing CSV under 10 kB vs 6 MB for pandas CSV and 562 MB for pandas XLSX; sum over an array ref 4 kB vs 8 MB for a flattened list</em></p>

<p>The reductions also accept a flat list, <code>List::Util</code>-style: <code>sum(@x)</code>. That makes Perl copy the whole array onto the stack. Pass <code>\@x</code> instead and <code>sum</code>, <code>sd</code>, <code>var</code>, <code>skew</code> and friends stay at about 4 kB however long the vector is. A flattened million-element list costs 8 MB, whether it’s <code>List::Util</code>, Python or my own list form.</p>

<p><code>write_table</code> streams, so it used under 10 kB for every CSV I tried. pandas reaches 6 MB for CSV and 562 MB for XLSX.</p>

<h3>Reading and writing files</h3>

<p><em>300,000 rows. Write CSV: 55 ms vs 346 ms (R) and 483 ms (pandas). Write XLSX: 0.41 s vs 17.3 s. Read mixed CSV: 174 ms vs 376 ms and 409 ms.</em></p>

<p>This is the clearest win. A 300,000-row CSV takes 55 ms to write, against 346 ms in R and 483 ms in pandas. The same table as XLSX takes 0.41 seconds, against 17.3 seconds in Python.</p>

<h3>Transforms</h3>

<p><code>rank</code> and <code>auc</code> beat both rivals at every size. <code>auc</code> is 30 times faster than R at 1,000 elements and still 3 times faster than Python at a million. <code>uniq</code> and <code>seq</code> lose everywhere; R’s <code>seq</code> is lazy and doesn’t build anything, so that one was never going to go my way.</p>

<h3>Data frames</h3>

<p>At 1,000 rows, Stats::LikeR wins most comparisons: <code>group_by</code> plus <code>mean</code> is 5 times faster than pandas, and <code>select_cols</code> takes about 2 microseconds whatever the frame size. <code>aoh2hoa</code>, which turns a list of rows into a hash of columns, is 20 times faster than the pandas equivalent even at 300,000 rows.</p>

<h3>Against other Perl modules</h3>

<p>The set functions (<code>union</code>, <code>intersection</code>, <code>Lonly</code>, <code>Ronly</code>, <code>is_equivalent</code>) are 1.5 to 3.3 times faster than <code>List::Compare</code> 0.55 at a million elements, and use 2 to 40% less memory.</p>

<h3>Where to use something else</h3>

<p><em>merge of 300,000 rows: 214 ms vs 2 ms in pandas. All-numeric CSV read: 148 ms vs 61 ms. sum over a million: 1.4 ms vs 0.8 ms in R.</em></p>

<p>I’d rather you hear this from me. At 300,000 rows pandas does a <code>merge</code> in 2 ms and I take 214 ms; <code>filter</code>, <code>group_by</code> and <code>transpose</code> fall behind too. pandas also reads all-numeric CSV faster. If you’re joining big tables, use pandas.</p>

<h2>What’s in it</h2>

<p>The README documents over 115 functions. Roughly:</p>

<table style="border-collapse:collapse; width:100%; font-size:90%;">
<tr><th style="border:1px solid #ccc; padding:4px 6px; text-align:left;">Area</th><th style="border:1px solid #ccc; padding:4px 6px; text-align:left;">Functions</th><th style="border:1px solid #ccc; padding:4px 6px; text-align:left;">What you’d use otherwise</th></tr>
<tr><td style="border:1px solid #ccc; padding:4px 6px; vertical-align:top;"><b>Descriptive statistics</b></td><td style="border:1px solid #ccc; padding:4px 6px; vertical-align:top;"><code>mean</code>, <code>median</code>, <code>mode</code>, <code>sd</code>, <code>var</code>, <code>quantile</code>, <code>skew</code>, <code>kurtosis</code>, <code>summary</code>, <code>table_one</code>, <code>density</code>, <code>hist</code></td><td style="border:1px solid #ccc; padding:4px 6px; vertical-align:top;">R <code>base</code>/<code>stats</code>; NumPy, SciPy</td></tr>
<tr><td style="border:1px solid #ccc; padding:4px 6px; vertical-align:top;"><b>Hypothesis tests</b></td><td style="border:1px solid #ccc; padding:4px 6px; vertical-align:top;"><code>t_test</code>, <code>wilcox_test</code>, <code>chisq_test</code>, <code>fisher_test</code>, <code>ks_test</code>, <code>shapiro_test</code>, <code>kruskal_test</code>, <code>friedman_test</code>, <code>mcnemar_test</code>, <code>cmh_test</code>, <code>binom_test</code>, <code>prop_test</code>, <code>var_test</code>, <code>dunn_test</code>, <code>p_adjust</code>, <code>power_t_test</code></td><td style="border:1px solid #ccc; padding:4px 6px; vertical-align:top;">R <code>stats</code>; <code>scipy.stats</code></td></tr>
<tr><td style="border:1px solid #ccc; padding:4px 6px; vertical-align:top;"><b>Models</b></td><td style="border:1px solid #ccc; padding:4px 6px; vertical-align:top;"><code>lm</code>, <code>glm</code>, <code>aov</code>, <code>anova</code>, <code>lmer</code>, <code>ivreg</code>, <code>svyglm</code>, <code>coxph</code>, <code>hurdle</code>, <code>zerotrunc</code>, <code>prcomp</code>, <code>predict</code>, <code>vif</code></td><td style="border:1px solid #ccc; padding:4px 6px; vertical-align:top;"><code>lm</code>, <code>lme4</code>, <code>survival</code>, <code>survey</code>; <code>statsmodels</code></td></tr>
<tr><td style="border:1px solid #ccc; padding:4px 6px; vertical-align:top;"><b>Effect sizes &amp; diagnostics</b></td><td style="border:1px solid #ccc; padding:4px 6px; vertical-align:top;"><code>cohen_d</code>, <code>cramers_v</code>, <code>eta_squared</code>, <code>smd</code>, <code>roc</code>, <code>auc</code>, <code>bedroc</code>, <code>hosmer_lemeshow</code>, <code>survfit</code>, <code>logrank_test</code></td><td style="border:1px solid #ccc; padding:4px 6px; vertical-align:top;"><code>effectsize</code>, <code>pROC</code>; <code>scikit-learn</code></td></tr>
<tr><td style="border:1px solid #ccc; padding:4px 6px; vertical-align:top;"><b>Data frames</b></td><td style="border:1px solid #ccc; padding:4px 6px; vertical-align:top;"><code>read_table</code>, <code>write_table</code>, <code>filter</code>, <code>select_cols</code>, <code>group_by</code>, <code>agg</code>, <code>merge</code>, <code>pivot_table</code>, <code>melt</code>, <code>concat</code>, <code>drop_duplicates</code>, <code>dropna</code>, <code>fillna</code>, <code>interpolate</code>, <code>qcut</code>, <code>value_counts</code>, <code>view</code></td><td style="border:1px solid #ccc; padding:4px 6px; vertical-align:top;"><code>dplyr</code>, <code>tidyr</code>; <code>pandas</code></td></tr>
<tr><td style="border:1px solid #ccc; padding:4px 6px; vertical-align:top;"><b>Distributions &amp; sampling</b></td><td style="border:1px solid #ccc; padding:4px 6px; vertical-align:top;"><code>dnorm</code>, <code>pnorm</code> and the rest of the d/p/q/r family, <code>rnorm</code>, <code>runif</code>, <code>rbinom</code>, <code>sample</code>, <code>seq</code></td><td style="border:1px solid #ccc; padding:4px 6px; vertical-align:top;">R <code>stats</code>; <code>numpy.random</code>, <code>scipy.stats</code></td></tr>
</table>

<h2>Correct first, then fast</h2>

<p>A stats library that’s fast but wrong is worse than useless, so the tests compare against R’s output, not against the module’s own earlier results. Some specifics:</p>

<ul>
<li><code>lmer</code> gives the Satterthwaite degrees of freedom that <code>lmerTest</code> adds.</li>
<li><code>coxph</code> handles ties with Efron’s method, like <code>survival::coxph</code>.</li>
<li><code>interpolate</code> matches pandas and SciPy to machine precision.</li>
<li><code>min</code>, <code>max</code> and <code>sum</code> pass <code>List::Util</code>’s own test suite, apart from a few deliberate R-like choices: a <code>NaN</code> anywhere in the list gives <code>NaN</code>, and an empty list dies instead of returning <code>undef</code>.</li>
</ul>

<p>Where I chose not to copy R, the docs say so and explain why. For example, <code>lm</code> sorts factor levels by byte value, as <code>patsy</code> and pandas do, rather than by locale. The fit is identical, but the reference level can differ.</p>

<h2>Should you use it?</h2>

<p>If your data is already in Perl and fits in memory, I think so. That covers a lot: log analysis, lab pipelines, survey files, nightly reports, most things under a few hundred thousand rows. You drop the export step and the second language, and at those sizes it’s usually quicker too.</p>

<p>It runs on Perl 5.10.1 and later and needs a C compiler to build:</p>

<pre><code>cpanm Stats::LikeR
</code></pre>

<p><code>use Stats::LikeR;</code> exports the whole toolkit, which is handy for quick scripts. In a larger program, import just what you need:</p>

<pre><code>use Stats::LikeR qw(read_table t_test lm);
</code></pre>

<p>Bug reports are welcome on GitHub, and so are benchmarks where it does badly. Those are the most useful ones.</p>

<h2>Links</h2>

<ul>
<li><a href="https://metacpan.org/pod/Stats::LikeR">Stats::LikeR on MetaCPAN</a></li>
<li><a href="https://github.com/hhg7/Stats-LikeR">Source and issue tracker on GitHub</a></li>
</ul>

<p><em>Also posted on <a href="https://bioinformaticsdcondonwordpress.wordpress.com/2026/10/24/statistics-in-perl-statsliker/">my WordPress blog</a>.</em></p>

<!-- ==================== /EXTENDED ==================== -->
]]>
        

    </content>
</entry>

</feed>
