Counts, proportions and tables · Choose a method

Comparing percentages between groups: % positive cells, % survival per litter, % correct trials

Positive cells out of cells counted, per animal? Compare animals, not pooled cells: Welch's t-test on each animal's percentage, or a quasi-binomial model.

By Licklider · Published · Last reviewed · How this page was made · Markdown

On this page
  1. Which analysis fits your percentages?
  2. Worked example: Ki-67-positive cells in five mice per group
  3. Run it in R and Python
  4. How often each analysis gives a false positive
  5. Other situations
  6. Report it
  7. Check what an AI assistant ran
  8. References

Quick answer. If each animal gives a count out of a total (positive cells out of cells counted, correct trials out of trials), the animal is the unit: keep one row per animal with its two counts and compare the animals, never the pooled cells; when animals differed, a test on pooled cells gave 29–56% false positives in our simulation. The default is Welch’s t-test on each animal’s percentage, which reports a difference in percentage points and kept false positives at or below 4.8% in our simulation with 3–8 animals per group. If you want an odds ratio that gives more weight to animals with more cells counted, plan a quasi-binomial model on the counts instead; it ran at or slightly above 5% (4.5–6.7%) in the same simulation. Write the choice into your plan before you see the data; with no written plan, say so, use the default and report the other analysis as a labelled sensitivity analysis. For pups in litters where the dam was treated, the litter is the animal. If each animal gives a single yes/no, use the chi-square vs Fisher’s exact test page instead. To start, copy the code under Run it in R and Python and the template under Report it.

Terms used below. Proportion: a count divided by its total (14 of 134 cells = 0.104, or 10.4%). Percentage point: the plain difference between two percentages (27% − 13% = 14 points). Pooling: adding every animal’s cells into one big table. Welch’s t-test: the two-group t-test that does not assume equal spread in the groups (R’s default t.test). Paired t-test: the t-test on each animal’s difference between two conditions. False-positive rate: how often a test gives p < 0.05 when there is no real effect; it should be about 5%. Power: how often a test detects a real effect. 95% CI (confidence interval): the range of effects compatible with the data at the 95% level. t(8) = 3.31: the test statistic with its degrees of freedom in brackets; for a model with one row per animal and two groups, that is animals minus 2. Welch’s test gives a fractional df (4.62 in the example), which is expected. Odds: positive ÷ negative (14 ÷ 120); the odds ratio compares the odds of two groups, and log-odds is its log scale. Binomial: the spread you would expect if every cell in a group had the same chance of being positive. Overdispersion: more spread between animals than the binomial allows, because animals really differ. Dispersion: a number that measures it, computed from the Pearson residuals (each animal’s observed minus expected count, scaled by its binomial spread); 1 means no overdispersion, 4 means four times the binomial variance. Quasi-binomial model: a logistic model that estimates the dispersion from the data and widens its intervals accordingly. Binomial mixed model (GLMM): a logistic model in which each animal has its own baseline log-odds (a random effect). z-test and Wald CI: p-values and intervals that assume many animals. Parametric bootstrap: a p-value from refitting the model on many datasets simulated from it. Covariate: an extra measured variable in the model, such as cell size. Sensitivity analysis: a second, labelled analysis that checks whether the conclusion depends on the method. Yates: a correction to the chi-square test that makes it more cautious. Exact test: a test computed from all possible tables rather than an approximation. Multiplicity: the extra false positives from making many comparisons.

Which analysis fits your percentages?

