What a CSV loses: archiving an R analysis

R
reproducibility
data management
ecology tutorial
A deposited CSV round-trips the numbers and drops the structure. The fit is identical to machine precision and the reported trend changes sign. Worked in R.
Author

Tidy Ecology

Published

2026-08-06

The file you deposit with the paper is not the object you analysed. It is a projection of that object onto a grid of characters, and the projection is lossy in a way that R will not warn you about, because everything that survives the round trip survives it perfectly.

The numbers come back exact. The fitted model comes back exact. What does not come back is the structure you put on top of the numbers, and in the case below that structure is the whole result.

An ordered factor, written out and read back

Cover class low, medium, high, and a Poisson count that rises across them. The analyst declared the ordering, because low, medium and high are not three arbitrary labels, and R gave them polynomial contrasts: a linear term and a quadratic term, which is exactly what an ordered predictor is for.

library(ggplot2)

te_paper  <- "#f5f4ee"
te_ink    <- "#16241d"
te_body   <- "#2c3a31"
te_forest <- "#275139"
te_rust   <- "#b5534e"
te_gold   <- "#c9b458"
te_line   <- "#dad9ca"

theme_datasheet <- function() {
  theme_minimal(base_size = 12) +
    theme(plot.background  = element_rect(fill = te_paper, colour = NA),
          panel.background = element_rect(fill = te_paper, colour = NA),
          panel.grid.major = element_line(colour = te_line, linewidth = 0.3),
          panel.grid.minor = element_blank(),
          text             = element_text(colour = te_body),
          plot.title       = element_text(colour = te_ink, face = "bold"),
          axis.text        = element_text(colour = te_body))
}

make_plots <- function(n_per = 40) {
  lev <- c("low", "medium", "high")
  d <- data.frame(cover = factor(rep(lev, each = n_per), levels = lev,
                                 ordered = TRUE))
  d$count <- rpois(nrow(d), exp(1.2 + 0.5 * (as.integer(d$cover) - 1)))
  d
}

set.seed(20260806)
field <- make_plots()
str(field$cover)
 Ord.factor w/ 3 levels "low"<"medium"<..: 1 1 1 1 1 1 1 1 1 1 ...

The analysis is one line, and the coefficient the paper would quote is the linear term.

fit_before <- glm(count ~ cover, family = poisson, data = field)
round(coef(summary(fit_before)), 4)
            Estimate Std. Error z value Pr(>|z|)
(Intercept)   1.7227     0.0399 43.1852   0.0000
cover.L       0.6440     0.0693  9.2939   0.0000
cover.Q       0.0673     0.0689  0.9769   0.3286

Now archive it the way almost everyone archives it, and read it back the way the next person will.

path <- tempfile(fileext = ".csv")
write.csv(field, path, row.names = FALSE)

back <- read.csv(path)
back$cover <- factor(back$cover, ordered = TRUE)   # the reasonable next step
levels(back$cover)
[1] "high"   "low"    "medium"

There is the loss, and it is one line long. The CSV stored the labels and nothing else, so factor did what it always does with an unknown level set: it sorted alphabetically. High now comes first and medium last, and the ordering the ecologist declared has been replaced by the ordering of the Latin alphabet.

The fit is identical and the answer is not

fit_after <- glm(count ~ cover, family = poisson, data = back)

c(same_fitted  = isTRUE(all.equal(unname(fitted(fit_before)),
                                  unname(fitted(fit_after)))),
  same_loglik  = isTRUE(all.equal(as.numeric(logLik(fit_before)),
                                  as.numeric(logLik(fit_after)))))
same_fitted same_loglik 
       TRUE        TRUE 
round(c(deviance_before = deviance(fit_before),
        deviance_after  = deviance(fit_after)), 4)
deviance_before  deviance_after 
       131.6229        131.6229 

Every diagnostic an ecologist would run passes. The fitted values agree, the deviance agrees to 131.6229 on both sides, the residual plots are the same plots. Nothing is broken, because nothing is broken: both models describe the same three group means.

lin_before  <- coef(fit_before)[["cover.L"]]
lin_after   <- coef(fit_after)[["cover.L"]]
quad_before <- coef(fit_before)[["cover.Q"]]
quad_after  <- coef(fit_after)[["cover.Q"]]

round(c(linear_before = lin_before, linear_after = lin_after,
        quad_before = quad_before, quad_after = quad_after), 4)
linear_before  linear_after   quad_before    quad_after 
       0.6440       -0.3803        0.0673        0.5241 

The linear contrast goes from +0.6440 to -0.3803. That is the number the abstract quotes, and it has changed sign. The quadratic term moves from +0.0673 to +0.5241 and absorbs most of the pattern instead.

