Datawrangling

April 21, 2026

Based on a few past projects, I made a fake dataset describing hypothetical animal captures and a few relevant variables. According to the variable names, we are interested in disease status, body measures (mass and length), life history characteristics (sex and age), along along with typical experimental data (id, site, transect), and metadata (date and notes). In itself it contains no particularly interesting features, but it does demonstrate a variety of data entry inconsistencies and our reasonable responses to them. Feeling nostalgic, we will do this in a very hands on way using only base R commands. You could certainly do this with tidyverse commands, but I have personally ever only done one such task faster with tidyverse commands that I could with base R commands. More importantly I like the hands-on, iterative, investigative nature of this. I find it a good way to learn.

dat <- read.csv("../data/fake_data.csv", header = TRUE, sep = ",")
head(dat)
        date    id    sex      age               site transect   notes mass
1 01/13/2025 anim1   Male juvenile Martin Nature Park        A         24.4
2 01/15/2024 anim2 Female    adult      plunkett park        A         20.7
3 07/13/2025 anim3 Female       A? Martin Nature Park        A         22.1
4 01/15/2024 anim4   Male juvenile         Mitch Park        A         19.8
5 01/15/2024 anim5 Female       A?         Mitch Park        B escaped 29.3
6 01/15/2024 anim6   Male    adult         Mitch Park        A         21.1
    length disease
1 177.4698       0
2 193.6316       1
3 191.0409       1
4 137.6869       0
5 218.5889       1
6 178.2634       1

We could use summary()1 which often generates nice tabular or numerical summaries, column-by-column as appropriate, but noticing a couple columns of numbers, we will make a scatter plot. Generating the data, I had the idea for mass first and I calculated length from it with some randomness. Looking back, it might make more sense to mass as a function of length, but no one complained, so we carry on.

par(mar = c(5.1, 4.1, 0.8, 0.8))
plot(length ~ mass, dat)
A scatterplot of variables length against height. The primary feature is two outliers including one roughly 1000 times larger than the corresponding values shown in the tabular example of the data.
Figure 1: A scatterplot of length against height for simulated small mammals.

Noticing that those outliers are far out of the range, we can easily plot a subset of data to get a better sense of the more relevant picture.

par(mar = c(5.1, 4.1, 0.8, 0.8))
plot(length ~ mass, dat, subset = mass < 1000)
A scatterplot of variables length against height. The primary feature is an outlier with a negative mass.
Figure 2: A scatterplot of length against height for simulated small mammals.

Noticing the range of values of the bulk of the data, it seems reasonable and safe to exclude the negative value, though likely a typo, and the two large values, likely typos or miscalculations (e.g., a unit error). It would probably be safe to flip the negative sign, but in all cases best to review field notes or other raw data sources. To aid with comparing to original sources, we can first look at the rows corresponding to the negative or large entries, but testing which row numbers satisfy the given logical condition. By request we added a trend line using abline(lm(...)) the compatibility of the syntakx of plot(...) and lm(...) is convenient. Though this is compact, A nice alternative is to store (mod <- lm(length ~ mass, dat)), then plot (abline(mod))) the model output. This means the model output can be inspected in greater detail.

dat[!(dat$mass < 40 & dat$mass > 0), ]
          date      id  sex   age               site transect notes     mass
523 07/13/2025 anim523 Male adult         Mitch Park        B          -22.5
526 01/13/2025 anim526 Male adult Martin Nature Park        B        19400.0
601 07/13/2025 anim601 Male    A? Martin Nature Park        A       210000.0
      length disease
523 141.3426       9
526 164.4454       1
601 168.3455       1
dat <- dat[dat$mass < 40 & dat$mass > 0, ]
#dim(dat)
par(mar = c(5.1, 4.1, 0.8, 0.8))
plot(length ~ mass, dat, las = 1)
abline(lm(length ~ mass, dat))
Though not apparent, three outliers have been removed. The graph shows a scatterplot with a positive trend showing body length increasing with respect to mass at a rate of about 5 millimeters in length per gram body mass.
Figure 3: A scatterplot for the edited data set.