What each animal gives you Example Analysis What you report
One yes/no per animal Tumour present; survived to day 28 A 2 × 2 table of animals, never of cells: chi-square without Yates, or an exact test for small counts Percentages of animals, risk difference or odds ratio
A count out of a total Ki-67-positive cells out of 30–200 counted per mouse Welch’s t-test on each animal’s percentage (default; 2.5–4.8% false positives with 3–8 animals per group in our simulation) Difference in percentage points with its 95% CI
A count out of a total, and you planned an odds ratio that weights animals by their cells As above Quasi-binomial model, one row per animal (4.5–6.7% false positives with 3–8 animals per group in our simulation) Odds ratio with its 95% CI and the dispersion
A count out of a total, and you need cell-level covariates Cell size as a covariate Binomial mixed model (GLMM) with animal as a random effect; its z-test gave 8.9–15.3% false positives with 3–8 animals per group in our simulation, so take the p-value from a parametric bootstrap (R pbkrtest::PBmodcomp, after install.packages("pbkrtest"); it can take minutes) or ask a statistician Odds ratio
Pups out of a litter, treatment given to the dam Pups surviving to weaning The litter is the unit (what counts as n): one row per litter (R aggregate(cbind(positive = survived, counted = 1) ~ litter + group, data = pups, FUN = sum) from one row per pup), then as in the second row; if a dam contributes more than one litter, the dam is the unit Difference in percentage points, n = litters
Both conditions within each animal Treated and untreated slices from each mouse One row per animal with columns pct_untreated and pct_treated; paired t-test on the two percentages (R t.test(d$pct_treated, d$pct_untreated, paired = TRUE); Python stats.ttest_rel(d.pct_treated, d.pct_untreated)), or see nested data Mean paired difference in percentage points with its 95% CI
A percentage with no counts behind it % of control, % area stained, % inhibition Treat it as a measurement: fold change and % of control (log scale) Ratio or difference with its CI

Whichever row applies, n is the number of animals (or litters). For cell counts from mice, Lazic (2010) puts it this way: “A more appropriate analysis would be to determine the proportion (or percentage) of each rod type for each individual mouse”. Welch’s test stays valid whether every animal has 100 cells scored or the totals vary; it gives each animal equal weight. The quasi-binomial model gives animals with more cells more weight. Write the analysis into your plan before you see the data, because on the same data the two per-animal analyses can land on different sides of 0.05, as the example below shows.

Avoid the arcsine transformation. Warton and Hui (2011) argue that “the arcsine transform should not be used” for proportions and that for binomial data “logistic regression has greater interpretability and higher power than analyses of transformed data”, provided overdispersion is checked and accounted for. They suggest random effects for that; with 3–8 animals per group, account for it instead with Welch on per-animal percentages or the quasi-binomial model, because the random-effects model’s z-test gave 8.9–15.3% false positives at those sizes in our simulation (below).

What to do: find your row, write the analysis into the plan, and keep one row per animal with its positive count and its total.

Worked example: Ki-67-positive cells in five mice per group

Constructed data: five mice per group, 30–200 cells counted per mouse (cells.csv). The data were generated with a mouse-level odds ratio of 2.0 and real mouse-to-mouse variation; the seed was fixed in advance, not chosen for the result. The plan named Welch’s t-test on per-mouse percentages, the default.

Mouse Vehicle: positive / counted % Mouse Drug: positive / counted %
V1 28 / 161 17.4 D1 20 / 67 29.9
V2 22 / 137 16.1 D2 7 / 43 16.3
V3 22 / 172 12.8 D3 41 / 108 38.0
V4 14 / 134 10.4 D4 68 / 174 39.1
V5 6 / 61 9.8 D5 14 / 105 13.3

The same data, four ways (Python and R print the same numbers where both compute the value):

Analysis Effect p Verdict
Pooled chi-square (wrong unit) on 1,162 cells, without the Yates correction 13.8% vs 30.2% of cells 1.1 × 10⁻¹¹ Wrong unit: treats cells as independent animals
Welch’s t-test on per-mouse percentages (planned) 14.0 percentage points (95% CI −0.7 to 28.7), t(4.62) = 2.51 0.058 Valid; equal weight per mouse
Quasi-binomial model, one row per mouse Odds ratio 2.69 (95% CI 1.35 to 5.37), t(8) = 3.31 0.011 Valid; weights mice by cells; dispersion 4.0
Binomial mixed model (GLMM), mouse as random effect (R glmer, z-test) Odds ratio 2.45 (Wald 95% CI 1.39 to 4.32) 0.0019 Too optimistic with five mice per group (see below)