Nothing is wrong with either fit. The second one is a perfectly correct linear contrast across high, low, medium in that order, which is a sequence with no meaning at all. The archive did not corrupt the data; it dropped the sentence that said which order the levels were in.

pred_panel <- function(m, d, lab) {
  nd <- data.frame(cover = factor(levels(d$cover), levels = levels(d$cover),
                                  ordered = TRUE))
  nd$fit   <- predict(m, nd, type = "response")
  nd$panel <- lab
  nd$pos   <- seq_len(nrow(nd))
  nd$label <- as.character(nd$cover)
  nd
}
pan <- rbind(
  pred_panel(fit_before, field,
             sprintf("as analysed: linear %+.3f", lin_before)),
  pred_panel(fit_after, back,
             sprintf("as archived: linear %+.3f", lin_after)))
pan$panel <- factor(pan$panel, levels = unique(pan$panel))

ggplot(pan, aes(x = pos, y = fit)) +
  geom_line(linewidth = 0.9, colour = te_line) +
  geom_point(aes(colour = label), size = 4) +
  geom_text(aes(label = label), vjust = -1.1, size = 3.6, colour = te_body) +
  facet_wrap(~ panel) +
  scale_colour_manual(values = c(low = te_gold, medium = te_forest,
                                 high = te_rust)) +
  scale_x_continuous(breaks = 1:3, labels = c("first", "second", "third"),
                     expand = expansion(add = 0.42)) +
  scale_y_continuous(expand = expansion(mult = c(0.08, 0.18))) +
  labs(x = "position in the level ordering", y = "fitted count",
       title = "Same three means, opposite reported trend") +
  theme_datasheet() +
  theme(legend.position = "none",
        strip.text = element_text(colour = te_ink, face = "bold"))
Two panels of three points each. The heights of the points are identical between panels, but the horizontal ordering differs, and each panel carries an annotation giving its linear contrast: positive in the left panel, negative in the right one.
Figure 1: Fitted counts per cover class before and after the archive round trip, with the linear polynomial contrast each fit reports.

It is not the seed

One data set proves nothing about a mechanism, so run it a few hundred times and look at the two contrasts side by side.

set.seed(7)
sw <- t(replicate(400, {
  d <- make_plots()
  b <- glm(count ~ cover, family = poisson, data = d)
  p <- tempfile(fileext = ".csv")
  write.csv(d, p, row.names = FALSE)
  r <- read.csv(p)
  r$cover <- factor(r$cover, ordered = TRUE)
  unlink(p)
  a <- glm(count ~ cover, family = poisson, data = r)
  c(before = coef(b)[["cover.L"]], after = coef(a)[["cover.L"]])
}))

round(c(mean_before   = mean(sw[, "before"]),
        mean_after    = mean(sw[, "after"]),
        sign_flips    = mean(sign(sw[, "before"]) != sign(sw[, "after"])),
        max_abs_gap   = max(abs(sw[, "before"] - sw[, "after"]))), 4)
mean_before  mean_after  sign_flips max_abs_gap 
     0.7104     -0.3542      1.0000      1.4132 

Across 400 archived data sets the linear contrast averages +0.710 as analysed and -0.354 as archived, and the sign flips in 100 per cent of them. The largest single discrepancy is 1.413. This is not a rare collision between a seed and an alphabet; it is what happens every time the field ordering and the alphabetical ordering disagree, which for low, medium and high they always do.

sw_df <- as.data.frame(sw)

ggplot(sw_df, aes(x = before, y = after)) +
  geom_hline(yintercept = 0, colour = te_line, linewidth = 0.6) +
  geom_vline(xintercept = 0, colour = te_line, linewidth = 0.6) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", colour = te_ink) +
  geom_point(alpha = 0.35, size = 1.9, colour = te_forest) +
  annotate("text", x = max(sw_df$before), y = max(sw_df$before), hjust = 1.05,
           vjust = -0.4, size = 3.4, colour = te_ink,
           label = "the archive would have to land here") +
  labs(x = "linear contrast as analysed", y = "linear contrast as archived",
       title = "Every point is a paper that would report the other sign") +
  theme_datasheet()
A scatter of four hundred points against a dashed diagonal line marking equality and a solid line at zero. No point is near the diagonal, and the cloud sits entirely in the quadrant where the analysed contrast is positive and the archived one is negative.
Figure 2: Linear contrast as analysed against the same contrast recovered from the archived file, over 400 simulated data sets.

What else the grid drops

The ordering is the loudest case because it changes a coefficient. The same round trip quietly loses several other things, and the pattern is the same each time: the values return, the metadata does not.

mixed <- data.frame(
  site   = factor(c("A", "B", "A"), levels = c("A", "B", "C")),  # C never sampled
  visit  = as.Date(c("2026-05-01", "2026-05-08", "2026-05-15")),
  n_stem = c(3L, 5L, 8L),
  cover  = c(0.10, 0.25, 0.40))
