# Create directory
dir.create("data/raw",
recursive = TRUE, # create the last element of the path- here it will be "raw"
showWarnings = FALSE)
download.file(
"https://raw.githubusercontent.com/anoobvinu07/Genomic_assisted_selection/master/data/FitnessTraits_GeneticParameters_RedSpruce.txt",
destfile = "data/raw/red_spruce_fitness_traits.txt")R: Module 1 Basics for reproducible data analysis
Goal
Learn core R syntax and data-handling skills by analyzing the red-spruce fitness-trait dataset you already downloaded in Bashing the cluster, and finish with a first reproducible R script whose outputs land in results/.
This module picks up exactly where the cluster exercise left off: data/raw/ holds the raw file, src/ will hold your R scripts, and results/ will hold everything R produces.
Prerequisites
- Completed the cluster exercise
- R (>= 4.2) installed; RStudio/Positron recommended but not required
- The dataset available at
data/raw/red_spruce_fitness_traits.txt
If you’re working locally instead of on the cluster, grab the same file directly from R:
This is work on my system, probably not on yours. Lets use a package called here to make sure that code is reproducibile on your system as well.
# Create directory
dir.create(here::here("data/raw"),
recursive = TRUE, # create the last element of the path- here it will be "raw"
showWarnings = FALSE)
download.file(
"https://raw.githubusercontent.com/anoobvinu07/Genomic_assisted_selection/master/data/FitnessTraits_GeneticParameters_RedSpruce.txt",
destfile = here::here("data/raw/red_spruce_fitness_traits.txt"))I am using the here package to make this script reproducible. This makes sure that irrespective of of which system this code is run, it will work on that system. Read more about here here.
Instead of calling the package specifically at the top of the script with a library(here) or require(here), I am calling them as a require it. That is done with the syntax packageName::packageFunction. This is useful when you have a lot of packages at the top, but you want to keep track of where a specific function is being called from. There are other reasons as well for following this syntax, for example when you need to call a function that is part of a package not part of the base R. Have you encounetered such situations while coding?
Recreate the rest of the project layout if you don’t already have it:
dir.create(here::here("results"), showWarnings = FALSE)
dir.create(here::here("src"), showWarnings = FALSE)What is the output when you type in the following commands in your system? What does it mean?
getwd()
here::here()This excercise assumes the project folder follows the following structure:
cluster-exercise/
├── data/
│ └── raw/ # downloaded, unchanged source data
├── logs/ # Slurm standard output and error logs
├── results/ # analysis products created by R
└── src/ # R and Slurm scripts
Learning objectives
By the end of this module you should be able to:
- Start R/RStudio with a reproducible working directory (no hard-coded absolute paths)
- Describe R’s core data types and structures: vectors, factors, data frames, lists
- Import and inspect a tab-delimited file
- Subset, filter, and summarize data with base R
- Produce basic plots (histogram, boxplot, scatterplot)
- Save tables and plots into
results/ - Turn interactive code into a script you can run non-interactively with
Rscript
R quick reference
| Function/operator | Purpose | Example |
|---|---|---|
c() |
Combine values into a vector | c(1, 2, 3) |
<- |
Assignment | x <- 5 |
class() |
Report the type of an object | class(traits$Height) |
is.na() |
Flag missing values | sum(is.na(traits$Height)) |
na.rm = TRUE |
Skip NAs inside a computation | mean(x, na.rm = TRUE) |
read.delim() |
Read a tab-delimited file | read.delim("data/raw/file.txt") |
str() |
Show structure/types of an object | str(df) |
dim(), nrow(), ncol() |
Dimensions of a data frame | dim(df) |
head() / tail() |
First/last rows | head(df, 5) |
summary() |
Quick numeric/factor summary | summary(df$Height) |
df$col |
Access a column by name | df$Fitness |
df[rows, cols] |
Subset by position/condition | df[df$Region == "E", ] |
table() |
Count occurrences | table(df$Population) |
tapply() / aggregate() |
Grouped summaries | tapply(df$Height, df$Population, mean) |
function(...) {} |
Define a function | f <- function(x) mean(x, na.rm = TRUE) |
for (i in seq) |
Loop | for (p in unique(df$Population)) {...} |
hist(), boxplot(), plot() |
Base R plotting | hist(df$Height) |
write.csv() |
Save a table | write.csv(x, "results/out.csv") |
Setting up your R project
On the cluster, load R and start it from inside cluster-exercise/:
cd ~/projects/cluster-exercise
module biocontainers/default
module load r # module name may differ on your cluster; check `module avail r`
R # start the R sessionInside R, always confirm where you are before reading or writing anything:
getwd()[1] "/Users/anoobprakash/Documents/GitHub/anoobvinu07.github.io/notebook/learn-to-code/r-basics"
#or
here::here() # when using the here package to set the paths[1] "/Users/anoobprakash/Documents/GitHub/anoobvinu07.github.io"
If you’re using RStudio, create an RStudio Project (File > New Project) rooted at cluster-exercise/ — this keeps every path relative and portable, exactly like the data/raw/... paths from the bash module.
R data types and structures
# Vectors: one-dimensional, single type
heights <- c(27.3, 31.1, 29.6, 32.0)
pops <- c("AB", "AB", "CC", "CC")
# Factors: categorical vectors with fixed levels
region <- factor(c("E", "E", "C", "C"))
levels(region)[1] "C" "E"
# Data frames: a table — columns can be different types
df_demo <- data.frame(pop = pops, height = heights)
str(df_demo)'data.frame': 4 obs. of 2 variables:
$ pop : chr "AB" "AB" "CC" "CC"
$ height: num 27.3 31.1 29.6 32
# Lists: can hold mixed, differently-shaped objects
result <- list(mean_height = mean(heights), populations = unique(pops))
result$mean_height[1] 30
Key idea: almost everything you do in this module is a data frame — one row per tree, one column per variable — with vectors as its columns.
The four structures, one at a time
A vector is R’s atomic unit: one dimension, one type. Everything you compute with — a column of heights, a column of region labels — is a vector.
heights # print it[1] 27.3 31.1 29.6 32.0
class(heights) # what type is it? "numeric"[1] "numeric"
length(heights) # how many elements?[1] 4
class(pops) # "character"[1] "character"
A vector’s type is decided by the most flexible element inside it — this is called implicit coercion, and it is the source of many silent surprises:
c(1, 2, 3) # numeric: 1 2 3[1] 1 2 3
c(1, "a") # character: "1" "a" — the number was demoted to text![1] "1" "a"
c(TRUE, 1) # numeric: 1 1 — TRUE became 1[1] 1 1
The hierarchy is logical → numeric → character: mixing types in one c() promotes everything to the highest type present. Nothing errors, nothing warns — your 1 just silently becomes "1" and later comparisons like x == 1 stop matching.
A factor is a categorical vector with a fixed, ordered set of levels:
region[1] E E C C
Levels: C E
as.integer(region) # stored internally as 2 2 1 1 (E=2, C=1: levels are alphabetical)[1] 2 2 1 1
table(region) # counts per levelregion
C E
2 2
Two things make factors different from plain text:
- Memory and clarity: a column with 326
"C","E","M"strings stores the full text repeatedly; a factor stores small integers plus the level table once. - Fixed classes:
levels()defines which categories exist, even if they have zero observations. This matters for counting — as you’ll see with genotypes in population genetics module,table()on a plain vector drops categories that don’t occur, whiletable(factor(x, levels = ...))keeps them with a count of zero.
Plotting and modeling functions (boxplot, lm) use levels to decide the order of groups on the axis, which is why the idea check asks about boxplot(Height ~ Region, ...).
A data frame is a list of equal-length vectors displayed as a table — which is why columns can have different types but every column has the same number of rows. It is the structure you will use 95% of the time:
df_demo pop height
1 AB 27.3
2 AB 31.1
3 CC 29.6
4 CC 32.0
nrow(df_demo) # rows = observations[1] 4
ncol(df_demo) # columns = variables[1] 2
One row = one observation (one tree); one column = one variable (its height, its region). This “tidy” layout is what aggregate(), boxplot(Height ~ Region), and almost every R modeling function expect.
A list is a container with no constraints: elements can be different types and different lengths — even other lists. They are how functions bundle multiple outputs:
result$mean_height
[1] 30
$populations
[1] "AB" "CC"
names(result) # each slot has a name[1] "mean_height" "populations"
result$mean_height[1] 30
You will mostly read lists (model objects, str() output) before you write them, but recognizing [[1]] vs $name access patterns pays off immediately when you start calling summary() on fitted models.
Missing values: NA, the silent saboteur
Real datasets have missing data, and this one is no exception — 13 trees are missing a Height measurement. R represents a missing data with NA, and the rule you must internalize is:
Almost any computation that touches an
NAreturnsNA.
x <- c(27.3, 31.1, NA, 32.0)
mean(x) # NA — R refuses to guess[1] NA
mean(x, na.rm = TRUE) # 30.13 — "remove NAs, then compute"[1] 30.13333
is.na(x) # FALSE FALSE TRUE FALSE — a logical flag per element[1] FALSE FALSE TRUE FALSE
sum(is.na(x)) # 1 — counting NAs by summing TRUEs[1] 1
sum(!is.na(x)) # 3 — counting non-missing values[1] 3
Why does sum(is.na(x)) work? is.na() returns a logical vector, and in arithmetic TRUE counts as 1 and FALSE as 0 — so summing counts the TRUEs. That idiom (sum(is.na(col))) is the standard first move when inspecting any new dataset.
NA is contagious in subsetting too — see the indexing section below.
Importing and inspecting the dataset
traits <- read.delim(here::here("data/raw/red_spruce_fitness_traits.txt"),
header = TRUE, sep = "\t", stringsAsFactors = FALSE)
dim(traits) # rows x columns[1] 326 19
names(traits) # column names [1] "Family" "Population"
[3] "Tree" "Location"
[5] "Region" "Latitude"
[7] "Longitude" "SeedWeight"
[9] "Germination" "Survival"
[11] "Height" "Fitness"
[13] "Elevation" "PC1"
[15] "PC2" "Family_Homozygosity"
[17] "Population_Homozygosity" "Genetic_Diversity"
[19] "Genetic_Load"
str(traits) # types per column'data.frame': 326 obs. of 19 variables:
$ Family : chr "AB_05" "AB_08" "AB_12" "AB_16" ...
$ Population : chr "AB" "AB" "AB" "AB" ...
$ Tree : int 5 8 12 16 1 2 3 5 6 1 ...
$ Location : chr "TN" "TN" "TN" "TN" ...
$ Region : chr "E" "E" "E" "E" ...
$ Latitude : num 35.6 35.6 35.5 35.5 44.3 ...
$ Longitude : num 83.5 83.5 83.5 83.5 70.8 ...
$ SeedWeight : num 0.00387 0.0047 0.00418 0.00348 0.00274 ...
$ Germination : num 0.14 0.04 0.2 0.36 0.78 0.66 0.66 0.72 0.84 0.24 ...
$ Survival : num 0.923 0.692 0.933 0.867 1 ...
$ Height : num 27.3 31.1 29.6 32 23.4 ...
$ Fitness : num 3.531 0.862 5.527 9.976 18.269 ...
$ Elevation : int 1812 1785 1750 1738 455 524 523 573 589 743 ...
$ PC1 : num -0.0446 -0.0395 -0.043 -0.0439 0.0336 ...
$ PC2 : num -0.0569 -0.0747 -0.0598 -0.0606 0.0524 ...
$ Family_Homozygosity : num 0.906 0.888 0.878 0.918 0.89 ...
$ Population_Homozygosity: num 0.0109 0.0109 0.0109 0.0109 0.00684 ...
$ Genetic_Diversity : num 0.0965 0.0965 0.0965 0.0965 0.0945 ...
$ Genetic_Load : num 1.01 1.01 1.01 1.01 1 ...
head(traits, 5) # first 5 rows Family Population Tree Location Region Latitude Longitude SeedWeight
1 AB_05 AB 5 TN E 35.55297 83.49438 0.003868
2 AB_08 AB 8 TN E 35.55212 83.49259 0.004702
3 AB_12 AB 12 TN E 35.53890 83.49463 0.004182
4 AB_16 AB 16 TN E 35.53882 83.49534 0.003484
5 ALB_01 ALB 1 ME C 44.30670 70.84019 0.002736
Germination Survival Height Fitness Elevation PC1 PC2
1 0.14 0.9230769 27.32543 3.5312860 1812 -0.04462027 -0.05689514
2 0.04 0.6923077 31.14205 0.8623953 1785 -0.03947036 -0.07471556
3 0.20 0.9333333 29.61083 5.5273541 1750 -0.04297319 -0.05978979
4 0.36 0.8666667 31.97449 9.9760420 1738 -0.04391060 -0.06055466
5 0.78 1.0000000 23.42167 18.2689001 455 0.03360038 0.05241128
Family_Homozygosity Population_Homozygosity Genetic_Diversity Genetic_Load
1 0.9063498 0.010900911 0.09649039 1.014863
2 0.8878673 0.010900911 0.09649039 1.014863
3 0.8783499 0.010900911 0.09649039 1.014863
4 0.9183347 0.010900911 0.09649039 1.014863
5 0.8903996 0.006835856 0.09449166 1.003412
summary(traits$Height) Min. 1st Qu. Median Mean 3rd Qu. Max. NA's
19.39 27.04 29.94 30.03 32.38 47.56 13
You should see 19 columns including identifiers (Family, Population, Tree, Location, Region), coordinates and environment (Latitude, Longitude, Elevation), fitness traits (SeedWeight, Germination, Survival, Height, Fitness), and genetic parameters (PC1, PC2, Family_Homozygosity, Population_Homozygosity, Genetic_Diversity, Genetic_Load) — we’ll use that last group in the Population genetics foundation module.
Don’t assume a column’s type from its name — Always check with str() before analyzing.
Indexing and subsetting
# A single column
traits$Fitness |> head() # |> is called the pipe command, which lets you run the additional commands (head()) at the end [1] 3.5312860 0.8623953 5.5273541 9.9760420 18.2689001 19.9572132
# Rows meeting a condition
edge <- traits[traits$Region == "E", ]
nrow(edge)[1] 101
# Multiple conditions
healthy_edge <- traits[traits$Region == "E" & traits$Survival > 0.8, ]
# subset() reads more like a sentence
subset(traits, Region == "E" & Height > 35, select = c(Population, Height, Fitness)) Population Height Fitness
83 CRA 37.28518 31.319550
300 XCV 38.33505 21.467628
301 XCV 38.46439 26.155787
326 XWS 35.68774 2.141264
How the bracket syntax works
df[rows, cols] — the comma splits rows from columns, and an empty slot means “everything”:
| Code | Meaning |
|---|---|
df[1, ] |
row 1, all columns |
df[, 2] |
all rows, column 2 |
df[1:5, c("Height", "Fitness")] |
rows 1–5, two named columns |
df[df$Region == "E", ] |
every row where the condition is TRUE, all columns |
df[, -1] |
all rows, drop column 1 |
The filter df[condition, ] works because the condition is a logical vector with one TRUE/FALSE per row, and bracket-indexing keeps exactly the rows whose position is TRUE. That is the vectorized comparison from the logic check, doing real work.
Three beginner traps:
==vs=.==compares (“is Region equal to E?”);=assigns or names arguments.traits[traits$Region = "E", ]is a syntax error.&vs|.&is AND (both conditions),|is OR (either). For filter vectors use the single forms —&&/||only compare single values.- NA rows vanish. The one tree with missing
Survivalmakestraits$Survival > 0.8evaluate toNAfor that row, and[dropsNArows silently. Usually harmless (that row can’t satisfy the condition anyway) — but if you need to keep or count them explicitly, usewhich()orcomplete.cases():
# Explicit about missing data: keep only rows with no NA anywhere
traits_complete <- traits[complete.cases(traits), ]
nrow(traits_complete) # rows surviving the NA filter[1] 309
Summarizing data
# Counts
table(traits$Population) # number of inds per population
AB ALB APP ASC B BAL BBE BER BFA BLA BRA BRB BRU
4 5 6 6 5 6 5 6 5 5 5 4 5
CAM CR CRA CRR DG EQU ESC G13 G18 G57 GPA GRE HR
6 5 6 2 5 6 6 5 3 11 4 6 5
HUN IND JAY KAN KIL KOS LEU LOL MAC MMF MPG MRC MSK
7 6 3 5 6 6 5 5 5 7 6 5 4
MT NBTIC NOR OCT OKE OWL PRK PRO RP SAV SPR TWI WA
7 6 6 6 6 4 5 4 5 7 5 5 5
WCA WHI XBM XCS XCV XDS XFS XGL XPK XSK XWS
6 6 6 2 5 6 3 4 4 3 3
table(traits$Region) # number of inds per region
C E M
178 101 47
# Grouped means with tapply()
tapply(traits$Height, traits$Population, mean, na.rm = TRUE) AB ALB APP ASC B BAL BBE BER
30.01320 28.57737 29.63241 28.52612 33.56843 35.15642 30.99741 31.03151
BFA BLA BRA BRB BRU CAM CR CRA
28.63783 37.17194 32.56467 25.90857 40.61024 30.87601 28.62938 30.82941
CRR DG EQU ESC G13 G18 G57 GPA
30.85341 26.87212 31.32729 28.55595 27.88004 33.12132 30.55111 29.95064
GRE HR HUN IND JAY KAN KIL KOS
28.31803 29.59271 25.68274 27.40152 25.99047 31.04648 26.37646 28.74159
LEU LOL MAC MMF MPG MRC MSK MT
29.14787 29.32470 27.94443 28.08837 26.60019 27.54005 30.86224 28.95013
NBTIC NOR OCT OKE OWL PRK PRO RP
35.64952 25.63859 31.69911 28.33508 35.86874 29.17456 29.83356 28.79050
SAV SPR TWI WA WCA WHI XBM XCS
31.95079 34.35978 27.82040 26.31308 29.42220 29.29455 38.03620 31.99923
XCV XDS XFS XGL XPK XSK XWS
35.35950 27.99949 25.99399 29.10061 28.37384 26.40402 30.18033
# Grouped summary with aggregate()
aggregate(cbind(Height, Fitness) ~ Population, data = traits, FUN = mean) |> head() Population Height Fitness
1 AB 30.01320 4.974269
2 ALB 28.57737 20.792160
3 APP 29.63241 16.351045
4 ASC 28.52612 18.725993
5 B 33.56843 11.649563
6 BAL 35.15642 14.434015
What each summarizer is for
| Function | Question it answers | Key arguments |
|---|---|---|
table() |
How many observations per category? | the categorical vector |
tapply() |
Apply one function to one column, split by one group | (values, groups, function) |
aggregate() |
Same, but multiple columns and multiple groups at once | formula like cbind(a, b) ~ group |
The formula syntax (Height ~ Population) reads right-to-left: “height, explained by population” — split Height by Population. cbind() glues several response columns together so one call summarizes all of them.
Notice the na.rm = TRUE inside tapply(): it is passed through to mean(). Without it, every population containing one of the 13 missing heights would report NA — and with this dataset, that would be most of them. When a grouped summary comes back full of NAs, this is the first thing to check.
# The formula version of aggregate with two grouping factors
aggregate(Height ~ Region, data = traits, FUN = mean) Region Height
1 C 29.68908
2 E 28.88793
3 M 34.03509
Basic plotting
Saving outputs and scripting
pop_summary <- aggregate(cbind(Height, Fitness, SeedWeight) ~ Population, data = traits, FUN = mean)
write.csv(pop_summary, here::here("results/population_trait_summary.csv"), row.names = FALSE)# exporting plot as a .png
png(here::here("results/height_by_region.png"), width = 800, height = 600)
boxplot(Height ~ Region, data = traits)
dev.off()Put everything into src/01_explore_traits.R and run it non-interactively — this is what makes it reproducible and Slurm-friendly:
Rscript src/01_explore_traits.RWrap up
Idea check
Answer these in your own words before moving to the exercises — write your answers as comments at the top of src/01_explore_traits.R.
- What is the difference between a vector and a data frame in R?
- Why prefer a relative path like
"data/raw/red_spruce_fitness_traits.txt"over an absolute path like/home/your_username/...? - What does
str(traits)tell you thathead(traits)does not? - Why does treating
Regionas afactor(rather than plain character text) matter forboxplot(Height ~ Region, data = traits)? - If
mean(traits$Height)returnedNA, what would you check first, and how would you fix it? - Why write outputs into
results/instead of overwriting anything indata/raw/?
Practical exercises
Work through these in order inside an R session, then consolidate the working code into src/01_explore_traits.R.
Exercise 1 — Load and confirm the shape
Load the dataset and confirm it has 326 rows and 19 columns.
Exercise 2 — Map every column to its type
Run str(traits) and list every column name along with its type (chr, int, num).
Exercise 3 — Summary statistics, and the NA trap
Compute overall summary statistics (mean, sd, min, max) for Height and Fitness.
Exercise 4 — Count trees per group
Use table() to count trees per Population and per Region. Which population has the most trees sampled?
Exercise 5 — Subsetting with two conditions
Subset the data to trees in Region == "C" with Survival > 0.8. How many trees meet this criterion?
Exercise 6 — Grouped means
Use aggregate() or tapply() to get mean Height and mean Fitness per Population. Identify the top 3 populations by mean Fitness.
Exercise 7 — Plots, saved to files
Create a histogram of SeedWeight and a boxplot of Height by Region. Save both as PNG files in results/.
Exercise 8 — Save the summary table
Save your population-level summary table as results/population_trait_summary.csv.
Exercise 9 — The reproducibility test
Move all working code into src/01_explore_traits.R and confirm it runs cleanly end-to-end with Rscript src/01_explore_traits.R, producing the expected files in results/.
Homework
- Compute summary statistics (mean, sd, min, max) for at least 4 traits (e.g.,
Height,Fitness,SeedWeight,Germination), broken down by bothPopulationandRegion. - Produce at least 3 plots: one histogram, one boxplot grouped by
Region, and one scatterplot relating two continuous variables (e.g.,Elevationvs.Fitness). Save all three toresults/. - Write your interpretation as comments at the bottom of the script, answering:
- Which population has the highest and lowest mean fitness?
- Is there a visible relationship between elevation and fitness or seed weight? Describe it in one or two sentences.
- Bonus (optional): recreate one plot with
ggplot2instead of base R.