Read the planned result first: the drug group was 14.0 percentage points higher, and the 95% CI runs from slightly below zero to 28.7 points, so five mice per group leave both no difference and a large increase compatible with the data. Why the two valid analyses differ: Welch’s test gives each mouse the same weight, so mouse D2 (43 cells) counts as much as mouse D4 (174 cells); the quasi-binomial model weights each mouse by its cells and, through the dispersion of 4.0, still lets the mice differ more than cells would. The effects also measure different things: a difference in percentage points (Welch), an odds ratio averaged over mice (quasi-binomial) and an odds ratio for a typical mouse (GLMM), so none of them is expected to equal the 2.0 used to generate the data. An ordinary binomial model on the same counts gives the same odds ratio but p = 2.9 × 10⁻¹¹, because it assumes a dispersion of 1; the dispersion of 4.0 is the warning that the mice differ, and with only 10 mice the dispersion itself is estimated imprecisely.

What to do: report the analysis you planned, with its effect and interval; if you had no written plan, say so, use the default and report the other analysis as a labelled sensitivity analysis; if a reviewer asks for the other one, add it the same way without replacing the planned result.

Run it in R and Python

Both code blocks read cells.csv, one row per mouse. Your own file needs the same four columns, named mouse, group, positive and counted, and the group labels vehicle and drug (or change those two names in the code). Save it in the same folder as the script and run the script from that folder (RStudio: Session → Set Working Directory → To Source File Location). If your export (from QuPath, for example) has one row per cell, read it into a data frame cells, add mouse and group columns if the export lacks them, and make a 0/1 column pos from the class column (in R, cells$pos <- as.integer(cells$Class == "Positive"), using your export’s class name). Then make the per-mouse file: in R, d <- aggregate(cbind(positive = pos, counted = 1) ~ mouse + group, data = cells, FUN = sum); in Python, d = cells.groupby(["mouse", "group"], as_index=False).agg(positive=("pos", "sum"), counted=("pos", "size")). If you have only each animal’s percentage and no counts, put it in a column pct with mouse and group, skip the line that computes pct, and run only the Welch part. R needs no extra packages for this code. Python needs pip install pandas statsmodels scipy.

d <- read.csv("cells.csv")                 # one row per mouse: mouse, group, positive, counted
d$group <- factor(d$group, levels = c("vehicle", "drug"))

# Planned default: one percentage per mouse, Welch's t-test (percentage points)
d$pct <- 100 * d$positive / d$counted
w <- t.test(d$pct[d$group == "drug"], d$pct[d$group == "vehicle"])   # Welch is R's default
cat(sprintf("Difference %.1f percentage points, 95%% CI %.1f to %.1f, t(%.2f) = %.2f, p = %.3f\n",
            diff(rev(w$estimate)), w$conf.int[1], w$conf.int[2], w$parameter, w$statistic, w$p.value))

# Planned alternative: quasi-binomial model on each mouse's counts (odds ratio)
qb <- glm(cbind(positive, counted - positive) ~ group, family = quasibinomial, data = d)
s <- summary(qb)$coefficients["groupdrug", ]
ci <- exp(s[["Estimate"]] + c(-1, 1) * qt(0.975, qb$df.residual) * s[["Std. Error"]])
cat(sprintf("Odds ratio %.2f, 95%% CI %.2f to %.2f, t(%d) = %.2f, p = %.2g, dispersion %.1f\n",
            exp(s[["Estimate"]]), ci[1], ci[2], qb$df.residual, s[["t value"]], s[["Pr(>|t|)"]],
            summary(qb)$dispersion))
Difference 14.0 percentage points, 95% CI -0.7 to 28.7, t(4.62) = 2.51, p = 0.058
Odds ratio 2.69, 95% CI 1.35 to 5.37, t(8) = 3.31, p = 0.011, dispersion 4.0
import numpy as np
import pandas as pd
import statsmodels.api as sm
from scipy import stats

d = pd.read_csv("cells.csv")               # one row per mouse: mouse, group, positive, counted