attr(mixed$cover, "units") <- "proportion of quadrat"

p2 <- tempfile(fileext = ".csv")
write.csv(mixed, p2, row.names = FALSE)
back2 <- read.csv(p2)

data.frame(
  column    = names(mixed),
  before    = sapply(mixed, function(v) class(v)[1]),
  after     = sapply(back2, function(v) class(v)[1]),
  row.names = NULL)
  column  before     after
1   site  factor character
2  visit    Date character
3 n_stem integer   integer
4  cover numeric   numeric

The count column survives intact, and the two that change are the two that carried a decision. The date became a character string, which still sorts correctly for ISO dates and stops doing so the moment anyone opens the file in a spreadsheet or writes it in another format. The factor became character, and with it went the level set.

That last one is worth spelling out, because it is not visible in the class column at all.

c(levels_before = nlevels(mixed$site),
  levels_after  = nlevels(factor(back2$site)),
  units_before  = attr(mixed$cover, "units"),
  units_after   = is.null(attr(back2$cover, "units")))
          levels_before            levels_after            units_before 
                    "3"                     "2" "proportion of quadrat" 
            units_after 
                 "TRUE" 

Site C was in the design and was never sampled, so it has no rows, so it is not in the file, so it is not in the level set that comes back. A table of sites now has two rows where the design had three, and a zero that meant “looked and found none” has become indistinguishable from a site that was never visited. The units attribute, the only place in the object that said what cover was a proportion of, is gone without trace: is.null on the returned attribute is TRUE.

Archiving so the object survives

A CSV is still the right deposit format: it opens anywhere, in thirty years, without R. The fix is not to abandon it but to stop expecting it to carry the structure.

Write the structure down next to it. A small dictionary file with one row per column, giving the type, the level set in order, and the units, is machine-readable, human-readable and costs a few lines.

dictionary <- data.frame(
  column = c("cover", "count"),
  type   = c("ordered factor", "integer count"),
  levels = c(paste(levels(field$cover), collapse = " < "), NA),
  units  = c("visual cover class", "stems per quadrat"))
dictionary
  column           type              levels              units
1  cover ordered factor low < medium < high visual cover class
2  count  integer count                <NA>  stems per quadrat

Then rebuild from the dictionary rather than from the file’s own ordering, and the contrast returns.

lv <- strsplit(dictionary$levels[dictionary$column == "cover"], " < ")[[1]]
rebuilt <- read.csv(path)
rebuilt$cover <- factor(rebuilt$cover, levels = lv, ordered = TRUE)

fit_rebuilt <- glm(count ~ cover, family = poisson, data = rebuilt)
round(c(analysed = lin_before,
        archived = lin_after,
        rebuilt  = coef(fit_rebuilt)[["cover.L"]]), 4)
analysed archived  rebuilt 
  0.6440  -0.3803   0.6440 

The check that catches the whole class takes one paragraph of effort and no new tools: read your own archive back in a clean session, re-run the script on it, and compare the numbers you are about to publish. If any of them move, the archive is not the analysis.

An .rds alongside the CSV is worth adding for the same reason, with the CSV as the durable copy and the .rds as the exact one. Neither replaces the other.

Honest limits

The demonstration uses write.csv and read.csv because that is what deposited files are made with. readr and data.table differ in the details, and one of the differences matters here: they do not convert strings to factors either, so the level ordering is lost in exactly the same way, just at a different point in the script.

Nothing above says the archived file is wrong. It is a faithful record of the observations, and the observations are what an archive is for. The claim is narrower and harder to see: the analysis object carried decisions that the observations do not, and those decisions have to be archived separately or they are gone.

The sign flip is specific to a predictor whose field ordering and alphabetical ordering disagree, which is common for cover classes and severity scores and rare for numbered treatments. The wider loss, of level sets, types and units, does not depend on the labels at all.

Finally, a data dictionary is only as good as the discipline that maintains it. It drifts from the file it describes exactly like a comment drifts from code, and the only defence is the same one: rebuild from it, on every run, so a stale dictionary breaks loudly instead of silently.

References

Wickham H 2014 Journal of Statistical Software 59(10):1-23 (10.18637/jss.v059.i10)

White EP, Baldridge E, Brym ZT, Locey KJ, McGlinn DJ, Supp SR 2013 Ideas in Ecology and Evolution 6(2):1-10 (10.4033/iee.2013.6b.6.f)

Roche DG, Kruuk LEB, Lanfear R, Binning SA 2015 PLOS Biology 13(11):e1002295 (10.1371/journal.pbio.1002295)

Newsletter

Get new tutorials by email

New R and QGIS tutorials for ecologists, straight to your inbox. No spam; unsubscribe anytime.

By subscribing you agree to receive these emails and confirm your address once. See the privacy policy.