We might be interested in sex differences so tabulate the entries to see how they are encoded. It is a mess, we have abbreviations and all sorts of capitalizations. Imagine each member of a field team entering data each night into separate copies of a template. At the end of the week they merge their edited spreadsheets together, but realize that a few have made unique notation decisions. Sample inconsistencies are found in the table, and in this case are relatively unambiguous and can be found and replaced with a nice search. These are not the only entries and this sex, like many other variables, sometimes can not readily be assessed or assigned a value for a variety of reasons.

table(dat$sex)

     f      F Female      M   male   Male 
    40     40    455     11      6    445 
dat[dat$sex %in% c("f", "F"), "sex"] <- "Female"
dat[dat$sex %in% c("M", "male"), "sex"] <- "Male"
table(dat$sex)

Female   Male 
   535    462 

Though a little more work is required, as unfolds below, Figure 4 shows a reasonable plot of length against mass for male and female rodents.

par(mar = c(5.1, 4.1, 0.8, 0.8))
plot(length ~ mass, dat, subset = sex == "Male", pch = 19, col = "gray", las = 1)
points(length ~ mass, dat, subset = sex == "Female", pch = 15, col = "black")
For all animals, independent of sex, length increases with body mass from about 100 mm at 15 grams to about 250 mm at 30 grams, but the variability is reasonably large.
Figure 4: A scatterplot for length against mass for male and female rodents.

You could expect various measures of body size to vary with age or “stage class”, but that potential relationship was not built in to the fake data simulation. So, the result in Figure 5 is clear, but uninteresting. Knowing that I wanted to make a box plot to show the role of a categorical variable, I mistakenly chose disease as the response, but the graph is confusing and unhelpful. Make that change and think abou the new result in contrast to the one below.

par(mar = c(5.1, 4.1, 0.8, 0.8))
#names(dat)
boxplot(length ~ age, dat)
Median length is about 170 millimeters, independent of age, but the boxplot highlights a strange variable encoding.
Figure 5: A boxplot for length against mass for male and female rodents.

Despite uninteresting graphs, we did learn about an odd encoding of A?. Again consider someone might be recording data separately for some reason, so the notational inconsistency arises. Because this seems a little more ambiguous, we will make a duplicate column age2 to edit. Suppose we checked with our collaborator or with the raw data ourselves and decide that the encoding had a clear, but unproblematic, originand can easily be fixed.

table(dat$age)

      A?    adult juvenile 
     335      355      307 
dat$age2 <- dat$age
dat[dat$age2 == "A?", "age2"] <- "adult"
table(dat$age2)

   adult juvenile 
     690      307 

So far we have tested for values in a list or against single values, but we can also use grep to search for patterns within columns. Because of the way certain characters are interpreted, to search for a question mark we use the pattern \\?. We migth have expected a clear connection to the comments, but there was not really a pattern in the data itself. No notes to explain this encoding of age.

head(dat[grep("\\?", dat$age), c("age", "notes")])
   age   notes
3   A?        
5   A? escaped
10  A?        
11  A? escaped
14  A?        
17  A?        

Moving on, we might as well examine the relationship between mass and sex. Figure 6

par(mar = c(5.1, 4.1, 0.8, 0.8))
boxplot(mass ~ sex, dat)
Median mass is about 22 grams, regardless of sex.
Figure 6: A boxplot for mass against sex for male and female rodents.

We can explore the potential effect of interactions by changing the right-hand side of the formula to include a second categorical variable. By Figure 7, there is no clear evidence for a relationship. Switching the order of the interaction from disease*sex to sex*disease changes the ordering of the terms along the axis and for some it might make sense to specify colors.

par(mar = c(5.1, 4.1, 0.8, 0.8))
boxplot(mass ~ disease*sex, dat)
Median mass is about 22 grams, regardless of sex and disease status.
Figure 7: A boxplot for mass against disease status for male and female rodents.

Now we get serious. In the most substantial update to the code from class, the plotting symbol for diseased individuals was set to pch = 15, this to compensate for relatively-low contrast. A nice touch, with a slightly more complicated layout could put boxplots summarizing the distribution in the margins. In an update to class work, for the ability to edit its transparency, gray has been encoded as rgb(0, 0, 0, alpha = 0.6). It took some work convincing, but we have added a strong and convincing title - even if the message itself is rather weak. In favor of a legend, we have labeled one point corresponding to the maximum value for each variable with a color-coded label for disease status.