# Planned default: one percentage per mouse, Welch's t-test (percentage points)
pct = 100 * d.positive / d.counted
res = stats.ttest_ind(pct[d.group == "drug"], pct[d.group == "vehicle"], equal_var=False)
ci = res.confidence_interval()             # needs SciPy 1.11 or later
diff = pct[d.group == "drug"].mean() - pct[d.group == "vehicle"].mean()
print(f"Difference {diff:.1f} percentage points, 95% CI {ci.low:.1f} to {ci.high:.1f}, "
      f"t({res.df:.2f}) = {res.statistic:.2f}, p = {res.pvalue:.3f}")

# Planned alternative: quasi-binomial model on each mouse's counts (odds ratio)
X = sm.add_constant((d.group == "drug").astype(float).rename("drug"))
qb = sm.GLM(d.positive / d.counted, X, family=sm.families.Binomial(),
            var_weights=d.counted).fit(scale="X2", use_t=True)
lo, hi = np.exp(qb.conf_int().loc["drug"])
print(f"Odds ratio {np.exp(qb.params['drug']):.2f}, 95% CI {lo:.2f} to {hi:.2f}, "
      f"t({qb.df_resid:.0f}) = {qb.tvalues['drug']:.2f}, p = {qb.pvalues['drug']:.2g}, dispersion {qb.scale:.1f}")
Difference 14.0 percentage points, 95% CI -0.7 to 28.7, t(4.62) = 2.51, p = 0.058
Odds ratio 2.69, 95% CI 1.35 to 5.37, t(8) = 3.31, p = 0.011, dispersion 4.0

R’s quasibinomial family and statsmodels’ scale="X2" both estimate the dispersion from the Pearson residuals, which Harrison (2015) describes as “the ratio of the sum of squared Pearson residuals to the residual degrees of freedom”, with values above 1 indicating overdispersion. A dispersion near 1 means the animals vary no more than cells would; well above 1 (here 4.0) means they differ, which the quasi-binomial model already allows for: report the dispersion with the odds ratio. The same analyses extend to three or more groups by adding levels to group; adjust the pairwise comparisons as in which correction to use.

What to do: for the quasi-binomial model, keep cbind(positive, counted - positive) in R, or the proportion with var_weights set to the totals in Python, so the model knows how many cells each percentage rests on.

How often each analysis gives a false positive

We simulated experiments like the example (30–200 cells per mouse, baseline 15% positive, mouse-to-mouse variation on the log-odds scale; R 4.3.3, lme4 1.1-35.1; 2,000 runs per row; seed 20261110):

Mice per group Mouse-to-mouse SD (log-odds) True odds ratio Pooled chi-square Welch on per-mouse % Quasi-binomial Binomial mixed model (z)
5 0 1 (no effect) 6.1% 4.3% 5.0% 5.2%
5 0.4 1 30.0% 3.8% 5.6% 12.0%
5 0.8 1 56.0% 4.0% 6.1% 11.7%
3 0.4 1 29.3% 2.5% 4.5% 15.3%
8 0.4 1 32.3% 4.8% 6.7% 8.9%
5 0.4 2 89.2% 47.8% 55.2% 71.9%
8 0.4 2 96.1% 73.4% 75.1% 83.7%

With no true effect (the first five rows), each column should be near 5%. The pooled chi-square is far above it as soon as mice differ. With 3–8 mice per group, the binomial mixed model’s z-test gave 8.9–15.3% false positives, so its higher “power” in the last two rows is not usable. Welch’s test on per-mouse percentages stayed at or below 4.8%. The quasi-binomial model gave 4.5–6.7%, above 5% in three of the five no-effect rows. It detected the doubling of the odds in 55.2% of runs against 47.8% for Welch’s test with five mice per group, but it also gave more false positives in the matching no-effect row (5.6% against 3.8%), so part of that gap is not usable power. The simulation covered 3–8 mice per group and drew each mouse’s cell total independently of its percentage. The code is published as simulation.R with its output.

What to do: with 3–8 animals per group, choose Welch on per-animal percentages or the quasi-binomial model in the plan; if you need the binomial mixed model for cell-level covariates, take its p-value from a parametric bootstrap rather than the z-test.

