Answer
Skewed data with zeros: is log(x + 1) OK, and what else works?
The short answer
Adding a constant before taking logs makes zeros usable, but the answer then depends on the constant: with many zeros, a small c mostly compares how many zeros each group has. Use log(x + c) only when zeros are rare or c has a real meaning. Otherwise model the data on their own scale with a log link (Poisson, quasi-Poisson, negative binomial or Tweedie), or use a two-part model for 'any versus none' and 'how much'.
Why adding a constant is a shaky fix
A log is attractive for right-skewed data because differences on the log scale are ratios on the original scale. But log(0) does not exist, so a common patch is to log x + 1, or x plus some small number. The trouble is that the zeros all land on the same value, log(c), and how far that value sits from the rest of the data depends entirely on the c you chose. With c = 1 and values in the tens, the zeros sit a few units below the bulk. With c = 0.001 they sit about 7 units below log(1), far out in a cluster of their own.
So the smaller the constant, the more a mean on that scale is driven by the share of zeros rather than by the sizes of the non-zero values. The back-transformed 'ratio' no longer has a clean meaning either: exp of a mean of log(x + c) is neither a mean nor a geometric mean of x, and it changes as c changes. The constant also depends on the units. Adding 1 to dollars is not the same as adding 1 to cents, so two analysts with the same data can reach different numbers.
log(x + c) is reasonable when the zeros are few, when the non-zero values are large compared with c, or when c has a real meaning (for example the detection limit of an instrument). In those cases, check that your conclusions hold for a couple of different constants.
Options that deal with zeros directly
- A log-link GLM. Poisson, quasi-Poisson or negative binomial regression model the log of the mean, not the mean of the logs, so zeros cause no problem and coefficients are still ratios. Quasi-Poisson, or Poisson with robust standard errors, also works for non-negative amounts that are not counts.
- A Tweedie model suits continuous amounts with exact zeros, such as rainfall or insurance claims, in one model.
- A two-part (hurdle) model. Fit a logistic regression for 'any versus none', then a model such as a log-normal or gamma regression for the size of the non-zero values. Use it when zeros arise from a different process, such as people who never buy.
- The inverse hyperbolic sine, asinh(x) = log(x + √(x² + 1)). It is 0 at 0, nearly linear near zero and close to log(2x) for large x. It still depends on the units, as the example shows.
- A square root or cube root. Gentler than a log and fine at zero. The square root roughly stabilises the variance of Poisson counts, but its effects have no ratio meaning.
- A rank-based test such as Mann-Whitney, when you only need to know whether one group tends to have larger values.
Before you pick, ask what you want to estimate. If the question is about totals or averages (spending, costs, rainfall), you want the arithmetic mean, and a log-link GLM answers it directly. If zeros and amounts tell different stories, a two-part model keeps them apart. Our page on when to log-transform data covers the case where every value is positive.
See it in R and Python
The example simulates spending for two groups of 200 people, where group A has more non-spenders and group B spends a little more when it spends. It compares the groups with Welch t tests on log(x + c) for three constants and on asinh in dollars and in cents, then fits a quasi-Poisson GLM with a log link. The R version uses base R; the Python tab uses NumPy and SciPy and writes out the GLM's closed form for one two-level predictor.
R
set.seed(1444)
n <- 200
# Spending-like data: many people spend nothing, the rest are right-skewed
a <- ifelse(runif(n) < 0.40, 0, rlnorm(n, meanlog = 2.0, sdlog = 1))
b <- ifelse(runif(n) < 0.30, 0, rlnorm(n, meanlog = 2.3, sdlog = 1))
round(c(zeros_a = mean(a == 0), zeros_b = mean(b == 0),
mean_a = mean(a), mean_b = mean(b)), 2)
# 1. log(x + c): the "ratio" and the test depend on the constant you pick
for (k in c(1, 0.1, 0.001)) {
tt <- t.test(log(b + k), log(a + k))
cat("c =", k, " ratio =", round(exp(unname(tt$estimate[1] - tt$estimate[2])), 2),
" t =", round(unname(tt$statistic), 2), " p =", signif(tt$p.value, 3), "\n")
}
# 2. asinh(x) is defined at zero, but the answer changes with the units
for (s in c(1, 100)) { # dollars, then cents
tt <- t.test(asinh(s * b), asinh(s * a))
cat("units x", s, " diff =", round(unname(tt$estimate[1] - tt$estimate[2]), 2),
" t =", round(unname(tt$statistic), 2), " p =", signif(tt$p.value, 3), "\n")
}
# 3. Model the mean on a log scale instead: quasi-Poisson GLM, zeros are fine
y <- c(a, b)
g <- factor(rep(c("a", "b"), each = n))
fit <- glm(y ~ g, family = quasipoisson)
est <- coef(summary(fit))["gb", ]
ci <- exp(est[1] + c(-1, 1) * qt(0.975, df.residual(fit)) * est[2])
round(c(ratio = exp(unname(est[1])), lower = ci[1], upper = ci[2],
t = unname(est[3]), df = df.residual(fit)), 2)
signif(unname(est[4]), 3)
# The GLM ratio is just the ratio of the group means, in any units
round(c(ratio_of_means = mean(b) / mean(a),
cents = exp(unname(coef(glm(I(100 * y) ~ g, family = quasipoisson))[2]))), 2)Python
import numpy as np
from scipy import stats
rng = np.random.default_rng(1444)
n = 200
# Spending-like data: many people spend nothing, the rest are right-skewed
a = np.where(rng.uniform(size=n) < 0.40, 0, rng.lognormal(2.0, 1, size=n))
b = np.where(rng.uniform(size=n) < 0.30, 0, rng.lognormal(2.3, 1, size=n))
print("zeros", round((a == 0).mean(), 2), round((b == 0).mean(), 2),
"means", round(a.mean(), 2), round(b.mean(), 2))
# 1. log(x + c): the "ratio" and the test depend on the constant you pick
for k in [1, 0.1, 0.001]:
la, lb = np.log(a + k), np.log(b + k)
res = stats.ttest_ind(lb, la, equal_var=False)
print("c =", k, " ratio =", round(np.exp(lb.mean() - la.mean()), 2),
" t =", round(res.statistic, 2), " p =", float(f"{res.pvalue:.3g}"))
# 2. asinh(x) is defined at zero, but the answer changes with the units
for s in [1, 100]: # dollars, then cents
ha, hb = np.arcsinh(s * a), np.arcsinh(s * b)
res = stats.ttest_ind(hb, ha, equal_var=False)
print("units x", s, " diff =", round(hb.mean() - ha.mean(), 2),
" t =", round(res.statistic, 2), " p =", float(f"{res.pvalue:.3g}"))
# 3. Model the mean on a log scale instead: quasi-Poisson GLM with one group
# indicator. Its fitted values are the group means, so it has a closed form.
mu_a, mu_b = a.mean(), b.mean()
y = np.concatenate([a, b])
mu = np.repeat([mu_a, mu_b], n)
df = 2 * n - 2
phi = np.sum((y - mu) ** 2 / mu) / df # Pearson dispersion, as in R
beta = np.log(mu_b / mu_a)
se = np.sqrt(phi * (1 / (n * mu_a) + 1 / (n * mu_b)))
t = beta / se
tcrit = stats.t.ppf(0.975, df)
print("ratio", round(np.exp(beta), 2), "lower", round(np.exp(beta - tcrit * se), 2),
"upper", round(np.exp(beta + tcrit * se), 2), "t", round(t, 2), "df", df)
print("p", float(f"{2 * stats.t.sf(abs(t), df):.3g}"))
# The GLM ratio is just the ratio of the group means, in any units
print("ratio of means", round(mu_b / mu_a, 2),
"cents", round((100 * b).mean() / (100 * a).mean(), 2))The figures below come from one seeded run of the R code. Python uses a different random number generator, so its figures differ slightly, but they show the same pattern.
- The data. 44% of group A and 28% of group B are zeros. The group means are 8.46 and 12.72 dollars.
- The constant drives the 'ratio'. Back-transformed differences on log(x + c) are 1.56 with c = 1, 2.17 with c = 0.1 and 4.32 with c = 0.001. Here the t statistics stayed near 3.3, but in other samples the test result can move too (in the Python run, p rose from .015 to .046).
- asinh depends on units. The difference is 0.53 on asinh(dollars) and 1.22 on asinh(cents), because rescaling moves the non-zero values but leaves the zeros at 0.
- The GLM gives one stable answer. Group B's mean is 1.50 times group A's, 95% CI [1.05, 2.16], t(398) = 2.21, p = 0.0278. That equals the plain ratio of means, 12.72 / 8.46, and stays 1.50 in cents.
The GLM's p-value is larger than the log-scale tests' because it asks a different question: it compares averages, which a few big spenders make noisy, while the log-scale tests mostly react to the gap in zeros. Neither is wrong, but only the GLM's estimate has a meaning that does not depend on an arbitrary choice. If you are unsure which test fits your design, try the test chooser.
How to report a ratio of means in APA style (7th edition)
Say in the method section which model you used and why, then report the back-transformed ratio with its interval. Using the example above:
"Because spending was right-skewed with many zero values, groups were compared with a quasi-Poisson regression with a log link. Mean spending in Group B was 1.50 times that in Group A, 95% CI [1.05, 2.16], t(398) = 2.21, p = .028."
If you do use log(x + c), name the constant in the method section and add a sentence or a table note showing that the conclusions hold with other constants. In descriptive tables, give the percentage of zeros alongside the mean (or median) of each group.
Related tools and guides
- APA 7 formatter for regression
- t-test power and sample size calculator
- APA 7 formatter for t tests
- Which statistical test should I use?
- Log-transforming data: when it helps, and what it changes
- t test or Mann-Whitney in small samples
- Data transformation (Wikipedia)
More answered questions
- Kolmogorov-Smirnov, t test or Mann-Whitney: which detects a change from a baseline?
- t tests vs the F test in regression: what is the difference?
- Log-transforming data: when it helps, and what it changes
Working with your own data?
General answers only go so far. Send us your situation and we'll reply by email within two business days.
Ask your questionWritten by AskStats with AI assistance. This is general information, not advice for your specific data or study. When the results matter, check your approach with a qualified statistician.