par(mar = c(5.1, 4.1, 3.8, 0.8))
plot(length ~ mass, dat, subset = (sex == "Male" & disease == 0), xlim = c(10, 40), ylim = c(50, 300), xlab = "Mass (grams)", ylab = "Length (mm)", pch = 19, col = rgb(0, 0, 0, alpha = 0.6), las = 1, font.lab = 2)
points(length ~ mass, dat, subset = (sex == "Male" & disease == 1), pch = 15, col = rgb(1, 0, 0, alpha = 0.6))
title(main = "No apparent impact of disease on\nlength and mass of male mice")
#legend("bottomright", c("No disease", "Disease"), pch = 19, col = c("gray", rgb(1, 0, 0, alpha = 0.5)))
## find longest healthy male and label
maxl <- max(dat[dat$sex == "Male" & dat$disease == 0, "length"])
text(dat[dat$sex == "Male" & dat$disease == 0 & dat$length == maxl, c("mass", "length")], "No disease", pos = 4, font = 2)
## find heaviest diseased male and label
maxm <- max(dat[dat$sex == "Male" & dat$disease == 1, "mass"])
text(dat[dat$sex == "Male" & dat$disease == 1 & dat$mass == maxm, c("mass", "length")], "Disease", pos = 4, font = 2, col = rgb(1, 0, 0))
The graph shows a scatterplot with a positive trend showing body length increasing with respect to mass at a rate of about 5 millimeters in length per gram body mass. The graph is tastefully stylized, though perhaps low contrast. To assist point plotting symbols have been changed to indicate the two disease statuses.
Figure 8: A scatterplot of length against mass with resepct to disease status for male rodents.

To close, we will look at creating a variable derived from the comments containing a particularly important word, phrase, or code. This is a bit risky since comments can be coded erratically or with slowly-evolving emphasis or conventions, but under the right circumstances it can be effective.

table(dat$notes)

                       dead       escaped rambling rant         ticks 
          600           107           105            38           147 
grep("ticks", dat$notes)
  [1]   8  22  23  25  31  44  47  56  57  67  80  88  93  99 116 125 126 142
 [19] 170 180 191 206 214 215 224 228 234 238 255 257 259 266 274 282 289 293
 [37] 294 307 315 323 324 325 332 343 357 370 372 377 383 390 402 403 406 411
 [55] 413 414 416 430 444 447 448 450 461 464 465 475 487 489 490 492 496 506
 [73] 510 517 523 533 534 553 560 567 568 576 595 608 609 612 613 615 621 630
 [91] 641 651 666 670 692 693 694 701 705 707 712 713 716 719 729 731 739 750
[109] 752 774 776 782 786 788 797 805 808 811 822 825 827 836 846 847 862 869
[127] 870 873 874 886 889 904 905 907 913 935 940 949 952 961 965 968 973 974
[145] 981 986 987
dat$ticks <- 0
dat[grep("ticks", dat$notes), "ticks"] <- 1
table(dat$ticks)

  0   1 
850 147 

Even with this small data set, hours later there is much more that could be done. We could calculate Julian dates for ease of graphing or computation. This data has no repeated captures of individuals, but you could calculate a growth rates or graph values over time.

Additionally, we could use aggregate() to calculate totals, means, or any other statistical quantity with aggregate(dat$<var>, by = list(dat$<var1>, dat$<var2>), mean). There are a few syntactic approaches to this, each with benefits, but it is a convenient way to tabulate summaries. We could tabulate capture totals and plot totals changing over time.

Lastly, we could use merge() to merge additional covariates from a separate data set containing one or more shared variables. The variable names can be different, but the entries must match (e.g., merge(x, y, by.x = "<var.x>", by.y = "<var.y>")). The command takes a variety of other options about which rows to keep or toss, this is an area where tidyverse command might be especially helpful. But if in the cleaning stage, whatever tool helps you best learn about various features of the data is the best tool. There might be incentive to outsource this step to some sort of artificial intelligence, but data privacy issues could be of some concern as is the loss of specific insight or general learning opportunity. If nothing else, think of it like a useful puzzle.

Footnotes

  1. This is the last time I will mention this, “or tidyverse equivalents”.↩︎