Other situations

  • Zero or 100% in some animals. A few animals at 0% or 100% are fine for both analyses. If every animal in one group is at 0% (or 100%), the quasi-binomial model cannot estimate a finite odds ratio; Welch on per-animal percentages still runs, using the other group’s spread, and you should report each animal’s counts. If every animal in both groups has the same percentage, no test on percentages is possible: report the counts per animal.
  • Many categories per animal (cell-type composition that adds to 100%). The categories are not independent; one comparison per category without accounting for that dependence can mislead. Compare the categories you planned, adjust for multiplicity, and seek specialist advice for full compositional analysis.
  • Counts without a total (foci per mouse, colonies per plate): see statistical tests for count data.

What to do: if your design fits none of the table rows, describe the unit and the totals in your plan and choose the model before you collect the data.

Report it

Template, filled with the example’s constructed numbers; replace every bracket with your own values:

Ki-67-positive cells were counted in each mouse ([30–200] cells per mouse; n = [5] mice per group). The planned analysis was Welch’s t-test on each mouse’s percentage of positive cells. Mean percentages were [13.3]% (vehicle) and [27.3]% (drug), a difference of [14.0] percentage points (95% CI [−0.7] to [28.7]; t([4.62]) = [2.51], p = [0.058]). In a sensitivity analysis, a quasi-binomial logistic model on each mouse’s positive and negative counts gave an odds ratio of [2.69] (95% CI [1.35] to [5.37]; t([8]) = [3.31], p = [0.011]; estimated dispersion [4.0]). Analyses used [R 4.3.3].

For paired data, report the mean paired difference: “Within each mouse, the percentage of positive cells was [x] percentage points higher in treated than in untreated slices (95% CI [lower] to [upper]; paired t-test, t([df]) = [t], p = [p]; n = [animals] mice).”

If your planned analysis was the quasi-binomial model, put its result in the main results sentence and Welch’s result in the sensitivity sentence. In figures, plot one point per animal (its percentage), not one bar of pooled cells (SuperPlots).

What to do: state the unit (animals), the totals, the model and its effect with the interval; give n as the number of animals, not cells.

Check what an AI assistant ran

  1. Pooled cells? A chisq.test or chi2_contingency on summed cell counts (pooled chi-square, wrong unit), or a t-test on cells, treats cells as animals. Ask for one row per animal.
  2. Counts or percentages? Give a logistic model the counts (cbind(positive, counted - positive) in R, or the proportion with var_weights set to the totals in Python), so it knows how many cells each percentage rests on.
  3. Binomial or quasi-binomial? family = binomial with no random effect assumes a dispersion of 1. Look for quasibinomial or scale="X2", and check the printed dispersion.
  4. Arcsine? asin(sqrt(p)) is not needed; use Welch on per-animal percentages or the quasi-binomial model.
  5. Mixed model with few animals? glmer z-test p-values were too small with 3–8 animals per group in our simulation; ask for a parametric bootstrap p-value or one of the two per-animal analyses.

What to do: paste the generated code next to this list and check each point before you report the result.

References

  • Warton DI, Hui FKC (2011). The arcsine is asinine: the analysis of proportions in ecology. Ecology 92(1):3–10. doi:10.1890/10-0340.1
  • Harrison XA (2015). A comparison of observation-level random effect and Beta-Binomial models for modelling overdispersion in Binomial data in ecology & evolution. PeerJ 3:e1114. doi:10.7717/peerj.1114
  • Lazic SE (2010). The problem of pseudoreplication in neuroscientific studies: is it affecting your analysis? BMC Neuroscience 11:5. doi:10.1186/1471-2202-11-5

How this page was made

  • Written and published by Licklider. Recommendations cite peer-reviewed methods papers and official software documentation listed under References; cited passages were checked against the source text.
  • The worked example uses constructed data. Its numbers and the simulation were produced by stored code (Python with SciPy 1.18.1 and statsmodels 0.15.0; R 4.3.3 with lme4 1.1-35.1); Python and R print the same numbers where both compute the value.
  • Separate review passes checked the statistics, the citations and readability before publication.